Skip to content

feat: build senses from DSL cards - #25

Merged
widgetii merged 2 commits into
mainfrom
feat/m6-card-builder
Oct 4, 2026
Merged

widgetii merged 2 commits into
mainfrom
feat/m6-card-builder

Conversation

@widgetii

@widgetii widgetii commented Oct 4, 2026

Copy link
Copy Markdown
Member

Layer four of the BKRS importer: a card's token stream becomes structured senses. This is the layer the rebuild exists for.

The bug this replaces

The predecessor produced one sense per card — 3,434,222 rows over 3,434,222 headwords, exactly one each — because it treated a physical line as a sense, and a card's entire sense run arrives on one line. It then tried to recover the structure downstream by splitting that blob on semicolons.

Over the same input this produces 3,691,468 senses, of which 115,825 cards carry more than one. 了 has 30; 一 and 打 have 57 apiece, rendered as numbered lists under Roman-numeral divisions with parts of speech and split examples.

[mN] is an indent level, not a container

Getting this wrong is the one mistake that still passes a unit test. A card writes [m1]…[/m][m2]…[/m] — each span closed before the next opens — so by the time the [m2] arrives its parent is already closed. Hierarchy comes from the levels in document order, not from which spans happen to be open.

My first version used an open/close stack and gave every nested sense a nil parent. SenseAssembly now keeps lastByLevel: [Int: Int], the most recent sense at each depth: that is what lets an [m4] find the right [m2], and what makes a later [m1] start a new branch instead of joining the old one. Opening a level forgets everything deeper.

Markup is not reliably balanced either — the set has two more [mN] opens than [/m] closes — so anything still open at the end of a card is closed there.

Other decisions worth the review

  • One entry per reading, not one entry whose reading is le, liǎo, liào. It is what the schema says, the duplication is negligible (9,069 of 3,434,224 cards), and the alternative puts all three readings above the word on the page, which is the prototype behaviour this replaces.
  • _ is not a reading. A naive split yields [""] — an entry claiming its pronunciation is the empty string — for 77% of the dictionary.
  • Examples keep raw unconditionally, because the Chinese/translation split is a heuristic. CJK blocks alone are not enough: the quotation marks Chinese uses live in General Punctuation, so a block test stops at the first “ and hands half the sentence to the translation.
  • Unrecognised [p] markers go to registers verbatim rather than being dropped. A field marker the list has not heard of is still something the dictionary chose to print.
  • Encoding omits defaults. Writing the empty new fields for every CC-CEDICT sense added 14 MB to a 35 MB bundled dictionary; omitting them takes it to 32 MB, below where it started. Decoding is field by field, so a container compiled before these fields existed still opens instead of a model addition forcing a re-import.

Validation

Against the full local BKRS set (never committed — see CONTRIBUTING):

cards:        3434224
entries:      3442623
senses:       3691468   (1.07 per card)
diagnostics:  0
elapsed:      70.1 s

cards with nesting:    38301
cards with examples:   35510
cards with a category:  3802
cards with numbering:  85037
readings not split:     4474

30 new tests (24 card builder, 6 sense coding). Core suite at 223 tests, lint clean, no warnings under -warnings-as-errors, both app bundles build, bundled dictionary 32 MB.

🤖 Generated with Claude Code

Layer four: turning a card's token stream into structured senses. This is
the layer the rebuild exists for.

The predecessor produced **one sense per card** — 3,434,222 rows over
3,434,222 headwords, exactly one each — because it treated a line as a sense
and a card's whole sense run arrives on a single physical line. It then tried
to recover the structure downstream by splitting that blob on semicolons.
Over the same input this produces 3,691,468 senses, of which 115,825 cards
carry more than one; 了 has 30 and 一 and 打 have 57 apiece.

`[mN]` is an **indent level, not a container**, and getting that wrong is the
one mistake that looks right in a unit test. A card writes
`[m1]…[/m][m2]…[/m]`, closing each span before opening the next, so by the
time the `[m2]` arrives its parent is already closed. Hierarchy comes from
the levels in document order, not from which spans happen to be open — my
first version used an open/close stack and gave every nested sense a nil
parent. A level-indexed map of the most recent sense at each depth is what
makes `[m4]` find the right `[m2]`, and what makes a later `[m1]` start a new
branch instead of joining the old one.

Markup is not reliably balanced either: the set has two more `[mN]` opens
than `[/m]` closes, so anything still open at the end of a card is closed
there.

One entry per reading, rather than one entry whose reading is the whole
comma-separated string. It is what the schema says, the duplication is
nothing (9,069 cards of 3,434,224), and the alternative puts `le, liǎo,
liào` above the word on the page — which is precisely the prototype
behaviour this replaces.

`_` is not a reading. A naive split yields `[""]`, an entry claiming its
pronunciation is the empty string, for 77% of the dictionary.

Examples split at the end of the leading Han run, keeping `raw` always
because the split is a heuristic. The CJK blocks alone are not enough: the
quotation marks Chinese uses live in General Punctuation, and a block test
stops at the first opening quote and hands half the sentence to the
translation.

Senses gained level, parent, label, part of speech, registers and examples;
glosses gained style runs. Both encode only what they use — writing the empty
defaults for every CC-CEDICT sense added 14 MB to a 35 MB bundled dictionary,
and omitting them takes it to 32 MB, below where it started. Both decode
field by field so a container compiled before these fields existed still
opens, rather than a model addition forcing a re-import.

Validated over the whole set: 3,434,224 cards to 3,442,623 entries and
3,691,468 senses in 70 seconds, with zero diagnostics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Build structured senses from DSL cards

✨ Enhancement 🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Convert DSL card markup into distinct, nested senses instead of one gloss per card.
• Create an entry per reading while preserving categories, examples, references, and emphasis.
• Keep older dictionary payloads readable and omit unused fields to limit bundle size.
Diagram

graph TD
  Card["DSL Card"] --> Builder["Card Builder"] --> Lexer["DSL Lexer"] --> Assembly["Sense Assembly"] --> Senses["Structured Senses"] --> Entries["Dictionary Entries"] --> Coding["Compact Coding"]
  Builder --> Syllabifier["Pinyin Syllabifier"] --> Entries
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Represent senses as a recursive tree
  • ➕ Makes parent-child relationships implicit in the data structure.
  • ➖ Requires recursive coding and traversal instead of directly supporting an indented list.
  • ➖ Still requires document-order level tracking to identify parents.

Recommendation: Keep the flat sense list with explicit parents and a most-recent-sense-per-level map. DSL indent levels, rather than tag lifetimes, determine hierarchy; this approach also fits the existing entry model and list rendering.

Files changed (5) +1044 / -2

Enhancement (2) +293 / -2
DSLCardBuilder.swiftCreate dictionary entries from DSL cards +124/-0

Create dictionary entries from DSL cards

• Adds the card model and builder that lexes body lines, assembles senses, and propagates diagnostics. It creates one entry per reading, treats '_' as no reading, and retains unsplittable readings as literals.

Sources/HanReaderCore/Format/DSLCardBuilder.swift

DictionaryEntry.swiftExtend sense models with compact, compatible coding +169/-2

Extend sense models with compact, compatible coding

• Adds sense hierarchy, labels, categories, examples, and gloss style ranges. Custom coding omits default values and supplies defaults when decoding payloads written before these fields existed.

Sources/HanReaderCore/Model/DictionaryEntry.swift

Bug fix (1) +377 / -0
SenseAssembly.swiftAssemble nested senses and their annotations +377/-0

Assemble nested senses and their annotations

• Builds senses from DSL tokens using the most recent sense at each indent level to determine parents. It extracts numbering, grammatical labels, examples, emphasis, and cross-references while retaining unmarked body text.

Sources/HanReaderCore/Format/SenseAssembly.swift

Tests (2) +374 / -0
DSLCardBuilderTests.swiftTest card-to-sense conversion +268/-0

Test card-to-sense conversion

• Covers multiple senses on one line, indent-based parenting, unbalanced markup, readings, annotations, examples, headwords, and diagnostic propagation.

Tests/HanReaderCoreTests/DSLCardBuilderTests.swift

SenseCodingTests.swiftTest compact coding and legacy payloads +106/-0

Test compact coding and legacy payloads

• Checks omission of unused fields, encoding of populated fields, round-tripping, and decoding of older sense and gloss payloads.

Tests/HanReaderCoreTests/SenseCodingTests.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 (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Labelled glosses lose reference links ✓ Resolved
Description
liftLabel shifts style and reference ranges by the label length but also trims whitespace after
the label without accounting for it. When a numbered gloss begins 1) [ref]word[/ref], the
reference range extends past the resulting gloss and is discarded, while a style on the same text
covers the wrong characters.
Code

Sources/HanReaderCore/Format/SenseAssembly.swift[R258-262]

+        let removed = draft.text.utf16.count - text.utf16.count
+            + match.utf16.count
+        draft.text = String(text.dropFirst(match.count))
+            .trimmingCharacters(in: .whitespaces)
+        draft.styles = shift(draft.styles, by: removed, limit: draft.text.utf16.count)
Evidence
The replacement gloss trims text after dropping the label, but both ranges are adjusted using
removed, which includes only the label and the difference from trimming the original text. An
original reference at 3..<7 in 1) word becomes 1..<5 for a four-character gloss and fails the
bounds check.

Sources/HanReaderCore/Format/SenseAssembly.swift[254-273]
Sources/HanReaderCore/Format/SenseAssembly.swift[163-183]

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

## Issue description
Lifting a sense label removes post-label whitespace without including it in the offset adjustment, corrupting styles and dropping references.
## Fix Focus Areas
- Sources/HanReaderCore/Format/SenseAssembly.swift[254-273]
- Tests/HanReaderCoreTests/DSLCardBuilderTests.swift[179-207]
## Recommended Fix
Calculate the actual UTF-16 prefix removed from the original text, including whitespace after the label, and shift both kinds of range by that amount. Test styled and referenced text following `1) `.

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



Remediation recommended

2. Leading spaces break gloss markup ranges ✓ Resolved
Description
finish() trims draft.text for Gloss.text but passes the unadjusted style and reference ranges
into that gloss. When text before a marked span begins with whitespace and has no lifted label, the
published range points to the wrong characters or beyond the end of the gloss.
Code

Sources/HanReaderCore/Format/SenseAssembly.swift[R228-231]

+                gloss: Gloss(
+                    text: draft.text.trimmingCharacters(in: .whitespaces),
+                    references: draft.references,
+                    styles: draft.styles,
Evidence
Styles and references record UTF-16 offsets in the untrimmed draft, whereas the finished gloss uses
trimmed text without shifting either array. For [m1]  [i]foo[/i][/m], the style remains 2..<5
while the gloss is foo.

Sources/HanReaderCore/Format/SenseAssembly.swift[136-139]
Sources/HanReaderCore/Format/SenseAssembly.swift[163-183]
Sources/HanReaderCore/Format/SenseAssembly.swift[219-239]

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

## Issue description
Final gloss trimming changes the text without updating its recorded markup offsets.
## Fix Focus Areas
- Sources/HanReaderCore/Format/SenseAssembly.swift[219-240]
- Tests/HanReaderCoreTests/DSLCardBuilderTests.swift[179-207]
## Recommended Fix
Apply the leading-trim offset to styles and references and clip them to the final gloss, including when no label is present. Add tests with whitespace before styled and referenced spans.

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


3. Unclosed labels and examples disappear ✓ Resolved
Description
finish() returns drafts without committing the active grammar or example buffer, although
append has already diverted their text away from the gloss. If a valid [p] or [ex] opening
lacks its closing tag, its contents are absent from the resulting sense; if another sense opens
first, that active buffer can also capture the next sense's text.
Code

Sources/HanReaderCore/Format/SenseAssembly.swift[R219-222]

+    mutating func finish() -> [Sense] {
+        lastByLevel.removeAll()
+        current = nil
+        return drafts.enumerated().map { index, draft in
Evidence
append returns after adding text to an active grammar or example buffer. Only the matching close
methods add those buffers to a draft, while finish neither calls them nor otherwise preserves the
text; openSense does not reset either buffer.

Sources/HanReaderCore/Format/SenseAssembly.swift[104-121]
Sources/HanReaderCore/Format/SenseAssembly.swift[141-159]
Sources/HanReaderCore/Format/SenseAssembly.swift[191-222]

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

## Issue description
Unclosed grammar and example tags leave captured text outside every finished sense and can carry capture into a later sense.
## Fix Focus Areas
- Sources/HanReaderCore/Format/SenseAssembly.swift[104-121]
- Sources/HanReaderCore/Format/SenseAssembly.swift[141-149]
- Sources/HanReaderCore/Format/SenseAssembly.swift[191-222]
## Recommended Fix
Resolve active grammar and example buffers before changing senses and at card completion, preserving their text or metadata. Test missing closing tags both at end-of-card and before another sense.

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


4. Unclosed emphasis loses its formatting ✓ Resolved
Description
closeDeepestSense deletes pending style and reference starts instead of completing their spans
against the current sense. If a closing [/i], [/b], [ /c], or [/ref] is missing before
[/m], the text survives but its emphasis or cross-reference metadata does not; the same pending
starts remain uncommitted at card completion.
Code

Sources/HanReaderCore/Format/SenseAssembly.swift[R129-132]

+    private mutating func closeDeepestSense() {
+        styleStarts.removeAll()
+        referenceStart = nil
+    }
Evidence
Opening tags only record offsets, and the close methods are the only paths that create StyleRun
and CrossReference values. The sense-close path clears those offsets, and finish does not
finalize any still open.

Sources/HanReaderCore/Format/SenseAssembly.swift[72-79]
Sources/HanReaderCore/Format/SenseAssembly.swift[129-132]
Sources/HanReaderCore/Format/SenseAssembly.swift[163-183]
Sources/HanReaderCore/Format/SenseAssembly.swift[219-240]

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

## Issue description
Sense closure and card completion discard open emphasis and reference metadata.
## Fix Focus Areas
- Sources/HanReaderCore/Format/SenseAssembly.swift[104-132]
- Sources/HanReaderCore/Format/SenseAssembly.swift[163-183]
- Sources/HanReaderCore/Format/SenseAssembly.swift[219-222]
## Recommended Fix
Finalize pending spans against the current sense before clearing their starts at a sense boundary or card completion. Cover missing closing style and reference tags in tests.

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


View medium (3)
5. Unmarked cards lose opening emphasis ✓ Resolved
Description
append creates the fallback sense on the first text token, and openSense clears style and
reference starts recorded before that token. For a body without [mN] that begins [i]text[/i] or
[ref]word[/ref], the gloss text is retained but the opening markup never becomes a style or
reference.
Code

Sources/HanReaderCore/Format/SenseAssembly.swift[R154-156]

+        if current == nil {
+            openSense(level: 0)
+        }
Evidence
A style or reference opening records offset zero while current is nil. The first text token then
calls openSense, which clears both sets of starts, so the eventual closing tag has nothing to
commit.

Sources/HanReaderCore/Format/SenseAssembly.swift[72-79]
Sources/HanReaderCore/Format/SenseAssembly.swift[104-121]
Sources/HanReaderCore/Format/SenseAssembly.swift[141-168]

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

## Issue description
Creating a fallback sense after an opening decoration erases that decoration's start offset.
## Fix Focus Areas
- Sources/HanReaderCore/Format/SenseAssembly.swift[64-79]
- Sources/HanReaderCore/Format/SenseAssembly.swift[104-121]
- Sources/HanReaderCore/Format/SenseAssembly.swift[141-159]
## Recommended Fix
Ensure a fallback sense exists before recording a style or reference opening, without clearing that opening during text append. Test styled and referenced bodies without sense tags.

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


6. Cards with extra spellings lose headwords ✓ Resolved
Description
entries(from:) constructs a single Headword from only the first two members of card.headwords.
When a DSL card has three or more headword lines, every later spelling is absent from its entries
and cannot reach lookup or lexicon generation.
Code

Sources/HanReaderCore/Format/DSLCardBuilder.swift[R72-76]

+        guard let simplified = card.headwords.first else { return ([], diagnostics) }
+        let headword = Headword(
+            simplified: simplified,
+            traditional: card.headwords.dropFirst().first,
+        )
Evidence
DSLCard explicitly accepts multiple headword lines, but this construction reads only indices zero
and one. Headword.bothScripts exposes only the resulting pair, and lexicon generation uses that
pair, leaving no downstream path for later lines.

Sources/HanReaderCore/Format/DSLCardBuilder.swift[5-20]
Sources/HanReaderCore/Format/DSLCardBuilder.swift[72-89]
Sources/HanReaderCore/Model/DictionaryEntry.swift[28-40]
Sources/HanReaderCore/Dictionary/LexiconBuilder.swift[76-83]

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 builder accepts several headwords per card but discards all spellings after the second.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLCardBuilder.swift[5-20]
- Sources/HanReaderCore/Format/DSLCardBuilder.swift[72-89]
## Recommended Fix
Represent each additional headword in emitted entries rather than treating the entire array as one simplified/traditional pair. Add a three-headword card test that verifies every spelling remains available.

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


7. Words across body lines run together ✓ Resolved
Description
entries(from:) passes each body line's tokens directly to the same assembly without supplying a
line boundary, and append concatenates adjacent text verbatim. When one sense continues from a
line ending in first to a line beginning in second, its gloss becomes firstsecond.
Code

Sources/HanReaderCore/Format/DSLCardBuilder.swift[R65-69]

+        for line in card.body {
+            let lexed = DSLLexer.tokens(in: line)
+            diagnostics.append(contentsOf: lexed.diagnostics)
+            assembly.consume(lexed.tokens)
+        }
Evidence
DSLCard.body stores separate strings, but the loop consumes their tokens consecutively. The
assembly appends text with += and receives no newline or space between those calls.

Sources/HanReaderCore/Format/DSLCardBuilder.swift[14-20]
Sources/HanReaderCore/Format/DSLCardBuilder.swift[65-70]
Sources/HanReaderCore/Format/SenseAssembly.swift[141-159]

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

## Issue description
Separate physical body lines are concatenated without a separator when they contribute to one sense.
## Fix Focus Areas
- Sources/HanReaderCore/Format/DSLCardBuilder.swift[65-70]
- Sources/HanReaderCore/Format/SenseAssembly.swift[141-159]
## Recommended Fix
Supply an appropriate text boundary between consecutive body lines when both contribute prose to the same sense, without adding unwanted separators between distinct markup-delimited senses. Test a sense whose text continues onto a second line.

ⓘ 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 describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Sources/HanReaderCore/Format/SenseAssembly.swift Outdated
Comment thread Sources/HanReaderCore/Format/SenseAssembly.swift Outdated
Comment thread Sources/HanReaderCore/Format/SenseAssembly.swift
Comment thread Sources/HanReaderCore/Format/SenseAssembly.swift
Comment thread Sources/HanReaderCore/Format/SenseAssembly.swift Outdated
Comment thread Sources/HanReaderCore/Format/DSLCardBuilder.swift Outdated
Comment thread Sources/HanReaderCore/Format/DSLCardBuilder.swift Outdated
@widgetii
widgetii enabled auto-merge (squash) October 4, 2026 19:59
Eight correctness fixes to the sense builder, seven of them raised in review
on this branch and all eight measured against the full 3,434,224-card set.

**Gloss ranges moved out from under the text they describe.** A style run or
a cross-reference records a UTF-16 range into the sense's text, and the text
was then trimmed in one function while the offsets were adjusted in another.
Lifting `1)` out of a gloss also removes the space after it, which the offset
arithmetic did not account for, so every range in a numbered sense pointed one
character to the right: 16,957 senses with a style run and 3,974 with a
cross-reference, the latter not merely shifted but dropped whenever it reached
the end of its gloss. A further 85,395 unnumbered senses had the same fault
from the final trim alone — anything whose prose follows a `[p]…[/p]`, which
leaves a leading space, and there are 395,878 of those tags.

Now the prefix is measured once, where it is removed, and every range moves by
that one number and is clipped to the result. Clipped, not discarded: a run
reaching into whitespace the trim took is still the run the dictionary asked
for. Over the whole set, 0 of 380,683 style runs and 0 of 96,165 references
now point at anything other than what they claim to; the tests assert the
substring a range selects rather than its offsets, because an offset that is
wrong by one still reads as a plausible number in a failure message.

**Markup left open was discarded rather than settled.** `[/m]` cleared the
pending style and reference offsets, so 241 sense spans that close with a
style tag still open kept their text and lost their emphasis. An unclosed
`[p]` or `[ex]` was worse: those divert text out of the gloss, so its contents
left the sense altogether and the buffer went on collecting the *next* sense's
prose. None occur in this set, but losing text is the one thing this importer
must never do. Everything still open is now settled against the sense it was
opened in — at `[/m]`, when the next sense opens, and at the end of the card.

**A decoration opening before any `[mN]`** — 479 cards — lost its offset,
because the fallback sense was created later by the first text token and
cleared what the `[i]` had already recorded. Settling on sense boundaries
makes the clearing unnecessary, so it is gone rather than guarded.

**A `[ref]` span including the space before its closing tag** — 21 of them,
`[ref]尽管 [/ref]` — recorded a headword with a trailing space, which matches
no entry in any dictionary, so the link pointed nowhere. The span is trimmed
along with the string, so the range and the word it names cannot disagree.

**A card's article is not always one physical line.** 乐芙兰 is the one card
in 3,434,224 that breaks a line inside an open `[m1]`, and most other DSL
dictionaries write one sense per line. A break inside a sense is worth a
space: concatenating gave `Ле Блан(чемпион из Лиги Легенд)`.

**Several headword lines are several spellings**, each now its own entry over
the same senses, so every spelling can be looked up. Reading the second line
as the traditional form of the first was an invention — the format says
consecutive headword lines are alternative headwords and says nothing about
script — and it silently dropped every spelling after the second. No card in
this set has more than one headword, so this changes nothing about what BKRS
imports; it changes what the next dictionary imports.

Counts over the whole set are unchanged — 3,434,224 cards to 3,442,623 entries
and 3,691,468 senses, zero diagnostics, 17.8 s — which is the point: these fix
what the senses contain, not how many there are. Each fix was mutation-tested;
two of the first attempts were killed by no test at all and the tests were
written until they were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii merged commit ed17fd3 into main Oct 4, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m6-card-builder branch October 4, 2026 20:29
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