Skip to content

feat: import a DSL dictionary, resumably - #29

Merged
widgetii merged 5 commits into
mainfrom
feat/m6-import-pipeline
Oct 13, 2026
Merged

widgetii merged 5 commits into
mainfrom
feat/m6-import-pipeline

Conversation

@widgetii

@widgetii widgetii commented Oct 4, 2026

Copy link
Copy Markdown
Member

Stacked on #26 — review that first; this branch contains its commits.

The last layer of the BKRS importer: a streaming, resumable pipeline, plus hanreader-dictgen dsl to drive it. The real 350 MB set imports end to end — 3,434,224 cards to 3,442,623 entries, zero diagnostics — and an import interrupted four times with kill -9 resumes to a container identical to one never interrupted.

Resumability is a requirement, not a refinement

On iOS the app will be suspended or killed part way through a minute-long import. Three things make it work:

The checkpoint and the rows it describes are one transaction. Written separately, a crash between them leaves the checkpoint behind the rows and the next run writes those rows again.

Every entry carries its own card's offset. This is the bug the gate existed to find. Stamping a whole batch with the offset of the card that happened to fill it means a resume deletes the batch and re-reads only its last card — measured over four interruptions of a 574,709-entry import, 14,997 entries vanished and it reported success:

kill 1 -> rows=55001     kill 3 -> rows=455001
kill 2 -> rows=210001    resume  -> entries=559712   (reference: 574709)

With per-entry offsets, the same test gives entries=574709, cards=572237, lexemes=345038 — every number equal to the reference, and a row-by-row comparison of all 574,709 rows identical. The stored card count also excludes the checkpoint card, since a resume re-reads it; otherwise the total grew by one per interruption.

WAL, not journal_mode = OFF. Compiling CC-CEDICT runs with durability off, which is safe because the output is a build artefact regenerated in seconds. A reader's own import is not that, and under journal_mode = OFF a process killed mid-transaction can leave the file corrupt — and a corrupt file cannot be resumed. The two requirements are mutually exclusive and resumability is the one that was asked for. A power cut would need FULL; a dictionary import is not worth an fsync per batch, and the job resumes from the last batch that reached disk.

The source is identified by name, size and modification time, never by path: on iOS the container directory's UUID changes across installs and restores, so a stored path is stale by the time it matters — the same mistake the predecessor made with absolute audio paths. A job from a different file or a different parser version is discarded rather than continued; no schema migration reconciles output from two parsers, and half a dictionary from each is worse than either.

Memory is bounded by the batch

Peak 55.9 MB over the full set against a 60 MB ceiling, with the card-reading phase steady at 25–30 MB (sampled every 6 s through a whole run). Getting there meant finding two things that only appear at scale, neither of them in the import loop:

  • LexiconBuilder.characterReadings took every single-character entry as an array. Those are the densest cards in a dictionary — 38,618 of them carrying 27 MB of encoded senses, 一 and 打 with 57 senses each — and decoding them all at once took peak memory to 857 MB. It is now a collector fed one entry at a time through a cursor, keeping five small values per candidate reading.
  • DictionaryContainer.lexicon() in the --verbose summary fetched all 1,806,893 lexemes to print one number: 450 MB, which for a while looked like the import itself had blown its ceiling. Added lexemeCount().

The lexicon is capped at four characters for a dictionary like this one

At the general cap of six, BKRS contributes 2,804,554 lexemes — a segmentation lexicon in which almost every run of Han characters is a word, which is precisely the condition under which maximum-matching produces confident nonsense, and 176 MB of index for the privilege. Four is where the measured length distribution says to stop: 1→33,372, 2→427,842, 3→455,438, 4→959,810, then 5→585,617 and 6→439,262 of increasingly phrase-like entries.

Character readings and the syllable inventory are derived at the end from what is on disk rather than accumulated while writing. Both need to see the whole dictionary — a character's readings are ranked against each other — and accumulated state is state a crash destroys and a resume cannot rebuild. The source index is dropped once the import finishes (53 MB the reader would carry for a resume that can no longer happen) and recreated if one ever starts again.

Nothing about the dictionary is hardcoded

Name, languages and version come from the file's own directives — the predecessor took the name from a constant in its source, so it mislabelled any other DSL dictionary. The volume suffix is stripped, because ( 1 / 3 ) belongs to the file and the three volumes compile into one container.

No licence is claimed. BKRS states none, and inventing one would be worse than admitting it, because the Acknowledgements screen renders whatever is here verbatim. The attribution says the file came from the reader, that HanReader does not redistribute it, and what changes the import made.

Two gates are met and two are not

gate measured
kill -9 mid-import resumes clean passes — identical after 4 kills
< 60 MB RSS over baseline passes — 55.9 MB peak
350 MB set in < 90 s 205.5 s
on-disk size (plan's Tier 1 target: 210–240 MB) 868 MB

Both misses have one cause, and it is measured rather than guessed. The entry table is 625 MB of the 868, and of a sense payload only 40.7% is text — the rest is JSON syntax and key names. On top of that the summary column duplicates a gloss already inside the payload (~160 MB), and UsageExample stores its text twice, as raw and again as the split halves.

A compact binary SensePayloadCodec — which Docs/ and the architecture plan already call for, with block compression as a later tier behind the same seam — addresses the size and most of the time together. It changes the container format and the bundled CC-CEDICT dictionary with it, so it belongs in its own change rather than as a rider on this one.

A VACUUM is not the answer, for the record: it recovers 66 MB of 868 and leaves entry at 625 MB. The bytes are real.

Validation

discovery:  includeDirectives
files:      dabkrs_1.dsl, dabkrs_2.dsl, dabkrs_3.dsl
cards:      3434224      entries: 3442623
lexemes:    1806893      characters: 31,195 readings over 422 syllable bases
diagnostics: 0
slug:       dabkrs-250920        name: 大БКРС - 250920
elapsed:    205.5s       peak RSS: 55.9 MB       size: 868 MB

13 new tests, including a deterministic interruption: the first progress value means the first batch has committed, so waiting for it and then cancelling interrupts at a batch boundary with work on both sides — no sleep, no race. (A sleep did race: on a quiet machine the whole 400-card import finished inside 40 ms.)

Full suite green, lint clean, no warnings under -warnings-as-errors.

🤖 Generated with Claude Code

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add a streaming, resumable ABBYY DSL dictionary importer

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Import multi-volume DSL dictionaries in bounded batches that resume after interruption.
• Derive dictionary metadata and lookup tables without retaining the full import in memory.
• Add a dictgen dsl command and tests for resume correctness, progress, and metadata.
Diagram

graph TD
  CLI["dictgen dsl"] --> Importer["DSL importer"] --> Reader["Card reader"] --> Builder["Card builder"] --> Writer["Dictionary writer"] --> DB[("SQLite container")]
  Writer --> Lexicon["Lexicon builder"] --> DB
  Writer -- "resume checkpoint" --> Importer
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add a compact sense codec in this PR
  • ➕ Could address the measured import-time and container-size misses together.
  • ➖ Changes the shared container format and bundled CC-CEDICT output, substantially expanding migration and review scope.

Recommendation: Keep the transactional, WAL-backed import separate from the codec change: it directly addresses interruption and memory requirements without coupling crash recovery to a format rewrite. Treat the reported 205.5-second runtime and 868 MB output as open gates, not as resolved by this PR; the proposed codec warrants a follow-up before those targets can be claimed.

Files changed (9) +1604 / -24

Enhancement (8) +1295 / -24
LexiconBuilder.swiftBound encyclopaedic lexemes and stream character-reading candidates +64/-18

Bound encyclopaedic lexemes and stream character-reading candidates

• Adds a configurable lexicon length limit, with a four-character cap for encyclopaedic dictionaries. Extracts a one-entry-at-a-time character-reading collector so ranking no longer requires an array of decoded entries.

Sources/HanReaderCore/Dictionary/LexiconBuilder.swift

DSLImporter.swiftCoordinate streaming DSL imports and resumable progress +480/-0

Coordinate streaming DSL imports and resumable progress

• Streams discovered volumes through the existing card reader and builder into bounded writer batches, with cancellation, checkpoints, and throttled progress. Derives container metadata from DSL directives, including languages and a volume-independent name, without claiming an unstated licence.

Sources/HanReaderDictionaryImport/DSLImporter.swift

DictionaryContainer.swiftExpose inexpensive lexeme counts and source offsets +23/-1

Expose inexpensive lexeme counts and source offsets

• Adds a SQL count for lexemes so summaries need not materialize the lexicon, plus ordered source offsets for resume verification. Makes the existing metadata writer available within the persistence module.

Sources/HanReaderPersistence/DictionaryContainer.swift

DictionarySchema.swiftMigrate containers for resumable imports +76/-1

Migrate containers for resumable imports

• Adds a v2 migration with per-entry source anchors and a single-row import job for source identity, parser version, progress, and cross-file headword carry-over. Increments the container format version.

Sources/HanReaderPersistence/DictionarySchema.swift

DictionaryWriter.swiftPersist crash-safe batches and finalize derived tables +481/-0

Persist crash-safe batches and finalize derived tables

• Opens imports with WAL, validates whether a job can resume, and commits sourced entries, lexemes, and checkpoints in transactions. Rebuilds character readings and syllable bases from database cursors at completion, then clears the job and drops the resume index.

Sources/HanReaderPersistence/DictionaryWriter.swift

CEDICTCommand.swiftCount lexemes without loading them for CLI output +1/-1

Count lexemes without loading them for CLI output

• Uses the new SQL count in the CC-CEDICT verbose summary instead of fetching every lexeme.

Sources/hanreader-dictgen/CEDICTCommand.swift

DSLCommand.swiftAdd the resumable 'dictgen dsl' command +165/-0

Add the resumable 'dictgen dsl' command

• Accepts a DSL volume, output path, optional syllable-base container, batch size, and minimum-entry threshold. Reports progress and import statistics, and allows the same command to resume an interrupted import.

Sources/hanreader-dictgen/DSLCommand.swift

DictgenCommand.swiftRegister and describe the DSL subcommand +5/-3

Register and describe the DSL subcommand

• Adds 'dsl' to the dictgen command list and replaces the future-milestone description with guidance on reader-supplied sources and resumption.

Sources/hanreader-dictgen/DictgenCommand.swift

Tests (1) +309 / -0
DSLImporterTests.swiftCover DSL import, interruption, offsets, progress, and metadata +309/-0

Cover DSL import, interruption, offsets, progress, and metadata

• Adds UTF-16 DSL fixtures and tests for complete and multi-file imports, deterministic interruption followed by resume, per-entry offsets, changed-source restarts, progress, and source-derived metadata. The resume test compares counts, offsets, and sampled senses with an uninterrupted import.

Tests/HanReaderDictionaryImportTests/DSLImporterTests.swift

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. A resumed import can lose a seam card ✓ Resolved
Description
Run.importFile clears the carried headwords before it can persist a batch containing a card
completed across a file seam. If that card fills a batch and the import stops, its checkpoint
restarts the next file without the headword, so the card's entries are deleted and its article
cannot rebuild them.
Code

Sources/HanReaderDictionaryImport/DSLImporter.swift[235]

+            state.checkpoint.leadingHeadwords = []
Evidence
The reader gives a carried-headword card no offset. The importer clears its saved headwords,
persists a batch checkpoint without advancing the offset, and resume deletes rows at that offset
before reconstructing a reader that no longer has the headword.

Sources/HanReaderCore/Format/DSLCardReader.swift[132-149]
Sources/HanReaderCore/Format/DSLCardReader.swift[178-191]
Sources/HanReaderDictionaryImport/DSLImporter.swift[226-273]
Sources/HanReaderPersistence/DictionaryWriter.swift[233-244]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A committed batch containing a seam-completed card can leave a checkpoint that cannot replay that card after interruption.
## Fix Focus Areas
- Sources/HanReaderDictionaryImport/DSLImporter.swift[226-273]
- Sources/HanReaderPersistence/DictionaryWriter.swift[233-244]
## Recommended Fix
Keep the carried headwords and a valid replay position until the seam card is safely past the checkpoint, or defer committing that batch until a replayable card boundary. Add a test that interrupts immediately after a seam card fills a batch.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Changed later volumes can mix old entries ✓ Resolved
Description
DictionaryWriter.open validates the first volume's identity but does not validate the identities
of the remaining volumes before accepting a checkpoint. If a later volume changes while the first
remains unchanged, prepare retains earlier entries and the importer continues with the new file
set, potentially skipping changed content or combining two versions.
Code

Sources/HanReaderPersistence/DictionaryWriter.swift[R181-185]

+            guard row["sourceName"] as String == source.name,
+                  row["sourceSize"] as Int == source.size,
+                  row["parserVersion"] as Int == source.parserVersion,
+                  abs((row["sourceModified"] as Double) - source.modified.timeIntervalSince1970)
+                  < 1
Evidence
The importer describes the set using the first file's name, size, and modification time. The resume
guard compares only those first-file fields and parser version, while the resume cleanup preserves
rows before the accepted checkpoint.

Sources/HanReaderDictionaryImport/DSLImporter.swift[109-124]
Sources/HanReaderPersistence/DictionaryWriter.swift[177-196]
Sources/HanReaderPersistence/DictionaryWriter.swift[233-244]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A checkpoint can be accepted after an included volume changes because resume validates only the first volume.
## Fix Focus Areas
- Sources/HanReaderDictionaryImport/DSLImporter.swift[109-124]
- Sources/HanReaderPersistence/DictionaryWriter.swift[177-196]
- Sources/HanReaderPersistence/DictionarySchema.swift[172-201]
## Recommended Fix
Persist and compare an ordered identity for every discovered volume, including its name, size, and modification time. Discard the job and rebuild when any volume differs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Missing volumes produce a finished dictionary 🐞 Bug ≡ Correctness
Description
DSLImporter.run proceeds without checking fileSet.diagnostics for discovery failures. When an
included volume is missing or the file limit truncates discovery, it finishes the available files
and returns a summary that does not count the discovery diagnostic.
Code

Sources/HanReaderDictionaryImport/DSLImporter.swift[R109-113]

+        let sources = try fileSet.files.map { try FileDescription(url: $0) }
+        guard let first = sources.first else {
+            throw DSLImportError.noFiles
+        }
+        let totalBytes = sources.reduce(0) { $0 + $1.size }
Evidence
Discovery records missing includes and file-limit truncation but returns a file set. The importer
reads its files without inspecting those diagnostics, and its summary uses only the checkpoint
diagnostic count.

Sources/HanReaderCore/Format/DSLFileSet.swift[120-159]
Sources/HanReaderDictionaryImport/DSLImporter.swift[109-157]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Discovery can identify a missing or truncated volume, but the importer still publishes the available files as a completed dictionary.
## Fix Focus Areas
- Sources/HanReaderDictionaryImport/DSLImporter.swift[104-113]
- Sources/HanReaderCore/Format/DSLFileSet.swift[120-159]
## Recommended Fix
Treat missing includes and file-limit truncation as import-stopping errors before opening the destination. Ensure discovery diagnostics are represented accurately to callers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (1)
4. Changing reading bases mixes parse results ✓ Resolved
Description
DictionaryWriter.Source has no identity for the syllableBases supplied to DSLCardBuilder.
Re-running an interrupted import with a different --bases value, or adding one after an initial
run without it, retains entries parsed with the old bases and parses later entries with the new
ones.
Code

Sources/HanReaderDictionaryImport/DSLImporter.swift[123]

+                parserVersion: Self.parserVersion,
Evidence
The card builder uses the supplied bases to turn readings into mergeable syllable tokens, while the
writer's persisted source identity and resume guard do not include those bases.

Sources/HanReaderCore/Format/DSLCardBuilder.swift[65-70]
Sources/HanReaderCore/Format/DSLCardBuilder.swift[119-135]
Sources/HanReaderDictionaryImport/DSLImporter.swift[115-137]
Sources/HanReaderPersistence/DictionaryWriter.swift[104-127]
Sources/HanReaderPersistence/DictionaryWriter.swift[180-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Changing the supplied syllable inventory between runs can produce one container with incompatible reading merge keys.
## Fix Focus Areas
- Sources/HanReaderDictionaryImport/DSLImporter.swift[115-137]
- Sources/HanReaderPersistence/DictionaryWriter.swift[104-127]
- Sources/HanReaderPersistence/DictionaryWriter.swift[180-196]
## Recommended Fix
Store and validate a stable fingerprint of the effective syllable-base set as part of the job identity. Restart the import when it differs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Capitalised syllables cannot be reused ✓ Resolved
Description
writeSyllableBases stores syllable.base without the lowercasing used by the existing in-memory
builder. If an imported dictionary supplies an uppercase-only base, a later import using its
inventory cannot match that base because the syllabifier looks up lowercase candidates.
Code

Sources/HanReaderPersistence/DictionaryWriter.swift[449]

+                        bases.insert(syllable.base)
Evidence
Pinyin syllables preserve case; the original base collector lowercases them and the syllabifier's
membership test also lowercases. The new disk-derived collector alone does not.

Sources/HanReaderCore/Pinyin/PinyinSyllable.swift[5-25]
Sources/HanReaderCore/Dictionary/LexiconBuilder.swift[263-275]
Sources/HanReaderCore/Pinyin/PinyinSyllabifier.swift[121-128]
Sources/HanReaderPersistence/DictionaryWriter.swift[439-457]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The streaming writer stores case-preserved bases, but the syllabifier looks them up in lowercase.
## Fix Focus Areas
- Sources/HanReaderPersistence/DictionaryWriter.swift[446-450]
## Recommended Fix
Insert `syllable.base.lowercased()` into the derived base set, matching `LexiconBuilder.syllableBases(from:)`. Test an inventory containing only a capitalised reading.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Interruptions miscount parse diagnostics ✓ Resolved
Description
Run.importFile excludes a checkpoint card from the stored card count but leaves that card's
builder diagnostics in the stored diagnostic count, so replay counts them again. Reader diagnostics
accumulated before a batch checkpoint have the opposite problem: they are added only at end-of-file
and are lost when resume seeks past them.
Code

Sources/HanReaderDictionaryImport/DSLImporter.swift[R271-273]

+                var stored = state.checkpoint
+                stored.cardsRead -= 1
+                try writer.append(batch, checkpoint: stored)
Evidence
Builder diagnostics are incremented before the batch commit, which subtracts only one from
cardsRead. Reader diagnostics remain on the reader until end-of-file, whereas resume restores the
persisted counter and seeks to the checkpoint offset.

Sources/HanReaderDictionaryImport/DSLImporter.swift[237-285]
Sources/HanReaderPersistence/DictionaryWriter.swift[187-193]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The diagnostic count does not describe the same replay boundary as the stored card count, and reader diagnostics are deferred past batch checkpoints.
## Fix Focus Areas
- Sources/HanReaderDictionaryImport/DSLImporter.swift[237-285]
## Recommended Fix
Store diagnostic counts only for input before the replay boundary, including reader diagnostics already encountered, and exclude diagnostics for the card that will be replayed. Test interruptions after both kinds of diagnostic.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. The verifier rejects other DSL dictionaries ✓ Resolved
Description
Adding the DSL compiler to dictgen makes containers from arbitrary DSL sources available beside
the existing verify command, whose checks still require specific CC-CEDICT words and readings. A
successfully imported dictionary without those exact entries fails verification even when its own
entries, metadata, and indexes are valid.
Code

Sources/hanreader-dictgen/DictgenCommand.swift[42]

+        subcommands: [CompileCEDICT.self, CompileDSL.self, VerifyDictionary.self],
Evidence
The new command compiles any DSL set into the same container format accepted by verify. The
verifier unconditionally requires a fixed list of CC-CEDICT readings, rather than selecting
expectations for the container being checked.

Sources/hanreader-dictgen/DSLCommand.swift[9-27]
Sources/hanreader-dictgen/DictgenCommand.swift[36-42]
Sources/hanreader-dictgen/VerifyCommand.swift[40-63]
Sources/hanreader-dictgen/VerifyCommand.swift[90-158]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The existing verification command applies CC-CEDICT fixture checks to newly supported DSL containers.
## Fix Focus Areas
- Sources/hanreader-dictgen/VerifyCommand.swift[40-112]
- Sources/hanreader-dictgen/DictgenCommand.swift[36-42]
## Recommended Fix
Run generic integrity, count, and metadata checks for every container, and apply known-word expectations only to the dictionary they describe. Avoid fetching the complete large DSL lexicon solely for counts.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn these tips off under Display > Comment parts

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Sources/HanReaderDictionaryImport/DSLImporter.swift Outdated
Comment thread Sources/HanReaderPersistence/DictionaryWriter.swift Outdated
Comment thread Sources/HanReaderDictionaryImport/DSLImporter.swift
Comment thread Sources/HanReaderDictionaryImport/DSLImporter.swift
Comment thread Sources/HanReaderPersistence/DictionaryWriter.swift Outdated
Comment thread Sources/HanReaderDictionaryImport/DSLImporter.swift Outdated
Comment thread Sources/hanreader-dictgen/DictgenCommand.swift
Base automatically changed from feat/m6-card-reader to main October 4, 2026 21:31
widgetii and others added 2 commits October 13, 2026 19:04
The last layer of the BKRS importer: a streaming, resumable pipeline, and
`hanreader-dictgen dsl` to drive it. The real 350 MB set imports end to end —
3,434,224 cards to 3,442,623 entries with zero diagnostics — and an import
interrupted four times with `kill -9` resumes to a container **identical** to
one never interrupted.

**Resumability is a requirement, not a refinement**, because on iOS the app
will be suspended or killed part way through a minute-long import. Three
things make it work:

*The checkpoint and the rows it describes are one transaction.* Written
separately, a crash between them leaves the checkpoint behind the rows and the
next run writes those rows again.

*Every entry carries its own card's offset.* This is the bug the gate was for.
Stamping a whole batch with the offset of the card that happened to fill it
means a resume deletes the batch and re-reads only its last card: measured
over four interruptions of a 574,709-entry import, **14,997 entries vanished
and it reported success**. The stored card count likewise excludes the
checkpoint card, since a resume re-reads it — otherwise the total grew by one
per interruption.

*WAL, not `journal_mode = OFF`.* Compiling CC-CEDICT runs with durability off
because the output is a build artefact regenerated in seconds. A reader's own
import is not that, and under `journal_mode = OFF` a process killed
mid-transaction can leave the file corrupt — which cannot be resumed. The two
requirements are mutually exclusive and resumability is the one that was
asked for, so every committed batch survives the process dying. A power cut
would need `FULL`; a dictionary import is not worth an fsync per batch, and
the job resumes from the last batch that reached disk.

The source is identified by name, size and modification time, never by path:
on iOS the container directory's UUID changes across installs and restores, so
a stored path is stale by the time it matters. A job from a different file or
a different parser version is discarded rather than continued — no schema
migration reconciles output from two parsers, and half a dictionary from each
is worse than either.

**Memory is bounded by the batch.** Measured peak over the full set is 55.9 MB
against a 60 MB ceiling, with the card-reading phase steady at 25–30 MB.
Getting there meant finding two things that only appear at scale, neither of
them in the import loop:

- `LexiconBuilder.characterReadings` took every single-character entry as an
  array. Those are the densest cards in a dictionary — 38,618 of them carrying
  27 MB of encoded senses, 一 and 打 with 57 each — and decoding them all at
  once took peak memory to **857 MB**. It is now a collector fed one entry at
  a time through a cursor, keeping five small values per candidate reading.
- `DictionaryContainer.lexicon()` in the `--verbose` summary fetched all
  1,806,893 lexemes to print one number, which cost **450 MB** and looked for
  a while like the import itself. Added `lexemeCount()`.

**The lexicon is capped at four characters for a dictionary like this one.**
At the general cap of six, BKRS contributes 2,804,554 lexemes — a segmentation
lexicon in which almost every run of Han characters is a word, which is the
condition under which maximum-matching produces confident nonsense, and 176 MB
of index for it. Four is where the measured length distribution says to stop:
1→33,372, 2→427,842, 3→455,438, 4→959,810, then 5→585,617 and 6→439,262 of
increasingly phrase-like entries.

Character readings and the syllable inventory are derived at the end from what
is on disk rather than accumulated while writing: both need to see the whole
dictionary, and accumulated state is state a crash destroys and a resume
cannot rebuild. The source index is dropped once the import finishes — 53 MB
the reader would carry for a resume that can no longer happen — and recreated
if one ever starts again.

**Nothing about the dictionary is hardcoded.** Name, languages and version
come out of the file's own directives; the predecessor took the name from a
constant in its source, so it mislabelled any other DSL dictionary. No licence
is claimed: BKRS states none, and inventing one would be worse than admitting
it, because the Acknowledgements screen renders whatever is here verbatim.

### Two gates are met and two are not

| gate | measured |
|---|---|
| `kill -9` mid-import resumes clean | **passes** — identical after 4 kills |
| < 60 MB RSS over baseline | **passes** — 55.9 MB peak |
| 350 MB set in < 90 s | **205.5 s** |
| on-disk size | **868 MB** |

Both misses have one cause, and it is measurable: the `entry` table is 625 MB
of the 868, and only **40.7%** of a sense payload is text — the rest is JSON
syntax and key names. The `summary` column adds a further ~160 MB duplicating
a gloss that is already inside the payload, and `UsageExample` stores its text
twice, as `raw` and again as the split halves. A compact binary
`SensePayloadCodec`, which the architecture plan already calls for, addresses
the size and most of the time at once; it changes the container format and the
bundled CC-CEDICT dictionary with it, so it is its own change rather than a
rider on this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven correctness fixes raised in review, plus one the review's own test
exposed, all in the resume path.

**Resuming at a file boundary read the byte-order mark as content.** An
end-of-file checkpoint stores byte offset zero to mean "from the beginning of
the next volume", and handing that straight back to the byte reader starts it
*at* the mark — which then arrives as the first character of the first
headword. Every multi-file resume at a boundary corrupted one headword, and
nothing would ever find that word again. Zero now means nil to the reader,
which is where the mark is skipped.

**A card assembled across a file seam lost its headword.** The carried
headwords were cleared before the batch containing that card committed, so a
checkpoint whose frontier was the seam card restarted the next volume without
it: the card's entries were deleted on resume and its article could not
rebuild them. They are now cleared only when a real card offset is committed.

**Only the first volume's identity was checked.** A changed third volume with
an unchanged first one resumed onto entries from the old version — a
dictionary half from each, reporting success. The same hole covered the
syllable inventory, which decides how a reading is split and therefore what
the cross-dictionary merge key is, so re-running with a different `--bases`
mixed readings parsed two ways. Both are now in one fingerprint covering every
volume's name, size and modification time and the sorted bases. Spelled out
rather than hashed: `Hasher` is seeded per process, so `hashValue` is not
comparable across runs at all, and the bug would have been invisible and
intermittent.

**Discovery's own complaints were dropped on the floor.** A volume an
`#INCLUDE` names but that is not there, or a set longer than discovery will
follow, produced a dictionary missing part of itself and a summary that said
nothing. They are reported separately from the in-file diagnostics, because a
reader has to be told about these whatever the import otherwise achieved.

**Diagnostic counts drifted across an interruption**, in both directions. A
frontier card's parse diagnostics were counted and then counted again on
replay; the reader's own were added only at end of file, so a resume seeking
past the lines that caused them lost them. The split is now by whether a
resume will re-read the line: the reader's complaints describe lines it has
already gone past, so they are settled and belong in the checkpoint; the
frontier card's do not.

That last one is where the review's test found a bug of mine rather than the
reverse. My first fix snapshotted the totals before counting the reader's
diagnostics, which put an orphaned article line on the wrong side of the
boundary — and the test for it was flaky, passing alone and failing under the
full suite, because whether it bit depended on where the interruption landed.
Ordering the two counts correctly makes it deterministic.

**And `verify` rejected any dictionary that was not CC-CEDICT.** Its content
checks are CC-CEDICT's own words and its entry and lexeme floors are
CC-CEDICT's size; a container compiled from someone's DSL dictionary is a
valid container that does not contain 中国 with the reading `zhong1 guo2`.
Those checks now apply to the format they describe, and the structural ones to
everything. It also fetched the whole lexicon to take a maximum, which on a
BKRS container is 1,806,893 rows; `longestLexeme()` asks the database.

Verified on a two-volume set killed three times, the third kill landing inside
the second volume: 574,708 entries, 572,236 cards, 345,038 lexemes, zero
diagnostics and zero byte-order-mark-corrupted headwords — row for row
identical to an uninterrupted import. Each fix was mutation-tested; four of
the first six survived every test I had written for them, because a completed
import clears its job and a test that runs one to the end and then runs it
again is testing a fresh import and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii force-pushed the feat/m6-import-pipeline branch from 5e7318c to aabb4b5 Compare October 13, 2026 16:24
@widgetii
widgetii enabled auto-merge (squash) October 13, 2026 16:32
Three resume tests passed locally and failed on CI, all with `resumed: false`
— the import had finished before the test could cancel it, so there was
nothing interrupted and nothing to resume.

Waiting for a progress value and then cancelling is not enough on its own:
the importer carries on in the meantime, and a twenty-card import takes less
time than an async test needs to react. The callback now signals the test and
then *blocks*, so the cancellation is in place before the importer can take
another step. Progress is reported on the first batch to commit, so afterwards
exactly one batch is in the container and the rest of the file is not.

The racy shape is gone from all four interruption tests rather than patched in
the three that happened to fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
widgetii and others added 2 commits October 13, 2026 20:20
`shouldContinue` stops at the next card boundary and returns a summary with
`completed == false`: the work so far is committed, the job row stays, and
nothing has thrown. That is what a Cancel button wants. Cancelling the task
still throws `CancellationError`, for a caller that is tearing down rather
than pausing.

It also replaces the only mechanism the resume tests had for interrupting an
import, and the two earlier attempts at that were both wrong in instructive
ways. Sleeping and then cancelling **raced** — a twenty-card import finishes
before an async test can react, which passed locally and failed on CI with
`resumed: false`. Signalling the test and blocking the callback until it
cancelled was deterministic but **blocked a thread of the cooperative pool**,
and with Swift Testing running these tests in parallel the pool deadlocked:
CI sat in that one suite for twenty minutes before I cancelled it.

Asking the importer to stop needs no concurrency at all. Progress is reported
when a batch commits and `shouldContinue` is asked at the next card, so the
test stops the import with exactly one batch written and the rest of the file
unread — synchronously, with no threads and nothing to race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two performance gates M6 missed, closed by measurement rather than by
guessing where the bytes were. Over the full 350 MB BKRS set:

| | before | after |
|---|---|---|
| on disk | 868 MB | **493 MB** |
| elapsed | 206 s | **74.7 s** |

The bundled CC-CEDICT dictionary goes from 32 MB to **24 MB** with it, and
3,434,224 cards still produce 3,442,623 entries and zero diagnostics.

**A packed binary codec for sense payloads.** The `entry` table was 625 MB of
the 868, its payloads 327 MB of that, and **only 40.7% of a payload was text**
— the rest `{"gloss":{"text":…},"level":0,"kind":…}` written out once per
sense, 3,691,468 times. The packed form is 168 MB, within 4% of the raw text
it carries, which is about as close as an uncompressed encoding gets. Three
ideas earn their complexity: a presence byte, since the common sense is a
gloss and a level and nothing else; varints, since every integer here is a
level, a parent, an offset or a count; and storing a usage example once, as
its raw text plus the two boundaries inside it, where before `raw` and the
heuristic split of it were both written out in full and cost the same again.

`SensePayloadCodec` is a seam, not a single implementation, because this
choice will be made again — a block-compressing codec is the obvious next tier
and the plan already names it. A container records which codec wrote it, so
adding one does not invalidate the dictionaries already on disk, and JSON
still reads. An unrecognised identifier is an error rather than a fallback,
because misreading a payload produces plausible nonsense.

**The `summary` column is gone.** The denormalised first gloss, written since
v1 and **read by nothing** — 160 MB of Cyrillic prose already sitting in the
payload beside it. It was there so a result list could render without decoding
a payload per row; with the packed codec that decode is microseconds for the
ten rows a list shows.

**The lookup indexes are built once, at the end.** Maintained while rows
arrive, `entry_on_simplified` is a random-order insert into an 84 MB B-tree
three and a half million times. Built in one pass over rows that are all
already there it is also 63 MB smaller, because it carries none of the page
splits random insertion leaves behind. That one change is most of the
130-second difference.

It costs memory, and that is the honest shape of it: `CREATE INDEX` is a
single atomic statement, so the whole new index is dirty at once — about
92 MB, which no `cache_size`, `temp_store`, `threads`, `soft_heap_limit` or
`hard_heap_limit` pragma reduces. All five were tried. So the two budgets
trade against each other and the choice is explicit:

| `Indexing` | time | peak memory | size |
|---|---|---|---|
| `.atTheEnd` | **75 s** | 134 MB | **493 MB** |
| `.whileWriting` | 188 s | **53 MB** | 556 MB |

`.atTheEnd` is the default because compiling a DSL set is a desktop operation
by design — a Mac compiles it once and a phone imports the compiled container
— so the 60 MB ceiling, which exists for iOS, is not the binding constraint
on this path. `.whileWriting` exists for the day something runs an import
where it is, and both numbers are on the type so the trade is not rediscovered.

What remains above the plan's 210–240 MB estimate is no longer payload. Of
493 MB: 168 MB of senses that are 96% text, 84 MB of headword index, 104 MB of
segmentation lexicon, ~75 MB of headwords and readings, and the row overhead
of three and a half million rows. The estimate was computed from the
predecessor's flattened definitions and did not account for the lexicon, the
index, or storing sense structure and examples at all. Compressing the payload
is the next real lever and the seam is there for it.

Verified by round-tripping all 523 senses of the committed real-data slice,
field for field; by a two-volume import killed three times, the third landing
inside the second volume, coming back row-for-row identical to an
uninterrupted one; and by truncating a payload at every length from zero,
which throws rather than trapping at each.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii merged commit a82866a into main Oct 13, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m6-import-pipeline branch October 13, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant