From 9818f120fc4d055bf0fa181979ada0a65665180a Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 1 Sep 2026 14:45:26 -0400 Subject: [PATCH 1/6] 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. --- .../GrammarHealthChecker.cs | 230 ++++++++++++++++++ .../GrammarHealthFinding.cs | 90 +++++++ .../GrammarHealthCheckerTests.cs | 127 ++++++++++ 3 files changed, 447 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..41dd6829 --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs @@ -0,0 +1,230 @@ +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..65621f79 --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs @@ -0,0 +1,127 @@ +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() + { + // 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" }; + + // 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); + } +} From 96679b682c56ffcfb22acf44c84e6d32ed022d90 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 4 Sep 2026 11:22:15 -0400 Subject: [PATCH 2/6] 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 00000000..ab6ede90 --- /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 1c12fd25399702d795fb4bf9f6310d1bfc904213 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 4 Sep 2026 11:26:41 -0400 Subject: [PATCH 3/6] docs: plan partial morpheme health check --- ...026-09-04-partial-morpheme-health-check.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-partial-morpheme-health-check.md diff --git a/docs/superpowers/plans/2026-09-04-partial-morpheme-health-check.md b/docs/superpowers/plans/2026-09-04-partial-morpheme-health-check.md new file mode 100644 index 00000000..0bf37250 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-partial-morpheme-health-check.md @@ -0,0 +1,215 @@ +# Partial Morpheme Health Check Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an actionable `GrammarHealthChecker` warning for every distinct HermitCrab morpheme whose published `IsPartial` flag is true. + +**Architecture:** Extend the existing diagnostic-only checker with one private enumeration pass over lexical entries, ordinary morphemic rules, and template-slot rules. Deduplicate the model objects by reference, then emit one stable-coded warning whose subject is the original morpheme; do not infer partiality from POS, slots, or feature structures. + +**Tech Stack:** C#, .NET 10 test project, netstandard2.0 library, NUnit 4, CSharpier + +--- + +### Task 1: Specify partial-morpheme findings with failing tests + +**Files:** +- Modify: `tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs` + +- [ ] **Step 1: Add the morphological-rules import** + +```csharp +using SIL.Machine.Morphology.HermitCrab.MorphologicalRules; +``` + +- [ ] **Step 2: Add a failing partial lexical-entry test** + +```csharp +[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 })); +} +``` + +- [ ] **Step 3: Add a failing ordinary-rule test** + +```csharp +[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 })); +} +``` + +- [ ] **Step 4: Add a failing template-rule deduplication test** + +```csharp +[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 })); +} +``` + +- [ ] **Step 5: Run the focused tests and verify RED** + +Run: + +```powershell +dotnet test tests\SIL.Machine.Morphology.HermitCrab.Tests\SIL.Machine.Morphology.HermitCrab.Tests.csproj --configuration Release --filter "FullyQualifiedName~GrammarHealthCheckerTests" +``` + +Expected: compilation fails because `GrammarHealthCodes.PartialMorpheme` does not exist. This is the intended red result. + +- [ ] **Step 6: Commit the failing specification** + +```powershell +git add tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs +git commit -m "test: specify partial morpheme health findings" +``` + +### Task 2: Implement the checker from the published model fact + +**Files:** +- Modify: `src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs` +- Modify: `src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs` + +- [ ] **Step 1: Add the stable finding code** + +Add to `GrammarHealthCodes`: + +```csharp +public const string PartialMorpheme = "hc-partial-morpheme"; +``` + +- [ ] **Step 2: Invoke the new diagnostic pass** + +Add after the existing checks in `GrammarHealthChecker.Check`: + +```csharp +CheckPartialMorphemes(language, findings); +``` + +- [ ] **Step 3: Enumerate every morpheme-bearing location and deduplicate references** + +Add a private method that creates: + +```csharp +var seen = new HashSet(new ReferenceEqualityComparer()); +``` + +For every stratum, visit `stratum.Entries`, `stratum.MorphologicalRules.OfType()`, and every rule in every `AffixTemplateSlot`. Pass each object to a helper that returns immediately when `!morpheme.IsPartial || !seen.Add(morpheme)`. + +- [ ] **Step 4: Emit one warning with the original object as subject** + +For a `LexEntry`, prefer its non-empty `Id`; for a `MorphemicMorphologicalRule`, prefer its non-empty `Name`; fall back to `Id`, `Gloss`, and finally `"unnamed"`. Emit: + +```csharp +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 } +) +``` + +- [ ] **Step 5: Run the focused tests and verify GREEN** + +Run the Task 1 test command. + +Expected: all `GrammarHealthCheckerTests` pass. + +- [ ] **Step 6: Commit the implementation** + +```powershell +git add src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs +git commit -m "feat: report partially analyzed morphemes" +``` + +### Task 3: Verify, document the PR, and publish the branch + +**Files:** +- Modify if formatting requires it: the two production files and one test file above +- External: GitHub pull request #475 body + +- [ ] **Step 1: Run CSharpier** + +```powershell +dotnet tool restore +dotnet csharpier format src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs +``` + +Expected: exit code 0. Commit any formatting-only changes. + +- [ ] **Step 2: Run the full targeted suite** + +```powershell +dotnet test tests\SIL.Machine.Morphology.HermitCrab.Tests\SIL.Machine.Morphology.HermitCrab.Tests.csproj --configuration Release +``` + +Expected: all tests pass with zero warnings. + +- [ ] **Step 3: Check the complete branch diff** + +```powershell +git diff --check origin/master...HEAD +git status --short --branch +``` + +Expected: no whitespace errors and a clean worktree. + +- [ ] **Step 4: Push the approved PR head** + +```powershell +git push origin HEAD:feature/grammar-health-checker +``` + +Expected: GitHub updates pull request #475. + +- [ ] **Step 5: Update the pull-request description** + +Add the third checker contract and its new tests to the existing PR description. Preserve the diagnostic-only and netstandard2.0 design statements. Explicitly state that the partial finding advises authors to complete missing category or slot information while #491 retains conservative final-template behavior. From 06ebb6660f5ac2756efa0db5d1464d0b25a74379 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 4 Sep 2026 11:28:04 -0400 Subject: [PATCH 4/6] 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 65621f79..26832c27 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; @@ -124,4 +125,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 fa9613a3afc9e6be78375e6c64393f8d153cdd58 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 4 Sep 2026 11:31:05 -0400 Subject: [PATCH 5/6] feat: report partially analyzed morphemes --- .../GrammarHealthChecker.cs | 83 ++++++++++++++++--- .../GrammarHealthFinding.cs | 1 + .../GrammarHealthCheckerTests.cs | 21 +++++ 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthChecker.cs index 41dd6829..caaabbc8 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 810b2e2a..5ac8b56d 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs @@ -26,6 +26,7 @@ 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"; } diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs index 26832c27..28480f5d 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/GrammarHealthCheckerTests.cs @@ -182,4 +182,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 }) + ); + } } From 97683899aa26e8fcaa4890bd14f0fb2b7f2561b1 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 4 Sep 2026 11:35:43 -0400 Subject: [PATCH 6/6] docs: distinguish health warnings from errors --- .../GrammarHealthFinding.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs index 5ac8b56d..fcf44d9c 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/GrammarHealthFinding.cs @@ -31,8 +31,9 @@ public static class GrammarHealthCodes } /// - /// 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 {