Skip to content

LT-22714: Stop the IPA populate path duplicating phoneme features - #1087

Draft
johnml1135 wants to merge 2 commits into
mainfrom
phon-features-duplicated
Draft

LT-22714: Stop the IPA populate path duplicating phoneme features#1087
johnml1135 wants to merge 2 commits into
mainfrom
phon-features-duplicated

Conversation

@johnml1135

@johnml1135 johnml1135 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Setting or correcting a phoneme's Basic IPA Symbol no longer leaves it holding
the same phonological feature twice. Changing a symbol from p to t
previously appended t's entire standard feature set on top of p's: 16
duplicated features from one ordinary edit, many carrying contradictory values.

The unknown you are starting with is whether this is the whole cause or a
symptom. Every writer to PhPhoneme.FeaturesOA was audited. Both feature
choosers and the bulk-edit path clear or upsert before writing; the IPA
populate loop was the only one that appended blind. Two defects compound there

  • the shipped inventory names two features twice for IPA j, and a slice-local
    latch short-circuits the "features are not already filled" gate and can never
    reset while the symbol is non-empty. Review is better spent on whether an
    upsert is the right containment than on hunting for other writers.

Where to look

  • BasicIPASymbolSlice.cs upserts through IFsFeatStruc.GetOrCreateValue, the
    helper the choosers and bulk edit already use.
  • The feature lookup now requires an IFsClosedFeature, which subsumes the old
    null check and skips anything with no symbolic value to assign.
  • In BasicIPAInfo.xml the retained j rows are the ones in canonical
    position; its first eight features now match the order k uses.
  • All five new tests were verified to fail without the fix. One initially
    passed for the wrong reason and was corrected before being trusted.
  • A data test covers all 245 segment definitions, so a duplicated pair cannot
    be reintroduced silently.

Deliberately not here

  • The m_justChangedFeatures latch stays.
  • Features set in the chooser first still cause a later IPA symbol to populate
    nothing at all (recorded on LT-22714).
  • Phonemes already corrupted are not repaired (LT-22716).

Verification. Build clean, 0 warnings. MorphologyEditorDllTests 12/12.
Red and green confirmed in both directions. The full suite was not run.
build.ps1 -CommentHygiene could not complete: it trips on an untracked
scratch file that predates this branch, and that run confirmed zero violations
in these three files.


Reading this a year from now - start here

This branch is the containment half of a two-part story. The duplication had
two independent causes, and only the cheap one is fixed here. The expensive one

  • a UI-local flag standing in for knowledge the model does not record - is
    still present by choice.

If you are here because the duplication came back, check first whether the
latch is still in BasicIPASymbolSlice.SetFeaturesBasedOnIPA. If it is, the
bug you are looking at is probably not a duplicate but a stale feature: one
the previous IPA symbol specified and the current one does not name, which the
upsert has no reason to remove.

Decisions, and why

Upsert rather than skip. The obvious guard - "if this feature already
exists, skip it" - is wrong in a way that is easy to miss. On a p to t
edit it prevents the duplicate but silently discards t's values for every
shared feature, leaving the phoneme still described as p. Any guard here has
to update the existing specification, not step over it.

The latch was kept deliberately. m_justChangedFeatures is a crude record
of "these features are mine, not the user's". Deleting it would make the
mechanism idempotent, but it would also mean an IPA symbol overwrites
hand-picked feature values, and it would force a decision about whether a
symbol change owns the whole feature set or only the features the new symbol
names. The durable answer is a view showing where a phoneme's declared features
diverge from the standard features of its IPA symbol. There is nowhere to
present that today, so the latch remains a placeholder for the missing view
rather than an oversight to clean up.

Which duplicate rows to delete from the inventory. The two duplicated pairs
for j were byte-identical, so the choice looked arbitrary. It was not: j's
feature order was compared against neighbouring segments, and positions 1-8
match k exactly, with Anterior, Coronal and High at 4, 5 and 6. The
oddly-indented block was therefore in canonical position and the later,
normally-indented pair was the intruder - the opposite of what the stray
indentation suggests.

Paths not taken

Three fixes were costed on LT-22714.

Categorical, in the slice. Remove the latch from the gate and upsert. This
closes the bug class rather than the instance and also fixes the chooser-first
defect. Rejected for now only because it forces the ownership-semantics
decision described above, which wants the divergence view first.

Model invariant in liblcm. Enforce one specification per feature inside
FsFeatStruc so no caller anywhere can duplicate. Attractive, and the only
universal option, but it hits a concrete obstacle: every call site reviewed
adds the specification before setting its feature (FeatureSpecsOC.Add(value); value.FeatureRA = featDefn;). At Add time FeatureRA is still null, so an
add-time check cannot see the feature at all; the invariant would have to hook
the FeatureRA setter. Throwing there also risks breaking incremental object
construction during project load, XML import and undo replay. It is worth doing
later as a debug-only assertion, not a runtime invariant.

Surprising findings

Several plausible culprits were checked and cleared, which is worth recording
so they are not re-investigated:

  • Event handler accumulation. BasicIPASymbolChanged is an event on the
    long-lived model object subscribed from a UI slice, which is the classic leak
    shape. It does not leak: DataTree.Reset and DataTree.RemoveSlice both
    dispose, and the unsubscribe runs before base.Dispose.
  • Re-committing the same text. Retyping an identical symbol does not
    re-fire the side effect; the generated setter does a value-equality check.
  • LIFT import. It adds without a pre-check, but writes MSA inflection
    features into a freshly created structure, and LIFT carries no phonemes.
  • PriorityUnion matching. It reads as matching on FeatureRA.Name, but
    Name is an accessor cached per object with no operator== overload, so the
    comparison is really feature-object identity. It works, but it will not
    collapse two distinct feature definitions that share a name.
  • PriorityUnion cannot repair. It resolves matches with
    myFeatureValues.First(), so on an already-duplicated phoneme it updates one
    copy and leaves the twin. This is why bulk edit will not clean up existing
    corruption, and why LT-22716 exists.
Evidence

The regression tests were run against a reverted working tree to confirm they
are not vacuous. Pre-fix failures, which also quantify the defect:

Test Pre-fix result
SettingSymbol_AddsEachFeatureOnce j duplicated 2 features on a fresh phoneme, from the data defect alone
RepopulatingSameSymbol_DoesNotDuplicateFeatures repopulating p duplicated 18 features
ChangingSymbol_DoesNotDuplicateSharedFeatures p to t duplicated 16 features
SymbolEditAfterFeaturesAlreadySet_DoesNotDuplicateFeatures same 16
BasicIPAInfo_NoSegmentNamesTheSameFeatureTwice j: fPAAnterior, fPACoronal

RepopulatingSameSymbol initially failed with Not in the right state to register a change rather than on the assertion, because it called
SetFeaturesBasedOnIPA outside a unit of work. That also meant it passed green
for the wrong reason: post-fix the upsert writes the same value back, registers
no change, and so raised nothing. It was wrapped in a unit of work and both
directions re-run before being trusted.

A scripted audit of the shipped inventory found exactly one affected segment
out of 245.

Preflight review details

See .review/summary.md on the branch author's working copy. The findings it
records are reproduced in the pitch and accordions above: no Critical and no
Important findings; five Minor, of which three are the deliberate deferrals
listed under "Deliberately not here", one is a latent liblcm edge case that
this path cannot reach, and one is the comment-hygiene gate being blocked by an
untracked file predating the branch.


This change is Reviewable

The Basic IPA Symbol slice created a new closed value for every
FeatureValuePair the IPA inventory lists and appended it, so a phoneme
could end up holding several specifications for one phonological
feature. It now upserts through IFsFeatStruc.GetOrCreateValue, updating
the specification a feature already has rather than adding another. The
lookup requires a closed feature, which also covers the null case, and
skips a feature that has no symbolic value to assign.

The shipped inventory also named fPAAnterior and fPACoronal twice for
IPA j, which put two duplicates on a phoneme the first time that symbol
populated its features. The surviving rows are the ones in canonical
position, matching the feature order comparable segments use.

The m_justChangedFeatures latch stays. It stands in for a view of how a
phoneme's declared features diverge from the standard features of its
IPA symbol, which there is currently nowhere to show.

Five tests cover setting a symbol, repopulating the same symbol,
changing a symbol, and editing a symbol after features were already
set, plus a check that no segment in the shipped inventory names a
feature twice. All five fail without this change.

Phonemes corrupted before this change are not repaired by it and are
tracked in LT-22716.

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

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.39130% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.37%. Comparing base (6c6f9eb) to head (c48fbdd).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
Src/LexText/Morphology/PhonemeFeaturePopulator.cs 66.66% 7 Missing and 7 partials ⚠️
Src/LexText/Morphology/BasicIPASymbolSlice.cs 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1087      +/-   ##
==========================================
+ Coverage   38.07%   38.37%   +0.30%     
==========================================
  Files        1499     1508       +9     
  Lines      350141   350653     +512     
  Branches    40238    40309      +71     
==========================================
+ Hits       133304   134576    +1272     
+ Misses     187558   186848     -710     
+ Partials    29279    29229      -50     
Files with missing lines Coverage Δ
Src/LexText/Morphology/BasicIPASymbolSlice.cs 77.02% <75.00%> (+77.02%) ⬆️
Src/LexText/Morphology/PhonemeFeaturePopulator.cs 66.66% <66.66%> (ø)

... and 57 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

NUnit Tests

    1 files  ± 0      1 suites  ±0   8m 54s ⏱️ - 1m 23s
5 873 tests +77  5 792 ✅ +77  81 💤 ±0  0 ❌ ±0 
5 882 runs  +77  5 801 ✅ +77  81 💤 ±0  0 ❌ ±0 

Results for commit c48fbdd. ± Comparison against base commit 1cfe930.

This pull request removes 20 and adds 97 tests. Note that renamed tests count towards both.
FwAvaloniaTests.DetailMenuRequestTests ‑ RightClick_OnLabel_NoLongerRaisesAnyRequest
FwAvaloniaTests.DetailMenuRequestTests ‑ RightClick_OnUnboundRow_RaisesNoRequest
SIL.FieldWorks.Common.FwUtils.UIModeGatesTests ‑ IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues("   ")
SIL.FieldWorks.Common.FwUtils.UIModeGatesTests ‑ IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues(" off ")
SIL.FieldWorks.Common.FwUtils.UIModeGatesTests ‑ IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues("")
SIL.FieldWorks.Common.FwUtils.UIModeGatesTests ‑ IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues("0")
SIL.FieldWorks.Common.FwUtils.UIModeGatesTests ‑ IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues("False")
SIL.FieldWorks.Common.FwUtils.UIModeGatesTests ‑ IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues("false")
SIL.FieldWorks.Common.FwUtils.UIModeGatesTests ‑ IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues("off")
SIL.FieldWorks.Common.FwUtils.UIModeGatesTests ‑ IsSwitchingEnabled_FailsClosedForUnsetAndNegativeValues(null)
…
FwAvaloniaTests.DetailMenuRequestTests ‑ ContextMenuKey_InTheValueBox_AnchorsToTheEditField_NotThePointer
FwAvaloniaTests.DetailMenuRequestTests ‑ ContextMenuKey_InTheValueBox_RaisesTheContextMenuRequest
FwAvaloniaTests.DetailMenuRequestTests ‑ ContextMenuKey_OnTheFocusedLabelCell_RaisesTheSliceMenuRequest
FwAvaloniaTests.DetailMenuRequestTests ‑ ContextMenuKey_OnTheLabelCell_AnchorsToThatCell
FwAvaloniaTests.DetailMenuRequestTests ‑ DetailMenuFlyout_AnchoredPlacement_DropsFromTheTargetsBottomLeft
FwAvaloniaTests.DetailMenuRequestTests ‑ DetailMenuFlyout_PointerPlacement_LeavesTheDefault
FwAvaloniaTests.DetailMenuRequestTests ‑ FieldMenuButton_AlwaysAnchorsToTheButton
FwAvaloniaTests.DetailMenuRequestTests ‑ LabelAndValue_RaiseTheirOwnDistinctMenus
FwAvaloniaTests.DetailMenuRequestTests ‑ RightClick_InTheValue_RaisesExactlyOneRequest_NotAlsoTheRowHandler
FwAvaloniaTests.DetailMenuRequestTests ‑ RightClick_OnASectionHeader_RaisesTheSliceMenuRequest
…

♻️ This comment has been updated with latest results.

@jasonleenaylor

Copy link
Copy Markdown
Contributor

On the existing-project problem deferred to LT-22716: that repair should be run by the
user
, not applied silently. Two routes, and which one depends on a question worth answering
explicitly.

If the repair is deterministic — if a duplicated pair always has an unambiguous winner, so
no human judgement is needed — then it likely belongs in Find and fix errors, alongside the
existing SIL.LCModel.FixData rules that ErrorFixer
(Src/Utilities/FixFwDataDll/ErrorFixer.cs) surfaces. That is the better outcome for users,
because Find and fix errors is something people already run when a project misbehaves; nobody
has to know a new utility exists. The cost is that the rule lives in liblcm, so it is a
cross-repo change on a different release cadence, and it runs over the .fwdata file rather
than a live cache. Worth talking to Ken Zook before choosing — he knows that rule set and
whether this shape fits it.

If it is not deterministic — if the two specs can disagree and a person has to decide which
value survives, which is the merge-policy question you are deliberately deferring — then it
wants its own utility under Tools > Utilities.

That seam is five members (Src/FwCoreDlgs/IUtility.cs):

string Label { get; }
UtilityDlg Dialog { set; }
void LoadUtilities();
void OnSelection();
void Process();

The closest exemplar is DuplicateAnalysisFixer (Src/LexText/Interlinear/DuplicateAnalysisFixer.cs)
— same shape: find duplicated data the user cannot easily see, merge it, keep it undoable. The
whole class is about 50 lines, and Process() carries the work:

var cache = m_dlg.PropTable.GetValue<LcmCache>("cache");
UndoableUnitOfWorkHelper.Do(ITextStrings.ksUndoMergeAnalyses, ITextStrings.ksRedoMergeAnalyses,
    cache.ActionHandlerAccessor,
    () => WfiWordformServices.MergeDuplicateAnalyses(cache, new ProgressBarWrapper(m_dlg.ProgressBar)));

OnSelection() sets WhenDescription, WhatDescription and RedoDescription — which is
exactly where the "which value survives" rule should be written down for the user. Registration
is one line in DistFiles/Language Explorer/Configuration/UtilityCatalogInclude.xml,
instantiated by reflection; MorphologyEditorDll already ships ParserAnalysisRemover there
(:4), so this is precedented in the project you are already changing.

Either route argues for extracting the populate loop out of BasicIPASymbolSlice. Neither a
utility nor a FixData rule can call a UserControl. If the loop becomes a plain service taking
(LcmCache, IPhPhoneme, XDocument), the repair path has something to call, the slice just calls
it too, and the four behavioural tests stop constructing a WinForms control — which
CreateSlice() currently does in all four, against the standing rule and with no
[Apartment(STA)]. Your tests are 266 of 275 added lines, so extraction is probably the cheaper
path to the same coverage.


This review was assisted by Claude Fable 5.

Move the populate loop into PhonemeFeaturePopulator, a plain service taking
(LcmCache, IPhPhoneme, XDocument). The LT-22716 repair for phonemes already
carrying duplicates needs something to call, and neither a Tools > Utilities
utility nor a FixData rule can call a UserControl, which every Slice is.

The slice keeps the decision of WHEN to write, because that depends on slice
state: the m_justChangedFeatures latch records that this slice populated the
features, which is what lets clearing the symbol clear them again without
discarding features a user set through the chooser. Only the writing moved.

Also expose DuplicatedFeatureIds, so the repair can both find affected phonemes
and report what it fixed, and ClearFeatures, which the slice's clear branch now
calls.

Add four service-level tests that drive the populator directly rather than
constructing a UserControl, proving idempotence unconditionally instead of
through the slice's gate. Removing the fix makes five tests red across both
levels, so the extraction is behaviour-preserving and the new tests are
sensitive rather than decorative.

Give the fixture Apartment(STA). It builds a real Slice, whose rootsite creates
apartment-threaded Views COM objects, and NUnit 3 defaults to MTA. Three
existing slice fixtures in the repo omit this, so it is a latent problem there
too rather than something this branch introduced.

Correct the doc comment on SetFeaturesBasedOnIPA, which was the description
method's summary copy-pasted onto it.
@johnml1135

Copy link
Copy Markdown
Contributor Author

Both points taken, and the extraction is done in c48fbdd8a. Your UserControl
argument was the decisive one, and it holds exactly: BasicIPASymbolSlice : StringSlice → ViewPropertySlice → ViewSlice → Slice : UserControl.

The extraction

PhonemeFeaturePopulator (Src/LexText/Morphology/), a plain service as you
suggested:

  • ApplyFeaturesFromIpaSymbol(cache, phoneme, ipaInfo) — writes the inventory's
    specs for the phoneme's current symbol, updating an existing spec rather than
    adding a second one, and returns how many it wrote.
  • DuplicatedFeatureIds(phoneme) — the features a phoneme specifies more than
    once. A repair can use it both to find affected phonemes and to report what it
    fixed.
  • ClearFeatures(phoneme)

Callers own the unit of work; nothing in there starts one.

What did not move is the decision of when to write. That depends on slice
state: the m_justChangedFeatures latch records that this slice populated the
features, and that is what lets clearing the symbol clear them again without
discarding features a user set through the chooser. A repair has no such state
and wants the unconditional version, which is what the service is.

Where I did not follow you

Two of your three reasons for retargeting the tests hold; one does not.

  • [Apartment(STA)] was genuinely missing. Added — the fixture builds a real
    Slice, whose rootsite creates apartment-threaded Views COM objects, and
    NUnit 3 defaults to MTA.
  • "Against the standing rule" I could not substantiate. I found no such rule
    written down, and DataTreeTests.cs, SliceTests.cs and
    ReversalEntryViewTests.cs all construct slices with no STA either. So this
    PR was following existing practice rather than breaking a rule — which does
    not weaken your UserControl argument, but it is not a second argument.
  • "Extraction is the cheaper path to the same coverage" is not quite right, and
    it is why I added tests rather than moving them. SetSymbol only sets the
    model property; the slice populates through PropChanged. So those four tests
    are integration tests of that wiring, and retargeting them at the service
    would have dropped the wiring coverage — the path the user actually takes.

So the four stayed, and four service-level tests were added that drive the
populator directly: idempotence under repeated application, a symbol change
leaving one spec per feature with the new value, an empty symbol writing
nothing (so a repair cannot blank a phoneme), and DuplicatedFeatureIds
actually reporting an injected duplicate rather than always returning empty.

That is 9 tests where there were 5, and only the pre-existing four construct a
control.

Evidence the extraction did not change behaviour: with GetOrCreateValue
reverted to the pre-fix "always create a new spec", 5 of 9 go red — three of
the original slice tests and both new idempotence tests. The slice tests still
failing is what shows the slice still runs through the fixed path; the service
tests failing is what shows they are sensitive rather than decorative.

LT-22716

Both routes are now written onto the ticket, with the determinism question
stated as the deciding factor, the exemplars and file paths for each, and an
explicit note that Ken Zook should confirm whether this shape fits the
FixData rule set before the deterministic route is chosen. I have not chosen
between them.

One thing I added there that is worth flagging here: re-running
ApplyFeaturesFromIpaSymbol is not by itself a complete repair. It
collapses duplicates for features the inventory names for that symbol, but a
phoneme carrying a duplicate for a feature the symbol does not name, or with
no symbol at all, is untouched. That gap is exactly where your determinism
question bites, so it should be settled before the route is picked rather than
discovered during implementation.

Verification

Debug build clean, comment-hygiene clean. MorphologyEditorDllTests 16/16, up
from 12. I also re-checked the data half of the fix: every SegmentDefinition
in BasicIPAInfo.xml now names each feature at most once — 0 remaining
duplicates, not just the one segment the diff touches.

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.

3 participants