Skip to content

Follow a request's timeframe on every bar, and keep persistent state per timeframe - #316

Merged
luisleo526 merged 21 commits into
mainfrom
per-bar-request-timeframe
Nov 2, 2026
Merged

luisleo526 merged 21 commits into
mainfrom
per-bar-request-timeframe

Conversation

@luisleo526

@luisleo526 luisleo526 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Follow a request's timeframe on every bar, and keep persistent state per timeframe

TradingView evaluates the timeframe argument of request.security again on every bar. PineForge registered one fixed timeframe per request, so a script that chose its timeframe per bar (if bar_index % 2 == 0 ... "60" else "240", a ternary, a joined string, a helper that returns a string) either was refused or ran a different series from TradingView's.

This pull request carries two groups of commits on main (21 commits). Merge it as one change: the second group accepts a form that the first group would otherwise refuse, so they must not land apart.

  1. Following a request's timeframe on every bar (15 commits, including its regression kit).
  2. Persistent state inside a per-bar requested expression (6 commits).

1. Follow a request's timeframe on every bar

A request whose timeframe is chosen on each bar by an authored expression is lowered by registering one stream per timeframe the expression can select, then selecting among the streams at the call with the authored value, on every bar. The registered set is found by value from the authored source:

  • the arms of an if/switch/ternary,
  • string pieces that are joined, built in a library function or computed by a pure helper (a bounded closed set: at most 128 distinct values and a bounded amount of evaluation work; beyond that the script is refused with a located reason, as before. 128 is the compile-time bound of this inventory; run-time registration of a timeframe known only at run time replaces it in a later change and makes the limit a parameter),
  • a request inside a loop, behind a local alias, or in a lower-timeframe array,
  • a condition that is a compile-time constant takes only its taken arm (the interpreter's arm set is pinned against the compiled C++).

A script whose timeframe depends on the chart bar in a way that cannot be closed is still refused, with the located reason it had. Earlier diagnostics and registrations are kept when the per-bar lowering takes over a request.

2. Persistent state in a per-bar requested expression, with one copy per timeframe

For a request whose timeframe is chosen on each bar, one stream is registered per timeframe the choice can select. Persistent state that the requested expression uses gets its own independent copy for each of those streams:

  • state declared inside the requested expression (a var or varip counter or accumulator in the expression or in a helper it calls): one independent helper series and one once-only seed per registered timeframe and helper call; varip follows the historical requested-bar protocol (a new requested bar carries the prior value, a repeated evaluation rolls back);
  • a persistent counter declared at the top level of the script and read by the requested expression: the same replay the fixed-timeframe request already uses, applied independently to each registered stream, including aliases and history reads.

A stateful requested expression is never evaluated on the chart path, because that would share its state between the timeframes. Three different causes used to produce one refusal text; they now have their own located reasons, written for the author: the state is declared outside the requested expression and this request cannot replay it; the requested expression has to be evaluated on the chart path, which would share its state between timeframes (use an expression that can run entirely on the requested bars); the state could not receive a separate copy for each timeframe. The conditions and the order of the checks are unchanged.

This fixes the codegen known issue #314 ("state in per-bar timeframe requests"): the float recurrence under a per-bar choice of "120" and "180" was wrong on main and on the v1.9.1 release (126 of 205 entries equal to TradingView) and is exact with this change (205 of 205).

Evidence

  • Native regression kit (tests/test_e2e_series_timeframe_values.py, fixtures and README under tests/fixtures/series_timeframe/): twelve sources joined by entry time to committed TradingView exports, exact comparison with no tolerance. On the final head (hourly BTCUSDT feed of 1,441 bars): 43 of 43 rows of the differential module, 220 of 220 rows of the kit module and 279 of 279 rows of the state modules pass, collected = passed, none skipped or deselected. (These native runs were made before the commits were re-based onto main; the re-base changed only two documentation files.)
  • The two state sources of the kit (the prior close and a three-bar moving average at the selected timeframe) are checked against the engine's own fixed-timeframe runs, entry by entry on the raw quantity: 477 of 477 equal for both. Counter sources and the hour sources: exact equality with the exports, except for the one entry listed under known differences.
  • Persistent state, values against TradingView (five exact sources, one per form, joined by entry time, 205 of 205 entries equal, none different): state inside the requested expression, the same with varip, a counter declared outside it, two calls of one helper, and a nested helper; the fixed-timeframe form of the outside counter is 205 of 205 as well.
  • Scope of the state value evidence: those five forms are integer counters. The float accumulator form (var or varip float seeded with na, grown through nz(...) + close) is checked as accepted emission (its generated code, one independent chain per timeframe, the na seed pinned by the emission assertions), plus two NEW float carriers (a var and a varip source with a quantity that encodes floor(value) % 5) that match one exported TradingView tape in 205 of 205 entries: they show the recurrence's pattern, not full-precision accumulator values, and cannot distinguish a zero seed from na.
  • Tests of the accepted shapes pin values, not only the protocol, with wrong-result controls made by changing a real generated body (drop a counter, return zero, add 2, reverse the operator) that the same assertion must reject. One row per refusal cause checks the code, phase, exact reason and authored line and column.
  • Cloud test run on this exact head against the engine on main (pytest, corpus compile, parity gate): 18,214 passed, 0 failed, 235 skipped; 312 corpus strategies compile; the parity gate passes over 268 fixtures (255 that must compile and 13 that must be rejected). The 13 rejected fixtures are the same 13 files as on main (the gate corpus is byte-identical), and each is rejected with the identical message, position included, on main and on this head (compared by transpiling the 13 files on both commits). The native kit rows skip in the cloud run for lack of the feed; they ran natively as stated above.
  • Parity sweep over the pinned probe population on this exact head paired with the engine on main: verdict PASS. 7,989 probes, all comparable; 1,009 on the hard gate, none regressed and none moved; 6,980 on the target gate, none moved up and none moved down. No probe in the measured population got better or worse: the change is parity-neutral on it.

Known differences, stated and pinned

Both are older than this change and are present on the previous release and on main; neither is caused by the lowering. The kit pins each exactly, so any other difference, or an improvement, fails the test.

  1. One missing entry. For the explicit-parity and if-parity sources the engine places no entry at 2024-01-01 18:00 UTC; the tape has it (204 of 205 entries). The quantity of that order is na (the requested daily value is na on the whole first day), so the order falls back to the strategy's default quantity (here 100% of equity). TradingView cuts that quantity to the symbol's quantity step before it checks that the order is affordable at the fill; the engine applies no quantity step in a plain run, so the uncut quantity costs slightly more at the fill than the equity and the order is declined. The same happens with a literal qty = na and no request at all. The pin is to be turned into plain equality when the engine applies the symbol's quantity step.

  2. Last digit of the position size. For the two state sources the engine's quantity is above TradingView's on 222 to 251 of 477 entries per report, by at most one unit of the fifth decimal and never below, which is TradingView cutting the quantity to the symbol's quantity step (the same missing step as in the first item). The six reports are pinned in both directions (equal and different counts and a fingerprint of the different entry times):

    report equal different
    selected history 226 251
    history, fixed 60 240 237
    history, fixed 240 227 250
    selected moving average 254 223
    moving average, fixed 60 255 222
    moving average, fixed 240 232 245

    Measured on an unpatched engine with the symbol's quantity step supplied as a parameter (0.00001 for BTCUSDT): the two history fixed-timeframe runs equal TradingView on 477 of 477 entries and the na-quantity source on 178 of 178 entries including the 27 orders TradingView declines, so the missing piece is that the step reaches a plain run; the moving-average runs move to 395 and 376 of 477 (their last-bit difference is a separate matter). A decrease fails the test too, with a message to verify the fix and empty the pin.

Conventions of the kit

  • Quantities are compared after quantizing the engine's value half-up to the precision of the tape column. This is a precision convention, not a slack: a difference of one unit in the last place is a difference.
  • The counter exports carry no time labels; their entry times are the authored schedule (every seventh bar from 2024-01-01 11:00 UTC), derived from the source, not from any engine output. explicit-parity is compared with the if-parity export because the explicit form is the same selection written out by hand; that equivalence is an assumption of the kit and is stated in its README. No warm-up entries are excluded.

Known gaps, stated

  • A requested timeframe that is NOT a multiple of the chart timeframe (for example "75" on an hourly chart) differs from TradingView whether it is fixed or chosen per bar, on the v1.9.1 release, on main and on this change alike (a state-free fixed "75": 74 of 205 entries equal; with state: 36 of 205). This is the engine known issue Known issue in PineForge Engine 1.9.1: non-multiple requested timeframes pineforge-engine#718 ("non-multiple requested timeframes"); it stays a KNOWN ISSUE after this change: values may differ from TradingView when a requested timeframe is not a multiple of the chart timeframe; a run-time warning for it is planned as a separate change. The cause is not established. For a state-free per-bar choice of "75" or "180" this change gives 135 of 205 entries equal against 142 on main: the "180" arm is 109 of 109 equal on every head; in the "75" arm 26 of 96 are equal against 33 on main (14 entries moved toward the tape, 21 away from it, 16 changed but still differ). For that case this change is a BEHAVIOUR CHANGE confined to the non-multiple-timeframe difference, not a fix.
  • The twelve joined kit sources use timeframes that are multiples of the chart timeframe. The "75" / "180" sources inside tests/test_security_series_timeframe.py check the generated code only; none runs the engine and none labels a value as TradingView's.
  • The 75-arm mismatch of a float accumulator under a per-bar choice is a finding with an undecided cause (non-multiple requested timeframe).
  • Not in this change: a helper whose timeframe is a parameter fed a per-bar choice (on main it compiles and runs but computes a different series from TradingView's; it is the next change); state types and conditional helper declarations that the replay cannot carry keep their refusals.

🤖 Generated with Claude Code

luisleo526 and others added 21 commits November 3, 2026 03:22
…hart bar

The labelled refusal of a scalar `if` that chooses a request timeframe fired for every condition the setup proof could not replay, which also stopped scripts that compile and run correctly: a user function that reads no series, a chart.* member, a function over inputs or symbol information. The message said the condition reads a chart series value, which is false for those.

Classify the authored condition instead: it is refused when its value can change from bar to bar (a series read, a history subscript, a ta call, a per-bar built-in, a user function whose body or arguments do, a global reassigned per bar, a recursive call). Conditions fixed for the whole run keep the lowering they had before the refusal. The setup proof and the refusal's message, code and span are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…inst the built-in inventories

A table-driven refusal test derives its rows from the code generator's own lists of per-bar built-ins, strategy and built-in variables and non-pure namespaces, so an omission shows as a red row. Document the classifier's cost bound and that unknown identifiers count as fixed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rame condition

A for-loop iterator, or a collection element or index bound by for-in, was not provably fixed for the run (a loop bound can read a series) yet fell through the classifier's unknown-identifier default as fixed. Record each identifier's declaring node in the analyzer and treat an identifier declared by a loop as per-bar. Split the inventory-derived test table so that names the classifier proves fixed for the run (chart type predicates, strategy commission constants) are accepted rows and every per-bar name stays a refusal row, with a guard that no bar built-in can land in the accepted table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A request.security call whose timeframe is chosen by a ternary, if or switch that reads a chart series value was registered once, before the first bar, with one arm fixed for the whole run. TradingView evaluates the choice on every bar and returns the value of the request that bar selects.

When the arms form a closed set (string literals, constants, inputs, timeframe members), register one site per distinct arm with the same symbol, expression and flags, and select the current result of the matching site on each bar with the original condition. Each site keeps its own requested-expression state and history. Requests whose timeframe does not depend on a series are untouched and emit the same code as before.

Open sets (a computed string, an unresolved name), requested expressions with persistent var/varip state, and requests of another symbol stay located refusals under the new code PF-E3111.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The per-bar lowering decided whether a timeframe condition varies with the older setup proof while the refusal of a scalar if used the dependence classifier. Use the classifier for both and restore the setup proof to its previous text. Selections the lowering handles are lowered; open sets keep their located refusal; tests that described the refusal now describe the lowering.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…and a lower-timeframe array

A request.security timeframe chosen by the chart bar was lowered only at the top level of a script and only for scalar requests. A request inside a loop, a timeframe held in a block-local or function-local binding, and request.security_lower_tf with a chart-chosen timeframe still ended in a refusal, a C++ compile error or one fixed timeframe for the whole run, while TradingView computes the timeframe on every bar.

Follow a local alias through its exact declaration, register one request site per distinct closed arm, and select the site at the call by comparing the authored timeframe value with each site's timeframe, as the top-level lowering already does. The array form returns the selected site's array. Six refusal rows of the inventory test become value assertions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…mputed strings lower like literals

A request.security timeframe chosen by the chart bar was lowered only when every arm was written as a literal, an input or the chart period. An arm built from joined string pieces, from str.tostring of a number the bar chooses, or from a pure helper was refused, or went unseen and kept one fixed timeframe for the whole run, while TradingView computes the same trades as for the literal form.

Evaluate the timeframe expression with an interpreter of pure scalar operations that treats every chart-dependent condition as a free boolean, so a selection contributes the union of its arms. The resulting constants become the registered sites and the call selects among them by the authored value, as before. A value the interpreter cannot prove (an unbounded string, unproved number formatting, persistent state) keeps the existing refusal. The inventory size and work are bounded by named parameters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… lowering takes over a request

The per-bar timeframe lowering changed what a few already accepted or already refused requests produced. A map call with input-only arguments was treated as a new computed set and refused although it translated before; an unsafe string arm of a setup block lost its precise refusal to a generic one; a block-local alias of one sibling block read the initializer of the next; and a lower-timeframe request reported the scalar diagnostic for an unsafe string instead of its own.

Classify computed timeframe leaves without the implicit-call rule that only conditions need, keep the setup block's own refusal until the new proof has actually succeeded, follow a local alias through its exact declaration even when its storage is global, and mark the request kind before the arms are validated so a lower-timeframe request reports its own diagnostic. The inventory and persistent-leaf tests now state their literal expectations: variable and call inventories are counted separately, and a chart-chosen persistent timeframe is selected on each bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the interpreter's arm set against the compiled C++

The closed-set interpreter of a per-bar timeframe kept both arms of a condition that is constant at compile time. An arm that is never taken therefore still counted: a one-minute dead arm on an hourly chart stopped the script at setup, and a zero dead arm was refused, where TradingView never reaches the arm.

Prune a ternary or boolean block selection only when literals, operators and primitive casts prove its condition; chart reads, inputs, history, names and calls keep both arms as before. Add native rows that compile and run scalar operators, casts, formatting, defaults and let chains and require the timeframe the compiled C++ selects to lie in the interpreter's set, with mutation rows that fail when a fold rule is changed; rows that pin the behavior for an unreached invalid arm and for closed sets of 65 and 128 values; a row for a pure builtin the interpreter does not evaluate; and the two remaining refusal reasons with their closing work named. Remove the unreachable other-symbol refusal reason and cite the engine's number rendering in the interpreter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… to their committed TradingView exports by entry time

The per-bar timeframe fixtures were checked only structurally and by comparing exports with each other, so nothing in the repository showed that the generated strategies compute the exported values.

Add a native module that translates, builds and runs each committed fixture source on the one-hour BTCUSDT feed and joins the engine's entries to its committed tape by entry time at the tape's own precision, with a row that proves the join reports a changed quantity. The native module is skipped with a named reason when the paired engine inputs or the feed are absent. Rename the tape-against-tape tests as fixture self-consistency checks and describe in the fixture README what each kind of test establishes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eframe

The rows that pin the pruning of a proven-constant condition ran only healthy code, and the existing mutants changed the arithmetic fold rules, so a pruning that was disabled, applied to the wrong arm or applied to a condition that is not constant would not have failed any row.

Patch the pruning decision inside the tests, without touching production code, in each of those three ways, and require the registration rows and the native rows that pass on the healthy interpreter to fail under the mutated one: the dead arm of a constant condition is registered again, the taken arm is lost, or an arm that a chart read, an input, a bound name or a helper keeps alive is dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…own differences

The native entry-quantity kit compared the engine with the committed
TradingView tapes and was red on four of its twelve join rows. Both causes
are pre-existing engine differences, identical on main, not caused by the
per-bar timeframe lowering. The kit now says so without any tolerance.

- Counter sources (explicit-parity, if-parity): the one missing second fill
  (2024-01-01 18:00 UTC) is pinned in KNOWN_TAPE_ONLY. The row fails when the
  entry stops being missing (the message asks to empty the table) or when any
  other entry is missing, extra or different. The pin must flip to plain
  equality when the engine difference is fixed.
- State sources (history-parity, sma-parity): the lowered source is compared,
  on raw engine quantities and without rounding, with the engine's own
  fixed-arm runs, entry by entry. New fixtures history-fixed-60/240 and
  sma-fixed-60/240 differ from their parity source only in the literal
  timeframe and the title suffix. The arm of each entry comes from the tape's
  authored schedule, checked against the rule in the source. The 5-decimal
  join against the tape stays as a disclosed report (the engine's quantity
  differs from TradingView's in the last digit); it must become an exact
  assertion when that difference is fixed.
- Engine-free rows check the tape against its own fixed arms, the schedule
  rule, and the comparators (one quantum, missing or extra entries, wrong
  arm, changed entry sets, the exact known cell, fixture/source equality).

Not run here: the native rows need the engine and the hourly feed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… directions

The history and sma native joins report the engine's quantity against
TradingView's tape in the last digit, a disclosed pre-existing difference.
A report that cannot fail would hide a regression or a silent improvement.

- Six reports (the selected source and the fixed-60 and fixed-240 controls of
  each state source) are pinned by equal count, different count and a
  fingerprint: sha256 of the sorted different entry times, as decimal
  milliseconds, one per line. The values come from a native run on the
  previous head and are recomputed from its join logs with this definition.
- The check fails on any change, including a lower different count (the
  message then asks to verify the fix and empty the pin), a higher count, a
  moved set with the same count, a changed equal count, and a report missing
  from the table. There is no tolerance and no "at most".
- Engine-free rows exercise the check on a synthetic table built from tape
  cells; the fingerprint is order-independent and the empty list hashes to the
  empty string.
- The pin of the missing second fill now states its measured cause.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…CSV reader

Two engine-free sensitivities of the native kit were weak.

- The precision controls mutated every tape by the same quantum the
  comparison computes, so a wrong column precision (for example 1 for an
  all-integer counter export) could pass. The expected precision is now a
  literal per export (0.00001 for the counter and state exports, 0.01 for the
  hour export), and a literal last-digit change on an integer-valued cell and
  on a fractional cell must be reported different and rejected.
- The raw-quantity controls started from tape dictionaries and never went
  through the function that reads the engine's CSV. Synthetic runner-format
  blobs now check that a nine-decimal quantity is kept exactly, that a change
  of 1e-9 is visible to the exact selected-versus-fixed comparison, that Entry
  long and short rows are read and Exit rows ignored, that the label is read as
  UTC (calendar literals), that a byte-order mark is accepted, and that a
  duplicate entry label is refused.

No native row, table or fixture changed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The comment above the pinned missing entry named a cause that a complete
decoding of the same behaviour refuted. It now states the decoded one: the
engine applies no quantity step in a plain run, so a quantity that is na and
sized at the full equity costs slightly more than the equity at the fill and
the order is declined, while TradingView cuts the quantity to the symbol's
quantity step and then checks that the order is affordable at the fill.

Comment lines only: no assertion, pin, row or code changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…istent state at each registered timeframe

A request.security timeframe chosen by the chart bar was refused whenever the requested expression reached a var or varip declaration, although each registered timeframe already runs its own evaluator and TradingView keeps one independent counter per timeframe. The same script built without the refusal used one fixed timeframe for the whole run.

Tell apart persistent declarations that live inside the requested expression (a counting function it calls) from bindings declared outside it, using the exact declaration of each identifier. The first now expand into one site per timeframe, each with its own counter and seed cell, so a counter advances once per requested bar of its own timeframe. A persistent binding declared outside the expression, and any stateful evaluator that would keep a shared chart helper call, keep the existing refusal. Helper-local varip uses the same once-per-bar carry and rollback protocol as var.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tside it, with one counter chain per registered timeframe

A request.security timeframe chosen by the chart bar was refused whenever the requested expression read a var or varip counter declared at the top level of the script, although the same request with a fixed timeframe already replays that counter's declaration and writes inside its requested evaluator, and TradingView computes the per-bar form with the same value.

Let the expanded sites use the fixed request's replay plan: every registered timeframe gets its own copy of the counter chain, seeded once and advanced once per requested bar of its own timeframe, while the chart keeps its own counter. The counter's declaration must be present in that replay plan by exact declaration identity; a same-named binding in another scope cannot satisfy it. Whatever the fixed request cannot replay (writes in a top-level loop or switch, a payload that needs a chart helper call, persistent locals of the requesting helper) keeps the existing refusal.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… in the persistent-state timeframe tests

The tests that check a requested timeframe's persistent state stays out of the chart method took everything after the chart method's opening line as its body. That text runs on through the requested evaluators, so the isolation assertions found each site's own initialization inside what they called the chart and failed although the emitted C++ keeps them apart.

Cut the chart body at the end of the chart method, and keep every isolation assertion: the chart must not touch a requested copy, and the requested copies of different sites must be disjoint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…in the accepted shapes by their values

A series-chosen requested timeframe whose expression carries persistent state
was refused with one reason for three different causes. Each cause now has its
own located reason, written for the author:

- state declared outside the requested expression that this request cannot
  replay,
- an expression that has to be evaluated on the chart path, which would share
  its state between the timeframes,
- state that could not receive a separate copy for each timeframe.

The reasons are in the PF-E3111 vocabulary, in the catalog generator's source
data and in the generated catalog, and a row per cause checks the code, the
phase, the exact reason and the authored line and column. The refusal itself and
the order of the checks are unchanged: a chart evaluation is still reported
first when both conditions hold.

The tests of the accepted shapes now pin the values, not only the protocol:
the authored increment, the returned selected value and the sum of two counters
on each arm, the authored comparison operator, the na initializer and the
requested result, with wrong-result controls made by changing a real generated
body (drop a counter, return zero, add 2, reverse the operator) that the same
assertion must reject. The float accumulator sources (var and varip, ternary,
if and switch) that the earlier tests exercised are accepted-emission controls
again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ol reason

- The refusal for an expression that has to be evaluated on the chart path told
  the author to "use a payload", a term of the compiler. It now says to use an
  expression that can run entirely on the requested bars.
- The refusal for a request on another symbol used a reason outside the closed
  vocabulary of the located timeframe diagnostic, so it could not be rendered
  from the catalog if it were ever reached. The reason is now part of the
  vocabulary, in the generator's source data, the generated catalog and the test
  that pins the list.

No condition, check order or other behaviour changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The README of the native kit still said that persistent requested state keeps a
located refusal and that var/varip state was not measured. This change lowers
that state, so the sentence became false. It now says what is true: the state
inside the requested expression and a counter declared outside it are lowered
with one independent chain per registered timeframe, their values were measured
against TradingView on five separate sources rather than by this kit, the
emitted code is checked by the security timeframe tests, and state the replay
cannot carry keeps a located refusal with its own reason.

Documentation only: no code, test or fixture file changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 22 changed function(s) (2 sampled, not proven, 20 not verified).

Formal verification. PR-changed functions: 2/22 verified (0 proven, 2 may-equivalent, 0 distinguished) · 20 not verified (1 vacuous, 19 unsupported).

Not verified on this run: \_\_init\_\_ (unsupported), \_visit\_ForInStmt (unsupported), \_visit\_ForStmt (unsupported), \_visit\_Identifier (unsupported), \_visit\_VarDecl (unsupported), analyze (unsupported), \_\_init\_\_ (unsupported), \_emit\_security\_evaluator\_requested (unsupported), \_emit\_security\_linear\_helper\_call\_scoped (unsupported), \_emit\_security\_rebind\_var\_decl (unsupported), and 10 more.


Graphify review — findings

Adds closed-set analysis for per-bar request.security timeframes: selection arms are unioned by value through pure scalar operators, exact integral string conversions, plain indexed str.format and pure v6 helpers, and each resulting timeframe gets its own registered stream while the authored expression still runs at the call. Expanded requests give the requested expression's scalar var state independent helper series and once seeds per timeframe and helper call, admit helper-local varip on the historical requested-bar protocol, and replay outside mutable globals per stream; unknown values, unproved formatting, persistent timeframe storage and writes still raise PF-E3111, and fixed requests keep their varip refusal. The analyzer now records the exact declaration node behind every identifier read, because loop binders and ordinary locals can share a name and scope label, and the new local-binding selection path can force setup-fixed local selections onto the series path where they may be refused.

Worth a look

  • Locally bound timeframe selections with setup-fixed conditions are now forced onto the series path and can be refused — pineforge_codegen/codegen/security.py:284 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 5133 functions depend on the 1719 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: Analyzer — 85 callers, 14 callees
  • worse: CodeGen — 53 callers, 12 callees
  • worse: ScriptIndex — 20 callers, 22 callees
  • worse: _generate() — 7 callers, 23 callees
  • worse: walk() — 25 callers, 5 callees
  • worse: _Taint — 3 callers, 20 callees
  • worse: emit_stmt() — 2 callers, 19 callees
  • worse: _Fold — 4 callers, 8 callees
  • …and 15 more — each is listed as a finding

Verification — 5133 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 4348 function(s) in the blast radius were not formally verified this run

Cross-repo impact

Cross-repo impact

No cross-repo impact detected among the indexed peer repo(s).

1 peer repo(s) in this group are not yet indexed — cross-repo impact may be incomplete.

Cross-repo linkage is derived (SCIP-moniker matches are sound; name/contract matches are heuristic) and ADVISORY — it never affects this check's conclusion.

Docs that may be stale (advisory)

…and 10 more.

Formal verification

Could not verify: Could not verify \_\_init\_\_.

The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_visit\_ForInStmt.

The verifier did not have enough to check \_visit\_ForInStmt, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_visit\_ForStmt.

The verifier did not have enough to check \_visit\_ForStmt, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_visit\_Identifier.

The verifier did not have enough to check \_visit\_Identifier, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_visit\_VarDecl.

The verifier did not have enough to check \_visit\_VarDecl, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify analyze.

The verifier did not have enough to check analyze, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_\_init\_\_.

The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_emit\_security\_evaluator\_requested.

The verifier did not have enough to check \_emit\_security\_evaluator\_requested, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_emit\_security\_linear\_helper\_call\_scoped.

The verifier did not have enough to check \_emit\_security\_linear\_helper\_call\_scoped, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_emit\_security\_rebind\_var\_decl.

The verifier did not have enough to check \_emit\_security\_rebind\_var\_decl, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

No difference found (not proven): No behavior difference found in \_emit\_security\_selection\_stmt (not a proof).

The verifier ran both versions of \_emit\_security\_selection\_stmt on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: the synth_receiver tier. A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_resolve\_security\_tf.

The verifier did not have enough to check \_resolve\_security\_tf, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_security\_helper\_call\_plan.

The verifier did not have enough to check \_security\_helper\_call\_plan, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_security\_needs\_string\_series.

The verifier did not have enough to check \_security\_needs\_string\_series, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_security\_root\_chart\_read.

The verifier did not have enough to check \_security\_root\_chart\_read, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_security\_selection\_arms.

The verifier did not have enough to check \_security\_selection\_arms, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_security\_tf\_runtime\_expr.

The verifier did not have enough to check \_security\_tf\_runtime\_expr, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

No difference found (not proven): No behavior difference found in \_validate\_security\_persistent\_var\_control\_flow (not a proof).

The verifier ran both versions of \_validate\_security\_persistent\_var\_control\_flow on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: the synth_receiver tier. A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_visit\_func\_call.

The verifier did not have enough to check \_visit\_func\_call, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_is\_pure.

The verifier did not have enough to check \_is\_pure, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)

Could not verify: Could not verify \_specialize.

The verifier did not have enough to check \_specialize, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `program` is annotated `Program` — outside the synthesizable primitive/collection set

Could not verify: Could not verify main.

The verifier did not have enough to check main, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 71 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

· 14 grounded finding(s) anchored inline below; 9 more finding(s) on lines outside this diff (see the check run).

else:
walk(self._security_selection_arm_expr(body, owner))

def walk(value):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — walk()

fans out to 7 callees (efferent coupling).

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

return list(csv.DictReader(stream, delimiter="\t"))


def _tape_entries(case: Case) -> dict[int, Decimal]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — _tape_entries()

9 callers depend on it (afferent coupling).

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

return {stamp: amount.quantize(quantum) for stamp, amount in entries.items()}


def _state_rows(source: str) -> dict[int, dict[str, str]]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — _state_rows()

high coupling complexity (Ca·Ce = 12).

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

)


def _assert_selected_matches_fixed(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — _assert_selected_matches_fixed()

6 callers depend on it (afferent coupling).

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



@pytest.mark.parametrize("case", CASES, ids=lambda case: case.fixture)
def test_native_entry_quantities_match_committed_tape_rules(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — test_native_entry_quantities_match_committed_tape_rules()

fans out to 8 callees (efferent coupling).

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

("payload", "saved_v", "75", "180"),
("advance_count", "tally_v", "45", "2D"),
))
def test_persistent_requested_expression_has_independent_bar_counters(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — test_persistent_requested_expression_has_independent_bar_counters()

fans out to 7 callees (efferent coupling).

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

'if close > open\n "75"\nelse\n "180"\n',
'switch close > open\n true => "75"\n => "180"\n',
))
def test_original_float_accumulator_has_independent_requested_state(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — test_original_float_accumulator_has_independent_requested_state()

fans out to 7 callees (efferent coupling).

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

'switch close > open\n true => "75"\n => "180"\n',
))
@pytest.mark.parametrize("expression", ("saved_v", "payload()", "saved_v[1]"))
def test_global_persistent_requested_expression_uses_each_sites_replay(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — test_global_persistent_requested_expression_uses_each_sites_replay()

fans out to 6 callees (efferent coupling).

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



@pytest.mark.parametrize("persistent", ("var", "varip"))
def test_two_requests_get_four_global_counter_chains_and_keep_chart_storage(persistent):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — test_two_requests_get_four_global_counter_chains_and_keep_chart_storage()

fans out to 6 callees (efferent coupling).

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

assert not re.search(r"\b(?:count_v|tally_v)\b", body)


def test_outside_counter_value_probe_has_two_replayed_chains():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — test_outside_counter_value_probe_has_two_replayed_chains()

fans out to 6 callees (efferent coupling).

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

@luisleo526
luisleo526 merged commit 5bfa78b into main Nov 2, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant