From 9f247cd6a0727990896cb5787c74227f1e39cfd8 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 13 Nov 2026 00:48:54 -0500 Subject: [PATCH 1/2] HermitCrab: prune a copy-agreement match whose copy is empty and the other is not Co-Authored-By: Claude Opus 5.5 --- .../AnalysisMorphologicalTransform.cs | 9 ++++++++- .../PruneDisagreeingCopiesTests.cs | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs index ed77b3cb3..d0807e32d 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs @@ -90,7 +90,14 @@ out List> copies { GroupCapture capture = match.GroupCaptures[GetGroupName(partName, i)]; if (!capture.Success) - return false; + { + // an empty capture is a zero-length copy, which cannot equal a non-empty one; but a restored + // deletion (an optional node) might fill it, so such a word stays undecidable + if (match.Input.Shape.Any(node => node.Annotation.Optional)) + return false; + copies.Add(new List()); + continue; + } // an unapplied word-initial deletion sits before the capture, not inside it List nodes = MorphologicalOutputAction diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs index 07314040a..02e3a607f 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs @@ -154,16 +154,29 @@ public void WordInitialOptionalNonSegmentIsNeverPruned() } [Test] - public void EmptyCopyIsNeverPruned() + public void EmptyCopyAgainstANonEmptyCopyIsPruned() { (string first, string second) = FindIncompatibleSegments(); Pattern part = Pattern.New("copy").Annotation(AnySegment).ZeroOrMore.Value; AffixProcessRule rule = CreateCopyRule(part); Word input = CreateInput(first + second); - // the split with an empty copy has nothing to compare, so only the split of two incompatible copies is removed + // "whole word + empty" and "empty + whole word" cannot both be copies of one input, and neither can the + // split of two incompatible halves Assert.That(AnalysisForms(false, rule, input), Is.EquivalentTo(new[] { first, first + second })); - Assert.That(AnalysisForms(true, rule, input), Is.EqualTo(new[] { first + second })); + Assert.That(AnalysisForms(true, rule, input), Is.Empty); + } + + [Test] + public void EmptyCopyIsKeptWhenTheWordHasAnOptionalNode() + { + // a restored deletion could fill the empty copy, so the match stays undecidable + (string first, string second) = FindIncompatibleSegments(); + Pattern part = Pattern.New("copy").Annotation(AnySegment).ZeroOrMore.Value; + AffixProcessRule rule = CreateCopyRule(part); + Word input = CreateInputWithOptionalSecondSegment(first + second); + + Assert.That(AnalysisForms(true, rule, input), Is.EquivalentTo(AnalysisForms(false, rule, input))); } [TestCase(false, 3)] From b8fe5b4d2101aaa93155ae35a414df6dbcc0ca5f Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 13 Nov 2026 00:59:36 -0500 Subject: [PATCH 2/2] Test that a zero-or-more copy rule adds no lexical lookups to words it does not reduplicate Co-Authored-By: Claude Opus 5.5 --- .../PruneDisagreeingCopiesTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs index 02e3a607f..eff3576d7 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs @@ -179,6 +179,21 @@ public void EmptyCopyIsKeptWhenTheWordHasAnOptionalNode() Assert.That(AnalysisForms(true, rule, input), Is.EquivalentTo(AnalysisForms(false, rule, input))); } + [Test] + public void ZeroOrMoreCopyRuleAddsNoSynthesisWorkForAWordItDoesNotReduplicate() + { + // sillsdev/machine#634: the match "whole word + empty copy" used to re-run lookup and synthesis + string[] words = { "pit", "pʰit", "pʰut", "sagd", "sagdv" }; + (int lookups, int syntheses) without = CountSearchWork(words); + + Morphophonemic.MorphologicalRules.Add( + CreateCopyRule(Pattern.New("copy").Annotation(AnySegment).ZeroOrMore.Value) + ); + + Assert.That(without.syntheses, Is.GreaterThan(0)); + Assert.That(CountSearchWork(words), Is.EqualTo(without)); + } + [TestCase(false, 3)] [TestCase(true, 1)] public void RealizationalFullCopyKeepsOnlyTheSplitWhoseCopiesAgree(bool pruneCopies, int expectedOutputs) @@ -563,6 +578,32 @@ private RewriteRule CreateBetweenVowelsRewrite(CharacterDefinitionTable table, s return rule; } + private (int Lookups, int Syntheses) CountSearchWork(string[] words) + { + var morpher = new Morpher(new TraceManager { IsTracing = true, RetainWords = false }, Language, 1); + int lookups = 0; + int syntheses = 0; + foreach (string word in words) + { + morpher.ParseWord(word, out object trace); + foreach (Trace node in Flatten((Trace)trace)) + { + if (node.Type == TraceType.LexicalLookup) + lookups++; + else if (node.Type == TraceType.WordSynthesis) + syntheses++; + } + } + return (lookups, syntheses); + } + + private static IEnumerable Flatten(Trace trace) + { + yield return trace; + foreach (Trace descendant in trace.Children.SelectMany(Flatten)) + yield return descendant; + } + private Morpher CreateMorpher(bool pruneCopies, int maxDegreeOfParallelism = 0) { return new Morpher(TraceManager, Language, maxDegreeOfParallelism) { PruneDisagreeingCopies = pruneCopies };