Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f592845
Update nfa compiler
GeorgeNgMsft Jul 8, 2026
332df3a
Cleanup comments
GeorgeNgMsft Jul 8, 2026
338ecad
Merge branch 'main' into dev/georgeng/fix_nfa_with_subrules
GeorgeNgMsft Jul 8, 2026
920b970
Refactor to reduce complexity
GeorgeNgMsft Jul 9, 2026
ed60c05
Merge remote-tracking branch 'origin/dev/georgeng/fix_nfa_with_subrul…
GeorgeNgMsft Jul 9, 2026
1b9cbb3
Refresh complexity baseline exceptions after merge
GeorgeNgMsft Jul 9, 2026
0f2ae5c
Address comments
GeorgeNgMsft Jul 9, 2026
b18a579
Add additional test
GeorgeNgMsft Jul 9, 2026
4c12e58
Merge branch 'main' into dev/georgeng/fix_nfa_with_subrules
GeorgeNgMsft Jul 9, 2026
b1bde99
Remove duplicate tests
GeorgeNgMsft Jul 10, 2026
20082d5
Update comments and fix behavior regression
GeorgeNgMsft Jul 10, 2026
40ecc0c
Merge branch 'dev/georgeng/fix_nfa_with_subrules' of https://github.c…
GeorgeNgMsft Jul 10, 2026
ab2bbae
Clean up logic
GeorgeNgMsft Jul 10, 2026
7461207
Update documents
GeorgeNgMsft Jul 10, 2026
20c9ae7
Merge branch 'main' into dev/georgeng/fix_nfa_with_subrules
GeorgeNgMsft Jul 10, 2026
680b75a
Merge branch 'main' into dev/georgeng/fix_nfa_with_subrules
GeorgeNgMsft Jul 10, 2026
a108a4a
Remove unnecessary --nfa-safe flag and extract shared implicitValue e…
GeorgeNgMsft Jul 10, 2026
ab7c97a
Merge branch 'dev/georgeng/fix_nfa_with_subrules' of https://github.c…
GeorgeNgMsft Jul 10, 2026
2003a63
Rename grammarImplicitExtractor to grammarValueDeriver
GeorgeNgMsft Jul 10, 2026
f754122
Resolve merge conflict
GeorgeNgMsft Jul 11, 2026
c41544b
Unify value extraction on derviceValue()
GeorgeNgMsft Jul 11, 2026
cf0b4b1
Use findSingleValueBearingPart in grammarValueValidator, update findS…
GeorgeNgMsft Jul 11, 2026
2f4c706
Refactor for semantic and readaibility improvements
GeorgeNgMsft Jul 11, 2026
bddf6ed
Cleanup return statement in checkForwardinPromotable
GeorgeNgMsft Jul 11, 2026
baca38e
Unify getIMplicitDefaultValue on deriveEffectiveValue, more tailcall …
GeorgeNgMsft Jul 13, 2026
c74e366
Clean up comments
GeorgeNgMsft Jul 13, 2026
df3d9f7
Remove deriveValue
GeorgeNgMsft Jul 14, 2026
ee8ae8a
Simplify further
GeorgeNgMsft Jul 14, 2026
d33185f
Additional cleanup of deadcode paths, add default parameters, further…
GeorgeNgMsft Jul 14, 2026
82dedd2
Also swap order of describeRule and requireValue in deriveEffectiveValue
GeorgeNgMsft Jul 14, 2026
a5b7f34
Add change accidentally left out
GeorgeNgMsft Jul 14, 2026
18f2ccc
Merge branch 'main' into dev/georgeng/fix_nfa_with_subrules
GeorgeNgMsft Jul 14, 2026
5cd35b5
Simplify further
GeorgeNgMsft Jul 14, 2026
80598c9
Merge branch 'dev/georgeng/fix_nfa_with_subrules' of https://github.c…
GeorgeNgMsft Jul 14, 2026
f6c5076
Update test
GeorgeNgMsft Jul 14, 2026
25cfbf1
Clean up unnecessary commenting
GeorgeNgMsft Jul 14, 2026
f67df0d
Inline findSingleValueBearingPart in grammarValueTypeValidator and up…
GeorgeNgMsft Jul 14, 2026
d396278
Remove findSingleValueBearingPart and inline the logic
GeorgeNgMsft Jul 14, 2026
861cf81
Update test coverage and documentation
GeorgeNgMsft Jul 14, 2026
0e43ac3
Merge branch 'main' into dev/georgeng/fix_nfa_with_subrules
GeorgeNgMsft Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions ts/docs/architecture/core/actionGrammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,10 +602,13 @@ tail contract, so the non-tail wrapper builder is only reached when
`tailFactoring` is off.

**NFA compatibility.** Tail RulesParts are understood only by the
NFA-interpreter matcher (`grammarMatcher.ts`). The NFA compiler /
DFA path (`nfaCompiler.ts`) explicitly throws on encountering one,
so consumers that route through the NFA/DFA path must leave
`tailFactoring` off.
AST-walking matcher (`grammarMatcher.ts`). The NFA compiler / DFA path
(`nfaCompiler.ts`) explicitly throws on encountering one - this is a known gap and is not yet implemented. NFAs are naturally well-suited to sharing prefix states, but the equivalent frame-sharing
/ variable-forwarding wiring for that backend hasn't been built yet.
Until it is, consumers that route through the NFA/DFA path must leave
`tailFactoring` off (and `promoteTailRulesParts`, which produces the
same tail-call shape) when calling `optimizeGrammar` /
`loadGrammarRules` directly.

**No fixed-point loop.** Factoring is applied once per group of
alternatives — the trie's grouping converges in a single pass and
Expand Down Expand Up @@ -1518,6 +1521,17 @@ matched text as a string. For example, `"hello"` produces `"hello"`.
explicit value expression produce no value — the compiler warns about
this because the output is ambiguous.

The NFA compiler (`nfaCompiler.ts`) mirrors this same "no value unless
actually needed" principle via the shared `deriveEffectiveValue`
(`grammarValueDeriver.ts`), which forwards a rule's
single variable-bearing part (whatever its shape - a single-part rule,
or a multi-part rule reshaped by `factorCommonPrefixes`), and only
treats an ambiguous or missing implicit value as a hard error when the
value is actually required — i.e. for top-level action rules, or nested
rules a parent captures via `$(name:<Rule>)`. A nested, uncaptured rule
with an ambiguous shape is harmless (nothing reads its value) and
silently resolves to "no value," exactly like the `none` kind above.

### Design Principles

1. **Strict Conformance** — the purpose of type checking is to ensure
Expand Down
56 changes: 6 additions & 50 deletions ts/packages/actionGrammar/src/grammarOptimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { leadingWordBoundaryScriptPrefix } from "./spacingScripts.js";
import { leadingNonSeparatorRun } from "./grammarMatcher.js";
import { getDispatchEffectiveMembers } from "./dispatchHelpers.js";
import { deriveEffectiveValue } from "./grammarValueDeriver.js";
import {
globalPhraseSetRegistry,
PhraseSetMatcher,
Expand Down Expand Up @@ -3238,14 +3239,11 @@ function trySubstituteMembers(
let bailed = false;
const rewriteOne = (m: GrammarRule): GrammarRule => {
const renamed = renameAllChildBindings(m.parts, m.value, renameState);
const effective =
renamed.value !== undefined
? renamed.value
: getImplicitDefaultValue({
...m,
parts: renamed.parts,
value: renamed.value,
});
const effective = deriveEffectiveValue({
...m,
parts: renamed.parts,
value: renamed.value,
});
if (effective === undefined) {
// Member has no explicit value and we can't synthesize
// one to substitute (e.g. unbound single-part
Expand Down Expand Up @@ -3299,48 +3297,6 @@ function trySubstituteMembers(
return { dispatch: newDispatch, alternatives: fallback };
}

/**
* Compute a value expression equivalent to what the matcher's
* implicit-default rule would produce for `rule` if it were matched
* standalone. Returns `undefined` if the rule doesn't have a
* structurally-expressible default (e.g. an unbound single-part
* `phraseSet` / `string` whose value depends on the matched text).
*
* Used by the value-substitution branch of `tryPromoteTrailing` to
* fold each member's effective value into the parent's value
* expression.
*/
function getImplicitDefaultValue(
rule: GrammarRule,
): CompiledValueNode | undefined {
if (rule.value !== undefined) return rule.value;
const parts = rule.parts;
if (parts.length === 0) return undefined;
if (parts.length === 1) {
// Single-part rule: matcher forwards the part's value. For
// var-bearing parts we can express that as a variable
// reference; for unbound `string` / `phraseSet` (whose
// matcher value derives from the matched text) and unbound
// `rules` (whose value derives from the inner match) we
// can't reify the result without changing the AST, so bail.
const name = parts[0].variable;
return name !== undefined ? { type: "variable", name } : undefined;
}
// Multi-part: implicit default requires exactly one
// var-bearing part. Same predicate as the inliner's
// binding-friendly check for multi-part children.
let theVar: string | undefined;
for (const p of parts) {
const name = p.variable;
if (name === undefined) continue;
if (theVar !== undefined) return undefined;
theVar = name;
}
return theVar !== undefined
? { type: "variable", name: theVar }
: undefined;
}

// ─────────────────────────────────────────────────────────────────────────────
// Structural validation: RulesPart.tailCall contract
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
45 changes: 45 additions & 0 deletions ts/packages/actionGrammar/src/grammarValueDeriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Value-derivation logic shared by grammarOptimizer.ts and
* nfaCompiler.ts: computing a rule's effective value expression.
*/

import type { CompiledValueNode, GrammarRule } from "./grammarTypes.js";

/**
* A rule's explicit `->` value when present, otherwise the implicit
* value from a single variable-bearing part across the rule's `parts`.
* Throws if a value is required and missing/ambiguous. Not every rule
* needs a value - only when `requireValue` is set (top-level action
* rules, or nested rules captured by a parent variable) do
* ambiguous/missing values throw; otherwise both resolve to
* `undefined`. `describeRule` is only invoked when an error is thrown.
*/
export function deriveEffectiveValue(
rule: GrammarRule,
requireValue = false,
describeRule?: () => string,
): CompiledValueNode | undefined {
if (rule.value !== undefined) {
return rule.value;
}
const variableParts = rule.parts.filter((p) => p.variable !== undefined);
if (variableParts.length === 1) {
return { type: "variable", name: variableParts[0].variable! };
}
if (!requireValue) {
return undefined;
}
const description = describeRule?.() ?? "";
const term = rule.parts.length === 1 ? "term" : "terms";
const reason =
variableParts.length > 1
? "more than one part carries a variable - the implicit value is ambiguous"
: "no part carries a variable";
throw new Error(
`Internal error: ${description} has ${rule.parts.length} ${term} but no value expression, and ${reason}. ` +
"Rules must have an explicit value expression (using ->) unless exactly one part carries a variable.",
);
}
7 changes: 2 additions & 5 deletions ts/packages/actionGrammar/src/grammarValueTypeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,11 +500,8 @@ export function classifyRuleValue(rule: GrammarRule): RuleValueKind {
}
const variableParts = rule.parts.filter((p) => p.variable !== undefined);
if (variableParts.length === 1) {
return {
kind: "variable",
variableName: variableParts[0].variable!,
part: variableParts[0],
};
const part = variableParts[0];
return { kind: "variable", variableName: part.variable!, part };
}
if (variableParts.length === 0 && rule.parts.length === 1) {
const part = rule.parts[0];
Expand Down
87 changes: 23 additions & 64 deletions ts/packages/actionGrammar/src/nfaCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
ValueExpression,
} from "./environment.js";
import { normalizeToken } from "./nfaMatcher.js";
import { deriveEffectiveValue } from "./grammarValueDeriver.js";

// Scripts that require a word-boundary separator between adjacent tokens.
// CJK and other logographic/syllabic scripts are NOT included — no separator needed.
Expand Down Expand Up @@ -353,6 +354,21 @@ function normalizeRule(
rule: GrammarRule,
cache: Map<GrammarRule[], GrammarRule[]>,
): GrammarRule {
// tailCall is an NYI NFA/DFA backend gap, unrelated to value
// derivation. Checked here since normalizeRule visits every rule
// exactly once (top-level and nested) before compilation begins -
// the single earliest point to reject it.
const tailCallPart = rule.parts.find(
(p): p is RulesPart => p.type === "rules" && !!p.tailCall,
);
if (tailCallPart !== undefined) {
throw new Error(
`normalizeGrammar: tail RulesPart (name='${tailCallPart.name ?? "<unnamed>"}') is not yet supported by the ` +
"NFA/DFA backend - disable `tailFactoring` / `promoteTailRulesParts` in the " +
"grammar optimizer for NFA/DFA paths.",
);
}

// First, normalize all nested RulesParts
const normalizedParts = rule.parts.map((part) =>
normalizePart(part, cache),
Expand Down Expand Up @@ -450,29 +466,6 @@ function stripDispatch(part: RulesPart): RulesPart {
});
}

/**
* Check if a rule is a single-variable rule (e.g., <ArtistName> = $(x:wildcard);)
* Such rules should implicitly produce their variable's value: -> $(x)
*/
function isSingleVariableRule(rule: GrammarRule): { variable: string } | false {
// A single-variable rule has:
// 1. No explicit value expression
// 2. Single part that is a wildcard or number with a variable
if (rule.value) {
return false; // Has explicit value
}
if (rule.parts.length !== 1) {
return false; // Multiple parts
}
const part = rule.parts[0];
if (part.type === "wildcard" || part.type === "number") {
if (part.variable) {
return { variable: part.variable };
}
}
return false;
}

/**
* Collect all variable names from a rule's parts
* Returns them in order of appearance
Expand Down Expand Up @@ -614,24 +607,12 @@ export function compileGrammarToNFA(grammar: Grammar, name?: string): NFA {
) {
const rule = normalizedGrammar.alternatives[ruleIndex];

// VALIDATION: Multi-term rules MUST have value expressions
// Single-term rules can omit value expressions (they inherit from the term)
if (rule.parts.length > 1 && !rule.value) {
throw new Error(
`Grammar rule at index ${ruleIndex} has ${rule.parts.length} terms but no value expression. ` +
`Multi-term rules must have an explicit value expression (using ->).`,
);
}

// Check for single-variable rules like <ArtistName> = $(x:wildcard);
// These should implicitly produce their variable's value: -> x
let effectiveValue = rule.value;
if (!effectiveValue) {
const singleVar = isSingleVariableRule(rule);
if (singleVar) {
effectiveValue = { type: "variable", name: singleVar.variable };
}
}
// Check for single-variable / factored rules with no explicit -> value.
const effectiveValue = deriveEffectiveValue(
rule,
true,
() => `Grammar rule at index ${ruleIndex}`,
);

const ruleEntry = builder.createState(false);

Expand Down Expand Up @@ -1391,13 +1372,6 @@ function compileRulesPart(
checkedVariables?: Set<string>,
overrideVariableName?: string,
): number {
if (part.tailCall) {
throw new Error(
`compileRulesPart: tail RulesParts are not supported by the NFA compiler ` +
`(part.name='${part.name ?? "<unnamed>"}'). ` +
"Disable `tailFactoring` in the grammar optimizer for NFA/DFA paths.",
);
}
if (part.alternatives.length === 0) {
// Empty rules - epsilon transition
builder.addEpsilonTransition(fromState, toState);
Expand Down Expand Up @@ -1472,13 +1446,6 @@ function compileRulesPartWithSlots(
toState: number,
context: RuleCompilationContext,
): number {
if (part.tailCall) {
throw new Error(
`compileRulesPartWithSlots: tail RulesParts are not supported by the NFA compiler ` +
`(part.name='${part.name ?? "<unnamed>"}'). ` +
"Disable `tailFactoring` in the grammar optimizer for NFA/DFA paths.",
);
}
if (part.alternatives.length === 0) {
// Empty rules - epsilon transition
builder.addEpsilonTransition(fromState, toState);
Expand Down Expand Up @@ -1528,15 +1495,7 @@ function compileRulesPartWithSlots(

// Compile and store the action value on the entry state FIRST
// We need to know if there's a value before deciding about environments
// Passthrough normalization is done upfront, so rules already have explicit values
// Just check for single-variable rules like <ArtistName> = $(x:wildcard);
let effectiveValue = rule.value;
if (!effectiveValue) {
const singleVar = isSingleVariableRule(rule);
if (singleVar) {
effectiveValue = { type: "variable", name: singleVar.variable };
}
}
const effectiveValue = deriveEffectiveValue(rule);

let compiledValue: ValueExpression | undefined;
let nestedCompletionActionName: string | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,33 @@ describe("Grammar Optimizer - tailFactoring + NFA compatibility", () => {
},
});
expect(() => compileGrammarToNFA(optimized)).toThrow(
/tail RulesParts are not supported by the NFA compiler/,
/tail RulesPart .* is not yet supported by the NFA\/DFA backend/,
);
});

// Regression test: a rule whose entire `parts` array is just the
// tailCall RulesPart (no prefix) is `isPassthroughRule`-eligible, so
// it must be caught before that normalization transform runs.
it("NFA compile of a single-part tailCall rule throws the same descriptive error", async () => {
const { compileGrammarToNFA } = await import("../src/nfaCompiler.js");
const memberA: GrammarRule = {
parts: [createStringPart(["alpha"])],
value: { type: "literal", value: "a" },
};
const memberB: GrammarRule = {
parts: [createStringPart(["beta"])],
value: { type: "literal", value: "b" },
};
const tailPart: RulesPart = {
type: "rules",
optional: undefined,
variable: undefined,
alternatives: [memberA, memberB],
tailCall: true,
};
const grammar: Grammar = { alternatives: [{ parts: [tailPart] }] };
expect(() => compileGrammarToNFA(grammar)).toThrow(
/tail RulesPart .* is not yet supported by the NFA\/DFA backend/,
);
});
});
Expand Down
Loading
Loading