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..eff3576d7 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs @@ -154,16 +154,44 @@ 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))); + } + + [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)] @@ -550,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 };