diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs
index 1cddacd0a..73c6007d2 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs
@@ -61,6 +61,7 @@ public Morpher(ITraceManager traceManager, Language lang, int maxDegreeOfParalle
MaxStemCount = 2;
MaxAlternatives = 0;
MergeEquivalentAnalyses = true;
+ PruneDisagreeingCopies = true;
LexEntrySelector = entry => true;
RuleSelector = rule => true;
@@ -110,6 +111,13 @@ public ITraceManager TraceManager
///
public bool AlwaysEnforceFinalTemplates { get; set; }
+ ///
+ /// When unapplying an affix-process or compounding rule that copies a part more than once
+ /// (reduplication), skip matches whose copies cannot unify segment by segment. Such a match can never
+ /// survive synthesis, so this removes only doomed analyses. On by default, including while tracing.
+ ///
+ public bool PruneDisagreeingCopies { get; set; }
+
///
/// Caps the concurrency used within a single parse or generation -- analysis cascade,
/// affix-template unapplication and synthesis alike. A value of 1 runs the work fully
diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs
index 9ce94c148..895f9eb18 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessAllomorphRuleSpec.cs
@@ -8,10 +8,13 @@ public class AnalysisAffixProcessAllomorphRuleSpec : AnalysisMorphologicalTransf
{
private readonly AffixProcessAllomorph _allomorph;
- public AnalysisAffixProcessAllomorphRuleSpec(AffixProcessAllomorph allomorph)
- : base(allomorph.Lhs, allomorph.Rhs)
+ public AnalysisAffixProcessAllomorphRuleSpec(AffixProcessAllomorph allomorph, Morpher morpher = null)
+ : base(allomorph.Lhs, allomorph.Rhs, morpher)
{
_allomorph = allomorph;
+ // the matcher builds a Match for every candidate it tests, so rules that copy nothing get no check
+ if (HasRepeatedParts)
+ Pattern.Acceptable = CopiesMayAgree;
Pattern.Freeze();
}
diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs
index 11980053c..d22149d32 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs
@@ -30,7 +30,7 @@ public AnalysisAffixProcessRule(Morpher morpher, AffixProcessRule rule)
{
_rules.Add(
new MultiplePatternRule(
- new AnalysisAffixProcessAllomorphRuleSpec(allo),
+ new AnalysisAffixProcessAllomorphRuleSpec(allo, morpher),
new MatcherSettings
{
Filter = ann => ann.Type() == HCFeatureSystem.Segment,
diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs
index f77bf96e4..8df7e3900 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs
@@ -29,7 +29,7 @@ public AnalysisCompoundingRule(Morpher morpher, CompoundingRule rule)
{
_rules.Add(
new MultiplePatternRule(
- new AnalysisCompoundingSubruleRuleSpec(sr),
+ new AnalysisCompoundingSubruleRuleSpec(sr, morpher),
new MatcherSettings
{
Filter = ann => ann.Type() == HCFeatureSystem.Segment,
diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs
index 50f6177bb..db11fa690 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingSubruleRuleSpec.cs
@@ -9,11 +9,12 @@ public class AnalysisCompoundingSubruleRuleSpec : AnalysisMorphologicalTransform
{
private readonly CompoundingSubrule _subrule;
- public AnalysisCompoundingSubruleRuleSpec(CompoundingSubrule subrule)
- : base(subrule.HeadLhs.Concat(subrule.NonHeadLhs), subrule.Rhs)
+ public AnalysisCompoundingSubruleRuleSpec(CompoundingSubrule subrule, Morpher morpher = null)
+ : base(subrule.HeadLhs.Concat(subrule.NonHeadLhs), subrule.Rhs, morpher)
{
_subrule = subrule;
- Pattern.Acceptable = match => _subrule.HeadLhs.Any(part => IsPartCaptured(match, part.Name));
+ Pattern.Acceptable = match =>
+ _subrule.HeadLhs.Any(part => IsPartCaptured(match, part.Name)) && CopiesMayAgree(match);
Pattern.Freeze();
}
diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs
index 93e7a0ef6..5871dbb7c 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransform.cs
@@ -46,6 +46,126 @@ protected IDictionary CapturedParts
get { return _capturedParts; }
}
+ internal bool HasRepeatedParts
+ {
+ get { return _capturedParts.Values.Any(count => count >= 2); }
+ }
+
+ ///
+ /// Synthesis writes every copy of a part from the same input, and anything that later changes one
+ /// copy is unapplied before this rule, so copies proven to disagree segment by segment cannot lead
+ /// to a valid analysis. An optional segment (an unapplied deletion) stands for any number of segments
+ /// it unifies with, because can restore fewer segments
+ /// than were deleted, so copies disagree only if no such choice lines them up. A part the rule
+ /// modifies, or a copy with an optional node that is not a segment, is never reported as disagreeing.
+ ///
+ internal bool HasDisagreeingCopies(Match match)
+ {
+ foreach (KeyValuePair capturedPart in _capturedParts)
+ {
+ if (capturedPart.Value < 2 || _modifyFromInfos.ContainsKey(capturedPart.Key))
+ continue;
+
+ if (
+ TryGetSegmentCopies(match, capturedPart.Key, capturedPart.Value, out List> copies)
+ && AnyCopiesDisagree(copies)
+ )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryGetSegmentCopies(
+ Match match,
+ string partName,
+ int copyCount,
+ out List> copies
+ )
+ {
+ copies = new List>(copyCount);
+ for (int i = 0; i < copyCount; i++)
+ {
+ GroupCapture capture = match.GroupCaptures[GetGroupName(partName, i)];
+ if (!capture.Success)
+ return false;
+
+ // an unapplied word-initial deletion sits before the capture, not inside it
+ List nodes = MorphologicalOutputAction
+ .GetSkippedOptionalNodes(match.Input.Shape, capture.Range)
+ .Concat(match.Input.Shape.GetNodes(capture.Range))
+ .ToList();
+ if (nodes.Any(node => node.Annotation.Optional && node.Annotation.Type() != HCFeatureSystem.Segment))
+ return false;
+
+ copies.Add(nodes.Where(node => node.Annotation.Type() == HCFeatureSystem.Segment).ToList());
+ }
+ return true;
+ }
+
+ // unifiability is not transitive, so every pair is compared, not just each copy against the first
+ private static bool AnyCopiesDisagree(List> copies)
+ {
+ for (int i = 0; i < copies.Count; i++)
+ {
+ for (int j = i + 1; j < copies.Count; j++)
+ {
+ if (!CanAlign(copies[i], copies[j]))
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static bool CanAlign(List first, List second)
+ {
+ if (!first.Any(node => node.Annotation.Optional) && !second.Any(node => node.Annotation.Optional))
+ {
+ if (first.Count != second.Count)
+ return false;
+ for (int k = 0; k < first.Count; k++)
+ {
+ if (!first[k].Annotation.FeatureStruct.IsUnifiable(second[k].Annotation.FeatureStruct))
+ return false;
+ }
+ return true;
+ }
+
+ // reachable[i, j]: the first i nodes of one copy can be lined up with the first j of the other;
+ // an optional node is passed over, or absorbs a node of the other copy and stays available
+ var reachable = new bool[first.Count + 1, second.Count + 1];
+ reachable[0, 0] = true;
+ for (int i = 0; i <= first.Count; i++)
+ {
+ for (int j = 0; j <= second.Count; j++)
+ {
+ if (!reachable[i, j])
+ continue;
+ bool firstOptional = i < first.Count && first[i].Annotation.Optional;
+ bool secondOptional = j < second.Count && second[j].Annotation.Optional;
+ if (firstOptional)
+ reachable[i + 1, j] = true;
+ if (secondOptional)
+ reachable[i, j + 1] = true;
+ if (
+ i < first.Count
+ && j < second.Count
+ && first[i].Annotation.FeatureStruct.IsUnifiable(second[j].Annotation.FeatureStruct)
+ )
+ {
+ reachable[i + 1, j + 1] = true;
+ if (firstOptional)
+ reachable[i, j + 1] = true;
+ if (secondOptional)
+ reachable[i + 1, j] = true;
+ }
+ }
+ }
+ return reachable[first.Count, second.Count];
+ }
+
public Pattern Pattern
{
get { return _pattern; }
diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs
index a23ae84b1..826259988 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisMorphologicalTransformRuleSpec.cs
@@ -9,11 +9,27 @@ public abstract class AnalysisMorphologicalTransformRuleSpec
: AnalysisMorphologicalTransform,
IPatternRuleSpec
{
+ private readonly Morpher _morpher;
+ private readonly bool _hasRepeatedParts;
+
protected AnalysisMorphologicalTransformRuleSpec(
IEnumerable> lhs,
- IList rhs
+ IList rhs,
+ Morpher morpher
)
- : base(lhs, rhs) { }
+ : base(lhs, rhs)
+ {
+ _morpher = morpher;
+ _hasRepeatedParts = HasRepeatedParts;
+ }
+
+ protected bool CopiesMayAgree(Match match)
+ {
+ return _morpher == null
+ || !_morpher.PruneDisagreeingCopies
+ || !_hasRepeatedParts
+ || !HasDisagreeingCopies(match);
+ }
public bool IsApplicable(Word input)
{
diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs
index 89e30fbe2..544e531c6 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs
@@ -23,7 +23,7 @@ public AnalysisRealizationalAffixProcessRule(Morpher morpher, RealizationalAffix
{
_rules.Add(
new MultiplePatternRule(
- new AnalysisAffixProcessAllomorphRuleSpec(allo),
+ new AnalysisAffixProcessAllomorphRuleSpec(allo, morpher),
new MatcherSettings
{
Filter = ann => ann.Type() == HCFeatureSystem.Segment,
diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs
index 5595a232f..53ed1ddd0 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/MorphologicalOutputAction.cs
@@ -37,7 +37,7 @@ IDictionary capturedParts
/// The output word synthesis.
public abstract IEnumerable> Apply(Match match, Word output);
- protected IEnumerable GetSkippedOptionalNodes(Shape shape, Range range)
+ protected internal static IEnumerable GetSkippedOptionalNodes(Shape shape, Range range)
{
ShapeNode node = range.Start.Prev;
var skippedNodes = new List();
diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs
index 329074617..579d997db 100644
--- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs
+++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/AffixProcessRuleTests.cs
@@ -965,8 +965,9 @@ public void SimulfixRules()
AssertMorphsEqual(morpher.ParseWord("sɯɯpu"), "50 SIMUL");
}
- [Test]
- public void ReduplicationRules()
+ [TestCase(false)]
+ [TestCase(true)]
+ public void ReduplicationRules(bool pruneCopies)
{
var any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value;
var cons = FeatureStruct
@@ -1015,7 +1016,7 @@ public void ReduplicationRules()
);
Morphophonemic.MorphologicalRules.Add(redup);
- var morpher = new Morpher(TraceManager, Language);
+ var morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("sasag"), "RED 32");
var voicing = new RewriteRule
@@ -1033,7 +1034,7 @@ public void ReduplicationRules()
);
Allophonic.PhonologicalRules.Add(voicing);
- morpher = new Morpher(TraceManager, Language);
+ morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("sazag"), "RED 32");
var affrication = new RewriteRule
@@ -1050,7 +1051,7 @@ public void ReduplicationRules()
);
Allophonic.PhonologicalRules.Add(affrication);
- morpher = new Morpher(TraceManager, Language);
+ morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("tsazag"), "RED 32");
redup.Allomorphs.Clear();
@@ -1066,7 +1067,7 @@ public void ReduplicationRules()
}
);
- morpher = new Morpher(TraceManager, Language);
+ morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("tsagag"), "32 RED");
Allophonic.PhonologicalRules.Clear();
@@ -1084,7 +1085,7 @@ public void ReduplicationRules()
}
);
- morpher = new Morpher(TraceManager, Language);
+ morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("sagsag"), "32 RED");
AssertMorphsEqual(morpher.ParseWord("sasibudbud"), "38 RED");
@@ -1115,7 +1116,7 @@ public void ReduplicationRules()
);
Allophonic.PhonologicalRules.Add(gDelete);
- morpher = new Morpher(TraceManager, Language);
+ morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("saag"), "32 RED");
gDelete.Subrules.Clear();
@@ -1126,7 +1127,7 @@ public void ReduplicationRules()
}
);
- morpher = new Morpher(TraceManager, Language);
+ morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("saga"), "32 RED");
Allophonic.PhonologicalRules.Clear();
@@ -1152,7 +1153,7 @@ public void ReduplicationRules()
}
);
- morpher = new Morpher(TraceManager, Language);
+ morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("buiuib"), "30 RED", "31 RED");
}
@@ -1900,8 +1901,9 @@ public void SubsumedAffix()
AssertMorphsEqual(morpher.ParseWord("bubib"), "42 PRES", "43 PRES");
}
- [Test]
- public void ModifyFromInputRules()
+ [TestCase(false)]
+ [TestCase(true)]
+ public void ModifyFromInputRules(bool pruneCopies)
{
var any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value;
var vowel = FeatureStruct
@@ -1941,7 +1943,7 @@ public void ModifyFromInputRules()
);
Morphophonemic.MorphologicalRules.Add(sSuffix);
- var morpher = new Morpher(TraceManager, Language);
+ var morpher = new Morpher(TraceManager, Language) { PruneDisagreeingCopies = pruneCopies };
AssertMorphsEqual(morpher.ParseWord("puso"), "52 PL");
}
diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs
new file mode 100644
index 000000000..fa573bf21
--- /dev/null
+++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorphologicalRules/PruneDisagreeingCopiesTests.cs
@@ -0,0 +1,400 @@
+using NUnit.Framework;
+using SIL.Machine.Annotations;
+using SIL.Machine.FeatureModel;
+using SIL.Machine.Matching;
+using SIL.Machine.Morphology.HermitCrab.PhonologicalRules;
+
+namespace SIL.Machine.Morphology.HermitCrab.MorphologicalRules;
+
+public class PruneDisagreeingCopiesTests : HermitCrabTestBase
+{
+ private static readonly FeatureStruct AnySegment = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value;
+
+ [Test]
+ public void PruningIsOnByDefault()
+ {
+ Assert.That(new Morpher(TraceManager, Language).PruneDisagreeingCopies, Is.True);
+ }
+
+ [TestCase(false, 3)]
+ [TestCase(true, 1)]
+ public void FullCopyKeepsOnlyTheSplitWhoseCopiesAgree(bool pruneCopies, int expectedOutputs)
+ {
+ (string first, string second) = FindIncompatibleSegments();
+ AffixProcessRule rule = CreateCopyRule(FullPart());
+
+ List outputs = new AnalysisAffixProcessRule(CreateMorpher(pruneCopies), rule)
+ .Apply(CreateInput(first + second + first + second))
+ .ToList();
+
+ Assert.That(outputs.Count, Is.EqualTo(expectedOutputs));
+ if (pruneCopies)
+ Assert.That(outputs.Single().Shape.Count, Is.EqualTo(2));
+ }
+
+ [TestCase(false, 1)]
+ [TestCase(true, 0)]
+ public void DisagreeingCopiesAreRemovedOnlyWhenPruning(bool pruneCopies, int expectedOutputs)
+ {
+ (string first, string second) = FindIncompatibleSegments();
+ AffixProcessRule rule = CreateCopyRule(SingleSegmentPart());
+
+ List outputs = new AnalysisAffixProcessRule(CreateMorpher(pruneCopies), rule)
+ .Apply(CreateInput(first + second))
+ .ToList();
+
+ Assert.That(outputs.Count, Is.EqualTo(expectedOutputs));
+ }
+
+ [Test]
+ public void TracingStillPrunesDisagreeingCopies()
+ {
+ (string first, string second) = FindIncompatibleSegments();
+ AffixProcessRule rule = CreateCopyRule(SingleSegmentPart());
+ var input = new Word(Surface, Table3.Segment(first + second)) { AnalysisScope = new AnalysisScope() };
+ TraceManager.AnalyzeWord(Language, input);
+ input.Freeze();
+ TraceManager.IsTracing = true;
+ try
+ {
+ List outputs = new AnalysisAffixProcessRule(CreateMorpher(true), rule).Apply(input).ToList();
+
+ Assert.That(outputs, Is.Empty);
+ }
+ finally
+ {
+ TraceManager.IsTracing = false;
+ }
+ }
+
+ [TestCase(false, 1)]
+ [TestCase(true, 0)]
+ public void EveryPairOfCopiesIsCompared(bool pruneCopies, int expectedOutputs)
+ {
+ // the first copy is underspecified, as unapplied phonology can leave it, so it unifies with both others
+ (string first, string second) = FindIncompatibleSegments();
+ AffixProcessRule rule = CreateCopyRule(SingleSegmentPart(), copyCount: 3);
+ var input = new Word(Surface, Table3.Segment(first + first + second)) { AnalysisScope = new AnalysisScope() };
+ input.Shape.First.Annotation.FeatureStruct = AnySegment.Clone();
+ input.Freeze();
+
+ List outputs = new AnalysisAffixProcessRule(CreateMorpher(pruneCopies), rule).Apply(input).ToList();
+
+ Assert.That(outputs.Count, Is.EqualTo(expectedOutputs));
+ }
+
+ [Test]
+ public void OptionalSegmentThatCannotLineUpIsPruned()
+ {
+ // skipping or keeping the restored segment, every split still pairs the two incompatible segments
+ (string first, string second) = FindIncompatibleSegments();
+ AffixProcessRule rule = CreateCopyRule(FullPart());
+ Word input = CreateInputWithOptionalSecondSegment(first + second + second);
+
+ Assert.That(new AnalysisAffixProcessRule(CreateMorpher(false), rule).Apply(input), Is.Not.Empty);
+ Assert.That(new AnalysisAffixProcessRule(CreateMorpher(true), rule).Apply(input), Is.Empty);
+ }
+
+ [Test]
+ public void OptionalSegmentThatLinesUpWhenSkippedIsKept()
+ {
+ (string first, string second) = FindIncompatibleSegments();
+ AffixProcessRule rule = CreateCopyRule(FullPart());
+ Word input = CreateInputWithOptionalSecondSegment(first + second + first);
+
+ Assert.That(
+ new AnalysisAffixProcessRule(CreateMorpher(true), rule).Apply(input).Count(),
+ Is.EqualTo(new AnalysisAffixProcessRule(CreateMorpher(false), rule).Apply(input).Count())
+ );
+ }
+
+ [TestCase(false, 3)]
+ [TestCase(true, 1)]
+ public void RealizationalFullCopyKeepsOnlyTheSplitWhoseCopiesAgree(bool pruneCopies, int expectedOutputs)
+ {
+ (string first, string second) = FindIncompatibleSegments();
+ var rule = new RealizationalAffixProcessRule { Name = "real_copy", Gloss = "RED" };
+ rule.Allomorphs.Add(CreateCopyAllomorph(FullPart(), copyCount: 2));
+
+ List outputs = new AnalysisRealizationalAffixProcessRule(CreateMorpher(pruneCopies), rule)
+ .Apply(CreateInput(first + second + first + second))
+ .ToList();
+
+ Assert.That(outputs.Count, Is.EqualTo(expectedOutputs));
+ }
+
+ [TestCase(false, 3)]
+ [TestCase(true, 1)]
+ public void CompoundHeadCopiedTwiceKeepsOnlyTheSplitWhoseCopiesAgree(bool pruneCopies, int expectedOutputs)
+ {
+ var crule = new CompoundingRule { Name = "head_copy_compound" };
+ crule.Subrules.Add(
+ new CompoundingSubrule
+ {
+ HeadLhs = { Pattern.New("head").Annotation(AnySegment).OneOrMore.Value },
+ NonHeadLhs = { Pattern.New("nonHead").Annotation(AnySegment).OneOrMore.Value },
+ Rhs = { new CopyFromInput("head"), new CopyFromInput("head"), new CopyFromInput("nonHead") },
+ }
+ );
+ Morphophonemic.MorphologicalRules.Add(crule);
+
+ // "pu" is the only root the input can end in, so every analysis differs only in how "tata" splits
+ List outputs = new AnalysisCompoundingRule(CreateMorpher(pruneCopies), crule)
+ .Apply(CreateInput("tatapu"))
+ .ToList();
+
+ Assert.That(outputs.Count, Is.EqualTo(expectedOutputs));
+ Assert.That(outputs.Select(w => w.CurrentNonHead.RootAllomorph.Morpheme.Gloss), Is.All.EqualTo("52"));
+ }
+
+ [TestCase(0)]
+ [TestCase(1)]
+ [TestCase(2)]
+ public void ParsesTheSameUnderEveryParallelismSetting(int maxDegreeOfParallelism)
+ {
+ AffixProcessRule redup = CreateCopyRule(FullPart());
+ redup.RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value;
+ Morphophonemic.MorphologicalRules.Add(redup);
+
+ string[] unpruned = ParseGlosses(CreateMorpher(false, maxDegreeOfParallelism: 1), "sagsag");
+ Assert.That(unpruned, Is.EqualTo(new[] { "32 RED" }));
+ Assert.That(ParseGlosses(CreateMorpher(true, maxDegreeOfParallelism), "sagsag"), Is.EqualTo(unpruned));
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void PartialMorphemesParseTheSameWithAndWithoutPruning(bool alwaysEnforceFinalTemplates)
+ {
+ Morphophonemic.MorphologicalRules.Add(CreateCopyRule(FullPart()));
+
+ string[] Parse(bool pruneCopies, string word)
+ {
+ Morpher morpher = CreateMorpher(pruneCopies);
+ Assert.That(morpher.IsPartial, Is.True, "entry 54 has an empty syntactic feature structure");
+ morpher.AlwaysEnforceFinalTemplates = alwaysEnforceFinalTemplates;
+ return ParseGlosses(morpher, word);
+ }
+
+ Assert.That(Parse(false, "pipi"), Does.Contain("54 RED"));
+ foreach (string word in new[] { "pipi", "sagsag", "sagsa" })
+ Assert.That(Parse(true, word), Is.EqualTo(Parse(false, word)), word);
+ }
+
+ ///
+ /// The prune's weakest point: a self-feeding deletion strips two segments from the second copy only,
+ /// and = 0 lets analysis restore just one of them.
+ ///
+ [TestCase(0)]
+ [TestCase(1)]
+ [TestCase(2)]
+ public void CapLimitedDeletionInOneCopyParsesTheSameWithAndWithoutPruning(int deletionReapplications)
+ {
+ var vowel = FeatureStruct
+ .New(Language.PhonologicalFeatureSystem)
+ .Symbol(HCFeatureSystem.Segment)
+ .Symbol("voc+")
+ .Value;
+ AffixProcessRule redup = CreateCopyRule(FullPart());
+ redup.RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value;
+ Morphophonemic.MorphologicalRules.Add(redup);
+
+ var gDelete = new RewriteRule
+ {
+ Name = "g_delete",
+ Lhs = Pattern.New().Annotation(Character(Table1, "g")).Value,
+ };
+ gDelete.Subrules.Add(
+ new RewriteSubrule { LeftEnvironment = Pattern.New().Annotation(vowel).Value }
+ );
+ Allophonic.PhonologicalRules.Add(gDelete);
+
+ LexEntry root = AddEntry(
+ "GG",
+ FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value,
+ Morphophonemic,
+ "ggasa"
+ );
+ try
+ {
+ var generator = new Morpher(TraceManager, Language);
+ Assert.That(
+ generator.GenerateWords(root, new Morpheme[] { redup }, FeatureStruct.New().Value),
+ Is.EquivalentTo(new[] { "ggasaasa" }),
+ "forward derivation defines the expected parse"
+ );
+
+ string[] Parse(bool pruneCopies)
+ {
+ Morpher morpher = CreateMorpher(pruneCopies);
+ morpher.DeletionReapplications = deletionReapplications;
+ return ParseGlosses(morpher, "ggasaasa");
+ }
+
+ string[] unpruned = Parse(false);
+ Assert.That(unpruned, Does.Contain("GG RED"), "the unpruned engine finds the generated analysis");
+ Assert.That(Parse(true), Is.EqualTo(unpruned));
+ }
+ finally
+ {
+ Morphophonemic.Entries.Remove(root);
+ }
+ }
+
+ ///
+ /// Counterbleeding opacity: voicing changes only the second copy, then raising destroys the vowel that
+ /// conditioned it, so analysis must restore the copy through an environment that is no longer on the surface.
+ ///
+ [Test]
+ public void OpaqueChangeToOneCopyParsesTheSameWithAndWithoutPruning()
+ {
+ AffixProcessRule redup = CreateCopyRule(FullPart());
+ redup.RequiredSyntacticFeatureStruct = FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value;
+ Morphophonemic.MorphologicalRules.Add(redup);
+
+ var voicing = new RewriteRule
+ {
+ Name = "voicing",
+ Lhs = Pattern.New().Annotation(Character(Table1, "s")).Value,
+ };
+ voicing.Subrules.Add(
+ new RewriteSubrule
+ {
+ Rhs = Pattern
+ .New()
+ .Annotation(
+ FeatureStruct
+ .New(Language.PhonologicalFeatureSystem)
+ .Symbol(HCFeatureSystem.Segment)
+ .Symbol("vd+")
+ .Value
+ )
+ .Value,
+ LeftEnvironment = Pattern.New().Annotation(Character(Table1, "a")).Value,
+ RightEnvironment = Pattern.New().Annotation(Character(Table1, "a")).Value,
+ }
+ );
+ Allophonic.PhonologicalRules.Add(voicing);
+
+ var raising = new RewriteRule
+ {
+ Name = "raising",
+ Lhs = Pattern.New().Annotation(Character(Table1, "a")).Value,
+ };
+ raising.Subrules.Add(
+ new RewriteSubrule
+ {
+ Rhs = Pattern
+ .New()
+ .Annotation(
+ FeatureStruct
+ .New(Language.PhonologicalFeatureSystem)
+ .Symbol(HCFeatureSystem.Segment)
+ .Symbol("high+")
+ .Symbol("low-")
+ .Symbol("back-")
+ .Value
+ )
+ .Value,
+ LeftEnvironment = Pattern.New().Annotation(Character(Table1, "z")).Value,
+ }
+ );
+ Allophonic.PhonologicalRules.Add(raising);
+
+ LexEntry root = AddEntry(
+ "SA",
+ FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value,
+ Morphophonemic,
+ "sa"
+ );
+ try
+ {
+ var generator = new Morpher(TraceManager, Language);
+ Assert.That(
+ generator.GenerateWords(root, new Morpheme[] { redup }, FeatureStruct.New().Value),
+ Is.EquivalentTo(new[] { "sazi" }),
+ "forward derivation defines the expected parse"
+ );
+
+ string[] unpruned = ParseGlosses(CreateMorpher(false), "sazi");
+ Assert.That(unpruned, Does.Contain("SA RED"), "the unpruned engine finds the generated analysis");
+ Assert.That(ParseGlosses(CreateMorpher(true), "sazi"), Is.EqualTo(unpruned));
+ }
+ finally
+ {
+ Morphophonemic.Entries.Remove(root);
+ }
+ }
+
+ private Morpher CreateMorpher(bool pruneCopies, int maxDegreeOfParallelism = 0)
+ {
+ return new Morpher(TraceManager, Language, maxDegreeOfParallelism) { PruneDisagreeingCopies = pruneCopies };
+ }
+
+ private static string[] ParseGlosses(Morpher morpher, string word)
+ {
+ return morpher
+ .ParseWord(word)
+ .Select(w => string.Join(" ", w.AllomorphsInMorphOrder.Select(a => a.Morpheme.Gloss)))
+ .OrderBy(s => s, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ private static Pattern FullPart()
+ {
+ return Pattern.New("copy").Annotation(AnySegment).OneOrMore.Value;
+ }
+
+ private static Pattern SingleSegmentPart()
+ {
+ return Pattern.New("copy").Annotation(AnySegment).Value;
+ }
+
+ private static AffixProcessRule CreateCopyRule(Pattern part, int copyCount = 2)
+ {
+ var rule = new AffixProcessRule
+ {
+ Name = "full_copy",
+ Gloss = "RED",
+ RequiredSyntacticFeatureStruct = FeatureStruct.New().Value,
+ OutSyntacticFeatureStruct = FeatureStruct.New().Value,
+ };
+ rule.Allomorphs.Add(CreateCopyAllomorph(part, copyCount));
+ return rule;
+ }
+
+ private static AffixProcessAllomorph CreateCopyAllomorph(Pattern part, int copyCount)
+ {
+ var allomorph = new AffixProcessAllomorph { Lhs = { part } };
+ for (int i = 0; i < copyCount; i++)
+ allomorph.Rhs.Add(new CopyFromInput("copy"));
+ return allomorph;
+ }
+
+ private (string First, string Second) FindIncompatibleSegments()
+ {
+ CharacterDefinition[] segments = Table3
+ .Where(definition => definition.Type == HCFeatureSystem.Segment)
+ .ToArray();
+ var pair = segments
+ .SelectMany(
+ (first, index) => segments.Skip(index + 1).Select(second => new { First = first, Second = second })
+ )
+ .First(candidate => !candidate.First.FeatureStruct.IsUnifiable(candidate.Second.FeatureStruct));
+ return (pair.First.Representations.First(), pair.Second.Representations.First());
+ }
+
+ private Word CreateInputWithOptionalSecondSegment(string representation)
+ {
+ var input = new Word(Surface, Table3.Segment(representation)) { AnalysisScope = new AnalysisScope() };
+ input.Shape.First.Next.Annotation.Optional = true;
+ input.Freeze();
+ return input;
+ }
+
+ private Word CreateInput(string representation)
+ {
+ var input = new Word(Surface, Table3.Segment(representation)) { AnalysisScope = new AnalysisScope() };
+ input.Freeze();
+ return input;
+ }
+}