Compare range-element parents under NFC - #28
Merged
Conversation
imnasnainaec
force-pushed
the
range-parent-nfc
branch
from
August 13, 2026 13:15
b8ab20a to
2860d04
Compare
imnasnainaec
force-pushed
the
range-parent-nfc
branch
from
August 14, 2026 18:41
0685bf1 to
94ec467
Compare
FLEx normalizes strings to NFC on export, but a few writes emit the NFD it holds in memory -- among them the grammatical-info and lexical-relation range-element ids, while the parent attribute on those same elements is normalized. Comparing the two as exact strings reported a sound parent link as dangling: both range-parent errors in the sango fixture are that encoding difference, not defects. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The validation notes described the .lift as NFC and the .lift-ranges as NFD. It is narrower than that: the normalizer is applied per write, so a range-element id can be NFD beside a label, a parent, and a .lift value that are all NFC. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Normalizing both sides of the range comparisons hid a real property of FLEx output: an id and the names referring to it can differ in encoding, and any consumer comparing raw strings -- a Send/Receive merge, say -- fails to resolve them. Report that as a warning, once per range-element id however many references differ (6 rather than 82 in the sango fixture), naming both spellings by code point because they render identically, and addressing it to the file the id lives in. The new negative pair carries the shape end to end: NFD ids, NFC parents and grammatical-info value, one parent that dangles in every normalization, and bytes that must survive the round trip unchanged -- normalization belongs to the comparison, not to what gets written. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keying each range's lookup by both the ids as written and their NFC forms, with an exact id mapping to itself, makes a reference that matches a sibling exactly stay an exact match. Before, the first spelling of an NFC-equal pair won, so a range carrying one name in both normalizations -- distinct ids as strings, and FLEx normalizes some writes and not others -- would resolve the second element's references to the first and report them as mismatched. The lookup is now built once per range and read by the value checks, which were rebuilding it per trait: thousands of traits over a few ranges of hundreds of elements each. The sango fixture's semantic pass drops from 1.54s to 0.84s. The module docstring's semantic checks fed the API reference as one sentence running nine checks and their parentheticals together. They are a bullet each now, named by the Problem code the check reports, matching how layer 1 lays out its deviations; the comments around the normalization helpers drop the mechanism they were re-telling from that list. Also escapes the list markers in the new PROVENANCE sentence, per that file's convention for hard-wrapped prose, and drops a Markdown escape that had no business in an XML comment. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file the mismatch warning reports against is worked out for that warning alone, three lines under a range-parent error about the very same range-element that still reports against the .lift. Both belong to the document the element lives in, so the lookup moves above the check that walks the ranges and answers for both. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comparison of a name against a range-element id was normalized while the comparison that reaches the range in the first place -- a trait name, a header range id -- stayed exact. A range whose id FLEx wrote in NFD is therefore never found by the NFC trait names keyed on it, and its values go unchecked: an unresolved name is indistinguishable from a trait no range defines, which is silently accepted, so the whole range drops out of validation with nothing to show for it. Both lookups are now built by one helper over a collection of ids, and a range reached only by normalizing is a normalization-mismatch warning like an element is. The value checks name their range through that resolution, which also drops the allowed-ids set from the per-check tuples: they carry the resolved range id, and the lookup kept for it answers by that key. The validate guide's normalization entry, five sentences beside four one-liners once this landed, keeps the mechanism on the bullet and gives the rest a sub-bullet each. It moves last as well, so the list runs schema deviations first and the semantic check after them, as the layers above are ordered. The Sango notes counted its 6 warnings as 6 ids. They are 5: FLEx aliases the part-of-speech list under two range ids, and the id under both is warned about under each, since each range defines its own element. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolving the header's range id against the companion's own id was done inside the check for a dangling href, which runs only for a lexicon read from disk. Whether those two spellings agree is a property of the document, so a lexicon held in memory reported nothing where the same document on disk warned -- and validation is supposed to answer for what save() would write, wherever the document came from. Every header range id resolves before that check now, and the check reads the answer. Ranges that carry their own elements resolve to themselves and record nothing, so covering them all costs a lookup each. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The existing coverage for reporting a normalization mismatch once per id uses references that are all spelled the same way, so it pins only the collapse of identical references. Add a case where two references differ from the id and from each other -- a parent link in canonical mark order and a grammatical-info value with the same marks reversed -- and assert one warning. Which spelling the message names is deliberately left unasserted, so the order references are reached in stays free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
imnasnainaec
force-pushed
the
range-parent-nfc
branch
from
August 17, 2026 18:13
8b9cd90 to
5c560f0
Compare
The roster of Problem codes was stated in three places -- the changelog's validation bullet, the module docstring, and the guide's layers list -- and none of them gave a code's level, though --strict promotes every warning to an error and validate's exit code is a supported interface. The guide gains a Problem codes section: all eleven codes, alphabetical, each with its level and what it flags, with the two that come from the schema layers named in the intro rather than in a column that would read "semantic" for nine of eleven rows and could not hold one value for schema. The layers list goes back to describing the three passes, and the module docstring points at the table instead of repeating it, leaving Problem.code as the one place the names are listed in the source. The changelog's validation bullet keeps only the codes whose behavior a reader would not guess -- missing-id because it is opt-in, and normalization-mismatch because resolving names under NFC is surprising -- and names the two RELAX NG departures by what a user sees rather than by how they are implemented. It goes from 18 lines to 10, in range with the bullets around it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The addressing paragraph named range-parent, normalization-mismatch and dangling-ranges-href as the findings that carry a file and nothing else, which sorted every code into one of two buckets. Four cases fall outside that split: a companion schema error has no entry and sometimes no line; duplicate-guid raised for a range-element carries a guid without an entry; missing-media names an entry but has no line; and file itself is None whenever the lexicon has no path. duplicate-guid and schema also land in either bucket depending on where the finding is, so extending the list would mean naming most of the codes and splitting two of them by context. Describe instead when each of file, entry_id, guid, and line is set, and keep the companion addressing as its own sentence. Nothing enumerated, so nothing to revisit as codes are added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
imnasnainaec
marked this pull request as ready for review
August 17, 2026 21:49
The writes that emitted NFD range-element ids have been fixed upstream (LT-22697), so the docs, the fixture comments, and the validator's own comment now present the split as something existing exports carry rather than something FLEx still does. The NFC comparison and the normalization-mismatch warning are unchanged: sil-lift never rewrites ids, and the Sango pair is a pre-fix export. The warning's description also loses a Send/Receive example that no longer holds, and PROVENANCE.md pins the asymmetry to the FLEx version that wrote the Sango pair. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The remaining non-ASCII literals in the validation tests are spelled \uXXXX, so no test depends on an editor or a tool preserving a composition it cannot show on screen. The third spelling in the dedup test is the one that mattered: it is dot-above before dot-below, which canonical ordering would swap, making it equal to the parent link and quietly retiring the two-differing-references case rather than failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jasonleenaylor
approved these changes
Aug 18, 2026
jasonleenaylor
left a comment
There was a problem hiding this comment.
@jasonleenaylor reviewed 9 files and all commit messages, and made 1 comment.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on imnasnainaec).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Devin review: https://app.devin.ai/review/sillsdev/python-sil-lift/pull/28
Fixes #14. Fixes #27.
range-parentcompared arange-element'sparentagainst its siblings'ids asexact strings. FLEx wrote the two with different normalization — the range-element
ids skipped its normalizing helper and came out NFD, theparenton that sameelement went through it and came out NFC — so sound parent links in real exports (Sena
in #14, Sango in #27) were reported as errors. Fixed upstream since (LT-22697);
that changes nothing here, as those files are permanent and sil-lift never rewrites
ids.
What changes
parentlinks, range values, and — new — the name that keys a range at all(a
traitname, a headerrangeid).undefined-range-valuenormalized before, so one encoding split was an errorin one check and invisible in another.
it never found it, and an unresolved name looks like a trait no range defines.
normalization-mismatchwarning, once per id however many references differ.
resolve those references, and nothing else would say so.
'Comple\u0301ments'vs'Compl\u00e9ments'), since they render identically.range-parentstays an error for a parent no sibling defines in any normalization.the
.liftthat merely references it. Header ids resolve before the dangling-hrefcheck, so a header id against a companion's own id is answered in memory too.
1.54s → 0.84s over the same findings.
.liftNFC and the.lift-rangesNFD, but the normalizer ran per write, not per file.Coverage
negative/nfd-range-ids.lift+.lift-ranges— NFD ids, NFC references, one parentdangling in every normalization: one error, one deduplicated warning, and both files
still byte-identical on save.
normalizations, where each reference must resolve to the sibling it matches exactly.
\uXXXXescapes — a tool that normalizes on save would weakenthe assertions rather than fail them.
Effect on the corpus
sango.liftrange-parentsango.liftnormalization-mismatchsango.liftundefined-range-valueprenom)prenom)Not changed
dangling-ref— all 7 corpus findings are ASCII and none resolve under NFC.duplicate-guid— guids, ASCII by construction.Verification
ruff check,ruff format --check,mypy, full suite (553 passed).Upstream, all merged: sillsdev/FieldWorks#1063, sillsdev/FieldWorks#1064 (the exporter
writes, plus an escaping gap on the
morph-typeid), sillsdev/chorus#394.This change is