Skip to content

fix: choose a heteronym's reading from the words around it - #33

Merged
widgetii merged 3 commits into
mainfrom
fix/heteronym-context
Oct 19, 2026
Merged

widgetii merged 3 commits into
mainfrom
fix/heteronym-context

Conversation

@widgetii

Copy link
Copy Markdown
Member

The defect

In 「我在一个山村学校当老师,教学任务很重」 the reader annotated 重 as chóng — "to repeat" — where the sentence means "very heavy", zhòng.

The sentence segments to … | 任务 | 很 | 重, so 重 is a lone token. ReadingComposer ranks its readings by preferring lowercase, then a neutral tone, then whichever reading the dictionary says most about. 重 has neither a capital nor a neutral tone, so the sense count decided it — and CC-CEDICT holds:

重 [chong2] 5 senses  /to repeat/repetition/again/re-/classifier: layer/
重 [zhong4] 3 senses  /heavy/serious/to attach importance to/

LexiconBuilder.CharacterReadings ranks on the same sense count, so both the whole-word and the per-character paths produced chóng. The sense count is a frequency proxy, and it is wrong for precisely the characters whose readings are different words. ReadingComposer already named 为, 长, 重 and 相 as the cases it gets wrong.

Why it needed context

No context-free default could fix it, because both readings are right somewhere: 重 is chóng in 重复 and zhòng in 很重. Only the neighbouring words separate them.

So ReadingComposer now takes a ReadingContext, and HeteronymRules answers for the cases a closed-class neighbour settles grammatically. Closed classes only, deliberately: a rule over an open class needs a tagger, while a rule over a list of two dozen adverbs is a finite fact about the grammar that a reviewer can check.

  • Degree adverb (很, 太, 非常, 最, …) — modifies a stative predicate, so the word after it is an adjective, never a transitive verb or a classifier. It does not say which reading is commoner; it says which are impossible.
  • Negator (不, 并不) — a weaker signal, kept separate and given a shorter table. 不 takes verbs as readily as adjectives, and earns its place only where both readings it admits point the same way: 不重 is "not heavy" because "does not repeat" would be 不重复. 长 and 薄 are deliberately excluded, since 不长 can be "does not grow" and 待我不薄 is bó.

The table holds only the five characters where context overturns the context-free answer — 重, 长, 空, 差, 薄. 好, 难, 累, 大 and 少 are already ranked correctly, and a test asserts they stay that way, so a change to the generic ranking fails there rather than quietly creating the need for more rules.

This is not a part-of-speech tagger and is not a step towards one.

Consequences

  • A reading is now keyed on the word and its context, not on the word alone: a heteronym has no single answer per book. The key space is still the vocabulary times a handful of classes, so readings stay bounded by vocabulary rather than by document length.
  • One core function, ReadingContext.contexts(in:), classifies neighbours for both the model, which decides what to fetch, and the paragraph view, which decides what to look up — so the two cannot disagree. A test pins it against the single-token classifier a tap uses.
  • Punctuation severs a context; whitespace does not. Contexts are computed per block, so nothing reaches across a paragraph break.
  • The detail panel's one-line summary now follows the reading shown above the word. It did not: after the rule chose zhòng, the panel still read "to repeat" beside it, because the summary came from whichever group the dictionary listed first. The expanded sheet leads with the same group and still lists every reading.

Verification

Verified on the running macOS app over 「我在一个山村学校当老师,教学任务很重。他重复了三次。任务不重。」:

token before after
很重 chóng zhòng, panel "heavy"
不重 chóng zhòng
重复 chóngfù chóngfù (unchanged)

21 new tests; 372 pass. Nine mutants planted against the new code and all nine killed — including three that survived a first attempt and exposed a real gap: the single-token and whole-block classifiers agreed only because no test put a degree adverb behind punctuation.

Known limits

Still a guess, and still marked .ambiguous so the ruby is drawn faintly and the panel lists every reading. 为 remains wrong (wéi where running text usually wants wèi) — it has no closed-class neighbour that settles it, and a headword-count frequency proxy picks wéi too.

🤖 Generated with Claude Code

重 in 「教学任务很重」 was annotated chóng, "to repeat", where the sentence
plainly means "very heavy" — zhòng.

Nothing in the dictionary separates the two. ReadingComposer ranks a
character's readings by preferring lowercase, then a neutral tone, then
whichever reading the dictionary says most about; 重 has neither a capital
nor a neutral tone, and CC-CEDICT gives chóng five senses to zhòng's three.
The sense count is a frequency proxy, and it is wrong for exactly the
characters whose readings are different words.

No context-free default could have fixed it, because both readings are
right somewhere: 重 is chóng in 重复 and zhòng in 很重. So the composer now
takes a ReadingContext, and HeteronymRules answers for the cases a
closed-class neighbour settles grammatically — a degree adverb modifies a
stative predicate, so 很 before 重 rules chóng out rather than merely making
it less likely. Negators are a separate, shorter table: 不 admits verbs as
readily as adjectives, and earns its place only where both readings it
allows point the same way.

The table holds only the five characters where context overturns the
context-free answer. 好, 难, 累, 大 and 少 are already ranked correctly and
are asserted to stay that way, so a change to the generic ranking fails a
test rather than quietly needing more rules.

Readings are now keyed on the word and its context rather than on the word
alone — a heteronym has no single answer per book — and one core function
classifies neighbours for both the model, which decides what to fetch, and
the paragraph view, which decides what to look up, so the two cannot
disagree. The detail panel's summary follows the reading shown above the
word, which it did not: after the rule chose zhòng the panel still read
"to repeat" beside it.

Verified on the running app over 「我在一个山村学校当老师,教学任务很重。他重复
了三次。任务不重。」 — 很重 and 不重 both zhòng, 重复 still chóngfù.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii enabled auto-merge (squash) October 18, 2026 19:31
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Choose heteronym readings from grammatical context

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Use preceding degree adverbs and selected negators to correct ambiguous single-character readings.
• Key fetched readings by word and context so annotations remain consistent across occurrences.
• Align detail-panel definitions with the displayed reading and cover the behavior with tests.
Diagram

graph TD
  B["Text blocks"] --> C["Reading context"] --> M["Reader model"] --> S["Dictionary service"] --> R["Reading composer"] --> H["Heteronym rules"]
  C --> P["Paragraph view"]
  M --> P
  M --> D["Word detail"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Part-of-speech tagging
  • ➕ Could resolve more heteronyms beyond the closed-class cases.
  • ➖ Adds a tagging model or lexicon, more runtime work, and a substantially larger error surface.

Recommendation: Keep the narrow, reviewable rules for this defect: they use grammatical evidence without changing the fallback for unrelated words. Consider tagging only if broader coverage becomes a product requirement.

Files changed (16) +864 / -61

Bug fix (8) +419 / -40
ReadingComposer.swiftApply context before fallback reading rankings +60/-8

Apply context before fallback reading rankings

• Accepts optional reading context for exact-entry selection. Prefers a rule's reading only when a usable dictionary entry supplies it; otherwise retains the existing ranking and ambiguity marking.

Sources/HanReaderCore/Dictionary/ReadingComposer.swift

ReadingContext.swiftDefine bounded reading contexts and heteronym rules +241/-0

Define bounded reading contexts and heteronym rules

• Classifies preceding degree adverbs and negators, provides block-wide and single-token context lookup, and defines word-and-context request keys. Adds a limited rule table for five single-character heteronyms, with narrower coverage for negators.

Sources/HanReaderCore/Dictionary/ReadingContext.swift

ReaderModel.swiftFetch and display readings by word and context +68/-11

Fetch and display readings by word and context

• Builds contextual requests per block for the reading window instead of deduplicating by word alone. Uses the selected token's context when loading detail so its reading agrees with the annotation.

Sources/HanReaderUI/Models/ReaderModel.swift

ParagraphView.swiftLook up paragraph annotations with contextual keys +24/-7

Look up paragraph annotations with contextual keys

• Pairs each token with its block-local context and uses a ReadingRequest to retrieve its reading. This matches the keys produced by the model.

Sources/HanReaderUI/Reader/ParagraphView.swift

ReaderSurface.swiftPass contextual readings into paragraphs +1/-1

Pass contextual readings into paragraphs

• Changes the reading map accepted by the surface from word keys to ReadingRequest keys.

Sources/HanReaderUI/Reader/ReaderSurface.swift

WordDetailContent.swiftLead expanded detail with the displayed reading +1/-1

Lead expanded detail with the displayed reading

• Renders the reading group matching the annotation first while retaining the other groups.

Sources/HanReaderUI/Screens/WordDetailContent.swift

WordDetailPanel.swiftMatch the summary to the displayed reading +1/-1

Match the summary to the displayed reading

• Supplies entries associated with the shown reading to the one-line panel summary instead of using all entries indiscriminately.

Sources/HanReaderUI/Screens/WordDetailPanel.swift

DictionaryService.swiftCache and batch context-sensitive reading requests +23/-11

Cache and batch context-sensitive reading requests

• Keys reading results and the bounded reading cache by word plus context. Passes that context to the composer while continuing to cache dictionary entries by word.

Sources/HanReaderUI/Services/DictionaryService.swift

Tests (7) +434 / -21
ReaderFixtures.swiftGenerate contextual reading keys for fixtures +25/-2

Generate contextual reading keys for fixtures

• Derives fixture reading requests from each document or block's tokens rather than maintaining word-only rendering keys. Updates fixture surfaces and previews to use those requests.

Sources/HanReaderUI/Reader/ReaderFixtures.swift

ReadingContextTests.swiftTest grammatical selection and context boundaries +330/-0

Test grammatical selection and context boundaries

• Covers the reported 重 error, rule scope, traditional forms, missing preferred entries, and unchanged fallback readings. Verifies punctuation and whitespace behavior and agreement between block-wide and single-token classification.

Tests/HanReaderCoreTests/ReadingContextTests.swift

DictionaryServiceTests.swiftAdapt dictionary-service tests to reading requests +14/-13

Adapt dictionary-service tests to reading requests

• Updates lookup, batching, caching, and failure tests to call the service with ReadingRequest keys.

Tests/HanReaderUITests/DictionaryServiceTests.swift

ReaderFixtureTests.swiftCheck fixture coverage with contextual readings +7/-2

Check fixture coverage with contextual readings

• Uses the generated contextual fixture map when checking that the document still contains a token without an annotation.

Tests/HanReaderUITests/ReaderFixtureTests.swift

ReaderLayoutRenderTests.swiftRender layouts with contextual fixture keys +1/-1

Render layouts with contextual fixture keys

• Supplies paragraph-derived ReadingRequest keys to the layout render test.

Tests/HanReaderUITests/ReaderLayoutRenderTests.swift

ReaderModelTests.swiftAssert model readings through contextual keys +3/-3

Assert model readings through contextual keys

• Updates model assertions to inspect reading results using default-context ReadingRequest keys.

Tests/HanReaderUITests/ReaderModelTests.swift

WordDetailTests.swiftTest summary and group ordering for selected readings +54/-0

Test summary and group ordering for selected readings

• Checks that zhòng yields the heavy summary and leads the expanded groups without hiding chóng. Also covers composed readings and the absence of a selected reading.

Tests/HanReaderUITests/WordDetailTests.swift

Documentation (1) +11 / -0
CHANGELOG.mdDocument the heteronym and detail-panel fixes +11/-0

Document the heteronym and detail-panel fixes

• Adds an unreleased fix note explaining context-sensitive readings and the corrected definition summary.

CHANGELOG.md

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

qodo-free-for-open-source-projects Bot commented Oct 18, 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


Remediation recommended

1. Split compounds can get the wrong reading ✓ Resolved
Description
HeteronymRules.preferredReading checks only whether the current token is one character, then
forces 重 to zhòng after a degree adverb. When a segmenter splits 很重复 into 很 | 重 | 复, the new
rule overrides the chóng reading required by 重复; the multi-character guard helps only if the
compound remains one token.
Code

Sources/HanReaderCore/Dictionary/ReadingContext.swift[R213-215]

+        .degreeAdverb: [
+            // 很重 "very heavy", not chóng "to repeat"
+            "重": "zhong4",
Evidence
The supported character segmenter splits every Han character; context classification then takes the
preceding token, and the new rule applies to a single-character headword without checking its
following neighbour.

Sources/HanReaderCore/Text/TextSegmenter.swift[223-234]
Sources/HanReaderCore/Dictionary/ReadingContext.swift[125-135]
Sources/HanReaderCore/Dictionary/ReadingContext.swift[195-215]
Sources/HanReaderCore/Dictionary/ReadingComposer.swift[206-218]

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 one-character token can be part of a split compound. The degree-adverb rule can therefore override the compound's correct reading.
## Fix Focus Areas
- Sources/HanReaderCore/Dictionary/ReadingContext.swift[196-215]
- Sources/HanReaderCore/Text/TextSegmenter.swift[223-234]
## Recommended Fix
Before applying a contextual rule, account for an adjacent token that forms a compound with the character, or otherwise avoid claiming that a one-character token is necessarily a standalone word. Add a test using a segmenter that splits `很重复`.

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


2. Some negated differences read as “bad” ✓ Resolved
Description
HeteronymRules forces 差 to chà whenever its preceding token is a negator, although the included
dictionary also records chā for “to differ” and bù chā for “to not lack.” When 不 and 差
remain separate tokens, including across whitespace that the classifier skips, this rule changes
that reading to chà regardless of the sentence's meaning.
Code

Sources/HanReaderCore/Dictionary/ReadingContext.swift[R235-236]

+            // 不差 "not bad", also 不差钱 — never chā "difference"
+            "差": "cha4",
Evidence
The bundled dictionary explicitly gives 不差 two readings and defines 差 [cha1] as “to differ.” The
new classifier skips whitespace, and the new table forces cha4 after 不.

Dictionaries/cc-cedict/cedict_ts.u8[1971-1972]
Dictionaries/cc-cedict/cedict_ts.u8[31761-31762]
Sources/HanReaderCore/Dictionary/ReadingContext.swift[116-135]
Sources/HanReaderCore/Dictionary/ReadingContext.swift[232-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
A negator does not uniquely determine the reading of 差: the bundled dictionary records both `bù chā` and `bù chà`.
## Fix Focus Areas
- Sources/HanReaderCore/Dictionary/ReadingContext.swift[228-239]
## Recommended Fix
Remove 差 from the negator table unless a narrower, independently verifiable context can distinguish these meanings. Test both readings with separate `不` and `差` tokens.

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



Informational

3. Context lookup's docs sit on the wrong function ✓ Resolved
Description
In ReadingContext, the new comment block for contexts(in:) (block-level, alignment, "shared by
the two callers") was joined to the comment for context(at:in:). The joined text now documents
context(at:in:), and contexts(in:) is left with no documentation. Quick Help for the
single-token function therefore says it is "aligned one-to-one with tokens" and shared by the
model and paragraph view, which describes the other function. Anyone working out which of the two
functions to call or keep in step is pointed at the wrong one.
Code

Sources/HanReaderCore/Dictionary/ReadingContext.swift[R112-116]

+    /// they would otherwise express as pinyin flickering between two values.
+    /// The context of one token, for a caller holding a position rather than
+    /// a paragraph — a tap, which has no reason to classify every word around
+    /// it to annotate one.
+    public static func context(at index: Int, in tokens: [Token]) -> Self {
Evidence
Lines 101-115 are one unbroken /// run that ends directly above context(at:in:) at line 116.
contexts(in:) at line 125 has no doc comment of its own.

Sources/HanReaderCore/Dictionary/ReadingContext.swift[101-136]

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 doc comment for `ReadingContext.contexts(in:)` was merged into the one for `context(at:in:)`, so the single-token function carries the block function's docs and `contexts(in:)` has none.
## Fix Focus Areas
- Sources/HanReaderCore/Dictionary/ReadingContext.swift[101-136]
## Recommended Fix
Split the comment. Keep lines 113-115 ("The context of one token, for a caller holding a position...") above `context(at:in:)`. Move lines 101-112 so they sit directly above `public static func contexts(in:)`.

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


4. Fixture comment now describes the wrong member ✓ Resolved
Description
The new ReaderFixtures.readings(for document:) was placed between the existing "Readings for the
sample vocabulary… 阅兵式 is marked composed on purpose" comment and readingsByWord, which that
comment documents. As a result, the old text and the new "authored table" text now form one comment
on the function, and readingsByWord has no documentation. The note explaining why 阅兵式 is
deliberately marked composed is now attached to a function that marks nothing as composed.
Code

Sources/HanReaderUI/Reader/ReaderFixtures.swift[R38-44]

+    /// The authored table, keyed the way the reading surface asks for it.
+    ///
+    /// Derived from a document rather than written out, because a reading is
+    /// keyed on the word *and* its context: writing the keys by hand would
+    /// mean every fixture word silently losing its annotation the day someone
+    /// adds a 很 to the prose.
+    static func readings(for document: SegmentedDocument) -> [ReadingRequest: TokenReading] {
Evidence
Lines 31-37 (the original readingsByWord doc) run directly into the new lines 38-43 and attach to
readings(for:) at line 44. readingsByWord at line 61 now has no comment.

Sources/HanReaderUI/Reader/ReaderFixtures.swift[31-61]

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 new `readings(for:)` helpers were inserted between `readingsByWord` and its doc comment, so that comment now documents the wrong declaration.
## Fix Focus Areas
- Sources/HanReaderUI/Reader/ReaderFixtures.swift[31-61]
## Recommended Fix
Move the two `readings(for:)` functions, with their own doc comments, to after the `readingsByWord` literal. That leaves the "Readings for the sample vocabulary" comment directly above `readingsByWord`.

ⓘ 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 import rules and skills only from the repositories your team follows

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Sources/HanReaderCore/Dictionary/ReadingContext.swift
Comment thread Sources/HanReaderCore/Dictionary/ReadingContext.swift Outdated
Comment thread Sources/HanReaderCore/Dictionary/ReadingContext.swift Outdated
Comment thread Sources/HanReaderUI/Reader/ReaderFixtures.swift Outdated
价 in 「她叫我给个价」 was annotated jiè, "great, middleman", where the
sentence means "name me a price" — jià.

Unlike 重, this is not a context problem. jiè is not a reading of modern
Chinese, so no neighbour needs to rule it out; the ranking simply believes
it. CC-CEDICT documents the archaic sense in four glosses against three for
jià, and writes it with a neutral tone besides, so both the neutral-tone
preference and the sense count point at it. Removing either would not help.

No automatic signal fixes it either. Compound attestation looks decisive —
161 headwords spell 价 jià and none spell it jiè — but applied as a ranking
it flips every sentence-final particle, since 吧, 呢, 啊 and 吗 are absent
from compounds too, for reasons that have nothing to do with being archaic.

So the corrections are a generated table, not a ranking and not a curated
list. Scripts/heteronym-defaults.py applies one criterion to the pinned
snapshot — a reading used by no multi-character headword beside one used by
at least twenty, excluding readings glossed as grammatical — and prints the
table verbatim. It yields five characters: 价, 椎, 炮, 络, 膏. Each is a
dead reading outranking a living one, and the particles are left alone.

The correction sits below the context rules and above the ranking: a
neighbour is a reason, a default is the absence of one. The two tables are
disjoint today, so that order decides nothing yet, and a test fails the
moment they overlap rather than asserting an accident.

Applied at lookup rather than baked into the per-character table, so an
already-imported container does not need rebuilding — and applied on the
composed path too, or a word assembled from its characters would spell 价
jiè while the same character tapped on its own read jià.

Verified on the running app: 听力64 「她叫我给个价」 now reads jià, panel
"price".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii

Copy link
Copy Markdown
Member Author

Second defect, same area, different cause — pushed as ee56662.

Reported: 听力64, 「卖主是位六十多岁的老太太,她叫我给个价」 — 价 read jiè ("great") where the sentence means "name me a price", jià.

Not a context problem. jiè is not a reading of modern Chinese, so no neighbour needs to rule it out. The ranking simply believes it, and every signal it has agrees:

价 [jie5] 4 senses  /great/good/middleman/servant/   ← neutral tone, more senses
价 [jia4] 3 senses  /price/value/valence (on an atom)/

The neutral-tone preference fires and the sense count agrees, so dropping either rule changes nothing.

No automatic signal fixes it. Compound attestation looks decisive — 161 headwords spell 价 jià, zero spell it jiè — but as a ranking it flips every sentence-final particle: 吧, 呢, 啊 and 吗 are all absent from compounds too, for reasons that have nothing to do with being archaic. Thresholding doesn't separate them either (耶 at 51 should stay neutral, 底 at 147 should flip).

So: a generated table, not a ranking and not a curated list. Scripts/heteronym-defaults.py applies one criterion to the pinned snapshot — a reading used by no multi-character headword, beside one used by at least twenty, excluding readings glossed as grammatical — and prints the Swift verbatim. It yields five characters:

corrected to evidence was
价 jià 161 compounds, 0 jiè "great, middleman"
椎 zhuī 21, 0 chuí "a hammer"
炮 pào 65, 0 bāo "to sauté"
络 luò 87, 0 lào "small net"
膏 gāo 38, 0 gào "to grease"

The correction sits below the context rules and above the ranking — a neighbour is a reason, a default is the absence of one. The two tables are disjoint today, so that order decides nothing yet; rather than assert an accident, a test fails the moment they overlap.

Applied at lookup rather than baked into the per-character table, so an already-imported container needs no rebuild, and applied on the composed path too — otherwise a word assembled from its characters spelled 价 jiè while the same character tapped on its own read jià.

Verified on the running app: 听力64 now reads jià, panel "price". 381 tests; nine more mutants planted and killed, two after a first attempt exposed vacuous tests — the composed path was untested, and the tier-ordering test was asserting nothing because the tables don't overlap.

🤖 Generated with Claude Code

Review findings on #33.

The bundled dictionary settles 差 against the rule: it records both
不差 [bu4 cha1] "to not lack" and 不差 [bu4 cha4] "not bad" as headwords, so
a negator decides nothing there. Removed, with a test asserting a negator
leaves 差 to the ranking while a degree adverb still settles it — 很差 is
only ever "very poor". That asymmetry is the difference between the two
classes, so it is now pinned rather than described.

The split-compound concern does not reproduce: both shipped segmenters keep
重复 whole, because a compound in the lexicon is what maximum matching
matches, and the repair pass only ever merges. But the rule does lean on
that, so it is now a test rather than an assumption — 很重复, 不重复 and
这个任务很重复 keep 重复 as one token, no lone 重 reaches the rule, and a
character-by-character segmentation is shown being repaired back to 很 | 重复.

Two doc comments ended up on the wrong declarations, both because a new
function was inserted between a comment and what it documented:
contexts(in:) lost its documentation to context(at:in:), and readingsByWord
lost the note about 阅兵式 to readings(for:).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii

Copy link
Copy Markdown
Member Author

All four review findings addressed in b459782. Two were real defects, one was a real latent risk, one did not reproduce but is now pinned anyway.

2 — 差 in the negator table: correct, and the bundled dictionary settles it. CC-CEDICT records both as headwords:

不差 [bu4 cha1] to not lack
不差 [bu4 cha4] not bad/OK

So a negator decides nothing for 差. Removed. A degree adverb still settles it — 很差 is only ever "very poor" — and that asymmetry is exactly what separates the two classes, so it is now a test rather than a sentence in a comment.

1 — split compounds: does not reproduce, but the rule did lean on an untested assumption. Both shipped segmenters keep 重复 whole (很重复 → 很 | 重复, 不重复 → 不 | 重复): a compound in the lexicon is what maximum matching matches, and DictionaryRepairPass only ever merges, so it can rejoin a split compound but can never create one. Rather than argue that, it is now asserted — 很重复, 不重复 and 这个任务很重复 keep 重复 as one token and no lone 重 reaches the rule, plus a test that segments character by character and shows the repair pass putting 很 | 重复 back together.

3 and 4 — doc comments on the wrong declarations: correct, and my error. Both from inserting a function between a comment and what it documented: contexts(in:) lost its documentation to context(at:in:), and readingsByWord lost the note about 阅兵式 being deliberately .composed to readings(for:). Both moved back.

384 tests. One more mutant planted — 差 put back into the negator table — and killed.

🤖 Generated with Claude Code

@widgetii
widgetii merged commit 3a0b544 into main Oct 19, 2026
8 checks passed
@widgetii
widgetii deleted the fix/heteronym-context branch October 19, 2026 04:45
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