From db02d7e6775d05933ef25fd2001b84ff2c4da7dd Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 28 Oct 2026 11:27:32 -0400 Subject: [PATCH] The required-slot look-ahead keeps testing behind a slot that truncates a segment, allowing that many missing segments if the slot can restore them. Co-Authored-By: Claude Opus 5.5 --- .../AnalysisAffixTemplateRule.cs | 6 +- .../RequiredSlotMaterial.cs | 138 +++++++- .../AnalysisAffixTemplateFusionTests.cs | 4 +- .../AnalysisAffixTemplateRuleTests.cs | 15 +- .../AnalysisAffixTemplateSupplyTests.cs | 313 ++++++++++++++++++ 5 files changed, 453 insertions(+), 23 deletions(-) create mode 100644 tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateSupplyTests.cs diff --git a/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs b/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs index 1f95113d0..193415de3 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs @@ -83,8 +83,8 @@ public IEnumerable Apply(Word input) return output; } - // Testing runs from the next slot to unapply and stops after an impure slot, which makes the test - // for any slot behind it unsound. Shadow mode prunes nothing and carries the fired slots in fired. + // Testing runs from the next slot to unapply and stops after a slot whose written-back segments cannot be + // bounded. Shadow mode prunes nothing and carries the fired slots in fired. private bool RequiredSlotCannotUnapply(Word word, int lastSlot, ref int[] fired) { RequiredSlotLookaheadMode mode = _morpher.RequiredSlotLookahead; @@ -111,7 +111,7 @@ private bool RequiredSlotCannotUnapply(Word word, int lastSlot, ref int[] fired) _morpher.RequiredSlotShadow?.Invoke("fired", _template, j); } _lookahead.Advance(j, ref walk); - if (!_lookahead.IsPure(j)) + if (!_lookahead.CanWalkPast(j)) break; } if (unifications != 0) diff --git a/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs b/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs index 2d0e24ec8..b22d8bdce 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs @@ -47,6 +47,11 @@ internal struct SlotWalk /// False once a slot walked so far can reorder the nodes it copies. public bool Ordered; + /// + /// The slots walked so far that can write segments the shape does not hold, as a bit mask of slot indexes. + /// + public int Suppliers; + public static SlotWalk Start() { return new SlotWalk @@ -94,20 +99,28 @@ public static SlotWalk Start() /// internal class RequiredSlotMaterial { + // Slots at or above this index cannot be recorded in SlotWalk.Suppliers. + private const int MaxSupplierSlots = 31; + private readonly SlotSummary[] _slots; public RequiredSlotMaterial(AffixTemplate template) { _slots = template.Slots.Select(Summarize).ToArray(); + for (int i = 0; i < _slots.Length; i++) + { + foreach (AllomorphSummary allomorph in _slots[i].Allomorphs) + allomorph.Cover = CoverMasks(allomorph, i); + } } /// - /// Whether every rule of the slot is pure, so that unapplying the slot leaves the segments needed by - /// slots before it no easier to find. + /// Whether the walk may go on past the slot: it is pure, or the segments it can write are bounded, so that + /// the slots before it are tested with those segments allowed to be missing from the shape. /// - public bool IsPure(int slot) + public bool CanWalkPast(int slot) { - return _slots[slot].IsPure; + return _slots[slot].IsPure || (_slots[slot].Suppliable && slot < MaxSupplierSlots); } /// @@ -117,6 +130,8 @@ public bool IsPure(int slot) public void Advance(int slot, ref SlotWalk walk) { SlotSummary summary = _slots[slot]; + if (!summary.IsPure && summary.Suppliable && slot < MaxSupplierSlots) + walk.Suppliers |= 1 << slot; if (!walk.Ordered || !summary.IsOrdered) { walk.Ordered = false; @@ -132,7 +147,8 @@ public void Advance(int slot, ref SlotWalk walk) /// /// 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. When the slot can unapply and is ordered, narrows the + /// walked between this word and the slot is pure or has its written-back segments bounded and counted + /// in the walk. When the slot can unapply and is ordered, narrows the /// window of the walk for the slots before it. /// /// The slot to test. @@ -157,7 +173,7 @@ public bool CannotUnapply(int slot, Shape shape, ref SlotWalk walk, ref long uni int suffixStart = int.MaxValue; bool found = walk.Ordered ? Embeds(allomorph, shape, ref walk, ref unifications, out prefixEnd, out suffixStart) - : AllPresent(allomorph, shape, ref unifications); + : AllPresent(allomorph, shape, walk.Suppliers, ref unifications); if (!found) continue; @@ -254,12 +270,15 @@ out int suffixStart 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) + // The order of the shape says nothing once a reordering slot is walked, so each constraint is looked for + // alone. One with no node may be met by a segment a walked supplier writes, once each. + private bool AllPresent(AllomorphSummary allomorph, Shape shape, int suppliers, ref long unifications) { - foreach (FeatureStruct required in allomorph.Sequence) + int missing = 0; + int covering = 0; + for (int i = 0; i < allomorph.Sequence.Length; i++) { + FeatureStruct required = allomorph.Sequence[i]; if (required == null) continue; @@ -272,10 +291,46 @@ private static bool AllPresent(AllomorphSummary allomorph, Shape shape, ref long break; } } - if (!found) + if (found) + continue; + + int coverable = allomorph.Cover[i] & suppliers; + if (coverable == 0) return false; + missing++; + covering |= coverable; } - return true; + if (missing == 0) + return true; + + int budget = 0; + for (int slot = 0; covering != 0; slot++, covering >>= 1) + { + if ((covering & 1) != 0) + budget += _slots[slot].Budget; + } + return missing <= budget; + } + + // For each constraint, the slots that can write a segment it unifies with. + private int[] CoverMasks(AllomorphSummary allomorph, int own) + { + var masks = new int[allomorph.Sequence.Length]; + for (int i = 0; i < masks.Length; i++) + { + FeatureStruct required = allomorph.Sequence[i]; + if (required == null) + continue; + + for (int slot = 0; slot < _slots.Length && slot < MaxSupplierSlots; slot++) + { + if (slot == own || !_slots[slot].Suppliable || _slots[slot].IsPure) + continue; + if (_slots[slot].Supply.Any(fs => fs == null || fs.IsUnifiable(required, false, null))) + masks[i] |= 1 << slot; + } + } + return masks; } private static bool IsUnifiable(ShapeNode node, FeatureStruct required, ref long unifications) @@ -288,6 +343,7 @@ private static SlotSummary Summarize(AffixTemplateSlot slot) { var summary = new SlotSummary(); var allomorphs = new List(); + var supply = new List(); foreach (MorphemicMorphologicalRule rule in slot.Rules) { IList ruleAllomorphs; @@ -302,6 +358,7 @@ private static SlotSummary Summarize(AffixTemplateSlot slot) default: summary.MayAlwaysUnapply = true; summary.IsPure = false; + summary.Suppliable = false; summary.IsOrdered = false; summary.ReachLeft = SlotWalk.Unbounded; summary.ReachRight = SlotWalk.Unbounded; @@ -313,6 +370,11 @@ private static SlotSummary Summarize(AffixTemplateSlot slot) bool pure = IsPure(allomorph); if (!pure) summary.IsPure = false; + int restored = Restored(allomorph, supply); + if (restored < 0) + summary.Suppliable = false; + else + summary.Budget = Math.Max(summary.Budget, restored); if (!pure || !IsOrdered(allomorph)) summary.IsOrdered = false; @@ -326,6 +388,7 @@ private static SlotSummary Summarize(AffixTemplateSlot slot) } } summary.Allomorphs = allomorphs.ToArray(); + summary.Supply = supply.ToArray(); return summary; } @@ -438,6 +501,53 @@ private static bool IsPure(AffixProcessAllomorph allomorph) return true; } + // The most segments the allomorph writes back, their constraints added to supply (null with variables). + // -1 if it does more than copy and insert, as a modified node can change any feature of any node. + private static int Restored(AffixProcessAllomorph allomorph, List supply) + { + var copied = new HashSet(); + foreach (MorphologicalOutputAction action in allomorph.Rhs) + { + if (action is CopyFromInput) + copied.Add(action.PartName); + else if (!IsInsertion(action)) + return -1; + } + + int total = 0; + foreach (Pattern part in allomorph.Lhs) + { + if (!HasSegmentConstraint(part) || RestoredSegments(part) == 0) + continue; + if (copied.Contains(part.Name) && MinSegments(part) > 0) + continue; + total += RestoredSegments(part); + AddRestoredConstraints(part, supply); + } + return total; + } + + private static void AddRestoredConstraints(PatternNode node, List supply) + { + foreach (PatternNode child in node.Children) + { + if (child is Constraint constraint && constraint.Type() == HCFeatureSystem.Segment) + { + FeatureStruct copy = null; + if (!constraint.FeatureStruct.HasVariables) + { + copy = constraint.FeatureStruct.Clone(); + copy.Freeze(); + } + supply.Add(copy); + } + else if (!(child is Quantifier quantifier) || quantifier.MaxOccur > 0) + { + AddRestoredConstraints(child, supply); + } + } + } + // 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) @@ -500,6 +610,7 @@ private static int MinSegments(PatternNode node) private class AllomorphSummary { public FeatureStruct[] Sequence; + public int[] Cover; public int Pre; public int Post; public int ReachLeft; @@ -511,6 +622,9 @@ private class SlotSummary public bool IsPure = true; public bool IsOrdered = true; public bool MayAlwaysUnapply; + public bool Suppliable = true; + public int Budget; + public FeatureStruct[] Supply; public int ReachLeft; public int ReachRight; public AllomorphSummary[] Allomorphs; diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateFusionTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateFusionTests.cs index 4c1c7c2a2..adfb9fb41 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateFusionTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateFusionTests.cs @@ -175,9 +175,9 @@ public void Apply_SlotWithABoundedRunThatMayBeEmpty_RestoresASegmentTheInnerSlot } [Test] - public void Apply_SlotWithABoundedRunThatMayBeEmpty_StaysImpureWhenTheMaterialIsAbsent() + public void Apply_SlotWithABoundedRunThatMayBeEmpty_StaysImpureWhenTheRunCouldBeTheMissingSegment() { - AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, BoundedRunSuffix("z"))); + AffixTemplate template = TemplateOf(Slot(false, Suffix("i")), Slot(true, BoundedRunSuffix("z"))); (WalkResult off, WalkResult on) = WalkBoth(template, "sagz"); diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs index 494235ddc..9e2a0254d 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs @@ -280,11 +280,12 @@ public void Apply_RequiredSlotWithNullAffix_IsNeverPruned() }); } - // An optional slot whose rule inserts "z" after the copied parts; the word never contains the t and i - // that the required inner slot inserts, so a pure rule lets the walk prune at entry, before the optional rule is tried, and an impure one does not. + // An optional slot inserting "z"; the word lacks the inner slot's segments, so a pure rule prunes at entry. + // An impure rule does not if it can restore them: those cases use the one segment it may restore. private (WalkResult Off, WalkResult On) WalkWithOptionalSlot( Pattern[] lhs, - MorphologicalOutputAction[] rhs + MorphologicalOutputAction[] rhs, + string inner = "ti" ) { var rule = new AffixProcessRule @@ -302,7 +303,7 @@ MorphologicalOutputAction[] rhs allomorph.Rhs.Add(action); allomorph.Rhs.Add(new InsertSegments(Table3, "z")); rule.Allomorphs.Add(allomorph); - AffixTemplate template = TemplateOf(Slot(false, SuffixRule("ti", "ti")), Slot(true, rule)); + AffixTemplate template = TemplateOf(Slot(false, SuffixRule(inner, inner)), Slot(true, rule)); return WalkBoth(template, "sagz"); } @@ -311,7 +312,8 @@ public void Apply_CopiedPartCappedAtOneThatMayMatchNothing_MakesTheSlotImpure() { (WalkResult off, WalkResult on) = WalkWithOptionalSlot( new[] { Pattern.New("1").Annotation(Any).Optional.Value }, - new MorphologicalOutputAction[] { new CopyFromInput("1") } + new MorphologicalOutputAction[] { new CopyFromInput("1") }, + "i" ); Assert.That(on.RuleAttempts, Is.GreaterThan(0), "the optional rule is still tried at entry"); @@ -370,7 +372,8 @@ public void Apply_CopiedAlternationWithAnOptionalBranch_MakesTheSlotImpure() .Optional.Or.Annotation(Character(Table3, "u")) .Value, }, - new MorphologicalOutputAction[] { new CopyFromInput("1"), new CopyFromInput("2") } + new MorphologicalOutputAction[] { new CopyFromInput("1"), new CopyFromInput("2") }, + "i" ); Assert.That(on.RuleAttempts, Is.GreaterThan(0), "the optional rule is still tried at entry"); diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateSupplyTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateSupplyTests.cs new file mode 100644 index 000000000..64c117687 --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateSupplyTests.cs @@ -0,0 +1,313 @@ +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 truncating slot writes a segment back when it unapplies. Slots behind it may be missing that many +// segments, if it can write them. Slots are in synthesis order and unapplied last to first. +[TestFixture] +public class AnalysisAffixTemplateSupplyTests : HermitCrabTestBase +{ + private static readonly FeatureStruct Any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + + private sealed record WalkResult(List Parses, int RuleAttempts, int Violations); + + private FeatureStruct Vowel => + FeatureStruct.New(Language.PhonologicalFeatureSystem).Symbol(HCFeatureSystem.Segment).Symbol("voc+").Value; + + 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 = "supply_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) + ); + + // Deletes the last segment of the stem, which has to match deleted, and then adds the suffix. + private AffixProcessRule TruncatingSuffix(string insert, FeatureStruct deleted) => + RuleOf( + "truncating_" + insert, + new[] + { + Pattern.New("1").Annotation(Any).OneOrMore.Value, + Pattern.New("2").Annotation(deleted).Value, + }, + new CopyFromInput("1"), + new InsertSegments(Table3, insert) + ); + + // Deletes the last two segments of the stem, so unapplying it writes two segments back. + private AffixProcessRule TwoSegmentTruncatingSuffix(string insert) => + RuleOf( + "truncating2_" + insert, + new[] + { + Pattern.New("1").Annotation(Any).OneOrMore.Value, + Pattern.New("2").Annotation(Any).Value, + Pattern.New("3").Annotation(Any).Value, + }, + new CopyFromInput("1"), + new InsertSegments(Table3, insert) + ); + + // An uncopied run of up to two segments that may be empty, which is written back as two optional segments. + private AffixProcessRule RunOfTwoSuffix(string insert) => + RuleOf( + "run2_" + insert, + new[] + { + Pattern.New("1").Annotation(Any).OneOrMore.Value, + Pattern.New("2").Annotation(Any).Range(0, 2).Value, + }, + new CopyFromInput("1"), + new InsertSegments(Table3, insert) + ); + + private WalkResult Walk(AffixTemplate template, string form, RequiredSlotLookaheadMode mode) + { + int attempts = 0; + int violations = 0; + var morpher = new Morpher(TraceManager, Language, 1) + { + RequiredSlotLookahead = mode, + RuleSelector = rule => + { + if (rule is AffixProcessRule) + attempts++; + return true; + }, + RequiredSlotShadow = (kind, t, slot) => + { + if (kind != "fired") + 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_RequiredSlotNeedsMoreSegmentsThanTheTruncatingSlotRestores_PrunesAtEntry() + { + // -ti needs two segments, the shape has neither, and one deleted segment gives back at most one + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, TruncatingSuffix("z", Any))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagz"); + + Assert.Multiple(() => + { + Assert.That(off.RuleAttempts, Is.GreaterThan(0)); + Assert.That(on.RuleAttempts, Is.EqualTo(0)); + }); + } + + [Test] + public void Apply_RequiredSlotNeedsOneSegmentTheTruncatingSlotRestores_KeepsTheParse() + { + // sag + ti, then the i is deleted and z added: the i of -ti is not in the word + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, TruncatingSuffix("z", Any))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagtz"); + + Assert.Multiple(() => + { + Assert.That(on.Parses, Is.Not.Empty); + Assert.That(on.RuleAttempts, Is.EqualTo(off.RuleAttempts)); + }); + } + + [Test] + public void Apply_MissingSegmentIsOneTheTruncatingSlotCannotRestore_PrunesAtEntry() + { + // the deleted segment is a vowel, so it cannot be the t of -t + AffixTemplate template = TemplateOf(Slot(false, Suffix("t")), Slot(true, TruncatingSuffix("z", Vowel))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagz"); + + Assert.Multiple(() => + { + Assert.That(off.RuleAttempts, Is.GreaterThan(0)); + Assert.That(on.RuleAttempts, Is.EqualTo(0)); + }); + } + + [Test] + public void Apply_MissingSegmentIsOneTheTruncatingSlotCanRestore_StillTriesTheRules() + { + AffixTemplate template = TemplateOf(Slot(false, Suffix("i")), Slot(true, TruncatingSuffix("z", Vowel))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagz"); + + Assert.That(on.RuleAttempts, Is.EqualTo(off.RuleAttempts)); + } + + [Test] + public void Apply_TwoTruncatingSlots_RestoreTheirSegmentsTogether() + { + // sag + ti, the i is deleted for z and the z for k: sagtk has neither t's partner i nor the z + AffixTemplate template = TemplateOf( + Slot(false, Suffix("ti")), + Slot(true, TruncatingSuffix("z", Any)), + Slot(true, TruncatingSuffix("k", Any)) + ); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagtk"); + Assert.Multiple(() => + { + Assert.That(on.Parses, Is.Not.Empty); + Assert.That(on.RuleAttempts, Is.EqualTo(off.RuleAttempts)); + }); + + (_, WalkResult onAbsent) = WalkBoth(template, "sagk"); + Assert.That(onAbsent.RuleAttempts, Is.GreaterThan(0), "two restored segments may be enough"); + } + + [Test] + public void Apply_SlotThatWritesBackTwoSegments_KeepsAWordMissingTwoSegments() + { + // sag + ti, then both are deleted and z added: the t and i of -ti are not in the word + AffixTemplate template = TemplateOf(Slot(false, Suffix("ti")), Slot(true, TwoSegmentTruncatingSuffix("z"))); + + (WalkResult off, WalkResult on) = WalkBoth(template, "sagz"); + + Assert.Multiple(() => + { + Assert.That(on.Parses, Is.Not.Empty); + Assert.That(on.RuleAttempts, Is.EqualTo(off.RuleAttempts), "two restored segments are enough"); + }); + + (WalkResult off3, WalkResult on3) = WalkBoth( + TemplateOf(Slot(false, Suffix("tik")), Slot(true, TwoSegmentTruncatingSuffix("z"))), + "sagz" + ); + Assert.Multiple(() => + { + Assert.That(off3.RuleAttempts, Is.GreaterThan(0)); + Assert.That(on3.RuleAttempts, Is.EqualTo(0), "three missing segments are more than two restored"); + }); + } + + [Test] + public void Apply_SlotWithAnUncopiedRunOfUpToTwo_AllowsTwoMissingSegmentsAndNoMore() + { + (WalkResult off2, WalkResult on2) = WalkBoth( + TemplateOf(Slot(false, Suffix("ti")), Slot(true, RunOfTwoSuffix("z"))), + "sagz" + ); + Assert.That(on2.RuleAttempts, Is.EqualTo(off2.RuleAttempts), "the run restores up to two segments"); + + (WalkResult off3, WalkResult on3) = WalkBoth( + TemplateOf(Slot(false, Suffix("tik")), Slot(true, RunOfTwoSuffix("z"))), + "sagz" + ); + Assert.Multiple(() => + { + Assert.That(off3.RuleAttempts, Is.GreaterThan(0)); + Assert.That(on3.RuleAttempts, Is.EqualTo(0)); + }); + } + + [Test] + public void Apply_OnEveryTruncationTemplateAndWord_KeepsTheParsesAndNeverViolatesTheShadow() + { + var templates = new Dictionary + { + [TemplateOf(Slot(false, Suffix("ti")), Slot(true, TruncatingSuffix("z", Any)))] = new[] + { + "sagz", + "sagtz", + "sagtiz", + "sagtzz", + }, + [TemplateOf(Slot(false, Suffix("t")), Slot(true, TruncatingSuffix("z", Vowel)))] = new[] + { + "sagz", + "sagtz", + "sagtiz", + }, + [ + TemplateOf( + Slot(false, Suffix("ti")), + Slot(true, TruncatingSuffix("z", Any)), + Slot(true, TruncatingSuffix("k", Any)) + ) + ] = new[] { "sagtk", "sagk", "sagtzk", "sagtik" }, + [ + TemplateOf( + Slot(false, TruncatingSuffix("u", Any)), + Slot(false, Suffix("ti")), + Slot(true, TruncatingSuffix("z", Any)) + ) + ] = new[] { "sagtu", "sagzu", "sagtiuz", "sagtzu" }, + }; + + foreach ((AffixTemplate template, string[] forms) in templates) + { + foreach (string form in forms) + { + WalkResult off = Walk(template, form, RequiredSlotLookaheadMode.Off); + WalkResult on = Walk(template, form, RequiredSlotLookaheadMode.On); + WalkResult shadow = Walk(template, form, RequiredSlotLookaheadMode.Shadow); + 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); + }); + } + } + } +}