From 141a12dfb0c7740f81aaa04cd609d37b909cfb26 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 1 Sep 2026 14:45:26 -0400 Subject: [PATCH 1/4] Add a grammar health checker for two unenforced 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. Co-Authored-By: Claude Opus 5.5 --- .../GrammarHealthChecker.cs | 262 ++++++++++++++++++ .../GrammarHealthFinding.cs | 90 ++++++ .../GrammarHealthCheckerTests.cs | 215 ++++++++++++++ 3 files changed, 567 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 000000000..a1b2a4c7f --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs @@ -0,0 +1,262 @@ +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 + ); + } + } + + // A rule reached only through an affix-template slot is not necessarily in + // stratum.MorphologicalRules; a rule reached both ways must still report once. + var seenRules = new HashSet(new ReferenceEqualityComparer()); + + foreach (IMorphologicalRule rule in stratum.MorphologicalRules) + CheckRuleSegmentsDeclared(rule, seenRules, findings); + + foreach (AffixTemplate template in stratum.AffixTemplates) + { + foreach (MorphemicMorphologicalRule rule in template.Slots.SelectMany(slot => slot.Rules)) + CheckRuleSegmentsDeclared(rule, seenRules, 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 } + ) + ); + } + } + } + + private static void CheckRuleSegmentsDeclared( + IMorphologicalRule rule, + HashSet seen, + List findings + ) + { + if (!seen.Add(rule)) + return; + + var affixRule = rule as AffixProcessRule; + if (affixRule != null) + CheckAffixAllomorphsDeclared(affixRule.Name, affixRule.Allomorphs, findings); + + // A sibling of AffixProcessRule, not a subclass, but it owns the same allomorph type. + var realizationalRule = rule as RealizationalAffixProcessRule; + if (realizationalRule != null) + CheckAffixAllomorphsDeclared(realizationalRule.Name, realizationalRule.Allomorphs, 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 + ); + } + } + } + } + + private static void CheckAffixAllomorphsDeclared( + string ruleName, + IEnumerable allomorphs, + List findings + ) + { + foreach (AffixProcessAllomorph allomorph in allomorphs) + { + foreach (InsertSegments insert in allomorph.Rhs.OfType()) + { + CheckSegmentsDeclared( + insert.Segments, + string.Format( + "Morphological rule '{0}' inserted segments '{1}'", + ruleName, + insert.Segments.Representation + ), + findings + ); + } + } + } + + // 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 000000000..810b2e2ac --- /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 000000000..aec7dd2cf --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs @@ -0,0 +1,215 @@ +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; + } + + // Built by hand, bypassing CharacterDefinitionTable.Segment's validation; a host building the + // model directly is not required to call it. + private static Segments UndeclaredSegments(CharacterDefinitionTable table, FeatureSystem featSys) + { + 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); + return new Segments(table, "z", shape); + } + + [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, 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() + { + // No PhonologicalFeatureSystem at all (the strrep-identity shape): every segment's bundle is + // the same empty struct by construction, so this must not be reported as a duplicate. + 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" }; + + Segments segments = UndeclaredSegments(table, featSys); + + 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_TemplateOnlyRuleInsertsUndeclaredSegment_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" }; + var rule = new AffixProcessRule { Name = "plural" }; + rule.Allomorphs.Add( + new AffixProcessAllomorph { Rhs = { new InsertSegments(UndeclaredSegments(table, featSys)) } } + ); + var template = new AffixTemplate { Name = "verb" }; + template.Slots.Add(new AffixTemplateSlot(rule)); + stratum.AffixTemplates.Add(template); + // rule is deliberately absent from stratum.MorphologicalRules -- reached only via the template slot. + + 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("plural")); + } + + [Test] + public void Check_RealizationalRuleInsertsUndeclaredSegment_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" }; + var rule = new RealizationalAffixProcessRule { Name = "past_suffix" }; + rule.Allomorphs.Add( + new AffixProcessAllomorph { Rhs = { new InsertSegments(UndeclaredSegments(table, featSys)) } } + ); + stratum.MorphologicalRules.Add(rule); + + 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("past_suffix")); + } + + [Test] + public void Check_NaturalClassReferencesUndeclaredSegment_ReportsFinding() + { + FeatureSystem featSys = VocFeatureSystem(); + var declaredTable = new CharacterDefinitionTable { Name = "table1" }; + declaredTable.AddSegment("a", FeatureStruct.NewMutable(featSys).Symbol("voc+").Value); + + var undeclaredTable = new CharacterDefinitionTable { Name = "table2" }; + CharacterDefinition undeclaredSegment = undeclaredTable.AddSegment( + "z", + FeatureStruct.NewMutable(featSys).Symbol("voc-").Value + ); + // undeclaredTable is deliberately never added to language.CharacterDefinitionTables. + + var naturalClass = new SegmentNaturalClass(new[] { undeclaredSegment }) { Name = "Vowel" }; + + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(declaredTable); + language.NaturalClasses.Add(naturalClass); + + 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("Vowel")); + Assert.That(findings[0].Message, Does.Contain("z")); + Assert.That(findings[0].Subjects, Is.EqualTo(new object[] { naturalClass, undeclaredSegment })); + } + + [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); + } +} From 79a0f8ee730391bf735c2e804ceca90ec61428a2 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 4 Sep 2026 11:22:15 -0400 Subject: [PATCH 2/4] docs: design partial morpheme health check --- ...04-partial-morpheme-health-check-design.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md diff --git a/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md b/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md new file mode 100644 index 000000000..ab6ede90f --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-partial-morpheme-health-check-design.md @@ -0,0 +1,65 @@ +# Partial Morpheme Health Check + +## Goal + +Extend `GrammarHealthChecker` on pull request #475 to identify every partially +analyzed HermitCrab morpheme. The finding should tell grammar authors to finish +the incomplete analysis and explain that partial morphemes can broaden search +and prevent safe final-template pruning. + +This is a production-readiness diagnostic. It does not change grammar loading, +parsing, synthesis, or the conservative final-template correctness guard. + +## Diagnostic contract + +Add the stable code `hc-partial-morpheme` with warning severity. Emit one +finding for each distinct `Morpheme` whose `IsPartial` property is true. + +The check covers: + +- lexical entries in every stratum; +- ordinary morphemic morphological rules in every stratum; and +- morphemic rules referenced by affix-template slots. + +The same rule object can be referenced more than once, so enumeration must use +reference identity and report it once. Each finding's first and only subject is +the partial `Morpheme`, allowing a host to navigate to the original object. + +The message identifies whether the subject is a lexical entry or morphological +rule, names it using the best available identifier, and recommends supplying +its missing category or template/slot analysis. It also states that leaving the +morpheme partial can broaden analysis and disable safe final-template pruning. + +## Placement + +`GrammarHealthChecker.Check(Language)` will invoke a private partial-morpheme +check alongside the two existing checks. The implementation stays inside the +`netstandard2.0` HermitCrab library and remains diagnostic-only. + +No new parser option or model field is introduced. `Morpheme.IsPartial` remains +the owner of the decision; the health checker reports that published fact and +does not re-derive partiality from POS, slots, or feature structures. + +## Tests + +Tests will be written and observed failing before production code changes. They +will prove that: + +1. a partial lexical entry produces one actionable warning and exposes the + entry as its subject; +2. a partial ordinary affix rule produces one warning; +3. a partial template rule produces one warning even if referenced by multiple + slots or templates; +4. non-partial morphemes produce no partial-morpheme warning; and +5. the existing checks continue to compose with the new check. + +The targeted HermitCrab suite and formatting check must pass before the branch +is pushed. + +## Relationship to pull request #491 + +Pull request #491 keeps its safe default: final-template pruning remains +disabled wherever partial morphemes make the stronger conclusion unsafe. The +new health finding gives grammar authors an actionable route to remove that +performance blocker instead of weakening the correctness guard or silently +forcing the optimization. From d6cae632a01e594d65789561cd90409c8975d1b6 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 4 Sep 2026 11:28:04 -0400 Subject: [PATCH 3/4] test: specify partial morpheme health findings --- .../GrammarHealthCheckerTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs index aec7dd2cf..ce00176fd 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs @@ -1,6 +1,7 @@ using NUnit.Framework; using SIL.Machine.Annotations; using SIL.Machine.FeatureModel; +using SIL.Machine.Morphology.HermitCrab.MorphologicalRules; namespace SIL.Machine.Morphology.HermitCrab; @@ -212,4 +213,61 @@ public void Check_CleanGrammar_NoFindingsAtAll() Assert.That(GrammarHealthChecker.Check(language), Is.Empty); } + + [Test] + public void Check_PartialLexicalEntry_ReportsActionableWarning() + { + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var entry = new LexEntry { Id = "entry1", IsPartial = true }; + stratum.Entries.Add(entry); + var language = new Language(); + language.Strata.Add(stratum); + + GrammarHealthFinding finding = GrammarHealthChecker.Check(language).Single(); + + Assert.That(finding.Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(finding.Severity, Is.EqualTo(GrammarHealthSeverity.Warning)); + Assert.That(finding.Message, Does.Contain("entry1")); + Assert.That(finding.Message, Does.Contain("partially analyzed")); + Assert.That(finding.Message, Does.Contain("final-template pruning")); + Assert.That(finding.Subjects, Is.EqualTo(new object[] { entry })); + } + + [Test] + public void Check_PartialOrdinaryRule_ReportsRule() + { + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var rule = new AffixProcessRule { Name = "plural", IsPartial = true }; + stratum.MorphologicalRules.Add(rule); + var language = new Language(); + language.Strata.Add(stratum); + + GrammarHealthFinding finding = GrammarHealthChecker.Check(language).Single(); + + Assert.That(finding.Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(finding.Message, Does.Contain("plural")); + Assert.That(finding.Subjects, Is.EqualTo(new object[] { rule })); + } + + [Test] + public void Check_PartialTemplateRuleReferencedTwice_ReportsOnce() + { + var table = new CharacterDefinitionTable { Name = "table1" }; + var stratum = new Stratum(table) { Name = "Surface" }; + var rule = new AffixProcessRule { Name = "subject", IsPartial = true }; + var template = new AffixTemplate { Name = "verb" }; + template.Slots.Add(new AffixTemplateSlot(rule)); + template.Slots.Add(new AffixTemplateSlot(rule)); + stratum.AffixTemplates.Add(template); + var language = new Language(); + language.Strata.Add(stratum); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That(findings, Has.Count.EqualTo(1)); + Assert.That(findings[0].Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); + Assert.That(findings[0].Subjects, Is.EqualTo(new object[] { rule })); + } } From 6dd8e2145382eb8bf0cf101a6b84b2629b2512cd Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 4 Sep 2026 11:31:05 -0400 Subject: [PATCH 4/4] feat: report partially analyzed morphemes CheckPartialMorphemes flags LexEntry and MorphemicMorphologicalRule instances marked IsPartial, deduplicating by identity so a rule referenced from multiple template slots is reported once. Leaving a morpheme partial can broaden analysis and disable safe final-template pruning. Broaden GrammarHealthFinding's doc comment to describe findings generally, since a finding can now be a warning as well as an error. Co-Authored-By: Claude Opus 5.5 --- .../GrammarHealthChecker.cs | 83 ++++++++++++++++--- .../GrammarHealthFinding.cs | 6 +- .../GrammarHealthCheckerTests.cs | 21 +++++ 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs index a1b2a4c7f..16b32db0c 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs @@ -4,25 +4,24 @@ using SIL.Machine.Annotations; using SIL.Machine.FeatureModel; using SIL.Machine.Morphology.HermitCrab.MorphologicalRules; +using SIL.ObjectModel; 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. + /// Checks a loaded for problems HermitCrab does not otherwise report: + /// segments used without a declaration, declared segments with duplicate phonological feature + /// bundles, and morphemes whose analysis is marked partial. These problems can silently refuse + /// words, make morpheme identification unreliable, or broaden analysis enough to disable safe + /// final-template pruning. This checker surfaces 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. + /// order the checks ran. An empty list means every registered check passed, not that nothing + /// was checked -- see for what each finding's code means. /// public static IList Check(Language language) { @@ -32,9 +31,73 @@ public static IList Check(Language language) var findings = new List(); CheckDuplicateFeatureBundles(language, findings); CheckUndeclaredSegments(language, findings); + CheckPartialMorphemes(language, findings); return findings; } + private static void CheckPartialMorphemes(Language language, List findings) + { + var seen = new HashSet(new ReferenceEqualityComparer()); + + foreach (Stratum stratum in language.Strata) + { + foreach (LexEntry entry in stratum.Entries) + CheckPartialMorpheme(entry, seen, findings); + + foreach (Morpheme rule in stratum.MorphologicalRules.OfType()) + CheckPartialMorpheme(rule, seen, findings); + + foreach (AffixTemplate template in stratum.AffixTemplates) + { + foreach (MorphemicMorphologicalRule rule in template.Slots.SelectMany(slot => slot.Rules)) + CheckPartialMorpheme(rule, seen, findings); + } + } + } + + private static void CheckPartialMorpheme( + Morpheme morpheme, + HashSet seen, + List findings + ) + { + if (!morpheme.IsPartial || !seen.Add(morpheme)) + return; + + string kind; + string name; + var rule = morpheme as MorphemicMorphologicalRule; + if (rule != null) + { + kind = "Morphological rule"; + name = FirstNonEmpty(rule.Name, rule.Id, rule.Gloss); + } + else + { + kind = "Lexical entry"; + name = FirstNonEmpty(morpheme.Id, morpheme.Gloss); + } + + findings.Add( + new GrammarHealthFinding( + GrammarHealthSeverity.Warning, + GrammarHealthCodes.PartialMorpheme, + string.Format( + "{0} '{1}' is partially analyzed. Supply its missing category or template/slot analysis; " + + "leaving it partial can broaden analysis and disable safe final-template pruning.", + kind, + name + ), + new object[] { morpheme } + ) + ); + } + + private static string FirstNonEmpty(params string[] values) + { + return values.FirstOrDefault(value => !string.IsNullOrEmpty(value)) ?? "unnamed"; + } + // 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) diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs index 810b2e2ac..fcf44d9c6 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs @@ -26,12 +26,14 @@ public enum GrammarHealthSeverity public static class GrammarHealthCodes { public const string DuplicateFeatureBundle = "hc-duplicate-feature-bundle"; + public const string PartialMorpheme = "hc-partial-morpheme"; 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. + /// One problem or production-readiness risk found in a by + /// . This is diagnostic only: producing a finding never + /// changes how the grammar parses. /// public class GrammarHealthFinding { diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs index ce00176fd..521eff97d 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs @@ -270,4 +270,25 @@ public void Check_PartialTemplateRuleReferencedTwice_ReportsOnce() Assert.That(findings[0].Code, Is.EqualTo(GrammarHealthCodes.PartialMorpheme)); Assert.That(findings[0].Subjects, Is.EqualTo(new object[] { rule })); } + + [Test] + public void Check_PartialMorphemeAndExistingProblem_ReportsBoth() + { + 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" }; + stratum.Entries.Add(new LexEntry { Id = "entry1", IsPartial = true }); + var language = new Language { PhonologicalFeatureSystem = featSys }; + language.CharacterDefinitionTables.Add(table); + language.Strata.Add(stratum); + + IList findings = GrammarHealthChecker.Check(language); + + Assert.That( + findings.Select(finding => finding.Code), + Is.EquivalentTo(new[] { GrammarHealthCodes.DuplicateFeatureBundle, GrammarHealthCodes.PartialMorpheme }) + ); + } }