From faa24998044cfbb97cba79a67eb862f95c4212bb Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 28 Oct 2026 10:37:49 -0400 Subject: [PATCH] The required-slot look-ahead keeps testing behind a slot whose unmatched parts are unbounded runs, which restore no segments. Co-Authored-By: Claude Opus 5.5 --- .../RequiredSlotMaterial.cs | 22 +- .../AnalysisAffixTemplateFusionTests.cs | 238 ++++++++++++++++++ .../AnalysisAffixTemplateRuleTests.cs | 4 +- 3 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateFusionTests.cs 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") } );