@pineforge/codegen-pyodide 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,8 @@ results from AnalyzerContext instead of walking the AST to collect info.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from dataclasses import dataclass
10
+
9
11
  from ..ast_nodes import (
10
12
  ASTNode, Program, StrategyDecl, VarDecl, Assignment, IfStmt, ForStmt, ForInStmt,
11
13
  WhileStmt, SwitchStmt, BreakStmt, ContinueStmt, FuncDef, ExprStmt,
@@ -23,10 +25,19 @@ from ..analyzer import (
23
25
  TA_NO_CTOR,
24
26
  TA_PERIOD_ARG,
25
27
  )
26
- from ..symbols import PineType, TypeSpec
28
+ from ..symbols import PineType, TypeSpec, method_receiver_type_name
27
29
  from .. import signatures as sigs
28
30
  from ..errors import CompileError, Diagnostic, Level, Phase, SourceLocation
29
31
 
32
+
33
+ @dataclass(frozen=True)
34
+ class _StableVarCtorLiteral:
35
+ """Exact declaration identity admitted by the TA-length literal route."""
36
+
37
+ value: int
38
+ declaration_id: int
39
+ top_level_index: int
40
+
30
41
  # ---------------------------------------------------------------------------
31
42
  # Mapping tables — definitions live in ``tables.py``; re-imported here so
32
43
  # inline references inside this module (BAR_FIELDS[name], MATH_FUNC_MAP[fn],
@@ -63,6 +74,7 @@ from .tables import (
63
74
  MATRIX_SORT_ALLOWED_GENERIC_ELEMS,
64
75
  MATH_FUNC_MAP,
65
76
  STR_FUNC_MAP,
77
+ _MATH_SIGN_CPP_FN,
66
78
  _merge_kwargs,
67
79
  )
68
80
 
@@ -95,7 +107,7 @@ from .helpers import CPP_RESERVED, NamingHelper
95
107
  from .types import TypeInferer
96
108
 
97
109
  # TaSiteHelper owns site lookup, .compute() arg construction, and the TA
98
- # hoisting machinery. The runtime-reset chain (_resolve_known and friends)
110
+ # call-site machinery. The runtime-reset chain (_resolve_known and friends)
99
111
  # stays on CodeGen for now because it relies on Python's compile-time
100
112
  # expression evaluator.
101
113
  from .ta import TaSiteHelper
@@ -154,7 +166,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
154
166
  per-function emitters used by Pine functions and UDT methods
155
167
  * ``SecurityEmitter`` -- ``request.security()`` lowering pipeline
156
168
  (evaluators, dispatch, rebind, TA variants)
157
- * ``TaSiteHelper`` -- TA call-site lookup + .compute() arg construction + TA hoisting
169
+ * ``TaSiteHelper`` -- TA call-site lookup + .compute() arg construction
158
170
  * ``TypeInferer`` -- _type_spec_*, _infer_type, _array/_map_method_expr
159
171
  * ``InputHelper`` -- Pine ``input.*`` defaults / titles / getter dispatch
160
172
  * ``NamingHelper`` -- _safe_name / _resolve_callee / _walk_ast / ...
@@ -172,6 +184,104 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
172
184
 
173
185
  def __init__(self, ctx: AnalyzerContext) -> None:
174
186
  self.ctx = ctx
187
+ # Lexical Pine names remain in ``ctx.func_var_members``. This overlay
188
+ # carries exact class-member identities only for collision-qualified
189
+ # ordinary FuncDefs (identity mappings for every other ordinary UDF).
190
+ self._func_var_storage_names: dict[str, dict[str, str]] = {
191
+ owner: dict(names)
192
+ for owner, names in getattr(
193
+ ctx, "func_var_storage_names", {}
194
+ ).items()
195
+ }
196
+ self._qualified_func_var_raw_names: set[str] = {
197
+ raw_name
198
+ for storage_names in self._func_var_storage_names.values()
199
+ for raw_name, storage_name in storage_names.items()
200
+ if storage_name != raw_name
201
+ }
202
+ self._ordinary_global_binding_names: set[str] = set(
203
+ getattr(ctx, "ordinary_global_binding_names", set())
204
+ )
205
+ self._ordinary_global_series_names: set[str] = set(
206
+ getattr(ctx, "ordinary_global_series_names", set())
207
+ )
208
+ self._nonpersistent_series_decl_names: set[str] = set(
209
+ getattr(ctx, "nonpersistent_series_decl_names", set())
210
+ )
211
+ self._func_nonpersistent_series_vars: dict[str, set[str]] = {
212
+ owner: set(names)
213
+ for owner, names in getattr(
214
+ ctx, "func_nonpersistent_series_vars", {}
215
+ ).items()
216
+ }
217
+ # Exact declarations that are direct children of Program.body. A
218
+ # same-named declaration inside a top-level ``if``/loop is still a
219
+ # lexical block local; raw membership in ``_global_member_vars`` must
220
+ # not route that nested declaration into the hoisted class member.
221
+ self._ordinary_global_var_decl_nodes: set[int] = {
222
+ id(stmt)
223
+ for stmt in ctx.ast.body
224
+ if isinstance(stmt, VarDecl)
225
+ and not stmt.is_var
226
+ and not stmt.is_varip
227
+ }
228
+ self._direct_program_var_decl_nodes: set[int] = {
229
+ id(stmt)
230
+ for stmt in ctx.ast.body
231
+ if isinstance(stmt, VarDecl)
232
+ }
233
+ self._direct_program_tuple_decl_nodes: set[int] = {
234
+ id(stmt)
235
+ for stmt in ctx.ast.body
236
+ if isinstance(stmt, TupleAssign)
237
+ }
238
+ self._direct_program_tuple_binding_names: set[str] = {
239
+ name
240
+ for stmt in ctx.ast.body
241
+ if isinstance(stmt, TupleAssign)
242
+ for name in stmt.names
243
+ if name != "_"
244
+ }
245
+ self._direct_program_binding_names: set[str] = {
246
+ stmt.name
247
+ for stmt in ctx.ast.body
248
+ if isinstance(stmt, VarDecl)
249
+ }
250
+ self._direct_program_binding_names.update(
251
+ name
252
+ for stmt in ctx.ast.body
253
+ if isinstance(stmt, TupleAssign)
254
+ for name in stmt.names
255
+ if name != "_"
256
+ )
257
+ self._callable_state_raw_names: set[str] = {
258
+ name
259
+ for members in ctx.func_var_members.values()
260
+ for name, _ptype, _init in members
261
+ }
262
+ self._callable_state_raw_names.update(
263
+ name
264
+ for names in ctx.func_series_vars.values()
265
+ for name in names
266
+ )
267
+
268
+ def func_var_storage(owner: str, raw_name: str) -> str:
269
+ return self._func_var_storage_names.get(owner, {}).get(
270
+ raw_name, raw_name
271
+ )
272
+
273
+ self._func_var_storage_name = func_var_storage
274
+ # Security metadata collection can render runtime timeframe
275
+ # expressions before the rest of the constructor state is prepared.
276
+ # Exact persistent-member identity is immutable analyzer output, so
277
+ # initialize it first for every expression visitor.
278
+ self._persistent_var_member_names: set[str] = {
279
+ self._safe_name(name) for name, _, _ in ctx.var_members
280
+ }
281
+ self._series_var_member_names: set[str] = {
282
+ self._safe_name(name)
283
+ for name in getattr(ctx, "series_var_members", set())
284
+ }
175
285
  # Build lookup: node id -> TACallSite (only for non-function-local sites)
176
286
  self._ta_site_map: dict[int, TACallSite] = {}
177
287
  # Build per-call-site TA member name remapping for user functions
@@ -179,8 +289,6 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
179
289
  self._func_cs_ta_remap: dict[tuple[str, int], dict[str, str]] = {}
180
290
  # Active TA name remap (set during per-call-site function emission)
181
291
  self._active_ta_remap: dict[str, str] = {}
182
- # Flag: inside a per-call-site function variant (enables TA hoisting)
183
- self._in_ta_func_variant: bool = False
184
292
  # Active call-site index (set during per-call-site function emission)
185
293
  self._active_call_site_idx: int | None = None
186
294
  # Set of TA member names that belong to user functions
@@ -200,32 +308,16 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
200
308
  self._reset_input_getter_mode: bool = False
201
309
  # Set of var/series member names that belong to user functions (need cloning)
202
310
  self._func_var_members_set: set[str] = set()
203
- # BUG C: function-local names emitted as ``UDT*`` pointer aliases (a UDT
204
- # local initialised from a var/global UDT lvalue, mutated through, AND
205
- # later rebound to a different lvalue). Member access lowers to ``->``
206
- # and rebinds to ``&(...)``. Reset per function in _emit_func_def is not
207
- # needed: names are function-unique and the value-copy fallback ignores
208
- # entries for inactive functions.
209
- self._udt_ptr_alias_locals: set[str] = set()
210
- # Names of hoisted GLOBAL-scope UDT loop-locals bound from a UDT array
211
- # element (``z = arr.get(i)``) and later field-mutated. Pine array
212
- # elements of a user-defined type are references, so such a local must
213
- # ALIAS the element, not value-copy — the mutation has to write back
214
- # into the array. These are de-hoisted from the class-member value-copy
215
- # to a fresh per-iteration ``UDT& z = arr[i];`` local reference (the same
216
- # form the non-hoisted function-local alias path already emits). Read-only
217
- # get-locals are NOT recorded (no field mutation) and keep value-copy
218
- # semantics. Populated by _register_udt_array_get_ref_locals.
219
- self._udt_array_get_ref_locals: set[str] = set()
220
311
  self._precalc_loop_active: bool = False
221
- # Names of ``var`` members that live in a FUNCTION scope (not global).
222
- # These are initialized once-per-function-variant on first call (a
223
- # function-local static equivalent), NOT in the constructor / on_bar
224
- # preamble. See ``_emit_func_var_init_block``.
312
+ # Names of ``var`` members that live in a callable scope (not global).
313
+ # Their exact declaration statements own initialization; they must not
314
+ # be initialized by the constructor or the global on_bar preamble.
225
315
  self._func_local_var_names: set[str] = set()
226
- for _vlist in ctx.func_var_members.values():
316
+ for _owner, _vlist in ctx.func_var_members.items():
227
317
  for _n, _, _ in _vlist:
228
- self._func_local_var_names.add(_n)
318
+ self._func_local_var_names.add(
319
+ self._func_var_storage_name(_owner, _n)
320
+ )
229
321
 
230
322
  # Build per-function var/series name lists for cloning.
231
323
  # For each function with call-site variants, collect ALL function-scoped
@@ -241,17 +333,38 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
241
333
  # are themselves sets (analyzer stores ``dict[str, set]``), so we must
242
334
  # iterate each value in ``sorted`` order to be hash-seed independent.
243
335
  all_func_scoped_series: list[str] = []
244
- for svars in ctx.func_series_vars.values():
336
+ for owner, svars in ctx.func_series_vars.items():
245
337
  for sv in sorted(svars):
246
- if sv not in all_func_scoped_series:
247
- all_func_scoped_series.append(sv)
338
+ exact = self._func_var_storage_name(owner, sv)
339
+ # ``func_series_vars`` also carries history-referenced
340
+ # parameters/plain locals. Translate only a real persistent
341
+ # member whose analyzer-exact identity is Series storage.
342
+ if (self._safe_name(exact)
343
+ in self._series_var_member_names):
344
+ storage = exact
345
+ elif (sv in self._qualified_func_var_raw_names
346
+ and sv not in self._func_nonpersistent_series_vars.get(
347
+ owner, set()
348
+ )):
349
+ # A history parameter/plain local in an unrelated UDF may
350
+ # reuse the Pine spelling of owner-qualified persistent
351
+ # state. It has no class-member identity to propagate into
352
+ # every other function's clone map; doing so remaps an
353
+ # initializer's earlier global read to a bogus ``x_csN``
354
+ # Series member before the persistent local is active.
355
+ continue
356
+ else:
357
+ storage = sv
358
+ if storage not in all_func_scoped_series:
359
+ all_func_scoped_series.append(storage)
248
360
  # Also include function-scoped var_members (same ordered-list rationale).
249
361
  # ``ctx.func_var_members`` values are lists (already insertion-ordered).
250
362
  all_func_scoped_vars: list[str] = []
251
- for vlist in ctx.func_var_members.values():
363
+ for owner, vlist in ctx.func_var_members.items():
252
364
  for n, _, _ in vlist:
253
- if n not in all_func_scoped_vars:
254
- all_func_scoped_vars.append(n)
365
+ exact = self._func_var_storage_name(owner, n)
366
+ if exact not in all_func_scoped_vars:
367
+ all_func_scoped_vars.append(exact)
255
368
 
256
369
  # For each function with call-site cloning (has TA ranges or is called multiple times),
257
370
  # include ALL function-scoped series/var vars that could be used in its body.
@@ -265,13 +378,21 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
265
378
  # Include function's own vars
266
379
  if fname in ctx.func_var_members:
267
380
  for n, _, _ in ctx.func_var_members[fname]:
268
- if n not in orig_names:
269
- orig_names.append(n)
381
+ exact = self._func_var_storage_name(fname, n)
382
+ if exact not in orig_names:
383
+ orig_names.append(exact)
270
384
  # Include function's own series vars (set -> sorted for determinism)
271
385
  if fname in ctx.func_series_vars:
272
386
  for sv in sorted(ctx.func_series_vars[fname]):
273
- if sv not in orig_names:
274
- orig_names.append(sv)
387
+ exact = self._func_var_storage_name(fname, sv)
388
+ storage = (
389
+ exact
390
+ if self._safe_name(exact)
391
+ in self._series_var_member_names
392
+ else sv
393
+ )
394
+ if storage not in orig_names:
395
+ orig_names.append(storage)
275
396
  # Include series vars from sub-functions (they share the same class members)
276
397
  for sv in all_func_scoped_series:
277
398
  if sv not in orig_names:
@@ -300,7 +421,10 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
300
421
  # Build TA site map and per-call-site remapping
301
422
  func_ta_originals: dict[str, list[str]] = {} # func_name -> list of original member names
302
423
  for fname, (start, end) in ctx.func_ta_ranges.items():
303
- orig_names = [ctx.ta_call_sites[i].member_name for i in range(start, end)]
424
+ indices = (ctx.func_ta_indices or {}).get(fname)
425
+ if indices is None:
426
+ indices = range(start, end)
427
+ orig_names = [ctx.ta_call_sites[i].member_name for i in indices]
304
428
  func_ta_originals[fname] = orig_names
305
429
  self._func_ta_members.update(orig_names)
306
430
  # cs0 uses originals (identity mapping)
@@ -354,7 +478,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
354
478
  self._current_instance_name: str | None = None
355
479
  self._instance_dispatch: dict[tuple[str | None, int], str] = {}
356
480
  self._fresh_instances: list[dict] = []
357
- self._fresh_var_members: list[tuple[str, str]] = []
481
+ self._fresh_var_members: list[tuple[str, str, str]] = []
358
482
  # Fresh fixnan members for context-sensitive helper instances (nested
359
483
  # helpers reached through >1 distinct call path). Each fresh instance
360
484
  # gets its OWN previous-value member so two paths never share fixnan
@@ -381,6 +505,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
381
505
  self._security_inline_counter = 0
382
506
  self._random_call_counter = 0
383
507
  self._for_counter = 0
508
+ self._tuple_assign_counter = 0
384
509
  # Synthetic history buffers used by inline call-history and by scalar
385
510
  # expressions passed to UDF series parameters. They are pre-registered
386
511
  # at generate() time so declarations precede method emission, then
@@ -408,6 +533,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
408
533
  self._current_input_var_name: str | None = None
409
534
  # Build known_vars for constant propagation
410
535
  self._known_vars: dict[str, int | float | bool | str] = {}
536
+ # Source-ordered lexical declarations can shadow a same-named direct
537
+ # compile-time global. Constant folding is raw-name keyed, so every
538
+ # fold path must consult this COW tombstone set before substituting a
539
+ # global literal inside the nested lexical scope.
540
+ self._lexical_known_var_tombstones: set[str] = set()
411
541
  # Subset of _known_vars whose value came from an input.*() call. These
412
542
  # MUST NOT be inlined at identifier use sites because strategy_set_input()
413
543
  # can override them at runtime. Ctor-time uses (TA buffer sizing,
@@ -433,6 +563,12 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
433
563
  # state are NOT here, so a TA length fed by them is still rejected
434
564
  # by the constructor guard.
435
565
  self._stable_runtime_vars: set[str] = set()
566
+ # Narrow subset of persistent scalars that a TA constructor may fold
567
+ # directly: one unique top-level ``var [int] name = <int literal>``
568
+ # binding, never reassigned and never promoted to Series storage.
569
+ # Keep this separate from ``_known_vars`` so ordinary identifier use
570
+ # still reads the persistent member and only TA buffer sizing changes.
571
+ self._stable_var_ctor_literals: dict[str, _StableVarCtorLiteral] = {}
436
572
  # ``_var_names`` (var/varip persistent-state members) is needed by the
437
573
  # stability classifier during _collect_known_vars, so pre-seed it from
438
574
  # the analyzer's var_members before that pass runs; the canonical
@@ -440,11 +576,30 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
440
576
  self._var_names: set[str] = set()
441
577
  for _vn, _, _ in ctx.var_members:
442
578
  self._var_names.add(_vn)
579
+ ordinary_global_member_names = self._ordinary_global_binding_names
580
+ for _vlist in ctx.func_var_members.values():
581
+ self._var_names.update(
582
+ _n
583
+ for _n, _, _ in _vlist
584
+ if _n not in ordinary_global_member_names
585
+ )
443
586
  self._collect_known_vars()
444
587
  # Track var names
445
588
  self._var_names = set()
446
589
  for name, _, _ in ctx.var_members:
447
590
  self._var_names.add(name)
591
+ # Preserve the established raw lexical allow-list even when the class
592
+ # member itself is owner-qualified. Declaration-site remapping resolves
593
+ # legal reads/writes to exact storage; this union also prevents the
594
+ # legacy non-var-Series loop from emitting a second raw class member.
595
+ # A real ordinary global with that spelling is deliberately excluded:
596
+ # it owns separate class state and must still be emitted.
597
+ for _vlist in ctx.func_var_members.values():
598
+ self._var_names.update(
599
+ _n
600
+ for _n, _, _ in _vlist
601
+ if _n not in ordinary_global_member_names
602
+ )
448
603
  # Every name bound ANYWHERE in the program (top-level, nested in
449
604
  # if/for/while/switch blocks, or inside function bodies). The
450
605
  # unknown-identifier guard in _visit_ident uses this as a generous
@@ -548,7 +703,33 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
548
703
  self._global_member_vars.add(name)
549
704
  self._global_mutable_infos: dict[str, object] = getattr(ctx, "global_mutable_infos", {}) or {}
550
705
  self._udt_var_types: dict[str, str] = getattr(ctx, "udt_var_types", {}) or {}
551
- self._collection_types: dict[str, TypeSpec] = getattr(ctx, "collection_types", {}) or {}
706
+ self._global_collection_types: dict[str, TypeSpec] = dict(
707
+ getattr(ctx, "collection_types", {}) or {}
708
+ )
709
+ self._collection_types: dict[str, TypeSpec] = dict(
710
+ self._global_collection_types
711
+ )
712
+ self._func_collection_types: dict[str, dict[str, TypeSpec]] = {
713
+ name: dict(specs)
714
+ for name, specs in (
715
+ getattr(ctx, "func_collection_types", {}) or {}
716
+ ).items()
717
+ }
718
+ self._block_collection_types: dict[int, dict[str, TypeSpec | None]] = {
719
+ owner_id: dict(specs)
720
+ for owner_id, specs in (
721
+ getattr(ctx, "block_collection_types", {}) or {}
722
+ ).items()
723
+ }
724
+ self._block_collection_owners: dict[int, str] = dict(
725
+ getattr(ctx, "block_collection_owners", {}) or {}
726
+ )
727
+ self._callable_collection_bindings: dict[int, TypeSpec | None] = dict(
728
+ getattr(ctx, "callable_collection_bindings", {}) or {}
729
+ )
730
+ self._callable_collection_binding_owners: dict[int, str] = dict(
731
+ getattr(ctx, "callable_collection_binding_owners", {}) or {}
732
+ )
552
733
  # id(block_node) -> {raw_var_name: unique_member} for block-scoped var
553
734
  # name collisions (see Analyzer._visit_VarDecl). Activated into
554
735
  # ``_active_var_remap`` while emitting the owning block's statements.
@@ -579,11 +760,44 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
579
760
  # Current function parameter types (set during _emit_func_def)
580
761
  self._current_func_param_types: dict[str, str] = {}
581
762
  self._current_func_param_specs: dict[str, "TypeSpec"] = {}
763
+ # Copy-on-write lexical collection inventory for the function/method
764
+ # currently being emitted. Activated by _emit_func_def and restored
765
+ # together with the legacy raw-name registries at function exit.
766
+ self._current_func_collection_specs: dict[str, "TypeSpec"] = {}
767
+ # Direct callable-body declarations that shadow a top-level collection.
768
+ # Nested declarations are activated only by their block COW overlay.
769
+ self._current_func_collection_shadows: set[str] = set()
770
+ # While emitting ``name = RHS`` for a declaration that shadows an
771
+ # already-active same-named collection, RHS identifiers are redirected
772
+ # through a pre-declaration alias. C++ brings the new name into scope
773
+ # inside its own initializer, so raw ``name`` would otherwise self-bind.
774
+ self._pending_decl_outer_alias: dict[str, str] = {}
775
+ self._collection_shadow_tmp_counter = 0
776
+ self._collection_shadow_tmp_names: set[str] = set()
582
777
  # Current function params that are series (const Series<double>&)
583
778
  self._current_func_series_params: set[str] = set()
779
+ # Element types parallel to ``_current_func_series_params``. Keeping
780
+ # these separate from the full C++ reference spelling lets expression
781
+ # inference see ``src`` as its Pine scalar family inside the body.
782
+ self._current_func_series_param_types: dict[str, str] = {}
584
783
  # Locals declared in the function currently being emitted (symbol table loses them after analysis)
585
784
  self._current_func_locals: set[str] = set()
586
785
  self._current_func_local_types: dict[str, str] = {}
786
+ # Source-ordered drawing-handle bindings in the currently emitted
787
+ # lexical block. Values are exact C++ handle types; ``None`` is a
788
+ # scalar/non-drawing tombstone that prevents an outer same-named handle
789
+ # from leaking inward. Block push/pop provides sibling isolation.
790
+ self._lexical_drawing_types: dict[str, str | None] = {}
791
+ # Source-ordered arbitrary UDT bindings. Drawing handles have their
792
+ # specialized registry above, while this parallel map preserves the
793
+ # exact authored UDT name needed to target-type a later bare ``na``
794
+ # reassignment. ``None`` is an ordinary scalar/collection tombstone;
795
+ # block push/pop keeps sibling declarations independent.
796
+ self._lexical_udt_types: dict[str, str | None] = {}
797
+ # Source-ordered lexical Series status. A False tombstone prevents a
798
+ # scalar local from inheriting a same-spelled global entry from the
799
+ # legacy raw-name ``ctx.series_vars`` union.
800
+ self._lexical_series_bindings: dict[str, bool] = {}
587
801
  # for-in loop iterator names (must resolve member access, not enum fallback)
588
802
  self._current_loop_vars: set[str] = set()
589
803
  self._current_loop_var_specs: dict[str, "TypeSpec"] = {}
@@ -708,9 +922,8 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
708
922
  self._all_member_names.add(self._safe_name(name))
709
923
  for name, _, _ in ctx.var_members:
710
924
  self._all_member_names.add(self._safe_name(name))
711
-
712
925
  self._register_global_aggregate_member_types()
713
- self._register_udt_array_get_ref_locals()
926
+ self._uses_map = self._detect_map_usage()
714
927
  self._uses_matrix = self._detect_matrix_usage()
715
928
  # Drawing-objects-as-data: gate all new emission (drawing.hpp include +
716
929
  # the per-type arenas) on this flag so non-drawing strategies stay
@@ -742,6 +955,328 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
742
955
  # history reads off security-helper series.
743
956
  self._max_bars_back_cap: int | None = self._compute_max_bars_back_cap()
744
957
 
958
+ # Declaration-site ``var`` metadata is prepared in ``generate()``
959
+ # after context-sensitive function instances have been built. Once
960
+ # flags share the class-member namespace with those fresh members, so
961
+ # allocating the flags here would not yet have the complete collision
962
+ # inventory.
963
+
964
+ def _scalar_var_init_depends_on_runtime_input(self, init_ast) -> bool:
965
+ """Whether a nominally constant initializer depends on input state.
966
+
967
+ ``_resolve_known`` intentionally folds input defaults for constructor
968
+ sizing and other compile-time decisions. A Pine ``var`` initializer,
969
+ however, must observe any host override installed before the first
970
+ bar. Treat direct input aliases and arithmetic aliases derived from
971
+ them as declaration-time expressions even when their default happens
972
+ to fold to a C++ literal.
973
+ """
974
+ if init_ast is None:
975
+ return False
976
+ runtime_names = set(self._input_backed_vars) | set(self._derived_input_expr)
977
+ for node in self._walk_ast(init_ast):
978
+ if isinstance(node, FuncCall) and self._is_input_call(node):
979
+ return True
980
+ if isinstance(node, Identifier) and node.name in runtime_names:
981
+ return True
982
+ return False
983
+
984
+ def _is_runtime_scalar_var_initializer(
985
+ self, name: str, ptype, init_str: str, init_ast,
986
+ drawing_cpp: str | None = None,
987
+ is_series: bool = False) -> bool:
988
+ """Return True for a persistent primitive that must init in execution.
989
+
990
+ Global Series and aggregate state keep their specialized preamble
991
+ routes. Callable vars are selected separately by the exact-declaration
992
+ rule below. This predicate identifies the remaining primitive global /
993
+ on-bar-scope members that need declaration-site once guards.
994
+ """
995
+ if init_ast is None:
996
+ return False
997
+ if name in self._visual_drop_vars:
998
+ return False
999
+ if name in self._array_vars or name in self._map_vars \
1000
+ or name in self._matrix_specs:
1001
+ return False
1002
+ if drawing_cpp is not None:
1003
+ # A nontrivial drawing-handle initializer may depend on preceding
1004
+ # source-order state. Default construction already represents a
1005
+ # bare ``na``, so only real expressions need a declaration-site
1006
+ # once guard.
1007
+ return (
1008
+ is_series
1009
+ or not self._is_na_expr(init_ast)
1010
+ )
1011
+ if is_series and name in self.ctx.series_vars:
1012
+ return False
1013
+ udt_type = self._member_udt_type(name)
1014
+ if udt_type in self._udt_defs:
1015
+ return False
1016
+ type_spec = self._collection_types.get(name)
1017
+ if type_spec is not None and type_spec.kind in {
1018
+ "array", "map", "matrix", "udt",
1019
+ }:
1020
+ return False
1021
+
1022
+ ctor_val = self._resolve_known(init_str)
1023
+ ctor_val = self._typed_na_init(ctor_val, name, ptype)
1024
+ return (
1025
+ not self._is_compile_time_value(ctor_val)
1026
+ or self._scalar_var_init_depends_on_runtime_input(init_ast)
1027
+ )
1028
+
1029
+ def _prepare_runtime_scalar_var_initializers(self) -> None:
1030
+ """Index declaration-site scalar ``var`` initialization and flags.
1031
+
1032
+ The analyzer supplies exact metadata for every VarDecl, including
1033
+ sibling-block disambiguation and callable ownership. Its insertion
1034
+ order follows source analysis and reaches declarations nested inside
1035
+ if/switch expressions, while the ownership bit excludes function and
1036
+ method bodies. This lets emission preserve ordinary dependency order
1037
+ and Pine's lazy first-entry semantics for conditional declarations.
1038
+ """
1039
+ self._runtime_scalar_var_init_by_node: dict[int, dict] = {}
1040
+ self._runtime_scalar_var_init_by_member: dict[str, dict] = {}
1041
+ self._runtime_scalar_var_init_members: set[str] = set()
1042
+ self._drawing_var_member_cpp_types: dict[str, str] = {}
1043
+ self._drawing_var_decl_info_by_node: dict[int, dict] = {}
1044
+ self._global_drawing_cpp_types: dict[str, str] = {}
1045
+ # Exact direct-program UDT identity, including ``None`` tombstones for
1046
+ # primitive globals. The analyzer's legacy UDT registry is keyed only
1047
+ # by raw spelling and can be overwritten by an unrelated callable
1048
+ # local with the same name.
1049
+ self._global_udt_types: dict[str, str | None] = {}
1050
+ for stmt in self.ctx.ast.body:
1051
+ if not isinstance(stmt, VarDecl):
1052
+ continue
1053
+ spec = (
1054
+ self._type_spec_from_hint_name(stmt.type_hint)
1055
+ if stmt.type_hint
1056
+ else self._type_spec_from_expr(stmt.value)
1057
+ )
1058
+ self._global_udt_types[stmt.name] = (
1059
+ spec.name
1060
+ if spec is not None and spec.kind == "udt"
1061
+ else None
1062
+ )
1063
+ # Analyzer metadata preserves the collision-safe storage identity for
1064
+ # every persistent declaration, including chart-scope siblings that
1065
+ # are not direct Program children. Keep primitive tombstones too: the
1066
+ # raw-name UDT union must not swap ``state`` and ``state__blk1`` types.
1067
+ self._member_udt_types: dict[str, str | None] = {}
1068
+ metadata_by_node = getattr(
1069
+ self.ctx, "var_member_metadata_by_node", {}
1070
+ ) or {}
1071
+ type_specs_by_node = getattr(
1072
+ self.ctx, "var_member_type_specs_by_node", {}
1073
+ ) or {}
1074
+ for node_id, meta in metadata_by_node.items():
1075
+ stmt, member_name, _ptype, _init_str, _callable = meta
1076
+ spec = type_specs_by_node.get(node_id)
1077
+ if spec is None and isinstance(stmt, VarDecl):
1078
+ spec = (
1079
+ self._type_spec_from_hint_name(stmt.type_hint)
1080
+ if stmt.type_hint
1081
+ else self._type_spec_from_expr(stmt.value)
1082
+ )
1083
+ self._member_udt_types[member_name] = (
1084
+ spec.name
1085
+ if spec is not None and spec.kind == "udt"
1086
+ else None
1087
+ )
1088
+ self._runtime_var_init_flags: dict[tuple[int, str], str] = {}
1089
+
1090
+ used_names = set(self._all_member_names)
1091
+ # ``_all_member_names`` historically covers persistent ``var`` and
1092
+ # Series members only. Plain global declarations are class members as
1093
+ # well, so include them before minting a generated flag; otherwise a
1094
+ # legal user binding such as ``_pf_var_init_seeded = 1`` can collide
1095
+ # with the flag for ``var seeded = low``.
1096
+ used_names.update(
1097
+ self._safe_name(name) for name, _ptype in self.ctx.global_var_decls
1098
+ )
1099
+ # Natural call-site clones and context-sensitive fresh instances are
1100
+ # emitted later, but they occupy the same C++ class-member namespace as
1101
+ # these flags. Reserve the complete callable-state inventory up front;
1102
+ # in particular, an authored local named ``_pf_var_init_state`` also
1103
+ # produces a legal ``_pf_var_init_state_cs1`` storage clone.
1104
+ for remap in self._func_cs_var_remap.values():
1105
+ used_names.update(remap.values())
1106
+ used_names.update(
1107
+ fresh_safe
1108
+ for _owner, _orig_safe, fresh_safe in self._fresh_var_members
1109
+ )
1110
+ used_names.update(
1111
+ remapped
1112
+ for remap in self._func_cs_fixnan_remap.values()
1113
+ for remapped in remap.values()
1114
+ )
1115
+ used_names.update(
1116
+ fresh_safe for _site, fresh_safe in self._fresh_fixnan_members
1117
+ )
1118
+ # Keep function naming stable across this late preparation pass. A
1119
+ # flag must avoid an emitted base/clone/fresh method name rather than
1120
+ # making ``_func_safe_name`` change after the instance graph was built.
1121
+ for func_info in self.ctx.func_infos:
1122
+ emitted = self._func_cpp_base_name(func_info.name)
1123
+ used_names.add(emitted)
1124
+ for callsite in range(
1125
+ self.ctx.func_call_site_counts.get(func_info.name, 0)
1126
+ ):
1127
+ used_names.add(f"{emitted}_cs{callsite}")
1128
+ used_names.update(inst["name"] for inst in self._fresh_instances)
1129
+
1130
+ def allocate_flag(base: str) -> str:
1131
+ flag = base
1132
+ suffix = 2
1133
+ while flag in used_names:
1134
+ flag = f"{base}_{suffix}"
1135
+ suffix += 1
1136
+ used_names.add(flag)
1137
+ self._all_member_names.add(flag)
1138
+ return flag
1139
+
1140
+ metadata_by_node = getattr(
1141
+ self.ctx, "var_member_metadata_by_node", {}
1142
+ ) or {}
1143
+ type_specs_by_node = getattr(
1144
+ self.ctx, "var_member_type_specs_by_node", {}
1145
+ ) or {}
1146
+ owners_by_node = getattr(
1147
+ self.ctx, "var_member_owners_by_node", {}
1148
+ ) or {}
1149
+ top_level_node_ids = {id(stmt) for stmt in self.ctx.ast.body}
1150
+ for node_id, meta in metadata_by_node.items():
1151
+ stmt, member_name, ptype, init_str, is_callable_scoped = meta
1152
+ if not isinstance(stmt, VarDecl) or not (stmt.is_var or stmt.is_varip):
1153
+ continue
1154
+ stmt_spec = type_specs_by_node.get(node_id)
1155
+ if stmt_spec is None:
1156
+ stmt_spec = (
1157
+ self._type_spec_from_hint_name(stmt.type_hint)
1158
+ if stmt.type_hint
1159
+ else self._type_spec_from_expr(stmt.value)
1160
+ )
1161
+ drawing_cpp = (
1162
+ DRAWING_TYPE_TO_CPP.get(stmt_spec.name)
1163
+ if stmt_spec is not None and stmt_spec.kind == "udt"
1164
+ else None
1165
+ )
1166
+ if drawing_cpp is not None:
1167
+ self._drawing_var_member_cpp_types[member_name] = drawing_cpp
1168
+ self._drawing_var_decl_info_by_node[node_id] = {
1169
+ "node_id": node_id,
1170
+ "raw_name": stmt.name,
1171
+ "member_name": member_name,
1172
+ "drawing_cpp": drawing_cpp,
1173
+ "is_callable_scoped": is_callable_scoped,
1174
+ "owner": owners_by_node.get(node_id),
1175
+ "type_spec": stmt_spec,
1176
+ }
1177
+ if node_id in top_level_node_ids:
1178
+ self._global_drawing_cpp_types[stmt.name] = drawing_cpp
1179
+ is_series = self._safe_name(member_name) in self._series_var_member_names
1180
+ if node_id in self._drawing_var_decl_info_by_node:
1181
+ self._drawing_var_decl_info_by_node[node_id]["is_series"] = is_series
1182
+ # Every initialized callable ``var`` declaration must run at its
1183
+ # exact statement. Nested declarations may be unreached, while a
1184
+ # direct declaration can depend on ordinary statements immediately
1185
+ # before it; a function-entry preamble violates both Pine rules.
1186
+ # Exact-node once flags also preserve independent call-site clones.
1187
+ # A bare UDT/drawing ``na`` already has the correct default member
1188
+ # state and needs no assignment guard.
1189
+ callable_decl_site = (
1190
+ is_callable_scoped
1191
+ and stmt.value is not None
1192
+ and not (
1193
+ stmt_spec is not None
1194
+ and stmt_spec.kind == "udt"
1195
+ and self._is_na_expr(stmt.value)
1196
+ )
1197
+ )
1198
+ nullable_collection_selection_decl_site = (
1199
+ stmt_spec is not None
1200
+ and stmt_spec.kind in {"map", "matrix"}
1201
+ and isinstance(stmt.value, (IfStmt, SwitchStmt))
1202
+ )
1203
+ established_decl_site = (
1204
+ (not is_callable_scoped or drawing_cpp is not None)
1205
+ and (
1206
+ nullable_collection_selection_decl_site
1207
+ or self._is_runtime_scalar_var_initializer(
1208
+ member_name, ptype, init_str, stmt.value, drawing_cpp,
1209
+ is_series
1210
+ )
1211
+ )
1212
+ )
1213
+ if not callable_decl_site and not established_decl_site:
1214
+ continue
1215
+
1216
+ base_flag = f"_pf_var_init_{self._safe_name(member_name)}"
1217
+ flag = allocate_flag(base_flag)
1218
+ self._runtime_scalar_var_init_members.add(member_name)
1219
+ info = {
1220
+ "member_name": member_name,
1221
+ "node_id": node_id,
1222
+ "owner": owners_by_node.get(node_id),
1223
+ "is_callable_scoped": is_callable_scoped,
1224
+ "ptype": ptype,
1225
+ "flag": flag,
1226
+ "drawing_cpp": drawing_cpp,
1227
+ "type_spec": stmt_spec,
1228
+ "is_series": is_series,
1229
+ }
1230
+ self._runtime_scalar_var_init_by_node[node_id] = info
1231
+ base_storage = self._safe_name(member_name)
1232
+ self._runtime_var_init_flags[(node_id, base_storage)] = flag
1233
+ if is_callable_scoped:
1234
+ owner = owners_by_node.get(node_id)
1235
+ for (remap_owner, _cs_idx), remap in self._func_cs_var_remap.items():
1236
+ if remap_owner != owner or base_storage not in remap:
1237
+ continue
1238
+ storage = remap[base_storage]
1239
+ if storage == base_storage:
1240
+ continue
1241
+ clone_base = f"_pf_var_init_{storage}"
1242
+ clone_flag = allocate_flag(clone_base)
1243
+ self._runtime_var_init_flags[(node_id, storage)] = clone_flag
1244
+ for inst in self._fresh_instances:
1245
+ if inst.get("fname") != owner:
1246
+ continue
1247
+ storage = inst.get("var_remap", {}).get(
1248
+ base_storage, base_storage
1249
+ )
1250
+ key = (node_id, storage)
1251
+ if key in self._runtime_var_init_flags:
1252
+ continue
1253
+ fresh_base = f"_pf_var_init_{storage}"
1254
+ self._runtime_var_init_flags[key] = allocate_flag(
1255
+ fresh_base
1256
+ )
1257
+ if not is_callable_scoped:
1258
+ self._runtime_scalar_var_init_by_member[member_name] = info
1259
+
1260
+ self._runtime_var_init_flag_used_names = used_names
1261
+
1262
+ # Top-level non-persistent drawing declarations are not represented in
1263
+ # var-member metadata. Record their exact source declaration type so
1264
+ # functions can resolve globals without consulting the analyzer's
1265
+ # legacy raw-name UDT registry (which later locals may overwrite).
1266
+ for stmt in self.ctx.ast.body:
1267
+ if not isinstance(stmt, VarDecl) or stmt.is_var or stmt.is_varip:
1268
+ continue
1269
+ spec = (
1270
+ self._type_spec_from_hint_name(stmt.type_hint)
1271
+ if stmt.type_hint
1272
+ else self._type_spec_from_expr(stmt.value)
1273
+ )
1274
+ if (spec is not None and spec.kind == "udt"
1275
+ and spec.name in DRAWING_TYPE_TO_CPP):
1276
+ self._global_drawing_cpp_types[stmt.name] = (
1277
+ DRAWING_TYPE_TO_CPP[spec.name]
1278
+ )
1279
+
745
1280
  # ------------------------------------------------------------------
746
1281
  # Context-sensitive (call-path) instance machinery
747
1282
  # ------------------------------------------------------------------
@@ -815,11 +1350,69 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
815
1350
  if node is not None and getattr(node, "body", None):
816
1351
  func_bodies.setdefault(fi.name, node.body)
817
1352
 
1353
+ # Pine forbids recursive callable execution. Most scalar-only cycles
1354
+ # never enter this state-instance pass (and some legacy dead-branch
1355
+ # typing probes intentionally retain them), but a cycle carrying TA,
1356
+ # var, series, fixnan, or transitive state would otherwise mint a new
1357
+ # fresh path instance forever. Reject exactly that stateful subgraph
1358
+ # before expanding any members or dispatch records.
1359
+ stateful_edges: dict[str, list[tuple[str, FuncCall]]] = {
1360
+ name: [] for name in stateful
1361
+ }
1362
+ inherited_names = getattr(
1363
+ ctx, "func_inherited_call_names", {}
1364
+ ) or {}
1365
+ for owner, body in func_bodies.items():
1366
+ if owner not in stateful:
1367
+ continue
1368
+ for callnode in self._iter_func_calls(body):
1369
+ cs_info = ctx.func_call_cs_map.get(id(callnode))
1370
+ callee = (
1371
+ cs_info[0]
1372
+ if cs_info is not None
1373
+ else inherited_names.get(id(callnode))
1374
+ )
1375
+ if callee in stateful:
1376
+ stateful_edges[owner].append((callee, callnode))
1377
+
1378
+ colors: dict[str, int] = {}
1379
+ stack: list[str] = []
1380
+
1381
+ def reject_stateful_cycle(name: str) -> None:
1382
+ colors[name] = 1
1383
+ stack.append(name)
1384
+ for callee, callnode in stateful_edges.get(name, ()):
1385
+ color = colors.get(callee, 0)
1386
+ if color == 1:
1387
+ start = stack.index(callee)
1388
+ cycle = [*stack[start:], callee]
1389
+ self._codegen_error(
1390
+ callnode,
1391
+ "Recursive stateful callable cycle is not supported: "
1392
+ + " -> ".join(cycle)
1393
+ + ".",
1394
+ hint=(
1395
+ "Remove the recursive call; Pine user-defined "
1396
+ "functions cannot execute recursively."
1397
+ ),
1398
+ )
1399
+ if color == 0:
1400
+ reject_stateful_cycle(callee)
1401
+ stack.pop()
1402
+ colors[name] = 2
1403
+
1404
+ for name in sorted(stateful):
1405
+ if colors.get(name, 0) == 0:
1406
+ reject_stateful_cycle(name)
1407
+
818
1408
  def ta_originals(fname: str) -> list[str]:
819
1409
  return list(self._func_cs_ta_remap.get((fname, 0), {}).keys())
820
1410
 
821
1411
  def var_originals(fname: str) -> list[str]:
822
- return [self._safe_name(n) for n, _, _ in ctx.func_var_members.get(fname, [])]
1412
+ return [
1413
+ self._safe_name(self._func_var_storage_name(fname, n))
1414
+ for n, _, _ in ctx.func_var_members.get(fname, [])
1415
+ ]
823
1416
 
824
1417
  def fixnan_originals(fname: str) -> list[str]:
825
1418
  return list(self._func_cs_fixnan_remap.get((fname, 0), {}).keys())
@@ -842,6 +1435,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
842
1435
  worklist.append({
843
1436
  "fname": fname,
844
1437
  "name": natural_name(fname, k),
1438
+ "call_site_idx": k,
845
1439
  "ta_remap": self._func_cs_ta_remap.get((fname, k), {}),
846
1440
  "var_remap": self._func_cs_var_remap.get((fname, k), {}),
847
1441
  "fixnan_remap": self._func_cs_fixnan_remap.get((fname, k), {}),
@@ -850,6 +1444,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
850
1444
  worklist.append({
851
1445
  "fname": fname,
852
1446
  "name": self._func_cpp_base_name(fname),
1447
+ "call_site_idx": None,
853
1448
  "ta_remap": {},
854
1449
  "var_remap": {},
855
1450
  "fixnan_remap": {},
@@ -864,12 +1459,24 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
864
1459
  if not body:
865
1460
  continue
866
1461
  active_ta = inst["ta_remap"]
1462
+ active_var = inst.get("var_remap", {})
867
1463
  active_fixnan = inst.get("fixnan_remap", {})
868
1464
  for callnode in self._iter_func_calls(body):
869
1465
  cs_info = ctx.func_call_cs_map.get(id(callnode))
870
1466
  if cs_info is None:
871
- continue
872
- g_name, j = cs_info
1467
+ # Natural csN parents deliberately leave inherited nested
1468
+ # calls unmapped so visit_call can thread N through its
1469
+ # active-index fallback. A fresh context-sensitive parent
1470
+ # has no active index; recover only that removed edge's
1471
+ # exact callee identity and compose it from natural cs0.
1472
+ if not inst.get("fresh", False):
1473
+ continue
1474
+ g_name = ctx.func_inherited_call_names.get(id(callnode))
1475
+ if g_name is None:
1476
+ continue
1477
+ j = 0
1478
+ else:
1479
+ g_name, j = cs_info
873
1480
  if g_name not in stateful:
874
1481
  continue
875
1482
  natural_ta = self._func_cs_ta_remap.get((g_name, j), {})
@@ -877,18 +1484,56 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
877
1484
  for m in ta_originals(g_name):
878
1485
  mid = natural_ta.get(m, m)
879
1486
  composed_ta[m] = active_ta.get(mid, mid)
1487
+ g_var_originals = var_originals(g_name)
1488
+ full_natural_var = self._func_cs_var_remap.get(
1489
+ (g_name, j), {}
1490
+ )
1491
+ natural_var = {
1492
+ name: full_natural_var.get(name, name)
1493
+ for name in g_var_originals
1494
+ }
1495
+ composed_var = {}
1496
+ for m in g_var_originals:
1497
+ mid = natural_var.get(m, m)
1498
+ composed_var[m] = active_var.get(mid, mid)
880
1499
  natural_fixnan = self._func_cs_fixnan_remap.get((g_name, j), {})
881
1500
  composed_fixnan = {}
882
1501
  for m in fixnan_originals(g_name):
883
1502
  mid = natural_fixnan.get(m, m)
884
1503
  composed_fixnan[m] = active_fixnan.get(mid, mid)
885
- if composed_ta == natural_ta and composed_fixnan == natural_fixnan:
1504
+ owns_non_ta_state = bool(
1505
+ g_var_originals or fixnan_originals(g_name)
1506
+ )
1507
+ # Every non-cs0 enclosing variant is a distinct Pine written
1508
+ # call path. Even a pure wrapper can reach var/fixnan state
1509
+ # farther down the graph, so preserve the path identity here
1510
+ # and let the fresh wrapper compose its next edge in turn.
1511
+ path_requires_fresh_state = (
1512
+ inst.get("fresh", False)
1513
+ or inst.get("call_site_idx") not in (None, 0)
1514
+ )
1515
+ if (
1516
+ not path_requires_fresh_state
1517
+ and composed_ta == natural_ta
1518
+ and composed_var == natural_var
1519
+ and composed_fixnan == natural_fixnan
1520
+ ):
886
1521
  # Path resolves to the callee's own cs{j} clone — reuse it.
887
1522
  self._instance_dispatch[(inst["name"], id(callnode))] = \
888
1523
  natural_name(g_name, j)
889
1524
  continue
890
- key = (g_name, frozenset(composed_ta.items()),
891
- frozenset(composed_fixnan.items()))
1525
+ path_identity = (
1526
+ (inst["name"], id(callnode))
1527
+ if owns_non_ta_state or path_requires_fresh_state
1528
+ else None
1529
+ )
1530
+ key = (
1531
+ g_name,
1532
+ frozenset(composed_ta.items()),
1533
+ frozenset(composed_var.items()),
1534
+ frozenset(composed_fixnan.items()),
1535
+ path_identity,
1536
+ )
892
1537
  ginst = interned.get(key)
893
1538
  if ginst is None:
894
1539
  fresh_counter += 1
@@ -897,7 +1542,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
897
1542
  for v in var_originals(g_name):
898
1543
  fresh_member = f"{v}__ni{fresh_counter}"
899
1544
  fvar_remap[v] = fresh_member
900
- self._fresh_var_members.append((v, fresh_member))
1545
+ self._fresh_var_members.append((g_name, v, fresh_member))
901
1546
  # Fresh fixnan members: each path gets its OWN previous-
902
1547
  # value member so two call paths never share fixnan state.
903
1548
  ffixnan_remap: dict[str, str] = {}
@@ -917,6 +1562,8 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
917
1562
  ginst = {
918
1563
  "fname": g_name,
919
1564
  "name": inst_name,
1565
+ "fresh": True,
1566
+ "call_site_idx": None,
920
1567
  "ta_remap": composed_ta,
921
1568
  "var_remap": fvar_remap,
922
1569
  "fixnan_remap": ffixnan_remap,
@@ -926,8 +1573,107 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
926
1573
  worklist.append(ginst)
927
1574
  self._instance_dispatch[(inst["name"], id(callnode))] = ginst["name"]
928
1575
 
1576
+ def _callable_var_collection_spec(
1577
+ self, name: str, owner_func: str | None = None) -> TypeSpec | None:
1578
+ """Exact TypeSpec for a function-scoped persistent collection member.
1579
+
1580
+ Only declaration nodes that actually became callable-scoped persistent
1581
+ members are eligible here. A raw-name scan of the general callable
1582
+ inventory can otherwise turn a global scalar member into a collection
1583
+ merely because an unrelated helper has a same-named ordinary local.
1584
+ Clone contexts may own state reachable through another callable, so an
1585
+ explicit owner is preferred and the owner-less fallback remains
1586
+ deliberately limited to a single unambiguous persistent TypeSpec.
1587
+ """
1588
+ safe = self._safe_name(name)
1589
+
1590
+ candidates_by_owner: dict[str, list[TypeSpec]] = {}
1591
+ metadata = getattr(self.ctx, "var_member_metadata_by_node", {}) or {}
1592
+ for node_id, meta in metadata.items():
1593
+ _node, member_name, _ptype, _init_str, is_callable_scoped = meta
1594
+ if (not is_callable_scoped
1595
+ or self._safe_name(member_name) != safe):
1596
+ continue
1597
+ spec = self._callable_collection_bindings.get(node_id)
1598
+ owner = self._callable_collection_binding_owners.get(node_id)
1599
+ if (owner is None or spec is None
1600
+ or spec.kind not in {"array", "map", "matrix"}):
1601
+ continue
1602
+ owner_candidates = candidates_by_owner.setdefault(owner, [])
1603
+ if spec not in owner_candidates:
1604
+ owner_candidates.append(spec)
1605
+
1606
+ if owner_func is not None:
1607
+ owned = candidates_by_owner.get(owner_func, [])
1608
+ if len(owned) == 1:
1609
+ return owned[0]
1610
+ # The existing clone inventory deliberately unions persistent vars
1611
+ # from reachable helpers into an enclosing function's remap. In
1612
+ # that case ``owner_func`` names the clone context, not the lexical
1613
+ # declaration owner; fall through to the unambiguous owner search.
1614
+
1615
+ candidates: list[TypeSpec] = []
1616
+ for owned in candidates_by_owner.values():
1617
+ for spec in owned:
1618
+ if spec not in candidates:
1619
+ candidates.append(spec)
1620
+ return candidates[0] if len(candidates) == 1 else None
1621
+
1622
+ def _callable_var_udt_spec(
1623
+ self, name: str, owner_func: str | None = None) -> TypeSpec | None:
1624
+ """Exact UDT TypeSpec for one callable persistent member identity.
1625
+
1626
+ The analyzer's legacy ``_udt_var_types`` registry is keyed by raw Pine
1627
+ spelling, so sibling declarations such as ``state`` / ``state__blk1``
1628
+ can overwrite each other. Declaration metadata retains both the exact
1629
+ collision-safe member name and lexical owner; use it for base members
1630
+ and every written-callsite clone.
1631
+ """
1632
+ safe = self._safe_name(name)
1633
+ candidates_by_owner: dict[str, list[TypeSpec]] = {}
1634
+ metadata = getattr(self.ctx, "var_member_metadata_by_node", {}) or {}
1635
+ specs = getattr(self.ctx, "var_member_type_specs_by_node", {}) or {}
1636
+ owners = getattr(self.ctx, "var_member_owners_by_node", {}) or {}
1637
+ for node_id, meta in metadata.items():
1638
+ _node, member_name, _ptype, _init_str, is_callable_scoped = meta
1639
+ if (not is_callable_scoped
1640
+ or self._safe_name(member_name) != safe):
1641
+ continue
1642
+ spec = specs.get(node_id)
1643
+ owner = owners.get(node_id)
1644
+ if (owner is None or spec is None
1645
+ or spec.kind != "udt"
1646
+ or spec.name not in self._udt_defs):
1647
+ continue
1648
+ owner_candidates = candidates_by_owner.setdefault(owner, [])
1649
+ if spec not in owner_candidates:
1650
+ owner_candidates.append(spec)
1651
+
1652
+ if owner_func is not None:
1653
+ owned = candidates_by_owner.get(owner_func, [])
1654
+ if len(owned) == 1:
1655
+ return owned[0]
1656
+
1657
+ candidates: list[TypeSpec] = []
1658
+ for owned in candidates_by_owner.values():
1659
+ for spec in owned:
1660
+ if spec not in candidates:
1661
+ candidates.append(spec)
1662
+ return candidates[0] if len(candidates) == 1 else None
1663
+
1664
+ def _member_udt_type(self, name: str) -> str | None:
1665
+ """Exact UDT type for class-member storage, with global tombstones."""
1666
+ global_types = getattr(self, "_global_udt_types", {})
1667
+ if name in global_types:
1668
+ return global_types[name]
1669
+ member_types = getattr(self, "_member_udt_types", {})
1670
+ if name in member_types:
1671
+ return member_types[name]
1672
+ return self._udt_var_types.get(name)
1673
+
929
1674
  def _emit_cloned_var_decl(self, orig_safe: str, cloned_safe: str,
930
- series_suffix: str, lines: list[str]) -> None:
1675
+ series_suffix: str, lines: list[str],
1676
+ owner_func: str | None = None) -> None:
931
1677
  """Declare a per-clone copy of a function-scoped ``var`` member, matching
932
1678
  the original's C++ type (series / matrix / array / map / drawing-handle /
933
1679
  UDT / scalar). Shared by the per-call-site clone loop and the fresh
@@ -935,22 +1681,59 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
935
1681
  for vname, ptype, _init_str in self.ctx.var_members:
936
1682
  if self._safe_name(vname) == orig_safe:
937
1683
  cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double")
938
- if vname in self.ctx.series_vars:
1684
+ if cpp_type == "int" and self._is_int64_builtin_init(vname):
1685
+ cpp_type = "int64_t"
1686
+ collection_spec = self._callable_var_collection_spec(
1687
+ vname, owner_func
1688
+ )
1689
+ udt_spec = self._callable_var_udt_spec(vname, owner_func)
1690
+ member_udt_type = self._member_udt_type(vname)
1691
+ drawing_cpp = self._drawing_var_member_cpp_types.get(vname)
1692
+ if (drawing_cpp is not None
1693
+ and orig_safe in self._series_var_member_names):
1694
+ lines.append(
1695
+ f" Series<{drawing_cpp}> {cloned_safe}{series_suffix};"
1696
+ )
1697
+ elif (orig_safe in self._series_var_member_names
1698
+ or vname in self.ctx.series_vars):
939
1699
  lines.append(f" Series<{cpp_type}> {cloned_safe}{series_suffix};")
1700
+ elif collection_spec is not None:
1701
+ lines.append(
1702
+ f" {self._type_spec_to_cpp(collection_spec)} {cloned_safe};"
1703
+ )
940
1704
  elif vname in self._matrix_specs:
941
1705
  lines.append(f" {self._type_spec_to_cpp(self._matrix_specs[vname])} {cloned_safe};")
942
1706
  elif vname in self._array_vars:
943
1707
  lines.append(f" {self._type_spec_to_cpp(self._array_spec_for_name(vname))} {cloned_safe};")
944
1708
  elif vname in self._map_vars:
945
1709
  lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(vname))} {cloned_safe};")
946
- elif vname in self._udt_var_types:
1710
+ elif drawing_cpp is not None:
1711
+ lines.append(
1712
+ f" {drawing_cpp} {cloned_safe} = {drawing_cpp}{{}};"
1713
+ )
1714
+ elif udt_spec is not None or member_udt_type is not None:
947
1715
  # Drawing handle / UDT var clone must match the original's
948
1716
  # type (Line/Label/Box/<UDT>), not the coarse PineType
949
1717
  # default (double) — otherwise the clone can't hold the
950
1718
  # handle and drawing access on it reads a garbage / na id.
951
- udt_t = self._udt_var_types[vname]
1719
+ udt_t = (
1720
+ udt_spec.name
1721
+ if udt_spec is not None
1722
+ else member_udt_type
1723
+ )
952
1724
  handle_cpp = DRAWING_TYPE_TO_CPP.get(udt_t, udt_t)
953
1725
  lines.append(f" {handle_cpp} {cloned_safe} = {handle_cpp}{{}};")
1726
+ elif vname in self._runtime_scalar_var_init_members:
1727
+ # Declaration-site state may remain unreached while COOF
1728
+ # snapshots value-copy every clone. Match the base member's
1729
+ # typed Pine-na default instead of leaving primitive clone
1730
+ # storage indeterminate (or giving it a legacy zero).
1731
+ pending = self._typed_na_init(
1732
+ "na<double>()", vname, ptype
1733
+ )
1734
+ lines.append(
1735
+ f" {cpp_type} {cloned_safe} = {pending};"
1736
+ )
954
1737
  else:
955
1738
  lines.append(f" {cpp_type} {cloned_safe};")
956
1739
  return
@@ -961,6 +1744,52 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
961
1744
  else:
962
1745
  lines.append(f" double {cloned_safe} = 0.0;")
963
1746
 
1747
+ def _binding_is_series(
1748
+ self,
1749
+ raw_name: str,
1750
+ resolved_safe_name: str | None = None,
1751
+ ) -> bool:
1752
+ """Resolve Series-ness without conflating renamed persistent siblings.
1753
+
1754
+ ``ctx.series_vars`` is a legacy raw-name union. When a persistent
1755
+ member identity is known, its exact membership wins; otherwise keep the
1756
+ established raw-name behavior for ordinary non-var series, bar
1757
+ builtins, and parameters. A call-site clone resolves to ``h_cs1`` but
1758
+ inherits the base member ``h`` status, while a sibling rename such as
1759
+ ``x__blk1`` is itself an exact member and therefore wins directly.
1760
+ """
1761
+ base_safe = self._safe_name(raw_name)
1762
+ resolved = resolved_safe_name or base_safe
1763
+ if raw_name in self._lexical_series_bindings:
1764
+ return self._lexical_series_bindings[raw_name]
1765
+ if resolved in self._persistent_var_member_names:
1766
+ return resolved in self._series_var_member_names
1767
+ if (raw_name in self._qualified_func_var_raw_names
1768
+ and raw_name in self._ordinary_global_binding_names):
1769
+ return raw_name in self._ordinary_global_series_names
1770
+ if base_safe in self._persistent_var_member_names:
1771
+ return base_safe in self._series_var_member_names
1772
+ return raw_name in self.ctx.series_vars
1773
+
1774
+ def _known_var_is_lexically_shadowed(self, name: str) -> bool:
1775
+ """Whether a local/parameter/loop binder hides a known global name."""
1776
+ return name in self._lexical_known_var_tombstones
1777
+
1778
+ def _decl_binding_is_series(self, node_id: int, raw_name: str) -> bool:
1779
+ """Return exact history status for one declaration binding.
1780
+
1781
+ A VarDecl has one name, but a TupleAssign node owns several names. The
1782
+ node-only legacy set therefore cannot distinguish ``[x, y]`` when only
1783
+ one element is history-referenced. Prefer the exact analyzer key and
1784
+ retain the node-only fallback for older/manually-built contexts.
1785
+ """
1786
+ exact = getattr(self.ctx, "series_decl_bindings", set()) or set()
1787
+ if (node_id, raw_name) in exact:
1788
+ return True
1789
+ if any(binding_node == node_id for binding_node, _ in exact):
1790
+ return False
1791
+ return node_id in getattr(self.ctx, "series_decl_nodes", set())
1792
+
964
1793
  @staticmethod
965
1794
  def _int_literal_value(node: ASTNode | None) -> int | None:
966
1795
  """Return the integer value of a (possibly unary-minus) NumberLiteral,
@@ -1018,6 +1847,29 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1018
1847
  ``global_expr_map``; without registering it here, ``m`` was emitted as a scalar
1019
1848
  while ``on_bar`` still assigned ``PineMatrix``.
1020
1849
  """
1850
+ # A typed ``matrix<T> m = na`` or an inferred nullable selection has no
1851
+ # constructor call for the legacy RHS scan below to recognize. Resolve
1852
+ # declarations in source order from the authored hint/RHS, not from
1853
+ # ``ctx.collection_types``: that raw-name table describes the analyzer's
1854
+ # final state and can already contain an invalid later reassignment's
1855
+ # element type. The original declaration must remain the compatibility
1856
+ # baseline used to reject matrix<int> := matrix<float>.
1857
+ for stmt, _in_loop in self._walk_global_scope_with_loopflag(
1858
+ getattr(self.ctx.ast, "body", []), False
1859
+ ):
1860
+ if not isinstance(stmt, VarDecl):
1861
+ continue
1862
+ if stmt.type_hint:
1863
+ declared_spec = self._type_spec_from_hint_name(stmt.type_hint)
1864
+ elif isinstance(stmt.value, (Ternary, IfStmt, SwitchStmt)):
1865
+ declared_spec = self._type_spec_from_expr(stmt.value)
1866
+ else:
1867
+ continue
1868
+ if declared_spec is None or declared_spec.kind != "matrix":
1869
+ continue
1870
+ self._matrix_specs.setdefault(stmt.name, declared_spec)
1871
+ self._collection_types[stmt.name] = self._matrix_specs[stmt.name]
1872
+
1021
1873
  gem = getattr(self.ctx, "global_expr_map", {}) or {}
1022
1874
  for name, _ptype in self.ctx.global_var_decls:
1023
1875
  expr = gem.get(name)
@@ -1099,7 +1951,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1099
1951
  a separate scope and must not be attributed to a global member. The
1100
1952
  ``in_loop`` flag is True once inside any for/while loop body."""
1101
1953
  for s in stmts:
1102
- if isinstance(s, FuncDef):
1954
+ if isinstance(s, (FuncDef, MethodDef)):
1103
1955
  continue
1104
1956
  yield s, in_loop
1105
1957
  child_in_loop = in_loop or isinstance(s, (ForStmt, ForInStmt, WhileStmt))
@@ -1113,47 +1965,6 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1113
1965
  if isinstance(case_stmts, list):
1114
1966
  yield from self._walk_global_scope_with_loopflag(case_stmts, child_in_loop)
1115
1967
 
1116
- def _register_udt_array_get_ref_locals(self) -> None:
1117
- """Detect global-scope UDT loop-locals that alias a UDT array element and
1118
- are later field-mutated (Pine array elements of a user-defined type are
1119
- references — ``z = arr.get(i)`` then ``z.f := v`` MUST write back into
1120
- ``arr``).
1121
-
1122
- A non-``var`` global-scope ``UDT z = arr.get(i)`` (or ``.first`` /
1123
- ``.last``) nested inside a for/while loop mis-lowers to a value copy: a
1124
- global ``while`` loop hoists ``z`` to a class member whose in-loop init
1125
- becomes a value-copy assignment, and a global ``for`` loop keeps ``z`` a
1126
- true local but the function-local alias path (``_udt_local_alias_kind``)
1127
- no-ops at global scope (``_current_func_body`` is None) — both silently
1128
- drop the field mutation. We record exactly this shape so the (possible)
1129
- class member is suppressed and the in-loop VarDecl is emitted as a fresh
1130
- per-iteration ``UDT& z = arr[i];`` reference instead (the same alias form
1131
- the non-hoisted function-local path already produces). Strictly gated:
1132
- the RHS must be a UDT-array-element lvalue AND the name must be field-
1133
- mutated at global scope AND the declaration must be loop-nested. A
1134
- read-only get-local is never recorded, so its value-copy output is
1135
- unchanged. Function-local get-locals are excluded (the walker skips
1136
- function bodies) — those keep using the existing alias path."""
1137
- pairs = list(self._walk_global_scope_with_loopflag(self.ctx.ast.body, False))
1138
- field_mutated: set[str] = set()
1139
- for s, _in_loop in pairs:
1140
- if (isinstance(s, Assignment)
1141
- and isinstance(s.target, MemberAccess)
1142
- and isinstance(s.target.object, Identifier)):
1143
- field_mutated.add(s.target.object.name)
1144
- for s, in_loop in pairs:
1145
- if not isinstance(s, VarDecl) or s.is_var or s.is_varip:
1146
- continue
1147
- if not in_loop:
1148
- continue
1149
- if not isinstance(s.value, FuncCall):
1150
- continue
1151
- if self._is_udt_lvalue(s.value) is None:
1152
- continue
1153
- if s.name not in field_mutated:
1154
- continue
1155
- self._udt_array_get_ref_locals.add(s.name)
1156
-
1157
1968
  def _extract_receiver_name(self, call_node) -> str | None:
1158
1969
  """Extract receiver Identifier name from m.method(...) or matrix.method(m, ...).
1159
1970
 
@@ -1216,8 +2027,70 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1216
2027
  else:
1217
2028
  self._codegen_error(node, "matrix.sort requires int, bool, string, or float element type; UDT matrices cannot be sorted")
1218
2029
 
2030
+ def _iter_declared_matrix_specs(self):
2031
+ """Yield matrix TypeSpecs reachable from declared source types.
2032
+
2033
+ A typed ``matrix<T> x = na`` has no matrix call for a syntactic scan
2034
+ to find. Matrix types can also be nested in UDT fields or appear only
2035
+ at a callable boundary, so include selection must start from analyzer
2036
+ metadata and recurse through aggregate types.
2037
+ """
2038
+ roots = list(self._collection_types.values())
2039
+ roots.extend(
2040
+ spec
2041
+ for specs in self._func_collection_types.values()
2042
+ for spec in specs.values()
2043
+ )
2044
+ roots.extend(
2045
+ spec
2046
+ for specs in self._block_collection_types.values()
2047
+ for spec in specs.values()
2048
+ if spec is not None
2049
+ )
2050
+ roots.extend(
2051
+ spec
2052
+ for fields in self._udt_field_type_specs.values()
2053
+ for spec in fields.values()
2054
+ )
2055
+ for fi in self.ctx.func_infos:
2056
+ roots.extend(
2057
+ spec
2058
+ for spec in (getattr(fi, "param_type_specs", []) or [])
2059
+ if spec is not None
2060
+ )
2061
+ return_spec = getattr(fi, "return_type_spec", None)
2062
+ if return_spec is not None:
2063
+ roots.append(return_spec)
2064
+
2065
+ def walk(spec: TypeSpec | None, visiting_udts: frozenset[str]):
2066
+ if spec is None:
2067
+ return
2068
+ if spec.kind == "matrix":
2069
+ yield spec
2070
+ return
2071
+ if spec.kind == "array":
2072
+ yield from walk(spec.element, visiting_udts)
2073
+ return
2074
+ if spec.kind == "map":
2075
+ yield from walk(spec.key, visiting_udts)
2076
+ yield from walk(spec.value, visiting_udts)
2077
+ return
2078
+ if spec.kind == "udt" and spec.name:
2079
+ if spec.name in visiting_udts:
2080
+ return
2081
+ nested_visiting = visiting_udts | {spec.name}
2082
+ for field_spec in self._udt_field_type_specs.get(
2083
+ spec.name, {}
2084
+ ).values():
2085
+ yield from walk(field_spec, nested_visiting)
2086
+
2087
+ for root in roots:
2088
+ yield from walk(root, frozenset())
2089
+
1219
2090
  def _detect_matrix_usage(self) -> bool:
1220
- """True if emitted C++ will need runtime/matrix.hpp (PineMatrix)."""
2091
+ """True if emitted C++ will need a matrix runtime header."""
2092
+ if next(self._iter_declared_matrix_specs(), None) is not None:
2093
+ return True
1221
2094
  for _, _, init_str in self.ctx.var_members:
1222
2095
  if init_str and "matrix.new" in str(init_str):
1223
2096
  return True
@@ -1228,6 +2101,90 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1228
2101
  return True
1229
2102
  return False
1230
2103
 
2104
+ def _detect_generic_matrix_usage(self) -> bool:
2105
+ """True if emitted C++ mentions a non-float matrix specialization."""
2106
+ float_spec = TypeSpec.primitive("float")
2107
+ if any(
2108
+ spec.element != float_spec
2109
+ for spec in self._iter_declared_matrix_specs()
2110
+ ):
2111
+ return True
2112
+
2113
+ # A standalone matrix.new<T>() expression may have no declaration
2114
+ # TypeSpec. Read its explicit template argument directly so the
2115
+ # generated call still receives the generic runtime declaration.
2116
+ for node in self._walk_ast(self.ctx.ast):
2117
+ if not isinstance(node, FuncCall):
2118
+ continue
2119
+ fn, ns = self._resolve_callee(node.callee)
2120
+ if ns != "matrix" or fn != "new":
2121
+ continue
2122
+ targs = self._template_args_from_call(node)
2123
+ if not targs:
2124
+ continue
2125
+ elem_spec = self._type_spec_from_hint_name(targs[0])
2126
+ if elem_spec is not None and elem_spec != float_spec:
2127
+ return True
2128
+ return False
2129
+
2130
+ def _detect_map_usage(self) -> bool:
2131
+ """True when emitted C++ needs the PineMap handle/runtime helpers.
2132
+
2133
+ The call scan catches constructors and built-in operations. TypeSpec
2134
+ scans additionally catch typed ``na`` maps and map fields that never
2135
+ execute a map call, so their declarations still receive map.hpp and
2136
+ map-aware COOF checkpoint support.
2137
+ """
2138
+ def contains_map(
2139
+ spec: TypeSpec | None, visiting_udts: frozenset[str] = frozenset()
2140
+ ) -> bool:
2141
+ if spec is None:
2142
+ return False
2143
+ if spec.kind == "map":
2144
+ return True
2145
+ if spec.kind in {"array", "matrix"}:
2146
+ return contains_map(spec.element, visiting_udts)
2147
+ if spec.kind == "udt" and spec.name:
2148
+ if spec.name in visiting_udts:
2149
+ return False
2150
+ nested_visiting = visiting_udts | {spec.name}
2151
+ return any(
2152
+ contains_map(field_spec, nested_visiting)
2153
+ for field_spec in self._udt_field_type_specs.get(
2154
+ spec.name, {}
2155
+ ).values()
2156
+ )
2157
+ return False
2158
+
2159
+ if self._map_vars:
2160
+ return True
2161
+ if any(contains_map(spec) for spec in self._collection_types.values()):
2162
+ return True
2163
+ if any(
2164
+ contains_map(spec)
2165
+ for fields in self._udt_field_type_specs.values()
2166
+ for spec in fields.values()
2167
+ ):
2168
+ return True
2169
+ for specs in self._func_collection_types.values():
2170
+ if any(contains_map(spec) for spec in specs.values()):
2171
+ return True
2172
+ for fi in self._func_info_map.values():
2173
+ if any(contains_map(spec) for spec in fi.param_type_specs):
2174
+ return True
2175
+ if contains_map(getattr(fi, "return_type_spec", None)):
2176
+ return True
2177
+ for node in self._walk_ast(self.ctx.ast):
2178
+ if isinstance(node, FuncCall):
2179
+ _fn, namespace = self._resolve_callee(node.callee)
2180
+ if namespace == "map":
2181
+ return True
2182
+ if isinstance(node, VarDecl):
2183
+ spec = self._type_spec_from_hint_name(node.type_hint)
2184
+ if contains_map(spec):
2185
+ return True
2186
+ return False
2187
+
1231
2188
  # The type-inference helpers (_type_spec_*, _infer_type, _array_method_expr,
1232
2189
  # _map_method_expr, _template_args_from_call, ...) live on TypeInferer
1233
2190
  # — see codegen/types.py.
@@ -1262,6 +2219,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1262
2219
  # First, find all variables that are reassigned anywhere in the AST.
1263
2220
  # These cannot be inlined as constants since their value changes at runtime.
1264
2221
  reassigned = self._find_reassigned_vars()
2222
+ # Register never-reassigned ``var``/``varip`` scalars with a stable
2223
+ # init FIRST — a later derived var (or a UDF body) may reference such a
2224
+ # scalar as a stable length component, so it must be classified stable
2225
+ # before those exprs are evaluated below.
2226
+ self._collect_stable_var_scalars(reassigned)
1265
2227
  for stmt in self.ctx.ast.body:
1266
2228
  if isinstance(stmt, VarDecl) and stmt.name not in reassigned:
1267
2229
  self._collect_known_var(stmt)
@@ -1274,6 +2236,262 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1274
2236
  # dependent reassignments are left untracked (rejected by the guard).
1275
2237
  self._collect_reassigned_stable_scalars(reassigned)
1276
2238
 
2239
+ def _collect_stable_var_scalars(self, reassigned: set[str]) -> None:
2240
+ """Track top-level ``var``/``varip`` scalars declared exactly once (never
2241
+ reassigned) from a stable init expression.
2242
+
2243
+ A ``var`` scalar's one-shot initializer runs once and the value never
2244
+ changes across bars, so a never-reassigned ``var`` over a stable init
2245
+ (``var int _tfSec = timeframe.in_seconds()``) is a bar-invariant scalar —
2246
+ safe to embed in a TA ctor runtime-reset expression. Recording it in
2247
+ ``_derived_input_expr`` + ``_stable_runtime_vars`` lets the reset path
2248
+ expand the name and lets ``_expr_is_stable`` classify it (the
2249
+ ``_stable_runtime_vars`` check precedes the ``_var_names`` rejection).
2250
+
2251
+ A ``var`` that is reassigned anywhere (``:=`` in ``reassigned``), is a
2252
+ series var, or is initialized from a non-stable value stays untracked —
2253
+ so a TA length fed by genuinely-mutable persistent state is still
2254
+ rejected by the constructor guard. Names are NOT folded into
2255
+ ``_known_vars`` (no use-site inlining): only the length-analysis path is
2256
+ affected, and the ``var`` member still emits and initializes normally.
2257
+ """
2258
+ literal_candidates = self._admitted_stable_var_ctor_literals(reassigned)
2259
+
2260
+ for stmt in (self.ctx.ast.body or []):
2261
+ if not isinstance(stmt, VarDecl):
2262
+ continue
2263
+ if not (stmt.is_var or stmt.is_varip):
2264
+ continue
2265
+ literal_record = literal_candidates.get(stmt.name)
2266
+ if stmt.name in reassigned:
2267
+ continue
2268
+ if self._decl_binding_is_series(id(stmt), stmt.name):
2269
+ continue
2270
+ if stmt.value is None or not self._expr_is_stable(stmt.value):
2271
+ continue
2272
+ expr_str = self._arith_expr_to_str(stmt.value)
2273
+ if expr_str is None:
2274
+ continue
2275
+ self._derived_input_expr[stmt.name] = expr_str
2276
+ self._stable_runtime_vars.add(stmt.name)
2277
+ if literal_record is not None:
2278
+ self._stable_var_ctor_literals[stmt.name] = literal_record
2279
+ # Mark input-backed iff the init references an input, so the reset
2280
+ # emits override-aware get_input_*() reads for it.
2281
+ import re as _re
2282
+ toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", expr_str))
2283
+ if any(t in self._input_backed_vars for t in toks):
2284
+ self._input_backed_vars.add(stmt.name)
2285
+
2286
+ @staticmethod
2287
+ def _is_direct_int_var_literal(node) -> bool:
2288
+ """Whether ``node`` is the one literal persistent shape we admit."""
2289
+
2290
+ return (
2291
+ isinstance(node, VarDecl)
2292
+ and node.is_var
2293
+ and not node.is_varip
2294
+ and node.type_hint in (None, "int")
2295
+ and isinstance(node.value, NumberLiteral)
2296
+ and isinstance(node.value.value, int)
2297
+ and not isinstance(node.value.value, bool)
2298
+ )
2299
+
2300
+ @staticmethod
2301
+ def _walk_candidate_surface(root):
2302
+ """Yield every AST node reachable through compiler-owned metadata.
2303
+
2304
+ The regular codegen walker intentionally follows only historically
2305
+ emitted statement/expression slots. Literal admission is a safety
2306
+ proof and therefore needs the larger authored surface: loop bounds and
2307
+ iterables, enum/type defaults, function/method parameter defaults kept
2308
+ in ``annotations``, and any future AST-bearing metadata. ``vars()``
2309
+ plus identity de-duplication is both exhaustive and cycle-safe.
2310
+ """
2311
+
2312
+ seen: set[int] = set()
2313
+
2314
+ def walk(value):
2315
+ if value is None or isinstance(
2316
+ value, (str, bytes, int, float, bool)
2317
+ ):
2318
+ return
2319
+ value_id = id(value)
2320
+ if value_id in seen:
2321
+ return
2322
+ seen.add(value_id)
2323
+
2324
+ if isinstance(value, ASTNode):
2325
+ yield value
2326
+ if isinstance(value, dict):
2327
+ for key, child in value.items():
2328
+ yield from walk(key)
2329
+ yield from walk(child)
2330
+ return
2331
+ if isinstance(value, (list, tuple, set, frozenset)):
2332
+ for child in value:
2333
+ yield from walk(child)
2334
+ return
2335
+ if hasattr(value, "__dict__"):
2336
+ for child in vars(value).values():
2337
+ yield from walk(child)
2338
+
2339
+ yield from walk(root)
2340
+
2341
+ def _stable_var_literal_has_earlier_use(
2342
+ self,
2343
+ name: str,
2344
+ declaration_index: int,
2345
+ ) -> bool:
2346
+ """Whether authored code reads ``name`` before its global declaration."""
2347
+
2348
+ for root in (self.ctx.ast.body or [])[:declaration_index]:
2349
+ if any(
2350
+ isinstance(node, Identifier) and node.name == name
2351
+ for node in self._walk_candidate_surface(root)
2352
+ ):
2353
+ return True
2354
+ return False
2355
+
2356
+ def _candidate_surface_mentions_name(self, root, name: str) -> bool:
2357
+ return any(
2358
+ isinstance(node, Identifier) and node.name == name
2359
+ for node in self._walk_candidate_surface(root)
2360
+ )
2361
+
2362
+ def _stable_var_literal_has_unsupported_declaration(self) -> bool:
2363
+ """Whether authored metadata would require cross-boundary dataflow.
2364
+
2365
+ Stable-var constructor folding is deliberately declaration-local. A
2366
+ user function, method, type, or enum introduces defaults, aliases, or
2367
+ receiver effects that require a wider proof, so this narrow feature
2368
+ fails closed for the whole script instead of approximating that proof.
2369
+ """
2370
+
2371
+ return any(
2372
+ isinstance(node, (FuncDef, MethodDef, TypeDecl, EnumDecl))
2373
+ for node in self._walk_candidate_surface(self.ctx.ast)
2374
+ )
2375
+
2376
+ def _stable_var_literal_has_parse_recovery(self) -> bool:
2377
+ """Whether the parser discarded any authored source fragment.
2378
+
2379
+ Recovery intentionally keeps broad corpus transpilation moving, but a
2380
+ discarded declaration can hide aliases or override a builtin method.
2381
+ Literal admission therefore requires a lossless parse even though all
2382
+ existing non-admission codegen paths retain their recovery behavior.
2383
+ """
2384
+
2385
+ return bool(
2386
+ (self.ctx.ast.annotations or {}).get("parse_recovery_count", 0)
2387
+ )
2388
+
2389
+ def _stable_var_literal_has_unsupported_receiver_use(
2390
+ self,
2391
+ name: str,
2392
+ ) -> bool:
2393
+ """Catch primitive extension-method syntax dropped by the parser."""
2394
+
2395
+ roots: list[object] = [self.ctx.ast]
2396
+ roots.extend(
2397
+ pragma.expr_node
2398
+ for pragma in (getattr(self.ctx, "pf_trace_pragmas", None) or [])
2399
+ if getattr(pragma, "expr_node", None) is not None
2400
+ )
2401
+ for root in roots:
2402
+ for node in self._walk_candidate_surface(root):
2403
+ if (
2404
+ isinstance(node, FuncCall)
2405
+ and isinstance(node.callee, MemberAccess)
2406
+ and self._candidate_surface_mentions_name(
2407
+ node.callee.object, name
2408
+ )
2409
+ ):
2410
+ return True
2411
+ return False
2412
+
2413
+ def _stable_var_literal_has_history_use(self, name: str) -> bool:
2414
+ """Whether any authored or post-analyzer expression histories ``name``."""
2415
+
2416
+ roots: list[object] = [self.ctx.ast]
2417
+ roots.extend(
2418
+ pragma.expr_node
2419
+ for pragma in (getattr(self.ctx, "pf_trace_pragmas", None) or [])
2420
+ if getattr(pragma, "expr_node", None) is not None
2421
+ )
2422
+ for root in roots:
2423
+ for node in self._walk_candidate_surface(root):
2424
+ if not isinstance(node, Subscript):
2425
+ continue
2426
+ if any(
2427
+ isinstance(receiver_node, Identifier)
2428
+ and receiver_node.name == name
2429
+ for receiver_node in self._walk_candidate_surface(
2430
+ node.object
2431
+ )
2432
+ ):
2433
+ return True
2434
+ return False
2435
+
2436
+ def _admitted_stable_var_ctor_literals(
2437
+ self,
2438
+ reassigned: set[str],
2439
+ ) -> dict[str, _StableVarCtorLiteral]:
2440
+ """Return declaration-exact persistent literals safe for TA sizing."""
2441
+
2442
+ occurrences: dict[str, list[tuple[int, VarDecl]]] = {}
2443
+ for index, stmt in enumerate(self.ctx.ast.body or []):
2444
+ if self._is_direct_int_var_literal(stmt):
2445
+ occurrences.setdefault(stmt.name, []).append((index, stmt))
2446
+
2447
+ unique = {
2448
+ name: items[0]
2449
+ for name, items in occurrences.items()
2450
+ if len(items) == 1
2451
+ }
2452
+ if not unique:
2453
+ return {}
2454
+
2455
+ candidate_names = set(unique)
2456
+ binding_counts = {name: 0 for name in candidate_names}
2457
+ for node in self._walk_candidate_surface(self.ctx.ast):
2458
+ names: list[str] = []
2459
+ if isinstance(node, VarDecl) and node.name:
2460
+ names.append(node.name)
2461
+ elif isinstance(node, TupleAssign):
2462
+ names.extend(name for name in node.names if name != "_")
2463
+ elif isinstance(node, ForStmt) and node.var:
2464
+ names.append(node.var)
2465
+ elif isinstance(node, ForInStmt):
2466
+ if node.var:
2467
+ names.append(node.var)
2468
+ names.extend(name for name in (node.vars or []) if name != "_")
2469
+ if isinstance(node, (FuncDef, MethodDef)):
2470
+ names.extend(name for name in node.params if name != "_")
2471
+ for bound_name in names:
2472
+ if bound_name in binding_counts:
2473
+ binding_counts[bound_name] += 1
2474
+
2475
+ admitted: dict[str, _StableVarCtorLiteral] = {}
2476
+ for name, (index, declaration) in unique.items():
2477
+ if (
2478
+ name in reassigned
2479
+ or binding_counts.get(name) != 1
2480
+ or self._decl_binding_is_series(id(declaration), name)
2481
+ or self._stable_var_literal_has_earlier_use(name, index)
2482
+ or self._stable_var_literal_has_history_use(name)
2483
+ or self._stable_var_literal_has_unsupported_declaration()
2484
+ or self._stable_var_literal_has_parse_recovery()
2485
+ or self._stable_var_literal_has_unsupported_receiver_use(name)
2486
+ ):
2487
+ continue
2488
+ admitted[name] = _StableVarCtorLiteral(
2489
+ value=declaration.value.value,
2490
+ declaration_id=id(declaration),
2491
+ top_level_index=index,
2492
+ )
2493
+ return admitted
2494
+
1277
2495
  def _collect_reassigned_stable_scalars(self, reassigned: set[str]) -> None:
1278
2496
  """Track class-scope scalars that are reassigned but only along stable
1279
2497
  if/elif paths (see ``test_stable_reassigned_class_scope_length``).
@@ -1438,7 +2656,117 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1438
2656
  "ismonthly", "isdwm", "isseconds", "in_seconds", "isticks",
1439
2657
  })
1440
2658
 
1441
- def _expr_is_stable(self, node) -> bool:
2659
+ # Depth ceiling for inlining nested single-expression user functions while
2660
+ # classifying a TA length's stability. Pine forbids recursion, so any real
2661
+ # chain is shallow; the cap is a backstop against pathological input and is
2662
+ # enforced together with a name-stack cycle guard.
2663
+ _UDF_INLINE_MAX_DEPTH = 16
2664
+
2665
+ def _get_udf_def(self, name: str):
2666
+ """Return the top-level single-name ``FuncDef`` for ``name`` (or None).
2667
+
2668
+ Built lazily and cached. UDT ``MethodDef``s are intentionally excluded —
2669
+ only a free function can appear as a bare-name TA length call.
2670
+ """
2671
+ cache = getattr(self, "_udf_def_cache", None)
2672
+ if cache is None:
2673
+ cache = {}
2674
+ for stmt in (self.ctx.ast.body or []):
2675
+ if isinstance(stmt, FuncDef):
2676
+ # Last definition wins; a name map is all the length path needs.
2677
+ cache[stmt.name] = stmt
2678
+ self._udf_def_cache = cache
2679
+ return cache.get(name)
2680
+
2681
+ def _inline_single_expr_udf(self, node, _udf_stack: frozenset = frozenset(),
2682
+ _depth: int = 0):
2683
+ """If ``node`` is a call to a user-defined SINGLE-EXPRESSION function,
2684
+ return its body expression with each parameter substituted by the
2685
+ corresponding call-argument node. Returns None when the call is not such
2686
+ a function, the arity/kwargs do not match, the body is not a single
2687
+ expression, the body contains a shape we do not clone, or the call would
2688
+ recurse (cycle / depth-limit).
2689
+
2690
+ Purely structural: it does NOT judge stability (the caller does, via
2691
+ ``_expr_is_stable`` on the returned node). The conservative None keeps
2692
+ the TA-ctor guard intact.
2693
+ """
2694
+ if not isinstance(node, FuncCall):
2695
+ return None
2696
+ func_name, namespace = self._resolve_callee(node.callee)
2697
+ if namespace is not None or func_name is None:
2698
+ return None
2699
+ if func_name in _udf_stack or _depth >= self._UDF_INLINE_MAX_DEPTH:
2700
+ return None
2701
+ fdef = self._get_udf_def(func_name)
2702
+ if fdef is None:
2703
+ return None
2704
+ # A body that is exactly ONE expression statement is inlinable, whether
2705
+ # written inline after ``=>`` (``is_single_expr=True``) or as a one-line
2706
+ # indented block (``f(x) =>`` then a single indented expr, which the
2707
+ # parser records as ``is_single_expr=False`` with a one-ExprStmt body —
2708
+ # the gonzowiththewind-sisyphus ``f_bars`` shape). A multi-statement
2709
+ # body (len != 1, or a non-ExprStmt) is conservatively refused.
2710
+ body = fdef.body
2711
+ if not body or len(body) != 1 or not isinstance(body[0], ExprStmt):
2712
+ return None
2713
+ # Require a plain positional call: one arg per param, no kwargs, no
2714
+ # default-parameter fill-in — anything else is conservatively refused.
2715
+ if node.kwargs or len(node.args) != len(fdef.params):
2716
+ return None
2717
+ subst = dict(zip(fdef.params, node.args))
2718
+ return self._subst_params(body[0].expr, subst)
2719
+
2720
+ def _subst_params(self, node, subst: dict):
2721
+ """Return a copy of ``node`` with every ``Identifier`` whose name is a
2722
+ key in ``subst`` replaced by the mapped argument node. Returns None for
2723
+ any node outside the small arithmetic/call grammar we fold (an
2724
+ unrecognised construct — e.g. a history ``Subscript`` — conservatively
2725
+ aborts the inline). ``dataclasses.replace`` preserves ``loc``/
2726
+ ``annotations`` so diagnostics still point at real source spans."""
2727
+ import dataclasses as _dc
2728
+ if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral, NaLiteral)):
2729
+ return node
2730
+ if isinstance(node, Identifier):
2731
+ return subst.get(node.name, node)
2732
+ if isinstance(node, MemberAccess):
2733
+ obj = self._subst_params(node.object, subst)
2734
+ if obj is None:
2735
+ return None
2736
+ return _dc.replace(node, object=obj)
2737
+ if isinstance(node, Ternary):
2738
+ c = self._subst_params(node.condition, subst)
2739
+ t = self._subst_params(node.true_val, subst)
2740
+ f = self._subst_params(node.false_val, subst)
2741
+ if c is None or t is None or f is None:
2742
+ return None
2743
+ return _dc.replace(node, condition=c, true_val=t, false_val=f)
2744
+ if isinstance(node, BinOp):
2745
+ l = self._subst_params(node.left, subst)
2746
+ r = self._subst_params(node.right, subst)
2747
+ if l is None or r is None:
2748
+ return None
2749
+ return _dc.replace(node, left=l, right=r)
2750
+ if isinstance(node, UnaryOp):
2751
+ o = self._subst_params(node.operand, subst)
2752
+ if o is None:
2753
+ return None
2754
+ return _dc.replace(node, operand=o)
2755
+ if isinstance(node, FuncCall):
2756
+ if node.kwargs:
2757
+ return None
2758
+ new_args = []
2759
+ for a in node.args:
2760
+ sa = self._subst_params(a, subst)
2761
+ if sa is None:
2762
+ return None
2763
+ new_args.append(sa)
2764
+ return _dc.replace(node, args=new_args)
2765
+ # Subscript (history read) and anything else: not a stable-length shape.
2766
+ return None
2767
+
2768
+ def _expr_is_stable(self, node, _udf_stack: frozenset = frozenset(),
2769
+ _depth: int = 0) -> bool:
1442
2770
  """True iff ``node``'s value is a bar-invariant scalar.
1443
2771
 
1444
2772
  A stable expression depends only on: literals, ``input.*`` values,
@@ -1462,7 +2790,10 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1462
2790
  return True
1463
2791
  if isinstance(node, Identifier):
1464
2792
  name = node.name
1465
- if name in self._known_vars:
2793
+ if self._known_var_is_lexically_shadowed(name):
2794
+ return False
2795
+ if (name in self._known_vars
2796
+ and not self._known_var_is_lexically_shadowed(name)):
1466
2797
  return True
1467
2798
  if name in self._stable_runtime_vars:
1468
2799
  return True
@@ -1496,13 +2827,14 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1496
2827
  # History read (``close[1]``) or indexed access — per-bar.
1497
2828
  return False
1498
2829
  if isinstance(node, Ternary):
1499
- return (self._expr_is_stable(node.condition)
1500
- and self._expr_is_stable(node.true_val)
1501
- and self._expr_is_stable(node.false_val))
2830
+ return (self._expr_is_stable(node.condition, _udf_stack, _depth)
2831
+ and self._expr_is_stable(node.true_val, _udf_stack, _depth)
2832
+ and self._expr_is_stable(node.false_val, _udf_stack, _depth))
1502
2833
  if isinstance(node, BinOp):
1503
- return self._expr_is_stable(node.left) and self._expr_is_stable(node.right)
2834
+ return (self._expr_is_stable(node.left, _udf_stack, _depth)
2835
+ and self._expr_is_stable(node.right, _udf_stack, _depth))
1504
2836
  if isinstance(node, UnaryOp):
1505
- return self._expr_is_stable(node.operand)
2837
+ return self._expr_is_stable(node.operand, _udf_stack, _depth)
1506
2838
  if isinstance(node, FuncCall):
1507
2839
  func_name, namespace = self._resolve_callee(node.callee)
1508
2840
  if namespace == "ta":
@@ -1510,20 +2842,40 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1510
2842
  if namespace == "math":
1511
2843
  if func_name not in self._MATH_STABLE_MEMBERS:
1512
2844
  return False
1513
- return all(self._expr_is_stable(a) for a in node.args)
2845
+ return all(self._expr_is_stable(a, _udf_stack, _depth)
2846
+ for a in node.args)
1514
2847
  if namespace == "timeframe":
1515
2848
  # ``timeframe.in_seconds()`` (and any other function-form
1516
2849
  # timeframe member) is a stable per-run scalar — it reflects
1517
2850
  # the script's resolution, not a per-bar value.
1518
2851
  if func_name not in self._TF_STABLE_MEMBERS:
1519
2852
  return False
1520
- return all(self._expr_is_stable(a) for a in node.args)
2853
+ return all(self._expr_is_stable(a, _udf_stack, _depth)
2854
+ for a in node.args)
1521
2855
  if namespace == "input":
1522
2856
  return True
1523
2857
  if namespace is None and func_name in ("int", "float", "bool", "string"):
1524
- return all(self._expr_is_stable(a) for a in node.args)
1525
- # Any other call (user functions, str.*, array.*, ...) — series
1526
- # by default; the conservative answer keeps the guard honest.
2858
+ return all(self._expr_is_stable(a, _udf_stack, _depth)
2859
+ for a in node.args)
2860
+ # A user-defined single-expression function is stable iff every
2861
+ # argument is stable AND its body (with the params bound to those
2862
+ # args) is stable — i.e. the body references only stable scalars
2863
+ # (inputs / consts / timeframe.* / math.* / never-reassigned var
2864
+ # scalars) and no series / strategy state / ta.* results. Inlining
2865
+ # the body (params substituted by the arg nodes) lets the ordinary
2866
+ # classifier decide; the name-stack + depth guard refuses recursion
2867
+ # so a cyclic / malformed UDF is rejected, not looped forever.
2868
+ if namespace is None and func_name is not None:
2869
+ inlined = self._inline_single_expr_udf(node, _udf_stack, _depth)
2870
+ if inlined is not None:
2871
+ if not all(self._expr_is_stable(a, _udf_stack, _depth)
2872
+ for a in node.args):
2873
+ return False
2874
+ return self._expr_is_stable(
2875
+ inlined, _udf_stack | {func_name}, _depth + 1)
2876
+ # Any other call (multi-statement user functions, str.*, array.*,
2877
+ # ...) — series by default; the conservative answer keeps the guard
2878
+ # honest.
1527
2879
  return False
1528
2880
  return False
1529
2881
 
@@ -1536,22 +2888,28 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1536
2888
  # re-parsed and lowered through the expression visitor).
1537
2889
  _ATOMIC_ARITH_NODES = (NumberLiteral, Identifier, MemberAccess, FuncCall)
1538
2890
 
1539
- def _arith_operand_to_str(self, node) -> str | None:
2891
+ def _arith_operand_to_str(self, node, _udf_stack: frozenset = frozenset(),
2892
+ _depth: int = 0) -> str | None:
1540
2893
  """Serialize ``node`` for use as an operand: parenthesize it unless its
1541
2894
  serialized form is already self-delimiting, so grouping survives a
1542
2895
  round-trip through the parser."""
1543
- s = self._arith_expr_to_str(node)
2896
+ s = self._arith_expr_to_str(node, _udf_stack, _depth)
1544
2897
  if s is None:
1545
2898
  return None
1546
2899
  if isinstance(node, self._ATOMIC_ARITH_NODES):
1547
2900
  return s
1548
2901
  return f"({s})"
1549
2902
 
1550
- def _arith_expr_to_str(self, node) -> str | None:
2903
+ def _arith_expr_to_str(self, node, _udf_stack: frozenset = frozenset(),
2904
+ _depth: int = 0) -> str | None:
1551
2905
  """Render a numeric arithmetic-over-identifiers expression to a string
1552
2906
  that re-parses to the SAME tree (grouping preserved via
1553
2907
  ``_arith_operand_to_str``). Returns None for any node shape we don't
1554
2908
  fold (series subscripts, etc.) so the caller leaves the var untracked.
2909
+
2910
+ ``_udf_stack``/``_depth`` guard the single-expression-UDF inlining below
2911
+ against recursion cycles (Pine forbids recursion, but a malformed source
2912
+ must be refused, not looped forever).
1555
2913
  """
1556
2914
  if isinstance(node, NumberLiteral):
1557
2915
  v = node.value
@@ -1563,30 +2921,44 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1563
2921
  if isinstance(node, MemberAccess) and isinstance(node.object, Identifier):
1564
2922
  return f"{node.object.name}.{node.member}"
1565
2923
  if isinstance(node, BinOp):
1566
- l = self._arith_operand_to_str(node.left)
1567
- r = self._arith_operand_to_str(node.right)
2924
+ l = self._arith_operand_to_str(node.left, _udf_stack, _depth)
2925
+ r = self._arith_operand_to_str(node.right, _udf_stack, _depth)
1568
2926
  if l is None or r is None:
1569
2927
  return None
1570
2928
  return f"{l} {node.op} {r}"
1571
2929
  if isinstance(node, UnaryOp):
1572
- o = self._arith_operand_to_str(node.operand)
2930
+ o = self._arith_operand_to_str(node.operand, _udf_stack, _depth)
1573
2931
  if o is None:
1574
2932
  return None
1575
2933
  return f"{node.op}{o}"
1576
2934
  if isinstance(node, Ternary):
1577
- c = self._arith_operand_to_str(node.condition)
1578
- t = self._arith_operand_to_str(node.true_val)
1579
- f = self._arith_operand_to_str(node.false_val)
2935
+ c = self._arith_operand_to_str(node.condition, _udf_stack, _depth)
2936
+ t = self._arith_operand_to_str(node.true_val, _udf_stack, _depth)
2937
+ f = self._arith_operand_to_str(node.false_val, _udf_stack, _depth)
1580
2938
  if c is None or t is None or f is None:
1581
2939
  return None
1582
2940
  return f"{c} ? {t} : {f}"
1583
2941
  if isinstance(node, FuncCall):
1584
- callee = self._arith_expr_to_str(node.callee)
2942
+ # A bare-name call to a single-expression user function has no C++
2943
+ # counterpart at class scope — inline its body (params substituted
2944
+ # by the arg expressions) so the rendered string is pure
2945
+ # math/timeframe/input arithmetic the ctor-reset path can expand.
2946
+ # Namespaced calls (math.*/timeframe.*/int(...)) fall through to the
2947
+ # ordinary ``callee(args)`` rendering below. The stack/depth guard
2948
+ # refuses a recursive UDF (returns None -> caller leaves it untracked
2949
+ # -> the ctor guard rejects it loudly) instead of recursing forever.
2950
+ fn, ns = self._resolve_callee(node.callee)
2951
+ if ns is None and fn is not None and self._get_udf_def(fn) is not None:
2952
+ inlined = self._inline_single_expr_udf(node, _udf_stack, _depth)
2953
+ if inlined is None:
2954
+ return None
2955
+ return self._arith_expr_to_str(inlined, _udf_stack | {fn}, _depth + 1)
2956
+ callee = self._arith_expr_to_str(node.callee, _udf_stack, _depth)
1585
2957
  if callee is None:
1586
2958
  return None
1587
2959
  parts = []
1588
2960
  for a in node.args:
1589
- s = self._arith_expr_to_str(a)
2961
+ s = self._arith_expr_to_str(a, _udf_stack, _depth)
1590
2962
  if s is None:
1591
2963
  return None
1592
2964
  parts.append(s)
@@ -1596,7 +2968,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1596
2968
  def _collect_known_var(self, node: VarDecl) -> None:
1597
2969
  """Extract known constant value from a VarDecl."""
1598
2970
  # Don't inline series variables — their values change over time
1599
- if node.name in self.ctx.series_vars:
2971
+ if self._decl_binding_is_series(id(node), node.name):
1600
2972
  return
1601
2973
  # Don't inline var/varip variables — they're mutable state that persists
1602
2974
  # across bars and can be reassigned with :=
@@ -1834,29 +3206,145 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1834
3206
  for child in walk_nodes(fi.node):
1835
3207
  owner_by_node[id(child)] = fi.name
1836
3208
 
3209
+ # ``ctx.series_vars`` is a legacy raw-name union. A raw name can bind
3210
+ # both an exact Series declaration and an unrelated scalar declaration
3211
+ # (sibling blocks, or a callable local shadowing a global). Such calls
3212
+ # may need a synthetic history bridge even though the raw name appears
3213
+ # in that union, so reserve the bridge up front; source-order lexical
3214
+ # state decides whether the emitted call actually uses it.
3215
+ binding_series_states: dict[str, set[bool]] = {}
3216
+ var_metadata = getattr(
3217
+ self.ctx, "var_member_metadata_by_node", {}
3218
+ ) or {}
3219
+ for decl in walk_nodes(self.ctx.ast):
3220
+ if isinstance(decl, VarDecl):
3221
+ metadata = var_metadata.get(id(decl))
3222
+ if metadata is not None:
3223
+ exact_member = self._safe_name(metadata[1])
3224
+ is_series = exact_member in self._series_var_member_names
3225
+ else:
3226
+ is_series = self._decl_binding_is_series(
3227
+ id(decl), decl.name
3228
+ )
3229
+ binding_series_states.setdefault(decl.name, set()).add(
3230
+ is_series
3231
+ )
3232
+ elif isinstance(decl, TupleAssign):
3233
+ for name in decl.names:
3234
+ if name != "_":
3235
+ binding_series_states.setdefault(name, set()).add(
3236
+ self._decl_binding_is_series(id(decl), name)
3237
+ )
3238
+ elif isinstance(decl, ForStmt):
3239
+ if decl.var:
3240
+ binding_series_states.setdefault(decl.var, set()).add(
3241
+ False
3242
+ )
3243
+ elif isinstance(decl, ForInStmt):
3244
+ loop_names = (
3245
+ [decl.var]
3246
+ if decl.var
3247
+ else list(decl.vars or [])
3248
+ )
3249
+ for name in loop_names:
3250
+ if name and name != "_":
3251
+ binding_series_states.setdefault(name, set()).add(
3252
+ False
3253
+ )
3254
+ ambiguous_series_names = {
3255
+ name for name, states in binding_series_states.items()
3256
+ if len(states) > 1
3257
+ }
3258
+
3259
+ def register_one(
3260
+ kind: str,
3261
+ source_key: tuple,
3262
+ cpp_type: str,
3263
+ context: str | None,
3264
+ ) -> None:
3265
+ if cpp_type not in ("double", "int", "int64_t", "bool"):
3266
+ cpp_type = "double"
3267
+ key = (kind, *source_key, context)
3268
+ if key in self._inline_history_member_by_key:
3269
+ return
3270
+ counters[kind] += 1
3271
+ member_name = f"_{kind}_{counters[kind]}"
3272
+ self._inline_history_member_by_key[key] = member_name
3273
+ self._inline_history_members.append({
3274
+ "kind": kind,
3275
+ "member_name": member_name,
3276
+ "cpp_type": cpp_type,
3277
+ "context": context,
3278
+ })
3279
+
1837
3280
  def register(kind: str, source_key: tuple, cpp_type: str,
1838
3281
  owner: str | None) -> None:
1839
- if cpp_type not in ("double", "int", "bool"):
1840
- cpp_type = "double"
1841
3282
  for context in self._inline_history_contexts_for_owner(owner):
1842
- key = (kind, *source_key, context)
1843
- if key in self._inline_history_member_by_key:
1844
- continue
1845
- counters[kind] += 1
1846
- member_name = f"_{kind}_{counters[kind]}"
1847
- self._inline_history_member_by_key[key] = member_name
1848
- self._inline_history_members.append({
1849
- "kind": kind,
1850
- "member_name": member_name,
1851
- "cpp_type": cpp_type,
1852
- "context": context,
1853
- })
3283
+ register_one(kind, source_key, cpp_type, context)
1854
3284
 
1855
- def actual_args_for(call: FuncCall, params: list[str]) -> list:
3285
+ def owner_cs_for_context(
3286
+ owner: str | None, context: str | None
3287
+ ) -> int | None:
3288
+ if owner is None or context is None:
3289
+ return None
3290
+ prefix = f"{self._func_cpp_base_name(owner)}_cs"
3291
+ if not context.startswith(prefix):
3292
+ return None
3293
+ suffix = context[len(prefix):]
3294
+ return int(suffix) if suffix.isdigit() else None
3295
+
3296
+ def target_cs_for_context(
3297
+ fi,
3298
+ call: FuncCall,
3299
+ owner: str | None,
3300
+ context: str | None,
3301
+ ) -> int | None:
3302
+ owner_cs = owner_cs_for_context(owner, context)
3303
+ cs_info = self.ctx.func_call_cs_map.get(id(call))
3304
+ count = self.ctx.func_call_site_counts.get(fi.name, 0)
3305
+ if owner_cs is not None and (
3306
+ cs_info is not None or count > 1
3307
+ ):
3308
+ return owner_cs
3309
+ if cs_info is not None and cs_info[0] == fi.name:
3310
+ return cs_info[1]
3311
+ return None
3312
+
3313
+ def actual_args_for(call: FuncCall, params: list[str], method_info=None) -> list:
3314
+ if method_info is not None:
3315
+ return list(
3316
+ self._bind_typed_method_args(method_info, call).args_by_param
3317
+ )
1856
3318
  if call.kwargs:
1857
3319
  return _merge_kwargs(call.args, call.kwargs, params, lambda arg: arg)
1858
3320
  return list(call.args)
1859
3321
 
3322
+ def owner_lexical_specs(owner: str | None) -> dict[str, TypeSpec | None]:
3323
+ """Recover callable parameter types after analyzer scopes exit."""
3324
+ if owner is None:
3325
+ return {}
3326
+ info = self._func_info_map.get(owner)
3327
+ if info is None or info.node is None:
3328
+ return {}
3329
+ specs = list(getattr(info, "param_type_specs", ()) or ())
3330
+ lexical = {
3331
+ name: specs[index] if index < len(specs) else None
3332
+ for index, name in enumerate(info.node.params)
3333
+ }
3334
+ if (
3335
+ getattr(info, "is_udt_method", False)
3336
+ and info.node.params
3337
+ and info.udt_type_name
3338
+ ):
3339
+ receiver_spec = specs[0] if specs else None
3340
+ if receiver_spec is None:
3341
+ receiver_spec = self._type_spec_from_hint_name(
3342
+ info.udt_type_name
3343
+ )
3344
+ lexical[info.node.params[0]] = receiver_spec
3345
+ lexical.update(self._func_collection_types.get(owner, {}))
3346
+ return lexical
3347
+
1860
3348
  for node in walk_nodes(self.ctx.ast):
1861
3349
  owner = owner_by_node.get(id(node))
1862
3350
  if isinstance(node, Subscript) and isinstance(node.object, FuncCall):
@@ -1866,8 +3354,28 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1866
3354
 
1867
3355
  if not isinstance(node, FuncCall):
1868
3356
  continue
1869
- func_name, _ = self._resolve_callee(node.callee)
1870
- fi = self._func_info_map.get(func_name)
3357
+ fi = None
3358
+ method_call = False
3359
+ if isinstance(node.callee, MemberAccess):
3360
+ # Method syntax is type-directed. Resolve it before considering
3361
+ # any same-named bare UDF, and use the AST owner's lexical
3362
+ # parameter types: analyzer symbol scopes no longer exist in
3363
+ # this source-order prepass.
3364
+ receiver_spec = self._map_effect_type_spec(
3365
+ node.callee.object, owner_lexical_specs(owner)
3366
+ )
3367
+ receiver_name = method_receiver_type_name(receiver_spec)
3368
+ if receiver_name is not None:
3369
+ method_key = f"{receiver_name}.{node.callee.member}"
3370
+ candidate = self._func_info_map.get(method_key)
3371
+ if (
3372
+ candidate is not None
3373
+ and getattr(candidate, "is_udt_method", False)
3374
+ ):
3375
+ fi = candidate
3376
+ method_call = True
3377
+ elif isinstance(node.callee, Identifier):
3378
+ fi = self._func_info_map.get(node.callee.name)
1871
3379
  if fi is None or fi.node is None:
1872
3380
  continue
1873
3381
  func_sv = self.ctx.func_series_vars.get(fi.name, set())
@@ -1876,18 +3384,72 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1876
3384
  }
1877
3385
  if not series_param_indices:
1878
3386
  continue
1879
- args = actual_args_for(node, list(fi.node.params))
3387
+ if method_call:
3388
+ args = [
3389
+ node.callee.object,
3390
+ *actual_args_for(
3391
+ node, list(fi.node.params[1:]), method_info=fi
3392
+ ),
3393
+ ]
3394
+ else:
3395
+ args = actual_args_for(node, list(fi.node.params))
1880
3396
  for idx, arg in enumerate(args):
1881
3397
  if idx not in series_param_indices:
1882
3398
  continue
1883
- if isinstance(arg, Identifier):
1884
- if arg.name in BAR_FIELDS or arg.name in BAR_SERIES_PUSH:
1885
- continue
1886
- if arg.name in self.ctx.series_vars:
1887
- continue
1888
- register(
1889
- "series_arg", (id(node), idx), self._infer_type(arg), owner
1890
- )
3399
+ for context in self._inline_history_contexts_for_owner(owner):
3400
+ target_cs = target_cs_for_context(
3401
+ fi, node, owner, context
3402
+ )
3403
+ expected_cpp_type = self._series_param_element_cpp_type(
3404
+ fi, idx, target_cs
3405
+ )
3406
+ needs_bridge = True
3407
+ if isinstance(arg, Identifier):
3408
+ if (
3409
+ (
3410
+ arg.name in BAR_FIELDS
3411
+ or arg.name in BAR_SERIES_PUSH
3412
+ )
3413
+ and expected_cpp_type == "double"
3414
+ ):
3415
+ needs_bridge = False
3416
+ owner_info = self._func_info_map.get(owner or "")
3417
+ owner_series_cpp_type = None
3418
+ if (
3419
+ owner_info is not None
3420
+ and owner_info.node is not None
3421
+ and arg.name in owner_info.node.params
3422
+ and arg.name in self.ctx.func_series_vars.get(
3423
+ owner_info.name, set()
3424
+ )
3425
+ ):
3426
+ owner_index = owner_info.node.params.index(
3427
+ arg.name
3428
+ )
3429
+ owner_series_cpp_type = (
3430
+ self._series_param_element_cpp_type(
3431
+ owner_info,
3432
+ owner_index,
3433
+ owner_cs_for_context(owner, context),
3434
+ )
3435
+ )
3436
+ if owner_series_cpp_type == expected_cpp_type:
3437
+ needs_bridge = False
3438
+ if (
3439
+ owner_series_cpp_type is None
3440
+ and arg.name in self.ctx.series_vars
3441
+ and arg.name not in ambiguous_series_names
3442
+ and self._series_type_for(arg.name)
3443
+ == expected_cpp_type
3444
+ ):
3445
+ needs_bridge = False
3446
+ if needs_bridge:
3447
+ register_one(
3448
+ "series_arg",
3449
+ (id(node), idx),
3450
+ expected_cpp_type,
3451
+ context,
3452
+ )
1891
3453
 
1892
3454
  def _inline_history_member(self, kind: str, node: ASTNode,
1893
3455
  arg_idx: int | None = None) -> str:
@@ -1902,12 +3464,153 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1902
3464
  )
1903
3465
  return member
1904
3466
 
3467
+ @staticmethod
3468
+ def _allocate_generated_cpp_name(preferred: str, used: set[str]) -> str:
3469
+ """Reserve one deterministic generated C++ identifier.
3470
+
3471
+ Pine permits identifiers beginning with underscores, so generated
3472
+ support names cannot rely on a magic prefix being unavailable to the
3473
+ author. Preserve the historical spelling when it is free and append
3474
+ a stable suffix only for an actual collision.
3475
+ """
3476
+ candidate = preferred
3477
+ suffix = 2
3478
+ while candidate in used:
3479
+ candidate = f"{preferred}__pf{suffix}"
3480
+ suffix += 1
3481
+ used.add(candidate)
3482
+ return candidate
3483
+
3484
+ def _prepare_udt_generated_names(self) -> None:
3485
+ """Allocate UDT support symbols outside every authored namespace.
3486
+
3487
+ Record/trait/template names live at generated-file scope, while arena
3488
+ instances live in ``GeneratedStrategy``. This pass runs only after
3489
+ callable clones, declaration-site flags and inline-history members have
3490
+ their final names. Keep one complete class-scope inventory so adding a
3491
+ UDT member cannot make ``_func_safe_name`` change after those prepasses,
3492
+ then extend the file-scope inventory with the same names because an
3493
+ unqualified class member, parameter or local can hide a generated
3494
+ support type. In the ordinary non-colliding case the preferred names
3495
+ remain byte-for-byte unchanged.
3496
+ """
3497
+ class_used: set[str] = set(
3498
+ getattr(self, "_runtime_var_init_flag_used_names", ())
3499
+ )
3500
+ class_used.update(self._all_member_names)
3501
+ class_used.update(
3502
+ self._safe_name(name) for name in self._all_bound_names
3503
+ )
3504
+ class_used.update(
3505
+ self._safe_name(name)
3506
+ for name, _ptype in self.ctx.global_var_decls
3507
+ )
3508
+ for func_info in self.ctx.func_infos:
3509
+ node = getattr(func_info, "node", None)
3510
+ if node is not None:
3511
+ class_used.update(
3512
+ self._safe_name(name)
3513
+ for name in (getattr(node, "params", ()) or ())
3514
+ )
3515
+
3516
+ # These generated members are not all recorded in
3517
+ # ``_runtime_var_init_flag_used_names``. Reserve them explicitly so
3518
+ # the inventory remains authoritative even for scripts without a
3519
+ # runtime scalar ``var`` initializer.
3520
+ class_used.update(
3521
+ remapped
3522
+ for remap in self._func_cs_ta_remap.values()
3523
+ for remapped in remap.values()
3524
+ )
3525
+ class_used.update(
3526
+ site.member_name
3527
+ for index, site in enumerate(self.ctx.ta_call_sites)
3528
+ if index not in self._dead_ta_indices
3529
+ )
3530
+ class_used.update(
3531
+ f"_precalc_{site.member_name}"
3532
+ for index, site in enumerate(self.ctx.ta_call_sites)
3533
+ if index not in self._dead_ta_indices
3534
+ and self._ta_site_uses_precalc(site)
3535
+ )
3536
+ class_used.update(
3537
+ site.member_name
3538
+ for index, site in enumerate(self.ctx.fixnan_sites)
3539
+ if index not in self._dead_fixnan_indices
3540
+ )
3541
+ class_used.update(
3542
+ info["member_name"] for info in self._inline_history_members
3543
+ )
3544
+ class_used.update(
3545
+ variant["member_name"]
3546
+ for info in self._security_eval_info
3547
+ for variants in (info.get("ta_variants") or {}).values()
3548
+ for variant in variants
3549
+ )
3550
+ for instance in self._fresh_instances:
3551
+ class_used.add(instance["name"])
3552
+ for remap_name in ("ta_remap", "var_remap", "fixnan_remap"):
3553
+ class_used.update(instance.get(remap_name, {}).values())
3554
+
3555
+ global_used: set[str] = set(self._udt_defs)
3556
+ global_used.update(self._enum_defs)
3557
+ global_used.update(
3558
+ f"{enum_name}_{member}"
3559
+ for enum_name, members in self._enum_defs.items()
3560
+ for member in members
3561
+ )
3562
+ global_used.update(
3563
+ f"{enum_name}_str_values"
3564
+ for enum_name, members in self._enum_defs.items()
3565
+ if self._enum_member_strings.get(enum_name)
3566
+ and len(self._enum_member_strings[enum_name]) == len(members)
3567
+ )
3568
+ global_used.update(class_used)
3569
+
3570
+ self._udt_arena_template_cpp_name = self._allocate_generated_cpp_name(
3571
+ "_PFUdtArena", global_used
3572
+ )
3573
+ self._udt_undo_coordinator_cpp_name = self._allocate_generated_cpp_name(
3574
+ "_PFUdtUndoCoordinator", global_used
3575
+ )
3576
+ self._checkpoint_traits_cpp_name = self._allocate_generated_cpp_name(
3577
+ "_PFCheckpointTraits", global_used
3578
+ )
3579
+ self._udt_record_cpp_names: dict[str, str] = {}
3580
+ for type_name in self._udt_defs:
3581
+ self._udt_record_cpp_names[type_name] = (
3582
+ self._allocate_generated_cpp_name(
3583
+ f"_PFUdtRecord_{type_name}", global_used
3584
+ )
3585
+ )
3586
+
3587
+ member_used = set(class_used)
3588
+ self._udt_undo_coordinator_member_name = self._allocate_generated_cpp_name(
3589
+ "_pf_udt_undo", member_used
3590
+ )
3591
+ self._all_member_names.add(self._udt_undo_coordinator_member_name)
3592
+ self._udt_arena_member_names: dict[str, str] = {}
3593
+ for type_name in self._udt_defs:
3594
+ preferred = f"_pf_udt_{self._safe_name(type_name)}"
3595
+ allocated = self._allocate_generated_cpp_name(
3596
+ preferred, member_used
3597
+ )
3598
+ self._udt_arena_member_names[type_name] = allocated
3599
+ self._all_member_names.add(allocated)
3600
+
1905
3601
  def generate(self) -> str:
1906
3602
  """Generate C++ source from the AnalyzerContext."""
1907
3603
  # Context-sensitive instance pre-pass (needs the naming helpers populated
1908
3604
  # in __init__). Computes nested stateful-helper dispatch + fresh instances.
1909
3605
  self._build_func_instances()
3606
+ # Allocate declaration-site once flags only after every natural and
3607
+ # context-sensitive callable-state member name is known.
3608
+ self._prepare_runtime_scalar_var_initializers()
1910
3609
  self._prepare_inline_history_members()
3610
+ # UDT support names are the final generated-name category. They must
3611
+ # suffix around every authored/callable identifier already frozen by
3612
+ # the passes above, never force an earlier callable to rename itself.
3613
+ self._prepare_udt_generated_names()
1911
3614
  # Pre-scan for strategy series vars
1912
3615
  self._prescan_strategy_series()
1913
3616
  self._security_ohlc_hist_fields_by_sec: dict[int, set[str]] = {}
@@ -1917,6 +3620,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1917
3620
  # request.security helper-call results read at a history offset
1918
3621
  # (``myHelper()[k]``). Maps (sec_id, node-id) -> backing Series metadata.
1919
3622
  self._security_expr_hist_by_node: dict[tuple[int, int], dict] = {}
3623
+ self._prepare_lazy_saturated_roc3_sites()
1920
3624
 
1921
3625
  lines: list[str] = []
1922
3626
 
@@ -1927,14 +3631,197 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1927
3631
  # 1. Includes
1928
3632
  self._emit_includes(lines)
1929
3633
 
1930
- # 1b. UDT structs
1931
- # Drawing field names per struct are pre-computed in __init__ as
1932
- # ``self._udt_omitted_fields`` so visit_expr / visit_stmt can
1933
- # consult the same map. Drawing types: label, line, box, table,
1934
- # linefill, polyline, chart.point. These have no backtest runtime
1935
- # representation in PineForge — see pineforge-codegen issue #10.
1936
- for type_name, fields in self._udt_defs.items():
3634
+ # 1b. User-defined object handles and backing records.
3635
+ #
3636
+ # Pine UDT values are object IDs: ordinary assignment aliases the same
3637
+ # object, while ``copy()`` allocates one detached outer object and keeps
3638
+ # nested UDT/map/matrix IDs shallow-shared. Direct array-valued fields
3639
+ # still use std::vector value storage, so their built-in UDT copy path
3640
+ # is rejected explicitly until arrays gain handle identity. Emit every
3641
+ # authored type as a small nullable handle first, then its value record.
3642
+ # The two-phase order also permits self/nested UDT fields without
3643
+ # embedding a C++ type recursively by value.
3644
+ for type_name in self._udt_defs:
1937
3645
  lines.append(f"struct {type_name} {{")
3646
+ lines.append(" int32_t __pf_id = -1;")
3647
+ lines.append("};")
3648
+ lines.append(
3649
+ f"inline bool is_na(const {type_name}& _z) "
3650
+ "{ return _z.__pf_id < 0; }"
3651
+ )
3652
+ lines.append("")
3653
+
3654
+ if self._udt_defs:
3655
+ lines.extend([
3656
+ "template <typename _PFValue>",
3657
+ f"struct {self._checkpoint_traits_cpp_name};",
3658
+ "",
3659
+ f"class {self._udt_undo_coordinator_cpp_name} {{",
3660
+ " std::vector<std::function<void()>> _pf_undo_;",
3661
+ " uint64_t _pf_generation_ = 0;",
3662
+ " bool _pf_active_ = false;",
3663
+ "public:",
3664
+ " struct Snapshot { uint64_t generation; };",
3665
+ f" {self._udt_undo_coordinator_cpp_name}() = default;",
3666
+ f" {self._udt_undo_coordinator_cpp_name}(",
3667
+ f" const {self._udt_undo_coordinator_cpp_name}&) = delete;",
3668
+ f" {self._udt_undo_coordinator_cpp_name}& operator=(",
3669
+ f" const {self._udt_undo_coordinator_cpp_name}&) = delete;",
3670
+ f" {self._udt_undo_coordinator_cpp_name}(",
3671
+ f" {self._udt_undo_coordinator_cpp_name}&&) = delete;",
3672
+ f" {self._udt_undo_coordinator_cpp_name}& operator=(",
3673
+ f" {self._udt_undo_coordinator_cpp_name}&&) = delete;",
3674
+ "",
3675
+ " Snapshot snapshot() {",
3676
+ " if (_pf_generation_ == std::numeric_limits<uint64_t>::max()) {",
3677
+ ' throw std::overflow_error("UDT checkpoint generation exhausted");',
3678
+ " }",
3679
+ " ++_pf_generation_;",
3680
+ " _pf_undo_.clear();",
3681
+ " _pf_active_ = true;",
3682
+ " return Snapshot{_pf_generation_};",
3683
+ " }",
3684
+ " uint64_t generation() const { return _pf_generation_; }",
3685
+ " bool active() const { return _pf_active_; }",
3686
+ " bool empty() const { return _pf_undo_.empty(); }",
3687
+ " void record(uint64_t generation, std::function<void()> undo) {",
3688
+ " if (!_pf_active_ || generation != _pf_generation_) {",
3689
+ ' throw std::runtime_error("invalid UDT undo generation");',
3690
+ " }",
3691
+ " _pf_undo_.push_back(std::move(undo));",
3692
+ " }",
3693
+ " void restore(const Snapshot& snapshot) {",
3694
+ " if (!_pf_active_ || snapshot.generation != _pf_generation_) {",
3695
+ ' throw std::runtime_error("invalid UDT coordinator checkpoint token");',
3696
+ " }",
3697
+ " for (auto entry = _pf_undo_.rbegin();",
3698
+ " entry != _pf_undo_.rend(); ++entry) (*entry)();",
3699
+ " _pf_undo_.clear();",
3700
+ " }",
3701
+ "};",
3702
+ "",
3703
+ "template <typename _PFHandle, typename _PFRecord>",
3704
+ f"class {self._udt_arena_template_cpp_name} {{",
3705
+ f" using _PFRecordTraits = {self._checkpoint_traits_cpp_name}<_PFRecord>;",
3706
+ " using _PFRecordSnapshot = typename _PFRecordTraits::snapshot_type;",
3707
+ " struct _PFSlot {",
3708
+ " _PFRecord value;",
3709
+ " uint64_t logged_generation = 0;",
3710
+ " };",
3711
+ " std::deque<_PFSlot> _pf_records_;",
3712
+ f" {self._udt_undo_coordinator_cpp_name}* _pf_coordinator_;",
3713
+ " std::size_t _pf_checkpoint_size_ = 0;",
3714
+ " uint64_t _pf_checkpoint_generation_ = 0;",
3715
+ " bool _pf_checkpoint_active_ = false;",
3716
+ "",
3717
+ " void capture(std::size_t index) {",
3718
+ " if (!_pf_checkpoint_active_) return;",
3719
+ " auto& slot = _pf_records_.at(index);",
3720
+ " if (slot.logged_generation",
3721
+ " == _pf_checkpoint_generation_) return;",
3722
+ " auto snapshot = _PFRecordTraits::take(slot.value);",
3723
+ " _pf_coordinator_->record(_pf_checkpoint_generation_,",
3724
+ " [this, index, snapshot = std::move(snapshot)]() mutable {",
3725
+ " auto& restore_slot = _pf_records_.at(index);",
3726
+ " _PFRecordTraits::restore(restore_slot.value, snapshot);",
3727
+ " restore_slot.logged_generation = 0;",
3728
+ " });",
3729
+ " slot.logged_generation = _pf_checkpoint_generation_;",
3730
+ " }",
3731
+ "public:",
3732
+ " struct Snapshot {",
3733
+ " uint64_t generation;",
3734
+ " std::size_t size;",
3735
+ " };",
3736
+ "",
3737
+ f" explicit {self._udt_arena_template_cpp_name}(",
3738
+ f" {self._udt_undo_coordinator_cpp_name}* coordinator)",
3739
+ " : _pf_coordinator_(coordinator) {",
3740
+ " if (!_pf_coordinator_)",
3741
+ ' throw std::invalid_argument("UDT arena requires undo coordinator");',
3742
+ " }",
3743
+ f" {self._udt_arena_template_cpp_name}(",
3744
+ f" const {self._udt_arena_template_cpp_name}&) = delete;",
3745
+ f" {self._udt_arena_template_cpp_name}& operator=(",
3746
+ f" const {self._udt_arena_template_cpp_name}&) = delete;",
3747
+ f" {self._udt_arena_template_cpp_name}(",
3748
+ f" {self._udt_arena_template_cpp_name}&&) = delete;",
3749
+ f" {self._udt_arena_template_cpp_name}& operator=(",
3750
+ f" {self._udt_arena_template_cpp_name}&&) = delete;",
3751
+ "",
3752
+ " _PFHandle create(_PFRecord value) {",
3753
+ " if (_pf_records_.size() > static_cast<std::size_t>(",
3754
+ " std::numeric_limits<int32_t>::max())) {",
3755
+ ' throw std::length_error("UDT object-ID capacity exceeded");',
3756
+ " }",
3757
+ " const auto id = static_cast<int32_t>(_pf_records_.size());",
3758
+ " _pf_records_.push_back(_PFSlot{std::move(value), 0});",
3759
+ " return _PFHandle{id};",
3760
+ " }",
3761
+ " _PFHandle copy(_PFHandle value) {",
3762
+ " return create(static_cast<const "
3763
+ f"{self._udt_arena_template_cpp_name}&>(*this).get(value));",
3764
+ " }",
3765
+ " _PFRecord& get(_PFHandle value) {",
3766
+ " if (value.__pf_id < 0",
3767
+ " || static_cast<std::size_t>(value.__pf_id) >= _pf_records_.size()) {",
3768
+ ' throw std::runtime_error("UDT access on na or invalid object ID");',
3769
+ " }",
3770
+ " const auto index = static_cast<std::size_t>(value.__pf_id);",
3771
+ " capture(index);",
3772
+ " return _pf_records_[index].value;",
3773
+ " }",
3774
+ " const _PFRecord& get(_PFHandle value) const {",
3775
+ " if (value.__pf_id < 0",
3776
+ " || static_cast<std::size_t>(value.__pf_id) >= _pf_records_.size()) {",
3777
+ ' throw std::runtime_error("UDT access on na or invalid object ID");',
3778
+ " }",
3779
+ " return _pf_records_[static_cast<std::size_t>(value.__pf_id)].value;",
3780
+ " }",
3781
+ " const _PFRecord& read(_PFHandle value) const {",
3782
+ " return get(value);",
3783
+ " }",
3784
+ " std::size_t size() const { return _pf_records_.size(); }",
3785
+ " _PFRecord& record_at(std::size_t index) {",
3786
+ " capture(index);",
3787
+ " return _pf_records_.at(index).value;",
3788
+ " }",
3789
+ " const _PFRecord& record_at(std::size_t index) const {",
3790
+ " return _pf_records_.at(index).value;",
3791
+ " }",
3792
+ " Snapshot snapshot() {",
3793
+ " if (!_pf_coordinator_->active()) {",
3794
+ ' throw std::runtime_error("UDT coordinator checkpoint is not active");',
3795
+ " }",
3796
+ " _pf_checkpoint_generation_ = _pf_coordinator_->generation();",
3797
+ " _pf_checkpoint_size_ = _pf_records_.size();",
3798
+ " _pf_checkpoint_active_ = true;",
3799
+ " return Snapshot{_pf_checkpoint_generation_,",
3800
+ " _pf_checkpoint_size_};",
3801
+ " }",
3802
+ " void restore(const Snapshot& snapshot) {",
3803
+ " if (!_pf_checkpoint_active_",
3804
+ " || snapshot.generation != _pf_checkpoint_generation_",
3805
+ " || snapshot.generation != _pf_coordinator_->generation()",
3806
+ " || snapshot.size != _pf_checkpoint_size_",
3807
+ " || _pf_records_.size() < snapshot.size",
3808
+ " || !_pf_coordinator_->empty()) {",
3809
+ ' throw std::runtime_error("invalid UDT checkpoint token");',
3810
+ " }",
3811
+ " _pf_records_.resize(snapshot.size);",
3812
+ " }",
3813
+ "};",
3814
+ "",
3815
+ ])
3816
+
3817
+ # Drawing field names per record are pre-computed in __init__ as
3818
+ # ``self._udt_omitted_fields`` so visit_expr / visit_stmt can consult
3819
+ # the same map. Authored field defaults are evaluated by ``Type.new``
3820
+ # at the call site; record declarations use only inert typed defaults so
3821
+ # bar/TA expressions never leak into namespace-scope C++ initializers.
3822
+ for type_name, fields in self._udt_defs.items():
3823
+ record_type = self._udt_record_cpp_type(type_name)
3824
+ lines.append(f"struct {record_type} {{")
1938
3825
  field_specs = self._udt_field_type_specs.get(type_name, {})
1939
3826
  omitted = self._udt_omitted_fields.get(type_name, set())
1940
3827
  for f in fields:
@@ -1942,28 +3829,16 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1942
3829
  continue
1943
3830
  spec = field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name)
1944
3831
  cpp_type = self._type_spec_to_cpp(spec)
1945
- # Pine ``int`` is 64-bit (it routinely holds UNIX-ms timestamps
1946
- # and large bar indices); emit UDT int fields as ``int64_t`` so a
1947
- # field initialised from ``time``/``current_bar_.timestamp`` does
1948
- # not truncate / narrow-init.
1949
3832
  if cpp_type == "int":
1950
3833
  cpp_type = "int64_t"
1951
- if f.default:
1952
- default = self._visit_expr(f.default)
1953
- else:
1954
- default = self._default_for_spec(spec)
3834
+ default = self._default_for_spec(spec)
1955
3835
  lines.append(f" {cpp_type} {f.name} = {default};")
1956
- # NA sentinel (always the last data member). A default-constructed
1957
- # UDT - ``var T x = na``, an array fill slot, ``T.copy()`` no-arg -
1958
- # is na; the ``T.new(...)`` lowering sets this false. This lets
1959
- # ``na(udtVar)`` lower to the ``is_na(const T&)`` overload below
1960
- # instead of failing because no ``is_na`` accepts a struct.
1961
- lines.append(f" bool __pf_na = true;")
1962
- lines.append(f" static {type_name} create() {{ return {type_name}{{}}; }}")
1963
3836
  lines.append("};")
1964
- lines.append(f"inline bool is_na(const {type_name}& _z) {{ return _z.__pf_na; }}")
1965
3837
  lines.append("")
1966
3838
 
3839
+ if self._uses_map or self._uses_matrix or self._udt_defs:
3840
+ self._emit_handle_checkpoint_traits(lines)
3841
+
1967
3842
  # 1c. Enum constants + string tables for str.tostring(enumVar)
1968
3843
  for enum_name, members in self._enum_defs.items():
1969
3844
  for i, member in enumerate(members):
@@ -1978,15 +3853,41 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
1978
3853
  )
1979
3854
  lines.append("")
1980
3855
 
3856
+ # Source-shaped lazy ROC call clocks are generated support types, not
3857
+ # script state themselves. Their per-callsite instances are declared
3858
+ # below inside GeneratedStrategy and therefore join the automatic COOF
3859
+ # checkpoint inventory.
3860
+ self._emit_lazy_saturated_roc3_helper(lines)
3861
+
1981
3862
  # 2. Open class
1982
3863
  lines.append("class GeneratedStrategy : public BacktestEngine {")
1983
3864
  lines.append("public:")
1984
3865
  _script_state_decl_start = len(lines)
3866
+
3867
+ # One strategy-local undo coordinator gives every UDT arena a single
3868
+ # reverse first-touch order. This is required when records from distinct
3869
+ # authored UDT types shallow-share the same map or matrix identity.
3870
+ if self._udt_defs:
3871
+ lines.append(
3872
+ f" {self._udt_undo_coordinator_cpp_name} "
3873
+ f"{self._udt_undo_coordinator_member_name};"
3874
+ )
3875
+
3876
+ # Per-type object stores precede every handle-bearing script member.
3877
+ # COOF rollback therefore replays the coordinator, truncates arenas,
3878
+ # then rebinds ordinary variables, arrays and matrices in that order.
3879
+ for type_name in self._udt_defs:
3880
+ lines.append(
3881
+ f" {self._udt_arena_cpp_type(type_name)} "
3882
+ f"{self._udt_arena_member_name(type_name)}"
3883
+ f"{{&{self._udt_undo_coordinator_member_name}}};"
3884
+ )
1985
3885
 
1986
3886
  # request.security state
1987
3887
  for item in self._security_calls:
1988
3888
  sec_id = item["sec_id"]
1989
3889
  expr_node = item["expr_node"]
3890
+ self._validate_security_persistent_var_control_flow(expr_node)
1990
3891
  returns_tuple = item.get("returns_tuple", False)
1991
3892
  tuple_size = item.get("tuple_size", 0)
1992
3893
  if item.get("is_lower_tf_array"):
@@ -2036,15 +3937,33 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2036
3937
  if ctype == "std::vector<double>":
2037
3938
  lines.append(f" {ctype} _req_sec_{sec_id}_{i}{{}};")
2038
3939
  else:
2039
- lines.append(f" {ctype} _req_sec_{sec_id}_{i} = na<double>();")
3940
+ default = {
3941
+ "double": "na<double>()",
3942
+ "bool": "false",
3943
+ "int": "0",
3944
+ "std::string": 'std::string("")',
3945
+ }.get(ctype, self._default_for_type(ctype))
3946
+ lines.append(
3947
+ f" {ctype} _req_sec_{sec_id}_{i} = {default};"
3948
+ )
2040
3949
  elif returns_tuple and tuple_size and tuple_size > 0:
2041
3950
  self._security_ohlc_hist_fields_by_sec[sec_id] = (
2042
3951
  self._collect_security_ohlc_hist_fields_for_call(item)
2043
3952
  )
2044
3953
  site = self._get_ta_site(expr_node)
2045
3954
  ta_name = self._ta_name_from_site(site) if site is not None else ""
2046
- ctype = TA_TUPLE_RESULT_TYPES.get(ta_name, "std::tuple<double, double>")
2047
- default = self._security_tuple_result_default(ctype, tuple_size)
3955
+ ctype = TA_TUPLE_RESULT_TYPES.get(
3956
+ ta_name,
3957
+ self._security_helper_tuple_cpp_type(
3958
+ tuple_size,
3959
+ item.get("tuple_element_types", ()),
3960
+ ),
3961
+ )
3962
+ default = self._security_tuple_result_default(
3963
+ ctype,
3964
+ tuple_size,
3965
+ item.get("tuple_element_types", ()),
3966
+ )
2048
3967
  lines.append(f" {ctype} _req_sec_{sec_id} = {default};")
2049
3968
  else:
2050
3969
  self._security_ohlc_hist_fields_by_sec[sec_id] = (
@@ -2094,6 +4013,21 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2094
4013
  lines.append(f" std::vector<{vtype}> _precalc_{site.member_name};")
2095
4014
  lines.append(" bool _use_precalc = false;")
2096
4015
 
4016
+ for clock_name in self._lazy_saturated_roc3_clock_by_node.values():
4017
+ lines.append(
4018
+ f" {self._lazy_saturated_roc3_type_name} {clock_name};"
4019
+ )
4020
+ if self._lazy_saturated_roc3_clock_by_node:
4021
+ # Dedicated eager close[3] fallback. Its fixed four-slot capacity
4022
+ # is independent of the user's max_bars_back directive, which may
4023
+ # legitimately be smaller than the offset this generated route
4024
+ # requires. It is ordinary copyable script state and therefore
4025
+ # joins the automatic COOF checkpoint below.
4026
+ lines.append(
4027
+ " Series<double> "
4028
+ f"{self._lazy_saturated_roc3_history_name}{{4}};"
4029
+ )
4030
+
2097
4031
  # Security evaluator TA members (cloned from expression dependencies)
2098
4032
  # Skip for user function call expressions — their TA deps are internal to the function
2099
4033
  for info in self._security_eval_info:
@@ -2113,6 +4047,22 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2113
4047
  continue
2114
4048
  seen_var_members.add(name)
2115
4049
  safe = self._safe_name(name)
4050
+ callable_collection_spec = (
4051
+ None
4052
+ if name in self._global_collection_types
4053
+ else self._callable_var_collection_spec(name)
4054
+ )
4055
+ if callable_collection_spec is not None:
4056
+ lines.append(
4057
+ f" {self._type_spec_to_cpp(callable_collection_spec)} {safe};"
4058
+ )
4059
+ continue
4060
+ callable_udt_spec = self._callable_var_udt_spec(name)
4061
+ if callable_udt_spec is not None:
4062
+ lines.append(
4063
+ f" {self._type_spec_to_cpp(callable_udt_spec)} {safe};"
4064
+ )
4065
+ continue
2116
4066
  # Detect array vars from init expression. Guard the substring
2117
4067
  # heuristic against a UDT constructor that merely WRAPS array.new /
2118
4068
  # array.from in its arguments — e.g.
@@ -2123,12 +4073,24 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2123
4073
  _is_udt_ctor_init = any(
2124
4074
  _init_str_s.startswith(f"{u}.new") for u in self._udt_defs
2125
4075
  )
2126
- if (not _is_udt_ctor_init) and (
2127
- "array.new" in _init_str_s or "array.from" in _init_str_s
2128
- or name in self._array_vars
4076
+ exact_member_spec = self._global_collection_types.get(name)
4077
+ exact_array_member = (
4078
+ exact_member_spec is not None
4079
+ and exact_member_spec.kind == "array"
4080
+ )
4081
+ if exact_array_member or (
4082
+ not _is_udt_ctor_init
4083
+ and (
4084
+ "array.new" in _init_str_s
4085
+ or "array.from" in _init_str_s
4086
+ or name in self._array_vars
4087
+ )
2129
4088
  ):
2130
4089
  self._array_vars.add(name)
2131
- lines.append(f" {self._type_spec_to_cpp(self._array_spec_for_name(name))} {safe};")
4090
+ array_spec = exact_member_spec or self._array_spec_for_name(name)
4091
+ lines.append(
4092
+ f" {self._type_spec_to_cpp(array_spec)} {safe};"
4093
+ )
2132
4094
  continue
2133
4095
  # Detect matrix vars from init expression OR from the set
2134
4096
  # populated by ``_register_global_aggregate_member_types``
@@ -2136,7 +4098,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2136
4098
  # not just ``matrix.new``).
2137
4099
  if name in self._matrix_specs:
2138
4100
  pass # already registered upstream
2139
- elif "matrix.new" in str(init_str):
4101
+ elif not _is_udt_ctor_init and "matrix.new" in str(init_str):
2140
4102
  self._matrix_specs[name] = TypeSpec.matrix(TypeSpec.primitive("float"))
2141
4103
  self._collection_types[name] = self._matrix_specs[name]
2142
4104
  if name in self._matrix_specs:
@@ -2145,9 +4107,17 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2145
4107
  if "ta.pivot_point_levels" in str(init_str):
2146
4108
  lines.append(f" std::vector<double> {safe};")
2147
4109
  continue
2148
- if "map.new" in str(init_str) or name in self._map_vars:
4110
+ exact_map_member = (
4111
+ exact_member_spec is not None
4112
+ and exact_member_spec.kind == "map"
4113
+ )
4114
+ if exact_map_member or (
4115
+ not _is_udt_ctor_init
4116
+ and ("map.new" in str(init_str) or name in self._map_vars)
4117
+ ):
2149
4118
  self._map_vars.add(name)
2150
- lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(name))} {safe};")
4119
+ map_spec = exact_member_spec or self._map_spec_for_name(name)
4120
+ lines.append(f" {self._type_spec_to_cpp(map_spec)} {safe};")
2151
4121
  continue
2152
4122
  # Detect UDT vars. Two signals: (1) the analyzer recorded an
2153
4123
  # explicit UDT type annotation in ``_udt_var_types`` - this is the
@@ -2161,14 +4131,18 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2161
4131
  # C++ handle struct (Series<Line> when also history-referenced).
2162
4132
  # Drawing names are NOT in _udt_defs, so the udt branch below would
2163
4133
  # self-zero them to double; handle them first.
2164
- _draw_cpp = DRAWING_TYPE_TO_CPP.get(self._udt_var_types.get(name))
4134
+ member_udt_type = self._member_udt_type(name)
4135
+ _draw_cpp = (
4136
+ self._drawing_var_member_cpp_types.get(name)
4137
+ or DRAWING_TYPE_TO_CPP.get(member_udt_type)
4138
+ )
2165
4139
  if _draw_cpp is not None:
2166
- if name in self.ctx.series_vars:
4140
+ if safe in self._series_var_member_names:
2167
4141
  lines.append(f" Series<{_draw_cpp}> {safe}{_mbb};")
2168
4142
  else:
2169
4143
  lines.append(f" {_draw_cpp} {safe};")
2170
4144
  continue
2171
- udt_type = self._udt_var_types.get(name)
4145
+ udt_type = member_udt_type
2172
4146
  if udt_type not in self._udt_defs:
2173
4147
  udt_type = None
2174
4148
  if udt_type is None:
@@ -2184,14 +4158,35 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2184
4158
  # (time/time_close/timestamp), otherwise the na sentinel narrows.
2185
4159
  if cpp_type == "int" and self._is_int64_builtin_init(name):
2186
4160
  cpp_type = "int64_t"
2187
- if name in self.ctx.series_vars:
4161
+ # Persistent declarations have collision-safe exact member names
4162
+ # (for example ``x`` and ``x__blk1`` for sibling lexical blocks).
4163
+ # ``ctx.series_vars`` is only the legacy raw-name union, so using it
4164
+ # here can invert the storage types: the unreferenced sibling gets
4165
+ # Series<T> while the exact history-referenced sibling stays a
4166
+ # scalar. Exact analyzer identity must win for every persistent
4167
+ # primitive, just as it already does for drawing handles above.
4168
+ if safe in self._series_var_member_names:
2188
4169
  lines.append(f" Series<{cpp_type}> {safe}{_mbb};")
2189
4170
  else:
2190
- lines.append(f" {cpp_type} {safe};")
4171
+ if name in self._runtime_scalar_var_init_members:
4172
+ # A conditional declaration may not execute for many bars,
4173
+ # while COOF rollback still value-copies every member. Give
4174
+ # pending runtime vars a typed Pine-na sentinel so that copy
4175
+ # is always defined; the declaration-site guard overwrites
4176
+ # it on the first actual execution.
4177
+ pending = self._typed_na_init("na<double>()", name, ptype)
4178
+ lines.append(f" {cpp_type} {safe} = {pending};")
4179
+ else:
4180
+ lines.append(f" {cpp_type} {safe};")
2191
4181
 
2192
4182
  # 6. Non-var series vars
2193
4183
  for name in sorted(self.ctx.series_vars):
2194
- if name not in self._var_names:
4184
+ if (name not in self._var_names
4185
+ or name in self._nonpersistent_series_decl_names):
4186
+ if (name in self._qualified_func_var_raw_names
4187
+ and name in self._ordinary_global_binding_names
4188
+ and name not in self._ordinary_global_series_names):
4189
+ continue
2195
4190
  safe = self._safe_name(name)
2196
4191
  cpp_type = self._series_type_for(name)
2197
4192
  lines.append(f" Series<{cpp_type}> {safe}{_mbb};")
@@ -2200,7 +4195,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2200
4195
  for _fi_idx, site in enumerate(self.ctx.fixnan_sites):
2201
4196
  if _fi_idx in self._dead_fixnan_indices:
2202
4197
  continue
2203
- cpp_type = PINE_TYPE_TO_CPP.get(site.pine_type, "double")
4198
+ cpp_type = self._fixnan_site_cpp_type(site)
2204
4199
  lines.append(f" {cpp_type} {site.member_name} = na<{cpp_type}>();")
2205
4200
 
2206
4201
  # 8. Strategy series (e.g., strategy.closedtrades[1])
@@ -2225,13 +4220,14 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2225
4220
  # (so user-defined functions can reference them)
2226
4221
  seen_global = set()
2227
4222
  for name, ptype in self.ctx.global_var_decls:
2228
- if name in seen_global or name in self.ctx.series_vars or name in self._var_names:
2229
- continue
2230
- # De-hoisted UDT array-element alias (Pine reference semantics): the
2231
- # in-loop VarDecl is emitted as a fresh ``UDT& z = arr[i];`` local
2232
- # reference each iteration, so there is no persistent class member.
2233
- if name in self._udt_array_get_ref_locals:
2234
- seen_global.add(name)
4223
+ exact_scalar_override = (
4224
+ name in self._qualified_func_var_raw_names
4225
+ and name in self._ordinary_global_binding_names
4226
+ and name not in self._ordinary_global_series_names
4227
+ )
4228
+ if (name in seen_global
4229
+ or (name in self.ctx.series_vars and not exact_scalar_override)
4230
+ or name in self._var_names):
2235
4231
  continue
2236
4232
  seen_global.add(name)
2237
4233
  safe = self._safe_name(name)
@@ -2246,12 +4242,12 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2246
4242
  "localPivots", "securityPivotPointsArray", "pivotPointsArray",
2247
4243
  ):
2248
4244
  lines.append(f" std::vector<double> {safe} = std::vector<double>();")
2249
- elif name in self._udt_var_types:
4245
+ elif self._member_udt_type(name) is not None:
2250
4246
  # Non-var global of UDT type — declare as the struct so
2251
4247
  # downstream method dispatch works. Probes:
2252
4248
  # data/validation/udt-method-probe-19-array-of-udt-method,
2253
4249
  # data/validation/udt-method-probe-20-udt-return-from-func.
2254
- udt_t = self._udt_var_types[name]
4250
+ udt_t = self._member_udt_type(name)
2255
4251
  # Drawing handle global (L-N6 / U): map line/box/label/linefill
2256
4252
  # to the C++ handle struct (the default is na, id=-1).
2257
4253
  _draw_cpp = DRAWING_TYPE_TO_CPP.get(udt_t)
@@ -2261,7 +4257,21 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2261
4257
  lines.append(f" {udt_t} {safe} = {udt_t}{{}};")
2262
4258
  else:
2263
4259
  expr = self.ctx.global_expr_map.get(name) if hasattr(self.ctx, "global_expr_map") else None
2264
- cpp_type = self._infer_type(expr) if expr is not None else PINE_TYPE_TO_CPP.get(ptype, "double")
4260
+ if (
4261
+ name in self._direct_program_tuple_binding_names
4262
+ and ptype == PineType.BOOL
4263
+ ):
4264
+ # request.security homogeneous-bool helper tuples carry
4265
+ # their exact element family in analyzer metadata. The
4266
+ # coarse request.security call expression itself still
4267
+ # reports FLOAT, so prefer that exact binding type here.
4268
+ cpp_type = "bool"
4269
+ else:
4270
+ cpp_type = (
4271
+ self._infer_type(expr)
4272
+ if expr is not None
4273
+ else PINE_TYPE_TO_CPP.get(ptype, "double")
4274
+ )
2265
4275
  default = self._default_for_type(cpp_type)
2266
4276
  lines.append(f" {cpp_type} {safe} = {default};")
2267
4277
 
@@ -2275,16 +4285,20 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2275
4285
  if cloned_safe in emitted_clones:
2276
4286
  continue # already declared by another function's clone
2277
4287
  emitted_clones.add(cloned_safe)
2278
- self._emit_cloned_var_decl(orig_safe, cloned_safe, _mbb, lines)
4288
+ self._emit_cloned_var_decl(
4289
+ orig_safe, cloned_safe, _mbb, lines, owner_func=fname
4290
+ )
2279
4291
 
2280
4292
  # 8c2. Fresh var members for context-sensitive helper instances (nested
2281
4293
  # helpers reached through >1 distinct call path). Each fresh instance
2282
4294
  # gets its OWN scalar/series state so two paths never collide.
2283
- for orig_safe, fresh_safe in self._fresh_var_members:
4295
+ for owner_func, orig_safe, fresh_safe in self._fresh_var_members:
2284
4296
  if fresh_safe in emitted_clones:
2285
4297
  continue
2286
4298
  emitted_clones.add(fresh_safe)
2287
- self._emit_cloned_var_decl(orig_safe, fresh_safe, _mbb, lines)
4299
+ self._emit_cloned_var_decl(
4300
+ orig_safe, fresh_safe, _mbb, lines, owner_func=owner_func
4301
+ )
2288
4302
 
2289
4303
  # 8c3. Fresh fixnan members for context-sensitive helper instances.
2290
4304
  # Each fresh instance gets its OWN previous-value member so two
@@ -2293,7 +4307,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2293
4307
  if fresh_safe in emitted_clones:
2294
4308
  continue
2295
4309
  emitted_clones.add(fresh_safe)
2296
- cpp_type = PINE_TYPE_TO_CPP.get(orig_site.pine_type, "double")
4310
+ cpp_type = self._fixnan_site_cpp_type(orig_site)
2297
4311
  lines.append(f" {cpp_type} {fresh_safe} = na<{cpp_type}>();")
2298
4312
 
2299
4313
  # 8d. Drawing-objects-as-data arenas (gated on _uses_drawing so
@@ -2311,39 +4325,59 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2311
4325
  if self.ctx.var_members:
2312
4326
  lines.append(" bool _var_initialized = false;")
2313
4327
 
2314
- # 9a. Per-function-variant ``var`` init flags. A function-scoped
2315
- # ``var`` (Pine "init once" semantics) is a function-local static:
2316
- # its initializer runs on the FIRST call to that function variant
2317
- # (with the first bar's values the function actually sees) and the
2318
- # result persists for the strategy's lifetime. Each clone (cs0,
2319
- # cs1, ...) is an independent instance with its own flag.
2320
- # ``func_var_members`` is keyed by the plain Pine function name
2321
- # (``fi.name``), so this matches both plain UDFs and UDT methods.
4328
+ # 9a. Per-member flags for primitive runtime ``var`` / ``varip``
4329
+ # initializers. Unlike the global aggregate/Series latch above, these
4330
+ # live at the declaration site so prior statements are available and a
4331
+ # conditional declaration initializes on its first actual execution.
4332
+ emitted_runtime_flags: set[str] = set()
4333
+ for flag in self._runtime_var_init_flags.values():
4334
+ if flag in emitted_runtime_flags:
4335
+ continue
4336
+ emitted_runtime_flags.add(flag)
4337
+ lines.append(f" bool {flag} = false;")
4338
+
4339
+ # 9b. Legacy per-variant latch members are retained for generated-C++
4340
+ # and checkpoint-shape compatibility. Callable initializers no longer
4341
+ # consult them: every initialized declaration owns an exact-site flag.
4342
+ # Keep their historical spelling when free, but avoid legal authored
4343
+ # ``_fvinit_*`` members and every natural/fresh clone name.
4344
+ used_legacy_flags = set(self._runtime_var_init_flag_used_names)
4345
+
4346
+ def allocate_legacy_flag(base: str) -> str:
4347
+ flag = base
4348
+ suffix = 2
4349
+ while flag in used_legacy_flags:
4350
+ flag = f"{base}_{suffix}"
4351
+ suffix += 1
4352
+ used_legacy_flags.add(flag)
4353
+ return flag
4354
+
2322
4355
  for fi in self.ctx.func_infos:
2323
4356
  if fi.name not in self.ctx.func_var_members:
2324
4357
  continue
4358
+ emitted = self._func_cpp_base_name(fi.name)
2325
4359
  total_cs = self.ctx.func_call_site_counts.get(fi.name, 0)
2326
- if total_cs > 0:
2327
- for cs_idx in range(total_cs):
2328
- lines.append(f" bool _fvinit_{self._func_safe_name(fi.name)}_cs{cs_idx} = false;")
2329
- else:
2330
- lines.append(f" bool _fvinit_{self._func_safe_name(fi.name)} = false;")
4360
+ variants = range(total_cs) if total_cs > 0 else (None,)
4361
+ for cs_idx in variants:
4362
+ suffix = f"_cs{cs_idx}" if cs_idx is not None else ""
4363
+ flag = allocate_legacy_flag(f"_fvinit_{emitted}{suffix}")
4364
+ lines.append(f" bool {flag} = false;")
2331
4365
 
2332
- # 9a2. ``var`` init flags for fresh context-sensitive helper instances.
2333
4366
  for inst in self._fresh_instances:
2334
4367
  if inst["fname"] in self.ctx.func_var_members and inst["var_remap"]:
2335
- lines.append(f" bool _fvinit_{inst['name']} = false;")
4368
+ flag = allocate_legacy_flag(f"_fvinit_{inst['name']}")
4369
+ lines.append(f" bool {flag} = false;")
2336
4370
 
2337
- # 9b. _ta_initialized_ flag for runtime TA re-sizing (first on_bar only).
4371
+ # 9c. _ta_initialized_ flag for runtime TA re-sizing (first on_bar only).
2338
4372
  if self.ctx.ta_call_sites:
2339
4373
  lines.append(" bool _ta_initialized_ = false;")
2340
4374
 
2341
- # 9c. _inputs_initialized_ flag for cached global inputs.
4375
+ # 9d. _inputs_initialized_ flag for cached global inputs.
2342
4376
  lines.append(" bool _inputs_initialized_ = false;")
2343
4377
 
2344
4378
  lines.append("")
2345
4379
 
2346
- # 9d. Historical execution rollback checkpoint. Derive the member
4380
+ # 9e. Historical execution rollback checkpoint. Derive the member
2347
4381
  # inventory from the declarations above so every future generated
2348
4382
  # state category is captured automatically (or generation fails loudly
2349
4383
  # if it introduces an unfamiliar declaration form).
@@ -2447,8 +4481,26 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2447
4481
 
2448
4482
  # _get_ta_site / _ta_member_name / _ta_name_from_site / _TA_IMPLICIT_REPLACE
2449
4483
  # / _ta_compute_args_for_site / _security_ta_compute_args_for_site /
2450
- # _if_body_has_ta / _is_result_assignment / _expr_contains_ta /
2451
- # _hoist_if_body live on TaSiteHelper (codegen/ta.py).
4484
+ def _resolve_ta_ctor_arg(self, arg_str: str) -> str:
4485
+ """Resolve one TA constructor argument without widening const folding.
4486
+
4487
+ ``var`` values deliberately stay out of ``_known_vars`` because normal
4488
+ expression reads must still target persistent storage. A uniquely
4489
+ bound, never-reassigned top-level integer literal is nevertheless a
4490
+ faithful static TA buffer length. Admit only that exact bare alias;
4491
+ arithmetic, input/timeframe expressions, shadows, and all mutable or
4492
+ history-bearing bindings continue through the existing resolver/reset
4493
+ guardrails unchanged.
4494
+ """
4495
+ resolved = self._resolve_known(arg_str)
4496
+ if self._is_compile_time_value(resolved):
4497
+ return resolved
4498
+ if (
4499
+ arg_str in self._stable_var_ctor_literals
4500
+ and not self._known_var_is_lexically_shadowed(arg_str)
4501
+ ):
4502
+ return str(self._stable_var_ctor_literals[arg_str].value)
4503
+ return resolved
2452
4504
 
2453
4505
  def _resolve_known(self, arg_str: str) -> str:
2454
4506
  """Resolve a string arg, replacing known var names with their values.
@@ -2459,7 +4511,8 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2459
4511
  if arg_str == "na":
2460
4512
  return "na<double>()"
2461
4513
  # Direct variable lookup
2462
- if arg_str in self._known_vars:
4514
+ if (arg_str in self._known_vars
4515
+ and not self._known_var_is_lexically_shadowed(arg_str)):
2463
4516
  val = self._known_vars[arg_str]
2464
4517
  if isinstance(val, bool):
2465
4518
  return "true" if val else "false"
@@ -2476,6 +4529,8 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2476
4529
  resolved = arg_str
2477
4530
  # Sort by length (longest first) to avoid partial replacements
2478
4531
  for name in sorted(self._known_vars, key=len, reverse=True):
4532
+ if self._known_var_is_lexically_shadowed(name):
4533
+ continue
2479
4534
  val = self._known_vars[name]
2480
4535
  if isinstance(val, (int, float)):
2481
4536
  import re
@@ -2546,15 +4601,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2546
4601
  """
2547
4602
  if not isinstance(node, MemberAccess):
2548
4603
  return False
2549
- # Cheap path: receiver is a bare identifier we already track in
2550
- # ``_udt_var_types`` (the common case — ``m.tag``, ``s.ln``).
2551
- if isinstance(node.object, Identifier):
2552
- udt_name = self._udt_var_types.get(node.object.name)
2553
- if udt_name is None:
2554
- return False
2555
- return node.member in self._udt_omitted_fields.get(udt_name, ())
2556
- # General path: try to infer the receiver's UDT type via the same
2557
- # spec-resolver visit_expr uses for fallback member access.
4604
+ # Resolve even bare identifiers through the lexical/exact TypeSpec
4605
+ # path. The legacy raw-name UDT registry can describe an unrelated
4606
+ # callable local and therefore cannot safely drive field omission.
2558
4607
  recv_spec = self._type_spec_from_expr(node.object)
2559
4608
  if recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name:
2560
4609
  return node.member in self._udt_omitted_fields.get(recv_spec.name, ())
@@ -2594,7 +4643,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2594
4643
  "exp": "std::exp", "pow": "std::pow",
2595
4644
  "sin": "std::sin", "cos": "std::cos", "tan": "std::tan",
2596
4645
  "asin": "std::asin", "acos": "std::acos", "atan": "std::atan",
2597
- "sign": "(double)([] (double _v) { return (_v>0) - (_v<0); })",
4646
+ "sign": _MATH_SIGN_CPP_FN,
2598
4647
  }
2599
4648
 
2600
4649
  # Pine logical operators (word form) -> C++ operator, used when rendering
@@ -2719,7 +4768,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2719
4768
  return s
2720
4769
  def _rep(p: re.Match) -> str:
2721
4770
  nm = p.group(0)
2722
- if nm in self._derived_input_expr and nm not in seen:
4771
+ if (nm in self._derived_input_expr
4772
+ and nm not in seen
4773
+ and not self._known_var_is_lexically_shadowed(nm)):
2723
4774
  inner = self._derived_input_expr[nm]
2724
4775
  return "(" + _expand_derived(inner, seen | {nm}, depth + 1) + ")"
2725
4776
  return nm
@@ -2728,6 +4779,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2728
4779
  expanded = _expand_derived(arg_str)
2729
4780
 
2730
4781
  tokens = set(ident_re.findall(expanded))
4782
+ if any(
4783
+ self._known_var_is_lexically_shadowed(name)
4784
+ for name in tokens
4785
+ ):
4786
+ return None
2731
4787
 
2732
4788
  # Gate: every identifier token must be renderable. If any token is an
2733
4789
  # unrecognised bare identifier (not an input, not a known const, not
@@ -2749,7 +4805,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2749
4805
  # Known compile-time consts that survived expansion (e.g. ``pi`` was
2750
4806
  # NOT tracked as a Python value but its name token is a stable var
2751
4807
  # already; pure numeric names are in _known_vars and covered above).
2752
- leftover = {t for t in leftover if t not in self._known_vars}
4808
+ leftover = {
4809
+ t for t in leftover
4810
+ if (t not in self._known_vars
4811
+ or self._known_var_is_lexically_shadowed(t))
4812
+ }
2753
4813
  # Inline ``input(...)`` / ``input.<t>(...)`` calls (a bare input
2754
4814
  # expression passed straight as a length arg, e.g.
2755
4815
  # ``adx(input(15), input(15))``) are re-parsed and rendered below,
@@ -2820,6 +4880,8 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2820
4880
  # reads, and inline known compile-time consts (non-input) as literals.
2821
4881
  def _sub(p: re.Match) -> str:
2822
4882
  name = p.group(0)
4883
+ if self._known_var_is_lexically_shadowed(name):
4884
+ return name
2823
4885
  if name in self._input_backed_vars:
2824
4886
  call_node = self._input_var_to_call.get(name)
2825
4887
  if call_node is None:
@@ -2827,7 +4889,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2827
4889
  func_name_i, namespace_i = self._resolve_callee(call_node.callee)
2828
4890
  title = self._get_input_title(call_node, var_name=name)
2829
4891
  return self._render_input_value(call_node, func_name_i, namespace_i, title)
2830
- if name in self._known_vars and name not in self._input_backed_vars:
4892
+ if (name in self._known_vars
4893
+ and name not in self._input_backed_vars
4894
+ and not self._known_var_is_lexically_shadowed(name)):
2831
4895
  val = self._known_vars[name]
2832
4896
  if isinstance(val, bool):
2833
4897
  return "true" if val else "false"
@@ -2893,7 +4957,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2893
4957
  return rendered
2894
4958
 
2895
4959
 
2896
- def _collect_ta_runtime_resets(self) -> list[str]:
4960
+ def _collect_ta_runtime_resets(
4961
+ self,
4962
+ *,
4963
+ security_source_node: ASTNode | None = None,
4964
+ ) -> list[str]:
2897
4965
  """Collect reassignment statements for every TA object whose ctor args
2898
4966
  depend on an input-backed variable. Returned strings are raw C++
2899
4967
  assignment statements (no enclosing block/indent). Empty list when no
@@ -2917,7 +4985,7 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2917
4985
  runtime_args.append(rt)
2918
4986
  any_runtime = True
2919
4987
  else:
2920
- resolved = self._resolve_known(a)
4988
+ resolved = self._resolve_ta_ctor_arg(a)
2921
4989
  runtime_args.append(resolved if self._is_compile_time_value(resolved) else "1")
2922
4990
  if any_runtime:
2923
4991
  resets.append(
@@ -2942,6 +5010,11 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2942
5010
  sec_cs_idx = (sec_item or {}).get("callsite_idx")
2943
5011
  for idx, variants in (info.get("ta_variants") or {}).items():
2944
5012
  site = self.ctx.ta_call_sites[idx]
5013
+ if (
5014
+ security_source_node is not None
5015
+ and site.node is not security_source_node
5016
+ ):
5017
+ continue
2945
5018
  ctor_site = site
2946
5019
  if sec_containing and sec_cs_idx is not None:
2947
5020
  remap = self._func_cs_ta_remap.get((sec_containing, sec_cs_idx))
@@ -2953,18 +5026,55 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
2953
5026
  ctor_site = cand
2954
5027
  if not ctor_site.ctor_args:
2955
5028
  continue
2956
- runtime_args = []
2957
- any_runtime = False
2958
- for a in ctor_site.ctor_args:
2959
- rt = self._runtime_ctor_arg_for_reset(a)
2960
- if rt is not None:
2961
- runtime_args.append(rt)
2962
- any_runtime = True
2963
- else:
2964
- resolved = self._resolve_known(a)
2965
- runtime_args.append(resolved if self._is_compile_time_value(resolved) else "1")
2966
- if any_runtime:
2967
- for variant in variants:
5029
+ for variant in variants:
5030
+ ctor_args, ctor_arg_stability = self._security_ta_ctor_args_for_variant(
5031
+ info["sec_id"],
5032
+ site,
5033
+ variant.get("binding_stack", ()),
5034
+ fallback_args=ctor_site.ctor_args,
5035
+ )
5036
+ runtime_args = []
5037
+ any_runtime = False
5038
+ for arg_pos, a in enumerate(ctor_args):
5039
+ rt = self._runtime_ctor_arg_for_reset(a)
5040
+ resolved = self._resolve_ta_ctor_arg(a)
5041
+ lowered_variant = ctor_arg_stability is not None
5042
+ stable_variant_arg = (
5043
+ lowered_variant and ctor_arg_stability[arg_pos]
5044
+ )
5045
+ if (
5046
+ rt is None
5047
+ and stable_variant_arg
5048
+ and not self._is_compile_time_value(resolved)
5049
+ ):
5050
+ # This is trusted C++ from _build_security_expr and
5051
+ # its fully helper-bound AST passed _expr_is_stable.
5052
+ rt = a
5053
+ if (
5054
+ rt is None
5055
+ and lowered_variant
5056
+ and not self._is_compile_time_value(resolved)
5057
+ ):
5058
+ self._codegen_error(
5059
+ getattr(site, "node", None),
5060
+ f"Unsupported requested-context TA constructor "
5061
+ f"length '{a}' for {site.class_name}: the "
5062
+ "helper-bound expression is not a stable "
5063
+ "per-run scalar.",
5064
+ hint=("Use a literal, an input.*() value, "
5065
+ "timeframe.* metadata, or arithmetic "
5066
+ "over those for TA lengths."),
5067
+ )
5068
+ if rt is not None:
5069
+ runtime_args.append(rt)
5070
+ any_runtime = True
5071
+ else:
5072
+ runtime_args.append(
5073
+ resolved
5074
+ if self._is_compile_time_value(resolved)
5075
+ else "1"
5076
+ )
5077
+ if any_runtime:
2968
5078
  resets.append(
2969
5079
  f"{variant['member_name']} = {site.class_name}({', '.join(runtime_args)});"
2970
5080
  )