diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs index 26660edc1..c48d327fe 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..580b663fb 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs @@ -12,6 +12,7 @@ public AnalysisAffixProcessAllomorphRuleSpec(AffixProcessAllomorph allomorph) : base(allomorph.Lhs, allomorph.Rhs) { _allomorph = allomorph; + Pattern.Acceptable = CopyAgreementCheck; Pattern.Freeze(); } diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs index 7ccba3372..59651247c 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) { PruningMorpher = 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..c5cdfb11d 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) { PruningMorpher = 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..b8ff171e9 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using SIL.Machine.Annotations; using SIL.Machine.Matching; @@ -13,7 +14,10 @@ public AnalysisCompoundingSubruleRuleSpec(CompoundingSubrule subrule) : base(subrule.HeadLhs.Concat(subrule.NonHeadLhs), subrule.Rhs) { _subrule = subrule; - Pattern.Acceptable = match => _subrule.HeadLhs.Any(part => IsPartCaptured(match, part.Name)); + Func, bool> copyAgreement = CopyAgreementCheck; + Pattern.Acceptable = match => + _subrule.HeadLhs.Any(part => IsPartCaptured(match, part.Name)) + && (copyAgreement == null || copyAgreement(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..ed77b3cb3 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs @@ -12,6 +12,7 @@ public class AnalysisMorphologicalTransform private readonly Pattern _pattern; private readonly Dictionary> _modifyFromInfos; private readonly Dictionary _capturedParts; + private readonly List> _copyCheckedParts; public AnalysisMorphologicalTransform( IEnumerable> lhs, @@ -34,8 +35,15 @@ IList rhs ); } } + // a modified part is never checked for disagreeing copies, so repeating it needs no predicate + _copyCheckedParts = _capturedParts + .Where(part => part.Value >= 2 && !_modifyFromInfos.ContainsKey(part.Key)) + .ToList(); + HasRepeatedParts = _copyCheckedParts.Count > 0; } + internal bool HasRepeatedParts { get; } + internal static string GetGroupName(string partName, int index) { return string.Format("{0}_{1}", partName, index); @@ -46,6 +54,118 @@ protected IDictionary CapturedParts get { return _capturedParts; } } + /// + /// 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 _copyCheckedParts) + { + 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 + .CollectSkippedOptionalNodes(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..d36d640b1 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using SIL.Machine.Annotations; using SIL.Machine.Matching; using SIL.Machine.Rules; @@ -15,6 +16,25 @@ IList rhs ) : base(lhs, rhs) { } + /// + /// The morpher whose setting governs pruning; unset means none. + /// + internal Morpher PruningMorpher { get; set; } + + /// + /// The acceptance predicate that prunes matches whose copies cannot agree, or null when no part repeats and + /// there is nothing to check (the matcher builds a Match for every candidate it tests). + /// + internal Func, bool> CopyAgreementCheck + { + get { return HasRepeatedParts ? CopiesMayAgree : (Func, bool>)null; } + } + + private bool CopiesMayAgree(Match match) + { + return PruningMorpher == null || !PruningMorpher.PruneDisagreeingCopies || !HasDisagreeingCopies(match); + } + public bool IsApplicable(Word input) { return true; diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs index 89e30fbe2..db1c99cb8 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) { PruningMorpher = 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..2bb7eaa57 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 CollectSkippedOptionalNodes(shape, range); + } + + internal static IEnumerable CollectSkippedOptionalNodes(Shape shape, Range range) { ShapeNode node = range.Start.Prev; var skippedNodes = new List(); diff --git a/src/SIL.Machine.Morphology.HermitCrab/PhonologicalRules/FeatureAnalysisRewriteRuleSpec.cs b/src/SIL.Machine.Morphology.HermitCrab/PhonologicalRules/FeatureAnalysisRewriteRuleSpec.cs index 588f12fdf..13ccb09a1 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/PhonologicalRules/FeatureAnalysisRewriteRuleSpec.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/PhonologicalRules/FeatureAnalysisRewriteRuleSpec.cs @@ -55,6 +55,13 @@ Tuple, PatternNode> tuple in lhs.C FeatureStruct fs = rhsConstraint.FeatureStruct.AntiFeatureStruct(); fs.Subtract(lhsConstraint.FeatureStruct.AntiFeatureStruct()); + StringFeatureValue lhsStrRep; + if (lhsConstraint.FeatureStruct.TryGetValue(HCFeatureSystem.StrRep, out lhsStrRep)) + { + // StrRep is a built-in feature: inversion adds the input spelling, not its complement. + fs.AddValue(HCFeatureSystem.StrRep, lhsStrRep.Clone()); + rhsAntiFSs[i].AddValue(HCFeatureSystem.StrRep, lhsStrRep.Clone()); + } fs.AddValue(HCFeatureSystem.Type, HCFeatureSystem.Segment); _analysisRhs.Children.Add(new Constraint(fs)); @@ -95,6 +102,17 @@ private bool IsUnapplicationNonvacuous(Match match, IEnumerable } } } + foreach (StringFeature sf in fs.Features.OfType()) + { + StringFeatureValue nodeSfv; + if ( + node.Annotation.FeatureStruct.TryGetValue(sf, out nodeSfv) + && !nodeSfv.IsSupersetOf(fs.GetValue(sf)) + ) + { + return true; + } + } i++; } diff --git a/src/SIL.Machine/Corpora/ScrVersExtensions.cs b/src/SIL.Machine/Corpora/ScrVersExtensions.cs index 620074a68..f8cab53db 100644 --- a/src/SIL.Machine/Corpora/ScrVersExtensions.cs +++ b/src/SIL.Machine/Corpora/ScrVersExtensions.cs @@ -67,8 +67,7 @@ public static bool IsEquivalentTo(this ScrVers scrVers, ScrVers other) if (scrVers.Equals(other)) return true; - // If all verses in the versifications are 1) equal (accounts for mapping) - // and 2) graphically identical in regard to book, chapter, and verse, then the versifications are equivalent + // Equivalence requires both mapped equality and identical book, chapter, and verse numbers. foreach ( (VerseRef thisVerse, VerseRef otherVerse) in scrVers .AllIncludedVerses() diff --git a/src/SIL.Machine/FeatureModel/StringFeatureValue.cs b/src/SIL.Machine/FeatureModel/StringFeatureValue.cs index ae05be2a9..6df5eaf05 100644 --- a/src/SIL.Machine/FeatureModel/StringFeatureValue.cs +++ b/src/SIL.Machine/FeatureModel/StringFeatureValue.cs @@ -80,6 +80,11 @@ public bool Overlaps(IEnumerable strings) return Not ? !_values.Overlaps(strings) : _values.Overlaps(strings); } + public bool IsSupersetOf(StringFeatureValue other, bool notOther = false) + { + return IsSupersetOf(false, other, notOther); + } + protected override bool IsSupersetOf(bool not, SimpleFeatureValue other, bool notOther) { if (!(other is StringFeatureValue otherSfv)) 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..d532e6457 --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs @@ -0,0 +1,641 @@ +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()) + ); + } + + [Test] + public void WordInitialOptionalSegmentThatLinesUpWhenRestoredIsKept() + { + // "(a)bab" for stem "ab": the optional first segment can be restored into the first copy so [a,b] meets [a,b] + (string first, string second) = FindIncompatibleSegments(); + AffixProcessRule rule = CreateCopyRule(FullPart()); + Word input = CreateInputWithOptionalFirstSegment(first + second + first + second); + + string[] unpruned = AnalysisForms(false, rule, input); + Assert.That(unpruned, Is.EquivalentTo(new[] { first, first + second, first + second + first })); + // only the split that pairs "(a)b" with "ab" lines up; the other splits pair incompatible segments + Assert.That(AnalysisForms(true, rule, input), Is.EqualTo(new[] { first + second })); + } + + [Test] + public void OptionalNonSegmentInACopyIsNeverPruned() + { + (string first, string second) = FindIncompatibleSegments(); + AffixProcessRule rule = CreateCopyRule(FullPart()); + var input = new Word(Surface, Table3.Segment(first + second + second)) { AnalysisScope = new AnalysisScope() }; + input.Shape.AddAfter(input.Shape.First.Next, FeatureStruct.New().Symbol(HCFeatureSystem.Boundary).Value, true); + input.Freeze(); + + // a copy holding an optional boundary is never reported as disagreeing, so its split survives pruning; + // the split of two plain, incompatible copies is the only one removed + Assert.That(AnalysisForms(false, rule, input), Is.EquivalentTo(new[] { first, first + second })); + Assert.That(AnalysisForms(true, rule, input), Is.EqualTo(new[] { first })); + } + + [Test] + public void WordInitialOptionalNonSegmentIsNeverPruned() + { + // the matcher skips the leading boundary, so it sits before the first copy rather than inside it + (string first, string second) = FindIncompatibleSegments(); + AffixProcessRule rule = CreateCopyRule(FullPart()); + var input = new Word(Surface, Table3.Segment(first + second + first + second)) + { + AnalysisScope = new AnalysisScope(), + }; + input.Shape.AddAfter(input.Shape.Begin, FeatureStruct.New().Symbol(HCFeatureSystem.Boundary).Value, true); + input.Freeze(); + + // nothing is pruned: with the boundary ignored, pruning keeps exactly what the unpruned rule produces + string[] expected = { first, first + second, first + second + first }; + Assert.That(AnalysisForms(false, rule, input), Is.EquivalentTo(expected)); + Assert.That(AnalysisForms(true, rule, input), Is.EquivalentTo(expected)); + } + + [Test] + public void EmptyCopyIsNeverPruned() + { + (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 + Assert.That(AnalysisForms(false, rule, input), Is.EquivalentTo(new[] { first, first + second })); + Assert.That(AnalysisForms(true, rule, input), Is.EqualTo(new[] { first + second })); + } + + [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); + } + } + + /// + /// Feature-bearing rewrite (s -> z between vowels, voicing): analysis unapplies it, so pruning keeps the parse. + /// + [TestCase(false)] + [TestCase(true)] + public void FeatureRewriteOfOneCopyParsesTheSameWithAndWithoutPruning(bool pruneCopies) + { + AffixProcessRule redup = CreateCopyRule(FullPart()); + redup.RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value; + Morphophonemic.MorphologicalRules.Add(redup); + Allophonic.PhonologicalRules.Add(CreateBetweenVowelsRewrite(Table1, "s", Character(Table1, "z"))); + LexEntry root = AddEntry( + "ROOT", + FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value, + Morphophonemic, + "sa" + ); + try + { + Assert.That( + new Morpher(TraceManager, Language).GenerateWords( + root, + new Morpheme[] { redup }, + FeatureStruct.New().Value + ), + Is.EquivalentTo(new[] { "saza" }) + ); + Assert.That(ParseGlosses(CreateMorpher(pruneCopies), "saza"), Does.Contain("ROOT RED")); + } + finally + { + Morphophonemic.Entries.Remove(root); + } + } + + /// + /// A rewrite between segments that differ only by their string representation (no symbolic features) must + /// be unapplied by analysis so the changed copy can agree with the other; otherwise pruning would drop the parse. + /// + [TestCase(false)] + [TestCase(true)] + public void StrRepOnlyRewriteOfOneCopyParsesTheSameWithAndWithoutPruning(bool pruneCopies) + { + var table = new CharacterDefinitionTable { Name = "strrep" }; + table.AddSegment("a"); + table.AddSegment("s"); + table.AddSegment("z"); + var syntactic = new SyntacticFeatureSystem(); + syntactic.AddPartsOfSpeech(new FeatureSymbol("V", "Verb")); + syntactic.Freeze(); + var phonological = new FeatureSystem(); + phonological.Freeze(); + var stratum = new Stratum(table) { Name = "S", MorphologicalRuleOrder = MorphologicalRuleOrder.Unordered }; + var language = new Language + { + Name = "StrRepOnly", + PhonologicalFeatureSystem = phonological, + SyntacticFeatureSystem = syntactic, + Strata = { stratum }, + }; + AffixProcessRule redup = CreateCopyRule(FullPart()); + redup.RequiredSyntacticFeatureStruct = FeatureStruct.New(syntactic).Symbol("V").Value; + stratum.MorphologicalRules.Add(redup); + stratum.PhonologicalRules.Add(CreateBetweenVowelsRewrite(table, "s", table["z"].FeatureStruct)); + var root = new LexEntry + { + Id = "ROOT", + Gloss = "ROOT", + SyntacticFeatureStruct = FeatureStruct.New(syntactic).Symbol("V").Value, + }; + root.Allomorphs.Add(new RootAllomorph(new Segments(table, "sa", true))); + stratum.Entries.Add(root); + + var morpher = new Morpher(TraceManager, language) { PruneDisagreeingCopies = pruneCopies }; + Assert.That( + morpher.GenerateWords(root, new Morpheme[] { redup }, FeatureStruct.New().Value), + Is.EquivalentTo(new[] { "saza" }) + ); + Assert.That(ParseGlosses(morpher, "saza"), Does.Contain("ROOT RED")); + } + + [Test] + public void CopyAndModifyOfTheSamePartDoesNotInstallTheCopyAgreementPredicate() + { + AffixProcessAllomorph allomorph = CreateCopyAndModifyAllomorph(); + + Assert.That(new AnalysisAffixProcessAllomorphRuleSpec(allomorph).CopyAgreementCheck, Is.Null); + } + + [Test] + public void TwoCopiesOfAPartStillInstallTheCopyAgreementPredicate() + { + AffixProcessAllomorph allomorph = CreateCopyAllomorph(FullPart(), 2); + + Assert.That(new AnalysisAffixProcessAllomorphRuleSpec(allomorph).CopyAgreementCheck, Is.Not.Null); + } + + [TestCase(false)] + [TestCase(true)] + public void CopyAndModifyOfTheSamePartParsesTheSameWithAndWithoutPruning(bool pruneCopies) + { + var rule = new AffixProcessRule + { + Name = "copy_modify", + Gloss = "RED", + RequiredSyntacticFeatureStruct = FeatureStruct.New().Value, + OutSyntacticFeatureStruct = FeatureStruct.New().Value, + }; + rule.Allomorphs.Add(CreateCopyAndModifyAllomorph()); + Morphophonemic.MorphologicalRules.Add(rule); + LexEntry root = AddEntry( + "ROOT", + FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value, + Morphophonemic, + "pi" + ); + try + { + string[] generated = new Morpher(TraceManager, Language) + .GenerateWords(root, new Morpheme[] { rule }, FeatureStruct.New().Value) + .ToArray(); + Assert.That(generated, Is.Not.Empty); + Assert.That(ParseGlosses(CreateMorpher(pruneCopies), generated[0]), Does.Contain("ROOT RED")); + } + finally + { + Morphophonemic.Entries.Remove(root); + Morphophonemic.MorphologicalRules.Remove(rule); + } + } + + private AffixProcessAllomorph CreateCopyAndModifyAllomorph() + { + FeatureStruct voiced = FeatureStruct + .New(Language.PhonologicalFeatureSystem) + .Symbol(HCFeatureSystem.Segment) + .Symbol("vd+") + .Value; + return new AffixProcessAllomorph + { + Lhs = { Pattern.New("1").Annotation(AnySegment).OneOrMore.Value }, + Rhs = { new CopyFromInput("1"), new ModifyFromInput("1", voiced) }, + }; + } + + private RewriteRule CreateBetweenVowelsRewrite(CharacterDefinitionTable table, string from, FeatureStruct rhs) + { + var rule = new RewriteRule + { + Name = "rewrite", + Lhs = Pattern.New().Annotation(Character(table, from)).Value, + }; + rule.Subrules.Add( + new RewriteSubrule + { + Rhs = Pattern.New().Annotation(rhs).Value, + LeftEnvironment = Pattern.New().Annotation(Character(table, "a")).Value, + RightEnvironment = Pattern.New().Annotation(Character(table, "a")).Value, + } + ); + return rule; + } + + 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 string[] AnalysisForms(bool pruneCopies, AffixProcessRule rule, Word input) + { + return new AnalysisAffixProcessRule(CreateMorpher(pruneCopies), rule) + .Apply(input) + .Select(output => output.Shape.ToString(Table3, false)) + .OrderBy(form => form, StringComparer.Ordinal) + .ToArray(); + } + + 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 CreateInputWithOptionalFirstSegment(string representation) + { + var input = new Word(Surface, Table3.Segment(representation)) { AnalysisScope = new AnalysisScope() }; + input.Shape.First.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; + } +} diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/PhonologicalRules/StrRepRewriteRuleTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/PhonologicalRules/StrRepRewriteRuleTests.cs new file mode 100644 index 000000000..f93c70ca5 --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/PhonologicalRules/StrRepRewriteRuleTests.cs @@ -0,0 +1,166 @@ +using NUnit.Framework; +using SIL.Machine.Annotations; +using SIL.Machine.FeatureModel; +using SIL.Machine.Matching; + +namespace SIL.Machine.Morphology.HermitCrab.PhonologicalRules; + +public class StrRepRewriteRuleTests +{ + [Test] + public void LiteralRewriteAfterSegmentClassAnalyzesKadAndRejectsKat() + { + var table = new CharacterDefinitionTable(); + foreach (string segment in new[] { "a", "t", "d", "k" }) + table.AddSegment(segment); + var language = new Language(); + language.SyntacticFeatureSystem.AddPartsOfSpeech(new FeatureSymbol("N")); + var stratum = new Stratum(table); + language.Strata.Add(stratum); + var entry = new LexEntry + { + Id = "kat", + SyntacticFeatureStruct = FeatureStruct.New(language.SyntacticFeatureSystem).Symbol("N").Value, + }; + entry.Allomorphs.Add(new RootAllomorph(new Segments(table, "kat"))); + stratum.Entries.Add(entry); + var vowels = new SegmentNaturalClass(new[] { table["a"] }); + var rule = new RewriteRule { Lhs = Pattern.New().Annotation(table["t"].FeatureStruct).Value }; + rule.Subrules.Add( + new RewriteSubrule + { + Rhs = Pattern.New().Annotation(table["d"].FeatureStruct).Value, + LeftEnvironment = Pattern.New().Annotation(vowels.FeatureStruct).Value, + } + ); + stratum.PhonologicalRules.Add(rule); + var morpher = new Morpher(new TraceManager(), language); + + Assert.That( + morpher.GenerateWords(entry, Array.Empty(), new FeatureStruct()), + Is.EquivalentTo(new[] { "kad" }) + ); + Assert.That(morpher.ParseWord("kad").Select(w => w.RootAllomorph.Morpheme.Id), Is.EqualTo(new[] { "kat" })); + foreach (string neighbor in new[] { "kat", "kak", "dat", "tat", "kta", "ka" }) + Assert.That(morpher.ParseWord(neighbor), Is.Empty, neighbor); + } + + [TestCase(RewriteApplicationMode.Iterative)] + [TestCase(RewriteApplicationMode.Simultaneous)] + public void LiteralRewriteWithoutFeaturesAnalyzesItsSynthesisAndRejectsVacuousUnapplication( + RewriteApplicationMode applicationMode + ) + { + var table = new CharacterDefinitionTable(); + foreach (string segment in new[] { "m", "p", "x", "u", "a" }) + table.AddSegment(segment); + var language = new Language(); + language.SyntacticFeatureSystem.AddPartsOfSpeech(new FeatureSymbol("N")); + var stratum = new Stratum(table); + language.Strata.Add(stratum); + var entries = new Dictionary(); + foreach (string form in new[] { "xmuma", "muma", "xuma" }) + { + var entry = new LexEntry + { + Id = form, + SyntacticFeatureStruct = FeatureStruct.New(language.SyntacticFeatureSystem).Symbol("N").Value, + }; + entry.Allomorphs.Add(new RootAllomorph(new Segments(table, form))); + stratum.Entries.Add(entry); + entries.Add(form, entry); + } + var rule = new RewriteRule + { + Lhs = Pattern.New().Annotation(table["m"].FeatureStruct).Value, + ApplicationMode = applicationMode, + }; + rule.Subrules.Add( + new RewriteSubrule + { + Rhs = Pattern.New().Annotation(table["p"].FeatureStruct).Value, + LeftEnvironment = Pattern.New().Annotation(table["x"].FeatureStruct).Value, + } + ); + stratum.PhonologicalRules.Add(rule); + var morpher = new Morpher(new TraceManager(), language); + + Assert.That( + morpher.GenerateWords(entries["xmuma"], Array.Empty(), new FeatureStruct()), + Is.EquivalentTo(new[] { "xpuma" }) + ); + Assert.That(morpher.ParseWord("xpuma").Select(w => w.RootAllomorph.Morpheme.Id), Is.EqualTo(new[] { "xmuma" })); + Assert.That(morpher.ParseWord("muma").Select(w => w.RootAllomorph.Morpheme.Id), Is.EqualTo(new[] { "muma" })); + Assert.That(morpher.ParseWord("xuma").Select(w => w.RootAllomorph.Morpheme.Id), Is.EqualTo(new[] { "xuma" })); + Assert.That(morpher.ParseWord("xmuma"), Is.Empty); + Assert.That(morpher.ParseWord("puma"), Is.Empty); + + var analysisRule = new AnalysisRewriteRule(morpher, rule); + var word = new Word(stratum, new Segments(table, "xpuma").Shape.Clone()); + Assert.That(analysisRule.Apply(word), Is.Not.Empty); + word.ResetDirty(); + Assert.That(analysisRule.Apply(word), Is.Empty); + } + + [TestCase(RewriteApplicationMode.Iterative)] + [TestCase(RewriteApplicationMode.Simultaneous)] + public void MultiSpellingLiteralRewriteAnalyzesEachSourceSpellingAndRejectsUnrewrittenForms( + RewriteApplicationMode applicationMode + ) + { + var table = new CharacterDefinitionTable(); + foreach (string segment in new[] { "a", "t", "k", "d", "p" }) + table.AddSegment(segment); + var language = new Language(); + language.SyntacticFeatureSystem.AddPartsOfSpeech(new FeatureSymbol("N")); + var stratum = new Stratum(table); + language.Strata.Add(stratum); + var entries = new Dictionary(); + foreach (string form in new[] { "ata", "aka", "apa" }) + { + var entry = new LexEntry + { + Id = form, + SyntacticFeatureStruct = FeatureStruct.New(language.SyntacticFeatureSystem).Symbol("N").Value, + }; + entry.Allomorphs.Add(new RootAllomorph(new Segments(table, form))); + stratum.Entries.Add(entry); + entries.Add(form, entry); + } + var stops = new SegmentNaturalClass(new[] { table["t"], table["k"] }); + var rule = new RewriteRule + { + Lhs = Pattern.New().Annotation(stops.FeatureStruct).Value, + ApplicationMode = applicationMode, + }; + rule.Subrules.Add( + new RewriteSubrule + { + Rhs = Pattern.New().Annotation(table["d"].FeatureStruct).Value, + LeftEnvironment = Pattern.New().Annotation(table["a"].FeatureStruct).Value, + } + ); + stratum.PhonologicalRules.Add(rule); + var morpher = new Morpher(new TraceManager(), language); + + foreach (string form in new[] { "ata", "aka" }) + { + Assert.That( + morpher.GenerateWords(entries[form], Array.Empty(), new FeatureStruct()), + Is.EquivalentTo(new[] { "ada" }), + form + ); + } + Assert.That( + morpher.ParseWord("ada").Select(w => w.RootAllomorph.Morpheme.Id), + Is.EquivalentTo(new[] { "ata", "aka" }) + ); + Assert.That( + morpher.GenerateWords(entries["apa"], Array.Empty(), new FeatureStruct()), + Is.EquivalentTo(new[] { "apa" }) + ); + Assert.That(morpher.ParseWord("apa").Select(w => w.RootAllomorph.Morpheme.Id), Is.EqualTo(new[] { "apa" })); + foreach (string unrewritten in new[] { "ata", "aka", "adp" }) + Assert.That(morpher.ParseWord(unrewritten), Is.Empty, unrewritten); + } +}