From 28cbee19992d95ee99a4f1d2c407c93d9c4a2de4 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 27 Oct 2026 21:53:26 -0400 Subject: [PATCH] Check required slot material in order and at the word edges before walking a template The required-slot look-ahead now needs each rule's inserted segments to match distinct segments in the order the rule inserts them, a slot that inserts only before or after its copied parts to find that material within the reach of the outer slots' own edge material, and the material of a required ordered slot to lie after the prefix and before the suffix of the required slots that unapply first. Slots that can reorder, repeat or remove from the middle switch the order and edge tests off for the slots behind them. Closes #567 Co-Authored-By: Claude Opus 5.5 --- .../AnalysisAffixTemplateRule.cs | 4 +- .../RequiredSlotMaterial.cs | 417 ++++++++++++++---- .../AnalysisAffixTemplateOrderTests.cs | 362 +++++++++++++++ 3 files changed, 691 insertions(+), 92 deletions(-) create mode 100644 tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateOrderTests.cs diff --git a/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs b/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs index d46b5971b..1f95113d0 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs @@ -93,12 +93,13 @@ private bool RequiredSlotCannotUnapply(Word word, int lastSlot, ref int[] fired) long unifications = 0; bool cannot = false; + SlotWalk walk = SlotWalk.Start(); for (int j = lastSlot; j >= 0; j--) { if ( !_template.Slots[j].Optional && !IsFired(fired, j) - && _lookahead.CannotUnapply(j, word.Shape, ref unifications) + && _lookahead.CannotUnapply(j, word.Shape, ref walk, ref unifications) ) { if (mode == RequiredSlotLookaheadMode.On) @@ -109,6 +110,7 @@ private bool RequiredSlotCannotUnapply(Word word, int lastSlot, ref int[] fired) fired = fired == null ? new[] { j } : fired.Append(j).ToArray(); _morpher.RequiredSlotShadow?.Invoke("fired", _template, j); } + _lookahead.Advance(j, ref walk); if (!_lookahead.IsPure(j)) break; } diff --git a/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs b/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs index 27e60caa6..f7e9cfdc3 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using SIL.Machine.Annotations; @@ -22,26 +23,73 @@ internal enum RequiredSlotLookaheadMode On, } + /// + /// What a walk over the slots of one template has established about the shape, in the ordinals of the shape + /// the walk started from. Create it with . + /// + internal struct SlotWalk + { + /// Stands for "any number" in and . + public const int Unbounded = int.MaxValue / 4; + + /// Every node the slots still to unapply can match lies after this ordinal. + public int Lo; + + /// Every node the slots still to unapply can match lies before this ordinal. + public int Hi; + + /// The most nodes that the slots walked so far can have removed from the left edge. + public int ReachLeft; + + /// The most nodes that the slots walked so far can have removed from the right edge. + public int ReachRight; + + /// False once a slot walked so far can reorder the nodes it copies. + public bool Ordered; + + public static SlotWalk Start() + { + return new SlotWalk + { + Lo = -1, + Hi = int.MaxValue, + Ordered = true, + }; + } + } + /// /// A necessary condition for a required affix template slot to unapply later in a template walk: some - /// allomorph of some rule in the slot must have every inserted segment unify with a segment of the shape. + /// allomorph of some rule in the slot must have its inserted segments, in the order it inserts them, unify + /// with distinct segment nodes of the shape in that order, and the nodes must be where the slots that unapply + /// first can leave them. /// /// /// The tables are built once from the template and depend only on the grammar. /// - /// Soundness. The analysis pattern of an allomorph holds one top-level constraint per inserted segment - /// (, ), - /// and the rule can only unapply if each constraint unifies with a distinct segment node of the shape. - /// 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 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 one template walk only the rules of that template's slots - /// run, and the stratum's phonological rules ran before the walk. The test for slot j is therefore used - /// only when every rule in the slots still to be walked after j is pure. Optional nodes count as present - /// because the matcher can match them. Anything that cannot be summarised (a null affix, a constraint with - /// variables, an unknown rule type) leaves the slot unprunable. + /// Presence. The analysis pattern of an allomorph holds one top-level constraint per inserted segment, in the + /// order of the right-hand side (, + /// ), 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 + /// 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 + /// one template walk only the rules of that template's slots run, and the stratum's phonological rules ran + /// before the walk. The test for slot j is therefore used only when every rule in the slots still to be walked + /// after j is pure. Optional nodes count as present because the matcher can match them. Anything that cannot + /// be summarised (a null affix, a constraint with variables that is then matched by any segment, an unknown + /// rule type) can only weaken the test. + /// + /// Order. A pure rule is ordered when it copies each part at most once and in left-hand order, so the shape + /// it unapplies to is a subsequence of the shape it was given. An ordered rule whose inserts all come before + /// its first copied part (after its last) is anchored on that side: the analysis matcher is anchored at both + /// ends of the shape, so the first inserted constraint must match the first segment left once the outer slots + /// have removed their own prefixes. A rule can remove from an edge at most as many nodes as it inserts there + /// (), and the nodes of a required ordered slot lie after the prefix and before + /// the suffix it inserts (, ). Both only hold while every + /// slot walked so far is ordered, and the reach only while none can remove from the middle. /// internal class RequiredSlotMaterial { @@ -61,70 +109,184 @@ public bool IsPure(int slot) return _slots[slot].IsPure; } + /// + /// Records that the slot is the next one to unapply, so that the slots before it are tested against what + /// it can leave of the shape. + /// + public void Advance(int slot, ref SlotWalk walk) + { + SlotSummary summary = _slots[slot]; + if (!walk.Ordered || !summary.IsOrdered) + { + walk.Ordered = false; + walk.Lo = -1; + walk.Hi = int.MaxValue; + walk.ReachLeft = SlotWalk.Unbounded; + walk.ReachRight = SlotWalk.Unbounded; + return; + } + walk.ReachLeft = Math.Min(SlotWalk.Unbounded, walk.ReachLeft + summary.ReachLeft); + walk.ReachRight = Math.Min(SlotWalk.Unbounded, walk.ReachRight + summary.ReachRight); + } + /// /// True if no allomorph of the slot can unapply to a word with this shape, provided that every slot - /// walked between this word and the slot is pure. + /// walked between this word and the slot is pure. When the slot can unapply and is ordered, narrows the + /// window of the walk for the slots before it. /// /// The slot to test. /// The shape of the partial. + /// What the slots walked since the shape was taken have established. /// Incremented by the feature structure unifications performed. - public bool CannotUnapply(int slot, Shape shape, ref long unifications) + public bool CannotUnapply(int slot, Shape shape, ref SlotWalk walk, ref long unifications) { SlotSummary summary = _slots[slot]; if (summary.MayAlwaysUnapply) return false; - // The constraints of a slot repeat across its allomorphs, so each distinct one is looked up once. - ulong known = 0; - ulong present = 0; - foreach (int[] requirements in summary.Allomorphs) + // The window is only narrowed for a slot that has slots after it in the walk, and that needs every + // passing allomorph, not the first. + bool narrow = walk.Ordered && summary.IsOrdered && slot > 0; + int newLo = int.MaxValue; + int newHi = -1; + bool any = false; + foreach (AllomorphSummary allomorph in summary.Allomorphs) { - bool all = true; - foreach (int index in requirements) + int prefixEnd = -1; + int suffixStart = int.MaxValue; + bool found = walk.Ordered + ? Embeds(allomorph, shape, ref walk, ref unifications, out prefixEnd, out suffixStart) + : AllPresent(allomorph, shape, ref unifications); + if (!found) + continue; + + if (!narrow) + return false; + any = true; + newLo = Math.Min(newLo, allomorph.Pre > 0 ? prefixEnd : walk.Lo); + newHi = Math.Max(newHi, allomorph.Post > 0 ? suffixStart : walk.Hi); + } + if (!any) + return true; + + walk.Lo = newLo; + walk.Hi = newHi; + return false; + } + + // The ordinals are those of the shape, margins excluded. A node is counted against the reach only when it + // cannot be skipped, because the matcher may skip an optional node. + private static bool Embeds( + AllomorphSummary allomorph, + Shape shape, + ref SlotWalk walk, + ref long unifications, + out int prefixEnd, + out int suffixStart + ) + { + prefixEnd = -1; + suffixStart = int.MaxValue; + FeatureStruct[] sequence = allomorph.Sequence; + int front = sequence.Length - allomorph.Post; + int forwardEnd = -1; + + int next = 0; + int before = 0; + int ordinal = -1; + foreach (ShapeNode node in shape) + { + if (next == front) + break; + ordinal++; + if (ordinal >= walk.Hi) + break; + + bool segment = node.Type() == HCFeatureSystem.Segment; + if (segment && ordinal > walk.Lo) { - bool found; - ulong bit = index < 64 ? 1UL << index : 0; - if ((known & bit) != 0) + if (next < allomorph.Pre && before > Math.Min(SlotWalk.Unbounded, walk.ReachLeft + next)) + return false; + + FeatureStruct required = sequence[next]; + if (required == null || IsUnifiable(node, required, ref unifications)) { - found = (present & bit) != 0; + if (next == allomorph.Pre - 1) + prefixEnd = ordinal; + forwardEnd = ordinal; + next++; } - else + } + if (segment && !node.Annotation.Optional) + before++; + } + if (next < front) + return false; + if (allomorph.Post == 0) + return true; + + int taken = 0; + int after = 0; + ordinal = shape.Count; + for (ShapeNode node = shape.Last; node != null && taken < allomorph.Post; node = node.Prev) + { + ordinal--; + if (ordinal <= walk.Lo || ordinal <= forwardEnd) + break; + + bool segment = node.Type() == HCFeatureSystem.Segment; + if (segment && ordinal < walk.Hi) + { + if (after > Math.Min(SlotWalk.Unbounded, walk.ReachRight + taken)) + return false; + + FeatureStruct required = sequence[sequence.Length - 1 - taken]; + if (required == null || IsUnifiable(node, required, ref unifications)) { - found = IsPresent(summary.Distinct[index], shape, ref unifications); - known |= bit; - if (found) - present |= bit; + suffixStart = ordinal; + taken++; } - if (!found) + } + if (segment && !node.Annotation.Optional) + after++; + } + return taken == allomorph.Post; + } + + // Once a slot that can reorder nodes has been walked the order of the shape says nothing about the order of + // the shape the slot unapplies to, so each constraint is only looked for on its own. + private static bool AllPresent(AllomorphSummary allomorph, Shape shape, ref long unifications) + { + foreach (FeatureStruct required in allomorph.Sequence) + { + if (required == null) + continue; + + bool found = false; + foreach (ShapeNode node in shape) + { + if (node.Type() == HCFeatureSystem.Segment && IsUnifiable(node, required, ref unifications)) { - all = false; + found = true; break; } } - if (all) + if (!found) return false; } return true; } - private static bool IsPresent(FeatureStruct required, Shape shape, ref long unifications) + private static bool IsUnifiable(ShapeNode node, FeatureStruct required, ref long unifications) { - foreach (ShapeNode node in shape) - { - if (node.Type() != HCFeatureSystem.Segment) - continue; - - unifications++; - if (node.Annotation.FeatureStruct.IsUnifiable(required, false, null)) - return true; - } - return false; + unifications++; + return node.Annotation.FeatureStruct.IsUnifiable(required, false, null); } private static SlotSummary Summarize(AffixTemplateSlot slot) { var summary = new SlotSummary(); - var allomorphs = new List(); + var allomorphs = new List(); foreach (MorphemicMorphologicalRule rule in slot.Rules) { IList ruleAllomorphs; @@ -139,73 +301,117 @@ private static SlotSummary Summarize(AffixTemplateSlot slot) default: summary.MayAlwaysUnapply = true; summary.IsPure = false; + summary.IsOrdered = false; + summary.ReachLeft = SlotWalk.Unbounded; + summary.ReachRight = SlotWalk.Unbounded; continue; } foreach (AffixProcessAllomorph allomorph in ruleAllomorphs) { - if (!IsPure(allomorph)) + bool pure = IsPure(allomorph); + if (!pure) summary.IsPure = false; + if (!pure || !IsOrdered(allomorph)) + summary.IsOrdered = false; - FeatureStruct[] requirements = GetRequirements(allomorph); - if (requirements.Length == 0) + AllomorphSummary allomorphSummary = Summarize(allomorph); + summary.ReachLeft = Math.Max(summary.ReachLeft, allomorphSummary.ReachLeft); + summary.ReachRight = Math.Max(summary.ReachRight, allomorphSummary.ReachRight); + if (allomorphSummary.Sequence.Length == 0) summary.MayAlwaysUnapply = true; else - allomorphs.Add(requirements); + allomorphs.Add(allomorphSummary); } } - var distinct = new List(); - summary.Allomorphs = allomorphs - .Select(requirements => - requirements - .Select(fs => - { - int index = distinct.FindIndex(d => d.ValueEquals(fs)); - if (index < 0) - { - distinct.Add(fs); - index = distinct.Count - 1; - } - return index; - }) - .ToArray() - ) - .ToArray(); - summary.Distinct = distinct.ToArray(); + summary.Allomorphs = allomorphs.ToArray(); return summary; } - private static FeatureStruct[] GetRequirements(AffixProcessAllomorph allomorph) + // Pre is the inserts before the first non-insertion, Post those after the last; any between is an infix. + // A non-segment constraint matches nothing the matcher sees, so skipping it only weakens the test. + private static AllomorphSummary Summarize(AffixProcessAllomorph allomorph) { - var requirements = new List(); - foreach (MorphologicalOutputAction action in allomorph.Rhs) + var sequence = new List(); + IList rhs = allomorph.Rhs; + int first = -1; + int last = -1; + for (int i = 0; i < rhs.Count; i++) { - if (action is InsertSegments insertSegments) + if (IsInsertion(rhs[i])) + continue; + if (first < 0) + first = i; + last = i; + } + + int pre = 0; + int post = 0; + bool infix = false; + for (int i = 0; i < rhs.Count; i++) + { + if (!IsInsertion(rhs[i])) + continue; + + int before = sequence.Count; + foreach (FeatureStruct fs in InsertedConstraints(rhs[i])) { - foreach (ShapeNode node in insertSegments.Segments.Shape) + if ((FeatureSymbol)fs.GetValue(HCFeatureSystem.Type) != HCFeatureSystem.Segment) + continue; + + // A constraint with variables is bound by earlier matches, so any segment stands for it. + FeatureStruct copy = null; + if (!fs.HasVariables) { - if (node.Type() != HCFeatureSystem.Boundary) - AddRequirement(requirements, node.Annotation.FeatureStruct); + copy = fs.Clone(); + copy.Freeze(); } + sequence.Add(copy); } - else if (action is InsertSimpleContext insertContext) - { - AddRequirement(requirements, insertContext.SimpleContext.FeatureStruct); - } + int added = sequence.Count - before; + if (first < 0 || (i > first && i < last)) + infix = true; + else if (i < first) + pre += added; + else + post += added; + } + + var summary = new AllomorphSummary { Sequence = sequence.ToArray() }; + if (infix) + { + summary.ReachLeft = SlotWalk.Unbounded; + summary.ReachRight = SlotWalk.Unbounded; } - return requirements.ToArray(); + else + { + summary.Pre = pre; + summary.Post = post; + summary.ReachLeft = pre; + summary.ReachRight = post; + } + return summary; } - // A constraint with variables is bound by earlier matches, and one that is not a segment is never - // matched against the segments the matcher considers, so neither can be tested here. - private static void AddRequirement(List requirements, FeatureStruct fs) + private static bool IsInsertion(MorphologicalOutputAction action) { - if (fs.HasVariables || (FeatureSymbol)fs.GetValue(HCFeatureSystem.Type) != HCFeatureSystem.Segment) - return; + return action is InsertSegments || action is InsertSimpleContext; + } - FeatureStruct copy = fs.Clone(); - copy.Freeze(); - requirements.Add(copy); + private static IEnumerable InsertedConstraints(MorphologicalOutputAction action) + { + if (action is InsertSegments insertSegments) + { + foreach (ShapeNode node in insertSegments.Segments.Shape) + { + if (node.Type() != HCFeatureSystem.Boundary) + yield return node.Annotation.FeatureStruct; + } + } + else if (action is InsertSimpleContext insertContext) + { + yield return insertContext.SimpleContext.FeatureStruct; + } } private static bool IsPure(AffixProcessAllomorph allomorph) @@ -215,7 +421,7 @@ private static bool IsPure(AffixProcessAllomorph allomorph) { if (action is CopyFromInput) copied.Add(action.PartName); - else if (!(action is InsertSegments || action is InsertSimpleContext)) + else if (!IsInsertion(action)) return false; } @@ -229,6 +435,24 @@ private static bool IsPure(AffixProcessAllomorph allomorph) return true; } + // GenerateShape writes parts in left-hand order, so copying them in another order, or one twice, can + // reorder or repeat nodes. + private static bool IsOrdered(AffixProcessAllomorph allomorph) + { + int previous = -1; + foreach (MorphologicalOutputAction action in allomorph.Rhs) + { + if (!(action is CopyFromInput)) + continue; + + int index = allomorph.Lhs.ToList().FindIndex(part => part.Name == action.PartName); + if (index <= previous) + return false; + previous = index; + } + return true; + } + private static bool HasSegmentConstraint(PatternNode node) { if (node is Constraint constraint) @@ -253,12 +477,23 @@ private static int MinSegments(PatternNode node) } } + private class AllomorphSummary + { + public FeatureStruct[] Sequence; + public int Pre; + public int Post; + public int ReachLeft; + public int ReachRight; + } + private class SlotSummary { public bool IsPure = true; + public bool IsOrdered = true; public bool MayAlwaysUnapply; - public int[][] Allomorphs; - public FeatureStruct[] Distinct; + public int ReachLeft; + public int ReachRight; + public AllomorphSummary[] Allomorphs; } } } diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateOrderTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateOrderTests.cs new file mode 100644 index 000000000..93b223e20 --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateOrderTests.cs @@ -0,0 +1,362 @@ +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; + +// Order and edge tests of the required-slot look-ahead; slots are in synthesis order and unapplied last to first. +[TestFixture] +public class AnalysisAffixTemplateOrderTests : 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 = "order_template" }; + foreach (AffixTemplateSlot slot in slots) + template.Slots.Add(slot); + return template; + } + + private static Pattern Part(string name) => + Pattern.New(name).Annotation(Any).OneOrMore.Value; + + private static Pattern OneSegment(string name) => + Pattern.New(name).Annotation(Any).Value; + + 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[] { Part("1") }, new CopyFromInput("1"), new InsertSegments(Table3, insert)); + + private AffixProcessRule Prefix(string insert) => + RuleOf("prefix_" + insert, new[] { Part("1") }, new InsertSegments(Table3, insert), new CopyFromInput("1")); + + private AffixProcessRule Circumfix(string before, string after) => + RuleOf( + "circumfix_" + before + after, + new[] { Part("1") }, + new InsertSegments(Table3, before), + new CopyFromInput("1"), + new InsertSegments(Table3, after) + ); + + // inserts the material after the first segment of the stem + private AffixProcessRule Infix(string insert) => + RuleOf( + "infix_" + insert, + new[] { OneSegment("1"), Part("2") }, + new CopyFromInput("1"), + new InsertSegments(Table3, insert), + new CopyFromInput("2") + ); + + // writes the last segment of the stem twice + private static AffixProcessRule RepeatLast() => + RuleOf( + "repeat_last", + new[] { Part("1"), OneSegment("2") }, + new CopyFromInput("1"), + new CopyFromInput("2"), + new CopyFromInput("2") + ); + + // swaps the last two segments of the stem, so it copies its parts out of order + private static AffixProcessRule Metathesis() => + RuleOf( + "metathesis", + new[] { Part("1"), OneSegment("2"), OneSegment("3") }, + new CopyFromInput("1"), + new CopyFromInput("3"), + new CopyFromInput("2") + ); + + 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_RequiredSuffixesInTheWrongOrder_PrunesAtEntry() + { + // both literals are present in "saguti", but the outer suffix -u has to end the word + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(false, Suffix("u"))); + + (WalkResult off, WalkResult wrong) = WalkBoth(template, "saguti"); + (WalkResult offRight, WalkResult right) = WalkBoth(template, "sagtiu"); + + Assert.Multiple(() => + { + Assert.That(off.Parses, Is.Empty); + Assert.That(off.RuleAttempts, Is.GreaterThan(0)); + Assert.That(wrong.RuleAttempts, Is.EqualTo(0)); + Assert.That(right.Parses, Is.Not.Empty); + Assert.That(right.RuleAttempts, Is.EqualTo(offRight.RuleAttempts)); + }); + } + + [Test] + public void Apply_PrefixSlotWhoseMaterialIsNotAtTheLeftEdge_PrunesAtEntry() + { + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(false, Prefix("ka"))); + + (WalkResult off, WalkResult wrong) = WalkBoth(template, "sagkati"); + (WalkResult offRight, WalkResult right) = WalkBoth(template, "kasagti"); + + Assert.Multiple(() => + { + Assert.That(off.RuleAttempts, Is.GreaterThan(0)); + Assert.That(wrong.RuleAttempts, Is.EqualTo(0)); + Assert.That(right.Parses, Is.Not.Empty); + Assert.That(right.RuleAttempts, Is.EqualTo(offRight.RuleAttempts)); + }); + } + + [Test] + public void Apply_CircumfixWhoseSuffixPartIsNotAtTheRightEdge_PrunesAtEntry() + { + AffixTemplate template = TemplateOf(Slot(false, Circumfix("ka", "ti"))); + + (WalkResult off, WalkResult wrong) = WalkBoth(template, "kasagtz"); + (WalkResult offRight, WalkResult right) = WalkBoth(template, "kasagti"); + + Assert.Multiple(() => + { + Assert.That(wrong.RuleAttempts, Is.EqualTo(0)); + Assert.That(off.RuleAttempts, Is.GreaterThan(0)); + Assert.That(right.Parses, Is.Not.Empty); + Assert.That(right.RuleAttempts, Is.EqualTo(offRight.RuleAttempts)); + }); + } + + [Test] + public void Apply_CircumfixPartsThatWouldShareANode_PrunesAtEntry() + { + // ka- and -ak could both use the a of "kak" + AffixTemplate template = TemplateOf(Slot(false, Circumfix("ka", "ak"))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "kak"); + + Assert.That(on.RuleAttempts, Is.EqualTo(0)); + Assert.That(off.RuleAttempts, Is.GreaterThan(0)); + } + + // an infix does not anchor, so only the window left by the outer required prefix can rule out its material + private AffixTemplate InfixInsidePrefixTemplate() => + TemplateOf(Slot(false, Infix("ak")), Slot(false, Prefix("ka"))); + + [Test] + public void Apply_InnerMaterialOnlyInsideTheOuterPrefix_PrunesAtEntry() + { + // the a of "ka" is the only a before the k that -ak needs, so the infix has no material of its own + (WalkResult off, WalkResult on) = WalkBoth(InfixInsidePrefixTemplate(), "kakg"); + + Assert.Multiple(() => + { + Assert.That(off.RuleAttempts, Is.GreaterThan(0)); + Assert.That(on.RuleAttempts, Is.EqualTo(0)); + }); + } + + [Test] + public void Apply_InnerMaterialAfterTheOuterPrefix_IsNotPruned() + { + // "sag" with the infix ak is "sakag", and ka- in front gives "kasakag" + (WalkResult off, WalkResult on) = WalkBoth(InfixInsidePrefixTemplate(), "kasakag"); + + Assert.Multiple(() => + { + Assert.That(on.Parses, Is.Not.Empty); + Assert.That(on.RuleAttempts, Is.EqualTo(off.RuleAttempts)); + }); + } + + [Test] + public void Apply_OptionalOuterPrefix_CountsTowardsTheLeftEdgeBudget() + { + AffixTemplate template = TemplateOf(Slot(false, Prefix("ka")), Slot(true, Prefix("ba"))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "bakasag"); + + Assert.That(on.Parses, Is.Not.Empty); + } + + [Test] + public void Apply_OuterSlotWithNullAffix_IsNeverPrunedAndTakesNoEdgeMaterial() + { + AffixProcessRule uOrNull = Suffix("u"); + uOrNull.Allomorphs.Add(new AffixProcessAllomorph { Lhs = { Part("1") }, Rhs = { new CopyFromInput("1") } }); + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(false, uOrNull)); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagti"); + + Assert.Multiple(() => + { + Assert.That(on.Parses, Is.Not.Empty); + Assert.That(on.RuleAttempts, Is.EqualTo(off.RuleAttempts)); + }); + } + + [Test] + public void Apply_OptionalOuterSlot_CountsTowardsTheEdgeBudget() + { + // the optional -z may stand between -ti and the end of the word + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, Suffix("z"))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagtiz"); + + Assert.That(on.Parses, Is.Not.Empty); + } + + [Test] + public void Apply_InfixSlotBetween_DoesNotAnchorTheInnerSlot() + { + // unapplying the infix z of "kzasag" gives "kasag", which starts with ka- + AffixTemplate template = TemplateOf(Slot(false, Prefix("ka")), Slot(true, Infix("z"))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "kzasag"); + + Assert.That(on.Parses, Is.Not.Empty); + } + + [Test] + public void Apply_SlotThatCopiesOutOfOrderBetween_ChecksOnlyPresence() + { + // unapplying the metathesis of "sagit" gives "sagti", whose -ti is in the opposite order + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, Metathesis())); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagit"); + + Assert.That(on.Parses, Is.Not.Empty); + } + + [Test] + public void Apply_PartCopiedTwiceBetween_DoesNotAnchorTheInnerSlot() + { + // unapplying the repeated i of "sagtii" gives "sagti", which ends in -ti + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, RepeatLast())); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagtii"); + + Assert.That(on.Parses, Is.Not.Empty); + } + + [Test] + public void Apply_ConstraintWithVariables_IsMatchedByAnySegment() + { + var context = new SimpleContext(new NaturalClass(Any), new[] { Variable("vd", "a") }); + AffixProcessRule suffix = RuleOf( + "variable_suffix", + new[] { Part("1") }, + new CopyFromInput("1"), + new InsertSimpleContext(context) + ); + AffixTemplate template = TemplateOf(Slot(false, suffix)); + + foreach (string form in new[] { "sagd", "sagt", "sag" }) + WalkBoth(template, form); + } + + [TestCase(1)] + [TestCase(0)] + public void Apply_OnEveryTemplateAndWord_KeepsTheParsesAndNeverViolatesTheShadow(int maxDegreeOfParallelism) + { + var templates = new Dictionary + { + [TemplateOf(Slot(false, Suffix("ti")), Slot(false, Suffix("u")))] = new[] { "saguti", "sagtiu", "sagti" }, + [TemplateOf(Slot(false, Suffix("ti")), Slot(false, Prefix("ka")))] = new[] { "kasagti", "sagkati" }, + [TemplateOf(Slot(false, Circumfix("ka", "ti")), Slot(true, Suffix("z")), Slot(false, Prefix("ba")))] = new[] + { + "bakasagtiz", + "kabasagti", + "bakasagtzi", + }, + [TemplateOf(Slot(false, Prefix("ka")), Slot(true, Infix("z")))] = new[] { "kzasag", "kasag" }, + [TemplateOf(Slot(false, Suffix("ti")), Slot(true, RepeatLast()))] = new[] { "sagtii", "sagti" }, + [TemplateOf(Slot(false, Suffix("ti")), Slot(true, Metathesis()))] = new[] { "sagit", "sagti" }, + }; + + 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); + }); + } + } + } +}