From c16f4cde7c567885fd657989bd428c92458b518c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 20 Jul 2026 20:12:34 +0800 Subject: [PATCH] fix: honor callable var first-reach lifecycles --- pineforge_codegen/analyzer/base.py | 149 +++++- pineforge_codegen/analyzer/call_handlers.py | 37 +- pineforge_codegen/analyzer/types.py | 8 + pineforge_codegen/codegen/base.py | 269 +++++++--- pineforge_codegen/codegen/emit_top.py | 187 +------ pineforge_codegen/codegen/ta.py | 98 +--- pineforge_codegen/codegen/types.py | 10 + pineforge_codegen/codegen/visit_call.py | 60 ++- pineforge_codegen/codegen/visit_stmt.py | 41 +- tests/test_callable_var_first_reach.py | 494 ++++++++++++++++++ tests/test_codegen_drawing_data.py | 2 +- tests/test_codegen_validation_fixes.py | 34 +- .../test_method_written_callsite_lifecycle.py | 458 ++++++++++++++++ tests/test_na_reassign_retype.py | 2 +- tests/test_persistent_callable_owner_state.py | 33 +- tests/test_runtime_var_initialization.py | 9 +- 16 files changed, 1455 insertions(+), 436 deletions(-) create mode 100644 tests/test_callable_var_first_reach.py create mode 100644 tests/test_method_written_callsite_lifecycle.py diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index d6cfc73..fc29fde 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -2131,10 +2131,13 @@ def _resolved_user_call_name(call: FuncCall, owner: str | None) -> str | None: method = call.callee.member udt_name: str | None = None if isinstance(recv, Identifier): - udt_name = self._udt_var_types.get(recv.name) owner_info = func_info_by_name.get(owner or "") - if udt_name is None and owner_info is not None \ - and owner_info.node is not None: + # Resolve the active callable's lexical parameters before the + # flat variable registry. A parameter may legally shadow a + # top-level UDT variable with a different type; consulting the + # global binding first misidentifies the method call edge and + # silently shares state between written wrapper call sites. + if owner_info is not None and owner_info.node is not None: if (getattr(owner_info, "is_udt_method", False) and owner_info.node.params and recv.name == owner_info.node.params[0]): @@ -2145,6 +2148,8 @@ def _resolved_user_call_name(call: FuncCall, owner: str | None) -> str | None: spec = specs[param_idx] if param_idx < len(specs) else None if spec is not None and spec.kind == "udt": udt_name = spec.name + if udt_name is None: + udt_name = self._udt_var_types.get(recv.name) if udt_name is None: spec = self._type_spec_from_expr(recv) if spec is not None and spec.kind == "udt": @@ -2191,6 +2196,7 @@ def _find_calls(node, known_funcs: set[str], calls_by_callee: dict[str, list[FuncCall]] = { name: [] for name in known_func_names } + call_edges: list[tuple[str | None, str, FuncCall]] = [] # Preserve source order across definitions and top-level statements. # Method bodies use their ``Type.method`` owner so ``self.sibling()`` # resolves without relying on a now-exited symbol-table scope. @@ -2203,6 +2209,74 @@ def _find_calls(node, known_funcs: set[str], owner = None for callee, call in _find_calls(stmt, known_func_names, owner): calls_by_callee.setdefault(callee, []).append(call) + call_edges.append((owner, callee, call)) + + # 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 + # generated C++ cannot bind it to the callee's ``const Series&``. + # Resolve this after every UDF/method definition is known and iterate + # to a fixed point so method <- inner <- outer chains are independent + # of declaration/source order. + def _bound_user_call_args(callee: str, call: FuncCall) -> list: + info = func_info_by_name.get(callee) + if info is None or info.node is None: + return [] + params = list(info.node.params) + if ( + getattr(info, "is_udt_method", False) + and isinstance(call.callee, MemberAccess) + ): + return [ + call.callee.object, + *self._bind_callable_args(call, params[1:]), + ] + return self._bind_callable_args(call, params) + + series_requirements: dict[str, set[str]] = { + name: set(params) + for name, params in self._func_series_vars.items() + if params + } + series_changed = True + while series_changed: + series_changed = False + for owner, callee, call in call_edges: + callee_info = func_info_by_name.get(callee) + if callee_info is None or callee_info.node is None: + continue + callee_series = series_requirements.get(callee, set()) + if not callee_series: + continue + actuals = _bound_user_call_args(callee, call) + for index, param_name in enumerate(callee_info.node.params): + if param_name not in callee_series or index >= len(actuals): + continue + actual = actuals[index] + if not isinstance(actual, Identifier): + continue + if actual.name in BAR_FIELDS: + self._series_bar_fields.add(actual.name) + if owner is None: + continue + owner_info = func_info_by_name.get(owner) + if ( + owner_info is None + or owner_info.node is None + or actual.name not in owner_info.node.params + ): + continue + owner_series = self._func_series_vars.setdefault( + owner, set() + ) + if actual.name not in owner_series: + owner_series.add(actual.name) + owner_requirements = series_requirements.setdefault( + owner, set() + ) + if actual.name not in owner_requirements: + owner_requirements.add(actual.name) + series_changed = True # Codegen synthesizes a Series buffer for two expression shapes that # do not appear in ``_func_series_vars`` themselves: @@ -2355,6 +2429,13 @@ def _has_synthetic_history_state( next_idx = max(next_idx, existing[1] + 1) continue self._func_call_cs_map[id(call)] = (fname, next_idx) + # Most ordinary UDFs were already materialized by the lexical + # call visitor. UDT method calls are resolved in this late + # graph pass, so their TA/fixnan state still needs matching + # cs0/cs1/... members before codegen emits the variants. + self._materialize_user_func_call_site_state( + fname, next_idx, call + ) next_idx += 1 if next_idx > current: self._func_call_site_count[fname] = next_idx @@ -3034,7 +3115,6 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: # Track var members if node.is_var or node.is_varip: init_str = self._expr_to_str(node.value) - scope_name = self._symbols.current_scope.name # Block-scoped var name-collision disambiguation. A ``var``/``varip`` # declared inside any non-global block (an ``if`` / ``for`` / # ``while`` body, including one nested in a callable) is keyed by @@ -3089,12 +3169,13 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: # promote the symbol storage type to ``int64_t``). if node.value is not None: self._var_member_init_exprs[member_name] = node.value - # Track function-scoped var members - if scope_name.startswith("func_"): - func_name = scope_name[5:] # strip "func_" prefix - if func_name not in self._func_var_members: - self._func_var_members[func_name] = [] - self._func_var_members[func_name].append( + # Track callable-scoped var members under the analyzer's canonical + # owner identity. Ordinary UDFs use ``name`` and UDT methods use + # ``Type.method``; both feed the same written-callsite clone graph. + if is_callable_scoped and callable_owner is not None: + if callable_owner not in self._func_var_members: + self._func_var_members[callable_owner] = [] + self._func_var_members[callable_owner].append( (member_name, val_type, init_str) ) @@ -3603,9 +3684,14 @@ def _visit_MethodDef(self, node) -> PineType: setattr(sym, "_pf_parameter_owner", method_key) self._symbols.define(sym) ret_type = PineType.VOID + ta_start = len(self._ta_call_sites) old_global = self._global_scope self._global_scope = False + self._enclosing_func_params.append(set(node.params)) + self._enclosing_func_names.append(method_key) self._collection_scope_stack.append(method_key) + previous_nested_ta_touched = self._nested_ta_touched + self._nested_ta_touched = set() terminal_ret_expr = self._direct_terminal_return_expr(node) return_type_spec = None try: @@ -3618,6 +3704,21 @@ def _visit_MethodDef(self, node) -> PineType: finally: self._global_scope = old_global self._collection_scope_stack.pop() + self._enclosing_func_params.pop() + self._enclosing_func_names.pop() + nested_touched = self._nested_ta_touched + 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 + # 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) self._symbols.exit_scope() # Detect tuple return on UDT methods (mirrors the regular FuncDef logic @@ -4108,6 +4209,22 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: ) }, ) + method_series = self._func_series_vars.get( + method_key, set() + ) + for index, param_name in enumerate(method_params): + if ( + param_name not in method_series + or index >= len(full_bound) + ): + continue + arg = full_bound[index] + if isinstance(arg, Identifier) and arg.name in BAR_FIELDS: + self._series_bar_fields.add(arg.name) + elif isinstance(arg, Identifier): + arg_sym = self._symbols.resolve(arg.name) + if arg_sym is not None: + arg_sym.is_series = True return method_info.return_type # Matrix method dispatch: ``m.get(0, 0)`` on ``matrix`` must # type as INT, not VOID, so ``v = m.get(...)`` propagates the @@ -4741,9 +4858,19 @@ def _visit_Subscript(self, node: Subscript) -> PineType: if not shadows_map_state: self._series_vars.add(name) sym.is_series = True - # Track function-scoped series vars + # Track callable-scoped series vars. Method symbol + # scopes use ``method_Type_name`` while every later + # clone/remap table is keyed by canonical + # ``Type.method`` identity. + func_name: str | None = None if sym.scope and sym.scope.startswith("func_"): func_name = sym.scope[5:] + elif ( + sym.scope != "global" + and self._collection_scope_stack + ): + func_name = self._collection_scope_stack[-1] + if func_name is not None: if func_name not in self._func_series_vars: self._func_series_vars[func_name] = set() self._func_series_vars[func_name].add(name) diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index c59f847..e752361 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -959,12 +959,43 @@ def _materialize_user_func_call_site_state( call-path state and must be reused rather than duplicated under a disambiguated-but-unused name. """ - func_def = self._func_defs[func_name] + func_def = self._func_defs.get(func_name) + method_info = None + if func_def is None: + method_info = next( + ( + info + for info in self._func_infos + if info.name == func_name + and getattr(info, "is_udt_method", False) + ), + None, + ) + if method_info is not None: + func_def = method_info.node + if func_def is None: + self._error( + f"Cannot materialize callable state for unknown function '{func_name}'.", + node.loc, + ) + return param_arg_map: dict[str, str] = {} + positional_args = list(node.args) + if ( + method_info is not None + and isinstance(node.callee, MemberAccess) + ): + positional_args.insert(0, node.callee.object) 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 p_idx < len(positional_args): + param_arg_map[param_name] = self._expr_to_str( + positional_args[p_idx] + ) + elif param_name in node.kwargs: + param_arg_map[param_name] = self._expr_to_str( + node.kwargs[param_name] + ) if func_name in self._func_ta_ranges: start, end = self._func_ta_ranges[func_name] diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index b74b948..dc2348b 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -194,6 +194,14 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: if isinstance(value, Ternary): true_spec = self._type_spec_from_expr(value.true_val) false_spec = self._type_spec_from_expr(value.false_val) + # Selecting between two values of the same user-defined type + # preserves that receiver type. Codegen already applies this + # rule; the analyzer must agree so stateful method calls on a UDT + # ternary enter the written-callsite clone graph. + if (true_spec is not None + and true_spec.kind == "udt" + and true_spec == false_spec): + return true_spec if (true_spec is not None and true_spec.kind == "map" and true_spec == false_spec): diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index ed37ec0..f2d0f99 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -107,7 +107,7 @@ class _StableVarCtorLiteral: from .types import TypeInferer # TaSiteHelper owns site lookup, .compute() arg construction, and the TA -# hoisting machinery. The runtime-reset chain (_resolve_known and friends) +# call-site machinery. The runtime-reset chain (_resolve_known and friends) # stays on CodeGen for now because it relies on Python's compile-time # expression evaluator. from .ta import TaSiteHelper @@ -166,7 +166,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm per-function emitters used by Pine functions and UDT methods * ``SecurityEmitter`` -- ``request.security()`` lowering pipeline (evaluators, dispatch, rebind, TA variants) - * ``TaSiteHelper`` -- TA call-site lookup + .compute() arg construction + TA hoisting + * ``TaSiteHelper`` -- TA call-site lookup + .compute() arg construction * ``TypeInferer`` -- _type_spec_*, _infer_type, _array/_map_method_expr * ``InputHelper`` -- Pine ``input.*`` defaults / titles / getter dispatch * ``NamingHelper`` -- _safe_name / _resolve_callee / _walk_ast / ... @@ -289,8 +289,6 @@ def func_var_storage(owner: str, raw_name: str) -> str: self._func_cs_ta_remap: dict[tuple[str, int], dict[str, str]] = {} # Active TA name remap (set during per-call-site function emission) self._active_ta_remap: dict[str, str] = {} - # Flag: inside a per-call-site function variant (enables TA hoisting) - self._in_ta_func_variant: bool = False # Active call-site index (set during per-call-site function emission) self._active_call_site_idx: int | None = None # Set of TA member names that belong to user functions @@ -328,10 +326,9 @@ def func_var_storage(owner: str, raw_name: str) -> str: # semantics. Populated by _register_udt_array_get_ref_locals. self._udt_array_get_ref_locals: set[str] = set() self._precalc_loop_active: bool = False - # Names of ``var`` members that live in a FUNCTION scope (not global). - # These are initialized once-per-function-variant on first call (a - # function-local static equivalent), NOT in the constructor / on_bar - # preamble. See ``_emit_func_var_init_block``. + # Names of ``var`` members that live in a callable scope (not global). + # Their exact declaration statements own initialization; they must not + # be initialized by the constructor or the global on_bar preamble. self._func_local_var_names: set[str] = set() for _owner, _vlist in ctx.func_var_members.items(): for _n, _, _ in _vlist: @@ -963,11 +960,11 @@ def func_var_storage(owner: str, raw_name: str) -> str: # history reads off security-helper series. self._max_bars_back_cap: int | None = self._compute_max_bars_back_cap() - # Non-series persistent scalars whose initializer cannot run in the - # C++ constructor are initialized at their Pine declaration site. The - # source-order metadata and collision-safe once flags must be prepared - # before function-instance naming and class-member emission begin. - self._prepare_runtime_scalar_var_initializers() + # Declaration-site ``var`` metadata is prepared in ``generate()`` + # after context-sensitive function instances have been built. Once + # flags share the class-member namespace with those fresh members, so + # allocating the flags here would not yet have the complete collision + # inventory. def _scalar_var_init_depends_on_runtime_input(self, init_ast) -> bool: """Whether a nominally constant initializer depends on input state. @@ -995,10 +992,10 @@ def _is_runtime_scalar_var_initializer( is_series: bool = False) -> bool: """Return True for a persistent primitive that must init in execution. - Series and aggregate state keep their existing specialized preamble - routes. Function-local vars keep their per-function-variant route. This - predicate only selects primitive global/on-bar-scope members for the - declaration-site once guards prepared below. + Global Series and aggregate state keep their specialized preamble + routes. Callable vars are selected separately by the exact-declaration + rule below. This predicate identifies the remaining primitive global / + on-bar-scope members that need declaration-site once guards. """ if init_ast is None: return False @@ -1061,6 +1058,47 @@ def _prepare_runtime_scalar_var_initializers(self) -> None: used_names.update( self._safe_name(name) for name, _ptype in self.ctx.global_var_decls ) + # Natural call-site clones and context-sensitive fresh instances are + # emitted later, but they occupy the same C++ class-member namespace as + # these flags. Reserve the complete callable-state inventory up front; + # in particular, an authored local named ``_pf_var_init_state`` also + # produces a legal ``_pf_var_init_state_cs1`` storage clone. + for remap in self._func_cs_var_remap.values(): + used_names.update(remap.values()) + used_names.update( + fresh_safe + for _owner, _orig_safe, fresh_safe in self._fresh_var_members + ) + used_names.update( + remapped + for remap in self._func_cs_fixnan_remap.values() + for remapped in remap.values() + ) + used_names.update( + fresh_safe for _site, fresh_safe in self._fresh_fixnan_members + ) + # Keep function naming stable across this late preparation pass. A + # flag must avoid an emitted base/clone/fresh method name rather than + # making ``_func_safe_name`` change after the instance graph was built. + for func_info in self.ctx.func_infos: + emitted = self._func_cpp_base_name(func_info.name) + used_names.add(emitted) + for callsite in range( + self.ctx.func_call_site_counts.get(func_info.name, 0) + ): + used_names.add(f"{emitted}_cs{callsite}") + used_names.update(inst["name"] for inst in self._fresh_instances) + + def allocate_flag(base: str) -> str: + flag = base + suffix = 2 + while flag in used_names: + flag = f"{base}_{suffix}" + suffix += 1 + used_names.add(flag) + self._all_member_names.add(flag) + return flag + metadata_by_node = getattr( self.ctx, "var_member_metadata_by_node", {} ) or {} @@ -1103,21 +1141,34 @@ def _prepare_runtime_scalar_var_initializers(self) -> None: is_series = self._safe_name(member_name) in self._series_var_member_names if node_id in self._drawing_var_decl_info_by_node: self._drawing_var_decl_info_by_node[node_id]["is_series"] = is_series - if is_callable_scoped and drawing_cpp is None: - continue - if not self._is_runtime_scalar_var_initializer( + # Every initialized callable ``var`` declaration must run at its + # exact statement. Nested declarations may be unreached, while a + # direct declaration can depend on ordinary statements immediately + # before it; a function-entry preamble violates both Pine rules. + # Exact-node once flags also preserve independent call-site clones. + # A bare UDT/drawing ``na`` already has the correct default member + # state and needs no assignment guard. + callable_decl_site = ( + is_callable_scoped + and stmt.value is not None + and not ( + stmt_spec is not None + and stmt_spec.kind == "udt" + and self._is_na_expr(stmt.value) + ) + ) + established_decl_site = ( + (not is_callable_scoped or drawing_cpp is not None) + and self._is_runtime_scalar_var_initializer( member_name, ptype, init_str, stmt.value, drawing_cpp, - is_series): + is_series + ) + ) + if not callable_decl_site and not established_decl_site: continue base_flag = f"_pf_var_init_{self._safe_name(member_name)}" - flag = base_flag - suffix = 2 - while flag in used_names: - flag = f"{base_flag}_{suffix}" - suffix += 1 - used_names.add(flag) - self._all_member_names.add(flag) + flag = allocate_flag(base_flag) self._runtime_scalar_var_init_members.add(member_name) info = { "member_name": member_name, @@ -1127,6 +1178,7 @@ def _prepare_runtime_scalar_var_initializers(self) -> None: "ptype": ptype, "flag": flag, "drawing_cpp": drawing_cpp, + "type_spec": stmt_spec, "is_series": is_series, } self._runtime_scalar_var_init_by_node[node_id] = info @@ -1141,14 +1193,21 @@ def _prepare_runtime_scalar_var_initializers(self) -> None: if storage == base_storage: continue clone_base = f"_pf_var_init_{storage}" - clone_flag = clone_base - clone_suffix = 2 - while clone_flag in used_names: - clone_flag = f"{clone_base}_{clone_suffix}" - clone_suffix += 1 - used_names.add(clone_flag) - self._all_member_names.add(clone_flag) + clone_flag = allocate_flag(clone_base) self._runtime_var_init_flags[(node_id, storage)] = clone_flag + for inst in self._fresh_instances: + if inst.get("fname") != owner: + continue + storage = inst.get("var_remap", {}).get( + base_storage, base_storage + ) + key = (node_id, storage) + if key in self._runtime_var_init_flags: + continue + fresh_base = f"_pf_var_init_{storage}" + self._runtime_var_init_flags[key] = allocate_flag( + fresh_base + ) if not is_callable_scoped: self._runtime_scalar_var_init_by_member[member_name] = info @@ -1415,6 +1474,8 @@ def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str, for vname, ptype, _init_str in self.ctx.var_members: if self._safe_name(vname) == orig_safe: cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double") + if cpp_type == "int" and self._is_int64_builtin_init(vname): + cpp_type = "int64_t" collection_spec = self._callable_var_collection_spec( vname, owner_func ) @@ -1449,6 +1510,17 @@ def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str, udt_t = self._udt_var_types[vname] handle_cpp = DRAWING_TYPE_TO_CPP.get(udt_t, udt_t) lines.append(f" {handle_cpp} {cloned_safe} = {handle_cpp}{{}};") + elif vname in self._runtime_scalar_var_init_members: + # Declaration-site state may remain unreached while COOF + # snapshots value-copy every clone. Match the base member's + # typed Pine-na default instead of leaving primitive clone + # storage indeterminate (or giving it a legacy zero). + pending = self._typed_na_init( + "na()", vname, ptype + ) + lines.append( + f" {cpp_type} {cloned_safe} = {pending};" + ) else: lines.append(f" {cpp_type} {cloned_safe};") return @@ -2924,6 +2996,27 @@ def actual_args_for(call: FuncCall, params: list[str]) -> list: return _merge_kwargs(call.args, call.kwargs, params, lambda arg: arg) return list(call.args) + def owner_lexical_specs(owner: str | None) -> dict[str, TypeSpec | None]: + """Recover callable parameter types after analyzer scopes exit.""" + if owner is None: + return {} + info = self._func_info_map.get(owner) + if info is None or info.node is None: + return {} + specs = list(getattr(info, "param_type_specs", ()) or ()) + lexical = { + name: specs[index] if index < len(specs) else None + for index, name in enumerate(info.node.params) + } + if ( + getattr(info, "is_udt_method", False) + and info.node.params + and info.udt_type_name + ): + lexical[info.node.params[0]] = TypeSpec.udt(info.udt_type_name) + lexical.update(self._func_collection_types.get(owner, {})) + return lexical + for node in walk_nodes(self.ctx.ast): owner = owner_by_node.get(id(node)) if isinstance(node, Subscript) and isinstance(node.object, FuncCall): @@ -2933,8 +3026,33 @@ def actual_args_for(call: FuncCall, params: list[str]) -> list: if not isinstance(node, FuncCall): continue - func_name, _ = self._resolve_callee(node.callee) - fi = self._func_info_map.get(func_name) + fi = None + method_call = False + if isinstance(node.callee, MemberAccess): + # Method syntax is type-directed. Resolve it before considering + # any same-named bare UDF, and use the AST owner's lexical + # parameter types: analyzer symbol scopes no longer exist in + # this source-order prepass. + receiver_spec = self._map_effect_type_spec( + node.callee.object, owner_lexical_specs(owner) + ) + if ( + receiver_spec is not None + and receiver_spec.kind == "udt" + and receiver_spec.name + ): + method_key = ( + f"{receiver_spec.name}.{node.callee.member}" + ) + candidate = self._func_info_map.get(method_key) + if ( + candidate is not None + and getattr(candidate, "is_udt_method", False) + ): + fi = candidate + method_call = True + elif isinstance(node.callee, Identifier): + fi = self._func_info_map.get(node.callee.name) if fi is None or fi.node is None: continue func_sv = self.ctx.func_series_vars.get(fi.name, set()) @@ -2943,7 +3061,13 @@ def actual_args_for(call: FuncCall, params: list[str]) -> list: } if not series_param_indices: continue - args = actual_args_for(node, list(fi.node.params)) + if method_call: + args = [ + node.callee.object, + *actual_args_for(node, list(fi.node.params[1:])), + ] + else: + args = actual_args_for(node, list(fi.node.params)) for idx, arg in enumerate(args): if idx not in series_param_indices: continue @@ -2975,6 +3099,9 @@ def generate(self) -> str: # Context-sensitive instance pre-pass (needs the naming helpers populated # in __init__). Computes nested stateful-helper dispatch + fresh instances. self._build_func_instances() + # Allocate declaration-site once flags only after every natural and + # context-sensitive callable-state member name is known. + self._prepare_runtime_scalar_var_initializers() self._prepare_inline_history_members() # Pre-scan for strategy series vars self._prescan_strategy_series() @@ -3513,30 +3640,6 @@ def generate(self) -> str: # initializers. Unlike the global aggregate/Series latch above, these # live at the declaration site so prior statements are available and a # conditional declaration initializes on its first actual execution. - used_runtime_flags = set(self._runtime_var_init_flag_used_names) - for info in self._runtime_scalar_var_init_by_node.values(): - if not info.get("is_callable_scoped"): - continue - node_id = info["node_id"] - base_storage = self._safe_name(info["member_name"]) - for inst in self._fresh_instances: - if inst.get("fname") != info.get("owner"): - continue - storage = inst.get("var_remap", {}).get( - base_storage, base_storage - ) - key = (node_id, storage) - if key in self._runtime_var_init_flags: - continue - flag_base = f"_pf_var_init_{storage}" - flag = flag_base - suffix = 2 - while flag in used_runtime_flags: - flag = f"{flag_base}_{suffix}" - suffix += 1 - used_runtime_flags.add(flag) - self._runtime_var_init_flags[key] = flag - emitted_runtime_flags: set[str] = set() for flag in self._runtime_var_init_flags.values(): if flag in emitted_runtime_flags: @@ -3544,28 +3647,37 @@ def generate(self) -> str: emitted_runtime_flags.add(flag) lines.append(f" bool {flag} = false;") - # 9b. Per-function-variant ``var`` init flags. A function-scoped - # ``var`` (Pine "init once" semantics) is a function-local static: - # its initializer runs on the FIRST call to that function variant - # (with the first bar's values the function actually sees) and the - # result persists for the strategy's lifetime. Each clone (cs0, - # cs1, ...) is an independent instance with its own flag. - # ``func_var_members`` is keyed by the plain Pine function name - # (``fi.name``), so this matches both plain UDFs and UDT methods. + # 9b. Legacy per-variant latch members are retained for generated-C++ + # and checkpoint-shape compatibility. Callable initializers no longer + # consult them: every initialized declaration owns an exact-site flag. + # Keep their historical spelling when free, but avoid legal authored + # ``_fvinit_*`` members and every natural/fresh clone name. + used_legacy_flags = set(self._runtime_var_init_flag_used_names) + + def allocate_legacy_flag(base: str) -> str: + flag = base + suffix = 2 + while flag in used_legacy_flags: + flag = f"{base}_{suffix}" + suffix += 1 + used_legacy_flags.add(flag) + return flag + for fi in self.ctx.func_infos: if fi.name not in self.ctx.func_var_members: continue + emitted = self._func_cpp_base_name(fi.name) total_cs = self.ctx.func_call_site_counts.get(fi.name, 0) - if total_cs > 0: - for cs_idx in range(total_cs): - lines.append(f" bool _fvinit_{self._func_safe_name(fi.name)}_cs{cs_idx} = false;") - else: - lines.append(f" bool _fvinit_{self._func_safe_name(fi.name)} = false;") + variants = range(total_cs) if total_cs > 0 else (None,) + for cs_idx in variants: + suffix = f"_cs{cs_idx}" if cs_idx is not None else "" + flag = allocate_legacy_flag(f"_fvinit_{emitted}{suffix}") + lines.append(f" bool {flag} = false;") - # 9b2. ``var`` init flags for fresh context-sensitive helper instances. for inst in self._fresh_instances: if inst["fname"] in self.ctx.func_var_members and inst["var_remap"]: - lines.append(f" bool _fvinit_{inst['name']} = false;") + flag = allocate_legacy_flag(f"_fvinit_{inst['name']}") + lines.append(f" bool {flag} = false;") # 9c. _ta_initialized_ flag for runtime TA re-sizing (first on_bar only). if self.ctx.ta_call_sites: @@ -3680,9 +3792,6 @@ def generate(self) -> str: # _get_ta_site / _ta_member_name / _ta_name_from_site / _TA_IMPLICIT_REPLACE # / _ta_compute_args_for_site / _security_ta_compute_args_for_site / - # _if_body_has_ta / _is_result_assignment / _expr_contains_ta / - # _hoist_if_body live on TaSiteHelper (codegen/ta.py). - def _resolve_ta_ctor_arg(self, arg_str: str) -> str: """Resolve one TA constructor argument without widening const folding. diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index dfd7379..3a6d6fa 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -59,7 +59,6 @@ - ``self._current_func_locals`` (``set[str]``). - ``self._active_ta_remap`` (``dict[str, str]``). - ``self._active_var_remap`` (``dict[str, str]``). -- ``self._in_ta_func_variant`` (``bool``). - ``self._active_call_site_idx`` (``int | None``). Sibling-mixin methods consumed via ``self``: @@ -567,11 +566,9 @@ def _emit_constructor(self, lines: list[str]) -> None: safe = self._safe_name(name) if name in self._array_vars or name in self._map_vars: continue - # Function-scoped ``var`` members are initialized once-per-variant - # on first call (see _emit_func_var_init_block); exclude them from - # the constructor so they are not double-initialized and so their - # (possibly bar-dependent) initializer is lowered in the function's - # own scope (with its active var remap for clones). + # Callable-scoped ``var`` members initialize at their exact source + # declarations; exclude them from the constructor so source order, + # first reach, and the active clone remap remain authoritative. if name in getattr(self, "_func_local_var_names", ()): continue # Runtime primitive initializers execute at their Pine declaration @@ -856,8 +853,8 @@ def _emit_on_bar(self, lines: list[str]) -> None: if self.ctx.var_members: lines.append(" if (!_var_initialized) {") for name, ptype, init_expr in self.ctx.var_members: - # Function-scoped ``var`` members are init'd once-per-variant - # on first call (see _emit_func_var_init_block), not here. + # Callable-scoped ``var`` members initialize at their exact + # declaration statements, not in the global on_bar preamble. if name in getattr(self, "_func_local_var_names", ()): continue safe = self._safe_name(name) @@ -1325,7 +1322,6 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # For per-call-site variants, suffix the function name and activate TA + var remapping func_name = self._emit_udt_method_cpp_name(fi) if is_udt else self._func_safe_name(fi.name) - var_init_flag: str | None = None if instance is not None: # Fresh context-sensitive instance: name + composed remaps come from # the instance record. No textual cs index (dispatch is via the @@ -1334,10 +1330,8 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._active_ta_remap = instance["ta_remap"] self._active_var_remap = instance["var_remap"] self._active_fixnan_remap = instance.get("fixnan_remap", {}) - self._in_ta_func_variant = True self._active_call_site_idx = None self._current_instance_name = instance["name"] - var_init_flag = f"_fvinit_{instance['name']}" elif call_site_idx is not None: func_name = f"{func_name}_cs{call_site_idx}" remap = self._func_cs_ta_remap.get((fi.name, call_site_idx), {}) @@ -1345,7 +1339,6 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No var_remap = self._func_cs_var_remap.get((fi.name, call_site_idx), {}) self._active_var_remap = var_remap self._active_fixnan_remap = self._func_cs_fixnan_remap.get((fi.name, call_site_idx), {}) - self._in_ta_func_variant = True self._active_call_site_idx = call_site_idx # Use the actual emitted name. Plain UDFs are unchanged; UDT # methods carry their `_udt_Type_method` prefix. This identity is @@ -1356,7 +1349,6 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._active_ta_remap = {} self._active_var_remap = {} self._active_fixnan_remap = {} - self._in_ta_func_variant = False self._active_call_site_idx = None self._current_instance_name = None @@ -1392,47 +1384,6 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No lines.append(f" {ret_type} {func_name}({', '.join(param_strs)}) {{") - # Function-scoped ``var`` one-shot initializer: Pine ``var`` inside a - # function is a function-local static — its initializer runs exactly - # once, on the first call to THIS variant, with the first bar's values - # the function actually sees. Each clone (cs0/cs1/…) is independent. - # Generate that initializer against its own source-ordered lexical - # state. Persistent declarations must be visible to later persistent - # initializers (``var copy = seed.copy()``), but preloading them into - # the ordinary body would make future declarations shadow earlier - # reads. Copy-on-write here provides both properties. - init_collection_state = ( - self._current_func_collection_specs, - self._current_func_collection_shadows, - self._collection_types, - self._array_vars, - self._map_vars, - self._matrix_specs, - ) - self._current_func_collection_specs = dict( - self._current_func_collection_specs - ) - self._current_func_collection_shadows = set( - self._current_func_collection_shadows - ) - self._collection_types = dict(self._collection_types) - self._array_vars = set(self._array_vars) - self._map_vars = set(self._map_vars) - self._matrix_specs = dict(self._matrix_specs) - try: - self._emit_func_var_init_block( - fi, call_site_idx, lines, flag_override=var_init_flag - ) - finally: - ( - self._current_func_collection_specs, - self._current_func_collection_shadows, - self._collection_types, - self._array_vars, - self._map_vars, - self._matrix_specs, - ) = init_collection_state - emitted_return = False if node.is_single_expr and node.body: expr = node.body[0].expr if isinstance(node.body[0], ExprStmt) else None @@ -1523,137 +1474,9 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._active_ta_remap = {} self._active_var_remap = {} self._active_fixnan_remap = {} - self._in_ta_func_variant = False self._active_call_site_idx = None self._current_instance_name = None - def _func_var_init_flag_name(self, fname: str, call_site_idx: int | None) -> str: - suffix = f"_cs{call_site_idx}" if call_site_idx is not None else "" - return f"_fvinit_{self._func_safe_name(fname)}{suffix}" - - def _emit_func_var_init_block(self, fi: FuncInfo, call_site_idx: int | None, - lines: list[str], flag_override: str | None = None) -> None: - """Emit the one-shot initializer block for a function's ``var`` members. - - Pine ``var`` declared inside a function is a function-local static: - the initializer runs exactly once on the FIRST call to this variant - (using the first bar's values the function actually sees) and the - result persists for the strategy's lifetime. Each per-call-site clone - is an independent instance with its own flag and its own set of - (remapped) members. - - This closes the gap where a function-scoped ``var line x = line.new(...)`` - (or any non-compile-time initializer — drawing handles, UDT ctors, - arrays, runtime expressions) was declared as a default-constructed - class member but its initializer was dropped, leaving the member ``na`` - / uninitialised and causing "drawing access on na handle" at runtime. - """ - members = self.ctx.func_var_members.get(fi.name) - if not members: - return - flag = flag_override or self._func_var_init_flag_name(fi.name, call_site_idx) - # ``_active_var_remap`` is already set for this variant by the caller, - # so lowering each init expression here correctly resolves references - # to sibling var members (which are themselves remapped for clones). - init_lines: list[str] = [] - declaration_site_drawing_names = { - info["raw_name"] - for info in self._drawing_var_decl_info_by_node.values() - if info.get("owner") == fi.name - and info.get("node_id") in self._runtime_scalar_var_init_by_node - } - # Initializers are lowered in declaration order. A qualified member's - # raw lexical spelling becomes visible to *later initializers* only - # after its own RHS has been rendered. Restore the storage-only clone - # map before emitting the ordinary body so a future local cannot shadow - # an earlier same-named global read. - body_var_remap = self._active_var_remap - self._active_var_remap = dict(body_var_remap) - for name, ptype, _init_str in members: - if name in declaration_site_drawing_names: - continue - storage_name = self._func_var_storage_name(fi.name, name) - init_ast = self.ctx.var_member_init_exprs.get(storage_name) - safe = self._safe_name(storage_name) - target = self._active_var_remap.get(safe, safe) - collection_spec = self._callable_var_collection_spec( - storage_name, fi.name - ) - - def activate_member() -> None: - self._activate_callable_collection_binding( - name, collection_spec - ) - self._active_var_remap[self._safe_name(name)] = target - - if self._safe_name(storage_name) in self._series_var_member_names: - if init_ast is None: - activate_member() - continue - drawing_cpp = self._drawing_var_member_cpp_types.get( - storage_name - ) - if drawing_cpp is not None: - init_cpp = self._visit_rhs_value( - init_ast, - storage_name, - target_cpp_type=drawing_cpp, - ) - init_lines.append(f" {target}.update({init_cpp});") - else: - init_cpp = self._visit_expr(init_ast) - init_cpp = self._typed_na_init( - init_cpp, storage_name, ptype - ) - init_lines.append(f" {target}.push({init_cpp});") - activate_member() - continue - if init_ast is None: - # No initializer to lower (e.g. bare ``var box b``); leave the - # member at its default-constructed value. - activate_member() - continue - # Skip a plain ``na`` initializer for drawing handles / UDTs whose - # default-constructed member is already the na sentinel; assigning - # ``na()`` would not type-match the handle / struct. - udt_t = self._udt_var_types.get(storage_name) - drawing_cpp = ( - self._drawing_var_member_cpp_types.get(storage_name) - or DRAWING_TYPE_TO_CPP.get(udt_t) - ) - is_drawing = drawing_cpp is not None - is_udt = udt_t in self._udt_defs if udt_t else False - from ..ast_nodes import NaLiteral - if (is_drawing or is_udt) and isinstance(init_ast, NaLiteral): - activate_member() - continue - init_cpp = self._visit_rhs_value( - init_ast, - storage_name, - target_cpp_type=( - self._type_spec_to_cpp(collection_spec) - if collection_spec is not None - and collection_spec.kind == "map" - else drawing_cpp - ), - ) - # A bare-``na`` initializer for an int/int64_t/bool ``var`` member - # must be typed (``na()``), mirroring the class-scope ctor - # init (``_typed_na_init`` at _emit_constructor); a raw ``na()`` - # NaN stored into an int member is UB and defeats is_na(). - init_cpp = self._typed_na_init( - init_cpp, storage_name, ptype - ) - init_lines.append(f" {target} = {init_cpp};") - activate_member() - self._active_var_remap = body_var_remap - if not init_lines: - return - lines.append(f" if (!{flag}) {{") - lines.extend(init_lines) - lines.append(f" {flag} = true;") - lines.append(" }") - def _emit_precalculate_and_run(self, lines: list[str]) -> None: has_static_ta = any( self._ta_site_uses_precalc(site) diff --git a/pineforge_codegen/codegen/ta.py b/pineforge_codegen/codegen/ta.py index f5d5aa6..ed31be0 100644 --- a/pineforge_codegen/codegen/ta.py +++ b/pineforge_codegen/codegen/ta.py @@ -1,8 +1,7 @@ """TA (technical analysis) call-site helpers for the codegen. Holds the eval-free TA helpers: call-site lookup, ``.compute()`` arg -construction, the TA-hoisting machinery for if-bodies, and a small -``_is_compile_time_value`` predicate. The runtime-reset chain that +construction, and a small ``_is_compile_time_value`` predicate. The runtime-reset chain that depends on Python's compile-time expression evaluator (``_resolve_known`` / ``_runtime_ctor_arg_for_reset`` / ``_collect_ta_runtime_resets`` / ``_emit_ta_runtime_reset``) stays @@ -13,8 +12,6 @@ - ``self._ta_site_map`` (``dict[int, TACallSite]``). - ``self._active_ta_remap`` (``dict[str, str] | None``). -- ``self._hoist_var_counter`` (``int``, optional — auto-managed). - Sibling-mixin methods consumed via ``self``: - ``self._visit_expr`` / ``self._visit_stmt`` (visitor mixins, currently @@ -40,7 +37,7 @@ class TaSiteHelper: - """TA call-site lookups, .compute() argument construction, and TA hoisting in if-bodies.""" + """TA call-site lookups and ``.compute()`` argument construction.""" # TA functions where an explicit source argument REPLACES the implicit # bar-data default (vs. ATR / supertrend / DMI where bar OHLC must @@ -93,97 +90,6 @@ def _ta_name_from_site(site: "TACallSite") -> str: f"'_ta__' convention. Internal codegen bug." ) - # ------------------------------------------------------------------ - # TA hoisting in if-bodies (computations unconditional, result conditional) - # ------------------------------------------------------------------ - - def _if_body_has_ta(self, stmts: list) -> bool: - """True if any statement in ``stmts`` references a TA call-site (recursively).""" - for s in stmts: - if isinstance(s, VarDecl) and s.value is not None: - if self._expr_contains_ta(s.value): - return True - if isinstance(s, Assignment) and hasattr(s, "value"): - if self._expr_contains_ta(s.value): - return True - if isinstance(s, ExprStmt): - if self._expr_contains_ta(s.expr): - return True - return False - - def _is_result_assignment(self, stmt) -> bool: - """True iff ``stmt`` is an assignment to the synthetic ``result`` variable. - - ``result`` is the function-body return target injected when a Pine - function body becomes a C++ function-call site; assignments to it - carry semantic weight in TA hoisting (they are the conditional-emit - targets).""" - if isinstance(stmt, Assignment): - target_name = self._get_target_name(stmt.target) - if target_name == "result": - return True - return False - - def _expr_contains_ta(self, expr) -> bool: - """Recursive check: does any subnode of ``expr`` resolve to a TA site?""" - if expr is None: - return False - if self._get_ta_site(expr) is not None: - return True - if isinstance(expr, BinOp): - return self._expr_contains_ta(expr.left) or self._expr_contains_ta(expr.right) - if isinstance(expr, UnaryOp): - return self._expr_contains_ta(expr.operand) - if isinstance(expr, Ternary): - return (self._expr_contains_ta(expr.true_val) - or self._expr_contains_ta(expr.false_val)) - if isinstance(expr, FuncCall): - return any(self._expr_contains_ta(a) for a in expr.args) - return False - - def _hoist_if_body(self, stmts: list, cond: str, lines: list[str], pad: str, indent: int) -> None: - """Emit an if-body with TA hoisting. - - Pine evaluates TA on every bar regardless of branch; C++ TA - instances must compute() unconditionally to keep their state - in sync. We split each result-assignment whose RHS contains a - TA call into: - - - an unconditional ``double _hoist_ = ;`` line, - - a conditional ``if () { result = _hoist_; }``. - - Non-result statements are emitted unconditionally inside an - opening scope block; result assignments without a TA reference - stay fully conditional.""" - lines.append(f"{pad}{{") - conditional_stmts: list = [] - _hoist_counter = getattr(self, "_hoist_var_counter", 0) - - for s in stmts: - if self._is_result_assignment(s): - rhs = s.value if hasattr(s, "value") else None - if rhs is not None and self._expr_contains_ta(rhs): - _hoist_counter += 1 - tmp_var = f"_hoist_{_hoist_counter}" - compute_expr = self._visit_expr(rhs) - lines.append(f"{pad} double {tmp_var} = {compute_expr};") - conditional_stmts.append(("result", tmp_var)) - else: - conditional_stmts.append(("stmt", s)) - else: - self._visit_stmt(s, lines, indent + 1) - - if conditional_stmts: - lines.append(f"{pad} if ({cond}) {{") - for item in conditional_stmts: - if item[0] == "result": - lines.append(f"{pad} result = {item[1]};") - else: - self._visit_stmt(item[1], lines, indent + 2) - lines.append(f"{pad} }}") - lines.append(f"{pad}}}") - self._hoist_var_counter = _hoist_counter - # ------------------------------------------------------------------ # .compute() arg-string construction # ------------------------------------------------------------------ diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index b0ab4e2..8856c9d 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -282,6 +282,16 @@ def _collection_receiver_expr(self, name: str) -> str: if owner is not None else name ) + # Once an exact callable-local collection declaration has become + # lexically active, its per-written-callsite variant must read the + # same cloned member that its declaration-site initializer wrote. + # Before the declaration, ``_current_func_collection_specs`` has no + # entry, so an inherited same-named global still resolves normally. + if ( + name in getattr(self, "_current_func_collection_specs", {}) + and safe in self._active_var_remap + ): + return self._active_var_remap[safe] # Preserve byte-identical output for every non-qualified callable. A # qualified persistent array becomes visible through the raw lexical # spelling only after its VarDecl installs this active remap. diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index b3c4043..9624b72 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -759,6 +759,50 @@ def _ordered_user_call_expr( ) return lowered + def _visit_udt_method_series_arg( + self, + func_info, + call_node: FuncCall, + arg_node, + rest_index: int, + ) -> str: + """Lower one UDT-method argument with history-Series awareness.""" + param_index = rest_index + 1 # receiver is parameter 0 + param_name = ( + func_info.node.params[param_index] + if func_info.node is not None + and param_index < len(func_info.node.params) + else None + ) + method_series = self.ctx.func_series_vars.get(func_info.name, set()) + if param_name not in method_series: + return self._visit_expr(arg_node) + if isinstance(arg_node, Identifier): + arg_name = arg_node.name + if arg_name in BAR_FIELDS or arg_name in BAR_SERIES_PUSH: + return f"_s_{arg_name}" + safe = self._safe_name(arg_name) + if arg_name in self._current_func_series_params: + return safe + if self._active_var_remap and safe in self._active_var_remap: + safe = self._active_var_remap[safe] + if self._binding_is_series(arg_name, safe): + return safe + expr_cpp = self._visit_expr(arg_node) + cpp_type = self._infer_type(arg_node) + if cpp_type not in ("double", "int", "bool"): + cpp_type = "double" + member = self._inline_history_member( + "series_arg", call_node, arg_idx=param_index + ) + return ( + f"([&]() -> const Series<{cpp_type}>& {{ " + f"{cpp_type} _sv = ({expr_cpp}); " + f"if (history_advances_new_bar()) {member}.push(_sv); " + f"else {member}.update(_sv); " + f"return {member}; }}())" + ) + def _visit_func_call(self, node: FuncCall) -> str: callee = node.callee if isinstance(callee, MemberAccess): @@ -804,7 +848,12 @@ def _visit_func_call(self, node: FuncCall) -> str: node.args, node.kwargs, param_names, param_defaults, lambda x: x, ) - rest = [self._visit_expr(a) for a in rest_nodes] + rest = [ + self._visit_udt_method_series_arg( + fi_u, node, arg, index + ) + for index, arg in enumerate(rest_nodes) + ] return self._ordered_user_call_expr( fn_cpp, [callee.object, *rest_nodes], @@ -1046,7 +1095,12 @@ def _visit_func_call(self, node: FuncCall) -> str: node.args, node.kwargs, param_names, param_defaults, lambda x: x, ) - rest = [self._visit_expr(a) for a in rest_nodes] + rest = [ + self._visit_udt_method_series_arg( + fi_u, node, arg, index + ) + for index, arg in enumerate(rest_nodes) + ] return self._ordered_user_call_expr( fn_cpp, [obj, *rest_nodes], @@ -1886,7 +1940,7 @@ def _visit_arg_for_series(arg_node, arg_idx): # can contain the same raw name, but applying it here makes # cs1+ ignore the actual parameter and read an unrelated # class member instead. - if aname in self._current_func_param_types: + if aname in self._current_func_series_params: return safe if self._active_var_remap and safe in self._active_var_remap: safe = self._active_var_remap[safe] diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index fc677d0..f10d70e 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -37,8 +37,6 @@ ``_visit_var_decl`` populates it from inferred specs. - ``self._active_var_remap`` (``dict[str, str]``): per-call-site rename map for cloned function-local var/series names. -- ``self._in_ta_func_variant`` (``bool``): set during per-call-site - function emission; gates the TA-hoist branch in ``_visit_if``. - ``self._current_loop_vars`` (``set[str]``): for-in iterator names; saved/restored around ``_visit_for_in`` bodies so member-access resolution can distinguish iterators from enum constants. @@ -58,7 +56,7 @@ ``_map_spec_for_name``. - ``TaSiteHelper`` (``codegen/ta.py``): ``_get_ta_site``, ``_ta_member_name``, ``_ta_compute_args_for_site``, - ``_ta_name_from_site``, ``_if_body_has_ta``, ``_hoist_if_body``. + ``_ta_name_from_site``. - ``InputHelper`` (``codegen/input.py``): ``_is_input_call``, ``_get_input_default``, ``_get_input_title``, ``_input_type_to_getter``, @@ -474,11 +472,19 @@ def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: previous_input_name = self._current_input_var_name self._current_input_var_name = node.name try: - if info.get("drawing_cpp") is not None: + type_spec = info.get("type_spec") + target_cpp_type = info.get("drawing_cpp") + if ( + target_cpp_type is None + and type_spec is not None + and type_spec.kind in {"map", "matrix"} + ): + target_cpp_type = self._type_spec_to_cpp(type_spec) + if target_cpp_type is not None: init_cpp = self._visit_rhs_value( node.value, member_name, - target_cpp_type=info["drawing_cpp"], + target_cpp_type=target_cpp_type, ) else: init_cpp = self._visit_expr(node.value) @@ -1346,31 +1352,6 @@ def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None: def _visit_if_body(self, node: IfStmt, lines: list[str], indent: int) -> None: pad = " " * indent - # TA hoisting: inside per-call-site function variants, execute ALL - # statements unconditionally (PineScript execution model) but wrap - # the result assignment in the condition. - if self._in_ta_func_variant and self._if_body_has_ta(node.body): - cond = self._visit_expr(node.condition) - saved = self._push_block_var_remap(node.body) - try: - self._hoist_if_body(node.body, cond, lines, pad, indent) - finally: - self._pop_block_var_remap(saved) - # Handle else_body similarly - if node.else_body: - if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt): - self._visit_if(node.else_body[0], lines, indent) - else: - neg_cond = f"!({cond})" - saved = self._push_block_var_remap(node.else_body) - try: - self._hoist_if_body( - node.else_body, neg_cond, lines, pad, indent - ) - finally: - self._pop_block_var_remap(saved) - return - cond = self._visit_expr(node.condition) lines.append(f"{pad}if ({cond}) {{") self._visit_block_statements(node.body, lines, indent + 1) diff --git a/tests/test_callable_var_first_reach.py b/tests/test_callable_var_first_reach.py new file mode 100644 index 0000000..0a543fc --- /dev/null +++ b/tests/test_callable_var_first_reach.py @@ -0,0 +1,494 @@ +"""Runtime coverage for callable-local ``var`` declaration lifecycles. + +Pine v6 initializes a ``var`` declaration when execution first reaches that +declaration. Entering its containing UDF is insufficient when the declaration +lives under conditional control flow. Each written UDF call site owns an +independent persistent instance. +""" + +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile +from tests.test_runtime_var_initialization import _compile_and_run + + +def _matrix_source(storage: str, delayed: bool, callsites: int) -> str: + if storage == "scalar": + declaration = "var float state = close" + read = "state" + else: + declaration = "var array state = array.from(close)" + read = "state.get(0)" + if delayed: + conditions = ["bar_index >= 1", "bar_index >= 2"] + else: + conditions = ["true", "true"] + calls = [f"value = probe({conditions[0]})"] + if callsites == 2: + calls = [ + f"left_value = probe({conditions[0]})", + f"right_value = probe({conditions[1]})", + ] + return ( + "//@version=6\n" + 'strategy("callable var first reach matrix")\n' + "probe(bool active) =>\n" + " float observed = 0.0\n" + " if active\n" + f" {declaration}\n" + f" observed := {read}\n" + " observed\n" + + "\n".join(calls) + + "\n" + ) + + +def _matrix_driver(callsites: int) -> str: + output = ( + "strategy.value" + if callsites == 1 + else 'strategy.left_value << " " << strategy.right_value' + ) + return f'''\n#include \nint main() {{\n Bar bars[] = {{\n Bar{{10.0, 11.0, 9.0, 10.0, 1.0, 1000}},\n Bar{{20.0, 21.0, 19.0, 20.0, 1.0, 61000}},\n Bar{{30.0, 31.0, 29.0, 30.0, 1.0, 121000}},\n }};\n GeneratedStrategy strategy;\n strategy.run(bars, 3);\n if (!strategy.last_error().empty()) return 7;\n std::cout << {output} << "\\n";\n}}\n''' + + +@pytest.mark.parametrize("storage", ["scalar", "array"]) +@pytest.mark.parametrize("delayed", [False, True], ids=["eager", "delayed"]) +@pytest.mark.parametrize("callsites", [1, 2], ids=["one-site", "two-sites"]) +def test_callable_var_first_reach_exhaustive_matrix( + storage: str, delayed: bool, callsites: int +) -> None: + """All 2^3 cells run; no first passing combination short-circuits this gate.""" + cpp = transpile(_matrix_source(storage, delayed, callsites)) + observed = tuple( + float(value) + for value in _compile_and_run(cpp + _matrix_driver(callsites)).split() + ) + if not delayed: + expected = (10.0,) * callsites + elif callsites == 1: + expected = (20.0,) + else: + expected = (20.0, 30.0) + assert observed == expected + + +def test_conditional_initializer_side_effect_occurs_at_first_reach() -> None: + source = '''//@version=6 +strategy("callable var initializer side effect") +var array initializer_bars = array.new() +seed() => + initializer_bars.push(bar_index) + close +probe(bool active) => + float observed = -1.0 + if active + var float state = seed() + observed := state + observed +value = probe(bar_index >= 2) +side_count = initializer_bars.size() +side_first = side_count > 0 ? initializer_bars.get(0) : -1 +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 11.0, 9.0, 10.0, 1.0, 1000}, + Bar{20.0, 21.0, 19.0, 20.0, 1.0, 61000}, + Bar{30.0, 31.0, 29.0, 30.0, 1.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.value << " " << strategy.side_count << " " + << strategy.side_first << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "30 1 2\n" + + +def test_conditional_persistent_map_uses_exact_callsite_storage() -> None: + source = '''//@version=6 +strategy("callable map first reach") +make(int seed) => + map value = map.new() + value.put("seed", seed) + value +probe(bool active) => + int observed = -1 + if active + var map state = make(bar_index) + observed := state.get("seed") + observed +left_value = probe(bar_index >= 1) +right_value = probe(bar_index >= 2) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 11.0, 9.0, 10.0, 1.0, 1000}, + Bar{20.0, 21.0, 19.0, 20.0, 1.0, 61000}, + Bar{30.0, 31.0, 29.0, 30.0, 1.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.left_value << " " << strategy.right_value << "\n"; +} +''' + cpp = transpile(source) + assert "if (!this->_pf_var_init_state)" in cpp + assert "if (!this->_pf_var_init_state_cs1)" in cpp + assert "}((state_cs1))" in cpp + assert _compile_and_run(cpp + driver) == "1 2\n" + + +@pytest.mark.parametrize("storage", ["udt", "matrix"]) +@pytest.mark.parametrize("callsites", [1, 2], ids=["one-site", "two-sites"]) +def test_conditional_udt_and_matrix_initialize_at_first_reach( + storage: str, callsites: int +) -> None: + if storage == "udt": + preamble = "type Point\n float value\n" + declaration = "var Point state = Point.new(close)" + read = "state.value" + else: + preamble = "" + declaration = "var matrix state = matrix.new(1, 1, close)" + read = "state.get(0, 0)" + calls = ["value = probe(bar_index >= 1)"] + if callsites == 2: + calls = [ + "left_value = probe(bar_index >= 1)", + "right_value = probe(bar_index >= 2)", + ] + source = ( + "//@version=6\n" + 'strategy("callable aggregate first reach")\n' + + preamble + + "probe(bool active) =>\n" + " float observed = 0.0\n" + " if active\n" + f" {declaration}\n" + f" observed := {read}\n" + " observed\n" + + "\n".join(calls) + + "\n" + ) + observed = tuple( + float(value) + for value in _compile_and_run( + transpile(source) + _matrix_driver(callsites) + ).split() + ) + assert observed == ((20.0,) if callsites == 1 else (20.0, 30.0)) + + +def test_direct_callable_var_runs_after_preceding_side_effect() -> None: + source = '''//@version=6 +strategy("direct callable var source order") +var array seen = array.new() +probe() => + seen.push(close) + var float first_size = seen.size() + first_size +value = probe() +side_count = seen.size() +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 11.0, 9.0, 10.0, 1.0, 1000}, + Bar{20.0, 21.0, 19.0, 20.0, 1.0, 61000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 2); + std::cout << strategy.value << " " << strategy.side_count << "\n"; +} +''' + assert _compile_and_run(transpile(source) + driver) == "1 2\n" + + +@pytest.mark.parametrize("declaration_kind", ["var", "varip"]) +def test_direct_callable_var_and_internal_varip_lowering_read_preceding_local( + declaration_kind: str, +) -> None: + source = f'''//@version=6 +strategy("direct callable var reads preceding local") +probe() => + float seed = close * 2.0 + {declaration_kind} float first = seed + first +value = probe() +''' + # Public batch backtests intentionally reject varip because they have no + # realtime ticks. Bypass only that support gate to pin the shared historical + # declaration-lowering predicate; this does not claim realtime varip parity. + cpp = transpile(source, check_support=declaration_kind == "var") + body = cpp[cpp.index("double probe_cs0("):] + assert body.index("double seed =") < body.index("if (!this->_pf_var_init_first)") + assert _compile_and_run(cpp + _matrix_driver(1)) == "20\n" + + +@pytest.mark.parametrize("declaration_kind", ["var", "varip"]) +def test_nested_callable_var_and_varip_initialize_on_delayed_first_reach( + declaration_kind: str, +) -> None: + source = f'''//@version=6 +strategy("nested callable varip first reach") +probe(bool active) => + float observed = -1.0 + if active + {declaration_kind} float state = close + observed := state + observed +left_value = probe(bar_index >= 1) +right_value = probe(bar_index >= 2) +''' + cpp = transpile(source, check_support=declaration_kind == "var") + assert _compile_and_run(cpp + _matrix_driver(2)) == "20 30\n" + + +def test_loop_scoped_callable_var_initializes_on_first_iteration_per_callsite() -> None: + source = '''//@version=6 +strategy("loop scoped callable var first reach") +probe(float bias) => + float observed = -1.0 + for i = 0 to 2 + var float first = close + i + bias + observed := first + observed +left_value = probe(0.0) +right_value = probe(100.0) +''' + cpp = transpile(source) + assert "for (int i = _for_start_" in cpp + assert cpp.index("for (int i = _for_start_") < cpp.index( + "if (!this->_pf_var_init_first)" + ) + assert _compile_and_run(cpp + _matrix_driver(2)) == "10 110\n" + + +@pytest.mark.parametrize("storage", ["scalar", "array", "map", "matrix", "udt"]) +def test_direct_callable_var_source_order_isolated_per_written_callsite( + storage: str, +) -> None: + preamble = "" + if storage == "scalar": + declaration = "var float state = seed" + read = "state" + elif storage == "array": + declaration = "var array state = array.from(seed)" + read = "state.get(0)" + elif storage == "map": + preamble = '''build_state(float seed) => + map result = map.new() + result.put("seed", seed) + result +''' + declaration = "var map state = build_state(seed)" + read = 'state.get("seed")' + elif storage == "matrix": + declaration = "var matrix state = matrix.new(1, 1, seed)" + read = "state.get(0, 0)" + else: + preamble = "type Point\n float value\n" + declaration = "var Point state = Point.new(seed)" + read = "state.value" + source = ( + "//@version=6\n" + 'strategy("direct callable var callsite matrix")\n' + + preamble + + "probe(float bias) =>\n" + " float seed = close + bias\n" + f" {declaration}\n" + f" {read}\n" + "left_value = probe(1.0)\n" + "right_value = probe(100.0)\n" + ) + assert _compile_and_run(transpile(source) + _matrix_driver(2)) == "11 110\n" + + +def test_callable_once_flags_reserve_authored_callsite_clone_names() -> None: + source = '''//@version=6 +strategy("callable once flag collision") +probe(bool active) => + var float _pf_var_init_state = 100.0 + float observed = -1.0 + if active + var float state = close + observed := state + observed + _pf_var_init_state +left_value = probe(bar_index >= 1) +right_value = probe(bar_index >= 2) +''' + cpp = transpile(source) + assert "double _pf_var_init_state_cs1 = na();" in cpp + assert "bool _pf_var_init_state_2 = false;" in cpp + assert "bool _pf_var_init_state_cs1_2 = false;" in cpp + assert cpp.count(" _pf_var_init_state_cs1 =") == 1 + assert _compile_and_run(cpp + _matrix_driver(2)) == "120 130\n" + + +def test_unreached_callable_primitive_base_and_clone_remain_na() -> None: + source = '''//@version=6 +strategy("unreached callable primitive state") +probe(bool active) => + if active + var float state = close + 0.0 +left_value = probe(false) +right_value = probe(false) +''' + cpp = transpile(source) + assert "double state = na();" in cpp + assert "double state_cs1 = na();" in cpp + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 11.0, 9.0, 10.0, 1.0, 1000}, + Bar{20.0, 21.0, 19.0, 20.0, 1.0, 61000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 2); + std::cout << is_na(strategy.state) << " " + << is_na(strategy.state_cs1) << "\n"; +} +''' + assert _compile_and_run(cpp + driver) == "1 1\n" + + +def test_callable_conditional_ta_keeps_lifecycle_and_side_effects_in_branch() -> None: + source = '''//@version=6 +strategy("conditional TA callable lifecycle") +var array reached = array.new() +probe(float src, bool active) => + float observed = na + if active + reached.push(bar_index) + var float state = src + float avg = ta.sma(src, 2) + float fixed = fixnan(src) + float prev = src[1] + observed := state + avg + fixed + prev + observed +value = probe(close, bar_index >= 1) +reach_count = reached.size() +first_reach = reach_count > 0 ? reached.get(0) : -1 +''' + cpp = transpile(source) + body = cpp[cpp.index("double probe_cs0("):cpp.index(" void on_bar(")] + assert body.index("if (active) {") < body.index( + "if (!this->_pf_var_init_state)" + ) + assert body.index("if (active) {") < body.index("reached.push") + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 11.0, 9.0, 10.0, 1.0, 1000}, + Bar{20.0, 21.0, 19.0, 20.0, 1.0, 61000}, + Bar{30.0, 31.0, 29.0, 30.0, 1.0, 121000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.value << " " << strategy.reach_count << " " + << strategy.first_reach << "\n"; +} +''' + assert _compile_and_run(cpp + driver) == "95 2 1\n" + + +def test_callable_conditional_ta_history_advances_only_on_executed_branch() -> None: + source = '''//@version=6 +strategy("conditional TA local history") +probe(float src, bool active) => + float observed = na + if active + var float state = src + float avg = ta.sma(src, 2) + float fixed = fixnan(src) + float prev = src[1] + observed := state + avg + fixed + prev + observed +value = probe(close, bar_index != 1) +''' + cpp = transpile(source) + body = cpp[cpp.index("double probe_cs0("):cpp.index(" void on_bar(")] + assert "if (active) {" in body + assert "_hoist_" not in body + # The conditional SMA sees bars 0 and 2, so its final value is 20. Pine + # warns about this lazy local history; it does not rewrite the call to run + # on the skipped bar 1. Eager TA advancement would incorrectly yield 85. + assert _compile_and_run(cpp + _matrix_driver(1)) == "80\n" + + +@pytest.mark.parametrize("storage", ["scalar", "array"]) +@pytest.mark.parametrize("callsites", [1, 2], ids=["one-site", "two-sites"]) +def test_udt_method_persistent_state_isolated_per_written_callsite( + storage: str, callsites: int +) -> None: + if storage == "scalar": + declaration = "var float state = close" + read = "state" + else: + declaration = "var array state = array.from(close)" + read = "state.get(0)" + calls = ["value = holder.probe(bar_index >= 1)"] + if callsites == 2: + calls = [ + "left_value = holder.probe(bar_index >= 1)", + "right_value = holder.probe(bar_index >= 2)", + ] + source = ( + "//@version=6\n" + 'strategy("method written callsite state")\n' + "type Holder\n" + " float pad\n" + "method probe(Holder self, bool active) =>\n" + " float observed = -1.0\n" + " if active\n" + f" {declaration}\n" + f" observed := {read}\n" + " observed\n" + "var Holder holder = Holder.new(0.0)\n" + + "\n".join(calls) + + "\n" + ) + cpp = transpile(source) + assert "_udt_Holder_probe_cs0" in cpp + assert ("_udt_Holder_probe_cs1" in cpp) == (callsites == 2) + expected = "20\n" if callsites == 1 else "20 30\n" + assert _compile_and_run(cpp + _matrix_driver(callsites)) == expected + + +def test_stateless_method_and_udf_stay_on_uncloned_compile_paths() -> None: + source = '''//@version=6 +strategy("stateless callable controls") +type Holder + float pad +method add(Holder self, float value) => value + self.pad +plus_one(float value) => value + 1.0 +var Holder holder = Holder.new(2.0) +method_value = holder.add(close) +udf_value = plus_one(close) +''' + cpp = transpile(source) + assert "double _udt_Holder_add(" in cpp + assert "_udt_Holder_add_cs" not in cpp + assert "double plus_one(" in cpp + assert "plus_one_cs" not in cpp + driver = r''' +#include +int main() { + Bar bars[] = {Bar{10.0, 11.0, 9.0, 10.0, 1.0, 1000}}; + GeneratedStrategy strategy; + strategy.run(bars, 1); + std::cout << strategy.method_value << " " << strategy.udf_value << "\n"; +} +''' + assert _compile_and_run(cpp + driver) == "12 11\n" diff --git a/tests/test_codegen_drawing_data.py b/tests/test_codegen_drawing_data.py index dec27ee..1b7f995 100644 --- a/tests/test_codegen_drawing_data.py +++ b/tests/test_codegen_drawing_data.py @@ -585,7 +585,7 @@ def test_global_drawing_and_persistent_scalar_get_distinct_storage(): cpp = transpile(src) assert " Line x = Line{};" in cpp - assert " double _pfv_1_x__f;" in cpp + assert " double _pfv_1_x__f = na();" in cpp assert "_pfv_1_x__f = 1.0;" in cpp assert "x = pf_line_new(" in cpp skip_if_no_compile_env() diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index 953a6a2..69f1380 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -545,10 +545,10 @@ def test_callable_persistent_siblings_keep_exact_clone_storage(): "b = wrap(open)\n" "plot(a + b)" ) - assert "double x;" in cpp - assert "double x__blk1;" in cpp - assert "double x_cs1;" in cpp - assert "double x__blk1_cs1;" in cpp + assert "double x = na();" in cpp + assert "double x__blk1 = na();" in cpp + assert "double x_cs1 = na();" in cpp + assert "double x__blk1_cs1 = na();" in cpp assert "scaled = (x * 2.0)" in cpp assert "scaled = (x_cs1 * 2.0)" in cpp assert "double _sv = (x__blk1)" in cpp @@ -1205,7 +1205,7 @@ def test_per_call_site_clone_of_drawing_var_is_handle_not_double(): # --------------------------------------------------------------------------- -# Round 3: function-scoped ``var`` one-shot initializer semantics +# Round 3: function-scoped ``var`` exact declaration-site semantics # (jevondijefferson "drawing access on na handle" + counter root cause) # --------------------------------------------------------------------------- def test_function_scoped_var_numeric_init_runs_once(): @@ -1220,12 +1220,13 @@ def test_function_scoped_var_numeric_init_runs_once(): "_z = counter()\n" "plot(_z)" ) - # The initializer must be emitted, guarded by a per-function init flag. - assert "_fvinit_counter" in cpp + # The initializer must be emitted at the declaration and guarded by its + # own exact-site latch. The retained legacy member is checkpoint-shape + # compatibility only and must not control initialization timing. + assert "if (!this->_pf_var_init_c)" in cpp assert "c = 5" in cpp - assert "bool _fvinit_counter" in cpp # the flag member exists - # The init is gated (runs once), not unconditionally each call. - assert "if (!_fvinit_counter" in cpp + assert "bool _fvinit_counter_cs0 = false;" in cpp + assert "if (!_fvinit_counter_cs0" not in cpp def test_function_scoped_var_drawing_handle_init_runs_once(): @@ -1281,7 +1282,8 @@ def test_function_scoped_var_udt_init_runs_once(): "_z = f()\n" "plot(_z)" ) - assert "_fvinit_f" in cpp + assert "if (!this->_pf_var_init_p)" in cpp + assert "if (!_fvinit_f_cs0" not in cpp # The UDT constructor expression is lowered inside the guarded init block. assert "p = pivot{" in cpp or "p = pivot(" in cpp @@ -1300,13 +1302,15 @@ def test_function_scoped_var_na_drawing_handle_skips_assignment(): "plot(_a)" ) assert "x = na()" not in cpp - assert "bool _fvinit_f" in cpp + # Bare ``na`` drawing handles need neither an assignment nor an exact-site + # latch. The legacy per-variant member remains for checkpoint compatibility. + assert "bool _fvinit_f_cs0 = false;" in cpp def test_function_scoped_var_not_in_constructor_init_list(): - # Function-scoped var members are initialized once-per-call in the function - # body, NOT in the constructor initializer list (avoid double-init + allows - # bar-dependent initializers). + # Function-scoped var members initialize at their exact declaration site in + # the function body, NOT in the constructor initializer list (avoids + # double-init and allows bar-dependent/prior-local initializers). cpp = _cpp( "counter() =>\n" " var int c = 5\n" diff --git a/tests/test_method_written_callsite_lifecycle.py b/tests/test_method_written_callsite_lifecycle.py new file mode 100644 index 0000000..d05b65e --- /dev/null +++ b/tests/test_method_written_callsite_lifecycle.py @@ -0,0 +1,458 @@ +"""UDT-method state follows Pine's written-callsite lifecycle.""" + +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile +from tests.test_runtime_var_initialization import _compile_and_run + + +def _driver(outputs: list[str], *, split_ohlc: bool = False) -> str: + streamed = ' << " " << '.join( + f"strategy.{output}" for output in outputs + ) + if split_ohlc: + bars = """ + Bar{1.0, 15.0, 1.0, 15.0, 1.0, 0}, + Bar{2.0, 25.0, 2.0, 25.0, 1.0, 60000}, + Bar{3.0, 35.0, 3.0, 35.0, 1.0, 120000}, +""" + else: + bars = """ + Bar{15.0, 15.0, 15.0, 15.0, 1.0, 0}, + Bar{25.0, 25.0, 25.0, 25.0, 1.0, 60000}, + Bar{35.0, 35.0, 35.0, 35.0, 1.0, 120000}, +""" + return f""" +#include +#include +int main() {{ + Bar bars[] = {{{bars} }}; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << std::fixed << std::setprecision(1) + << {streamed} << "\\n"; + return 0; +}} +""" + + +def _persistent_method_source(*, nested: bool, written_sites: int) -> str: + body = ( + """method sample(Holder self) => + float observed = na + if true + var float state = close + observed := state + observed +""" + if nested + else """method sample(Holder self) => + var float state = close + state +""" + ) + calls = "first = time >= 60000 ? holder.sample() : na\n" + if written_sites == 2: + calls += "second = time >= 120000 ? holder.sample() : na\n" + return ( + """//@version=6 +strategy("method written callsite lifecycle") +type Holder + float seed +""" + + body + + "var Holder holder = Holder.new(0.0)\n" + + calls + ) + + +@pytest.mark.parametrize("nested", [False, True], ids=["direct", "nested"]) +@pytest.mark.parametrize("written_sites", [1, 2], ids=["one-site", "two-sites"]) +def test_method_var_written_callsite_matrix( + nested: bool, written_sites: int +) -> None: + cpp = transpile( + _persistent_method_source(nested=nested, written_sites=written_sites) + ) + + assert "double _udt_Holder_sample_cs0(" in cpp + assert "_fvinit__udt_Holder_sample_cs0" in cpp + assert "_udt_Holder_sample_cs0(holder)" in cpp + if written_sites == 2: + assert "double _udt_Holder_sample_cs1(" in cpp + assert "_fvinit__udt_Holder_sample_cs1" in cpp + assert "_udt_Holder_sample_cs1(holder)" in cpp + outputs = ["first", "second"] + expected = "25.0 35.0\n" + else: + assert "_udt_Holder_sample_cs1" not in cpp + outputs = ["first"] + expected = "25.0\n" + assert _compile_and_run(cpp + _driver(outputs)) == expected + + +def test_method_state_propagates_through_udf_wrapper_paths() -> None: + source = """//@version=6 +strategy("method wrapper written callsite lifecycle") +type Holder + float seed +var Holder holder = Holder.new(0.0) +method sample(Holder self) => + var float state = close + state +wrapped() => holder.sample() +first = time >= 60000 ? wrapped() : na +second = time >= 120000 ? wrapped() : na +""" + cpp = transpile(source) + + assert "double wrapped_cs0()" in cpp + assert "double wrapped_cs1()" in cpp + assert "return _udt_Holder_sample_cs0(holder);" in cpp + assert "return _udt_Holder_sample_cs1(holder);" in cpp + assert _compile_and_run(cpp + _driver(["first", "second"])) == ( + "25.0 35.0\n" + ) + + +def test_wrapper_udt_parameter_shadows_same_named_global_for_callsite_state() -> None: + source = """//@version=6 +strategy("lexical method receiver") +type A + float pad +type B + float pad +method sample(B self) => + var float state = close + state +var A obj = A.new(0.0) +wrapped(B obj) => obj.sample() +var B b = B.new(0.0) +first = time >= 60000 ? wrapped(b) : na +second = time >= 120000 ? wrapped(b) : na +""" + cpp = transpile(source) + + assert "double wrapped_cs0(B& obj)" in cpp + assert "double wrapped_cs1(B& obj)" in cpp + assert "return _udt_B_sample_cs0(obj);" in cpp + assert "return _udt_B_sample_cs1(obj);" in cpp + assert _compile_and_run(cpp + _driver(["first", "second"])) == ( + "25.0 35.0\n" + ) + + +def test_equal_udt_ternary_receiver_keeps_written_callsite_state() -> None: + source = """//@version=6 +strategy("ternary method receiver") +type Holder + float seed +method sample(Holder self) => + var float state = close + state +var Holder left = Holder.new(0.0) +var Holder right = Holder.new(1.0) +first = time >= 60000 ? (close > open ? left : right).sample() : na +second = time >= 120000 ? (close < open ? left : right).sample() : na +""" + cpp = transpile(source) + + assert "double _udt_Holder_sample_cs0(" in cpp + assert "double _udt_Holder_sample_cs1(" in cpp + assert _compile_and_run(cpp + _driver(["first", "second"])) == ( + "25.0 35.0\n" + ) + + +def test_method_var_ta_history_and_fixnan_clone_together() -> None: + source = """//@version=6 +strategy("method state clone panel") +type Holder + float seed +method sample(Holder self, float src) => + var float state = src + avg = ta.sma(src, 2) + fixed = fixnan(src) + prev = src[1] + state + avg + fixed + prev +var Holder holder = Holder.new(0.0) +first = holder.sample(close) +second = holder.sample(open) +""" + cpp = transpile(source) + + assert "ta::SMA _ta_sma_1;" in cpp + assert "ta::SMA _ta_sma_1_cs1;" in cpp + assert "double _prev_fixnan_1 = na();" in cpp + assert "double _prev_fixnan_1_cs1 = na();" in cpp + assert ( + "double _udt_Holder_sample_cs0(Holder& self, " + "const Series& src)" in cpp + ) + assert ( + "double _udt_Holder_sample_cs1(Holder& self, " + "const Series& src)" in cpp + ) + assert "first = _udt_Holder_sample_cs0(holder, _s_close);" in cpp + assert "second = _udt_Holder_sample_cs1(holder, _s_open);" in cpp + assert _compile_and_run( + cpp + _driver(["first", "second"], split_ohlc=True) + ) == "105.0 8.5\n" + + +def test_method_series_requirement_propagates_through_udf_wrapper_chain() -> None: + source = """//@version=6 +strategy("method series wrapper propagation") +type Holder + float seed +method sample(Holder self, float src, bool active) => + float observed = na + if active + var float state = src + float avg = ta.sma(src, 2) + float fixed = fixnan(src) + float prev = src[1] + observed := state + avg + fixed + prev + observed +var Holder holder = Holder.new(0.0) +inner(float src, bool active) => holder.sample(src, active) +outer(float src, bool active) => inner(src, active) +first = outer(close, true) +second = outer(open, true) +""" + cpp = transpile(source) + + for name in ("inner_cs0", "inner_cs1", "outer_cs0", "outer_cs1"): + assert f"double {name}(const Series& src, bool active)" in cpp + assert ( + "double _udt_Holder_sample_cs0(Holder& self, " + "const Series& src, bool active)" in cpp + ) + assert ( + "double _udt_Holder_sample_cs1(Holder& self, " + "const Series& src, bool active)" in cpp + ) + assert _compile_and_run( + cpp + _driver(["first", "second"], split_ohlc=True) + ) == "105.0 8.5\n" + + +def test_method_series_requirement_promotes_wrapper_parameter() -> None: + source = """//@version=6 +strategy("method series wrapper") +type Holder + float seed +method sample(Holder self, float src, bool active) => + var float state = src + avg = ta.sma(src, 2) + fixed = fixnan(src) + prev = src[1] + active ? state + avg + fixed + prev : na +var Holder holder = Holder.new(0.0) +wrapped(float src, bool active) => holder.sample(src, active) +first = wrapped(close, true) +""" + cpp = transpile(source) + + assert "double wrapped_cs0(const Series& src, bool active)" in cpp + assert "return _udt_Holder_sample_cs0(holder, src, active);" in cpp + assert "first = wrapped_cs0(_s_close, true);" in cpp + assert _compile_and_run( + cpp + _driver(["first"], split_ohlc=True) + ) == "105.0\n" + + +def test_method_series_keyword_compound_actual_uses_local_bridge() -> None: + source = """//@version=6 +strategy("method keyword compound wrapper") +type Holder + float seed +method sample(Holder self, float src, bool active) => + var float state = src + avg = ta.sma(src, 2) + fixed = fixnan(src) + prev = src[1] + active ? state + avg + fixed + prev : na +var Holder holder = Holder.new(0.0) +wrapped(float src) => holder.sample(active = true, src = src + 1.0) +first = wrapped(close) +""" + cpp = transpile(source) + + # A compound actual needs its own history bridge inside the wrapper; the + # scalar wrapper parameter itself must not be over-promoted to Series. + assert "double wrapped_cs0(double src)" in cpp + assert "Series _series_arg_1;" in cpp + assert "_udt_Holder_sample_cs0(holder, ([&]() -> const Series&" in cpp + assert _compile_and_run( + cpp + _driver(["first"], split_ohlc=True) + ) == "109.0\n" + + +def test_method_conditional_ta_preserves_delayed_var_first_reach() -> None: + source = '''//@version=6 +strategy("method conditional TA lifecycle") +type Holder + float seed +method sample(Holder self, float src, bool active) => + float observed = na + if active + var float state = src + float avg = ta.sma(src, 2) + float fixed = fixnan(src) + float prev = src[1] + observed := state + avg + fixed + prev + observed +var Holder holder = Holder.new(0.0) +first = holder.sample(close, bar_index >= 1) +''' + cpp = transpile(source) + body = cpp[ + cpp.index("double _udt_Holder_sample_cs0("):cpp.index(" void on_bar(") + ] + assert body.index("if (active) {") < body.index( + "if (!this->_pf_var_init_state)" + ) + assert _compile_and_run( + cpp + _driver(["first"], split_ohlc=True) + ) == "115.0\n" + + +def test_history_series_requirement_flows_from_udf_into_calling_method() -> None: + source = '''//@version=6 +strategy("method calls history UDF") +history(float src) => src[1] +type Holder + float seed +method sample(Holder self, float src) => history(src) +var Holder holder = Holder.new(0.0) +first = holder.sample(close) +second = holder.sample(open) +''' + cpp = transpile(source) + assert ( + "double _udt_Holder_sample_cs0(Holder& self, " + "const Series& src)" in cpp + ) + assert "return history_cs0(src);" in cpp + assert "return history_cs1(src);" in cpp + assert _compile_and_run( + cpp + _driver(["first", "second"], split_ohlc=True) + ) == "25.0 2.0\n" + + +def test_history_series_requirement_flows_between_sibling_methods() -> None: + source = '''//@version=6 +strategy("sibling method history flow") +type Holder + float seed +method inner(Holder self, float src) => src[1] +method outer(Holder self, float src) => self.inner(src) +var Holder holder = Holder.new(0.0) +first = holder.outer(close) +second = holder.outer(open) +''' + cpp = transpile(source) + for index in (0, 1): + assert ( + f"double _udt_Holder_outer_cs{index}(Holder& self, " + "const Series& src)" in cpp + ) + assert f"_udt_Holder_inner_cs{index}(self, src)" in cpp + assert _compile_and_run( + cpp + _driver(["first", "second"], split_ohlc=True) + ) == "25.0 2.0\n" + + +def test_compound_history_argument_flows_between_sibling_methods() -> None: + source = '''//@version=6 +strategy("sibling method compound history bridge") +type Holder + float seed +method inner(Holder self, float src) => src[1] +method outer(Holder self, float src) => self.inner(src + 1.0) +var Holder holder = Holder.new(0.0) +first = holder.outer(close) +second = holder.outer(open) +''' + cpp = transpile(source) + assert "Series _series_arg_" in cpp + assert _compile_and_run( + cpp + _driver(["first", "second"], split_ohlc=True) + ) == "26.0 3.0\n" + + +def test_compound_history_argument_uses_wrapper_udt_parameter_type() -> None: + source = '''//@version=6 +strategy("wrapper method compound history bridge") +type Holder + float seed +method inner(Holder self, float src) => src[1] +wrapped(Holder obj, float src) => obj.inner(src + 1.0) +var Holder holder = Holder.new(0.0) +first = wrapped(holder, close) +second = wrapped(holder, open) +''' + cpp = transpile(source) + assert "Series _series_arg_" in cpp + assert _compile_and_run( + cpp + _driver(["first", "second"], split_ohlc=True) + ) == "26.0 3.0\n" + + +def test_compound_method_history_bridge_wins_over_same_named_udf() -> None: + source = '''//@version=6 +strategy("method history bridge name collision") +inner(float src) => src + 100.0 +type Holder + float seed +method inner(Holder self, float src) => src[1] +method outer(Holder self, float src) => self.inner(src + 1.0) +var Holder holder = Holder.new(0.0) +first = holder.outer(close) +second = holder.outer(open) +''' + cpp = transpile(source) + assert "Series _series_arg_" in cpp + assert "_udt_Holder_inner_cs0(self," in cpp + assert "return inner_cs0(" not in cpp + assert _compile_and_run( + cpp + _driver(["first", "second"], split_ohlc=True) + ) == "26.0 3.0\n" + + +def test_method_to_ta_udf_keeps_scalar_parameter_control() -> None: + source = '''//@version=6 +strategy("method calls TA UDF scalar control") +smooth(float src) => ta.sma(src, 2) +type Holder + float seed +method sample(Holder self, float src) => smooth(src) +var Holder holder = Holder.new(0.0) +first = holder.sample(close) +second = holder.sample(open) +''' + cpp = transpile(source) + assert "_udt_Holder_sample_cs0(Holder& self, double src)" in cpp + assert _compile_and_run( + cpp + _driver(["first", "second"], split_ohlc=True) + ) == "30.0 2.5\n" + + +def test_stateless_method_does_not_gain_clone_state() -> None: + source = """//@version=6 +strategy("pure method remains single") +type Holder + float seed +method pure(Holder self, float value) => value + self.seed +var Holder holder = Holder.new(2.0) +first = holder.pure(close) +second = holder.pure(open) +""" + cpp = transpile(source) + + assert cpp.count("double _udt_Holder_pure(") == 1 + assert "_udt_Holder_pure_cs" not in cpp + assert "_fvinit__udt_Holder_pure" not in cpp diff --git a/tests/test_na_reassign_retype.py b/tests/test_na_reassign_retype.py index 375906b..2c3bcd4 100644 --- a/tests/test_na_reassign_retype.py +++ b/tests/test_na_reassign_retype.py @@ -95,7 +95,7 @@ def test_function_local_int_var_na_reassign_retypes_to_int(): plot(f()) """ cpp = transpile(src) - assert "int et;" in cpp + assert "int et = na();" in cpp # No double-NaN slip into the int member (init block OR reassignment). assert "et = na();" not in cpp, cpp assert "et = na();" in cpp diff --git a/tests/test_persistent_callable_owner_state.py b/tests/test_persistent_callable_owner_state.py index 44b64fd..8ffc54f 100644 --- a/tests/test_persistent_callable_owner_state.py +++ b/tests/test_persistent_callable_owner_state.py @@ -8,6 +8,7 @@ from __future__ import annotations +import math import re import pytest @@ -101,7 +102,8 @@ def _qualified_member(cpp: str, cpp_type: str, raw: str, owner: str) -> str: """Resolve one opaque reserved member without pinning its sequence token.""" match = re.search( rf"^ {re.escape(cpp_type)} " - rf"(_pfv_[0-9]+_{re.escape(raw)}__{re.escape(owner)});$", + rf"(_pfv_[0-9]+_{re.escape(raw)}__{re.escape(owner)})" + rf"(?: = [^;]+)?;$", cpp, re.M, ) @@ -139,7 +141,12 @@ def test_reverse_nested_multicallsite_storage_is_independent_at_runtime( left = _qualified_member(cpp, cpp_type, "state", "left") right = _qualified_member(cpp, cpp_type, "state", "right") for name in (left, right, f"{left}_cs1", f"{right}_cs1"): - assert f" {cpp_type} {name};" in cpp + declaration = ( + f" {cpp_type} {name} = na();" + if not array + else f" {cpp_type} {name};" + ) + assert declaration in cpp assert _compile_and_run(cpp + _driver(outputs)) == ("3.0 120.0 3.0 120.0\n") @@ -274,8 +281,9 @@ def test_qualified_storage_cannot_alias_generated_sibling_block_clone() -> None: assert checkpoint_members.count(qualified) == 1 assert checkpoint_members.count(block_clone) == 1 compile_cpp(cpp, label="persistent-owner-generated-block-clone") - assert ( - _compile_and_run( + observed = tuple( + float(value) + for value in _compile_and_run( cpp + _driver( [ @@ -288,9 +296,12 @@ def test_qualified_storage_cannot_alias_generated_sibling_block_clone() -> None: ], bars=1, ) - ) - == "2.0 110.0 8.0 70.0 7.0 80.0\n" + ).split() ) + assert observed[:3] == (2.0, 110.0, 8.0) + assert math.isnan(observed[3]) + assert math.isnan(observed[4]) + assert observed[5] == 80.0 def test_direct_and_block_persistent_name_ambiguity_fails_closed() -> None: @@ -542,8 +553,8 @@ def test_noncolliding_persistent_output_keeps_legacy_member_names() -> None: """) assert "_pfv_" not in cpp - assert " double state;" in cpp - assert " double state_cs1;" in cpp + assert " double state = na();" in cpp + assert " double state_cs1 = na();" in cpp assert "state = 1.0;" in cpp assert "state_cs1 = 1.0;" in cpp compile_cpp(cpp, label="persistent-owner-noncolliding") @@ -1369,7 +1380,7 @@ def test_authored_helper_and_clone_tokens_are_never_reused() -> None: assert left == "_pfv_2_state__left" assert right == "_pfv_4_state__right" for name in (left, right, f"{left}_cs1", f"{right}_cs1"): - assert re.search(rf"^ double {name};$", cpp, re.M) + assert re.search(rf"^ double {name} = na\(\);$", cpp, re.M) assert not re.search(r"^ double _pfv_1_state__left;$", cpp, re.M) assert not re.search(r"^ double _pfv_3_state__right;$", cpp, re.M) compile_cpp(cpp, label="persistent-owner-authored-helper-tokens") @@ -1397,10 +1408,10 @@ def test_global_and_udt_method_storage_stay_on_existing_paths() -> None: a = left() b = right() c = holder.read_float() -""") + """) assert " int globalState;" in cpp - assert " int methodState;" in cpp + assert " int methodState = na();" in cpp assert not re.search(r"_pfv_[0-9]+_globalState__", cpp) assert not re.search(r"_pfv_[0-9]+_methodState__", cpp) _qualified_member(cpp, "int", "state", "left") diff --git a/tests/test_runtime_var_initialization.py b/tests/test_runtime_var_initialization.py index f757767..dd1226e 100644 --- a/tests/test_runtime_var_initialization.py +++ b/tests/test_runtime_var_initialization.py @@ -193,7 +193,7 @@ def test_switch_expression_case_vars_receive_runtime_init_routes(): assert "result = caseState__blk1;" in cpp -def test_function_vars_keep_per_variant_initialization_route(): +def test_function_vars_use_exact_declaration_site_initialization_route(): cpp = transpile("""//@version=6 strategy("function var init isolation") helper() => @@ -203,8 +203,11 @@ def test_function_vars_keep_per_variant_initialization_route(): b = helper() """) - assert "_pf_var_init_functionSeed" not in cpp - assert "_fvinit_" in cpp + assert "if (!this->_pf_var_init_functionSeed)" in cpp + # Legacy per-variant members remain part of the checkpoint shape, but the + # callable body must no longer consult them for initialization timing. + assert "bool _fvinit_helper_cs0 = false;" in cpp + assert "if (!_fvinit_helper_cs0" not in cpp assert "functionSeed = current_bar_.high;" in cpp