transforms 2.1.1

A transform library to track reference frames and provide transforms between them.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.1.1] - 2026-09-01

### Changed

- MSRV lowered from 1.86 to 1.85 — the edition 2024 floor, and the rustc
  Debian 13 "trixie" ships. Every runtime dependency already permits 1.85;
  the previous 1.86 came solely from the `criterion` dev-dependency, which
  is held at 0.7 (declared MSRV 1.80) so the claim stays verifiable by the
  CI MSRV job rather than resting on criterion 0.8's rolling MSRV policy.
  Dev-only: downstream builds compile the same code as before.

## [2.1.0] - 2026-08-27

### Added

- `Registry::latest_common_time(target, source)`: returns the newest
  instant `get_transform` can serve for the pair — the oldest of the
  connecting chain's dynamic hops' newest samples, consulting only the
  hops the chain actually crosses, so the answer is exact for mid-tree
  pairs too. All-static chains (and `target == source`) return
  `Stamp::Static`: the chain puts no bound on time and the caller picks
  the instant. The intended idiom is this call followed by
  `get_transform` at the returned instant, under one lock guard when the
  registry is shared.
- `RegistryError::NoCommonTime`: `latest_common_time`'s refusal when no
  instant is servable by every hop, naming the hop that rules it out —
  `covered: Some(range)` when the chain's covered ranges are disjoint,
  `covered: None` when the hop holds no data at all. Unknown and
  disconnected frames report the same `UnknownFrame` and `Disconnected`
  variants as a failed lookup.

### Changed

- The retry-off-`covered` "latest available" idiom is retired from the
  `NotFoundAt` documentation in favor of `latest_common_time`, which is
  exact where the loop was conservative (mid-tree targets) and does not
  spend a failed chain walk per attempt. `examples/std_full.rs` now
  demonstrates the new idiom instead of hardcoding a lookup lag coupled
  to the writer's publish rate.
- `Registry`'s `Debug` output summarizes each frame's buffer — frames,
  kind, sample count, covered range — instead of dumping every stored
  sample. `Debug` output is not a stability surface; match on error
  variants and read accessors, never on formatted text.

## [2.0.0] - 2026-08-22

The 1.x line's silent-wrong-answer surfaces are closed: 2.0.0 validates
what enters the registry, diagnoses what fails, and pins its numerics
bit for bit across feature modes. A migration guide from 1.4.1 lives in
[MIGRATION.md](MIGRATION.md). Five pre-releases were published on the
way — alpha.1 and beta.1 through beta.4, with no release candidate —
and this section is the consolidated `v1.4.1 → v2.0.0` delta, their
sections folded together; the per-pre-release history stays readable in
the [changelog as published with
beta.4](https://github.com/deniz-hofmeister/transforms/blob/v2.0.0-beta.4/CHANGELOG.md).

### Changed

- **Breaking:** the static-transform sentinel is eliminated, not moved:
  `Transform.timestamp` is retyped from `T` to `Stamp<T> { Static, At(T) }`.
  Staticness is a variant of the type, so **no timestamp value is
  reserved** — every instant a clock can produce, including `t=0` on the
  boot-relative clocks the embedded story courts and `UNIX_EPOCH` for
  `SystemTime`, is ordinary dynamic data. On the wire a zero-stamped
  message is an ordinary dynamic sample, and a message *missing* its
  timestamp field is rejected rather than silently becoming an eternal
  static transform. `Transform::static_between` builds static
  transforms; every other transform passes its stamp to `Transform::new`
  as `Stamp::At(...)`.
- **Breaking:** `Transform`'s fields are private and its constructors
  validate. `Transform::new(parent, child, translation, rotation, stamp)` and
  `Transform::static_between(parent, child, translation, rotation)` return
  `Result<Self, TransformError>`, rejecting non-finite components and
  rotations whose norm leaves the unit tolerance — published as the
  module-level const `geometry::UNIT_NORM_TOLERANCE`; `translation()`,
  `rotation()`, `timestamp()`, `parent()` and `child()` read the components
  back, and the struct is `#[non_exhaustive]`. In 1.4.1 nothing validated a
  transform anywhere, which left the public `Mul` and
  `Transformable::transform` applying geometric garbage silently — a
  norm-1.01 rotation scaled everything it touched by 2% and returned `Ok`,
  and a NaN translation was stored and served. Validation sits at
  construction *and* at insert: `add_transform` still reports
  `NonUnitRotation` and `NonFiniteValues`, because results of `*`, `inverse`,
  `interpolate` and registry lookups are deliberately not re-validated —
  rotation norms drift a few ulps per composition and re-checking a long
  chain would reject legitimate results — so a caller who flattens a chain
  and re-publishes it still hands the registry a value only the insert path
  checks. `Transform::validate` stays public for transforms of uncontrolled
  provenance.
- **Breaking:** `add_transform` returns `Result` and validates on insertion:
  non-finite values, non-unit rotations, self-referential frames,
  re-parenting, and cycles are rejected. The frame tree is strict — a child
  frame's parent and its static-or-dynamic kind are pinned by its first
  insert, the pins survive the frame being drained of every sample, and
  `Registry::remove_frame` is the only release (and the escape hatch for
  re-parenting). Lookups on a drained frame report `NotFoundAt` with
  `covered: None` rather than `UnknownFrame`, so routine cleanup can never
  quietly re-open a refused re-parenting or change of kind.
- **Breaking:** `Registry::new(max_age)` splits into `Registry::new()`  no automatic cleanup — and `Registry::with_max_age(max_age)`, and both
  exist in both feature modes: automatic cleanup no longer requires
  `std`. `Registry` also implements `Default`. Mind rustc's suggestion
  on the 1.x call site: "remove the extra argument" compiles into a
  registry that never evicts — if you had a `max_age`, you want
  `with_max_age` (MIGRATION.md break 1 spells this out).
- **Breaking:** every `Registry` call reports one flat error type,
  `errors::RegistryError<T>`. 1.4.1 answered a failed lookup with the
  catch-all `TransformError::NotFound(from, to)` — or, for frames in
  different trees, with `IncompatibleFrames` — and could return a
  partway-resolved chain as `Ok` across a mid-chain data gap; lookups
  now diagnose —
  `UnknownFrame` when a requested frame exists nowhere in the tree,
  `Disconnected` when both frames exist but no chain connects them, and
  `NotFoundAt` when the walk stopped at a frame that cannot serve the
  requested time, mirroring tf2's LookupException / ConnectivityException
  / ExtrapolationException — so a mid-chain data gap and frames from
  disconnected trees no longer masquerade as "frames do not have a
  parent-child relationship". `NotFoundAt` carries `frame`,
  `requested: T` and `covered: Option<(T, T)>` directly — `Some` a gap in
  data the frame holds, `None` a frame holding nothing at all — and its
  docs work through the terminating latest-available retry idiom, with a
  property test pinning its exactness for root-target lookups. The
  payloads are typed in the registry's own time type instead of
  pre-formatted seconds, so they compare against the clock the caller
  asked with; `Display` still renders seconds through
  `TimePoint::as_seconds_lossy`. Insert rejections are flat variants of
  the same enum — `NonUnitRotation`, `NonFiniteValues`,
  `SelfReferentialFrame`, `ReparentingNotSupported { current_parent }`,
  `CycleDetected`, `StaticDynamicConflict` — and the two validation
  causes are flat on every path that reports them, including the
  half-chain inversion by which a lookup rejects an overflowed
  translation, so no condition has two spellings to match on. (A lookup
  still does not re-validate its result: the ancestor-ward direction
  inverts nothing and returns such a translation as `Ok`.)
  `TransformError` shrinks to what its name says — the geometry and time
  failures of the `Transform` constructors, `inverse`, `interpolate`, `*`
  and `Transformable::transform`.
- **Breaking:** every error payload field is named, and every
  frame-related error names its frames: `TimestampMismatch { lhs, rhs }`,
  `TimestampOutOfRange { requested, start, end }`,
  `IncompatibleFrames { expected, found }`,
  `SameFrameMultiplication { frame }`,
  `Disconnected { target_frame, source_frame }`, and `NotFoundAt
  { target_frame, source_frame, frame, requested, covered }` — the
  lookup-argument fields carry the `_frame` suffix because a field literally
  named `source` belongs to the error trait's source-chaining convention.
  All public error enums are `#[non_exhaustive]`.
- **Breaking:** `Buffer` and the `core` module are private, and
  `Registry`'s internal storage is too. `Registry` — at the crate root,
  where it was already re-exported — is the entire public entry point.
  The standalone-buffer API is gone with it, `BufferError` included: a
  single buffer enforces only its own parent and child pins, while the
  cycle check needs a view of the whole tree and lives in `Registry`, so
  hand-held buffers were a second, weaker way to store transforms for a
  use case nobody had — one that could close a cycle nothing would
  reject. Its storage is now free to change without a major release.
- **Breaking:** every public type has a single canonical path
  (`geometry::Point`, `time::Timestamp`, ...): the leaf modules are private,
  matching the error-module pattern. Error types live at `errors::*`.
- **Breaking:** `TimePoint` is pure time arithmetic, in exactly the three
  methods the core calls — `duration_since`, `checked_sub`, and
  `as_seconds_lossy` — and gains `Debug` as a supertrait next to
  `Copy + Ord`. `static_timestamp()` and `is_static()` are gone with the
  sentinel (the trait is unsealed and `is_static` was overridable
  independently of `static_timestamp`, silently breaking kind detection),
  and by 2.0 neither `checked_add` nor `as_seconds` had a call site
  left (`Stamp` took over the timestamp arithmetic, `as_seconds_lossy`
  the error formatting) — on a soft-float MCU target an unused checked
  conversion is a double-precision divide nobody asked for. `as_seconds_lossy` is
  required rather than defaulted, which states its contract in the type
  system: error formatting cannot fail. The `Debug` bound stops a clock
  type without its own derive from making `Transform<YourClock>`
  silently unprintable in the diagnostics that report a bad lookup.
- **Breaking:** `Timestamp`'s inner nanosecond field is private and narrows
  from `u128` to `u64`; `from_nanos(u64)` / `as_nanos() -> u64` are the API.
  u64 nanoseconds span ~584 years (mid-2554 from the Unix epoch) — past the
  service life of anything this crate positions — while shrinking per-sample
  stamp storage and removing multi-word arithmetic from the 32-bit MCU
  targets. The stamp's integer narrows from 16 bytes to 8 and sheds the
  16-byte alignment it imposed on everything stored beside it: measured on
  x86-64, `Transform<Timestamp>` is 120 bytes where the `u128` made it 128,
  and the buffer's `BTreeMap` key goes from 16 bytes to 8. On the wire the
  stamp is a native integer in every serde format (see the serde entry
  under Added): variable-width encodings write the same bytes they would
  for a `u128` of equal value, bincode 1.x and bincode 2's
  `config::legacy()` write eight fixed bytes, and MessagePack emits a
  native integer rather than the 16-byte blob a `u128` forces.
  `Timestamp::now()` gains a second (2554) way to panic, and `try_now`
  reports it as `TimeError::DurationOverflow`.
- **Breaking:** `Timestamp::as_seconds_unchecked` is renamed
  `as_seconds_lossy`, matching the `TimePoint` vocabulary — the operation
  is lossy, not unsafe. `Timestamp::as_seconds` gains an honest accuracy
  contract: it errs beyond 2^53 nanoseconds (~104 days), where `f64`
  loses sub-nanosecond accuracy.
- **Breaking:** `Registry::delete_transforms_before` is renamed
  `Registry::remove_transforms_before`. The crate spelled one operation two
  ways — `remove_frame` next to `delete_transforms_before` — inviting readers
  to look for a distinction that never existed.
- **Breaking:** `==` on geometry types is exact IEEE 754 equality; the
  unsound `Eq` impl on `Transform` and the `PartialOrd` derives on
  `Quaternion`, `Vector3`, and `Point` are removed. Tolerant comparison
  lives in the `approx` traits (`AbsDiffEq`/`RelativeEq`, now implemented
  for all geometry types).
- **Breaking:** `Quaternion` keeps only its rotation algebra — `*`,
  `conjugate`, `normalize`, `norm`, `rotate_vector`, `slerp` and the
  constructors. The vector-space surface leaves the public API: `+`, `-`,
  `/` (with its `QuaternionError::DivisionByZero`) and the `Default` impl
  had no caller outside their own unit tests, `norm_squared` served only
  the removed `/`, and `scale` survives privately inside `normalize` and
  `slerp`. Summing rotations and renormalizing is the classic silent
  wrong answer, and `/` returned `Ok` with all-NaN components for a NaN
  divisor and an all-zero, finite quaternion for an overflowing one.
  `q2 / q1` on unit quaternions is `q2 * q1.conjugate()` up to the
  divisor's norm drift — the conjugate spelling is the more accurate.
  Slerp's blend moved to a private helper, unchanged bit for bit under
  the pinned slerp tests.
- **Breaking:** `Vector3` loses `dot`, `cross` and
  `unit_x`/`unit_y`/`unit_z``dot` and `cross` were exercised only by
  their own unit tests, the unit constructors by nothing at all, the
  crate already points more general needs at a linear-algebra library,
  and the public components make each a one-liner. The operator set
  stays complete: `+`, `-`, both scalar multiplications and `/` remain.
- **Breaking:** `Point` gains `Point::new(position, orientation, timestamp,
  frame)` and `#[non_exhaustive]`, which ends its struct literal. Its fields
  stay public: a point is a data record, not an invariant carrier — the
  invariants live on the `Transform` applied to it.
- **Behavior change:** float math is `libm`'s in every feature mode. 1.x
  used the platform's own `sqrt`/`sin`/`acos` — so an interpolated
  rotation depended on the host's math library, and a desktop replay of a
  flight log could differ from the flight controller that produced it, in
  a domain where the two are compared to decide which one was right. The
  `std` feature now changes which API exists, never a computed value.
  Against `std` math on glibc this moves interpolated rotations by up to
  four ulps per component (measured over a sweep of arcs and
  interpolation factors); `sqrt` is unaffected, being correctly rounded
  everywhere. Three slerp cases — interior, near-antipodal, and
  near-identity — are pinned bit for bit and run in both feature modes.
- **Behavior change:** a lookup inverts at most once. Both halves of a
  resolved chain are composed in their natural direction, deleting a
  reverse-and-invert pass that cost one inversion per hop plus one at the
  end — in the crate's own documented direction
  (`get_transform("map", "lidar", t)`), the one the README teaches. Measured
  on x86-64: a single hop drops from 11 heap allocations (1456 B) to 5
  (488 B), four hops from 23 to 11, and 64 hops from 271 to 135. That
  direction is no longer renormalized on the way out, which is what makes a
  single-hop lookup at a stored timestamp return the stored transform bit for
  bit; in 1.x it came back through two inversions, up to ten ulps off in
  the translation.
- **Behavior change:** same-frame lookups succeed: `get_transform(x, x, t)`
  returns the identity transform instead of an error. Lookup results
  always carry the requested timestamp, also over all-static chains, and
  a static transform applies to data of any timestamp through
  `Transformable`.
- **Behavior change:** there is no extrapolation anywhere. An
  out-of-range `Transform::interpolate` reports `TimestampOutOfRange`
  with the requested time and both range endpoints (a registry lookup
  reports `NotFoundAt` with the frame's covered range), and
  `Quaternion::slerp` clamps its factor to `[0.0, 1.0]`.
- `get_transform`'s parameters are renamed `target`/`source` (previously
  `from`/`to`; positional call sites are unaffected), aligning with
  `get_transform_at` and tf2's `lookupTransform`, and its docs gain an
  explicit direction-convention section — the old names read backwards and
  silently produced the inverse for plain-English callers.
- `with_max_age` eviction pops expired entries from the front of the
  ordered map — O(log n + evicted) per insert instead of a full-buffer
  scan (previously ~144 µs per insert at 60k live entries, the README
  Quick Start configuration at 1 kHz). A 60k-entry steady-state benchmark
  guards the regression.
- `get_transform_at` composes its two legs through a private
  time-agnostic path instead of stamping both legs static to bypass
  `Mul`'s timestamp check, as 1.x did.
- Error `Display` messages are lowercase per the Rust API guidelines,
  and their exact strings are documented as not a stability surface.
- Crate upgraded to edition 2024; MSRV is 1.86, verified in CI.

### Removed

- `Transform::identity()`. It returned empty parent and child frames —
  self-referential, so it could be neither inserted nor composed — and
  existed only as a base for field-poking, which private fields end. The
  registry synthesizes its own identity for same-frame lookups.
- `TransformError::NotFound` — 1.x's primary lookup-miss error since
  1.0 — replaced by the diagnosed `UnknownFrame` / `Disconnected` /
  `NotFoundAt` reported through `RegistryError`.
- `TransformError::TransformTreeEmpty`. 1.4.1 produced it from a
  same-frame lookup on a frame that held data; that lookup now returns
  the identity, and its only remaining `return` sat on an empty chain no
  public call can reach. Removing an enum variant after stable would be
  a breaking change, so it goes now.
- The deprecated `TimestampError` alias (use `TimeError`) and the
  never-produced `BufferError::MaxAgeInvalid` variant. `BufferError`
  itself is gone with `Buffer` — see the privatization entry.

### Added

- MIGRATION.md: a migration guide from 1.4.1, organized by compile
  errors first and runtime behavior changes second, with a dedicated
  section for users coming from a 2.0.0 pre-release. It ships in the
  published crate alongside this changelog.
- Optional, default-off `serde` feature for the geometry and time types,
  `no_std`-compatible, with the wire format frozen by golden-byte tests.
  `Stamp` is an explicitly tagged enum — `{"At": 1753142400000000000}`
  and `"Static"` in JSON, a variant index ahead of the payload in
  non-self-describing formats, the width of index and integer belonging
  to the codec (postcard and bincode 2's `config::standard()` write a
  1-byte index and a LEB128 varint; bincode 1.x and bincode 2's
  `config::legacy()` a fixed 4-byte index and fixed-width integer) — and
  `Timestamp` is `#[serde(transparent)]`, a bare nanosecond integer
  rather than a one-field record. No magic value appears on the wire and
  staticness is always spelled: a `timestamp` that is *missing* or
  `null` is a decode error rather than a silently minted eternal static
  transform. Deserializing a `Transform` runs constructor validation, so
  a denormalized rotation or non-finite component on the wire is a
  decode error too. The `std` feature forwards `serde?/std`, so
  `Transform<SystemTime>` serializes for downstream users who enable
  `std` + `serde` without depending on serde's `std` feature themselves.
- `Timestamp::try_now()`: panic-free counterpart of `now()`, returning
  `TimeError::DurationUnderflow` on a pre-epoch system clock and
  `TimeError::DurationOverflow` on one set past the u64 nanosecond range.
- `Quaternion::from_wxyz(w, x, y, z)`: the constructor states its
  component order in its name, because the other common convention puts
  the scalar part last and a caller passing `(x, y, z, w)` builds a
  perfectly valid unit quaternion describing the wrong rotation — an
  error no validation can catch. `Quaternion` literals still work.
- `Timestamp::from_nanos` / `as_nanos`, `Registry::remove_frame`,
  `Transform::validate`, and `TimePoint::as_seconds_lossy` (required;
  implement it as best-effort, yielding `NaN` over a plausible-looking
  number, because it formats error messages).
- `TransformError::StaticInterpolation`: a static transform used as an
  interpolation endpoint is rejected explicitly instead of falling
  through to an incidental error.
- `QuaternionError::NonFinite`: `Quaternion::normalize` rejects
  non-finite inputs explicitly — 1.x normalized a NaN quaternion to
  `Ok` with all-NaN components.
- Two of the six examples (`std_full`, `no_std_full`) now demonstrate a
  static sensor mount chained with dynamic edges — the feature was
  previously unexercised outside the test suite.
- Golden vectors computed outside the crate (`tests/golden_vectors.rs`):
  five poses derived with SciPy and asserted against literal digits — a
  quarter turn of yaw plus an offset applied to a known point, a two-hop
  chain in both directions, a rotation about no coordinate axis, and an
  interpolated lookup against a reference slerp. Every other test builds
  its expectation with the same conventions as the code under test, so a
  convention flipped consistently — a transposed rotation, a swapped
  quaternion product — passed all of them.
- Property-based test suite (proptest) covering the core invariants;
  fully deterministic test fixtures and non-mutating benchmarks; panic
  policy enforced with clippy restriction lints and documented in the
  crate-level Reliability section. All public types are `Send + Sync`,
  documented and compile-asserted.
- Behavioral pin tests for commitments that freeze at stable: duplicate-
  timestamp upserts, `SameFrameMultiplication`, `max_age` boundary
  semantics (`Duration::ZERO`, inclusive boundary, out-of-order inserts),
  static transforms over custom clocks including range extremes,
  interior-point and near-antipodal slerp, `Point` error paths, mid-tree
  `remove_frame`, exact `NotFoundAt` payloads, and postcard golden-bytes
  tests for both `Stamp` arms freezing the serde wire format for
  non-self-describing formats (struct field order and `Stamp`'s variant
  order are part of the wire contract). Plus the tests a mutation run
  demanded: `inverse` renormalizing a rotation that drifted within
  tolerance, the exact acceptance boundary around `UNIT_NORM_TOLERANCE`,
  slerp's lerp/slerp switchover, `interpolate` returning the earlier
  endpoint when both endpoints share a stamp, the post-truncation chain
  lengths of a deep common trunk, and error formatting under a clock
  whose instants cannot be expressed as seconds.
- Benchmarks for the shapes real users hit: a realistic 6-edge robot tree
  (mixed static/dynamic), a 3-hop interpolating dynamic chain,
  `get_transform_at`, a 100k-resident insert bench pinning the eviction fix
  at depth, a 4-hop chain measured in both lookup directions, and a
  lookup past the newest sample — the failure a consumer running ahead of
  its publisher hits every tick. Dynamic samples rotate about a
  non-axis-aligned axis, so interpolation runs slerp's sin-weighted
  branch — the dominant float cost on a soft-float target.
- CI runs the full verification gate — `tests/test_all.sh` verbatim in a
  `gate` job, making the script the single source of truth: builds,
  tests, clippy and rustdoc at `-D warnings` across all four feature
  combinations, formatting, examples, bench smoke runs, and bare-metal
  builds for `thumbv7em-none-eabihf`, `thumbv6m-none-eabi` and
  `thumbv8m.main-none-eabihf` (CI adds `riscv32imc-unknown-none-elf`,
  a native ARM64 test job, an MSRV check on 1.86, and `cargo audit`).
- `Cargo.lock` is committed, so every checkout resolves the same dependency
  versions. It also ships in the published tarball — `cargo package` includes
  it — where it governs builds of this crate itself and never a downstream
  crate's own resolution.

### Fixed

- `get_transform` verifies that the resolved chain actually connects the two
  requested frames; querying an unknown frame previously returned the
  transform to the tree root instead of an error.
- Static and dynamic transforms can no longer mix within one child
  frame, which previously corrupted interpolation or silently shadowed
  data: in 1.x the most recent insert decided how the whole buffer was
  read, so one query could answer three different ways across three
  inserts. Mixing is rejected with `StaticDynamicConflict`; chaining
  static and dynamic *frames* is unaffected.
- `t = 0` is usable as an ordinary dynamic timestamp: the most natural
  insert loop there is (`for i in 0.. { insert(tf(i * step)) }`) no
  longer turns its first sample into an eternal static transform — in
  1.x the zero sentinel made insert `i = 0` static and served it at
  every instant, each later insert re-deciding the buffer's kind.
  Pinned by a regression test, and the property-test timestamp strategy
  includes zero.
- `Transform` multiplication only accepts valid compositions
  (`t_a_b * t_b_c`); the reversed operand order produced a frame-inconsistent
  result.
- Manual cleanup (`remove_transforms_before`) no longer destroys static
  transforms — a static transform is valid for all time.
- `Registry::remove_transforms_before` resets each frame's `max_age` expiry
  reference along with its samples: previously a wiped buffer kept its
  pre-wipe latest timestamp, so a restarted stream at earlier times was
  evicted by the very insert that added it — the insert reported no
  error and the frame stayed empty.
- `get_transform_at` resolves when `source` equals the fixed frame
  (including all three frames equal) instead of always failing with
  `SameFrameMultiplication`; coinciding-frame legs are short-circuited
  rather than composed with a self-referential identity.
- Error diagnostics survive wall-clock timestamps: messages are formatted via
  the infallible `TimePoint::as_seconds_lossy`, so a conversion error can no
  longer mask the error being reported.
- `no_std` works on real bare-metal targets: float math goes through
  `libm` and dependencies no longer pull in `std`. A heap allocator
  (`alloc`) is required.
- Docs: `Registry::new` states what its lack of a `max_age` costs — not
  only unbounded retention, but an unbounded interpolation gap, since a
  lookup between samples recorded either side of a publisher stall
  interpolates straight across it. `Registry::with_max_age` bounds both,
  and the `Default` impl points at the same explanation.
- Docs: duplicate-timestamp inserts are documented as last-write-wins
  upserts; `remove_frame` documents that it strands descendants of a
  mid-tree frame; interpolation is documented to span interior gaps of any
  size (bounding freshness is the caller's job); the O(log n) lookup claim is
  qualified (per-frame; linear in chain depth; O(frames) failure
  diagnosis); the `approx` 0.5 public-API commitment is recorded;
  allocation-failure behavior and the deterministic-hasher trade-off are
  stated for `no_std`.
- Docs: the scalar type is a commitment, not an accident. f32 and
  mixed-precision arithmetic are Non-Goals (README and crate root, one
  identical list), and the README publishes the envelope that commitment
  implies: measured per-operation cost and allocation counts on x86-64,
  about 320 B of resident heap per stored sample while both frame names are
  32 characters or shorter, and a platform × rate × chain-depth table that
  says which workloads fit an MCU and which run out of SRAM first.
- Docs: `Quaternion::normalize` documents the threshold it actually applies
  — a norm below `f64::EPSILON` — instead of a figure that was wrong by
  eight orders of magnitude in the permissive direction; a doctest pins
  both sides of the boundary.
- Docs: `Transformable` states the map an implementation owes — rotate,
  then translate; orientation composed with the transform's rotation on
  the left; a free vector takes the rotation only — and a `Point` test
  pins the left composition against hand-derived digits from a
  non-commuting starting orientation.
- Docs: the README concurrency snippet uses `RwLock`, matching the `&self`
  read design it exists to demonstrate, and points at `examples/std_full.rs`
  as the compiled version. That example publishes each sample at the
  instant the sample describes and reads at a stamp its publisher already
  covers, which is what no extrapolation requires of a reader.
- Docs: serde feature-gating is stated on every serde-capable type
  (rustdoc cannot banner derive-generated impls — verified against the
  docs.rs configuration, which the gate builds).

## [1.4.1] - 2026-03-20

- Dependency updates.

## [1.4.0] - 2026-03-12

- `get_transform`, `get_transform_for`, and `get_transform_at` take `&self`
  instead of `&mut self`, enabling concurrent reads.

## [1.3.0] - 2026-03-11

- Added the `Localized` trait and `get_transform_for`, resolving a transform
  directly from a value's frame and timestamp.

## [1.2.0] - 2026-03-03

- Core types generic over time via the `TimePoint` trait;
  `std::time::SystemTime` supported out of the box.
- Added `get_transform_at` ("time travel"): query source and target frames at
  different times through a fixed frame.

## [1.1.1] - 2026-01-22

- Republish of 1.1.0 with no code changes. Exists on crates.io only; there
  is no corresponding git tag.

## [1.1.0] - 2026-01-22

- Fixed static (`t=0`) and dynamic transforms not coexisting in the same
  tree; buffer expiration uses the latest inserted timestamp instead of
  wall-clock time.

## [1.0.3] - 2025-12-06

- Documentation updates.

## [1.0.2] - 2025-12-02

- Dependency updates. Tagged in git but never published to crates.io.

## [1.0.1] - 2025-07-28

- Dependency updates.

## [1.0.0] - 2025-07-24

- First stable release: `no_std` support, transform chaining, SLERP
  interpolation, `Transformable` trait, automatic buffer cleanup.

[2.1.1]: https://github.com/deniz-hofmeister/transforms/compare/v2.1.0...v2.1.1
[2.1.0]: https://github.com/deniz-hofmeister/transforms/compare/v2.0.0...v2.1.0
[2.0.0]: https://github.com/deniz-hofmeister/transforms/compare/v1.4.1...v2.0.0
[1.4.1]: https://github.com/deniz-hofmeister/transforms/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/deniz-hofmeister/transforms/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/deniz-hofmeister/transforms/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/deniz-hofmeister/transforms/compare/v1.1.0...v1.2.0
[1.1.1]: https://crates.io/crates/transforms/1.1.1
[1.1.0]: https://github.com/deniz-hofmeister/transforms/compare/v1.0.3...v1.1.0
[1.0.3]: https://github.com/deniz-hofmeister/transforms/compare/v1.0.2...v1.0.3
[1.0.2]: https://github.com/deniz-hofmeister/transforms/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/deniz-hofmeister/transforms/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/deniz-hofmeister/transforms/releases/tag/v1.0.0