From b225637fe024fe41e28c8e4e28e112194cddd05b Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 00:10:14 +0800 Subject: [PATCH 1/9] Store a reassigned int variable as double when it receives a value that can be fractional TradingView keeps the fraction when a variable that starts from an int is later assigned a quotient (`s = n` then `s := s / 2`), whatever its declaration form: inferred, an explicit `int`, a literal start or a series. We declared the variable `int` from its initializer and cut the quotient to an int when it was written, so every later read, a loop start or a plain `qty = s`, saw only the integer part (2 where TradingView trades 2.5; 9.0 where it trades 10.5). A pre-pass over the `:=` writes now joins the fractional evidence into each int declaration before any reader, member or history snapshot is recorded, so the storage is a double. It does not look at the `int` keyword. A write of a float literal, a quotient that is proved integral, and a variable only ever assigned ints keep their present C++. The loop counter and the storage share one value query, so there is a single rule for "this value can be fractional"; the counter-only query is removed. Measured on TradingView and pinned from the recorded tapes: the explicit-int, plain, literal and series forms and the loop forms. Names declared more than once, tuple targets, compound assignments, methods and an untyped helper parameter fed a quotient keep their present behaviour and are named in the tests. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 12 +- CLAUDE.md | 12 +- pineforge_codegen/analyzer/base.py | 15 +- pineforge_codegen/analyzer/value_bindings.py | 9 +- pineforge_codegen/codegen/types.py | 4 +- pineforge_codegen/loop_types.py | 99 ++++++- tests/_for_fractional_carriers.py | 4 +- .../for_counter_headers/helper_param.pine | 12 + .../reassign_int_hint.pine | 9 + .../reassign_literal_qty.pine | 8 + .../reassign_plain_qty.pine | 9 + .../reassign_series_qty.pine | 9 + .../for_counter_headers/reassign_typed.pine | 12 + .../for_counter_headers/reassign_untyped.pine | 12 + .../tradingview_quantities.json | 25 ++ tests/test_e2e_for_counter_headers.py | 47 +++- tests/test_float_reassignment.py | 258 ++++++++++++++++++ 17 files changed, 525 insertions(+), 31 deletions(-) create mode 100644 tests/fixtures/for_counter_headers/helper_param.pine create mode 100644 tests/fixtures/for_counter_headers/reassign_int_hint.pine create mode 100644 tests/fixtures/for_counter_headers/reassign_literal_qty.pine create mode 100644 tests/fixtures/for_counter_headers/reassign_plain_qty.pine create mode 100644 tests/fixtures/for_counter_headers/reassign_series_qty.pine create mode 100644 tests/fixtures/for_counter_headers/reassign_typed.pine create mode 100644 tests/fixtures/for_counter_headers/reassign_untyped.pine create mode 100644 tests/test_float_reassignment.py diff --git a/AGENTS.md b/AGENTS.md index 7d7c1ef5..622c2cb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,7 +162,8 @@ 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 also proves fractional +│ reassignment storage before analysis. ├── pragmas.py // @pf-trace extraction ├── library_modules.py A Pine library source parsed as a │ module: its library() declaration, @@ -480,6 +481,15 @@ 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. 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. +`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..a463188b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,7 +162,8 @@ 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 also proves fractional +│ reassignment storage before analysis. ├── pragmas.py // @pf-trace extraction ├── library_modules.py A Pine library source parsed as a │ module: its library() declaration, @@ -480,6 +481,15 @@ 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. 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. +`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/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..e2f9f213 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,6 +116,59 @@ 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) + 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: def __init__(self, sources, bindings, scopes): @@ -113,8 +185,11 @@ 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 (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 +204,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 +242,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 +374,8 @@ 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.""" 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/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/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..ba7d2d20 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -2,6 +2,7 @@ "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.", "ta_counter_type_receipt": { "receipt": "tacarriers-2302", "sha256": "be854854835eed60f33abe809a60e100cf978a304ba68138a332ef9d1124d1cc", @@ -22,6 +23,12 @@ "counter_string": 615 }, "entry_quantities": { + "reassign_int_hint": 2.5, + "reassign_plain_qty": 2.5, + "reassign_literal_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 +79,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], @@ -119,6 +131,12 @@ } }, "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 +157,13 @@ "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"}, + "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/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index 49c4877f..e98584dd 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -34,6 +34,13 @@ 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", +) +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. DIVISION_TAPE_TWINS = { "division_start_no_step": ("for i = 5 / 2 to", "for i = 2.5 to"), @@ -50,7 +57,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 +222,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 +300,9 @@ 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, 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" @@ -410,6 +426,11 @@ 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): + 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): @@ -463,16 +484,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..189953f1 --- /dev/null +++ b/tests/test_float_reassignment.py @@ -0,0 +1,258 @@ +"""Fractional storage, measured quantities, and unchanged integer controls.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re +from textwrap import dedent + +import pytest + +from pineforge_codegen import transpile +from tests import _compile as compile_env +from tests._e2e import reference_codegen, transpile_json + + +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")), + ) + + +# 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 + + +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_measured_quotient(name): + # 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_storage_and_loop_keep_the_same_fraction(): + 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("rhs", ("math.sqrt(n)", "n * 0.5", "float(n)")) +def test_positive_float_expression_writes_use_double_storage(rhs): + 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)] + assert all("_pf_v" 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)) + """, + "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"] From 9d68ae4a6b89d9c9a47d6d7c850afdb5a62eebd1 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 00:16:28 +0800 Subject: [PATCH 2/9] Check the narrowing helper's own text in the float-write storage rows The three rows that write a float expression into an int variable looked for the substring `_pf_v` in every write to the variable, but the state-checkpoint line `this->s = _pf_script_state_checkpoint_->_pf_value_1;` carries a member named `_pf_value_1`, so the rows failed on a line that has no narrowing. They now look for the helper's own text: an int cast of the written value and the int na value. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_float_reassignment.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_float_reassignment.py b/tests/test_float_reassignment.py index 189953f1..cc5372ec 100644 --- a/tests/test_float_reassignment.py +++ b/tests/test_float_reassignment.py @@ -204,7 +204,10 @@ def test_positive_float_expression_writes_use_double_storage(rhs): 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)] - assert all("_pf_v" not in line for line in writes), writes + # 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 = { From b515338d0f5f5ad3d469fbd5966b7409bcfe3737 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 00:28:57 +0800 Subject: [PATCH 3/9] Pin the parameter branch of the counter check, label the consistency and engine-only rows, and state the same-name limit A loop start that is a function parameter takes the type of the parameter's own hint in the counter check (an int parameter with a float step is refused, a float or untyped one translates), where the check previously followed the function's return type. Three rows pin that branch directly and three more compare the whole generated C++ of a parameter-fed loop against the frozen reference, so the translation of such a loop is shown unchanged. The registry probe `f(float p) => p; f(2)`, which TradingView refuses and we translate, is noted as a known difference in the safe direction; these rows make no TradingView claim. The explicit-int loop row is named and described as a consistency row (the same shape as the measured untyped loop; TradingView was not run on it), and the rows that compare the engine with an engine twin say so. A comment at the name-collision guard records why a name declared more than once is not widened: the emitter looks storage up by name and cannot tell two same-named bindings apart. No generated C++ changes. Co-Authored-By: Claude Sonnet 5.5 --- pineforge_codegen/loop_types.py | 7 +++ tests/test_e2e_for_counter_headers.py | 6 +++ tests/test_float_reassignment.py | 75 ++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index e2f9f213..d4d7884d 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -129,6 +129,13 @@ def widen_reassignments(self, bindings, *, budget=None): 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] = [] diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index e98584dd..d881bca0 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -38,10 +38,12 @@ "reassign_int_hint", "reassign_plain_qty", "reassign_literal_qty", "reassign_series_qty", "reassign_untyped", "reassign_typed", ) +# 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"), @@ -415,6 +417,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" @@ -428,12 +431,14 @@ def test_division_tape_fixture_keeps_its_counter_family_and_twin(runs, name): @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")] @@ -454,6 +459,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] diff --git a/tests/test_float_reassignment.py b/tests/test_float_reassignment.py index cc5372ec..467ed740 100644 --- a/tests/test_float_reassignment.py +++ b/tests/test_float_reassignment.py @@ -10,8 +10,16 @@ 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" @@ -184,7 +192,11 @@ def test_scope_storage_keeps_the_measured_quotient(name): assert float(actual) == TV["entry_quantities"]["reassign_int_hint"] -def test_explicit_int_storage_and_loop_keep_the_same_fraction(): +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 @@ -201,6 +213,7 @@ def test_explicit_int_storage_and_loop_keep_the_same_fraction(): @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)] @@ -259,3 +272,63 @@ def test_integer_and_unmeasured_controls_keep_complete_frozen_cpp(name, tmp_path 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 safe-direction difference: TradingView refuses the registry probe +# f(float p) => p; f(2) with CE10106, while PineForge translates it. +# 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"] From 9b593df95d611f254ebecd43390e446d62be9181 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 00:41:14 +0800 Subject: [PATCH 4/9] Keep the int check type of a widened variable; only its value and storage are double TradingView refuses a float-step loop whose start is a variable that starts as an int and is later assigned a quotient (`s = n`, `s := s / 2`, `for i = s to 6 by 0.5`, and the same with `int s = n`): its compile check types the variable as a series int, while the value it holds is a double. The storage change made the variable a float for the compile check too, so both scripts would have been accepted. The widened declaration now reads as float only in the value query, which chooses the loop counter family and the storage; the check query that refuses a float step keeps the hint or the initializer's int. The two scripts are refused with the same message as before the storage change, and a loop with the default step or an int step from the same variable keeps its double counter and its translation. No generated C++ changes. The rows that note a difference from TradingView on a helper parameter now call it a known gap. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 9 +++-- CLAUDE.md | 9 +++-- pineforge_codegen/loop_types.py | 10 +++++- .../tradingview_quantities.json | 15 +++++++++ .../widened_float_step.pine | 12 +++++++ .../widened_int_hint_float_step.pine | 12 +++++++ tests/test_float_reassignment.py | 33 +++++++++++++++++-- 7 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/for_counter_headers/widened_float_step.pine create mode 100644 tests/fixtures/for_counter_headers/widened_int_hint_float_step.pine diff --git a/AGENTS.md b/AGENTS.md index 622c2cb4..ecec913a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,8 +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; -│ its value query also proves fractional -│ reassignment storage before analysis. +│ 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, @@ -483,7 +484,9 @@ 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. This covers unambiguous global, +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. diff --git a/CLAUDE.md b/CLAUDE.md index a463188b..c04a9018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,8 +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; -│ its value query also proves fractional -│ reassignment storage before analysis. +│ 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, @@ -483,7 +484,9 @@ 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. This covers unambiguous global, +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. diff --git a/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index d4d7884d..69335c4d 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -178,6 +178,9 @@ def widen_reassignments(self, bindings, *, budget=None): 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 @@ -193,7 +196,10 @@ def declaration(self, node): self.declaration_specs[key] = None if isinstance(node, VarDecl): spec = ( - _FLOAT if (node.annotations or {}).get(FLOAT_REASSIGNMENT) else + _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) ) @@ -384,6 +390,8 @@ def array_result(name, spec): 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) self.check_query = check_query diff --git a/tests/fixtures/for_counter_headers/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index ba7d2d20..f024bfe5 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -3,6 +3,7 @@ "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.", + "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.", "ta_counter_type_receipt": { "receipt": "tacarriers-2302", "sha256": "be854854835eed60f33abe809a60e100cf978a304ba68138a332ef9d1124d1cc", @@ -128,6 +129,20 @@ "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", + "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", + "source_sha256": "436c4da2f6674e4b32770a88957ed0c3b775d6fcc206a89796d7b7f8bd0f2f69", + "error_json_sha256": "cd67711a0727c06058e5f74d8b50eaa92a07dfcab7e652e669baa8191738594b" } }, "known_gaps": { 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_float_reassignment.py b/tests/test_float_reassignment.py index 467ed740..6a791232 100644 --- a/tests/test_float_reassignment.py +++ b/tests/test_float_reassignment.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json from pathlib import Path import re @@ -58,6 +59,19 @@ def test_measured_reassignment_forms_use_double_storage(name): ) +@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"] + assert_float_step_refused(pine) + + # 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. @@ -211,6 +225,20 @@ def test_explicit_int_loop_is_consistent_with_the_untyped_loop_measurement(): 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.""" @@ -285,8 +313,9 @@ def _parameter_loop_source(parameter: str) -> str: """) -# Known safe-direction difference: TradingView refuses the registry probe -# f(float p) => p; f(2) with CE10106, while PineForge translates it. +# 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")), From 64ad93d52f876f1dc47c5821c7ab2ac6bea21878 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 00:45:32 +0800 Subject: [PATCH 5/9] Record TradingView's refusal span for the widened float-step rows and check the engine's location against it The two refusal rows now carry the span TradingView reports for the step literal (line 7, columns 19 to 21, last column inclusive), and the row asserts that the engine refuses at the same columns, where it previously checked only the code and the message. Test data and assertions only; no generated C++ changes. Co-Authored-By: Claude Sonnet 5.5 --- .../for_counter_headers/tradingview_quantities.json | 2 ++ tests/test_float_reassignment.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/for_counter_headers/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index f024bfe5..cf6d3e3f 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -134,6 +134,7 @@ "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" }, @@ -141,6 +142,7 @@ "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" } diff --git a/tests/test_float_reassignment.py b/tests/test_float_reassignment.py index 6a791232..27548eb2 100644 --- a/tests/test_float_reassignment.py +++ b/tests/test_float_reassignment.py @@ -69,7 +69,12 @@ def test_float_step_loop_from_a_widened_variable_is_refused_like_int(name): assert refusal["verdict"] == "REFUSED" assert refusal["code"] == "CE10106" assert hashlib.sha256(source).hexdigest() == refusal["source_sha256"] - assert_float_step_refused(pine) + # 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)) # These controls derive from the measured plain-quantity row. They exercise From 342303eacd3b0230b7ece5d0ba620617b53ea782 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 01:20:06 +0800 Subject: [PATCH 6/9] Keep the widened storage of a variable declared inside a function through the function's return type A persistent `var int s` declared inside a function, assigned a quotient and returned, still gave the integer part when the function was called: the member was already a double, but the function's return type was inferred from the written `int` hint, so the value was cut at the return. The return inference now reads the same widening annotation as the declaration, so such a function returns a double and the caller sees the fraction. A function whose variable is written only with ints keeps its present C++. Rows: a translation row for the callable `var` form and its `varip` sibling, frozen-C++ controls for int-only writes in the same shapes, and the scope rows are now named and described as engine-consistency controls (the expected quantity is the plain-global tape's value; TradingView was not run on each storage scope). Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 3 + CLAUDE.md | 3 + pineforge_codegen/analyzer/call_handlers.py | 6 + tests/test_float_reassignment.py | 118 +++++++++++++++++++- 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ecec913a..e2affc55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -490,6 +490,9 @@ 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++. diff --git a/CLAUDE.md b/CLAUDE.md index c04a9018..d3cce324 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -490,6 +490,9 @@ 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++. 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/tests/test_float_reassignment.py b/tests/test_float_reassignment.py index 27548eb2..8186f190 100644 --- a/tests/test_float_reassignment.py +++ b/tests/test_float_reassignment.py @@ -186,6 +186,81 @@ def test_fractional_storage_reaches_each_unambiguous_scope(name): 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 @@ -203,7 +278,12 @@ def test_fractional_storage_reaches_each_unambiguous_scope(name): @pytest.mark.parametrize("name", tuple(SCOPE_SOURCES)) -def test_scope_storage_keeps_the_measured_quotient(name): +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, @@ -274,6 +354,42 @@ def test_positive_float_expression_writes_use_double_storage(rhs): 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 From 2520d9c2d46b10de99a111304552b02e31403174 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 01:34:16 +0800 Subject: [PATCH 7/9] Bind TradingView's two callable tapes to fixtures and pin the refusal of a callable result as a float-step loop start TradingView accepted both callable sources (a persistent `var int s` and a plain `int s` declared inside a function, assigned a quotient and returned) and traded 2.5 on all 205 entries. The two sources are now byte-exact fixtures with their measurement records (quantity, entry count, full source and tape digests), registered with the native per-entry quantity rows and the source-digest checks. The scope rows that carry the same quotient into other storage scopes stay engine-consistency controls. Four rows pin that a loop whose start is a callable's result and whose step is a float is refused with the float-step diagnostic, directly and through a name, for both callables. TradingView's verdict for that shape was not measured; the rows say so and only make a later change visible. No product file changes. Co-Authored-By: Claude Sonnet 5.5 --- .../callable_local_qty.pine | 12 +++++ .../for_counter_headers/callable_var_qty.pine | 13 +++++ .../tradingview_quantities.json | 7 +++ tests/test_e2e_for_counter_headers.py | 4 +- tests/test_float_reassignment.py | 54 +++++++++++++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/for_counter_headers/callable_local_qty.pine create mode 100644 tests/fixtures/for_counter_headers/callable_var_qty.pine 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/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index cf6d3e3f..6c17c763 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -3,6 +3,7 @@ "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.", "ta_counter_type_receipt": { "receipt": "tacarriers-2302", @@ -19,6 +20,8 @@ }, "entries_per_probe": 205, "entry_counts": { + "callable_var_qty": 205, + "callable_local_qty": 205, "fractional_descending": 205, "fractional_array_index": 205, "counter_string": 615 @@ -27,6 +30,8 @@ "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, @@ -177,6 +182,8 @@ "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"}, diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index d881bca0..a9072382 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -38,6 +38,7 @@ "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 @@ -303,7 +304,8 @@ def test_tradingview_evidence_has_complete_sha256(): @pytest.mark.parametrize("name", ( - *REASSIGNMENT_TAPES, HELPER_PARAMETER_GAP, *DIVISION_TAPE_TWINS, + *REASSIGNMENT_TAPES, *CALLABLE_QUANTITY_TAPES, + HELPER_PARAMETER_GAP, *DIVISION_TAPE_TWINS, )) def test_division_fixture_is_the_measured_source(name): evidence = TV["evidence"][name] diff --git a/tests/test_float_reassignment.py b/tests/test_float_reassignment.py index 8186f190..78056476 100644 --- a/tests/test_float_reassignment.py +++ b/tests/test_float_reassignment.py @@ -77,6 +77,60 @@ def test_float_step_loop_from_a_widened_variable_is_refused_like_int(name): 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 verdict unmeasured; engine refusal pinned so a later change is visible.""" + 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. From aaa0901f3f87d2609697f84924dfaf3d723938a8 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 01:44:52 +0800 Subject: [PATCH 8/9] Pin TradingView's refusal of a float-step loop that starts at a function's result against its recorded verdict TradingView refuses a loop whose start is the result of a function that holds a persistent `var int s` assigned a quotient (CE10106, the same verdict as for an int start), both for a direct call `for i = measure(5) to 6 by 0.5` and for a result bound to a name. The two sources are now byte-exact fixtures with their recorded verdict, span and digests, and two rows assert that the engine refuses each with the float-step diagnostic at the span's columns. The four inline rows for a callable result as a loop start keep pinning the engine's refusal for the `var` and plain-local forms; their description now says which shapes TradingView refused (the `var` form directly and bound, the plain-local form directly with a literal, directly with an input and bound) and that the plain-local receipts are not stored as fixtures. No product file changes. Co-Authored-By: Claude Sonnet 5.5 --- .../callable_bound_float_step.pine | 16 +++++++++ .../callable_direct_float_step.pine | 14 ++++++++ .../tradingview_quantities.json | 17 ++++++++++ tests/test_float_reassignment.py | 33 ++++++++++++++++++- 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/for_counter_headers/callable_bound_float_step.pine create mode 100644 tests/fixtures/for_counter_headers/callable_direct_float_step.pine 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/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index 6c17c763..dd51995e 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -5,6 +5,7 @@ "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 and callable_bound_float_step fixtures copy the measured sources byte for byte. TradingView refused both 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 table has SHA-256 e6e85132a0d37351bfc7238c05628552050398ce1206fbbccac1a15583809693. Plain-local callable float-step shapes remain TradingView-unmeasured.", "ta_counter_type_receipt": { "receipt": "tacarriers-2302", "sha256": "be854854835eed60f33abe809a60e100cf978a304ba68138a332ef9d1124d1cc", @@ -150,6 +151,22 @@ "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" } }, "known_gaps": { diff --git a/tests/test_float_reassignment.py b/tests/test_float_reassignment.py index 78056476..8ff77196 100644 --- a/tests/test_float_reassignment.py +++ b/tests/test_float_reassignment.py @@ -77,6 +77,29 @@ def test_float_step_loop_from_a_widened_variable_is_refused_like_int(name): assert_float_step_refused(pine, location=(line, first, last + 1)) +@pytest.mark.parametrize("name", ( + "callable_direct_float_step", "callable_bound_float_step", +)) +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) => @@ -127,7 +150,15 @@ def test_float_step_loop_from_a_widened_variable_is_refused_like_int(name): @pytest.mark.parametrize("name", tuple(CALLABLE_LOOP_START_REFUSALS)) def test_float_step_loop_from_a_widened_callable_is_refused(name): - """TradingView verdict unmeasured; engine refusal pinned so a later change is visible.""" + """TradingView refuses a float-step loop that starts at a callable's result. + + Measured (CE10106): the var callable called directly with a literal and + bound to a name (the two fixture rows above), and the plain-local callable + called directly with a literal, directly with an input and bound to a + name. These inline rows pin the engine's refusal of each shape so a later + change is visible; the TradingView receipts for the plain-local shapes are + not stored as fixtures here. + """ assert_float_step_refused(CALLABLE_LOOP_START_REFUSALS[name]) From 5e28bf3867f2443c95c748a75b2bcef843c4a85f Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 01:56:15 +0800 Subject: [PATCH 9/9] Bind the remaining TradingView refusals of a function's result as a float-step loop start TradingView refused four more loops whose start is the result of a function that holds a quotient-assigned variable: the persistent `var` function called with an input, and the plain-local function called directly with a literal, directly with an input and bound to a name (CE10106 each, one export per source). The four sources are byte-exact fixtures with their recorded verdict, span and digests, and each gets the same row as the two earlier fixtures: the engine must refuse it with the float-step diagnostic at the recorded span's columns. The description of the inline rows now says that TradingView refused every measured form of the shape, that the fixtures pin the receipts, and that the inline rows pin the engine's refusal of the shapes written inline. No product file changes. Co-Authored-By: Claude Sonnet 5.5 --- .../callable_input_float_step.pine | 15 ++++++++ .../plain_callable_bound_input.pine | 15 ++++++++ .../plain_callable_direct_input.pine | 14 ++++++++ .../plain_callable_direct_literal.pine | 13 +++++++ .../tradingview_quantities.json | 34 ++++++++++++++++++- tests/test_float_reassignment.py | 16 ++++----- 6 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/for_counter_headers/callable_input_float_step.pine create mode 100644 tests/fixtures/for_counter_headers/plain_callable_bound_input.pine create mode 100644 tests/fixtures/for_counter_headers/plain_callable_direct_input.pine create mode 100644 tests/fixtures/for_counter_headers/plain_callable_direct_literal.pine 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/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/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index dd51995e..cd85a971 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -5,7 +5,7 @@ "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 and callable_bound_float_step fixtures copy the measured sources byte for byte. TradingView refused both 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 table has SHA-256 e6e85132a0d37351bfc7238c05628552050398ce1206fbbccac1a15583809693. Plain-local callable float-step shapes remain TradingView-unmeasured.", + "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", @@ -167,6 +167,38 @@ "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": { diff --git a/tests/test_float_reassignment.py b/tests/test_float_reassignment.py index 8ff77196..92af1898 100644 --- a/tests/test_float_reassignment.py +++ b/tests/test_float_reassignment.py @@ -79,6 +79,8 @@ def test_float_step_loop_from_a_widened_variable_is_refused_like_int(name): @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() @@ -150,14 +152,12 @@ def test_float_step_loop_from_a_measured_callable_is_refused_like_int(name): @pytest.mark.parametrize("name", tuple(CALLABLE_LOOP_START_REFUSALS)) def test_float_step_loop_from_a_widened_callable_is_refused(name): - """TradingView refuses a float-step loop that starts at a callable's result. - - Measured (CE10106): the var callable called directly with a literal and - bound to a name (the two fixture rows above), and the plain-local callable - called directly with a literal, directly with an input and bound to a - name. These inline rows pin the engine's refusal of each shape so a later - change is visible; the TradingView receipts for the plain-local shapes are - not stored as fixtures here. + """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])