From d1a9e6f5bb04b664873d5923a0b7bd6e34f114a6 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Fri, 17 Jul 2026 18:10:16 +0800 Subject: [PATCH] fix: initialize persistent runtime scalars deterministically --- pineforge_codegen/analyzer/base.py | 58 +++- pineforge_codegen/analyzer/contracts.py | 3 + pineforge_codegen/codegen/base.py | 138 +++++++- pineforge_codegen/codegen/emit_top.py | 5 + pineforge_codegen/codegen/visit_stmt.py | 104 ++++-- tests/test_runtime_var_initialization.py | 409 +++++++++++++++++++++++ 6 files changed, 672 insertions(+), 45 deletions(-) create mode 100644 tests/test_runtime_var_initialization.py diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index ac4b906..606535f 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -127,14 +127,19 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._global_var_decls: list[tuple[str, PineType]] = [] self._global_expr_map: dict[str, Any] = {} self._var_member_init_exprs: dict[str, Any] = {} + # Exact declaration-node ownership for persistent vars. The member + # name can differ from ``node.name`` when sibling blocks declare the + # same raw identifier, so codegen must not reconstruct this mapping + # from names when it emits declaration-site initialization. + self._var_member_metadata_by_node: dict[int, tuple] = {} # Block-scoped ``var``/``varip`` name-collision disambiguation. # Two same-named block-scoped vars in SIBLING non-global, non-function # scopes (e.g. ``var bool valid`` declared inside ``if A`` and again # inside ``if B``) would otherwise dedupe to ONE C++ member and # cross-contaminate. ``_block_node_stack`` tracks the enclosing - # block AST nodes during analysis; ``_block_var_owner`` maps a raw - # block-var name to the id() of the FIRST block that declared it; - # ``_block_var_renames`` maps id(block_node) -> {raw_name: unique} + # branch/loop body owners during analysis; ``_block_var_owner`` maps a + # raw block-var name to the id() of the FIRST body that declared it; + # ``_block_var_renames`` maps id(body_owner) -> {raw_name: unique} # for every later colliding block so codegen can activate the # rename via ``_active_var_remap`` while emitting that block. self._block_node_stack: list[Any] = [] @@ -341,6 +346,7 @@ def analyze(self) -> AnalyzerContext: global_var_decls=self._global_var_decls, global_expr_map=pure_global_expr_map, var_member_init_exprs=self._var_member_init_exprs, + var_member_metadata_by_node=self._var_member_metadata_by_node, func_ta_ranges=self._func_ta_ranges, func_call_cs_map=self._func_call_cs_map, func_call_site_counts=self._func_call_site_count, @@ -1277,6 +1283,16 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: member_name = f"{node.name}__blk{self._block_var_seq}" self._block_var_renames.setdefault(block_id, {})[node.name] = member_name self._var_members.append((member_name, val_type, init_str)) + scope_cursor = self._symbols.current_scope + is_callable_scoped = False + while scope_cursor is not None: + if scope_cursor.name.startswith(("func_", "method_")): + is_callable_scoped = True + break + scope_cursor = scope_cursor.parent + self._var_member_metadata_by_node[id(node)] = ( + node, member_name, val_type, init_str, is_callable_scoped, + ) # Capture the init AST too so codegen can inspect the RHS callee # (used to detect int64-returning builtins like ``time()`` and # promote the symbol storage type to ``int64_t``). @@ -1675,16 +1691,23 @@ def _visit_MethodDef(self, node) -> PineType: def _visit_IfStmt(self, node: IfStmt) -> PineType: old_global = self._global_scope self._global_scope = False - self._block_node_stack.append(node) try: self._visit(node.condition) body_type = PineType.VOID - for stmt in node.body: - body_type = self._visit(stmt) - for stmt in node.else_body: - self._visit(stmt) + self._block_node_stack.append(node.body) + try: + for stmt in node.body: + body_type = self._visit(stmt) + finally: + self._block_node_stack.pop() + if node.else_body: + self._block_node_stack.append(node.else_body) + try: + for stmt in node.else_body: + self._visit(stmt) + finally: + self._block_node_stack.pop() finally: - self._block_node_stack.pop() self._global_scope = old_global # If used as expression (x = if ...), return last expr type return body_type @@ -1776,10 +1799,19 @@ def _visit_SwitchStmt(self, node: SwitchStmt) -> PineType: for case_expr, case_body in node.cases: if case_expr: self._visit(case_expr) - for stmt in case_body: - result_type = self._visit(stmt) - for stmt in node.default_body: - self._visit(stmt) + self._block_node_stack.append(case_body) + try: + for stmt in case_body: + result_type = self._visit(stmt) + finally: + self._block_node_stack.pop() + if node.default_body: + self._block_node_stack.append(node.default_body) + try: + for stmt in node.default_body: + self._visit(stmt) + finally: + self._block_node_stack.pop() finally: self._global_scope = old_global # If used as expression (x = switch ...), return last expr type diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index 167da77..5e9ce72 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -199,6 +199,9 @@ class AnalyzerContext: global_var_decls: list = field(default_factory=list) # [(name, PineType)] non-var global scope vars global_expr_map: dict = field(default_factory=dict) # name -> defining AST expr (global, non-var) var_member_init_exprs: dict = field(default_factory=dict) # var-member name -> init AST expr + # id(VarDecl) -> (node, emitted member name, PineType, rendered initializer, + # is function/method scoped) + var_member_metadata_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) func_call_cs_map: dict = field(default_factory=dict) # call_node_id -> (func_name, call_site_index) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 51028bc..ea72972 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -742,6 +742,114 @@ def __init__(self, ctx: AnalyzerContext) -> None: # history reads off security-helper series. self._max_bars_back_cap: int | None = self._compute_max_bars_back_cap() + # 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() + + def _scalar_var_init_depends_on_runtime_input(self, init_ast) -> bool: + """Whether a nominally constant initializer depends on input state. + + ``_resolve_known`` intentionally folds input defaults for constructor + sizing and other compile-time decisions. A Pine ``var`` initializer, + however, must observe any host override installed before the first + bar. Treat direct input aliases and arithmetic aliases derived from + them as declaration-time expressions even when their default happens + to fold to a C++ literal. + """ + if init_ast is None: + return False + runtime_names = set(self._input_backed_vars) | set(self._derived_input_expr) + for node in self._walk_ast(init_ast): + if isinstance(node, FuncCall) and self._is_input_call(node): + return True + if isinstance(node, Identifier) and node.name in runtime_names: + return True + return False + + def _is_runtime_scalar_var_initializer( + self, name: str, ptype, init_str: str, init_ast) -> 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. + """ + if init_ast is None or name in self.ctx.series_vars: + return False + if name in self._visual_drop_vars: + return False + if name in self._array_vars or name in self._map_vars \ + or name in self._matrix_specs: + return False + type_spec = self._collection_types.get(name) + if type_spec is not None and type_spec.kind in { + "array", "map", "matrix", "udt", + }: + return False + udt_type = self._udt_var_types.get(name) + if udt_type in self._udt_defs or udt_type in DRAWING_TYPE_TO_CPP: + return False + + ctor_val = self._resolve_known(init_str) + ctor_val = self._typed_na_init(ctor_val, name, ptype) + return ( + not self._is_compile_time_value(ctor_val) + or self._scalar_var_init_depends_on_runtime_input(init_ast) + ) + + def _prepare_runtime_scalar_var_initializers(self) -> None: + """Index declaration-site scalar ``var`` initialization and flags. + + The analyzer supplies exact metadata for every VarDecl, including + sibling-block disambiguation and callable ownership. Its insertion + order follows source analysis and reaches declarations nested inside + if/switch expressions, while the ownership bit excludes function and + method bodies. This lets emission preserve ordinary dependency order + and Pine's lazy first-entry semantics for conditional declarations. + """ + self._runtime_scalar_var_init_by_node: dict[int, dict] = {} + self._runtime_scalar_var_init_members: set[str] = set() + + used_names = set(self._all_member_names) + # ``_all_member_names`` historically covers persistent ``var`` and + # Series members only. Plain global declarations are class members as + # well, so include them before minting a generated flag; otherwise a + # legal user binding such as ``_pf_var_init_seeded = 1`` can collide + # with the flag for ``var seeded = low``. + used_names.update( + self._safe_name(name) for name, _ptype in self.ctx.global_var_decls + ) + metadata_by_node = getattr( + self.ctx, "var_member_metadata_by_node", {} + ) or {} + for node_id, meta in metadata_by_node.items(): + stmt, member_name, ptype, init_str, is_callable_scoped = meta + if is_callable_scoped: + continue + if not isinstance(stmt, VarDecl) or not (stmt.is_var or stmt.is_varip): + continue + if not self._is_runtime_scalar_var_initializer( + member_name, ptype, init_str, stmt.value): + 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) + self._runtime_scalar_var_init_members.add(member_name) + self._runtime_scalar_var_init_by_node[node_id] = { + "member_name": member_name, + "ptype": ptype, + "flag": flag, + } + # ------------------------------------------------------------------ # Context-sensitive (call-path) instance machinery # ------------------------------------------------------------------ @@ -1099,7 +1207,7 @@ def _walk_global_scope_with_loopflag(self, stmts, in_loop): a separate scope and must not be attributed to a global member. The ``in_loop`` flag is True once inside any for/while loop body.""" for s in stmts: - if isinstance(s, FuncDef): + if isinstance(s, (FuncDef, MethodDef)): continue yield s, in_loop child_in_loop = in_loop or isinstance(s, (ForStmt, ForInStmt, WhileStmt)) @@ -2408,7 +2516,16 @@ def generate(self) -> str: if name in self.ctx.series_vars: lines.append(f" Series<{cpp_type}> {safe}{_mbb};") else: - lines.append(f" {cpp_type} {safe};") + if name in self._runtime_scalar_var_init_members: + # A conditional declaration may not execute for many bars, + # while COOF rollback still value-copies every member. Give + # pending runtime vars a typed Pine-na sentinel so that copy + # is always defined; the declaration-site guard overwrites + # it on the first actual execution. + pending = self._typed_na_init("na()", name, ptype) + lines.append(f" {cpp_type} {safe} = {pending};") + else: + lines.append(f" {cpp_type} {safe};") # 6. Non-var series vars for name in sorted(self.ctx.series_vars): @@ -2532,7 +2649,14 @@ def generate(self) -> str: if self.ctx.var_members: lines.append(" bool _var_initialized = false;") - # 9a. Per-function-variant ``var`` init flags. A function-scoped + # 9a. Per-member flags for primitive runtime ``var`` / ``varip`` + # 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. + for info in self._runtime_scalar_var_init_by_node.values(): + lines.append(f" bool {info['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 @@ -2550,21 +2674,21 @@ def generate(self) -> str: else: lines.append(f" bool _fvinit_{self._func_safe_name(fi.name)} = false;") - # 9a2. ``var`` init flags for fresh context-sensitive helper instances. + # 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;") - # 9b. _ta_initialized_ flag for runtime TA re-sizing (first on_bar only). + # 9c. _ta_initialized_ flag for runtime TA re-sizing (first on_bar only). if self.ctx.ta_call_sites: lines.append(" bool _ta_initialized_ = false;") - # 9c. _inputs_initialized_ flag for cached global inputs. + # 9d. _inputs_initialized_ flag for cached global inputs. lines.append(" bool _inputs_initialized_ = false;") lines.append("") - # 9d. Historical execution rollback checkpoint. Derive the member + # 9e. Historical execution rollback checkpoint. Derive the member # inventory from the declarations above so every future generated # state category is captured automatically (or generation fails loudly # if it introduces an unfamiliar declaration form). diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 58ab140..29a38fa 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -412,6 +412,11 @@ def _emit_constructor(self, lines: list[str]) -> None: # own scope (with its active var remap for clones). if name in getattr(self, "_func_local_var_names", ()): continue + # Runtime primitive initializers execute at their Pine declaration + # site under a per-member once flag. This preserves source-order + # dependencies and first-entry semantics for conditional blocks. + if name in self._runtime_scalar_var_init_members: + continue # UDT-typed var members (``var SDZone z = na``) default-construct to # na via the struct's in-class ``__pf_na = true``; a ctor init like # ``z(na())`` would not type-match the struct member. diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 7a1fccc..5ecea37 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -247,8 +247,30 @@ def _concat_receiver_name(self, expr) -> str | None: return None def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: - # var/varip — handled as members in on_bar preamble + # Primitive runtime var/varip initializers execute at the declaration + # site under a dedicated once flag. This preserves source order (a + # preceding input/plain/TA declaration is already available) and Pine's + # first-entry semantics for declarations nested in conditional blocks. + # Series, aggregates, constructor constants, and function-local vars + # retain their specialized initialization paths. if node.is_var or node.is_varip: + info = self._runtime_scalar_var_init_by_node.get(id(node)) + if info is not None: + member_name = info["member_name"] + target = self._safe_name(member_name) + previous_input_name = self._current_input_var_name + self._current_input_var_name = node.name + try: + init_cpp = self._visit_expr(node.value) + finally: + self._current_input_var_name = previous_input_name + init_cpp = self._typed_na_init( + init_cpp, member_name, info["ptype"] + ) + lines.append(f"{pad}if (!{info['flag']}) {{") + lines.append(f"{pad} {target} = {init_cpp};") + lines.append(f"{pad} {info['flag']} = true;") + lines.append(f"{pad}}}") return safe = self._safe_name(node.name) @@ -695,12 +717,12 @@ def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> lines.append(f"{pad}/* unsupported tuple assignment */") - def _push_block_var_remap(self, node): - """Activate block-scoped var renames for ``node`` (BUG 1). Returns the + def _push_block_var_remap(self, owner): + """Activate block-scoped var renames for ``owner`` (BUG 1). Returns the previous ``_active_var_remap`` to restore (or ``_NO_BLOCK_REMAP`` if this block owns no renames). Renames are MERGED over the inherited remap so nested blocks keep any enclosing func-clone / outer-block mapping.""" - renames = self._block_var_renames.get(id(node)) + renames = self._block_var_renames.get(id(owner)) if not renames: return _NO_BLOCK_REMAP saved = self._active_var_remap @@ -711,12 +733,27 @@ def _pop_block_var_remap(self, saved) -> None: if saved is not _NO_BLOCK_REMAP: self._active_var_remap = saved - def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None: - _blk_saved = self._push_block_var_remap(node) + def _visit_block_statements(self, body: list, lines: list[str], + indent: int) -> None: + """Emit one lexical branch/loop body with its exact var remap.""" + saved = self._push_block_var_remap(body) try: - self._visit_if_body(node, lines, indent) + for stmt in body: + self._visit_stmt(stmt, lines, indent) finally: - self._pop_block_var_remap(_blk_saved) + self._pop_block_var_remap(saved) + + def _emit_block_with_assign(self, body: list, target: str, + lines: list[str], indent: int) -> None: + """Expression-body counterpart of :meth:`_visit_block_statements`.""" + saved = self._push_block_var_remap(body) + try: + self._emit_body_with_assign(body, target, lines, indent) + finally: + self._pop_block_var_remap(saved) + + def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None: + self._visit_if_body(node, lines, indent) def _visit_if_body(self, node: IfStmt, lines: list[str], indent: int) -> None: pad = " " * indent @@ -726,20 +763,29 @@ def _visit_if_body(self, node: IfStmt, lines: list[str], indent: int) -> None: # 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) - self._hoist_if_body(node.body, cond, lines, pad, indent) + 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})" - self._hoist_if_body(node.else_body, neg_cond, lines, pad, indent) + 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}) {{") - for s in node.body: - self._visit_stmt(s, lines, indent + 1) + self._visit_block_statements(node.body, lines, indent + 1) lines.append(f"{pad}}}") if node.else_body: if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt): @@ -747,8 +793,9 @@ def _visit_if_body(self, node: IfStmt, lines: list[str], indent: int) -> None: self._visit_if(node.else_body[0], lines, indent) else: lines[-1] = f"{pad}}} else {{" - for s in node.else_body: - self._visit_stmt(s, lines, indent + 1) + self._visit_block_statements( + node.else_body, lines, indent + 1 + ) lines.append(f"{pad}}}") def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None: @@ -879,22 +926,21 @@ def _visit_switch(self, node: SwitchStmt, lines: list[str], indent: int) -> None prefix = "if" if i == 0 else "else if" case_val = self._visit_expr(case_expr) lines.append(f"{pad}{prefix} ({expr_var} == {case_val}) {{") - for s in case_body: - self._visit_stmt(s, lines, indent + 1) + self._visit_block_statements(case_body, lines, indent + 1) lines.append(f"{pad}}}") else: for i, (case_expr, case_body) in enumerate(node.cases): prefix = "if" if i == 0 else "else if" cond = self._visit_expr(case_expr) lines.append(f"{pad}{prefix} ({cond}) {{") - for s in case_body: - self._visit_stmt(s, lines, indent + 1) + self._visit_block_statements(case_body, lines, indent + 1) lines.append(f"{pad}}}") if node.default_body: lines.append(f"{pad}else {{") - for s in node.default_body: - self._visit_stmt(s, lines, indent + 1) + self._visit_block_statements( + node.default_body, lines, indent + 1 + ) lines.append(f"{pad}}}") # ------------------------------------------------------------------ @@ -941,7 +987,7 @@ def _visit_if_switch_expr(self, node, target: str, if isinstance(node, IfStmt): cond = self._visit_expr(node.condition) lines.append(f"{pad}if ({cond}) {{") - self._emit_body_with_assign(node.body, target, lines, indent + 1) + self._emit_block_with_assign(node.body, target, lines, indent + 1) lines.append(f"{pad}}}") if node.else_body: if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt): @@ -949,7 +995,9 @@ def _visit_if_switch_expr(self, node, target: str, self._visit_if_switch_expr(node.else_body[0], target, lines, indent) else: lines[-1] = f"{pad}}} else {{" - self._emit_body_with_assign(node.else_body, target, lines, indent + 1) + self._emit_block_with_assign( + node.else_body, target, lines, indent + 1 + ) lines.append(f"{pad}}}") elif isinstance(node, SwitchStmt): if node.expr: @@ -960,16 +1008,22 @@ def _visit_if_switch_expr(self, node, target: str, prefix = "if" if i == 0 else "else if" case_val = self._visit_expr(case_expr) lines.append(f"{pad}{prefix} ({expr_var} == {case_val}) {{") - self._emit_body_with_assign(case_body, target, lines, indent + 1) + self._emit_block_with_assign( + case_body, target, lines, indent + 1 + ) lines.append(f"{pad}}}") else: for i, (case_expr, case_body) in enumerate(node.cases): prefix = "if" if i == 0 else "else if" cond = self._visit_expr(case_expr) lines.append(f"{pad}{prefix} ({cond}) {{") - self._emit_body_with_assign(case_body, target, lines, indent + 1) + self._emit_block_with_assign( + case_body, target, lines, indent + 1 + ) lines.append(f"{pad}}}") if node.default_body: lines.append(f"{pad}else {{") - self._emit_body_with_assign(node.default_body, target, lines, indent + 1) + self._emit_block_with_assign( + node.default_body, target, lines, indent + 1 + ) lines.append(f"{pad}}}") diff --git a/tests/test_runtime_var_initialization.py b/tests/test_runtime_var_initialization.py new file mode 100644 index 0000000..274ddc2 --- /dev/null +++ b/tests/test_runtime_var_initialization.py @@ -0,0 +1,409 @@ +"""Regression coverage for runtime scalar ``var`` initializers. + +Pine evaluates a ``var`` / ``varip`` initializer once, when execution first +reaches its declaration. A generated C++ constructor cannot evaluate +bar-dependent expressions, and a global first-bar preamble cannot preserve +dependencies or conditional first-entry semantics, so primitive runtime +initializers use declaration-site once guards. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from pineforge_codegen import transpile +from tests import _compile as compile_env + + +def _var_init_block(cpp: str) -> str: + start = cpp.index(" if (!_var_initialized) {") + end = cpp.index(" _var_initialized = true;", start) + return cpp[start:end] + + +def test_runtime_scalar_var_and_varip_initialize_at_declaration_once(): + cpp = transpile("""//@version=6 +strategy("runtime scalar var init") +var float seededLow = low +varip float seededHigh = high +var float literalSeed = 7.5 +seededLow += 1.0 + seededHigh += 1.0 +""", check_support=False) + + assert "double seededLow = na();" in cpp + assert "double seededHigh = na();" in cpp + assert "if (!_pf_var_init_seededLow) {" in cpp + assert "if (!_pf_var_init_seededHigh) {" in cpp + assert cpp.count("seededLow = current_bar_.low;") == 1 + assert cpp.count("seededHigh = current_bar_.high;") == 1 + assert "decltype(GeneratedStrategy::_pf_var_init_seededLow)" in cpp + assert "this->_pf_var_init_seededLow =" in cpp + + # The literal retains its existing constructor route and receives no + # redundant declaration-site guard. + assert "literalSeed(7.5)" in cpp + assert "_pf_var_init_literalSeed" not in cpp + + +def test_runtime_scalar_dependencies_follow_source_order(): + cpp = transpile("""//@version=6 +strategy("runtime scalar dependency order") +length = input.int(3, "Length") +emaValue = ta.ema(close, length) +plainValue = emaValue + 2.0 +var int fromInput = length +var float fromTA = emaValue +var float fromPlain = plainValue +var float directInput = input.float(4.5, "Direct Seed") +""") + + # Input-backed aliases are runtime values even though their defaults can + # be constant-folded for other constructor decisions. + assert "fromInput(3)" not in cpp + assert "if (!_pf_var_init_fromInput)" in cpp + assert "fromInput = length;" in cpp + assert "directInput = get_input_double(\"Direct Seed\", 4.5);" in cpp + + on_bar = cpp[cpp.index(" void on_bar("):] + input_pos = on_bar.index('length = get_input_int("Length", 3);') + ema_pos = on_bar.index("emaValue =") + plain_pos = on_bar.index("plainValue =") + from_input_pos = on_bar.index("fromInput = length;") + from_ta_pos = on_bar.index("fromTA = emaValue;") + from_plain_pos = on_bar.index("fromPlain = plainValue;") + assert input_pos < ema_pos < plain_pos + assert plain_pos < from_input_pos < from_ta_pos < from_plain_pos + + +def test_conditional_sibling_vars_get_distinct_lazy_members_and_flags(): + cpp = transpile("""//@version=6 +strategy("conditional runtime scalar vars") +if close > 50 + var float pending = low + pending += 1.0 +if close < 0 + var float pending = high + pending += 2.0 +""") + + assert "double pending = na();" in cpp + assert "double pending__blk1 = na();" in cpp + assert "bool _pf_var_init_pending = false;" in cpp + assert "bool _pf_var_init_pending__blk1 = false;" in cpp + assert "pending = current_bar_.low;" in cpp + assert "pending__blk1 = current_bar_.high;" in cpp + on_bar = cpp[cpp.index(" void on_bar("):] + assert on_bar.index("if (!_pf_var_init_pending)") > on_bar.index("if (([&]") + + +def test_if_else_same_name_mixed_runtime_and_literal_vars_are_independent(): + cpp = transpile("""//@version=6 +strategy("if else mixed var init") +length = input.float(4.0, "Length") +if close > 0 + var float branchValue = length + branchValue += 1.0 +else + var float branchValue = 7.0 + branchValue += 2.0 +""") + + assert "double branchValue = na();" in cpp + assert "double branchValue__blk1;" in cpp + assert "branchValue__blk1(7)" in cpp + assert "bool _pf_var_init_branchValue = false;" in cpp + assert "_pf_var_init_branchValue__blk1" not in cpp + assert "branchValue = length;" in cpp + assert "branchValue__blk1 += 2.0;" in cpp + + +def test_switch_cases_same_name_get_per_case_storage_and_init_routes(): + cpp = transpile("""//@version=6 +strategy("switch mixed var init") +switch + close > 10 => + var float caseValue = low + caseValue += 1.0 + close > 0 => + var float caseValue = 2.0 + caseValue += 2.0 + => + var float caseValue = high + caseValue += 3.0 +""") + + assert "double caseValue = na();" in cpp + assert "double caseValue__blk1;" in cpp + assert "double caseValue__blk2 = na();" in cpp + assert "caseValue__blk1(2)" in cpp + assert "bool _pf_var_init_caseValue = false;" in cpp + assert "bool _pf_var_init_caseValue__blk2 = false;" in cpp + assert "_pf_var_init_caseValue__blk1" not in cpp + assert "caseValue = current_bar_.low;" in cpp + assert "caseValue__blk1 += 2.0;" in cpp + assert "caseValue__blk2 = current_bar_.high;" in cpp + + +def test_if_expression_branch_vars_receive_runtime_init_routes(): + cpp = transpile("""//@version=6 +strategy("if expression runtime var init") +float result = if close > 0 + var float branchState = low + branchState +else + var float branchState = high + branchState +""") + + assert "double branchState = na();" in cpp + assert "double branchState__blk1 = na();" in cpp + assert "bool _pf_var_init_branchState = false;" in cpp + assert "bool _pf_var_init_branchState__blk1 = false;" in cpp + assert "branchState = current_bar_.low;" in cpp + assert "branchState__blk1 = current_bar_.high;" in cpp + assert "result = branchState;" in cpp + assert "result = branchState__blk1;" in cpp + + +def test_switch_expression_case_vars_receive_runtime_init_routes(): + cpp = transpile("""//@version=6 +strategy("switch expression runtime var init") +float result = switch + close > 10 => + var float caseState = low + caseState + => + var float caseState = high + caseState +""") + + assert "double caseState = na();" in cpp + assert "double caseState__blk1 = na();" in cpp + assert "bool _pf_var_init_caseState = false;" in cpp + assert "bool _pf_var_init_caseState__blk1 = false;" in cpp + assert "caseState = current_bar_.low;" in cpp + assert "caseState__blk1 = current_bar_.high;" in cpp + assert "result = caseState;" in cpp + assert "result = caseState__blk1;" in cpp + + +def test_function_vars_keep_per_variant_initialization_route(): + cpp = transpile("""//@version=6 +strategy("function var init isolation") +helper() => + var float functionSeed = high + functionSeed += 1.0 + functionSeed +b = helper() +""") + + assert "_pf_var_init_functionSeed" not in cpp + assert "_fvinit_" in cpp + assert "functionSeed = current_bar_.high;" in cpp + + +def test_runtime_var_flag_name_avoids_user_member_collision(): + cpp = transpile("""//@version=6 +strategy("runtime var flag collision") +var float _pf_var_init_seeded = 1.0 +_pf_var_init_otherSeed = 2.0 +var float seeded = low +var float otherSeed = high +seeded += _pf_var_init_seeded +otherSeed += _pf_var_init_otherSeed +""") + + assert "double _pf_var_init_seeded;" in cpp + assert "bool _pf_var_init_seeded_2 = false;" in cpp + assert "if (!_pf_var_init_seeded_2)" in cpp + assert "double _pf_var_init_otherSeed = 0.0;" in cpp + assert "bool _pf_var_init_otherSeed_2 = false;" in cpp + assert "if (!_pf_var_init_otherSeed_2)" in cpp + + +def test_runtime_scalar_route_does_not_duplicate_specialized_var_initializers(): + cpp = transpile("""//@version=6 +strategy("specialized var init") +type Point + float x +var array values = array.new() +var matrix grid = matrix.new(1, 1, low) +var map lookup = map.new() +var Point point = Point.new(low) +var line marker = na +""") + + init_block = _var_init_block(cpp) + assert init_block.count("values = std::vector();") == 1 + assert init_block.count( + "grid = PineMatrix::new_(1, 1, current_bar_.low);" + ) == 1 + assert init_block.count( + "lookup = std::unordered_map();" + ) == 1 + assert init_block.count( + "point = Point{.x = current_bar_.low, .__pf_na = false};" + ) == 1 + assert "marker =" not in init_block + + +_RUNTIME_PINE = """//@version=6 +strategy("runtime var init execution") +var float seeded = low +var float directSeed = input.float(5.0, "Direct Seed") +aliasSeed = input.float(6.0, "Alias Seed") +var float aliasedSeed = aliasSeed +seeded += 1.0 +directSeed += 1.0 +aliasedSeed += 1.0 +""" + + +_CPP_DRIVER = r""" +#include +#include + +int main() { + Bar bars[] = { + Bar{11.0, 12.0, 10.0, 11.5, 100.0, 1000}, + Bar{101.0, 102.0, 100.0, 101.5, 200.0, 2000}, + }; + + GeneratedStrategy first; + GeneratedStrategy second; + first.set_input("Direct Seed", "20.0"); + first.set_input("Alias Seed", "30.0"); + second.set_input("Direct Seed", "20.0"); + second.set_input("Alias Seed", "30.0"); + first.run(bars, 2); + second.run(bars, 2); + + std::cout << std::fixed << std::setprecision(1) + << first.seeded << "\t" + << first.directSeed << "\t" + << first.aliasedSeed << "\t" + << second.seeded << "\t" + << second.directSeed << "\t" + << second.aliasedSeed << "\n"; + return 0; +} +""" + + +def _find_engine_library() -> Path | None: + explicit = os.environ.get("PINEFORGE_ENGINE_LIB") + if explicit: + path = Path(explicit).expanduser().resolve() + return path if path.is_file() else None + + engine_inc = compile_env._ENGINE_INC + if engine_inc is None: + return None + candidates: list[Path] = [] + for pattern in ("build*/lib/libpineforge.a", "build*/lib/libpineforge.dylib"): + candidates.extend(sorted(engine_inc.parent.glob(pattern))) + return candidates[0].resolve() if candidates else None + + +def _compile_and_run(cpp_source: str) -> str: + compile_env.skip_if_no_compile_env() + engine_lib = _find_engine_library() + if engine_lib is None: + pytest.skip( + "built libpineforge not found; set PINEFORGE_ENGINE_LIB or build " + "the engine beside PINEFORGE_ENGINE_INCLUDE" + ) + + compiler = compile_env._COMPILER + engine_inc = compile_env._ENGINE_INC + eigen_inc = compile_env._EIGEN_INC + assert compiler is not None and engine_inc is not None and eigen_inc is not None + + with tempfile.TemporaryDirectory(prefix="pineforge-runtime-var-init-") as tmp: + cpp_path = Path(tmp) / "runtime_var_init.cpp" + exe_path = Path(tmp) / "runtime_var_init" + cpp_path.write_text(cpp_source) + + cmd = [ + compiler, + "-std=c++17", + "-O0", + "-I", str(engine_inc), + "-I", str(eigen_inc), + ] + if compile_env._GENERATED_INC is not None: + cmd += ["-I", str(compile_env._GENERATED_INC)] + cmd += [str(cpp_path), str(engine_lib), "-pthread", "-o", str(exe_path)] + + built = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if built.returncode != 0: + raise AssertionError( + "runtime var initializer probe failed to link\n" + + "\n".join((built.stderr or built.stdout).splitlines()[:80]) + ) + + ran = subprocess.run( + [str(exe_path)], capture_output=True, text=True, timeout=30 + ) + if ran.returncode != 0: + raise AssertionError( + f"runtime var initializer probe exited {ran.returncode}\n" + f"stdout:\n{ran.stdout}\nstderr:\n{ran.stderr}" + ) + return ran.stdout + + +def test_runtime_var_initializer_uses_first_bar_once_and_is_deterministic(): + cpp = transpile(_RUNTIME_PINE) + stdout = _compile_and_run(cpp + _CPP_DRIVER) + + # ``seeded`` starts at the first bar's low (10), then increments once on + # each of two bars. Reinitializing from the second bar's low would be 101; + # dropping the initializer cannot deterministically produce 12. + assert stdout == "12.0\t22.0\t32.0\t12.0\t22.0\t32.0\n" + + +_CONDITIONAL_RUNTIME_PINE = """//@version=6 +strategy("conditional runtime var execution") +if close > 50 + var float pending = low + pending += 1.0 +if close < 0 + var float pending = high + pending += 2.0 +""" + + +_CONDITIONAL_CPP_DRIVER = r""" +#include +#include + +int main() { + Bar bars[] = { + Bar{9.0, 12.0, 8.0, 10.0, 100.0, 1000}, + Bar{55.0, 65.0, 50.0, 60.0, 100.0, 2000}, + Bar{0.0, 5.0, -2.0, -1.0, 100.0, 3000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << std::fixed << std::setprecision(1) + << strategy.pending << "\t" << strategy.pending__blk1 << "\n"; + return 0; +} +""" + + +def test_conditional_vars_initialize_on_first_block_entry_at_runtime(): + cpp = transpile(_CONDITIONAL_RUNTIME_PINE) + stdout = _compile_and_run(cpp + _CONDITIONAL_CPP_DRIVER) + + # Neither block executes on bar zero. The first declaration therefore + # seeds from bar one's low (50), and the same-named sibling independently + # seeds from bar two's high (5). + assert stdout == "51.0\t7.0\n"