nc-gcode-interpreter 0.2.3__tar.gz → 0.2.5__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.3 → nc_gcode_interpreter-0.2.5}/CHANGELOG.md +34 -0
  2. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/Cargo.lock +8 -1
  3. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/Cargo.toml +2 -1
  4. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/PKG-INFO +1 -1
  5. nc_gcode_interpreter-0.2.5/docs/resumable-interpretation-design.md +233 -0
  6. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/interpret_rules.rs +10 -2
  7. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/interpreter.rs +8 -1
  8. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/output.rs +128 -17
  9. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/state.rs +36 -11
  10. nc_gcode_interpreter-0.2.5/src/vm.rs +661 -0
  11. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/.github/workflows/build-and-release.yml +0 -0
  12. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/.gitignore +0 -0
  13. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/CONTRIBUTING.md +0 -0
  14. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/Development.md +0 -0
  15. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/LICENSE +0 -0
  16. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/README.md +0 -0
  17. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/TODO.md +0 -0
  18. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/docs/sinumerik-execution-model.md +0 -0
  19. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/actual_position.csv +0 -0
  20. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/actual_position.mpf +0 -0
  21. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/arc.csv +0 -0
  22. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/arc.mpf +0 -0
  23. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/arrays.csv +0 -0
  24. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/arrays.mpf +0 -0
  25. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/axis_index_assignment.csv +0 -0
  26. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/axis_index_assignment.mpf +0 -0
  27. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/basic_math.csv +0 -0
  28. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/basic_math.mpf +0 -0
  29. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/case.csv +0 -0
  30. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/case.mpf +0 -0
  31. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/case_insensitive_variables.csv +0 -0
  32. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/case_insensitive_variables.mpf +0 -0
  33. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/custom_vars.csv +0 -0
  34. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/custom_vars.mpf +0 -0
  35. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/def_string.csv +0 -0
  36. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/def_string.mpf +0 -0
  37. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/defaults.csv +0 -0
  38. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/defaults.mpf +0 -0
  39. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/edge_cases.csv +0 -0
  40. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/edge_cases.mpf +0 -0
  41. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/flattening/flatten_demo.mpf +0 -0
  42. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/flattening/flatten_demo_flattened.csv +0 -0
  43. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/flattening/flatten_demo_raw.csv +0 -0
  44. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/for_loop.csv +0 -0
  45. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/for_loop.mpf +0 -0
  46. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/function_calls.csv +0 -0
  47. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/function_calls.mpf +0 -0
  48. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/goto.csv +0 -0
  49. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/goto.mpf +0 -0
  50. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/if_statement.csv +0 -0
  51. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/if_statement.mpf +0 -0
  52. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/increment.csv +0 -0
  53. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/increment.mpf +0 -0
  54. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/logic_operators.csv +0 -0
  55. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/logic_operators.mpf +0 -0
  56. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/loop.csv +0 -0
  57. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/loop.mpf +0 -0
  58. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/multiple_m_codes.csv +0 -0
  59. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/multiple_m_codes.mpf +0 -0
  60. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/precedence.csv +0 -0
  61. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/precedence.mpf +0 -0
  62. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/r_param.csv +0 -0
  63. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/r_param.mpf +0 -0
  64. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/repeat.csv +0 -0
  65. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/repeat.mpf +0 -0
  66. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/simple.csv +0 -0
  67. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/simple.mpf +0 -0
  68. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/spline.csv +0 -0
  69. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/spline.mpf +0 -0
  70. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/tool.csv +0 -0
  71. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/tool.mpf +0 -0
  72. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trans.csv +0 -0
  73. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trans.mpf +0 -0
  74. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trans2.csv +0 -0
  75. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trans2.mpf +0 -0
  76. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trans_ic.csv +0 -0
  77. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trans_ic.mpf +0 -0
  78. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trans_reset.csv +0 -0
  79. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trans_reset.mpf +0 -0
  80. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trig.csv +0 -0
  81. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/trig.mpf +0 -0
  82. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/variables.csv +0 -0
  83. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/examples/variables.mpf +0 -0
  84. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/ggroups/generate_g_commands.py +0 -0
  85. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/pyproject.toml +0 -0
  86. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/example/minimal.py +0 -0
  87. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/example/streaming.py +0 -0
  88. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/nc_gcode_interpreter/__init__.py +0 -0
  89. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/nc_gcode_interpreter/_internal.pyi +0 -0
  90. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/nc_gcode_interpreter/cli.py +0 -0
  91. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/nc_gcode_interpreter/ggroups.json +0 -0
  92. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/nc_gcode_interpreter/py.typed +0 -0
  93. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/nc_gcode_interpreter/viz.py +0 -0
  94. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_arithmetic_functions.py +0 -0
  95. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_batch_variables.py +0 -0
  96. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_batches.py +0 -0
  97. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_diagnostics.py +0 -0
  98. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_expected_output.py +0 -0
  99. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_field_defects.py +0 -0
  100. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_flags.py +0 -0
  101. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_flatten.py +0 -0
  102. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_g_groups.py +0 -0
  103. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_g_vocabulary.py +0 -0
  104. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_jumps.py +0 -0
  105. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_real_comparisons.py +0 -0
  106. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_stage1.py +0 -0
  107. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_streaming.py +0 -0
  108. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_structured_errors.py +0 -0
  109. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/python/tests/test_unsupported.py +0 -0
  110. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/rustfmt.toml +0 -0
  111. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/errors.rs +0 -0
  112. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/flatten.rs +0 -0
  113. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/grammar.pest +0 -0
  114. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/lib.rs +0 -0
  115. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/line_driver.rs +0 -0
  116. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/main.rs +0 -0
  117. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/modal_groups.rs +0 -0
  118. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/structure_scan.rs +0 -0
  119. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/src/types.rs +0 -0
  120. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/tests/cli.rs +0 -0
  121. {nc_gcode_interpreter-0.2.3 → nc_gcode_interpreter-0.2.5}/uv.lock +0 -0
@@ -4,6 +4,40 @@ 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.5] - 2026-07-08
8
+
9
+ ### Changed
10
+
11
+ - Interpreter throughput on the DataFrame/batch path: the per-output-row `Row`
12
+ allocations are now recycled instead of freed. Profiling the 1.1 GB → DataFrame
13
+ conversion showed ~50% of CPU in the system allocator, dominated by allocating
14
+ and freeing each of the 22M rows' cell buffers. After a batch is built its rows
15
+ are cleared (capacity retained) into a pool and handed back to the interpreter
16
+ to refill, bounding live row allocations to ~2× the batch size instead of the
17
+ whole-file row count. Output is byte-identical; the streaming (`nc_to_rows`)
18
+ and in-memory collect paths are unchanged. ~10% off end-to-end on the large
19
+ real-world program (#61).
20
+
21
+ ## [v0.2.4] - 2026-07-08
22
+
23
+ ### Changed
24
+
25
+ - Interpreter throughput: the hot, closed-vocabulary lookup maps (`axes`,
26
+ `translation`, `output_keys`, and the table builder's per-cell/forward-fill
27
+ maps) now use the non-cryptographic FxHash hasher, and `State::update_axis`
28
+ overwrites in place instead of re-allocating the key on repeat writes. ~17%
29
+ off the collect (dataframe) path on the large real-world benchmark, with no
30
+ behavior change. `symbol_table` deliberately stays on the default SipHash
31
+ hasher: its keys are user-controlled variable names, so it keeps its
32
+ hash-flooding resistance (#60).
33
+
34
+ ### Added
35
+
36
+ - Experimental execution-cursor interpreter (opt-in via `NC_VM=1`): the
37
+ recursive control-flow walk is reified into an explicit frame stack, enabling
38
+ in-memory checkpoint/resume. Off by default; the standard path is unchanged.
39
+ Groundwork for resumable/streaming interpretation (#47, #59).
40
+
7
41
  ## [v0.2.3] - 2026-07-07
8
42
 
9
43
  ### Added
@@ -506,7 +506,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
506
506
 
507
507
  [[package]]
508
508
  name = "nc-gcode-interpreter"
509
- version = "0.2.3"
509
+ version = "0.2.5"
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.3"
3
+ version = "0.2.5"
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.3
3
+ Version: 0.2.5
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*).
@@ -7,6 +7,11 @@ use std::collections::HashMap;
7
7
 
8
8
  type Output = crate::output::OutputRows;
9
9
 
10
+ // Experimental execution-cursor VM (#47), a child module so it can reuse this
11
+ // module's private leaf fns via `super::`. Gated behind `NC_VM=1`.
12
+ #[path = "vm.rs"]
13
+ pub(crate) mod vm;
14
+
10
15
  /// Control-flow signal returned by block interpretation: either fall through
11
16
  /// to the next block, or a pending GOTO that must be resolved against the
12
17
  /// block list of the current scope or, failing that, an enclosing scope
@@ -1850,8 +1855,11 @@ fn interpret_statement(element: Pair<Rule>, output: &mut Output, state: &mut Sta
1850
1855
  match state.resolve_output_key(&key) {
1851
1856
  Some((ColKind::Axis, skey)) => {
1852
1857
  // State keeps local coordinates; the output row gets the machine
1853
- // coordinate under the translation active at this point in the program.
1854
- let machine_value = state.get_axis_machine(skey).unwrap_or(local_value);
1858
+ // coordinate under the translation active here. `update_axis` just
1859
+ // stored this axis' local == `local_value`, so machine =
1860
+ // local_value + translation — one lookup instead of
1861
+ // `get_axis_machine`'s two (it would re-read the axis we just set).
1862
+ let machine_value = local_value + state.get_translation(skey);
1855
1863
  last.insert(skey, Value::Float(machine_value));
1856
1864
  }
1857
1865
  Some((ColKind::Block, skey)) => {
@@ -270,7 +270,14 @@ fn interpret_file(input: &str, state: &mut State, output: &mut OutputRows) -> Re
270
270
  message: "No inner blocks found".to_string(),
271
271
  })?;
272
272
 
273
- match interpret_blocks(blocks, output, state)? {
273
+ // Experimental: the explicit-stack VM (#47) runs the same structured path
274
+ // when NC_VM=1, for differential parity against the recursive walker.
275
+ let flow = if crate::interpret_rules::vm::vm_enabled() {
276
+ crate::interpret_rules::vm::run(blocks, output, state)?
277
+ } else {
278
+ interpret_blocks(blocks, output, state)?
279
+ };
280
+ match flow {
274
281
  BlockFlow::Continue | BlockFlow::EndProgram => Ok(()),
275
282
  // A jump that no scope could resolve: the destination does not exist
276
283
  // in the programmed search direction (alarm 14080 on a real control).
@@ -14,6 +14,11 @@ use crate::types::Value;
14
14
  use std::collections::{HashMap, HashSet};
15
15
  use std::sync::{Mutex, OnceLock};
16
16
 
17
+ /// FxHash map for the hot table-build lookups (`index_of`, per cell per row;
18
+ /// `fill`, per column per batch) — trusted `&'static str` keys, no need for
19
+ /// SipHash's DoS resistance. See [`crate::state::FxMap`].
20
+ type FxMap<K, V> = HashMap<K, V, rustc_hash::FxBuildHasher>;
21
+
17
22
  /// Intern a column name to a process-stable `&'static str`.
18
23
  ///
19
24
  /// The set of distinct output-column names is a small closed vocabulary
@@ -77,6 +82,13 @@ impl CellMap {
77
82
  None
78
83
  }
79
84
 
85
+ /// Empty the cells, retaining the backing allocation so a recycled row can
86
+ /// be refilled without a fresh heap allocation (see [`Row::reset_for_reuse`]).
87
+ #[inline]
88
+ pub fn clear(&mut self) {
89
+ self.entries.clear();
90
+ }
91
+
80
92
  #[inline]
81
93
  pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
82
94
  self.entries.iter_mut().find(|(k, _)| *k == key).map(|(_, v)| v)
@@ -126,6 +138,20 @@ pub struct Row {
126
138
  pub variable_changes: Vec<(String, f64)>,
127
139
  }
128
140
 
141
+ impl Row {
142
+ /// Empty this row for reuse as the next `current`, retaining both the cells
143
+ /// and variable-change allocations. On the batch path a finished row's
144
+ /// buffers are consumed into the columnar batch and then recycled here,
145
+ /// which collapses the per-row heap alloc/free (~22M on the 1.1 GB file,
146
+ /// the dominant allocator cost) into a small pool cycled batch-to-batch.
147
+ #[inline]
148
+ pub fn reset_for_reuse(&mut self) {
149
+ self.line_no = 0;
150
+ self.cells.clear();
151
+ self.variable_changes.clear();
152
+ }
153
+ }
154
+
129
155
  /// Sparse side-table of variable-change events for the batch path, mirroring
130
156
  /// what the streaming `nc_to_rows` yields per row as `variable_changes`. Stored
131
157
  /// columnar (struct-of-arrays) so it converts to an Arrow table cheaply:
@@ -200,6 +226,10 @@ pub struct BatchStreamSink {
200
226
  sender: std::sync::mpsc::SyncSender<Table>,
201
227
  builder: BatchBuilder,
202
228
  buffer: Vec<Row>,
229
+ /// Emptied rows kept for reuse: after a batch is built the buffered rows'
230
+ /// allocations are cleared (not freed) into this pool, and `accept` hands
231
+ /// them back to the interpreter to refill instead of allocating fresh.
232
+ recycle: Vec<Row>,
203
233
  batch_size: usize,
204
234
  /// Whether to accumulate variable-change events (opt-in `include_variables`).
205
235
  record_variables: bool,
@@ -221,7 +251,7 @@ impl BatchStreamSink {
221
251
  /// rows have accumulated. Variable-only rows carry no output cells and are
222
252
  /// dropped from the batch table, but their variable changes are still
223
253
  /// recorded into the sparse event log when `record_variables` is on.
224
- fn accept(&mut self, row: Row) -> Result<(), ParsingError> {
254
+ fn accept(&mut self, mut row: Row) -> Result<Option<Row>, ParsingError> {
225
255
  if self.record_variables {
226
256
  for (name, value) in &row.variable_changes {
227
257
  let id = match self.name_ids.get(name) {
@@ -241,15 +271,20 @@ impl BatchStreamSink {
241
271
  self.events.value.push(*value);
242
272
  }
243
273
  }
274
+ // A variable-only row carries no output cell: it is not buffered, so hand
275
+ // its (cleared) allocation straight back for reuse.
244
276
  if row.cells.is_empty() {
245
- return Ok(());
277
+ row.reset_for_reuse();
278
+ return Ok(Some(row));
246
279
  }
247
280
  self.output_row_count += 1;
248
281
  self.buffer.push(row);
249
282
  if self.buffer.len() >= self.batch_size {
250
283
  self.emit()?;
251
284
  }
252
- Ok(())
285
+ // Hand back a pooled row (refilled after the last emit) so the caller
286
+ // refills a retained allocation instead of allocating a fresh one.
287
+ Ok(self.recycle.pop())
253
288
  }
254
289
 
255
290
  /// Send the accumulated variable-change events once, at finish. A no-op when
@@ -267,8 +302,20 @@ impl BatchStreamSink {
267
302
  if self.buffer.is_empty() {
268
303
  return Ok(());
269
304
  }
270
- let rows = std::mem::take(&mut self.buffer);
305
+ // Take the buffered rows out to build the batch, then recycle their
306
+ // allocations: `build_batch` only borrows the rows (it copies each cell
307
+ // value into the columnar builders), so once it returns the row buffers
308
+ // are free to be cleared and pooled rather than dropped.
309
+ let mut rows = std::mem::take(&mut self.buffer);
271
310
  let table = self.builder.build_batch(&rows);
311
+ self.recycle.reserve(rows.len());
312
+ for mut row in rows.drain(..) {
313
+ row.reset_for_reuse();
314
+ self.recycle.push(row);
315
+ }
316
+ // `rows` is now empty but keeps its `Vec<Row>` capacity - reinstate it as
317
+ // the buffer so the outer vector is not reallocated each batch either.
318
+ self.buffer = rows;
272
319
  self.sender.send(table).map_err(|_| ParsingError::StreamClosed)
273
320
  }
274
321
  }
@@ -357,6 +404,7 @@ impl OutputRows {
357
404
  sender,
358
405
  builder: BatchBuilder::new(disable_forward_fill).with_line_numbers(emit_line_no),
359
406
  buffer: Vec::new(),
407
+ recycle: Vec::new(),
360
408
  batch_size,
361
409
  record_variables,
362
410
  events_sender,
@@ -378,32 +426,86 @@ impl OutputRows {
378
426
  self.flattener = Some(flattener);
379
427
  }
380
428
 
381
- /// Route a finished row to the sink, passing it through the flattener
382
- /// first when one is installed. Shared by `flush`.
383
- fn deliver(&mut self, row: Row) -> Result<(), ParsingError> {
429
+ /// Number of committed rows in a `Collect` sink (0 for streaming sinks).
430
+ /// Lets the experimental resumable VM (#47) pause at a row boundary.
431
+ #[allow(dead_code)]
432
+ pub(crate) fn collected_len(&self) -> usize {
433
+ match &self.sink {
434
+ RowSink::Collect(rows) => rows.len(),
435
+ _ => 0,
436
+ }
437
+ }
438
+
439
+ /// Deep-clone a checkpointable `Collect`-sink output: the committed rows,
440
+ /// the in-flight row, and the warn latches. `None` when the sink is a
441
+ /// streaming channel or a flattener is installed (their state isn't
442
+ /// snapshottable this simply). For the experimental resumable VM (#47);
443
+ /// the whole-file forward-fill happens later in `Table::from_rows`, so a
444
+ /// Collect snapshot needs no columnar carry.
445
+ #[allow(dead_code)]
446
+ pub(crate) fn snapshot_collect(&self) -> Option<OutputRows> {
447
+ if self.flattener.is_some() {
448
+ return None;
449
+ }
450
+ match &self.sink {
451
+ RowSink::Collect(rows) => Some(OutputRows {
452
+ current: self.current.clone(),
453
+ sink: RowSink::Collect(rows.clone()),
454
+ record_variables: self.record_variables,
455
+ flattener: None,
456
+ warned_g91: self.warned_g91,
457
+ }),
458
+ _ => None,
459
+ }
460
+ }
461
+
462
+ /// Return a recycled row to the sink's pool. Used when a single `deliver`
463
+ /// yields more recycled rows than the one the caller can reuse as `current`
464
+ /// (the flattener fans one block out to many rows): the surplus goes back to
465
+ /// the pool instead of being freed, so recycling stays effective on
466
+ /// flattened paths too. A no-op for the non-recycling sinks.
467
+ fn reclaim(&mut self, row: Row) {
468
+ if let RowSink::Batch(sink) = &mut self.sink {
469
+ sink.recycle.push(row);
470
+ }
471
+ }
472
+
473
+ /// Route a row to the sink, passing it through the flattener first when one
474
+ /// is installed, and returning an emptied row the sink no longer needs
475
+ /// (batch path) so the caller can reuse its allocation as the next
476
+ /// `current`. `None` when nothing is available to recycle.
477
+ fn deliver(&mut self, row: Row) -> Result<Option<Row>, ParsingError> {
384
478
  if let Some(mut flattener) = self.flattener.take() {
385
479
  let mut flattened = Vec::new();
386
480
  flattener.push(row, &mut flattened);
387
481
  self.flattener = Some(flattener);
482
+ // Keep one recycled row for the caller to reuse; hand any others the
483
+ // sink returns this call straight back to the pool so they are not
484
+ // freed (a flattened block delivers many rows in one `deliver`).
485
+ let mut recycled = None;
388
486
  for row in flattened {
389
- self.deliver_to_sink(row)?;
487
+ if let Some(r) = self.deliver_to_sink(row)? {
488
+ if let Some(prev) = recycled.replace(r) {
489
+ self.reclaim(prev);
490
+ }
491
+ }
390
492
  }
391
- return Ok(());
493
+ return Ok(recycled);
392
494
  }
393
495
  self.deliver_to_sink(row)
394
496
  }
395
497
 
396
498
  /// Route a finished row to the sink: collected, streamed row-at-a-time, or
397
499
  /// fed to the worker-side batch producer.
398
- fn deliver_to_sink(&mut self, row: Row) -> Result<(), ParsingError> {
500
+ fn deliver_to_sink(&mut self, row: Row) -> Result<Option<Row>, ParsingError> {
399
501
  match &mut self.sink {
400
502
  RowSink::Collect(rows) => {
401
503
  rows.push(row);
402
- Ok(())
504
+ Ok(None)
403
505
  }
404
506
  // The receiver hung up: the consumer stopped iterating. Abort
405
507
  // interpretation instead of running the rest of the program.
406
- RowSink::Stream(sender) => sender.send(row).map_err(|_| ParsingError::StreamClosed),
508
+ RowSink::Stream(sender) => sender.send(row).map(|_| None).map_err(|_| ParsingError::StreamClosed),
407
509
  RowSink::Batch(sink) => sink.accept(row),
408
510
  }
409
511
  }
@@ -426,7 +528,13 @@ impl OutputRows {
426
528
  self.warned_g91 = true;
427
529
  }
428
530
  }
429
- self.deliver(row)
531
+ // `mem::take` above left `current` as an empty (unallocated) default; if
532
+ // the sink handed back a pooled row, adopt its retained allocation for
533
+ // the next block instead of allocating fresh on the first cell insert.
534
+ if let Some(recycled) = self.deliver(row)? {
535
+ self.current = recycled;
536
+ }
537
+ Ok(())
430
538
  }
431
539
 
432
540
  /// Begin the row for the block at `line_no`, flushing the previous row.
@@ -710,7 +818,7 @@ pub struct BatchBuilder {
710
818
  /// table.
711
819
  columns: Vec<&'static str>,
712
820
  /// Last carried non-null value per forward-filled column.
713
- fill: HashMap<&'static str, Carry>,
821
+ fill: FxMap<&'static str, Carry>,
714
822
  }
715
823
 
716
824
  impl BatchBuilder {
@@ -719,7 +827,7 @@ impl BatchBuilder {
719
827
  disable_forward_fill,
720
828
  emit_line_no: false,
721
829
  columns: Vec::new(),
722
- fill: HashMap::new(),
830
+ fill: FxMap::default(),
723
831
  }
724
832
  }
725
833
 
@@ -756,7 +864,8 @@ impl BatchBuilder {
756
864
  // One typed builder per column, in canonical order, each pre-filled
757
865
  // with `height` nulls. A name->position index lets each cell find its
758
866
  // builder in O(1).
759
- let mut index_of: HashMap<&'static str, usize> = HashMap::with_capacity(self.columns.len());
867
+ let mut index_of: FxMap<&'static str, usize> =
868
+ FxMap::with_capacity_and_hasher(self.columns.len(), Default::default());
760
869
  let mut builders: Vec<ColumnBuilder> = Vec::with_capacity(self.columns.len());
761
870
  for (position, &name) in self.columns.iter().enumerate() {
762
871
  index_of.insert(name, position);
@@ -765,7 +874,9 @@ impl BatchBuilder {
765
874
 
766
875
  // Single pass: dispatch each present cell to its column builder. Every
767
876
  // cell key is in `present` (hence in `index_of`), so the lookup always
768
- // hits; the `if let` is defensive only.
877
+ // hits; the `if let` is defensive only. FxHash beat a linear scan of
878
+ // `self.columns` here (~22 keys need content compare — interning does
879
+ // NOT guarantee pointer identity across all column sources).
769
880
  for (row_index, cell) in cells.iter().enumerate() {
770
881
  for (&key, value) in cell.iter() {
771
882
  if let Some(&position) = index_of.get(key) {