nc-gcode-interpreter 0.2.2__tar.gz → 0.2.4__tar.gz

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 (121) hide show
  1. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/CHANGELOG.md +40 -0
  2. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/Cargo.lock +8 -1
  3. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/Cargo.toml +2 -1
  4. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/PKG-INFO +1 -1
  5. nc_gcode_interpreter-0.2.4/docs/resumable-interpretation-design.md +233 -0
  6. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/nc_gcode_interpreter/_internal.pyi +4 -0
  7. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_structured_errors.py +41 -0
  8. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/errors.rs +240 -8
  9. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/interpret_rules.rs +35 -8
  10. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/interpreter.rs +8 -1
  11. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/lib.rs +13 -8
  12. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/output.rs +45 -4
  13. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/state.rs +45 -12
  14. nc_gcode_interpreter-0.2.4/src/vm.rs +661 -0
  15. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/.github/workflows/build-and-release.yml +0 -0
  16. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/.gitignore +0 -0
  17. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/CONTRIBUTING.md +0 -0
  18. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/Development.md +0 -0
  19. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/LICENSE +0 -0
  20. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/README.md +0 -0
  21. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/TODO.md +0 -0
  22. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/docs/sinumerik-execution-model.md +0 -0
  23. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/actual_position.csv +0 -0
  24. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/actual_position.mpf +0 -0
  25. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/arc.csv +0 -0
  26. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/arc.mpf +0 -0
  27. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/arrays.csv +0 -0
  28. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/arrays.mpf +0 -0
  29. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/axis_index_assignment.csv +0 -0
  30. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/axis_index_assignment.mpf +0 -0
  31. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/basic_math.csv +0 -0
  32. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/basic_math.mpf +0 -0
  33. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/case.csv +0 -0
  34. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/case.mpf +0 -0
  35. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/case_insensitive_variables.csv +0 -0
  36. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/case_insensitive_variables.mpf +0 -0
  37. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/custom_vars.csv +0 -0
  38. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/custom_vars.mpf +0 -0
  39. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/def_string.csv +0 -0
  40. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/def_string.mpf +0 -0
  41. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/defaults.csv +0 -0
  42. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/defaults.mpf +0 -0
  43. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/edge_cases.csv +0 -0
  44. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/edge_cases.mpf +0 -0
  45. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/flattening/flatten_demo.mpf +0 -0
  46. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/flattening/flatten_demo_flattened.csv +0 -0
  47. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/flattening/flatten_demo_raw.csv +0 -0
  48. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/for_loop.csv +0 -0
  49. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/for_loop.mpf +0 -0
  50. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/function_calls.csv +0 -0
  51. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/function_calls.mpf +0 -0
  52. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/goto.csv +0 -0
  53. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/goto.mpf +0 -0
  54. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/if_statement.csv +0 -0
  55. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/if_statement.mpf +0 -0
  56. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/increment.csv +0 -0
  57. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/increment.mpf +0 -0
  58. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/logic_operators.csv +0 -0
  59. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/logic_operators.mpf +0 -0
  60. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/loop.csv +0 -0
  61. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/loop.mpf +0 -0
  62. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/multiple_m_codes.csv +0 -0
  63. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/multiple_m_codes.mpf +0 -0
  64. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/precedence.csv +0 -0
  65. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/precedence.mpf +0 -0
  66. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/r_param.csv +0 -0
  67. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/r_param.mpf +0 -0
  68. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/repeat.csv +0 -0
  69. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/repeat.mpf +0 -0
  70. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/simple.csv +0 -0
  71. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/simple.mpf +0 -0
  72. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/spline.csv +0 -0
  73. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/spline.mpf +0 -0
  74. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/tool.csv +0 -0
  75. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/tool.mpf +0 -0
  76. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trans.csv +0 -0
  77. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trans.mpf +0 -0
  78. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trans2.csv +0 -0
  79. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trans2.mpf +0 -0
  80. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trans_ic.csv +0 -0
  81. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trans_ic.mpf +0 -0
  82. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trans_reset.csv +0 -0
  83. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trans_reset.mpf +0 -0
  84. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trig.csv +0 -0
  85. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/trig.mpf +0 -0
  86. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/variables.csv +0 -0
  87. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/examples/variables.mpf +0 -0
  88. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/ggroups/generate_g_commands.py +0 -0
  89. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/pyproject.toml +0 -0
  90. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/example/minimal.py +0 -0
  91. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/example/streaming.py +0 -0
  92. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/nc_gcode_interpreter/__init__.py +0 -0
  93. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/nc_gcode_interpreter/cli.py +0 -0
  94. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/nc_gcode_interpreter/ggroups.json +0 -0
  95. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/nc_gcode_interpreter/py.typed +0 -0
  96. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/nc_gcode_interpreter/viz.py +0 -0
  97. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_arithmetic_functions.py +0 -0
  98. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_batch_variables.py +0 -0
  99. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_batches.py +0 -0
  100. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_diagnostics.py +0 -0
  101. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_expected_output.py +0 -0
  102. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_field_defects.py +0 -0
  103. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_flags.py +0 -0
  104. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_flatten.py +0 -0
  105. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_g_groups.py +0 -0
  106. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_g_vocabulary.py +0 -0
  107. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_jumps.py +0 -0
  108. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_real_comparisons.py +0 -0
  109. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_stage1.py +0 -0
  110. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_streaming.py +0 -0
  111. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/python/tests/test_unsupported.py +0 -0
  112. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/rustfmt.toml +0 -0
  113. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/flatten.rs +0 -0
  114. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/grammar.pest +0 -0
  115. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/line_driver.rs +0 -0
  116. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/main.rs +0 -0
  117. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/modal_groups.rs +0 -0
  118. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/structure_scan.rs +0 -0
  119. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/src/types.rs +0 -0
  120. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/tests/cli.rs +0 -0
  121. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.4}/uv.lock +0 -0
@@ -4,6 +4,46 @@ Notable changes to **nc-gcode-interpreter**. The format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are git tags,
5
5
  released to PyPI.
6
6
 
7
+ ## [v0.2.4] - 2026-07-08
8
+
9
+ ### Changed
10
+
11
+ - Interpreter throughput: the hot, closed-vocabulary lookup maps (`axes`,
12
+ `translation`, `output_keys`, and the table builder's per-cell/forward-fill
13
+ maps) now use the non-cryptographic FxHash hasher, and `State::update_axis`
14
+ overwrites in place instead of re-allocating the key on repeat writes. ~17%
15
+ off the collect (dataframe) path on the large real-world benchmark, with no
16
+ behavior change. `symbol_table` deliberately stays on the default SipHash
17
+ hasher: its keys are user-controlled variable names, so it keeps its
18
+ hash-flooding resistance (#60).
19
+
20
+ ### Added
21
+
22
+ - Experimental execution-cursor interpreter (opt-in via `NC_VM=1`): the
23
+ recursive control-flow walk is reified into an explicit frame stack, enabling
24
+ in-memory checkpoint/resume. Off by default; the standard path is unchanged.
25
+ Groundwork for resumable/streaming interpretation (#47, #59).
26
+
27
+ ## [v0.2.3] - 2026-07-07
28
+
29
+ ### Added
30
+
31
+ - `NcError.kind`: a stable, machine-readable string discriminating the error
32
+ class (e.g. `"unexpected_axis"`, `"undefined_variable"`, `"unknown_g_command"`,
33
+ `"parse_context"`), so a consumer can branch on the kind of error without
34
+ string-matching the formatted message. Present on every `NcError` alongside
35
+ the existing `line` / `column` / `context` / `line_text` location attributes
36
+ (#56).
37
+
38
+ ### Fixed
39
+
40
+ - Validation/semantic errors now carry their source location. `Unexpected axis`,
41
+ and the "axis/reserved name used as a variable" definition errors, previously
42
+ raised an `NcError` with `line` / `column` / `context` / `line_text` all
43
+ `None`; they now anchor to the offending line (and expose its text), so an
44
+ editor can mark the exact spot - matching what syntactic parse errors already
45
+ did (#56).
46
+
7
47
  ## [v0.2.2] - 2026-07-07
8
48
 
9
49
  ### Added
@@ -506,7 +506,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
506
506
 
507
507
  [[package]]
508
508
  name = "nc-gcode-interpreter"
509
- version = "0.2.2"
509
+ version = "0.2.4"
510
510
  dependencies = [
511
511
  "arrow-array",
512
512
  "arrow-data",
@@ -517,6 +517,7 @@ dependencies = [
517
517
  "pest_derive",
518
518
  "pyo3",
519
519
  "rayon",
520
+ "rustc-hash",
520
521
  "thiserror",
521
522
  ]
522
523
 
@@ -726,6 +727,12 @@ dependencies = [
726
727
  "crossbeam-utils",
727
728
  ]
728
729
 
730
+ [[package]]
731
+ name = "rustc-hash"
732
+ version = "2.1.3"
733
+ source = "registry+https://github.com/rust-lang/crates.io-index"
734
+ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
735
+
729
736
  [[package]]
730
737
  name = "rustversion"
731
738
  version = "1.0.22"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "nc-gcode-interpreter"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -19,6 +19,7 @@ pest_derive = "2.8.6"
19
19
  thiserror = "2.0.18"
20
20
  clap = { version = "4.6", features = ["derive"] }
21
21
  rayon = "1.10"
22
+ rustc-hash = "2.1"
22
23
 
23
24
  # NOTE: keep every dependency version inline (`crate = { version = "..." }`),
24
25
  # NOT as a `[dependencies.crate]` block with a standalone `version = "..."`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nc-gcode-interpreter
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -0,0 +1,233 @@
1
+ # Resumable interpretation — checkpoint/resume design (#47, targeting 0.3.0)
2
+
3
+ Status: **design + proof-of-concept**. This document scopes the resumable-`NcSession`
4
+ feature requested in issue #47 and the interpreter change it requires. A working
5
+ Phase-1 proof of concept — the execution-cursor VM (`src/vm.rs`, opt-in via
6
+ `NC_VM=1`) plus in-memory checkpoint/resume — now exists alongside this design;
7
+ the public API and Phases 2–3 remain future work.
8
+
9
+ ## 1. Goal and consumer requirements
10
+
11
+ Expose a resumable interpretation handle:
12
+
13
+ - `checkpoint() -> token` — capture the complete execution position roughly
14
+ every *N* output rows.
15
+ - `resume(token, patched_text)` — restart interpretation from a checkpoint,
16
+ against an edited program.
17
+
18
+ Consumer: ribweaver `/sim` phase-E *incremental re-interpret* for multi-million-row
19
+ loop expansions. When the user edits source line *L*, it resumes from the last
20
+ checkpoint whose block cursor precedes *L* instead of re-running the whole program.
21
+ Downstream is evidence-gated: debounced-full re-interpret is fine below ~10⁵ rows,
22
+ so the resume path only has to pay off for the *giant* case — which is exactly the
23
+ hard one, because a million-row expansion is typically **one** top-level `WHILE`/`LOOP`.
24
+ Top-level-only checkpoints would yield a single checkpoint for the whole expansion
25
+ and be useless; checkpoints must be capturable **mid-loop**.
26
+
27
+ Cancellation already exists (drop the iterator → `StreamClosed`); resume composes
28
+ with it as the latest-wins primitive.
29
+
30
+ ## 2. Why this is hard — the crux
31
+
32
+ The issue's premise ("no call stack exists, so block-granularity checkpoints are
33
+ tractable") is half right. There is no *subprogram* call stack (#48), but the
34
+ **control structures form an implicit stack**, and the instruction pointer lives
35
+ on the **Rust call stack**, not in any serializable structure.
36
+
37
+ Precise findings (`src/interpret_rules.rs`):
38
+
39
+ - The instruction pointer is `index: usize`, a **stack-local** in `run_blocks`
40
+ (`:2071-2103`) — one per active scope. There is no IP counter in `State`.
41
+ - Every control construct drives its body by **recursively calling**
42
+ `interpret_blocks`, holding borrowed `Pair<Rule>` iterators and a `loop_count`
43
+ as stack locals: `WHILE` (`:1386`), `LOOP` (`:1413`), `FOR` (`:1434`),
44
+ `REPEAT/UNTIL` (`:1493`), `IF/ELSE` (`:1305`), `CASE` (`:1623`).
45
+ - `Pair<Rule>` is `pest::iterators::Pair<'i, Rule>` — a **borrowed cursor into the
46
+ parse tree**, tied to the lifetime of the input `&str`. Execution positions are
47
+ expressed as *paths within the pest tree*: inherently non-serializable.
48
+ - `loop_count` (WHILE/LOOP/REPEAT) is **purely stack-local** — not reflected in
49
+ `State`. `FOR`'s counter *is* in `symbol_table`, but its `end_value` bound and
50
+ `variable_name` are stack-local. Loop *conditions* re-evaluate from `State` each
51
+ iteration, so they are re-derivable — but the position within the tree is not.
52
+ - `IF` branch choice is implicit in *which recursive call is currently live*.
53
+ - `BlockFlow::Jump` bubbles up through scopes via `resolve_jump` (`:184`); an
54
+ unresolved jump propagates to the enclosing `run_blocks` (`:2098`) — that is how
55
+ a jump leaves a loop or IF body. `EndProgram` (M2/M17/M30) terminates.
56
+
57
+ **Paused three loops deep inside an IF**, the resume position that is *not* in
58
+ `State` is: each nested `index`, each `jumps_taken`, each `loop_count`, each loop's
59
+ cached `end_value`/condition/blocks pest pairs, the live IF branch, and every
60
+ `block_pairs` slice cursor — all borrowed views into the pest tree.
61
+
62
+ So mid-loop resume requires **reifying the control stack** into an explicit,
63
+ serializable structure: replacing recursion with an execution cursor.
64
+
65
+ ## 3. Convergence — this is already on the roadmap
66
+
67
+ `docs/sinumerik-execution-model.md` §"Scaling limits and the road to streaming",
68
+ point 4, already prescribes this exact change for gigabyte-scale programs:
69
+
70
+ > *Execution cursor* instead of recursion, yielding `(line_no, row)` — … makes
71
+ > seek-by-checkpoint (snapshot of `pc` + `State`) cheap.
72
+
73
+ So #47 is **not a throwaway rewrite**. The execution cursor it needs is the same
74
+ one already planned for streaming/scaling. Reifying the control stack:
75
+
76
+ 1. unblocks checkpoint/resume (#47),
77
+ 2. unblocks O(1)-start lazy streaming for >10⁷-line programs, and
78
+ 3. removes the deep-recursion stack-depth risk on pathological nesting.
79
+
80
+ One change, three payoffs — that is the argument for spending a 0.3.0 on it.
81
+
82
+ ## 4. What a checkpoint must capture (full inventory)
83
+
84
+ Assembled from a line-anchored audit of the three state-bearing subsystems.
85
+
86
+ ### 4.1 `State` (`src/state.rs:52-86`) — already `#[derive(Clone)]`, cheap
87
+ `axes`, `symbol_table`, `string_table`, `translation`, `jump_scopes` (label sets,
88
+ mirrors scope depth but not the index within each), `seen_jump_targets`,
89
+ `warned_addresses`; config (`axis_identifiers`, `iteration_limit`,
90
+ `axis_index_map`, `allow_undefined_variables`, `output_keys`); `input`/`line_offsets`
91
+ shared via `Arc` so clone is O(live symbols). ✅ Already snapshot-ready in memory.
92
+
93
+ ### 4.2 Control-flow position (`src/interpret_rules.rs`) — THE reified stack
94
+ Not in `State` today; must become an explicit `Vec<Frame>`:
95
+ - per scope: `index` (IP), `jumps_taken`;
96
+ - per loop frame: kind (WHILE/LOOP/FOR/REPEAT), `loop_count`, and cached bounds
97
+ (`FOR` `end_value` + `variable_name`);
98
+ - the scope/branch identity (which blocks list, which IF branch) as a **stable
99
+ address**, not a borrowed pest `Pair`.
100
+
101
+ ### 4.3 Flattener (`src/flatten.rs:130-150`) — NOT currently `Clone`
102
+ - Reconstructable from `new(tolerance, axis_identifiers)`: `tolerance`,
103
+ `geometric_axes`.
104
+ - Must snapshot: `positions`, `motion` (modal), `plane` (modal), `spline_buffer`
105
+ (raw buffered `Row`s of an in-progress multi-block spline — no compressed form,
106
+ capture verbatim), `spline_start`, `spline_degree`, `warned_motions` (warn-once).
107
+ - Blockers: `SplineItem` needs a `Clone` derive (mechanical; wraps `Row: Clone`);
108
+ interned `&'static str` keys re-intern via `intern_column` on *disk* restore only.
109
+
110
+ ### 4.4 Output pipeline (`src/output.rs`)
111
+ - `OutputRows::current` — the **in-flight row** for the block being interpreted
112
+ (not yet flushed to the sink); `warned_g91` latch. A checkpoint between blocks
113
+ must capture `current` together with the sink, and reproduce the
114
+ flush-on-next-`start_row` ordering so no row is dropped or double-emitted.
115
+ - Batch path forward-fill carry (`BatchBuilder`): `columns` (growing canonical
116
+ set, monotonic — resuming with an empty set changes later-batch schema),
117
+ `fill` (last-seen value per forward-filled column — the core carry).
118
+ - `BatchStreamSink`: `buffer` (rows since last emitted batch), `events`,
119
+ `name_ids`, `output_row_count` (drives `row_idx` attribution and batch cadence).
120
+ - Plumbing that a checkpoint *cannot* recreate: the channel handles (`Stream`
121
+ sender, batch `sender`, `events_sender`). Resume attaches fresh channels.
122
+ - Process-global (outside any checkpoint): the `intern_column` `OnceLock` pool —
123
+ serialize key *content*, rehydrate through `intern_column`.
124
+
125
+ ## 5. Design: the reified execution VM
126
+
127
+ ### 5.1 Addressing a position without a borrowed pest `Pair`
128
+ The single change that makes everything else fall out: express an execution
129
+ position as a **stable, owned address** instead of a `pest::Pair` cursor.
130
+
131
+ Two candidate representations:
132
+
133
+ - **(a) Path into the retained tree.** Keep the parse tree alive (already `Arc`-able
134
+ via the input) and address a block by a `Vec<u32>` path of child indices from the
135
+ root. Serializable (just integers); resolved to a `Pair` by walking `into_inner`.
136
+ Smallest change; still tied to pest at runtime.
137
+ - **(b) Pre-flattened owned IR (the execution cursor).** One whole-file pass lowers
138
+ the block/control tree into an owned, indexable program: a `Vec` of block
139
+ instructions with explicit scope boundaries and pre-resolved jump tables (a
140
+ whole-program `scan_jump_targets`). Execution is a `pc` over the IR with an
141
+ explicit frame stack; pest is gone at runtime. This is the roadmap's "execution
142
+ cursor" — larger, but unlocks lazy parse (Phase 3) and full serialization.
143
+
144
+ **Recommendation: target (b).** It is the roadmap direction; doing (a) then (b) is
145
+ double work. (a) is a viable *interim* only if we need #47 shipped before the IR
146
+ lands.
147
+
148
+ ### 5.2 The frame stack and the token
149
+ ```
150
+ Frame = { scope: ScopeId, ip: u32, jumps_taken: u32, loop: Option<LoopState> }
151
+ LoopState = While { count } | Loop { count } | Repeat { count }
152
+ | For { count, var: SymbolId, end: f64 }
153
+ Checkpoint = {
154
+ program_fingerprint, // structural hash of the program prefix ≤ cursor
155
+ stack: Vec<Frame>, // the reified control stack (innermost last)
156
+ state: State, // cloned
157
+ flattener: FlattenerSnapshot, // §4.3 evolving fields
158
+ output_carry: OutputCarry, // §4.4 forward-fill + counts + in-flight row
159
+ cursor_line_no: u32, // for the consumer's "checkpoint precedes L" gate
160
+ }
161
+ ```
162
+ Loop conditions are **not** stored — they re-evaluate from `state` on resume,
163
+ exactly as the interpreter does each iteration today. That is what keeps the token
164
+ small and correct.
165
+
166
+ ### 5.3 `checkpoint()` / `resume()` semantics
167
+ - `checkpoint()` is valid only at a **between-rows boundary** (`output.start_row`,
168
+ `interpret_rules.rs:2005`) where the in-flight `current` row is well-defined.
169
+ Emit checkpoints every *N* output rows.
170
+ - `resume(token, patched_text)`:
171
+ 1. Re-lower `patched_text` to the IR.
172
+ 2. Verify `program_fingerprint` against the patched program's prefix up to
173
+ `cursor_line_no`. If the edit changed block structure *before* the cursor, the
174
+ stored `ip`/`ScopeId`s are stale → **fail loudly** (`ParsingError`, a new
175
+ `kind = "checkpoint_invalidated"`), never resume into a shifted tree. This
176
+ matches the consumer's own gate ("last checkpoint whose block cursor precedes
177
+ L") and the project's loud-failure-over-silent-wrongness rule.
178
+ 3. Restore `state` + frame stack + flattener + output carry; attach fresh output
179
+ channels; continue the VM loop from `stack`.
180
+
181
+ ### 5.4 Resume + edited text — the validity contract
182
+ A checkpoint is resumable against a patched program **iff the patch lies at or
183
+ after the checkpoint's block cursor**. Enforced by the prefix fingerprint. The
184
+ consumer already honours this; the API makes it a checked invariant rather than a
185
+ convention.
186
+
187
+ ## 6. Phasing
188
+
189
+ - **Phase 1 — reify the stack (delivers #47).** Replace the recursive tree-walk in
190
+ `run_blocks`/`interpret_*` with the explicit VM + frame stack over IR (b), keeping
191
+ eager whole-file parse. Add `NcSession` with in-memory, **same-process**
192
+ `checkpoint()`/`resume()` (token holds owned `State`/frames/snapshots; no disk
193
+ serialization yet). This is everything the consumer needs.
194
+ - **Phase 2 — serializable token.** `Serialize`/`Deserialize` for the snapshots;
195
+ re-intern `&'static str` keys on load; address symbols/scopes by stable ids.
196
+ Only if cross-process/persisted checkpoints are wanted.
197
+ - **Phase 3 — lazy parse / O(1) start.** The streaming payoff: lazy per-line parse
198
+ feeding the same VM, demoting pest to the rare structural lines (the two-pass
199
+ design already prototyped in `src/line_driver.rs`). Independent follow-on.
200
+
201
+ Phases 2 and 3 are optional and can slip past 0.3.0; Phase 1 is the release.
202
+
203
+ ## 7. Correctness strategy
204
+
205
+ The VM must be a **behaviour-preserving** refactor of the tree-walker before
206
+ `checkpoint` is even exposed. Guardrails:
207
+
208
+ - **Differential parity**: run the whole existing golden-file suite + the mill-sim
209
+ corpus through both the old recursive path and the new VM, asserting byte-identical
210
+ output — the same discipline `test_stage1.py` already uses for the line-driver.
211
+ - **Checkpoint/resume equivalence**: for a program run straight through vs.
212
+ checkpointed-and-resumed at every row boundary, assert identical `(line_no, row)`
213
+ streams and identical final `State` — including the hard cases: mid-`WHILE`,
214
+ mid-`FOR` (counter in `State`, bound in the frame), mid-spline (buffered
215
+ `spline_buffer`), a jump out of a loop body, and forward-fill continuity across a
216
+ resumed batch boundary.
217
+ - **Invalidation**: edits before the cursor must fail loudly, never silently resume.
218
+
219
+ ## 8. Rough scope
220
+
221
+ Phase 1 is real interpreter surgery — the IR lowering, the VM loop, the frame
222
+ stack, `FlattenerSnapshot`/`OutputCarry` capture-restore, and the parity harness —
223
+ but it is bounded and well-understood after this audit, and it is load-bearing for
224
+ the streaming roadmap regardless of #47. Estimate: the dominant 0.3.0 work item.
225
+ Phases 2–3 are additive and independently schedulable.
226
+
227
+ ## 9. Open questions for review
228
+ - (b) IR now vs (a) tree-path interim — how urgently does the consumer need #47
229
+ relative to the streaming work? If both are wanted this quarter, go straight to (b).
230
+ - Token type: opaque handle only (Phase 1) vs. a documented serializable schema —
231
+ affects how much of §5.2 is public API from day one.
232
+ - Checkpoint cadence policy: fixed *N* rows vs. adaptive (e.g. denser near loop
233
+ headers so an edit resumes closer to *L*).
@@ -9,6 +9,10 @@ class NcError(ValueError):
9
9
  """NC parse/interpret error carrying structured location data. Subclasses
10
10
  ValueError so `except ValueError` keeps working."""
11
11
 
12
+ #: Stable, machine-readable discriminator for the error class (e.g.
13
+ #: ``"unexpected_axis"``, ``"undefined_variable"``), for branching without
14
+ #: matching the formatted message.
15
+ kind: str
12
16
  line: Optional[int]
13
17
  column: Optional[int]
14
18
  context: Optional[str]
@@ -48,3 +48,44 @@ def test_str_is_still_the_full_message():
48
48
  with pytest.raises(NcError) as exc:
49
49
  nc_to_dataframe("X=R99\n")
50
50
  assert "Undefined variable" in str(exc.value)
51
+
52
+
53
+ def test_kind_is_a_stable_machine_readable_discriminator():
54
+ # #56: consumers branch on `kind` instead of string-matching the message.
55
+ with pytest.raises(NcError) as exc:
56
+ nc_to_dataframe("X=R99\n")
57
+ assert exc.value.kind == "undefined_variable"
58
+
59
+ with pytest.raises(NcError) as exc:
60
+ nc_to_dataframe("G999 X1\n")
61
+ assert exc.value.kind == "unknown_g_command"
62
+
63
+ # Present (and stable) on every NcError, alongside the location attrs.
64
+ with pytest.raises(NcError) as exc:
65
+ nc_to_dataframe("G1 X10\nX20 Y((\n")
66
+ assert exc.value.kind == "parse_context"
67
+
68
+
69
+ def test_validation_error_carries_kind_and_location():
70
+ # #56: an undeclared axis is a validation error; it now reports both a
71
+ # stable kind AND the source location (previously all-None).
72
+ with pytest.raises(NcError) as exc:
73
+ # Q is not a declared axis; the frame instruction is on line 2.
74
+ nc_to_dataframe("G1 X0 Y0 F100\nTRANS Q=10\n")
75
+ e = exc.value
76
+ assert e.kind == "unexpected_axis"
77
+ assert e.line == 2
78
+ assert e.column is None
79
+ assert e.line_text is not None and "Q=10" in e.line_text
80
+ assert "Unexpected axis" in str(e)
81
+
82
+
83
+ def test_axis_used_as_variable_carries_location():
84
+ # Declaring a variable with an axis name is a validation error that now
85
+ # anchors to the offending DEF line.
86
+ with pytest.raises(NcError) as exc:
87
+ nc_to_dataframe("DEF REAL X\n")
88
+ e = exc.value
89
+ assert e.kind == "axis_used_as_variable"
90
+ assert e.line == 1
91
+ assert e.line_text is not None and "X" in e.line_text
@@ -99,11 +99,26 @@ To fix this, ensure that each block contains at most one M command.
99
99
  message: String,
100
100
  },
101
101
  #[error("Unexpected axis '{axis}'. Valid axes are: {axes}")]
102
- UnexpectedAxis { axis: String, axes: String },
102
+ UnexpectedAxis {
103
+ axis: String,
104
+ axes: String,
105
+ /// Source location of the offending axis word. Not shown in the
106
+ /// formatted message, only exposed as data (see [`ParsingError::location`]).
107
+ line_no: usize,
108
+ preview: String,
109
+ },
103
110
  #[error("Cannot define a variable named '{name}', as it conflicts with an axis name")]
104
- AxisUsedAsVariable { name: String },
111
+ AxisUsedAsVariable {
112
+ name: String,
113
+ line_no: usize,
114
+ preview: String,
115
+ },
105
116
  #[error("Cannot define a variable named '{name}', as it is a reserved block address (spline PW/SD/PL)")]
106
- ReservedNameUsedAsVariable { name: String },
117
+ ReservedNameUsedAsVariable {
118
+ name: String,
119
+ line_no: usize,
120
+ preview: String,
121
+ },
107
122
  #[error(
108
123
  r#"
109
124
  Missing axis mapping on line {line_no}
@@ -280,16 +295,48 @@ impl ParsingError {
280
295
  | Self::JumpTargetNotFound { line_no, preview, .. }
281
296
  | Self::UnmatchedStructure { line_no, preview, .. }
282
297
  | Self::UnknownGCommand { line_no, preview, .. }
283
- | Self::InvalidFunctionArity { line_no, preview, .. } => some(*line_no, None, None, Some(preview)),
298
+ | Self::InvalidFunctionArity { line_no, preview, .. }
299
+ // Semantic/validation errors: the offending block is known at raise
300
+ // time, so they anchor to a line (no column) like the others above.
301
+ | Self::UnexpectedAxis { line_no, preview, .. }
302
+ | Self::AxisUsedAsVariable { line_no, preview, .. }
303
+ | Self::ReservedNameUsedAsVariable { line_no, preview, .. } => some(*line_no, None, None, Some(preview)),
284
304
  Self::ParseError { .. }
285
305
  | Self::InvalidElementCount { .. }
286
306
  | Self::InvalidCondition
287
307
  | Self::UnexpectedOperator { .. }
288
308
  | Self::LoopLimit { .. }
289
- | Self::StreamClosed
290
- | Self::UnexpectedAxis { .. }
291
- | Self::AxisUsedAsVariable { .. }
292
- | Self::ReservedNameUsedAsVariable { .. } => None,
309
+ | Self::StreamClosed => None,
310
+ }
311
+ }
312
+
313
+ /// A stable, machine-readable discriminator for the error class, so a
314
+ /// consumer can branch on the kind of error without string-matching the
315
+ /// formatted message. Exposed to Python as the `NcError.kind` attribute.
316
+ /// These strings are part of the public API: keep them stable.
317
+ pub fn kind(&self) -> &'static str {
318
+ match self {
319
+ Self::ParsingContext { .. } => "parse_context",
320
+ Self::UnknownVariable { .. } => "unknown_variable",
321
+ Self::UndefinedVariable { .. } => "undefined_variable",
322
+ Self::UnexpectedRule { .. } => "unexpected_rule",
323
+ Self::ParseError { .. } => "parse_error",
324
+ Self::InvalidElementCount { .. } => "invalid_element_count",
325
+ Self::InvalidCondition => "invalid_condition",
326
+ Self::UnexpectedOperator { .. } => "unexpected_operator",
327
+ Self::LoopLimit { .. } => "loop_limit",
328
+ Self::StreamClosed => "stream_closed",
329
+ Self::TooManyMCommands { .. } => "too_many_m_commands",
330
+ Self::UnexpectedAxis { .. } => "unexpected_axis",
331
+ Self::AxisUsedAsVariable { .. } => "axis_used_as_variable",
332
+ Self::ReservedNameUsedAsVariable { .. } => "reserved_name_used_as_variable",
333
+ Self::MissingAxisMapping { .. } => "missing_axis_mapping",
334
+ Self::InvalidAxisIndex { .. } => "invalid_axis_index",
335
+ Self::UnsupportedStatement { .. } => "unsupported_statement",
336
+ Self::JumpTargetNotFound { .. } => "jump_target_not_found",
337
+ Self::UnmatchedStructure { .. } => "unmatched_structure",
338
+ Self::UnknownGCommand { .. } => "unknown_g_command",
339
+ Self::InvalidFunctionArity { .. } => "invalid_function_arity",
293
340
  }
294
341
  }
295
342
  }
@@ -333,4 +380,189 @@ mod tests {
333
380
  assert!(ParsingError::StreamClosed.location().is_none());
334
381
  assert!(ParsingError::InvalidCondition.location().is_none());
335
382
  }
383
+
384
+ #[test]
385
+ fn validation_errors_now_carry_a_location() {
386
+ // #56: semantic/validation errors (previously location-less) anchor to
387
+ // the offending line so an editor can mark the spot.
388
+ let axis = ParsingError::UnexpectedAxis {
389
+ axis: "QQ".to_string(),
390
+ axes: "X, Y, Z".to_string(),
391
+ line_no: 4,
392
+ preview: "TRANS QQ10".to_string(),
393
+ };
394
+ let loc = axis.location().expect("UnexpectedAxis now has a location");
395
+ assert_eq!((loc.line, loc.column), (4, None));
396
+ assert_eq!(loc.line_text.as_deref(), Some("TRANS QQ10"));
397
+
398
+ let dup = ParsingError::AxisUsedAsVariable {
399
+ name: "X".to_string(),
400
+ line_no: 2,
401
+ preview: "DEF REAL X".to_string(),
402
+ };
403
+ assert_eq!(dup.location().expect("has location").line, 2);
404
+ }
405
+
406
+ #[test]
407
+ fn kind_is_a_stable_per_variant_discriminator() {
408
+ // Exhaustive: `kind()` strings are public API, so pin every variant's
409
+ // value. The wildcard-free match in `kind()` forces a new variant to be
410
+ // handled there; this list forces its string to be chosen deliberately.
411
+ use crate::types::Rule;
412
+ let s = String::new;
413
+ let cases: Vec<(ParsingError, &str)> = vec![
414
+ (
415
+ ParsingError::ParsingContext {
416
+ line_no: 1,
417
+ column: None,
418
+ preview: s(),
419
+ context: s(),
420
+ message: s(),
421
+ },
422
+ "parse_context",
423
+ ),
424
+ (
425
+ ParsingError::UnknownVariable {
426
+ line_no: 1,
427
+ preview: s(),
428
+ variable: s(),
429
+ },
430
+ "unknown_variable",
431
+ ),
432
+ (
433
+ ParsingError::UndefinedVariable {
434
+ line_no: 1,
435
+ preview: s(),
436
+ name: s(),
437
+ },
438
+ "undefined_variable",
439
+ ),
440
+ (
441
+ ParsingError::UnexpectedRule {
442
+ rule: Rule::EOI,
443
+ context: s(),
444
+ line_no: 1,
445
+ preview: s(),
446
+ message: s(),
447
+ },
448
+ "unexpected_rule",
449
+ ),
450
+ (ParsingError::ParseError { message: s() }, "parse_error"),
451
+ (
452
+ ParsingError::InvalidElementCount { expected: 1, actual: 2 },
453
+ "invalid_element_count",
454
+ ),
455
+ (ParsingError::InvalidCondition, "invalid_condition"),
456
+ (
457
+ ParsingError::UnexpectedOperator { operator: s() },
458
+ "unexpected_operator",
459
+ ),
460
+ (ParsingError::LoopLimit { limit: s() }, "loop_limit"),
461
+ (ParsingError::StreamClosed, "stream_closed"),
462
+ (
463
+ ParsingError::TooManyMCommands {
464
+ line_no: 1,
465
+ preview: s(),
466
+ message: s(),
467
+ },
468
+ "too_many_m_commands",
469
+ ),
470
+ (
471
+ ParsingError::UnexpectedAxis {
472
+ axis: s(),
473
+ axes: s(),
474
+ line_no: 1,
475
+ preview: s(),
476
+ },
477
+ "unexpected_axis",
478
+ ),
479
+ (
480
+ ParsingError::AxisUsedAsVariable {
481
+ name: s(),
482
+ line_no: 1,
483
+ preview: s(),
484
+ },
485
+ "axis_used_as_variable",
486
+ ),
487
+ (
488
+ ParsingError::ReservedNameUsedAsVariable {
489
+ name: s(),
490
+ line_no: 1,
491
+ preview: s(),
492
+ },
493
+ "reserved_name_used_as_variable",
494
+ ),
495
+ (
496
+ ParsingError::MissingAxisMapping {
497
+ line_no: 1,
498
+ preview: s(),
499
+ axis: s(),
500
+ },
501
+ "missing_axis_mapping",
502
+ ),
503
+ (
504
+ ParsingError::InvalidAxisIndex {
505
+ line_no: 1,
506
+ preview: s(),
507
+ axis: s(),
508
+ index: 0,
509
+ },
510
+ "invalid_axis_index",
511
+ ),
512
+ (
513
+ ParsingError::UnsupportedStatement {
514
+ line_no: 1,
515
+ preview: s(),
516
+ statement: s(),
517
+ hint: s(),
518
+ },
519
+ "unsupported_statement",
520
+ ),
521
+ (
522
+ ParsingError::JumpTargetNotFound {
523
+ line_no: 1,
524
+ preview: s(),
525
+ target: s(),
526
+ search_direction: s(),
527
+ hint: s(),
528
+ },
529
+ "jump_target_not_found",
530
+ ),
531
+ (
532
+ ParsingError::UnmatchedStructure {
533
+ line_no: 1,
534
+ preview: s(),
535
+ message: s(),
536
+ },
537
+ "unmatched_structure",
538
+ ),
539
+ (
540
+ ParsingError::UnknownGCommand {
541
+ line_no: 1,
542
+ preview: s(),
543
+ code: s(),
544
+ },
545
+ "unknown_g_command",
546
+ ),
547
+ (
548
+ ParsingError::InvalidFunctionArity {
549
+ line_no: 1,
550
+ preview: s(),
551
+ name: s(),
552
+ expected: 1,
553
+ actual: 2,
554
+ },
555
+ "invalid_function_arity",
556
+ ),
557
+ ];
558
+ for (err, expected) in &cases {
559
+ assert_eq!(err.kind(), *expected, "kind mismatch for {err:?}");
560
+ }
561
+ // Every kind string is distinct (no two variants share a discriminator).
562
+ let mut kinds: Vec<&str> = cases.iter().map(|(_, k)| *k).collect();
563
+ kinds.sort_unstable();
564
+ let unique = kinds.len();
565
+ kinds.dedup();
566
+ assert_eq!(kinds.len(), unique, "duplicate kind discriminator");
567
+ }
336
568
  }