transform-tree 0.0.2__cp39-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tf_tree/__init__.py ADDED
@@ -0,0 +1,107 @@
1
+ """tf_tree — a transform tree engine.
2
+
3
+ A faster, more scalable alternative to ROS ``tf2``.
4
+
5
+ Two things surprise people, and both are deliberate:
6
+
7
+ **Stamps are integer nanoseconds.** There is no float-seconds overload. At a
8
+ 2026 epoch the ULP of ``float64`` seconds is 238 ns, so *every* interval in a
9
+ 1 kHz stream is wrong after a round trip. :func:`from_sec` exists for callers
10
+ who genuinely have float seconds and accept the loss.
11
+
12
+ **Nothing returns a view into shared memory.** An edge's samples are a ring
13
+ being overwritten by another process, and correct reads go through a seqlock;
14
+ an array pointing into it would be a data race by construction. "Zero-copy"
15
+ here means no *intermediate* allocation — results are written once, into their
16
+ final home. Use :meth:`Plan.at_into` to supply that home yourself.
17
+
18
+ **Identifying a build.** A benchmark number or a bug report has to say which
19
+ build produced it, and three values do that::
20
+
21
+ tf_tree.__version__ # the extension build, as a str
22
+ tf_tree.arena_format_version() # the header's set of fields
23
+ f"0x{tf_tree.arena_layout_hash():08X}" # the geometry, as tft prints it
24
+
25
+ They are three because they fail independently: the right version can still
26
+ refuse to attach, because the arena it was pointed at was written by a
27
+ different geometry. The last two are what every participant compares on attach.
28
+
29
+ ``__version__`` is compiled in from ``crates/tf_tree_py/Cargo.toml``, and it is
30
+ *not* the canonical answer for the wheel: ``importlib.metadata.version`` is,
31
+ and it reads ``pyproject.toml``. ``tests/python/test_version.py`` asserts the
32
+ two agree, so a disagreement is a stale wheel or a half-applied bump rather
33
+ than two right answers — which is the attribution this whole trio exists to
34
+ get right.
35
+ """
36
+
37
+ from ._core import (
38
+ BufferError,
39
+ DerivativesUnavailableError,
40
+ DisconnectedError,
41
+ ExtrapolationError,
42
+ FrameNotDeclaredError,
43
+ NoDataError,
44
+ NoSegmentError,
45
+ Plan,
46
+ Publisher,
47
+ TfTreeError,
48
+ TopologyChangedError,
49
+ Tree,
50
+ arena_format_version,
51
+ arena_layout_hash,
52
+ build,
53
+ from_parts,
54
+ from_ros,
55
+ from_sec,
56
+ has_shared_memory,
57
+ open_arena,
58
+ open_file,
59
+ push,
60
+ )
61
+
62
+ # Its own statement, and the redundant alias is not a typo. `__version__` is
63
+ # deliberately absent from `__all__` (the note below it says why), so the alias
64
+ # is what marks it as re-exported — without it ruff reports F401 and a type
65
+ # checker treats the name as private to this module. ruff's isort keeps aliased
66
+ # imports in a separate statement from plain ones, which is why it is down here
67
+ # rather than inside the block above.
68
+ from ._core import (
69
+ __version__ as __version__,
70
+ )
71
+
72
+ __all__ = [
73
+ "BufferError",
74
+ "DerivativesUnavailableError",
75
+ "DisconnectedError",
76
+ "ExtrapolationError",
77
+ "FrameNotDeclaredError",
78
+ "NoDataError",
79
+ "NoSegmentError",
80
+ "Plan",
81
+ "Publisher",
82
+ "TfTreeError",
83
+ "TopologyChangedError",
84
+ "Tree",
85
+ "arena_format_version",
86
+ "arena_layout_hash",
87
+ "build",
88
+ "from_parts",
89
+ "from_ros",
90
+ "from_sec",
91
+ "has_shared_memory",
92
+ "open",
93
+ "open_arena",
94
+ "open_file",
95
+ "push",
96
+ ]
97
+
98
+ # `__version__` is deliberately absent from `__all__` above. It is not an
99
+ # oversight and not a style call: `tests/python/test_stubs.py` asserts `__all__`
100
+ # equals the package's public namespace, and it computes that namespace by
101
+ # skipping underscore-prefixed names — so listing the dunder here would make
102
+ # those two sets differ by exactly this name. `from tf_tree import *` binding a
103
+ # `__version__` is not something anyone wants either.
104
+
105
+ # `open` shadows the builtin inside this module only; the public spelling is
106
+ # `tf_tree.open()`, which is what §4.1 promises.
107
+ open = open_arena # noqa: A001
tf_tree/_core.pyd ADDED
Binary file
tf_tree/_core.pyi ADDED
@@ -0,0 +1,544 @@
1
+ """Type stubs for the `tf_tree` extension module.
2
+
3
+ Hand-written, not generated (`docs/PHASE3.md` §9). Generated stubs cannot
4
+ express the scalar-vs-array return overloads on `Plan.at`, and those overloads
5
+ are the most important thing a user needs to see: they are what makes the
6
+ vectorised path the obvious one.
7
+
8
+ The standing hazard with hand-written stubs is not that they are wrong on day
9
+ one — it is that a method added in Rust never reaches them. `tests/python/
10
+ test_stubs.py` closes that: it asserts every public symbol of the built module
11
+ appears here. Signatures are ours; *existence* is checkable, and that is the
12
+ half that rots.
13
+ """
14
+
15
+ import os
16
+ from typing import Literal, overload
17
+
18
+ import numpy as np
19
+ from numpy.typing import NDArray
20
+
21
+ class TfTreeError(Exception): ...
22
+ class ExtrapolationError(TfTreeError): ...
23
+ class DisconnectedError(TfTreeError): ...
24
+ class NoDataError(TfTreeError): ...
25
+ class TopologyChangedError(TfTreeError): ...
26
+ class FrameNotDeclaredError(TfTreeError): ...
27
+ class BufferError(TfTreeError): ...
28
+
29
+ class DerivativesUnavailableError(TfTreeError):
30
+ """This edge's interpolator has no exact derivative.
31
+
32
+ Raised only by `layout="quat_twist"`: `LerpSlerp` is `tf2`'s interpolator
33
+ and has no exact body twist, so it is refused rather than
34
+ finite-differenced. Declare the edge `ScLerp` — which is the default — or
35
+ ask for a pose layout.
36
+
37
+ A property of the *edge*, so it fires at element 0 of a batch and does not
38
+ go away on its own. Its sibling `NoSegmentError` is the opposite.
39
+ """
40
+
41
+ class NoSegmentError(TfTreeError):
42
+ """A pose exists at this stamp, but no segment to differentiate.
43
+
44
+ The other refusal `layout="quat_twist"` adds over the pose layouts, and the
45
+ one that is **transient**: the edge retains a single sample, or the two
46
+ samples bracketing the stamp carry equal stamps (which is legal — stamps are
47
+ non-decreasing, not strictly increasing). Publish another sample, or ask
48
+ again later.
49
+
50
+ Distinct from `NoDataError`, which means the edge is empty. Here the
51
+ transform is perfectly well defined and only the derivative is not, so being
52
+ told "no data" would send you to the wrong problem. A property of the
53
+ *stamp*, so it can fire partway through a batch.
54
+ """
55
+
56
+ F32Layout = Literal["affine32"]
57
+ """The one layout that writes `float32`."""
58
+
59
+ F64Layout = Literal["mat4", "quat", "quat_twist"]
60
+ """The layouts that write `float64`."""
61
+
62
+ Layout = Literal["mat4", "quat", "affine32", "quat_twist"]
63
+ """How a transform is written into memory. Stated, never inferred.
64
+
65
+ `"mat4"` is `(4, 4)` / `(N, 4, 4)` float64; `"quat"` is `(7,)` / `(N, 7)`
66
+ float64 as `[qw qx qy qz tx ty tz]`; `"affine32"` is `(12,)` / `(N, 12)`
67
+ **float32**, row-major 3x4, GPU-facing; `"quat_twist"` is `(13,)` / `(N, 13)`
68
+ float64, `"quat"` with the body twist `[wx wy wz vx vy vz]` appended.
69
+ """
70
+
71
+ class Plan:
72
+ """A compiled lookup path. Build with `Tree.plan`."""
73
+
74
+ @overload
75
+ def at(self, stamps: int, /) -> NDArray[np.float64]:
76
+ """One stamp in, one `(4, 4)` float64 transform out.
77
+
78
+ The default layout is `"mat4"`, which is float64 — so this returns
79
+ `NDArray[np.float64]`, not a union a caller has to narrow. Only
80
+ `layout="affine32"` produces float32, and it has its own overload.
81
+ """
82
+
83
+ @overload
84
+ def at(self, stamps: NDArray[np.int64], /) -> NDArray[np.float64]:
85
+ """`(N,)` stamps in, `(N, 4, 4)` float64 out — the path to prefer.
86
+
87
+ A Python loop over the scalar form costs ~200 ns per iteration; this
88
+ amortises to near-native.
89
+ """
90
+
91
+ @overload
92
+ def at(
93
+ self, stamps: int | NDArray[np.int64], /, *, layout: F32Layout
94
+ ) -> NDArray[np.float32]:
95
+ """`layout="affine32"`: `(12,)` or `(N, 12)` **float32**, row-major 3x4."""
96
+
97
+ @overload
98
+ def at(
99
+ self, stamps: int | NDArray[np.int64], /, *, layout: F64Layout | None = ...
100
+ ) -> NDArray[np.float64]:
101
+ """`layout=` selects what is written per stamp (see `Layout`).
102
+
103
+ Keyword-only; `stamps` stays positional-only, which is where the
104
+ measured 29 ns of `METH_FASTCALL` lives.
105
+
106
+ `layout="quat_twist"` is `at_with_derivatives` as a batch: it appends
107
+ the body twist, in the plan's **source** frame, angular part first. It
108
+ is the only layout that can raise `DerivativesUnavailableError` or
109
+ `NoSegmentError`.
110
+ """
111
+
112
+ @overload
113
+ def at(
114
+ self, stamps: int | NDArray[np.int64], /, *, layout: Layout | None = ...
115
+ ) -> NDArray[np.float64] | NDArray[np.float32]:
116
+ """The fallback, for a `layout` whose value is not statically known.
117
+
118
+ Passing a variable of type `Layout` cannot resolve to one dtype, so this
119
+ is the only overload that hands back a union — and it is reached only by
120
+ a caller who genuinely does not know which layout they are asking for.
121
+ """
122
+
123
+ @overload
124
+ def at_into(
125
+ self, stamps: int, out: object, /, *, layout: Layout | None = ...
126
+ ) -> None:
127
+ """Evaluate one stamp into a caller-provided `(4, 4)` float64 array.
128
+
129
+ **The allocation-free scalar path, for a control loop.** A node does one
130
+ lookup per tick and cannot batch, so `at`'s per-call array allocation is
131
+ paid every tick forever. Measured on a depth-3 chain, release build:
132
+ `at` 224 ns against `at_into` **173 ns**, and nothing allocated.
133
+
134
+ Allocate `out` once, outside the loop.
135
+ """
136
+
137
+ @overload
138
+ def at_into(
139
+ self, stamps: NDArray[np.int64], out: object, /, *, layout: Layout | None = ...
140
+ ) -> None:
141
+ """Evaluate into a caller-provided `(N, 4, 4)` float64 array.
142
+
143
+ With `layout=`, `out` is `(N, layout_elems)` — or `(layout_elems,)` for
144
+ a scalar stamp — and `float32` for `"affine32"`, `float64` otherwise.
145
+ Every batch entry point has an `_into` form (`API.md` R2), so a layout
146
+ reachable only through the allocating call would be a batch path with
147
+ no allocation-free tier.
148
+
149
+ Allocates nothing. `out` must be C-contiguous and exactly the right
150
+ shape; it is validated completely *before* any element is written, so a
151
+ rejected call leaves it untouched.
152
+
153
+ Raises `BufferError` on a wrong shape, dtype or stride. Non-contiguous
154
+ input is refused rather than silently copied — a silent copy would
155
+ defeat the point of this method while appearing to work.
156
+
157
+ **With `layout=`, a bad `stamps` is reported the way `at` reports it**
158
+ — numpy's or PyO3's own conversion `TypeError` ("only integer scalar
159
+ arrays can be converted to a scalar index" for a float64 array) rather
160
+ than a `BufferError` naming `(N,) int64`. That is the trade for the two
161
+ things it bought: an `np.int64` scalar is accepted, and a `float` stamp
162
+ meets the `TypeError` carrying the 238 ns measurement instead of a
163
+ complaint about a buffer. The default `mat4` path still gives the
164
+ shape-naming `BufferError`, and still refuses `np.int64`; the
165
+ difference is in `Plan.at_into.__doc__`.
166
+
167
+ `out` is typed `object` rather than `NDArray` because the device check
168
+ below accepts anything and then refuses it by message. **Only
169
+ `numpy.ndarray` is written to** (subclasses included); a `memoryview`,
170
+ or a pinned torch or CuPy allocation, is refused whatever its layout.
171
+ `PHASE3.md` §5.5 describes those as qualifying and that is **not
172
+ implemented** — `np.asarray(...)` first.
173
+
174
+ **Device memory is refused** with a message naming the fix: a CPU store
175
+ to a `cudaMalloc` pointer is undefined, not slow.
176
+
177
+ A genuine `numpy.ndarray` skips the device check, because its data
178
+ pointer is host memory by construction; CuPy and torch arrays are not
179
+ numpy subclasses, so they still pay for it. The probe is a Python method
180
+ call — `__dlpack_device__()` — and running it on every numpy call cost
181
+ ~120 ns of a ~173 ns lookup.
182
+ """
183
+
184
+ def adaptive(
185
+ self,
186
+ start_ns: int,
187
+ end_ns: int,
188
+ /,
189
+ *,
190
+ lin: float = ...,
191
+ ang: float = ...,
192
+ ) -> tuple[NDArray[np.int64], NDArray[np.float64]]:
193
+ """Knots whose linear interpolation stays within `lin` m / `ang` rad.
194
+
195
+ Returns `(stamps, poses)` of shapes `(K,)` and `(K, 4, 4)`, strictly
196
+ increasing. LERP between adjacent knots on whatever device they live
197
+ on; the reconstruction error is bounded by construction.
198
+ """
199
+
200
+ def latest(self) -> NDArray[np.float64]:
201
+ """The most recent transform on this path, as `(4, 4)`."""
202
+
203
+ def depth(self) -> int:
204
+ """Folded depth of this path, in edges."""
205
+
206
+ def edges(self) -> list[tuple[str, str]]:
207
+ """The **dynamic** edges this plan samples, as `(parent, child)` pairs.
208
+
209
+ In fold order — the order the compositions happen, which is the order
210
+ the plan is.
211
+
212
+ Shorter than `depth()` when the path crosses a static edge. A static
213
+ edge (or a whole run of them) is folded into one constant transform at
214
+ compile time and its identity does not survive the fold, so a plan
215
+ cannot list what it no longer knows. Use `Tree.edges()` for the
216
+ topology; this is what *this path samples at evaluation time*.
217
+
218
+ Raises `TfTreeError` on a tree inherited across a `fork()`.
219
+ """
220
+
221
+ class Publisher:
222
+ """A claimed edge. Use as a context manager; the claim releases on exit."""
223
+
224
+ def __enter__(self) -> Publisher: ...
225
+ def __exit__(self, *args: object) -> bool: ...
226
+ def release(self) -> None:
227
+ """Drop the claim now, rather than at an unspecified finalization."""
228
+
229
+ def push(self, stamp_ns: int, quat7: list[float], /) -> None:
230
+ """Publish `[qw, qx, qy, qz, tx, ty, tz]` at `stamp_ns`."""
231
+
232
+ def push_many(
233
+ self, stamps: NDArray[np.int64], poses: NDArray[np.float64], /
234
+ ) -> None:
235
+ """Publish `(N,)` stamps and `(N, 7)` poses in one crossing."""
236
+
237
+ class Tree:
238
+ """A transform tree. Obtain with `tf_tree.open()` or `tf_tree.build()`."""
239
+
240
+ def plan(self, target: str, source: str, /) -> Plan:
241
+ """Compile a path from `source` to `target`.
242
+
243
+ Compile once and reuse: the path walk and per-edge metadata lookup
244
+ happen here, not per sample.
245
+ """
246
+
247
+ def publisher(self, child: str, parent: str, /) -> Publisher:
248
+ """Claim `child`'s edge. Argument order is **(child, parent)**."""
249
+
250
+ def lookup(self, target: str, source: str, stamp_ns: int, /) -> NDArray[np.float64]:
251
+ """One transform, without compiling a plan first.
252
+
253
+ The plan is cached per *thread*. Prefer `tree.plan(...)` in a loop —
254
+ this pays a cache probe per call and a compiled plan pays nothing.
255
+ """
256
+
257
+ def freeze(
258
+ self, path: str | os.PathLike[str], /, *, source: str | None = ...
259
+ ) -> None:
260
+ """Write this tree to `path` as a frozen `.tft` (`PHASE5.md` §2.3).
261
+
262
+ The file *is* the arena: `open_file` maps it back with no parse and no
263
+ fixups, and the lookups it answers are bit-identical to this tree's.
264
+
265
+ Replacing `path` is atomic — the bytes land in a sibling temporary and
266
+ are renamed over it — so an interrupted freeze leaves the previous index
267
+ intact instead of a half-written one under the name somebody will open
268
+ next week.
269
+
270
+ `source` is the recording these poses came from; it is recorded in the
271
+ manifest as `null` when there is none. Linux only.
272
+
273
+ The GIL is released for the copy, so a background freeze does not stall
274
+ the threads servicing your progress bar or socket.
275
+ """
276
+
277
+ def span(self, target: str, source: str, /) -> tuple[int, int] | None:
278
+ """The interval, in nanoseconds, over which `plan(target, source)` answers.
279
+
280
+ `LatestCommon` generalised to a range: the *intersection* of every
281
+ dynamic edge's retained window, so the lower end is a `max` and the
282
+ upper end a `min`. It is the query to reach for when a lookup fails at
283
+ a stamp, because the answer is nearly always "one edge on the path had
284
+ not started yet".
285
+
286
+ Three distinct answers:
287
+
288
+ * `(t0, t1)` with `t0 <= t1` — answerable there, nowhere else.
289
+ * `(t0, t1)` with `t0 > t1` — the windows do not overlap. That is a real
290
+ answer, not an error: `t0 <= t <= t1` is correctly false everywhere.
291
+ * `None` — every step on the path is static (or the path is empty), so
292
+ the plan answers at *any* stamp and there is no finite interval.
293
+
294
+ Raises `NoDataError`, naming the edge's two **frames**, when an edge on
295
+ the path has no samples at all — which is a different situation from a
296
+ non-overlapping window and calls for a different fix. Raises
297
+ `TopologyChangedError` if the tree was re-parented under the call.
298
+
299
+ On a live tree the answer is a snapshot that ages immediately, exactly
300
+ as `Plan.latest` does.
301
+ """
302
+
303
+ def frames(self) -> list[str]:
304
+ """The frame names on this tree, in declaration order.
305
+
306
+ The cheap way to see what is in an arena without shelling out to
307
+ `tf_tree doctor`. Frame identity is append-only, so a name that appears
308
+ here will never be removed or renumbered — but on a *live* shared arena
309
+ a peer process can add one under you, so treat the list as a snapshot,
310
+ exactly as `Plan.latest` and `Tree.span` already are.
311
+
312
+ A name longer than 48 bytes was truncated when it was interned; the
313
+ stored form is what comes back.
314
+
315
+ **The list is not guaranteed to be free of duplicates.** If a peer
316
+ process stalls mid-intern and another rescues the slot, the loser's
317
+ record stays written but unreferenced, so the same name can appear at
318
+ two ids. Rare — it needs the rescue path — but it means `len()` is an
319
+ upper bound and `dict(zip(tree.frames(), ...))` can lose an entry.
320
+
321
+ Raises `TfTreeError` on a tree inherited across a `fork()` — the child's
322
+ mapping is gone, so there is nothing to list.
323
+ """
324
+
325
+ def edges(self) -> list[tuple[str, str]]:
326
+ """The edges on this tree, as `(parent, child)` name pairs.
327
+
328
+ `(parent, child)` is the order `tf_tree.build` and `tf_tree.open(
329
+ create=...)` take. It is deliberately *not* `Tree.publisher`'s
330
+ `(child, parent)` order: an edge list silently reversed builds a tree
331
+ that is upside down and still perfectly valid.
332
+
333
+ **This is the parent/child graph, not a round trip.** The list does not
334
+ say whether an edge is static or dynamic, and `tf_tree.build` has no way
335
+ to declare a static one — so feeding it back reproduces the graph, but
336
+ every static edge comes back as a dynamic edge with no samples, and a
337
+ lookup crossing one raises `NoDataError` instead of returning the
338
+ constant it had. On a tree you built with `tf_tree.build` every edge is
339
+ already dynamic and the distinction cannot arise; on a `.tft` or on an
340
+ arena a Rust or C peer created, it can.
341
+
342
+ **Names only** — no rate, no jitter, no gaps, no sample count. Those are
343
+ `PHASE5.md` §4.2's `ds.edges()` and are held back until the counting
344
+ pass that can answer them honestly exists: a ring knows what it
345
+ *retained*, which is not what the publisher produced, and a rate derived
346
+ from the one and reported as the other is worse than no rate at all.
347
+
348
+ Raises `TfTreeError` on a tree inherited across a `fork()`.
349
+ """
350
+
351
+ def instance_uuid(self) -> str:
352
+ """Which arena instance this is, as 32 hex characters.
353
+
354
+ All-zero in-process. Two processes that resolved the same *name* can
355
+ still hold different segments; this is what tells them apart.
356
+
357
+ Raises `TfTreeError` on a tree inherited across a `fork()`, rather than
358
+ returning the all-zero value the child's poison mapping holds — which is
359
+ the spelling that means "in-process", so two peers chasing a split brain
360
+ would conclude they had never been shared. `repr()` does not raise; it
361
+ prints `detached-by-fork` in place of the instance.
362
+ """
363
+
364
+ def is_shared(self) -> bool:
365
+ """Whether this tree's arena is shared with other processes."""
366
+
367
+ def is_writable(self) -> bool:
368
+ """Whether this process may publish into this tree."""
369
+
370
+ def build(
371
+ edges: list[tuple[str, str]],
372
+ *,
373
+ capacity: int = ...,
374
+ interp: Literal["sclerp", "lerpslerp"] = ...,
375
+ frame_headroom: int = ...,
376
+ ) -> Tree:
377
+ """An in-process tree from `(parent, child)` edges.
378
+
379
+ Topology is builder-time (decision `0004`), so there is no `declare_*` on a
380
+ live tree: the layout is a property of the arena, fixed when it is created.
381
+
382
+ `interp` defaults to `"sclerp"` — the SE(3) screw geodesic, which is the
383
+ engine's own default and the only policy with an exact derivative, so
384
+ `plan.at(stamps, layout="quat_twist")` works on a tree built this way.
385
+
386
+ Pass `interp="lerpslerp"` for `tf2`-bit-compatible interpolation
387
+ (translation LERP + rotation SLERP). It is not right-invariant —
388
+ interpolating `T0 @ C, T1 @ C` is not `interp(T0, T1) @ C` — and it has no
389
+ exact body twist, so `layout="quat_twist"` over such an edge raises
390
+ `DerivativesUnavailableError`.
391
+
392
+ This binding hard-coded `"lerpslerp"` until now, diverging from Rust with no
393
+ measurement behind it; `PROJECT.md` §5 D5 requires one, so the default moved
394
+ rather than the rule.
395
+ """
396
+
397
+ def push(
398
+ tree: Tree, child: str, parent: str, stamp_ns: int, quat7: list[float], /
399
+ ) -> None:
400
+ """Publish `[qw, qx, qy, qz, tx, ty, tz]` onto an edge at `stamp_ns`.
401
+
402
+ Takes the engine's own representation rather than a 4x4: a *nearly* rigid
403
+ matrix — which is what arrives after any floating-point round trip — has no
404
+ exact conversion back, only a projection.
405
+ """
406
+
407
+ def open_arena(
408
+ *,
409
+ name: str | None = ...,
410
+ domain: int | None = ...,
411
+ mode: Literal["ro", "rw"] = ...,
412
+ create: list[tuple[str, str]] | None = ...,
413
+ capacity: int = ...,
414
+ interp: Literal["sclerp", "lerpslerp"] = ...,
415
+ frame_headroom: int = ...,
416
+ ) -> Tree:
417
+ """Attach to a running arena. Exported as `tf_tree.open`.
418
+
419
+ `mode="ro"` by default and `create=None`: a consumer must be incapable of
420
+ corrupting a robot's tree (the MMU enforces it), and a notebook started
421
+ before the robot must fail loudly rather than create an empty arena the
422
+ real publisher then refuses to join.
423
+
424
+ Pass `create=[(parent, child), ...]` — the same edge list `build` takes —
425
+ to create the arena when it is absent. An arena is sized from its declared
426
+ edges, so there is no way to create one without saying what is in it; that
427
+ is why this is an edge list rather than a boolean. **It requires
428
+ `mode="rw"`** and is refused otherwise, so a read-only consumer still
429
+ cannot bring an arena into existence.
430
+
431
+ `capacity` and `interp` describe the edges being created; both are
432
+ `build`'s, with the same defaults. Without `create` they describe nothing —
433
+ but `interp` is still validated, so a misspelling raises here exactly as it
434
+ does in `build` rather than being silently discarded.
435
+ """
436
+
437
+ def open_file(path: str | os.PathLike[str], /) -> Tree:
438
+ """Open a frozen `.tft` and read it as an ordinary `Tree` (`PHASE5.md` §4.1).
439
+
440
+ Opening is an `mmap`, so it costs microseconds and no parse — and it hands
441
+ back the **same** `Tree` a live arena does. `plan`, `at`, `at_into`,
442
+ `adaptive`, `latest` and `span` are the objects that were already there,
443
+ with the same semantics and bit-identical results. There is no offline API
444
+ to learn.
445
+
446
+ The tree is permanently read-only: `is_writable()` is `False` and
447
+ `publisher()` refuses, because the mapping is `PROT_READ` and a store
448
+ through it would be a fault rather than an error.
449
+
450
+ Raises `FileNotFoundError` (and its `OSError` siblings) for a path problem,
451
+ and `TfTreeError` for a file that is not a readable `.tft` — a layout or
452
+ format mismatch names both values and says to re-freeze, since a `.tft` is a
453
+ cache and not an archive.
454
+
455
+ **Dataloader pattern (§4.3).** Documented, not shipped: a
456
+ `torch.utils.data.Dataset` subclass would bind this package to a framework
457
+ version for no benefit, and the pattern is four lines::
458
+
459
+ class Frames(Dataset):
460
+ def __init__(self, path):
461
+ self.path, self.ds = path, None
462
+
463
+ def __getitem__(self, i):
464
+ if self.ds is None: # per worker
465
+ self.ds = tf_tree.open_file(self.path)
466
+ ...
467
+
468
+ Open it **in the worker, not in the parent**. A `Tree` cannot be pickled,
469
+ and a `DataLoader` with `num_workers > 0` sends the dataset object to its
470
+ workers — by pickle under `spawn` and `forkserver`, which is CPython 3.14's
471
+ default start method on Linux. The lazy `None` is what keeps the object
472
+ picklable.
473
+
474
+ Under a plain `fork` an inherited `.tft` mapping does keep working: it is
475
+ `MAP_PRIVATE | PROT_READ` and is deliberately *not* poisoned at fork, unlike
476
+ a shared-memory attach. So the rule is about picklability, not about the
477
+ arena going away — §4.3 gives the fork-poisoning reason and that reason does
478
+ not apply to a frozen file.
479
+
480
+ Sixteen workers that each open the same file share one set of clean
481
+ page-cache pages, so the marginal cost per worker is about zero. That is the
482
+ entire argument for `.tft` (§2.2), and opening once in the parent and
483
+ passing poses down instead gives it up.
484
+ """
485
+
486
+ def from_sec(seconds: float, /) -> int:
487
+ """Nanoseconds from float seconds. Lossy above ~10^7 s.
488
+
489
+ Prefer the exact converters: `from_parts` for a `(sec, nanosec)` pair and
490
+ `from_ros` for a `builtin_interfaces/Time`. Neither takes a float.
491
+ """
492
+
493
+ def from_parts(sec: int, nanosec: int, /) -> int:
494
+ """Exact nanoseconds from a `(sec, nanosec)` pair.
495
+
496
+ Raises `ValueError` for a `nanosec` outside `[0, 1e9)` — **refused, not
497
+ normalised** — and for a sum outside `int64` — **refused, not wrapped**.
498
+ Both alternatives produce a stamp that looks perfectly well formed, which
499
+ is the failure this converter exists to prevent.
500
+ """
501
+
502
+ def from_ros(stamp: object, /) -> int:
503
+ """Exact nanoseconds from a ROS 2 `builtin_interfaces/Time`.
504
+
505
+ Never via `to_sec()`: the message is `{int32 sec, uint32 nanosec}` and
506
+ converts exactly. Duck-typed on `.sec` and `.nanosec`, so `rclpy` is not a
507
+ dependency of this wheel and must not become one.
508
+
509
+ Refusals are `from_parts`'s.
510
+ """
511
+
512
+ def has_shared_memory() -> bool:
513
+ """Whether this build can share a tree between processes."""
514
+
515
+ __version__: str
516
+ """This extension's version, compiled in from the crate manifest.
517
+
518
+ `importlib.metadata.version("transform_tree")` is the canonical answer and reads a
519
+ different file — `pyproject.toml`'s `[project] version` against this one's
520
+ `Cargo.toml`. `tests/python/test_version.py` asserts the two agree, which is
521
+ the only thing that keeps them from drifting.
522
+ """
523
+
524
+ def arena_format_version() -> int:
525
+ """This build's arena format version — the *set of fields* in the header.
526
+
527
+ 3 as of `PHASE5.md` §1. A different one is never compatible: there is no
528
+ conversion layer, so every participant is rebuilt from one commit and
529
+ restarted together.
530
+ """
531
+
532
+ def arena_layout_hash() -> int:
533
+ """This build's arena layout hash — the *geometry*.
534
+
535
+ Checked on attach beside `arena_format_version()`, and a mismatch on either
536
+ is refused. Two builds agreeing on the version and disagreeing on the hash
537
+ disagree about *where* things are, which is worse than disagreeing about
538
+ what they are.
539
+
540
+ An `int`, because it is compared rather than read. For a report write
541
+ `f"0x{tf_tree.arena_layout_hash():08X}"` — the literal `0x` matters, since
542
+ Python's `{:#010X}` produces `0X…` and would not match what
543
+ `tft doctor --explain-version` prints.
544
+ """
tf_tree/py.typed ADDED
File without changes