From b12553f2eb259b8fa0ccdae1eae2c5147ea3738b Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 14 Aug 2026 10:53:06 -0400 Subject: [PATCH] Add a grammar health checker for two HermitCrab admissibility preconditions HermitCrab imposes two requirements on a grammar that nothing reports today, so a grammar author only learns of a violation as a parse that silently returns nothing. Every segment used must be declared in a CharacterDefinitionTable: an undeclared segment makes the parser refuse every word containing it. And each segment needs a distinct phonological feature bundle within its table: when two share one, the parser cannot reliably determine which morphemes are involved. GrammarHealthChecker.Check(Language) reports both as findings with a severity, a stable code and the offending declarations named. It is diagnostic only -- a grammar the engine would load still loads, and nothing throws. Lives in the netstandard2.0 engine library rather than a tool, so FieldWorks and any other host can call it directly on a loaded Language. The duplicate-bundle check is skipped for a grammar that declares no PhonologicalFeatureSystem at all. Such a grammar distinguishes segments by their representation alone, so every bundle is the same empty struct by construction and reporting it would be a false positive on a correct grammar. --- .../GrammarHealthChecker.cs | 227 ++++++++++++++++++ .../GrammarHealthFinding.cs | 90 +++++++ .../GrammarHealthCheckerTests.cs | 126 ++++++++++ 3 files changed, 443 insertions(+) create mode 100644 src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs create mode 100644 src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs create mode 100644 tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs new file mode 100644 index 00000000..7834c3ae --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SIL.Machine.Annotations; +using SIL.Machine.FeatureModel; +using SIL.Machine.Morphology.HermitCrab.MorphologicalRules; + +namespace SIL.Machine.Morphology.HermitCrab +{ + /// + /// Checks a loaded against two admissibility preconditions HermitCrab + /// depends on but never enforces itself: every segment used by the grammar must be declared in + /// a (an undeclared segment makes the engine refuse the + /// whole word, silently), and every declared segment in a table must have a phonological + /// feature bundle distinct from its neighbors (otherwise a segment-changing rule cannot tell + /// which one it is looking at). Both violations parse successfully today with no warning, so + /// this exists to surface them before the grammar ships. It is diagnostic only: it never + /// changes how a parses. + /// + public static class GrammarHealthChecker + { + /// + /// Runs every check against and returns the findings, in the + /// order the checks ran. An empty list means both preconditions hold, not that nothing was + /// checked -- see for what each finding's code means. + /// + public static IList Check(Language language) + { + if (language == null) + throw new ArgumentNullException("language"); + + var findings = new List(); + CheckDuplicateFeatureBundles(language, findings); + CheckUndeclaredSegments(language, findings); + return findings; + } + + // Every table's segments must have distinct phonological feature bundles, or a segment-changing rule cannot tell them apart. + private static void CheckDuplicateFeatureBundles(Language language, List findings) + { + // No feature system means every bundle is the same empty struct by construction (see PhonologicalBundle), not a collision. + if (language.PhonologicalFeatureSystem.Count == 0) + return; + + foreach (CharacterDefinitionTable table in language.CharacterDefinitionTables) + { + List segmentDefs = table + .Where(cd => cd.Type == HCFeatureSystem.Segment) + .OrderBy(cd => cd.Representations.First(), StringComparer.Ordinal) + .ToList(); + + // ValueEquals is the model's own deep, order-independent feature-value equality. + var groups = new List>(); + foreach (CharacterDefinition cd in segmentDefs) + { + FeatureStruct bundle = PhonologicalBundle(cd); + List group = groups.FirstOrDefault( + g => PhonologicalBundle(g[0]).ValueEquals(bundle) + ); + if (group == null) + { + group = new List(); + groups.Add(group); + } + group.Add(cd); + } + + foreach (List group in groups) + { + if (group.Count < 2) + continue; + + string names = string.Join(", ", group.Select(cd => cd.Representations.First())); + var subjects = new List { table }; + subjects.AddRange(group); + findings.Add( + new GrammarHealthFinding( + GrammarHealthSeverity.Warning, + GrammarHealthCodes.DuplicateFeatureBundle, + string.Format( + "Character definition table '{0}' has {1} segments with an identical " + + "phonological feature bundle, so a segment-changing rule cannot reliably " + + "tell them apart: {2}.", + table.Name, + group.Count, + names + ), + subjects + ) + ); + } + } + } + + // Strips Type (constant per segment) and any synthesized StrRep, neither of which the grammar author chose. + private static FeatureStruct PhonologicalBundle(CharacterDefinition cd) + { + FeatureStruct bundle = cd.FeatureStruct.Clone(); + bundle.RemoveValue(HCFeatureSystem.Type); + bundle.RemoveValue(HCFeatureSystem.StrRep); + return bundle; + } + + // Every segment the grammar actually uses must be declared in the table it is used against. + private static void CheckUndeclaredSegments(Language language, List findings) + { + var declaredTables = new HashSet(language.CharacterDefinitionTables); + + foreach (Stratum stratum in language.Strata) + { + foreach (LexEntry entry in stratum.Entries) + { + foreach (RootAllomorph allomorph in entry.Allomorphs) + { + CheckSegmentsDeclared( + allomorph.Segments, + string.Format( + "Lexical entry '{0}' allomorph '{1}'", + entry.Id, + allomorph.Segments.Representation + ), + findings + ); + } + } + + foreach (IMorphologicalRule rule in stratum.MorphologicalRules) + { + var affixRule = rule as AffixProcessRule; + if (affixRule != null) + { + foreach (AffixProcessAllomorph allomorph in affixRule.Allomorphs) + { + foreach (InsertSegments insert in allomorph.Rhs.OfType()) + { + CheckSegmentsDeclared( + insert.Segments, + string.Format( + "Morphological rule '{0}' inserted segments '{1}'", + affixRule.Name, + insert.Segments.Representation + ), + findings + ); + } + } + } + + var compoundingRule = rule as CompoundingRule; + if (compoundingRule != null) + { + foreach (CompoundingSubrule subrule in compoundingRule.Subrules) + { + foreach (InsertSegments insert in subrule.Rhs.OfType()) + { + CheckSegmentsDeclared( + insert.Segments, + string.Format( + "Compounding rule '{0}' inserted segments '{1}'", + compoundingRule.Name, + insert.Segments.Representation + ), + findings + ); + } + } + } + } + } + + foreach (NaturalClass naturalClass in language.NaturalClasses) + { + var segmentClass = naturalClass as SegmentNaturalClass; + if (segmentClass == null) + continue; + + foreach (CharacterDefinition cd in segmentClass.Segments) + { + if (cd.CharacterDefinitionTable != null && declaredTables.Contains(cd.CharacterDefinitionTable)) + continue; + + findings.Add( + new GrammarHealthFinding( + GrammarHealthSeverity.Error, + GrammarHealthCodes.UndeclaredSegment, + string.Format( + "Natural class '{0}' references a segment ('{1}') that does not belong to any " + + "character definition table in this language.", + naturalClass.Name, + cd.Representations.Count > 0 ? cd.Representations.First() : cd.FeatureStruct.ToString() + ), + new object[] { naturalClass, cd } + ) + ); + } + } + } + + // Same GetMatchingStrReps lookup used to render a shape back to text; boundary/anchor nodes are structural, not graphemes. + private static void CheckSegmentsDeclared(Segments segments, string where, List findings) + { + CharacterDefinitionTable table = segments.CharacterDefinitionTable; + foreach (ShapeNode node in segments.Shape) + { + if (node.Annotation.Type() != HCFeatureSystem.Segment) + continue; + if (table.GetMatchingStrReps(node).Any()) + continue; + + findings.Add( + new GrammarHealthFinding( + GrammarHealthSeverity.Error, + GrammarHealthCodes.UndeclaredSegment, + string.Format( + "{0} contains a segment with feature bundle {1} that character definition table " + + "'{2}' does not declare.", + where, + node.Annotation.FeatureStruct, + table.Name + ), + new object[] { table, segments, node } + ) + ); + } + } + } +} diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs new file mode 100644 index 00000000..810b2e2a --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace SIL.Machine.Morphology.HermitCrab +{ + /// + /// How serious a is. Error means the engine will + /// behave incorrectly (or refuse the word outright) whenever the offending construct is + /// exercised, with no further information needed to know that. Warning means the + /// construct is a genuine risk to the grammar's reliability, but whether it actually causes a + /// problem for a given word depends on how the grammar's rules use it. + /// + public enum GrammarHealthSeverity + { + Warning, + Error, + } + + /// + /// The stable finding codes reports. Treat these strings, + /// not , as the identifier a host uses to filter, + /// suppress, or test for a particular kind of finding -- the message text is free to change. + /// + public static class GrammarHealthCodes + { + public const string DuplicateFeatureBundle = "hc-duplicate-feature-bundle"; + public const string UndeclaredSegment = "hc-undeclared-segment"; + } + + /// + /// One admissibility problem found in a by . + /// This is diagnostic only: producing a finding never changes how the grammar parses. + /// + public class GrammarHealthFinding + { + private readonly ReadOnlyCollection _subjects; + + public GrammarHealthFinding( + GrammarHealthSeverity severity, + string code, + string message, + IEnumerable subjects + ) + { + if (code == null) + throw new ArgumentNullException("code"); + if (message == null) + throw new ArgumentNullException("message"); + if (subjects == null) + throw new ArgumentNullException("subjects"); + + Severity = severity; + Code = code; + Message = message; + _subjects = new ReadOnlyCollection(subjects.ToList()); + } + + public GrammarHealthSeverity Severity { get; private set; } + + /// + /// A stable identifier for the kind of problem found. See . + /// + public string Code { get; private set; } + + /// + /// A human-readable description naming the offending declaration(s). + /// + public string Message { get; private set; } + + /// + /// The model objects the finding is about (e.g. a , + /// the s that collide, a , or a + /// ), in the order most useful for a host to navigate to them. This + /// is the object model itself, not a copy or a serialized form, so a host that already + /// holds the same can use reference equality to find its own + /// project-specific wrapper around each subject. + /// + public ReadOnlyCollection Subjects + { + get { return _subjects; } + } + + public override string ToString() + { + return string.Format("[{0}] {1}: {2}", Severity, Code, Message); + } + } +} diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs new file mode 100644 index 00000000..9cd6da63 --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs @@ -0,0 +1,126 @@ +using NUnit.Framework; +using SIL.Machine.Annotations; +using SIL.Machine.FeatureModel; + +namespace SIL.Machine.Morphology.HermitCrab; + +[TestFixture] +public class GrammarHealthCheckerTests +{ + private static FeatureSystem VocFeatureSystem() + { + var featSys = new FeatureSystem + { + new SymbolicFeature("voc", new FeatureSymbol("voc+", "+"), new FeatureSymbol("voc-", "-")), + }; + featSys.Freeze(); + return featSys; + } + + [Test] + public void Check_TwoSegmentsShareFeatureBundle_ReportsBothByName() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + table.AddSegment("b", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That(findings, Has.Count.EqualTo(1)); + GrammarHealthFinding finding = findings[0]; + Assert.That(finding.Code, Is.EqualTo(GrammarHealthCodes.DuplicateFeatureBundle)); + Assert.That(finding.Message, Does.Contain("a")); + Assert.That(finding.Message, Does.Contain("b")); + Assert.That(finding.Subjects, Contains.Item(table)); + } + + [Test] + public void Check_EverySegmentHasDistinctFeatureBundle_NoFindings() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + table.AddSegment("b", FeatureStruct.NewMutable(featSys).Symbol("voc-").Value); + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + + Assert.That(GrammarHealthChecker.Check(language), Is.Empty); + } + + [Test] + public void Check_NoPhonologicalFeatureSystem_DoesNotFlagTriviallyIdenticalBundles() + { + // A grammar with no PhonologicalFeatureSystem distinguishes segments by their representation + // alone, so every segment's bundle is the same empty struct by construction and reporting it + // as a duplicate would be a false positive on a correct grammar. + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a"); + table.AddSegment("b"); + table.AddSegment("c"); + + var language = new Language(); + language.CharacterDefinitionTables.Add(table); + + Assert.That(GrammarHealthChecker.Check(language), Is.Empty); + } + + [Test] + public void Check_LexicalEntryUsesSegmentNoTableDeclares_ReportsFinding() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + + var stratum = new Stratum(table) { Name = "Surface" }; + + // A Segments object built by hand rather than through CharacterDefinitionTable.Segment, which + // is the only place that validates a representation's characters against the table -- a host + // building the object model directly (not via XmlLanguageLoader) is not required to go through it. + FeatureStruct undeclaredFs = FeatureStruct.NewMutable(featSys).Symbol("voc-").Value; + undeclaredFs.AddValue(HCFeatureSystem.Type, HCFeatureSystem.Segment); + undeclaredFs.Freeze(); + var shape = new Shape(begin => new ShapeNode(begin ? HCFeatureSystem.LeftSideAnchor : HCFeatureSystem.RightSideAnchor)); + shape.Add(undeclaredFs); + var segments = new Segments(table, "z", shape); + + var entry = new LexEntry { Id = "e1" }; + entry.Allomorphs.Add(new RootAllomorph(segments)); + stratum.Entries.Add(entry); + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + language.Strata.Add(stratum); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That(findings, Has.Count.EqualTo(1)); + Assert.That(findings[0].Code, Is.EqualTo(GrammarHealthCodes.UndeclaredSegment)); + Assert.That(findings[0].Severity, Is.EqualTo(GrammarHealthSeverity.Error)); + Assert.That(findings[0].Message, Does.Contain("e1")); + } + + [Test] + public void Check_CleanGrammar_NoFindingsAtAll() + { + FeatureSystem featSys = VocFeatureSystem(); + var table = new CharacterDefinitionTable { Name = "table1" }; + table.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + table.AddSegment("b", FeatureStruct.NewMutable(featSys).Symbol("voc-").Value); + + var stratum = new Stratum(table) { Name = "Surface" }; + var entry = new LexEntry { Id = "e1" }; + entry.Allomorphs.Add(new RootAllomorph(new Segments(table, "ab"))); + stratum.Entries.Add(entry); + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + language.Strata.Add(stratum); + + Assert.That(GrammarHealthChecker.Check(language), Is.Empty); + } +}