From b576e34ae09d969a95a3a65a99f92a1620d7c514 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 20 Jul 2026 20:39:50 +0800 Subject: [PATCH 1/5] fix: forward TA lengths through UDT methods --- pineforge_codegen/analyzer/base.py | 133 ++++++++- pineforge_codegen/analyzer/call_handlers.py | 33 ++- pineforge_codegen/analyzer/contracts.py | 4 + pineforge_codegen/codegen/base.py | 5 +- tests/test_callable_ta_forwarded_length.py | 297 ++++++++++++++++++++ 5 files changed, 454 insertions(+), 18 deletions(-) create mode 100644 tests/test_callable_ta_forwarded_length.py diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index d107f25..f6a8386 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -274,6 +274,15 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._func_nonpersistent_series_vars: dict[str, set[str]] = {} # Per-call-site TA tracking for user functions self._func_ta_ranges: dict[str, tuple[int, int]] = {} # func_name -> (start, end) indices + # Exact counterpart to the legacy contiguous ranges. Borrowed nested + # sites can straddle unrelated TA allocations, so constructor argument + # substitution and codegen remaps must iterate these identities only. + self._func_ta_indices: dict[str, list[int]] = {} + # A shared TA site can be viewed through more than one callable's + # parameter names (innerLen -> outerLen). Preserve one constructor + # template per callable/site instead of mutating the site's single + # source template and relying on same-spelled forwarding parameters. + self._func_ta_ctor_args: dict[str, dict[int, list[str]]] = {} 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) # Primitive type facts retained per written call AST and reconciled to @@ -542,6 +551,7 @@ def analyze(self) -> AnalyzerContext: var_member_type_specs_by_node=self._var_member_type_specs_by_node, var_member_owners_by_node=self._var_member_owners_by_node, func_ta_ranges=self._func_ta_ranges, + func_ta_indices=self._func_ta_indices, func_call_cs_map=self._func_call_cs_map, func_call_site_counts=self._func_call_site_count, func_callsite_param_types={ @@ -2237,6 +2247,63 @@ def _find_calls(node, known_funcs: set[str], calls_by_callee.setdefault(callee, []).append(call) call_edges.append((owner, callee, call)) + # A source-ordered method visit now threads TA constructor arguments + # through any already-known method callee. Keep the same behavior for + # a forward-defined method: when ``outer`` was visited before ``inner`` + # there was no FuncInfo/range to materialize yet, but the complete + # call graph above can resolve that edge now. Process only still- + # unnumbered nested UDT-method edges and close to a fixed point so a + # reverse-ordered ``outer -> middle -> inner(TA)`` chain is covered. + # The selected TA slice becomes part of the caller's range; a later + # top-level call can then substitute its input through every boundary. + late_method_ta_changed = True + while late_method_ta_changed: + late_method_ta_changed = False + for owner, callee, call in call_edges: + if owner is None or id(call) in self._func_call_cs_map: + continue + callee_info = func_info_by_name.get(callee) + if ( + callee_info is None + or not getattr(callee_info, "is_udt_method", False) + or callee not in self._func_ta_ranges + ): + continue + + source_indices = list(self._func_ta_indices.get(callee, ())) + if not source_indices: + source_range = self._func_ta_ranges[callee] + source_indices = list(range(*source_range)) + cs_idx = self._func_call_site_count.get(callee, 0) + before = len(self._ta_call_sites) + self._func_call_site_count[callee] = cs_idx + 1 + self._func_call_cs_map[id(call)] = (callee, cs_idx) + self._materialize_user_func_call_site_state( + callee, cs_idx, call + ) + + selected_indices = ( + source_indices + if cs_idx == 0 + else list(range(before, len(self._ta_call_sites))) + ) + if selected_indices: + current_indices = self._func_ta_indices.setdefault(owner, []) + current_indices[:] = sorted( + set(current_indices) | set(selected_indices) + ) + self._func_ta_ranges[owner] = ( + min(current_indices), max(current_indices) + 1 + ) + owner_templates = self._func_ta_ctor_args.setdefault( + owner, {} + ) + for index in selected_indices: + owner_templates[index] = list( + self._ta_call_sites[index].ctor_args + ) + late_method_ta_changed = True + # A history-reading callable receives ``Series`` parameters. That # requirement must flow outward through every wrapper parameter that # is forwarded unchanged; otherwise a wrapper stays ``double`` and @@ -3854,12 +3921,19 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: # (e.g. f_basisMa's sites parameterized by f_bbwp's _bbwLen), so resolving # this function at its call site re-substitutes those nested sites too. ta_end = len(self._ta_call_sites) - lo, hi = ta_start, ta_end - if nested_touched: - lo = min(lo, min(nested_touched)) - hi = max(hi, max(nested_touched) + 1) - if hi > lo: - self._func_ta_ranges[node.name] = (lo, hi) + exact_indices = sorted( + set(range(ta_start, ta_end)) | set(nested_touched or ()) + ) + if exact_indices: + self._func_ta_indices[node.name] = exact_indices + self._func_ta_ranges[node.name] = ( + min(exact_indices), max(exact_indices) + 1 + ) + templates = self._func_ta_ctor_args.setdefault(node.name, {}) + for index in exact_indices: + templates.setdefault( + index, list(self._ta_call_sites[index].ctor_args) + ) inferred_param_specs = self._param_type_specs_from_def(node) for i, param in enumerate(node.params): @@ -4188,12 +4262,19 @@ def _visit_MethodDef(self, node) -> PineType: # UDFs. Preserve their owned and nested-callee TA range under the # canonical ``Type.method`` identity so written call sites can clone it. ta_end = len(self._ta_call_sites) - lo, hi = ta_start, ta_end - if nested_touched: - lo = min(lo, min(nested_touched)) - hi = max(hi, max(nested_touched) + 1) - if hi > lo: - self._func_ta_ranges[method_key] = (lo, hi) + exact_indices = sorted( + set(range(ta_start, ta_end)) | set(nested_touched or ()) + ) + if exact_indices: + self._func_ta_indices[method_key] = exact_indices + self._func_ta_ranges[method_key] = ( + min(exact_indices), max(exact_indices) + 1 + ) + templates = self._func_ta_ctor_args.setdefault(method_key, {}) + for index in exact_indices: + templates.setdefault( + index, list(self._ta_call_sites[index].ctor_args) + ) self._symbols.exit_scope() if method_udt_return is not None: self._func_udt_return_types[method_key] = method_udt_return @@ -4740,6 +4821,34 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: arg_sym = self._symbols.resolve(arg.name) if arg_sym is not None: arg_sym.is_series = True + # Materialize TA state while the lexical caller is still + # active, just as ``_handle_user_func_call`` does for a + # bare UDF call. Deferring every UDT-method call to the + # whole-program call-graph pass loses the enclosing + # callable's parameter stack: ``outer(self, len) => + # self.inner(len)`` then leaves ``inner``'s constructor + # argument as the bare local name ``len`` instead of + # threading the eventual top-level input through both + # boundaries. Besides substituting the arguments, the + # cs0 path records the borrowed TA site in + # ``_nested_ta_touched`` so the caller's range is widened + # and can be resolved again at its own call site. + if method_key in self._func_ta_ranges: + existing_site = self._func_call_cs_map.get(id(node)) + if ( + existing_site is None + or existing_site[0] != method_key + ): + cs_idx = self._func_call_site_count.get( + method_key, 0 + ) + self._func_call_site_count[method_key] = cs_idx + 1 + self._func_call_cs_map[id(node)] = ( + method_key, cs_idx + ) + self._materialize_user_func_call_site_state( + method_key, cs_idx, node + ) return self._callsite_callable_return_type( method_info.node, full_param_types, diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 3a7be9b..48cae7c 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -1272,6 +1272,12 @@ def _materialize_user_func_call_site_state( if func_name in self._func_ta_ranges: start, end = self._func_ta_ranges[func_name] + site_indices = list(self._func_ta_indices.get(func_name, ())) + if not site_indices: + site_indices = list(range(start, end)) + func_ctor_templates = self._func_ta_ctor_args.setdefault( + func_name, {} + ) # Map local derived length variables back to expressions over the # function's parameters before substituting call-site arguments. @@ -1309,15 +1315,26 @@ def _rep(match: re.Match) -> str: 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): + for i in site_indices: 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 ] + # Each callable owns its own view of a borrowed site's + # constructor expression. Expand locals in that owner view + # before substituting actual parameters; a template first + # recorded at definition time can still contain a local + # alias such as ``effectiveLen``. + func_ctor_templates[i] = [ + _expand_locals(arg) + for arg in func_ctor_templates.get( + i, site._orig_ctor_args + ) + ] site.ctor_args = [ _subst_params(arg, param_arg_map) - for arg in site._orig_ctor_args + for arg in func_ctor_templates[i] ] # If a ctor is now expressed in an enclosing UDF's params, # retain that expression so the enclosing call can resolve @@ -1327,14 +1344,20 @@ def _rep(match: re.Match) -> str: 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) + if self._enclosing_func_names: + caller = self._enclosing_func_names[-1] + self._func_ta_ctor_args.setdefault( + caller, {} + )[i] = list(site.ctor_args) self._nested_ta_touched.add(i) break else: clone_name_map: dict[str, str] = {} - for i in range(start, end): + for i in site_indices: orig = self._ta_call_sites[i] - orig_args = getattr(orig, '_orig_ctor_args', orig.ctor_args) + orig_args = func_ctor_templates.get( + i, getattr(orig, '_orig_ctor_args', orig.ctor_args) + ) resolved_ctor = [ _subst_params(arg, param_arg_map) for arg in orig_args ] diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index ce76104..3996af7 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -237,6 +237,10 @@ class AnalyzerContext: var_member_owners_by_node: dict = field(default_factory=dict) # Per-call-site TA member cloning for user functions: func_ta_ranges: dict = field(default_factory=dict) # func_name -> (start_idx, end_idx) + # Exact TA indices belonging to each callable path. ``func_ta_ranges`` is + # retained as the legacy stateful-callable marker, but a widened min/max + # range can contain unrelated sites allocated between two borrowed sites. + func_ta_indices: dict = field(default_factory=dict) # func_name -> [site_idx, ...] func_call_cs_map: dict = field(default_factory=dict) # call_node_id -> (func_name, call_site_index) func_call_site_counts: dict = field(default_factory=dict) # func_name -> int # Exact primitive parameter/return types for each emitted written-callsite diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 131ce00..c7a5276 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -438,7 +438,10 @@ def func_var_storage(owner: str, raw_name: str) -> str: # Build TA site map and per-call-site remapping func_ta_originals: dict[str, list[str]] = {} # func_name -> list of original member names for fname, (start, end) in ctx.func_ta_ranges.items(): - orig_names = [ctx.ta_call_sites[i].member_name for i in range(start, end)] + indices = (ctx.func_ta_indices or {}).get(fname) + if indices is None: + indices = range(start, end) + orig_names = [ctx.ta_call_sites[i].member_name for i in indices] func_ta_originals[fname] = orig_names self._func_ta_members.update(orig_names) # cs0 uses originals (identity mapping) diff --git a/tests/test_callable_ta_forwarded_length.py b/tests/test_callable_ta_forwarded_length.py new file mode 100644 index 0000000..5126ae4 --- /dev/null +++ b/tests/test_callable_ta_forwarded_length.py @@ -0,0 +1,297 @@ +"""TA constructor lengths forwarded through callable boundaries. + +The probes in this module are authored as compact Pine v6 reductions. They +exercise the call-path argument propagation used to size stateful TA helpers; +the generated C++ must never retain a callable-local ``len`` constructor arg. +""" + +import re + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests.test_runtime_var_initialization import _compile_and_run + + +def _ctor_periods(cpp: str, ta_name: str = "sma") -> list[str]: + members = re.findall(rf"(_ta_{ta_name}_\d+(?:_[A-Za-z0-9]+)*)\(", cpp) + periods: list[str] = [] + for member in members: + match = re.search(rf"\b{re.escape(member)}\(([^)]*)\)", cpp) + assert match is not None + periods.append(match.group(1).strip()) + return periods + + +def _reset_lines(cpp: str, ta_name: str = "sma") -> list[str]: + return [ + line.strip() + for line in cpp.splitlines() + if re.match( + rf"_ta_{ta_name}_\d+(?:_[A-Za-z0-9]+)*\s*=\s*ta::", + line.strip(), + ) + ] + + +def test_direct_udf_parameter_length_control() -> None: + source = '''//@version=6 +strategy("direct UDF length") +smooth(float src, int len) => ta.sma(src, len) +n = input.int(3, "Len") +out = smooth(close, n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp) == ["3"] + assert any('get_input_int("Len", 3)' in line for line in _reset_lines(cpp)) + + +def test_nested_udf_parameter_length_control() -> None: + source = '''//@version=6 +strategy("nested UDF length") +inner(float src, int len) => ta.sma(src, len) +outer(float src, int len) => inner(src, len) +n = input.int(4, "Len") +out = outer(close, n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp) == ["4"] + assert any('get_input_int("Len", 4)' in line for line in _reset_lines(cpp)) + + +def test_direct_method_parameter_length() -> None: + source = '''//@version=6 +strategy("direct method length") +type Holder + float seed +method smooth(Holder self, float src, int len) => ta.sma(src, len) +var Holder holder = Holder.new(0.0) +n = input.int(5, "Len") +out = holder.smooth(close, n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp) == ["5"] + assert any('get_input_int("Len", 5)' in line for line in _reset_lines(cpp)) + + +def test_sibling_method_forwards_parameter_length() -> None: + source = '''//@version=6 +strategy("sibling method length") +type Holder + float seed +method inner(Holder self, int len) => ta.sma(close, len) +method outer(Holder self, int len) => self.inner(len) +var Holder holder = Holder.new(0.0) +n = input.int(6, "Len") +out = holder.outer(n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp) == ["6"] + assert any('get_input_int("Len", 6)' in line for line in _reset_lines(cpp)) + + +def test_udf_wrapper_receiver_forwards_parameter_length() -> None: + source = '''//@version=6 +strategy("wrapper receiver length") +type Holder + float seed +method inner(Holder self, int len) => ta.sma(close, len) +wrapped(Holder obj, int len) => obj.inner(len) +var Holder holder = Holder.new(0.0) +n = input.int(7, "Len") +out = wrapped(holder, n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp) == ["7"] + assert any('get_input_int("Len", 7)' in line for line in _reset_lines(cpp)) + + +def test_forward_defined_sibling_method_forwards_parameter_length() -> None: + source = '''//@version=6 +strategy("forward sibling method length") +type Holder + float seed +method outer(Holder self, int len) => self.inner(len) +method inner(Holder self, int len) => ta.sma(close, len) +var Holder holder = Holder.new(0.0) +n = input.int(10, "Len") +out = holder.outer(n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp) == ["10"] + assert any('get_input_int("Len", 10)' in line for line in _reset_lines(cpp)) + + +def test_forward_defined_sibling_method_composes_renamed_parameters() -> None: + source = '''//@version=6 +strategy("forward renamed sibling method length") +type Holder + float seed +method outer(Holder self, int outerLen) => self.inner(outerLen) +method inner(Holder self, int innerLen) => ta.sma(close, innerLen) +var Holder holder = Holder.new(0.0) +n = input.int(2, "Len") +out = holder.outer(n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp) == ["2"] + assert "innerLen" not in "\n".join(_reset_lines(cpp)) + assert any('get_input_int("Len", 2)' in line for line in _reset_lines(cpp)) + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, 20.0, 1.0, 61000}, + Bar{3.0, 31.0, 2.0, 30.0, 1.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.out << "\n"; +} +''' + assert _compile_and_run(cpp + driver) == "25\n" + + +def test_widened_caller_indices_exclude_interleaved_unrelated_ta() -> None: + source = '''//@version=6 +strategy("interleaved unrelated TA length") +type Holder + float seed +method inner(Holder self, int len) => ta.sma(close, len) +method unrelated(Holder self, int len) => ta.ema(close, len) +method outer(Holder self, int len) => + own = ta.rma(close, len) + own + self.inner(len) +var Holder holder = Holder.new(0.0) +n = input.int(2, "Len") +unrelatedOut = holder.unrelated(20) +out = holder.outer(n) +plot(out + unrelatedOut) +''' + cpp = transpile(source) + assert _ctor_periods(cpp, "sma") == ["2"] + assert _ctor_periods(cpp, "rma") == ["2"] + assert _ctor_periods(cpp, "ema") == ["20"] + assert _reset_lines(cpp, "ema") == [] + driver = r''' +#include +#include +int main() { + std::vector bars; + for (int i = 1; i <= 21; ++i) { + bars.push_back(Bar{ + double(i), double(i), double(i), double(i), 1.0, + 1000LL + 60000LL * i, + }); + } + GeneratedStrategy strategy; + strategy.run(bars.data(), bars.size()); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.unrelatedOut << "\n"; +} +''' + assert _compile_and_run(cpp + driver) == "12.7835\n" + + +def test_reverse_defined_three_method_chain_forwards_parameter_length() -> None: + source = '''//@version=6 +strategy("reverse method chain length") +type Holder + float seed +method outer(Holder self, int len) => self.middle(len) +method middle(Holder self, int len) => self.inner(len) +method inner(Holder self, int len) => ta.sma(close, len) +var Holder holder = Holder.new(0.0) +n = input.int(11, "Len") +out = holder.outer(n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp) == ["11"] + assert any('get_input_int("Len", 11)' in line for line in _reset_lines(cpp)) + + +@pytest.mark.parametrize("forward_defined", [False, True]) +def test_branch_wrapper_and_two_callsites_keep_distinct_lengths( + forward_defined: bool, +) -> None: + inner = ( + "method inner(Holder self, float src, int len) => ta.ema(src, len)" + ) + outer = '''method outer(Holder self, float src, int len, bool choose) => + choose ? self.inner(src, len) : self.inner(src, len + 1)''' + method_defs = f"{outer}\n{inner}" if forward_defined else f"{inner}\n{outer}" + source = f'''//@version=6 +strategy("branch and callsite lengths") +type Holder + float seed +{method_defs} +var Holder holder = Holder.new(0.0) +fast = input.int(8, "Fast") +slow = input.int(13, "Slow") +a = holder.outer(close, fast, true) +b = holder.outer(open, slow, false) +plot(a + b) +''' + cpp = transpile(source) + periods = _ctor_periods(cpp, "ema") + assert periods == ["8", "9", "13", "14"] + resets = _reset_lines(cpp, "ema") + assert sum('get_input_int("Fast", 8)' in line for line in resets) == 2 + assert sum('get_input_int("Slow", 13)' in line for line in resets) == 2 + + +def test_series_dynamic_length_remains_rejected_through_method_wrapper() -> None: + source = '''//@version=6 +strategy("dynamic method length guard") +type Holder + float seed +method inner(Holder self, int len) => ta.sma(close, len) +method outer(Holder self, int len) => self.inner(len) +var Holder holder = Holder.new(0.0) +out = holder.outer(int(ta.atr(14))) +plot(out) +''' + with pytest.raises(CompileError) as exc: + transpile(source) + assert "Unsupported TA constructor length" in str(exc.value) + + +def test_two_method_callsites_run_with_distinct_forwarded_lengths() -> None: + source = '''//@version=6 +strategy("runtime forwarded lengths") +type Holder + float seed +method inner(Holder self, float src, int len) => ta.sma(src, len) +method outer(Holder self, float src, int len) => self.inner(src, len) +var Holder holder = Holder.new(0.0) +fast = input.int(2, "Fast") +slow = input.int(3, "Slow") +a = holder.outer(close, fast) +b = holder.outer(open, slow) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, 20.0, 1.0, 61000}, + Bar{3.0, 31.0, 2.0, 30.0, 1.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.a << " " << strategy.b << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "25 2\n" From 57946f6dc8f98aa1ee9fdd61430d60dcb780f170 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 20 Jul 2026 22:50:44 +0800 Subject: [PATCH 2/5] fix: close forwarded TA ownership propagation --- pineforge_codegen/analyzer/base.py | 165 ++++++++++++++++---- pineforge_codegen/analyzer/call_handlers.py | 85 ++++++++-- pineforge_codegen/codegen/base.py | 11 +- pineforge_codegen/codegen/emit_top.py | 11 ++ tests/test_callable_ta_forwarded_length.py | 116 ++++++++++++++ 5 files changed, 342 insertions(+), 46 deletions(-) diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index f6a8386..32d43dd 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -283,6 +283,17 @@ def __init__(self, ast: Program, filename: str = "") -> None: # template per callable/site instead of mutating the site's single # source template and relying on same-spelled forwarding parameters. self._func_ta_ctor_args: dict[str, dict[int, list[str]]] = {} + # Exact TA-site mapping and source-template snapshot for each textual + # callable edge. Late whole-program propagation uses these to revisit + # an already-numbered edge when its callee gains another owned TA site + # (or refines an existing per-owner constructor template), without + # cloning the edge's previously-materialized state a second time. + self._func_ta_call_targets: dict[ + tuple[int, int], dict[int, int] + ] = {} + self._func_ta_call_templates: dict[ + int, dict[int, tuple[str, ...]] + ] = {} 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) # Primitive type facts retained per written call AST and reconciled to @@ -2247,20 +2258,24 @@ def _find_calls(node, known_funcs: set[str], calls_by_callee.setdefault(callee, []).append(call) call_edges.append((owner, callee, call)) - # A source-ordered method visit now threads TA constructor arguments - # through any already-known method callee. Keep the same behavior for - # a forward-defined method: when ``outer`` was visited before ``inner`` - # there was no FuncInfo/range to materialize yet, but the complete - # call graph above can resolve that edge now. Process only still- - # unnumbered nested UDT-method edges and close to a fixed point so a - # reverse-ordered ``outer -> middle -> inner(TA)`` chain is covered. - # The selected TA slice becomes part of the caller's range; a later - # top-level call can then substitute its input through every boundary. - late_method_ta_changed = True - while late_method_ta_changed: + # A source-ordered method visit threads TA constructor arguments through + # any already-known method callee. Forward definitions require the + # complete call graph above, and an edge may need revisiting even after + # it has a cs identity: its callee can acquire another exact TA owner (or + # a refined per-owner ctor template) later in this same closure. + # + # Track the exact source-index/template snapshot materialized for each + # textual edge. Only the delta is materialized on a later round, which + # prevents cs>0 state from being cloned twice. A finite callable graph + # converges in at most its path depth; retain a conservative explicit + # bound and fail closed rather than looping on a recursive TA cycle. + max_ta_rounds = max(2, len(func_defs) + len(call_edges) + 2) + last_changed_call: FuncCall | None = None + for _round in range(max_ta_rounds): late_method_ta_changed = False for owner, callee, call in call_edges: - if owner is None or id(call) in self._func_call_cs_map: + call_id = id(call) + if call_id in self._func_inherited_call_nodes: continue callee_info = func_info_by_name.get(callee) if ( @@ -2274,23 +2289,74 @@ def _find_calls(node, known_funcs: set[str], if not source_indices: source_range = self._func_ta_ranges[callee] source_indices = list(range(*source_range)) - cs_idx = self._func_call_site_count.get(callee, 0) - before = len(self._ta_call_sites) - self._func_call_site_count[callee] = cs_idx + 1 - self._func_call_cs_map[id(call)] = (callee, cs_idx) - self._materialize_user_func_call_site_state( - callee, cs_idx, call + callee_templates = self._func_ta_ctor_args.get(callee, {}) + processed_templates = self._func_ta_call_templates.get( + call_id, {} ) + existing_site = self._func_call_cs_map.get(call_id) + candidate_cs_idx = ( + existing_site[1] + if existing_site is not None and existing_site[0] == callee + else self._func_call_site_count.get(callee, 0) + ) + selected_targets = self._func_ta_call_targets.get( + (call_id, candidate_cs_idx), {} + ) + owner_indices = ( + set(self._func_ta_indices.get(owner, ())) + if owner is not None + else set() + ) + owner_templates = ( + self._func_ta_ctor_args.get(owner, {}) + if owner is not None + else {} + ) + stale_indices: list[int] = [] + for index in source_indices: + site = self._ta_call_sites[index] + source_template = tuple( + callee_templates.get( + index, + getattr(site, "_orig_ctor_args", site.ctor_args), + ) + ) + target = selected_targets.get(index) + if ( + processed_templates.get(index) != source_template + or target is None + or ( + owner is not None + and ( + target not in owner_indices + or target not in owner_templates + ) + ) + ): + stale_indices.append(index) + if not stale_indices: + continue - selected_indices = ( - source_indices - if cs_idx == 0 - else list(range(before, len(self._ta_call_sites))) + if existing_site is not None and existing_site[0] == callee: + cs_idx = existing_site[1] + materialize_fixnan = False + else: + cs_idx = candidate_cs_idx + self._func_call_site_count[callee] = cs_idx + 1 + self._func_call_cs_map[call_id] = (callee, cs_idx) + materialize_fixnan = True + + selected = self._materialize_user_func_call_site_state( + callee, + cs_idx, + call, + ta_site_indices=stale_indices, + materialize_fixnan=materialize_fixnan, ) - if selected_indices: + if selected and owner is not None: current_indices = self._func_ta_indices.setdefault(owner, []) current_indices[:] = sorted( - set(current_indices) | set(selected_indices) + set(current_indices) | set(selected.values()) ) self._func_ta_ranges[owner] = ( min(current_indices), max(current_indices) + 1 @@ -2298,11 +2364,20 @@ def _find_calls(node, known_funcs: set[str], owner_templates = self._func_ta_ctor_args.setdefault( owner, {} ) - for index in selected_indices: - owner_templates[index] = list( - self._ta_call_sites[index].ctor_args + for target in selected.values(): + owner_templates[target] = list( + self._ta_call_sites[target].ctor_args ) late_method_ta_changed = True + last_changed_call = call + if not late_method_ta_changed: + break + else: + self._error( + "Callable TA ownership propagation did not converge; " + "recursive stateful call paths are unsupported.", + last_changed_call.loc if last_changed_call is not None else None, + ) # A history-reading callable receives ``Series`` parameters. That # requirement must flow outward through every wrapper parameter that @@ -2535,6 +2610,32 @@ def _has_synthetic_history_state( # Inherit each multi-call-site parent's index space down the full path. # Re-run to a fixed point for A -> B -> C chains. + def _ta_variant_target_indices( + func_name: str, cs_idx: int + ) -> set[int]: + source_indices = list(self._func_ta_indices.get(func_name, ())) + if not source_indices and func_name in self._func_ta_ranges: + source_indices = list(range(*self._func_ta_ranges[func_name])) + if cs_idx == 0: + return set(source_indices) + overrides = self._func_cs_ta_clone_names.get( + (func_name, cs_idx), {} + ) + by_member = { + site.member_name: index + for index, site in enumerate(self._ta_call_sites) + } + targets: set[int] = set() + for source_index in source_indices: + source_name = self._ta_call_sites[source_index].member_name + target_name = overrides.get( + source_name, f"{source_name}_cs{cs_idx}" + ) + target_index = by_member.get(target_name) + if target_index is not None: + targets.add(target_index) + return targets + changed = True while changed: changed = False @@ -2574,11 +2675,15 @@ def _has_synthetic_history_state( (sub, 0), None ) for cs_idx in range(current, count): + parent_ta_targets = _ta_variant_target_indices( + fname, cs_idx + ) self._materialize_user_func_call_site_state( sub, cs_idx, call_node, reuse_existing_owner=fname, + reuse_existing_targets=parent_ta_targets, ) self._func_call_site_count[sub] = count changed = True @@ -4846,9 +4951,9 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: self._func_call_cs_map[id(node)] = ( method_key, cs_idx ) - self._materialize_user_func_call_site_state( - method_key, cs_idx, node - ) + self._materialize_user_func_call_site_state( + method_key, cs_idx, node + ) return self._callsite_callable_return_type( method_info.node, full_param_types, diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 48cae7c..183ffc4 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -1214,7 +1214,10 @@ def _scan_reassign(stmts): def _materialize_user_func_call_site_state( self, func_name: str, cs_idx: int, node: FuncCall, - *, reuse_existing_owner: str | None = None) -> None: + *, reuse_existing_owner: str | None = None, + reuse_existing_targets: set[int] | None = None, + ta_site_indices: list[int] | None = None, + materialize_fixnan: bool = True) -> dict[int, int]: """Materialize TA/fixnan state for one UDF call-site variant. Ordinary call sites are handled while walking the AST. A second class @@ -1230,7 +1233,9 @@ def _materialize_user_func_call_site_state( ``{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. + disambiguated-but-unused name. ``reuse_existing_targets`` extends that + proof through another borrowed layer: the immediate parent's active + remap can legitimately target a clone owned by its own caller. """ func_def = self._func_defs.get(func_name) method_info = None @@ -1251,7 +1256,9 @@ def _materialize_user_func_call_site_state( f"Cannot materialize callable state for unknown function '{func_name}'.", node.loc, ) - return + return {} + + selected_ta_indices: dict[int, int] = {} param_arg_map: dict[str, str] = {} positional_args = list(node.args) @@ -1272,8 +1279,12 @@ def _materialize_user_func_call_site_state( if func_name in self._func_ta_ranges: start, end = self._func_ta_ranges[func_name] - site_indices = list(self._func_ta_indices.get(func_name, ())) - if not site_indices: + site_indices = ( + list(ta_site_indices) + if ta_site_indices is not None + else list(self._func_ta_indices.get(func_name, ())) + ) + if ta_site_indices is None and not site_indices: site_indices = list(range(start, end)) func_ctor_templates = self._func_ta_ctor_args.setdefault( func_name, {} @@ -1336,6 +1347,7 @@ def _rep(match: re.Match) -> str: _subst_params(arg, param_arg_map) for arg in func_ctor_templates[i] ] + selected_ta_indices[i] = i # 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. @@ -1355,23 +1367,53 @@ def _rep(match: re.Match) -> str: clone_name_map: dict[str, str] = {} for i in site_indices: orig = self._ta_call_sites[i] - orig_args = func_ctor_templates.get( - i, getattr(orig, '_orig_ctor_args', orig.ctor_args) - ) + if not hasattr(orig, '_orig_ctor_args'): + orig._orig_ctor_args = [ + _expand_locals(arg) for arg in orig.ctor_args + ] + func_ctor_templates[i] = [ + _expand_locals(arg) + for arg in func_ctor_templates.get( + i, orig._orig_ctor_args + ) + ] + orig_args = func_ctor_templates[i] resolved_ctor = [ _subst_params(arg, param_arg_map) for arg in orig_args ] + existing_target = self._func_ta_call_targets.get( + (id(node), cs_idx), {} + ).get(i) + if existing_target is not None: + self._ta_call_sites[existing_target].ctor_args = resolved_ctor + selected_ta_indices[i] = existing_target + continue 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), + existing_pair = next( + ( + (index, site) + for index, site in enumerate(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): + if ( + existing_pair is not None + and ( + ( + reuse_existing_owner is not None + and existing_pair[1].owner_func + == reuse_existing_owner + ) + or ( + reuse_existing_targets is not None + and existing_pair[0] in reuse_existing_targets + ) + ) + ): # The active parent's widened range already made the # exact member this inherited callee variant needs. + selected_ta_indices[i] = existing_pair[0] continue if clone_name in self._ta_member_names: base = clone_name @@ -1390,15 +1432,26 @@ def _rep(match: re.Match) -> str: is_static=orig.is_static, owner_func=func_name, ) + selected_ta_indices[i] = len(self._ta_call_sites) 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 + call_targets = self._func_ta_call_targets.setdefault( + (id(node), cs_idx), {} + ) + call_targets.update(selected_ta_indices) + call_templates = self._func_ta_call_templates.setdefault(id(node), {}) + for index in site_indices: + template = func_ctor_templates.get(index) + if template is not None: + call_templates[index] = tuple(template) + # 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: + if materialize_fixnan and cs_idx > 0 and fn_indices: clone_map: dict[str, str] = {} for fi in fn_indices: orig = self._fixnan_sites[fi] @@ -1430,6 +1483,8 @@ def _rep(match: re.Match) -> str: if clone_map: self._func_cs_fixnan_clone_names[(func_name, cs_idx)] = clone_map + return selected_ta_indices + 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] diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index c7a5276..1d0c498 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -4472,7 +4472,11 @@ def _lower_reset_expr_via_visitor(self, expanded: str) -> str | None: return rendered - def _collect_ta_runtime_resets(self) -> list[str]: + def _collect_ta_runtime_resets( + self, + *, + security_source_node: ASTNode | None = None, + ) -> list[str]: """Collect reassignment statements for every TA object whose ctor args depend on an input-backed variable. Returned strings are raw C++ assignment statements (no enclosing block/indent). Empty list when no @@ -4521,6 +4525,11 @@ def _collect_ta_runtime_resets(self) -> list[str]: sec_cs_idx = (sec_item or {}).get("callsite_idx") for idx, variants in (info.get("ta_variants") or {}).items(): site = self.ctx.ta_call_sites[idx] + if ( + security_source_node is not None + and site.node is not security_source_node + ): + continue ctor_site = site if sec_containing and sec_cs_idx is not None: remap = self._func_cs_ta_remap.get((sec_containing, sec_cs_idx)) diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index adc1375..621df0c 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -518,6 +518,17 @@ def _emit_constructor(self, lines: list[str]) -> None: r = self._resolve_ta_ctor_arg(a) if (not self._is_compile_time_value(r) and self._runtime_ctor_arg_for_reset(a) is None): + # A TA source reached through request.security can have + # several helper-bound constructor variants. Validate + # only variants of this exact source node before the + # ordinary guard fires, so an unstable requested-context + # expression keeps its provenance-specific diagnostic + # without allowing a later, unrelated security error to + # reorder an earlier ordinary error. + if self._security_eval_info and site.node is not None: + self._collect_ta_runtime_resets( + security_source_node=site.node + ) self._codegen_error( getattr(site, "node", None), f"Unsupported TA constructor length '{a}' for " diff --git a/tests/test_callable_ta_forwarded_length.py b/tests/test_callable_ta_forwarded_length.py index 5126ae4..07d597c 100644 --- a/tests/test_callable_ta_forwarded_length.py +++ b/tests/test_callable_ta_forwarded_length.py @@ -221,6 +221,122 @@ def test_reverse_defined_three_method_chain_forwards_parameter_length() -> None: assert any('get_input_int("Len", 11)' in line for line in _reset_lines(cpp)) +def test_reverse_defined_three_method_chain_revisits_growing_ta_owner() -> None: + source = '''//@version=6 +strategy("reverse growing method chain length") +type Holder + float seed +method outer(Holder self, int outerLen) => self.middle(outerLen) +method middle(Holder self, int middleLen) => + ta.ema(close, middleLen) + self.inner(middleLen) +method inner(Holder self, int innerLen) => ta.sma(close, innerLen) +var Holder holder = Holder.new(0.0) +n = input.int(11, "Len") +out = holder.outer(n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp, "ema") == ["11"] + assert _ctor_periods(cpp, "sma") == ["11"] + assert any( + 'get_input_int("Len", 11)' in line for line in _reset_lines(cpp, "ema") + ) + assert any( + 'get_input_int("Len", 11)' in line for line in _reset_lines(cpp, "sma") + ) + + +def test_existing_method_and_top_level_edges_revisit_growing_ta_owner() -> None: + source = '''//@version=6 +strategy("existing growing method edges") +type Holder + float seed +method middle(Holder self, int middleLen) => + ta.ema(close, middleLen) + self.inner(middleLen) +method outer(Holder self, int outerLen) => self.middle(outerLen) +method inner(Holder self, int innerLen) => ta.sma(close, innerLen) +var Holder holder = Holder.new(0.0) +n = input.int(11, "Len") +out = holder.outer(n) +plot(out) +''' + cpp = transpile(source) + assert _ctor_periods(cpp, "ema") == ["11"] + assert _ctor_periods(cpp, "sma") == ["11"] + + +def test_growing_chain_keeps_unrelated_ta_out_of_two_constant_callsites() -> None: + source = '''//@version=6 +strategy("growing method owner slice") +type Holder + float seed +method outer(Holder self, int outerLen) => self.middle(outerLen) +method unrelated(Holder self) => ta.rma(close, 19) +method middle(Holder self, int middleLen) => + ta.ema(close, middleLen) + self.inner(middleLen) +method inner(Holder self, int innerLen) => ta.sma(close, innerLen) +var Holder holder = Holder.new(0.0) +noise = holder.unrelated() +first = holder.outer(4) +second = holder.outer(9) +plot(noise + first + second) +''' + cpp = transpile(source) + assert _ctor_periods(cpp, "rma") == ["19"] + assert _ctor_periods(cpp, "ema") == ["4", "9"] + assert _ctor_periods(cpp, "sma") == ["4", "9"] + + +def test_security_nested_dynamic_length_keeps_requested_context_diagnostic() -> None: + source = '''//@version=6 +strategy("requested nested mixed length") +length = input.int(2, "L") +inner(float src, int innerLen) => ta.ema(src, innerLen) +outer(float src, int outerLen) => + [inner(src, outerLen), inner(src, outerLen + int(close))] +[a, b] = request.security(syminfo.tickerid, "1", outer(close, length)) +plot(a + b) +''' + with pytest.raises(CompileError) as exc_info: + transpile(source) + message = str(exc_info.value) + assert "requested-context TA constructor length" in message + assert "not a stable per-run scalar" in message + + +def test_earlier_unrelated_ordinary_ta_length_error_keeps_precedence() -> None: + source = '''//@version=6 +strategy("two length errors") +bad(int n) => ta.sma(close, n) +ordinary = bad(int(close)) +e(float src, int len) => ta.ema(src, len) +four(float src, int p) => [e(src, p), e(src, p + int(close))] +[a, b] = request.security(syminfo.tickerid, "1", four(close, input.int(2, "L"))) +plot(ordinary + a + b) +''' + with pytest.raises(CompileError) as exc_info: + transpile(source) + message = str(exc_info.value) + assert "Unsupported TA constructor length 'int(close)'" in message + assert "requested-context" not in message + + +def test_same_ta_source_with_safe_requested_variant_keeps_ordinary_error() -> None: + source = '''//@version=6 +strategy("same TA node mixed contexts") +e(float src, int len) => ta.ema(src, len) +ordinary = e(close, int(close)) +requested = request.security( + syminfo.tickerid, "1", e(close, input.int(2, "L"))) +plot(ordinary + requested) +''' + with pytest.raises(CompileError) as exc_info: + transpile(source) + message = str(exc_info.value) + assert "Unsupported TA constructor length 'int(close)'" in message + assert "requested-context" not in message + + @pytest.mark.parametrize("forward_defined", [False, True]) def test_branch_wrapper_and_two_callsites_keep_distinct_lengths( forward_defined: bool, From b9812a3f52bb8521dd54bfd44e3adceb706f2f1b Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 21 Jul 2026 00:58:57 +0800 Subject: [PATCH 3/5] fix: isolate nested TA call paths --- pineforge_codegen/analyzer/base.py | 16 ++++++------ pineforge_codegen/analyzer/call_handlers.py | 14 ++++++++--- tests/test_callable_ta_forwarded_length.py | 28 +++++++++++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 32d43dd..788bf83 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -382,8 +382,11 @@ def __init__(self, ast: Program, filename: str = "") -> None: # pass can tell borrowed clones apart from a dead function's own # sites (see contracts.TACallSite.owner_func). self._enclosing_func_names: list[str] = [] - # Set of TA-site indices a nested user-func call rewrote in terms of the - # current enclosing function's params (None when not inside a FuncDef body). + # Exact TA targets borrowed through nested callable edges while visiting + # the current callable body. Constructor templates are tracked + # separately in ``_func_ta_ctor_args`` because state ownership also + # matters for parameterless helpers such as ``ta.change``. ``None`` + # means no callable body is active. self._nested_ta_touched: set | None = None # Pre-populate builtins @@ -4021,10 +4024,9 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: nested_touched = self._nested_ta_touched self._nested_ta_touched = None - # Record TA range for this function. Widen to cover any nested-callee TA - # sites whose ctor args were rewritten in terms of THIS function's params - # (e.g. f_basisMa's sites parameterized by f_bbwp's _bbwLen), so resolving - # this function at its call site re-substitutes those nested sites too. + # Record exact TA ownership for this function: direct allocations plus + # targets borrowed from every nested stateful call. Per-owner ctor + # templates retain forwarded-parameter expressions independently. ta_end = len(self._ta_call_sites) exact_indices = sorted( set(range(ta_start, ta_end)) | set(nested_touched or ()) @@ -4364,7 +4366,7 @@ def _visit_MethodDef(self, node) -> PineType: self._nested_ta_touched = previous_nested_ta_touched # UDT methods participate in the same stateful call graph as ordinary - # UDFs. Preserve their owned and nested-callee TA range under the + # UDFs. Preserve exact direct and borrowed TA ownership under the # canonical ``Type.method`` identity so written call sites can clone it. ta_end = len(self._ta_call_sites) exact_indices = sorted( diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 183ffc4..5be215f 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -1348,9 +1348,6 @@ def _rep(match: re.Match) -> str: for arg in func_ctor_templates[i] ] selected_ta_indices[i] = i - # 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( @@ -1361,7 +1358,6 @@ def _rep(match: re.Match) -> str: self._func_ta_ctor_args.setdefault( caller, {} )[i] = list(site.ctor_args) - self._nested_ta_touched.add(i) break else: clone_name_map: dict[str, str] = {} @@ -1438,6 +1434,16 @@ def _rep(match: re.Match) -> str: if clone_name_map: self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map + # A nested stateful helper belongs to the enclosing callable's + # call path even when its constructor has no forwarded parameters + # (``ta.change`` is the canonical example). Constructor-template + # forwarding above is intentionally narrower: it only rewrites + # expressions that reference enclosing parameters. Keep state + # ownership independent from that substitution test so every + # written parent call site receives its exact borrowed TA targets. + if self._nested_ta_touched is not None: + self._nested_ta_touched.update(selected_ta_indices.values()) + call_targets = self._func_ta_call_targets.setdefault( (id(node), cs_idx), {} ) diff --git a/tests/test_callable_ta_forwarded_length.py b/tests/test_callable_ta_forwarded_length.py index 07d597c..3eba14e 100644 --- a/tests/test_callable_ta_forwarded_length.py +++ b/tests/test_callable_ta_forwarded_length.py @@ -411,3 +411,31 @@ def test_two_method_callsites_run_with_distinct_forwarded_lengths() -> None: } ''' assert _compile_and_run(transpile(source) + driver) == "25 2\n" + + +def test_nested_parameterless_ta_state_stays_isolated_when_callee_has_more_callsites() -> None: + source = '''//@version=6 +strategy("nested state crowded namespace") +inner(float src) => ta.change(src) +outer(float src) => inner(src) +noise1 = inner(high) +noise2 = inner(low) +noise3 = inner(volume) +a = outer(close) +b = outer(open) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, 20.0, 2.0, 61000}, + Bar{3.0, 31.0, 2.0, 30.0, 3.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.a << " " << strategy.b << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "10 1\n" From 99148a43bcd683b39ac1f7bcbd490755f981255f Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 21 Jul 2026 01:36:58 +0800 Subject: [PATCH 4/5] fix: reuse exact transitive TA targets --- pineforge_codegen/analyzer/base.py | 12 ++++---- pineforge_codegen/analyzer/call_handlers.py | 32 +++++++++++++++------ tests/test_callable_ta_forwarded_length.py | 29 +++++++++++++++++++ 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 788bf83..721489c 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -2613,14 +2613,14 @@ def _has_synthetic_history_state( # Inherit each multi-call-site parent's index space down the full path. # Re-run to a fixed point for A -> B -> C chains. - def _ta_variant_target_indices( + def _ta_variant_target_map( func_name: str, cs_idx: int - ) -> set[int]: + ) -> dict[int, int]: source_indices = list(self._func_ta_indices.get(func_name, ())) if not source_indices and func_name in self._func_ta_ranges: source_indices = list(range(*self._func_ta_ranges[func_name])) if cs_idx == 0: - return set(source_indices) + return {index: index for index in source_indices} overrides = self._func_cs_ta_clone_names.get( (func_name, cs_idx), {} ) @@ -2628,7 +2628,7 @@ def _ta_variant_target_indices( site.member_name: index for index, site in enumerate(self._ta_call_sites) } - targets: set[int] = set() + targets: dict[int, int] = {} for source_index in source_indices: source_name = self._ta_call_sites[source_index].member_name target_name = overrides.get( @@ -2636,7 +2636,7 @@ def _ta_variant_target_indices( ) target_index = by_member.get(target_name) if target_index is not None: - targets.add(target_index) + targets[source_index] = target_index return targets changed = True @@ -2678,7 +2678,7 @@ def _ta_variant_target_indices( (sub, 0), None ) for cs_idx in range(current, count): - parent_ta_targets = _ta_variant_target_indices( + parent_ta_targets = _ta_variant_target_map( fname, cs_idx ) self._materialize_user_func_call_site_state( diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 5be215f..79ce1d8 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -1215,7 +1215,7 @@ def _scan_reassign(stmts): def _materialize_user_func_call_site_state( self, func_name: str, cs_idx: int, node: FuncCall, *, reuse_existing_owner: str | None = None, - reuse_existing_targets: set[int] | None = None, + reuse_existing_targets: dict[int, int] | None = None, ta_site_indices: list[int] | None = None, materialize_fixnan: bool = True) -> dict[int, int]: """Materialize TA/fixnan state for one UDF call-site variant. @@ -1233,9 +1233,10 @@ def _materialize_user_func_call_site_state( ``{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. ``reuse_existing_targets`` extends that - proof through another borrowed layer: the immediate parent's active - remap can legitimately target a clone owned by its own caller. + disambiguated-but-unused name. ``reuse_existing_targets`` maps each + source TA identity to the immediate parent's already-resolved target; + this extends the proof through another borrowed layer even when clone + name collisions forced a disambiguating suffix. """ func_def = self._func_defs.get(func_name) method_info = None @@ -1377,6 +1378,25 @@ def _rep(match: re.Match) -> str: resolved_ctor = [ _subst_params(arg, param_arg_map) for arg in orig_args ] + reuse_target = ( + reuse_existing_targets.get(i) + if reuse_existing_targets is not None + else None + ) + if reuse_target is not None: + # The parent's active clone already resolved this + # exact source identity through the next outer call + # boundary. Reuse it directly; do not overwrite its + # concrete constructor with this edge's still-local + # parameter spelling. + selected_ta_indices[i] = reuse_target + target_name = self._ta_call_sites[ + reuse_target + ].member_name + default_name = f"{orig.member_name}_cs{cs_idx}" + if target_name != default_name: + clone_name_map[orig.member_name] = target_name + continue existing_target = self._func_ta_call_targets.get( (id(node), cs_idx), {} ).get(i) @@ -1401,10 +1421,6 @@ def _rep(match: re.Match) -> str: and existing_pair[1].owner_func == reuse_existing_owner ) - or ( - reuse_existing_targets is not None - and existing_pair[0] in reuse_existing_targets - ) ) ): # The active parent's widened range already made the diff --git a/tests/test_callable_ta_forwarded_length.py b/tests/test_callable_ta_forwarded_length.py index 3eba14e..7698e11 100644 --- a/tests/test_callable_ta_forwarded_length.py +++ b/tests/test_callable_ta_forwarded_length.py @@ -439,3 +439,32 @@ def test_nested_parameterless_ta_state_stays_isolated_when_callee_has_more_calls } ''' assert _compile_and_run(transpile(source) + driver) == "10 1\n" + + +def test_three_level_dynamic_length_reuses_outer_target_when_leaf_namespace_is_crowded() -> None: + source = '''//@version=6 +strategy("three level crowded dynamic length") +leaf(float src, int len) => ta.sma(src, len) +middle(float src, int len) => leaf(src, len) +entry(float src, int len) => middle(src, len) +noise1 = leaf(high, 2) +noise2 = leaf(low, 3) +noise3 = leaf(volume, 4) +a = entry(close, 2) +b = entry(open, 3) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, 20.0, 2.0, 61000}, + Bar{3.0, 31.0, 2.0, 30.0, 3.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.a << " " << strategy.b << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "25 2\n" From f67563fe1c827d0250df3e27353897b1ed0516fe Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 21 Jul 2026 02:31:23 +0800 Subject: [PATCH 5/5] fix: preserve exact nested callable state paths --- pineforge_codegen/analyzer/base.py | 62 +++++++- pineforge_codegen/analyzer/contracts.py | 4 + pineforge_codegen/codegen/base.py | 119 ++++++++++++++- tests/test_callable_ta_forwarded_length.py | 169 +++++++++++++++++++++ tests/test_codegen_validation_fixes.py | 15 +- 5 files changed, 360 insertions(+), 9 deletions(-) diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 721489c..548d3df 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -312,6 +312,11 @@ def __init__(self, ast: Program, filename: str = "") -> None: # 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() + # Exact callee identity for those removed mappings. Natural parent + # clones still use active-index fallback, while a context-sensitive + # fresh parent has no active index and needs this edge identity to + # compose and dispatch its nested instance explicitly. + self._func_inherited_call_names: dict[int, str] = {} # 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 @@ -567,6 +572,9 @@ def analyze(self) -> AnalyzerContext: func_ta_ranges=self._func_ta_ranges, func_ta_indices=self._func_ta_indices, func_call_cs_map=self._func_call_cs_map, + func_inherited_call_names=dict( + self._func_inherited_call_names + ), func_call_site_counts=self._func_call_site_count, func_callsite_param_types={ key: tuple(types) @@ -2639,6 +2647,50 @@ def _ta_variant_target_map( targets[source_index] = target_index return targets + def _edge_ta_variant_target_map( + parent_name: str, + parent_cs_idx: int, + callee_name: str, + call_node: FuncCall, + ) -> dict[int, int]: + """Compose callee source identity through one parent call edge. + + ``_ta_variant_target_map`` is keyed by the parent's base TA + identities. Those identities need not equal the callee's source + indices: an earlier textual call can make this edge select a + shifted parent site. The edge's original materialization records + the exact ``callee source -> parent base`` relation; compose that + with the active parent variant instead of assuming equal keys. + """ + parent_targets = _ta_variant_target_map( + parent_name, parent_cs_idx + ) + cs_info = self._func_call_cs_map.get(id(call_node)) + edge_cs_idx = ( + cs_info[1] + if cs_info is not None and cs_info[0] == callee_name + else 0 + ) + edge_targets = self._func_ta_call_targets.get( + (id(call_node), edge_cs_idx), {} + ) + source_indices = list( + self._func_ta_indices.get(callee_name, ()) + ) + if not source_indices and callee_name in self._func_ta_ranges: + source_indices = list( + range(*self._func_ta_ranges[callee_name]) + ) + composed: dict[int, int] = {} + for source_index in source_indices: + parent_source_index = edge_targets.get( + source_index, source_index + ) + active_target = parent_targets.get(parent_source_index) + if active_target is not None: + composed[source_index] = active_target + return composed + changed = True while changed: changed = False @@ -2666,6 +2718,9 @@ def _ta_variant_target_map( if cs_info == (sub, 0): self._func_call_cs_map.pop(id(call_node), None) self._func_inherited_call_nodes.add(id(call_node)) + self._func_inherited_call_names[ + id(call_node) + ] = sub # Its definition-time type profile was likewise # provisional: forwarded untyped owner params # are still UNKNOWN during that visit. Each @@ -2678,8 +2733,11 @@ def _ta_variant_target_map( (sub, 0), None ) for cs_idx in range(current, count): - parent_ta_targets = _ta_variant_target_map( - fname, cs_idx + parent_ta_targets = _edge_ta_variant_target_map( + fname, + cs_idx, + sub, + call_node, ) self._materialize_user_func_call_site_state( sub, diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index 3996af7..0f711b7 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -242,6 +242,10 @@ class AnalyzerContext: # range can contain unrelated sites allocated between two borrowed sites. func_ta_indices: dict = field(default_factory=dict) # func_name -> [site_idx, ...] func_call_cs_map: dict = field(default_factory=dict) # call_node_id -> (func_name, call_site_index) + # Removed lexical nested-call mappings inherit their natural parent's + # active cs index. Fresh context-sensitive parents have no such index, so + # codegen uses this exact node -> callee identity to compose dispatch. + func_inherited_call_names: dict = field(default_factory=dict) func_call_site_counts: dict = field(default_factory=dict) # func_name -> int # Exact primitive parameter/return types for each emitted written-callsite # variant. Untyped Pine parameters are independently specialized at every diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 1d0c498..521cc9b 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -1360,6 +1360,61 @@ def _build_func_instances(self) -> None: if node is not None and getattr(node, "body", None): func_bodies.setdefault(fi.name, node.body) + # Pine forbids recursive callable execution. Most scalar-only cycles + # never enter this state-instance pass (and some legacy dead-branch + # typing probes intentionally retain them), but a cycle carrying TA, + # var, series, fixnan, or transitive state would otherwise mint a new + # fresh path instance forever. Reject exactly that stateful subgraph + # before expanding any members or dispatch records. + stateful_edges: dict[str, list[tuple[str, FuncCall]]] = { + name: [] for name in stateful + } + inherited_names = getattr( + ctx, "func_inherited_call_names", {} + ) or {} + for owner, body in func_bodies.items(): + if owner not in stateful: + continue + for callnode in self._iter_func_calls(body): + cs_info = ctx.func_call_cs_map.get(id(callnode)) + callee = ( + cs_info[0] + if cs_info is not None + else inherited_names.get(id(callnode)) + ) + if callee in stateful: + stateful_edges[owner].append((callee, callnode)) + + colors: dict[str, int] = {} + stack: list[str] = [] + + def reject_stateful_cycle(name: str) -> None: + colors[name] = 1 + stack.append(name) + for callee, callnode in stateful_edges.get(name, ()): + color = colors.get(callee, 0) + if color == 1: + start = stack.index(callee) + cycle = [*stack[start:], callee] + self._codegen_error( + callnode, + "Recursive stateful callable cycle is not supported: " + + " -> ".join(cycle) + + ".", + hint=( + "Remove the recursive call; Pine user-defined " + "functions cannot execute recursively." + ), + ) + if color == 0: + reject_stateful_cycle(callee) + stack.pop() + colors[name] = 2 + + for name in sorted(stateful): + if colors.get(name, 0) == 0: + reject_stateful_cycle(name) + def ta_originals(fname: str) -> list[str]: return list(self._func_cs_ta_remap.get((fname, 0), {}).keys()) @@ -1390,6 +1445,7 @@ def natural_name(fname: str, cs_idx: int) -> str: worklist.append({ "fname": fname, "name": natural_name(fname, k), + "call_site_idx": k, "ta_remap": self._func_cs_ta_remap.get((fname, k), {}), "var_remap": self._func_cs_var_remap.get((fname, k), {}), "fixnan_remap": self._func_cs_fixnan_remap.get((fname, k), {}), @@ -1398,6 +1454,7 @@ def natural_name(fname: str, cs_idx: int) -> str: worklist.append({ "fname": fname, "name": self._func_cpp_base_name(fname), + "call_site_idx": None, "ta_remap": {}, "var_remap": {}, "fixnan_remap": {}, @@ -1412,12 +1469,24 @@ def natural_name(fname: str, cs_idx: int) -> str: if not body: continue active_ta = inst["ta_remap"] + active_var = inst.get("var_remap", {}) active_fixnan = inst.get("fixnan_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 + # Natural csN parents deliberately leave inherited nested + # calls unmapped so visit_call can thread N through its + # active-index fallback. A fresh context-sensitive parent + # has no active index; recover only that removed edge's + # exact callee identity and compose it from natural cs0. + if not inst.get("fresh", False): + continue + g_name = ctx.func_inherited_call_names.get(id(callnode)) + if g_name is None: + continue + j = 0 + else: + g_name, j = cs_info if g_name not in stateful: continue natural_ta = self._func_cs_ta_remap.get((g_name, j), {}) @@ -1425,18 +1494,56 @@ def natural_name(fname: str, cs_idx: int) -> str: for m in ta_originals(g_name): mid = natural_ta.get(m, m) composed_ta[m] = active_ta.get(mid, mid) + g_var_originals = var_originals(g_name) + full_natural_var = self._func_cs_var_remap.get( + (g_name, j), {} + ) + natural_var = { + name: full_natural_var.get(name, name) + for name in g_var_originals + } + composed_var = {} + for m in g_var_originals: + mid = natural_var.get(m, m) + composed_var[m] = active_var.get(mid, mid) natural_fixnan = self._func_cs_fixnan_remap.get((g_name, j), {}) composed_fixnan = {} for m in fixnan_originals(g_name): mid = natural_fixnan.get(m, m) composed_fixnan[m] = active_fixnan.get(mid, mid) - if composed_ta == natural_ta and composed_fixnan == natural_fixnan: + owns_non_ta_state = bool( + g_var_originals or fixnan_originals(g_name) + ) + # Every non-cs0 enclosing variant is a distinct Pine written + # call path. Even a pure wrapper can reach var/fixnan state + # farther down the graph, so preserve the path identity here + # and let the fresh wrapper compose its next edge in turn. + path_requires_fresh_state = ( + inst.get("fresh", False) + or inst.get("call_site_idx") not in (None, 0) + ) + if ( + not path_requires_fresh_state + and composed_ta == natural_ta + and composed_var == natural_var + and composed_fixnan == natural_fixnan + ): # 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()), - frozenset(composed_fixnan.items())) + path_identity = ( + (inst["name"], id(callnode)) + if owns_non_ta_state or path_requires_fresh_state + else None + ) + key = ( + g_name, + frozenset(composed_ta.items()), + frozenset(composed_var.items()), + frozenset(composed_fixnan.items()), + path_identity, + ) ginst = interned.get(key) if ginst is None: fresh_counter += 1 @@ -1465,6 +1572,8 @@ def natural_name(fname: str, cs_idx: int) -> str: ginst = { "fname": g_name, "name": inst_name, + "fresh": True, + "call_site_idx": None, "ta_remap": composed_ta, "var_remap": fvar_remap, "fixnan_remap": ffixnan_remap, diff --git a/tests/test_callable_ta_forwarded_length.py b/tests/test_callable_ta_forwarded_length.py index 7698e11..c7f6fac 100644 --- a/tests/test_callable_ta_forwarded_length.py +++ b/tests/test_callable_ta_forwarded_length.py @@ -468,3 +468,172 @@ def test_three_level_dynamic_length_reuses_outer_target_when_leaf_namespace_is_c } ''' assert _compile_and_run(transpile(source) + driver) == "25 2\n" + + +def test_three_level_dynamic_length_composes_shifted_parent_source_identity() -> None: + source = '''//@version=6 +strategy("three level shifted parent source identity") +leaf(float src, int len) => ta.sma(src, len) +sub(float src, int len) => leaf(src, len) +pre(float src, int len) => sub(src, len) +outer(float src, int len) => sub(src, len) +p = pre(volume, 4) +a = outer(close, 1) +b = outer(open, 2) +c = outer(high, 3) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, 20.0, 2.0, 61000}, + Bar{3.0, 31.0, 2.0, 30.0, 3.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.a << " " << strategy.b << " " << strategy.c << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "30 2.5 21\n" + + +def test_crowded_nested_var_paths_get_fresh_persistent_state() -> None: + source = '''//@version=6 +strategy("crowded callable var only") +inner(float src) => + var float total = 0.0 + total += src + total +outer(float src) => inner(src) +noise = inner(high) +a = outer(close) +b = outer(open) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, 20.0, 2.0, 61000}, + Bar{3.0, 31.0, 2.0, 30.0, 3.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.a << " " << strategy.b << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "60 6\n" + + +def test_crowded_nested_fixnan_paths_get_fresh_previous_value_state() -> None: + source = '''//@version=6 +strategy("crowded callable fixnan only") +inner(float src) => fixnan(src) +outer(float src) => inner(src) +noise = inner(high) +a = outer(close) +b = outer(open) +''' + driver = r''' +#include +#include +int main() { + double nan = std::numeric_limits::quiet_NaN(); + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, nan, 2.0, 61000}, + Bar{3.0, 31.0, 2.0, nan, 3.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.a << " " << strategy.b << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "10 3\n" + + +def test_transitive_crowded_var_and_fixnan_paths_cross_pure_wrappers() -> None: + source = '''//@version=6 +strategy("transitive crowded var and fixnan") +inner(float src) => + var float total = 0.0 + held = fixnan(src) + total += held + total +middle(float src) => inner(src) +outer(float src) => middle(src) +noise = inner(high) +a = outer(close) +b = outer(open) +''' + driver = r''' +#include +#include +int main() { + double nan = std::numeric_limits::quiet_NaN(); + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, nan, 2.0, 61000}, + Bar{3.0, 31.0, 2.0, nan, 3.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.a << " " << strategy.b << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "30 6\n" + + +def test_shifted_dynamic_ta_paths_keep_nested_var_state_independent() -> None: + source = '''//@version=6 +strategy("shifted TA lineage with nested var state") +state(float src) => + var float total = 0.0 + total += src + total +sub(float src, int len) => + ignored = ta.sma(src, len) + state(src) +pre(float src, int len) => sub(src, len) +outer(float src, int len) => sub(src, len) +p = pre(volume, 4) +a = outer(close, 1) +b = outer(open, 2) +c = outer(high, 3) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 11.0, 0.0, 10.0, 1.0, 1000}, + Bar{2.0, 21.0, 1.0, 20.0, 2.0, 61000}, + Bar{3.0, 31.0, 2.0, 30.0, 3.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 7; + std::cout << strategy.p << " " << strategy.a << " " + << strategy.b << " " << strategy.c << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "6 60 6 63\n" + + +def test_recursive_stateful_callable_is_rejected_before_instance_expansion() -> None: + source = '''//@version=6 +strategy("recursive stateful callable") +rec(float src, int n) => + var float total = 0.0 + total += src + n <= 0 ? total : rec(src, n - 1) +out = rec(close, 1) +''' + with pytest.raises( + CompileError, match="Recursive stateful callable cycle" + ): + transpile(source) diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index ef424f1..7ce75bf 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -569,8 +569,19 @@ def test_series_parameter_shadowing_global_is_not_remapped_to_clone_member(): ) assert "double wrap_cs0(const Series& x)" in cpp assert "double wrap_cs1(const Series& x)" in cpp - assert cpp.count("return lag_cs1(x);") == 2 - assert "return lag_cs1(x_cs1);" not in cpp + wrap_cs0 = cpp.split( + "double wrap_cs0(const Series& x)", 1 + )[1].split("\n }", 1)[0] + wrap_cs1 = cpp.split( + "double wrap_cs1(const Series& x)", 1 + )[1].split("\n }", 1)[0] + assert "return lag_cs1(x);" in wrap_cs0 + # The second written path may own a fresh nested history instance. Its + # argument must still be the lexical parameter, never the same-spelled + # global Series clone. + assert re.search(r"return lag__ni\d+\(x\);", wrap_cs1) + assert "x_cs1" not in wrap_cs0 + assert "x_cs1" not in wrap_cs1 compile_cpp(cpp, label="series-parameter-shadow-global-clone-remap")