What prompted this
Reviewing the suggestion breakdown work (#213), a Devin finding pointed at breakdownOf in src/utils/suggestion-engine.ts. Verifying it turned up a reachable path where a TokenAnalysis can carry morphemes whose form is the empty string. The breakdown render is only where it happens to be visible; the record itself is the problem, and it originates in the PT9 importer.
What is established
parseLexemeKeyId accepts an id with an empty form. LEXEME_KEY_ID_RE in src/parsers/pt9/lexemeKey.ts uses a lazy, zero-width-capable form group, so parseLexemeKeyId('Stem:') returns { Type: 'Stem', Form: '' } rather than undefined (verified by a throwaway probe against HEAD).
Nothing on the bare-word path rejects that. src/converters/pt9/bareWordAnalyses.ts:122 copies key.Form onto the morpheme verbatim; its only rejection is a keys.length === 0 check, which counts keys rather than inspecting their content. So a word parse with ids ["Stem:", "Suffix:"] yields a TokenAnalysis with two blank-form morphemes.
The cluster path in src/converters/pt9/analysisMerger.ts is incidentally protected: clusterAnchoring.ts matches the concatenation of a parse's forms against the token's surface text, and an all-blank parse concatenates to '', which matches nothing. That protection is a side effect of anchoring, not a guard — a mixed parse like ["", "ba"] still anchors to token "ba" and persists the blank morpheme alongside the real one.
The in-app write paths cannot produce this. MorphemeEditor.tsx normalizes and blocks empty commits, and TokenChip.tsx independently re-filters with .filter(Boolean), so import is the only source.
What is not established
Whether PT9 ever actually emits an id of this shape. The reachability above is a code-path argument, not an observed input — no real project data was examined. That is the question this issue exists to answer.
Investigation
- Determine whether PT9 emits bare
Type: ids in practice, and if so what they mean. src/parsers/pt9/pt9-xml.md documents the schema; real interlinear data from a PT9 project is the better evidence.
- Decide whether the parse boundary should reject an empty form regardless of the answer. The machinery already exists but is never reached: returning
undefined routes the cluster path to the existing unparseableLexemeId drop counter, and makes bareWordAnalyses' keys.length !== ids.length check catch it too.
- If blank forms turn out to be meaningful rather than malformed, the fix belongs downstream instead, and the consumers below need to handle them deliberately.
Downstream consumers if such a record exists
breakdownOf (src/utils/suggestion-engine.ts) joins forms on a space, so all-blank morphemes yield " " and a mixed parse yields " ba". Both are !== undefined, so the suggestion row renders a blank annotation and speaks an incomplete aria-label.
MorphemeBox renders blank forms as empty grid cells that are still clickable edit triggers.
morphemeIdentity (src/utils/analysis-identity.ts) folds morphemes into the payload identity, so all-blank breakdowns for different words collapse into one dedup bucket.
Patching any one of these in isolation would hide the symptom at one surface while leaving the malformed record in the store, which is why this is filed as an import-level investigation rather than a render fix.
What prompted this
Reviewing the suggestion breakdown work (#213), a Devin finding pointed at
breakdownOfinsrc/utils/suggestion-engine.ts. Verifying it turned up a reachable path where aTokenAnalysiscan carry morphemes whoseformis the empty string. The breakdown render is only where it happens to be visible; the record itself is the problem, and it originates in the PT9 importer.What is established
parseLexemeKeyIdaccepts an id with an empty form.LEXEME_KEY_ID_REinsrc/parsers/pt9/lexemeKey.tsuses a lazy, zero-width-capable form group, soparseLexemeKeyId('Stem:')returns{ Type: 'Stem', Form: '' }rather thanundefined(verified by a throwaway probe againstHEAD).Nothing on the bare-word path rejects that.
src/converters/pt9/bareWordAnalyses.ts:122copieskey.Formonto the morpheme verbatim; its only rejection is akeys.length === 0check, which counts keys rather than inspecting their content. So a word parse with ids["Stem:", "Suffix:"]yields aTokenAnalysiswith two blank-form morphemes.The cluster path in
src/converters/pt9/analysisMerger.tsis incidentally protected:clusterAnchoring.tsmatches the concatenation of a parse's forms against the token's surface text, and an all-blank parse concatenates to'', which matches nothing. That protection is a side effect of anchoring, not a guard — a mixed parse like["", "ba"]still anchors to token "ba" and persists the blank morpheme alongside the real one.The in-app write paths cannot produce this.
MorphemeEditor.tsxnormalizes and blocks empty commits, andTokenChip.tsxindependently re-filters with.filter(Boolean), so import is the only source.What is not established
Whether PT9 ever actually emits an id of this shape. The reachability above is a code-path argument, not an observed input — no real project data was examined. That is the question this issue exists to answer.
Investigation
Type:ids in practice, and if so what they mean.src/parsers/pt9/pt9-xml.mddocuments the schema; real interlinear data from a PT9 project is the better evidence.undefinedroutes the cluster path to the existingunparseableLexemeIddrop counter, and makesbareWordAnalyses'keys.length !== ids.lengthcheck catch it too.Downstream consumers if such a record exists
breakdownOf(src/utils/suggestion-engine.ts) joins forms on a space, so all-blank morphemes yield" "and a mixed parse yields" ba". Both are!== undefined, so the suggestion row renders a blank annotation and speaks an incompletearia-label.MorphemeBoxrenders blank forms as empty grid cells that are still clickable edit triggers.morphemeIdentity(src/utils/analysis-identity.ts) folds morphemes into the payload identity, so all-blank breakdowns for different words collapse into one dedup bucket.Patching any one of these in isolation would hide the symptom at one surface while leaving the malformed record in the store, which is why this is filed as an import-level investigation rather than a render fix.