From f902cf8797759ca35ee1d0fa5a30deecc66f996e Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 27 Oct 2026 13:16:59 -0400 Subject: [PATCH] Stop synthesis trying affix allomorphs after the one analysis recorded Analysis records which allomorph of each affix rule it unapplied, and synthesis only keeps output from that allomorph or an earlier one. Later allomorphs were still pattern-matched and built, then discarded. The loop in SynthesisAffixProcessRule.Apply now ends at the recorded allomorph. Co-Authored-By: Claude Opus 5.5 --- .../SynthesisAffixProcessRule.cs | 6 + src/SIL.Machine.Morphology.HermitCrab/Word.cs | 6 + .../AffixProcessRuleTests.cs | 119 ++++++++++++++++++ 3 files changed, 131 insertions(+) diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/SynthesisAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/SynthesisAffixProcessRule.cs index f3191145f..fbea71375 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/SynthesisAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/SynthesisAffixProcessRule.cs @@ -137,8 +137,14 @@ public IEnumerable Apply(Word input) var appliedAllomorphIndices = new HashSet(); var output = new List(); + // Allomorphs after the recorded one can never yield output, so the loop ends there. + int recordedIndex = -1; + if (input.CurrentRecordedAllomorph is AffixProcessAllomorph recorded) + recordedIndex = _rule.Allomorphs.IndexOf(recorded); for (int i = 0; i < _rules.Count; i++) { + if (recordedIndex >= 0 && i > recordedIndex) + break; AffixProcessAllomorph allo = _rule.Allomorphs[i]; MprFeatureGroup group; if ( diff --git a/src/SIL.Machine.Morphology.HermitCrab/Word.cs b/src/SIL.Machine.Morphology.HermitCrab/Word.cs index a1722a18a..aefe26cc9 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Word.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Word.cs @@ -260,6 +260,12 @@ internal bool IsAllomorphApplicable(Allomorph allomorph) return curAllomorph == null || curAllomorph == allomorph; } + /// + /// The allomorph that analysis recorded for the rule now being applied, or null when any allomorph is + /// acceptable or no rule is pending. + /// + internal Allomorph CurrentRecordedAllomorph => _mruleAppIndex < 0 ? null : _allomorphApps[_mruleAppIndex]; + internal bool HasRemainingRulesFromStratum(Stratum stratum) { if (_mruleAppIndex < 0) diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs index 278f46155..f1e6ede38 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs @@ -2088,4 +2088,123 @@ public void IsAllomorphApplicable() Assert.That(output.Count(), Is.EqualTo(1)); Assert.That(output.First().ToString(), Is.EqualTo("sagz")); } + + private AffixProcessRule AddThreeSegmentSuffix( + string seg0, + string env0, + string seg1, + string env1, + string seg2, + string env2 + ) + { + FeatureStruct any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + var sSuffix = new AffixProcessRule + { + Name = "s_suffix", + Gloss = "3SG", + RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value, + }; + foreach ((string seg, string env) in new[] { (seg0, env0), (seg1, env1), (seg2, env2) }) + { + var allomorph = new AffixProcessAllomorph + { + Lhs = { Pattern.New("1").Annotation(any).OneOrMore.Value }, + Rhs = { new CopyFromInput("1"), new InsertSegments(Table3, seg) }, + }; + if (env.Length > 0) + { + allomorph.Environments.Add( + new AllomorphEnvironment( + ConstraintType.Require, + null, + Pattern.New().Annotation(Character(Table3, env)).Value + ) + ); + } + sSuffix.Allomorphs.Add(allomorph); + } + Morphophonemic.MorphologicalRules.Add(sSuffix); + + var edSuffix = new AffixProcessRule + { + Name = "ed_suffix", + Gloss = "PAST", + RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value, + }; + edSuffix.Allomorphs.Add( + new AffixProcessAllomorph + { + Lhs = { Pattern.New("1").Annotation(any).OneOrMore.Value }, + Rhs = { new CopyFromInput("1"), new InsertSegments(Table3, "+ɯd") }, + } + ); + Morphophonemic.MorphologicalRules.Add(edSuffix); + return sSuffix; + } + + private static void CollectSubruleApplications( + Trace trace, + IMorphologicalRule rule, + List<(int Index, int Recorded)> found + ) + { + if ( + trace.Type == TraceType.MorphologicalRuleSynthesis + && trace.Source == rule + && trace.SubruleIndex >= 0 + && trace.Input?.CurrentRecordedAllomorph is AffixProcessAllomorph recorded + ) + { + found.Add((trace.SubruleIndex, ((AffixProcessRule)rule).Allomorphs.IndexOf(recorded))); + } + foreach (Trace child in trace.Children) + CollectSubruleApplications(child, rule, found); + } + + private List<(int Index, int Recorded)> ParseTraced(AffixProcessRule rule, string word, params string[] expected) + { + TraceManager.IsTracing = true; + var morpher = new Morpher(TraceManager, Language); + AssertMorphsEqual(morpher.ParseWord(word, out object trace), expected); + TraceManager.IsTracing = false; + var found = new List<(int Index, int Recorded)>(); + CollectSubruleApplications((Trace)trace, rule, found); + return found; + } + + [Test] + public void SynthesisStopsAtRecordedAllomorph() + { + AffixProcessRule sSuffix = AddThreeSegmentSuffix("s", "ɯ", "z", "a", "z", "a"); + + var applications = ParseTraced(sSuffix, "sagsɯd", "32 3SG PAST"); + + Assert.That(applications, Is.Not.Empty); + Assert.That(applications.Any(a => a.Index == a.Recorded), Is.True); + Assert.That(applications.Any(a => a.Index > a.Recorded), Is.False); + } + + [Test] + public void SynthesisTriesEveryAllomorphUpToRecordedLast() + { + AffixProcessRule sSuffix = AddThreeSegmentSuffix("s", "a", "z", "a", "z", "ɯ"); + + var applications = ParseTraced(sSuffix, "sagzɯd", "32 3SG PAST"); + + Assert.That(applications.Any(a => a.Recorded == 2 && a.Index == 2), Is.True); + Assert.That(applications.Any(a => a.Index > a.Recorded), Is.False); + } + + [Test] + public void EarlierEnvironmentFreeAllomorphStillBlocksLaterOnes() + { + AffixProcessRule sSuffix = AddThreeSegmentSuffix("s", "", "z", "ɯ", "z", "ɯ"); + + // allomorph 0 has no environment, so the disjunctive property rejects the later z allomorphs + var applications = ParseTraced(sSuffix, "sagzɯd"); + + Assert.That(applications.Any(a => a.Index > a.Recorded), Is.False); + ParseTraced(sSuffix, "sagsɯd", "32 3SG PAST"); + } }