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
18 changes: 17 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ pineforge_codegen/
├── loop_types.py Counted-loop counter-family evidence,
│ shared by analyzer and emitter, with a
│ memo per declaration in each query;
│ independent of shared storage inference.
│ its value query alone sees fractional
│ reassignment storage before analysis;
│ check types retain hints or initializers.
├── pragmas.py // @pf-trace extraction
├── library_modules.py A Pine library source parsed as a
│ module: its library() declaration,
Expand Down Expand Up @@ -480,6 +482,20 @@ that replay cannot carry retain PF-E3111.
A stateful expanded evaluator cannot fall back to a chart helper call;
conditional helper declarations and unsupported state types keep their gates.
Fixed requests retain their helper-local `varip` refusal.
A numerically int declaration with a fractional `:=` write uses float storage,
including an explicit `int` hint. Reassignments and counters share one numeric
value query and its integral-division proofs. The step check retains the hint
or initializer's type, so a widened int still refuses a float step with PF-E2079.
This covers unambiguous global,
callable, block and loop-body declarations; duplicate storage names, unknown
initializer families and untyped helper parameters remain gaps. Direct
initializers and direct non-quotient numeric-literal writes keep their policy.
Call-site return inference honors the same widening annotation, including
callable-local `var` and `varip`; an `int` return must not narrow their double
storage.
`tests/test_float_reassignment.py` and the measured reassignment fixtures pin
storage, quantities and complete unchanged integer-control C++.

Registry builtins with verified keyword names
(`IntrinsicFunc.keyword_names_verified`) declare their complete keyword set
through their signature parameters. These names are validated before lowering
Expand Down
18 changes: 17 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ pineforge_codegen/
├── loop_types.py Counted-loop counter-family evidence,
│ shared by analyzer and emitter, with a
│ memo per declaration in each query;
│ independent of shared storage inference.
│ its value query alone sees fractional
│ reassignment storage before analysis;
│ check types retain hints or initializers.
├── pragmas.py // @pf-trace extraction
├── library_modules.py A Pine library source parsed as a
│ module: its library() declaration,
Expand Down Expand Up @@ -480,6 +482,20 @@ that replay cannot carry retain PF-E3111.
A stateful expanded evaluator cannot fall back to a chart helper call;
conditional helper declarations and unsupported state types keep their gates.
Fixed requests retain their helper-local `varip` refusal.
A numerically int declaration with a fractional `:=` write uses float storage,
including an explicit `int` hint. Reassignments and counters share one numeric
value query and its integral-division proofs. The step check retains the hint
or initializer's type, so a widened int still refuses a float step with PF-E2079.
This covers unambiguous global,
callable, block and loop-body declarations; duplicate storage names, unknown
initializer families and untyped helper parameters remain gaps. Direct
initializers and direct non-quotient numeric-literal writes keep their policy.
Call-site return inference honors the same widening annotation, including
callable-local `var` and `varip`; an `int` return must not narrow their double
storage.
`tests/test_float_reassignment.py` and the measured reassignment fixtures pin
storage, quantities and complete unchanged integer-control C++.

Registry builtins with verified keyword names
(`IntrinsicFunc.keyword_names_verified`) declare their complete keyword set
through their signature parameters. These names are validated before lowering
Expand Down
15 changes: 14 additions & 1 deletion pineforge_codegen/analyzer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
)
from ..errors import SourceLocation, Diagnostic, CompileError, Level, Phase
from ..limits import TimeBudget, iter_ast_nodes
from ..loop_types import LoopTypeSources, for_counter_type
from ..loop_types import FLOAT_REASSIGNMENT, LoopTypeSources, for_counter_type
from ..security_contexts import (
PASS_WARNINGS_ANNOTATION, context_key, reads_bar_series,
)
Expand Down Expand Up @@ -616,6 +616,10 @@ def analyze(self) -> AnalyzerContext:
from .value_bindings import ValueBindings
self._value_bindings = ValueBindings(self._ast, self._filename, self._budget)
self._value_bindings.resolve()
self._loop_type_sources = LoopTypeSources(self._ast)
self._loop_type_sources.widen_reassignments(
self._value_bindings.declaration_bindings, budget=self._budget,
)
self._check_direct_terminal_array_element_callee_shadows()
self._visit(self._ast)
if self._operator_condition_errors:
Expand Down Expand Up @@ -4687,6 +4691,15 @@ def _visit_VarDecl(self, node: VarDecl) -> PineType:
node.loc,
)

# Establish storage before any reader, member or history snapshot.
# A later quotient write can keep a fraction even with an int hint.
if (node.annotations or {}).get(FLOAT_REASSIGNMENT):
if val_type == PineType.INT:
val_type = PineType.FLOAT
type_spec = TypeSpec.primitive("float")
elif val_type != PineType.FLOAT:
node.annotations.pop(FLOAT_REASSIGNMENT)

self._var_decl_types_by_node[id(node)] = (val_type, type_spec)

if self._global_scope:
Expand Down
6 changes: 6 additions & 0 deletions pineforge_codegen/analyzer/call_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
Ternary, TupleAssign, TupleLiteral, UnaryOp, VarDecl, NaLiteral,
)
from ..method_binding import bind_function_defaults
from ..loop_types import FLOAT_REASSIGNMENT
from ..pine_spelling import sub_outside_literals
from ..external_requests import FEED_LOWERING, LOWERING_ANNOTATION
from ..security_contexts import CONTEXT_ANNOTATION, DEAD_ANNOTATION
Expand Down Expand Up @@ -243,6 +244,11 @@ def _apply_callsite_local_type_effects(
loops/collections remain unknown and cannot silently pick FLOAT.
"""
if isinstance(stmt, VarDecl):
if (stmt.annotations or {}).get(FLOAT_REASSIGNMENT):
# Return variants must keep the declaration's widened storage
# family even when its written hint or initializer is int.
local_types[stmt.name] = PineType.FLOAT
return
hinted = {
"int": PineType.INT,
"float": PineType.FLOAT,
Expand Down
9 changes: 8 additions & 1 deletion pineforge_codegen/analyzer/value_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ def __init__(self, program: Program, filename: str,
self.position = 0
self.scopes: list[_Scope] = []
self.uses: list[tuple[Identifier, _Scope, int, str]] = []
# Current-tree identities for numeric source queries. Authored-use
# annotations can retain pre-lowering identities through a copy.
self.declaration_bindings: dict[int, tuple[int, str] | None] = {}

def _scope(self, parent: _Scope | None, kind: str,
callable_name: str | None = None) -> _Scope:
Expand Down Expand Up @@ -212,9 +215,13 @@ def resolve(self) -> None:
for node, scope, position, role in self.uses:
if self.budget is not None:
self.budget.check(node.loc, Phase.ANALYZER)
binding = scope.resolve(node.name, position)
self.declaration_bindings[id(node)] = (
(binding.declaration, binding.name)
if binding is not None and binding.kind != "builtin" else None
)
origin = (node.annotations or {}).get(_VALUE_USE)
if not isinstance(origin, _ValueUse):
binding = scope.resolve(node.name, position)
origin = _ValueUse(
binding,
None if binding is not None else self._missing(
Expand Down
4 changes: 3 additions & 1 deletion pineforge_codegen/codegen/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from ..errors import Phase
from ..external_requests import UNPINNED_ANNOTATION
from ..limits import iter_ast_nodes
from ..loop_types import LoopTypeSources, for_counter_type
from ..loop_types import FLOAT_REASSIGNMENT, LoopTypeSources, for_counter_type
from ..matrix_overloads import matrix_sum_has_rhs
from ..symbols import (
PineType,
Expand Down Expand Up @@ -2536,6 +2536,8 @@ def _numeric_na_member(self, name: str) -> bool:

def _type_for_decl(self, node: VarDecl) -> str:
"""Determine the C++ type for a ``VarDecl``: explicit hint, then symbol, then RHS inference."""
if (node.annotations or {}).get(FLOAT_REASSIGNMENT):
return "double"
history_value_cpp = (self._array_history_value_cpp_type(node.name)
if not getattr(self, "_active_func_name", None) else None)
if history_value_cpp is not None:
Expand Down
114 changes: 104 additions & 10 deletions pineforge_codegen/loop_types.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Source evidence used exclusively to choose a counted loop's numeric family."""
"""Numeric source evidence shared by reassignment storage and counted loops."""

from __future__ import annotations

from .ast_nodes import (
Assignment, BinOp, ExprStmt, ForStmt, FuncCall, FuncDef, Identifier, IfStmt,
MemberAccess, NaLiteral, NumberLiteral, Subscript, SwitchStmt, Ternary,
TupleAssign, TupleLiteral, UnaryOp, VarDecl,
Assignment, BinOp, ExprStmt, ForInStmt, ForStmt, FuncCall, FuncDef, Identifier,
IfStmt, MemberAccess, MethodDef, NaLiteral, NumberLiteral, Subscript,
SwitchStmt, Ternary, TupleAssign, TupleLiteral, UnaryOp, VarDecl,
)
from .limits import iter_ast_nodes
from .errors import CompileError, Diagnostic, Level, Phase
Expand All @@ -16,6 +16,7 @@
_INT = TypeSpec.primitive("int")
_FLOAT = TypeSpec.primitive("float")
_NA = TypeSpec.primitive("na")
FLOAT_REASSIGNMENT = "pf_float_reassignment"

# These calls declare a float result independently of their operands. Do not
# use get_return_type(... ) == FLOAT here: _sig defaults to FLOAT even for
Expand Down Expand Up @@ -75,19 +76,37 @@ def _join(specs):


class LoopTypeSources:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — LoopTypeSources

fans out to 11 callees (efferent coupling); 11 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""Immutable syntax inventory; no inferred C++ or analyzer fallback types."""
"""Syntax inventory and numeric proofs; no C++ or analyzer fallback types."""

def __init__(self, ast):
self.declarations = {}
self.functions = {}
self.parameter_hints = {}
self.reassigned_names = set()
self.assignments = []
self.name_declarations = {}
for node, _depth in iter_ast_nodes(ast):
# Conservatively deny constant proofs for a name written anywhere.
if isinstance(node, Assignment) and isinstance(node.target, Identifier):
self.reassigned_names.add(node.target.name)
if node.op == ":=":
self.assignments.append(node)
if isinstance(node, (VarDecl, TupleAssign, ForStmt, FuncDef)):
self.declarations[id(node)] = node
if isinstance(node, VarDecl):
names = (node.name,)
elif isinstance(node, TupleAssign):
names = node.names
elif isinstance(node, ForStmt):
names = (node.var,)
elif isinstance(node, ForInStmt):
names = node.vars or [node.var]
elif isinstance(node, (FuncDef, MethodDef)):
names = node.params
else:
names = ()
for name in names:
self.name_declarations.setdefault(name, set()).add(id(node))
if isinstance(node, FuncDef):
self.functions.setdefault(node.name, []).append(node)
hints = (node.annotations or {}).get("param_type_hints", ())
Expand All @@ -97,8 +116,71 @@ def __init__(self, ast):
(f"func_{node.name}", name), [],
).append(_hint_spec(hint))

def widen_reassignments(self, bindings, *, budget=None):
"""Join fractional writes before any read or storage snapshot.

Declaration identities resolve each write. Names with multiple storage
declarations retain their existing path: some emitter slot lookups
still select storage by name, even for lexically distinct bindings.
"""
candidates = {}
for key, node in self.declarations.items():
if not isinstance(node, VarDecl):
continue
if node.annotations:
node.annotations.pop(FLOAT_REASSIGNMENT, None)
# A variable whose name is declared more than once in a file (sibling
# locals, a local shadowing a global or a parameter, repeated helper
# clones) is not widened.
# The emitter's by-name storage lookup
# (pineforge_codegen/codegen/types.py:3505, _slot_scalar_cpp_type) scans
# var_members, function locals, parameters and global members by name;
# it cannot tell two same-named bindings apart.
if ((node.annotations or {}).get("pine_version") != 5
and self.name_declarations[node.name] == {key}):
candidates[key] = []
for assignment in self.assignments:
if (assignment.annotations or {}).get("pine_version") == 5:
continue
value = assignment.value
while isinstance(value, UnaryOp) and value.op in {"+", "-"}:
value = value.operand
if isinstance(value, NumberLiteral):
# Non-quotient literal writes have no measurement for widening.
# Preserve them uniformly, without consulting the target hint.
continue
binding = bindings.get(id(assignment.target))
if binding is not None and binding[0] in candidates:
candidates[binding[0]].append(assignment.value)
check = _CounterQuery(self, bindings, {})
candidates = {
key: values for key, values in candidates.items()
if values and check.declaration(self.declarations[key]) == _INT
}
# Widened storage can carry a fraction to another reassigned variable.
# Publish a round together, then discard its declaration/constant memo.
while candidates:
query = _CounterQuery(self, bindings, {})
widened = []
for key, values in candidates.items():
if budget is not None:
budget.check(self.declarations[key].loc, Phase.ANALYZER)
if any(query.value_spec(value) == _FLOAT for value in values):
widened.append(key)
if not widened:
break
for key in widened:
node = self.declarations[key]
node.annotations = {
**(node.annotations or {}), FLOAT_REASSIGNMENT: True,
}
del candidates[key]


class _CounterQuery:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _CounterQuery

fans out to 22 callees (efferent coupling); 9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

# Fractional reassignment changes values, not the declared check type.
_use_reassignment_values = False

def __init__(self, sources, bindings, scopes):
self.sources = sources
self.bindings = bindings
Expand All @@ -113,8 +195,14 @@ def declaration(self, node):
if key not in self.declaration_specs:
self.declaration_specs[key] = None
if isinstance(node, VarDecl):
spec = (_hint_spec(node.type_hint) if node.type_hint
else self.infer(node.value))
spec = (
_FLOAT if (
self._use_reassignment_values
and (node.annotations or {}).get(FLOAT_REASSIGNMENT)
) else
_hint_spec(node.type_hint) if node.type_hint else
self.infer(node.value)
)
elif isinstance(node, TupleAssign):
spec = (self.infer(node.value)
if isinstance(node.value, (FuncCall, TupleLiteral)) else None)
Expand All @@ -129,7 +217,7 @@ def declaration(self, node):

def value_spec(self, node):
if self._value_query is None:
self._value_query = _CounterValueQuery(self)
self._value_query = _NumericValueQuery(self)
return self._value_query.infer(node)

def counter_spec(self, start, step, *, counter_name=None, step_location=None):
Expand Down Expand Up @@ -167,6 +255,10 @@ def infer(self, node):
binding = self.bindings.get(id(node))
if binding is not None:
declaration = self.sources.declarations.get(binding[0])
if isinstance(declaration, FuncDef) and binding[1] in declaration.params:
index = declaration.params.index(binding[1])
hints = (declaration.annotations or {}).get("param_type_hints", ())
return _hint_spec(hints[index]) if index < len(hints) else None
spec = self.declaration(declaration)
if isinstance(declaration, TupleAssign):
index = declaration.names.index(binding[1])
Expand Down Expand Up @@ -295,8 +387,10 @@ def array_result(name, spec):
return None


class _CounterValueQuery(_CounterQuery):
"""Keep fractional division values unless literal arithmetic proves an int."""
class _NumericValueQuery(_CounterQuery):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _NumericValueQuery

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""One value query for storage and counters, including integral proofs."""

_use_reassignment_values = True

def __init__(self, check_query):
super().__init__(check_query.sources, check_query.bindings, check_query.scopes)
Expand Down
4 changes: 2 additions & 2 deletions tests/_for_fractional_carriers.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def _source(body: str) -> str:
}

CARRIER_FAMILIES = {
name: "int" if name in {"reassignment", "helper_parameter"} else "double"
name: "int" if name == "helper_parameter" else "double"
for name in CARRIER_SOURCES
}
KNOWN_CARRIER_GAPS = {"reassignment", "helper_parameter"}
KNOWN_CARRIER_GAPS = {"helper_parameter"}
16 changes: 16 additions & 0 deletions tests/fixtures/for_counter_headers/callable_bound_float_step.pine
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//@version=6
strategy("Callable bound float-step check", initial_capital = 1000000000000, currency = currency.NONE)
n = input.int(5, "Numerator")
measure(int seed) =>
var int s = seed
s := seed
s := s / 2
s
x = measure(n)
count_v = 0.0
for i = x to 6 by 0.5
count_v += 1
if bar_index > 6 and bar_index % 7 == 3
strategy.entry("L", strategy.long, qty = 1 + count_v / 100)
if bar_index > 6 and bar_index % 7 == 5
strategy.close("L")
14 changes: 14 additions & 0 deletions tests/fixtures/for_counter_headers/callable_direct_float_step.pine
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//@version=6
strategy("Callable direct float-step check", initial_capital = 1000000000000, currency = currency.NONE)
measure(int seed) =>
var int s = seed
s := seed
s := s / 2
s
count_v = 0.0
for i = measure(5) to 6 by 0.5
count_v += 1
if bar_index > 6 and bar_index % 7 == 3
strategy.entry("L", strategy.long, qty = 1 + count_v / 100)
if bar_index > 6 and bar_index % 7 == 5
strategy.close("L")
Loading