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