diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 130e06e..e23355c 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -171,15 +171,23 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._func_ta_ranges: dict[str, tuple[int, int]] = {} # func_name -> (start, end) indices self._func_call_site_count: dict[str, int] = {} # func_name -> count self._func_call_cs_map: dict[int, tuple[str, int]] = {} # call_node_id -> (func_name, cs_idx) + # Textual nested calls whose identity is inherited from the active + # parent clone rather than assigned a fixed source-level cs index. + # Kept separately so a second propagation pass (security TF cloning) + # does not accidentally backfill them as a new cs{N} call site. + self._func_inherited_call_nodes: set[int] = set() # Per-function fixnan site ownership: func_name -> list of fixnan site # indices in self._fixnan_sites owned by that function. Mirrors the # TA-range slicing but for fixnan state, so per-call-site cloning can # mint fresh fixnan members per variant and the codegen dead-code pass # can skip fixnan state owned by dead functions. self._func_fixnan_indices: dict[str, list[int]] = {} - # Functions whose ONLY reason for needing per-call-site body cloning - # is a security-tf-monomorphized request.security (no TA/series state - # of their own — see _check_mixed_callsite_security_tf). + # Functions that need per-call-site BODY cloning despite owning no + # TA/series/var state. The set originated for security-tf + # monomorphization; it is also the existing emitter contract used by + # pure wrappers around stateful callees and fixnan-only functions. + # Security evaluator identity remains independently tracked by each + # SecurityCallInfo.callsite_idx. self._func_security_clone_only: set[str] = set() # Authoritative clone-name map: (func_name, cs_idx) -> {orig_member_name: # cloned_member_name}. The codegen rebuilds its TA remap from the @@ -463,40 +471,118 @@ def walk(value: Any) -> None: return out def _propagate_call_site_counts(self) -> None: - """Propagate call-site counts from parent functions to sub-functions. - - If function F has N call sites and calls sub-function G internally, - G also needs N variants so that F_csK can call G_csK with isolated state. - This ensures every stateful sub-function gets per-call-site isolation - inherited from its parent. + """Propagate stateful UDF identity through complete call paths. + + A UDF is stateful when it owns series/``var`` state, TA state, or + ``fixnan`` state, and every pure wrapper that reaches such a UDF is + stateful transitively. Give each wrapper's textual calls stable cs + identities, then inherit a multi-call-site parent's count down to its + stateful callees. Any inherited TA/fixnan variants are materialized + immediately; exporting a count without the corresponding members would + make codegen reference undeclared or shared state. """ from pineforge_codegen.ast_nodes import FuncCall, FuncDef, Identifier - # Collect all user function definitions from AST func_defs: dict[str, FuncDef] = {} for stmt in self._ast.body: if isinstance(stmt, FuncDef): func_defs[stmt.name] = stmt - # Find which functions call which sub-functions (direct calls only) - def _find_calls(node, known_funcs: set[str]) -> set[str]: - calls: set[str] = set() + # Preserve call-node identity as well as the callee name: late clone + # materialization needs the textual call's argument mapping to resolve + # parameterized TA constructor lengths. + def _find_calls(node, known_funcs: set[str]) -> list[tuple[str, FuncCall]]: + calls: list[tuple[str, FuncCall]] = [] if isinstance(node, FuncCall) and isinstance(node.callee, Identifier): if node.callee.name in known_funcs: - calls.add(node.callee.name) + calls.append((node.callee.name, node)) for attr_val in vars(node).values(): if isinstance(attr_val, list): for item in attr_val: if hasattr(item, '__dict__'): - calls |= _find_calls(item, known_funcs) + calls.extend(_find_calls(item, known_funcs)) elif hasattr(attr_val, '__dict__'): - calls |= _find_calls(attr_val, known_funcs) + calls.extend(_find_calls(attr_val, known_funcs)) return calls known_func_names = set(func_defs.keys()) + calls_by_parent = { + name: _find_calls(func_def, known_func_names) + for name, func_def in func_defs.items() + } + + # request.security owns a separate evaluator context and already + # materializes/remaps its embedded TA state per SecurityCallInfo. Do + # not thread ordinary UDF clone indices through a function used as a + # security expression (or through the containing wrapper); doing so + # would double-clone evaluator state and disturb expression identity. + security_boundary_funcs: set[str] = set() + for sec in getattr(self, "_security_calls", []) or []: + containing = getattr(sec, "containing_func", "") or "" + if containing: + security_boundary_funcs.add(containing) + expression = getattr(sec, "expression", None) + if expression is not None: + security_boundary_funcs.update( + sub for sub, _ in _find_calls(expression, known_func_names) + ) + + # Canonical direct-state predicate. TA-only and fixnan-only helpers + # are just as stateful as functions carrying an explicit series/var. + stateful = ( + set(self._func_series_vars) + | set(self._func_var_members) + | set(self._func_ta_ranges) + | set(self._func_fixnan_indices) + ) - # For each multi-call-site function, propagate count to sub-functions - # that have stateful locals (series vars or var members) + # Close upward over the call graph so a pure A -> B -> stateful C chain + # receives variants at every level. The existing clone-only emitter + # marker is intentionally separate from request.security evaluator + # identity, so ordinary security calls remain shared unless the + # dedicated timeframe-monomorphization pass clones them. + changed = True + while changed: + changed = False + for fname, calls in calls_by_parent.items(): + if fname in stateful: + continue + if any(sub in stateful for sub, _ in calls): + stateful.add(fname) + changed = True + + # Direct fixnan-only functions and pure transitive wrappers own no + # TA/series member that would trip the emitter's ordinary body-clone + # gate. Reuse its established body-only clone marker; this does not + # create or renumber any SecurityCallInfo. + for fname in sorted(stateful): + if (fname not in self._func_ta_ranges + and fname not in self._func_series_vars + and fname not in self._func_var_members): + self._func_security_clone_only.add(fname) + + # The initial visitor only numbers directly-stateful callees. Backfill + # stable identities for newly discovered pure wrappers without + # disturbing any indices already assigned by the visitor or security + # monomorphization. + for fname in sorted(stateful): + calls = list(self._iter_user_func_calls(fname)) + current = self._func_call_site_count.get(fname, 0) + next_idx = current + for call in calls: + if id(call) in self._func_inherited_call_nodes: + continue + existing = self._func_call_cs_map.get(id(call)) + if existing is not None and existing[0] == fname: + next_idx = max(next_idx, existing[1] + 1) + continue + self._func_call_cs_map[id(call)] = (fname, next_idx) + next_idx += 1 + if next_idx > current: + self._func_call_site_count[fname] = next_idx + + # Inherit each multi-call-site parent's index space down the full path. + # Re-run to a fixed point for A -> B -> C chains. changed = True while changed: changed = False @@ -505,14 +591,32 @@ def _find_calls(node, known_funcs: set[str]) -> set[str]: continue if fname not in func_defs: continue - sub_calls = _find_calls(func_defs[fname], known_func_names) - for sub in sub_calls: - has_state = (sub in self._func_series_vars or - sub in self._func_var_members) - if not has_state: + if fname in security_boundary_funcs: + continue + for sub, call_node in calls_by_parent.get(fname, []): + if sub not in stateful: continue current = self._func_call_site_count.get(sub, 0) if current < count: + # One textual nested call is not an independent cs0 + # path: it inherits the active parent clone index. + # Remove the visitor's provisional cs0 map so codegen's + # established active-index fallback dispatches + # F_csK -> G_csK. Keeping the map would make the + # context-sensitive instance pre-pass pin every parent + # clone to G_cs0 before that fallback can run. + if current == 1: + cs_info = self._func_call_cs_map.get(id(call_node)) + if cs_info == (sub, 0): + self._func_call_cs_map.pop(id(call_node), None) + self._func_inherited_call_nodes.add(id(call_node)) + for cs_idx in range(current, count): + self._materialize_user_func_call_site_state( + sub, + cs_idx, + call_node, + reuse_existing_owner=fname, + ) self._func_call_site_count[sub] = count changed = True diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index cf90daf..7987c77 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -900,6 +900,170 @@ def _scan_reassign(stmts): defs[stmt.name] = self._expr_to_str(stmt.value) return defs + def _materialize_user_func_call_site_state( + self, func_name: str, cs_idx: int, node: FuncCall, + *, reuse_existing_owner: str | None = None) -> None: + """Materialize TA/fixnan state for one UDF call-site variant. + + Ordinary call sites are handled while walking the AST. A second class + is discovered only after that walk: a stateful helper reached through a + multi-call-site parent needs the parent's additional call-path indices + even though the helper has only one textual call. The late propagation + pass in ``Analyzer._propagate_call_site_counts`` calls this same helper + for those inherited variants so the exported count never references a + TA/fixnan clone that was not actually declared. + + ``reuse_existing_owner`` is used only by late propagation. A + range-widened parent may already have materialized the default + ``{member}_cs{idx}`` clone for the borrowed callee site; when that clone + belongs to the parent currently being propagated, it is the desired + call-path state and must be reused rather than duplicated under a + disambiguated-but-unused name. + """ + func_def = self._func_defs[func_name] + + param_arg_map: dict[str, str] = {} + for p_idx, param_name in enumerate(func_def.params): + if p_idx < len(node.args): + param_arg_map[param_name] = self._expr_to_str(node.args[p_idx]) + + if func_name in self._func_ta_ranges: + start, end = self._func_ta_ranges[func_name] + + # Map local derived length variables back to expressions over the + # function's parameters before substituting call-site arguments. + local_defs = self._func_local_length_defs(func_def) + + def _subst_params(arg: str, pmap: dict[str, str]) -> str: + import re + result = arg + for param, value in sorted( + pmap.items(), key=lambda item: len(item[0]), reverse=True): + result = re.sub(rf'\b{re.escape(param)}\b', value, result) + return result + + def _expand_locals(arg: str) -> str: + import re + if not local_defs: + return arg + for _ in range(32): + def _rep(match: re.Match) -> str: + name = match.group(0) + if name in local_defs: + return "(" + local_defs[name] + ")" + return name + expanded = re.sub(r"[A-Za-z_][A-Za-z_0-9]*", _rep, arg) + if expanded == arg: + break + arg = expanded + return arg + + import re as _re + enclosing_params: set[str] = set() + for names in self._enclosing_func_params: + enclosing_params |= names + + if cs_idx == 0: + # cs0 owns the source-level sites. Preserve their parameterized + # ctor args for every later direct or inherited clone. + for i in range(start, end): + site = self._ta_call_sites[i] + if not hasattr(site, '_orig_ctor_args'): + site._orig_ctor_args = [ + _expand_locals(arg) for arg in site.ctor_args + ] + site.ctor_args = [ + _subst_params(arg, param_arg_map) + for arg in site._orig_ctor_args + ] + # If a ctor is now expressed in an enclosing UDF's params, + # retain that expression so the enclosing call can resolve + # it and widen the enclosing TA range as before. + if enclosing_params and self._nested_ta_touched is not None: + for arg in site.ctor_args: + tokens = set(_re.findall( + r"[A-Za-z_][A-Za-z_0-9]*", arg)) + if tokens & enclosing_params: + site._orig_ctor_args = list(site.ctor_args) + self._nested_ta_touched.add(i) + break + else: + clone_name_map: dict[str, str] = {} + for i in range(start, end): + orig = self._ta_call_sites[i] + orig_args = getattr(orig, '_orig_ctor_args', orig.ctor_args) + resolved_ctor = [ + _subst_params(arg, param_arg_map) for arg in orig_args + ] + clone_name = f"{orig.member_name}_cs{cs_idx}" + existing = next( + (site for site in self._ta_call_sites + if site.member_name == clone_name), + None, + ) + if (reuse_existing_owner is not None + and existing is not None + and existing.owner_func == reuse_existing_owner): + # The active parent's widened range already made the + # exact member this inherited callee variant needs. + continue + if clone_name in self._ta_member_names: + base = clone_name + suffix = 2 + while clone_name in self._ta_member_names: + clone_name = f"{base}_u{suffix}" + suffix += 1 + clone_name_map[orig.member_name] = clone_name + cloned = TACallSite( + member_name=clone_name, + class_name=orig.class_name, + ctor_args=resolved_ctor, + compute_args=orig.compute_args[:], + returns_tuple=orig.returns_tuple, + node=orig.node, + is_static=orig.is_static, + owner_func=func_name, + ) + self._ta_call_sites.append(cloned) + self._ta_member_names.add(clone_name) + if clone_name_map: + self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map + + # fixnan is stateful for the same reason as a rolling TA reducer: each + # emitted function variant needs its own previous-value member. + fn_indices = self._func_fixnan_indices.get(func_name, []) + if cs_idx > 0 and fn_indices: + clone_map: dict[str, str] = {} + for fi in fn_indices: + orig = self._fixnan_sites[fi] + clone_name = f"{orig.member_name}_cs{cs_idx}" + existing = next( + (site for site in self._fixnan_sites + if site.member_name == clone_name), + None, + ) + if (reuse_existing_owner is not None + and existing is not None + and existing.owner_func == reuse_existing_owner): + continue + if clone_name in self._fixnan_member_names: + base = clone_name + suffix = 2 + while clone_name in self._fixnan_member_names: + clone_name = f"{base}_u{suffix}" + suffix += 1 + clone_map[orig.member_name] = clone_name + cloned = FixnanCallSite( + member_name=clone_name, + pine_type=orig.pine_type, + node=orig.node, + owner_func=func_name, + ) + self._fixnan_sites.append(cloned) + self._fixnan_member_names.add(clone_name) + if clone_map: + self._func_cs_fixnan_clone_names[(func_name, cs_idx)] = clone_map + def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: """Handle calls to user-defined functions.""" func_def = self._func_defs[func_name] @@ -964,176 +1128,16 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: else: self._series_vars.add(arg.name) - # Per-call-site cloning: if this function has TA calls or series vars, - # track call sites so codegen can create per-call-site variants. - # This prevents shared state corruption when the function is called - # multiple times per bar. + # Per-call-site cloning: TA, series/var, and fixnan state all advance + # across bars/calls and therefore require isolated UDF variants. has_ta = func_name in self._func_ta_ranges has_series = func_name in self._func_series_vars or func_name in self._func_var_members - if has_ta or has_series: + has_fixnan = func_name in self._func_fixnan_indices + if has_ta or has_series or has_fixnan: cs_idx = self._func_call_site_count.get(func_name, 0) self._func_call_site_count[func_name] = cs_idx + 1 self._func_call_cs_map[id(node)] = (func_name, cs_idx) - - # Build parameter -> call-site argument string mapping - param_arg_map: dict[str, str] = {} - for p_idx, param_name in enumerate(func_def.params): - if p_idx < len(node.args): - param_arg_map[param_name] = self._expr_to_str(node.args[p_idx]) - - # Clone TA call sites (only if function has TA ranges) - if has_ta: - start, end = self._func_ta_ranges[func_name] - - # Map of this function's local (non-param, non-series) derived - # length vars to their raw RHS expression strings, e.g. - # ``qqeCalc`` => ``wp = sf * 2 - 1`` -> {"wp": "sf * 2 - 1"}. - # A TA ctor arg captured as the bare local name ("wp") must be - # expanded to its definition so the subsequent param-substitution - # turns it into a class-scope expression ("rsiSmooth * 2 - 1") - # rather than leaving a dangling local that degenerates to - # period 1 in codegen. - local_defs = self._func_local_length_defs(func_def) - - def _subst_params(arg: str, pmap: dict[str, str]) -> str: - """Substitute parameter names in an expression string. - - Handles both exact matches ('len' -> 'len3') and parameter - names within expressions ('len / 2' -> 'len3 / 2'). - """ - import re - result = arg - # Sort by length descending to avoid partial replacements - for param, value in sorted(pmap.items(), key=lambda x: len(x[0]), reverse=True): - result = re.sub(rf'\b{re.escape(param)}\b', value, result) - return result - - def _expand_locals(arg: str) -> str: - """Recursively expand function-local length vars to their RHS - (parenthesized) so only params / class-scope names remain.""" - import re - if not local_defs: - return arg - for _ in range(32): - def _rep(m: re.Match) -> str: - nm = m.group(0) - if nm in local_defs: - return "(" + local_defs[nm] + ")" - return nm - new = re.sub(r"[A-Za-z_][A-Za-z_0-9]*", _rep, arg) - if new == arg: - break - arg = new - return arg - - # Params of the function we are *currently inside* (if this is a - # nested user-func call). Used to detect when a substituted ctor - # arg becomes parameterized by the OUTER function, so the outer - # call site can resolve it (f_bbwp's _bbwLen -> i_bbwLen reaches - # f_basisMa's sites). - import re as _re - enclosing_params: set[str] = set() - for s in self._enclosing_func_params: - enclosing_params |= s - - if cs_idx == 0: - # First call site: save original param-based ctor_args for future cloning, - # then resolve to actual call-site values - for i in range(start, end): - site = self._ta_call_sites[i] - if not hasattr(site, '_orig_ctor_args'): - site._orig_ctor_args = [ - _expand_locals(a) for a in site.ctor_args - ] - site.ctor_args = [_subst_params(a, param_arg_map) for a in site._orig_ctor_args] - # If a substituted arg is now expressed in terms of an - # enclosing function's params, promote it to the original - # so the enclosing call re-substitutes, and mark the site - # so the enclosing function's TA range widens to cover it. - if enclosing_params and self._nested_ta_touched is not None: - for a in site.ctor_args: - toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", a)) - if toks & enclosing_params: - site._orig_ctor_args = list(site.ctor_args) - self._nested_ta_touched.add(i) - break - else: - # Subsequent call sites: clone using saved original param names, - # substituted with this call site's arguments - clone_name_map: dict[str, str] = {} - for i in range(start, end): - orig = self._ta_call_sites[i] - orig_args = getattr(orig, '_orig_ctor_args', orig.ctor_args) - resolved_ctor = [_subst_params(a, param_arg_map) for a in orig_args] - # Default name follows the ``{base}_cs{cs_idx}`` formula the - # codegen re-derives. But the SAME base TA site can be reached - # through more than one enclosing function (e.g. a helper cloned - # both via its own call sites AND via a range-widened outer - # function), so two distinct (func, cs_idx) namespaces can mint - # the same name. Detect that collision and fall back to a - # globally-unique name; record the chosen name so the codegen - # consumes it verbatim (see _func_cs_ta_clone_names). - clone_name = f"{orig.member_name}_cs{cs_idx}" - if clone_name in self._ta_member_names: - base = clone_name - n = 2 - while clone_name in self._ta_member_names: - clone_name = f"{base}_u{n}" - n += 1 - clone_name_map[orig.member_name] = clone_name - cloned = TACallSite( - member_name=clone_name, - class_name=orig.class_name, - ctor_args=resolved_ctor, - compute_args=orig.compute_args[:], - returns_tuple=orig.returns_tuple, - node=orig.node, - is_static=orig.is_static, - # The clone belongs to the CALLEE's per-call-site - # namespace (``func_name``), NOT the caller whose - # body visit minted it. The codegen dead-code pass - # uses owner_func to decide whether to drop the - # declaration: a clone owned by a live callee must - # survive even when minted during a dead caller's - # body visit (regression: quantbyboji DMI). - owner_func=func_name, - ) - self._ta_call_sites.append(cloned) - self._ta_member_names.add(clone_name) - if clone_name_map: - self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map - - # Clone fixnan sites for cs_idx > 0 so each emitted variant of - # this function references its OWN previous-value member (two - # call sites must not share fixnan state -- the second would - # otherwise read the first's last non-na value). cs0 keeps the - # originals. The clone name follows the ``{orig}_cs{cs_idx}`` - # formula the codegen re-derives (mirrors TA clone naming). - fn_indices = self._func_fixnan_indices.get(func_name, []) - if cs_idx > 0 and fn_indices: - clone_map: dict[str, str] = {} - for fi in fn_indices: - orig = self._fixnan_sites[fi] - fn_clone_name = f"{orig.member_name}_cs{cs_idx}" - # Disambiguate collisions (a fixnan site reached through - # >1 enclosing function could collide on the formula). - if fn_clone_name in self._fixnan_member_names: - base = fn_clone_name - n = 2 - while fn_clone_name in self._fixnan_member_names: - fn_clone_name = f"{base}_u{n}" - n += 1 - clone_map[orig.member_name] = fn_clone_name - cloned_fn = FixnanCallSite( - member_name=fn_clone_name, - pine_type=orig.pine_type, - node=orig.node, - owner_func=func_name, - ) - self._fixnan_sites.append(cloned_fn) - self._fixnan_member_names.add(fn_clone_name) - if clone_map: - self._func_cs_fixnan_clone_names[(func_name, cs_idx)] = clone_map + self._materialize_user_func_call_site_state(func_name, cs_idx, node) # Create or update FuncInfo is_tuple = self._func_returns_tuple.get(func_name, False) diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index b4d2771..a2c5bf2 100644 --- a/tests/test_codegen_new.py +++ b/tests/test_codegen_new.py @@ -1438,6 +1438,108 @@ def _ta_decls_and_computed(cpp: str): return decls, computed +def test_nested_ta_only_helper_clones_full_call_path_state(): + """A TA-only callee inherits every stateful outer call path. + + ``inner`` deliberately owns no ``var``/series state: its only state is the + rolling ``math.sum`` member. The two outer calls consume different source + streams, so sharing one sum would advance the same window twice per bar and + make the runtime results order-dependent. + """ + cpp = _generate( + """ +//@version=6 +strategy("nested TA-only call paths") +inner(float src) => + math.sum(src, 2) +outer(float src) => + inner(src) +close_sum = outer(close) +open_sum = outer(open) +plot(close_sum) +plot(open_sum) +""" + ) + + sums = _re.findall(r"math::Sum\s+(_ta_sum_\w+);", cpp) + assert len(sums) == 2, f"expected two independent Sum members, got {sums}" + + outer_bodies = dict(_re.findall( + r"double\s+(outer_cs[01])\(double src\)\s*\{(.*?)\n \}", cpp, _re.S + )) + assert "inner_cs0(src)" in outer_bodies.get("outer_cs0", ""), outer_bodies + assert "inner_cs1(src)" in outer_bodies.get("outer_cs1", ""), outer_bodies + + inner_bodies = dict(_re.findall( + r"double\s+(inner_cs[01])\(double src\)\s*\{(.*?)\n \}", cpp, _re.S + )) + used_by_inner = {} + for name in ("inner_cs0", "inner_cs1"): + hits = _re.findall(r"(_ta_sum_\w+)\.(?:compute|recompute)\(src\)", + inner_bodies.get(name, "")) + assert hits, f"{name} does not compute its own Sum member: {inner_bodies}" + used_by_inner[name] = set(hits) + assert used_by_inner["inner_cs0"].isdisjoint(used_by_inner["inner_cs1"]), ( + f"nested call paths share rolling state: {used_by_inner}" + ) + + computed = set(_re.findall(r"(_ta_sum_\w+)\.(?:compute|recompute)\(", cpp)) + assert set(sums) == computed, ( + f"declared/computed Sum mismatch (unused clone or dangling use): " + f"declared={set(sums)}, computed={computed}" + ) + + # Distinct top-level sources must dispatch through distinct outer variants; + # together with the disjoint members above, this pins runtime independence. + assert "close_sum = outer_cs0(current_bar_.close);" in cpp + assert "open_sum = outer_cs1(current_bar_.open);" in cpp + + +def test_nested_fixnan_only_helper_clones_full_call_path_state(): + """The same inherited isolation applies when fixnan is the only state.""" + cpp = _generate( + """ +//@version=6 +strategy("nested fixnan-only call paths") +inner(float src) => + fixnan(src) +outer(float src) => + inner(src) +close_held = outer(close) +open_held = outer(open) +plot(close_held) +plot(open_held) +""" + ) + + members = _re.findall(r"double\s+(_prev_fixnan_\w+)\s*=\s*na", cpp) + assert len(members) == 2, f"expected two fixnan members, got {members}" + + outer_bodies = dict(_re.findall( + r"double\s+(outer_cs[01])\(double src\)\s*\{(.*?)\n \}", cpp, _re.S + )) + assert "inner_cs0(src)" in outer_bodies.get("outer_cs0", ""), outer_bodies + assert "inner_cs1(src)" in outer_bodies.get("outer_cs1", ""), outer_bodies + + inner_bodies = dict(_re.findall( + r"double\s+(inner_cs[01])\(double src\)\s*\{(.*?)\n \}", cpp, _re.S + )) + used = {} + for name in ("inner_cs0", "inner_cs1"): + hits = set(_re.findall(r"(_prev_fixnan_\w+)\s*=\s*src", + inner_bodies.get(name, ""))) + assert hits, f"{name} does not update its own fixnan member: {inner_bodies}" + used[name] = hits + assert used["inner_cs0"].isdisjoint(used["inner_cs1"]), ( + f"nested call paths share fixnan state: {used}" + ) + assert set(members) == used["inner_cs0"] | used["inner_cs1"], ( + f"declared-but-unused fixnan clone: declared={members}, used={used}" + ) + assert "close_held = outer_cs0(current_bar_.close);" in cpp + assert "open_held = outer_cs1(current_bar_.open);" in cpp + + 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(