ZibinDong commited on
Commit
9c19ede
·
verified ·
1 Parent(s): 3392c8b

Release extended first-order ActionCodec2 tokenizer

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. LICENSE +202 -0
  2. README.md +100 -0
  3. actioncodec2_kernels/__init__.py +1 -0
  4. actioncodec2_kernels/module.cpp +90 -0
  5. actioncodec2_kernels/parallel.hpp +116 -0
  6. actioncodec2_kernels/physical.cpp +407 -0
  7. actioncodec2_kernels/resample.cpp +833 -0
  8. actioncodec2_kernels/setbpe.cpp +394 -0
  9. actioncodec2_kernels/so3.cpp +313 -0
  10. config.json +4 -0
  11. processing_actioncodec2.py +94 -0
  12. processor_config.json +10 -0
  13. profiles/eef/bpe.json +5950 -0
  14. profiles/eef/config.json +4 -0
  15. profiles/eef/config.yaml +74 -0
  16. profiles/eef/primitives.npz +3 -0
  17. profiles/eef/processor_config.json +3 -0
  18. pyproject.toml +16 -0
  19. release.json +12 -0
  20. requirements.txt +6 -0
  21. router_config.yaml +120 -0
  22. runtime/__init__.py +1 -0
  23. runtime/backend/__init__.py +1 -0
  24. runtime/backend/native_backend.py +39 -0
  25. runtime/common/__init__.py +1 -0
  26. runtime/common/_frontend_base.py +108 -0
  27. runtime/common/_types.py +12 -0
  28. runtime/common/_validation.py +89 -0
  29. runtime/frontend/__init__.py +1 -0
  30. runtime/frontend/tokenizer.py +398 -0
  31. runtime/hub_acceleration.py +239 -0
  32. runtime/hub_entry.py +94 -0
  33. runtime/integration/__init__.py +1 -0
  34. runtime/integration/exporting.py +401 -0
  35. runtime/integration/hf.py +199 -0
  36. runtime/physical/__init__.py +1 -0
  37. runtime/physical/_so3.py +99 -0
  38. runtime/physical/boundary.py +153 -0
  39. runtime/physical/configuration.py +794 -0
  40. runtime/physical/physical_codec.py +950 -0
  41. runtime/physical/physical_processor.py +384 -0
  42. runtime/physical/primitives.py +83 -0
  43. runtime/physical/semantic.py +452 -0
  44. runtime/physical/trajectory.py +151 -0
  45. runtime/processing_actioncodec2.py +553 -0
  46. runtime/routing/__init__.py +1 -0
  47. runtime/routing/cache.py +45 -0
  48. runtime/routing/defaults.py +622 -0
  49. runtime/routing/presets.py +872 -0
  50. runtime/routing/routing.py +809 -0
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: actioncodec2
4
+ tags:
5
+ - robotics
6
+ - action-tokenization
7
+ - transformers
8
+ - extended-edition
9
+ ---
10
+
11
+ # ActionCodec2 Extended Edition — 1st Order
12
+
13
+ This tokenizer was trained on a curated corpus with lower-quality datasets and most stationary-arm segments removed. The data curation concerns tokenizer training, independently of a downstream policy's training data.
14
+
15
+ This is a complete tokenizer artifact for continuous robot actions, rather than
16
+ a neural policy checkpoint. Keep all files together when copying it.
17
+
18
+ - Encoding order: 1
19
+ - Codec rate: 15 Hz
20
+ - Codebook size: 2048 per profile
21
+ - Fitted profiles: eef
22
+ - Gripper representation: binary
23
+
24
+ - Trajectory tracking precision tier: 1mm
25
+
26
+ The [ActionCodec2 collection](https://huggingface.co/collections/ZibinDong/actioncodec2-6ab6c4860ee90a881dda0a8b)
27
+ contains the paper and extended editions. Edition and encoding order are separate choices.
28
+
29
+ ## Install and enable C++ acceleration
30
+
31
+ ```bash
32
+ hf download ZibinDong/ActionCodec2-Extended-1mm-2k --local-dir ./actioncodec2-tokenizer
33
+ pip install -r ./actioncodec2-tokenizer/requirements.txt
34
+ pip install ./actioncodec2-tokenizer
35
+ ```
36
+
37
+ The last command compiles the bundled C++17 CPU kernels. It requires a C++17
38
+ compiler and installs `actioncodec2-kernels`, independently of the G0.5 package.
39
+ The processor automatically uses installed kernels; no GPU is needed for
40
+ tokenization. An installed copy of the
41
+ [source repository](https://github.com/ZibinDong/actioncodec2) also supplies them.
42
+ Loading without kernels uses the bundled Python implementation. Set
43
+ `ACTIONCODEC2_NO_NATIVE=1` to explicitly select that reference path.
44
+
45
+ Acceleration covers physical quantization, Set-BPE encode/decode, trajectory
46
+ resampling and pose operations. Equal-horizon batches use C++ worker threads,
47
+ which release the Python GIL. Thread counts adapt to measured chunk cost and
48
+ the process CPU allowance. Forked DataLoader workers use one thread each by
49
+ default; `ACTIONCODEC2_NUM_THREADS` sets an explicit per-process budget.
50
+
51
+ ## Encode and decode
52
+
53
+ ```python
54
+ import numpy as np
55
+ from transformers import AutoProcessor
56
+
57
+ codec = AutoProcessor.from_pretrained(
58
+ "ZibinDong/ActionCodec2-Extended-1mm-2k", trust_remote_code=True,
59
+ )
60
+ # Pin revision to a Hub commit for reproducible downloads.
61
+ actions = np.zeros((2, 12, 7), dtype=np.float32)
62
+ actions[..., 6] = 1.0
63
+
64
+ tokens = codec.encode(actions, fps=15)
65
+ reconstructed = codec.decode(tokens, fps=15)
66
+ ```
67
+
68
+ Inputs are `(T, D)` or `(B, T, D)`, with a common horizon within a batch.
69
+ Tokens are ragged integer rows; decode returns CPU `torch.float32` tensors with
70
+ shape `(B, T_out, D)`. Pass the actual recording rate as `fps`; resampling can
71
+ change the decoded horizon. Batch chunks together for encoding throughput.
72
+
73
+ The default layout is `single_eef_delta`: position increments in metres,
74
+ body-frame rotation-vector increments in radians, then gripper (open at >= 0.8).
75
+ Rotation follows `R_next = R_previous @ Exp(rotvec)`. Convert normalized
76
+ controller commands to these units before encoding. Inspect other layouts with
77
+ `codec.print_action_spaces()` and select with `codec.for_action_space(name)`.
78
+ Absolute layouts require the same `current_state` of shape `(B, D)` during
79
+ encoding and decoding. Second-order profiles additionally require explicit
80
+ `previous_first_order` boundaries at the codec rate.
81
+
82
+ `codec.grammar(horizon, fps=...)` constrains generated token coverage. Remove
83
+ model prompt, EOS and padding IDs before decoding. Each profile occupies its
84
+ own namespace; `router_config.yaml` records token offsets. A policy must have
85
+ enough action vocabulary rows for its selected profile or routed namespace.
86
+ The source repository's G0.5 adapter currently accepts at most 4096 per profile.
87
+
88
+ ## Validation scope
89
+
90
+ This configuration has not been evaluated in the selected extended-edition LIBERO comparison.
91
+
92
+ The extended edition currently contains first-order profiles. At 1 mm, EEF
93
+ starts at 2k and joint profiles at 4k because of the primitive vocabulary size;
94
+ 5 mm and 10 mm provide 1k, 2k, 4k, 8k and 16k. Only six first-order configurations
95
+ have been tested on LIBERO. The full range has not been validated downstream.
96
+ Precision tiers describe tokenizer trajectory tracking, not robot execution
97
+ accuracy. Selected benchmark results do not establish a general advantage for
98
+ the extended edition or predict performance on other tasks or real robots.
99
+
100
+ See [configuration and measured results](https://github.com/ZibinDong/actioncodec2).
actioncodec2_kernels/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Portable ActionCodec2 CPU kernels."""
actioncodec2_kernels/module.cpp ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // pybind11 entry point for the ActionCodec2 physical-stage kernels.
2
+ //
3
+ // These are the encode/decode kernels the runtime bundled with an ActionCodec2
4
+ // Hugging Face artifact calls when a native module is available. Each one has
5
+ // a bit-identical NumPy implementation in that runtime.
6
+
7
+ #include <pybind11/numpy.h>
8
+ #include <pybind11/pybind11.h>
9
+
10
+ #include <cstdint>
11
+ #include <stdexcept>
12
+
13
+ #include "parallel.hpp"
14
+
15
+ namespace py = pybind11;
16
+
17
+ py::array_t<int32_t> so3_second_order_encode_batch(
18
+ const py::array_t<double, py::array::c_style | py::array::forcecast>&,
19
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
20
+ const py::array_t<double, py::array::c_style | py::array::forcecast>&,
21
+ const py::array_t<double, py::array::c_style | py::array::forcecast>&,
22
+ int32_t, bool, double, double, bool, int);
23
+
24
+ py::array_t<int32_t> additive_second_order_encode_batch(
25
+ const py::array_t<float, py::array::c_style | py::array::forcecast>&,
26
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
27
+ const py::array_t<double, py::array::c_style | py::array::forcecast>&,
28
+ const py::array_t<double, py::array::c_style | py::array::forcecast>&,
29
+ const py::array_t<float, py::array::c_style | py::array::forcecast>&, bool, int);
30
+
31
+ py::array_t<float> physical_decode_batch(
32
+ const py::array_t<float>&, const py::array_t<int32_t>&, const py::array_t<uint8_t>&,
33
+ const py::array_t<float>&, const py::array_t<float>&,
34
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
35
+ const py::array_t<float, py::array::c_style | py::array::forcecast>&,
36
+ const py::array_t<float, py::array::c_style | py::array::forcecast>&, int32_t, int);
37
+
38
+ py::array_t<int32_t> physical_encode_batch(
39
+ const py::array_t<float>&, const py::array_t<int32_t>&, const py::array_t<uint8_t>&,
40
+ const py::array_t<float>&, const py::array_t<float>&,
41
+ const py::array_t<float, py::array::c_style | py::array::forcecast>&,
42
+ const py::array_t<float, py::array::c_style | py::array::forcecast>&,
43
+ const py::array_t<float, py::array::c_style | py::array::forcecast>&, int32_t, int,
44
+ const py::array_t<float>&, bool);
45
+
46
+ py::array_t<int32_t> so3_encode_batch(
47
+ const py::array_t<double, py::array::c_style | py::array::forcecast>&,
48
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>&,
49
+ const py::array_t<float, py::array::c_style | py::array::forcecast>&, int32_t, bool, bool,
50
+ double, bool, int);
51
+
52
+ void register_setbpe(py::module_& module);
53
+ void register_resample(py::module_& module);
54
+
55
+ PYBIND11_MODULE(_actioncodec2_native, module) {
56
+ module.doc() = "ActionCodec2 encode/decode kernels: physical stage, resampling, Set-BPE";
57
+ register_setbpe(module);
58
+ register_resample(module);
59
+ module.def(
60
+ "set_thread_budget",
61
+ [](int threads) {
62
+ if (threads < 0) throw std::invalid_argument("thread budget must be non-negative");
63
+ ac2::thread_budget().store(threads);
64
+ },
65
+ py::arg("threads"),
66
+ "Cap threads per call for this process; 0 uses every CPU it may run on.");
67
+ module.def("thread_budget", [] { return ac2::thread_budget().load(); });
68
+ module.def("plan_threads", &ac2::plan_threads, py::arg("count"), py::arg("requested"),
69
+ py::arg("seconds_each"), "Threads a call would use for ``count`` items.");
70
+ module.def("so3_second_order_encode_batch", &so3_second_order_encode_batch,
71
+ py::arg("table"), py::arg("bins"), py::arg("quaternions"),
72
+ py::arg("previous_first_order"), py::arg("input_order"), py::arg("left"),
73
+ py::arg("limit"), py::arg("radius"), py::arg("smooth"), py::arg("threads") = 0);
74
+ module.def("additive_second_order_encode_batch", &additive_second_order_encode_batch,
75
+ py::arg("table"), py::arg("bins"), py::arg("positions"),
76
+ py::arg("previous_first_order"), py::arg("epsilon"), py::arg("smooth"),
77
+ py::arg("threads") = 0);
78
+ module.def("physical_decode_batch", &physical_decode_batch, py::arg("table"),
79
+ py::arg("bins"), py::arg("binary"), py::arg("lower"), py::arg("upper"),
80
+ py::arg("cells"), py::arg("initial"), py::arg("initial_velocity"),
81
+ py::arg("order"), py::arg("threads") = 0);
82
+ module.def("physical_encode_batch", &physical_encode_batch, py::arg("table"), py::arg("bins"),
83
+ py::arg("binary"), py::arg("lower"), py::arg("upper"), py::arg("positions"),
84
+ py::arg("initial"), py::arg("initial_velocity"), py::arg("order"),
85
+ py::arg("threads") = 0, py::arg("epsilon") = py::array_t<float>(),
86
+ py::arg("smooth") = false);
87
+ module.def("so3_encode_batch", &so3_encode_batch, py::arg("table"), py::arg("bins"),
88
+ py::arg("actions"), py::arg("input_order"), py::arg("rotation_vector"),
89
+ py::arg("left"), py::arg("limit"), py::arg("smooth"), py::arg("threads") = 0);
90
+ }
actioncodec2_kernels/parallel.hpp ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Fork-safe data parallelism for the runtime encode/decode kernels.
2
+ //
3
+ // These kernels run inside forked processes (for example PyTorch DataLoader
4
+ // workers). GNU OpenMP keeps a process-wide thread pool that does not survive
5
+ // fork(): once the parent has entered a parallel region -- ours, or any other
6
+ // library's sharing libgomp -- the child hangs or crashes when it enters one.
7
+ // This helper instead starts its threads per call and joins them before
8
+ // returning, so no thread state outlives a call and fork() is always safe.
9
+ //
10
+ // Iterations are split into contiguous static blocks, like OpenMP's
11
+ // ``schedule(static)``, and every iteration writes only its own outputs, so
12
+ // results do not depend on the thread count.
13
+ //
14
+ // Starting a thread costs tens of microseconds, and one iteration costs from
15
+ // well under a microsecond to about a hundred depending on the kernel and the
16
+ // profile's primitive counts. So each call times its first iteration on the
17
+ // calling thread and sizes the split of the remaining work ``W`` from it: the
18
+ // calling thread starts threads one after another at ``s`` seconds each, so a
19
+ // split over ``w`` threads takes about ``W / w + s * w``, least at
20
+ // ``w = sqrt(W / s)``.
21
+ #pragma once
22
+
23
+ #include <algorithm>
24
+ #include <atomic>
25
+ #include <chrono>
26
+ #include <cmath>
27
+ #include <cstdint>
28
+ #include <exception>
29
+ #include <mutex>
30
+ #include <thread>
31
+ #include <vector>
32
+
33
+ #ifdef __linux__
34
+ #include <sched.h>
35
+ #endif
36
+
37
+ namespace ac2 {
38
+
39
+ // Process-wide cap on threads per call; 0 means every CPU the process may use.
40
+ inline std::atomic<int>& thread_budget() {
41
+ static std::atomic<int> value{0};
42
+ return value;
43
+ }
44
+
45
+ inline int available_cpus() {
46
+ #ifdef __linux__
47
+ cpu_set_t allowed;
48
+ CPU_ZERO(&allowed);
49
+ if (sched_getaffinity(0, sizeof(allowed), &allowed) == 0) {
50
+ return std::max(1, CPU_COUNT(&allowed));
51
+ }
52
+ #endif
53
+ return std::max(1, static_cast<int>(std::thread::hardware_concurrency()));
54
+ }
55
+
56
+ // Measured cost of starting and joining one thread (seconds).
57
+ constexpr double kThreadStartSeconds = 30e-6;
58
+
59
+ // Threads for ``count`` iterations of ``seconds_each``: at most ``requested``
60
+ // when positive, at most the process budget, and at most ``sqrt(W / s)``.
61
+ inline int64_t plan_threads(int64_t count, int requested, double seconds_each) {
62
+ const int budget = thread_budget().load();
63
+ int64_t threads = budget > 0 ? budget : available_cpus();
64
+ if (requested > 0) threads = std::min<int64_t>(threads, requested);
65
+ const double by_work = std::sqrt(count * seconds_each / kThreadStartSeconds);
66
+ if (by_work < static_cast<double>(threads)) threads = static_cast<int64_t>(by_work);
67
+ return std::max<int64_t>(1, std::min(threads, count));
68
+ }
69
+
70
+ // Call ``body(index)`` for every index in [0, count). Iteration 0 runs first
71
+ // on the calling thread and sizes the split of the rest; the calling thread
72
+ // also runs the first block of the rest. The first exception thrown by any
73
+ // block is rethrown after all threads have joined.
74
+ template <class Body>
75
+ void parallel_for(int64_t count, int threads, Body&& body) {
76
+ if (count <= 0) return;
77
+ const auto start = std::chrono::steady_clock::now();
78
+ body(0);
79
+ const double seconds_each =
80
+ std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
81
+ const int64_t rest = count - 1;
82
+ if (rest == 0) return;
83
+ const int64_t workers = plan_threads(rest, threads, seconds_each);
84
+ auto run = [&](int64_t worker) {
85
+ const int64_t begin = 1 + rest * worker / workers;
86
+ const int64_t end = 1 + rest * (worker + 1) / workers;
87
+ for (int64_t index = begin; index < end; ++index) body(index);
88
+ };
89
+ if (workers == 1) {
90
+ run(0);
91
+ return;
92
+ }
93
+ std::exception_ptr failure;
94
+ std::mutex failure_lock;
95
+ auto guarded = [&](int64_t worker) {
96
+ try {
97
+ run(worker);
98
+ } catch (...) {
99
+ std::lock_guard<std::mutex> hold(failure_lock);
100
+ if (!failure) failure = std::current_exception();
101
+ }
102
+ };
103
+ std::vector<std::thread> pool;
104
+ pool.reserve(static_cast<size_t>(workers - 1));
105
+ try {
106
+ for (int64_t worker = 1; worker < workers; ++worker) pool.emplace_back(guarded, worker);
107
+ } catch (...) {
108
+ for (auto& thread : pool) thread.join();
109
+ throw;
110
+ }
111
+ guarded(0);
112
+ for (auto& thread : pool) thread.join();
113
+ if (failure) std::rethrow_exception(failure);
114
+ }
115
+
116
+ } // namespace ac2
actioncodec2_kernels/physical.cpp ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Native physical-stage recursions.
2
+ //
3
+ // The encoders are strictly sequential in time -- each step's request depends
4
+ // on the reconstruction error the previous step left behind -- so the win here
5
+ // is not vectorization but removing per-step interpreter and NumPy dispatch
6
+ // overhead. That overhead dominates exactly where it hurts most: caching a
7
+ // dataset as fixed-horizon chunks calls the encoder millions of times with
8
+ // T ~ 20, where a NumPy implementation spends nearly all of its time in call
9
+ // setup. The batch entry points additionally spread chunks over threads.
10
+
11
+ #include <pybind11/pybind11.h>
12
+ #include <pybind11/numpy.h>
13
+ #include <pybind11/stl.h>
14
+
15
+ #include <algorithm>
16
+ #include <cfloat>
17
+ #include <cmath>
18
+ #include <cstdint>
19
+ #include <vector>
20
+
21
+ #include "parallel.hpp"
22
+
23
+ namespace py = pybind11;
24
+
25
+ namespace {
26
+
27
+ struct SecondOrderAxis {
28
+ const float* values;
29
+ int32_t count;
30
+ bool ordered;
31
+ int32_t nonnegative;
32
+ int32_t nonpositive;
33
+ };
34
+
35
+ template <bool Smooth>
36
+ inline int32_t pick_second_order_axis(const SecondOrderAxis& axis, double request,
37
+ double direction, double epsilon) {
38
+ const float* values = axis.values;
39
+ if (axis.ordered) {
40
+ const int32_t insertion = std::lower_bound(values, values + axis.count, request) - values;
41
+ const int32_t left = std::max(0, insertion - 1);
42
+ const int32_t right = std::min(insertion, axis.count - 1);
43
+ int32_t nearest = std::fabs(request - values[left]) <= std::fabs(request - values[right])
44
+ ? left : right;
45
+ // Rounded distance plateaus must keep the original first-cell tie-break.
46
+ while (nearest > 0 && std::fabs(request - values[nearest - 1]) == std::fabs(request - values[nearest]))
47
+ --nearest;
48
+ if constexpr (!Smooth) return nearest;
49
+ const int32_t preferred = direction > 0 ? std::max(nearest, axis.nonnegative)
50
+ : direction < 0 ? std::min(nearest, axis.nonpositive) : nearest;
51
+ const double primitive = values[preferred];
52
+ const double sign = primitive > 0 ? 1 : primitive < 0 ? -1 : 0;
53
+ return sign * direction >= 0 && std::fabs(request - primitive) <= epsilon ? preferred : nearest;
54
+ }
55
+ // Small tables are faster to scan; arbitrary stored order also uses this path.
56
+ int32_t nearest = 0, feasible = -1, preferred = -1;
57
+ double nearest_distance = INFINITY, feasible_distance = INFINITY, preferred_distance = INFINITY;
58
+ for (int32_t k = 0; k < axis.count; ++k) {
59
+ const double primitive = values[k];
60
+ const double distance = std::fabs(request - primitive);
61
+ if (distance < nearest_distance) { nearest = k; nearest_distance = distance; }
62
+ if constexpr (!Smooth) continue;
63
+ if (distance > epsilon) continue;
64
+ if (distance < feasible_distance) { feasible = k; feasible_distance = distance; }
65
+ const double sign = primitive > 0 ? 1 : primitive < 0 ? -1 : 0;
66
+ if (sign * direction >= 0 && distance < preferred_distance) {
67
+ preferred = k;
68
+ preferred_distance = distance;
69
+ }
70
+ }
71
+ return preferred >= 0 ? preferred : feasible >= 0 ? feasible : nearest;
72
+ }
73
+
74
+ } // namespace
75
+
76
+ py::array_t<int32_t> additive_second_order_encode_batch(
77
+ const py::array_t<float, py::array::c_style | py::array::forcecast>& table,
78
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& bins,
79
+ const py::array_t<double, py::array::c_style | py::array::forcecast>& positions,
80
+ const py::array_t<double, py::array::c_style | py::array::forcecast>& previous_first_order,
81
+ const py::array_t<float, py::array::c_style | py::array::forcecast>& epsilon,
82
+ bool smooth, int threads) {
83
+ if (positions.ndim() != 3 || table.ndim() != 2 || previous_first_order.ndim() != 2)
84
+ throw std::invalid_argument("invalid second-order additive batch shapes");
85
+ const int64_t batch = positions.shape(0), steps = positions.shape(1), dim = positions.shape(2);
86
+ if (table.shape(0) != dim || bins.size() != dim || epsilon.size() != dim ||
87
+ previous_first_order.shape(0) != batch || previous_first_order.shape(1) != dim)
88
+ throw std::invalid_argument("inconsistent second-order additive batch dimensions");
89
+ for (int64_t j = 0; j < dim; ++j) {
90
+ if (bins.data()[j] < 1 || bins.data()[j] > table.shape(1))
91
+ throw std::invalid_argument("primitive count exceeds table width");
92
+ }
93
+ py::array_t<int32_t> output({batch, steps, dim});
94
+ const auto* targets = positions.data();
95
+ const auto* initial = previous_first_order.data();
96
+ const auto* axes = table.data();
97
+ const auto* counts = bins.data();
98
+ const auto* limits = epsilon.data();
99
+ auto* cells = output.mutable_data();
100
+ const int64_t width = table.shape(1);
101
+ std::vector<SecondOrderAxis> search;
102
+ for (int64_t j = 0; j < dim; ++j) {
103
+ const float* begin = axes + j * width;
104
+ const float* end = begin + counts[j];
105
+ const bool ordered = counts[j] > 16 &&
106
+ std::adjacent_find(begin, end, [](float a, float b) { return a >= b; }) == end;
107
+ const int32_t nonnegative = ordered ? std::lower_bound(begin, end, 0.0f) - begin : 0;
108
+ const int32_t nonpositive = ordered ? std::upper_bound(begin, end, 0.0f) - begin - 1 : 0;
109
+ search.push_back({begin, counts[j], ordered, std::min(nonnegative, counts[j] - 1),
110
+ std::max(nonpositive, 0)});
111
+ }
112
+ py::gil_scoped_release release;
113
+ ac2::parallel_for(batch, threads, [&](int64_t i) {
114
+ std::vector<double> position(dim, 0), velocity(initial+i*dim, initial+(i+1)*dim), direction(dim, 0);
115
+ for (int64_t t = 0; t < steps; ++t) {
116
+ for (int64_t j = 0; j < dim; ++j) {
117
+ const double request = targets[(i*steps+t)*dim+j] - (position[j]+velocity[j]);
118
+ const int32_t choice = smooth
119
+ ? pick_second_order_axis<true>(search[j], request, direction[j], limits[j])
120
+ : pick_second_order_axis<false>(search[j], request, direction[j], limits[j]);
121
+ const double primitive = axes[j*width+choice];
122
+ velocity[j] += primitive;
123
+ position[j] += velocity[j];
124
+ if (primitive != 0) direction[j] = primitive > 0 ? 1 : -1;
125
+ cells[(i*steps+t)*dim+j] = choice;
126
+ }
127
+ }
128
+ });
129
+ return output;
130
+ }
131
+
132
+ namespace {
133
+
134
+ struct Layout {
135
+ int32_t dimension;
136
+ int32_t width; // padded primitive-table stride
137
+ const float* table; // [dimension * width], +inf padding
138
+ const int32_t* bins; // [dimension]
139
+ const uint8_t* binary; // [dimension], 1 for absolute two-valued axes
140
+ const float* lower;
141
+ const float* upper;
142
+ const float* epsilon; // [dimension]; null unless `smooth` is set
143
+ bool smooth; // pick by FirstOrderCodec._select_smooth
144
+ };
145
+
146
+ // Matches PhysicalCodecBase.SOURCE_TOLERANCE.
147
+ constexpr float kSourceTolerance = 8.0F * FLT_EPSILON;
148
+
149
+ inline int32_t nearest(const Layout& layout, int32_t axis, float request) {
150
+ const float* values = layout.table + static_cast<size_t>(axis) * layout.width;
151
+ const int32_t count = layout.bins[axis];
152
+ int32_t best = 0;
153
+ float best_distance = std::fabs(values[0] - request);
154
+ for (int32_t index = 1; index < count; ++index) {
155
+ const float distance = std::fabs(values[index] - request);
156
+ if (distance < best_distance) {
157
+ best_distance = distance;
158
+ best = index;
159
+ }
160
+ }
161
+ return best;
162
+ }
163
+
164
+ // FirstOrderCodec._select_smooth, primitive for primitive. Lexicographic in
165
+ // (does not reverse `direction`, distance to the request), restricted to the
166
+ // primitives already within epsilon; nothing feasible falls back to `nearest`.
167
+ inline int32_t select_smooth(const Layout& layout, int32_t axis, float request, float direction) {
168
+ const float* values = layout.table + static_cast<size_t>(axis) * layout.width;
169
+ const int32_t count = layout.bins[axis];
170
+ const float radius = layout.epsilon[axis] + kSourceTolerance;
171
+ int32_t nearest_index = 0;
172
+ float nearest_distance = std::fabs(values[0] - request);
173
+ bool any_feasible = false;
174
+ int32_t feasible_index = 0;
175
+ float feasible_distance = 0.0F;
176
+ bool any_preferred = false;
177
+ int32_t preferred_index = 0;
178
+ float preferred_distance = 0.0F;
179
+ for (int32_t index = 0; index < count; ++index) {
180
+ const float distance = std::fabs(values[index] - request);
181
+ if (index > 0 && distance < nearest_distance) {
182
+ nearest_distance = distance;
183
+ nearest_index = index;
184
+ }
185
+ if (!(distance <= radius)) continue;
186
+ if (!any_feasible || distance < feasible_distance) {
187
+ any_feasible = true;
188
+ feasible_distance = distance;
189
+ feasible_index = index;
190
+ }
191
+ // np.sign(value) * direction >= 0: a zero primitive never reverses.
192
+ const float sign = values[index] > 0.0F ? 1.0F : (values[index] < 0.0F ? -1.0F : 0.0F);
193
+ if (!(sign * direction >= 0.0F)) continue;
194
+ if (!any_preferred || distance < preferred_distance) {
195
+ any_preferred = true;
196
+ preferred_distance = distance;
197
+ preferred_index = index;
198
+ }
199
+ }
200
+ if (any_preferred) return preferred_index;
201
+ if (any_feasible) return feasible_index;
202
+ return nearest_index;
203
+ }
204
+
205
+ void encode_first_order(const Layout& layout, const float* positions, int64_t steps,
206
+ const float* initial, int32_t* cells) {
207
+ const int32_t dimension = layout.dimension;
208
+ std::vector<float> state(initial, initial + dimension);
209
+ // Sign of the last non-zero primitive each coordinate emitted.
210
+ std::vector<float> direction(dimension, 0.0F);
211
+ for (int64_t step = 0; step < steps; ++step) {
212
+ const float* target = positions + step * dimension;
213
+ int32_t* row = cells + step * dimension;
214
+ for (int32_t axis = 0; axis < dimension; ++axis) {
215
+ const float* values = layout.table + static_cast<size_t>(axis) * layout.width;
216
+ if (layout.binary[axis]) {
217
+ const int32_t cell = nearest(layout, axis, target[axis]);
218
+ state[axis] = values[cell];
219
+ row[axis] = cell;
220
+ continue;
221
+ }
222
+ const float request = target[axis] - state[axis];
223
+ const int32_t cell = layout.smooth ? select_smooth(layout, axis, request, direction[axis])
224
+ : nearest(layout, axis, request);
225
+ const float picked = values[cell];
226
+ state[axis] = std::min(std::max(state[axis] + picked, layout.lower[axis]),
227
+ layout.upper[axis]);
228
+ if (picked != 0.0F) direction[axis] = picked > 0.0F ? 1.0F : -1.0F;
229
+ row[axis] = cell;
230
+ }
231
+ }
232
+ }
233
+
234
+ void decode_first_order(const Layout& layout, const int32_t* cells, int64_t steps,
235
+ const float* initial, float* positions) {
236
+ const int32_t dimension = layout.dimension;
237
+ std::vector<float> state(initial, initial + dimension);
238
+ for (int64_t step = 0; step < steps; ++step) {
239
+ const int32_t* row = cells + step * dimension;
240
+ float* out = positions + step * dimension;
241
+ for (int32_t axis = 0; axis < dimension; ++axis) {
242
+ const float* values = layout.table + static_cast<size_t>(axis) * layout.width;
243
+ const float primitive = values[row[axis]];
244
+ if (layout.binary[axis]) {
245
+ state[axis] = primitive;
246
+ } else {
247
+ state[axis] = std::min(std::max(state[axis] + primitive, layout.lower[axis]),
248
+ layout.upper[axis]);
249
+ }
250
+ out[axis] = state[axis];
251
+ }
252
+ }
253
+ }
254
+
255
+ void encode_second_order(const Layout& layout, const float* positions, int64_t steps,
256
+ const float* initial, const float* initial_velocity, int32_t* cells) {
257
+ const int32_t dimension = layout.dimension;
258
+ std::vector<float> source(initial, initial + dimension);
259
+ std::vector<float> source_velocity(initial_velocity, initial_velocity + dimension);
260
+ std::vector<float> recon(initial, initial + dimension);
261
+ std::vector<float> recon_velocity(initial_velocity, initial_velocity + dimension);
262
+ std::vector<float> error(dimension, 0.0F);
263
+ std::vector<float> previous(dimension, 0.0F);
264
+ for (int64_t step = 0; step < steps; ++step) {
265
+ const float* target = positions + step * dimension;
266
+ int32_t* row = cells + step * dimension;
267
+ for (int32_t axis = 0; axis < dimension; ++axis) {
268
+ const float* values = layout.table + static_cast<size_t>(axis) * layout.width;
269
+ if (layout.binary[axis]) {
270
+ row[axis] = nearest(layout, axis, target[axis]);
271
+ continue;
272
+ }
273
+ const float velocity = target[axis] - source[axis];
274
+ const float request =
275
+ 2.0F * error[axis] - previous[axis] + (velocity - source_velocity[axis]);
276
+ const int32_t cell = nearest(layout, axis, request);
277
+ recon_velocity[axis] += values[cell];
278
+ recon[axis] += recon_velocity[axis];
279
+ source[axis] = target[axis];
280
+ source_velocity[axis] = velocity;
281
+ previous[axis] = error[axis];
282
+ error[axis] = source[axis] - recon[axis];
283
+ row[axis] = cell;
284
+ }
285
+ }
286
+ }
287
+
288
+ void decode_second_order(const Layout& layout, const int32_t* cells, int64_t steps,
289
+ const float* initial, const float* initial_velocity, float* positions) {
290
+ const int32_t dimension = layout.dimension;
291
+ std::vector<float> state(initial, initial + dimension);
292
+ std::vector<float> velocity(initial_velocity, initial_velocity + dimension);
293
+ for (int64_t step = 0; step < steps; ++step) {
294
+ const int32_t* row = cells + step * dimension;
295
+ float* out = positions + step * dimension;
296
+ for (int32_t axis = 0; axis < dimension; ++axis) {
297
+ const float primitive = layout.table[static_cast<size_t>(axis) * layout.width + row[axis]];
298
+ if (layout.binary[axis]) {
299
+ out[axis] = primitive;
300
+ continue;
301
+ }
302
+ velocity[axis] += primitive;
303
+ state[axis] += velocity[axis];
304
+ out[axis] = state[axis];
305
+ }
306
+ }
307
+ }
308
+
309
+ Layout make_layout(const py::array_t<float>& table, const py::array_t<int32_t>& bins,
310
+ const py::array_t<uint8_t>& binary, const py::array_t<float>& lower,
311
+ const py::array_t<float>& upper, const py::array_t<float>& epsilon,
312
+ bool smooth) {
313
+ Layout layout;
314
+ layout.dimension = static_cast<int32_t>(table.shape(0));
315
+ layout.width = static_cast<int32_t>(table.shape(1));
316
+ layout.table = table.data();
317
+ layout.bins = bins.data();
318
+ layout.binary = binary.data();
319
+ layout.lower = lower.data();
320
+ layout.upper = upper.data();
321
+ layout.epsilon = epsilon.size() == 0 ? nullptr : epsilon.data();
322
+ layout.smooth = smooth && layout.epsilon != nullptr;
323
+ if (smooth && layout.epsilon == nullptr) {
324
+ throw std::invalid_argument("smooth selection needs a per-coordinate epsilon");
325
+ }
326
+ if (layout.epsilon != nullptr && epsilon.size() != layout.dimension) {
327
+ throw std::invalid_argument("epsilon must have one entry per coordinate");
328
+ }
329
+ return layout;
330
+ }
331
+
332
+ using Array2D = py::array_t<float, py::array::c_style | py::array::forcecast>;
333
+ using Array3D = py::array_t<float, py::array::c_style | py::array::forcecast>;
334
+ using Cells3D = py::array_t<int32_t, py::array::c_style | py::array::forcecast>;
335
+
336
+ } // namespace
337
+
338
+ py::array_t<float> physical_decode_batch(const py::array_t<float>& table,
339
+ const py::array_t<int32_t>& bins,
340
+ const py::array_t<uint8_t>& binary,
341
+ const py::array_t<float>& lower,
342
+ const py::array_t<float>& upper, const Cells3D& cells,
343
+ const Array2D& initial,
344
+ const Array2D& initial_velocity, int32_t order,
345
+ int threads) {
346
+ if (cells.ndim() != 3 || cells.shape(2) != table.shape(0) || initial.ndim() != 2 ||
347
+ initial_velocity.ndim() != 2 || initial.shape(0) != cells.shape(0) ||
348
+ initial_velocity.shape(0) != cells.shape(0) || initial.shape(1) != cells.shape(2) ||
349
+ initial_velocity.shape(1) != cells.shape(2) || (order != 1 && order != 2)) {
350
+ throw std::invalid_argument("invalid physical decode batch shapes or order");
351
+ }
352
+ const Layout layout =
353
+ make_layout(table, bins, binary, lower, upper, py::array_t<float>(), false);
354
+ const int64_t batch = cells.shape(0);
355
+ const int64_t steps = cells.shape(1);
356
+ const int64_t dimension = layout.dimension;
357
+ py::array_t<float> positions({batch, steps, dimension});
358
+ {
359
+ py::gil_scoped_release release;
360
+ ac2::parallel_for(batch, threads, [&](int64_t index) {
361
+ const int32_t* source = cells.data() + index * steps * dimension;
362
+ float* target = positions.mutable_data() + index * steps * dimension;
363
+ if (order == 1) {
364
+ decode_first_order(layout, source, steps, initial.data() + index * dimension, target);
365
+ } else {
366
+ decode_second_order(layout, source, steps, initial.data() + index * dimension,
367
+ initial_velocity.data() + index * dimension, target);
368
+ }
369
+ });
370
+ }
371
+ return positions;
372
+ }
373
+
374
+ // One thread per chunk. ``positions`` is [B, T, D] and ``initial`` is [B, D].
375
+ py::array_t<int32_t> physical_encode_batch(const py::array_t<float>& table,
376
+ const py::array_t<int32_t>& bins,
377
+ const py::array_t<uint8_t>& binary,
378
+ const py::array_t<float>& lower,
379
+ const py::array_t<float>& upper,
380
+ const Array3D& positions, const Array2D& initial,
381
+ const Array2D& initial_velocity, int32_t order,
382
+ int threads, const py::array_t<float>& epsilon,
383
+ bool smooth) {
384
+ const Layout layout = make_layout(table, bins, binary, lower, upper, epsilon, smooth);
385
+ const int64_t batch = positions.shape(0);
386
+ const int64_t steps = positions.shape(1);
387
+ const int64_t dimension = layout.dimension;
388
+ py::array_t<int32_t> cells({batch, steps, dimension});
389
+ const float* source = positions.data();
390
+ const float* starts = initial.data();
391
+ const float* velocities = initial_velocity.data();
392
+ int32_t* output = cells.mutable_data();
393
+ {
394
+ py::gil_scoped_release release;
395
+ ac2::parallel_for(batch, threads, [&](int64_t index) {
396
+ const float* chunk = source + index * steps * dimension;
397
+ int32_t* target = output + index * steps * dimension;
398
+ if (order == 1) {
399
+ encode_first_order(layout, chunk, steps, starts + index * dimension, target);
400
+ } else {
401
+ encode_second_order(layout, chunk, steps, starts + index * dimension,
402
+ velocities + index * dimension, target);
403
+ }
404
+ });
405
+ }
406
+ return cells;
407
+ }
actioncodec2_kernels/resample.cpp ADDED
@@ -0,0 +1,833 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Rate conversion and pose algebra for canonical ActionCodec2 trajectories.
2
+ //
3
+ // A port of the runtime's ``physical/semantic.py`` resampling and pose helpers,
4
+ // including the scipy pieces they call (natural CubicSpline, PchipInterpolator,
5
+ // PPoly evaluation, RotationSpline). Operations follow the NumPy/scipy order
6
+ // of evaluation, and the LAPACK solvers are the reference algorithms, so
7
+ // results agree with the Python runtime to the last bits of float64; the
8
+ // float32 outputs therefore match except where a value sits on a rounding
9
+ // boundary. Batch entry points run rows in parallel without the GIL.
10
+
11
+ #include <pybind11/numpy.h>
12
+ #include <pybind11/pybind11.h>
13
+
14
+ #include <algorithm>
15
+ #include <array>
16
+ #include <cmath>
17
+ #include <cstdint>
18
+ #include <stdexcept>
19
+ #include <string>
20
+ #include <vector>
21
+
22
+ #include "parallel.hpp"
23
+
24
+ namespace py = pybind11;
25
+
26
+ namespace {
27
+
28
+ using Vec = std::vector<double>;
29
+
30
+ struct Quat {
31
+ double x, y, z, w;
32
+ };
33
+
34
+ // ---- runtime ``_so3`` quaternion math (xyzw), NumPy evaluation order ----
35
+
36
+ Quat so3_normalize(const Quat& q) {
37
+ const double norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
38
+ if (norm < 1e-12) throw std::invalid_argument("a pose contains a zero quaternion");
39
+ return {q.x / norm, q.y / norm, q.z / norm, q.w / norm};
40
+ }
41
+
42
+ Quat so3_multiply(const Quat& a, const Quat& b) {
43
+ const double cx = a.y * b.z - a.z * b.y;
44
+ const double cy = a.z * b.x - a.x * b.z;
45
+ const double cz = a.x * b.y - a.y * b.x;
46
+ return so3_normalize({a.w * b.x + b.w * a.x + cx, a.w * b.y + b.w * a.y + cy,
47
+ a.w * b.z + b.w * a.z + cz,
48
+ a.w * b.w - (a.x * b.x + a.y * b.y + a.z * b.z)});
49
+ }
50
+
51
+ Quat so3_from_rotvec(double x, double y, double z) {
52
+ const double angle = std::sqrt(x * x + y * y + z * z);
53
+ const double scale = angle >= 1e-12 ? std::sin(0.5 * angle) / angle : 0.5;
54
+ return so3_normalize({x * scale, y * scale, z * scale, std::cos(0.5 * angle)});
55
+ }
56
+
57
+ void so3_to_rotvec(const Quat& raw, double* out) {
58
+ Quat q = so3_normalize(raw);
59
+ if (q.w < 0.0) q = {-q.x, -q.y, -q.z, -q.w};
60
+ const double vector_norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z);
61
+ const double angle = 2.0 * std::atan2(vector_norm, std::min(std::max(q.w, 0.0), 1.0));
62
+ const double scale = vector_norm >= 1e-12 ? angle / vector_norm : 2.0;
63
+ out[0] = q.x * scale;
64
+ out[1] = q.y * scale;
65
+ out[2] = q.z * scale;
66
+ }
67
+
68
+ // ---- scipy ``Rotation`` operations used by RotationSpline ----
69
+
70
+ Quat sp_from_quat(const Quat& q) {
71
+ const double norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
72
+ if (!(norm > 0.0)) throw std::invalid_argument("found zero norm quaternions in `quat`");
73
+ return {q.x / norm, q.y / norm, q.z / norm, q.w / norm};
74
+ }
75
+
76
+ Quat sp_inv(const Quat& q) { return {-q.x, -q.y, -q.z, q.w}; }
77
+
78
+ Quat sp_compose(const Quat& p, const Quat& q) {
79
+ const double cx = p.y * q.z - p.z * q.y;
80
+ const double cy = p.z * q.x - p.x * q.z;
81
+ const double cz = p.x * q.y - p.y * q.x;
82
+ const Quat r{p.w * q.x + q.w * p.x + cx, p.w * q.y + q.w * p.y + cy, p.w * q.z + q.w * p.z + cz,
83
+ p.w * q.w - (p.x * q.x + p.y * q.y + p.z * q.z)};
84
+ return sp_from_quat(r);
85
+ }
86
+
87
+ void sp_as_rotvec(Quat q, double* out) {
88
+ if (q.w < 0.0) q = {-q.x, -q.y, -q.z, -q.w};
89
+ const double angle = 2.0 * std::atan2(std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z), q.w);
90
+ double scale;
91
+ if (angle <= 1e-3) {
92
+ const double angle2 = angle * angle;
93
+ scale = 2.0 + angle2 / 12.0 + 7.0 * angle2 * angle2 / 2880.0;
94
+ } else {
95
+ scale = angle / std::sin(angle / 2.0);
96
+ }
97
+ out[0] = scale * q.x;
98
+ out[1] = scale * q.y;
99
+ out[2] = scale * q.z;
100
+ }
101
+
102
+ Quat sp_from_rotvec(const double* v) {
103
+ const double angle = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
104
+ double scale;
105
+ if (angle <= 1e-3) {
106
+ const double angle2 = angle * angle;
107
+ scale = 0.5 - angle2 / 48.0 + angle2 * angle2 / 3840.0;
108
+ } else {
109
+ scale = std::sin(angle / 2.0) / angle;
110
+ }
111
+ return {scale * v[0], scale * v[1], scale * v[2], std::cos(angle / 2.0)};
112
+ }
113
+
114
+ // ---- piecewise cubics (scipy PPoly with CubicHermiteSpline coefficients) ----
115
+
116
+ // PPoly interval: x[i] <= t < x[i+1], extrapolating with the end intervals.
117
+ int64_t ppoly_interval(const Vec& x, double t) {
118
+ const int64_t n = static_cast<int64_t>(x.size());
119
+ if (t >= x[n - 1]) return n - 2;
120
+ if (t < x[0]) return 0;
121
+ return static_cast<int64_t>(std::upper_bound(x.begin(), x.end(), t) - x.begin()) - 1;
122
+ }
123
+
124
+ // scipy ``evaluate_poly1`` for a cubic: sum of c[k-1-p] * s**p, powers built by
125
+ // repeated multiplication.
126
+ double ppoly_value(const double* c, double s) {
127
+ double result = 0.0, z = 1.0;
128
+ result = result + c[3] * z;
129
+ z *= s;
130
+ result = result + c[2] * z;
131
+ z *= s;
132
+ result = result + c[1] * z;
133
+ z *= s;
134
+ result = result + c[0] * z;
135
+ return result;
136
+ }
137
+
138
+ // Hermite coefficients per interval and column: c[(k*4 + p)*cols + a] with
139
+ // p = 0..3 the descending powers, as CubicHermiteSpline stacks them.
140
+ Vec hermite_coefficients(const Vec& x, const Vec& y, const Vec& slopes, int64_t cols) {
141
+ const int64_t n = static_cast<int64_t>(x.size());
142
+ Vec c(static_cast<size_t>((n - 1) * 4 * cols));
143
+ for (int64_t k = 0; k < n - 1; ++k) {
144
+ const double dx = x[k + 1] - x[k];
145
+ for (int64_t a = 0; a < cols; ++a) {
146
+ const double y0 = y[k * cols + a], y1 = y[(k + 1) * cols + a];
147
+ const double d0 = slopes[k * cols + a], d1 = slopes[(k + 1) * cols + a];
148
+ const double slope = (y1 - y0) / dx;
149
+ const double t = (d0 + d1 - 2.0 * slope) / dx;
150
+ c[(k * 4 + 0) * cols + a] = t / dx;
151
+ c[(k * 4 + 1) * cols + a] = (slope - d0) / dx - t;
152
+ c[(k * 4 + 2) * cols + a] = d0;
153
+ c[(k * 4 + 3) * cols + a] = y0;
154
+ }
155
+ }
156
+ return c;
157
+ }
158
+
159
+ void ppoly_evaluate(const Vec& x, const Vec& c, int64_t cols, const Vec& times, int64_t out_cols,
160
+ int64_t col_offset, double* out) {
161
+ for (size_t i = 0; i < times.size(); ++i) {
162
+ const int64_t k = ppoly_interval(x, times[i]);
163
+ const double s = times[i] - x[k];
164
+ for (int64_t a = 0; a < cols; ++a) {
165
+ const double coeff[4] = {c[(k * 4 + 0) * cols + a], c[(k * 4 + 1) * cols + a],
166
+ c[(k * 4 + 2) * cols + a], c[(k * 4 + 3) * cols + a]};
167
+ out[i * out_cols + col_offset + a] = ppoly_value(coeff, s);
168
+ }
169
+ }
170
+ }
171
+
172
+ // LAPACK dgtsv (reference algorithm) for several right-hand sides b[n][cols].
173
+ void gtsv(Vec dl, Vec d, Vec du, Vec& b, int64_t cols) {
174
+ const int64_t n = static_cast<int64_t>(d.size());
175
+ auto pivot = [&](int64_t i, bool last) {
176
+ if (std::fabs(d[i]) >= std::fabs(dl[i])) {
177
+ if (d[i] == 0.0) throw std::invalid_argument("singular matrix");
178
+ const double fact = dl[i] / d[i];
179
+ d[i + 1] = d[i + 1] - fact * du[i];
180
+ for (int64_t j = 0; j < cols; ++j) b[(i + 1) * cols + j] = b[(i + 1) * cols + j] - fact * b[i * cols + j];
181
+ if (!last) dl[i] = 0.0;
182
+ } else {
183
+ const double fact = d[i] / dl[i];
184
+ d[i] = dl[i];
185
+ const double temp = d[i + 1];
186
+ d[i + 1] = du[i] - fact * temp;
187
+ if (!last) {
188
+ dl[i] = du[i + 1];
189
+ du[i + 1] = -fact * dl[i];
190
+ }
191
+ du[i] = temp;
192
+ for (int64_t j = 0; j < cols; ++j) {
193
+ const double swap = b[i * cols + j];
194
+ b[i * cols + j] = b[(i + 1) * cols + j];
195
+ b[(i + 1) * cols + j] = swap - fact * b[(i + 1) * cols + j];
196
+ }
197
+ }
198
+ };
199
+ for (int64_t i = 0; i + 2 < n; ++i) pivot(i, false);
200
+ if (n > 1) pivot(n - 2, true);
201
+ if (d[n - 1] == 0.0) throw std::invalid_argument("singular matrix");
202
+ for (int64_t j = 0; j < cols; ++j) {
203
+ b[(n - 1) * cols + j] = b[(n - 1) * cols + j] / d[n - 1];
204
+ if (n > 1)
205
+ b[(n - 2) * cols + j] = (b[(n - 2) * cols + j] - du[n - 2] * b[(n - 1) * cols + j]) / d[n - 2];
206
+ for (int64_t i = n - 3; i >= 0; --i) {
207
+ b[i * cols + j] =
208
+ (b[i * cols + j] - du[i] * b[(i + 1) * cols + j] - dl[i] * b[(i + 2) * cols + j]) / d[i];
209
+ }
210
+ }
211
+ }
212
+
213
+ // scipy CubicSpline(bc_type="natural") slopes at the knots.
214
+ Vec natural_cubic_slopes(const Vec& x, const Vec& y, int64_t cols) {
215
+ const int64_t n = static_cast<int64_t>(x.size());
216
+ Vec dx(n - 1), slope((n - 1) * cols);
217
+ for (int64_t k = 0; k < n - 1; ++k) {
218
+ dx[k] = x[k + 1] - x[k];
219
+ for (int64_t a = 0; a < cols; ++a) slope[k * cols + a] = (y[(k + 1) * cols + a] - y[k * cols + a]) / dx[k];
220
+ }
221
+ // Banded A (3 x n): A[0] superdiagonal, A[1] diagonal, A[2] subdiagonal.
222
+ Vec upper(n, 0.0), diagonal(n, 0.0), lower(n, 0.0), b(n * cols);
223
+ for (int64_t k = 1; k < n - 1; ++k) diagonal[k] = 2.0 * (dx[k - 1] + dx[k]);
224
+ for (int64_t k = 2; k < n; ++k) upper[k] = dx[k - 2];
225
+ for (int64_t k = 0; k + 2 < n; ++k) lower[k] = dx[k + 1];
226
+ for (int64_t k = 1; k < n - 1; ++k) {
227
+ for (int64_t a = 0; a < cols; ++a)
228
+ b[k * cols + a] = 3.0 * (dx[k] * slope[(k - 1) * cols + a] + dx[k - 1] * slope[k * cols + a]);
229
+ }
230
+ // Natural ends: second derivative zero (bc value 0.0).
231
+ diagonal[0] = 2.0 * dx[0];
232
+ upper[1] = dx[0];
233
+ diagonal[n - 1] = 2.0 * dx[n - 2];
234
+ lower[n - 2] = dx[n - 2];
235
+ for (int64_t a = 0; a < cols; ++a) {
236
+ b[a] = -0.5 * 0.0 * (dx[0] * dx[0]) + 3.0 * (y[cols + a] - y[a]);
237
+ b[(n - 1) * cols + a] =
238
+ 0.5 * 0.0 * (dx[n - 2] * dx[n - 2]) + 3.0 * (y[(n - 1) * cols + a] - y[(n - 2) * cols + a]);
239
+ }
240
+ Vec dl(lower.begin(), lower.end() - 1), du(upper.begin() + 1, upper.end());
241
+ gtsv(dl, diagonal, du, b, cols);
242
+ return b;
243
+ }
244
+
245
+ double sign(double v) { return v > 0.0 ? 1.0 : (v < 0.0 ? -1.0 : (v == 0.0 ? 0.0 : v)); }
246
+
247
+ double pchip_edge(double h0, double h1, double m0, double m1) {
248
+ double d = ((2.0 * h0 + h1) * m0 - h0 * m1) / (h0 + h1);
249
+ const bool mask = sign(d) != sign(m0);
250
+ const bool mask2 = (sign(m0) != sign(m1)) && (std::fabs(d) > 3.0 * std::fabs(m0));
251
+ if (mask) d = 0.0;
252
+ else if (mask2) d = 3.0 * m0;
253
+ return d;
254
+ }
255
+
256
+ // scipy PchipInterpolator derivatives at the knots.
257
+ Vec pchip_slopes(const Vec& x, const Vec& y, int64_t cols) {
258
+ const int64_t n = static_cast<int64_t>(x.size());
259
+ Vec h(n - 1), m((n - 1) * cols), dk(n * cols, 0.0);
260
+ for (int64_t k = 0; k < n - 1; ++k) {
261
+ h[k] = x[k + 1] - x[k];
262
+ for (int64_t a = 0; a < cols; ++a) m[k * cols + a] = (y[(k + 1) * cols + a] - y[k * cols + a]) / h[k];
263
+ }
264
+ if (n == 2) {
265
+ for (int64_t a = 0; a < cols; ++a) dk[a] = dk[cols + a] = m[a];
266
+ return dk;
267
+ }
268
+ for (int64_t k = 1; k < n - 1; ++k) {
269
+ const double w1 = 2.0 * h[k] + h[k - 1];
270
+ const double w2 = h[k] + 2.0 * h[k - 1];
271
+ for (int64_t a = 0; a < cols; ++a) {
272
+ const double left = m[(k - 1) * cols + a], right = m[k * cols + a];
273
+ const bool condition = sign(right) != sign(left) || right == 0.0 || left == 0.0;
274
+ if (condition) {
275
+ dk[k * cols + a] = 0.0;
276
+ } else {
277
+ const double whmean = (w1 / left + w2 / right) / (w1 + w2);
278
+ dk[k * cols + a] = 1.0 / whmean;
279
+ }
280
+ }
281
+ }
282
+ for (int64_t a = 0; a < cols; ++a) {
283
+ dk[a] = pchip_edge(h[0], h[1], m[a], m[cols + a]);
284
+ dk[(n - 1) * cols + a] = pchip_edge(h[n - 2], h[n - 3], m[(n - 2) * cols + a], m[(n - 3) * cols + a]);
285
+ }
286
+ return dk;
287
+ }
288
+
289
+ // ---- scipy RotationSpline ----
290
+
291
+ using Mat3 = std::array<double, 9>;
292
+
293
+ Mat3 skew(const double* v) { return {0.0, -v[2], v[1], v[2], 0.0, -v[0], -v[1], v[0], 0.0}; }
294
+
295
+ Mat3 matmul(const Mat3& a, const Mat3& b) {
296
+ Mat3 r{};
297
+ for (int i = 0; i < 3; ++i)
298
+ for (int j = 0; j < 3; ++j)
299
+ r[i * 3 + j] = a[i * 3 + 0] * b[0 * 3 + j] + a[i * 3 + 1] * b[1 * 3 + j] + a[i * 3 + 2] * b[2 * 3 + j];
300
+ return r;
301
+ }
302
+
303
+ void matvec(const Mat3& a, const double* v, double* out) {
304
+ for (int i = 0; i < 3; ++i) out[i] = a[i * 3 + 0] * v[0] + a[i * 3 + 1] * v[1] + a[i * 3 + 2] * v[2];
305
+ }
306
+
307
+ double norm3(const double* v) { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); }
308
+
309
+ void cross(const double* a, const double* b, double* out) {
310
+ out[0] = a[1] * b[2] - a[2] * b[1];
311
+ out[1] = a[2] * b[0] - a[0] * b[2];
312
+ out[2] = a[0] * b[1] - a[1] * b[0];
313
+ }
314
+
315
+ Mat3 rate_to_rotvec_dot(const double* rotvec) {
316
+ const double nm = norm3(rotvec);
317
+ const double k = nm > 1e-4 ? (1.0 - 0.5 * nm / std::tan(0.5 * nm)) / (nm * nm)
318
+ : 1.0 / 12.0 + 1.0 / 720.0 * (nm * nm);
319
+ const Mat3 s = skew(rotvec), ss = matmul(s, s);
320
+ Mat3 r{};
321
+ for (int i = 0; i < 9; ++i) r[i] = ((i % 4 == 0 ? 1.0 : 0.0) + 0.5 * s[i]) + k * ss[i];
322
+ return r;
323
+ }
324
+
325
+ Mat3 rotvec_dot_to_rate(const double* rotvec) {
326
+ const double nm = norm3(rotvec);
327
+ double k1, k2;
328
+ if (nm > 1e-4) {
329
+ k1 = (1.0 - std::cos(nm)) / (nm * nm);
330
+ k2 = (nm - std::sin(nm)) / std::pow(nm, 3.0);
331
+ } else {
332
+ k1 = 0.5 - (nm * nm) / 24.0;
333
+ k2 = 1.0 / 6.0 - (nm * nm) / 120.0;
334
+ }
335
+ const Mat3 s = skew(rotvec), ss = matmul(s, s);
336
+ Mat3 r{};
337
+ for (int i = 0; i < 9; ++i) r[i] = ((i % 4 == 0 ? 1.0 : 0.0) - k1 * s[i]) + k2 * ss[i];
338
+ return r;
339
+ }
340
+
341
+ void nonlinear_term(const double* rv, const double* rvd, double* out) {
342
+ const double nm = norm3(rv);
343
+ const double dp = rv[0] * rvd[0] + rv[1] * rvd[1] + rv[2] * rvd[2];
344
+ double cp[3], ccp[3], dccp[3];
345
+ cross(rv, rvd, cp);
346
+ cross(rv, cp, ccp);
347
+ cross(rvd, cp, dccp);
348
+ double k1, k2, k3;
349
+ if (nm > 1e-4) {
350
+ const double sn = std::sin(nm), cs = std::cos(nm);
351
+ k1 = (-nm * sn - 2.0 * (cs - 1.0)) / std::pow(nm, 4.0);
352
+ k2 = (-2.0 * nm + 3.0 * sn - nm * cs) / std::pow(nm, 5.0);
353
+ k3 = (nm - sn) / std::pow(nm, 3.0);
354
+ } else {
355
+ k1 = 1.0 / 12.0 - (nm * nm) / 180.0;
356
+ k2 = -1.0 / 60.0 + (nm * nm) / 12604.0;
357
+ k3 = 1.0 / 6.0 - (nm * nm) / 120.0;
358
+ }
359
+ for (int i = 0; i < 3; ++i) out[i] = dp * (k1 * cp[i] + k2 * ccp[i]) + k3 * dccp[i];
360
+ }
361
+
362
+ // LAPACK dgbsv (reference dgbtf2 + dgbtrs) for one right-hand side. ``ab``
363
+ // holds A in band storage with ``kl`` extra rows for fill-in: ldab =
364
+ // 2*kl+ku+1 and ab[(kl+ku+i-j) + ldab*j] = A(i, j), all zero-based.
365
+ void gbsv(int64_t n, int64_t kl, int64_t ku, Vec& ab, Vec& b) {
366
+ const int64_t ldab = 2 * kl + ku + 1, kv = ku + kl;
367
+ auto at = [&](int64_t row, int64_t col) -> double& { return ab[row + ldab * col]; };
368
+ std::vector<int64_t> pivots(n);
369
+ int64_t ju = 0;
370
+ for (int64_t j = 0; j < n; ++j) {
371
+ const int64_t km = std::min(kl, n - 1 - j);
372
+ int64_t jp = 0;
373
+ double best = std::fabs(at(kv, j));
374
+ for (int64_t i = 1; i <= km; ++i) {
375
+ if (std::fabs(at(kv + i, j)) > best) { best = std::fabs(at(kv + i, j)); jp = i; }
376
+ }
377
+ pivots[j] = jp + j;
378
+ if (at(kv + jp, j) == 0.0) throw std::invalid_argument("singular matrix");
379
+ ju = std::max(ju, std::min(j + ku + jp, n - 1));
380
+ if (jp != 0) {
381
+ for (int64_t c = 0; c <= ju - j; ++c) std::swap(at(kv + jp - c, j + c), at(kv - c, j + c));
382
+ }
383
+ if (km > 0) {
384
+ const double reciprocal = 1.0 / at(kv, j);
385
+ for (int64_t i = 1; i <= km; ++i) at(kv + i, j) = reciprocal * at(kv + i, j);
386
+ for (int64_t c = 1; c <= ju - j; ++c) {
387
+ const double y = at(kv - c, j + c);
388
+ if (y != 0.0) {
389
+ const double temp = -1.0 * y;
390
+ for (int64_t i = 1; i <= km; ++i) at(kv - c + i, j + c) = at(kv - c + i, j + c) + at(kv + i, j) * temp;
391
+ }
392
+ }
393
+ }
394
+ }
395
+ // Forward: L, with the row interchanges.
396
+ for (int64_t j = 0; j + 1 < n; ++j) {
397
+ const int64_t lm = std::min(kl, n - 1 - j);
398
+ if (pivots[j] != j) std::swap(b[pivots[j]], b[j]);
399
+ if (b[j] != 0.0) {
400
+ const double temp = -1.0 * b[j];
401
+ for (int64_t i = 1; i <= lm; ++i) b[j + i] = b[j + i] + at(kv + i, j) * temp;
402
+ }
403
+ }
404
+ // Backward: U, a band of width kl+ku above the diagonal (dtbsv).
405
+ const int64_t k = kl + ku;
406
+ for (int64_t j = n - 1; j >= 0; --j) {
407
+ if (b[j] != 0.0) {
408
+ b[j] = b[j] / at(k, j);
409
+ const double temp = b[j];
410
+ for (int64_t i = j - 1; i >= std::max<int64_t>(0, j - k); --i) b[i] = b[i] - temp * at(k + i - j, j);
411
+ }
412
+ }
413
+ }
414
+
415
+ struct RotationSpline {
416
+ Vec times;
417
+ std::vector<Quat> rotations;
418
+ Vec coefficients; // ppoly layout, 3 columns
419
+
420
+ RotationSpline(const Vec& knots, const std::vector<Quat>& raw) : times(knots) {
421
+ const int64_t n = static_cast<int64_t>(knots.size());
422
+ rotations.reserve(n);
423
+ for (const Quat& q : raw) rotations.push_back(sp_from_quat(q));
424
+ const int64_t m = n - 1;
425
+ Vec dt(m), rotvecs(m * 3), rates(m * 3), rotvecs_dot;
426
+ for (int64_t k = 0; k < m; ++k) {
427
+ dt[k] = times[k + 1] - times[k];
428
+ sp_as_rotvec(sp_compose(sp_inv(rotations[k]), rotations[k + 1]), &rotvecs[k * 3]);
429
+ for (int a = 0; a < 3; ++a) rates[k * 3 + a] = rotvecs[k * 3 + a] / dt[k];
430
+ }
431
+ if (n == 2) {
432
+ rotvecs_dot = rates;
433
+ } else {
434
+ solve_rates(dt, rates, rotvecs, rotvecs_dot);
435
+ }
436
+ coefficients.assign(m * 4 * 3, 0.0);
437
+ for (int64_t k = 0; k < m; ++k) {
438
+ for (int a = 0; a < 3; ++a) {
439
+ const double rv = rotvecs[k * 3 + a], ar = rates[k * 3 + a], rd = rotvecs_dot[k * 3 + a];
440
+ coefficients[(k * 4 + 0) * 3 + a] = (-2.0 * rv + dt[k] * ar + dt[k] * rd) / std::pow(dt[k], 3.0);
441
+ coefficients[(k * 4 + 1) * 3 + a] = (3.0 * rv - 2.0 * dt[k] * ar - dt[k] * rd) / (dt[k] * dt[k]);
442
+ coefficients[(k * 4 + 2) * 3 + a] = ar;
443
+ coefficients[(k * 4 + 3) * 3 + a] = 0.0;
444
+ }
445
+ }
446
+ }
447
+
448
+ // scipy RotationSpline._solve_for_angular_rates; ``rates`` comes back as the
449
+ // knot angular rates, ``rotvecs_dot`` as the segment rotation-vector rates.
450
+ static void solve_rates(const Vec& dt, Vec& rates, const Vec& rotvecs, Vec& rotvecs_dot) {
451
+ const int64_t m = static_cast<int64_t>(dt.size());
452
+ const double first[3] = {rates[0], rates[1], rates[2]};
453
+ std::vector<Mat3> a(m), a_inv(m);
454
+ for (int64_t k = 0; k < m; ++k) {
455
+ a[k] = rate_to_rotvec_dot(&rotvecs[k * 3]);
456
+ a_inv[k] = rotvec_dot_to_rate(&rotvecs[k * 3]);
457
+ }
458
+ // Block-tridiagonal system of m-1 unknown 3-vectors in (5, 5) band form.
459
+ const int64_t size = 3 * (m - 1), kl = 5, ku = 5, ldab = 2 * kl + ku + 1;
460
+ Vec band(ldab * size, 0.0);
461
+ auto set = [&](int64_t i, int64_t j, double value) { band[(kl + ku + i - j) + ldab * j] = value; };
462
+ for (int64_t k = 0; k < m - 2; ++k) {
463
+ for (int r = 0; r < 3; ++r) {
464
+ for (int c = 0; c < 3; ++c) {
465
+ set(3 * (k + 1) + r, 3 * k + c, 2.0 * a_inv[k + 1][r * 3 + c] / dt[k + 1]);
466
+ set(3 * k + r, 3 * (k + 1) + c, 2.0 * a[k + 1][r * 3 + c] / dt[k + 1]);
467
+ }
468
+ }
469
+ }
470
+ for (int64_t k = 0; k < m - 1; ++k) {
471
+ const double diagonal = 4.0 * (1.0 / dt[k] + 1.0 / dt[k + 1]);
472
+ for (int r = 0; r < 3; ++r) set(3 * k + r, 3 * k + r, diagonal);
473
+ }
474
+ Vec b0(size);
475
+ for (int64_t k = 0; k < m - 1; ++k) {
476
+ for (int r = 0; r < 3; ++r)
477
+ b0[3 * k + r] = 6.0 * (rotvecs[k * 3 + r] * std::pow(dt[k], -2.0) +
478
+ rotvecs[(k + 1) * 3 + r] * std::pow(dt[k + 1], -2.0));
479
+ }
480
+ double product[3];
481
+ matvec(a_inv[0], first, product);
482
+ for (int r = 0; r < 3; ++r) b0[r] -= 2.0 / dt[0] * product[r];
483
+ matvec(a[m - 1], &rates[(m - 1) * 3], product);
484
+ for (int r = 0; r < 3; ++r) b0[size - 3 + r] -= 2.0 / dt[m - 1] * product[r];
485
+
486
+ rotvecs_dot.assign(m * 3, 0.0);
487
+ for (int iteration = 0; iteration < 10; ++iteration) {
488
+ for (int64_t k = 0; k < m; ++k) matvec(a[k], &rates[k * 3], &rotvecs_dot[k * 3]);
489
+ Vec b(size);
490
+ for (int64_t k = 0; k < m - 1; ++k) {
491
+ double delta_beta[3];
492
+ nonlinear_term(&rotvecs[k * 3], &rotvecs_dot[k * 3], delta_beta);
493
+ for (int r = 0; r < 3; ++r) b[3 * k + r] = b0[3 * k + r] - delta_beta[r];
494
+ }
495
+ Vec work = band;
496
+ gbsv(size, kl, ku, work, b);
497
+ bool converged = true;
498
+ for (int64_t i = 0; i < size; ++i) {
499
+ const double delta = std::fabs(b[i] - rates[i]);
500
+ rates[i] = b[i];
501
+ if (!(delta < 1e-9 * (1.0 + std::fabs(b[i])))) converged = false;
502
+ }
503
+ if (converged) break;
504
+ }
505
+ for (int64_t k = 0; k < m; ++k) matvec(a[k], &rates[k * 3], &rotvecs_dot[k * 3]);
506
+ // Knot rates: the fixed first rate, then the solved interior ones.
507
+ Vec shifted(m * 3);
508
+ for (int r = 0; r < 3; ++r) shifted[r] = first[r];
509
+ for (int64_t i = 0; i < 3 * (m - 1); ++i) shifted[3 + i] = rates[i];
510
+ rates = shifted;
511
+ }
512
+
513
+ Quat operator()(double t) const {
514
+ const int64_t m = static_cast<int64_t>(times.size()) - 1;
515
+ const int64_t k = ppoly_interval(times, t);
516
+ const double s = t - times[k];
517
+ double rotvec[3];
518
+ for (int a = 0; a < 3; ++a) {
519
+ const double coeff[4] = {coefficients[(k * 4 + 0) * 3 + a], coefficients[(k * 4 + 1) * 3 + a],
520
+ coefficients[(k * 4 + 2) * 3 + a], coefficients[(k * 4 + 3) * 3 + a]};
521
+ rotvec[a] = ppoly_value(coeff, s);
522
+ }
523
+ int64_t index = static_cast<int64_t>(std::upper_bound(times.begin(), times.end(), t) - times.begin()) - 1;
524
+ index = std::min(std::max<int64_t>(index, 0), m - 1);
525
+ return sp_compose(rotations[index], sp_from_rotvec(rotvec));
526
+ }
527
+ };
528
+
529
+ Quat slerp(const Quat& left, const Quat& right, double weight) {
530
+ const Quat first = so3_normalize(left);
531
+ Quat second = so3_normalize(right);
532
+ double dot = first.x * second.x + first.y * second.y + first.z * second.z + first.w * second.w;
533
+ if (dot < 0.0) second = {-second.x, -second.y, -second.z, -second.w};
534
+ dot = std::min(std::max(std::fabs(dot), 0.0), 1.0);
535
+ Quat out;
536
+ if (dot > 0.9995) {
537
+ out = {first.x + weight * (second.x - first.x), first.y + weight * (second.y - first.y),
538
+ first.z + weight * (second.z - first.z), first.w + weight * (second.w - first.w)};
539
+ } else {
540
+ const double angle = std::acos(dot), denominator = std::sin(angle);
541
+ const double a = std::sin((1.0 - weight) * angle) / denominator, b = std::sin(weight * angle) / denominator;
542
+ out = {a * first.x + b * second.x, a * first.y + b * second.y, a * first.z + b * second.z,
543
+ a * first.w + b * second.w};
544
+ }
545
+ return so3_normalize(out);
546
+ }
547
+
548
+ // ---- runtime resample_uniform ----
549
+
550
+ enum Kind { kLinear = 0, kHold = 1, kPose = 2 };
551
+ enum Method { kLinearMethod = 0, kPchip = 1, kCubic = 2 };
552
+
553
+ int64_t resampled_horizon(int64_t length, double source_hz, double target_hz) {
554
+ return std::max<int64_t>(1, static_cast<int64_t>(std::floor(length * target_hz / source_hz + 0.5)));
555
+ }
556
+
557
+ int64_t output_width(int kind, int64_t width) { return kind == kPose ? 7 : width; }
558
+
559
+ // One row: float32 [T, D] in, float32 [count, D'] out.
560
+ void resample_row(const float* input, int64_t steps, int64_t width, double source_hz, double target_hz,
561
+ int kind, int64_t count, bool identity, int method, float* output) {
562
+ Vec values(steps * width);
563
+ for (int64_t i = 0; i < steps * width; ++i) values[i] = input[i];
564
+ const int64_t out_width = output_width(kind, width);
565
+ if (identity) {
566
+ for (int64_t i = 0; i < steps * width; ++i) output[i] = static_cast<float>(values[i]);
567
+ return;
568
+ }
569
+ Vec target_time(count), source_time(steps), interpolation_time(count);
570
+ for (int64_t i = 0; i < count; ++i) target_time[i] = static_cast<double>(i) / target_hz;
571
+ for (int64_t i = 0; i < steps; ++i) source_time[i] = static_cast<double>(i) / source_hz;
572
+ std::vector<int64_t> left(count), right(count);
573
+ Vec weight(count);
574
+ for (int64_t i = 0; i < count; ++i) {
575
+ interpolation_time[i] = std::min(target_time[i], source_time[steps - 1]);
576
+ const double position = target_time[i] * source_hz;
577
+ left[i] = std::min(static_cast<int64_t>(std::floor(position)), steps - 1);
578
+ right[i] = std::min(left[i] + 1, steps - 1);
579
+ weight[i] = position - static_cast<double>(left[i]);
580
+ }
581
+ Vec result(count * out_width);
582
+ if (kind == kHold) {
583
+ for (int64_t i = 0; i < count; ++i)
584
+ for (int64_t a = 0; a < width; ++a) result[i * out_width + a] = values[left[i] * width + a];
585
+ } else {
586
+ const bool pose = kind == kPose;
587
+ const int64_t cols = pose ? 3 : width;
588
+ Vec continuous(steps * cols);
589
+ for (int64_t t = 0; t < steps; ++t)
590
+ for (int64_t a = 0; a < cols; ++a) continuous[t * cols + a] = values[t * width + a];
591
+ if (steps == 1) {
592
+ for (int64_t i = 0; i < count; ++i)
593
+ for (int64_t a = 0; a < cols; ++a) result[i * out_width + a] = continuous[a];
594
+ } else if (method == kPchip) {
595
+ const Vec c = hermite_coefficients(source_time, continuous, pchip_slopes(source_time, continuous, cols), cols);
596
+ ppoly_evaluate(source_time, c, cols, interpolation_time, out_width, 0, result.data());
597
+ } else if (method == kCubic) {
598
+ const Vec c = hermite_coefficients(source_time, continuous, natural_cubic_slopes(source_time, continuous, cols), cols);
599
+ ppoly_evaluate(source_time, c, cols, interpolation_time, out_width, 0, result.data());
600
+ // Guard: fall back to PCHIP on axes that materially leave their envelope.
601
+ std::vector<int64_t> fallback;
602
+ for (int64_t a = 0; a < cols; ++a) {
603
+ double lower = continuous[a], upper = continuous[a];
604
+ for (int64_t t = 1; t < steps; ++t) {
605
+ lower = std::min(lower, continuous[t * cols + a]);
606
+ upper = std::max(upper, continuous[t * cols + a]);
607
+ }
608
+ const double tolerance = 1e-9 + 0.05 * (upper - lower);
609
+ for (int64_t i = 0; i < count; ++i) {
610
+ const double v = result[i * out_width + a];
611
+ if (v < lower - tolerance || v > upper + tolerance) { fallback.push_back(a); break; }
612
+ }
613
+ }
614
+ if (!fallback.empty()) {
615
+ const int64_t f = static_cast<int64_t>(fallback.size());
616
+ Vec subset(steps * f);
617
+ for (int64_t t = 0; t < steps; ++t)
618
+ for (int64_t j = 0; j < f; ++j) subset[t * f + j] = continuous[t * cols + fallback[j]];
619
+ const Vec c = hermite_coefficients(source_time, subset, pchip_slopes(source_time, subset, f), f);
620
+ Vec sampled(count * f);
621
+ ppoly_evaluate(source_time, c, f, interpolation_time, f, 0, sampled.data());
622
+ for (int64_t i = 0; i < count; ++i)
623
+ for (int64_t j = 0; j < f; ++j) result[i * out_width + fallback[j]] = sampled[i * f + j];
624
+ }
625
+ } else {
626
+ for (int64_t i = 0; i < count; ++i)
627
+ for (int64_t a = 0; a < cols; ++a) {
628
+ const double lo = values[left[i] * width + a], hi = values[right[i] * width + a];
629
+ result[i * out_width + a] = lo + weight[i] * (hi - lo);
630
+ }
631
+ }
632
+ if (pose) {
633
+ std::vector<Quat> quaternions(steps);
634
+ for (int64_t t = 0; t < steps; ++t)
635
+ quaternions[t] = {values[t * width + 3], values[t * width + 4], values[t * width + 5], values[t * width + 6]};
636
+ auto store = [&](int64_t i, const Quat& q) {
637
+ result[i * out_width + 3] = q.x;
638
+ result[i * out_width + 4] = q.y;
639
+ result[i * out_width + 5] = q.z;
640
+ result[i * out_width + 6] = q.w;
641
+ };
642
+ if (steps == 1) {
643
+ for (int64_t i = 0; i < count; ++i) store(i, quaternions[0]);
644
+ } else if (method == kLinearMethod) {
645
+ for (int64_t i = 0; i < count; ++i) store(i, slerp(quaternions[left[i]], quaternions[right[i]], weight[i]));
646
+ } else {
647
+ const RotationSpline spline(source_time, quaternions);
648
+ for (int64_t i = 0; i < count; ++i) store(i, spline(interpolation_time[i]));
649
+ }
650
+ }
651
+ }
652
+ for (int64_t i = 0; i < count * out_width; ++i) output[i] = static_cast<float>(result[i]);
653
+ }
654
+
655
+ int parse_kind(const std::string& kind) {
656
+ if (kind == "linear") return kLinear;
657
+ if (kind == "hold") return kHold;
658
+ if (kind == "pose_xyzw") return kPose;
659
+ throw std::invalid_argument("unsupported resampling kind");
660
+ }
661
+
662
+ int parse_method(const std::string& method) {
663
+ if (method == "linear") return kLinearMethod;
664
+ if (method == "pchip") return kPchip;
665
+ if (method == "cubic") return kCubic;
666
+ throw std::invalid_argument("method must be one of ['cubic', 'linear', 'pchip']");
667
+ }
668
+
669
+ using F32 = py::array_t<float, py::array::c_style | py::array::forcecast>;
670
+
671
+ void check_finite(const F32& values) {
672
+ const float* data = values.data();
673
+ for (py::ssize_t i = 0; i < values.size(); ++i)
674
+ if (!std::isfinite(data[i])) throw std::invalid_argument("values must be a non-empty finite [T, D] array");
675
+ }
676
+
677
+ F32 resample_uniform_batch(const F32& values, double source_hz, double target_hz, const std::string& kind_name,
678
+ int64_t target_length, const std::string& method_name, int threads) {
679
+ if (values.ndim() != 3 || values.shape(0) < 1 || values.shape(1) < 1)
680
+ throw std::invalid_argument("values must have non-empty shape [B, T, D]");
681
+ if (!(std::isfinite(source_hz) && source_hz > 0.0)) throw std::invalid_argument("source_hz must be positive and finite");
682
+ if (!(std::isfinite(target_hz) && target_hz > 0.0)) throw std::invalid_argument("target_hz must be positive and finite");
683
+ check_finite(values);
684
+ const int kind = parse_kind(kind_name), method = parse_method(method_name);
685
+ const int64_t batch = values.shape(0), steps = values.shape(1), width = values.shape(2);
686
+ if (kind == kPose && width != 7) throw std::invalid_argument("pose_xyzw values must have shape [T, 7]");
687
+ const int64_t standard = resampled_horizon(steps, source_hz, target_hz);
688
+ const bool identity = source_hz == target_hz && (target_length < 0 || target_length == standard);
689
+ const int64_t count = identity ? steps : (target_length < 0 ? standard : target_length);
690
+ if (count < 1) throw std::invalid_argument("target_length must be a positive integer");
691
+ const int64_t out_width = output_width(kind, width);
692
+ F32 output({batch, count, out_width});
693
+ const float* in = values.data();
694
+ float* out = output.mutable_data();
695
+ {
696
+ py::gil_scoped_release release;
697
+ ac2::parallel_for(batch, threads, [&](int64_t row) {
698
+ resample_row(in + row * steps * width, steps, width, source_hz, target_hz, kind, count, identity, method,
699
+ out + row * count * out_width);
700
+ });
701
+ }
702
+ return output;
703
+ }
704
+
705
+ // Elementwise pose maps over [B, T, D] float32 arrays.
706
+ template <class Row>
707
+ F32 map_rows(const F32& values, int64_t in_width, int64_t out_width, int threads, Row&& row) {
708
+ if (values.ndim() != 3 || values.shape(2) != in_width || values.shape(1) < 1)
709
+ throw std::invalid_argument("expected shape [B, T, " + std::to_string(in_width) + "]");
710
+ check_finite(values);
711
+ const int64_t batch = values.shape(0), steps = values.shape(1);
712
+ F32 output({batch, steps, out_width});
713
+ const float* in = values.data();
714
+ float* out = output.mutable_data();
715
+ {
716
+ py::gil_scoped_release release;
717
+ ac2::parallel_for(batch, threads, [&](int64_t b) {
718
+ row(in + b * steps * in_width, steps, out + b * steps * out_width);
719
+ });
720
+ }
721
+ return output;
722
+ }
723
+
724
+ // runtime integrate_eef_deltas: [T, 6] body increments -> [T, 7] absolute poses.
725
+ F32 integrate_eef_deltas_batch(const F32& deltas, int threads) {
726
+ return map_rows(deltas, 6, 7, threads, [](const float* in, int64_t steps, float* out) {
727
+ double position[3] = {0.0, 0.0, 0.0};
728
+ Quat current{0.0, 0.0, 0.0, 1.0};
729
+ for (int64_t t = 0; t < steps; ++t) {
730
+ for (int a = 0; a < 3; ++a) {
731
+ position[a] = position[a] + static_cast<double>(in[t * 6 + a]);
732
+ out[t * 7 + a] = static_cast<float>(position[a]);
733
+ }
734
+ const Quat increment = so3_from_rotvec(in[t * 6 + 3], in[t * 6 + 4], in[t * 6 + 5]);
735
+ current = so3_multiply(current, increment);
736
+ out[t * 7 + 3] = static_cast<float>(current.x);
737
+ out[t * 7 + 4] = static_cast<float>(current.y);
738
+ out[t * 7 + 5] = static_cast<float>(current.z);
739
+ out[t * 7 + 6] = static_cast<float>(current.w);
740
+ }
741
+ });
742
+ }
743
+
744
+ // runtime eef_pose_from_components(..., "rotation_vector").
745
+ F32 eef_pose_from_rotvec_batch(const F32& poses, int threads) {
746
+ return map_rows(poses, 6, 7, threads, [](const float* in, int64_t steps, float* out) {
747
+ for (int64_t t = 0; t < steps; ++t) {
748
+ for (int a = 0; a < 3; ++a) out[t * 7 + a] = in[t * 6 + a];
749
+ const Quat q = so3_from_rotvec(in[t * 6 + 3], in[t * 6 + 4], in[t * 6 + 5]);
750
+ out[t * 7 + 3] = static_cast<float>(q.x);
751
+ out[t * 7 + 4] = static_cast<float>(q.y);
752
+ out[t * 7 + 5] = static_cast<float>(q.z);
753
+ out[t * 7 + 6] = static_cast<float>(q.w);
754
+ }
755
+ });
756
+ }
757
+
758
+ // runtime eef_pose_absolute: [T, 7] xyz + xyzw -> [T, 6] xyz + rotation vector.
759
+ F32 eef_pose_absolute_batch(const F32& poses, int threads) {
760
+ return map_rows(poses, 7, 6, threads, [](const float* in, int64_t steps, float* out) {
761
+ for (int64_t t = 0; t < steps; ++t) {
762
+ for (int a = 0; a < 3; ++a) out[t * 6 + a] = in[t * 7 + a];
763
+ double rotvec[3];
764
+ so3_to_rotvec({in[t * 7 + 3], in[t * 7 + 4], in[t * 7 + 5], in[t * 7 + 6]}, rotvec);
765
+ for (int a = 0; a < 3; ++a) out[t * 6 + 3 + a] = static_cast<float>(rotvec[a]);
766
+ }
767
+ });
768
+ }
769
+
770
+ // runtime eef_pose_deltas: local [T, 7] path -> [T, 6] body increments.
771
+ F32 eef_pose_deltas_batch(const F32& poses, int threads) {
772
+ return map_rows(poses, 7, 6, threads, [](const float* in, int64_t steps, float* out) {
773
+ Quat previous{0.0, 0.0, 0.0, 1.0};
774
+ for (int64_t t = 0; t < steps; ++t) {
775
+ for (int a = 0; a < 3; ++a) {
776
+ const double before = t == 0 ? 0.0 : static_cast<double>(in[(t - 1) * 7 + a]);
777
+ out[t * 6 + a] = static_cast<float>(static_cast<double>(in[t * 7 + a]) - before);
778
+ }
779
+ const Quat q = so3_normalize({in[t * 7 + 3], in[t * 7 + 4], in[t * 7 + 5], in[t * 7 + 6]});
780
+ double rotvec[3];
781
+ so3_to_rotvec(so3_multiply({-previous.x, -previous.y, -previous.z, previous.w}, q), rotvec);
782
+ for (int a = 0; a < 3; ++a) out[t * 6 + 3 + a] = static_cast<float>(rotvec[a]);
783
+ previous = q;
784
+ }
785
+ });
786
+ }
787
+
788
+ // Re-express per-step rotation increments between world-frame (left) and
789
+ // body-frame (right) multiplication, as scipy ``Rotation`` composes them.
790
+ F32 rotvec_left_to_right_batch(const F32& increments, int threads) {
791
+ return map_rows(increments, 3, 3, threads, [](const float* in, int64_t steps, float* out) {
792
+ Quat current{0.0, 0.0, 0.0, 1.0};
793
+ for (int64_t t = 0; t < steps; ++t) {
794
+ const double v[3] = {in[t * 3], in[t * 3 + 1], in[t * 3 + 2]};
795
+ const Quat following = sp_compose(sp_from_rotvec(v), current);
796
+ double rotvec[3];
797
+ sp_as_rotvec(sp_compose(sp_inv(current), following), rotvec);
798
+ for (int a = 0; a < 3; ++a) out[t * 3 + a] = static_cast<float>(rotvec[a]);
799
+ current = following;
800
+ }
801
+ });
802
+ }
803
+
804
+ F32 rotvec_right_to_left_batch(const F32& increments, int threads) {
805
+ return map_rows(increments, 3, 3, threads, [](const float* in, int64_t steps, float* out) {
806
+ Quat current{0.0, 0.0, 0.0, 1.0};
807
+ for (int64_t t = 0; t < steps; ++t) {
808
+ const double v[3] = {in[t * 3], in[t * 3 + 1], in[t * 3 + 2]};
809
+ const Quat following = sp_compose(current, sp_from_rotvec(v));
810
+ double rotvec[3];
811
+ sp_as_rotvec(sp_compose(following, sp_inv(current)), rotvec);
812
+ for (int a = 0; a < 3; ++a) out[t * 3 + a] = static_cast<float>(rotvec[a]);
813
+ current = following;
814
+ }
815
+ });
816
+ }
817
+
818
+ } // namespace
819
+
820
+ void register_resample(py::module_& module) {
821
+ module.def("resample_uniform_batch", &resample_uniform_batch, py::arg("values"), py::arg("source_hz"),
822
+ py::arg("target_hz"), py::arg("kind"), py::arg("target_length") = -1,
823
+ py::arg("method") = "linear", py::arg("threads") = 0,
824
+ "runtime resample_uniform over each [T, D] row of a [B, T, D] batch.");
825
+ module.def("integrate_eef_deltas_batch", &integrate_eef_deltas_batch, py::arg("deltas"), py::arg("threads") = 0);
826
+ module.def("eef_pose_from_rotvec_batch", &eef_pose_from_rotvec_batch, py::arg("poses"), py::arg("threads") = 0);
827
+ module.def("eef_pose_absolute_batch", &eef_pose_absolute_batch, py::arg("poses"), py::arg("threads") = 0);
828
+ module.def("eef_pose_deltas_batch", &eef_pose_deltas_batch, py::arg("poses"), py::arg("threads") = 0);
829
+ module.def("rotvec_left_to_right_batch", &rotvec_left_to_right_batch, py::arg("increments"),
830
+ py::arg("threads") = 0);
831
+ module.def("rotvec_right_to_left_batch", &rotvec_right_to_left_batch, py::arg("increments"),
832
+ py::arg("threads") = 0);
833
+ }
actioncodec2_kernels/setbpe.cpp ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
11
+
12
+ #include <algorithm>
13
+ #include <cstdint>
14
+ #include <functional>
15
+ #include <queue>
16
+ #include <stdexcept>
17
+ #include <string>
18
+ #include <tuple>
19
+ #include <unordered_map>
20
+ #include <utility>
21
+ #include <vector>
22
+
23
+ #include "parallel.hpp"
24
+
25
+ namespace py = pybind11;
26
+
27
+ namespace {
28
+
29
+ int lowest_bit(uint64_t mask) { return mask ? __builtin_ctzll(mask) : -1; }
30
+
31
+ uint64_t pair_key(int64_t left, int64_t right) {
32
+ return (static_cast<uint64_t>(left) << 32) | static_cast<uint32_t>(right);
33
+ }
34
+
35
+ struct Rule {
36
+ int64_t child;
37
+ int64_t rank;
38
+ };
39
+
40
+ class SetBPE {
41
+ public:
42
+ SetBPE(std::vector<int64_t> bins, int64_t lookahead,
43
+ const std::vector<std::pair<int64_t, int64_t>>& spatial_merges,
44
+ const std::vector<std::tuple<int64_t, int64_t, int64_t>>& temporal_merges)
45
+ : bins_(std::move(bins)), lookahead_(lookahead) {
46
+ const int64_t dimension = static_cast<int64_t>(bins_.size());
47
+ if (dimension < 1 || dimension > 63) throw std::invalid_argument("bins must have 1..63 slots");
48
+ if (lookahead_ < 1) throw std::invalid_argument("lookahead must be positive");
49
+ offsets_.assign(dimension + 1, 0);
50
+ for (int64_t d = 0; d < dimension; ++d) {
51
+ if (bins_[d] < 1) throw std::invalid_argument("every dimension needs at least one bin");
52
+ offsets_[d + 1] = offsets_[d] + bins_[d];
53
+ }
54
+ atom_count_ = offsets_[dimension];
55
+ spatial_size_ = atom_count_ + static_cast<int64_t>(spatial_merges.size());
56
+ support_.assign(spatial_size_, 0);
57
+ expansion_.assign(spatial_size_ * dimension, -1);
58
+ for (int64_t d = 0; d < dimension; ++d) {
59
+ for (int64_t v = 0; v < bins_[d]; ++v) {
60
+ const int64_t token = offsets_[d] + v;
61
+ support_[token] = uint64_t{1} << d;
62
+ expansion_[token * dimension + d] = v;
63
+ }
64
+ }
65
+ for (size_t rank = 0; rank < spatial_merges.size(); ++rank) {
66
+ const auto [left, right] = spatial_merges[rank];
67
+ const int64_t token = atom_count_ + static_cast<int64_t>(rank);
68
+ if (left < 0 || right < 0 || left >= token || right >= token)
69
+ throw std::invalid_argument("a merge rule must reference already-created tokens");
70
+ if (support_[left] & support_[right])
71
+ throw std::invalid_argument("a merge rule joins two overlapping supports");
72
+ support_[token] = support_[left] | support_[right];
73
+ for (int64_t d = 0; d < dimension; ++d) {
74
+ expansion_[token * dimension + d] =
75
+ std::max(expansion_[left * dimension + d], expansion_[right * dimension + d]);
76
+ }
77
+ spatial_rules_[pair_key(std::min(left, right), std::max(left, right))] =
78
+ Rule{token, static_cast<int64_t>(rank)};
79
+ }
80
+ spatial_anchor_.resize(spatial_size_);
81
+ for (int64_t token = 0; token < spatial_size_; ++token)
82
+ spatial_anchor_[token] = lowest_bit(support_[token]);
83
+
84
+ // Rooted temporal tokens: a pattern of (time offset, spatial token) pairs.
85
+ vocab_size_ = spatial_size_ + static_cast<int64_t>(temporal_merges.size());
86
+ patterns_.resize(vocab_size_);
87
+ root_.resize(vocab_size_);
88
+ for (int64_t token = 0; token < spatial_size_; ++token) {
89
+ patterns_[token] = {{0, token}};
90
+ root_[token] = token;
91
+ }
92
+ for (size_t rank = 0; rank < temporal_merges.size(); ++rank) {
93
+ const auto [left, right, offset] = temporal_merges[rank];
94
+ const int64_t child = spatial_size_ + static_cast<int64_t>(rank);
95
+ if (left < 0 || right < 0 || left >= child || right >= child)
96
+ throw std::invalid_argument("a temporal merge must reference already-created tokens");
97
+ if (offset < 1 || offset >= lookahead_)
98
+ throw std::invalid_argument("a temporal merge offset must be in [1, lookahead)");
99
+ std::vector<std::pair<int64_t, int64_t>> pattern = patterns_[left];
100
+ for (const auto& [time, token] : patterns_[right]) pattern.emplace_back(time + offset, token);
101
+ std::sort(pattern.begin(), pattern.end());
102
+ const int64_t span = 1 + pattern.back().first;
103
+ if (span > lookahead_) throw std::invalid_argument("temporal merge exceeds the lookahead horizon");
104
+ std::vector<uint64_t> masks(span, 0);
105
+ int roots = 0;
106
+ for (const auto& [time, token] : pattern) {
107
+ if (masks[time] & support_[token])
108
+ throw std::invalid_argument("temporal merge joins overlapping spatial supports");
109
+ masks[time] |= support_[token];
110
+ roots += time == 0;
111
+ }
112
+ if (roots != 1)
113
+ throw std::invalid_argument("a rooted temporal token must have exactly one time-zero spatial token");
114
+ patterns_[child] = std::move(pattern);
115
+ root_[child] = root_[left];
116
+ const auto key = std::make_tuple(left, right, offset);
117
+ if (temporal_rules_.count(key)) throw std::invalid_argument("duplicate temporal merge rule");
118
+ temporal_rules_[key] = Rule{child, static_cast<int64_t>(rank)};
119
+ }
120
+ }
121
+
122
+ int64_t vocab_size() const { return vocab_size_; }
123
+
124
+ // One dense [T, D] trajectory with -1 for semantically absent slots.
125
+ std::vector<int64_t> encode(const int64_t* cells, int64_t steps) const {
126
+ const int64_t dimension = static_cast<int64_t>(bins_.size());
127
+ if (steps < 1) throw std::invalid_argument("every episode must have at least one step");
128
+ uint64_t first_support = 0;
129
+ for (int64_t t = 0; t < steps; ++t) {
130
+ uint64_t support = 0;
131
+ for (int64_t d = 0; d < dimension; ++d) {
132
+ const int64_t value = cells[t * dimension + d];
133
+ if (value < -1 || value >= bins_[d])
134
+ throw std::invalid_argument("episodes must contain -1 or an in-range cell index");
135
+ if (value >= 0) support |= uint64_t{1} << d;
136
+ }
137
+ if (!support) throw std::invalid_argument("every timestep needs at least one active coordinate");
138
+ if (t == 0) first_support = support;
139
+ else if (support != first_support)
140
+ throw std::invalid_argument("active dimensions must remain constant within an episode");
141
+ }
142
+
143
+ // Instances, as in the Python encoder: token, anchor step, liveness.
144
+ std::vector<int64_t> tokens, anchors;
145
+ std::vector<char> active;
146
+ std::vector<std::vector<std::pair<int64_t, int64_t>>> at(steps); // (token, instance)
147
+ for (int64_t t = 0; t < steps; ++t) {
148
+ for (int64_t token : spatial_encode(cells + t * dimension)) {
149
+ at[t].emplace_back(token, static_cast<int64_t>(tokens.size()));
150
+ tokens.push_back(token);
151
+ anchors.push_back(t);
152
+ active.push_back(1);
153
+ }
154
+ }
155
+
156
+ // (rank, anchor, root anchor dimension, left instance, right instance, child)
157
+ using Offer = std::tuple<int64_t, int64_t, int64_t, int64_t, int64_t, int64_t>;
158
+ std::priority_queue<Offer, std::vector<Offer>, std::greater<Offer>> frontier;
159
+ auto offer = [&](int64_t left_instance, int64_t right_instance) {
160
+ const auto found = temporal_rules_.find(std::make_tuple(
161
+ tokens[left_instance], tokens[right_instance],
162
+ anchors[right_instance] - anchors[left_instance]));
163
+ if (found == temporal_rules_.end()) return;
164
+ const Rule& rule = found->second;
165
+ frontier.emplace(rule.rank, anchors[left_instance],
166
+ spatial_anchor_[root_[rule.child]], left_instance, right_instance,
167
+ rule.child);
168
+ };
169
+ for (int64_t anchor = 0; anchor < steps; ++anchor) {
170
+ for (int64_t offset = 1; offset < std::min<int64_t>(lookahead_, steps - anchor); ++offset) {
171
+ for (const auto& [left_token, left_instance] : at[anchor]) {
172
+ for (const auto& [right_token, right_instance] : at[anchor + offset]) {
173
+ offer(left_instance, right_instance);
174
+ }
175
+ }
176
+ }
177
+ }
178
+ auto erase = [&](int64_t step, int64_t token) {
179
+ auto& items = at[step];
180
+ for (auto it = items.begin(); it != items.end(); ++it) {
181
+ if (it->first == token) { items.erase(it); return; }
182
+ }
183
+ };
184
+ while (!frontier.empty()) {
185
+ const auto [rank, anchor, root_anchor, left_instance, right_instance, child] = frontier.top();
186
+ frontier.pop();
187
+ (void)rank; (void)root_anchor;
188
+ if (!active[left_instance] || !active[right_instance]) continue;
189
+ if (anchors[left_instance] != anchor) continue;
190
+ const auto resolved = temporal_rules_.find(std::make_tuple(
191
+ tokens[left_instance], tokens[right_instance], anchors[right_instance] - anchor));
192
+ if (resolved == temporal_rules_.end() || resolved->second.child != child) continue;
193
+ active[left_instance] = 0;
194
+ active[right_instance] = 0;
195
+ erase(anchor, tokens[left_instance]);
196
+ erase(anchors[right_instance], tokens[right_instance]);
197
+ const int64_t new_instance = static_cast<int64_t>(tokens.size());
198
+ tokens.push_back(child);
199
+ anchors.push_back(anchor);
200
+ active.push_back(1);
201
+ at[anchor].emplace_back(child, new_instance);
202
+ for (int64_t offset = 1; offset < lookahead_; ++offset) {
203
+ const int64_t future = anchor + offset;
204
+ if (future < steps) {
205
+ for (const auto& [token, other] : at[future]) offer(new_instance, other);
206
+ }
207
+ const int64_t past = anchor - offset;
208
+ if (past >= 0) {
209
+ for (const auto& [token, other] : at[past]) offer(other, new_instance);
210
+ }
211
+ }
212
+ }
213
+
214
+ std::vector<std::tuple<int64_t, int64_t, int64_t>> ordered;
215
+ for (int64_t anchor = 0; anchor < steps; ++anchor) {
216
+ for (const auto& [token, instance] : at[anchor]) {
217
+ if (active[instance]) ordered.emplace_back(anchor, spatial_anchor_[root_[token]], token);
218
+ }
219
+ }
220
+ std::sort(ordered.begin(), ordered.end());
221
+ std::vector<int64_t> stream;
222
+ stream.reserve(ordered.size());
223
+ for (const auto& item : ordered) stream.push_back(std::get<2>(item));
224
+ return stream;
225
+ }
226
+
227
+ // Tokens -> dense [T, D] cells (-1 outside ``full``), validating like the runtime.
228
+ std::vector<int64_t> decode(const int64_t* stream, int64_t length, uint64_t full) const {
229
+ const int64_t dimension = static_cast<int64_t>(bins_.size());
230
+ std::vector<uint64_t> coverage{0};
231
+ std::vector<std::vector<int64_t>> spatial_tokens(1);
232
+ size_t front = 0;
233
+ for (int64_t index = 0; index < length; ++index) {
234
+ const int64_t token = stream[index];
235
+ if (token < 0 || token >= vocab_size_)
236
+ throw std::invalid_argument("temporal token " + std::to_string(token) + " is outside the vocabulary");
237
+ while (front < coverage.size() && coverage[front] == full) ++front;
238
+ if (front == coverage.size()) {
239
+ coverage.push_back(0);
240
+ spatial_tokens.emplace_back();
241
+ }
242
+ const uint64_t uncovered = full & ~coverage[front];
243
+ if (spatial_anchor_[root_[token]] != lowest_bit(uncovered))
244
+ throw std::invalid_argument("temporal stream violates root order at the earliest incomplete step");
245
+ for (const auto& [offset, spatial_token] : patterns_[token]) {
246
+ const size_t step = front + static_cast<size_t>(offset);
247
+ while (step >= coverage.size()) {
248
+ coverage.push_back(0);
249
+ spatial_tokens.emplace_back();
250
+ }
251
+ if (coverage[step] & support_[spatial_token])
252
+ throw std::invalid_argument("temporal stream contains overlapping spatial support");
253
+ coverage[step] |= support_[spatial_token];
254
+ spatial_tokens[step].push_back(spatial_token);
255
+ }
256
+ }
257
+ for (uint64_t mask : coverage) {
258
+ if (mask != full) throw std::invalid_argument("temporal stream ended with an incomplete control step");
259
+ }
260
+ std::vector<int64_t> cells(coverage.size() * dimension, -1);
261
+ for (size_t step = 0; step < coverage.size(); ++step) {
262
+ for (int64_t token : spatial_tokens[step]) {
263
+ for (int64_t d = 0; d < dimension; ++d) {
264
+ const int64_t value = expansion_[token * dimension + d];
265
+ if (value >= 0) cells[step * dimension + d] = value;
266
+ }
267
+ }
268
+ }
269
+ return cells;
270
+ }
271
+
272
+ private:
273
+ // Spatial Set-BPE of one dense row: merges in rank order, then anchor order.
274
+ std::vector<int64_t> spatial_encode(const int64_t* row) const {
275
+ const int64_t dimension = static_cast<int64_t>(bins_.size());
276
+ std::vector<int64_t> present;
277
+ for (int64_t d = 0; d < dimension; ++d) {
278
+ if (row[d] >= 0) present.push_back(offsets_[d] + row[d]);
279
+ }
280
+ std::sort(present.begin(), present.end());
281
+ using Candidate = std::tuple<int64_t, int64_t, int64_t, int64_t>; // rank, child, left, right
282
+ std::priority_queue<Candidate, std::vector<Candidate>, std::greater<Candidate>> heap;
283
+ for (size_t i = 0; i < present.size(); ++i) {
284
+ for (size_t j = i + 1; j < present.size(); ++j) {
285
+ const auto found = spatial_rules_.find(pair_key(present[i], present[j]));
286
+ if (found != spatial_rules_.end())
287
+ heap.emplace(found->second.rank, found->second.child, present[i], present[j]);
288
+ }
289
+ }
290
+ std::vector<int64_t> tokens = present; // a small set; linear lookups are fastest
291
+ auto contains = [&](int64_t token) {
292
+ return std::find(tokens.begin(), tokens.end(), token) != tokens.end();
293
+ };
294
+ while (!heap.empty()) {
295
+ const auto [rank, child, left, right] = heap.top();
296
+ heap.pop();
297
+ (void)rank;
298
+ if (!contains(left) || !contains(right)) continue;
299
+ tokens.erase(std::find(tokens.begin(), tokens.end(), left));
300
+ tokens.erase(std::find(tokens.begin(), tokens.end(), right));
301
+ for (int64_t other : tokens) {
302
+ const int64_t low = std::min(child, other), high = std::max(child, other);
303
+ const auto found = spatial_rules_.find(pair_key(low, high));
304
+ if (found != spatial_rules_.end())
305
+ heap.emplace(found->second.rank, found->second.child, low, high);
306
+ }
307
+ tokens.push_back(child);
308
+ }
309
+ std::sort(tokens.begin(), tokens.end(), [&](int64_t a, int64_t b) {
310
+ return std::make_pair(spatial_anchor_[a], a) < std::make_pair(spatial_anchor_[b], b);
311
+ });
312
+ return tokens;
313
+ }
314
+
315
+ struct TupleHash {
316
+ size_t operator()(const std::tuple<int64_t, int64_t, int64_t>& key) const {
317
+ const auto [a, b, c] = key;
318
+ return std::hash<uint64_t>()((static_cast<uint64_t>(a) * 1000003u) ^
319
+ (static_cast<uint64_t>(b) << 20) ^ static_cast<uint64_t>(c));
320
+ }
321
+ };
322
+
323
+ std::vector<int64_t> bins_;
324
+ int64_t lookahead_;
325
+ std::vector<int64_t> offsets_;
326
+ int64_t atom_count_ = 0, spatial_size_ = 0, vocab_size_ = 0;
327
+ std::vector<uint64_t> support_;
328
+ std::vector<int64_t> expansion_;
329
+ std::vector<int> spatial_anchor_;
330
+ std::unordered_map<uint64_t, Rule> spatial_rules_;
331
+ std::vector<std::vector<std::pair<int64_t, int64_t>>> patterns_;
332
+ std::vector<int64_t> root_;
333
+ std::unordered_map<std::tuple<int64_t, int64_t, int64_t>, Rule, TupleHash> temporal_rules_;
334
+ };
335
+
336
+ // Encode B dense [T, D] trajectories in parallel; returns one stream per row.
337
+ py::list encode_batch(const SetBPE& codec,
338
+ const py::array_t<int64_t, py::array::c_style | py::array::forcecast>& cells,
339
+ int threads) {
340
+ if (cells.ndim() != 3) throw std::invalid_argument("cells must have shape [B, T, D]");
341
+ const int64_t batch = cells.shape(0), steps = cells.shape(1), width = cells.shape(2);
342
+ const int64_t* data = cells.data();
343
+ std::vector<std::vector<int64_t>> streams(batch);
344
+ {
345
+ py::gil_scoped_release release;
346
+ ac2::parallel_for(batch, threads, [&](int64_t index) {
347
+ streams[index] = codec.encode(data + index * steps * width, steps);
348
+ });
349
+ }
350
+ py::list output(batch);
351
+ for (int64_t index = 0; index < batch; ++index) output[index] = py::cast(streams[index]);
352
+ return output;
353
+ }
354
+
355
+ // Decode B equal-horizon streams to dense [B, T, D] cells (-1 outside ``full``).
356
+ py::array_t<int64_t> decode_batch(const SetBPE& codec, const std::vector<std::vector<int64_t>>& streams,
357
+ int64_t width, uint64_t full, int threads) {
358
+ const int64_t batch = static_cast<int64_t>(streams.size());
359
+ if (batch < 1) throw std::invalid_argument("streams must not be empty");
360
+ std::vector<std::vector<int64_t>> cells(batch);
361
+ {
362
+ py::gil_scoped_release release;
363
+ ac2::parallel_for(batch, threads, [&](int64_t index) {
364
+ if (streams[index].empty()) throw std::invalid_argument("token stream must not be empty");
365
+ cells[index] = codec.decode(streams[index].data(), static_cast<int64_t>(streams[index].size()), full);
366
+ });
367
+ }
368
+ const int64_t steps = static_cast<int64_t>(cells[0].size()) / width;
369
+ for (const auto& row : cells) {
370
+ if (static_cast<int64_t>(row.size()) != steps * width)
371
+ throw std::invalid_argument("all token streams in a batch must decode to the same length");
372
+ }
373
+ py::array_t<int64_t> output({batch, steps, width});
374
+ int64_t* out = output.mutable_data();
375
+ for (int64_t index = 0; index < batch; ++index)
376
+ std::copy(cells[index].begin(), cells[index].end(), out + index * steps * width);
377
+ return output;
378
+ }
379
+
380
+ } // namespace
381
+
382
+ void register_setbpe(py::module_& module) {
383
+ py::class_<SetBPE>(module, "SetBPE")
384
+ .def(py::init<std::vector<int64_t>, int64_t, const std::vector<std::pair<int64_t, int64_t>>&,
385
+ const std::vector<std::tuple<int64_t, int64_t, int64_t>>&>(),
386
+ py::arg("bins"), py::arg("lookahead"), py::arg("spatial_merges"),
387
+ py::arg("temporal_merges"))
388
+ .def_property_readonly("vocab_size", &SetBPE::vocab_size)
389
+ .def("encode_batch", &encode_batch, py::arg("cells"), py::arg("threads") = 0,
390
+ "Dense [B, T, D] cells (-1 = absent) -> one token stream per row.")
391
+ .def("decode_batch", &decode_batch, py::arg("streams"), py::arg("width"),
392
+ py::arg("full_support"), py::arg("threads") = 0,
393
+ "Equal-horizon streams -> dense [B, T, D] cells, -1 outside full_support.");
394
+ }
actioncodec2_kernels/so3.cpp ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Intrinsic first- and stateful second-order SO(3) error-feedback encoders.
2
+
3
+ #include <pybind11/numpy.h>
4
+ #include <pybind11/pybind11.h>
5
+
6
+ #include <algorithm>
7
+ #include <array>
8
+ #include <cmath>
9
+ #include <cstdint>
10
+ #include <limits>
11
+ #include <vector>
12
+
13
+ #include "parallel.hpp"
14
+
15
+ namespace py = pybind11;
16
+
17
+ namespace {
18
+
19
+ struct Quaternion {
20
+ double x;
21
+ double y;
22
+ double z;
23
+ double w;
24
+ };
25
+
26
+ Quaternion normalize(Quaternion q) {
27
+ const double norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
28
+ q.x /= norm;
29
+ q.y /= norm;
30
+ q.z /= norm;
31
+ q.w /= norm;
32
+ return q;
33
+ }
34
+
35
+ Quaternion multiply(const Quaternion& a, const Quaternion& b) {
36
+ return normalize({a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
37
+ a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
38
+ a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
39
+ a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z});
40
+ }
41
+
42
+ Quaternion conjugate(const Quaternion& q) { return {-q.x, -q.y, -q.z, q.w}; }
43
+
44
+ Quaternion from_euler(const float* euler) {
45
+ const double roll = 0.5 * static_cast<double>(euler[0]);
46
+ const double pitch = 0.5 * static_cast<double>(euler[1]);
47
+ const double yaw = 0.5 * static_cast<double>(euler[2]);
48
+ const double sr = std::sin(roll), cr = std::cos(roll);
49
+ const double sp = std::sin(pitch), cp = std::cos(pitch);
50
+ const double sy = std::sin(yaw), cy = std::cos(yaw);
51
+ return normalize({sr * cp * cy - cr * sp * sy, cr * sp * cy + sr * cp * sy,
52
+ cr * cp * sy - sr * sp * cy, cr * cp * cy + sr * sp * sy});
53
+ }
54
+
55
+ void to_rotvec(Quaternion q, double* output) {
56
+ if (q.w < 0.0) {
57
+ q.x = -q.x;
58
+ q.y = -q.y;
59
+ q.z = -q.z;
60
+ q.w = -q.w;
61
+ }
62
+ const double norm = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z);
63
+ const double scale = norm < 1e-12 ? 2.0 : 2.0 * std::atan2(norm, std::max(0.0, q.w)) / norm;
64
+ output[0] = q.x * scale;
65
+ output[1] = q.y * scale;
66
+ output[2] = q.z * scale;
67
+ }
68
+
69
+ Quaternion from_rotvec(const double* value) {
70
+ const double angle = std::sqrt(value[0] * value[0] + value[1] * value[1] +
71
+ value[2] * value[2]);
72
+ const double scale = angle < 1e-12 ? 0.5 : std::sin(0.5 * angle) / angle;
73
+ return normalize({value[0] * scale, value[1] * scale, value[2] * scale,
74
+ std::cos(0.5 * angle)});
75
+ }
76
+
77
+ Quaternion from_input(const float* value, bool rotation_vector) {
78
+ if (!rotation_vector) return from_euler(value);
79
+ const double converted[3] = {static_cast<double>(value[0]), static_cast<double>(value[1]),
80
+ static_cast<double>(value[2])};
81
+ return from_rotvec(converted);
82
+ }
83
+
84
+ Quaternion residual(const Quaternion& target, const Quaternion& reconstruction, bool left) {
85
+ return left ? multiply(target, conjugate(reconstruction))
86
+ : multiply(conjugate(reconstruction), target);
87
+ }
88
+
89
+ Quaternion apply(const Quaternion& state, const Quaternion& increment, bool left) {
90
+ return left ? multiply(increment, state) : multiply(state, increment);
91
+ }
92
+
93
+ std::array<double, 3> as_rotvec(const Quaternion& value) {
94
+ std::array<double, 3> output{};
95
+ to_rotvec(value, output.data());
96
+ return output;
97
+ }
98
+
99
+ struct SO3Layout {
100
+ const double* table;
101
+ const int32_t* bins;
102
+ int64_t width;
103
+ double limit;
104
+ bool smooth;
105
+ bool rotation_vector;
106
+ bool left;
107
+ int32_t input_order;
108
+ double projection_radius = std::numeric_limits<double>::infinity();
109
+ };
110
+
111
+ void project_primitive(double* value, double radius) {
112
+ if (!std::isfinite(radius)) return;
113
+ const double norm = std::sqrt(value[0]*value[0] + value[1]*value[1] + value[2]*value[2]);
114
+ if (norm > radius) {
115
+ const double scale = radius / norm;
116
+ for (int j = 0; j < 3; ++j) value[j] *= scale;
117
+ }
118
+ }
119
+
120
+ inline double primitive(const SO3Layout& layout, int axis, int32_t index) {
121
+ return layout.table[static_cast<int64_t>(axis) * layout.width + index];
122
+ }
123
+
124
+ std::array<int32_t, 3> nearest_choice(const SO3Layout& layout,
125
+ const std::array<double, 3>& request) {
126
+ std::array<int32_t, 3> choice{};
127
+ for (int axis = 0; axis < 3; ++axis) {
128
+ double best = std::numeric_limits<double>::infinity();
129
+ for (int32_t index = 0; index < layout.bins[axis]; ++index) {
130
+ const double distance = std::fabs(request[axis] - primitive(layout, axis, index));
131
+ if (distance < best) {
132
+ best = distance;
133
+ choice[axis] = index;
134
+ }
135
+ }
136
+ }
137
+ return choice;
138
+ }
139
+
140
+ std::vector<int32_t> smooth_axis(const SO3Layout& layout, int axis, double request,
141
+ int32_t nearest) {
142
+ std::vector<int32_t> output;
143
+ bool has_nearest = false;
144
+ const double radius = 2.0 * layout.limit + 1e-12;
145
+ for (int32_t index = 0; index < layout.bins[axis]; ++index) {
146
+ if (std::fabs(primitive(layout, axis, index) - request) <= radius) {
147
+ output.push_back(index);
148
+ has_nearest = has_nearest || index == nearest;
149
+ }
150
+ }
151
+ if (!has_nearest) output.push_back(nearest);
152
+ return output;
153
+ }
154
+
155
+ bool earlier(double error, const std::array<int32_t, 3>& choice, double best_error,
156
+ const std::array<int32_t, 3>& best_choice, bool found) {
157
+ return !found || error < best_error || (error == best_error && choice < best_choice);
158
+ }
159
+
160
+ std::array<int32_t, 3> smooth_choice(const SO3Layout& layout,
161
+ const std::array<double, 3>& request,
162
+ const Quaternion& reconstruction,
163
+ const Quaternion& target,
164
+ const std::array<double, 3>& direction,
165
+ const std::array<int32_t, 3>& nearest) {
166
+ const auto x = smooth_axis(layout, 0, request[0], nearest[0]);
167
+ const auto y = smooth_axis(layout, 1, request[1], nearest[1]);
168
+ const auto z = smooth_axis(layout, 2, request[2], nearest[2]);
169
+ std::array<int32_t, 3> best_any{}, best_preferred{};
170
+ double any_error = 0.0, preferred_error = 0.0;
171
+ bool found_any = false, found_preferred = false;
172
+ for (const int32_t ix : x) {
173
+ for (const int32_t iy : y) {
174
+ for (const int32_t iz : z) {
175
+ const std::array<int32_t, 3> choice{ix, iy, iz};
176
+ double vector[3] = {primitive(layout, 0, ix), primitive(layout, 1, iy),
177
+ primitive(layout, 2, iz)};
178
+ project_primitive(vector, layout.projection_radius);
179
+ const Quaternion rebuilt = apply(reconstruction, from_rotvec(vector), layout.left);
180
+ const auto remaining = as_rotvec(residual(target, rebuilt, layout.left));
181
+ const double error = std::sqrt(remaining[0] * remaining[0] + remaining[1] * remaining[1] +
182
+ remaining[2] * remaining[2]);
183
+ if (error > layout.limit + 1e-12) continue;
184
+ if (earlier(error, choice, any_error, best_any, found_any)) {
185
+ best_any = choice;
186
+ any_error = error;
187
+ found_any = true;
188
+ }
189
+ const double dot = vector[0] * direction[0] + vector[1] * direction[1] +
190
+ vector[2] * direction[2];
191
+ if (dot >= -1e-15 && earlier(error, choice, preferred_error, best_preferred,
192
+ found_preferred)) {
193
+ best_preferred = choice;
194
+ preferred_error = error;
195
+ found_preferred = true;
196
+ }
197
+ }
198
+ }
199
+ }
200
+ return found_preferred ? best_preferred : (found_any ? best_any : nearest);
201
+ }
202
+
203
+ void encode_trajectory(const SO3Layout& layout, const float* actions, int64_t steps,
204
+ int32_t* cells) {
205
+ Quaternion truth{0.0, 0.0, 0.0, 1.0};
206
+ Quaternion reconstruction = truth;
207
+ std::array<double, 3> direction{};
208
+ for (int64_t step = 0; step < steps; ++step) {
209
+ const Quaternion value = from_input(actions + 3 * step, layout.rotation_vector);
210
+ truth = layout.input_order == 0 ? value : apply(truth, value, layout.left);
211
+ const auto request = as_rotvec(residual(truth, reconstruction, layout.left));
212
+ auto choice = nearest_choice(layout, request);
213
+ if (layout.smooth) {
214
+ choice = smooth_choice(layout, request, reconstruction, truth, direction, choice);
215
+ }
216
+ const double vector[3] = {primitive(layout, 0, choice[0]),
217
+ primitive(layout, 1, choice[1]),
218
+ primitive(layout, 2, choice[2])};
219
+ reconstruction = apply(reconstruction, from_rotvec(vector), layout.left);
220
+ if (vector[0] != 0.0 || vector[1] != 0.0 || vector[2] != 0.0) {
221
+ direction = {vector[0], vector[1], vector[2]};
222
+ }
223
+ for (int axis = 0; axis < 3; ++axis) cells[3 * step + axis] = choice[axis];
224
+ }
225
+ }
226
+
227
+ } // namespace
228
+
229
+ py::array_t<int32_t> so3_second_order_encode_batch(
230
+ const py::array_t<double, py::array::c_style | py::array::forcecast>& table,
231
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& bins,
232
+ const py::array_t<double, py::array::c_style | py::array::forcecast>& quaternions,
233
+ const py::array_t<double, py::array::c_style | py::array::forcecast>& previous_first_order,
234
+ int32_t input_order, bool left, double limit, double radius, bool smooth, int threads) {
235
+ if (table.ndim() != 2 || table.shape(0) != 3 || bins.size() != 3 ||
236
+ quaternions.ndim() != 3 || quaternions.shape(2) != 4 ||
237
+ previous_first_order.ndim() != 2 || previous_first_order.shape(1) != 4 ||
238
+ previous_first_order.shape(0) != quaternions.shape(0) ||
239
+ (input_order != 0 && input_order != 1) || !(limit > 0) || !(radius > 0)) {
240
+ throw std::invalid_argument("invalid second-order SO(3) batch arguments");
241
+ }
242
+ for (int j = 0; j < 3; ++j) {
243
+ if (bins.data()[j] < 1 || bins.data()[j] > table.shape(1))
244
+ throw std::invalid_argument("SO(3) primitive count exceeds table width");
245
+ }
246
+ const SO3Layout layout{table.data(), bins.data(), table.shape(1), limit, smooth,
247
+ true, left, input_order, radius};
248
+ const int64_t batch = quaternions.shape(0), steps = quaternions.shape(1);
249
+ py::array_t<int32_t> output({batch, steps, int64_t{3}});
250
+ const double* values = quaternions.data();
251
+ const double* previous = previous_first_order.data();
252
+ int32_t* result = output.mutable_data();
253
+ const Quaternion identity{0, 0, 0, 1};
254
+ py::gil_scoped_release release;
255
+ ac2::parallel_for(batch, threads, [&](int64_t i) {
256
+ Quaternion truth = identity, reconstruction = identity;
257
+ Quaternion increment{previous[4*i], previous[4*i+1], previous[4*i+2], previous[4*i+3]};
258
+ std::array<double, 3> direction{};
259
+ for (int64_t t = 0; t < steps; ++t) {
260
+ const double* raw = values + (i*steps+t)*4;
261
+ const Quaternion value{raw[0], raw[1], raw[2], raw[3]};
262
+ truth = input_order == 0 ? value : apply(truth, value, left);
263
+ const auto desired = residual(truth, reconstruction, left);
264
+ const auto correction = residual(desired, increment, left);
265
+ const auto request = as_rotvec(correction);
266
+ auto choice = nearest_choice(layout, request);
267
+ if (smooth) {
268
+ // Python scores U^-1 W for both conventions; use exactly that order.
269
+ SO3Layout selection = layout;
270
+ selection.left = false;
271
+ choice = smooth_choice(selection, request, identity, correction, direction, choice);
272
+ }
273
+ double vector[3] = {primitive(layout, 0, choice[0]), primitive(layout, 1, choice[1]), primitive(layout, 2, choice[2])};
274
+ project_primitive(vector, radius);
275
+ increment = apply(increment, from_rotvec(vector), left);
276
+ reconstruction = apply(reconstruction, increment, left);
277
+ if (vector[0] != 0 || vector[1] != 0 || vector[2] != 0)
278
+ direction = {vector[0], vector[1], vector[2]};
279
+ for (int j = 0; j < 3; ++j) result[(i*steps+t)*3+j] = choice[j];
280
+ }
281
+ });
282
+ return output;
283
+ }
284
+
285
+ py::array_t<int32_t> so3_encode_batch(
286
+ const py::array_t<double, py::array::c_style | py::array::forcecast>& table,
287
+ const py::array_t<int32_t, py::array::c_style | py::array::forcecast>& bins,
288
+ const py::array_t<float, py::array::c_style | py::array::forcecast>& actions,
289
+ int32_t input_order, bool rotation_vector, bool left, double limit, bool smooth,
290
+ int threads) {
291
+ if (table.ndim() != 2 || table.shape(0) != 3 || bins.size() != 3 || actions.ndim() != 3 ||
292
+ actions.shape(2) != 3 || (input_order != 0 && input_order != 1) || limit <= 0.0) {
293
+ throw std::invalid_argument("invalid SO(3) batch encoder arguments");
294
+ }
295
+ for (int axis = 0; axis < 3; ++axis) {
296
+ if (bins.data()[axis] < 1 || bins.data()[axis] > table.shape(1)) {
297
+ throw std::invalid_argument("SO(3) primitive count exceeds table width");
298
+ }
299
+ }
300
+ const SO3Layout layout{table.data(), bins.data(), table.shape(1), limit, smooth,
301
+ rotation_vector, left, input_order};
302
+ const int64_t batch = actions.shape(0);
303
+ const int64_t steps = actions.shape(1);
304
+ py::array_t<int32_t> output({batch, steps, int64_t{3}});
305
+ {
306
+ py::gil_scoped_release release;
307
+ ac2::parallel_for(batch, threads, [&](int64_t index) {
308
+ encode_trajectory(layout, actions.data() + index * steps * 3, steps,
309
+ output.mutable_data() + index * steps * 3);
310
+ });
311
+ }
312
+ return output;
313
+ }
config.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "model_type": "actioncodec2",
3
+ "processor_class": "ActionCodec2"
4
+ }
processing_actioncodec2.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small Hugging Face entry point for a grouped ActionCodec2 artifact.
2
+
3
+ Transformers copies only top-level Python files from a Hub repository into its
4
+ dynamic-module cache. This entry point locates the artifact, then loads the
5
+ versioned runtime stored in ``runtime/`` without installing another package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import importlib
12
+ import importlib.util
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ class ActionCodec2:
18
+ """Load the processor implementation bundled with a pretrained artifact."""
19
+
20
+ @classmethod
21
+ def register_for_auto_class(cls, auto_class="AutoProcessor"):
22
+ """Satisfy Transformers' dynamic class hook for this thin loader."""
23
+ return cls
24
+
25
+ @classmethod
26
+ def from_pretrained(cls, pretrained_model_name_or_path, **kwargs):
27
+ source = Path(pretrained_model_name_or_path)
28
+ subfolder = str(kwargs.pop("subfolder", ""))
29
+ action_space = kwargs.pop("action_space", None)
30
+ kwargs.pop("_from_auto", None)
31
+ kwargs.pop("trust_remote_code", None)
32
+ allowed = {
33
+ "cache_dir",
34
+ "force_download",
35
+ "local_files_only",
36
+ "token",
37
+ "revision",
38
+ "repo_type",
39
+ }
40
+ unknown = sorted(set(kwargs) - allowed)
41
+ if unknown:
42
+ raise TypeError(
43
+ f"unsupported from_pretrained keyword(s): {', '.join(unknown)}"
44
+ )
45
+ if source.is_dir():
46
+ root = source / subfolder
47
+ else:
48
+ from huggingface_hub import snapshot_download
49
+
50
+ prefix = f"{subfolder.rstrip('/')}/" if subfolder else ""
51
+ patterns = [
52
+ "config.json",
53
+ "processor_config.json",
54
+ "router_config.yaml",
55
+ "profiles/**",
56
+ "runtime/**",
57
+ "actioncodec2_kernels/**",
58
+ "setup.py", "pyproject.toml", "LICENSE",
59
+ "README.md",
60
+ "requirements.txt",
61
+ "fit_report.json",
62
+ ]
63
+ snapshot = snapshot_download(
64
+ repo_id=str(pretrained_model_name_or_path),
65
+ allow_patterns=[prefix + pattern for pattern in patterns],
66
+ **kwargs,
67
+ )
68
+ root = Path(snapshot) / subfolder
69
+
70
+ runtime = root / "runtime"
71
+ package_file = runtime / "__init__.py"
72
+ if not package_file.is_file():
73
+ raise FileNotFoundError(
74
+ f"ActionCodec2 runtime is missing from {root}; copy the whole artifact"
75
+ )
76
+ identity = hashlib.sha256(str(runtime.resolve()).encode()).hexdigest()[:16]
77
+ package_name = f"_actioncodec2_artifact_{identity}"
78
+ if package_name not in sys.modules:
79
+ spec = importlib.util.spec_from_file_location(
80
+ package_name, package_file, submodule_search_locations=[str(runtime)]
81
+ )
82
+ if spec is None or spec.loader is None:
83
+ raise ImportError(f"cannot load ActionCodec2 runtime from {runtime}")
84
+ package = importlib.util.module_from_spec(spec)
85
+ sys.modules[package_name] = package
86
+ try:
87
+ spec.loader.exec_module(package)
88
+ except BaseException:
89
+ del sys.modules[package_name]
90
+ raise
91
+ processor = importlib.import_module(f"{package_name}.processing_actioncodec2")
92
+ acceleration = importlib.import_module(f"{package_name}.hub_acceleration")
93
+ acceleration.install(package_name)
94
+ return processor.ActionCodec2.from_pretrained(root, action_space=action_space)
processor_config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "processor_class": "ActionCodec2",
3
+ "action_space": "single_eef_delta",
4
+ "fitted_profiles": [
5
+ "eef"
6
+ ],
7
+ "auto_map": {
8
+ "AutoProcessor": "processing_actioncodec2.ActionCodec2"
9
+ }
10
+ }
profiles/eef/bpe.json ADDED
@@ -0,0 +1,5950 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "spatial_merges": [
3
+ [
4
+ 40,
5
+ 121
6
+ ],
7
+ [
8
+ 202,
9
+ 720
10
+ ],
11
+ [
12
+ 338,
13
+ 816
14
+ ],
15
+ [
16
+ 529,
17
+ 818
18
+ ],
19
+ [
20
+ 819,
21
+ 821
22
+ ],
23
+ [
24
+ 338,
25
+ 817
26
+ ],
27
+ [
28
+ 820,
29
+ 822
30
+ ],
31
+ [
32
+ 337,
33
+ 816
34
+ ],
35
+ [
36
+ 339,
37
+ 816
38
+ ],
39
+ [
40
+ 201,
41
+ 720
42
+ ],
43
+ [
44
+ 822,
45
+ 823
46
+ ],
47
+ [
48
+ 41,
49
+ 121
50
+ ],
51
+ [
52
+ 529,
53
+ 819
54
+ ],
55
+ [
56
+ 39,
57
+ 121
58
+ ],
59
+ [
60
+ 40,
61
+ 120
62
+ ],
63
+ [
64
+ 202,
65
+ 721
66
+ ],
67
+ [
68
+ 337,
69
+ 817
70
+ ],
71
+ [
72
+ 40,
73
+ 122
74
+ ],
75
+ [
76
+ 202,
77
+ 719
78
+ ],
79
+ [
80
+ 339,
81
+ 817
82
+ ],
83
+ [
84
+ 203,
85
+ 720
86
+ ],
87
+ [
88
+ 528,
89
+ 818
90
+ ],
91
+ [
92
+ 336,
93
+ 816
94
+ ],
95
+ [
96
+ 340,
97
+ 816
98
+ ],
99
+ [
100
+ 530,
101
+ 818
102
+ ],
103
+ [
104
+ 201,
105
+ 721
106
+ ],
107
+ [
108
+ 820,
109
+ 821
110
+ ],
111
+ [
112
+ 201,
113
+ 719
114
+ ],
115
+ [
116
+ 41,
117
+ 120
118
+ ],
119
+ [
120
+ 41,
121
+ 122
122
+ ],
123
+ [
124
+ 335,
125
+ 816
126
+ ],
127
+ [
128
+ 341,
129
+ 816
130
+ ],
131
+ [
132
+ 819,
133
+ 820
134
+ ],
135
+ [
136
+ 203,
137
+ 719
138
+ ],
139
+ [
140
+ 39,
141
+ 120
142
+ ],
143
+ [
144
+ 39,
145
+ 122
146
+ ],
147
+ [
148
+ 529,
149
+ 820
150
+ ],
151
+ [
152
+ 203,
153
+ 721
154
+ ],
155
+ [
156
+ 336,
157
+ 817
158
+ ],
159
+ [
160
+ 340,
161
+ 817
162
+ ],
163
+ [
164
+ 200,
165
+ 720
166
+ ],
167
+ [
168
+ 821,
169
+ 823
170
+ ],
171
+ [
172
+ 820,
173
+ 830
174
+ ],
175
+ [
176
+ 42,
177
+ 121
178
+ ],
179
+ [
180
+ 334,
181
+ 816
182
+ ],
183
+ [
184
+ 342,
185
+ 816
186
+ ],
187
+ [
188
+ 528,
189
+ 827
190
+ ],
191
+ [
192
+ 819,
193
+ 823
194
+ ],
195
+ [
196
+ 823,
197
+ 830
198
+ ],
199
+ [
200
+ 204,
201
+ 720
202
+ ],
203
+ [
204
+ 529,
205
+ 823
206
+ ],
207
+ [
208
+ 530,
209
+ 820
210
+ ],
211
+ [
212
+ 38,
213
+ 121
214
+ ],
215
+ [
216
+ 202,
217
+ 722
218
+ ],
219
+ [
220
+ 202,
221
+ 718
222
+ ],
223
+ [
224
+ 200,
225
+ 721
226
+ ],
227
+ [
228
+ 200,
229
+ 719
230
+ ],
231
+ [
232
+ 333,
233
+ 816
234
+ ],
235
+ [
236
+ 40,
237
+ 119
238
+ ],
239
+ [
240
+ 343,
241
+ 816
242
+ ],
243
+ [
244
+ 40,
245
+ 123
246
+ ],
247
+ [
248
+ 528,
249
+ 820
250
+ ],
251
+ [
252
+ 335,
253
+ 817
254
+ ],
255
+ [
256
+ 341,
257
+ 817
258
+ ],
259
+ [
260
+ 529,
261
+ 825
262
+ ],
263
+ [
264
+ 529,
265
+ 826
266
+ ],
267
+ [
268
+ 820,
269
+ 839
270
+ ],
271
+ [
272
+ 42,
273
+ 120
274
+ ],
275
+ [
276
+ 531,
277
+ 820
278
+ ],
279
+ [
280
+ 42,
281
+ 122
282
+ ],
283
+ [
284
+ 201,
285
+ 722
286
+ ],
287
+ [
288
+ 527,
289
+ 820
290
+ ],
291
+ [
292
+ 204,
293
+ 719
294
+ ],
295
+ [
296
+ 201,
297
+ 718
298
+ ],
299
+ [
300
+ 530,
301
+ 825
302
+ ],
303
+ [
304
+ 530,
305
+ 826
306
+ ],
307
+ [
308
+ 41,
309
+ 119
310
+ ],
311
+ [
312
+ 204,
313
+ 721
314
+ ],
315
+ [
316
+ 528,
317
+ 819
318
+ ],
319
+ [
320
+ 41,
321
+ 123
322
+ ],
323
+ [
324
+ 38,
325
+ 122
326
+ ],
327
+ [
328
+ 38,
329
+ 120
330
+ ],
331
+ [
332
+ 332,
333
+ 816
334
+ ],
335
+ [
336
+ 820,
337
+ 842
338
+ ],
339
+ [
340
+ 203,
341
+ 718
342
+ ],
343
+ [
344
+ 344,
345
+ 816
346
+ ],
347
+ [
348
+ 39,
349
+ 123
350
+ ],
351
+ [
352
+ 39,
353
+ 119
354
+ ],
355
+ [
356
+ 203,
357
+ 722
358
+ ],
359
+ [
360
+ 832,
361
+ 834
362
+ ],
363
+ [
364
+ 835,
365
+ 837
366
+ ],
367
+ [
368
+ 839,
369
+ 850
370
+ ],
371
+ [
372
+ 43,
373
+ 121
374
+ ],
375
+ [
376
+ 205,
377
+ 720
378
+ ],
379
+ [
380
+ 530,
381
+ 823
382
+ ],
383
+ [
384
+ 199,
385
+ 720
386
+ ],
387
+ [
388
+ 528,
389
+ 829
390
+ ],
391
+ [
392
+ 827,
393
+ 844
394
+ ],
395
+ [
396
+ 334,
397
+ 817
398
+ ],
399
+ [
400
+ 823,
401
+ 839
402
+ ],
403
+ [
404
+ 342,
405
+ 817
406
+ ],
407
+ [
408
+ 37,
409
+ 121
410
+ ],
411
+ [
412
+ 528,
413
+ 825
414
+ ],
415
+ [
416
+ 531,
417
+ 825
418
+ ],
419
+ [
420
+ 531,
421
+ 826
422
+ ],
423
+ [
424
+ 529,
425
+ 833
426
+ ],
427
+ [
428
+ 842,
429
+ 850
430
+ ],
431
+ [
432
+ 200,
433
+ 722
434
+ ],
435
+ [
436
+ 527,
437
+ 818
438
+ ],
439
+ [
440
+ 529,
441
+ 836
442
+ ],
443
+ [
444
+ 202,
445
+ 717
446
+ ],
447
+ [
448
+ 528,
449
+ 826
450
+ ],
451
+ [
452
+ 345,
453
+ 816
454
+ ],
455
+ [
456
+ 202,
457
+ 723
458
+ ],
459
+ [
460
+ 331,
461
+ 816
462
+ ],
463
+ [
464
+ 530,
465
+ 819
466
+ ],
467
+ [
468
+ 200,
469
+ 718
470
+ ],
471
+ [
472
+ 822,
473
+ 825
474
+ ],
475
+ [
476
+ 528,
477
+ 823
478
+ ],
479
+ [
480
+ 822,
481
+ 826
482
+ ],
483
+ [
484
+ 833,
485
+ 844
486
+ ],
487
+ [
488
+ 532,
489
+ 820
490
+ ],
491
+ [
492
+ 836,
493
+ 844
494
+ ],
495
+ [
496
+ 199,
497
+ 721
498
+ ],
499
+ [
500
+ 42,
501
+ 119
502
+ ],
503
+ [
504
+ 40,
505
+ 118
506
+ ],
507
+ [
508
+ 529,
509
+ 827
510
+ ],
511
+ [
512
+ 40,
513
+ 124
514
+ ],
515
+ [
516
+ 205,
517
+ 719
518
+ ],
519
+ [
520
+ 822,
521
+ 834
522
+ ],
523
+ [
524
+ 527,
525
+ 825
526
+ ],
527
+ [
528
+ 42,
529
+ 123
530
+ ],
531
+ [
532
+ 839,
533
+ 865
534
+ ],
535
+ [
536
+ 527,
537
+ 826
538
+ ],
539
+ [
540
+ 204,
541
+ 718
542
+ ],
543
+ [
544
+ 199,
545
+ 719
546
+ ],
547
+ [
548
+ 822,
549
+ 837
550
+ ],
551
+ [
552
+ 530,
553
+ 838
554
+ ],
555
+ [
556
+ 526,
557
+ 820
558
+ ],
559
+ [
560
+ 205,
561
+ 721
562
+ ],
563
+ [
564
+ 38,
565
+ 123
566
+ ],
567
+ [
568
+ 201,
569
+ 717
570
+ ],
571
+ [
572
+ 201,
573
+ 723
574
+ ],
575
+ [
576
+ 825,
577
+ 830
578
+ ],
579
+ [
580
+ 821,
581
+ 825
582
+ ],
583
+ [
584
+ 821,
585
+ 826
586
+ ],
587
+ [
588
+ 826,
589
+ 830
590
+ ],
591
+ [
592
+ 43,
593
+ 120
594
+ ],
595
+ [
596
+ 204,
597
+ 722
598
+ ],
599
+ [
600
+ 38,
601
+ 119
602
+ ],
603
+ [
604
+ 838,
605
+ 844
606
+ ],
607
+ [
608
+ 43,
609
+ 122
610
+ ],
611
+ [
612
+ 827,
613
+ 829
614
+ ],
615
+ [
616
+ 346,
617
+ 816
618
+ ],
619
+ [
620
+ 41,
621
+ 118
622
+ ],
623
+ [
624
+ 330,
625
+ 816
626
+ ],
627
+ [
628
+ 41,
629
+ 124
630
+ ],
631
+ [
632
+ 823,
633
+ 842
634
+ ],
635
+ [
636
+ 831,
637
+ 838
638
+ ],
639
+ [
640
+ 819,
641
+ 839
642
+ ],
643
+ [
644
+ 37,
645
+ 122
646
+ ],
647
+ [
648
+ 37,
649
+ 120
650
+ ],
651
+ [
652
+ 333,
653
+ 817
654
+ ],
655
+ [
656
+ 527,
657
+ 823
658
+ ],
659
+ [
660
+ 343,
661
+ 817
662
+ ],
663
+ [
664
+ 39,
665
+ 118
666
+ ],
667
+ [
668
+ 827,
669
+ 859
670
+ ],
671
+ [
672
+ 531,
673
+ 823
674
+ ],
675
+ [
676
+ 831,
677
+ 860
678
+ ],
679
+ [
680
+ 39,
681
+ 124
682
+ ],
683
+ [
684
+ 530,
685
+ 840
686
+ ],
687
+ [
688
+ 203,
689
+ 717
690
+ ],
691
+ [
692
+ 203,
693
+ 723
694
+ ],
695
+ [
696
+ 829,
697
+ 834
698
+ ],
699
+ [
700
+ 529,
701
+ 840
702
+ ],
703
+ [
704
+ 530,
705
+ 841
706
+ ],
707
+ [
708
+ 833,
709
+ 859
710
+ ],
711
+ [
712
+ 842,
713
+ 865
714
+ ],
715
+ [
716
+ 827,
717
+ 884
718
+ ],
719
+ [
720
+ 836,
721
+ 859
722
+ ],
723
+ [
724
+ 528,
725
+ 840
726
+ ],
727
+ [
728
+ 44,
729
+ 121
730
+ ],
731
+ [
732
+ 206,
733
+ 720
734
+ ],
735
+ [
736
+ 529,
737
+ 841
738
+ ],
739
+ [
740
+ 528,
741
+ 841
742
+ ],
743
+ [
744
+ 819,
745
+ 842
746
+ ],
747
+ [
748
+ 821,
749
+ 834
750
+ ],
751
+ [
752
+ 532,
753
+ 826
754
+ ],
755
+ [
756
+ 532,
757
+ 825
758
+ ],
759
+ [
760
+ 531,
761
+ 840
762
+ ],
763
+ [
764
+ 821,
765
+ 837
766
+ ],
767
+ [
768
+ 531,
769
+ 841
770
+ ],
771
+ [
772
+ 529,
773
+ 837
774
+ ],
775
+ [
776
+ 820,
777
+ 864
778
+ ],
779
+ [
780
+ 829,
781
+ 860
782
+ ],
783
+ [
784
+ 36,
785
+ 121
786
+ ],
787
+ [
788
+ 199,
789
+ 722
790
+ ],
791
+ [
792
+ 832,
793
+ 860
794
+ ],
795
+ [
796
+ 835,
797
+ 860
798
+ ],
799
+ [
800
+ 834,
801
+ 846
802
+ ],
803
+ [
804
+ 202,
805
+ 716
806
+ ],
807
+ [
808
+ 829,
809
+ 866
810
+ ],
811
+ [
812
+ 832,
813
+ 866
814
+ ],
815
+ [
816
+ 347,
817
+ 816
818
+ ],
819
+ [
820
+ 835,
821
+ 866
822
+ ],
823
+ [
824
+ 530,
825
+ 837
826
+ ]
827
+ ],
828
+ "temporal_merges": [
829
+ [
830
+ 824,
831
+ 824,
832
+ 1
833
+ ],
834
+ [
835
+ 1024,
836
+ 1024,
837
+ 2
838
+ ],
839
+ [
840
+ 828,
841
+ 828,
842
+ 1
843
+ ],
844
+ [
845
+ 1025,
846
+ 1025,
847
+ 4
848
+ ],
849
+ [
850
+ 816,
851
+ 816,
852
+ 1
853
+ ],
854
+ [
855
+ 817,
856
+ 817,
857
+ 1
858
+ ],
859
+ [
860
+ 831,
861
+ 831,
862
+ 2
863
+ ],
864
+ [
865
+ 527,
866
+ 527,
867
+ 1
868
+ ],
869
+ [
870
+ 829,
871
+ 829,
872
+ 2
873
+ ],
874
+ [
875
+ 1026,
876
+ 1026,
877
+ 2
878
+ ],
879
+ [
880
+ 526,
881
+ 526,
882
+ 1
883
+ ],
884
+ [
885
+ 832,
886
+ 832,
887
+ 2
888
+ ],
889
+ [
890
+ 528,
891
+ 528,
892
+ 1
893
+ ],
894
+ [
895
+ 835,
896
+ 835,
897
+ 2
898
+ ],
899
+ [
900
+ 530,
901
+ 530,
902
+ 1
903
+ ],
904
+ [
905
+ 531,
906
+ 531,
907
+ 1
908
+ ],
909
+ [
910
+ 848,
911
+ 848,
912
+ 1
913
+ ],
914
+ [
915
+ 849,
916
+ 849,
917
+ 1
918
+ ],
919
+ [
920
+ 529,
921
+ 529,
922
+ 1
923
+ ],
924
+ [
925
+ 834,
926
+ 834,
927
+ 1
928
+ ],
929
+ [
930
+ 532,
931
+ 532,
932
+ 1
933
+ ],
934
+ [
935
+ 847,
936
+ 847,
937
+ 2
938
+ ],
939
+ [
940
+ 533,
941
+ 533,
942
+ 1
943
+ ],
944
+ [
945
+ 837,
946
+ 837,
947
+ 1
948
+ ],
949
+ [
950
+ 856,
951
+ 856,
952
+ 1
953
+ ],
954
+ [
955
+ 838,
956
+ 838,
957
+ 2
958
+ ],
959
+ [
960
+ 525,
961
+ 525,
962
+ 1
963
+ ],
964
+ [
965
+ 198,
966
+ 198,
967
+ 1
968
+ ],
969
+ [
970
+ 857,
971
+ 857,
972
+ 1
973
+ ],
974
+ [
975
+ 840,
976
+ 840,
977
+ 1
978
+ ],
979
+ [
980
+ 833,
981
+ 833,
982
+ 2
983
+ ],
984
+ [
985
+ 841,
986
+ 841,
987
+ 1
988
+ ],
989
+ [
990
+ 843,
991
+ 843,
992
+ 2
993
+ ],
994
+ [
995
+ 117,
996
+ 117,
997
+ 1
998
+ ],
999
+ [
1000
+ 125,
1001
+ 125,
1002
+ 1
1003
+ ],
1004
+ [
1005
+ 836,
1006
+ 836,
1007
+ 2
1008
+ ],
1009
+ [
1010
+ 846,
1011
+ 846,
1012
+ 2
1013
+ ],
1014
+ [
1015
+ 852,
1016
+ 852,
1017
+ 2
1018
+ ],
1019
+ [
1020
+ 1024,
1021
+ 824,
1022
+ 2
1023
+ ],
1024
+ [
1025
+ 853,
1026
+ 853,
1027
+ 2
1028
+ ],
1029
+ [
1030
+ 825,
1031
+ 825,
1032
+ 1
1033
+ ],
1034
+ [
1035
+ 845,
1036
+ 845,
1037
+ 2
1038
+ ],
1039
+ [
1040
+ 826,
1041
+ 826,
1042
+ 1
1043
+ ],
1044
+ [
1045
+ 820,
1046
+ 820,
1047
+ 1
1048
+ ],
1049
+ [
1050
+ 818,
1051
+ 818,
1052
+ 2
1053
+ ],
1054
+ [
1055
+ 200,
1056
+ 200,
1057
+ 1
1058
+ ],
1059
+ [
1060
+ 206,
1061
+ 206,
1062
+ 1
1063
+ ],
1064
+ [
1065
+ 202,
1066
+ 202,
1067
+ 1
1068
+ ],
1069
+ [
1070
+ 37,
1071
+ 37,
1072
+ 1
1073
+ ],
1074
+ [
1075
+ 861,
1076
+ 861,
1077
+ 2
1078
+ ],
1079
+ [
1080
+ 724,
1081
+ 724,
1082
+ 1
1083
+ ],
1084
+ [
1085
+ 205,
1086
+ 205,
1087
+ 1
1088
+ ],
1089
+ [
1090
+ 43,
1091
+ 43,
1092
+ 1
1093
+ ],
1094
+ [
1095
+ 862,
1096
+ 862,
1097
+ 1
1098
+ ],
1099
+ [
1100
+ 827,
1101
+ 827,
1102
+ 2
1103
+ ],
1104
+ [
1105
+ 42,
1106
+ 42,
1107
+ 1
1108
+ ],
1109
+ [
1110
+ 118,
1111
+ 118,
1112
+ 1
1113
+ ],
1114
+ [
1115
+ 863,
1116
+ 863,
1117
+ 1
1118
+ ],
1119
+ [
1120
+ 38,
1121
+ 38,
1122
+ 1
1123
+ ],
1124
+ [
1125
+ 1028,
1126
+ 1028,
1127
+ 2
1128
+ ],
1129
+ [
1130
+ 36,
1131
+ 36,
1132
+ 1
1133
+ ],
1134
+ [
1135
+ 40,
1136
+ 40,
1137
+ 2
1138
+ ],
1139
+ [
1140
+ 1029,
1141
+ 1029,
1142
+ 2
1143
+ ],
1144
+ [
1145
+ 528,
1146
+ 527,
1147
+ 1
1148
+ ],
1149
+ [
1150
+ 124,
1151
+ 124,
1152
+ 1
1153
+ ],
1154
+ [
1155
+ 201,
1156
+ 201,
1157
+ 1
1158
+ ],
1159
+ [
1160
+ 121,
1161
+ 121,
1162
+ 1
1163
+ ],
1164
+ [
1165
+ 119,
1166
+ 119,
1167
+ 1
1168
+ ],
1169
+ [
1170
+ 526,
1171
+ 527,
1172
+ 1
1173
+ ],
1174
+ [
1175
+ 120,
1176
+ 120,
1177
+ 1
1178
+ ],
1179
+ [
1180
+ 204,
1181
+ 204,
1182
+ 1
1183
+ ],
1184
+ [
1185
+ 858,
1186
+ 858,
1187
+ 2
1188
+ ],
1189
+ [
1190
+ 534,
1191
+ 534,
1192
+ 1
1193
+ ],
1194
+ [
1195
+ 123,
1196
+ 123,
1197
+ 1
1198
+ ],
1199
+ [
1200
+ 530,
1201
+ 529,
1202
+ 1
1203
+ ],
1204
+ [
1205
+ 122,
1206
+ 122,
1207
+ 1
1208
+ ],
1209
+ [
1210
+ 44,
1211
+ 44,
1212
+ 1
1213
+ ],
1214
+ [
1215
+ 824,
1216
+ 824,
1217
+ 2
1218
+ ],
1219
+ [
1220
+ 851,
1221
+ 851,
1222
+ 2
1223
+ ],
1224
+ [
1225
+ 199,
1226
+ 199,
1227
+ 1
1228
+ ],
1229
+ [
1230
+ 718,
1231
+ 718,
1232
+ 1
1233
+ ],
1234
+ [
1235
+ 717,
1236
+ 717,
1237
+ 1
1238
+ ],
1239
+ [
1240
+ 203,
1241
+ 203,
1242
+ 1
1243
+ ],
1244
+ [
1245
+ 723,
1246
+ 723,
1247
+ 1
1248
+ ],
1249
+ [
1250
+ 828,
1251
+ 828,
1252
+ 2
1253
+ ],
1254
+ [
1255
+ 207,
1256
+ 207,
1257
+ 1
1258
+ ],
1259
+ [
1260
+ 530,
1261
+ 531,
1262
+ 1
1263
+ ],
1264
+ [
1265
+ 528,
1266
+ 529,
1267
+ 1
1268
+ ],
1269
+ [
1270
+ 524,
1271
+ 524,
1272
+ 1
1273
+ ],
1274
+ [
1275
+ 39,
1276
+ 39,
1277
+ 1
1278
+ ],
1279
+ [
1280
+ 526,
1281
+ 525,
1282
+ 1
1283
+ ],
1284
+ [
1285
+ 41,
1286
+ 41,
1287
+ 1
1288
+ ],
1289
+ [
1290
+ 720,
1291
+ 720,
1292
+ 1
1293
+ ],
1294
+ [
1295
+ 855,
1296
+ 855,
1297
+ 2
1298
+ ],
1299
+ [
1300
+ 870,
1301
+ 870,
1302
+ 2
1303
+ ],
1304
+ [
1305
+ 716,
1306
+ 716,
1307
+ 1
1308
+ ],
1309
+ [
1310
+ 532,
1311
+ 531,
1312
+ 1
1313
+ ],
1314
+ [
1315
+ 35,
1316
+ 35,
1317
+ 1
1318
+ ],
1319
+ [
1320
+ 880,
1321
+ 880,
1322
+ 1
1323
+ ],
1324
+ [
1325
+ 197,
1326
+ 197,
1327
+ 1
1328
+ ],
1329
+ [
1330
+ 831,
1331
+ 831,
1332
+ 3
1333
+ ],
1334
+ [
1335
+ 45,
1336
+ 45,
1337
+ 1
1338
+ ],
1339
+ [
1340
+ 116,
1341
+ 116,
1342
+ 1
1343
+ ],
1344
+ [
1345
+ 881,
1346
+ 881,
1347
+ 1
1348
+ ],
1349
+ [
1350
+ 126,
1351
+ 126,
1352
+ 1
1353
+ ],
1354
+ [
1355
+ 823,
1356
+ 823,
1357
+ 1
1358
+ ],
1359
+ [
1360
+ 719,
1361
+ 719,
1362
+ 1
1363
+ ],
1364
+ [
1365
+ 867,
1366
+ 867,
1367
+ 2
1368
+ ],
1369
+ [
1370
+ 532,
1371
+ 533,
1372
+ 1
1373
+ ],
1374
+ [
1375
+ 721,
1376
+ 721,
1377
+ 1
1378
+ ],
1379
+ [
1380
+ 722,
1381
+ 722,
1382
+ 1
1383
+ ],
1384
+ [
1385
+ 875,
1386
+ 875,
1387
+ 1
1388
+ ],
1389
+ [
1390
+ 856,
1391
+ 834,
1392
+ 1
1393
+ ],
1394
+ [
1395
+ 877,
1396
+ 877,
1397
+ 1
1398
+ ],
1399
+ [
1400
+ 848,
1401
+ 862,
1402
+ 1
1403
+ ],
1404
+ [
1405
+ 876,
1406
+ 876,
1407
+ 2
1408
+ ],
1409
+ [
1410
+ 848,
1411
+ 840,
1412
+ 1
1413
+ ],
1414
+ [
1415
+ 849,
1416
+ 863,
1417
+ 1
1418
+ ],
1419
+ [
1420
+ 878,
1421
+ 878,
1422
+ 2
1423
+ ],
1424
+ [
1425
+ 857,
1426
+ 837,
1427
+ 1
1428
+ ],
1429
+ [
1430
+ 829,
1431
+ 829,
1432
+ 3
1433
+ ],
1434
+ [
1435
+ 849,
1436
+ 841,
1437
+ 1
1438
+ ],
1439
+ [
1440
+ 724,
1441
+ 725,
1442
+ 1
1443
+ ],
1444
+ [
1445
+ 534,
1446
+ 533,
1447
+ 1
1448
+ ],
1449
+ [
1450
+ 715,
1451
+ 715,
1452
+ 1
1453
+ ],
1454
+ [
1455
+ 833,
1456
+ 833,
1457
+ 1
1458
+ ],
1459
+ [
1460
+ 836,
1461
+ 836,
1462
+ 1
1463
+ ],
1464
+ [
1465
+ 1026,
1466
+ 828,
1467
+ 2
1468
+ ],
1469
+ [
1470
+ 832,
1471
+ 832,
1472
+ 3
1473
+ ],
1474
+ [
1475
+ 1033,
1476
+ 1033,
1477
+ 4
1478
+ ],
1479
+ [
1480
+ 854,
1481
+ 854,
1482
+ 2
1483
+ ],
1484
+ [
1485
+ 830,
1486
+ 830,
1487
+ 2
1488
+ ],
1489
+ [
1490
+ 835,
1491
+ 835,
1492
+ 3
1493
+ ],
1494
+ [
1495
+ 535,
1496
+ 535,
1497
+ 1
1498
+ ],
1499
+ [
1500
+ 524,
1501
+ 525,
1502
+ 1
1503
+ ],
1504
+ [
1505
+ 885,
1506
+ 885,
1507
+ 2
1508
+ ],
1509
+ [
1510
+ 887,
1511
+ 887,
1512
+ 2
1513
+ ],
1514
+ [
1515
+ 825,
1516
+ 840,
1517
+ 1
1518
+ ],
1519
+ [
1520
+ 826,
1521
+ 841,
1522
+ 1
1523
+ ],
1524
+ [
1525
+ 523,
1526
+ 523,
1527
+ 1
1528
+ ],
1529
+ [
1530
+ 838,
1531
+ 838,
1532
+ 3
1533
+ ],
1534
+ [
1535
+ 843,
1536
+ 827,
1537
+ 2
1538
+ ],
1539
+ [
1540
+ 208,
1541
+ 208,
1542
+ 1
1543
+ ],
1544
+ [
1545
+ 845,
1546
+ 827,
1547
+ 2
1548
+ ],
1549
+ [
1550
+ 873,
1551
+ 873,
1552
+ 2
1553
+ ],
1554
+ [
1555
+ 894,
1556
+ 894,
1557
+ 2
1558
+ ],
1559
+ [
1560
+ 871,
1561
+ 871,
1562
+ 1
1563
+ ],
1564
+ [
1565
+ 725,
1566
+ 725,
1567
+ 1
1568
+ ],
1569
+ [
1570
+ 897,
1571
+ 897,
1572
+ 2
1573
+ ],
1574
+ [
1575
+ 872,
1576
+ 872,
1577
+ 1
1578
+ ],
1579
+ [
1580
+ 856,
1581
+ 880,
1582
+ 1
1583
+ ],
1584
+ [
1585
+ 34,
1586
+ 34,
1587
+ 1
1588
+ ],
1589
+ [
1590
+ 874,
1591
+ 874,
1592
+ 1
1593
+ ],
1594
+ [
1595
+ 857,
1596
+ 881,
1597
+ 1
1598
+ ],
1599
+ [
1600
+ 900,
1601
+ 900,
1602
+ 1
1603
+ ],
1604
+ [
1605
+ 903,
1606
+ 903,
1607
+ 1
1608
+ ],
1609
+ [
1610
+ 818,
1611
+ 818,
1612
+ 1
1613
+ ],
1614
+ [
1615
+ 898,
1616
+ 898,
1617
+ 2
1618
+ ],
1619
+ [
1620
+ 899,
1621
+ 899,
1622
+ 2
1623
+ ],
1624
+ [
1625
+ 206,
1626
+ 205,
1627
+ 1
1628
+ ],
1629
+ [
1630
+ 1031,
1631
+ 527,
1632
+ 2
1633
+ ],
1634
+ [
1635
+ 831,
1636
+ 831,
1637
+ 1
1638
+ ],
1639
+ [
1640
+ 829,
1641
+ 829,
1642
+ 1
1643
+ ],
1644
+ [
1645
+ 825,
1646
+ 820,
1647
+ 1
1648
+ ],
1649
+ [
1650
+ 826,
1651
+ 820,
1652
+ 1
1653
+ ],
1654
+ [
1655
+ 837,
1656
+ 837,
1657
+ 2
1658
+ ],
1659
+ [
1660
+ 869,
1661
+ 869,
1662
+ 2
1663
+ ],
1664
+ [
1665
+ 875,
1666
+ 862,
1667
+ 1
1668
+ ],
1669
+ [
1670
+ 202,
1671
+ 201,
1672
+ 1
1673
+ ],
1674
+ [
1675
+ 534,
1676
+ 535,
1677
+ 1
1678
+ ],
1679
+ [
1680
+ 206,
1681
+ 207,
1682
+ 1
1683
+ ],
1684
+ [
1685
+ 838,
1686
+ 838,
1687
+ 1
1688
+ ],
1689
+ [
1690
+ 127,
1691
+ 127,
1692
+ 1
1693
+ ],
1694
+ [
1695
+ 877,
1696
+ 863,
1697
+ 1
1698
+ ],
1699
+ [
1700
+ 905,
1701
+ 905,
1702
+ 2
1703
+ ],
1704
+ [
1705
+ 904,
1706
+ 904,
1707
+ 2
1708
+ ],
1709
+ [
1710
+ 46,
1711
+ 46,
1712
+ 1
1713
+ ],
1714
+ [
1715
+ 115,
1716
+ 115,
1717
+ 1
1718
+ ],
1719
+ [
1720
+ 717,
1721
+ 716,
1722
+ 1
1723
+ ],
1724
+ [
1725
+ 910,
1726
+ 910,
1727
+ 2
1728
+ ],
1729
+ [
1730
+ 833,
1731
+ 833,
1732
+ 3
1733
+ ],
1734
+ [
1735
+ 916,
1736
+ 916,
1737
+ 1
1738
+ ],
1739
+ [
1740
+ 843,
1741
+ 843,
1742
+ 1
1743
+ ],
1744
+ [
1745
+ 724,
1746
+ 723,
1747
+ 1
1748
+ ],
1749
+ [
1750
+ 834,
1751
+ 834,
1752
+ 2
1753
+ ],
1754
+ [
1755
+ 717,
1756
+ 718,
1757
+ 1
1758
+ ],
1759
+ [
1760
+ 200,
1761
+ 199,
1762
+ 1
1763
+ ],
1764
+ [
1765
+ 836,
1766
+ 836,
1767
+ 3
1768
+ ],
1769
+ [
1770
+ 886,
1771
+ 886,
1772
+ 1
1773
+ ],
1774
+ [
1775
+ 868,
1776
+ 868,
1777
+ 2
1778
+ ],
1779
+ [
1780
+ 918,
1781
+ 918,
1782
+ 1
1783
+ ],
1784
+ [
1785
+ 197,
1786
+ 198,
1787
+ 1
1788
+ ],
1789
+ [
1790
+ 196,
1791
+ 196,
1792
+ 1
1793
+ ],
1794
+ [
1795
+ 845,
1796
+ 845,
1797
+ 1
1798
+ ],
1799
+ [
1800
+ 117,
1801
+ 116,
1802
+ 1
1803
+ ],
1804
+ [
1805
+ 851,
1806
+ 851,
1807
+ 1
1808
+ ],
1809
+ [
1810
+ 715,
1811
+ 714,
1812
+ 1
1813
+ ],
1814
+ [
1815
+ 890,
1816
+ 890,
1817
+ 1
1818
+ ],
1819
+ [
1820
+ 889,
1821
+ 889,
1822
+ 1
1823
+ ],
1824
+ [
1825
+ 715,
1826
+ 716,
1827
+ 1
1828
+ ],
1829
+ [
1830
+ 37,
1831
+ 36,
1832
+ 1
1833
+ ],
1834
+ [
1835
+ 125,
1836
+ 126,
1837
+ 1
1838
+ ],
1839
+ [
1840
+ 524,
1841
+ 523,
1842
+ 1
1843
+ ],
1844
+ [
1845
+ 847,
1846
+ 835,
1847
+ 2
1848
+ ],
1849
+ [
1850
+ 37,
1851
+ 38,
1852
+ 1
1853
+ ],
1854
+ [
1855
+ 43,
1856
+ 42,
1857
+ 1
1858
+ ],
1859
+ [
1860
+ 200,
1861
+ 201,
1862
+ 1
1863
+ ],
1864
+ [
1865
+ 536,
1866
+ 536,
1867
+ 1
1868
+ ],
1869
+ [
1870
+ 726,
1871
+ 726,
1872
+ 1
1873
+ ],
1874
+ [
1875
+ 204,
1876
+ 203,
1877
+ 1
1878
+ ],
1879
+ [
1880
+ 858,
1881
+ 858,
1882
+ 1
1883
+ ],
1884
+ [
1885
+ 855,
1886
+ 855,
1887
+ 1
1888
+ ],
1889
+ [
1890
+ 531,
1891
+ 530,
1892
+ 1
1893
+ ],
1894
+ [
1895
+ 529,
1896
+ 530,
1897
+ 1
1898
+ ],
1899
+ [
1900
+ 529,
1901
+ 528,
1902
+ 1
1903
+ ],
1904
+ [
1905
+ 117,
1906
+ 118,
1907
+ 1
1908
+ ],
1909
+ [
1910
+ 202,
1911
+ 203,
1912
+ 1
1913
+ ],
1914
+ [
1915
+ 204,
1916
+ 205,
1917
+ 1
1918
+ ],
1919
+ [
1920
+ 35,
1921
+ 36,
1922
+ 1
1923
+ ],
1924
+ [
1925
+ 125,
1926
+ 124,
1927
+ 1
1928
+ ],
1929
+ [
1930
+ 824,
1931
+ 1024,
1932
+ 2
1933
+ ],
1934
+ [
1935
+ 895,
1936
+ 895,
1937
+ 1
1938
+ ],
1939
+ [
1940
+ 832,
1941
+ 832,
1942
+ 1
1943
+ ],
1944
+ [
1945
+ 121,
1946
+ 120,
1947
+ 1
1948
+ ],
1949
+ [
1950
+ 43,
1951
+ 44,
1952
+ 1
1953
+ ],
1954
+ [
1955
+ 119,
1956
+ 118,
1957
+ 1
1958
+ ],
1959
+ [
1960
+ 888,
1961
+ 888,
1962
+ 1
1963
+ ],
1964
+ [
1965
+ 527,
1966
+ 528,
1967
+ 1
1968
+ ],
1969
+ [
1970
+ 522,
1971
+ 522,
1972
+ 1
1973
+ ],
1974
+ [
1975
+ 121,
1976
+ 122,
1977
+ 1
1978
+ ],
1979
+ [
1980
+ 853,
1981
+ 835,
1982
+ 2
1983
+ ],
1984
+ [
1985
+ 831,
1986
+ 831,
1987
+ 4
1988
+ ],
1989
+ [
1990
+ 847,
1991
+ 829,
1992
+ 2
1993
+ ],
1994
+ [
1995
+ 891,
1996
+ 891,
1997
+ 1
1998
+ ],
1999
+ [
2000
+ 526,
2001
+ 1050,
2002
+ 1
2003
+ ],
2004
+ [
2005
+ 123,
2006
+ 124,
2007
+ 1
2008
+ ],
2009
+ [
2010
+ 45,
2011
+ 44,
2012
+ 1
2013
+ ],
2014
+ [
2015
+ 719,
2016
+ 718,
2017
+ 1
2018
+ ],
2019
+ [
2020
+ 911,
2021
+ 911,
2022
+ 1
2023
+ ],
2024
+ [
2025
+ 722,
2026
+ 723,
2027
+ 1
2028
+ ],
2029
+ [
2030
+ 1051,
2031
+ 198,
2032
+ 2
2033
+ ],
2034
+ [
2035
+ 919,
2036
+ 919,
2037
+ 2
2038
+ ],
2039
+ [
2040
+ 39,
2041
+ 38,
2042
+ 1
2043
+ ],
2044
+ [
2045
+ 824,
2046
+ 824,
2047
+ 3
2048
+ ],
2049
+ [
2050
+ 846,
2051
+ 846,
2052
+ 3
2053
+ ],
2054
+ [
2055
+ 119,
2056
+ 120,
2057
+ 1
2058
+ ],
2059
+ [
2060
+ 41,
2061
+ 42,
2062
+ 1
2063
+ ],
2064
+ [
2065
+ 930,
2066
+ 930,
2067
+ 1
2068
+ ],
2069
+ [
2070
+ 932,
2071
+ 932,
2072
+ 1
2073
+ ],
2074
+ [
2075
+ 864,
2076
+ 864,
2077
+ 2
2078
+ ],
2079
+ [
2080
+ 1039,
2081
+ 531,
2082
+ 2
2083
+ ],
2084
+ [
2085
+ 720,
2086
+ 721,
2087
+ 1
2088
+ ],
2089
+ [
2090
+ 913,
2091
+ 913,
2092
+ 1
2093
+ ],
2094
+ [
2095
+ 875,
2096
+ 900,
2097
+ 1
2098
+ ],
2099
+ [
2100
+ 123,
2101
+ 122,
2102
+ 1
2103
+ ],
2104
+ [
2105
+ 877,
2106
+ 903,
2107
+ 1
2108
+ ],
2109
+ [
2110
+ 1028,
2111
+ 816,
2112
+ 2
2113
+ ],
2114
+ [
2115
+ 852,
2116
+ 852,
2117
+ 3
2118
+ ],
2119
+ [
2120
+ 714,
2121
+ 714,
2122
+ 1
2123
+ ],
2124
+ [
2125
+ 867,
2126
+ 867,
2127
+ 1
2128
+ ],
2129
+ [
2130
+ 1037,
2131
+ 835,
2132
+ 4
2133
+ ],
2134
+ [
2135
+ 1046,
2136
+ 533,
2137
+ 2
2138
+ ],
2139
+ [
2140
+ 208,
2141
+ 207,
2142
+ 1
2143
+ ],
2144
+ [
2145
+ 209,
2146
+ 209,
2147
+ 1
2148
+ ],
2149
+ [
2150
+ 907,
2151
+ 907,
2152
+ 2
2153
+ ],
2154
+ [
2155
+ 879,
2156
+ 879,
2157
+ 1
2158
+ ],
2159
+ [
2160
+ 722,
2161
+ 721,
2162
+ 1
2163
+ ],
2164
+ [
2165
+ 33,
2166
+ 33,
2167
+ 1
2168
+ ],
2169
+ [
2170
+ 854,
2171
+ 854,
2172
+ 1
2173
+ ],
2174
+ [
2175
+ 199,
2176
+ 198,
2177
+ 1
2178
+ ],
2179
+ [
2180
+ 526,
2181
+ 1031,
2182
+ 1
2183
+ ],
2184
+ [
2185
+ 908,
2186
+ 908,
2187
+ 2
2188
+ ],
2189
+ [
2190
+ 828,
2191
+ 828,
2192
+ 3
2193
+ ],
2194
+ [
2195
+ 1086,
2196
+ 1086,
2197
+ 4
2198
+ ],
2199
+ [
2200
+ 1030,
2201
+ 831,
2202
+ 4
2203
+ ],
2204
+ [
2205
+ 827,
2206
+ 843,
2207
+ 2
2208
+ ],
2209
+ [
2210
+ 823,
2211
+ 837,
2212
+ 1
2213
+ ],
2214
+ [
2215
+ 726,
2216
+ 725,
2217
+ 1
2218
+ ],
2219
+ [
2220
+ 892,
2221
+ 892,
2222
+ 1
2223
+ ],
2224
+ [
2225
+ 833,
2226
+ 833,
2227
+ 4
2228
+ ],
2229
+ [
2230
+ 861,
2231
+ 861,
2232
+ 1
2233
+ ],
2234
+ [
2235
+ 1043,
2236
+ 834,
2237
+ 2
2238
+ ],
2239
+ [
2240
+ 943,
2241
+ 943,
2242
+ 2
2243
+ ],
2244
+ [
2245
+ 882,
2246
+ 882,
2247
+ 2
2248
+ ],
2249
+ [
2250
+ 827,
2251
+ 845,
2252
+ 2
2253
+ ],
2254
+ [
2255
+ 893,
2256
+ 893,
2257
+ 1
2258
+ ],
2259
+ [
2260
+ 902,
2261
+ 902,
2262
+ 1
2263
+ ],
2264
+ [
2265
+ 836,
2266
+ 836,
2267
+ 4
2268
+ ],
2269
+ [
2270
+ 832,
2271
+ 832,
2272
+ 4
2273
+ ],
2274
+ [
2275
+ 848,
2276
+ 1077,
2277
+ 1
2278
+ ],
2279
+ [
2280
+ 838,
2281
+ 838,
2282
+ 4
2283
+ ],
2284
+ [
2285
+ 1025,
2286
+ 1024,
2287
+ 4
2288
+ ],
2289
+ [
2290
+ 35,
2291
+ 34,
2292
+ 1
2293
+ ],
2294
+ [
2295
+ 945,
2296
+ 945,
2297
+ 2
2298
+ ],
2299
+ [
2300
+ 536,
2301
+ 535,
2302
+ 1
2303
+ ],
2304
+ [
2305
+ 916,
2306
+ 880,
2307
+ 1
2308
+ ],
2309
+ [
2310
+ 883,
2311
+ 883,
2312
+ 2
2313
+ ],
2314
+ [
2315
+ 720,
2316
+ 719,
2317
+ 1
2318
+ ],
2319
+ [
2320
+ 531,
2321
+ 532,
2322
+ 1
2323
+ ],
2324
+ [
2325
+ 849,
2326
+ 1081,
2327
+ 1
2328
+ ],
2329
+ [
2330
+ 128,
2331
+ 128,
2332
+ 1
2333
+ ],
2334
+ [
2335
+ 906,
2336
+ 906,
2337
+ 1
2338
+ ],
2339
+ [
2340
+ 47,
2341
+ 47,
2342
+ 1
2343
+ ],
2344
+ [
2345
+ 918,
2346
+ 881,
2347
+ 1
2348
+ ],
2349
+ [
2350
+ 40,
2351
+ 40,
2352
+ 1
2353
+ ],
2354
+ [
2355
+ 823,
2356
+ 834,
2357
+ 1
2358
+ ],
2359
+ [
2360
+ 1025,
2361
+ 824,
2362
+ 4
2363
+ ],
2364
+ [
2365
+ 942,
2366
+ 942,
2367
+ 2
2368
+ ],
2369
+ [
2370
+ 847,
2371
+ 847,
2372
+ 3
2373
+ ],
2374
+ [
2375
+ 536,
2376
+ 537,
2377
+ 1
2378
+ ],
2379
+ [
2380
+ 873,
2381
+ 873,
2382
+ 1
2383
+ ],
2384
+ [
2385
+ 528,
2386
+ 528,
2387
+ 2
2388
+ ],
2389
+ [
2390
+ 846,
2391
+ 846,
2392
+ 1
2393
+ ],
2394
+ [
2395
+ 527,
2396
+ 526,
2397
+ 1
2398
+ ],
2399
+ [
2400
+ 1053,
2401
+ 840,
2402
+ 2
2403
+ ],
2404
+ [
2405
+ 522,
2406
+ 523,
2407
+ 1
2408
+ ],
2409
+ [
2410
+ 828,
2411
+ 1026,
2412
+ 2
2413
+ ],
2414
+ [
2415
+ 525,
2416
+ 526,
2417
+ 1
2418
+ ],
2419
+ [
2420
+ 1055,
2421
+ 841,
2422
+ 2
2423
+ ],
2424
+ [
2425
+ 949,
2426
+ 949,
2427
+ 2
2428
+ ],
2429
+ [
2430
+ 853,
2431
+ 853,
2432
+ 3
2433
+ ],
2434
+ [
2435
+ 114,
2436
+ 114,
2437
+ 1
2438
+ ],
2439
+ [
2440
+ 726,
2441
+ 727,
2442
+ 1
2443
+ ],
2444
+ [
2445
+ 1067,
2446
+ 820,
2447
+ 2
2448
+ ],
2449
+ [
2450
+ 1047,
2451
+ 837,
2452
+ 2
2453
+ ],
2454
+ [
2455
+ 980,
2456
+ 980,
2457
+ 1
2458
+ ],
2459
+ [
2460
+ 852,
2461
+ 852,
2462
+ 1
2463
+ ],
2464
+ [
2465
+ 825,
2466
+ 825,
2467
+ 2
2468
+ ],
2469
+ [
2470
+ 830,
2471
+ 830,
2472
+ 1
2473
+ ],
2474
+ [
2475
+ 870,
2476
+ 870,
2477
+ 1
2478
+ ],
2479
+ [
2480
+ 1083,
2481
+ 1083,
2482
+ 4
2483
+ ],
2484
+ [
2485
+ 874,
2486
+ 874,
2487
+ 2
2488
+ ],
2489
+ [
2490
+ 1032,
2491
+ 829,
2492
+ 4
2493
+ ],
2494
+ [
2495
+ 826,
2496
+ 826,
2497
+ 2
2498
+ ],
2499
+ [
2500
+ 982,
2501
+ 982,
2502
+ 1
2503
+ ],
2504
+ [
2505
+ 713,
2506
+ 713,
2507
+ 1
2508
+ ],
2509
+ [
2510
+ 869,
2511
+ 869,
2512
+ 1
2513
+ ],
2514
+ [
2515
+ 1029,
2516
+ 817,
2517
+ 2
2518
+ ],
2519
+ [
2520
+ 528,
2521
+ 1031,
2522
+ 1
2523
+ ],
2524
+ [
2525
+ 1042,
2526
+ 529,
2527
+ 2
2528
+ ],
2529
+ [
2530
+ 864,
2531
+ 827,
2532
+ 2
2533
+ ],
2534
+ [
2535
+ 819,
2536
+ 819,
2537
+ 2
2538
+ ],
2539
+ [
2540
+ 115,
2541
+ 116,
2542
+ 1
2543
+ ],
2544
+ [
2545
+ 127,
2546
+ 126,
2547
+ 1
2548
+ ],
2549
+ [
2550
+ 1079,
2551
+ 42,
2552
+ 2
2553
+ ],
2554
+ [
2555
+ 524,
2556
+ 1050,
2557
+ 1
2558
+ ],
2559
+ [
2560
+ 878,
2561
+ 835,
2562
+ 2
2563
+ ],
2564
+ [
2565
+ 45,
2566
+ 46,
2567
+ 1
2568
+ ],
2569
+ [
2570
+ 1082,
2571
+ 38,
2572
+ 2
2573
+ ],
2574
+ [
2575
+ 1080,
2576
+ 118,
2577
+ 2
2578
+ ],
2579
+ [
2580
+ 843,
2581
+ 843,
2582
+ 3
2583
+ ],
2584
+ [
2585
+ 871,
2586
+ 871,
2587
+ 2
2588
+ ],
2589
+ [
2590
+ 533,
2591
+ 532,
2592
+ 1
2593
+ ],
2594
+ [
2595
+ 839,
2596
+ 839,
2597
+ 2
2598
+ ],
2599
+ [
2600
+ 958,
2601
+ 958,
2602
+ 2
2603
+ ],
2604
+ [
2605
+ 522,
2606
+ 521,
2607
+ 1
2608
+ ],
2609
+ [
2610
+ 872,
2611
+ 872,
2612
+ 2
2613
+ ],
2614
+ [
2615
+ 1084,
2616
+ 36,
2617
+ 2
2618
+ ],
2619
+ [
2620
+ 838,
2621
+ 851,
2622
+ 2
2623
+ ],
2624
+ [
2625
+ 197,
2626
+ 196,
2627
+ 1
2628
+ ],
2629
+ [
2630
+ 1088,
2631
+ 124,
2632
+ 2
2633
+ ],
2634
+ [
2635
+ 1075,
2636
+ 205,
2637
+ 2
2638
+ ],
2639
+ [
2640
+ 971,
2641
+ 971,
2642
+ 1
2643
+ ],
2644
+ [
2645
+ 1093,
2646
+ 120,
2647
+ 2
2648
+ ],
2649
+ [
2650
+ 1035,
2651
+ 832,
2652
+ 4
2653
+ ],
2654
+ [
2655
+ 195,
2656
+ 195,
2657
+ 1
2658
+ ],
2659
+ [
2660
+ 973,
2661
+ 973,
2662
+ 1
2663
+ ],
2664
+ [
2665
+ 856,
2666
+ 1043,
2667
+ 1
2668
+ ],
2669
+ [
2670
+ 965,
2671
+ 965,
2672
+ 2
2673
+ ],
2674
+ [
2675
+ 915,
2676
+ 909,
2677
+ 1
2678
+ ],
2679
+ [
2680
+ 829,
2681
+ 829,
2682
+ 4
2683
+ ],
2684
+ [
2685
+ 533,
2686
+ 534,
2687
+ 1
2688
+ ],
2689
+ [
2690
+ 858,
2691
+ 827,
2692
+ 2
2693
+ ],
2694
+ [
2695
+ 1030,
2696
+ 1030,
2697
+ 4
2698
+ ],
2699
+ [
2700
+ 967,
2701
+ 967,
2702
+ 2
2703
+ ],
2704
+ [
2705
+ 1034,
2706
+ 527,
2707
+ 2
2708
+ ],
2709
+ [
2710
+ 930,
2711
+ 903,
2712
+ 1
2713
+ ],
2714
+ [
2715
+ 838,
2716
+ 855,
2717
+ 2
2718
+ ],
2719
+ [
2720
+ 854,
2721
+ 854,
2722
+ 3
2723
+ ],
2724
+ [
2725
+ 932,
2726
+ 900,
2727
+ 1
2728
+ ],
2729
+ [
2730
+ 1099,
2731
+ 122,
2732
+ 2
2733
+ ],
2734
+ [
2735
+ 972,
2736
+ 972,
2737
+ 2
2738
+ ],
2739
+ [
2740
+ 1089,
2741
+ 201,
2742
+ 2
2743
+ ],
2744
+ [
2745
+ 1038,
2746
+ 530,
2747
+ 2
2748
+ ],
2749
+ [
2750
+ 969,
2751
+ 969,
2752
+ 2
2753
+ ],
2754
+ [
2755
+ 1085,
2756
+ 41,
2757
+ 1
2758
+ ],
2759
+ [
2760
+ 845,
2761
+ 845,
2762
+ 3
2763
+ ],
2764
+ [
2765
+ 925,
2766
+ 925,
2767
+ 1
2768
+ ],
2769
+ [
2770
+ 974,
2771
+ 974,
2772
+ 2
2773
+ ],
2774
+ [
2775
+ 837,
2776
+ 857,
2777
+ 1
2778
+ ],
2779
+ [
2780
+ 912,
2781
+ 912,
2782
+ 2
2783
+ ],
2784
+ [
2785
+ 901,
2786
+ 901,
2787
+ 2
2788
+ ],
2789
+ [
2790
+ 853,
2791
+ 853,
2792
+ 1
2793
+ ],
2794
+ [
2795
+ 928,
2796
+ 928,
2797
+ 1
2798
+ ],
2799
+ [
2800
+ 841,
2801
+ 826,
2802
+ 1
2803
+ ],
2804
+ [
2805
+ 206,
2806
+ 1109,
2807
+ 1
2808
+ ],
2809
+ [
2810
+ 1100,
2811
+ 44,
2812
+ 2
2813
+ ],
2814
+ [
2815
+ 117,
2816
+ 1126,
2817
+ 1
2818
+ ],
2819
+ [
2820
+ 840,
2821
+ 825,
2822
+ 1
2823
+ ],
2824
+ [
2825
+ 856,
2826
+ 1122,
2827
+ 1
2828
+ ],
2829
+ [
2830
+ 840,
2831
+ 848,
2832
+ 1
2833
+ ],
2834
+ [
2835
+ 1085,
2836
+ 39,
2837
+ 1
2838
+ ],
2839
+ [
2840
+ 1057,
2841
+ 1057,
2842
+ 2
2843
+ ],
2844
+ [
2845
+ 125,
2846
+ 1128,
2847
+ 1
2848
+ ],
2849
+ [
2850
+ 537,
2851
+ 537,
2852
+ 1
2853
+ ],
2854
+ [
2855
+ 868,
2856
+ 834,
2857
+ 1
2858
+ ],
2859
+ [
2860
+ 1045,
2861
+ 847,
2862
+ 4
2863
+ ],
2864
+ [
2865
+ 1058,
2866
+ 1058,
2867
+ 2
2868
+ ],
2869
+ [
2870
+ 931,
2871
+ 931,
2872
+ 1
2873
+ ],
2874
+ [
2875
+ 983,
2876
+ 983,
2877
+ 2
2878
+ ],
2879
+ [
2880
+ 532,
2881
+ 1039,
2882
+ 1
2883
+ ],
2884
+ [
2885
+ 841,
2886
+ 849,
2887
+ 1
2888
+ ],
2889
+ [
2890
+ 1034,
2891
+ 525,
2892
+ 2
2893
+ ],
2894
+ [
2895
+ 197,
2896
+ 1051,
2897
+ 1
2898
+ ],
2899
+ [
2900
+ 530,
2901
+ 530,
2902
+ 2
2903
+ ],
2904
+ [
2905
+ 833,
2906
+ 833,
2907
+ 5
2908
+ ],
2909
+ [
2910
+ 1024,
2911
+ 824,
2912
+ 3
2913
+ ],
2914
+ [
2915
+ 934,
2916
+ 934,
2917
+ 1
2918
+ ],
2919
+ [
2920
+ 979,
2921
+ 979,
2922
+ 2
2923
+ ],
2924
+ [
2925
+ 1025,
2926
+ 1062,
2927
+ 4
2928
+ ],
2929
+ [
2930
+ 939,
2931
+ 939,
2932
+ 1
2933
+ ],
2934
+ [
2935
+ 329,
2936
+ 329,
2937
+ 1
2938
+ ],
2939
+ [
2940
+ 836,
2941
+ 836,
2942
+ 5
2943
+ ],
2944
+ [
2945
+ 1106,
2946
+ 203,
2947
+ 2
2948
+ ],
2949
+ [
2950
+ 941,
2951
+ 941,
2952
+ 1
2953
+ ],
2954
+ [
2955
+ 987,
2956
+ 987,
2957
+ 2
2958
+ ],
2959
+ [
2960
+ 890,
2961
+ 890,
2962
+ 2
2963
+ ],
2964
+ [
2965
+ 889,
2966
+ 879,
2967
+ 1
2968
+ ],
2969
+ [
2970
+ 876,
2971
+ 876,
2972
+ 1
2973
+ ],
2974
+ [
2975
+ 1040,
2976
+ 862,
2977
+ 2
2978
+ ],
2979
+ [
2980
+ 728,
2981
+ 727,
2982
+ 1
2983
+ ],
2984
+ [
2985
+ 978,
2986
+ 978,
2987
+ 2
2988
+ ],
2989
+ [
2990
+ 32,
2991
+ 32,
2992
+ 1
2993
+ ],
2994
+ [
2995
+ 1036,
2996
+ 528,
2997
+ 2
2998
+ ],
2999
+ [
3000
+ 818,
3001
+ 818,
3002
+ 3
3003
+ ],
3004
+ [
3005
+ 208,
3006
+ 209,
3007
+ 1
3008
+ ],
3009
+ [
3010
+ 1090,
3011
+ 1090,
3012
+ 2
3013
+ ],
3014
+ [
3015
+ 922,
3016
+ 922,
3017
+ 1
3018
+ ],
3019
+ [
3020
+ 921,
3021
+ 921,
3022
+ 1
3023
+ ],
3024
+ [
3025
+ 850,
3026
+ 850,
3027
+ 2
3028
+ ],
3029
+ [
3030
+ 1041,
3031
+ 863,
3032
+ 2
3033
+ ],
3034
+ [
3035
+ 946,
3036
+ 946,
3037
+ 1
3038
+ ],
3039
+ [
3040
+ 999,
3041
+ 999,
3042
+ 1
3043
+ ],
3044
+ [
3045
+ 1036,
3046
+ 527,
3047
+ 2
3048
+ ],
3049
+ [
3050
+ 917,
3051
+ 917,
3052
+ 2
3053
+ ],
3054
+ [
3055
+ 39,
3056
+ 40,
3057
+ 1
3058
+ ],
3059
+ [
3060
+ 41,
3061
+ 40,
3062
+ 1
3063
+ ],
3064
+ [
3065
+ 520,
3066
+ 521,
3067
+ 1
3068
+ ],
3069
+ [
3070
+ 851,
3071
+ 851,
3072
+ 3
3073
+ ],
3074
+ [
3075
+ 725,
3076
+ 724,
3077
+ 1
3078
+ ],
3079
+ [
3080
+ 1034,
3081
+ 526,
3082
+ 2
3083
+ ],
3084
+ [
3085
+ 713,
3086
+ 714,
3087
+ 1
3088
+ ],
3089
+ [
3090
+ 210,
3091
+ 210,
3092
+ 1
3093
+ ],
3094
+ [
3095
+ 1104,
3096
+ 718,
3097
+ 2
3098
+ ],
3099
+ [
3100
+ 847,
3101
+ 887,
3102
+ 2
3103
+ ],
3104
+ [
3105
+ 1048,
3106
+ 834,
3107
+ 2
3108
+ ],
3109
+ [
3110
+ 868,
3111
+ 837,
3112
+ 1
3113
+ ],
3114
+ [
3115
+ 1040,
3116
+ 848,
3117
+ 2
3118
+ ],
3119
+ [
3120
+ 857,
3121
+ 1127,
3122
+ 1
3123
+ ],
3124
+ [
3125
+ 858,
3126
+ 873,
3127
+ 2
3128
+ ],
3129
+ [
3130
+ 200,
3131
+ 1103,
3132
+ 1
3133
+ ],
3134
+ [
3135
+ 895,
3136
+ 895,
3137
+ 2
3138
+ ],
3139
+ [
3140
+ 953,
3141
+ 953,
3142
+ 1
3143
+ ],
3144
+ [
3145
+ 875,
3146
+ 1077,
3147
+ 1
3148
+ ],
3149
+ [
3150
+ 957,
3151
+ 957,
3152
+ 1
3153
+ ],
3154
+ [
3155
+ 1041,
3156
+ 849,
3157
+ 2
3158
+ ],
3159
+ [
3160
+ 855,
3161
+ 838,
3162
+ 2
3163
+ ],
3164
+ [
3165
+ 530,
3166
+ 1039,
3167
+ 1
3168
+ ],
3169
+ [
3170
+ 853,
3171
+ 831,
3172
+ 2
3173
+ ],
3174
+ [
3175
+ 524,
3176
+ 526,
3177
+ 1
3178
+ ],
3179
+ [
3180
+ 830,
3181
+ 830,
3182
+ 3
3183
+ ],
3184
+ [
3185
+ 956,
3186
+ 956,
3187
+ 1
3188
+ ],
3189
+ [
3190
+ 842,
3191
+ 842,
3192
+ 2
3193
+ ],
3194
+ [
3195
+ 48,
3196
+ 48,
3197
+ 1
3198
+ ],
3199
+ [
3200
+ 877,
3201
+ 1081,
3202
+ 1
3203
+ ],
3204
+ [
3205
+ 888,
3206
+ 888,
3207
+ 2
3208
+ ],
3209
+ [
3210
+ 952,
3211
+ 952,
3212
+ 1
3213
+ ],
3214
+ [
3215
+ 827,
3216
+ 827,
3217
+ 3
3218
+ ],
3219
+ [
3220
+ 533,
3221
+ 535,
3222
+ 1
3223
+ ],
3224
+ [
3225
+ 1038,
3226
+ 529,
3227
+ 2
3228
+ ],
3229
+ [
3230
+ 846,
3231
+ 829,
3232
+ 2
3233
+ ],
3234
+ [
3235
+ 885,
3236
+ 885,
3237
+ 1
3238
+ ],
3239
+ [
3240
+ 1073,
3241
+ 861,
3242
+ 4
3243
+ ],
3244
+ [
3245
+ 886,
3246
+ 886,
3247
+ 2
3248
+ ],
3249
+ [
3250
+ 847,
3251
+ 897,
3252
+ 2
3253
+ ],
3254
+ [
3255
+ 1071,
3256
+ 1071,
3257
+ 2
3258
+ ],
3259
+ [
3260
+ 716,
3261
+ 717,
3262
+ 1
3263
+ ],
3264
+ [
3265
+ 1107,
3266
+ 723,
3267
+ 2
3268
+ ],
3269
+ [
3270
+ 896,
3271
+ 896,
3272
+ 2
3273
+ ],
3274
+ [
3275
+ 882,
3276
+ 882,
3277
+ 1
3278
+ ],
3279
+ [
3280
+ 538,
3281
+ 538,
3282
+ 1
3283
+ ],
3284
+ [
3285
+ 852,
3286
+ 831,
3287
+ 2
3288
+ ],
3289
+ [
3290
+ 532,
3291
+ 1046,
3292
+ 1
3293
+ ],
3294
+ [
3295
+ 891,
3296
+ 891,
3297
+ 2
3298
+ ],
3299
+ [
3300
+ 1026,
3301
+ 828,
3302
+ 3
3303
+ ],
3304
+ [
3305
+ 916,
3306
+ 980,
3307
+ 1
3308
+ ],
3309
+ [
3310
+ 857,
3311
+ 1047,
3312
+ 1
3313
+ ],
3314
+ [
3315
+ 33,
3316
+ 34,
3317
+ 1
3318
+ ],
3319
+ [
3320
+ 909,
3321
+ 915,
3322
+ 1
3323
+ ],
3324
+ [
3325
+ 862,
3326
+ 848,
3327
+ 1
3328
+ ],
3329
+ [
3330
+ 1072,
3331
+ 1072,
3332
+ 2
3333
+ ],
3334
+ [
3335
+ 883,
3336
+ 883,
3337
+ 1
3338
+ ],
3339
+ [
3340
+ 918,
3341
+ 982,
3342
+ 1
3343
+ ],
3344
+ [
3345
+ 1044,
3346
+ 531,
3347
+ 2
3348
+ ],
3349
+ [
3350
+ 1052,
3351
+ 1052,
3352
+ 2
3353
+ ],
3354
+ [
3355
+ 908,
3356
+ 835,
3357
+ 2
3358
+ ],
3359
+ [
3360
+ 913,
3361
+ 198,
3362
+ 1
3363
+ ],
3364
+ [
3365
+ 713,
3366
+ 712,
3367
+ 1
3368
+ ],
3369
+ [
3370
+ 1000,
3371
+ 1000,
3372
+ 1
3373
+ ],
3374
+ [
3375
+ 863,
3376
+ 849,
3377
+ 1
3378
+ ],
3379
+ [
3380
+ 846,
3381
+ 832,
3382
+ 2
3383
+ ],
3384
+ [
3385
+ 930,
3386
+ 971,
3387
+ 1
3388
+ ],
3389
+ [
3390
+ 1085,
3391
+ 40,
3392
+ 1
3393
+ ],
3394
+ [
3395
+ 836,
3396
+ 838,
3397
+ 1
3398
+ ],
3399
+ [
3400
+ 37,
3401
+ 1084,
3402
+ 1
3403
+ ],
3404
+ [
3405
+ 1048,
3406
+ 880,
3407
+ 2
3408
+ ],
3409
+ [
3410
+ 868,
3411
+ 868,
3412
+ 1
3413
+ ],
3414
+ [
3415
+ 932,
3416
+ 973,
3417
+ 1
3418
+ ],
3419
+ [
3420
+ 530,
3421
+ 1042,
3422
+ 1
3423
+ ],
3424
+ [
3425
+ 534,
3426
+ 1046,
3427
+ 1
3428
+ ],
3429
+ [
3430
+ 1036,
3431
+ 529,
3432
+ 2
3433
+ ],
3434
+ [
3435
+ 892,
3436
+ 892,
3437
+ 2
3438
+ ],
3439
+ [
3440
+ 878,
3441
+ 897,
3442
+ 2
3443
+ ],
3444
+ [
3445
+ 858,
3446
+ 874,
3447
+ 2
3448
+ ],
3449
+ [
3450
+ 818,
3451
+ 835,
3452
+ 2
3453
+ ],
3454
+ [
3455
+ 129,
3456
+ 129,
3457
+ 1
3458
+ ],
3459
+ [
3460
+ 894,
3461
+ 894,
3462
+ 1
3463
+ ],
3464
+ [
3465
+ 1076,
3466
+ 1076,
3467
+ 2
3468
+ ],
3469
+ [
3470
+ 893,
3471
+ 893,
3472
+ 2
3473
+ ],
3474
+ [
3475
+ 852,
3476
+ 832,
3477
+ 2
3478
+ ],
3479
+ [
3480
+ 113,
3481
+ 113,
3482
+ 1
3483
+ ],
3484
+ [
3485
+ 727,
3486
+ 727,
3487
+ 1
3488
+ ],
3489
+ [
3490
+ 966,
3491
+ 966,
3492
+ 1
3493
+ ],
3494
+ [
3495
+ 1013,
3496
+ 1013,
3497
+ 1
3498
+ ],
3499
+ [
3500
+ 838,
3501
+ 836,
3502
+ 1
3503
+ ],
3504
+ [
3505
+ 843,
3506
+ 833,
3507
+ 2
3508
+ ],
3509
+ [
3510
+ 833,
3511
+ 843,
3512
+ 1
3513
+ ],
3514
+ [
3515
+ 825,
3516
+ 825,
3517
+ 3
3518
+ ],
3519
+ [
3520
+ 1021,
3521
+ 1021,
3522
+ 1
3523
+ ],
3524
+ [
3525
+ 1038,
3526
+ 531,
3527
+ 2
3528
+ ],
3529
+ [
3530
+ 332,
3531
+ 332,
3532
+ 1
3533
+ ],
3534
+ [
3535
+ 854,
3536
+ 854,
3537
+ 4
3538
+ ],
3539
+ [
3540
+ 47,
3541
+ 46,
3542
+ 1
3543
+ ],
3544
+ [
3545
+ 527,
3546
+ 525,
3547
+ 1
3548
+ ],
3549
+ [
3550
+ 202,
3551
+ 1089,
3552
+ 1
3553
+ ],
3554
+ [
3555
+ 1035,
3556
+ 1035,
3557
+ 4
3558
+ ],
3559
+ [
3560
+ 855,
3561
+ 855,
3562
+ 3
3563
+ ],
3564
+ [
3565
+ 344,
3566
+ 344,
3567
+ 1
3568
+ ],
3569
+ [
3570
+ 826,
3571
+ 826,
3572
+ 3
3573
+ ],
3574
+ [
3575
+ 43,
3576
+ 1079,
3577
+ 1
3578
+ ],
3579
+ [
3580
+ 898,
3581
+ 898,
3582
+ 1
3583
+ ],
3584
+ [
3585
+ 907,
3586
+ 834,
3587
+ 1
3588
+ ],
3589
+ [
3590
+ 914,
3591
+ 914,
3592
+ 2
3593
+ ],
3594
+ [
3595
+ 521,
3596
+ 521,
3597
+ 1
3598
+ ],
3599
+ [
3600
+ 926,
3601
+ 926,
3602
+ 2
3603
+ ],
3604
+ [
3605
+ 528,
3606
+ 1042,
3607
+ 1
3608
+ ],
3609
+ [
3610
+ 1032,
3611
+ 1032,
3612
+ 4
3613
+ ],
3614
+ [
3615
+ 836,
3616
+ 845,
3617
+ 1
3618
+ ],
3619
+ [
3620
+ 920,
3621
+ 920,
3622
+ 1
3623
+ ],
3624
+ [
3625
+ 843,
3626
+ 833,
3627
+ 1
3628
+ ],
3629
+ [
3630
+ 837,
3631
+ 868,
3632
+ 1
3633
+ ],
3634
+ [
3635
+ 1040,
3636
+ 840,
3637
+ 2
3638
+ ],
3639
+ [
3640
+ 1091,
3641
+ 1091,
3642
+ 2
3643
+ ],
3644
+ [
3645
+ 960,
3646
+ 960,
3647
+ 1
3648
+ ],
3649
+ [
3650
+ 208,
3651
+ 1109,
3652
+ 1
3653
+ ],
3654
+ [
3655
+ 959,
3656
+ 959,
3657
+ 1
3658
+ ],
3659
+ [
3660
+ 199,
3661
+ 200,
3662
+ 1
3663
+ ],
3664
+ [
3665
+ 115,
3666
+ 114,
3667
+ 1
3668
+ ],
3669
+ [
3670
+ 716,
3671
+ 715,
3672
+ 1
3673
+ ],
3674
+ [
3675
+ 879,
3676
+ 879,
3677
+ 2
3678
+ ],
3679
+ [
3680
+ 899,
3681
+ 899,
3682
+ 1
3683
+ ],
3684
+ [
3685
+ 206,
3686
+ 1075,
3687
+ 1
3688
+ ],
3689
+ [
3690
+ 985,
3691
+ 985,
3692
+ 1
3693
+ ],
3694
+ [
3695
+ 861,
3696
+ 829,
3697
+ 2
3698
+ ],
3699
+ [
3700
+ 948,
3701
+ 948,
3702
+ 1
3703
+ ],
3704
+ [
3705
+ 718,
3706
+ 717,
3707
+ 1
3708
+ ],
3709
+ [
3710
+ 127,
3711
+ 128,
3712
+ 1
3713
+ ],
3714
+ [
3715
+ 875,
3716
+ 848,
3717
+ 1
3718
+ ],
3719
+ [
3720
+ 724,
3721
+ 931,
3722
+ 1
3723
+ ],
3724
+ [
3725
+ 1085,
3726
+ 1085,
3727
+ 1
3728
+ ],
3729
+ [
3730
+ 845,
3731
+ 836,
3732
+ 1
3733
+ ],
3734
+ [
3735
+ 1041,
3736
+ 841,
3737
+ 2
3738
+ ],
3739
+ [
3740
+ 845,
3741
+ 836,
3742
+ 2
3743
+ ],
3744
+ [
3745
+ 1037,
3746
+ 1037,
3747
+ 4
3748
+ ],
3749
+ [
3750
+ 929,
3751
+ 929,
3752
+ 1
3753
+ ],
3754
+ [
3755
+ 904,
3756
+ 904,
3757
+ 1
3758
+ ],
3759
+ [
3760
+ 877,
3761
+ 849,
3762
+ 1
3763
+ ],
3764
+ [
3765
+ 37,
3766
+ 1082,
3767
+ 1
3768
+ ],
3769
+ [
3770
+ 119,
3771
+ 1080,
3772
+ 1
3773
+ ],
3774
+ [
3775
+ 831,
3776
+ 831,
3777
+ 5
3778
+ ],
3779
+ [
3780
+ 905,
3781
+ 905,
3782
+ 1
3783
+ ],
3784
+ [
3785
+ 838,
3786
+ 855,
3787
+ 1
3788
+ ],
3789
+ [
3790
+ 910,
3791
+ 910,
3792
+ 1
3793
+ ],
3794
+ [
3795
+ 951,
3796
+ 951,
3797
+ 1
3798
+ ],
3799
+ [
3800
+ 1050,
3801
+ 525,
3802
+ 2
3803
+ ],
3804
+ [
3805
+ 827,
3806
+ 864,
3807
+ 2
3808
+ ],
3809
+ [
3810
+ 824,
3811
+ 909,
3812
+ 1
3813
+ ],
3814
+ [
3815
+ 837,
3816
+ 823,
3817
+ 1
3818
+ ],
3819
+ [
3820
+ 1133,
3821
+ 721,
3822
+ 2
3823
+ ],
3824
+ [
3825
+ 1097,
3826
+ 1097,
3827
+ 2
3828
+ ],
3829
+ [
3830
+ 329,
3831
+ 328,
3832
+ 1
3833
+ ],
3834
+ [
3835
+ 819,
3836
+ 819,
3837
+ 1
3838
+ ],
3839
+ [
3840
+ 204,
3841
+ 1106,
3842
+ 1
3843
+ ],
3844
+ [
3845
+ 723,
3846
+ 724,
3847
+ 1
3848
+ ],
3849
+ [
3850
+ 835,
3851
+ 847,
3852
+ 2
3853
+ ],
3854
+ [
3855
+ 861,
3856
+ 887,
3857
+ 2
3858
+ ],
3859
+ [
3860
+ 195,
3861
+ 196,
3862
+ 1
3863
+ ],
3864
+ [
3865
+ 838,
3866
+ 851,
3867
+ 1
3868
+ ],
3869
+ [
3870
+ 834,
3871
+ 868,
3872
+ 1
3873
+ ],
3874
+ [
3875
+ 348,
3876
+ 348,
3877
+ 1
3878
+ ],
3879
+ [
3880
+ 1113,
3881
+ 1113,
3882
+ 2
3883
+ ],
3884
+ [
3885
+ 911,
3886
+ 911,
3887
+ 2
3888
+ ],
3889
+ [
3890
+ 902,
3891
+ 902,
3892
+ 2
3893
+ ],
3894
+ [
3895
+ 1116,
3896
+ 1116,
3897
+ 2
3898
+ ],
3899
+ [
3900
+ 1115,
3901
+ 1115,
3902
+ 2
3903
+ ],
3904
+ [
3905
+ 117,
3906
+ 1080,
3907
+ 1
3908
+ ],
3909
+ [
3910
+ 534,
3911
+ 532,
3912
+ 1
3913
+ ],
3914
+ [
3915
+ 838,
3916
+ 833,
3917
+ 1
3918
+ ],
3919
+ [
3920
+ 529,
3921
+ 529,
3922
+ 2
3923
+ ],
3924
+ [
3925
+ 923,
3926
+ 923,
3927
+ 2
3928
+ ],
3929
+ [
3930
+ 35,
3931
+ 1175,
3932
+ 1
3933
+ ],
3934
+ [
3935
+ 1052,
3936
+ 881,
3937
+ 2
3938
+ ],
3939
+ [
3940
+ 1069,
3941
+ 1069,
3942
+ 2
3943
+ ],
3944
+ [
3945
+ 728,
3946
+ 729,
3947
+ 1
3948
+ ],
3949
+ [
3950
+ 872,
3951
+ 928,
3952
+ 1
3953
+ ],
3954
+ [
3955
+ 889,
3956
+ 956,
3957
+ 1
3958
+ ],
3959
+ [
3960
+ 1044,
3961
+ 532,
3962
+ 2
3963
+ ],
3964
+ [
3965
+ 818,
3966
+ 829,
3967
+ 2
3968
+ ],
3969
+ [
3970
+ 121,
3971
+ 1093,
3972
+ 1
3973
+ ],
3974
+ [
3975
+ 125,
3976
+ 1088,
3977
+ 1
3978
+ ],
3979
+ [
3980
+ 123,
3981
+ 1088,
3982
+ 1
3983
+ ],
3984
+ [
3985
+ 45,
3986
+ 1100,
3987
+ 1
3988
+ ],
3989
+ [
3990
+ 39,
3991
+ 1082,
3992
+ 1
3993
+ ],
3994
+ [
3995
+ 878,
3996
+ 945,
3997
+ 2
3998
+ ],
3999
+ [
4000
+ 824,
4001
+ 924,
4002
+ 1
4003
+ ],
4004
+ [
4005
+ 927,
4006
+ 927,
4007
+ 2
4008
+ ],
4009
+ [
4010
+ 906,
4011
+ 906,
4012
+ 2
4013
+ ],
4014
+ [
4015
+ 886,
4016
+ 869,
4017
+ 1
4018
+ ],
4019
+ [
4020
+ 1070,
4021
+ 205,
4022
+ 2
4023
+ ],
4024
+ [
4025
+ 719,
4026
+ 720,
4027
+ 1
4028
+ ],
4029
+ [
4030
+ 871,
4031
+ 931,
4032
+ 1
4033
+ ],
4034
+ [
4035
+ 43,
4036
+ 1100,
4037
+ 1
4038
+ ],
4039
+ [
4040
+ 818,
4041
+ 831,
4042
+ 2
4043
+ ],
4044
+ [
4045
+ 871,
4046
+ 833,
4047
+ 1
4048
+ ],
4049
+ [
4050
+ 1112,
4051
+ 525,
4052
+ 2
4053
+ ],
4054
+ [
4055
+ 1123,
4056
+ 198,
4057
+ 2
4058
+ ],
4059
+ [
4060
+ 851,
4061
+ 838,
4062
+ 2
4063
+ ],
4064
+ [
4065
+ 1048,
4066
+ 1048,
4067
+ 2
4068
+ ],
4069
+ [
4070
+ 31,
4071
+ 31,
4072
+ 1
4073
+ ],
4074
+ [
4075
+ 968,
4076
+ 924,
4077
+ 1
4078
+ ],
4079
+ [
4080
+ 121,
4081
+ 1099,
4082
+ 1
4083
+ ],
4084
+ [
4085
+ 843,
4086
+ 864,
4087
+ 2
4088
+ ],
4089
+ [
4090
+ 914,
4091
+ 829,
4092
+ 2
4093
+ ],
4094
+ [
4095
+ 1083,
4096
+ 816,
4097
+ 4
4098
+ ],
4099
+ [
4100
+ 1051,
4101
+ 1051,
4102
+ 2
4103
+ ],
4104
+ [
4105
+ 524,
4106
+ 1163,
4107
+ 1
4108
+ ],
4109
+ [
4110
+ 726,
4111
+ 724,
4112
+ 1
4113
+ ],
4114
+ [
4115
+ 1033,
4116
+ 1026,
4117
+ 4
4118
+ ],
4119
+ [
4120
+ 119,
4121
+ 1093,
4122
+ 1
4123
+ ],
4124
+ [
4125
+ 206,
4126
+ 1000,
4127
+ 1
4128
+ ],
4129
+ [
4130
+ 890,
4131
+ 867,
4132
+ 1
4133
+ ],
4134
+ [
4135
+ 711,
4136
+ 712,
4137
+ 1
4138
+ ],
4139
+ [
4140
+ 1063,
4141
+ 853,
4142
+ 4
4143
+ ],
4144
+ [
4145
+ 1064,
4146
+ 825,
4147
+ 2
4148
+ ],
4149
+ [
4150
+ 538,
4151
+ 537,
4152
+ 1
4153
+ ],
4154
+ [
4155
+ 827,
4156
+ 833,
4157
+ 1
4158
+ ],
4159
+ [
4160
+ 533,
4161
+ 1096,
4162
+ 1
4163
+ ],
4164
+ [
4165
+ 520,
4166
+ 519,
4167
+ 1
4168
+ ],
4169
+ [
4170
+ 941,
4171
+ 198,
4172
+ 1
4173
+ ],
4174
+ [
4175
+ 127,
4176
+ 1128,
4177
+ 1
4178
+ ],
4179
+ [
4180
+ 936,
4181
+ 936,
4182
+ 2
4183
+ ],
4184
+ [
4185
+ 820,
4186
+ 825,
4187
+ 1
4188
+ ],
4189
+ [
4190
+ 820,
4191
+ 826,
4192
+ 1
4193
+ ],
4194
+ [
4195
+ 823,
4196
+ 981,
4197
+ 1
4198
+ ],
4199
+ [
4200
+ 1034,
4201
+ 1034,
4202
+ 2
4203
+ ],
4204
+ [
4205
+ 1066,
4206
+ 826,
4207
+ 2
4208
+ ],
4209
+ [
4210
+ 835,
4211
+ 853,
4212
+ 2
4213
+ ],
4214
+ [
4215
+ 115,
4216
+ 1126,
4217
+ 1
4218
+ ],
4219
+ [
4220
+ 856,
4221
+ 1017,
4222
+ 1
4223
+ ],
4224
+ [
4225
+ 123,
4226
+ 1099,
4227
+ 1
4228
+ ],
4229
+ [
4230
+ 845,
4231
+ 864,
4232
+ 2
4233
+ ],
4234
+ [
4235
+ 194,
4236
+ 194,
4237
+ 1
4238
+ ],
4239
+ [
4240
+ 833,
4241
+ 827,
4242
+ 1
4243
+ ],
4244
+ [
4245
+ 919,
4246
+ 919,
4247
+ 1
4248
+ ],
4249
+ [
4250
+ 856,
4251
+ 907,
4252
+ 1
4253
+ ],
4254
+ [
4255
+ 869,
4256
+ 886,
4257
+ 1
4258
+ ],
4259
+ [
4260
+ 872,
4261
+ 836,
4262
+ 1
4263
+ ],
4264
+ [
4265
+ 870,
4266
+ 870,
4267
+ 3
4268
+ ],
4269
+ [
4270
+ 858,
4271
+ 873,
4272
+ 1
4273
+ ],
4274
+ [
4275
+ 831,
4276
+ 832,
4277
+ 1
4278
+ ],
4279
+ [
4280
+ 1061,
4281
+ 852,
4282
+ 4
4283
+ ],
4284
+ [
4285
+ 832,
4286
+ 831,
4287
+ 1
4288
+ ],
4289
+ [
4290
+ 1033,
4291
+ 828,
4292
+ 4
4293
+ ],
4294
+ [
4295
+ 534,
4296
+ 1157,
4297
+ 1
4298
+ ],
4299
+ [
4300
+ 989,
4301
+ 989,
4302
+ 1
4303
+ ],
4304
+ [
4305
+ 35,
4306
+ 1084,
4307
+ 1
4308
+ ],
4309
+ [
4310
+ 41,
4311
+ 1079,
4312
+ 1
4313
+ ],
4314
+ [
4315
+ 523,
4316
+ 525,
4317
+ 1
4318
+ ],
4319
+ [
4320
+ 990,
4321
+ 990,
4322
+ 1
4323
+ ],
4324
+ [
4325
+ 1045,
4326
+ 1045,
4327
+ 4
4328
+ ],
4329
+ [
4330
+ 895,
4331
+ 867,
4332
+ 1
4333
+ ],
4334
+ [
4335
+ 884,
4336
+ 884,
4337
+ 2
4338
+ ],
4339
+ [
4340
+ 1036,
4341
+ 1036,
4342
+ 2
4343
+ ],
4344
+ [
4345
+ 204,
4346
+ 1075,
4347
+ 1
4348
+ ],
4349
+ [
4350
+ 858,
4351
+ 874,
4352
+ 1
4353
+ ],
4354
+ [
4355
+ 716,
4356
+ 714,
4357
+ 1
4358
+ ],
4359
+ [
4360
+ 916,
4361
+ 1122,
4362
+ 1
4363
+ ],
4364
+ [
4365
+ 871,
4366
+ 888,
4367
+ 1
4368
+ ],
4369
+ [
4370
+ 824,
4371
+ 854,
4372
+ 1
4373
+ ],
4374
+ [
4375
+ 1060,
4376
+ 846,
4377
+ 4
4378
+ ],
4379
+ [
4380
+ 835,
4381
+ 835,
4382
+ 4
4383
+ ],
4384
+ [
4385
+ 210,
4386
+ 209,
4387
+ 1
4388
+ ],
4389
+ [
4390
+ 717,
4391
+ 1119,
4392
+ 1
4393
+ ],
4394
+ [
4395
+ 872,
4396
+ 891,
4397
+ 1
4398
+ ],
4399
+ [
4400
+ 912,
4401
+ 912,
4402
+ 1
4403
+ ],
4404
+ [
4405
+ 1094,
4406
+ 1094,
4407
+ 2
4408
+ ],
4409
+ [
4410
+ 851,
4411
+ 838,
4412
+ 1
4413
+ ],
4414
+ [
4415
+ 824,
4416
+ 938,
4417
+ 1
4418
+ ],
4419
+ [
4420
+ 33,
4421
+ 32,
4422
+ 1
4423
+ ],
4424
+ [
4425
+ 843,
4426
+ 827,
4427
+ 1
4428
+ ],
4429
+ [
4430
+ 538,
4431
+ 539,
4432
+ 1
4433
+ ],
4434
+ [
4435
+ 1067,
4436
+ 1067,
4437
+ 2
4438
+ ],
4439
+ [
4440
+ 824,
4441
+ 935,
4442
+ 1
4443
+ ],
4444
+ [
4445
+ 913,
4446
+ 953,
4447
+ 1
4448
+ ],
4449
+ [
4450
+ 925,
4451
+ 873,
4452
+ 1
4453
+ ],
4454
+ [
4455
+ 839,
4456
+ 839,
4457
+ 1
4458
+ ],
4459
+ [
4460
+ 49,
4461
+ 49,
4462
+ 1
4463
+ ],
4464
+ [
4465
+ 824,
4466
+ 937,
4467
+ 1
4468
+ ],
4469
+ [
4470
+ 879,
4471
+ 889,
4472
+ 1
4473
+ ],
4474
+ [
4475
+ 829,
4476
+ 847,
4477
+ 2
4478
+ ],
4479
+ [
4480
+ 827,
4481
+ 827,
4482
+ 1
4483
+ ],
4484
+ [
4485
+ 533,
4486
+ 531,
4487
+ 1
4488
+ ],
4489
+ [
4490
+ 832,
4491
+ 846,
4492
+ 2
4493
+ ],
4494
+ [
4495
+ 824,
4496
+ 940,
4497
+ 1
4498
+ ],
4499
+ [
4500
+ 918,
4501
+ 1127,
4502
+ 1
4503
+ ],
4504
+ [
4505
+ 867,
4506
+ 890,
4507
+ 1
4508
+ ],
4509
+ [
4510
+ 969,
4511
+ 887,
4512
+ 2
4513
+ ],
4514
+ [
4515
+ 840,
4516
+ 862,
4517
+ 1
4518
+ ],
4519
+ [
4520
+ 841,
4521
+ 863,
4522
+ 1
4523
+ ],
4524
+ [
4525
+ 949,
4526
+ 887,
4527
+ 2
4528
+ ],
4529
+ [
4530
+ 1040,
4531
+ 1040,
4532
+ 2
4533
+ ],
4534
+ [
4535
+ 824,
4536
+ 915,
4537
+ 1
4538
+ ],
4539
+ [
4540
+ 831,
4541
+ 853,
4542
+ 2
4543
+ ],
4544
+ [
4545
+ 1070,
4546
+ 207,
4547
+ 2
4548
+ ],
4549
+ [
4550
+ 210,
4551
+ 211,
4552
+ 1
4553
+ ],
4554
+ [
4555
+ 934,
4556
+ 874,
4557
+ 1
4558
+ ],
4559
+ [
4560
+ 851,
4561
+ 836,
4562
+ 1
4563
+ ],
4564
+ [
4565
+ 1057,
4566
+ 116,
4567
+ 2
4568
+ ],
4569
+ [
4570
+ 875,
4571
+ 1178,
4572
+ 1
4573
+ ],
4574
+ [
4575
+ 206,
4576
+ 946,
4577
+ 1
4578
+ ],
4579
+ [
4580
+ 1013,
4581
+ 36,
4582
+ 1
4583
+ ],
4584
+ [
4585
+ 117,
4586
+ 943,
4587
+ 1
4588
+ ],
4589
+ [
4590
+ 1069,
4591
+ 200,
4592
+ 2
4593
+ ],
4594
+ [
4595
+ 999,
4596
+ 44,
4597
+ 1
4598
+ ],
4599
+ [
4600
+ 827,
4601
+ 843,
4602
+ 1
4603
+ ],
4604
+ [
4605
+ 833,
4606
+ 838,
4607
+ 1
4608
+ ],
4609
+ [
4610
+ 955,
4611
+ 955,
4612
+ 2
4613
+ ],
4614
+ [
4615
+ 1058,
4616
+ 126,
4617
+ 2
4618
+ ],
4619
+ [
4620
+ 1121,
4621
+ 1121,
4622
+ 2
4623
+ ],
4624
+ [
4625
+ 718,
4626
+ 719,
4627
+ 1
4628
+ ],
4629
+ [
4630
+ 984,
4631
+ 950,
4632
+ 1
4633
+ ],
4634
+ [
4635
+ 877,
4636
+ 1179,
4637
+ 1
4638
+ ],
4639
+ [
4640
+ 939,
4641
+ 533,
4642
+ 1
4643
+ ],
4644
+ [
4645
+ 327,
4646
+ 328,
4647
+ 1
4648
+ ],
4649
+ [
4650
+ 1021,
4651
+ 971,
4652
+ 1
4653
+ ],
4654
+ [
4655
+ 908,
4656
+ 837,
4657
+ 1
4658
+ ],
4659
+ [
4660
+ 1052,
4661
+ 837,
4662
+ 2
4663
+ ],
4664
+ [
4665
+ 201,
4666
+ 200,
4667
+ 1
4668
+ ],
4669
+ [
4670
+ 867,
4671
+ 895,
4672
+ 1
4673
+ ],
4674
+ [
4675
+ 855,
4676
+ 838,
4677
+ 1
4678
+ ],
4679
+ [
4680
+ 830,
4681
+ 830,
4682
+ 4
4683
+ ],
4684
+ [
4685
+ 974,
4686
+ 897,
4687
+ 2
4688
+ ],
4689
+ [
4690
+ 1041,
4691
+ 1041,
4692
+ 2
4693
+ ],
4694
+ [
4695
+ 857,
4696
+ 1010,
4697
+ 1
4698
+ ],
4699
+ [
4700
+ 1103,
4701
+ 199,
4702
+ 2
4703
+ ],
4704
+ [
4705
+ 197,
4706
+ 1216,
4707
+ 1
4708
+ ],
4709
+ [
4710
+ 1014,
4711
+ 1014,
4712
+ 1
4713
+ ],
4714
+ [
4715
+ 861,
4716
+ 885,
4717
+ 2
4718
+ ],
4719
+ [
4720
+ 526,
4721
+ 956,
4722
+ 1
4723
+ ],
4724
+ [
4725
+ 1026,
4726
+ 1108,
4727
+ 2
4728
+ ],
4729
+ [
4730
+ 1074,
4731
+ 724,
4732
+ 2
4733
+ ],
4734
+ [
4735
+ 878,
4736
+ 904,
4737
+ 2
4738
+ ],
4739
+ [
4740
+ 985,
4741
+ 531,
4742
+ 1
4743
+ ],
4744
+ [
4745
+ 125,
4746
+ 945,
4747
+ 1
4748
+ ],
4749
+ [
4750
+ 855,
4751
+ 833,
4752
+ 1
4753
+ ],
4754
+ [
4755
+ 45,
4756
+ 1200,
4757
+ 1
4758
+ ],
4759
+ [
4760
+ 981,
4761
+ 981,
4762
+ 1
4763
+ ],
4764
+ [
4765
+ 828,
4766
+ 828,
4767
+ 4
4768
+ ],
4769
+ [
4770
+ 329,
4771
+ 973,
4772
+ 1
4773
+ ],
4774
+ [
4775
+ 876,
4776
+ 876,
4777
+ 3
4778
+ ],
4779
+ [
4780
+ 836,
4781
+ 851,
4782
+ 1
4783
+ ],
4784
+ [
4785
+ 961,
4786
+ 961,
4787
+ 2
4788
+ ],
4789
+ [
4790
+ 848,
4791
+ 1053,
4792
+ 1
4793
+ ],
4794
+ [
4795
+ 913,
4796
+ 941,
4797
+ 1
4798
+ ],
4799
+ [
4800
+ 1024,
4801
+ 1024,
4802
+ 3
4803
+ ],
4804
+ [
4805
+ 1038,
4806
+ 1038,
4807
+ 2
4808
+ ],
4809
+ [
4810
+ 849,
4811
+ 1055,
4812
+ 1
4813
+ ],
4814
+ [
4815
+ 901,
4816
+ 901,
4817
+ 1
4818
+ ],
4819
+ [
4820
+ 202,
4821
+ 1106,
4822
+ 1
4823
+ ],
4824
+ [
4825
+ 1160,
4826
+ 887,
4827
+ 4
4828
+ ],
4829
+ [
4830
+ 1044,
4831
+ 1044,
4832
+ 2
4833
+ ],
4834
+ [
4835
+ 964,
4836
+ 964,
4837
+ 2
4838
+ ],
4839
+ [
4840
+ 528,
4841
+ 528,
4842
+ 3
4843
+ ],
4844
+ [
4845
+ 527,
4846
+ 527,
4847
+ 2
4848
+ ],
4849
+ [
4850
+ 933,
4851
+ 933,
4852
+ 2
4853
+ ],
4854
+ [
4855
+ 827,
4856
+ 836,
4857
+ 1
4858
+ ],
4859
+ [
4860
+ 834,
4861
+ 823,
4862
+ 1
4863
+ ],
4864
+ [
4865
+ 848,
4866
+ 988,
4867
+ 1
4868
+ ],
4869
+ [
4870
+ 832,
4871
+ 852,
4872
+ 2
4873
+ ],
4874
+ [
4875
+ 825,
4876
+ 854,
4877
+ 1
4878
+ ],
4879
+ [
4880
+ 833,
4881
+ 855,
4882
+ 1
4883
+ ],
4884
+ [
4885
+ 827,
4886
+ 845,
4887
+ 1
4888
+ ],
4889
+ [
4890
+ 998,
4891
+ 840,
4892
+ 1
4893
+ ],
4894
+ [
4895
+ 1109,
4896
+ 207,
4897
+ 2
4898
+ ],
4899
+ [
4900
+ 332,
4901
+ 980,
4902
+ 1
4903
+ ],
4904
+ [
4905
+ 1125,
4906
+ 44,
4907
+ 2
4908
+ ],
4909
+ [
4910
+ 848,
4911
+ 992,
4912
+ 1
4913
+ ],
4914
+ [
4915
+ 849,
4916
+ 993,
4917
+ 1
4918
+ ],
4919
+ [
4920
+ 872,
4921
+ 902,
4922
+ 1
4923
+ ],
4924
+ [
4925
+ 826,
4926
+ 854,
4927
+ 1
4928
+ ],
4929
+ [
4930
+ 344,
4931
+ 982,
4932
+ 1
4933
+ ],
4934
+ [
4935
+ 1043,
4936
+ 1043,
4937
+ 2
4938
+ ],
4939
+ [
4940
+ 532,
4941
+ 530,
4942
+ 1
4943
+ ],
4944
+ [
4945
+ 831,
4946
+ 835,
4947
+ 2
4948
+ ],
4949
+ [
4950
+ 712,
4951
+ 712,
4952
+ 1
4953
+ ],
4954
+ [
4955
+ 1009,
4956
+ 1009,
4957
+ 1
4958
+ ],
4959
+ [
4960
+ 1071,
4961
+ 201,
4962
+ 2
4963
+ ],
4964
+ [
4965
+ 1007,
4966
+ 1007,
4967
+ 1
4968
+ ],
4969
+ [
4970
+ 1072,
4971
+ 36,
4972
+ 2
4973
+ ],
4974
+ [
4975
+ 206,
4976
+ 957,
4977
+ 1
4978
+ ],
4979
+ [
4980
+ 845,
4981
+ 827,
4982
+ 1
4983
+ ],
4984
+ [
4985
+ 722,
4986
+ 1107,
4987
+ 1
4988
+ ],
4989
+ [
4990
+ 952,
4991
+ 205,
4992
+ 1
4993
+ ],
4994
+ [
4995
+ 976,
4996
+ 976,
4997
+ 2
4998
+ ],
4999
+ [
5000
+ 1002,
5001
+ 841,
5002
+ 1
5003
+ ],
5004
+ [
5005
+ 949,
5006
+ 897,
5007
+ 2
5008
+ ],
5009
+ [
5010
+ 1031,
5011
+ 1031,
5012
+ 2
5013
+ ],
5014
+ [
5015
+ 857,
5016
+ 1023,
5017
+ 1
5018
+ ],
5019
+ [
5020
+ 1005,
5021
+ 1005,
5022
+ 1
5023
+ ],
5024
+ [
5025
+ 1006,
5026
+ 1006,
5027
+ 1
5028
+ ],
5029
+ [
5030
+ 871,
5031
+ 906,
5032
+ 1
5033
+ ],
5034
+ [
5035
+ 889,
5036
+ 948,
5037
+ 1
5038
+ ],
5039
+ [
5040
+ 831,
5041
+ 852,
5042
+ 2
5043
+ ],
5044
+ [
5045
+ 130,
5046
+ 130,
5047
+ 1
5048
+ ],
5049
+ [
5050
+ 117,
5051
+ 972,
5052
+ 1
5053
+ ],
5054
+ [
5055
+ 1018,
5056
+ 1018,
5057
+ 1
5058
+ ],
5059
+ [
5060
+ 829,
5061
+ 846,
5062
+ 2
5063
+ ],
5064
+ [
5065
+ 953,
5066
+ 198,
5067
+ 1
5068
+ ],
5069
+ [
5070
+ 529,
5071
+ 531,
5072
+ 1
5073
+ ],
5074
+ [
5075
+ 892,
5076
+ 882,
5077
+ 1
5078
+ ],
5079
+ [
5080
+ 836,
5081
+ 827,
5082
+ 1
5083
+ ],
5084
+ [
5085
+ 889,
5086
+ 951,
5087
+ 1
5088
+ ],
5089
+ [
5090
+ 1068,
5091
+ 818,
5092
+ 1
5093
+ ],
5094
+ [
5095
+ 925,
5096
+ 723,
5097
+ 1
5098
+ ],
5099
+ [
5100
+ 348,
5101
+ 349,
5102
+ 1
5103
+ ],
5104
+ [
5105
+ 849,
5106
+ 1001,
5107
+ 1
5108
+ ],
5109
+ [
5110
+ 1076,
5111
+ 42,
5112
+ 2
5113
+ ],
5114
+ [
5115
+ 960,
5116
+ 888,
5117
+ 1
5118
+ ],
5119
+ [
5120
+ 834,
5121
+ 856,
5122
+ 1
5123
+ ],
5124
+ [
5125
+ 869,
5126
+ 854,
5127
+ 1
5128
+ ],
5129
+ [
5130
+ 345,
5131
+ 345,
5132
+ 1
5133
+ ],
5134
+ [
5135
+ 891,
5136
+ 872,
5137
+ 1
5138
+ ],
5139
+ [
5140
+ 842,
5141
+ 842,
5142
+ 1
5143
+ ],
5144
+ [
5145
+ 893,
5146
+ 883,
5147
+ 1
5148
+ ],
5149
+ [
5150
+ 1074,
5151
+ 723,
5152
+ 2
5153
+ ],
5154
+ [
5155
+ 1086,
5156
+ 817,
5157
+ 4
5158
+ ],
5159
+ [
5160
+ 839,
5161
+ 839,
5162
+ 3
5163
+ ],
5164
+ [
5165
+ 1083,
5166
+ 1028,
5167
+ 4
5168
+ ],
5169
+ [
5170
+ 47,
5171
+ 48,
5172
+ 1
5173
+ ],
5174
+ [
5175
+ 1070,
5176
+ 1070,
5177
+ 2
5178
+ ],
5179
+ [
5180
+ 886,
5181
+ 922,
5182
+ 1
5183
+ ],
5184
+ [
5185
+ 527,
5186
+ 1034,
5187
+ 1
5188
+ ],
5189
+ [
5190
+ 837,
5191
+ 908,
5192
+ 1
5193
+ ],
5194
+ [
5195
+ 331,
5196
+ 331,
5197
+ 1
5198
+ ],
5199
+ [
5200
+ 528,
5201
+ 864,
5202
+ 1
5203
+ ],
5204
+ [
5205
+ 1090,
5206
+ 120,
5207
+ 2
5208
+ ],
5209
+ [
5210
+ 715,
5211
+ 1119,
5212
+ 1
5213
+ ],
5214
+ [
5215
+ 913,
5216
+ 858,
5217
+ 1
5218
+ ],
5219
+ [
5220
+ 942,
5221
+ 942,
5222
+ 1
5223
+ ],
5224
+ [
5225
+ 833,
5226
+ 843,
5227
+ 2
5228
+ ],
5229
+ [
5230
+ 834,
5231
+ 907,
5232
+ 1
5233
+ ],
5234
+ [
5235
+ 823,
5236
+ 823,
5237
+ 2
5238
+ ],
5239
+ [
5240
+ 725,
5241
+ 723,
5242
+ 1
5243
+ ],
5244
+ [
5245
+ 886,
5246
+ 921,
5247
+ 1
5248
+ ],
5249
+ [
5250
+ 925,
5251
+ 888,
5252
+ 1
5253
+ ],
5254
+ [
5255
+ 1135,
5256
+ 862,
5257
+ 2
5258
+ ],
5259
+ [
5260
+ 844,
5261
+ 844,
5262
+ 2
5263
+ ],
5264
+ [
5265
+ 892,
5266
+ 921,
5267
+ 1
5268
+ ],
5269
+ [
5270
+ 728,
5271
+ 728,
5272
+ 1
5273
+ ],
5274
+ [
5275
+ 835,
5276
+ 829,
5277
+ 1
5278
+ ],
5279
+ [
5280
+ 835,
5281
+ 831,
5282
+ 1
5283
+ ],
5284
+ [
5285
+ 1061,
5286
+ 1061,
5287
+ 4
5288
+ ],
5289
+ [
5290
+ 1072,
5291
+ 38,
5292
+ 2
5293
+ ],
5294
+ [
5295
+ 725,
5296
+ 727,
5297
+ 1
5298
+ ],
5299
+ [
5300
+ 1130,
5301
+ 719,
5302
+ 2
5303
+ ],
5304
+ [
5305
+ 886,
5306
+ 939,
5307
+ 1
5308
+ ],
5309
+ [
5310
+ 1077,
5311
+ 862,
5312
+ 2
5313
+ ],
5314
+ [
5315
+ 861,
5316
+ 910,
5317
+ 2
5318
+ ],
5319
+ [
5320
+ 715,
5321
+ 717,
5322
+ 1
5323
+ ],
5324
+ [
5325
+ 874,
5326
+ 858,
5327
+ 1
5328
+ ],
5329
+ [
5330
+ 889,
5331
+ 826,
5332
+ 1
5333
+ ],
5334
+ [
5335
+ 524,
5336
+ 522,
5337
+ 1
5338
+ ],
5339
+ [
5340
+ 893,
5341
+ 922,
5342
+ 1
5343
+ ],
5344
+ [
5345
+ 889,
5346
+ 825,
5347
+ 1
5348
+ ],
5349
+ [
5350
+ 1105,
5351
+ 717,
5352
+ 2
5353
+ ],
5354
+ [
5355
+ 112,
5356
+ 112,
5357
+ 1
5358
+ ],
5359
+ [
5360
+ 1064,
5361
+ 840,
5362
+ 2
5363
+ ],
5364
+ [
5365
+ 1137,
5366
+ 863,
5367
+ 2
5368
+ ],
5369
+ [
5370
+ 1131,
5371
+ 867,
5372
+ 1
5373
+ ],
5374
+ [
5375
+ 540,
5376
+ 539,
5377
+ 1
5378
+ ],
5379
+ [
5380
+ 966,
5381
+ 205,
5382
+ 1
5383
+ ],
5384
+ [
5385
+ 1039,
5386
+ 1039,
5387
+ 2
5388
+ ],
5389
+ [
5390
+ 520,
5391
+ 520,
5392
+ 1
5393
+ ],
5394
+ [
5395
+ 129,
5396
+ 128,
5397
+ 1
5398
+ ],
5399
+ [
5400
+ 970,
5401
+ 970,
5402
+ 2
5403
+ ],
5404
+ [
5405
+ 722,
5406
+ 1133,
5407
+ 1
5408
+ ],
5409
+ [
5410
+ 829,
5411
+ 835,
5412
+ 2
5413
+ ],
5414
+ [
5415
+ 117,
5416
+ 983,
5417
+ 1
5418
+ ],
5419
+ [
5420
+ 857,
5421
+ 908,
5422
+ 1
5423
+ ],
5424
+ [
5425
+ 1057,
5426
+ 118,
5427
+ 2
5428
+ ],
5429
+ [
5430
+ 1071,
5431
+ 202,
5432
+ 2
5433
+ ],
5434
+ [
5435
+ 1063,
5436
+ 1063,
5437
+ 4
5438
+ ],
5439
+ [
5440
+ 891,
5441
+ 845,
5442
+ 1
5443
+ ],
5444
+ [
5445
+ 991,
5446
+ 991,
5447
+ 2
5448
+ ],
5449
+ [
5450
+ 1081,
5451
+ 863,
5452
+ 2
5453
+ ],
5454
+ [
5455
+ 1090,
5456
+ 122,
5457
+ 2
5458
+ ],
5459
+ [
5460
+ 206,
5461
+ 911,
5462
+ 1
5463
+ ],
5464
+ [
5465
+ 988,
5466
+ 988,
5467
+ 1
5468
+ ],
5469
+ [
5470
+ 530,
5471
+ 529,
5472
+ 2
5473
+ ],
5474
+ [
5475
+ 113,
5476
+ 114,
5477
+ 1
5478
+ ],
5479
+ [
5480
+ 526,
5481
+ 528,
5482
+ 1
5483
+ ],
5484
+ [
5485
+ 525,
5486
+ 523,
5487
+ 1
5488
+ ],
5489
+ [
5490
+ 1058,
5491
+ 124,
5492
+ 2
5493
+ ],
5494
+ [
5495
+ 879,
5496
+ 854,
5497
+ 1
5498
+ ],
5499
+ [
5500
+ 212,
5501
+ 211,
5502
+ 1
5503
+ ],
5504
+ [
5505
+ 1166,
5506
+ 207,
5507
+ 2
5508
+ ],
5509
+ [
5510
+ 1060,
5511
+ 1060,
5512
+ 4
5513
+ ],
5514
+ [
5515
+ 125,
5516
+ 987,
5517
+ 1
5518
+ ],
5519
+ [
5520
+ 878,
5521
+ 1037,
5522
+ 2
5523
+ ],
5524
+ [
5525
+ 1091,
5526
+ 118,
5527
+ 2
5528
+ ],
5529
+ [
5530
+ 1094,
5531
+ 203,
5532
+ 2
5533
+ ],
5534
+ [
5535
+ 818,
5536
+ 832,
5537
+ 2
5538
+ ],
5539
+ [
5540
+ 861,
5541
+ 1032,
5542
+ 2
5543
+ ],
5544
+ [
5545
+ 847,
5546
+ 1032,
5547
+ 2
5548
+ ],
5549
+ [
5550
+ 1066,
5551
+ 841,
5552
+ 2
5553
+ ],
5554
+ [
5555
+ 1172,
5556
+ 897,
5557
+ 4
5558
+ ],
5559
+ [
5560
+ 530,
5561
+ 528,
5562
+ 1
5563
+ ],
5564
+ [
5565
+ 928,
5566
+ 872,
5567
+ 1
5568
+ ],
5569
+ [
5570
+ 846,
5571
+ 885,
5572
+ 2
5573
+ ],
5574
+ [
5575
+ 882,
5576
+ 892,
5577
+ 1
5578
+ ],
5579
+ [
5580
+ 1073,
5581
+ 1073,
5582
+ 4
5583
+ ],
5584
+ [
5585
+ 975,
5586
+ 975,
5587
+ 2
5588
+ ],
5589
+ [
5590
+ 831,
5591
+ 835,
5592
+ 1
5593
+ ],
5594
+ [
5595
+ 888,
5596
+ 871,
5597
+ 1
5598
+ ],
5599
+ [
5600
+ 1044,
5601
+ 533,
5602
+ 2
5603
+ ],
5604
+ [
5605
+ 1087,
5606
+ 528,
5607
+ 2
5608
+ ],
5609
+ [
5610
+ 1020,
5611
+ 947,
5612
+ 1
5613
+ ],
5614
+ [
5615
+ 825,
5616
+ 1053,
5617
+ 1
5618
+ ],
5619
+ [
5620
+ 890,
5621
+ 946,
5622
+ 1
5623
+ ],
5624
+ [
5625
+ 1030,
5626
+ 1030,
5627
+ 3
5628
+ ],
5629
+ [
5630
+ 1118,
5631
+ 870,
5632
+ 4
5633
+ ],
5634
+ [
5635
+ 534,
5636
+ 536,
5637
+ 1
5638
+ ],
5639
+ [
5640
+ 882,
5641
+ 840,
5642
+ 1
5643
+ ],
5644
+ [
5645
+ 883,
5646
+ 893,
5647
+ 1
5648
+ ],
5649
+ [
5650
+ 716,
5651
+ 1148,
5652
+ 1
5653
+ ],
5654
+ [
5655
+ 873,
5656
+ 858,
5657
+ 1
5658
+ ],
5659
+ [
5660
+ 1121,
5661
+ 36,
5662
+ 2
5663
+ ],
5664
+ [
5665
+ 900,
5666
+ 862,
5667
+ 1
5668
+ ],
5669
+ [
5670
+ 328,
5671
+ 328,
5672
+ 1
5673
+ ],
5674
+ [
5675
+ 868,
5676
+ 828,
5677
+ 1
5678
+ ],
5679
+ [
5680
+ 993,
5681
+ 993,
5682
+ 1
5683
+ ],
5684
+ [
5685
+ 831,
5686
+ 832,
5687
+ 2
5688
+ ],
5689
+ [
5690
+ 831,
5691
+ 870,
5692
+ 2
5693
+ ],
5694
+ [
5695
+ 867,
5696
+ 911,
5697
+ 1
5698
+ ],
5699
+ [
5700
+ 958,
5701
+ 958,
5702
+ 1
5703
+ ],
5704
+ [
5705
+ 721,
5706
+ 720,
5707
+ 1
5708
+ ],
5709
+ [
5710
+ 208,
5711
+ 1287,
5712
+ 1
5713
+ ],
5714
+ [
5715
+ 1000,
5716
+ 207,
5717
+ 1
5718
+ ],
5719
+ [
5720
+ 903,
5721
+ 863,
5722
+ 1
5723
+ ],
5724
+ [
5725
+ 1022,
5726
+ 954,
5727
+ 1
5728
+ ],
5729
+ [
5730
+ 824,
5731
+ 901,
5732
+ 1
5733
+ ],
5734
+ [
5735
+ 859,
5736
+ 995,
5737
+ 1
5738
+ ],
5739
+ [
5740
+ 1049,
5741
+ 1049,
5742
+ 4
5743
+ ],
5744
+ [
5745
+ 1076,
5746
+ 44,
5747
+ 2
5748
+ ],
5749
+ [
5750
+ 529,
5751
+ 527,
5752
+ 1
5753
+ ],
5754
+ [
5755
+ 869,
5756
+ 854,
5757
+ 2
5758
+ ],
5759
+ [
5760
+ 836,
5761
+ 845,
5762
+ 2
5763
+ ],
5764
+ [
5765
+ 711,
5766
+ 710,
5767
+ 1
5768
+ ],
5769
+ [
5770
+ 1096,
5771
+ 535,
5772
+ 2
5773
+ ],
5774
+ [
5775
+ 826,
5776
+ 1055,
5777
+ 1
5778
+ ],
5779
+ [
5780
+ 883,
5781
+ 841,
5782
+ 1
5783
+ ],
5784
+ [
5785
+ 726,
5786
+ 1171,
5787
+ 1
5788
+ ],
5789
+ [
5790
+ 31,
5791
+ 30,
5792
+ 1
5793
+ ],
5794
+ [
5795
+ 1014,
5796
+ 198,
5797
+ 1
5798
+ ],
5799
+ [
5800
+ 847,
5801
+ 1037,
5802
+ 2
5803
+ ],
5804
+ [
5805
+ 875,
5806
+ 932,
5807
+ 1
5808
+ ],
5809
+ [
5810
+ 836,
5811
+ 851,
5812
+ 2
5813
+ ],
5814
+ [
5815
+ 877,
5816
+ 930,
5817
+ 1
5818
+ ],
5819
+ [
5820
+ 1056,
5821
+ 843,
5822
+ 4
5823
+ ],
5824
+ [
5825
+ 848,
5826
+ 998,
5827
+ 1
5828
+ ],
5829
+ [
5830
+ 902,
5831
+ 872,
5832
+ 1
5833
+ ],
5834
+ [
5835
+ 1097,
5836
+ 124,
5837
+ 2
5838
+ ],
5839
+ [
5840
+ 829,
5841
+ 835,
5842
+ 1
5843
+ ],
5844
+ [
5845
+ 518,
5846
+ 519,
5847
+ 1
5848
+ ],
5849
+ [
5850
+ 924,
5851
+ 968,
5852
+ 1
5853
+ ],
5854
+ [
5855
+ 828,
5856
+ 868,
5857
+ 1
5858
+ ],
5859
+ [
5860
+ 833,
5861
+ 855,
5862
+ 2
5863
+ ],
5864
+ [
5865
+ 920,
5866
+ 840,
5867
+ 1
5868
+ ],
5869
+ [
5870
+ 818,
5871
+ 829,
5872
+ 1
5873
+ ],
5874
+ [
5875
+ 891,
5876
+ 959,
5877
+ 1
5878
+ ],
5879
+ [
5880
+ 730,
5881
+ 729,
5882
+ 1
5883
+ ],
5884
+ [
5885
+ 965,
5886
+ 885,
5887
+ 2
5888
+ ],
5889
+ [
5890
+ 894,
5891
+ 894,
5892
+ 3
5893
+ ],
5894
+ [
5895
+ 906,
5896
+ 871,
5897
+ 1
5898
+ ],
5899
+ [
5900
+ 824,
5901
+ 986,
5902
+ 1
5903
+ ],
5904
+ [
5905
+ 950,
5906
+ 984,
5907
+ 1
5908
+ ],
5909
+ [
5910
+ 944,
5911
+ 944,
5912
+ 2
5913
+ ],
5914
+ [
5915
+ 1105,
5916
+ 718,
5917
+ 2
5918
+ ],
5919
+ [
5920
+ 1047,
5921
+ 1047,
5922
+ 2
5923
+ ],
5924
+ [
5925
+ 865,
5926
+ 865,
5927
+ 2
5928
+ ],
5929
+ [
5930
+ 824,
5931
+ 968,
5932
+ 1
5933
+ ],
5934
+ [
5935
+ 537,
5936
+ 535,
5937
+ 1
5938
+ ],
5939
+ [
5940
+ 1125,
5941
+ 1125,
5942
+ 2
5943
+ ],
5944
+ [
5945
+ 1026,
5946
+ 1026,
5947
+ 3
5948
+ ]
5949
+ ]
5950
+ }
profiles/eef/config.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "model_type": "actioncodec2_unified",
3
+ "processor_class": "ActionCodec2Processor"
4
+ }
profiles/eef/config.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.001
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.004
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: 1024
71
+ vocab_size: 2048
72
+ quality_control:
73
+ hard_order_1_scale: 3.0
74
+ min_segment_length: 2
profiles/eef/primitives.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b393814f311daf6b609475c9cf943bfc9dde617b6b4249bb404df50d98b2526b
3
+ size 7406
profiles/eef/processor_config.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "processor_class": "ActionCodec2Processor"
3
+ }
pyproject.toml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]
release.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "edition": "Extended",
3
+ "encoding_order": 1,
4
+ "precision": "1mm",
5
+ "codebook_size": 2048,
6
+ "profiles": [
7
+ "eef"
8
+ ],
9
+ "gripper": "binary",
10
+ "source_revision": "589f307",
11
+ "libero_evaluated": false
12
+ }
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ numpy
2
+ scipy
3
+ torch>=2.3
4
+ transformers>=4.57,<6
5
+ huggingface-hub
6
+ pyyaml
router_config.yaml ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_type: actioncodec2_router
2
+ config_version: 3
3
+ profiles:
4
+ - name: eef
5
+ token_offset: 0
6
+ token_budget: 2048
7
+ artifact_fingerprint: 9272f9beecca4cd1bd2906f2b34c27d81f6f95ba547010843c8f2edfc7018af6
8
+ presets:
9
+ - name: single_eef_abs
10
+ input_dim: 7
11
+ input: absolute
12
+ source_hz: 15.0
13
+ target_hz: 15.0
14
+ interpolation: cubic
15
+ components:
16
+ arm:
17
+ space: eef
18
+ position_dims:
19
+ - 0
20
+ - 1
21
+ - 2
22
+ rotation_dims:
23
+ - 3
24
+ - 4
25
+ - 5
26
+ rotation_format: rotation_vector
27
+ gripper_dim: 6
28
+ gripper_threshold: 0.8
29
+ gripper_open_is_high: true
30
+ - name: single_eef_delta
31
+ input_dim: 7
32
+ input: delta
33
+ source_hz: 15.0
34
+ target_hz: 15.0
35
+ interpolation: cubic
36
+ components:
37
+ arm:
38
+ space: eef
39
+ position_dims:
40
+ - 0
41
+ - 1
42
+ - 2
43
+ rotation_dims:
44
+ - 3
45
+ - 4
46
+ - 5
47
+ rotation_format: rotation_vector
48
+ gripper_dim: 6
49
+ gripper_threshold: 0.8
50
+ gripper_open_is_high: true
51
+ - name: dual_eef_abs
52
+ input_dim: 14
53
+ input: absolute
54
+ source_hz: 15.0
55
+ target_hz: 15.0
56
+ interpolation: cubic
57
+ components:
58
+ left_arm:
59
+ space: eef
60
+ position_dims:
61
+ - 0
62
+ - 1
63
+ - 2
64
+ rotation_dims:
65
+ - 3
66
+ - 4
67
+ - 5
68
+ rotation_format: rotation_vector
69
+ gripper_dim: 6
70
+ gripper_threshold: 0.8
71
+ gripper_open_is_high: true
72
+ right_arm:
73
+ space: eef
74
+ position_dims:
75
+ - 7
76
+ - 8
77
+ - 9
78
+ rotation_dims:
79
+ - 10
80
+ - 11
81
+ - 12
82
+ rotation_format: rotation_vector
83
+ gripper_dim: 13
84
+ gripper_threshold: 0.8
85
+ gripper_open_is_high: true
86
+ - name: dual_eef_delta
87
+ input_dim: 14
88
+ input: delta
89
+ source_hz: 15.0
90
+ target_hz: 15.0
91
+ interpolation: cubic
92
+ components:
93
+ left_arm:
94
+ space: eef
95
+ position_dims:
96
+ - 0
97
+ - 1
98
+ - 2
99
+ rotation_dims:
100
+ - 3
101
+ - 4
102
+ - 5
103
+ rotation_format: rotation_vector
104
+ gripper_dim: 6
105
+ gripper_threshold: 0.8
106
+ gripper_open_is_high: true
107
+ right_arm:
108
+ space: eef
109
+ position_dims:
110
+ - 7
111
+ - 8
112
+ - 9
113
+ rotation_dims:
114
+ - 10
115
+ - 11
116
+ - 12
117
+ rotation_format: rotation_vector
118
+ gripper_dim: 13
119
+ gripper_threshold: 0.8
120
+ gripper_open_is_high: true
runtime/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Private inference runtime bundled with this ActionCodec2 artifact."""
runtime/backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Internal backend components for ActionCodec2."""
runtime/backend/native_backend.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional CPU accelerator; build with python setup.py build_ext --inplace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ __all__ = ["native", "have_native", "require_native", "resolve_backend"]
8
+
9
+ try: # pragma: no cover - exercised by whichever branch the build state picks
10
+ from .. import _native as native
11
+ except ImportError: # pragma: no cover
12
+ native = None
13
+
14
+
15
+ def have_native() -> bool:
16
+ """True when the compiled kernels are importable and not disabled."""
17
+ return native is not None and not os.environ.get("ACTIONCODEC2_NO_NATIVE")
18
+
19
+
20
+ def require_native():
21
+ """Return the native module or explain how to get one."""
22
+ if not have_native():
23
+ raise RuntimeError(
24
+ "the actioncodec2 native extension is not available; build it from "
25
+ "the package native/ directory (python setup.py build_ext --inplace) or "
26
+ "select backend='python'"
27
+ )
28
+ return native
29
+
30
+
31
+ def resolve_backend(backend: str) -> str:
32
+ """Turn ``'auto'`` into a concrete backend name."""
33
+ if backend not in {"auto", "native", "python"}:
34
+ raise ValueError("backend must be 'auto', 'native' or 'python'")
35
+ if backend == "auto":
36
+ return "native" if have_native() else "python"
37
+ if backend == "native":
38
+ require_native()
39
+ return backend
runtime/common/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Internal common components for ActionCodec2."""
runtime/common/_frontend_base.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ActionCodec2-only tensor and token-row validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+ from ._types import ActionIdxBatch, TokenIds
11
+
12
+
13
+ class BaseActionTokenizer:
14
+ """Shared action tensor methods, without policy-specific sentinels."""
15
+
16
+ @property
17
+ def new_action_tokens(self) -> list[str]:
18
+ """Token strings in id order — downstream registers these into its VLM vocab."""
19
+ return list(self._new_action_tokens)
20
+
21
+ def to_token_strs(self, token_ids: TokenIds) -> list[str]:
22
+ """Render one id row as its token strings, e.g. ``<right_arm_0>`` / ``<action0042>``.
23
+
24
+ This is exactly the text a VLM would see after registering
25
+ :attr:`new_action_tokens` — useful for eyeballing sequences and for
26
+ prompt-template debugging.
27
+
28
+ Args:
29
+ token_ids: One row of action-token ids in ``[0, vocab_size)``.
30
+
31
+ Returns:
32
+ list[str]: Token strings in row order.
33
+ """
34
+ row = self._to_id_row(token_ids).tolist()
35
+ n = len(self._new_action_tokens)
36
+ bad = [i for i in row if not 0 <= i < n]
37
+ if bad:
38
+ raise ValueError(f"token ids {bad[:5]} out of range [0, {n})")
39
+ return [self._new_action_tokens[i] for i in row]
40
+
41
+ def _check_encode_kwargs(self, encode_kwargs: dict | None) -> dict:
42
+ """Normalize ``encode_kwargs`` to a dict, rejecting keys outside :attr:`encode_kwargs`."""
43
+ kw = dict(encode_kwargs or {})
44
+ unknown = sorted(set(kw) - self.encode_kwargs)
45
+ if unknown:
46
+ raise ValueError(
47
+ f"{type(self).__name__} does not consume encode_kwargs {unknown}; it declares {sorted(self.encode_kwargs)}"
48
+ )
49
+ return kw
50
+
51
+ @staticmethod
52
+ def _to_id_row(token_ids: TokenIds) -> torch.Tensor:
53
+ """Validate and normalize one integer id row to a 1-D long tensor."""
54
+ if isinstance(token_ids, torch.Tensor):
55
+ row = token_ids.detach().cpu()
56
+ if (
57
+ row.dtype == torch.bool
58
+ or row.dtype.is_floating_point
59
+ or row.dtype.is_complex
60
+ ):
61
+ raise ValueError("token ids must be integers")
62
+ row = row.to(torch.long)
63
+ else:
64
+ values = np.asarray(token_ids)
65
+ if values.dtype.kind not in {"i", "u"}:
66
+ raise ValueError("token ids must be integers")
67
+ row = torch.from_numpy(np.asarray(values, dtype=np.int64))
68
+ if row.ndim != 1:
69
+ raise ValueError(f"token id row must be 1D, got {tuple(row.shape)}")
70
+ return row
71
+
72
+ def _checked_decode_row(self, token_ids: TokenIds) -> torch.Tensor:
73
+ """Return one validated row without repairing invalid model output."""
74
+ row = self._to_id_row(token_ids)
75
+ invalid = (row < 0) | (row >= int(self._action_vocab_size))
76
+ if torch.any(invalid):
77
+ bad = row[invalid][:5].tolist()
78
+ raise ValueError(
79
+ f"token ids {bad} out of range [0, {int(self._action_vocab_size)})"
80
+ )
81
+ return row
82
+
83
+ def _decode_token_ids_to_actions_batch(
84
+ self,
85
+ action_token_ids: Sequence[TokenIds],
86
+ *,
87
+ time_horizon: int | None = None,
88
+ action_dim: int | None = None,
89
+ decode_kwargs: dict | None = None,
90
+ ) -> torch.Tensor:
91
+ """Decode a batch of id rows to actions ``(B, T, D)``.
92
+
93
+ The decoder applies the configured action-space layout to each row.
94
+ """
95
+ batch_action_idx: ActionIdxBatch = [
96
+ self._checked_decode_row(a).tolist() for a in action_token_ids
97
+ ]
98
+ (actions_np, _) = self._decode_action_indices(
99
+ batch_action_idx,
100
+ time_horizon=time_horizon,
101
+ action_dim=action_dim,
102
+ decode_kwargs=decode_kwargs,
103
+ )
104
+ return torch.from_numpy(actions_np)
105
+
106
+ @property
107
+ def vocab_size(self) -> int:
108
+ return int(self._action_vocab_size)
runtime/common/_types.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared lightweight types for the frontend layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import torch
7
+
8
+ ActionIdxBatch = list[list[int]]
9
+ TokenIds = list[int] | np.ndarray | torch.Tensor
10
+
11
+
12
+ __all__ = ["ActionIdxBatch", "TokenIds"]
runtime/common/_validation.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared validation primitives for ActionCodec2 public boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from typing import TypeVar
7
+
8
+ import numpy as np
9
+
10
+ ErrorT = TypeVar("ErrorT", bound=Exception)
11
+
12
+
13
+ def active_dimensions(
14
+ active_dims: Sequence[int] | None,
15
+ dimension: int,
16
+ *,
17
+ subject: str = "active_dims",
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")
31
+ return dims
32
+
33
+
34
+ def optional_finite_array(
35
+ value: np.ndarray | None,
36
+ *,
37
+ shape: tuple[int, ...],
38
+ name: str = "current_state",
39
+ ) -> np.ndarray | None:
40
+ """Convert an optional float32 array and enforce its exact shape/finiteness."""
41
+ if value is None:
42
+ return None
43
+ result = np.asarray(value, dtype=np.float32)
44
+ if result.shape != shape:
45
+ rendered = ", ".join(str(item) for item in shape)
46
+ raise ValueError(f"{name} must have shape [{rendered}]")
47
+ if not np.all(np.isfinite(result)):
48
+ raise ValueError(f"{name} contains a non-finite value")
49
+ return result
50
+
51
+
52
+ def broadcast_vector(
53
+ value: object,
54
+ width: int,
55
+ *,
56
+ dtype: np.dtype,
57
+ name: str,
58
+ ) -> np.ndarray:
59
+ """Broadcast a scalar or width-sized value to one numerical vector."""
60
+ array = np.asarray(value, dtype=dtype)
61
+ try:
62
+ return np.broadcast_to(array, (width,)).copy()
63
+ except ValueError as error:
64
+ raise ValueError(f"{name} must be scalar or contain {width} values") from error
65
+
66
+
67
+ def validate_dimension_partition(
68
+ groups: Iterable[tuple[str, Sequence[int]]],
69
+ dimension: int,
70
+ *,
71
+ label: str,
72
+ error_type: type[ErrorT] = ValueError,
73
+ ) -> None:
74
+ """Validate that named dimension groups form one exact non-overlapping partition."""
75
+ owners: dict[int, str] = {}
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:
89
+ raise error_type(f"{label}(s) {missing} are not assigned")
runtime/frontend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Internal frontend components for ActionCodec2."""
runtime/frontend/tokenizer.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Action-space-bound, arbitrary-horizon ActionCodec2 frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import warnings
7
+ from collections.abc import Mapping, Sequence
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+ from ..common._frontend_base import BaseActionTokenizer
15
+ from ..common._types import ActionIdxBatch, TokenIds
16
+ from ..routing.presets import PresetConfig
17
+ from ..routing.routing import ActionCodec2Router
18
+ from ..physical.semantic import resampled_horizon
19
+
20
+
21
+ def _numpy(value):
22
+ """Convert a tensor to CPU float32 without changing NumPy input semantics."""
23
+ return (
24
+ value.detach().cpu().float().numpy()
25
+ if isinstance(value, torch.Tensor)
26
+ else value
27
+ )
28
+
29
+
30
+ class ActionCodec2Tokenizer(BaseActionTokenizer):
31
+ """ActionCodec2 frontend bound to physical tensor semantics, not a horizon.
32
+
33
+ The selected action space fixes the meaning of every source column. Sequence
34
+ length remains unrestricted: encode and decode infer any positive ``T``
35
+ directly from the lossless token coverage.
36
+
37
+ Use :meth:`from_pretrained` for the normal one-call loading path. Built-in
38
+ and registered action spaces are discoverable through :meth:`print_action_spaces`
39
+ and are included in :func:`str` output.
40
+
41
+ Args:
42
+ backend: Router containing the shared physical processors.
43
+ action_space: Registered action-space name to use for this frontend.
44
+ """
45
+
46
+ encode_kwargs = frozenset({"fps", "current_state", "previous_first_order"})
47
+
48
+ def __init__(self, backend: ActionCodec2Router, *, action_space: str) -> None:
49
+ if not isinstance(backend, ActionCodec2Router):
50
+ raise TypeError(
51
+ f"ActionCodec2Tokenizer requires ActionCodec2Router, got {type(backend).__name__}"
52
+ )
53
+ preset = backend.preset(action_space)
54
+ self.action_tokenizer = backend
55
+ self._action_space = preset
56
+ self._action_vocab_size = int(backend.vocab_size)
57
+ self._new_action_tokens = list(backend.new_action_tokens)
58
+ self.action_dim = preset.input_dim
59
+ self._warned_default_fps = False
60
+ self.requires_previous_first_order = any(
61
+ physical.primitive_order == 2
62
+ and set(component.active_dims).intersection(physical.dims)
63
+ for component in preset.components
64
+ for physical in backend.profiles[
65
+ component.space
66
+ ].processor.config.physical.components.values()
67
+ )
68
+ self._encoded_action_dim = sum(
69
+ len(component.active_dims) for component in preset.components
70
+ )
71
+
72
+ @classmethod
73
+ def from_pretrained(
74
+ cls,
75
+ pretrained_model_name_or_path: str | Path,
76
+ *,
77
+ action_space: str | Path | PresetConfig | Mapping[str, object],
78
+ **kwargs: object,
79
+ ) -> ActionCodec2Tokenizer:
80
+ """Load a local/Hub router and bind one explicit physical action space.
81
+
82
+ Args:
83
+ pretrained_model_name_or_path: Local artifact directory or Hub repo ID.
84
+ action_space: Registered name, preset YAML path, ``PresetConfig``, or
85
+ strict preset mapping. Custom configs are registered before binding.
86
+ **kwargs: Standard Hub snapshot options such as ``revision``,
87
+ ``cache_dir``, ``token``, and ``local_files_only``.
88
+ """
89
+ router = ActionCodec2Router.from_pretrained(
90
+ pretrained_model_name_or_path, **kwargs
91
+ )
92
+ if isinstance(action_space, (PresetConfig, Mapping, Path)):
93
+ name = router.register_action_space(action_space).name
94
+ elif isinstance(action_space, str):
95
+ candidate = Path(action_space)
96
+ name = (
97
+ router.register_action_space(candidate).name
98
+ if candidate.is_file()
99
+ else action_space
100
+ )
101
+ else:
102
+ raise TypeError(
103
+ "action_space must be a registered name, PresetConfig, mapping, or YAML path"
104
+ )
105
+ return cls(router, action_space=name)
106
+
107
+ def __str__(self) -> str:
108
+ """Show supported tensor layouts, physical units, and invocation requirements."""
109
+ return self._describe_action_spaces()
110
+
111
+ def _describe_action_spaces(self, action_dim: int | None = None) -> str:
112
+ return self.action_tokenizer.describe_action_spaces(
113
+ selected=self.action_space.name, action_dim=action_dim
114
+ )
115
+
116
+ @property
117
+ def action_space(self) -> PresetConfig:
118
+ """The immutable tensor semantics selected for this tokenizer."""
119
+ return self._action_space
120
+
121
+ @property
122
+ def action_spaces(self) -> Mapping[str, PresetConfig]:
123
+ """Every built-in or user-registered option in the loaded artifact."""
124
+ return self.action_tokenizer.action_spaces
125
+
126
+ def print_action_spaces(
127
+ self, *, action_dim: int | None = None, file: TextIO | None = None
128
+ ) -> None:
129
+ """Print all layouts or candidates matching ``action_dim`` without changing the binding."""
130
+ description = self._describe_action_spaces(action_dim)
131
+ print(description, file=sys.stdout if file is None else file)
132
+
133
+ def register_action_space(
134
+ self,
135
+ preset: PresetConfig | Mapping[str, object] | str | Path,
136
+ ) -> PresetConfig:
137
+ """Register a custom action space without changing this instance's binding."""
138
+ return self.action_tokenizer.register_action_space(preset)
139
+
140
+ def for_action_space(self, name: str) -> ActionCodec2Tokenizer:
141
+ """Return a new tokenizer sharing weights but bound to another option."""
142
+ return type(self)(self.action_tokenizer, action_space=name)
143
+
144
+ def _source_fps(self, fps, preset=None):
145
+ preset = self.action_space if preset is None else preset
146
+ if fps is None:
147
+ if not self._warned_default_fps:
148
+ warnings.warn(
149
+ f"fps was omitted; assuming {preset.source_hz:g} Hz from action_space "
150
+ f"{preset.name!r}. Pass the actual fps explicitly to avoid changing action timing.",
151
+ UserWarning,
152
+ stacklevel=3,
153
+ )
154
+ self._warned_default_fps = True
155
+ return preset.source_hz
156
+ return fps
157
+
158
+ def first_order_template(self, batch_size: int) -> dict[str, np.ndarray]:
159
+ """Explicit zero boundary templates, shaped {component: (B,D_active)}.
160
+
161
+ Zeros are valid only when the preceding canonical increment is known to
162
+ be zero. Otherwise replace them with measured/reconstructed values at the
163
+ action space's configured codec rate.
164
+ Only active second-order components are included; first order returns {}.
165
+ """
166
+ if type(batch_size) is not int or batch_size < 1:
167
+ raise ValueError("batch_size must be a positive integer")
168
+ return {
169
+ c.name: np.zeros((batch_size, len(c.active_dims)), dtype=np.float32)
170
+ for c in self.action_space.components
171
+ if any(
172
+ p.primitive_order == 2 and set(c.active_dims).intersection(p.dims)
173
+ for p in self.action_tokenizer.profiles[
174
+ c.space
175
+ ].processor.config.physical.components.values()
176
+ )
177
+ }
178
+
179
+ def max_tokens_for_horizon(self, horizon: int, *, fps: float | None = None) -> int:
180
+ """Return the token bound after converting a source chunk to the codec clock.
181
+
182
+ This helper describes encode input geometry. It does not store
183
+ ``horizon`` in the token stream or pass it to decode.
184
+ """
185
+ source_fps = self._source_fps(fps)
186
+ internal = resampled_horizon(
187
+ horizon, source_hz=source_fps, target_hz=self.action_space.target_hz
188
+ )
189
+ return internal * self._encoded_action_dim
190
+
191
+ def grammar(self, horizon: int, *, fps: float | None = None):
192
+ """Build a grammar for the codec steps implied by source chunk geometry.
193
+
194
+ The grammar constrains token coverage only. When ``fps`` exceeds the
195
+ codec clock, several source row counts can map to the same codec
196
+ step count; decode therefore returns the unique canonical resampling
197
+ implied by the tokens and requested ``fps``, not a hidden source count.
198
+ """
199
+ source_fps = self._source_fps(fps)
200
+ internal = resampled_horizon(
201
+ horizon, source_hz=source_fps, target_hz=self.action_space.target_hz
202
+ )
203
+ return self.action_tokenizer.grammar(
204
+ preset=self.action_space.name, horizon=internal
205
+ )
206
+
207
+ def __call__(
208
+ self,
209
+ action: torch.Tensor | np.ndarray,
210
+ *,
211
+ fps: float | None = None,
212
+ current_state: torch.Tensor | np.ndarray | None = None,
213
+ previous_first_order: Mapping[str, torch.Tensor | np.ndarray] | None = None,
214
+ encode_kwargs: dict | None = None,
215
+ ) -> ActionIdxBatch:
216
+ """Encode arbitrary-length ``(B,T,D)`` or ``(T,D)`` actions.
217
+
218
+ Second order requires ``previous_first_order`` mapping component names
219
+ to canonical ``(B,D_active)`` increments at the configured codec rate (``B=1`` for a single
220
+ trajectory). Absolute inputs also require ``current_state(B,D)``.
221
+ """
222
+ kwargs = self._check_encode_kwargs(encode_kwargs)
223
+ direct = {
224
+ "fps": fps,
225
+ "current_state": current_state,
226
+ "previous_first_order": previous_first_order,
227
+ }
228
+ duplicates = sorted(
229
+ key for key, value in direct.items() if value is not None and key in kwargs
230
+ )
231
+ if duplicates:
232
+ raise ValueError(f"encode arguments provided twice: {duplicates}")
233
+ kwargs.update(
234
+ {key: value for key, value in direct.items() if value is not None}
235
+ )
236
+ reference = kwargs.get("current_state")
237
+ if self.action_space.input == "absolute" and reference is None:
238
+ raise ValueError(
239
+ f"action space {self.action_space.name!r} is absolute; encode requires "
240
+ "current_state with shape [B, D] to preserve the first transition"
241
+ )
242
+ if self.action_space.input == "delta" and reference is not None:
243
+ raise ValueError(
244
+ f"action space {self.action_space.name!r} is delta; omit current_state"
245
+ )
246
+ values = torch.as_tensor(action)
247
+ if values.ndim == 2:
248
+ values = values.unsqueeze(0)
249
+ return self._encode_action_indices(values, encode_kwargs=kwargs)
250
+
251
+ encode = __call__
252
+
253
+ def decode(
254
+ self,
255
+ action_token_ids: Sequence[TokenIds],
256
+ *,
257
+ fps: float | None = None,
258
+ current_state: torch.Tensor | np.ndarray | None = None,
259
+ previous_first_order: Mapping[str, torch.Tensor | np.ndarray] | None = None,
260
+ ) -> torch.Tensor:
261
+ """Decode a batch, deriving sequence length from tokens and output rate.
262
+
263
+ At the codec rate, token coverage determines ``T`` exactly. Other output rates
264
+ deterministically resample that decoded path. No source horizon is
265
+ accepted because downsampling cannot preserve an arbitrary original row
266
+ count without encoding extra metadata.
267
+ ``previous_first_order`` maps component names to canonical
268
+ ``(B,D_active)`` codec-rate increments; second order requires the same
269
+ boundary as encoding, independently of ``current_state(B,D)``.
270
+ """
271
+ return self._decode_token_ids_to_actions_batch(
272
+ action_token_ids,
273
+ action_dim=self.action_dim,
274
+ decode_kwargs={
275
+ "fps": fps,
276
+ "current_state": current_state,
277
+ "previous_first_order": previous_first_order,
278
+ },
279
+ )
280
+
281
+ @staticmethod
282
+ def _numpy_boundary(previous_first_order):
283
+ if previous_first_order is None:
284
+ return None
285
+ if not isinstance(previous_first_order, Mapping):
286
+ raise ValueError(
287
+ "previous_first_order must map component names to [B,D_active] arrays"
288
+ )
289
+ return {name: _numpy(value) for name, value in previous_first_order.items()}
290
+
291
+ def final_first_order(
292
+ self,
293
+ action_token_ids: Sequence[TokenIds],
294
+ *,
295
+ previous_first_order: Mapping[str, torch.Tensor | np.ndarray],
296
+ executed_steps: int | None = None,
297
+ ) -> dict[str, np.ndarray]:
298
+ """Return reconstructed component states after a canonical-step prefix.
299
+
300
+ Results are ``[B,D_active]`` arrays at the configured codec rate,
301
+ suitable for the next encode/decode call. ``executed_steps`` counts
302
+ codec steps.
303
+ """
304
+ return self.action_tokenizer.final_first_order(
305
+ [self._checked_decode_row(row).tolist() for row in action_token_ids],
306
+ preset=self.action_space.name,
307
+ previous_first_order=self._numpy_boundary(previous_first_order),
308
+ executed_steps=executed_steps,
309
+ )
310
+
311
+ def _encode_action_indices(
312
+ self, action: torch.Tensor, encode_kwargs: dict | None = None
313
+ ) -> ActionIdxBatch:
314
+ """Encode finite ``(B,T,D)`` actions without fixing ``T`` at construction."""
315
+ kwargs = dict(encode_kwargs or {})
316
+ fps = float(self._source_fps(kwargs.get("fps")))
317
+ current_state = _numpy(kwargs.get("current_state"))
318
+ values = _numpy(action)
319
+ if values.ndim != 3 or values.shape[2] != self.action_space.input_dim:
320
+ raise ValueError(
321
+ f"actions must have shape [B, T_source, {self.action_space.input_dim}]"
322
+ )
323
+ return self.action_tokenizer.encode_batch(
324
+ values,
325
+ preset=self.action_space.name,
326
+ fps=fps,
327
+ current_state=current_state,
328
+ previous_first_order=self._numpy_boundary(
329
+ kwargs.get("previous_first_order")
330
+ ),
331
+ )
332
+
333
+ def _decode_action_indices(
334
+ self,
335
+ action_indices: ActionIdxBatch,
336
+ *,
337
+ time_horizon: int | None = None,
338
+ action_dim: int | None = None,
339
+ decode_kwargs: dict | None = None,
340
+ ) -> tuple[np.ndarray, list[set[str]]]:
341
+ """Decode rows to the selected preset's original ``(B,T,D)`` layout."""
342
+ if time_horizon is not None:
343
+ raise ValueError(
344
+ "ActionCodec2 decode infers sequence length from tokens; "
345
+ "do not provide time_horizon"
346
+ )
347
+ if action_dim is not None and int(action_dim) != self.action_dim:
348
+ raise ValueError(
349
+ f"action_dim={action_dim} disagrees with action space "
350
+ f"{self.action_space.name!r} (D={self.action_dim})"
351
+ )
352
+ kwargs = dict(decode_kwargs or {})
353
+ unknown = sorted(set(kwargs) - self.encode_kwargs)
354
+ if unknown:
355
+ raise ValueError(
356
+ f"ActionCodec2Tokenizer does not consume decode_kwargs {unknown}"
357
+ )
358
+ raw_fps = kwargs.get("fps")
359
+ fps = float(self._source_fps(raw_fps))
360
+ current_state = _numpy(kwargs.get("current_state"))
361
+ if self.action_space.input == "absolute" and current_state is None:
362
+ raise ValueError(
363
+ f"action space {self.action_space.name!r} is absolute; decode requires "
364
+ "current_state with shape [B, D] to restore its external reference"
365
+ )
366
+ if self.action_space.input == "delta" and current_state is not None:
367
+ raise ValueError(
368
+ f"action space {self.action_space.name!r} is delta; omit current_state "
369
+ "to decode in the registered input semantics"
370
+ )
371
+ decoded = self.action_tokenizer.decode_batch(
372
+ action_indices,
373
+ preset=self.action_space.name,
374
+ fps=fps,
375
+ current_state=current_state,
376
+ previous_first_order=self._numpy_boundary(
377
+ kwargs.get("previous_first_order")
378
+ ),
379
+ )
380
+ actions = self._restore_source_layout(decoded)
381
+ return actions, [set() for _ in range(actions.shape[0])]
382
+
383
+ def _restore_source_layout(self, decoded: Mapping[str, np.ndarray]) -> np.ndarray:
384
+ """Place component outputs back into their registered source columns."""
385
+ first = next(iter(decoded.values()))
386
+ batch, steps = first.shape[:2]
387
+ if any(values.shape[:2] != (batch, steps) for values in decoded.values()):
388
+ raise RuntimeError(
389
+ "decoded components disagree on batch or sequence length"
390
+ )
391
+ actions = np.empty((batch, steps, self.action_dim), dtype=np.float32)
392
+ for component in self.action_space.components:
393
+ values = decoded[component.name]
394
+ motion_width = len(component.source_dims)
395
+ actions[:, :, component.source_dims] = values[:, :, :motion_width]
396
+ if component.gripper_dim is not None:
397
+ actions[:, :, component.gripper_dim] = values[:, :, motion_width]
398
+ return actions
runtime/hub_acceleration.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Install the shared C++ kernels in a portable ActionCodec2 runtime.
2
+
3
+ This module is copied into exported Hub artifacts. It imports runtime classes
4
+ by their private package name so each artifact retains its own Python fallback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import importlib
11
+ from numbers import Integral
12
+ import os
13
+
14
+ import numpy as np
15
+
16
+
17
+ def load_native():
18
+ """Return installed portable or VLA kernels, with a fork-safe thread budget."""
19
+ if os.environ.get("ACTIONCODEC2_NO_NATIVE"):
20
+ return None
21
+ for name in (
22
+ "actioncodec2_kernels._actioncodec2_native",
23
+ "actioncodec2.tokenizers._actioncodec2_native",
24
+ ):
25
+ try:
26
+ native = importlib.import_module(name)
27
+ except ImportError:
28
+ continue
29
+ _configure(native)
30
+ return native
31
+ return None
32
+
33
+
34
+ @functools.cache
35
+ def _configure(native):
36
+ configured = os.environ.get("ACTIONCODEC2_NUM_THREADS")
37
+ if configured is not None:
38
+ if not configured.isdigit() or int(configured) < 1:
39
+ raise ValueError("ACTIONCODEC2_NUM_THREADS must be a positive integer")
40
+ native.set_thread_budget(int(configured))
41
+ else:
42
+ native.set_thread_budget(0)
43
+ if hasattr(os, "register_at_fork"):
44
+ os.register_at_fork(after_in_child=lambda: native.set_thread_budget(1))
45
+
46
+
47
+ def install(package: str) -> None:
48
+ """Accelerate an artifact while preserving its profiles and public contracts."""
49
+ backend = importlib.import_module(f"{package}.backend.native_backend")
50
+ if getattr(backend, "_portable_acceleration", False):
51
+ return
52
+ backend._portable_acceleration = True
53
+ backend.native = load_native()
54
+ semantic = importlib.import_module(f"{package}.physical.semantic")
55
+ presets = importlib.import_module(f"{package}.routing.presets")
56
+ trajectory = importlib.import_module(f"{package}.physical.trajectory")
57
+ physical = importlib.import_module(f"{package}.physical.physical_processor")
58
+ validation = importlib.import_module(f"{package}.common._validation")
59
+
60
+ def native_now():
61
+ return backend.native if backend.have_native() else None
62
+
63
+ def wrap_pose(name, kernel, width):
64
+ original = getattr(semantic, name)
65
+
66
+ @functools.wraps(original)
67
+ def wrapped(values):
68
+ native = native_now()
69
+ array = np.asarray(values)
70
+ if (native is None or array.dtype != np.float32 or array.ndim != 2
71
+ or array.shape[1] != width or len(array) < 1
72
+ or not np.all(np.isfinite(array))):
73
+ return original(values)
74
+ return getattr(native, kernel)(array[None])[0]
75
+
76
+ setattr(semantic, name, wrapped)
77
+ if hasattr(presets, name):
78
+ setattr(presets, name, wrapped)
79
+
80
+ for name, kernel, width in (
81
+ ("integrate_eef_deltas", "integrate_eef_deltas_batch", 6),
82
+ ("eef_pose_absolute", "eef_pose_absolute_batch", 7),
83
+ ("eef_pose_deltas", "eef_pose_deltas_batch", 7),
84
+ ):
85
+ wrap_pose(name, kernel, width)
86
+
87
+ original_resample = semantic.resample_uniform
88
+
89
+ @functools.wraps(original_resample)
90
+ def resample(values, *, source_hz, target_hz, kind="linear", target_length=None,
91
+ method="linear"):
92
+ native = native_now()
93
+ array = np.asarray(values)
94
+ if (native is None or array.dtype != np.float32 or array.ndim != 2
95
+ or len(array) < 1 or not np.all(np.isfinite(array))
96
+ or (target_length is not None and (
97
+ isinstance(target_length, bool) or not isinstance(target_length, Integral)
98
+ or target_length < 1))):
99
+ return original_resample(values, source_hz=source_hz, target_hz=target_hz,
100
+ kind=kind, target_length=target_length, method=method)
101
+ return native.resample_uniform_batch(
102
+ array[None], source_hz, target_hz, kind,
103
+ -1 if target_length is None else target_length, method,
104
+ )[0]
105
+
106
+ semantic.resample_uniform = presets.resample_uniform = resample
107
+ original_canonicalize = presets.PresetConfig.canonicalize
108
+
109
+ @functools.wraps(original_canonicalize)
110
+ def canonicalize(self, values, *, source_hz=None, current_state=None):
111
+ native = native_now()
112
+ array = np.asarray(values, dtype=np.float32)
113
+ if (native is None or self.input != "delta" or current_state is not None
114
+ or array.ndim != 3 or min(array.shape) < 1
115
+ or array.shape[2] != self.input_dim or not np.all(np.isfinite(array))
116
+ or any(c.space == "eef" and c.rotation_format != "rotation_vector"
117
+ for c in self.components)):
118
+ return original_canonicalize(self, values, source_hz=source_hz,
119
+ current_state=current_state)
120
+ rate = self.source_hz if source_hz is None else source_hz
121
+ output = {}
122
+ for component in self.components:
123
+ if component.space == "eef":
124
+ motion = np.concatenate((array[..., component.position_dims],
125
+ array[..., component.rotation_dims]), axis=2)
126
+ pose = native.integrate_eef_deltas_batch(motion)
127
+ sampled = native.resample_uniform_batch(
128
+ pose, rate, self.target_hz, "pose_xyzw", -1, self.interpolation
129
+ )
130
+ motion = native.eef_pose_absolute_batch(sampled)
131
+ else:
132
+ motion = np.cumsum(array[..., component.joint_dims], axis=1,
133
+ dtype=np.float32)
134
+ motion = native.resample_uniform_batch(
135
+ motion, rate, self.target_hz, "linear", -1, self.interpolation
136
+ )
137
+ if component.gripper_dim is not None:
138
+ gripper = native.resample_uniform_batch(
139
+ array[..., component.gripper_dim:component.gripper_dim + 1],
140
+ rate, self.target_hz, "hold", -1, self.interpolation,
141
+ )
142
+ gripper = semantic.binarize_gripper_actions(
143
+ gripper.reshape(-1, 1), threshold=component.gripper_threshold,
144
+ open_is_high=component.gripper_open_is_high,
145
+ ).reshape(gripper.shape)
146
+ motion = np.concatenate((motion, gripper), axis=2)
147
+ output[component.name] = [
148
+ trajectory.ActiveTrajectory(row, component.active_dims) for row in motion
149
+ ]
150
+ return output
151
+
152
+ presets.PresetConfig.canonicalize = canonicalize
153
+ original_encode = physical.ActionCodec2Processor.encode_batch
154
+ original_decode = physical.ActionCodec2Processor.decode_batch
155
+
156
+ def serializer(self):
157
+ native = native_now()
158
+ if native is None or self.serializer.scheme != "set_bpe":
159
+ return None
160
+ if not hasattr(self, "_portable_setbpe"):
161
+ saved = self.serializer
162
+ self._portable_setbpe = native.SetBPE(
163
+ saved.bins, saved.lookahead, saved.spatial_merges, saved.temporal_merges
164
+ )
165
+ return self._portable_setbpe
166
+
167
+ @functools.wraps(original_encode)
168
+ def encode_batch(self, actions, *, active_dims=None, previous_first_order=None):
169
+ codec = serializer(self)
170
+ if codec is None:
171
+ return original_encode(self, actions, active_dims=active_dims,
172
+ previous_first_order=previous_first_order)
173
+ values = np.asarray(actions, dtype=np.float32)
174
+ if values.ndim != 3 or min(values.shape) < 1:
175
+ raise ValueError("actions must have non-empty shape [B, T, D_active]")
176
+ dims = validation.active_dimensions(active_dims, self.action_dim)
177
+ if len(dims) != values.shape[2]:
178
+ raise ValueError("active_dims must have one entry per action coordinate")
179
+ dense = np.zeros((*values.shape[:2], self.action_dim), dtype=np.float32)
180
+ dense[..., dims] = values
181
+ previous = self._dense_previous_first_order(previous_first_order, dims, len(values))
182
+ cells = self.quantize_batch(dense, previous_first_order=previous)
183
+ absent = np.ones(self.action_dim, dtype=bool)
184
+ absent[list(dims)] = False
185
+ cells[..., absent] = -1
186
+ return codec.encode_batch(cells)
187
+
188
+ @functools.wraps(original_decode)
189
+ def decode_batch(self, streams, *, active_dims=None, previous_first_order=None):
190
+ codec = serializer(self)
191
+ if codec is None:
192
+ return original_decode(self, streams, active_dims=active_dims,
193
+ previous_first_order=previous_first_order)
194
+ dims = validation.active_dimensions(active_dims, self.action_dim)
195
+ checked = []
196
+ for stream in streams:
197
+ row = []
198
+ for token in stream:
199
+ if isinstance(token, bool) or not isinstance(token, Integral):
200
+ raise ValueError("tokens must be integers")
201
+ if not 0 <= int(token) < self.vocab_size:
202
+ raise ValueError("token is outside the vocabulary")
203
+ row.append(int(token))
204
+ checked.append(row)
205
+ cells = codec.decode_batch(checked, self.action_dim, sum(1 << d for d in dims))
206
+ previous = self._dense_previous_first_order(previous_first_order, dims, len(checked))
207
+ decoded = self.physical.decode_batch(
208
+ self._fill_inactive_cells(cells), previous_first_order=previous
209
+ )
210
+ return np.take(decoded, dims, axis=2)
211
+
212
+ physical.ActionCodec2Processor.encode_batch = encode_batch
213
+ physical.ActionCodec2Processor.decode_batch = decode_batch
214
+
215
+ exporting = importlib.import_module(f"{package}.integration.exporting")
216
+ for name in ("hub_acceleration.py", "hub_entry.py"):
217
+ if name not in exporting.MODULE_FILES:
218
+ exporting.MODULE_FILES.append(name)
219
+ original_export = exporting.ArtifactExporter.export
220
+
221
+ @functools.wraps(original_export)
222
+ def export(self):
223
+ import shutil
224
+
225
+ files = original_export(self)
226
+ source = self.package_root.parent
227
+ for name in ("setup.py", "pyproject.toml", "LICENSE"):
228
+ path = source / name
229
+ target = self.output / name
230
+ if path.is_file() and path.resolve() != target.resolve():
231
+ shutil.copyfile(path, target)
232
+ files.append(str(target))
233
+ kernels = source / "actioncodec2_kernels"
234
+ if kernels.is_dir() and kernels.resolve() != (self.output / kernels.name).resolve():
235
+ shutil.copytree(kernels, self.output / kernels.name, dirs_exist_ok=True,
236
+ ignore=shutil.ignore_patterns("__pycache__", "*.so", "*.pyc"))
237
+ return files
238
+
239
+ exporting.ArtifactExporter.export = export
runtime/hub_entry.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small Hugging Face entry point for a grouped ActionCodec2 artifact.
2
+
3
+ Transformers copies only top-level Python files from a Hub repository into its
4
+ dynamic-module cache. This entry point locates the artifact, then loads the
5
+ versioned runtime stored in ``runtime/`` without installing another package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import importlib
12
+ import importlib.util
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ class ActionCodec2:
18
+ """Load the processor implementation bundled with a pretrained artifact."""
19
+
20
+ @classmethod
21
+ def register_for_auto_class(cls, auto_class="AutoProcessor"):
22
+ """Satisfy Transformers' dynamic class hook for this thin loader."""
23
+ return cls
24
+
25
+ @classmethod
26
+ def from_pretrained(cls, pretrained_model_name_or_path, **kwargs):
27
+ source = Path(pretrained_model_name_or_path)
28
+ subfolder = str(kwargs.pop("subfolder", ""))
29
+ action_space = kwargs.pop("action_space", None)
30
+ kwargs.pop("_from_auto", None)
31
+ kwargs.pop("trust_remote_code", None)
32
+ allowed = {
33
+ "cache_dir",
34
+ "force_download",
35
+ "local_files_only",
36
+ "token",
37
+ "revision",
38
+ "repo_type",
39
+ }
40
+ unknown = sorted(set(kwargs) - allowed)
41
+ if unknown:
42
+ raise TypeError(
43
+ f"unsupported from_pretrained keyword(s): {', '.join(unknown)}"
44
+ )
45
+ if source.is_dir():
46
+ root = source / subfolder
47
+ else:
48
+ from huggingface_hub import snapshot_download
49
+
50
+ prefix = f"{subfolder.rstrip('/')}/" if subfolder else ""
51
+ patterns = [
52
+ "config.json",
53
+ "processor_config.json",
54
+ "router_config.yaml",
55
+ "profiles/**",
56
+ "runtime/**",
57
+ "actioncodec2_kernels/**",
58
+ "setup.py", "pyproject.toml", "LICENSE",
59
+ "README.md",
60
+ "requirements.txt",
61
+ "fit_report.json",
62
+ ]
63
+ snapshot = snapshot_download(
64
+ repo_id=str(pretrained_model_name_or_path),
65
+ allow_patterns=[prefix + pattern for pattern in patterns],
66
+ **kwargs,
67
+ )
68
+ root = Path(snapshot) / subfolder
69
+
70
+ runtime = root / "runtime"
71
+ package_file = runtime / "__init__.py"
72
+ if not package_file.is_file():
73
+ raise FileNotFoundError(
74
+ f"ActionCodec2 runtime is missing from {root}; copy the whole artifact"
75
+ )
76
+ identity = hashlib.sha256(str(runtime.resolve()).encode()).hexdigest()[:16]
77
+ package_name = f"_actioncodec2_artifact_{identity}"
78
+ if package_name not in sys.modules:
79
+ spec = importlib.util.spec_from_file_location(
80
+ package_name, package_file, submodule_search_locations=[str(runtime)]
81
+ )
82
+ if spec is None or spec.loader is None:
83
+ raise ImportError(f"cannot load ActionCodec2 runtime from {runtime}")
84
+ package = importlib.util.module_from_spec(spec)
85
+ sys.modules[package_name] = package
86
+ try:
87
+ spec.loader.exec_module(package)
88
+ except BaseException:
89
+ del sys.modules[package_name]
90
+ raise
91
+ processor = importlib.import_module(f"{package_name}.processing_actioncodec2")
92
+ acceleration = importlib.import_module(f"{package_name}.hub_acceleration")
93
+ acceleration.install(package_name)
94
+ return processor.ActionCodec2.from_pretrained(root, action_space=action_space)
runtime/integration/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Internal integration components for ActionCodec2."""
runtime/integration/exporting.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Write the portable runtime and user guide for a saved ActionCodec2 artifact."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ # Files included in a portable runtime artifact.
7
+ MODULE_FILES: list[str]
8
+ SUPPORT_FILES: dict[str, str]
9
+ USER_GUIDE: str
10
+
11
+
12
+ class ArtifactExporter:
13
+ """Copy portable code alongside numerical artifacts.
14
+
15
+ Args:
16
+ directory: Destination directory.
17
+ source_directory: Existing artifact or package directory used as a
18
+ fallback for runtime files and native resources.
19
+ """
20
+
21
+ def __init__(
22
+ self, directory: str | Path, *, source_directory: str | Path | None = None
23
+ ) -> None:
24
+ self.output = Path(directory)
25
+ self.package_root = Path(__file__).resolve().parent.parent
26
+ self.source = Path(source_directory) if source_directory else self.package_root
27
+
28
+ def export(self) -> list[str]:
29
+ self.output.mkdir(parents=True, exist_ok=True)
30
+ files = []
31
+ runtime = self.output / "runtime"
32
+ runtime.mkdir(parents=True, exist_ok=True)
33
+ for name in MODULE_FILES:
34
+ # Copy the current implementation so an artifact can be moved alone.
35
+ source = self.package_root / name
36
+ if not source.is_file():
37
+ source = self.source / name
38
+ target = runtime / name
39
+ target.parent.mkdir(parents=True, exist_ok=True)
40
+ if source.resolve() != target.resolve():
41
+ shutil.copyfile(source, target)
42
+ files.append(str(target))
43
+ (runtime / "__init__.py").write_text(
44
+ '"""Private inference runtime bundled with this ActionCodec2 artifact."""\n',
45
+ encoding="utf-8",
46
+ )
47
+ entry = self.package_root / "hub_entry.py"
48
+ if not entry.is_file():
49
+ entry = self.source / "runtime" / "hub_entry.py"
50
+ target = self.output / "processing_actioncodec2.py"
51
+ if entry.resolve() != target.resolve():
52
+ shutil.copyfile(entry, target)
53
+ files.append(str(target))
54
+ # A saved artifact needs runtime dependencies, but not training CLIs or
55
+ # package-build metadata.
56
+ for name in ("requirements.txt",):
57
+ content = SUPPORT_FILES[name]
58
+ target = self.output / name
59
+ target.write_text(content, encoding="utf-8")
60
+ files.append(str(target))
61
+ if not (self.output / "README.md").exists():
62
+ (self.output / "README.md").write_text(USER_GUIDE, encoding="utf-8")
63
+ return files
64
+
65
+
66
+ USER_GUIDE = """# Loading this saved artifact
67
+
68
+ This directory is a complete, portable ActionCodec2 artifact. Install its runtime dependencies, then point Transformers at this directory. Keep every file and subdirectory together when copying or uploading it.
69
+
70
+ ```bash
71
+ python -m pip install -r requirements.txt
72
+ ```
73
+
74
+ ```python
75
+ from transformers import AutoProcessor
76
+
77
+ codec = AutoProcessor.from_pretrained(
78
+ "ZibinDong/ActionCodec2-1st-order",
79
+ trust_remote_code=True,
80
+ )
81
+ print(codec)
82
+ ```
83
+
84
+ # ActionCodec2
85
+
86
+ ActionCodec2 converts continuous robot actions into integer token IDs and decodes
87
+ them to the same column layout. Physical quantization is lossy; token
88
+ serialization is exact for a fixed fitted codec. The Python `ActionCodec2` class
89
+ is the main interface. Its input is an action tensor, not a text prompt.
90
+
91
+ Python 3.10+ is required.
92
+
93
+ ```bash
94
+ python -m pip install .
95
+ ```
96
+
97
+ ## Choose an action space before encoding
98
+
99
+ Action shape alone cannot identify units or column meanings. Confirm the action
100
+ order, units, rotation representation, absolute versus delta convention,
101
+ gripper convention, and sampling rate from your data source. `D=7` can match
102
+ several different layouts.
103
+
104
+ ```python
105
+ from actioncodec2 import ActionCodec2
106
+
107
+ # No artifact is needed to inspect the built-in layout definitions.
108
+ print(ActionCodec2.builtin_action_spaces().keys())
109
+
110
+ ARTIFACT = "ZibinDong/ActionCodec2-1st-order" # or a Hub repository ID
111
+ print(ActionCodec2.describe_pretrained(ARTIFACT, action_dim=7))
112
+ codec = ActionCodec2.from_pretrained(ARTIFACT, action_space="single_eef_delta")
113
+ print(codec) # All registered layouts, column meanings, units, and requirements
114
+ codec.print_action_spaces(action_dim=7) # Candidates only; never auto-selects
115
+ ```
116
+
117
+ A saved artifact can include its selected `action_space`; in that case
118
+ `ActionCodec2.from_pretrained(ARTIFACT)` restores it. For an unbound router,
119
+ `action_space` is required. You can inspect `codec.action_space` for the selected
120
+ contract and `codec.action_spaces` for every registered contract. Use
121
+ `codec.for_action_space("NAME")` to create another binding over the same fitted
122
+ profiles. The original object keeps its binding.
123
+
124
+ To define a layout with explicit semantics, register a preset mapping or YAML
125
+ file. Start from a known contract only when its column meanings really match:
126
+
127
+ ```python
128
+ preset = codec.action_space.to_dict()
129
+ preset["name"] = "my_30hz_eef_delta"
130
+ preset["source_hz"] = 30.0
131
+ codec.register_action_space(preset)
132
+ codec = codec.for_action_space("my_30hz_eef_delta")
133
+ codec.save_pretrained("./my-bound-codec") # Persists the custom layout and binding
134
+ ```
135
+
136
+ Changing `source_hz` does not change the meaning of action columns. For a new
137
+ robot or a different physical tensor, define the complete preset instead of
138
+ renaming an existing layout.
139
+
140
+ ## Encode and decode
141
+
142
+ ```python
143
+ import numpy as np
144
+
145
+ actions = np.asarray(your_actions, dtype=np.float32) # (T, D) or (B, T, D)
146
+ tokens = codec.encode(actions, fps=30) # list[list[int]], one row per episode
147
+ reconstructed = codec.decode(tokens, fps=30) # CPU torch.float32, (B, T_out, D)
148
+ features = codec(actions, fps=30) # Transformers BatchFeature with input_ids
149
+ ```
150
+
151
+ `T` can vary across calls. For unequal episode lengths, encode episodes one at a
152
+ time; a single tensor batch must have equal `T`. Pass the real sampling rate with
153
+ `fps`. If omitted, the codec warns once and assumes the selected preset's
154
+ `source_hz`. Decoding at a different rate resamples the reconstructed path, so
155
+ `T_out` may differ from the original `T`. Physical quantization and time
156
+ resampling prevent exact numeric round trips.
157
+
158
+ For independently generated rows with different decoded horizons, use
159
+ `codec.batch_decode(rows, fps=30)`. It returns a list of tensors shaped
160
+ `(1, T_i, D)`. With `errors="return"`, an invalid row becomes a `ValueError` in
161
+ its list position without aborting other rows. Ordinary `decode` expects a
162
+ regular batch with the same decoded length.
163
+
164
+ ### Absolute actions and second-order profiles
165
+
166
+ An absolute layout needs the physical state immediately before the first action.
167
+ Pass the same `current_state` to encode and decode; it has shape `(B, D)` even
168
+ when the input is one `(T, D)` episode:
169
+
170
+ ```python
171
+ tokens = codec.encode(actions, fps=30, current_state=current_state)
172
+ reconstructed = codec.decode(tokens, fps=30, current_state=current_state)
173
+ ```
174
+
175
+ A second-order profile additionally needs the preceding reconstructed
176
+ first-order increment for each active component. Inspect
177
+ `codec.requires_previous_first_order`. The boundary mapping has the form
178
+ `{component_name: (B, D_active)}` at the codec's target rate. It is independent
179
+ of `current_state`.
180
+
181
+ ```python
182
+ # Only use zeros when the preceding motion is known to be zero.
183
+ boundary = codec.first_order_template(batch_size=1)
184
+ tokens = codec.encode(actions, fps=30, previous_first_order=boundary)
185
+ reconstructed = codec.decode(tokens, fps=30, previous_first_order=boundary)
186
+ next_boundary = codec.final_first_order(
187
+ tokens, previous_first_order=boundary, executed_steps=None
188
+ )
189
+ ```
190
+
191
+ `final_first_order` returns the boundary for the next chunk. `executed_steps`
192
+ can select an executed prefix in codec-rate steps. For an absolute second-order
193
+ layout, pass both `current_state` and `previous_first_order` to encode/decode.
194
+ The library never invents a boundary from the first action.
195
+
196
+ For diagnostics, `codec.is_fitted`, `codec.fitted_profiles`, and
197
+ `codec.requires_previous_first_order` report whether the selected artifact is
198
+ ready for inference and whether the second-order boundary is mandatory.
199
+
200
+ ## Fit and save
201
+
202
+ Fit on actions whose columns follow the selected layout. The same `ActionCodec2`
203
+ object is the training interface:
204
+
205
+ ```python
206
+ codec = ActionCodec2(action_space="single_eef_delta")
207
+ codec.fit(episodes, fps=15, backend="auto")
208
+ print(codec.is_fitted, codec.fitted_profiles)
209
+ print(codec.training_report)
210
+ codec.save_pretrained("./my-codec")
211
+ ```
212
+
213
+ `episodes` can be one `(T, D)` array, one regular `(B, T, D)` array, or an
214
+ iterable of variable-length episodes. For mixed layouts or sampling rates, pass
215
+ records with explicit metadata:
216
+
217
+ ```python
218
+ records = [
219
+ {"actions": episode_a, "action_space": "single_eef_delta", "fps": 15},
220
+ {"actions": episode_b, "action_space": "single_joint_delta", "fps": 30},
221
+ ]
222
+ codec.fit(records, backend="auto")
223
+ ```
224
+
225
+ A record may also contain `current_state` and `previous_first_order` as required
226
+ by its layout. `fit` supports `primitive_order=2` codecs, an explicit
227
+ `bootstrap_context=True` option for two measured context frames, and
228
+ `profile_configs={"joint": ..., "eef": ...}` at construction for custom
229
+ physical profile settings. `backend` is `"auto"`, `"native"`, or `"python"`;
230
+ `threads`, `batch_size`, `progress`, `max_cell_bytes`, and `work_dir` control
231
+ resource use. Fitting replaces token assignments; re-encode datasets after
232
+ refitting. `save_pretrained` writes the complete loadable artifact, including
233
+ the layout binding and model card. `save_pretrained(path, push_to_hub=True,
234
+ repo_id="ZibinDong/my-actioncodec2")` also uploads it to a Hub repository you control.
235
+
236
+ The optional C++ extension accelerates fitting when available. `backend="auto"`
237
+ uses it if installed and otherwise uses the Python implementation.
238
+
239
+ ### Reuse separately fitted physical profiles
240
+
241
+ If you already have fitted joint and EEF profile directories, assemble a router
242
+ without refitting. Budgets come from the recorded training configuration unless
243
+ you supply `token_budgets`:
244
+
245
+ ```python
246
+ codec = ActionCodec2.from_profiles(
247
+ "./joint-profile", "./eef-profile", action_space="single_eef_delta"
248
+ )
249
+ codec.save_pretrained("./combined-codec")
250
+ ```
251
+
252
+ The equivalent command is:
253
+
254
+ ```bash
255
+ actioncodec2-bundle --joint ./joint-profile --eef ./eef-profile \\
256
+ --action-space single_eef_delta --out ./combined-codec
257
+ ```
258
+
259
+ The command also accepts `--joint-budget` and `--eef-budget`. Omit
260
+ `--action-space` only when you intentionally want to select a layout at load
261
+ time.
262
+
263
+ ## Constrain model generation
264
+
265
+ The codec supplies a grammar for an exact action horizon; it does not generate
266
+ actions by itself. `codec.grammar(horizon, fps=...)` has
267
+ `next_token_mask(prefix)` and `is_complete(tokens)`. A truncated or otherwise
268
+ invalid stream cannot be decoded as a complete action chunk.
269
+
270
+ ```python
271
+ horizon = 12
272
+ grammar = codec.grammar(horizon, fps=15)
273
+ max_action_tokens = codec.max_tokens_for_horizon(horizon, fps=15)
274
+ assert grammar.is_complete(tokens)
275
+ ```
276
+
277
+ For a Transformers model, use the adapter returned by
278
+ `codec.logits_processor(...)` in `generate`:
279
+
280
+ ```python
281
+ from transformers import LogitsProcessorList
282
+
283
+ constraint = codec.logits_processor(
284
+ horizon,
285
+ fps=15,
286
+ prompt_length=input_ids.shape[1],
287
+ eos_token_id=model.config.eos_token_id,
288
+ token_offset=action_token_offset,
289
+ )
290
+ output = model.generate(
291
+ input_ids,
292
+ logits_processor=LogitsProcessorList([constraint]),
293
+ )
294
+ ```
295
+
296
+ The model must already have a contiguous range of action IDs beginning at
297
+ `action_token_offset`; the codec does not modify its vocabulary. EOS and any
298
+ padding ID must lie outside that range. `codec.vocab_size` is the number of
299
+ action IDs. `codec.new_action_tokens` provides their ordered names, and
300
+ `codec.to_token_strs(one_token_row)` renders a row for inspection. Remove the
301
+ model's token offset and EOS/padding IDs before passing generated action IDs to
302
+ `codec.decode`.
303
+
304
+ ## Advanced profile commands
305
+
306
+ The recommended training path is `codec.fit`. The following commands operate
307
+ on physical profile tensors rather than raw action-space layouts. Use them when
308
+ you intentionally need to fit or audit individual joint/EEF profiles. Their
309
+ `.npy`/`.npz` inputs must already contain canonical `(T, D_profile)` or
310
+ `(B, T, D_profile)` trajectories; `actioncodec2-fit-profile` does not perform
311
+ layout routing or source-rate conversion.
312
+
313
+ ```bash
314
+ actioncodec2-fit-profile --config ./eef-config.yaml --data ./eef.npy \\
315
+ --output-dir ./eef-profile
316
+ actioncodec2-audit-profile stats --config ./eef-config.yaml \\
317
+ --data ./eef.npy --output ./eef-stats.json
318
+ actioncodec2-audit-profile validate --artifact ./eef-profile \\
319
+ --data ./eef.npy --output ./eef-validation.json
320
+ ```
321
+
322
+ `--config` is an `ActionCodec2` physical profile YAML with a `train` section.
323
+ The fit command writes a physical profile and a JSON report. `stats` summarizes
324
+ source trajectories before fitting; `validate` checks a fitted profile against
325
+ data and writes a machine-readable report. Run each command with `--help` for
326
+ resource limits, metadata fields, and overwrite controls.
327
+
328
+ ## Public interface at a glance
329
+
330
+ | Task | Entry point | Result |
331
+ | --- | --- | --- |
332
+ | Inspect layouts | `ActionCodec2.builtin_action_spaces()`, `describe_pretrained()`, `print(codec)`, `codec.print_action_spaces()` | Explicit physical contracts |
333
+ | Load and switch | `from_pretrained()`, `for_action_space()`, `register_action_space()` | Bound `ActionCodec2` |
334
+ | Fit and package | `ActionCodec2(...)`, `fit()`, `from_profiles()`, `save_pretrained()` | Loadable artifact |
335
+ | Encode and decode | `encode()`, `decode()`, `batch_decode()`, `codec(...)` | Token rows or action tensors |
336
+ | Stateful chunks | `first_order_template()`, `final_first_order()` | Explicit second-order boundaries |
337
+ | Generate | `grammar()`, `logits_processor()`, `max_tokens_for_horizon()` | Valid token constraints |
338
+ | Inspect tokens | `vocab_size`, `new_action_tokens`, `to_token_strs()` | ID-to-name mapping |
339
+
340
+ `AutoProcessor.from_pretrained(path, trust_remote_code=True)` loads an artifact
341
+ without importing this package first. If the package is installed and imported,
342
+ its processor registration also supports local `AutoProcessor` resolution.
343
+
344
+ ## Tests
345
+
346
+ ```bash
347
+ python -m pytest tests -q
348
+ ```
349
+
350
+ ## Artifact contents
351
+
352
+ A saved artifact contains the metadata, fitted profiles, private runtime, and
353
+ this guide. Keep the complete directory together when moving it or uploading it
354
+ to Hugging Face.
355
+ """
356
+
357
+ SUPPORT_FILES = {
358
+ "requirements.txt": "numpy\nscipy\ntorch>=2.3\ntransformers>=4.57,<6\nhuggingface-hub\npyyaml\n",
359
+ }
360
+
361
+ MODULE_FILES = [
362
+ "backend/__init__.py",
363
+ "backend/native_backend.py",
364
+ "common/__init__.py",
365
+ "common/_frontend_base.py",
366
+ "common/_types.py",
367
+ "common/_validation.py",
368
+ "frontend/__init__.py",
369
+ "frontend/tokenizer.py",
370
+ "integration/__init__.py",
371
+ "integration/exporting.py",
372
+ "integration/hf.py",
373
+ "physical/__init__.py",
374
+ "physical/_so3.py",
375
+ "physical/boundary.py",
376
+ "physical/configuration.py",
377
+ "physical/physical_codec.py",
378
+ "physical/physical_processor.py",
379
+ "physical/primitives.py",
380
+ "physical/semantic.py",
381
+ "physical/trajectory.py",
382
+ "routing/__init__.py",
383
+ "routing/cache.py",
384
+ "routing/defaults.py",
385
+ "routing/presets.py",
386
+ "routing/routing.py",
387
+ "tokenization/__init__.py",
388
+ "tokenization/bpe.py",
389
+ "tokenization/bpe_training.py",
390
+ "tokenization/bpe_vocabulary.py",
391
+ "tokenization/generation.py",
392
+ "tokenization/grammar.py",
393
+ "tokenization/setbpe_spatial.py",
394
+ "tokenization/setbpe_spatial_training.py",
395
+ "tokenization/setbpe_temporal.py",
396
+ "tokenization/setbpe_temporal_training.py",
397
+ "training/__init__.py",
398
+ "training/fitting.py",
399
+ "training/training.py",
400
+ "processing_actioncodec2.py",
401
+ ]
runtime/integration/hf.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face AutoProcessor integration.
2
+
3
+ ActionCodec2 is a processor, not a model: it has no trainable ``forward``
4
+ parameters and maps continuous trajectories to/from discrete model-facing token
5
+ IDs. The numerical artifact remains ``config.yaml``/``primitives.npz``/
6
+ ``bpe.json``; the two small JSON files here are standard Hugging Face discovery
7
+ metadata only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from transformers import AutoConfig, AutoProcessor, PretrainedConfig
17
+
18
+ from ..physical.configuration import MODEL_TYPE
19
+ from .exporting import USER_GUIDE
20
+
21
+ if TYPE_CHECKING:
22
+ from ..physical.physical_processor import ActionCodec2Processor
23
+ from ..routing.routing import ActionCodec2Router
24
+
25
+ HF_CONFIG_NAME = "config.json"
26
+ PROCESSOR_CONFIG_NAME = "processor_config.json"
27
+ PROCESSOR_CLASS_NAME = "ActionCodec2Processor"
28
+ ROUTER_MODEL_TYPE = "actioncodec2_router"
29
+ ROUTER_PROCESSOR_CLASS_NAME = "ActionCodec2Router"
30
+ ROUTER_CONFIG_NAME = "router_config.yaml"
31
+ ROUTER_ALLOW_PATTERNS = [
32
+ ROUTER_CONFIG_NAME,
33
+ HF_CONFIG_NAME,
34
+ PROCESSOR_CONFIG_NAME,
35
+ "profiles/**",
36
+ "fit_report.json",
37
+ "README.md",
38
+ "*.py",
39
+ "requirements.txt",
40
+ "runtime/**", "actioncodec2_kernels/**", "setup.py", "pyproject.toml", "LICENSE",
41
+ # Compatibility for artifacts emitted before runtime/ was introduced.
42
+ "native/*",
43
+ ]
44
+
45
+
46
+ class ActionCodec2FrontendHFConfig(PretrainedConfig):
47
+ """AutoConfig discovery key for the action-space-bound public processor."""
48
+
49
+ model_type = "actioncodec2"
50
+
51
+ def __init__(self, **kwargs: Any) -> None:
52
+ kwargs.setdefault("processor_class", "ActionCodec2")
53
+ super().__init__(**kwargs)
54
+
55
+
56
+ class ActionCodec2HFConfig(PretrainedConfig):
57
+ """Minimal AutoConfig key that dispatches to :class:`ActionCodec2Processor`."""
58
+
59
+ model_type = MODEL_TYPE
60
+
61
+ def __init__(self, **kwargs: Any) -> None:
62
+ kwargs.setdefault("processor_class", PROCESSOR_CLASS_NAME)
63
+ super().__init__(**kwargs)
64
+
65
+
66
+ class ActionCodec2RouterHFConfig(PretrainedConfig):
67
+ """Minimal AutoConfig key that dispatches to :class:`ActionCodec2Router`."""
68
+
69
+ model_type = ROUTER_MODEL_TYPE
70
+
71
+ def __init__(self, **kwargs: Any) -> None:
72
+ kwargs.setdefault("processor_class", ROUTER_PROCESSOR_CLASS_NAME)
73
+ super().__init__(**kwargs)
74
+
75
+
76
+ def save_huggingface_metadata(
77
+ directory: str | Path,
78
+ *,
79
+ model_type: str = MODEL_TYPE,
80
+ processor_class: str = PROCESSOR_CLASS_NAME,
81
+ **processor_config: Any,
82
+ ) -> tuple[Path, Path]:
83
+ """Write standard local/Hub discovery files for ``AutoProcessor``."""
84
+ root = Path(directory)
85
+ root.mkdir(parents=True, exist_ok=True)
86
+ config_path = root / HF_CONFIG_NAME
87
+ processor_path = root / PROCESSOR_CONFIG_NAME
88
+ config_path.write_text(
89
+ json.dumps(
90
+ {
91
+ "model_type": model_type,
92
+ "processor_class": processor_class,
93
+ },
94
+ indent=2,
95
+ )
96
+ + "\n",
97
+ encoding="utf-8",
98
+ )
99
+ processor_path.write_text(
100
+ json.dumps({"processor_class": processor_class, **processor_config}, indent=2)
101
+ + "\n",
102
+ encoding="utf-8",
103
+ )
104
+ return config_path, processor_path
105
+
106
+
107
+ def resolve_pretrained_directory(
108
+ pretrained_model_name_or_path: str | Path,
109
+ *,
110
+ allow_patterns: list[str] | None = None,
111
+ **kwargs: Any,
112
+ ) -> Path:
113
+ """Resolve a local directory or download one immutable Hub snapshot."""
114
+ source = Path(pretrained_model_name_or_path)
115
+ kwargs.pop("_from_auto", None)
116
+ kwargs.pop("trust_remote_code", None)
117
+ subfolder = str(kwargs.pop("subfolder", ""))
118
+ allowed = {
119
+ "cache_dir",
120
+ "force_download",
121
+ "local_files_only",
122
+ "token",
123
+ "revision",
124
+ "repo_type",
125
+ }
126
+ unknown = sorted(set(kwargs) - allowed)
127
+ if unknown:
128
+ raise TypeError(f"unsupported from_pretrained keyword(s): {', '.join(unknown)}")
129
+ if source.is_dir():
130
+ return source / subfolder
131
+
132
+ try:
133
+ from huggingface_hub import snapshot_download
134
+ except ImportError as error:
135
+ raise ImportError(
136
+ "loading a Hub artifact requires huggingface_hub (installed with Transformers)"
137
+ ) from error
138
+ patterns = allow_patterns or [
139
+ "config.yaml",
140
+ "primitives.npz",
141
+ "bpe.json",
142
+ HF_CONFIG_NAME,
143
+ PROCESSOR_CONFIG_NAME,
144
+ ]
145
+ if subfolder:
146
+ prefix = subfolder.rstrip("/")
147
+ patterns = [f"{prefix}/{pattern}" for pattern in patterns]
148
+ snapshot = snapshot_download(
149
+ repo_id=str(pretrained_model_name_or_path),
150
+ allow_patterns=patterns,
151
+ **kwargs,
152
+ )
153
+ return Path(snapshot) / subfolder
154
+
155
+
156
+ def upload_pretrained_directory(directory: Path, **kwargs: Any) -> None:
157
+ """Upload a complete, already-saved processor artifact to the Hub."""
158
+ from huggingface_hub import HfApi
159
+
160
+ repo_id = kwargs.pop("repo_id", directory.name)
161
+ commit_message = kwargs.pop("commit_message", "Upload ActionCodec2 processor")
162
+ api = HfApi(token=kwargs.pop("token", None))
163
+ api.create_repo(repo_id=repo_id, exist_ok=True, **kwargs)
164
+ api.upload_folder(
165
+ repo_id=repo_id,
166
+ folder_path=str(directory),
167
+ commit_message=commit_message,
168
+ )
169
+
170
+
171
+ def register_actioncodec2_auto_processors(
172
+ processor_class: type[ActionCodec2Processor],
173
+ router_class: type[ActionCodec2Router],
174
+ ) -> bool:
175
+ """Register both installed ActionCodec2 artifact types with Transformers.
176
+
177
+ Registration is idempotent and never changes the serialized numerical artifact.
178
+ """
179
+ AutoConfig.register(MODEL_TYPE, ActionCodec2HFConfig, exist_ok=True)
180
+ AutoProcessor.register(ActionCodec2HFConfig, processor_class, exist_ok=True)
181
+ AutoConfig.register(ROUTER_MODEL_TYPE, ActionCodec2RouterHFConfig, exist_ok=True)
182
+ AutoProcessor.register(ActionCodec2RouterHFConfig, router_class, exist_ok=True)
183
+ return True
184
+
185
+
186
+ def save_model_card(directory: str | Path, description: str) -> Path:
187
+ """Create an informative Hub card on first save, preserving existing user cards."""
188
+ path = Path(directory) / "README.md"
189
+ if not path.exists():
190
+ path.parent.mkdir(parents=True, exist_ok=True)
191
+ guide = USER_GUIDE
192
+ path.write_text(
193
+ guide
194
+ + "\n\n## Artifact-specific report\n\n```text\n"
195
+ + description
196
+ + "\n```\n",
197
+ encoding="utf-8",
198
+ )
199
+ return path
runtime/physical/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Internal physical components for ActionCodec2."""
runtime/physical/_so3.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared NumPy quaternion operations using the xyzw convention."""
2
+
3
+ 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):
13
+ raise ValueError("a pose contains a zero quaternion")
14
+ return quaternions / norms
15
+
16
+
17
+ 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(
24
+ np.concatenate(
25
+ (
26
+ first_scalar * second_vector
27
+ + second_scalar * first_vector
28
+ + np.cross(first_vector, second_vector),
29
+ first_scalar * second_scalar
30
+ - np.sum(first_vector * second_vector, axis=-1, keepdims=True),
31
+ ),
32
+ axis=-1,
33
+ )
34
+ )
35
+
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
42
+
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)
63
+ angle = 2.0 * np.arctan2(vector_norm, np.clip(values[..., 3], 0.0, 1.0))
64
+ scale = np.full_like(angle, 2.0)
65
+ regular = vector_norm >= 1e-12
66
+ scale[regular] = angle[regular] / vector_norm[regular]
67
+ return values[..., :3] * scale[..., None]
68
+
69
+
70
+ def euler_xyz_to_quaternion(euler: np.ndarray) -> np.ndarray:
71
+ """Convert ``(N,3)`` XYZ Euler angles to xyzw quaternions."""
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
78
+ result = np.empty((len(values), 4), dtype=np.float64)
79
+ result[:, 0] = sin_roll * cos_pitch * cos_yaw - cos_roll * sin_pitch * sin_yaw
80
+ result[:, 1] = cos_roll * sin_pitch * cos_yaw + sin_roll * cos_pitch * sin_yaw
81
+ result[:, 2] = cos_roll * cos_pitch * sin_yaw - sin_roll * sin_pitch * cos_yaw
82
+ result[:, 3] = cos_roll * cos_pitch * cos_yaw + sin_roll * sin_pitch * sin_yaw
83
+ return normalize_quaternion(result)
84
+
85
+
86
+ def quaternion_to_euler_xyz(quaternion: np.ndarray) -> np.ndarray:
87
+ """Convert ``(N,4)`` xyzw quaternions to XYZ Euler angles."""
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
+ (
94
+ np.arctan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y)),
95
+ np.arcsin(np.clip(2.0 * (w * y - z * x), -1.0, 1.0)),
96
+ np.arctan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)),
97
+ ),
98
+ axis=1,
99
+ )
runtime/physical/boundary.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit measured-context preparation for stateful trajectory fitting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .configuration import ActionCodec2Config
8
+ from .physical_codec import _scale, _SO3Runtime
9
+ from .trajectory import (
10
+ ActiveTrajectory,
11
+ TrajectoryLike,
12
+ prepare_trajectory,
13
+ trajectory_boundary,
14
+ )
15
+
16
+
17
+ def source_differences(
18
+ trajectory: TrajectoryLike, config: ActionCodec2Config
19
+ ) -> dict[str, tuple[tuple[int, ...], np.ndarray, np.ndarray]]:
20
+ """Measure physical first/second differences with the declared group law.
21
+
22
+ Returns component names mapped to global dims, ``[T,d]`` first-order
23
+ values, and second-order values. If no first-order boundary is supplied
24
+ for a first-order profile, the first second difference is unobserved and
25
+ omitted. Second-order profiles require their explicit boundary.
26
+ """
27
+ values, dims = prepare_trajectory(
28
+ trajectory, dimension=config.physical.input_dim, index=0
29
+ )
30
+ boundary = trajectory_boundary(trajectory, config, dims)
31
+ output = {}
32
+ for name, component in config.physical.components.items():
33
+ present = tuple(d for d in component.dims if d in dims)
34
+ if not present or component.primitive_order == 0:
35
+ continue
36
+ local = [dims.index(d) for d in present]
37
+ indices = [component.dims.index(d) for d in present]
38
+ scale = np.take(_scale(component), indices)
39
+ raw = values[:, local].astype(np.float64) * scale
40
+ previous = (
41
+ None
42
+ if boundary is None
43
+ else boundary[list(present)].astype(np.float64) * scale
44
+ )
45
+ if component.space == "additive":
46
+ if component.input_order == 0:
47
+ first = np.diff(raw, axis=0, prepend=np.zeros((1, len(present))))
48
+ elif component.input_order == 1:
49
+ first = raw
50
+ else:
51
+ if previous is None:
52
+ raise ValueError(
53
+ "order-2 input statistics require previous_first_order"
54
+ )
55
+ first = np.cumsum(raw, axis=0) + previous
56
+ second = (
57
+ np.diff(first, axis=0)
58
+ if previous is None
59
+ else np.diff(first, axis=0, prepend=previous[None])
60
+ )
61
+ else:
62
+ if len(present) != 3:
63
+ raise ValueError(
64
+ "differential statistics require complete SO(3) components"
65
+ )
66
+ runtime = _SO3Runtime(component, (np.zeros(1),) * 3)
67
+ quaternion = runtime._from_representation(raw)
68
+ if component.input_order == 0:
69
+ anchors = np.concatenate(
70
+ (np.array([[0.0, 0.0, 0.0, 1.0]]), quaternion[:-1])
71
+ )
72
+ increments = runtime._residual(quaternion, anchors)
73
+ else:
74
+ increments = quaternion
75
+ first = runtime._to_representation(increments)
76
+ if previous is None:
77
+ changes = runtime._residual(increments[1:], increments[:-1])
78
+ else:
79
+ anchors = np.concatenate(
80
+ (runtime._from_representation(previous[None]), increments[:-1])
81
+ )
82
+ changes = runtime._residual(increments, anchors)
83
+ second = runtime._to_representation(changes)
84
+ output[name] = present, first, second
85
+ return output
86
+
87
+
88
+ def bootstrap_second_order_trajectory(
89
+ trajectory: ActiveTrajectory, config: ActionCodec2Config
90
+ ) -> ActiveTrajectory:
91
+ """Use two observed positions as context and encode the remaining suffix.
92
+
93
+ Args:
94
+ trajectory: Canonical ``[T,D_active]`` absolute trajectory, ``T >= 3``.
95
+ The first two frames establish an exact measured first-order
96
+ boundary; they are context, not encoded targets.
97
+ config: Physical profile with order-0 input components.
98
+
99
+ Returns:
100
+ ActiveTrajectory: ``[T-2,D_active]`` local trajectory anchored at the
101
+ second observed frame, with explicit ``previous_first_order``. This is
102
+ an offline initialization helper, not a replacement for carrying the
103
+ reconstructed boundary between deployment chunks.
104
+ """
105
+ values, dims = prepare_trajectory(
106
+ trajectory, dimension=config.physical.input_dim, index=0
107
+ )
108
+ if len(values) < 3:
109
+ raise ValueError(
110
+ "second-order bootstrap requires two context frames and at least one target"
111
+ )
112
+ if trajectory.previous_first_order is not None:
113
+ raise ValueError(
114
+ "trajectory already has previous_first_order; do not bootstrap it again"
115
+ )
116
+ result = values[2:].copy()
117
+ previous = np.zeros(len(dims), dtype=np.float32)
118
+ for component in config.physical.components.values():
119
+ local = [dims.index(d) for d in component.dims if d in dims]
120
+ if not local:
121
+ continue
122
+ if component.input_order != 0:
123
+ raise ValueError("measured-context bootstrap requires input_order=0")
124
+ if component.primitive_order == 0:
125
+ continue
126
+ if component.space == "additive":
127
+ result[:, local] -= values[1, local]
128
+ previous[local] = values[1, local] - values[0, local]
129
+ else:
130
+ if len(local) != 3:
131
+ raise ValueError("bootstrap requires complete SO(3) components")
132
+ runtime = _SO3Runtime(component, (np.zeros(1),) * 3)
133
+ quaternion = runtime._from_representation(
134
+ values[:, local] * _scale(component)
135
+ )
136
+ increment = runtime._residual(quaternion[1:2], quaternion[:1])
137
+ # Rebase by a fixed group action that preserves the configured
138
+ # increment convention (body/right versus world/left).
139
+ from ._so3 import conjugate_quaternion, multiply_quaternions
140
+
141
+ anchor = conjugate_quaternion(quaternion[1:2])
142
+ local_path = (
143
+ multiply_quaternions(quaternion[2:], anchor)
144
+ if runtime.left
145
+ else multiply_quaternions(anchor, quaternion[2:])
146
+ )
147
+ result[:, local] = runtime._to_representation(local_path) / _scale(
148
+ component
149
+ )
150
+ previous[local] = runtime._to_representation(increment)[0] / _scale(
151
+ component
152
+ )
153
+ return ActiveTrajectory(result, dims, previous_first_order=previous)
runtime/physical/configuration.py ADDED
@@ -0,0 +1,794 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validated ``config.yaml`` model contract for ActionCodec2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from types import MappingProxyType
11
+
12
+ import yaml
13
+
14
+ from ..common._validation import validate_dimension_partition
15
+
16
+ CONFIG_NAME = "config.yaml"
17
+ # On-disk format identifier; retained because it participates in artifact fingerprints.
18
+ MODEL_TYPE = "actioncodec2_unified"
19
+ CONFIG_VERSION = 1
20
+ _SPACES = ("additive", "so3")
21
+ _SO3_REPRESENTATIONS = ("rotation_vector", "euler_xyz")
22
+ _SO3_MULTIPLICATIONS = ("left", "right")
23
+ _PRIMITIVE_SELECTIONS = ("nearest", "smooth")
24
+ _ORDER_KEY = re.compile(r"order_(0|[1-9][0-9]*)\Z")
25
+
26
+ NumberOrVector = float | tuple[float, ...]
27
+
28
+
29
+ class ConfigError(ValueError):
30
+ """The serialized model definition is incomplete or inconsistent."""
31
+
32
+
33
+ class _UniqueKeyLoader(yaml.SafeLoader):
34
+ """Safe YAML loader that rejects duplicate mapping keys."""
35
+
36
+
37
+ def _construct_unique_mapping(
38
+ loader: _UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False
39
+ ) -> dict[object, object]:
40
+ loader.flatten_mapping(node)
41
+ output: dict[object, object] = {}
42
+ for key_node, value_node in node.value:
43
+ key = loader.construct_object(key_node, deep=deep)
44
+ try:
45
+ duplicate = key in output
46
+ except TypeError as error:
47
+ raise ConfigError("config.yaml contains an invalid mapping key") from error
48
+ if duplicate:
49
+ raise ConfigError(f"config.yaml contains duplicate field {key!r}")
50
+ output[key] = loader.construct_object(value_node, deep=deep)
51
+ return output
52
+
53
+
54
+ _UniqueKeyLoader.add_constructor(
55
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
56
+ _construct_unique_mapping,
57
+ )
58
+
59
+
60
+ def _mapping(value: object, path: str) -> Mapping[str, object]:
61
+ if not isinstance(value, Mapping):
62
+ raise ConfigError(f"{path} must be a mapping")
63
+ if not all(isinstance(key, str) for key in value):
64
+ raise ConfigError(f"every field in {path} must be a string")
65
+ return value
66
+
67
+
68
+ def _reject_unknown(
69
+ payload: Mapping[str, object], allowed: set[str], path: str
70
+ ) -> None:
71
+ unknown = sorted(set(payload) - allowed)
72
+ if unknown:
73
+ rendered = ", ".join(unknown)
74
+ raise ConfigError(f"{path} contains unknown field(s): {rendered}")
75
+
76
+
77
+ def _required(payload: Mapping[str, object], key: str, path: str) -> object:
78
+ if key not in payload:
79
+ raise ConfigError(f"{path} is missing required field {key!r}")
80
+ return payload[key]
81
+
82
+
83
+ def _integer(value: object, path: str, *, positive: bool = False) -> int:
84
+ if isinstance(value, bool) or not isinstance(value, int):
85
+ raise ConfigError(f"{path} must be an integer")
86
+ minimum = 1 if positive else 0
87
+ if value < minimum:
88
+ qualifier = "positive" if positive else "non-negative"
89
+ raise ConfigError(f"{path} must be {qualifier}")
90
+ return int(value)
91
+
92
+
93
+ def _number(value: object, path: str) -> float:
94
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
95
+ raise ConfigError(f"{path} must be a number")
96
+ result = float(value)
97
+ if not math.isfinite(result):
98
+ raise ConfigError(f"{path} must be finite")
99
+ return result
100
+
101
+
102
+ def _number_or_vector(value: object, width: int, path: str) -> NumberOrVector:
103
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
104
+ result = tuple(
105
+ _number(item, f"{path}[{index}]") for index, item in enumerate(value)
106
+ )
107
+ if len(result) != width:
108
+ raise ConfigError(f"{path} must be a scalar or contain {width} values")
109
+ return result
110
+ return _number(value, path)
111
+
112
+
113
+ def _expanded(value: NumberOrVector, width: int) -> tuple[float, ...]:
114
+ if isinstance(value, tuple):
115
+ return value
116
+ return (value,) * width
117
+
118
+
119
+ def _serialize_number_or_vector(value: NumberOrVector) -> float | list[float]:
120
+ return list(value) if isinstance(value, tuple) else value
121
+
122
+
123
+ def _parse_dims(value: object, input_dim: int, path: str) -> tuple[int, ...]:
124
+ if isinstance(value, Mapping):
125
+ payload = _mapping(value, path)
126
+ _reject_unknown(payload, {"start", "stop"}, path)
127
+ start = _integer(_required(payload, "start", path), f"{path}.start")
128
+ stop = _integer(_required(payload, "stop", path), f"{path}.stop")
129
+ if stop <= start:
130
+ raise ConfigError(f"{path}.stop must be greater than {path}.start")
131
+ dims = tuple(range(start, stop))
132
+ elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
133
+ dims = tuple(
134
+ _integer(item, f"{path}[{index}]") for index, item in enumerate(value)
135
+ )
136
+ if not dims:
137
+ raise ConfigError(f"{path} must not be empty")
138
+ else:
139
+ raise ConfigError(f"{path} must be a list or a start/stop range")
140
+
141
+ if len(set(dims)) != len(dims):
142
+ raise ConfigError(f"{path} contains a repeated dimension")
143
+ outside = [dimension for dimension in dims if dimension >= input_dim]
144
+ if outside:
145
+ raise ConfigError(
146
+ f"{path} contains dimension {outside[0]}, outside input_dim={input_dim}"
147
+ )
148
+ return dims
149
+
150
+
151
+ def _serialize_dims(dims: tuple[int, ...]) -> object:
152
+ if len(dims) > 1 and dims == tuple(range(dims[0], dims[-1] + 1)):
153
+ return {"start": dims[0], "stop": dims[-1] + 1}
154
+ return list(dims)
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class ConstraintConfig:
159
+ """Allowed inclusive interval for one differential order."""
160
+
161
+ minimum: NumberOrVector
162
+ maximum: NumberOrVector
163
+
164
+ @classmethod
165
+ def from_dict(cls, payload: object, *, width: int, path: str) -> ConstraintConfig:
166
+ values = _mapping(payload, path)
167
+ _reject_unknown(values, {"min", "max"}, path)
168
+ minimum = _number_or_vector(
169
+ _required(values, "min", path), width, f"{path}.min"
170
+ )
171
+ maximum = _number_or_vector(
172
+ _required(values, "max", path), width, f"{path}.max"
173
+ )
174
+ if any(
175
+ lower > upper
176
+ for lower, upper in zip(
177
+ _expanded(minimum, width), _expanded(maximum, width), strict=True
178
+ )
179
+ ):
180
+ raise ConfigError(f"{path}.min must not exceed {path}.max")
181
+ return cls(minimum=minimum, maximum=maximum)
182
+
183
+ def to_dict(self) -> dict[str, object]:
184
+ return {
185
+ "min": _serialize_number_or_vector(self.minimum),
186
+ "max": _serialize_number_or_vector(self.maximum),
187
+ }
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class InputTransformConfig:
192
+ """Input-unit conversion and optional source-side clipping."""
193
+
194
+ scale: NumberOrVector = 1.0
195
+ clip: bool = False
196
+
197
+ @classmethod
198
+ def from_dict(
199
+ cls, payload: object | None, *, width: int, path: str
200
+ ) -> InputTransformConfig:
201
+ if payload is None:
202
+ return cls()
203
+ values = _mapping(payload, path)
204
+ _reject_unknown(values, {"scale", "clip"}, path)
205
+ scale = _number_or_vector(values.get("scale", 1.0), width, f"{path}.scale")
206
+ if any(value <= 0.0 for value in _expanded(scale, width)):
207
+ raise ConfigError(f"{path}.scale must be positive")
208
+ clip = values.get("clip", False)
209
+ if not isinstance(clip, bool):
210
+ raise ConfigError(f"{path}.clip must be true or false")
211
+ return cls(scale=scale, clip=clip)
212
+
213
+ def to_dict(self) -> dict[str, object]:
214
+ return {
215
+ "scale": _serialize_number_or_vector(self.scale),
216
+ "clip": self.clip,
217
+ }
218
+
219
+
220
+ def _parse_component_geometry(
221
+ values: Mapping[str, object], *, input_dim: int, path: str
222
+ ) -> tuple[tuple[int, ...], str, int, int, str | None, str | None]:
223
+ dims = _parse_dims(_required(values, "dims", path), input_dim, f"{path}.dims")
224
+ space = _required(values, "space", path)
225
+ if space not in _SPACES:
226
+ raise ConfigError(f"{path}.space must be one of {_SPACES}")
227
+ input_order = _integer(
228
+ _required(values, "input_order", path), f"{path}.input_order"
229
+ )
230
+ primitive_order = _integer(
231
+ _required(values, "primitive_order", path), f"{path}.primitive_order"
232
+ )
233
+ representation = values.get("representation")
234
+ multiplication = values.get("multiplication")
235
+ if space == "so3":
236
+ if len(dims) != 3:
237
+ raise ConfigError(f"{path} with space='so3' must contain exactly 3 dims")
238
+ if input_order not in {0, 1} or primitive_order not in {1, 2}:
239
+ raise ConfigError(
240
+ f"{path} with space='so3' supports input_order 0 or 1 and primitive_order 1 or 2"
241
+ )
242
+ if representation not in _SO3_REPRESENTATIONS:
243
+ raise ConfigError(
244
+ f"{path}.representation must be one of {_SO3_REPRESENTATIONS}"
245
+ )
246
+ if multiplication not in _SO3_MULTIPLICATIONS:
247
+ raise ConfigError(
248
+ f"{path}.multiplication must be one of {_SO3_MULTIPLICATIONS}"
249
+ )
250
+ else:
251
+ if input_order not in {0, 1, 2} or primitive_order not in {0, 1, 2}:
252
+ raise ConfigError(
253
+ f"{path} with space='additive' supports input_order and primitive_order 0, 1, or 2"
254
+ )
255
+ if representation is not None or multiplication is not None:
256
+ raise ConfigError(
257
+ f"{path}.representation and multiplication are only valid for space='so3'"
258
+ )
259
+ return (
260
+ dims,
261
+ str(space),
262
+ input_order,
263
+ primitive_order,
264
+ representation,
265
+ multiplication,
266
+ )
267
+
268
+
269
+ def _parse_component_constraints(
270
+ values: Mapping[str, object], *, width: int, path: str
271
+ ) -> dict[str, ConstraintConfig]:
272
+ raw = _mapping(_required(values, "constraints", path), f"{path}.constraints")
273
+ if not raw:
274
+ raise ConfigError(f"{path}.constraints must not be empty")
275
+ constraints: dict[str, ConstraintConfig] = {}
276
+ for key, constraint in raw.items():
277
+ if _ORDER_KEY.fullmatch(key) is None:
278
+ raise ConfigError(
279
+ f"{path}.constraints field {key!r} must be named order_0, order_1, ..."
280
+ )
281
+ constraints[key] = ConstraintConfig.from_dict(
282
+ constraint, width=width, path=f"{path}.constraints.{key}"
283
+ )
284
+ return constraints
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class ComponentConfig:
289
+ """One orthogonal physical component of the action vector.
290
+
291
+ ``dims`` defines the only global slots this component owns. ``input_order``
292
+ describes the dataset boundary, ``primitive_order`` describes the decoder
293
+ recursion, constraints are expressed after ``input_transform.scale``, and
294
+ ``distortion_limit`` is coordinate-wise for additive spaces but one
295
+ geodesic angle for SO(3).
296
+ """
297
+
298
+ dims: tuple[int, ...]
299
+ space: str
300
+ input_order: int
301
+ primitive_order: int
302
+ input_transform: InputTransformConfig
303
+ constraints: Mapping[str, ConstraintConfig]
304
+ distortion_limit: NumberOrVector
305
+ primitive_selection: str
306
+ representation: str | None = None
307
+ multiplication: str | None = None
308
+
309
+ @classmethod
310
+ def from_dict(
311
+ cls,
312
+ payload: object,
313
+ *,
314
+ input_dim: int,
315
+ path: str,
316
+ ) -> ComponentConfig:
317
+ values = _mapping(payload, path)
318
+ allowed = {
319
+ "dims",
320
+ "space",
321
+ "input_order",
322
+ "primitive_order",
323
+ "input_transform",
324
+ "constraints",
325
+ "distortion_limit",
326
+ "primitive_selection",
327
+ "representation",
328
+ "multiplication",
329
+ }
330
+ _reject_unknown(values, allowed, path)
331
+ dims, space, input_order, primitive_order, representation, multiplication = (
332
+ _parse_component_geometry(values, input_dim=input_dim, path=path)
333
+ )
334
+ width = len(dims)
335
+ constraints = _parse_component_constraints(values, width=width, path=path)
336
+
337
+ input_transform = InputTransformConfig.from_dict(
338
+ values.get("input_transform"),
339
+ width=width,
340
+ path=f"{path}.input_transform",
341
+ )
342
+ if input_transform.clip and f"order_{input_order}" not in constraints:
343
+ raise ConfigError(
344
+ f"{path}.input_transform.clip requires constraints.order_{input_order}"
345
+ )
346
+
347
+ distortion_limit = _number_or_vector(
348
+ _required(values, "distortion_limit", path),
349
+ width,
350
+ f"{path}.distortion_limit",
351
+ )
352
+ if any(value <= 0.0 for value in _expanded(distortion_limit, width)):
353
+ raise ConfigError(f"{path}.distortion_limit must be positive")
354
+ if space == "so3" and isinstance(distortion_limit, tuple):
355
+ raise ConfigError(
356
+ f"{path}.distortion_limit must be one scalar geodesic angle for SO(3)"
357
+ )
358
+
359
+ primitive_selection = _required(values, "primitive_selection", path)
360
+ if primitive_selection not in _PRIMITIVE_SELECTIONS:
361
+ raise ConfigError(f"{path}.primitive_selection must be nearest or smooth")
362
+ if (
363
+ primitive_selection == "smooth"
364
+ and space == "additive"
365
+ and primitive_order not in {1, 2}
366
+ ):
367
+ raise ConfigError(
368
+ f"{path}.primitive_selection='smooth' requires first- or second-order additive primitives"
369
+ )
370
+ if primitive_order == 2 and "order_2" not in constraints:
371
+ raise ConfigError(
372
+ f"{path} second-order primitives require constraints.order_2"
373
+ )
374
+ return cls(
375
+ dims=dims,
376
+ space=space,
377
+ input_order=input_order,
378
+ primitive_order=primitive_order,
379
+ input_transform=input_transform,
380
+ constraints=MappingProxyType(constraints),
381
+ distortion_limit=distortion_limit,
382
+ primitive_selection=str(primitive_selection),
383
+ representation=None if representation is None else str(representation),
384
+ multiplication=None if multiplication is None else str(multiplication),
385
+ )
386
+
387
+ def to_dict(self) -> dict[str, object]:
388
+ payload: dict[str, object] = {
389
+ "dims": _serialize_dims(self.dims),
390
+ "space": self.space,
391
+ "input_order": self.input_order,
392
+ "primitive_order": self.primitive_order,
393
+ "input_transform": self.input_transform.to_dict(),
394
+ "constraints": {
395
+ key: constraint.to_dict()
396
+ for key, constraint in self.constraints.items()
397
+ },
398
+ "distortion_limit": _serialize_number_or_vector(self.distortion_limit),
399
+ "primitive_selection": self.primitive_selection,
400
+ }
401
+ if self.space == "so3":
402
+ payload["representation"] = self.representation
403
+ payload["multiplication"] = self.multiplication
404
+ return payload
405
+
406
+
407
+ @dataclass(frozen=True)
408
+ class PhysicalConfig:
409
+ """Complete, non-overlapping component partition of the input's last axis."""
410
+
411
+ input_dim: int
412
+ components: Mapping[str, ComponentConfig]
413
+
414
+ @classmethod
415
+ def from_dict(cls, payload: object, *, path: str = "physical") -> PhysicalConfig:
416
+ values = _mapping(payload, path)
417
+ _reject_unknown(values, {"input_dim", "components"}, path)
418
+ input_dim = _integer(
419
+ _required(values, "input_dim", path), f"{path}.input_dim", positive=True
420
+ )
421
+ raw_components = _mapping(
422
+ _required(values, "components", path), f"{path}.components"
423
+ )
424
+ if not raw_components:
425
+ raise ConfigError(f"{path}.components must not be empty")
426
+
427
+ components: dict[str, ComponentConfig] = {}
428
+ for name, component_payload in raw_components.items():
429
+ if not name.strip():
430
+ raise ConfigError(f"{path}.components contains an empty component name")
431
+ component = ComponentConfig.from_dict(
432
+ component_payload,
433
+ input_dim=input_dim,
434
+ path=f"{path}.components.{name}",
435
+ )
436
+ components[name] = component
437
+
438
+ validate_dimension_partition(
439
+ ((name, component.dims) for name, component in components.items()),
440
+ input_dim,
441
+ label="physical dimension",
442
+ error_type=ConfigError,
443
+ )
444
+ return cls(input_dim=input_dim, components=MappingProxyType(components))
445
+
446
+ def to_dict(self) -> dict[str, object]:
447
+ return {
448
+ "input_dim": self.input_dim,
449
+ "components": {
450
+ name: component.to_dict() for name, component in self.components.items()
451
+ },
452
+ }
453
+
454
+
455
+ TOKENIZATION_SCHEMES = ("set_bpe", "bpe")
456
+
457
+
458
+ @dataclass(frozen=True)
459
+ class SetBPEConfig:
460
+ """Runtime settings of the tokenization stage.
461
+
462
+ ``scheme`` selects between rooted spatial-temporal Set-BPE (``"set_bpe"``,
463
+ the production default) and plain BPE over the row-major flattened cell
464
+ stream (``"bpe"``), the FAST-style ablation baseline. Both schemes share
465
+ the primitive atoms, the artifact layout, and the vocabulary budget.
466
+
467
+ ``lookahead`` caps a Set-BPE temporal token at that many control steps and
468
+ a plain-BPE token at ``lookahead * physical.input_dim`` primitive cells.
469
+
470
+ ``allow_cross_component_merge`` is frozen into the artifact. ``False``
471
+ makes each arm/body component a hard spatial and temporal BPE boundary,
472
+ while all components still contribute pooled frequencies to the same
473
+ joint or EEF vocabulary.
474
+ """
475
+
476
+ lookahead: int
477
+ allow_cross_component_merge: bool = False
478
+ scheme: str = "set_bpe"
479
+
480
+ @classmethod
481
+ def from_dict(cls, payload: object, *, path: str = "set_bpe") -> SetBPEConfig:
482
+ values = _mapping(payload, path)
483
+ _reject_unknown(
484
+ values, {"lookahead", "allow_cross_component_merge", "scheme"}, path
485
+ )
486
+ lookahead = _integer(
487
+ _required(values, "lookahead", path), f"{path}.lookahead", positive=True
488
+ )
489
+ allow_cross = values.get("allow_cross_component_merge", False)
490
+ if not isinstance(allow_cross, bool):
491
+ raise ConfigError(
492
+ f"{path}.allow_cross_component_merge must be true or false"
493
+ )
494
+ scheme = values.get("scheme", "set_bpe")
495
+ if scheme not in TOKENIZATION_SCHEMES:
496
+ raise ConfigError(
497
+ f"{path}.scheme must be one of {', '.join(TOKENIZATION_SCHEMES)}"
498
+ )
499
+ return cls(
500
+ lookahead=lookahead, allow_cross_component_merge=allow_cross, scheme=scheme
501
+ )
502
+
503
+ def to_dict(self) -> dict[str, object]:
504
+ payload: dict[str, object] = {
505
+ "lookahead": self.lookahead,
506
+ "allow_cross_component_merge": self.allow_cross_component_merge,
507
+ }
508
+ # Omitted for the default so every already-published artifact keeps its
509
+ # fingerprint; the field only appears on ablation artifacts.
510
+ if self.scheme != "set_bpe":
511
+ payload["scheme"] = self.scheme
512
+ return payload
513
+
514
+
515
+ @dataclass(frozen=True)
516
+ class PrimitiveTrainingConfig:
517
+ """Serialized config-v1 metadata retained until artifacts are versioned again.
518
+
519
+ Fitting forces exact zero on every primitive axis regardless of this list.
520
+ Keeping strict parsing here preserves the currently published artifact
521
+ fingerprint; the next schema version can remove the field and republish.
522
+ """
523
+
524
+ zero_primitive_components: tuple[str, ...]
525
+
526
+ @classmethod
527
+ def from_dict(
528
+ cls,
529
+ payload: object,
530
+ *,
531
+ physical: PhysicalConfig,
532
+ path: str = "train.primitives",
533
+ ) -> PrimitiveTrainingConfig:
534
+ values = _mapping(payload, path)
535
+ _reject_unknown(values, {"zero_primitive_components"}, path)
536
+ raw_names = _required(values, "zero_primitive_components", path)
537
+ if not isinstance(raw_names, Sequence) or isinstance(
538
+ raw_names, (str, bytes, bytearray)
539
+ ):
540
+ raise ConfigError(f"{path}.zero_primitive_components must be a list")
541
+ names = tuple(raw_names)
542
+ if not all(isinstance(name, str) and name for name in names):
543
+ raise ConfigError(
544
+ f"{path}.zero_primitive_components must contain non-empty component names"
545
+ )
546
+ if len(set(names)) != len(names):
547
+ raise ConfigError(f"{path}.zero_primitive_components contains a duplicate")
548
+ unknown = sorted(set(names) - set(physical.components))
549
+ if unknown:
550
+ raise ConfigError(
551
+ f"{path}.zero_primitive_components contains unknown component {unknown[0]!r}"
552
+ )
553
+
554
+ selected = set(names)
555
+ for name, component in physical.components.items():
556
+ if component.space == "additive":
557
+ if component.primitive_order not in {0, 1, 2}:
558
+ raise ConfigError(
559
+ f"{path} cannot train additive primitive_order="
560
+ f"{component.primitive_order} for component {name!r}"
561
+ )
562
+ domain_order = component.primitive_order
563
+ if name in selected and component.primitive_order == 0:
564
+ raise ConfigError(
565
+ f"{path}.zero_primitive_components cannot contain absolute "
566
+ f"component {name!r}"
567
+ )
568
+ else:
569
+ domain_order = component.primitive_order
570
+ if f"order_{domain_order}" not in component.constraints:
571
+ raise ConfigError(
572
+ f"physical component {name!r} needs constraints.order_{domain_order} "
573
+ "to train its primitives"
574
+ )
575
+ return cls(zero_primitive_components=names)
576
+
577
+ def to_dict(self) -> dict[str, object]:
578
+ return {"zero_primitive_components": list(self.zero_primitive_components)}
579
+
580
+
581
+ @dataclass(frozen=True)
582
+ class BPETrainingConfig:
583
+ """Spatial and final vocabulary budgets used for whole-trajectory BPE.
584
+
585
+ ``spatial_vocab_size`` splits the budget between the two Set-BPE stages and
586
+ is unused by the plain-BPE scheme, which has a single merge stage.
587
+ """
588
+
589
+ spatial_vocab_size: int
590
+ vocab_size: int
591
+
592
+ @classmethod
593
+ def from_dict(
594
+ cls, payload: object, *, path: str = "train.bpe"
595
+ ) -> BPETrainingConfig:
596
+ values = _mapping(payload, path)
597
+ _reject_unknown(values, {"spatial_vocab_size", "vocab_size"}, path)
598
+ spatial_vocab_size = _integer(
599
+ _required(values, "spatial_vocab_size", path),
600
+ f"{path}.spatial_vocab_size",
601
+ positive=True,
602
+ )
603
+ vocab_size = _integer(
604
+ _required(values, "vocab_size", path), f"{path}.vocab_size", positive=True
605
+ )
606
+ if spatial_vocab_size > vocab_size:
607
+ raise ConfigError(f"{path}.spatial_vocab_size must not exceed vocab_size")
608
+ return cls(spatial_vocab_size=spatial_vocab_size, vocab_size=vocab_size)
609
+
610
+ def to_dict(self) -> dict[str, object]:
611
+ return {
612
+ "spatial_vocab_size": self.spatial_vocab_size,
613
+ "vocab_size": self.vocab_size,
614
+ }
615
+
616
+
617
+ @dataclass(frozen=True)
618
+ class QualityControlConfig:
619
+ """Pretraining-only policy for extreme first-order discontinuities.
620
+
621
+ ``constraints.order_1`` remains the primitive domain and therefore the
622
+ region with the configured distortion guarantee. Values outside that
623
+ domain are still encodable through saturated primitives and causal error
624
+ feedback. Only steps beyond ``hard_order_1_scale`` times the primitive
625
+ domain are treated as data discontinuities and split before BPE fitting.
626
+ """
627
+
628
+ hard_order_1_scale: float
629
+ min_segment_length: int
630
+
631
+ @classmethod
632
+ def from_dict(
633
+ cls, payload: object, *, path: str = "train.quality_control"
634
+ ) -> QualityControlConfig:
635
+ values = _mapping(payload, path)
636
+ _reject_unknown(values, {"hard_order_1_scale", "min_segment_length"}, path)
637
+ scale = _number(
638
+ _required(values, "hard_order_1_scale", path),
639
+ f"{path}.hard_order_1_scale",
640
+ )
641
+ if scale <= 1.0:
642
+ raise ConfigError(f"{path}.hard_order_1_scale must be greater than 1")
643
+ return cls(
644
+ hard_order_1_scale=scale,
645
+ min_segment_length=_integer(
646
+ _required(values, "min_segment_length", path),
647
+ f"{path}.min_segment_length",
648
+ positive=True,
649
+ ),
650
+ )
651
+
652
+ def to_dict(self) -> dict[str, object]:
653
+ return {
654
+ "hard_order_1_scale": self.hard_order_1_scale,
655
+ "min_segment_length": self.min_segment_length,
656
+ }
657
+
658
+
659
+ @dataclass(frozen=True)
660
+ class TrainConfig:
661
+ """Non-derivable fit choices and BPE budgets.
662
+
663
+ Physical domains and distortion limits are intentionally absent: primitive
664
+ construction derives them from :class:`PhysicalConfig`, preventing two
665
+ conflicting sources of truth.
666
+ """
667
+
668
+ primitives: PrimitiveTrainingConfig
669
+ bpe: BPETrainingConfig
670
+ quality_control: QualityControlConfig | None = None
671
+
672
+ @classmethod
673
+ def from_dict(
674
+ cls,
675
+ payload: object,
676
+ *,
677
+ physical: PhysicalConfig,
678
+ path: str = "train",
679
+ ) -> TrainConfig:
680
+ values = _mapping(payload, path)
681
+ _reject_unknown(values, {"primitives", "bpe", "quality_control"}, path)
682
+ raw_quality_control = values.get("quality_control")
683
+ return cls(
684
+ primitives=PrimitiveTrainingConfig.from_dict(
685
+ _required(values, "primitives", path), physical=physical
686
+ ),
687
+ bpe=BPETrainingConfig.from_dict(_required(values, "bpe", path)),
688
+ quality_control=(
689
+ None
690
+ if raw_quality_control is None
691
+ else QualityControlConfig.from_dict(raw_quality_control)
692
+ ),
693
+ )
694
+
695
+ def to_dict(self) -> dict[str, object]:
696
+ payload = {"primitives": self.primitives.to_dict(), "bpe": self.bpe.to_dict()}
697
+ if self.quality_control is not None:
698
+ payload["quality_control"] = self.quality_control.to_dict()
699
+ return payload
700
+
701
+
702
+ @dataclass(frozen=True)
703
+ class ActionCodec2Config:
704
+ """Strict YAML configuration for an ActionCodec2 processor artifact.
705
+
706
+ ``physical`` and ``set_bpe`` are required at deployment. ``train`` is an
707
+ optional reproducibility recipe and is never consulted by encode/decode.
708
+ This is a processor configuration, not a neural ``PretrainedConfig``;
709
+ ``config.json`` exists only as Hugging Face AutoProcessor discovery metadata.
710
+ """
711
+
712
+ physical: PhysicalConfig
713
+ set_bpe: SetBPEConfig
714
+ train: TrainConfig | None = None
715
+ model_type: str = MODEL_TYPE
716
+ config_version: int = CONFIG_VERSION
717
+
718
+ @classmethod
719
+ def from_dict(cls, payload: object) -> ActionCodec2Config:
720
+ values = _mapping(payload, "config")
721
+ _reject_unknown(
722
+ values,
723
+ {"model_type", "config_version", "physical", "set_bpe", "train"},
724
+ "config",
725
+ )
726
+ model_type = _required(values, "model_type", "config")
727
+ if model_type != MODEL_TYPE:
728
+ raise ConfigError(f"config.model_type must be {MODEL_TYPE!r}")
729
+ config_version = _integer(
730
+ _required(values, "config_version", "config"), "config.config_version"
731
+ )
732
+ if config_version != CONFIG_VERSION:
733
+ raise ConfigError(
734
+ f"unsupported config_version {config_version}; expected {CONFIG_VERSION}"
735
+ )
736
+ physical = PhysicalConfig.from_dict(_required(values, "physical", "config"))
737
+ raw_train = values.get("train")
738
+ return cls(
739
+ physical=physical,
740
+ set_bpe=SetBPEConfig.from_dict(_required(values, "set_bpe", "config")),
741
+ train=(
742
+ None
743
+ if raw_train is None
744
+ else TrainConfig.from_dict(raw_train, physical=physical)
745
+ ),
746
+ model_type=str(model_type),
747
+ config_version=config_version,
748
+ )
749
+
750
+ @classmethod
751
+ def from_yaml(cls, path: str | Path) -> ActionCodec2Config:
752
+ source = Path(path)
753
+ try:
754
+ payload = yaml.load(
755
+ source.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader
756
+ )
757
+ except OSError:
758
+ raise
759
+ except yaml.YAMLError as error:
760
+ raise ConfigError(f"cannot parse {source}: {error}") from error
761
+ return cls.from_dict(payload)
762
+
763
+ @classmethod
764
+ def from_pretrained(cls, directory: str | Path) -> ActionCodec2Config:
765
+ """Load the strict numerical configuration from ``config.yaml``."""
766
+ return cls.from_yaml(Path(directory) / CONFIG_NAME)
767
+
768
+ def to_dict(self) -> dict[str, object]:
769
+ payload: dict[str, object] = {
770
+ "model_type": self.model_type,
771
+ "config_version": self.config_version,
772
+ "physical": self.physical.to_dict(),
773
+ "set_bpe": self.set_bpe.to_dict(),
774
+ }
775
+ if self.train is not None:
776
+ payload["train"] = self.train.to_dict()
777
+ return payload
778
+
779
+ def save_yaml(self, path: str | Path) -> None:
780
+ destination = Path(path)
781
+ destination.parent.mkdir(parents=True, exist_ok=True)
782
+ destination.write_text(
783
+ yaml.safe_dump(
784
+ self.to_dict(),
785
+ sort_keys=False,
786
+ allow_unicode=True,
787
+ default_flow_style=False,
788
+ ),
789
+ encoding="utf-8",
790
+ )
791
+
792
+ def save_pretrained(self, directory: str | Path) -> None:
793
+ """Write ``config.yaml`` without embedding primitive or BPE arrays."""
794
+ self.save_yaml(Path(directory) / CONFIG_NAME)
runtime/physical/physical_codec.py ADDED
@@ -0,0 +1,950 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration-driven physical quantization.
2
+
3
+ This module owns only the lossy physical stage. Components receive transformed
4
+ actions and emit one primitive cell per global action dimension; Set-BPE never
5
+ appears here. Batch dimensions are vectorized, while the short time recursion
6
+ remains explicit because error feedback is inherently causal.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+ import os
13
+ from typing import Protocol
14
+
15
+ import numpy as np
16
+
17
+ from ._so3 import conjugate_quaternion as _conjugate
18
+ from ._so3 import euler_xyz_to_quaternion as _euler_xyz_to_quaternion
19
+ from ._so3 import multiply_quaternions as _multiply
20
+ from ._so3 import quaternion_to_euler_xyz as _quaternion_to_euler_xyz
21
+ from ._so3 import quaternion_to_rotvec as _quaternion_to_rotvec
22
+ from ._so3 import rotvec_to_quaternion as _rotvec_to_quaternion
23
+ from ..common._validation import broadcast_vector
24
+ from .configuration import ActionCodec2Config, ComponentConfig, ConfigError
25
+ from ..backend.native_backend import require_native, resolve_backend
26
+ from .primitives import PrimitiveStore
27
+
28
+ _FLOAT32_SLACK = np.float32(8 * np.finfo(np.float32).eps)
29
+ _IDENTITY = np.asarray((0.0, 0.0, 0.0, 1.0), dtype=np.float64)
30
+
31
+
32
+ def _constraint(
33
+ component: ComponentConfig, order: int
34
+ ) -> tuple[np.ndarray, np.ndarray] | None:
35
+ value = component.constraints.get(f"order_{order}")
36
+ if value is None:
37
+ return None
38
+ return (
39
+ np.asarray(value.minimum, dtype=np.float32),
40
+ np.asarray(value.maximum, dtype=np.float32),
41
+ )
42
+
43
+
44
+ def _scale(component: ComponentConfig) -> np.ndarray:
45
+ return broadcast_vector(
46
+ component.input_transform.scale,
47
+ len(component.dims),
48
+ dtype=np.float32,
49
+ name="input_transform.scale",
50
+ )
51
+
52
+
53
+ def _transform(component: ComponentConfig, actions: np.ndarray) -> np.ndarray:
54
+ """Apply the component's unit conversion and encoder-only clipping."""
55
+ values = np.asarray(actions, dtype=np.float32) * _scale(component)
56
+ if component.input_transform.clip:
57
+ bounds = _constraint(component, component.input_order)
58
+ if bounds is None: # defensive: configuration validation normally catches this
59
+ raise ConfigError(
60
+ f"clipping requires constraints.order_{component.input_order}"
61
+ )
62
+ values = np.clip(values, bounds[0], bounds[1])
63
+ return np.asarray(values, dtype=np.float32)
64
+
65
+
66
+ def _integrate_additive(values: np.ndarray, order: int) -> np.ndarray:
67
+ """Integrate along the penultimate (time) axis from a zero boundary."""
68
+ result = np.asarray(values, dtype=np.float32)
69
+ for _ in range(order):
70
+ result = np.cumsum(result, axis=-2, dtype=np.float32)
71
+ return result
72
+
73
+
74
+ def _difference_additive(values: np.ndarray, order: int) -> np.ndarray:
75
+ """Take zero-anchored finite differences along the time axis."""
76
+ result = np.asarray(values, dtype=np.float32)
77
+ for _ in range(order):
78
+ previous = np.concatenate(
79
+ (np.zeros_like(result[..., :1, :]), result[..., :-1, :]), axis=-2
80
+ )
81
+ result = result - previous
82
+ return result
83
+
84
+
85
+ class _ComponentRuntime(Protocol):
86
+ """Internal interface shared by additive and SO(3) components."""
87
+
88
+ def encode_batch(
89
+ self, actions: np.ndarray, previous_first_order: np.ndarray | None = None
90
+ ) -> np.ndarray: ...
91
+
92
+ def decode_batch(
93
+ self, cells: np.ndarray, previous_first_order: np.ndarray | None = None
94
+ ) -> np.ndarray: ...
95
+
96
+
97
+ class _AdditiveRuntime:
98
+ """Causal Euclidean error-feedback quantizer for one component."""
99
+
100
+ def __init__(
101
+ self, component: ComponentConfig, primitives: tuple[np.ndarray, ...]
102
+ ) -> None:
103
+ self.config = component
104
+ self.primitives = tuple(
105
+ np.asarray(value, dtype=np.float32) for value in primitives
106
+ )
107
+ self.width = len(component.dims)
108
+ self.order = component.primitive_order
109
+ self._second_order_axes = (
110
+ tuple(axis.astype(np.float64) for axis in self.primitives)
111
+ if self.order == 2
112
+ else ()
113
+ )
114
+ self._second_order_sorted = tuple(
115
+ bool(np.all(axis[1:] > axis[:-1])) for axis in self._second_order_axes
116
+ )
117
+ self._second_order_zero_bounds = tuple(
118
+ (
119
+ int(np.searchsorted(axis, 0)),
120
+ int(np.searchsorted(axis, 0, side="right")) - 1,
121
+ )
122
+ if ordered
123
+ else (0, 0)
124
+ for axis, ordered in zip(
125
+ self._second_order_axes, self._second_order_sorted, strict=True
126
+ )
127
+ )
128
+ raw_position_bounds = _constraint(component, 0)
129
+ self.position_bounds = (
130
+ None
131
+ if raw_position_bounds is None
132
+ else (
133
+ np.broadcast_to(raw_position_bounds[0], (self.width,)).copy(),
134
+ np.broadcast_to(raw_position_bounds[1], (self.width,)).copy(),
135
+ )
136
+ )
137
+ self.limit = broadcast_vector(
138
+ component.distortion_limit,
139
+ self.width,
140
+ dtype=np.float32,
141
+ name="distortion_limit",
142
+ )
143
+ table_width = max(len(values) for values in self.primitives)
144
+ table = np.full((self.width, table_width), np.inf, dtype=np.float32)
145
+ for coordinate, values in enumerate(self.primitives):
146
+ table[coordinate, : len(values)] = values
147
+ self._native_table = np.ascontiguousarray(table)
148
+ self._native_bins = np.asarray(
149
+ [len(values) for values in self.primitives], dtype=np.int32
150
+ )
151
+ self._native_binary = np.zeros(self.width, dtype=np.uint8)
152
+
153
+ def _pick_batch(self, request: np.ndarray, direction: np.ndarray) -> np.ndarray:
154
+ """Select cells for an arbitrary leading batch shape."""
155
+ flat_request = np.asarray(request, dtype=np.float32).reshape(-1, self.width)
156
+ flat_direction = np.asarray(direction, dtype=np.float32).reshape(-1, self.width)
157
+ choice = np.empty(flat_request.shape, dtype=np.int64)
158
+ for coordinate, primitives in enumerate(self.primitives):
159
+ distance = np.abs(flat_request[:, coordinate, None] - primitives[None, :])
160
+ nearest = np.argmin(distance, axis=1)
161
+ if self.config.primitive_selection == "nearest":
162
+ choice[:, coordinate] = nearest
163
+ continue
164
+ feasible = distance <= self.limit[coordinate] + _FLOAT32_SLACK
165
+ preferred = feasible & (
166
+ np.sign(primitives)[None, :] * flat_direction[:, coordinate, None]
167
+ >= 0.0
168
+ )
169
+ candidates = np.where(
170
+ np.any(preferred, axis=1, keepdims=True), preferred, feasible
171
+ )
172
+ smooth = np.argmin(np.where(candidates, distance, np.inf), axis=1)
173
+ choice[:, coordinate] = np.where(np.any(feasible, axis=1), smooth, nearest)
174
+ return choice.reshape(request.shape)
175
+
176
+ def _advance_batch(self, state: np.ndarray, primitive: np.ndarray) -> None:
177
+ """Advance all integrator states in-place by one timestep."""
178
+ higher = primitive
179
+ for level in range(self.order - 1, -1, -1):
180
+ state[:, level] += higher
181
+ higher = state[:, level]
182
+ if self.order == 1 and self.position_bounds is not None:
183
+ state[:, 0] = np.clip(
184
+ state[:, 0], self.position_bounds[0], self.position_bounds[1]
185
+ )
186
+
187
+ def encode_batch(
188
+ self, actions: np.ndarray, previous_first_order: np.ndarray | None = None
189
+ ) -> np.ndarray:
190
+ """Quantize ``[B,T,d]`` actions, vectorizing all trajectories per step."""
191
+ if self.order == 2:
192
+ return self._encode_second_order(actions, previous_first_order)
193
+ transformed = _transform(self.config, actions)
194
+ target = _integrate_additive(transformed, self.config.input_order)
195
+ batch, steps, _ = target.shape
196
+ if self.order == 0:
197
+ return self._pick_batch(target, np.zeros_like(target))
198
+ if self.position_bounds is not None and resolve_backend("auto") == "native":
199
+ starts = np.zeros((batch, self.width), dtype=np.float32)
200
+ cells = require_native().physical_encode_batch(
201
+ self._native_table,
202
+ self._native_bins,
203
+ self._native_binary,
204
+ np.ascontiguousarray(self.position_bounds[0], dtype=np.float32),
205
+ np.ascontiguousarray(self.position_bounds[1], dtype=np.float32),
206
+ np.ascontiguousarray(target, dtype=np.float32),
207
+ starts,
208
+ starts,
209
+ self.order,
210
+ min(batch, os.cpu_count() or 1),
211
+ np.ascontiguousarray(self.limit, dtype=np.float32),
212
+ self.config.primitive_selection == "smooth",
213
+ )
214
+ return np.asarray(cells, dtype=np.int64)
215
+
216
+ cells = np.empty(target.shape, dtype=np.int64)
217
+ state = np.zeros((batch, self.order, self.width), dtype=np.float32)
218
+ direction = np.zeros((batch, self.width), dtype=np.float32)
219
+ zero = np.zeros((batch, self.width), dtype=np.float32)
220
+ for step in range(steps):
221
+ baseline = state.copy()
222
+ self._advance_batch(baseline, zero)
223
+ request = target[:, step] - baseline[:, 0]
224
+ choice = self._pick_batch(request, direction)
225
+ primitive = np.stack(
226
+ [
227
+ self.primitives[index][choice[:, index]]
228
+ for index in range(self.width)
229
+ ],
230
+ axis=1,
231
+ )
232
+ self._advance_batch(state, primitive)
233
+ moved = primitive != 0.0
234
+ direction = np.where(moved, np.sign(primitive), direction)
235
+ cells[:, step] = choice
236
+ return cells
237
+
238
+ def decode_batch(
239
+ self, cells: np.ndarray, previous_first_order: np.ndarray | None = None
240
+ ) -> np.ndarray:
241
+ """Decode ``[B,T,d]`` cells to actions in the component's input units."""
242
+ if self.order == 2:
243
+ return self._decode_second_order(cells, previous_first_order)
244
+ indices = np.asarray(cells, dtype=np.int64)
245
+ if self.order == 0:
246
+ positions = np.stack(
247
+ [
248
+ self.primitives[index][indices[:, :, index]]
249
+ for index in range(self.width)
250
+ ],
251
+ axis=2,
252
+ )
253
+ elif self.position_bounds is not None and resolve_backend("auto") == "native":
254
+ batch = len(indices)
255
+ starts = np.zeros((batch, self.width), dtype=np.float32)
256
+ positions = require_native().physical_decode_batch(
257
+ self._native_table,
258
+ self._native_bins,
259
+ self._native_binary,
260
+ np.ascontiguousarray(self.position_bounds[0], dtype=np.float32),
261
+ np.ascontiguousarray(self.position_bounds[1], dtype=np.float32),
262
+ np.ascontiguousarray(indices, dtype=np.int32),
263
+ starts,
264
+ starts,
265
+ self.order,
266
+ min(batch, os.cpu_count() or 1),
267
+ )
268
+ else:
269
+ picked = np.stack(
270
+ [
271
+ self.primitives[index][indices[:, :, index]]
272
+ for index in range(self.width)
273
+ ],
274
+ axis=2,
275
+ )
276
+ batch, steps, _ = indices.shape
277
+ positions = np.empty(indices.shape, dtype=np.float32)
278
+ state = np.zeros((batch, self.order, self.width), dtype=np.float32)
279
+ for step in range(steps):
280
+ self._advance_batch(state, picked[:, step])
281
+ positions[:, step] = state[:, 0]
282
+ actions = _difference_additive(positions, self.config.input_order)
283
+ return np.asarray(actions / _scale(self.config), dtype=np.float32)
284
+
285
+ def _encode_second_order(
286
+ self, actions: np.ndarray, previous_first_order: np.ndarray
287
+ ) -> np.ndarray:
288
+ """Track positions from a supplied ``[B,d]`` reconstructed displacement.
289
+
290
+ Double integration uses float64 to avoid horizon-dependent float32
291
+ cancellation. Source clipping never changes the shared boundary.
292
+ """
293
+ transformed = _transform(self.config, actions).astype(np.float64)
294
+ velocity = previous_first_order.astype(np.float64) * _scale(self.config)
295
+ target = transformed
296
+ if self.config.input_order == 2:
297
+ target = np.cumsum(target, axis=1) + velocity[:, None]
298
+ if self.config.input_order >= 1:
299
+ target = np.cumsum(target, axis=1)
300
+ if resolve_backend("auto") == "native" and hasattr(
301
+ require_native(), "additive_second_order_encode_batch"
302
+ ):
303
+ return np.asarray(
304
+ require_native().additive_second_order_encode_batch(
305
+ self._native_table,
306
+ self._native_bins,
307
+ np.ascontiguousarray(target),
308
+ np.ascontiguousarray(velocity),
309
+ self.limit,
310
+ self.config.primitive_selection == "smooth",
311
+ min(
312
+ len(actions),
313
+ max(
314
+ 1,
315
+ int(os.environ.get("ACTIONCODEC2_PHYSICAL_THREADS", "16")),
316
+ ),
317
+ ),
318
+ ),
319
+ dtype=np.int64,
320
+ )
321
+ position = np.zeros_like(velocity)
322
+ direction = np.zeros_like(velocity)
323
+ cells = np.empty(actions.shape, dtype=np.int64)
324
+ for step in range(actions.shape[1]):
325
+ request = target[:, step] - (position + velocity)
326
+ # Keep the feedback calculation in double precision, including
327
+ # distances near the boundary of the admissible error interval.
328
+ choice = self._pick_second_order(request, direction)
329
+ primitive = np.stack(
330
+ [axis[choice[:, j]] for j, axis in enumerate(self.primitives)], axis=1
331
+ )
332
+ velocity += primitive
333
+ position += velocity
334
+ direction = np.where(primitive != 0, np.sign(primitive), direction)
335
+ cells[:, step] = choice
336
+ return cells
337
+
338
+ def _pick_second_order(
339
+ self, request: np.ndarray, direction: np.ndarray
340
+ ) -> np.ndarray:
341
+ choice = np.empty(request.shape, dtype=np.int64)
342
+ for j, axis in enumerate(self._second_order_axes):
343
+ # A tiny nearest-only table is faster as one NumPy reduction.
344
+ if self._second_order_sorted[j] and (
345
+ self.config.primitive_selection == "smooth" or len(axis) > 32
346
+ ):
347
+ right = np.searchsorted(axis, request[:, j])
348
+ left = np.clip(right - 1, 0, len(axis) - 1)
349
+ right = np.minimum(right, len(axis) - 1)
350
+ nearest = np.where(
351
+ np.abs(request[:, j] - axis[left])
352
+ <= np.abs(request[:, j] - axis[right]),
353
+ left,
354
+ right,
355
+ )
356
+ # Very large requests can round several distances to the same
357
+ # float64 value. Preserve argmin's first-cell tie-break there.
358
+ tied = (nearest > 0) & (
359
+ np.abs(request[:, j] - axis[np.maximum(nearest - 1, 0)])
360
+ == np.abs(request[:, j] - axis[nearest])
361
+ )
362
+ if np.any(tied):
363
+ nearest[tied] = np.argmin(
364
+ np.abs(request[tied, j, None] - axis), axis=1
365
+ )
366
+ if self.config.primitive_selection == "smooth":
367
+ nonnegative, nonpositive = self._second_order_zero_bounds[j]
368
+ preferred = np.where(
369
+ direction[:, j] > 0,
370
+ np.maximum(nearest, min(nonnegative, len(axis) - 1)),
371
+ np.where(
372
+ direction[:, j] < 0,
373
+ np.minimum(nearest, max(nonpositive, 0)),
374
+ nearest,
375
+ ),
376
+ )
377
+ admissible = (np.sign(axis[preferred]) * direction[:, j] >= 0) & (
378
+ np.abs(request[:, j] - axis[preferred]) <= self.limit[j]
379
+ )
380
+ nearest = np.where(admissible, preferred, nearest)
381
+ choice[:, j] = nearest
382
+ continue
383
+ # Arbitrary stored cell order retains the original ID tie-break.
384
+ distance = np.abs(request[:, j, None] - axis)
385
+ nearest = np.argmin(distance, axis=1)
386
+ if self.config.primitive_selection == "nearest":
387
+ choice[:, j] = nearest
388
+ continue
389
+ feasible = distance <= self.limit[j]
390
+ preferred = feasible & (np.sign(axis)[None] * direction[:, j, None] >= 0)
391
+ candidates = np.where(
392
+ preferred.any(axis=1, keepdims=True), preferred, feasible
393
+ )
394
+ smooth = np.argmin(np.where(candidates, distance, np.inf), axis=1)
395
+ choice[:, j] = np.where(feasible.any(axis=1), smooth, nearest)
396
+ return choice
397
+
398
+ def _decode_second_order(
399
+ self, cells: np.ndarray, previous_first_order: np.ndarray
400
+ ) -> np.ndarray:
401
+ """Reconstruct ``[B,T,d]`` at the configured input differential order."""
402
+ picked = np.stack(
403
+ [axis[cells[:, :, j]] for j, axis in enumerate(self.primitives)], axis=2
404
+ ).astype(np.float64)
405
+ if self.config.input_order == 2:
406
+ result = picked
407
+ else:
408
+ result = np.cumsum(picked, axis=1) + previous_first_order[:, None] * _scale(
409
+ self.config
410
+ )
411
+ if self.config.input_order == 0:
412
+ result = np.cumsum(result, axis=1)
413
+ return np.asarray(result / _scale(self.config), dtype=np.float32)
414
+
415
+
416
+ class _SO3Runtime:
417
+ """Intrinsic rotation quantizer with left- or right-multiplicative dynamics."""
418
+
419
+ def __init__(
420
+ self, component: ComponentConfig, primitives: tuple[np.ndarray, ...]
421
+ ) -> None:
422
+ self.config = component
423
+ self.primitives = tuple(
424
+ np.asarray(value, dtype=np.float64) for value in primitives
425
+ )
426
+ self.left = component.multiplication == "left"
427
+ self.limit = float(component.distortion_limit)
428
+ if component.primitive_order == 2:
429
+ bounds = component.constraints["order_2"]
430
+ corner = np.maximum(np.abs(bounds.minimum), np.abs(bounds.maximum))
431
+ corner = np.broadcast_to(corner, (3,))
432
+ radius = (
433
+ np.linalg.norm(corner)
434
+ if component.representation == "rotation_vector"
435
+ else np.sum(corner)
436
+ )
437
+ self.second_order_radius = min(np.pi, float(radius) + 3 * self.limit)
438
+ table_width = max(len(values) for values in self.primitives)
439
+ table = np.full((3, table_width), np.inf, dtype=np.float64)
440
+ for coordinate, values in enumerate(self.primitives):
441
+ table[coordinate, : len(values)] = values
442
+ self._native_table = table
443
+ self._native_bins = np.asarray(
444
+ [len(values) for values in self.primitives], dtype=np.int32
445
+ )
446
+
447
+ def _from_representation(self, values: np.ndarray) -> np.ndarray:
448
+ shape = values.shape[:-1]
449
+ flat = np.asarray(values).reshape(-1, 3)
450
+ converted = (
451
+ _rotvec_to_quaternion(flat)
452
+ if self.config.representation == "rotation_vector"
453
+ else _euler_xyz_to_quaternion(flat)
454
+ )
455
+ return converted.reshape(*shape, 4)
456
+
457
+ def _to_representation(self, values: np.ndarray) -> np.ndarray:
458
+ shape = values.shape[:-1]
459
+ flat = np.asarray(values).reshape(-1, 4)
460
+ converted = (
461
+ _quaternion_to_rotvec(flat)
462
+ if self.config.representation == "rotation_vector"
463
+ else _quaternion_to_euler_xyz(flat)
464
+ )
465
+ return converted.reshape(*shape, 3)
466
+
467
+ def _residual(self, target: np.ndarray, reconstruction: np.ndarray) -> np.ndarray:
468
+ return (
469
+ _multiply(target, _conjugate(reconstruction))
470
+ if self.left
471
+ else _multiply(_conjugate(reconstruction), target)
472
+ )
473
+
474
+ def _apply(self, state: np.ndarray, increment: np.ndarray) -> np.ndarray:
475
+ return _multiply(increment, state) if self.left else _multiply(state, increment)
476
+
477
+ def _nearest_batch(self, request: np.ndarray) -> np.ndarray:
478
+ return np.stack(
479
+ [
480
+ np.argmin(np.abs(request[:, index, None] - values[None, :]), axis=1)
481
+ for index, values in enumerate(self.primitives)
482
+ ],
483
+ axis=1,
484
+ ).astype(np.int64, copy=False)
485
+
486
+ def _smooth(
487
+ self,
488
+ request: np.ndarray,
489
+ reconstruction: np.ndarray,
490
+ target: np.ndarray,
491
+ direction: np.ndarray,
492
+ nearest: np.ndarray,
493
+ ) -> np.ndarray:
494
+ """Apply the exact admissible/non-reversing SO(3) selection rule.
495
+
496
+ On the principal rotation-vector ball, the logarithm's co-Lipschitz
497
+ bound implies that a geodesically admissible primitive lies within
498
+ ``2*limit`` in every log coordinate. This prunes the Cartesian lattice
499
+ without the former arbitrary candidate-count cap. Every retained tuple
500
+ is then checked with the actual geodesic error.
501
+ """
502
+ axes = []
503
+ for coordinate, values in enumerate(self.primitives):
504
+ candidates = np.flatnonzero(
505
+ np.abs(values - request[coordinate]) <= 2.0 * self.limit + 1e-12
506
+ )
507
+ if nearest[coordinate] not in candidates:
508
+ candidates = np.append(candidates, nearest[coordinate])
509
+ axes.append(candidates)
510
+
511
+ best_any: tuple[float, tuple[int, int, int]] | None = None
512
+ best_preferred: tuple[float, tuple[int, int, int]] | None = None
513
+ for raw_choice in itertools.product(*axes):
514
+ choice = tuple(int(value) for value in raw_choice)
515
+ rotvec = np.asarray(
516
+ [self.primitives[index][choice[index]] for index in range(3)]
517
+ )
518
+ rebuilt = self._apply(reconstruction, _rotvec_to_quaternion(rotvec))
519
+ error = float(
520
+ np.linalg.norm(_quaternion_to_rotvec(self._residual(target, rebuilt)))
521
+ )
522
+ if error > self.limit + 1e-12:
523
+ continue
524
+ candidate = (error, choice)
525
+ if best_any is None or candidate < best_any:
526
+ best_any = candidate
527
+ # Orthogonal moves can land a few ULP below zero depending on the
528
+ # BLAS reduction order. Treat that numerical tie as non-reversing.
529
+ if np.dot(rotvec, direction) >= -1e-15 and (
530
+ best_preferred is None or candidate < best_preferred
531
+ ):
532
+ best_preferred = candidate
533
+ selected = best_preferred or best_any
534
+ return nearest if selected is None else np.asarray(selected[1], dtype=np.int64)
535
+
536
+ def encode_batch(
537
+ self, actions: np.ndarray, previous_first_order: np.ndarray | None = None
538
+ ) -> np.ndarray:
539
+ """Quantize ``[B,T,3]`` rotations with a vectorized batch state."""
540
+ if self.config.primitive_order == 2:
541
+ return self._encode_second_order(actions, previous_first_order)
542
+ transformed = _transform(self.config, actions).astype(np.float64)
543
+ if resolve_backend("auto") == "native":
544
+ cells = require_native().so3_encode_batch(
545
+ self._native_table,
546
+ self._native_bins,
547
+ np.ascontiguousarray(transformed, dtype=np.float32),
548
+ self.config.input_order,
549
+ self.config.representation == "rotation_vector",
550
+ self.left,
551
+ self.limit,
552
+ self.config.primitive_selection == "smooth",
553
+ min(len(transformed), os.cpu_count() or 1),
554
+ )
555
+ return np.asarray(cells, dtype=np.int64)
556
+ converted = self._from_representation(transformed)
557
+ batch, steps = transformed.shape[:2]
558
+ true = np.broadcast_to(_IDENTITY, (batch, 4)).copy()
559
+ reconstruction = true.copy()
560
+ direction = np.zeros((batch, 3), dtype=np.float64)
561
+ cells = np.empty((batch, steps, 3), dtype=np.int64)
562
+ for step in range(steps):
563
+ value = converted[:, step]
564
+ true = value if self.config.input_order == 0 else self._apply(true, value)
565
+ residual = _quaternion_to_rotvec(self._residual(true, reconstruction))
566
+ choice = self._nearest_batch(residual)
567
+ if self.config.primitive_selection == "smooth":
568
+ choice = np.stack(
569
+ [
570
+ self._smooth(
571
+ residual[index],
572
+ reconstruction[index],
573
+ true[index],
574
+ direction[index],
575
+ choice[index],
576
+ )
577
+ for index in range(batch)
578
+ ]
579
+ )
580
+ primitive = np.stack(
581
+ [self.primitives[index][choice[:, index]] for index in range(3)], axis=1
582
+ )
583
+ reconstruction = self._apply(
584
+ reconstruction, _rotvec_to_quaternion(primitive)
585
+ )
586
+ moved = np.linalg.norm(primitive, axis=1) > 0.0
587
+ direction[moved] = primitive[moved]
588
+ cells[:, step] = choice
589
+ return cells
590
+
591
+ def decode_batch(
592
+ self, cells: np.ndarray, previous_first_order: np.ndarray | None = None
593
+ ) -> np.ndarray:
594
+ """Decode ``[B,T,3]`` cells in the configured boundary representation."""
595
+ if self.config.primitive_order == 2:
596
+ return self._decode_second_order(cells, previous_first_order)
597
+ indices = np.asarray(cells, dtype=np.int64)
598
+ rotvec = np.stack(
599
+ [self.primitives[index][indices[:, :, index]] for index in range(3)], axis=2
600
+ )
601
+ if self.config.input_order == 1:
602
+ represented = (
603
+ rotvec
604
+ if self.config.representation == "rotation_vector"
605
+ else self._to_representation(
606
+ _rotvec_to_quaternion(rotvec.astype(np.float32))
607
+ )
608
+ )
609
+ actions = np.asarray(represented / _scale(self.config), dtype=np.float32)
610
+ # Existing artifacts expose first-order actions through a zero-
611
+ # anchored position adapter. The float32 round trip is observable
612
+ # and therefore intentionally preserved for exact compatibility.
613
+ return _difference_additive(np.cumsum(actions, axis=1, dtype=np.float32), 1)
614
+
615
+ batch, steps = indices.shape[:2]
616
+ reconstruction = np.broadcast_to(_IDENTITY, (batch, 4)).copy()
617
+ quaternion = np.empty((batch, steps, 4), dtype=np.float64)
618
+ primitive_quaternion = _rotvec_to_quaternion(rotvec)
619
+ for step in range(steps):
620
+ reconstruction = self._apply(reconstruction, primitive_quaternion[:, step])
621
+ quaternion[:, step] = reconstruction
622
+ represented = self._to_representation(quaternion)
623
+ return np.asarray(represented / _scale(self.config), dtype=np.float32)
624
+
625
+ def _encode_second_order(
626
+ self, actions: np.ndarray, previous_first_order: np.ndarray
627
+ ) -> np.ndarray:
628
+ """Quantize increment changes on SO(3), respecting multiplication order."""
629
+ converted = self._from_representation(
630
+ _transform(self.config, actions).astype(np.float64)
631
+ )
632
+ increment = self._from_representation(
633
+ previous_first_order.astype(np.float64) * _scale(self.config)
634
+ )
635
+ if resolve_backend("auto") == "native" and hasattr(
636
+ require_native(), "so3_second_order_encode_batch"
637
+ ):
638
+ return np.asarray(
639
+ require_native().so3_second_order_encode_batch(
640
+ self._native_table,
641
+ self._native_bins,
642
+ np.ascontiguousarray(converted),
643
+ np.ascontiguousarray(increment),
644
+ self.config.input_order,
645
+ self.left,
646
+ self.limit,
647
+ self.second_order_radius,
648
+ self.config.primitive_selection == "smooth",
649
+ min(
650
+ len(actions),
651
+ max(
652
+ 1,
653
+ int(os.environ.get("ACTIONCODEC2_PHYSICAL_THREADS", "16")),
654
+ ),
655
+ ),
656
+ ),
657
+ dtype=np.int64,
658
+ )
659
+ batch, steps = actions.shape[:2]
660
+ true = np.broadcast_to(_IDENTITY, (batch, 4)).copy()
661
+ reconstruction = true.copy()
662
+ direction = np.zeros((batch, 3), dtype=np.float64)
663
+ cells = np.empty((batch, steps, 3), dtype=np.int64)
664
+ for step in range(steps):
665
+ true = (
666
+ converted[:, step]
667
+ if self.config.input_order == 0
668
+ else self._apply(true, converted[:, step])
669
+ )
670
+ desired = self._residual(true, reconstruction)
671
+ correction = self._residual(desired, increment)
672
+ request = _quaternion_to_rotvec(correction)
673
+ choice = self._nearest_batch(request)
674
+ if self.config.primitive_selection == "smooth":
675
+ choice = self._smooth_second_order_batch(
676
+ request, correction, direction, choice
677
+ )
678
+ primitive = np.stack(
679
+ [axis[choice[:, j]] for j, axis in enumerate(self.primitives)], axis=1
680
+ )
681
+ primitive = self._second_order_primitive(primitive)
682
+ increment = self._apply(increment, _rotvec_to_quaternion(primitive))
683
+ reconstruction = self._apply(reconstruction, increment)
684
+ moved = np.linalg.norm(primitive, axis=1) > 0
685
+ direction[moved] = primitive[moved]
686
+ cells[:, step] = choice
687
+ return cells
688
+
689
+ def _second_order_primitive(self, rotvec: np.ndarray) -> np.ndarray:
690
+ """Project the lattice to the declared increment-change ball.
691
+
692
+ Euclidean projection cannot increase distance to any log in the ball;
693
+ Exp is nonexpansive. Thus the Cartesian epsilon cover remains valid,
694
+ while every executable primitive respects c_R + 3*epsilon_R.
695
+ """
696
+ norm = np.linalg.norm(rotvec, axis=-1, keepdims=True)
697
+ return rotvec * np.minimum(
698
+ 1.0, self.second_order_radius / np.maximum(norm, np.finfo(float).tiny)
699
+ )
700
+
701
+ def _smooth_second_order_batch(
702
+ self,
703
+ request: np.ndarray,
704
+ correction: np.ndarray,
705
+ direction: np.ndarray,
706
+ nearest: np.ndarray,
707
+ ) -> np.ndarray:
708
+ """Evaluate local Cartesian candidates in batches with intrinsic errors.
709
+
710
+ Search windows use the same geometric pruning as ``_smooth``. Blocks
711
+ cap scratch memory even for unusually dense user-supplied grids.
712
+ """
713
+ if len(request) > 64:
714
+ return np.concatenate(
715
+ [
716
+ self._smooth_second_order_batch(
717
+ request[i : i + 64],
718
+ correction[i : i + 64],
719
+ direction[i : i + 64],
720
+ nearest[i : i + 64],
721
+ )
722
+ for i in range(0, len(request), 64)
723
+ ]
724
+ )
725
+ axes, counts = [], []
726
+ for j, axis in enumerate(self.primitives):
727
+ mask = np.abs(axis[None] - request[:, j, None]) <= 2 * self.limit + 1e-12
728
+ mask[np.arange(len(request)), nearest[:, j]] = True
729
+ count = mask.sum(axis=1)
730
+ rows, columns = np.nonzero(mask)
731
+ slots = np.arange(len(rows)) - np.repeat(np.cumsum(count) - count, count)
732
+ candidates = np.zeros((len(request), int(count.max())), dtype=np.int64)
733
+ candidates[rows, slots] = columns
734
+ axes.append(candidates)
735
+ counts.append(count)
736
+ counts = np.stack(counts, axis=1)
737
+ widths = counts.max(axis=0)
738
+ total = int(np.prod(widths))
739
+ best = nearest.copy()
740
+ best_error = np.full(len(request), np.inf)
741
+ best_preferred = np.zeros(len(request), dtype=bool)
742
+ for offset in range(0, total, 4096):
743
+ lattice = np.stack(
744
+ np.unravel_index(
745
+ np.arange(offset, min(total, offset + 4096)), tuple(widths)
746
+ ),
747
+ axis=1,
748
+ )
749
+ valid = np.all(lattice[None] < counts[:, None], axis=2)
750
+ choices = np.stack(
751
+ [
752
+ axis[
753
+ np.arange(len(request))[:, None],
754
+ np.minimum(lattice[None, :, j], counts[:, j, None] - 1),
755
+ ]
756
+ for j, axis in enumerate(axes)
757
+ ],
758
+ axis=2,
759
+ )
760
+ rotvec = np.stack(
761
+ [axis[choices[:, :, j]] for j, axis in enumerate(self.primitives)],
762
+ axis=2,
763
+ )
764
+ rotvec = self._second_order_primitive(rotvec)
765
+ primitive = _rotvec_to_quaternion(rotvec)
766
+ residual = _multiply(_conjugate(primitive), correction[:, None])
767
+ error = np.linalg.norm(_quaternion_to_rotvec(residual), axis=2)
768
+ valid &= error <= self.limit + 1e-12
769
+ preferred = valid & (np.sum(rotvec * direction[:, None], axis=2) >= -1e-15)
770
+ has_preferred = preferred.any(axis=1)
771
+ eligible = np.where(has_preferred[:, None], preferred, valid)
772
+ scores = np.where(eligible, error, np.inf)
773
+ index = np.argmin(scores, axis=1)
774
+ score = scores[np.arange(len(request)), index]
775
+ improve = np.isfinite(score) & (
776
+ (has_preferred & ~best_preferred)
777
+ | ((has_preferred == best_preferred) & (score < best_error))
778
+ )
779
+ best[improve] = choices[np.arange(len(request)), index][improve]
780
+ best_error[improve] = score[improve]
781
+ best_preferred[improve] = has_preferred[improve]
782
+ return best
783
+
784
+ def _decode_second_order(
785
+ self, cells: np.ndarray, previous_first_order: np.ndarray
786
+ ) -> np.ndarray:
787
+ """Integrate rotation changes from a shared ``[B,3]`` first-order state."""
788
+ rotvec = np.stack(
789
+ [axis[cells[:, :, j]] for j, axis in enumerate(self.primitives)], axis=2
790
+ )
791
+ rotvec = self._second_order_primitive(rotvec)
792
+ changes = _rotvec_to_quaternion(rotvec)
793
+ increment = self._from_representation(
794
+ previous_first_order.astype(np.float64) * _scale(self.config)
795
+ )
796
+ reconstruction = np.broadcast_to(_IDENTITY, increment.shape).copy()
797
+ output = np.empty((*cells.shape[:2], 4), dtype=np.float64)
798
+ for step in range(cells.shape[1]):
799
+ increment = self._apply(increment, changes[:, step])
800
+ reconstruction = self._apply(reconstruction, increment)
801
+ output[:, step] = (
802
+ increment if self.config.input_order == 1 else reconstruction
803
+ )
804
+ return np.asarray(
805
+ self._to_representation(output) / _scale(self.config), dtype=np.float32
806
+ )
807
+
808
+
809
+ class PhysicalCodec:
810
+ """Compose independent physical components in global action-dimension order.
811
+
812
+ The public contract is ``[B,T,D] -> [B,T,D]``. Components may be declared
813
+ in any YAML order and may use arbitrary non-overlapping dimension lists;
814
+ scattering always restores global dimension ``j`` to slot ``j``.
815
+ """
816
+
817
+ def __init__(self, config: ActionCodec2Config, primitives: PrimitiveStore) -> None:
818
+ if primitives.dimension != config.physical.input_dim:
819
+ raise ConfigError(
820
+ f"primitive dimension {primitives.dimension} does not match input_dim "
821
+ f"{config.physical.input_dim}"
822
+ )
823
+ self.config = config
824
+ self.primitives = primitives
825
+ runtimes: list[tuple[ComponentConfig, _ComponentRuntime]] = []
826
+ for component in config.physical.components.values():
827
+ component_primitives = tuple(
828
+ primitives.values[index] for index in component.dims
829
+ )
830
+ runtime: _ComponentRuntime = (
831
+ _AdditiveRuntime(component, component_primitives)
832
+ if component.space == "additive"
833
+ else _SO3Runtime(component, component_primitives)
834
+ )
835
+ runtimes.append((component, runtime))
836
+ self._runtimes = tuple(runtimes)
837
+ self.bins = primitives.bins
838
+ self.requires_previous_first_order = any(
839
+ c.primitive_order == 2 for c, _ in runtimes
840
+ )
841
+
842
+ def _check_previous_first_order(
843
+ self, value: np.ndarray | None, batch: int
844
+ ) -> np.ndarray | None:
845
+ if value is None:
846
+ if self.requires_previous_first_order:
847
+ raise ValueError(
848
+ "second-order tokenization requires previous_first_order; no zero initialization is inferred"
849
+ )
850
+ return None
851
+ array = np.asarray(value, dtype=np.float32)
852
+ shape = (batch, self.config.physical.input_dim)
853
+ if array.shape != shape or not np.all(np.isfinite(array)):
854
+ raise ValueError(f"previous_first_order must be finite with shape {shape}")
855
+ return array
856
+
857
+ def _check_actions(self, actions: np.ndarray) -> np.ndarray:
858
+ values = np.asarray(actions, dtype=np.float32)
859
+ expected = self.config.physical.input_dim
860
+ if values.ndim != 3 or values.shape[2] != expected or values.shape[1] < 1:
861
+ raise ValueError(f"actions must have non-empty shape [B, T, {expected}]")
862
+ if not np.all(np.isfinite(values)):
863
+ raise ValueError("actions must contain only finite values")
864
+ return values
865
+
866
+ def final_first_order(
867
+ self, cells: np.ndarray, *, previous_first_order: np.ndarray
868
+ ) -> np.ndarray:
869
+ """Return ``[B,D]`` state after these cells, without position differencing.
870
+
871
+ Only second-order component slots carry state; other slots are zero.
872
+ Pass a cell prefix when only part of the generated chunk was executed.
873
+ """
874
+ values = self._check_cells(cells)
875
+ previous = self._check_previous_first_order(previous_first_order, len(values))
876
+ result = np.zeros(
877
+ (len(values), self.config.physical.input_dim), dtype=np.float32
878
+ )
879
+ for component, runtime in self._runtimes:
880
+ if component.primitive_order != 2:
881
+ continue
882
+ local = np.take(values, component.dims, axis=2)
883
+ boundary = np.take(previous, component.dims, axis=1)
884
+ picked = np.stack(
885
+ [axis[local[:, :, j]] for j, axis in enumerate(runtime.primitives)],
886
+ axis=2,
887
+ )
888
+ if component.space == "additive":
889
+ final = boundary.astype(np.float64) + np.sum(
890
+ picked.astype(np.float64), axis=1
891
+ ) / _scale(component)
892
+ else:
893
+ increment = runtime._from_representation(
894
+ boundary.astype(np.float64) * _scale(component)
895
+ )
896
+ changes = _rotvec_to_quaternion(runtime._second_order_primitive(picked))
897
+ for step in range(local.shape[1]):
898
+ increment = runtime._apply(increment, changes[:, step])
899
+ final = runtime._to_representation(increment) / _scale(component)
900
+ result[:, component.dims] = final
901
+ return result
902
+
903
+ def _check_cells(self, cells: np.ndarray) -> np.ndarray:
904
+ values = np.asarray(cells, dtype=np.int64)
905
+ expected = self.config.physical.input_dim
906
+ if values.ndim != 3 or values.shape[2] != expected or values.shape[1] < 1:
907
+ raise ValueError(f"cells must have non-empty shape [B, T, {expected}]")
908
+ counts = np.asarray(self.bins, dtype=np.int64)
909
+ if values.size and (
910
+ np.any(values < 0) or np.any(values >= counts[None, None, :])
911
+ ):
912
+ raise ValueError("a cell index is outside its dimension's primitive set")
913
+ return values
914
+
915
+ def encode_batch(
916
+ self, actions: np.ndarray, *, previous_first_order: np.ndarray | None = None
917
+ ) -> np.ndarray:
918
+ """Quantize ``[B,T,D]`` with a required ``[B,D]`` boundary for order 2.
919
+
920
+ The boundary is the preceding reconstructed first-order quantity, in
921
+ input units and representation, before scaling (never source-clipped).
922
+ Position/orientation coordinates are local to zero/identity per call.
923
+ """
924
+ values = self._check_actions(actions)
925
+ previous = self._check_previous_first_order(previous_first_order, len(values))
926
+ cells = np.empty(values.shape, dtype=np.int64)
927
+ for component, runtime in self._runtimes:
928
+ component_cells = runtime.encode_batch(
929
+ np.take(values, component.dims, axis=2),
930
+ None if previous is None else np.take(previous, component.dims, axis=1),
931
+ )
932
+ for local, global_dimension in enumerate(component.dims):
933
+ cells[:, :, global_dimension] = component_cells[:, :, local]
934
+ return cells
935
+
936
+ def decode_batch(
937
+ self, cells: np.ndarray, *, previous_first_order: np.ndarray | None = None
938
+ ) -> np.ndarray:
939
+ """Decode ``[B,T,D]`` using the same ``[B,D]`` boundary as encoding."""
940
+ values = self._check_cells(cells)
941
+ previous = self._check_previous_first_order(previous_first_order, len(values))
942
+ actions = np.empty(values.shape, dtype=np.float32)
943
+ for component, runtime in self._runtimes:
944
+ component_actions = runtime.decode_batch(
945
+ np.take(values, component.dims, axis=2),
946
+ None if previous is None else np.take(previous, component.dims, axis=1),
947
+ )
948
+ for local, global_dimension in enumerate(component.dims):
949
+ actions[:, :, global_dimension] = component_actions[:, :, local]
950
+ return actions
runtime/physical/physical_processor.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public ActionCodec2 processor and constrained-generation grammars.
2
+
3
+ The processor is scheme-agnostic: ``config.set_bpe.scheme`` picks the
4
+ serializer and the matching grammar, and everything else -- physical
5
+ quantization, batching, second-order boundaries, artifact layout -- is shared.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from collections.abc import Sequence
13
+ from numbers import Integral
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ from transformers.processing_utils import ProcessorMixin
19
+
20
+ from ..common._validation import active_dimensions
21
+ from ..tokenization.bpe import (
22
+ BPE_NAME,
23
+ BPESerializer,
24
+ SetBPESerializer,
25
+ serializer_class,
26
+ )
27
+ from ..routing.cache import BoundedCache
28
+ from .configuration import CONFIG_NAME, MODEL_TYPE, ActionCodec2Config
29
+ from ..tokenization.grammar import ActionCodec2BPEGrammar as ActionCodec2BPEGrammar
30
+ from ..tokenization.grammar import ActionCodec2Grammar as ActionCodec2Grammar
31
+ from ..integration.hf import (
32
+ resolve_pretrained_directory,
33
+ save_huggingface_metadata,
34
+ upload_pretrained_directory,
35
+ )
36
+ from .physical_codec import PhysicalCodec
37
+ from .primitives import PRIMITIVES_NAME, PrimitiveStore
38
+
39
+
40
+ class ActionCodec2Processor(ProcessorMixin):
41
+ """Hugging Face-style processor for continuous robot action trajectories.
42
+
43
+ ActionCodec2 is an ``AutoProcessor``-class object, not an ``AutoModel``: it
44
+ has no neural ``forward`` method or trainable weights. Its fitted state is a
45
+ physical primitive vocabulary plus lossless Set-BPE rules. Encoding maps
46
+ ``[B,T,D]`` continuous actions to ragged token-ID streams; decoding performs
47
+ the inverse token/cell path and the configured lossy physical reconstruction.
48
+
49
+ Args:
50
+ config: Physical quantization and tokenization scheme configuration.
51
+ primitives: Fitted primitive values for every physical coordinate.
52
+ serializer: Fitted BPE rules matching the configured scheme, primitive
53
+ bin counts, and lookahead.
54
+ """
55
+
56
+ model_type = MODEL_TYPE
57
+ name = "actioncodec2"
58
+ is_exact = False
59
+ set_bpe_is_lossless = True
60
+ model_input_names = ["input_ids"]
61
+ attributes = []
62
+
63
+ def __init__(
64
+ self,
65
+ config: ActionCodec2Config,
66
+ primitives: PrimitiveStore,
67
+ serializer: SetBPESerializer | BPESerializer,
68
+ ) -> None:
69
+ if serializer.scheme != config.set_bpe.scheme:
70
+ raise ValueError("serializer scheme does not match config.yaml")
71
+ if tuple(serializer.bins) != tuple(primitives.bins):
72
+ raise ValueError("BPE bins do not match primitives.npz")
73
+ if serializer.lookahead != config.set_bpe.lookahead:
74
+ raise ValueError("BPE lookahead does not match config.yaml")
75
+ # ProcessorMixin is designed around modality sub-processors. This
76
+ # processor is atomic, so it deliberately owns and serializes its three
77
+ # artifact objects directly instead of invoking ProcessorMixin.__init__.
78
+ self.config = config
79
+ self.primitives = primitives
80
+ self.serializer = serializer
81
+ self._grammar_cache = BoundedCache(8)
82
+ self.physical = PhysicalCodec(config, primitives)
83
+ self.action_dim = config.physical.input_dim
84
+ self.bins = primitives.bins
85
+ self.vocab_size = serializer.vocab_size
86
+ self.lookahead = serializer.lookahead
87
+ self.requires_previous_first_order = self.physical.requires_previous_first_order
88
+ zero_cells = []
89
+ for dimension, axis in enumerate(primitives.values):
90
+ matches = np.flatnonzero(np.asarray(axis) == 0)
91
+ if not len(matches):
92
+ raise ValueError(
93
+ "ActionCodec2 artifacts require an exact zero primitive in every axis; "
94
+ f"dimension {dimension} has none"
95
+ )
96
+ zero_cells.append(int(matches[0]))
97
+ self._zero_cells = np.asarray(zero_cells, dtype=np.int64)
98
+
99
+ @classmethod
100
+ def from_pretrained(
101
+ cls,
102
+ pretrained_model_name_or_path: str | Path,
103
+ **kwargs: Any,
104
+ ) -> ActionCodec2Processor:
105
+ """Load a local directory or Hugging Face Hub snapshot.
106
+
107
+ Standard Hub keywords such as ``revision``, ``cache_dir``, ``token``,
108
+ and ``local_files_only`` are supported. Numerical files remain the
109
+ strict physical artifact rather than being embedded in JSON metadata.
110
+ """
111
+ directory = resolve_pretrained_directory(
112
+ pretrained_model_name_or_path, **kwargs
113
+ )
114
+ config = ActionCodec2Config.from_pretrained(directory)
115
+ primitives = PrimitiveStore.from_pretrained(
116
+ directory, input_dim=config.physical.input_dim
117
+ )
118
+ serializer = serializer_class(config).from_pretrained(
119
+ directory,
120
+ bins=primitives.bins,
121
+ lookahead=config.set_bpe.lookahead,
122
+ )
123
+ return cls(config, primitives, serializer)
124
+
125
+ def save_pretrained(
126
+ self,
127
+ save_directory: str | Path,
128
+ push_to_hub: bool = False,
129
+ **kwargs: Any,
130
+ ) -> list[str]:
131
+ """Save numerical artifacts plus standard AutoProcessor metadata."""
132
+ if not push_to_hub and kwargs:
133
+ raise TypeError(
134
+ f"unused save_pretrained keyword(s): {', '.join(sorted(kwargs))}"
135
+ )
136
+ directory = Path(save_directory)
137
+ self.config.save_pretrained(directory)
138
+ self.primitives.save_pretrained(directory)
139
+ self.serializer.save_pretrained(directory)
140
+ metadata = save_huggingface_metadata(directory)
141
+ files = [
142
+ directory / CONFIG_NAME,
143
+ directory / PRIMITIVES_NAME,
144
+ directory / BPE_NAME,
145
+ *metadata,
146
+ ]
147
+ if push_to_hub:
148
+ upload_pretrained_directory(directory, **kwargs)
149
+ return [str(path) for path in files]
150
+
151
+ def to_dict(self, legacy_serialization: bool = True) -> dict[str, object]:
152
+ """Return metadata without embedding numerical artifact arrays.
153
+
154
+ ``legacy_serialization`` is retained because Transformers passes it to
155
+ processor serializers in older releases; ActionCodec2 has one format.
156
+ """
157
+ del legacy_serialization
158
+ return {
159
+ "processor_class": self.__class__.__name__,
160
+ "model_type": self.model_type,
161
+ "action_dim": self.action_dim,
162
+ "vocab_size": self.vocab_size,
163
+ }
164
+
165
+ @property
166
+ def artifact_fingerprint(self) -> str:
167
+ """Content hash over config, primitive axes, and ordered BPE rules."""
168
+ digest = hashlib.sha256()
169
+ digest.update(
170
+ json.dumps(
171
+ self.config.to_dict(), sort_keys=True, separators=(",", ":")
172
+ ).encode()
173
+ )
174
+ for axis in self.primitives.values:
175
+ values = np.ascontiguousarray(axis)
176
+ digest.update(str(values.dtype).encode())
177
+ digest.update(np.asarray(values.shape, dtype=np.int64).tobytes())
178
+ digest.update(values.tobytes())
179
+ digest.update(
180
+ json.dumps(
181
+ self.serializer.to_dict(), sort_keys=True, separators=(",", ":")
182
+ ).encode()
183
+ )
184
+ return digest.hexdigest()
185
+
186
+ def quantize_batch(
187
+ self, actions: np.ndarray, *, previous_first_order: np.ndarray | None = None
188
+ ) -> np.ndarray:
189
+ """Quantize ``[B,T,D]`` using a ``[B,D]`` first-order boundary if required."""
190
+ return self.physical.encode_batch(
191
+ actions, previous_first_order=previous_first_order
192
+ )
193
+
194
+ def _dense_previous_first_order(
195
+ self, previous_first_order: np.ndarray | None, dims: tuple[int, ...], batch: int
196
+ ) -> np.ndarray | None:
197
+ """Expand a compact ``[B,D_active]`` boundary, validating active slots."""
198
+ active = set(dims)
199
+ for component in self.config.physical.components.values():
200
+ present = active.intersection(component.dims)
201
+ if (
202
+ component.primitive_order == 2
203
+ and component.space == "so3"
204
+ and present
205
+ and present != set(component.dims)
206
+ ):
207
+ raise ValueError(
208
+ "second-order SO(3) requires all three active component dimensions"
209
+ )
210
+ if previous_first_order is None:
211
+ if any(
212
+ c.primitive_order == 2 and active.intersection(c.dims)
213
+ for c in self.config.physical.components.values()
214
+ ):
215
+ raise ValueError(
216
+ f"second-order tokenization requires previous_first_order with shape {(batch, len(dims))} in canonical input units"
217
+ )
218
+ return (
219
+ np.zeros((batch, self.action_dim), dtype=np.float32)
220
+ if self.requires_previous_first_order
221
+ else None
222
+ )
223
+ values = np.asarray(previous_first_order, dtype=np.float32)
224
+ if values.shape != (batch, len(dims)) or not np.all(np.isfinite(values)):
225
+ raise ValueError(
226
+ f"previous_first_order must be finite with shape {(batch, len(dims))}"
227
+ )
228
+ dense = np.zeros((batch, self.action_dim), dtype=np.float32)
229
+ dense[:, dims] = values
230
+ return dense
231
+
232
+ def tokens_from_cells(
233
+ self,
234
+ cells: np.ndarray,
235
+ *,
236
+ active_dims: Sequence[int] | None = None,
237
+ ) -> list[int]:
238
+ """Apply the lossless tokenizer to one compact or dense cell trajectory."""
239
+ return self.serializer.encode(cells, active_dims=active_dims)
240
+
241
+ def cells_from_tokens(
242
+ self,
243
+ tokens: Sequence[int],
244
+ *,
245
+ active_dims: Sequence[int] | None = None,
246
+ ) -> np.ndarray:
247
+ """Losslessly recover dense cells with ``-1`` in absent dimensions."""
248
+ return self.serializer.decode(tokens, active_dims=active_dims)
249
+
250
+ def _fill_inactive_cells(self, cells: np.ndarray) -> np.ndarray:
251
+ """Replace semantic absence with each physical axis's exact zero cell."""
252
+ values = np.asarray(cells, dtype=np.int64)
253
+ return np.where(values >= 0, values, self._zero_cells)
254
+
255
+ def encode_batch(
256
+ self,
257
+ actions: np.ndarray,
258
+ *,
259
+ active_dims: Sequence[int] | None = None,
260
+ previous_first_order: np.ndarray | None = None,
261
+ ) -> list[list[int]]:
262
+ """Encode a dense or compact ``[B,T,D]`` batch into ragged streams.
263
+
264
+ ``active_dims`` maps compact coordinates into this processor's physical
265
+ profile. Supplying the whole batch at once preserves native physical
266
+ quantization across the batch dimension.
267
+ ``previous_first_order[B,D_active]`` is required for active second-order
268
+ components and follows the same compact mapping, in input units.
269
+ """
270
+ values = np.asarray(actions, dtype=np.float32)
271
+ if (
272
+ values.ndim != 3
273
+ or len(values) < 1
274
+ or values.shape[1] < 1
275
+ or values.shape[2] < 1
276
+ ):
277
+ raise ValueError("actions must have non-empty shape [B, T, D_active]")
278
+ dims = active_dimensions(active_dims, self.action_dim)
279
+ if len(dims) != values.shape[2]:
280
+ raise ValueError("active_dims must have one entry per action coordinate")
281
+ full_width = dims == tuple(range(self.action_dim))
282
+ if full_width:
283
+ dense = values
284
+ else:
285
+ dense = np.zeros(
286
+ (len(values), values.shape[1], self.action_dim), dtype=np.float32
287
+ )
288
+ dense[:, :, dims] = values
289
+ previous = self._dense_previous_first_order(
290
+ previous_first_order, dims, len(values)
291
+ )
292
+ cells = self.quantize_batch(dense, previous_first_order=previous)
293
+ if not full_width:
294
+ cells = np.take(cells, dims, axis=2)
295
+ return [self.tokens_from_cells(row, active_dims=dims) for row in cells]
296
+
297
+ def decode_batch(
298
+ self,
299
+ streams: Sequence[Sequence[int]],
300
+ *,
301
+ active_dims: Sequence[int] | None = None,
302
+ previous_first_order: np.ndarray | None = None,
303
+ ) -> np.ndarray:
304
+ """Decode equal-length streams into one batched physical array.
305
+
306
+ With ``active_dims``, the last output dimension is compact and follows
307
+ that mapping. Inactive physical-profile slots never appear at the
308
+ semantic boundary.
309
+ ``previous_first_order[B,D_active]`` must match the encoding boundary
310
+ for active second-order components.
311
+ """
312
+ dims = active_dimensions(active_dims, self.action_dim)
313
+ cells = [self.cells_from_tokens(stream, active_dims=dims) for stream in streams]
314
+ if not cells:
315
+ raise ValueError("streams must not be empty")
316
+ lengths = {len(value) for value in cells}
317
+ if len(lengths) != 1:
318
+ raise ValueError(
319
+ "all token streams in a batch must decode to the same length"
320
+ )
321
+ stacked_cells = np.stack(cells)
322
+ dense_cells = self._fill_inactive_cells(stacked_cells)
323
+ previous = self._dense_previous_first_order(
324
+ previous_first_order, dims, len(cells)
325
+ )
326
+ decoded = self.physical.decode_batch(dense_cells, previous_first_order=previous)
327
+ return np.take(decoded, dims, axis=2)
328
+
329
+ def final_first_order(
330
+ self,
331
+ streams: Sequence[Sequence[int]],
332
+ *,
333
+ previous_first_order: np.ndarray,
334
+ active_dims: Sequence[int] | None = None,
335
+ executed_steps: int | None = None,
336
+ ) -> np.ndarray:
337
+ """Return the next compact ``[B,D_active]`` reconstructed boundary.
338
+
339
+ ``executed_steps`` selects the executed prefix in codec timesteps;
340
+ omitted means the full chunk. Tokens may span the prefix boundary.
341
+ """
342
+ dims = active_dimensions(active_dims, self.action_dim)
343
+ cells = [self.cells_from_tokens(stream, active_dims=dims) for stream in streams]
344
+ if not cells or len({len(row) for row in cells}) != 1:
345
+ raise ValueError(
346
+ "streams must be non-empty and have equal decoded horizons"
347
+ )
348
+ if executed_steps is not None:
349
+ if (
350
+ isinstance(executed_steps, bool)
351
+ or not isinstance(executed_steps, Integral)
352
+ or not 1 <= executed_steps <= len(cells[0])
353
+ ):
354
+ raise ValueError("executed_steps must be within the decoded horizon")
355
+ cells = [row[:executed_steps] for row in cells]
356
+ previous = self._dense_previous_first_order(
357
+ previous_first_order, dims, len(cells)
358
+ )
359
+ result = self.physical.final_first_order(
360
+ self._fill_inactive_cells(np.stack(cells)), previous_first_order=previous
361
+ )
362
+ return np.take(result, dims, axis=1)
363
+
364
+ def grammar(
365
+ self,
366
+ horizon: int,
367
+ *,
368
+ active_dims: Sequence[int] | None = None,
369
+ ) -> ActionCodec2Grammar | ActionCodec2BPEGrammar:
370
+ """Build a constrained grammar for one horizon and route support."""
371
+ if isinstance(horizon, bool) or not isinstance(horizon, int) or horizon < 1:
372
+ raise ValueError("horizon must be a positive integer")
373
+ dims = active_dimensions(active_dims, self.action_dim)
374
+ key = (horizon, dims)
375
+ grammar = self._grammar_cache.get(key)
376
+ if grammar is None:
377
+ grammar_class = (
378
+ ActionCodec2BPEGrammar
379
+ if isinstance(self.serializer, BPESerializer)
380
+ else ActionCodec2Grammar
381
+ )
382
+ grammar = grammar_class(self.serializer, horizon=horizon, active_dims=dims)
383
+ self._grammar_cache[key] = grammar
384
+ return grammar
runtime/physical/primitives.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Numerical primitive parameters stored in ``primitives.npz``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from .configuration import ConfigError
11
+
12
+ PRIMITIVES_NAME = "primitives.npz"
13
+
14
+
15
+ def _key(dimension: int) -> str:
16
+ return f"dimension_{dimension:04d}"
17
+
18
+
19
+ class PrimitiveStore:
20
+ """Immutable numerical primitive axes in global action-dimension order.
21
+
22
+ Array ``values[j]`` is the decoder vocabulary selected by cell slot ``j``.
23
+ NPZ key names are internal and deliberately absent from ``config.yaml``.
24
+ """
25
+
26
+ def __init__(self, values: Sequence[np.ndarray | Sequence[float]]) -> None:
27
+ if not values:
28
+ raise ConfigError("primitives must contain at least one dimension")
29
+ arrays = []
30
+ for dimension, raw in enumerate(values):
31
+ array = np.asarray(raw)
32
+ if array.dtype.hasobject:
33
+ raise ConfigError(
34
+ f"primitive dimension {dimension} must not use object dtype"
35
+ )
36
+ if array.ndim != 1 or not len(array):
37
+ raise ConfigError(
38
+ f"primitive dimension {dimension} must be a non-empty vector"
39
+ )
40
+ real_numeric = np.issubdtype(array.dtype, np.integer) or np.issubdtype(
41
+ array.dtype, np.floating
42
+ )
43
+ if not real_numeric or not np.all(np.isfinite(array)):
44
+ raise ConfigError(
45
+ f"primitive dimension {dimension} must contain finite numbers"
46
+ )
47
+ arrays.append(np.array(array, copy=True))
48
+ self.values = tuple(arrays)
49
+ self.bins = tuple(len(array) for array in arrays)
50
+
51
+ @property
52
+ def dimension(self) -> int:
53
+ return len(self.values)
54
+
55
+ @classmethod
56
+ def from_pretrained(
57
+ cls, directory: str | Path, *, input_dim: int
58
+ ) -> PrimitiveStore:
59
+ """Load exactly one safe numeric vector per expected global dimension."""
60
+ path = Path(directory) / PRIMITIVES_NAME
61
+ with np.load(path, allow_pickle=False) as payload:
62
+ expected = {_key(index) for index in range(input_dim)}
63
+ actual = set(payload.files)
64
+ missing = sorted(expected - actual)
65
+ unknown = sorted(actual - expected)
66
+ if missing:
67
+ raise ConfigError(
68
+ f"{PRIMITIVES_NAME} is missing array(s): {', '.join(missing)}"
69
+ )
70
+ if unknown:
71
+ raise ConfigError(
72
+ f"{PRIMITIVES_NAME} contains unknown array(s): {', '.join(unknown)}"
73
+ )
74
+ values = [payload[_key(index)] for index in range(input_dim)]
75
+ return cls(values)
76
+
77
+ def save_pretrained(self, directory: str | Path) -> None:
78
+ """Write ``primitives.npz`` without pickled objects or path metadata."""
79
+ path = Path(directory) / PRIMITIVES_NAME
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ np.savez(
82
+ path, **{_key(index): value for index, value in enumerate(self.values)}
83
+ )
runtime/physical/semantic.py ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical physical preprocessing for routed ActionCodec2 profiles.
2
+
3
+ Dataset presets may select columns, convert units, and resample observations,
4
+ but they may not define primitives or BPE rules. This boundary keeps robot
5
+ storage conventions outside the shared physical vocabularies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import numpy as np
13
+ from scipy.interpolate import CubicSpline, PchipInterpolator
14
+ from scipy.spatial.transform import Rotation, RotationSpline
15
+
16
+ from ._so3 import euler_xyz_to_quaternion as _euler_xyz_to_quaternion
17
+ from ._so3 import multiply_quaternions as _quaternion_multiply
18
+ from ._so3 import normalize_quaternion as _normalize_quaternion
19
+ from ._so3 import quaternion_to_rotvec as _quaternion_to_rotvec
20
+ from ._so3 import rotvec_to_quaternion as _rotvec_to_quaternion
21
+
22
+ _INTERPOLATION_METHODS = {"linear", "pchip", "cubic"}
23
+
24
+
25
+ def _validate_rate(value: float, name: str) -> float:
26
+ rate = float(value)
27
+ if not math.isfinite(rate) or rate <= 0.0:
28
+ raise ValueError(f"{name} must be positive and finite")
29
+ return rate
30
+
31
+
32
+ def _quaternion_slerp(
33
+ left: np.ndarray, right: np.ndarray, weight: np.ndarray
34
+ ) -> np.ndarray:
35
+ """Shortest-path SLERP over matching ``(N, 4)`` xyzw arrays."""
36
+ first = _normalize_quaternion(np.asarray(left, dtype=np.float64))
37
+ second = _normalize_quaternion(np.asarray(right, dtype=np.float64))
38
+ dot = np.sum(first * second, axis=1)
39
+ flip = dot < 0.0
40
+ second[flip] *= -1.0
41
+ dot = np.clip(np.abs(dot), 0.0, 1.0)
42
+ output = np.empty_like(first)
43
+ near = dot > 0.9995
44
+ output[near] = first[near] + weight[near, None] * (second[near] - first[near])
45
+ regular = ~near
46
+ if np.any(regular):
47
+ angle = np.arccos(dot[regular])
48
+ denominator = np.sin(angle)
49
+ w = weight[regular]
50
+ output[regular] = (
51
+ np.sin((1.0 - w) * angle)[:, None] / denominator[:, None] * first[regular]
52
+ + np.sin(w * angle)[:, None] / denominator[:, None] * second[regular]
53
+ )
54
+ return _normalize_quaternion(output)
55
+
56
+
57
+ def resampled_horizon(length: int, *, source_hz: float, target_hz: float) -> int:
58
+ """Return the target control-step count for a regular source horizon.
59
+
60
+ A ``[T,D]`` action/state target represents ``T`` controller intervals, so
61
+ its duration is ``T / source_hz``. This differs intentionally from a
62
+ point-sampled signal whose first and last rows span only ``T-1`` periods.
63
+ The interval convention maps duration to a rounded number of target-rate
64
+ controller intervals, which is the runtime action-chunk contract.
65
+ """
66
+ if (
67
+ isinstance(length, bool)
68
+ or not isinstance(length, (int, np.integer))
69
+ or length < 1
70
+ ):
71
+ raise ValueError("a trajectory must contain at least one sample")
72
+ source_rate = _validate_rate(source_hz, "source_hz")
73
+ target_rate = _validate_rate(target_hz, "target_hz")
74
+ scaled = int(math.floor(int(length) * target_rate / source_rate + 0.5))
75
+ return max(1, scaled)
76
+
77
+
78
+ def _target_times(
79
+ length: int,
80
+ source_hz: float,
81
+ target_hz: float,
82
+ *,
83
+ target_length: int | None,
84
+ ) -> np.ndarray:
85
+ count = (
86
+ resampled_horizon(length, source_hz=source_hz, target_hz=target_hz)
87
+ if target_length is None
88
+ else target_length
89
+ )
90
+ if isinstance(count, bool) or not isinstance(count, (int, np.integer)) or count < 1:
91
+ raise ValueError("target_length must be a positive integer")
92
+ return np.arange(int(count), dtype=np.float64) / target_hz
93
+
94
+
95
+ def _guarded_natural_cubic(
96
+ source_time: np.ndarray,
97
+ values: np.ndarray,
98
+ target_time: np.ndarray,
99
+ ) -> np.ndarray:
100
+ """Use natural cubic unless an axis materially leaves its observed envelope.
101
+
102
+ Natural cubic is C2-continuous but can overshoot sparse control knots.
103
+ Falling back only the offending additive axes to PCHIP preserves the
104
+ smoother curve wherever it is supported by observations while preventing
105
+ interpolation from inventing a large new coordinate-wise extremum.
106
+ """
107
+ sampled = np.asarray(
108
+ CubicSpline(source_time, values, axis=0, bc_type="natural")(target_time),
109
+ dtype=np.float64,
110
+ )
111
+ lower = np.min(values, axis=0)
112
+ upper = np.max(values, axis=0)
113
+ # Small cubic ringing can be the mechanism that removes velocity corners;
114
+ # treat only a material (>5% of the observed axis span) new extremum as an
115
+ # unsafe invention. Profile order-0/order-1 checks remain the hard gate.
116
+ tolerance = 1e-9 + 0.05 * (upper - lower)
117
+ fallback_axes = np.any(
118
+ (sampled < lower[None] - tolerance[None])
119
+ | (sampled > upper[None] + tolerance[None]),
120
+ axis=0,
121
+ )
122
+ if np.any(fallback_axes):
123
+ sampled[:, fallback_axes] = PchipInterpolator(
124
+ source_time, values[:, fallback_axes], axis=0
125
+ )(target_time)
126
+ return sampled
127
+
128
+
129
+ def resample_uniform(
130
+ values: np.ndarray,
131
+ *,
132
+ source_hz: float,
133
+ target_hz: float,
134
+ kind: str = "linear",
135
+ target_length: int | None = None,
136
+ method: str = "linear",
137
+ ) -> np.ndarray:
138
+ """Resample one regular control-horizon trajectory onto a target clock.
139
+
140
+ Args:
141
+ values (np.ndarray): ``(T, D)`` continuous/discrete state samples.
142
+ source_hz: Source frequency in Hz.
143
+ target_hz: Target frequency in Hz.
144
+ kind: ``linear``, ``hold``, ``pose_xyzw``, or ``pose_euler_xyz``.
145
+ Pose output is always ``(x, y, z, qx, qy, qz, qw)`` and uses
146
+ SLERP for orientation.
147
+ target_length: Explicit target horizon. By default the interval
148
+ duration ``len(values) / source_hz`` is preserved.
149
+ method: ``linear`` uses linear translation plus SLERP. ``pchip`` uses
150
+ shape-preserving cubic translation. ``cubic`` uses a natural cubic
151
+ translation spline, with per-axis PCHIP fallback if the spline
152
+ materially leaves the source trajectory's observed value envelope.
153
+ Both cubic methods use an intrinsic SO(3) ``RotationSpline`` for
154
+ orientation.
155
+
156
+ Returns:
157
+ np.ndarray: ``(T_target, D)`` regularly sampled targets. When
158
+ upsampling, times after the final source knot hold its value until
159
+ the final source control interval ends.
160
+ """
161
+ source_rate = _validate_rate(source_hz, "source_hz")
162
+ target_rate = _validate_rate(target_hz, "target_hz")
163
+ array = np.asarray(values, dtype=np.float64)
164
+ if array.ndim != 2 or len(array) < 1 or not np.all(np.isfinite(array)):
165
+ raise ValueError("values must be a non-empty finite [T, D] array")
166
+ if kind not in {"linear", "hold", "pose_xyzw", "pose_euler_xyz"}:
167
+ raise ValueError("unsupported resampling kind")
168
+ if method not in _INTERPOLATION_METHODS:
169
+ raise ValueError(f"method must be one of {sorted(_INTERPOLATION_METHODS)}")
170
+ if kind == "pose_xyzw" and array.shape[1] != 7:
171
+ raise ValueError("pose_xyzw values must have shape [T, 7]")
172
+ if kind == "pose_euler_xyz" and array.shape[1] != 6:
173
+ raise ValueError("pose_euler_xyz values must have shape [T, 6]")
174
+ standard_length = resampled_horizon(
175
+ len(array), source_hz=source_rate, target_hz=target_rate
176
+ )
177
+ if source_rate == target_rate and target_length in {None, standard_length}:
178
+ if kind == "pose_euler_xyz":
179
+ array = np.concatenate(
180
+ (array[:, :3], _euler_xyz_to_quaternion(array[:, 3:6])), axis=1
181
+ )
182
+ return np.asarray(array, dtype=np.float32)
183
+
184
+ target_time = _target_times(
185
+ len(array), source_rate, target_rate, target_length=target_length
186
+ )
187
+ source_time = np.arange(len(array), dtype=np.float64) / source_rate
188
+ interpolation_time = np.minimum(target_time, source_time[-1])
189
+ source_position = target_time * source_rate
190
+ left = np.minimum(np.floor(source_position).astype(np.int64), len(array) - 1)
191
+ right = np.minimum(left + 1, len(array) - 1)
192
+ weight = source_position - left
193
+ if kind == "hold":
194
+ result = array[left]
195
+ else:
196
+ pose = kind in {"pose_xyzw", "pose_euler_xyz"}
197
+ continuous = array[:, :3] if pose else array
198
+ if len(array) == 1:
199
+ sampled = np.repeat(continuous, len(target_time), axis=0)
200
+ elif method == "pchip":
201
+ sampled = PchipInterpolator(source_time, continuous, axis=0)(
202
+ interpolation_time
203
+ )
204
+ elif method == "cubic":
205
+ sampled = _guarded_natural_cubic(
206
+ source_time, continuous, interpolation_time
207
+ )
208
+ else:
209
+ sampled = array[left, : continuous.shape[1]] + weight[:, None] * (
210
+ array[right, : continuous.shape[1]] - array[left, : continuous.shape[1]]
211
+ )
212
+ if not pose:
213
+ result = sampled
214
+ else:
215
+ quaternion = (
216
+ array[:, 3:7]
217
+ if kind == "pose_xyzw"
218
+ else _euler_xyz_to_quaternion(array[:, 3:6])
219
+ )
220
+ if len(array) == 1:
221
+ rotation = np.repeat(quaternion, len(target_time), axis=0)
222
+ elif method == "linear":
223
+ rotation = _quaternion_slerp(
224
+ quaternion[left], quaternion[right], weight
225
+ )
226
+ else:
227
+ rotation = RotationSpline(source_time, Rotation.from_quat(quaternion))(
228
+ interpolation_time
229
+ ).as_quat()
230
+ result = np.concatenate((sampled, rotation), axis=1)
231
+ return np.asarray(result, dtype=np.float32)
232
+
233
+
234
+ def eef_pose_absolute(poses: np.ndarray) -> np.ndarray:
235
+ """Convert canonical ``xyz + xyzw`` state to ``xyz + rotation_vector``."""
236
+ values = np.asarray(poses, dtype=np.float64)
237
+ if values.ndim != 2 or values.shape[1] != 7 or not np.all(np.isfinite(values)):
238
+ raise ValueError("poses must be a finite [T, 7] array")
239
+ return np.asarray(
240
+ np.concatenate((values[:, :3], _quaternion_to_rotvec(values[:, 3:7])), axis=1),
241
+ dtype=np.float32,
242
+ )
243
+
244
+
245
+ def eef_pose_components(poses: np.ndarray, representation: str) -> np.ndarray:
246
+ """Convert canonical ``xyz + xyzw`` poses to one registered source layout."""
247
+ values = np.asarray(poses, dtype=np.float64)
248
+ if values.ndim != 2 or values.shape[1] != 7 or not np.all(np.isfinite(values)):
249
+ raise ValueError("poses must be a finite [T, 7] array")
250
+ quaternion = _normalize_quaternion(values[:, 3:7])
251
+ if representation == "rotation_vector":
252
+ rotation = _quaternion_to_rotvec(quaternion)
253
+ elif representation == "euler_xyz":
254
+ rotation = Rotation.from_quat(quaternion).as_euler("xyz")
255
+ elif representation == "quaternion_xyzw":
256
+ rotation = quaternion
257
+ else:
258
+ raise ValueError("unsupported EEF rotation representation")
259
+ return np.asarray(
260
+ np.concatenate((values[:, :3], rotation), axis=1), dtype=np.float32
261
+ )
262
+
263
+
264
+ def eef_pose_from_components(
265
+ position: np.ndarray,
266
+ rotation: np.ndarray,
267
+ representation: str,
268
+ ) -> np.ndarray:
269
+ """Build canonical ``xyz + xyzw`` poses from an explicit source layout.
270
+
271
+ Args:
272
+ position (np.ndarray): ``(T, 3)`` translation in metres.
273
+ rotation (np.ndarray): ``(T, 3)`` rotation vectors/XYZ Euler angles or
274
+ ``(T, 4)`` xyzw quaternions.
275
+ representation: ``rotation_vector``, ``euler_xyz``, or
276
+ ``quaternion_xyzw``.
277
+
278
+ Returns:
279
+ np.ndarray: Finite normalized poses with shape ``(T, 7)``.
280
+ """
281
+ translation = np.asarray(position, dtype=np.float64)
282
+ orientation = np.asarray(rotation, dtype=np.float64)
283
+ if translation.ndim != 2 or translation.shape[1] != 3 or len(translation) < 1:
284
+ raise ValueError("position must have non-empty shape [T, 3]")
285
+ if len(orientation) != len(translation) or not np.all(np.isfinite(translation)):
286
+ raise ValueError("position and rotation must have matching finite trajectories")
287
+ if representation == "rotation_vector":
288
+ if orientation.ndim != 2 or orientation.shape[1] != 3:
289
+ raise ValueError("rotation_vector must have shape [T, 3]")
290
+ quaternion = _rotvec_to_quaternion(orientation)
291
+ elif representation == "euler_xyz":
292
+ if orientation.ndim != 2 or orientation.shape[1] != 3:
293
+ raise ValueError("euler_xyz must have shape [T, 3]")
294
+ quaternion = _euler_xyz_to_quaternion(orientation)
295
+ elif representation == "quaternion_xyzw":
296
+ if orientation.ndim != 2 or orientation.shape[1] != 4:
297
+ raise ValueError("quaternion_xyzw must have shape [T, 4]")
298
+ quaternion = _normalize_quaternion(orientation)
299
+ else:
300
+ raise ValueError("unsupported EEF rotation representation")
301
+ if not np.all(np.isfinite(orientation)):
302
+ raise ValueError("position and rotation must be finite")
303
+ return np.asarray(
304
+ np.concatenate((translation, quaternion), axis=1), dtype=np.float32
305
+ )
306
+
307
+
308
+ def eef_pose_local_absolute(
309
+ poses: np.ndarray, *, initial_pose: np.ndarray | None = None
310
+ ) -> np.ndarray:
311
+ """Express measured poses relative to an explicit or inferred initial pose.
312
+
313
+ Translation is relative to the initial position. Orientation is the
314
+ right-multiplicative relative pose ``q_initial^-1 * q_t``, matching the
315
+ shared EEF profile. When ``initial_pose`` is omitted, the first observation
316
+ is the initial pose and row zero is exactly zero/identity. This is a local
317
+ position trajectory, not a per-step delta sequence.
318
+ """
319
+ values = np.asarray(poses, dtype=np.float64)
320
+ if (
321
+ values.ndim != 2
322
+ or values.shape[1] != 7
323
+ or len(values) < 1
324
+ or not np.all(np.isfinite(values))
325
+ ):
326
+ raise ValueError("poses must be a finite non-empty [T, 7] array")
327
+ if initial_pose is None:
328
+ initial = values[:1]
329
+ else:
330
+ initial = np.asarray(initial_pose, dtype=np.float64)
331
+ if initial.shape == (7,):
332
+ initial = initial[None]
333
+ if initial.shape != (1, 7) or not np.all(np.isfinite(initial)):
334
+ raise ValueError("initial_pose must be a finite [7] pose")
335
+ quaternion = _normalize_quaternion(values[:, 3:7])
336
+ initial_quaternion = _normalize_quaternion(initial[:, 3:7])
337
+ inverse_start = initial_quaternion.copy()
338
+ inverse_start[:, :3] *= -1.0
339
+ relative = _quaternion_multiply(
340
+ np.broadcast_to(inverse_start, quaternion.shape), quaternion
341
+ )
342
+ return np.asarray(
343
+ np.concatenate(
344
+ (values[:, :3] - initial[:, :3], _quaternion_to_rotvec(relative)), axis=1
345
+ ),
346
+ dtype=np.float32,
347
+ )
348
+
349
+
350
+ def eef_pose_deltas(poses: np.ndarray) -> np.ndarray:
351
+ """Differentiate a local ``xyz + xyzw`` path from zero/identity.
352
+
353
+ Translation uses fixed-axis differences. Rotation uses right-multiplicative
354
+ increments ``q_previous^-1 * q_t``. The returned ``(T, 6)`` array is the
355
+ exact inverse convention of :func:`integrate_eef_deltas` before floating
356
+ point roundoff.
357
+ """
358
+ values = np.asarray(poses, dtype=np.float64)
359
+ if (
360
+ values.ndim != 2
361
+ or values.shape[1] != 7
362
+ or len(values) < 1
363
+ or not np.all(np.isfinite(values))
364
+ ):
365
+ raise ValueError("poses must be a finite non-empty [T, 7] array")
366
+ quaternion = _normalize_quaternion(values[:, 3:7])
367
+ previous_position = np.concatenate((np.zeros((1, 3)), values[:-1, :3]), axis=0)
368
+ identity = np.asarray([[0.0, 0.0, 0.0, 1.0]], dtype=np.float64)
369
+ previous_quaternion = np.concatenate((identity, quaternion[:-1]), axis=0)
370
+ inverse_previous = previous_quaternion.copy()
371
+ inverse_previous[:, :3] *= -1.0
372
+ increment = _quaternion_multiply(inverse_previous, quaternion)
373
+ return np.asarray(
374
+ np.concatenate(
375
+ (values[:, :3] - previous_position, _quaternion_to_rotvec(increment)),
376
+ axis=1,
377
+ ),
378
+ dtype=np.float32,
379
+ )
380
+
381
+
382
+ def eef_pose_from_local_absolute(
383
+ poses: np.ndarray, *, initial_pose: np.ndarray
384
+ ) -> np.ndarray:
385
+ """Attach an external initial pose to a local ``xyz + xyzw`` path."""
386
+ values = np.asarray(poses, dtype=np.float64)
387
+ initial = np.asarray(initial_pose, dtype=np.float64)
388
+ if (
389
+ values.ndim != 2
390
+ or values.shape[1] != 7
391
+ or len(values) < 1
392
+ or not np.all(np.isfinite(values))
393
+ ):
394
+ raise ValueError("poses must be a finite non-empty [T, 7] array")
395
+ if initial.shape == (7,):
396
+ initial = initial[None]
397
+ if initial.shape != (1, 7) or not np.all(np.isfinite(initial)):
398
+ raise ValueError("initial_pose must be a finite [7] pose")
399
+ local_quaternion = _normalize_quaternion(values[:, 3:7])
400
+ initial_quaternion = _normalize_quaternion(initial[:, 3:7])
401
+ quaternion = _quaternion_multiply(
402
+ np.broadcast_to(initial_quaternion, local_quaternion.shape), local_quaternion
403
+ )
404
+ return np.asarray(
405
+ np.concatenate((values[:, :3] + initial[:, :3], quaternion), axis=1),
406
+ dtype=np.float32,
407
+ )
408
+
409
+
410
+ def integrate_eef_deltas(deltas: np.ndarray) -> np.ndarray:
411
+ """Integrate ``[dx,dy,dz,body-rotvec]`` from a zero/identity pose.
412
+
413
+ Args:
414
+ deltas (np.ndarray): ``(T, 6)`` translation and right-multiplicative
415
+ rotation increments in canonical units.
416
+
417
+ Returns:
418
+ np.ndarray: ``(T, 7)`` absolute ``xyz + xyzw`` poses. Row ``t`` is the
419
+ pose after applying delta ``t``; the implicit boundary before row zero
420
+ is position zero and identity orientation.
421
+ """
422
+ values = np.asarray(deltas, dtype=np.float64)
423
+ if values.ndim != 2 or values.shape[1] != 6 or not np.all(np.isfinite(values)):
424
+ raise ValueError("deltas must be a finite [T, 6] array")
425
+ if len(values) < 1:
426
+ raise ValueError("deltas must contain at least one step")
427
+ translation = np.cumsum(values[:, :3], axis=0)
428
+ increments = _rotvec_to_quaternion(values[:, 3:6])
429
+ orientation = np.empty((len(values), 4), dtype=np.float64)
430
+ current = np.asarray([[0.0, 0.0, 0.0, 1.0]], dtype=np.float64)
431
+ for index, increment in enumerate(increments):
432
+ current = _quaternion_multiply(current, increment[None])
433
+ orientation[index] = current[0]
434
+ return np.asarray(
435
+ np.concatenate((translation, orientation), axis=1), dtype=np.float32
436
+ )
437
+
438
+
439
+ def binarize_gripper_actions(
440
+ values: np.ndarray,
441
+ *,
442
+ threshold: float,
443
+ open_is_high: bool,
444
+ ) -> np.ndarray:
445
+ """Map one controller gripper channel to canonical ``0=closed, 1=open``."""
446
+ array = np.asarray(values, dtype=np.float32)
447
+ if array.ndim != 2 or array.shape[1] != 1 or not np.all(np.isfinite(array)):
448
+ raise ValueError("gripper action must be a finite [T, 1] array")
449
+ if not math.isfinite(threshold):
450
+ raise ValueError("gripper threshold must be finite")
451
+ opened = array >= threshold if open_is_high else array <= threshold
452
+ return opened.astype(np.float32)
runtime/physical/trajectory.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Variable-width trajectory contract shared by statistics and fitting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+
10
+ from ..common._validation import active_dimensions
11
+ from .configuration import ActionCodec2Config
12
+
13
+ __all__ = ["ActiveTrajectory", "TrajectoryLike", "prepare_trajectory"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ActiveTrajectory:
18
+ """One complete trajectory over an explicit subset of codebook dimensions.
19
+
20
+ Args:
21
+ values (np.ndarray): ``(T, D_active)`` finite canonical values. ``T`` and
22
+ ``D_active`` may differ between trajectories.
23
+ active_dims: Strictly increasing global codebook dimension ids. This is
24
+ required when compact columns are not the global prefix, for example
25
+ ``[j1..j6, gripper]`` in a seven-joint-plus-gripper codebook.
26
+ previous_first_order (np.ndarray | None): ``(D_active,)`` preceding
27
+ reconstructed first-order values in canonical input units. Required
28
+ by second-order fitting; never inferred from a zero boundary.
29
+ """
30
+
31
+ values: np.ndarray
32
+ active_dims: tuple[int, ...]
33
+ previous_first_order: np.ndarray | None = None
34
+
35
+ def __init__(
36
+ self,
37
+ values: np.ndarray,
38
+ active_dims: Sequence[int],
39
+ *,
40
+ previous_first_order: np.ndarray | None = None,
41
+ ) -> None:
42
+ object.__setattr__(self, "values", np.asarray(values))
43
+ object.__setattr__(self, "active_dims", tuple(active_dims))
44
+ previous = (
45
+ None
46
+ if previous_first_order is None
47
+ else np.asarray(previous_first_order, dtype=np.float32)
48
+ )
49
+ if previous is not None and (
50
+ previous.shape != (len(self.active_dims),)
51
+ or not np.all(np.isfinite(previous))
52
+ ):
53
+ raise ValueError(
54
+ "previous_first_order must be finite with shape [D_active]"
55
+ )
56
+ object.__setattr__(self, "previous_first_order", previous)
57
+
58
+ @classmethod
59
+ def from_padded(
60
+ cls,
61
+ values: np.ndarray,
62
+ *,
63
+ active_dims: Sequence[int],
64
+ previous_first_order: np.ndarray | None = None,
65
+ ) -> ActiveTrajectory:
66
+ """Drop engineering-only dense padding before the semantic boundary.
67
+
68
+ Values in inactive columns are deliberately not inspected. They may be
69
+ zero, NaN, or any collator sentinel because they never reach physical
70
+ quantization, statistics, or Set-BPE.
71
+ """
72
+ array = np.asarray(values)
73
+ dims = tuple(int(value) for value in active_dims)
74
+ if array.ndim != 2 or not dims or min(dims) < 0 or max(dims) >= array.shape[1]:
75
+ raise ValueError(
76
+ "padded values and active_dims do not define a valid trajectory"
77
+ )
78
+ previous = (
79
+ None
80
+ if previous_first_order is None
81
+ else np.take(previous_first_order, dims)
82
+ )
83
+ return cls(np.take(array, dims, axis=1), dims, previous_first_order=previous)
84
+
85
+
86
+ def trajectory_boundary(
87
+ trajectory: TrajectoryLike, config: ActionCodec2Config, active_dims: tuple[int, ...]
88
+ ) -> np.ndarray | None:
89
+ """Expand explicit compact state to ``[D]`` for fitting and validation."""
90
+ previous = getattr(trajectory, "previous_first_order", None)
91
+ active = set(active_dims)
92
+ required = any(
93
+ c.primitive_order == 2 and active.intersection(c.dims)
94
+ for c in config.physical.components.values()
95
+ )
96
+ if previous is None and required:
97
+ raise ValueError(
98
+ "second-order trajectories require explicit previous_first_order"
99
+ )
100
+ if previous is None and not any(
101
+ c.primitive_order == 2 for c in config.physical.components.values()
102
+ ):
103
+ return None
104
+ dense = np.zeros(config.physical.input_dim, dtype=np.float32)
105
+ if previous is not None:
106
+ dense[list(active_dims)] = previous
107
+ return dense
108
+
109
+
110
+ TrajectoryLike = np.ndarray | ActiveTrajectory
111
+
112
+
113
+ def prepare_trajectory(
114
+ trajectory: TrajectoryLike,
115
+ *,
116
+ dimension: int,
117
+ index: int,
118
+ dtype: np.dtype | type = np.float32,
119
+ ) -> tuple[np.ndarray, tuple[int, ...]]:
120
+ """Validate a trajectory and return compact values plus global dimension ids.
121
+
122
+ A plain ``(T,D)`` array uses prefix dimensions ``0..D-1``. Callers must use
123
+ :class:`ActiveTrajectory` for a non-prefix layout. No padding value is part
124
+ of this public contract.
125
+ """
126
+ if isinstance(trajectory, ActiveTrajectory):
127
+ raw = trajectory.values
128
+ active_dims = trajectory.active_dims
129
+ else:
130
+ raw = trajectory
131
+ array = np.asarray(raw)
132
+ active_dims = tuple(range(array.shape[1])) if array.ndim == 2 else ()
133
+
134
+ values = np.asarray(raw, dtype=dtype)
135
+ if values.ndim != 2 or len(values) < 1 or values.shape[1] < 1:
136
+ raise ValueError(
137
+ f"trajectory {index} must have non-empty shape [T, D_active], got {values.shape}"
138
+ )
139
+ if len(active_dims) != values.shape[1]:
140
+ raise ValueError(
141
+ f"trajectory {index} active_dims has {len(active_dims)} entries for "
142
+ f"width {values.shape[1]}"
143
+ )
144
+ if any(isinstance(item, bool) or not isinstance(item, int) for item in active_dims):
145
+ raise ValueError(f"trajectory {index} active_dims must contain integers")
146
+ active_dims = active_dimensions(
147
+ active_dims, dimension, subject=f"trajectory {index} active_dims"
148
+ )
149
+ if not np.all(np.isfinite(values)):
150
+ raise ValueError(f"trajectory {index} contains a non-finite value")
151
+ return np.ascontiguousarray(values), active_dims
runtime/processing_actioncodec2.py ADDED
@@ -0,0 +1,553 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face processor over the shared ActionCodec2 frontend implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import torch
12
+ from transformers.feature_extraction_utils import BatchFeature
13
+ from transformers.processing_utils import ProcessorMixin
14
+
15
+ from .routing.defaults import CodecBuilder, ProfileBundleBuilder, builtin_action_spaces
16
+ from .integration.hf import (
17
+ PROCESSOR_CONFIG_NAME,
18
+ ROUTER_ALLOW_PATTERNS,
19
+ ActionCodec2FrontendHFConfig,
20
+ resolve_pretrained_directory,
21
+ save_huggingface_metadata,
22
+ save_model_card,
23
+ upload_pretrained_directory,
24
+ )
25
+ from .routing.presets import PresetConfig
26
+ from .routing.routing import ActionCodec2Router
27
+ from .frontend.tokenizer import ActionCodec2Tokenizer
28
+
29
+
30
+ def _saved_metadata(directory: Path) -> dict[str, Any]:
31
+ metadata = directory / PROCESSOR_CONFIG_NAME
32
+ return (
33
+ json.loads(metadata.read_text(encoding="utf-8")) if metadata.is_file() else {}
34
+ )
35
+
36
+
37
+ def _fitted_status(profiles) -> str:
38
+ status = ", ".join(sorted(profiles)) or "none; call codec.fit(episodes)"
39
+ return f"Fitted profiles: {status}\n"
40
+
41
+
42
+ class ActionCodec2(ActionCodec2Tokenizer, ProcessorMixin):
43
+ """Action-space-bound processor with lossless token serialization.
44
+
45
+ ``encode`` returns ragged token-ID rows; ``decode`` reconstructs a CPU
46
+ ``torch.float32`` tensor in the original ``(B,T,D)`` column layout.
47
+ Calling the processor wraps those same rows in a ``BatchFeature`` under
48
+ ``input_ids``. Physical quantization remains lossy as configured.
49
+
50
+ Released first- and second-order bundles both register 13 layouts: single
51
+ and dual EEF arms, single and dual arms with 6 or 7 joint coordinates, each
52
+ with absolute/delta variants and binary grippers, plus a six-continuous-
53
+ coordinate absolute layout without a separate binary gripper column.
54
+ The registered names and column meanings come from the loaded artifact.
55
+ ``print(codec)`` lists all of them; ``print(cls.describe_pretrained(path))``
56
+ inspects them before binding. ``print_action_spaces(action_dim=D)`` only
57
+ filters candidates, because shape alone cannot identify physical meaning.
58
+
59
+ Inputs accept ``(T,D)`` or ``(B,T,D)`` with any positive T; unequal-length
60
+ episodes must be encoded separately. Absolute layouts require the same
61
+ ``current_state(B,D)`` for encode/decode. Second-order physical profiles
62
+ additionally require ``previous_first_order: {component: (B,D_active)}``
63
+ at the action space's configured codec rate, independently of the
64
+ absolute/delta choice.
65
+ Boundary arrays retain B=1 for a single episode. Physical order does not
66
+ change the caller's action layout into an acceleration tensor.
67
+
68
+ Args:
69
+ backend: Optional fitted router. Omit to construct a fresh codec for fit().
70
+ action_space: Registered name defining the input tensor's semantics.
71
+ primitive_order: Physical primitive order, 1 or 2, for fresh construction.
72
+ bpe_scheme: Optional 'set_bpe' or 'bpe' override for fresh construction.
73
+ profile_configs: Optional joint/EEF physical configs, mappings or YAML paths.
74
+ Defaults ship inside the package; no repository checkout is required.
75
+ """
76
+
77
+ model_type = ActionCodec2FrontendHFConfig.model_type
78
+ model_input_names = ["input_ids"]
79
+ # Numerical artifacts are owned by the router, not modality sub-processors.
80
+ attributes = []
81
+
82
+ def __init__(
83
+ self,
84
+ backend: ActionCodec2Router | None = None,
85
+ *,
86
+ action_space: str,
87
+ primitive_order: int = 1,
88
+ bpe_scheme: str | None = None,
89
+ profile_configs: Mapping[str, object] | None = None,
90
+ ) -> None:
91
+ fresh = backend is None
92
+ if fresh:
93
+ backend = CodecBuilder(
94
+ primitive_order=primitive_order,
95
+ bpe_scheme=bpe_scheme,
96
+ profile_configs=profile_configs,
97
+ ).build()
98
+ elif (
99
+ primitive_order != 1
100
+ or bpe_scheme is not None
101
+ or profile_configs is not None
102
+ ):
103
+ raise ValueError(
104
+ "profile construction options cannot accompany an existing backend"
105
+ )
106
+ super().__init__(backend, action_space=action_space)
107
+ ProcessorMixin.__init__(self)
108
+ self._fitted_profiles = frozenset() if fresh else frozenset(backend.profiles)
109
+ self.training_report = None
110
+ self._source_directory = Path(__file__).parent
111
+
112
+ def __dir__(self):
113
+ inapplicable = {
114
+ "action_tokenizer",
115
+ "attributes",
116
+ "audio_tokenizer",
117
+ "chat_template",
118
+ "check_argument_for_proper_class",
119
+ "encode_kwargs",
120
+ "feature_extractor_class",
121
+ "from_args_and_dict",
122
+ "get_possibly_dynamic_module",
123
+ "get_processor_dict",
124
+ "model_input_names",
125
+ "model_type",
126
+ "optional_attributes",
127
+ "optional_call_args",
128
+ "apply_chat_template",
129
+ "post_process_image_text_to_text",
130
+ "pad_action_id",
131
+ "bos_blk_id",
132
+ "parts_meta",
133
+ "BAR_SENTINEL_TOKENS",
134
+ "push_to_hub",
135
+ "register_for_auto_class",
136
+ "tokenizer_class",
137
+ "to_dict",
138
+ "to_json_file",
139
+ "to_json_string",
140
+ "valid_processor_kwargs",
141
+ "validate_init_kwargs",
142
+ }
143
+ return [name for name in super().__dir__() if name not in inapplicable]
144
+
145
+ def __repr__(self) -> str:
146
+ return (
147
+ f"ActionCodec2(action_space={self.action_space.name!r}, "
148
+ f"D={self.action_dim}, vocab={self.action_tokenizer.vocab_size})"
149
+ )
150
+
151
+ @staticmethod
152
+ def builtin_action_spaces():
153
+ """Return the immutable named layout definitions shipped in the package."""
154
+ return builtin_action_spaces()
155
+
156
+ @classmethod
157
+ def from_profiles(cls, joint, eef, *, action_space: str, token_budgets=None):
158
+ """Bind an artifact assembled from existing joint/EEF profile directories.
159
+
160
+ Args:
161
+ joint: Fitted joint profile directory.
162
+ eef: Fitted EEF profile directory.
163
+ action_space: Explicit built-in tensor layout to bind.
164
+ token_budgets: Optional joint/EEF overrides; defaults to recorded
165
+ training budgets, or fitted vocabulary sizes if unavailable.
166
+ """
167
+ router = ProfileBundleBuilder(joint, eef, token_budgets=token_budgets).build()
168
+ return cls(router, action_space=action_space)
169
+
170
+ def logits_processor(
171
+ self,
172
+ horizon: int,
173
+ *,
174
+ fps: float,
175
+ prompt_length: int,
176
+ eos_token_id: int,
177
+ token_offset: int = 0,
178
+ pad_token_id=None,
179
+ ):
180
+ """Create a Transformers LogitsProcessor for a fixed action horizon.
181
+
182
+ ``prompt_length`` excludes leading model prompt IDs from the grammar;
183
+ ``token_offset`` explicitly maps codec IDs to a contiguous model range.
184
+ EOS/padding IDs must be outside that range. No model vocabulary is changed.
185
+ """
186
+ from .tokenization.generation import ActionCodec2LogitsProcessor
187
+
188
+ return ActionCodec2LogitsProcessor(
189
+ self,
190
+ horizon,
191
+ fps=fps,
192
+ prompt_length=prompt_length,
193
+ eos_token_id=eos_token_id,
194
+ token_offset=token_offset,
195
+ pad_token_id=pad_token_id,
196
+ )
197
+
198
+ def batch_decode(
199
+ self,
200
+ token_rows,
201
+ *,
202
+ fps=None,
203
+ current_state=None,
204
+ previous_first_order=None,
205
+ errors="raise",
206
+ ):
207
+ """Decode independent token rows, including different valid horizons.
208
+
209
+ Args:
210
+ token_rows: Iterable of token-ID rows. Each must cover a complete
211
+ trajectory; intended generation horizon must be checked separately
212
+ with grammar.is_complete before decode.
213
+ fps: Output sampling rate, shared by all rows.
214
+ current_state: Optional (B,D) preceding states in original layout.
215
+ previous_first_order: Optional {component: (B,D_active)} boundaries.
216
+ errors: 'raise' stops at the first invalid row; 'return' returns the
217
+ ValueError at that position so other valid rows remain usable.
218
+
219
+ Returns:
220
+ List of CPU float32 tensors shaped (1,T_i,D), or ValueError entries
221
+ when errors='return'. No truncated row is padded or executed partially.
222
+ """
223
+ self._require_fitted()
224
+ if errors not in ("raise", "return"):
225
+ raise ValueError("errors must be 'raise' or 'return'")
226
+ rows = list(token_rows)
227
+ current = (
228
+ None
229
+ if current_state is None
230
+ else np.asarray(
231
+ current_state.detach().cpu()
232
+ if isinstance(current_state, torch.Tensor)
233
+ else current_state
234
+ )
235
+ )
236
+ if current is not None and current.shape != (len(rows), self.action_dim):
237
+ raise ValueError(
238
+ f"current_state must have shape {(len(rows), self.action_dim)}"
239
+ )
240
+ previous = self._numpy_boundary(previous_first_order)
241
+ if previous is not None and any(
242
+ np.ndim(v) != 2 or len(v) != len(rows) for v in previous.values()
243
+ ):
244
+ raise ValueError(
245
+ "previous_first_order arrays must have batch dimension equal to token rows"
246
+ )
247
+ result = []
248
+ for index, row in enumerate(rows):
249
+ try:
250
+ decoded = self.decode(
251
+ [row],
252
+ fps=fps,
253
+ current_state=None
254
+ if current is None
255
+ else current[index : index + 1],
256
+ previous_first_order=None
257
+ if previous is None
258
+ else {k: v[index : index + 1] for k, v in previous.items()},
259
+ )
260
+ except ValueError as error:
261
+ error = ValueError(f"token row {index}: {error}")
262
+ if errors == "raise":
263
+ raise error from None
264
+ decoded = error
265
+ result.append(decoded)
266
+ return result
267
+
268
+ @property
269
+ def fitted_profiles(self) -> frozenset[str]:
270
+ """Names of profiles fitted on data; persisted by save_pretrained()."""
271
+ return self._fitted_profiles
272
+
273
+ @property
274
+ def is_fitted(self) -> bool:
275
+ """Whether every profile used by the selected action space is fitted."""
276
+ return all(
277
+ c.space in self.fitted_profiles for c in self.action_space.components
278
+ )
279
+
280
+ def _require_fitted(self):
281
+ missing = {c.space for c in self.action_space.components} - self.fitted_profiles
282
+ if missing:
283
+ raise RuntimeError(
284
+ f"Unfitted profiles {sorted(missing)}; call codec.fit(episodes) first"
285
+ )
286
+
287
+ def _describe_action_spaces(self, action_dim: int | None = None) -> str:
288
+ return _fitted_status(self.fitted_profiles) + super()._describe_action_spaces(
289
+ action_dim
290
+ )
291
+
292
+ def for_action_space(self, name: str) -> ActionCodec2:
293
+ """Share current weights with a new binding; subsequent fit replaces only that instance."""
294
+ codec = super().for_action_space(name)
295
+ codec._fitted_profiles = self.fitted_profiles
296
+ codec.training_report = self.training_report
297
+ codec._source_directory = self._source_directory
298
+ return codec
299
+
300
+ def fit(
301
+ self,
302
+ episodes,
303
+ *,
304
+ fps: float | None = None,
305
+ current_state=None,
306
+ previous_first_order=None,
307
+ bootstrap_context: bool = False,
308
+ backend: str = "auto",
309
+ threads: int = 0,
310
+ batch_size: int = 256,
311
+ progress: bool = False,
312
+ max_cell_bytes: int | None = None,
313
+ work_dir: str | Path | None = None,
314
+ ) -> ActionCodec2:
315
+ """Fit physical vocabularies from actions in declared source layouts.
316
+
317
+ Args:
318
+ episodes: Array/tensor (T,D), (B,T,D), or iterable of variable-length
319
+ episodes. Records may contain ``actions``, ``action_space`` (a
320
+ registered name), ``fps``, ``current_state(B,D)``, and
321
+ ``previous_first_order: {component: (B,D_active)}``. A record's
322
+ metadata must not duplicate the corresponding keyword argument.
323
+ fps: Source frequency override shared by all input episodes.
324
+ current_state: Preceding source state (B,D), required for absolute
325
+ inputs. Use per-record values when episodes have different states.
326
+ previous_first_order: Preceding canonical codec-rate increments by component,
327
+ with shape (B,D_active), required for second-order profiles.
328
+ bootstrap_context: Use two measured canonical frames as second-order
329
+ context, training on the remaining suffix. Mutually exclusive with
330
+ previous_first_order. Inference still requires explicit boundaries.
331
+ backend: Exact BPE implementation: 'auto', 'native', or 'python'.
332
+ threads: Native worker count; 0 uses the backend default.
333
+ batch_size: Maximum canonical trajectories per quantization batch.
334
+ progress: Display BPE training progress.
335
+ max_cell_bytes: Hard bound on retained quantized corpus bytes per profile.
336
+ work_dir: Parent scratch directory for sequential source staging files,
337
+ automatically removed on success or failure. Defaults to system temp.
338
+
339
+ Returns:
340
+ This instance, with per-profile results in ``training_report``. Only
341
+ profiles present in the data are fitted; other weights are preserved.
342
+ Vocabulary replacement is atomic on success. Refit changes token IDs:
343
+ regenerate previously encoded datasets before using the new vocabulary.
344
+ """
345
+ # Training is intentionally lazy: a loaded Hub artifact only needs the
346
+ # inference runtime. The full fitting pipeline remains available from
347
+ # the source package and is imported when the user calls fit().
348
+ from .training.fitting import ActionCodec2Fitter
349
+
350
+ router, report = ActionCodec2Fitter(
351
+ self, bootstrap_context=bootstrap_context
352
+ ).fit(
353
+ episodes,
354
+ work_dir=work_dir,
355
+ metadata=dict(
356
+ fps=fps,
357
+ current_state=current_state,
358
+ previous_first_order=previous_first_order,
359
+ ),
360
+ backend=backend,
361
+ threads=threads,
362
+ batch_size=batch_size,
363
+ progress=progress,
364
+ max_cell_bytes=max_cell_bytes,
365
+ )
366
+ fitted = self.fitted_profiles | report["profiles"].keys()
367
+ replacement = type(self)(router, action_space=self.action_space.name)
368
+ replacement._fitted_profiles = frozenset(fitted)
369
+ replacement.training_report = report
370
+ replacement._source_directory = self._source_directory
371
+ replacement._warned_default_fps = self._warned_default_fps
372
+ self.__dict__.update(replacement.__dict__)
373
+ return self
374
+
375
+ def _encode_action_indices(self, action, encode_kwargs=None):
376
+ self._require_fitted()
377
+ return super()._encode_action_indices(action, encode_kwargs)
378
+
379
+ def _decode_action_indices(self, action_indices, **kwargs):
380
+ self._require_fitted()
381
+ return super()._decode_action_indices(action_indices, **kwargs)
382
+
383
+ def grammar(self, horizon: int, *, fps: float | None = None):
384
+ self._require_fitted()
385
+ return super().grammar(horizon, fps=fps)
386
+
387
+ def final_first_order(
388
+ self, action_token_ids, *, previous_first_order, executed_steps=None
389
+ ):
390
+ self._require_fitted()
391
+ return super().final_first_order(
392
+ action_token_ids,
393
+ previous_first_order=previous_first_order,
394
+ executed_steps=executed_steps,
395
+ )
396
+
397
+ @classmethod
398
+ def describe_pretrained(
399
+ cls,
400
+ pretrained_model_name_or_path: str | Path,
401
+ *,
402
+ action_dim: int | None = None,
403
+ **kwargs: Any,
404
+ ) -> str:
405
+ """Inspect a local/Hub artifact before choosing an action space.
406
+
407
+ Args:
408
+ pretrained_model_name_or_path: Saved processor/router directory or
409
+ Hugging Face Hub repository ID. Loads its fitted router for inspection.
410
+ action_dim: Optional D from ``actions.shape[-1]``. Only filters the
411
+ report; tensor shape cannot establish physical semantics.
412
+ **kwargs: Hub options such as ``revision``, ``subfolder``,
413
+ ``cache_dir``, ``token``, and ``local_files_only``.
414
+
415
+ Returns:
416
+ Human-readable layout guide, suitable for ``print(...)``. No binding
417
+ is required or inferred, and no action data is encoded.
418
+ """
419
+ directory = resolve_pretrained_directory(
420
+ pretrained_model_name_or_path,
421
+ allow_patterns=ROUTER_ALLOW_PATTERNS,
422
+ **kwargs,
423
+ )
424
+ router = ActionCodec2Router.from_pretrained(directory)
425
+ metadata = _saved_metadata(directory)
426
+ return _fitted_status(
427
+ metadata.get("fitted_profiles", router.profiles)
428
+ ) + router.describe_action_spaces(
429
+ selected=metadata.get("action_space"), action_dim=action_dim
430
+ )
431
+
432
+ @classmethod
433
+ def from_pretrained(
434
+ cls,
435
+ pretrained_model_name_or_path: str | Path,
436
+ *,
437
+ action_space: str | Path | PresetConfig | Mapping[str, object] | None = None,
438
+ **kwargs: Any,
439
+ ) -> ActionCodec2:
440
+ """Load a local/Hub artifact and restore or override its action space.
441
+
442
+ Args:
443
+ pretrained_model_name_or_path: Saved processor or router directory,
444
+ or a Hugging Face Hub repository ID.
445
+ action_space: Registered name, preset YAML path, config, or mapping.
446
+ Required for an unbound router; otherwise defaults to the saved
447
+ binding. Custom definitions are saved in the router manifest.
448
+ **kwargs: Hub options including ``revision``, ``subfolder``,
449
+ ``cache_dir``, ``token``, and ``local_files_only``.
450
+ """
451
+ directory = resolve_pretrained_directory(
452
+ pretrained_model_name_or_path,
453
+ allow_patterns=ROUTER_ALLOW_PATTERNS,
454
+ **kwargs,
455
+ )
456
+ if action_space is None:
457
+ action_space = _saved_metadata(directory).get("action_space")
458
+ if action_space is None:
459
+ raise ValueError(
460
+ "action_space is required when loading an unbound router artifact. "
461
+ "First inspect its layouts with print(ActionCodec2.describe_pretrained(path)), "
462
+ "then pass action_space='NAME'; shape alone cannot identify physical meaning."
463
+ )
464
+ codec = super().from_pretrained(directory, action_space=action_space)
465
+ metadata = _saved_metadata(directory)
466
+ fitted = metadata.get("fitted_profiles", list(codec.action_tokenizer.profiles))
467
+ if not isinstance(fitted, list) or any(
468
+ not isinstance(name, str) or name not in codec.action_tokenizer.profiles
469
+ for name in fitted
470
+ ):
471
+ raise ValueError("invalid fitted_profiles in processor_config.json")
472
+ codec._fitted_profiles = frozenset(fitted)
473
+ codec._source_directory = directory
474
+ report_path = directory / "fit_report.json"
475
+ if report_path.is_file():
476
+ codec.training_report = json.loads(report_path.read_text(encoding="utf-8"))
477
+ return codec
478
+
479
+ def to_dict(self, legacy_serialization: bool = True) -> dict[str, object]:
480
+ """Return lightweight discovery metadata.
481
+
482
+ ``legacy_serialization`` is accepted for ProcessorMixin signature
483
+ compatibility. ActionCodec2 has one serialization format for both values.
484
+ """
485
+ del legacy_serialization
486
+ return {
487
+ "processor_class": type(self).__name__,
488
+ "model_type": self.model_type,
489
+ "action_space": self.action_space.name,
490
+ "fitted_profiles": sorted(self.fitted_profiles),
491
+ }
492
+
493
+ def save_pretrained(
494
+ self, save_directory: str | Path, push_to_hub: bool = False, **kwargs: Any
495
+ ) -> list[str]:
496
+ """Save the complete router and the selected action-space binding."""
497
+ if not push_to_hub and kwargs:
498
+ raise TypeError(
499
+ f"unused save_pretrained keyword(s): {', '.join(sorted(kwargs))}"
500
+ )
501
+ from .integration.exporting import ArtifactExporter
502
+
503
+ directory = Path(save_directory)
504
+ save_model_card(directory, str(self))
505
+ files = self.action_tokenizer.save_pretrained(directory, include_code=False)
506
+ files.extend(
507
+ ArtifactExporter(
508
+ directory, source_directory=self._source_directory
509
+ ).export()
510
+ )
511
+ save_huggingface_metadata(
512
+ directory,
513
+ **self.to_dict(),
514
+ auto_map={"AutoProcessor": "processing_actioncodec2.ActionCodec2"},
515
+ )
516
+ report_path = directory / "fit_report.json"
517
+ if self.training_report is not None:
518
+ report_path.write_text(
519
+ json.dumps(self.training_report, indent=2) + "\n", encoding="utf-8"
520
+ )
521
+ files.append(str(report_path))
522
+ elif report_path.exists():
523
+ report_path.unlink()
524
+ if push_to_hub:
525
+ upload_pretrained_directory(directory, **kwargs)
526
+ return files
527
+
528
+ def __call__(
529
+ self,
530
+ action: torch.Tensor | np.ndarray,
531
+ *,
532
+ fps: float | None = None,
533
+ current_state: torch.Tensor | np.ndarray | None = None,
534
+ previous_first_order: Mapping[str, torch.Tensor | np.ndarray] | None = None,
535
+ encode_kwargs: dict | None = None,
536
+ return_tensors: str | None = None,
537
+ ) -> BatchFeature:
538
+ """Prepare ``input_ids`` from ``(B,T,D)`` or a single ``(T,D)`` action.
539
+
540
+ ``current_state(B,D)`` is required for absolute actions. Second-order
541
+ artifacts require ``previous_first_order`` mapping component names to
542
+ ``(B,D_active)`` canonical increments, as in :meth:`encode`.
543
+ ``return_tensors`` may be ``"pt"`` or ``"np"`` for equal-length rows;
544
+ omitted, rows remain ragged lists without adding padding tokens.
545
+ """
546
+ rows = self.encode(
547
+ action,
548
+ fps=fps,
549
+ current_state=current_state,
550
+ previous_first_order=previous_first_order,
551
+ encode_kwargs=encode_kwargs,
552
+ )
553
+ return BatchFeature({"input_ids": rows}, tensor_type=return_tensors)
runtime/routing/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Internal routing components for ActionCodec2."""
runtime/routing/cache.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small per-instance LRU caches; no global references to fitted codecs."""
2
+
3
+ from collections import OrderedDict
4
+ from collections.abc import MutableMapping
5
+ from threading import RLock
6
+
7
+
8
+ class BoundedCache(MutableMapping):
9
+ """Evict least-recently-used entries above a fixed capacity.
10
+
11
+ Args:
12
+ capacity: Maximum retained entries (positive integer).
13
+ """
14
+
15
+ def __init__(self, capacity):
16
+ if type(capacity) is not int or capacity < 1:
17
+ raise ValueError("cache capacity must be a positive integer")
18
+ self.capacity = capacity
19
+ self._items = OrderedDict()
20
+ self._lock = RLock()
21
+
22
+ def __getitem__(self, key):
23
+ with self._lock:
24
+ value = self._items[key]
25
+ self._items.move_to_end(key)
26
+ return value
27
+
28
+ def __setitem__(self, key, value):
29
+ with self._lock:
30
+ self._items[key] = value
31
+ self._items.move_to_end(key)
32
+ while len(self._items) > self.capacity:
33
+ self._items.popitem(last=False)
34
+
35
+ def __delitem__(self, key):
36
+ with self._lock:
37
+ del self._items[key]
38
+
39
+ def __iter__(self):
40
+ with self._lock:
41
+ return iter(tuple(self._items))
42
+
43
+ def __len__(self):
44
+ with self._lock:
45
+ return len(self._items)
runtime/routing/defaults.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Package-owned configuration templates for fresh ActionCodec2 instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from ..tokenization.bpe import BPESerializer, SetBPESerializer
8
+ from ..physical.configuration import ActionCodec2Config
9
+ from ..physical.physical_processor import ActionCodec2Processor
10
+ from .presets import PresetConfig
11
+ from .routing import ActionCodec2Router
12
+
13
+
14
+ class CodecBuilder:
15
+ """Build unfitted physical profiles from packaged defaults or explicit configs.
16
+
17
+ Args:
18
+ primitive_order: First- or second-order default physical primitives.
19
+ bpe_scheme: Optional scheme override for both physical profiles.
20
+ profile_configs: Optional joint/EEF config objects, mappings, or YAML paths.
21
+ """
22
+
23
+ def __init__(self, *, primitive_order=1, bpe_scheme=None, profile_configs=None):
24
+ if type(primitive_order) is not int or primitive_order not in (1, 2):
25
+ raise ValueError("primitive_order must be 1 or 2")
26
+ if bpe_scheme not in (None, "set_bpe", "bpe"):
27
+ raise ValueError("bpe_scheme must be 'set_bpe' or 'bpe'")
28
+ if profile_configs is not None and not isinstance(profile_configs, Mapping):
29
+ raise TypeError(
30
+ "profile_configs must map 'joint'/'eef' to physical configurations"
31
+ )
32
+ self.profile_configs = dict(profile_configs or {})
33
+ if set(self.profile_configs) - {"joint", "eef"}:
34
+ raise ValueError("profile_configs keys must be 'joint' or 'eef'")
35
+ self.primitive_order = primitive_order
36
+ self.bpe_scheme = bpe_scheme
37
+
38
+ def _config(self, name: str) -> ActionCodec2Config:
39
+ suffix = "" if self.primitive_order == 1 else "_second_order_pretrain_v2"
40
+ raw = self.profile_configs.get(name, _PROFILE_DEFAULTS[f"{name}{suffix}"])
41
+ if isinstance(raw, ActionCodec2Config):
42
+ config = raw
43
+ elif isinstance(raw, Mapping):
44
+ config = ActionCodec2Config.from_dict(raw)
45
+ else:
46
+ config = ActionCodec2Config.from_yaml(raw)
47
+ if config.train is None:
48
+ raise ValueError(f"{name} profile config requires a train section")
49
+ if self.bpe_scheme is not None:
50
+ payload = config.to_dict()
51
+ payload["set_bpe"]["scheme"] = self.bpe_scheme
52
+ if self.bpe_scheme == "bpe":
53
+ payload["train"]["bpe"]["spatial_vocab_size"] = payload["train"]["bpe"][
54
+ "vocab_size"
55
+ ]
56
+ config = ActionCodec2Config.from_dict(payload)
57
+ return config
58
+
59
+ def build(self) -> ActionCodec2Router:
60
+ """Create primitive-only profiles; the public facade tracks fitted state."""
61
+ processors = {}
62
+ budgets = {}
63
+ for name in ("joint", "eef"):
64
+ config = self._config(name)
65
+ # Keep training-only modules out of the inference artifact. They
66
+ # are imported only when a fresh codec is constructed for fitting.
67
+ from ..training.training import build_primitives
68
+
69
+ primitives = build_primitives(config)
70
+ if config.set_bpe.scheme == "bpe":
71
+ serializer = BPESerializer(
72
+ bins=primitives.bins, lookahead=config.set_bpe.lookahead, merges=()
73
+ )
74
+ else:
75
+ serializer = SetBPESerializer(
76
+ bins=primitives.bins,
77
+ lookahead=config.set_bpe.lookahead,
78
+ spatial_merges=(),
79
+ temporal_merges=(),
80
+ )
81
+ processors[name] = ActionCodec2Processor(config, primitives, serializer)
82
+ budgets[name] = config.train.bpe.vocab_size
83
+ presets = list(builtin_action_spaces().values())
84
+ return ActionCodec2Router.from_processors(
85
+ processors, token_budgets=budgets, presets=presets
86
+ )
87
+
88
+
89
+ def builtin_action_spaces():
90
+ """Return a read-only name-to-preset mapping shipped in the installed package."""
91
+ from types import MappingProxyType
92
+
93
+ presets = [PresetConfig.from_dict(value) for value in _PRESET_DEFAULTS.values()]
94
+ return MappingProxyType({preset.name: preset for preset in presets})
95
+
96
+
97
+ class ProfileBundleBuilder:
98
+ """Assemble two existing physical artifacts using their recorded vocabulary budgets.
99
+
100
+ Args:
101
+ joint: Local directory containing the fitted joint profile.
102
+ eef: Local directory containing the fitted EEF profile.
103
+ token_budgets: Optional explicit joint/EEF budget overrides. Defaults to
104
+ recorded training budgets, or the fitted vocabulary size if absent.
105
+ """
106
+
107
+ def __init__(self, joint, eef, *, token_budgets=None):
108
+ self.paths = {"joint": joint, "eef": eef}
109
+ self.budgets = dict(token_budgets or {})
110
+ if self.budgets.keys() - self.paths.keys():
111
+ raise ValueError("token_budgets accepts only 'joint' and 'eef'")
112
+
113
+ def build(self):
114
+ processors = {
115
+ name: ActionCodec2Processor.from_pretrained(path)
116
+ for name, path in self.paths.items()
117
+ }
118
+ budgets = {
119
+ name: self.budgets.get(
120
+ name,
121
+ processor.config.train.bpe.vocab_size
122
+ if processor.config.train is not None
123
+ else processor.vocab_size,
124
+ )
125
+ for name, processor in processors.items()
126
+ }
127
+ return ActionCodec2Router.from_processors(
128
+ processors,
129
+ token_budgets=budgets,
130
+ presets=list(builtin_action_spaces().values()),
131
+ )
132
+
133
+
134
+ _PROFILE_DEFAULTS = {
135
+ "eef": {
136
+ "model_type": "actioncodec2_unified",
137
+ "config_version": 1,
138
+ "physical": {
139
+ "input_dim": 7,
140
+ "components": {
141
+ "translation_m": {
142
+ "dims": {"start": 0, "stop": 3},
143
+ "space": "additive",
144
+ "input_order": 0,
145
+ "primitive_order": 1,
146
+ "input_transform": {"scale": 1.0, "clip": False},
147
+ "constraints": {
148
+ "order_0": {"min": -2.0, "max": 2.0},
149
+ "order_1": {"min": -0.08, "max": 0.08},
150
+ },
151
+ "distortion_limit": 0.005,
152
+ "primitive_selection": "smooth",
153
+ },
154
+ "orientation_so3": {
155
+ "dims": {"start": 3, "stop": 6},
156
+ "space": "so3",
157
+ "input_order": 0,
158
+ "primitive_order": 1,
159
+ "input_transform": {"scale": 1.0, "clip": False},
160
+ "constraints": {
161
+ "order_0": {
162
+ "min": -3.141592653589793,
163
+ "max": 3.141592653589793,
164
+ },
165
+ "order_1": {"min": -0.25, "max": 0.25},
166
+ },
167
+ "distortion_limit": 0.02,
168
+ "primitive_selection": "smooth",
169
+ "representation": "rotation_vector",
170
+ "multiplication": "right",
171
+ },
172
+ "gripper_open_command": {
173
+ "dims": [6],
174
+ "space": "additive",
175
+ "input_order": 0,
176
+ "primitive_order": 0,
177
+ "input_transform": {"scale": 1.0, "clip": True},
178
+ "constraints": {"order_0": {"min": 0.0, "max": 1.0}},
179
+ "distortion_limit": 0.5,
180
+ "primitive_selection": "nearest",
181
+ },
182
+ },
183
+ },
184
+ "set_bpe": {"lookahead": 8, "allow_cross_component_merge": False},
185
+ "train": {
186
+ "primitives": {
187
+ "zero_primitive_components": ["translation_m", "orientation_so3"]
188
+ },
189
+ "bpe": {"spatial_vocab_size": 2048, "vocab_size": 4096},
190
+ "quality_control": {"hard_order_1_scale": 3.0, "min_segment_length": 2},
191
+ },
192
+ },
193
+ "eef_second_order_pretrain_v2": {
194
+ "model_type": "actioncodec2_unified",
195
+ "config_version": 1,
196
+ "physical": {
197
+ "input_dim": 7,
198
+ "components": {
199
+ "translation_m": {
200
+ "dims": {"start": 0, "stop": 3},
201
+ "space": "additive",
202
+ "input_order": 0,
203
+ "primitive_order": 2,
204
+ "input_transform": {"scale": 1.0, "clip": False},
205
+ "constraints": {
206
+ "order_0": {"min": -2.0, "max": 2.0},
207
+ "order_2": {"min": -0.025, "max": 0.025},
208
+ "order_1": {"min": -0.08, "max": 0.08},
209
+ },
210
+ "distortion_limit": 0.005,
211
+ "primitive_selection": "smooth",
212
+ },
213
+ "orientation_so3": {
214
+ "dims": {"start": 3, "stop": 6},
215
+ "space": "so3",
216
+ "input_order": 0,
217
+ "primitive_order": 2,
218
+ "input_transform": {"scale": 1.0, "clip": False},
219
+ "constraints": {
220
+ "order_0": {
221
+ "min": -3.141592653589793,
222
+ "max": 3.141592653589793,
223
+ },
224
+ "order_2": {"min": -0.08, "max": 0.08},
225
+ "order_1": {"min": -0.25, "max": 0.25},
226
+ },
227
+ "distortion_limit": 0.02,
228
+ "primitive_selection": "smooth",
229
+ "representation": "rotation_vector",
230
+ "multiplication": "right",
231
+ },
232
+ "gripper_open_command": {
233
+ "dims": [6],
234
+ "space": "additive",
235
+ "input_order": 0,
236
+ "primitive_order": 0,
237
+ "input_transform": {"scale": 1.0, "clip": True},
238
+ "constraints": {"order_0": {"min": 0.0, "max": 1.0}},
239
+ "distortion_limit": 0.5,
240
+ "primitive_selection": "nearest",
241
+ },
242
+ },
243
+ },
244
+ "set_bpe": {"lookahead": 8, "allow_cross_component_merge": False},
245
+ "train": {
246
+ "primitives": {
247
+ "zero_primitive_components": ["translation_m", "orientation_so3"]
248
+ },
249
+ "bpe": {"spatial_vocab_size": 2048, "vocab_size": 4096},
250
+ "quality_control": {"hard_order_1_scale": 3.0, "min_segment_length": 2},
251
+ },
252
+ },
253
+ "joint": {
254
+ "model_type": "actioncodec2_unified",
255
+ "config_version": 1,
256
+ "physical": {
257
+ "input_dim": 8,
258
+ "components": {
259
+ "generalized_joint_position": {
260
+ "dims": {"start": 0, "stop": 7},
261
+ "space": "additive",
262
+ "input_order": 0,
263
+ "primitive_order": 1,
264
+ "input_transform": {"scale": 1.0, "clip": False},
265
+ "constraints": {
266
+ "order_0": {
267
+ "min": -6.283185307179586,
268
+ "max": 6.283185307179586,
269
+ },
270
+ "order_1": {"min": -0.35, "max": 0.35},
271
+ },
272
+ "distortion_limit": [
273
+ 0.004,
274
+ 0.004,
275
+ 0.006,
276
+ 0.0075,
277
+ 0.015,
278
+ 0.02,
279
+ 0.02,
280
+ ],
281
+ "primitive_selection": "smooth",
282
+ },
283
+ "gripper_open_command": {
284
+ "dims": [7],
285
+ "space": "additive",
286
+ "input_order": 0,
287
+ "primitive_order": 0,
288
+ "input_transform": {"scale": 1.0, "clip": True},
289
+ "constraints": {"order_0": {"min": 0.0, "max": 1.0}},
290
+ "distortion_limit": 0.5,
291
+ "primitive_selection": "nearest",
292
+ },
293
+ },
294
+ },
295
+ "set_bpe": {"lookahead": 8, "allow_cross_component_merge": False},
296
+ "train": {
297
+ "primitives": {"zero_primitive_components": ["generalized_joint_position"]},
298
+ "bpe": {"spatial_vocab_size": 2048, "vocab_size": 4096},
299
+ "quality_control": {"hard_order_1_scale": 3.0, "min_segment_length": 2},
300
+ },
301
+ },
302
+ "joint_second_order_pretrain_v2": {
303
+ "model_type": "actioncodec2_unified",
304
+ "config_version": 1,
305
+ "physical": {
306
+ "input_dim": 8,
307
+ "components": {
308
+ "generalized_joint_position": {
309
+ "dims": {"start": 0, "stop": 7},
310
+ "space": "additive",
311
+ "input_order": 0,
312
+ "primitive_order": 2,
313
+ "input_transform": {"scale": 1.0, "clip": False},
314
+ "constraints": {
315
+ "order_0": {
316
+ "min": -6.283185307179586,
317
+ "max": 6.283185307179586,
318
+ },
319
+ "order_2": {"min": -0.25, "max": 0.25},
320
+ "order_1": {"min": -0.35, "max": 0.35},
321
+ },
322
+ "distortion_limit": [
323
+ 0.004,
324
+ 0.004,
325
+ 0.006,
326
+ 0.0075,
327
+ 0.015,
328
+ 0.02,
329
+ 0.02,
330
+ ],
331
+ "primitive_selection": "smooth",
332
+ },
333
+ "gripper_open_command": {
334
+ "dims": [7],
335
+ "space": "additive",
336
+ "input_order": 0,
337
+ "primitive_order": 0,
338
+ "input_transform": {"scale": 1.0, "clip": True},
339
+ "constraints": {"order_0": {"min": 0.0, "max": 1.0}},
340
+ "distortion_limit": 0.5,
341
+ "primitive_selection": "nearest",
342
+ },
343
+ },
344
+ },
345
+ "set_bpe": {"lookahead": 8, "allow_cross_component_merge": False},
346
+ "train": {
347
+ "primitives": {"zero_primitive_components": ["generalized_joint_position"]},
348
+ "bpe": {"spatial_vocab_size": 2048, "vocab_size": 4096},
349
+ "quality_control": {"hard_order_1_scale": 3.0, "min_segment_length": 2},
350
+ },
351
+ },
352
+ }
353
+
354
+ _PRESET_DEFAULTS = {
355
+ "dual_eef_abs": {
356
+ "name": "dual_eef_abs",
357
+ "input_dim": 14,
358
+ "input": "absolute",
359
+ "source_hz": 15.0,
360
+ "target_hz": 15.0,
361
+ "interpolation": "cubic",
362
+ "components": {
363
+ "left_arm": {
364
+ "space": "eef",
365
+ "position_dims": [0, 1, 2],
366
+ "rotation_dims": [3, 4, 5],
367
+ "rotation_format": "rotation_vector",
368
+ "gripper_dim": 6,
369
+ "gripper_threshold": 0.8,
370
+ "gripper_open_is_high": True,
371
+ },
372
+ "right_arm": {
373
+ "space": "eef",
374
+ "position_dims": [7, 8, 9],
375
+ "rotation_dims": [10, 11, 12],
376
+ "rotation_format": "rotation_vector",
377
+ "gripper_dim": 13,
378
+ "gripper_threshold": 0.8,
379
+ "gripper_open_is_high": True,
380
+ },
381
+ },
382
+ },
383
+ "dual_eef_delta": {
384
+ "name": "dual_eef_delta",
385
+ "input_dim": 14,
386
+ "input": "delta",
387
+ "source_hz": 15.0,
388
+ "target_hz": 15.0,
389
+ "interpolation": "cubic",
390
+ "components": {
391
+ "left_arm": {
392
+ "space": "eef",
393
+ "position_dims": [0, 1, 2],
394
+ "rotation_dims": [3, 4, 5],
395
+ "rotation_format": "rotation_vector",
396
+ "gripper_dim": 6,
397
+ "gripper_threshold": 0.8,
398
+ "gripper_open_is_high": True,
399
+ },
400
+ "right_arm": {
401
+ "space": "eef",
402
+ "position_dims": [7, 8, 9],
403
+ "rotation_dims": [10, 11, 12],
404
+ "rotation_format": "rotation_vector",
405
+ "gripper_dim": 13,
406
+ "gripper_threshold": 0.8,
407
+ "gripper_open_is_high": True,
408
+ },
409
+ },
410
+ },
411
+ "dual_joint6_abs": {
412
+ "name": "dual_joint6_abs",
413
+ "input_dim": 14,
414
+ "input": "absolute",
415
+ "source_hz": 15.0,
416
+ "target_hz": 15.0,
417
+ "interpolation": "cubic",
418
+ "components": {
419
+ "left_arm": {
420
+ "space": "joint",
421
+ "joint_dims": [0, 1, 2, 3, 4, 5],
422
+ "gripper_dim": 6,
423
+ "gripper_threshold": 0.8,
424
+ "gripper_open_is_high": True,
425
+ },
426
+ "right_arm": {
427
+ "space": "joint",
428
+ "joint_dims": [7, 8, 9, 10, 11, 12],
429
+ "gripper_dim": 13,
430
+ "gripper_threshold": 0.8,
431
+ "gripper_open_is_high": True,
432
+ },
433
+ },
434
+ },
435
+ "dual_joint6_delta": {
436
+ "name": "dual_joint6_delta",
437
+ "input_dim": 14,
438
+ "input": "delta",
439
+ "source_hz": 15.0,
440
+ "target_hz": 15.0,
441
+ "interpolation": "cubic",
442
+ "components": {
443
+ "left_arm": {
444
+ "space": "joint",
445
+ "joint_dims": [0, 1, 2, 3, 4, 5],
446
+ "gripper_dim": 6,
447
+ "gripper_threshold": 0.8,
448
+ "gripper_open_is_high": True,
449
+ },
450
+ "right_arm": {
451
+ "space": "joint",
452
+ "joint_dims": [7, 8, 9, 10, 11, 12],
453
+ "gripper_dim": 13,
454
+ "gripper_threshold": 0.8,
455
+ "gripper_open_is_high": True,
456
+ },
457
+ },
458
+ },
459
+ "dual_joint7_abs": {
460
+ "name": "dual_joint7_abs",
461
+ "input_dim": 16,
462
+ "input": "absolute",
463
+ "source_hz": 15.0,
464
+ "target_hz": 15.0,
465
+ "interpolation": "cubic",
466
+ "components": {
467
+ "left_arm": {
468
+ "space": "joint",
469
+ "joint_dims": [0, 1, 2, 3, 4, 5, 6],
470
+ "gripper_dim": 7,
471
+ "gripper_threshold": 0.8,
472
+ "gripper_open_is_high": True,
473
+ },
474
+ "right_arm": {
475
+ "space": "joint",
476
+ "joint_dims": [8, 9, 10, 11, 12, 13, 14],
477
+ "gripper_dim": 15,
478
+ "gripper_threshold": 0.8,
479
+ "gripper_open_is_high": True,
480
+ },
481
+ },
482
+ },
483
+ "dual_joint7_delta": {
484
+ "name": "dual_joint7_delta",
485
+ "input_dim": 16,
486
+ "input": "delta",
487
+ "source_hz": 15.0,
488
+ "target_hz": 15.0,
489
+ "interpolation": "cubic",
490
+ "components": {
491
+ "left_arm": {
492
+ "space": "joint",
493
+ "joint_dims": [0, 1, 2, 3, 4, 5, 6],
494
+ "gripper_dim": 7,
495
+ "gripper_threshold": 0.8,
496
+ "gripper_open_is_high": True,
497
+ },
498
+ "right_arm": {
499
+ "space": "joint",
500
+ "joint_dims": [8, 9, 10, 11, 12, 13, 14],
501
+ "gripper_dim": 15,
502
+ "gripper_threshold": 0.8,
503
+ "gripper_open_is_high": True,
504
+ },
505
+ },
506
+ },
507
+ "single_eef_abs": {
508
+ "name": "single_eef_abs",
509
+ "input_dim": 7,
510
+ "input": "absolute",
511
+ "source_hz": 15.0,
512
+ "target_hz": 15.0,
513
+ "interpolation": "cubic",
514
+ "components": {
515
+ "arm": {
516
+ "space": "eef",
517
+ "position_dims": [0, 1, 2],
518
+ "rotation_dims": [3, 4, 5],
519
+ "rotation_format": "rotation_vector",
520
+ "gripper_dim": 6,
521
+ "gripper_threshold": 0.8,
522
+ "gripper_open_is_high": True,
523
+ }
524
+ },
525
+ },
526
+ "single_eef_delta": {
527
+ "name": "single_eef_delta",
528
+ "input_dim": 7,
529
+ "input": "delta",
530
+ "source_hz": 15.0,
531
+ "target_hz": 15.0,
532
+ "interpolation": "cubic",
533
+ "components": {
534
+ "arm": {
535
+ "space": "eef",
536
+ "position_dims": [0, 1, 2],
537
+ "rotation_dims": [3, 4, 5],
538
+ "rotation_format": "rotation_vector",
539
+ "gripper_dim": 6,
540
+ "gripper_threshold": 0.8,
541
+ "gripper_open_is_high": True,
542
+ }
543
+ },
544
+ },
545
+ "single_joint6_abs": {
546
+ "name": "single_joint6_abs",
547
+ "input_dim": 7,
548
+ "input": "absolute",
549
+ "source_hz": 15.0,
550
+ "target_hz": 15.0,
551
+ "interpolation": "cubic",
552
+ "components": {
553
+ "arm": {
554
+ "space": "joint",
555
+ "joint_dims": [0, 1, 2, 3, 4, 5],
556
+ "gripper_dim": 6,
557
+ "gripper_threshold": 0.8,
558
+ "gripper_open_is_high": True,
559
+ }
560
+ },
561
+ },
562
+ "single_joint6_abs_continuous_gripper": {
563
+ "name": "single_joint6_abs_continuous_gripper",
564
+ "input_dim": 6,
565
+ "input": "absolute",
566
+ "source_hz": 15.0,
567
+ "target_hz": 15.0,
568
+ "interpolation": "cubic",
569
+ "components": {"arm": {"space": "joint", "joint_dims": [0, 1, 2, 3, 4, 5]}},
570
+ },
571
+ "single_joint6_delta": {
572
+ "name": "single_joint6_delta",
573
+ "input_dim": 7,
574
+ "input": "delta",
575
+ "source_hz": 15.0,
576
+ "target_hz": 15.0,
577
+ "interpolation": "cubic",
578
+ "components": {
579
+ "arm": {
580
+ "space": "joint",
581
+ "joint_dims": [0, 1, 2, 3, 4, 5],
582
+ "gripper_dim": 6,
583
+ "gripper_threshold": 0.8,
584
+ "gripper_open_is_high": True,
585
+ }
586
+ },
587
+ },
588
+ "single_joint7_abs": {
589
+ "name": "single_joint7_abs",
590
+ "input_dim": 8,
591
+ "input": "absolute",
592
+ "source_hz": 15.0,
593
+ "target_hz": 15.0,
594
+ "interpolation": "cubic",
595
+ "components": {
596
+ "arm": {
597
+ "space": "joint",
598
+ "joint_dims": [0, 1, 2, 3, 4, 5, 6],
599
+ "gripper_dim": 7,
600
+ "gripper_threshold": 0.8,
601
+ "gripper_open_is_high": True,
602
+ }
603
+ },
604
+ },
605
+ "single_joint7_delta": {
606
+ "name": "single_joint7_delta",
607
+ "input_dim": 8,
608
+ "input": "delta",
609
+ "source_hz": 15.0,
610
+ "target_hz": 15.0,
611
+ "interpolation": "cubic",
612
+ "components": {
613
+ "arm": {
614
+ "space": "joint",
615
+ "joint_dims": [0, 1, 2, 3, 4, 5, 6],
616
+ "gripper_dim": 7,
617
+ "gripper_threshold": 0.8,
618
+ "gripper_open_is_high": True,
619
+ }
620
+ },
621
+ },
622
+ }
runtime/routing/presets.py ADDED
@@ -0,0 +1,872 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-facing action layouts compiled into shared joint/EEF trajectories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import yaml
12
+
13
+ from ..common._validation import optional_finite_array, validate_dimension_partition
14
+ from ..physical.configuration import (
15
+ ConfigError,
16
+ _integer,
17
+ _mapping,
18
+ _number,
19
+ _reject_unknown,
20
+ _required,
21
+ _UniqueKeyLoader,
22
+ )
23
+ from ..physical.semantic import (
24
+ binarize_gripper_actions,
25
+ eef_pose_absolute,
26
+ eef_pose_components,
27
+ eef_pose_deltas,
28
+ eef_pose_from_components,
29
+ eef_pose_from_local_absolute,
30
+ eef_pose_local_absolute,
31
+ integrate_eef_deltas,
32
+ resample_uniform,
33
+ )
34
+ from ..physical.trajectory import ActiveTrajectory
35
+
36
+ _NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
37
+ _SPACES = {"eef", "joint"}
38
+ _INPUTS = {"absolute", "delta"}
39
+ _ROTATIONS = {"rotation_vector", "euler_xyz", "quaternion_xyzw"}
40
+ _INTERPOLATIONS = {"linear", "pchip", "cubic"}
41
+ _GRIPPER_DIM = {"eef": 6, "joint": 7}
42
+
43
+
44
+ def _format_dims(dims: Sequence[int]) -> str:
45
+ values = tuple(dims)
46
+ if len(values) == 1:
47
+ return str(values[0])
48
+ if values == tuple(range(values[0], values[-1] + 1)):
49
+ return f"{values[0]}..{values[-1]}"
50
+ return ",".join(map(str, values))
51
+
52
+
53
+ def _dims(value: object, path: str) -> tuple[int, ...]:
54
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
55
+ raise ConfigError(f"{path} must be a list of dimensions")
56
+ output = tuple(
57
+ _integer(item, f"{path}[{index}]") for index, item in enumerate(value)
58
+ )
59
+ if not output:
60
+ raise ConfigError(f"{path} must not be empty")
61
+ if len(set(output)) != len(output):
62
+ raise ConfigError(f"{path} contains a repeated dimension")
63
+ return output
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ComponentSpec:
68
+ """One independently encoded arm/body component in an input tensor.
69
+
70
+ ``dims`` always refer to source tensor columns. Component names separate
71
+ BPE boundaries and output ordering, but never select primitive/BPE state.
72
+ The ``space`` field alone routes values to the shared joint or EEF profile.
73
+
74
+ Args:
75
+ name: Component label, such as ``arm``, ``left_arm``, or ``right_arm``;
76
+ also the key for component-wise first-order boundaries.
77
+ space: ``joint`` for generalized joint coordinates, or ``eef`` for
78
+ end-effector position and orientation.
79
+ joint_dims: Ordered source columns for 1..7 joint coordinates. Revolute
80
+ coordinates use radians; other quantities need a canonical mapping.
81
+ position_dims: Three source columns for EEF x, y, z translation in meters.
82
+ rotation_dims: Source rotation columns, in the order of ``rotation_format``.
83
+ rotation_format: ``rotation_vector`` (axis times angle in radians),
84
+ ``euler_xyz`` (extrinsic XYZ angles in radians), or
85
+ ``quaternion_xyzw`` (unit quaternion). EEF delta layouts require
86
+ rotation vectors interpreted as right-multiplicative increments.
87
+ gripper_dim: Optional binary controller-command column. Omitted means
88
+ no separate binary gripper channel; it does not add a source column.
89
+ gripper_threshold: Inclusive threshold for open. Defaults to 0.8 when
90
+ high means open, or 0.2 when low means open.
91
+ gripper_open_is_high: If true, values >= threshold mean open; otherwise
92
+ values <= threshold mean open. Decode uses 1=open and 0=closed in
93
+ both absolute and delta layouts.
94
+ """
95
+
96
+ name: str
97
+ space: str
98
+ joint_dims: tuple[int, ...] | None = None
99
+ position_dims: tuple[int, ...] | None = None
100
+ rotation_dims: tuple[int, ...] | None = None
101
+ rotation_format: str = "rotation_vector"
102
+ gripper_dim: int | None = None
103
+ gripper_threshold: float | None = None
104
+ gripper_open_is_high: bool = True
105
+
106
+ def __post_init__(self) -> None:
107
+ if not isinstance(self.name, str) or _NAME.fullmatch(self.name) is None:
108
+ raise ConfigError(f"component name must match {_NAME.pattern!r}")
109
+ if self.space not in _SPACES:
110
+ raise ConfigError(f"component {self.name!r}.space must be joint or eef")
111
+ if self.rotation_format not in _ROTATIONS:
112
+ raise ConfigError(
113
+ f"component {self.name!r}.rotation_format must be one of {sorted(_ROTATIONS)}"
114
+ )
115
+ for label, dims in (
116
+ ("joint_dims", self.joint_dims),
117
+ ("position_dims", self.position_dims),
118
+ ("rotation_dims", self.rotation_dims),
119
+ ):
120
+ if dims is not None:
121
+ _dims(dims, f"component {self.name}.{label}")
122
+ self._validate_motion_dims()
123
+ raw_dims = self.source_dims
124
+ if self.gripper_dim is not None:
125
+ _integer(self.gripper_dim, f"component {self.name}.gripper_dim")
126
+ if self.gripper_dim in raw_dims:
127
+ raise ConfigError(
128
+ f"component {self.name!r}.gripper_dim overlaps motion dims"
129
+ )
130
+ if not isinstance(self.gripper_open_is_high, bool):
131
+ raise ConfigError(
132
+ f"component {self.name!r}.gripper_open_is_high must be boolean"
133
+ )
134
+ threshold = self.gripper_threshold
135
+ if threshold is None:
136
+ threshold = 0.8 if self.gripper_open_is_high else 0.2
137
+ object.__setattr__(self, "gripper_threshold", threshold)
138
+ _number(threshold, f"component {self.name}.gripper_threshold")
139
+
140
+ def _validate_motion_dims(self) -> None:
141
+ if self.space == "joint":
142
+ if self.joint_dims is None or not 1 <= len(self.joint_dims) <= 7:
143
+ raise ConfigError(
144
+ f"joint component {self.name!r} requires 1..7 joint_dims"
145
+ )
146
+ if self.position_dims is not None or self.rotation_dims is not None:
147
+ raise ConfigError(
148
+ f"joint component {self.name!r} cannot define EEF dims"
149
+ )
150
+ return
151
+ if self.joint_dims is not None:
152
+ raise ConfigError(f"EEF component {self.name!r} cannot define joint_dims")
153
+ if self.position_dims is None or len(self.position_dims) != 3:
154
+ raise ConfigError(
155
+ f"EEF component {self.name!r} requires three position_dims"
156
+ )
157
+ expected = 4 if self.rotation_format == "quaternion_xyzw" else 3
158
+ if self.rotation_dims is None or len(self.rotation_dims) != expected:
159
+ raise ConfigError(
160
+ f"EEF component {self.name!r} requires {expected} rotation_dims for "
161
+ f"{self.rotation_format}"
162
+ )
163
+ if set(self.position_dims).intersection(self.rotation_dims):
164
+ raise ConfigError(f"EEF component {self.name!r} reuses a pose dimension")
165
+
166
+ @property
167
+ def source_dims(self) -> tuple[int, ...]:
168
+ """All source motion columns in canonical component order."""
169
+ if self.space == "joint":
170
+ assert self.joint_dims is not None
171
+ return self.joint_dims
172
+ assert self.position_dims is not None and self.rotation_dims is not None
173
+ return (*self.position_dims, *self.rotation_dims)
174
+
175
+ @property
176
+ def all_source_dims(self) -> tuple[int, ...]:
177
+ """Motion plus optional binary-gripper source columns."""
178
+ return (
179
+ self.source_dims
180
+ if self.gripper_dim is None
181
+ else (*self.source_dims, self.gripper_dim)
182
+ )
183
+
184
+ @property
185
+ def active_dims(self) -> tuple[int, ...]:
186
+ """Shared-profile slots emitted by this component."""
187
+ motion_width = len(self.joint_dims or ()) if self.space == "joint" else 6
188
+ dims = tuple(range(motion_width))
189
+ return dims if self.gripper_dim is None else (*dims, _GRIPPER_DIM[self.space])
190
+
191
+ @classmethod
192
+ def from_dict(cls, name: str, payload: object, *, path: str) -> ComponentSpec:
193
+ values = _mapping(payload, path)
194
+ allowed = {
195
+ "space",
196
+ "joint_dims",
197
+ "position_dims",
198
+ "rotation_dims",
199
+ "rotation_format",
200
+ "gripper_dim",
201
+ "gripper_threshold",
202
+ "gripper_open_is_high",
203
+ }
204
+ _reject_unknown(values, allowed, path)
205
+ gripper_dim = values.get("gripper_dim")
206
+ if gripper_dim is not None:
207
+ gripper_dim = _integer(gripper_dim, f"{path}.gripper_dim")
208
+ return cls(
209
+ name=name,
210
+ space=str(_required(values, "space", path)),
211
+ joint_dims=None
212
+ if "joint_dims" not in values
213
+ else _dims(values["joint_dims"], f"{path}.joint_dims"),
214
+ position_dims=None
215
+ if "position_dims" not in values
216
+ else _dims(values["position_dims"], f"{path}.position_dims"),
217
+ rotation_dims=None
218
+ if "rotation_dims" not in values
219
+ else _dims(values["rotation_dims"], f"{path}.rotation_dims"),
220
+ rotation_format=str(values.get("rotation_format", "rotation_vector")),
221
+ gripper_dim=gripper_dim,
222
+ gripper_threshold=(
223
+ None
224
+ if values.get("gripper_threshold") is None
225
+ else _number(values["gripper_threshold"], f"{path}.gripper_threshold")
226
+ ),
227
+ gripper_open_is_high=values.get("gripper_open_is_high", True),
228
+ )
229
+
230
+ def to_dict(self) -> dict[str, object]:
231
+ payload: dict[str, object] = {"space": self.space}
232
+ if self.space == "joint":
233
+ payload["joint_dims"] = list(self.joint_dims or ())
234
+ else:
235
+ payload.update(
236
+ {
237
+ "position_dims": list(self.position_dims or ()),
238
+ "rotation_dims": list(self.rotation_dims or ()),
239
+ "rotation_format": self.rotation_format,
240
+ }
241
+ )
242
+ if self.gripper_dim is not None:
243
+ payload.update(
244
+ {
245
+ "gripper_dim": self.gripper_dim,
246
+ "gripper_threshold": self.gripper_threshold,
247
+ "gripper_open_is_high": self.gripper_open_is_high,
248
+ }
249
+ )
250
+ return payload
251
+
252
+
253
+ @dataclass(frozen=True)
254
+ class PresetConfig:
255
+ """A dataset- and embodiment-independent physical ``[B,T,D]`` contract.
256
+
257
+ Names describe physical structure and control semantics. Dataset field
258
+ selection, normalization, and benchmark identity belong outside presets.
259
+
260
+ Repeating components supports multi-arm layouts; their source columns must
261
+ partition all D columns exactly once. The same layout can be used with
262
+ first- or second-order physical processors; primitive order is not selected
263
+ by this preset. See ``describe()`` for the complete source-column contract.
264
+
265
+ Args:
266
+ name: Registered layout name describing physical structure and semantics.
267
+ input_dim: Total D in a source tensor ``(B,T,D)``; includes any grippers.
268
+ input: ``absolute`` targets or per-step ``delta`` motion increments.
269
+ The public frontend requires ``current_state(B,D)`` for absolute
270
+ inputs and omits it for delta inputs. Grippers remain commands.
271
+ components: Ordered joint/EEF component definitions with disjoint source
272
+ columns, including their optional binary gripper columns.
273
+ source_hz: Default source sampling rate, overridden by runtime ``fps``.
274
+ target_hz: Physical codec clock used by the token stream.
275
+ interpolation: Motion resampling method: ``linear``, ``pchip`` or
276
+ ``cubic``. Binary gripper commands use hold interpolation.
277
+ """
278
+
279
+ name: str
280
+ input_dim: int
281
+ input: str
282
+ components: tuple[ComponentSpec, ...]
283
+ source_hz: float = 15.0
284
+ target_hz: float = 15.0
285
+ interpolation: str = "cubic"
286
+
287
+ def __post_init__(self) -> None:
288
+ if not isinstance(self.name, str) or _NAME.fullmatch(self.name) is None:
289
+ raise ConfigError(f"preset.name must match {_NAME.pattern!r}")
290
+ _integer(self.input_dim, "preset.input_dim", positive=True)
291
+ if self.input not in _INPUTS:
292
+ raise ConfigError("preset.input must be absolute or delta")
293
+ if not self.components:
294
+ raise ConfigError("preset.components must not be empty")
295
+ if not all(
296
+ isinstance(component, ComponentSpec) for component in self.components
297
+ ):
298
+ raise ConfigError("preset.components must contain ComponentSpec values")
299
+ names = [component.name for component in self.components]
300
+ if len(set(names)) != len(names):
301
+ raise ConfigError("preset.components contains a duplicate name")
302
+ self._validate_source_partition()
303
+ for rate, path in (
304
+ (self.source_hz, "source_hz"),
305
+ (self.target_hz, "target_hz"),
306
+ ):
307
+ checked = _number(rate, f"preset.{path}")
308
+ if checked <= 0.0:
309
+ raise ConfigError(f"preset.{path} must be positive and finite")
310
+ if self.interpolation not in _INTERPOLATIONS:
311
+ raise ConfigError(
312
+ f"preset.interpolation must be one of {sorted(_INTERPOLATIONS)}"
313
+ )
314
+ if self.input == "delta" and any(
315
+ component.space == "eef" and component.rotation_format != "rotation_vector"
316
+ for component in self.components
317
+ ):
318
+ raise ConfigError(
319
+ "EEF delta components require rotation_format=rotation_vector"
320
+ )
321
+
322
+ def _validate_source_partition(self) -> None:
323
+ validate_dimension_partition(
324
+ (
325
+ (component.name, component.all_source_dims)
326
+ for component in self.components
327
+ ),
328
+ self.input_dim,
329
+ label="input dim",
330
+ error_type=ConfigError,
331
+ )
332
+
333
+ @classmethod
334
+ def from_dict(cls, payload: object, *, path: str = "preset") -> PresetConfig:
335
+ values = _mapping(payload, path)
336
+ allowed = {
337
+ "name",
338
+ "input_dim",
339
+ "input",
340
+ "source_hz",
341
+ "target_hz",
342
+ "interpolation",
343
+ "components",
344
+ }
345
+ _reject_unknown(values, allowed, path)
346
+ raw_components = _mapping(
347
+ _required(values, "components", path), f"{path}.components"
348
+ )
349
+ components = tuple(
350
+ ComponentSpec.from_dict(name, raw, path=f"{path}.components.{name}")
351
+ for name, raw in raw_components.items()
352
+ )
353
+ return cls(
354
+ name=str(_required(values, "name", path)),
355
+ input_dim=_integer(
356
+ _required(values, "input_dim", path), f"{path}.input_dim", positive=True
357
+ ),
358
+ input=str(_required(values, "input", path)),
359
+ components=components,
360
+ source_hz=_number(values.get("source_hz", 15.0), f"{path}.source_hz"),
361
+ target_hz=_number(values.get("target_hz", 15.0), f"{path}.target_hz"),
362
+ interpolation=str(values.get("interpolation", "cubic")),
363
+ )
364
+
365
+ @classmethod
366
+ def from_yaml(cls, path: str | Path) -> PresetConfig:
367
+ """Load one strict user preset without executing arbitrary code."""
368
+ source = Path(path)
369
+ try:
370
+ payload = yaml.load(
371
+ source.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader
372
+ )
373
+ except yaml.YAMLError as error:
374
+ raise ConfigError(f"cannot parse {source}: {error}") from error
375
+ return cls.from_dict(payload, path=str(source))
376
+
377
+ def save_yaml(self, path: str | Path) -> Path:
378
+ """Save the complete reproducible preset and return its path."""
379
+ target = Path(path)
380
+ target.parent.mkdir(parents=True, exist_ok=True)
381
+ target.write_text(
382
+ yaml.safe_dump(self.to_dict(), sort_keys=False), encoding="utf-8"
383
+ )
384
+ return target
385
+
386
+ def to_dict(self) -> dict[str, object]:
387
+ payload = {
388
+ "name": self.name,
389
+ "input_dim": self.input_dim,
390
+ "input": self.input,
391
+ "source_hz": self.source_hz,
392
+ "target_hz": self.target_hz,
393
+ "interpolation": self.interpolation,
394
+ "components": {
395
+ component.name: component.to_dict() for component in self.components
396
+ },
397
+ }
398
+ return payload
399
+
400
+ def summary(self) -> str:
401
+ """Return a compact action-space label for discovery UIs."""
402
+ spaces = "+".join(
403
+ dict.fromkeys(component.space for component in self.components)
404
+ )
405
+ return f"{self.name} (D={self.input_dim}, {self.input}, {spaces}, {self.source_hz:g} Hz input)"
406
+
407
+ def describe(self) -> str:
408
+ """Describe every source tensor column and its physical interpretation."""
409
+ motion = "position" if self.input == "absolute" else "per-step delta"
410
+ lines = [self.summary()]
411
+ for component in self.components:
412
+ if component.space == "joint":
413
+ assert component.joint_dims is not None
414
+ lines.append(
415
+ f" dims {_format_dims(component.joint_dims)}: {component.name} "
416
+ f"generalized-joint {motion} [rad or registered canonical unit]"
417
+ )
418
+ else:
419
+ assert component.position_dims is not None
420
+ assert component.rotation_dims is not None
421
+ lines.append(
422
+ f" dims {_format_dims(component.position_dims)}: {component.name} "
423
+ f"EEF translation {motion} (x, y, z in listed order) [m]"
424
+ )
425
+ rotation_motion = (
426
+ "orientation" if self.input == "absolute" else "increment"
427
+ )
428
+ rotation_unit = (
429
+ "unit xyzw quaternion"
430
+ if component.rotation_format == "quaternion_xyzw"
431
+ else "rad"
432
+ )
433
+ lines.append(
434
+ f" dims {_format_dims(component.rotation_dims)}: {component.name} EEF "
435
+ f"{rotation_motion} [{component.rotation_format}, {rotation_unit}]"
436
+ )
437
+ if self.input == "delta":
438
+ lines.append(
439
+ " rotation_vector = axis * angle [rad], right-multiplicative "
440
+ "body-frame increment: R_next = R_previous @ Exp(rotvec)"
441
+ )
442
+ elif component.rotation_format == "rotation_vector":
443
+ lines.append(
444
+ " rotation_vector = axis * angle [rad], not Euler angles"
445
+ )
446
+ elif component.rotation_format == "euler_xyz":
447
+ lines.append(" euler_xyz = extrinsic x, y, z Euler angles [rad]")
448
+ if component.gripper_dim is not None:
449
+ comparison = ">=" if component.gripper_open_is_high else "<="
450
+ lines.append(
451
+ f" dim {component.gripper_dim}: {component.name} gripper command "
452
+ f"(open if value {comparison} {component.gripper_threshold:g}, otherwise closed; "
453
+ "decoded as absolute command: 1=open, 0=closed)"
454
+ )
455
+ else:
456
+ lines.append(
457
+ f" {component.name}: no binary gripper channel; all listed columns are motion"
458
+ )
459
+ if self.input == "absolute":
460
+ lines.append(
461
+ " encode/decode require the same current_state (B,D), immediately before row 0"
462
+ )
463
+ else:
464
+ lines.append(
465
+ " encode/decode: omit current_state; deltas are per-step increments, not velocities"
466
+ )
467
+ lines.append(
468
+ f" resampling: {self.source_hz:g} Hz default -> {self.target_hz:g} Hz codec "
469
+ f"clock ({self.interpolation})"
470
+ )
471
+ return "\n".join(lines)
472
+
473
+ def canonicalize(
474
+ self,
475
+ values: np.ndarray,
476
+ *,
477
+ source_hz: float | None = None,
478
+ current_state: np.ndarray | None = None,
479
+ ) -> dict[str, list[ActiveTrajectory]]:
480
+ """Convert a batch into local paths for the two shared profiles.
481
+
482
+ Args:
483
+ values (np.ndarray): ``(B,T,D)`` tensor interpreted by this preset.
484
+ source_hz: Sampling rate of ``values``.
485
+ current_state (np.ndarray | None): Optional ``(B,D)`` state before
486
+ row zero. For order-0 inputs it defines the first transition;
487
+ when omitted, each input's first row is its anchor. Order-1
488
+ inputs are already self-contained and do not use the value.
489
+ """
490
+ array = np.asarray(values, dtype=np.float32)
491
+ if array.ndim != 3 or len(array) < 1 or array.shape[1] < 1:
492
+ raise ValueError("preset input must have non-empty shape [B, T, D]")
493
+ if array.shape[2] != self.input_dim:
494
+ raise ValueError(
495
+ f"preset {self.name!r} expects input_dim={self.input_dim}, got {array.shape[2]}"
496
+ )
497
+ if not np.all(np.isfinite(array)):
498
+ raise ValueError("preset input contains a non-finite value")
499
+ states: list[np.ndarray | None]
500
+ if current_state is None:
501
+ states = [None] * len(array)
502
+ else:
503
+ state_array = optional_finite_array(
504
+ current_state, shape=(len(array), self.input_dim)
505
+ )
506
+ assert state_array is not None
507
+ states = list(state_array)
508
+ return {
509
+ component.name: [
510
+ self._canonicalize_component(
511
+ episode,
512
+ component,
513
+ source_hz=self.source_hz if source_hz is None else source_hz,
514
+ current_state=state,
515
+ )
516
+ for episode, state in zip(array, states, strict=True)
517
+ ]
518
+ for component in self.components
519
+ }
520
+
521
+ def canonicalize_episode(
522
+ self,
523
+ values: np.ndarray,
524
+ *,
525
+ source_hz: float | None = None,
526
+ current_state: np.ndarray | None = None,
527
+ ) -> dict[str, ActiveTrajectory]:
528
+ """Canonicalize one variable-length episode and optional ``(D,)`` anchor."""
529
+ episode = np.asarray(values, dtype=np.float32)
530
+ if episode.ndim != 2 or len(episode) < 1 or episode.shape[1] != self.input_dim:
531
+ raise ValueError(
532
+ f"preset {self.name!r} expects a non-empty [T, {self.input_dim}] episode"
533
+ )
534
+ if not np.all(np.isfinite(episode)):
535
+ raise ValueError("preset input contains a non-finite value")
536
+ state = optional_finite_array(current_state, shape=(self.input_dim,))
537
+ return {
538
+ component.name: self._canonicalize_component(
539
+ episode,
540
+ component,
541
+ source_hz=self.source_hz if source_hz is None else source_hz,
542
+ current_state=state,
543
+ )
544
+ for component in self.components
545
+ }
546
+
547
+ def profile_trajectories(
548
+ self, values: np.ndarray, *, current_state: np.ndarray | None = None
549
+ ) -> dict[str, list[ActiveTrajectory]]:
550
+ """Canonicalize one episode and group its components by joint/EEF profile."""
551
+ canonical = self.canonicalize_episode(values, current_state=current_state)
552
+ output: dict[str, list[ActiveTrajectory]] = {}
553
+ for component in self.components:
554
+ output.setdefault(component.space, []).append(canonical[component.name])
555
+ return output
556
+
557
+ def _canonicalize_component(
558
+ self,
559
+ episode: np.ndarray,
560
+ component: ComponentSpec,
561
+ *,
562
+ source_hz: float,
563
+ current_state: np.ndarray | None,
564
+ ) -> ActiveTrajectory:
565
+ if component.space == "joint":
566
+ motion = self._joint_motion(
567
+ episode, component, source_hz=source_hz, current_state=current_state
568
+ )
569
+ else:
570
+ motion = self._eef_motion(
571
+ episode, component, source_hz=source_hz, current_state=current_state
572
+ )
573
+ if component.gripper_dim is None:
574
+ return ActiveTrajectory(motion, component.active_dims)
575
+ gripper = resample_uniform(
576
+ episode[:, component.gripper_dim : component.gripper_dim + 1],
577
+ source_hz=source_hz,
578
+ target_hz=self.target_hz,
579
+ kind="hold",
580
+ method=self.interpolation,
581
+ )
582
+ binary = binarize_gripper_actions(
583
+ gripper,
584
+ threshold=component.gripper_threshold,
585
+ open_is_high=component.gripper_open_is_high,
586
+ )
587
+ if len(binary) != len(motion):
588
+ raise RuntimeError("motion and gripper produced different target horizons")
589
+ return ActiveTrajectory(
590
+ np.concatenate((motion, binary), axis=1), component.active_dims
591
+ )
592
+
593
+ def _joint_motion(
594
+ self,
595
+ episode: np.ndarray,
596
+ component: ComponentSpec,
597
+ *,
598
+ source_hz: float,
599
+ current_state: np.ndarray | None,
600
+ ) -> np.ndarray:
601
+ assert component.joint_dims is not None
602
+ source = np.take(episode, component.joint_dims, axis=1)
603
+ if self.input == "delta":
604
+ source = np.cumsum(source, axis=0, dtype=np.float32)
605
+ anchor = np.zeros((1, source.shape[1]), dtype=np.float32)
606
+ else:
607
+ raw_anchor = (
608
+ source[:1]
609
+ if current_state is None
610
+ else np.take(current_state, component.joint_dims)[None]
611
+ )
612
+ unwrapped = np.unwrap(np.concatenate((raw_anchor, source), axis=0), axis=0)
613
+ anchor = np.asarray(unwrapped[:1], dtype=np.float32)
614
+ source = np.asarray(unwrapped[1:], dtype=np.float32)
615
+ sampled = resample_uniform(
616
+ source,
617
+ source_hz=source_hz,
618
+ target_hz=self.target_hz,
619
+ kind="linear",
620
+ method=self.interpolation,
621
+ )
622
+ return np.asarray(sampled - anchor, dtype=np.float32)
623
+
624
+ def _eef_motion(
625
+ self,
626
+ episode: np.ndarray,
627
+ component: ComponentSpec,
628
+ *,
629
+ source_hz: float,
630
+ current_state: np.ndarray | None,
631
+ ) -> np.ndarray:
632
+ assert (
633
+ component.position_dims is not None and component.rotation_dims is not None
634
+ )
635
+ position = np.take(episode, component.position_dims, axis=1)
636
+ rotation = np.take(episode, component.rotation_dims, axis=1)
637
+ if self.input == "delta":
638
+ pose = integrate_eef_deltas(np.concatenate((position, rotation), axis=1))
639
+ pose = resample_uniform(
640
+ pose,
641
+ source_hz=source_hz,
642
+ target_hz=self.target_hz,
643
+ kind="pose_xyzw",
644
+ method=self.interpolation,
645
+ )
646
+ return eef_pose_absolute(pose)
647
+ pose = eef_pose_from_components(position, rotation, component.rotation_format)
648
+ if current_state is None:
649
+ initial_pose = pose[0]
650
+ else:
651
+ initial_pose = eef_pose_from_components(
652
+ np.take(current_state, component.position_dims)[None],
653
+ np.take(current_state, component.rotation_dims)[None],
654
+ component.rotation_format,
655
+ )[0]
656
+ pose = resample_uniform(
657
+ pose,
658
+ source_hz=source_hz,
659
+ target_hz=self.target_hz,
660
+ kind="pose_xyzw",
661
+ method=self.interpolation,
662
+ )
663
+ return eef_pose_local_absolute(pose, initial_pose=initial_pose)
664
+
665
+
666
+ def format_action_spaces(
667
+ presets: Mapping[str, PresetConfig],
668
+ *,
669
+ selected: str | None = None,
670
+ action_dim: int | None = None,
671
+ second_order_dims: Mapping[str, Sequence[int]] | None = None,
672
+ ) -> str:
673
+ """Explain registered tensor layouts without inferring semantics from shape.
674
+
675
+ Args:
676
+ presets: Registered names mapped to their tensor-layout definitions.
677
+ selected: Currently bound action-space name, if any.
678
+ action_dim: Optional source-width filter; matching layouts are candidates.
679
+ second_order_dims: Physical profile names mapped to dimensions requiring
680
+ an explicit first-order boundary in the loaded artifact. ``None``
681
+ means the artifact's boundary requirements are unknown.
682
+ """
683
+ if action_dim is not None:
684
+ action_dim = _integer(action_dim, "action_dim", positive=True)
685
+ if not presets:
686
+ return "No action spaces are registered."
687
+ candidates = sorted(
688
+ (
689
+ preset
690
+ for preset in presets.values()
691
+ if action_dim is None or preset.input_dim == action_dim
692
+ ),
693
+ key=lambda preset: (preset.input_dim, preset.name),
694
+ )
695
+ lines = [
696
+ "ActionCodec2 — action layout guide",
697
+ f"Selected action_space: {selected or 'none (choose a layout before encoding)'}",
698
+ "",
699
+ "Input: NumPy arrays or Torch tensors, (T,D) or (B,T,D).",
700
+ " T = timesteps, D = columns per action, B = batch size; column indices start at 0.",
701
+ " Any positive T is accepted. A batch needs equal T; encode unequal-length episodes separately.",
702
+ " decode returns (B,T,D), including B=1 for a single episode; values are quantized, not lossless.",
703
+ "",
704
+ "How to choose:",
705
+ " joint = robot joint coordinates; EEF = end-effector (tool) position and orientation.",
706
+ " absolute = target coordinates; delta = per-step motion increments, not velocities.",
707
+ " Match column order, units, rotation convention, gripper commands and controller fps.",
708
+ " Shape alone cannot identify physical meaning: layouts sharing D are only candidates.",
709
+ " Consult your dataset/controller documentation if these meanings are unknown.",
710
+ " Supply physical units, not normalized [-1,1] features; convert degrees to radians.",
711
+ " Non-radian joint coordinates need an explicit mapping to the registered canonical units.",
712
+ " Pass the actual sampling rate as fps=...; defaults and codec rates appear below.",
713
+ " Resampling can change decoded T; token coverage determines the returned length.",
714
+ "",
715
+ f"Registered configurations: {len(candidates)} shown / {len(presets)} total"
716
+ + (
717
+ ""
718
+ if action_dim is None
719
+ else f" (D={action_dim} candidates, not automatic selection)"
720
+ ),
721
+ ]
722
+ if not candidates:
723
+ widths = ", ".join(
724
+ map(str, sorted({preset.input_dim for preset in presets.values()}))
725
+ )
726
+ lines.extend(
727
+ [
728
+ f" No registered action space matches D={action_dim}. Available D: {widths}.",
729
+ " Confirm your column meanings and register a custom layout; do not pad/truncate to force a match.",
730
+ ]
731
+ )
732
+ for preset in candidates:
733
+ name = preset.name
734
+ marker = " [selected]" if name == selected else ""
735
+ description = preset.describe().splitlines()
736
+ lines.extend(["", f"- {description[0]}{marker}"])
737
+ lines.extend(f" {line.strip()}" for line in description[1:])
738
+ if second_order_dims is not None:
739
+ boundaries = [
740
+ f"{component.name!r}: (B,{len(component.active_dims)})"
741
+ for component in preset.components
742
+ if set(component.active_dims).intersection(
743
+ second_order_dims.get(component.space, ())
744
+ )
745
+ ]
746
+ lines.append(
747
+ " previous_first_order: "
748
+ + (
749
+ "required in encode/decode, {" + ", ".join(boundaries) + "}; "
750
+ f"reconstructed increments at {preset.target_hz:g} Hz, B=1 for a single episode"
751
+ if boundaries
752
+ else "not required by this artifact for this layout"
753
+ )
754
+ )
755
+ lines.extend(
756
+ [
757
+ "",
758
+ "Next steps:",
759
+ " Load: ActionCodec2.from_pretrained(path, action_space='NAME')",
760
+ " Switch: codec = codec.for_action_space('NAME')",
761
+ " Filter: codec.print_action_spaces(action_dim=D)",
762
+ " Config: codec.action_spaces['NAME'].to_dict() # exact settings for a listed layout",
763
+ " Custom: codec.register_action_space(preset) # explicit column definitions, then switch",
764
+ " Custom layouts can reorder columns; each joint component supports 1..7 coordinates.",
765
+ " EEF absolute rotations support rotation_vector, euler_xyz or quaternion_xyzw (unit x,y,z,w).",
766
+ " EEF delta rotations require rotation_vector with the convention shown above.",
767
+ " For absolute layouts, current_state is (B,D), even when actions are (T,D).",
768
+ ]
769
+ )
770
+ return "\n".join(lines)
771
+
772
+
773
+ def resample_canonical_trajectory(
774
+ trajectory: ActiveTrajectory,
775
+ component: ComponentSpec,
776
+ *,
777
+ source_hz: float,
778
+ target_hz: float,
779
+ target_length: int | None = None,
780
+ method: str = "linear",
781
+ ) -> ActiveTrajectory:
782
+ """Rate-convert one decoded canonical component without changing slots."""
783
+ values = np.asarray(trajectory.values, dtype=np.float32)
784
+ motion_width = len(component.joint_dims or ()) if component.space == "joint" else 6
785
+ motion = values[:, :motion_width]
786
+ if component.space == "eef":
787
+ pose = eef_pose_from_components(
788
+ motion[:, :3], motion[:, 3:6], "rotation_vector"
789
+ )
790
+ sampled_pose = resample_uniform(
791
+ pose,
792
+ source_hz=source_hz,
793
+ target_hz=target_hz,
794
+ kind="pose_xyzw",
795
+ target_length=target_length,
796
+ method=method,
797
+ )
798
+ sampled_motion = eef_pose_absolute(sampled_pose)
799
+ else:
800
+ sampled_motion = resample_uniform(
801
+ motion,
802
+ source_hz=source_hz,
803
+ target_hz=target_hz,
804
+ kind="linear",
805
+ target_length=target_length,
806
+ method=method,
807
+ )
808
+ if component.gripper_dim is None:
809
+ return ActiveTrajectory(sampled_motion, trajectory.active_dims)
810
+ gripper = resample_uniform(
811
+ values[:, motion_width : motion_width + 1],
812
+ source_hz=source_hz,
813
+ target_hz=target_hz,
814
+ kind="hold",
815
+ target_length=target_length,
816
+ method=method,
817
+ )
818
+ return ActiveTrajectory(
819
+ np.concatenate((sampled_motion, gripper), axis=1), trajectory.active_dims
820
+ )
821
+
822
+
823
+ def externalize_canonical_trajectory(
824
+ trajectory: ActiveTrajectory,
825
+ component: ComponentSpec,
826
+ *,
827
+ current_state: np.ndarray | None,
828
+ ) -> np.ndarray:
829
+ """Return deltas, or absolute values when an external anchor is supplied.
830
+
831
+ The input is always a decoded local position/pose path. Gripper remains an
832
+ absolute binary command in both output modes.
833
+ """
834
+ values = np.asarray(trajectory.values, dtype=np.float32)
835
+ motion_width = len(component.joint_dims or ()) if component.space == "joint" else 6
836
+ motion = values[:, :motion_width]
837
+ if component.space == "joint":
838
+ if current_state is None:
839
+ previous = np.concatenate((np.zeros_like(motion[:1]), motion[:-1]), axis=0)
840
+ output_motion = motion - previous
841
+ else:
842
+ assert component.joint_dims is not None
843
+ output_motion = motion + np.take(current_state, component.joint_dims)[None]
844
+ else:
845
+ local_pose = eef_pose_from_components(
846
+ motion[:, :3], motion[:, 3:6], "rotation_vector"
847
+ )
848
+ if current_state is None:
849
+ output_motion = eef_pose_deltas(local_pose)
850
+ else:
851
+ assert (
852
+ component.position_dims is not None
853
+ and component.rotation_dims is not None
854
+ )
855
+ initial_pose = eef_pose_from_components(
856
+ np.take(current_state, component.position_dims)[None],
857
+ np.take(current_state, component.rotation_dims)[None],
858
+ component.rotation_format,
859
+ )[0]
860
+ output_motion = eef_pose_components(
861
+ eef_pose_from_local_absolute(local_pose, initial_pose=initial_pose),
862
+ component.rotation_format,
863
+ )
864
+ if component.gripper_dim is None:
865
+ return np.asarray(output_motion, dtype=np.float32)
866
+ gripper = values[:, motion_width : motion_width + 1]
867
+ if not component.gripper_open_is_high:
868
+ gripper = 1.0 - gripper
869
+ return np.asarray(
870
+ np.concatenate((output_motion, gripper), axis=1),
871
+ dtype=np.float32,
872
+ )
runtime/routing/routing.py ADDED
@@ -0,0 +1,809 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Required-preset routing over the two shared ActionCodec2 processors.
2
+
3
+ Each profile owns a disjoint token namespace and is named for a physical
4
+ quantity, never a robot. Presets describe tensor slots and preprocessing but
5
+ cannot alter already-fitted primitives, BPE rules, or token meanings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Mapping, Sequence
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+ from types import MappingProxyType
15
+ from typing import TYPE_CHECKING
16
+
17
+ import numpy as np
18
+ import yaml
19
+
20
+ from ..common._validation import optional_finite_array
21
+ from .cache import BoundedCache
22
+ from ..physical.configuration import (
23
+ ConfigError,
24
+ _integer,
25
+ _mapping,
26
+ _reject_unknown,
27
+ _required,
28
+ _UniqueKeyLoader,
29
+ )
30
+ from ..integration.hf import (
31
+ ROUTER_ALLOW_PATTERNS,
32
+ ROUTER_CONFIG_NAME,
33
+ ROUTER_MODEL_TYPE,
34
+ resolve_pretrained_directory,
35
+ save_huggingface_metadata,
36
+ save_model_card,
37
+ )
38
+ from ..physical.physical_processor import ActionCodec2Processor
39
+ from .presets import (
40
+ ComponentSpec,
41
+ PresetConfig,
42
+ externalize_canonical_trajectory,
43
+ format_action_spaces,
44
+ resample_canonical_trajectory,
45
+ )
46
+ from ..physical.semantic import resampled_horizon
47
+ from ..physical.trajectory import ActiveTrajectory
48
+
49
+ if TYPE_CHECKING:
50
+ from .processing_actioncodec2 import ActionCodec2
51
+
52
+ # v3 freezes stateless decode semantics: no current_state -> deltas;
53
+ # current_state -> absolute motion. v2 returned local position paths.
54
+ ROUTER_CONFIG_VERSION = 3
55
+ _NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
56
+ _PROFILES = {"eef", "joint"}
57
+ _PROFILE_WIDTHS = {"eef": 7, "joint": 8}
58
+
59
+
60
+ def _current_state_rows(
61
+ current_state: np.ndarray | None, *, count: int, input_dim: int
62
+ ) -> list[np.ndarray | None]:
63
+ if current_state is None:
64
+ return [None] * count
65
+ values = optional_finite_array(current_state, shape=(count, input_dim))
66
+ assert values is not None
67
+ return list(values)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ProfileSpec:
72
+ """Token namespace metadata for one shared physical processor."""
73
+
74
+ name: str
75
+ token_offset: int
76
+ token_budget: int
77
+ artifact_fingerprint: str
78
+
79
+ def __post_init__(self) -> None:
80
+ if _NAME.fullmatch(self.name) is None:
81
+ raise ConfigError(f"profile.name must match {_NAME.pattern!r}")
82
+ _integer(self.token_offset, "profile.token_offset")
83
+ _integer(self.token_budget, "profile.token_budget", positive=True)
84
+ if re.fullmatch(r"[0-9a-f]{64}", self.artifact_fingerprint) is None:
85
+ raise ConfigError(
86
+ "profile.artifact_fingerprint must be a SHA-256 hex digest"
87
+ )
88
+
89
+ @classmethod
90
+ def from_dict(cls, payload: object, *, path: str) -> ProfileSpec:
91
+ values = _mapping(payload, path)
92
+ allowed = {"name", "token_offset", "token_budget", "artifact_fingerprint"}
93
+ _reject_unknown(values, allowed, path)
94
+ return cls(
95
+ name=str(_required(values, "name", path)),
96
+ token_offset=_integer(
97
+ _required(values, "token_offset", path), f"{path}.token_offset"
98
+ ),
99
+ token_budget=_integer(
100
+ _required(values, "token_budget", path),
101
+ f"{path}.token_budget",
102
+ positive=True,
103
+ ),
104
+ artifact_fingerprint=str(_required(values, "artifact_fingerprint", path)),
105
+ )
106
+
107
+ def to_dict(self) -> dict[str, object]:
108
+ return asdict(self)
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class _ProfileRuntime:
113
+ spec: ProfileSpec
114
+ processor: ActionCodec2Processor
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class _Segment:
119
+ component: ComponentSpec
120
+ profile: _ProfileRuntime
121
+
122
+
123
+ class RoutedActionCodec2Grammar:
124
+ """Constrained grammar over a preset's ordered, namespaced segments."""
125
+
126
+ def __init__(
127
+ self, router: ActionCodec2Router, *, preset: str, horizon: int
128
+ ) -> None:
129
+ horizon = _integer(horizon, "horizon", positive=True)
130
+ self.segments = router._segments(router.preset(preset))
131
+ self.vocab_size = router.vocab_size
132
+ self._grammars = tuple(
133
+ segment.profile.processor.grammar(
134
+ horizon, active_dims=segment.component.active_dims
135
+ )
136
+ for segment in self.segments
137
+ )
138
+ initial = self._normalize_state(0, self._grammars[0]._initial_state())
139
+ self._initial_state = initial
140
+ self._state_cache = BoundedCache(256)
141
+ self._state_cache[()] = initial
142
+
143
+ def _normalize_state(
144
+ self, segment_index: int, state: tuple[int, ...]
145
+ ) -> tuple[int, tuple[int, ...]]:
146
+ while segment_index < len(self.segments) and self._grammars[
147
+ segment_index
148
+ ]._state_is_complete(state):
149
+ segment_index += 1
150
+ if segment_index < len(self.segments):
151
+ state = self._grammars[segment_index]._initial_state()
152
+ return segment_index, state
153
+
154
+ def _advance_routed_state(
155
+ self,
156
+ routed_state: tuple[int, tuple[int, ...]],
157
+ token: int,
158
+ ) -> tuple[int, tuple[int, ...]] | None:
159
+ segment_index, state = routed_state
160
+ if segment_index == len(self.segments):
161
+ return None
162
+ segment = self.segments[segment_index]
163
+ local_token = token - segment.profile.spec.token_offset
164
+ if not 0 <= local_token < segment.profile.processor.vocab_size:
165
+ return None
166
+ next_state = self._grammars[segment_index]._advance(state, local_token)
167
+ if next_state is None:
168
+ return None
169
+ return self._normalize_state(segment_index, next_state)
170
+
171
+ def _consume(self, tokens: Sequence[int]) -> tuple[int, tuple[int, ...]] | None:
172
+ checked: list[int] = []
173
+ for raw_token in tokens:
174
+ if isinstance(raw_token, bool) or not isinstance(
175
+ raw_token, (int, np.integer)
176
+ ):
177
+ return None
178
+ checked.append(int(raw_token))
179
+ key = tuple(checked)
180
+ if not key:
181
+ return self._initial_state
182
+ missing = object()
183
+ cached = self._state_cache.get(key, missing)
184
+ if cached is not missing:
185
+ return cached
186
+ parent = key[:-1]
187
+ state = self._state_cache.get(parent, missing)
188
+ if state is missing:
189
+ state = self._initial_state
190
+ for token in parent:
191
+ if state is None:
192
+ break
193
+ state = self._advance_routed_state(state, token)
194
+ self._state_cache[parent] = state
195
+ if state is not None:
196
+ state = self._advance_routed_state(state, key[-1])
197
+ self._state_cache[key] = state
198
+ return state
199
+
200
+ def next_token_mask(self, tokens: Sequence[int]) -> np.ndarray:
201
+ """Return a global namespace mask for the next preset token."""
202
+ state = self._consume(tokens)
203
+ if state is None:
204
+ raise ValueError("token prefix is not legal for this routed preset")
205
+ segment_index, local_state = state
206
+ output = np.zeros(self.vocab_size, dtype=bool)
207
+ if segment_index == len(self.segments):
208
+ return output
209
+ segment = self.segments[segment_index]
210
+ mask = self._grammars[segment_index]._next_token_mask_from_state(local_state)
211
+ start = segment.profile.spec.token_offset
212
+ output[start : start + len(mask)] = mask
213
+ return output
214
+
215
+ def is_complete(self, tokens: Sequence[int]) -> bool:
216
+ """Whether the prefix completes every segment exactly once."""
217
+ state = self._consume(tokens)
218
+ return state is not None and state[0] == len(self.segments)
219
+
220
+
221
+ class ActionCodec2Router:
222
+ """Route heterogeneous dataset states into physical token namespaces.
223
+
224
+ The router is fitted only from already-built physical processors. Presets
225
+ cannot alter their primitives, merge rules, or token meanings.
226
+
227
+ Args:
228
+ profiles: Ordered namespace metadata and fitted processor pairs for
229
+ the shared joint and end-effector physical profiles.
230
+ presets: Action-space definitions mapping source tensor columns into
231
+ the physical profiles.
232
+ """
233
+
234
+ model_type = ROUTER_MODEL_TYPE
235
+
236
+ def __init__(
237
+ self,
238
+ profiles: Sequence[tuple[ProfileSpec, ActionCodec2Processor]],
239
+ presets: Sequence[PresetConfig],
240
+ ) -> None:
241
+ runtimes: dict[str, _ProfileRuntime] = {}
242
+ expected_offset = 0
243
+ for spec, processor in profiles:
244
+ if spec.name in runtimes:
245
+ raise ConfigError(f"duplicate physical profile {spec.name!r}")
246
+ if spec.token_offset != expected_offset:
247
+ raise ConfigError(
248
+ "profile token namespaces must be contiguous and ordered"
249
+ )
250
+ if processor.vocab_size > spec.token_budget:
251
+ raise ConfigError(
252
+ f"profile {spec.name!r} uses {processor.vocab_size} tokens, "
253
+ f"exceeding budget {spec.token_budget}"
254
+ )
255
+ if processor.artifact_fingerprint != spec.artifact_fingerprint:
256
+ raise ConfigError(
257
+ f"profile {spec.name!r} artifact fingerprint does not match its processor"
258
+ )
259
+ if processor.config.set_bpe.allow_cross_component_merge:
260
+ raise ConfigError(
261
+ "allow_cross_component_merge=true is not supported by the v1 router; "
262
+ "a component-aware Set-BPE serializer and grammar are required"
263
+ )
264
+ runtimes[spec.name] = _ProfileRuntime(spec, processor)
265
+ expected_offset += spec.token_budget
266
+ if not runtimes or not set(runtimes) <= _PROFILES:
267
+ raise ConfigError(
268
+ "router requires joint and/or eef physical profiles"
269
+ )
270
+ for name, runtime in runtimes.items():
271
+ if runtime.processor.action_dim != _PROFILE_WIDTHS[name]:
272
+ raise ConfigError(
273
+ f"profile {name!r} must have width {_PROFILE_WIDTHS[name]}, "
274
+ f"got {runtime.processor.action_dim}"
275
+ )
276
+ self.profiles = MappingProxyType(runtimes)
277
+ self._source_directory = Path(__file__).resolve().parent.parent
278
+ self._grammar_cache = BoundedCache(8)
279
+ self._action_spaces: dict[str, PresetConfig] = {}
280
+ for preset in presets:
281
+ self.register_action_space(preset)
282
+ self.vocab_size = expected_offset
283
+
284
+ @property
285
+ def action_spaces(self) -> Mapping[str, PresetConfig]:
286
+ """Read-only registered built-in and user-created action spaces."""
287
+ return MappingProxyType(self._action_spaces)
288
+
289
+ def __str__(self) -> str:
290
+ return self.describe_action_spaces()
291
+
292
+ def describe_action_spaces(
293
+ self, *, selected: str | None = None, action_dim: int | None = None
294
+ ) -> str:
295
+ """Explain all layouts, or source-width candidates, using this artifact's state requirements."""
296
+ second_order_dims = {
297
+ name: tuple(
298
+ dimension
299
+ for component in runtime.processor.config.physical.components.values()
300
+ if component.primitive_order == 2
301
+ for dimension in component.dims
302
+ )
303
+ for name, runtime in self.profiles.items()
304
+ }
305
+ return format_action_spaces(
306
+ self.action_spaces,
307
+ selected=selected,
308
+ action_dim=action_dim,
309
+ second_order_dims=second_order_dims,
310
+ )
311
+
312
+ @classmethod
313
+ def from_processors(
314
+ cls,
315
+ processors: Mapping[str, ActionCodec2Processor],
316
+ *,
317
+ token_budgets: Mapping[str, int],
318
+ presets: Sequence[PresetConfig],
319
+ ) -> ActionCodec2Router:
320
+ """Assign stable contiguous namespaces in canonical joint/eef order."""
321
+ if set(processors) != set(token_budgets):
322
+ raise ConfigError("processors and token_budgets must share keys")
323
+ if set(processors) != _PROFILES:
324
+ raise ConfigError(
325
+ "processors must contain exactly the joint and eef profiles"
326
+ )
327
+ offset = 0
328
+ entries = []
329
+ for name in ("joint", "eef"):
330
+ processor = processors[name]
331
+ budget = _integer(
332
+ token_budgets[name], f"token_budgets.{name}", positive=True
333
+ )
334
+ spec = ProfileSpec(
335
+ name,
336
+ offset,
337
+ budget,
338
+ processor.artifact_fingerprint,
339
+ )
340
+ entries.append((spec, processor))
341
+ offset += budget
342
+ return cls(entries, presets)
343
+
344
+ def preset(self, name: str) -> PresetConfig:
345
+ """Resolve one preset or fail with the available names."""
346
+ try:
347
+ return self._action_spaces[name]
348
+ except KeyError as error:
349
+ raise KeyError(
350
+ f"unknown preset {name!r}; available: {sorted(self._action_spaces)}"
351
+ ) from error
352
+
353
+ def register_action_space(
354
+ self,
355
+ preset: PresetConfig | Mapping[str, object] | str | Path,
356
+ ) -> PresetConfig:
357
+ """Register a validated action-space config, mapping, or YAML file.
358
+
359
+ Registration changes only tensor interpretation. It cannot alter either
360
+ fitted physical vocabulary or any token ID.
361
+ """
362
+ if isinstance(preset, Mapping):
363
+ preset = PresetConfig.from_dict(preset)
364
+ elif isinstance(preset, (str, Path)):
365
+ preset = PresetConfig.from_yaml(preset)
366
+ if not isinstance(preset, PresetConfig):
367
+ raise TypeError(
368
+ "register_action_space expects PresetConfig, a mapping, or a preset YAML path"
369
+ )
370
+ existing = self._action_spaces.get(preset.name)
371
+ if existing is not None and existing != preset:
372
+ raise ConfigError(
373
+ f"action-space name {preset.name!r} is already registered differently"
374
+ )
375
+ self._action_spaces[preset.name] = preset
376
+ return preset
377
+
378
+ def _segments(self, preset: PresetConfig) -> tuple[_Segment, ...]:
379
+ return tuple(
380
+ _Segment(component, self.profiles[component.space])
381
+ for component in preset.components
382
+ )
383
+
384
+ def encode_batch(
385
+ self,
386
+ inputs: np.ndarray,
387
+ *,
388
+ preset: str,
389
+ fps: float | None = None,
390
+ current_state: np.ndarray | None = None,
391
+ previous_first_order: Mapping[str, np.ndarray] | None = None,
392
+ ) -> list[list[int]]:
393
+ """Encode one regular-rate ``[B,T,D]`` tensor at ``fps``.
394
+
395
+ Preset preprocessing and runtime encoding use the same canonical rate
396
+ adapter. The shared physical processors therefore only receive the
397
+ preset's target clock. For order-0 presets,
398
+ ``current_state[B,D]`` is the state immediately before row zero. The
399
+ low-level router permits omitting it and then anchors at row zero; the
400
+ public frontend requires it for absolute action spaces so the first
401
+ transition cannot be silently discarded.
402
+ ``previous_first_order`` maps component names to ``[B,D_active]``
403
+ reconstructed increments in canonical units at the preset's target
404
+ clock; active second-order components require an entry.
405
+ """
406
+ preset_config = self.preset(preset)
407
+ self._validate_boundary_keys(previous_first_order, preset_config)
408
+ canonical = preset_config.canonicalize(
409
+ inputs, source_hz=fps, current_state=current_state
410
+ )
411
+ batch = len(next(iter(canonical.values())))
412
+ rows: list[list[int]] = [[] for _ in range(batch)]
413
+ for segment in self._segments(preset_config):
414
+ values = canonical[segment.component.name]
415
+ streams = segment.profile.processor.encode_batch(
416
+ np.stack([trajectory.values for trajectory in values]),
417
+ active_dims=segment.component.active_dims,
418
+ previous_first_order=None
419
+ if previous_first_order is None
420
+ else previous_first_order.get(segment.component.name),
421
+ )
422
+ offset = segment.profile.spec.token_offset
423
+ for row, stream in zip(rows, streams, strict=True):
424
+ row.extend(offset + token for token in stream)
425
+ return rows
426
+
427
+ def route_trajectories(
428
+ self,
429
+ inputs: np.ndarray,
430
+ *,
431
+ preset: str,
432
+ current_state: np.ndarray | None = None,
433
+ fps: float | None = None,
434
+ previous_first_order: Mapping[str, np.ndarray] | None = None,
435
+ ) -> dict[str, list[ActiveTrajectory]]:
436
+ """Expose canonical complete trajectories grouped by physical profile.
437
+
438
+ This is the fitting boundary: callers pool the returned trajectories
439
+ from many presets by profile name, balance datasets, then call
440
+ :meth:`ProfileTrainer.fit` once per profile.
441
+ ``previous_first_order`` uses the same component-to-``[B,D_active]``
442
+ canonical boundary mapping as :meth:`encode_batch`.
443
+ """
444
+ preset_config = self.preset(preset)
445
+ self._validate_boundary_keys(previous_first_order, preset_config)
446
+ canonical = preset_config.canonicalize(
447
+ inputs, current_state=current_state, source_hz=fps
448
+ )
449
+ output: dict[str, list[ActiveTrajectory]] = {}
450
+ for component in preset_config.components:
451
+ trajectories = canonical[component.name]
452
+ processor = self.profiles[component.space].processor
453
+ previous = (
454
+ None
455
+ if previous_first_order is None
456
+ else previous_first_order.get(component.name)
457
+ )
458
+ dense = processor._dense_previous_first_order(
459
+ previous, component.active_dims, len(trajectories)
460
+ )
461
+ rows = (
462
+ [None] * len(trajectories)
463
+ if dense is None
464
+ else np.take(dense, component.active_dims, axis=1)
465
+ )
466
+ output.setdefault(component.space, []).extend(
467
+ ActiveTrajectory(t.values, t.active_dims, previous_first_order=p)
468
+ for t, p in zip(trajectories, rows, strict=True)
469
+ )
470
+ return output
471
+
472
+ def _validate_boundary_keys(
473
+ self,
474
+ previous_first_order: Mapping[str, np.ndarray] | None,
475
+ preset: PresetConfig,
476
+ ) -> None:
477
+ if previous_first_order is None:
478
+ previous_first_order = {}
479
+ if not isinstance(previous_first_order, Mapping):
480
+ raise ValueError(
481
+ "previous_first_order must map component names to canonical [B,D_active] boundaries"
482
+ )
483
+ unknown = set(previous_first_order) - {c.name for c in preset.components}
484
+ if unknown:
485
+ raise ValueError(
486
+ f"unknown previous_first_order component(s): {sorted(unknown)}"
487
+ )
488
+ required = {
489
+ c.name: f"(B, {len(c.active_dims)})"
490
+ for c in preset.components
491
+ if any(
492
+ p.primitive_order == 2 and set(c.active_dims).intersection(p.dims)
493
+ for p in self.profiles[
494
+ c.space
495
+ ].processor.config.physical.components.values()
496
+ )
497
+ }
498
+ missing = set(required) - previous_first_order.keys()
499
+ if missing:
500
+ raise ValueError(
501
+ "second-order tokenization requires previous_first_order with arrays "
502
+ f"{required}; missing {sorted(missing)}. Use canonical {preset.target_hz:g} Hz "
503
+ "reconstructed increments. codec.first_order_template(batch_size) explicitly "
504
+ "creates zeros; use zeros only if the preceding increment is known to be zero."
505
+ )
506
+
507
+ def final_first_order(
508
+ self,
509
+ streams: Sequence[Sequence[int]],
510
+ *,
511
+ preset: str,
512
+ previous_first_order: Mapping[str, np.ndarray],
513
+ executed_steps: int | None = None,
514
+ ) -> dict[str, np.ndarray]:
515
+ """Return component ``[B,D_active]`` boundaries at the canonical clock.
516
+
517
+ ``executed_steps`` counts codec steps, before output resampling. EEF
518
+ motion uses local Cartesian displacements and rotation vectors in the
519
+ profile's multiplication convention. Gripper slots carry no state.
520
+ """
521
+ preset_config = self.preset(preset)
522
+ if previous_first_order is None:
523
+ raise ValueError("final_first_order requires previous_first_order")
524
+ self._validate_boundary_keys(previous_first_order, preset_config)
525
+ segments = self._segments(preset_config)
526
+ steps = self._batch_steps(streams, segments)
527
+ split = [self._split_stream(stream, segments, steps) for stream in streams]
528
+ output = {}
529
+ for index, segment in enumerate(segments):
530
+ processor = segment.profile.processor
531
+ if not processor.requires_previous_first_order:
532
+ continue
533
+ output[segment.component.name] = processor.final_first_order(
534
+ [row[index] for row in split],
535
+ previous_first_order=previous_first_order.get(segment.component.name),
536
+ active_dims=segment.component.active_dims,
537
+ executed_steps=executed_steps,
538
+ )
539
+ return output
540
+
541
+ def _split_stream(
542
+ self, tokens: Sequence[int], segments: Sequence[_Segment], steps: int
543
+ ) -> list[list[int]]:
544
+ cursor = 0
545
+ output = []
546
+ for segment in segments:
547
+ grammar = segment.profile.processor.grammar(
548
+ steps, active_dims=segment.component.active_dims
549
+ )
550
+ state = grammar._initial_state()
551
+ local: list[int] = []
552
+ while cursor < len(tokens) and not grammar._state_is_complete(state):
553
+ raw_token = tokens[cursor]
554
+ if isinstance(raw_token, bool) or not isinstance(
555
+ raw_token, (int, np.integer)
556
+ ):
557
+ raise ValueError(f"tokens[{cursor}] must be an integer")
558
+ token = int(raw_token) - segment.profile.spec.token_offset
559
+ if not 0 <= token < segment.profile.processor.vocab_size:
560
+ raise ValueError(
561
+ "token belongs to the wrong routed profile namespace"
562
+ )
563
+ next_state = grammar._advance(state, token)
564
+ if next_state is None:
565
+ raise ValueError(
566
+ f"Action token stream has invalid coverage for component {segment.component.name!r}. Constrain generation with codec.grammar(H, fps=...).next_token_mask(prefix)."
567
+ )
568
+ state = next_state
569
+ local.append(token)
570
+ cursor += 1
571
+ if not grammar._state_is_complete(state):
572
+ raise ValueError(
573
+ f"Incomplete action token stream for component {segment.component.name!r}. Check codec.grammar(H, fps=...).is_complete(tokens); discard or regenerate this row."
574
+ )
575
+ output.append(local)
576
+ if cursor != len(tokens):
577
+ raise ValueError(
578
+ "Action token stream has extra tokens after complete coverage; remove model prompt/EOS/padding before codec.decode()."
579
+ )
580
+ return output
581
+
582
+ def _token_cell_count(self, raw_token: object, *, index: int) -> int:
583
+ """Return the number of primitive cells represented by one routed token."""
584
+ if isinstance(raw_token, bool) or not isinstance(raw_token, (int, np.integer)):
585
+ raise ValueError(f"tokens[{index}] must be an integer")
586
+ token = int(raw_token)
587
+ for runtime in self.profiles.values():
588
+ local = token - runtime.spec.token_offset
589
+ if 0 <= local < runtime.processor.vocab_size:
590
+ return int(runtime.processor.serializer.token_cell_counts[local])
591
+ raise ValueError(f"tokens[{index}] is outside the routed profile vocabularies")
592
+
593
+ def _decoded_steps(
594
+ self, tokens: Sequence[int], segments: Sequence[_Segment]
595
+ ) -> int:
596
+ """Derive sequence length from the primitive-cell coverage in a routed stream."""
597
+ if not tokens:
598
+ raise ValueError("token stream must not be empty")
599
+ cells_per_step = sum(len(segment.component.active_dims) for segment in segments)
600
+ represented_cells = sum(
601
+ self._token_cell_count(token, index=index)
602
+ for index, token in enumerate(tokens)
603
+ )
604
+ steps, remainder = divmod(represented_cells, cells_per_step)
605
+ if steps < 1 or remainder:
606
+ raise ValueError(
607
+ "Incomplete action token stream: "
608
+ f"{cells_per_step - remainder} more coordinate values are needed to finish a step. "
609
+ "Constrain generation with codec.grammar(H, fps=...).next_token_mask(prefix) "
610
+ "and check is_complete(tokens) for the intended H before decoding. "
611
+ "Use codec.batch_decode(..., errors='return') to isolate invalid rows; "
612
+ "do not pad truncated tokens with zeros."
613
+ )
614
+ return steps
615
+
616
+ def _batch_steps(
617
+ self,
618
+ streams: Sequence[Sequence[int]],
619
+ segments: Sequence[_Segment],
620
+ ) -> int:
621
+ """Derive the common decoded length of a dense output batch."""
622
+ if not streams:
623
+ raise ValueError("streams must not be empty")
624
+ lengths = [self._decoded_steps(stream, segments) for stream in streams]
625
+ if len(set(lengths)) != 1:
626
+ raise ValueError(
627
+ f"all token streams must decode to the same length, got {lengths}; use codec.batch_decode(...) for independent variable-length outputs"
628
+ )
629
+ return lengths[0]
630
+
631
+ def decode_batch(
632
+ self,
633
+ streams: Sequence[Sequence[int]],
634
+ *,
635
+ preset: str,
636
+ fps: float | None = None,
637
+ current_state: np.ndarray | None = None,
638
+ previous_first_order: Mapping[str, np.ndarray] | None = None,
639
+ ) -> dict[str, np.ndarray]:
640
+ """Decode rows to deltas, or absolute components with ``current_state``.
641
+
642
+ Args:
643
+ streams: One namespaced token stream per batch item.
644
+ preset: Registered tensor/component interpretation.
645
+ fps: Requested external output rate; defaults to the preset's
646
+ ``source_hz``. The output row count is the canonical rate
647
+ conversion of the token-determined codec-rate path; no original
648
+ pre-downsampling row count is inferred.
649
+ current_state: Optional ``(B,D)`` preset-layout state immediately
650
+ before row zero. When supplied, decoded local paths are attached
651
+ to it and returned as order-0 values. When omitted, the return
652
+ value is per-step order-1 motion. Gripper is always absolute.
653
+ previous_first_order: Component names mapped to ``(B,D_active)``
654
+ canonical reconstructed increments at the target clock, using
655
+ the same boundary as encode. Required for second-order motion.
656
+ """
657
+ preset_config = self.preset(preset)
658
+ self._validate_boundary_keys(previous_first_order, preset_config)
659
+ segments = self._segments(preset_config)
660
+ internal_steps = self._batch_steps(streams, segments)
661
+ states = _current_state_rows(
662
+ current_state, count=len(streams), input_dim=preset_config.input_dim
663
+ )
664
+ output_fps = preset_config.source_hz if fps is None else fps
665
+ output_steps = resampled_horizon(
666
+ internal_steps,
667
+ source_hz=preset_config.target_hz,
668
+ target_hz=output_fps,
669
+ )
670
+ output: dict[str, list[np.ndarray]] = {
671
+ component.name: [] for component in preset_config.components
672
+ }
673
+ split_streams = [
674
+ self._split_stream(stream, segments, internal_steps) for stream in streams
675
+ ]
676
+ for segment_index, segment in enumerate(segments):
677
+ decoded_batch = segment.profile.processor.decode_batch(
678
+ [split[segment_index] for split in split_streams],
679
+ active_dims=segment.component.active_dims,
680
+ previous_first_order=None
681
+ if previous_first_order is None
682
+ else previous_first_order.get(segment.component.name),
683
+ )
684
+ for decoded, state in zip(decoded_batch, states, strict=True):
685
+ restored = resample_canonical_trajectory(
686
+ ActiveTrajectory(decoded, segment.component.active_dims),
687
+ segment.component,
688
+ source_hz=preset_config.target_hz,
689
+ target_hz=output_fps,
690
+ target_length=output_steps,
691
+ method=preset_config.interpolation,
692
+ )
693
+ output[segment.component.name].append(
694
+ externalize_canonical_trajectory(
695
+ restored, segment.component, current_state=state
696
+ )
697
+ )
698
+ return {name: np.stack(values) for name, values in output.items()}
699
+
700
+ def grammar(self, *, preset: str, horizon: int) -> RoutedActionCodec2Grammar:
701
+ """Build the global constrained grammar for one preset and target horizon."""
702
+ horizon = _integer(horizon, "horizon", positive=True)
703
+ self.preset(preset)
704
+ key = (preset, horizon)
705
+ grammar = self._grammar_cache.get(key)
706
+ if grammar is None:
707
+ grammar = RoutedActionCodec2Grammar(self, preset=preset, horizon=horizon)
708
+ self._grammar_cache[key] = grammar
709
+ return grammar
710
+
711
+ @property
712
+ def new_action_tokens(self) -> list[str]:
713
+ """Stable physical token names, including reserved profile budget slots."""
714
+ return [
715
+ f"<action_{name}_{index:04d}>"
716
+ for name, runtime in self.profiles.items()
717
+ for index in range(runtime.spec.token_budget)
718
+ ]
719
+
720
+ def save_pretrained(
721
+ self, directory: str | Path, *, include_code: bool = True
722
+ ) -> list[str]:
723
+ """Save one manifest plus isolated physical processor subdirectories."""
724
+ root = Path(directory)
725
+ files = []
726
+ for runtime in self.profiles.values():
727
+ files.extend(
728
+ runtime.processor.save_pretrained(root / "profiles" / runtime.spec.name)
729
+ )
730
+ manifest = {
731
+ "model_type": self.model_type,
732
+ "config_version": ROUTER_CONFIG_VERSION,
733
+ "profiles": [runtime.spec.to_dict() for runtime in self.profiles.values()],
734
+ "presets": [preset.to_dict() for preset in self.action_spaces.values()],
735
+ }
736
+ path = root / ROUTER_CONFIG_NAME
737
+ path.parent.mkdir(parents=True, exist_ok=True)
738
+ path.write_text(yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8")
739
+ metadata = save_huggingface_metadata(
740
+ root,
741
+ model_type=self.model_type,
742
+ processor_class=type(self).__name__,
743
+ **(
744
+ {"auto_map": {"AutoProcessor": "processing_actioncodec2.ActionCodec2"}}
745
+ if include_code
746
+ else {}
747
+ ),
748
+ )
749
+ card = save_model_card(root, str(self))
750
+ if include_code:
751
+ from ..integration.exporting import ArtifactExporter
752
+
753
+ files.extend(
754
+ ArtifactExporter(root, source_directory=self._source_directory).export()
755
+ )
756
+ return [str(path), str(card), *map(str, metadata), *files]
757
+
758
+ @classmethod
759
+ def from_pretrained(
760
+ cls,
761
+ pretrained_model_name_or_path: str | Path,
762
+ **kwargs: object,
763
+ ) -> ActionCodec2Router | ActionCodec2:
764
+ """Load a router, or bind its public processor when given ``action_space``.
765
+
766
+ This also lets ``AutoProcessor`` bind an existing router artifact in
767
+ one call. Without ``action_space``, the result remains a router.
768
+ """
769
+ if "action_space" in kwargs:
770
+ from .processing_actioncodec2 import ActionCodec2
771
+
772
+ return ActionCodec2.from_pretrained(pretrained_model_name_or_path, **kwargs)
773
+ root = resolve_pretrained_directory(
774
+ pretrained_model_name_or_path,
775
+ allow_patterns=ROUTER_ALLOW_PATTERNS,
776
+ **kwargs,
777
+ )
778
+ path = root / ROUTER_CONFIG_NAME
779
+ payload = _mapping(
780
+ yaml.load(path.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader),
781
+ "router",
782
+ )
783
+ _reject_unknown(
784
+ payload, {"model_type", "config_version", "profiles", "presets"}, "router"
785
+ )
786
+ if payload.get("model_type") != ROUTER_MODEL_TYPE:
787
+ raise ConfigError(f"router.model_type must be {ROUTER_MODEL_TYPE!r}")
788
+ if payload.get("config_version") != ROUTER_CONFIG_VERSION:
789
+ raise ConfigError(
790
+ f"unsupported router config_version {payload.get('config_version')!r}"
791
+ )
792
+ raw_profiles = payload.get("profiles")
793
+ raw_presets = payload.get("presets")
794
+ if not isinstance(raw_profiles, list) or not isinstance(raw_presets, list):
795
+ raise ConfigError("router.profiles and router.presets must be lists")
796
+ profiles = []
797
+ for index, raw in enumerate(raw_profiles):
798
+ spec = ProfileSpec.from_dict(raw, path=f"router.profiles[{index}]")
799
+ processor = ActionCodec2Processor.from_pretrained(
800
+ root / "profiles" / spec.name
801
+ )
802
+ profiles.append((spec, processor))
803
+ presets = [
804
+ PresetConfig.from_dict(raw, path=f"router.presets[{index}]")
805
+ for index, raw in enumerate(raw_presets)
806
+ ]
807
+ router = cls(profiles, presets)
808
+ router._source_directory = root
809
+ return router