Sign in

    tesseract

    Tiny multi-key JSON value database over a 4D sudoku lattice (1..215 cells per axis, 81 by default), with a SQL surface and sudoku digit constraints.

    sudoku
    database
    key-value
    lattice
    4d
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    5 hours ago
    Downloads
    2

    #Tesseract — 4D, 3 cells per axis by default

    A tiny multi-key JSON value database stored over a 4D sudoku lattice, with a SQL surface and sudoku digit constraints.

    This is the MoonBit port of the C#/Avalonia tesseract project, and the C# project itself has been lifted to the same 4D lattice. The two implementations were run against the same 26-statement sequence and produce identical output — same success flags, error codes, positions, coordinates and digits.

    #Install

    moon add riantr/tesseract@0.1.0

    The library is the package riantr/tesseract/src/tesseract:

    ///|
    import {
    "riantr/tesseract/src/tesseract",
    }

    cmd/main, cmd/persist and cmd/gui are the three executables in the same module — the command line, the file-backed store, and the window. Registry entry: mooncakes.io/docs/riantr/tesseract@0.1.0.

    #The lattice

    C# originalthis version
    shapeflat 9-cell ring shown as 3x33x3x3x3 lattice, 81 cells (default)
    addressinggrid_position 1..9grid_position 1..81, or (x,y,z,w)
    sudokunonedigits 1..3, one set per axis-parallel line
    structure9 rows / 1 box4 axes x 27 lines = 108 lines, 12 slabs

    #Cells per axis is a value, not a constant

    side — how many cells each of the four axes has — is chosen when the database is built. Everything above follows from it:

    side = 3 (default)side = 4
    cells81256
    digit domain1..31..4
    lines108256
    slabs (each)12 of 27 cells16 of 64 cells
    peers per cell812

    The sudoku rule is unchanged by this: digit(x,y,z,w) = (x+y+z+w) mod side + 1 steps by one along every axis for any side, so every axis-parallel line is a permutation of 1..side. A side-4 lattice solves with 0 line violations and 0 slab imbalances, checked in the test suite at every side from 1 to 4.

    The range is 1..215, and the upper bound is storage rather than arithmetic. Array is indexed by Int, which is 32-bit on this toolchain, so side^4 cells have to stay addressable and 215^4 = 2,136,750,625 is the largest count that fits. With Int64 index arithmetic alone the root would be 55108; lifting it needs storage that is not one flat array. lattice_of refuses a side above the limit and says which rule it applied rather than truncating.

    moon run cmd/main -- --side 4 .status moon run cmd/main -- --side 4 .solve .verify

    Omitting --side gives 3, the lattice every existing .tsr and .db file was written under, so an invocation without the flag behaves exactly as before.

    Two numbering schemes coexist deliberately:

    • coordinates (x, y, z, w) are 0-based, matching array indexing;
    • positions 1..total are 1-based, matching the C# grid_position column, so stored SQL and .tsr files keep the same shape.

    position = 1 + x*side^3 + y*side^2 + z*side + w (row-major, x slowest)

    The mapping is exact in both directions for every cell.

    #Importing across sides

    A position number only means something relative to the side it was written under: 78 of the 81 positions in a 3-per-axis lattice name a different cell once the side is 4 (position 40 is (1,1,1,0) at side 3 and (0,2,1,3) at side 4). So .tsr import places a record by the coordinates the file carries, and consults the position number only for a record that has no coordinates at all. A coordinate the target lattice cannot hold is an error naming that coordinate — never a clamp, and never a fall-through to a number that would quietly address a different cell.

    #What the sudoku constraint is at this size

    Worth stating plainly, because the 2D intuition misleads here.

    A 2D sudoku has two constraint families: 9 rows/columns of 9 cells, and 9 boxes of 3x3. Both draw from one digit domain of 9. Along a 3-long axis the row rule still works — 3 cells, digits 1..3 — but the box rule degenerates: a box is only non-trivial when the axis length is at least the digit domain, and at side 3 the only box is the whole grid.

    So what survives as a real constraint is the line family: 4 axes x 27 lines x 3 cells, each line a permutation of 1, 2, 3. Two cells share such a line exactly when they agree on three of four coordinates, and each cell has exactly 8 peers.

    Slab balance — each digit appearing 9 times in a 27-cell slab — is a consequence of the line rule, not an independent one: a slab is exactly nine lines parallel to any axis within it, and each contributes one of every digit. The code checks this rather than assuming it (slab_violations), and solve_lattice produces solutions by independent backtracking so the implication is tested on solutions nobody designed, not only on the closed form.

    A closed-form solution exists and is used as the default fill:

    digit(x,y,z,w) = (x + y + z + w) mod 3 + 1

    Stepping one cell along any axis adds 1 mod 3, so every axis-parallel line cycles through all three digits.

    #Layout

    src/tesseract/ text.mbt ASCII helpers: trim, case folding, integer parse, split/join coords.mbt the lattice: coordinates, positions, neighbours, shares_line lattice.mbt lines, slabs, planes, peers sudoku.mbt digits, the line rule, slab balance, backtracking solver txn.mbt transactions: the undo journal, savepoints, statement atomicity types.mbt GridCell, Record, Computed, DBResponse, DBStatus, formatting logger.mbt bounded log ring (100 entries, matching the C# logger) clock.mbt the injected clock and its ISO-8601 rendering db.mbt the store, and the in-place resize between shapes lexer.mbt the SQL tokenizer ast.mbt the parsed shape of a statement parser.mbt recursive descent, precedence climbing exec.mbt the executor: expressions, aggregates, ordering, transactions serialize.mbt .tsr JSON, .md table, .sql dump, lattice rendering cmd/main/ command line front end cmd/gui/ the proton desktop window main.mbt the App: entry point, page asset, command registration state.mbt the store the window drives, and its JSON projections commands.mbt the seven typed commands the page can call web/index.html the page itself - inline CSS and JS, no build step proton.project.json bundle identity, backend package, packaging settings gui-smoke.mjs end-to-end smoke test driven through Chromium DevTools

    The library has no external dependencies: the core library in this toolchain carries no file I/O and no stdin reader, so what the package needs is implemented here rather than pulled in. cmd/main inherits that. It does carry a clock — @env.now() from moonbitlang/core/env, which is the toolchain rather than a mooncakes import — so the wall clock cost one line of moon.pkg, not a dependency.

    cmd/gui is the one exception, and only because it needs a window: it depends on moonbit-community/proton@0.3.4. Proton is native-only, so that package declares supported_targets = "native" and the rest of the module stays target-agnostic.

    #The window

    proton is Electron-shaped: the page is HTML/CSS/JS, the windows and lifecycle live in MoonBit, and the two talk over a typed command bridge. The page never touches the lattice directly — it calls seven operations and renders what comes back, so the store's rules still decide everything:

    operationwhat it does
    app:stateoccupancy, cursor, digit count, the current side × side plane, sudoku health, log tail
    app:latticechange side; rebuilds the store in place and reports kept/dropped
    app:writeinsert / update / delete / clear / cursor, plus solve and demo
    app:queryone SQL statement, returning rows and a repaint
    app:geometryline / slab / peers around a cell, with the covered positions
    app:export.tsr, .md, .sql, or the ASCII lattice
    app:importload a .tsr, .md or .sql dump back in

    A digit collision in the window returns the same SUDOKU_CONFLICT and the same message the CLI prints, because it is the same Tesseract::insert call. solve and demo run the CLI's .solve and .demo statement for statement, and the smoke test pins the end state: after demo the store holds 2 records and 1 digit, with user_002 still at {"age": 30} — the same numbers moon run cmd/main -- .demo prints.

    Three things the window refuses to do, all because the library's own semantics would otherwise make a mistyped form destructive:

    • A position outside 1..side⁴ is an error, not "use the cursor." The page omits position only when the box is empty; anything the user actually typed goes through, so 999 gets INVALID_POSITION at side 3 instead of quietly landing on the cursor cell.
    • delete needs a key or a position. With neither, the library means "empty the whole lattice", which is what 清空整个点阵 is for; the delete verb refuses that form with INVALID_INPUT rather than wiping 81 cells from an empty form.
    • delete in the window is cell-scoped. A bare key is the library's "remove every cell carrying it" form, and this button sits in a form framed around one cell — so a key with no position (and nothing selected) is refused at the page rather than removing rows that are not on screen. Deleting all cells by key stays reachable in the SQL console, where the statement says so.

    The 清除 digit button is likewise a no-op on a cell that is already empty: it used to take the insert branch and materialise a cell_N record carrying nothing but the string 0. And 「无」 in the digit select is sent as an explicit digit: 0, because update reads 0 as "drop the digit" — omitting the field would have kept the old one, so the select and the digit row disagreed about the same intent.

    示例数据巡演 starts with DELETE FROM tesseract;, so it is marked as destructive and its button says 「先清空」: it cannot preserve what is already stored.

    The page picks one of the (x, y) planes — side of them — and shows it as a side × side grid over (z, w), addressed exactly as the library does. At the default side 3 that is one classic sudoku box, which is where the picture below was taken.

    The selector beside the plane pickers changes the side. It offers 1..9, not the full 1..215 the library accepts: side 9 is 6561 cells, which a window cannot draw, and the SQL console and --side still reach the whole range. The page is not allowed to remember the shape between snapshots — app:state carries side every time — because a page holding a stale shape would offer a digit the store refuses. Changing the side calls Tesseract::set_side, which resizes in place and carries records by coordinate, the same rule .tsr import uses. It does not re-solve: a side-3 solution carried to side 4 stays 81 digits in 256 cells, and the badge says 无冲突(81/256)rather than 完整解, because there is nothing there to be a solution of yet.

    The Tesseract window, filled from the closed-form solution

    Two things about that bridge are worth writing down, because both cost a debugging round:

    • Optional request fields are omitted, never sent as null. MoonBit's decoder for Int? accepts a number or no key at all, and treats an explicit null as a type error. Sending position: null fails the whole dispatch with InvalidPayload before the handler runs.
    • is_solution means "no rule is broken", not "every cell is filled." The library's line and slab checks skip empty cells, so a lattice with one hole still reads as a solution. The window's chip shows the placed count alongside the verdict for exactly that reason.

    solve runs the closed form through SQL inserts, so it obeys the digit rule like everything else: filling a partially-filled lattice can legitimately be refused at some positions. Both surfaces name the first refusal — the window in its response, the CLI as placed 80 digits first refused at position 1:SUDOKU_CONFLICT (Digit 1 already used at position 55).

    #SQL

    A real lexer, a real AST and a real executor: lexer.mbt → parser.mbt → exec.mbt. Not SQLite, and not a hand-rolled scan either — the earlier version was 932 lines of repeated string scanning whose WHERE was a struct holding at most one predicate per field, and the shape of that struct was the bug.

    The rule the executor is built around: a clause the parser accepts is a clause the executor applies, or the statement is refused. The version it replaced parsed LIMIT 1 and ORDER BY key DESC as nothing, dropped them, and reported success — so LIMIT 1 returned all three rows and ORDER BY ... DESC returned them in insertion order. A filter that silently answers a different question than the one asked is worse than a filter that fails, so nothing here can fail that way.

    beforenow
    WHERE key='a' AND key='b'1 row — the last conjunct overwrote the first0 rows
    WHERE key='a' OR key='b'INVALID_SYNTAX2 rows
    LIMIT 13 rows1 row
    ORDER BY key DESCinsertion ordersorted
    WHERE digit=1+1INVALID_SYNTAXevaluates

    The C# statement shapes and error codes still work, so existing dumps load:

    CREATE DATABASE tesseract; INSERT INTO tesseract (key, value) VALUES ('user_001', '{"age": 25}'); SELECT * FROM tesseract WHERE key = 'user_001'; SELECT * FROM tesseract WHERE value LIKE '%age%'; UPDATE tesseract SET value = '{"age": 26}' WHERE key = 'user_001'; DELETE FROM tesseract WHERE key = 'user_001';

    The 4D additions:

    -- explicit address, as a position or as coordinates INSERT INTO tesseract (key, value) VALUES ('k', 'v') AT 42; INSERT INTO tesseract (key, value) VALUES ('k', 'v') AT (1,2,0,2); -- sudoku digits, enforced against the lines through the cell INSERT INTO tesseract (key, value, digit) VALUES ('k', 'v', 2); UPDATE tesseract SET value = 'v', digit = 3 WHERE grid_position = 42; -- coordinate predicates SELECT * FROM tesseract WHERE x = 1 AND y = 2; SELECT * FROM tesseract WHERE digit = 1; -- geometry SELECT * FROM tesseract WHERE line(42, 0); -- the 3-cell axis-parallel line SELECT * FROM tesseract WHERE slab(42, 1); -- the 27-cell slab SELECT * FROM tesseract WHERE peers(42); -- the 8 cells sharing a line

    Every condition in a WHERE applies, including alongside a geometry predicate — line(42, 0) AND digit = 2 is the digit-2 cell of that line, not the whole line:

    SELECT * FROM tesseract WHERE peers(42) AND key = 'user_001';

    #Expressions

    WHERE is an expression tree, so a conjunction over one column is a conjunction rather than the last one winning, and NOT negates a comparison rather than the whole clause. Operators, loosest first:

    OR < AND < NOT < = <> != < <= > >= LIKE IN IS NULL < + - < * / % < unary - < literals, columns, ( ), functions

    SELECT * FROM tesseract WHERE key = 'a' OR key = 'b'; SELECT * FROM tesseract WHERE digit = 1 + 1; SELECT * FROM tesseract WHERE key IN ('a', 'b', 'c'); SELECT * FROM tesseract WHERE NOT (x = 1 AND y = 2);

    Three-valued throughout: NULL is unknown, AND/OR/NOT propagate it, and a row is kept only on a known true. WHERE digit = NULL is therefore not digit =0 — it matches nothing, which is the difference between "no digit" and "digit zero".

    Columns are key, value, position (or grid_position), digit, timestamp, x, y, z, w and side. An unknown one is INVALID_COLUMNS, including on an empty table — whether the table has data is not a fact that decides whether a statement is well formed, and a filter evaluated against no rows used to make an unknown column look acceptable.

    Functions: length, upper, lower, abs, coords, digits, the three geometry predicates, and the aggregates count, sum, min, max, avg.

    #Grouping, ordering, limits

    SELECT digit, count(*) FROM tesseract GROUP BY digit; SELECT sum(digit) FROM tesseract WHERE digit > 0; SELECT key FROM tesseract ORDER BY key DESC, digit ASC; SELECT key FROM tesseract ORDER BY key LIMIT 10 OFFSET 20; SELECT DISTINCT value FROM tesseract;

    ORDER BY is stable: rows whose sort keys tie keep their position order, so LIMIT after a tie picks the same rows in two runs of the same query.

    Record has a fixed shape, so a narrower select is an emptied row rather than a narrower one, and an aggregate cannot live in a cell column. Non-column select items are evaluated into Record.computed, each under the label it was written with (count(*), sum(digit), length(key)), and format_response prints them. SELECT DISTINCT value compares what was selected: the projection empties position and coords when they were not asked for, which is what makes three cells holding one value collapse to one row.

    #Transactions

    BEGIN; INSERT INTO tesseract (key, value) VALUES ('a', '1') AT 1; SAVEPOINT sp; INSERT INTO tesseract (key, value) VALUES ('b', '2') AT 2; ROLLBACK TO sp; -- the 'b' insert goes; the transaction stays open COMMIT; ROLLBACK; -- or undo the whole thing

    Implemented as an undo journal of before-images, not a snapshot, so a transaction costs what it wrote rather than what the lattice holds — a copy of all 81 cells per statement would make side = 215 impossible in a way it currently is not. Every mutation goes through one function (Tesseract::write_cell), so a transaction cannot quietly cover six of the seven write paths.

    Two properties worth stating separately:

    • Every statement is atomic, with or without BEGIN. Each one opens an implicit savepoint, so a multi-row UPDATE refused on its last row commits none of its earlier ones. This is the case the journal exists for.
    • ROLLBACK TO sp keeps sp and drops only the savepoints after it, matching SQL. RELEASE sp forgets it and keeps the work — the opposite effect, under the same kind of name, which is why both have their own test.

    BEGIN inside a transaction is refused rather than nested; SAVEPOINT is the supported way to have a rollback point inside one.

    LIKE follows the SQL spelling rather than being a substring test: %x% is a contains test and a bare literal is equality. So value LIKE 'hello' matches the value hello exactly, and value LIKE '%hello%' matches any value containing it. An unwrapped literal used to be stripped to "no predicate" and matched every row, which is the direction a filter must not be wrong in.

    A malformed literal is an error rather than a value: WHERE key = 'a and WHERE key = 'a' garbage both return INVALID_SYNTAX instead of quietly becoming a key that matches nothing. Both \' and the standard '' spell an embedded quote.

    Error codes: INVALID_INPUT, INVALID_DIGIT, INVALID_POSITION, INVALID_COLUMNS, INVALID_SYNTAX, KEY_NOT_FOUND, POSITION_NOT_FOUND, POSITION_EMPTY, POSITION_OUT_OF_RANGE, SUDOKU_CONFLICT, UNSUPPORTED_SQL, EMPTY_SQL. That list is the complete set this port emits — every code in it is reachable and nothing outside it is produced.

    #Running it

    #Command line

    moon check --target all # type-check every backend moon test # 220 tests moon fmt --check # canonical formatting node cli-smoke.mjs # both CLIs, as real processes (builds them first) node gui-smoke.mjs # the window, over CDP (needs `proton_cli dev` running) moon run cmd/main -- .help moon run cmd/main -- .status moon run cmd/main -- .demo moon run cmd/main -- .solve .verify moon run cmd/main -- "INSERT INTO tesseract (key,value) VALUES ('a','1') AT 5;" "SELECT * FROM tesseract;" moon run cmd/main -- .export tsr > dump.tsr # 256 cells, digits 1..4 moon run cmd/main -- --side 4 .status moon run cmd/main -- --side 4 .solve .verify

    --side N (or --side=N) sets the cells per axis for the run and may appear anywhere among the commands; only the first occurrence counts. A value that is not an integer, is below 1, or is above 215 is refused before any statement runs:

    $ moon run cmd/main -- --side 999 .status --side side 999 is above the maximum 215 (cells are stored in one flat array indexed by a 32-bit Int)

    #Persistence

    cmd/persist is the same store and the same SQL parser with a .db file behind it. It is a separate binary, not a flag on cmd/main, because moonbit-community/sqlite3 builds for native and wasm only — adding it to cmd/main or to src/tesseract would drop those packages from js and wasm-gc and break the zero-dependency core. cmd/main is untouched, and so is every behaviour in src/tesseract; the one change to the library is that Tesseract::insert_stamped is now pub rather than private, so this adapter can restore timestamps the way import_tsr already did.

    moon run cmd/persist --target native -- .open sudoku.db \ "INSERT INTO tesseract (key,value,digit) VALUES ('user_001','{\"age\": 25}',1) AT 1;" \ .save .status .verify # a second process reads the same file back moon run cmd/persist --target native -- .open sudoku.db .status

    commandeffect
    .open <path>replace the store with the file's contents
    .save [path]write the store out, to <path> or to the last .open
    .statusoccupancy, digits, cursor, and the file in use
    .verifyline and slab check over the digits in the store

    Everything else runs as SQL through the same parser cmd/main uses, so the sudoku digit rule still decides what happens: an INSERT that clashes on a shared line comes back SUDOKU_CONFLICT whether the store came from memory or from a file.

    Three things to know before relying on it:

    • It is a snapshot, not a mirror. The lattice lives in memory between .open and .save; nothing is written back on exit. A session that opens a file and never saves says so rather than losing the work quietly.
    • Timestamps round trip. Each cell is written with its timestamp and loaded back through the public Tesseract::insert_stamped, the same call import_tsr makes, so a reopened .db carries the stamps it was saved with instead of ones minted from this process's log counter. What does not carry over is the log: sequence numbers continue from where the new process started, so stamps made after a load do not continue the saved file's numbering. .tsr is the format when that continuity matters — .db restores what it stored.
    • A load replaces, it does not merge, the same way import_tsr clears first. Rows the store refuses — a hand-edited file can hold two cells with the same digit on a shared line — are reported as rejected rather than dropped, because "loaded 3 of 4" and "loaded 4" are different answers.

    The CLI is driven by its arguments, one statement or .command each, run in order against one shared database. MoonBit's core library exposes no stdin reader and no file I/O, so exports go to stdout for the shell to redirect, and a REPL is a shell loop over the same commands:

    A command and its argument are two arguments, as a shell splits them — .export md is .export and md, and the two are rejoined before matching. cli-smoke.mjs covers that, because matching only the joined form is what once let .export md write an .tsr document under a .md name and .open fall through to the SQL parser as Unsupported SQL statement: .open.

    while read -r line; do moon run cmd/main -- "$line"; done

    #Static analysis

    src/tesseract_sa and cmd/sa run riantr/moonbit_static_analysis over this project and keep the result in a SQLite database, so a scan is queryable history rather than text that scrolled past.

    moonx riantr/moonbit_static_analysis@0.4.1 . > scan.tsv moon run cmd/sa --target native -- store scan.tsv scan.db moon run cmd/sa --target native -- runs scan.db moon run cmd/sa --target native -- show scan.db moon run cmd/sa --target native -- files scan.db moon run cmd/sa --target native -- unparsed scan.db

    tableholds
    sa_runsone row per scan: coverage, counts, target
    sa_fileseach file the analyzer reported on, with outcome, defects, notices, shown
    sa_findingsthe findings that were actually rendered
    sa_by_kindthe per-.mbt/.mbti/.mbt.md rollup the tool prints
    sa_unparsedper reason: how many files, and up to three example paths

    Runs accumulate, so a 0.3.5 scan and a 0.4.0 scan of the same tree sit in one file and runs prints them side by side. The parser accepts both formats; refusing the older one would mean a database that can only ever hold scans from one version of the tool.

    Six things to know before reading a number out of it:

    • Quote the coverage, not the finding count. The analyzer's frontend covers a subset of MoonBit, so most .mbt files come back unread and their findings are withheld — they measure what the analyzer does not understand, not what your code does wrong. actionable is the only column that describes the code.
    • 0.4.0 splits every file into three outcomes. clean and found mean the file was read end to end; partial means it was read up to its first parse error and nothing after it was looked at. sa_files.read_up_to and sa_files.not_shown keep where that line was. On this project all 12 fully read files carry defects=0 and every actionable finding sits on a partially read file — so 30.7% coverage is the honest context for the number 4.
    • Three of the four counters do not describe your code. notices are constructs outside the analyzed subset, unreliable are findings from a partial read, withheld are findings on files never read. They are stored in their own columns precisely so they cannot be summed into a defect count.
    • The database keeps the truncation visible. The tool prints at most three examples per unparsed reason and only a few findings per file, so sa_files.defects/notices (what the header reported) and sa_files.shown (what was rendered) are both stored. A list shorter than the count is a sample, and the table says so.
    • Withheld findings have nowhere to go on purpose. There is no column for them, because a database that held 1487 rows of them would read as 1487 problems.
    • A run that did not finish is refused. The tool exits 0 even when it cannot find its target, printing only an ERROR line. parse treats a missing SUMMARY as the failure it is, so a failed scan cannot be stored as a clean one.

    One thing about the tool itself, pinned in the tests rather than corrected here: 0.4.0 labels its SUMMARY line (actionable+notices+unreliable+withheld =total), and that identity does not hold. On this project 4 + 67 + 5122 +1487 = 6680 against a reported total of 5193; the sum that does hold omits withheld. sa_runs.total stores what the tool printed rather than a recomputation.

    The counts quoted above come from src/tesseract_sa/testdata_scan.tsv, a captured run of the tool that is kept frozen so the parser has a fixed input to be tested against. Re-running the analyzer today gives slightly different numbers — the tree has changed — and that is expected; refresh the capture with _verify/capture.ps1 and update the pinned values in sa_test.mbt with it.

    moonx is invoked separately and its output read from a file, because the tool is designed so the analysed project neither depends on it nor is modified.

    #The window

    Pin proton_cli to the same version as the proton dependency, then install the CEF runtime once:

    moon update # refresh the registry index moon install moonbit-community/proton_cli@0.3.4 # match the library version proton_cli cef setup # CEF runtime + subprocess helper proton_cli doctor # identity, toolchain and runtime proton_cli dev # run the window proton_cli build # native build proton_cli package --dry-run # check the packaging plan proton_cli package # produce dist/

    proton_cli and the CEF layout it installs are both version-keyed, so a CLI left on an older version still works — it resolves the same CEF build — but it prints an upgrade notice on every run. Matching them keeps doctor output meaningful.

    proton_cli dev builds cmd/gui for the native target and launches it with the resource directory pointed at the repository root, so cmd/gui/web/index.html is found relative to that root. Editing the HTML and re-running dev is the whole edit loop; there is no bundler and no generated front end.

    Packaged builds resolve the same relative path against Resources/ beside proton-package.json, so the page has to be declared or the shipped app starts with nothing to show. That is what package.resources in proton.project.json does — each entry is copied to Resources/<its path relative to the repo root>, which lands the page exactly where @proton.asset looks for it:

    "resources": [ "cmd/gui/web" ]

    proton_cli dev opens a Chromium DevTools endpoint; a packaged build does not, because debug mode is off by default. Set TESSERACT_DEBUG=1 to get the same endpoint out of a packaged build — that is how the packaged path is checked with the same tooling as the dev path.

    gui-smoke.mjs drives the running window through Chromium's remote-debugging endpoint — it clicks the real buttons and asserts on what the page renders, so it covers HTML → bridge → typed command → lattice → JSON reply, not just that the binary starts:

    node gui-smoke.mjs

    #Export formats

    formatrole
    .tsrJSON, the native save format; round-trips key, value, position, digit and timestamp losslessly
    .mdMarkdown table, human-readable and diffable
    .sqlSQL dump this package's own parser re-imports

    The C# exporter's .sql dialect still loads: backticked `key` / `value` quoting is stripped and a timestamp column is accepted and ignored.

    #Deliberate departures from the C# original

    Each of these is a behaviour change, so each is deliberate:

    1. The grid is 81 cells, not 9, and is addressable by coordinate. The cell count per axis is a value rather than a constant, so 81 is the default rather than the only shape; --side 4 gives 256.
    2. current_count is computed from occupancy, not accumulated. The C# counter saturated at 9 on the first wrap and then only decremented, so it drifted away from the number of stored records; reporting the real occupancy is what GetStatus was always asking for.
    3. A malformed address never resolves to another cell. Out-of-range positions and unparsable coordinates are errors, not clamped values — a silent clamp would write to a cell the caller never asked for.
    4. Out-of-range positions give a clean error code instead of the C# KeyNotFoundException surfacing as a generic failure. Which code depends on the verb, and the split is deliberate: POSITION_OUT_OF_RANGE for the verbs that address an existing record (update, delete, the geometry queries), and INVALID_POSITION for INSERT, whose position names a target to write rather than a record to find.
    5. Timestamps are stamped, not read at the call site. The C# original put DateTime.UtcNow in the logger. MoonBit's core library does carry a clock — @env.now(), milliseconds since the Unix epoch, implemented for native, js and wasm — so a real one was available without a dependency and without pinning the package to a target. It is injected as a Clock rather than called inline because a clock read at the call site makes every stamp a function of when the process started. cmd/main, cmd/persist and the window read the host; the library default is a logical sequence number, which is what keeps the test suite byte-comparable between runs. So log lines and records show 2026-10-16T19:45:00.123Z, and a .tsr written by either clock still imports into the other.
    6. There is a window, and it is not Avalonia. The C# original was Avalonia; this is a proton app — the same HTML/CSS page surface and native-window model, rebuilt on a different runtime. The CLI is still there and still shares the library.
    7. The store lives in memory, and so does the window's copy of it. The C# app had the same in-memory model; there is no file backing either, so closing the window discards what is in it. The export formats are the way out — and, if you want a file that a SQL engine can read, cmd/persist below.

    Three format caveats: the Markdown table writes newlines as <br>, so a value containing that literal text comes back as a newline — .tsr is the lossless format. The SQL dump escapes \n, \r, \t, \' and \\, matching what the parser reverses. And the Markdown table escapes TAB and CR as \t and \r rather than reformatting them, which is why all three formats round-trip these values.

    Timestamps are restored by .tsr and by the .db adapter below, both of which reinsert through Tesseract::insert_stamped. The other two formats do not: import_markdown re-stamps from the log counter, and import_sql ignores the column — so .md exports carry a timestamp column that its own importer throws away, and .sql carries none. Use .tsr or .db when the stamps matter.

    An import replaces the store rather than merging into it, so a failed load leaves an empty database instead of a half-overwritten one, and a record with no usable address is refused by name rather than being written to cell 1.