Skip to content

Fix narrowing-rule unapplication across an interposed optional node - #540

Open
johnml1135 wants to merge 2 commits into
masterfrom
fix/narrow-interposed-optional
Open

johnml1135 wants to merge 2 commits into
masterfrom
fix/narrow-interposed-optional

Conversation

@johnml1135

@johnml1135 johnml1135 commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Retargeted to master. The conformance suite (#480) is deferred until after this release, so this PR no longer stacks on it. Its edge-cases/interposed-optional-multi-rhs fixture is held back and will be added to #480 after this merges; the unit tests here cover the fix.

Quick summary

A root is no longer lost in analysis when a deletion rule ordered after a narrowing rule reinserts an optional node inside the narrowing rule's replacement. Only parses that were silently dropped change; the HermitCrab suite is otherwise unchanged at 109/109. Synthesis, feature rules, and PR #539's union encoding are untouched.

Where to look

  • The unapply walk now reads each replacement position from its own group capture, as FeatureAnalysisRewriteRuleSpec already does -- RewriteRuleTests.MergeRuleUnappliesAcrossInterposedOptionalSegment (red on master, line 636 assertion; green here).
  • A boundary interposed in the same span (the analysis matcher filters boundaries out, so the raw walk stepped onto it too) -- RewriteRuleTests.MergeRuleUnappliesAcrossInterposedOptionalBoundary (red on master, green here).
  • Expansion rules (u -> i i) share this spec and this walk -- RewriteRuleTests.ExpandRules still passes.
  • A replacement is not validated to be plain constraints, so a quantifier there can leave its group capture empty -- RewriteRuleTests.MergeRuleWithOptionalReplacementSegmentSkipsEmptyCapture (NullReferenceException without the Success guard, green here; it also passes on master, whose raw walk happens to land on real nodes for this word).

Deliberately not included

Validation

  • dotnet test tests/SIL.Machine.Morphology.HermitCrab.Tests -c Release -m:4 -nodeReuse:false on master with the two interposed-node tests -- Failed: 2, Passed: 106, Total: 108 (the two new tests).
  • Same command at 154f9d5 (fix without the guard) filtered to the quantifier test -- Failed: 1, NullReferenceException in NarrowAnalysisRewriteRuleSpec.Unapply.
  • Same command at HEAD, full suite -- Failed: 0, Passed: 109, Total: 109.
  • pwsh ./scripts/comment-hygiene.ps1 -- clean (the --agent-strict gate).
  • dotnet CSharpier.dll check . (csharpier 1.2.6 from the restored package; dotnet csharpier refuses to resolve the local tool on this machine) -- Checked 720 files, exit 0.
  • dotnet restore, dotnet build --no-restore -c Release, dotnet test --verbosity normal, each with -m:4 -nodeReuse:false (the remaining local_check.sh steps, run individually to keep the build-parallelism cap) -- restore exit 0; build exit 0; test exit 0, 881 passed / 3 skipped (SIL.Machine.Tests), 4 passed, 109 passed (HermitCrab), 93 passed.
  • git diff --check origin/master...HEAD -- clean.

Not CI-equivalent: no coverage collected.


Reading this a year from now

NarrowAnalysisRewriteRuleSpec.Unapply handles a rule l1..ln -> r1..rm in analysis by inserting l1..ln as optional nodes after the match and marking the matched r1..rm optional, so lexical lookup can read either the surface form or the underlying one. It found r1..rm by walking range.Start.Next m times. The analysis matcher skips optional nodes and never sees boundaries (its filter is Segment|Anchor), so a match of r1 r2 can span an interior optional node or a boundary. The raw walk then marked that node instead of r2; r2 stayed mandatory and the l1..ln reading was unreachable.

Reproduction (test fixture language, Morphophonemic stratum): synthesis order s a g -> n i, then z -> 0 / n _ i, which is vacuous in synthesis. In analysis the deletion unapplies first and inserts (z) between n and i; the merge then matches n (z) i and the walk marks n and (z), leaving i mandatory. Surface ni should parse to root 32 (sag) and did not. Replacing z with the boundary + reproduces the same loss through the matcher's filter instead of through an optional node.

The fix wraps each replacement constraint in a named group in the analysis pattern and marks the captured node of each group optional. That is the encoding FeatureAnalysisRewriteRuleSpec already uses for its per-position read-back.

Paths not taken

🤖 Generated with Claude Code


This change is Reviewable

@codecov-commenter

codecov-commenter commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.31%. Comparing base (a2f80e2) to head (4ed11ad).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #540      +/-   ##
==========================================
- Coverage   74.36%   74.31%   -0.05%     
==========================================
  Files         456      456              
  Lines       38325    38332       +7     
  Branches     5263     5266       +3     
==========================================
- Hits        28499    28486      -13     
- Misses       8669     8690      +21     
+ Partials     1157     1156       -1     

☔ 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.

GroupCapture<ShapeNode> capture = targetMatch.GroupCaptures["target" + i];
if (!capture.Success)
continue;
foreach (ShapeNode node in targetMatch.Input.Shape.GetNodes(capture.Range))

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: F1. A capture range can cover nodes that are not the replacement: when the matcher skips an optional node, the closing register takes the skipped annotation's end (TraversalMethodBase.cs:270), so in right-to-left analysis of n (z) + i the i capture spans z + i and GetNodes marks the non-optional + optional. FeatureAnalysisRewriteRuleSpec.cs:111 reads only Range.GetStart. Unverified: I could not run a scratch matcher test here, and whether an optional boundary changes any parse is unconfirmed.

}

[Test]
public void MergeRuleUnappliesAcrossInterposedOptionalSegment()

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: F2. No test has an expansion rule matching across an interposed node, though expansion rules take the same group-capture path (AnalysisRewriteRule builds this spec when the LHS is not the same length as the RHS). ExpandRules passing shows only that adjacent matches still work. A u -> i i case with an optional z between the two is would cover it.

}
);

// The optional i captures nothing in "pu"; only p becomes optional and u must still be consumed.

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. The comment states an invariant that the assertion does not check: pu parses to 52 whether or not u is marked optional, so the test only proves the empty capture no longer throws. Either reword the comment to say that, or assert the invariant.

@claude

This comment has been minimized.

@claude

This comment has been minimized.

johnml1135 and others added 2 commits October 16, 2026 09:16
NarrowAnalysisRewriteRuleSpec.Unapply marked the matched replacement nodes
optional by walking range.Start.Next once per replacement position. The
analysis matcher skips optional nodes and filters out boundaries, so a
multi-node replacement match can span one; the walk then marked that node
instead of the real second replacement node, which stayed mandatory, and the
underlying reading of the root was unreachable.

Read each replacement position back from its own group capture in the
analysis pattern, as FeatureAnalysisRewriteRuleSpec already does, and mark
exactly those nodes optional.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A narrowing rule's replacement is not validated to be plain constraints, so
a quantifier there (an optional segment sequence) compiles and may match no
node. Reading the start of an empty capture dereferenced a null range. Skip
such positions; a multi-node capture marks every node it covers optional.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@johnml1135
johnml1135 force-pushed the fix/narrow-interposed-optional branch from fef6889 to 4ed11ad Compare October 16, 2026 13:19
@johnml1135
johnml1135 changed the base branch from integrate-conformance-framework to master October 16, 2026 13:19
AssertMorphsEqual(morpher.ParseWord("biiiibiiii"), "27");
}

[Test]

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. All three new tests use the default rule direction, so none checks the capture read-back when a RightToLeft rule makes analysis match left-to-right and the group registers swap order. A copy of MergeRuleUnappliesAcrossInterposedOptionalSegment with Direction = Direction.RightToLeft on the merge rule would cover it.

@claude

claude Bot commented Oct 16, 2026

Copy link
Copy Markdown
Contributor
  1. Verdict: approve with fixes. The narrowing fix is the same code as the last two rounds and still restores the lost root; nothing blocks it.
  2. Most important: F1, NarrowAnalysisRewriteRuleSpec.cs:67. GetNodes(capture.Range) may still mark a non-replacement boundary optional when a capture stretches over a skipped optional node.
  3. Counts: Critical 0, Important 0, Low 4 (F1, F2, F3, F5).
  4. Ran: dotnet test tests/SIL.Machine.Morphology.HermitCrab.Tests -c Release on the merge checkout: Failed 0, Passed 615, Total 615. NarrowAnalysisRewriteRuleSpec.cs and RewriteRuleTests.cs have the same blobs (e4b6c543, 6b3d0cc5) at fef6889 and at the head, so F1-F3 apply unchanged. ./local_check.sh --agent-strict and comment-hygiene.ps1 were refused by the permissions in this environment.
  5. Not verified: whether F1 changes any parse; the strict comment-hygiene gate; CI coverage.

The PR has been retargeted from integrate-conformance-framework to master, and compare against fef6889 reports diverged, so this round treated the whole PR as changed. The fixture and ledger commit is gone, which takes F4 with it.

Out of scope, not a finding: EpenthesisAnalysisRewriteRuleSpec.Unapply still walks range.Start.Next the same way. That code is pre-existing and this diff does not touch it.

Public API, target framework, package, machine.py parity: None verified.

Findings: F1 open, unverified; F2 open; F3 open; F4 withdrawn; F5 new.

Reviewed at 4ed11ad

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.

3 participants