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