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
5 changes: 4 additions & 1 deletion pineforge_codegen/codegen/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
88 changes: 87 additions & 1 deletion pineforge_codegen/codegen/visit_expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>::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<bool>() == false).
_NA_SCALAR_CPP: frozenset[str] = frozenset({"int", "int64_t", "double"})


class ExprVisitor:
"""Expression-level visitor methods shared across the codegen.

Expand Down Expand Up @@ -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<double>()`` — 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<T>::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)
Expand All @@ -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)
Expand Down
219 changes: 219 additions & 0 deletions tests/test_na_relational_lowering.py
Original file line number Diff line number Diff line change
@@ -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<double>()`` 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")
Loading