@pineforge/codegen-pyodide 1.2.0 → 1.4.0

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.
Files changed (34) hide show
  1. package/LICENSE +19 -3
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/pineforge_codegen/__init__.py +6 -5
  5. package/pineforge_codegen/analyzer/base.py +3 -3
  6. package/pineforge_codegen/analyzer/types.py +7 -3
  7. package/pineforge_codegen/codegen/base.py +36 -17
  8. package/pineforge_codegen/codegen/capabilities.py +143 -10
  9. package/pineforge_codegen/codegen/checked_settings.py +161 -4
  10. package/pineforge_codegen/codegen/collection_history.py +163 -12
  11. package/pineforge_codegen/codegen/emit_top.py +29 -13
  12. package/pineforge_codegen/codegen/helpers.py +25 -1
  13. package/pineforge_codegen/codegen/host_members.py +1 -0
  14. package/pineforge_codegen/codegen/input.py +23 -8
  15. package/pineforge_codegen/codegen/order_shapes.py +356 -0
  16. package/pineforge_codegen/codegen/run_stops.py +52 -0
  17. package/pineforge_codegen/codegen/security.py +66 -10
  18. package/pineforge_codegen/codegen/tables.py +144 -28
  19. package/pineforge_codegen/codegen/tv_number_format.py +9 -2
  20. package/pineforge_codegen/codegen/types.py +85 -4
  21. package/pineforge_codegen/codegen/visit_call.py +235 -55
  22. package/pineforge_codegen/codegen/visit_expr.py +40 -8
  23. package/pineforge_codegen/codegen/visit_stmt.py +5 -4
  24. package/pineforge_codegen/collection_history.py +122 -20
  25. package/pineforge_codegen/diagnostics_catalog.json +1 -0
  26. package/pineforge_codegen/external_requests.py +30 -9
  27. package/pineforge_codegen/matrix_overloads.py +7 -0
  28. package/pineforge_codegen/parser.py +7 -2
  29. package/pineforge_codegen/signatures.py +2 -2
  30. package/pineforge_codegen/support_checker.py +101 -10
  31. package/pineforge_codegen-1.4.0.tar.gz +0 -0
  32. package/release.json +2 -2
  33. package/tables.json +7 -3
  34. package/pineforge_codegen-1.2.0.tar.gz +0 -0
@@ -194,13 +194,19 @@ class SecurityEmitter:
194
194
  chart timeframe — its evaluator result is never read. Any other
195
195
  timeframe registration cannot compute is refused: it used to register
196
196
  the chart timeframe, silently.
197
+
198
+ Every timeframe string resolved from the script -- the literal, a
199
+ constant, a helper parameter's argument, a ternary or switch arm --
200
+ is a Pine timeframe (``_refuse_invalid_security_tf``).
197
201
  """
198
202
  if isinstance(tf_node, StringLiteral):
203
+ self._refuse_invalid_security_tf(tf_node.value, tf_node)
199
204
  return tf_node.value, None
200
205
  if isinstance(tf_node, SwitchStmt):
201
206
  # Keep diagnostics from the registration-time switch renderer
202
207
  # visible; the broad expression fallback below intentionally
203
208
  # catches ordinary unresolved expressions.
209
+ self._check_security_tf_arms(tf_node, set())
204
210
  return None, self._security_tf_runtime_expr(tf_node)
205
211
  if isinstance(tf_node, Identifier):
206
212
  name = tf_node.name
@@ -209,6 +215,7 @@ class SecurityEmitter:
209
215
  if (name in self._known_vars and name not in self._input_backed_vars
210
216
  and not self._known_var_is_lexically_shadowed(name)
211
217
  and isinstance(self._known_vars[name], str)):
218
+ self._refuse_invalid_security_tf(self._known_vars[name], tf_node)
212
219
  return self._known_vars[name], None
213
220
  if (name in self._input_backed_vars
214
221
  and name in self._input_var_to_call
@@ -216,6 +223,7 @@ class SecurityEmitter:
216
223
  return None, self._visit_expr(self._input_var_to_call[name])
217
224
  global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
218
225
  if name in global_expr_map:
226
+ self._check_security_tf_arms(global_expr_map[name], {name})
219
227
  expanded = self._security_tf_runtime_expr(
220
228
  global_expr_map[name], resolving={name}
221
229
  )
@@ -238,6 +246,7 @@ class SecurityEmitter:
238
246
  self._security_tf_unresolved(tf_node, f"timeframe '{name}'")
239
247
  # any other expression — visit if it resolves at class scope
240
248
  try:
249
+ self._check_security_tf_arms(tf_node, set())
241
250
  expanded = self._security_tf_runtime_expr(tf_node)
242
251
  return None, expanded if expanded is not None else self._visit_expr(tf_node)
243
252
  except CompileError:
@@ -245,6 +254,45 @@ class SecurityEmitter:
245
254
  except Exception:
246
255
  self._security_tf_unresolved(tf_node, "timeframe expression")
247
256
 
257
+ def _refuse_invalid_security_tf(self, value: str, node) -> None:
258
+ """Refuse a timeframe string resolved from the script that is not a
259
+ Pine timeframe, with the support checker's diagnostic for the same
260
+ string written in the call (``invalid_tf_literal``): it reaches the
261
+ C++ registration. An empty string registers the chart's timeframe."""
262
+ if not value:
263
+ return
264
+ from ..support_checker import invalid_tf_literal
265
+ fn_label = ("request.security_lower_tf" if getattr(self, "_security_tf_lower", False)
266
+ else "request.security")
267
+ refusal = invalid_tf_literal(value, fn_label)
268
+ if refusal is not None:
269
+ message, hint = refusal
270
+ self._codegen_error(node, message, hint=hint)
271
+
272
+ def _check_security_tf_arms(self, node, resolving: set[str]) -> None:
273
+ """Refuse an invalid timeframe string a registration-time timeframe
274
+ expression can select: the expression itself, a ternary or switch
275
+ arm, read through the constants and globals it names."""
276
+ def check(value) -> None:
277
+ if isinstance(value, StringLiteral):
278
+ self._refuse_invalid_security_tf(value.value, value)
279
+ elif isinstance(value, Ternary):
280
+ check(value.true_val)
281
+ check(value.false_val)
282
+ elif isinstance(value, SwitchStmt):
283
+ arms = [body for _case, body in value.cases]
284
+ for body in arms + ([value.default_body] if value.default_body else []):
285
+ if len(body) == 1 and isinstance(body[0], ExprStmt):
286
+ check(self._substitute_tf_input_reads(body[0].expr, resolving))
287
+
288
+ check(node if isinstance(node, SwitchStmt)
289
+ else self._substitute_tf_input_reads(node, resolving))
290
+
291
+ def _security_tf_literal(self, tf: str) -> str:
292
+ """A timeframe string as the C++ string literal registration and the
293
+ evaluator's ``timeframe.*`` reads spell it."""
294
+ return f'"{self._cpp_string_escape(tf)}"'
295
+
248
296
  def _security_tf_unresolved(self, tf_node, what: str) -> None:
249
297
  """Refuse a request.security timeframe registration cannot compute."""
250
298
  self._codegen_error(
@@ -1981,8 +2029,9 @@ class SecurityEmitter:
1981
2029
  parts = request.annotations[RECORDED_KEY_ANNOTATION]
1982
2030
  tail = (f"|{parts['field']}|{parts['period']}|gaps_{parts['gaps']}"
1983
2031
  f"|lookahead_{parts['lookahead']}")
1984
- return (f'(std::string("{parts["fn"]}|") + {self._visit_expr(request.args[0])} + '
1985
- f'std::string("{tail}"))')
2032
+ fn = self._cpp_string_escape(parts["fn"])
2033
+ return (f'(std::string("{fn}|") + {self._visit_expr(request.args[0])} + '
2034
+ f'std::string("{self._cpp_string_escape(tail)}"))')
1986
2035
 
1987
2036
  def _recorded_sites(self) -> dict[int, int]:
1988
2037
  """Each recorded request's index N: ``_pf_recorded`` sets its
@@ -2606,7 +2655,7 @@ class SecurityEmitter:
2606
2655
 
2607
2656
  def _security_expr_hist_series_names(self, sec_id: int) -> list[str]:
2608
2657
  names = []
2609
- for (sid, _node_id), meta in sorted(self._security_expr_hist_by_node.items()):
2658
+ for (sid, _node_id), meta in self._security_expr_hist_by_node.items():
2610
2659
  if sid == sec_id:
2611
2660
  names.append(meta["name"])
2612
2661
  return names
@@ -2709,7 +2758,7 @@ class SecurityEmitter:
2709
2758
  """C++ expression for the timeframe of a request.security evaluator."""
2710
2759
  info = self._security_eval_info[sec_id]
2711
2760
  if info.get("tf"):
2712
- return f'"{info["tf"]}"'
2761
+ return self._security_tf_literal(info["tf"])
2713
2762
  if info.get("tf_expr"):
2714
2763
  return info["tf_expr"]
2715
2764
  return "input_tf_"
@@ -2775,7 +2824,7 @@ class SecurityEmitter:
2775
2824
  if series_name in self._security_string_series
2776
2825
  else "_security_helper_series_"
2777
2826
  )
2778
- return f'{store}["{series_name}"]'
2827
+ return f'{store}["{self._cpp_string_escape(series_name)}"]'
2779
2828
 
2780
2829
  def _security_helper_var_state_type(self, stmt: VarDecl) -> str:
2781
2830
  """The type family of a helper ``var`` whose declaration reads
@@ -6377,7 +6426,7 @@ class SecurityEmitter:
6377
6426
  if column is not None and self._security_foreign(sec_id):
6378
6427
  # request.footprint(...) of another symbol: the delta its feed
6379
6428
  # records for the requested bar (the value its delta() reads).
6380
- return f'_pf_symbol_column({sec_id}, "{column}")'
6429
+ return f'_pf_symbol_column({sec_id}, "{self._cpp_string_escape(column)}")'
6381
6430
 
6382
6431
  if resolving is None:
6383
6432
  resolving = set()
@@ -7178,7 +7227,8 @@ class SecurityEmitter:
7178
7227
  "request.security: a Heikin-Ashi request inside another request's "
7179
7228
  "expression reads that request's Heikin-Ashi bars, which PineForge "
7180
7229
  "does not build")
7181
- return (f'([&]() -> {cpp_t} {{ pine_runtime_error(std::string("{message}")); '
7230
+ line = expr_node.loc.line if expr_node.loc else 1
7231
+ return (f'([&]() -> {cpp_t} {{ _PF_UNSUPPORTED_STOP("nested_heikinashi_request", {line}, "{message}"); '
7182
7232
  f"return {na_value}; }}())")
7183
7233
 
7184
7234
  site = self._get_ta_site(expr_node)
@@ -7837,8 +7887,9 @@ class SecurityEmitter:
7837
7887
  def _security_variant_order_key(self, signature: tuple, binding_stack) -> tuple:
7838
7888
  """The order of a TA site's requested-context variants (``_v0``,
7839
7889
  ``_v1``, ...): where each argument binding's value is written in the
7840
- source, then the signature. The signature names a bound node by its
7841
- ``id()``, so ordering by its ``repr`` followed memory addresses, and
7890
+ source, then the signature's stable scope snapshot, preserving
7891
+ traversal order at ties. The signature names a bound node by its
7892
+ ``id()``, so ordering by its raw ``repr`` followed memory addresses, and
7842
7893
  the same script could number its variants differently from run to
7843
7894
  run or between CPython and Pyodide."""
7844
7895
  frames = []
@@ -7860,4 +7911,9 @@ class SecurityEmitter:
7860
7911
  frames.append(("arguments", tuple(items)))
7861
7912
  else:
7862
7913
  frames.append(("locals", tuple(sorted(frame.keys()))))
7863
- return tuple(frames), repr(signature)
7914
+ def stable_part(value):
7915
+ if isinstance(value, tuple):
7916
+ return tuple(stable_part(item) for item in value)
7917
+ return 0 if isinstance(value, int) else value
7918
+
7919
+ return tuple(frames), repr(stable_part(signature))
@@ -545,6 +545,18 @@ ORDER_DIRECTION_MAP = {
545
545
  "descending": 'std::string("descending")',
546
546
  }
547
547
 
548
+ NAME_ECHO_STRING_MEMBERS = {
549
+ "currency": frozenset({
550
+ "AED", "ARS", "AUD", "BDT", "BHD", "BRL", "BTC", "CAD", "CHF", "CLP",
551
+ "CNY", "COP", "CZK", "DKK", "EGP", "ETH", "EUR", "GBP", "HKD", "HUF",
552
+ "IDR", "ILS", "INR", "ISK", "JPY", "KES", "KRW", "KWD", "LKR", "MAD",
553
+ "MXN", "MYR", "NGN", "NOK", "NONE", "NZD", "PEN", "PHP", "PKR", "PLN",
554
+ "QAR", "RON", "RSD", "RUB", "SAR", "SEK", "SGD", "THB", "TND", "TRY",
555
+ "TWD", "USD", "USDT", "VES", "VND", "ZAR",
556
+ }),
557
+ "format": frozenset({"inherit", "price", "volume", "percent", "mintick"}),
558
+ }
559
+
548
560
 
549
561
  # ---------------------------------------------------------------------------
550
562
  # Array / Map / Matrix method dispatch
@@ -553,6 +565,7 @@ ORDER_DIRECTION_MAP = {
553
565
 
554
566
  def _checked_array_index_prelude(
555
567
  *,
568
+ method: str,
556
569
  normalize_negative: bool = True,
557
570
  allow_size: bool = False,
558
571
  name: str = "index",
@@ -590,18 +603,18 @@ def _checked_array_index_prelude(
590
603
  f"using {raw_type}=std::decay_t<decltype({raw_value})>; "
591
604
  f"if constexpr(!std::is_same_v<{raw_type},bool>) {{ "
592
605
  f"if(is_na({raw_value})) "
593
- "pine_runtime_error(std::string(\"Index na is out of bounds. Array size is \")+"
606
+ f'_PF_ARRAY_STOP("index_out_of_bounds", "{method}", std::string("Index na is out of bounds. Array size is ")+ '
594
607
  "std::to_string((int64_t)__pf_array.size())); } "
595
608
  f"if constexpr(std::is_floating_point_v<{raw_type}>) {{ "
596
609
  f"if(!std::isfinite({raw_value})) {{ "
597
610
  f"std::string {raw_text}={raw_value}>0?\"inf\":\"-inf\"; "
598
- f"pine_runtime_error(std::string(\"Index \")+{raw_text}+"
611
+ f'_PF_ARRAY_STOP("index_out_of_bounds", "{method}", std::string("Index ")+{raw_text}+'
599
612
  "\" is out of bounds. Array size is \"+"
600
613
  "std::to_string((int64_t)__pf_array.size())); } "
601
614
  f"long double {raw_wide}=(long double){raw_value}; "
602
615
  f"if({raw_wide}<(long double)std::numeric_limits<int64_t>::min()||"
603
616
  f"{raw_wide}>(long double)std::numeric_limits<int64_t>::max()) "
604
- "pine_runtime_error(std::string(\"Index \")+"
617
+ f'_PF_ARRAY_STOP("index_out_of_bounds", "{method}", std::string("Index ")+ '
605
618
  f"std::to_string((double){raw_value})+"
606
619
  "\" is out of bounds. Array size is \"+"
607
620
  "std::to_string((int64_t)__pf_array.size())); } "
@@ -609,12 +622,12 @@ def _checked_array_index_prelude(
609
622
  f"int64_t {size}=(int64_t)__pf_array.size(); "
610
623
  f"int64_t {checked}={checked_index}; "
611
624
  f"if({checked}<0||{checked}{upper}{size}) "
612
- f"pine_runtime_error(std::string(\"Index \")+std::to_string({raw})+"
625
+ f'_PF_ARRAY_STOP("index_out_of_bounds", "{method}", std::string("Index ")+std::to_string({raw})+'
613
626
  f"\" is out of bounds. Array size is \"+std::to_string({size})); "
614
627
  )
615
628
 
616
629
 
617
- def _checked_array_range_prelude(*, reject_inverted: bool = True) -> str:
630
+ def _checked_array_range_prelude(*, method: str, reject_inverted: bool = True) -> str:
618
631
  """Validate the half-open ``[index_from, index_to)`` range of fill/slice.
619
632
 
620
633
  Both endpoints are checked with ``allow_size`` (``index_to`` is exclusive,
@@ -625,14 +638,14 @@ def _checked_array_range_prelude(*, reject_inverted: bool = True) -> str:
625
638
  """
626
639
  return (
627
640
  _checked_array_index_prelude(
628
- normalize_negative=False, allow_size=True, name="index_from"
641
+ method=method, normalize_negative=False, allow_size=True, name="index_from"
629
642
  )
630
643
  + _checked_array_index_prelude(
631
- normalize_negative=False, allow_size=True, name="index_to"
644
+ method=method, normalize_negative=False, allow_size=True, name="index_to"
632
645
  )
633
646
  + (
634
647
  "if(__pf_array_index_from>__pf_array_index_to) "
635
- "pine_runtime_error(\"Index 'from' should be less than index 'to'.\"); "
648
+ '_PF_ARRAY_STOP("slice_range_inverted", "slice", "Index \'from\' should be less than index \'to\'."); '
636
649
  if reject_inverted else ""
637
650
  )
638
651
  )
@@ -644,7 +657,7 @@ def _checked_array_insert(a: str, args: list[str], normalize_negative: bool = Tr
644
657
  ``index == size`` appends, so the bound is ``index <= size``; a negative
645
658
  index is end-relative, exactly as for ``get``/``set``/``remove``.
646
659
  """
647
- check = _checked_array_index_prelude(allow_size=True,
660
+ check = _checked_array_index_prelude(method="insert", allow_size=True,
648
661
  normalize_negative=normalize_negative)
649
662
  return (
650
663
  "[&](auto&& __pf_array){ "
@@ -659,7 +672,7 @@ def _checked_array_insert(a: str, args: list[str], normalize_negative: bool = Tr
659
672
 
660
673
  def _checked_array_fill_range(a: str, args: list[str]) -> str:
661
674
  """``array.fill(id, value, index_from, index_to)`` — bounded range fill."""
662
- check = _checked_array_range_prelude(reject_inverted=False)
675
+ check = _checked_array_range_prelude(method="fill", reject_inverted=False)
663
676
  return (
664
677
  "[&](auto&& __pf_array){ "
665
678
  "return [&](auto&& __pf_array_value){ "
@@ -679,7 +692,7 @@ def checked_array_slice(a: str, args: list[str], *, result_type: str) -> str:
679
692
  ``result_type`` stays caller-supplied so the typed method lane keeps
680
693
  emitting the receiver's own element type; only the bounds checks are new.
681
694
  """
682
- check = _checked_array_range_prelude()
695
+ check = _checked_array_range_prelude(method="slice")
683
696
  return (
684
697
  "[&](auto&& __pf_array){ "
685
698
  "return [&](auto&& __pf_raw_index_from_value){ "
@@ -692,7 +705,7 @@ def checked_array_slice(a: str, args: list[str], *, result_type: str) -> str:
692
705
 
693
706
 
694
707
  def _checked_array_get(a: str, args: list[str], normalize_negative: bool = True) -> str:
695
- check = _checked_array_index_prelude(normalize_negative=normalize_negative)
708
+ check = _checked_array_index_prelude(method="get", normalize_negative=normalize_negative)
696
709
  return (
697
710
  "[&](auto&& __pf_array)->decltype(auto){ "
698
711
  "return [&](auto&& __pf_raw_index_value)->decltype(auto){ "
@@ -707,7 +720,7 @@ def _checked_array_get(a: str, args: list[str], normalize_negative: bool = True)
707
720
 
708
721
 
709
722
  def _checked_array_set(a: str, args: list[str], normalize_negative: bool = True) -> str:
710
- check = _checked_array_index_prelude(normalize_negative=normalize_negative)
723
+ check = _checked_array_index_prelude(method="set", normalize_negative=normalize_negative)
711
724
  return (
712
725
  "[&](auto&& __pf_array){ "
713
726
  "return [&](auto&& __pf_raw_index_value){ "
@@ -719,7 +732,7 @@ def _checked_array_set(a: str, args: list[str], normalize_negative: bool = True)
719
732
 
720
733
 
721
734
  def _checked_array_remove(a: str, args: list[str], normalize_negative: bool = True) -> str:
722
- check = _checked_array_index_prelude(normalize_negative=normalize_negative)
735
+ check = _checked_array_index_prelude(method="remove", normalize_negative=normalize_negative)
723
736
  return (
724
737
  "[&](auto&& __pf_array){ "
725
738
  "return [&](auto&& __pf_raw_index_value){ "
@@ -738,7 +751,7 @@ def _checked_array_end_get(a: str, method: str) -> str:
738
751
  access = "front" if method == "first" else "back"
739
752
  return (
740
753
  "[&](auto&& __pf_array)->decltype(auto){ "
741
- f"if(__pf_array.empty()) pine_runtime_error(\"Cannot use {method}() "
754
+ f'if(__pf_array.empty()) _PF_ARRAY_STOP("empty_array_access", "{method}", "Cannot use {method}() '
742
755
  "if array is empty.\"); "
743
756
  "if constexpr(std::is_lvalue_reference_v<decltype(__pf_array)>) "
744
757
  f"return (__pf_array.{access}()); "
@@ -758,7 +771,7 @@ def _checked_array_end_remove(a: str, method: str) -> str:
758
771
  )
759
772
  return (
760
773
  "[&](auto&& __pf_array){ "
761
- f"if(__pf_array.empty()) pine_runtime_error(\"Cannot use {method}() "
774
+ f'if(__pf_array.empty()) _PF_ARRAY_STOP("empty_array_access", "{method}", "Cannot use {method}() '
762
775
  "if array is empty.\"); "
763
776
  "using __pf_array_value_type=typename "
764
777
  "std::decay_t<decltype(__pf_array)>::value_type; "
@@ -769,7 +782,7 @@ def _checked_array_end_remove(a: str, method: str) -> str:
769
782
 
770
783
  def _checked_array_percentrank(a: str, args: list[str]) -> str:
771
784
  """Preserve degenerate results, then reject invalid PercentRank indices."""
772
- check = _checked_array_index_prelude(normalize_negative=False)
785
+ check = _checked_array_index_prelude(method="percentrank", normalize_negative=False)
773
786
  return (
774
787
  "[&](auto&& __pf_array){ "
775
788
  "return [&](auto&& __pf_raw_index_value){ "
@@ -810,7 +823,7 @@ ARRAY_METHODS = {
810
823
  "size": lambda a, args: f"(double){a}.size()",
811
824
  "clear": lambda a, args: f"{a}.clear()",
812
825
  "fill": lambda a, args: f"std::fill({a}.begin(), {a}.end(), {args[0]})" if len(args) == 1
813
- else _checked_array_fill_range(a, args),
826
+ else _checked_array_fill_range(a, args + [f"(int){a}.size()"] if len(args) == 2 else args),
814
827
  "includes": lambda a, args: f"(std::find({a}.begin(), {a}.end(), {args[0]}) != {a}.end())",
815
828
  "indexof": lambda a, args: f"[&](){{ auto __pf_it=std::find({a}.begin(),{a}.end(),{args[0]}); return __pf_it!={a}.end()?(double)(__pf_it-{a}.begin()):-1.0; }}()",
816
829
  "lastindexof": lambda a, args: f"[&](){{ for(int __pf_i=(int){a}.size()-1;__pf_i>=0;__pf_i--)if({a}[__pf_i]=={args[0]})return(double)__pf_i; return -1.0; }}()",
@@ -888,6 +901,9 @@ CHECKED_ARRAY_METHOD_KWARGS: dict[str, list[str]] = {
888
901
  "last": [],
889
902
  "pop": [],
890
903
  "shift": [],
904
+ "sort": ["order"],
905
+ "indexof": ["value"],
906
+ "unshift": ["value"],
891
907
  }
892
908
 
893
909
  MAP_METHODS = {
@@ -942,10 +958,109 @@ def _matrix_add_col(m: str, args: list) -> str:
942
958
  raise IndexError("matrix.add_col")
943
959
 
944
960
 
945
- # Keyword parameter order for matrix methods (Pine v6); used by ``_merge_kwargs``.
961
+ def _matrix_sum(receiver: str, arguments: list[str]) -> str:
962
+ """Use the runtime's exposed Eigen arithmetic for matrix/scalar sums."""
963
+ if not arguments:
964
+ return f"{receiver}.sum()"
965
+ if len(arguments) != 1:
966
+ raise IndexError("matrix.sum")
967
+ return (
968
+ "([](const auto& _pf_sum_left, const auto& _pf_sum_right) { "
969
+ "auto _pf_sum_result = _pf_sum_left.copy(); "
970
+ "if constexpr (std::is_arithmetic_v<std::decay_t<decltype(_pf_sum_right)>>) { "
971
+ "_pf_sum_result.data().array() += _pf_sum_right; "
972
+ "} else { "
973
+ "if (_pf_sum_left.rows() != _pf_sum_right.rows() || "
974
+ "_pf_sum_left.columns() != _pf_sum_right.columns()) "
975
+ 'throw std::runtime_error("Cannot sum matrices with different dimensions."); '
976
+ "_pf_sum_result.data() += _pf_sum_right.data(); "
977
+ "} return _pf_sum_result; "
978
+ f"}}({receiver}, {arguments[0]}))"
979
+ )
980
+
981
+
982
+ def _matrix_submatrix(receiver: str, arguments: list[str]) -> str:
983
+ if len(arguments) > 4:
984
+ raise IndexError("matrix.submatrix")
985
+ if len(arguments) == 4 and all(argument is not None for argument in arguments):
986
+ return f"{receiver}.submatrix({', '.join(_matrix_int_arg(argument) for argument in arguments)})"
987
+ defaults = ["0", "_pf_matrix_target.rows()", "0", "_pf_matrix_target.columns()"]
988
+ arguments = arguments + [None] * (4 - len(arguments))
989
+ bounds = [argument if argument is not None else defaults[index]
990
+ for index, argument in enumerate(arguments)]
991
+ return ("[&](auto&& _pf_matrix_target) { return _pf_matrix_target.submatrix("
992
+ f"{', '.join(_matrix_int_arg(argument) for argument in bounds)}); }}({receiver})")
993
+
994
+
995
+ # Parameter order of the matrix methods (Pine v6), receiver excluded: the
996
+ # slots ``CallVisitor._collection_arg_nodes`` binds keyword arguments to. A
997
+ # method absent here takes only its receiver. ``fill`` names its range
998
+ # parameters so that a keyword one binds as a positional one does: the
999
+ # lowering reads only ``value`` (the engine fills the whole matrix), a
1000
+ # pre-existing approximation of both spellings that the call does not refuse.
946
1001
  MATRIX_METHOD_KWARGS: dict[str, list[str]] = {
947
- "add_row": ["row_index", "array_id"],
948
- "add_col": ["col_index", "array_id"],
1002
+ "add_row": ["row", "array_id"],
1003
+ "add_col": ["column", "array_id"],
1004
+ "sort": ["column", "order"],
1005
+ "submatrix": ["from_row", "to_row", "from_column", "to_column"],
1006
+ "sum": ["id2"],
1007
+ "diff": ["id2"],
1008
+ "mult": ["id2"],
1009
+ "get": ["row", "column"],
1010
+ "set": ["row", "column", "value"],
1011
+ "fill": ["value", "from_row", "to_row", "from_column", "to_column"],
1012
+ "row": ["row"],
1013
+ "col": ["column"],
1014
+ "remove_row": ["row"],
1015
+ "remove_col": ["column"],
1016
+ "swap_rows": ["row1", "row2"],
1017
+ "swap_columns": ["column1", "column2"],
1018
+ "reshape": ["rows", "columns"],
1019
+ "concat": ["id2", "vertical"],
1020
+ "pow": ["power"],
1021
+ "kron": ["id2"],
1022
+ }
1023
+
1024
+ # Keyword spellings an earlier build bound that Pine's reference does not
1025
+ # name; they keep binding to the same slot.
1026
+ MATRIX_METHOD_KWARG_ALIASES: dict[str, dict[str, str]] = {
1027
+ "add_row": {"row_index": "row"},
1028
+ "add_col": {"col_index": "column"},
1029
+ }
1030
+
1031
+ # The receiver's own keyword in the namespace form (``matrix.diff(id1=m, ...)``).
1032
+ MATRIX_RECEIVER_KEYWORD: dict[str, str] = {
1033
+ "sum": "id1", "diff": "id1", "mult": "id1", "concat": "id1", "kron": "id1",
1034
+ }
1035
+
1036
+
1037
+ def _matrix_functional_receiver(method: str, node):
1038
+ """The receiver node of ``matrix.<method>(...)``: its first positional
1039
+ argument, else its keyword."""
1040
+ return node.args[0] if node.args else node.kwargs.get(
1041
+ MATRIX_RECEIVER_KEYWORD.get(method, "id"))
1042
+
1043
+
1044
+ # The optional parameters of the collection methods whose keywords bind by
1045
+ # position, and what a gap left before one stands for when only a later
1046
+ # keyword is written (``m.sort(order = order.descending)`` leaves ``column``
1047
+ # open): a number is Pine's default, ``None`` an omitted argument the method's
1048
+ # template defaults from the receiver's own extent (``_matrix_submatrix``'s
1049
+ # ``to_row`` / ``to_column``, the defaulted ``add_row`` / ``add_col`` of
1050
+ # ``TypeInferer._matrix_method_expr``). A parameter that is not listed is
1051
+ # required: a gap before it is refused.
1052
+ MATRIX_OPTIONAL_PARAMS: dict[str, dict[str, int | None]] = {
1053
+ "sort": {"column": 0, "order": None},
1054
+ "submatrix": {"from_row": 0, "to_row": None, "from_column": 0, "to_column": None},
1055
+ "add_row": {"row": None, "array_id": None},
1056
+ "add_col": {"column": None, "array_id": None},
1057
+ "fill": {"from_row": None, "to_row": None, "from_column": None, "to_column": None},
1058
+ "sum": {"id2": None},
1059
+ "concat": {"vertical": None},
1060
+ }
1061
+ ARRAY_OPTIONAL_PARAMS: dict[str, dict[str, int | None]] = {
1062
+ "fill": {"index_from": 0, "index_to": None},
1063
+ "sort": {"order": None},
949
1064
  }
950
1065
 
951
1066
  # Matrix mutators whose established C++ lowering returns ``void``. A Pine
@@ -1012,17 +1127,17 @@ MATRIX_METHODS = {
1012
1127
  "swap_rows": lambda m, args: f"{m}.swap_rows({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])})",
1013
1128
  "swap_columns": lambda m, args: f"{m}.swap_columns({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])})",
1014
1129
  "copy": lambda m, args: f"{m}.copy()",
1015
- "submatrix": lambda m, args: f"{m}.submatrix({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])}, {_matrix_int_arg(args[2])}, {_matrix_int_arg(args[3])})",
1130
+ "submatrix": _matrix_submatrix,
1016
1131
  "reshape": lambda m, args: f"{m}.reshape({_matrix_int_arg(args[0])}, {_matrix_int_arg(args[1])})",
1017
1132
  "reverse": lambda m, args: f"{m}.reverse()",
1018
1133
  "transpose": lambda m, args: f"{m}.transpose()",
1019
- "sort": lambda m, args: f"{m}.sort({_matrix_int_arg(args[0])}, {args[1]} != \"descending\")" if len(args)>1 else f"{m}.sort({_matrix_int_arg(args[0])})",
1134
+ "sort": lambda m, args: f"{m}.sort({_matrix_int_arg(args[0])}, {args[1]} != \"descending\")" if len(args)>1 else f"{m}.sort({_matrix_int_arg(args[0])})" if args else f"{m}.sort(0)",
1020
1135
  "concat": lambda m, args: f"{m}.concat({args[0]}, {pine_truth_cast(args[1])})" if len(args)>1 else f"{m}.concat({args[0]}, true)",
1021
1136
  "avg": lambda m, args: f"{m}.avg()",
1022
1137
  "min": lambda m, args: f"{m}.min()",
1023
1138
  "max": lambda m, args: f"{m}.max()",
1024
1139
  "mode": lambda m, args: f"{m}.mode()",
1025
- "sum": lambda m, args: f"{m}.sum()",
1140
+ "sum": _matrix_sum,
1026
1141
  "diff": lambda m, args: f"{m}.diff({args[0]})",
1027
1142
  "mult": lambda m, args: f"{m}.mult({args[0]})",
1028
1143
  "pow": lambda m, args: f"{m}.pow({_matrix_int_arg(args[0])})",
@@ -1116,10 +1231,11 @@ MATH_FUNC_MAP = {
1116
1231
  STR_FUNC_MAP = {
1117
1232
  "tostring": None, # handled separately (already works)
1118
1233
  "tonumber": lambda args: (
1119
- f"[&](){{ "
1120
- f"try {{ return std::stod({args[0]}); }} "
1121
- f"catch (...) {{ return na<double>(); }} "
1122
- f"}}()"
1234
+ f"[&](std::string _pf_number_text){{ "
1235
+ f"try {{ return std::stod(_pf_number_text); }} "
1236
+ f"catch (const std::invalid_argument&) {{ return na<double>(); }} "
1237
+ f"catch (const std::out_of_range&) {{ return na<double>(); }} "
1238
+ f"}}(({args[0]}))"
1123
1239
  ),
1124
1240
  "length": lambda args: f"(int){args[0]}.length()",
1125
1241
  "contains": lambda args: f"({args[0]}.find({args[1]}) != std::string::npos)",
@@ -60,7 +60,7 @@ static std::string _pf_tv_decimal(double value, int min_fraction,
60
60
  char buffer[128];
61
61
  const auto converted = std::to_chars(buffer, buffer + sizeof buffer, value);
62
62
  if (converted.ec != std::errc{})
63
- throw std::runtime_error("shortest-decimal conversion failed");
63
+ _PF_ENGINE_INVARIANT("shortest-decimal conversion failed", std::runtime_error);
64
64
  std::string spelling(buffer, converted.ptr);
65
65
  const bool negative = !spelling.empty() && spelling[0] == '-';
66
66
  if (negative) spelling.erase(0, 1);
@@ -240,7 +240,14 @@ static std::string pine_str_format_tv(
240
240
  i = end + 1;
241
241
  continue;
242
242
  }
243
- const size_t index = static_cast<size_t>(std::stoul(index_text));
243
+ size_t index = 0;
244
+ for (char _pf_digit : index_text) {
245
+ const size_t _pf_value = static_cast<size_t>(_pf_digit - '0');
246
+ if (index > (std::numeric_limits<size_t>::max() - _pf_value) / 10) {
247
+ _PF_STRING_STOP("format_index_overflow", "stoul");
248
+ }
249
+ index = index * 10 + _pf_value;
250
+ }
244
251
  if (index >= args.size()) {
245
252
  result += format_string.substr(i, end - i + 1);
246
253
  i = end + 1;