Skip to content

Held-out correctness fixes: nested shorthand, short-circuit, map_get nil, list values - #137

Merged
howlcipher merged 2 commits into
mainfrom
heldout-fixes
Oct 12, 2026
Merged

howlcipher merged 2 commits into
mainfrom
heldout-fixes

Conversation

@howlcipher

Copy link
Copy Markdown
Owner

Fixes the four correctness bugs found by the held-out benchmark (#136). Base: main c21a029c. No new built-ins, no token claims, no production -compile-bc/HFIR flip, #90 untouched.

1. Nested shorthand under the governed profile

  • isSugarExprContext now covers every value-taking built-in (write_file, to_lower, read_file, …).
  • A new registry drift test keeps the list complete.
  • Fail-closed order is unchanged: pre-desugar scan, then desugar, then post-desugar VerifyProfile, then build. A test checks that an exec hidden in a nested shorthand produces no artifact and no effect.

2. Short-circuit and/or

  • The interpreter decides before evaluating the right side.
  • AST bytecode and HFIR bytecode both use the new bytecode.ShortCircuit (a JUMP_IF_FALSE guard).
  • Effect-skipping test: a write_file on the skipped side does not run, and to_int "qq" there does not error.

3. map_get miss → nil (behavior change)

  • Applies on all paths. In Go, a miss on a dynamic map is nil; typed map[string]string still returns "", but is_nil of a named map_get is a key-presence check.
  • nil renders as "" in print, stderr, to_string and str_join (vm.TextOf, plus Go/JS print/to_string).
  • (= (map_get m k) "") on a miss is now false.
  • Migrated: apps/task_api (now or is_nil … ""), the apps/release_authority test (dead is_nil fallbacks now fire), the vm/gogen/js absence/path tests, and the ABI conformance fixture map_get_absence.

4. Lists are values

  • append rebinds only the named variable. To update a list held in a map, map_set it back.
  • The VM already did this. JS push aliased: it now copies. Go append could share a backing array: it now uses a full slice expression.

Tests

  • heldout_correctness_cli_test.go: each bug on the interpreter (-run), governed AST bytecode (-run-bc) and HFIR bytecode.
  • Go/JS target tests.
  • The desugar drift test.

Verification (no claims)

  • go build/vet/test ./... and go test ./experiments/... pass.
  • v0 run.sh: 60/60 for each language.
  • held-out run.sh: 126/126 references verified, the same 2 gaps; results unchanged.
  • h05/h07/h12 in natural form pass in a scratch run but were not committed (frozen hashes are kept).

Journal: docs/journals/2026-10-12_heldout_correctness_fixes.md.

Caveats: the JS nz lowering bug predates this PR (a let in expression position); Go dict literals are string-valued only; Go nz on a typed string map can still see "".

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev-lead review (COMMENT-only), head d67884b5c69f0b9dd68670f1f13e831210bf8c5d, base c21a029c.

Verdict: not merge-ready yet, because of one small doc blocker (below). Code is sound. Once the doc is fixed, Okabe may undraft and squash-merge when CI is green.

Per fix

  1. Nested shorthand under governed: OK. isSugarExprContext now lists value-taking built-ins (write_file, exec, llm_generate, store_, req_, ...). This only controls where sugar expands. Authorization is unchanged: pre-desugar profile scan, then desugar, then post-desugar hfir.VerifyProfile, then build. So the expanded form is re-verified and nothing is allow-listed. TestHeldoutNestedShorthandGovernedFailsClosed (exec inside str_replace inside write_file) shows no artifact and no side file. The drift test walks construct.Table() and fails if a supported, non-opaque construct isn't a sugar parent, unless it's in the structural allow-list with a reason (defun/dict/neural_circuit/ephemeral_circuit). That pins new parent constructs. A new shorthand is still guarded by the strict default Scan, so unknown forms are denied.
  2. Short-circuit and/or: OK. Interpreter evalBinop decides before evaluating the right side. AST bytecode and HFIR bytecode share bytecode.ShortCircuit (a JUMP_IF_FALSE guard). Go/JS already used native &&/||. The CLI test has write_file on the skipped side across -run, governed -run-bc and -compile-hfir-bc. The result is still bool (ToBool), not the operand value, so there is no truthiness semantics change.
  3. map_get miss returns nil: code OK, docs incomplete. VM/bytecode/HFIR-bc push nil, JS returns null, Go dynamic maps return nil. vm.TextOf keeps print/stderr/to_string/str_join rendering nil as "", and Go/JS to_string/print match. task_api is migrated to (or (is_nil x) (= x "")), which is safe now that or short-circuits. release_authority's is_nil fallbacks are now live and tested. The ABI fixture change false to true is intentional and journaled. I found no unmigrated .howl callers comparing a map_get result to "" (the only hit is the new test). The numeric = uses in vm tests are on present keys.
    • Blocker (doc): docs/reference/lowered_hfir_abi_v1.md:56 still says a missing key is "" and is_nil of it is false. That now contradicts the conformance fixture. Please update it.
    • Non-blocking stale notes: internal/vm/map_get_path_test.go:13 comment ("still """), and improvements.md:937/981 (#103 absence contract). Add a superseded note or update them. The historical journals can stay.
  4. Lists as values: OK. JS howlFrameAppend copies. Go uses append(xs[:len(xs):len(xs)], item) in the direct form and the helper, so the backing array is never shared. The VM already copied. This is O(n) per append, so a loop of appends is O(n²) on all targets. That's acceptable for now, but worth a note for later perf work.

Go typed-map caveat

Go map[string]string can't hold nil, so a value read of a miss is still "". is_nil (map_get m k) is lowered to the key-presence check howlFrameMapGetIsNil, so is_nil agrees with the VM. But nz (map_get m k) d and (= (map_get m k) "") on a typed string map in Go still see "". That's a real cross-target divergence (the default doesn't fire in Go), not just a cosmetic limitation. It's documented in the journal, but not covered by a test that pins the divergence. Non-blocking: add a gogen test pinning current behavior, or lower nz(map_get …) via presence too. The list-in-map case isn't testable on Go because dict literals are string-valued, which predates this PR and is documented.

Benchmarks

The journal reports token-efficiency-v0 at 60/60 for each language and heldout-v0 at exit 0 with 126 verified and the same h31/h32 gaps. Committed results and frozen hashes are unchanged, and the experiments/token-efficiency* Go tests pass locally.

Scope, hard nos, CI

21 files: desugar plus drift test, vm, bytecode, hfir lowering, gogen, js, absence/path tests, task_api, release_authority test, ABI fixture, journal, CLI test. No prod -compile-bc/HFIR default flip, #90 untouched, no live LLM, no H1 claim. Local go test ./... passed on all packages. CI build succeeded on this head.

@howlcipher
howlcipher marked this pull request as ready for review October 12, 2026 17:03
@howlcipher
howlcipher merged commit 6ed0184 into main Oct 12, 2026
1 check passed
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