diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index fc29fde..8fcca2f 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -276,6 +276,17 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._func_ta_ranges: dict[str, tuple[int, int]] = {} # func_name -> (start, end) indices self._func_call_site_count: dict[str, int] = {} # func_name -> count self._func_call_cs_map: dict[int, tuple[str, int]] = {} # call_node_id -> (func_name, cs_idx) + # Primitive type facts retained per written call AST and reconciled to + # the emitted cs0/cs1/... identities after the stateful call graph is + # closed. Pine explicitly permits an untyped parameter to inherit a + # different type at each written call site. + self._callable_bound_param_types_by_node: dict[int, list[PineType]] = {} + self._func_callsite_param_types: dict[ + tuple[str, int], list[PineType] + ] = {} + self._func_callsite_return_types: dict[ + tuple[str, int], PineType + ] = {} # Textual nested calls whose identity is inherited from the active # parent clone rather than assigned a fixed source-level cs index. # Kept separately so a second propagation pass (security TF cloning) @@ -533,6 +544,17 @@ def analyze(self) -> AnalyzerContext: 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, + func_callsite_param_types={ + key: tuple(types) + for key, types in self._func_callsite_param_types.items() + }, + func_callsite_return_types=dict( + self._func_callsite_return_types + ), + func_declared_param_type_specs={ + name: tuple(specs) + for name, specs in self._func_param_type_specs.items() + }, func_security_clone_only=self._func_security_clone_only, func_cs_ta_clone_names=self._func_cs_ta_clone_names, udt_defs=self._udt_fields, @@ -2469,6 +2491,17 @@ def _has_synthetic_history_state( if cs_info == (sub, 0): self._func_call_cs_map.pop(id(call_node), None) self._func_inherited_call_nodes.add(id(call_node)) + # Its definition-time type profile was likewise + # provisional: forwarded untyped owner params + # are still UNKNOWN during that visit. Each + # inherited parent clone supplies the real + # primitive profile below. + self._func_callsite_param_types.pop( + (sub, 0), None + ) + self._func_callsite_return_types.pop( + (sub, 0), None + ) for cs_idx in range(current, count): self._materialize_user_func_call_site_state( sub, @@ -2479,6 +2512,372 @@ def _has_synthetic_history_state( self._func_call_site_count[sub] = count changed = True + self._resolve_callable_callsite_primitive_types( + call_edges, + func_info_by_name, + _bound_user_call_args, + ) + + @staticmethod + def _primitive_pine_type_from_spec(spec) -> PineType: + if spec is None or getattr(spec, "kind", None) != "primitive": + return PineType.UNKNOWN + return { + "int": PineType.INT, + "float": PineType.FLOAT, + "bool": PineType.BOOL, + "string": PineType.STRING, + "color": PineType.COLOR, + }.get(getattr(spec, "name", None), PineType.UNKNOWN) + + def _resolve_callable_callsite_primitive_types( + self, + call_edges, + func_info_by_name, + bound_user_call_args, + ) -> None: + """Reconcile per-written-call primitive types after clone closure. + + Direct UDF calls are typed during the lexical visit, but UDT methods + and pure wrappers receive their cs identities only in the late stateful + call-graph pass. Propagate an enclosing variant's parameter profile + through exact forwarded argument ASTs until every emitted history + callable variant has its own stable primitive profile. + """ + edge_by_call_id = { + id(call): (owner, callee, call) + for owner, callee, call in call_edges + } + + def declared_types(name: str, size: int) -> list[PineType]: + specs = list(self._func_param_type_specs.get(name, ())) + return [ + self._primitive_pine_type_from_spec( + specs[index] if index < len(specs) else None + ) + for index in range(size) + ] + + def merge_profile( + callee: str, + cs_idx: int, + incoming: list[PineType], + call, + ) -> bool: + info = func_info_by_name.get(callee) + if info is None or info.node is None: + return False + size = len(info.node.params) + declared = declared_types(callee, size) + key = (callee, cs_idx) + current = list( + self._func_callsite_param_types.get( + key, [PineType.UNKNOWN] * size + ) + ) + while len(current) < size: + current.append(PineType.UNKNOWN) + changed = False + for index in range(size): + candidate = declared[index] + if candidate == PineType.UNKNOWN and index < len(incoming): + candidate = incoming[index] + if candidate == PineType.UNKNOWN: + continue + if current[index] == PineType.UNKNOWN: + current[index] = candidate + changed = True + elif current[index] != candidate: + self._error( + "Cannot safely specialize untyped parameter '" + + info.node.params[index] + + "' of callable '" + + callee + + "': distinct primitive types collapse onto the same " + + f"written-call variant cs{cs_idx}. Inline the calls " + + "or declare an explicit parameter type.", + call.loc, + ) + if changed or key not in self._func_callsite_param_types: + self._func_callsite_param_types[key] = current + return changed + + # Backfill every direct UDF/method identity assigned by the late graph + # pass. Definition-time wrapper calls can still contain UNKNOWN params; + # the fixed point below resolves those from their owner variant. + for _owner, callee, call in call_edges: + if _owner is not None: + # Definition-time analyzer fallbacks for an expression over an + # untyped owner parameter are not concrete facts (``y + 0`` + # historically reports FLOAT while ``y`` is still UNKNOWN). + # The owner-variant fixed point below resolves nested calls. + continue + cs_info = self._func_call_cs_map.get(id(call)) + if cs_info is None or cs_info[0] != callee: + continue + incoming = self._callable_bound_param_types_by_node.get( + id(call), [] + ) + merge_profile(callee, cs_info[1], incoming, call) + + def owner_profile(owner: str, owner_cs: int | None) -> list[PineType]: + info = func_info_by_name.get(owner) + if info is None or info.node is None: + return [] + size = len(info.node.params) + if owner_cs is not None: + profile = self._func_callsite_param_types.get( + (owner, owner_cs) + ) + if profile is not None: + return list(profile) + declared = declared_types(owner, size) + legacy = list(getattr(info, "param_types", ()) or ()) + return [ + declared[index] + if declared[index] != PineType.UNKNOWN + else ( + legacy[index] + if index < len(legacy) + else PineType.UNKNOWN + ) + for index in range(size) + ] + + def target_variant( + owner: str | None, + owner_cs: int | None, + callee: str, + call, + ) -> int | None: + cs_info = self._func_call_cs_map.get(id(call)) + callee_count = self._func_call_site_count.get(callee, 0) + if owner is None: + if cs_info is not None and cs_info[0] == callee: + return cs_info[1] + return None + if cs_info is not None and cs_info[0] == callee: + # A surviving lexical mapping is authoritative. Codegen's + # context-sensitive instance dispatcher pins every clone of + # this owner to that same written callee variant. If two owner + # profiles disagree, merge_profile must reject the collapse; + # pretending the owner index selects another callee clone + # would type a function different from the one actually + # emitted at the call edge. + return cs_info[1] + if owner_cs is not None and callee_count > 1: + # A removed lexical map marks an inherited single-call path; + # those variants deliberately follow the enclosing clone. + return owner_cs if owner_cs < callee_count else None + return None + + def mentions_owner_parameter(value, names: set[str]) -> bool: + if value is None: + return False + if isinstance(value, Identifier): + return value.name in names + if isinstance(value, (list, tuple)): + return any( + mentions_owner_parameter(item, names) for item in value + ) + if isinstance(value, dict): + return any( + mentions_owner_parameter(item, names) + for item in value.values() + ) + if not hasattr(value, "__dict__"): + return False + return any( + mentions_owner_parameter(child, names) + for child in vars(value).values() + ) + + # Calls visited inside an untyped callable are initially analyzed + # before any concrete outer call-site profile exists. The ordinary + # expression analyzer therefore records defaults for transformed + # expressions (many numeric built-ins default to FLOAT). That value + # is not evidence about the eventual written call: discard it for + # every nested argument that depends on an untyped owner parameter so + # the fixed point below either derives the type from the owner variant + # or rejects the unresolved transformation deterministically. + for owner, callee, call in call_edges: + if owner is None: + continue + owner_info = func_info_by_name.get(owner) + callee_info = func_info_by_name.get(callee) + if ( + owner_info is None + or owner_info.node is None + or callee_info is None + or callee_info.node is None + ): + continue + owner_declared = declared_types( + owner, len(owner_info.node.params) + ) + untyped_owner_params = { + param + for index, param in enumerate(owner_info.node.params) + if owner_declared[index] == PineType.UNKNOWN + } + if not untyped_owner_params: + continue + actuals = bound_user_call_args(callee, call) + dependent_slots = { + index + for index, actual in enumerate(actuals) + if mentions_owner_parameter(actual, untyped_owner_params) + } + if not dependent_slots: + continue + callee_declared = declared_types( + callee, len(callee_info.node.params) + ) + owner_count = self._func_call_site_count.get(owner, 0) + owner_variants = range(owner_count) if owner_count > 0 else (None,) + for owner_cs in owner_variants: + callee_cs = target_variant(owner, owner_cs, callee, call) + if callee_cs is None: + continue + key = (callee, callee_cs) + current = list( + self._func_callsite_param_types.get( + key, + [PineType.UNKNOWN] * len(callee_info.node.params), + ) + ) + while len(current) < len(callee_info.node.params): + current.append(PineType.UNKNOWN) + invalidated = False + for index in dependent_slots: + if ( + index < len(current) + and index < len(callee_declared) + and callee_declared[index] == PineType.UNKNOWN + and current[index] != PineType.UNKNOWN + ): + current[index] = PineType.UNKNOWN + invalidated = True + if invalidated: + self._func_callsite_param_types[key] = current + self._func_callsite_return_types.pop(key, None) + + # Resolve parameter forwarding and direct-wrapper returns together. + # The graph is finite and primitive types only move UNKNOWN -> known. + for _ in range(64): + changed = False + for owner, callee, call in call_edges: + if owner is None: + continue + owner_count = self._func_call_site_count.get(owner, 0) + owner_variants = ( + range(owner_count) if owner_count > 0 else (None,) + ) + owner_info = func_info_by_name.get(owner) + if owner_info is None or owner_info.node is None: + continue + actuals = bound_user_call_args(callee, call) + recorded = self._callable_bound_param_types_by_node.get( + id(call), [] + ) + for owner_cs in owner_variants: + callee_cs = target_variant( + owner, owner_cs, callee, call + ) + if callee_cs is None: + continue + profile = owner_profile(owner, owner_cs) + param_map = { + name: ( + profile[index] + if index < len(profile) + else PineType.UNKNOWN + ) + for index, name in enumerate(owner_info.node.params) + } + incoming: list[PineType] = [] + owner_param_names = set(owner_info.node.params) + for index, actual in enumerate(actuals): + inferred = self._callsite_primitive_expr_type( + actual, param_map + ) + if ( + inferred == PineType.UNKNOWN + and not mentions_owner_parameter( + actual, owner_param_names + ) + and index < len(recorded) + ): + inferred = recorded[index] + incoming.append(inferred) + if merge_profile(callee, callee_cs, incoming, call): + changed = True + + # Recompute each variant's primitive return. A direct terminal + # wrapper call uses the exact callee variant return rather than the + # shared definition-level cache. + for key, profile in list( + self._func_callsite_param_types.items() + ): + name, cs_idx = key + info = func_info_by_name.get(name) + if info is None or info.node is None: + continue + ret = self._callsite_callable_return_type( + info.node, list(profile), info.return_type + ) + terminal = self._direct_terminal_return_expr(info.node) + if isinstance(terminal, FuncCall): + edge = edge_by_call_id.get(id(terminal)) + if edge is not None: + _, terminal_callee, terminal_call = edge + terminal_cs = target_variant( + name, cs_idx, terminal_callee, terminal_call + ) + if terminal_cs is not None: + nested_ret = self._func_callsite_return_types.get( + (terminal_callee, terminal_cs), + PineType.UNKNOWN, + ) + if nested_ret != PineType.UNKNOWN: + ret = nested_ret + if self._func_callsite_return_types.get(key) != ret: + self._func_callsite_return_types[key] = ret + changed = True + if not changed: + break + + # A live untyped history parameter without a variant type cannot be + # emitted faithfully. Reject it rather than falling back to the first + # call's global FuncInfo type and reintroducing source-order coercion. + for name, series_names in self._func_series_vars.items(): + info = func_info_by_name.get(name) + if info is None or info.node is None: + continue + count = self._func_call_site_count.get(name, 0) + declared = declared_types(name, len(info.node.params)) + for cs_idx in range(count): + profile = self._func_callsite_param_types.get( + (name, cs_idx), [] + ) + for index, param in enumerate(info.node.params): + if param not in series_names: + continue + if declared[index] != PineType.UNKNOWN: + continue + resolved = ( + profile[index] + if index < len(profile) + else PineType.UNKNOWN + ) + if resolved == PineType.UNKNOWN: + self._error( + "Cannot infer the per-callsite primitive type of " + f"history parameter '{param}' in callable '{name}' " + f"variant cs{cs_idx}; declare its type explicitly.", + info.node.loc, + ) + # ------------------------------------------------------------------ # Mixed-callsite UDF timeframe-param security rejection. # @@ -3670,7 +4069,11 @@ def _visit_MethodDef(self, node) -> PineType: for i, p in enumerate(node.params): udt_self = node.type_name if i == 0 else None hint = param_hints[i] if i < len(param_hints) else None - ptype = self._type_hint_to_pine(hint) if hint else PineType.FLOAT + # Only the receiver is required to be typed in Pine methods. Every + # other omitted type is polymorphic per written call, exactly like + # a regular UDF parameter; seeding it as FLOAT makes even a single + # bool/int history call silently coerce through Series. + ptype = self._type_hint_to_pine(hint) if hint else PineType.UNKNOWN pspec = self._type_spec_from_hint(hint) if hint else None param_types.append(ptype) param_specs.append(pspec) @@ -3746,12 +4149,18 @@ def _visit_MethodDef(self, node) -> PineType: # them (e.g., synthetic MethodDef nodes from tests). while len(param_defaults) < len(node.params): param_defaults.append(None) + self._func_param_type_specs[method_key] = list(param_specs) fi = FuncInfo( name=method_key, param_types=param_types, return_type=ret_type, - node=FuncDef(name=node.name, params=node.params, - body=node.body, is_single_expr=node.is_single_expr), + node=FuncDef( + name=node.name, + params=node.params, + body=node.body, + is_single_expr=node.is_single_expr, + annotations=dict(node.annotations or {}), + ), is_udt_method=True, udt_type_name=node.type_name, returns_tuple=returns_tuple, @@ -4156,11 +4565,12 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: return self._visit(callee) # General member call (e.g., array.push, etc.) - self._visit(obj) + receiver_pine_type = self._visit(obj) + visited_member_arg_types: dict[int, PineType] = {} for arg in node.args: - self._visit(arg) + visited_member_arg_types[id(arg)] = self._visit(arg) for val in node.kwargs.values(): - self._visit(val) + visited_member_arg_types[id(val)] = self._visit(val) # UDT method call-site typing. Method definitions may contain an # untyped parameter history read; resolve receiver + args here and # apply the same deferred map-history gate as regular UDFs before @@ -4186,6 +4596,36 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: rest_params = method_params[1:] rest_bound = self._bind_callable_args(node, rest_params) full_bound: list[ASTNode | None] = [obj, *rest_bound] + full_param_types = [ + receiver_pine_type, + *[ + visited_member_arg_types.get( + id(arg), PineType.UNKNOWN + ) + if arg is not None + else PineType.UNKNOWN + for arg in rest_bound + ], + ] + declared_specs = list( + self._func_param_type_specs.get(method_key, ()) + ) + full_param_types = [ + ( + self._primitive_pine_type_from_spec( + declared_specs[index] + ) + if index < len(declared_specs) + and self._primitive_pine_type_from_spec( + declared_specs[index] + ) != PineType.UNKNOWN + else param_type + ) + for index, param_type in enumerate(full_param_types) + ] + self._callable_bound_param_types_by_node[id(node)] = list( + full_param_types + ) effective_specs = list( getattr(method_info, "param_type_specs", None) or [] ) @@ -4225,7 +4665,11 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: arg_sym = self._symbols.resolve(arg.name) if arg_sym is not None: arg_sym.is_series = True - return method_info.return_type + return self._callsite_callable_return_type( + method_info.node, + full_param_types, + 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 # element PineType. ``_type_spec_from_expr`` already carries the diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index e752361..3a7be9b 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -66,8 +66,9 @@ from typing import Any from ..ast_nodes import ( - ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, - NumberLiteral, StringLiteral, Ternary, TupleLiteral, UnaryOp, VarDecl, + ASTNode, Assignment, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, + IfStmt, MemberAccess, NumberLiteral, StringLiteral, Subscript, SwitchStmt, + Ternary, TupleLiteral, UnaryOp, VarDecl, ) from ..symbols import PineType from .. import signatures as sigs @@ -89,6 +90,278 @@ class CallHandlers: # TA call handling # ------------------------------------------------------------------ + def _callsite_primitive_expr_type( + self, + expr: ASTNode | None, + parameter_types: dict[str, PineType], + ) -> PineType: + """Infer a primitive return using one written call's parameter types. + + The analyzer's legacy ``FuncInfo`` is definition-wide, but Pine's + untyped parameters are polymorphic per written call. Keep this helper + intentionally primitive and expression-local: it provides the exact + family needed by history-preserving identity/wrapper functions without + attempting to monomorphize collections or reference types here. + """ + if expr is None: + return PineType.UNKNOWN + if isinstance(expr, ExprStmt): + return self._callsite_primitive_expr_type( + expr.expr, parameter_types + ) + if isinstance(expr, Identifier): + if expr.name in parameter_types: + return parameter_types[expr.name] + if expr.name in {"true", "false"}: + return PineType.BOOL + return PineType.UNKNOWN + if isinstance(expr, Subscript): + # Pine's history operator preserves the receiver's scalar family. + return self._callsite_primitive_expr_type( + expr.object, parameter_types + ) + if isinstance(expr, NumberLiteral): + return ( + PineType.FLOAT + if isinstance(expr.value, float) + else PineType.INT + ) + if isinstance(expr, BoolLiteral): + return PineType.BOOL + if isinstance(expr, StringLiteral): + return PineType.STRING + if isinstance(expr, UnaryOp): + if expr.op == "not": + return PineType.BOOL + return self._callsite_primitive_expr_type( + expr.operand, parameter_types + ) + if isinstance(expr, BinOp): + left = self._callsite_primitive_expr_type( + expr.left, parameter_types + ) + right = self._callsite_primitive_expr_type( + expr.right, parameter_types + ) + if expr.op in {"==", "!=", ">", "<", ">=", "<=", "and", "or"}: + return PineType.BOOL + if left == PineType.STRING or right == PineType.STRING: + return PineType.STRING + if expr.op == "/" or PineType.FLOAT in {left, right}: + return PineType.FLOAT + if left == PineType.INT and right == PineType.INT: + return PineType.INT + return PineType.UNKNOWN + if isinstance(expr, Ternary): + true_type = self._callsite_primitive_expr_type( + expr.true_val, parameter_types + ) + false_type = self._callsite_primitive_expr_type( + expr.false_val, parameter_types + ) + if true_type == false_type: + return true_type + if PineType.STRING in {true_type, false_type}: + return PineType.STRING + if PineType.FLOAT in {true_type, false_type}: + return PineType.FLOAT + return PineType.UNKNOWN + if isinstance(expr, FuncCall): + callee = expr.callee + if isinstance(callee, Identifier): + if callee.name == "int": + return PineType.INT + if callee.name == "float": + return PineType.FLOAT + if callee.name == "bool": + return PineType.BOOL + # A user callable's definition-wide return cache is not a + # call-site fact. In particular, a pure untyped transform + # nested inside a polymorphic history wrapper may have been + # analyzed first as FLOAT even when this path carries an + # int64 timestamp or bool. Exact direct-wrapper call edges are + # reconciled later; arbitrary nested transforms stay UNKNOWN + # and therefore fail closed instead of source-order coercing. + return PineType.UNKNOWN + return PineType.UNKNOWN + + @staticmethod + def _join_callsite_primitive_types( + types: list[PineType], + ) -> PineType: + if not types or PineType.UNKNOWN in types: + return PineType.UNKNOWN + first = types[0] + if all(value == first for value in types): + return first + if set(types).issubset({PineType.INT, PineType.FLOAT}): + return PineType.FLOAT + return PineType.UNKNOWN + + def _apply_callsite_local_type_effects( + self, + stmt: ASTNode, + local_types: dict[str, PineType], + ) -> None: + """Flow one statement's primitive local writes into ``local_types``. + + This is deliberately a small callable-return analysis, not a second + semantic analyzer. It exists so a terminal alias such as + ``prior = src[1]; prior`` keeps the exact written-call family. Unknown + loops/collections remain unknown and cannot silently pick FLOAT. + """ + if isinstance(stmt, VarDecl): + hinted = { + "int": PineType.INT, + "float": PineType.FLOAT, + "bool": PineType.BOOL, + "string": PineType.STRING, + "color": PineType.COLOR, + }.get(stmt.type_hint or "", PineType.UNKNOWN) + local_types[stmt.name] = ( + hinted + if hinted != PineType.UNKNOWN + else self._callsite_primitive_expr_type( + stmt.value, local_types + ) + ) + return + if ( + isinstance(stmt, Assignment) + and isinstance(stmt.target, Identifier) + ): + rhs = self._callsite_primitive_expr_type( + stmt.value, local_types + ) + if stmt.op == ":=": + # Pine variables retain their declaration/inferred primitive + # family for their lifetime. An ``na``/otherwise unknown + # declaration may acquire the first concrete family here. + current = local_types.get( + stmt.target.name, PineType.UNKNOWN + ) + local_types[stmt.target.name] = ( + current if current != PineType.UNKNOWN else rhs + ) + else: + local_types[stmt.target.name] = ( + self._join_callsite_primitive_types( + [ + local_types.get( + stmt.target.name, PineType.UNKNOWN + ), + rhs, + ] + ) + ) + return + if isinstance(stmt, IfStmt): + before = dict(local_types) + branch_envs: list[dict[str, PineType]] = [] + for body in (stmt.body, stmt.else_body): + branch = dict(before) + for child in body: + self._apply_callsite_local_type_effects(child, branch) + branch_envs.append(branch) + all_names = set().union(*(env.keys() for env in branch_envs)) + for name in all_names: + local_types[name] = self._join_callsite_primitive_types( + [ + env.get(name, before.get(name, PineType.UNKNOWN)) + for env in branch_envs + ] + ) + return + if isinstance(stmt, SwitchStmt): + before = dict(local_types) + bodies = [body for _, body in stmt.cases] + if stmt.default_body: + bodies.append(stmt.default_body) + else: + bodies.append([]) + branch_envs = [] + for body in bodies: + branch = dict(before) + for child in body: + self._apply_callsite_local_type_effects(child, branch) + branch_envs.append(branch) + all_names = set().union(*(env.keys() for env in branch_envs)) + for name in all_names: + local_types[name] = self._join_callsite_primitive_types( + [ + env.get(name, before.get(name, PineType.UNKNOWN)) + for env in branch_envs + ] + ) + + def _callsite_body_return_type( + self, + body: list[ASTNode], + parameter_types: dict[str, PineType], + ) -> PineType: + if not body: + return PineType.UNKNOWN + local_types = dict(parameter_types) + for stmt in body[:-1]: + self._apply_callsite_local_type_effects(stmt, local_types) + terminal = body[-1] + if isinstance(terminal, ExprStmt): + terminal = terminal.expr + if isinstance(terminal, VarDecl): + return self._callsite_primitive_expr_type( + terminal.value, local_types + ) + if isinstance(terminal, IfStmt): + if not terminal.body: + return PineType.UNKNOWN + branches = [terminal.body] + # A missing else produces contextual ``na`` in Pine and therefore + # does not erase the concrete family's type. + if terminal.else_body: + branches.append(terminal.else_body) + return self._join_callsite_primitive_types( + [ + self._callsite_body_return_type( + branch, dict(local_types) + ) + for branch in branches + ] + ) + if isinstance(terminal, SwitchStmt): + bodies = [case_body for _, case_body in terminal.cases] + if terminal.default_body: + bodies.append(terminal.default_body) + if not bodies: + return PineType.UNKNOWN + return self._join_callsite_primitive_types( + [ + self._callsite_body_return_type( + branch, dict(local_types) + ) + for branch in bodies + ] + ) + return self._callsite_primitive_expr_type(terminal, local_types) + + def _callsite_callable_return_type( + self, + func_def, + param_types: list[PineType], + fallback: PineType, + ) -> PineType: + inferred = self._callsite_body_return_type( + func_def.body, + { + name: ( + param_types[index] + if index < len(param_types) + else PineType.UNKNOWN + ) + for index, name in enumerate(func_def.params) + }, + ) + return inferred if inferred != PineType.UNKNOWN else fallback + def _merge_ta_args(self, func_name: str, node: FuncCall) -> list: """Merge positional args and kwargs into a unified positional list.""" param_names = sigs.get_param_names("ta", func_name) @@ -1155,6 +1428,23 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: else PineType.UNKNOWN for arg in bound_args ] + declared_specs = list( + self._func_param_type_specs.get(func_name, ()) + ) + effective_param_types = [ + ( + self._primitive_pine_type_from_spec(declared_specs[index]) + if index < len(declared_specs) + and self._primitive_pine_type_from_spec( + declared_specs[index] + ) != PineType.UNKNOWN + else param_type + ) + for index, param_type in enumerate(param_types) + ] + self._callable_bound_param_types_by_node[id(node)] = list( + effective_param_types + ) # Per-param TypeSpec: declared hints are authoritative; for untyped # params, infer from this call site's argument. Validate deferred @@ -1212,6 +1502,14 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: return_type = PineType.FLOAT break + # History and other primitive operations preserve an untyped + # parameter's family independently at each written call. Do this + # before the legacy FuncInfo merge so the first call cannot dictate + # every later call's return type. + return_type = self._callsite_callable_return_type( + func_def, effective_param_types, return_type + ) + # If this function has series params, ensure bar-field arguments # passed at the call site are registered as series_bar_fields so that # the codegen can create Series members for them. @@ -1278,6 +1576,13 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: self._materialize_user_func_call_site_state( func_name, cs_idx, node ) + callsite = self._func_call_cs_map.get(id(node)) + if callsite is not None and callsite[0] == func_name: + key = (func_name, callsite[1]) + self._func_callsite_param_types[key] = list( + effective_param_types + ) + self._func_callsite_return_types[key] = return_type # Create or update FuncInfo is_tuple = self._func_returns_tuple.get(func_name, False) @@ -1393,4 +1698,4 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: }, ) - return self._func_return_types.get(func_name, return_type) + return return_type diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index 146004f..ce76104 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -239,6 +239,17 @@ class AnalyzerContext: 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) func_call_site_counts: dict = field(default_factory=dict) # func_name -> int + # Exact primitive parameter/return types for each emitted written-callsite + # variant. Untyped Pine parameters are independently specialized at every + # written call; collapsing these onto FuncInfo's first observed type makes + # generated semantics depend on source order (for example one ``src[1]`` + # helper called with both ``bar_index`` and ``close``). + func_callsite_param_types: dict = field(default_factory=dict) + func_callsite_return_types: dict = field(default_factory=dict) + # Declaration-time parameter specs, before any call-site inference fills + # untyped slots on the legacy FuncInfo record. Codegen uses this to apply + # variant overrides only to genuinely untyped parameters. + func_declared_param_type_specs: dict = field(default_factory=dict) # Functions that need per-call-site body cloning PURELY because they # contain a request.security(...) whose timeframe is a UDF parameter # called with >= 2 distinct literal timeframes (the security-tf- diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index f2d0f99..f130101 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -790,6 +790,10 @@ def func_var_storage(owner: str, raw_name: str) -> str: self._collection_shadow_tmp_names: set[str] = set() # Current function params that are series (const Series&) self._current_func_series_params: set[str] = set() + # Element types parallel to ``_current_func_series_params``. Keeping + # these separate from the full C++ reference spelling lets expression + # inference see ``src`` as its Pine scalar family inside the body. + self._current_func_series_param_types: dict[str, str] = {} # Locals declared in the function currently being emitted (symbol table loses them after analysis) self._current_func_locals: set[str] = set() self._current_func_local_types: dict[str, str] = {} @@ -2973,23 +2977,59 @@ def walk_nodes(value): if len(states) > 1 } + def register_one( + kind: str, + source_key: tuple, + cpp_type: str, + context: str | None, + ) -> None: + if cpp_type not in ("double", "int", "int64_t", "bool"): + cpp_type = "double" + key = (kind, *source_key, context) + if key in self._inline_history_member_by_key: + return + counters[kind] += 1 + member_name = f"_{kind}_{counters[kind]}" + self._inline_history_member_by_key[key] = member_name + self._inline_history_members.append({ + "kind": kind, + "member_name": member_name, + "cpp_type": cpp_type, + "context": context, + }) + def register(kind: str, source_key: tuple, cpp_type: str, owner: str | None) -> None: - if cpp_type not in ("double", "int", "bool"): - cpp_type = "double" for context in self._inline_history_contexts_for_owner(owner): - key = (kind, *source_key, context) - if key in self._inline_history_member_by_key: - continue - counters[kind] += 1 - member_name = f"_{kind}_{counters[kind]}" - self._inline_history_member_by_key[key] = member_name - self._inline_history_members.append({ - "kind": kind, - "member_name": member_name, - "cpp_type": cpp_type, - "context": context, - }) + register_one(kind, source_key, cpp_type, context) + + def owner_cs_for_context( + owner: str | None, context: str | None + ) -> int | None: + if owner is None or context is None: + return None + prefix = f"{self._func_cpp_base_name(owner)}_cs" + if not context.startswith(prefix): + return None + suffix = context[len(prefix):] + return int(suffix) if suffix.isdigit() else None + + def target_cs_for_context( + fi, + call: FuncCall, + owner: str | None, + context: str | None, + ) -> int | None: + owner_cs = owner_cs_for_context(owner, context) + cs_info = self.ctx.func_call_cs_map.get(id(call)) + count = self.ctx.func_call_site_counts.get(fi.name, 0) + if owner_cs is not None and ( + cs_info is not None or count > 1 + ): + return owner_cs + if cs_info is not None and cs_info[0] == fi.name: + return cs_info[1] + return None def actual_args_for(call: FuncCall, params: list[str]) -> list: if call.kwargs: @@ -3071,15 +3111,60 @@ def owner_lexical_specs(owner: str | None) -> dict[str, TypeSpec | None]: for idx, arg in enumerate(args): if idx not in series_param_indices: continue - if isinstance(arg, Identifier): - if arg.name in BAR_FIELDS or arg.name in BAR_SERIES_PUSH: - continue - if (arg.name in self.ctx.series_vars - and arg.name not in ambiguous_series_names): - continue - register( - "series_arg", (id(node), idx), self._infer_type(arg), owner - ) + for context in self._inline_history_contexts_for_owner(owner): + target_cs = target_cs_for_context( + fi, node, owner, context + ) + expected_cpp_type = self._series_param_element_cpp_type( + fi, idx, target_cs + ) + needs_bridge = True + if isinstance(arg, Identifier): + if ( + ( + arg.name in BAR_FIELDS + or arg.name in BAR_SERIES_PUSH + ) + and expected_cpp_type == "double" + ): + needs_bridge = False + owner_info = self._func_info_map.get(owner or "") + owner_series_cpp_type = None + if ( + owner_info is not None + and owner_info.node is not None + and arg.name in owner_info.node.params + and arg.name in self.ctx.func_series_vars.get( + owner_info.name, set() + ) + ): + owner_index = owner_info.node.params.index( + arg.name + ) + owner_series_cpp_type = ( + self._series_param_element_cpp_type( + owner_info, + owner_index, + owner_cs_for_context(owner, context), + ) + ) + if owner_series_cpp_type == expected_cpp_type: + needs_bridge = False + if ( + owner_series_cpp_type is None + and arg.name in self.ctx.series_vars + and arg.name not in ambiguous_series_names + and self._series_type_for(arg.name) + == expected_cpp_type + ): + needs_bridge = False + if needs_bridge: + register_one( + "series_arg", + (id(node), idx), + expected_cpp_type, + context, + ) def _inline_history_member(self, kind: str, node: ASTNode, arg_idx: int | None = None) -> str: @@ -3500,7 +3585,7 @@ def generate(self) -> str: for _fi_idx, site in enumerate(self.ctx.fixnan_sites): if _fi_idx in self._dead_fixnan_indices: continue - cpp_type = PINE_TYPE_TO_CPP.get(site.pine_type, "double") + cpp_type = self._fixnan_site_cpp_type(site) lines.append(f" {cpp_type} {site.member_name} = na<{cpp_type}>();") # 8. Strategy series (e.g., strategy.closedtrades[1]) @@ -3618,7 +3703,7 @@ def generate(self) -> str: if fresh_safe in emitted_clones: continue emitted_clones.add(fresh_safe) - cpp_type = PINE_TYPE_TO_CPP.get(orig_site.pine_type, "double") + cpp_type = self._fixnan_site_cpp_type(orig_site) lines.append(f" {cpp_type} {fresh_safe} = na<{cpp_type}>();") # 8d. Drawing-objects-as-data arenas (gated on _uses_drawing so diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 3a6d6fa..781edb8 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -86,7 +86,7 @@ ExprStmt, FuncCall, Identifier, IfStmt, SwitchStmt, VarDecl, ) from ..analyzer import FuncInfo -from ..symbols import TypeSpec +from ..symbols import PineType, TypeSpec from .tables import ( BAR_SERIES_PUSH, DRAWING_TYPE_TO_CPP, @@ -1231,8 +1231,21 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No for alias in (param, self._safe_name(param)) } self._current_func_series_params = set() + self._current_func_series_param_types = {} self._udt_param_udt = {} func_sv = self.ctx.func_series_vars.get(fi.name, set()) + declared_param_specs = list( + getattr(self.ctx, "func_declared_param_type_specs", {}).get( + fi.name, () + ) + ) + variant_param_types = ( + getattr(self.ctx, "func_callsite_param_types", {}).get( + (fi.name, call_site_idx), () + ) + if call_site_idx is not None + else () + ) for i, p in enumerate(node.params): spec = None if is_udt and i == 0 and fi.udt_type_name: @@ -1261,9 +1274,44 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No elif fi.name == "isInSession" and i < 2: cpp_t = "std::string" elif p in func_sv: - # This param uses history access (e.g. src[1]) — pass as Series - cpp_t = "const Series&" + # This param uses history access (e.g. src[1]) — preserve its + # Pine scalar family instead of hard-coding Series. + elem_cpp_t = self._series_param_element_cpp_type( + fi, i, call_site_idx + ) + cpp_t = f"const Series<{elem_cpp_t}>&" self._current_func_series_params.add(p) + self._current_func_series_param_types[p] = elem_cpp_t + self._current_func_series_param_types[ + self._safe_name(p) + ] = elem_cpp_t + if ( + i < len(getattr(fi, "param_type_specs", [])) + and fi.param_type_specs[i] is not None + ): + spec = fi.param_type_specs[i] + elif ( + i >= len(declared_param_specs) + or declared_param_specs[i] is None + ) and i < len(variant_param_types) and variant_param_types[i] in { + PineType.INT, + PineType.FLOAT, + PineType.BOOL, + PineType.STRING, + PineType.COLOR, + }: + # Non-history untyped parameters are polymorphic too. A pure + # wrapper around a stateful helper is cloned per written call, + # so its scalar boundary must use that clone's actual family + # instead of the first call's shared FuncInfo inference. + variant_pt = variant_param_types[i] + cpp_t = { + PineType.INT: "int64_t", + PineType.FLOAT: "double", + PineType.BOOL: "bool", + PineType.STRING: "std::string", + PineType.COLOR: "int", + }[variant_pt] elif i < len(getattr(fi, "param_type_specs", [])) and fi.param_type_specs[i] is not None: # Precise per-param TypeSpec (declared hint or call-site inference): # ``pivot hi`` -> ``pivot&``, ``line ln`` -> ``Line&``, an untyped @@ -1307,6 +1355,24 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # A function returning a drawing handle must emit the C++ handle # struct (Line/Box/Label/Linefill), not the unknown lowercase name. ret_type = DRAWING_TYPE_TO_CPP.get(fi.udt_return_type, fi.udt_return_type) + elif self._func_int_return_uses_wide_history( + fi, call_site_idx=call_site_idx + ): + # The history parameter is represented as int64_t so timestamp + # values and their na sentinel cannot narrow at the return edge. + ret_type = "int64_t" + elif ( + call_site_idx is not None + and self._callsite_callable_return_pine_type( + fi, call_site_idx + ) != PineType.UNKNOWN + ): + ret_type = PINE_TYPE_TO_CPP.get( + self._callsite_callable_return_pine_type( + fi, call_site_idx + ), + "double", + ) elif getattr(fi, "return_type_spec", None) is not None: # Exact return TypeSpec (collections plus narrowly cached primitive # terminal reads) takes precedence over the coarse PineType slot. @@ -1456,6 +1522,7 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._current_func_param_specs = {} self._current_func_declared_param_names = set() self._current_func_series_params = set() + self._current_func_series_param_types = {} self._udt_param_udt = {} self._current_func_locals = prev_func_locals self._current_func_local_types = prev_func_local_types diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 8856c9d..2b9aff7 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -34,7 +34,7 @@ from __future__ import annotations from ..ast_nodes import ( - BinOp, BoolLiteral, ExprStmt, FuncCall, FuncDef, Identifier, IfStmt, + Assignment, BinOp, BoolLiteral, ExprStmt, FuncCall, FuncDef, Identifier, IfStmt, MemberAccess, NaLiteral, NumberLiteral, StringLiteral, SwitchStmt, Subscript, Ternary, TupleLiteral, UnaryOp, VarDecl, ) @@ -160,7 +160,7 @@ def _default_for_type(cpp_type: str) -> str: return 'std::string("")' if cpp_type == "bool": return "false" - if cpp_type == "int": + if cpp_type in ("int", "int64_t"): return "0" if cpp_type in DRAWING_TYPE_TO_CPP.values(): return f"{cpp_type}{{}}" @@ -790,13 +790,30 @@ def _map_method_expr( def _type_for_decl(self, node: VarDecl) -> str: """Determine the C++ type for a ``VarDecl``: explicit hint, then symbol, then RHS inference.""" + def promote_wide_int(cpp_type: str) -> str: + if cpp_type != "int": + return cpp_type + owner = self._func_info_map.get( + getattr(self, "_active_func_name", "") or "" + ) + if self._expr_returns_wide_int( + node.value, + owner, + set(), + getattr(self, "_active_call_site_idx", None), + ) or self._is_int64_builtin_init(node.name): + return "int64_t" + return cpp_type + if node.type_hint: spec = self._type_spec_from_hint_name(node.type_hint) if spec is not None: - return self._type_spec_to_cpp(spec) + return promote_wide_int(self._type_spec_to_cpp(spec)) if node.type_hint in self._udt_defs: return node.type_hint - return PINE_TYPE_TO_CPP.get(node.type_hint, "double") + return promote_wide_int( + PINE_TYPE_TO_CPP.get(node.type_hint, "double") + ) # The analyzer records exact callable-local collection bindings before # its lexical scopes are popped. In particular, an inferred local # such as ``selected = cond ? na : global_map`` has a map TypeSpec even @@ -827,7 +844,7 @@ def _type_for_decl(self, node: VarDecl) -> str: # local. The local binding is known from the emitted body inventory; # infer it from its own RHS rather than borrowing the global's PineType. if node.name in getattr(self, "_current_func_locals", set()): - return self._infer_type(node.value) + return promote_wide_int(self._infer_type(node.value)) sym = self.ctx.symbols.resolve(node.name) if sym is not None: inferred = self._infer_type(node.value) @@ -835,8 +852,8 @@ def _type_for_decl(self, node: VarDecl) -> str: return inferred cpp_type = PINE_TYPE_TO_CPP.get(sym.pine_type, "double") if cpp_type != "double" or sym.pine_type != PineType.UNKNOWN: - return cpp_type - return self._infer_type(node.value) + return promote_wide_int(cpp_type) + return promote_wide_int(self._infer_type(node.value)) def _series_type_for(self, name: str) -> str: """C++ element type for a series variable's history buffer.""" @@ -852,6 +869,318 @@ def _series_type_for(self, name: str) -> str: return PINE_TYPE_TO_CPP.get(sym.pine_type, "double") return "double" + def _series_param_element_cpp_type( + self, + func_info, + index: int, + call_site_idx: int | None = None, + ) -> str: + """Canonical element type for one history-referenced UDF parameter. + + A Pine parameter used as ``src[n]`` is a typed time series, not an + implicitly-float series. The old emitter hard-coded every such + parameter as ``Series``. That happened to work for price + sources, but rejected real ``series int``/``series bool`` bindings and + let synthetic-expression bridges conceal the same mismatch by + converting their values to double. + + Pine ``int`` also carries epoch-millisecond builtins such as ``time``. + Use the codegen's existing wide integer representation at this + callable boundary so one declared ``int`` parameter can safely accept + both ``bar_index`` and timestamp values without narrowing. Scalar + integer values are widened when a synthetic bridge is required. + """ + declared_specs = list( + getattr(self.ctx, "func_declared_param_type_specs", {}).get( + func_info.name, () + ) + ) + declared_spec = ( + declared_specs[index] if index < len(declared_specs) else None + ) + # A declared type is authoritative across every written call. Only a + # truly untyped slot may consult the per-callsite specialization map. + spec = declared_spec + if spec is not None and spec.kind == "primitive": + if spec.name == "int": + return "int64_t" + if spec.name == "bool": + return "bool" + if spec.name == "color": + return "int" + if spec.name == "float": + return "double" + + if call_site_idx is not None: + variant = getattr( + self.ctx, "func_callsite_param_types", {} + ).get((func_info.name, call_site_idx)) + pine_type = ( + variant[index] + if variant is not None and index < len(variant) + else PineType.UNKNOWN + ) + if pine_type == PineType.INT: + return "int64_t" + if pine_type == PineType.BOOL: + return "bool" + if pine_type == PineType.COLOR: + return "int" + if pine_type == PineType.FLOAT: + return "double" + + specs = list(getattr(func_info, "param_type_specs", ()) or ()) + spec = specs[index] if index < len(specs) else None + if spec is not None and spec.kind == "primitive": + if spec.name == "int": + return "int64_t" + if spec.name == "bool": + return "bool" + if spec.name == "color": + return "int" + if spec.name == "float": + return "double" + param_types = list(getattr(func_info, "param_types", ()) or ()) + pine_type = param_types[index] if index < len(param_types) else None + if pine_type == PineType.INT: + return "int64_t" + if pine_type == PineType.BOOL: + return "bool" + if pine_type == PineType.COLOR: + return "int" + return "double" + + def _callsite_callable_return_pine_type( + self, func_info, call_site_idx: int | None + ): + if call_site_idx is not None: + variant = getattr( + self.ctx, "func_callsite_return_types", {} + ).get((func_info.name, call_site_idx)) + if variant is not None and variant != PineType.UNKNOWN: + return variant + return getattr(func_info, "return_type", PineType.UNKNOWN) + + def _func_int_return_uses_wide_history( + self, + func_info, + seen: set[str] | None = None, + call_site_idx: int | None = None, + ) -> bool: + """Whether an integer callable must preserve the ``int64_t`` family. + + History-referenced ``int`` parameters are one source of wide values, + but not the only one. A parameterless helper can capture ``time[1]`` + directly, and an otherwise-pure wrapper can return that helper. The + analyzer's Pine-level return type is intentionally just ``INT`` in all + of those cases, so follow the terminal expression/call chain here to + prevent the C++ callable boundary from narrowing epoch milliseconds. + """ + if self._callsite_callable_return_pine_type( + func_info, call_site_idx + ) != PineType.INT: + return False + node = getattr(func_info, "node", None) + if node is None: + return False + + if seen is None: + seen = set() + func_name = getattr(func_info, "name", "") + if func_name in seen: + return False + seen = {*seen, func_name} + + series_names = self.ctx.func_series_vars.get(func_info.name, set()) + if any( + param in series_names + and self._series_param_element_cpp_type( + func_info, index, call_site_idx + ) + == "int64_t" + for index, param in enumerate(node.params) + ): + return True + + if not node.body: + return False + terminal = node.body[-1] + if isinstance(terminal, ExprStmt): + terminal = terminal.expr + return self._expr_returns_wide_int( + terminal, func_info, seen, call_site_idx + ) + + def _expr_returns_wide_int( + self, + expr, + owner_info, + seen: set[str], + call_site_idx: int | None = None, + ) -> bool: + """Trace wide integer provenance through a callable return expression.""" + if expr is None: + return False + if isinstance(expr, Subscript): + return self._expr_returns_wide_int( + expr.object, owner_info, seen, call_site_idx + ) + if isinstance(expr, Identifier): + # A terminal local can be a narrow-looking alias for ``time[1]``. + # Resolve its declaration inside this callable without consulting + # the already-popped analyzer scope. + node = getattr(owner_info, "node", None) + if node is not None: + if expr.name in node.params: + return False + local_key = ( + f"{getattr(owner_info, 'name', '')}:local:{expr.name}" + ) + if local_key in seen: + return False + local_seen = {*seen, local_key} + for child in self._walk_ast_list(node.body): + if ( + isinstance(child, VarDecl) + and child.name == expr.name + and child.value is not None + ): + if self._expr_returns_wide_int( + child.value, + owner_info, + local_seen, + call_site_idx, + ): + return True + if ( + isinstance(child, Assignment) + and isinstance(child.target, Identifier) + and child.target.name == expr.name + ): + if self._expr_returns_wide_int( + child.value, + owner_info, + local_seen, + call_site_idx, + ): + return True + return self._expr_is_int64_builtin(expr) + if self._expr_is_int64_builtin(expr): + return True + if isinstance(expr, FuncCall): + func_name, namespace = self._resolve_callee(expr.callee) + if namespace is None and func_name in { + "int", + "float", + "nz", + "fixnan", + }: + # Casts and Pine's missing-value wrappers preserve the wide + # integer provenance needed by an int destination. ``nz`` may + # obtain it from either the source or replacement argument. + return any( + self._expr_returns_wide_int( + arg, owner_info, seen, call_site_idx + ) + for arg in expr.args + ) + if namespace == "math" and func_name in {"abs", "max", "min"}: + return any( + self._expr_returns_wide_int( + arg, owner_info, seen, call_site_idx + ) + for arg in expr.args + ) + + callee_info = None + if namespace is None and func_name: + callee_info = self._func_info_map.get(func_name) + elif isinstance(expr.callee, MemberAccess): + receiver = expr.callee.object + owner_node = getattr(owner_info, "node", None) + if ( + getattr(owner_info, "is_udt_method", False) + and owner_node is not None + and owner_node.params + and isinstance(receiver, Identifier) + and receiver.name == owner_node.params[0] + and getattr(owner_info, "udt_type_name", None) + ): + callee_info = self._func_info_map.get( + f"{owner_info.udt_type_name}.{expr.callee.member}" + ) + if callee_info is None: + receiver_spec = self._type_spec_from_expr(receiver) + if ( + receiver_spec is not None + and receiver_spec.kind == "udt" + and receiver_spec.name + ): + callee_info = self._func_info_map.get( + f"{receiver_spec.name}.{expr.callee.member}" + ) + return ( + callee_info is not None + and self._func_int_return_uses_wide_history( + callee_info, + seen, + call_site_idx=call_site_idx, + ) + ) + if isinstance(expr, BinOp): + return ( + self._expr_returns_wide_int( + expr.left, owner_info, seen, call_site_idx + ) + or self._expr_returns_wide_int( + expr.right, owner_info, seen, call_site_idx + ) + ) + if isinstance(expr, UnaryOp): + return self._expr_returns_wide_int( + expr.operand, owner_info, seen, call_site_idx + ) + if isinstance(expr, Ternary): + return ( + self._expr_returns_wide_int( + expr.true_val, owner_info, seen, call_site_idx + ) + or self._expr_returns_wide_int( + expr.false_val, owner_info, seen, call_site_idx + ) + ) + if isinstance(expr, IfStmt): + branches = [expr.body, expr.else_body] + return any( + branch + and self._expr_returns_wide_int( + branch[-1].expr + if isinstance(branch[-1], ExprStmt) + else branch[-1], + owner_info, + seen, + call_site_idx, + ) + for branch in branches + ) + if isinstance(expr, SwitchStmt): + branches = [body for _, body in expr.cases] + if expr.default_body: + branches.append(expr.default_body) + return any( + branch + and self._expr_returns_wide_int( + branch[-1].expr + if isinstance(branch[-1], ExprStmt) + else branch[-1], + owner_info, + seen, + call_site_idx, + ) + for branch in branches + ) + return False + def _expr_is_int64_builtin(self, expr) -> bool: """True if ``expr`` is a top-level int64-returning Pine builtin: either a call to one of ``INT64_BUILTINS`` (``time(...)``, ``timestamp(...)``, …) @@ -867,6 +1196,28 @@ def _expr_is_int64_builtin(self, expr) -> bool: return expr.name in INT64_BUILTIN_IDENTIFIERS return False + def _fixnan_site_cpp_type(self, site) -> str: + """Storage type for one fixnan previous-value slot. + + ``PineType.INT`` does not distinguish ordinary counters from epoch + milliseconds, but fixnan stores the exact prior value. Inspect the + authored argument's wide provenance so the state member cannot narrow + ``time[1]`` before an otherwise-int64 local receives it. + """ + cpp_type = PINE_TYPE_TO_CPP.get(site.pine_type, "double") + if site.pine_type != PineType.INT: + return cpp_type + node = getattr(site, "node", None) + args = getattr(node, "args", ()) if node is not None else () + owner_info = self._func_info_map.get( + getattr(site, "owner_func", None) or "" + ) + if args and self._expr_returns_wide_int( + args[0], owner_info, set(), None + ): + return "int64_t" + return cpp_type + def _int64_reassign_targets(self) -> set[str]: """Names of vars that are reassigned (``:=``/``=``) anywhere in the AST with an RHS that is a top-level int64-returning builtin. Cached on the @@ -875,14 +1226,31 @@ def _int64_reassign_targets(self) -> set[str]: cached = getattr(self, "_int64_reassign_cache", None) if cached is not None: return cached - from ..ast_nodes import Assignment targets: set[str] = set() + # Callable locals need their lexical owner so a reassignment through a + # wrapper/method returning wide history is recognized as well as a + # direct ``time[1]`` RHS. + for info in getattr(self.ctx, "func_infos", ()): + node = getattr(info, "node", None) + if node is None: + continue + for child in self._walk_ast_list(node.body): + if ( + isinstance(child, Assignment) + and isinstance(child.target, Identifier) + and self._expr_returns_wide_int( + child.value, info, set(), None + ) + ): + targets.add(child.target.name) ast = getattr(self.ctx, "ast", None) if ast is not None: for node in self._walk_ast(ast): if (isinstance(node, Assignment) and isinstance(node.target, Identifier) - and self._expr_is_int64_builtin(node.value)): + and self._expr_returns_wide_int( + node.value, None, set(), None + )): targets.add(node.target.name) self._int64_reassign_cache = targets return targets @@ -1273,6 +1641,10 @@ def _infer_type(self, node) -> str: and ternaries / if / switch expressions. Returns the string ``"double"`` as the safe fallback when no narrower type can be determined.""" + if isinstance(node, Subscript) and isinstance(node.object, FuncCall): + # A callable-result history read keeps the callable's scalar + # family. Collection subscripts follow separate element-type paths. + return self._infer_type(node.object) if isinstance(node, NumberLiteral): return "double" if isinstance(node.value, float) else "int" if isinstance(node, BoolLiteral): @@ -1297,6 +1669,10 @@ def _infer_type(self, node) -> str: return "int" if isinstance(val, float): return "double" + if node.name in getattr( + self, "_current_func_series_param_types", {} + ): + return self._current_func_series_param_types[node.name] if node.name in self._current_func_param_types: return self._current_func_param_types[node.name] if node.name in getattr(self, "_current_func_local_types", {}): @@ -1374,16 +1750,42 @@ def _infer_type(self, node) -> str: if recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name: fi_u = self._func_info_map.get(f"{recv_spec.name}.{member_name}") if fi_u is not None: + call_site_idx = self._callable_target_callsite_idx( + fi_u, node + ) + if self._func_int_return_uses_wide_history( + fi_u, call_site_idx=call_site_idx + ): + return "int64_t" if getattr(fi_u, "return_type_spec", None) is not None: return self._type_spec_to_cpp(fi_u.return_type_spec) - return PINE_TYPE_TO_CPP.get(fi_u.return_type, "double") + return PINE_TYPE_TO_CPP.get( + self._callsite_callable_return_pine_type( + fi_u, call_site_idx + ), + "double", + ) + if namespace is None and func_name in self._func_info_map: + fi_u = self._func_info_map[func_name] + call_site_idx = self._callable_target_callsite_idx(fi_u, node) + if self._func_int_return_uses_wide_history( + fi_u, call_site_idx=call_site_idx + ): + return "int64_t" spec = self._type_spec_from_expr(node) if spec is not None: return self._type_spec_to_cpp(spec) if namespace in self._udt_defs and func_name == "new": return namespace if namespace is None and func_name in self._func_info_map: - return PINE_TYPE_TO_CPP.get(self._func_info_map[func_name].return_type, "double") + fi_u = self._func_info_map[func_name] + return PINE_TYPE_TO_CPP.get( + self._callsite_callable_return_pine_type( + fi_u, + self._callable_target_callsite_idx(fi_u, node), + ), + "double", + ) site = self._get_ta_site(node) if site is not None: ta_name = self._ta_name_from_site(site) diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 9624b72..2962451 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -247,6 +247,37 @@ def _udt_method_call_emit_name(self, fi, node: FuncCall) -> str: return f"{base}_cs{self._active_call_site_idx}" return base + def _callable_target_callsite_idx(self, fi, node: FuncCall) -> int | None: + """Return the primitive profile selected by this emitted call path.""" + cs_info = self.ctx.func_call_cs_map.get(id(node)) + count = self.ctx.func_call_site_counts.get(fi.name, 0) + if self._active_call_site_idx is not None and ( + cs_info is not None or count > 1 + ): + return self._active_call_site_idx + if cs_info is not None and cs_info[0] == fi.name: + return cs_info[1] + return None + + @staticmethod + def _series_bridge_value_expr(expr_cpp: str, cpp_type: str) -> str: + """Convert one scalar to a Series element without erasing Pine na. + + Numeric C++ casts do not translate sentinels: INT64_MIN converted to + double is finite, while NaN converted to an integer is undefined. A + history bridge must re-materialize ``na`` in the destination family + before pushing the value. + """ + return ( + "([&]() { auto _pf_series_raw = (" + + expr_cpp + + "); return is_na(_pf_series_raw) ? na<" + + cpp_type + + ">() : static_cast<" + + cpp_type + + ">(_pf_series_raw); }())" + ) + def _array_init_value_expr(self, elem_spec: TypeSpec | None, value_node) -> str: if isinstance(value_node, NaLiteral): if elem_spec is not None and elem_spec.kind == "udt": @@ -777,27 +808,45 @@ def _visit_udt_method_series_arg( 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) + expected_cpp_type = self._series_param_element_cpp_type( + func_info, + param_index, + self._callable_target_callsite_idx(func_info, call_node), + ) if isinstance(arg_node, Identifier): arg_name = arg_node.name - if arg_name in BAR_FIELDS or arg_name in BAR_SERIES_PUSH: + if ( + (arg_name in BAR_FIELDS or arg_name in BAR_SERIES_PUSH) + and expected_cpp_type == "double" + ): return f"_s_{arg_name}" safe = self._safe_name(arg_name) - if arg_name in self._current_func_series_params: + is_current_series_param = ( + arg_name in self._current_func_series_params + ) + if ( + is_current_series_param + and self._current_func_series_param_types.get(arg_name) + == expected_cpp_type + ): 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): + if ( + not is_current_series_param + and self._binding_is_series(arg_name, safe) + and self._series_type_for(arg_name) == expected_cpp_type + ): 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" + cpp_type = expected_cpp_type + bridge_cpp = self._series_bridge_value_expr(expr_cpp, cpp_type) 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"{cpp_type} _sv = {bridge_cpp}; " f"if (history_advances_new_bar()) {member}.push(_sv); " f"else {member}.update(_sv); " f"return {member}; }}())" @@ -1924,10 +1973,18 @@ def _visit_func_call(self, node: FuncCall) -> str: def _visit_arg_for_series(arg_node, arg_idx): """Visit a function argument, returning Series ref for series params.""" if arg_idx in _func_series_param_indices: + expected_cpp_type = self._series_param_element_cpp_type( + fi_lookup, + arg_idx, + self._callable_target_callsite_idx(fi_lookup, node), + ) if isinstance(arg_node, Identifier): aname = arg_node.name # Bar field: pass _s_close instead of current_bar_.close - if aname in BAR_FIELDS or aname in BAR_SERIES_PUSH: + if ( + (aname in BAR_FIELDS or aname in BAR_SERIES_PUSH) + and expected_cpp_type == "double" + ): return f"_s_{aname}" # Exact Series binding: pass the Series object directly. # A raw name can also denote a scalar sibling/callable @@ -1940,22 +1997,35 @@ 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_series_params: + is_current_series_param = ( + aname in self._current_func_series_params + ) + if ( + is_current_series_param + and self._current_func_series_param_types.get(aname) + == expected_cpp_type + ): 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(aname, safe): + if ( + not is_current_series_param + and self._binding_is_series(aname, safe) + and self._series_type_for(aname) + == expected_cpp_type + ): return safe expr_cpp = self._visit_expr(arg_node) - cpp_t = self._infer_type(arg_node) - if cpp_t not in ("double", "int", "bool"): - cpp_t = "double" + cpp_t = expected_cpp_type + bridge_cpp = self._series_bridge_value_expr( + expr_cpp, cpp_t + ) member = self._inline_history_member( "series_arg", node, arg_idx=arg_idx ) return ( f"([&]() -> const Series<{cpp_t}>& {{ " - f"{cpp_t} _sv = ({expr_cpp}); " + f"{cpp_t} _sv = {bridge_cpp}; " f"if (history_advances_new_bar()) {member}.push(_sv); " f"else {member}.update(_sv); " f"return {member}; }}())" diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 7b7a274..0ca3bc3 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -1051,7 +1051,7 @@ def _visit_subscript(self, node: Subscript) -> str: if isinstance(node.object, FuncCall): inner = self._visit_expr(node.object) cpp_t = self._infer_type(node.object) - if cpp_t not in ("double", "int", "bool"): + if cpp_t not in ("double", "int", "int64_t", "bool"): cpp_t = "double" member = self._inline_history_member("hist_call", node) ta_site = self._get_ta_site(node.object) diff --git a/tests/test_callable_series_param_types.py b/tests/test_callable_series_param_types.py new file mode 100644 index 0000000..4e67b3b --- /dev/null +++ b/tests/test_callable_series_param_types.py @@ -0,0 +1,565 @@ +"""Pine scalar families are preserved across callable history parameters. + +The history operator does not erase the underlying Pine type. In +particular, ``src[1]`` on an ``int`` or ``bool`` UDF parameter must not force +that parameter to ``Series``. The runtime panel deliberately crosses +plain UDFs, nested UDFs, UDT methods, arithmetic/ternary actuals, a timestamp +actual, and float controls so a compensating bridge bug cannot make a narrow +compile-only probe look green. +""" + +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests.test_runtime_var_initialization import _compile_and_run + + +_SOURCE = '''//@version=6 +strategy("typed series param runtime panel") +intHistory(int src) => src[1] +intOuter(int src) => intHistory(src) +boolHistory(bool src) => src[1] +floatHistory(float src) => src[1] +type Holder + int seed +method intHistory(Holder self, int src) => src[1] +method intOuter(Holder self, int src) => self.intHistory(src) +var Holder holder = Holder.new(0) +flag = close > open +udf_direct = intHistory(bar_index) +udf_arithmetic = intHistory(bar_index + 1) +udf_ternary = intHistory(close > open ? bar_index : bar_index + 1) +udf_time = intHistory(time) +udf_nested = intOuter(bar_index) +method_direct = holder.intHistory(bar_index) +method_nested = holder.intOuter(bar_index) +bool_direct = boolHistory(flag) +bool_compound = boolHistory(close > open ? true : false) +float_control = floatHistory(close) +var bool int_first_missing = false +var bool time_first_missing = false +var bool bool_first_is_false = false +if bar_index == 0 + int_first_missing := na(udf_direct) + time_first_missing := na(udf_time) + bool_first_is_false := bool_direct == false +''' + + +_DRIVER = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 12.0, 9.0, 11.0, 1.0, 1700000000000LL}, + Bar{20.0, 21.0, 18.0, 19.0, 1.0, 1700000060000LL}, + Bar{30.0, 32.0, 29.0, 31.0, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.udf_direct << " " + << strategy.udf_arithmetic << " " + << strategy.udf_ternary << " " + << strategy.udf_time << " " + << strategy.udf_nested << " " + << strategy.method_direct << " " + << strategy.method_nested << " " + << strategy.bool_direct << " " + << strategy.bool_compound << " " + << strategy.float_control << " " + << strategy.int_first_missing << " " + << strategy.time_first_missing << " " + << strategy.bool_first_is_false << "\n"; +} +''' + + +def test_callable_history_parameters_keep_their_pine_scalar_family() -> None: + cpp = transpile(_SOURCE) + + assert "int64_t intHistory_cs0(const Series& src)" in cpp + assert "int64_t intOuter_cs0(const Series& src)" in cpp + assert ( + "int64_t _udt_Holder_intHistory_cs0(Holder& self, " + "const Series& src)" + ) in cpp + assert ( + "int64_t _udt_Holder_intOuter_cs0(Holder& self, " + "const Series& src)" + ) in cpp + assert "bool boolHistory_cs0(const Series& src)" in cpp + assert "double floatHistory_cs0(const Series& src)" in cpp + + # bar_index uses Series at chart scope, so the int callable boundary + # widens its call-site history. Epoch time already uses Series and + # can bind directly; neither path is allowed to detour through double. + assert "auto _pf_series_raw = (pine_bar_index())" in cpp + assert "is_na(_pf_series_raw) ? na()" in cpp + assert "udf_time = intHistory_cs4(time);" in cpp + assert "intHistory_cs0(const Series& src)" not in cpp + assert "boolHistory_cs0(const Series& src)" not in cpp + + +def test_callable_history_scalar_family_panel_runs_natively() -> None: + cpp = transpile(_SOURCE) + assert _compile_and_run(cpp + _DRIVER) == ( + "1 2 2 1700000060000 1 1 1 0 0 19 1 1 1\n" + ) + + +def test_nested_int_series_widens_into_float_history_natively() -> None: + source = '''//@version=6 +strategy("nested int to float history") +floatHistory(float src) => src[1] +intOuter(int src) => floatHistory(src) +type Holder + int seed +method floatHistory(Holder self, float src) => src[1] +method intOuter(Holder self, int src) => self.floatHistory(src) +var Holder holder = Holder.new(0) +widened = intOuter(time) +methodWidened = holder.intOuter(time) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 1.0, 1.0, 1.0, 1.0, 1700000000000LL}, + Bar{2.0, 2.0, 2.0, 2.0, 1.0, 1700000060000LL}, + Bar{3.0, 3.0, 3.0, 3.0, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << static_cast(strategy.widened) << " " + << static_cast(strategy.methodWidened) << "\n"; +} +''' + cpp = transpile(source) + assert "double floatHistory_cs0(const Series& src)" in cpp + assert "double intOuter_cs0(const Series& src)" in cpp + assert ( + "double _udt_Holder_floatHistory_cs0(Holder& self, " + "const Series& src)" + ) in cpp + assert ( + "double _udt_Holder_intOuter_cs0(Holder& self, " + "const Series& src)" + ) in cpp + assert "([&]() -> const Series&" in cpp + assert _compile_and_run(cpp + driver) == ( + "1700000060000 1700000060000\n" + ) + + +def test_parameterless_wrapper_preserves_captured_time_history_natively() -> None: + source = '''//@version=6 +strategy("captured time history") +captureTime() => time[1] +wrapper() => captureTime() +captured = wrapper() +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 1.0, 1.0, 1.0, 1.0, 1700000000000LL}, + Bar{2.0, 2.0, 2.0, 2.0, 1.0, 1700000060000LL}, + Bar{3.0, 3.0, 3.0, 3.0, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.captured << "\n"; +} +''' + cpp = transpile(source) + assert "int64_t captureTime()" in cpp + assert "int64_t wrapper()" in cpp + assert "int64_t captured = 0;" in cpp + assert _compile_and_run(cpp + driver) == "1700000060000\n" + + +def test_untyped_udf_history_specializes_both_source_orders_natively() -> None: + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 12.0, 9.0, 11.25, 1.0, 1700000000000LL}, + Bar{20.0, 21.0, 18.0, 19.75, 1.0, 1700000060000LL}, + Bar{30.0, 32.0, 29.0, 31.50, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.intOut << " " << strategy.floatOut << " " + << strategy.intFirstMissing << "\n"; +} +''' + orders = ( + ( + "int-first", + "int intOut = history(src=bar_index)\n" + "float floatOut = history(src=close)", + "int64_t history_cs0(const Series& src)", + "double history_cs1(const Series& src)", + ), + ( + "float-first", + "float floatOut = history(src=close)\n" + "int intOut = history(src=bar_index)", + "double history_cs0(const Series& src)", + "int64_t history_cs1(const Series& src)", + ), + ) + for title, calls, first_signature, second_signature in orders: + source = f'''//@version=6 +strategy("untyped udf {title}") +history(src) => src[1] +{calls} +var bool intFirstMissing = false +if bar_index == 0 + intFirstMissing := na(intOut) +''' + cpp = transpile(source) + assert first_signature in cpp + assert second_signature in cpp + assert _compile_and_run(cpp + driver) == "1 19.75 1\n" + + +def test_untyped_method_history_specializes_both_source_orders_natively() -> None: + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 12.0, 9.0, 11.25, 1.0, 1700000000000LL}, + Bar{20.0, 21.0, 18.0, 19.75, 1.0, 1700000060000LL}, + Bar{30.0, 32.0, 29.0, 31.50, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.intOut << " " << strategy.floatOut << " " + << strategy.boolOut << " " << strategy.intFirstMissing << " " + << strategy.boolFirstValue << "\n"; +} +''' + orders = ( + ( + "int-first", + "int intOut = holder.history(src=bar_index)\n" + "float floatOut = holder.history(src=close)", + "int64_t _udt_Holder_history_cs0", + "double _udt_Holder_history_cs1", + ), + ( + "float-first", + "float floatOut = holder.history(src=close)\n" + "int intOut = holder.history(src=bar_index)", + "double _udt_Holder_history_cs0", + "int64_t _udt_Holder_history_cs1", + ), + ) + for title, calls, first_signature, second_signature in orders: + source = f'''//@version=6 +strategy("untyped method {title}") +type Holder + int seed +method history(Holder self, src) => src[1] +var Holder holder = Holder.new(0) +{calls} +bool boolOut = holder.history(src=close > open) +var bool intFirstMissing = false +var bool boolFirstValue = true +if bar_index == 0 + intFirstMissing := na(intOut) + boolFirstValue := boolOut +''' + cpp = transpile(source) + assert first_signature in cpp + assert second_signature in cpp + assert "const Series& src" in cpp + assert _compile_and_run(cpp + driver) == "1 19.75 0 1 0\n" + + +def test_transformed_untyped_wrapper_profiles_own_each_history_bridge() -> None: + source = '''//@version=6 +strategy("transformed untyped wrapper") +inner(innerSrc) => innerSrc[1] +outer(outerSrc) => inner(outerSrc + 0) +int intOut = outer(bar_index) +float floatOut = outer(close) +var bool intFirstMissing = false +if bar_index == 0 + intFirstMissing := na(intOut) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 12.0, 9.0, 11.25, 1.0, 1700000000000LL}, + Bar{20.0, 21.0, 18.0, 19.75, 1.0, 1700000060000LL}, + Bar{30.0, 32.0, 29.0, 31.50, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.intOut << " " << strategy.floatOut << " " + << strategy.intFirstMissing << "\n"; +} +''' + cpp = transpile(source) + assert "Series _series_arg_" in cpp + assert "Series _series_arg_" in cpp + assert "int64_t outer_cs0(int64_t outerSrc)" in cpp + assert "double outer_cs1(double outerSrc)" in cpp + assert _compile_and_run(cpp + driver) == "1 19.75 1\n" + + +def test_mutable_local_wide_history_survives_udf_and_method_wrappers() -> None: + source = '''//@version=6 +strategy("mutable local wide history") +captureReassigned() => + int value = 0 + value := time[1] + value +wrapper() => captureReassigned() +type Holder + int seed +method captureReassigned(Holder self) => + int value = 0 + value := time[1] + value +method wrapper(Holder self) => self.captureReassigned() +var Holder holder = Holder.new(0) +udfObserved = wrapper() +methodObserved = holder.wrapper() +var bool udfFirstMissing = false +var bool methodFirstMissing = false +if bar_index == 0 + udfFirstMissing := na(udfObserved) + methodFirstMissing := na(methodObserved) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 1.0, 1.0, 1.0, 1.0, 1700000000000LL}, + Bar{2.0, 2.0, 2.0, 2.0, 1.0, 1700000060000LL}, + Bar{3.0, 3.0, 3.0, 3.0, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.udfObserved << " " << strategy.methodObserved << " " + << strategy.udfFirstMissing << " " + << strategy.methodFirstMissing << "\n"; +} +''' + cpp = transpile(source) + assert cpp.count("int64_t value = 0;") == 2 + assert "int64_t captureReassigned()" in cpp + assert "int64_t wrapper()" in cpp + assert "int64_t _udt_Holder_captureReassigned(Holder& self)" in cpp + assert "int64_t _udt_Holder_wrapper(Holder& self)" in cpp + assert _compile_and_run(cpp + driver) == ( + "1700000060000 1700000060000 1 1\n" + ) + + +def test_callable_result_history_preserves_int64_and_first_bar_na() -> None: + source = '''//@version=6 +strategy("call result wide history") +captureTime() => time +prior = captureTime()[1] +var bool firstMissing = false +if bar_index == 0 + firstMissing := na(prior) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 1.0, 1.0, 1.0, 1.0, 1700000000000LL}, + Bar{2.0, 2.0, 2.0, 2.0, 1.0, 1700000060000LL}, + Bar{3.0, 3.0, 3.0, 3.0, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.prior << " " << strategy.firstMissing << "\n"; +} +''' + cpp = transpile(source) + assert "Series _hist_call_" in cpp + assert "int64_t prior = 0;" in cpp + assert _compile_and_run(cpp + driver) == "1700000060000 1\n" + + +def test_int64_to_float_series_bridge_translates_na_sentinel() -> None: + source = '''//@version=6 +strategy("sentinel preserving widening") +intHistory(int src) => src[1] +floatHistory(float src) => src[1] +out = floatHistory(intHistory(time)) +var bool firstMissing = false +var bool secondMissing = false +if bar_index == 0 + firstMissing := na(out) +if bar_index == 1 + secondMissing := na(out) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 1.0, 1.0, 1.0, 1.0, 1700000000000LL}, + Bar{2.0, 2.0, 2.0, 2.0, 1.0, 1700000060000LL}, + Bar{3.0, 3.0, 3.0, 3.0, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << static_cast(strategy.out) << " " + << strategy.firstMissing << " " << strategy.secondMissing + << "\n"; +} +''' + cpp = transpile(source) + assert "is_na(_pf_series_raw) ? na()" in cpp + assert _compile_and_run(cpp + driver) == "1700000000000 1 1\n" + + +def test_unresolved_transformed_untyped_history_profile_fails_closed() -> None: + source = '''//@version=6 +strategy("unresolved transformed profile") +inner(x) => x[1] +outer(y) => inner(math.abs(y)) +out = outer(close) +''' + with pytest.raises( + CompileError, + match=( + "Cannot infer the per-callsite primitive type of history " + "parameter 'x'" + ), + ): + transpile(source) + + +def test_multi_wrapper_untyped_history_profile_collision_fails_closed() -> None: + source = '''//@version=6 +strategy("multi-wrapper profile collision") +history(src) => src[1] +first(value) => history(value) +second(value) => history(value) +int intOut = first(bar_index) +float floatOut = first(close) +int timeOut = second(time) +''' + with pytest.raises( + CompileError, + match=( + "distinct primitive types collapse onto the same written-call " + "variant" + ), + ): + transpile(source) + + +def test_history_return_alias_and_terminal_if_keep_variant_family_natively() -> None: + source = '''//@version=6 +strategy("history return flow") +aliasHistory(src) => + prior = src[1] + prior +branchHistory(src) => + if bar_index >= 0 + src[1] + else + src +int aliasInt = aliasHistory(bar_index) +float aliasFloat = aliasHistory(close) +int branchInt = branchHistory(bar_index) +float branchFloat = branchHistory(close) +var bool aliasFirstMissing = false +var bool branchFirstMissing = false +if bar_index == 0 + aliasFirstMissing := na(aliasInt) + branchFirstMissing := na(branchInt) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{10.0, 12.0, 9.0, 11.25, 1.0, 1700000000000LL}, + Bar{20.0, 21.0, 18.0, 19.75, 1.0, 1700000060000LL}, + Bar{30.0, 32.0, 29.0, 31.50, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.aliasInt << " " << strategy.aliasFloat << " " + << strategy.branchInt << " " << strategy.branchFloat << " " + << strategy.aliasFirstMissing << " " + << strategy.branchFirstMissing << "\n"; +} +''' + cpp = transpile(source) + assert "int64_t aliasHistory_cs0(const Series& src)" in cpp + assert "double aliasHistory_cs1(const Series& src)" in cpp + assert "int64_t branchHistory_cs0(const Series& src)" in cpp + assert "double branchHistory_cs1(const Series& src)" in cpp + assert _compile_and_run(cpp + driver) == "1 19.75 1 19.75 1 1\n" + + +def test_wide_local_reassignment_through_na_wrappers_runs_natively() -> None: + source = '''//@version=6 +strategy("wide local na wrappers") +captureNz() => + int value = 0 + value := nz(time[1]) + value +captureFixnan() => + int value = 0 + value := fixnan(time[1]) + value +nzObserved = captureNz() +fixnanObserved = captureFixnan() +var bool fixnanFirstMissing = false +if bar_index == 0 + fixnanFirstMissing := na(fixnanObserved) +''' + driver = r''' +#include +int main() { + Bar bars[] = { + Bar{1.0, 1.0, 1.0, 1.0, 1.0, 1700000000000LL}, + Bar{2.0, 2.0, 2.0, 2.0, 1.0, 1700000060000LL}, + Bar{3.0, 3.0, 3.0, 3.0, 1.0, 1700000120000LL}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + std::cout << strategy.nzObserved << " " << strategy.fixnanObserved << " " + << strategy.fixnanFirstMissing << "\n"; +} +''' + cpp = transpile(source) + assert cpp.count("int64_t value = 0;") == 2 + assert "int64_t captureNz()" in cpp + assert "int64_t captureFixnan_cs0()" in cpp + assert "int64_t _prev_fixnan_1 = na();" in cpp + assert _compile_and_run(cpp + driver) == ( + "1700000060000 1700000060000 1\n" + ) + + +def test_nested_untyped_pure_transform_does_not_use_shared_return_cache() -> None: + source = '''//@version=6 +strategy("nested pure transform") +history(src) => src[1] +castInt(value) => int(value) +outer(value) => history(castInt(value)) +out = outer(time) +''' + with pytest.raises( + CompileError, + match=( + "Cannot infer the per-callsite primitive type of history " + "parameter 'src'" + ), + ): + transpile(source) diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index 69f1380..ef424f1 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -551,8 +551,9 @@ def test_callable_persistent_siblings_keep_exact_clone_storage(): 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 - assert "double _sv = (x__blk1_cs1)" in cpp + assert "auto _pf_series_raw = (x__blk1)" in cpp + assert "auto _pf_series_raw = (x__blk1_cs1)" in cpp + assert "is_na(_pf_series_raw) ? na()" in cpp compile_cpp(cpp, label="callable-persistent-sibling-clone-storage") @@ -584,7 +585,8 @@ def test_counted_loop_binder_shadows_global_series_and_uses_history_bridge(): "plot(globalPrior + total)" ) assert "total += (i + lag_cs1(" in cpp - assert "double _sv = (i)" in cpp + assert "auto _pf_series_raw = (i)" in cpp + assert "is_na(_pf_series_raw) ? na()" in cpp assert "total += (i[0]" not in cpp compile_cpp(cpp, label="counted-loop-binder-shadows-global-series")