From ac70d0b8b17e30026bd2c8938d163f4f618cfd57 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sat, 11 Jul 2026 03:36:43 +0800 Subject: [PATCH] Classify UDF-wrapped and var-scalar stable expressions as TA lengths The stable-TA-length guard hard-rejected two bar-invariant shapes: a TA constructor length computed through a pure single-expression user-defined function (any UDF call was treated as series-valued), and a never-reassigned var scalar whose initializer is a stable per-run expression (all var names were treated as mutable state). The stability classifier and the reset-expression renderer now inline single-ExprStmt-body UDFs structurally (parameter substitution over the arithmetic/call grammar; cycle guard and depth cap at the shared choke-point, so recursive UDFs are refused without hanging), and a first pass registers never-reassigned top-level var/varip scalars with stable initializers as stable derived expressions without inlining their use-sites. Genuinely series-dynamic lengths (e.g. a ternary over an ATR-derived ratio) are still rejected with the same error. Note: block-form single-expression UDFs parse with is_single_expr set false; the inliner keys off the body being exactly one expression statement, not that flag. Co-Authored-By: Claude Fable 5 --- pineforge_codegen/codegen/base.py | 242 +++++++++++++++++++++--- tests/test_codegen_ta_derived_length.py | 169 +++++++++++++++++ 2 files changed, 389 insertions(+), 22 deletions(-) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 4e70dff..4350262 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -1262,6 +1262,11 @@ def _collect_known_vars(self) -> None: # First, find all variables that are reassigned anywhere in the AST. # These cannot be inlined as constants since their value changes at runtime. reassigned = self._find_reassigned_vars() + # Register never-reassigned ``var``/``varip`` scalars with a stable + # init FIRST — a later derived var (or a UDF body) may reference such a + # scalar as a stable length component, so it must be classified stable + # before those exprs are evaluated below. + self._collect_stable_var_scalars(reassigned) for stmt in self.ctx.ast.body: if isinstance(stmt, VarDecl) and stmt.name not in reassigned: self._collect_known_var(stmt) @@ -1274,6 +1279,48 @@ def _collect_known_vars(self) -> None: # dependent reassignments are left untracked (rejected by the guard). self._collect_reassigned_stable_scalars(reassigned) + def _collect_stable_var_scalars(self, reassigned: set[str]) -> None: + """Track top-level ``var``/``varip`` scalars declared exactly once (never + reassigned) from a stable init expression. + + A ``var`` scalar's one-shot initializer runs once and the value never + changes across bars, so a never-reassigned ``var`` over a stable init + (``var int _tfSec = timeframe.in_seconds()``) is a bar-invariant scalar — + safe to embed in a TA ctor runtime-reset expression. Recording it in + ``_derived_input_expr`` + ``_stable_runtime_vars`` lets the reset path + expand the name and lets ``_expr_is_stable`` classify it (the + ``_stable_runtime_vars`` check precedes the ``_var_names`` rejection). + + A ``var`` that is reassigned anywhere (``:=`` in ``reassigned``), is a + series var, or is initialized from a non-stable value stays untracked — + so a TA length fed by genuinely-mutable persistent state is still + rejected by the constructor guard. Names are NOT folded into + ``_known_vars`` (no use-site inlining): only the length-analysis path is + affected, and the ``var`` member still emits and initializes normally. + """ + for stmt in (self.ctx.ast.body or []): + if not isinstance(stmt, VarDecl): + continue + if not (stmt.is_var or stmt.is_varip): + continue + if stmt.name in reassigned: + continue + if stmt.name in self.ctx.series_vars: + continue + if stmt.value is None or not self._expr_is_stable(stmt.value): + continue + expr_str = self._arith_expr_to_str(stmt.value) + if expr_str is None: + continue + self._derived_input_expr[stmt.name] = expr_str + self._stable_runtime_vars.add(stmt.name) + # Mark input-backed iff the init references an input, so the reset + # emits override-aware get_input_*() reads for it. + import re as _re + toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", expr_str)) + if any(t in self._input_backed_vars for t in toks): + self._input_backed_vars.add(stmt.name) + def _collect_reassigned_stable_scalars(self, reassigned: set[str]) -> None: """Track class-scope scalars that are reassigned but only along stable if/elif paths (see ``test_stable_reassigned_class_scope_length``). @@ -1438,7 +1485,117 @@ def walk(node): "ismonthly", "isdwm", "isseconds", "in_seconds", "isticks", }) - def _expr_is_stable(self, node) -> bool: + # Depth ceiling for inlining nested single-expression user functions while + # classifying a TA length's stability. Pine forbids recursion, so any real + # chain is shallow; the cap is a backstop against pathological input and is + # enforced together with a name-stack cycle guard. + _UDF_INLINE_MAX_DEPTH = 16 + + def _get_udf_def(self, name: str): + """Return the top-level single-name ``FuncDef`` for ``name`` (or None). + + Built lazily and cached. UDT ``MethodDef``s are intentionally excluded — + only a free function can appear as a bare-name TA length call. + """ + cache = getattr(self, "_udf_def_cache", None) + if cache is None: + cache = {} + for stmt in (self.ctx.ast.body or []): + if isinstance(stmt, FuncDef): + # Last definition wins; a name map is all the length path needs. + cache[stmt.name] = stmt + self._udf_def_cache = cache + return cache.get(name) + + def _inline_single_expr_udf(self, node, _udf_stack: frozenset = frozenset(), + _depth: int = 0): + """If ``node`` is a call to a user-defined SINGLE-EXPRESSION function, + return its body expression with each parameter substituted by the + corresponding call-argument node. Returns None when the call is not such + a function, the arity/kwargs do not match, the body is not a single + expression, the body contains a shape we do not clone, or the call would + recurse (cycle / depth-limit). + + Purely structural: it does NOT judge stability (the caller does, via + ``_expr_is_stable`` on the returned node). The conservative None keeps + the TA-ctor guard intact. + """ + if not isinstance(node, FuncCall): + return None + func_name, namespace = self._resolve_callee(node.callee) + if namespace is not None or func_name is None: + return None + if func_name in _udf_stack or _depth >= self._UDF_INLINE_MAX_DEPTH: + return None + fdef = self._get_udf_def(func_name) + if fdef is None: + return None + # A body that is exactly ONE expression statement is inlinable, whether + # written inline after ``=>`` (``is_single_expr=True``) or as a one-line + # indented block (``f(x) =>`` then a single indented expr, which the + # parser records as ``is_single_expr=False`` with a one-ExprStmt body — + # the gonzowiththewind-sisyphus ``f_bars`` shape). A multi-statement + # body (len != 1, or a non-ExprStmt) is conservatively refused. + body = fdef.body + if not body or len(body) != 1 or not isinstance(body[0], ExprStmt): + return None + # Require a plain positional call: one arg per param, no kwargs, no + # default-parameter fill-in — anything else is conservatively refused. + if node.kwargs or len(node.args) != len(fdef.params): + return None + subst = dict(zip(fdef.params, node.args)) + return self._subst_params(body[0].expr, subst) + + def _subst_params(self, node, subst: dict): + """Return a copy of ``node`` with every ``Identifier`` whose name is a + key in ``subst`` replaced by the mapped argument node. Returns None for + any node outside the small arithmetic/call grammar we fold (an + unrecognised construct — e.g. a history ``Subscript`` — conservatively + aborts the inline). ``dataclasses.replace`` preserves ``loc``/ + ``annotations`` so diagnostics still point at real source spans.""" + import dataclasses as _dc + if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral, NaLiteral)): + return node + if isinstance(node, Identifier): + return subst.get(node.name, node) + if isinstance(node, MemberAccess): + obj = self._subst_params(node.object, subst) + if obj is None: + return None + return _dc.replace(node, object=obj) + if isinstance(node, Ternary): + c = self._subst_params(node.condition, subst) + t = self._subst_params(node.true_val, subst) + f = self._subst_params(node.false_val, subst) + if c is None or t is None or f is None: + return None + return _dc.replace(node, condition=c, true_val=t, false_val=f) + if isinstance(node, BinOp): + l = self._subst_params(node.left, subst) + r = self._subst_params(node.right, subst) + if l is None or r is None: + return None + return _dc.replace(node, left=l, right=r) + if isinstance(node, UnaryOp): + o = self._subst_params(node.operand, subst) + if o is None: + return None + return _dc.replace(node, operand=o) + if isinstance(node, FuncCall): + if node.kwargs: + return None + new_args = [] + for a in node.args: + sa = self._subst_params(a, subst) + if sa is None: + return None + new_args.append(sa) + return _dc.replace(node, args=new_args) + # Subscript (history read) and anything else: not a stable-length shape. + return None + + def _expr_is_stable(self, node, _udf_stack: frozenset = frozenset(), + _depth: int = 0) -> bool: """True iff ``node``'s value is a bar-invariant scalar. A stable expression depends only on: literals, ``input.*`` values, @@ -1496,13 +1653,14 @@ def _expr_is_stable(self, node) -> bool: # History read (``close[1]``) or indexed access — per-bar. return False if isinstance(node, Ternary): - return (self._expr_is_stable(node.condition) - and self._expr_is_stable(node.true_val) - and self._expr_is_stable(node.false_val)) + return (self._expr_is_stable(node.condition, _udf_stack, _depth) + and self._expr_is_stable(node.true_val, _udf_stack, _depth) + and self._expr_is_stable(node.false_val, _udf_stack, _depth)) if isinstance(node, BinOp): - return self._expr_is_stable(node.left) and self._expr_is_stable(node.right) + return (self._expr_is_stable(node.left, _udf_stack, _depth) + and self._expr_is_stable(node.right, _udf_stack, _depth)) if isinstance(node, UnaryOp): - return self._expr_is_stable(node.operand) + return self._expr_is_stable(node.operand, _udf_stack, _depth) if isinstance(node, FuncCall): func_name, namespace = self._resolve_callee(node.callee) if namespace == "ta": @@ -1510,20 +1668,40 @@ def _expr_is_stable(self, node) -> bool: if namespace == "math": if func_name not in self._MATH_STABLE_MEMBERS: return False - return all(self._expr_is_stable(a) for a in node.args) + return all(self._expr_is_stable(a, _udf_stack, _depth) + for a in node.args) if namespace == "timeframe": # ``timeframe.in_seconds()`` (and any other function-form # timeframe member) is a stable per-run scalar — it reflects # the script's resolution, not a per-bar value. if func_name not in self._TF_STABLE_MEMBERS: return False - return all(self._expr_is_stable(a) for a in node.args) + return all(self._expr_is_stable(a, _udf_stack, _depth) + for a in node.args) if namespace == "input": return True if namespace is None and func_name in ("int", "float", "bool", "string"): - return all(self._expr_is_stable(a) for a in node.args) - # Any other call (user functions, str.*, array.*, ...) — series - # by default; the conservative answer keeps the guard honest. + return all(self._expr_is_stable(a, _udf_stack, _depth) + for a in node.args) + # A user-defined single-expression function is stable iff every + # argument is stable AND its body (with the params bound to those + # args) is stable — i.e. the body references only stable scalars + # (inputs / consts / timeframe.* / math.* / never-reassigned var + # scalars) and no series / strategy state / ta.* results. Inlining + # the body (params substituted by the arg nodes) lets the ordinary + # classifier decide; the name-stack + depth guard refuses recursion + # so a cyclic / malformed UDF is rejected, not looped forever. + if namespace is None and func_name is not None: + inlined = self._inline_single_expr_udf(node, _udf_stack, _depth) + if inlined is not None: + if not all(self._expr_is_stable(a, _udf_stack, _depth) + for a in node.args): + return False + return self._expr_is_stable( + inlined, _udf_stack | {func_name}, _depth + 1) + # Any other call (multi-statement user functions, str.*, array.*, + # ...) — series by default; the conservative answer keeps the guard + # honest. return False return False @@ -1536,22 +1714,28 @@ def _expr_is_stable(self, node) -> bool: # re-parsed and lowered through the expression visitor). _ATOMIC_ARITH_NODES = (NumberLiteral, Identifier, MemberAccess, FuncCall) - def _arith_operand_to_str(self, node) -> str | None: + def _arith_operand_to_str(self, node, _udf_stack: frozenset = frozenset(), + _depth: int = 0) -> str | None: """Serialize ``node`` for use as an operand: parenthesize it unless its serialized form is already self-delimiting, so grouping survives a round-trip through the parser.""" - s = self._arith_expr_to_str(node) + s = self._arith_expr_to_str(node, _udf_stack, _depth) if s is None: return None if isinstance(node, self._ATOMIC_ARITH_NODES): return s return f"({s})" - def _arith_expr_to_str(self, node) -> str | None: + def _arith_expr_to_str(self, node, _udf_stack: frozenset = frozenset(), + _depth: int = 0) -> str | None: """Render a numeric arithmetic-over-identifiers expression to a string that re-parses to the SAME tree (grouping preserved via ``_arith_operand_to_str``). Returns None for any node shape we don't fold (series subscripts, etc.) so the caller leaves the var untracked. + + ``_udf_stack``/``_depth`` guard the single-expression-UDF inlining below + against recursion cycles (Pine forbids recursion, but a malformed source + must be refused, not looped forever). """ if isinstance(node, NumberLiteral): v = node.value @@ -1563,30 +1747,44 @@ def _arith_expr_to_str(self, node) -> str | None: if isinstance(node, MemberAccess) and isinstance(node.object, Identifier): return f"{node.object.name}.{node.member}" if isinstance(node, BinOp): - l = self._arith_operand_to_str(node.left) - r = self._arith_operand_to_str(node.right) + l = self._arith_operand_to_str(node.left, _udf_stack, _depth) + r = self._arith_operand_to_str(node.right, _udf_stack, _depth) if l is None or r is None: return None return f"{l} {node.op} {r}" if isinstance(node, UnaryOp): - o = self._arith_operand_to_str(node.operand) + o = self._arith_operand_to_str(node.operand, _udf_stack, _depth) if o is None: return None return f"{node.op}{o}" if isinstance(node, Ternary): - c = self._arith_operand_to_str(node.condition) - t = self._arith_operand_to_str(node.true_val) - f = self._arith_operand_to_str(node.false_val) + c = self._arith_operand_to_str(node.condition, _udf_stack, _depth) + t = self._arith_operand_to_str(node.true_val, _udf_stack, _depth) + f = self._arith_operand_to_str(node.false_val, _udf_stack, _depth) if c is None or t is None or f is None: return None return f"{c} ? {t} : {f}" if isinstance(node, FuncCall): - callee = self._arith_expr_to_str(node.callee) + # A bare-name call to a single-expression user function has no C++ + # counterpart at class scope — inline its body (params substituted + # by the arg expressions) so the rendered string is pure + # math/timeframe/input arithmetic the ctor-reset path can expand. + # Namespaced calls (math.*/timeframe.*/int(...)) fall through to the + # ordinary ``callee(args)`` rendering below. The stack/depth guard + # refuses a recursive UDF (returns None -> caller leaves it untracked + # -> the ctor guard rejects it loudly) instead of recursing forever. + fn, ns = self._resolve_callee(node.callee) + if ns is None and fn is not None and self._get_udf_def(fn) is not None: + inlined = self._inline_single_expr_udf(node, _udf_stack, _depth) + if inlined is None: + return None + return self._arith_expr_to_str(inlined, _udf_stack | {fn}, _depth + 1) + callee = self._arith_expr_to_str(node.callee, _udf_stack, _depth) if callee is None: return None parts = [] for a in node.args: - s = self._arith_expr_to_str(a) + s = self._arith_expr_to_str(a, _udf_stack, _depth) if s is None: return None parts.append(s) diff --git a/tests/test_codegen_ta_derived_length.py b/tests/test_codegen_ta_derived_length.py index 871a851..5d6a82c 100644 --- a/tests/test_codegen_ta_derived_length.py +++ b/tests/test_codegen_ta_derived_length.py @@ -596,3 +596,172 @@ def test_reset_uses_float_division_for_two_int_operands(): assert "std::pow" in reset assert "ta::EMA(1)" not in reset + +# =========================================================================== +# 8. UDF-wrapped stable lengths + never-reassigned ``var`` scalar lengths. +# +# Two false-positive shapes were hard-rejected by the stable-length guard even +# though the length is a bar-invariant scalar (gonzowiththewind-sisyphus): +# GAP-1 a single-expression user function over stable args +# (``swingBars = f_bars(swingMinutes)``, +# ``f_bars(m) => math.max(1, math.round(m*60.0/_tfSec))``) — the UDF +# boundary blocked length-stability analysis. +# GAP-2 a ``var`` scalar initialized once from a stable per-run expression +# and NEVER reassigned (``var int _tfSec = timeframe.in_seconds()``) +# was treated as mutable persistent state. +# +# The sentinel that MUST stay rejected: a length that is genuinely series- +# dynamic (wellmanapex ``activeSwingLookback`` — a ternary over a ta.atr +# ratio). Covered by ``test_series_dependent_ternary_length_rejected`` / +# ``test_series_reassigned_ternary_length_rejected`` above, plus the UDF-over- +# series and reassigned-var-body guards below. +# --------------------------------------------------------------------------- + + +# R1 — UDF-wrapped stable length (isolates GAP-1: no var involved). +def test_udf_wrapped_stable_length_transpiles(): + src = """//@version=6 +strategy("udf-stable-len") +swingInput = input.int(7, "Swing Bars") +f_len(int m) => math.max(1, m * 2) +n = f_len(swingInput) +plot(ta.ema(close, n)) +""" + cpp = transpile(src) + members = re.findall(r"(_ta_ema_\d+)\(", cpp) + assert members, "no EMA member emitted" + member = members[0] + # ctor placeholder; the runtime reset overwrites it on the first bar. + assert _ctor_period(cpp, member) == "1" + reset = _reset_line(cpp, member) + assert reset, "no runtime reset emitted for UDF-wrapped length" + # The UDF body is inlined and expanded to an override-aware input read. + assert 'get_input_int("Swing Bars", 7)' in reset + assert "std::max" in reset + # No dangling UDF call spelling / bare param may survive. + assert "f_len" not in reset + assert " m " not in reset and "= m;" not in reset + assert "ta::EMA(1)" not in reset # must NOT collapse to the silent no-op + + +# R2 — never-reassigned ``var`` scalar stable-init used in a length (isolates +# GAP-2: no UDF involved). +def test_never_reassigned_var_scalar_length_transpiles(): + src = """//@version=6 +strategy("var-scalar-len") +barsPerHour = input.int(60, "Bars Per Hour") +var int _tfSec = timeframe.in_seconds() +lenv = math.max(1, math.round(barsPerHour * 60.0 / _tfSec)) +plot(ta.sma(close, lenv)) +""" + cpp = transpile(src) + members = re.findall(r"(_ta_sma_\d+)\(", cpp) + assert members, "no SMA member emitted" + member = members[0] + assert _ctor_period(cpp, member) == "1" + reset = _reset_line(cpp, member) + assert reset, "no runtime reset emitted for var-scalar length" + # Input read + timeframe helper both render into the reset. + assert 'get_input_int("Bars Per Hour", 60)' in reset + assert "tf_to_seconds(script_tf_)" in reset + assert "std::max" in reset + assert "std::round" in reset + # The stable var must be fully expanded; no bare ``_tfSec`` symbol leaks. + assert "_tfSec" not in reset + assert "timeframe.in_seconds" not in reset + assert "ta::SMA(1)" not in reset + + +# R3 — combined shape: the exact gonzowiththewind-sisyphus reduction (both +# gaps at once — a single-expr UDF whose body reads a never-reassigned var). +def test_udf_over_var_scalar_length_transpiles(): + src = """//@version=6 +strategy("udf-over-var-len") +swingMinutes = input.float(3.0, "Swing Minutes", minval=0.5) +var int _tfSec = timeframe.in_seconds() +f_bars(float minutes) => + math.max(1, math.round(minutes * 60.0 / _tfSec)) +swingBars = f_bars(swingMinutes) +recentLow = ta.lowest(low[1], swingBars) +plot(recentLow) +""" + cpp = transpile(src) + members = re.findall(r"(_ta_lowest_\d+)\(", cpp) + assert members, "no Lowest member emitted" + member = members[0] + assert _ctor_period(cpp, member) == "1" + reset = _reset_line(cpp, member) + assert reset, "no runtime reset emitted for UDF-over-var length" + # Both the input (via the UDF arg) and the timeframe var expand into the + # reset — override-aware and referencing no dangling locals. (Pine ``float`` + # inputs lower to the engine's ``get_input_double`` accessor.) + assert 'get_input_double("Swing Minutes", 3' in reset + assert "tf_to_seconds(script_tf_)" in reset + assert "std::max" in reset + assert "std::round" in reset + assert "f_bars" not in reset + assert "_tfSec" not in reset + assert "swingBars" not in reset + + +# G1 — a UDF call whose ARG is a series value stays rejected: the args must be +# stable for the inlined body to be a stable length. +def test_udf_over_series_arg_length_rejected(): + src = """//@version=6 +strategy("udf-series-arg") +f_len(int m) => math.max(1, m) +n = f_len(int(ta.atr(14))) +plot(ta.ema(close, n)) +""" + with pytest.raises(CompileError) as ei: + transpile(src) + assert "Unsupported TA constructor length" in str(ei.value) + + +# G2 — a single-expr UDF whose body reads a REASSIGNED var (genuinely mutable +# persistent state) stays rejected. +def test_udf_over_reassigned_var_body_length_rejected(): + src = """//@version=6 +strategy("udf-reassigned-var") +inp = input.int(5, "Len") +var int _acc = 0 +_acc := _acc + 1 +f_bad(int m) => math.max(1, m + _acc) +n = f_bad(inp) +plot(ta.ema(close, n)) +""" + with pytest.raises(CompileError) as ei: + transpile(src) + assert "Unsupported TA constructor length" in str(ei.value) + + +# G3 — a self-recursive UDF is refused without hanging (Pine forbids recursion; +# the inliner must break the cycle and reject rather than loop forever). +def test_recursive_udf_length_refused_without_hang(): + src = """//@version=6 +strategy("udf-recursive") +inp = input.int(5, "Len") +f_rec(int m) => f_rec(m) + 1 +n = f_rec(inp) +plot(ta.ema(close, n)) +""" + with pytest.raises(CompileError): + transpile(src) + + +# G4 — a MULTI-statement UDF used as a class-scope length is conservatively +# rejected (only single-expression UDFs are inlined for length stability). +def test_multi_statement_udf_length_rejected(): + src = """//@version=6 +strategy("udf-multi-stmt") +inp = input.int(5, "Len") +f_multi(int m) => + x = m * 2 + x + 1 +n = f_multi(inp) +plot(ta.ema(close, n)) +""" + with pytest.raises(CompileError) as ei: + transpile(src) + assert "Unsupported TA constructor length" in str(ei.value) +