Optimized compilation of string patterns - #540
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| case _: (Literal | ClassLike | Concat | CharClass) => Never | ||
| case pattern: (Record | Tuple) => pattern | ||
| case _: (MatchedClassLike | Synonym) => lastWords("unexpected specialized/complete node in specialize(lit)") | ||
| pattern Input = | ||
| ((Char.Whitespace ~ (Input as inp)) => inp) | ((c ~ (Input as inp)) => [c, ..inp]) | ("" => []) | ||
|
|
||
| :fixme |
There was a problem hiding this comment.
This case should probably be moved out of this file.
| //│ return Stack⁰.Nil⁰ | ||
| //│ }; | ||
| //│ set tmp = [lambda⁰, lambda¹, lambda²]; | ||
| //│ set parseResult = runtime⁰.StrPat⁰.parseWhole⁰("130,1,0,3,17,47,0;37,38,43,44,45,46,47,48,58,64,65,91,95,96,97,123;0,3,1,98,-1,1,128,-1,1,129,-1,1,1,0,0,3,1,35,-1,1,65,-1,1,66,-1,1,1,3,2,1,0,4,1,44,44,1,1,5,3,2,1,15,-1,1,6,-1,2,1,9,-1,1,7,-1,2,1,10,-1,1,11,-1,1,0,8,1,97,122,1,0,8,1,65,90,2,1,13,-1,1,14,-1,1,0,9,1,97,122,1,0,9,1,65,90,1,0,12,1,46,46,2,1,17,-1,1,15,-1,3,1,18,-1,1,21,-1,1,22,-1,2,1,19,-1,1,20,-1,1,0,16,1,97,122,1,0,16,1,65,90,1,0,16,1,48,57,1,0,16,1,45,45,1,0,17,1,64,64,2,1,25,-1,1,23,-1,7,1,26,-1,1,29,-1,1,30,-1,1,31,-1,1,32,-1,1,33,-1,1,34,-1,2,1,27,-1,1,28,-1,1,0,24,1,97,122,1,0,24,1,65,90,1,0,24,1,48,57,1,0,24,1,46,46,1,0,24,1,95,95,1,0,24,1,37,37,1,0,24,1,43,43,1,0,24,1,45,45,1,1,25,4,1,1,2,5,2,1,45,-1,1,36,-1,2,1,39,-1,1,37,-1,2,1,40,-1,1,41,-1,1,0,38,1,97,122,1,0,38,1,65,90,2,1,43,-1,1,44,-1,1,0,39,1,97,122,1,0,39,1,65,90,1,0,42,1,46,46,2,1,47,-1,1,45,-1,3,1,48,-1,1,51,-1,1,52,-1,2,1,49,-1,1,50,-1,1,0,46,1,97,122,1,0,46,1,65,90,1,0,46,1,48,57,1,0,46,1,45,45,1,0,47,1,64,64,2,1,55,-1,1,53,-1,7,1,56,-1,1,59,-1,1,60,-1,1,61,-1,1,62,-1,1,63,-1,1,64,-1,2,1,57,-1,1,58,-1,1,0,54,1,97,122,1,0,54,1,65,90,1,0,54,1,48,57,1,0,54,1,46,46,1,0,54,1,95,95,1,0,54,1,37,37,1,0,54,1,43,43,1,0,54,1,45,45,1,1,55,4,1,1,2,6,1,1,3,7,1,0,67,1,44,44,1,1,68,3,2,1,78,-1,1,69,-1,2,1,72,-1,1,70,-1,2,1,73,-1,1,74,-1,1,0,71,1,97,122,1,0,71,1,65,90,2,1,76,-1,1,77,-1,1,0,72,1,97,122,1,0,72,1,65,90,1,0,75,1,46,46,2,1,80,-1,1,78,-1,3,1,81,-1,1,84,-1,1,85,-1,2,1,82,-1,1,83,-1,1,0,79,1,97,122,1,0,79,1,65,90,1,0,79,1,48,57,1,0,79,1,45,45,1,0,80,1,64,64,2,1,88,-1,1,86,-1,7,1,89,-1,1,92,-1,1,93,-1,1,94,-1,1,95,-1,1,96,-1,1,97,-1,2,1,90,-1,1,91,-1,1,0,87,1,97,122,1,0,87,1,65,90,1,0,87,1,48,57,1,0,87,1,46,46,1,0,87,1,95,95,1,0,87,1,37,37,1,0,87,1,43,43,1,0,87,1,45,45,1,1,88,4,1,1,0,5,2,1,108,-1,1,99,-1,2,1,102,-1,1,100,-1,2,1,103,-1,1,104,-1,1,0,101,1,97,122,1,0,101,1,65,90,2,1,106,-1,1,107,-1,1,0,102,1,97,122,1,0,102,1,65,90,1,0,105,1,46,46,2,1,110,-1,1,108,-1,3,1,111,-1,1,114,-1,1,115,-1,2,1,112,-1,1,113,-1,1,0,109,1,97,122,1,0,109,1,65,90,1,0,109,1,48,57,1,0,109,1,45,45,1,0,110,1,64,64,2,1,118,-1,1,116,-1,7,1,119,-1,1,122,-1,1,123,-1,1,124,-1,1,125,-1,1,126,-1,1,127,-1,2,1,120,-1,1,121,-1,1,0,117,1,97,122,1,0,117,1,65,90,1,0,117,1,48,57,1,0,117,1,46,46,1,0,117,1,95,95,1,0,117,1,37,37,1,0,117,1,43,43,1,0,117,1,45,45,1,1,118,4,1,1,0,6;8,1,7,8,4,1,3,5,0,2,0,1,4,9,1,1,0,4,1,4,0,3,1,0,8,1,4,2,3,5,1,1,2,3,5,2,0,5,6,9,1,1,0;1,1,1,1,2,1,1,1,1,1,1,3,1,1,1,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,5,1,1,1,6,1,1,1,1,1,1,1,1,1,1,1,1,7,1,1,1,8,1,1,1,1,1,1,1,1,1,1,1,1,7,1,1,1,8,1,1,1,1,1,1,1,1,1,1,1,1,9,1,1,1,10,1,1,1,1,1,1,1,1,1,1,1,1,9,1,1,1,10,1,1,1,1,1,1,1,11,1,1,1,1,7,1,1,1,8,1,1,1,1,1,1,1,11,1,1,1,1,7,1,1,1,8,1,1,1,1,1,1,1,12,1,1,1,1,9,1,1,1,10,1,1,1,1,1,1,1,12,1,1,1,1,9,1,1,1,10,1,1,1,1,1,1,13,1,1,14,1,1,15,1,1,1,16,1,1,1,1,1,1,17,1,1,18,1,1,19,1,1,1,20,1,1,1,1,1,1,13,1,1,14,1,21,22,1,1,1,23,1,1,1,1,1,1,13,1,1,14,1,21,22,1,1,1,23,1,1,1,1,1,1,13,1,1,14,1,21,24,1,1,1,25,1,1,1,1,1,1,13,1,1,14,1,21,24,1,1,1,25,1,1,1,1,1,1,17,1,1,18,1,26,27,1,1,1,28,1,1,1,1,1,1,17,1,1,18,1,26,27,1,1,1,28,1,1,1,1,1,1,17,1,1,18,1,26,29,1,1,1,30,1,1,1,1,1,1,17,1,1,18,1,26,29,1,1,1,30,1,1,31,1,32,1,33,34,1,35,1,1,36,1,37,1,38,1,1,1,1,1,1,13,1,1,14,1,21,22,1,1,1,23,1,1,1,1,1,1,13,1,1,14,1,21,22,1,1,1,23,1,1,1,1,1,1,13,11,1,14,1,21,24,1,1,1,25,1,1,1,1,1,1,13,11,1,14,1,21,24,1,1,1,25,1,1,39,1,40,1,41,42,1,43,1,1,44,1,45,1,46,1,1,1,1,1,1,17,1,1,18,1,26,27,1,1,1,28,1,1,1,1,1,1,17,1,1,18,1,26,27,1,1,1,28,1,1,1,1,1,1,17,12,1,18,1,26,29,1,1,1,30,1,1,1,1,1,1,17,12,1,18,1,26,29,1,1,1,30,1,1,31,1,32,2,33,34,1,35,1,1,36,1,37,1,38,1,1,31,1,32,2,33,34,1,35,1,1,36,1,37,1,38,1,1,31,1,32,2,33,34,1,35,1,1,36,1,37,1,38,1,1,31,1,32,2,33,34,1,35,1,1,36,1,37,1,38,1,1,31,1,32,2,33,34,1,35,1,1,36,1,37,1,38,1,1,31,1,32,2,33,34,1,35,1,1,36,1,37,1,38,1,1,31,1,32,2,33,34,1,35,1,1,36,1,37,1,38,1,1,31,1,32,2,33,34,1,35,1,1,36,1,37,1,38,1,1,39,1,40,2,41,42,1,43,1,1,44,1,45,1,46,1,1,39,1,40,2,41,42,1,43,1,1,44,1,45,1,46,1,1,39,1,40,2,41,42,1,43,1,1,44,1,45,1,46,1,1,39,1,40,2,41,42,1,43,1,1,44,1,45,1,46,1,1,39,1,40,2,41,42,1,43,1,1,44,1,45,1,46,1,1,39,1,40,2,41,42,1,43,1,1,44,1,45,1,46,1,1,39,1,40,2,41,42,1,43,1,1,44,1,45,1,46,1,1,39,1,40,2,41,42,1,43,1,1,44,1,45,1,46,1;+AAAAA4AAAAwAAAAHAAAAEAAAAAAAAAAAAAAAAAAAAAAeAAAAAAAAADwAAAAAAAAAAAAAAANAAAAAAAAABoAAAAAAAAAA4AAAAAAAAAHAAAAADQAAAAAAAAAaAAAAAAAAAAOAAAAAAAAABwAAAAAAAAAAAAAAA2gAAAAAAAAG0AAAAAAAAADsAAAAAAAAAdgAAAANoAAAAAAAABtAAAAAAAAAA7AAAAAAAAAHYAAAAAAAAAAAAAAGBgAAAAAAAAwMAAAAYGAAAAAAAADAwAAAAAAAAAAAAAAAMIAAAAAAAABhAAAAAAAAAAxAAAAAAAAAGIAAAAAAAANDoAAAAAAABodAAAAAAAAA4PAAAAAAAAHB4AAAAAwgAAAAAAAAGEAAAAAAAAADEAAAAAAAAAYgAAAAAAAA0OgAAAAAAAGh0AAAAAAAADg8AAAAAAAAcHgAAAAAAAAAAAAAAABgAAAAAAAAAMAAAAAAAAAOgAAAAAAAAB0AAAAAAAAAA8AAAAAAAAAHgAAAAAAAANroAAAAAAABtdAAAAAAAAA7PAAAAAAAAHZ4AAAAAAYAAAAAAAAADAAAAAAAAADoAAAAAAAAAdAAAAAAAAAAPAAAAAAAAAB4AAAAAAAADa6AAAAAAAAbXQAAAAAAAAOzwAAAAAAAB2eAAAAAAABYAAAAAAADAlAAAAAAAAYElgAAAAAAAMBUAAAAAAABgKWAAAAAAAAwDQAAAAAAAGAZYAAAAAAADCFAAAAAAAAYQlgAAAAAAAMQUAAAAAAABiCWAAAAAAAA6BQAAAAAAAHQJYAAAAAAADBFAAAAAAAAYIlgAAAAAAAPAUAAAAAAAB4CWAAAwJAAAAAQAAGBIAAAABYAADAUAAAABAAAYCgAAAAFgAAMAwAAAAEAABgGAAAAAWAAAwhAAAAAQAAGEIAAAABYAADEEAAAABAAAYggAAAAFgAAOgQAAAAEAAB0CAAAAAWAAAwRAAAAAQAAGCIAAAABYAADwEAAAABAAAeAgAAAAA", tmp, input); |
There was a problem hiding this comment.
This is a crazy scheme. It reparses a string on every match. Clearly, this should be cached into a top-level field of the current file.
Setting HKMC2_STRPAT_STATS to a file path when running the diff tests records one line per table literal embedded in generated code (file, test-block line, table kind, length), for tracking the effect of table-compression work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
reduceStates so far only contracted operation-free single-edge states. Extend the reduction to op-carrying ones: an epsilon in-edge absorbs the fused state's operations after its own. The operations cross no character edge and no choice point, and the fused state's viability coincides with its target's, so the committed parse is preserved. Chains ending in a dead epsilon-cycle are left to dropDeadEdges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recognition observes one bit per reverse state (start-membership), so Moore partition refinement merges states the parsing oracle must keep apart. The parsing table is untouched: its reverse states are each observed through their full viability row, which subset construction already keeps distinct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comma-separated decimal spent ~3 characters per integer; source-map style VLQ (five value bits per Base64 character, low group first, sixth bit flagging continuation) spends 1 for values below 32, which covers most state ids and op codes. The epsilon-edge ops-id field is stored shifted by one so the encoding stays non-negative. Packed tables are no longer human-readable, so the encoder now logs the automaton and its reverse DFA in a readable form under the ucs:string-compiler scope, which replaces the eyeball-debugging the decimal encoding used to provide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Character range bounds are now validated as single code points rather than single UTF-16 code units, so ranges like `"😀" ..= "😏"` are accepted. The instantiator decomposes ranges reaching beyond the BMP into surrogate-pair sequences over the existing `Concat`/`CharClass` nodes (partial first high surrogate, full middle ones, partial last), the standard decomposition used by regular expression engines; ranges spanning both planes split into their two halves. The instantiated form now also honors exclusive upper bounds, which were previously treated as inclusive on this path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
String regions containing guards, chains, extraction arguments, or unresolved pattern references keep the legacy backtracking translation, whose observable behavior differs from compiled automata (transforms can run on abandoned parses; splitting is greedy rather than decided jointly for the whole sequence). This fallback used to be silent, so adding such a construct to a working pattern quietly changed its semantics. `regionSupported` is now a thin wrapper over `unsupportedRegionConstruct`, which reports the first offending construct and its location — including locations inside referenced pattern definitions, such as the guarded stdlib `Char` patterns. The match-site fallback warns with both the region and the construct. References to pattern parameters stay silent: they only occur in parametric definition bodies, whose use sites compile their own automata rather than falling back. The new FallbackWarning test also pins down a pre-existing bug: the legacy translation miscompiles extraction arguments in sequences (`arg$...` scope lookup failure), marked :fixme there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A negated pattern in a string region now matches any consumed segment that the negated pattern itself does not match, instead of being a hard error. The negated pattern is built as a self-contained fragment, forward-determinized over its own alphabet classes (`determinizeFragment`, complete over the alphabet with the empty subset as sink), and embedded as its complement: a state exits to the continuation exactly when its subset does not accept the fragment. Consuming edges come first, so the complement consumes greedily like the wildcard, and the priority-based committed parse is unaffected (a DFA adds no choice points). Negations are recognition-only: fragments must be pure. Aliases directly under a negation are already dropped during elaboration, and same-SCC recursion under a negation is already rejected by the tail-position check (the fragment never jumps out), so the only new rejection is transforms reaching a negation, e.g. through a referenced definition. The fragment DFA is also the building block for conjunctions (product construction) in a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A conjunction in a string region now matches a segment satisfying all of its conjuncts, instead of being a hard error. All conjuncts consume the same slice, so a pure conjunct's value is that slice and only an impure conjunct can contribute a distinct value or bindings: one conjunct — the only impure one, or the first when all are pure — acts as the driver, built as an ordinary prioritized fragment whose priorities and operations determine the committed parse and the conjunction's value. Every other conjunct is forward-determinized (reusing `FragmentDfa`) and run in lockstep as a constraint: the product of the driver fragment with the constraint DFAs completes only in states where every constraint accepts the consumed slice. Character edges are split wherever a constraint changes alphabet class; the pieces of one driver edge are disjoint, so priorities are unaffected and the committed parse is the driver's highest-priority parse among the slices all constraints admit. Conjunctions with two or more impure conjuncts are rejected with an error, as their combined value would be ambiguous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two commits of this PR strip the trailing indentation from seven pre-existing blank lines (one in `ups/Compiler.scala`, two in `ups/Pattern.scala`, four in the `module Str` block of `Runtime.mls`), which shows up as needless churn in the PR diff. AGENTS.md asks that indentation whitespace never be stripped and that a PR diff carry no gratuitous empty-line changes, so put those seven lines back. The surrounding blank lines in all three files keep their indentation, so the stripped ones were the outliers rather than a deliberate file-wide normalization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seven behaviours of the new automaton path are wrong, and all seven pass
silently under the current suite. Each one is pinned here with the correct
answer as `:expect` and marked `:fixme` until it is fixed. All of them were
checked against `hkust-taco/hkmc2` and behave correctly there, so they are
regressions rather than missing features:
- `isPureDeep` memoizes the optimistic answer it assumes for an
instantiation already being visited, so a member of an impure SCC can be
recorded as pure. Because `Or(ps) => ps.forall(...)` short-circuits,
which member that is depends on the order the alternatives are written
in: swapping two alternatives of the same grammar changes the result.
- `makeStringRegionSplit` emits the `StrPat` call with no `Str` head test,
unlike the absorbed `Str` head of the multi-matcher, so `matchWhole`
reads `.length` off a non-string scrutinee and reports a match.
- `Instantiator` drops `rightInclusive` when building a `CharClass`, so
`..<` silently means `..=` inside a `~` sequence (it is still honoured
by `makeRangeTest` outside one). No test in the tree used `..<` on
strings.
- Binding slots are global to the region and rewritten at every recursion
level; `Op.Defer` captures the slots a frame reads before writing, but a
slot the frame writes itself is read back from the global store, so the
outermost transform sees the innermost activation's binding.
- `multiMatcherStringBranch` calls `matchWhole` in match-only mode even
when the region has transforms, so `@compile` drops their effects, while
`makeStringRegionSplit` forces the parsing entry point in that case.
- Every label of a multi-matcher gets its own `StringCompiler`, so a
definition reached from two labels emits its transform lambda twice with
the same parameter symbols, which `SymbolRefresher` asserts against.
- `isParametricStringSite` routes a use site to an in-place automaton that
only knows the region's own output, so extraction parameters are lost
and the whole match is returned instead.
The file passes as written; each block starts failing as soon as its bug is
fixed, at which point the marker should be dropped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full write-up of the review: 61 findings raised across nine areas of the change, each then handed to three independent verifiers instructed to refute it (does the code do this, is the path reachable, is it introduced here or pre-existing). 50 survived and are recorded with a traced explanation, a concrete failing scenario and a proposed fix; the 11 that were knocked down are kept at the end so they are not raised again. Also records the seven confirmed regressions side by side with their behaviour on `hkust-taco/hkmc2`, and the pre-existing issues the review turned up in the same areas. This is review correspondence rather than project content — drop the file once the findings have been triaged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LPTK
left a comment
There was a problem hiding this comment.
[Claude] Automated review of the automaton-based compilation of string patterns.
The design is sound — Frisch–Cardelli two-pass matching, right-linear SCC detection, deferred frames for the value operations that follow a tail call — and StringCompiler.scala is unusually well documented. The implementation is not there yet.
8 critical defects: 7 silent miscompilations and 1 compiler crash. All 8 are regressions: every program below produces the correct answer on hkust-taco/hkmc2 and the wrong one here. None is caught by the suite, which is fully green (814 tests, WASM included, no golden rewrites) — which is exactly the situation AGENTS.md warns about.
| program | base | this PR |
|---|---|---|
pattern Y = ("a" ~ X) | (("b"~"c") => "T"), pattern X = ("d" ~ Y) | "e"; "zae" is ("z" ~ Y) as r |
"zae" |
"()z" |
pattern Funny = "" ~ "" ~ ""; 42 is Funny |
TypeError |
true |
pattern Half = ("a" ..< "z") ~ "!"; "z!" is Half |
false |
true |
pattern Listed = (((Digit as h) ~ (Listed as t)) => h+","+t) | ("" => "$"); "123" is Listed |
"1,2,3,$" |
"3,2,3,$" |
@compile (("a" => print("ran")) ~ "b") in condition position |
clean "unsupported" error | transform never runs |
pattern P2 = Box(T ~ "c") | Box(T ~ "d") under @compile |
clean "unsupported" error | AssertionError: already defined: w |
Bracket(Str) as v, for pattern Bracket(pattern P, inner) = "[" ~ (P as inner) ~ "]" |
"no" |
"[ab]" (should be "ab") |
Each is on its own inline comment below, with the trace and a suggested fix. Every repro was run on this branch and again on a worktree of the base ref, except the effect-handler one (C2), which is traced and reproduced at the engine level but not run end to end under :effectHandlers — that comment says so.
Method: nine reviewers each took one area of the change (NFA construction, NFA reduction, reverse determinization and encoding, the StrPat engine, SplitCompiler gating, the multi-matcher and specialize, Instantiator/Pattern, tests and goldens, performance and AGENTS.md conformance). Every finding was then handed to three independent verifiers instructed to refute it — does the code actually do this, is the path reachable from surface syntax, and is the defect introduced here or pre-existing. 61 raised, 50 survived. Only the 8 critical ones are inline here; there are 26 further major findings (unbounded reverse determinization, regionSupported admitting &/! that build then hard-fails on, unapplyStringPrefix swallowing those errors into a constant failure, Concat flattening re-associating ~, a compiler hang on polymorphic-recursive parametric patterns) available on request.
Two small things not worth their own comment:
- The diff strips the trailing indentation from 7 pre-existing blank lines (
ups/Compiler.scala×1,ups/Pattern.scala×2,Runtime.mls×4). AGENTS.md asks that indentation whitespace never be stripped and that a PR carry no gratuitous empty-line changes. Compiled.resultPrefixSizeis dead code, while the result-array layout it documents is open-coded at three call sites.
A regression-test file pinning all 8 with :expect + :fixme is ready and passes as written; each block starts failing as soon as its bug is fixed. Happy to push it here if useful.
| case Not(p) => loop(p, visiting) | ||
| case Synonym(inst) => | ||
| if visiting contains inst then true | ||
| else pureMemo.getOrElseUpdate(inst, loop(context.get(inst), visiting + inst)) |
There was a problem hiding this comment.
[Claude] Critical — the purity of a recursive component depends on the order its alternatives are written in.
The optimistic true on the line above (for an instantiation already in visiting) is sound for the query that made the assumption, but getOrElseUpdate memoizes it region-wide. Since Or(ps) => ps.forall(...) short-circuits, a body whose first alternative reaches back into the SCC gets pureMemo := true written for the callee before the second alternative reveals the Extract. Two members of one SCC then disagree about purity.
The pure-subtree shortcut (l.355) then fires for a fragment containing a same-SCC tail reference: it emits Op.Mark on the way in and puts the matching Op.Slice on an exit state the goto can never reach. reduceStates prunes that state, so the fragment pushes zero values instead of the one the completion protocol promises, and the enclosing deferred Op.Add underflows.
pattern Y = ("a" ~ X) | (("b" ~ "c") => "T")
pattern X = ("d" ~ Y) | "e"
if "zae" is ("z" ~ Y) as r then r else "no"
//| = "()z" <- base: "zae"
Writing Y's two alternatives in the other order makes it correct again, which is what pins the cause on the memo rather than on the grammar.
Purity is a property of the SCC condensation, not of an individual traversal. Suggest computing it once bottom-up after computeSccs(), so all members of an SCC agree by construction. If the recursive formulation is kept, thread a "consulted an assumption" flag out of loop and only memoize when it is false. The softAssert at l.480 does not catch this: it guards sccContext.pure && needValue, which is exactly the combination that does not arise on this path.
There was a problem hiding this comment.
[Claude] Addressed — fix written and verified locally (not pushed here yet).
isPureDeep now keeps results that consulted the in-cycle assumption out of the memo, so a later query recomputes them exactly. The assumption itself stays (it is what makes the traversal well-founded); only its caching was unsound. One consequence worth noting: the traversal can no longer short-circuit on the first impure element, because it has to visit every branch to learn whether any of them consulted the assumption — so forall became a fold over (pure, assumed).
The two spellings of the grammar now agree:
pattern Y = ("a" ~ X) | (("b" ~ "c") => "T")
pattern X = ("d" ~ Y) | "e"
if "zae" is ("z" ~ Y) as r then r else "no" //| = "zae"
pattern Y2 = (("b" ~ "c") => "T") | ("a" ~ X2)
pattern X2 = ("d" ~ Y2) | "e"
if "zae" is ("z" ~ Y2) as r then r else "no" //| = "zae"
This is the minimal sound fix and it does not disturb the surrounding design. The stronger version I suggested in the original comment — computing purity once over the SCC condensation after computeSccs() — is still worth doing: it would make all members of an SCC agree by construction rather than by the memo being careful, and it would restore the short-circuit. I left that to you as it changes the shape of the analysis.
Not addressed, and related: the softAssert at l.480 does not cover the invariant that actually broke here (the pure-subtree shortcut must not be entered for a fragment that can reach a same-SCC reference). Worth adding while this is fresh.
Leaving this thread open for you.
| while j < argCount do | ||
| args.push(readSlot(ops.[k + 3 + j], overlay)) | ||
| set j += 1 | ||
| valStack.push(actions.[actionId].apply(null, args)) |
There was a problem hiding this comment.
[Claude] Critical — a transform that raises an algebraic effect corrupts the parse.
parseWhole/parsePrefix are not annotated @mayNotRaiseEffects, but the closure is applied from inside the engine's own loop, which the handler transformation never instruments. When a transform suspends, runtime.curEffect is set, apply returns Unit, and parseRun pushes that onto valStack and keeps walking — running every remaining committed-path transform with () in place of the suspended value.
Reproduced by driving the shipped engine directly with the CommaSep(Email) table from the EmailAddress.mls golden plus one effectful action: the effectful transform runs and sets curEffect, and the outer head :: tail transform then still runs, with () for the inner result.
The MLscript shape that reaches this is any compiled string region with an effectful => under :effectHandlers, e.g. if s is ((("a".."z") as c) => h.ask(c)) ~ "!" then …. ups/regex/CompiledSemantics.mls already exercises that shape with a pure print, so the path is live. To be precise about the evidence: I traced this and reproduced it at the engine level, but did not run the handler-level program end to end — so treat the MLscript-level manifestation as traced rather than measured.
The clean fix is to hoist transform application out of the engine: have parseRun return the committed call plan (action ids plus argument tuples, in commit order) and apply the closures in the generated user block, where handler lowering applies. As a stopgap that at least turns silent corruption into something diagnosable, check runtime.curEffect !== null right after actions.[actionId].apply(...) and call runtime.illegalEffect. Either way this wants a :effectHandlers regression test with an effectful => under ~, :fixme-marked if the real fix is deferred.
| * This is the case exactly when every argument is a pattern argument and | ||
| * a string sequence occurs in the definition or the arguments. | ||
| */ | ||
| private def isParametricStringSite(patternSymbol: PatternSymbol, arguments: Opt[Ls[SP]]): Bool = |
There was a problem hiding this comment.
[Claude] Critical — use sites of parametric string patterns lose their extraction parameters.
This predicate requires defn.patternParams.nonEmpty and a matching argument count, but never looks at defn.extractionParams. When it fires, the site compiles an in-place automaton that knows only the region's own output, so the extraction binding is dropped and the whole matched string comes back instead.
pattern Bracket(pattern P, inner) = "[" ~ (P as inner) ~ "]"
if "[ab]" is Bracket(Str) as v then v else "no"
//| = "[ab]" <- should be "ab"; base: "no"
With two extraction parameters the site should yield a tuple and instead yields the input string. Alias in makeMatchSplit binds symbol -> output, so ... as v faithfully receives the wrong output — nothing downstream can notice.
The cheap correct gate is to also require defn.extractionParams.isEmpty here, and let those definitions keep the unapply route (worth a line of comment saying why). Projecting the extraction parameters out of compiled.visibleSlots would be better, but that needs Instantiator/StringCompiler to keep definition-internal Renames visible through Synonym, which they currently do not.
For completeness: matching the extraction argument directly — Bracket(Str, x) — returns "no" on both branches, so that half is pre-existing rather than a regression from this PR.
| if compiled.pure || | ||
| (!outputNeeded && compiled.visibleSlots.isEmpty && compiled.actions.isEmpty) then | ||
| (makeConsequent, alternative) => | ||
| val callTerm = app(strPatMatchWhole, |
There was a problem hiding this comment.
[Claude] Critical — a string region matches a non-string scrutinee.
Both call sites emitted here go straight to StrPat.matchWhole/parseWhole with no Str head test, unlike Compiler.buildMultiMatcherBody, which guards its absorbed Str branch with FlatPattern.ClassLike(Str, …). matchWhole then reads .length off whatever it is handed.
pattern Funny = "" ~ "" ~ ""
42 is Funny //| = true <- base: TypeError
[] is Funny //| = true <- base: TypeError
42 is @compile Funny //| = false <- the multi-matcher gets this right
The encoded table for Funny is "1,0;;1,1;10", so starts[seedRev] == '1' and any scrutinee with a falsy or zero length is reported as a match. On the parsing entry point the same gap surfaces differently — trimStart(42) walks into Error: StrPat: no viable transition (this is a compiler bug), blaming the compiler for what is a user type error.
Wrapping both emitted sites in the same Branch(scrutinee(), FlatPattern.ClassLike(ctx.builtins.Str.safeRef, ctx.builtins.Str, N, false)(Tree.Dummy), …) ~: alternative that the multi-matcher already uses fixes it. Defensively, matchWhole and parseRun should also reject a non-string input rather than silently reading .length off it.
There was a problem hiding this comment.
[Claude] Addressed — fix written and verified locally (not pushed here yet).
Both emitted call sites now sit under the same Str branch that Compiler.buildMultiMatcherBody already uses for the absorbed head, so the two paths agree. The generated code is what you would want — a single guard, scrutinee referenced directly, no extra temporary:
define ab as fun ab(x) {
let stringMatched;
match x
Str =>
set stringMatched = runtime.StrPat.matchWhole("4,0;97,98,99;...;0001", x);
match stringMatched
true => return 1
else => return 0
end
else
return 0
end
}
pattern Funny = "" ~ "" ~ ""
42 is Funny //| = false (was: true)
[] is Funny //| = false (was: true)
trimStart(42) //| = 42 (was: "no viable transition (this is a compiler bug)")
Not addressed: the defensive half of the suggestion. matchWhole and parseRun still read .length off whatever they are handed, so they remain wrong if any future call site forgets the guard. A cheap typeof input !== "string" early return would make the engine safe on its own — your call whether the runtime should pay for that.
Leaving this thread open for you.
| // Note that each label needs its own compiler: a compiler instance | ||
| // accumulates the automaton states (and failure flag) of a single | ||
| // region. | ||
| case fragment => StringCompiler().compile(fragment, StringCompiler.Mode.Whole) match |
There was a problem hiding this comment.
[Claude] Critical — compiler crash when two labels of one multi-matcher reach the same transform.
Each label gets its own StringCompiler here, and Extract closures are interned per instance (actionSources.indexWhere(_ eq term)). A definition reached from two labels therefore emits its transform lambda twice, and both copies bind the same VarSymbols, because correspondence comes straight off the shared AST node.
class Box(val v)
pattern T = (("a" ~ "b") as w) => [w]
pattern P2 = Box(T ~ "c") | Box(T ~ "d")
fun f2(x) = if x is @compile P2 as y then y else "no"
//| /!!!\ Uncaught error: java.lang.AssertionError: assertion failed: already defined: w
//| at: hkmc2.codegen.SymbolRefresherWalker.assertUpdate(SymbolRefresher.scala:15)
The whole definition is lost and every later use fails with ReferenceError: f2 is not defined. Minimal pair: one label (Box(T ~ "c") alone) compiles and runs; the same P2 without @compile compiles and runs; two labels in one multi-matcher crashes.
This makes the TODO above the Extract case in StringCompiler optimistic — the hazard is not only "when a simplifier pass duplicates a subtree containing both", it fires on ordinary two-label input. Threading one interning table through all labels of a multi-matcher body would fix it; the TODO's own suggestion — hosting each definition's transforms as methods on the pattern object and referencing them by selection — fixes it properly.
| // previous expansion `(lower.head to upper.head)`. Keeping the range | ||
| // symbolic lets the string pattern compiler emit compact | ||
| // character-class transitions instead of wide disjunctions. | ||
| CharClass(lower.head.toInt, upper.head.toInt).withLocOf(pattern) |
There was a problem hiding this comment.
[Claude] Critical — ..< silently becomes ..= inside a ~ sequence.
rightInclusive is bound in the enclosing match and never used here, so the emitted CharClass is always inclusive. The comment is right that this mirrors the previous (lower.head to upper.head) expansion — but that expansion was only reachable from the @compile path, whereas routing Concatenation through Instantiator now makes it the default for ordinary code.
pattern Half = ("a" ..< "z") ~ "!"
"z!" is Half //| = true <- base: false
pattern Plain = "a" ..< "z"
"z" is Plain //| = false <- still correct, via makeRangeTest
So the same range means two different things depending on whether it sits inside a sequence. Nothing caught it because no test in the tree uses ..< on strings — only ..=.
Suggest CharClass(lo, if rightInclusive then hi else hi - 1), collapsing to Never when the class becomes empty, or an explicit inclusivity field on CharClass. Worth noting the IntLit branch a few lines below has the same bug (lower to upper regardless of the flag) — pre-existing rather than introduced here, but it is right there.
There was a problem hiding this comment.
[Claude] Addressed — fix written and verified locally (not pushed here yet).
The upper bound is now lowered by one for an exclusive range, collapsing to Never when that empties the class:
pattern Half = ("a" ..< "z") ~ "!"
"z!" is Half //| = false (was: true)
"y!" is Half //| = true
pattern Plain = "a" ..< "z"
"z" is Plain //| = false (unchanged — this path always honoured the flag)
I also fixed the integer branch immediately below, which had the same bug (lower to upper regardless of rightInclusive). That one is pre-existing rather than introduced by this PR, but it is literally the adjacent case and the same two lines, so it seemed worse to leave it. Shout if you would rather it were split out.
I did not touch the lower.nonEmpty && upper.nonEmpty guard on this case. It is unreachable — Elaborator.isInvalidStringBounds already rejects any bound whose length is not exactly 1, which also rules out astral characters, since a surrogate pair has length 2 — so a bound reaching here is always a single code unit. Removing the guard would send those to the "Range patterns are not supported in pattern compilation" arm, whose message would be misleading. Left as is, but it is dead defensive code either way.
Leaving this thread open for you.
| if prefix do result.push(input.slice(remStart)) | ||
| let s = 0 | ||
| while s < prog.slotCount do | ||
| result.push(bindings.get(s)) |
There was a problem hiding this comment.
[Claude] Critical — the outer activations of a recursive component read the innermost activation's bindings.
Binding slots are global to the region and rewritten at every recursion level, and this loop exports whatever the global store holds at the end. Op.Defer captures the slots a frame reads before writing them, which is the right rule for h in the shape below — but only when the frame is created; through unapply the outermost transform ends up reading the last-written value instead.
pattern Digit = "0" ..= "9"
pattern Listed = (((Digit as h) ~ (Listed as t)) => h + "," + t) | ("" => "$")
if "1" is Listed as r then r //| = "1,$" <- correct
if "12" is Listed as r then r //| = "2,2,$" <- base: "1,2,$"
if "123" is Listed as r then r //| = "3,2,3,$" <- base: "1,2,3,$"
fun viaCompile(s) = if s is @compile Listed as r then r else "NOPE"
viaCompile("123") //| = "1,2,3,$" <- correct
One definition, three different answers depending on how it is referenced, with no diagnostic. Depth 1 is correct, which is why UpsBugsBacklog.mls's Input case does not expose it — the greedy wildcard eats the whole string, so there is only ever one activation.
Making unapply compile the whole body as one region when containsStringSeq(pd.pattern) && regionSupported(pd.pattern, Set.empty) — exactly the condition already used for unapplyStringPrefix — puts the Transform inside the automaton, where Op.Defer capture already does the right thing. Short of that, StringCompiler.compile should refuse to export a visible slot that can be written more than once (i.e. bound inside a recursive SCC), and softAssert it.
| case N => emptyMatchResult("rejected string pattern") | ||
| case S(compiled) => | ||
| val matchTableTerm = str(compiled.matchTable) | ||
| if isMatchOnly then |
There was a problem hiding this comment.
[Claude] Critical — @compile silently drops a transform's effects.
This branch calls matchWhole regardless of compiled.actions, while makeStringRegionSplit documents the opposite rule and follows it: "Transforms … run exactly once on the committed parse, even when the match is only used as a condition". ups/regex/CompiledSemantics.mls pins that rule — for the inline path only.
fun probe(x) = if x is ("a" => print("ran")) ~ "b" then "yes" else "no"
probe("ab") //| > ran
//| = "yes"
fun probe2(x) = if x is @compile (("a" => print("ran2")) ~ "b") then "yes" else "no"
probe2("ab") //| = "yes" <- "ran2" is never printed
Whether the transform runs depends on whether the caller happens to bind the output (MatchOnly vs Full), so adding @compile — documented as an optimisation switch — changes observable behaviour.
Suggest factoring the decision into one predicate on Compiled that both call sites consult, e.g. def needsParsing(outputNeeded: Bool, bindingsNeeded: Bool): Bool = !pure && (outputNeeded || bindingsNeeded || actions.nonEmpty || visibleSlots.nonEmpty). At minimum, if isMatchOnly && compiled.actions.isEmpty then here, plus a softAssert that the matchWhole path is only taken when actions.isEmpty.
In fairness: dropping Extract in ResultMode.MatchOnly is pre-existing base behaviour for every pattern shape, so the underlying policy question is broader than this PR. What is new is that two paths now disagree for the same pattern, silently.
All three produce the correct answer on the naive translation the automaton
replaced, and none was caught by the test suite.
`isPureDeep` memoized the optimistic answer it assumes for an instantiation
already in `visiting`. That assumption is only valid for the query that
introduced it, so caching it let one query's optimistic `true` leak into
another's — and because `forall` short-circuits, which member of an impure SCC
got recorded as pure depended on the order the alternatives happened to be
written in. The pure-subtree shortcut then fired for a fragment containing a
same-SCC tail reference, emitting an `Op.Mark` whose matching `Op.Slice` sits
on a state the goto can never reach, so the fragment pushed no value at all and
the enclosing deferred `Op.Add` underflowed. Results that consulted the
assumption are now kept out of the memo, so a later query recomputes them
exactly. The traversal no longer short-circuits, because it has to visit every
branch to learn whether any of them consulted the assumption.
`makeStringRegionSplit` emitted the `StrPat` call with no `Str` class test,
unlike the absorbed `Str` head of the multi-matcher, so a non-string scrutinee
reached `matchWhole`, which reads `.length` off it and reported a match for
anything with a falsy length: `42 is ("" ~ "" ~ "")` was `true`. Both emitted
call sites now sit under the same `Str` branch the multi-matcher uses.
`Instantiator` dropped `rightInclusive` when building a `CharClass`, so `..<`
silently meant `..=` as soon as the range sat inside a `~` sequence, while a
bare range still went through `makeRangeTest` and honoured the flag. The upper
bound is now lowered for an exclusive range, collapsing to `Never` when that
empties the class. The integer branch just below had the same bug — it always
used `lower to upper` — which is pre-existing rather than new, but is fixed
here too, being the same two lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`ups/UpsBugsBacklog.mls`'s `Input` case is no longer a bug — it used to overflow the stack in the backtracking translation and now terminates — so it does not belong in a backlog of known bugs. Moved to `ups/regex/ CompiledSemantics.mls`, next to the other wildcard-greediness cases it illustrates, and given assertions: the rendered `[a b c]` does not quote the element, so the shape is pinned separately with `.length` and `.at(0)` rather than by `:expect`-ing the ambiguous rendering. `ups/regex/Separation.mls` had five call sites rewritten from the plain form to `@compile`, which moved them onto the multi-matcher and left the un-annotated path — the one an ordinary program takes — uncovered. Both spellings are now tested side by side; they are supposed to agree, and this file is where that would show up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`regionSupported` decides whether a string sequence is compiled to an automaton
or stays on the legacy prefix composition, so it must admit exactly what
`StringCompiler.build` can compile. Two cases it admitted are not:
- Conjunctions and negations. `build` rejects both outright with `fail`, and
a rejected region compiles to nothing, so patterns the base branch handled
— `Composition(false, …)` emits real code there, and `Negation` at least
matched nothing quietly — now produce a hard compilation error. Worse,
`compilePattern` builds `unapplyStringPrefix` under a silencing `Raise`, on
the theory that any error was already reported while building `unapply`;
that does not hold here, because the prefix path compiles the whole
definition body as one region while `unapply` only compiles its `~`
sub-nodes. A body mixing a sequence with a `&` or `!` therefore turned
`unapplyStringPrefix` into a constant failure with no diagnostic at all.
Only disjunctions are admitted now; the rest keep the legacy composition,
which implements them.
- Constructor patterns whose target did not resolve. Elaboration reports the
error and leaves a `Term.Error` behind, which carries no symbol for
`Instantiator`, so compilation aborted with `lastWords("Missing symbol for
constructor pattern")` — reachable from e.g. `if "x" is (0..< 256) ~ "y"`,
whose erroneous elaboration `ups/RangePatterns.mls` already documents.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`computeBounds` emitted a class boundary at 0 whenever a character range started at U+0000 — which every `Str` or wildcard in string position does, since they compile to the full `(0, MaxUnit)` range. Class 0 is then `[0, 0)`, i.e. empty: `classOf` can never return it, because it counts the boundaries that are `<=` the code unit and 0 is `<=` everything. The column was pure payload — one integer in every reverse-transition row and one bit per state in the viability matrix. `classRepresentative(bounds, 0)` also returned 0, which is not a member of the class it names, so the invariant it documents did not actually hold. `Compiled.resultPrefixSize` had no callers anywhere in the repo; the layout it documents is open-coded at its three use sites. Removed rather than wired up, since threading `Mode` to those sites to save two literals is not obviously an improvement — though the layout is worth centralizing if it ever grows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
That method was compiled under `given Raise = Function.const(())`, which discards every diagnostic — including the `InternalError`s that `softAssert` and `softTODO` raise, whose whole purpose is to be seen. A compiler bug on this path left no trace at all: injecting a `softAssert(false, …)` into `makeStringPrefixAutomatonSplit` and compiling `pattern Num = Digit ~ (Num | "")` produced no output whatsoever. It now reports, and the suppression of everything else is unchanged, so no golden moves. The suppression itself is still warranted, but not for the reason the comment gave. `unapplyStringPrefix` is generated for *every* pattern definition, string-shaped or not, and recompiles the pattern `unapply` has just compiled, so its user-facing diagnostics are noise twice over: those caused by the pattern were already reported by `unapply`, and those specific to this compilation complain about a method the pattern may never need — the legacy prefix translation eagerly rejects every `@compile`d body, and the automaton path re-derives the same tail-position errors. Un-silencing them outright adds 19 spurious diagnostics across 8 test files. They are now logged before being dropped, so they stay visible when tracing this pass. Suppressing by duplicate detection instead would be better, and is what the first category actually calls for. It does not work while `Raise` is a constructor parameter of `SplitCompiler`: a `given Raise` in a method body only reaches code that re-takes `(using Raise)` explicitly — which both prefix entry points do, and which is why suppression works here at all — while the `unapply` compilation resolves the class parameter instead, so there is no way to observe what it reported without threading a `Raise` through `makeMatchSplit` and everything beneath it. Noted in the comment for whoever takes that on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The doc on `compilePattern` claimed the method "will not be generated" for a pattern that does not represent a string pattern. It always is, and it has to be: pattern parameters are dispatched dynamically, so a use site such as `pattern Rep(pattern P) = P ~ …` selects `P.unapplyStringPrefix` on whatever pattern object is passed for `P`. Were the method absent from a non-string pattern, that selection would be a missing-method error at runtime. For a pattern that cannot match as a string prefix, `makeStringPrefixMatchSplit` returns `RejectPrefixSplit`, so the generated body is simply the always-failing `failure` alternative. Doc only; no behaviour change. Also explains why `prefixMethodRaise` cannot avoid the whole thing by not compiling the method for non-string patterns — the follow-up I floated when fixing the silenced-`Raise` bug, which this shows was not achievable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The exclusive-range fix expanded `5 ..< 5` to `lower until upper`, which is empty, and `Or(Nil)` — the natural result of expanding an empty range into a disjunction of its literals — is the *wildcard* in the instantiated pattern language, not `Never`. So `x is @compile (5 ..< 5)` matched every value, and `"hello" is (5 ..< 5) ~ ""` matched every string. Reversed bounds (`5 ..= 3`) collapsed the same way even before that fix, since `5 to 3` is equally empty; the elaborator validates neither. The string branch received an explicit `hi < lo` guard together with the exclusive fix, but the integer branch did not. An empty integer range now produces `Never` explicitly, mirroring the string branch, and the regression is pinned in `CompiledBugs.mls` next to the range cases it extends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `Guarded` or `Chain` node anywhere in a string sequence used to make `regionSupported` return false, silently sending the whole region to the legacy greedy prefix composition — whose matching relation differs from the automaton's, so a vacuous `where true` could flip a match into a failure. The greedy translation is incomplete and slated for removal, so instead of quietly changing semantics these constructs are now rejected with an error, and the sequence compiles to a never-matching split. `regionSupported` is split into a three-way `regionSupport`: `Rejected` (guards and chains, reported at the `Concatenation` gate of `makeMatchSplit`) versus `Unsupported` (parametric-definition bodies, conjunctions, negations, extraction arguments — these still take the legacy path, unchanged). The combination deliberately does not stop at the first `Unsupported` operand, so whether a guard is diagnosed does not depend on what happens to sit next to it. The two predicate call sites (`isParametricStringSite` and the `unapplyStringPrefix` gate) keep their boolean view; errors raised while generating `unapplyStringPrefix` remain suppressed by `prefixMethodRaise`, so the definition site reports exactly once. `ups/transformation/BindingLess.mls` loses its `ToUppercaseString` example: `Char.AnyChar` carries a guard, and the sequence referencing it only worked because the greedy fallback consumes one character per step there. That is now the documented rejection. (A guard-free `AnyChar` is not expressible today — a bare string range used as a whole pattern compares strings lexicographically, so it cannot enforce the one-character shape.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings in the fix for the inverted encoding of the pattern-lattice units
(`Wildcard` is now `And(Nil)` and `Never` is `Or(Nil)`). The files unique to
this branch match the raw constructors in a few places, which the textual
merge cannot see, so they are retargeted as part of the merge:
- `StringCompiler.build`: the wildcard-in-string-position arm moves from
`Or(Nil)` to `And(Nil)`, and the dead-state arm from `And(Nil)` to
`Or(Nil)` — hoisted above the general `Or(patterns)` arm, which would
otherwise shadow it (into a state with no alternatives, coincidentally
also dead, but the explicit arm should not be unreachable).
- `Compiler.multiMatcherStringBranch`: the no-string-shaped-alternative
check on the simplified fragment tests `Or(Nil)` instead of `And(Nil)`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The explicit `if range.isEmpty then Never` in the integer-range expansion guarded against `Or(Nil)` being the wildcard. With the pattern-lattice units fixed, the empty disjunction *is* `Never`, so the natural expansion already matches nothing and the guard (whose comment described the old encoding) is redundant. The regression tests in `CompiledBugs.mls` stay, with their comment reworded to describe the history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a dated status section: which findings each commit since the review resolved (the three 42a1829 miscompilations, the legacy-path routing, the un-swallowed internal errors, the whitespace and test-organization items), the guard/chain rejection that supersedes M21 — noting we may later investigate the two-pass approach to guards it sketched — and the inverted pattern-lattice-unit encoding found during verification and fixed on the base branch. The regressions table gains a status column; the individual finding sections are left as written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`makeRangeTest` compared the bare scrutinee against the bounds, inheriting
JS comparison semantics: strings compared lexicographically, so
`"abc" is ("a" ..= "z")` matched, and foreign values coerced, so
`[] is (0 ..< 65536)` matched via `[] == 0`. Both contradicted the design:
the bounds validation only admits single-character string bounds, the
elaborator describes ranges as sugar for the disjunction of the literals in
the range, and every compiled path already implements that reading
(`CharClass` matches one code unit, integer ranges expand to literal
disjunctions, the prefix path range-tests the extracted head), producing
divergences like `"abc" is Lower` being true while
`"abc" is @compile Lower` was false.
The comparisons are now guarded by the scrutinee's type, per bound type:
character ranges require a `Str` of length 1, integer ranges an `Int`,
decimal ranges a `Num` (so integers still fall within decimal ranges). The
guards reuse the same class tests as user-written `is Int` etc., keeping
whatever runtime representation those choose. The prefix path shares
`makeRangeTest`, where the extra guard is redundant but harmless: its head
is a one-character string by construction.
This also finally makes `Char.AnyChar` expressible without a guard, as the
full character range `"\u0000" ..= "\uffff"`, matching exactly the strings
of length 1, like the `Str as s where s.length is 1` definition it replaces,
but usable within string patterns (where guards are rejected) and under
`@compile`. `BindingLess.mls`'s `ToUppercaseString` accordingly works again,
now through the automaton, with its results pinned by `:expect`. The new
semantics are pinned in `RangePatterns.mls`, including the plain/compiled
agreement and the `[] is UnsignedShort` coercion case that flipped to false.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`multiMatcherStringBranch` took the recognition-only `matchWhole` path whenever the result mode was match-only, with no test on the region's actions, so a `=>` transform inside an `@compile`d string pattern silently never ran when the match was only used as a condition — while the identical un-annotated pattern ran it (CompiledBugs.mls pinned the divergence). The choice between recognition and parsing is now a single predicate, `Compiled.recognitionSuffices`, consulted by both region call sites so they cannot drift apart; when a match-only region does carry transforms, the multi-matcher emits `parseWhole` and observes only the success of the parse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The transform calling convention now derives both the closure's parameter list and its argument slots from the symbols the definition itself binds (the ones `correspondence` maps). `p.symbols` also contains bindings from substituted pattern arguments, so the parameter construction crashed on an unguarded lookup, and — because the closure is interned once while the argument slots were recomputed per occurrence — two instantiations of one definition could disagree with the closure's arity. The record-based transform in `Compiler.completePattern` had the same unguarded lookup. - `unapply` for a non-parametric, string-only definition now compiles the whole body as ONE region, under the same support condition as `unapplyStringPrefix` plus a new `stringOnlyAlternatives` check (an alternative that can match non-strings must keep the general dispatch, as it would be a dead state inside the `Str`-guarded region). This is not an optimization: a transform wrapping a recursive sequence must sit inside the automaton, where deferred frames capture the slot values of their own activation — outside it, the transform read the global slot store after the parse and saw the innermost activation's bindings (`"123" is Listed` returned `"3,2,3,$"`). - `isParametricStringSite` requires `defn.extractionParams.isEmpty`: providing exactly the pattern arguments means "all extraction parameters omitted", and `unapply` returns the extraction bindings, which the in-place automaton cannot — it returned the whole match instead. - Instantiation no longer flattens nested concatenations into the parent sequence: the output of a sequence is a left fold of JS `+`, which is not associative across mixed operand types, so re-association changed the value a grouped sub-pattern produces — one value through a synonym, another as a pattern argument. Relatedly, `simplify` no longer drops bare empty string literals from sequences: a leading `""` coerces the folded output to a string, and the simplification ran on the `@compile` path only, so the annotation changed the output's type. All fixed cases are pinned in `CompiledBugs.mls`; the `Listed` blocks lose their `:fixme` and the parametric-extraction blocks now agree with the `unapply` route (still `:fixme` for the pre-existing extraction limitation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Diagnostics on instantiated patterns no longer crash on multi-origin
spans: `Pattern.diagnosticLoc` merges child locations when they share an
origin and pins the first piece otherwise, and the conjunction/negation
rejections in `StringCompiler.build` use it. An instantiated junction over
definitions from different source blocks used to trip `AutoLocated`'s
single-origin assertion instead of reporting (pinned in
`NonRegular.mls`). `Instantiator` also pins source locations on the
junction nodes it builds, which keeps the better use-site locations on
the region path (the multi-matcher pipeline rebuilds nodes, so its
diagnostics rely on `diagnosticLoc`).
- Erroneous constructor arguments degrade to `Never`: `Str("x")` (and a
module with arguments) used to report and then match like the bare
pattern — `Str("x") ~ "b"` matched every `…b`. The now-unreachable
`Str`-with-arguments branch of `build` becomes a `softAssert`.
- The final pruning seeds its worklist with the accept state instead of
force-keeping it afterwards, so the kept set is closed under edges by
construction (a force-kept accept with outgoing edges would have been
silently retargeted through zero-initialized renumber slots).
- `specialize(lit)` asserts that a `StrLit` head never reaches a
string-shaped pattern: `buildMultiMatcherBody` absorbs those into the
`Str` head, and a violation of that invariant would silently turn a
matchable pattern into a no-match.
- New `ups/regex/Prefix.mls` pins the `unapplyStringPrefix` protocol
(remainder, failure, leftmost-first commitment over longest-match,
greediness through recursion, and a transform in prefix position), which
had a single assertion with an empty remainder. `Identifier.mls`'s stale
ranges-expand-to-disjunctions comments now describe the automaton
absorption, and `isManyDigits` gains negative cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rejected recursive definition now fails wholly through both entry points (a consequence of compiling string-only bodies as one region), which is the resolution the review's M17 recommended; `NonRegular.mls` pins the surviving-alternative case on both routes, with the per-use-site re-reporting (M18, still open) marked expected. The findings document's status section records the follow-up round — C3, C7, C8, M2/M3, M13/M14, M15, M17, M19, M20, M26 and the minor items — and narrows the still-open list to the genuinely design-laden findings (C2, C5, M4, M5/M6/M22, M11, M18, and the performance items). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ects
`stringOnlyAlternatives` was introduced by reasoning — a definition like
`("a" ~ "b") | Box(1)` contains a string sequence and passes
`regionSupported`, so without the gate its `unapply` would compile as a
`Str`-guarded region in which the `Box(1)` alternative is a dead state —
but no test pinned that boundary. Now both alternatives are asserted to
match through both routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The remote branch carries 26 commits: three upstream merges plus a review pass over the compiled string patterns (see `PR540-review-findings.md`). Three of its changes interact with the local feature commits. `Wildcard` and `Never` swapped representations — `Wildcard` is now the empty `And` and `Never` the empty `Or`, which is what the generic junction code already assumed. `Instantiator.codePointRange` returned an empty `And` for an empty character range, which under the new reading matched *everything*; it now returns an empty `Or`. `regionSupport` and `unsupportedRegionConstruct` restructured the same traversal for different ends, both answering finding M21. They are unified here: guards and chained patterns are `Rejected` with an error, as the review decided, because the legacy composition they used to fall back to matches with a different relation; everything still falling back (extraction arguments, unresolved references) carries the construct's name and location and warns. Conjunctions and negations stay `Supported` — the local commits compile them as constraint products and determinized complements, so they are no longer diverted to the legacy path, and the cases the automaton still cannot express are diagnosed in `StringCompiler`, where the operands' purity is known. Those two diagnostics now use `diagnosticLoc`, the review's fix for junctions that span several source blocks. `recognitionSuffices` replaces the open-coded entry-point choice at both region call sites, and the table-size measurement records the table that is actually embedded — in match-only mode a region carrying transforms now embeds the parse table, not the match table. Tests: `NonRegular.mls` pinned the conjunction rejection that no longer happens; the pure conjunction now compiles (and still matches nothing), and a two-transform conjunction across source blocks takes over as the cross-origin location regression. `FallbackWarning.mls` moves its guard and chain blocks from `:w` to `:e`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This PR support compiling supported string patterns into automata for whole and prefix matching. String patterns are compiled into automata encoded in string literals and the runtime can decode and execute automata. Transformations are done in the second pass after matching.