ZibinDong commited on
Commit
1bfc3f0
·
verified ·
1 Parent(s): 59442cc

Publish audited ActionCodec2 release files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +46 -96
  2. actioncodec2-0.1.1.tar.gz → actioncodec2-0.3.0.tar.gz +2 -2
  3. actioncodec2_kernels/__init__.py +0 -1
  4. actioncodec2_kernels/bpe.cpp +428 -0
  5. actioncodec2_kernels/common.hpp +139 -0
  6. actioncodec2_kernels/grammar.cpp +243 -126
  7. actioncodec2_kernels/inference.cpp +245 -0
  8. actioncodec2_kernels/module.cpp +63 -25
  9. actioncodec2_kernels/parallel.hpp +5 -1
  10. actioncodec2_kernels/physical.cpp +30 -86
  11. actioncodec2_kernels/preprocessing.cpp +281 -0
  12. actioncodec2_kernels/quaternion.hpp +1 -1
  13. actioncodec2_kernels/reconstruct.cpp +0 -146
  14. actioncodec2_kernels/repacking.hpp +437 -0
  15. actioncodec2_kernels/replay.cpp +160 -0
  16. actioncodec2_kernels/resample.cpp +58 -30
  17. actioncodec2_kernels/setbpe.cpp +63 -46
  18. actioncodec2_kernels/so3.cpp +62 -22
  19. actioncodec2_kernels/sparse.cpp +1308 -0
  20. actioncodec2_kernels/spatial.cpp +297 -0
  21. actioncodec2_kernels/temporal.cpp +799 -0
  22. actioncodec2_kernels/token_streams.hpp +48 -0
  23. config.json +4 -1
  24. processing_actioncodec2.py +30 -32
  25. pyproject.toml +0 -13
  26. requirements.txt +0 -6
  27. runtime/__init__.py +1 -1
  28. runtime/backend/__init__.py +0 -1
  29. runtime/backends/__init__.py +0 -0
  30. runtime/backends/actioncodec2/__init__.py +8 -0
  31. runtime/{physical → backends/actioncodec2}/_so3.py +33 -3
  32. runtime/{common → backends/actioncodec2}/_validation.py +4 -10
  33. runtime/backends/actioncodec2/assets/presets/dual_eef_abs.yaml +20 -0
  34. runtime/backends/actioncodec2/assets/presets/dual_eef_delta.yaml +20 -0
  35. runtime/backends/actioncodec2/assets/presets/dual_joint6_abs.yaml +18 -0
  36. runtime/backends/actioncodec2/assets/presets/dual_joint6_delta.yaml +16 -0
  37. runtime/backends/actioncodec2/assets/presets/dual_joint7_abs.yaml +18 -0
  38. runtime/backends/actioncodec2/assets/presets/dual_joint7_delta.yaml +16 -0
  39. runtime/backends/actioncodec2/assets/presets/single_eef_abs.yaml +14 -0
  40. runtime/backends/actioncodec2/assets/presets/single_eef_delta.yaml +14 -0
  41. runtime/backends/actioncodec2/assets/presets/single_joint6_abs.yaml +13 -0
  42. runtime/backends/actioncodec2/assets/presets/single_joint6_abs_no_gripper.yaml +12 -0
  43. runtime/backends/actioncodec2/assets/presets/single_joint6_delta.yaml +12 -0
  44. runtime/backends/actioncodec2/assets/presets/single_joint7_abs.yaml +13 -0
  45. runtime/backends/actioncodec2/assets/presets/single_joint7_delta.yaml +12 -0
  46. runtime/backends/actioncodec2/assets/profiles/eef.yaml +74 -0
  47. runtime/backends/actioncodec2/assets/profiles/eef_second_order.yaml +80 -0
  48. runtime/backends/actioncodec2/assets/profiles/joint.yaml +52 -0
  49. runtime/backends/actioncodec2/assets/profiles/joint_second_order.yaml +62 -0
  50. runtime/backends/actioncodec2/batch_semantics.py +269 -0
README.md CHANGED
@@ -9,32 +9,16 @@ tags:
9
 
10
  # ActionCodec2
11
 
12
- Tokenizers for **ActionCodec2: A Streaming Action Tokenizer**. This repository contains
13
- 30 variants with one shared runtime and an optional native extension.
 
14
 
15
- ## Editions
16
-
17
- - **Paper edition**: `paper-1st` and `paper-2nd` are the first- and second-order tokenizers
18
- used in the paper. Both use **5 mm translation precision** and a **4,096-token budget
19
- per profile**, with joint and EEF profiles.
20
- - **Extended edition**: tokenizers retrained after additional cleaning and filtering of
21
- the pretraining data. They offer first and second order, **1/5/10 mm translation
22
- precision**, and **1k–16k token budgets per profile** for a wider range of precision
23
- and vocabulary requirements. The 1 mm tier starts at 2k; its 2k variants contain
24
- EEF profiles only.
25
-
26
- Precision labels describe translation quantization settings. Rotation and joint settings
27
- are specified separately in each profile's configuration. Paper and Extended variants
28
- have separately trained vocabularies, including when their precision and budget match.
29
- The six released VLA policies use the Paper edition and must keep their original variant.
30
 
31
  ## Tokenizer usage
32
 
33
- Select a variant explicitly to load its configuration and fitted assets together:
34
-
35
  ```bash
36
- export HF_ENDPOINT=https://hf-mirror.com
37
- pip install numpy scipy torch "transformers>=4.57,<6" huggingface-hub pyyaml
38
  ```
39
 
40
  ```python
@@ -42,50 +26,46 @@ import numpy as np
42
  from transformers import AutoProcessor
43
 
44
  codec = AutoProcessor.from_pretrained(
45
- "ZibinDong/ActionCodec2", variant="paper-1st", trust_remote_code=True,
46
- revision="v0.1.1",
47
  )
48
- actions = np.zeros((12, 7), dtype=np.float32)
49
  tokens = codec.encode(actions, fps=15)
50
- decoded = codec.decode(tokens, fps=15)
51
- codec.save_pretrained("./saved-tokenizer")
52
- restored = AutoProcessor.from_pretrained(
53
- "./saved-tokenizer", trust_remote_code=True, local_files_only=True,
54
- )
55
  ```
56
 
57
- The default action space is `single_eef_delta`: translation increments in metres,
58
- body-frame rotation-vector increments in radians, and an absolute binary gripper command.
59
- Rotation follows `R_next = R_previous @ Exp(rotvec)`; the gripper opens at values >= 0.8.
60
- Pass the actual recording rate as `fps`; the tokenizer resamples to 15 Hz.
61
- Inputs have shape `(T, D)` or `(B, T, D)`; decoded CPU float32 tensors have shape `(B, T_out, D)`.
62
- Resampling may change the length. Quantization is lossy.
 
 
 
 
 
63
 
64
- Second-order variants require the preceding first-order motion at every chunk boundary:
 
 
 
 
65
 
66
  ```python
67
- codec = AutoProcessor.from_pretrained(
68
- "ZibinDong/ActionCodec2", variant="paper-2nd", trust_remote_code=True,
69
- revision="v0.1.1",
70
- )
71
- # Use a zero template only when the preceding motion is known to be zero.
72
- boundary = codec.first_order_template(batch_size=1)
73
- tokens = codec.encode(actions, fps=15, previous_first_order=boundary)
74
- decoded = codec.decode(tokens, fps=15, previous_first_order=boundary)
75
- boundary = codec.final_first_order(tokens, previous_first_order=boundary, executed_steps=None)
76
  ```
77
 
78
- `executed_steps` counts reconstructed steps at the tokenizer's internal **15 Hz**, even
79
- when `encode` or `decode` uses a different `fps`; `None` carries the full chunk boundary.
80
- `previous_first_order` uses the same 15 Hz convention. The VLA adapter handles conversion
81
- from the last raw executed action at the policy's control frequency.
82
- Use `codec.for_action_space("single_joint7_delta")` or another registered layout to change
83
- the physical input convention. Absolute layouts require `current_state` for reconstruction.
84
- The 1mm/2k variants contain EEF profiles only.
85
 
86
  ## Available variants
87
 
88
- | Variant | Edition | Order | Translation precision | Token budget per profile | Profiles |
89
  | --- | --- | --- | --- | --- | --- |
90
  | `paper-1st` | Paper | 1 | 5mm | 4096 | joint, eef |
91
  | `paper-2nd` | Paper | 2 | 5mm | 4096 | joint, eef |
@@ -117,52 +97,22 @@ The 1mm/2k variants contain EEF profiles only.
117
  | `extended-2nd-10mm-4k` | Extended | 2 | 10mm | 4096 | joint, eef |
118
  | `extended-2nd-10mm-8k` | Extended | 2 | 10mm | 8192 | joint, eef |
119
  | `extended-2nd-10mm-16k` | Extended | 2 | 10mm | 16384 | joint, eef |
 
 
 
 
120
 
121
- Joint and EEF token IDs occupy disjoint namespaces. Budgets apply separately to each
122
- profile. Quantization is lossy; the precision label alone does not determine policy accuracy.
123
- Pin `revision` to a commit for reproducibility; `variants.json` records each variant's
124
- asset hashes and its previous repository ID. Loading downloads shared files plus the
125
- selected variant, and verifies its assets. `subfolder` is not a variant selector.
126
-
127
- Saved processors contain only the selected assets and the complete portable runtime.
128
- They load offline and preserve `artifact_manifest.json` with their asset hashes and origin.
129
- Select a different variant by loading the catalog again.
130
-
131
- ## Native acceleration
132
-
133
- Build the optional C++17 kernels once for all variants:
134
-
135
- ```bash
136
- hf download ZibinDong/ActionCodec2 --revision v0.1.1 --include "setup.py" "pyproject.toml" "LICENSE" "actioncodec2_kernels/*" --local-dir ./actioncodec2-kernels
137
- pip install ./actioncodec2-kernels
138
- ```
139
-
140
- `ACTIONCODEC2_NUM_THREADS` controls the per-process thread budget.
141
- `ACTIONCODEC2_NO_NATIVE=1` selects the Python reference implementation.
142
-
143
- ## VLA inference package
144
-
145
- The versioned source archive `actioncodec2-0.1.1.tar.gz` contains policy inference code,
146
- native kernels, training recipes, and evaluation scripts. Use **Python 3.12 or later**
147
- and a C++17 compiler. Inference has been checked with Python 3.12, PyTorch 2.7.1,
148
- and Transformers 5.14.1.
149
-
150
- ```bash
151
- pip install "actioncodec2[cuda] @ https://hf-mirror.com/ZibinDong/ActionCodec2/resolve/v0.1.1/actioncodec2-0.1.1.tar.gz"
152
- ```
153
 
154
- The `cuda` extra adds Liger and Flash Linear Attention. Each policy bundles its exact
155
- AC2 variant and text tokenizer; once downloaded, inference works offline without
156
- another model or tokenizer repository. Each policy's README describes its inputs.
157
 
158
- | Training data / evaluation target | First-order policy | Second-order policy |
159
- | --- | --- | --- |
160
- | LIBERO | [First order](https://hf-mirror.com/ZibinDong/G05_ActionCodec2-1st_LIBERO) | [Second order](https://hf-mirror.com/ZibinDong/G05_ActionCodec2-2nd_LIBERO) |
161
- | Bridge / SimplerEnv (WidowX) | [First order](https://hf-mirror.com/ZibinDong/G05_ActionCodec2-1st_SimplerEnv) | [Second order](https://hf-mirror.com/ZibinDong/G05_ActionCodec2-2nd_SimplerEnv) |
162
- | DROID | [First order](https://hf-mirror.com/ZibinDong/G05_ActionCodec2-1st_DROID) | [Second order](https://hf-mirror.com/ZibinDong/G05_ActionCodec2-2nd_DROID) |
163
 
164
  ## License
165
 
166
- The tokenizer runtime and fitted assets use [Apache-2.0](LICENSE). The source archive
167
- also includes G0.5 components under `LICENSE-G0.5`. G0.5-derived policy weights use the
168
- G0.5 Community License; see the individual policy cards and the archive's license files.
 
9
 
10
  # ActionCodec2
11
 
12
+ - **Paper edition**: first- and second-order tokenizers used in the paper; 5 mm translation precision and 4,096 tokens per profile.
13
+ - **Extended edition**: retrained after additional pretraining-data cleaning and filtering, with more precision and vocabulary options.
14
+ - **Sparse version**: standard and high-dynamic first-order tokenizers, each with EEF and joint profiles and binary (`2bin`) or 21-level (`21bin`) grippers.
15
 
16
+ All 34 variants use one shared runtime. Precision labels describe translation quantization; rotation and joint limits are in each profile's configuration.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  ## Tokenizer usage
19
 
 
 
20
  ```bash
21
+ pip install numpy scipy torch "transformers>=5.2,<6" huggingface-hub pyyaml
 
22
  ```
23
 
24
  ```python
 
26
  from transformers import AutoProcessor
27
 
28
  codec = AutoProcessor.from_pretrained(
29
+ "ZibinDong/ActionCodec2", variant="paper-1st",
30
+ action_space="single_eef_delta", trust_remote_code=True,
31
  )
32
+ actions = np.zeros((1, 16, 7), dtype=np.float32)
33
  tokens = codec.encode(actions, fps=15)
34
+ restored = codec.decode(tokens, fps=15)
 
 
 
 
35
  ```
36
 
37
+ Use `variant="sparse-standard-2bin"` or `"sparse-high-dynamic-21bin"` for Sparse;
38
+ Sparse decoding requires `horizon=actions.shape[-2]` in source frames.
39
+ Each profile has its own token namespace; the budget is per profile, not the total routed vocabulary.
40
+
41
+ The EEF layout is `[x, y, z, rx, ry, rz, gripper]`, in metres and radians.
42
+ Delta rotations use `R_next = R_previous @ Exp(rotvec)`. Grippers are absolute open fractions (0 closed, 1 open).
43
+ Inputs are `(T,D)` or `(B,T,D)` and are resampled to 15 Hz. Reconstruction is lossy.
44
+ Absolute layouts also require `current_state`; second-order variants require
45
+ `previous_first_order` in canonical 15 Hz increments in both encode and decode.
46
+ Zero boundaries are valid only when the preceding motion is known to be zero.
47
+ Use `final_first_order` to carry reconstructed second-order boundaries between chunks.
48
 
49
+ `codec.print_action_spaces()` lists the layouts. Dual arms are interleaved by time;
50
+ BPE tokens never merge across arms. The historical `single_joint6_abs_continuous_gripper`
51
+ name aliases `single_joint6_abs_no_gripper`: six joint coordinates, no separate gripper slot.
52
+
53
+ ## Train tokenizers
54
 
55
  ```python
56
+ codec.fit(episodes, fps=15)
57
+ codec.save_pretrained("./my-tokenizer")
 
 
 
 
 
 
 
58
  ```
59
 
60
+ Cleaning is off by default, including VLA training and inference. Only tokenizer fitting
61
+ accepts `apply_preprocessing=True`, with a recorded or explicitly supplied `preprocessing`
62
+ configuration. It acts on continuous canonical trajectories before quantization.
63
+ Split recording discontinuities first; second-order cleaning needs `bootstrap_context=True`.
64
+ Saved cleaning settings do not enable filtering during encode or decode.
 
 
65
 
66
  ## Available variants
67
 
68
+ | Variant | Edition | Order | Translation precision | Budget / profile | Profiles |
69
  | --- | --- | --- | --- | --- | --- |
70
  | `paper-1st` | Paper | 1 | 5mm | 4096 | joint, eef |
71
  | `paper-2nd` | Paper | 2 | 5mm | 4096 | joint, eef |
 
97
  | `extended-2nd-10mm-4k` | Extended | 2 | 10mm | 4096 | joint, eef |
98
  | `extended-2nd-10mm-8k` | Extended | 2 | 10mm | 8192 | joint, eef |
99
  | `extended-2nd-10mm-16k` | Extended | 2 | 10mm | 16384 | joint, eef |
100
+ | `sparse-standard-2bin` | Sparse | 1 | 5mm | 4096 | eef, joint |
101
+ | `sparse-standard-21bin` | Sparse | 1 | 5mm | 4096 | eef, joint |
102
+ | `sparse-high-dynamic-2bin` | Sparse | 1 | 5mm | 4096 | eef, joint |
103
+ | `sparse-high-dynamic-21bin` | Sparse | 1 | 5mm | 4096 | eef, joint |
104
 
105
+ Pin `revision` for reproducibility. `variants.json` records asset hashes; a selected
106
+ `save_pretrained()` directory contains its assets and the shared portable runtime.
107
+ The optional C++17 kernels can be built with `pip install ./downloaded-tokenizer`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
+ ## VLA policies
 
 
110
 
111
+ Installation, paper-policy evaluation and VLA training: [source repository](https://github.com/ZibinDong/actioncodec2).
112
+ The existing six AC2 policies retain their single-arm tokenizer behavior. The released Sparse
113
+ profiles have a 4,096-token budget; the current G0.5 Sparse adapter additionally needs an EOS
114
+ position and therefore requires a vocabulary of at most 4,095 tokens when training a new VLA.
 
115
 
116
  ## License
117
 
118
+ Tokenizer code and assets: Apache-2.0. G0.5 policy code and weights use their separate community license.
 
 
actioncodec2-0.1.1.tar.gz → actioncodec2-0.3.0.tar.gz RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:dac31a13dfbe46534186f8b51db19fceab9febcfe54fd880f029b5fd9ce5915d
3
- size 302806
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fdb76fb260435f45fba5a6572122eee3f3ceacb11480091f781e645ab60c0bec
3
+ size 503117
actioncodec2_kernels/__init__.py CHANGED
@@ -1 +0,0 @@
1
- """Portable ActionCodec2 CPU kernels."""
 
 
actioncodec2_kernels/bpe.cpp ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Native BPE trainer -- the FAST-style ablation baseline.
2
+ //
3
+ // Set-BPE treats one control step as an unordered set of primitive cells and
4
+ // merges spatially before merging across time. Plain BPE instead serializes
5
+ // the quantized ``[T, D]`` cell matrix row-major into one symbol sequence and
6
+ // runs textbook byte-pair encoding on it, which is what FAST does after its
7
+ // DCT stage. Both trainers consume the identical quantized corpus,
8
+ // so a paired run isolates the tokenizer structure and nothing else.
9
+ //
10
+ // Correctness is identical to the Python reference trainer in
11
+ // ``actioncodec2/bpe_training.py``, including the tie-break (largest count,
12
+ // then lexicographically smallest ordered ``(left, right)`` pair) and the
13
+ // left-to-right application order, so the two emit the same merge list.
14
+ //
15
+ // Where the time goes, and what is done about it:
16
+ // * occurrences of every observed ordered pair are indexed once, so a merge
17
+ // touches only the positions that actually carry it;
18
+ // * the corpus is a doubly linked list over one flat symbol array, so
19
+ // collapsing a pair is O(1) and never rewrites the tail;
20
+ // * counters live in the same flat linear-probing table as the Set-BPE
21
+ // kernels (common.hpp), and the heap is lazy with per-merge deduplication
22
+ // of the keys a merge could have changed.
23
+
24
+ #include <pybind11/pybind11.h>
25
+ #include <pybind11/numpy.h>
26
+ #include <pybind11/stl.h>
27
+
28
+ #include <algorithm>
29
+ #include <cstdint>
30
+ #include <stdexcept>
31
+ #include <string>
32
+ #include <vector>
33
+
34
+ #include "common.hpp"
35
+
36
+ namespace py = pybind11;
37
+
38
+ namespace {
39
+
40
+ // BPE pairs are ordered, so the key keeps ``left`` in the high word. The
41
+ // heap tie-break in common.hpp prefers the smallest key, which is therefore the
42
+ // lexicographically smallest ``(left, right)`` -- the Python reference rule.
43
+ inline uint64_t ordered_key(int32_t left, int32_t right) {
44
+ return (static_cast<uint64_t>(static_cast<uint32_t>(left)) << 32) |
45
+ static_cast<uint64_t>(static_cast<uint32_t>(right));
46
+ }
47
+
48
+ inline int32_t key_left(uint64_t key) { return static_cast<int32_t>(key >> 32); }
49
+ inline int32_t key_right(uint64_t key) { return static_cast<int32_t>(key & 0xffffffffull); }
50
+
51
+ // Per-merge set of pair keys whose counters moved. A merge can only touch
52
+ // pairs incident to its two parents or its child, so the live population is
53
+ // bounded by a small multiple of the vocabulary; clearing is a generation bump
54
+ // rather than a table sweep.
55
+ class StampSet {
56
+ public:
57
+ explicit StampSet(size_t capacity_hint) {
58
+ size_t target = 1024;
59
+ while (target < capacity_hint * 4) target <<= 1;
60
+ keys_.assign(target, 0);
61
+ stamps_.assign(target, 0);
62
+ mask_ = target - 1;
63
+ }
64
+
65
+ void reset() {
66
+ ++generation_;
67
+ members_.clear();
68
+ if (generation_ != 0) return;
69
+ std::fill(stamps_.begin(), stamps_.end(), 0);
70
+ generation_ = 1;
71
+ }
72
+
73
+ void insert(uint64_t key) {
74
+ size_t slot = mix(key) & mask_;
75
+ while (stamps_[slot] == generation_) {
76
+ if (keys_[slot] == key) return;
77
+ slot = (slot + 1) & mask_;
78
+ }
79
+ if ((members_.size() + 1) * 4 > (mask_ + 1) * 3) {
80
+ grow();
81
+ slot = mix(key) & mask_;
82
+ while (stamps_[slot] == generation_) {
83
+ if (keys_[slot] == key) return;
84
+ slot = (slot + 1) & mask_;
85
+ }
86
+ }
87
+ keys_[slot] = key;
88
+ stamps_[slot] = generation_;
89
+ members_.push_back(key);
90
+ }
91
+
92
+ const std::vector<uint64_t>& members() const { return members_; }
93
+
94
+ private:
95
+ static uint64_t mix(uint64_t value) {
96
+ value ^= value >> 30;
97
+ value *= 0xbf58476d1ce4e5b9ull;
98
+ value ^= value >> 27;
99
+ value *= 0x94d049bb133111ebull;
100
+ return value ^ (value >> 31);
101
+ }
102
+
103
+ void grow() {
104
+ const size_t buckets = (mask_ + 1) << 1;
105
+ keys_.assign(buckets, 0);
106
+ stamps_.assign(buckets, 0);
107
+ mask_ = buckets - 1;
108
+ generation_ = 1;
109
+ for (const uint64_t key : members_) {
110
+ size_t slot = mix(key) & mask_;
111
+ while (stamps_[slot] == generation_) slot = (slot + 1) & mask_;
112
+ keys_[slot] = key;
113
+ stamps_[slot] = generation_;
114
+ }
115
+ }
116
+
117
+ std::vector<uint64_t> keys_;
118
+ std::vector<uint32_t> stamps_;
119
+ std::vector<uint64_t> members_;
120
+ size_t mask_ = 0;
121
+ uint32_t generation_ = 1;
122
+ };
123
+
124
+ // Occurrence index. ``slots`` maps a pair key to its 1-based row in
125
+ // ``lists``; a value of zero means "no row yet", which is exactly the absent
126
+ // value CounterMap already reports, so no separate presence bit is needed.
127
+ struct OccurrenceIndex {
128
+ ac2::CounterMap slots{1 << 16};
129
+ std::vector<std::vector<int32_t>> lists;
130
+ std::vector<uint64_t> keys;
131
+ int64_t stored = 0;
132
+
133
+ int64_t row_for(uint64_t key) {
134
+ const int64_t existing = slots.get(key);
135
+ if (existing > 0) return existing - 1;
136
+ const int64_t row = static_cast<int64_t>(lists.size());
137
+ lists.emplace_back();
138
+ keys.push_back(key);
139
+ slots.add(key, row + 1);
140
+ return row;
141
+ }
142
+
143
+ void append(uint64_t key, int32_t position) {
144
+ lists[static_cast<size_t>(row_for(key))].push_back(position);
145
+ ++stored;
146
+ }
147
+ };
148
+
149
+ struct Corpus {
150
+ std::vector<int32_t> symbol; // live token id per slot, -1 once absorbed
151
+ std::vector<int32_t> prev;
152
+ std::vector<int32_t> next;
153
+ int64_t actions = 0;
154
+ int64_t episodes = 0;
155
+ };
156
+
157
+ void ingest_chunk(Corpus& corpus, const int32_t* cells, int64_t rows, int32_t dimension,
158
+ const int32_t* offsets, const int32_t* bins, const int64_t* episode_offsets,
159
+ int64_t episode_count) {
160
+ for (int64_t episode = 0; episode < episode_count; ++episode) {
161
+ const int64_t start = episode_offsets[episode];
162
+ const int64_t stop = episode_offsets[episode + 1];
163
+ if (stop <= start || start < 0 || stop > rows) {
164
+ throw std::invalid_argument("BPE episode offsets must be increasing and in range");
165
+ }
166
+ const size_t first = corpus.symbol.size();
167
+ const int32_t* initial = cells + start * dimension;
168
+ for (int64_t row = start; row < stop; ++row) {
169
+ const int32_t* values = cells + row * dimension;
170
+ for (int32_t dim = 0; dim < dimension; ++dim) {
171
+ const int32_t value = values[dim];
172
+ if ((value >= 0) != (initial[dim] >= 0)) {
173
+ throw std::invalid_argument("active dimensions must remain constant within an episode");
174
+ }
175
+ if (value < 0) {
176
+ if (value != -1) throw std::invalid_argument("cells must contain -1 or a valid index");
177
+ continue;
178
+ }
179
+ if (value >= bins[dim]) throw std::invalid_argument("cell index exceeds its bin count");
180
+ corpus.symbol.push_back(offsets[dim] + value);
181
+ }
182
+ }
183
+ const size_t last = corpus.symbol.size();
184
+ if (last == first) throw std::invalid_argument("every control step needs an active coordinate");
185
+ if (last > static_cast<size_t>(INT32_MAX)) {
186
+ throw std::invalid_argument(
187
+ "BPE corpus exceeds 2^31 primitive cells; lower the fitting coverage");
188
+ }
189
+ corpus.prev.resize(last);
190
+ corpus.next.resize(last);
191
+ for (size_t index = first; index < last; ++index) {
192
+ corpus.prev[index] = index == first ? -1 : static_cast<int32_t>(index - 1);
193
+ corpus.next[index] = index + 1 == last ? -1 : static_cast<int32_t>(index + 1);
194
+ }
195
+ corpus.actions += stop - start;
196
+ ++corpus.episodes;
197
+ }
198
+ }
199
+
200
+ } // namespace
201
+
202
+ py::tuple train_bpe_native(py::iterable cell_chunks, const std::vector<int32_t>& bins,
203
+ int64_t vocab_size, int64_t max_token_cells, int threads) {
204
+ const int32_t dimension = static_cast<int32_t>(bins.size());
205
+ if (dimension < 1) throw std::invalid_argument("bins must not be empty");
206
+ std::vector<int32_t> offsets(dimension + 1, 0);
207
+ for (int32_t index = 0; index < dimension; ++index) {
208
+ if (bins[index] < 1) throw std::invalid_argument("every dimension needs at least one bin");
209
+ offsets[index + 1] = offsets[index] + bins[index];
210
+ }
211
+ const int32_t atom_count = offsets[dimension];
212
+ if (vocab_size < atom_count) {
213
+ throw std::invalid_argument("vocab_size is below the number of primitive atoms");
214
+ }
215
+ if (max_token_cells < 1) throw std::invalid_argument("max_token_cells must be positive");
216
+
217
+ // Greedy merge updates are serial. Preserve the requested option for the
218
+ // shared trainer API without changing the calling thread's OpenMP settings.
219
+ const int thread_count = 1;
220
+
221
+ Corpus corpus;
222
+ for (py::handle raw_chunk : cell_chunks) {
223
+ py::tuple chunk = py::cast<py::tuple>(raw_chunk);
224
+ if (chunk.size() != 2) throw std::invalid_argument("each chunk must be (cells, offsets)");
225
+ auto cells = py::cast<py::array_t<int32_t, py::array::c_style | py::array::forcecast>>(chunk[0]);
226
+ auto bounds =
227
+ py::cast<py::array_t<int64_t, py::array::c_style | py::array::forcecast>>(chunk[1]);
228
+ auto cells_view = cells.unchecked<2>();
229
+ auto bounds_view = bounds.unchecked<1>();
230
+ if (cells_view.shape(1) != dimension) {
231
+ throw std::invalid_argument("cells must have one column per bin count");
232
+ }
233
+ if (bounds_view.shape(0) < 2) throw std::invalid_argument("offsets must bound one episode");
234
+ {
235
+ py::gil_scoped_release release;
236
+ ingest_chunk(corpus, cells_view.data(0, 0), cells_view.shape(0), dimension, offsets.data(),
237
+ bins.data(), bounds_view.data(0), bounds_view.shape(0) - 1);
238
+ }
239
+ }
240
+ if (corpus.symbol.empty()) throw std::invalid_argument("BPE training needs one episode");
241
+
242
+ const int64_t initial_length = static_cast<int64_t>(corpus.symbol.size());
243
+ int64_t length = initial_length;
244
+ std::vector<int32_t>& symbol = corpus.symbol;
245
+ std::vector<int32_t>& prev = corpus.prev;
246
+ std::vector<int32_t>& next = corpus.next;
247
+
248
+ ac2::CounterMap counts(1 << 16);
249
+ OccurrenceIndex index;
250
+ std::vector<std::pair<int32_t, int32_t>> merges;
251
+ std::vector<int32_t> token_cells(static_cast<size_t>(atom_count), 1);
252
+ int64_t initial_pair_types = 0;
253
+ int64_t heap_pops = 0;
254
+ int64_t counter_updates = 0;
255
+ int64_t compactions = 0;
256
+ int64_t peak_stored = 0;
257
+ int64_t oversized_pairs = 0;
258
+ int32_t observed_cells = 1;
259
+
260
+ {
261
+ py::gil_scoped_release release;
262
+
263
+ // One pass builds ascending occurrence lists; the counter is their length.
264
+ for (int64_t position = 0; position < initial_length; ++position) {
265
+ const int32_t follower = next[position];
266
+ if (follower < 0) continue;
267
+ index.append(ordered_key(symbol[position], symbol[follower]),
268
+ static_cast<int32_t>(position));
269
+ }
270
+ for (size_t row = 0; row < index.lists.size(); ++row) {
271
+ counts.add(index.keys[row], static_cast<int64_t>(index.lists[row].size()));
272
+ }
273
+ initial_pair_types = static_cast<int64_t>(index.lists.size());
274
+ peak_stored = index.stored;
275
+
276
+ std::vector<ac2::HeapEntry> heap;
277
+ heap.reserve(index.lists.size());
278
+ counts.for_each([&](uint64_t key, int64_t value) { heap.push_back({value, key, 0}); });
279
+ std::make_heap(heap.begin(), heap.end(), ac2::HeapOrder{});
280
+
281
+ token_cells.reserve(static_cast<size_t>(vocab_size));
282
+ merges.reserve(static_cast<size_t>(vocab_size - atom_count));
283
+
284
+ ac2::CounterMap oversized(1 << 12);
285
+ StampSet touched(static_cast<size_t>(vocab_size));
286
+ std::vector<int32_t> occurrences;
287
+
288
+ while (static_cast<int64_t>(atom_count + merges.size()) < vocab_size && !heap.empty()) {
289
+ const ac2::HeapEntry top = ac2::heap_pop(heap);
290
+ ++heap_pops;
291
+ const uint64_t key = top.key;
292
+ const int64_t frequency = counts.get(key);
293
+ if (frequency <= 0 || frequency != top.count) continue;
294
+ if (oversized.get(key) != 0) continue;
295
+ const int32_t left = key_left(key);
296
+ const int32_t right = key_right(key);
297
+ const int64_t span = static_cast<int64_t>(token_cells[left]) + token_cells[right];
298
+ if (span > max_token_cells) {
299
+ oversized.add(key, 1);
300
+ continue;
301
+ }
302
+
303
+ const int32_t child = atom_count + static_cast<int32_t>(merges.size());
304
+ token_cells.push_back(static_cast<int32_t>(span));
305
+ observed_cells = std::max(observed_cells, static_cast<int32_t>(span));
306
+
307
+ occurrences.clear();
308
+ occurrences.swap(index.lists[static_cast<size_t>(index.slots.get(key) - 1)]);
309
+ index.stored -= static_cast<int64_t>(occurrences.size());
310
+ // Left-to-right application is what makes overlapping runs deterministic;
311
+ // later appends leave the list unsorted, so restore the order first.
312
+ std::sort(occurrences.begin(), occurrences.end());
313
+ touched.reset();
314
+ touched.insert(key);
315
+
316
+ int64_t applied = 0;
317
+ for (const int32_t position : occurrences) {
318
+ if (symbol[position] != left) continue;
319
+ const int32_t follower = next[position];
320
+ if (follower < 0 || symbol[follower] != right) continue;
321
+ const int32_t before = prev[position];
322
+ const int32_t after = next[follower];
323
+ if (before >= 0) {
324
+ const uint64_t stale = ordered_key(symbol[before], left);
325
+ counts.add(stale, -1);
326
+ touched.insert(stale);
327
+ }
328
+ if (after >= 0) {
329
+ const uint64_t stale = ordered_key(right, symbol[after]);
330
+ counts.add(stale, -1);
331
+ touched.insert(stale);
332
+ }
333
+ symbol[position] = child;
334
+ symbol[follower] = -1;
335
+ next[position] = after;
336
+ if (after >= 0) prev[after] = position;
337
+ prev[follower] = -1;
338
+ next[follower] = -1;
339
+ if (before >= 0) {
340
+ const uint64_t fresh = ordered_key(symbol[before], child);
341
+ counts.add(fresh, 1);
342
+ index.append(fresh, before);
343
+ touched.insert(fresh);
344
+ }
345
+ if (after >= 0) {
346
+ const uint64_t fresh = ordered_key(child, symbol[after]);
347
+ counts.add(fresh, 1);
348
+ index.append(fresh, position);
349
+ touched.insert(fresh);
350
+ }
351
+ ++applied;
352
+ }
353
+ counts.add(key, -applied);
354
+ counter_updates += 4 * applied;
355
+ length -= applied;
356
+ merges.emplace_back(left, right);
357
+
358
+ for (const uint64_t member : touched.members()) {
359
+ const int64_t value = counts.get(member);
360
+ if (value > 0) ac2::heap_push(heap, {value, member, 0});
361
+ }
362
+ peak_stored = std::max(peak_stored, index.stored);
363
+
364
+ // Superseded positions stay in the lists of pairs that never won. Sweep
365
+ // them out once they outnumber the live corpus, so memory tracks the
366
+ // corpus rather than the merge history.
367
+ if (index.stored > 3 * length) {
368
+ int64_t retained = 0;
369
+ for (size_t row = 0; row < index.lists.size(); ++row) {
370
+ auto& list = index.lists[row];
371
+ if (list.empty()) continue;
372
+ const int32_t expected_left = key_left(index.keys[row]);
373
+ const int32_t expected_right = key_right(index.keys[row]);
374
+ size_t write = 0;
375
+ for (const int32_t position : list) {
376
+ const int32_t follower = next[position];
377
+ if (symbol[position] != expected_left) continue;
378
+ if (follower < 0 || symbol[follower] != expected_right) continue;
379
+ list[write++] = position;
380
+ }
381
+ list.resize(write);
382
+ if (write * 2 < list.capacity()) list.shrink_to_fit();
383
+ retained += static_cast<int64_t>(write);
384
+ }
385
+ index.stored = retained;
386
+ ++compactions;
387
+ }
388
+
389
+ if (heap.size() > 4 * counts.size() + 65536) {
390
+ heap.clear();
391
+ counts.for_each([&](uint64_t member, int64_t value) { heap.push_back({value, member, 0}); });
392
+ std::make_heap(heap.begin(), heap.end(), ac2::HeapOrder{});
393
+ }
394
+ }
395
+
396
+ oversized_pairs = static_cast<int64_t>(oversized.live_entries());
397
+ }
398
+
399
+ py::list merge_list;
400
+ for (const auto& merge : merges) {
401
+ merge_list.append(py::make_tuple(merge.first, merge.second));
402
+ }
403
+ py::dict report;
404
+ report["episodes"] = corpus.episodes;
405
+ report["actions"] = corpus.actions;
406
+ report["atoms"] = static_cast<int64_t>(atom_count);
407
+ report["merges"] = static_cast<int64_t>(merges.size());
408
+ report["vocab_size"] = static_cast<int64_t>(atom_count + merges.size());
409
+ report["cells"] = initial_length;
410
+ report["tokens_per_action_initial"] =
411
+ static_cast<double>(initial_length) / static_cast<double>(corpus.actions);
412
+ report["tokens_per_action"] =
413
+ static_cast<double>(length) / static_cast<double>(corpus.actions);
414
+ report["compression"] = static_cast<double>(initial_length) / static_cast<double>(length);
415
+ report["max_token_cells"] = max_token_cells;
416
+ report["max_token_cells_observed"] = static_cast<int64_t>(observed_cells);
417
+ report["oversized_pairs_skipped"] = oversized_pairs;
418
+ report["initial_pair_types"] = initial_pair_types;
419
+ report["pair_types_peak"] = static_cast<int64_t>(index.lists.size());
420
+ report["occurrence_slots_peak"] = peak_stored;
421
+ report["occurrence_compactions"] = compactions;
422
+ report["heap_pops"] = heap_pops;
423
+ report["counter_updates"] = counter_updates;
424
+ report["threads"] = thread_count;
425
+ report["requested_threads"] = threads;
426
+ report["trainer"] = "native_sparse_exact";
427
+ return py::make_tuple(merge_list, report);
428
+ }
actioncodec2_kernels/common.hpp ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared primitives for the ActionCodec2 native kernels.
2
+ #pragma once
3
+
4
+ #include <cstdint>
5
+ #include <algorithm>
6
+ #include <utility>
7
+ #include <vector>
8
+
9
+ namespace ac2 {
10
+
11
+ class CounterMap {
12
+ public:
13
+ static constexpr uint64_t kEmpty = ~0ull;
14
+
15
+ explicit CounterMap(size_t capacity_hint = 1024) { reserve(capacity_hint); }
16
+
17
+ void reserve(size_t entries) {
18
+ size_t target = 16;
19
+ while (target * 3 < entries * 4) target <<= 1; // keep load factor <= 0.75
20
+ if (target <= mask_ + 1 && !keys_.empty()) return;
21
+ rehash(target);
22
+ }
23
+
24
+ size_t size() const { return size_; }
25
+ size_t buckets() const { return mask_ + 1; }
26
+
27
+ void clear() {
28
+ std::fill(keys_.begin(), keys_.end(), kEmpty);
29
+ size_ = 0;
30
+ }
31
+
32
+ int64_t get(uint64_t key) const {
33
+ size_t slot = probe(key);
34
+ return keys_[slot] == key ? values_[slot] : 0;
35
+ }
36
+
37
+ int64_t add(uint64_t key, int64_t delta) {
38
+ return add_with_previous(key, delta).second;
39
+ }
40
+
41
+ // Update and expose both values with one hash-table probe. This is useful
42
+ // when a caller must distinguish a newly observed key from a stale one.
43
+ std::pair<int64_t, int64_t> add_with_previous(uint64_t key, int64_t delta) {
44
+ size_t slot = probe(key);
45
+ if (keys_[slot] != key) {
46
+ if ((size_ + 1) * 4 > (mask_ + 1) * 3) {
47
+ rehash((mask_ + 1) << 1);
48
+ slot = probe(key);
49
+ }
50
+ keys_[slot] = key;
51
+ values_[slot] = 0;
52
+ ++size_;
53
+ }
54
+ const int64_t previous = values_[slot];
55
+ values_[slot] += delta;
56
+ return {previous, values_[slot]};
57
+ }
58
+
59
+ template <typename Fn>
60
+ void for_each(Fn&& fn) const {
61
+ for (size_t slot = 0; slot <= mask_; ++slot) {
62
+ if (keys_[slot] != kEmpty && values_[slot] != 0) fn(keys_[slot], values_[slot]);
63
+ }
64
+ }
65
+
66
+ size_t live_entries() const {
67
+ size_t live = 0;
68
+ for (size_t slot = 0; slot <= mask_; ++slot) {
69
+ if (keys_[slot] != kEmpty && values_[slot] != 0) ++live;
70
+ }
71
+ return live;
72
+ }
73
+
74
+ private:
75
+ static uint64_t mix(uint64_t value) { // splitmix64 finalizer
76
+ value ^= value >> 30;
77
+ value *= 0xbf58476d1ce4e5b9ull;
78
+ value ^= value >> 27;
79
+ value *= 0x94d049bb133111ebull;
80
+ return value ^ (value >> 31);
81
+ }
82
+
83
+ size_t probe(uint64_t key) const {
84
+ size_t slot = mix(key) & mask_;
85
+ while (keys_[slot] != kEmpty && keys_[slot] != key) slot = (slot + 1) & mask_;
86
+ return slot;
87
+ }
88
+
89
+ void rehash(size_t buckets) {
90
+ std::vector<uint64_t> old_keys;
91
+ std::vector<int64_t> old_values;
92
+ old_keys.swap(keys_);
93
+ old_values.swap(values_);
94
+ keys_.assign(buckets, kEmpty);
95
+ values_.assign(buckets, 0);
96
+ mask_ = buckets - 1;
97
+ size_ = 0;
98
+ for (size_t slot = 0; slot < old_keys.size(); ++slot) {
99
+ if (old_keys[slot] != kEmpty && old_values[slot] != 0) {
100
+ size_t target = probe(old_keys[slot]);
101
+ keys_[target] = old_keys[slot];
102
+ values_[target] = old_values[slot];
103
+ ++size_;
104
+ }
105
+ }
106
+ }
107
+
108
+ std::vector<uint64_t> keys_;
109
+ std::vector<int64_t> values_;
110
+ size_t mask_ = 0;
111
+ size_t size_ = 0;
112
+ };
113
+
114
+ struct HeapEntry {
115
+ int64_t count;
116
+ uint64_t key;
117
+ uint32_t version; // only shifted temporal self-rules use this
118
+ };
119
+
120
+ struct HeapOrder {
121
+ bool operator()(const HeapEntry& a, const HeapEntry& b) const {
122
+ if (a.count != b.count) return a.count < b.count; // larger count first
123
+ return a.key > b.key; // smaller key first
124
+ }
125
+ };
126
+
127
+ inline void heap_push(std::vector<HeapEntry>& heap, const HeapEntry& entry) {
128
+ heap.push_back(entry);
129
+ std::push_heap(heap.begin(), heap.end(), HeapOrder{});
130
+ }
131
+
132
+ inline HeapEntry heap_pop(std::vector<HeapEntry>& heap) {
133
+ std::pop_heap(heap.begin(), heap.end(), HeapOrder{});
134
+ HeapEntry top = heap.back();
135
+ heap.pop_back();
136
+ return top;
137
+ }
138
+
139
+ } // namespace ac2
actioncodec2_kernels/grammar.cpp CHANGED
@@ -1,162 +1,279 @@
 
1
  #include <pybind11/numpy.h>
2
  #include <pybind11/pybind11.h>
3
  #include <pybind11/stl.h>
4
-
5
  #include <algorithm>
6
  #include <cstdint>
7
- #include <limits>
8
- #include <map>
9
  #include <memory>
10
  #include <stdexcept>
11
  #include <vector>
 
 
12
 
13
- namespace py = pybind11;
14
  namespace {
15
- using State = std::vector<int64_t>;
16
-
17
- class CoverageGrammar {
18
- protected:
19
- int64_t horizon_, vocabulary_;
20
- virtual void validate_state(const State&) const = 0;
21
- virtual bool allowed(const State&, int64_t) const = 0;
22
- virtual State apply(const State&, int64_t) const = 0;
23
  public:
24
- virtual ~CoverageGrammar() = default;
25
- State advance(const State& state, int64_t token) const {
26
- validate_state(state);
27
- if (token < 0 || token >= vocabulary_ || !allowed(state, token)) return {};
28
- return apply(state, token);
29
- }
30
- py::array_t<bool> mask(const State& state) const {
31
- validate_state(state);
32
- py::array_t<bool> result(vocabulary_);
33
- auto* output = result.mutable_data();
34
  py::gil_scoped_release release;
35
- for (int64_t token = 0; token < vocabulary_; ++token) output[token] = allowed(state, token);
36
  return result;
37
  }
38
  };
39
-
40
- class SetGrammar : public CoverageGrammar {
41
  uint64_t full_;
42
- std::vector<int64_t> roots_;
43
- std::vector<std::vector<std::pair<int64_t, uint64_t>>> patterns_;
44
- std::vector<bool> scope_;
45
-
46
- void validate_state(const State& state) const override {
47
- if (static_cast<int64_t>(state.size()) != horizon_)
48
- throw std::invalid_argument("invalid grammar state length");
49
- for (int64_t value : state) {
50
- if (value < 0 || (static_cast<uint64_t>(value) & ~full_))
51
- throw std::invalid_argument("invalid grammar state support");
 
 
 
 
 
 
 
 
 
52
  }
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
- int64_t front(const State& state) const {
55
- for (int64_t t = 0; t < horizon_; ++t)
56
- if (static_cast<uint64_t>(state[t]) != full_) return t;
57
- return horizon_;
58
- }
59
- bool allowed(const State& state, int64_t token) const override {
60
- if (!scope_[token]) return false;
61
- const int64_t start = front(state);
62
- if (start == horizon_) return false;
63
- uint64_t missing = full_ & ~static_cast<uint64_t>(state[start]);
64
- int64_t root = 0;
65
- while (!(missing & 1)) { missing >>= 1; ++root; }
66
- if (roots_[token] != root) return false;
67
- for (auto [offset, support] : patterns_[token]) {
68
- if (offset >= horizon_ - start || (static_cast<uint64_t>(state[start + offset]) & support))
69
- return false;
70
  }
71
  return true;
72
  }
73
- State apply(const State& state, int64_t token) const override {
74
- State result = state;
75
- const int64_t start = front(state);
76
- for (auto [offset, support] : patterns_[token])
77
- result[start + offset] |= static_cast<int64_t>(support);
78
  return result;
79
  }
 
 
 
 
80
  public:
81
- SetGrammar(int64_t horizon, uint64_t full, std::vector<int64_t> roots,
82
- std::vector<std::vector<std::pair<int64_t, uint64_t>>> patterns)
83
- : full_(full), roots_(std::move(roots)), patterns_(std::move(patterns)) {
84
- if (horizon < 1 || !full || full > static_cast<uint64_t>(INT64_MAX) ||
85
- roots_.empty() || roots_.size() != patterns_.size())
86
- throw std::invalid_argument("invalid Set-BPE grammar metadata");
87
- horizon_ = horizon;
88
- vocabulary_ = roots_.size();
89
- for (int64_t token = 0; token < vocabulary_; ++token) {
90
- if (roots_[token] < 0 || roots_[token] >= 63 || patterns_[token].empty())
91
- throw std::invalid_argument("invalid grammar root or empty pattern");
92
- std::map<int64_t, uint64_t> coverage;
93
- bool scope = true;
94
- for (auto [offset, support] : patterns_[token]) {
95
- if (offset < 0 || !support || support > static_cast<uint64_t>(INT64_MAX) ||
96
- (coverage[offset] & support))
97
- throw std::invalid_argument("invalid grammar pattern offset or support");
98
- coverage[offset] |= support;
99
- scope = scope && !(support & ~full_);
100
- }
101
- const uint64_t anchor = coverage[0];
102
- if (!(anchor & (uint64_t{1} << roots_[token])) ||
103
- (anchor & ((uint64_t{1} << roots_[token]) - 1)))
104
- throw std::invalid_argument("grammar root must anchor the pattern");
105
- scope_.push_back(scope);
106
- }
107
  }
108
  };
109
-
110
- class PlainGrammar : public CoverageGrammar {
111
- int64_t width_, cells_;
112
- std::vector<int64_t> lengths_;
113
- std::vector<bool> phase_;
114
-
115
- void validate_state(const State& state) const override {
116
- if (state.size() != 1 || state[0] < 0 || state[0] > cells_)
117
- throw std::invalid_argument("invalid plain-BPE grammar state");
118
  }
119
- bool allowed(const State& state, int64_t token) const override {
120
- return lengths_[token] <= cells_ - state[0] &&
121
- phase_[(state[0] % width_) * vocabulary_ + token];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
- State apply(const State& state, int64_t token) const override {
124
- return {state[0] + lengths_[token]};
 
 
 
 
 
 
 
 
 
125
  }
126
- public:
127
- PlainGrammar(int64_t horizon, std::vector<int64_t> lengths,
128
- const py::array_t<bool, py::array::c_style | py::array::forcecast>& phase)
129
- : lengths_(std::move(lengths)) {
130
- if (horizon < 1 || lengths_.empty() || phase.ndim() != 2 || phase.shape(0) < 1 ||
131
- phase.shape(1) != static_cast<int64_t>(lengths_.size()) ||
132
- horizon > std::numeric_limits<int64_t>::max() / phase.shape(0))
133
- throw std::invalid_argument("invalid plain-BPE grammar metadata");
134
- for (int64_t length : lengths_) {
135
- if (length < 1) throw std::invalid_argument("token expansion must be positive");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  }
137
- horizon_ = horizon;
138
- width_ = phase.shape(0);
139
- cells_ = horizon * width_;
140
- vocabulary_ = lengths_.size();
141
- phase_.assign(phase.data(), phase.data() + phase.size());
142
  }
143
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  }
 
145
 
146
  void register_grammar(py::module_& module) {
147
- py::class_<CoverageGrammar, std::shared_ptr<CoverageGrammar>>(module, "CoverageGrammar")
148
- .def("advance", [](const CoverageGrammar& grammar, const State& state, int64_t token) -> py::object {
149
- State next = grammar.advance(state, token);
150
- if (next.empty()) return py::none();
151
- py::tuple result(next.size());
152
- for (size_t i = 0; i < next.size(); ++i) result[i] = next[i];
153
- return result;
154
  })
155
- .def("mask", &CoverageGrammar::mask);
156
- py::class_<SetGrammar, CoverageGrammar, std::shared_ptr<SetGrammar>>(module, "SetGrammar")
157
- .def(py::init<int64_t, uint64_t, std::vector<int64_t>,
158
- std::vector<std::vector<std::pair<int64_t, uint64_t>>>>());
159
- py::class_<PlainGrammar, CoverageGrammar, std::shared_ptr<PlainGrammar>>(module, "PlainGrammar")
160
- .def(py::init<int64_t, std::vector<int64_t>,
161
- const py::array_t<bool, py::array::c_style | py::array::forcecast>&>());
 
 
 
 
162
  }
 
1
+ // Coverage grammars and multi-component routing, including time-major streams.
2
  #include <pybind11/numpy.h>
3
  #include <pybind11/pybind11.h>
4
  #include <pybind11/stl.h>
 
5
  #include <algorithm>
6
  #include <cstdint>
 
 
7
  #include <memory>
8
  #include <stdexcept>
9
  #include <vector>
10
+ #include "parallel.hpp"
11
+ #include "token_streams.hpp"
12
 
13
+ namespace py=pybind11;
14
  namespace {
15
+ using State=std::vector<int64_t>;
16
+ using States=std::vector<State>;
17
+ class Grammar {
 
 
 
 
 
18
  public:
19
+ int64_t horizon,vocabulary;
20
+ virtual ~Grammar()=default;
21
+ virtual State initial()const=0;
22
+ virtual State advance(const State&,int64_t)const=0;
23
+ virtual int64_t covered(const State&)const=0;
24
+ virtual int64_t next_dimension(const State&)const=0;
25
+ virtual bool allowed(const State&,int64_t)const=0;
26
+ virtual std::shared_ptr<Grammar> clone(int64_t)const=0;
27
+ py::array_t<bool> mask(const State& state)const {
28
+ py::array_t<bool> result(vocabulary);
29
  py::gil_scoped_release release;
30
+ for(int64_t token=0;token<vocabulary;++token)result.mutable_data()[token]=allowed(state,token);
31
  return result;
32
  }
33
  };
34
+ class SetGrammar:public Grammar {
 
35
  uint64_t full_;
36
+ using Patterns=std::vector<std::vector<std::pair<int64_t,uint64_t>>>;
37
+ std::shared_ptr<const std::vector<int64_t>> roots_;
38
+ std::shared_ptr<const Patterns> patterns_;
39
+ std::shared_ptr<const std::vector<bool>> scope_;
40
+ public:
41
+ SetGrammar(int64_t steps,uint64_t full,std::vector<int64_t> roots,
42
+ std::vector<std::vector<std::pair<int64_t,uint64_t>>> patterns)
43
+ :full_(full),roots_(std::make_shared<const std::vector<int64_t>>(std::move(roots))),
44
+ patterns_(std::make_shared<const Patterns>(std::move(patterns))) {
45
+ if(steps<1 || !full || roots_->size()!=patterns_->size())throw std::invalid_argument("invalid grammar metadata");
46
+ horizon=steps;vocabulary=roots_->size();
47
+ std::vector<bool> scopes;
48
+ for(const auto& pattern:*patterns_) {
49
+ bool scope=true;
50
+ for(auto[offset,support]:pattern) {
51
+ if(offset<0)throw std::invalid_argument("negative grammar pattern offset");
52
+ scope=scope && !(support & ~full_);
53
+ }
54
+ scopes.push_back(scope);
55
  }
56
+ scope_=std::make_shared<const std::vector<bool>>(std::move(scopes));
57
+ }
58
+ std::shared_ptr<Grammar> clone(int64_t steps)const override {
59
+ if(steps<1)throw std::invalid_argument("horizon must be positive");
60
+ auto result=std::make_shared<SetGrammar>(*this);result->horizon=steps;return result;
61
+ }
62
+ State initial()const override{return State(horizon,0);}
63
+ int64_t covered(const State& state)const override {
64
+ if(static_cast<int64_t>(state.size())!=horizon)throw std::invalid_argument("invalid grammar state");
65
+ for(int64_t i=0;i<horizon;++i)if(static_cast<uint64_t>(state[i])!=full_)return i;
66
+ return horizon;
67
  }
68
+ int64_t next_dimension(const State& state)const override {
69
+ int64_t front=covered(state);if(front==horizon)return -1;
70
+ uint64_t missing=full_ & ~static_cast<uint64_t>(state[front]);
71
+ int64_t d=0;while(!(missing & 1)){missing>>=1;++d;}return d;
72
+ }
73
+ bool allowed(const State& state,int64_t token)const override {
74
+ if(token<0 || token>=vocabulary || !(*scope_)[token])return false;
75
+ const int64_t front=covered(state);
76
+ if(front==horizon || (*roots_)[token]!=next_dimension(state))return false;
77
+ for(auto[offset,support]:(*patterns_)[token]) {
78
+ if(front+offset>=horizon || (static_cast<uint64_t>(state[front+offset]) & support))return false;
 
 
 
 
 
79
  }
80
  return true;
81
  }
82
+ State advance(const State& state,int64_t token)const override {
83
+ if(!allowed(state,token))return {};
84
+ State result=state;int64_t front=covered(state);
85
+ for(auto[offset,support]:(*patterns_)[token])result[front+offset]|=static_cast<int64_t>(support);
 
86
  return result;
87
  }
88
+ };
89
+ class PlainGrammar:public Grammar {
90
+ std::shared_ptr<const std::vector<int64_t>> lengths_,dims_;
91
+ std::shared_ptr<const std::vector<bool>> phase_;
92
  public:
93
+ PlainGrammar(int64_t steps,std::vector<int64_t> lengths,std::vector<int64_t> dims,
94
+ py::array_t<bool,py::array::c_style|py::array::forcecast> phase)
95
+ :lengths_(std::make_shared<const std::vector<int64_t>>(std::move(lengths))),
96
+ dims_(std::make_shared<const std::vector<int64_t>>(std::move(dims))) {
97
+ horizon=steps;vocabulary=lengths_->size();
98
+ if(steps<1 || dims_->empty() || phase.ndim()!=2 || phase.shape(0)!=static_cast<int64_t>(dims_->size()) || phase.shape(1)!=vocabulary)
99
+ throw std::invalid_argument("invalid plain-BPE grammar metadata");
100
+ for(auto value:*lengths_)if(value<1)throw std::invalid_argument("invalid token expansion");
101
+ phase_=std::make_shared<const std::vector<bool>>(phase.data(),phase.data()+phase.size());
102
+ }
103
+ std::shared_ptr<Grammar> clone(int64_t steps)const override {
104
+ if(steps<1)throw std::invalid_argument("horizon must be positive");
105
+ auto result=std::make_shared<PlainGrammar>(*this);result->horizon=steps;return result;
106
+ }
107
+ State initial()const override{return {0};}
108
+ int64_t covered(const State& state)const override {
109
+ if(state.size()!=1 || state[0]<0)throw std::invalid_argument("invalid grammar state");
110
+ return state[0]/dims_->size();
111
+ }
112
+ int64_t next_dimension(const State& state)const override{return (*dims_)[state[0]%dims_->size()];}
113
+ bool allowed(const State& state,int64_t token)const override {
114
+ return token>=0 && token<vocabulary && state[0]+(*lengths_)[token]<=horizon*static_cast<int64_t>(dims_->size()) && (*phase_)[(state[0]%dims_->size())*vocabulary+token];
115
+ }
116
+ State advance(const State& state,int64_t token)const override {
117
+ if(!allowed(state,token))return {};
118
+ return {state[0]+(*lengths_)[token]};
119
  }
120
  };
121
+ using Grammars=std::vector<std::shared_ptr<Grammar>>;
122
+ int64_t next_component(const Grammars& grammars,const States& states,bool time_major) {
123
+ int64_t selected=grammars.size(),minimum=INT64_MAX;
124
+ for(size_t i=0;i<grammars.size();++i) {
125
+ const int64_t front=grammars[i]->covered(states[i]);
126
+ if(front>=grammars[i]->horizon)continue;
127
+ if(!time_major)return i;
128
+ if(front<minimum){minimum=front;selected=i;}
 
129
  }
130
+ return selected;
131
+ }
132
+ States initial(const Grammars& grammars) {
133
+ States states;for(const auto& grammar:grammars)states.push_back(grammar->initial());return states;
134
+ }
135
+ bool advance(const Grammars& grammars,const std::vector<int64_t>& offsets,States& states,int64_t index,int64_t token) {
136
+ if(index<0 || index>=static_cast<int64_t>(grammars.size()))return false;
137
+ State next=grammars[index]->advance(states[index],token-offsets[index]);
138
+ if(next.empty())return false;
139
+ states[index]=std::move(next);
140
+ return true;
141
+ }
142
+ py::tuple state_tuple(const State& state) {
143
+ py::tuple result(state.size());for(size_t i=0;i<state.size();++i)result[i]=state[i];return result;
144
+ }
145
+ py::object route_advance(const Grammars& grammars,const std::vector<int64_t>& offsets,
146
+ bool time_major,int64_t index,States states,int64_t token) {
147
+ if(states.size()!=grammars.size() || offsets.size()!=grammars.size())throw std::invalid_argument("invalid routed metadata");
148
+ if(!advance(grammars,offsets,states,index,token))return py::none();
149
+ py::tuple local(states.size());for(size_t i=0;i<states.size();++i)local[i]=state_tuple(states[i]);
150
+ return py::make_tuple(next_component(grammars,states,time_major),local);
151
+ }
152
+ std::vector<int64_t> interleave(const Grammars& grammars,const std::vector<int64_t>& offsets,
153
+ bool time_major,const std::vector<std::vector<int64_t>>& streams) {
154
+ if(streams.size()!=grammars.size() || offsets.size()!=grammars.size())throw std::invalid_argument("one stream per component is required");
155
+ States states=initial(grammars);std::vector<size_t> cursor(streams.size());std::vector<int64_t> output;
156
+ int64_t index;
157
+ while((index=next_component(grammars,states,time_major))<static_cast<int64_t>(grammars.size())) {
158
+ if(cursor[index]==streams[index].size())throw std::invalid_argument("component stream ends before complete coverage");
159
+ const int64_t token=streams[index][cursor[index]++]+offsets[index];
160
+ if(!advance(grammars,offsets,states,index,token))throw std::invalid_argument("invalid routed component stream");
161
+ output.push_back(token);
162
  }
163
+ for(size_t i=0;i<cursor.size();++i)if(cursor[i]!=streams[i].size())throw std::invalid_argument("extra component tokens");
164
+ return output;
165
+ }
166
+ std::vector<std::vector<int64_t>> split(const Grammars& grammars,const std::vector<int64_t>& offsets,
167
+ bool time_major,const std::vector<int64_t>& tokens) {
168
+ if(offsets.size()!=grammars.size())throw std::invalid_argument("invalid routed offsets");
169
+ States states=initial(grammars);std::vector<std::vector<int64_t>> output(grammars.size());
170
+ for(int64_t token:tokens) {
171
+ int64_t index=next_component(grammars,states,time_major);
172
+ if(!advance(grammars,offsets,states,index,token))throw std::invalid_argument("invalid routed token coverage");
173
+ output[index].push_back(token-offsets[index]);
174
  }
175
+ if(next_component(grammars,states,time_major)!=static_cast<int64_t>(grammars.size()))
176
+ throw std::invalid_argument("Incomplete action token stream");
177
+ return output;
178
+ }
179
+
180
+ using Streams=std::vector<std::vector<int64_t>>;
181
+ using ComponentStreams=std::vector<Streams>;
182
+
183
+ Streams interleave_batch(const Grammars& grammars,const std::vector<int64_t>& offsets,
184
+ bool time_major,const ComponentStreams& streams) {
185
+ if(streams.size()!=grammars.size() || streams.empty() || streams.front().empty())
186
+ throw std::invalid_argument("one non-empty batch per component is required");
187
+ const int64_t batch=streams.front().size();
188
+ for(const auto& rows:streams)if(static_cast<int64_t>(rows.size())!=batch)
189
+ throw std::invalid_argument("component batches must have the same size");
190
+ Streams output(batch);
191
+ py::gil_scoped_release release;
192
+ ac2::parallel_for(batch,0,[&](int64_t b) {
193
+ Streams local;for(const auto& rows:streams)local.push_back(rows[b]);
194
+ output[b]=interleave(grammars,offsets,time_major,local);
195
+ });
196
+ return output;
197
+ }
198
+
199
+ py::tuple decode_batch(const Grammars& prototypes,const std::vector<int64_t>& offsets,
200
+ bool time_major,const py::iterable& streams,
201
+ py::array_t<int64_t,py::array::c_style|py::array::forcecast> counts,
202
+ int64_t cells_per_step) {
203
+ if(prototypes.empty() || counts.ndim()!=1 || cells_per_step<1)
204
+ throw std::invalid_argument("invalid routed batch metadata");
205
+ const auto rows=ac2::checked_token_streams(streams,counts.size(),true);
206
+ int64_t steps=-1;
207
+ ComponentStreams output(prototypes.size(),Streams(rows.size()));
208
+ {
209
+ py::gil_scoped_release release;
210
+ for(const auto& row:rows) {
211
+ int64_t cells=0;
212
+ for(int64_t token:row) {
213
+ if(counts.data()[token]<1)throw std::invalid_argument("token is outside the routed profile vocabularies");
214
+ cells+=counts.data()[token];
215
+ }
216
+ if(cells%cells_per_step || cells/cells_per_step<1)
217
+ throw std::invalid_argument("Incomplete action token stream: " +
218
+ std::to_string(cells_per_step-cells%cells_per_step) +
219
+ " more coordinate values are needed to finish a step. "
220
+ "Constrain generation with codec.grammar(H, fps=...).next_token_mask(prefix) "
221
+ "and check is_complete(tokens) for the intended H before decoding. "
222
+ "Use codec.batch_decode(..., errors='return') to isolate invalid rows; "
223
+ "do not pad truncated tokens with zeros.");
224
+ if(steps>=0 && steps!=cells/cells_per_step)
225
+ throw std::invalid_argument("all token streams must decode to the same length; use codec.batch_decode(...) for independent variable-length outputs");
226
+ steps=cells/cells_per_step;
227
  }
228
+ Grammars grammars;for(const auto& prototype:prototypes)grammars.push_back(prototype->clone(steps));
229
+ ac2::parallel_for(rows.size(),0,[&](int64_t b) {
230
+ auto local=split(grammars,offsets,time_major,rows[b]);
231
+ for(size_t i=0;i<local.size();++i)output[i][b]=std::move(local[i]);
232
+ });
233
  }
234
+ return py::make_tuple(steps,output);
235
+ }
236
+
237
+ std::vector<int64_t> complete(const Grammars& grammars,const std::vector<int64_t>& offsets,
238
+ bool time_major,std::vector<int64_t> tokens,int64_t steps,
239
+ const std::vector<std::vector<int64_t>>& zero_atoms) {
240
+ if(grammars.empty() || offsets.size()!=grammars.size() || zero_atoms.size()!=grammars.size() ||
241
+ steps<1 || steps>grammars.front()->horizon)throw std::invalid_argument("invalid prefix completion metadata");
242
+ py::gil_scoped_release release;
243
+ States states=initial(grammars);
244
+ for(int64_t token:tokens) {
245
+ int64_t index=next_component(grammars,states,time_major);
246
+ if(!advance(grammars,offsets,states,index,token))throw std::invalid_argument("invalid routed token prefix");
247
+ }
248
+ for(size_t i=0;i<grammars.size();++i)if(grammars[i]->covered(states[i])<steps)
249
+ throw std::invalid_argument("action prefix does not cover the requested frames");
250
+ int64_t index;
251
+ while((index=next_component(grammars,states,time_major))<static_cast<int64_t>(grammars.size())) {
252
+ int64_t dimension=grammars[index]->next_dimension(states[index]);
253
+ if(dimension<0 || dimension>=static_cast<int64_t>(zero_atoms[index].size()))
254
+ throw std::invalid_argument("missing zero-motion primitive atom");
255
+ const int64_t token=offsets[index]+zero_atoms[index][dimension];
256
+ if(!advance(grammars,offsets,states,index,token))throw std::invalid_argument("invalid zero-motion suffix");
257
+ tokens.push_back(token);
258
+ }
259
+ return tokens;
260
  }
261
+ } // namespace
262
 
263
  void register_grammar(py::module_& module) {
264
+ py::class_<Grammar,std::shared_ptr<Grammar>>(module,"CoverageGrammar")
265
+ .def("advance",[](const Grammar& g,const State& s,int64_t token)->py::object {
266
+ State next=g.advance(s,token);return next.empty()?py::object(py::none()):py::object(state_tuple(next));
 
 
 
 
267
  })
268
+ .def("mask",&Grammar::mask).def("covered",&Grammar::covered).def("clone",&Grammar::clone);
269
+ py::class_<SetGrammar,Grammar,std::shared_ptr<SetGrammar>>(module,"SetGrammar")
270
+ .def(py::init<int64_t,uint64_t,std::vector<int64_t>,std::vector<std::vector<std::pair<int64_t,uint64_t>>>>());
271
+ py::class_<PlainGrammar,Grammar,std::shared_ptr<PlainGrammar>>(module,"PlainGrammar")
272
+ .def(py::init<int64_t,std::vector<int64_t>,std::vector<int64_t>,py::array_t<bool,py::array::c_style|py::array::forcecast>>());
273
+ module.def("route_advance",&route_advance);
274
+ module.def("route_interleave",&interleave,py::call_guard<py::gil_scoped_release>());
275
+ module.def("route_split",&split,py::call_guard<py::gil_scoped_release>());
276
+ module.def("route_interleave_batch",&interleave_batch);
277
+ module.def("route_decode_batch",&decode_batch);
278
+ module.def("route_complete",&complete);
279
  }
actioncodec2_kernels/inference.cpp ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Lossless plain-BPE inference and additive physical reconstruction.
2
+ #include <pybind11/numpy.h>
3
+ #include <pybind11/pybind11.h>
4
+ #include <pybind11/stl.h>
5
+ #include <algorithm>
6
+ #include <cstdint>
7
+ #include <map>
8
+ #include <limits>
9
+ #include <queue>
10
+ #include <stdexcept>
11
+ #include <vector>
12
+ #include "parallel.hpp"
13
+ #include "quaternion.hpp"
14
+
15
+ namespace py = pybind11;
16
+ namespace {
17
+ using I64 = py::array_t<int64_t, py::array::c_style | py::array::forcecast>;
18
+ using F64 = py::array_t<double, py::array::c_style | py::array::forcecast>;
19
+ using F32 = py::array_t<float, py::array::c_style | py::array::forcecast>;
20
+
21
+
22
+
23
+ F64 quaternion_map(const F64& values, const std::string& operation) {
24
+ const bool vector_in = operation == "from_rotvec" || operation == "from_euler";
25
+ const int input_width=vector_in?3:4;
26
+ const bool vector_out=operation == "to_rotvec" || operation == "to_euler";
27
+ const int width=vector_out?3:4;
28
+ if(values.ndim()!=2 || values.shape(1)!=input_width)
29
+ throw std::invalid_argument("invalid quaternion operation shape");
30
+ F64 output({values.shape(0),static_cast<int64_t>(width)});
31
+ py::gil_scoped_release release;
32
+ for(int64_t i=0;i<values.shape(0);++i) {
33
+ const double* v=values.data()+i*input_width;
34
+ ac2q::Q q=vector_in?(operation=="from_rotvec"?ac2q::from_rotvec(v):ac2q::from_euler(v))
35
+ :ac2q::Q{v[0],v[1],v[2],v[3]};
36
+ double* out=output.mutable_data()+i*width;
37
+ if(operation=="to_rotvec")ac2q::to_rotvec(q,out);
38
+ else if(operation=="to_euler")ac2q::to_euler(q,out);
39
+ else {
40
+ if(operation=="normalize")q=ac2q::normalize(q);
41
+ else if(operation=="conjugate")q=ac2q::conjugate(q);
42
+ else if(!vector_in)throw std::invalid_argument("unknown quaternion operation");
43
+ std::copy(q.begin(),q.end(),out);
44
+ }
45
+ }
46
+ return output;
47
+ }
48
+
49
+ F64 quaternion_multiply(const F64& first, const F64& second) {
50
+ if(first.ndim()!=2 || first.shape(1)!=4 || second.ndim()!=2 ||
51
+ second.shape(1)!=4 || first.shape(0)!=second.shape(0))
52
+ throw std::invalid_argument("invalid quaternion product shape");
53
+ F64 output({first.shape(0),int64_t{4}});
54
+ py::gil_scoped_release release;
55
+ for(int64_t i=0;i<first.shape(0);++i) {
56
+ auto* a=first.data()+4*i;auto* b=second.data()+4*i;
57
+ auto q=ac2q::multiply({a[0],a[1],a[2],a[3]},{b[0],b[1],b[2],b[3]});
58
+ std::copy(q.begin(),q.end(),output.mutable_data()+4*i);
59
+ }
60
+ return output;
61
+ }
62
+
63
+ F32 so3_reconstruct(const F64& table, const I64& cells, const F32& previous,
64
+ const F32& scale, int input_order, int primitive_order,
65
+ bool left, bool rotation_vector, double radius) {
66
+ if(cells.ndim()!=3 || cells.shape(2)!=3 || table.ndim()!=2 || table.shape(0)!=3 ||
67
+ previous.ndim()!=2 || previous.shape(0)!=cells.shape(0) || previous.shape(1)!=3 || scale.size()!=3)
68
+ throw std::invalid_argument("invalid SO(3) reconstruction shapes");
69
+ const int64_t batch=cells.shape(0),steps=cells.shape(1);
70
+ F32 output({batch,steps,int64_t{3}});
71
+ py::gil_scoped_release release;
72
+ ac2::parallel_for(batch,0,[&](int64_t b) {
73
+ double boundary[3];
74
+ for(int d=0;d<3;++d)boundary[d]=static_cast<double>(previous.data()[3*b+d])*scale.data()[d];
75
+ auto increment=rotation_vector?ac2q::from_rotvec(boundary):ac2q::from_euler(boundary);
76
+ ac2q::Q state{0,0,0,1};
77
+ float accumulated[3]={0,0,0};
78
+ auto apply=[&](ac2q::Q a,ac2q::Q v){return left?ac2q::multiply(v,a):ac2q::multiply(a,v);};
79
+ for(int64_t t=0;t<steps;++t) {
80
+ double v[3],represented[3];
81
+ for(int d=0;d<3;++d) {
82
+ const int64_t cell=cells.data()[(b*steps+t)*3+d];
83
+ if(cell<0 || cell>=table.shape(1))throw std::invalid_argument("invalid primitive cell");
84
+ v[d]=table.data()[d*table.shape(1)+cell];
85
+ }
86
+ if(primitive_order==2) {
87
+ const double norm=std::sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2]);
88
+ const double factor=std::min(1.,radius/std::max(norm,std::numeric_limits<double>::min()));
89
+ for(double& x:v)x*=factor;
90
+ increment=apply(increment,ac2q::from_rotvec(v));
91
+ state=apply(state,increment);
92
+ auto q=input_order==1?increment:state;
93
+ if(rotation_vector)ac2q::to_rotvec(q,represented);else ac2q::to_euler(q,represented);
94
+ } else if(input_order==1) {
95
+ if(rotation_vector)std::copy(v,v+3,represented);
96
+ else {for(double& x:v)x=static_cast<float>(x);ac2q::to_euler(ac2q::from_rotvec(v),represented);}
97
+ } else {
98
+ state=apply(state,ac2q::from_rotvec(v));
99
+ if(rotation_vector)ac2q::to_rotvec(state,represented);else ac2q::to_euler(state,represented);
100
+ }
101
+ for(int d=0;d<3;++d) {
102
+ float value=static_cast<float>(represented[d]/scale.data()[d]);
103
+ if(primitive_order==1 && input_order==1) {
104
+ const float next=accumulated[d]+value;value=next-accumulated[d];accumulated[d]=next;
105
+ }
106
+ output.mutable_data()[(b*steps+t)*3+d]=value;
107
+ }
108
+ }
109
+ });
110
+ return output;
111
+ }
112
+
113
+ F32 additive_reconstruct(const F64& table, const I64& cells, const F32& previous,
114
+ const F32& scale, int input_order, int primitive_order) {
115
+ if (cells.ndim() != 3 || table.ndim() != 2 || scale.ndim() != 1 ||
116
+ cells.shape(2) != table.shape(0) || scale.size() != cells.shape(2) ||
117
+ previous.ndim() != 2 || previous.shape(0) != cells.shape(0) || previous.shape(1) != cells.shape(2))
118
+ throw std::invalid_argument("invalid additive reconstruction shapes");
119
+ if (primitive_order != 2 || input_order < 0 || input_order > 2)
120
+ throw std::invalid_argument("invalid differential order");
121
+ const int64_t batch = cells.shape(0), steps = cells.shape(1), width = cells.shape(2);
122
+ F32 output({batch, steps, width});
123
+ py::gil_scoped_release release;
124
+ ac2::parallel_for(batch, 0, [&](int64_t b) {
125
+ for (int64_t d = 0; d < width; ++d) {
126
+ double change_sum = 0, position = 0;
127
+ const float boundary = previous.data()[b * width + d] * scale.data()[d];
128
+ for (int64_t t = 0; t < steps; ++t) {
129
+ const int64_t index = (b * steps + t) * width + d, cell = cells.data()[index];
130
+ if (cell < 0 || cell >= table.shape(1)) throw std::invalid_argument("invalid cell");
131
+ const double primitive = table.data()[d * table.shape(1) + cell];
132
+ change_sum += primitive;
133
+ const double velocity = change_sum + boundary;
134
+ position += velocity;
135
+ const double value = input_order == 2 ? primitive : input_order == 1 ? velocity : position;
136
+ output.mutable_data()[index] = static_cast<float>(value / scale.data()[d]);
137
+ }
138
+ }
139
+ });
140
+ return output;
141
+ }
142
+
143
+ I64 choose_cells(const F32& table,const I64& bins,const F32& request,
144
+ const F32& direction,const F32& limits,bool smooth) {
145
+ if(table.ndim()!=2 || request.ndim()!=2 || direction.ndim()!=2 ||
146
+ direction.shape(0)!=request.shape(0) || direction.shape(1)!=request.shape(1) ||
147
+ table.shape(0)!=request.shape(1) || bins.size()!=request.shape(1) || limits.size()!=request.shape(1))
148
+ throw std::invalid_argument("invalid primitive selection shapes");
149
+ I64 output({request.shape(0),request.shape(1)});
150
+ py::gil_scoped_release release;
151
+ const int64_t width=request.shape(1);
152
+ for(int64_t i=0;i<request.shape(0);++i)for(int64_t d=0;d<width;++d) {
153
+ int64_t nearest=0,feasible=-1,preferred=-1;
154
+ float best=INFINITY,best_feasible=INFINITY,best_preferred=INFINITY;
155
+ const float q=request.data()[i*width+d],sign=direction.data()[i*width+d];
156
+ for(int64_t j=0;j<bins.data()[d];++j) {
157
+ float p=table.data()[d*table.shape(1)+j],distance=std::abs(q-p);
158
+ if(distance<best){best=distance;nearest=j;}
159
+ if(distance<=limits.data()[d]+8*std::numeric_limits<float>::epsilon()) {
160
+ if(distance<best_feasible){best_feasible=distance;feasible=j;}
161
+ if((p>0?1.F:p<0?-1.F:0.F)*sign>=0 && distance<best_preferred) {
162
+ best_preferred=distance;preferred=j;
163
+ }
164
+ }
165
+ }
166
+ output.mutable_data()[i*width+d]=smooth?(preferred>=0?preferred:feasible>=0?feasible:nearest):nearest;
167
+ }
168
+ return output;
169
+ }
170
+
171
+ F32 additive_decode(const F32& table,const I64& cells,const F32& scale,
172
+ const F32& lower,const F32& upper,int input_order,int primitive_order) {
173
+ if(cells.ndim()!=3 || table.ndim()!=2 || table.shape(0)!=cells.shape(2) ||
174
+ scale.size()!=cells.shape(2) || lower.size()!=cells.shape(2) || upper.size()!=cells.shape(2) ||
175
+ primitive_order<0 || primitive_order>1 || input_order<0 || input_order>2)
176
+ throw std::invalid_argument("invalid additive decoding metadata");
177
+ const int64_t batch=cells.shape(0),steps=cells.shape(1),width=cells.shape(2);
178
+ F32 output({batch,steps,width});
179
+ py::gil_scoped_release release;
180
+ ac2::parallel_for(batch,0,[&](int64_t b) {
181
+ for(int64_t d=0;d<width;++d) {
182
+ float position=0;
183
+ for(int64_t t=0;t<steps;++t) {
184
+ const int64_t index=(b*steps+t)*width+d,cell=cells.data()[index];
185
+ if(cell<0 || cell>=table.shape(1))throw std::invalid_argument("invalid primitive cell");
186
+ const float picked=table.data()[d*table.shape(1)+cell];
187
+ position=primitive_order?std::clamp(position+picked,lower.data()[d],upper.data()[d]):picked;
188
+ output.mutable_data()[index]=position;
189
+ }
190
+ for(int order=0;order<input_order;++order)for(int64_t t=steps-1;t>0;--t)
191
+ output.mutable_data()[(b*steps+t)*width+d]-=output.data()[(b*steps+t-1)*width+d];
192
+ for(int64_t t=0;t<steps;++t)output.mutable_data()[(b*steps+t)*width+d]/=scale.data()[d];
193
+ }
194
+ });
195
+ return output;
196
+ }
197
+
198
+ I64 spatial_expand(const std::vector<int64_t>& tokens,const I64& expansions,
199
+ const I64& supports,uint64_t full) {
200
+ if(expansions.ndim()!=2 || supports.ndim()!=1 || expansions.shape(0)!=supports.size())
201
+ throw std::invalid_argument("invalid spatial metadata");
202
+ const int64_t width=expansions.shape(1);I64 output(width);
203
+ std::fill(output.mutable_data(),output.mutable_data()+width,-1);
204
+ uint64_t covered=0;
205
+ py::gil_scoped_release release;
206
+ for(int64_t token:tokens) {
207
+ if(token<0 || token>=supports.size())throw std::invalid_argument("token is outside the vocabulary");
208
+ const uint64_t support=supports.data()[token];
209
+ if(covered & support)throw std::invalid_argument("token supports overlap");
210
+ covered|=support;
211
+ for(int64_t d=0;d<width;++d) {
212
+ const int64_t value=expansions.data()[token*width+d];
213
+ if(value>=0)output.mutable_data()[d]=value;
214
+ }
215
+ }
216
+ if(covered!=full)throw std::invalid_argument("token set does not exactly cover the requested active dimensions");
217
+ return output;
218
+ }
219
+ F32 additive_transform(const F32& values,int order,bool integrate) {
220
+ if(values.ndim()!=3 || order<0 || order>2)throw std::invalid_argument("expected [B,T,D] and order 0..2");
221
+ const int64_t batch=values.shape(0),steps=values.shape(1),width=values.shape(2);
222
+ F32 output({batch,steps,width});std::copy(values.data(),values.data()+values.size(),output.mutable_data());
223
+ py::gil_scoped_release release;
224
+ for(int level=0;level<order;++level)for(int64_t b=0;b<batch;++b)for(int64_t d=0;d<width;++d) {
225
+ if(integrate) {
226
+ float sum=0;
227
+ for(int64_t t=0;t<steps;++t) {const int64_t i=(b*steps+t)*width+d;sum+=output.data()[i];output.mutable_data()[i]=sum;}
228
+ } else {
229
+ for(int64_t t=steps-1;t>0;--t)output.mutable_data()[(b*steps+t)*width+d]-=output.data()[(b*steps+t-1)*width+d];
230
+ }
231
+ }
232
+ return output;
233
+ }
234
+ } // namespace
235
+
236
+ void register_inference(py::module_& module) {
237
+ module.def("additive_reconstruct", &additive_reconstruct);
238
+ module.def("so3_reconstruct", &so3_reconstruct);
239
+ module.def("quaternion_map", &quaternion_map);
240
+ module.def("quaternion_multiply", &quaternion_multiply);
241
+ module.def("choose_cells", &choose_cells);
242
+ module.def("additive_decode", &additive_decode);
243
+ module.def("spatial_expand", &spatial_expand);
244
+ module.def("additive_transform", &additive_transform);
245
+ }
actioncodec2_kernels/module.cpp CHANGED
@@ -1,13 +1,25 @@
1
- #include <pybind11/numpy.h>
 
 
 
 
 
2
  #include <pybind11/pybind11.h>
 
 
3
 
4
  #include <cstdint>
5
- #include <stdexcept>
6
-
7
- #include "parallel.hpp"
8
 
9
  namespace py = pybind11;
10
 
 
 
 
 
 
 
 
11
  py::array_t<int32_t> so3_second_order_encode_batch(
12
  const py::array_t<double, py::array::c_style | py::array::forcecast>&,
13
  const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
@@ -22,6 +34,15 @@ py::array_t<int32_t> additive_second_order_encode_batch(
22
  const py::array_t<double, py::array::c_style | py::array::forcecast>&,
23
  const py::array_t<float, py::array::c_style | py::array::forcecast>&, bool, int);
24
 
 
 
 
 
 
 
 
 
 
25
  py::array_t<float> physical_decode_batch(
26
  const py::array_t<float>&, const py::array_t<int32_t>&, const py::array_t<uint8_t>&,
27
  const py::array_t<float>&, const py::array_t<float>&,
@@ -41,30 +62,24 @@ py::array_t<int32_t> so3_encode_batch(
41
  const py::array_t<double, py::array::c_style | py::array::forcecast>&,
42
  const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
43
  const py::array_t<float, py::array::c_style | py::array::forcecast>&, int32_t, bool, bool,
44
- double, bool, int);
45
 
46
- void register_setbpe(py::module_& module);
47
- void register_resample(py::module_& module);
48
- void register_reconstruction(py::module_& module);
49
- void register_grammar(py::module_& module);
50
 
51
- PYBIND11_MODULE(_actioncodec2_native, module) {
52
- module.doc() = "ActionCodec2 encode/decode kernels: physical stage, resampling, Set-BPE";
53
- register_setbpe(module);
 
 
 
 
54
  register_resample(module);
55
- register_reconstruction(module);
 
 
56
  register_grammar(module);
57
- module.def(
58
- "set_thread_budget",
59
- [](int threads) {
60
- if (threads < 0) throw std::invalid_argument("thread budget must be non-negative");
61
- ac2::thread_budget().store(threads);
62
- },
63
- py::arg("threads"),
64
- "Cap threads per call for this process; 0 uses every CPU it may run on.");
65
- module.def("thread_budget", [] { return ac2::thread_budget().load(); });
66
- module.def("plan_threads", &ac2::plan_threads, py::arg("count"), py::arg("requested"),
67
- py::arg("seconds_each"), "Threads a call would use for ``count`` items.");
68
  module.def("so3_second_order_encode_batch", &so3_second_order_encode_batch,
69
  py::arg("table"), py::arg("bins"), py::arg("quaternions"),
70
  py::arg("previous_first_order"), py::arg("input_order"), py::arg("left"),
@@ -73,16 +88,39 @@ PYBIND11_MODULE(_actioncodec2_native, module) {
73
  py::arg("table"), py::arg("bins"), py::arg("positions"),
74
  py::arg("previous_first_order"), py::arg("epsilon"), py::arg("smooth"),
75
  py::arg("threads") = 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  module.def("physical_decode_batch", &physical_decode_batch, py::arg("table"),
77
  py::arg("bins"), py::arg("binary"), py::arg("lower"), py::arg("upper"),
78
  py::arg("cells"), py::arg("initial"), py::arg("initial_velocity"),
79
  py::arg("order"), py::arg("threads") = 0);
 
80
  module.def("physical_encode_batch", &physical_encode_batch, py::arg("table"), py::arg("bins"),
81
  py::arg("binary"), py::arg("lower"), py::arg("upper"), py::arg("positions"),
82
  py::arg("initial"), py::arg("initial_velocity"), py::arg("order"),
83
  py::arg("threads") = 0, py::arg("epsilon") = py::array_t<float>(),
84
  py::arg("smooth") = false);
 
85
  module.def("so3_encode_batch", &so3_encode_batch, py::arg("table"), py::arg("bins"),
86
  py::arg("actions"), py::arg("input_order"), py::arg("rotation_vector"),
87
- py::arg("left"), py::arg("limit"), py::arg("smooth"), py::arg("threads") = 0);
 
 
 
 
 
 
88
  }
 
1
+ // pybind11 entry point for the ActionCodec2 native kernels.
2
+ //
3
+ // Every function here has an exactly equivalent Python implementation in the
4
+ // package. The native module is a pure accelerator: if it is not built, the
5
+ // package still works, and the test suite asserts the two agree bit for bit.
6
+
7
  #include <pybind11/pybind11.h>
8
+ #include <pybind11/numpy.h>
9
+ #include <pybind11/stl.h>
10
 
11
  #include <cstdint>
12
+ #include <vector>
 
 
13
 
14
  namespace py = pybind11;
15
 
16
+ void register_resample(py::module_&);
17
+ void register_setbpe(py::module_&);
18
+ void register_inference(py::module_&);
19
+ void register_preprocessing(py::module_&);
20
+ void register_grammar(py::module_&);
21
+ void register_sparse(py::module_&);
22
+
23
  py::array_t<int32_t> so3_second_order_encode_batch(
24
  const py::array_t<double, py::array::c_style | py::array::forcecast>&,
25
  const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
 
34
  const py::array_t<double, py::array::c_style | py::array::forcecast>&,
35
  const py::array_t<float, py::array::c_style | py::array::forcecast>&, bool, int);
36
 
37
+ py::tuple train_spatial_native(py::array_t<int32_t, py::array::c_style | py::array::forcecast>,
38
+ py::array_t<int64_t, py::array::c_style | py::array::forcecast>,
39
+ const std::vector<int32_t>&, int64_t, int);
40
+
41
+ py::tuple train_temporal_native(py::iterable, int64_t,
42
+ py::array_t<uint64_t, py::array::c_style | py::array::forcecast>,
43
+ py::array_t<int32_t, py::array::c_style | py::array::forcecast>,
44
+ int32_t, int32_t, int64_t, int64_t, int);
45
+
46
  py::array_t<float> physical_decode_batch(
47
  const py::array_t<float>&, const py::array_t<int32_t>&, const py::array_t<uint8_t>&,
48
  const py::array_t<float>&, const py::array_t<float>&,
 
62
  const py::array_t<double, py::array::c_style | py::array::forcecast>&,
63
  const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
64
  const py::array_t<float, py::array::c_style | py::array::forcecast>&, int32_t, bool, bool,
65
+ double, int32_t, int);
66
 
67
+ py::tuple train_bpe_native(py::iterable, const std::vector<int32_t>&, int64_t, int64_t, int);
 
 
 
68
 
69
+ py::tuple spatial_encode_instances(
70
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
71
+ const py::array_t<int64_t, py::array::c_style | py::array::forcecast>&,
72
+ const std::vector<int32_t>&,
73
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&, int);
74
+
75
+ PYBIND11_MODULE(_native, module) {
76
  register_resample(module);
77
+ register_setbpe(module);
78
+ register_inference(module);
79
+ register_preprocessing(module);
80
  register_grammar(module);
81
+ register_sparse(module);
82
+ module.doc() = "ActionCodec2 native kernels (Set-BPE training and batched physical stage)";
 
 
 
 
 
 
 
 
 
83
  module.def("so3_second_order_encode_batch", &so3_second_order_encode_batch,
84
  py::arg("table"), py::arg("bins"), py::arg("quaternions"),
85
  py::arg("previous_first_order"), py::arg("input_order"), py::arg("left"),
 
88
  py::arg("table"), py::arg("bins"), py::arg("positions"),
89
  py::arg("previous_first_order"), py::arg("epsilon"), py::arg("smooth"),
90
  py::arg("threads") = 0);
91
+
92
+ module.def("train_spatial", &train_spatial_native, py::arg("codes"), py::arg("weights"),
93
+ py::arg("bins"), py::arg("vocab_size"), py::arg("threads") = 0,
94
+ "Exact greedy spatial Set-BPE; returns (merges, report).");
95
+
96
+ module.def("train_temporal", &train_temporal_native, py::arg("instance_tokens"),
97
+ py::arg("total_anchors"), py::arg("spatial_support"),
98
+ py::arg("spatial_anchor"), py::arg("dimension"), py::arg("lookahead"),
99
+ py::arg("vocab_size"), py::arg("action_count"), py::arg("threads") = 0,
100
+ "Exact greedy rooted temporal Set-BPE; returns (merges, report).");
101
+
102
+ module.def("train_bpe", &train_bpe_native, py::arg("cell_chunks"), py::arg("bins"),
103
+ py::arg("vocab_size"), py::arg("max_token_cells"), py::arg("threads") = 0,
104
+ "Exact greedy BPE over the row-major flattened cell stream; returns (merges, report).");
105
+
106
  module.def("physical_decode_batch", &physical_decode_batch, py::arg("table"),
107
  py::arg("bins"), py::arg("binary"), py::arg("lower"), py::arg("upper"),
108
  py::arg("cells"), py::arg("initial"), py::arg("initial_velocity"),
109
  py::arg("order"), py::arg("threads") = 0);
110
+
111
  module.def("physical_encode_batch", &physical_encode_batch, py::arg("table"), py::arg("bins"),
112
  py::arg("binary"), py::arg("lower"), py::arg("upper"), py::arg("positions"),
113
  py::arg("initial"), py::arg("initial_velocity"), py::arg("order"),
114
  py::arg("threads") = 0, py::arg("epsilon") = py::array_t<float>(),
115
  py::arg("smooth") = false);
116
+
117
  module.def("so3_encode_batch", &so3_encode_batch, py::arg("table"), py::arg("bins"),
118
  py::arg("actions"), py::arg("input_order"), py::arg("rotation_vector"),
119
+ py::arg("left"), py::arg("limit"), py::arg("selection"), py::arg("threads") = 0,
120
+ "Batched exact SO(3) error-feedback encoding; selection 0 nearest, 1 smooth, "
121
+ "2 momentum_lazy.");
122
+
123
+ module.def("spatial_encode_instances", &spatial_encode_instances, py::arg("codes"),
124
+ py::arg("row_anchors"), py::arg("bins"), py::arg("merges"),
125
+ py::arg("threads") = 0);
126
  }
actioncodec2_kernels/parallel.hpp CHANGED
@@ -16,6 +16,7 @@
16
 
17
  namespace ac2 {
18
 
 
19
  inline std::atomic<int>& thread_budget() {
20
  static std::atomic<int> value{0};
21
  return value;
@@ -32,8 +33,11 @@ inline int available_cpus() {
32
  return std::max(1, static_cast<int>(std::thread::hardware_concurrency()));
33
  }
34
 
 
35
  constexpr double kThreadStartSeconds = 30e-6;
36
 
 
 
37
  inline int64_t plan_threads(int64_t count, int requested, double seconds_each) {
38
  const int budget = thread_budget().load();
39
  int64_t threads = budget > 0 ? budget : available_cpus();
@@ -85,4 +89,4 @@ void parallel_for(int64_t count, int threads, Body&& body) {
85
  if (failure) std::rethrow_exception(failure);
86
  }
87
 
88
- }
 
16
 
17
  namespace ac2 {
18
 
19
+ // Process-wide cap on threads per call; 0 means every CPU the process may use.
20
  inline std::atomic<int>& thread_budget() {
21
  static std::atomic<int> value{0};
22
  return value;
 
33
  return std::max(1, static_cast<int>(std::thread::hardware_concurrency()));
34
  }
35
 
36
+ // Measured cost of starting and joining one thread (seconds).
37
  constexpr double kThreadStartSeconds = 30e-6;
38
 
39
+ // Threads for ``count`` iterations of ``seconds_each``: at most ``requested``
40
+ // when positive, at most the process budget, and at most ``sqrt(W / s)``.
41
  inline int64_t plan_threads(int64_t count, int requested, double seconds_each) {
42
  const int budget = thread_budget().load();
43
  int64_t threads = budget > 0 ? budget : available_cpus();
 
89
  if (failure) std::rethrow_exception(failure);
90
  }
91
 
92
+ } // namespace ac2
actioncodec2_kernels/physical.cpp CHANGED
@@ -1,3 +1,4 @@
 
1
  #include <pybind11/pybind11.h>
2
  #include <pybind11/numpy.h>
3
  #include <pybind11/stl.h>
@@ -6,7 +7,6 @@
6
  #include <cfloat>
7
  #include <cmath>
8
  #include <cstdint>
9
- #include <limits>
10
  #include <vector>
11
 
12
  #include "parallel.hpp"
@@ -33,7 +33,7 @@ inline int32_t pick_second_order_axis(const SecondOrderAxis& axis, double reques
33
  const int32_t right = std::min(insertion, axis.count - 1);
34
  int32_t nearest = std::fabs(request - values[left]) <= std::fabs(request - values[right])
35
  ? left : right;
36
-
37
  while (nearest > 0 && std::fabs(request - values[nearest - 1]) == std::fabs(request - values[nearest]))
38
  --nearest;
39
  if constexpr (!Smooth) return nearest;
@@ -43,7 +43,7 @@ inline int32_t pick_second_order_axis(const SecondOrderAxis& axis, double reques
43
  const double sign = primitive > 0 ? 1 : primitive < 0 ? -1 : 0;
44
  return sign * direction >= 0 && std::fabs(request - primitive) <= epsilon ? preferred : nearest;
45
  }
46
-
47
  int32_t nearest = 0, feasible = -1, preferred = -1;
48
  double nearest_distance = INFINITY, feasible_distance = INFINITY, preferred_distance = INFINITY;
49
  for (int32_t k = 0; k < axis.count; ++k) {
@@ -62,7 +62,7 @@ inline int32_t pick_second_order_axis(const SecondOrderAxis& axis, double reques
62
  return preferred >= 0 ? preferred : feasible >= 0 ? feasible : nearest;
63
  }
64
 
65
- }
66
 
67
  py::array_t<int32_t> additive_second_order_encode_batch(
68
  const py::array_t<float, py::array::c_style | py::array::forcecast>& table,
@@ -124,16 +124,17 @@ namespace {
124
 
125
  struct Layout {
126
  int32_t dimension;
127
- int32_t width;
128
- const float* table;
129
- const int32_t* bins;
130
- const uint8_t* binary;
131
  const float* lower;
132
  const float* upper;
133
- const float* epsilon;
134
- bool smooth;
135
  };
136
 
 
137
  constexpr float kSourceTolerance = 8.0F * FLT_EPSILON;
138
 
139
  inline int32_t nearest(const Layout& layout, int32_t axis, float request) {
@@ -175,7 +176,7 @@ inline int32_t select_smooth(const Layout& layout, int32_t axis, float request,
175
  feasible_distance = distance;
176
  feasible_index = index;
177
  }
178
-
179
  const float sign = values[index] > 0.0F ? 1.0F : (values[index] < 0.0F ? -1.0F : 0.0F);
180
  if (!(sign * direction >= 0.0F)) continue;
181
  if (!any_preferred || distance < preferred_distance) {
@@ -193,7 +194,7 @@ void encode_first_order(const Layout& layout, const float* positions, int64_t st
193
  const float* initial, int32_t* cells) {
194
  const int32_t dimension = layout.dimension;
195
  std::vector<float> state(initial, initial + dimension);
196
-
197
  std::vector<float> direction(dimension, 0.0F);
198
  for (int64_t step = 0; step < steps; ++step) {
199
  const float* target = positions + step * dimension;
@@ -297,47 +298,6 @@ Layout make_layout(const py::array_t<float>& table, const py::array_t<int32_t>&
297
  const py::array_t<uint8_t>& binary, const py::array_t<float>& lower,
298
  const py::array_t<float>& upper, const py::array_t<float>& epsilon,
299
  bool smooth) {
300
- if (table.ndim() != 2 || table.shape(0) < 1 || table.shape(1) < 1 ||
301
- table.shape(0) > std::numeric_limits<int32_t>::max() ||
302
- table.shape(1) > std::numeric_limits<int32_t>::max()) {
303
- throw std::invalid_argument("table must have non-empty shape [D, K]");
304
- }
305
- for (const py::array* array : {static_cast<const py::array*>(&table),
306
- static_cast<const py::array*>(&bins),
307
- static_cast<const py::array*>(&binary),
308
- static_cast<const py::array*>(&lower),
309
- static_cast<const py::array*>(&upper),
310
- static_cast<const py::array*>(&epsilon)}) {
311
- if (!(array->flags() & py::array::c_style)) {
312
- throw std::invalid_argument("physical layout arrays must be C-contiguous");
313
- }
314
- }
315
- const auto dimension = table.shape(0);
316
- if (bins.ndim() != 1 || binary.ndim() != 1 || lower.ndim() != 1 || upper.ndim() != 1 ||
317
- bins.size() != dimension || binary.size() != dimension ||
318
- lower.size() != dimension || upper.size() != dimension) {
319
- throw std::invalid_argument("layout vectors must have shape [D]");
320
- }
321
- if ((epsilon.size() && (epsilon.ndim() != 1 || epsilon.size() != dimension)) ||
322
- (smooth && !epsilon.size())) {
323
- throw std::invalid_argument("smooth selection needs epsilon with shape [D]");
324
- }
325
- for (py::ssize_t axis = 0; axis < dimension; ++axis) {
326
- const int32_t count = bins.data()[axis];
327
- if (count < 1 || count > table.shape(1) || binary.data()[axis] > 1 ||
328
- !(lower.data()[axis] <= upper.data()[axis])) {
329
- throw std::invalid_argument("invalid physical layout bounds or primitive count");
330
- }
331
- if (epsilon.size() &&
332
- !(std::isfinite(epsilon.data()[axis]) && epsilon.data()[axis] >= 0)) {
333
- throw std::invalid_argument("epsilon must be finite and non-negative");
334
- }
335
- for (int32_t cell = 0; cell < count; ++cell) {
336
- if (!std::isfinite(table.data()[axis * table.shape(1) + cell])) {
337
- throw std::invalid_argument("active primitives must be finite");
338
- }
339
- }
340
- }
341
  Layout layout;
342
  layout.dimension = static_cast<int32_t>(table.shape(0));
343
  layout.width = static_cast<int32_t>(table.shape(1));
@@ -348,6 +308,12 @@ Layout make_layout(const py::array_t<float>& table, const py::array_t<int32_t>&
348
  layout.upper = upper.data();
349
  layout.epsilon = epsilon.size() == 0 ? nullptr : epsilon.data();
350
  layout.smooth = smooth && layout.epsilon != nullptr;
 
 
 
 
 
 
351
  return layout;
352
  }
353
 
@@ -355,7 +321,7 @@ using Array2D = py::array_t<float, py::array::c_style | py::array::forcecast>;
355
  using Array3D = py::array_t<float, py::array::c_style | py::array::forcecast>;
356
  using Cells3D = py::array_t<int32_t, py::array::c_style | py::array::forcecast>;
357
 
358
- }
359
 
360
  py::array_t<float> physical_decode_batch(const py::array_t<float>& table,
361
  const py::array_t<int32_t>& bins,
@@ -365,29 +331,21 @@ py::array_t<float> physical_decode_batch(const py::array_t<float>& table,
365
  const Array2D& initial,
366
  const Array2D& initial_velocity, int32_t order,
367
  int threads) {
368
- const Layout layout =
369
- make_layout(table, bins, binary, lower, upper, py::array_t<float>(), false);
370
- if (cells.ndim() != 3 || cells.shape(1) < 1 || cells.shape(2) != layout.dimension || initial.ndim() != 2 ||
371
  initial_velocity.ndim() != 2 || initial.shape(0) != cells.shape(0) ||
372
  initial_velocity.shape(0) != cells.shape(0) || initial.shape(1) != cells.shape(2) ||
373
  initial_velocity.shape(1) != cells.shape(2) || (order != 1 && order != 2)) {
374
  throw std::invalid_argument("invalid physical decode batch shapes or order");
375
  }
 
 
376
  const int64_t batch = cells.shape(0);
377
  const int64_t steps = cells.shape(1);
378
  const int64_t dimension = layout.dimension;
379
- for (py::ssize_t index = 0; index < initial.size(); ++index) {
380
- if (!std::isfinite(initial.data()[index]) || !std::isfinite(initial_velocity.data()[index])) {
381
- throw std::invalid_argument("initial state and velocity must be finite");
382
- }
383
- }
384
- for (py::ssize_t index = 0; index < cells.size(); ++index) {
385
- const int32_t cell = cells.data()[index];
386
- if (cell < 0 || cell >= layout.bins[index % dimension]) {
387
- throw std::invalid_argument("cell index is outside its primitive table");
388
- }
389
- }
390
  py::array_t<float> positions({batch, steps, dimension});
 
 
 
391
  {
392
  py::gil_scoped_release release;
393
  ac2::parallel_for(batch, threads, [&](int64_t index) {
@@ -404,6 +362,7 @@ py::array_t<float> physical_decode_batch(const py::array_t<float>& table,
404
  return positions;
405
  }
406
 
 
407
  py::array_t<int32_t> physical_encode_batch(const py::array_t<float>& table,
408
  const py::array_t<int32_t>& bins,
409
  const py::array_t<uint8_t>& binary,
@@ -414,24 +373,6 @@ py::array_t<int32_t> physical_encode_batch(const py::array_t<float>& table,
414
  int threads, const py::array_t<float>& epsilon,
415
  bool smooth) {
416
  const Layout layout = make_layout(table, bins, binary, lower, upper, epsilon, smooth);
417
- if (positions.ndim() != 3 || positions.shape(1) < 1 ||
418
- positions.shape(2) != layout.dimension || initial.ndim() != 2 ||
419
- initial_velocity.ndim() != 2 || initial.shape(0) != positions.shape(0) ||
420
- initial_velocity.shape(0) != positions.shape(0) ||
421
- initial.shape(1) != layout.dimension || initial_velocity.shape(1) != layout.dimension ||
422
- (order != 1 && order != 2)) {
423
- throw std::invalid_argument("invalid physical encode batch shapes or order");
424
- }
425
- for (py::ssize_t index = 0; index < positions.size(); ++index) {
426
- if (!std::isfinite(positions.data()[index])) {
427
- throw std::invalid_argument("positions must be finite");
428
- }
429
- }
430
- for (py::ssize_t index = 0; index < initial.size(); ++index) {
431
- if (!std::isfinite(initial.data()[index]) || !std::isfinite(initial_velocity.data()[index])) {
432
- throw std::invalid_argument("initial state and velocity must be finite");
433
- }
434
- }
435
  const int64_t batch = positions.shape(0);
436
  const int64_t steps = positions.shape(1);
437
  const int64_t dimension = layout.dimension;
@@ -440,6 +381,9 @@ py::array_t<int32_t> physical_encode_batch(const py::array_t<float>& table,
440
  const float* starts = initial.data();
441
  const float* velocities = initial_velocity.data();
442
  int32_t* output = cells.mutable_data();
 
 
 
443
  {
444
  py::gil_scoped_release release;
445
  ac2::parallel_for(batch, threads, [&](int64_t index) {
 
1
+
2
  #include <pybind11/pybind11.h>
3
  #include <pybind11/numpy.h>
4
  #include <pybind11/stl.h>
 
7
  #include <cfloat>
8
  #include <cmath>
9
  #include <cstdint>
 
10
  #include <vector>
11
 
12
  #include "parallel.hpp"
 
33
  const int32_t right = std::min(insertion, axis.count - 1);
34
  int32_t nearest = std::fabs(request - values[left]) <= std::fabs(request - values[right])
35
  ? left : right;
36
+ // Rounded distance plateaus must keep the original first-cell tie-break.
37
  while (nearest > 0 && std::fabs(request - values[nearest - 1]) == std::fabs(request - values[nearest]))
38
  --nearest;
39
  if constexpr (!Smooth) return nearest;
 
43
  const double sign = primitive > 0 ? 1 : primitive < 0 ? -1 : 0;
44
  return sign * direction >= 0 && std::fabs(request - primitive) <= epsilon ? preferred : nearest;
45
  }
46
+ // Small tables are faster to scan; arbitrary stored order also uses this path.
47
  int32_t nearest = 0, feasible = -1, preferred = -1;
48
  double nearest_distance = INFINITY, feasible_distance = INFINITY, preferred_distance = INFINITY;
49
  for (int32_t k = 0; k < axis.count; ++k) {
 
62
  return preferred >= 0 ? preferred : feasible >= 0 ? feasible : nearest;
63
  }
64
 
65
+ } // namespace
66
 
67
  py::array_t<int32_t> additive_second_order_encode_batch(
68
  const py::array_t<float, py::array::c_style | py::array::forcecast>& table,
 
124
 
125
  struct Layout {
126
  int32_t dimension;
127
+ int32_t width; // padded primitive-table stride
128
+ const float* table; // [dimension * width], +inf padding
129
+ const int32_t* bins; // [dimension]
130
+ const uint8_t* binary; // [dimension], 1 for absolute two-valued axes
131
  const float* lower;
132
  const float* upper;
133
+ const float* epsilon; // [dimension]; null unless `smooth` is set
134
+ bool smooth; // pick by FirstOrderCodec._select_smooth
135
  };
136
 
137
+ // Matches PhysicalCodecBase.SOURCE_TOLERANCE.
138
  constexpr float kSourceTolerance = 8.0F * FLT_EPSILON;
139
 
140
  inline int32_t nearest(const Layout& layout, int32_t axis, float request) {
 
176
  feasible_distance = distance;
177
  feasible_index = index;
178
  }
179
+ // np.sign(value) * direction >= 0: a zero primitive never reverses.
180
  const float sign = values[index] > 0.0F ? 1.0F : (values[index] < 0.0F ? -1.0F : 0.0F);
181
  if (!(sign * direction >= 0.0F)) continue;
182
  if (!any_preferred || distance < preferred_distance) {
 
194
  const float* initial, int32_t* cells) {
195
  const int32_t dimension = layout.dimension;
196
  std::vector<float> state(initial, initial + dimension);
197
+ // Sign of the last non-zero primitive each coordinate emitted.
198
  std::vector<float> direction(dimension, 0.0F);
199
  for (int64_t step = 0; step < steps; ++step) {
200
  const float* target = positions + step * dimension;
 
298
  const py::array_t<uint8_t>& binary, const py::array_t<float>& lower,
299
  const py::array_t<float>& upper, const py::array_t<float>& epsilon,
300
  bool smooth) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  Layout layout;
302
  layout.dimension = static_cast<int32_t>(table.shape(0));
303
  layout.width = static_cast<int32_t>(table.shape(1));
 
308
  layout.upper = upper.data();
309
  layout.epsilon = epsilon.size() == 0 ? nullptr : epsilon.data();
310
  layout.smooth = smooth && layout.epsilon != nullptr;
311
+ if (smooth && layout.epsilon == nullptr) {
312
+ throw std::invalid_argument("smooth selection needs a per-coordinate epsilon");
313
+ }
314
+ if (layout.epsilon != nullptr && epsilon.size() != layout.dimension) {
315
+ throw std::invalid_argument("epsilon must have one entry per coordinate");
316
+ }
317
  return layout;
318
  }
319
 
 
321
  using Array3D = py::array_t<float, py::array::c_style | py::array::forcecast>;
322
  using Cells3D = py::array_t<int32_t, py::array::c_style | py::array::forcecast>;
323
 
324
+ } // namespace
325
 
326
  py::array_t<float> physical_decode_batch(const py::array_t<float>& table,
327
  const py::array_t<int32_t>& bins,
 
331
  const Array2D& initial,
332
  const Array2D& initial_velocity, int32_t order,
333
  int threads) {
334
+ if (cells.ndim() != 3 || cells.shape(2) != table.shape(0) || initial.ndim() != 2 ||
 
 
335
  initial_velocity.ndim() != 2 || initial.shape(0) != cells.shape(0) ||
336
  initial_velocity.shape(0) != cells.shape(0) || initial.shape(1) != cells.shape(2) ||
337
  initial_velocity.shape(1) != cells.shape(2) || (order != 1 && order != 2)) {
338
  throw std::invalid_argument("invalid physical decode batch shapes or order");
339
  }
340
+ const Layout layout =
341
+ make_layout(table, bins, binary, lower, upper, py::array_t<float>(), false);
342
  const int64_t batch = cells.shape(0);
343
  const int64_t steps = cells.shape(1);
344
  const int64_t dimension = layout.dimension;
 
 
 
 
 
 
 
 
 
 
 
345
  py::array_t<float> positions({batch, steps, dimension});
346
+ #ifndef _OPENMP
347
+ (void)threads;
348
+ #endif
349
  {
350
  py::gil_scoped_release release;
351
  ac2::parallel_for(batch, threads, [&](int64_t index) {
 
362
  return positions;
363
  }
364
 
365
+ // One thread per chunk. ``positions`` is [B, T, D] and ``initial`` is [B, D].
366
  py::array_t<int32_t> physical_encode_batch(const py::array_t<float>& table,
367
  const py::array_t<int32_t>& bins,
368
  const py::array_t<uint8_t>& binary,
 
373
  int threads, const py::array_t<float>& epsilon,
374
  bool smooth) {
375
  const Layout layout = make_layout(table, bins, binary, lower, upper, epsilon, smooth);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  const int64_t batch = positions.shape(0);
377
  const int64_t steps = positions.shape(1);
378
  const int64_t dimension = layout.dimension;
 
381
  const float* starts = initial.data();
382
  const float* velocities = initial_velocity.data();
383
  int32_t* output = cells.mutable_data();
384
+ #ifndef _OPENMP
385
+ (void)threads;
386
+ #endif
387
  {
388
  py::gil_scoped_release release;
389
  ac2::parallel_for(batch, threads, [&](int64_t index) {
actioncodec2_kernels/preprocessing.cpp ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Continuous-segment preprocessing kernels. Policy and statistics stay in Python.
2
+ #include <pybind11/numpy.h>
3
+ #include <pybind11/pybind11.h>
4
+ #include <pybind11/stl.h>
5
+ #include <algorithm>
6
+ #include <cmath>
7
+ #include <map>
8
+ #include <mutex>
9
+ #include <numeric>
10
+ #include <vector>
11
+ #include "quaternion.hpp"
12
+
13
+ namespace py = pybind11;
14
+ namespace {
15
+ using D64=py::array_t<double,py::array::c_style|py::array::forcecast>;
16
+ using F32=py::array_t<float,py::array::c_style|py::array::forcecast>;
17
+
18
+ double step_norm(const double* a,const double* b,int64_t width,bool single) {
19
+ if(single) {
20
+ float sum=0;
21
+ for(int64_t d=0;d<width;++d) {const float v=static_cast<float>(b[d])-static_cast<float>(a[d]);sum+=v*v;}
22
+ return std::sqrt(sum);
23
+ }
24
+ double sum=0;
25
+ for(int64_t d=0;d<width;++d){double v=b[d]-a[d];sum+=v*v;}
26
+ return std::sqrt(sum);
27
+ }
28
+
29
+ D64 step_norms(const D64& values,bool single) {
30
+ if(values.ndim()!=2)throw std::invalid_argument("expected [T,D] values");
31
+ const int64_t steps=std::max<int64_t>(0,values.shape(0)-1),width=values.shape(1);
32
+ D64 output(steps);
33
+ py::gil_scoped_release release;
34
+ for(int64_t t=0;t<steps;++t)output.mutable_data()[t]=step_norm(values.data()+t*width,values.data()+(t+1)*width,width,single);
35
+ return output;
36
+ }
37
+
38
+ std::pair<int64_t,int64_t> boundary_trim(const D64& values,double tau,int64_t idle,
39
+ int64_t context,bool legacy,bool single) {
40
+ if(values.ndim()!=2 || context<1)throw std::invalid_argument("invalid trimming input");
41
+ int64_t length=values.shape(0),width=values.shape(1);
42
+ if(length<2)return {0,length};
43
+ py::gil_scoped_release release;
44
+ int64_t first=-1,last=-1;
45
+ for(int64_t t=0;t<length-1;++t) {
46
+ if(step_norm(values.data()+t*width,values.data()+(t+1)*width,width,single)>tau) {
47
+ if(first<0)first=t;
48
+ last=t;
49
+ }
50
+ }
51
+ if(first<0)return legacy?std::make_pair(length,length):std::make_pair(int64_t{0},length);
52
+ const int64_t trailing=length-2-last;
53
+ int64_t start=first>=idle?(legacy?first+1:std::max<int64_t>(0,first-context+1)):0;
54
+ int64_t stop=trailing>=idle?last+2:length;
55
+ return {start,legacy?std::max(start,stop):std::max(start+1,stop)};
56
+ }
57
+
58
+ py::dict trim_deviation(const D64& values,int64_t start,int64_t stop,
59
+ const std::string& profile,int64_t gripper) {
60
+ if(values.ndim()!=2 || start<0 || start>=stop || stop>values.shape(0))
61
+ throw std::invalid_argument("trim slice must retain at least one row");
62
+ const int64_t width=values.shape(1),length=values.shape(0);
63
+ std::vector<int64_t> motion;
64
+ for(int64_t d=0;d<width;++d)if(d!=gripper)motion.push_back(d);
65
+ if(profile=="eef" && motion.size()<6)throw std::invalid_argument("EEF needs six motion columns");
66
+ std::vector<double> maximum(motion.size(),0);
67
+ double translation=0,rotation=0;bool changed=false;
68
+ {
69
+ py::gil_scoped_release release;
70
+ for(int side=0;side<2;++side) {
71
+ int64_t begin=side?stop:0,end=side?length:start,anchor=side?stop-1:start;
72
+ const double* a=values.data()+anchor*width;
73
+ for(int64_t t=begin;t<end;++t) {
74
+ const double* v=values.data()+t*width;
75
+ for(size_t d=0;d<motion.size();++d)maximum[d]=std::max(maximum[d],std::abs(v[motion[d]]-a[motion[d]]));
76
+ if(gripper>=0)changed=changed || v[gripper]!=a[gripper];
77
+ if(profile=="eef") {
78
+ double distance=0,ra[3],rv[3],relative[3];
79
+ for(int d=0;d<3;++d) {
80
+ double delta=v[motion[d]]-a[motion[d]];distance+=delta*delta;
81
+ ra[d]=a[motion[d+3]];rv[d]=v[motion[d+3]];
82
+ }
83
+ translation=std::max(translation,std::sqrt(distance));
84
+ ac2q::to_rotvec(ac2q::multiply(ac2q::conjugate(ac2q::from_rotvec(ra)),ac2q::from_rotvec(rv)),relative);
85
+ rotation=std::max(rotation,std::sqrt(relative[0]*relative[0]+relative[1]*relative[1]+relative[2]*relative[2]));
86
+ }
87
+ }
88
+ }
89
+ }
90
+ py::dict result;
91
+ result["motion_max_error"]=maximum;result["translation_max_error_m"]=translation;
92
+ result["rotation_max_error_rad"]=rotation;result["gripper_changed"]=changed;
93
+ return result;
94
+ }
95
+
96
+ // Small normalized Vandermonde least-squares systems, solved in extended precision.
97
+ std::vector<double> polynomial_weights(int window,int degree) {
98
+ const int p=degree+1;
99
+ std::vector<long double> basis(window*p),matrix(p*2*p,0);
100
+ for(int i=0;i<window;++i) {
101
+ long double x=2.L*i/(window-1)-1.L,v=1;
102
+ for(int d=0;d<p;++d){basis[i*p+d]=v;v*=x;}
103
+ }
104
+ for(int a=0;a<p;++a) {
105
+ for(int b=0;b<p;++b)for(int i=0;i<window;++i)matrix[a*2*p+b]+=basis[i*p+a]*basis[i*p+b];
106
+ matrix[a*2*p+p+a]=1;
107
+ }
108
+ for(int a=0;a<p;++a) {
109
+ int pivot=a;
110
+ for(int b=a+1;b<p;++b)if(std::abs(matrix[b*2*p+a])>std::abs(matrix[pivot*2*p+a]))pivot=b;
111
+ if(std::abs(matrix[pivot*2*p+a])<1e-25L)throw std::invalid_argument("singular polynomial fit");
112
+ for(int j=0;j<2*p;++j)std::swap(matrix[a*2*p+j],matrix[pivot*2*p+j]);
113
+ long double scale=matrix[a*2*p+a];
114
+ for(int j=0;j<2*p;++j)matrix[a*2*p+j]/=scale;
115
+ for(int b=0;b<p;++b)if(b!=a) {
116
+ const long double factor=matrix[b*2*p+a];
117
+ for(int j=0;j<2*p;++j)matrix[b*2*p+j]-=factor*matrix[a*2*p+j];
118
+ }
119
+ }
120
+ std::vector<double> weights(window*window);
121
+ for(int target=0;target<window;++target)for(int i=0;i<window;++i) {
122
+ long double w=0;
123
+ for(int a=0;a<p;++a)for(int b=0;b<p;++b)w+=basis[target*p+a]*matrix[a*2*p+p+b]*basis[i*p+b];
124
+ weights[target*window+i]=static_cast<double>(w);
125
+ }
126
+ return weights;
127
+ }
128
+
129
+ F32 savgol(const D64& values,int window,int degree) {
130
+ if(values.ndim()!=2 || window<3 || window%2!=1 || degree<0 || degree>=window || window>values.shape(0))
131
+ throw std::invalid_argument("invalid Savitzky-Golay shape or polynomial order");
132
+ std::vector<double> weights;
133
+ {
134
+ static std::mutex mutex;
135
+ static std::map<std::pair<int,int>,std::vector<double>> cache;
136
+ std::lock_guard<std::mutex> lock(mutex);
137
+ auto key=std::make_pair(window,degree);auto it=cache.find(key);
138
+ if(it==cache.end())it=cache.emplace(key,polynomial_weights(window,degree)).first;
139
+ weights=it->second;
140
+ }
141
+ const int64_t length=values.shape(0),width=values.shape(1);
142
+ F32 output({length,width});
143
+ py::gil_scoped_release release;
144
+ for(int64_t t=0;t<length;++t) {
145
+ int64_t begin=std::clamp<int64_t>(t-window/2,0,length-window),target=t-begin;
146
+ for(int64_t d=0;d<width;++d) {
147
+ double value=0;
148
+ for(int i=0;i<window;++i)value+=weights[target*window+i]*values.data()[(begin+i)*width+d];
149
+ output.mutable_data()[t*width+d]=static_cast<float>(value);
150
+ }
151
+ }
152
+ return output;
153
+ }
154
+
155
+ D64 median(const D64& values,int window,bool nearest) {
156
+ if(values.ndim()!=1 || window<1 || window%2!=1)throw std::invalid_argument("invalid median filter input");
157
+ const int64_t length=values.size();D64 output(length);
158
+ py::gil_scoped_release release;
159
+ std::vector<double> sample(window);
160
+ for(int64_t i=0;i<length;++i) {
161
+ for(int j=0;j<window;++j) {
162
+ int64_t k=i+j-window/2;
163
+ sample[j]=(k<0 || k>=length)?(nearest?values.data()[std::clamp<int64_t>(k,0,length-1)]:0):values.data()[k];
164
+ }
165
+ std::nth_element(sample.begin(),sample.begin()+window/2,sample.end());
166
+ output.mutable_data()[i]=sample[window/2];
167
+ }
168
+ return output;
169
+ }
170
+
171
+ F32 ease(const D64& values,const D64& limits) {
172
+ if(values.ndim()!=2 || limits.ndim()!=1 || limits.size()!=values.shape(1))
173
+ throw std::invalid_argument("invalid easing shape");
174
+ int64_t length=values.shape(0),width=values.shape(1);F32 output({length,width});
175
+ py::gil_scoped_release release;
176
+ for(int64_t d=0;d<width;++d) {
177
+ if(length==0)continue;
178
+ double debt=0,sum=0,limit=limits.data()[d];
179
+ output.mutable_data()[d]=static_cast<float>(values.data()[d]);
180
+ for(int64_t t=1;t<length;++t) {
181
+ double step=values.data()[t*width+d]-values.data()[(t-1)*width+d],candidate=step+debt;
182
+ if(std::abs(candidate)<=limit){step=candidate;debt=0;}
183
+ else {step=(candidate>0?1:-1)*limit;debt=candidate-step;}
184
+ sum+=step;output.mutable_data()[t*width+d]=static_cast<float>(values.data()[d]+sum);
185
+ }
186
+ }
187
+ return output;
188
+ }
189
+
190
+ F32 gripper(const D64& values,const std::string& operation,double first,double second) {
191
+ if(values.ndim()!=2 || values.shape(1)!=1)throw std::invalid_argument("gripper action must be [T,1]");
192
+ F32 output({values.shape(0),int64_t{1}});
193
+ py::gil_scoped_release release;
194
+ for(int64_t i=0;i<values.size();++i) {
195
+ float v=static_cast<float>(values.data()[i]);
196
+ if(!std::isfinite(v))throw std::invalid_argument("gripper fraction must be finite");
197
+ if(operation=="normalize") {
198
+ const float span=static_cast<float>(second-first);
199
+ output.mutable_data()[i]=std::clamp((v-static_cast<float>(first))/span,0.F,1.F);
200
+ } else if(operation=="binary")output.mutable_data()[i]=v>=static_cast<float>(first)?1.F:0.F;
201
+ else if(operation=="eleven")output.mutable_data()[i]=std::nearbyint(v*10.F)/10.F;
202
+ else throw std::invalid_argument("invalid gripper operation");
203
+ }
204
+ return output;
205
+ }
206
+ std::vector<double> max_change(const D64& values,const D64& reference,
207
+ const std::vector<int64_t>& columns,bool single) {
208
+ if(values.ndim()!=2 || reference.ndim()!=2 || values.shape(0)!=reference.shape(0) || values.shape(1)!=reference.shape(1))
209
+ throw std::invalid_argument("mismatched change metric shapes");
210
+ std::vector<double> result(columns.size(),0);const int64_t width=values.shape(1);
211
+ py::gil_scoped_release release;
212
+ for(size_t i=0;i<columns.size();++i) {
213
+ const int64_t d=(columns[i]%width+width)%width;
214
+ for(int64_t t=0;t<values.shape(0);++t) {
215
+ double change=single?std::abs(static_cast<float>(values.data()[t*width+d])-static_cast<float>(reference.data()[t*width+d]))
216
+ :std::abs(values.data()[t*width+d]-reference.data()[t*width+d]);
217
+ result[i]=std::max(result[i],change);
218
+ }
219
+ }
220
+ return result;
221
+ }
222
+
223
+ int64_t overspeed_steps(const D64& values,const D64& limits,bool single) {
224
+ if(values.ndim()!=2 || limits.size()!=values.shape(1))throw std::invalid_argument("invalid overspeed metric shapes");
225
+ int64_t count=0,width=values.shape(1);py::gil_scoped_release release;
226
+ for(int64_t t=1;t<values.shape(0);++t)for(int64_t d=0;d<width;++d) {
227
+ double delta=single?static_cast<float>(values.data()[t*width+d])-static_cast<float>(values.data()[(t-1)*width+d])
228
+ :values.data()[t*width+d]-values.data()[(t-1)*width+d];
229
+ if(std::abs(delta)>limits.data()[d]){++count;break;}
230
+ }
231
+ return count;
232
+ }
233
+
234
+ F32 wrap_angles(const D64& values) {
235
+ std::vector<py::ssize_t> shape(values.shape(),values.shape()+values.ndim());F32 output(shape);
236
+ const double pi=std::acos(-1.);py::gil_scoped_release release;
237
+ for(py::ssize_t i=0;i<values.size();++i) {
238
+ double remainder=std::fmod(pi-values.data()[i],2*pi);if(remainder<0)remainder+=2*pi;
239
+ output.mutable_data()[i]=static_cast<float>(pi-remainder);
240
+ }
241
+ return output;
242
+ }
243
+
244
+ template<class T>
245
+ void unwrap_column(const double* values,double* output,int64_t length,int64_t width,int64_t d) {
246
+ if(!length)return;
247
+ const T pi=static_cast<T>(std::acos(-1.)),period=static_cast<T>(2*std::acos(-1.));
248
+ T sum=0;output[d]=values[d];
249
+ for(int64_t t=1;t<length;++t) {
250
+ const T delta=static_cast<T>(values[t*width+d])-static_cast<T>(values[(t-1)*width+d]);
251
+ T reduced=std::fmod(static_cast<T>(delta+pi),period);if(reduced<0)reduced+=period;
252
+ reduced-=pi;if(reduced==-pi && delta>0)reduced=pi;
253
+ const T correction=std::abs(delta)<pi?0:reduced-delta;sum+=correction;
254
+ output[t*width+d]=static_cast<T>(static_cast<T>(values[t*width+d])+sum);
255
+ }
256
+ }
257
+ D64 unwrap_angles(const D64& values,bool single) {
258
+ if(values.ndim()!=2)throw std::invalid_argument("unwrap expects [T,D]");
259
+ int64_t length=values.shape(0),width=values.shape(1);D64 output({length,width});
260
+ py::gil_scoped_release release;
261
+ for(int64_t d=0;d<width;++d) {
262
+ if(single)unwrap_column<float>(values.data(),output.mutable_data(),length,width,d);
263
+ else unwrap_column<double>(values.data(),output.mutable_data(),length,width,d);
264
+ }
265
+ return output;
266
+ }
267
+ } // namespace
268
+
269
+ void register_preprocessing(py::module_& module) {
270
+ module.def("step_norms",&step_norms);
271
+ module.def("boundary_trim",&boundary_trim);
272
+ module.def("trim_deviation",&trim_deviation);
273
+ module.def("savgol_smooth",&savgol);
274
+ module.def("median_filter",&median);
275
+ module.def("ease_overspeed",&ease);
276
+ module.def("gripper_transform",&gripper);
277
+ module.def("max_change",&max_change);
278
+ module.def("overspeed_steps",&overspeed_steps);
279
+ module.def("wrap_angles",&wrap_angles);
280
+ module.def("unwrap_angles",&unwrap_angles);
281
+ }
actioncodec2_kernels/quaternion.hpp CHANGED
@@ -43,4 +43,4 @@ inline void to_euler(Q q, double* out) {
43
  out[1]=std::asin(std::clamp(2*(w*y-z*x),-1.,1.));
44
  out[2]=std::atan2(2*(w*z+x*y),1-2*(y*y+z*z));
45
  }
46
- }
 
43
  out[1]=std::asin(std::clamp(2*(w*y-z*x),-1.,1.));
44
  out[2]=std::atan2(2*(w*z+x*y),1-2*(y*y+z*z));
45
  }
46
+ } // namespace ac2q
actioncodec2_kernels/reconstruct.cpp DELETED
@@ -1,146 +0,0 @@
1
- #include <pybind11/numpy.h>
2
- #include <pybind11/pybind11.h>
3
-
4
- #include <algorithm>
5
- #include <cmath>
6
- #include <cstdint>
7
- #include <limits>
8
- #include <stdexcept>
9
-
10
- #include "parallel.hpp"
11
- #include "quaternion.hpp"
12
-
13
- namespace py = pybind11;
14
- namespace {
15
- using I64 = py::array_t<int64_t, py::array::c_style | py::array::forcecast>;
16
- using I32 = py::array_t<int32_t, py::array::c_style | py::array::forcecast>;
17
- using F64 = py::array_t<double, py::array::c_style | py::array::forcecast>;
18
- using F32 = py::array_t<float, py::array::c_style | py::array::forcecast>;
19
-
20
- void validate_reconstruction(const F64& table, const I32& bins, const I64& cells,
21
- const F32& previous, const F32& scale) {
22
- if (table.ndim() != 2 || table.shape(0) < 1 || table.shape(1) < 1 ||
23
- bins.ndim() != 1 || bins.size() != table.shape(0) ||
24
- cells.ndim() != 3 || cells.shape(0) < 1 || cells.shape(1) < 1 ||
25
- cells.shape(2) != table.shape(0) || previous.ndim() != 2 ||
26
- previous.shape(0) != cells.shape(0) || previous.shape(1) != cells.shape(2) ||
27
- scale.ndim() != 1 || scale.size() != cells.shape(2)) {
28
- throw std::invalid_argument("invalid reconstruction shapes");
29
- }
30
- for (py::ssize_t d = 0; d < bins.size(); ++d) {
31
- if (bins.data()[d] < 1 || bins.data()[d] > table.shape(1))
32
- throw std::invalid_argument("primitive count exceeds table width");
33
- if (!std::isfinite(scale.data()[d]) || scale.data()[d] <= 0)
34
- throw std::invalid_argument("scale must be positive and finite");
35
- for (int32_t k = 0; k < bins.data()[d]; ++k) {
36
- if (!std::isfinite(table.data()[d * table.shape(1) + k]))
37
- throw std::invalid_argument("active primitives must be finite");
38
- }
39
- }
40
- for (py::ssize_t i = 0; i < previous.size(); ++i) {
41
- if (!std::isfinite(previous.data()[i]))
42
- throw std::invalid_argument("previous first-order values must be finite");
43
- }
44
- for (py::ssize_t i = 0; i < cells.size(); ++i) {
45
- if (cells.data()[i] < 0 || cells.data()[i] >= bins.data()[i % bins.size()])
46
- throw std::invalid_argument("cell index is outside its primitive table");
47
- }
48
- }
49
-
50
- F32 so3_reconstruct(const F64& table, const I32& bins, const I64& cells,
51
- const F32& previous, const F32& scale, int input_order,
52
- int primitive_order, bool left, bool rotation_vector, double radius) {
53
- validate_reconstruction(table, bins, cells, previous, scale);
54
- if (table.shape(0) != 3 || (input_order != 0 && input_order != 1) ||
55
- (primitive_order != 1 && primitive_order != 2) ||
56
- std::isnan(radius) || radius <= 0)
57
- throw std::invalid_argument("invalid SO(3) reconstruction order or radius");
58
- const int64_t batch = cells.shape(0), steps = cells.shape(1);
59
- F32 output({batch, steps, int64_t{3}});
60
- auto* target = output.mutable_data();
61
- py::gil_scoped_release release;
62
- ac2::parallel_for(batch, 0, [&](int64_t b) {
63
- double boundary[3];
64
- for (int d = 0; d < 3; ++d)
65
- boundary[d] = static_cast<double>(previous.data()[3 * b + d]) * scale.data()[d];
66
- auto increment = rotation_vector ? ac2q::from_rotvec(boundary) : ac2q::from_euler(boundary);
67
- ac2q::Q state{0, 0, 0, 1};
68
- float accumulated[3] = {0, 0, 0};
69
- auto apply = [&](ac2q::Q a, ac2q::Q v) {
70
- return left ? ac2q::multiply(v, a) : ac2q::multiply(a, v);
71
- };
72
- for (int64_t t = 0; t < steps; ++t) {
73
- double v[3], represented[3];
74
- for (int d = 0; d < 3; ++d)
75
- v[d] = table.data()[d * table.shape(1) + cells.data()[(b * steps + t) * 3 + d]];
76
- if (primitive_order == 2) {
77
- const double norm = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
78
- const double factor = std::min(1., radius / std::max(norm, std::numeric_limits<double>::min()));
79
- for (double& x : v) x *= factor;
80
- increment = apply(increment, ac2q::from_rotvec(v));
81
- state = apply(state, increment);
82
- auto q = input_order == 1 ? increment : state;
83
- if (rotation_vector) ac2q::to_rotvec(q, represented);
84
- else ac2q::to_euler(q, represented);
85
- } else if (input_order == 1) {
86
- if (rotation_vector) std::copy(v, v + 3, represented);
87
- else {
88
- for (double& x : v) x = static_cast<float>(x);
89
- ac2q::to_euler(ac2q::from_rotvec(v), represented);
90
- }
91
- } else {
92
- state = apply(state, ac2q::from_rotvec(v));
93
- if (rotation_vector) ac2q::to_rotvec(state, represented);
94
- else ac2q::to_euler(state, represented);
95
- }
96
- for (int d = 0; d < 3; ++d) {
97
- float value = static_cast<float>(represented[d] / scale.data()[d]);
98
- if (primitive_order == 1 && input_order == 1) {
99
- const float next = accumulated[d] + value;
100
- value = next - accumulated[d];
101
- accumulated[d] = next;
102
- }
103
- target[(b * steps + t) * 3 + d] = value;
104
- }
105
- }
106
- });
107
- return output;
108
- }
109
-
110
- F32 additive_reconstruct(const F64& table, const I32& bins, const I64& cells,
111
- const F32& previous, const F32& scale, int input_order) {
112
- validate_reconstruction(table, bins, cells, previous, scale);
113
- if (input_order < 0 || input_order > 2)
114
- throw std::invalid_argument("invalid additive reconstruction order");
115
- const int64_t batch = cells.shape(0), steps = cells.shape(1), width = cells.shape(2);
116
- F32 output({batch, steps, width});
117
- auto* target = output.mutable_data();
118
- py::gil_scoped_release release;
119
- ac2::parallel_for(batch, 0, [&](int64_t b) {
120
- for (int64_t d = 0; d < width; ++d) {
121
- double change_sum = 0, position = 0;
122
- const float boundary = previous.data()[b * width + d] * scale.data()[d];
123
- for (int64_t t = 0; t < steps; ++t) {
124
- const int64_t index = (b * steps + t) * width + d;
125
- const double primitive = table.data()[d * table.shape(1) + cells.data()[index]];
126
- change_sum += primitive;
127
- const double velocity = change_sum + boundary;
128
- position += velocity;
129
- const double value = input_order == 2 ? primitive : input_order == 1 ? velocity : position;
130
- target[index] = static_cast<float>(value / scale.data()[d]);
131
- }
132
- }
133
- });
134
- return output;
135
- }
136
- }
137
-
138
- void register_reconstruction(py::module_& module) {
139
- module.def("so3_reconstruct", &so3_reconstruct, py::arg("table"), py::arg("bins"),
140
- py::arg("cells"), py::arg("previous_first_order"), py::arg("scale"),
141
- py::arg("input_order"), py::arg("primitive_order"), py::arg("left"),
142
- py::arg("rotation_vector"), py::arg("radius"));
143
- module.def("additive_reconstruct", &additive_reconstruct, py::arg("table"), py::arg("bins"),
144
- py::arg("cells"), py::arg("previous_first_order"), py::arg("scale"),
145
- py::arg("input_order"));
146
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
actioncodec2_kernels/repacking.hpp ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Optional lossless fixed-vocabulary packing for the native Set-BPE encoder.
2
+ #pragma once
3
+ #include <algorithm>
4
+ #include <array>
5
+ #include <cstdint>
6
+ #include <functional>
7
+ #include <stdexcept>
8
+ #include <tuple>
9
+ #include <unordered_map>
10
+ #include <utility>
11
+ #include <vector>
12
+
13
+ namespace ac2pack {
14
+ using Instance = std::tuple<int64_t, int64_t, int64_t>;
15
+ using Cover = std::array<uint64_t, 4>;
16
+
17
+ struct CoverHash {
18
+ size_t operator()(const Cover& value) const {
19
+ size_t hash = 0;
20
+ for (uint64_t word : value) hash ^= std::hash<uint64_t>{}(word) +
21
+ 0x9e3779b97f4a7c15ULL + (hash << 6) + (hash >> 2);
22
+ return hash;
23
+ }
24
+ };
25
+
26
+ struct Placement {
27
+ Cover cells{};
28
+ int64_t token;
29
+ int anchor, count;
30
+ };
31
+
32
+ struct Requirement { int offset; uint64_t mask, key; };
33
+ using MatchCache = std::array<std::unordered_map<uint64_t, std::vector<int64_t>>, 8>;
34
+
35
+ inline int first_free(const Cover& cover) {
36
+ for (int word = 0; word < 4; ++word)
37
+ if (~cover[word]) return word * 64 + __builtin_ctzll(~cover[word]);
38
+ return 256;
39
+ }
40
+
41
+ inline int free_count(const Cover& cover) {
42
+ int count = 0;
43
+ for (uint64_t word : cover) count += __builtin_popcountll(~word);
44
+ return count;
45
+ }
46
+
47
+ inline bool overlaps(const Cover& a, const Cover& b) {
48
+ for (int word = 0; word < 4; ++word) if (a[word] & b[word]) return true;
49
+ return false;
50
+ }
51
+
52
+ inline Cover unite(Cover a, const Cover& b) {
53
+ for (int word = 0; word < 4; ++word) a[word] |= b[word];
54
+ return a;
55
+ }
56
+
57
+ class Search {
58
+ public:
59
+ Search(const std::vector<int64_t>& bins, const std::vector<uint64_t>& support,
60
+ const std::vector<int64_t>& expansion,
61
+ const std::vector<std::vector<std::pair<int64_t, int64_t>>>& patterns,
62
+ const std::vector<int64_t>& roots)
63
+ : bins_(bins), width_(bins.size()), spatial_size_(support.size()),
64
+ support_(support), patterns_(patterns) {
65
+ if (width_ > 8) throw std::invalid_argument("packing search supports at most eight slots");
66
+ for (int64_t count : bins)
67
+ if (count > 255) throw std::invalid_argument("packing search supports at most 255 bins per slot");
68
+ keys_.resize(spatial_size_);
69
+ anchors_.resize(patterns.size());
70
+ spans_.resize(patterns.size(), 1);
71
+ counts_.resize(patterns.size());
72
+ for (int64_t id = 0; id < spatial_size_; ++id) {
73
+ for (int d = 0; d < width_; ++d) {
74
+ const int64_t value = expansion[id * width_ + d];
75
+ if (value >= 0) keys_[id] |= uint64_t(value + 1) << (8 * d);
76
+ }
77
+ lookup_.emplace(keys_[id], id);
78
+ }
79
+ temporal_by_root_.resize(spatial_size_);
80
+ for (size_t id = 0; id < patterns.size(); ++id) {
81
+ anchors_[id] = __builtin_ctzll(support[roots[id]]);
82
+ int count = 0;
83
+ for (auto [time, sid] : patterns[id]) {
84
+ count += __builtin_popcountll(support[sid]);
85
+ spans_[id] = std::max(spans_[id], int(time + 1));
86
+ }
87
+ max_cells_ = std::max(max_cells_, count);
88
+ counts_[id] = count;
89
+ if (id >= static_cast<size_t>(spatial_size_))
90
+ temporal_by_root_[lookup_.at(keys_[roots[id]])].push_back(id);
91
+ }
92
+ byte_masks_.resize(1 << width_);
93
+ for (int mask = 1; mask < (1 << width_); ++mask) {
94
+ const int d = __builtin_ctz(mask);
95
+ byte_masks_[mask] = byte_masks_[mask ^ (1 << d)] | (uint64_t{255} << (8 * d));
96
+ }
97
+ prepare_spatial_trie();
98
+ prepare_patterns();
99
+ }
100
+
101
+ std::vector<int64_t> apply(const int64_t* cells, int64_t steps,
102
+ const std::vector<int64_t>& original, bool spatial, bool temporal, int window, int beam) const {
103
+ if (!temporal) return spatial ? repack(cells, steps, original) : original;
104
+ auto result = local_repack(cells, steps, original, window, beam);
105
+ return spatial ? repack(cells, steps, result) : result;
106
+ }
107
+
108
+ private:
109
+ struct TrieNode { std::vector<int32_t> children; int64_t token = -1; };
110
+
111
+ int32_t trie_node(int dimension) {
112
+ const auto index = static_cast<int32_t>(trie_.size());
113
+ trie_.emplace_back();
114
+ if (dimension < width_) trie_.back().children.assign(bins_[dimension] + 1, -1);
115
+ return index;
116
+ }
117
+
118
+ void prepare_spatial_trie() {
119
+ for (int d = 0; d < width_; ++d) trie_roots_[d].assign(bins_[d] + 1, -1);
120
+ for (int64_t sid = 0; sid < spatial_size_; ++sid) {
121
+ if (lookup_.at(keys_[sid]) != sid) continue;
122
+ const int root = anchors_[sid], value = (keys_[sid] >> (8 * root)) & 255;
123
+ int32_t node = trie_roots_[root][value];
124
+ if (node < 0) {
125
+ node = trie_node(root + 1);
126
+ trie_roots_[root][value] = node;
127
+ }
128
+ for (int d = root + 1; d < width_; ++d) {
129
+ const int cell = (keys_[sid] >> (8 * d)) & 255;
130
+ int32_t child = trie_[node].children[cell];
131
+ if (child < 0) {
132
+ child = trie_node(d + 1);
133
+ trie_[node].children[cell] = child;
134
+ }
135
+ node = child;
136
+ }
137
+ trie_[node].token = sid;
138
+ }
139
+ }
140
+
141
+ void query_spatial_trie(int32_t node, int dimension, uint64_t row,
142
+ std::vector<int64_t>& result) const {
143
+ if (node < 0) return;
144
+ if (dimension == width_) { result.push_back(trie_[node].token); return; }
145
+ query_spatial_trie(trie_[node].children[0], dimension + 1, row, result);
146
+ const int value = (row >> (8 * dimension)) & 255;
147
+ if (value) query_spatial_trie(trie_[node].children[value], dimension + 1, row, result);
148
+ }
149
+
150
+ void prepare_patterns() {
151
+ relative_.resize(patterns_.size());
152
+ requirements_.resize(patterns_.size());
153
+ for (size_t token = 0; token < patterns_.size(); ++token) {
154
+ if (spans_[token] * width_ > 256) continue;
155
+ std::vector<Requirement> rows(spans_[token], Requirement{0, 0, 0});
156
+ for (auto [offset, sid] : patterns_[token]) {
157
+ rows[offset].offset = offset;
158
+ rows[offset].mask |= byte_masks_[support_[sid]];
159
+ rows[offset].key |= keys_[sid];
160
+ uint64_t support = support_[sid];
161
+ while (support) {
162
+ const int d = __builtin_ctzll(support); support &= support - 1;
163
+ const int bit = offset * width_ + d;
164
+ relative_[token][bit / 64] |= uint64_t{1} << (bit % 64);
165
+ }
166
+ }
167
+ for (const auto& row : rows) if (row.mask) requirements_[token].push_back(row);
168
+ }
169
+ }
170
+
171
+ uint64_t row_key(const int64_t* row) const {
172
+ uint64_t key = 0;
173
+ for (int d = 0; d < width_; ++d)
174
+ if (row[d] >= 0) key |= uint64_t(row[d] + 1) << (8 * d);
175
+ return key;
176
+ }
177
+
178
+ std::vector<int64_t> matching_spatial(uint64_t row, int dim) const {
179
+ std::vector<int64_t> result;
180
+ const int value = (row >> (8 * dim)) & 255;
181
+ if (value) query_spatial_trie(trie_roots_[dim][value], dim + 1, row, result);
182
+ return result;
183
+ }
184
+
185
+ bool placement(int64_t token, int anchor, const std::vector<uint64_t>& rows,
186
+ const Cover& fixed, Placement& out) const {
187
+ if (anchor + spans_[token] > static_cast<int>(rows.size())) return false;
188
+ for (const auto& req : requirements_[token])
189
+ if ((rows[anchor + req.offset] & req.mask) != req.key) return false;
190
+ out = Placement{}; out.token = token; out.anchor = anchor; out.count = counts_[token];
191
+ const int start = anchor * width_, word = start / 64, shift = start % 64;
192
+ const int words = (spans_[token] * width_ + 63) / 64;
193
+ for (int i = 0; i < words && word + i < 4; ++i) {
194
+ out.cells[word + i] |= relative_[token][i] << shift;
195
+ if (shift && word + i + 1 < 4)
196
+ out.cells[word + i + 1] |= relative_[token][i] >> (64 - shift);
197
+ }
198
+ return !overlaps(out.cells, fixed);
199
+ }
200
+
201
+ std::vector<Placement> candidates(
202
+ int root, const std::vector<uint64_t>& rows, const Cover& fixed,
203
+ MatchCache& cache) const {
204
+ std::vector<Placement> result;
205
+ const int time = root / width_, dim = root % width_;
206
+ auto& table = cache[dim];
207
+ auto found = table.find(rows[time]);
208
+ if (found == table.end())
209
+ found = table.emplace(rows[time], matching_spatial(rows[time], dim)).first;
210
+ for (int64_t sid : found->second) {
211
+ Placement offer;
212
+ if (placement(sid, time, rows, fixed, offer)) result.push_back(offer);
213
+ for (int64_t token : temporal_by_root_[sid])
214
+ if (placement(token, time, rows, fixed, offer)) result.push_back(offer);
215
+ }
216
+ std::sort(result.begin(), result.end(), [](const Placement& a, const Placement& b) {
217
+ return std::make_pair(-a.count, a.token) < std::make_pair(-b.count, b.token);
218
+ });
219
+ return result;
220
+ }
221
+
222
+ using Options = std::function<const std::vector<Placement>&(int)>;
223
+
224
+ std::vector<Instance> greedy_cover(const Options& options, Cover occupied, int bound) const {
225
+ std::vector<Instance> result;
226
+ while (first_free(occupied) != 256 && static_cast<int>(result.size()) + 1 < bound) {
227
+ const auto& offers = options(first_free(occupied));
228
+ auto choice = std::find_if(offers.begin(), offers.end(), [&](const Placement& offer) {
229
+ return !overlaps(occupied, offer.cells);
230
+ });
231
+ if (choice == offers.end()) return {};
232
+ result.emplace_back(choice->anchor, anchors_[choice->token], choice->token);
233
+ occupied = unite(occupied, choice->cells);
234
+ }
235
+ return first_free(occupied) == 256 ? result : std::vector<Instance>{};
236
+ }
237
+
238
+ std::vector<Instance> beam_cover(const Options& options,
239
+ const Cover& fixed, int bound, int beam) const {
240
+ if (beam == 1) return greedy_cover(options, fixed, bound);
241
+ struct Node { Cover covered; int parent; int64_t token; int anchor, remaining, root; };
242
+ std::vector<Node> arena{{fixed, -1, -1, -1, free_count(fixed), first_free(fixed)}};
243
+ arena.reserve(beam * bound + 1);
244
+ std::vector<int> frontier{0};
245
+ frontier.reserve(beam);
246
+ std::array<Node, 64> next;
247
+ auto better = [](const Node& a, const Node& b) {
248
+ if (a.remaining != b.remaining) return a.remaining < b.remaining;
249
+ return a.root != b.root ? a.root > b.root : a.covered < b.covered;
250
+ };
251
+ for (int depth = 0; depth + 1 < bound && !frontier.empty(); ++depth) {
252
+ int keep = 0;
253
+ for (int index : frontier) {
254
+ for (const auto& offer : options(arena[index].root)) {
255
+ if (overlaps(arena[index].covered, offer.cells)) continue;
256
+ Cover covered = unite(arena[index].covered, offer.cells);
257
+ const int remaining = arena[index].remaining - offer.count;
258
+ if (!remaining) {
259
+ std::vector<Instance> result{{offer.anchor, anchors_[offer.token], offer.token}};
260
+ for (int p = index; arena[p].parent >= 0; p = arena[p].parent)
261
+ result.emplace_back(arena[p].anchor, anchors_[arena[p].token], arena[p].token);
262
+ std::reverse(result.begin(), result.end());
263
+ return result;
264
+ }
265
+ if (keep == beam && remaining > next[keep - 1].remaining) continue;
266
+ Node node{covered, index, offer.token, offer.anchor, remaining, first_free(covered)};
267
+ if (keep == beam && !better(node, next[keep - 1])) continue;
268
+ bool duplicate = false;
269
+ for (int i = 0; i < keep; ++i) if (covered == next[i].covered) { duplicate = true; break; }
270
+ if (duplicate) continue;
271
+ int position = keep < beam ? keep++ : keep - 1;
272
+ while (position && better(node, next[position - 1])) {
273
+ next[position] = next[position - 1]; --position;
274
+ }
275
+ next[position] = node;
276
+ }
277
+ }
278
+ frontier.clear();
279
+ for (int i = 0; i < keep; ++i) {
280
+ frontier.push_back(arena.size());
281
+ arena.push_back(std::move(next[i]));
282
+ }
283
+ }
284
+ return {};
285
+ }
286
+
287
+ std::vector<int64_t> local_repack(const int64_t* cells, int64_t steps,
288
+ const std::vector<int64_t>& stream, int window, int beam) const {
289
+ uint64_t full = 0;
290
+ for (int d = 0; d < width_; ++d) if (cells[d] >= 0) full |= uint64_t{1} << d;
291
+ std::vector<uint64_t> coverage(steps, 0), fixed(steps, 0);
292
+ std::vector<std::vector<Instance>> blocks((steps + window - 1) / window);
293
+ std::vector<Instance> result;
294
+ int64_t front = 0;
295
+ for (int64_t token : stream) {
296
+ while (front < steps && coverage[front] == full) ++front;
297
+ const bool crossing = front / window != (front + spans_[token] - 1) / window;
298
+ Instance instance{front, anchors_[token], token};
299
+ if (crossing) result.push_back(instance);
300
+ else blocks[front / window].push_back(instance);
301
+ for (auto [offset, sid] : patterns_[token]) {
302
+ coverage[front + offset] |= support_[sid];
303
+ if (crossing) fixed[front + offset] |= support_[sid];
304
+ }
305
+ }
306
+ MatchCache cache;
307
+ for (size_t block = 0; block < blocks.size(); ++block) {
308
+ const int start = block * window, length = std::min<int64_t>(window, steps - start);
309
+ Cover occupied; occupied.fill(~uint64_t{0});
310
+ std::vector<uint64_t> rows(length);
311
+ for (int t = 0; t < length; ++t) {
312
+ rows[t] = row_key(cells + (start + t) * width_);
313
+ for (int d = 0; d < width_; ++d) {
314
+ const int bit = t * width_ + d;
315
+ if ((full & (uint64_t{1} << d)) && !(fixed[start + t] & (uint64_t{1} << d)))
316
+ occupied[bit / 64] &= ~(uint64_t{1} << (bit % 64));
317
+ }
318
+ }
319
+ std::vector<Instance> improved;
320
+ const int bound = blocks[block].size();
321
+ if ((free_count(occupied) + max_cells_ - 1) / max_cells_ < bound) {
322
+ std::vector<std::vector<Placement>> options(length * width_);
323
+ std::vector<char> ready(length * width_, 0);
324
+ auto get_options = [&](int root) -> const std::vector<Placement>& {
325
+ if (!ready[root]) {
326
+ options[root] = candidates(root, rows, occupied, cache);
327
+ ready[root] = 1;
328
+ }
329
+ return options[root];
330
+ };
331
+ improved = beam_cover(get_options, occupied, bound, beam);
332
+ }
333
+ if (improved.empty()) result.insert(result.end(), blocks[block].begin(), blocks[block].end());
334
+ else for (auto [time, root, token] : improved) result.emplace_back(start + time, root, token);
335
+ for (auto& table : cache) if (table.size() > 4096) table.clear();
336
+ }
337
+ std::sort(result.begin(), result.end());
338
+ std::vector<int64_t> tokens;
339
+ for (auto [time, root, token] : result) tokens.push_back(token);
340
+ if (tokens.size() > stream.size()) throw std::runtime_error("local packing regressed");
341
+ return tokens;
342
+ }
343
+
344
+ std::vector<int64_t> optimal(uint64_t row, int mask,
345
+ const std::vector<int64_t>& incumbent) const {
346
+ if (incumbent.size() < 2) return incumbent;
347
+ std::array<int64_t, 256> matched;
348
+ matched.fill(-2);
349
+ auto match = [&](int subset) {
350
+ int64_t& token = matched[subset];
351
+ if (token == -2) {
352
+ const auto found = lookup_.find(row & byte_masks_[subset]);
353
+ token = found == lookup_.end() ? -1 : found->second;
354
+ }
355
+ return token;
356
+ };
357
+ if (int64_t token = match(mask); token >= 0) return {token};
358
+ if (incumbent.size() == 2) return incumbent;
359
+ const int bit = mask & -mask;
360
+ for (int sub = (mask - 1) & mask; sub; sub = (sub - 1) & mask) {
361
+ if (!(sub & bit)) continue;
362
+ const int64_t left = match(sub), right = match(mask ^ sub);
363
+ if (left >= 0 && right >= 0) return {left, right};
364
+ }
365
+ if (incumbent.size() == 3) return incumbent;
366
+ std::array<int, 256> costs, choices;
367
+ costs.fill(99); choices.fill(0); costs[0] = 0;
368
+ std::function<int(int)> solve = [&](int remaining) {
369
+ if (costs[remaining] != 99) return costs[remaining];
370
+ int best = 98;
371
+ const int first = remaining & -remaining;
372
+ for (int sub = remaining; sub; sub = (sub - 1) & remaining) {
373
+ if (!(sub & first) || match(sub) < 0) continue;
374
+ const int cost = 1 + solve(remaining ^ sub);
375
+ if (cost < best) { best = cost; choices[remaining] = sub; }
376
+ }
377
+ return costs[remaining] = best;
378
+ };
379
+ if (solve(mask) >= static_cast<int>(incumbent.size())) return incumbent;
380
+ std::vector<int64_t> result;
381
+ for (int remaining = mask; remaining; remaining ^= choices[remaining])
382
+ result.push_back(match(choices[remaining]));
383
+ return result;
384
+ }
385
+
386
+ std::vector<int64_t> repack(const int64_t* cells, int64_t steps,
387
+ const std::vector<int64_t>& stream) const {
388
+ uint64_t full = 0;
389
+ for (int d = 0; d < width_; ++d) if (cells[d] >= 0) full |= uint64_t{1} << d;
390
+ std::vector<uint64_t> covered(steps, 0), fixed(steps, 0);
391
+ std::vector<std::vector<int64_t>> old_spatial(steps), temporal(steps);
392
+ int64_t front = 0;
393
+ for (int64_t token : stream) {
394
+ while (front < steps && covered[front] == full) ++front;
395
+ if (front == steps || token < 0 || token >= static_cast<int64_t>(patterns_.size()) ||
396
+ anchors_[token] != __builtin_ctzll(full & ~covered[front]))
397
+ throw std::invalid_argument("invalid original token stream");
398
+ const bool frozen = token >= spatial_size_;
399
+ (frozen ? temporal[front] : old_spatial[front]).push_back(token);
400
+ for (auto [offset, id] : patterns_[token]) {
401
+ if (front + offset >= steps || (covered[front + offset] & support_[id]))
402
+ throw std::invalid_argument("invalid original token coverage");
403
+ covered[front + offset] |= support_[id];
404
+ if (frozen) fixed[front + offset] |= support_[id];
405
+ }
406
+ }
407
+ std::vector<int64_t> result;
408
+ result.reserve(stream.size());
409
+ for (int64_t time = 0; time < steps; ++time) {
410
+ if (covered[time] != full) throw std::invalid_argument("incomplete original stream");
411
+ auto tokens = optimal(row_key(cells + time * width_), full & ~fixed[time], old_spatial[time]);
412
+ tokens.insert(tokens.end(), temporal[time].begin(), temporal[time].end());
413
+ std::sort(tokens.begin(), tokens.end(), [&](int64_t a, int64_t b) {
414
+ return std::make_pair(anchors_[a], a) < std::make_pair(anchors_[b], b);
415
+ });
416
+ result.insert(result.end(), tokens.begin(), tokens.end());
417
+ }
418
+ if (result.size() > stream.size()) throw std::runtime_error("packing regressed");
419
+ return result;
420
+ }
421
+
422
+ const std::vector<int64_t>& bins_;
423
+ int width_;
424
+ int64_t spatial_size_;
425
+ const std::vector<uint64_t>& support_;
426
+ const std::vector<std::vector<std::pair<int64_t, int64_t>>>& patterns_;
427
+ std::vector<uint64_t> keys_, byte_masks_;
428
+ std::vector<int> anchors_, spans_, counts_;
429
+ std::vector<Cover> relative_;
430
+ std::vector<std::vector<Requirement>> requirements_;
431
+ std::vector<std::vector<int64_t>> temporal_by_root_;
432
+ std::vector<TrieNode> trie_;
433
+ std::array<std::vector<int32_t>, 8> trie_roots_;
434
+ int max_cells_ = 1;
435
+ std::unordered_map<uint64_t, int64_t> lookup_;
436
+ };
437
+ } // namespace ac2pack
actioncodec2_kernels/replay.cpp ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Parallel replay of a learned Spatial Set-BPE vocabulary over action cells.
2
+
3
+ #include <pybind11/numpy.h>
4
+ #include <pybind11/pybind11.h>
5
+
6
+ #include <algorithm>
7
+ #include <cstdint>
8
+ #include <functional>
9
+ #include <queue>
10
+ #include <tuple>
11
+ #include <vector>
12
+
13
+ #ifdef _OPENMP
14
+ #include <omp.h>
15
+ #endif
16
+
17
+ namespace py = pybind11;
18
+
19
+ namespace {
20
+
21
+ struct Replay {
22
+ int32_t dimension;
23
+ int32_t vocab_size;
24
+ std::vector<int32_t> offsets;
25
+ std::vector<int32_t> rule;
26
+ std::vector<int32_t> anchor;
27
+
28
+ int32_t child(int32_t left, int32_t right) const {
29
+ if (left > right) std::swap(left, right);
30
+ return rule[static_cast<size_t>(left) * vocab_size + right];
31
+ }
32
+
33
+ std::vector<int32_t> encode(const int32_t* code) const {
34
+ std::vector<int32_t> active;
35
+ active.reserve(dimension);
36
+ for (int32_t axis = 0; axis < dimension; ++axis) {
37
+ if (code[axis] < 0) continue;
38
+ active.push_back(offsets[axis] + code[axis]);
39
+ }
40
+ using Candidate = std::tuple<int32_t, int32_t, int32_t>;
41
+ std::priority_queue<Candidate, std::vector<Candidate>, std::greater<Candidate>> heap;
42
+ auto offer = [&](int32_t left, int32_t right) {
43
+ const int32_t value = child(left, right);
44
+ if (value >= 0) heap.emplace(value, std::min(left, right), std::max(left, right));
45
+ };
46
+ for (int32_t a = 0; a < static_cast<int32_t>(active.size()); ++a) {
47
+ for (int32_t b = a + 1; b < static_cast<int32_t>(active.size()); ++b) {
48
+ offer(active[a], active[b]);
49
+ }
50
+ }
51
+ while (!heap.empty()) {
52
+ const auto [child_token, left, right] = heap.top();
53
+ heap.pop();
54
+ auto left_it = std::find(active.begin(), active.end(), left);
55
+ auto right_it = std::find(active.begin(), active.end(), right);
56
+ if (left_it == active.end() || right_it == active.end()) continue;
57
+ *left_it = child_token;
58
+ active.erase(right_it);
59
+ for (const int32_t other : active) {
60
+ if (other != child_token) offer(child_token, other);
61
+ }
62
+ }
63
+ std::sort(active.begin(), active.end(), [&](int32_t left, int32_t right) {
64
+ if (anchor[left] != anchor[right]) return anchor[left] < anchor[right];
65
+ return left < right;
66
+ });
67
+ return active;
68
+ }
69
+ };
70
+
71
+ } // namespace
72
+
73
+ py::tuple spatial_encode_instances(
74
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& codes,
75
+ const py::array_t<int64_t, py::array::c_style | py::array::forcecast>& row_anchors,
76
+ const std::vector<int32_t>& bins,
77
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& merges,
78
+ int threads) {
79
+ if (codes.ndim() != 2 || row_anchors.ndim() != 1 ||
80
+ row_anchors.shape(0) != codes.shape(0) ||
81
+ codes.shape(1) != static_cast<int64_t>(bins.size()) || merges.ndim() != 2 ||
82
+ merges.shape(1) != 2) {
83
+ throw std::invalid_argument("invalid spatial replay inputs");
84
+ }
85
+ Replay replay;
86
+ replay.dimension = static_cast<int32_t>(bins.size());
87
+ replay.offsets.assign(replay.dimension + 1, 0);
88
+ for (int32_t axis = 0; axis < replay.dimension; ++axis) {
89
+ replay.offsets[axis + 1] = replay.offsets[axis] + bins[axis];
90
+ }
91
+ const int32_t atoms = replay.offsets.back();
92
+ replay.vocab_size = atoms + static_cast<int32_t>(merges.shape(0));
93
+ replay.rule.assign(static_cast<size_t>(replay.vocab_size) * replay.vocab_size, -1);
94
+ replay.anchor.assign(replay.vocab_size, -1);
95
+ for (int32_t axis = 0; axis < replay.dimension; ++axis) {
96
+ for (int32_t token = replay.offsets[axis]; token < replay.offsets[axis + 1]; ++token) {
97
+ replay.anchor[token] = axis;
98
+ }
99
+ }
100
+ auto rules = merges.unchecked<2>();
101
+ for (int32_t index = 0; index < merges.shape(0); ++index) {
102
+ const int32_t left = rules(index, 0), right = rules(index, 1), child = atoms + index;
103
+ if (left < 0 || right < 0 || left >= child || right >= child) {
104
+ throw std::invalid_argument("invalid spatial merge rule");
105
+ }
106
+ replay.rule[static_cast<size_t>(std::min(left, right)) * replay.vocab_size +
107
+ std::max(left, right)] = child;
108
+ replay.anchor[child] = std::min(replay.anchor[left], replay.anchor[right]);
109
+ }
110
+
111
+ const int64_t rows = codes.shape(0);
112
+ std::vector<int32_t> counts(rows);
113
+ const int32_t* source = codes.data();
114
+ for (int64_t row = 0; row < rows; ++row) {
115
+ int32_t active = 0;
116
+ for (int32_t axis = 0; axis < replay.dimension; ++axis) {
117
+ const int32_t value = source[row * replay.dimension + axis];
118
+ if (value < -1 || value >= bins[axis]) {
119
+ throw std::invalid_argument("a cell index is outside its coordinate's bin count");
120
+ }
121
+ active += value >= 0;
122
+ }
123
+ if (active == 0) throw std::invalid_argument("every row needs an active coordinate");
124
+ }
125
+ #ifndef _OPENMP
126
+ (void)threads;
127
+ #endif
128
+ {
129
+ py::gil_scoped_release release;
130
+ #ifdef _OPENMP
131
+ #pragma omp parallel for schedule(static) num_threads(threads > 0 ? threads : omp_get_max_threads())
132
+ #endif
133
+ for (int64_t row = 0; row < rows; ++row) {
134
+ counts[row] = static_cast<int32_t>(
135
+ replay.encode(source + row * replay.dimension).size());
136
+ }
137
+ }
138
+ std::vector<int64_t> offsets(rows + 1, 0);
139
+ for (int64_t row = 0; row < rows; ++row) offsets[row + 1] = offsets[row] + counts[row];
140
+ py::array_t<int32_t> tokens(offsets.back());
141
+ py::array_t<int64_t> anchors(offsets.back());
142
+ int32_t* output_tokens = tokens.mutable_data();
143
+ int64_t* output_anchors = anchors.mutable_data();
144
+ const int64_t* input_anchors = row_anchors.data();
145
+ {
146
+ py::gil_scoped_release release;
147
+ #ifdef _OPENMP
148
+ #pragma omp parallel for schedule(static) num_threads(threads > 0 ? threads : omp_get_max_threads())
149
+ #endif
150
+ for (int64_t row = 0; row < rows; ++row) {
151
+ const auto encoded = replay.encode(source + row * replay.dimension);
152
+ const int64_t begin = offsets[row];
153
+ for (int32_t index = 0; index < static_cast<int32_t>(encoded.size()); ++index) {
154
+ output_tokens[begin + index] = encoded[index];
155
+ output_anchors[begin + index] = input_anchors[row];
156
+ }
157
+ }
158
+ }
159
+ return py::make_tuple(tokens, anchors);
160
+ }
actioncodec2_kernels/resample.cpp CHANGED
@@ -1,3 +1,4 @@
 
1
  #include <pybind11/numpy.h>
2
  #include <pybind11/pybind11.h>
3
 
@@ -21,6 +22,8 @@ struct Quat {
21
  double x, y, z, w;
22
  };
23
 
 
 
24
  Quat so3_normalize(const Quat& q) {
25
  const double norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
26
  if (norm < 1e-12) throw std::invalid_argument("a pose contains a zero quaternion");
@@ -53,6 +56,8 @@ void so3_to_rotvec(const Quat& raw, double* out) {
53
  out[2] = q.z * scale;
54
  }
55
 
 
 
56
  Quat sp_from_quat(const Quat& q) {
57
  const double norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
58
  if (!(norm > 0.0)) throw std::invalid_argument("found zero norm quaternions in `quat`");
@@ -97,6 +102,9 @@ Quat sp_from_rotvec(const double* v) {
97
  return {scale * v[0], scale * v[1], scale * v[2], std::cos(angle / 2.0)};
98
  }
99
 
 
 
 
100
  int64_t ppoly_interval(const Vec& x, double t) {
101
  const int64_t n = static_cast<int64_t>(x.size());
102
  if (t >= x[n - 1]) return n - 2;
@@ -104,6 +112,8 @@ int64_t ppoly_interval(const Vec& x, double t) {
104
  return static_cast<int64_t>(std::upper_bound(x.begin(), x.end(), t) - x.begin()) - 1;
105
  }
106
 
 
 
107
  double ppoly_value(const double* c, double s) {
108
  double result = 0.0, z = 1.0;
109
  result = result + c[3] * z;
@@ -116,6 +126,8 @@ double ppoly_value(const double* c, double s) {
116
  return result;
117
  }
118
 
 
 
119
  Vec hermite_coefficients(const Vec& x, const Vec& y, const Vec& slopes, int64_t cols) {
120
  const int64_t n = static_cast<int64_t>(x.size());
121
  Vec c(static_cast<size_t>((n - 1) * 4 * cols));
@@ -148,6 +160,7 @@ void ppoly_evaluate(const Vec& x, const Vec& c, int64_t cols, const Vec& times,
148
  }
149
  }
150
 
 
151
  void gtsv(Vec dl, Vec d, Vec du, Vec& b, int64_t cols) {
152
  const int64_t n = static_cast<int64_t>(d.size());
153
  auto pivot = [&](int64_t i, bool last) {
@@ -188,6 +201,7 @@ void gtsv(Vec dl, Vec d, Vec du, Vec& b, int64_t cols) {
188
  }
189
  }
190
 
 
191
  Vec natural_cubic_slopes(const Vec& x, const Vec& y, int64_t cols) {
192
  const int64_t n = static_cast<int64_t>(x.size());
193
  Vec dx(n - 1), slope((n - 1) * cols);
@@ -195,7 +209,7 @@ Vec natural_cubic_slopes(const Vec& x, const Vec& y, int64_t cols) {
195
  dx[k] = x[k + 1] - x[k];
196
  for (int64_t a = 0; a < cols; ++a) slope[k * cols + a] = (y[(k + 1) * cols + a] - y[k * cols + a]) / dx[k];
197
  }
198
-
199
  Vec upper(n, 0.0), diagonal(n, 0.0), lower(n, 0.0), b(n * cols);
200
  for (int64_t k = 1; k < n - 1; ++k) diagonal[k] = 2.0 * (dx[k - 1] + dx[k]);
201
  for (int64_t k = 2; k < n; ++k) upper[k] = dx[k - 2];
@@ -204,7 +218,7 @@ Vec natural_cubic_slopes(const Vec& x, const Vec& y, int64_t cols) {
204
  for (int64_t a = 0; a < cols; ++a)
205
  b[k * cols + a] = 3.0 * (dx[k] * slope[(k - 1) * cols + a] + dx[k - 1] * slope[k * cols + a]);
206
  }
207
-
208
  diagonal[0] = 2.0 * dx[0];
209
  upper[1] = dx[0];
210
  diagonal[n - 1] = 2.0 * dx[n - 2];
@@ -230,6 +244,7 @@ double pchip_edge(double h0, double h1, double m0, double m1) {
230
  return d;
231
  }
232
 
 
233
  Vec pchip_slopes(const Vec& x, const Vec& y, int64_t cols) {
234
  const int64_t n = static_cast<int64_t>(x.size());
235
  Vec h(n - 1), m((n - 1) * cols), dk(n * cols, 0.0);
@@ -262,6 +277,8 @@ Vec pchip_slopes(const Vec& x, const Vec& y, int64_t cols) {
262
  return dk;
263
  }
264
 
 
 
265
  using Mat3 = std::array<double, 9>;
266
 
267
  Mat3 skew(const double* v) { return {0.0, -v[2], v[1], v[2], 0.0, -v[0], -v[1], v[0], 0.0}; }
@@ -363,7 +380,7 @@ void gbsv(int64_t n, int64_t kl, int64_t ku, Vec& ab, Vec& b) {
363
  }
364
  }
365
  }
366
-
367
  for (int64_t j = 0; j + 1 < n; ++j) {
368
  const int64_t lm = std::min(kl, n - 1 - j);
369
  if (pivots[j] != j) std::swap(b[pivots[j]], b[j]);
@@ -372,7 +389,7 @@ void gbsv(int64_t n, int64_t kl, int64_t ku, Vec& ab, Vec& b) {
372
  for (int64_t i = 1; i <= lm; ++i) b[j + i] = b[j + i] + at(kv + i, j) * temp;
373
  }
374
  }
375
-
376
  const int64_t k = kl + ku;
377
  for (int64_t j = n - 1; j >= 0; --j) {
378
  if (b[j] != 0.0) {
@@ -386,7 +403,7 @@ void gbsv(int64_t n, int64_t kl, int64_t ku, Vec& ab, Vec& b) {
386
  struct RotationSpline {
387
  Vec times;
388
  std::vector<Quat> rotations;
389
- Vec coefficients;
390
 
391
  RotationSpline(const Vec& knots, const std::vector<Quat>& raw) : times(knots) {
392
  const int64_t n = static_cast<int64_t>(knots.size());
@@ -416,8 +433,8 @@ struct RotationSpline {
416
  }
417
  }
418
 
419
-
420
-
421
  static void solve_rates(const Vec& dt, Vec& rates, const Vec& rotvecs, Vec& rotvecs_dot) {
422
  const int64_t m = static_cast<int64_t>(dt.size());
423
  const double first[3] = {rates[0], rates[1], rates[2]};
@@ -426,7 +443,7 @@ struct RotationSpline {
426
  a[k] = rate_to_rotvec_dot(&rotvecs[k * 3]);
427
  a_inv[k] = rotvec_dot_to_rate(&rotvecs[k * 3]);
428
  }
429
-
430
  const int64_t size = 3 * (m - 1), kl = 5, ku = 5, ldab = 2 * kl + ku + 1;
431
  Vec band(ldab * size, 0.0);
432
  auto set = [&](int64_t i, int64_t j, double value) { band[(kl + ku + i - j) + ldab * j] = value; };
@@ -474,7 +491,7 @@ struct RotationSpline {
474
  if (converged) break;
475
  }
476
  for (int64_t k = 0; k < m; ++k) matvec(a[k], &rates[k * 3], &rotvecs_dot[k * 3]);
477
-
478
  Vec shifted(m * 3);
479
  for (int r = 0; r < 3; ++r) shifted[r] = first[r];
480
  for (int64_t i = 0; i < 3 * (m - 1); ++i) shifted[3 + i] = rates[i];
@@ -516,6 +533,8 @@ Quat slerp(const Quat& left, const Quat& right, double weight) {
516
  return so3_normalize(out);
517
  }
518
 
 
 
519
  enum Kind { kLinear = 0, kHold = 1, kPose = 2 };
520
  enum Method { kLinearMethod = 0, kPchip = 1, kCubic = 2 };
521
 
@@ -525,7 +544,8 @@ int64_t resampled_horizon(int64_t length, double source_hz, double target_hz) {
525
 
526
  int64_t output_width(int kind, int64_t width) { return kind == kPose ? 7 : width; }
527
 
528
- void resample_row(const float* input, int64_t steps, int64_t width, double source_hz, double target_hz,
 
529
  int kind, int64_t count, bool identity, int method, float* output) {
530
  Vec values(steps * width);
531
  for (int64_t i = 0; i < steps * width; ++i) values[i] = input[i];
@@ -565,7 +585,7 @@ void resample_row(const float* input, int64_t steps, int64_t width, double sourc
565
  } else if (method == kCubic) {
566
  const Vec c = hermite_coefficients(source_time, continuous, natural_cubic_slopes(source_time, continuous, cols), cols);
567
  ppoly_evaluate(source_time, c, cols, interpolation_time, out_width, 0, result.data());
568
-
569
  std::vector<int64_t> fallback;
570
  for (int64_t a = 0; a < cols; ++a) {
571
  double lower = continuous[a], upper = continuous[a];
@@ -635,14 +655,15 @@ int parse_method(const std::string& method) {
635
  }
636
 
637
  using F32 = py::array_t<float, py::array::c_style | py::array::forcecast>;
 
638
 
639
- void check_finite(const F32& values) {
640
- const float* data = values.data();
641
  for (py::ssize_t i = 0; i < values.size(); ++i)
642
  if (!std::isfinite(data[i])) throw std::invalid_argument("values must be a non-empty finite [T, D] array");
643
  }
644
 
645
- F32 resample_uniform_batch(const F32& values, double source_hz, double target_hz, const std::string& kind_name,
646
  int64_t target_length, const std::string& method_name, int threads) {
647
  if (values.ndim() != 3 || values.shape(0) < 1 || values.shape(1) < 1)
648
  throw std::invalid_argument("values must have non-empty shape [B, T, D]");
@@ -658,7 +679,7 @@ F32 resample_uniform_batch(const F32& values, double source_hz, double target_hz
658
  if (count < 1) throw std::invalid_argument("target_length must be a positive integer");
659
  const int64_t out_width = output_width(kind, width);
660
  F32 output({batch, count, out_width});
661
- const float* in = values.data();
662
  float* out = output.mutable_data();
663
  {
664
  py::gil_scoped_release release;
@@ -670,14 +691,15 @@ F32 resample_uniform_batch(const F32& values, double source_hz, double target_hz
670
  return output;
671
  }
672
 
 
673
  template <class Row>
674
- F32 map_rows(const F32& values, int64_t in_width, int64_t out_width, int threads, Row&& row) {
675
  if (values.ndim() != 3 || values.shape(2) != in_width || values.shape(1) < 1)
676
  throw std::invalid_argument("expected shape [B, T, " + std::to_string(in_width) + "]");
677
  check_finite(values);
678
  const int64_t batch = values.shape(0), steps = values.shape(1);
679
  F32 output({batch, steps, out_width});
680
- const float* in = values.data();
681
  float* out = output.mutable_data();
682
  {
683
  py::gil_scoped_release release;
@@ -688,8 +710,9 @@ F32 map_rows(const F32& values, int64_t in_width, int64_t out_width, int threads
688
  return output;
689
  }
690
 
691
- F32 integrate_eef_deltas_batch(const F32& deltas, int threads) {
692
- return map_rows(deltas, 6, 7, threads, [](const float* in, int64_t steps, float* out) {
 
693
  double position[3] = {0.0, 0.0, 0.0};
694
  Quat current{0.0, 0.0, 0.0, 1.0};
695
  for (int64_t t = 0; t < steps; ++t) {
@@ -707,8 +730,9 @@ F32 integrate_eef_deltas_batch(const F32& deltas, int threads) {
707
  });
708
  }
709
 
710
- F32 eef_pose_from_rotvec_batch(const F32& poses, int threads) {
711
- return map_rows(poses, 6, 7, threads, [](const float* in, int64_t steps, float* out) {
 
712
  for (int64_t t = 0; t < steps; ++t) {
713
  for (int a = 0; a < 3; ++a) out[t * 7 + a] = in[t * 6 + a];
714
  const Quat q = so3_from_rotvec(in[t * 6 + 3], in[t * 6 + 4], in[t * 6 + 5]);
@@ -720,8 +744,9 @@ F32 eef_pose_from_rotvec_batch(const F32& poses, int threads) {
720
  });
721
  }
722
 
723
- F32 eef_pose_absolute_batch(const F32& poses, int threads) {
724
- return map_rows(poses, 7, 6, threads, [](const float* in, int64_t steps, float* out) {
 
725
  for (int64_t t = 0; t < steps; ++t) {
726
  for (int a = 0; a < 3; ++a) out[t * 6 + a] = in[t * 7 + a];
727
  double rotvec[3];
@@ -731,8 +756,9 @@ F32 eef_pose_absolute_batch(const F32& poses, int threads) {
731
  });
732
  }
733
 
734
- F32 eef_pose_deltas_batch(const F32& poses, int threads) {
735
- return map_rows(poses, 7, 6, threads, [](const float* in, int64_t steps, float* out) {
 
736
  Quat previous{0.0, 0.0, 0.0, 1.0};
737
  for (int64_t t = 0; t < steps; ++t) {
738
  for (int a = 0; a < 3; ++a) {
@@ -748,8 +774,10 @@ F32 eef_pose_deltas_batch(const F32& poses, int threads) {
748
  });
749
  }
750
 
751
- F32 rotvec_left_to_right_batch(const F32& increments, int threads) {
752
- return map_rows(increments, 3, 3, threads, [](const float* in, int64_t steps, float* out) {
 
 
753
  Quat current{0.0, 0.0, 0.0, 1.0};
754
  for (int64_t t = 0; t < steps; ++t) {
755
  const double v[3] = {in[t * 3], in[t * 3 + 1], in[t * 3 + 2]};
@@ -762,8 +790,8 @@ F32 rotvec_left_to_right_batch(const F32& increments, int threads) {
762
  });
763
  }
764
 
765
- F32 rotvec_right_to_left_batch(const F32& increments, int threads) {
766
- return map_rows(increments, 3, 3, threads, [](const float* in, int64_t steps, float* out) {
767
  Quat current{0.0, 0.0, 0.0, 1.0};
768
  for (int64_t t = 0; t < steps; ++t) {
769
  const double v[3] = {in[t * 3], in[t * 3 + 1], in[t * 3 + 2]};
@@ -776,7 +804,7 @@ F32 rotvec_right_to_left_batch(const F32& increments, int threads) {
776
  });
777
  }
778
 
779
- }
780
 
781
  void register_resample(py::module_& module) {
782
  module.def("resample_uniform_batch", &resample_uniform_batch, py::arg("values"), py::arg("source_hz"),
 
1
+
2
  #include <pybind11/numpy.h>
3
  #include <pybind11/pybind11.h>
4
 
 
22
  double x, y, z, w;
23
  };
24
 
25
+ // ---- runtime ``_so3`` quaternion math (xyzw), NumPy evaluation order ----
26
+
27
  Quat so3_normalize(const Quat& q) {
28
  const double norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
29
  if (norm < 1e-12) throw std::invalid_argument("a pose contains a zero quaternion");
 
56
  out[2] = q.z * scale;
57
  }
58
 
59
+ // ---- scipy ``Rotation`` operations used by RotationSpline ----
60
+
61
  Quat sp_from_quat(const Quat& q) {
62
  const double norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
63
  if (!(norm > 0.0)) throw std::invalid_argument("found zero norm quaternions in `quat`");
 
102
  return {scale * v[0], scale * v[1], scale * v[2], std::cos(angle / 2.0)};
103
  }
104
 
105
+ // ---- piecewise cubics (scipy PPoly with CubicHermiteSpline coefficients) ----
106
+
107
+ // PPoly interval: x[i] <= t < x[i+1], extrapolating with the end intervals.
108
  int64_t ppoly_interval(const Vec& x, double t) {
109
  const int64_t n = static_cast<int64_t>(x.size());
110
  if (t >= x[n - 1]) return n - 2;
 
112
  return static_cast<int64_t>(std::upper_bound(x.begin(), x.end(), t) - x.begin()) - 1;
113
  }
114
 
115
+ // scipy ``evaluate_poly1`` for a cubic: sum of c[k-1-p] * s**p, powers built by
116
+ // repeated multiplication.
117
  double ppoly_value(const double* c, double s) {
118
  double result = 0.0, z = 1.0;
119
  result = result + c[3] * z;
 
126
  return result;
127
  }
128
 
129
+ // Hermite coefficients per interval and column: c[(k*4 + p)*cols + a] with
130
+ // p = 0..3 the descending powers, as CubicHermiteSpline stacks them.
131
  Vec hermite_coefficients(const Vec& x, const Vec& y, const Vec& slopes, int64_t cols) {
132
  const int64_t n = static_cast<int64_t>(x.size());
133
  Vec c(static_cast<size_t>((n - 1) * 4 * cols));
 
160
  }
161
  }
162
 
163
+ // LAPACK dgtsv (reference algorithm) for several right-hand sides b[n][cols].
164
  void gtsv(Vec dl, Vec d, Vec du, Vec& b, int64_t cols) {
165
  const int64_t n = static_cast<int64_t>(d.size());
166
  auto pivot = [&](int64_t i, bool last) {
 
201
  }
202
  }
203
 
204
+ // scipy CubicSpline(bc_type="natural") slopes at the knots.
205
  Vec natural_cubic_slopes(const Vec& x, const Vec& y, int64_t cols) {
206
  const int64_t n = static_cast<int64_t>(x.size());
207
  Vec dx(n - 1), slope((n - 1) * cols);
 
209
  dx[k] = x[k + 1] - x[k];
210
  for (int64_t a = 0; a < cols; ++a) slope[k * cols + a] = (y[(k + 1) * cols + a] - y[k * cols + a]) / dx[k];
211
  }
212
+ // Banded A (3 x n): A[0] superdiagonal, A[1] diagonal, A[2] subdiagonal.
213
  Vec upper(n, 0.0), diagonal(n, 0.0), lower(n, 0.0), b(n * cols);
214
  for (int64_t k = 1; k < n - 1; ++k) diagonal[k] = 2.0 * (dx[k - 1] + dx[k]);
215
  for (int64_t k = 2; k < n; ++k) upper[k] = dx[k - 2];
 
218
  for (int64_t a = 0; a < cols; ++a)
219
  b[k * cols + a] = 3.0 * (dx[k] * slope[(k - 1) * cols + a] + dx[k - 1] * slope[k * cols + a]);
220
  }
221
+ // Natural ends: second derivative zero (bc value 0.0).
222
  diagonal[0] = 2.0 * dx[0];
223
  upper[1] = dx[0];
224
  diagonal[n - 1] = 2.0 * dx[n - 2];
 
244
  return d;
245
  }
246
 
247
+ // scipy PchipInterpolator derivatives at the knots.
248
  Vec pchip_slopes(const Vec& x, const Vec& y, int64_t cols) {
249
  const int64_t n = static_cast<int64_t>(x.size());
250
  Vec h(n - 1), m((n - 1) * cols), dk(n * cols, 0.0);
 
277
  return dk;
278
  }
279
 
280
+ // ---- scipy RotationSpline ----
281
+
282
  using Mat3 = std::array<double, 9>;
283
 
284
  Mat3 skew(const double* v) { return {0.0, -v[2], v[1], v[2], 0.0, -v[0], -v[1], v[0], 0.0}; }
 
380
  }
381
  }
382
  }
383
+ // Forward: L, with the row interchanges.
384
  for (int64_t j = 0; j + 1 < n; ++j) {
385
  const int64_t lm = std::min(kl, n - 1 - j);
386
  if (pivots[j] != j) std::swap(b[pivots[j]], b[j]);
 
389
  for (int64_t i = 1; i <= lm; ++i) b[j + i] = b[j + i] + at(kv + i, j) * temp;
390
  }
391
  }
392
+ // Backward: U, a band of width kl+ku above the diagonal (dtbsv).
393
  const int64_t k = kl + ku;
394
  for (int64_t j = n - 1; j >= 0; --j) {
395
  if (b[j] != 0.0) {
 
403
  struct RotationSpline {
404
  Vec times;
405
  std::vector<Quat> rotations;
406
+ Vec coefficients; // ppoly layout, 3 columns
407
 
408
  RotationSpline(const Vec& knots, const std::vector<Quat>& raw) : times(knots) {
409
  const int64_t n = static_cast<int64_t>(knots.size());
 
433
  }
434
  }
435
 
436
+ // scipy RotationSpline._solve_for_angular_rates; ``rates`` comes back as the
437
+ // knot angular rates, ``rotvecs_dot`` as the segment rotation-vector rates.
438
  static void solve_rates(const Vec& dt, Vec& rates, const Vec& rotvecs, Vec& rotvecs_dot) {
439
  const int64_t m = static_cast<int64_t>(dt.size());
440
  const double first[3] = {rates[0], rates[1], rates[2]};
 
443
  a[k] = rate_to_rotvec_dot(&rotvecs[k * 3]);
444
  a_inv[k] = rotvec_dot_to_rate(&rotvecs[k * 3]);
445
  }
446
+ // Block-tridiagonal system of m-1 unknown 3-vectors in (5, 5) band form.
447
  const int64_t size = 3 * (m - 1), kl = 5, ku = 5, ldab = 2 * kl + ku + 1;
448
  Vec band(ldab * size, 0.0);
449
  auto set = [&](int64_t i, int64_t j, double value) { band[(kl + ku + i - j) + ldab * j] = value; };
 
491
  if (converged) break;
492
  }
493
  for (int64_t k = 0; k < m; ++k) matvec(a[k], &rates[k * 3], &rotvecs_dot[k * 3]);
494
+ // Knot rates: the fixed first rate, then the solved interior ones.
495
  Vec shifted(m * 3);
496
  for (int r = 0; r < 3; ++r) shifted[r] = first[r];
497
  for (int64_t i = 0; i < 3 * (m - 1); ++i) shifted[3 + i] = rates[i];
 
533
  return so3_normalize(out);
534
  }
535
 
536
+ // ---- runtime resample_uniform ----
537
+
538
  enum Kind { kLinear = 0, kHold = 1, kPose = 2 };
539
  enum Method { kLinearMethod = 0, kPchip = 1, kCubic = 2 };
540
 
 
544
 
545
  int64_t output_width(int kind, int64_t width) { return kind == kPose ? 7 : width; }
546
 
547
+ // One row: float32 [T, D] in, float32 [count, D'] out.
548
+ void resample_row(const double* input, int64_t steps, int64_t width, double source_hz, double target_hz,
549
  int kind, int64_t count, bool identity, int method, float* output) {
550
  Vec values(steps * width);
551
  for (int64_t i = 0; i < steps * width; ++i) values[i] = input[i];
 
585
  } else if (method == kCubic) {
586
  const Vec c = hermite_coefficients(source_time, continuous, natural_cubic_slopes(source_time, continuous, cols), cols);
587
  ppoly_evaluate(source_time, c, cols, interpolation_time, out_width, 0, result.data());
588
+ // Guard: fall back to PCHIP on axes that materially leave their envelope.
589
  std::vector<int64_t> fallback;
590
  for (int64_t a = 0; a < cols; ++a) {
591
  double lower = continuous[a], upper = continuous[a];
 
655
  }
656
 
657
  using F32 = py::array_t<float, py::array::c_style | py::array::forcecast>;
658
+ using F64 = py::array_t<double, py::array::c_style | py::array::forcecast>;
659
 
660
+ void check_finite(const F64& values) {
661
+ const double* data = values.data();
662
  for (py::ssize_t i = 0; i < values.size(); ++i)
663
  if (!std::isfinite(data[i])) throw std::invalid_argument("values must be a non-empty finite [T, D] array");
664
  }
665
 
666
+ F32 resample_uniform_batch(const F64& values, double source_hz, double target_hz, const std::string& kind_name,
667
  int64_t target_length, const std::string& method_name, int threads) {
668
  if (values.ndim() != 3 || values.shape(0) < 1 || values.shape(1) < 1)
669
  throw std::invalid_argument("values must have non-empty shape [B, T, D]");
 
679
  if (count < 1) throw std::invalid_argument("target_length must be a positive integer");
680
  const int64_t out_width = output_width(kind, width);
681
  F32 output({batch, count, out_width});
682
+ const double* in = values.data();
683
  float* out = output.mutable_data();
684
  {
685
  py::gil_scoped_release release;
 
691
  return output;
692
  }
693
 
694
+ // Elementwise pose maps over [B, T, D] float32 arrays.
695
  template <class Row>
696
+ F32 map_rows(const F64& values, int64_t in_width, int64_t out_width, int threads, Row&& row) {
697
  if (values.ndim() != 3 || values.shape(2) != in_width || values.shape(1) < 1)
698
  throw std::invalid_argument("expected shape [B, T, " + std::to_string(in_width) + "]");
699
  check_finite(values);
700
  const int64_t batch = values.shape(0), steps = values.shape(1);
701
  F32 output({batch, steps, out_width});
702
+ const double* in = values.data();
703
  float* out = output.mutable_data();
704
  {
705
  py::gil_scoped_release release;
 
710
  return output;
711
  }
712
 
713
+ // runtime integrate_eef_deltas: [T, 6] body increments -> [T, 7] absolute poses.
714
+ F32 integrate_eef_deltas_batch(const F64& deltas, int threads) {
715
+ return map_rows(deltas, 6, 7, threads, [](const double* in, int64_t steps, float* out) {
716
  double position[3] = {0.0, 0.0, 0.0};
717
  Quat current{0.0, 0.0, 0.0, 1.0};
718
  for (int64_t t = 0; t < steps; ++t) {
 
730
  });
731
  }
732
 
733
+ // runtime eef_pose_from_components(..., "rotation_vector").
734
+ F32 eef_pose_from_rotvec_batch(const F64& poses, int threads) {
735
+ return map_rows(poses, 6, 7, threads, [](const double* in, int64_t steps, float* out) {
736
  for (int64_t t = 0; t < steps; ++t) {
737
  for (int a = 0; a < 3; ++a) out[t * 7 + a] = in[t * 6 + a];
738
  const Quat q = so3_from_rotvec(in[t * 6 + 3], in[t * 6 + 4], in[t * 6 + 5]);
 
744
  });
745
  }
746
 
747
+ // runtime eef_pose_absolute: [T, 7] xyz + xyzw -> [T, 6] xyz + rotation vector.
748
+ F32 eef_pose_absolute_batch(const F64& poses, int threads) {
749
+ return map_rows(poses, 7, 6, threads, [](const double* in, int64_t steps, float* out) {
750
  for (int64_t t = 0; t < steps; ++t) {
751
  for (int a = 0; a < 3; ++a) out[t * 6 + a] = in[t * 7 + a];
752
  double rotvec[3];
 
756
  });
757
  }
758
 
759
+ // runtime eef_pose_deltas: local [T, 7] path -> [T, 6] body increments.
760
+ F32 eef_pose_deltas_batch(const F64& poses, int threads) {
761
+ return map_rows(poses, 7, 6, threads, [](const double* in, int64_t steps, float* out) {
762
  Quat previous{0.0, 0.0, 0.0, 1.0};
763
  for (int64_t t = 0; t < steps; ++t) {
764
  for (int a = 0; a < 3; ++a) {
 
774
  });
775
  }
776
 
777
+ // Re-express per-step rotation increments between world-frame (left) and
778
+ // body-frame (right) multiplication, as scipy ``Rotation`` composes them.
779
+ F32 rotvec_left_to_right_batch(const F64& increments, int threads) {
780
+ return map_rows(increments, 3, 3, threads, [](const double* in, int64_t steps, float* out) {
781
  Quat current{0.0, 0.0, 0.0, 1.0};
782
  for (int64_t t = 0; t < steps; ++t) {
783
  const double v[3] = {in[t * 3], in[t * 3 + 1], in[t * 3 + 2]};
 
790
  });
791
  }
792
 
793
+ F32 rotvec_right_to_left_batch(const F64& increments, int threads) {
794
+ return map_rows(increments, 3, 3, threads, [](const double* in, int64_t steps, float* out) {
795
  Quat current{0.0, 0.0, 0.0, 1.0};
796
  for (int64_t t = 0; t < steps; ++t) {
797
  const double v[3] = {in[t * 3], in[t * 3 + 1], in[t * 3 + 2]};
 
804
  });
805
  }
806
 
807
+ } // namespace
808
 
809
  void register_resample(py::module_& module) {
810
  module.def("resample_uniform_batch", &resample_uniform_batch, py::arg("values"), py::arg("source_hz"),
actioncodec2_kernels/setbpe.cpp CHANGED
@@ -1,3 +1,10 @@
 
 
 
 
 
 
 
1
  #include <pybind11/numpy.h>
2
  #include <pybind11/pybind11.h>
3
  #include <pybind11/stl.h>
@@ -5,6 +12,8 @@
5
  #include <algorithm>
6
  #include <cstdint>
7
  #include <functional>
 
 
8
  #include <queue>
9
  #include <stdexcept>
10
  #include <string>
@@ -14,6 +23,8 @@
14
  #include <vector>
15
 
16
  #include "parallel.hpp"
 
 
17
 
18
  namespace py = pybind11;
19
 
@@ -74,7 +85,7 @@ class SetBPE {
74
  for (int64_t token = 0; token < spatial_size_; ++token)
75
  spatial_anchor_[token] = lowest_bit(support_[token]);
76
 
77
-
78
  vocab_size_ = spatial_size_ + static_cast<int64_t>(temporal_merges.size());
79
  patterns_.resize(vocab_size_);
80
  root_.resize(vocab_size_);
@@ -113,9 +124,16 @@ class SetBPE {
113
  }
114
 
115
  int64_t vocab_size() const { return vocab_size_; }
116
- int64_t dimension() const { return static_cast<int64_t>(bins_.size()); }
117
 
 
 
 
 
 
 
118
 
 
119
  std::vector<int64_t> encode(const int64_t* cells, int64_t steps) const {
120
  const int64_t dimension = static_cast<int64_t>(bins_.size());
121
  if (steps < 1) throw std::invalid_argument("every episode must have at least one step");
@@ -134,10 +152,10 @@ class SetBPE {
134
  throw std::invalid_argument("active dimensions must remain constant within an episode");
135
  }
136
 
137
-
138
  std::vector<int64_t> tokens, anchors;
139
  std::vector<char> active;
140
- std::vector<std::vector<std::pair<int64_t, int64_t>>> at(steps);
141
  for (int64_t t = 0; t < steps; ++t) {
142
  for (int64_t token : spatial_encode(cells + t * dimension)) {
143
  at[t].emplace_back(token, static_cast<int64_t>(tokens.size()));
@@ -147,7 +165,7 @@ class SetBPE {
147
  }
148
  }
149
 
150
-
151
  using Offer = std::tuple<int64_t, int64_t, int64_t, int64_t, int64_t, int64_t>;
152
  std::priority_queue<Offer, std::vector<Offer>, std::greater<Offer>> frontier;
153
  auto offer = [&](int64_t left_instance, int64_t right_instance) {
@@ -218,7 +236,7 @@ class SetBPE {
218
  return stream;
219
  }
220
 
221
-
222
  std::vector<int64_t> decode(const int64_t* stream, int64_t length, uint64_t full) const {
223
  const int64_t dimension = static_cast<int64_t>(bins_.size());
224
  std::vector<uint64_t> coverage{0};
@@ -264,7 +282,7 @@ class SetBPE {
264
  }
265
 
266
  private:
267
-
268
  std::vector<int64_t> spatial_encode(const int64_t* row) const {
269
  const int64_t dimension = static_cast<int64_t>(bins_.size());
270
  std::vector<int64_t> present;
@@ -272,7 +290,7 @@ class SetBPE {
272
  if (row[d] >= 0) present.push_back(offsets_[d] + row[d]);
273
  }
274
  std::sort(present.begin(), present.end());
275
- using Candidate = std::tuple<int64_t, int64_t, int64_t, int64_t>;
276
  std::priority_queue<Candidate, std::vector<Candidate>, std::greater<Candidate>> heap;
277
  for (size_t i = 0; i < present.size(); ++i) {
278
  for (size_t j = i + 1; j < present.size(); ++j) {
@@ -281,7 +299,7 @@ class SetBPE {
281
  heap.emplace(found->second.rank, found->second.child, present[i], present[j]);
282
  }
283
  }
284
- std::vector<int64_t> tokens = present;
285
  auto contains = [&](int64_t token) {
286
  return std::find(tokens.begin(), tokens.end(), token) != tokens.end();
287
  };
@@ -325,13 +343,40 @@ class SetBPE {
325
  std::vector<std::vector<std::pair<int64_t, int64_t>>> patterns_;
326
  std::vector<int64_t> root_;
327
  std::unordered_map<std::tuple<int64_t, int64_t, int64_t>, Rule, TupleHash> temporal_rules_;
 
 
328
  };
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  py::list encode_batch(const SetBPE& codec,
331
  const py::array_t<int64_t, py::array::c_style | py::array::forcecast>& cells,
332
  int threads) {
333
- if (cells.ndim() != 3 || cells.shape(1) < 1 || cells.shape(2) != codec.dimension())
334
- throw std::invalid_argument("cells must have shape [B, T, D] with the codec's dimension");
335
  const int64_t batch = cells.shape(0), steps = cells.shape(1), width = cells.shape(2);
336
  const int64_t* data = cells.data();
337
  std::vector<std::vector<int64_t>> streams(batch);
@@ -346,15 +391,11 @@ py::list encode_batch(const SetBPE& codec,
346
  return output;
347
  }
348
 
 
349
  py::array_t<int64_t> decode_batch(const SetBPE& codec, const std::vector<std::vector<int64_t>>& streams,
350
  int64_t width, uint64_t full, int threads) {
351
  const int64_t batch = static_cast<int64_t>(streams.size());
352
  if (batch < 1) throw std::invalid_argument("streams must not be empty");
353
- if (width != codec.dimension())
354
- throw std::invalid_argument("width must equal the codec's dimension");
355
- const uint64_t all = (uint64_t{1} << codec.dimension()) - 1;
356
- if (!full || (full & ~all))
357
- throw std::invalid_argument("full_support must select existing dimensions");
358
  std::vector<std::vector<int64_t>> cells(batch);
359
  {
360
  py::gil_scoped_release release;
@@ -375,7 +416,7 @@ py::array_t<int64_t> decode_batch(const SetBPE& codec, const std::vector<std::ve
375
  return output;
376
  }
377
 
378
- }
379
 
380
  void register_setbpe(py::module_& module) {
381
  py::class_<SetBPE>(module, "SetBPE")
@@ -386,38 +427,14 @@ void register_setbpe(py::module_& module) {
386
  .def_property_readonly("vocab_size", &SetBPE::vocab_size)
387
  .def("encode_batch", &encode_batch, py::arg("cells"), py::arg("threads") = 0,
388
  "Dense [B, T, D] cells (-1 = absent) -> one token stream per row.")
 
 
 
389
  .def("decode_batch", &decode_batch, py::arg("streams"), py::arg("width"),
390
  py::arg("full_support"), py::arg("threads") = 0,
391
  "Equal-horizon streams -> dense [B, T, D] cells, -1 outside full_support.")
392
  .def("decode_checked_batch", [](const SetBPE& codec, const py::iterable& streams,
393
- int64_t width, uint64_t full, int threads) {
394
- const py::object numpy_integer = py::module_::import("numpy").attr("integer");
395
- py::object integral;
396
- std::vector<std::vector<int64_t>> rows;
397
- if (PyList_Check(streams.ptr())) rows.reserve(PyList_GET_SIZE(streams.ptr()));
398
- for (py::handle stream : streams) {
399
- std::vector<int64_t> row;
400
- if (PyList_Check(stream.ptr())) row.reserve(PyList_GET_SIZE(stream.ptr()));
401
- for (py::handle raw : py::reinterpret_borrow<py::iterable>(stream)) {
402
- bool integer = PyLong_Check(raw.ptr()) || py::isinstance(raw, numpy_integer);
403
- if (!integer) {
404
- if (!integral) integral = py::module_::import("numbers").attr("Integral");
405
- integer = py::isinstance(raw, integral);
406
- }
407
- if (PyBool_Check(raw.ptr()) || !integer)
408
- throw std::invalid_argument("tokens must be integers");
409
- py::object number = PyLong_Check(raw.ptr())
410
- ? py::reinterpret_borrow<py::object>(raw)
411
- : py::reinterpret_steal<py::object>(PyNumber_Long(raw.ptr()));
412
- if (!number) throw py::error_already_set();
413
- int overflow = 0;
414
- const int64_t token = PyLong_AsLongLongAndOverflow(number.ptr(), &overflow);
415
- if (PyErr_Occurred()) throw py::error_already_set();
416
- if (overflow) throw std::invalid_argument("token is outside the vocabulary");
417
- row.push_back(token);
418
- }
419
- rows.push_back(std::move(row));
420
- }
421
- return decode_batch(codec, rows, width, full, threads);
422
  }, py::arg("streams"), py::arg("width"), py::arg("full_support"), py::arg("threads") = 0);
423
  }
 
1
+ // Set-BPE serialization: quantized cell trajectories <-> token streams.
2
+ //
3
+ // A port of the runtime's SpatialVocabulary / TemporalVocabulary (Python,
4
+ // ``tokenization/setbpe_{spatial,temporal}.py``). Every merge is applied from
5
+ // a priority queue ordered exactly as the Python heaps order their tuples, so
6
+ // the token streams are identical, not merely equivalent.
7
+
8
  #include <pybind11/numpy.h>
9
  #include <pybind11/pybind11.h>
10
  #include <pybind11/stl.h>
 
12
  #include <algorithm>
13
  #include <cstdint>
14
  #include <functional>
15
+ #include <memory>
16
+ #include <mutex>
17
  #include <queue>
18
  #include <stdexcept>
19
  #include <string>
 
23
  #include <vector>
24
 
25
  #include "parallel.hpp"
26
+ #include "repacking.hpp"
27
+ #include "token_streams.hpp"
28
 
29
  namespace py = pybind11;
30
 
 
85
  for (int64_t token = 0; token < spatial_size_; ++token)
86
  spatial_anchor_[token] = lowest_bit(support_[token]);
87
 
88
+ // Rooted temporal tokens: a pattern of (time offset, spatial token) pairs.
89
  vocab_size_ = spatial_size_ + static_cast<int64_t>(temporal_merges.size());
90
  patterns_.resize(vocab_size_);
91
  root_.resize(vocab_size_);
 
124
  }
125
 
126
  int64_t vocab_size() const { return vocab_size_; }
127
+ int64_t dimension() const { return bins_.size(); }
128
 
129
+ const ac2pack::Search& packing_search() const {
130
+ std::call_once(packing_once_, [&] {
131
+ packing_ = std::make_unique<ac2pack::Search>(bins_, support_, expansion_, patterns_, root_);
132
+ });
133
+ return *packing_;
134
+ }
135
 
136
+ // One dense [T, D] trajectory with -1 for semantically absent slots.
137
  std::vector<int64_t> encode(const int64_t* cells, int64_t steps) const {
138
  const int64_t dimension = static_cast<int64_t>(bins_.size());
139
  if (steps < 1) throw std::invalid_argument("every episode must have at least one step");
 
152
  throw std::invalid_argument("active dimensions must remain constant within an episode");
153
  }
154
 
155
+ // Instances, as in the Python encoder: token, anchor step, liveness.
156
  std::vector<int64_t> tokens, anchors;
157
  std::vector<char> active;
158
+ std::vector<std::vector<std::pair<int64_t, int64_t>>> at(steps); // (token, instance)
159
  for (int64_t t = 0; t < steps; ++t) {
160
  for (int64_t token : spatial_encode(cells + t * dimension)) {
161
  at[t].emplace_back(token, static_cast<int64_t>(tokens.size()));
 
165
  }
166
  }
167
 
168
+ // (rank, anchor, root anchor dimension, left instance, right instance, child)
169
  using Offer = std::tuple<int64_t, int64_t, int64_t, int64_t, int64_t, int64_t>;
170
  std::priority_queue<Offer, std::vector<Offer>, std::greater<Offer>> frontier;
171
  auto offer = [&](int64_t left_instance, int64_t right_instance) {
 
236
  return stream;
237
  }
238
 
239
+ // Tokens -> dense [T, D] cells (-1 outside ``full``), validating like the runtime.
240
  std::vector<int64_t> decode(const int64_t* stream, int64_t length, uint64_t full) const {
241
  const int64_t dimension = static_cast<int64_t>(bins_.size());
242
  std::vector<uint64_t> coverage{0};
 
282
  }
283
 
284
  private:
285
+ // Spatial Set-BPE of one dense row: merges in rank order, then anchor order.
286
  std::vector<int64_t> spatial_encode(const int64_t* row) const {
287
  const int64_t dimension = static_cast<int64_t>(bins_.size());
288
  std::vector<int64_t> present;
 
290
  if (row[d] >= 0) present.push_back(offsets_[d] + row[d]);
291
  }
292
  std::sort(present.begin(), present.end());
293
+ using Candidate = std::tuple<int64_t, int64_t, int64_t, int64_t>; // rank, child, left, right
294
  std::priority_queue<Candidate, std::vector<Candidate>, std::greater<Candidate>> heap;
295
  for (size_t i = 0; i < present.size(); ++i) {
296
  for (size_t j = i + 1; j < present.size(); ++j) {
 
299
  heap.emplace(found->second.rank, found->second.child, present[i], present[j]);
300
  }
301
  }
302
+ std::vector<int64_t> tokens = present; // a small set; linear lookups are fastest
303
  auto contains = [&](int64_t token) {
304
  return std::find(tokens.begin(), tokens.end(), token) != tokens.end();
305
  };
 
343
  std::vector<std::vector<std::pair<int64_t, int64_t>>> patterns_;
344
  std::vector<int64_t> root_;
345
  std::unordered_map<std::tuple<int64_t, int64_t, int64_t>, Rule, TupleHash> temporal_rules_;
346
+ mutable std::once_flag packing_once_;
347
+ mutable std::unique_ptr<ac2pack::Search> packing_;
348
  };
349
 
350
+ py::list encode_search_batch(const SetBPE& codec,
351
+ const py::array_t<int64_t, py::array::c_style | py::array::forcecast>& cells,
352
+ bool spatial, bool temporal, int window, int beam, int threads) {
353
+ if (cells.ndim() != 3 || cells.shape(0) < 1 || cells.shape(1) < 1 ||
354
+ cells.shape(2) != codec.dimension())
355
+ throw std::invalid_argument("expected nonempty [B,T,D] cells matching codec width");
356
+ if (window < 1 || window * codec.dimension() > 256 || beam < 1 || beam > 64)
357
+ throw std::invalid_argument("packing requires window*D <= 256 and beam in [1,64]");
358
+ const auto* search = spatial || temporal ? &codec.packing_search() : nullptr;
359
+ const int64_t batch = cells.shape(0), steps = cells.shape(1), width = cells.shape(2);
360
+ std::vector<std::vector<int64_t>> streams(batch);
361
+ {
362
+ py::gil_scoped_release release;
363
+ ac2::parallel_for(batch, threads, [&](int64_t index) {
364
+ const auto* row = cells.data() + index * steps * width;
365
+ auto original = codec.encode(row, steps);
366
+ streams[index] = search ? search->apply(row, steps, original, spatial, temporal, window, beam)
367
+ : std::move(original);
368
+ });
369
+ }
370
+ py::list output(batch);
371
+ for (int64_t index = 0; index < batch; ++index) output[index] = py::cast(streams[index]);
372
+ return output;
373
+ }
374
+
375
+ // Encode B dense [T, D] trajectories in parallel; returns one stream per row.
376
  py::list encode_batch(const SetBPE& codec,
377
  const py::array_t<int64_t, py::array::c_style | py::array::forcecast>& cells,
378
  int threads) {
379
+ if (cells.ndim() != 3) throw std::invalid_argument("cells must have shape [B, T, D]");
 
380
  const int64_t batch = cells.shape(0), steps = cells.shape(1), width = cells.shape(2);
381
  const int64_t* data = cells.data();
382
  std::vector<std::vector<int64_t>> streams(batch);
 
391
  return output;
392
  }
393
 
394
+ // Decode B equal-horizon streams to dense [B, T, D] cells (-1 outside ``full``).
395
  py::array_t<int64_t> decode_batch(const SetBPE& codec, const std::vector<std::vector<int64_t>>& streams,
396
  int64_t width, uint64_t full, int threads) {
397
  const int64_t batch = static_cast<int64_t>(streams.size());
398
  if (batch < 1) throw std::invalid_argument("streams must not be empty");
 
 
 
 
 
399
  std::vector<std::vector<int64_t>> cells(batch);
400
  {
401
  py::gil_scoped_release release;
 
416
  return output;
417
  }
418
 
419
+ } // namespace
420
 
421
  void register_setbpe(py::module_& module) {
422
  py::class_<SetBPE>(module, "SetBPE")
 
427
  .def_property_readonly("vocab_size", &SetBPE::vocab_size)
428
  .def("encode_batch", &encode_batch, py::arg("cells"), py::arg("threads") = 0,
429
  "Dense [B, T, D] cells (-1 = absent) -> one token stream per row.")
430
+ .def("encode_search_batch", &encode_search_batch, py::arg("cells"),
431
+ py::arg("spatial_search") = false, py::arg("temporal_search") = false,
432
+ py::arg("search_window") = 32, py::arg("search_beam") = 4, py::arg("threads") = 0)
433
  .def("decode_batch", &decode_batch, py::arg("streams"), py::arg("width"),
434
  py::arg("full_support"), py::arg("threads") = 0,
435
  "Equal-horizon streams -> dense [B, T, D] cells, -1 outside full_support.")
436
  .def("decode_checked_batch", [](const SetBPE& codec, const py::iterable& streams,
437
+ int64_t width, uint64_t full, int threads) {
438
+ return decode_batch(codec, ac2::checked_token_streams(streams, codec.vocab_size()), width, full, threads);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  }, py::arg("streams"), py::arg("width"), py::arg("full_support"), py::arg("threads") = 0);
440
  }
actioncodec2_kernels/so3.cpp CHANGED
@@ -1,3 +1,5 @@
 
 
1
  #include <pybind11/numpy.h>
2
  #include <pybind11/pybind11.h>
3
 
@@ -104,6 +106,8 @@ struct SO3Layout {
104
  bool left;
105
  int32_t input_order;
106
  double projection_radius = std::numeric_limits<double>::infinity();
 
 
107
  };
108
 
109
  void project_primitive(double* value, double radius) {
@@ -150,22 +154,31 @@ std::vector<int32_t> smooth_axis(const SO3Layout& layout, int axis, double reque
150
  return output;
151
  }
152
 
153
- bool earlier(double error, const std::array<int32_t, 3>& choice, double best_error,
154
- const std::array<int32_t, 3>& best_choice, bool found) {
155
- return !found || error < best_error || (error == best_error && choice < best_choice);
156
- }
 
 
 
 
 
 
 
 
 
157
 
158
  std::array<int32_t, 3> smooth_choice(const SO3Layout& layout,
159
  const std::array<double, 3>& request,
160
  const Quaternion& reconstruction,
161
  const Quaternion& target,
162
  const std::array<double, 3>& direction,
163
- const std::array<int32_t, 3>& nearest) {
 
164
  const auto x = smooth_axis(layout, 0, request[0], nearest[0]);
165
  const auto y = smooth_axis(layout, 1, request[1], nearest[1]);
166
  const auto z = smooth_axis(layout, 2, request[2], nearest[2]);
167
- std::array<int32_t, 3> best_any{}, best_preferred{};
168
- double any_error = 0.0, preferred_error = 0.0;
169
  bool found_any = false, found_preferred = false;
170
  for (const int32_t ix : x) {
171
  for (const int32_t iy : y) {
@@ -179,23 +192,30 @@ std::array<int32_t, 3> smooth_choice(const SO3Layout& layout,
179
  const double error = std::sqrt(remaining[0] * remaining[0] + remaining[1] * remaining[1] +
180
  remaining[2] * remaining[2]);
181
  if (error > layout.limit + 1e-12) continue;
182
- if (earlier(error, choice, any_error, best_any, found_any)) {
183
- best_any = choice;
184
- any_error = error;
 
 
 
 
 
 
 
 
 
185
  found_any = true;
186
  }
187
  const double dot = vector[0] * direction[0] + vector[1] * direction[1] +
188
  vector[2] * direction[2];
189
- if (dot >= -1e-15 && earlier(error, choice, preferred_error, best_preferred,
190
- found_preferred)) {
191
- best_preferred = choice;
192
- preferred_error = error;
193
  found_preferred = true;
194
  }
195
  }
196
  }
197
  }
198
- return found_preferred ? best_preferred : (found_any ? best_any : nearest);
199
  }
200
 
201
  void encode_trajectory(const SO3Layout& layout, const float* actions, int64_t steps,
@@ -203,14 +223,16 @@ void encode_trajectory(const SO3Layout& layout, const float* actions, int64_t st
203
  Quaternion truth{0.0, 0.0, 0.0, 1.0};
204
  Quaternion reconstruction = truth;
205
  std::array<double, 3> direction{};
 
206
  for (int64_t step = 0; step < steps; ++step) {
207
  const Quaternion value = from_input(actions + 3 * step, layout.rotation_vector);
208
  truth = layout.input_order == 0 ? value : apply(truth, value, layout.left);
209
  const auto request = as_rotvec(residual(truth, reconstruction, layout.left));
210
  auto choice = nearest_choice(layout, request);
211
  if (layout.smooth) {
212
- choice = smooth_choice(layout, request, reconstruction, truth, direction, choice);
213
  }
 
214
  const double vector[3] = {primitive(layout, 0, choice[0]),
215
  primitive(layout, 1, choice[1]),
216
  primitive(layout, 2, choice[2])};
@@ -222,7 +244,7 @@ void encode_trajectory(const SO3Layout& layout, const float* actions, int64_t st
222
  }
223
  }
224
 
225
- }
226
 
227
  py::array_t<int32_t> so3_second_order_encode_batch(
228
  const py::array_t<double, py::array::c_style | py::array::forcecast>& table,
@@ -263,7 +285,7 @@ py::array_t<int32_t> so3_second_order_encode_batch(
263
  const auto request = as_rotvec(correction);
264
  auto choice = nearest_choice(layout, request);
265
  if (smooth) {
266
-
267
  SO3Layout selection = layout;
268
  selection.left = false;
269
  choice = smooth_choice(selection, request, identity, correction, direction, choice);
@@ -284,10 +306,11 @@ py::array_t<int32_t> so3_encode_batch(
284
  const py::array_t<double, py::array::c_style | py::array::forcecast>& table,
285
  const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& bins,
286
  const py::array_t<float, py::array::c_style | py::array::forcecast>& actions,
287
- int32_t input_order, bool rotation_vector, bool left, double limit, bool smooth,
288
  int threads) {
289
  if (table.ndim() != 2 || table.shape(0) != 3 || bins.size() != 3 || actions.ndim() != 3 ||
290
- actions.shape(2) != 3 || (input_order != 0 && input_order != 1) || limit <= 0.0) {
 
291
  throw std::invalid_argument("invalid SO(3) batch encoder arguments");
292
  }
293
  for (int axis = 0; axis < 3; ++axis) {
@@ -295,11 +318,28 @@ py::array_t<int32_t> so3_encode_batch(
295
  throw std::invalid_argument("SO(3) primitive count exceeds table width");
296
  }
297
  }
298
- const SO3Layout layout{table.data(), bins.data(), table.shape(1), limit, smooth,
299
- rotation_vector, left, input_order};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  const int64_t batch = actions.shape(0);
301
  const int64_t steps = actions.shape(1);
302
  py::array_t<int32_t> output({batch, steps, int64_t{3}});
 
 
 
303
  {
304
  py::gil_scoped_release release;
305
  ac2::parallel_for(batch, threads, [&](int64_t index) {
 
1
+ // Intrinsic first- and stateful second-order SO(3) error-feedback encoders.
2
+
3
  #include <pybind11/numpy.h>
4
  #include <pybind11/pybind11.h>
5
 
 
106
  bool left;
107
  int32_t input_order;
108
  double projection_radius = std::numeric_limits<double>::infinity();
109
+ bool lazy = false; // momentum_lazy ranking (first order only)
110
+ std::array<int32_t, 3> zero{}; // exact zero primitive per axis, used by ``lazy``
111
  };
112
 
113
  void project_primitive(double* value, double radius) {
 
154
  return output;
155
  }
156
 
157
+ // Lexicographic selection key: (moving axes, zero distance) under ``lazy``, then error, cell.
158
+ struct ChoiceKey {
159
+ int moving = 0;
160
+ int distance = 0;
161
+ double error = 0.0;
162
+ std::array<int32_t, 3> choice{};
163
+ bool operator<(const ChoiceKey& o) const {
164
+ if (moving != o.moving) return moving < o.moving;
165
+ if (distance != o.distance) return distance < o.distance;
166
+ if (error != o.error) return error < o.error;
167
+ return choice < o.choice;
168
+ }
169
+ };
170
 
171
  std::array<int32_t, 3> smooth_choice(const SO3Layout& layout,
172
  const std::array<double, 3>& request,
173
  const Quaternion& reconstruction,
174
  const Quaternion& target,
175
  const std::array<double, 3>& direction,
176
+ const std::array<int32_t, 3>& nearest,
177
+ const std::array<int32_t, 3>& previous = {-1, -1, -1}) {
178
  const auto x = smooth_axis(layout, 0, request[0], nearest[0]);
179
  const auto y = smooth_axis(layout, 1, request[1], nearest[1]);
180
  const auto z = smooth_axis(layout, 2, request[2], nearest[2]);
181
+ ChoiceKey best_any, best_preferred;
 
182
  bool found_any = false, found_preferred = false;
183
  for (const int32_t ix : x) {
184
  for (const int32_t iy : y) {
 
192
  const double error = std::sqrt(remaining[0] * remaining[0] + remaining[1] * remaining[1] +
193
  remaining[2] * remaining[2]);
194
  if (error > layout.limit + 1e-12) continue;
195
+ if (layout.lazy && choice == previous) return choice;
196
+ ChoiceKey key;
197
+ key.error = error;
198
+ key.choice = choice;
199
+ if (layout.lazy) {
200
+ for (int axis = 0; axis < 3; ++axis) {
201
+ key.moving += choice[axis] != layout.zero[axis];
202
+ key.distance += std::abs(choice[axis] - layout.zero[axis]);
203
+ }
204
+ }
205
+ if (!found_any || key < best_any) {
206
+ best_any = key;
207
  found_any = true;
208
  }
209
  const double dot = vector[0] * direction[0] + vector[1] * direction[1] +
210
  vector[2] * direction[2];
211
+ if (dot >= -1e-15 && (!found_preferred || key < best_preferred)) {
212
+ best_preferred = key;
 
 
213
  found_preferred = true;
214
  }
215
  }
216
  }
217
  }
218
+ return found_preferred ? best_preferred.choice : (found_any ? best_any.choice : nearest);
219
  }
220
 
221
  void encode_trajectory(const SO3Layout& layout, const float* actions, int64_t steps,
 
223
  Quaternion truth{0.0, 0.0, 0.0, 1.0};
224
  Quaternion reconstruction = truth;
225
  std::array<double, 3> direction{};
226
+ std::array<int32_t, 3> previous = layout.zero;
227
  for (int64_t step = 0; step < steps; ++step) {
228
  const Quaternion value = from_input(actions + 3 * step, layout.rotation_vector);
229
  truth = layout.input_order == 0 ? value : apply(truth, value, layout.left);
230
  const auto request = as_rotvec(residual(truth, reconstruction, layout.left));
231
  auto choice = nearest_choice(layout, request);
232
  if (layout.smooth) {
233
+ choice = smooth_choice(layout, request, reconstruction, truth, direction, choice, previous);
234
  }
235
+ previous = choice;
236
  const double vector[3] = {primitive(layout, 0, choice[0]),
237
  primitive(layout, 1, choice[1]),
238
  primitive(layout, 2, choice[2])};
 
244
  }
245
  }
246
 
247
+ } // namespace
248
 
249
  py::array_t<int32_t> so3_second_order_encode_batch(
250
  const py::array_t<double, py::array::c_style | py::array::forcecast>& table,
 
285
  const auto request = as_rotvec(correction);
286
  auto choice = nearest_choice(layout, request);
287
  if (smooth) {
288
+ // Python scores U^-1 W for both conventions; use exactly that order.
289
  SO3Layout selection = layout;
290
  selection.left = false;
291
  choice = smooth_choice(selection, request, identity, correction, direction, choice);
 
306
  const py::array_t<double, py::array::c_style | py::array::forcecast>& table,
307
  const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& bins,
308
  const py::array_t<float, py::array::c_style | py::array::forcecast>& actions,
309
+ int32_t input_order, bool rotation_vector, bool left, double limit, int32_t selection,
310
  int threads) {
311
  if (table.ndim() != 2 || table.shape(0) != 3 || bins.size() != 3 || actions.ndim() != 3 ||
312
+ actions.shape(2) != 3 || (input_order != 0 && input_order != 1) || limit <= 0.0 ||
313
+ selection < 0 || selection > 2) {
314
  throw std::invalid_argument("invalid SO(3) batch encoder arguments");
315
  }
316
  for (int axis = 0; axis < 3; ++axis) {
 
318
  throw std::invalid_argument("SO(3) primitive count exceeds table width");
319
  }
320
  }
321
+ // selection: 0 nearest, 1 smooth, 2 momentum_lazy
322
+ SO3Layout layout{table.data(), bins.data(), table.shape(1), limit, selection != 0,
323
+ rotation_vector, left, input_order};
324
+ layout.lazy = selection == 2;
325
+ for (int axis = 0; axis < 3; ++axis) {
326
+ double best = std::numeric_limits<double>::infinity();
327
+ for (int32_t index = 0; index < bins.data()[axis]; ++index) {
328
+ if (std::fabs(primitive(layout, axis, index)) < best) {
329
+ best = std::fabs(primitive(layout, axis, index));
330
+ layout.zero[axis] = index;
331
+ }
332
+ }
333
+ if (layout.lazy && best != 0.0) {
334
+ throw std::invalid_argument("momentum_lazy needs an exact zero primitive per axis");
335
+ }
336
+ }
337
  const int64_t batch = actions.shape(0);
338
  const int64_t steps = actions.shape(1);
339
  py::array_t<int32_t> output({batch, steps, int64_t{3}});
340
+ #ifndef _OPENMP
341
+ (void)threads;
342
+ #endif
343
  {
344
  py::gil_scoped_release release;
345
  ac2::parallel_for(batch, threads, [&](int64_t index) {
actioncodec2_kernels/sparse.cpp ADDED
@@ -0,0 +1,1308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ #include <pybind11/numpy.h>
3
+ #include <pybind11/pybind11.h>
4
+ #include <pybind11/stl.h>
5
+
6
+ #include <algorithm>
7
+ #include <array>
8
+ #include <chrono>
9
+ #include <cstdint>
10
+ #include <limits>
11
+ #include <queue>
12
+ #include <set>
13
+ #include <stdexcept>
14
+ #include <string>
15
+ #include <tuple>
16
+ #include <unordered_map>
17
+ #include <vector>
18
+
19
+ #include "parallel.hpp"
20
+
21
+ #ifdef _OPENMP
22
+ #include <omp.h>
23
+ #endif
24
+ #if defined(_OPENMP) && defined(__GLIBCXX__)
25
+ #include <parallel/algorithm>
26
+ #define AC2_PARALLEL_SORT __gnu_parallel::sort
27
+ #else
28
+ #define AC2_PARALLEL_SORT std::sort
29
+ #endif
30
+
31
+ namespace py = pybind11;
32
+
33
+ namespace {
34
+
35
+ constexpr int kMaxLookahead = 32;
36
+ constexpr size_t kCertifyBatch = 32;
37
+ constexpr size_t kApplyGrain = 256; // minimum candidate pairs per parallel apply group
38
+
39
+ constexpr size_t kShards = 64; // rule-table shards updated concurrently after a merge
40
+
41
+ inline uint64_t mix(uint64_t key) { return key * 0x9E3779B97F4A7C15ull; }
42
+ inline size_t shard_of(uint64_t key) { return static_cast<size_t>(mix(key) >> 58); }
43
+
44
+ // Small open-addressing accumulator for rule-count deltas; clearing costs O(used).
45
+ class DeltaMap {
46
+ public:
47
+ void add(uint64_t key, int64_t delta) {
48
+ if ((filled_.size() + 1) * 2 > keys_.size()) grow();
49
+ size_t h = slot(key);
50
+ while (keys_[h] != kEmpty && keys_[h] != key) h = (h + 1) & (keys_.size() - 1);
51
+ if (keys_[h] == kEmpty) {
52
+ keys_[h] = key;
53
+ filled_.push_back(static_cast<uint32_t>(h));
54
+ }
55
+ values_[h] += delta;
56
+ }
57
+ template <class F>
58
+ void each(F&& visit) const {
59
+ for (uint32_t h : filled_) visit(keys_[h], values_[h]);
60
+ }
61
+ void clear() {
62
+ for (uint32_t h : filled_) {
63
+ keys_[h] = kEmpty;
64
+ values_[h] = 0;
65
+ }
66
+ filled_.clear();
67
+ }
68
+
69
+ private:
70
+ static constexpr uint64_t kEmpty = ~0ull;
71
+ size_t slot(uint64_t key) const {
72
+ return static_cast<size_t>(mix(key) >> 20) & (keys_.size() - 1);
73
+ }
74
+ void grow() {
75
+ std::vector<uint64_t> keys = std::move(keys_);
76
+ std::vector<int64_t> values = std::move(values_);
77
+ std::vector<uint32_t> filled = std::move(filled_);
78
+ const size_t capacity = std::max<size_t>(64, keys.size() * 2);
79
+ keys_.assign(capacity, kEmpty);
80
+ values_.assign(capacity, 0);
81
+ filled_.clear();
82
+ for (uint32_t h : filled) add(keys[h], values[h]);
83
+ }
84
+ std::vector<uint64_t> keys_;
85
+ std::vector<int64_t> values_;
86
+ std::vector<uint32_t> filled_;
87
+ };
88
+
89
+ inline uint64_t rule_key(int32_t left, int32_t right, int32_t offset) {
90
+ return (static_cast<uint64_t>(left) << 40) | (static_cast<uint64_t>(right) << 16) |
91
+ static_cast<uint64_t>(offset);
92
+ }
93
+
94
+ inline void unpack(uint64_t key, int32_t& left, int32_t& right, int32_t& offset) {
95
+ left = static_cast<int32_t>(key >> 40);
96
+ right = static_cast<int32_t>((key >> 16) & 0xffffffull);
97
+ offset = static_cast<int32_t>(key & 0xffffull);
98
+ }
99
+
100
+ // Immutable-per-token geometry shared by the trainer, encoder and decoder.
101
+ struct Tables {
102
+ int32_t dims = 0; // physical slots including E
103
+ int32_t e_dim = 0; // index of E
104
+ int32_t lookahead = 0; // maximum token span in frames
105
+ std::vector<uint32_t> masks; // token * lookahead -> support mask
106
+ std::vector<uint32_t> emask; // token -> bit per offset holding E
107
+ std::vector<int8_t> first_from; // token * lookahead + k -> first support offset >= k
108
+ std::vector<int32_t> span, rdim;
109
+ std::vector<std::vector<std::pair<int32_t, int32_t>>> pattern; // (offset, spatial token)
110
+ int32_t base = 0;
111
+
112
+ void init(const std::vector<uint32_t>& spatial_support, int32_t dims_, int32_t lookahead_) {
113
+ dims = dims_;
114
+ e_dim = dims_ - 1;
115
+ lookahead = lookahead_;
116
+ if (lookahead < 1 || lookahead > kMaxLookahead) throw std::invalid_argument("lookahead");
117
+ if (dims < 2 || dims > 16) throw std::invalid_argument("dims must be in [2, 16]");
118
+ base = static_cast<int32_t>(spatial_support.size());
119
+ for (int32_t t = 0; t < base; ++t) {
120
+ uint32_t s = spatial_support[t];
121
+ if (!s) throw std::invalid_argument("spatial token without support");
122
+ push({{0, t}}, std::vector<uint32_t>{s});
123
+ }
124
+ }
125
+
126
+ void push(std::vector<std::pair<int32_t, int32_t>> pat, const std::vector<uint32_t>& m) {
127
+ const size_t token = span.size();
128
+ masks.resize((token + 1) * lookahead, 0);
129
+ uint32_t e = 0;
130
+ int32_t sp = 0;
131
+ for (size_t o = 0; o < m.size(); ++o) {
132
+ masks[token * lookahead + o] = m[o];
133
+ if (m[o]) sp = static_cast<int32_t>(o) + 1;
134
+ if (m[o] >> e_dim & 1u) e |= 1u << o;
135
+ }
136
+ emask.push_back(e);
137
+ first_from.resize((token + 1) * lookahead, -1);
138
+ for (int32_t k = sp - 1, next = -1; k >= 0; --k) {
139
+ if (masks[token * lookahead + k]) next = k;
140
+ first_from[token * lookahead + k] = static_cast<int8_t>(next);
141
+ }
142
+ span.push_back(sp);
143
+ rdim.push_back(__builtin_ctz(m[0]));
144
+ pattern.push_back(std::move(pat));
145
+ }
146
+
147
+ int32_t size() const { return static_cast<int32_t>(span.size()); }
148
+ uint32_t mask(int32_t token, int32_t o) const {
149
+ return masks[static_cast<size_t>(token) * lookahead + o];
150
+ }
151
+
152
+ // Merge geometry; returns false when the union would exceed the lookahead or overlap.
153
+ bool compose(int32_t a, int32_t b, int32_t off, std::vector<uint32_t>& out) const {
154
+ if (off < 0 || off >= lookahead) return false;
155
+ const int32_t sp = std::max(span[a], off + span[b]);
156
+ if (sp > lookahead) return false;
157
+ out.assign(sp, 0);
158
+ for (int32_t o = 0; o < span[a]; ++o) out[o] = mask(a, o);
159
+ for (int32_t o = 0; o < span[b]; ++o) {
160
+ if (out[o + off] & mask(b, o)) return false;
161
+ out[o + off] |= mask(b, o);
162
+ }
163
+ return true;
164
+ }
165
+
166
+ int32_t add_merge(int32_t a, int32_t b, int32_t off) {
167
+ std::vector<uint32_t> m;
168
+ if (!compose(a, b, off, m)) throw std::invalid_argument("illegal temporal merge");
169
+ auto pat = pattern[a];
170
+ for (auto [o, t] : pattern[b]) pat.push_back({o + off, t});
171
+ std::sort(pat.begin(), pat.end());
172
+ push(std::move(pat), m);
173
+ return size() - 1;
174
+ }
175
+ };
176
+
177
+ using Key = std::tuple<int64_t, int32_t, int64_t, int32_t>;
178
+ using InstanceId = uint32_t;
179
+ constexpr InstanceId kNoInstance = UINT32_MAX;
180
+
181
+ // Mutable instance state over a global frame axis. Used per chunk by the encoder and
182
+ // globally by the trainer.
183
+ struct Instances {
184
+ const Tables* tb = nullptr;
185
+ int64_t frames = 0;
186
+ std::vector<int32_t> tok;
187
+ std::vector<int64_t> root;
188
+ std::vector<uint8_t> alive;
189
+ std::vector<InstanceId> member; // frame * dims + dim -> instance
190
+
191
+ void reset(const Tables* tables, int64_t frame_count) {
192
+ tb = tables;
193
+ frames = frame_count;
194
+ tok.clear();
195
+ root.clear();
196
+ alive.clear();
197
+ member.assign(static_cast<size_t>(frame_count) * tables->dims, kNoInstance);
198
+ }
199
+
200
+ InstanceId cell(int64_t f, int32_t d) const {
201
+ return member[static_cast<size_t>(f) * tb->dims + d];
202
+ }
203
+
204
+ Key key(InstanceId i) const {
205
+ const uint32_t e = tb->emask[tok[i]];
206
+ return Key(root[i], e ? 1 : 0, e ? root[i] + __builtin_ctz(e) : 0, tb->rdim[tok[i]]);
207
+ }
208
+
209
+ InstanceId add(int32_t token, int64_t r) {
210
+ if (tok.size() >= kNoInstance) throw std::length_error("instance ids");
211
+ const InstanceId i = static_cast<InstanceId>(tok.size());
212
+ tok.push_back(token);
213
+ root.push_back(r);
214
+ alive.push_back(1);
215
+ place(i);
216
+ return i;
217
+ }
218
+
219
+ // Reserves dead slots that disjoint frame ranges then fill concurrently via ``place``.
220
+ InstanceId reserve(size_t count) {
221
+ const size_t base = tok.size();
222
+ if (base + count > static_cast<size_t>(kNoInstance)) throw std::length_error("instance ids");
223
+ tok.resize(base + count, 0);
224
+ root.resize(base + count, 0);
225
+ alive.resize(base + count, 0);
226
+ return static_cast<InstanceId>(base);
227
+ }
228
+
229
+ void place(InstanceId i) {
230
+ const int32_t token = tok[i];
231
+ const int64_t r = root[i];
232
+ alive[i] = 1;
233
+ for (int32_t o = 0; o < tb->span[token]; ++o) {
234
+ uint32_t m = tb->mask(token, o);
235
+ while (m) {
236
+ const int32_t d = __builtin_ctz(m);
237
+ m &= m - 1;
238
+ InstanceId& slot = member[static_cast<size_t>(r + o) * tb->dims + d];
239
+ if (slot != kNoInstance) throw std::runtime_error("instance overlap");
240
+ slot = i;
241
+ }
242
+ }
243
+ }
244
+
245
+ void kill(InstanceId i) {
246
+ alive[i] = 0;
247
+ const int32_t token = tok[i];
248
+ for (int32_t o = 0; o < tb->span[token]; ++o) {
249
+ uint32_t m = tb->mask(token, o);
250
+ while (m) {
251
+ const int32_t d = __builtin_ctz(m);
252
+ m &= m - 1;
253
+ member[static_cast<size_t>(root[i] + o) * tb->dims + d] = kNoInstance;
254
+ }
255
+ }
256
+ }
257
+
258
+ bool first(InstanceId a, InstanceId b) const {
259
+ return std::make_pair(root[a], tb->rdim[tok[a]]) < std::make_pair(root[b], tb->rdim[tok[b]]);
260
+ }
261
+
262
+ // Ordered candidate pairs (a, b, offset) involving i inside the lookahead window.
263
+ // Each neighbour is emitted once, at its first support cell inside the window.
264
+ template <class F>
265
+ void partners(InstanceId i, bool allow_zero, F&& emit) const {
266
+ const int64_t r = root[i];
267
+ const int32_t L = tb->lookahead;
268
+ const int64_t lo = std::max<int64_t>(0, r - L + 1), hi = std::min<int64_t>(frames - 1, r + L - 1);
269
+ for (int64_t f = lo; f <= hi; ++f) {
270
+ for (int32_t d = 0; d < tb->dims; ++d) {
271
+ const InstanceId j = cell(f, d);
272
+ if (j == kNoInstance || j == i) continue;
273
+ const int32_t tj = tok[j];
274
+ const int64_t o = f - root[j];
275
+ const int64_t k = std::max<int64_t>(0, lo - root[j]);
276
+ if (tb->first_from[static_cast<size_t>(tj) * L + k] != o) continue;
277
+ if (__builtin_ctz(tb->mask(tj, static_cast<int32_t>(o))) != d) continue;
278
+ const bool ij = first(i, j);
279
+ const InstanceId a = ij ? i : j, b = ij ? j : i;
280
+ const int64_t off = root[b] - root[a];
281
+ if (off >= L || (off == 0 && !allow_zero)) continue;
282
+ if (std::max<int64_t>(tb->span[tok[a]], off + tb->span[tok[b]]) > L) continue;
283
+ emit(a, b, static_cast<int32_t>(off));
284
+ }
285
+ }
286
+ }
287
+
288
+ // Would merging a (rooted first) with b keep the stream decodable?
289
+ bool valid(InstanceId a, InstanceId b, int32_t off) const {
290
+ const int32_t ta = tok[a], tb_ = tok[b];
291
+ const int32_t sp = std::max(tb->span[ta], off + tb->span[tb_]);
292
+ uint32_t emask = tb->emask[ta] | (tb->emask[tb_] << off);
293
+ const Key km(root[a], emask ? 1 : 0, emask ? root[a] + __builtin_ctz(emask) : 0,
294
+ tb->rdim[ta]);
295
+ for (int32_t o = 0; o < sp; ++o) {
296
+ uint32_t m = (o < tb->span[ta] ? tb->mask(ta, o) : 0) |
297
+ (o >= off && o - off < tb->span[tb_] ? tb->mask(tb_, o - off) : 0);
298
+ if (!m) continue;
299
+ const int64_t u = root[a] + o;
300
+ const InstanceId h = cell(u, tb->e_dim);
301
+ if (h != kNoInstance && h != a && h != b && !(key(h) > km)) return false;
302
+ if (emask >> o & 1u) {
303
+ for (int32_t d = 0; d < tb->dims; ++d) {
304
+ const InstanceId j = cell(u, d);
305
+ if (j != kNoInstance && j != a && j != b && !(key(j) < km)) return false;
306
+ }
307
+ }
308
+ }
309
+ return true;
310
+ }
311
+
312
+ // Right partner of left instance a under rule (left, right, off), or -1.
313
+ InstanceId right_of(InstanceId a, int32_t right, int32_t off) const {
314
+ const int64_t f = root[a] + off;
315
+ if (f >= frames) return kNoInstance;
316
+ const InstanceId b = cell(f, tb->rdim[right]);
317
+ if (b == kNoInstance || b == a || !alive[b] || tok[b] != right || root[b] != f) return kNoInstance;
318
+ return b;
319
+ }
320
+
321
+ // Left partner of right instance b under rule (left, right, off), or -1.
322
+ InstanceId left_of(InstanceId b, int32_t left, int32_t off) const {
323
+ const int64_t f = root[b] - off;
324
+ if (f < 0) return kNoInstance;
325
+ const InstanceId a = cell(f, tb->rdim[left]);
326
+ if (a == kNoInstance || a == b || !alive[a] || tok[a] != left || root[a] != f) return kNoInstance;
327
+ return a;
328
+ }
329
+
330
+ std::vector<int32_t> serialize() const {
331
+ std::vector<InstanceId> live;
332
+ for (size_t i = 0; i < tok.size(); ++i)
333
+ if (alive[i]) live.push_back(static_cast<InstanceId>(i));
334
+ std::sort(live.begin(), live.end(), [&](InstanceId x, InstanceId y) { return key(x) < key(y); });
335
+ std::vector<int32_t> out;
336
+ out.reserve(live.size());
337
+ for (InstanceId i : live) out.push_back(tok[i]);
338
+ return out;
339
+ }
340
+ };
341
+
342
+ struct Stat {
343
+ bool retired = false; // a rule is learned at most once; leftovers stay unmerged
344
+ int64_t raw = 0;
345
+ uint32_t version = 0;
346
+ int64_t exact = -1; // matching- and validity-certified count at exact_version
347
+ uint32_t exact_version = 0;
348
+ int64_t added = 0; // cumulative pair insertions; bounds growth since certification
349
+ int64_t added_at_exact = 0;
350
+ uint32_t dirty_epoch = 0; // merge batch that last queued this key for re-push
351
+ bool live = false; // a heap entry (queued_version, queued_count) represents the key
352
+ uint32_t queued_version = 0;
353
+ int64_t queued_count = 0;
354
+ };
355
+
356
+ // Open-addressing rule table; references are invalidated only by inserting a new key.
357
+ class StatMap {
358
+ public:
359
+ Stat* find(uint64_t key) {
360
+ if (keys_.empty()) return nullptr;
361
+ for (size_t h = slot(key);; h = (h + 1) & (keys_.size() - 1)) {
362
+ if (keys_[h] == key) return &values_[h];
363
+ if (keys_[h] == kEmpty) return nullptr;
364
+ }
365
+ }
366
+ Stat& operator[](uint64_t key) {
367
+ if ((used_ + 1) * 2 > keys_.size()) grow();
368
+ size_t h = slot(key);
369
+ while (keys_[h] != kEmpty && keys_[h] != key) h = (h + 1) & (keys_.size() - 1);
370
+ if (keys_[h] == kEmpty) {
371
+ keys_[h] = key;
372
+ ++used_;
373
+ }
374
+ return values_[h];
375
+ }
376
+ size_t size() const { return used_; }
377
+ template <class F>
378
+ void each(F&& visit) const {
379
+ for (size_t h = 0; h < keys_.size(); ++h)
380
+ if (keys_[h] != kEmpty) visit(keys_[h], values_[h]);
381
+ }
382
+
383
+ private:
384
+ static constexpr uint64_t kEmpty = ~0ull;
385
+ size_t slot(uint64_t key) const {
386
+ return static_cast<size_t>(mix(key) >> 20) & (keys_.size() - 1);
387
+ }
388
+ void grow() {
389
+ std::vector<uint64_t> keys = std::move(keys_);
390
+ std::vector<Stat> values = std::move(values_);
391
+ const size_t capacity = std::max<size_t>(1 << 16, keys.size() * 2);
392
+ keys_.assign(capacity, kEmpty);
393
+ values_.assign(capacity, Stat{});
394
+ used_ = 0;
395
+ for (size_t h = 0; h < keys.size(); ++h)
396
+ if (keys[h] != kEmpty) (*this)[keys[h]] = values[h];
397
+ }
398
+ std::vector<uint64_t> keys_;
399
+ std::vector<Stat> values_;
400
+ size_t used_ = 0;
401
+ };
402
+
403
+ // Rule table split by key hash so disjoint shards can be updated concurrently.
404
+ class ShardedStats {
405
+ public:
406
+ Stat* find(uint64_t key) { return shards_[shard_of(key)].find(key); }
407
+ Stat& operator[](uint64_t key) { return shards_[shard_of(key)][key]; }
408
+ StatMap& shard(size_t p) { return shards_[p]; }
409
+ size_t size() const {
410
+ size_t n = 0;
411
+ for (const auto& s : shards_) n += s.size();
412
+ return n;
413
+ }
414
+ template <class F>
415
+ void each(F&& visit) const {
416
+ for (const auto& s : shards_) s.each(visit);
417
+ }
418
+
419
+ private:
420
+ StatMap shards_[kShards];
421
+ };
422
+
423
+ struct Entry {
424
+ int64_t count;
425
+ uint64_t key;
426
+ uint32_t version;
427
+ bool operator<(const Entry& o) const {
428
+ if (count != o.count) return count < o.count;
429
+ return key > o.key;
430
+ }
431
+ };
432
+
433
+ class Trainer {
434
+ public:
435
+ Trainer(std::vector<uint32_t> spatial_support, int32_t dims, int32_t lookahead,
436
+ int64_t vocab_size, bool allow_zero, int64_t min_count, int threads,
437
+ bool dense_census = false, int64_t max_instances = UINT32_MAX)
438
+ : vocab_size_(vocab_size), allow_zero_(allow_zero), dense_census_(dense_census),
439
+ min_count_(min_count), max_instances_(max_instances) {
440
+ if (max_instances_ < 1 || max_instances_ > UINT32_MAX)
441
+ throw std::invalid_argument("max_instances");
442
+ tables_.init(spatial_support, dims, lookahead);
443
+ #ifdef _OPENMP
444
+ threads_ = threads > 0 ? threads : std::min(omp_get_max_threads(), 32);
445
+ #else
446
+ (void)threads;
447
+ threads_ = 1;
448
+ #endif
449
+ }
450
+
451
+ // anchors: global frame index per instance (chunks already separated by >= lookahead).
452
+ void set_instances(py::array_t<int64_t, py::array::c_style> anchors,
453
+ py::array_t<int32_t, py::array::c_style> tokens, int64_t frames) {
454
+ auto a = anchors.unchecked<1>();
455
+ auto t = tokens.unchecked<1>();
456
+ if (a.shape(0) != t.shape(0)) throw std::invalid_argument("anchors/tokens length");
457
+ if (a.shape(0) > max_instances_)
458
+ throw std::length_error("live instance capacity exceeded before training");
459
+ st_.reset(&tables_, frames);
460
+ // Pre-sized so references into the outer vector stay valid while children are added.
461
+ occurrence_.assign(static_cast<size_t>(vocab_size_) + 16, {});
462
+ dead_.assign(static_cast<size_t>(vocab_size_) + 16, 0);
463
+ for (py::ssize_t i = 0; i < a.shape(0); ++i) {
464
+ const int32_t tk = t(i);
465
+ if (tk < 0 || tk >= tables_.base) throw std::invalid_argument("token outside spatial vocab");
466
+ occurrence_[tk].push_back(st_.add(tk, a(i)));
467
+ }
468
+ for (auto& occ : occurrence_)
469
+ std::sort(occ.begin(), occ.end(), [&](InstanceId x, InstanceId y) { return st_.root[x] < st_.root[y]; });
470
+ initial_instances_ = a.shape(0);
471
+ // Supports never change, so frames preceded by >= lookahead empty frames split the
472
+ // axis into ranges whose merges never read or write each other's cells.
473
+ safe_.clear();
474
+ int64_t empty = tables_.lookahead;
475
+ for (int64_t f = 0; f < frames; ++f) {
476
+ bool occupied = false;
477
+ for (int32_t d = 0; d < tables_.dims && !occupied; ++d) occupied = st_.cell(f, d) != kNoInstance;
478
+ if (!occupied) {
479
+ ++empty;
480
+ continue;
481
+ }
482
+ if (empty >= tables_.lookahead) safe_.push_back(f);
483
+ empty = 0;
484
+ }
485
+ }
486
+
487
+ py::dict train(bool progress) {
488
+ const auto t0 = std::chrono::steady_clock::now();
489
+ {
490
+ py::gil_scoped_release release;
491
+ census();
492
+ std::vector<uint64_t> keys;
493
+ keys.reserve(stats_.size());
494
+ stats_.each([&](uint64_t k, const Stat&) { keys.push_back(k); });
495
+ for (uint64_t k : keys) queue(k, stats_[k], stats_[k].raw);
496
+ initial_types_ = stats_.size();
497
+ census_seconds_ = seconds_since(t0);
498
+ run(progress);
499
+ }
500
+ py::dict report;
501
+ report["initial_instances"] = initial_instances_;
502
+ report["final_instances"] = live_;
503
+ report["initial_rule_types"] = initial_types_;
504
+ report["merges"] = static_cast<int64_t>(merges_.size());
505
+ report["dry_runs"] = dry_runs_;
506
+ report["rejected_by_validity"] = rejected_;
507
+ report["applied"] = applied_;
508
+ report["census_seconds"] = census_seconds_;
509
+ report["dry_run_seconds"] = dry_seconds_;
510
+ report["apply_seconds"] = apply_seconds_;
511
+ report["collect_seconds"] = collect_seconds_;
512
+ report["parallel_seconds"] = parallel_seconds_;
513
+ report["serial_seconds"] = serial_seconds_;
514
+ report["merge_seconds"] = merge_seconds_;
515
+ report["changes"] = changes_;
516
+ report["compactions"] = compactions_;
517
+ report["total_seconds"] = seconds_since(t0);
518
+ return report;
519
+ }
520
+
521
+ std::vector<std::tuple<int32_t, int32_t, int32_t>> merges() const { return merges_; }
522
+
523
+ // Count every forward pair once: blockwise parallel key generation, parallel sort, RLE.
524
+ void census() {
525
+ const int64_t n = static_cast<int64_t>(st_.tok.size());
526
+ const int64_t base = tables_.base;
527
+ const int64_t universe = base * base * tables_.lookahead;
528
+ if (dense_census_ && universe <= 4000000) {
529
+ std::vector<std::vector<int64_t>> local(
530
+ threads_, std::vector<int64_t>(static_cast<size_t>(universe), 0));
531
+ #ifdef _OPENMP
532
+ #pragma omp parallel num_threads(threads_)
533
+ #endif
534
+ {
535
+ #ifdef _OPENMP
536
+ auto& counts = local[omp_get_thread_num()];
537
+ #else
538
+ auto& counts = local[0];
539
+ #endif
540
+ #ifdef _OPENMP
541
+ #pragma omp for schedule(static)
542
+ #endif
543
+ for (int64_t i = 0; i < n; ++i) {
544
+ const InstanceId ii = static_cast<InstanceId>(i);
545
+ st_.partners(ii, allow_zero_, [&](InstanceId a, InstanceId b, int32_t off) {
546
+ if (a == ii) {
547
+ const int64_t key = (static_cast<int64_t>(st_.tok[a]) * base + st_.tok[b]) *
548
+ tables_.lookahead + off;
549
+ ++counts[static_cast<size_t>(key)];
550
+ }
551
+ });
552
+ }
553
+ }
554
+ for (int64_t key = 0; key < universe; ++key) {
555
+ int64_t count = 0;
556
+ for (const auto& counts : local) count += counts[static_cast<size_t>(key)];
557
+ if (!count) continue;
558
+ const int32_t offset = static_cast<int32_t>(key % tables_.lookahead);
559
+ const int64_t pair = key / tables_.lookahead;
560
+ const int32_t left = static_cast<int32_t>(pair / base);
561
+ const int32_t right = static_cast<int32_t>(pair % base);
562
+ stats_[rule_key(left, right, offset)].raw += count;
563
+ }
564
+ return;
565
+ }
566
+ const int64_t block = int64_t{1} << 22;
567
+ std::vector<std::vector<uint64_t>> local(threads_);
568
+ std::vector<uint64_t> keys;
569
+ for (int64_t start = 0; start < n; start += block) {
570
+ const int64_t stop = std::min(n, start + block);
571
+ #ifdef _OPENMP
572
+ #pragma omp parallel num_threads(threads_)
573
+ #endif
574
+ {
575
+ #ifdef _OPENMP
576
+ auto& out = local[omp_get_thread_num()];
577
+ #else
578
+ auto& out = local[0];
579
+ #endif
580
+ out.clear();
581
+ #ifdef _OPENMP
582
+ #pragma omp for schedule(static)
583
+ #endif
584
+ for (int64_t i = start; i < stop; ++i) {
585
+ const InstanceId ii = static_cast<InstanceId>(i);
586
+ st_.partners(ii, allow_zero_, [&](InstanceId a, InstanceId b, int32_t off) {
587
+ if (a == ii) out.push_back(rule_key(st_.tok[a], st_.tok[b], off));
588
+ });
589
+ }
590
+ }
591
+ keys.clear();
592
+ for (auto& v : local) keys.insert(keys.end(), v.begin(), v.end());
593
+ AC2_PARALLEL_SORT(keys.begin(), keys.end());
594
+ for (size_t k = 0; k < keys.size();) {
595
+ size_t e = k;
596
+ while (e < keys.size() && keys[e] == keys[k]) ++e;
597
+ stats_[keys[k]].raw += static_cast<int64_t>(e - k);
598
+ k = e;
599
+ }
600
+ }
601
+ }
602
+
603
+ private:
604
+ // Reclaim dead slots without changing token order, coverage, or candidate statistics.
605
+ void compact_instances() {
606
+ std::vector<InstanceId> remap(st_.tok.size(), kNoInstance);
607
+ std::vector<int32_t> tokens;
608
+ std::vector<int64_t> roots;
609
+ std::vector<uint8_t> alive;
610
+ tokens.reserve(static_cast<size_t>(live_));
611
+ roots.reserve(static_cast<size_t>(live_));
612
+ alive.reserve(static_cast<size_t>(live_));
613
+ for (size_t old = 0; old < st_.tok.size(); ++old) {
614
+ if (!st_.alive[old]) continue;
615
+ remap[old] = static_cast<InstanceId>(tokens.size());
616
+ tokens.push_back(st_.tok[old]);
617
+ roots.push_back(st_.root[old]);
618
+ alive.push_back(1);
619
+ }
620
+ if (tokens.size() != static_cast<size_t>(live_))
621
+ throw std::runtime_error("instance compaction live count mismatch");
622
+ for (auto& occ : occurrence_) {
623
+ size_t next = 0;
624
+ for (InstanceId old : occ) {
625
+ const InstanceId mapped = remap[static_cast<size_t>(old)];
626
+ if (mapped != kNoInstance) occ[next++] = mapped;
627
+ }
628
+ occ.resize(next);
629
+ }
630
+ for (auto& slot : st_.member)
631
+ if (slot != kNoInstance) slot = remap[static_cast<size_t>(slot)];
632
+ st_.tok.swap(tokens);
633
+ st_.root.swap(roots);
634
+ st_.alive.swap(alive);
635
+ std::fill(dead_.begin(), dead_.end(), 0);
636
+ ++compactions_;
637
+ }
638
+
639
+ void change(uint64_t k, int64_t delta, StatMap& table, std::vector<uint64_t>& dirty) {
640
+ Stat& s = table[k];
641
+ s.raw += delta;
642
+ if (delta > 0) s.added += delta;
643
+ ++s.version;
644
+ if (s.dirty_epoch != epoch_) {
645
+ s.dirty_epoch = epoch_;
646
+ dirty.push_back(k);
647
+ }
648
+ }
649
+
650
+ std::vector<InstanceId>& lefts(int32_t left) {
651
+ auto& occ = occurrence_[left];
652
+ // Lists are root-sorted by construction: atoms are sorted once, and every child list
653
+ // is appended in the root order of its single merge batch.
654
+ if (dead_[left] * 4 > static_cast<int64_t>(occ.size())) {
655
+ occ.erase(std::remove_if(occ.begin(), occ.end(), [&](InstanceId i) { return !st_.alive[i]; }),
656
+ occ.end());
657
+ dead_[left] = 0;
658
+ }
659
+ return occ;
660
+ }
661
+
662
+ static double seconds_since(std::chrono::steady_clock::time_point t) {
663
+ return std::chrono::duration<double>(std::chrono::steady_clock::now() - t).count();
664
+ }
665
+
666
+ // Candidate (a, b) pairs are visited in increasing root order of a by scanning the
667
+ // shorter occurrence list; the partner is one cell lookup away in either direction.
668
+ const std::vector<InstanceId>& scanned(int32_t left, int32_t right, bool& forward) const {
669
+ const auto& lo = occurrence_[left];
670
+ const auto& ro = occurrence_[right];
671
+ forward = left == right || lo.size() <= ro.size();
672
+ return forward ? lo : ro;
673
+ }
674
+
675
+ template <class F>
676
+ void pairs_in(int32_t left, int32_t right, int32_t off, size_t begin, size_t end,
677
+ F&& visit) const {
678
+ bool forward;
679
+ const auto& scan = scanned(left, right, forward);
680
+ for (size_t i = begin; i < end; ++i) {
681
+ const InstanceId x = scan[i];
682
+ if (!st_.alive[x]) continue;
683
+ if (forward) {
684
+ const InstanceId b = st_.right_of(x, right, off);
685
+ if (b != kNoInstance) visit(x, b);
686
+ } else {
687
+ const InstanceId a = st_.left_of(x, left, off);
688
+ if (a != kNoInstance) visit(a, x);
689
+ }
690
+ }
691
+ }
692
+
693
+ // Cuts of a root-sorted list into blocks of about ``grain`` entries at safe frames, so
694
+ // per-block replay (self-rule chains included) equals one sequential pass.
695
+ std::vector<size_t> blocks(const std::vector<InstanceId>& scan, size_t grain) const {
696
+ std::vector<size_t> cuts{0};
697
+ for (size_t next = grain; next < scan.size();) {
698
+ auto frame = std::upper_bound(safe_.begin(), safe_.end(), st_.root[scan[next]]);
699
+ if (frame == safe_.end()) break;
700
+ const size_t at = static_cast<size_t>(
701
+ std::lower_bound(scan.begin() + next, scan.end(), *frame,
702
+ [&](InstanceId i, int64_t f) { return st_.root[i] < f; }) -
703
+ scan.begin());
704
+ if (at >= scan.size()) break;
705
+ cuts.push_back(at);
706
+ next = at + grain;
707
+ }
708
+ cuts.push_back(scan.size());
709
+ return cuts;
710
+ }
711
+
712
+ // Candidate pairs in scan order, gathered by blocks in parallel.
713
+ std::vector<std::pair<InstanceId, InstanceId>> collect(int32_t left, int32_t right, int32_t off) const {
714
+ bool forward;
715
+ const std::vector<size_t> cuts = blocks(scanned(left, right, forward), 1 << 16);
716
+ std::vector<std::vector<std::pair<InstanceId, InstanceId>>> parts(cuts.size() - 1);
717
+ #ifdef _OPENMP
718
+ #pragma omp parallel for schedule(dynamic, 1) num_threads(threads_) if (parts.size() > 1)
719
+ #endif
720
+ for (size_t k = 0; k < parts.size(); ++k)
721
+ pairs_in(left, right, off, cuts[k], cuts[k + 1],
722
+ [&](InstanceId a, InstanceId b) { parts[k].emplace_back(a, b); });
723
+ std::vector<std::pair<InstanceId, InstanceId>> pairs;
724
+ for (auto& part : parts) pairs.insert(pairs.end(), part.begin(), part.end());
725
+ return pairs;
726
+ }
727
+
728
+ // Exact count of one block under the real merge's matching and validity tests.
729
+ int64_t count_exact(int32_t left, int32_t right, int32_t off, size_t begin, size_t end) const {
730
+ int64_t n = 0;
731
+ // Self rules chain: a may be the right half of a recent match (roots increase).
732
+ std::vector<InstanceId> recent;
733
+ size_t head = 0;
734
+ pairs_in(left, right, off, begin, end, [&](InstanceId a, InstanceId b) {
735
+ if (left == right) {
736
+ while (head < recent.size() && st_.root[recent[head]] < st_.root[a]) ++head;
737
+ if (std::find(recent.begin() + head, recent.end(), a) != recent.end()) return;
738
+ }
739
+ if (!st_.valid(a, b, off)) return;
740
+ if (left == right) recent.push_back(b);
741
+ ++n;
742
+ });
743
+ return n;
744
+ }
745
+
746
+ // Certifies a batch of keys; every key is split into blocks so one large list does
747
+ // not serialize the batch. Read-only on shared state.
748
+ void certify(std::vector<Entry>& batch) {
749
+ struct Timer {
750
+ double& sink;
751
+ std::chrono::steady_clock::time_point t;
752
+ ~Timer() { sink += seconds_since(t); }
753
+ } timer{dry_seconds_, std::chrono::steady_clock::now()};
754
+ dry_runs_ += static_cast<int64_t>(batch.size());
755
+ struct Task {
756
+ size_t key, begin, end;
757
+ };
758
+ std::vector<Task> tasks;
759
+ for (size_t k = 0; k < batch.size(); ++k) {
760
+ int32_t l, r, o;
761
+ unpack(batch[k].key, l, r, o);
762
+ lefts(l); // compaction mutates lists: do it before the parallel region
763
+ lefts(r);
764
+ bool forward;
765
+ const std::vector<size_t> cuts = blocks(scanned(l, r, forward), 1 << 15);
766
+ for (size_t c = 0; c + 1 < cuts.size(); ++c) tasks.push_back({k, cuts[c], cuts[c + 1]});
767
+ }
768
+ std::vector<int64_t> partial(tasks.size());
769
+ #ifdef _OPENMP
770
+ #pragma omp parallel for schedule(dynamic, 1) num_threads(threads_)
771
+ #endif
772
+ for (size_t t = 0; t < tasks.size(); ++t) {
773
+ int32_t l, r, o;
774
+ unpack(batch[tasks[t].key].key, l, r, o);
775
+ partial[t] = count_exact(l, r, o, tasks[t].begin, tasks[t].end);
776
+ }
777
+ std::vector<int64_t> exact(batch.size(), 0);
778
+ for (size_t t = 0; t < tasks.size(); ++t) exact[tasks[t].key] += partial[t];
779
+ for (size_t k = 0; k < batch.size(); ++k) {
780
+ Stat& s = stats_[batch[k].key];
781
+ s.exact = exact[k];
782
+ s.exact_version = s.version;
783
+ s.added_at_exact = s.added;
784
+ if (s.exact >= min_count_) queue(batch[k].key, s, s.exact);
785
+ }
786
+ }
787
+
788
+ // Pairs are applied in root order; ranges split at safe frames are independent, so
789
+ // each is replayed sequentially by one thread with exactly the serial outcome.
790
+ void apply(int32_t left, int32_t right, int32_t off, int32_t child) {
791
+ struct Timer {
792
+ double& sink;
793
+ std::chrono::steady_clock::time_point t;
794
+ ~Timer() { sink += seconds_since(t); }
795
+ } timer{apply_seconds_, std::chrono::steady_clock::now()};
796
+ lefts(left);
797
+ lefts(right);
798
+ // Candidates are collected first: applying merges kills instances in both lists.
799
+ const auto collect_start = std::chrono::steady_clock::now();
800
+ std::vector<std::pair<InstanceId, InstanceId>> pairs = collect(left, right, off);
801
+ if (st_.tok.size() + pairs.size() > static_cast<size_t>(max_instances_)) {
802
+ pairs.clear();
803
+ pairs.shrink_to_fit();
804
+ compact_instances();
805
+ pairs = collect(left, right, off);
806
+ if (st_.tok.size() + pairs.size() > static_cast<size_t>(max_instances_))
807
+ throw std::length_error("live instance capacity exceeded after compaction");
808
+ }
809
+ collect_seconds_ += seconds_since(collect_start);
810
+ const std::vector<size_t> cuts = split(pairs);
811
+ const size_t groups = cuts.size() - 1;
812
+ const InstanceId base = st_.reserve(pairs.size());
813
+ if (pool_.size() < groups) pool_.resize(groups, std::vector<DeltaMap>(kShards));
814
+ dirty_shards_.resize(kShards);
815
+ std::vector<std::vector<InstanceId>> born(groups);
816
+ std::vector<int64_t> applied(groups, 0), rejected(groups, 0);
817
+ const auto loop_start = std::chrono::steady_clock::now();
818
+ #ifdef _OPENMP
819
+ #pragma omp parallel for schedule(dynamic, 1) num_threads(std::min<size_t>(threads_, groups))
820
+ #endif
821
+ for (size_t g = 0; g < groups; ++g) {
822
+ auto& delta = pool_[g];
823
+ auto add = [&](uint64_t key, int64_t d) { delta[shard_of(key)].add(key, d); };
824
+ for (size_t k = cuts[g]; k < cuts[g + 1]; ++k) {
825
+ const InstanceId a = pairs[k].first, b = pairs[k].second;
826
+ if (!st_.alive[a] || !st_.alive[b]) continue;
827
+ if (!st_.valid(a, b, off)) {
828
+ ++rejected[g];
829
+ continue;
830
+ }
831
+ st_.partners(a, allow_zero_, [&](InstanceId x, InstanceId y, int32_t o) {
832
+ add(rule_key(st_.tok[x], st_.tok[y], o), -1);
833
+ });
834
+ st_.partners(b, allow_zero_, [&](InstanceId x, InstanceId y, int32_t o) {
835
+ if (x != a && y != a) add(rule_key(st_.tok[x], st_.tok[y], o), -1);
836
+ });
837
+ const int64_t r = st_.root[a];
838
+ st_.kill(a);
839
+ st_.kill(b);
840
+ const InstanceId m = base + static_cast<InstanceId>(k);
841
+ st_.tok[m] = child;
842
+ st_.root[m] = r;
843
+ st_.place(m);
844
+ born[g].push_back(m);
845
+ st_.partners(m, allow_zero_, [&](InstanceId x, InstanceId y, int32_t o) {
846
+ add(rule_key(st_.tok[x], st_.tok[y], o), +1);
847
+ });
848
+ ++applied[g];
849
+ }
850
+ }
851
+ (groups > 1 ? parallel_seconds_ : serial_seconds_) += seconds_since(loop_start);
852
+ const auto merge_start = std::chrono::steady_clock::now();
853
+ std::vector<int64_t> changes(kShards, 0);
854
+ #ifdef _OPENMP
855
+ #pragma omp parallel for schedule(dynamic, 1) num_threads(threads_) if (groups > 1)
856
+ #endif
857
+ for (size_t p = 0; p < kShards; ++p) {
858
+ StatMap& table = stats_.shard(p);
859
+ for (size_t g = 0; g < groups; ++g) {
860
+ pool_[g][p].each([&](uint64_t k, int64_t d) {
861
+ change(k, d, table, dirty_shards_[p]);
862
+ ++changes[p];
863
+ });
864
+ pool_[g][p].clear();
865
+ }
866
+ }
867
+ for (size_t p = 0; p < kShards; ++p) {
868
+ dirty_.insert(dirty_.end(), dirty_shards_[p].begin(), dirty_shards_[p].end());
869
+ dirty_shards_[p].clear();
870
+ changes_ += changes[p];
871
+ }
872
+ for (size_t g = 0; g < groups; ++g) {
873
+ auto& occ = occurrence_[child];
874
+ occ.insert(occ.end(), born[g].begin(), born[g].end());
875
+ dead_[left] += applied[g];
876
+ dead_[right] += applied[g];
877
+ applied_ += applied[g];
878
+ rejected_ += rejected[g];
879
+ live_ -= applied[g];
880
+ }
881
+ merge_seconds_ += seconds_since(merge_start);
882
+ }
883
+
884
+ // Cut root-ordered pairs into contiguous groups at safe frames.
885
+ std::vector<size_t> split(const std::vector<std::pair<InstanceId, InstanceId>>& pairs) const {
886
+ std::vector<size_t> cuts{0};
887
+ const size_t n = pairs.size();
888
+ const size_t groups = std::max<size_t>(1, std::min<size_t>(threads_, n / kApplyGrain));
889
+ for (size_t g = 1; g < groups; ++g) {
890
+ const size_t target = std::max(cuts.back(), n * g / groups);
891
+ if (target >= n) break;
892
+ auto frame = std::upper_bound(safe_.begin(), safe_.end(), st_.root[pairs[target].first]);
893
+ if (frame == safe_.end()) break;
894
+ const size_t cut = static_cast<size_t>(
895
+ std::lower_bound(pairs.begin() + target, pairs.end(), *frame,
896
+ [&](const std::pair<InstanceId, InstanceId>& p, int64_t f) {
897
+ return st_.root[p.first] < f;
898
+ }) -
899
+ pairs.begin());
900
+ if (cut > cuts.back() && cut < n) cuts.push_back(cut);
901
+ }
902
+ cuts.push_back(n);
903
+ return cuts;
904
+ }
905
+
906
+ static bool certified(const Stat& s) { return s.exact >= 0 && s.exact_version == s.version; }
907
+
908
+ static int64_t bound(const Stat& s) {
909
+ if (certified(s)) return s.exact;
910
+ return s.exact >= 0 ? std::min(s.raw, s.exact + s.added - s.added_at_exact) : s.raw;
911
+ }
912
+
913
+ void queue(uint64_t key, Stat& s, int64_t count) {
914
+ heap_.push({count, key, s.version});
915
+ s.live = true;
916
+ s.queued_version = s.version;
917
+ s.queued_count = count;
918
+ }
919
+
920
+ bool take(Entry& e, Stat*& s) {
921
+ e = heap_.top();
922
+ heap_.pop();
923
+ s = stats_.find(e.key);
924
+ if (!s || s->retired || !s->live || e.version != s->queued_version) return false;
925
+ s->live = false;
926
+ if (e.version == s->version) return true;
927
+ const int64_t b = bound(*s);
928
+ if (b > 0) queue(e.key, *s, b);
929
+ return false;
930
+ }
931
+
932
+ bool current_certified(const Entry& e) {
933
+ const Stat* s = stats_.find(e.key);
934
+ return s && !s->retired && s->live && e.version == s->queued_version &&
935
+ e.version == s->version && certified(*s);
936
+ }
937
+
938
+ void run(bool progress) {
939
+ live_ = initial_instances_;
940
+ while (tables_.size() < vocab_size_ && !heap_.empty()) {
941
+ Entry top;
942
+ Stat* found;
943
+ if (!take(top, found)) continue;
944
+ Stat& s = *found;
945
+ if (!certified(s)) {
946
+ std::vector<Entry> batch{top};
947
+ while (!heap_.empty() && batch.size() < kCertifyBatch) {
948
+ if (current_certified(heap_.top())) break;
949
+ Entry e;
950
+ Stat* next;
951
+ if (take(e, next)) batch.push_back(e);
952
+ }
953
+ certify(batch);
954
+ continue;
955
+ }
956
+ if (top.count != s.exact) continue;
957
+ if (s.exact < min_count_) continue;
958
+ int32_t l, r, o;
959
+ unpack(top.key, l, r, o);
960
+ std::vector<uint32_t> m;
961
+ if (!tables_.compose(l, r, o, m)) {
962
+ s.exact = 0;
963
+ continue;
964
+ }
965
+ const int32_t child = tables_.add_merge(l, r, o);
966
+ merges_.emplace_back(l, r, o);
967
+ s.retired = true;
968
+ dirty_.clear();
969
+ ++epoch_;
970
+ apply(l, r, o, child);
971
+ // Lazy decrease-key: only a grown bound needs a new entry now.
972
+ for (uint64_t k : dirty_) {
973
+ Stat& d = stats_[k];
974
+ if (d.retired) continue;
975
+ const int64_t b = bound(d);
976
+ if (b > 0 && (!d.live || b > d.queued_count)) queue(k, d, b);
977
+ }
978
+ dirty_.clear();
979
+ if (progress && merges_.size() % 256 == 0) {
980
+ py::gil_scoped_acquire acquire;
981
+ py::print("sparse temporal merges", merges_.size(), "live", live_, py::arg("flush") = true);
982
+ }
983
+ }
984
+ }
985
+
986
+ Tables tables_;
987
+ Instances st_;
988
+ int64_t vocab_size_;
989
+ bool allow_zero_;
990
+ bool dense_census_;
991
+ int64_t min_count_;
992
+ int64_t max_instances_;
993
+ std::vector<std::vector<InstanceId>> occurrence_;
994
+ ShardedStats stats_;
995
+ std::vector<std::vector<DeltaMap>> pool_; // [apply group][shard]
996
+ std::vector<std::vector<uint64_t>> dirty_shards_; // [shard]
997
+ std::priority_queue<Entry> heap_;
998
+ std::vector<uint64_t> dirty_;
999
+ std::vector<std::tuple<int32_t, int32_t, int32_t>> merges_;
1000
+ std::vector<int64_t> dead_;
1001
+ std::vector<int64_t> safe_;
1002
+ uint32_t epoch_ = 0;
1003
+ int threads_ = 1;
1004
+ int64_t initial_instances_ = 0, live_ = 0, dry_runs_ = 0, rejected_ = 0, applied_ = 0;
1005
+ int64_t compactions_ = 0;
1006
+ size_t initial_types_ = 0;
1007
+ int64_t changes_ = 0;
1008
+ double census_seconds_ = 0, dry_seconds_ = 0, apply_seconds_ = 0, collect_seconds_ = 0,
1009
+ parallel_seconds_ = 0, serial_seconds_ = 0, merge_seconds_ = 0;
1010
+ };
1011
+
1012
+ struct Spatial {
1013
+ std::vector<int64_t> offsets;
1014
+ std::vector<uint32_t> support;
1015
+ std::vector<std::vector<int32_t>> expansion; // token -> value per sparse slot (-1 absent)
1016
+ std::unordered_map<uint64_t, std::pair<int32_t, int32_t>> rules; // (lo, hi) -> (child, rank)
1017
+ int32_t dims = 0;
1018
+
1019
+ static uint64_t pair_key(int32_t a, int32_t b) {
1020
+ if (a > b) std::swap(a, b);
1021
+ return (static_cast<uint64_t>(a) << 32) | static_cast<uint32_t>(b);
1022
+ }
1023
+
1024
+ void init(const std::vector<int64_t>& bins, const std::vector<std::pair<int32_t, int32_t>>& merges) {
1025
+ dims = static_cast<int32_t>(bins.size());
1026
+ offsets.assign(1, 0);
1027
+ for (int64_t b : bins) {
1028
+ if (b < 1) throw std::invalid_argument("every sparse slot needs at least one value");
1029
+ offsets.push_back(offsets.back() + b);
1030
+ }
1031
+ for (int32_t d = 0; d < dims; ++d)
1032
+ for (int64_t v = 0; v < bins[d]; ++v) {
1033
+ support.push_back(1u << d);
1034
+ std::vector<int32_t> e(dims, -1);
1035
+ e[d] = static_cast<int32_t>(v);
1036
+ expansion.push_back(std::move(e));
1037
+ }
1038
+ const int32_t atoms = static_cast<int32_t>(support.size());
1039
+ for (size_t r = 0; r < merges.size(); ++r) {
1040
+ const auto [a, b] = merges[r];
1041
+ const int32_t child = atoms + static_cast<int32_t>(r);
1042
+ if (a < 0 || b < 0 || a >= child || b >= child || (support[a] & support[b]))
1043
+ throw std::invalid_argument("invalid spatial merge");
1044
+ support.push_back(support[a] | support[b]);
1045
+ std::vector<int32_t> e(dims);
1046
+ for (int32_t d = 0; d < dims; ++d) e[d] = std::max(expansion[a][d], expansion[b][d]);
1047
+ expansion.push_back(std::move(e));
1048
+ rules.emplace(pair_key(a, b), std::make_pair(child, static_cast<int32_t>(r)));
1049
+ }
1050
+ }
1051
+
1052
+ std::vector<int32_t> encode(const int32_t* row) const {
1053
+ std::set<int32_t> tokens;
1054
+ for (int32_t d = 0; d < dims; ++d)
1055
+ if (row[d] >= 0) tokens.insert(static_cast<int32_t>(offsets[d] + row[d]));
1056
+ using Item = std::tuple<int32_t, int32_t, int32_t, int32_t>;
1057
+ std::priority_queue<Item, std::vector<Item>, std::greater<Item>> heap;
1058
+ auto offer = [&](int32_t a, int32_t b) {
1059
+ auto it = rules.find(pair_key(a, b));
1060
+ if (it != rules.end())
1061
+ heap.emplace(it->second.second, it->second.first, std::min(a, b), std::max(a, b));
1062
+ };
1063
+ for (auto i = tokens.begin(); i != tokens.end(); ++i)
1064
+ for (auto j = std::next(i); j != tokens.end(); ++j) offer(*i, *j);
1065
+ while (!heap.empty()) {
1066
+ const auto [rank, child, left, right] = heap.top();
1067
+ heap.pop();
1068
+ if (!tokens.count(left) || !tokens.count(right)) continue;
1069
+ tokens.erase(left);
1070
+ tokens.erase(right);
1071
+ for (int32_t other : tokens) offer(child, other);
1072
+ tokens.insert(child);
1073
+ }
1074
+ return {tokens.begin(), tokens.end()};
1075
+ }
1076
+ };
1077
+
1078
+ // Runtime codec over dense primitive cells ``(T, D)`` with ``-1`` for inactive slots.
1079
+ class SparseCodec {
1080
+ public:
1081
+ SparseCodec(std::vector<int64_t> bins, std::vector<int64_t> zero,
1082
+ std::vector<std::pair<int32_t, int32_t>> spatial_merges, int32_t lookahead,
1083
+ std::vector<std::tuple<int32_t, int32_t, int32_t>> temporal_merges)
1084
+ : bins_(std::move(bins)), zero_(std::move(zero)) {
1085
+ if (bins_.empty() || bins_.size() != zero_.size())
1086
+ throw std::invalid_argument("bins and zero must have one entry per slot");
1087
+ dims_ = static_cast<int32_t>(bins_.size());
1088
+ std::vector<int64_t> sparse;
1089
+ for (int32_t d = 0; d < dims_; ++d) {
1090
+ // -2 retains every dense value; -1 change-codes an absolute slot.
1091
+ if (zero_[d] >= bins_[d] || zero_[d] < -2) throw std::invalid_argument("zero index");
1092
+ sparse.push_back(bins_[d] - (zero_[d] >= 0));
1093
+ }
1094
+ sparse.push_back(1); // E
1095
+ spatial_.init(sparse, spatial_merges);
1096
+ tables_.init(spatial_.support, dims_ + 1, lookahead);
1097
+ for (size_t r = 0; r < temporal_merges.size(); ++r) {
1098
+ auto [a, b, o] = temporal_merges[r];
1099
+ if (a < 0 || b < 0 || a >= tables_.size() || b >= tables_.size())
1100
+ throw std::invalid_argument("merge order");
1101
+ tables_.add_merge(a, b, o);
1102
+ if (!rank_.emplace(rule_key(a, b, o), static_cast<int32_t>(r)).second)
1103
+ throw std::invalid_argument("duplicate temporal rule");
1104
+ }
1105
+ }
1106
+
1107
+ int32_t vocab_size() const { return tables_.size(); }
1108
+ int32_t spatial_vocab_size() const { return static_cast<int32_t>(spatial_.support.size()); }
1109
+
1110
+ std::vector<std::vector<int32_t>> encode_batch(
1111
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& cells,
1112
+ int threads) const {
1113
+ if (cells.ndim() != 3 || cells.shape(2) != dims_ || cells.shape(0) < 1 || cells.shape(1) < 1)
1114
+ throw std::invalid_argument("cells must have shape (B, T, D) with B, T >= 1");
1115
+ const int64_t batch = cells.shape(0), steps = cells.shape(1);
1116
+ const int32_t* data = cells.data();
1117
+ std::vector<std::vector<int32_t>> out(batch);
1118
+ {
1119
+ py::gil_scoped_release release;
1120
+ ac2::parallel_for(batch, threads, [&](int64_t b) {
1121
+ out[b] = encode_one(data + b * steps * dims_, steps);
1122
+ });
1123
+ }
1124
+ return out;
1125
+ }
1126
+
1127
+ py::array_t<int32_t> decode_batch(const std::vector<std::vector<int32_t>>& rows,
1128
+ int64_t horizon, const std::vector<bool>& active) const {
1129
+ if (horizon < 1) throw std::invalid_argument("horizon must be positive");
1130
+ if (static_cast<int32_t>(active.size()) != dims_)
1131
+ throw std::invalid_argument("active must have one flag per slot");
1132
+ py::array_t<int32_t> out({static_cast<int64_t>(rows.size()), horizon, int64_t{dims_}});
1133
+ int32_t* data = out.mutable_data();
1134
+ for (size_t k = 0; k < rows.size(); ++k) decode_one(rows[k], horizon, active, data + k * horizon * dims_);
1135
+ return out;
1136
+ }
1137
+
1138
+ // Spatial tokens of sparse frames ``(N, D + 1)``: per-frame counts and flat tokens.
1139
+ py::tuple spatial_encode(
1140
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& frames) const {
1141
+ if (frames.ndim() != 2 || frames.shape(1) != dims_ + 1)
1142
+ throw std::invalid_argument("frames must have shape (N, D + 1)");
1143
+ const int64_t n = frames.shape(0);
1144
+ py::array_t<int64_t> counts(n);
1145
+ std::vector<int32_t> flat;
1146
+ for (int64_t i = 0; i < n; ++i) {
1147
+ const int32_t* row = frames.data() + i * (dims_ + 1);
1148
+ for (int32_t d = 0; d <= dims_; ++d)
1149
+ if (row[d] >= static_cast<int32_t>(spatial_.offsets[d + 1] - spatial_.offsets[d]))
1150
+ throw std::invalid_argument("sparse value out of range");
1151
+ const auto tokens = spatial_.encode(row);
1152
+ counts.mutable_data()[i] = static_cast<int64_t>(tokens.size());
1153
+ flat.insert(flat.end(), tokens.begin(), tokens.end());
1154
+ }
1155
+ return py::make_tuple(counts, py::array_t<int32_t>(flat.size(), flat.data()));
1156
+ }
1157
+
1158
+ std::vector<std::vector<std::pair<int32_t, int32_t>>> patterns() const { return tables_.pattern; }
1159
+
1160
+ private:
1161
+ // Dense chunk -> sparse frames (T, D + 1); validates slot activity and ranges.
1162
+ std::vector<int32_t> sparsify(const int32_t* cells, int64_t steps) const {
1163
+ const int32_t width = dims_ + 1;
1164
+ std::vector<int32_t> sparse(static_cast<size_t>(steps) * width, -1);
1165
+ int64_t last = -1;
1166
+ for (int32_t d = 0; d < dims_; ++d) {
1167
+ const bool active = cells[d] >= 0;
1168
+ for (int64_t t = 0; t < steps; ++t) {
1169
+ const int32_t v = cells[t * dims_ + d];
1170
+ if ((v >= 0) != active)
1171
+ throw std::invalid_argument("a slot must be active for the whole chunk or not at all");
1172
+ if (v >= bins_[d]) throw std::invalid_argument("cell outside the primitive range");
1173
+ if (!active) continue;
1174
+ int32_t s = -1;
1175
+ if (zero_[d] == -2) {
1176
+ s = v;
1177
+ } else if (zero_[d] < 0) {
1178
+ if (t == 0 || v != cells[(t - 1) * dims_ + d]) s = v;
1179
+ } else if (v != zero_[d]) {
1180
+ s = v - (v > zero_[d]);
1181
+ }
1182
+ sparse[t * width + d] = s;
1183
+ if (s >= 0) last = std::max(last, t);
1184
+ }
1185
+ }
1186
+ for (int64_t t = 0; t < last; ++t) sparse[t * width + dims_] = 0;
1187
+ return sparse;
1188
+ }
1189
+
1190
+ std::vector<int32_t> encode_one(const int32_t* cells, int64_t steps) const {
1191
+ const std::vector<int32_t> sparse = sparsify(cells, steps);
1192
+ Instances s;
1193
+ s.reset(&tables_, steps + tables_.lookahead);
1194
+ for (int64_t t = 0; t < steps; ++t)
1195
+ for (int32_t token : spatial_.encode(sparse.data() + t * (dims_ + 1))) s.add(token, t);
1196
+ using Cand = std::tuple<int32_t, int64_t, int32_t, int64_t, InstanceId, InstanceId, int32_t>;
1197
+ std::priority_queue<Cand, std::vector<Cand>, std::greater<Cand>> heap;
1198
+ auto offer = [&](InstanceId i) {
1199
+ s.partners(i, true, [&](InstanceId a, InstanceId b, int32_t off) {
1200
+ auto it = rank_.find(rule_key(s.tok[a], s.tok[b], off));
1201
+ if (it == rank_.end()) return;
1202
+ heap.emplace(it->second, s.root[a], tables_.rdim[s.tok[a]], s.root[b], a, b, off);
1203
+ });
1204
+ };
1205
+ for (size_t i = 0; i < s.tok.size(); ++i) offer(static_cast<InstanceId>(i));
1206
+ while (!heap.empty()) {
1207
+ auto [r, ra, da, rb, a, b, off] = heap.top();
1208
+ heap.pop();
1209
+ if (!s.alive[a] || !s.alive[b] || !s.valid(a, b, off)) continue;
1210
+ const int64_t root = s.root[a];
1211
+ s.kill(a);
1212
+ s.kill(b);
1213
+ offer(s.add(tables_.base + r, root));
1214
+ }
1215
+ return s.serialize();
1216
+ }
1217
+
1218
+ void decode_one(const std::vector<int32_t>& row, int64_t horizon,
1219
+ const std::vector<bool>& active, int32_t* cells) const {
1220
+ const int32_t L = tables_.lookahead, width = dims_ + 1;
1221
+ uint32_t allowed = 1u << dims_;
1222
+ for (int32_t d = 0; d < dims_; ++d)
1223
+ if (active[d]) allowed |= 1u << d;
1224
+ std::vector<uint32_t> cover(horizon + L, 0);
1225
+ std::vector<int32_t> sparse(static_cast<size_t>(horizon) * width, -1);
1226
+ int64_t front = 0;
1227
+ std::vector<uint32_t> own(L, 0);
1228
+ std::vector<int32_t> frames;
1229
+ for (int32_t token : row) {
1230
+ if (token < 0 || token >= tables_.size()) throw std::invalid_argument("token outside vocab");
1231
+ if (front >= horizon) throw std::invalid_argument("token after the last open frame");
1232
+ frames.clear();
1233
+ for (auto [o, t] : tables_.pattern[token]) {
1234
+ const int64_t u = front + o;
1235
+ const uint32_t m = spatial_.support[t];
1236
+ if (u >= horizon) throw std::invalid_argument("token exceeds horizon");
1237
+ if (m & ~allowed) throw std::invalid_argument("token touches an inactive slot");
1238
+ if (m & own[o]) throw std::invalid_argument("overlapping token supports");
1239
+ if ((m >> dims_ & 1u) && u == horizon - 1)
1240
+ throw std::invalid_argument("end-of-frame atom on the last frame");
1241
+ if (!own[o]) frames.push_back(o);
1242
+ own[o] |= m;
1243
+ for (int32_t d = 0; d < width; ++d)
1244
+ if (spatial_.expansion[t][d] >= 0) sparse[u * width + d] = spatial_.expansion[t][d];
1245
+ }
1246
+ for (int32_t o : frames) {
1247
+ const int64_t u = front + o;
1248
+ if (cover[u] & own[o]) throw std::invalid_argument("overlapping token supports");
1249
+ if (cover[u] >> dims_ & 1u) throw std::invalid_argument("token touches a closed frame");
1250
+ cover[u] |= own[o];
1251
+ own[o] = 0;
1252
+ }
1253
+ while (front < horizon && (cover[front] >> dims_ & 1u)) ++front;
1254
+ }
1255
+ for (int32_t d = 0; d < dims_; ++d) {
1256
+ int32_t carried = -1;
1257
+ for (int64_t t = 0; t < horizon; ++t) {
1258
+ const int32_t v = sparse[t * width + d];
1259
+ int32_t& out = cells[t * dims_ + d];
1260
+ if (!active[d]) {
1261
+ out = -1;
1262
+ } else if (zero_[d] == -2) {
1263
+ if (v < 0) throw std::invalid_argument("dense active slot is missing");
1264
+ out = v;
1265
+ } else if (zero_[d] < 0) {
1266
+ if (v >= 0) carried = v;
1267
+ if (carried < 0) throw std::invalid_argument("an absolute slot must be stated on frame 0");
1268
+ out = carried;
1269
+ } else {
1270
+ out = v < 0 ? static_cast<int32_t>(zero_[d]) : v + (v >= zero_[d]);
1271
+ }
1272
+ }
1273
+ }
1274
+ }
1275
+
1276
+ std::vector<int64_t> bins_, zero_;
1277
+ int32_t dims_ = 0;
1278
+ Spatial spatial_;
1279
+ Tables tables_;
1280
+ std::unordered_map<uint64_t, int32_t> rank_;
1281
+ };
1282
+
1283
+ } // namespace
1284
+
1285
+ void register_sparse(py::module_& m) {
1286
+ py::class_<Trainer>(m, "SparseTrainer")
1287
+ .def(py::init<std::vector<uint32_t>, int32_t, int32_t, int64_t, bool, int64_t, int, bool, int64_t>(),
1288
+ py::arg("spatial_support"), py::arg("dims"), py::arg("lookahead"),
1289
+ py::arg("vocab_size"), py::arg("allow_zero_offset"), py::arg("min_count") = 2,
1290
+ py::arg("threads") = 0, py::arg("dense_census") = false,
1291
+ py::arg("max_instances") = UINT32_MAX)
1292
+ .def("set_instances", &Trainer::set_instances)
1293
+ .def("train", &Trainer::train, py::arg("progress") = false)
1294
+ .def("merges", &Trainer::merges);
1295
+ py::class_<SparseCodec>(m, "SparseCodec")
1296
+ .def(py::init<std::vector<int64_t>, std::vector<int64_t>,
1297
+ std::vector<std::pair<int32_t, int32_t>>, int32_t,
1298
+ std::vector<std::tuple<int32_t, int32_t, int32_t>>>(),
1299
+ py::arg("bins"), py::arg("zero"), py::arg("spatial_merges"), py::arg("lookahead"),
1300
+ py::arg("temporal_merges"))
1301
+ .def_property_readonly("vocab_size", &SparseCodec::vocab_size)
1302
+ .def_property_readonly("spatial_vocab_size", &SparseCodec::spatial_vocab_size)
1303
+ .def("encode_batch", &SparseCodec::encode_batch, py::arg("cells"), py::arg("threads") = 0)
1304
+ .def("decode_batch", &SparseCodec::decode_batch, py::arg("rows"), py::arg("horizon"),
1305
+ py::arg("active"))
1306
+ .def("spatial_encode", &SparseCodec::spatial_encode, py::arg("frames"))
1307
+ .def("patterns", &SparseCodec::patterns);
1308
+ }
actioncodec2_kernels/spatial.cpp ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ #include <pybind11/pybind11.h>
3
+ #include <pybind11/numpy.h>
4
+ #include <pybind11/stl.h>
5
+
6
+ #include <algorithm>
7
+ #include <cstdint>
8
+ #include <vector>
9
+
10
+ #ifdef _OPENMP
11
+ #include <omp.h>
12
+ #endif
13
+
14
+ #include "common.hpp"
15
+
16
+ namespace py = pybind11;
17
+
18
+ namespace {
19
+
20
+ inline uint64_t pair_key(int32_t left, int32_t right) {
21
+ uint32_t low = static_cast<uint32_t>(left < right ? left : right);
22
+ uint32_t high = static_cast<uint32_t>(left < right ? right : left);
23
+ return (static_cast<uint64_t>(low) << 32) | high;
24
+ }
25
+
26
+ struct Corpus {
27
+ int64_t records = 0;
28
+ int32_t width = 0; // slots per record == action dimension
29
+ std::vector<int32_t> member; // [records * width], -1 when free
30
+ std::vector<int64_t> weights; // [records]
31
+ };
32
+
33
+ } // namespace
34
+
35
+ py::tuple train_spatial_native(py::array_t<int32_t, py::array::c_style | py::array::forcecast> codes,
36
+ py::array_t<int64_t, py::array::c_style | py::array::forcecast> weights,
37
+ const std::vector<int32_t>& bins, int64_t vocab_size,
38
+ int threads) {
39
+ auto codes_view = codes.unchecked<2>();
40
+ auto weights_view = weights.unchecked<1>();
41
+ const int64_t records = codes_view.shape(0);
42
+ const int32_t dimension = static_cast<int32_t>(codes_view.shape(1));
43
+ if (dimension != static_cast<int32_t>(bins.size())) {
44
+ throw std::invalid_argument("codes must have one column per bin count");
45
+ }
46
+ if (weights_view.shape(0) != records) {
47
+ throw std::invalid_argument("weights must have one entry per record");
48
+ }
49
+
50
+ std::vector<int32_t> offsets(dimension + 1, 0);
51
+ for (int32_t index = 0; index < dimension; ++index) offsets[index + 1] = offsets[index] + bins[index];
52
+ const int32_t atom_count = offsets[dimension];
53
+ if (vocab_size < atom_count) {
54
+ throw std::invalid_argument("vocab_size is below the number of primitive atoms");
55
+ }
56
+
57
+ #ifdef _OPENMP
58
+ const int thread_count = threads > 0 ? threads : omp_get_max_threads();
59
+ #else
60
+ (void)threads;
61
+ const int thread_count = 1;
62
+ #endif
63
+
64
+ Corpus corpus;
65
+ corpus.records = records;
66
+ corpus.width = dimension;
67
+ corpus.member.resize(static_cast<size_t>(records) * dimension);
68
+ corpus.weights.resize(records);
69
+ int64_t total_weight = 0;
70
+ int64_t initial_length = 0;
71
+ for (int64_t record = 0; record < records; ++record) {
72
+ corpus.weights[record] = weights_view(record);
73
+ total_weight += weights_view(record);
74
+ int32_t active = 0;
75
+ for (int32_t slot = 0; slot < dimension; ++slot) {
76
+ const int32_t value = codes_view(record, slot);
77
+ if (value < -1 || value >= bins[slot]) {
78
+ throw std::invalid_argument("a cell index is outside its coordinate's bin count");
79
+ }
80
+ if (value < 0) {
81
+ corpus.member[static_cast<size_t>(record) * dimension + slot] = -1;
82
+ } else {
83
+ corpus.member[static_cast<size_t>(record) * dimension + slot] = offsets[slot] + value;
84
+ ++active;
85
+ }
86
+ }
87
+ if (active == 0) throw std::invalid_argument("every record needs an active coordinate");
88
+ initial_length += weights_view(record) * active;
89
+ }
90
+
91
+ // Occurrence sets, kept sorted so intersections and differences are linear.
92
+ std::vector<std::vector<int32_t>> occurrence(atom_count);
93
+ for (int32_t slot = 0; slot < dimension; ++slot) {
94
+ for (int64_t record = 0; record < records; ++record) {
95
+ const int32_t token = corpus.member[static_cast<size_t>(record) * dimension + slot];
96
+ if (token >= 0) occurrence[token].push_back(static_cast<int32_t>(record));
97
+ }
98
+ }
99
+ for (auto& members : occurrence) std::sort(members.begin(), members.end());
100
+
101
+ // --- initial census -------------------------------------------------------
102
+ ac2::CounterMap counts(static_cast<size_t>(records) * 4 + 64);
103
+ {
104
+ std::vector<ac2::CounterMap> shards;
105
+ shards.reserve(thread_count);
106
+ for (int index = 0; index < thread_count; ++index) shards.emplace_back(1024);
107
+ #ifdef _OPENMP
108
+ #pragma omp parallel num_threads(thread_count)
109
+ #endif
110
+ {
111
+ #ifdef _OPENMP
112
+ const int thread = omp_get_thread_num();
113
+ #else
114
+ const int thread = 0;
115
+ #endif
116
+ ac2::CounterMap& local = shards[thread];
117
+ #ifdef _OPENMP
118
+ #pragma omp for schedule(static)
119
+ #endif
120
+ for (int64_t record = 0; record < records; ++record) {
121
+ const int32_t* row = &corpus.member[static_cast<size_t>(record) * dimension];
122
+ const int64_t weight = corpus.weights[record];
123
+ for (int32_t a = 0; a < dimension; ++a) {
124
+ if (row[a] < 0) continue;
125
+ for (int32_t b = a + 1; b < dimension; ++b) {
126
+ if (row[b] < 0) continue;
127
+ local.add(pair_key(row[a], row[b]), weight);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ for (auto& shard : shards) {
133
+ shard.for_each([&](uint64_t key, int64_t value) { counts.add(key, value); });
134
+ }
135
+ }
136
+
137
+ std::vector<ac2::HeapEntry> heap;
138
+ heap.reserve(counts.size());
139
+ counts.for_each([&](uint64_t key, int64_t value) { heap.push_back({value, key, 0}); });
140
+ std::make_heap(heap.begin(), heap.end(), ac2::HeapOrder{});
141
+ const size_t initial_pair_types = heap.size();
142
+
143
+ // --- greedy merging -------------------------------------------------------
144
+ std::vector<std::pair<int32_t, int32_t>> merges;
145
+ merges.reserve(static_cast<size_t>(vocab_size - atom_count));
146
+ int64_t length = initial_length; // total active tokens over the corpus
147
+ int64_t heap_pops = 0;
148
+ int64_t counter_updates = 0;
149
+
150
+ std::vector<int32_t> matched;
151
+ std::vector<int32_t> remainder;
152
+ std::vector<int32_t> affected;
153
+ std::vector<int32_t> stamp(static_cast<size_t>(vocab_size) + 16, 0);
154
+ int32_t generation = 0;
155
+ std::vector<ac2::CounterMap> deltas;
156
+ deltas.reserve(thread_count);
157
+ for (int index = 0; index < thread_count; ++index) deltas.emplace_back(1024);
158
+
159
+ while (static_cast<int64_t>(merges.size()) + atom_count < vocab_size) {
160
+ int32_t left = -1;
161
+ int32_t right = -1;
162
+ int64_t frequency = 0;
163
+ while (!heap.empty()) {
164
+ const ac2::HeapEntry top = ac2::heap_pop(heap);
165
+ ++heap_pops;
166
+ const int64_t current = counts.get(top.key);
167
+ if (current <= 0) continue;
168
+ if (current != top.count) continue; // a fresher entry for this key exists
169
+ left = static_cast<int32_t>(top.key >> 32);
170
+ right = static_cast<int32_t>(top.key & 0xffffffffu);
171
+ frequency = current;
172
+ break;
173
+ }
174
+ if (left < 0) break; // every record has collapsed to a single token
175
+
176
+ matched.clear();
177
+ std::set_intersection(occurrence[left].begin(), occurrence[left].end(),
178
+ occurrence[right].begin(), occurrence[right].end(),
179
+ std::back_inserter(matched));
180
+ if (matched.empty()) { // cannot happen while counters are exact
181
+ counts.add(pair_key(left, right), -frequency);
182
+ continue;
183
+ }
184
+
185
+ const int32_t child = static_cast<int32_t>(occurrence.size());
186
+ occurrence.emplace_back();
187
+
188
+ const bool parallel = matched.size() > 4096 && thread_count > 1;
189
+ if (parallel) {
190
+ for (auto& shard : deltas) shard = ac2::CounterMap(1024);
191
+ }
192
+ #ifdef _OPENMP
193
+ #pragma omp parallel if (parallel) num_threads(thread_count)
194
+ #endif
195
+ {
196
+ #ifdef _OPENMP
197
+ const int thread = parallel ? omp_get_thread_num() : 0;
198
+ #else
199
+ const int thread = 0;
200
+ #endif
201
+ ac2::CounterMap& local = parallel ? deltas[thread] : counts;
202
+ #ifdef _OPENMP
203
+ #pragma omp for schedule(static)
204
+ #endif
205
+ for (int64_t index = 0; index < static_cast<int64_t>(matched.size()); ++index) {
206
+ const int32_t record = matched[index];
207
+ const int32_t* row = &corpus.member[static_cast<size_t>(record) * dimension];
208
+ const int64_t weight = corpus.weights[record];
209
+ for (int32_t slot = 0; slot < dimension; ++slot) {
210
+ const int32_t other = row[slot];
211
+ if (other < 0 || other == left || other == right) continue;
212
+ local.add(pair_key(left, other), -weight);
213
+ local.add(pair_key(right, other), -weight);
214
+ local.add(pair_key(child, other), weight);
215
+ }
216
+ }
217
+ }
218
+ if (parallel) {
219
+ for (auto& shard : deltas) {
220
+ shard.for_each([&](uint64_t key, int64_t value) { counts.add(key, value); });
221
+ }
222
+ }
223
+
224
+ // Rewrite the touched rows: the child takes the left parent's slot.
225
+ for (const int32_t record : matched) {
226
+ int32_t* row = &corpus.member[static_cast<size_t>(record) * dimension];
227
+ for (int32_t slot = 0; slot < dimension; ++slot) {
228
+ if (row[slot] == left) {
229
+ row[slot] = child;
230
+ } else if (row[slot] == right) {
231
+ row[slot] = -1;
232
+ }
233
+ }
234
+ }
235
+
236
+ counts.add(pair_key(left, right), -frequency);
237
+ counter_updates += static_cast<int64_t>(matched.size()) * dimension;
238
+
239
+ occurrence[child] = matched;
240
+ for (const int32_t parent : {left, right}) {
241
+ remainder.clear();
242
+ std::set_difference(occurrence[parent].begin(), occurrence[parent].end(), matched.begin(),
243
+ matched.end(), std::back_inserter(remainder));
244
+ occurrence[parent].swap(remainder);
245
+ }
246
+
247
+ affected.clear();
248
+ if (static_cast<size_t>(child) >= stamp.size()) stamp.resize(child + 1024, 0);
249
+ ++generation;
250
+ for (const int32_t record : matched) {
251
+ const int32_t* row = &corpus.member[static_cast<size_t>(record) * dimension];
252
+ for (int32_t slot = 0; slot < dimension; ++slot) {
253
+ const int32_t other = row[slot];
254
+ if (other < 0 || other == child) continue;
255
+ if (stamp[other] == generation) continue;
256
+ stamp[other] = generation;
257
+ affected.push_back(other);
258
+ }
259
+ }
260
+ for (const int32_t other : affected) {
261
+ for (const int32_t parent : {left, right, child}) {
262
+ const uint64_t key = pair_key(parent, other);
263
+ const int64_t value = counts.get(key);
264
+ if (value > 0) ac2::heap_push(heap, {value, key, 0});
265
+ }
266
+ }
267
+
268
+ merges.emplace_back(left, right);
269
+ length -= frequency;
270
+
271
+ if (heap.size() > 4 * counts.size() + 65536) {
272
+ heap.clear();
273
+ counts.for_each([&](uint64_t key, int64_t value) { heap.push_back({value, key, 0}); });
274
+ std::make_heap(heap.begin(), heap.end(), ac2::HeapOrder{});
275
+ }
276
+ }
277
+
278
+ py::list merge_list;
279
+ for (const auto& merge : merges) {
280
+ merge_list.append(py::make_tuple(merge.first, merge.second));
281
+ }
282
+ py::dict report;
283
+ report["records"] = records;
284
+ report["weight"] = total_weight;
285
+ report["atoms"] = atom_count;
286
+ report["merges"] = static_cast<int64_t>(merges.size());
287
+ report["vocab_size"] = atom_count + static_cast<int64_t>(merges.size());
288
+ report["tokens_per_action_initial"] = static_cast<float>(initial_length) / total_weight;
289
+ report["tokens_per_action"] = static_cast<float>(length) / static_cast<float>(total_weight);
290
+ report["compression"] = static_cast<float>(initial_length) / static_cast<float>(length);
291
+ report["initial_pair_types"] = static_cast<int64_t>(initial_pair_types);
292
+ report["heap_pops"] = heap_pops;
293
+ report["counter_updates"] = counter_updates;
294
+ report["threads"] = thread_count;
295
+ report["backend"] = "native_sparse_exact";
296
+ return py::make_tuple(merge_list, report);
297
+ }
actioncodec2_kernels/temporal.cpp ADDED
@@ -0,0 +1,799 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Native exact rooted temporal Set-BPE trainer.
2
+ //
3
+ // A temporal symbol is rooted at one token of the current control step and may
4
+ // absorb spatial tokens from a bounded number of future steps. Training is the
5
+ // exact sparse local-graph algorithm: counters exist only for rule keys that
6
+ // actually occur inside the lookahead window, each merge updates only the edges
7
+ // incident to the instances it changed, and shifted self-rules (left == right)
8
+ // are certified by exact left-to-right maximum matching before they may win.
9
+ //
10
+ // This mirrors actioncodec2/setbpe/temporal.py merge for merge, including the
11
+ // tie-break, so the reference trainer can prove the two agree. What differs is
12
+ // the constant factor: the Python trainer allocates a dense
13
+ // ``lookahead x V x V`` legality table, while this one derives legality from the
14
+ // per-token time-shifted support masks in O(span) with no allocation, which is
15
+ // what makes large vocabularies tractable.
16
+
17
+ #include <pybind11/pybind11.h>
18
+ #include <pybind11/numpy.h>
19
+ #include <pybind11/stl.h>
20
+
21
+ #include <algorithm>
22
+ #include <array>
23
+ #include <chrono>
24
+ #include <cstdint>
25
+ #include <deque>
26
+ #include <limits>
27
+ #include <memory>
28
+ #include <string>
29
+ #include <unordered_map>
30
+ #include <vector>
31
+
32
+ #include "common.hpp"
33
+
34
+ #ifdef _OPENMP
35
+ #include <omp.h>
36
+ #endif
37
+
38
+ namespace py = pybind11;
39
+
40
+ namespace {
41
+
42
+ struct TemporalEntry {
43
+ int64_t count;
44
+ uint64_t key;
45
+ uint32_t version;
46
+ uint8_t certified;
47
+ };
48
+
49
+ struct TemporalOrder {
50
+ bool operator()(const TemporalEntry& a, const TemporalEntry& b) const {
51
+ if (a.count != b.count) return a.count < b.count; // larger count first
52
+ if (a.key != b.key) return a.key > b.key; // smaller key first
53
+ return a.certified > b.certified; // uncertified first
54
+ }
55
+ };
56
+
57
+ struct InstanceChunk {
58
+ const int32_t* tokens;
59
+ const int64_t* anchors;
60
+ int64_t size;
61
+ };
62
+
63
+ size_t initial_counter_capacity(int64_t instance_count, int32_t base_vocab, int32_t lookahead) {
64
+ const size_t by_instances = static_cast<size_t>(instance_count) * 4 + 64;
65
+ const size_t vocab = static_cast<size_t>(base_vocab);
66
+ const size_t offsets = static_cast<size_t>(std::max(lookahead - 1, 0));
67
+ if (vocab != 0 && offsets > (std::numeric_limits<size_t>::max() - 64) / vocab / vocab) {
68
+ return by_instances;
69
+ }
70
+ const size_t by_key_universe = vocab * vocab * offsets + 64;
71
+ return std::min(by_instances, by_key_universe);
72
+ }
73
+
74
+ inline uint64_t rule_key(int32_t left, int32_t right, int32_t offset) {
75
+ return (static_cast<uint64_t>(left) << 40) | (static_cast<uint64_t>(right) << 16) |
76
+ static_cast<uint64_t>(offset);
77
+ }
78
+
79
+ inline void unpack(uint64_t key, int32_t& left, int32_t& right, int32_t& offset) {
80
+ left = static_cast<int32_t>(key >> 40);
81
+ right = static_cast<int32_t>((key >> 16) & 0xffffffull);
82
+ offset = static_cast<int32_t>(key & 0xffffull);
83
+ }
84
+
85
+ class Trainer {
86
+ public:
87
+ Trainer(int64_t total_anchors, int64_t instance_capacity_hint,
88
+ const uint64_t* spatial_support, const int32_t* spatial_anchor, int32_t base_vocab,
89
+ int32_t dimension, int32_t lookahead, int64_t vocab_size, int threads)
90
+ : base_vocab_(base_vocab),
91
+ dimension_(dimension),
92
+ lookahead_(lookahead),
93
+ total_anchors_(total_anchors),
94
+ counts_(initial_counter_capacity(instance_capacity_hint, base_vocab, lookahead)) {
95
+ #ifdef _OPENMP
96
+ // More workers eventually make the per-merge local-map reduction slower
97
+ // than the incident scan itself. Keep auto mode at the measured scaling
98
+ // knee; an explicit value remains an opt-in override.
99
+ threads_ = threads > 0 ? threads : std::min(omp_get_max_threads(), 32);
100
+ #else
101
+ (void)threads;
102
+ threads_ = 1;
103
+ #endif
104
+ const size_t capacity = static_cast<size_t>(vocab_size) + 16;
105
+ masks_.assign(capacity * lookahead_, 0);
106
+ spans_.assign(capacity, 1);
107
+ root_anchor_.assign(capacity, 0);
108
+ pattern_start_.assign(capacity + 1, 0);
109
+ for (int32_t token = 0; token < base_vocab_; ++token) {
110
+ masks_[static_cast<size_t>(token) * lookahead_] = spatial_support[token];
111
+ root_anchor_[token] = spatial_anchor[token];
112
+ pattern_start_[token] = static_cast<int32_t>(pattern_.size());
113
+ pattern_.push_back({0, token});
114
+ }
115
+ pattern_start_[base_vocab_] = static_cast<int32_t>(pattern_.size());
116
+ token_count_ = base_vocab_;
117
+
118
+ member_.assign(static_cast<size_t>(total_anchors) * dimension_, -1);
119
+ occurrence_.resize(capacity);
120
+ dirty_.assign(capacity, 0);
121
+ }
122
+
123
+ void add_chunk(const InstanceChunk& chunk) {
124
+ for (int64_t index = 0; index < chunk.size; ++index) {
125
+ add_instance(chunk.tokens[index], chunk.anchors[index]);
126
+ }
127
+ initial_count_ += chunk.size;
128
+ }
129
+
130
+ void finalize_instances() {
131
+ if (initial_count_ <= 0) throw std::runtime_error("temporal training needs instances");
132
+ active_count_ = initial_count_;
133
+ const auto census_started = std::chrono::steady_clock::now();
134
+ census();
135
+ census_seconds_ = elapsed(census_started);
136
+ counts_.for_each([&](uint64_t key, int64_t value) { heap_.push_back({value, key, 0, 0}); });
137
+ std::make_heap(heap_.begin(), heap_.end(), TemporalOrder{});
138
+ initial_edge_types_ = heap_.size();
139
+ }
140
+
141
+ // -- geometry ------------------------------------------------------------
142
+
143
+ bool legal(int32_t left, int32_t right, int32_t offset) const {
144
+ const int32_t right_span = spans_[right];
145
+ if (offset + right_span > lookahead_) return false;
146
+ const uint64_t* left_masks = &masks_[static_cast<size_t>(left) * lookahead_];
147
+ const uint64_t* right_masks = &masks_[static_cast<size_t>(right) * lookahead_];
148
+ const int32_t overlap = std::min(spans_[left] - offset, right_span);
149
+ for (int32_t step = 0; step < overlap; ++step) {
150
+ if (left_masks[offset + step] & right_masks[step]) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ // -- instance bookkeeping ------------------------------------------------
156
+
157
+ int32_t add_instance(int32_t token, int64_t anchor) {
158
+ if (token < 0 || static_cast<size_t>(token) >= occurrence_.size()) {
159
+ throw std::runtime_error("temporal instance token " + std::to_string(token) +
160
+ " is outside trainer capacity " +
161
+ std::to_string(occurrence_.size()));
162
+ }
163
+ if (anchor < 0 || anchor >= total_anchors_) {
164
+ throw std::runtime_error("temporal instance anchor is outside the corpus timeline");
165
+ }
166
+ int32_t* row = &member_[static_cast<size_t>(anchor) * dimension_];
167
+ int32_t slot = 0;
168
+ while (slot < dimension_ && row[slot] >= 0) ++slot;
169
+ if (slot == dimension_) {
170
+ throw std::runtime_error("temporal anchor exceeded the token-width bound");
171
+ }
172
+ row[slot] = token;
173
+ occurrence_[token].push_back(anchor);
174
+ dirty_[token] = 1;
175
+ return slot;
176
+ }
177
+
178
+ // Removal touches only the row mirror. The occurrence list is allowed to
179
+ // keep stale anchors because ``occurs_at`` is an exact membership test, so
180
+ // compaction can be deferred to the next time the list is actually read.
181
+ void remove_instance(int32_t token, int64_t anchor) {
182
+ int32_t* row = &member_[static_cast<size_t>(anchor) * dimension_];
183
+ for (int32_t slot = 0; slot < dimension_; ++slot) {
184
+ if (row[slot] == token) {
185
+ row[slot] = -1;
186
+ break;
187
+ }
188
+ }
189
+ dirty_[token] = 1;
190
+ }
191
+
192
+ const std::vector<int64_t>& sorted_occurrence(int32_t token) {
193
+ auto& anchors = occurrence_[token];
194
+ if (dirty_[token]) {
195
+ std::sort(anchors.begin(), anchors.end());
196
+ anchors.erase(std::unique(anchors.begin(), anchors.end()), anchors.end());
197
+ anchors.erase(std::remove_if(anchors.begin(), anchors.end(),
198
+ [&](int64_t anchor) { return !occurs_at(token, anchor); }),
199
+ anchors.end());
200
+ dirty_[token] = 0;
201
+ }
202
+ return anchors;
203
+ }
204
+
205
+ bool occurs_at(int32_t token, int64_t anchor) const {
206
+ const int32_t* row = &member_[static_cast<size_t>(anchor) * dimension_];
207
+ for (int32_t slot = 0; slot < dimension_; ++slot) {
208
+ if (row[slot] == token) return true;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ // -- matching ------------------------------------------------------------
214
+
215
+ std::vector<std::pair<int64_t, int64_t>> select(int32_t left, int32_t right, int32_t offset) {
216
+ std::vector<std::pair<int64_t, int64_t>> matches;
217
+ const std::vector<int64_t>& anchors = sorted_occurrence(left);
218
+ if (left != right) {
219
+ #ifdef _OPENMP
220
+ if (threads_ > 1 && anchors.size() >= 65536) {
221
+ const int workers = std::min<int64_t>(threads_, anchors.size() / 32768);
222
+ std::vector<std::vector<std::pair<int64_t, int64_t>>> local(workers);
223
+ #pragma omp parallel num_threads(workers)
224
+ {
225
+ const int worker = omp_get_thread_num();
226
+ const size_t start = anchors.size() * static_cast<size_t>(worker) / workers;
227
+ const size_t stop = anchors.size() * static_cast<size_t>(worker + 1) / workers;
228
+ auto& output = local[worker];
229
+ output.reserve(stop - start);
230
+ for (size_t index = start; index < stop; ++index) {
231
+ const int64_t anchor = anchors[index];
232
+ const int64_t target = anchor + offset;
233
+ if (target < total_anchors_ && occurs_at(right, target)) {
234
+ output.emplace_back(anchor, target);
235
+ }
236
+ }
237
+ }
238
+ size_t count = 0;
239
+ for (const auto& output : local) count += output.size();
240
+ matches.reserve(count);
241
+ for (auto& output : local) {
242
+ matches.insert(matches.end(), output.begin(), output.end());
243
+ }
244
+ return matches;
245
+ }
246
+ #endif
247
+ for (const int64_t anchor : anchors) {
248
+ const int64_t target = anchor + offset;
249
+ if (target < total_anchors_ && occurs_at(right, target)) {
250
+ matches.emplace_back(anchor, target);
251
+ }
252
+ }
253
+ return matches;
254
+ }
255
+ // Shifted self-rule: an instance cannot be both the left and the right end
256
+ // of two different merges, so the true gain is a maximum matching, which
257
+ // for a fixed shift on a line is exactly greedy left to right.
258
+ std::deque<int64_t> consumed_targets;
259
+ for (const int64_t anchor : anchors) {
260
+ while (!consumed_targets.empty() && consumed_targets.front() < anchor) {
261
+ consumed_targets.pop_front();
262
+ }
263
+ if (!consumed_targets.empty() && consumed_targets.front() == anchor) {
264
+ consumed_targets.pop_front();
265
+ continue;
266
+ }
267
+ const int64_t wide = anchor + offset;
268
+ if (wide >= total_anchors_) continue;
269
+ const int64_t target = wide;
270
+ if (!occurs_at(left, target)) continue;
271
+ matches.emplace_back(anchor, target);
272
+ consumed_targets.push_back(target);
273
+ }
274
+ return matches;
275
+ }
276
+
277
+ // -- search --------------------------------------------------------------
278
+
279
+ bool best(int32_t& left, int32_t& right, int32_t& offset,
280
+ std::vector<std::pair<int64_t, int64_t>>& matches) {
281
+ while (!heap_.empty()) {
282
+ std::pop_heap(heap_.begin(), heap_.end(), TemporalOrder{});
283
+ const TemporalEntry top = heap_.back();
284
+ heap_.pop_back();
285
+ ++heap_pops_;
286
+ const int64_t current = counts_.get(top.key);
287
+ if (current <= 0) continue;
288
+ unpack(top.key, left, right, offset);
289
+ const bool self_rule = left == right;
290
+ const uint32_t version = self_rule ? self_version(top.key) : 0;
291
+ const bool stale = self_rule ? version != top.version : current != top.count;
292
+ if (stale) {
293
+ heap_.push_back({current, top.key, version, 0});
294
+ std::push_heap(heap_.begin(), heap_.end(), TemporalOrder{});
295
+ continue;
296
+ }
297
+ if (self_rule && !top.certified) {
298
+ // Raw edge count only upper-bounds the achievable gain here; replace
299
+ // the priority with the exact matching size and re-queue.
300
+ auto certified = select(left, right, offset);
301
+ self_cache_[top.key] = {version, certified};
302
+ heap_.push_back({static_cast<int64_t>(certified.size()), top.key, version, 1});
303
+ std::push_heap(heap_.begin(), heap_.end(), TemporalOrder{});
304
+ ++self_certifications_;
305
+ continue;
306
+ }
307
+ if (top.certified) {
308
+ auto cached = self_cache_.find(top.key);
309
+ if (cached == self_cache_.end() || cached->second.first != version) continue;
310
+ matches = cached->second.second;
311
+ } else {
312
+ matches = select(left, right, offset);
313
+ }
314
+ if (static_cast<int64_t>(matches.size()) != top.count) {
315
+ throw std::runtime_error("temporal counter disagrees with exact matching");
316
+ }
317
+ return !matches.empty();
318
+ }
319
+ return false;
320
+ }
321
+
322
+ // -- commit --------------------------------------------------------------
323
+
324
+ void commit(int32_t left, int32_t right, int32_t offset,
325
+ const std::vector<std::pair<int64_t, int64_t>>& matches) {
326
+ const int32_t child = token_count_++;
327
+ // The child's pattern is the left pattern plus the right pattern shifted.
328
+ const int32_t start = static_cast<int32_t>(pattern_.size());
329
+ for (int32_t index = pattern_start_[left]; index < pattern_start_[left + 1]; ++index) {
330
+ pattern_.push_back(pattern_[index]);
331
+ }
332
+ for (int32_t index = pattern_start_[right]; index < pattern_start_[right + 1]; ++index) {
333
+ pattern_.push_back({pattern_[index].first + offset, pattern_[index].second});
334
+ }
335
+ std::sort(pattern_.begin() + start, pattern_.end());
336
+ pattern_start_[child] = start;
337
+ pattern_start_[child + 1] = static_cast<int32_t>(pattern_.size());
338
+
339
+ int32_t span = 0;
340
+ int32_t roots = 0;
341
+ uint64_t* child_masks = &masks_[static_cast<size_t>(child) * lookahead_];
342
+ for (int32_t index = start; index < static_cast<int32_t>(pattern_.size()); ++index) {
343
+ const int32_t time = pattern_[index].first;
344
+ const uint64_t support = masks_[static_cast<size_t>(pattern_[index].second) * lookahead_];
345
+ if (time >= lookahead_) throw std::runtime_error("temporal merge exceeds the lookahead");
346
+ if (child_masks[time] & support) {
347
+ throw std::runtime_error("temporal merge joins overlapping spatial supports");
348
+ }
349
+ child_masks[time] |= support;
350
+ span = std::max(span, time + 1);
351
+ roots += (time == 0);
352
+ }
353
+ if (roots != 1) throw std::runtime_error("a rooted temporal token needs one time-zero token");
354
+ spans_[child] = span;
355
+ root_anchor_[child] = root_anchor_[left];
356
+
357
+ ac2::CounterMap deltas(
358
+ std::min(matches.size() * 8 + 64, incident_key_capacity(token_count_)));
359
+ std::vector<std::pair<int32_t, int64_t>> removed; // (token, anchor), sorted by anchor
360
+ removed.reserve(matches.size() * 2);
361
+ size_t left_index = 0;
362
+ size_t right_index = 0;
363
+ while (left_index < matches.size() || right_index < matches.size()) {
364
+ const int64_t left_anchor =
365
+ left_index < matches.size() ? matches[left_index].first : total_anchors_;
366
+ const int64_t right_anchor =
367
+ right_index < matches.size() ? matches[right_index].second : total_anchors_;
368
+ if (left_anchor <= right_anchor) {
369
+ removed.emplace_back(left, left_anchor);
370
+ ++left_index;
371
+ } else {
372
+ removed.emplace_back(right, right_anchor);
373
+ ++right_index;
374
+ }
375
+ }
376
+ accumulate_incident(removed, -1, deltas);
377
+ correct_internal(removed, +1, deltas);
378
+
379
+ for (const auto& instance : removed) remove_instance(instance.first, instance.second);
380
+ std::vector<std::pair<int32_t, int64_t>> children;
381
+ children.reserve(matches.size());
382
+ for (const auto& match : matches) {
383
+ add_instance(child, match.first);
384
+ children.emplace_back(child, match.first);
385
+ }
386
+ accumulate_incident(children, +1, deltas);
387
+ correct_internal(children, -1, deltas);
388
+
389
+ apply(deltas, rule_key(left, right, offset));
390
+ active_count_ -= static_cast<int64_t>(matches.size());
391
+ merges_.push_back({left, right, offset});
392
+ }
393
+
394
+ // -- accessors -----------------------------------------------------------
395
+
396
+ const std::vector<std::array<int32_t, 3>>& merges() const { return merges_; }
397
+ int64_t active_count() const { return active_count_; }
398
+ int64_t initial_count() const { return initial_count_; }
399
+ int64_t heap_pops() const { return heap_pops_; }
400
+ int64_t counter_updates() const { return counter_updates_; }
401
+ int64_t self_certifications() const { return self_certifications_; }
402
+ size_t initial_edge_types() const { return initial_edge_types_; }
403
+ size_t live_edge_types() const { return counts_.live_entries(); }
404
+ size_t counter_buckets() const { return counts_.buckets(); }
405
+ int threads() const { return threads_; }
406
+ double census_seconds() const { return census_seconds_; }
407
+ double incident_seconds() const { return incident_seconds_; }
408
+ double incident_parallel_seconds() const { return incident_parallel_seconds_; }
409
+ double incident_merge_seconds() const { return incident_merge_seconds_; }
410
+ double correction_seconds() const { return correction_seconds_; }
411
+ double correction_parallel_seconds() const { return correction_parallel_seconds_; }
412
+ double apply_seconds() const { return apply_seconds_; }
413
+
414
+ private:
415
+ static double elapsed(std::chrono::steady_clock::time_point started) {
416
+ return std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();
417
+ }
418
+
419
+ size_t incident_key_capacity(int32_t vocabulary) const {
420
+ return static_cast<size_t>(6) * static_cast<size_t>(vocabulary) *
421
+ static_cast<size_t>(std::max(lookahead_ - 1, 0)) +
422
+ 64;
423
+ }
424
+
425
+ void census_anchor(int64_t anchor, ac2::CounterMap& counter) const {
426
+ const int32_t* row = &member_[static_cast<size_t>(anchor) * dimension_];
427
+ for (int32_t slot = 0; slot < dimension_; ++slot) {
428
+ const int32_t left = row[slot];
429
+ if (left < 0) continue;
430
+ for (int32_t offset = 1; offset < lookahead_; ++offset) {
431
+ const int64_t target = anchor + offset;
432
+ if (target >= total_anchors_) break;
433
+ const int32_t* other_row = &member_[static_cast<size_t>(target) * dimension_];
434
+ for (int32_t other_slot = 0; other_slot < dimension_; ++other_slot) {
435
+ const int32_t right = other_row[other_slot];
436
+ if (right >= 0 && legal(left, right, offset)) {
437
+ counter.add(rule_key(left, right, offset), 1);
438
+ }
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ void census() {
445
+ const size_t key_universe = initial_counter_capacity(total_anchors_, base_vocab_, lookahead_);
446
+ #ifdef _OPENMP
447
+ if (threads_ > 1 && total_anchors_ >= 4096) {
448
+ const int workers = std::min<int64_t>(threads_, total_anchors_ / 2048);
449
+ // Small rule universes fit comfortably in one local map per worker.
450
+ // For a large spatial vocabulary, each long-running worker would
451
+ // eventually discover almost the whole universe and multiply memory by
452
+ // the thread count. Merge fixed-size anchor batches instead: this keeps
453
+ // local memory bounded while the global counter remains exact.
454
+ const bool batched = key_universe > (1u << 20);
455
+ const int64_t anchors_per_worker = batched ? 8192 : total_anchors_;
456
+ const int64_t batch_size = anchors_per_worker * workers;
457
+ const size_t local_hint = batched
458
+ ? std::min<size_t>(key_universe,
459
+ anchors_per_worker * 32 + 64)
460
+ : key_universe;
461
+ std::vector<std::unique_ptr<ac2::CounterMap>> local;
462
+ local.reserve(workers);
463
+ for (int worker = 0; worker < workers; ++worker) {
464
+ local.push_back(std::make_unique<ac2::CounterMap>(local_hint));
465
+ }
466
+ for (int64_t batch_start = 0; batch_start < total_anchors_; batch_start += batch_size) {
467
+ const int64_t batch_end = std::min(total_anchors_, batch_start + batch_size);
468
+ #pragma omp parallel for schedule(static) num_threads(workers)
469
+ for (int64_t anchor = batch_start; anchor < batch_end; ++anchor) {
470
+ census_anchor(anchor, *local[omp_get_thread_num()]);
471
+ }
472
+ for (const auto& counter : local) {
473
+ counter->for_each([&](uint64_t key, int64_t value) { counts_.add(key, value); });
474
+ if (batched) counter->clear();
475
+ }
476
+ }
477
+ return;
478
+ }
479
+ #endif
480
+ for (int64_t anchor = 0; anchor < total_anchors_; ++anchor) census_anchor(anchor, counts_);
481
+ }
482
+
483
+ uint32_t self_version(uint64_t key) const {
484
+ auto found = self_versions_.find(key);
485
+ return found == self_versions_.end() ? 0u : found->second;
486
+ }
487
+
488
+ void accumulate_incident(const std::vector<std::pair<int32_t, int64_t>>& instances,
489
+ int64_t multiplier, ac2::CounterMap& deltas) {
490
+ const auto started = std::chrono::steady_clock::now();
491
+ #ifdef _OPENMP
492
+ if (threads_ > 1 && instances.size() >= 1024) {
493
+ const int workers = std::min<int>(threads_, static_cast<int>(instances.size() / 512));
494
+ const size_t hint = std::min(
495
+ instances.size() * 8 / static_cast<size_t>(workers) + 64,
496
+ incident_key_capacity(token_count_));
497
+ std::vector<std::unique_ptr<ac2::CounterMap>> local;
498
+ local.reserve(workers);
499
+ for (int worker = 0; worker < workers; ++worker) {
500
+ local.push_back(std::make_unique<ac2::CounterMap>(hint));
501
+ }
502
+ const auto parallel_started = std::chrono::steady_clock::now();
503
+ #pragma omp parallel for schedule(static) num_threads(workers)
504
+ for (int64_t index = 0; index < static_cast<int64_t>(instances.size()); ++index) {
505
+ accumulate_one(instances[static_cast<size_t>(index)], multiplier,
506
+ *local[omp_get_thread_num()]);
507
+ }
508
+ incident_parallel_seconds_ += elapsed(parallel_started);
509
+ const auto merge_started = std::chrono::steady_clock::now();
510
+ for (const auto& counter : local) {
511
+ counter->for_each([&](uint64_t key, int64_t value) { deltas.add(key, value); });
512
+ }
513
+ incident_merge_seconds_ += elapsed(merge_started);
514
+ incident_seconds_ += elapsed(started);
515
+ return;
516
+ }
517
+ #endif
518
+ for (const auto& instance : instances) {
519
+ accumulate_one(instance, multiplier, deltas);
520
+ }
521
+ incident_seconds_ += elapsed(started);
522
+ }
523
+
524
+ void accumulate_one(const std::pair<int32_t, int64_t>& instance, int64_t multiplier,
525
+ ac2::CounterMap& deltas) const {
526
+ const int32_t token = instance.first;
527
+ const int64_t anchor = instance.second;
528
+ for (int32_t offset = 1; offset < lookahead_; ++offset) {
529
+ const int64_t forward = anchor + offset;
530
+ if (forward < total_anchors_) {
531
+ const int32_t* row = &member_[static_cast<size_t>(forward) * dimension_];
532
+ for (int32_t slot = 0; slot < dimension_; ++slot) {
533
+ const int32_t other = row[slot];
534
+ if (other >= 0 && legal(token, other, offset)) {
535
+ deltas.add(rule_key(token, other, offset), multiplier);
536
+ }
537
+ }
538
+ }
539
+ const int64_t backward = anchor - offset;
540
+ if (backward >= 0) {
541
+ const int32_t* row = &member_[static_cast<size_t>(backward) * dimension_];
542
+ for (int32_t slot = 0; slot < dimension_; ++slot) {
543
+ const int32_t other = row[slot];
544
+ if (other >= 0 && legal(other, token, offset)) {
545
+ deltas.add(rule_key(other, token, offset), multiplier);
546
+ }
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ // Incident aggregation sees a changed-to-changed edge once from each end.
553
+ void correct_internal(const std::vector<std::pair<int32_t, int64_t>>& instances,
554
+ int64_t correction, ac2::CounterMap& deltas) {
555
+ const auto started = std::chrono::steady_clock::now();
556
+ if (instances.empty()) return;
557
+ std::vector<size_t> groups{0};
558
+ groups.reserve(instances.size());
559
+ for (size_t index = 1; index < instances.size(); ++index) {
560
+ if (instances[index].second < instances[index - 1].second) {
561
+ throw std::runtime_error("changed temporal instances must be anchor-sorted");
562
+ }
563
+ if (instances[index].second != instances[index - 1].second) groups.push_back(index);
564
+ }
565
+ groups.push_back(instances.size());
566
+ const size_t group_count = groups.size() - 1;
567
+ #ifdef _OPENMP
568
+ if (threads_ > 1 && group_count >= 1024) {
569
+ const int workers = std::min<int>(threads_, static_cast<int>(group_count / 512));
570
+ const size_t hint = std::min(group_count * 2 / static_cast<size_t>(workers) + 64,
571
+ incident_key_capacity(token_count_));
572
+ std::vector<std::unique_ptr<ac2::CounterMap>> local;
573
+ local.reserve(workers);
574
+ for (int worker = 0; worker < workers; ++worker) {
575
+ local.push_back(std::make_unique<ac2::CounterMap>(hint));
576
+ }
577
+ const auto parallel_started = std::chrono::steady_clock::now();
578
+ #pragma omp parallel for schedule(static) num_threads(workers)
579
+ for (int64_t index = 0; index < static_cast<int64_t>(group_count); ++index) {
580
+ correct_group(static_cast<size_t>(index), groups, instances, correction,
581
+ *local[omp_get_thread_num()]);
582
+ }
583
+ correction_parallel_seconds_ += elapsed(parallel_started);
584
+ for (const auto& counter : local) {
585
+ counter->for_each([&](uint64_t key, int64_t value) { deltas.add(key, value); });
586
+ }
587
+ } else
588
+ #endif
589
+ {
590
+ for (size_t group = 0; group < group_count; ++group) {
591
+ correct_group(group, groups, instances, correction, deltas);
592
+ }
593
+ }
594
+ correction_seconds_ += elapsed(started);
595
+ }
596
+
597
+ void correct_group(size_t group, const std::vector<size_t>& groups,
598
+ const std::vector<std::pair<int32_t, int64_t>>& instances,
599
+ int64_t correction, ac2::CounterMap& deltas) const {
600
+ const size_t left_start = groups[group];
601
+ const size_t left_stop = groups[group + 1];
602
+ const int64_t anchor = instances[left_start].second;
603
+ for (size_t other = group + 1; other + 1 < groups.size(); ++other) {
604
+ const size_t right_start = groups[other];
605
+ const int64_t distance = instances[right_start].second - anchor;
606
+ if (distance >= lookahead_) break;
607
+ const int32_t offset = static_cast<int32_t>(distance);
608
+ for (size_t left_index = left_start; left_index < left_stop; ++left_index) {
609
+ const int32_t left = instances[left_index].first;
610
+ for (size_t right_index = right_start; right_index < groups[other + 1]; ++right_index) {
611
+ const int32_t right = instances[right_index].first;
612
+ if (legal(left, right, offset)) {
613
+ deltas.add(rule_key(left, right, offset), correction);
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ void apply(const ac2::CounterMap& deltas, uint64_t selected) {
621
+ const auto started = std::chrono::steady_clock::now();
622
+ deltas.for_each([&](uint64_t key, int64_t delta) {
623
+ const auto [previous, count] = counts_.add_with_previous(key, delta);
624
+ if (count < 0) {
625
+ int32_t bl = 0, br = 0, bo = 0;
626
+ unpack(key, bl, br, bo);
627
+ throw std::runtime_error("temporal edge counter became negative: rule=(" +
628
+ std::to_string(bl) + "," + std::to_string(br) + "," +
629
+ std::to_string(bo) + ") previous=" + std::to_string(previous) +
630
+ " delta=" + std::to_string(delta) + " at merge " +
631
+ std::to_string(merges_.size()));
632
+ }
633
+ counter_updates_ += delta < 0 ? -delta : delta;
634
+ int32_t left = 0;
635
+ int32_t right = 0;
636
+ int32_t offset = 0;
637
+ unpack(key, left, right, offset);
638
+ uint32_t version = 0;
639
+ if (left == right) {
640
+ version = self_version(key) + 1;
641
+ self_versions_[key] = version;
642
+ self_cache_.erase(key);
643
+ }
644
+ // Every edge a commit adds contains the freshly minted child token, so a
645
+ // pre-existing key can only lose mass; its stale heap priority stays an
646
+ // admissible upper bound and is repaired when popped. New keys and the
647
+ // key just consumed need an entry right away.
648
+ if (count > 0 && (previous == 0 || key == selected)) {
649
+ heap_.push_back({count, key, version, 0});
650
+ std::push_heap(heap_.begin(), heap_.end(), TemporalOrder{});
651
+ }
652
+ });
653
+ apply_seconds_ += elapsed(started);
654
+ }
655
+
656
+ int32_t base_vocab_;
657
+ int32_t dimension_;
658
+ int32_t lookahead_;
659
+ int64_t total_anchors_;
660
+ int threads_ = 1;
661
+ int32_t token_count_ = 0;
662
+
663
+ std::vector<uint64_t> masks_;
664
+ std::vector<int32_t> spans_;
665
+ std::vector<int32_t> root_anchor_;
666
+ std::vector<std::pair<int32_t, int32_t>> pattern_; // (time, spatial token)
667
+ std::vector<int32_t> pattern_start_;
668
+
669
+ std::vector<int32_t> member_;
670
+ std::vector<std::vector<int64_t>> occurrence_;
671
+ std::vector<uint8_t> dirty_;
672
+
673
+ ac2::CounterMap counts_;
674
+ std::vector<TemporalEntry> heap_;
675
+ std::unordered_map<uint64_t, uint32_t> self_versions_;
676
+ std::unordered_map<uint64_t, std::pair<uint32_t, std::vector<std::pair<int64_t, int64_t>>>>
677
+ self_cache_;
678
+
679
+ std::vector<std::array<int32_t, 3>> merges_;
680
+ int64_t active_count_ = 0;
681
+ int64_t initial_count_ = 0;
682
+ int64_t heap_pops_ = 0;
683
+ int64_t counter_updates_ = 0;
684
+ int64_t self_certifications_ = 0;
685
+ size_t initial_edge_types_ = 0;
686
+ double census_seconds_ = 0.0;
687
+ double incident_seconds_ = 0.0;
688
+ double incident_parallel_seconds_ = 0.0;
689
+ double incident_merge_seconds_ = 0.0;
690
+ double correction_seconds_ = 0.0;
691
+ double correction_parallel_seconds_ = 0.0;
692
+ double apply_seconds_ = 0.0;
693
+ };
694
+
695
+ } // namespace
696
+
697
+ py::tuple train_temporal_native(
698
+ py::iterable instance_chunks, int64_t total_anchors,
699
+ py::array_t<uint64_t, py::array::c_style | py::array::forcecast> spatial_support,
700
+ py::array_t<int32_t, py::array::c_style | py::array::forcecast> spatial_anchor,
701
+ int32_t dimension, int32_t lookahead, int64_t vocab_size, int64_t action_count, int threads) {
702
+ if (lookahead < 1) throw std::invalid_argument("lookahead must be positive");
703
+ const int32_t base_vocab = static_cast<int32_t>(spatial_support.size());
704
+ if (vocab_size < base_vocab) {
705
+ throw std::invalid_argument("vocab_size is below the spatial vocabulary size");
706
+ }
707
+ std::unique_ptr<Trainer> trainer;
708
+ {
709
+ py::gil_scoped_release release;
710
+ trainer = std::make_unique<Trainer>(
711
+ total_anchors, action_count, spatial_support.data(),
712
+ spatial_anchor.data(), base_vocab, dimension, lookahead, vocab_size, threads);
713
+ }
714
+ int64_t instance_count = 0;
715
+ for (py::handle raw_chunk : instance_chunks) {
716
+ py::tuple chunk = py::cast<py::tuple>(raw_chunk);
717
+ if (chunk.size() != 2) {
718
+ throw std::invalid_argument("every temporal instance chunk must be a token/anchor pair");
719
+ }
720
+ auto tokens = py::cast<
721
+ py::array_t<int32_t, py::array::c_style | py::array::forcecast>>(chunk[0]);
722
+ auto anchors = py::cast<
723
+ py::array_t<int64_t, py::array::c_style | py::array::forcecast>>(chunk[1]);
724
+ if (tokens.ndim() != 1 || anchors.ndim() != 1 || tokens.size() != anchors.size()) {
725
+ throw std::invalid_argument("every temporal token/anchor chunk must be an aligned vector");
726
+ }
727
+ for (py::ssize_t index = 0; index < tokens.size(); ++index) {
728
+ if (tokens.data()[index] < 0 || tokens.data()[index] >= base_vocab) {
729
+ throw std::invalid_argument("input temporal token is outside the spatial vocabulary");
730
+ }
731
+ }
732
+ const InstanceChunk view{tokens.data(), anchors.data(), tokens.size()};
733
+ {
734
+ py::gil_scoped_release release;
735
+ trainer->add_chunk(view);
736
+ }
737
+ instance_count += tokens.size();
738
+ }
739
+ if (instance_count <= 0) {
740
+ throw std::invalid_argument("temporal instance chunks must not be empty");
741
+ }
742
+ int32_t left = 0;
743
+ int32_t right = 0;
744
+ int32_t offset = 0;
745
+ std::vector<std::pair<int64_t, int64_t>> matches;
746
+ double selection_seconds = 0.0;
747
+ double commit_seconds = 0.0;
748
+ {
749
+ py::gil_scoped_release release;
750
+ trainer->finalize_instances();
751
+ while (static_cast<int64_t>(trainer->merges().size()) + base_vocab < vocab_size) {
752
+ auto started = std::chrono::steady_clock::now();
753
+ const bool found = trainer->best(left, right, offset, matches);
754
+ selection_seconds +=
755
+ std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();
756
+ if (!found) break;
757
+ started = std::chrono::steady_clock::now();
758
+ trainer->commit(left, right, offset, matches);
759
+ commit_seconds +=
760
+ std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();
761
+ }
762
+ }
763
+
764
+ py::list merge_list;
765
+ for (const auto& merge : trainer->merges()) {
766
+ merge_list.append(py::make_tuple(merge[0], merge[1], merge[2]));
767
+ }
768
+ py::dict report;
769
+ report["lookahead"] = lookahead;
770
+ report["spatial_vocab_size"] = base_vocab;
771
+ report["merges"] = static_cast<int64_t>(trainer->merges().size());
772
+ report["vocab_size"] = base_vocab + static_cast<int64_t>(trainer->merges().size());
773
+ report["actions"] = action_count;
774
+ report["tokens_per_action_initial"] =
775
+ static_cast<float>(trainer->initial_count()) / static_cast<float>(action_count);
776
+ report["tokens_per_action"] =
777
+ static_cast<float>(trainer->active_count()) / static_cast<float>(action_count);
778
+ report["compression"] =
779
+ static_cast<float>(trainer->initial_count()) / static_cast<float>(trainer->active_count());
780
+ report["initial_edge_types"] = static_cast<int64_t>(trainer->initial_edge_types());
781
+ report["live_edge_types"] = static_cast<int64_t>(trainer->live_edge_types());
782
+ report["counter_buckets"] = static_cast<int64_t>(trainer->counter_buckets());
783
+ report["census_seconds"] = trainer->census_seconds();
784
+ report["incident_seconds"] = trainer->incident_seconds();
785
+ report["incident_parallel_seconds"] = trainer->incident_parallel_seconds();
786
+ report["incident_merge_seconds"] = trainer->incident_merge_seconds();
787
+ report["correction_seconds"] = trainer->correction_seconds();
788
+ report["correction_parallel_seconds"] = trainer->correction_parallel_seconds();
789
+ report["apply_seconds"] = trainer->apply_seconds();
790
+ report["selection_seconds"] = selection_seconds;
791
+ report["commit_seconds"] = commit_seconds;
792
+ report["heap_pops"] = trainer->heap_pops();
793
+ report["counter_updates"] = trainer->counter_updates();
794
+ report["self_certifications"] = trainer->self_certifications();
795
+ report["backend"] = "native_sparse_exact";
796
+ report["threads"] = trainer->threads();
797
+ report["threads_requested"] = threads;
798
+ return py::make_tuple(merge_list, report);
799
+ }
actioncodec2_kernels/token_streams.hpp ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #pragma once
2
+
3
+ #include <pybind11/pybind11.h>
4
+ #include <cstdint>
5
+ #include <stdexcept>
6
+ #include <string>
7
+ #include <vector>
8
+
9
+ namespace ac2 {
10
+ namespace py = pybind11;
11
+
12
+ // Preserve the public Python integer contract; never coerce floats or booleans.
13
+ inline std::vector<std::vector<int64_t>> checked_token_streams(
14
+ const py::iterable& streams, int64_t vocabulary, bool routed = false) {
15
+ const py::object numpy_integer = py::module_::import("numpy").attr("integer");
16
+ py::object integral;
17
+ std::vector<std::vector<int64_t>> output;
18
+ if (PyList_Check(streams.ptr())) output.reserve(PyList_GET_SIZE(streams.ptr()));
19
+ for (py::handle stream : streams) {
20
+ std::vector<int64_t> row;
21
+ if (PyList_Check(stream.ptr())) row.reserve(PyList_GET_SIZE(stream.ptr()));
22
+ for (py::handle raw : py::reinterpret_borrow<py::iterable>(stream)) {
23
+ bool integer = PyLong_Check(raw.ptr()) || py::isinstance(raw, numpy_integer);
24
+ if (!integer && !routed) {
25
+ if (!integral) integral = py::module_::import("numbers").attr("Integral");
26
+ integer = py::isinstance(raw, integral);
27
+ }
28
+ if (PyBool_Check(raw.ptr()) || !integer)
29
+ throw std::invalid_argument(routed ? "tokens[" + std::to_string(row.size()) + "] must be an integer"
30
+ : "tokens must be integers");
31
+ py::object number = py::reinterpret_steal<py::object>(PyNumber_Long(raw.ptr()));
32
+ if (!number) throw py::error_already_set();
33
+ int overflow = 0;
34
+ const int64_t token = PyLong_AsLongLongAndOverflow(number.ptr(), &overflow);
35
+ if (PyErr_Occurred()) throw py::error_already_set();
36
+ if (overflow || token < 0 || token >= vocabulary)
37
+ throw std::invalid_argument(routed ? "tokens[" + std::to_string(row.size()) +
38
+ "] is outside the routed profile vocabularies; out of range [0, " + std::to_string(vocabulary) + ")"
39
+ : "token is outside the vocabulary");
40
+ row.push_back(token);
41
+ }
42
+ if (row.empty()) throw std::invalid_argument("token stream must not be empty");
43
+ output.push_back(std::move(row));
44
+ }
45
+ if (output.empty()) throw std::invalid_argument("streams must not be empty");
46
+ return output;
47
+ }
48
+ } // namespace ac2
config.json CHANGED
@@ -1,4 +1,7 @@
1
  {
2
  "model_type": "actioncodec2",
3
- "processor_class": "ActionCodec2"
 
 
 
4
  }
 
1
  {
2
  "model_type": "actioncodec2",
3
+ "processor_class": "ActionCodec2",
4
+ "auto_map": {
5
+ "AutoProcessor": "processing_actioncodec2.ActionCodec2"
6
+ }
7
  }
processing_actioncodec2.py CHANGED
@@ -21,11 +21,15 @@ SHARED_PATTERNS = (
21
 
22
  def asset_inventory(directory: Path) -> dict[str, str]:
23
  """Hash the complete configuration and fitted assets for one variant."""
24
- names = ["router_config.yaml", "config.json", "processor_config.json"]
 
25
  profiles = [name for name in ("eef", "joint") if (directory / "profiles" / name).is_dir()]
26
  if not profiles:
27
  raise ValueError("variant has no fitted profiles")
28
- names.extend(f"profiles/{profile}/{name}" for profile in profiles for name in PROFILE_FILES)
 
 
 
29
  return {name: hashlib.sha256((directory / name).read_bytes()).hexdigest()
30
  for name in sorted(names)}
31
 
@@ -62,11 +66,12 @@ class _VariantBinding:
62
 
63
  def save_pretrained(self, save_directory, push_to_hub=False, **kwargs):
64
  directory = Path(save_directory)
 
65
  if (directory / "variants.json").is_file():
66
  raise ValueError("save a selected variant to a separate directory, outside the catalog root")
67
  if not push_to_hub and kwargs:
68
  raise TypeError(f"unused save_pretrained keyword(s): {', '.join(sorted(kwargs))}")
69
- files = super().save_pretrained(directory)
70
  inventory = asset_inventory(directory)
71
  manifest = directory / "artifact_manifest.json"
72
  manifest.write_text(json.dumps({
@@ -77,10 +82,8 @@ class _VariantBinding:
77
  }, indent=2) + "\n", encoding="utf-8")
78
  files.append(str(manifest))
79
  if push_to_hub:
80
- package = type(self.action_tokenizer).__module__.split(".routing.", 1)[0]
81
- importlib.import_module(f"{package}.integration.hf").upload_pretrained_directory(
82
- directory, **kwargs
83
- )
84
  return files
85
 
86
 
@@ -169,7 +172,7 @@ class ActionCodec2:
169
  root = Path(snapshot_download(
170
  str(pretrained_model_name_or_path),
171
  allow_patterns=[*SHARED_PATTERNS, "router_config.yaml", "profiles/**",
172
- "artifact_manifest.json"], **kwargs,
173
  ))
174
  directory = root
175
  manifest = root / "artifact_manifest.json"
@@ -185,33 +188,28 @@ class ActionCodec2:
185
  if variant_fingerprint is not None and variant_fingerprint != origin.get("fingerprint"):
186
  raise ValueError("variant fingerprint differs from the tokenizer bound to this policy")
187
 
188
- runtime = root / "runtime"
189
- package_file = runtime / "__init__.py"
190
- if not package_file.is_file():
191
- raise FileNotFoundError(f"ActionCodec2 runtime is missing from {root}")
192
- identity = hashlib.sha256(str(runtime.resolve()).encode()).hexdigest()[:16]
193
- package_name = f"_actioncodec2_artifact_{identity}"
194
- if package_name not in sys.modules:
195
- spec = importlib.util.spec_from_file_location(
196
- package_name, package_file, submodule_search_locations=[str(runtime)]
197
- )
198
- if spec is None or spec.loader is None:
199
- raise ImportError(f"cannot load ActionCodec2 runtime from {runtime}")
200
- package = importlib.util.module_from_spec(spec)
201
- sys.modules[package_name] = package
202
- try:
 
203
  spec.loader.exec_module(package)
204
- except BaseException:
205
- del sys.modules[package_name]
206
- raise
207
- processor = importlib.import_module(f"{package_name}.processing_actioncodec2")
208
- if (runtime / "hub_acceleration.py").is_file():
209
- acceleration = importlib.import_module(f"{package_name}.hub_acceleration")
210
- acceleration.install(package_name)
211
  if not hasattr(processor, "_catalog_processor_class"):
212
  processor._catalog_processor_class = type(
213
- "ActionCodec2", (_VariantBinding, processor.ActionCodec2),
214
- {"__module__": processor.__name__},
215
  )
216
  codec = processor._catalog_processor_class.from_pretrained(directory, action_space=action_space)
217
  codec._variant_origin = origin
 
21
 
22
  def asset_inventory(directory: Path) -> dict[str, str]:
23
  """Hash the complete configuration and fitted assets for one variant."""
24
+ sparse = (directory / "sparse_router_config.yaml").is_file()
25
+ names = ["sparse_router_config.yaml" if sparse else "router_config.yaml", "config.json", "processor_config.json"]
26
  profiles = [name for name in ("eef", "joint") if (directory / "profiles" / name).is_dir()]
27
  if not profiles:
28
  raise ValueError("variant has no fitted profiles")
29
+ names.extend(f"profiles/{profile}/{name}" for profile in profiles
30
+ for name in (tuple("sparse_bpe.json" if n == "bpe.json" else n for n in PROFILE_FILES) if sparse else PROFILE_FILES))
31
+ names.extend(f"profiles/{profile}/preprocessing.json" for profile in profiles
32
+ if (directory / "profiles" / profile / "preprocessing.json").is_file())
33
  return {name: hashlib.sha256((directory / name).read_bytes()).hexdigest()
34
  for name in sorted(names)}
35
 
 
66
 
67
  def save_pretrained(self, save_directory, push_to_hub=False, **kwargs):
68
  directory = Path(save_directory)
69
+ include_training_report = kwargs.pop("include_training_report", False)
70
  if (directory / "variants.json").is_file():
71
  raise ValueError("save a selected variant to a separate directory, outside the catalog root")
72
  if not push_to_hub and kwargs:
73
  raise TypeError(f"unused save_pretrained keyword(s): {', '.join(sorted(kwargs))}")
74
+ files = super().save_pretrained(directory, include_training_report=include_training_report)
75
  inventory = asset_inventory(directory)
76
  manifest = directory / "artifact_manifest.json"
77
  manifest.write_text(json.dumps({
 
82
  }, indent=2) + "\n", encoding="utf-8")
83
  files.append(str(manifest))
84
  if push_to_hub:
85
+ package = type(self).__module__.split(".frontend.", 1)[0]
86
+ importlib.import_module(f"{package}.backends.actioncodec2.hf").upload_pretrained_directory(directory, **kwargs)
 
 
87
  return files
88
 
89
 
 
172
  root = Path(snapshot_download(
173
  str(pretrained_model_name_or_path),
174
  allow_patterns=[*SHARED_PATTERNS, "router_config.yaml", "profiles/**",
175
+ "sparse_router_config.yaml", "artifact_manifest.json"], **kwargs,
176
  ))
177
  directory = root
178
  manifest = root / "artifact_manifest.json"
 
188
  if variant_fingerprint is not None and variant_fingerprint != origin.get("fingerprint"):
189
  raise ValueError("variant fingerprint differs from the tokenizer bound to this policy")
190
 
191
+ family = "actioncodec2_sparse" if (directory / "sparse_router_config.yaml").is_file() else "actioncodec2"
192
+ if __package__ == "actioncodec2.core":
193
+ package_name = __package__
194
+ else:
195
+ runtime = root / "runtime"
196
+ package_file = runtime / "__init__.py"
197
+ if not package_file.is_file():
198
+ raise FileNotFoundError(f"ActionCodec2 runtime is missing from {root}")
199
+ identity = hashlib.sha256(str(runtime.resolve()).encode()).hexdigest()[:16]
200
+ package_name = f"_actioncodec2_artifact_{identity}"
201
+ if package_name not in sys.modules:
202
+ spec = importlib.util.spec_from_file_location(
203
+ package_name, package_file, submodule_search_locations=[str(runtime)]
204
+ )
205
+ package = importlib.util.module_from_spec(spec)
206
+ sys.modules[package_name] = package
207
  spec.loader.exec_module(package)
208
+ processor = importlib.import_module(f"{package_name}.frontend.{family}.processor")
209
+ base = processor.ActionCodec2Sparse if family == "actioncodec2_sparse" else processor.ActionCodec2
 
 
 
 
 
210
  if not hasattr(processor, "_catalog_processor_class"):
211
  processor._catalog_processor_class = type(
212
+ base.__name__, (_VariantBinding, base), {"__module__": processor.__name__}
 
213
  )
214
  codec = processor._catalog_processor_class.from_pretrained(directory, action_space=action_space)
215
  codec._variant_origin = origin
pyproject.toml CHANGED
@@ -1,16 +1,3 @@
1
  [build-system]
2
  requires = ["setuptools>=77", "wheel", "pybind11>=2.12"]
3
  build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "actioncodec2-kernels"
7
- version = "0.1.0"
8
- description = "Portable C++17 encode/decode kernels for ActionCodec2 tokenizer artifacts"
9
- requires-python = ">=3.10"
10
- license = "Apache-2.0"
11
-
12
- [tool.setuptools]
13
- packages = ["actioncodec2_kernels"]
14
-
15
- [tool.setuptools.package-data]
16
- actioncodec2_kernels = ["*.cpp", "*.hpp"]
 
1
  [build-system]
2
  requires = ["setuptools>=77", "wheel", "pybind11>=2.12"]
3
  build-backend = "setuptools.build_meta"
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt DELETED
@@ -1,6 +0,0 @@
1
- numpy
2
- scipy
3
- torch>=2.3
4
- transformers>=4.57,<6
5
- huggingface-hub
6
- pyyaml
 
 
 
 
 
 
 
runtime/__init__.py CHANGED
@@ -1 +1 @@
1
- """Private inference runtime bundled with this ActionCodec2 artifact."""
 
1
+ """Shared ActionCodec2 and ActionCodec2Sparse runtime."""
runtime/backend/__init__.py DELETED
@@ -1 +0,0 @@
1
- """Internal backend components for ActionCodec2."""
 
 
runtime/backends/__init__.py ADDED
File without changes
runtime/backends/actioncodec2/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Shared physical models, layouts, and dense and sparse routers."""
2
+ from .configuration import ActionCodec2Config, ConfigError
3
+ from .presets import ComponentSpec, PresetConfig
4
+ from .processor import ActionCodec2Processor
5
+ from .routing import ActionCodec2Router
6
+ from .trajectory import ActiveTrajectory
7
+
8
+ __all__ = ["ActionCodec2Config", "ConfigError", "ComponentSpec", "PresetConfig", "ActionCodec2Processor", "ActionCodec2Router", "ActiveTrajectory"]
runtime/{physical → backends/actioncodec2}/_so3.py RENAMED
@@ -4,9 +4,22 @@ from __future__ import annotations
4
 
5
  import numpy as np
6
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  def normalize_quaternion(values: np.ndarray) -> np.ndarray:
9
  """Normalize quaternion rows and reject an undefined zero rotation."""
 
 
 
10
  quaternions = np.asarray(values, dtype=np.float64)
11
  norms = np.linalg.norm(quaternions, axis=-1, keepdims=True)
12
  if np.any(norms < 1e-12):
@@ -18,6 +31,10 @@ def multiply_quaternions(left: np.ndarray, right: np.ndarray) -> np.ndarray:
18
  """Return the Hamilton product of broadcastable xyzw arrays."""
19
  first = np.asarray(left, dtype=np.float64)
20
  second = np.asarray(right, dtype=np.float64)
 
 
 
 
21
  first_vector, first_scalar = first[..., :3], first[..., 3:4]
22
  second_vector, second_scalar = second[..., :3], second[..., 3:4]
23
  return normalize_quaternion(
@@ -36,6 +53,9 @@ def multiply_quaternions(left: np.ndarray, right: np.ndarray) -> np.ndarray:
36
 
37
  def conjugate_quaternion(quaternion: np.ndarray) -> np.ndarray:
38
  """Return the conjugate of an xyzw quaternion array."""
 
 
 
39
  result = np.asarray(quaternion, dtype=np.float64).copy()
40
  result[..., :3] *= -1.0
41
  return result
@@ -43,20 +63,24 @@ def conjugate_quaternion(quaternion: np.ndarray) -> np.ndarray:
43
 
44
  def rotvec_to_quaternion(rotvec: np.ndarray) -> np.ndarray:
45
  """Convert rotation vectors to xyzw quaternions."""
 
 
 
46
  values = np.asarray(rotvec, dtype=np.float64)
47
  angle = np.linalg.norm(values, axis=-1)
48
  scale = np.full_like(angle, 0.5)
49
  regular = angle >= 1e-12
50
  scale[regular] = np.sin(0.5 * angle[regular]) / angle[regular]
51
  return normalize_quaternion(
52
- np.concatenate(
53
- (values * scale[..., None], np.cos(0.5 * angle)[..., None]), axis=-1
54
- )
55
  )
56
 
57
 
58
  def quaternion_to_rotvec(quaternion: np.ndarray) -> np.ndarray:
59
  """Convert xyzw quaternions to shortest-path rotation vectors."""
 
 
 
60
  values = normalize_quaternion(quaternion).copy()
61
  values[values[..., 3] < 0.0] *= -1.0
62
  vector_norm = np.linalg.norm(values[..., :3], axis=-1)
@@ -72,6 +96,9 @@ def euler_xyz_to_quaternion(euler: np.ndarray) -> np.ndarray:
72
  values = np.asarray(euler, dtype=np.float64)
73
  if values.ndim != 2 or values.shape[1] != 3:
74
  raise ValueError("XYZ Euler angles must have shape [N, 3]")
 
 
 
75
  half = 0.5 * values
76
  sin_roll, sin_pitch, sin_yaw = np.sin(half).T
77
  cos_roll, cos_pitch, cos_yaw = np.cos(half).T
@@ -88,6 +115,9 @@ def quaternion_to_euler_xyz(quaternion: np.ndarray) -> np.ndarray:
88
  values = normalize_quaternion(quaternion)
89
  if values.ndim != 2 or values.shape[1] != 4:
90
  raise ValueError("xyzw quaternions must have shape [N, 4]")
 
 
 
91
  x, y, z, w = values.T
92
  return np.stack(
93
  (
 
4
 
5
  import numpy as np
6
 
7
+ from .native_backend import native_kernel
8
+
9
+
10
+ def _mapped(values, operation, width):
11
+ kernel = native_kernel("quaternion_map")
12
+ if kernel is None:
13
+ return None
14
+ array = np.asarray(values, dtype=np.float64)
15
+ return kernel(array.reshape(-1, array.shape[-1]), operation).reshape(*array.shape[:-1], width)
16
+
17
 
18
  def normalize_quaternion(values: np.ndarray) -> np.ndarray:
19
  """Normalize quaternion rows and reject an undefined zero rotation."""
20
+ result = _mapped(values, "normalize", 4)
21
+ if result is not None:
22
+ return result
23
  quaternions = np.asarray(values, dtype=np.float64)
24
  norms = np.linalg.norm(quaternions, axis=-1, keepdims=True)
25
  if np.any(norms < 1e-12):
 
31
  """Return the Hamilton product of broadcastable xyzw arrays."""
32
  first = np.asarray(left, dtype=np.float64)
33
  second = np.asarray(right, dtype=np.float64)
34
+ kernel = native_kernel("quaternion_multiply")
35
+ if kernel is not None:
36
+ first, second = np.broadcast_arrays(first, second)
37
+ return kernel(first.reshape(-1, 4), second.reshape(-1, 4)).reshape(first.shape)
38
  first_vector, first_scalar = first[..., :3], first[..., 3:4]
39
  second_vector, second_scalar = second[..., :3], second[..., 3:4]
40
  return normalize_quaternion(
 
53
 
54
  def conjugate_quaternion(quaternion: np.ndarray) -> np.ndarray:
55
  """Return the conjugate of an xyzw quaternion array."""
56
+ native = _mapped(quaternion, "conjugate", 4)
57
+ if native is not None:
58
+ return native
59
  result = np.asarray(quaternion, dtype=np.float64).copy()
60
  result[..., :3] *= -1.0
61
  return result
 
63
 
64
  def rotvec_to_quaternion(rotvec: np.ndarray) -> np.ndarray:
65
  """Convert rotation vectors to xyzw quaternions."""
66
+ result = _mapped(rotvec, "from_rotvec", 4)
67
+ if result is not None:
68
+ return result
69
  values = np.asarray(rotvec, dtype=np.float64)
70
  angle = np.linalg.norm(values, axis=-1)
71
  scale = np.full_like(angle, 0.5)
72
  regular = angle >= 1e-12
73
  scale[regular] = np.sin(0.5 * angle[regular]) / angle[regular]
74
  return normalize_quaternion(
75
+ np.concatenate((values * scale[..., None], np.cos(0.5 * angle)[..., None]), axis=-1)
 
 
76
  )
77
 
78
 
79
  def quaternion_to_rotvec(quaternion: np.ndarray) -> np.ndarray:
80
  """Convert xyzw quaternions to shortest-path rotation vectors."""
81
+ result = _mapped(quaternion, "to_rotvec", 3)
82
+ if result is not None:
83
+ return result
84
  values = normalize_quaternion(quaternion).copy()
85
  values[values[..., 3] < 0.0] *= -1.0
86
  vector_norm = np.linalg.norm(values[..., :3], axis=-1)
 
96
  values = np.asarray(euler, dtype=np.float64)
97
  if values.ndim != 2 or values.shape[1] != 3:
98
  raise ValueError("XYZ Euler angles must have shape [N, 3]")
99
+ result = _mapped(values, "from_euler", 4)
100
+ if result is not None:
101
+ return result
102
  half = 0.5 * values
103
  sin_roll, sin_pitch, sin_yaw = np.sin(half).T
104
  cos_roll, cos_pitch, cos_yaw = np.cos(half).T
 
115
  values = normalize_quaternion(quaternion)
116
  if values.ndim != 2 or values.shape[1] != 4:
117
  raise ValueError("xyzw quaternions must have shape [N, 4]")
118
+ result = _mapped(values, "to_euler", 3)
119
+ if result is not None:
120
+ return result
121
  x, y, z, w = values.T
122
  return np.stack(
123
  (
runtime/{common → backends/actioncodec2}/_validation.py RENAMED
@@ -18,13 +18,11 @@ def active_dimensions(
18
  ) -> tuple[int, ...]:
19
  """Normalize and validate a non-empty increasing subset of dimensions."""
20
  dims = (
21
- tuple(range(dimension))
22
- if active_dims is None
23
- else tuple(int(item) for item in active_dims)
24
  )
25
  if not dims:
26
  raise ValueError(f"{subject} must not be empty")
27
- if any(left >= right for left, right in zip(dims, dims[1:])):
28
  raise ValueError(f"{subject} must be strictly increasing")
29
  if dims[0] < 0 or dims[-1] >= dimension:
30
  raise ValueError(f"{subject} contains a dimension outside the vocabulary")
@@ -76,13 +74,9 @@ def validate_dimension_partition(
76
  for name, dims in groups:
77
  for index in dims:
78
  if index < 0 or index >= dimension:
79
- raise error_type(
80
- f"{label} {index} used by {name!r} is outside input_dim"
81
- )
82
  if index in owners:
83
- raise error_type(
84
- f"{label} {index} belongs to both {owners[index]!r} and {name!r}"
85
- )
86
  owners[index] = name
87
  missing = sorted(set(range(dimension)) - set(owners))
88
  if missing:
 
18
  ) -> tuple[int, ...]:
19
  """Normalize and validate a non-empty increasing subset of dimensions."""
20
  dims = (
21
+ tuple(range(dimension)) if active_dims is None else tuple(int(item) for item in active_dims)
 
 
22
  )
23
  if not dims:
24
  raise ValueError(f"{subject} must not be empty")
25
+ if any(left >= right for left, right in zip(dims, dims[1:], strict=False)):
26
  raise ValueError(f"{subject} must be strictly increasing")
27
  if dims[0] < 0 or dims[-1] >= dimension:
28
  raise ValueError(f"{subject} contains a dimension outside the vocabulary")
 
74
  for name, dims in groups:
75
  for index in dims:
76
  if index < 0 or index >= dimension:
77
+ raise error_type(f"{label} {index} used by {name!r} is outside input_dim")
 
 
78
  if index in owners:
79
+ raise error_type(f"{label} {index} belongs to both {owners[index]!r} and {name!r}")
 
 
80
  owners[index] = name
81
  missing = sorted(set(range(dimension)) - set(owners))
82
  if missing:
runtime/backends/actioncodec2/assets/presets/dual_eef_abs.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: dual_eef_abs
2
+ input_dim: 14
3
+ input: absolute
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ left_arm:
10
+ space: eef
11
+ position_dims: [0, 1, 2]
12
+ rotation_dims: [3, 4, 5]
13
+ rotation_format: rotation_vector
14
+ gripper_dim: 6
15
+ right_arm:
16
+ space: eef
17
+ position_dims: [7, 8, 9]
18
+ rotation_dims: [10, 11, 12]
19
+ rotation_format: rotation_vector
20
+ gripper_dim: 13
runtime/backends/actioncodec2/assets/presets/dual_eef_delta.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: dual_eef_delta
2
+ input_dim: 14
3
+ input: delta
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ left_arm:
10
+ space: eef
11
+ position_dims: [0, 1, 2]
12
+ rotation_dims: [3, 4, 5]
13
+ rotation_format: rotation_vector
14
+ gripper_dim: 6
15
+ right_arm:
16
+ space: eef
17
+ position_dims: [7, 8, 9]
18
+ rotation_dims: [10, 11, 12]
19
+ rotation_format: rotation_vector
20
+ gripper_dim: 13
runtime/backends/actioncodec2/assets/presets/dual_joint6_abs.yaml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: dual_joint6_abs
2
+ input_dim: 14
3
+ input: absolute
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ left_arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5]
12
+ joint_wrap: pi
13
+ gripper_dim: 6
14
+ right_arm:
15
+ space: joint
16
+ joint_dims: [7, 8, 9, 10, 11, 12]
17
+ joint_wrap: pi
18
+ gripper_dim: 13
runtime/backends/actioncodec2/assets/presets/dual_joint6_delta.yaml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: dual_joint6_delta
2
+ input_dim: 14
3
+ input: delta
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ left_arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5]
12
+ gripper_dim: 6
13
+ right_arm:
14
+ space: joint
15
+ joint_dims: [7, 8, 9, 10, 11, 12]
16
+ gripper_dim: 13
runtime/backends/actioncodec2/assets/presets/dual_joint7_abs.yaml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: dual_joint7_abs
2
+ input_dim: 16
3
+ input: absolute
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ left_arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5, 6]
12
+ joint_wrap: pi
13
+ gripper_dim: 7
14
+ right_arm:
15
+ space: joint
16
+ joint_dims: [8, 9, 10, 11, 12, 13, 14]
17
+ joint_wrap: pi
18
+ gripper_dim: 15
runtime/backends/actioncodec2/assets/presets/dual_joint7_delta.yaml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: dual_joint7_delta
2
+ input_dim: 16
3
+ input: delta
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ left_arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5, 6]
12
+ gripper_dim: 7
13
+ right_arm:
14
+ space: joint
15
+ joint_dims: [8, 9, 10, 11, 12, 13, 14]
16
+ gripper_dim: 15
runtime/backends/actioncodec2/assets/presets/single_eef_abs.yaml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: single_eef_abs
2
+ input_dim: 7
3
+ input: absolute
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ arm:
10
+ space: eef
11
+ position_dims: [0, 1, 2]
12
+ rotation_dims: [3, 4, 5]
13
+ rotation_format: rotation_vector
14
+ gripper_dim: 6
runtime/backends/actioncodec2/assets/presets/single_eef_delta.yaml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: single_eef_delta
2
+ input_dim: 7
3
+ input: delta
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ arm:
10
+ space: eef
11
+ position_dims: [0, 1, 2]
12
+ rotation_dims: [3, 4, 5]
13
+ rotation_format: rotation_vector
14
+ gripper_dim: 6
runtime/backends/actioncodec2/assets/presets/single_joint6_abs.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: single_joint6_abs
2
+ input_dim: 7
3
+ input: absolute
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5]
12
+ joint_wrap: pi
13
+ gripper_dim: 6
runtime/backends/actioncodec2/assets/presets/single_joint6_abs_no_gripper.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: single_joint6_abs_no_gripper
2
+ input_dim: 6
3
+ input: absolute
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5]
12
+ joint_wrap: pi
runtime/backends/actioncodec2/assets/presets/single_joint6_delta.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: single_joint6_delta
2
+ input_dim: 7
3
+ input: delta
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5]
12
+ gripper_dim: 6
runtime/backends/actioncodec2/assets/presets/single_joint7_abs.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: single_joint7_abs
2
+ input_dim: 8
3
+ input: absolute
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5, 6]
12
+ joint_wrap: pi
13
+ gripper_dim: 7
runtime/backends/actioncodec2/assets/presets/single_joint7_delta.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: single_joint7_delta
2
+ input_dim: 8
3
+ input: delta
4
+ source_hz: 15.0
5
+ target_hz: 15.0
6
+ interpolation: cubic
7
+ token_order: time_major
8
+ components:
9
+ arm:
10
+ space: joint
11
+ joint_dims: [0, 1, 2, 3, 4, 5, 6]
12
+ gripper_dim: 7
runtime/backends/actioncodec2/assets/profiles/eef.yaml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_type: actioncodec2_unified
2
+ config_version: 1
3
+ physical:
4
+ input_dim: 7
5
+ components:
6
+ translation_m:
7
+ dims:
8
+ start: 0
9
+ stop: 3
10
+ space: additive
11
+ input_order: 0
12
+ primitive_order: 1
13
+ input_transform:
14
+ scale: 1.0
15
+ clip: false
16
+ constraints:
17
+ order_0:
18
+ min: -2.0
19
+ max: 2.0
20
+ order_1:
21
+ min: -0.08
22
+ max: 0.08
23
+ distortion_limit: 0.005
24
+ primitive_selection: smooth
25
+ orientation_so3:
26
+ dims:
27
+ start: 3
28
+ stop: 6
29
+ space: so3
30
+ input_order: 0
31
+ primitive_order: 1
32
+ input_transform:
33
+ scale: 1.0
34
+ clip: false
35
+ constraints:
36
+ order_0:
37
+ min: -3.141592653589793
38
+ max: 3.141592653589793
39
+ order_1:
40
+ min: -0.25
41
+ max: 0.25
42
+ distortion_limit: 0.02
43
+ primitive_selection: smooth
44
+ representation: rotation_vector
45
+ multiplication: right
46
+ gripper_open_command:
47
+ dims:
48
+ - 6
49
+ space: additive
50
+ input_order: 0
51
+ primitive_order: 0
52
+ input_transform:
53
+ scale: 1.0
54
+ clip: true
55
+ constraints:
56
+ order_0:
57
+ min: 0.0
58
+ max: 1.0
59
+ distortion_limit: 0.5
60
+ primitive_selection: nearest
61
+ set_bpe:
62
+ lookahead: 8
63
+ allow_cross_component_merge: false
64
+ train:
65
+ primitives:
66
+ zero_primitive_components:
67
+ - translation_m
68
+ - orientation_so3
69
+ bpe:
70
+ spatial_vocab_size: 2048
71
+ vocab_size: 4096
72
+ quality_control:
73
+ hard_order_1_scale: 3.0
74
+ min_segment_length: 2
runtime/backends/actioncodec2/assets/profiles/eef_second_order.yaml ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_type: actioncodec2_unified
2
+ config_version: 1
3
+ physical:
4
+ input_dim: 7
5
+ components:
6
+ translation_m:
7
+ dims:
8
+ start: 0
9
+ stop: 3
10
+ space: additive
11
+ input_order: 0
12
+ primitive_order: 2
13
+ input_transform:
14
+ scale: 1.0
15
+ clip: false
16
+ constraints:
17
+ order_0:
18
+ min: -2.0
19
+ max: 2.0
20
+ order_2:
21
+ min: -0.025
22
+ max: 0.025
23
+ order_1:
24
+ min: -0.08
25
+ max: 0.08
26
+ distortion_limit: 0.005
27
+ primitive_selection: smooth
28
+ orientation_so3:
29
+ dims:
30
+ start: 3
31
+ stop: 6
32
+ space: so3
33
+ input_order: 0
34
+ primitive_order: 2
35
+ input_transform:
36
+ scale: 1.0
37
+ clip: false
38
+ constraints:
39
+ order_0:
40
+ min: -3.141592653589793
41
+ max: 3.141592653589793
42
+ order_2:
43
+ min: -0.08
44
+ max: 0.08
45
+ order_1:
46
+ min: -0.25
47
+ max: 0.25
48
+ distortion_limit: 0.02
49
+ primitive_selection: smooth
50
+ representation: rotation_vector
51
+ multiplication: right
52
+ gripper_open_command:
53
+ dims:
54
+ - 6
55
+ space: additive
56
+ input_order: 0
57
+ primitive_order: 0
58
+ input_transform:
59
+ scale: 1.0
60
+ clip: true
61
+ constraints:
62
+ order_0:
63
+ min: 0.0
64
+ max: 1.0
65
+ distortion_limit: 0.5
66
+ primitive_selection: nearest
67
+ set_bpe:
68
+ lookahead: 8
69
+ allow_cross_component_merge: false
70
+ train:
71
+ primitives:
72
+ zero_primitive_components:
73
+ - translation_m
74
+ - orientation_so3
75
+ bpe:
76
+ spatial_vocab_size: 2048
77
+ vocab_size: 4096
78
+ quality_control:
79
+ hard_order_1_scale: 3.0
80
+ min_segment_length: 2
runtime/backends/actioncodec2/assets/profiles/joint.yaml ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_type: actioncodec2_unified
2
+ config_version: 1
3
+ physical:
4
+ input_dim: 8
5
+ components:
6
+ generalized_joint_position:
7
+ dims:
8
+ start: 0
9
+ stop: 7
10
+ space: additive
11
+ input_order: 0
12
+ primitive_order: 1
13
+ input_transform:
14
+ scale: 1.0
15
+ clip: false
16
+ constraints:
17
+ order_0:
18
+ min: -6.283185307179586
19
+ max: 6.283185307179586
20
+ order_1:
21
+ min: -0.35
22
+ max: 0.35
23
+ distortion_limit: [0.004, 0.004, 0.006, 0.0075, 0.015, 0.020, 0.020]
24
+ primitive_selection: smooth
25
+ gripper_open_command:
26
+ dims:
27
+ - 7
28
+ space: additive
29
+ input_order: 0
30
+ primitive_order: 0
31
+ input_transform:
32
+ scale: 1.0
33
+ clip: true
34
+ constraints:
35
+ order_0:
36
+ min: 0.0
37
+ max: 1.0
38
+ distortion_limit: 0.5
39
+ primitive_selection: nearest
40
+ set_bpe:
41
+ lookahead: 8
42
+ allow_cross_component_merge: false
43
+ train:
44
+ primitives:
45
+ zero_primitive_components:
46
+ - generalized_joint_position
47
+ bpe:
48
+ spatial_vocab_size: 2048
49
+ vocab_size: 4096
50
+ quality_control:
51
+ hard_order_1_scale: 3.0
52
+ min_segment_length: 2
runtime/backends/actioncodec2/assets/profiles/joint_second_order.yaml ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_type: actioncodec2_unified
2
+ config_version: 1
3
+ physical:
4
+ input_dim: 8
5
+ components:
6
+ generalized_joint_position:
7
+ dims:
8
+ start: 0
9
+ stop: 7
10
+ space: additive
11
+ input_order: 0
12
+ primitive_order: 2
13
+ input_transform:
14
+ scale: 1.0
15
+ clip: false
16
+ constraints:
17
+ order_0:
18
+ min: -6.283185307179586
19
+ max: 6.283185307179586
20
+ order_2:
21
+ min: -0.25
22
+ max: 0.25
23
+ order_1:
24
+ min: -0.35
25
+ max: 0.35
26
+ distortion_limit:
27
+ - 0.004
28
+ - 0.004
29
+ - 0.006
30
+ - 0.0075
31
+ - 0.015
32
+ - 0.02
33
+ - 0.02
34
+ primitive_selection: smooth
35
+ gripper_open_command:
36
+ dims:
37
+ - 7
38
+ space: additive
39
+ input_order: 0
40
+ primitive_order: 0
41
+ input_transform:
42
+ scale: 1.0
43
+ clip: true
44
+ constraints:
45
+ order_0:
46
+ min: 0.0
47
+ max: 1.0
48
+ distortion_limit: 0.5
49
+ primitive_selection: nearest
50
+ set_bpe:
51
+ lookahead: 8
52
+ allow_cross_component_merge: false
53
+ train:
54
+ primitives:
55
+ zero_primitive_components:
56
+ - generalized_joint_position
57
+ bpe:
58
+ spatial_vocab_size: 2048
59
+ vocab_size: 4096
60
+ quality_control:
61
+ hard_order_1_scale: 3.0
62
+ min_segment_length: 2
runtime/backends/actioncodec2/batch_semantics.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical adapters shared by episode fitting and batched inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+
9
+ from ._so3 import (
10
+ conjugate_quaternion,
11
+ multiply_quaternions,
12
+ normalize_quaternion,
13
+ quaternion_to_rotvec,
14
+ )
15
+ from .native_backend import native_kernel
16
+ from .semantic import (
17
+ difference_additive,
18
+ eef_pose_absolute,
19
+ eef_pose_components,
20
+ eef_pose_deltas,
21
+ eef_pose_from_components,
22
+ integrate_additive,
23
+ integrate_eef_deltas,
24
+ normalize_gripper_actions,
25
+ resample_uniform,
26
+ wrap_to_pi,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from .presets import ComponentSpec, PresetConfig
31
+
32
+
33
+ def _resample(values, source_hz, target_hz, method, *, kind="linear", target_length=None):
34
+ source_hz, target_hz = float(source_hz), float(target_hz)
35
+ if source_hz == target_hz and target_length in (None, values.shape[1]):
36
+ return values
37
+ kernel = native_kernel("resample_uniform_batch")
38
+ if kernel is not None:
39
+ return kernel(
40
+ values,
41
+ source_hz,
42
+ target_hz,
43
+ kind,
44
+ -1 if target_length is None else target_length,
45
+ method,
46
+ )
47
+ return np.stack(
48
+ [
49
+ resample_uniform(
50
+ row,
51
+ source_hz=source_hz,
52
+ target_hz=target_hz,
53
+ kind=kind,
54
+ target_length=target_length,
55
+ method=method,
56
+ )
57
+ for row in values
58
+ ]
59
+ )
60
+
61
+
62
+ def _pose_map(values, kernel_name, reference):
63
+ kernel = native_kernel(kernel_name)
64
+ return kernel(values) if kernel is not None else np.stack([reference(row) for row in values])
65
+
66
+
67
+ def _rotvec_poses(values):
68
+ kernel = native_kernel("eef_pose_from_rotvec_batch")
69
+ if kernel is not None:
70
+ return kernel(values)
71
+ flat = values.reshape(-1, 6)
72
+ return eef_pose_from_components(flat[:, :3], flat[:, 3:], "rotation_vector").reshape(
73
+ *values.shape[:2], 7
74
+ )
75
+
76
+
77
+ def _poses(values: np.ndarray, component: ComponentSpec) -> np.ndarray:
78
+ position = np.take(values, component.position_dims, axis=-1)
79
+ rotation = np.take(values, component.rotation_dims, axis=-1)
80
+ if component.rotation_format == "rotation_vector":
81
+ return _rotvec_poses(np.concatenate((position, rotation), axis=-1))
82
+ return eef_pose_from_components(
83
+ position.reshape(-1, 3), rotation.reshape(-1, rotation.shape[-1]), component.rotation_format
84
+ ).reshape(*values.shape[:2], 7)
85
+
86
+
87
+ def _local(poses, anchor):
88
+ quaternion = normalize_quaternion(poses[..., 3:7])
89
+ inverse = conjugate_quaternion(normalize_quaternion(anchor[..., 3:7]))
90
+ relative = multiply_quaternions(inverse, quaternion)
91
+ return np.concatenate(
92
+ (poses[..., :3].astype(np.float64) - anchor[..., :3], quaternion_to_rotvec(relative)),
93
+ axis=-1,
94
+ ).astype(np.float32)
95
+
96
+
97
+ def _joint(preset, values, component, source_hz, current_state):
98
+ source = np.take(values, component.joint_dims, axis=-1)
99
+ if preset.input == "delta":
100
+ source = integrate_additive(source, 1)
101
+ anchor = np.zeros((len(source), 1, source.shape[-1]), np.float32)
102
+ else:
103
+ anchor = (
104
+ source[:, :1]
105
+ if current_state is None
106
+ else np.take(current_state, component.joint_dims, axis=-1)[:, None]
107
+ )
108
+ joined = np.concatenate((anchor, source), axis=1)
109
+ if component.joint_wrap == "pi":
110
+ outside = np.abs(joined) > np.pi + 1e-5
111
+ if outside.any():
112
+ row = int(np.flatnonzero(outside.any(axis=(1, 2)))[0])
113
+ column = component.joint_dims[int(np.argmax(outside[row].any(axis=0)))]
114
+ raise ValueError(
115
+ f"action space {preset.name!r} declares joint_wrap='pi' for component "
116
+ f"{component.name!r}, so its absolute joint columns must lie in (-pi, pi]; "
117
+ f"source column {column} reaches {joined[row][outside[row]][0]:.6g} rad. Wrap the "
118
+ "inputs, or register a layout with joint_wrap='none' for coordinates that "
119
+ "are continuous past one turn, such as a multi-turn or prismatic axis."
120
+ )
121
+ batch, steps, width = joined.shape
122
+ kernel = native_kernel("unwrap_angles")
123
+ unwrapped = (
124
+ (
125
+ kernel(joined.transpose(1, 0, 2).reshape(steps, batch * width), True)
126
+ .reshape(steps, batch, width)
127
+ .transpose(1, 0, 2)
128
+ )
129
+ if kernel is not None
130
+ else np.unwrap(joined, axis=1)
131
+ )
132
+ anchor, source = unwrapped[:, :1], unwrapped[:, 1:]
133
+ return np.asarray(
134
+ _resample(source, source_hz, preset.target_hz, preset.interpolation) - anchor,
135
+ dtype=np.float32,
136
+ )
137
+
138
+
139
+ def _eef(preset, values, component, source_hz, current_state):
140
+ if preset.input == "delta":
141
+ delta = np.concatenate(
142
+ (
143
+ np.take(values, component.position_dims, axis=-1),
144
+ np.take(values, component.rotation_dims, axis=-1),
145
+ ),
146
+ axis=-1,
147
+ )
148
+ poses = _pose_map(delta, "integrate_eef_deltas_batch", integrate_eef_deltas)
149
+ sampled = _resample(
150
+ poses, source_hz, preset.target_hz, preset.interpolation, kind="pose_xyzw"
151
+ )
152
+ return _pose_map(sampled, "eef_pose_absolute_batch", eef_pose_absolute)
153
+ poses = _poses(values, component)
154
+ anchor = poses[:, :1] if current_state is None else _poses(current_state[:, None], component)
155
+ sampled = _resample(poses, source_hz, preset.target_hz, preset.interpolation, kind="pose_xyzw")
156
+ return _local(sampled, anchor)
157
+
158
+
159
+ def canonicalize_batch(preset: PresetConfig, values, *, source_hz, current_state):
160
+ """Return component ``(B,T_codec,D_active)`` arrays from validated input."""
161
+ return {
162
+ component.name: canonicalize_component_batch(
163
+ preset, values, component, source_hz=source_hz, current_state=current_state
164
+ )
165
+ for component in preset.components
166
+ }
167
+
168
+
169
+ def canonicalize_component_batch(preset, values, component, *, source_hz, current_state):
170
+ """Convert one component of validated ``(B,T,D)`` source actions."""
171
+ motion = (_joint if component.space == "joint" else _eef)(
172
+ preset, values, component, source_hz, current_state
173
+ )
174
+ if component.gripper_dim is None:
175
+ return motion
176
+ gripper = values[..., component.gripper_dim : component.gripper_dim + 1]
177
+ if component.gripper_threshold is not None:
178
+ gripper = ((gripper >= component.gripper_threshold) if component.gripper_open_is_high
179
+ else (gripper <= component.gripper_threshold)).astype(np.float32)
180
+ else:
181
+ gripper = normalize_gripper_actions(
182
+ gripper.reshape(-1, 1),
183
+ closed_value=component.gripper_closed_value,
184
+ open_value=component.gripper_open_value,
185
+ ).reshape(*values.shape[:2], 1)
186
+ gripper = _resample(gripper, source_hz, preset.target_hz, preset.interpolation, kind="hold")
187
+ return np.concatenate((motion, gripper), axis=-1)
188
+
189
+
190
+ def _external_eef(motion, component, current_state):
191
+ poses = _rotvec_poses(motion)
192
+ if current_state is None:
193
+ return _pose_map(poses, "eef_pose_deltas_batch", eef_pose_deltas)
194
+ anchor = _poses(current_state[:, None], component)
195
+ quaternion = multiply_quaternions(
196
+ normalize_quaternion(anchor[..., 3:7]), normalize_quaternion(poses[..., 3:7])
197
+ )
198
+ attached = np.concatenate(
199
+ (poses[..., :3].astype(np.float64) + anchor[..., :3], quaternion), axis=-1
200
+ ).astype(np.float32)
201
+ return eef_pose_components(attached.reshape(-1, 7), component.rotation_format).reshape(
202
+ *attached.shape[:2], 3 + len(component.rotation_dims)
203
+ )
204
+
205
+
206
+ def externalize_batch(
207
+ values, component: ComponentSpec, *, source_hz, target_hz, target_length, method, current_state
208
+ ):
209
+ """Resample decoded paths and return ``(B,T_output,D_component)`` arrays."""
210
+ sampled = resample_canonical_batch(
211
+ values,
212
+ component,
213
+ source_hz=source_hz,
214
+ target_hz=target_hz,
215
+ target_length=target_length,
216
+ method=method,
217
+ )
218
+ return externalize_canonical_batch(sampled, component, current_state=current_state)
219
+
220
+
221
+ def resample_canonical_batch(
222
+ values, component: ComponentSpec, *, source_hz, target_hz, target_length, method
223
+ ):
224
+ """Rate-convert local ``(B,T,D_active)`` paths, holding gripper commands."""
225
+ motion_width = len(component.joint_dims or ()) if component.space == "joint" else 6
226
+ motion = values[..., :motion_width]
227
+ if component.space == "eef":
228
+ poses = _rotvec_poses(motion)
229
+ poses = _resample(
230
+ poses, source_hz, target_hz, method, kind="pose_xyzw", target_length=target_length
231
+ )
232
+ motion = _pose_map(poses, "eef_pose_absolute_batch", eef_pose_absolute)
233
+ else:
234
+ motion = _resample(motion, source_hz, target_hz, method, target_length=target_length)
235
+ if component.gripper_dim is None:
236
+ return np.asarray(motion, dtype=np.float32)
237
+ gripper = _resample(
238
+ values[..., motion_width : motion_width + 1],
239
+ source_hz,
240
+ target_hz,
241
+ method,
242
+ kind="hold",
243
+ target_length=target_length,
244
+ )
245
+ return np.asarray(np.concatenate((motion, gripper), axis=-1), dtype=np.float32)
246
+
247
+
248
+ def externalize_canonical_batch(values, component: ComponentSpec, *, current_state):
249
+ """Restore source coordinates or deltas from ``(B,T,D_active)`` local paths."""
250
+ motion_width = len(component.joint_dims or ()) if component.space == "joint" else 6
251
+ motion = values[..., :motion_width]
252
+ if component.space == "eef":
253
+ motion = _external_eef(motion, component, current_state)
254
+ elif current_state is None:
255
+ motion = difference_additive(motion, 1)
256
+ else:
257
+ motion = motion + np.take(current_state, component.joint_dims, axis=-1)[:, None]
258
+ if component.joint_wrap == "pi":
259
+ motion = wrap_to_pi(motion)
260
+ if component.gripper_dim is None:
261
+ return np.asarray(motion, dtype=np.float32)
262
+ gripper = values[..., motion_width : motion_width + 1]
263
+ if component.gripper_threshold is not None:
264
+ gripper = gripper if component.gripper_open_is_high else 1.0 - gripper
265
+ return np.asarray(np.concatenate((motion, gripper), axis=-1), dtype=np.float32)
266
+ gripper = component.gripper_closed_value + gripper * (
267
+ component.gripper_open_value - component.gripper_closed_value
268
+ )
269
+ return np.asarray(np.concatenate((motion, gripper), axis=-1), dtype=np.float32)