diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 5801dba..171a21e 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -2275,7 +2275,10 @@ def _build_security_expr( op = cpp_ops.get(expr_node.op, expr_node.op) if expr_node.op == "%": return f"std::fmod((double)({left}), (double)({right}))" - return f"({left} {op} {right})" + # KI-71: honour Pine's falsy-on-na relational rule inside + # request.security expressions too (this builder is a second + # relational emission site independent of _visit_binop). + return self._lower_relational(op, expr_node.left, expr_node.right, left, right) if isinstance(expr_node, UnaryOp): operand = self._build_security_expr( diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index abe3c44..a4de02a 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -166,6 +166,20 @@ def _color_literal_to_int64(value: str) -> str: }) +# KI-71: Pine relational comparisons (``==`` ``!=`` ``<`` ``>`` ``<=`` ``>=``) +# with an ``na`` operand evaluate *falsy*. Naive C++ relationals do not honour +# this for the engine's na sentinels, so these ops route through na-aware +# lowering when an operand can be na (see ``_visit_binop`` / ``_operand_na_kind``). +_RELATIONAL_OPS: frozenset[str] = frozenset({"==", "!=", "<", ">", "<=", ">="}) + +# C++ scalar types carrying a detectable ``na`` sentinel via ``is_na``: +# ``double`` -> NaN (IEEE), ``int``/``int64_t`` -> ``numeric_limits::min()``. +# Only these route through the na-aware relational lowering — ``is_na`` has no +# overload for ``bool``/``std::string``/vector/UDT-value operands, and Pine's +# na-bool is engine-indistinguishable from ``false`` (na() == false). +_NA_SCALAR_CPP: frozenset[str] = frozenset({"int", "int64_t", "double"}) + + class ExprVisitor: """Expression-level visitor methods shared across the codegen. @@ -760,6 +774,78 @@ def _visit_member_access(self, node: MemberAccess) -> str: return f'std::string("{node.member}")' return f"{obj}.{node.member}" + def _operand_na_kind(self, node, cpp_type: str) -> str | None: + """Classify a relational operand by the ``na`` sentinel it can carry. + + Returns: + + * ``"int"`` — an ``int``/``int64_t`` expression that can hold the + ``INT_MIN`` sentinel. Because that sentinel is a *finite* very-negative + integer (not NaN), naive C++ diverges from Pine's falsy-on-na rule for + EVERY relational — ordered (``<`` ``>`` ``<=`` ``>=``) and equality + (``==`` ``!=``) alike. + * ``"float"`` — a ``double`` expression that can hold NaN. IEEE already + yields ``false`` for ``==`` ``<`` ``>`` ``<=`` ``>=`` against NaN + (matching Pine's falsy), so the ONLY diverging float cell is ``!=`` + (IEEE ``NaN != x`` is true; Pine is falsy). + * ``None`` — provably not na (numeric/bool literal, inlined + compile-time constant) or a non-scalar type with no ``is_na`` overload; + the naive emission is already correct. + """ + if cpp_type not in _NA_SCALAR_CPP: + return None + # Literals are never na. + if isinstance(node, (NumberLiteral, BoolLiteral)): + return None + # A bare ``na`` lowers to ``na()`` — a real NaN, i.e. it IS na. + if self._is_na_expr(node): + return "float" + # Inlined compile-time constants (non-input known vars) never hold na. + if (isinstance(node, Identifier) + and node.name in self._known_vars + and node.name not in self._input_backed_vars): + return None + return "int" if cpp_type in ("int", "int64_t") else "float" + + def _emit_na_relational(self, op: str, left: str, right: str) -> str: + """Emit an na-aware relational: ``false`` when either operand is ``na``. + + Mirrors the ``nz()`` lambda idiom (``visit_call``): each operand is + hoisted to a temporary so a stateful operand expression is evaluated + exactly once (no double-step), then compared only when neither side is + na. ``is_na`` resolves via the emitted ``using namespace pineforge;`` + (``double`` -> ``isnan``; integral -> ``== numeric_limits::min()``). + """ + return (f"([&]{{ auto _pna_l = ({left}); auto _pna_r = ({right}); " + f"return !is_na(_pna_l) && !is_na(_pna_r) && " + f"(_pna_l {op} _pna_r); }}())") + + def _lower_relational(self, op: str, left_node, right_node, + left_cpp: str, right_cpp: str) -> str: + """Lower a Pine relational to C++, applying KI-71 na-aware wrapping. + + Shared by ``_visit_binop`` and the ``request.security`` expression + builder so EVERY relational emission site honours Pine's falsy-on-na + rule. Wraps only the diverging cells: any na-capable *integer* operand + (the INT_MIN sentinel poisons all six operators) or a ``!=`` with an + na-capable *float* operand (the sole IEEE-diverging float cell). Pure + ``double`` ``==`` ``<`` ``>`` ``<=`` ``>=`` keep the naive emission — + IEEE is already falsy-on-NaN there, so wrapping would be a pure no-op. + Non-relational ``op`` (or non-scalar operands with no ``is_na``) fall + through to the naive ``(left op right)`` form unchanged. + """ + if op in _RELATIONAL_OPS: + lt = self._infer_type(left_node) + rt = self._infer_type(right_node) + if lt in _NA_SCALAR_CPP and rt in _NA_SCALAR_CPP: + lk = self._operand_na_kind(left_node, lt) + rk = self._operand_na_kind(right_node, rt) + int_na = "int" in (lk, rk) + float_na = "float" in (lk, rk) + if int_na or (op == "!=" and float_na): + return self._emit_na_relational(op, left_cpp, right_cpp) + return f"({left_cpp} {op} {right_cpp})" + def _visit_binop(self, node: BinOp) -> str: left = self._visit_expr(node.left) right = self._visit_expr(node.right) @@ -786,7 +872,7 @@ def _as_string(rendered, inferred): # Ref: https://www.tradingview.com/pine-script-docs/concepts/operators/ if node.op == "/": return f"((double)({left}) / (double)({right}))" - return f"({left} {op} {right})" + return self._lower_relational(op, node.left, node.right, left, right) def _visit_unaryop(self, node: UnaryOp) -> str: operand = self._visit_expr(node.operand) diff --git a/tests/test_na_relational_lowering.py b/tests/test_na_relational_lowering.py new file mode 100644 index 0000000..dd3e8b1 --- /dev/null +++ b/tests/test_na_relational_lowering.py @@ -0,0 +1,219 @@ +"""KI-71: na-aware relational lowering. + +Pine Script relational comparisons (``==`` ``!=`` ``<`` ``>`` ``<=`` ``>=``) +with an ``na`` operand evaluate *falsy*. The engine's na sentinels are +``INT_MIN`` for ``int``/``int64_t`` and NaN for ``double``: + +* An ``int`` sentinel is a *finite* very-negative integer, so naive C++ + diverges from Pine on EVERY relational (ordered and equality alike). +* A ``double`` NaN already yields ``false`` under IEEE for + ``==`` ``<`` ``>`` ``<=`` ``>=`` (matching Pine); the only diverging float + cell is ``!=`` (IEEE ``NaN != x`` is true, Pine is falsy). + +The codegen therefore wraps a relational in an na-aware lambda +(``!is_na(l) && !is_na(r) && (l op r)``) exactly when a diverging cell is +present — any na-capable integer operand, or a ``!=`` with an na-capable +float operand — and leaves the already-correct IEEE cells (pure ``double`` +``==`` ``<`` ``>`` ``<=`` ``>=``) as naive relationals. + +Exemplar mechanism (pf-probe-concord-lockedregime-composed, event-level +792/792 vs TV): ``trend[1] == na`` on bar 0 makes ``raw_regime`` na; the +``barstate.isfirst`` latch ``raw_regime == 0 ? 1 : raw_regime`` takes the +false branch (na ``==`` is falsy) and stores na into ``var int +locked_regime`` — permanently, since every downstream ``lr == +/-1`` is +falsy. Naive C++ ``INT_MIN != x`` is true, so the pre-fix engine *heals* the +latch; the na-aware lowering keeps it na, matching TV. + +REDs contract: every assertion that pins the wrapped form fails against the +pre-fix (aa774ff) lowering, which emits naive relationals with no ``is_na``. +""" + +from __future__ import annotations + +from pineforge_codegen import transpile + +from tests._compile import compile_cpp, skip_if_no_compile_env + + +def _gen(body: str) -> str: + return transpile('//@version=6\nstrategy("T")\n' + body + "\n") + + +# The na-aware wrapper's fingerprint in the emitted C++. +def _wrapped(cpp: str, op: str) -> bool: + """True iff ``cpp`` contains an na-aware relational lambda using ``op``.""" + return ( + "_pna_l = (" in cpp + and "!is_na(_pna_l) && !is_na(_pna_r)" in cpp + and f"(_pna_l {op} _pna_r)" in cpp + ) + + +# --------------------------------------------------------------------------- +# Integer relationals — the INT_MIN sentinel poisons ALL SIX operators. +# `a`/`b` are na-capable `var int` series (either may latch INT_MIN). +# --------------------------------------------------------------------------- +_INT_PRELUDE = "var int a = na\nvar int b = na\n" + + +def test_int_eq_wraps(): # na == na / x == na + cpp = _gen(_INT_PRELUDE + "x = a == b ? 1 : 0\nplot(x)") + assert _wrapped(cpp, "=="), cpp + + +def test_int_neq_wraps(): # x != na + cpp = _gen(_INT_PRELUDE + "x = a != b ? 1 : 0\nplot(x)") + assert _wrapped(cpp, "!="), cpp + + +def test_int_gt_wraps(): # x > na + cpp = _gen(_INT_PRELUDE + "x = a > b ? 1 : 0\nplot(x)") + assert _wrapped(cpp, ">"), cpp + + +def test_int_lt_wraps(): # na < x + cpp = _gen(_INT_PRELUDE + "x = a < b ? 1 : 0\nplot(x)") + assert _wrapped(cpp, "<"), cpp + + +def test_int_le_wraps(): # na <= x + cpp = _gen(_INT_PRELUDE + "x = a <= b ? 1 : 0\nplot(x)") + assert _wrapped(cpp, "<="), cpp + + +def test_int_ge_wraps(): # x >= na + cpp = _gen(_INT_PRELUDE + "x = a >= b ? 1 : 0\nplot(x)") + assert _wrapped(cpp, ">="), cpp + + +def test_int_compared_to_literal_wraps(): + """``locked_regime == 0`` — one operand a literal, the other an na-capable + int — still wraps (the int var can be INT_MIN).""" + cpp = _gen("var int lr = na\nx = lr == 0 ? 1 : 0\nplot(x)") + assert _wrapped(cpp, "=="), cpp + + +def test_int_compared_to_na_literal_wraps(): + """``someInt == na`` — the bare ``na`` lowers to ``na()`` but the + int operand is INT_MIN-capable, so all-op wrapping applies.""" + cpp = _gen("var int lr = na\nx = lr == na ? 1 : 0\nplot(x)") + assert _wrapped(cpp, "=="), cpp + + +# --------------------------------------------------------------------------- +# Float relationals — IEEE already falsy-on-NaN for ==//<=/>=; only != diverges. +# --------------------------------------------------------------------------- +def test_float_neq_wraps(): # the sole diverging float cell + cpp = _gen("float f = close\nfloat g = open\nx = f != g ? 1 : 0\nplot(x)") + assert _wrapped(cpp, "!="), cpp + + +def test_float_neq_na_literal_wraps(): + cpp = _gen("float f = close\nx = f != na ? 1 : 0\nplot(x)") + assert _wrapped(cpp, "!="), cpp + + +# --------------------------------------------------------------------------- +# GUARD — pure double ==//<=/>= must stay NAIVE (IEEE already matches TV). +# Wrapping them would be a pure no-op, so churn must be avoided AND the +# already-correct IEEE behaviour must not be disturbed. +# --------------------------------------------------------------------------- +def test_float_eq_is_naive(): + cpp = _gen("float f = close\nfloat g = open\nx = f == g ? 1 : 0\nplot(x)") + assert "_pna_" not in cpp, cpp + assert "(f == g)" in cpp, cpp + + +def test_float_lt_is_naive(): + cpp = _gen("float f = close\nfloat g = open\nx = f < g ? 1 : 0\nplot(x)") + assert "_pna_" not in cpp, cpp + assert "(f < g)" in cpp, cpp + + +def test_float_gt_is_naive(): + cpp = _gen("float f = close\nfloat g = open\nx = f > g ? 1 : 0\nplot(x)") + assert "_pna_" not in cpp, cpp + assert "(f > g)" in cpp, cpp + + +def test_float_ge_is_naive(): + cpp = _gen("float f = close\nfloat g = open\nx = f >= g ? 1 : 0\nplot(x)") + assert "_pna_" not in cpp, cpp + + +def test_float_le_is_naive(): + cpp = _gen("float f = close\nfloat g = open\nx = f <= g ? 1 : 0\nplot(x)") + assert "_pna_" not in cpp, cpp + + +def test_numeric_literal_comparison_is_naive(): + """Two compile-time constants can never be na — stay naive, no churn.""" + cpp = _gen("x = 3 > 2 ? 1 : 0\nplot(x)") + assert "_pna_" not in cpp, cpp + + +def test_bool_and_or_unaffected(): + """``and``/``or`` are boolean logic, never relational — untouched.""" + cpp = _gen("bool p = close > open\nbool q = close < open\n" + "x = p and q ? 1 : 0\nplot(x)") + # the AND itself is naive &&; only the inner close>open (double >) matters, + # which is naive. No na-aware wrapping anywhere. + assert "_pna_" not in cpp, cpp + assert "&&" in cpp, cpp + + +# --------------------------------------------------------------------------- +# request.security is a SECOND relational emission site — must be covered. +# --------------------------------------------------------------------------- +def test_security_int_relational_wraps(): + cpp = _gen('var int r = na\n' + 'htf = request.security(syminfo.tickerid, "D", r == 0 ? 1 : 2)\n' + 'plot(htf)') + assert _wrapped(cpp, "=="), cpp + + +def test_security_double_ordered_is_naive(): + cpp = _gen('htf = request.security(syminfo.tickerid, "D", ' + 'ta.change(close) > 0 ? 1 : 0)\nplot(htf)') + assert "_pna_" not in cpp, cpp + + +# --------------------------------------------------------------------------- +# Concord latch shape, end-to-end: var int latched na at bar 0 via isfirst + +# ternary, stays na, and every downstream lr comparison is falsy. +# --------------------------------------------------------------------------- +def test_concord_latch_shape_end_to_end(): + src = ( + "int trend = na\n" # trend[1] on bar 0 is na + "int raw = trend\n" + "var int lr = 0\n" + "if barstate.isfirst\n" + " lr := raw == 0 ? 1 : raw\n" # na == 0 falsy -> latches raw (na) + "var int cb = 0\n" + "cb := raw != lr ? cb + 1 : 0\n" # na-aware != feeds the reset branch + "up = lr == 1\n" # falsy forever once lr is na + "dn = lr == -1\n" + "x = up ? 1 : (dn ? -1 : 0)\n" + "plot(x)" + ) + cpp = _gen(src) + # The isfirst latch condition, the cb reset != and both lr==±1 reads + # must all be na-aware. + assert _wrapped(cpp, "=="), cpp + assert _wrapped(cpp, "!="), cpp + # There must be several wrapped comparisons (latch, cb, up, dn). + assert cpp.count("_pna_l = (") >= 4, cpp + + +# --------------------------------------------------------------------------- +# The emitted lambda must actually compile against the engine headers +# (is_na resolves via `using namespace pineforge;`; auto temps deduce int/double). +# --------------------------------------------------------------------------- +def test_na_relational_compiles(): + skip_if_no_compile_env() + cpp = _gen( + "var int a = na\nvar int b = na\nfloat f = close\n" + "x = (a == b) or (a < b) or (a != b) or (f != f) ? 1 : 0\nplot(x)" + ) + assert "_pna_l = (" in cpp + compile_cpp(cpp, label="ki71_na_relational")