From e2a6ba6426bfbe0beddfb89ee3458214751dd801 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 30 Jun 2026 23:03:54 +0800 Subject: [PATCH] fix(codegen): context-sensitive cloning so nested stateful helpers don't share TA/var state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pineforge_codegen/codegen/base.py | 279 ++++++++++++++++++++---- pineforge_codegen/codegen/emit_top.py | 31 ++- pineforge_codegen/codegen/visit_call.py | 11 +- tests/test_codegen_new.py | 99 +++++++++ 4 files changed, 377 insertions(+), 43 deletions(-) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index b1cb504..1d4aed1 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -302,13 +302,34 @@ def __init__(self, ctx: AnalyzerContext) -> None: for site in ctx.ta_call_sites: if site.node is not None: if site.member_name not in self._func_ta_members: + # Top-level (non-function) site: maps to itself. self._ta_site_map[id(site.node)] = site - elif not any(site.member_name.endswith(f"_cs{i}") for i in range(1, 100)): - # Original (cs0) function-local site — add to map for initial visit + elif id(site.node) not in self._ta_site_map: + # Function-local site. Multiple clones share the SAME AST + # node (clones copy ``node=orig.node``); the FIRST one in + # ``ta_call_sites`` order is the canonical original (cs0) + # whose ``member_name`` the per-call-site remap is keyed on. + # Later clones (``_cs{i}``, ``_cs{i}_cs{j}``, ``_u{n}`` …) + # must NOT overwrite it: doing so poisons the base name so + # the active-remap lookup misses and every clone collapses + # onto one member. Keep the original; the variant member is + # resolved via ``_active_ta_remap`` at emit time. self._ta_site_map[id(site.node)] = site self._ta_index_by_site_id: dict[int, int] = { id(site): i for i, site in enumerate(ctx.ta_call_sites) } + # Context-sensitive (call-path) instance machinery for nested stateful + # helpers. Built by ``_build_func_instances`` below. ``_current_instance_name`` + # names the function clone whose body is currently being emitted (None at + # top level / non-variant bodies). ``_instance_dispatch`` maps + # ``(enclosing_instance_name, call_node_id) -> callee emit-name`` and is the + # authority for nested stateful-helper dispatch (see visit_call). + self._current_instance_name: str | None = None + self._instance_dispatch: dict[tuple[str | None, int], str] = {} + self._fresh_instances: list[dict] = [] + self._fresh_var_members: list[tuple[str, str]] = [] + # NOTE: _build_func_instances() runs at the top of generate() (it needs + # _all_member_names / _func_safe_name, which are populated later in __init__). # Build lookup: node id -> FixnanCallSite (counter-based) self._fixnan_counter = 0 self._switch_counter = 0 @@ -534,6 +555,194 @@ def __init__(self, ctx: AnalyzerContext) -> None: # history reads off security-helper series. self._max_bars_back_cap: int | None = self._compute_max_bars_back_cap() + # ------------------------------------------------------------------ + # Context-sensitive (call-path) instance machinery + # ------------------------------------------------------------------ + def _iter_func_calls(self, root) -> list: + """Collect every ``FuncCall`` node reachable from ``root`` (a stmt list + or single AST node). Order-independent; used by the instance pre-pass to + find nested user-function calls inside a function body.""" + out: list = [] + seen: set[int] = set() + stack: list = list(root) if isinstance(root, (list, tuple)) else [root] + while stack: + node = stack.pop() + if node is None: + continue + if isinstance(node, (list, tuple)): + stack.extend(node) + continue + if isinstance(node, dict): + stack.extend(node.values()) + continue + if not hasattr(node, "__dict__"): + continue + nid = id(node) + if nid in seen: + continue + seen.add(nid) + if isinstance(node, FuncCall): + out.append(node) + for v in vars(node).values(): + if isinstance(v, (list, tuple, dict)) or hasattr(v, "__dict__"): + stack.append(v) + return out + + def _build_func_instances(self) -> None: + """Context-sensitive cloning of nested stateful helper functions. + + A stateful helper ``G`` (carrying TA state and/or ``var`` members) may be + reached through several distinct call paths — e.g. ``leg`` called from + three clones of ``f_get`` (lengths 10/20/30) *and* directly. Each path is + a logically-distinct instance that must drive its OWN TA/var members. + + The analyzer already mints the per-path members (via range-widening), but + the flat ``{G}_cs{idx}`` clone namespace conflates a callee's own textual + call sites with the enclosing function's call sites. This pre-pass walks + the call graph from each natural clone and, for every nested stateful + call, composes the enclosing clone's active remap with the callee's + per-call-site remap: + + composed_ta[m] = R_enclosing.get(R_callee_cs[m], R_callee_cs[m]) + + When the composition equals the callee's natural ``cs{j}`` remap the call + dispatches to the existing ``{G}_cs{j}`` clone (output stays byte-identical + for the common single-caller case). Otherwise a fresh instance is minted, + bound to the path-specific members (and FRESH ``var`` members so two paths + never share scalar state). ``_instance_dispatch`` records the resolved + emit-name per ``(enclosing_instance, call_node)``; ``_fresh_instances`` / + ``_fresh_var_members`` carry the extra code to emit. + """ + ctx = self.ctx + stateful = (set(ctx.func_ta_ranges.keys()) + | set(ctx.func_var_members.keys()) + | set(ctx.func_series_vars.keys())) + if not stateful: + return + + func_bodies: dict[str, list] = {} + for fi in ctx.func_infos: + node = getattr(fi, "node", None) + if node is not None and getattr(node, "body", None): + func_bodies.setdefault(fi.name, node.body) + + def ta_originals(fname: str) -> list[str]: + return list(self._func_cs_ta_remap.get((fname, 0), {}).keys()) + + def var_originals(fname: str) -> list[str]: + return [self._safe_name(n) for n, _, _ in ctx.func_var_members.get(fname, [])] + + def natural_name(fname: str, cs_idx: int) -> str: + return f"{self._func_safe_name(fname)}_cs{cs_idx}" + + interned: dict[tuple, dict] = {} + worklist: list[dict] = [] + seen_walk: set[str] = set() + fresh_counter = 0 + + # Seed with the natural clones the flat emission loop produces. + for fname in stateful: + if fname not in func_bodies: + continue + total_cs = ctx.func_call_site_counts.get(fname, 0) + if total_cs > 0: + for k in range(total_cs): + worklist.append({ + "fname": fname, + "name": natural_name(fname, k), + "ta_remap": self._func_cs_ta_remap.get((fname, k), {}), + "var_remap": self._func_cs_var_remap.get((fname, k), {}), + }) + else: + worklist.append({ + "fname": fname, + "name": self._func_safe_name(fname), + "ta_remap": {}, + "var_remap": {}, + }) + + while worklist: + inst = worklist.pop() + if inst["name"] in seen_walk: + continue + seen_walk.add(inst["name"]) + body = func_bodies.get(inst["fname"]) + if not body: + continue + active_ta = inst["ta_remap"] + for callnode in self._iter_func_calls(body): + cs_info = ctx.func_call_cs_map.get(id(callnode)) + if cs_info is None: + continue + g_name, j = cs_info + if g_name not in stateful: + continue + natural_ta = self._func_cs_ta_remap.get((g_name, j), {}) + composed_ta = {} + for m in ta_originals(g_name): + mid = natural_ta.get(m, m) + composed_ta[m] = active_ta.get(mid, mid) + if composed_ta == natural_ta: + # Path resolves to the callee's own cs{j} clone — reuse it. + self._instance_dispatch[(inst["name"], id(callnode))] = \ + natural_name(g_name, j) + continue + key = (g_name, frozenset(composed_ta.items())) + ginst = interned.get(key) + if ginst is None: + fresh_counter += 1 + inst_name = f"{self._func_safe_name(g_name)}__ni{fresh_counter}" + fvar_remap: dict[str, str] = {} + for v in var_originals(g_name): + fresh_member = f"{v}__ni{fresh_counter}" + fvar_remap[v] = fresh_member + self._fresh_var_members.append((v, fresh_member)) + ginst = { + "fname": g_name, + "name": inst_name, + "ta_remap": composed_ta, + "var_remap": fvar_remap, + } + interned[key] = ginst + self._fresh_instances.append(ginst) + worklist.append(ginst) + self._instance_dispatch[(inst["name"], id(callnode))] = ginst["name"] + + def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str, + series_suffix: str, lines: list[str]) -> None: + """Declare a per-clone copy of a function-scoped ``var`` member, matching + the original's C++ type (series / matrix / array / map / drawing-handle / + UDT / scalar). Shared by the per-call-site clone loop and the fresh + context-sensitive instance loop.""" + for vname, ptype, _init_str in self.ctx.var_members: + if self._safe_name(vname) == orig_safe: + cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double") + if vname in self.ctx.series_vars: + lines.append(f" Series<{cpp_type}> {cloned_safe}{series_suffix};") + elif vname in self._matrix_specs: + lines.append(f" {self._type_spec_to_cpp(self._matrix_specs[vname])} {cloned_safe};") + elif vname in self._array_vars: + lines.append(f" {self._type_spec_to_cpp(self._array_spec_for_name(vname))} {cloned_safe};") + elif vname in self._map_vars: + lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(vname))} {cloned_safe};") + elif vname in self._udt_var_types: + # Drawing handle / UDT var clone must match the original's + # type (Line/Label/Box/), not the coarse PineType + # default (double) — otherwise the clone can't hold the + # handle and drawing access on it reads a garbage / na id. + udt_t = self._udt_var_types[vname] + handle_cpp = DRAWING_TYPE_TO_CPP.get(udt_t, udt_t) + lines.append(f" {handle_cpp} {cloned_safe} = {handle_cpp}{{}};") + else: + lines.append(f" {cpp_type} {cloned_safe};") + return + # Non-var series var + if orig_safe in [self._safe_name(n) for n in self.ctx.series_vars]: + cpp_type = self._series_type_for(orig_safe) + lines.append(f" Series<{cpp_type}> {cloned_safe}{series_suffix};") + else: + lines.append(f" double {cloned_safe} = 0.0;") + @staticmethod def _int_literal_value(node: ASTNode | None) -> int | None: """Return the integer value of a (possibly unary-minus) NumberLiteral, @@ -979,6 +1188,9 @@ def walk(node): def generate(self) -> str: """Generate C++ source from the AnalyzerContext.""" + # Context-sensitive instance pre-pass (needs the naming helpers populated + # in __init__). Computes nested stateful-helper dispatch + fresh instances. + self._build_func_instances() # Pre-scan for strategy series vars self._prescan_strategy_series() self._security_ohlc_hist_fields_by_sec: dict[int, set[str]] = {} @@ -1306,41 +1518,16 @@ def generate(self) -> str: if cloned_safe in emitted_clones: continue # already declared by another function's clone emitted_clones.add(cloned_safe) - # Determine the type by finding the original declaration - orig_name = orig_safe # _safe_name was already applied - # Check if it's a var member (Series) or plain series - found = False - for vname, ptype, init_str in self.ctx.var_members: - if self._safe_name(vname) == orig_safe: - cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double") - if vname in self.ctx.series_vars: - lines.append(f" Series<{cpp_type}> {cloned_safe}{_mbb};") - elif vname in self._matrix_specs: - lines.append(f" {self._type_spec_to_cpp(self._matrix_specs[vname])} {cloned_safe};") - elif vname in self._array_vars: - lines.append(f" {self._type_spec_to_cpp(self._array_spec_for_name(vname))} {cloned_safe};") - elif vname in self._map_vars: - lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(vname))} {cloned_safe};") - elif vname in self._udt_var_types: - # Drawing handle / UDT var clone must match the - # original's type (Line/Label/Box/), not the - # coarse PineType default (double) — otherwise the - # clone can't hold the handle and drawing access on - # it reads a garbage / na id. - udt_t = self._udt_var_types[vname] - handle_cpp = DRAWING_TYPE_TO_CPP.get(udt_t, udt_t) - lines.append(f" {handle_cpp} {cloned_safe} = {handle_cpp}{{}};") - else: - lines.append(f" {cpp_type} {cloned_safe};") - found = True - break - if not found: - # Non-var series var - if orig_safe in [self._safe_name(n) for n in self.ctx.series_vars]: - cpp_type = self._series_type_for(orig_safe) - lines.append(f" Series<{cpp_type}> {cloned_safe}{_mbb};") - else: - lines.append(f" double {cloned_safe} = 0.0;") + self._emit_cloned_var_decl(orig_safe, cloned_safe, _mbb, lines) + + # 8c2. Fresh var members for context-sensitive helper instances (nested + # helpers reached through >1 distinct call path). Each fresh instance + # gets its OWN scalar/series state so two paths never collide. + for orig_safe, fresh_safe in self._fresh_var_members: + if fresh_safe in emitted_clones: + continue + emitted_clones.add(fresh_safe) + self._emit_cloned_var_decl(orig_safe, fresh_safe, _mbb, lines) # 8d. Drawing-objects-as-data arenas (gated on _uses_drawing so # non-drawing strategies emit byte-identical C++). Each arena is a @@ -1375,6 +1562,11 @@ def generate(self) -> str: else: lines.append(f" bool _fvinit_{self._func_safe_name(fi.name)} = false;") + # 9a2. ``var`` init flags for fresh context-sensitive helper instances. + for inst in self._fresh_instances: + if inst["fname"] in self.ctx.func_var_members and inst["var_remap"]: + lines.append(f" bool _fvinit_{inst['name']} = false;") + # 9b. _ta_initialized_ flag for runtime TA re-sizing (first on_bar only). if self.ctx.ta_call_sites: lines.append(" bool _ta_initialized_ = false;") @@ -1403,7 +1595,20 @@ def generate(self) -> str: self._emit_func_def(fi, lines) lines.append("") + # 10a. Fresh context-sensitive instances of nested stateful helpers + # (reached through >1 distinct call path). Each is bound to its own + # path-specific TA + var members; see _build_func_instances. + if self._fresh_instances: + fi_by_name = {fi.name: fi for fi in self.ctx.func_infos} + for inst in self._fresh_instances: + fi = fi_by_name.get(inst["fname"]) + if fi is None: + continue + self._emit_func_def(fi, lines, instance=inst) + lines.append("") + # 11. on_bar() + self._current_instance_name = None self._emit_on_bar(lines) lines.append("") diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index f03b19c..da96332 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -751,11 +751,17 @@ def _emit_udt_method_cpp_name(self, fi: FuncInfo) -> str: base = fi.node.name if fi.node else "" return self._func_safe_name(f"_udt_{udt}_{base}") - def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | None = None) -> None: + def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | None = None, + instance: dict | None = None) -> None: """Emit a user-defined function as a class method. If call_site_idx is not None, emit a per-call-site variant with TA member names remapped to call-site-specific copies. + + If ``instance`` is provided (a fresh context-sensitive instance minted by + ``_build_func_instances``), emit a uniquely-named clone whose TA/var + members come from the instance's composed remaps instead of the flat + ``_func_cs_*_remap`` tables. """ node = fi.node if node is None: @@ -834,7 +840,19 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # For per-call-site variants, suffix the function name and activate TA + var remapping func_name = self._emit_udt_method_cpp_name(fi) if is_udt else self._func_safe_name(fi.name) - if call_site_idx is not None: + var_init_flag: str | None = None + if instance is not None: + # Fresh context-sensitive instance: name + composed remaps come from + # the instance record. No textual cs index (dispatch is via the + # instance map), but it IS a state-isolated variant. + func_name = instance["name"] + self._active_ta_remap = instance["ta_remap"] + self._active_var_remap = instance["var_remap"] + self._in_ta_func_variant = True + self._active_call_site_idx = None + self._current_instance_name = instance["name"] + var_init_flag = f"_fvinit_{instance['name']}" + elif call_site_idx is not None: func_name = f"{func_name}_cs{call_site_idx}" remap = self._func_cs_ta_remap.get((fi.name, call_site_idx), {}) self._active_ta_remap = remap @@ -842,11 +860,13 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._active_var_remap = var_remap self._in_ta_func_variant = True self._active_call_site_idx = call_site_idx + self._current_instance_name = f"{self._func_safe_name(fi.name)}_cs{call_site_idx}" else: self._active_ta_remap = {} self._active_var_remap = {} self._in_ta_func_variant = False self._active_call_site_idx = None + self._current_instance_name = None prev_func_locals = self._current_func_locals prev_func_body = getattr(self, "_current_func_body", None) @@ -873,7 +893,7 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # function is a function-local static — its initializer runs exactly # once, on the first call to THIS variant, with the first bar's values # the function actually sees. Each clone (cs0/cs1/…) is independent. - self._emit_func_var_init_block(fi, call_site_idx, lines) + self._emit_func_var_init_block(fi, call_site_idx, lines, flag_override=var_init_flag) emitted_return = False if node.is_single_expr and node.body: @@ -936,13 +956,14 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._active_var_remap = {} self._in_ta_func_variant = False self._active_call_site_idx = None + self._current_instance_name = None def _func_var_init_flag_name(self, fname: str, call_site_idx: int | None) -> str: suffix = f"_cs{call_site_idx}" if call_site_idx is not None else "" return f"_fvinit_{self._func_safe_name(fname)}{suffix}" def _emit_func_var_init_block(self, fi: FuncInfo, call_site_idx: int | None, - lines: list[str]) -> None: + lines: list[str], flag_override: str | None = None) -> None: """Emit the one-shot initializer block for a function's ``var`` members. Pine ``var`` declared inside a function is a function-local static: @@ -961,7 +982,7 @@ class member but its initializer was dropped, leaving the member ``na`` members = self.ctx.func_var_members.get(fi.name) if not members: return - flag = self._func_var_init_flag_name(fi.name, call_site_idx) + flag = flag_override or self._func_var_init_flag_name(fi.name, call_site_idx) # ``_active_var_remap`` is already set for this variant by the caller, # so lowering each init expression here correctly resolves references # to sibling var members (which are themselves remapped for clones). diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 4ac22ba..8617f4c 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -1083,7 +1083,16 @@ def _visit_arg_for_series(arg_node, arg_idx): emit_name = self._func_safe_name(func_name) if func_name in self._func_names else func_name # Per-call-site variant: if this function has TA/series calls, call the correct variant cs_info = self.ctx.func_call_cs_map.get(id(node)) - if self._active_call_site_idx is not None and cs_info is not None: + dispatch_key = (self._current_instance_name, id(node)) + if dispatch_key in self._instance_dispatch: + # Context-sensitive (call-path) dispatch: the instance pre-pass + # resolved this nested stateful-helper call to the clone bound to + # THIS enclosing path's members (see _build_func_instances). This + # is authoritative — it supersedes the textual-cs threading below, + # which conflates a callee's own call sites with the enclosing + # function's call sites for helpers reached through >1 path. + emit_name = self._instance_dispatch[dispatch_key] + elif self._active_call_site_idx is not None and cs_info is not None: # Inside a per-call-site variant: override the cs_map index with # the parent's active call-site index. This ensures sub-functions # called from ma_cs6() use their _cs6 variant, not _cs0. diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index 6c43488..f2b33d2 100644 --- a/tests/test_codegen_new.py +++ b/tests/test_codegen_new.py @@ -1286,3 +1286,102 @@ def test_barstate_members_use_runtime_state(): assert "is_last_tick_" in cpp assert "barstate_islast_" in cpp + + +# === Regression: nested stateful helper reached through multiple call paths === +# A single inner helper (`leg`, carrying ta.highest/lowest + a `var` member) is +# reached through clones of more than one outer helper with DIFFERENT length +# args. The flat `{G}_cs{idx}` clone namespace used to conflate the callee's own +# call sites with the enclosing functions' call sites, collapsing every clone +# onto ONE shared (last-written) TA member and leaving the rest DECLARED-but- +# never-COMPUTED ("dead"). This pins the context-sensitive (call-path) cloning +# that gives each path its own member. (Was: all clones shared one member.) + +import re as _re + + +def _ta_decls_and_computed(cpp: str): + decls = _re.findall(r'ta::(?:Highest|Lowest|Change|Sma|Ema) (_ta_\w+);', cpp) + computed = set(_re.findall(r'(_ta_\w+)\.(?:compute|recompute)\(', cpp)) + return decls, computed + + +def test_nested_helper_multi_path_distinct_ta_members(): + # `leg` reached via f_get (called twice: 10, 20) AND g_get (called once: 30). + cpp = _generate( + """ +//@version=6 +strategy("nested multipath") +leg(int size) => + var int l = 0 + h = ta.highest(size) + lo = ta.lowest(size) + l := h > lo ? 1 : -1 + l +f_get(int len) => + leg(len) +g_get(int len) => + leg(len) +a = f_get(10) +b = f_get(20) +c = g_get(30) +plot(a + b + c) +""" + ) + decls, computed = _ta_decls_and_computed(cpp) + highest = [d for d in decls if d.startswith("_ta_highest_")] + lowest = [d for d in decls if d.startswith("_ta_lowest_")] + # Three distinct rolling windows are needed (lengths 10/20/30). + assert len(highest) >= 3, f"expected >=3 highest members, got {highest}" + assert len(lowest) >= 3, f"expected >=3 lowest members, got {lowest}" + # No DEAD members: every declared TA member must be computed at least once. + dead = [d for d in decls if d not in computed] + assert not dead, f"declared-but-never-computed TA members: {dead}" + # Each emitted `leg` clone must reference a DISTINCT highest member (no two + # clones share one rolling window). + bodies = _re.findall(r'int (leg(?:_cs\d+|__ni\d+))\(int size\) \{(.*?)\n \}', cpp, _re.S) + used = [] + for _fn, body in bodies: + hits = _re.findall(r'(_ta_highest_\w+)\.(?:compute|recompute)\(', body) + used.extend(set(hits)) + assert len(used) == len(set(used)), ( + f"two leg clones share a highest member (state collision): {used}" + ) + # And at least 3 leg clones bound to the 3 distinct windows. + assert len(set(used)) >= 3, f"expected >=3 distinct leg windows, got {set(used)}" + + +def test_nested_helper_multi_path_distinct_var_state(): + # Same shape; assert the `var int l` is NOT shared across the distinct paths + # (each leg clone gets its own scalar state member). + cpp = _generate( + """ +//@version=6 +strategy("nested multipath var") +leg(int size) => + var int l = 0 + h = ta.highest(size) + lo = ta.lowest(size) + l := h > lo ? 1 : -1 + l +f_get(int len) => + leg(len) +g_get(int len) => + leg(len) +a = f_get(10) +b = f_get(20) +c = g_get(30) +plot(a + b + c) +""" + ) + bodies = _re.findall(r'int (leg(?:_cs\d+|__ni\d+))\(int size\) \{(.*?)\n \}', cpp, _re.S) + var_used = [] + for _fn, body in bodies: + # the returned var member: `return l...;` + m = _re.search(r'return (l(?:_cs\d+|__ni\d+)?);', body) + if m: + var_used.append(m.group(1)) + assert len(var_used) >= 3, f"expected >=3 leg clones, got {var_used}" + assert len(var_used) == len(set(var_used)), ( + f"two leg clones share the `var int l` state member: {var_used}" + )