diff --git a/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs b/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs
index f7e9cfdc3..2d0e24ec8 100644
--- a/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs
+++ b/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs
@@ -72,7 +72,8 @@ public static SlotWalk Start()
/// ), and the rule can only unapply if the constraints
/// unify with segment nodes of the shape in that order, each node used once. A rule is pure when every
/// right-hand side action copies an input part or inserts material, and every left-hand part is copied by a
- /// that must capture a segment, or has no segment to restore. Then
+ /// that must capture a segment, or restores no segment when it captures nothing
+ /// ( writes nothing back for an unbounded quantifier). Then
/// only copies input nodes and never untruncates a
/// segment. Unification can only narrow the feature structure of a copied node, so a constraint that unifies
/// with no node of the shape unifies with no node of the shape after any number of pure unapplications. Inside
@@ -429,6 +430,8 @@ private static bool IsPure(AffixProcessAllomorph allomorph)
{
if (!HasSegmentConstraint(part))
continue;
+ if (RestoredSegments(part) == 0)
+ continue;
if (!copied.Contains(part.Name) || MinSegments(part) == 0)
return false;
}
@@ -460,6 +463,23 @@ private static bool HasSegmentConstraint(PatternNode node)
return node.Children.Any(HasSegmentConstraint);
}
+ // The segment nodes that AnalysisMorphologicalTransform.Untruncate writes for a part that was not
+ // captured. An unbounded quantifier writes none, as in Mbugwe's vowel-fusion slots.
+ private static int RestoredSegments(PatternNode node)
+ {
+ int total = 0;
+ foreach (PatternNode child in node.Children)
+ {
+ if (child is Constraint constraint && constraint.Type() == HCFeatureSystem.Segment)
+ total++;
+ else if (child is Quantifier quantifier)
+ total += Math.Max(0, quantifier.MaxOccur) * RestoredSegments(quantifier);
+ else
+ total += RestoredSegments(child);
+ }
+ return total;
+ }
+
// The fewest segments a successful match of the node must consume. A part matching none is captured
// as a null range, and GenerateShape then untruncates it, which adds segments that are not copies.
private static int MinSegments(PatternNode node)
diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateFusionTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateFusionTests.cs
new file mode 100644
index 000000000..4c1c7c2a2
--- /dev/null
+++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateFusionTests.cs
@@ -0,0 +1,238 @@
+using NUnit.Framework;
+using SIL.Machine.Annotations;
+using SIL.Machine.FeatureModel;
+using SIL.Machine.Matching;
+using SIL.Machine.Morphology.HermitCrab.MorphologicalRules;
+
+namespace SIL.Machine.Morphology.HermitCrab;
+
+// A vowel-fusion slot copies a part that may match nothing; the look-ahead may test behind it only if it
+// restores no segment. Slots are in synthesis order and unapplied last to first.
+[TestFixture]
+public class AnalysisAffixTemplateFusionTests : HermitCrabTestBase
+{
+ private static readonly FeatureStruct Any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value;
+
+ private sealed record WalkResult(List Parses, int RuleAttempts, int Violations);
+
+ private static AffixTemplateSlot Slot(bool optional, params MorphemicMorphologicalRule[] rules) =>
+ new(rules) { Optional = optional };
+
+ private static AffixTemplate TemplateOf(params AffixTemplateSlot[] slots)
+ {
+ var template = new AffixTemplate { Name = "fusion_template" };
+ foreach (AffixTemplateSlot slot in slots)
+ template.Slots.Add(slot);
+ return template;
+ }
+
+ private static AffixProcessRule RuleOf(
+ string name,
+ Pattern[] lhs,
+ params MorphologicalOutputAction[] rhs
+ )
+ {
+ var rule = new AffixProcessRule
+ {
+ Id = name,
+ Name = name,
+ Gloss = name,
+ RequiredSyntacticFeatureStruct = FeatureStruct.New().Value,
+ OutSyntacticFeatureStruct = FeatureStruct.New().Value,
+ };
+ var allomorph = new AffixProcessAllomorph();
+ foreach (Pattern part in lhs)
+ allomorph.Lhs.Add(part);
+ foreach (MorphologicalOutputAction action in rhs)
+ allomorph.Rhs.Add(action);
+ rule.Allomorphs.Add(allomorph);
+ return rule;
+ }
+
+ private AffixProcessRule Suffix(string insert) =>
+ RuleOf(
+ "suffix_" + insert,
+ new[] { Pattern.New("1").Annotation(Any).OneOrMore.Value },
+ new CopyFromInput("1"),
+ new InsertSegments(Table3, insert)
+ );
+
+ // Like Mbugwe's object markers: a prefix whose stem vowel is copied twice, followed by an unbounded run
+ // that may be empty.
+ private AffixProcessRule FusionPrefix(string name, string before, string between) =>
+ RuleOf(
+ name,
+ new[]
+ {
+ Pattern.New("1").Annotation(Any).Value,
+ Pattern.New("2").Annotation(Any).ZeroOrMore.Value,
+ },
+ new InsertSegments(Table3, before),
+ new CopyFromInput("1"),
+ new InsertSegments(Table3, between),
+ new CopyFromInput("1"),
+ new CopyFromInput("2")
+ );
+
+ // The run is capped at one segment, so an unmatched run is written back as an optional segment.
+ private AffixProcessRule BoundedRunSuffix(string insert) =>
+ RuleOf(
+ "bounded_" + insert,
+ new[]
+ {
+ Pattern.New("1").Annotation(Any).OneOrMore.Value,
+ Pattern.New("2").Annotation(Any).Optional.Value,
+ },
+ new CopyFromInput("1"),
+ new InsertSegments(Table3, insert)
+ );
+
+ private WalkResult Walk(
+ AffixTemplate template,
+ string form,
+ RequiredSlotLookaheadMode mode,
+ int maxDegreeOfParallelism = 1
+ )
+ {
+ int attempts = 0;
+ int violations = 0;
+ var morpher = new Morpher(TraceManager, Language, maxDegreeOfParallelism)
+ {
+ RequiredSlotLookahead = mode,
+ RuleSelector = rule =>
+ {
+ if (rule is AffixProcessRule)
+ System.Threading.Interlocked.Increment(ref attempts);
+ return true;
+ },
+ RequiredSlotShadow = (kind, t, slot) =>
+ {
+ if (kind != "fired")
+ System.Threading.Interlocked.Increment(ref violations);
+ },
+ };
+ TraceManager.IsTracing = false;
+ var input = new Word(Morphophonemic, Morphophonemic.CharacterDefinitionTable.Segment(form));
+ input.Freeze();
+ List parses = template
+ .CompileAnalysisRule(morpher)
+ .Apply(input)
+ .Select(w => w.ToString() + " " + string.Join(",", w.AllomorphsInMorphOrder.Select(a => a.Morpheme.Gloss)))
+ .OrderBy(p => p, System.StringComparer.Ordinal)
+ .ToList();
+ return new WalkResult(parses, attempts, violations);
+ }
+
+ private (WalkResult Off, WalkResult On) WalkBoth(AffixTemplate template, string form)
+ {
+ WalkResult off = Walk(template, form, RequiredSlotLookaheadMode.Off);
+ WalkResult on = Walk(template, form, RequiredSlotLookaheadMode.On);
+ Assert.That(on.Parses, Is.EqualTo(off.Parses), "the look-ahead must not change the parses of " + form);
+ return (off, on);
+ }
+
+ [Test]
+ public void Apply_FusionSlotWithAnUnboundedRun_StillTestsTheRequiredSlotBehindIt()
+ {
+ // -ti is required inside the fusion prefix and "sagu" has no t
+ AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, FusionPrefix("fuse", "k", "a")));
+
+ (WalkResult off, WalkResult on) = WalkBoth(template, "sagu");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(off.Parses, Is.Empty);
+ Assert.That(off.RuleAttempts, Is.GreaterThan(0));
+ Assert.That(on.RuleAttempts, Is.EqualTo(0), "the fusion slot restores nothing, so -ti cannot appear");
+ });
+ }
+
+ [Test]
+ public void Apply_FusionSlotWithAnUnboundedRun_KeepsAWordThatHasTheMaterial()
+ {
+ AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, FusionPrefix("fuse", "k", "a")));
+
+ foreach (string form in new[] { "kasagti", "ksagti", "sagti", "kasagtiz" })
+ {
+ (WalkResult off, WalkResult on) = WalkBoth(template, form);
+ Assert.That(on.RuleAttempts, Is.EqualTo(off.RuleAttempts), form);
+ }
+ }
+
+ [Test]
+ public void Apply_SlotWithABoundedRunThatMayBeEmpty_RestoresASegmentTheInnerSlotCanUse()
+ {
+ // the run is written back as an optional segment, which can stand for the i of -ti
+ AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, BoundedRunSuffix("z")));
+
+ (WalkResult off, WalkResult on) = WalkBoth(template, "sagtz");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(on.Parses, Is.Not.Empty, "the parse that needs the restored segment must survive");
+ Assert.That(on.RuleAttempts, Is.EqualTo(off.RuleAttempts));
+ });
+ }
+
+ [Test]
+ public void Apply_SlotWithABoundedRunThatMayBeEmpty_StaysImpureWhenTheMaterialIsAbsent()
+ {
+ AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, BoundedRunSuffix("z")));
+
+ (WalkResult off, WalkResult on) = WalkBoth(template, "sagz");
+
+ Assert.That(on.RuleAttempts, Is.GreaterThan(0), "the slot may restore up to one segment, so no pruning");
+ }
+
+ [TestCase(1)]
+ [TestCase(0)]
+ public void Apply_OnEveryFusionTemplateAndWord_KeepsTheParsesAndNeverViolatesTheShadow(int maxDegreeOfParallelism)
+ {
+ var templates = new Dictionary
+ {
+ [TemplateOf(Slot(false, Suffix("ti")), Slot(true, FusionPrefix("fuse", "k", "a")))] = new[]
+ {
+ "kasagti",
+ "ksagti",
+ "sagu",
+ "sagti",
+ },
+ [
+ TemplateOf(
+ Slot(false, Suffix("ti")),
+ Slot(false, FusionPrefix("fuse", "k", "a")),
+ Slot(true, Suffix("z"))
+ )
+ ] = new[] { "kasagtiz", "kasagtzi", "kasagz" },
+ [TemplateOf(Slot(false, Suffix("ti")), Slot(true, BoundedRunSuffix("z")))] = new[]
+ {
+ "sagtz",
+ "sagz",
+ "sagtiz",
+ },
+ [
+ TemplateOf(
+ Slot(false, Suffix("u")),
+ Slot(false, FusionPrefix("fuse", "k", "a")),
+ Slot(false, Suffix("ti"))
+ )
+ ] = new[] { "kasagtiu", "kasaguti", "sagtiu" },
+ };
+
+ foreach ((AffixTemplate template, string[] forms) in templates)
+ {
+ foreach (string form in forms)
+ {
+ WalkResult off = Walk(template, form, RequiredSlotLookaheadMode.Off, maxDegreeOfParallelism);
+ WalkResult on = Walk(template, form, RequiredSlotLookaheadMode.On, maxDegreeOfParallelism);
+ WalkResult shadow = Walk(template, form, RequiredSlotLookaheadMode.Shadow, maxDegreeOfParallelism);
+ Assert.Multiple(() =>
+ {
+ Assert.That(on.Parses, Is.EqualTo(off.Parses), form);
+ Assert.That(shadow.Parses, Is.EqualTo(off.Parses), form);
+ Assert.That(shadow.Violations, Is.EqualTo(0), form);
+ });
+ }
+ }
+ }
+}
diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs
index ee799323a..494235ddc 100644
--- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs
+++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs
@@ -307,10 +307,10 @@ MorphologicalOutputAction[] rhs
}
[Test]
- public void Apply_CopiedPartThatMayMatchNothing_MakesTheSlotImpure()
+ public void Apply_CopiedPartCappedAtOneThatMayMatchNothing_MakesTheSlotImpure()
{
(WalkResult off, WalkResult on) = WalkWithOptionalSlot(
- new[] { Pattern.New("1").Annotation(Any).ZeroOrMore.Value },
+ new[] { Pattern.New("1").Annotation(Any).Optional.Value },
new MorphologicalOutputAction[] { new CopyFromInput("1") }
);