diff --git a/AGENTS.md b/AGENTS.md index 7d7c1ef5..e2affc55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 24462a1e..d3cce324 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, @@ -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 diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 007b8832..be06fe2d 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -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, ) @@ -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: @@ -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: diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 5e83f0d9..c8ca8982 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -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 @@ -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, diff --git a/pineforge_codegen/analyzer/value_bindings.py b/pineforge_codegen/analyzer/value_bindings.py index ff051a5f..0bbce388 100644 --- a/pineforge_codegen/analyzer/value_bindings.py +++ b/pineforge_codegen/analyzer/value_bindings.py @@ -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: @@ -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( diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 52b3f75c..fa8c9bfa 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -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, @@ -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: diff --git a/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index 711fca3a..69335c4d 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -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 @@ -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 @@ -75,19 +76,37 @@ def _join(specs): class LoopTypeSources: - """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", ()) @@ -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: + # 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 @@ -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) @@ -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): @@ -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]) @@ -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): + """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) diff --git a/tests/_for_fractional_carriers.py b/tests/_for_fractional_carriers.py index 97d394f3..de35ca26 100644 --- a/tests/_for_fractional_carriers.py +++ b/tests/_for_fractional_carriers.py @@ -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"} diff --git a/tests/fixtures/for_counter_headers/callable_bound_float_step.pine b/tests/fixtures/for_counter_headers/callable_bound_float_step.pine new file mode 100644 index 00000000..929fbc81 --- /dev/null +++ b/tests/fixtures/for_counter_headers/callable_bound_float_step.pine @@ -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") diff --git a/tests/fixtures/for_counter_headers/callable_direct_float_step.pine b/tests/fixtures/for_counter_headers/callable_direct_float_step.pine new file mode 100644 index 00000000..20eecb48 --- /dev/null +++ b/tests/fixtures/for_counter_headers/callable_direct_float_step.pine @@ -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") diff --git a/tests/fixtures/for_counter_headers/callable_input_float_step.pine b/tests/fixtures/for_counter_headers/callable_input_float_step.pine new file mode 100644 index 00000000..be748748 --- /dev/null +++ b/tests/fixtures/for_counter_headers/callable_input_float_step.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Callable input direct 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 +count_v = 0.0 +for i = measure(n) 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") diff --git a/tests/fixtures/for_counter_headers/callable_local_qty.pine b/tests/fixtures/for_counter_headers/callable_local_qty.pine new file mode 100644 index 00000000..9cfcd608 --- /dev/null +++ b/tests/fixtures/for_counter_headers/callable_local_qty.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("carrier probe callable_local_qty", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +measure(int seed) => + int s = seed + s := s / 2 + s +observed = measure(n) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = observed) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/callable_var_qty.pine b/tests/fixtures/for_counter_headers/callable_var_qty.pine new file mode 100644 index 00000000..c8a0ff1d --- /dev/null +++ b/tests/fixtures/for_counter_headers/callable_var_qty.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("carrier probe callable_var_qty", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +measure(int seed) => + var int s = seed + s := seed + s := s / 2 + s +observed = measure(n) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = observed) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/helper_param.pine b/tests/fixtures/for_counter_headers/helper_param.pine new file mode 100644 index 00000000..c498eb09 --- /dev/null +++ b/tests/fixtures/for_counter_headers/helper_param.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("carrier probe helper_param", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +count_from(p) => + float acc = 0.0 + for i = p to p + 2 + acc += i + acc +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = count_from(n / 2)) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/plain_callable_bound_input.pine b/tests/fixtures/for_counter_headers/plain_callable_bound_input.pine new file mode 100644 index 00000000..5ebabf79 --- /dev/null +++ b/tests/fixtures/for_counter_headers/plain_callable_bound_input.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Plain callable bound float-step check", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +measure(int seed) => + int 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") diff --git a/tests/fixtures/for_counter_headers/plain_callable_direct_input.pine b/tests/fixtures/for_counter_headers/plain_callable_direct_input.pine new file mode 100644 index 00000000..ba7c248d --- /dev/null +++ b/tests/fixtures/for_counter_headers/plain_callable_direct_input.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Plain callable input float-step check", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +measure(int seed) => + int s = seed + s := s / 2 + s +count_v = 0.0 +for i = measure(n) 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") diff --git a/tests/fixtures/for_counter_headers/plain_callable_direct_literal.pine b/tests/fixtures/for_counter_headers/plain_callable_direct_literal.pine new file mode 100644 index 00000000..56e2741a --- /dev/null +++ b/tests/fixtures/for_counter_headers/plain_callable_direct_literal.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Plain callable literal float-step check", initial_capital = 1000000000000, currency = currency.NONE) +measure(int seed) => + int 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") diff --git a/tests/fixtures/for_counter_headers/reassign_int_hint.pine b/tests/fixtures/for_counter_headers/reassign_int_hint.pine new file mode 100644 index 00000000..c7b6e312 --- /dev/null +++ b/tests/fixtures/for_counter_headers/reassign_int_hint.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("carrier probe reassign_int_hint", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +int s = n +s := s / 2 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = s) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/reassign_literal_qty.pine b/tests/fixtures/for_counter_headers/reassign_literal_qty.pine new file mode 100644 index 00000000..a1ee8bfe --- /dev/null +++ b/tests/fixtures/for_counter_headers/reassign_literal_qty.pine @@ -0,0 +1,8 @@ +//@version=6 +strategy("carrier probe reassign_literal_qty", initial_capital = 1000000000000, currency = currency.NONE) +s = 5 +s := s / 2 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = s) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/reassign_plain_qty.pine b/tests/fixtures/for_counter_headers/reassign_plain_qty.pine new file mode 100644 index 00000000..cafa2eee --- /dev/null +++ b/tests/fixtures/for_counter_headers/reassign_plain_qty.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("carrier probe reassign_plain_qty", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +s = n +s := s / 2 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = s) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/reassign_series_qty.pine b/tests/fixtures/for_counter_headers/reassign_series_qty.pine new file mode 100644 index 00000000..d05a9392 --- /dev/null +++ b/tests/fixtures/for_counter_headers/reassign_series_qty.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("carrier probe reassign_series_qty", initial_capital = 1000000000000, currency = currency.NONE) +n = bar_index % 2 == 0 ? 5 : 7 +s = n +s := s / 2 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = s) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/reassign_typed.pine b/tests/fixtures/for_counter_headers/reassign_typed.pine new file mode 100644 index 00000000..77cb36d5 --- /dev/null +++ b/tests/fixtures/for_counter_headers/reassign_typed.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("carrier probe reassign_typed", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +float s = n +s := s / 2 +float acc = 0.0 +for i = s to s + 2 + acc += i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/reassign_untyped.pine b/tests/fixtures/for_counter_headers/reassign_untyped.pine new file mode 100644 index 00000000..d40fb5f2 --- /dev/null +++ b/tests/fixtures/for_counter_headers/reassign_untyped.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("carrier probe reassign_untyped", initial_capital = 1000000000000, currency = currency.NONE) +n = input.int(5, "Numerator") +s = n +s := s / 2 +float acc = 0.0 +for i = s to s + 2 + acc += i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index 410befef..cd85a971 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -2,6 +2,10 @@ "provenance": "TradingView measurements, 2026-11-02. Each sha256 is the SHA-256 of that probe's TradingView export: the trades list or the reply that stops the run. Each source_sha256 identifies the measured source, not necessarily the repository fixture. The export files are not stored in the repository. For float_counter_*, first_end_read_*, int_counter_float_end and step_reads_counter_descending, fixtures preserve the measured expressions but may change strategy titles, comments and whitespace; float_counter_helper_value also places the helper body on its declaration line. The float-counter value and thirteen older iteration-count rows were measured on BINANCE:BTCUSDT 60, including seven tolerance-bracket probes at 12:22; each quantity holds on all 205 entries. The four additional large/small-step rows preserve every source byte except the strategy title; counts 3, 4, 3 and 3 are encoded as entry quantities 1.03, 1.04, 1.03 and 1.03 on all 180 exported entries. Their trade schedule is every eight bars from bar 0, with a close on the next bar. The first_end_read_start_two_down and first_end_read_start_two_up probes were measured at 11:57 on BINANCE:BTCUSDT 60; their exports have identical bytes and all 205 entries have quantity 1.1. The zero-entry probe is recorded separately because its accumulator starts at 0. The fstep_* sources are exact copies of the accepted TradingView scripts; their entry quantities encode 5, 7 and 3 loop iterations as 1 + count / 100. Their trade schedule is every eight bars from bar 0, with a close on the next bar. Compile-refusal spans use TradingView's 1-based inclusive end columns.", "division_provenance": "The division_start_no_step, division_start_int_step, division_named_start, division_end_only and division_counter_value fixtures copy the measured v01 through v05 scripts byte for byte. fractional_input and fractional_series copy the measured fractional-input.pine and fractional-series.pine byte for byte. All seven exports contain 205 entries. The v01 and v03 exports are the same file. The series export alternates odd n (10.5, 103 entries) and even n (12, 102 entries), starting with odd n. Each sha256 below is the supplied full TradingView export digest; the source hashes identify the copied scripts. Carrier controls derive their 10.5 oracle from fractional_input's identical loop shape; they do not claim separate TradingView tapes.", "fractional_consumer_provenance": "TradingView accepted descending.pine, array-index.pine and counter-string.pine on 2026-11-02 at 22:59. fractional_descending.pine, fractional_array_index.pine and counter_string.pine copy those sources byte for byte. The descending export has 205 entries of quantity 4.5. The array-index export has 205 entries of quantity 120: indices 2.5, 3.5 and 4.5 truncate to 2, 3 and 4. The counter-string export has 615 entries of quantity 1, with 205 entries for each exact id recorded below. Full export and source digests are the supplied measurement receipts. The counter_tostring known gap records current engine text separately from TradingView's 18-place ids.", + "reassignment_provenance": "All seven reassignment/helper fixtures copy the measured sources byte for byte. TradingView accepted every source on BINANCE:BTCUSDT 60 with default inputs. reassign_int_hint is the controlling row: an explicit int hint retains the quotient, quantity 2.5 on 205 entries, as do the plain and literal initializers. reassign_series_qty alternates 2.5 (103 entries) and 3.5 (102). reassign_untyped, reassign_typed (float hint) and helper_param each have quantity 10.5 on 205 entries and share fractional_input's supplied export digest. The helper_param source digest is computed from the copied source; export digests are supplied measurements, with no export or C++ hash recapture.", + "callable_quantity_provenance": "callable_var_qty.pine and callable_local_qty.pine copy the measured sources byte for byte. TradingView accepted each source on BINANCE:BTCUSDT 60 with default inputs. Each source was exported once; both exports contain 205 entries, all with quantity 2.5. The two exports are byte-equal, with each export digest bound to its own source digest below. The source and export digests are supplied measurement receipts; the supplied verdict table has SHA-256 0167f54f735894bacf4a6beb4b1643dff4ad2590a12015f42f78af0c54ac44a2.", + "widened_step_provenance": "The widened_float_step and widened_int_hint_float_step fixtures copy the measured sources byte for byte. TradingView refused both on BINANCE:BTCUSDT 60 with default inputs, reporting CE10106. Their check type stays int although the plain-quantity reassignment rows measure a value of 2.5. The source and error-artifact digests are supplied measurement receipts; the supplied verdict table has SHA-256 88b9dd5b851000ca157b7ade785ea20a85c8a2f8c7f10260ea93d1a0aa11763a.", + "callable_step_provenance": "The callable_direct_float_step, callable_bound_float_step, callable_input_float_step, plain_callable_direct_literal, plain_callable_direct_input and plain_callable_bound_input fixtures copy the measured sources byte for byte. TradingView refused all six on BINANCE:BTCUSDT 60 with default inputs, reporting CE10106 for a series int counter and literal float step. Each source was exported once. The source and error-artifact digests are supplied measurement receipts. The supplied verdict tables have SHA-256 e6e85132a0d37351bfc7238c05628552050398ce1206fbbccac1a15583809693 for callable_direct_float_step and callable_bound_float_step, b0eacb211dadf42644a3403b7658daa4b5e6626705a8a80f9b7aef76e354877d for callable_input_float_step and 81611becfb6be66642ccdbc1424db4b12b74dd5d145e838748243afa2259dc9b for the three plain-local callable fixtures.", "ta_counter_type_receipt": { "receipt": "tacarriers-2302", "sha256": "be854854835eed60f33abe809a60e100cf978a304ba68138a332ef9d1124d1cc", @@ -17,11 +21,21 @@ }, "entries_per_probe": 205, "entry_counts": { + "callable_var_qty": 205, + "callable_local_qty": 205, "fractional_descending": 205, "fractional_array_index": 205, "counter_string": 615 }, "entry_quantities": { + "reassign_int_hint": 2.5, + "reassign_plain_qty": 2.5, + "reassign_literal_qty": 2.5, + "callable_var_qty": 2.5, + "callable_local_qty": 2.5, + "reassign_untyped": 10.5, + "reassign_typed": 10.5, + "helper_param": 10.5, "end_reads_counter": 1, "end_reads_counter_other": 3, "step_reads_counter": 15, @@ -72,6 +86,11 @@ "counter_string": 1 }, "entry_quantity_patterns": { + "reassign_series_qty": { + "quantities": [2.5, 3.5], + "counts": [103, 102], + "order": "Alternating n = 5, n = 7, starting with n = 5" + }, "fractional_series": { "quantities": [10.5, 12], "counts": [103, 102], @@ -116,9 +135,79 @@ "span": "5:31-5:34", "source_sha256": "9c6aaeb9578724c1fddc0296a679395dd00f0271968d059709cc52d38655a1ec", "error_json_sha256": "074376481bdab118aebc8fe4548b73616cca98d27e93c1432992435c9d9b85c5" + }, + "widened_float_step": { + "source_shape": "n = input.int(5); s = n; s := s / 2; for i = s to 6 by 0.5", + "verdict": "REFUSED", + "code": "CE10106", + "span": "7:19-7:21", + "source_sha256": "7e4b1ac7f4804d94a18e4d2e25c5b73ca8a53f8dfcbec37828468342c885376f", + "error_json_sha256": "15fae462d3cc80171955e5f8f1109ee8036f5f40fa22b3d73898da517f457d7a" + }, + "widened_int_hint_float_step": { + "source_shape": "n = input.int(5); int s = n; s := s / 2; for i = s to 6 by 0.5", + "verdict": "REFUSED", + "code": "CE10106", + "span": "7:19-7:21", + "source_sha256": "436c4da2f6674e4b32770a88957ed0c3b775d6fcc206a89796d7b7f8bd0f2f69", + "error_json_sha256": "cd67711a0727c06058e5f74d8b50eaa92a07dfcab7e652e669baa8191738594b" + }, + "callable_direct_float_step": { + "source_shape": "measure(int seed) => var int s = seed; s := seed; s := s / 2; s; for i = measure(5) to 6 by 0.5", + "verdict": "REFUSED", + "code": "CE10106", + "span": "9:28-9:30", + "source_sha256": "6b31db71ff140465c89f4704756f6ff7bc7792334c8ea055bb6bad045360936e", + "error_json_sha256": "222565ee733a6a7071ddab26813a3f85f44d7827f09b5bc1f499dd69b8f28164" + }, + "callable_bound_float_step": { + "source_shape": "measure(int seed) => var int s = seed; s := seed; s := s / 2; s; x = measure(n); for i = x to 6 by 0.5", + "verdict": "REFUSED", + "code": "CE10106", + "span": "11:19-11:21", + "source_sha256": "3d14afbd3351a98fdff6dfdaa3071013a28b00b6c1f705c3ca9fa940debc0eee", + "error_json_sha256": "0a0e26ca288a99bdff7971d2274508d876d688614137729c96f51ccbb882da9c" + }, + "callable_input_float_step": { + "source_shape": "n = input.int(5); measure(int seed) => var int s = seed; s := seed; s := s / 2; s; for i = measure(n) to 6 by 0.5", + "verdict": "REFUSED", + "code": "CE10106", + "span": "10:28-10:30", + "source_sha256": "93577e6dd9124856e177ecee4758eb8c947194bda4aadf26055ef2a30521cc41", + "error_json_sha256": "41abe4c758be1f7a17fd58a981ae0580d155ddb2b9eea2d730855a3ac33cf0e6" + }, + "plain_callable_direct_literal": { + "source_shape": "measure(int seed) => int s = seed; s := s / 2; s; for i = measure(5) to 6 by 0.5", + "verdict": "REFUSED", + "code": "CE10106", + "span": "8:28-8:30", + "source_sha256": "437f66ef40957df8f1046e59dfee9f7ebdf5a36a8391229af18d8ba70750d3bd", + "error_json_sha256": "7d7da1f8c771d560f1248d7d06b478e5c0818f35b48288c1c148123ffd9cb6ee" + }, + "plain_callable_direct_input": { + "source_shape": "n = input.int(5); measure(int seed) => int s = seed; s := s / 2; s; for i = measure(n) to 6 by 0.5", + "verdict": "REFUSED", + "code": "CE10106", + "span": "9:28-9:30", + "source_sha256": "f7e06871d0f08a5141037b621892fce6119f0c04d2d9615e36697c9568d5e189", + "error_json_sha256": "3edb8d447ddde8302749d720ca43ac5c55384cf2ceb5e191637f18f492823140" + }, + "plain_callable_bound_input": { + "source_shape": "n = input.int(5); measure(int seed) => int s = seed; s := s / 2; s; x = measure(n); for i = x to 6 by 0.5", + "verdict": "REFUSED", + "code": "CE10106", + "span": "10:19-10:21", + "source_sha256": "feefbb1600b23a3a898b4963865e333d3bd81254da292ab88d7811eecde01219", + "error_json_sha256": "69a733318218c63957e40a329114e8df1b8b302c334b3397305abe29748db437" } }, "known_gaps": { + "helper_parameter_fraction": { + "probe": "helper_param", + "reason": "The source value query has no call-argument environment for an untyped parameter. The default-step helper counter stays int and loses the quotient before summation.", + "current_entry_quantity": 9, + "tradingview_entry_quantity": 10.5 + }, "untyped_parameter_start": "For f(x) containing for v = x to 1.0 by 0.25, TradingView refuses f(0) with CE10106 (helper_int_argument_float_step) and accepts f(0.0). PineForge accepts and runs both with a float counter because this inference cannot distinguish their call-argument types; refusing the unknown start would also refuse the valid float-argument call.", "counter_tostring": { "probe": "counter_string", @@ -139,6 +228,15 @@ "runtime_error_bar": 0, "runtime_error_trade_count": 0, "evidence": { + "reassign_int_hint": {"source": "reassign_int_hint.pine", "measured_source": "reassign_int_hint.pine", "sha256": "7c4c77e0830bfa3464171ec5562fc4724619a76d8275157a21b517431ce9f7dd", "source_sha256": "c9d7ebf0744337d9a9b6097f394b35dede11a08e7ff60c3cd261c6094e615ac6"}, + "reassign_plain_qty": {"source": "reassign_plain_qty.pine", "measured_source": "reassign_plain_qty.pine", "sha256": "7c4c77e0830bfa3464171ec5562fc4724619a76d8275157a21b517431ce9f7dd", "source_sha256": "907d5827b99cc2279c2afe7f6e83283f2ff39592443595d02f12216cc81d333c"}, + "reassign_literal_qty": {"source": "reassign_literal_qty.pine", "measured_source": "reassign_literal_qty.pine", "sha256": "7c4c77e0830bfa3464171ec5562fc4724619a76d8275157a21b517431ce9f7dd", "source_sha256": "95f293b390973e38f38665adbe3ae946112ab4b7f413a17579d40bb34d790b9e"}, + "callable_var_qty": {"source": "callable_var_qty.pine", "measured_source": "callable_var_qty.pine", "sha256": "7c4c77e0830bfa3464171ec5562fc4724619a76d8275157a21b517431ce9f7dd", "source_sha256": "4e9d77ca008bc0542b07593977b00ff112f7696ce64e8d1206693452175b42b7"}, + "callable_local_qty": {"source": "callable_local_qty.pine", "measured_source": "callable_local_qty.pine", "sha256": "7c4c77e0830bfa3464171ec5562fc4724619a76d8275157a21b517431ce9f7dd", "source_sha256": "04374d2c8500f4b9d9911df542735fcc2ec1359595edaa06ebf180e137a37a99"}, + "reassign_series_qty": {"source": "reassign_series_qty.pine", "measured_source": "reassign_series_qty.pine", "sha256": "8b2923a9096d7b7d2a941c0f6bcb54a11e04a96714606dca91352d87b5b996dc", "source_sha256": "0ba66e2669200eed53015f3f9a8f5e7baf3cefa6ecc12e2caa1d82835635a565"}, + "reassign_untyped": {"source": "reassign_untyped.pine", "measured_source": "reassign_untyped.pine", "sha256": "3aa0f43925521663a43f0421f5f4d8d0cee5f2925eeb09d0d22078c2d75aaa62", "source_sha256": "21896d14a3b77e0b889d804a4287b34422678aab64db2015d8ec5e35782c9ab1"}, + "reassign_typed": {"source": "reassign_typed.pine", "measured_source": "reassign_typed.pine", "sha256": "3aa0f43925521663a43f0421f5f4d8d0cee5f2925eeb09d0d22078c2d75aaa62", "source_sha256": "1dfba9ed13151c4446d50dc871044df5c904e4f99a650de91155cb7ef2722e2c"}, + "helper_param": {"source": "helper_param.pine", "measured_source": "helper_param.pine", "sha256": "3aa0f43925521663a43f0421f5f4d8d0cee5f2925eeb09d0d22078c2d75aaa62", "source_sha256": "ffa8cd5728b85a0c53b10d2b4131cc74cf40fa625d9e675b7716ca3259d0aead"}, "end_reads_counter": {"sha256": "e84496c2adc3436a4dd5d64496b684eea53b2daaa097ec550f5cb21459deb35b", "source_sha256": "6e7c9ac5494f2e85a31efcb856358ef0133cbf149d4b93447cb15ee9ee1ed522"}, "end_reads_counter_other": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "8149b6a265232fb359219a2124b1ae9a98e9f861df9f5282f5da4d0c1de3ee1f"}, "step_reads_counter": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e", "source_sha256": "b61b4c7933e515491fdb16425c99ae51b944078eab5175b4087c6c3a7efdb323"}, diff --git a/tests/fixtures/for_counter_headers/widened_float_step.pine b/tests/fixtures/for_counter_headers/widened_float_step.pine new file mode 100644 index 00000000..52b63653 --- /dev/null +++ b/tests/fixtures/for_counter_headers/widened_float_step.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("carrier probe widened_float_step", initial_capital = 1000000, currency = currency.NONE) +n = input.int(5, "Numerator") +s = n +s := s / 2 +float acc = 0.0 +for i = s to 6 by 0.5 + acc := acc + 1 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1 + acc / 100) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/widened_int_hint_float_step.pine b/tests/fixtures/for_counter_headers/widened_int_hint_float_step.pine new file mode 100644 index 00000000..256b1da1 --- /dev/null +++ b/tests/fixtures/for_counter_headers/widened_int_hint_float_step.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("carrier probe widened_int_hint_float_step", initial_capital = 1000000, currency = currency.NONE) +n = input.int(5, "Numerator") +int s = n +s := s / 2 +float acc = 0.0 +for i = s to 6 by 0.5 + acc := acc + 1 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1 + acc / 100) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index 49c4877f..a9072382 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -34,7 +34,17 @@ FRACTIONAL_COUNTER_TAPES = ( "fractional_descending", "fractional_array_index", "counter_string", ) +REASSIGNMENT_TAPES = ( + "reassign_int_hint", "reassign_plain_qty", "reassign_literal_qty", + "reassign_series_qty", "reassign_untyped", "reassign_typed", +) +CALLABLE_QUANTITY_TAPES = ("callable_var_qty", "callable_local_qty") +# Each twin is an engine-vs-engine control, not TradingView evidence. +REASSIGNMENT_TAPE_TWINS = { + name: ("s := s / 2", "s := s * 0.5") for name in REASSIGNMENT_TAPES +} # These twins change authored Pine expressions, never emitted C++ or a tape. +# Each twin is an engine-vs-engine control, not TradingView evidence. DIVISION_TAPE_TWINS = { "division_start_no_step": ("for i = 5 / 2 to", "for i = 2.5 to"), "division_start_int_step": ("for i = 7 / 2 to", "for i = 3.5 to"), @@ -50,7 +60,11 @@ # Shared parameter inference still truncates this float counter. Run it as # a documented discrepancy, retaining TradingView's measured 5 in the quantities file. HELPER_VALUE_GAP = "float_counter_helper_value" -MATCHING_MEASURED = tuple(name for name in MEASURED if name != HELPER_VALUE_GAP) +HELPER_PARAMETER_GAP = "helper_param" +MATCHING_MEASURED = tuple( + name for name in MEASURED + if name not in (HELPER_VALUE_GAP, HELPER_PARAMETER_GAP) +) # These measured rows distinguish accumulated rounding near 0.3 from an # end shortfall at a larger scale. Both use TradingView's recorded quantities. END_DIFFERENCE_PROBES = ( @@ -211,12 +225,15 @@ def runs(run_environment): pine = (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") builds[name] = Build(pine) counters = re.findall(r"(?m)^\s*for (\w+) =", pine) + writes = re.findall(r"(?m)^(\w+) :=", pine) + rename = counters[-1] if counters else writes[0] if writes else "i" renamed = re.sub( - rf"\b{counters[-1] if counters else 'i'}\b", "sampleIndex", pine, + rf"\b{rename}\b", "sampleIndex", pine, ) builds[f"{name}_renamed"] = Build(renamed) - if name in DIVISION_TAPE_TWINS: - before, after = DIVISION_TAPE_TWINS[name] + twin = DIVISION_TAPE_TWINS.get(name) or REASSIGNMENT_TAPE_TWINS.get(name) + if twin is not None: + before, after = twin assert pine.count(before) == 1, name builds[f"{name}_twin"] = Build(pine.replace(before, after)) if name in FIRST_END_READ_PROBES: @@ -286,7 +303,10 @@ def test_tradingview_evidence_has_complete_sha256(): assert re.fullmatch(r"[0-9a-fA-F]{64}", TV["ta_counter_type_receipt"]["sha256"]) -@pytest.mark.parametrize("name", tuple(DIVISION_TAPE_TWINS)) +@pytest.mark.parametrize("name", ( + *REASSIGNMENT_TAPES, *CALLABLE_QUANTITY_TAPES, + HELPER_PARAMETER_GAP, *DIVISION_TAPE_TWINS, +)) def test_division_fixture_is_the_measured_source(name): evidence = TV["evidence"][name] assert evidence["source"] == f"{name}.pine" @@ -399,6 +419,7 @@ def test_counter_binding_compositions(runs, name): @pytest.mark.parametrize("name", tuple(DIVISION_TAPE_TWINS)) def test_division_tape_fixture_keeps_its_counter_family_and_twin(runs, name): + """engine-vs-engine control, not TradingView evidence.""" pine = (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") cpp = transpile(pine) family = "int" if name == "division_end_only" else "double" @@ -410,9 +431,16 @@ def test_division_tape_fixture_keeps_its_counter_family_and_twin(runs, name): assert_same_runs(ok(runs, name), ok(runs, name + "_twin")) +@pytest.mark.parametrize("name", REASSIGNMENT_TAPES) +def test_reassignment_tape_matches_its_fraction_preserving_twin(runs, name): + """engine-vs-engine control, not TradingView evidence.""" + assert_same_runs(ok(runs, name), ok(runs, name + "_twin")) + + @pytest.mark.parametrize("name", PATTERN_MEASURED) @pytest.mark.parametrize("suffix", ["", "_renamed", "_twin"]) def test_tradingview_alternating_entry_quantities(runs, name, suffix): + # The _twin suffix is an engine-vs-engine control, not TradingView evidence. outcome = ok(runs, name + suffix) rows = csv.DictReader(outcome.trades["default"].decode("utf-8-sig").splitlines()) entries = [row for row in rows if row["Type"].startswith("Entry")] @@ -433,6 +461,7 @@ def test_known_gap_counter_tostring(runs, run_environment, suffix): Quantity and entry counts match the tape; ids are a known formatter gap. This row must flip to the recorded TV ids when the formatter is fixed. """ + # The _twin suffix is an engine-vs-engine control, not TradingView evidence. gap = TV["known_gaps"]["counter_tostring"] name = gap["probe"] pattern = TV["entry_id_patterns"][name] @@ -463,16 +492,14 @@ def test_fractional_start_carrier_matches_the_measured_loop_shape(runs, name): ) -def test_known_gap_reassignment_fraction(runs): - """TradingView gives 10.5 and we give 9; flips when reassignment keeps the fraction.""" - assert TV["entry_quantities"]["fractional_input"] == 10.5 - _assert_quantities(ok(runs, "carrier_reassignment"), "default", 9) - - -def test_known_gap_helper_parameter_fraction(runs): +@pytest.mark.parametrize("name", ( + "carrier_helper_parameter", HELPER_PARAMETER_GAP, HELPER_PARAMETER_GAP + "_renamed", +)) +def test_known_gap_helper_parameter_fraction(runs, name): """TradingView gives 10.5 and we give 9; flips when a helper parameter keeps the fraction.""" - assert TV["entry_quantities"]["fractional_input"] == 10.5 - _assert_quantities(ok(runs, "carrier_helper_parameter"), "default", 9) + gap = TV["known_gaps"]["helper_parameter_fraction"] + assert TV["entry_quantities"][HELPER_PARAMETER_GAP] == gap["tradingview_entry_quantity"] + _assert_quantities(ok(runs, name), "default", gap["current_entry_quantity"]) STOP_DRIVER = r''' diff --git a/tests/test_float_reassignment.py b/tests/test_float_reassignment.py new file mode 100644 index 00000000..92af1898 --- /dev/null +++ b/tests/test_float_reassignment.py @@ -0,0 +1,569 @@ +"""Fractional storage, measured quantities, and unchanged integer controls.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +from textwrap import dedent + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.analyzer.value_bindings import ValueBindings +from pineforge_codegen.ast_nodes import ForStmt, FuncDef +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.limits import iter_ast_nodes +from pineforge_codegen.loop_types import LoopTypeSources, _CounterQuery +from pineforge_codegen.parser import Parser +from pineforge_codegen.symbols import TypeSpec +from tests import _compile as compile_env +from tests._e2e import reference_codegen, transpile_json +from tests._for_float_step import assert_float_step_refused + + +FIXTURES = Path(__file__).parent / "fixtures" / "for_counter_headers" +TV = json.loads((FIXTURES / "tradingview_quantities.json").read_text(encoding="utf-8")) +CPP_REFERENCE = "daa5102558b9b4c1ca764f3a770cbd206003e91a" +# The explicit-int quantity row is first, independently of loop lowering. +MEASURED_FORMS = ( + "reassign_int_hint", "reassign_plain_qty", "reassign_literal_qty", + "reassign_series_qty", "reassign_untyped", "reassign_typed", +) + + +def _source(body: str) -> str: + return ( + '//@version=6\nstrategy("Fractional storage", currency=currency.NONE)\n' + 'n = input.int(5, "Numerator")\n' + + dedent(body).lstrip() + ) + + +def _assert_fractional_storage(cpp: str) -> None: + assert re.search(r"\b(?:double|Series)\s+s(?:\b|_cs)", cpp), cpp + assert not re.search(r"\b(?:int|int64_t|Series)\s+s\b", cpp), cpp + writes = [ + line for line in cpp.splitlines() + if re.search(r"\bs(?:_cs\d+)?(?: = |\.update\()", line) and " / " in line + ] + assert writes, cpp + assert all("_pf_v" not in line and "na()" not in line for line in writes), writes + + +@pytest.mark.parametrize("name", MEASURED_FORMS) +def test_measured_reassignment_forms_use_double_storage(name): + _assert_fractional_storage( + transpile((FIXTURES / f"{name}.pine").read_text(encoding="utf-8")), + ) + + +@pytest.mark.parametrize("name", ( + "widened_float_step", "widened_int_hint_float_step", +)) +def test_float_step_loop_from_a_widened_variable_is_refused_like_int(name): + source = (FIXTURES / f"{name}.pine").read_bytes() + pine = source.decode("utf-8") + refusal = TV["compile_refusals"][name] + assert refusal["verdict"] == "REFUSED" + assert refusal["code"] == "CE10106" + assert hashlib.sha256(source).hexdigest() == refusal["source_sha256"] + # TradingView's recorded span "line:first-last" (last inclusive) is the + # step literal; the engine's location is the same columns, end exclusive. + start, end = refusal["span"].split("-") + line, first = (int(part) for part in start.split(":")) + _, last = (int(part) for part in end.split(":")) + assert_float_step_refused(pine, location=(line, first, last + 1)) + + +@pytest.mark.parametrize("name", ( + "callable_direct_float_step", "callable_bound_float_step", + "callable_input_float_step", "plain_callable_direct_literal", + "plain_callable_direct_input", "plain_callable_bound_input", +)) +def test_float_step_loop_from_a_measured_callable_is_refused_like_int(name): + source = (FIXTURES / f"{name}.pine").read_bytes() + pine = source.decode("utf-8") + refusal = TV["compile_refusals"][name] + assert refusal["verdict"] == "REFUSED" + assert refusal["code"] == "CE10106" + assert hashlib.sha256(source).hexdigest() == refusal["source_sha256"] + start, end = refusal["span"].split("-") + line, first = (int(part) for part in start.split(":")) + end_line, last = (int(part) for part in end.split(":")) + assert end_line == line + # The recorded span must select the step on the loop-header line. + header = re.fullmatch( + r"\s*for i = .* by (?P.+)", pine.splitlines()[line - 1], + ) + assert header is not None + assert (header.start("step") + 1, header.end("step")) == (first, last) + assert_float_step_refused(pine, location=(line, first, last + 1)) + + +CALLABLE_LOOP_START_REFUSALS = { + "callable_var_direct": _source(""" + measure(int seed) => + var int s = seed + s := seed + s := s / 2 + s + float observed = 0.0 + for i = measure(5) to 6 by 0.5 + observed += i + plot(observed) + """), + "callable_var_named": _source(""" + measure(int seed) => + var int s = seed + s := seed + s := s / 2 + s + x = measure(n) + float observed = 0.0 + for i = x to 6 by 0.5 + observed += i + plot(observed) + """), + "callable_local_direct": _source(""" + measure(int seed) => + int s = seed + s := s / 2 + s + float observed = 0.0 + for i = measure(5) to 6 by 0.5 + observed += i + plot(observed) + """), + "callable_local_named": _source(""" + measure(int seed) => + int s = seed + s := s / 2 + s + x = measure(n) + float observed = 0.0 + for i = x to 6 by 0.5 + observed += i + plot(observed) + """), +} + + +@pytest.mark.parametrize("name", tuple(CALLABLE_LOOP_START_REFUSALS)) +def test_float_step_loop_from_a_widened_callable_is_refused(name): + """TradingView refused every measured float-step loop from a callable result. + + Both the var and plain-local callables were refused (CE10106) when called + directly with a literal, directly with an input, or bound to a name. + The fixtures above pin the receipts; these inline rows pin the engine's + refusal of the shapes written inline. + """ + assert_float_step_refused(CALLABLE_LOOP_START_REFUSALS[name]) + + +# These controls derive from the measured plain-quantity row. They exercise +# where the quotient is stored, without claiming additional TradingView tapes. +# Persistent cases reset before dividing so every bar has the measured value. +SCOPE_SOURCES = { + "global": _source(""" + int s = n + s := s / 2 + observed = s + """), + "global_var": _source(""" + var int s = n + s := n + s := s / 2 + observed = s + """), + "global_varip": _source(""" + varip int s = n + s := n + s := s / 2 + observed = s + """), + "callable_local": _source(""" + measure(int seed) => + int s = seed + s := s / 2 + s + observed = measure(n) + """), + "callable_inferred": _source(""" + measure(int seed) => + s = seed + s := s / 2 + s + observed = measure(n) + """), + "callable_var": _source(""" + measure(int seed) => + var int s = seed + s := seed + s := s / 2 + s + observed = measure(n) + """), + "block_local": _source(""" + float observed = 0.0 + if n > 0 + int s = n + s := s / 2 + observed := s + """), + "loop_body": _source(""" + float observed = 0.0 + for turn = 0 to 0 + int s = n + s := s / 2 + observed := s + """), + "write_in_loop": _source(""" + int s = n + for turn = 0 to 0 + s := s / 2 + observed = s + """), + "history": _source(""" + int s = n + s := s / 2 + observed = nz(s[1], s) + """), + "write_after_read": _source(""" + int s = n + before = s + if n > 0 + s := s / 2 + observed = s + (before - n) + """), + "write_after_loop_read": _source(""" + int s = n + float total = 0.0 + for i = s to s + 2 + total += i + s := s / 2 + observed = s + """), + "transitive_writes": _source(""" + int first = n + int s = n + s := first + first := first / 2 + s := first + observed = s + """), +} + + +@pytest.mark.parametrize("name", tuple(SCOPE_SOURCES)) +def test_fractional_storage_reaches_each_unambiguous_scope(name): + cpp = transpile(SCOPE_SOURCES[name]) + if name == "transitive_writes": + # The division writes first; s receives the value through another :=. + assert re.search(r"\bdouble first\b", cpp), cpp + assert re.search(r"\bdouble s\b", cpp), cpp + else: + _assert_fractional_storage(cpp) + if name == "history": + assert re.search(r"\bSeries\s+s\b", cpp), cpp + if name == "write_after_loop_read": + assert re.search(r"\bfor \(double i = ", cpp), cpp + + +@pytest.mark.parametrize("qualifier", ("var", "varip")) +@pytest.mark.parametrize("signature,call,cpp_name", ( + ("measure(int seed)", "measure(n)", "measure"), + ("method measure(int seed)", "n.measure()", "_udt_int_measure"), +)) +def test_persistent_callable_storage_and_return_keep_the_fraction( + qualifier, signature, call, cpp_name, +): + cpp = transpile(_source(f""" + {signature} => + {qualifier} int s = seed + s := seed + s := s / 2 + s + observed = {call} + """)) + _assert_fractional_storage(cpp) + # A double member alone did not prevent an int return variant from + # truncating it. Check the function boundary as well as the quotient write. + definitions = list(re.finditer( + rf"\b(double|int|int64_t) {cpp_name}(?:_cs\d+)?\([^)]*\) \{{" + r"(.*?)\n \}", + cpp, re.DOTALL, + )) + assert definitions, cpp + for definition in definitions: + assert definition.group(1) == "double", definition.group(0) + assert "(int)_pf_v" not in definition.group(2), definition.group(0) + assert re.search(r"\bdouble observed\b", cpp), cpp + + +@pytest.mark.parametrize("qualifier", ("var", "varip")) +def test_cloned_persistent_callable_members_and_returns_are_double(qualifier): + pine = SCOPE_SOURCES["callable_var"].replace("var int s", f"{qualifier} int s") + cpp = transpile(pine + "other = measure(n + 1)\n") + _assert_fractional_storage(cpp) + assert re.search(r"\bdouble s_cs1\b", cpp), cpp + assert re.search(r"\bdouble measure_cs0\(", cpp), cpp + assert re.search(r"\bdouble measure_cs1\(", cpp), cpp + assert not re.search(r"\b(?:int|int64_t) measure_cs\d+\(", cpp), cpp + + +@pytest.mark.parametrize("qualifier", ("var", "varip")) +def test_loop_body_persistent_storage_keeps_the_fraction(qualifier): + cpp = transpile(_source(f""" + float observed = 0.0 + for turn = 0 to 0 + {qualifier} int s = n + s := n + s := s / 2 + observed := s + """)) + _assert_fractional_storage(cpp) + + +def test_security_helper_persistent_storage_keeps_the_fraction(): + pine = SCOPE_SOURCES["callable_var"].replace( + "observed = measure(n)", + 'observed = request.security(syminfo.tickerid, "2", measure(n))', + ) + cpp = transpile(pine) + _assert_fractional_storage(cpp) + start = cpp.index("void _eval_security_0(") + body = cpp[start:cpp.index("void evaluate_security(", start)] + assert "@var_seed" in body, body + writes = [ + line for line in body.splitlines() + if "_security_helper_series_[" in line and ".update(" in line + and " / " in line + ] + assert writes, body + assert all("(int)_pf_v" not in line and "na()" not in line + for line in writes), writes + + +VALUE_DRIVER = r''' +#include +#include +int main() { + Bar bars[] = { + {10.0, 10.0, 10.0, 10.0, 1.0, 60000}, + {10.0, 10.0, 10.0, 10.0, 1.0, 120000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 2, "1", "1"); + assert(strategy.last_error().empty()); + std::cout << strategy.observed; +} +''' + + +@pytest.mark.parametrize("name", tuple(SCOPE_SOURCES)) +def test_scope_storage_keeps_the_same_quotient_in_every_scope(name): + """Engine-consistency control, not TradingView evidence for each scope. + + The expected quantity is the plain-global shape's recorded TradingView + value; TradingView was not run on these storage-scope sources. + """ + # The existing helper owns the native-prerequisite skip. + actual = compile_env.run_emitted_tu( + transpile(SCOPE_SOURCES[name]), VALUE_DRIVER, opt="-O2", label=name, + ) + assert float(actual) == TV["entry_quantities"]["reassign_int_hint"] + + +def test_explicit_int_loop_is_consistent_with_the_untyped_loop_measurement(): + """CONSISTENCY row: the same loop shape as the measured untyped source. + + TradingView was not run on this explicit-int loop source; it is not a tape row. + """ + pine = _source(""" + int s = n + s := s / 2 + float observed = 0.0 + for i = s to s + 2 + observed += i + """) + cpp = transpile(pine) + _assert_fractional_storage(cpp) + assert re.search(r"\bfor \(double i = ", cpp), cpp + actual = compile_env.run_emitted_tu(cpp, VALUE_DRIVER, opt="-O2") + assert float(actual) == TV["entry_quantities"]["reassign_untyped"] + + +@pytest.mark.parametrize("hint", ("", "int ")) +@pytest.mark.parametrize("step", ("", " by 1")) +def test_widened_loop_start_keeps_double_storage_with_an_integer_step(hint, step): + cpp = transpile(_source(f""" + {hint}s = n + s := s / 2 + float observed = 0.0 + for i = s to s + 2{step} + observed += i + """)) + _assert_fractional_storage(cpp) + assert re.search(r"\bfor \(double i = ", cpp), cpp + + +@pytest.mark.parametrize("rhs", ("math.sqrt(n)", "n * 0.5", "float(n)")) +def test_positive_float_expression_writes_use_double_storage(rhs): + """engine-vs-engine control, not TradingView evidence.""" + cpp = transpile(_source(f"int s = n\ns := {rhs}\nplot(s)\n")) + assert re.search(r"\bdouble s\b", cpp), cpp + writes = [line for line in cpp.splitlines() if re.search(r"\bs = ", line)] + # The narrowing helper's own text: an int cast of the written value. The + # state checkpoint lines carry a member named _pf_value_N, which is not it. + assert all("(int)_pf_v" not in line and "na" not in line + for line in writes), writes + + +FROZEN_CONTROLS = { + "inferred_integer_write": "s = n\ns := s + 1\nplot(s)\n", + "hinted_integer_write": "int s = n\ns := s + 1\nplot(s)\n", + "literal_integer_write": "s = 5\ns := s + 1\nplot(s)\n", + "persistent_integer_write": "var int s = n\ns := s + 1\nplot(s)\n", + "direct_quotient_initializer": "int s = n / 2\nplot(s)\n", + "proved_integral_quotient_write": "int s = n\ns := 4 / 2\nplot(s)\n", + # Direct, non-quotient float-literal writes are unmeasured and retain + # their existing narrowing for every declaration spelling. + "float_literal_write": "int s = n\ns := 5.7\nplot(s)\n", + "signed_float_literal_write": "int s = n\ns := -5.7\nplot(s)\n", + "local_integer_write": """ + measure(int seed) => + int s = seed + s := s + 1 + s + plot(measure(n)) + """, + "callable_var_integer_write": """ + measure(int seed) => + var int s = seed + s := s + 1 + s + plot(measure(n)) + """, + "callable_varip_integer_write": """ + measure(int seed) => + varip int s = seed + s := s + 1 + s + plot(measure(n)) + """, + "callable_var_literal_write": """ + measure(int seed) => + var int s = seed + s := 5.7 + s + plot(measure(n)) + """, + "callable_var_integral_quotient_write": """ + measure(int seed) => + var int s = seed + s := 4 / 2 + s + plot(measure(n)) + """, + "shadowed_callable_var": """ + int s = n + measure(int seed) => + var int s = seed + s := s / 2 + s + plot(s + measure(n)) + """, + "loop_body_integer_write": """ + float total = 0.0 + for turn = 0 to 0 + int s = n + s := s + 1 + total += s + plot(total) + """, + # Name-based emitter lookups still prevent safe widening of these two + # distinct bindings. Preserve this known gap without refusing the source. + "shadowed_storage": """ + int s = n + measure() => + int s = n + s := s / 2 + s + plot(s + measure()) + """, +} + + +@pytest.mark.parametrize("name", tuple(FROZEN_CONTROLS)) +def test_integer_and_unmeasured_controls_keep_complete_frozen_cpp(name, tmp_path): + pine = _source(FROZEN_CONTROLS[name]) + reference = reference_codegen(CPP_REFERENCE) + assert reference is not None, "Required C++ reference unavailable; restore git history." + path = tmp_path / "strategy.pine" + path.write_text(pine, encoding="utf-8") + before = transpile_json(path, root=reference) + assert before["ok"], before["diagnostics"] + assert transpile(pine) == before["cpp"] + + +def _parameter_loop_source(parameter: str) -> str: + return _source(f""" + f({parameter}) => + float total = 0.0 + for i = p to 6 by 0.5 + total += i + total + plot(f(2)) + """) + + +# Known gap: TradingView refuses the registry probe +# f(float p) => p; f(2) with CE10106, while PineForge translates it +# (accepting what TradingView refuses is a disagreement too). +# These parameter-loop rows make no TradingView agreement claim. +@pytest.mark.parametrize("parameter,expected", [ + ("int p", TypeSpec.primitive("int")), + ("float p", TypeSpec.primitive("float")), + ("p", None), +]) +def test_declaration_bound_parameter_check_uses_its_own_hint(parameter, expected): + pine = _parameter_loop_source(parameter) + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + bindings = ValueBindings(ast, "parameter.pine") + bindings.resolve() + function = next(node for node, _ in iter_ast_nodes(ast) if isinstance(node, FuncDef)) + loop = next(node for node, _ in iter_ast_nodes(ast) if isinstance(node, ForStmt)) + assert bindings.declaration_bindings[id(loop.start)] == (id(function), "p") + query = _CounterQuery(LoopTypeSources(ast), bindings.declaration_bindings, {}) + # The function returns a float total regardless of its parameter hint. + # Following that return instead makes the int and untyped rows fail. + assert query.infer(loop.start) == expected + + +# Analyzer parameter symbols have no declaration identity in the counter map. +# The frozen query already reads their hints: int refuses the float step; +# float and untyped starts translate with double counters. +@pytest.mark.parametrize("parameter,accepted", [ + pytest.param("int p", False, id="int_parameter"), + pytest.param("float p", True, id="float_parameter"), + pytest.param("p", True, id="untyped_parameter"), +]) +def test_parameter_loop_start_is_unchanged_by_the_storage_fix( + parameter, accepted, tmp_path, +): + reference = reference_codegen(CPP_REFERENCE) + assert reference is not None, "Required C++ reference unavailable; restore git history." + pine = _parameter_loop_source(parameter) + path = tmp_path / "strategy.pine" + path.write_text(pine, encoding="utf-8") + before = transpile_json(path, root=reference) + assert before["ok"] is accepted, before["diagnostics"] + if not accepted: + assert "PF-E2079" in {diagnostic["code"] for diagnostic in before["diagnostics"]} + assert_float_step_refused(pine) + else: + cpp = transpile(pine) + assert re.search(r"\bfor \(double i = ", cpp), cpp + assert cpp == before["cpp"]