Skip to content

fix(codegen): context-sensitive cloning so nested stateful helpers don't share TA/var state - #32

Merged
luisleo526 merged 1 commit into
mainfrom
fix/nested-helper-context-sensitive-cloning
Jun 30, 2026
Merged

luisleo526 merged 1 commit into
mainfrom
fix/nested-helper-context-sensitive-cloning

Conversation

@luisleo526

Copy link
Copy Markdown
Contributor

Problem

A stateful Pine helper reached through multiple distinct call paths shared one TA member (and one var). Two compounding defects:

  1. _ta_site_map poisoning (codegen/base.py): function-local TA sites are shared across clones (clones copy node=orig.node). The _u{n} collision-renamed clone sites overwrote the canonical cs0 entry, so the active-remap lookup missed its base name and every clone collapsed onto one member.
  2. Broken nested dispatch (codegen/visit_call.py): a nested helper call inside a clone emitted {G}_cs{enclosing_active_cs}, conflating the callee's own textual call sites with the enclosing function's. For leg (reached via f_get×3 / track / wyckoff×2 / getcur×2 / direct = 9 paths) this produced 5 clones all bound to one shared length-30 member; the other 8 correctly-sized ta::Highest/Lowest members were declared but dead.

Fix

Add a context-sensitive (call-path) instance pre-pass _build_func_instances: walk the call graph from each natural clone and, per nested stateful call, compose the enclosing clone's active remap with the callee's per-call-site remap:

composed[m] = R_enclosing.get(R_callee_cs[m], R_callee_cs[m])

When the composition equals the callee's natural cs{j} remap it reuses the existing {G}_cs{j} clone — so output is byte-identical for the common single-caller case. Otherwise it mints a fresh instance bound to the path-specific TA members and fresh var members (so two paths never share scalar state). _instance_dispatch then authoritatively routes each nested call.

Validation

result
jevondijefferson-big-breakout (leg reached via 9 paths) 0.9% → 36.1% match vs TV (count 143→172, price-exact 94.9% of matched); deterministic ×3
full gate PINEFORGE_ENGINE_INCLUDE=... pytest 1386 passed, 1 skipped, 0 failed (1384 baseline + 2 new tests; 254-strategy corpus compile all green)
87-strategy behavioral sweep byte-identical transpile on 86/87, only jevondijefferson moved (improved) — zero regressions

New test_nested_helper_multi_path_distinct_ta_members / ..._distinct_var_state have teeth (fail on the unpatched tree: only 2 clones + dead members).

Note: the residual to 36% on jevondijefferson is a window-start warmup transient (path-dependent state needs TV's full pre-range history), not a fill-model or accounting gap — out of scope for this codegen fix.

🤖 Generated with Claude Code

…n't share TA/var state

A stateful helper reached through multiple distinct call paths shared one TA
member (and one var). Two compounding defects:

1. _ta_site_map poisoning (codegen/base.py): function-local TA sites are shared
   across clones (clones copy node=orig.node). The `_u{n}` collision-renamed
   clone sites overwrote the canonical cs0 entry, so the active-remap lookup
   missed its base name and every clone collapsed onto one member.
2. Broken nested dispatch (codegen/visit_call.py): a nested helper call inside a
   clone emitted `{G}_cs{enclosing_active_cs}`, conflating the callee's own
   textual call sites with the enclosing function's. For `leg` (reached via
   f_get x3 / track / wyckoff x2 / getcur x2 / direct = 9 paths) this produced 5
   clones all bound to one shared length-30 member; the other 8 correctly-sized
   ta::Highest/Lowest members were declared but DEAD.

Add a context-sensitive (call-path) instance pre-pass `_build_func_instances`:
walk the call graph from each natural clone and, per nested stateful call,
compose the enclosing clone's active remap with the callee's per-call-site
remap (composed[m] = R_enclosing.get(R_callee_cs[m], R_callee_cs[m])). When the
composition equals the callee's natural cs{j} remap it reuses the existing
{G}_cs{j} clone — so output is BYTE-IDENTICAL for the common single-caller case;
otherwise it mints a fresh instance bound to the path-specific TA members and
FRESH var members (so two paths never share scalar state). _instance_dispatch
then authoritatively routes each nested call.

jevondijefferson-big-breakout (leg reached via 9 paths): 0.9%->36.1% match vs
TradingView (count 143->172 toward TV 214, price-exact 94.9% of matched);
deterministic across runs. Full gate PINEFORGE_ENGINE_INCLUDE=... pytest: 1386
passed, 1 skipped, 0 failed (1384 baseline + 2 new tests; 254-strategy corpus
compile all green, 0 new failures, nothing added to KNOWN_*_FAILURES). 87-strategy
behavioral sweep: byte-identical transpile on 86/87, only jevondijefferson moved
(improved). New test_nested_helper_multi_path_distinct_ta_members /
..._distinct_var_state have teeth (fail on the unpatched tree).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@luisleo526
luisleo526 merged commit ceaf1a2 into main Jun 30, 2026
9 checks passed
@luisleo526
luisleo526 deleted the fix/nested-helper-context-sensitive-cloning branch June 30, 2026 15:05
luisleo526 added a commit that referenced this pull request Oct 6, 2026
* docs: changelog and version scoping for 1.3.0

1.3.0 pairs only with engine v1.3.0. The changelog section takes in the
Unreleased note and covers #173, #178, #179, #177, #174 and #175: the
confirmed-bar receipt and what engine v1.3.0's live runner does with it,
the capability receipt's request entries now read from the lowering, the
order-shapes receipt, which no engine reads yet, the checked settings fix
for string dropdowns with built-in constants (now supported=false where
1.2.0 wrote an integer code or published the input as supported), the
PineForge Source License 1.2, and the pairing and migration steps. The
Python and JSON contract is unchanged; the engine's report harness is the
same file as in v1.2.0, so no report key changes.

The README, AGENTS.md / CLAUDE.md, CONTRIBUTING.md, LEGAL.md,
docs/PUBLIC_CONTRACT.md, docs/pine-cap-activation.md and npm/README.md
name 1.3.0 where they named 1.2.0 as the current release or pair, the
pairing table gets a 1.3.0 row, the "unreleased (after 1.2.0)" markers
name 1.3.0, and the license badge reads 1.2. The release scoreboard
sentence and the pf markers are left to the release lane.

Measured for 1.3.0: the engine's 325 corpus sources and the 277 gate
fixtures, each in a fresh process, transpile to 1.2.0's C++ byte for byte
once the confirmed-bar and order-shapes functions are removed (13
refusals, same messages), with transpile_full results and transpile_json
envelopes without cpp equal to 1.2.0's, each in under 0.1 s on CPython
3.14.6 on an Apple M4 Max. The tutorial run of the quick-start C++
against engine main 7a1f01c0 (Linux x86_64, GCC 13) prints 9 trades and
+738.20, and 13 trades with the old sizing declared, as 1.2.0 with engine
v1.2.0 does.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs: release 1.3.0's grades in the README and the changelog, from the facts tokens

The README's release sentence moves to the releases[1.3.0] tokens and its
main-scoreboard lines re-render, and the 1.3.0 changelog says which registry
baseline grades the release and why that baseline is the release's code,
all rendered from pineforge-release facts/facts.json at ff21de0 (#32).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
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