diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 170fa80..e9d488a 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -29,6 +29,12 @@ method_receiver_type_name, ) from ..errors import SourceLocation, Diagnostic, CompileError, Level, Phase +from ..method_binding import ( + BoundMethodArgs, + MethodBindError, + bind_method_call, + inventory_method_signatures, +) from .. import signatures as sigs from .. import tv_input_choices as tv_in # Output dataclasses (contract with the codegen) live in contracts.py so @@ -121,6 +127,10 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper): def __init__(self, ast: Program, filename: str = "") -> None: self._ast = ast self._filename = filename + self._method_signatures = inventory_method_signatures(ast) + self._method_call_bindings: dict[ + tuple[int, str], BoundMethodArgs + ] = {} self._symbols = SymbolTable() self._ta_call_sites: list[TACallSite] = [] self._series_vars: set[str] = set() @@ -678,6 +688,87 @@ def _check_forward_tuple_helper_wrappers(self) -> None: terminal.loc, ) + def _refresh_direct_typed_method_wrapper_returns(self) -> None: + """Reconcile direct sibling-method return types without revisiting AST. + + Method signatures are inventoried before source-order analysis so a + call to a later declaration binds to the authored method, but the + callee's body-derived return type does not exist yet. A direct wrapper + such as ``outer(H self) => self.inner()`` therefore initially receives + the generic scalar fallback. Once declarations are registered, copy + the exact terminal callee contract through direct first-parameter + receiver edges to a fixed point. This pass is deliberately structural: it does + not call ``_visit`` and cannot duplicate TA, fixnan or written-callsite + state. + """ + infos = {info.name: info for info in self._func_infos} + if not infos: + return + + def direct_callee_key(info: FuncInfo) -> str | None: + node = info.node + if ( + node is None + or not info.is_udt_method + or not node.params + ): + return None + terminal = self._direct_terminal_return_expr(node) + if not ( + isinstance(terminal, FuncCall) + and isinstance(terminal.callee, MemberAccess) + and isinstance(terminal.callee.object, Identifier) + and terminal.callee.object.name == node.params[0] + ): + return None + if not info.param_type_specs: + return None + receiver_name = method_receiver_type_name( + info.param_type_specs[0] + ) + if receiver_name is None: + return None + return f"{receiver_name}.{terminal.callee.member}" + + # One newly resolved leaf can unlock one wrapper edge per pass. Cycles + # without an exact leaf retain their existing fail-closed fallback. + for _ in range(len(infos) + 1): + changed = False + for wrapper in infos.values(): + callee_key = direct_callee_key(wrapper) + callee = infos.get(callee_key) if callee_key else None + if callee is None: + continue + + if ( + callee.return_type not in {PineType.UNKNOWN, PineType.VOID} + and wrapper.return_type != callee.return_type + ): + wrapper.return_type = callee.return_type + self._func_return_types[wrapper.name] = callee.return_type + changed = True + + callee_spec = callee.return_type_spec + if ( + callee_spec is not None + and wrapper.return_type_spec != callee_spec + ): + wrapper.return_type_spec = callee_spec + self._func_return_type_specs[wrapper.name] = callee_spec + changed = True + + if ( + callee.udt_return_type is not None + and wrapper.udt_return_type != callee.udt_return_type + ): + wrapper.udt_return_type = callee.udt_return_type + self._func_udt_return_types[wrapper.name] = ( + callee.udt_return_type + ) + changed = True + if not changed: + break + def _refresh_direct_terminal_array_temporary_returns(self) -> None: """Reconcile exact primitive returns without revisiting call sites. @@ -2419,6 +2510,9 @@ def _bound_user_call_args(callee: str, call: FuncCall) -> list: getattr(info, "is_udt_method", False) and isinstance(call.callee, MemberAccess) ): + if callee in self._method_signatures: + binding = self._bind_typed_method_call(callee, call) + return [call.callee.object, *binding.args_by_param] return [ call.callee.object, *self._bind_callable_args(call, params[1:]), @@ -4251,9 +4345,11 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: if (udt_ret is None and terminal_direct_return_spec is not None and terminal_direct_return_spec.kind == "udt"): - from .types import _DRAWING_TYPE_NAMES - if terminal_direct_return_spec.name in _DRAWING_TYPE_NAMES: - udt_ret = terminal_direct_return_spec.name + # A user UDT is a numeric object-ID handle just like a drawing + # handle. Returning a parameter/local identity must retain + # that exact handle type; limiting this route to drawings made + # ``identity(Item value) => value`` emit ``double``. + udt_ret = terminal_direct_return_spec.name if udt_ret is None: # Drawing-handle returns wrapped in an if-statement terminal # branch (``makeEventLabel``) or returned as a bare drawing-handle @@ -4424,12 +4520,18 @@ def _visit_MethodDef(self, node) -> PineType: terminal_spec = self._type_spec_from_expr(terminal_ret_expr) if terminal_spec is not None and terminal_spec.kind == "map": return_type_spec = terminal_spec - method_udt_return = ( - self._udt_name_from_ctor(terminal_ret_expr) - or self._udt_name_from_nullable_ctor_selection( - terminal_ret_expr + if terminal_spec is not None and terminal_spec.kind == "udt": + # Methods may return ``self`` or another UDT-typed + # parameter/local, not only a syntactic ``Type.new``. + method_udt_return = terminal_spec.name + return_type_spec = terminal_spec + else: + method_udt_return = ( + self._udt_name_from_ctor(terminal_ret_expr) + or self._udt_name_from_nullable_ctor_selection( + terminal_ret_expr + ) ) - ) if method_udt_return is not None: return_type_spec = TypeSpec.udt(method_udt_return) finally: @@ -4508,6 +4610,9 @@ def _visit_MethodDef(self, node) -> PineType: udt_return_type=method_udt_return, ) self._func_infos.append(fi) + # A just-registered later sibling can resolve an earlier direct method + # wrapper before the next source statement is analyzed. + self._refresh_direct_typed_method_wrapper_returns() return PineType.VOID # ------------------------------------------------------------------ @@ -4902,6 +5007,26 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: self._visit(val) return self._visit(callee) + # Compatibility functional form: ``Type.copy(object)``. The + # canonical Pine spelling is ``object.copy()`` below, but retaining + # this existing surface keeps older corpus sources well-defined. + if ( + isinstance(obj, Identifier) + and obj.name in self._udt_fields + and member == "copy" + ): + for arg in node.args: + self._visit(arg) + for val in node.kwargs.values(): + self._visit(val) + if len(node.args) != 1 or node.kwargs: + self._error( + f"{obj.name}.copy(...) expects exactly one object argument", + node.loc, + ) + return PineType.UNKNOWN + return self._visit(node.args[0]) + # General member call (e.g., array.push, etc.) receiver_pine_type = self._visit(obj) visited_member_arg_types: dict[int, PineType] = {} @@ -4917,6 +5042,21 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: receiver_type_name = method_receiver_type_name(receiver_spec) if receiver_type_name is not None: method_key = f"{receiver_type_name}.{member}" + signature = self._method_signatures.get(method_key) + strict_binding = ( + self._bind_typed_method_call(method_key, node) + if signature is not None + else None + ) + if strict_binding is not None: + # Defaults are real call-site expressions. Visit only the + # inserted nodes here; written actuals were visited above. + supplied_ids = { + id(arg) for arg in [*node.args, *node.kwargs.values()] + } + for arg in strict_binding.evaluation_order: + if id(arg) not in supplied_ids: + visited_member_arg_types[id(arg)] = self._visit(arg) method_info = next( ( info @@ -4929,7 +5069,11 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: if method_info is not None and method_info.node is not None: method_params = list(method_info.node.params) rest_params = method_params[1:] - rest_bound = self._bind_callable_args(node, rest_params) + rest_bound = ( + list(strict_binding.args_by_param) + if strict_binding is not None + else self._bind_callable_args(node, rest_params) + ) full_bound: list[ASTNode | None] = [obj, *rest_bound] full_param_types = [ receiver_pine_type, @@ -5033,6 +5177,24 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: full_param_types, method_info.return_type, ) + if signature is not None: + # The exact authored method exists later in source. Its + # body-derived return/effect metadata is intentionally not + # guessed here, and the call must not fall through to a + # same-named builtin such as UDT ``copy()``. + return PineType.UNKNOWN + if ( + receiver_spec.kind == "udt" + and receiver_spec.name in self._udt_fields + and member == "copy" + ): + if node.args or node.kwargs: + self._error( + f"{receiver_spec.name}.copy() expects no arguments", + node.loc, + ) + return PineType.UNKNOWN + return receiver_pine_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 @@ -5276,6 +5438,26 @@ def _bind_callable_args( bound[param_names.index(name)] = value return bound + def _bind_typed_method_call( + self, + method_key: str, + node: FuncCall, + ) -> BoundMethodArgs: + """Strict shared binding for one exact typed method call.""" + + cache_key = (id(node), method_key) + cached = self._method_call_bindings.get(cache_key) + if cached is not None: + return cached + signature = self._method_signatures[method_key] + try: + bound = bind_method_call(signature, node) + except MethodBindError as exc: + self._error(str(exc), node.loc) + raise AssertionError("unreachable") from exc + self._method_call_bindings[cache_key] = bound + return bound + def _record_deferred_param_call_edge( self, call_node: FuncCall, diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 79ce1d8..ab524f2 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -1262,12 +1262,20 @@ def _materialize_user_func_call_site_state( selected_ta_indices: dict[int, int] = {} param_arg_map: dict[str, str] = {} - positional_args = list(node.args) if ( method_info is not None and isinstance(node.callee, MemberAccess) + and func_name in self._method_signatures ): - positional_args.insert(0, node.callee.object) + binding = self._bind_typed_method_call(func_name, node) + positional_args = [node.callee.object, *binding.args_by_param] + else: + positional_args = list(node.args) + if ( + method_info is not None + and isinstance(node.callee, MemberAccess) + ): + positional_args.insert(0, node.callee.object) for p_idx, param_name in enumerate(func_def.params): if p_idx < len(positional_args): param_arg_map[param_name] = self._expr_to_str( diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index 49af7c8..4fb66d9 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -388,11 +388,16 @@ def direct_user_udt_ctor_name(node: ASTNode) -> str | None: typed_receiver_name = method_receiver_type_name( typed_receiver_spec ) + method_key = ( + f"{typed_receiver_name}.{func}" + if typed_receiver_name is not None + else None + ) method_info = next( ( info for info in getattr(self, "_func_infos", ()) - if info.name == f"{typed_receiver_name}.{func}" + if info.name == method_key and getattr(info, "is_udt_method", False) ), None, @@ -419,6 +424,21 @@ def direct_user_udt_ctor_name(node: ASTNode) -> str | None: method_info.return_type ) return None + if ( + method_key is not None + and method_key in getattr(self, "_method_signatures", {}) + ): + # A later authored method declaration owns this surface. + # Its body-derived return type is not available yet, but a + # same-named builtin must not lend it a false type. + return None + if ( + typed_receiver_spec is not None + and typed_receiver_spec.kind == "udt" + and typed_receiver_spec.name in self._udt_fields + and func == "copy" + ): + return typed_receiver_spec # Drawing-objects-as-data return typing: *.new / *.copy -> handle of # the self-type; linefill.get_line* -> line; chart.point.* -> point. if ns in _DRAWING_NS: @@ -521,7 +541,7 @@ def direct_user_udt_ctor_name(node: ASTNode) -> str | None: if inner is None: return TypeSpec.array(TypeSpec.primitive("float")) return TypeSpec.array(self._pine_type_to_spec(inner)) - if ns in self._udt_fields and func == "new": + if ns in self._udt_fields and func in {"new", "copy"}: return TypeSpec.udt(ns) if isinstance(cal, MemberAccess): recv_spec = self._type_spec_from_expr(cal.object) @@ -569,6 +589,8 @@ def direct_user_udt_ctor_name(node: ASTNode) -> str | None: ) if return_spec is not None: return return_spec + if method_key in getattr(self, "_method_signatures", {}): + return None # Drawing method-form: a.copy() -> same handle; lf.get_line*() -> line. if (recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name in _DRAWING_TYPE_NAMES): diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 7ea6599..2c238ad 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -308,23 +308,6 @@ def func_var_storage(owner: str, raw_name: str) -> str: self._reset_input_getter_mode: bool = False # Set of var/series member names that belong to user functions (need cloning) self._func_var_members_set: set[str] = set() - # BUG C: function-local names emitted as ``UDT*`` pointer aliases (a UDT - # local initialised from a var/global UDT lvalue, mutated through, AND - # later rebound to a different lvalue). Member access lowers to ``->`` - # and rebinds to ``&(...)``. Reset per function in _emit_func_def is not - # needed: names are function-unique and the value-copy fallback ignores - # entries for inactive functions. - self._udt_ptr_alias_locals: set[str] = set() - # Names of hoisted GLOBAL-scope UDT loop-locals bound from a UDT array - # element (``z = arr.get(i)``) and later field-mutated. Pine array - # elements of a user-defined type are references, so such a local must - # ALIAS the element, not value-copy — the mutation has to write back - # into the array. These are de-hoisted from the class-member value-copy - # to a fresh per-iteration ``UDT& z = arr[i];`` local reference (the same - # form the non-hoisted function-local alias path already emits). Read-only - # get-locals are NOT recorded (no field mutation) and keep value-copy - # semantics. Populated by _register_udt_array_get_ref_locals. - self._udt_array_get_ref_locals: set[str] = set() self._precalc_loop_active: bool = False # Names of ``var`` members that live in a callable scope (not global). # Their exact declaration statements own initialization; they must not @@ -940,7 +923,6 @@ def func_var_storage(owner: str, raw_name: str) -> str: for name, _, _ in ctx.var_members: self._all_member_names.add(self._safe_name(name)) self._register_global_aggregate_member_types() - self._register_udt_array_get_ref_locals() self._uses_map = self._detect_map_usage() self._uses_matrix = self._detect_matrix_usage() # Drawing-objects-as-data: gate all new emission (drawing.hpp include + @@ -1983,47 +1965,6 @@ def _walk_global_scope_with_loopflag(self, stmts, in_loop): if isinstance(case_stmts, list): yield from self._walk_global_scope_with_loopflag(case_stmts, child_in_loop) - def _register_udt_array_get_ref_locals(self) -> None: - """Detect global-scope UDT loop-locals that alias a UDT array element and - are later field-mutated (Pine array elements of a user-defined type are - references — ``z = arr.get(i)`` then ``z.f := v`` MUST write back into - ``arr``). - - A non-``var`` global-scope ``UDT z = arr.get(i)`` (or ``.first`` / - ``.last``) nested inside a for/while loop mis-lowers to a value copy: a - global ``while`` loop hoists ``z`` to a class member whose in-loop init - becomes a value-copy assignment, and a global ``for`` loop keeps ``z`` a - true local but the function-local alias path (``_udt_local_alias_kind``) - no-ops at global scope (``_current_func_body`` is None) — both silently - drop the field mutation. We record exactly this shape so the (possible) - class member is suppressed and the in-loop VarDecl is emitted as a fresh - per-iteration ``UDT& z = arr[i];`` reference instead (the same alias form - the non-hoisted function-local path already produces). Strictly gated: - the RHS must be a UDT-array-element lvalue AND the name must be field- - mutated at global scope AND the declaration must be loop-nested. A - read-only get-local is never recorded, so its value-copy output is - unchanged. Function-local get-locals are excluded (the walker skips - function bodies) — those keep using the existing alias path.""" - pairs = list(self._walk_global_scope_with_loopflag(self.ctx.ast.body, False)) - field_mutated: set[str] = set() - for s, _in_loop in pairs: - if (isinstance(s, Assignment) - and isinstance(s.target, MemberAccess) - and isinstance(s.target.object, Identifier)): - field_mutated.add(s.target.object.name) - for s, in_loop in pairs: - if not isinstance(s, VarDecl) or s.is_var or s.is_varip: - continue - if not in_loop: - continue - if not isinstance(s.value, FuncCall): - continue - if self._is_udt_lvalue(s.value) is None: - continue - if s.name not in field_mutated: - continue - self._udt_array_get_ref_locals.add(s.name) - def _extract_receiver_name(self, call_node) -> str | None: """Extract receiver Identifier name from m.method(...) or matrix.method(m, ...). @@ -3369,7 +3310,11 @@ def target_cs_for_context( return cs_info[1] return None - def actual_args_for(call: FuncCall, params: list[str]) -> list: + def actual_args_for(call: FuncCall, params: list[str], method_info=None) -> list: + if method_info is not None: + return list( + self._bind_typed_method_args(method_info, call).args_by_param + ) if call.kwargs: return _merge_kwargs(call.args, call.kwargs, params, lambda arg: arg) return list(call.args) @@ -3442,7 +3387,9 @@ def owner_lexical_specs(owner: str | None) -> dict[str, TypeSpec | None]: if method_call: args = [ node.callee.object, - *actual_args_for(node, list(fi.node.params[1:])), + *actual_args_for( + node, list(fi.node.params[1:]), method_info=fi + ), ] else: args = actual_args_for(node, list(fi.node.params)) @@ -3517,6 +3464,140 @@ def _inline_history_member(self, kind: str, node: ASTNode, ) return member + @staticmethod + def _allocate_generated_cpp_name(preferred: str, used: set[str]) -> str: + """Reserve one deterministic generated C++ identifier. + + Pine permits identifiers beginning with underscores, so generated + support names cannot rely on a magic prefix being unavailable to the + author. Preserve the historical spelling when it is free and append + a stable suffix only for an actual collision. + """ + candidate = preferred + suffix = 2 + while candidate in used: + candidate = f"{preferred}__pf{suffix}" + suffix += 1 + used.add(candidate) + return candidate + + def _prepare_udt_generated_names(self) -> None: + """Allocate UDT support symbols outside every authored namespace. + + Record/trait/template names live at generated-file scope, while arena + instances live in ``GeneratedStrategy``. This pass runs only after + callable clones, declaration-site flags and inline-history members have + their final names. Keep one complete class-scope inventory so adding a + UDT member cannot make ``_func_safe_name`` change after those prepasses, + then extend the file-scope inventory with the same names because an + unqualified class member, parameter or local can hide a generated + support type. In the ordinary non-colliding case the preferred names + remain byte-for-byte unchanged. + """ + class_used: set[str] = set( + getattr(self, "_runtime_var_init_flag_used_names", ()) + ) + class_used.update(self._all_member_names) + class_used.update( + self._safe_name(name) for name in self._all_bound_names + ) + class_used.update( + self._safe_name(name) + for name, _ptype in self.ctx.global_var_decls + ) + for func_info in self.ctx.func_infos: + node = getattr(func_info, "node", None) + if node is not None: + class_used.update( + self._safe_name(name) + for name in (getattr(node, "params", ()) or ()) + ) + + # These generated members are not all recorded in + # ``_runtime_var_init_flag_used_names``. Reserve them explicitly so + # the inventory remains authoritative even for scripts without a + # runtime scalar ``var`` initializer. + class_used.update( + remapped + for remap in self._func_cs_ta_remap.values() + for remapped in remap.values() + ) + class_used.update( + site.member_name + for index, site in enumerate(self.ctx.ta_call_sites) + if index not in self._dead_ta_indices + ) + class_used.update( + f"_precalc_{site.member_name}" + for index, site in enumerate(self.ctx.ta_call_sites) + if index not in self._dead_ta_indices + and self._ta_site_uses_precalc(site) + ) + class_used.update( + site.member_name + for index, site in enumerate(self.ctx.fixnan_sites) + if index not in self._dead_fixnan_indices + ) + class_used.update( + info["member_name"] for info in self._inline_history_members + ) + class_used.update( + variant["member_name"] + for info in self._security_eval_info + for variants in (info.get("ta_variants") or {}).values() + for variant in variants + ) + for instance in self._fresh_instances: + class_used.add(instance["name"]) + for remap_name in ("ta_remap", "var_remap", "fixnan_remap"): + class_used.update(instance.get(remap_name, {}).values()) + + global_used: set[str] = set(self._udt_defs) + global_used.update(self._enum_defs) + global_used.update( + f"{enum_name}_{member}" + for enum_name, members in self._enum_defs.items() + for member in members + ) + global_used.update( + f"{enum_name}_str_values" + for enum_name, members in self._enum_defs.items() + if self._enum_member_strings.get(enum_name) + and len(self._enum_member_strings[enum_name]) == len(members) + ) + global_used.update(class_used) + + self._udt_arena_template_cpp_name = self._allocate_generated_cpp_name( + "_PFUdtArena", global_used + ) + self._udt_undo_coordinator_cpp_name = self._allocate_generated_cpp_name( + "_PFUdtUndoCoordinator", global_used + ) + self._checkpoint_traits_cpp_name = self._allocate_generated_cpp_name( + "_PFCheckpointTraits", global_used + ) + self._udt_record_cpp_names: dict[str, str] = {} + for type_name in self._udt_defs: + self._udt_record_cpp_names[type_name] = ( + self._allocate_generated_cpp_name( + f"_PFUdtRecord_{type_name}", global_used + ) + ) + + member_used = set(class_used) + self._udt_undo_coordinator_member_name = self._allocate_generated_cpp_name( + "_pf_udt_undo", member_used + ) + self._all_member_names.add(self._udt_undo_coordinator_member_name) + self._udt_arena_member_names: dict[str, str] = {} + for type_name in self._udt_defs: + preferred = f"_pf_udt_{self._safe_name(type_name)}" + allocated = self._allocate_generated_cpp_name( + preferred, member_used + ) + self._udt_arena_member_names[type_name] = allocated + self._all_member_names.add(allocated) + def generate(self) -> str: """Generate C++ source from the AnalyzerContext.""" # Context-sensitive instance pre-pass (needs the naming helpers populated @@ -3526,6 +3607,10 @@ def generate(self) -> str: # context-sensitive callable-state member name is known. self._prepare_runtime_scalar_var_initializers() self._prepare_inline_history_members() + # UDT support names are the final generated-name category. They must + # suffix around every authored/callable identifier already frozen by + # the passes above, never force an earlier callable to rename itself. + self._prepare_udt_generated_names() # Pre-scan for strategy series vars self._prescan_strategy_series() self._security_ohlc_hist_fields_by_sec: dict[int, set[str]] = {} @@ -3546,14 +3631,197 @@ def generate(self) -> str: # 1. Includes self._emit_includes(lines) - # 1b. UDT structs - # Drawing field names per struct are pre-computed in __init__ as - # ``self._udt_omitted_fields`` so visit_expr / visit_stmt can - # consult the same map. Drawing types: label, line, box, table, - # linefill, polyline, chart.point. These have no backtest runtime - # representation in PineForge — see pineforge-codegen issue #10. - for type_name, fields in self._udt_defs.items(): + # 1b. User-defined object handles and backing records. + # + # Pine UDT values are object IDs: ordinary assignment aliases the same + # object, while ``copy()`` allocates one detached outer object and keeps + # nested UDT/map/matrix IDs shallow-shared. Direct array-valued fields + # still use std::vector value storage, so their built-in UDT copy path + # is rejected explicitly until arrays gain handle identity. Emit every + # authored type as a small nullable handle first, then its value record. + # The two-phase order also permits self/nested UDT fields without + # embedding a C++ type recursively by value. + for type_name in self._udt_defs: lines.append(f"struct {type_name} {{") + lines.append(" int32_t __pf_id = -1;") + lines.append("};") + lines.append( + f"inline bool is_na(const {type_name}& _z) " + "{ return _z.__pf_id < 0; }" + ) + lines.append("") + + if self._udt_defs: + lines.extend([ + "template ", + f"struct {self._checkpoint_traits_cpp_name};", + "", + f"class {self._udt_undo_coordinator_cpp_name} {{", + " std::vector> _pf_undo_;", + " uint64_t _pf_generation_ = 0;", + " bool _pf_active_ = false;", + "public:", + " struct Snapshot { uint64_t generation; };", + f" {self._udt_undo_coordinator_cpp_name}() = default;", + f" {self._udt_undo_coordinator_cpp_name}(", + f" const {self._udt_undo_coordinator_cpp_name}&) = delete;", + f" {self._udt_undo_coordinator_cpp_name}& operator=(", + f" const {self._udt_undo_coordinator_cpp_name}&) = delete;", + f" {self._udt_undo_coordinator_cpp_name}(", + f" {self._udt_undo_coordinator_cpp_name}&&) = delete;", + f" {self._udt_undo_coordinator_cpp_name}& operator=(", + f" {self._udt_undo_coordinator_cpp_name}&&) = delete;", + "", + " Snapshot snapshot() {", + " if (_pf_generation_ == std::numeric_limits::max()) {", + ' throw std::overflow_error("UDT checkpoint generation exhausted");', + " }", + " ++_pf_generation_;", + " _pf_undo_.clear();", + " _pf_active_ = true;", + " return Snapshot{_pf_generation_};", + " }", + " uint64_t generation() const { return _pf_generation_; }", + " bool active() const { return _pf_active_; }", + " bool empty() const { return _pf_undo_.empty(); }", + " void record(uint64_t generation, std::function undo) {", + " if (!_pf_active_ || generation != _pf_generation_) {", + ' throw std::runtime_error("invalid UDT undo generation");', + " }", + " _pf_undo_.push_back(std::move(undo));", + " }", + " void restore(const Snapshot& snapshot) {", + " if (!_pf_active_ || snapshot.generation != _pf_generation_) {", + ' throw std::runtime_error("invalid UDT coordinator checkpoint token");', + " }", + " for (auto entry = _pf_undo_.rbegin();", + " entry != _pf_undo_.rend(); ++entry) (*entry)();", + " _pf_undo_.clear();", + " }", + "};", + "", + "template ", + f"class {self._udt_arena_template_cpp_name} {{", + f" using _PFRecordTraits = {self._checkpoint_traits_cpp_name}<_PFRecord>;", + " using _PFRecordSnapshot = typename _PFRecordTraits::snapshot_type;", + " struct _PFSlot {", + " _PFRecord value;", + " uint64_t logged_generation = 0;", + " };", + " std::deque<_PFSlot> _pf_records_;", + f" {self._udt_undo_coordinator_cpp_name}* _pf_coordinator_;", + " std::size_t _pf_checkpoint_size_ = 0;", + " uint64_t _pf_checkpoint_generation_ = 0;", + " bool _pf_checkpoint_active_ = false;", + "", + " void capture(std::size_t index) {", + " if (!_pf_checkpoint_active_) return;", + " auto& slot = _pf_records_.at(index);", + " if (slot.logged_generation", + " == _pf_checkpoint_generation_) return;", + " auto snapshot = _PFRecordTraits::take(slot.value);", + " _pf_coordinator_->record(_pf_checkpoint_generation_,", + " [this, index, snapshot = std::move(snapshot)]() mutable {", + " auto& restore_slot = _pf_records_.at(index);", + " _PFRecordTraits::restore(restore_slot.value, snapshot);", + " restore_slot.logged_generation = 0;", + " });", + " slot.logged_generation = _pf_checkpoint_generation_;", + " }", + "public:", + " struct Snapshot {", + " uint64_t generation;", + " std::size_t size;", + " };", + "", + f" explicit {self._udt_arena_template_cpp_name}(", + f" {self._udt_undo_coordinator_cpp_name}* coordinator)", + " : _pf_coordinator_(coordinator) {", + " if (!_pf_coordinator_)", + ' throw std::invalid_argument("UDT arena requires undo coordinator");', + " }", + f" {self._udt_arena_template_cpp_name}(", + f" const {self._udt_arena_template_cpp_name}&) = delete;", + f" {self._udt_arena_template_cpp_name}& operator=(", + f" const {self._udt_arena_template_cpp_name}&) = delete;", + f" {self._udt_arena_template_cpp_name}(", + f" {self._udt_arena_template_cpp_name}&&) = delete;", + f" {self._udt_arena_template_cpp_name}& operator=(", + f" {self._udt_arena_template_cpp_name}&&) = delete;", + "", + " _PFHandle create(_PFRecord value) {", + " if (_pf_records_.size() > static_cast(", + " std::numeric_limits::max())) {", + ' throw std::length_error("UDT object-ID capacity exceeded");', + " }", + " const auto id = static_cast(_pf_records_.size());", + " _pf_records_.push_back(_PFSlot{std::move(value), 0});", + " return _PFHandle{id};", + " }", + " _PFHandle copy(_PFHandle value) {", + " return create(static_cast(*this).get(value));", + " }", + " _PFRecord& get(_PFHandle value) {", + " if (value.__pf_id < 0", + " || static_cast(value.__pf_id) >= _pf_records_.size()) {", + ' throw std::runtime_error("UDT access on na or invalid object ID");', + " }", + " const auto index = static_cast(value.__pf_id);", + " capture(index);", + " return _pf_records_[index].value;", + " }", + " const _PFRecord& get(_PFHandle value) const {", + " if (value.__pf_id < 0", + " || static_cast(value.__pf_id) >= _pf_records_.size()) {", + ' throw std::runtime_error("UDT access on na or invalid object ID");', + " }", + " return _pf_records_[static_cast(value.__pf_id)].value;", + " }", + " const _PFRecord& read(_PFHandle value) const {", + " return get(value);", + " }", + " std::size_t size() const { return _pf_records_.size(); }", + " _PFRecord& record_at(std::size_t index) {", + " capture(index);", + " return _pf_records_.at(index).value;", + " }", + " const _PFRecord& record_at(std::size_t index) const {", + " return _pf_records_.at(index).value;", + " }", + " Snapshot snapshot() {", + " if (!_pf_coordinator_->active()) {", + ' throw std::runtime_error("UDT coordinator checkpoint is not active");', + " }", + " _pf_checkpoint_generation_ = _pf_coordinator_->generation();", + " _pf_checkpoint_size_ = _pf_records_.size();", + " _pf_checkpoint_active_ = true;", + " return Snapshot{_pf_checkpoint_generation_,", + " _pf_checkpoint_size_};", + " }", + " void restore(const Snapshot& snapshot) {", + " if (!_pf_checkpoint_active_", + " || snapshot.generation != _pf_checkpoint_generation_", + " || snapshot.generation != _pf_coordinator_->generation()", + " || snapshot.size != _pf_checkpoint_size_", + " || _pf_records_.size() < snapshot.size", + " || !_pf_coordinator_->empty()) {", + ' throw std::runtime_error("invalid UDT checkpoint token");', + " }", + " _pf_records_.resize(snapshot.size);", + " }", + "};", + "", + ]) + + # Drawing field names per record are pre-computed in __init__ as + # ``self._udt_omitted_fields`` so visit_expr / visit_stmt can consult + # the same map. Authored field defaults are evaluated by ``Type.new`` + # at the call site; record declarations use only inert typed defaults so + # bar/TA expressions never leak into namespace-scope C++ initializers. + for type_name, fields in self._udt_defs.items(): + record_type = self._udt_record_cpp_type(type_name) + lines.append(f"struct {record_type} {{") field_specs = self._udt_field_type_specs.get(type_name, {}) omitted = self._udt_omitted_fields.get(type_name, set()) for f in fields: @@ -3561,39 +3829,15 @@ def generate(self) -> str: continue spec = field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name) cpp_type = self._type_spec_to_cpp(spec) - # Pine ``int`` is 64-bit (it routinely holds UNIX-ms timestamps - # and large bar indices); emit UDT int fields as ``int64_t`` so a - # field initialised from ``time``/``current_bar_.timestamp`` does - # not truncate / narrow-init. if cpp_type == "int": cpp_type = "int64_t" - if f.default: - default = self._visit_rhs_value( - f.default, - target_cpp_type=( - cpp_type - if (self._is_nullable_collection_cpp_type(cpp_type) - or cpp_type in self._udt_defs - or cpp_type in DRAWING_TYPE_TO_CPP.values()) - else None - ), - ) - else: - default = self._default_for_spec(spec) + default = self._default_for_spec(spec) lines.append(f" {cpp_type} {f.name} = {default};") - # NA sentinel (always the last data member). A default-constructed - # UDT - ``var T x = na``, an array fill slot, ``T.copy()`` no-arg - - # is na; the ``T.new(...)`` lowering sets this false. This lets - # ``na(udtVar)`` lower to the ``is_na(const T&)`` overload below - # instead of failing because no ``is_na`` accepts a struct. - lines.append(f" bool __pf_na = true;") - lines.append(f" static {type_name} create() {{ return {type_name}{{}}; }}") lines.append("};") - lines.append(f"inline bool is_na(const {type_name}& _z) {{ return _z.__pf_na; }}") lines.append("") - if self._uses_map: - self._emit_map_checkpoint_traits(lines) + if self._uses_map or self._uses_matrix or self._udt_defs: + self._emit_handle_checkpoint_traits(lines) # 1c. Enum constants + string tables for str.tostring(enumVar) for enum_name, members in self._enum_defs.items(): @@ -3619,6 +3863,25 @@ def generate(self) -> str: lines.append("class GeneratedStrategy : public BacktestEngine {") lines.append("public:") _script_state_decl_start = len(lines) + + # One strategy-local undo coordinator gives every UDT arena a single + # reverse first-touch order. This is required when records from distinct + # authored UDT types shallow-share the same map or matrix identity. + if self._udt_defs: + lines.append( + f" {self._udt_undo_coordinator_cpp_name} " + f"{self._udt_undo_coordinator_member_name};" + ) + + # Per-type object stores precede every handle-bearing script member. + # COOF rollback therefore replays the coordinator, truncates arenas, + # then rebinds ordinary variables, arrays and matrices in that order. + for type_name in self._udt_defs: + lines.append( + f" {self._udt_arena_cpp_type(type_name)} " + f"{self._udt_arena_member_name(type_name)}" + f"{{&{self._udt_undo_coordinator_member_name}}};" + ) # request.security state for item in self._security_calls: @@ -3835,7 +4098,7 @@ def generate(self) -> str: # not just ``matrix.new``). if name in self._matrix_specs: pass # already registered upstream - elif "matrix.new" in str(init_str): + elif not _is_udt_ctor_init and "matrix.new" in str(init_str): self._matrix_specs[name] = TypeSpec.matrix(TypeSpec.primitive("float")) self._collection_types[name] = self._matrix_specs[name] if name in self._matrix_specs: @@ -3966,12 +4229,6 @@ def generate(self) -> str: or (name in self.ctx.series_vars and not exact_scalar_override) or name in self._var_names): continue - # De-hoisted UDT array-element alias (Pine reference semantics): the - # in-loop VarDecl is emitted as a fresh ``UDT& z = arr[i];`` local - # reference each iteration, so there is no persistent class member. - if name in self._udt_array_get_ref_locals: - seen_global.add(name) - continue seen_global.add(name) safe = self._safe_name(name) diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 5ea7714..ec132de 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -118,9 +118,16 @@ def _emit_includes(self, lines: list[str]) -> None: lines.append("#include ") lines.append("#include ") lines.append("#include ") + if getattr(self, "_udt_defs", {}): + lines.append("#include ") + lines.append("#include ") + lines.append("#include ") lines.append("#include ") lines.append("#include ") lines.append("#include ") + if getattr(self, "_udt_defs", {}): + lines.append("#include ") + lines.append("#include ") lines.append("#include ") lines.append("#include ") lines.append("#include ") @@ -277,17 +284,19 @@ def _collect_script_state_members(self, declaration_lines: list[str]) -> list[st members.append(name) return members - def _emit_map_checkpoint_traits(self, lines: list[str]) -> None: - """Emit recursive rollback adapters for shared-ID PineMap state. + def _emit_handle_checkpoint_traits(self, lines: list[str]) -> None: + """Emit recursive rollback adapters for shared-ID collection state. - This support block is emitted only for map-using scripts. The primary - trait retains the historical value-copy checkpoint for ordinary - runtime state; PineMap, vectors and generated UDTs recursively replace - shared handles with immutable runtime snapshots. + This support block is emitted for scripts using a shared-ID map/matrix + or an authored UDT arena. The primary trait retains the historical + value-copy checkpoint for ordinary runtime state; specialized map, + matrix, vector, UDT-record, and arena traits snapshot their owned state + without recursively following nested UDT handle graphs. """ + checkpoint_traits = self._checkpoint_traits_cpp_type() lines.extend([ "template ", - "struct _PFCheckpointTraits {", + f"struct {checkpoint_traits} {{", " using snapshot_type = _PFValue;", " static snapshot_type take(const _PFValue& value) { return value; }", " static void restore(_PFValue& value, const snapshot_type& snapshot) {", @@ -295,8 +304,30 @@ def _emit_map_checkpoint_traits(self, lines: list[str]) -> None: " }", "};", "", + ]) + + if getattr(self, "_udt_defs", {}): + coordinator_type = self._udt_undo_coordinator_cpp_name + lines.extend([ + "template <>", + f"struct {checkpoint_traits}<{coordinator_type}> {{", + f" using coordinator_type = {coordinator_type};", + " using snapshot_type = typename coordinator_type::Snapshot;", + " static snapshot_type take(coordinator_type& value) {", + " return value.snapshot();", + " }", + " static void restore(coordinator_type& value,", + " const snapshot_type& snapshot) {", + " value.restore(snapshot);", + " }", + "};", + "", + ]) + + if getattr(self, "_uses_map", False): + lines.extend([ "template ", - "struct _PFCheckpointTraits> {", + f"struct {checkpoint_traits}> {{", " using map_type = PineMap<_PFKey, _PFValue>;", " static_assert(map_type::snapshot_supported,", ' "generated map checkpoints require primitive map values");', @@ -314,9 +345,81 @@ def _emit_map_checkpoint_traits(self, lines: list[str]) -> None: " }", "};", "", + ]) + + if getattr(self, "_uses_matrix", False): + lines.extend([ + "template <>", + f"struct {checkpoint_traits} {{", + " using matrix_type = PineMatrix;", + " using snapshot_type = std::optional;", + " static snapshot_type take(const matrix_type& value) {", + " if (value.is_na()) return std::nullopt;", + " return value.snapshot();", + " }", + " static void restore(matrix_type& value, const snapshot_type& snapshot) {", + " if (!snapshot) {", + " value = matrix_type{};", + " return;", + " }", + " value.restore(*snapshot);", + " }", + "};", + "", + ]) + + if self._detect_generic_matrix_usage(): + lines.extend([ + "template ", + f"struct {checkpoint_traits}> {{", + " using matrix_type = PineGenericMatrix<_PFElement>;", + f" using element_traits = {checkpoint_traits}<_PFElement>;", + " using element_snapshot = typename element_traits::snapshot_type;", + " struct active_snapshot {", + " typename matrix_type::Snapshot outer;", + " int rows;", + " int columns;", + " std::vector elements;", + " };", + " using snapshot_type = std::optional;", + " static snapshot_type take(const matrix_type& value) {", + " if (value.is_na()) return std::nullopt;", + " active_snapshot snapshot{value.snapshot(), value.rows(),", + " value.columns(), {}};", + " snapshot.elements.reserve(", + " static_cast(snapshot.rows) *", + " static_cast(snapshot.columns));", + " for (int row = 0; row < snapshot.rows; ++row) {", + " for (int column = 0; column < snapshot.columns; ++column) {", + " snapshot.elements.push_back(", + " element_traits::take(value.get(row, column)));", + " }", + " }", + " return snapshot;", + " }", + " static void restore(matrix_type& value, const snapshot_type& snapshot) {", + " if (!snapshot) {", + " value = matrix_type{};", + " return;", + " }", + " value.restore(snapshot->outer);", + " std::size_t index = 0;", + " for (int row = 0; row < snapshot->rows; ++row) {", + " for (int column = 0; column < snapshot->columns; ++column) {", + " _PFElement element = value.get(row, column);", + " element_traits::restore(element, snapshot->elements[index++]);", + " value.set(row, column, element);", + " }", + " }", + " }", + "};", + "", + ]) + + lines.extend([ "template ", - "struct _PFCheckpointTraits> {", - " using element_traits = _PFCheckpointTraits<_PFElement>;", + f"struct {checkpoint_traits}> {{", + f" using element_traits = {checkpoint_traits}<_PFElement>;", " using element_snapshot = typename element_traits::snapshot_type;", " using snapshot_type = std::vector;", " static snapshot_type take(", @@ -344,73 +447,102 @@ def _emit_map_checkpoint_traits(self, lines: list[str]) -> None: "", ]) - # Pine requires referenced UDT types to be declared before their use, - # so source/declaration order is also a valid dependency order for the - # corresponding checkpoint specializations. + # Snapshot backing records field-by-field. A nested UDT field is only + # a numeric handle, so the primary trait copies its ID without recursing + # into another record. This makes self/cyclic object graphs finite; + # each arena snapshots every owned record exactly once. Shared-ID map + # and matrix fields still enter their specialized recursive traits. for type_name, fields in self._udt_defs.items(): + record_type = self._udt_record_cpp_type(type_name) emitted_fields = [ field for field in fields if field.name not in self._udt_omitted_fields.get(type_name, set()) ] checkpoint_fields = [field.name for field in emitted_fields] - checkpoint_fields.append("__pf_na") lines.append("template <>") - lines.append(f"struct _PFCheckpointTraits<{type_name}> {{") + lines.append(f"struct {checkpoint_traits}<{record_type}> {{") lines.append(" struct snapshot_type {") for index, field_name in enumerate(checkpoint_fields): lines.append( - " _PFCheckpointTraits<" - f"decltype({type_name}::{field_name})>::snapshot_type " + f" {checkpoint_traits}<" + f"decltype({record_type}::{field_name})>::snapshot_type " f"_pf_field_{index};" ) lines.append(" };") lines.append( - f" static snapshot_type take(const {type_name}& value) {{" + f" static snapshot_type take(const {record_type}& value) {{" ) lines.append(" return snapshot_type{") for field_name in checkpoint_fields: lines.append( - " _PFCheckpointTraits<" - f"decltype({type_name}::{field_name})>::take(value.{field_name})," + f" {checkpoint_traits}<" + f"decltype({record_type}::{field_name})>::take(value.{field_name})," ) lines.append(" };") lines.append(" }") lines.append( - f" static void restore({type_name}& value, " + f" static void restore({record_type}& value, " "const snapshot_type& snapshot) {" ) for index, field_name in enumerate(checkpoint_fields): lines.append( - " _PFCheckpointTraits<" - f"decltype({type_name}::{field_name})>::restore(" + f" {checkpoint_traits}<" + f"decltype({record_type}::{field_name})>::restore(" f"value.{field_name}, snapshot._pf_field_{index});" ) lines.append(" }") lines.append("};") lines.append("") + arena_type = self._udt_arena_cpp_type(type_name) + lines.append("template <>") + lines.append(f"struct {checkpoint_traits}<{arena_type}> {{") + lines.append( + f" using arena_type = {arena_type};" + ) + lines.append( + " using snapshot_type = typename arena_type::Snapshot;" + ) + lines.append(" static snapshot_type take(arena_type& value) {") + lines.append(" return value.snapshot();") + lines.append(" }") + lines.append( + " static void restore(arena_type& value, const snapshot_type& snapshot) {" + ) + lines.append(" value.restore(snapshot);") + lines.append(" }") + lines.append("};") + lines.append("") + def _emit_script_state_hooks(self, lines: list[str], members: list[str]) -> None: """Emit the engine's Pine rollback checkpoint hook implementation. - The checkpoint owns value copies of all generated mutable state. Every - runtime container used by generated code (Series, std::vector/map, - PineMatrix/generic matrices, UDTs and drawing arenas) has value - semantics, so copying recursively preserves data without retaining - pointers into live state. Drawing handles themselves are stable ids; - their arenas are captured in the same checkpoint. + The checkpoint owns detached snapshots of all generated mutable state. + Shared-ID maps and matrices retain their original backing identity so + rollback updates every alias. Generated UDT arenas snapshot each owned + record once; nested UDT/map/matrix fields therefore restore through + their numeric/shared IDs without recursively walking object graphs. + Vectors snapshot their elements, including UDT handle values. Drawing + handles themselves are stable ids and their arenas are captured in the + same checkpoint. The static assertions deliberately turn any future non-copyable member into a compile failure instead of a nominal, shallow rollback. Engine broker/order state lives in the base class and is intentionally absent: fills must survive while Pine script variables roll back. """ - map_aware = getattr(self, "_uses_map", False) + checkpoint_traits = self._checkpoint_traits_cpp_type() + handle_aware = bool( + getattr(self, "_uses_map", False) + or getattr(self, "_uses_matrix", False) + or getattr(self, "_udt_defs", {}) + ) lines.append(" struct _PFScriptState {") for idx, name in enumerate(members): - if map_aware: + if handle_aware: lines.append( - " _PFCheckpointTraits<" + f" {checkpoint_traits}<" f"decltype(GeneratedStrategy::{name})>::snapshot_type " f"_pf_value_{idx};" ) @@ -432,9 +564,9 @@ def _emit_script_state_hooks(self, lines: list[str], members: list[str]) -> None lines.append(" void snapshot_script_state() override {") lines.append(" _pf_script_state_checkpoint_.emplace(_PFScriptState{") for name in members: - if map_aware: + if handle_aware: lines.append( - " _PFCheckpointTraits<" + f" {checkpoint_traits}<" f"decltype(GeneratedStrategy::{name})>::take({name})," ) else: @@ -445,9 +577,9 @@ def _emit_script_state_hooks(self, lines: list[str], members: list[str]) -> None lines.append(" void restore_script_state() override {") lines.append(" if (!_pf_script_state_checkpoint_) return;") for idx, name in enumerate(members): - if map_aware: + if handle_aware: lines.append( - " _PFCheckpointTraits<" + f" {checkpoint_traits}<" f"decltype(GeneratedStrategy::{name})>::restore(" f"this->{name}, _pf_script_state_checkpoint_->_pf_value_{idx});" ) @@ -890,10 +1022,20 @@ def _emit_on_bar(self, lines: list[str]) -> None: break continue if name in self._matrix_specs: - # Matrix vars: initialize with matrix.new expression + # Matrix vars are nullable ID handles. Initialize from the + # authored RHS in declaration order, not only from a + # matrix.new(...) call: ``var matrix alias = source`` + # must copy source's ID once, while a later local rebind + # remains independent. for stmt in self.ctx.ast.body: - if isinstance(stmt, VarDecl) and stmt.name == name and isinstance(stmt.value, FuncCall): - cpp_val = self._visit_expr(stmt.value) + if isinstance(stmt, VarDecl) and stmt.name == name: + cpp_val = self._visit_rhs_value( + stmt.value, + name, + target_cpp_type=self._type_spec_to_cpp( + self._matrix_specs[name] + ), + ) lines.append(f" {safe} = {cpp_val};") break continue @@ -912,23 +1054,21 @@ def _emit_on_bar(self, lines: list[str]) -> None: continue if name in self._runtime_scalar_var_init_members: continue - # UDT vars: init with constructor expression - init_s = str(init_expr) - is_udt_init = False - for udt_name in self._udt_defs: - if init_s.startswith(f"{udt_name}.new"): - # Find the actual AST node to generate the init expression - for stmt in self.ctx.ast.body: - if isinstance(stmt, VarDecl) and stmt.name == name and isinstance(stmt.value, FuncCall): - cpp_val = self._visit_expr(stmt.value) - lines.append(f" {safe} = {cpp_val};") - is_udt_init = True - break - if not is_udt_init: - lines.append(f" {safe} = {udt_name}{{}};") - is_udt_init = True - break - if is_udt_init: + # Persistent authored UDT variables initialize from their full + # source RHS, not only from ``Type.new``. In particular, + # ``var Item alias = original`` must copy the numeric handle + # once and therefore alias the same arena record. + udt_name = self._member_udt_type(name) + if udt_name in self._udt_defs: + for stmt in self.ctx.ast.body: + if isinstance(stmt, VarDecl) and stmt.name == name: + cpp_val = self._visit_rhs_value( + stmt.value, + name, + target_cpp_type=udt_name, + ) + lines.append(f" {safe} = {cpp_val};") + break continue if self._binding_is_series(name, safe): cpp_val = self._resolve_known(init_expr) @@ -1299,10 +1439,10 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._safe_name(p) ] = elem_cpp_t elif is_method and i == 0 and fi.udt_type_name: - # Receiver pass modes follow Pine's value/ID families: - # primitives by value; arrays, matrices, UDTs, and drawings by - # reference; maps by copied shared-ID handle so mutations - # reach the caller while receiver rebinds remain local. + # Receiver pass modes follow Pine's value/ID families. A user + # UDT is itself a numeric object-ID handle, so pass it by value: + # field mutation dereferences the same arena record while + # ``self := other`` remains a local handle rebind. recv_spec = receiver_spec if recv_spec is None: recv_spec = self._type_spec_from_hint_name( @@ -1311,7 +1451,13 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No spec = recv_spec if recv_spec is not None: cpp_t = self._type_spec_to_cpp(recv_spec) - if recv_spec.kind in {"array", "matrix", "udt"}: + if ( + recv_spec.kind in {"array", "matrix"} + or ( + recv_spec.kind == "udt" + and recv_spec.name not in self._udt_defs + ) + ): cpp_t = f"{cpp_t}&" if recv_spec.kind == "udt" and recv_spec.name: safe_p = self._safe_name(p) @@ -1323,7 +1469,11 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No recv_cpp = DRAWING_TYPE_TO_CPP.get( fi.udt_type_name, fi.udt_type_name ) - cpp_t = f"{recv_cpp}&" + cpp_t = ( + recv_cpp + if fi.udt_type_name in self._udt_defs + else f"{recv_cpp}&" + ) safe_p = self._safe_name(p) self._udt_param_udt[safe_p] = fi.udt_type_name self._udt_param_udt[p] = fi.udt_type_name @@ -1370,16 +1520,17 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No }[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 - # ``s`` used as a string -> ``std::string``. UDT / collection - # params pass by reference (Pine UDTs/arrays are reference types, - # so mutations propagate and member access compiles). + # ``pivot hi`` -> ``pivot`` numeric handle, ``line ln`` keeps its + # established drawing-handle route, and an untyped ``s`` used as + # a string -> ``std::string``. User UDT field mutation propagates + # through the arena even though the handle parameter is by value. spec = fi.param_type_specs[i] cpp_t = self._type_spec_to_cpp(spec) if spec.kind == "udt": self._udt_param_udt[p] = spec.name self._udt_param_udt[self._safe_name(p)] = spec.name - cpp_t = f"{cpp_t}&" + if spec.name not in self._udt_defs: + cpp_t = f"{cpp_t}&" elif spec.kind in ("array", "map"): elem = spec.element if spec.kind == "array" else spec.value if elem is not None and elem.kind == "udt": @@ -1487,16 +1638,8 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No prev_known_var_tombstones = self._lexical_known_var_tombstones prev_func_body = getattr(self, "_current_func_body", None) prev_func_name = getattr(self, "_active_func_name", None) - # The function body is the lexical scope used by the UDT-alias analysis - # (BUG C): a local initialised from a var/global UDT lvalue and later - # mutated through must alias, not value-copy. self._current_func_body = node.body self._active_func_name = fi.name - # Pointer-aliased UDT locals are function-scoped: a name like ``p_ivot`` - # may be a rebinding pointer alias in one function and a ``pivot&`` - # parameter in another, so reset per function to avoid cross-contamination. - prev_ptr_alias = self._udt_ptr_alias_locals - self._udt_ptr_alias_locals = set() self._current_func_locals = {n for n, _, _ in self.ctx.func_var_members.get(fi.name, [])} self._current_func_local_types = {} self._lexical_drawing_types = { @@ -1629,7 +1772,6 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._lexical_known_var_tombstones = prev_known_var_tombstones self._current_func_body = prev_func_body self._active_func_name = prev_func_name - self._udt_ptr_alias_locals = prev_ptr_alias self._current_func_collection_specs = prev_func_collection_specs self._current_func_collection_shadows = prev_func_collection_shadows self._collection_types = prev_collection_types diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 20e73f1..e057ec4 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -933,46 +933,3 @@ def _merge_kwargs(args: list, kwargs: dict, param_names: list | None, visit_expr while merged and merged[-1] is None: merged.pop() return [visit_expr(a) for a in merged if a is not None] - - -def _merge_kwargs_with_defaults( - args: list, - kwargs: dict, - param_names: list, - param_defaults: list, - visit_expr, -) -> list: - """Like ``_merge_kwargs`` but fills missing slots from ``param_defaults``. - - ``param_defaults`` must be parallel to ``param_names`` (use ``None`` for - parameters without a default). Used by the UDT-method call lowering so - callers may invoke ``cfg.threshold()``, ``cfg.threshold(atrVal)``, or - ``cfg.threshold(mult=2.0, base=rsiVal)`` against ``method threshold(Cfg - self, float mult = 1.0, float base = 0.0) =>`` and have clang see the - full positional argument list. PineScript has no overloading, so every - parameter must be filled at the call site. - - Probe: data/validation/udt-method-probe-04-default-param. - - The result preserves the parameter order from ``param_names`` and - contains ``visit_expr(node)`` for each filled slot. Trailing slots that - have neither a caller-supplied value nor a default are dropped (matches - ``_merge_kwargs`` behaviour for required-only signatures). - """ - n = len(param_names) - slots: list = [None] * n - for i, a in enumerate(args): - if i < n: - slots[i] = a - for k, v in kwargs.items(): - if k in param_names: - slots[param_names.index(k)] = v - # Fill remaining holes from defaults (only where the caller omitted the - # slot AND the parameter has a declared default). - if param_defaults: - for i in range(n): - if slots[i] is None and i < len(param_defaults) and param_defaults[i] is not None: - slots[i] = param_defaults[i] - while slots and slots[-1] is None: - slots.pop() - return [visit_expr(a) for a in slots if a is not None] diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 1cd60f6..02b8b0b 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -154,6 +154,66 @@ def _type_spec_to_cpp(self, spec: TypeSpec | None) -> str: return f"PineGenericMatrix<{elem}>" return "double" + def _udt_record_cpp_type(self, type_name: str) -> str: + """Generated backing-record type for one user-defined object type.""" + return getattr(self, "_udt_record_cpp_names", {}).get( + type_name, f"_PFUdtRecord_{type_name}" + ) + + def _udt_arena_member_name(self, type_name: str) -> str: + """Per-strategy arena member owning one user-defined object's records.""" + return getattr(self, "_udt_arena_member_names", {}).get( + type_name, f"_pf_udt_{self._safe_name(type_name)}" + ) + + def _udt_arena_cpp_type(self, type_name: str) -> str: + """Generated arena specialization for one user-defined object type.""" + return ( + f"{getattr(self, '_udt_arena_template_cpp_name', '_PFUdtArena')}<" + f"{type_name}, " + f"{self._udt_record_cpp_type(type_name)}>" + ) + + def _checkpoint_traits_cpp_type(self) -> str: + """Collision-safe generated checkpoint trait template name.""" + return getattr( + self, "_checkpoint_traits_cpp_name", "_PFCheckpointTraits" + ) + + def _udt_direct_array_fields(self, type_name: str) -> tuple[str, ...]: + """Direct array-valued fields whose UDT copy semantics are unsupported. + + Maps, matrices, and nested UDTs are numeric/shared-ID handles, so an + outer record copy is correctly shallow. Arrays still lower directly to + ``std::vector``; copying such a record would silently deep-copy the + vector and diverge from Pine object identity. Keep this boundary narrow + and explicit until arrays themselves have a handle representation. + """ + return tuple( + field_name + for field_name, spec in self._udt_field_type_specs.get( + type_name, {} + ).items() + if spec is not None and spec.kind == "array" + ) + + def _reject_unsupported_udt_copy(self, type_name: str, node) -> None: + """Fail closed before copying a record with direct vector storage.""" + fields = self._udt_direct_array_fields(type_name) + if not fields: + return + rendered = ", ".join(fields) + self._codegen_error( + node, + f"{type_name}.copy() is unsupported because direct array field(s) " + f"{rendered} still use value storage", + hint=( + "Move the array outside the UDT and store a supported shared-ID " + "map/matrix or nested UDT handle until array fields gain handle " + "identity." + ), + ) + @staticmethod def _is_nullable_collection_cpp_type(cpp_type: str | None) -> bool: """Whether ``cpp_type`` has a default-constructed Pine ``na`` ID. @@ -624,6 +684,13 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: if primitive_name is not None else None ) + if ( + receiver_spec is not None + and receiver_spec.kind == "udt" + and receiver_spec.name in self._udt_defs + and node.callee.member == "copy" + ): + return receiver_spec # ticker.* constructors (inherit/standard/heikinashi) return a symbol # string; without this the member-type inference defaults to double # and a ``haTicker = ticker.heikinashi(...)`` global mis-declares as @@ -715,7 +782,7 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: return TypeSpec.primitive("bool") if func_name == "size": return TypeSpec.primitive("int") - if namespace in self._udt_defs and func_name == "new": + if namespace in self._udt_defs and func_name in {"new", "copy"}: return TypeSpec.udt(namespace) if isinstance(node.callee, MemberAccess): recv_spec = self._type_spec_from_expr(node.callee.object) @@ -1547,176 +1614,6 @@ def _na_reassign_cpp_type(self, name: str) -> str | None: return cpp_type return None - # ------------------------------------------------------------------ - # BUG C: user-defined-UDT lvalue aliasing - # ------------------------------------------------------------------ - - def _is_stable_lvalue_expr(self, expr) -> bool: - """Whether ``expr`` denotes storage that can safely back a C++ ref. - - Checked array access deliberately returns by reference for lvalue - receivers and by value for temporary receivers. The UDT alias pass - must make the same distinction or it can emit ``T&`` bound to the - checked helper's safe rvalue copy. - """ - if isinstance(expr, Identifier): - return True - if isinstance(expr, MemberAccess): - return self._is_stable_lvalue_expr(expr.object) - if isinstance(expr, FuncCall): - # An element selected from a stable array is itself stable. This - # must recurse independently of the element type so nested - # ``array>`` access keeps the inner array lvalue and the - # eventual UDT element can still alias it. Constructors, user - # function returns, matrix.row(), and other temporary producers do - # not enter this checked array-access shape. - func_name, namespace = self._resolve_callee(expr.callee) - receiver = None - if namespace == "array" and func_name in ("get", "first", "last"): - receiver = expr.args[0] if expr.args else expr.kwargs.get("id") - elif (isinstance(expr.callee, MemberAccess) - and func_name in ("get", "first", "last")): - candidate = expr.callee.object - candidate_spec = self._type_spec_from_expr(candidate) - if candidate_spec is not None and candidate_spec.kind == "array": - receiver = candidate - return ( - receiver is not None - and self._is_stable_lvalue_expr(receiver) - ) - if isinstance(expr, Ternary): - return ( - self._is_stable_lvalue_expr(expr.true_val) - and self._is_stable_lvalue_expr(expr.false_val) - ) - return False - - def _is_udt_lvalue(self, expr) -> str | None: - """If ``expr`` is a *user-defined* UDT lvalue (a bare ``Identifier`` that - names a class-scope ``var``/global UDT member, e.g. ``wyckoffSwingLow``, - or an element selected from ``array``), return its UDT type name; - else ``None``. - - Pine UDTs are reference types, so a local initialised from such an lvalue - and then mutated through must write back to the global. Drawing UDTs are - handled by the separate ``_uses_drawing`` path and are excluded here.""" - if isinstance(expr, FuncCall): - callee = expr.callee - func_name, namespace = self._resolve_callee(callee) - receiver = None - if namespace == "array" and func_name in ("get", "first", "last"): - receiver = expr.args[0] if expr.args else expr.kwargs.get("id") - elif (isinstance(callee, MemberAccess) - and func_name in ("get", "first", "last")): - receiver = callee.object - if receiver is not None: - if not self._is_stable_lvalue_expr(receiver): - return None - spec = self._type_spec_from_expr(receiver) - elem = spec.element if spec is not None and spec.kind == "array" else None - if (elem is not None and elem.kind == "udt" and elem.name in self._udt_defs - and elem.name not in DRAWING_TYPE_TO_CPP): - return elem.name - return None - if not isinstance(expr, Identifier): - return None - udt_t = self._identifier_udt_type(expr.name) - if udt_t is None or udt_t not in self._udt_defs: - return None - if udt_t in DRAWING_TYPE_TO_CPP: - return None - # Must be a known global/class-scope member (not a function param or a - # plain local snapshot) for write-through to be observable. - if expr.name in getattr(self, "_current_func_locals", set()): - # A function-local of UDT type that is itself a persistent ``var`` - # member still write-through aliases; but a plain inline local does - # not represent shared state. Only treat ``var`` func-locals (in - # func_var_members) as aliasable shared state. - fname = getattr(self, "_active_func_name", None) - var_locals = {n for n, _, _ in self.ctx.func_var_members.get(fname, [])} if fname else set() - if expr.name not in var_locals: - return None - return udt_t - - def _udt_lvalue_selection_type(self, expr) -> str | None: - """UDT type if ``expr`` is a UDT lvalue OR a ternary/switch whose every - selectable branch is a UDT lvalue of the SAME user-defined UDT type. - Returns ``None`` otherwise (so plain ``UDT a = b`` value-snapshots, calls, - ``.new(...)`` ctors, and mixed/non-lvalue selections never alias).""" - direct = self._is_udt_lvalue(expr) - if direct is not None: - return direct - branches: list = [] - if isinstance(expr, Ternary): - branches = [expr.true_val, expr.false_val] - elif isinstance(expr, SwitchStmt): - for _case_expr, stmts in (expr.cases or []): - if not stmts: - return None - last = stmts[-1] - branches.append(last.expr if isinstance(last, ExprStmt) else last) - if expr.default_body: - last = expr.default_body[-1] - branches.append(last.expr if isinstance(last, ExprStmt) else last) - else: - return None - if not branches: - return None - types = {self._is_udt_lvalue(b) for b in branches} - if len(types) == 1 and None not in types: - return next(iter(types)) - return None - - def _udt_local_alias_kind(self, node: VarDecl) -> tuple[str, str] | None: - """Decide whether a hintless/typed local UDT declaration must ALIAS the - global(s) it selects rather than value-copy (BUG C). - - Returns ``("ref", udt_type)`` for a non-rebinding reference alias, - ``("ptr", udt_type)`` for a pointer alias (the local is later reassigned - to a *different* UDT lvalue, which a C++ reference cannot do), or - ``None`` to keep the existing value-copy semantics. - - Conditions (all required): - * RHS is a UDT lvalue or a ternary/switch selecting same-typed UDT - lvalues (``_udt_lvalue_selection_type``). - * The local is MUTATED later in the enclosing function body - (``local.field := ...``) — a pure read-only snapshot needn't alias. - - The mutation requirement is the safety guard: a local that is only read - keeps value semantics, and a local initialised from a non-lvalue (a - ``.new()`` ctor, a function return, or a plain local copy) returns - ``None`` here, preserving intentional independent-copy semantics.""" - from ..ast_nodes import Assignment - body = getattr(self, "_current_func_body", None) - if body is None: - return None - udt_t = self._udt_lvalue_selection_type(node.value) - if udt_t is None: - return None - name = node.name - mutated = False - rebinds_to_other_lvalue = False - for stmt in self._walk_ast_list(body): - if not isinstance(stmt, Assignment): - continue - tgt = stmt.target - # Mutation through the local: ``p.field := ...`` - if (isinstance(tgt, MemberAccess) - and isinstance(tgt.object, Identifier) - and tgt.object.name == name): - mutated = True - # Rebind of the local itself to another UDT lvalue: ``p := other`` - elif isinstance(tgt, Identifier) and tgt.name == name: - if self._udt_lvalue_selection_type(stmt.value) is not None: - rebinds_to_other_lvalue = True - else: - # Reassigned to a non-lvalue (e.g. ``.new()`` / a copy): - # aliasing would be wrong; bail to value-copy. - return None - if not mutated: - return None - return ("ptr" if rebinds_to_other_lvalue else "ref"), udt_t - # ------------------------------------------------------------------ # BUG 2: collection (array / map / matrix) lvalue aliasing # ------------------------------------------------------------------ @@ -1739,7 +1636,7 @@ def _collection_lvalue_selection_spec(self, expr): whose every selectable branch is a collection lvalue of the SAME C++ type; ``None`` otherwise (so ``array.new(...)`` ctors, copies, function returns, and mixed selections keep value-copy semantics). Mirrors - ``_udt_lvalue_selection_type`` for the BUG-2 collection-alias path.""" + the former UDT alias-selection path.""" direct = self._collection_lvalue_spec(expr) if direct is not None: return direct @@ -1799,22 +1696,6 @@ def _walk_ast_list(self, stmts): for s in stmts: yield from self._walk_ast(s) - def _addr_of_udt_selection(self, expr, local_name: str): - """Render the address-of form of a UDT lvalue selection for a pointer - alias (BUG C rebind case): ``other`` -> ``&(other)``; - ``cond ? a : b`` -> ``(cond ? &(a) : &(b))``. The selectable branches are - guaranteed (by ``_udt_lvalue_selection_type``) to be UDT lvalues.""" - if isinstance(expr, Identifier): - return f"&({self._safe_name(expr.name)})" - if isinstance(expr, Ternary): - cond = self._visit_expr(expr.condition) - t = self._addr_of_udt_selection(expr.true_val, local_name) - f = self._addr_of_udt_selection(expr.false_val, local_name) - return f"({cond} ? {t} : {f})" - # Switch selection: lower to nested ternaries over case equality. Rare in - # practice; fall back to address-of the whole lowered expression. - return f"&({self._visit_expr(expr)})" - def _infer_cpp_type_for_security_elem(self, node) -> str: """C++ type for one element of the ``request.security(..., expr, ...)`` payload. diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 217ea17..a92afe5 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -143,6 +143,11 @@ VarDecl, ) from ..symbols import TypeSpec, method_receiver_type_name +from ..method_binding import ( + MethodBindError, + bind_method_call, + signature_from_callable, +) from .. import signatures as sigs from .drawing import ALL_DRAWING_METHODS from .tables import ( @@ -167,7 +172,6 @@ TIME_FIELD_EXPRS, _math_minmax_na_expr, _merge_kwargs, - _merge_kwargs_with_defaults, tz_time_field_lambda, ) @@ -261,6 +265,50 @@ def _typed_user_method_info(self, receiver, member: str): return receiver_spec, None return receiver_spec, method_info + def _bind_typed_method_args(self, method_info, node: FuncCall): + """Bind typed method arguments through the analyzer-shared rules.""" + + param_names = ( + list(method_info.node.params[1:]) + if method_info.node is not None + else [] + ) + param_defaults = list( + getattr(method_info, "param_defaults", ()) or () + )[1:] + hints = list( + (getattr(method_info.node, "annotations", None) or {}).get( + "param_type_hints", () + ) + if method_info.node is not None + else () + )[1:] + signature = signature_from_callable( + method_info.name, + param_names, + param_defaults, + hints, + ) + try: + return bind_method_call(signature, node) + except MethodBindError as exc: + self._codegen_error(node, str(exc)) + raise AssertionError("unreachable") from exc + + def _emit_udt_new_expr( + self, + type_name: str, + field_initializers: list[str], + ) -> str: + """Allocate one authored UDT record and return its numeric object ID.""" + record_type = self._udt_record_cpp_type(type_name) + arena = self._udt_arena_member_name(type_name) + return f"{arena}.create({record_type}{{{', '.join(field_initializers)}}})" + + def _emit_udt_copy_expr(self, type_name: str, source_cpp: str) -> str: + """Allocate a detached outer record while shallow-copying nested IDs.""" + return f"{self._udt_arena_member_name(type_name)}.copy({source_cpp})" + def _emit_typed_user_method_call( self, node: FuncCall, @@ -273,21 +321,8 @@ def _emit_typed_user_method_call( receiver_node = callee.object fn_cpp = self._udt_method_call_emit_name(method_info, node) - param_names = ( - list(method_info.node.params[1:]) - if method_info.node is not None - else [] - ) - param_defaults = list( - getattr(method_info, "param_defaults", []) or [] - )[1:] - rest_nodes = _merge_kwargs_with_defaults( - node.args, - node.kwargs, - param_names, - param_defaults, - lambda value: value, - ) + binding = self._bind_typed_method_args(method_info, node) + rest_nodes = list(binding.args_by_param) receiver_cpp = self._visit_typed_method_param( method_info, node, @@ -307,19 +342,20 @@ def _emit_typed_user_method_call( receiver_root = receiver_node while isinstance(receiver_root, MemberAccess): receiver_root = receiver_root.object - receiver_passes_by_reference = receiver_spec.kind in { - "array", - "matrix", - "udt", - } + receiver_passes_by_reference = ( + receiver_spec.kind in {"array", "matrix"} + or ( + receiver_spec.kind == "udt" + and receiver_spec.name not in self._udt_defs + ) + ) return self._ordered_user_call_expr( fn_cpp, [receiver_node, *rest_nodes], [receiver_cpp, *rest_cpp], source_order_nodes=[ receiver_node, - *node.args, - *node.kwargs.values(), + *binding.evaluation_order, ], force_stage=( receiver_passes_by_reference @@ -789,11 +825,11 @@ def _map_effect_callable_specs( } is_method = bool(getattr(func_info, "is_udt_method", False)) - positional = ( - [call.callee.object, *call.args] - if is_method and isinstance(call.callee, MemberAccess) - else list(call.args) - ) + if is_method and isinstance(call.callee, MemberAccess): + binding = self._bind_typed_method_args(func_info, call) + positional = [call.callee.object, *binding.args_by_param] + else: + positional = list(call.args) for index, actual in enumerate(positional): if index >= len(params) or result[params[index]] is not None: continue @@ -801,14 +837,14 @@ def _map_effect_callable_specs( actual, caller_specs ) - keyword_offset = 1 if is_method else 0 - for name, actual in call.kwargs.items(): - if name not in params[keyword_offset:]: - continue - if result[name] is None: - result[name] = self._map_effect_type_spec( - actual, caller_specs - ) + if not is_method: + for name, actual in call.kwargs.items(): + if name not in params: + continue + if result[name] is None: + result[name] = self._map_effect_type_spec( + actual, caller_specs + ) # Direct callable locals already carry analyzer-owned lexical # provenance. They override same-named parameters/globals exactly as @@ -979,6 +1015,23 @@ def _visit_func_call(self, node: FuncCall) -> str: method_info, ) recv_spec = self._type_spec_from_expr(callee.object) + if ( + recv_spec is not None + and recv_spec.kind == "udt" + and recv_spec.name in self._udt_defs + and callee.member == "copy" + ): + if node.args or node.kwargs: + self._codegen_error( + node, + f"{recv_spec.name}.copy() expects no arguments", + hint="Call copy() on the UDT object without arguments.", + ) + self._reject_unsupported_udt_copy(recv_spec.name, node) + return self._emit_udt_copy_expr( + recv_spec.name, + self._visit_expr(callee.object), + ) if ( recv_spec is not None and recv_spec.kind == "map" @@ -1011,15 +1064,8 @@ def _visit_func_call(self, node: FuncCall) -> str: while isinstance(receiver_root, MemberAccess): receiver_root = receiver_root.object stage_receiver = not isinstance(receiver_root, Identifier) - param_names = list(fi_u.node.params[1:]) if fi_u.node else [] - # Drop the leading ``self`` slot from param_defaults so the - # parallel array lines up with ``param_names`` (rest of - # the signature). Probe: udt-method-probe-04-default-param. - param_defaults = list(getattr(fi_u, "param_defaults", []) or [])[1:] - rest_nodes = _merge_kwargs_with_defaults( - node.args, node.kwargs, param_names, - param_defaults, lambda x: x, - ) + binding = self._bind_typed_method_args(fi_u, node) + rest_nodes = list(binding.args_by_param) rest = [ self._visit_udt_method_series_arg( fi_u, node, arg, index @@ -1032,8 +1078,7 @@ def _visit_func_call(self, node: FuncCall) -> str: [recv_e, *rest], source_order_nodes=[ callee.object, - *node.args, - *node.kwargs.values(), + *binding.evaluation_order, ], # Generated UDT methods accept ``T&``. A constructor or # function-return receiver is a C++ rvalue; bind it to a @@ -1260,15 +1305,8 @@ def _visit_func_call(self, node: FuncCall) -> str: if fi_u is not None and getattr(fi_u, "is_udt_method", False): fn_cpp = self._udt_method_call_emit_name(fi_u, node) recv_e = self._visit_expr(obj) - param_names = list(fi_u.node.params[1:]) if fi_u.node else [] - # Drop the leading ``self`` slot so param_defaults - # lines up with ``param_names``. Probe: - # udt-method-probe-04-default-param. - param_defaults = list(getattr(fi_u, "param_defaults", []) or [])[1:] - rest_nodes = _merge_kwargs_with_defaults( - node.args, node.kwargs, param_names, - param_defaults, lambda x: x, - ) + binding = self._bind_typed_method_args(fi_u, node) + rest_nodes = list(binding.args_by_param) rest = [ self._visit_udt_method_series_arg( fi_u, node, arg, index @@ -1281,8 +1319,7 @@ def _visit_func_call(self, node: FuncCall) -> str: [recv_e, *rest], source_order_nodes=[ obj, - *node.args, - *node.kwargs.values(), + *binding.evaluation_order, ], ) args = ", ".join(self._visit_expr(a) for a in node.args) @@ -2038,18 +2075,21 @@ def _visit_func_call(self, node: FuncCall) -> str: and val == "na()"): val = f"{f_cpp_type}{{}}" field_inits.append(f".{f.name} = {val}") - # Mark the constructed object non-na (the struct's ``__pf_na`` is the - # last declared field, so this designator stays in declaration order). - # A bare default-constructed UDT keeps ``__pf_na = true`` (na); only a - # real ``.new(...)`` flips it false so ``na(obj)`` reports correctly. - field_inits.append(".__pf_na = false") - return f"{namespace}{{{', '.join(field_inits)}}}" + return self._emit_udt_new_expr(namespace, field_inits) # UDT copy: TypeName.copy(obj) if namespace in self._udt_defs and func_name == "copy": - if node.args: - return self._visit_expr(node.args[0]) - return f"{namespace}{{}}" + if len(node.args) != 1 or node.kwargs: + self._codegen_error( + node, + f"{namespace}.copy(...) expects exactly one object argument", + hint="Prefer the canonical object.copy() form.", + ) + self._reject_unsupported_udt_copy(namespace, node) + return self._emit_udt_copy_expr( + namespace, + self._visit_expr(node.args[0]), + ) # Safety net before the generic emitter. Every builtin namespace and # bare builtin that codegen knows how to emit has been dispatched (and diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index a40946c..17e6374 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -495,6 +495,16 @@ def _ident_is_resolvable(self, name: str) -> bool: return True return False + def _visit_mutable_expr(self, node: ASTNode) -> str: + """Lower an assignment target with UDT journal capture enabled.""" + + previous = getattr(self, "_udt_mutable_expr_depth", 0) + self._udt_mutable_expr_depth = previous + 1 + try: + return self._visit_expr(node) + finally: + self._udt_mutable_expr_depth = previous + def _visit_member_access(self, node: MemberAccess) -> str: # UDT field whose type was a drawing primitive (label/line/box/ # linefill/polyline/table/chart.point) — the field is dropped from @@ -503,6 +513,34 @@ def _visit_member_access(self, node: MemberAccess) -> str: # See: pineforge-codegen issue #10. if self._is_omitted_udt_field(node): return "/* drawing field omitted */ 0" + + # User-defined objects are numeric handles into a per-type arena. The + # arena lookup returns a real record lvalue, so this one lowering serves + # reads, assignment targets, nested field chains and method receivers. + owner_spec = self._type_spec_from_expr(node.object) + if ( + owner_spec is not None + and owner_spec.kind == "udt" + and owner_spec.name in self._udt_defs + ): + owner = self._visit_expr(node.object) + arena = self._udt_arena_member_name(owner_spec.name) + field_spec = ( + self._udt_field_type_specs.get(owner_spec.name, {}).get( + node.member + ) + ) + mutable_collection = ( + field_spec is not None + and field_spec.kind in {"array", "map", "matrix"} + ) + access = ( + "get" + if getattr(self, "_udt_mutable_expr_depth", 0) + or mutable_collection + else "read" + ) + return f"{arena}.{access}({owner}).{node.member}" if isinstance(node.object, Identifier): ns = node.object.name if ns == "strategy": @@ -882,10 +920,6 @@ def _visit_member_access(self, node: MemberAccess) -> str: safe = self._safe_name(name) if self._active_var_remap and safe in self._active_var_remap: safe = self._active_var_remap[safe] - # Pointer-aliased UDT local (BUG C, rebinding case): field access - # goes through ``->`` since the local holds ``UDT*``. - if name in self._udt_ptr_alias_locals: - return f"{safe}->{node.member}" return f"{safe}.{node.member}" if name not in self.ctx.series_vars: # Unknown identifier — likely an enum value diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index e6d75f7..aa6b78b 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -793,26 +793,6 @@ def remember_local_type(cpp_type: str | None) -> None: ) return - # UDT lvalue alias (BUG C): a local initialised from a user-defined-UDT - # var/global lvalue (or a ternary/switch of such lvalues) and then - # mutated through must ALIAS the global, not value-copy — Pine UDTs are - # reference types. Emit a C++ reference (non-rebinding) or pointer - # (rebinding) alias instead of the default copy. - if not is_global_member: - alias = self._udt_local_alias_kind(node) - if alias is not None: - kind, udt_t = alias - if kind == "ref": - cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=udt_t) - lines.append(f"{pad}{udt_t}& {safe} = {cpp_val};") - return - # Pointer alias: take address of each selected lvalue; subsequent - # field access lowers to ``->`` and rebinds to ``&(other)``. - self._udt_ptr_alias_locals.add(node.name) - cpp_val = self._addr_of_udt_selection(node.value, node.name) - lines.append(f"{pad}{udt_t}* {safe} = {cpp_val};") - return - # Collection lvalue alias (BUG 2): a local bound to an existing array / # map / matrix lvalue (or a ternary/switch selecting same-typed ones) # and later MUTATED through must ALIAS the member, not value-copy — Pine @@ -843,27 +823,6 @@ def remember_local_type(cpp_type: str | None) -> None: lines.append(f"{pad}{cpp_type}{ref} {safe} = {cpp_val};") return - # Global-scope UDT array-element alias (BUG: Pine array elements of a - # user-defined type are references). A global loop-local bound from - # ``arr.get(i)`` and later field-mutated must ALIAS the element so the - # mutation writes back into the array. Emit a fresh per-iteration - # ``UDT& z = arr[i];`` reference (any hoisted class member was - # suppressed) — identical to the non-hoisted function-local alias path. - # Gated by _register_udt_array_get_ref_locals (which only records global- - # scope names); the ``_current_func_body is None`` guard confines this to - # global scope, so a function-local sharing the name keeps its own path. - if (node.name in self._udt_array_get_ref_locals - and getattr(self, "_current_func_body", None) is None): - udt_t = self._is_udt_lvalue(node.value) - if udt_t is None: - self._codegen_error( - node, - "UDT array-element alias lost its exact element type.", - ) - cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=udt_t) - lines.append(f"{pad}{udt_t}& {safe} = {cpp_val};") - return - # General declaration cpp_type = self._type_for_decl(node) if not is_global_member else None target_cpp_type = cpp_type or self._nullable_collection_target_cpp_type( @@ -993,7 +952,11 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non # If/switch expression in assignment: x := if cond ... if isinstance(node.value, (IfStmt, SwitchStmt)): - safe = self._safe_name(target_name) if target_name else self._visit_expr(node.target) + safe = ( + self._safe_name(target_name) + if target_name + else self._visit_mutable_expr(node.target) + ) selection_cpp_type = self._nullable_collection_target_cpp_type( name=target_name, target_node=node.target if target_name is None else None, @@ -1036,7 +999,7 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non ) return # General expression target (e.g., member access) - target_cpp = self._visit_expr(node.target) + target_cpp = self._visit_mutable_expr(node.target) target_cpp_type = self._nullable_collection_target_cpp_type( target_node=node.target, ) @@ -1062,12 +1025,6 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non if self._active_var_remap and safe in self._active_var_remap: safe = self._active_var_remap[safe] - # Pointer-aliased UDT local (BUG C, rebinding case): ``p := other`` - # rebinds the pointer to the address of the newly selected UDT lvalue. - if target_name in self._udt_ptr_alias_locals and node.op == ":=": - lines.append(f"{pad}{safe} = {self._addr_of_udt_selection(node.value, target_name)};") - return - if self._binding_is_series(target_name, safe): val_cpp = self._visit_rhs_value( node.value, @@ -1595,30 +1552,6 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int) -> None: self._current_loop_var_specs = saved_loop_specs lines.append(f"{pad}}}") - def _loop_elem_is_writeback_udt(self, iterable) -> bool: - """Whether a ``for x in coll`` loop variable must bind by reference. - - In Pine a ``for x in arr`` loop variable over an array of *user-defined - objects* is a reference to the element — field writes (``x.f := v``) - mutate the array in place — whereas over a primitive array it is a - copy. So emit C++ ``auto&`` only for arrays whose element is a - user-defined UDT struct. Primitive elements keep ``auto`` (Pine copy - semantics: writing the loop var must NOT write back). Drawing handles - (line/box/label/linefill/...) also keep ``auto``: their element type - name is a builtin, not in ``_udt_defs``, and a handle copy already - mutates the shared engine object. (Reassigning the loop var itself — - ``x := ...`` — is not modelled by either form, but Pine forbids it for - objects in practice and it does not occur in the corpus.) - """ - spec = self._type_spec_from_expr(iterable) - return ( - spec is not None - and spec.kind == "array" - and spec.element is not None - and spec.element.kind == "udt" - and spec.element.name in self._udt_defs - ) - def _visit_for_in(self, node, lines: list[str], indent: int) -> None: pad = " " * indent iterable = self._visit_expr(node.iterable) @@ -1654,8 +1587,12 @@ def _visit_for_in(self, node, lines: list[str], indent: int) -> None: ) if node.var: v_cpp = self._safe_name(node.var) - ref = "&" if self._loop_elem_is_writeback_udt(node.iterable) else "" - lines.append(f"{pad}for (auto{ref} {v_cpp} : {iterable}) {{") + # User UDT elements are numeric object-ID handles. Copying the + # loop binding preserves Pine semantics: field writes still reach + # the shared arena record, while rebinding the loop variable does + # not overwrite the array slot. Primitive elements use the same + # value-binding rule. + lines.append(f"{pad}for (auto {v_cpp} : {iterable}) {{") elif map_pair_loop: # Pine map iteration exposes insertion-ordered ``[key, value]`` # pairs. PineMap intentionally keeps its storage private, so take diff --git a/pineforge_codegen/method_binding.py b/pineforge_codegen/method_binding.py new file mode 100644 index 0000000..f4356eb --- /dev/null +++ b/pineforge_codegen/method_binding.py @@ -0,0 +1,163 @@ +"""Strict, source-order-preserving binding for typed Pine methods. + +Typed extension methods are inventoried before semantic analysis so a call may +precede its declaration without falling through to a same-named builtin. This +module deliberately contains no analyzer or codegen state: both phases consume +the same signature and binding result. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .ast_nodes import ASTNode, FuncCall, MethodDef, Program + + +@dataclass(frozen=True) +class MethodSignature: + key: str + param_names: tuple[str, ...] + param_defaults: tuple[ASTNode | None, ...] + param_type_hints: tuple[str | None, ...] + declaration: MethodDef | None = None + + +@dataclass(frozen=True) +class BoundMethodArgs: + """Non-receiver arguments in parameter and evaluation order.""" + + args_by_param: tuple[ASTNode, ...] + evaluation_order: tuple[ASTNode, ...] + + +class MethodBindError(ValueError): + pass + + +def signature_from_method_def(node: MethodDef) -> MethodSignature: + annotations = node.annotations or {} + defaults = list(annotations.get("param_defaults", ())) + hints = list(annotations.get("param_type_hints", ())) + while len(defaults) < len(node.params): + defaults.append(None) + while len(hints) < len(node.params): + hints.append(None) + return MethodSignature( + key=f"{node.type_name}.{node.name}", + param_names=tuple(node.params[1:]), + param_defaults=tuple(defaults[1:len(node.params)]), + param_type_hints=tuple(hints[1:len(node.params)]), + declaration=node, + ) + + +def signature_from_callable( + key: str, + param_names: list[str] | tuple[str, ...], + param_defaults: list[ASTNode | None] | tuple[ASTNode | None, ...], + param_type_hints: list[str | None] | tuple[str | None, ...] = (), +) -> MethodSignature: + """Build a non-receiver signature from an analyzed method FuncInfo.""" + + names = tuple(param_names) + defaults = list(param_defaults) + hints = list(param_type_hints) + while len(defaults) < len(names): + defaults.append(None) + while len(hints) < len(names): + hints.append(None) + return MethodSignature( + key=key, + param_names=names, + param_defaults=tuple(defaults[:len(names)]), + param_type_hints=tuple(hints[:len(names)]), + ) + + +def inventory_method_signatures(program: Program) -> dict[str, MethodSignature]: + """Inventory direct method declarations without analyzing their bodies.""" + + result: dict[str, MethodSignature] = {} + for stmt in program.body: + if isinstance(stmt, MethodDef): + signature = signature_from_method_def(stmt) + # Preserve the analyzer's historical first-declaration lookup. This + # prepass does not introduce method overloading or duplicate policy. + result.setdefault(signature.key, signature) + return result + + +def _written_actuals(call: FuncCall) -> list[ASTNode]: + """Recover written order lost by the parser's args/kwargs split.""" + + fallback = [*call.args, *call.kwargs.values()] + recorded = (call.annotations or {}).get("call_arg_order", ()) + if ( + len(recorded) == len(fallback) + and {id(node) for node in recorded} == {id(node) for node in fallback} + ): + return list(recorded) + if not fallback or any(getattr(node, "loc", None) is None for node in fallback): + return fallback + indexed = list(enumerate(fallback)) + indexed.sort( + key=lambda item: ( + item[1].loc.line, + item[1].loc.col, + item[0], + ) + ) + return [node for _index, node in indexed] + + +def bind_method_call( + signature: MethodSignature, + call: FuncCall, +) -> BoundMethodArgs: + """Bind one method call strictly, excluding the receiver argument.""" + + names = signature.param_names + if len(call.args) > len(names): + raise MethodBindError( + f"{signature.key}: too many positional arguments " + f"(expected {len(names)}, got {len(call.args)})" + ) + + for name in call.kwargs: + if name not in names: + raise MethodBindError( + f"{signature.key}: unknown keyword argument '{name}'" + ) + + bound: list[ASTNode | None] = [None] * len(names) + for index, value in enumerate(call.args): + bound[index] = value + for name, value in call.kwargs.items(): + index = names.index(name) + if bound[index] is not None: + raise MethodBindError( + f"{signature.key}: argument '{name}' passed both " + "positionally and by keyword" + ) + bound[index] = value + + inserted_defaults: list[ASTNode] = [] + for index, name in enumerate(names): + if bound[index] is not None: + continue + default = ( + signature.param_defaults[index] + if index < len(signature.param_defaults) + else None + ) + if default is None: + raise MethodBindError( + f"{signature.key}: missing required argument '{name}'" + ) + bound[index] = default + inserted_defaults.append(default) + + return BoundMethodArgs( + args_by_param=tuple(value for value in bound if value is not None), + evaluation_order=tuple([*_written_actuals(call), *inserted_defaults]), + ) diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index 327d3b6..45158b6 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -405,10 +405,13 @@ def _parse_strategy_decl(self) -> StrategyDecl: start_tok = self._advance() # consume 'strategy' or 'indicator' # Parse arguments as a function call, then convert to StrategyDecl self._consume(TokenType.LPAREN) - args, kwargs = self._parse_call_args() + args, kwargs, call_arg_order = self._parse_call_args() self._consume(TokenType.RPAREN) node = StrategyDecl(args=args, kwargs=kwargs) - node.annotations = {"decl_kind": start_tok.value} + node.annotations = { + "decl_kind": start_tok.value, + "call_arg_order": call_arg_order, + } return self._set_loc(node, start_tok) def _parse_import_stmt(self) -> ImportStmt: @@ -1225,15 +1228,17 @@ def _parse_call_with_callee(self, callee) -> FuncCall: """Parse (args, kwargs) after callee expression.""" start_tok = self._current() self._consume(TokenType.LPAREN) - args, kwargs = self._parse_call_args() + args, kwargs, call_arg_order = self._parse_call_args() self._consume(TokenType.RPAREN) node = FuncCall(callee=callee, args=args, kwargs=kwargs) + node.annotations = {"call_arg_order": call_arg_order} return self._set_loc(node, start_tok) - def _parse_call_args(self) -> tuple[list, dict]: + def _parse_call_args(self) -> tuple[list, dict, list]: """Parse function call arguments and keyword arguments.""" args: list = [] kwargs: dict = {} + call_arg_order: list = [] while not self._check(TokenType.RPAREN) and not self._at_end(): # Detect kwargs: IDENT = value (but not IDENT == value) @@ -1262,12 +1267,15 @@ def _parse_call_args(self) -> tuple[list, dict]: self._advance() # consume = val = self._parse_expression() kwargs[key_tok.value] = val + call_arg_order.append(val) else: - args.append(self._parse_expression()) + val = self._parse_expression() + args.append(val) + call_arg_order.append(val) self._match(TokenType.COMMA) - return args, kwargs + return args, kwargs, call_arg_order # -- Primary expressions -- diff --git a/tests/golden/matrix_eigen_pca.cpp b/tests/golden/matrix_eigen_pca.cpp index 4641b81..3cb2bcc 100644 --- a/tests/golden/matrix_eigen_pca.cpp +++ b/tests/golden/matrix_eigen_pca.cpp @@ -95,6 +95,60 @@ static inline std::string _pf_derive_country(const std::string& tickerid) { } // --- end syminfo derivation helpers --- +template +struct _PFCheckpointTraits { + using snapshot_type = _PFValue; + static snapshot_type take(const _PFValue& value) { return value; } + static void restore(_PFValue& value, const snapshot_type& snapshot) { + value = snapshot; + } +}; + +template <> +struct _PFCheckpointTraits { + using matrix_type = PineMatrix; + using snapshot_type = std::optional; + static snapshot_type take(const matrix_type& value) { + if (value.is_na()) return std::nullopt; + return value.snapshot(); + } + static void restore(matrix_type& value, const snapshot_type& snapshot) { + if (!snapshot) { + value = matrix_type{}; + return; + } + value.restore(*snapshot); + } +}; + +template +struct _PFCheckpointTraits> { + using element_traits = _PFCheckpointTraits<_PFElement>; + using element_snapshot = typename element_traits::snapshot_type; + using snapshot_type = std::vector; + static snapshot_type take( + const std::vector<_PFElement, _PFAllocator>& value) { + snapshot_type snapshot; + snapshot.reserve(value.size()); + for (std::size_t index = 0; index < value.size(); ++index) { + const _PFElement element = value[index]; + snapshot.push_back(element_traits::take(element)); + } + return snapshot; + } + static void restore( + std::vector<_PFElement, _PFAllocator>& value, + const snapshot_type& snapshot) { + value.clear(); + value.reserve(snapshot.size()); + for (const auto& element_snapshot_value : snapshot) { + _PFElement element{}; + element_traits::restore(element, element_snapshot_value); + value.push_back(element); + } + } +}; + class GeneratedStrategy : public BacktestEngine { public: ta::SMA _ta_sma_1; @@ -124,30 +178,30 @@ class GeneratedStrategy : public BacktestEngine { bool _inputs_initialized_ = false; struct _PFScriptState { - decltype(GeneratedStrategy::_ta_sma_1) _pf_value_0; - decltype(GeneratedStrategy::_ta_sma_2) _pf_value_1; - decltype(GeneratedStrategy::_ta_sma_3) _pf_value_2; - decltype(GeneratedStrategy::_ta_sma_4) _pf_value_3; - decltype(GeneratedStrategy::_ta_sma_5) _pf_value_4; - decltype(GeneratedStrategy::_ta_sma_6) _pf_value_5; - decltype(GeneratedStrategy::_ta_crossover_7) _pf_value_6; - decltype(GeneratedStrategy::_ta_crossunder_8) _pf_value_7; - decltype(GeneratedStrategy::m) _pf_value_8; - decltype(GeneratedStrategy::length) _pf_value_9; - decltype(GeneratedStrategy::v1) _pf_value_10; - decltype(GeneratedStrategy::v2) _pf_value_11; - decltype(GeneratedStrategy::v1_mean) _pf_value_12; - decltype(GeneratedStrategy::v2_mean) _pf_value_13; - decltype(GeneratedStrategy::cov11) _pf_value_14; - decltype(GeneratedStrategy::cov12) _pf_value_15; - decltype(GeneratedStrategy::cov21) _pf_value_16; - decltype(GeneratedStrategy::cov22) _pf_value_17; - decltype(GeneratedStrategy::covReady) _pf_value_18; - decltype(GeneratedStrategy::lam) _pf_value_19; - decltype(GeneratedStrategy::lamSma) _pf_value_20; - decltype(GeneratedStrategy::_var_initialized) _pf_value_21; - decltype(GeneratedStrategy::_ta_initialized_) _pf_value_22; - decltype(GeneratedStrategy::_inputs_initialized_) _pf_value_23; + _PFCheckpointTraits::snapshot_type _pf_value_0; + _PFCheckpointTraits::snapshot_type _pf_value_1; + _PFCheckpointTraits::snapshot_type _pf_value_2; + _PFCheckpointTraits::snapshot_type _pf_value_3; + _PFCheckpointTraits::snapshot_type _pf_value_4; + _PFCheckpointTraits::snapshot_type _pf_value_5; + _PFCheckpointTraits::snapshot_type _pf_value_6; + _PFCheckpointTraits::snapshot_type _pf_value_7; + _PFCheckpointTraits::snapshot_type _pf_value_8; + _PFCheckpointTraits::snapshot_type _pf_value_9; + _PFCheckpointTraits::snapshot_type _pf_value_10; + _PFCheckpointTraits::snapshot_type _pf_value_11; + _PFCheckpointTraits::snapshot_type _pf_value_12; + _PFCheckpointTraits::snapshot_type _pf_value_13; + _PFCheckpointTraits::snapshot_type _pf_value_14; + _PFCheckpointTraits::snapshot_type _pf_value_15; + _PFCheckpointTraits::snapshot_type _pf_value_16; + _PFCheckpointTraits::snapshot_type _pf_value_17; + _PFCheckpointTraits::snapshot_type _pf_value_18; + _PFCheckpointTraits::snapshot_type _pf_value_19; + _PFCheckpointTraits::snapshot_type _pf_value_20; + _PFCheckpointTraits::snapshot_type _pf_value_21; + _PFCheckpointTraits::snapshot_type _pf_value_22; + _PFCheckpointTraits::snapshot_type _pf_value_23; }; static_assert(std::is_copy_constructible_v<_PFScriptState>, "generated Pine state must be deep-copy constructible"); static_assert(std::is_copy_assignable_v<_PFScriptState>, "generated Pine state must be deep-copy assignable"); @@ -155,59 +209,59 @@ class GeneratedStrategy : public BacktestEngine { void snapshot_script_state() override { _pf_script_state_checkpoint_.emplace(_PFScriptState{ - _ta_sma_1, - _ta_sma_2, - _ta_sma_3, - _ta_sma_4, - _ta_sma_5, - _ta_sma_6, - _ta_crossover_7, - _ta_crossunder_8, - m, - length, - v1, - v2, - v1_mean, - v2_mean, - cov11, - cov12, - cov21, - cov22, - covReady, - lam, - lamSma, - _var_initialized, - _ta_initialized_, - _inputs_initialized_, + _PFCheckpointTraits::take(_ta_sma_1), + _PFCheckpointTraits::take(_ta_sma_2), + _PFCheckpointTraits::take(_ta_sma_3), + _PFCheckpointTraits::take(_ta_sma_4), + _PFCheckpointTraits::take(_ta_sma_5), + _PFCheckpointTraits::take(_ta_sma_6), + _PFCheckpointTraits::take(_ta_crossover_7), + _PFCheckpointTraits::take(_ta_crossunder_8), + _PFCheckpointTraits::take(m), + _PFCheckpointTraits::take(length), + _PFCheckpointTraits::take(v1), + _PFCheckpointTraits::take(v2), + _PFCheckpointTraits::take(v1_mean), + _PFCheckpointTraits::take(v2_mean), + _PFCheckpointTraits::take(cov11), + _PFCheckpointTraits::take(cov12), + _PFCheckpointTraits::take(cov21), + _PFCheckpointTraits::take(cov22), + _PFCheckpointTraits::take(covReady), + _PFCheckpointTraits::take(lam), + _PFCheckpointTraits::take(lamSma), + _PFCheckpointTraits::take(_var_initialized), + _PFCheckpointTraits::take(_ta_initialized_), + _PFCheckpointTraits::take(_inputs_initialized_), }); } void restore_script_state() override { if (!_pf_script_state_checkpoint_) return; - this->_ta_sma_1 = _pf_script_state_checkpoint_->_pf_value_0; - this->_ta_sma_2 = _pf_script_state_checkpoint_->_pf_value_1; - this->_ta_sma_3 = _pf_script_state_checkpoint_->_pf_value_2; - this->_ta_sma_4 = _pf_script_state_checkpoint_->_pf_value_3; - this->_ta_sma_5 = _pf_script_state_checkpoint_->_pf_value_4; - this->_ta_sma_6 = _pf_script_state_checkpoint_->_pf_value_5; - this->_ta_crossover_7 = _pf_script_state_checkpoint_->_pf_value_6; - this->_ta_crossunder_8 = _pf_script_state_checkpoint_->_pf_value_7; - this->m = _pf_script_state_checkpoint_->_pf_value_8; - this->length = _pf_script_state_checkpoint_->_pf_value_9; - this->v1 = _pf_script_state_checkpoint_->_pf_value_10; - this->v2 = _pf_script_state_checkpoint_->_pf_value_11; - this->v1_mean = _pf_script_state_checkpoint_->_pf_value_12; - this->v2_mean = _pf_script_state_checkpoint_->_pf_value_13; - this->cov11 = _pf_script_state_checkpoint_->_pf_value_14; - this->cov12 = _pf_script_state_checkpoint_->_pf_value_15; - this->cov21 = _pf_script_state_checkpoint_->_pf_value_16; - this->cov22 = _pf_script_state_checkpoint_->_pf_value_17; - this->covReady = _pf_script_state_checkpoint_->_pf_value_18; - this->lam = _pf_script_state_checkpoint_->_pf_value_19; - this->lamSma = _pf_script_state_checkpoint_->_pf_value_20; - this->_var_initialized = _pf_script_state_checkpoint_->_pf_value_21; - this->_ta_initialized_ = _pf_script_state_checkpoint_->_pf_value_22; - this->_inputs_initialized_ = _pf_script_state_checkpoint_->_pf_value_23; + _PFCheckpointTraits::restore(this->_ta_sma_1, _pf_script_state_checkpoint_->_pf_value_0); + _PFCheckpointTraits::restore(this->_ta_sma_2, _pf_script_state_checkpoint_->_pf_value_1); + _PFCheckpointTraits::restore(this->_ta_sma_3, _pf_script_state_checkpoint_->_pf_value_2); + _PFCheckpointTraits::restore(this->_ta_sma_4, _pf_script_state_checkpoint_->_pf_value_3); + _PFCheckpointTraits::restore(this->_ta_sma_5, _pf_script_state_checkpoint_->_pf_value_4); + _PFCheckpointTraits::restore(this->_ta_sma_6, _pf_script_state_checkpoint_->_pf_value_5); + _PFCheckpointTraits::restore(this->_ta_crossover_7, _pf_script_state_checkpoint_->_pf_value_6); + _PFCheckpointTraits::restore(this->_ta_crossunder_8, _pf_script_state_checkpoint_->_pf_value_7); + _PFCheckpointTraits::restore(this->m, _pf_script_state_checkpoint_->_pf_value_8); + _PFCheckpointTraits::restore(this->length, _pf_script_state_checkpoint_->_pf_value_9); + _PFCheckpointTraits::restore(this->v1, _pf_script_state_checkpoint_->_pf_value_10); + _PFCheckpointTraits::restore(this->v2, _pf_script_state_checkpoint_->_pf_value_11); + _PFCheckpointTraits::restore(this->v1_mean, _pf_script_state_checkpoint_->_pf_value_12); + _PFCheckpointTraits::restore(this->v2_mean, _pf_script_state_checkpoint_->_pf_value_13); + _PFCheckpointTraits::restore(this->cov11, _pf_script_state_checkpoint_->_pf_value_14); + _PFCheckpointTraits::restore(this->cov12, _pf_script_state_checkpoint_->_pf_value_15); + _PFCheckpointTraits::restore(this->cov21, _pf_script_state_checkpoint_->_pf_value_16); + _PFCheckpointTraits::restore(this->cov22, _pf_script_state_checkpoint_->_pf_value_17); + _PFCheckpointTraits::restore(this->covReady, _pf_script_state_checkpoint_->_pf_value_18); + _PFCheckpointTraits::restore(this->lam, _pf_script_state_checkpoint_->_pf_value_19); + _PFCheckpointTraits::restore(this->lamSma, _pf_script_state_checkpoint_->_pf_value_20); + _PFCheckpointTraits::restore(this->_var_initialized, _pf_script_state_checkpoint_->_pf_value_21); + _PFCheckpointTraits::restore(this->_ta_initialized_, _pf_script_state_checkpoint_->_pf_value_22); + _PFCheckpointTraits::restore(this->_inputs_initialized_, _pf_script_state_checkpoint_->_pf_value_23); } void commit_script_state() override { diff --git a/tests/test_array_checked_access.py b/tests/test_array_checked_access.py index d105502..b843ca3 100644 --- a/tests/test_array_checked_access.py +++ b/tests/test_array_checked_access.py @@ -206,8 +206,9 @@ def test_checked_udt_lvalue_access_preserves_alias(access: str): " array.push(pivots, Pivot.new(na))\n" "update(0)" ) - assert re.search(r"Pivot& p = .*pine_runtime_error", cpp) - assert "Pivot p =" not in cpp + assert re.search(r"Pivot p = .*pine_runtime_error", cpp) + assert "Pivot& p =" not in cpp + assert "_pf_udt_Pivot.get(p).level = current_bar_.close;" in cpp _VALID_NEGATIVE_SOURCE = """//@version=6 @@ -713,7 +714,8 @@ def test_nested_array_udt_access_keeps_write_through_alias(): observed = array.get(array.get(outer, 0), 0).level ''' cpp = transpile(source) - assert re.search(r"Pivot& p = .*pine_runtime_error", cpp) + assert re.search(r"Pivot p = .*pine_runtime_error", cpp) + assert "Pivot& p =" not in cpp driver = r""" #include int main() { @@ -924,7 +926,8 @@ def test_array_typed_udf_parameter_methods_route_by_typespec(): assert raw_call not in cpp assert "source.push_back(10);" in cpp assert "source.push_back(true);" in cpp - assert re.search(r"Pivot& p = .*pine_runtime_error", cpp) + assert re.search(r"Pivot p = .*pine_runtime_error", cpp) + assert "Pivot& p =" not in cpp # The checked set wrapper evaluates index then value exactly once. Its # nested-call spelling closes in reverse textual order, locking execution diff --git a/tests/test_array_terminal_returns.py b/tests/test_array_terminal_returns.py index 79e1b13..26506b3 100644 --- a/tests/test_array_terminal_returns.py +++ b/tests/test_array_terminal_returns.py @@ -216,7 +216,7 @@ def test_terminal_get_representation_and_order_matrix( compile_cpp(cpp) -def test_reference_like_array_elements_are_not_value_return_refined(): +def test_udt_array_element_return_preserves_handle_type(): source = r'''//@version=6 strategy("Terminal UDT array get remains identity-gated") type Item @@ -227,8 +227,9 @@ def test_reference_like_array_elements_are_not_value_return_refined(): observed = pick() ''' cpp = transpile(source) - assert "Item pick(" not in cpp - assert "double pick(" in cpp + assert "Item pick(" in cpp + assert "double pick(" not in cpp + compile_cpp(cpp) def test_scalar_array_binding_is_not_misclassified_as_builtin_namespace(): @@ -426,7 +427,7 @@ def test_arbitrary_temporary_array_receivers_remain_fail_closed(terminal: str): assert "double blocked(" in cpp -def test_nested_method_copy_reference_element_remains_fail_closed(): +def test_nested_method_copy_udt_element_preserves_handle_type(): source = r'''//@version=6 strategy("Nested method copy reference boundary") type Item @@ -436,8 +437,9 @@ def test_nested_method_copy_reference_element_remains_fail_closed(): observed = blocked() ''' cpp = transpile(source) - assert "Item blocked(" not in cpp - assert "double blocked(" in cpp + assert "Item blocked(" in cpp + assert "double blocked(" not in cpp + compile_cpp(cpp) def test_nested_method_copy_temporary_reader_recursion_is_rejected(): diff --git a/tests/test_calc_on_order_fills_codegen.py b/tests/test_calc_on_order_fills_codegen.py index 4c23adf..a9c2188 100644 --- a/tests/test_calc_on_order_fills_codegen.py +++ b/tests/test_calc_on_order_fills_codegen.py @@ -253,7 +253,10 @@ def _checkpoint_fields(cpp: str) -> dict[str, int]: return { name: int(index) for name, index in re.findall( - r"decltype\(GeneratedStrategy::(\w+)\) _pf_value_(\d+);", cpp + r"(?:_PFCheckpointTraits<)?" + r"decltype\(GeneratedStrategy::(\w+)\)" + r"(?:>::snapshot_type)? _pf_value_(\d+);", + cpp, ) } diff --git a/tests/test_callable_series_param_types.py b/tests/test_callable_series_param_types.py index 4e67b3b..884a9e4 100644 --- a/tests/test_callable_series_param_types.py +++ b/tests/test_callable_series_param_types.py @@ -82,11 +82,11 @@ def test_callable_history_parameters_keep_their_pine_scalar_family() -> None: 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, " + "int64_t _udt_Holder_intHistory_cs0(Holder self, " "const Series& src)" ) in cpp assert ( - "int64_t _udt_Holder_intOuter_cs0(Holder& self, " + "int64_t _udt_Holder_intOuter_cs0(Holder self, " "const Series& src)" ) in cpp assert "bool boolHistory_cs0(const Series& src)" in cpp @@ -140,11 +140,11 @@ def test_nested_int_series_widens_into_float_history_natively() -> None: 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, " + "double _udt_Holder_floatHistory_cs0(Holder self, " "const Series& src)" ) in cpp assert ( - "double _udt_Holder_intOuter_cs0(Holder& self, " + "double _udt_Holder_intOuter_cs0(Holder self, " "const Series& src)" ) in cpp assert "([&]() -> const Series&" in cpp @@ -356,8 +356,8 @@ def test_mutable_local_wide_history_survives_udf_and_method_wrappers() -> None: 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 "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" ) diff --git a/tests/test_codegen_matrix_typed.py b/tests/test_codegen_matrix_typed.py index dc35626..48abb8d 100644 --- a/tests/test_codegen_matrix_typed.py +++ b/tests/test_codegen_matrix_typed.py @@ -98,6 +98,7 @@ def test_member_access_matrix_copy_returns_same_spec(): # --------------------------------------------------------------------------- from pineforge_codegen import transpile +from tests import _compile as compile_env def _emit(src: str) -> str: @@ -145,6 +146,25 @@ def test_matrix_new_udt_uses_brace_init(): assert "PineGenericMatrix::new_(1, 1, Pt{})" in cpp +def test_udt_constructor_wrapping_matrix_new_keeps_udt_member_type(): + src = '''//@version=6 +strategy("t") +type Holder + matrix nested +var Holder holder = Holder.new(matrix.new(1, 1, 7)) +observed = holder.nested.get(0, 0) +''' + cpp = _emit(src) + + assert " Holder holder;" in cpp + assert " PineMatrix holder;" not in cpp + assert ( + "_pf_udt_Holder.create(_PFUdtRecord_Holder{" + ".nested = PineGenericMatrix::new_(1, 1, 7)})" + ) in cpp + compile_env.compile_cpp(cpp, label="udt-constructor-wrapping-matrix-new") + + # --------------------------------------------------------------------------- # Task 2.9: numeric-only gate # --------------------------------------------------------------------------- diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index 7ce75bf..33132b3 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -1098,9 +1098,9 @@ def test_tuple_return_infers_local_string_from_nested_decl(): assert "std::string" in cpp -def test_udt_param_emits_struct_reference(): - # jevon's f_get(... pivot hi, pivot lo): the param must emit as ``pivot&`` - # (not ``double``) so member access + reference semantics work. +def test_udt_param_emits_object_handle_value(): + # A user UDT parameter is a numeric object-ID handle. Passing it by value + # preserves field mutation through the arena while local rebind stays local. cpp = _cpp( "type pivot\n" " float level\n" @@ -1111,7 +1111,8 @@ def test_udt_param_emits_struct_reference(): "_z = f(p)\n" "plot(close)" ) - assert "f(pivot& lo)" in cpp + assert "f(pivot lo)" in cpp + assert "f(pivot& lo)" not in cpp assert "f(double lo)" not in cpp @@ -1298,7 +1299,7 @@ def test_function_scoped_var_udt_init_runs_once(): assert "if (!this->_pf_var_init_p)" in cpp assert "if (!_fvinit_f_cs0" not in cpp # The UDT constructor expression is lowered inside the guarded init block. - assert "p = pivot{" in cpp or "p = pivot(" in cpp + assert "p = _pf_udt_pivot.create(_PFUdtRecord_pivot{" in cpp def test_function_scoped_var_na_drawing_handle_skips_assignment(): @@ -1381,8 +1382,11 @@ def test_string_concat_preserves_udt_for_in_field_string_type(): " label.new(bar_index, lvl.price, \"hit \" + lvl.name)\n" "plot(close)" ) - assert "std::to_string(lvl.name)" not in cpp - assert 'std::string("hit ") + lvl.name' in cpp + assert "std::to_string(_pf_udt_Level.read(lvl).name)" not in cpp + assert ( + 'std::string("hit ") + _pf_udt_Level.read(lvl).name' + in cpp + ) def test_for_loop_with_explicit_by_infers_descending_direction(): diff --git a/tests/test_collection_scope_isolation.py b/tests/test_collection_scope_isolation.py index e930c46..56aad9b 100644 --- a/tests/test_collection_scope_isolation.py +++ b/tests/test_collection_scope_isolation.py @@ -835,7 +835,7 @@ def test_temporal_outer_alias_avoids_user_name_and_routes_subscript() -> None: def test_unique_local_collection_output_hash_is_stable() -> None: cpp = transpile(_IDENTITY_SOURCE) - assert len(cpp) == 12085 + assert len(cpp) == 12603 assert sha256(cpp.encode()).hexdigest() == ( - "fc0f8008f7f3dac180602891982fb2d28425b3d60ca527185345e0630efa6c7e" + "a062c63aa73d3c1ac72d90d34160ecbe416e1068f4a8643cc6d8a4d583585d0e" ) diff --git a/tests/test_map_call_diagnostics.py b/tests/test_map_call_diagnostics.py index dcfdeb8..6e22aed 100644 --- a/tests/test_map_call_diagnostics.py +++ b/tests/test_map_call_diagnostics.py @@ -209,7 +209,7 @@ def test_security_timeframe_clone_keeps_visible_map_receiver_source_order(): ), ( _NESTED_LEXICAL_MAP_ROOT_SOURCE, - "e1d53d2fce372fdb7007fd4c4b88d72b77fcef924282f6483a426874ef6093ca", + "bd4dd2ecd32a1eb751a49a3e1048e9eddb20a3c287df0b2480af872ef52f15b7", 7.0, ), ( diff --git a/tests/test_matrix_identity_checkpoint.py b/tests/test_matrix_identity_checkpoint.py new file mode 100644 index 0000000..6667a31 --- /dev/null +++ b/tests/test_matrix_identity_checkpoint.py @@ -0,0 +1,94 @@ +"""Shared matrix-ID lowering and calc-on-order-fills rollback regressions.""" + +from __future__ import annotations + +from pineforge_codegen import transpile +from tests import _compile as compile_env +from tests.test_pinemap_semantics import _compile_and_run + + +_SOURCE = r'''//@version=6 +strategy("Matrix identity checkpoint", calc_on_order_fills=true) +type Holder + matrix grid +var matrix root = matrix.new(1, 1, 1.0) +var matrix alias = root +var matrix independent = root.copy() +var matrix ints = matrix.new(1, 1, 2) +var matrix intsAlias = ints +var Holder holder = Holder.new(ints) +var matrix holders = matrix.new(1, 1, Holder.new(ints)) +''' + + +def test_matrix_only_scripts_emit_recursive_identity_checkpoints() -> None: + cpp = transpile(_SOURCE) + + assert "struct _PFCheckpointTraits" in cpp + assert "std::optional" in cpp + assert "struct _PFCheckpointTraits>" in cpp + assert "typename matrix_type::Snapshot outer;" in cpp + assert "element_traits::take(value.get(row, column))" in cpp + assert "element_traits::restore(element, snapshot->elements[index++])" in cpp + assert "struct _PFCheckpointTraits<_PFUdtRecord_Holder>" in cpp + assert ( + "struct _PFCheckpointTraits<" + "_PFUdtArena>" in cpp + ) + assert "_PFCheckpointTraits::take(root)" in cpp + assert "this->root, _pf_script_state_checkpoint_" in cpp + assert "alias = root;" in cpp + assert "intsAlias = ints;" in cpp + + +def test_matrix_identity_checkpoint_source_compiles() -> None: + compile_env.compile_cpp( + transpile(_SOURCE), label="matrix-identity-checkpoint" + ) + + +def test_matrix_identity_checkpoint_restores_alias_graph() -> None: + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy.root.get(0, 0) != 1.0) return 3; + if (strategy.alias.get(0, 0) != 1.0) return 4; + if (strategy.ints.get(0, 0) != 2) return 5; + + strategy.snapshot_script_state(); + + strategy.alias.set(0, 0, 9.0); + strategy.root = PineMatrix::new_(1, 1, 7.0); + strategy.intsAlias.set(0, 0, 8); + strategy.ints = PineGenericMatrix::new_(1, 1, 6); + strategy._pf_udt_Holder.get(strategy.holder).grid = + PineGenericMatrix::new_(1, 1, 5); + Holder nested = strategy.holders.get(0, 0); + strategy._pf_udt_Holder.get(nested).grid.set(0, 0, 4); + + strategy.restore_script_state(); + + if (strategy.root.get(0, 0) != 1.0) return 6; + if (strategy.alias.get(0, 0) != 1.0) return 7; + if (strategy.ints.get(0, 0) != 2) return 8; + if (strategy.intsAlias.get(0, 0) != 2) return 9; + if (strategy._pf_udt_Holder.get(strategy.holder).grid.get(0, 0) != 2) return 10; + if (strategy._pf_udt_Holder.get(strategy.holders.get(0, 0)).grid.get(0, 0) != 2) return 11; + + strategy.root.set(0, 0, 3.0); + if (strategy.alias.get(0, 0) != 3.0) return 12; + strategy.ints.set(0, 0, 13); + if (strategy.intsAlias.get(0, 0) != 13) return 13; + if (strategy.independent.get(0, 0) != 1.0) return 14; + + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_SOURCE) + driver, + label="matrix-identity-checkpoint-runtime", + ) == "ok\n" diff --git a/tests/test_method_written_callsite_lifecycle.py b/tests/test_method_written_callsite_lifecycle.py index d05b65e..d0ce004 100644 --- a/tests/test_method_written_callsite_lifecycle.py +++ b/tests/test_method_written_callsite_lifecycle.py @@ -135,8 +135,8 @@ def test_wrapper_udt_parameter_shadows_same_named_global_for_callsite_state() -> """ cpp = transpile(source) - assert "double wrapped_cs0(B& obj)" in cpp - assert "double wrapped_cs1(B& obj)" in cpp + assert "double wrapped_cs0(B obj)" in cpp + assert "double wrapped_cs1(B obj)" in cpp assert "return _udt_B_sample_cs0(obj);" in cpp assert "return _udt_B_sample_cs1(obj);" in cpp assert _compile_and_run(cpp + _driver(["first", "second"])) == ( @@ -188,11 +188,11 @@ def test_method_var_ta_history_and_fixnan_clone_together() -> None: assert "double _prev_fixnan_1 = na();" in cpp assert "double _prev_fixnan_1_cs1 = na();" in cpp assert ( - "double _udt_Holder_sample_cs0(Holder& self, " + "double _udt_Holder_sample_cs0(Holder self, " "const Series& src)" in cpp ) assert ( - "double _udt_Holder_sample_cs1(Holder& self, " + "double _udt_Holder_sample_cs1(Holder self, " "const Series& src)" in cpp ) assert "first = _udt_Holder_sample_cs0(holder, _s_close);" in cpp @@ -227,11 +227,11 @@ def test_method_series_requirement_propagates_through_udf_wrapper_chain() -> Non for name in ("inner_cs0", "inner_cs1", "outer_cs0", "outer_cs1"): assert f"double {name}(const Series& src, bool active)" in cpp assert ( - "double _udt_Holder_sample_cs0(Holder& self, " + "double _udt_Holder_sample_cs0(Holder self, " "const Series& src, bool active)" in cpp ) assert ( - "double _udt_Holder_sample_cs1(Holder& self, " + "double _udt_Holder_sample_cs1(Holder self, " "const Series& src, bool active)" in cpp ) assert _compile_and_run( @@ -333,7 +333,7 @@ def test_history_series_requirement_flows_from_udf_into_calling_method() -> None ''' cpp = transpile(source) assert ( - "double _udt_Holder_sample_cs0(Holder& self, " + "double _udt_Holder_sample_cs0(Holder self, " "const Series& src)" in cpp ) assert "return history_cs0(src);" in cpp @@ -357,7 +357,7 @@ def test_history_series_requirement_flows_between_sibling_methods() -> None: cpp = transpile(source) for index in (0, 1): assert ( - f"double _udt_Holder_outer_cs{index}(Holder& self, " + f"double _udt_Holder_outer_cs{index}(Holder self, " "const Series& src)" in cpp ) assert f"_udt_Holder_inner_cs{index}(self, src)" in cpp @@ -435,7 +435,7 @@ def test_method_to_ta_udf_keeps_scalar_parameter_control() -> None: second = holder.sample(open) ''' cpp = transpile(source) - assert "_udt_Holder_sample_cs0(Holder& self, double src)" in cpp + assert "_udt_Holder_sample_cs0(Holder self, double src)" in cpp assert _compile_and_run( cpp + _driver(["first", "second"], split_ohlc=True) ) == "30.0 2.5\n" diff --git a/tests/test_pinemap_boundaries_and_order.py b/tests/test_pinemap_boundaries_and_order.py index 4dec8cb..7b72677 100644 --- a/tests/test_pinemap_boundaries_and_order.py +++ b/tests/test_pinemap_boundaries_and_order.py @@ -123,6 +123,7 @@ def test_valid_declared_primitive_maps_remain_supported() -> None: var inferred_target = map.new() var transitive_target = map.new() var method_target = map.new() +var method_named_target = map.new() var Holder holder = Holder.new(0) direct_seen = observe(direct_target.put("first", 1), direct_target.put("second", 2)) named_seen = observe(second=named_target.put("second", 2), first=named_target.put("first", 1)) @@ -130,6 +131,7 @@ def test_valid_declared_primitive_maps_remain_supported() -> None: inferred_seen = observe(inferred_mutate(inferred_target, "first", 1), inferred_mutate(inferred_target, "second", 2)) transitive_seen = observe(relay(transitive_target, "first", 1), relay(transitive_target, "second", 2)) method_seen = holder.observe_method(method_target.put("first", 1), method_target.put("second", 2)) +method_named_seen = holder.observe_method(second=method_named_target.put("second", 2), first=method_named_target.put("first", 1)) ''' @@ -163,6 +165,7 @@ def test_user_and_udt_calls_stage_map_effects_in_pine_source_order() -> None: "inferred_seen", "transitive_seen", "method_seen", + "method_named_seen", ): assignment = next( line @@ -187,6 +190,7 @@ def test_user_and_udt_calls_stage_map_effects_in_pine_source_order() -> None: if (strategy.inferred_target.keys() != forward) return 6; if (strategy.transitive_target.keys() != forward) return 7; if (strategy.method_target.keys() != forward) return 8; + if (strategy.method_named_target.keys() != named) return 9; std::cout << "ok\n"; } ''' @@ -208,7 +212,7 @@ def test_non_map_user_call_remains_exact_baseline_bytes() -> None: ) -def test_temporary_udt_method_receiver_is_staged_once_and_compiles() -> None: +def test_temporary_udt_handle_receiver_is_evaluated_once_and_compiles() -> None: cpp = transpile(_TEMPORARY_UDT_RECEIVER_SOURCE) direct = next( line @@ -220,9 +224,10 @@ def test_temporary_udt_method_receiver_is_staged_once_and_compiles() -> None: for line in cpp.splitlines() if line.strip().startswith("ordered_previous =") ) - assert "_udt_H_get(__pf_call_arg_" in direct - assert "_udt_H_get(H{" not in direct - assert "_udt_H_apply(__pf_call_arg_" in ordered + assert "_udt_H_get(_pf_udt_H.create(_PFUdtRecord_H{" in direct + assert direct.count("_pf_udt_H.create(") == 1 + assert "_udt_H_apply(_pf_udt_H.create(_PFUdtRecord_H{" in ordered + assert ordered.count("_pf_udt_H.create(") == 1 assert ordered.count("receiver_map(order, root)") == 1 assert ordered.count("next_value(order)") == 1 compile_env.compile_cpp(cpp, label="pinemap-temporary-udt-receiver") diff --git a/tests/test_pinemap_semantics.py b/tests/test_pinemap_semantics.py index 8779d9e..00ce3e2 100644 --- a/tests/test_pinemap_semantics.py +++ b/tests/test_pinemap_semantics.py @@ -367,9 +367,13 @@ def test_pinemap_emission_uses_handle_identity_and_recursive_checkpoints() -> No assert "PineMap data" in checkpoint assert "_PFCheckpointTraits>" in checkpoint assert "std::optional" in checkpoint - assert "struct _PFCheckpointTraits" in checkpoint - assert "decltype(Holder::__pf_na)" in checkpoint - assert "struct _PFCheckpointTraits" in checkpoint + assert "struct _PFCheckpointTraits<_PFUdtRecord_Holder>" in checkpoint + assert "decltype(_PFUdtRecord_Holder::data)" in checkpoint + assert "struct _PFCheckpointTraits<_PFUdtRecord_Nested>" in checkpoint + assert ( + "struct _PFCheckpointTraits<" + "_PFUdtArena>" in checkpoint + ) assert "_PFCheckpointTraits>" in checkpoint @@ -744,16 +748,23 @@ def test_coof_recursive_snapshot_restore_runtime() -> None: strategy.root.put("after", 2); strategy.alias = PineMap::new_(); strategy.alias.put("wrong", 9); - strategy.holder.data = PineMap::new_(); - strategy.nested.inner.data = PineMap::new_(); - strategy.holders[0].data = PineMap::new_(); - strategy.holders.push_back(Holder{PineMap::new_(), false, false}); + strategy._pf_udt_Holder.get(strategy.holder).data = + PineMap::new_(); + Nested nested_record = strategy.nested; + Holder nested_inner = strategy._pf_udt_Nested.get(nested_record).inner; + strategy._pf_udt_Holder.get(nested_inner).data = + PineMap::new_(); + strategy._pf_udt_Holder.get(strategy.holders[0]).data = + PineMap::new_(); + _PFUdtRecord_Holder extra_holder{}; + extra_holder.data = PineMap::new_(); + extra_holder.active = false; + strategy.holders.push_back(strategy._pf_udt_Holder.create(extra_holder)); strategy.flags[0] = false; strategy.flags.push_back(true); - strategy.holder.active = false; - strategy.holder.__pf_na = true; - strategy.nested.__pf_na = true; - strategy.nested.inner.__pf_na = true; + strategy._pf_udt_Holder.get(strategy.holder).active = false; + strategy.holder = Holder{}; + strategy.nested = Nested{}; strategy.independent.put("copy_after", 3); strategy.nullable = PineMap::new_(); strategy.nullable.put("not_null", 4); @@ -762,21 +773,22 @@ def test_coof_recursive_snapshot_restore_runtime() -> None: if (strategy.root.contains("after")) return 5; if (strategy.root.get("seed") != 1) return 6; if (strategy.alias.get("seed") != 1) return 7; - if (strategy.holder.data.get("seed") != 1) return 8; - if (strategy.nested.inner.data.get("seed") != 1) return 9; + if (strategy._pf_udt_Holder.get(strategy.holder).data.get("seed") != 1) return 8; + Holder restored_inner = strategy._pf_udt_Nested.get(strategy.nested).inner; + if (strategy._pf_udt_Holder.get(restored_inner).data.get("seed") != 1) return 9; if (strategy.holders.size() != 1) return 10; - if (strategy.holders[0].data.get("seed") != 1) return 11; - if (!strategy.holder.active || strategy.holder.__pf_na) return 12; - if (strategy.nested.__pf_na || strategy.nested.inner.__pf_na) return 13; + if (strategy._pf_udt_Holder.get(strategy.holders[0]).data.get("seed") != 1) return 11; + if (!strategy._pf_udt_Holder.get(strategy.holder).active || is_na(strategy.holder)) return 12; + if (is_na(strategy.nested) || is_na(restored_inner)) return 13; if (strategy.independent.contains("copy_after")) return 14; if (!strategy.nullable.is_na()) return 15; if (strategy.flags.size() != 2 || !strategy.flags[0] || strategy.flags[1]) return 23; - strategy.holder.data.put("shared", 5); + strategy._pf_udt_Holder.get(strategy.holder).data.put("shared", 5); if (strategy.root.get("shared") != 5) return 16; if (strategy.alias.get("shared") != 5) return 17; - if (strategy.nested.inner.data.get("shared") != 5) return 18; - if (strategy.holders[0].data.get("shared") != 5) return 19; + if (strategy._pf_udt_Holder.get(restored_inner).data.get("shared") != 5) return 18; + if (strategy._pf_udt_Holder.get(strategy.holders[0]).data.get("shared") != 5) return 19; strategy.restore_script_state(); if (strategy.root.contains("shared")) return 20; @@ -792,9 +804,9 @@ def test_coof_recursive_snapshot_restore_runtime() -> None: def test_contextual_map_na_forms_emit_typed_handles_and_compile() -> None: cpp = transpile(_CONTEXTUAL_NA_SOURCE) - assert "PineMap defaulted = PineMap{};" in cpp + assert "PineMap defaulted = PineMap();" in cpp assert "global_bare = PineMap{};" in cpp - assert "holder.defaulted = PineMap{};" in cpp + assert "_pf_udt_H.get(holder).defaulted = PineMap{};" in cpp assert not [ line for line in cpp.splitlines() @@ -815,12 +827,14 @@ def test_contextual_map_na_forms_runtime() -> None: if (strategy.global_right.is_na() || !strategy.global_if_left.is_na()) return 4; if (strategy.global_if_right.is_na() || !strategy.persistent_na.is_na()) return 5; if (!strategy.local_ok) return 6; - if (!strategy.holder.defaulted.is_na() - || !strategy.holder.ternary_field.is_na()) return 7; - if (strategy.holder.if_field.is_na()) return 8; - if (!strategy.explicit_na.defaulted.is_na()) return 9; - if (strategy.explicit_na.ternary_field.is_na() - || strategy.explicit_na.if_field.is_na()) return 10; + const auto& holder = strategy._pf_udt_H.get(strategy.holder); + const auto& explicit_na = strategy._pf_udt_H.get(strategy.explicit_na); + if (!holder.defaulted.is_na() + || !holder.ternary_field.is_na()) return 7; + if (holder.if_field.is_na()) return 8; + if (!explicit_na.defaulted.is_na()) return 9; + if (explicit_na.ternary_field.is_na() + || explicit_na.if_field.is_na()) return 10; std::cout << "ok\n"; } ''' @@ -833,7 +847,7 @@ def test_contextual_map_na_forms_runtime() -> None: def test_map_return_specs_reach_lhs_and_arbitrary_receivers_compile() -> None: cpp = transpile(_MAP_RETURN_PROPAGATION_SOURCE) map_type = "PineMap" - assert f"{map_type} _udt_H_get(H& h)" in cpp + assert f"{map_type} _udt_H_get(H h)" in cpp assert f"{map_type} choose(bool cond" in cpp assert f"{map_type} choose_if(bool cond" in cpp for name in ( diff --git a/tests/test_runtime_var_initialization.py b/tests/test_runtime_var_initialization.py index dd1226e..142e2d7 100644 --- a/tests/test_runtime_var_initialization.py +++ b/tests/test_runtime_var_initialization.py @@ -251,7 +251,8 @@ def test_runtime_scalar_route_does_not_duplicate_specialized_var_initializers(): "lookup = PineMap::new_();" ) == 1 assert init_block.count( - "point = Point{.x = current_bar_.low, .__pf_na = false};" + "point = _pf_udt_Point.create(" + "_PFUdtRecord_Point{.x = current_bar_.low});" ) == 1 assert "marker =" not in init_block diff --git a/tests/test_udt_drawing_field_cleanup.py b/tests/test_udt_drawing_field_cleanup.py index 9a4f993..df9b182 100644 --- a/tests/test_udt_drawing_field_cleanup.py +++ b/tests/test_udt_drawing_field_cleanup.py @@ -27,9 +27,9 @@ def test_udt_label_field_is_real_handle(): # The struct now declares a real Label handle member. assert "Label tag" in cpp # The field write lowers onto the arena, NOT a dropped placeholder. - assert "m.tag = pf_label_new(_pf_labels_," in cpp + assert "_pf_udt_Marker.get(m).tag = pf_label_new(_pf_labels_," in cpp # The field read is a plain handle copy (real member access survives). - assert "m.tag" in cpp + assert "_pf_udt_Marker.get(m).tag" in cpp def test_udt_line_field_assignment_is_real(): @@ -46,7 +46,7 @@ def test_udt_line_field_assignment_is_real(): ''' cpp = transpile(src) assert "Line ln" in cpp - assert "s.ln = pf_line_new(_pf_lines_," in cpp + assert "_pf_udt_Segment.get(s).ln = pf_line_new(_pf_lines_," in cpp def test_udt_box_field_read_is_real(): @@ -64,7 +64,7 @@ def test_udt_box_field_read_is_real(): cpp = transpile(src) assert "Box bx" in cpp # The field read survives as a real handle member access. - assert "r.bx" in cpp + assert "_pf_udt_Region.read(r).bx" in cpp def test_udt_without_drawing_fields_unchanged(): @@ -85,7 +85,7 @@ def test_udt_without_drawing_fields_unchanged(): assert "double y" in cpp or "float y" in cpp # Direct field access must still work — at least one reference to p.x # survives in the executable body (the plot of v depends on it). - assert "p.x" in cpp - assert "p.y" in cpp + assert "_pf_udt_Pt.read(p).x" in cpp + assert "_pf_udt_Pt.read(p).y" in cpp # No drawing machinery for a non-drawing strategy. assert "pineforge/drawing.hpp" not in cpp diff --git a/tests/test_udt_lvalue_alias.py b/tests/test_udt_lvalue_alias.py index 1166d73..32b6051 100644 --- a/tests/test_udt_lvalue_alias.py +++ b/tests/test_udt_lvalue_alias.py @@ -1,9 +1,10 @@ -"""Regression (BUG C): UDT lvalue init must ALIAS, not value-copy. +"""Regression (BUG C): ordinary UDT assignment copies an object-ID handle. Pine UDTs are reference types. A local initialised from a var/global UDT lvalue (or a ternary/switch selecting such lvalues) and then mutated through must write -back to the global. Codegen previously emitted a value copy, so the mutation was -lost. The fix emits a C++ reference (non-rebinding) or pointer (rebinding) alias. +back to the global. Generated UDT values are now numeric handles into per-type +arenas, so ordinary C++ value assignment aliases the record while local rebind +remains independent. No selective C++ reference/pointer heuristic is needed. Also covers the related crash unmasked by the alias fix: a drawing-style visual constant (``label.style_*``) passed positionally into a user function's @@ -19,7 +20,11 @@ def _func_body(cpp: str, name: str) -> str: - m = re.search(rf"{re.escape(name)}\w*\(.*?\n \}}", cpp, re.S) + m = re.search( + rf"^ [^\n]*\b{re.escape(name)}\w*\([^;\n]*\) \{{.*?^ \}}", + cpp, + re.S | re.M, + ) assert m is not None, f"function {name} not found" return m.group(0) @@ -34,7 +39,7 @@ def _func_body(cpp: str, name: str) -> str: """ -def test_ternary_of_var_udts_mutated_emits_reference_alias(): +def test_ternary_of_var_udts_mutated_emits_handle_alias(): src = PROLOGUE + """ upd(bool internal) => pivot p = internal ? gLow : gHigh @@ -47,15 +52,14 @@ def test_ternary_of_var_udts_mutated_emits_reference_alias(): """ cpp = transpile(src) body = _func_body(cpp, "upd") - # Reference alias (non-rebinding), not a value copy. - assert re.search(r"pivot& p = \(\(internal\) \? \(gLow\) : \(gHigh\)\);", body) - assert "pivot p = ((internal)" not in body - # Mutations write through the reference with ``.`` member access. - assert "p.currentLevel = " in body - assert "p.crossed = " in body + assert re.search(r"pivot p = \(\(internal\) \? \(gLow\) : \(gHigh\)\);", body) + assert "pivot& p" not in body + assert "pivot* p" not in body + assert "_pf_udt_pivot.get(p).currentLevel = " in body + assert "_pf_udt_pivot.get(p).crossed = " in body -def test_rebound_udt_local_emits_pointer_alias(): +def test_rebound_udt_local_rebinds_handle_only(): src = PROLOGUE + """ disp(bool internal) => pivot p = internal ? gHigh : gLow @@ -69,16 +73,15 @@ def test_rebound_udt_local_emits_pointer_alias(): """ cpp = transpile(src) body = _func_body(cpp, "disp") - # Pointer alias because the local is rebound to a different lvalue. - assert re.search(r"pivot\* p = \(internal \? &\(gHigh\) : &\(gLow\)\);", body) - # Field access goes through ``->`` and the rebind takes the address. - assert "p->crossed = true;" in body - assert re.search(r"p = \(internal \? &\(gLow\) : &\(gHigh\)\);", body) + assert re.search(r"pivot p = \(\(internal\) \? \(gHigh\) : \(gLow\)\);", body) + assert "pivot& p" not in body + assert "pivot* p" not in body + assert "_pf_udt_pivot.get(p).crossed = true;" in body + assert re.search(r"p = \(\(internal\) \? \(gLow\) : \(gHigh\)\);", body) def test_value_snapshot_not_aliased(): - # CAUTION control: a local copied from a .new() ctor (not a var/global - # lvalue) and mutated must stay a VALUE copy, never an alias. + # CAUTION control: .new() must allocate a fresh record ID. src = PROLOGUE + """ mk() => pivot p = pivot.new(1.0, false) @@ -91,12 +94,12 @@ def test_value_snapshot_not_aliased(): body = _func_body(cpp, "mk") assert "pivot& p" not in body assert "pivot* p" not in body - assert "pivot p = " in body + assert "pivot p = _pf_udt_pivot.create(_PFUdtRecord_pivot{" in body + assert "_pf_udt_pivot.get(p).currentLevel = 2.0;" in body def test_readonly_var_udt_local_not_aliased(): - # A local read but never mutated needn't alias (value-copy is fine and - # avoids surprising reference semantics for pure reads). + # A read-only local uses the same ordinary handle-copy representation. src = PROLOGUE + """ rd(bool internal) => pivot p = internal ? gLow : gHigh @@ -108,9 +111,11 @@ def test_readonly_var_udt_local_not_aliased(): body = _func_body(cpp, "rd") assert "pivot& p" not in body assert "pivot* p" not in body + assert "pivot p = " in body + assert "_pf_udt_pivot.read(p).currentLevel" in body -def test_array_get_udt_local_mutation_emits_reference_alias(): +def test_array_get_udt_local_mutation_emits_handle_alias(): src = PROLOGUE + """ var array pivots = array.new() upd(int i) => @@ -126,15 +131,16 @@ def test_array_get_udt_local_mutation_emits_reference_alias(): """ cpp = transpile(src) body = _func_body(cpp, "upd") - assert "pivot& p =" in body + assert "pivot p =" in body assert "pine_runtime_error" in body assert "}((i)); }((pivots));" in body - assert "pivot p =" not in body - assert "p.currentLevel = " in body - assert "p.crossed = true;" in body + assert "pivot& p" not in body + assert "pivot* p" not in body + assert "_pf_udt_pivot.get(p).currentLevel = " in body + assert "_pf_udt_pivot.get(p).crossed = true;" in body -def test_array_param_get_udt_local_mutation_emits_reference_alias(): +def test_array_param_get_udt_local_mutation_emits_handle_alias(): src = PROLOGUE + """ var array pivots = array.new() upd(array items, int i) => @@ -150,11 +156,12 @@ def test_array_param_get_udt_local_mutation_emits_reference_alias(): """ cpp = transpile(src) body = _func_body(cpp, "upd") - assert "pivot& p =" in body + assert "pivot p =" in body assert "}((i)); }((items));" in body - assert "pivot p =" not in body - assert "p.currentLevel = " in body - assert "p.crossed = true;" in body + assert "pivot& p" not in body + assert "pivot* p" not in body + assert "_pf_udt_pivot.get(p).currentLevel = " in body + assert "_pf_udt_pivot.get(p).crossed = true;" in body _GLOBAL_ARRAY_PROLOGUE = PROLOGUE + """ @@ -164,7 +171,7 @@ def test_array_param_get_udt_local_mutation_emits_reference_alias(): """ -def test_global_while_array_get_udt_mutation_emits_reference_alias(): +def test_global_while_array_get_udt_mutation_emits_handle_alias(): # Regression: a GLOBAL-scope ``while`` loop-local bound from an array # element and field-mutated is hoisted to a class member and its in-loop # init lowered to a value-copy assignment, so the mutation was lost. It must @@ -180,19 +187,17 @@ def test_global_while_array_get_udt_mutation_emits_reference_alias(): plot(close) """ cpp = transpile(src) - # Fresh per-iteration reference to the array element (write-back), not copy. - assert "pivot& p =" in cpp + # Fresh per-iteration handle aliases the array element's backing record. + assert "pivot p =" in cpp assert "pine_runtime_error" in cpp assert "}((wi)); }((pivots));" in cpp - assert "pivot p =" not in cpp - # The hoisted value member is suppressed (the alias replaces it). - assert "pivot p = pivot{};" not in cpp - # Mutations write through the reference with ``.`` member access. - assert "p.currentLevel = " in cpp - assert "p.crossed = true;" in cpp + assert "pivot& p" not in cpp + assert "pivot* p" not in cpp + assert "_pf_udt_pivot.get(p).currentLevel = " in cpp + assert "_pf_udt_pivot.get(p).crossed = true;" in cpp -def test_global_for_array_get_udt_mutation_emits_reference_alias(): +def test_global_for_array_get_udt_mutation_emits_handle_alias(): # Same defect via a GLOBAL ``for i = ...`` loop. Here the get-local stays a # true local (not hoisted), but the function-local alias path no-ops at # global scope, so it also value-copied. Must alias for write-back. @@ -204,19 +209,17 @@ def test_global_for_array_get_udt_mutation_emits_reference_alias(): plot(close) """ cpp = transpile(src) - assert "pivot& p =" in cpp + assert "pivot p =" in cpp assert "pine_runtime_error" in cpp assert "}((fi)); }((pivots));" in cpp - assert "pivot p =" not in cpp - assert "p.currentLevel = " in cpp - assert "p.crossed = true;" in cpp + assert "pivot& p" not in cpp + assert "pivot* p" not in cpp + assert "_pf_udt_pivot.get(p).currentLevel = " in cpp + assert "_pf_udt_pivot.get(p).crossed = true;" in cpp def test_global_readonly_array_get_udt_stays_value_copy(): - # CAUTION control locking the scope of the fix: a GLOBAL get-local that is - # only READ (never field-mutated) must keep value-copy semantics — no - # needless reference/pointer aliasing (this is what keeps the read-only - # tripwire strategies byte-identical). + # A read-only array get uses the same value handle as a mutating get. src = _GLOBAL_ARRAY_PROLOGUE + """ float acc = 0.0 for ri = 0 to array.size(pivots) - 1 diff --git a/tests/test_udt_na_lifecycle.py b/tests/test_udt_na_lifecycle.py index 8dc55c0..f52ec1e 100644 --- a/tests/test_udt_na_lifecycle.py +++ b/tests/test_udt_na_lifecycle.py @@ -200,8 +200,8 @@ def test_plain_and_nested_udt_na_contexts_are_target_typed() -> None: assert "Inner inner = Inner{};" in cpp assert "Inner local = Inner{};" in cpp assert cpp.count("local = Inner{};") >= 3 - assert "holder.inner = Inner{};" in cpp - assert "Outer{.inner = Inner{}" in cpp + assert "_pf_udt_Outer.get(holder).inner = Inner{};" in cpp + assert "_PFUdtRecord_Outer{.inner = Inner{}" in cpp compile_cpp(cpp, label="udt-na-contexts") @@ -238,8 +238,8 @@ def test_callable_udt_locals_do_not_retype_same_named_global_scalars() -> None: cpp = transpile(source) assert re.search(r"^\s+double state(?: = [^;]+)?;$", cpp, re.M) assert re.search(r"^\s+double tracker(?: = [^;]+)?;$", cpp, re.M) - assert "State state = State{.value = 9.0" in cpp - assert "State tracker = State{.value = 10.0" in cpp + assert "State state = _pf_udt_State.create(_PFUdtRecord_State{.value = 9.0" in cpp + assert "State tracker = _pf_udt_State.create(_PFUdtRecord_State{.value = 10.0" in cpp assert "state = State{};" in cpp assert "tracker = State{};" in cpp compile_cpp(cpp, label="udt-na-raw-name-global-isolation") @@ -271,8 +271,8 @@ def test_exact_global_udt_drives_methods_fields_and_lvalue_aliases() -> None: ''' cpp = transpile(source) assert "_udt_Left_read(state)" in cpp - assert "Left& alias = state;" in cpp - assert "tagValue = state.tag;" in cpp + assert "Left alias = state;" in cpp + assert "tagValue = _pf_udt_Left.read(state).tag;" in cpp assert "tagValue = /* drawing field omitted */ 0;" not in cpp compile_cpp(cpp, label="udt-exact-global-identity") @@ -296,8 +296,10 @@ def test_global_udt_array_alias_uses_rhs_element_type_not_raw_name() -> None: bool ignored = shadow() ''' cpp = transpile(source) - assert "Left& state =" in cpp - assert "Right& state =" not in cpp + assert "Left state =" in cpp + assert "Left& state" not in cpp + assert "Right& state" not in cpp + assert "_pf_udt_Left.get(state).value = 3.0;" in cpp compile_cpp(cpp, label="udt-exact-global-array-alias") @@ -472,7 +474,10 @@ def test_direct_udt_ctor_na_selection_return_is_target_typed() -> None: ''' cpp = transpile(source) assert re.search(r"State choose\(bool enabled, double seed\)", cpp) - assert "State{.value = seed, .__pf_na = false}) : (State{})" in cpp + assert ( + "_pf_udt_State.create(_PFUdtRecord_State{.value = seed})) : (State{})" + in cpp + ) compile_cpp(cpp, label="udt-na-selection-return") driver = r''' #include @@ -529,7 +534,7 @@ def test_udt_method_nullable_udt_return_carries_exact_type() -> None: ''' cpp = transpile(source) assert re.search( - r"State _udt_Factory_choose(?:_cs\d+)?\(Factory& self, bool enabled\)", + r"State _udt_Factory_choose(?:_cs\d+)?\(Factory self, bool enabled\)", cpp, ) compile_cpp(cpp, label="udt-method-na-return") diff --git a/tests/test_udt_object_identity.py b/tests/test_udt_object_identity.py new file mode 100644 index 0000000..4ca846d --- /dev/null +++ b/tests/test_udt_object_identity.py @@ -0,0 +1,888 @@ +"""Pine v6 user-defined object identity, copy, and rollback regressions. + +These probes cover UDTs whose fields are scalar, nested UDT, or shared-ID +matrix values. Array-valued UDT fields intentionally remain outside this +claim; arrays *of* UDT handles are covered separately below. +""" + +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError, Phase +from tests._compile import compile_cpp +from tests.test_pinemap_semantics import _compile_and_run + + +_ALIAS_COPY_SOURCE = r'''//@version=6 +strategy("UDT object identity") +type Item + float value +type Box + Item inner +mutate_then_rebind(Item item, Item replacement) => + item.value += 1.0 + item := replacement + item.value += 2.0 + item.value +var Item original = Item.new(10.0) +var Item alias = original +var Item detached = original.copy() +var Item functional = Item.copy(original) +var Box holder = Box.new(original) +alias.value += 5.0 +detached.value := 99.0 +functional.value := 77.0 +paramResult = mutate_then_rebind(original, detached) +observedOriginal = original.value +observedAlias = alias.value +observedDetached = detached.value +observedFunctional = functional.value +observedNested = holder.inner.value +''' + + +def test_udt_values_emit_nullable_handles_records_and_arenas() -> None: + cpp = transpile(_ALIAS_COPY_SOURCE) + assert "struct Item {\n int32_t __pf_id = -1;" in cpp + assert "struct _PFUdtRecord_Item" in cpp + assert ( + "_PFUdtArena " + "_pf_udt_Item{&_pf_udt_undo};" + ) in cpp + assert "alias = original;" in cpp + assert "detached = _pf_udt_Item.copy(original);" in cpp + assert "functional = _pf_udt_Item.copy(original);" in cpp + assert "_pf_udt_Item.get(alias).value += 5.0;" in cpp + assert "Item& alias" not in cpp + assert "Item* alias" not in cpp + compile_cpp(cpp, label="udt-object-handles") + + +def test_udt_alias_copy_and_parameter_rebind_runtime() -> None: + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy.original.__pf_id != strategy.alias.__pf_id) return 3; + if (strategy.original.__pf_id == strategy.detached.__pf_id) return 4; + if (strategy.original.__pf_id == strategy.functional.__pf_id) return 5; + if (strategy.observedOriginal != 16.0) return 6; + if (strategy.observedAlias != 16.0) return 7; + if (strategy.observedDetached != 101.0) return 8; + if (strategy.observedFunctional != 77.0) return 9; + if (strategy.observedNested != 16.0) return 10; + if (strategy.paramResult != 101.0) return 11; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_ALIAS_COPY_SOURCE) + driver, + label="udt-alias-copy-runtime", + ) == "ok\n" + + +_MATRIX_FINGERPRINT_SOURCE = r'''//@version=6 +strategy("UDT matrix shallow copy") +type Holder + float scalar + matrix nested +var matrix shared = matrix.new(1, 1, 7) +var Holder original = Holder.new(10.0, shared) +var Holder alias = original +var Holder copied = original.copy() +alias.scalar += 5.0 +copied.scalar := 99.0 +copied.nested.set(0, 0, 13) +observedOriginal = original.scalar +observedAlias = alias.scalar +observedCopy = copied.scalar +observedOriginalNested = original.nested.get(0, 0) +observedAliasNested = alias.nested.get(0, 0) +observedCopyNested = copied.nested.get(0, 0) +''' + + +def test_udt_outer_copy_detaches_but_nested_matrix_stays_shared_runtime() -> None: + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + std::cout << strategy.observedOriginal << " " + << strategy.observedAlias << " " + << strategy.observedCopy << " " + << strategy.observedOriginalNested << " " + << strategy.observedAliasNested << " " + << strategy.observedCopyNested << "\n"; +} +''' + assert _compile_and_run( + transpile(_MATRIX_FINGERPRINT_SOURCE) + driver, + label="udt-matrix-fingerprint-runtime", + ) == "15 15 99 13 13 13\n" + + +_MAP_FIELD_COPY_SOURCE = r'''//@version=6 +strategy("UDT map shallow copy") +type Holder + map data +var map shared = map.new() +var Holder original = Holder.new(shared) +var Holder copied = original.copy() +copied.data.put("shared", 7) +seenThroughOriginal = original.data.get("shared") +copied.data := map.new() +copied.data.put("private", 9) +originalAfterRebind = original.data.get("shared") +copyPrivate = copied.data.get("private") +''' + + +def test_udt_outer_copy_shallow_shares_map_then_allows_field_rebind_runtime() -> None: + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy.original.__pf_id == strategy.copied.__pf_id) return 3; + if (strategy.seenThroughOriginal != 7) return 4; + if (strategy.originalAfterRebind != 7) return 5; + if (strategy.copyPrivate != 9) return 6; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_MAP_FIELD_COPY_SOURCE) + driver, + label="udt-map-field-copy-runtime", + ) == "ok\n" + + +_COOF_CYCLE_SOURCE = r'''//@version=6 +strategy("UDT cyclic checkpoint", calc_on_order_fills=true) +type Node + Node next = na + float value +var Node root = Node.new(na, 1.0) +var Node alias = root +var Node copied = root.copy() +if barstate.isfirst + root.next := root + copied.value := 3.0 +''' + + +def test_udt_checkpoint_restores_arena_topology_cycle_safely_runtime() -> None: + cpp = transpile(_COOF_CYCLE_SOURCE) + assert "struct _PFCheckpointTraits<_PFUdtRecord_Node>" in cpp + assert "struct _PFCheckpointTraits<_PFUdtArena>" in cpp + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy._pf_udt_Node.size() != 2) return 3; + const int32_t root_id = strategy.root.__pf_id; + const int32_t copied_id = strategy.copied.__pf_id; + strategy.snapshot_script_state(); + + strategy._pf_udt_Node.get(strategy.alias).value = 9.0; + strategy._pf_udt_Node.get(strategy.copied).next = strategy.copied; + _PFUdtRecord_Node extra{}; + extra.value = 7.0; + strategy.root = strategy._pf_udt_Node.create(extra); + if (strategy._pf_udt_Node.size() != 3) return 4; + + strategy.restore_script_state(); + if (strategy._pf_udt_Node.size() != 2) return 5; + if (strategy.root.__pf_id != root_id) return 6; + if (strategy.alias.__pf_id != root_id) return 7; + if (strategy.copied.__pf_id != copied_id) return 8; + const auto& root_record = strategy._pf_udt_Node.get(strategy.root); + const auto& copied_record = strategy._pf_udt_Node.get(strategy.copied); + if (root_record.value != 1.0) return 9; + if (root_record.next.__pf_id != root_id) return 10; + if (copied_record.value != 3.0) return 11; + if (!is_na(copied_record.next)) return 12; + + strategy._pf_udt_Node.get(strategy.alias).value = 11.0; + strategy._pf_udt_Node.create(extra); + strategy.restore_script_state(); + if (strategy._pf_udt_Node.size() != 2) return 13; + if (strategy._pf_udt_Node.get(strategy.root).value != 1.0) return 14; + + strategy._pf_udt_Node.get(strategy.alias).value = 5.0; + strategy.commit_script_state(); + strategy._pf_udt_Node.get(strategy.alias).value = 6.0; + strategy._pf_udt_Node.create(extra); + strategy.restore_script_state(); + if (strategy._pf_udt_Node.size() != 2) return 15; + if (strategy._pf_udt_Node.get(strategy.root).value != 5.0) return 16; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="udt-cycle-checkpoint-runtime", + ) == "ok\n" + + +def test_udt_checkpoint_restores_shared_map_in_reverse_first_touch_order_runtime() -> None: + source = r'''//@version=6 +strategy("UDT ordered alias rollback", calc_on_order_fills=true) +type Holder + map data +var map shared = map.new() +var Holder first = Holder.new(shared) +var Holder second = first.copy() +''' + cpp = transpile(source) + assert "class _PFUdtUndoCoordinator" in cpp + assert "std::vector> _pf_undo_;" in cpp + assert "_pf_undo_.rbegin()" in cpp + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy._pf_udt_Holder.size() != 2) return 3; + + strategy.snapshot_script_state(); + strategy._pf_udt_Holder.get(strategy.first).data.put("value", 1); + strategy._pf_udt_Holder.get(strategy.second).data.put("value", 2); + strategy.restore_script_state(); + if (strategy._pf_udt_Holder.get(strategy.first).data.contains("value")) return 4; + if (strategy._pf_udt_Holder.get(strategy.second).data.contains("value")) return 5; + + strategy.snapshot_script_state(); + const Holder speculative = strategy._pf_udt_Holder.copy(strategy.first); + strategy._pf_udt_Holder.get(speculative).data.put("new-record", 7); + if (!strategy._pf_udt_Holder.get(strategy.first).data.contains("new-record")) return 6; + strategy.restore_script_state(); + if (strategy._pf_udt_Holder.size() != 2) return 7; + if (strategy._pf_udt_Holder.get(strategy.first).data.contains("new-record")) return 8; + if (strategy._pf_udt_Holder.get(strategy.second).data.contains("new-record")) return 9; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="udt-ordered-alias-rollback", + ) == "ok\n" + + +def test_cross_type_udt_shared_map_restores_in_global_touch_order_runtime() -> None: + source = r'''//@version=6 +strategy("cross-type UDT alias rollback", calc_on_order_fills=true) +type First + map data +type Second + map data +var First first = First.new(map.new()) +var Second second = Second.new(first.data) +''' + cpp = transpile(source) + coordinator_restore = ( + "_PFCheckpointTraits::restore" + ) + first_arena_restore = ( + "_PFCheckpointTraits::restore" + ) + assert cpp.index(coordinator_restore) < cpp.index(first_arena_restore) + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + + strategy.snapshot_script_state(); + strategy._pf_udt_First.get(strategy.first).data.put("value", 1); + strategy._pf_udt_Second.get(strategy.second).data.put("value", 2); + strategy.restore_script_state(); + if (strategy._pf_udt_First.get(strategy.first).data.contains("value")) return 3; + if (strategy._pf_udt_Second.get(strategy.second).data.contains("value")) return 4; + + strategy.snapshot_script_state(); + strategy._pf_udt_Second.get(strategy.second).data.put("reverse", 3); + strategy._pf_udt_First.get(strategy.first).data.put("reverse", 4); + strategy.restore_script_state(); + if (strategy._pf_udt_First.get(strategy.first).data.contains("reverse")) return 5; + if (strategy._pf_udt_Second.get(strategy.second).data.contains("reverse")) return 6; + + strategy._pf_udt_First.get(strategy.first).data.put("shared", 9); + if (strategy._pf_udt_Second.get(strategy.second).data.get("shared") != 9) return 7; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="udt-cross-type-alias-rollback", + ) == "ok\n" + + +def test_udt_arena_checkpoint_is_lazy_under_real_coof_schedule_runtime() -> None: + source = r'''//@version=6 +strategy("UDT lazy COOF checkpoint", calc_on_order_fills=true) +type Item + float value +Item current = Item.new(close) +observed = current.value +''' + cpp = transpile(source) + arena_trait = cpp.split( + "struct _PFCheckpointTraits<_PFUdtArena>", + 1, + )[1].split("};", 1)[0] + assert "using snapshot_type = typename arena_type::Snapshot;" in arena_trait + assert "return value.snapshot();" in arena_trait + assert "for (" not in arena_trait + driver = r''' +#include +#include +int main() { + GeneratedStrategy strategy; + std::vector bars; + bars.reserve(2000); + for (int index = 0; index < 2000; ++index) { + const double value = 100.0 + static_cast(index); + bars.push_back(Bar{value, value, value, value, 1.0, index}); + } + strategy.run(bars.data(), bars.size()); + if (!strategy.last_error().empty()) return 2; + if (strategy._pf_udt_Item.size() != bars.size()) return 3; + if (strategy.observed != bars.back().close) return 4; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="udt-lazy-coof-checkpoint", + ) == "ok\n" + + +def test_udt_scalar_reads_do_not_log_but_writes_still_rollback_runtime() -> None: + source = r'''//@version=6 +strategy("UDT read-only journal") +type Item + float value +var Item holder = Item.new(1.0) +observed = holder.value +''' + cpp = transpile(source) + assert "observed = _pf_udt_Item.read(holder).value;" in cpp + assert "_pf_udt_Item.get(holder).value" not in cpp + driver = r''' +#include +#include +static_assert(!std::is_copy_constructible_v); +static_assert(!std::is_move_constructible_v); +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + + strategy.snapshot_script_state(); + if (strategy._pf_udt_Item.read(strategy.holder).value != 1.0) return 3; + if (!strategy._pf_udt_undo.empty()) return 4; + strategy._pf_udt_Item.get(strategy.holder).value = 9.0; + if (strategy._pf_udt_undo.empty()) return 5; + strategy.restore_script_state(); + if (strategy._pf_udt_Item.read(strategy.holder).value != 1.0) return 6; + if (!strategy._pf_udt_undo.empty()) return 7; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="udt-read-only-journal", + ) == "ok\n" + + +_ARRAY_OF_UDT_SOURCE = r'''//@version=6 +strategy("Array of UDT handles") +type Item + float value +var array items = array.from(Item.new(1.0)) +Item selected = array.get(items, 0) +selected.value += 1.0 +for item in items + item.value += 1.0 + item := Item.new(99.0) +observed = items.get(0).value +''' + + +def test_array_elements_and_for_in_bind_udt_handles_by_value_runtime() -> None: + cpp = transpile(_ARRAY_OF_UDT_SOURCE) + assert "for (auto item : items)" in cpp + assert "for (auto& item : items)" not in cpp + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy.observed != 3.0) return 3; + if (strategy._pf_udt_Item.get(strategy.items.at(0)).value != 3.0) return 4; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="array-of-udt-handles-runtime", + ) == "ok\n" + + +def test_udt_arena_growth_keeps_existing_record_references_stable_runtime() -> None: + source = r'''//@version=6 +strategy("UDT stable arena records") +type Item + float value +var Item holder = Item.new(1.0) +observed = holder.value +''' + cpp = transpile(source) + assert "std::deque<_PFSlot> _pf_records_;" in cpp + assert "UDT object-ID capacity exceeded" in cpp + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + auto& borrowed = strategy._pf_udt_Item.get(strategy.holder); + for (int index = 0; index < 4096; ++index) { + _PFUdtRecord_Item extra{}; + extra.value = static_cast(index); + strategy._pf_udt_Item.create(extra); + } + borrowed.value = 9.0; + if (strategy._pf_udt_Item.get(strategy.holder).value != 9.0) return 3; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="udt-stable-arena-records", + ) == "ok\n" + + +def test_typed_user_copy_method_precedes_builtin_udt_copy() -> None: + source = r'''//@version=6 +strategy("typed copy precedence") +type Item + float value +method copy(Item self, float replacement) => + self.value := replacement + self +var Item original = Item.new(1.0) +var Item result = original.copy(7.0) +observed = result.value +''' + cpp = transpile(source) + assert "_udt_Item_copy(original, 7.0)" in cpp + assert "result = _pf_udt_Item.copy(original);" not in cpp + compile_cpp(cpp, label="udt-user-copy-precedence") + + +def test_forward_typed_user_copy_method_precedes_builtin_runtime() -> None: + source = r'''//@version=6 +strategy("forward typed copy precedence") +type Item + float value +var Item original = Item.new(1.0) +var Item result = original.copy(7.0) +observed = result.value +method copy(Item self, float replacement) => + self.value := replacement + self +''' + cpp = transpile(source) + assert "_udt_Item_copy(original, 7.0)" in cpp + assert "result = _pf_udt_Item.copy(original);" not in cpp + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy.observed != 7.0) return 3; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="udt-forward-user-copy-precedence", + ) == "ok\n" + + +def test_forward_typed_method_fills_named_and_default_arguments_runtime() -> None: + source = r'''//@version=6 +strategy("forward typed method defaults") +type Item + float value +var Item item = Item.new(1.0) +var float observed = item.combine(second=4) +method combine(Item self, int first = 2, int second = 3) => + first * 10 + second +''' + cpp = transpile(source) + assert "observed = _udt_Item_combine(item, 2, 4);" in cpp + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy.observed != 24.0) return 3; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="udt-forward-method-defaults", + ) == "ok\n" + + +def test_forward_stateful_typed_method_default_sizes_ta_runtime() -> None: + source = r'''//@version=6 +strategy("forward stateful typed method default") +out = 1.smooth() +method smooth(int self, int len = 2) => + ta.sma(close, len) +''' + cpp = transpile(source) + assert "ta::SMA _ta_sma_1;" in cpp + assert "explicit GeneratedStrategy() : _ta_sma_1(2)" in cpp + assert "out = _udt_int_smooth_cs0(1, 2);" in cpp + compile_cpp(cpp, label="forward-stateful-method-default") + + +def test_forward_sibling_method_return_type_reconciles_runtime() -> None: + source = r'''//@version=6 +strategy("forward sibling method return") +type Holder + float value +method outer(Holder self) => + self.inner() +method inner(Holder self) => + "ok" +var Holder holder = Holder.new(1.0) +result = holder.outer() +''' + cpp = transpile(source) + assert "std::string _udt_Holder_outer(Holder self)" in cpp + assert "std::string _udt_Holder_inner(Holder self)" in cpp + driver = r''' +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) return 2; + if (strategy.result != "ok") return 3; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="forward-sibling-method-return", + ) == "ok\n" + + +@pytest.mark.parametrize("forward", [False, True]) +@pytest.mark.parametrize( + "call, message", + [ + ( + "original.edit(1.0, 2.0)", + "Item.edit: too many positional arguments (expected 1, got 2)", + ), + ( + "original.edit(unknown=1.0)", + "Item.edit: unknown keyword argument 'unknown'", + ), + ( + "original.edit(1.0, replacement=2.0)", + "Item.edit: argument 'replacement' passed both positionally and by keyword", + ), + ( + "original.edit()", + "Item.edit: missing required argument 'replacement'", + ), + ], +) +def test_typed_method_invalid_bindings_fail_in_analyzer( + forward: bool, + call: str, + message: str, +) -> None: + declaration = r'''method edit(Item self, float replacement) => + self.value := replacement + self +''' + call_site = f"var Item invalid = {call}\n" + body = call_site + declaration if forward else declaration + call_site + source = f'''//@version=6 +strategy("strict typed method binding") +type Item + float value +var Item original = Item.new(1.0) +{body}''' + with pytest.raises(CompileError) as raised: + transpile(source) + assert raised.value.diagnostics[0].phase is Phase.ANALYZER + assert raised.value.diagnostics[0].message == message + + +@pytest.mark.parametrize( + "call, message", + [ + ("original.copy(1.0)", r"Item\.copy\(\) expects no arguments"), + ("Item.copy(original, original)", r"Item\.copy\(\.\.\.\) expects exactly one"), + ], +) +def test_builtin_udt_copy_rejects_wrong_arity(call: str, message: str) -> None: + source = f'''//@version=6 +strategy("UDT copy arity") +type Item + float value +var Item original = Item.new(1.0) +var Item invalid = {call} +''' + with pytest.raises(CompileError, match=message): + transpile(source) + + +@pytest.mark.parametrize("copy_expr", ["original.copy()", "Bag.copy(original)"]) +def test_udt_copy_with_direct_array_field_fails_closed(copy_expr: str) -> None: + source = f'''//@version=6 +strategy("UDT array field copy boundary") +type Bag + array values +var Bag original = Bag.new(array.from(1, 2)) +var Bag invalid = {copy_expr} +''' + with pytest.raises( + CompileError, + match=r"Bag\.copy\(\) is unsupported because direct array field\(s\) values", + ): + transpile(source) + + +def test_udt_generated_record_name_avoids_authored_type_collision() -> None: + source = r'''//@version=6 +strategy("UDT record name collision") +type Item + float value +type _PFUdtRecord_Item + float other +var Item item = Item.new(1.0) +var _PFUdtRecord_Item authored = _PFUdtRecord_Item.new(2.0) +observed = item.value + authored.other +''' + cpp = transpile(source) + assert "struct _PFUdtRecord_Item__pf2" in cpp + assert "struct _PFUdtRecord_Item {\n int32_t __pf_id" in cpp + compile_cpp(cpp, label="udt-record-name-collision") + + +def test_udt_generated_arena_member_avoids_authored_binding_collision() -> None: + source = r'''//@version=6 +strategy("UDT arena member collision") +type Item + float value +var Item _pf_udt_Item = Item.new(1.0) +var Item alias = _pf_udt_Item +alias.value := 3.0 +observed = _pf_udt_Item.value +''' + cpp = transpile(source) + assert ( + "_PFUdtArena " + "_pf_udt_Item__pf2{&_pf_udt_undo};" + ) in cpp + assert "Item _pf_udt_Item;" in cpp + assert "_pf_udt_Item__pf2.get(alias).value = 3.0;" in cpp + compile_cpp(cpp, label="udt-arena-member-collision") + + +def test_udt_generated_support_templates_avoid_authored_type_collisions() -> None: + source = r'''//@version=6 +strategy("UDT support template collisions") +type _PFUdtArena + float value +type _PFCheckpointTraits + float value +var _PFUdtArena first = _PFUdtArena.new(1.0) +var _PFCheckpointTraits checkpoint_obj = _PFCheckpointTraits.new(2.0) +observed = first.value + checkpoint_obj.value +''' + cpp = transpile(source) + assert "class _PFUdtArena__pf2" in cpp + assert "struct _PFCheckpointTraits__pf2" in cpp + assert "_PFUdtArena__pf2<_PFUdtArena," in cpp + assert "_PFCheckpointTraits__pf2 None: + source = r'''//@version=6 +strategy("enum table record collision") +enum _PFUdtRecord_X + one = "one" +type X_str_values + float value +var X_str_values item = X_str_values.new(2.0) +choice = _PFUdtRecord_X.one +observed = item.value +labelText = str.tostring(choice) +''' + cpp = transpile(source) + assert "struct _PFUdtRecord_X_str_values__pf2" in cpp + assert "static const std::string _PFUdtRecord_X_str_values[]" in cpp + assert ( + "_PFUdtArena" + in cpp + ) + compile_cpp(cpp, label="udt-record-enum-table-collision") + + +def test_udt_checkpoint_traits_avoids_authored_binding_hiding() -> None: + source = r'''//@version=6 +strategy("trait binding collision") +type Item + float value +var float _PFCheckpointTraits = 4.0 +var Item item = Item.new(2.0) +observed = _PFCheckpointTraits + item.value +''' + cpp = transpile(source) + assert "struct _PFCheckpointTraits__pf2;" in cpp + assert "struct _PFCheckpointTraits__pf2 {" in cpp + assert "double _PFCheckpointTraits;" in cpp + assert ( + "_PFCheckpointTraits__pf2<" + "decltype(GeneratedStrategy::_pf_udt_undo)>" + ) in cpp + compile_cpp(cpp, label="udt-trait-binding-hiding") + + +def test_udt_generated_record_avoids_authored_udf_hiding() -> None: + source = r'''//@version=6 +strategy("record UDF collision") +type Item + float value +_PFUdtRecord_Item(float value) => + value + 1.0 +var Item item = Item.new(2.0) +observed = _PFUdtRecord_Item(item.value) +''' + cpp = transpile(source) + assert "struct _PFUdtRecord_Item__pf2" in cpp + assert "_PFUdtArena" in cpp + assert "double _PFUdtRecord_Item(double value)" in cpp + compile_cpp(cpp, label="udt-record-udf-hiding") + + +def test_udt_arena_member_avoids_callable_parameter_hiding() -> None: + source = r'''//@version=6 +strategy("arena parameter collision") +type Item + float value +read_value(Item item, float _pf_udt_Item) => + item.value + _pf_udt_Item +var Item item = Item.new(2.0) +observed = read_value(item, 3.0) +''' + cpp = transpile(source) + assert "_pf_udt_Item__pf2{&_pf_udt_undo};" in cpp + assert "double read_value(Item item, double _pf_udt_Item)" in cpp + assert ( + "_pf_udt_Item__pf2.read(item).value + _pf_udt_Item" + in cpp + ) + compile_cpp(cpp, label="udt-arena-parameter-hiding") + + +def test_udt_generated_name_skips_an_occupied_pf2_suffix() -> None: + source = r'''//@version=6 +strategy("occupied suffix collision") +type _PFUdtArena + float value +type _PFUdtArena__pf2 + float value +var _PFUdtArena first = _PFUdtArena.new(1.0) +var _PFUdtArena__pf2 other = _PFUdtArena__pf2.new(2.0) +observed = first.value + other.value +''' + cpp = transpile(source) + assert "class _PFUdtArena__pf3 {" in cpp + assert "class _PFUdtArena__pf2 {" not in cpp + compile_cpp(cpp, label="udt-support-occupied-suffix") + + +def test_udt_arena_member_avoids_natural_callable_clone_name() -> None: + source = r'''//@version=6 +strategy("natural clone collision") +type X_cs1 + float value +_pf_udt_X() => + var float state = 0.0 + state += 1.0 + state +a = _pf_udt_X() +b = _pf_udt_X() +var X_cs1 item = X_cs1.new(3.0) +observed = item.value + a + b +''' + cpp = transpile(source) + assert "double _pf_udt_X_cs1()" in cpp + assert ( + "_PFUdtArena " + "_pf_udt_X_cs1__pf2{&_pf_udt_undo};" + ) in cpp + compile_cpp(cpp, label="udt-arena-natural-clone-collision") + + +def test_udt_arena_member_avoids_fresh_callable_instance_name() -> None: + source = r'''//@version=6 +strategy("fresh clone collision") +type X__ni1 + float value +_pf_udt_X(int size) => + ta.highest(size) +f_get(int len) => _pf_udt_X(len) +g_get(int len) => _pf_udt_X(len) +a = f_get(10) +b = f_get(20) +c = g_get(30) +var X__ni1 item = X__ni1.new(3.0) +observed = item.value + a + b + c +''' + cpp = transpile(source) + assert "double _pf_udt_X__ni1(int size)" in cpp + assert ( + "_PFUdtArena " + "_pf_udt_X__ni1__pf2{&_pf_udt_undo};" + ) in cpp + compile_cpp(cpp, label="udt-arena-fresh-instance-collision")