Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/SIL.Machine.Morphology.HermitCrab/RequiredSlotMaterial.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ public static SlotWalk Start()
/// <see cref="InsertSimpleContext.GenerateAnalysisLhs"/>), 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
/// <see cref="CopyFromInput"/> that must capture a segment, or has no segment to restore. Then
/// <see cref="CopyFromInput"/> that must capture a segment, or restores no segment when it captures nothing
/// (<see cref="AnalysisMorphologicalTransform"/> writes nothing back for an unbounded quantifier). Then
/// <see cref="AnalysisMorphologicalTransform.GenerateShape"/> 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
Expand Down Expand Up @@ -429,6 +430,8 @@ private static bool IsPure(AffixProcessAllomorph allomorph)
{
if (!HasSegmentConstraint(part))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F4. !HasSegmentConstraint(part) is now redundant: a part with no segment constraint already has RestoredSegments(part) == 0. HasSegmentConstraint has no other caller, so both the line and the method can go.

continue;
if (RestoredSegments(part) == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F2. This skip comes before !copied.Contains, so a slot that deletes an unbounded run (lhs 1=[Seg]+, 2=[Seg]*, copies only 1) is now pure too, not just one that copies it. That is sound because Untruncate writes nothing for it, but every new test copies all of its unbounded parts, so this case has no test.

continue;
if (!copied.Contains(part.Name) || MinSegments(part) == 0)
return false;
}
Expand Down Expand Up @@ -460,6 +463,23 @@ private static bool HasSegmentConstraint(PatternNode<Word, ShapeNode> 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<Word, ShapeNode> node)
{
int total = 0;
foreach (PatternNode<Word, ShapeNode> child in node.Children)
{
if (child is Constraint<Word, ShapeNode> constraint && constraint.Type() == HCFeatureSystem.Segment)
total++;
else if (child is Quantifier<Word, ShapeNode> 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<Word, ShapeNode> node)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> 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<Word, ShapeNode>[] 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<Word, ShapeNode> 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<Word, ShapeNode>.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) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F3. Every newly pure slot in these tests is unordered, because FusionPrefix copies part 1 twice. Behind a newly pure ordered slot (copy 1, copy 2, insert z), the order, reach and window tests now run where they could not before, and nothing covers that.

RuleOf(
name,
new[]
{
Pattern<Word, ShapeNode>.New("1").Annotation(Any).Value,
Pattern<Word, ShapeNode>.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<Word, ShapeNode>.New("1").Annotation(Any).OneOrMore.Value,
Pattern<Word, ShapeNode>.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<string> 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<AffixTemplate, string[]>
{
[TemplateOf(Slot(false, Suffix("ti")), Slot(true, FusionPrefix("fuse", "k", "a")))] = new[]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: F1. No test fails if Untruncate starts writing back a segment for an unbounded run, even though the PR says the tests pin that. fuse unapplies with an empty run on no test word (ksagti gives run ti; the rest never match k X a X), so Off and On agree whatever is restored. Add a word like ktat with an inner Suffix("i"), so that the Off arm gains a parse a restored segment would allow and On would prune. Unverified: I reasoned this out and did not mutate Untruncate and run the tests.

{
"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);
});
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,10 @@ MorphologicalOutputAction[] rhs
}

[Test]
public void Apply_CopiedPartThatMayMatchNothing_MakesTheSlotImpure()
public void Apply_CopiedPartCappedAtOneThatMayMatchNothing_MakesTheSlotImpure()
{
(WalkResult off, WalkResult on) = WalkWithOptionalSlot(
new[] { Pattern<Word, ShapeNode>.New("1").Annotation(Any).ZeroOrMore.Value },
new[] { Pattern<Word, ShapeNode>.New("1").Annotation(Any).Optional.Value },
new MorphologicalOutputAction[] { new CopyFromInput("1") }
);

Expand Down
Loading