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);
+ }
+}