From 620c02eab3eb38940f11743b4b858d6066f9f090 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 24 Sep 2026 11:02:34 -0400 Subject: [PATCH 1/4] Prune reduplication matches whose copies cannot agree When unapplying an affix process rule that copies a part more than once, Morpher.PruneDisagreeingCopies (off by default) skips matches whose copies cannot unify segment by segment. Synthesis writes every copy from the same input, so such a match never survives synthesis. Copies containing an optional node or modified by the rule are always kept. Co-Authored-By: Claude Opus 5.5 --- .../Morpher.cs | 8 ++ .../AnalysisAffixProcessRule.cs | 3 +- .../AnalysisMorphologicalTransform.cs | 76 ++++++++++++++++++ .../AnalysisRealizationalAffixProcessRule.cs | 3 +- .../CopyAgreementPatternRule.cs | 38 +++++++++ .../MorphologicalOutputAction.cs | 5 ++ .../AffixProcessRuleTests.cs | 28 ++++--- .../CopyAgreementPruneTests.cs | 79 +++++++++++++++++++ 8 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/CopyAgreementPatternRule.cs create mode 100644 tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs index f55be0b49..37ec9387d 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs @@ -88,6 +88,14 @@ public ITraceManager TraceManager /// public bool MergeEquivalentAnalyses { get; set; } + /// + /// When unapplying a rule that copies a part more than once (reduplication), skip matches whose + /// copies cannot unify segment by segment. Such a match can never survive synthesis, so this removes + /// only doomed analyses; on reduplication-heavy grammars it is the difference between a parse that + /// exhausts memory and one that finishes in seconds. + /// + public bool PruneDisagreeingCopies { get; set; } + /// /// Caps the concurrency used within a single parse or generation -- analysis cascade, /// affix-template unapplication and synthesis alike. A value of 1 runs the work fully diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs index 52e57be3d..3d0ed466a 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs @@ -29,7 +29,8 @@ public AnalysisAffixProcessRule(Morpher morpher, AffixProcessRule rule) foreach (AffixProcessAllomorph allo in rule.Allomorphs) { _rules.Add( - new MultiplePatternRule( + new CopyAgreementPatternRule( + morpher, new AnalysisAffixProcessAllomorphRuleSpec(allo), new MatcherSettings { diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs index 93e7a0ef6..90948926d 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs @@ -46,6 +46,82 @@ protected IDictionary CapturedParts get { return _capturedParts; } } + internal bool HasRepeatedParts + { + get { return _capturedParts.Values.Any(count => count >= 2); } + } + + /// + /// Synthesis writes every copy of a part from the same input, and anything that later changes one + /// copy is unapplied before this rule, so copies proven to disagree segment by segment cannot lead + /// to a valid analysis. A copy containing an optional node (an unapplied deletion), or a part the + /// rule modifies, cannot be judged and never counts as disagreeing. + /// + internal bool HasDisagreeingCopies(Match match) + { + foreach (KeyValuePair capturedPart in _capturedParts) + { + string partName = capturedPart.Key; + int copyCount = capturedPart.Value; + if (copyCount < 2) + continue; + + if (_modifyFromInfos.ContainsKey(partName)) + continue; + + var copies = new List>(copyCount); + bool partUndecidable = false; + for (int i = 0; i < copyCount; i++) + { + GroupCapture capture = match.GroupCaptures[GetGroupName(partName, i)]; + if (!capture.Success) + { + partUndecidable = true; + break; + } + + List nodes = GetCapturedNodes(match.Input.Shape, capture.Range); + if (nodes.Any(node => node.Annotation.Optional)) + { + partUndecidable = true; + break; + } + + copies.Add(nodes.Where(node => node.Annotation.Type() == HCFeatureSystem.Segment).ToList()); + } + + if (partUndecidable) + continue; + + List firstCopy = copies[0]; + for (int i = 1; i < copies.Count; i++) + { + List otherCopy = copies[i]; + if (firstCopy.Count != otherCopy.Count) + return true; + + for (int j = 0; j < firstCopy.Count; j++) + { + if (!firstCopy[j].Annotation.FeatureStruct.IsUnifiable(otherCopy[j].Annotation.FeatureStruct)) + return true; + } + } + } + + return false; + } + + /// + /// Includes optional nodes the matcher skipped before the capture when they reach the start of the + /// shape, so an unapplied word-initial deletion is still seen. + /// + private static List GetCapturedNodes(Shape shape, Range range) + { + var nodes = new List(MorphologicalOutputAction.SkippedOptionalNodes(shape, range)); + nodes.AddRange(shape.GetNodes(range)); + return nodes; + } + public Pattern Pattern { get { return _pattern; } diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs index 749aa7b4d..cf4281c70 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs @@ -22,7 +22,8 @@ public AnalysisRealizationalAffixProcessRule(Morpher morpher, RealizationalAffix foreach (AffixProcessAllomorph allo in rule.Allomorphs) { _rules.Add( - new MultiplePatternRule( + new CopyAgreementPatternRule( + morpher, new AnalysisAffixProcessAllomorphRuleSpec(allo), new MatcherSettings { diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/CopyAgreementPatternRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/CopyAgreementPatternRule.cs new file mode 100644 index 000000000..58d5cf58e --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/CopyAgreementPatternRule.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using SIL.Machine.Annotations; +using SIL.Machine.Matching; +using SIL.Machine.Rules; + +namespace SIL.Machine.Morphology.HermitCrab.MorphologicalRules +{ + internal sealed class CopyAgreementPatternRule : MultiplePatternRule + { + private readonly Morpher _morpher; + private readonly AnalysisAffixProcessAllomorphRuleSpec _spec; + + public CopyAgreementPatternRule( + Morpher morpher, + AnalysisAffixProcessAllomorphRuleSpec ruleSpec, + MatcherSettings matcherSettings + ) + : base(ruleSpec, matcherSettings) + { + _morpher = morpher; + _spec = ruleSpec; + } + + protected override IEnumerable ApplyImpl(Word input, ShapeNode start) + { + bool prune = _morpher.PruneDisagreeingCopies && _spec.HasRepeatedParts; + var results = new List(); + foreach (Match match in Matcher.AllMatches(input, start)) + { + if (prune && _spec.HasDisagreeingCopies(match)) + continue; + + results.Add(RuleSpec.ApplyRhs(this, match)); + } + return results; + } + } +} diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs index 5595a232f..508a458c0 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs @@ -38,6 +38,11 @@ IDictionary capturedParts public abstract IEnumerable> Apply(Match match, Word output); protected IEnumerable GetSkippedOptionalNodes(Shape shape, Range range) + { + return SkippedOptionalNodes(shape, range); + } + + internal static IEnumerable SkippedOptionalNodes(Shape shape, Range range) { ShapeNode node = range.Start.Prev; var skippedNodes = new List(); diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs index 329074617..579d997db 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs @@ -965,8 +965,9 @@ public void SimulfixRules() AssertMorphsEqual(morpher.ParseWord("sɯɯpu"), "50 SIMUL"); } - [Test] - public void ReduplicationRules() + [TestCase(false)] + [TestCase(true)] + public void ReduplicationRules(bool pruneCopies) { var any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; var cons = FeatureStruct @@ -1015,7 +1016,7 @@ public void ReduplicationRules() ); Morphophonemic.MorphologicalRules.Add(redup); - var morpher = new Morpher(TraceManager, Language); + var morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("sasag"), "RED 32"); var voicing = new RewriteRule @@ -1033,7 +1034,7 @@ public void ReduplicationRules() ); Allophonic.PhonologicalRules.Add(voicing); - morpher = new Morpher(TraceManager, Language); + morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("sazag"), "RED 32"); var affrication = new RewriteRule @@ -1050,7 +1051,7 @@ public void ReduplicationRules() ); Allophonic.PhonologicalRules.Add(affrication); - morpher = new Morpher(TraceManager, Language); + morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("tsazag"), "RED 32"); redup.Allomorphs.Clear(); @@ -1066,7 +1067,7 @@ public void ReduplicationRules() } ); - morpher = new Morpher(TraceManager, Language); + morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("tsagag"), "32 RED"); Allophonic.PhonologicalRules.Clear(); @@ -1084,7 +1085,7 @@ public void ReduplicationRules() } ); - morpher = new Morpher(TraceManager, Language); + morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("sagsag"), "32 RED"); AssertMorphsEqual(morpher.ParseWord("sasibudbud"), "38 RED"); @@ -1115,7 +1116,7 @@ public void ReduplicationRules() ); Allophonic.PhonologicalRules.Add(gDelete); - morpher = new Morpher(TraceManager, Language); + morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("saag"), "32 RED"); gDelete.Subrules.Clear(); @@ -1126,7 +1127,7 @@ public void ReduplicationRules() } ); - morpher = new Morpher(TraceManager, Language); + morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("saga"), "32 RED"); Allophonic.PhonologicalRules.Clear(); @@ -1152,7 +1153,7 @@ public void ReduplicationRules() } ); - morpher = new Morpher(TraceManager, Language); + morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("buiuib"), "30 RED", "31 RED"); } @@ -1900,8 +1901,9 @@ public void SubsumedAffix() AssertMorphsEqual(morpher.ParseWord("bubib"), "42 PRES", "43 PRES"); } - [Test] - public void ModifyFromInputRules() + [TestCase(false)] + [TestCase(true)] + public void ModifyFromInputRules(bool pruneCopies) { var any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; var vowel = FeatureStruct @@ -1941,7 +1943,7 @@ public void ModifyFromInputRules() ); Morphophonemic.MorphologicalRules.Add(sSuffix); - var morpher = new Morpher(TraceManager, Language); + var morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; AssertMorphsEqual(morpher.ParseWord("puso"), "52 PL"); } diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs new file mode 100644 index 000000000..62206d6ca --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs @@ -0,0 +1,79 @@ +using NUnit.Framework; +using SIL.Machine.Annotations; +using SIL.Machine.FeatureModel; +using SIL.Machine.Matching; + +namespace SIL.Machine.Morphology.HermitCrab.MorphologicalRules; + +[TestFixture] +public class CopyAgreementPruneTests : HermitCrabTestBase +{ + [TestCase(false, 3)] + [TestCase(true, 1)] + public void FullCopyKeepsOnlyTheSplitWhoseCopiesAgree(bool pruneCopies, int expectedOutputs) + { + (string first, string second) = FindIncompatibleSegments(); + var anySegment = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + AffixProcessRule rule = CreateCopyRule( + Pattern.New("copy").Annotation(anySegment).OneOrMore.Value + ); + var morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; + + List outputs = new AnalysisAffixProcessRule(morpher, rule) + .Apply(CreateInput(first + second + first + second)) + .ToList(); + + Assert.That(outputs.Count, Is.EqualTo(expectedOutputs)); + if (pruneCopies) + Assert.That(outputs.Single().Shape.Count, Is.EqualTo(2)); + } + + [TestCase(false, 1)] + [TestCase(true, 0)] + public void DisagreeingCopiesAreRemovedOnlyWhenPruning(bool pruneCopies, int expectedOutputs) + { + (string first, string second) = FindIncompatibleSegments(); + var anySegment = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + AffixProcessRule rule = CreateCopyRule(Pattern.New("copy").Annotation(anySegment).Value); + var morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies }; + + List outputs = new AnalysisAffixProcessRule(morpher, rule).Apply(CreateInput(first + second)).ToList(); + + Assert.That(outputs.Count, Is.EqualTo(expectedOutputs)); + } + + private static AffixProcessRule CreateCopyRule(Pattern part) + { + var rule = new AffixProcessRule + { + Name = "full_copy", + Gloss = "RED", + RequiredSyntacticFeatureStruct = FeatureStruct.New().Value, + OutSyntacticFeatureStruct = FeatureStruct.New().Value, + }; + rule.Allomorphs.Add( + new AffixProcessAllomorph { Lhs = { part }, Rhs = { new CopyFromInput("copy"), new CopyFromInput("copy") } } + ); + return rule; + } + + private (string First, string Second) FindIncompatibleSegments() + { + CharacterDefinition[] segments = Table3 + .Where(definition => definition.Type == HCFeatureSystem.Segment) + .ToArray(); + var pair = segments + .SelectMany( + (first, index) => segments.Skip(index + 1).Select(second => new { First = first, Second = second }) + ) + .First(candidate => !candidate.First.FeatureStruct.IsUnifiable(candidate.Second.FeatureStruct)); + return (pair.First.Representations.First(), pair.Second.Representations.First()); + } + + private Word CreateInput(string representation) + { + var input = new Word(Surface, Table3.Segment(representation)) { AnalysisScope = new AnalysisScope() }; + input.Freeze(); + return input; + } +} From a7daad0a2478627194785db633f364575349d288 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 24 Sep 2026 13:25:40 -0400 Subject: [PATCH 2/4] Pin copy pruning against a cap-limited deletion in one copy A self-feeding deletion strips two segments from the second copy only; with DeletionReapplications 0 analysis restores one. Forward generation defines the expected parse, and pruning must return the unpruned result. Co-Authored-By: Claude Opus 5.5 --- .../CopyAgreementPruneTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs index 62206d6ca..bfc421488 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs @@ -2,6 +2,7 @@ using SIL.Machine.Annotations; using SIL.Machine.FeatureModel; using SIL.Machine.Matching; +using SIL.Machine.Morphology.HermitCrab.PhonologicalRules; namespace SIL.Machine.Morphology.HermitCrab.MorphologicalRules; @@ -42,6 +43,73 @@ public void DisagreeingCopiesAreRemovedOnlyWhenPruning(bool pruneCopies, int exp Assert.That(outputs.Count, Is.EqualTo(expectedOutputs)); } + /// + /// The prune's weakest point: a self-feeding deletion strips two segments from the second copy only, + /// and = 0 lets analysis restore just one of them. + /// + [TestCase(0)] + [TestCase(1)] + public void CapLimitedDeletionInOneCopyParsesTheSameWithAndWithoutPruning(int deletionReapplications) + { + var any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + var vowel = FeatureStruct + .New(Language.PhonologicalFeatureSystem) + .Symbol(HCFeatureSystem.Segment) + .Symbol("voc+") + .Value; + AffixProcessRule redup = CreateCopyRule(Pattern.New("copy").Annotation(any).OneOrMore.Value); + redup.RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value; + Morphophonemic.MorphologicalRules.Add(redup); + + var gDelete = new RewriteRule + { + Name = "g_delete", + Lhs = Pattern.New().Annotation(Character(Table1, "g")).Value, + }; + gDelete.Subrules.Add( + new RewriteSubrule { LeftEnvironment = Pattern.New().Annotation(vowel).Value } + ); + Allophonic.PhonologicalRules.Add(gDelete); + + LexEntry root = AddEntry( + "GG", + FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value, + Morphophonemic, + "ggasa" + ); + try + { + var generator = new Morpher(TraceManager, Language); + Assert.That( + generator.GenerateWords(root, new Morpheme[] { redup }, FeatureStruct.New().Value), + Is.EquivalentTo(new[] { "ggasaasa" }), + "forward derivation defines the expected parse" + ); + + string[] Parse(bool pruneCopies) + { + var morpher = new Morpher(TraceManager, Language) + { + DeletionReapplications = deletionReapplications, + PruneDisagreeingCopies = pruneCopies, + }; + return morpher + .ParseWord("ggasaasa") + .Select(w => string.Join(" ", w.AllomorphsInMorphOrder.Select(a => a.Morpheme.Gloss))) + .OrderBy(s => s, StringComparer.Ordinal) + .ToArray(); + } + + string[] unpruned = Parse(false); + Assert.That(unpruned, Does.Contain("GG RED"), "the unpruned engine finds the generated analysis"); + Assert.That(Parse(true), Is.EqualTo(unpruned)); + } + finally + { + Morphophonemic.Entries.Remove(root); + } + } + private static AffixProcessRule CreateCopyRule(Pattern part) { var rule = new AffixProcessRule From d73fc10de8dab890ff5041519714f0d530c8a30c Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 24 Sep 2026 21:46:20 -0400 Subject: [PATCH 3/4] Turn copy-agreement pruning on by default Co-Authored-By: Claude Opus 5.5 --- src/SIL.Machine.Morphology.HermitCrab/Morpher.cs | 4 ++-- .../MorphologicalRules/CopyAgreementPruneTests.cs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs index 37ec9387d..b972790ad 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs @@ -61,6 +61,7 @@ public Morpher(ITraceManager traceManager, Language lang, int maxDegreeOfParalle MaxStemCount = 2; MaxAlternatives = 0; MergeEquivalentAnalyses = true; + PruneDisagreeingCopies = true; LexEntrySelector = entry => true; RuleSelector = rule => true; @@ -91,8 +92,7 @@ public ITraceManager TraceManager /// /// When unapplying a rule that copies a part more than once (reduplication), skip matches whose /// copies cannot unify segment by segment. Such a match can never survive synthesis, so this removes - /// only doomed analyses; on reduplication-heavy grammars it is the difference between a parse that - /// exhausts memory and one that finishes in seconds. + /// only doomed analyses. On by default. /// public bool PruneDisagreeingCopies { get; set; } diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs index bfc421488..5f9a5d74c 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/CopyAgreementPruneTests.cs @@ -9,6 +9,12 @@ namespace SIL.Machine.Morphology.HermitCrab.MorphologicalRules; [TestFixture] public class CopyAgreementPruneTests : HermitCrabTestBase { + [Test] + public void PruningIsOnByDefault() + { + Assert.That(new Morpher(TraceManager, Language).PruneDisagreeingCopies, Is.True); + } + [TestCase(false, 3)] [TestCase(true, 1)] public void FullCopyKeepsOnlyTheSplitWhoseCopiesAgree(bool pruneCopies, int expectedOutputs) From 19a82cc8c2bc34ac22992f58b89d43e4c9fe46d8 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 25 Sep 2026 18:41:03 -0400 Subject: [PATCH 4/4] Compute HasRepeatedParts once per copy-agreement rule Rules that copy no part twice no longer rescan their captured parts on every unapplication. Mirrors PanGloss 77aca87f, which measured 0.62-0.64x of the prior time on Aweti with identical analyses. Co-Authored-By: Claude Opus 5.5 --- .../MorphologicalRules/CopyAgreementPatternRule.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/CopyAgreementPatternRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/CopyAgreementPatternRule.cs index 58d5cf58e..78abce1ff 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/CopyAgreementPatternRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/CopyAgreementPatternRule.cs @@ -9,6 +9,7 @@ internal sealed class CopyAgreementPatternRule : MultiplePatternRule matcherSettings { _morpher = morpher; _spec = ruleSpec; + _hasRepeatedParts = ruleSpec.HasRepeatedParts; } protected override IEnumerable ApplyImpl(Word input, ShapeNode start) { - bool prune = _morpher.PruneDisagreeingCopies && _spec.HasRepeatedParts; + bool prune = _morpher.PruneDisagreeingCopies && _hasRepeatedParts; var results = new List(); foreach (Match match in Matcher.AllMatches(input, start)) {