@pineforge/codegen-pyodide 0.10.0 → 0.10.2

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.
@@ -66,8 +66,9 @@ from __future__ import annotations
66
66
  from typing import Any
67
67
 
68
68
  from ..ast_nodes import (
69
- ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess,
70
- NumberLiteral, StringLiteral, Ternary, TupleLiteral, UnaryOp, VarDecl,
69
+ ASTNode, Assignment, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier,
70
+ IfStmt, MemberAccess, NumberLiteral, StringLiteral, Subscript, SwitchStmt,
71
+ Ternary, TupleLiteral, UnaryOp, VarDecl,
71
72
  )
72
73
  from ..symbols import PineType
73
74
  from .. import signatures as sigs
@@ -89,6 +90,278 @@ class CallHandlers:
89
90
  # TA call handling
90
91
  # ------------------------------------------------------------------
91
92
 
93
+ def _callsite_primitive_expr_type(
94
+ self,
95
+ expr: ASTNode | None,
96
+ parameter_types: dict[str, PineType],
97
+ ) -> PineType:
98
+ """Infer a primitive return using one written call's parameter types.
99
+
100
+ The analyzer's legacy ``FuncInfo`` is definition-wide, but Pine's
101
+ untyped parameters are polymorphic per written call. Keep this helper
102
+ intentionally primitive and expression-local: it provides the exact
103
+ family needed by history-preserving identity/wrapper functions without
104
+ attempting to monomorphize collections or reference types here.
105
+ """
106
+ if expr is None:
107
+ return PineType.UNKNOWN
108
+ if isinstance(expr, ExprStmt):
109
+ return self._callsite_primitive_expr_type(
110
+ expr.expr, parameter_types
111
+ )
112
+ if isinstance(expr, Identifier):
113
+ if expr.name in parameter_types:
114
+ return parameter_types[expr.name]
115
+ if expr.name in {"true", "false"}:
116
+ return PineType.BOOL
117
+ return PineType.UNKNOWN
118
+ if isinstance(expr, Subscript):
119
+ # Pine's history operator preserves the receiver's scalar family.
120
+ return self._callsite_primitive_expr_type(
121
+ expr.object, parameter_types
122
+ )
123
+ if isinstance(expr, NumberLiteral):
124
+ return (
125
+ PineType.FLOAT
126
+ if isinstance(expr.value, float)
127
+ else PineType.INT
128
+ )
129
+ if isinstance(expr, BoolLiteral):
130
+ return PineType.BOOL
131
+ if isinstance(expr, StringLiteral):
132
+ return PineType.STRING
133
+ if isinstance(expr, UnaryOp):
134
+ if expr.op == "not":
135
+ return PineType.BOOL
136
+ return self._callsite_primitive_expr_type(
137
+ expr.operand, parameter_types
138
+ )
139
+ if isinstance(expr, BinOp):
140
+ left = self._callsite_primitive_expr_type(
141
+ expr.left, parameter_types
142
+ )
143
+ right = self._callsite_primitive_expr_type(
144
+ expr.right, parameter_types
145
+ )
146
+ if expr.op in {"==", "!=", ">", "<", ">=", "<=", "and", "or"}:
147
+ return PineType.BOOL
148
+ if left == PineType.STRING or right == PineType.STRING:
149
+ return PineType.STRING
150
+ if expr.op == "/" or PineType.FLOAT in {left, right}:
151
+ return PineType.FLOAT
152
+ if left == PineType.INT and right == PineType.INT:
153
+ return PineType.INT
154
+ return PineType.UNKNOWN
155
+ if isinstance(expr, Ternary):
156
+ true_type = self._callsite_primitive_expr_type(
157
+ expr.true_val, parameter_types
158
+ )
159
+ false_type = self._callsite_primitive_expr_type(
160
+ expr.false_val, parameter_types
161
+ )
162
+ if true_type == false_type:
163
+ return true_type
164
+ if PineType.STRING in {true_type, false_type}:
165
+ return PineType.STRING
166
+ if PineType.FLOAT in {true_type, false_type}:
167
+ return PineType.FLOAT
168
+ return PineType.UNKNOWN
169
+ if isinstance(expr, FuncCall):
170
+ callee = expr.callee
171
+ if isinstance(callee, Identifier):
172
+ if callee.name == "int":
173
+ return PineType.INT
174
+ if callee.name == "float":
175
+ return PineType.FLOAT
176
+ if callee.name == "bool":
177
+ return PineType.BOOL
178
+ # A user callable's definition-wide return cache is not a
179
+ # call-site fact. In particular, a pure untyped transform
180
+ # nested inside a polymorphic history wrapper may have been
181
+ # analyzed first as FLOAT even when this path carries an
182
+ # int64 timestamp or bool. Exact direct-wrapper call edges are
183
+ # reconciled later; arbitrary nested transforms stay UNKNOWN
184
+ # and therefore fail closed instead of source-order coercing.
185
+ return PineType.UNKNOWN
186
+ return PineType.UNKNOWN
187
+
188
+ @staticmethod
189
+ def _join_callsite_primitive_types(
190
+ types: list[PineType],
191
+ ) -> PineType:
192
+ if not types or PineType.UNKNOWN in types:
193
+ return PineType.UNKNOWN
194
+ first = types[0]
195
+ if all(value == first for value in types):
196
+ return first
197
+ if set(types).issubset({PineType.INT, PineType.FLOAT}):
198
+ return PineType.FLOAT
199
+ return PineType.UNKNOWN
200
+
201
+ def _apply_callsite_local_type_effects(
202
+ self,
203
+ stmt: ASTNode,
204
+ local_types: dict[str, PineType],
205
+ ) -> None:
206
+ """Flow one statement's primitive local writes into ``local_types``.
207
+
208
+ This is deliberately a small callable-return analysis, not a second
209
+ semantic analyzer. It exists so a terminal alias such as
210
+ ``prior = src[1]; prior`` keeps the exact written-call family. Unknown
211
+ loops/collections remain unknown and cannot silently pick FLOAT.
212
+ """
213
+ if isinstance(stmt, VarDecl):
214
+ hinted = {
215
+ "int": PineType.INT,
216
+ "float": PineType.FLOAT,
217
+ "bool": PineType.BOOL,
218
+ "string": PineType.STRING,
219
+ "color": PineType.COLOR,
220
+ }.get(stmt.type_hint or "", PineType.UNKNOWN)
221
+ local_types[stmt.name] = (
222
+ hinted
223
+ if hinted != PineType.UNKNOWN
224
+ else self._callsite_primitive_expr_type(
225
+ stmt.value, local_types
226
+ )
227
+ )
228
+ return
229
+ if (
230
+ isinstance(stmt, Assignment)
231
+ and isinstance(stmt.target, Identifier)
232
+ ):
233
+ rhs = self._callsite_primitive_expr_type(
234
+ stmt.value, local_types
235
+ )
236
+ if stmt.op == ":=":
237
+ # Pine variables retain their declaration/inferred primitive
238
+ # family for their lifetime. An ``na``/otherwise unknown
239
+ # declaration may acquire the first concrete family here.
240
+ current = local_types.get(
241
+ stmt.target.name, PineType.UNKNOWN
242
+ )
243
+ local_types[stmt.target.name] = (
244
+ current if current != PineType.UNKNOWN else rhs
245
+ )
246
+ else:
247
+ local_types[stmt.target.name] = (
248
+ self._join_callsite_primitive_types(
249
+ [
250
+ local_types.get(
251
+ stmt.target.name, PineType.UNKNOWN
252
+ ),
253
+ rhs,
254
+ ]
255
+ )
256
+ )
257
+ return
258
+ if isinstance(stmt, IfStmt):
259
+ before = dict(local_types)
260
+ branch_envs: list[dict[str, PineType]] = []
261
+ for body in (stmt.body, stmt.else_body):
262
+ branch = dict(before)
263
+ for child in body:
264
+ self._apply_callsite_local_type_effects(child, branch)
265
+ branch_envs.append(branch)
266
+ all_names = set().union(*(env.keys() for env in branch_envs))
267
+ for name in all_names:
268
+ local_types[name] = self._join_callsite_primitive_types(
269
+ [
270
+ env.get(name, before.get(name, PineType.UNKNOWN))
271
+ for env in branch_envs
272
+ ]
273
+ )
274
+ return
275
+ if isinstance(stmt, SwitchStmt):
276
+ before = dict(local_types)
277
+ bodies = [body for _, body in stmt.cases]
278
+ if stmt.default_body:
279
+ bodies.append(stmt.default_body)
280
+ else:
281
+ bodies.append([])
282
+ branch_envs = []
283
+ for body in bodies:
284
+ branch = dict(before)
285
+ for child in body:
286
+ self._apply_callsite_local_type_effects(child, branch)
287
+ branch_envs.append(branch)
288
+ all_names = set().union(*(env.keys() for env in branch_envs))
289
+ for name in all_names:
290
+ local_types[name] = self._join_callsite_primitive_types(
291
+ [
292
+ env.get(name, before.get(name, PineType.UNKNOWN))
293
+ for env in branch_envs
294
+ ]
295
+ )
296
+
297
+ def _callsite_body_return_type(
298
+ self,
299
+ body: list[ASTNode],
300
+ parameter_types: dict[str, PineType],
301
+ ) -> PineType:
302
+ if not body:
303
+ return PineType.UNKNOWN
304
+ local_types = dict(parameter_types)
305
+ for stmt in body[:-1]:
306
+ self._apply_callsite_local_type_effects(stmt, local_types)
307
+ terminal = body[-1]
308
+ if isinstance(terminal, ExprStmt):
309
+ terminal = terminal.expr
310
+ if isinstance(terminal, VarDecl):
311
+ return self._callsite_primitive_expr_type(
312
+ terminal.value, local_types
313
+ )
314
+ if isinstance(terminal, IfStmt):
315
+ if not terminal.body:
316
+ return PineType.UNKNOWN
317
+ branches = [terminal.body]
318
+ # A missing else produces contextual ``na`` in Pine and therefore
319
+ # does not erase the concrete family's type.
320
+ if terminal.else_body:
321
+ branches.append(terminal.else_body)
322
+ return self._join_callsite_primitive_types(
323
+ [
324
+ self._callsite_body_return_type(
325
+ branch, dict(local_types)
326
+ )
327
+ for branch in branches
328
+ ]
329
+ )
330
+ if isinstance(terminal, SwitchStmt):
331
+ bodies = [case_body for _, case_body in terminal.cases]
332
+ if terminal.default_body:
333
+ bodies.append(terminal.default_body)
334
+ if not bodies:
335
+ return PineType.UNKNOWN
336
+ return self._join_callsite_primitive_types(
337
+ [
338
+ self._callsite_body_return_type(
339
+ branch, dict(local_types)
340
+ )
341
+ for branch in bodies
342
+ ]
343
+ )
344
+ return self._callsite_primitive_expr_type(terminal, local_types)
345
+
346
+ def _callsite_callable_return_type(
347
+ self,
348
+ func_def,
349
+ param_types: list[PineType],
350
+ fallback: PineType,
351
+ ) -> PineType:
352
+ inferred = self._callsite_body_return_type(
353
+ func_def.body,
354
+ {
355
+ name: (
356
+ param_types[index]
357
+ if index < len(param_types)
358
+ else PineType.UNKNOWN
359
+ )
360
+ for index, name in enumerate(func_def.params)
361
+ },
362
+ )
363
+ return inferred if inferred != PineType.UNKNOWN else fallback
364
+
92
365
  def _merge_ta_args(self, func_name: str, node: FuncCall) -> list:
93
366
  """Merge positional args and kwargs into a unified positional list."""
94
367
  param_names = sigs.get_param_names("ta", func_name)
@@ -316,6 +589,11 @@ class CallHandlers:
316
589
 
317
590
  returns_tuple = isinstance(expr_node, TupleLiteral)
318
591
  tuple_size = len(expr_node.elements) if returns_tuple else 0
592
+ tuple_element_types = (
593
+ self._tuple_element_types_by_node.get(id(expr_node), ())
594
+ if returns_tuple
595
+ else ()
596
+ )
319
597
  if not returns_tuple and isinstance(expr_node, FuncCall):
320
598
  expr_func = None
321
599
  expr_ns = None
@@ -323,6 +601,37 @@ class CallHandlers:
323
601
  and isinstance(expr_node.callee.object, Identifier)):
324
602
  expr_ns = expr_node.callee.object.name
325
603
  expr_func = expr_node.callee.member
604
+ elif isinstance(expr_node.callee, Identifier):
605
+ expr_func = expr_node.callee.name
606
+ if self._func_returns_tuple.get(expr_func, False):
607
+ tuple_size = self._func_tuple_element_count.get(expr_func, 0)
608
+ tuple_types = self._func_tuple_element_types.get(expr_func, ())
609
+ numeric_tuple = (
610
+ tuple_size >= 2
611
+ and len(tuple_types) == tuple_size
612
+ and all(
613
+ item in (PineType.INT, PineType.FLOAT)
614
+ for item in tuple_types
615
+ )
616
+ )
617
+ bool_tuple = (
618
+ tuple_size >= 2
619
+ and len(tuple_types) == tuple_size
620
+ and all(item == PineType.BOOL for item in tuple_types)
621
+ )
622
+ if not (numeric_tuple or bool_tuple):
623
+ inferred_types = ", ".join(
624
+ item.value for item in tuple_types
625
+ ) or "unknown"
626
+ self._error(
627
+ "request.security tuple-return helpers support two or more "
628
+ "numeric int/float elements or homogeneous bool elements; inferred "
629
+ f"{tuple_size} element(s) [{inferred_types}]",
630
+ expr_node.loc,
631
+ )
632
+ else:
633
+ returns_tuple = True
634
+ tuple_element_types = tuple_types
326
635
  if expr_ns == "ta":
327
636
  if expr_func == "vwap":
328
637
  merged_v = list(expr_node.args)
@@ -351,12 +660,15 @@ class CallHandlers:
351
660
  # so the codegen can resolve a parameter ``tf`` via the call sites.
352
661
  scope_name = self._symbols.current_scope.name
353
662
  containing_func = scope_name[5:] if scope_name.startswith("func_") else ""
663
+ if returns_tuple and tuple_element_types:
664
+ self._tuple_element_types_by_node[id(node)] = tuple_element_types
354
665
  self._security_calls.append(SecurityCallInfo(
355
666
  sec_id=sec_id,
356
667
  timeframe=tf_node,
357
668
  expression=expr_node,
358
669
  returns_tuple=returns_tuple,
359
670
  tuple_size=tuple_size,
671
+ tuple_element_types=tuple_element_types,
360
672
  gaps=gaps_node,
361
673
  lookahead=lookahead_node,
362
674
  ta_range=security_ta_range,
@@ -902,7 +1214,10 @@ class CallHandlers:
902
1214
 
903
1215
  def _materialize_user_func_call_site_state(
904
1216
  self, func_name: str, cs_idx: int, node: FuncCall,
905
- *, reuse_existing_owner: str | None = None) -> None:
1217
+ *, reuse_existing_owner: str | None = None,
1218
+ reuse_existing_targets: dict[int, int] | None = None,
1219
+ ta_site_indices: list[int] | None = None,
1220
+ materialize_fixnan: bool = True) -> dict[int, int]:
906
1221
  """Materialize TA/fixnan state for one UDF call-site variant.
907
1222
 
908
1223
  Ordinary call sites are handled while walking the AST. A second class
@@ -918,17 +1233,71 @@ class CallHandlers:
918
1233
  ``{member}_cs{idx}`` clone for the borrowed callee site; when that clone
919
1234
  belongs to the parent currently being propagated, it is the desired
920
1235
  call-path state and must be reused rather than duplicated under a
921
- disambiguated-but-unused name.
1236
+ disambiguated-but-unused name. ``reuse_existing_targets`` maps each
1237
+ source TA identity to the immediate parent's already-resolved target;
1238
+ this extends the proof through another borrowed layer even when clone
1239
+ name collisions forced a disambiguating suffix.
922
1240
  """
923
- func_def = self._func_defs[func_name]
1241
+ func_def = self._func_defs.get(func_name)
1242
+ method_info = None
1243
+ if func_def is None:
1244
+ method_info = next(
1245
+ (
1246
+ info
1247
+ for info in self._func_infos
1248
+ if info.name == func_name
1249
+ and getattr(info, "is_udt_method", False)
1250
+ ),
1251
+ None,
1252
+ )
1253
+ if method_info is not None:
1254
+ func_def = method_info.node
1255
+ if func_def is None:
1256
+ self._error(
1257
+ f"Cannot materialize callable state for unknown function '{func_name}'.",
1258
+ node.loc,
1259
+ )
1260
+ return {}
1261
+
1262
+ selected_ta_indices: dict[int, int] = {}
924
1263
 
925
1264
  param_arg_map: dict[str, str] = {}
1265
+ if (
1266
+ method_info is not None
1267
+ and isinstance(node.callee, MemberAccess)
1268
+ and func_name in self._method_signatures
1269
+ ):
1270
+ binding = self._bind_typed_method_call(func_name, node)
1271
+ positional_args = [node.callee.object, *binding.args_by_param]
1272
+ else:
1273
+ positional_args = list(node.args)
1274
+ if (
1275
+ method_info is not None
1276
+ and isinstance(node.callee, MemberAccess)
1277
+ ):
1278
+ positional_args.insert(0, node.callee.object)
926
1279
  for p_idx, param_name in enumerate(func_def.params):
927
- if p_idx < len(node.args):
928
- param_arg_map[param_name] = self._expr_to_str(node.args[p_idx])
1280
+ if p_idx < len(positional_args):
1281
+ param_arg_map[param_name] = self._expr_to_str(
1282
+ positional_args[p_idx]
1283
+ )
1284
+ elif param_name in node.kwargs:
1285
+ param_arg_map[param_name] = self._expr_to_str(
1286
+ node.kwargs[param_name]
1287
+ )
929
1288
 
930
1289
  if func_name in self._func_ta_ranges:
931
1290
  start, end = self._func_ta_ranges[func_name]
1291
+ site_indices = (
1292
+ list(ta_site_indices)
1293
+ if ta_site_indices is not None
1294
+ else list(self._func_ta_indices.get(func_name, ()))
1295
+ )
1296
+ if ta_site_indices is None and not site_indices:
1297
+ site_indices = list(range(start, end))
1298
+ func_ctor_templates = self._func_ta_ctor_args.setdefault(
1299
+ func_name, {}
1300
+ )
932
1301
 
933
1302
  # Map local derived length variables back to expressions over the
934
1303
  # function's parameters before substituting call-site arguments.
@@ -966,46 +1335,105 @@ class CallHandlers:
966
1335
  if cs_idx == 0:
967
1336
  # cs0 owns the source-level sites. Preserve their parameterized
968
1337
  # ctor args for every later direct or inherited clone.
969
- for i in range(start, end):
1338
+ for i in site_indices:
970
1339
  site = self._ta_call_sites[i]
971
1340
  if not hasattr(site, '_orig_ctor_args'):
972
1341
  site._orig_ctor_args = [
973
1342
  _expand_locals(arg) for arg in site.ctor_args
974
1343
  ]
1344
+ # Each callable owns its own view of a borrowed site's
1345
+ # constructor expression. Expand locals in that owner view
1346
+ # before substituting actual parameters; a template first
1347
+ # recorded at definition time can still contain a local
1348
+ # alias such as ``effectiveLen``.
1349
+ func_ctor_templates[i] = [
1350
+ _expand_locals(arg)
1351
+ for arg in func_ctor_templates.get(
1352
+ i, site._orig_ctor_args
1353
+ )
1354
+ ]
975
1355
  site.ctor_args = [
976
1356
  _subst_params(arg, param_arg_map)
977
- for arg in site._orig_ctor_args
1357
+ for arg in func_ctor_templates[i]
978
1358
  ]
979
- # If a ctor is now expressed in an enclosing UDF's params,
980
- # retain that expression so the enclosing call can resolve
981
- # it and widen the enclosing TA range as before.
1359
+ selected_ta_indices[i] = i
982
1360
  if enclosing_params and self._nested_ta_touched is not None:
983
1361
  for arg in site.ctor_args:
984
1362
  tokens = set(_re.findall(
985
1363
  r"[A-Za-z_][A-Za-z_0-9]*", arg))
986
1364
  if tokens & enclosing_params:
987
- site._orig_ctor_args = list(site.ctor_args)
988
- self._nested_ta_touched.add(i)
1365
+ if self._enclosing_func_names:
1366
+ caller = self._enclosing_func_names[-1]
1367
+ self._func_ta_ctor_args.setdefault(
1368
+ caller, {}
1369
+ )[i] = list(site.ctor_args)
989
1370
  break
990
1371
  else:
991
1372
  clone_name_map: dict[str, str] = {}
992
- for i in range(start, end):
1373
+ for i in site_indices:
993
1374
  orig = self._ta_call_sites[i]
994
- orig_args = getattr(orig, '_orig_ctor_args', orig.ctor_args)
1375
+ if not hasattr(orig, '_orig_ctor_args'):
1376
+ orig._orig_ctor_args = [
1377
+ _expand_locals(arg) for arg in orig.ctor_args
1378
+ ]
1379
+ func_ctor_templates[i] = [
1380
+ _expand_locals(arg)
1381
+ for arg in func_ctor_templates.get(
1382
+ i, orig._orig_ctor_args
1383
+ )
1384
+ ]
1385
+ orig_args = func_ctor_templates[i]
995
1386
  resolved_ctor = [
996
1387
  _subst_params(arg, param_arg_map) for arg in orig_args
997
1388
  ]
1389
+ reuse_target = (
1390
+ reuse_existing_targets.get(i)
1391
+ if reuse_existing_targets is not None
1392
+ else None
1393
+ )
1394
+ if reuse_target is not None:
1395
+ # The parent's active clone already resolved this
1396
+ # exact source identity through the next outer call
1397
+ # boundary. Reuse it directly; do not overwrite its
1398
+ # concrete constructor with this edge's still-local
1399
+ # parameter spelling.
1400
+ selected_ta_indices[i] = reuse_target
1401
+ target_name = self._ta_call_sites[
1402
+ reuse_target
1403
+ ].member_name
1404
+ default_name = f"{orig.member_name}_cs{cs_idx}"
1405
+ if target_name != default_name:
1406
+ clone_name_map[orig.member_name] = target_name
1407
+ continue
1408
+ existing_target = self._func_ta_call_targets.get(
1409
+ (id(node), cs_idx), {}
1410
+ ).get(i)
1411
+ if existing_target is not None:
1412
+ self._ta_call_sites[existing_target].ctor_args = resolved_ctor
1413
+ selected_ta_indices[i] = existing_target
1414
+ continue
998
1415
  clone_name = f"{orig.member_name}_cs{cs_idx}"
999
- existing = next(
1000
- (site for site in self._ta_call_sites
1001
- if site.member_name == clone_name),
1416
+ existing_pair = next(
1417
+ (
1418
+ (index, site)
1419
+ for index, site in enumerate(self._ta_call_sites)
1420
+ if site.member_name == clone_name
1421
+ ),
1002
1422
  None,
1003
1423
  )
1004
- if (reuse_existing_owner is not None
1005
- and existing is not None
1006
- and existing.owner_func == reuse_existing_owner):
1424
+ if (
1425
+ existing_pair is not None
1426
+ and (
1427
+ (
1428
+ reuse_existing_owner is not None
1429
+ and existing_pair[1].owner_func
1430
+ == reuse_existing_owner
1431
+ )
1432
+ )
1433
+ ):
1007
1434
  # The active parent's widened range already made the
1008
1435
  # exact member this inherited callee variant needs.
1436
+ selected_ta_indices[i] = existing_pair[0]
1009
1437
  continue
1010
1438
  if clone_name in self._ta_member_names:
1011
1439
  base = clone_name
@@ -1024,15 +1452,36 @@ class CallHandlers:
1024
1452
  is_static=orig.is_static,
1025
1453
  owner_func=func_name,
1026
1454
  )
1455
+ selected_ta_indices[i] = len(self._ta_call_sites)
1027
1456
  self._ta_call_sites.append(cloned)
1028
1457
  self._ta_member_names.add(clone_name)
1029
1458
  if clone_name_map:
1030
1459
  self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map
1031
1460
 
1461
+ # A nested stateful helper belongs to the enclosing callable's
1462
+ # call path even when its constructor has no forwarded parameters
1463
+ # (``ta.change`` is the canonical example). Constructor-template
1464
+ # forwarding above is intentionally narrower: it only rewrites
1465
+ # expressions that reference enclosing parameters. Keep state
1466
+ # ownership independent from that substitution test so every
1467
+ # written parent call site receives its exact borrowed TA targets.
1468
+ if self._nested_ta_touched is not None:
1469
+ self._nested_ta_touched.update(selected_ta_indices.values())
1470
+
1471
+ call_targets = self._func_ta_call_targets.setdefault(
1472
+ (id(node), cs_idx), {}
1473
+ )
1474
+ call_targets.update(selected_ta_indices)
1475
+ call_templates = self._func_ta_call_templates.setdefault(id(node), {})
1476
+ for index in site_indices:
1477
+ template = func_ctor_templates.get(index)
1478
+ if template is not None:
1479
+ call_templates[index] = tuple(template)
1480
+
1032
1481
  # fixnan is stateful for the same reason as a rolling TA reducer: each
1033
1482
  # emitted function variant needs its own previous-value member.
1034
1483
  fn_indices = self._func_fixnan_indices.get(func_name, [])
1035
- if cs_idx > 0 and fn_indices:
1484
+ if materialize_fixnan and cs_idx > 0 and fn_indices:
1036
1485
  clone_map: dict[str, str] = {}
1037
1486
  for fi in fn_indices:
1038
1487
  orig = self._fixnan_sites[fi]
@@ -1064,19 +1513,75 @@ class CallHandlers:
1064
1513
  if clone_map:
1065
1514
  self._func_cs_fixnan_clone_names[(func_name, cs_idx)] = clone_map
1066
1515
 
1516
+ return selected_ta_indices
1517
+
1067
1518
  def _handle_user_func_call(self, func_name: str, node: FuncCall) -> PineType:
1068
1519
  """Handle calls to user-defined functions."""
1069
1520
  func_def = self._func_defs[func_name]
1070
1521
 
1071
- # Visit the call args
1072
- arg_types = []
1522
+ # Visit every supplied argument in Pine source order, then bind both
1523
+ # positional and keyword forms into declaration order. The old
1524
+ # positional-only path skipped kwargs entirely, hiding nested calls and
1525
+ # leaving deferred map-history validation without a concrete TypeSpec.
1526
+ bound_args = self._bind_callable_args(node, list(func_def.params))
1527
+ visited_types: dict[int, PineType] = {}
1073
1528
  for arg in node.args:
1074
- arg_types.append(self._visit(arg))
1529
+ visited_types[id(arg)] = self._visit(arg)
1530
+ for arg in node.kwargs.values():
1531
+ visited_types[id(arg)] = self._visit(arg)
1532
+
1533
+ param_types = [
1534
+ visited_types.get(id(arg), PineType.UNKNOWN)
1535
+ if arg is not None
1536
+ else PineType.UNKNOWN
1537
+ for arg in bound_args
1538
+ ]
1539
+ declared_specs = list(
1540
+ self._func_param_type_specs.get(func_name, ())
1541
+ )
1542
+ effective_param_types = [
1543
+ (
1544
+ self._primitive_pine_type_from_spec(declared_specs[index])
1545
+ if index < len(declared_specs)
1546
+ and self._primitive_pine_type_from_spec(
1547
+ declared_specs[index]
1548
+ ) != PineType.UNKNOWN
1549
+ else param_type
1550
+ )
1551
+ for index, param_type in enumerate(param_types)
1552
+ ]
1553
+ self._callable_bound_param_types_by_node[id(node)] = list(
1554
+ effective_param_types
1555
+ )
1075
1556
 
1076
- # Determine param types from call-site args
1077
- param_types = arg_types[:len(func_def.params)]
1078
- while len(param_types) < len(func_def.params):
1079
- param_types.append(PineType.UNKNOWN)
1557
+ # Per-param TypeSpec: declared hints are authoritative; for untyped
1558
+ # params, infer from this call site's argument. Validate deferred
1559
+ # history receivers before series propagation/cloning can reinterpret
1560
+ # a map handle (or map-bearing UDT) as ``Series<double>``.
1561
+ param_specs = list(
1562
+ getattr(self, "_func_param_type_specs", {}).get(func_name)
1563
+ or self._param_type_specs_from_def(func_def)
1564
+ )
1565
+ arg_specs = [
1566
+ self._type_spec_from_expr(arg) if arg is not None else None
1567
+ for arg in bound_args
1568
+ ]
1569
+ for i in range(len(param_specs)):
1570
+ if param_specs[i] is None and i < len(arg_specs):
1571
+ param_specs[i] = arg_specs[i]
1572
+ self._record_deferred_param_call_edge(
1573
+ node,
1574
+ func_name,
1575
+ list(func_def.params),
1576
+ bound_args,
1577
+ )
1578
+ self._validate_deferred_param_history_refs(
1579
+ func_name,
1580
+ {
1581
+ name: spec
1582
+ for name, spec in zip(func_def.params, param_specs)
1583
+ },
1584
+ )
1080
1585
 
1081
1586
  # Determine return type: re-analyze the function body with known param types
1082
1587
  # For now, use the cached return type from initial analysis
@@ -1105,14 +1610,24 @@ class CallHandlers:
1105
1610
  return_type = PineType.FLOAT
1106
1611
  break
1107
1612
 
1613
+ # History and other primitive operations preserve an untyped
1614
+ # parameter's family independently at each written call. Do this
1615
+ # before the legacy FuncInfo merge so the first call cannot dictate
1616
+ # every later call's return type.
1617
+ return_type = self._callsite_callable_return_type(
1618
+ func_def, effective_param_types, return_type
1619
+ )
1620
+
1108
1621
  # If this function has series params, ensure bar-field arguments
1109
1622
  # passed at the call site are registered as series_bar_fields so that
1110
1623
  # the codegen can create Series<double> members for them.
1111
1624
  func_sv = self._func_series_vars.get(func_name, set())
1112
1625
  if func_sv:
1113
1626
  for p_idx, param_name in enumerate(func_def.params):
1114
- if param_name in func_sv and p_idx < len(node.args):
1115
- arg = node.args[p_idx]
1627
+ if param_name in func_sv and p_idx < len(bound_args):
1628
+ arg = bound_args[p_idx]
1629
+ if arg is None:
1630
+ continue
1116
1631
  if isinstance(arg, Identifier) and arg.name in BAR_FIELDS:
1117
1632
  self._series_bar_fields.add(arg.name)
1118
1633
  elif isinstance(arg, Identifier):
@@ -1126,6 +1641,29 @@ class CallHandlers:
1126
1641
  caller_name = sym.scope[5:]
1127
1642
  self._func_series_vars.setdefault(caller_name, set()).add(arg.name)
1128
1643
  else:
1644
+ # Keep the exact declaration/member registries
1645
+ # in lockstep with the legacy raw-name series
1646
+ # promotion. Codegen uses those identities to
1647
+ # distinguish the real global binding from a
1648
+ # same-named lexical scalar tombstone.
1649
+ exact_member = getattr(
1650
+ sym, "_pf_var_member_name", None
1651
+ )
1652
+ if exact_member is not None:
1653
+ self._series_var_members.add(exact_member)
1654
+ decl_node_id = getattr(
1655
+ sym, "_pf_decl_node_id", None
1656
+ )
1657
+ if decl_node_id is not None:
1658
+ self._series_decl_nodes.add(decl_node_id)
1659
+ binding_name = getattr(
1660
+ sym,
1661
+ "_pf_decl_binding_name",
1662
+ arg.name,
1663
+ )
1664
+ self._series_decl_bindings.add(
1665
+ (decl_node_id, binding_name)
1666
+ )
1129
1667
  self._series_vars.add(arg.name)
1130
1668
 
1131
1669
  # Per-call-site cloning: TA, series/var, and fixnan state all advance
@@ -1134,10 +1672,25 @@ class CallHandlers:
1134
1672
  has_series = func_name in self._func_series_vars or func_name in self._func_var_members
1135
1673
  has_fixnan = func_name in self._func_fixnan_indices
1136
1674
  if has_ta or has_series or has_fixnan:
1137
- cs_idx = self._func_call_site_count.get(func_name, 0)
1138
- self._func_call_site_count[func_name] = cs_idx + 1
1139
- self._func_call_cs_map[id(node)] = (func_name, cs_idx)
1140
- self._materialize_user_func_call_site_state(func_name, cs_idx, node)
1675
+ existing_site = self._func_call_cs_map.get(id(node))
1676
+ if existing_site is None or existing_site[0] != func_name:
1677
+ # Type/return inference may revisit the same FuncCall AST node
1678
+ # several times. It is still one Pine textual call site, not
1679
+ # a new state instance on every analyzer pass. Real caller
1680
+ # clones are expanded later by _propagate_call_site_counts().
1681
+ cs_idx = self._func_call_site_count.get(func_name, 0)
1682
+ self._func_call_site_count[func_name] = cs_idx + 1
1683
+ self._func_call_cs_map[id(node)] = (func_name, cs_idx)
1684
+ self._materialize_user_func_call_site_state(
1685
+ func_name, cs_idx, node
1686
+ )
1687
+ callsite = self._func_call_cs_map.get(id(node))
1688
+ if callsite is not None and callsite[0] == func_name:
1689
+ key = (func_name, callsite[1])
1690
+ self._func_callsite_param_types[key] = list(
1691
+ effective_param_types
1692
+ )
1693
+ self._func_callsite_return_types[key] = return_type
1141
1694
 
1142
1695
  # Create or update FuncInfo
1143
1696
  is_tuple = self._func_returns_tuple.get(func_name, False)
@@ -1145,19 +1698,64 @@ class CallHandlers:
1145
1698
  # Forward UDT-return inference (set in _visit_FuncDef) so codegen can
1146
1699
  # emit the struct return type. Probe: udt-method-probe-20.
1147
1700
  udt_ret = self._func_udt_return_types.get(func_name)
1701
+ existing = [fi for fi in self._func_infos if fi.name == func_name]
1702
+
1703
+ # A direct terminal map call on an untyped parameter cannot be typed
1704
+ # while the function definition is first visited: its map TypeSpec is
1705
+ # learned only here, from the call-site argument. Re-run only the
1706
+ # terminal-map classifier with those established parameter specs; do
1707
+ # not re-analyze the body or participate in general return inference.
1708
+ effective_param_specs = list(param_specs)
1709
+ if existing and existing[0].param_type_specs:
1710
+ for i, spec in enumerate(existing[0].param_type_specs):
1711
+ if i < len(effective_param_specs) and spec is not None:
1712
+ effective_param_specs[i] = spec
1713
+ terminal_map_return = self._terminal_map_call_return(
1714
+ self._direct_terminal_return_expr(func_def),
1715
+ {
1716
+ name: spec
1717
+ for name, spec in zip(func_def.params, effective_param_specs)
1718
+ },
1719
+ )
1148
1720
  ret_spec = getattr(self, "_func_return_type_specs", {}).get(func_name)
1149
- # Per-param TypeSpec: declared hints are authoritative; for untyped
1150
- # params, infer from the call-site argument's type_spec (so an untyped
1151
- # ``s`` used as a string, or a UDT passed by value, emits correctly).
1152
- param_specs = list(
1153
- getattr(self, "_func_param_type_specs", {}).get(func_name)
1154
- or self._param_type_specs_from_def(func_def)
1721
+ terminal_expr = self._direct_terminal_return_expr(func_def)
1722
+ terminal_selection_spec = self._terminal_map_selection_return_spec(
1723
+ terminal_expr,
1724
+ {
1725
+ name: spec
1726
+ for name, spec in zip(
1727
+ func_def.params, effective_param_specs
1728
+ )
1729
+ },
1155
1730
  )
1156
- arg_specs = [self._type_spec_from_expr(arg) for arg in node.args]
1157
- for i in range(len(param_specs)):
1158
- if param_specs[i] is None and i < len(arg_specs):
1159
- param_specs[i] = arg_specs[i]
1160
- existing = [fi for fi in self._func_infos if fi.name == func_name]
1731
+ if (
1732
+ terminal_map_return is None
1733
+ and isinstance(terminal_expr, Identifier)
1734
+ and terminal_expr.name in func_def.params
1735
+ ):
1736
+ terminal_index = func_def.params.index(terminal_expr.name)
1737
+ terminal_identity_spec = (
1738
+ effective_param_specs[terminal_index]
1739
+ if terminal_index < len(effective_param_specs)
1740
+ else None
1741
+ )
1742
+ if (terminal_identity_spec is not None
1743
+ and terminal_identity_spec.kind == "map"):
1744
+ # An untyped identity UDF learns the map handle type from its
1745
+ # call site. The handle is returned by value, preserving the
1746
+ # backing ID rather than cloning map contents.
1747
+ self._func_return_type_specs[func_name] = terminal_identity_spec
1748
+ ret_spec = terminal_identity_spec
1749
+ if terminal_selection_spec is not None:
1750
+ self._func_return_type_specs[func_name] = terminal_selection_spec
1751
+ ret_spec = terminal_selection_spec
1752
+ if terminal_map_return is not None:
1753
+ return_type, inferred_ret_spec = terminal_map_return
1754
+ self._func_return_types[func_name] = return_type
1755
+ if inferred_ret_spec is not None:
1756
+ self._func_return_type_specs[func_name] = inferred_ret_spec
1757
+ ret_spec = inferred_ret_spec
1758
+
1161
1759
  if not existing:
1162
1760
  fi = FuncInfo(
1163
1761
  name=func_name,
@@ -1174,7 +1772,10 @@ class CallHandlers:
1174
1772
  else:
1175
1773
  # Update with better type info if available
1176
1774
  fi = existing[0]
1177
- if fi.return_type in (PineType.UNKNOWN, PineType.VOID) and return_type not in (PineType.UNKNOWN, PineType.VOID):
1775
+ if terminal_map_return is not None:
1776
+ fi.return_type = return_type
1777
+ fi.return_type_spec = ret_spec
1778
+ elif fi.return_type in (PineType.UNKNOWN, PineType.VOID) and return_type not in (PineType.UNKNOWN, PineType.VOID):
1178
1779
  fi.return_type = return_type
1179
1780
  for i, pt in enumerate(param_types):
1180
1781
  if i < len(fi.param_types) and fi.param_types[i] == PineType.UNKNOWN:
@@ -1192,4 +1793,17 @@ class CallHandlers:
1192
1793
  if fi.return_type_spec is None and ret_spec is not None:
1193
1794
  fi.return_type_spec = ret_spec
1194
1795
 
1796
+ # A concrete map can arrive only at an outer wrapper's eventual call
1797
+ # site, after every nested untyped UDF was initially analyzed with
1798
+ # scalar fallbacks. Propagate the concrete specs through the exact
1799
+ # definition-time call edges and infer returns in post-order before
1800
+ # codegen snapshots the FuncInfo table.
1801
+ self._propagate_deferred_map_callable_specs(
1802
+ func_name,
1803
+ {
1804
+ name: spec
1805
+ for name, spec in zip(func_def.params, param_specs)
1806
+ },
1807
+ )
1808
+
1195
1809
  return return_type