Skip to content

feat(agent_expr): if_goto + goto control-flow opcodes (#25 phase-3)#18

Merged
zachgenius merged 3 commits into
masterfrom
feat/v1.6-25-control-flow
May 12, 2026
Merged

feat(agent_expr): if_goto + goto control-flow opcodes (#25 phase-3)#18
zachgenius merged 3 commits into
masterfrom
feat/v1.6-25-control-flow

Conversation

@zachgenius

Copy link
Copy Markdown
Owner

Summary

Fills the docs/28 0x90-0x9f control-flow range reserved in
phase-1 so #26 phase-2's in-target tracepoint VM can compile
predicates with branches instead of filtering daemon-side.

  • Op::kIfGoto = 0x90 + Op::kGoto = 0x91 in bytecode.h.
    gdb's spec puts these at 0x20 / 0x21, but 0x20 is already
    LDB's kReg. Picked LDB's reserved range; the one-byte
    translation moves to #26's eventual gdb-remote wire driver
    (kept out of the VM contract).
  • Evaluator reads u16-BE absolute-pc immediate; jump past
    code.size() surfaces kBadImmediate; backward-jump infinite
    loops still trip the existing kMaxInsnCount cap.
  • Compiler grows (if cond then else) and (when cond body)
    special forms. Emission layout is "if-not-cond, jump to else"
    (extra kLogNot byte) so single-pass codegen emits in source
    order — alternative gdb layout would need a side-buffer for the
    then branch.
  • 18 new test cases (9 evaluator, 9 compiler): taken /
    not-taken / backward / out-of-range / underflow / infinite-loop;
    short-circuit semantics (unchosen branch's (div 1 0) never
    runs), nested (if (if ...) ...), arity errors, codegen-shape.
  • docs/28 and docs/29 updated; phase-3 sections move
    from "future" to "landed".

Test plan

  • cmake --build build -j 8 — warning-clean
  • ctest --test-dir build --output-on-failure — 70/70 green
  • ldb_unit_tests '[agent_expr]' — 61 test cases / 257
    assertions (was 43 / 213); +18 test cases delta
  • ldb_unit_tests '[ctrl]' — 9 evaluator tests pass
  • ldb_unit_tests '[if]' '[when]' — 9 compiler tests pass

Required by #26 phase-2 (in-target tracepoint VM) for efficient
predicate short-circuiting.

🤖 Generated with Claude Code

@zachgenius zachgenius force-pushed the feat/v1.6-25-control-flow branch 2 times, most recently from fab5b0a to 75938a9 Compare May 12, 2026 11:31
zachgenius and others added 3 commits May 12, 2026 21:33
Fills the docs/28 0x90-0x9f control-flow range that was reserved
in phase-1 (the bytecode VM + evaluator), so #26 phase-2's in-target
tracepoint VM can compile predicates with branches instead of
filtering on the daemon side.

Opcode bytes: 0x90 for if_goto, 0x91 for goto. gdb's spec puts
these at 0x20 / 0x21 but 0x20 already maps to LDB's kReg —
matching gdb's bytes would either break every phase-1/phase-2
bytecode payload (reassign kReg) or accept a one-byte translation
in #26's eventual gdb-remote wire driver. Picked the latter
(translation lives in the wire driver, not the VM contract).

Compiler grows (if cond then else) and (when cond body) special
forms in src/agent_expr/compiler.cpp. Layout: <cond> kLogNot
kIfGoto Telse <then> kGoto Tend Telse: <else> Tend:. The
"if-not-cond, jump to else" shape lets the single-pass emitter
keep forward-branch back-patching local — the alternative gdb
layout would parse 'then' second but emit it last, fighting the
patch-as-we-go invariant. Extra kLogNot is one byte + one cycle;
tracepoints are #26's hot path, not daemon-side compilation.

New emit_jump + patch_u16_be helpers on State handle the forward-
jump placeholder + back-patch dance. Out-of-range target pc (jump
past code.size()) surfaces kBadImmediate; infinite backward loops
still trip the existing kMaxInsnCount cap.

18 new test cases (9 evaluator + 9 compiler) cover taken /
not-taken branches, backward jumps, short-circuit semantics
(unchosen branch's (div 1 0) never executes), nested
(if (if ...) ...), arity errors, codegen-shape assertion for
(if 1 42 99), out-of-range target detection, infinite-loop cap.

docs/28 §2 lists the new opcodes; docs/28 §4 failure-modes table
gets the kBadImmediate-on-out-of-range rows; docs/28 §6 phase-3
section moves from "future" to "landed". docs/29 §1 DSL grammar
documents (if ...) and (when ...) with worked examples.

ctest: 70/70 green. Agent-expression suite: 61 test cases
(was 43), 257 assertions, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…view)

The kIfGoto handler validated `target > prog.code.size()` only inside
the `if (cond != 0)` branch. A malformed predicate with a broken jump
target therefore passed silently whenever the popped cond happened to
be zero — and crashed (kBadImmediate) on the next call with a truthy
cond. Classic latent-validation bug: deferring a check until the data
is "needed" lets bad bytecode slip past first contact.

Fix: validate the target immediately after the immediate read, before
popping cond. Matches kGoto's existing unconditional check. Added an
invariant comment on both handlers so the next opcode author copying
this pattern doesn't reintroduce the asymmetry.

Regression test asserts kBadImmediate when the cond is zero AND the
target is out of range. Pre-fix that program returned kOk and silently
executed the post-branch tail; post-fix it correctly surfaces the bad
target.

agent_expr unit tests: 62 cases / 258 assertions (was 61 / 257).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two reviewer findings on the phase-3 test surface:

1. Misleading comment block above the (if/when) compiler suite
   described gdb's "branch to then, else falls through" layout. The
   compiler actually emits the opposite — "if-not-cond, jump to
   else" — by inverting the predicate with kLogNot. The byte-shape
   test at the bottom verifies the real layout correctly; only the
   comment was wrong. Fixed the comment to match what's emitted,
   with a pointer to the byte-shape test as the source of truth.

2. The "infinite loop via backward kGoto → kInsnLimitExceeded" test
   only asserted that the cap fired — it would have passed even on
   an off-by-one where the cap tripped on iteration 1. Strengthened
   by exposing insn_count via EvalResult and asserting it equals
   kMaxInsnCount exactly. Switched the test program from a single
   kGoto self-loop to a 3-op loop body (const + drop + goto) so
   the loop genuinely runs ~3333 cycles before tripping the cap.

   Added a companion test that asserts insn_count on a normal
   (non-cap) run so a regression in the counter surfaces locally,
   not only via the loop test.

agent_expr unit tests: 63 cases / 262 assertions (was 62 / 258).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zachgenius zachgenius force-pushed the feat/v1.6-25-control-flow branch from 75938a9 to d33de33 Compare May 12, 2026 11:33
@zachgenius zachgenius merged commit bc9b830 into master May 12, 2026
2 of 4 checks passed
@zachgenius zachgenius deleted the feat/v1.6-25-control-flow branch May 12, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant