tf_tree 0.0.1

std facade for the tf_tree transform engine: ergonomic builder, lookups, and Display errors.
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
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
//! Batch-sampling tests: `at_many` (monotone galloping + non-monotone fallback)
//! and `at_adaptive` (knot-count bound + reconstruction within tolerance).
#![allow(clippy::unwrap_used, clippy::expect_used)]

mod common;

use common::{max_err, ns, Chain};

use tf_tree::{AdaptiveScratch, ErrBound, Iso3, LerpSlerp, Stamp, SystemDomain, MAX_KNOTS};

/// `at_many` over a monotone stamp sweep equals calling `at` per stamp, and the
/// long sweep exercises the galloping (resume-from-cursor) path.
///
/// **The reference side takes a fresh `Guard` per stamp, and that is now
/// load-bearing.** `Guard` carries a per-step bracket-search cursor, so
/// `plan.at` on a *reused* guard resumes from the previous answer exactly as
/// `at_many` does — comparing the two on one guard would compare galloping
/// against galloping and assert nothing about the binary search. A guard built
/// per stamp starts every cursor cold, which is the independent search this test
/// means by "binary".
#[test]
fn at_many_monotone_matches_per_stamp() {
    let c = Chain::new(64, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let max_t = (c.n as i64 - 1) * c.dt;

    // ~700 interpolated stamps, strictly increasing — far more than log(n), so the
    // galloping resume is the dominant path.
    let stamps: Vec<Stamp> = (0..700).map(|k| ns((k as i64 * max_t) / 700)).collect();

    let mut out = vec![Iso3::IDENTITY; stamps.len()];
    plan.at_many(&g, &stamps, &mut out).unwrap();

    for (s, got) in stamps.iter().zip(out.iter()) {
        let want = plan.at(&c.tree.guard(), *s).unwrap();
        assert_eq!(
            got.to_bits(),
            want.to_bits(),
            "galloping vs binary at {s:?}"
        );
    }
}

/// A warm cursor never changes an answer.
///
/// `Guard` caches a per-step bracket-search hint so a scalar `Plan::at` resumes
/// beside the previous answer instead of restarting at the window midpoint —
/// worth ~9% at depth 3 (`docs/design/fast-path.md` §16). The entire safety
/// argument for that cache is that
/// [`SampleRing::sample_from`] returns exactly what `sample` returns and only
/// the *search path* differs, so a stale, wrong or absent hint costs time and
/// never accuracy. Nothing else in the type system enforces that, so this does.
///
/// Three shapes, because the cursor is in a different state in each:
///
/// * **monotone forward** — the case the cache is for, and the one where the
///   hint is always warm and always close;
/// * **non-monotone** — the hint points past the answer, so the gallop must walk
///   *backwards* and still land exactly;
/// * **two plans interleaved on one guard** — step `k` alternates between two
///   different edges, so the tag check is exercised on every call.
///
/// **Mutants, all three applied and run** — two of them survive, and saying so
/// is the point:
///
/// * `let i = lo` instead of `self.bracket(lo, hi, t)` in `sample_from` — the
///   gallop's lower bound used as the answer. **Caught**, and this test is the
///   only one of the eight that catches it.
/// * dropping the tag check in `Guard::sample_hinted`, so one plan's cursor is
///   used as another's hint. **Survives**, correctly: the gallop corrects a
///   wrong hint, so the tag is a *performance* guard and no correctness test can
///   or should kill it.
/// * dropping `clamp(lo_logical, newest)` on the hint in `sample_from`.
///   **Survives** — a cursor is only ever written after a successful sample, so
///   it is already inside the window and the clamp is defence against a state
///   this path cannot reach.
#[test]
fn a_warm_cursor_never_changes_an_answer() {
    let c = Chain::new(64, 1000);
    let max_t = (c.n as i64 - 1) * c.dt;
    let base_map = c.tree.plan(c.base, c.map).unwrap();
    let odom_map = c.tree.plan(c.odom, c.map).unwrap();

    // Cold reference: a fresh guard per lookup, so every cursor starts at 0 and
    // every search is an independent binary search.
    let cold = |plan: &tf_tree::Plan, s: Stamp| plan.at(&c.tree.guard(), s).unwrap();

    let monotone: Vec<Stamp> = (0..500).map(|k| ns((k as i64 * max_t) / 500)).collect();
    // Deterministic jumps around the window: forwards, backwards, and repeats.
    let scattered: Vec<Stamp> = (0..500)
        .map(|k: i64| ns(((k * 7919) % (max_t / 1000)) * 1000))
        .collect();

    for (label, stamps) in [("monotone", &monotone), ("scattered", &scattered)] {
        let warm = c.tree.guard();
        for s in stamps {
            assert_eq!(
                base_map.at(&warm, *s).unwrap().to_bits(),
                cold(&base_map, *s).to_bits(),
                "{label}: warm cursor disagreed with a cold search at {s:?}"
            );
        }
    }

    // Two plans on one guard: `base->map` has two dynamic steps and `odom->map`
    // has one, so step 0 alternates between two different edges and the tag
    // check decides on every call whether the hint is usable.
    let shared = c.tree.guard();
    for (i, s) in monotone.iter().enumerate() {
        let plan = if i % 2 == 0 { &base_map } else { &odom_map };
        assert_eq!(
            plan.at(&shared, *s).unwrap().to_bits(),
            cold(plan, *s).to_bits(),
            "interleaved plans: warm cursor disagreed at {s:?}"
        );
    }
}

/// Non-monotone input falls back to an independent search per stamp, still
/// matching `at`.
#[test]
fn at_many_nonmonotone_matches_per_stamp() {
    let c = Chain::new(64, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let max_t = (c.n as i64 - 1) * c.dt;

    // Deterministic non-monotone order.
    let stamps: Vec<Stamp> = [0.37, 0.9, 0.1, 0.55, 0.05, 0.99, 0.42, 0.7, 0.2, 0.8]
        .iter()
        .map(|f| ns((f * max_t as f64) as i64))
        .collect();

    let mut out = vec![Iso3::IDENTITY; stamps.len()];
    plan.at_many(&g, &stamps, &mut out).unwrap();
    for (s, got) in stamps.iter().zip(out.iter()) {
        let want = plan.at(&g, *s).unwrap();
        assert_eq!(got.to_bits(), want.to_bits(), "fallback vs binary at {s:?}");
    }
}

/// `at_adaptive` emits a bounded knot set whose LerpSlerp reconstruction stays
/// within tolerance across a curved trajectory.
///
/// # What was mutated
///
/// Widening the *request* to `ErrBound::new(1e9, 1e9)` ⇒ FAIL,
/// `reconstruction err 1.5658096717891843e-2 > 2e-3 at q=157`. The same edit was
/// then run against the pre-fix shape — `RECON_TOL` restored to
/// `tol.rot_rad.max(tol.trans) * 2.0` — and this binary reported **11 tests run:
/// 11 passed**, because the bound moved with the request. That is the defect,
/// measured on both sides rather than asserted.
#[test]
fn at_adaptive_bounded_and_within_tol() {
    let c = Chain::new(64, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let max_t = (c.n as i64 - 1) * c.dt;

    let tol = ErrBound::new(1e-3, 1e-3);
    let mut scratch = AdaptiveScratch::<SystemDomain>::new();
    let (stamps, poses) = plan
        .at_adaptive(&g, (ns(0), ns(max_t)), tol, &mut scratch)
        .unwrap();

    assert!(stamps.len() >= 2, "expected at least two knots");
    assert!(
        stamps.len() <= MAX_KNOTS,
        "knot count {} exceeds cap {MAX_KNOTS}",
        stamps.len()
    );
    // Knots are strictly increasing.
    for w in stamps.windows(2) {
        assert!(w[0].nanos() < w[1].nanos(), "knots not increasing");
    }

    // Reconstruct at 400 probe stamps by LerpSlerp between bracketing knots and
    // compare to the exact plan evaluation. The bisection bounds midpoint error;
    // `2 x` the requested tolerance allows for off-midpoint probes.
    //
    // **A literal, and that is the fix rather than the style.** This was
    // `tol.rot_rad.max(tol.trans) * 2.0` — derived from the very value under
    // test, so it moved with it and no request could ever be judged too loose.
    // The doc comment above records both halves of the measurement.
    const RECON_TOL: f64 = 2e-3;
    for k in 0..=400 {
        let q = (k as i64 * max_t) / 400;
        // Find bracket [i, j=i+1] with stamps[i] <= q < stamps[j] (or the last).
        let mut i = 0usize;
        while i + 1 < stamps.len() && stamps[i + 1].nanos() <= q {
            i += 1;
        }
        let j = (i + 1).min(stamps.len() - 1);
        let a_s = stamps[i].nanos();
        let b_s = stamps[j].nanos();
        let s = if b_s > a_s {
            (q - a_s) as f64 / (b_s - a_s) as f64
        } else {
            0.0
        };
        let approx = <LerpSlerp as tf_tree::Interp>::eval(&poses[i], &poses[j], s);
        let exact = plan.at(&g, ns(q)).unwrap();
        let e = max_err(approx, exact);
        assert!(
            e <= RECON_TOL,
            "reconstruction err {e:e} > {RECON_TOL:e} at q={q}"
        );
    }
}

/// A zero tolerance forces maximal subdivision but the knot count stays capped.
#[test]
fn at_adaptive_zero_tol_hits_cap() {
    let c = Chain::new(64, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let max_t = (c.n as i64 - 1) * c.dt;

    let tol = ErrBound::new(0.0, 0.0);
    let mut scratch = AdaptiveScratch::<SystemDomain>::new();
    let (stamps, _poses) = plan
        .at_adaptive(&g, (ns(0), ns(max_t)), tol, &mut scratch)
        .unwrap();
    assert!(
        stamps.len() <= MAX_KNOTS,
        "knot count {} must stay within cap {MAX_KNOTS}",
        stamps.len()
    );
}

// ---------------------------------------------------------------------------
// Layout kernels (`docs/decisions/0005` Milestone B, `docs/PHASE3.md` §5.2)
// ---------------------------------------------------------------------------

/// **The kernels must agree with `at_many`, bit for bit.**
///
/// `at_many_into` exists to avoid an intermediate `Iso3` buffer, not to compute
/// anything different. Any divergence would be a second implementation of the
/// interpolation drifting from the first — so this compares the emitted
/// elements against the ones derived from `at_many`'s output, exactly.
#[test]
fn at_many_into_agrees_with_at_many_exactly() {
    use tf_tree::Layout;

    let c = Chain::new(64, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let max_t = (c.n as i64 - 1) * c.dt;
    let stamps: Vec<Stamp> = (0..300).map(|k| ns((k as i64 * max_t) / 300)).collect();

    let mut reference = vec![Iso3::IDENTITY; stamps.len()];
    plan.at_many(&g, &stamps, &mut reference).unwrap();

    // Quat: the engine's own order, so equality is exact and unarguable.
    let mut quat = vec![0.0f64; stamps.len() * Layout::Quat.elems()];
    plan.at_many_into::<SystemDomain>(&g, &nanos(&stamps), Layout::Quat, &mut quat)
        .unwrap();
    for (i, iso) in reference.iter().enumerate() {
        let row = &quat[i * 7..(i + 1) * 7];
        assert_eq!(row[0].to_bits(), iso.q.w.to_bits(), "row {i} qw");
        assert_eq!(row[1].to_bits(), iso.q.x.to_bits(), "row {i} qx");
        assert_eq!(row[2].to_bits(), iso.q.y.to_bits(), "row {i} qy");
        assert_eq!(row[3].to_bits(), iso.q.z.to_bits(), "row {i} qz");
        assert_eq!(row[4].to_bits(), iso.t.x.to_bits(), "row {i} tx");
        assert_eq!(row[5].to_bits(), iso.t.y.to_bits(), "row {i} ty");
        assert_eq!(row[6].to_bits(), iso.t.z.to_bits(), "row {i} tz");
    }

    // Mat4: translation column is exact; the rotation block is checked by its
    // action in `tf_tree_core::layout`'s own tests.
    let mut mat = vec![0.0f64; stamps.len() * Layout::Mat4.elems()];
    plan.at_many_into::<SystemDomain>(&g, &nanos(&stamps), Layout::Mat4, &mut mat)
        .unwrap();
    for (i, iso) in reference.iter().enumerate() {
        let m = &mat[i * 16..(i + 1) * 16];
        assert_eq!(m[3].to_bits(), iso.t.x.to_bits(), "row {i} tx");
        assert_eq!(m[7].to_bits(), iso.t.y.to_bits(), "row {i} ty");
        assert_eq!(m[11].to_bits(), iso.t.z.to_bits(), "row {i} tz");
        assert_eq!(&m[12..16], &[0.0, 0.0, 0.0, 1.0], "row {i} bottom");
    }
}

/// **`Layout::QuatTwist` is `at_with_derivatives` in a buffer, not a second
/// implementation of it** — `docs/API.md` §3.3, `docs/PHASE5.md` §4.4.
///
/// The layout exists so derivatives reach a batch caller without a fourth
/// method. That is only true if the thirteen `f64` it writes are the *same
/// bits* the scalar call produces: the moment the batch path derives a twist its
/// own way — a finite difference, a re-composed adjoint chain — two bindings can
/// disagree about a velocity, and neither one is obviously wrong to a user.
/// Compared with `to_bits`, not a tolerance, for exactly that reason.
///
/// The first seven elements are also checked against `Layout::Quat` on the same
/// stamps, which is the other half of the promise: a consumer that already
/// parses a `(N, 7)` row can read a `(N, 13)` one by ignoring the tail.
///
/// **The stamps ascend, so the batch takes the monotone cursor branch while the
/// scalar reference does not** — each `at_with_derivatives` below is on a fresh
/// guard and restarts every bracket search at the window midpoint. So this is
/// also the plan-level assertion that resuming a search cannot move a bit of a
/// twist, which is what makes the cursor safe to pick from the *stamps* rather
/// than from anything the caller asked for.
///
/// Mutant: emit `v` before `ω` in `write_quat_twist` ⇒ the tail assertions fail
/// while the pose ones still pass. Mutant B: route `Layout::QuatTwist` through
/// `fold_batch(.., write_quat, ..)` and zero the tail ⇒ the pose half still
/// agrees and only the twist assertions catch it.
#[test]
fn quat_twist_rows_are_bit_identical_to_at_with_derivatives() {
    use tf_tree::Layout;

    let c = Chain::new(64, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let max_t = (c.n as i64 - 1) * c.dt;
    // Off-grid stamps, so the interpolant and its derivative both actually run.
    let stamps: Vec<Stamp> = (0..97).map(|k| ns((k as i64 * max_t) / 97 + 37)).collect();

    let mut rows = vec![0.0f64; stamps.len() * Layout::QuatTwist.elems()];
    plan.at_many_into::<SystemDomain>(&g, &nanos(&stamps), Layout::QuatTwist, &mut rows)
        .unwrap();

    // The pose half, against the layout it claims to extend.
    let mut quat = vec![0.0f64; stamps.len() * Layout::Quat.elems()];
    plan.at_many_into::<SystemDomain>(&g, &nanos(&stamps), Layout::Quat, &mut quat)
        .unwrap();

    let mut moving = 0usize;
    for (i, s) in stamps.iter().enumerate() {
        let row = &rows[i * 13..(i + 1) * 13];
        assert_eq!(
            &row[..7],
            &quat[i * 7..(i + 1) * 7],
            "row {i}: the pose half is not the Quat layout"
        );

        let want = plan.at_with_derivatives(&c.tree.guard(), *s).unwrap();
        for (k, bits) in [
            want.pose.q.w,
            want.pose.q.x,
            want.pose.q.y,
            want.pose.q.z,
            want.pose.t.x,
            want.pose.t.y,
            want.pose.t.z,
            want.twist.omega.x,
            want.twist.omega.y,
            want.twist.omega.z,
            want.twist.v.x,
            want.twist.v.y,
            want.twist.v.z,
        ]
        .into_iter()
        .enumerate()
        {
            assert_eq!(
                row[k].to_bits(),
                bits.to_bits(),
                "row {i} element {k}: the batch layout and the scalar call disagree"
            );
        }

        if want.twist.omega.norm() > 1e-6 && want.twist.v.norm() > 1e-6 {
            moving += 1;
        }
    }

    // Non-vacuity: a fixture whose twist is zero everywhere would pass every
    // assertion above against a layout that wrote six zeros.
    assert!(
        moving > 90,
        "the fixture is not moving; only {moving} of {} rows had a live twist",
        stamps.len()
    );
}

/// The 13-element buffer is sized and rejected like every other layout.
///
/// `elems()` is the single place the stride comes from, so the interesting
/// failure is not "13 is wrong" but "the check ran against a different number
/// than the write did" — which is why the error's `need` is asserted and not
/// merely that it failed.
///
/// Mutant, run: `Layout::QuatTwist => 7` in `elems()` ⇒ this test dies at
/// `layout.rs`'s `write_quat_twist` with "index out of bounds: the len is 7 but
/// the index is 7", **not** with a `BufferTooSmall` naming `need: 28`. That is
/// the point rather than a wrinkle: 51 ≥ 28, so a shrunk `elems()` makes the
/// size check *pass* and the write run off the end of the row it was sized for.
/// `quat_twist_rows_are_bit_identical_to_at_with_derivatives` dies the same way.
///
/// So the `need` assertion below is not what kills this mutant — the panic is.
/// It is here for the mutation in the other direction (`=> 26`, say), where the
/// check would refuse a buffer that is in fact long enough and no bounds check
/// would ever fire.
#[test]
fn a_short_quat_twist_buffer_is_refused_before_anything_is_written() {
    use tf_tree::{Layout, LookupError};

    let c = Chain::new(8, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let stamps: Vec<Stamp> = (0..4).map(|k| ns(k * 1000)).collect();

    const SENTINEL: f64 = -12345.5;
    let mut out = vec![SENTINEL; 4 * 13 - 1];
    assert_eq!(
        plan.at_many_into::<SystemDomain>(&g, &nanos(&stamps), Layout::QuatTwist, &mut out)
            .unwrap_err(),
        LookupError::BufferTooSmall { need: 52, got: 51 }
    );
    assert!(
        out.iter().all(|v| *v == SENTINEL),
        "the buffer was written before validation rejected the call"
    );

    // And it is an `f64` layout: the `f32` entry point must refuse it rather
    // than writing thirteen 4-byte elements where thirteen 8-byte ones go.
    let mut f32s = vec![0.0f32; 4 * 13];
    assert_eq!(
        plan.at_many_into_f32::<SystemDomain>(&g, &nanos(&stamps), Layout::QuatTwist, &mut f32s)
            .unwrap_err(),
        LookupError::WrongElementType
    );
}

/// The non-monotone fallback must produce the same answers as the cursor path.
///
/// Two loops, one shared kernel — but the *search* differs, and a cursor that
/// resumed wrongly on unsorted input would show up here and nowhere else.
#[test]
fn at_many_into_handles_unsorted_stamps() {
    use tf_tree::Layout;

    let c = Chain::new(32, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let max_t = (c.n as i64 - 1) * c.dt;

    let sorted: Vec<Stamp> = (0..64).map(|k| ns((k as i64 * max_t) / 64)).collect();
    let mut shuffled = sorted.clone();
    shuffled.reverse();

    let mut a = vec![0.0f64; sorted.len() * 7];
    let mut b = vec![0.0f64; sorted.len() * 7];
    plan.at_many_into::<SystemDomain>(&g, &nanos(&sorted), Layout::Quat, &mut a)
        .unwrap();
    plan.at_many_into::<SystemDomain>(&g, &nanos(&shuffled), Layout::Quat, &mut b)
        .unwrap();

    for (i, _) in sorted.iter().enumerate() {
        let j = sorted.len() - 1 - i;
        assert_eq!(
            &a[i * 7..(i + 1) * 7],
            &b[j * 7..(j + 1) * 7],
            "stamp {i} disagreed between the monotone and fallback paths"
        );
    }
}

/// The twist layout's two batch loops must agree, exactly as the pose layouts'
/// do.
///
/// `Layout::QuatTwist` gained the monotone cursor branch, so it now has the
/// same shape as `fold_batch`: ascending stamps gallop from a resumable cursor,
/// anything else restarts each search. Feeding the same stamps forward and
/// reversed puts one call down each branch, and the rows must come back
/// element-for-element identical after un-reversing.
///
/// **Reversed is not a second gallop direction.** An earlier revision of this
/// comment said the reversed order was what made `bracket_from`'s *downward*
/// arm run; that is false, and was checked rather than reasoned about. The
/// reversed call is non-monotone, so it takes the fallback arm, which calls
/// `bracket` and never `bracket_from` — no gallop runs in either direction.
/// Reversed is simply the cheapest input that is guaranteed non-monotone while
/// still being a permutation of the forward one, which is what lets the rows be
/// compared element for element. Injecting `panic!()` into `bracket_from`'s
/// downward arm leaves this test **passing** and fails `tf_tree_core`'s
/// `sample_from_agrees_with_sample_from_every_cursor` and
/// `sample_with_twist_from_agrees_with_sample_with_twist_from_every_cursor` —
/// that arm's coverage is there, in the `start in 0..21` sweep, and deleting it
/// would leave the arm untested whatever this test says.
///
/// What this *does* pin is the upward arm the monotone batch really uses, and
/// it pins it against an independent answer rather than against itself.
/// Mutant, run: in `bracket_from`'s upward arm, hand `bracket` a lower bound of
/// `hint + step` instead of `hint + step / 2` ⇒ fails, "stamp 0 element 0
/// disagreed between the cursor and fallback loops", `13824777323826317557`
/// against `...562`.
///
/// Mutant B, run: in `fold_batch_with_twist`, declare the `cursors` array
/// *inside* the loop so every stamp restarts cold ⇒ still passes, because a
/// cold cursor is a valid cursor. That is the shape of the limit here: the
/// cursor is a hint, so no assertion about the *values* can see whether it
/// advanced. The advance is pinned in `tf_tree_core`'s
/// `sample_with_twist_from_agrees_with_sample_with_twist_from_every_cursor`.
#[test]
fn quat_twist_agrees_between_the_cursor_and_fallback_batch_loops() {
    use tf_tree::Layout;

    let c = Chain::new(32, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let max_t = (c.n as i64 - 1) * c.dt;

    // Off-grid, so the interpolant and its derivative both actually run.
    let sorted: Vec<Stamp> = (0..64).map(|k| ns((k as i64 * max_t) / 64 + 37)).collect();
    let mut reversed = sorted.clone();
    reversed.reverse();

    let n = Layout::QuatTwist.elems();
    let mut a = vec![0.0f64; sorted.len() * n];
    let mut b = vec![0.0f64; sorted.len() * n];
    plan.at_many_into::<SystemDomain>(&g, &nanos(&sorted), Layout::QuatTwist, &mut a)
        .unwrap();
    plan.at_many_into::<SystemDomain>(&g, &nanos(&reversed), Layout::QuatTwist, &mut b)
        .unwrap();

    for i in 0..sorted.len() {
        let j = sorted.len() - 1 - i;
        for k in 0..n {
            assert_eq!(
                a[i * n + k].to_bits(),
                b[j * n + k].to_bits(),
                "stamp {i} element {k} disagreed between the cursor and fallback loops"
            );
        }
    }
    // Non-vacuity: the twist tail must be live, or this compares zeros.
    assert!(
        a.chunks_exact(n)
            .any(|r| r[7..].iter().any(|v| v.abs() > 1e-9)),
        "the fixture's twist is zero everywhere"
    );
}

/// Validation happens before a single element is written (`PHASE3.md` §5.3).
///
/// A half-written output is worse than none, because it looks like data: the
/// caller sees plausible transforms for the first k samples and garbage after,
/// with nothing marking the boundary.
#[test]
fn a_rejected_call_leaves_the_buffer_untouched() {
    use tf_tree::{Layout, LookupError};

    let c = Chain::new(8, 1000);
    let plan = c.tree.plan(c.base, c.map).unwrap();
    let g = c.tree.guard();
    let stamps: Vec<Stamp> = (0..4).map(|k| ns(k * 1000)).collect();

    const SENTINEL: f64 = -12345.5;
    let mut out = vec![SENTINEL; 4 * 7 - 1]; // one element short

    let err = plan
        .at_many_into::<SystemDomain>(&g, &nanos(&stamps), Layout::Quat, &mut out)
        .unwrap_err();
    assert_eq!(err, LookupError::BufferTooSmall { need: 28, got: 27 });
    assert!(
        out.iter().all(|v| *v == SENTINEL),
        "the buffer was written before validation rejected the call"
    );

    // And the f64/f32 entry points refuse each other's layouts rather than
    // writing a differently-sized element into the caller's memory.
    let mut big = vec![SENTINEL; 4 * 12];
    assert_eq!(
        plan.at_many_into::<SystemDomain>(&g, &nanos(&stamps), Layout::Affine32, &mut big)
            .unwrap_err(),
        LookupError::WrongElementType
    );
    assert!(big.iter().all(|v| *v == SENTINEL));

    let mut f32s = vec![0.0f32; 4 * 7];
    assert_eq!(
        plan.at_many_into_f32::<SystemDomain>(&g, &nanos(&stamps), Layout::Quat, &mut f32s)
            .unwrap_err(),
        LookupError::WrongElementType
    );
}

/// Raw nanoseconds from typed stamps, for the `*_into` entry points.
fn nanos(stamps: &[Stamp]) -> Vec<i64> {
    stamps.iter().map(|s| s.nanos()).collect()
}