From 177327a793e9d11d3095c76e185a6a9a6316c8f5 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Wed, 26 Aug 2026 11:33:59 -0600 Subject: [PATCH 1/4] Distinguish same-gloss suggestions by their morpheme breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two analyses of one surface form can share a gloss but differ in how they break the token down, which rendered as identical dropdown rows offering no way to choose. Rows now carry their breakdown, but only where the breakdowns actually differ — annotating rows a breakdown cannot separate adds noise without resolving anything. --- contributions/localizedStrings.json | 1 + .../components/TokenChip.suggestions.test.tsx | 75 +++++++++++ src/__tests__/utils/suggestion-engine.test.ts | 118 ++++++++++++++++++ src/components/PhraseStripContext.tsx | 3 + src/components/SuggestionDropdown.tsx | 37 +++++- src/components/TokenChip.tsx | 1 + src/hooks/usePhraseStripSetup.ts | 3 + src/utils/suggestion-engine.ts | 59 +++++++-- 8 files changed, 281 insertions(+), 16 deletions(-) diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 7aef2897..655473c3 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -65,6 +65,7 @@ "%interlinearizer_tokenChip_addToPhrase%": "Add \"{token}\" to phrase", "%interlinearizer_suggestion_accept%": "Accept suggestion {gloss} for {token}", "%interlinearizer_suggestion_promote%": "Promote {gloss} for {token}", + "%interlinearizer_suggestion_breakdown%": "broken down as {breakdown}", "%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Words in different segments can't be linked. Join the segments first to link across this boundary.", "%interlinearizer_linkButton_link%": "Link words", "%interlinearizer_linkButton_unlink%": "Unlink words", diff --git a/src/__tests__/components/TokenChip.suggestions.test.tsx b/src/__tests__/components/TokenChip.suggestions.test.tsx index 51af43dc..3e9c7829 100644 --- a/src/__tests__/components/TokenChip.suggestions.test.tsx +++ b/src/__tests__/components/TokenChip.suggestions.test.tsx @@ -85,6 +85,55 @@ function homographBankPool(financeGloss: string | undefined): TextAnalysis { return { ...emptyAnalysis(), tokenAnalyses: [river, fin], tokenAnalysisLinks: links }; } +/** + * Builds a pool where 'ran' carries two approved analyses glossed alike, breaking the + * lower-frequency one down into `rivalForms` — so only the breakdown tells the rows apart. + */ +function sameGlossParsePool(rivalForms: readonly string[]): TextAnalysis { + const pastTense: TokenAnalysis = { + ...FIXTURE_STAMPS, + id: 'ta-past', + surfaceText: 'ran', + gloss: { en: 'ran' }, + morphemes: [ + { id: 'm-run', form: 'run', writingSystem: 'und' }, + { id: 'm-pst', form: 'PST', writingSystem: 'und' }, + ], + }; + const rival: TokenAnalysis = { + ...FIXTURE_STAMPS, + id: 'ta-rival', + surfaceText: 'ran', + gloss: { en: 'ran' }, + morphemes: rivalForms.map((form, i) => ({ + id: `m-rival-${i}`, + form, + writingSystem: 'und', + })), + }; + const links: TokenAnalysisLink[] = [ + { + ...FIXTURE_STAMPS, + analysisId: 'ta-past', + status: 'approved', + token: { tokenRef: 'p1', surfaceText: 'ran' }, + }, + { + ...FIXTURE_STAMPS, + analysisId: 'ta-past', + status: 'approved', + token: { tokenRef: 'p2', surfaceText: 'ran' }, + }, + { + ...FIXTURE_STAMPS, + analysisId: 'ta-rival', + status: 'approved', + token: { tokenRef: 'v1', surfaceText: 'ran' }, + }, + ]; + return { ...emptyAnalysis(), tokenAnalyses: [pastTense, rival], tokenAnalysisLinks: links }; +} + /** * Builds the homograph 'bank' where the MOST-frequent analysis has no active-language (English) * gloss — only French — and a lower-frequency one carries `en:'finance'`. Exercises falling through @@ -409,6 +458,32 @@ describe('TokenChip suggestion dropdown', () => { expect(link?.status).toBe('approved'); }); + it('shows each breakdown when two suggestions share one gloss', async () => { + renderChip(makeWordToken('tok-new', 'ran'), { + initialAnalysis: sameGlossParsePool(['ran']), + }); + + await focusGloss(); + + expect(screen.getByTestId('suggestion-accept')).toHaveTextContent('run PST'); + expect(screen.getByTestId('suggestion-candidate')).toHaveTextContent('ran'); + expect(screen.getAllByTestId('suggestion-breakdown').map((el) => el.textContent)).toEqual([ + 'run PST', + 'ran', + ]); + }); + + it('omits the breakdown when the two suggestions carry different glosses', async () => { + renderChip(makeWordToken('tok-new', 'bank'), { + initialAnalysis: homographBankPool('finance'), + }); + + await focusGloss(); + + // 'riverbank' and 'finance' already differ, so neither row needs a disambiguating breakdown. + expect(screen.queryByTestId('suggestion-breakdown')).not.toBeInTheDocument(); + }); + it('omits a candidate that has no gloss in the active language', async () => { renderChip(makeWordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool(undefined) }); diff --git a/src/__tests__/utils/suggestion-engine.test.ts b/src/__tests__/utils/suggestion-engine.test.ts index 0e15925c..c56e197e 100644 --- a/src/__tests__/utils/suggestion-engine.test.ts +++ b/src/__tests__/utils/suggestion-engine.test.ts @@ -6,6 +6,7 @@ import type { ResolvedTokenAnalysis } from '../../utils/suggestion-engine'; import { buildPoolIndex, deriveTokenSuggestion, + glossedSuggestionEntries, resolvedTokenAnalysisEqual, } from '../../utils/suggestion-engine'; @@ -335,3 +336,120 @@ describe('resolvedTokenAnalysisEqual', () => { ).toBe(false); }); }); + +describe('glossedSuggestionEntries breakdown disambiguation', () => { + /** Builds an analysis of 'ran' glossed as `gloss` and broken down into `forms`. */ + function parsed(id: string, gloss: string, forms: readonly string[]): TokenAnalysis { + return { + ...FIXTURE_STAMPS, + id, + surfaceText: 'ran', + gloss: { en: gloss }, + morphemes: forms.map((form, i) => ({ id: `${id}-m${i}`, form, writingSystem: 'und' })), + }; + } + + it('carries the breakdown on same-gloss rows whose breakdowns differ', () => { + const pastTense = parsed('p1', 'ran', ['run', 'PST']); + const bareRoot = parsed('p2', 'ran', ['ran']); + + const entries = glossedSuggestionEntries( + { status: 'suggested', suggested: pastTense, candidates: [bareRoot] }, + 'en', + ); + + expect(entries).toEqual([ + { id: 'p1', gloss: 'ran', status: 'suggested', breakdown: 'run PST' }, + { id: 'p2', gloss: 'ran', status: 'candidate', breakdown: 'ran' }, + ]); + }); + + it('omits the breakdown when rows carry distinct glosses', () => { + // Distinct glosses already tell the rows apart, so a breakdown would be noise here. + const river = parsed('b1', 'riverbank', ['bank']); + const money = parsed('b2', 'finance', ['bank']); + + const entries = glossedSuggestionEntries( + { status: 'suggested', suggested: river, candidates: [money] }, + 'en', + ); + + expect(entries).toEqual([ + { id: 'b1', gloss: 'riverbank', status: 'suggested' }, + { id: 'b2', gloss: 'finance', status: 'candidate' }, + ]); + }); + + it('omits the breakdown when same-gloss rows share one breakdown', () => { + // These differ only by part of speech, which a breakdown cannot show; printing the identical + // breakdown twice would suggest a distinction it does not make. + const asVerb = { ...parsed('s1', 'ran', ['run', 'PST']), pos: 'V' }; + const asNoun = { ...parsed('s2', 'ran', ['run', 'PST']), pos: 'N' }; + + const entries = glossedSuggestionEntries( + { status: 'suggested', suggested: asVerb, candidates: [asNoun] }, + 'en', + ); + + expect(entries).toEqual([ + { id: 's1', gloss: 'ran', status: 'suggested' }, + { id: 's2', gloss: 'ran', status: 'candidate' }, + ]); + }); + + it('leaves a whole-word row unannotated while marking its parsed same-gloss rival', () => { + // The whole-word payload has nothing to print, and its rival's breakdown already separates them. + const wholeWord: TokenAnalysis = { + ...FIXTURE_STAMPS, + id: 'w1', + surfaceText: 'ran', + gloss: { en: 'ran' }, + }; + const parsedRival = parsed('w2', 'ran', ['run', 'PST']); + + const entries = glossedSuggestionEntries( + { status: 'suggested', suggested: wholeWord, candidates: [parsedRival] }, + 'en', + ); + + expect(entries).toEqual([ + { id: 'w1', gloss: 'ran', status: 'suggested' }, + { id: 'w2', gloss: 'ran', status: 'candidate', breakdown: 'run PST' }, + ]); + }); + + it('ignores a blank-in-active-language row when deciding what collides', () => { + // The blank row is filtered out before the collision pass, so it is not on screen to be + // confused with. + const shown = parsed('l1', 'ran', ['run', 'PST']); + const blankHere: TokenAnalysis = { ...parsed('l2', 'ran', ['ran']), gloss: { fr: 'couru' } }; + + const entries = glossedSuggestionEntries( + { status: 'suggested', suggested: shown, candidates: [blankHere] }, + 'en', + ); + + expect(entries).toEqual([{ id: 'l1', gloss: 'ran', status: 'suggested' }]); + }); + + it('annotates same-gloss rows on an approved token, which offers only promotions', () => { + // An approved token excludes its own payload, so the collision is decided over what is left. + const approved = parsed('a1', 'went', ['go', 'PST']); + const first = parsed('a2', 'ran', ['run', 'PST']); + const second = parsed('a3', 'ran', ['ran']); + + const entries = glossedSuggestionEntries( + { + status: 'approved', + analysis: approved, + poolSuggestion: { suggested: approved, candidates: [first, second] }, + }, + 'en', + ); + + expect(entries).toEqual([ + { id: 'a2', gloss: 'ran', status: 'candidate', breakdown: 'run PST' }, + { id: 'a3', gloss: 'ran', status: 'candidate', breakdown: 'ran' }, + ]); + }); +}); diff --git a/src/components/PhraseStripContext.tsx b/src/components/PhraseStripContext.tsx index 7fd6a03d..4ad1d7d0 100644 --- a/src/components/PhraseStripContext.tsx +++ b/src/components/PhraseStripContext.tsx @@ -25,6 +25,8 @@ export type TokenChipLabels = Readonly<{ acceptSuggestion: string; /** Accessible label for a dropdown row that promotes a candidate gloss. */ promoteSuggestion: string; + /** Accessible suffix naming the morpheme breakdown that sets a same-gloss dropdown row apart. */ + suggestionBreakdown: string; }>; /** @@ -41,6 +43,7 @@ export const TOKEN_CHIP_LABEL_KEYS = { morphemeGloss: '%interlinearizer_morphemeGloss_label%', acceptSuggestion: '%interlinearizer_suggestion_accept%', promoteSuggestion: '%interlinearizer_suggestion_promote%', + suggestionBreakdown: '%interlinearizer_suggestion_breakdown%', } as const satisfies Record; /** diff --git a/src/components/SuggestionDropdown.tsx b/src/components/SuggestionDropdown.tsx index 596e90aa..5b60841d 100644 --- a/src/components/SuggestionDropdown.tsx +++ b/src/components/SuggestionDropdown.tsx @@ -24,7 +24,12 @@ type SuggestionDropdownProps = Readonly<{ acceptLabelTemplate: string; /** Same as {@link acceptLabelTemplate}, for a "promote this candidate gloss" row. */ promoteLabelTemplate: string; - /** Surface form of the token being glossed, filling the `{token}` placeholder in both templates. */ + /** + * Accessible suffix naming a row's morpheme breakdown, with `{breakdown}` still to fill in. + * Appended to the label of each row carrying one, so same-gloss rows do not sound identical. + */ + breakdownLabelTemplate: string; + /** Surface form of the token being glossed, filling `{token}` in the accept and promote templates. */ tokenSurfaceText: string; /** Called with a row index when the pointer enters it, so hover and keyboard share one highlight. */ onActiveIndexChange: (index: number) => void; @@ -43,6 +48,9 @@ type SuggestionDropdownProps = Readonly<{ * Each row is colored and labeled by its own `status` — `'suggested'` (blue, "accept") or * `'candidate'` (gray, "promote") — carried on the entry rather than inferred from position, so a * dropped blank-in-language pick can never leave a candidate masquerading as the accept row. + * + * A row also renders its `breakdown` when it carries one, so two analyses glossed alike are never + * offered as visually identical choices. */ export default function SuggestionDropdown({ listboxId, @@ -51,6 +59,7 @@ export default function SuggestionDropdown({ activeIndex, acceptLabelTemplate, promoteLabelTemplate, + breakdownLabelTemplate, tokenSurfaceText, onActiveIndexChange, onSelect, @@ -99,10 +108,17 @@ export default function SuggestionDropdown({ {entries.map((entry, index) => (
onActiveIndexChange(index)} > {entry.gloss} + {entry.breakdown !== undefined && ( + // Hidden from assistive tech because the row's own label already speaks the breakdown, + // which would otherwise be announced twice. + + {entry.breakdown} + + )}
))} diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index 12c7be55..499391cd 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -576,6 +576,7 @@ export function TokenChip({ listboxId={listboxId} optionId={optionId} acceptLabelTemplate={labels.acceptSuggestion} + breakdownLabelTemplate={labels.suggestionBreakdown} promoteLabelTemplate={labels.promoteSuggestion} tokenSurfaceText={token.surfaceText} onActiveIndexChange={setActiveIndex} diff --git a/src/hooks/usePhraseStripSetup.ts b/src/hooks/usePhraseStripSetup.ts index 303f418e..64d4eaed 100644 --- a/src/hooks/usePhraseStripSetup.ts +++ b/src/hooks/usePhraseStripSetup.ts @@ -103,6 +103,7 @@ function useTokenChipLabels(): TokenChipLabels { const morphemeGloss = strings[TOKEN_CHIP_LABEL_KEYS.morphemeGloss]; const acceptSuggestion = strings[TOKEN_CHIP_LABEL_KEYS.acceptSuggestion]; const promoteSuggestion = strings[TOKEN_CHIP_LABEL_KEYS.promoteSuggestion]; + const suggestionBreakdown = strings[TOKEN_CHIP_LABEL_KEYS.suggestionBreakdown]; return useMemo( () => ({ @@ -113,6 +114,7 @@ function useTokenChipLabels(): TokenChipLabels { morphemeGloss, acceptSuggestion, promoteSuggestion, + suggestionBreakdown, }), [ tokenGloss, @@ -122,6 +124,7 @@ function useTokenChipLabels(): TokenChipLabels { morphemeGloss, acceptSuggestion, promoteSuggestion, + suggestionBreakdown, ], ); } diff --git a/src/utils/suggestion-engine.ts b/src/utils/suggestion-engine.ts index e5f98b8e..1831af32 100644 --- a/src/utils/suggestion-engine.ts +++ b/src/utils/suggestion-engine.ts @@ -157,10 +157,7 @@ export function deriveTokenSuggestion( }; } -/** - * One renderable suggestion entry: a payload id, its gloss in the active language, and the - * assignment status the UI colors and labels it by. - */ +/** One renderable suggestion entry: a matching payload reduced to what the gloss UI shows of it. */ export interface GlossedSuggestionEntry { /** The matching payload's id — the approve/promote target and the React key. */ id: string; @@ -173,6 +170,16 @@ export interface GlossedSuggestionEntry { * masquerading as the accept row. */ status: Extract; + /** + * The payload's morpheme forms, rendered beside the gloss to tell this row from another sharing + * it. Absent unless a rival row is glossed identically but broken down differently. + */ + breakdown?: string; +} + +/** The displayable breakdown of a payload, or `''` when it treats the token as one whole word. */ +function breakdownOf(analysis: TokenAnalysis): string { + return (analysis.morphemes ?? []).map((morpheme) => morpheme.form).join(' '); } /** @@ -181,13 +188,18 @@ export interface GlossedSuggestionEntry { * * This is the single home of suggestion-presentation policy — which matches are renderable, how a * blank-in-active-language pick falls through, the approved payload's exclusion from its own - * promote list, and each row's assignment status — so every surface ranks, colors, and labels - * suggestions identically instead of re-deriving any of it from row position. + * promote list, each row's assignment status, and which rows need a disambiguating breakdown — so + * every surface ranks, colors, and labels suggestions identically instead of re-deriving any of it + * from row position. * * Status is assigned _after_ blank picks are dropped. So when the engine's top pick has no gloss in * the active language, the next-ranked glossed match becomes the accept row rather than the whole * suggestion vanishing. An already-approved token has no accept row at all: every pool peer is a * promotion, so even the top row reads as one. + * + * Because a payload's identity extends past its gloss, two records can be glossed alike and offer + * no way to choose between them. Such rows carry their breakdown, but only where the breakdowns + * differ — one a breakdown cannot separate stays bare. */ export function glossedSuggestionEntries( resolved: ResolvedTokenAnalysis | undefined, @@ -205,17 +217,42 @@ export function glossedSuggestionEntries( ranked = [pool.suggested, ...pool.candidates].filter((a) => a.id !== resolved.analysis.id); } const glossed = ranked - .map((analysis) => ({ id: analysis.id, gloss: analysis.gloss?.[analysisLanguage] ?? '' })) + .map((analysis) => ({ + id: analysis.id, + gloss: analysis.gloss?.[analysisLanguage] ?? '', + breakdown: breakdownOf(analysis), + })) .filter((entry) => entry.gloss !== ''); + // Derived over the post-filter rows: a row dropped for being blank in this language is not on + // screen to be confused with. + const ambiguousGlosses = new Set( + glossed + .filter((entry, index) => + glossed.some( + (other, otherIndex) => + otherIndex !== index && + other.gloss === entry.gloss && + other.breakdown !== entry.breakdown, + ), + ) + .map((entry) => entry.gloss), + ); // Assign status by post-filter rank: only an un-approved token has an "accept" row (its top // renderable match); an approved token offers only promotions. Done after the blank filter so a // dropped top pick promotes the next-ranked glossed match to the accept row rather than leaving a // candidate masquerading as it. const hasAccept = resolved.status === 'suggested'; - return glossed.map((entry, index) => ({ - ...entry, - status: hasAccept && index === 0 ? 'suggested' : 'candidate', - })); + return glossed.map((entry, index) => { + // A whole-word row stays bare rather than showing a blank annotation, which would read as + // missing data rather than as "not broken down"; its rival's breakdown separates the pair. + const isDistinguishing = ambiguousGlosses.has(entry.gloss) && entry.breakdown !== ''; + return { + id: entry.id, + gloss: entry.gloss, + status: hasAccept && index === 0 ? 'suggested' : 'candidate', + ...(isDistinguishing ? { breakdown: entry.breakdown } : {}), + }; + }); } /** From 7eb6888f3f7cc37a47f89bb352aa08dc5e2f3395 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Fri, 28 Aug 2026 15:53:54 -0600 Subject: [PATCH 2/4] Simplify the same-gloss collision check and tighten its docs Drop the redundant self-comparison guard from the collision filter and rename the set for what it holds. Move the status-ordering and approved-exclusion notes into the function's doc comment, where they stand alone on hover, rather than repeating them inline. --- src/__tests__/utils/suggestion-engine.test.ts | 20 +++++++++++ src/utils/suggestion-engine.ts | 36 +++++++------------ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/__tests__/utils/suggestion-engine.test.ts b/src/__tests__/utils/suggestion-engine.test.ts index c56e197e..4772e8d1 100644 --- a/src/__tests__/utils/suggestion-engine.test.ts +++ b/src/__tests__/utils/suggestion-engine.test.ts @@ -432,6 +432,26 @@ describe('glossedSuggestionEntries breakdown disambiguation', () => { expect(entries).toEqual([{ id: 'l1', gloss: 'ran', status: 'suggested' }]); }); + it('annotates all three rows when a same-gloss pair is contested by a third breakdown', () => { + // The duplicate is the point: where a same-breakdown pair alone stays bare, a third breakdown + // makes the annotation worth printing — it marks the pair off from the third, even though it + // cannot separate the two from each other. + const entries = glossedSuggestionEntries( + { + status: 'suggested', + suggested: parsed('t1', 'ran', ['run', 'PST']), + candidates: [parsed('t2', 'ran', ['run', 'PST']), parsed('t3', 'ran', ['ran'])], + }, + 'en', + ); + + expect(entries).toEqual([ + { id: 't1', gloss: 'ran', status: 'suggested', breakdown: 'run PST' }, + { id: 't2', gloss: 'ran', status: 'candidate', breakdown: 'run PST' }, + { id: 't3', gloss: 'ran', status: 'candidate', breakdown: 'ran' }, + ]); + }); + it('annotates same-gloss rows on an approved token, which offers only promotions', () => { // An approved token excludes its own payload, so the collision is decided over what is left. const approved = parsed('a1', 'went', ['go', 'PST']); diff --git a/src/utils/suggestion-engine.ts b/src/utils/suggestion-engine.ts index 1831af32..64a98d76 100644 --- a/src/utils/suggestion-engine.ts +++ b/src/utils/suggestion-engine.ts @@ -186,16 +186,15 @@ function breakdownOf(analysis: TokenAnalysis): string { * Flattens the merged per-token read into the entries the gloss UI renders, in rank order, keeping * only those with a non-blank gloss in the active language. * - * This is the single home of suggestion-presentation policy — which matches are renderable, how a - * blank-in-active-language pick falls through, the approved payload's exclusion from its own - * promote list, each row's assignment status, and which rows need a disambiguating breakdown — so - * every surface ranks, colors, and labels suggestions identically instead of re-deriving any of it - * from row position. + * Single home of suggestion-presentation policy, so every surface offers the same rows rather than + * re-deriving any of it from row position; {@link GlossedSuggestionEntry} documents what each row + * carries. At most one entry is `'suggested'`, and often none (an approved token offers only + * promotions), so read `status` rather than assuming the first row is the accept row. * * Status is assigned _after_ blank picks are dropped. So when the engine's top pick has no gloss in * the active language, the next-ranked glossed match becomes the accept row rather than the whole - * suggestion vanishing. An already-approved token has no accept row at all: every pool peer is a - * promotion, so even the top row reads as one. + * suggestion vanishing. An approved token's own payload is excluded from its promote list, leaving + * only genuine alternatives. * * Because a payload's identity extends past its gloss, two records can be glossed alike and offer * no way to choose between them. Such rows carry their breakdown, but only where the breakdowns @@ -206,8 +205,6 @@ export function glossedSuggestionEntries( analysisLanguage: string, ): GlossedSuggestionEntry[] { if (!resolved) return []; - // The ranked payloads to offer, best-first. For an approved token its own payload is excluded so - // only genuine alternatives remain; for an un-approved token the engine's pick leads. let ranked: readonly TokenAnalysis[]; if (resolved.status === 'suggested') { ranked = [resolved.suggested, ...resolved.candidates]; @@ -223,29 +220,20 @@ export function glossedSuggestionEntries( breakdown: breakdownOf(analysis), })) .filter((entry) => entry.gloss !== ''); - // Derived over the post-filter rows: a row dropped for being blank in this language is not on - // screen to be confused with. - const ambiguousGlosses = new Set( + // Built from post-filter rows, so membership tracks what's actually on screen. Keyed per gloss: + // once two rows sharing a gloss break down differently, every row with that gloss is annotated. + const glossesSplitByBreakdown = new Set( glossed - .filter((entry, index) => - glossed.some( - (other, otherIndex) => - otherIndex !== index && - other.gloss === entry.gloss && - other.breakdown !== entry.breakdown, - ), + .filter((entry) => + glossed.some((other) => other.gloss === entry.gloss && other.breakdown !== entry.breakdown), ) .map((entry) => entry.gloss), ); - // Assign status by post-filter rank: only an un-approved token has an "accept" row (its top - // renderable match); an approved token offers only promotions. Done after the blank filter so a - // dropped top pick promotes the next-ranked glossed match to the accept row rather than leaving a - // candidate masquerading as it. const hasAccept = resolved.status === 'suggested'; return glossed.map((entry, index) => { // A whole-word row stays bare rather than showing a blank annotation, which would read as // missing data rather than as "not broken down"; its rival's breakdown separates the pair. - const isDistinguishing = ambiguousGlosses.has(entry.gloss) && entry.breakdown !== ''; + const isDistinguishing = glossesSplitByBreakdown.has(entry.gloss) && entry.breakdown !== ''; return { id: entry.id, gloss: entry.gloss, From 2f9505840c44a6e37fa440f3f1c239652781d15e Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 31 Aug 2026 09:48:11 -0600 Subject: [PATCH 3/4] Always show a suggestion's morpheme breakdown Suggestion rows previously carried their breakdown only where it disambiguated: when a rival row shared the gloss but broke down differently. Selective display invites the question of why one row is annotated and its neighbor is not, and a breakdown is useful context in its own right for a menu that is opened deliberately and otherwise shows little. A row now renders whatever breakdown its payload has, and nothing when it has none. This drops the per-gloss collision pass, and the engine tests that pinned it. --- .../components/TokenChip.suggestions.test.tsx | 8 +-- src/__tests__/utils/suggestion-engine.test.ts | 70 ++++--------------- src/components/PhraseStripContext.tsx | 2 +- src/components/SuggestionDropdown.tsx | 2 +- src/utils/suggestion-engine.ts | 43 ++++-------- 5 files changed, 34 insertions(+), 91 deletions(-) diff --git a/src/__tests__/components/TokenChip.suggestions.test.tsx b/src/__tests__/components/TokenChip.suggestions.test.tsx index 3e9c7829..b927c9d0 100644 --- a/src/__tests__/components/TokenChip.suggestions.test.tsx +++ b/src/__tests__/components/TokenChip.suggestions.test.tsx @@ -86,8 +86,8 @@ function homographBankPool(financeGloss: string | undefined): TextAnalysis { } /** - * Builds a pool where 'ran' carries two approved analyses glossed alike, breaking the - * lower-frequency one down into `rivalForms` — so only the breakdown tells the rows apart. + * Builds a pool where 'ran' carries two approved analyses glossed alike, the more frequent parsed + * as `run PST` and the other broken down into `rivalForms`. */ function sameGlossParsePool(rivalForms: readonly string[]): TextAnalysis { const pastTense: TokenAnalysis = { @@ -473,14 +473,14 @@ describe('TokenChip suggestion dropdown', () => { ]); }); - it('omits the breakdown when the two suggestions carry different glosses', async () => { + it('omits the breakdown on a suggestion with no morphological breakdown', async () => { + // The 'bank' pool analyses carry no morphemes, so there is no breakdown for either row to show. renderChip(makeWordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool('finance'), }); await focusGloss(); - // 'riverbank' and 'finance' already differ, so neither row needs a disambiguating breakdown. expect(screen.queryByTestId('suggestion-breakdown')).not.toBeInTheDocument(); }); diff --git a/src/__tests__/utils/suggestion-engine.test.ts b/src/__tests__/utils/suggestion-engine.test.ts index 4772e8d1..ae7d373a 100644 --- a/src/__tests__/utils/suggestion-engine.test.ts +++ b/src/__tests__/utils/suggestion-engine.test.ts @@ -337,7 +337,7 @@ describe('resolvedTokenAnalysisEqual', () => { }); }); -describe('glossedSuggestionEntries breakdown disambiguation', () => { +describe('glossedSuggestionEntries breakdowns', () => { /** Builds an analysis of 'ran' glossed as `gloss` and broken down into `forms`. */ function parsed(id: string, gloss: string, forms: readonly string[]): TokenAnalysis { return { @@ -349,7 +349,7 @@ describe('glossedSuggestionEntries breakdown disambiguation', () => { }; } - it('carries the breakdown on same-gloss rows whose breakdowns differ', () => { + it('carries each row its own breakdown, which is what tells same-gloss rows apart', () => { const pastTense = parsed('p1', 'ran', ['run', 'PST']); const bareRoot = parsed('p2', 'ran', ['ran']); @@ -364,8 +364,7 @@ describe('glossedSuggestionEntries breakdown disambiguation', () => { ]); }); - it('omits the breakdown when rows carry distinct glosses', () => { - // Distinct glosses already tell the rows apart, so a breakdown would be noise here. + it('carries the breakdown on rows that already differ by gloss', () => { const river = parsed('b1', 'riverbank', ['bank']); const money = parsed('b2', 'finance', ['bank']); @@ -375,31 +374,14 @@ describe('glossedSuggestionEntries breakdown disambiguation', () => { ); expect(entries).toEqual([ - { id: 'b1', gloss: 'riverbank', status: 'suggested' }, - { id: 'b2', gloss: 'finance', status: 'candidate' }, + { id: 'b1', gloss: 'riverbank', status: 'suggested', breakdown: 'bank' }, + { id: 'b2', gloss: 'finance', status: 'candidate', breakdown: 'bank' }, ]); }); - it('omits the breakdown when same-gloss rows share one breakdown', () => { - // These differ only by part of speech, which a breakdown cannot show; printing the identical - // breakdown twice would suggest a distinction it does not make. - const asVerb = { ...parsed('s1', 'ran', ['run', 'PST']), pos: 'V' }; - const asNoun = { ...parsed('s2', 'ran', ['run', 'PST']), pos: 'N' }; - - const entries = glossedSuggestionEntries( - { status: 'suggested', suggested: asVerb, candidates: [asNoun] }, - 'en', - ); - - expect(entries).toEqual([ - { id: 's1', gloss: 'ran', status: 'suggested' }, - { id: 's2', gloss: 'ran', status: 'candidate' }, - ]); - }); - - it('leaves a whole-word row unannotated while marking its parsed same-gloss rival', () => { - // The whole-word payload has nothing to print, and its rival's breakdown already separates them. - const wholeWord: TokenAnalysis = { + it('omits the breakdown on a row whose payload carries no morphemes at all', () => { + // Printing a blank annotation would read as missing data rather than as "nothing to show". + const unparsed: TokenAnalysis = { ...FIXTURE_STAMPS, id: 'w1', surfaceText: 'ran', @@ -408,7 +390,7 @@ describe('glossedSuggestionEntries breakdown disambiguation', () => { const parsedRival = parsed('w2', 'ran', ['run', 'PST']); const entries = glossedSuggestionEntries( - { status: 'suggested', suggested: wholeWord, candidates: [parsedRival] }, + { status: 'suggested', suggested: unparsed, candidates: [parsedRival] }, 'en', ); @@ -418,42 +400,18 @@ describe('glossedSuggestionEntries breakdown disambiguation', () => { ]); }); - it('ignores a blank-in-active-language row when deciding what collides', () => { - // The blank row is filtered out before the collision pass, so it is not on screen to be - // confused with. - const shown = parsed('l1', 'ran', ['run', 'PST']); - const blankHere: TokenAnalysis = { ...parsed('l2', 'ran', ['ran']), gloss: { fr: 'couru' } }; + it('omits the breakdown on a row whose morpheme list is empty', () => { + const emptyParse: TokenAnalysis = { ...parsed('e1', 'ran', []), morphemes: [] }; const entries = glossedSuggestionEntries( - { status: 'suggested', suggested: shown, candidates: [blankHere] }, + { status: 'suggested', suggested: emptyParse, candidates: [] }, 'en', ); - expect(entries).toEqual([{ id: 'l1', gloss: 'ran', status: 'suggested' }]); - }); - - it('annotates all three rows when a same-gloss pair is contested by a third breakdown', () => { - // The duplicate is the point: where a same-breakdown pair alone stays bare, a third breakdown - // makes the annotation worth printing — it marks the pair off from the third, even though it - // cannot separate the two from each other. - const entries = glossedSuggestionEntries( - { - status: 'suggested', - suggested: parsed('t1', 'ran', ['run', 'PST']), - candidates: [parsed('t2', 'ran', ['run', 'PST']), parsed('t3', 'ran', ['ran'])], - }, - 'en', - ); - - expect(entries).toEqual([ - { id: 't1', gloss: 'ran', status: 'suggested', breakdown: 'run PST' }, - { id: 't2', gloss: 'ran', status: 'candidate', breakdown: 'run PST' }, - { id: 't3', gloss: 'ran', status: 'candidate', breakdown: 'ran' }, - ]); + expect(entries).toEqual([{ id: 'e1', gloss: 'ran', status: 'suggested' }]); }); - it('annotates same-gloss rows on an approved token, which offers only promotions', () => { - // An approved token excludes its own payload, so the collision is decided over what is left. + it('carries breakdowns on an approved token, which offers only promotions', () => { const approved = parsed('a1', 'went', ['go', 'PST']); const first = parsed('a2', 'ran', ['run', 'PST']); const second = parsed('a3', 'ran', ['ran']); diff --git a/src/components/PhraseStripContext.tsx b/src/components/PhraseStripContext.tsx index 4ad1d7d0..8ade2b2e 100644 --- a/src/components/PhraseStripContext.tsx +++ b/src/components/PhraseStripContext.tsx @@ -25,7 +25,7 @@ export type TokenChipLabels = Readonly<{ acceptSuggestion: string; /** Accessible label for a dropdown row that promotes a candidate gloss. */ promoteSuggestion: string; - /** Accessible suffix naming the morpheme breakdown that sets a same-gloss dropdown row apart. */ + /** Accessible suffix naming the morpheme breakdown a suggestion dropdown row is showing. */ suggestionBreakdown: string; }>; diff --git a/src/components/SuggestionDropdown.tsx b/src/components/SuggestionDropdown.tsx index 5b60841d..48b48dee 100644 --- a/src/components/SuggestionDropdown.tsx +++ b/src/components/SuggestionDropdown.tsx @@ -50,7 +50,7 @@ type SuggestionDropdownProps = Readonly<{ * dropped blank-in-language pick can never leave a candidate masquerading as the accept row. * * A row also renders its `breakdown` when it carries one, so two analyses glossed alike are never - * offered as visually identical choices. + * offered as identical choices. */ export default function SuggestionDropdown({ listboxId, diff --git a/src/utils/suggestion-engine.ts b/src/utils/suggestion-engine.ts index 64a98d76..e88fcef8 100644 --- a/src/utils/suggestion-engine.ts +++ b/src/utils/suggestion-engine.ts @@ -171,15 +171,18 @@ export interface GlossedSuggestionEntry { */ status: Extract; /** - * The payload's morpheme forms, rendered beside the gloss to tell this row from another sharing - * it. Absent unless a rival row is glossed identically but broken down differently. + * The payload's morpheme forms, rendered beside the gloss as context for the choice and to tell + * this row from another sharing its gloss. Absent when the payload has no morphological + * breakdown. */ breakdown?: string; } -/** The displayable breakdown of a payload, or `''` when it treats the token as one whole word. */ -function breakdownOf(analysis: TokenAnalysis): string { - return (analysis.morphemes ?? []).map((morpheme) => morpheme.form).join(' '); +/** `undefined` when the payload has no morphological breakdown, so there are no forms to show. */ +function breakdownOf(analysis: TokenAnalysis): string | undefined { + const { morphemes } = analysis; + if (!morphemes || morphemes.length === 0) return undefined; + return morphemes.map((morpheme) => morpheme.form).join(' '); } /** @@ -195,10 +198,6 @@ function breakdownOf(analysis: TokenAnalysis): string { * the active language, the next-ranked glossed match becomes the accept row rather than the whole * suggestion vanishing. An approved token's own payload is excluded from its promote list, leaving * only genuine alternatives. - * - * Because a payload's identity extends past its gloss, two records can be glossed alike and offer - * no way to choose between them. Such rows carry their breakdown, but only where the breakdowns - * differ — one a breakdown cannot separate stays bare. */ export function glossedSuggestionEntries( resolved: ResolvedTokenAnalysis | undefined, @@ -220,27 +219,13 @@ export function glossedSuggestionEntries( breakdown: breakdownOf(analysis), })) .filter((entry) => entry.gloss !== ''); - // Built from post-filter rows, so membership tracks what's actually on screen. Keyed per gloss: - // once two rows sharing a gloss break down differently, every row with that gloss is annotated. - const glossesSplitByBreakdown = new Set( - glossed - .filter((entry) => - glossed.some((other) => other.gloss === entry.gloss && other.breakdown !== entry.breakdown), - ) - .map((entry) => entry.gloss), - ); const hasAccept = resolved.status === 'suggested'; - return glossed.map((entry, index) => { - // A whole-word row stays bare rather than showing a blank annotation, which would read as - // missing data rather than as "not broken down"; its rival's breakdown separates the pair. - const isDistinguishing = glossesSplitByBreakdown.has(entry.gloss) && entry.breakdown !== ''; - return { - id: entry.id, - gloss: entry.gloss, - status: hasAccept && index === 0 ? 'suggested' : 'candidate', - ...(isDistinguishing ? { breakdown: entry.breakdown } : {}), - }; - }); + return glossed.map((entry, index) => ({ + id: entry.id, + gloss: entry.gloss, + status: hasAccept && index === 0 ? 'suggested' : 'candidate', + ...(entry.breakdown === undefined ? {} : { breakdown: entry.breakdown }), + })); } /** From c990a627e2cad8a8cdfeec8faad1f362f4b032ec Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 31 Aug 2026 11:02:32 -0600 Subject: [PATCH 4/4] Drop the redundant differing-gloss breakdown test --- src/__tests__/utils/suggestion-engine.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/__tests__/utils/suggestion-engine.test.ts b/src/__tests__/utils/suggestion-engine.test.ts index ae7d373a..0cc71af5 100644 --- a/src/__tests__/utils/suggestion-engine.test.ts +++ b/src/__tests__/utils/suggestion-engine.test.ts @@ -364,21 +364,6 @@ describe('glossedSuggestionEntries breakdowns', () => { ]); }); - it('carries the breakdown on rows that already differ by gloss', () => { - const river = parsed('b1', 'riverbank', ['bank']); - const money = parsed('b2', 'finance', ['bank']); - - const entries = glossedSuggestionEntries( - { status: 'suggested', suggested: river, candidates: [money] }, - 'en', - ); - - expect(entries).toEqual([ - { id: 'b1', gloss: 'riverbank', status: 'suggested', breakdown: 'bank' }, - { id: 'b2', gloss: 'finance', status: 'candidate', breakdown: 'bank' }, - ]); - }); - it('omits the breakdown on a row whose payload carries no morphemes at all', () => { // Printing a blank annotation would read as missing data rather than as "nothing to show". const unparsed: TokenAnalysis = {