diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 8665637..e3e8531 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -171,6 +171,35 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._func_udt_return_types: dict[str, str] = {} self._func_return_type_specs: dict[str, "TypeSpec"] = {} self._func_param_type_specs: dict[str, list] = {} + # History receivers can mention an untyped UDF parameter before its + # concrete TypeSpec is learned from a call site. Keep the exact AST + # identifier identities that resolved to parameters during the + # definition pass; call handling re-checks those receivers once the + # argument specs are available. Node identity (rather than raw name) + # prevents a later local/loop binding with the same spelling from + # being mistaken for the parameter it shadows. + self._deferred_param_history_refs: dict[ + str, list[tuple[Subscript, dict[int, str]]] + ] = {} + # Parameter flow between user callables, captured while the caller's + # lexical symbols are still in scope. This lets a later concrete map + # call revalidate history hidden behind wrappers without re-analyzing + # whole function bodies or conflating same-spelled locals. + self._deferred_param_call_edges: dict[ + str, + list[ + tuple[ + int, + str, + list[str], + list[ASTNode | None], + list[dict[int, str]], + ] + ], + ] = {} + self._func_series_history_nodes: dict[ + tuple[str, str], Subscript + ] = {} # Per-function var_members and series_vars (for call-site cloning) self._func_var_members: dict[str, list] = {} # func_name -> [(name, PineType, init_str)] self._func_series_vars: dict[str, set] = {} # func_name -> set[str] @@ -318,6 +347,7 @@ def analyze(self) -> AnalyzerContext: """Run semantic analysis and return the analyzer context.""" self._ensure_pine_v6() self._visit(self._ast) + self._check_cross_callable_series_collection_collisions() # Propagate call-site counts to sub-functions called within # multi-call-site functions. If f() has N call sites and calls g() @@ -398,6 +428,48 @@ def analyze(self) -> AnalyzerContext: block_var_renames=dict(self._block_var_renames), ) + def _check_cross_callable_series_collection_collisions(self) -> None: + """Fail closed when legacy raw member names cannot preserve scoping. + + Persistent callable locals are class members. A scalar Series local + with the same spelling in another callable would otherwise bind to the + collection member during C++ emission (``slot.push`` on PineMap). Keep + the valid source out of malformed C++ until callable-owned Series + members have fully namespaced storage. + """ + persistent_collections: dict[str, set[str]] = {} + for owner, specs in self._func_collection_types.items(): + persistent_names = { + item[0] for item in self._func_var_members.get(owner, []) + } + persistent_collections[owner] = { + name + for name, spec in specs.items() + if name in persistent_names + and self._type_spec_contains_map(spec) + } + + emitted: set[tuple[str, str, str]] = set() + for series_owner, names in self._func_series_vars.items(): + for collection_owner, collection_names in persistent_collections.items(): + if series_owner == collection_owner: + continue + for name in names & collection_names: + key = (series_owner, collection_owner, name) + if key in emitted: + continue + emitted.add(key) + node = self._func_series_history_nodes.get( + (series_owner, name) + ) + self._error( + "History references on a scalar callable local named " + f"'{name}' conflict with a persistent map local of the " + "same name in another callable; scoped Series member " + "storage is not implemented yet.", + node.loc if node is not None else None, + ) + def _record_global_binding_stmt(self, name: str, pine_type: PineType, is_var: bool, decl_node: ASTNode | None = None) -> None: info = self._global_binding_infos.get(name) @@ -1262,6 +1334,12 @@ def _record_collection_type( self._collection_types[name] = spec def _visit_VarDecl(self, node: VarDecl) -> PineType: + outer_symbol = self._symbols.resolve(node.name) + outer_spec = ( + getattr(outer_symbol, "type_spec", None) + if outer_symbol is not None + else self._collection_types.get(node.name) + ) # Infer type from the value expression val_type = self._visit(node.value) type_spec = self._type_spec_from_hint(node.type_hint) if node.type_hint else None @@ -1329,6 +1407,12 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType: udt_type_name=udt_ctor, type_spec=type_spec, ) + if ( + not self._global_scope + and self._type_spec_contains_map(outer_spec) + and not self._type_spec_contains_map(type_spec) + ): + setattr(sym, "_pf_shadows_map_state", True) if node.name in self._static_vars: setattr(sym, "is_static_series", True) self._symbols.define(sym) @@ -1564,6 +1648,7 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: loc=loc, type_spec=pspec, ) + setattr(sym, "_pf_parameter_owner", node.name) self._symbols.define(sym) # Record TA counter before visiting body @@ -1616,6 +1701,9 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: # this narrow to map terminals so general/nonterminal inference and # generated output remain unchanged. terminal_ret_expr = self._direct_terminal_return_expr(node) + terminal_direct_return_spec = self._type_spec_from_expr( + terminal_ret_expr + ) terminal_map_return = self._terminal_map_call_return( terminal_ret_expr, { @@ -1668,6 +1756,9 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: ret_spec = self._type_spec_from_expr(ret_expr) if ret_spec is not None and ret_spec.kind == "array": self._func_return_type_specs[node.name] = ret_spec + if (terminal_direct_return_spec is not None + and terminal_direct_return_spec.kind == "map"): + self._func_return_type_specs[node.name] = terminal_direct_return_spec if terminal_map_return is not None: body_type, terminal_spec = terminal_map_return @@ -1752,20 +1843,28 @@ def _visit_MethodDef(self, node) -> PineType: pspec = self._type_spec_from_hint(hint) if hint else None param_types.append(ptype) param_specs.append(pspec) - self._symbols.define(Symbol( + sym = Symbol( name=p, pine_type=ptype, is_series=False, is_var=False, is_const=False, const_value=None, scope=self._symbols.current_scope.name, loc=loc, udt_type_name=udt_self, type_spec=pspec, - )) + ) + setattr(sym, "_pf_parameter_owner", method_key) + self._symbols.define(sym) ret_type = PineType.VOID old_global = self._global_scope self._global_scope = False self._collection_scope_stack.append(method_key) + terminal_ret_expr = self._direct_terminal_return_expr(node) + return_type_spec = None try: for stmt in node.body: ret_type = self._visit(stmt) + if terminal_ret_expr is not None: + 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 finally: self._global_scope = old_global self._collection_scope_stack.pop() @@ -1808,6 +1907,7 @@ def _visit_MethodDef(self, node) -> PineType: tuple_element_count=tuple_element_count, param_defaults=param_defaults, param_type_specs=param_specs, + return_type_spec=return_type_spec, ) self._func_infos.append(fi) return PineType.VOID @@ -1841,6 +1941,12 @@ def _visit_IfStmt(self, node: IfStmt) -> PineType: return body_type def _visit_ForStmt(self, node: ForStmt) -> PineType: + outer_symbol = self._symbols.resolve(node.var) + outer_spec = ( + getattr(outer_symbol, "type_spec", None) + if outer_symbol is not None + else self._collection_types.get(node.var) + ) self._symbols.enter_scope("for") loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1) @@ -1856,6 +1962,8 @@ def _visit_ForStmt(self, node: ForStmt) -> PineType: loc=loc, type_spec=TypeSpec.primitive("int"), ) + if self._type_spec_contains_map(outer_spec): + setattr(sym, "_pf_shadows_map_state", True) self._symbols.define(sym) old_global = self._global_scope @@ -1896,12 +2004,21 @@ def _visit_ForInStmt(self, node) -> PineType: pine_type = self._element_pine_type(element_spec) if pine_type == PineType.VOID: pine_type = PineType.FLOAT - self._symbols.define(Symbol( + outer_symbol = self._symbols.resolve(node.var) + outer_spec = ( + getattr(outer_symbol, "type_spec", None) + if outer_symbol is not None + else self._collection_types.get(node.var) + ) + sym = Symbol( name=node.var, pine_type=pine_type, is_series=False, is_var=False, is_const=False, const_value=None, scope=self._symbols.current_scope.name, loc=loc, type_spec=element_spec, - )) + ) + if self._type_spec_contains_map(outer_spec): + setattr(sym, "_pf_shadows_map_state", True) + self._symbols.define(sym) if node.vars: loc = node.loc or SourceLocation(file=self._filename, line=1, col=1, end_col=1) for idx, v in enumerate(node.vars): @@ -1913,12 +2030,21 @@ def _visit_ForInStmt(self, node) -> PineType: pine_type = self._element_pine_type(binder_spec) if pine_type == PineType.VOID: pine_type = PineType.FLOAT - self._symbols.define(Symbol( + outer_symbol = self._symbols.resolve(v) + outer_spec = ( + getattr(outer_symbol, "type_spec", None) + if outer_symbol is not None + else self._collection_types.get(v) + ) + sym = Symbol( name=v, pine_type=pine_type, is_series=False, is_var=False, is_const=False, const_value=None, scope=self._symbols.current_scope.name, loc=loc, type_spec=binder_spec, - )) + ) + if self._type_spec_contains_map(outer_spec): + setattr(sym, "_pf_shadows_map_state", True) + self._symbols.define(sym) for stmt in node.body: self._visit(stmt) self._symbols.exit_scope() @@ -2118,6 +2244,55 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: self._visit(arg) for val in node.kwargs.values(): self._visit(val) + # UDT method call-site typing. Method definitions may contain an + # untyped parameter history read; resolve receiver + args here and + # apply the same deferred map-history gate as regular UDFs before + # codegen can emit the parameter as a scalar double. + receiver_spec = self._type_spec_from_expr(obj) + if ( + receiver_spec is not None + and receiver_spec.kind == "udt" + and receiver_spec.name + ): + method_key = f"{receiver_spec.name}.{member}" + method_info = next( + ( + info + for info in self._func_infos + if info.name == method_key + and getattr(info, "is_udt_method", False) + ), + None, + ) + 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) + full_bound: list[ASTNode | None] = [obj, *rest_bound] + effective_specs = list( + getattr(method_info, "param_type_specs", None) or [] + ) + while len(effective_specs) < len(method_params): + effective_specs.append(None) + for index, arg in enumerate(full_bound): + if effective_specs[index] is None and arg is not None: + effective_specs[index] = self._type_spec_from_expr(arg) + self._record_deferred_param_call_edge( + node, + method_key, + method_params, + full_bound, + ) + self._validate_deferred_param_history_refs( + method_key, + { + name: spec + for name, spec in zip( + method_params, effective_specs + ) + }, + ) + return method_info.return_type # Matrix method dispatch: ``m.get(0, 0)`` on ``matrix`` must # type as INT, not VOID, so ``v = m.get(...)`` propagates the # element PineType. ``_type_spec_from_expr`` already carries the @@ -2204,10 +2379,510 @@ def _visit_FuncCall(self, node: FuncCall) -> PineType: self._visit(val) return PineType.VOID + def _type_spec_contains_map( + self, + spec: TypeSpec | None, + visiting_udts: frozenset[str] = frozenset(), + ) -> bool: + """Whether a lexical TypeSpec recursively owns a PineMap handle. + + History buffers value-copy their elements. A map ID anywhere in that + shape would therefore retain live storage across bars and silently + turn ``[1]`` into a current-state alias. This check belongs in the + analyzer, where symbol resolution is scope-aware: a scalar parameter + or block local can safely shadow a same-named global map, while typed + map parameters and inferred aliases are still rejected. + """ + if spec is None: + return False + if spec.kind == "map": + return True + if spec.kind in {"array", "matrix"}: + return self._type_spec_contains_map(spec.element, visiting_udts) + if spec.kind == "udt" and spec.name: + if spec.name in visiting_udts: + return False + nested_visiting = visiting_udts | {spec.name} + return any( + self._type_spec_contains_map(field_spec, nested_visiting) + for field_spec in self._udt_field_type_specs.get( + spec.name, {} + ).values() + ) + return False + + def _history_receiver_type_spec( + self, + node: ASTNode, + parameter_specs_by_node: dict[int, TypeSpec | None] | None = None, + ) -> TypeSpec | None: + """Resolve the value shape on the left of Pine's history operator. + + General expression inference intentionally stays conservative. The + history safety gate needs two extra, narrow facts: a ternary selecting + two equal aggregate handles keeps that aggregate type, and an untyped + parameter can be substituted with the TypeSpec learned at its call + site. The substitution is keyed by AST identifier identity so a + same-spelled local or loop binder is never confused with the parameter. + """ + overrides = parameter_specs_by_node or {} + if isinstance(node, Identifier): + if id(node) in overrides: + return overrides[id(node)] + return self._type_spec_from_expr(node) + if isinstance(node, Ternary): + true_spec = self._history_receiver_type_spec( + node.true_val, overrides + ) + false_spec = self._history_receiver_type_spec( + node.false_val, overrides + ) + if true_spec is not None and true_spec == false_spec: + return true_spec + # ``na`` is context-typed by the opposite branch in Pine. Keep + # this rule local to the history gate rather than broadening all + # declaration inference. + if isinstance(node.true_val, NaLiteral): + return false_spec + if isinstance(node.false_val, NaLiteral): + return true_spec + return None + if isinstance(node, MemberAccess): + owner = self._history_receiver_type_spec(node.object, overrides) + if owner is not None and owner.kind == "udt" and owner.name: + return (self._udt_field_type_specs.get(owner.name) or {}).get( + node.member + ) + return self._type_spec_from_expr(node) + if isinstance(node, FuncCall) and isinstance( + node.callee, MemberAccess + ): + callee = node.callee + receiver = None + if ( + isinstance(callee.object, Identifier) + and callee.object.name == "map" + and node.args + ): + receiver = node.args[0] + elif not ( + isinstance(callee.object, Identifier) + and callee.object.name in { + "array", "matrix", "map", "request", "ta" + } + ): + receiver = callee.object + if receiver is not None: + recv_spec = self._history_receiver_type_spec( + receiver, overrides + ) + if recv_spec is not None and recv_spec.kind == "map": + if callee.member == "copy": + return recv_spec + if callee.member in {"put", "get", "remove"}: + return recv_spec.value + if callee.member == "keys": + return TypeSpec.array( + recv_spec.key or TypeSpec.primitive("string") + ) + if callee.member == "values": + return TypeSpec.array( + recv_spec.value or TypeSpec.primitive("float") + ) + return self._type_spec_from_expr(node) + + def _parameter_identifiers_in_expr( + self, node: ASTNode + ) -> dict[str, dict[int, str]]: + """Return parameter identifier nodes grouped by their callable owner.""" + grouped: dict[str, dict[int, str]] = {} + + def visit(value) -> None: + if value is None: + return + if isinstance(value, Identifier): + sym = self._symbols.resolve(value.name) + owner = getattr(sym, "_pf_parameter_owner", None) + if owner: + grouped.setdefault(owner, {})[id(value)] = value.name + return + if isinstance(value, (list, tuple)): + for item in value: + visit(item) + return + if isinstance(value, dict): + for item in value.values(): + visit(item) + return + if isinstance(value, ASTNode): + for child in vars(value).values(): + visit(child) + + visit(node) + return grouped + + @staticmethod + def _bind_callable_args( + node: FuncCall, + param_names: list[str], + ) -> list[ASTNode | None]: + """Bind positional/keyword AST arguments into declaration order.""" + bound: list[ASTNode | None] = [None] * len(param_names) + for index, arg in enumerate(node.args): + if index < len(bound): + bound[index] = arg + for name, value in node.kwargs.items(): + if name in param_names: + bound[param_names.index(name)] = value + return bound + + def _record_deferred_param_call_edge( + self, + call_node: FuncCall, + callee_owner: str, + callee_param_names: list[str], + bound_args: list[ASTNode | None], + ) -> None: + """Capture caller-param flow while lexical symbol identity is known.""" + if not self._collection_scope_stack: + return + caller_owner = self._collection_scope_stack[-1] + parameter_nodes: list[dict[int, str]] = [] + has_flow = False + for arg in bound_args: + grouped = ( + self._parameter_identifiers_in_expr(arg) + if arg is not None + else {} + ) + current = grouped.get(caller_owner, {}) + parameter_nodes.append(current) + has_flow = has_flow or bool(current) + if not has_flow: + return + edges = self._deferred_param_call_edges.setdefault(caller_owner, []) + if any(edge[0] == id(call_node) for edge in edges): + return + edges.append( + ( + id(call_node), + callee_owner, + list(callee_param_names), + list(bound_args), + parameter_nodes, + ) + ) + + def _reject_unsupported_map_history( + self, spec: TypeSpec, node: Subscript + ) -> None: + if spec.kind == "map": + message = ( + "History references on map IDs are not supported in " + "PineForge; map rollback uses identity snapshots rather " + "than Series." + ) + else: + message = ( + "History references on map-bearing UDTs or collections " + "are not supported in PineForge; their Series value copy " + "would retain live map aliases." + ) + self._error(message, node.loc) + + def _validate_deferred_param_history_refs( + self, + owner: str, + parameter_specs: dict[str, TypeSpec | None], + visiting: frozenset[str] = frozenset(), + ) -> None: + """Re-run map-history safety after untyped callable args are known.""" + if owner in visiting: + return + next_visiting = visiting | {owner} + for node, parameter_nodes in self._deferred_param_history_refs.get( + owner, [] + ): + overrides = { + node_id: parameter_specs.get(name) + for node_id, name in parameter_nodes.items() + } + object_spec = self._history_receiver_type_spec( + node.object, overrides + ) + if self._type_spec_contains_map(object_spec): + assert object_spec is not None + self._reject_unsupported_map_history(object_spec, node) + + # Propagate concrete caller specs through wrapper calls. Each edge is + # identity-keyed from the definition pass, so a local that shadows a + # caller parameter cannot accidentally inherit its map TypeSpec. + for ( + _call_id, + callee_owner, + callee_param_names, + bound_args, + parameter_nodes_by_arg, + ) in self._deferred_param_call_edges.get(owner, []): + callee_specs: dict[str, TypeSpec | None] = {} + for index, param_name in enumerate(callee_param_names): + arg = bound_args[index] if index < len(bound_args) else None + if arg is None: + callee_specs[param_name] = None + continue + parameter_nodes = ( + parameter_nodes_by_arg[index] + if index < len(parameter_nodes_by_arg) + else {} + ) + overrides = { + node_id: parameter_specs.get(name) + for node_id, name in parameter_nodes.items() + } + callee_specs[param_name] = self._history_receiver_type_spec( + arg, overrides + ) + self._validate_deferred_param_history_refs( + callee_owner, + callee_specs, + next_visiting, + ) + + def _propagate_deferred_map_callable_specs( + self, + owner: str, + parameter_specs: dict[str, TypeSpec | None], + visiting: frozenset[str] = frozenset(), + ) -> bool: + """Monomorphize an all-untyped wrapper chain for one concrete map call. + + Function definitions are analyzed before their concrete top-level call + sites. Consequently ``wrapper(a) => identity(a)`` initially creates + both ``FuncInfo`` records with scalar fallbacks. The definition pass + already captured identity-keyed parameter-flow edges for deferred map + history validation; reuse those exact edges to carry a later map + ``TypeSpec`` inward, then infer map returns on the way back out. + + This is deliberately bounded and map-triggered. Cycles stop at the + active owner, incompatible previously-established parameter specs stop + that edge, and scalar-only call graphs never mutate. PineForge emits + one C++ body per ordinary UDF, so silently replacing one concrete map + specialization with a different one would be a false polymorphic + inference rather than a valid widening. + """ + if owner in visiting: + return False + if not any( + spec is not None and spec.kind == "map" + for spec in parameter_specs.values() + ): + return False + + func_info = next( + (info for info in self._func_infos if info.name == owner), + None, + ) + func_def = self._func_defs.get(owner) + if func_info is None or func_def is None: + return False + + changed = False + while len(func_info.param_type_specs) < len(func_def.params): + func_info.param_type_specs.append(None) + declared_specs = list( + self._func_param_type_specs.get(owner) + or self._param_type_specs_from_def(func_def) + ) + while len(declared_specs) < len(func_def.params): + declared_specs.append(None) + + # Refuse to overwrite a declared or previously learned concrete map + # specialization. Equal specs and still-unresolved slots are safe. + for index, param_name in enumerate(func_def.params): + incoming = parameter_specs.get(param_name) + if incoming is None: + continue + established = declared_specs[index] or func_info.param_type_specs[index] + if ( + established is not None + and established != incoming + and (established.kind == "map" or incoming.kind == "map") + ): + self._error( + f"User function '{owner}' is called with incompatible " + "map parameter types; PineForge cannot emit multiple " + "map specializations for one untyped function.", + func_def.loc, + ) + for index, param_name in enumerate(func_def.params): + incoming = parameter_specs.get(param_name) + if incoming is None or func_info.param_type_specs[index] is not None: + continue + func_info.param_type_specs[index] = incoming + changed = True + + next_visiting = visiting | {owner} + edges = self._deferred_param_call_edges.get(owner, []) + # Nested actual arguments can depend on a sibling edge's newly learned + # return spec. A bounded local fixed point removes source-order + # dependence without turning this into whole-program re-analysis. + for _ in range(max(1, len(edges) + 1)): + pass_changed = False + for ( + _call_id, + callee_owner, + callee_param_names, + bound_args, + parameter_nodes_by_arg, + ) in edges: + callee_specs: dict[str, TypeSpec | None] = {} + for index, param_name in enumerate(callee_param_names): + arg = bound_args[index] if index < len(bound_args) else None + if arg is None: + callee_specs[param_name] = None + continue + parameter_nodes = ( + parameter_nodes_by_arg[index] + if index < len(parameter_nodes_by_arg) + else {} + ) + overrides = { + node_id: parameter_specs.get(name) + for node_id, name in parameter_nodes.items() + } + callee_specs[param_name] = self._history_receiver_type_spec( + arg, overrides + ) + # A bare ``na`` actual argument acquires the one unambiguous + # map type carried by its sibling arguments. This is needed + # for wrappers such as ``select(c, m) => choose(c, m, na)``; + # without it the inner untyped parameter remains scalar even + # though Pine context-types the na handle. Multiple distinct + # map specs stay unresolved rather than guessing. + concrete_map_specs = { + spec + for spec in callee_specs.values() + if spec is not None and spec.kind == "map" + } + if len(concrete_map_specs) == 1: + contextual_map_spec = next(iter(concrete_map_specs)) + for index, param_name in enumerate(callee_param_names): + arg = ( + bound_args[index] + if index < len(bound_args) + else None + ) + if ( + callee_specs.get(param_name) is None + and ( + isinstance(arg, NaLiteral) + or ( + isinstance(arg, Identifier) + and arg.name == "na" + ) + ) + ): + callee_specs[param_name] = contextual_map_spec + if self._propagate_deferred_map_callable_specs( + callee_owner, + callee_specs, + next_visiting, + ): + pass_changed = True + changed = changed or pass_changed + if not pass_changed: + break + + terminal = self._direct_terminal_return_expr(func_def) + return_spec = None + scalar_return_type = None + if isinstance(terminal, Identifier) and terminal.name in parameter_specs: + candidate = parameter_specs.get(terminal.name) + if candidate is not None and candidate.kind == "map": + return_spec = candidate + if return_spec is None: + return_spec = self._terminal_map_selection_return_spec( + terminal, parameter_specs + ) + if return_spec is None: + terminal_map_call = self._terminal_map_call_return( + terminal, parameter_specs + ) + if terminal_map_call is not None: + terminal_return_type, candidate = terminal_map_call + if candidate is not None and candidate.kind == "map": + return_spec = candidate + elif terminal_return_type not in { + PineType.UNKNOWN, PineType.VOID + }: + scalar_return_type = terminal_return_type + if return_spec is None: + candidate = self._type_spec_from_expr(terminal) + if candidate is not None and candidate.kind == "map": + return_spec = candidate + if return_spec is not None: + established_return = self._func_return_type_specs.get(owner) + if established_return is None: + self._func_return_type_specs[owner] = return_spec + func_info.return_type_spec = return_spec + changed = True + elif established_return == return_spec: + if func_info.return_type_spec is None: + func_info.return_type_spec = return_spec + changed = True + # A different established return belongs to another concrete map + # specialization. Keep it unchanged rather than falsely widening. + + if scalar_return_type is None and isinstance(terminal, FuncCall): + callee = terminal.callee + callee_name = ( + callee.name if isinstance(callee, Identifier) else None + ) + callee_info = next( + ( + info + for info in self._func_infos + if info.name == callee_name + ), + None, + ) + if ( + callee_info is not None + and callee_info.return_type + not in {PineType.UNKNOWN, PineType.VOID} + ): + scalar_return_type = callee_info.return_type + if ( + return_spec is None + and scalar_return_type is not None + and func_info.return_type != scalar_return_type + ): + func_info.return_type = scalar_return_type + self._func_return_types[owner] = scalar_return_type + changed = True + + return changed + def _visit_Subscript(self, node: Subscript) -> PineType: obj_type = self._visit(node.object) self._visit(node.index) + object_spec = self._history_receiver_type_spec(node.object) + if self._type_spec_contains_map(object_spec): + assert object_spec is not None + self._reject_unsupported_map_history(object_spec, node) + + # An untyped parameter has no aggregate TypeSpec during the function + # definition pass. Remember only the identifier nodes that resolved + # to actual parameters; the call handler validates them before any + # codegen state is committed. + for owner, parameter_nodes in self._parameter_identifiers_in_expr( + node.object + ).items(): + self._deferred_param_history_refs.setdefault(owner, []).append( + (node, parameter_nodes) + ) + # Detect series vars / bar fields if isinstance(node.object, Identifier): name = node.object.name @@ -2216,8 +2891,27 @@ def _visit_Subscript(self, node: Subscript) -> PineType: else: sym = self._symbols.resolve(name) if sym is not None: + if getattr(sym, "_pf_shadows_map_state", False): + self._error( + "History references on scalar local or loop " + "bindings that shadow a map ID are not supported " + "until PineForge can allocate a lexically scoped " + "Series buffer for that binding.", + node.loc, + ) if getattr(sym, "type_spec", None) is None or sym.type_spec.kind not in ("array", "map"): - self._series_vars.add(name) + global_sym = self._symbols.global_scope.symbols.get(name) + shadows_map_state = ( + sym.scope != "global" + and global_sym is not None + and self._type_spec_contains_map(global_sym.type_spec) + ) + # ``_series_vars`` is a legacy global-by-name set. Do + # not let a lexical scalar series parameter poison a + # same-named global PineMap member; the scoped series + # registry below is authoritative for the function. + if not shadows_map_state: + self._series_vars.add(name) sym.is_series = True # Track function-scoped series vars if sym.scope and sym.scope.startswith("func_"): @@ -2225,6 +2919,9 @@ def _visit_Subscript(self, node: Subscript) -> PineType: if func_name not in self._func_series_vars: self._func_series_vars[func_name] = set() self._func_series_vars[func_name].add(name) + self._func_series_history_nodes.setdefault( + (func_name, name), node + ) return obj_type diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index c64b44a..47afec4 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -1088,15 +1088,52 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: """Handle calls to user-defined functions.""" func_def = self._func_defs[func_name] - # Visit the call args - arg_types = [] + # Visit every supplied argument in Pine source order, then bind both + # positional and keyword forms into declaration order. The old + # positional-only path skipped kwargs entirely, hiding nested calls and + # leaving deferred map-history validation without a concrete TypeSpec. + bound_args = self._bind_callable_args(node, list(func_def.params)) + visited_types: dict[int, PineType] = {} for arg in node.args: - arg_types.append(self._visit(arg)) + visited_types[id(arg)] = self._visit(arg) + for arg in node.kwargs.values(): + visited_types[id(arg)] = self._visit(arg) - # Determine param types from call-site args - param_types = arg_types[:len(func_def.params)] - while len(param_types) < len(func_def.params): - param_types.append(PineType.UNKNOWN) + param_types = [ + visited_types.get(id(arg), PineType.UNKNOWN) + if arg is not None + else PineType.UNKNOWN + for arg in bound_args + ] + + # Per-param TypeSpec: declared hints are authoritative; for untyped + # params, infer from this call site's argument. Validate deferred + # history receivers before series propagation/cloning can reinterpret + # a map handle (or map-bearing UDT) as ``Series``. + param_specs = list( + getattr(self, "_func_param_type_specs", {}).get(func_name) + or self._param_type_specs_from_def(func_def) + ) + arg_specs = [ + self._type_spec_from_expr(arg) if arg is not None else None + for arg in bound_args + ] + for i in range(len(param_specs)): + if param_specs[i] is None and i < len(arg_specs): + param_specs[i] = arg_specs[i] + self._record_deferred_param_call_edge( + node, + func_name, + list(func_def.params), + bound_args, + ) + self._validate_deferred_param_history_refs( + func_name, + { + name: spec + for name, spec in zip(func_def.params, param_specs) + }, + ) # Determine return type: re-analyze the function body with known param types # For now, use the cached return type from initial analysis @@ -1131,8 +1168,10 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: func_sv = self._func_series_vars.get(func_name, set()) if func_sv: for p_idx, param_name in enumerate(func_def.params): - if param_name in func_sv and p_idx < len(node.args): - arg = node.args[p_idx] + if param_name in func_sv and p_idx < len(bound_args): + arg = bound_args[p_idx] + if arg is None: + continue if isinstance(arg, Identifier) and arg.name in BAR_FIELDS: self._series_bar_fields.add(arg.name) elif isinstance(arg, Identifier): @@ -1165,17 +1204,6 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: # Forward UDT-return inference (set in _visit_FuncDef) so codegen can # emit the struct return type. Probe: udt-method-probe-20. udt_ret = self._func_udt_return_types.get(func_name) - # Per-param TypeSpec: declared hints are authoritative; for untyped - # params, infer from the call-site argument's type_spec (so an untyped - # ``s`` used as a string, or a UDT passed by value, emits correctly). - param_specs = list( - getattr(self, "_func_param_type_specs", {}).get(func_name) - or self._param_type_specs_from_def(func_def) - ) - arg_specs = [self._type_spec_from_expr(arg) for arg in node.args] - for i in range(len(param_specs)): - if param_specs[i] is None and i < len(arg_specs): - param_specs[i] = arg_specs[i] existing = [fi for fi in self._func_infos if fi.name == func_name] # A direct terminal map call on an untyped parameter cannot be typed @@ -1196,6 +1224,37 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: }, ) ret_spec = getattr(self, "_func_return_type_specs", {}).get(func_name) + terminal_expr = self._direct_terminal_return_expr(func_def) + terminal_selection_spec = self._terminal_map_selection_return_spec( + terminal_expr, + { + name: spec + for name, spec in zip( + func_def.params, effective_param_specs + ) + }, + ) + if ( + terminal_map_return is None + and isinstance(terminal_expr, Identifier) + and terminal_expr.name in func_def.params + ): + terminal_index = func_def.params.index(terminal_expr.name) + terminal_identity_spec = ( + effective_param_specs[terminal_index] + if terminal_index < len(effective_param_specs) + else None + ) + if (terminal_identity_spec is not None + and terminal_identity_spec.kind == "map"): + # An untyped identity UDF learns the map handle type from its + # call site. The handle is returned by value, preserving the + # backing ID rather than cloning map contents. + self._func_return_type_specs[func_name] = terminal_identity_spec + ret_spec = terminal_identity_spec + if terminal_selection_spec is not None: + self._func_return_type_specs[func_name] = terminal_selection_spec + ret_spec = terminal_selection_spec if terminal_map_return is not None: return_type, inferred_ret_spec = terminal_map_return self._func_return_types[func_name] = return_type @@ -1240,4 +1299,17 @@ def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType: if fi.return_type_spec is None and ret_spec is not None: fi.return_type_spec = ret_spec - return return_type + # A concrete map can arrive only at an outer wrapper's eventual call + # site, after every nested untyped UDF was initially analyzed with + # scalar fallbacks. Propagate the concrete specs through the exact + # definition-time call edges and infer returns in post-order before + # codegen snapshots the FuncInfo table. + self._propagate_deferred_map_callable_specs( + func_name, + { + name: spec + for name, spec in zip(func_def.params, param_specs) + }, + ) + + return self._func_return_types.get(func_name, return_type) diff --git a/pineforge_codegen/analyzer/types.py b/pineforge_codegen/analyzer/types.py index 9e627de..96b954e 100644 --- a/pineforge_codegen/analyzer/types.py +++ b/pineforge_codegen/analyzer/types.py @@ -40,8 +40,9 @@ from typing import Any from ..ast_nodes import ( - ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, - NaLiteral, NumberLiteral, StringLiteral, TupleLiteral, UnaryOp, + ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, IfStmt, + MemberAccess, NaLiteral, NumberLiteral, StringLiteral, Ternary, + TupleLiteral, UnaryOp, ) from ..symbols import PineType, TypeSpec @@ -181,6 +182,51 @@ def _array_from_element_spec(self, value: ASTNode | None) -> TypeSpec | None: def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: if value is None: return None + if isinstance(value, Ternary): + true_spec = self._type_spec_from_expr(value.true_val) + false_spec = self._type_spec_from_expr(value.false_val) + if (true_spec is not None + and true_spec.kind == "map" + and true_spec == false_spec): + return true_spec + # A Pine ``na`` arm acquires the other arm's map type. Keep this + # narrow to maps so unrelated scalar/array inference and generated + # output retain their established behavior. + if (true_spec is not None + and true_spec.kind == "map" + and isinstance(value.false_val, NaLiteral)): + return true_spec + if (false_spec is not None + and false_spec.kind == "map" + and isinstance(value.true_val, NaLiteral)): + return false_spec + return None + if isinstance(value, IfStmt): + def terminal_expr(body): + if not body: + return None + terminal = body[-1] + return terminal.expr if isinstance(terminal, ExprStmt) else terminal + + true_node = terminal_expr(value.body) + false_node = terminal_expr(value.else_body) + if true_node is None or false_node is None: + return None + true_spec = self._type_spec_from_expr(true_node) + false_spec = self._type_spec_from_expr(false_node) + if (true_spec is not None + and true_spec.kind == "map" + and true_spec == false_spec): + return true_spec + if (true_spec is not None + and true_spec.kind == "map" + and isinstance(false_node, NaLiteral)): + return true_spec + if (false_spec is not None + and false_spec.kind == "map" + and isinstance(true_node, NaLiteral)): + return false_spec + return None if isinstance(value, FuncCall): cal = value.callee func = cal.member if isinstance(cal, MemberAccess) else None @@ -237,6 +283,28 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: key = self._type_spec_from_hint(targs[0]) if len(targs) > 0 else TypeSpec.primitive("string") val = self._type_spec_from_hint(targs[1]) if len(targs) > 1 else TypeSpec.primitive("float") return TypeSpec.map(key or TypeSpec.primitive("string"), val or TypeSpec.primitive("float")) + if ns == "map" and func in { + "put", "get", "remove", "contains", "size", "keys", + "values", "copy", "put_all", "clear", + } and value.args: + recv_spec = self._type_spec_from_expr(value.args[0]) + if recv_spec is not None and recv_spec.kind == "map": + if func in ("put", "get", "remove"): + return recv_spec.value + if func == "keys": + return TypeSpec.array( + recv_spec.key or TypeSpec.primitive("string") + ) + if func == "values": + return TypeSpec.array( + recv_spec.value or TypeSpec.primitive("float") + ) + if func == "copy": + return recv_spec + if func == "contains": + return TypeSpec.primitive("bool") + if func == "size": + return TypeSpec.primitive("int") if ns == "str" and func == "split": return TypeSpec.array(TypeSpec.primitive("string")) if ns == "ta" and func == "pivot_point_levels": @@ -263,12 +331,18 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: if func in ("copy", "slice"): return recv_spec if recv_spec is not None and recv_spec.kind == "map": - if func in ("get", "remove"): + if func in ("put", "get", "remove"): return recv_spec.value if func == "keys": return TypeSpec.array(recv_spec.key or TypeSpec.primitive("string")) if func == "values": return TypeSpec.array(recv_spec.value or TypeSpec.primitive("float")) + if func == "copy": + return recv_spec + if func == "contains": + return TypeSpec.primitive("bool") + if func == "size": + return TypeSpec.primitive("int") if recv_spec is not None and recv_spec.kind == "matrix": if func in ("copy", "submatrix", "transpose", "concat"): return recv_spec @@ -278,6 +352,24 @@ def _type_spec_from_expr(self, value: ASTNode | None) -> TypeSpec | None: return recv_spec.element if func == "eigenvalues": return TypeSpec.array(TypeSpec.primitive("float")) + if (recv_spec is not None + and recv_spec.kind == "udt" + and recv_spec.name): + method_key = f"{recv_spec.name}.{func}" + method_info = next( + ( + info + for info in getattr(self, "_func_infos", ()) + if info.name == method_key + and getattr(info, "is_udt_method", False) + ), + None, + ) + return_spec = getattr( + method_info, "return_type_spec", None + ) + if return_spec is not None: + return return_spec # 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): @@ -316,6 +408,80 @@ def _direct_terminal_return_expr(func_def) -> ASTNode | None: return last_stmt return None + def _terminal_map_selection_return_spec( + self, + value: ASTNode | None, + parameter_specs: dict[str, TypeSpec | None], + ) -> TypeSpec | None: + """Infer a map returned by a terminal ternary/block selection. + + Untyped UDF parameters do not have a lexical ``TypeSpec`` while their + definition is first analyzed. At a concrete call site, however, the + argument specs are known. Propagate those specs through only the two + Pine selection shapes whose result type is determined by compatible + branches: ``condition ? a : b`` and terminal ``if`` expressions. + + A direct ``na`` arm is context-typed by the opposite map arm. All + other unresolved or incompatible shapes return ``None`` so this helper + cannot turn a scalar/non-map UDF into a map-returning function. + """ + + def branch_terminal(node: ASTNode | None) -> ASTNode | None: + if not isinstance(node, IfStmt): + return node + if not node.body or not node.else_body: + return None + return node + + def body_terminal(body: list[ASTNode] | None) -> ASTNode | None: + if not body: + return None + terminal = body[-1] + return terminal.expr if isinstance(terminal, ExprStmt) else terminal + + def resolve(node: ASTNode | None) -> TypeSpec | None: + if node is None or isinstance(node, NaLiteral): + return None + if isinstance(node, Identifier) and node.name in parameter_specs: + spec = parameter_specs[node.name] + return spec if spec is not None and spec.kind == "map" else None + if isinstance(node, Ternary): + return compatible(node.true_val, node.false_val) + if isinstance(node, IfStmt): + return compatible( + body_terminal(node.body), + body_terminal(node.else_body), + ) + spec = self._type_spec_from_expr(node) + return spec if spec is not None and spec.kind == "map" else None + + def compatible( + left_node: ASTNode | None, + right_node: ASTNode | None, + ) -> TypeSpec | None: + left_node = branch_terminal(left_node) + right_node = branch_terminal(right_node) + if left_node is None or right_node is None: + return None + left = resolve(left_node) + right = resolve(right_node) + if left is not None and right is not None: + return left if left == right else None + if left is not None and isinstance(right_node, NaLiteral): + return left + if right is not None and isinstance(left_node, NaLiteral): + return right + return None + + if isinstance(value, Ternary): + return compatible(value.true_val, value.false_val) + if isinstance(value, IfStmt): + return compatible( + body_terminal(value.body), + body_terminal(value.else_body), + ) + return None + def _terminal_map_call_return( self, value: ASTNode | None, diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 0f46f48..6a94fbc 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -752,6 +752,7 @@ def __init__(self, ctx: AnalyzerContext) -> None: 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 + # the per-type arenas) on this flag so non-drawing strategies stay @@ -1431,6 +1432,64 @@ def _detect_matrix_usage(self) -> bool: return True return False + def _detect_map_usage(self) -> bool: + """True when emitted C++ needs the PineMap handle/runtime helpers. + + The call scan catches constructors and built-in operations. TypeSpec + scans additionally catch typed ``na`` maps and map fields that never + execute a map call, so their declarations still receive map.hpp and + map-aware COOF checkpoint support. + """ + def contains_map( + spec: TypeSpec | None, visiting_udts: frozenset[str] = frozenset() + ) -> bool: + if spec is None: + return False + if spec.kind == "map": + return True + if spec.kind in {"array", "matrix"}: + return contains_map(spec.element, visiting_udts) + if spec.kind == "udt" and spec.name: + if spec.name in visiting_udts: + return False + nested_visiting = visiting_udts | {spec.name} + return any( + contains_map(field_spec, nested_visiting) + for field_spec in self._udt_field_type_specs.get( + spec.name, {} + ).values() + ) + return False + + if self._map_vars: + return True + if any(contains_map(spec) for spec in self._collection_types.values()): + return True + if any( + contains_map(spec) + for fields in self._udt_field_type_specs.values() + for spec in fields.values() + ): + return True + for specs in self._func_collection_types.values(): + if any(contains_map(spec) for spec in specs.values()): + return True + for fi in self._func_info_map.values(): + if any(contains_map(spec) for spec in fi.param_type_specs): + return True + if contains_map(getattr(fi, "return_type_spec", None)): + return True + for node in self._walk_ast(self.ctx.ast): + if isinstance(node, FuncCall): + _fn, namespace = self._resolve_callee(node.callee) + if namespace == "map": + return True + if isinstance(node, VarDecl): + spec = self._type_spec_from_hint_name(node.type_hint) + if contains_map(spec): + return True + return False + # The type-inference helpers (_type_spec_*, _infer_type, _array_method_expr, # _map_method_expr, _template_args_from_call, ...) live on TypeInferer # — see codegen/types.py. @@ -2351,7 +2410,14 @@ def generate(self) -> str: if cpp_type == "int": cpp_type = "int64_t" if f.default: - default = self._visit_expr(f.default) + default = self._visit_rhs_value( + f.default, + target_cpp_type=( + cpp_type + if cpp_type.startswith("PineMap<") + else None + ), + ) else: default = self._default_for_spec(spec) lines.append(f" {cpp_type} {f.name} = {default};") @@ -2366,6 +2432,9 @@ def generate(self) -> str: 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) + # 1c. Enum constants + string tables for str.tostring(enumVar) for enum_name, members in self._enum_defs.items(): for i, member in enumerate(members): @@ -2557,12 +2626,24 @@ def generate(self) -> str: _is_udt_ctor_init = any( _init_str_s.startswith(f"{u}.new") for u in self._udt_defs ) - if (not _is_udt_ctor_init) and ( - "array.new" in _init_str_s or "array.from" in _init_str_s - or name in self._array_vars + exact_member_spec = self._global_collection_types.get(name) + exact_array_member = ( + exact_member_spec is not None + and exact_member_spec.kind == "array" + ) + if exact_array_member or ( + not _is_udt_ctor_init + and ( + "array.new" in _init_str_s + or "array.from" in _init_str_s + or name in self._array_vars + ) ): self._array_vars.add(name) - lines.append(f" {self._type_spec_to_cpp(self._array_spec_for_name(name))} {safe};") + array_spec = exact_member_spec or self._array_spec_for_name(name) + lines.append( + f" {self._type_spec_to_cpp(array_spec)} {safe};" + ) continue # Detect matrix vars from init expression OR from the set # populated by ``_register_global_aggregate_member_types`` @@ -2579,9 +2660,17 @@ def generate(self) -> str: if "ta.pivot_point_levels" in str(init_str): lines.append(f" std::vector {safe};") continue - if "map.new" in str(init_str) or name in self._map_vars: + exact_map_member = ( + exact_member_spec is not None + and exact_member_spec.kind == "map" + ) + if exact_map_member or ( + not _is_udt_ctor_init + and ("map.new" in str(init_str) or name in self._map_vars) + ): self._map_vars.add(name) - lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(name))} {safe};") + map_spec = exact_member_spec or self._map_spec_for_name(name) + lines.append(f" {self._type_spec_to_cpp(map_spec)} {safe};") continue # Detect UDT vars. Two signals: (1) the analyzer recorded an # explicit UDT type annotation in ``_udt_var_types`` - this is the diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 7060af6..c09a94d 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -110,6 +110,8 @@ def _emit_includes(self, lines: list[str]) -> None: lines.append('#include ') lines.append('#include ') lines.append('#include ') + if getattr(self, "_uses_map", False): + lines.append('#include ') lines.append("#include ") lines.append("#include ") lines.append("#include ") @@ -296,6 +298,119 @@ 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. + + 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. + """ + lines.extend([ + "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 map_type = PineMap<_PFKey, _PFValue>;", + " static_assert(map_type::snapshot_supported,", + ' "generated map checkpoints require primitive map values");', + " using snapshot_type = std::optional;", + " static snapshot_type take(const map_type& value) {", + " if (value.is_na()) return std::nullopt;", + " return value.snapshot();", + " }", + " static void restore(map_type& value, const snapshot_type& snapshot) {", + " if (!snapshot) {", + " value = map_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);", + " }", + " }", + "};", + "", + ]) + + # 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. + for type_name, fields in self._udt_defs.items(): + 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(" struct snapshot_type {") + for index, field_name in enumerate(checkpoint_fields): + lines.append( + " _PFCheckpointTraits<" + f"decltype({type_name}::{field_name})>::snapshot_type " + f"_pf_field_{index};" + ) + lines.append(" };") + lines.append( + f" static snapshot_type take(const {type_name}& 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})," + ) + lines.append(" };") + lines.append(" }") + lines.append( + f" static void restore({type_name}& value, " + "const snapshot_type& snapshot) {" + ) + for index, field_name in enumerate(checkpoint_fields): + lines.append( + " _PFCheckpointTraits<" + f"decltype({type_name}::{field_name})>::restore(" + f"value.{field_name}, snapshot._pf_field_{index});" + ) + 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. @@ -311,11 +426,19 @@ def _emit_script_state_hooks(self, lines: list[str], members: list[str]) -> None 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) lines.append(" struct _PFScriptState {") for idx, name in enumerate(members): - lines.append( - f" decltype(GeneratedStrategy::{name}) _pf_value_{idx};" - ) + if map_aware: + lines.append( + " _PFCheckpointTraits<" + f"decltype(GeneratedStrategy::{name})>::snapshot_type " + f"_pf_value_{idx};" + ) + else: + lines.append( + f" decltype(GeneratedStrategy::{name}) _pf_value_{idx};" + ) lines.append(" };") lines.append( " static_assert(std::is_copy_constructible_v<_PFScriptState>, " @@ -330,16 +453,29 @@ 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: - lines.append(f" {name},") + if map_aware: + lines.append( + " _PFCheckpointTraits<" + f"decltype(GeneratedStrategy::{name})>::take({name})," + ) + else: + lines.append(f" {name},") lines.append(" });") lines.append(" }") lines.append("") lines.append(" void restore_script_state() override {") lines.append(" if (!_pf_script_state_checkpoint_) return;") for idx, name in enumerate(members): - lines.append( - f" this->{name} = _pf_script_state_checkpoint_->_pf_value_{idx};" - ) + if map_aware: + lines.append( + " _PFCheckpointTraits<" + f"decltype(GeneratedStrategy::{name})>::restore(" + f"this->{name}, _pf_script_state_checkpoint_->_pf_value_{idx});" + ) + else: + lines.append( + f" this->{name} = _pf_script_state_checkpoint_->_pf_value_{idx};" + ) lines.append(" }") lines.append("") lines.append(" void commit_script_state() override {") @@ -731,7 +867,13 @@ def _emit_on_bar(self, lines: list[str]) -> None: if name in self._map_vars: for stmt in self.ctx.ast.body: if isinstance(stmt, VarDecl) and stmt.name == name: - cpp_val = self._visit_expr(stmt.value) + cpp_val = self._visit_rhs_value( + stmt.value, + name, + target_cpp_type=self._type_spec_to_cpp( + self._map_spec_for_name(name) + ), + ) lines.append(f" {safe} = {cpp_val};") break continue @@ -1045,11 +1187,24 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # (egoigor's ``method slope(line ln)``) must emit ``Line&`` not # the unknown ``line&``. Register _udt_param_udt so the body's # getters dispatch through the §4.3 drawing path (L.6d / U.5). - recv_cpp = DRAWING_TYPE_TO_CPP.get(fi.udt_type_name, fi.udt_type_name) - cpp_t = 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 + recv_spec = ( + fi.param_type_specs[i] + if i < len(fi.param_type_specs) + else None + ) + if recv_spec is not None and recv_spec.kind == "map": + # A map method receiver is a copied ID handle. Mutations + # reach the caller's map while rebinds stay method-local. + spec = recv_spec + cpp_t = self._type_spec_to_cpp(recv_spec) + else: + recv_cpp = DRAWING_TYPE_TO_CPP.get( + fi.udt_type_name, fi.udt_type_name + ) + cpp_t = 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 elif fi.name == "isInSession" and i < 2: cpp_t = "std::string" elif p in func_sv: @@ -1073,7 +1228,8 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No if elem is not None and elem.kind == "udt": self._udt_param_udt[p] = elem.name self._udt_param_udt[self._safe_name(p)] = elem.name - cpp_t = f"{cpp_t}&" + if spec.kind == "array": + cpp_t = f"{cpp_t}&" elif i < len(fi.param_types): pt = fi.param_types[i] cpp_t = PINE_TYPE_TO_CPP.get(pt, "double") @@ -1104,6 +1260,9 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No ret_type = self._type_spec_to_cpp(fi.return_type_spec) else: ret_type = PINE_TYPE_TO_CPP.get(fi.return_type, "double") + map_return_cpp_type = ( + ret_type if ret_type.startswith("PineMap<") else None + ) # For per-call-site variants, suffix the function name and activate TA + var remapping func_name = self._emit_udt_method_cpp_name(fi) if is_udt else self._func_safe_name(fi.name) @@ -1214,7 +1373,10 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No # through to the default return. self._visit_stmt(node.body[0], lines, indent=2) elif expr: - lines.append(f" return {self._visit_expr(expr)};") + lines.append( + " return " + f"{self._visit_rhs_value(expr, target_cpp_type=map_return_cpp_type)};" + ) emitted_return = True else: for i, s in enumerate(node.body): @@ -1228,7 +1390,10 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No if self._call_is_void(s.expr) or self._is_skip_expr(s.expr): self._visit_stmt(s, lines, indent=2) else: - lines.append(f" return {self._visit_expr(s.expr)};") + lines.append( + " return " + f"{self._visit_rhs_value(s.expr, target_cpp_type=map_return_cpp_type)};" + ) emitted_return = True elif i == len(node.body) - 1 and isinstance(s, (SwitchStmt, IfStmt)): # Switch/if as last statement = return expression in PineScript @@ -1242,7 +1407,13 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No else: default_ret = self._default_for_type(ret_type) lines.append(f" {ret_type} _func_ret = {default_ret};") - self._visit_if_switch_expr(s, "_func_ret", lines, indent=2) + self._visit_if_switch_expr( + s, + "_func_ret", + lines, + indent=2, + target_cpp_type=map_return_cpp_type, + ) lines.append(f" return _func_ret;") emitted_return = True else: @@ -1349,7 +1520,16 @@ def activate_member() -> None: if (is_drawing or is_udt) and isinstance(init_ast, NaLiteral): activate_member() continue - init_cpp = self._visit_expr(init_ast) + init_cpp = self._visit_rhs_value( + init_ast, + name, + target_cpp_type=( + self._type_spec_to_cpp(collection_spec) + if collection_spec is not None + and collection_spec.kind == "map" + else None + ), + ) # A bare-``na`` initializer for an int/int64_t/bool ``var`` member # must be typed (``na()``), mirroring the class-scope ctor # init (``_typed_na_init`` at _emit_constructor); a raw ``na()`` diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 483b792..3608365 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -674,16 +674,16 @@ def _checked_array_percentrank(a: str, args: list[str]) -> str: } MAP_METHODS = { - "put": lambda m, args: f"({m}[{args[0]}] = {args[1]})", - "get": lambda m, args: f"({m}.count({args[0]}) ? {m}[{args[0]}] : na())", - "remove": lambda m, args: f"[&](){{ auto it={m}.find({args[0]}); if(it!={m}.end()){{ auto v=it->second; {m}.erase(it); return v; }} return na(); }}()", - "contains": lambda m, args: f"({m}.count({args[0]}) > 0)", - "size": lambda m, args: f"(double){m}.size()", + "put": lambda m, args: f"{m}.put({args[0]}, {args[1]})", + "get": lambda m, args: f"{m}.get({args[0]})", + "remove": lambda m, args: f"{m}.remove({args[0]})", + "contains": lambda m, args: f"{m}.contains({args[0]})", + "size": lambda m, args: f"{m}.size()", "clear": lambda m, args: f"{m}.clear()", - "keys": lambda m, args: f"[&](){{ std::vector v; for(auto& p:{m}) v.push_back(p.first); return v; }}()", - "values": lambda m, args: f"[&](){{ std::vector v; for(auto& p:{m}) v.push_back(p.second); return v; }}()", - "copy": lambda m, args: f"std::unordered_map({m})", - "put_all": lambda m, args: f"{m}.insert({args[0]}.begin(), {args[0]}.end())", + "keys": lambda m, args: f"{m}.keys()", + "values": lambda m, args: f"{m}.values()", + "copy": lambda m, args: f"{m}.copy()", + "put_all": lambda m, args: f"{m}.put_all({args[0]})", } # Declared signature order after removing the method receiver ``id``. This is diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 4210524..05fbb60 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -145,7 +145,7 @@ def _type_spec_to_cpp(self, spec: TypeSpec | None) -> str: if spec.kind == "array": return f"std::vector<{self._type_spec_to_cpp(spec.element)}>" if spec.kind == "map": - return f"std::unordered_map<{self._type_spec_to_cpp(spec.key)}, {self._type_spec_to_cpp(spec.value)}>" + return f"PineMap<{self._type_spec_to_cpp(spec.key)}, {self._type_spec_to_cpp(spec.value)}>" if spec.kind == "matrix": elem = self._type_spec_to_cpp(spec.element) if spec.element.kind == "primitive" and spec.element.name == "float": @@ -162,7 +162,7 @@ def _default_for_type(cpp_type: str) -> str: return "false" if cpp_type == "int": return "0" - if cpp_type.startswith("std::vector") or cpp_type.startswith("std::unordered_map"): + if cpp_type.startswith("std::vector") or cpp_type.startswith("PineMap"): return f"{cpp_type}()" return "0.0" @@ -180,7 +180,7 @@ def _default_for_spec(self, spec: TypeSpec | None) -> str: return f"{DRAWING_TYPE_TO_CPP[spec.name]}{{}}" return f"{spec.name}{{}}" cpp_type = self._type_spec_to_cpp(spec) - if cpp_type.startswith("std::vector") or cpp_type.startswith("std::unordered_map"): + if cpp_type.startswith("std::vector") or cpp_type.startswith("PineMap"): return f"{cpp_type}()" return self._default_for_type(cpp_type) @@ -416,22 +416,62 @@ def _type_spec_from_expr(self, node) -> TypeSpec | None: key = self._type_spec_from_hint_name(targs[0]) if len(targs) > 0 else TypeSpec.primitive("string") val = self._type_spec_from_hint_name(targs[1]) if len(targs) > 1 else TypeSpec.primitive("float") return TypeSpec.map(key or TypeSpec.primitive("string"), val or TypeSpec.primitive("float")) + if namespace == "map" and func_name in { + "put", "get", "remove", "contains", "size", "keys", + "values", "copy", "put_all", "clear", + } and node.args: + receiver_spec = self._type_spec_from_expr(node.args[0]) + if receiver_spec is not None and receiver_spec.kind == "map": + if func_name in ("put", "get", "remove"): + return receiver_spec.value + if func_name == "keys": + return TypeSpec.array( + receiver_spec.key or TypeSpec.primitive("string") + ) + if func_name == "values": + return TypeSpec.array( + receiver_spec.value or TypeSpec.primitive("float") + ) + if func_name == "copy": + return receiver_spec + if func_name == "contains": + return TypeSpec.primitive("bool") + if func_name == "size": + return TypeSpec.primitive("int") if namespace in self._udt_defs and func_name == "new": return TypeSpec.udt(namespace) if isinstance(node.callee, MemberAccess): recv_spec = self._type_spec_from_expr(node.callee.object) + member_name = node.callee.member if recv_spec is not None and recv_spec.kind == "array": if func_name in ("get", "first", "last", "pop", "shift", "remove"): return recv_spec.element if func_name in ("copy", "slice"): return recv_spec if recv_spec is not None and recv_spec.kind == "map": - if func_name in ("get", "remove"): + if member_name in ("put", "get", "remove"): return recv_spec.value - if func_name == "keys": + if member_name == "keys": return TypeSpec.array(recv_spec.key or TypeSpec.primitive("string")) - if func_name == "values": + if member_name == "values": return TypeSpec.array(recv_spec.value or TypeSpec.primitive("float")) + if member_name == "copy": + return recv_spec + if member_name == "contains": + return TypeSpec.primitive("bool") + if member_name == "size": + return TypeSpec.primitive("int") + if (recv_spec is not None + and recv_spec.kind == "udt" + and recv_spec.name): + method_info = self._func_info_map.get( + f"{recv_spec.name}.{member_name}" + ) + return_spec = getattr( + method_info, "return_type_spec", None + ) + if return_spec is not None: + return return_spec if recv_spec is not None and recv_spec.kind == "matrix": if func_name in ("copy", "submatrix", "transpose", "concat"): return recv_spec @@ -571,35 +611,34 @@ def lower_receiver(recv: str) -> str: def _map_method_expr( self, map_expr: str, method: str, args: list[str], spec: TypeSpec | None = None, ) -> str: - """Lower ``map.method(...)`` to its C++ form using the receiver's spec for default-key/value typing.""" - spec = spec or TypeSpec.map(TypeSpec.primitive("string"), TypeSpec.primitive("float")) - key_cpp = self._type_spec_to_cpp(spec.key) - value_cpp = self._type_spec_to_cpp(spec.value) - map_cpp = self._type_spec_to_cpp(spec) - default_value = ( - 'std::string("")' if value_cpp == "std::string" - else ("false" if value_cpp == "bool" else self._default_for_type(value_cpp)) - ) + """Lower a Pine map operation to the ordered, alias-preserving runtime. + + ``PineMap`` owns all missing-value, insertion-order and explicit-copy + semantics. Keeping this lowering as a direct method delegation also + guarantees that each receiver and argument expression occurs exactly + once; the typed-parameter lane may still wrap arguments to impose + Pine's source evaluation order. + """ if method == "put": - return f"({map_expr}[{args[0]}] = {args[1]})" + return f"{map_expr}.put({args[0]}, {args[1]})" if method == "get": - return f"({map_expr}.count({args[0]}) ? {map_expr}[{args[0]}] : {default_value})" + return f"{map_expr}.get({args[0]})" if method == "remove": - return f"[&](){{ auto it={map_expr}.find({args[0]}); if(it!={map_expr}.end()){{ auto v=it->second; {map_expr}.erase(it); return v; }} return {default_value}; }}()" + return f"{map_expr}.remove({args[0]})" if method == "contains": - return f"({map_expr}.count({args[0]}) > 0)" + return f"{map_expr}.contains({args[0]})" if method == "size": - return f"(double){map_expr}.size()" + return f"{map_expr}.size()" if method == "clear": return f"{map_expr}.clear()" if method == "keys": - return f"[&](){{ std::vector<{key_cpp}> v; for(auto& p:{map_expr}) v.push_back(p.first); return v; }}()" + return f"{map_expr}.keys()" if method == "values": - return f"[&](){{ std::vector<{value_cpp}> v; for(auto& p:{map_expr}) v.push_back(p.second); return v; }}()" + return f"{map_expr}.values()" if method == "copy": - return f"{map_cpp}({map_expr})" + return f"{map_expr}.copy()" if method == "put_all": - return f"{map_expr}.insert({args[0]}.begin(), {args[0]}.end())" + return f"{map_expr}.put_all({args[0]})" # Defensive: support_checker rejects any map.* method not in SUPPORTED_MAP # (derived from MAP_METHODS, which mirrors this if-chain). Reaching here # means the checker was bypassed or the tables drifted. @@ -621,6 +660,15 @@ def _type_for_decl(self, node: VarDecl) -> str: if node.type_hint in self._udt_defs: return node.type_hint return PINE_TYPE_TO_CPP.get(node.type_hint, "double") + # The analyzer records exact callable-local collection bindings before + # its lexical scopes are popped. In particular, an inferred local + # such as ``selected = cond ? na : global_map`` has a map TypeSpec even + # though generic C++ expression inference sees ``na`` as ``double``. + # Consume only the map form here; arrays/matrices and every non-map + # declaration retain their established inference/output paths. + captured = self._callable_collection_bindings.get(id(node)) + if captured is not None and captured.kind == "map": + return self._type_spec_to_cpp(captured) # Drawing handle local (L-N6): a hintless local whose RHS resolves to a # drawing udt must declare as the handle struct, not the analyzer's # scalar default. Covers ``ln = arr.get(i)``, alias ``b = a``, field read @@ -734,11 +782,13 @@ def _na_reassign_cpp_type(self, name: str) -> str | None: for ``double`` (already the default lowering), so those paths are unchanged. """ - # Collections / UDT / drawing handles never take a scalar ``na()``: - # leave them to the drawing-na / default lowering in _visit_rhs_value. + # Maps use their default-constructed null ID for Pine ``na``. Arrays, + # matrices, UDTs and drawing handles retain their established paths. collection_spec = self._collection_spec_for_name(name) + if collection_spec is not None and collection_spec.kind == "map": + return self._type_spec_to_cpp(collection_spec) if ((collection_spec is not None - and collection_spec.kind in {"array", "map", "matrix"}) + and collection_spec.kind in {"array", "matrix"}) or name in self._udt_var_types): return None cpp_type: str | None = None @@ -1186,6 +1236,8 @@ def _infer_type(self, node) -> str: if recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name: fi_u = self._func_info_map.get(f"{recv_spec.name}.{member_name}") if fi_u is not None: + if getattr(fi_u, "return_type_spec", None) is not None: + return self._type_spec_to_cpp(fi_u.return_type_spec) return PINE_TYPE_TO_CPP.get(fi_u.return_type, "double") spec = self._type_spec_from_expr(node) if spec is not None: diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 4469a16..9afdd30 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -133,6 +133,7 @@ from __future__ import annotations from ..ast_nodes import ( + ASTNode, FuncCall, Identifier, MemberAccess, @@ -470,13 +471,13 @@ def _map_call_arg_nodes( def _map_param_method_expr( self, map_expr: str, method: str, arg_nodes: list, spec: TypeSpec, ) -> str: - """Lower one typed-map parameter method with ordered, single-use args. + """Lower one map operation with receiver-first, ordered single use. - Existing map templates may reference a key more than once (``get`` / - ``contains`` / ``remove``), while C++ assignment evaluates the RHS of - ``put`` before its subscript LHS. Bind supplied expressions through - nested lambdas so this lane deterministically evaluates key then value - exactly once without changing established global/local map output. + C++17 sequences a member-call receiver before its arguments but does + not order sibling arguments. Pine requires receiver, key, then value. + Nested immediately-invoked lambdas bind the receiver first and every + argument in source/signature order, while retaining lvalue aliases and + extending temporary receiver lifetime through the operation. """ args = [self._visit_expr(arg) for arg in arg_nodes] occupied = "\n".join((map_expr, *args)) @@ -491,26 +492,299 @@ def _map_param_method_expr( bindings.append((token, arg)) self._map_param_arg_counter = counter + receiver_counter = getattr(self, "_map_receiver_counter", 0) + while True: + receiver_token = f"__pf_map_receiver_{receiver_counter}" + receiver_counter += 1 + if receiver_token not in occupied: + break + self._map_receiver_counter = receiver_counter + lowered = self._map_method_expr( - map_expr, method, [token for token, _arg in bindings], spec + receiver_token, method, [token for token, _arg in bindings], spec ) for token, arg in reversed(bindings): lowered = ( f"[&](auto&& {token})->decltype(auto){{ " f"return {lowered}; }}(({arg}))" ) + return ( + f"[&](auto&& {receiver_token})->decltype(auto){{ " + f"return {lowered}; }}(({map_expr}))" + ) + + def _expr_contains_map_operation( + self, + node, + visiting_callables: frozenset[str] = frozenset(), + lexical_specs: dict[str, TypeSpec | None] | None = None, + ) -> bool: + """Whether an expression directly or transitively operates on a map. + + C++17 does not order sibling function arguments. We keep ordinary + calls byte-identical and only stage calls whose argument tree can + observe map identity/state (including a user helper whose body performs + such an operation). + """ + if not isinstance(node, ASTNode): + return False + lexical_specs = lexical_specs or {} + if isinstance(node, FuncCall): + callee = node.callee + if isinstance(callee, MemberAccess): + if ( + isinstance(callee.object, Identifier) + and callee.object.name == "map" + and callee.member in (set(MAP_METHODS) | {"new"}) + ): + return True + receiver_spec = self._map_effect_type_spec( + callee.object, lexical_specs + ) + if ( + receiver_spec is not None + and receiver_spec.kind == "map" + and callee.member in MAP_METHODS + ): + return True + + info_key, func_info = self._map_effect_callable_info( + node, lexical_specs + ) + if ( + func_info is not None + and getattr(func_info, "node", None) is not None + and info_key not in visiting_callables + ): + next_visiting = visiting_callables | {info_key} + child_specs = self._map_effect_callable_specs( + func_info, + node, + lexical_specs, + ) + if any( + self._expr_contains_map_operation( + child, + next_visiting, + child_specs, + ) + for child in func_info.node.body + ): + return True + + for value in vars(node).values(): + if isinstance(value, ASTNode): + if self._expr_contains_map_operation( + value, visiting_callables, lexical_specs + ): + return True + elif isinstance(value, list): + if any( + self._expr_contains_map_operation( + item, visiting_callables, lexical_specs + ) + for item in value + if isinstance(item, ASTNode) + ): + return True + elif isinstance(value, dict): + if any( + self._expr_contains_map_operation( + item, visiting_callables, lexical_specs + ) + for item in value.values() + if isinstance(item, ASTNode) + ): + return True + return False + + def _map_effect_type_spec( + self, + node, + lexical_specs: dict[str, TypeSpec | None], + ) -> TypeSpec | None: + """Resolve an expression type in the callable being inspected. + + The ordinary codegen resolver describes the function currently being + emitted, not a transitive helper whose AST is being audited for effects. + Respect that helper's parameter/local bindings first, then fall back to + the established expression inference for globals and constructors. + """ + if isinstance(node, Identifier) and node.name in lexical_specs: + return lexical_specs[node.name] + if isinstance(node, MemberAccess): + owner = self._map_effect_type_spec(node.object, lexical_specs) + if owner is not None and owner.kind == "udt" and owner.name: + return (self._udt_field_type_specs.get(owner.name) or {}).get( + node.member + ) + if isinstance(node, FuncCall): + _key, info = self._map_effect_callable_info(node, lexical_specs) + return_spec = getattr(info, "return_type_spec", None) + if return_spec is not None: + return return_spec + return self._type_spec_from_expr(node) + + def _map_effect_callable_info( + self, + node: FuncCall, + lexical_specs: dict[str, TypeSpec | None], + ): + """Resolve a plain UDF or UDT method for transitive effect analysis.""" + callee = node.callee + if isinstance(callee, Identifier): + key = callee.name + return key, self._func_info_map.get(key) + if isinstance(callee, MemberAccess): + receiver_spec = self._map_effect_type_spec( + callee.object, lexical_specs + ) + if (receiver_spec is not None + and receiver_spec.kind == "udt" + and receiver_spec.name): + key = f"{receiver_spec.name}.{callee.member}" + return key, self._func_info_map.get(key) + return "", None + + def _map_effect_callable_specs( + self, + func_info, + call: FuncCall, + caller_specs: dict[str, TypeSpec | None], + ) -> dict[str, TypeSpec | None]: + """Build the inspected callee's lexical TypeSpec environment. + + Declared and already-inferred parameter specs are authoritative. Any + unresolved slot is filled from this exact call's argument in the + caller's lexical environment, which also lets an untyped map flow + through multiple helper layers before analyzer call-site inference has + materialized every nested parameter. + """ + func_node = func_info.node + params = list(func_node.params) if func_node is not None else [] + declared_specs = list(getattr(func_info, "param_type_specs", ()) or ()) + result: dict[str, TypeSpec | None] = { + param: declared_specs[index] if index < len(declared_specs) else None + for index, param in enumerate(params) + } + + 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) + ) + for index, actual in enumerate(positional): + if index >= len(params) or result[params[index]] is not None: + continue + result[params[index]] = self._map_effect_type_spec( + 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 + ) + + # Direct callable locals already carry analyzer-owned lexical + # provenance. They override same-named parameters/globals exactly as + # they do during real function emission. + result.update(self._func_collection_types.get(func_info.name, {})) + return result + + def _ordered_user_call_expr( + self, + call_head: str, + arg_nodes: list, + arg_exprs: list[str], + *, + source_order_nodes: list | None = None, + force_stage: bool = False, + ) -> str: + """Emit a user call with deterministic argument evaluation when needed. + + ``arg_nodes``/``arg_exprs`` are in destination parameter order. + ``source_order_nodes`` records the caller's written order, allowing + named arguments to be evaluated as written while still occupying their + declared parameter slots. + """ + raw = f"{call_head}({', '.join(arg_exprs)})" + has_map_effect = any( + self._expr_contains_map_operation(node) for node in arg_nodes + ) + if not force_stage and (len(arg_exprs) < 2 or not has_map_effect): + return raw + + occupied = "\n".join((call_head, *arg_exprs)) + counter = getattr(self, "_ordered_call_arg_counter", 0) + tokens: list[str] = [] + for _ in arg_exprs: + while True: + token = f"__pf_call_arg_{counter}" + counter += 1 + if token not in occupied: + break + tokens.append(token) + self._ordered_call_arg_counter = counter + + remaining = list(range(len(arg_nodes))) + evaluation_order: list[int] = [] + for source_node in source_order_nodes or arg_nodes: + for index in remaining: + if arg_nodes[index] is source_node: + evaluation_order.append(index) + remaining.remove(index) + break + evaluation_order.extend(remaining) + + lowered = f"{call_head}({', '.join(tokens)})" + for index in reversed(evaluation_order): + lowered = ( + f"[&](auto&& {tokens[index]})->decltype(auto){{ " + f"return {lowered}; }}(({arg_exprs[index]}))" + ) return lowered def _visit_func_call(self, node: FuncCall) -> str: callee = node.callee if isinstance(callee, MemberAccess): recv_spec = self._type_spec_from_expr(callee.object) + if ( + recv_spec is not None + and recv_spec.kind == "map" + and not isinstance(callee.object, (Identifier, MemberAccess)) + and callee.member in MAP_METHODS + ): + # Returned, constructed and selected map IDs are ordinary + # receivers too. Parenthesize the expression so ternaries and + # other compound producers remain one C++ receiver expression. + arg_nodes = self._map_call_arg_nodes( + callee.member, + node, + functional=False, + allow_keywords=False, + ) + receiver = f"({self._visit_expr(callee.object)})" + return self._map_param_method_expr( + receiver, + callee.member, + arg_nodes, + recv_spec, + ) if recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name: mk = f"{recv_spec.name}.{callee.member}" fi_u = self._func_info_map.get(mk) 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(callee.object) + receiver_root = callee.object + 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 @@ -521,7 +795,21 @@ def _visit_func_call(self, node: FuncCall) -> str: param_defaults, lambda x: x, ) rest = [self._visit_expr(a) for a in rest_nodes] - return f"{fn_cpp}({', '.join([recv_e] + rest)})" + return self._ordered_user_call_expr( + fn_cpp, + [callee.object, *rest_nodes], + [recv_e, *rest], + source_order_nodes=[ + callee.object, + *node.args, + *node.kwargs.values(), + ], + # Generated UDT methods accept ``T&``. A constructor or + # function-return receiver is a C++ rvalue; bind it to a + # named forwarding-reference lambda parameter first so + # the method sees a valid lvalue for the full call. + force_stage=stage_receiver, + ) # Drawing method dispatch (spec §4.3 / L.1). A KNOWN drawing method on a # receiver that resolves to a drawing udt — gated on the METHOD NAME @@ -596,10 +884,10 @@ def _visit_func_call(self, node: FuncCall) -> str: functional=False, allow_keywords=False, ) - return self._map_method_expr( + return self._map_param_method_expr( recv, meth, - [self._visit_expr(arg) for arg in arg_nodes], + arg_nodes, recv_spec, ) raw_args = [self._visit_expr(a) for a in node.args] @@ -692,8 +980,9 @@ def _visit_func_call(self, node: FuncCall) -> str: functional=False, allow_keywords=False, ) - margs = [self._visit_expr(a) for a in arg_nodes] - return self._map_method_expr(m, meth_raw, margs, recv_spec) + return self._map_param_method_expr( + m, meth_raw, arg_nodes, recv_spec + ) if ( recv_spec is not None and recv_spec.kind == "array" @@ -748,7 +1037,16 @@ def _visit_func_call(self, node: FuncCall) -> str: param_defaults, lambda x: x, ) rest = [self._visit_expr(a) for a in rest_nodes] - return f"{fn_cpp}({', '.join([recv_e] + rest)})" + return self._ordered_user_call_expr( + fn_cpp, + [obj, *rest_nodes], + [recv_e, *rest], + source_order_nodes=[ + obj, + *node.args, + *node.kwargs.values(), + ], + ) args = ", ".join(self._visit_expr(a) for a in node.args) recv = self._visit_expr(obj) meth = meth_raw @@ -860,8 +1158,9 @@ def _visit_func_call(self, node: FuncCall) -> str: functional=False, allow_keywords=False, ) - args = [self._visit_expr(a) for a in arg_nodes] - return self._map_method_expr(m, func_name, args, namespace_spec) + return self._map_param_method_expr( + m, func_name, arg_nodes, namespace_spec + ) # map.method(m, args...) — functional form if namespace == "map": @@ -877,12 +1176,13 @@ def _visit_func_call(self, node: FuncCall) -> str: ) if func_name == "new": spec = self._type_spec_from_expr(node) or TypeSpec.map(TypeSpec.primitive("string"), TypeSpec.primitive("float")) - return f"{self._type_spec_to_cpp(spec)}()" + return f"{self._type_spec_to_cpp(spec)}::new_()" if func_name in MAP_METHODS and node.args: m = self._visit_expr(node.args[0]) - rest = [self._visit_expr(a) for a in node.args[1:]] spec = self._type_spec_from_expr(node.args[0]) if node.args else None - return self._map_method_expr(m, func_name, rest, spec) + return self._map_param_method_expr( + m, func_name, list(node.args[1:]), spec + ) return "0" if ( @@ -1456,21 +1756,21 @@ def _visit_func_call(self, node: FuncCall) -> str: if namespace in self._udt_defs and func_name == "new": fields = self._udt_defs[namespace] field_names = [f.name for f in fields] - init_vals = {} + init_nodes = {} for i, a in enumerate(node.args): if i < len(field_names): - init_vals[field_names[i]] = self._visit_expr(a) + init_nodes[field_names[i]] = a for k, v in node.kwargs.items(): - init_vals[k] = self._visit_expr(v) + init_nodes[k] = v field_inits = [] field_specs = self._udt_field_type_specs.get(namespace, {}) for f in fields: - val = None - if f.name in init_vals: - val = init_vals[f.name] + value_node = None + if f.name in init_nodes: + value_node = init_nodes[f.name] elif f.default: - val = self._visit_expr(f.default) - if val is not None: + value_node = f.default + if value_node is not None: # Fix narrowing: brace-init (``T{.field = v}``) disallows # narrowing. Pine ``int`` UDT fields are emitted as # ``int64_t`` (see base.py) but are initialised from @@ -1479,11 +1779,22 @@ def _visit_func_call(self, node: FuncCall) -> str: f_cpp_type = self._type_spec_to_cpp(field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name)) if f_cpp_type == "int": f_cpp_type = "int64_t" + val = self._visit_rhs_value( + value_node, + target_cpp_type=( + f_cpp_type + if f_cpp_type.startswith("PineMap<") + else None + ), + ) if f_cpp_type == "int64_t": if "na" in val: val = val.replace("na()", "na()") else: val = f"(int64_t)({val})" + elif (f_cpp_type.startswith("PineMap<") + 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). @@ -1572,8 +1883,24 @@ def _visit_arg_for_series(arg_node, arg_idx): f"else {member}.update(_sv); " f"return {member}; }}())" ) + # A concrete map specialization learned through an untyped + # wrapper chain also target-types its call arguments. In + # particular, ``choose(cond, value, na)`` must pass a typed null + # PineMap handle rather than the generic ``na()``. Every + # non-map destination remains on the byte-identical expression + # path below. + if fi_lookup is not None: + param_specs = getattr(fi_lookup, "param_type_specs", []) or [] + if arg_idx < len(param_specs): + param_spec = param_specs[arg_idx] + if param_spec is not None and param_spec.kind == "map": + return self._visit_rhs_value( + arg_node, + target_cpp_type=self._type_spec_to_cpp(param_spec), + ) return self._visit_expr(arg_node) + ordered_arg_nodes: list = [] if node.kwargs: # Try to resolve kwargs using FuncInfo params for user-defined functions fi = self._func_info_map.get(func_name) @@ -1581,16 +1908,28 @@ def _visit_arg_for_series(arg_node, arg_idx): param_names = list(fi.node.params) # params is list[str] # Merge kwargs then visit with series awareness merged = _merge_kwargs(node.args, node.kwargs, param_names, lambda a: a) + ordered_arg_nodes = list(merged) all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(merged)] elif sigs.is_intrinsic_function(namespace, func_name): # Known intrinsic — use signature registry for kwargs resolution param_names = sigs.get_param_names(namespace, func_name) - all_args = _merge_kwargs(node.args, node.kwargs, param_names, self._visit_expr) + merged = _merge_kwargs( + node.args, node.kwargs, param_names, lambda a: a + ) + ordered_arg_nodes = list(merged) + all_args = [self._visit_expr(a) for a in merged] else: # Unknown function: positional args + kwargs values as fallback - all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)] - all_args.extend(self._visit_expr(v) for v in node.kwargs.values()) + ordered_arg_nodes = [*node.args, *node.kwargs.values()] + all_args = [ + _visit_arg_for_series(a, i) + for i, a in enumerate(node.args) + ] + all_args.extend( + self._visit_expr(v) for v in node.kwargs.values() + ) else: + ordered_arg_nodes = list(node.args) all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)] # Drawing-style/visual CONSTANT passed positionally into a user function's # ``string`` parameter: ``label.style_*`` / ``size.*`` / other @@ -1609,6 +1948,7 @@ def _visit_arg_for_series(arg_node, arg_idx): if fi and fi.node and fi.name == "isInSession" and len(fi.node.params) >= 2 and len(all_args) == 1: # Mirror Pine default `timeframe.period` instead of hard-coding 15m. all_args.append("script_tf_") + ordered_arg_nodes.append(None) prefix = f"{namespace}::" if namespace else "" # Use safe name for user-defined functions to avoid member name collision emit_name = self._func_safe_name(func_name) if func_name in self._func_names else func_name @@ -1638,7 +1978,15 @@ def _visit_arg_for_series(arg_node, arg_idx): # Inside a per-call-site variant: propagate call-site index to # sub-functions that also have variants (for state isolation) emit_name = f"{self._func_safe_name(func_name)}_cs{self._active_call_site_idx}" - return f"{prefix}{emit_name}({', '.join(all_args)})" + call_head = f"{prefix}{emit_name}" + if namespace is None and func_name in self._func_names: + return self._ordered_user_call_expr( + call_head, + ordered_arg_nodes, + all_args, + source_order_nodes=[*node.args, *node.kwargs.values()], + ) + return f"{call_head}({', '.join(all_args)})" def _coerce_drawing_style_string_args(self, func_name, arg_nodes, all_args) -> None: """In-place coerce positional args bound to a ``std::string`` user-function diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 1181e45..ab0dbc2 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -256,12 +256,38 @@ def _visit_rhs_value(self, value_node, target_name: str | None = None, ``string s = na;`` would both emit ``na()`` and fail to compile (no viable ``operator=`` / conversion). Every other RHS lowers unchanged. """ - if target_name and self._is_na_expr(value_node): + if self._is_na_expr(value_node): draw_default = self._drawing_na_default(target_name) if draw_default is not None: return draw_default + if target_cpp_type and target_cpp_type.startswith("PineMap<"): + # PineMap's default constructor is the typed ``na`` ID. A + # map.new call is the only operation that allocates storage. + return f"{target_cpp_type}{{}}" if target_cpp_type in ("std::string", "int", "int64_t", "bool"): return f"na<{target_cpp_type}>()" + if (target_cpp_type + and target_cpp_type.startswith("PineMap<") + and isinstance(value_node, Ternary)): + # C++ cannot deduce a common type for ``na()`` and a + # PineMap handle. Pine's ternary is target typed, so propagate the + # declared/reassignment target into both arms. Keep this map-only: + # every non-map ternary remains byte-for-byte on the established + # generic expression path. + condition = self._visit_expr(value_node.condition) + true_value = self._visit_rhs_value( + value_node.true_val, + target_name, + target_cpp_type=target_cpp_type, + ) + false_value = self._visit_rhs_value( + value_node.false_val, + target_name, + target_cpp_type=target_cpp_type, + ) + return ( + f"(({condition}) ? ({true_value}) : ({false_value}))" + ) return self._visit_expr(value_node) def _visit_ident(self, node: Identifier) -> str: diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 59320cd..0613e3f 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -323,6 +323,30 @@ def _concat_receiver_name(self, expr) -> str | None: return recv return None + def _map_target_cpp_type( + self, + *, + name: str | None = None, + target_node=None, + type_hint: str | None = None, + ) -> str | None: + """Return the exact C++ map type for a contextual RHS target. + + Declarations need the explicit hint before their lexical collection + binding is activated; reassignments can use the active name registry, + while UDT fields are resolved from the target expression itself. + Returning ``None`` for every non-map shape deliberately preserves the + established generic expression lowering byte-for-byte. + """ + spec = self._type_spec_from_hint_name(type_hint) if type_hint else None + if spec is None and target_node is not None: + spec = self._type_spec_from_expr(target_node) + if spec is None and name is not None: + spec = self._collection_spec_for_name(name) + if spec is None or spec.kind != "map": + return None + return self._type_spec_to_cpp(spec) + def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: # Primitive runtime var/varip initializers execute at the declaration # site under a dedicated once flag. This preserves source order (a @@ -483,10 +507,11 @@ def remember_local_type(cpp_type: str | None) -> None: self._map_vars.add(node.name) self._collection_types.setdefault(node.name, spec) cpp_type = self._type_spec_to_cpp(spec) + init = f"{cpp_type}::new_()" if is_global_member: - lines.append(f"{pad}{safe} = {cpp_type}();") + lines.append(f"{pad}{safe} = {init};") else: - lines.append(f"{pad}{cpp_type} {safe};") + lines.append(f"{pad}{cpp_type} {safe} = {init};") return # Visual/drawing function assignments (line.new, label.new, box.new, @@ -530,12 +555,26 @@ def remember_local_type(cpp_type: str | None) -> None: # If/switch expression: x = if cond ... else ... if isinstance(node.value, (IfStmt, SwitchStmt)): + cpp_type = self._type_for_decl(node) if not is_global_member else None + map_cpp_type = ( + cpp_type + if cpp_type is not None and cpp_type.startswith("PineMap<") + else self._map_target_cpp_type( + name=node.name, + type_hint=node.type_hint, + ) + ) if not is_global_member: - cpp_type = self._type_for_decl(node) default = self._default_for_type(cpp_type) lines.append(f"{pad}{cpp_type} {safe} = {default};") indent = len(pad) // 4 - self._visit_if_switch_expr(node.value, safe, lines, indent) + self._visit_if_switch_expr( + node.value, + safe, + lines, + indent, + target_cpp_type=map_cpp_type, + ) return # UDT lvalue alias (BUG C): a local initialised from a user-defined-UDT @@ -580,7 +619,12 @@ def remember_local_type(cpp_type: str | None) -> None: self._map_vars.add(node.name) elif coll_spec.kind == "matrix": self._matrix_specs[node.name] = coll_spec - lines.append(f"{pad}{cpp_type}& {safe} = {cpp_val};") + # PineMap is itself a shared-ID handle. Copying it by value is + # the correct alias operation and, unlike a C++ reference, + # keeps a later local rebind local. Arrays/matrices still need + # their established reference alias route. + ref = "" if coll_spec.kind == "map" else "&" + lines.append(f"{pad}{cpp_type}{ref} {safe} = {cpp_val};") return # Global-scope UDT array-element alias (BUG: Pine array elements of a @@ -601,7 +645,15 @@ def remember_local_type(cpp_type: str | None) -> None: # General declaration cpp_type = self._type_for_decl(node) if not is_global_member else None - cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type) + target_cpp_type = cpp_type or self._map_target_cpp_type( + name=node.name, + type_hint=node.type_hint, + ) + cpp_val = self._visit_rhs_value( + node.value, + node.name, + target_cpp_type=target_cpp_type, + ) if is_global_member: lines.append(f"{pad}{safe} = {cpp_val};") else: @@ -635,8 +687,18 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non if isinstance(node.value, (IfStmt, SwitchStmt)): target_name = self._get_target_name(node.target) safe = self._safe_name(target_name) if target_name else self._visit_expr(node.target) + map_cpp_type = self._map_target_cpp_type( + name=target_name, + target_node=node.target if target_name is None else None, + ) indent = len(pad) // 4 - self._visit_if_switch_expr(node.value, safe, lines, indent) + self._visit_if_switch_expr( + node.value, + safe, + lines, + indent, + target_cpp_type=map_cpp_type, + ) return # Get target name @@ -659,7 +721,12 @@ 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) - val_cpp = self._visit_expr(node.value) + target_cpp_type = self._map_target_cpp_type( + target_node=node.target, + ) + val_cpp = self._visit_rhs_value( + node.value, target_cpp_type=target_cpp_type + ) if node.op == ":=": lines.append(f"{pad}{target_cpp} = {val_cpp};") else: @@ -727,8 +794,9 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non # a double NaN is stored into an int/int64_t/bool member (UB, defeats # is_na()). Only computed for bare na — every other RHS is # unaffected. - tct = (self._na_reassign_cpp_type(target_name) - if self._is_na_expr(node.value) else None) + tct = self._map_target_cpp_type(name=target_name) + if tct is None and self._is_na_expr(node.value): + tct = self._na_reassign_cpp_type(target_name) val_cpp = self._visit_rhs_value(node.value, target_name, target_cpp_type=tct) if node.op == ":=": lines.append(f"{pad}{safe} = {val_cpp};") @@ -739,8 +807,9 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non else: lines.append(f"{pad}{safe} {node.op} {val_cpp};") else: - tct = (self._na_reassign_cpp_type(target_name) - if self._is_na_expr(node.value) else None) + tct = self._map_target_cpp_type(name=target_name) + if tct is None and self._is_na_expr(node.value): + tct = self._na_reassign_cpp_type(target_name) val_cpp = self._visit_rhs_value(node.value, target_name, target_cpp_type=tct) if node.op == ":=": lines.append(f"{pad}{safe} = {val_cpp};") @@ -916,12 +985,24 @@ def _visit_block_statements(self, body: list, lines: list[str], finally: self._pop_block_var_remap(saved) - def _emit_block_with_assign(self, body: list, target: str, - lines: list[str], indent: int) -> None: + def _emit_block_with_assign( + self, + body: list, + target: str, + lines: list[str], + indent: int, + target_cpp_type: str | None = None, + ) -> None: """Expression-body counterpart of :meth:`_visit_block_statements`.""" saved = self._push_block_var_remap(body) try: - self._emit_body_with_assign(body, target, lines, indent) + self._emit_body_with_assign( + body, + target, + lines, + indent, + target_cpp_type=target_cpp_type, + ) finally: self._pop_block_var_remap(saved) @@ -1070,10 +1151,67 @@ def _visit_for_in(self, node, lines: list[str], indent: int) -> None: if (idx < len(tuple_specs) and tuple_specs[idx] is not None): self._current_loop_var_specs[v] = tuple_specs[idx] + map_pair_loop = ( + iterable_spec is not None + and iterable_spec.kind == "map" + and node.vars is not None + and len(node.vars) == 2 + ) 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}) {{") + elif map_pair_loop: + # Pine map iteration exposes insertion-ordered ``[key, value]`` + # pairs. PineMap intentionally keeps its storage private, so take + # one handle copy (which aliases the same map ID), iterate keys(), + # and obtain each value through the public API. Binding the + # iterable once also preserves single-evaluation semantics for an + # arbitrary map-producing expression. + key_name, value_name = node.vars + authored_names = ( + set(self._all_bound_names) + | set(self._func_names) + | set(self._udt_defs) + ) + occupied_names = authored_names | { + self._safe_name(name) for name in authored_names + } | { + self._func_safe_name(name) for name in self._func_names + } + # Parameters are not statement bindings, so they are absent from + # ``_all_bound_names``. Include both their authored and C++-safe + # spellings before minting loop temporaries; otherwise a legal UDF + # or method parameter named ``__pf_map_iter_0`` (or, for ``_`` key + # loops, ``__pf_map_key_0``) is redeclared and then shadowed by the + # generated loop machinery. + active_params = set(self._current_func_param_types) + occupied_names.update(active_params) + occupied_names.update( + self._safe_name(name) for name in active_params + ) + while True: + fid = self._for_counter + self._for_counter += 1 + map_token = f"__pf_map_iter_{fid}" + internal_key = f"__pf_map_key_{fid}" + generated_names = {map_token} + if key_name == "_": + generated_names.add(internal_key) + if not (generated_names & occupied_names): + break + key_cpp = ( + self._safe_name(key_name) + if key_name != "_" + else internal_key + ) + lines.append(f"{pad}auto {map_token} = {iterable};") + lines.append(f"{pad}for (auto {key_cpp} : {map_token}.keys()) {{") + if value_name != "_": + value_cpp = self._safe_name(value_name) + lines.append( + f"{pad} auto {value_cpp} = {map_token}.get({key_cpp});" + ) elif node.vars: bindings = ", ".join(node.vars) lines.append(f"{pad}for (auto [{bindings}] : {iterable}) {{") @@ -1132,8 +1270,14 @@ def _visit_switch(self, node: SwitchStmt, lines: list[str], indent: int) -> None # _default_for_type lives on TypeInferer — see codegen/types.py. - def _emit_body_with_assign(self, body: list, target: str, - lines: list[str], indent: int) -> None: + def _emit_body_with_assign( + self, + body: list, + target: str, + lines: list[str], + indent: int, + target_cpp_type: str | None = None, + ) -> None: """Emit a body block where the last expression becomes an assignment.""" if not body: return @@ -1150,36 +1294,73 @@ def _emit_body_with_assign(self, body: list, target: str, if self._call_is_void(stmt.expr): self._visit_stmt(stmt, lines, indent) return - cpp = self._visit_expr(stmt.expr) + cpp = self._visit_rhs_value( + stmt.expr, + target_cpp_type=target_cpp_type, + ) pad = " " * indent lines.append(f"{pad}{target} = {cpp};") elif isinstance(stmt, IfStmt): # Nested if expression - self._visit_if_switch_expr(stmt, target, lines, indent) + self._visit_if_switch_expr( + stmt, + target, + lines, + indent, + target_cpp_type=target_cpp_type, + ) elif isinstance(stmt, SwitchStmt): - self._visit_if_switch_expr(stmt, target, lines, indent) + self._visit_if_switch_expr( + stmt, + target, + lines, + indent, + target_cpp_type=target_cpp_type, + ) else: self._visit_stmt(stmt, lines, indent) else: self._visit_stmt(stmt, lines, indent) - def _visit_if_switch_expr(self, node, target: str, - lines: list[str], indent: int) -> None: + def _visit_if_switch_expr( + self, + node, + target: str, + lines: list[str], + indent: int, + target_cpp_type: str | None = None, + ) -> None: """Emit an if/switch used as an expression, assigning to target.""" pad = " " * indent if isinstance(node, IfStmt): cond = self._visit_expr(node.condition) lines.append(f"{pad}if ({cond}) {{") - self._emit_block_with_assign(node.body, target, lines, indent + 1) + self._emit_block_with_assign( + node.body, + target, + lines, + indent + 1, + target_cpp_type=target_cpp_type, + ) lines.append(f"{pad}}}") if node.else_body: if len(node.else_body) == 1 and isinstance(node.else_body[0], IfStmt): lines[-1] = f"{pad}}} else" - self._visit_if_switch_expr(node.else_body[0], target, lines, indent) + self._visit_if_switch_expr( + node.else_body[0], + target, + lines, + indent, + target_cpp_type=target_cpp_type, + ) else: lines[-1] = f"{pad}}} else {{" self._emit_block_with_assign( - node.else_body, target, lines, indent + 1 + node.else_body, + target, + lines, + indent + 1, + target_cpp_type=target_cpp_type, ) lines.append(f"{pad}}}") elif isinstance(node, SwitchStmt): @@ -1192,7 +1373,11 @@ def _visit_if_switch_expr(self, node, target: str, case_val = self._visit_expr(case_expr) lines.append(f"{pad}{prefix} ({expr_var} == {case_val}) {{") self._emit_block_with_assign( - case_body, target, lines, indent + 1 + case_body, + target, + lines, + indent + 1, + target_cpp_type=target_cpp_type, ) lines.append(f"{pad}}}") else: @@ -1201,12 +1386,20 @@ def _visit_if_switch_expr(self, node, target: str, cond = self._visit_expr(case_expr) lines.append(f"{pad}{prefix} ({cond}) {{") self._emit_block_with_assign( - case_body, target, lines, indent + 1 + case_body, + target, + lines, + indent + 1, + target_cpp_type=target_cpp_type, ) lines.append(f"{pad}}}") if node.default_body: lines.append(f"{pad}else {{") self._emit_block_with_assign( - node.default_body, target, lines, indent + 1 + node.default_body, + target, + lines, + indent + 1, + target_cpp_type=target_cpp_type, ) lines.append(f"{pad}}}") diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index b5c57f4..71bc835 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -78,6 +78,9 @@ SUPPORTED_ARRAY: frozenset[str] = frozenset(set(ARRAY_METHODS) | {"new", "new_float", "new_int", "new_bool", "new_string", "from"} | set(ARRAY_DRAWING_NEW_CTORS)) SUPPORTED_MAP: frozenset[str] = frozenset(set(MAP_METHODS) | {"new"}) SUPPORTED_MATRIX: frozenset[str] = frozenset(set(MATRIX_METHODS) | {"new"}) +_SUPPORTED_MAP_VALUE_TYPES: frozenset[str] = frozenset( + {"float", "int", "bool", "string"} +) SUPPORTED_SYMINFO: frozenset[str] = frozenset(SYMINFO_MEMBER_MAP) # Drawing-objects-as-data (spec §4.5). Geometry methods are REAL (route to the # per-type arena); *_NOOP visual setters are accepted no-ops (Level.WARNING). @@ -474,6 +477,12 @@ def __init__(self, ast: Program, filename: str = "") -> None: # shapes before codegen. self._udt_drawing_fields: dict[str, set[str]] = {} self._var_udt_types: dict[str, str] = {} + # PineMap IDs require runtime snapshots during COOF rollback. History + # buffers and generic matrices still value-copy their elements, so + # map-bearing shapes that would silently retain live aliases are gated + # until those containers have recursive checkpoint adapters. + self._udt_field_type_names: dict[str, dict[str, str]] = {} + self._map_bearing_udts: set[str] = set() # Names (vars and function params) declared as a scalar visual-container # type (table/box/line/label/linefill). A method call on one of these # (``panel.cell(...)``) is a visual sink whose args may carry visual @@ -528,6 +537,10 @@ def _collect_user_definitions(self, ast: Program) -> None: for stmt in ast.body: if isinstance(stmt, TypeDecl): self._user_types.add(stmt.name) + self._udt_field_type_names[stmt.name] = { + field.name: str(field.type_name or "").replace(" ", "") + for field in stmt.fields + } drawing_fields = { field.name for field in stmt.fields @@ -549,6 +562,140 @@ def _collect_user_definitions(self, ast: Program) -> None: self._user_methods.add(stmt.name) self._collect_visual_container_params(stmt) + # Resolve direct and transitively nested map-bearing UDTs. Pine type + # declarations are normally dependency ordered, but the fixed point + # also keeps this guard deterministic for synthetic test ASTs. + changed = True + while changed: + changed = False + for udt_name, fields in self._udt_field_type_names.items(): + bearing_fields = { + field_name + for field_name, type_name in fields.items() + if "map<" in type_name + or any( + type_name == nested + or f"<{nested}>" in type_name + or f"<{nested}," in type_name + or f",{nested}>" in type_name + for nested in self._map_bearing_udts + ) + } + if bearing_fields: + if udt_name not in self._map_bearing_udts: + self._map_bearing_udts.add(udt_name) + changed = True + + # Constructor checks alone are insufficient: a typed ``na`` + # declaration, UDT field, or unused callable parameter can introduce + # the same unsupported runtime shape without ever calling map.new() or + # matrix.new(). Validate every declared type boundary after the UDT + # fixed point is known so unsupported shapes fail with a Pine source + # diagnostic instead of a generated-C++ static_assert. + for stmt in ast.body: + if isinstance(stmt, TypeDecl): + for field in stmt.fields: + self._validate_declared_type_hint( + stmt, + field.type_name, + context=f"field '{stmt.name}.{field.name}'", + ) + elif isinstance(stmt, (FuncDef, MethodDef)): + hints = ( + (getattr(stmt, "annotations", None) or {}) + .get("param_type_hints") + or [] + ) + for index, param_name in enumerate(stmt.params): + hint = hints[index] if index < len(hints) else None + if hint: + self._validate_declared_type_hint( + stmt, + hint, + context=f"parameter '{param_name}'", + ) + + @staticmethod + def _split_declared_type_args(text: str) -> list[str]: + """Split ``K,V`` while preserving nested generic arguments.""" + args: list[str] = [] + current: list[str] = [] + depth = 0 + for char in text: + if char == "<": + depth += 1 + elif char == ">": + depth -= 1 + if char == "," and depth == 0: + args.append("".join(current)) + current = [] + else: + current.append(char) + if current: + args.append("".join(current)) + return args + + def _validate_declared_type_hint( + self, + node: ASTNode, + type_name: str | None, + *, + context: str, + ) -> None: + """Fail closed for runtime collection limits at every type boundary.""" + if not type_name: + return + compact = str(type_name).replace(" ", "") + if compact.endswith("[]"): + compact = f"array<{compact[:-2]}>" + + if compact.startswith("map<") and compact.endswith(">"): + parts = self._split_declared_type_args(compact[4:-1]) + if len(parts) != 2: + return + key_type, value_type = parts + if key_type != "string": + self._err( + node, + f"{context}: map keys must be string in PineForge's " + "supported map subset.", + ) + if value_type not in _SUPPORTED_MAP_VALUE_TYPES: + self._err( + node, + f"{context}: map values must be primitive in PineForge's " + "supported map subset.", + ) + return + + if compact.startswith("matrix<") and compact.endswith(">"): + element_type = compact[len("matrix<"):-1] + if element_type in self._map_bearing_udts: + self._err( + node, + f"matrix<{element_type}> is not supported when the UDT " + "contains a map field; rollback would retain live map " + "aliases.", + hint="Use array for recursively checkpointed state.", + ) + return + if "<" in element_type: + self._err( + node, + f"matrix<{element_type}>: nested collection element types " + "not supported in v1.", + ) + return + + # Arrays are recursively checkpointed and may contain valid maps, but + # the nested map still has the same key/value boundary. + if compact.startswith("array<") and compact.endswith(">"): + self._validate_declared_type_hint( + node, + compact[len("array<"):-1], + context=context, + ) + def _collect_visual_container_params(self, fn) -> None: """Register a function's parameters that are declared with a scalar visual-container type (``table panel``, ``line ln``) so method calls on @@ -827,6 +974,12 @@ def _visit_VarDecl(self, node: VarDecl) -> None: # so a direct ``dash.cell(..., text.align_left)`` is treated as a visual # sink (mirrors the table/box/line/label/linefill PARAM tracking). decl_hint = str(node.type_hint).replace(" ", "") if node.type_hint else None + if decl_hint: + self._validate_declared_type_hint( + node, + decl_hint, + context=f"variable '{node.name}'", + ) if decl_hint in _VISUAL_CONTAINER_TYPES: self._visual_container_vars.add(node.name) elif isinstance(node.value, FuncCall): @@ -1173,7 +1326,7 @@ def _visit_FuncCall(self, node: FuncCall) -> None: targs = [str(t).replace(" ", "") for t in targs] if targs and targs[0] != "string": self._err(node, "map keys must be string in PineForge's supported map subset.") - if len(targs) > 1 and targs[1] not in {"float", "int", "bool", "string"}: + if len(targs) > 1 and targs[1] not in _SUPPORTED_MAP_VALUE_TYPES: self._err(node, "map values must be primitive in PineForge's supported map subset.") if ns == "matrix" and name == "new": targs = (getattr(node.callee, "annotations", None) or {}).get("template_args") or [] @@ -1185,6 +1338,15 @@ def _visit_FuncCall(self, node: FuncCall) -> None: self._visit_children(node) return allowed_prim = {"float", "int", "bool", "string", "color"} + if t in self._map_bearing_udts: + self._err( + node, + f"matrix<{t}> is not supported when the UDT contains " + "a map field; rollback would retain live map aliases.", + hint="Use array for recursively checkpointed state.", + ) + self._visit_children(node) + return if t not in allowed_prim and t not in self._user_types: self._err(node, f"matrix<{t}> element type not supported. Allowed: float, int, bool, string, color, or a declared UDT.") self._visit_children(node) diff --git a/tests/test_calc_on_order_fills_codegen.py b/tests/test_calc_on_order_fills_codegen.py index e44c33a..4c23adf 100644 --- a/tests/test_calc_on_order_fills_codegen.py +++ b/tests/test_calc_on_order_fills_codegen.py @@ -76,7 +76,8 @@ def test_script_state_checkpoint_covers_every_mutable_state_family(): fields = { name: int(index) for name, index in re.findall( - r"decltype\(GeneratedStrategy::(\w+)\) _pf_value_(\d+);", cpp + r"_PFCheckpointTraits::snapshot_type _pf_value_(\d+);", + cpp, ) } @@ -112,9 +113,15 @@ def test_script_state_checkpoint_covers_every_mutable_state_family(): # catches mutations which leave the struct populated but omit snapshot or # restore plumbing for one member. for name, index in fields.items(): - assert re.search(rf"^\s+{re.escape(name)},$", cpp, re.MULTILINE) + assert re.search( + rf"^\s+_PFCheckpointTraits::take\({re.escape(name)}\),$", + cpp, + re.MULTILINE, + ) assert ( - f"this->{name} = _pf_script_state_checkpoint_->_pf_value_{index};" in cpp + f"_PFCheckpointTraits::restore(" + f"this->{name}, _pf_script_state_checkpoint_->_pf_value_{index});" + in cpp ) # Full-dataset precalc output is immutable during a broker walk. It must diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index e1fedd5..3ea5565 100644 --- a/tests/test_codegen_new.py +++ b/tests/test_codegen_new.py @@ -1392,7 +1392,7 @@ def test_map_keys_returns_actual_keys(): map.put(m, "a", 1.0) k = map.keys(m) """) - assert "p.first" in cpp + assert ".keys()" in cpp def test_magnifier_series_guard(): diff --git a/tests/test_collection_scope_isolation.py b/tests/test_collection_scope_isolation.py index 64d21f6..e930c46 100644 --- a/tests/test_collection_scope_isolation.py +++ b/tests/test_collection_scope_isolation.py @@ -132,10 +132,10 @@ def test_callable_local_collection_specs_are_lexically_isolated( if family == "map-elements": text = _body(cpp, "std::string map_text(") numeric = _body(cpp, "double map_float(") - assert "std::unordered_map slot" in text - assert 'return std::string("");' in text - assert "std::unordered_map slot" in numeric - assert "return 0.0;" in numeric + assert "PineMap slot" in text + assert ".remove(" in text + assert "PineMap slot" in numeric + assert ".remove(" in numeric elif family == "array-elements": text = _body(cpp, "double array_text(") numeric = _body(cpp, "double array_int(") @@ -144,13 +144,13 @@ def test_callable_local_collection_specs_are_lexically_isolated( elif family == "map-array": mapped = _body(cpp, "std::string kind_map(") arrayed = _body(cpp, "double kind_array(") - assert '.find(std::string("k"))' in mapped + assert ".remove(" in mapped assert "std::vector copied = std::vector(slot);" in arrayed assert ".count(" not in arrayed elif family == "map-matrix": mapped = _body(cpp, "double kind_map(") matrixed = _body(cpp, "int kind_matrix(") - assert 'slot.count(std::string("k"))' in mapped + assert ".get(" in mapped assert "PineGenericMatrix slot" in matrixed assert "slot.get((int)(0), (int)(0))" in matrixed assert "slot.count(" not in matrixed @@ -195,10 +195,10 @@ def _global_local_source(reverse: bool) -> str: @pytest.mark.parametrize("reverse", [False, True]) def test_callable_local_collection_shadows_same_named_global(reverse: bool) -> None: cpp = transpile(_global_local_source(reverse)) - assert "std::unordered_map slot;" in cpp + assert "PineMap slot;" in cpp global_body = _body(cpp, "std::string global_read(") local_body = _body(cpp, "double local_copy(") - assert '.find(std::string("k"))' in global_body + assert ".remove(" in global_body assert "std::vector slot" in local_body assert "std::vector copied = std::vector(slot);" in local_body assert ".count(" not in local_body @@ -233,8 +233,8 @@ def test_udf_and_udt_method_collection_locals_are_isolated(reverse: bool) -> Non assert "PineGenericMatrix slot" in matrixed assert "slot.get((int)(0), (int)(0))" in matrixed assert "slot.count(" not in matrixed - assert "std::unordered_map slot" in mapped - assert 'slot.count(std::string("k"))' in mapped + assert "PineMap slot" in mapped + assert ".get(" in mapped compile_cpp(cpp, label=f"collection_scope_udf_method_{int(reverse)}") @@ -264,7 +264,7 @@ def test_function_var_collection_members_and_clones_keep_exact_types() -> None: for name in ("ints", "ints_cs1"): assert f"std::vector {name};" in cpp for name in ("weights", "weights_cs1"): - assert f"std::unordered_map {name};" in cpp + assert f"PineMap {name};" in cpp for name in ("grid", "grid_cs1"): assert f"PineGenericMatrix {name};" in cpp compile_cpp(cpp, label="collection_scope_persistent_clones") @@ -286,12 +286,12 @@ def test_function_var_collection_members_and_clones_keep_exact_types() -> None: def test_scalar_local_does_not_erase_same_named_global_collection() -> None: cpp = transpile(_SCALAR_SHADOW_SOURCE) - assert "std::unordered_map slot;" in cpp + assert "PineMap slot;" in cpp assert "std::string local_scalar(" in cpp assert "std::string slot = std::string(\"local\")" in _body( cpp, "std::string local_scalar(" ) - assert '.find(std::string("k"))' in _body(cpp, "std::string global_read(") + assert ".remove(" in _body(cpp, "std::string global_read(") compile_cpp(cpp, label="collection_scope_scalar_shadow") @@ -343,8 +343,8 @@ def test_sibling_blocks_keep_exact_collection_bindings( ) -> None: cpp = transpile(_sibling_branch_source(reverse, matrix)) body = _body(cpp, "int mixed_branch(") - assert "std::unordered_map slot" in body - assert '(slot[std::string("k")] = 1)' in body + assert "PineMap slot" in body + assert ".put(" in body if matrix: assert "PineGenericMatrix slot" in body assert "slot.set((int)(0), (int)(0), 8)" in body @@ -380,13 +380,13 @@ def test_callable_binding_activates_after_declaration_rhs() -> None: cpp = transpile(_TEMPORAL_SOURCE) arrayed = _body(cpp, "double temporal_array(") matrixed = _body(cpp, "int temporal_matrix(") - assert '(slot[std::string("before")] = std::string("global"))' in arrayed - assert '.find(std::string("before"))' in arrayed + assert ".put(" in arrayed + assert ".remove(" in arrayed assert "std::vector slot" in arrayed assert "slot.push_back(2)" in arrayed - assert '(slot[std::string("k")] = std::string("v"))' in matrixed + assert ".put(" in matrixed assert "auto& _pf_outer_slot_0 = this->slot" in matrixed - assert '_pf_outer_slot_0.count(std::string("k"))' in matrixed + assert ".contains(" in matrixed assert "PineGenericMatrix slot" in matrixed compile_cpp(cpp, label="collection_scope_temporal_activation") @@ -411,11 +411,11 @@ def test_global_collection_reassignment_is_not_a_local_shadow() -> None: cpp = transpile(_GLOBAL_REASSIGN_SOURCE) method = _body(cpp, "std::string method_reset(") functional = _body(cpp, "std::string functional_reset(") - assert "slot = std::unordered_map()" in method - assert '(slot[std::string("method")] = std::string("ok"))' in method - assert '.find(std::string("method"))' in method - assert '(slot[std::string("functional")] = std::string("ok"))' in functional - assert '.find(std::string("functional"))' in functional + assert "slot = PineMap::new_()" in method + assert ".put(" in method + assert ".remove(" in method + assert ".put(" in functional + assert ".remove(" in functional compile_cpp(cpp, label="collection_scope_global_reassignment") @@ -449,14 +449,14 @@ def test_nested_scalar_and_collection_shadows_restore_outer_binding() -> None: cpp = transpile(_NESTED_SHADOW_SOURCE) local = _body(cpp, "double restore_local(") globally = _body(cpp, "std::string restore_global(") - assert "std::unordered_map slot" in local + assert "PineMap slot" in local assert 'std::string slot = std::string("scalar")' in local assert "std::string copied = slot" in local assert "slot.push_back(2)" in local assert 'std::string shared = std::string("scalar")' in globally assert "std::string copied = shared" in globally - assert '(shared[std::string("after")] = std::string("global"))' in globally - assert '.find(std::string("after"))' in globally + assert ".put(" in globally + assert ".remove(" in globally compile_cpp(cpp, label="collection_scope_nested_restore") @@ -491,7 +491,7 @@ def test_block_scoped_persistent_collections_keep_exact_clone_types() -> None: for name in ("ints", "ints_cs1"): assert f"std::vector {name};" in cpp for name in ("weights", "weights_cs1"): - assert f"std::unordered_map {name};" in cpp + assert f"PineMap {name};" in cpp for name in ("grid", "grid_cs1"): assert f"PineGenericMatrix {name};" in cpp assert "#include " in cpp @@ -555,7 +555,7 @@ def test_temporal_outer_alias_avoids_callable_parameter_name() -> None: body = _body(cpp, "int param_alias(") assert "int _pf_outer_slot_0" in body assert "auto& _pf_outer_slot_1 = this->slot" in body - assert '_pf_outer_slot_1.count(std::string("k"))' in body + assert ".contains(" in body compile_cpp(cpp, label="collection_scope_param_alias_collision") @@ -597,9 +597,9 @@ def test_nested_same_name_array_keeps_analyzer_element_spec() -> None: def test_block_local_collection_shadows_collection_parameter() -> None: cpp = transpile(_PARAM_BLOCK_SHADOW_SOURCE) body = _body(cpp, "int param_block(") - assert "std::unordered_map slot" in body - assert '(slot[std::string("k")] = 1)' in body - assert 'slot.count(std::string("k"))' in body + assert "PineMap slot" in body + assert ".put(" in body + assert ".get(" in body compile_cpp(cpp, label="collection_scope_parameter_block_shadow") @@ -714,9 +714,8 @@ def test_loop_binder_shadows_collection_only_inside_loop() -> None: # Keep general scalar lowering byte-compatible; exact loop TypeSpecs are # consumed only by collection inference/dispatch in this patch. assert "double local_copy = slot" in body - assert '(slot[std::string("before")] = std::string("global"))' in body - assert '(slot[std::string("after")] = std::string("global"))' in body - assert '.find(std::string("after"))' in body + assert body.count(".put(") == 2 + assert ".remove(" in body compile_cpp(cpp, label="collection_scope_loop_shadow") @@ -790,7 +789,9 @@ def test_loop_binder_specs_match_array_from_declaration(case: str) -> None: else: assert "std::vector slot = std::vector" in cpp if case == "map-pair": - assert "for (auto [key, value] : pairs)" in cpp + assert "auto __pf_map_iter_0 = pairs;" in cpp + assert "for (auto key : __pf_map_iter_0.keys())" in cpp + assert "auto value = __pf_map_iter_0.get(key);" in cpp compile_cpp(cpp, label=f"collection_scope_loop_binder_{case}") @@ -832,9 +833,9 @@ def test_temporal_outer_alias_avoids_user_name_and_routes_subscript() -> None: ''' -def test_unique_local_collection_output_stays_byte_identical() -> None: +def test_unique_local_collection_output_hash_is_stable() -> None: cpp = transpile(_IDENTITY_SOURCE) - assert len(cpp) == 8892 + assert len(cpp) == 12085 assert sha256(cpp.encode()).hexdigest() == ( - "b3cea2eab92d45874cfddb94f2a624e0d7c463cf2dbb3d27edbe06a0a5fbb8d0" + "fc0f8008f7f3dac180602891982fb2d28425b3d60ca527185345e0630efa6c7e" ) diff --git a/tests/test_map_call_diagnostics.py b/tests/test_map_call_diagnostics.py index d5718bf..dcfdeb8 100644 --- a/tests/test_map_call_diagnostics.py +++ b/tests/test_map_call_diagnostics.py @@ -64,7 +64,7 @@ def test_duplicate_keyword_argument_is_a_parser_compile_error(): def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): cpp = transpile(_VALID_EXISTING_FORMS) assert sha256(cpp.encode()).hexdigest() == ( - "c39c46c7044b95e61648bdbd2029df583ff1f06cb6f65cbc44b6dcb4adc7d846" + "8a7b2f7c4d5d28cbe220677d56b646dd7c75561c504e6eec549de8af961d99db" ) @@ -78,7 +78,7 @@ def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): map.get("key") observed = probe(map.new()) ''', - "518fd354e1a1365d7957f71f69c7356b4cb0704f796c01cdef5e40f8a44d5aed", + "a5e0a9ccdddd7e4f540f1598e010b06d65fe3f191a8c750645c012825bdbb44b", ), ( '''//@version=6 @@ -89,7 +89,7 @@ def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): map.get("key") observed = probe() ''', - "cfc158b435ea5a9eadaf5c054bea35efa24d6dced16491d61fb5897544b24d3f", + "b1e0e923dbf0b6a5097b0f6aa2345cda5d13ab6f691a8b7f31a22d0006bc20e7", ), ( '''//@version=6 @@ -98,7 +98,7 @@ def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): map.put("key", 1) observed = map.get("key") ''', - "b1b548564467c9161250d34f1f0eeeccda8c27bccb7881f3860452797fde6aff", + "480401819f2dfc7352682e9142612cd6fccec5a049a5f5a0c8bb21d83cbf03c0", ), ], ) @@ -141,7 +141,7 @@ def test_security_timeframe_clone_keeps_preceding_map_namespace_source_order(): ) assert sha256(cpp.encode()).hexdigest() == ( - "a996c517c7365761c42184cc803d8db66620e6f2517523b4f30a69a64be9c786" + "43146dde90aa1c34a2ab1258bf744f0d6448c98613a8f0ac50950edd6a9b599c" ) @@ -159,7 +159,7 @@ def test_security_timeframe_clone_keeps_visible_map_receiver_source_order(): cpp = transpile(source, filename="synthetic-lexical-map-source-order.pine") assert sha256(cpp.encode()).hexdigest() == ( - "27a8da4b19d535a5fffe75acb1618ff878f3e2544ef93cc76289402af3f7dbdc" + "38b9b2f1ce68ddc70523e58e089474dd20a1fa54fc517019c0894850c5999ce3" ) @@ -204,22 +204,22 @@ def test_security_timeframe_clone_keeps_visible_map_receiver_source_order(): [ ( _LATER_GLOBAL_MAP_SOURCE, - "a840df36bd97818e0dfc74d9fac484b8ca194d979ee0e120c091f9ad53c9ea32", + "eaea8c04c1726540c7fc2143d75a0d62e0c08d35daa105e8633b81f4e5af8e81", 34.0, ), ( _NESTED_LEXICAL_MAP_ROOT_SOURCE, - "f8ae41ee93773775d6e1008f9890593271c71815a14681ed047787644bea2a48", + "e1d53d2fce372fdb7007fd4c4b88d72b77fcef924282f6483a426874ef6093ca", 7.0, ), ( _BLOCK_LOCAL_MAP_ISOLATION_SOURCE, - "5324c737eafc26029b64442ea245e7ef576c37353f18716c265f7e1fcb5b427c", + "b3d1448bbfc615de52d7e630c40c7ecde7a528c1ebd1c246993c04cfac102184", 923.0, ), ( _FOR_BLOCK_LOCAL_MAP_SOURCE, - "a68ce941c1d1d32ccbbc99dc81720c44e645844ab136eace2dcfa2f0bffa7e5e", + "b9178676580d67b4b2e1f198242351e179483fa268dd7bae2d51f2cfcf0a060b", 8.0, ), ], diff --git a/tests/test_map_param_methods.py b/tests/test_map_param_methods.py index 9bee017..4d23ad4 100644 --- a/tests/test_map_param_methods.py +++ b/tests/test_map_param_methods.py @@ -139,11 +139,11 @@ def _function_body(cpp: str, signature: str) -> str: def test_typed_map_parameter_methods_route_by_typespec_and_keywords(): cpp = transpile(_SOURCE) - assert "double mutate_int(std::unordered_map& target)" in cpp - assert "double mutate_float(std::unordered_map& target)" in cpp - assert "bool probe_bool(std::unordered_map& target)" in cpp + assert "double mutate_int(PineMap target)" in cpp + assert "double mutate_float(PineMap target)" in cpp + assert "bool probe_bool(PineMap target)" in cpp assert ( - "bool probe_string(std::unordered_map& target)" + "bool probe_string(PineMap target)" in cpp ) @@ -182,30 +182,33 @@ def test_typed_map_parameter_methods_route_by_typespec_and_keywords(): ) assert get_line.count("read_key()") == 1 - # Primitive missing-key defaults retain the parameter's value TypeSpec. - assert ": 0.0)" in _function_body(cpp, "bool missing_float(") - assert ": 0)" in _function_body(cpp, "bool missing_int(") - assert ": false)" in _function_body(cpp, "bool missing_bool(") - assert 'std::string("")' in _function_body(cpp, "bool missing_string(") + # Missing values are supplied by PineMap's typed Pine-na runtime contract. + for signature in ( + "bool missing_float(", "bool missing_int(", "bool missing_bool(", + "bool missing_string(", + ): + body = _function_body(cpp, signature) + assert ".get(" in body + assert ".count(" not in body # Zero-argument methods and typed collection results route through the # helper without synthetic arguments. inspect = _function_body(cpp, "double inspect_int(") - assert "std::vector v" in inspect - assert "std::vector v" in inspect - assert "std::unordered_map(target)" in inspect - assert "(double)target.size()" in inspect - assert "target.clear();" in _function_body(cpp, "double clear_int(") + assert ".keys()" in inspect + assert ".values()" in inspect + assert "PineMap copied" in inspect + assert ".copy()" in inspect + assert ".size()" in inspect + assert ".clear()" in _function_body(cpp, "double clear_int(") # Parameter TypeSpecs win over same-named global array/map/matrix entries. - assert ": 0)" in _function_body(cpp, "double shadow_array_probe(") - assert 'std::string("")' in _function_body(cpp, "bool shadow_map_probe(") - assert ": false)" in _function_body(cpp, "double shadow_matrix_probe(") + assert ".get(" in _function_body(cpp, "double shadow_array_probe(") + assert ".get(" in _function_body(cpp, "bool shadow_map_probe(") + assert ".get(" in _function_body(cpp, "double shadow_matrix_probe(") merge = _function_body(cpp, "double merge_int(") - assert "target.insert(" in merge - assert ".begin(), __pf_map_param_arg_" in merge - assert ".end()); }((source));" in merge + assert ".put_all(__pf_map_param_arg_" in merge + assert merge.count("}((source))") == 1 def test_udt_valued_map_parameter_remains_outside_supported_subset(): @@ -324,8 +327,8 @@ def test_typed_map_parameter_methods_preserve_runtime_aliases_and_order(): 2.5, 1.0, 1.0, - 1.0, - 1.0, + 0.0, + 0.0, 1.0, 1.0, 8.0, diff --git a/tests/test_map_terminal_returns.py b/tests/test_map_terminal_returns.py index 6405d68..3c22de4 100644 --- a/tests/test_map_terminal_returns.py +++ b/tests/test_map_terminal_returns.py @@ -349,14 +349,14 @@ def test_collection_map_terminals_infer_exact_types_across_forms(): "copy_functional", "copy_global", "copy_local", "copy_param", "copy_inferred", ): - assert f"std::unordered_map {name}(" in cpp + assert f"PineMap {name}(" in cpp for name in ("keys_a", "keys_b", "keys_c", "keys_d", "keys_e"): assert f"std::vector {name};" in cpp for name in ("values_a", "values_b", "values_c", "values_d", "values_e"): assert f"std::vector {name};" in cpp for name in ("copy_a", "copy_b", "copy_c", "copy_d", "copy_e"): - assert f"std::unordered_map {name};" in cpp + assert f"PineMap {name};" in cpp def test_string_map_terminals_infer_string_across_forms(): @@ -387,10 +387,10 @@ def test_map_terminal_return_forms_compile(): compile_cpp(transpile(_TERMINAL_SOURCE), label="map_terminal_returns") -def test_nonterminal_map_output_stays_byte_identical(): +def test_nonterminal_pinemap_output_hash_is_stable(): cpp = transpile(_NONTERMINAL_SOURCE) assert sha256(cpp.encode()).hexdigest() == ( - "d207acf7c4f53761cebd1ef6637380131f140ca5566c00d9436303af80a1cf40" + "73ae5680f7272cd61bb33c4ac8603159b1c441e986cff7ca59c355df140b5ef4" ) @@ -425,5 +425,5 @@ def test_invalid_terminal_map_shapes_raise_compile_errors(): def test_unresolved_parameter_keeps_lexical_precedence_over_global_map(): cpp = transpile(_SHADOWED_UNRESOLVED_PARAM_SOURCE) assert sha256(cpp.encode()).hexdigest() == ( - "331d8306eb6d8868e5a25542cbacedcab77b10a13c18daf8dd523a3e283e196d" + "0b913b2fa8802be720aa171e1cf5d353aff3b56c6e3bf9df85f5e8d08ef066f8" ) diff --git a/tests/test_pinemap_boundaries_and_order.py b/tests/test_pinemap_boundaries_and_order.py new file mode 100644 index 0000000..4dec8cb --- /dev/null +++ b/tests/test_pinemap_boundaries_and_order.py @@ -0,0 +1,252 @@ +"""Declared PineMap boundaries and cross-call evaluation-order regressions.""" + +from __future__ import annotations + +from hashlib import sha256 + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests import _compile as compile_env +from tests.test_pinemap_semantics import _compile_and_run + + +@pytest.mark.parametrize( + ("source", "message"), + [ + ( + '''//@version=6 +strategy("typed map key boundary") +var map values = na +''', + "map keys must be string", + ), + ( + '''//@version=6 +strategy("typed map value boundary") +type Payload + int value +var map values = na +''', + "map values must be primitive", + ), + ( + '''//@version=6 +strategy("UDT field map boundary") +type Payload + int value +type Holder + map values +var Holder holder = Holder.new(na) +''', + "map values must be primitive", + ), + ( + '''//@version=6 +strategy("UDF map parameter boundary") +type Payload + int value +unused(map values) => 0 +observed = 0 +''', + "map values must be primitive", + ), + ( + '''//@version=6 +strategy("method map parameter boundary") +type Payload + int value +method unused(Payload self, map values) => 0 +observed = 0 +''', + "map keys must be string", + ), + ( + '''//@version=6 +strategy("UDF matrix map-bearing boundary") +type Holder + map values +unused(matrix values) => 0 +observed = 0 +''', + "matrix is not supported when the UDT contains a map field", + ), + ( + '''//@version=6 +strategy("method matrix map-bearing boundary") +type Holder + map values +method unused(Holder self, matrix values) => 0 +observed = 0 +''', + "matrix is not supported when the UDT contains a map field", + ), + ], +) +def test_declared_map_and_matrix_boundaries_fail_closed( + source: str, message: str +) -> None: + with pytest.raises(CompileError, match=message): + transpile(source) + + +def test_valid_declared_primitive_maps_remain_supported() -> None: + source = '''//@version=6 +strategy("valid declared primitive maps") +type Holder + map values +identity(map values) => values +method read(Holder self, map flags) => flags.size() +var map names = na +var Holder holder = Holder.new(map.new()) +var map alias = identity(holder.values) +observed = holder.read(map.new()) +''' + cpp = transpile(source) + assert "PineMap" in cpp + assert "PineMap" in cpp + + +_ORDERED_CALL_SOURCE = '''//@version=6 +strategy("PineMap generic call order") +observe(int first, int second) => 0 +mutate(map target, string key, int value) => target.put(key, value) +inferred_mutate(target, string key, int value) => target.put(key, value) +relay(target, string key, int value) => mutate(target, key, value) +type Holder + int marker +method observe_method(Holder self, int first, int second) => 0 +var direct_target = map.new() +var named_target = map.new() +var helper_target = map.new() +var inferred_target = map.new() +var transitive_target = map.new() +var method_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)) +helper_seen = observe(mutate(helper_target, "first", 1), mutate(helper_target, "second", 2)) +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)) +''' + + +_TEMPORARY_UDT_RECEIVER_SOURCE = '''//@version=6 +strategy("temporary UDT method receiver") +type H + map data +method get(H self) => self.data +method apply(H self, int value) => + self.data.put("method", value) + self.data +receiver_map(array order, map root) => + order.push(1) + root +next_value(array order) => + order.push(2) + 7 +var order = array.new() +var root = map.new() +direct_previous = H.new(root).get().put("direct", 1) +ordered_previous = H.new(receiver_map(order, root)).apply(next_value(order)).put("after", 9) +''' + + +def test_user_and_udt_calls_stage_map_effects_in_pine_source_order() -> None: + cpp = transpile(_ORDERED_CALL_SOURCE) + for target in ( + "direct_seen", + "named_seen", + "helper_seen", + "inferred_seen", + "transitive_seen", + "method_seen", + ): + assignment = next( + line + for line in cpp.splitlines() + if line.strip().startswith(f"{target} =") + ) + assert "__pf_call_arg_" in assignment, target + assert assignment.count('std::string("first")') == 1, target + assert assignment.count('std::string("second")') == 1, target + 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; + const std::vector forward{"first", "second"}; + const std::vector named{"second", "first"}; + if (strategy.direct_target.keys() != forward) return 3; + if (strategy.named_target.keys() != named) return 4; + if (strategy.helper_target.keys() != forward) return 5; + if (strategy.inferred_target.keys() != forward) return 6; + if (strategy.transitive_target.keys() != forward) return 7; + if (strategy.method_target.keys() != forward) return 8; + std::cout << "ok\n"; +} +''' + assert _compile_and_run(cpp + driver, label="pinemap-call-order") == "ok\n" + + +def test_non_map_user_call_remains_exact_baseline_bytes() -> None: + source = '''//@version=6 +strategy("Non-map ordered call baseline") +combine(int a, int b) => a * 10 + b +left = 1 +right = 2 +observed = combine(left, right) +''' + cpp = transpile(source) + assert "__pf_call_arg_" not in cpp + assert sha256(cpp.encode()).hexdigest() == ( + "1b45f1b7e8a137e6c2257b11660129f38b9d3c2ba37f353020c52685d757b223" + ) + + +def test_temporary_udt_method_receiver_is_staged_once_and_compiles() -> None: + cpp = transpile(_TEMPORARY_UDT_RECEIVER_SOURCE) + direct = next( + line + for line in cpp.splitlines() + if line.strip().startswith("direct_previous =") + ) + ordered = next( + line + 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 ordered.count("receiver_map(order, root)") == 1 + assert ordered.count("next_value(order)") == 1 + compile_env.compile_cpp(cpp, label="pinemap-temporary-udt-receiver") + + +def test_temporary_udt_method_receiver_preserves_receiver_first_runtime() -> None: + cpp = transpile(_TEMPORARY_UDT_RECEIVER_SOURCE) + 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.order != std::vector{1, 2}) return 3; + if (strategy.root.get("direct") != 1) return 4; + if (strategy.root.get("method") != 7) return 5; + if (strategy.root.get("after") != 9) return 6; + if (!is_na(strategy.direct_previous) + || !is_na(strategy.ordered_previous)) return 7; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label="pinemap-temporary-udt-receiver-runtime", + ) == "ok\n" diff --git a/tests/test_pinemap_cold_followups_history.py b/tests/test_pinemap_cold_followups_history.py new file mode 100644 index 0000000..7c66737 --- /dev/null +++ b/tests/test_pinemap_cold_followups_history.py @@ -0,0 +1,164 @@ +"""Cold-review regressions for deferred history and generated-name safety.""" + +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests import _compile as compile_env + + +@pytest.mark.parametrize( + "source", + [ + '''//@version=6 +strategy("keyword map history") +previous(values) => values[1] +observed = previous(values=map.new()) +''', + '''//@version=6 +strategy("transitive map history") +previous(values) => values[1] +wrapper(values) => previous(values) +observed = wrapper(map.new()) +''', + '''//@version=6 +strategy("deep keyword map history") +previous(values) => values[1] +middle(values) => previous(values=values) +wrapper(values) => middle(values) +observed = wrapper(values=map.new()) +''', + '''//@version=6 +strategy("UDT method map history") +type Holder + int marker +method previous(Holder self, values) => values[1] +var Holder holder = Holder.new(0) +observed = holder.previous(map.new()) +''', + ], +) +def test_deferred_map_history_is_rejected_through_all_call_forms( + source: str, +) -> None: + with pytest.raises(CompileError, match="History references on map IDs"): + transpile(source) + + +@pytest.mark.parametrize( + "source", + [ + '''//@version=6 +strategy("nested array map-bearing matrix declaration") +type Outer + array> items +var matrix values = na +''', + '''//@version=6 +strategy("nested array map-bearing matrix constructor") +type Outer + array> items +var matrix values = matrix.new() +''', + '''//@version=6 +strategy("transitive nested array map-bearing matrix") +type Inner + array> items +type Outer + array nested +unused(matrix values) => 0 +observed = 0 +''', + ], +) +def test_nested_array_maps_mark_udts_map_bearing(source: str) -> None: + with pytest.raises( + CompileError, + match="is not supported when the UDT contains a map field", + ): + transpile(source) + + +@pytest.mark.parametrize("persistent_first", [True, False]) +def test_cross_callable_persistent_map_and_scalar_history_fail_closed( + persistent_first: bool, +) -> None: + owner = '''owner() => + var slot = map.new() + slot.put("x", 1) + slot.size() +''' + previous = '''previous() => + float slot = close + slot[1] +''' + body = owner + previous if persistent_first else previous + owner + source = ( + '//@version=6\nstrategy("cross callable history collision")\n' + + body + + 'owner_value = owner()\nprevious_value = previous()\n' + ) + with pytest.raises( + CompileError, + match="conflict with a persistent map local of the same name", + ): + transpile(source) + + +def test_cross_callable_nonpersistent_map_keeps_scalar_history_valid() -> None: + source = '''//@version=6 +strategy("cross callable nonpersistent map") +owner() => + slot = map.new() + slot.put("x", 1) + slot.size() +previous() => + float slot = close + slot[1] +owner_value = owner() +previous_value = previous() +''' + compile_env.compile_cpp( + transpile(source), label="cross-callable-nonpersistent-map" + ) + + +@pytest.mark.parametrize( + ("source", "expected_token"), + [ + ( + '''//@version=6 +strategy("pair loop UDF collision") +__pf_map_iter_0() => 7 +var pairs = map.new() +pairs.put("x", 1) +observed = 0 +for [key, value] in pairs + observed := __pf_map_iter_0() + value +''', + "auto __pf_map_iter_1 = pairs;", + ), + ( + '''//@version=6 +strategy("pair loop UDT collision") +type __pf_map_iter_0 + int value +var pairs = map.new() +pairs.put("x", 1) +var __pf_map_iter_0 observed = na +for [key, value] in pairs + observed := __pf_map_iter_0.new(value) +''', + "auto __pf_map_iter_1 = pairs;", + ), + ], +) +def test_pair_loop_temporaries_reserve_callable_and_udt_names( + source: str, expected_token: str +) -> None: + cpp = transpile(source) + assert expected_token in cpp + assert "auto __pf_map_iter_0 = pairs;" not in cpp + compile_env.compile_cpp(cpp, label="pair-loop-authored-name-collision") diff --git a/tests/test_pinemap_semantics.py b/tests/test_pinemap_semantics.py new file mode 100644 index 0000000..8779d9e --- /dev/null +++ b/tests/test_pinemap_semantics.py @@ -0,0 +1,1027 @@ +"""Atomic PineMap codegen, handle identity, and rollback regressions.""" + +from __future__ import annotations + +from hashlib import sha256 +import os +from pathlib import Path +import subprocess +import tempfile + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests import _compile as compile_env + + +_HANDLE_SOURCE = '''//@version=6 +strategy("PineMap handles") +identity(map value) => value +mutate_then_rebind(map value) => + value.put("caller", 10) + value := map.new() + value.put("private", 20) + value +local_alias_then_rebind(map value) => + alias = value + alias.put("local", 11) + alias := map.new() + alias.put("local_private", 12) + value.get("local") +var map original = map.new() +var map alias = identity(original) +var map rebound = mutate_then_rebind(alias) +var map copied = original.copy() +var map temporary = identity(map.new()) +local_seen = local_alias_then_rebind(original) +original.put("after_copy", 30) +var map ordered = map.new() +var map merge_source = map.new() +ordered.put("b", 2) +ordered.put("a", 1) +ordered.put("c", 3) +ordered.put("a", 10) +ordered.remove("b") +ordered.put("b", 20) +merge_source.put("a", 100) +merge_source.put("d", 4) +ordered.put_all(merge_source) +''' + + +_ORDER_SOURCE = '''//@version=6 +strategy("PineMap evaluation order") +var order = array.new() +var target = map.new() +receiver() => + order.push(1) + target +next_key() => + order.push(2) + "k" +next_value() => + order.push(3) + 7 +choose() => + order.push(1) + true +p1 = receiver().put(next_key(), next_value()) +p2 = map.put(receiver(), next_key(), next_value()) +p3 = (choose() ? target : target).put(next_key(), next_value()) +''' + + +_COOF_SOURCE = '''//@version=6 +strategy("PineMap COOF", calc_on_order_fills=true) +type Holder + map data + bool active +type Nested + Holder inner +var map root = map.new() +var map alias = root +var map independent = root.copy() +var map nullable = na +var Holder holder = Holder.new(root, true) +var Nested nested = Nested.new(Holder.new(root, true)) +var array holders = array.from(Holder.new(root, true)) +var array flags = array.from(true, false) +if barstate.isfirst + root.put("seed", 1) +''' + + +_NULL_SOURCE = '''//@version=6 +strategy("PineMap null IDs") +local_null_roundtrip() => + var map local = na + started_null = na(local) + local := map.new() + local.put("temporary", 1) + local := na + started_null and na(local) +var map global_null = map.new() +global_null.put("temporary", 1) +global_null := na +global_null_ok = na(global_null) +local_null_ok = local_null_roundtrip() +''' + + +_MAP_LOOP_SOURCE = '''//@version=6 +strategy("PineMap pair loop") +var pairs = map.new() +pairs.put("b", 2) +pairs.put("a", 1) +var string observed_keys = "" +var int observed_values = 0 +for [key, value] in pairs + observed_keys += key + observed_values := observed_values * 10 + value +''' + + +_CONTEXTUAL_NA_SOURCE = '''//@version=6 +strategy("PineMap contextual na") +type H + map defaulted = na + map ternary_field + map if_field +bool cond = true +map global_bare = na +map global_left = cond ? na : map.new() +map global_right = cond ? map.new() : na +map global_if_left = if cond + na +else + map.new() +map global_if_right = if cond + map.new() +else + na +var map persistent_na = na +var H holder = H.new() +var H explicit_na = H.new(na, map.new(), map.new()) +local_contexts(bool choose_na) => + map local_bare = na + map local_left = choose_na ? na : map.new() + map local_right = choose_na ? map.new() : na + map local_if_left = if choose_na + na + else + map.new() + map local_if_right = if choose_na + map.new() + else + na + local_left := choose_na ? map.new() : na + local_right := choose_na ? na : map.new() + local_if_left := if choose_na + map.new() + else + na + local_if_right := if choose_na + na + else + map.new() + na(local_bare) and not na(local_left) and na(local_right) and not na(local_if_left) and na(local_if_right) +local_ok = local_contexts(cond) +holder.ternary_field := cond ? na : map.new() +holder.if_field := if cond + map.new() +else + na +holder.defaulted := na +''' + + +_MAP_RETURN_PROPAGATION_SOURCE = '''//@version=6 +strategy("PineMap return propagation") +type H + map m +method get(H h) => h.m +direct(H h) => h.m +identity(map m) => m +make() => map.new() +choose(bool cond, map a, map b) => cond ? a : b +choose_if(bool cond, map a, map b) => + if cond + a + else + b +var map base = map.new() +base.put("seed", 7) +var H holder = H.new(base) +var method_lhs = holder.get() +var direct_lhs = direct(holder) +var identity_lhs = identity(base) +var made_copy = make().copy() +var selected_ternary = choose(true, base, make()) +var selected_if = choose_if(false, make(), base) +method_previous = holder.get().put("method", 1) +ternary_previous = choose(true, base, make()).put("ternary", 2) +if_previous = choose_if(false, make(), base).put("if", 3) +direct_previous = direct(holder).put("direct", 4) +identity_previous = identity(base).put("identity", 5) +copy_previous = made_copy.put("copy", 6) +''' + + +_UNTYPED_MAP_SELECTION_SOURCE = '''//@version=6 +strategy("PineMap untyped selection returns") +choose(cond, a, b) => cond ? a : b +branch(cond, a, b) => + if cond + a + else + b +choose_na(cond, a) => cond ? na : a +branch_na(cond, a) => + if cond + a + else + na +var left = map.new() +var right = map.new() +left.put("seed-left", 1) +right.put("seed-right", 2) +local_ternary(cond) => + local = cond ? na : left + local +local_if(cond) => + local = if cond + na + else + right + local +selected = choose(true, left, right) +branched = branch(false, left, right) +nullable_selected = choose_na(false, left) +nullable_branched = branch_na(true, right) +nullable_absent = choose_na(true, left) +branch_absent = branch_na(false, right) +local_selected = local_ternary(false) +local_branched = local_if(false) +selected_previous = choose(true, left, right).put("selected", 10) +branched_previous = branch(false, left, right).put("branched", 20) +deep_previous = choose(false, left, right).copy().put("deep-copy", 30) +nullable_previous = choose_na(false, left).put("nullable", 40) +local_previous = local_if(false).put("local-if", 50) +''' + + +_UNTYPED_MAP_CHAIN_SOURCE = '''//@version=6 +strategy("PineMap untyped wrapper chains") +choose(cond, a, b) => cond ? a : b +select_layer(cond, a, b) => choose(cond, a, b) +choose_nullable(cond, a, b) => cond ? a : b +nullable_layer(cond, value) => choose_nullable(cond, value, na) +identity(value) => value +identity_layer(value) => identity(value) +deep_select(cond, a, b) => identity_layer(select_layer(cond, a, b)) +effect_leaf(value) => value.put("effect", "propagated") +effect_middle(value) => effect_leaf(value) +effect_outer(value) => effect_middle(value) +cycle_passthrough(value) => + if false + cycle_passthrough(value) + value +var left = map.new() +var right = map.new() +var words = map.new() +left.put("seed-left", 1) +right.put("seed-right", 2) +words.put("seed-text", "present") +selected = deep_select(true, left, right) +copied = deep_select(false, left, right).copy() +cycled = cycle_passthrough(left) +nullable_selected = nullable_layer(true, left) +nullable_absent = nullable_layer(false, left) +selected_previous = deep_select(true, left, right).put("selected", 10) +effect_previous = effect_outer(words) +copy_previous = copied.put("copy-only", 20) +''' + + +def _find_engine_library() -> Path | None: + explicit = os.environ.get("PINEFORGE_ENGINE_LIB") + if explicit: + candidate = Path(explicit).expanduser().resolve() + return candidate if candidate.is_file() else None + if compile_env._ENGINE_INC is None: + return None + candidates: list[Path] = [] + for pattern in ("build*/lib/libpineforge.a", "build*/lib/libpineforge.dylib"): + candidates.extend(sorted(compile_env._ENGINE_INC.parent.glob(pattern))) + return candidates[0].resolve() if candidates else None + + +def _compile_and_run(cpp_source: str, *, label: str) -> str: + compile_env.skip_if_no_compile_env() + engine_library = _find_engine_library() + if engine_library is None: + pytest.skip("built libpineforge not found; set PINEFORGE_ENGINE_LIB") + assert compile_env._COMPILER is not None + assert compile_env._ENGINE_INC is not None + assert compile_env._EIGEN_INC is not None + + with tempfile.TemporaryDirectory(prefix=f"pineforge-{label}-") as tmp: + source_path = Path(tmp) / "probe.cpp" + executable = Path(tmp) / "probe" + source_path.write_text(cpp_source) + command = [ + compile_env._COMPILER, + "-std=c++17", + "-O0", + "-I", + str(compile_env._ENGINE_INC), + "-I", + str(compile_env._EIGEN_INC), + ] + if compile_env._GENERATED_INC is not None: + command += ["-I", str(compile_env._GENERATED_INC)] + command += [str(source_path), str(engine_library), "-pthread", "-o", str(executable)] + built = subprocess.run(command, capture_output=True, text=True, timeout=120) + if built.returncode != 0: + raise AssertionError( + f"{label} failed to link\n" + + "\n".join((built.stderr or built.stdout).splitlines()[:120]) + ) + ran = subprocess.run( + [str(executable)], capture_output=True, text=True, timeout=30 + ) + if ran.returncode != 0: + raise AssertionError( + f"{label} exited {ran.returncode}\n" + f"stdout:\n{ran.stdout}\nstderr:\n{ran.stderr}" + ) + return ran.stdout + + +@pytest.mark.parametrize( + ("source", "label"), + [ + (_HANDLE_SOURCE, "pinemap_handles"), + (_ORDER_SOURCE, "pinemap_order"), + (_COOF_SOURCE, "pinemap_coof"), + (_NULL_SOURCE, "pinemap_null"), + (_MAP_LOOP_SOURCE, "pinemap_pair_loop"), + ], +) +def test_pinemap_generated_sources_compile(source: str, label: str) -> None: + compile_env.compile_cpp(transpile(source), label=label) + + +def test_pinemap_emission_uses_handle_identity_and_recursive_checkpoints() -> None: + handles = transpile(_HANDLE_SOURCE) + assert '#include ' in handles + assert "PineMap identity(PineMap value)" in handles + assert "PineMap& value" not in handles + assert "PineMap::new_()" in handles + assert ".copy()" in handles + assert "std::unordered_map" not in handles + + checkpoint = transpile(_COOF_SOURCE) + assert "Holder holder;" in checkpoint + 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 "_PFCheckpointTraits>" in checkpoint + + +def test_non_map_cpp_remains_exact_baseline_bytes() -> None: + source = '''//@version=6 +strategy("No map checkpoint", calc_on_order_fills=true) +var float scalar = 1.0 +scalar += close +observed = scalar +''' + cpp = transpile(source) + # Directly captured from clean base commit 741465c before this integration. + assert sha256(cpp.encode()).hexdigest() == ( + "a2dda39086b904f0bd526a9696a7135e2526254d42667ae1e58354303d453fa6" + ) + assert '#include ' not in cpp + assert "_PFCheckpointTraits" not in cpp + + +@pytest.mark.parametrize( + ("source", "message"), + [ + ( + '''//@version=6 +strategy("map history") +var map values = map.new() +previous = values[1] +''', + "History references on map IDs", + ), + ( + '''//@version=6 +strategy("map parameter history") +previous(map values) => values[1] +observed = previous(map.new()) +''', + "History references on map IDs", + ), + ( + '''//@version=6 +strategy("inferred map parameter history") +previous(values) => values[1] +observed = previous(map.new()) +''', + "History references on map IDs", + ), + ( + '''//@version=6 +strategy("inferred map-bearing UDT parameter history") +type Holder + map values +previous(holder) => holder[1] +var Holder holder = Holder.new(map.new()) +observed = previous(holder) +''', + "History references on map-bearing UDTs", + ), + ( + '''//@version=6 +strategy("map alias history") +var root = map.new() +alias = root +previous = alias[1] +''', + "History references on map IDs", + ), + ( + '''//@version=6 +strategy("map ternary history") +var left = map.new() +var right = map.new() +condition = true +previous = (condition ? left : right)[1] +''', + "History references on map IDs", + ), + ( + '''//@version=6 +strategy("map current history") +var root = map.new() +current = root[0] +''', + "History references on map IDs", + ), + ( + '''//@version=6 +strategy("map UDT history") +type Holder + map values +var Holder holder = Holder.new(map.new()) +previous = holder[1] +''', + "History references on map-bearing UDTs", + ), + ( + '''//@version=6 +strategy("map UDT matrix") +type Holder + map values +var matrix holders = matrix.new(1, 1, Holder.new(map.new())) +''', + "matrix is not supported when the UDT contains a map field", + ), + ( + '''//@version=6 +strategy("UDT map value") +type Holder + int value +var map values = map.new() +''', + "map values must be primitive", + ), + ], +) +def test_unsupported_shallow_checkpoint_shapes_fail_closed( + source: str, message: str +) -> None: + with pytest.raises(CompileError, match=message): + transpile(source) + + +def test_scalar_parameter_lexically_shadows_same_named_global_map() -> None: + source = '''//@version=6 +strategy("map history lexical shadow") +var map values = map.new() +previous(float values) => values[1] +observed = previous(close) +''' + cpp = transpile(source) + assert "double previous_cs0(const Series& values)" in cpp + compile_env.compile_cpp(cpp, label="pinemap-history-scalar-shadow") + + +@pytest.mark.parametrize( + "body", + [ + '''probe() => + float slot = close + slot[1] +''', + '''probe() => + if close > 0 + float slot = close + previous = slot[1] + 0.0 +''', + '''probe() => + for slot = 0 to 1 + previous = slot[1] + 0.0 +''', + '''probe() => + for slot in array.from(0, 1) + previous = slot[1] + 0.0 +''', + ], + ids=["local", "block-local", "range-loop-binder", "for-in-binder"], +) +def test_scalar_bindings_shadowing_map_history_fail_closed(body: str) -> None: + source = f'''//@version=6 +strategy("map history scalar binding shadow") +var slot = map.new() +{body}observed = probe() +''' + with pytest.raises( + CompileError, + match="scalar local or loop bindings that shadow a map ID", + ): + transpile(source) + + +def test_handle_alias_copy_rebind_and_order_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.get("caller") != 10) return 3; + if (strategy.original.get("local") != 11) return 4; + if (strategy.original.get("after_copy") != 30) return 5; + if (strategy.alias.get("after_copy") != 30) return 6; + if (strategy.rebound.get("private") != 20) return 7; + if (strategy.rebound.contains("caller")) return 8; + if (strategy.copied.get("caller") != 10) return 9; + if (strategy.copied.contains("after_copy")) return 10; + if (strategy.temporary.size() != 0) return 11; + if (strategy.local_seen != 11) return 12; + const std::vector expected_keys{"a", "c", "b", "d"}; + const std::vector expected_values{100, 3, 20, 4}; + if (strategy.ordered.keys() != expected_keys) return 13; + if (strategy.ordered.values() != expected_values) return 14; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_HANDLE_SOURCE) + driver, label="pinemap-handle-runtime" + ) == "ok\n" + + +def test_pine_level_null_declaration_and_rebind_runtime() -> None: + cpp = transpile(_NULL_SOURCE) + assert "PineMap{}" in cpp + assert "na()" not in next( + line for line in cpp.splitlines() if "global_null =" in line + ) + 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.global_null_ok || !strategy.local_null_ok) return 3; + if (!strategy.global_null.is_na()) return 4; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, label="pinemap-null-runtime" + ) == "ok\n" + + +def test_receiver_key_value_evaluation_order_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; + const std::vector expected{1, 2, 3, 1, 2, 3, 1, 2, 3}; + if (strategy.order != expected) return 3; + if (!is_na(strategy.p1)) return 4; + if (strategy.p2 != 7 || strategy.p3 != 7) return 5; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_ORDER_SOURCE) + driver, label="pinemap-order-runtime" + ) == "ok\n" + + +def test_map_pair_loop_uses_insertion_order_and_public_api_runtime() -> None: + cpp = transpile(_MAP_LOOP_SOURCE) + assert "auto __pf_map_iter_" in cpp + assert ".keys())" in cpp + assert ".get(key)" 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_keys != "ba") return 3; + if (strategy.observed_values != 21) return 4; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, label="pinemap-pair-loop-runtime" + ) == "ok\n" + + +def test_map_pair_loop_temporary_avoids_user_binding_collision() -> None: + source = '''//@version=6 +strategy("PineMap pair loop collision") +var int __pf_map_iter_0 = 99 +var pairs = map.new() +pairs.put("a", 1) +for [key, value] in pairs + observed = value +''' + cpp = transpile(source) + assert "auto __pf_map_iter_1 = pairs;" in cpp + assert "auto __pf_map_iter_0 = pairs;" not in cpp + compile_env.compile_cpp(cpp, label="pinemap-pair-loop-collision") + + +@pytest.mark.parametrize( + ("as_method", "parameter", "key_name"), + [ + (False, "__pf_map_iter_0", "key"), + (False, "__pf_map_key_0", "_"), + (True, "__pf_map_iter_0", "key"), + (True, "__pf_map_key_0", "_"), + ], + ids=["udf-iter", "udf-key", "method-iter", "method-key"], +) +def test_map_pair_loop_temporary_avoids_parameter_collisions( + as_method: bool, parameter: str, key_name: str +) -> None: + if as_method: + prelude = '''type Accumulator + int seed +''' + declaration = ( + "method probe(Accumulator self, int " + f"{parameter}, map pairs) =>" + ) + setup = '''var Accumulator accumulator = Accumulator.new(0) +observed = accumulator.probe(10, pairs) +''' + else: + prelude = "" + declaration = ( + f"probe(int {parameter}, map pairs) =>" + ) + setup = "observed = probe(10, pairs)\n" + source = f'''//@version=6 +strategy("PineMap pair loop parameter collision") +{prelude}{declaration} + total = {parameter} + for [{key_name}, value] in pairs + total += value + total + {parameter} +var pairs = map.new() +pairs.put("a", 1) +pairs.put("b", 2) +{setup}''' + cpp = transpile(source) + if parameter == "__pf_map_iter_0": + assert "auto __pf_map_iter_0 = pairs;" not in cpp + else: + assert "for (auto __pf_map_key_0 :" not in cpp + assert f"return (total + {parameter});" in cpp + compile_env.compile_cpp( + cpp, + label=( + "pinemap-pair-loop-method-param-collision" + if as_method + else "pinemap-pair-loop-udf-param-collision" + ), + ) + + 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 != 23) return 3; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, + label=( + "pinemap-pair-loop-method-param-runtime" + if as_method + else "pinemap-pair-loop-udf-param-runtime" + ), + ) == "ok\n" + + +def test_coof_recursive_snapshot_restore_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.root.get("seed") != 1) return 3; + if (!strategy.nullable.is_na()) return 4; + strategy.snapshot_script_state(); + + 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.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.independent.put("copy_after", 3); + strategy.nullable = PineMap::new_(); + strategy.nullable.put("not_null", 4); + + strategy.restore_script_state(); + 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.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.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); + 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; + + strategy.restore_script_state(); + if (strategy.root.contains("shared")) return 20; + if (strategy.root.get("seed") != 1) return 21; + if (!strategy.nullable.is_na()) return 22; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_COOF_SOURCE) + driver, label="pinemap-coof-runtime" + ) == "ok\n" + + +def test_contextual_map_na_forms_emit_typed_handles_and_compile() -> None: + cpp = transpile(_CONTEXTUAL_NA_SOURCE) + assert "PineMap defaulted = PineMap{};" in cpp + assert "global_bare = PineMap{};" in cpp + assert "holder.defaulted = PineMap{};" in cpp + assert not [ + line + for line in cpp.splitlines() + if "PineMap" in line and "na()" in line + ] + compile_env.compile_cpp(cpp, label="pinemap-contextual-na") + + +def test_contextual_map_na_forms_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.global_bare.is_na() || !strategy.global_left.is_na()) return 3; + 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; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_CONTEXTUAL_NA_SOURCE) + driver, + label="pinemap-contextual-na-runtime", + ) == "ok\n" + + +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} choose(bool cond" in cpp + assert f"{map_type} choose_if(bool cond" in cpp + for name in ( + "method_lhs", + "direct_lhs", + "identity_lhs", + "made_copy", + "selected_ternary", + "selected_if", + ): + assert f"{map_type} {name};" in cpp + assert "None(" not in cpp + compile_env.compile_cpp(cpp, label="pinemap-return-propagation") + + +def test_map_return_specs_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; + const std::vector shared_keys{ + "seed", "method", "ternary", "if", "direct", "identity" + }; + for (const auto& key : shared_keys) { + if (!strategy.base.contains(key)) return 3; + if (!strategy.method_lhs.contains(key)) return 4; + if (!strategy.direct_lhs.contains(key)) return 5; + if (!strategy.identity_lhs.contains(key)) return 6; + if (!strategy.selected_ternary.contains(key)) return 7; + if (!strategy.selected_if.contains(key)) return 8; + } + if (!strategy.made_copy.contains("copy") + || strategy.base.contains("copy")) return 9; + if (!is_na(strategy.method_previous) + || !is_na(strategy.ternary_previous)) return 10; + if (!is_na(strategy.if_previous) + || !is_na(strategy.direct_previous)) return 11; + if (!is_na(strategy.identity_previous) + || !is_na(strategy.copy_previous)) return 12; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_MAP_RETURN_PROPAGATION_SOURCE) + driver, + label="pinemap-return-propagation-runtime", + ) == "ok\n" + + +def test_untyped_map_selection_returns_reach_lhs_and_chained_receivers() -> None: + cpp = transpile(_UNTYPED_MAP_SELECTION_SOURCE) + map_type = "PineMap" + for function in ( + "choose", + "branch", + "choose_na", + "branch_na", + "local_ternary", + "local_if", + ): + assert f"{map_type} {function}(" in cpp + for name in ( + "selected", + "branched", + "nullable_selected", + "nullable_branched", + "nullable_absent", + "branch_absent", + "local_selected", + "local_branched", + ): + assert f"{map_type} {name};" in cpp + assert ( + f"{map_type} local = ((cond) ? ({map_type}{{}}) : (left));" + in cpp + ) + assert f"{map_type} local = {map_type}();" in cpp + assert "None(" not in cpp + assert not [ + line + for line in cpp.splitlines() + if map_type in line and "na()" in line + ] + compile_env.compile_cpp(cpp, label="pinemap-untyped-selection-return") + + +def test_untyped_map_selection_returns_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.selected.contains("seed-left") + || !strategy.selected.contains("selected")) return 3; + if (!strategy.branched.contains("seed-right") + || !strategy.branched.contains("branched")) return 4; + if (!strategy.nullable_selected.contains("nullable")) return 5; + if (!strategy.nullable_branched.contains("local-if")) return 6; + if (!strategy.local_selected.contains("selected")) return 7; + if (!strategy.local_branched.contains("local-if")) return 8; + if (!strategy.nullable_absent.is_na() + || !strategy.branch_absent.is_na()) return 9; + if (strategy.right.contains("deep-copy")) return 10; + if (!is_na(strategy.selected_previous) + || !is_na(strategy.branched_previous)) return 11; + if (!is_na(strategy.deep_previous) + || !is_na(strategy.nullable_previous) + || !is_na(strategy.local_previous)) return 12; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_UNTYPED_MAP_SELECTION_SOURCE) + driver, + label="pinemap-untyped-selection-return-runtime", + ) == "ok\n" + + +def test_untyped_map_specs_propagate_through_deep_wrapper_chains() -> None: + cpp = transpile(_UNTYPED_MAP_CHAIN_SOURCE) + map_type = "PineMap" + for function in ( + "choose", + "select_layer", + "choose_nullable", + "nullable_layer", + "identity", + "identity_layer", + "deep_select", + "cycle_passthrough", + ): + assert f"{map_type} {function}(" in cpp + text_map_type = "PineMap" + for function in ("effect_leaf", "effect_middle", "effect_outer"): + assert f"std::string {function}({text_map_type} value)" in cpp + for name in ( + "selected", "copied", "cycled", + "nullable_selected", "nullable_absent", + ): + assert f"{map_type} {name};" in cpp + assert 'std::string effect_previous = std::string("");' in cpp + assert "None(" not in cpp + compile_env.compile_cpp(cpp, label="pinemap-untyped-wrapper-chain") + + +def test_untyped_map_wrapper_chains_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.selected.contains("seed-left") + || !strategy.selected.contains("selected")) return 3; + if (!strategy.cycled.contains("selected")) return 4; + if (strategy.words.get("effect") != "propagated") return 5; + if (!strategy.copied.contains("seed-right") + || !strategy.copied.contains("copy-only")) return 6; + if (strategy.right.contains("copy-only")) return 7; + if (!strategy.nullable_selected.contains("selected") + || !strategy.nullable_absent.is_na()) return 10; + if (!is_na(strategy.selected_previous) + || !is_na(strategy.copy_previous)) return 8; + if (!strategy.effect_previous.empty()) return 9; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + transpile(_UNTYPED_MAP_CHAIN_SOURCE) + driver, + label="pinemap-untyped-wrapper-chain-runtime", + ) == "ok\n" + + +def test_untyped_function_rejects_incompatible_map_specializations() -> None: + source = '''//@version=6 +strategy("PineMap incompatible untyped specializations") +identity(value) => value +var ints = map.new() +var strings = map.new() +first = identity(ints) +second = identity(strings) +''' + with pytest.raises( + CompileError, + match="cannot emit multiple map specializations", + ): + transpile(source) diff --git a/tests/test_pinemap_temp_receiver_var.py b/tests/test_pinemap_temp_receiver_var.py new file mode 100644 index 0000000..67dfbba --- /dev/null +++ b/tests/test_pinemap_temp_receiver_var.py @@ -0,0 +1,46 @@ +"""Persistent map results returned from temporary UDT method receivers.""" + +from __future__ import annotations + +from pineforge_codegen import transpile +from tests.test_pinemap_semantics import _compile_and_run + + +_SOURCE = '''//@version=6 +strategy("temporary UDT receiver persistent map") +type Holder + map values +method get(Holder self) => self.values +var base = map.new() +var inferred = Holder.new(base).get() +var map typed_result = Holder.new(base).get() +inferred.put("inferred", 11) +typed_result.put("typed", 22) +observed = Holder.new(base).get().get("inferred") +''' + + +def test_temporary_udt_receiver_map_results_use_exact_persistent_type() -> None: + cpp = transpile(_SOURCE) + assert "PineMap inferred;" in cpp + assert "PineMap typed_result;" in cpp + assert "Holder inferred;" not in cpp + assert "Holder typed_result;" 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.base.get("inferred") != 11) return 3; + if (strategy.base.get("typed") != 22) return 4; + if (strategy.inferred.get("typed") != 22) return 5; + if (strategy.typed_result.get("inferred") != 11) return 6; + if (strategy.observed != 11) return 7; + std::cout << "ok\n"; +} +''' + assert _compile_and_run( + cpp + driver, label="pinemap-temp-receiver-var" + ) == "ok\n" diff --git a/tests/test_runtime_var_initialization.py b/tests/test_runtime_var_initialization.py index 274ddc2..f757767 100644 --- a/tests/test_runtime_var_initialization.py +++ b/tests/test_runtime_var_initialization.py @@ -245,7 +245,7 @@ def test_runtime_scalar_route_does_not_duplicate_specialized_var_initializers(): "grid = PineMatrix::new_(1, 1, current_bar_.low);" ) == 1 assert init_block.count( - "lookup = std::unordered_map();" + "lookup = PineMap::new_();" ) == 1 assert init_block.count( "point = Point{.x = current_bar_.low, .__pf_na = false};"