Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions pineforge_codegen/analyzer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ def __init__(self, ast: Program, filename: str = "<stdin>") -> None:
self._block_var_seq = 0
self._ta_counter = 0
self._fixnan_counter = 0
# All fixnan member names minted so far (base + clones), for O(1)
# collision detection when minting a per-call-site fixnan clone.
self._fixnan_member_names: set[str] = set()
# Authoritative fixnan clone-name map for collisions: (func, cs_idx)
# -> {orig_member: cloned_member}. Consumed verbatim by the codegen
# when the default ``{base}_cs{cs_idx}`` formula would collide.
self._func_cs_fixnan_clone_names: dict[tuple[str, int], dict[str, str]] = {}
# Track user-defined function nodes for deferred analysis
self._func_defs: dict[str, FuncDef] = {}
# Track user-defined function return types
Expand All @@ -164,6 +171,12 @@ def __init__(self, ast: Program, filename: str = "<stdin>") -> None:
self._func_ta_ranges: dict[str, tuple[int, int]] = {} # func_name -> (start, end) indices
self._func_call_site_count: dict[str, int] = {} # func_name -> count
self._func_call_cs_map: dict[int, tuple[str, int]] = {} # call_node_id -> (func_name, cs_idx)
# Per-function fixnan site ownership: func_name -> list of fixnan site
# indices in self._fixnan_sites owned by that function. Mirrors the
# TA-range slicing but for fixnan state, so per-call-site cloning can
# mint fresh fixnan members per variant and the codegen dead-code pass
# can skip fixnan state owned by dead functions.
self._func_fixnan_indices: dict[str, list[int]] = {}
# Functions whose ONLY reason for needing per-call-site body cloning
# is a security-tf-monomorphized request.security (no TA/series state
# of their own — see _check_mixed_callsite_security_tf).
Expand Down Expand Up @@ -201,6 +214,13 @@ def __init__(self, ast: Program, filename: str = "<stdin>") -> None:
# a TA ctor length with one of the OUTER function's params, so the outer
# call site can re-substitute (e.g. f_bbwp(_bbwLen) -> f_basisMa(_len)).
self._enclosing_func_params: list[set[str]] = []
# Parallel stack of the function NAMES whose param-sets are in
# ``_enclosing_func_params``. The top of stack (or None at global
# scope) is the owner of any ORIGINAL ``ta.*`` site minted right now
# -- recorded on ``TACallSite.owner_func`` so the codegen dead-code
# pass can tell borrowed clones apart from a dead function's own
# sites (see contracts.TACallSite.owner_func).
self._enclosing_func_names: list[str] = []
# Set of TA-site indices a nested user-func call rewrote in terms of the
# current enclosing function's params (None when not inside a FuncDef body).
self._nested_ta_touched: set | None = None
Expand Down Expand Up @@ -303,6 +323,8 @@ def analyze(self) -> AnalyzerContext:
var_members=self._var_members,
func_infos=self._func_infos,
fixnan_sites=self._fixnan_sites,
func_fixnan_indices=self._func_fixnan_indices,
func_cs_fixnan_clone_names=self._func_cs_fixnan_clone_names,
strategy_params=self._strategy_params,
diagnostics=self._diagnostics,
filename=self._filename,
Expand Down Expand Up @@ -1163,13 +1185,15 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType:
old_global = self._global_scope
self._global_scope = False
self._enclosing_func_params.append(set(node.params))
self._enclosing_func_names.append(node.name)
self._nested_ta_touched = set()
try:
for stmt in node.body:
body_type = self._visit(stmt)
finally:
self._global_scope = old_global
self._enclosing_func_params.pop()
self._enclosing_func_names.pop()
nested_touched = self._nested_ta_touched
self._nested_ta_touched = None

Expand Down
70 changes: 68 additions & 2 deletions pineforge_codegen/analyzer/call_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@

from ..ast_nodes import (
ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess,
NumberLiteral, StringLiteral, TupleLiteral, UnaryOp, VarDecl,
NumberLiteral, StringLiteral, Ternary, TupleLiteral, UnaryOp, VarDecl,
)
from ..symbols import PineType
from .. import signatures as sigs
Expand Down Expand Up @@ -205,6 +205,7 @@ def _handle_ta_call(self, func_name: str, node: FuncCall) -> PineType:
returns_tuple=returns_tuple,
node=node,
is_static=is_static,
owner_func=(self._enclosing_func_names[-1] if self._enclosing_func_names else None),
)
self._ta_call_sites.append(site)
self._ta_member_names.add(site.member_name)
Expand Down Expand Up @@ -253,6 +254,7 @@ def _handle_ta_call(self, func_name: str, node: FuncCall) -> PineType:
returns_tuple=returns_tuple,
node=node,
is_static=is_static,
owner_func=(self._enclosing_func_names[-1] if self._enclosing_func_names else None),
)
self._ta_call_sites.append(site)
self._ta_member_names.add(site.member_name)
Expand Down Expand Up @@ -811,11 +813,18 @@ def _handle_fixnan_call(self, node: FuncCall) -> PineType:
arg_type = self._visit(arg)

self._fixnan_counter += 1
owner = self._enclosing_func_names[-1] if self._enclosing_func_names else None
site = FixnanCallSite(
member_name=f"_prev_fixnan_{self._fixnan_counter}",
pine_type=arg_type,
node=node,
owner_func=owner,
)
idx = len(self._fixnan_sites)
self._fixnan_sites.append(site)
self._fixnan_member_names.add(site.member_name)
if owner is not None:
self._func_fixnan_indices.setdefault(owner, []).append(idx)

return arg_type

Expand Down Expand Up @@ -843,13 +852,30 @@ def _is_arith(n) -> bool:
return _is_arith(n.left) and _is_arith(n.right)
if isinstance(n, UnaryOp):
return _is_arith(n.operand)
if isinstance(n, Ternary):
# ``cond ? a : b`` — expand when both branches are arith.
# The condition may be a comparison/logical over arith leaves;
# rely on the codegen stability gate to reject series deps.
return (_is_arith(n.true_val) and _is_arith(n.false_val)
and _is_arith(n.condition))
if isinstance(n, MemberAccess):
# ``timeframe.*`` / ``syminfo.*`` / ``math.pi`` etc. — stable
# per-run scalars that may appear inside a function-local
# derived length. Let the codegen stability classifier decide.
return True
if isinstance(n, FuncCall):
# Allow math.* helpers (math.round/sqrt/...) over arith args.
callee = n.callee
# Allow math.* helpers (math.round/sqrt/cos/...) over arith args.
if (isinstance(callee, MemberAccess)
and isinstance(callee.object, Identifier)
and callee.object.name == "math"):
return all(_is_arith(a) for a in n.args)
# Pine type-cast builtins ``int(x)`` / ``float(x)`` / ``bool(x)``
# / ``string(x)`` — transparent over arith args. Common in
# derived TA lengths (``int(math.round(2 / a))``).
if (isinstance(callee, Identifier)
and callee.name in ("int", "float", "bool", "string")):
return all(_is_arith(a) for a in n.args)
return False

reassigned: set[str] = set()
Expand Down Expand Up @@ -1063,12 +1089,52 @@ def _rep(m: re.Match) -> str:
returns_tuple=orig.returns_tuple,
node=orig.node,
is_static=orig.is_static,
# The clone belongs to the CALLEE's per-call-site
# namespace (``func_name``), NOT the caller whose
# body visit minted it. The codegen dead-code pass
# uses owner_func to decide whether to drop the
# declaration: a clone owned by a live callee must
# survive even when minted during a dead caller's
# body visit (regression: quantbyboji DMI).
owner_func=func_name,
)
self._ta_call_sites.append(cloned)
self._ta_member_names.add(clone_name)
if clone_name_map:
self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map

# Clone fixnan sites for cs_idx > 0 so each emitted variant of
# this function references its OWN previous-value member (two
# call sites must not share fixnan state -- the second would
# otherwise read the first's last non-na value). cs0 keeps the
# originals. The clone name follows the ``{orig}_cs{cs_idx}``
# formula the codegen re-derives (mirrors TA clone naming).
fn_indices = self._func_fixnan_indices.get(func_name, [])
if cs_idx > 0 and fn_indices:
clone_map: dict[str, str] = {}
for fi in fn_indices:
orig = self._fixnan_sites[fi]
fn_clone_name = f"{orig.member_name}_cs{cs_idx}"
# Disambiguate collisions (a fixnan site reached through
# >1 enclosing function could collide on the formula).
if fn_clone_name in self._fixnan_member_names:
base = fn_clone_name
n = 2
while fn_clone_name in self._fixnan_member_names:
fn_clone_name = f"{base}_u{n}"
n += 1
clone_map[orig.member_name] = fn_clone_name
cloned_fn = FixnanCallSite(
member_name=fn_clone_name,
pine_type=orig.pine_type,
node=orig.node,
owner_func=func_name,
)
self._fixnan_sites.append(cloned_fn)
self._fixnan_member_names.add(fn_clone_name)
if clone_map:
self._func_cs_fixnan_clone_names[(func_name, cs_idx)] = clone_map

# Create or update FuncInfo
is_tuple = self._func_returns_tuple.get(func_name, False)
tuple_count = self._func_tuple_element_count.get(func_name, 0)
Expand Down
29 changes: 29 additions & 0 deletions pineforge_codegen/analyzer/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ class TACallSite:
returns_tuple: bool # e.g., MACD, supertrend
node: Any = None # the FuncCall AST node
is_static: bool = False # true if global scope & arguments are recursively static
# Name of the user function that OWNS this site, or None for a top-level
# site. For an ORIGINAL site this is the function whose body textually
# contains the ``ta.*`` call. For a CLONE minted in
# ``_handle_user_func_call`` this is the CALLEE (the function being
# called, NOT the caller whose body visit triggered the clone) -- the
# clone belongs to the callee's per-call-site namespace.
#
# The codegen's dead-code pass keys off this rather than off
# ``func_ta_ranges`` slices, because a function's slice can include
# clones of ANOTHER (live) function's sites (minted while visiting a
# caller's body). Marking such a borrowed clone dead would leave the
# owning callee's emitted clone body referencing undeclared members
# (regression: quantbyboji-nq-hma-midday ``_ta_change_*_cs1``).
owner_func: str | None = None


@dataclass
Expand Down Expand Up @@ -87,6 +101,13 @@ class FixnanCallSite:
"""Per-call-site state for ``fixnan(...)`` (one previous-value member each)."""
member_name: str # e.g., "_prev_fixnan_1"
pine_type: Any # PineType
node: Any = None # the FuncCall AST node (for variant-aware lookup)
# Name of the user function that OWNS this site, or None for a top-level
# site. Mirrors ``TACallSite.owner_func``: the codegen's dead-code pass
# and per-variant clone logic key off this so a fixnan site minted inside
# a dead caller's body but cloned for a live callee survives, and each
# emitted function variant references its OWN fixnan member.
owner_func: str | None = None


@dataclass
Expand Down Expand Up @@ -164,6 +185,14 @@ class AnalyzerContext:
var_members: list = field(default_factory=list) # [(name, PineType, init_expr_str)]
func_infos: list = field(default_factory=list)
fixnan_sites: list = field(default_factory=list)
# Per-function fixnan site ownership (func_name -> list of indices into
# ``fixnan_sites``). Used by the codegen to clone fixnan members per
# call-site variant and to skip fixnan state owned by dead functions.
func_fixnan_indices: dict = field(default_factory=dict)
# (func_name, cs_idx) -> {orig_member_name: cloned_member_name}. Like
# ``func_cs_ta_clone_names`` but for fixnan: populated only when the
# default ``{base}_cs{cs_idx}`` clone name collides.
func_cs_fixnan_clone_names: dict = field(default_factory=dict)
strategy_params: dict = field(default_factory=dict)
diagnostics: list = field(default_factory=list) # warnings
filename: str = "<stdin>"
Expand Down
Loading
Loading