Skip to content
Merged
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))
continue;
if (RestoredSegments(part) == 0)
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) =>
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[]
{
"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 @@ -280,8 +280,8 @@
});
}

// An optional slot whose rule inserts "z" after the copied parts; the word never contains the t and i

Check warning on line 283 in tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs

View workflow job for this annotation

GitHub Actions / Report comment hygiene (ubuntu-22.04)

comment-too-long: 244 chars (budget 200): An optional slot whose rule inserts "z" after the copied parts; the word never contains the t and i

Check warning on line 283 in tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs

View workflow job for this annotation

GitHub Actions / Report comment hygiene (windows-latest)

comment-too-long: 244 chars (budget 200): 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.

Check warning on line 284 in tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs

View workflow job for this annotation

GitHub Actions / Report comment hygiene (ubuntu-22.04)

comment-line-too-long: 152 columns (max 120): 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.

Check warning on line 284 in tests/SIL.Machine.Morphology.HermitCrab.Tests/AnalysisAffixTemplateRuleTests.cs

View workflow job for this annotation

GitHub Actions / Report comment hygiene (windows-latest)

comment-line-too-long: 152 columns (max 120): 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.
private (WalkResult Off, WalkResult On) WalkWithOptionalSlot(
Pattern<Word, ShapeNode>[] lhs,
MorphologicalOutputAction[] rhs
Expand All @@ -307,10 +307,10 @@
}

[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