diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs index 1cddacd0a..73c6007d2 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; @@ -110,6 +111,13 @@ public ITraceManager TraceManager /// public bool AlwaysEnforceFinalTemplates { get; set; } + /// + /// When unapplying an affix-process or compounding 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 by default, including while tracing. + /// + 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/AnalysisAffixProcessAllomorphRuleSpec.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs index 9ce94c148..895f9eb18 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs @@ -8,10 +8,13 @@ public class AnalysisAffixProcessAllomorphRuleSpec : AnalysisMorphologicalTransf { private readonly AffixProcessAllomorph _allomorph; - public AnalysisAffixProcessAllomorphRuleSpec(AffixProcessAllomorph allomorph) - : base(allomorph.Lhs, allomorph.Rhs) + public AnalysisAffixProcessAllomorphRuleSpec(AffixProcessAllomorph allomorph, Morpher morpher = null) + : base(allomorph.Lhs, allomorph.Rhs, morpher) { _allomorph = allomorph; + // the matcher builds a Match for every candidate it tests, so rules that copy nothing get no check + if (HasRepeatedParts) + Pattern.Acceptable = CopiesMayAgree; Pattern.Freeze(); } diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs index 11980053c..d22149d32 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs @@ -30,7 +30,7 @@ public AnalysisAffixProcessRule(Morpher morpher, AffixProcessRule rule) { _rules.Add( new MultiplePatternRule( - new AnalysisAffixProcessAllomorphRuleSpec(allo), + new AnalysisAffixProcessAllomorphRuleSpec(allo, morpher), new MatcherSettings { Filter = ann => ann.Type() == HCFeatureSystem.Segment, diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs index f77bf96e4..8df7e3900 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs @@ -29,7 +29,7 @@ public AnalysisCompoundingRule(Morpher morpher, CompoundingRule rule) { _rules.Add( new MultiplePatternRule( - new AnalysisCompoundingSubruleRuleSpec(sr), + new AnalysisCompoundingSubruleRuleSpec(sr, morpher), new MatcherSettings { Filter = ann => ann.Type() == HCFeatureSystem.Segment, diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs index 50f6177bb..db11fa690 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs @@ -9,11 +9,12 @@ public class AnalysisCompoundingSubruleRuleSpec : AnalysisMorphologicalTransform { private readonly CompoundingSubrule _subrule; - public AnalysisCompoundingSubruleRuleSpec(CompoundingSubrule subrule) - : base(subrule.HeadLhs.Concat(subrule.NonHeadLhs), subrule.Rhs) + public AnalysisCompoundingSubruleRuleSpec(CompoundingSubrule subrule, Morpher morpher = null) + : base(subrule.HeadLhs.Concat(subrule.NonHeadLhs), subrule.Rhs, morpher) { _subrule = subrule; - Pattern.Acceptable = match => _subrule.HeadLhs.Any(part => IsPartCaptured(match, part.Name)); + Pattern.Acceptable = match => + _subrule.HeadLhs.Any(part => IsPartCaptured(match, part.Name)) && CopiesMayAgree(match); Pattern.Freeze(); } diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs index 93e7a0ef6..5871dbb7c 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs @@ -46,6 +46,126 @@ 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. An optional segment (an unapplied deletion) stands for any number of segments + /// it unifies with, because can restore fewer segments + /// than were deleted, so copies disagree only if no such choice lines them up. A part the rule + /// modifies, or a copy with an optional node that is not a segment, is never reported as disagreeing. + /// + internal bool HasDisagreeingCopies(Match match) + { + foreach (KeyValuePair capturedPart in _capturedParts) + { + if (capturedPart.Value < 2 || _modifyFromInfos.ContainsKey(capturedPart.Key)) + continue; + + if ( + TryGetSegmentCopies(match, capturedPart.Key, capturedPart.Value, out List> copies) + && AnyCopiesDisagree(copies) + ) + { + return true; + } + } + + return false; + } + + private static bool TryGetSegmentCopies( + Match match, + string partName, + int copyCount, + out List> copies + ) + { + copies = new List>(copyCount); + for (int i = 0; i < copyCount; i++) + { + GroupCapture capture = match.GroupCaptures[GetGroupName(partName, i)]; + if (!capture.Success) + return false; + + // an unapplied word-initial deletion sits before the capture, not inside it + List nodes = MorphologicalOutputAction + .GetSkippedOptionalNodes(match.Input.Shape, capture.Range) + .Concat(match.Input.Shape.GetNodes(capture.Range)) + .ToList(); + if (nodes.Any(node => node.Annotation.Optional && node.Annotation.Type() != HCFeatureSystem.Segment)) + return false; + + copies.Add(nodes.Where(node => node.Annotation.Type() == HCFeatureSystem.Segment).ToList()); + } + return true; + } + + // unifiability is not transitive, so every pair is compared, not just each copy against the first + private static bool AnyCopiesDisagree(List> copies) + { + for (int i = 0; i < copies.Count; i++) + { + for (int j = i + 1; j < copies.Count; j++) + { + if (!CanAlign(copies[i], copies[j])) + return true; + } + } + return false; + } + + private static bool CanAlign(List first, List second) + { + if (!first.Any(node => node.Annotation.Optional) && !second.Any(node => node.Annotation.Optional)) + { + if (first.Count != second.Count) + return false; + for (int k = 0; k < first.Count; k++) + { + if (!first[k].Annotation.FeatureStruct.IsUnifiable(second[k].Annotation.FeatureStruct)) + return false; + } + return true; + } + + // reachable[i, j]: the first i nodes of one copy can be lined up with the first j of the other; + // an optional node is passed over, or absorbs a node of the other copy and stays available + var reachable = new bool[first.Count + 1, second.Count + 1]; + reachable[0, 0] = true; + for (int i = 0; i <= first.Count; i++) + { + for (int j = 0; j <= second.Count; j++) + { + if (!reachable[i, j]) + continue; + bool firstOptional = i < first.Count && first[i].Annotation.Optional; + bool secondOptional = j < second.Count && second[j].Annotation.Optional; + if (firstOptional) + reachable[i + 1, j] = true; + if (secondOptional) + reachable[i, j + 1] = true; + if ( + i < first.Count + && j < second.Count + && first[i].Annotation.FeatureStruct.IsUnifiable(second[j].Annotation.FeatureStruct) + ) + { + reachable[i + 1, j + 1] = true; + if (firstOptional) + reachable[i, j + 1] = true; + if (secondOptional) + reachable[i + 1, j] = true; + } + } + } + return reachable[first.Count, second.Count]; + } + public Pattern Pattern { get { return _pattern; } diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs index a23ae84b1..826259988 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs @@ -9,11 +9,27 @@ public abstract class AnalysisMorphologicalTransformRuleSpec : AnalysisMorphologicalTransform, IPatternRuleSpec { + private readonly Morpher _morpher; + private readonly bool _hasRepeatedParts; + protected AnalysisMorphologicalTransformRuleSpec( IEnumerable> lhs, - IList rhs + IList rhs, + Morpher morpher ) - : base(lhs, rhs) { } + : base(lhs, rhs) + { + _morpher = morpher; + _hasRepeatedParts = HasRepeatedParts; + } + + protected bool CopiesMayAgree(Match match) + { + return _morpher == null + || !_morpher.PruneDisagreeingCopies + || !_hasRepeatedParts + || !HasDisagreeingCopies(match); + } public bool IsApplicable(Word input) { diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs index 89e30fbe2..544e531c6 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs @@ -23,7 +23,7 @@ public AnalysisRealizationalAffixProcessRule(Morpher morpher, RealizationalAffix { _rules.Add( new MultiplePatternRule( - new AnalysisAffixProcessAllomorphRuleSpec(allo), + new AnalysisAffixProcessAllomorphRuleSpec(allo, morpher), new MatcherSettings { Filter = ann => ann.Type() == HCFeatureSystem.Segment, diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs index 5595a232f..53ed1ddd0 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs @@ -37,7 +37,7 @@ IDictionary capturedParts /// The output word synthesis. public abstract IEnumerable> Apply(Match match, Word output); - protected IEnumerable GetSkippedOptionalNodes(Shape shape, Range range) + protected internal static IEnumerable GetSkippedOptionalNodes(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/PruneDisagreeingCopiesTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs new file mode 100644 index 000000000..fa573bf21 --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs @@ -0,0 +1,400 @@ +using NUnit.Framework; +using SIL.Machine.Annotations; +using SIL.Machine.FeatureModel; +using SIL.Machine.Matching; +using SIL.Machine.Morphology.HermitCrab.PhonologicalRules; + +namespace SIL.Machine.Morphology.HermitCrab.MorphologicalRules; + +public class PruneDisagreeingCopiesTests : HermitCrabTestBase +{ + private static readonly FeatureStruct AnySegment = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + + [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) + { + (string first, string second) = FindIncompatibleSegments(); + AffixProcessRule rule = CreateCopyRule(FullPart()); + + List outputs = new AnalysisAffixProcessRule(CreateMorpher(pruneCopies), 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(); + AffixProcessRule rule = CreateCopyRule(SingleSegmentPart()); + + List outputs = new AnalysisAffixProcessRule(CreateMorpher(pruneCopies), rule) + .Apply(CreateInput(first + second)) + .ToList(); + + Assert.That(outputs.Count, Is.EqualTo(expectedOutputs)); + } + + [Test] + public void TracingStillPrunesDisagreeingCopies() + { + (string first, string second) = FindIncompatibleSegments(); + AffixProcessRule rule = CreateCopyRule(SingleSegmentPart()); + var input = new Word(Surface, Table3.Segment(first + second)) { AnalysisScope = new AnalysisScope() }; + TraceManager.AnalyzeWord(Language, input); + input.Freeze(); + TraceManager.IsTracing = true; + try + { + List outputs = new AnalysisAffixProcessRule(CreateMorpher(true), rule).Apply(input).ToList(); + + Assert.That(outputs, Is.Empty); + } + finally + { + TraceManager.IsTracing = false; + } + } + + [TestCase(false, 1)] + [TestCase(true, 0)] + public void EveryPairOfCopiesIsCompared(bool pruneCopies, int expectedOutputs) + { + // the first copy is underspecified, as unapplied phonology can leave it, so it unifies with both others + (string first, string second) = FindIncompatibleSegments(); + AffixProcessRule rule = CreateCopyRule(SingleSegmentPart(), copyCount: 3); + var input = new Word(Surface, Table3.Segment(first + first + second)) { AnalysisScope = new AnalysisScope() }; + input.Shape.First.Annotation.FeatureStruct = AnySegment.Clone(); + input.Freeze(); + + List outputs = new AnalysisAffixProcessRule(CreateMorpher(pruneCopies), rule).Apply(input).ToList(); + + Assert.That(outputs.Count, Is.EqualTo(expectedOutputs)); + } + + [Test] + public void OptionalSegmentThatCannotLineUpIsPruned() + { + // skipping or keeping the restored segment, every split still pairs the two incompatible segments + (string first, string second) = FindIncompatibleSegments(); + AffixProcessRule rule = CreateCopyRule(FullPart()); + Word input = CreateInputWithOptionalSecondSegment(first + second + second); + + Assert.That(new AnalysisAffixProcessRule(CreateMorpher(false), rule).Apply(input), Is.Not.Empty); + Assert.That(new AnalysisAffixProcessRule(CreateMorpher(true), rule).Apply(input), Is.Empty); + } + + [Test] + public void OptionalSegmentThatLinesUpWhenSkippedIsKept() + { + (string first, string second) = FindIncompatibleSegments(); + AffixProcessRule rule = CreateCopyRule(FullPart()); + Word input = CreateInputWithOptionalSecondSegment(first + second + first); + + Assert.That( + new AnalysisAffixProcessRule(CreateMorpher(true), rule).Apply(input).Count(), + Is.EqualTo(new AnalysisAffixProcessRule(CreateMorpher(false), rule).Apply(input).Count()) + ); + } + + [TestCase(false, 3)] + [TestCase(true, 1)] + public void RealizationalFullCopyKeepsOnlyTheSplitWhoseCopiesAgree(bool pruneCopies, int expectedOutputs) + { + (string first, string second) = FindIncompatibleSegments(); + var rule = new RealizationalAffixProcessRule { Name = "real_copy", Gloss = "RED" }; + rule.Allomorphs.Add(CreateCopyAllomorph(FullPart(), copyCount: 2)); + + List outputs = new AnalysisRealizationalAffixProcessRule(CreateMorpher(pruneCopies), rule) + .Apply(CreateInput(first + second + first + second)) + .ToList(); + + Assert.That(outputs.Count, Is.EqualTo(expectedOutputs)); + } + + [TestCase(false, 3)] + [TestCase(true, 1)] + public void CompoundHeadCopiedTwiceKeepsOnlyTheSplitWhoseCopiesAgree(bool pruneCopies, int expectedOutputs) + { + var crule = new CompoundingRule { Name = "head_copy_compound" }; + crule.Subrules.Add( + new CompoundingSubrule + { + HeadLhs = { Pattern.New("head").Annotation(AnySegment).OneOrMore.Value }, + NonHeadLhs = { Pattern.New("nonHead").Annotation(AnySegment).OneOrMore.Value }, + Rhs = { new CopyFromInput("head"), new CopyFromInput("head"), new CopyFromInput("nonHead") }, + } + ); + Morphophonemic.MorphologicalRules.Add(crule); + + // "pu" is the only root the input can end in, so every analysis differs only in how "tata" splits + List outputs = new AnalysisCompoundingRule(CreateMorpher(pruneCopies), crule) + .Apply(CreateInput("tatapu")) + .ToList(); + + Assert.That(outputs.Count, Is.EqualTo(expectedOutputs)); + Assert.That(outputs.Select(w => w.CurrentNonHead.RootAllomorph.Morpheme.Gloss), Is.All.EqualTo("52")); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public void ParsesTheSameUnderEveryParallelismSetting(int maxDegreeOfParallelism) + { + AffixProcessRule redup = CreateCopyRule(FullPart()); + redup.RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value; + Morphophonemic.MorphologicalRules.Add(redup); + + string[] unpruned = ParseGlosses(CreateMorpher(false, maxDegreeOfParallelism: 1), "sagsag"); + Assert.That(unpruned, Is.EqualTo(new[] { "32 RED" })); + Assert.That(ParseGlosses(CreateMorpher(true, maxDegreeOfParallelism), "sagsag"), Is.EqualTo(unpruned)); + } + + [TestCase(false)] + [TestCase(true)] + public void PartialMorphemesParseTheSameWithAndWithoutPruning(bool alwaysEnforceFinalTemplates) + { + Morphophonemic.MorphologicalRules.Add(CreateCopyRule(FullPart())); + + string[] Parse(bool pruneCopies, string word) + { + Morpher morpher = CreateMorpher(pruneCopies); + Assert.That(morpher.IsPartial, Is.True, "entry 54 has an empty syntactic feature structure"); + morpher.AlwaysEnforceFinalTemplates = alwaysEnforceFinalTemplates; + return ParseGlosses(morpher, word); + } + + Assert.That(Parse(false, "pipi"), Does.Contain("54 RED")); + foreach (string word in new[] { "pipi", "sagsag", "sagsa" }) + Assert.That(Parse(true, word), Is.EqualTo(Parse(false, word)), word); + } + + /// + /// 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)] + [TestCase(2)] + public void CapLimitedDeletionInOneCopyParsesTheSameWithAndWithoutPruning(int deletionReapplications) + { + var vowel = FeatureStruct + .New(Language.PhonologicalFeatureSystem) + .Symbol(HCFeatureSystem.Segment) + .Symbol("voc+") + .Value; + AffixProcessRule redup = CreateCopyRule(FullPart()); + 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) + { + Morpher morpher = CreateMorpher(pruneCopies); + morpher.DeletionReapplications = deletionReapplications; + return ParseGlosses(morpher, "ggasaasa"); + } + + 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); + } + } + + /// + /// Counterbleeding opacity: voicing changes only the second copy, then raising destroys the vowel that + /// conditioned it, so analysis must restore the copy through an environment that is no longer on the surface. + /// + [Test] + public void OpaqueChangeToOneCopyParsesTheSameWithAndWithoutPruning() + { + AffixProcessRule redup = CreateCopyRule(FullPart()); + redup.RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value; + Morphophonemic.MorphologicalRules.Add(redup); + + var voicing = new RewriteRule + { + Name = "voicing", + Lhs = Pattern.New().Annotation(Character(Table1, "s")).Value, + }; + voicing.Subrules.Add( + new RewriteSubrule + { + Rhs = Pattern + .New() + .Annotation( + FeatureStruct + .New(Language.PhonologicalFeatureSystem) + .Symbol(HCFeatureSystem.Segment) + .Symbol("vd+") + .Value + ) + .Value, + LeftEnvironment = Pattern.New().Annotation(Character(Table1, "a")).Value, + RightEnvironment = Pattern.New().Annotation(Character(Table1, "a")).Value, + } + ); + Allophonic.PhonologicalRules.Add(voicing); + + var raising = new RewriteRule + { + Name = "raising", + Lhs = Pattern.New().Annotation(Character(Table1, "a")).Value, + }; + raising.Subrules.Add( + new RewriteSubrule + { + Rhs = Pattern + .New() + .Annotation( + FeatureStruct + .New(Language.PhonologicalFeatureSystem) + .Symbol(HCFeatureSystem.Segment) + .Symbol("high+") + .Symbol("low-") + .Symbol("back-") + .Value + ) + .Value, + LeftEnvironment = Pattern.New().Annotation(Character(Table1, "z")).Value, + } + ); + Allophonic.PhonologicalRules.Add(raising); + + LexEntry root = AddEntry( + "SA", + FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value, + Morphophonemic, + "sa" + ); + try + { + var generator = new Morpher(TraceManager, Language); + Assert.That( + generator.GenerateWords(root, new Morpheme[] { redup }, FeatureStruct.New().Value), + Is.EquivalentTo(new[] { "sazi" }), + "forward derivation defines the expected parse" + ); + + string[] unpruned = ParseGlosses(CreateMorpher(false), "sazi"); + Assert.That(unpruned, Does.Contain("SA RED"), "the unpruned engine finds the generated analysis"); + Assert.That(ParseGlosses(CreateMorpher(true), "sazi"), Is.EqualTo(unpruned)); + } + finally + { + Morphophonemic.Entries.Remove(root); + } + } + + private Morpher CreateMorpher(bool pruneCopies, int maxDegreeOfParallelism = 0) + { + return new Morpher(TraceManager, Language, maxDegreeOfParallelism) { PruneDisagreeingCopies = pruneCopies }; + } + + private static string[] ParseGlosses(Morpher morpher, string word) + { + return morpher + .ParseWord(word) + .Select(w => string.Join(" ", w.AllomorphsInMorphOrder.Select(a => a.Morpheme.Gloss))) + .OrderBy(s => s, StringComparer.Ordinal) + .ToArray(); + } + + private static Pattern FullPart() + { + return Pattern.New("copy").Annotation(AnySegment).OneOrMore.Value; + } + + private static Pattern SingleSegmentPart() + { + return Pattern.New("copy").Annotation(AnySegment).Value; + } + + private static AffixProcessRule CreateCopyRule(Pattern part, int copyCount = 2) + { + var rule = new AffixProcessRule + { + Name = "full_copy", + Gloss = "RED", + RequiredSyntacticFeatureStruct = FeatureStruct.New().Value, + OutSyntacticFeatureStruct = FeatureStruct.New().Value, + }; + rule.Allomorphs.Add(CreateCopyAllomorph(part, copyCount)); + return rule; + } + + private static AffixProcessAllomorph CreateCopyAllomorph(Pattern part, int copyCount) + { + var allomorph = new AffixProcessAllomorph { Lhs = { part } }; + for (int i = 0; i < copyCount; i++) + allomorph.Rhs.Add(new CopyFromInput("copy")); + return allomorph; + } + + 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 CreateInputWithOptionalSecondSegment(string representation) + { + var input = new Word(Surface, Table3.Segment(representation)) { AnalysisScope = new AnalysisScope() }; + input.Shape.First.Next.Annotation.Optional = true; + input.Freeze(); + return input; + } + + private Word CreateInput(string representation) + { + var input = new Word(Surface, Table3.Segment(representation)) { AnalysisScope = new AnalysisScope() }; + input.Freeze(); + return input; + } +}