A bail_noop memo that outlives a softcode/builtin registration can
refuse forever without re-fetch. Invalidate with the #2068 gate epoch
(#2140 review).
Two instruments stop returning numbers for questions they cannot answer.
Neither changes what is measured -- they change what is reported when the
measurement did not happen.
astbench(): jit= timed the decline
------------------------------------------------------------------
jit_eval() returns whether it HANDLED the expression, and fun_astbench
discarded it, timing the call either way. An expression the JIT never
ran still got a time and a ratio to two decimal places:
citer(a b c,1) ast=1.25us jit=4.95us ratio=0.3x before
citer(a b c,1) ast=4.22us jit=declined ratio=n/a after
CLAUDE.md and tests/growth/README.md both carry standing warnings not to
trust this field -- citer() reads a flat 2.7us at every N, which is
absence rendered as speed. A warning in prose is a workaround.
Counting every timed iteration also gives a third outcome the old code
could not express: mixed(N/iters), when the verdict changed mid-run. That
is a finding rather than a nuisance -- neither a time nor a decline
describes such a loop.
benchmark(): the cap clamped silently
------------------------------------------------------------------
This is the only one of the three instruments whose contract puts the
divisor in the CALLER's hands. It returns raw elapsed seconds, so the
caller divides by the count it asked for, and a silent substitution does
not shrink the experiment -- it scales every derived figure by the clamp
ratio. Ask for 200000, get 10000 run, divide by 200000, and every
us/call published is 20x optimistic with nothing saying so.
benchmark(add(1,2), 10000) 0.0148
benchmark(add(1,2), 10001) #-1 ITERATIONS EXCEEDS 10000
benchmark(add(1,2), 200000) #-1 ITERATIONS EXCEEDS 10000
astbench() and rvbench() clamp too and are DELIBERATELY left alone: both
divide by the clamped count internally and report per-call figures, so
their caps cost samples, never correctness. Stated here so the next
reader does not have to wonder why one of three was touched.
No caller in the tree exceeds 10000 (grep over testcases/ and tests/
yields 0, 1, 2, 3, 100), so nothing breaks; anyone who does exceed it
today is silently getting corrupt data.
Tests
------------------------------------------------------------------
There was NO astbench() coverage anywhere in the tree before this --
`grep -rln astbench testcases/ tests/` was empty, for a function three
documents warn people not to trust.
jitstats_fn.mux TC010 jit=declined for a declining shape AND a real
ratio=*x for a handled one, so it cannot pass
by everything declining.
benchmark_fn.mux TC004 the boundary on both sides: at the cap must
still measure, one over must refuse. tr.done
moves to it as the new last case.
Negative controls, by reverting each production change and confirming the
case fails for its reason:
item 1 TC010 Failed (declined=ast=1.25us jit=4.95us ratio=0.3x ...)
item 2 TC004 Failed (atcap=0.018678769 over=0.013143385 wayover=)
The second is the defect in one line: over=0.013143385 is a plausible
time returned for 10001 iterations when 10000 ran.
make test: 36 targets -- 36 passed, 0 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jit_can_handle() decided, on EVERY evaluation, whether the JIT could
compile an expression -- walking the whole AST with a heap-allocated
worklist and, per function-call node, building a std::string, uppercasing
it, copying it into a std::vector<UTF8> and doing two hash lookups. ASTs
are cached and re-evaluated many times, so this made a once-per-parse
question into a once-per-evaluation cost.
Measured on Linux/x86-64 as ~0.06-0.14us per evaluation, paid on
expressions the JIT declines, on plain text it will never touch, and on
expressions where it wins.
THE VERDICT IS NOT A PURE FUNCTION OF THE AST, which is the trap in "the
tree is cached, so cache the answer". It also depends on
mudconf.jit_eval_brackets (a runtime toggle) and on whether each call
node's name resolves in builtin_functions or ufunc_htab (@function,
module registration, the function_alias directive). So the memo carries
a stamp: the toggle in the low bit, an epoch bumped by
jit_gate_note_function_table_change() at the five sites that mutate
either table.
A missed invalidation would fail SILENTLY and in the dangerous direction
-- it picks the wrong evaluator, not a wrong answer, so both routes
return the same string and nothing reports it. That is why the hook is
declared with its reasoning in externs.h rather than an issue number, and
why TC008 asserts eval_attempts rather than values.
Measured, one binary two arms (MUX_NO_GATE_MEMO), min of 5, us/call:
add(add(add(1,2),add(3,4)),add(add(5,6),add(7,8))) 0.164 / 0.741 4.51x
bound(rand(100),10,90) 0.628 / 1.316 2.09x
strcat(abcdef,ghijkl) 0.166 / 0.329 1.98x
this is plain literal text with no function calls 0.388 / 0.596 1.54x
add(rand(100),1) 1.838 / 2.412 1.31x
iter(lnum(10),1) 4.039 / 4.439 1.10x
ladd(lnum(100)) 11.071 /11.066 1.00x
add(1,2) 0.906 / 0.906 1.00x
The two flat rows are the control, not a disappointment: at 15 and 8
bytes they are below AST_CACHE_MIN_LEN (16), so their trees are reparsed
every evaluation and the memo cannot hit. Predicted before the run; the
counters agree independently (20020 misses is 2 probes x 10000). That
also states the limitation -- the gate runs at >= 8 bytes but the AST
cache starts at 16, so 8-15 byte expressions still pay in full. Changing
that threshold is a separate question with its own cache-pressure
tradeoff.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The SQLite code_cache is keyed on blob_hash (s_blob_version), whose tier 1 leg
was a single __DATE__/__TIME__ in jit_compiler.cpp. Its comment stated the
assumption:
"Because the JIT requires a clean rebuild to take effect, this file is
recompiled every such build, so its __DATE__/__TIME__ stamp changes and
folds into the hash, invalidating every previously persisted entry
automatically."
Under incremental make that is false, and false in exactly the case the stamp
was added to defend against. Its own comment names "a codegen change in
another TU (e.g. hir_codegen.cpp) with no version bump" -- and a change in
another TU is precisely when jit_compiler.cpp is NOT recompiled.
Measured before the fix: touching hir_lower.cpp rebuilt hir_lower.eo and left
jit_compiler.eo untouched, so blob_hash stayed byte-identical at ad598f6b...
and every previously persisted entry still matched. The cache then served the
previous build's compiled output -- which contaminated a #2052 retest, where
TINYMUX_DUMP_HIR counted zero compilations while the old behaviour still ran.
On a live game it means attributes keep running the old compiler's output after
an upgrade until something evicts them.
Each tier 1 unit now carries its own stamp and all of them fold into the hash,
so the key moves when any of them is recompiled. Folded into BOTH
s_blob_version computations; the no-blob fallback had the identical hole and is
the leg a blob-less build runs on.
The set is deliberately explicit rather than a glob, because the invalidation
model is three-legged and only two legs belong:
softlib.rv64 MUST invalidate -- cached RV64 calls into it via a PC-relative
JAL to a resolved blob address (hir_codegen.cpp:2262), so
moving a blob function makes cached code jump into the middle
of something else. Already covered.
tier 1 JIT MUST invalidate -- it emits different RV64 for the same
softcode. This commit.
the DBT MUST NOT invalidate -- it only executes the stored RV64 and is
not baked into it (the cache holds guest RV64, never host
code). Invalidating for a dbt_*.cpp change would discard the
cache for no gain.
Verified by rebuilding after touching each unit in turn and reading blob_hash
from a fresh database. All six tier 1 units move the key; dbt.cpp and
dbt_interp.cpp leave it unchanged -- the negative control matters, since a fix
that simply invalidated on everything would pass the positive half and be
wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`think TAG [astbench(add(1,2),50)]` printed the report without TAG.
fun_astbench warms the compile cache with a throwaway jit_eval and then
discards what that emitted -- but it rewound to `buff`, the BASE of the
caller's buffer, rather than to the point where astbench started writing.
Everything already in the buffer went with it.
Save the entry cursor and rewind to that instead.
Low impact: nothing in the server composes output around astbench, which is
why this survived. It is a live tripwire for anyone writing a harness,
though, because the natural thing to do is tag each measurement with the case
it belongs to, and the tag vanishes while the report still looks perfectly
well-formed. tests/growth works around it by emitting tags as suffixes; that
workaround can come out now.
fun_rvbench does not have the problem.
astbench_fn.mux gains TC004, and it was validated by reintroducing the bug:
with `*bufc = buff` restored the case reports
TC004: astbench preserves preceding output. Failed (ast=0.10us ...)
i.e. red, with the missing prefix visible in the failure text.
Found while measuring #2052.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ast_eval_subst hand-rolled the retired v4 three-codepoint PUA delta
scheme (0xF0x00+channel) for non-palette-exact RGB. The live decoder is
the v5 two-codepoint CP1/CP2 layout, so %x<#123456> rendered as the wrong
truecolor channels while ansi(<#123456>,...) was correct.
Route extended %x/%X through LettersToBinary (same ColorTransitionBinary /
EmitSMPColor path as ansi()). Align the JIT lowerer the same way so
compiled softcode no longer falls back to nearest-256 only.
Smoke: testcases/px_truecolor_fn.mux compares percent forms to ansi()
(with trailing %xn to match TruncateToBuffer NOBLEED).
BAN_LEGACY falls 41 → 23. Convert every ratchet site that can reach
mux_sprintf/mux_snprintf:
nls path assembly, AST bench floats, attrcache sizes, mail folders,
named references, softcode %f (fixed-precision table; no %.*f),
exp3 safe_ltoa, ganl port, websocket 101 handshake.
Left frozen: dbt_test.cpp (22) and dbt_x64_div_harness.c (1) — standalone
test binaries that do not link libmux. Neither is player-facing text.
fun_astbench and fun_rvbench both parsed the iteration count with
mux_atoi64 straight into an `int`, then clamped:
int iterations = mux_atoi64(fargs[1]);
if (nLen == 0 || iterations < 1) return;
if (iterations > 100000) iterations = 100000;
The narrowing happens first, so a value above 2^31 wraps before the cap is
ever consulted. The cap advertises "you get at most 100000"; what a large
request actually got was whatever the low 32 bits said.
Measured on macOS arm64 -- note this truncates on LP64 too, so it is not one
of #1402's Windows-only items:
astbench(add(1,2), 100) ast=0.18us jit=0.03us ratio=6.0x result=3
astbench(add(1,2), 4294967296) (no output at all)
astbench(add(1,2), 4294967297) ast=0.00us jit=0.00us ratio=0.0x
2^32 truncates to 0 and hits `iterations < 1`, so the function returns
silently having done nothing. 2^32+1 truncates to exactly 1 and benchmarks a
single iteration, reporting 0.00us -- a plausible-looking answer to a question
nobody asked. rvbench has the same shape with its own floor, where 0 is
bumped back up to 1.
After, the same three calls:
100 ast=0.22us jit=0.05us ratio=4.4x result=3
4294967296 ast=0.10us jit=0.03us ratio=3.2x result=#-1 FUNCTION INVOCATION LIMIT EXCEEDED
4294967297 ast=0.08us jit=0.03us ratio=2.8x result=#-1 FUNCTION INVOCATION LIMIT EXCEEDED
and an over-cap request is now indistinguishable from asking for the cap:
astbench(add(1,2), 100000) ...result=#-1 FUNCTION INVOCATION LIMIT EXCEEDED
astbench(add(1,2), 4294967296) ...result=#-1 FUNCTION INVOCATION LIMIT EXCEEDED
which is the property that was missing. (The invocation-limit result is
correct for 100000 iterations of an evaluated expression; the point is that
both requests now reach it.)
This is #1402's Tier B done properly rather than left as-is: the clamp was
already there and already the right policy, it was just applied on the wrong
side of the narrowing.
Suite 1560 passed / 0 failed / 320 of 320; test-format 31744 assertions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mechanical hygiene under the opt-in M_() design: replace T("#-1…") and
T("#-2…") with S_() so softcode ABI tokens are obvious in source and
cannot enter a player catalog. ~400 call sites across engine, exp3,
mail, and driver. Assembled/library-spliced diagnostics (plan §4.2)
are unchanged where they are not a single literal.
My earlier sweep for unsupported conversions reported the tree clean. It was
wrong twice, and both errors are the reason this is a checked-in guard rather
than something someone re-greps.
First, it covered four wrappers. Eight route to mux_vsnprintf: raw_broadcast,
CLogFile::tinyprintf, log_printf and cf_log_syntax also do. The real surface
is 1170 call sites, not 986.
Second, and worse, the check accepted flags mux_vsnprintf does not implement.
The parser handles '-' and '0' and nothing else, but my regex consumed '+' as
a flag and then saw a supported "%d" -- so it waved through every one of the
seven forms #1429 is about. The check has to reject the FLAG, not just the
conversion character. '*' widths are not implemented either; the parser reads
literal digits only.
With that fixed the guard found a live instance:
ast.cpp:658 Log.tinyprintf(T("%*s... (truncated)"), indent, "")
ast.cpp:664 Log.tinyprintf(T("%*s%s"), indent, "", ast_node_name(...))
Verified against unmodified master:
fmt=%*s%s ... stringutil.cpp(6701): Assertion failed. exit 134
ast_dump has no caller but itself today, which is the only reason this never
fired -- but it is declared in ast.h as a debug helper and is meant to be
callable. Anyone wiring up AST dump logging would have aborted the server on
the first node. Fixed by building the indent by hand, bounded.
The guard checks two properties over all eight wrappers:
1. No literal format uses a conversion, flag, or width mux_vsnprintf does
not implement.
2. No format argument is a runtime value. A non-constant format cannot be
checked by (1), and would be a format-string bug if it ever became
config- or user-influenced.
Four call sites hold their format in a variable or table and are allowlisted
with the reason recorded; each was verified by reading every assignment.
Ternaries between two literals are accepted, since selecting between two
checked formats is as safe as either.
Why now: since #1429 an unsupported spec is echoed and the remainder of the
message dropped rather than aborting. That is right for production and worse
for noticing, so the noise moves to build time.
Verified the guard catches what it is for by injecting %+d, %#x, %hd, %a, %*d
and a runtime format -- 6 findings, 0 after reverting.
guard 1170 call sites clean; tests/format 31728 passed; smoke 1496 passed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(astbench): format floats with snprintf, not mux_vsnprintf (#1382)
mux_vsnprintf only knows integer and string conversions. astbench's
"%.2f" format fell through to mux_assert(0) and aborted the process under
muxscript. Format the floating-point fields with libc snprintf first
(same pattern rvbench already uses), then hand the strings to
safe_tprintf as %s.
* test(astbench): cover the builtin that had no caller (#1382)
astbench() had zero references anywhere in the corpus, which is the whole
reason #1382 survived: every call to it aborted the process, and nothing
called it. A builtin that kills the server on each invocation was
invisible because it was never invoked.
The cases assert the property that was actually violated -- that a call
returns at all, and returns the documented shape -- rather than the
benchmark numbers, which are timing- and host-dependent. TC002 requires
a real number in the ast and ratio fields so that a "just drop the %f"
repair returning "ast=us ratio=x" would not pass on TC001's wildcards.
The magnitudes are deliberately not pinned: jit= is legitimately 0.00 on
a build without TINYMUX_JIT.
Verified in both directions on the same tree:
with the fix TC001-TC003 pass, 1448 passed, 308/308 dispatched
without the fix Abort trap: 6, Crashes: 1, 17/308 dispatched
The second line is what the corpus could not previously see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2.13 emits the #-1 FUNCTION (...) NOT FOUND diagnostic and stops
evaluating the rest of the region (mux/src/eval.cpp:1497 -- `*bufc =
oldp; break;`). 2.14 emitted it and carried on, appending the remainder
after the error:
[qqnofn(1) add(1,2) tail]
2.13 #-1 FUNCTION (QQNOFN) NOT FOUND
2.14 #-1 FUNCTION (QQNOFN) NOT FOUND add(1,2) tail
The issue framed this as continue-vs-abort and predicted 2.14 would
evaluate the tail to "3". Measuring all three engines against a real
2.13.0.15 showed otherwise: 2.14 emits the tail as LITERAL text, because
a call already spent the region's one recognition opportunity.
FM_OK_TWICE shows the same rule after a SUCCESSFUL call, and there all
three engines agree.
So the difference was only whether the literal tail is emitted or
dropped -- no evaluation was ever at stake. Dropping it costs nothing
and stops the diagnostic from reading as though part of the region had
produced output. Adjudicated 2.13 on that basis.
Both routes. The AST route signals the failure from the funccall node
up to its enclosing sequence, since 2.14 evaluates a tree rather than
running 2.13's single loop; the compiled route already resolves the
unknown name at lowering time, so its sequence simply stops
concatenating. The signal is scoped exactly like the FCHECK
opportunity -- saved and cleared at an eval-bracket boundary, consumed
by the nearest enclosing EV_FMAND sequence -- so an inner region cannot
truncate its parent and a failure inside a u()'d attribute cannot
escape into its caller.
Deliberately NOT the accumulated-name model. 2.13 also rewinds the
buffer, folding text before the '(' into the reported name
([hello qqnofn(1)] -> "HELLO QQNOFN"); #1246 settled against that and
FM_FAIL_NAME keeps 2.14's answer. Only the stop is adopted, so this
does not reopen#1246.
corpus.txt: FM_FAIL_ABORT/ABORT_TAIL/TEXT/TWICE adjudicated 2.13, plus
four containment shapes at `both`. FM_ABORT_INNER puts the failing call
FIRST in the inner region on purpose -- written with text before it the
call is literal on 2.14 and the shape tests nothing.
testcases/fmand_abort_fn.mux: TC001 and TC003 detect (both fail with the
engine change reverted); TC002 and TC004 are controls that pass with and
without it, so an over-aggressive abort cannot satisfy them. An earlier
draft asserted an aborting shape inside TC002, which made it fail in
both states -- a control that only holds in one state is not a control.
Verified:
parity213 vs 2.13.0.15: satisfied 35 -> 43, VIOLATED unchanged at 5
(same pre-existing set), unadjudicated 7 -> 3
smoke 1439/1439 on BOTH routes, 0 crashes, 307/307 dispatched
bug-catch: 2 of the 4 new cases fail with the change reverted
Closes#1247.
Completes the sweep the issue called for. mux_atol returns long, which
is 32-bit on LLP64, so every caller silently truncated on Windows. Two
of those were real defects (the truthiness family and cf_size, fixed in
the preceding commits); the rest were latent, waiting for a value large
enough to matter.
Rather than audit 290 sites for whether each can reach 2^31 today, use
the 64-bit parser everywhere and remove the class. A dbref cannot
overflow now, but nothing stops a later caller passing that same site a
timestamp or a byte count.
Pure 1:1 substitution: 285 lines changed, and every removed line
contained mux_atol while every added line contains mux_atoi64. No
control flow, no types, no behaviour beyond the wider parse.
This is a NO-OP on LP64 -- long is already 64-bit on Linux and macOS, so
the generated code there is unchanged. It only widens the parse on
Windows. Narrowing destinations are unaffected either way: `int x =
mux_atoi64(s)` truncates exactly as `int x = mux_atol(s)` did, on both
models.
Left alone: mux_atol itself in mathutil, its declaration, and three
comments that name it. Callers that genuinely want 32-bit semantics can
still ask for them; none appear to.
Verified on Windows: full solution builds clean with no new warnings,
smoke is 1418 passed / 16 failed / 0 crashes / 306 of 306 dispatched --
identical to before the sweep, with the same 16 build-configuration
failures (exp3 module not loaded, hmac/digest behind UNIX_DIGEST).
Spot checks after the change: the boolean family returns 1 for multiples
of 2^32, cf_size round-trips 3000000000 and still reads -1 as unlimited,
and arithmetic, string and list functions are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mux_atol() returns long: 64-bit on LP64, 32-bit on Windows. Thirteen
sites evaluate truthiness as isTRUE(mux_atol(...)), so on Windows any
value whose low 32 bits are zero read as false.
xor(4294967296) 0 should be 1
lxor(4294967296) 0 should be 1
and(4294967296) 0 should be 1
or(4294967296) 0 should be 1
t(4294967296) 1 correct -- goes through xlate()
The last line is the tell: on Windows the server contradicted itself,
with t() calling a value true while and() called the same value false.
correctness_fn.mux TC001 and the comment in fun_xor both already say the
intent is "the full 64-bit value, not a 32-bit-truncated copy". The fix
simply never worked on LLP64, because long is the wrong type to say it
in. Two of the thirteen are in ast.cpp, the evaluator's own boolean
handling, so this was not confined to a few list functions.
mux_atoi64() already exists beside mux_atol() and returns int64_t. This
is a no-op on LP64 -- long is already 64-bit there -- and corrects LLP64.
isTRUE(x) is ((x) != 0), so nothing else changes.
Deliberately NOT switched to xlate(), even though that is the single
definition of a softcode boolean: doing so would change behaviour on
Linux too, since xlate treats #- errors and non-numeric text differently
from isTRUE(atol). This is a portability defect, not a semantics
decision.
Found by the first smoke run ever performed on Windows (#1347). Full
smoke there went from 1415 passed / 19 failed to 1418 / 16.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four remaining shapes where the two 2.14 routes disagreed all came
from one cause: 2.13's candidate function name is the accumulated
OUTPUT, so text preceding a call is folded into the name. 2.14
tokenizes and cannot express that.
Adjudicated in #1246 as the `2.14` verdict — the compiled route is the
reference, literal text is the better answer, and 2.13's span-name error
is not the target:
[[add(1,2)] mul(3,4)] -> 3 mul(3,4)
[zz mul(2,3)] -> zz mul(2,3)
[x add(1,2) y] -> x add(1,2) y
[x add(1,2)] -> x add(1,2)
This is a deletion rather than an addition. #1238 had introduced an
EV_FMAND special case in ast_eval_sequence_children so that text before
a call would not spend the recognition opportunity, specifically to
avoid settling these shapes while they were unadjudicated. Now that
they are settled the other way, that distinction was the thing producing
the wrong answer. Removing it restores the uniform rule the non-FMAND
path already used — any non-space child spends the opportunity — and the
helper returns to its #1239 shape.
#1238's own cases are preserved because a dispatched call is a non-space
child either way:
[add(1,2) mul(3,4)] -> 3 mul(3,4) (disarm_fn TC004)
[add(1,2) zz mul(3,4)] -> 3 zz mul(3,4) (disarm_fn TC004)
Corpus verdicts for all four set to 2.14, converting them from
unadjudicated divergences into harness-enforced agreement.
Verified on both routes: make test 1423/0 and
SMOKE_EXTRA_CONF="jit_eval_brackets 0" ./tools/Smoke 1423/0.
tests/parity213 now reports JIT vs AST: 128 agree, 0 differ — the two
2.14 routes are in full internal agreement. satisfied 24 -> 28.
Remaining VIOLATED are MX_BP and MX_ALL, pre-existing, both routes
agreeing with each other and belonging to the pinned name-space-paren
family.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2.13 clears EV_FCHECK after a dispatched call unconditionally
(mux/src/eval.cpp:1677), so a second call in the same region emits as
literal text whether or not EV_FMAND is set:
[add(1,2) mul(3,4)] -> 3 mul(3,4) not 3 12
[add(1,2) zz mul(3,4)] -> 3 zz mul(3,4)
2.14 gated disarm on EV_FMAND == 0, so it applied inside function
arguments (#1214) but not at the top of an eval bracket, where FMAND is
set. The compiled route already behaved like 2.13 here; only the AST
route over-evaluated.
What spends the recognition opportunity differs by region kind, and the
difference is not cosmetic. Without FMAND, 2.13 checks only the first
'(' it reaches, so any preceding text spends it. With FMAND the
candidate name is the accumulated OUTPUT rather than a token, so leading
text is folded into the name instead:
[x add(1,2) y] -> #-1 FUNCTION (X ADD) NOT FOUND
Only a call spends it there. ast_eval_sequence_children now models both
rules, leaving text before a call alone.
Deliberately unchanged: that span-name behaviour, and the FMAND abort on
a failed lookup ([nosuchfn(1) add(1,2)] yields just the error on 2.13,
discarding the rest, where 2.14 continues). Both need the accumulated-
output model 2.14's tokenizer cannot express, and both are separate from
disarm. Added as FM_LIT_CALL / FM_LIT_CALL2 to the corpus, deliberately
unadjudicated — the two 2.14 routes do not agree with each other there
and neither matches 2.13, so there is no settled value to pin.
Measured with tests/parity213 (three engines, live servers):
unadjudicated divergences 8 -> 4, nothing broken. UF_DISARM's AST leg
now reads '5 ufadd(4,5)', matching 2.13 exactly; it stays VIOLATED only
because this branch sits below #1231, so its JIT leg cannot yet resolve
globals.
disarm_fn.mux TC004 covers the bracket-level shapes. An earlier draft
also asserted [x add(1,2) y] -> 'x 3 y'; that was wrong and the JIT-route
run caught it, since it encoded one route's answer as truth when neither
route matches 2.13. The corpus is the right home for that shape.
Control-tested on the AST route: with the fix reverted TC004 reports
"fm=3 12 fm2=3 zz 12". The default JIT-route run passes either way —
that route was already correct — so the guard is:
SMOKE_EXTRA_CONF="jit_eval_brackets 0" ./tools/Smoke
Smoke 1416/0 on both routes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EV_FCHECK without EV_FMAND means "the first '(' in this region may be a
function call". 2.13 clears EV_FCHECK once that opportunity is used
(mux/src/eval.cpp:1677), so a later call in the same region emits as
literal text:
[strcat(x add(1,2) y)] -> x add(1,2) y not x 3 y
ast_eval_node's AST_SEQUENCE case implemented this correctly. But
ast_eval_argument has a space-compressed fast path that iterates a
SEQUENCE argument's children itself, calling ast_eval_node per child
with eval unchanged — bypassing the AST_SEQUENCE case entirely. The
rule was therefore dead for exactly the case that matters, a call
sitting mid-argument, while looking correct on inspection.
Extract the loop both paths need into ast_eval_sequence_children() and
call it from both, so the two cannot drift again. This is the same
shape as #1157 and #1159: a correct guard that one path silently skips.
Only the AST route was affected; the compiled route lowers sequences
through hir_lower, which already tracked s_fcheck_available.
Measured with tests/parity213 (three engines, live servers, 126
shapes): unadjudicated divergences 28 -> 8, nothing broken. The AST
route now matches 2.13 exactly on POS_MID, POS_END, POS_MID_COLON,
POS_ARG2_MID, the ESC_* and PCT_* families, DOUBLE_CALL, DOUBLE_MID,
NESTED_MID, DEEP_MID, U_ARG_MID, EMPTY_ARGS, ONE_ARG, UF_MIDTEXT,
DA_NESTED_ARG and DA_DEEP.
testcases/disarm_fn.mux pins the rule in both directions — a later call
stays literal, the first call still dispatches, and a bracket re-arms.
The values come from a tr.tc000 helper so each expression is a real
top-level bracket region; written inline inside cand() the expression
sits in an argument whose FCHECK opportunity is already spent, and an
earlier draft passed with the fix reverted for exactly that reason.
Control-tested on the AST route: with the fix reverted TC001 reports
"mid=x 3 y end=head 3" and TC003 "mixed=x 3 7"; with it, both pass.
Note the default JIT-route smoke run cannot discriminate here, since
that route was already correct — the dual-toggle run is what guards it:
SMOKE_EXTRA_CONF="jit_eval_brackets 0" ./tools/Smoke
Smoke 1415/0 on both routes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parseSequence ended a function argument at the next ')' or ',' whether or
not it matched, because a bare '(' in argument text becomes a standalone
literal token with no nesting effect. Bracket and brace depth were
already tracked (m_bracketDepth, m_braceDepth); parens were the one that
was missed.
The result was that any parenthesised text passed to a function had its
closing paren migrate to the end and any embedded comma eaten by argument
splitting:
[strcat(Meet me (Tue, 5pm) downtown)] Meet me (Tue5pm downtown)
[strcat(Hello (world) ok)] Hello (world ok)
[ansi(r,Warning (see help, page 2))] Warning (see help)
[ljust(Hello (world) ok,30)] #-1 FUNCTION (LJUST) EXPECTS 2 OR 3 ARGUMENTS
Note the second: no comma is involved, so this was never merely
comma-splitting — the paren depth itself was untracked.
2.13 handled all of these. parse_to_lite (mux/src/eval.cpp:468) keeps one
stack of expected closers: '[' pushes ']', '(' pushes ')', and a closer
found on the stack unwinds to it instead of ending the argument. Only a
closer NOT on the stack terminates.
This adds the paren half of that as a depth counter in parseSequence.
Only a bare '(' increments it — a '(' following a name is consumed by
parseFuncCall, which recurses and accounts for its own parens.
A counter rather than 2.13's stack, deliberately: the stack also handled
crossed nesting like '( [ ) ]' by unwinding past intervening entries. No
corpus shape currently distinguishes the two, so the simpler structure is
used until one does; tests/parity213 will name it if that changes.
Measured with tests/parity213 against a built 2.13:
2.13 vs JIT 27 differ -> 4 differ (23 shapes fixed, 0 broken)
JIT vs AST 19 differ -> 18 differ
The 4 remaining are not paren nesting: MX_BP/MX_ALL are 2.13 treating
`b (` as a call, and SEMI_PREFIX/OPENPAREN_PREFIX are separate shapes.
Those belong to the func+space+paren question, still open.
smoke: 1411 succeeded / 0 failed.
Three previously-masked tests now pass genuinely rather than vacuously
(right/member/extract over-max arity), which is why #1226 had to land
first: their assertions compare against error strings containing a
parenthesised function name, so #1219 truncated the expected text, the
leaked tail closed the enclosing cand() early, and the failing condition
was dropped from the assertion list. The count moved 1410 -> 1411, one
more than the three identified, so the truncation was swallowing at least
one further assertion that had been passing vacuously.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TRACE flag (and per-attribute trace) produced no output: the emitter
tcache_add/tcache_finish was carried into the AST/JIT evaluator but its caller
was never reconnected, so setting TRACE silently did nothing. Confirmed
regression -- it works in origin/release/2.13.
2.13 drove the trace inline from the tree-walking exec() recursion: compute
is_trace, capture the input substring on entry, tcache_add(input, result) on
exit, tcache_finish() at the top. That exact shape does not exist here because
evaluation moved to an AST interpreter plus a JIT/DBT compile path. This
reconnects the emitter against the surviving interpreter rather than porting
2.13's code:
* mux_exec computes is_trace = (Trace(executor) || (eval & EV_TRACE)) &&
!(eval & EV_NOTRACE). When set, it forces the AST interpreter (the JIT has
no per-node frames for the hook to observe) and brackets the outermost
evaluation so accumulated lines flush to the owner.
* ast_eval_node records (source -> result) for the call boundaries --
AST_FUNCCALL and AST_EVALBRACKET -- using ast_raw_text() for the source and
the span it just wrote for the result. The change-filter in tcache_add
drops no-op nodes. Off-path cost is a single bit test, since EV_TRACE is
only set while tracing.
Both the object TRACE flag and the per-attribute trace flag (AttrTrace ->
EV_TRACE) drive it, and EV_TRACE propagates through argument evaluation so
nested calls trace. The tcache emitter (owns orig, applies the change-filter,
respects trace_limit) is reused unchanged; it is exposed from eval.cpp via
externs.h.
Both config knobs restored to 2.13 parity:
* trace_topdown (default true): accumulate and flush outermost-first at the
top; false flushes each line as its subexpression completes (innermost
first).
* trace_output_limit (trace_limit, default 200): caps stored lines top-down;
a "N lines of trace output discarded." notice reports the overflow.
Verified (fresh DB per case): no flag -> result only, zero trace lines, JIT
still used; object TRACE and per-attribute trace each -> nested
'expr -> result' lines; top-down outermost-first, bottom-up innermost-first;
trace_output_limit 2 -> two lines plus "3 lines of trace output discarded."
Regression: smoke 1319/1319, JIT q-register oracle AST=JIT agree (the
trace-forced interpreter path matches the JIT), stress 8/8, netaddr 57/57,
ganl 14/14.
Not included: bare %-substitution granularity (2.13 also traced e.g. '%0'->'7'
as its own line). Call-boundary tracing -- the function nesting a builder
actually debugs -- is restored; per-substitution lines can be added later if
wanted.
Closes#1023.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The guard-lift campaign's final change (docs/plan-jit-evalbracket-lift
.md): eval brackets are JIT-compiled by default. Per the #1001 review
checklist, the same commit removes the netmux.conf soak opt-in (that
file ships) and retires jiteval() — with the default on, the
production route reaches everything the gate-bypass existed for.
Harnesses moved off default-off assumptions:
- jit_diff's I-side conf sets jit_eval_brackets 0 explicitly (relying
on the default would compare JIT against JIT).
- The q-register oracle now compares PRODUCTION evaluation across two
workspaces (default conf = JIT vs explicit 0 = AST) with u()
carriers and a jitstats canary; all nine shapes green.
The flip surfaced three latent items — Makesmoke bakes the smoke
database by EXECUTING setup commands under the conf default, a third
evaluation context never before run toggle-on:
1. #$ (switch token) was unimplemented in the lowerer and fell
through as literal text: @switch actions like [idiv(#$,2)]
computed idiv("#$",2) = 0. The lowerer now bails compilation on
#$, preserving AST semantics.
2. fdepth()/fcount() read func_nest_lev/func_invk_ctr, which
compiled code does not maintain (native lowering flattens the
nest): they read 0. The lowerer bails on both. The wider
function_recursion_limit design question (cost guard vs semantic
contract) is filed as #1002 — not a blocker, as the divergence
direction is fail-open into more capability.
3. The Smoke classifier greped case-sensitively for 'Failed';
nested_depth.mux's lowercase "failed" message slipped through and
the suite reported ALL PASSED around a real failure. Now -ci.
Final matrix: smoke 1318/1318 with the new default AND with the
toggle explicitly off; the smoke.flat re-bake is byte-identical to
the pre-flip bake; oracle 9/9 on the production route; sweeps
standard and brackets+utf8+longreg(SEED=7) both 400/0 LOGIC.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The guard-lift itself (docs/plan-jit-evalbracket-lift.md, Phase 4):
jit_can_handle() now admits terminated [...] eval brackets when the new
jit_eval_brackets config directive is on (cf_bool, CA_GOD, default
OFF — production behavior unchanged). Unterminated brackets always
bail; EV_NOFCHECK text is gated at the call site (literal passthrough
that the lowerer doesn't model).
Harness upgrades required to make the toggle-on green check
meaningful:
- jit_diff J/I sides split into separate processes: the I side always
runs with the toggle off, keeping the eval-bracket bail (the true
production interpreter route, production flags) as the oracle. An
asteval({...})-forced in-process I side was tried first and
manufactured ~113 false LOGIC divergences — fun_asteval trims a
trailing space after an empty-yielding bracket that production
preserves (filed as #987).
- JITDIFF_BRACKETS=1 mode: bracket-wrapped corpus (embedded/adjacent/
pure shapes), toggle in the J-side conf only, and a canary that
fails fast if the toggle didn't take.
- SMOKE_EXTRA_CONF passthrough in the smoke runner for whole-suite
toggle-on runs.
Results: with the toggle ON, letq_fn TC002 (the historical INNERINNER
failure) and the localize scoping cases route through the JIT and
pass; the bracket sweep is 400/0 LOGIC. The toggle-on smoke run also
witnessed two PRE-EXISTING production JIT bugs (bracket-independent,
reproduced bracket-free on today's default route): #988 maxArgsParsed
comma-catenation dropped (sha1(abc,def) -> sha1("abc")) and #989
tier2 wordpos() UTF-8 position miscount. Both block the Phase 5
default-on flip, not this landing.
Toggle OFF (default): oracle 7/7, smoke 1310/1310, standard sweep
400/0 LOGIC — byte-identical, cleanly gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the observation infrastructure for lifting the AST_EVALBRACKET
guard (docs/plan-jit-evalbracket-lift.md), with no production behavior
change:
- docs/plan-jit-evalbracket-lift.md: the phased plan, updated with
empirical Phase 0 findings — all three q-register divergence shapes
reproduced deterministically (D1-letq INNERINNER, D1-localize CCC,
D2-ecall-u stale slot with ecalls=3).
- jiteval(<expr>): wizard-only debug fn forcing an expression through
jit_eval(), bypassing jit_can_handle(). Makes guarded divergences
observable in-server via jiteval(v(attr)) vs asteval(v(attr)).
- jitstats qreg_resyncs counter: zero until the Phase 2 resync lands;
lets tests assert the new path actually fires.
- testcases/tools/jit_qreg/oracle.sh: opt-in oracle driving the three
shapes; expected red (exit 1) until Phases 2-3, then guards them.
Baseline jit_diff sweeps recorded (400 exprs x 3 seeds): 0 LOGIC on
default/SEED=7; SEED=13 surfaced one pre-existing tier2 divergence in
after() with ANSI args, filed as #980.
Smoke: 1309/1309.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AST fast-path variants bound mudstate.switch_token to the match
target before the pattern loop, so #$ inside a pattern expanded to the
target during matching -- switch(foo,#$,MATCHED,NOMATCH) returned
MATCHED via the AST path but NOMATCH via the interpreted path. Bind
the token only around matched-result and default evaluation, mirroring
switch_handler/switchall_handler.
Fixes#857
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The persistent (SQLite) compiled-code cache keyed staleness on the
hand-maintained tier-1 compiler version string plus the Tier-2 blob hash,
but not on the tier-1 codegen source. A codegen change without a manual
JIT_COMPILER_VERSION bump left old compiled programs matching the unchanged
blob_version and being served from cache. A stale constant-folded entry
that evaluated to empty made a $-command's @force'd command line
("+jobs/select monitor") evaluate to nothing, so the command silently
failed with "Huh?" until the cache was cleared by hand.
- jit_compiler.cpp: bump JIT_COMPILER_VERSION to jit-t1-003 and fold a
build stamp (__DATE__ " " __TIME__) into both s_blob_version hash sites.
The JIT requires a clean rebuild to take effect, so the stamp changes on
every build and auto-invalidates previously persisted entries, backstopping
the fragile manual version bump.
- ast.cpp: jit_can_handle() now also bails when the AST contains no function
call. Plain command text parses as a literal/space sequence whose root is
not a single literal, so it slipped past the existing single-node guard and
got compiled and cached for no benefit. Plain command lines no longer
touch the JIT cache.
All 1255 smoke tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AST evaluator (ast_eval_node) has a deliberate C-stack recursion cap
(AST_EVAL_MAX_DEPTH=400) to keep adversarial deep ASTs from overflowing the
stack, noting platforms with smaller stack defaults. The AST parser
(parseSequence/parseEvalBracket/parseBraceGroup/parseFunctionCall, mutually
recursive on []/{}/() nesting) had no such cap — bounded only by the LBUF input
length (~16380 max nesting) and small frames. On the 8 MiB main-thread stack
that is empirically safe (verified: 16380-deep brackets parse without crashing),
so this is defense-in-depth, not a reachable crash; but ~16380 frames is several
MiB and would overflow a smaller stack.
Mirror the evaluator's guard: a thread_local depth counter + RAII
AstParseDepthGuard at the top of parseSequence (every nesting level re-enters
there), capped at AST_PARSE_MAX_DEPTH=1000. Over the cap parseSequence returns
its empty node without recursing; the enclosing parseEvalBracket still consumed
its opening token so m_pos advances and parsing terminates. thread_local so it
also bounds the structural-arg re-parse. Real softcode nests a few levels.
muxscript: add(1,2)=3, [add(mul(2,3),4)]=10, [[[[5]]]]=5 unchanged; 16380-deep
survives. Smoke 1255/1255.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
do_ufun (fun_u) short-circuits when the attribute has AF_NOEVAL or the
target object is set NOEVAL, returning the attribute text literally.
ast_noeval_ulambda was missing exactly that branch, so
ulambda(obj/attr) evaluated a no_eval attribute that u(obj/attr)
returned literally -- the one piece of do_ufun the ulambda route
didn't mirror after the #718 unification.
Verified: u()/ulambda() now agree for a no_eval attribute, a NOEVAL
object, and a plain attribute, on both the AST and JIT routes;
regression test lambda_fn.mux TC012.
Closes#786.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ast_noeval_ulambda() evaluated the lambda body via ast_exec, hard-coding
the AST interpreter and bypassing the JIT — a deliberate workaround for a
blob-internal float intrinsic bug that made dynamically-provided cargs
unsafe through the JIT. That bug is fixed (#778), so flip the body
evaluation to mux_exec, mirroring do_ufun (fun_u).
The cargs we pass (&fargs[1]) populate %0-%9 correctly: jit_eval ->
run_cached_program copies them to CARGS_BASE. Verified that a direct
ulambda() call now JIT-compiles its body (jitstats: eval_attempts 0->1,
compile_ok=1, tier2=1) instead of always running the AST evaluator, while
producing identical results across int, float (sub/fdiv/sqrt), nested,
#apply, and #lambda forms.
Regression test: lambda_fn.mux TC011 semantically checks ulambda int/float
math (including the tier2-blob float paths from #778) so a regression in
either the route flip or the blob float intrinsics surfaces as a wrong
value rather than an opaque hash.
Full smoke suite passes (1102 tests, 0 failures) under --enable-jit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fourth wave: ast (11), funceval (10), funceval2 (7), comsys (3),
speech (10), set (6), engine (7), command (10). Covers the NOEVAL
handlers (cand/cor/if/switch/iter), function evaluators (ifelse,
letq, objeval, sortby, munge, while, sandbox), notify_check message
buffers, and command dispatch paths. Buffers stored in fargs[]
arrays, returned through output pointers, or used in ping-pong swap
patterns are left manual.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ast_eval_node() and ast_dump() are tree-recursive but the existing
soft limits (mudconf.func_nest_lim, nStackLimit) only fire on
re-entry into mux_exec or the bracket counter — they don't cover a
deeply nested tree walked within a single mux_exec call, so an
adversarial expression like `[[[[...x]]]]` with thousands of layers
could blow the native C stack.
Add a thread_local AST_EVAL_MAX_DEPTH guard (400 levels) via a small
RAII class at the top of ast_eval_node(); overflow sets
mudstate.bStackLimitReached and bails. Also bound ast_dump() by its
`indent` parameter so diagnostic logging truncates rather than
recursing unbounded on the same pathological input.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The JIT compiled expressions containing FUNCCALL nodes whose names
don't resolve to known functions (e.g., "eobject=strmatch" from
search eval arguments). The AST evaluator handles these via
ast_emit_literal_funccall with EV_FCHECK stripping, but the JIT
didn't replicate that behavior — causing 4 search eval test failures
under --enable-jit.
Add a function-name resolution check in jit_can_handle(): bail out
to the AST evaluator when any FUNCCALL name is not a known builtin
or user-defined function.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ast_emit_literal_funccall() was passing EV_FCHECK to children when
emitting a FUNCCALL whose name didn't resolve. The classic evaluator
consumes EV_FCHECK after a failed check, so nested function-like
patterns are treated as literal text. The AST path dispatched them
as real calls instead, breaking search(eobject=expr) where expr
contained nested functions with ## substitutions — the inner functions
were evaluated in the outer context where ## had no binding.
Strip EV_FCHECK in ast_emit_literal_funccall so children match classic
parser behavior. Add search_eval_fn.mux (9 test cases) covering
search(eval=...) with hasattr, get, strmatch, numeric compare, cand,
cleared attrs, and all-types search.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ulambda() provides anonymous function evaluation where the first
argument (the #lambda/body or #apply/func reference) is received
unevaluated, preventing the AST parser from dispatching inner
function calls. The body is then evaluated via ast_exec() to bypass
a JIT limitation where dynamically-provided cargs are ignored.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add LBuf class to alloc.h: an RAII wrapper around alloc_lbuf/free_lbuf
that moves LBUF_SIZE buffers from the stack to the heap pool. Convert
all 108 non-static UTF8 xxx[LBUF_SIZE] stack arrays across 25 source
files. Static BSS buffers (24) are unchanged.
This eliminates LBUF_SIZE from recursive stack frames, making it safe
to increase LBUF_SIZE without risking stack overflow in the evaluation
pipeline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Commit aa7f47659 changed ast_eval_branch to use a two-pass noeval→eval
path for 2.13-compatible switch/if branch semantics. But iter() also
called ast_eval_branch for its body, causing the body to be serialized
back to text and re-parsed on every iteration. This produced subtly
wrong output for string functions (extract, ldelete, ljust, rjust,
center, pickrand) nested inside iter().
Fix: iter() calls ast_eval_node directly with EV_EVAL|EV_STRIP_CURLY|
EV_FCHECK, matching the pre-aa7f47659 behavior. The two-pass noeval
path remains for if/switch/switchall branches where it's needed.
Fixes 22 test failures with --enable-jit (and without).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ast_eval_branch now uses a two-pass path matching 2.13 behavior:
pass 1 strips backslashes while copying % substitutions literally,
pass 2 re-tokenizes the result through mux_exec for evaluation.
The noeval pass folds Esc("\\") + Sub("%...") sequences so that
the reserialized text exposes the raw % form to pass 2, matching
how 2.13's character scanner processes these in a single stream.
Also adds has_close_bracket/has_close_brace tracking to ASTNode
so the noeval pass preserves malformed (unclosed) bracket/brace
structure instead of normalizing it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- color_ops.h: Add LIBMUX_API to all co_* declarations; fix CO_CS_NORMAL
compound literal for MSVC (both C and C++ modes)
- color_ops.c: Remove redundant (co_ColorState) casts that fail on MSVC C
- utf8tables.h/cpp: Add extern "C" wrapping for C/C++ linkage compatibility
- stringutil.h/cpp: extern "C" on utf8_FirstByte; LIBMUX_API on co_console_width
- unicode_tables_c.h: Guard extern declarations with #ifndef __cplusplus
(C++ gets them from utf8tables.h with proper LIBMUX_API and types);
add CO_OTT_CAST macro for string_desc/co_string_desc pointer conversion;
add proper LIBMUX_API fallback with dllexport/dllimport for Windows
- libmux.vcxproj: Add color_ops.c and unicode_tables.c as C compilation units
- ast.cpp: Use QueryPerformanceCounter for WIN32 astbench timing
- engine_com.cpp, functions.h, functions.cpp: Guard JIT-only code with TINYMUX_JIT
- utf/ generators: Add LIBMUX_API to all extern declarations in smutil.cpp,
strings.cpp, pairs.cpp; strings.cpp guards OTT externs with #ifndef __cplusplus;
preamble/postamble updated with CO_OTT_CAST and proper LIBMUX_API fallback
NOTE: Build not yet clean — ubuntu agent needs to regenerate auto-generated
files (utf8tables.h, utf8tables.cpp, unicode_tables_c.h) from updated
utf/ generators to pick up all LIBMUX_API and linkage fixes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AST evaluator: malformed %q<, %=<, %c< with no closing '>' now emit
literal text instead of silently dropping. Bare %= remains a no-op
(not a valid substitution).
FCHECK in ast_eval_node: replace positional (i > first) test with
type-based logic that skips AST_SPACE nodes, matching the classic
parser's behavior of stripping after the first '(' character.
ast_eval_argument left unchanged (each argument gets fresh FCHECK).
printf(): change center-alignment flag from '=' to '^' to avoid
conflict with the %= substitution namespace. Matches RhostMUSH.
Update smoke test hashes for TC002 (printf), TC029 (malformed color),
TC031 (mixed malformed). 603/0 pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes:
1. HIR_SETQ_SYNC was not listed in has_side_effects() in the DCE
pass, so dead code elimination silently removed all setr()/setq()
write-through ECALLs. The JIT returned the correct value from
setr() but never wrote to mudstate.global_regs — leaving %q0-%q9
null for any subsequent AST or JIT evaluation.
Fix: add HIR_SETQ_SYNC to the side-effects list in hir_opt.cpp.
2. Output slot exhaustion (10 slots × 8KB) caused alloc_output() to
return address 0, and codegen wrote RV64 instructions referencing
address 0 in guest memory — corrupting the string pool and causing
double-free or infinite loops.
Fix: alloc_output() sets out_exhausted flag; compile_expression()
checks it after codegen and returns prog.ok=false, causing
jit_eval to fall back to the AST evaluator. Removed the temporary
512-byte expression length guard.
Smoke results: 403 pass, 188 fail, clean shutdown, no duplicates.
Remaining failures are JIT output divergence (hash mismatches).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bugs found via Smoke test bisection with --enable-jit:
1. Runtime references (%0-%9 cargs, %#/%!/%n/%l/%q/%m/%k/%|/%+ SUBST
slots) were emitted as HIR_SCONST nodes. is_const() treated all
SCONST as compile-time constants, so the sequence lowering and
function-call constant folding merged them with surrounding literals
at compile time — baking in empty strings instead of runtime values.
Fix: add runtime_ref[] flag to hir_program, emit_sref() helper,
and exclude runtime refs from is_const().
2. ## and #@ outside a JIT-compiled iter() fell through to the
"unresolvable substitution" default and emitted empty string.
Fix: emit ECALL itext(0) / inum(0) to read mudstate.itext[]/inum[]
at runtime, matching what the AST evaluator does.
3. Arg validation in both compile-time and runtime ECALL paths returned
empty string for too-few-args, diverging from the AST evaluator
which returns "#-1 FUNCTION (X) EXPECTS N ARGUMENTS". The empty
result broke control flow in @if/@switch chains.
Fix: generate the same error string the AST evaluator produces.
4. Temporary 512-byte expression length guard to avoid output slot
exhaustion on large expressions (10 slots × 8KB each).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
astbench(<expr>, <iterations>) runs the expression through both the
AST evaluator and JIT, reporting microseconds per call for each:
think astbench(add(mul(3,4),5),10000)
→ ast=0.52us jit=0.04us ratio=13.0x result=17
Output includes the ratio and the result value for verification.
FN_NOEVAL so expressions aren't pre-evaluated. CA_WIZARD access.
593/593 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>