From 307b778086eb1dedfb8e1c460c3b52a86f62bfb9 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 23 Oct 2026 12:27:11 -0400 Subject: [PATCH 1/3] Keep final-template pruning on when only lexical entries are partial The analysis-side final-template prune (491) was switched off whenever Morpher.IsPartial was true, and IsPartial counts partial lexical entries. Grammars with partial entries but no partial rules therefore never pruned. A partial entry cannot make the prune lose a parse. A word takes its partiality from its root, but SynthesisAffixTemplatesRule applies no template to a word whose root morpheme is partial, so a derivation that unapplies a final template can never end at a partial root. Partial rules differ: they make a word partial after the template has applied, which lets a non-partial rule follow the final template, so they still disable the prune. Add Morpher.HasPartialRules and read it in the two analysis guards. IsPartial keeps its meaning. Lexical-pattern guesses were already excluded from IsPartial and pruned. Closes #551 Co-Authored-By: Claude Opus 5.5 --- .../AnalysisAffixTemplateRule.cs | 4 +- .../Morpher.cs | 9 +++ .../AnalysisAffixProcessRule.cs | 2 +- .../AffixTemplateTests.cs | 65 +++++++++++++++++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs b/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs index 9044a8488..407c7fdb1 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs @@ -41,10 +41,10 @@ public IEnumerable Apply(Word input) if (_morpher.TraceManager.IsTracing) _morpher.TraceManager.BeginUnapplyTemplate(_template, input); - // Do not allow a final template to unapply if we don't need to worry about partials + // Do not allow a final template to unapply if we don't need to worry about partial rules // and a non-template was last unapplied. if ( - (!_morpher.IsPartial || _morpher.AlwaysEnforceFinalTemplates) + (!_morpher.HasPartialRules || _morpher.AlwaysEnforceFinalTemplates) && input.FinalTemplateState == FinalTemplateState.NonTemplate && _template.IsFinal ) diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs index d0675509e..e159b6aa6 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs @@ -63,6 +63,7 @@ public Morpher(ITraceManager traceManager, Language lang, int maxDegreeOfParalle _morphemes = new ReadOnlyObservableCollection(morphemes); IsPartial = GetPartialMorphemes().Count() > 0; + HasPartialRules = GetPartialMorphemes().Any(morpheme => !(morpheme is LexEntry)); } public IEnumerable GetPartialMorphemes() @@ -102,6 +103,14 @@ public ITraceManager TraceManager /// public bool IsPartial { get; } + /// + /// True if any rule is partial, ignoring lexical entries. Analysis may prune a final template that follows + /// a non-template rule only when this is false. A partial lexical entry does not matter here: + /// SynthesisAffixTemplatesRule applies no template to a word whose root is partial, so a + /// derivation that unapplies a final template can never end at a partial root. + /// + public bool HasPartialRules { get; } + /// /// Enforce final templates even if some of the morphemes were partial. /// diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs index 7e841b1fb..f7f018607 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs @@ -80,7 +80,7 @@ out FeatureStruct syntacticFS outWord.SyntacticFeatureStruct = syntacticFS; } - if (!_morpher.IsPartial || _morpher.AlwaysEnforceFinalTemplates) + if (!_morpher.HasPartialRules || _morpher.AlwaysEnforceFinalTemplates) { if (!_rule.GetIsCliticRule()) { diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs index 5281410a8..165496c1b 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs @@ -501,6 +501,71 @@ public void EarlyPruningOfFinalTemplate() AssertMorphsEqual(morpher.ParseWord("sagdmis"), "32 PAST 53 PL"); } + [Test] + public void PartialLexicalEntryDoesNotDisableEarlyPruningOfFinalTemplate() + { + var any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + var verb = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value; + + AffixProcessRule CreateSuffix(string name, string gloss, string suffix) + { + var rule = new AffixProcessRule + { + Name = name, + Gloss = gloss, + RequiredSyntacticFeatureStruct = verb.Clone(), + }; + rule.Allomorphs.Add( + new AffixProcessAllomorph + { + Lhs = { Pattern.New("1").Annotation(any).OneOrMore.Value }, + Rhs = { new CopyFromInput("1"), new InsertSegments(Table3, suffix) }, + } + ); + return rule; + } + + var edSuffix = CreateSuffix("ed_suffix", "PAST", "d"); + var verbTemplate = new AffixTemplate { Name = "verb", RequiredSyntacticFeatureStruct = verb.Clone() }; + verbTemplate.Slots.Add(new AffixTemplateSlot(edSuffix)); + Morphophonemic.AffixTemplates.Add(verbTemplate); + var nominalizer = CreateSuffix("nominalizer", "NOM", "v"); + Morphophonemic.MorphologicalRules.Add(nominalizer); + AddEntry("partial_root", verb.Clone(), Morphophonemic, "sab").IsPartial = true; + + // The test grammar already has a partial entry, so only the rules decide the prune. + var morpher = new Morpher(TraceManager, Language); + Assert.That(morpher.IsPartial, Is.True); + Assert.That(morpher.HasPartialRules, Is.False); + Assert.That(morpher.AlwaysEnforceFinalTemplates, Is.False); + TraceManager.IsTracing = true; + AssertMorphsEqual(morpher.ParseWord("sagdv", out object trace)); + Assert.That( + GetFailureDepth((Trace)trace, FailureReason.NonPartialRuleProhibitedAfterFinalTemplate), + Is.EqualTo(2) + ); + TraceManager.IsTracing = false; + AssertMorphsEqual(morpher.ParseWord("sagd"), "32 PAST"); + + // No template applies to a partial root, so pruning loses nothing for it. + AssertMorphsEqual(morpher.ParseWord("sab"), "partial_root"); + AssertMorphsEqual(morpher.ParseWord("sabd")); + AssertMorphsEqual(morpher.ParseWord("sabdv")); + AssertMorphsEqual(morpher.ParseWord("sabv"), "partial_root NOM"); + + // A partial rule still turns the prune off, and what it licenses after a final template survives. + nominalizer.IsPartial = true; + morpher = new Morpher(TraceManager, Language); + Assert.That(morpher.HasPartialRules, Is.True); + TraceManager.IsTracing = true; + AssertMorphsEqual(morpher.ParseWord("sagdv", out trace), "32 PAST NOM"); + Assert.That( + GetFailureDepth((Trace)trace, FailureReason.NonPartialRuleProhibitedAfterFinalTemplate), + Is.EqualTo(0) + ); + TraceManager.IsTracing = false; + } + private static int GetFailureDepth(Trace trace, FailureReason reason) { if (trace == null) From aed14fd50b108748b09ec014eec93511e1c52511 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 26 Oct 2026 07:49:00 -0400 Subject: [PATCH 2/3] Correct HasPartialRules doc and clean up partial entry in test The summary claimed a derivation that unapplies a final template can never end at a partial root. SynthesisAffixTemplatesRule.Apply calls ChooseInflectionalStem before the root check, so a partial root can take a template through a non-partial family relative. State the contract and why the prune stays sound. The test left the partial "sab" entry in the shared Language; remove it in a finally block. Co-Authored-By: Claude Opus 5.5 --- .../Morpher.cs | 9 ++- .../AffixTemplateTests.cs | 74 ++++++++++--------- 2 files changed, 46 insertions(+), 37 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs index e159b6aa6..374fb1c76 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs @@ -104,10 +104,11 @@ public ITraceManager TraceManager public bool IsPartial { get; } /// - /// True if any rule is partial, ignoring lexical entries. Analysis may prune a final template that follows - /// a non-template rule only when this is false. A partial lexical entry does not matter here: - /// SynthesisAffixTemplatesRule applies no template to a word whose root is partial, so a - /// derivation that unapplies a final template can never end at a partial root. + /// True if any morpheme other than a is partial. Analysis may prune a final + /// template that follows a non-template rule only when this is false. Partial lexical entries do not + /// count: applies no template to a word whose root is partial, + /// and when it substitutes an inflectional-family relative for the root, the relative's word is + /// non-partial, so a following non-partial rule is blocked either way. /// public bool HasPartialRules { get; } diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs index 165496c1b..677b4eeac 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs @@ -531,39 +531,47 @@ AffixProcessRule CreateSuffix(string name, string gloss, string suffix) Morphophonemic.AffixTemplates.Add(verbTemplate); var nominalizer = CreateSuffix("nominalizer", "NOM", "v"); Morphophonemic.MorphologicalRules.Add(nominalizer); - AddEntry("partial_root", verb.Clone(), Morphophonemic, "sab").IsPartial = true; - - // The test grammar already has a partial entry, so only the rules decide the prune. - var morpher = new Morpher(TraceManager, Language); - Assert.That(morpher.IsPartial, Is.True); - Assert.That(morpher.HasPartialRules, Is.False); - Assert.That(morpher.AlwaysEnforceFinalTemplates, Is.False); - TraceManager.IsTracing = true; - AssertMorphsEqual(morpher.ParseWord("sagdv", out object trace)); - Assert.That( - GetFailureDepth((Trace)trace, FailureReason.NonPartialRuleProhibitedAfterFinalTemplate), - Is.EqualTo(2) - ); - TraceManager.IsTracing = false; - AssertMorphsEqual(morpher.ParseWord("sagd"), "32 PAST"); - - // No template applies to a partial root, so pruning loses nothing for it. - AssertMorphsEqual(morpher.ParseWord("sab"), "partial_root"); - AssertMorphsEqual(morpher.ParseWord("sabd")); - AssertMorphsEqual(morpher.ParseWord("sabdv")); - AssertMorphsEqual(morpher.ParseWord("sabv"), "partial_root NOM"); - - // A partial rule still turns the prune off, and what it licenses after a final template survives. - nominalizer.IsPartial = true; - morpher = new Morpher(TraceManager, Language); - Assert.That(morpher.HasPartialRules, Is.True); - TraceManager.IsTracing = true; - AssertMorphsEqual(morpher.ParseWord("sagdv", out trace), "32 PAST NOM"); - Assert.That( - GetFailureDepth((Trace)trace, FailureReason.NonPartialRuleProhibitedAfterFinalTemplate), - Is.EqualTo(0) - ); - TraceManager.IsTracing = false; + var partialRoot = AddEntry("partial_root", verb.Clone(), Morphophonemic, "sab"); + partialRoot.IsPartial = true; + try + { + // The test grammar already has a partial entry, so only the rules decide the prune. + var morpher = new Morpher(TraceManager, Language); + Assert.That(morpher.IsPartial, Is.True); + Assert.That(morpher.HasPartialRules, Is.False); + Assert.That(morpher.AlwaysEnforceFinalTemplates, Is.False); + TraceManager.IsTracing = true; + AssertMorphsEqual(morpher.ParseWord("sagdv", out object trace)); + Assert.That( + GetFailureDepth((Trace)trace, FailureReason.NonPartialRuleProhibitedAfterFinalTemplate), + Is.EqualTo(2) + ); + TraceManager.IsTracing = false; + AssertMorphsEqual(morpher.ParseWord("sagd"), "32 PAST"); + + // No template applies to a partial root, so pruning loses nothing for it. + AssertMorphsEqual(morpher.ParseWord("sab"), "partial_root"); + AssertMorphsEqual(morpher.ParseWord("sabd")); + AssertMorphsEqual(morpher.ParseWord("sabdv")); + AssertMorphsEqual(morpher.ParseWord("sabv"), "partial_root NOM"); + + // A partial rule still turns the prune off, and what it licenses after a final template survives. + nominalizer.IsPartial = true; + morpher = new Morpher(TraceManager, Language); + Assert.That(morpher.HasPartialRules, Is.True); + TraceManager.IsTracing = true; + AssertMorphsEqual(morpher.ParseWord("sagdv", out trace), "32 PAST NOM"); + Assert.That( + GetFailureDepth((Trace)trace, FailureReason.NonPartialRuleProhibitedAfterFinalTemplate), + Is.EqualTo(0) + ); + TraceManager.IsTracing = false; + } + finally + { + Morphophonemic.Entries.Remove(partialRoot); + Entries.Remove("partial_root"); + } } private static int GetFailureDepth(Trace trace, FailureReason reason) From b507a89122ff94e2e9ee9d678151fec8c5732c79 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 27 Oct 2026 09:37:56 -0400 Subject: [PATCH 3/3] Remove Morpher.IsPartial, superseded by HasPartialRules Nothing reads it now that the analysis-side final-template checks use HasPartialRules. The three test preconditions assert GetPartialMorphemes() directly. Co-Authored-By: Claude Haiku 5.5 --- src/SIL.Machine.Morphology.HermitCrab/Morpher.cs | 8 -------- .../AffixTemplateTests.cs | 4 ++-- .../MorphologicalRules/PruneDisagreeingCopiesTests.cs | 6 +++++- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs index 374fb1c76..2867ea6cf 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs @@ -62,7 +62,6 @@ public Morpher(ITraceManager traceManager, Language lang, int maxDegreeOfParalle RuleSelector = rule => true; _morphemes = new ReadOnlyObservableCollection(morphemes); - IsPartial = GetPartialMorphemes().Count() > 0; HasPartialRules = GetPartialMorphemes().Any(morpheme => !(morpheme is LexEntry)); } @@ -98,11 +97,6 @@ public ITraceManager TraceManager /// public bool MergeEquivalentAnalyses { get; set; } - /// - /// A Morpher is partial if any of the elements are partial. - /// - public bool IsPartial { get; } - /// /// True if any morpheme other than a is partial. Analysis may prune a final /// template that follows a non-template rule only when this is false. Partial lexical entries do not @@ -501,8 +495,6 @@ private IEnumerable LexicalGuess(Word input) SyntacticFeatureStruct = input.SyntacticFeatureStruct, Stratum = input.Stratum, }; - // Don't update Morpher.IsPartial even if lexEntry.IsPartial is true. - // We don't mind if final templates are filtered during analysis in this case. lexEntry.Allomorphs.Add(root); // Point the root allomorph to the lexical pattern in FieldWorks. if (lexicalPattern.Morpheme != null) diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs index 677b4eeac..83498f566 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AffixTemplateTests.cs @@ -473,7 +473,7 @@ public void EarlyPruningOfFinalTemplate() // Verify early pruning of final template. var morpher = new Morpher(TraceManager, Language); - Assert.That(morpher.IsPartial, Is.True); + Assert.That(morpher.GetPartialMorphemes(), Is.Not.Empty); morpher.AlwaysEnforceFinalTemplates = true; TraceManager.IsTracing = true; AssertMorphsEqual(morpher.ParseWord("sagdv", out object trace)); @@ -537,7 +537,7 @@ AffixProcessRule CreateSuffix(string name, string gloss, string suffix) { // The test grammar already has a partial entry, so only the rules decide the prune. var morpher = new Morpher(TraceManager, Language); - Assert.That(morpher.IsPartial, Is.True); + Assert.That(morpher.GetPartialMorphemes(), Is.Not.Empty); Assert.That(morpher.HasPartialRules, Is.False); Assert.That(morpher.AlwaysEnforceFinalTemplates, Is.False); TraceManager.IsTracing = true; diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs index ffd6fd9f8..07314040a 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs @@ -228,7 +228,11 @@ public void PartialMorphemesParseTheSameWithAndWithoutPruning(bool alwaysEnforce string[] Parse(bool pruneCopies, string word) { Morpher morpher = CreateMorpher(pruneCopies); - Assert.That(morpher.IsPartial, Is.True, "entry 54 has an empty syntactic feature structure"); + Assert.That( + morpher.GetPartialMorphemes(), + Is.Not.Empty, + "entry 54 has an empty syntactic feature structure" + ); morpher.AlwaysEnforceFinalTemplates = alwaysEnforceFinalTemplates; return ParseGlosses(morpher, word); }