Skip to content

Prune analysis rule outputs that contain no root when no later rule can restore stem material - #572

Closed
johnml1135 wants to merge 1 commit into
masterfrom
perf/hc-root-substring-prune
Closed

johnml1135 wants to merge 1 commit into
masterfrom
perf/hc-root-substring-prune

Conversation

@johnml1135

@johnml1135 johnml1135 commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Quick summary

Land only on top of #577 and #574, not on its own. Alone, this PR does more unification and matcher work than master on 3 of the 4 grammars it affects (Sena, conformance, the Bantu stress grammar); it saves events there, and saves on every count only on the early-lexicon fixture. Below, "less work" means analysis events plus synthesis steps, not unification or matcher work. In the full stack (#577, #576, #574 and this) it costs 1-4 points of unification and matcher work against the stack without it on conformance, Sena and the Bantu stress grammar.

HermitCrab analysis now drops a rule output that contains no root as a contiguous run of segments, in strata where nothing can later restore stem material. Against master it does 17.29% less work (analysis events plus synthesis steps) on Sena and 67.72% less on a synthetic early-lexicon agglutinative fixture, with identical parses. It costs unification and matcher work, because the probe is a trie walk: Sena does 9.05% more unification calls and 8.05% more matcher work than master, conformance 1.00% more and 1.14% more, the Bantu template stress grammar 37.48% more and 41.56% more; the early-lexicon fixture does 58.20% less unification and 59.42% less matcher work. Indonesian, Mbugwe, Amharic and Aweti are unaffected because their flag is off, so they make no probes. A probe is skipped when the output keeps at least as many segments as its input, and, new in this version, when a root run already found in the input still lies inside the output's window of the input's segments; a skipped probe only keeps a word. There is no adaptive off switch. Traces (Try-a-Word) lose the pruned subtree. Pruned analyses no longer count toward MaxAlternatives.

Where to look

  • Soundness of the flag -- RootSubstringPruneStratum (new file): a stratum is on only if every rule of it and of deeper strata copies all LHS parts once, unmodified, in order and adjacent, no deeper stratum has phonological rules, and no lexical-pattern root exists; RootSubstringPruneTests has one test per condition and each goes red when its condition is removed.
  • The three hooks -- AnalysisAffixProcessRule, AnalysisRealizationalAffixProcessRule, AnalysisCompoundingRule skip only output.Add; trace callbacks are unchanged.
  • The probe -- RootAllomorphTrie.ContainsRootSubstring: one unanchored, end-anchor-off transduction per trie.
  • The probe skip -- Morpher.MayContainRoot skips the probe when the output has at least as many segments as its input in the same stratum, or when a root run found in the input (Word.RootWitness, found once per input) lies inside the output's window of the input's segments (Word.RootWindowStart, set by the affix and compounding-head analysis rules only when the prune is active for the stratum; neither field is copied by Clone). Skipping only keeps a word; a stale witness can never prune. The probe is RootAllomorphTrie.TryFindRootRun: the same arcs as before, tried per start segment, so it also says where the run is. The probe count is exposed through a null-by-default RootSubstringProbed hook, so the hot path has no shared counter.
  • Direct-rule tests -- 12 existing tests set RootSubstringPrune = Off (see below).

Deliberately not included

  • Strata with infixes, reduplication, ModifyFromInput or segment-restoring rules (Indonesian, Mbugwe, Amharic, Aweti): a subsequence or restoration-class fallback would be a separate change.
  • No cache or memo: the flag is one bool per stratum and the probe reuses the existing root tries.

Validation

  • dotnet test tests/SIL.Machine.Morphology.HermitCrab.Tests -c Release -- 164 passed, 0 failed (origin/master: 151 passed, 0 failed); three new tests in RootSubstringPruneTests: probe count and prune decisions with root-run reuse, the witness found once and copied, and a stale witness that only skips.
  • dotnet csharpier check . -- clean.
  • pwsh -NoProfile -File ./scripts/comment-hygiene.ps1 -- clean. ./local_check.sh --agent-strict was not completed: it stops at the SentencePiece cmake build.
  • Conformance, base vs this branch (dotnet run --project src/SIL.Machine.Morphology.HermitCrab.Conformance -- --fixtures conformance --include-pathological) -- 146 passed, 4 failed on both; per-fixture verdicts identical (the 4 are the truncate-morphotactic fixtures).
  • Census against master, same binary and word set, deterministic counters, census mode, words completed in both arms (every cell: master / this branch, then % less or more work than master):
List (words) Analysis events + synthesis steps Unification calls (probe included) Matcher work (traversals + arc inspections + edge-prefilter unifications)
Sena (51) 96,376 / 79,714: 17.29% less 1,308,998 / 1,427,431: 9.05% more 1,408,298 / 1,521,703: 8.05% more
Conformance (1,452 runs) 37,207 / 37,125: 0.22% less 299,990 / 302,994: 1.00% more 296,170 / 299,535: 1.14% more
Bantu template stress (60) 41,974 / 39,302: 6.37% less 162,015 / 222,746: 37.48% more 157,272 / 222,634: 41.56% more
Bantu stress, shared trailing slots (60) 41,974 / 39,302: 6.37% less 162,015 / 222,746: 37.48% more 157,272 / 222,634: 41.56% more
Early-lexicon agglutinative fixture (60, synthetic) 20,060 / 6,476: 67.72% less 323,670 / 135,280: 58.20% less 363,574 / 147,545: 59.42% less
Mbugwe (40), Aweti (99), Amharic (24), Indonesian (70) equal equal (Aweti differs by 59 of 17.7 million) equal

Before the root-run reuse the same arms were: conformance 12.15% more unification and 14.97% more matcher work, Bantu stress 67.53% more and 70.76% more, early-lexicon 58.02% less and 59.58% less, Sena 8.47% more and 7.50% more (Sena is 0.58 and 0.55 points worse with reuse when this branch runs alone; in the combined stack with the other analysis PRs it is better). Events are unchanged by the reuse, as it only skips probes.
Parse parity: per-word signatures equal to master on every completed word in all arms (0 differences over 1,796 completed words: Mbugwe 40, Sena 51, Aweti 99, Amharic 24, Indonesian 70, conformance 1,452, stress 60 x 3).
Where the prune pays on events it can still cost more unification and matcher work than master. Measured with the other analysis PRs stacked (not in this PR), R2 with the reuse still does 1 to 4 points more unification and matcher work than the same stack without R2 on conformance (1.03 and 1.25 points), Sena (1.52 points of unification) and the Bantu stress grammar (3.71 points of unification), while it saves events (Sena 4.74 points, conformance 0.20, Bantu stress 0.50).

Where the prune costs more work than master

The probe is a trie walk, one feature-structure unification per arc, and a pass still costs the arcs up to the first accepting path. Alone against master, the prune saves events everywhere the flag is on but does 9.05% more unification calls on Sena, 1.00% more on conformance and 37.48% more on the Bantu template stress grammar (where the root lexicon is large enough that late prunes save little). It does 58.20% less on the early-lexicon fixture, which is synthetic and built to show the best case. The other four real grammars gain nothing and lose nothing, because the flag is off. Root-run reuse removed most of the earlier overhead (conformance 12.15% to 1.00% more, Bantu stress 67.53% to 37.48% more) but not the residual: the single input probe per stratum entry, the decisive probes, and probes where the stem root itself is cut and another root must be searched. I found no sound grammar-independent rule that removes it without a prune-rate or lexicon-size gate, and a gate was tried and rejected (it fits only the conformance toy lexicons). The residual is stated plainly: R2 still does 1 to 4 points more unification and matcher work than no R2 on conformance, Sena and the Bantu stress grammar when stacked with the other analysis PRs, while it saves events.

Traces

Try-a-Word and other trace output lose the pruned subtree: a rule output that is dropped never reaches its later rules, so those nodes do not appear in the trace.

Issue / porting context

Closes #562


Why the 12 direct-rule tests set RootSubstringPrune = Off

Twelve existing tests (one in AnalysisSyntacticFeatureStructTests, eleven in PruneDisagreeingCopiesTests through its CreateMorpher helper) failed with the prune on. They are not parses. Each builds a Morpher, then constructs AnalysisAffixProcessRule for a rule that was never added to any stratum, and applies it directly to a hand-built Word whose shape is arbitrary and holds no lexicon root (for example zimui, or a copy-agreement shape). The morpher computes its stratum flags in its constructor from the rules the language holds, so for these tests the flag is computed without the rule under test (the copy-twice rule would have turned it off). The prune then correctly sees a shape with no root and drops the output, which empties the list the test wants to inspect.

A real Morpher.ParseWord cannot reach that state: every rule it applies belongs to a stratum, the flag is computed with that rule included, and the word comes from the surface form through the cascade. Only AnalysisSyntacticFeatureStructTests.AnalysisAffixProcessRule_CategoryChangeChain_RequiredOverridesAccumulatedPos and every test in PruneDisagreeingCopiesTests (its CreateMorpher sets Off for the whole file) run with the prune off; the other tests in AnalysisSyntacticFeatureStructTests keep it on and pass. Whether the prune can fire on a real parse these tests represent: only on a branch where no lookup below the output could succeed, and, for the copy rules in PruneDisagreeingCopiesTests, never, because reduplication turns the flag off for the stratum.

Reading this a year from now

Lookup is exact, anchored at both ends. Below a rule output N, every word analysis can produce is a contiguous run of N's segment nodes with feature structures at least as specific, provided all rules at and below N copy their LHS parts adjacently and in order (so only removal happens), no deeper stratum has phonological rules (those can change segments between N and the lookup), and there are no lexical-pattern roots (guessRoot matches patterns against analyses, so analyses must not be removed). An exact root match anywhere below N is then a substring match in N. LexEntrySelector only removes roots, so searching all roots is an over-approximation. A compound passes the test because it contains its head root. The probe is one Fst.Transduce per non-empty trie of the stratum and deeper strata, with start anchor off and end anchor off, returning on the first accept; a root with no segments makes the test always true.

Pruned analyses no longer count toward MaxAlternatives, so a parse that used to throw MaxAlternativesExceededException can now complete; the reverse cannot happen.

Paths not taken

A reachability summary (ledger row 27) was closed for 0-5% prunable internal nodes; this prune is keyed on the shape instead. A gapped (subsequence) fallback for infix and reduplication strata, and wildcard classes for restoring rules, would enable Indonesian, Mbugwe, Amharic and Aweti, whose census ceilings are 29.6%, 38.5%, 13.6% and 14.8%; they are separate changes.

🤖 Generated with Claude Code


This change is Reviewable

@codecov-commenter

codecov-commenter commented Oct 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.77043% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.65%. Comparing base (c245476) to head (01bdf5a).

Files with missing lines Patch % Lines
...Machine.Morphology.HermitCrab/RootAllomorphTrie.cs 64.51% 10 Missing and 1 partial ⚠️
...rphologicalRules/AnalysisMorphologicalTransform.cs 59.09% 5 Missing and 4 partials ⚠️
...achine.Morphology.HermitCrab/RootSubstringPrune.cs 93.10% 3 Missing and 5 partials ⚠️
src/SIL.Machine.Morphology.HermitCrab/Morpher.cs 94.11% 0 Missing and 4 partials ⚠️
...icalRules/AnalysisAffixProcessAllomorphRuleSpec.cs 50.00% 0 Missing and 1 partial ⚠️
...logicalRules/AnalysisCompoundingSubruleRuleSpec.cs 50.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #572      +/-   ##
==========================================
+ Coverage   74.57%   74.65%   +0.07%     
==========================================
  Files         453      455       +2     
  Lines       38342    38596     +254     
  Branches     5284     5346      +62     
==========================================
+ Hits        28593    28813     +220     
- Misses       8583     8601      +18     
- Partials     1166     1182      +16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

if (_morpher.TraceManager.IsTracing)
_morpher.TraceManager.MorphologicalRuleUnapplied(_rule, i, input, outWord);
output.Add(outWord);
if (_morpher.MayContainRoot(input, outWord, nameof(AnalysisAffixProcessRule)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: F1. Try-a-Word loses the lexical lookup that tells a user which root is missing. The rule output is traced as MorphologicalRuleUnapplied and then dropped with no event, so its LexicalLookup failure never shows up in the trace. The prune fires in exactly that case: no root matches the stem. Turn the prune off while tracing, as AnalysisStratumRule.Apply does for merging (line 142), or trace the drop. The same applies to AnalysisCompoundingRule.cs:157 and AnalysisRealizationalAffixProcessRule.cs:62.

/// Controls the root-substring prune of <see cref="MayContainRoot"/>. Shadow evaluates the predicate and
/// reports through <see cref="RootSubstringShadow"/> without pruning.
/// </summary>
internal RootSubstringPruneMode RootSubstringPrune { get; set; } = RootSubstringPruneMode.On;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: F2. Callers cannot turn this off. The prune is on by default and changes which analyses come out, but its switch is internal, so FLEx and other netstandard2.0 consumers have no way back if it misprunes a grammar. The comparable switches MergeEquivalentAnalyses (line 100) and PruneDisagreeingCopies (line 117) are public bools. Expose a public bool and keep Shadow internal.


private Morpher CreateMorpher(RootSubstringPruneMode mode)
{
var morpher = new Morpher(TraceManager, Language, maxDegreeOfParallelism: 1) { RootSubstringPrune = mode };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F3. No test runs the prune in parallel. Every on/off comparison builds its morpher with maxDegreeOfParallelism: 1. The one test that uses the default parallelism (line 281) has the flag off in every stratum, so it never probes. docs/review/hermitcrab.md asks for both modes, and production runs ParallelCombinationRuleCascade with probes running at the same time on shared tries.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F3 still applies at 01bdf5a: Morpher.MayContainRoot now writes input.RootWitness on the frozen input word, and parallel branches share that word. Every thread writes the same run, so I see no wrong result. But the prune tests still build their morphers with maxDegreeOfParallelism: 1, so none of them runs this write concurrently.


/// <summary>
/// Whether any root occurs as a contiguous run of the shape's segments, starting anywhere and ending
/// anywhere. Stops at the first accepting path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F4. The doc comment says "Stops at the first accepting path", but that is not what happens. Fst.Transduce with allMatches: false collects every accepting path from the first start position that matches (Traverse(...).ToList(), then Sort, Fst.cs:389-399), and only then stops. Say "stops at the first start position with a match" so later callers do not rely on a cheaper cost than the real one.

@claude

This comment has been minimized.

…an restore stem material

A rule output is probed against the root tries only when it removed segments. The probe reuses a root run
already found in the input when it still lies inside the output's window of the input's segments, so most
probes are skipped; a skipped probe only keeps a word.

Closes #562

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@johnml1135
johnml1135 force-pushed the perf/hc-root-substring-prune branch from d58a8e8 to 01bdf5a Compare October 28, 2026 12:47
@johnml1135
johnml1135 marked this pull request as draft October 28, 2026 12:47
/// Whether any root occurs as a contiguous run of the shape's segments, starting anywhere and ending
/// anywhere. Stops at the first accepting path.
/// </summary>
public bool ContainsRootSubstring(Shape shape)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F5. ContainsRootSubstring is now dead code: since the root-run reuse, Morpher probes only through TryFindRootRun, and nothing in src/ or tests/ calls this method (I grepped both). Drop it. That also settles F4, whose doc comment is on this method.

@claude

claude Bot commented Oct 28, 2026

Copy link
Copy Markdown
Contributor
  1. Verdict: approve with fixes. The branch was rebased since the last round (the compare to d58a8e8 reports diverged), so I re-read the whole PR. The new root-run reuse in Morpher.MayContainRoot can only skip a probe and keep a word. A missing or wrong RootWitness, or a wrong RootWindowStart, never drops an output, so the reuse does not change which words parse.
  2. Most important: F1 (AnalysisAffixProcessRule.cs:96) is still open. Try-a-Word drops the pruned subtree, including the failed lexical lookup that tells a user which root is missing. The PR body now says this but does not fix it.
  3. Counts: 0 Critical, 2 Important (F1, F2), 3 Low (F3, F4, F5).
  4. I re-read the diff against c2454760 and checked how Fst.Transduce handles its arguments, which is what TryFindRootRun relies on. dotnet test tests/SIL.Machine.Morphology.HermitCrab.Tests -c Release: 164 passed, 0 failed. gh pr checks 572: Build on ubuntu-22.04 and windows-latest pass. Create NuGet package and claude-review are pending. The PR says --agent-strict was not completed, and the green Comment hygiene check does not stand in for it.
  5. Not verified: I did not rerun the Sena, conformance or Bantu stress census, so the unification and matcher numbers in the PR body are the author's. I did not run the prune with parallelism (F3).

Public API: none changed (the new members are internal). Morpher.ParseWord and the public Analysis*Rule.Apply now drop rule outputs that contain no root (see F2). Target framework, package, and sillsdev/machine.py parity: none verified (HermitCrab has no Python counterpart).

Findings: F1 open, F2 open, F3 open, F4 open, F5 new.

Reviewed at 01bdf5a

johnml1135 added a commit that referenced this pull request Oct 28, 2026
Adds the round-doc section "Combined stack and R2's cost": the full stack
against master (work, unification, matcher per grammar), additivity
(near-additive on Mbugwe, overlapping on Sena, super-additive on Aweti),
R2's root cause (its own probe, low prune rate) and the witness-reuse fix
folded into #572 @ 01bdf5a, the 1-4 point residual, and the landing
decision: #572 is a draft and lands only on top of #577 and #574. Stress
grammars are PR #578 (opt-in, [Explicit], not run in CI).

Ledger: row 40 (R2) Outcome records the fold and the landing decision;
new row 47 records the combined stack measurement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@johnml1135
johnml1135 requested a review from jtmaxwell3 October 28, 2026 18:08
@johnml1135

Copy link
Copy Markdown
Collaborator Author

Closing. Counting every unit of work as equal, this prune adds 7.7% to Sena's total work, Sena being a real grammar, and wins only on a synthetic early-lexicon grammar. We tried to measure how much a unification call costs relative to an event so the trade-off could be weighed properly, but the result was not reliable. The record is in #490.

Comment drafted by Claude (AI) for the maintainer.

@johnml1135 johnml1135 closed this Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HermitCrab analysis: prune on root substring at rule outputs, gated by restoration flags

3 participants