Skip to content

Distinguish same-gloss suggestions by their morpheme breakdown - #271

Open
alex-rawlings-yyc wants to merge 2 commits into
mainfrom
213-distinguish-same-gloss-analyses
Open

Distinguish same-gloss suggestions by their morpheme breakdown#271
alex-rawlings-yyc wants to merge 2 commits into
mainfrom
213-distinguish-same-gloss-analyses

Conversation

@alex-rawlings-yyc

@alex-rawlings-yyc alex-rawlings-yyc commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #213

Two analyses of one surface form can share a gloss while differing in how they break the token down. Both are legitimately distinct payloads — analysesAreIdentical counts morphemes, part of speech, features, and lexicon sense as identity — so both land in the same pool bucket and render as identical dropdown rows, offering no way to choose between them.

The data was already reaching the UI and being discarded. glossedSuggestionEntries projected each ranked analysis down to { id, gloss }, dropping the morphemes one line before the rows were built.

Approach

Rows now carry a breakdown, but only where it resolves something: the gloss is contested by a differing breakdown. Two consequences worth knowing:

  • Same gloss and same breakdown (differing only by pos/features/sense) → no annotation, since a breakdown cannot separate them. Still visually identical; see below.
  • A whole-word analysis contested by a parsed one → the whole-word row stays bare rather than rendering an empty string, which would read as missing data instead of "not broken down". The rival's breakdown alone distinguishes the pair.

The policy lives in glossedSuggestionEntries rather than the component, since that function's doc declares it the single home of suggestion-presentation policy, and TokenChip's memoization depends on the entry list being derived there.

The breakdown is also appended to each row's aria-label and the visible span is aria-hidden. Without the label change the rows would still sound identical — the same bug, just moved off-screen.

Not covered

Same-gloss rows differing only by part of speech, features, or lexicon sense remain indistinguishable. Fixing that means rendering those fields in a dropdown row, which is a wider UI question than this issue asked for — worth a follow-up if wanted.

Testing

1929 tests pass (baseline 1921; 6 engine + 2 component tests added). Coverage stays at 100% across statements, branches, functions, and lines, with no new ignore comments.

Verified the tests fail against the unfixed source: stashing only the two source files fails exactly the 4 tests asserting new behavior, while the negative cases correctly hold both ways.


This change is Reviewable

Summary by CodeRabbit

  • New Features

    • Added morphological breakdowns to suggestion dropdown entries when identical glosses require clarification.
    • Added accessible labels describing suggestion breakdowns.
    • Whole-word suggestions remain unannotated, while parsed alternatives display their morpheme forms.
  • Localization

    • Added English text for the suggestion breakdown label.
  • Tests

    • Added coverage for ambiguous, distinct, identical, and language-specific suggestion breakdown scenarios.

@alex-rawlings-yyc alex-rawlings-yyc self-assigned this Aug 26, 2026
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review Change Stack

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a02ed15-c992-4ce5-985c-9e5e0b85d4c5

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8dcb8ad1-2ebc-4ab0-b7e7-64bbb96a6d48

📥 Commits

Reviewing files that changed from the base of the PR and between f5e860c and eec6c1e.

📒 Files selected for processing (8)
  • contributions/localizedStrings.json
  • src/__tests__/components/TokenChip.suggestions.test.tsx
  • src/__tests__/utils/suggestion-engine.test.ts
  • src/components/PhraseStripContext.tsx
  • src/components/SuggestionDropdown.tsx
  • src/components/TokenChip.tsx
  • src/hooks/usePhraseStripSetup.ts
  • src/utils/suggestion-engine.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The suggestion engine now disambiguates same-gloss analyses by carrying morpheme breakdowns. The dropdown displays these breakdowns and includes them in accessible labels. Localization wiring and tests cover the new behavior.

Changes

Suggestion breakdown disambiguation

Layer / File(s) Summary
Breakdown generation and filtering
src/utils/suggestion-engine.ts, src/__tests__/utils/suggestion-engine.test.ts
The engine derives morpheme breakdowns and attaches them only to same-gloss entries with different breakdowns. Tests cover gloss, breakdown, whole-word, blank-gloss, and status cases.
Localized dropdown rendering
src/components/PhraseStripContext.tsx, src/hooks/usePhraseStripSetup.ts, src/components/SuggestionDropdown.tsx, src/components/TokenChip.tsx, contributions/localizedStrings.json, src/__tests__/components/TokenChip.suggestions.test.tsx
The localized breakdown label flows through TokenChip to SuggestionDropdown. The dropdown renders breakdown text and adds it to row accessibility labels. Component tests cover matching and differing glosses.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to eec6c

This localized UI change is merge-ready after normal checks and review; no actionable merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant Analysis as glossedSuggestionEntries
  participant Chip as TokenChip
  participant Dropdown as SuggestionDropdown
  Analysis->>Chip: provide entries with optional breakdown
  Chip->>Dropdown: pass breakdownLabelTemplate
  Dropdown->>Dropdown: append formatted breakdown to aria-label
  Dropdown->>Dropdown: render muted suggestion-breakdown text
Loading

Suggested reviewers: imnasnainaec

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: distinguishing same-gloss suggestions by morpheme breakdown.
Linked Issues check ✅ Passed The changes satisfy issue #213 by adding breakdown data for ambiguous same-gloss suggestions, rendering the distinction in the dropdown, adding accessibility text, and covering the behavior with tests…
Out of Scope Changes check ✅ Passed The localization, component, hook, suggestion-engine, and test changes directly support the same-gloss suggestion distinction described in issue #213. No unrelated code changes are identified.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 7 files. (1 skipped: 1 …
Full details: Linked Issues check

Explanation

The changes satisfy issue #213 by adding breakdown data for ambiguous same-gloss suggestions, rendering the distinction in the dropdown, adding accessibility text, and covering the behavior with tests.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 7 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 213-distinguish-same-gloss-analyses

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@alex-rawlings-yyc
alex-rawlings-yyc marked this pull request as ready for review August 26, 2026 18:06
@alex-rawlings-yyc
alex-rawlings-yyc force-pushed the 213-distinguish-same-gloss-analyses branch from 307fb85 to eec6c1e Compare August 26, 2026 19:27

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@imnasnainaec made 2 comments.
Reviewable status: 0 of 8 files reviewed, 2 unresolved discussions (waiting on alex-rawlings-yyc).


src/utils/suggestion-engine.ts line 204 at r1 (raw file):

 * differ — one a breakdown cannot separate stays bare.
 */
export function glossedSuggestionEntries(

⛏️ Suggested rewrite of the entire unwieldy glossedSuggestionEntries JSDocs:

/**
 * 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.
 *
 * 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.
 */

src/utils/suggestion-engine.ts line 239 at r1 (raw file):

      )
      .map((entry) => entry.gloss),
  );

⛏️ Possible replacement for ambiguousGlosses:

// 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),
);

And a test could be added to go with it:

it('annotates all three rows when a same-gloss pair is contested by a third breakdown', () => {
  // The duplicate is the point: unlike the pair above, this annotation marks both off from the third.
  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' },
  ]);
});

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.
@alex-rawlings-yyc
alex-rawlings-yyc force-pushed the 213-distinguish-same-gloss-analyses branch from eec6c1e to 6b3f039 Compare August 28, 2026 19:16
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.

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alex-rawlings-yyc made 2 comments.
Reviewable status: 0 of 8 files reviewed, 2 unresolved discussions (waiting on imnasnainaec).


src/utils/suggestion-engine.ts line 204 at r1 (raw file):

Previously, imnasnainaec (D. Ror.) wrote…

⛏️ Suggested rewrite of the entire unwieldy glossedSuggestionEntries JSDocs:

/**
 * 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.
 *
 * 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.
 */

Taken, with one thing added back.

Your opening and the "read status rather than assuming the first row is the accept row" line are both in — the latter is caller-facing advice the previous version only implied.

Kept two facts your rewrite drops. That status is assigned after blank picks are filtered: it's what makes the fall-through correct, and it isn't recoverable from hasAccept && index === 0 alone. And the approved-token self-exclusion, which is what the filter((a) => a.id !== resolved.analysis.id) branch implements. Folded the second into the ordering paragraph rather than restoring my original sentence, so that paragraph now covers both post-filter behaviors.

Knock-on: pulling those up into the doc comment made two inline comments in the body redundant — one restating the exclusion, one restating the ordering verbatim — so both are deleted.


src/utils/suggestion-engine.ts line 239 at r1 (raw file):

Previously, imnasnainaec (D. Ror.) wrote…

⛏️ Possible replacement for ambiguousGlosses:

// 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),
);

And a test could be added to go with it:

it('annotates all three rows when a same-gloss pair is contested by a third breakdown', () => {
  // The duplicate is the point: unlike the pair above, this annotation marks both off from the third.
  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' },
  ]);
});

Both applied as written.

Dropping the otherIndex !== index guard is safe: a row can't satisfy other.breakdown !== entry.breakdown against itself, so the self-comparison was inert. glossesSplitByBreakdown is the better name too — the set holds glosses that need annotating, not glosses that are ambiguous.

On the test: it passes against the pre-existing implementation, so it's pinning behavior rather than catching a bug — but it covers a shape nothing else did, since every existing case has distinct breakdowns per row. Worth being explicit that it locks in t1 and t2 rendering identically: the annotation marks them off from t3 without separating them from each other, which is the opposite of what the two-row same-breakdown case does. Kept your comment making that point, with "the pair above" swapped for an explicit referent — the test directly above it is now the blank-in-active-language case.

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.

No visual difference between different analyses with same gloss

2 participants