Skip to main content

logicaffeine_proof/
interval_replay.rs

1//! Independent range-safety checker for analog netlist claims — the trust core of the analog
2//! lane's `RangeCertificate`, in the same spirit as this crate's PR/RUP checker: a small,
3//! dependency-free replay the certificate producer cannot influence.
4//!
5//! The compiler (one crate over) claims per-node voltage bounds for a mapped analog dataflow.
6//! This module re-derives those bounds from the declared input boxes by DIRECTED-OUTWARD-ROUNDED
7//! `f64` interval arithmetic — after every interval operation each endpoint is bumped one ULP
8//! outward (`next_down`/`next_up`), so the replayed interval SOUNDLY contains every exactly-
9//! computed value regardless of intermediate rounding — and checks, fail-closed:
10//!
11//!   1. **Honesty** — the replayed interval of every node is contained in the node's CLAIMED
12//!      interval (a claim tighter than reality is rejected);
13//!   2. **No clipping** — every claimed interval lies within the usable rail half-span;
14//!   3. **No starvation** — every node's claimed full scale clears the noise floor by the
15//!      demanded dynamic-range ratio (a signal whose whole range drowns in noise is a
16//!      gain-scheduling bug, not a small signal).
17//!
18//! Any NaN or infinity anywhere — inputs, weights, claims, replay — rejects the certificate.
19//! No solver, no bignum, no imports beyond `std`. Trust this file, not the producer.
20
21/// An operand of a replayed cell: a plane input, a prior cell's net, or a programmed constant.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub enum RSrc {
24    In(usize),
25    Val(usize),
26    Const(f64),
27}
28
29/// The neutral cell vocabulary the checker replays. Mirrors (but does not import) the analog
30/// lane's tile set; the producer converts its netlist into this form to be judged.
31#[derive(Clone, Debug, PartialEq)]
32pub enum RCell {
33    /// `v = Σ wᵢ·xᵢ` — weighted Kirchhoff sum.
34    Sum {
35        inputs: Vec<RSrc>,
36        weights: Vec<f64>,
37    },
38    /// `v = a·b` — four-quadrant multiplier.
39    Mul { a: RSrc, b: RSrc },
40    /// `v = k·a` — programmed constant gain.
41    Gain { a: RSrc, k: f64 },
42    /// `v = if c < 0 then t else f` — comparator-driven switch. The replay rule is the
43    /// decided branch when the condition's box clears zero, else the branch HULL (the
44    /// comparator's ambiguity is an accuracy question for the budget, not a range one).
45    Select { c: RSrc, t: RSrc, f: RSrc },
46    /// `v = eˣ` — monotone image box (translinear).
47    Exp { a: RSrc },
48    /// `v = ln x` — monotone; a box touching the nonpositive axis is REJECTED, fail-closed.
49    Log { a: RSrc },
50    /// `v = tanh x` — monotone.
51    Tanh { a: RSrc },
52    /// `v = √x` — monotone; a box touching zero is REJECTED (unbounded error slope).
53    Sqrt { a: RSrc },
54    /// `v = a / b` — the divisor's box must CLEAR zero or the claim is rejected.
55    Div { a: RSrc, b: RSrc },
56}
57
58/// The claim under judgment: declared math-domain input boxes, per-net power-of-two scales,
59/// claimed VOLT-domain per-node bounds, and the physical envelope they must respect.
60#[derive(Clone, Debug, PartialEq)]
61pub struct RangeClaim {
62    /// Declared math-domain input bounds, one per plane input.
63    pub input_lo: Vec<f64>,
64    pub input_hi: Vec<f64>,
65    /// Per-cell power-of-two scale exponent: `volt = 2^scale_log2 · math` (exact in f64).
66    pub scale_log2: Vec<i32>,
67    /// Claimed volt-domain bounds, one per cell.
68    pub node_lo_v: Vec<f64>,
69    pub node_hi_v: Vec<f64>,
70    /// The usable half-span around mid-rail in volts (headroom already applied by the producer;
71    /// this checker takes it as the hard wall).
72    pub rail_half_span_v: f64,
73    /// The noise floor in volts (`0.0` for an ideal target — disables check 3).
74    pub noise_floor_v: f64,
75    /// Required ratio of a node's full scale to the noise floor (dynamic-range demand).
76    pub min_full_scale_over_noise: f64,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq)]
80struct Iv {
81    lo: f64,
82    hi: f64,
83}
84
85impl Iv {
86    fn finite(&self) -> bool {
87        self.lo.is_finite() && self.hi.is_finite() && self.lo <= self.hi
88    }
89    /// One-ULP outward bump — the directed-rounding step that makes every op sound.
90    fn outward(self) -> Iv {
91        Iv {
92            lo: self.lo.next_down(),
93            hi: self.hi.next_up(),
94        }
95    }
96    fn add(self, o: Iv) -> Iv {
97        Iv {
98            lo: self.lo + o.lo,
99            hi: self.hi + o.hi,
100        }
101        .outward()
102    }
103    fn scale(self, w: f64) -> Iv {
104        let (a, b) = (w * self.lo, w * self.hi);
105        Iv {
106            lo: a.min(b),
107            hi: a.max(b),
108        }
109        .outward()
110    }
111    fn mul(self, o: Iv) -> Iv {
112        let ps = [
113            self.lo * o.lo,
114            self.lo * o.hi,
115            self.hi * o.lo,
116            self.hi * o.hi,
117        ];
118        let mut lo = ps[0];
119        let mut hi = ps[0];
120        for p in ps {
121            lo = lo.min(p);
122            hi = hi.max(p);
123        }
124        Iv { lo, hi }.outward()
125    }
126    fn subset_of(self, outer: Iv) -> bool {
127        outer.lo <= self.lo && self.hi <= outer.hi
128    }
129}
130
131/// Replay the claim and judge it. `true` iff every check passes; ANY malformation (arity
132/// mismatch, dangling reference, non-finite number, inverted interval) is `false` — fail-closed.
133pub fn check_range_claim(cells: &[RCell], claim: &RangeClaim) -> bool {
134    let n_in = claim.input_lo.len();
135    if claim.input_hi.len() != n_in
136        || claim.scale_log2.len() != cells.len()
137        || claim.node_lo_v.len() != cells.len()
138        || claim.node_hi_v.len() != cells.len()
139        || !claim.rail_half_span_v.is_finite()
140        || claim.rail_half_span_v <= 0.0
141        || !claim.noise_floor_v.is_finite()
142        || claim.noise_floor_v < 0.0
143        || !claim.min_full_scale_over_noise.is_finite()
144        || claim.min_full_scale_over_noise < 1.0
145    {
146        return false;
147    }
148
149    let inputs: Vec<Iv> = (0..n_in)
150        .map(|i| Iv {
151            lo: claim.input_lo[i],
152            hi: claim.input_hi[i],
153        })
154        .collect();
155    if inputs.iter().any(|iv| !iv.finite()) {
156        return false;
157    }
158
159    let src = |s: &RSrc, vals: &[Iv]| -> Option<Iv> {
160        match s {
161            RSrc::In(k) => inputs.get(*k).copied(),
162            RSrc::Val(v) => vals.get(*v).copied(),
163            RSrc::Const(c) => c.is_finite().then_some(Iv { lo: *c, hi: *c }),
164        }
165    };
166
167    let mut vals: Vec<Iv> = Vec::with_capacity(cells.len());
168    for (i, cell) in cells.iter().enumerate() {
169        // Math-domain replay of this node.
170        let math = match cell {
171            RCell::Sum {
172                inputs: xs,
173                weights,
174            } => {
175                if xs.len() != weights.len()
176                    || xs.is_empty()
177                    || weights.iter().any(|w| !w.is_finite())
178                {
179                    return false;
180                }
181                let mut acc = Iv { lo: 0.0, hi: 0.0 };
182                for (x, w) in xs.iter().zip(weights) {
183                    let Some(v) = src(x, &vals) else { return false };
184                    acc = acc.add(v.scale(*w));
185                }
186                acc
187            }
188            RCell::Mul { a, b } => {
189                let (Some(a), Some(b)) = (src(a, &vals), src(b, &vals)) else {
190                    return false;
191                };
192                a.mul(b)
193            }
194            RCell::Gain { a, k } => {
195                if !k.is_finite() {
196                    return false;
197                }
198                let Some(a) = src(a, &vals) else { return false };
199                a.scale(*k)
200            }
201            RCell::Select { c, t, f } => {
202                let (Some(c), Some(t), Some(f)) = (src(c, &vals), src(t, &vals), src(f, &vals))
203                else {
204                    return false;
205                };
206                if c.hi < 0.0 {
207                    t
208                } else if c.lo >= 0.0 {
209                    f
210                } else {
211                    Iv {
212                        lo: t.lo.min(f.lo),
213                        hi: t.hi.max(f.hi),
214                    }
215                    .outward()
216                }
217            }
218            RCell::Exp { a } => {
219                let Some(a) = src(a, &vals) else { return false };
220                Iv {
221                    lo: a.lo.exp(),
222                    hi: a.hi.exp(),
223                }
224                .outward()
225            }
226            RCell::Log { a } => {
227                let Some(a) = src(a, &vals) else { return false };
228                if a.lo <= 0.0 {
229                    return false;
230                }
231                Iv {
232                    lo: a.lo.ln(),
233                    hi: a.hi.ln(),
234                }
235                .outward()
236            }
237            RCell::Tanh { a } => {
238                let Some(a) = src(a, &vals) else { return false };
239                Iv {
240                    lo: a.lo.tanh(),
241                    hi: a.hi.tanh(),
242                }
243                .outward()
244            }
245            RCell::Sqrt { a } => {
246                let Some(a) = src(a, &vals) else { return false };
247                if a.lo <= 0.0 {
248                    return false;
249                }
250                Iv {
251                    lo: a.lo.sqrt(),
252                    hi: a.hi.sqrt(),
253                }
254                .outward()
255            }
256            RCell::Div { a, b } => {
257                let (Some(a), Some(b)) = (src(a, &vals), src(b, &vals)) else {
258                    return false;
259                };
260                if b.lo <= 0.0 && b.hi >= 0.0 {
261                    return false;
262                }
263                let ps = [a.lo / b.lo, a.lo / b.hi, a.hi / b.lo, a.hi / b.hi];
264                let mut lo = ps[0];
265                let mut hi = ps[0];
266                for p in ps {
267                    lo = lo.min(p);
268                    hi = hi.max(p);
269                }
270                Iv { lo, hi }.outward()
271            }
272        };
273        if !math.finite() {
274            return false;
275        }
276
277        // Volt domain: multiply by the exact power-of-two scale.
278        let s = claim.scale_log2[i];
279        if !(-1000..=1000).contains(&s) {
280            return false;
281        }
282        let volt = math.scale(f64::powi(2.0, s));
283        if !volt.finite() {
284            return false;
285        }
286
287        let claimed = Iv {
288            lo: claim.node_lo_v[i],
289            hi: claim.node_hi_v[i],
290        };
291        if !claimed.finite() {
292            return false;
293        }
294        // 1. Honesty: the replayed bounds fit inside the claim.
295        if !volt.subset_of(claimed) {
296            return false;
297        }
298        // 2. No clipping: the claim fits inside the usable rail half-span.
299        let wall = Iv {
300            lo: -claim.rail_half_span_v,
301            hi: claim.rail_half_span_v,
302        };
303        if !claimed.subset_of(wall) {
304            return false;
305        }
306        // 3. No starvation: the claimed full scale clears the noise floor.
307        if claim.noise_floor_v > 0.0 {
308            let full_scale = claimed.lo.abs().max(claimed.hi.abs());
309            if full_scale < claim.noise_floor_v * claim.min_full_scale_over_noise {
310                return false;
311            }
312        }
313
314        vals.push(math);
315    }
316    true
317}
318
319/// Replay a STREAMED-REDUCTION claim: the same cells run once per frame with input 0 the
320/// element box and input 1 the carried accumulator (starting at `init`); the claim's per-node
321/// bounds must contain the HULL of every frame's replayed bounds, the claim's input-1 box must
322/// contain the whole accumulator trajectory, and every claimed bound must respect the rails and
323/// the noise floor. Fail-closed on any malformation, divergence, or a frame count of zero.
324pub fn check_reduction_claim(
325    cells: &[RCell],
326    out: RSrc,
327    init: f64,
328    frames: usize,
329    claim: &RangeClaim,
330) -> bool {
331    if frames == 0 || !init.is_finite() || claim.input_lo.len() != 2 || claim.input_hi.len() != 2 {
332        return false;
333    }
334    // Static shape/rail/noise judgment via the DAG checker on the FIRST frame's inputs is not
335    // sufficient — iterate, hull, then judge the hull against the claim.
336    let elem = Iv {
337        lo: claim.input_lo[0],
338        hi: claim.input_hi[0],
339    };
340    let acc_claim = Iv {
341        lo: claim.input_lo[1],
342        hi: claim.input_hi[1],
343    };
344    if !elem.finite() || !acc_claim.finite() {
345        return false;
346    }
347    let mut acc = Iv { lo: init, hi: init };
348    let mut hulls: Vec<Iv> = vec![
349        Iv {
350            lo: f64::INFINITY,
351            hi: f64::NEG_INFINITY
352        };
353        cells.len()
354    ];
355    for _ in 0..frames {
356        let inputs = [elem, acc];
357        let src = |s: &RSrc, vals: &[Iv]| -> Option<Iv> {
358            match s {
359                RSrc::In(k) => inputs.get(*k).copied(),
360                RSrc::Val(v) => vals.get(*v).copied(),
361                RSrc::Const(c) => c.is_finite().then_some(Iv { lo: *c, hi: *c }),
362            }
363        };
364        let mut vals: Vec<Iv> = Vec::with_capacity(cells.len());
365        for cell in cells {
366            let v = match cell {
367                RCell::Sum {
368                    inputs: xs,
369                    weights,
370                } => {
371                    if xs.len() != weights.len()
372                        || xs.is_empty()
373                        || weights.iter().any(|w| !w.is_finite())
374                    {
375                        return false;
376                    }
377                    let mut a = Iv { lo: 0.0, hi: 0.0 };
378                    for (x, w) in xs.iter().zip(weights) {
379                        let Some(iv) = src(x, &vals) else {
380                            return false;
381                        };
382                        a = a.add(iv.scale(*w));
383                    }
384                    a
385                }
386                RCell::Mul { a, b } => {
387                    let (Some(a), Some(b)) = (src(a, &vals), src(b, &vals)) else {
388                        return false;
389                    };
390                    a.mul(b)
391                }
392                RCell::Gain { a, k } => {
393                    if !k.is_finite() {
394                        return false;
395                    }
396                    let Some(a) = src(a, &vals) else { return false };
397                    a.scale(*k)
398                }
399                RCell::Select { c, t, f } => {
400                    let (Some(c), Some(t), Some(f)) = (src(c, &vals), src(t, &vals), src(f, &vals))
401                    else {
402                        return false;
403                    };
404                    if c.hi < 0.0 {
405                        t
406                    } else if c.lo >= 0.0 {
407                        f
408                    } else {
409                        Iv {
410                            lo: t.lo.min(f.lo),
411                            hi: t.hi.max(f.hi),
412                        }
413                        .outward()
414                    }
415                }
416                RCell::Exp { a } => {
417                    let Some(a) = src(a, &vals) else { return false };
418                    Iv {
419                        lo: a.lo.exp(),
420                        hi: a.hi.exp(),
421                    }
422                    .outward()
423                }
424                RCell::Log { a } => {
425                    let Some(a) = src(a, &vals) else { return false };
426                    if a.lo <= 0.0 {
427                        return false;
428                    }
429                    Iv {
430                        lo: a.lo.ln(),
431                        hi: a.hi.ln(),
432                    }
433                    .outward()
434                }
435                RCell::Tanh { a } => {
436                    let Some(a) = src(a, &vals) else { return false };
437                    Iv {
438                        lo: a.lo.tanh(),
439                        hi: a.hi.tanh(),
440                    }
441                    .outward()
442                }
443                RCell::Sqrt { a } => {
444                    let Some(a) = src(a, &vals) else { return false };
445                    if a.lo <= 0.0 {
446                        return false;
447                    }
448                    Iv {
449                        lo: a.lo.sqrt(),
450                        hi: a.hi.sqrt(),
451                    }
452                    .outward()
453                }
454                RCell::Div { a, b } => {
455                    let (Some(a), Some(b)) = (src(a, &vals), src(b, &vals)) else {
456                        return false;
457                    };
458                    if b.lo <= 0.0 && b.hi >= 0.0 {
459                        return false;
460                    }
461                    let ps = [a.lo / b.lo, a.lo / b.hi, a.hi / b.lo, a.hi / b.hi];
462                    let mut lo = ps[0];
463                    let mut hi = ps[0];
464                    for p in ps {
465                        lo = lo.min(p);
466                        hi = hi.max(p);
467                    }
468                    Iv { lo, hi }.outward()
469                }
470            };
471            if !v.finite() {
472                return false;
473            }
474            vals.push(v);
475        }
476        for (h, v) in hulls.iter_mut().zip(&vals) {
477            h.lo = h.lo.min(v.lo);
478            h.hi = h.hi.max(v.hi);
479        }
480        let Some(next) = src(&out, &vals) else {
481            return false;
482        };
483        if !next.finite() {
484            return false;
485        }
486        acc = next;
487        // The whole trajectory must live inside the claimed accumulator box.
488        if !acc.subset_of(acc_claim) {
489            return false;
490        }
491    }
492    // Judge the hulls exactly as the DAG checker judges per-node bounds.
493    if hulls.len() != claim.node_lo_v.len()
494        || hulls.len() != claim.node_hi_v.len()
495        || hulls.len() != claim.scale_log2.len()
496    {
497        return false;
498    }
499    let wall = Iv {
500        lo: -claim.rail_half_span_v,
501        hi: claim.rail_half_span_v,
502    };
503    if !claim.rail_half_span_v.is_finite() || claim.rail_half_span_v <= 0.0 {
504        return false;
505    }
506    for (i, h) in hulls.iter().enumerate() {
507        let h = if h.lo > h.hi {
508            Iv { lo: 0.0, hi: 0.0 }
509        } else {
510            *h
511        };
512        let s = claim.scale_log2[i];
513        if !(-1000..=1000).contains(&s) {
514            return false;
515        }
516        let volt = h.scale(f64::powi(2.0, s));
517        let claimed = Iv {
518            lo: claim.node_lo_v[i],
519            hi: claim.node_hi_v[i],
520        };
521        if !volt.finite()
522            || !claimed.finite()
523            || !volt.subset_of(claimed)
524            || !claimed.subset_of(wall)
525        {
526            return false;
527        }
528        if claim.noise_floor_v > 0.0 {
529            let full_scale = claimed.lo.abs().max(claimed.hi.abs());
530            if full_scale < claim.noise_floor_v * claim.min_full_scale_over_noise {
531                return false;
532            }
533        }
534    }
535    true
536}
537
538/// Replay a MULTI-STATE INTEGRATOR claim: the cells run once per frame over the held state
539/// vector (starting inside the `init` boxes); `outs[s]` gives state `s`'s next value. The
540/// claim's input boxes must contain every frame's state vector (the trajectory hull), and the
541/// per-node claims must contain the per-frame hulls, inside the rails / above the noise floor.
542pub fn check_integrator_claim(
543    cells: &[RCell],
544    outs: &[RSrc],
545    init_lo: &[f64],
546    init_hi: &[f64],
547    frames: usize,
548    claim: &RangeClaim,
549) -> bool {
550    let n_state = outs.len();
551    if frames == 0
552        || n_state == 0
553        || init_lo.len() != n_state
554        || init_hi.len() != n_state
555        || claim.input_lo.len() != n_state
556        || claim.input_hi.len() != n_state
557    {
558        return false;
559    }
560    let mut state: Vec<Iv> = (0..n_state)
561        .map(|s| Iv {
562            lo: init_lo[s],
563            hi: init_hi[s],
564        })
565        .collect();
566    if state.iter().any(|iv| !iv.finite()) {
567        return false;
568    }
569    let state_claim: Vec<Iv> = (0..n_state)
570        .map(|s| Iv {
571            lo: claim.input_lo[s],
572            hi: claim.input_hi[s],
573        })
574        .collect();
575    if state_claim.iter().any(|iv| !iv.finite()) {
576        return false;
577    }
578    // The initial boxes themselves must sit inside the claimed hull.
579    if state
580        .iter()
581        .zip(&state_claim)
582        .any(|(s, c)| !s.subset_of(*c))
583    {
584        return false;
585    }
586    let mut hulls: Vec<Iv> = vec![
587        Iv {
588            lo: f64::INFINITY,
589            hi: f64::NEG_INFINITY
590        };
591        cells.len()
592    ];
593    for _ in 0..frames {
594        let src = |s: &RSrc, vals: &[Iv]| -> Option<Iv> {
595            match s {
596                RSrc::In(k) => state.get(*k).copied(),
597                RSrc::Val(v) => vals.get(*v).copied(),
598                RSrc::Const(c) => c.is_finite().then_some(Iv { lo: *c, hi: *c }),
599            }
600        };
601        let mut vals: Vec<Iv> = Vec::with_capacity(cells.len());
602        for cell in cells {
603            let v = match cell {
604                RCell::Sum {
605                    inputs: xs,
606                    weights,
607                } => {
608                    if xs.len() != weights.len()
609                        || xs.is_empty()
610                        || weights.iter().any(|w| !w.is_finite())
611                    {
612                        return false;
613                    }
614                    let mut a = Iv { lo: 0.0, hi: 0.0 };
615                    for (x, w) in xs.iter().zip(weights) {
616                        let Some(iv) = src(x, &vals) else {
617                            return false;
618                        };
619                        a = a.add(iv.scale(*w));
620                    }
621                    a
622                }
623                RCell::Mul { a, b } => {
624                    let (Some(a), Some(b)) = (src(a, &vals), src(b, &vals)) else {
625                        return false;
626                    };
627                    a.mul(b)
628                }
629                RCell::Gain { a, k } => {
630                    if !k.is_finite() {
631                        return false;
632                    }
633                    let Some(a) = src(a, &vals) else { return false };
634                    a.scale(*k)
635                }
636                RCell::Select { c, t, f } => {
637                    let (Some(c), Some(t), Some(f)) = (src(c, &vals), src(t, &vals), src(f, &vals))
638                    else {
639                        return false;
640                    };
641                    if c.hi < 0.0 {
642                        t
643                    } else if c.lo >= 0.0 {
644                        f
645                    } else {
646                        Iv {
647                            lo: t.lo.min(f.lo),
648                            hi: t.hi.max(f.hi),
649                        }
650                        .outward()
651                    }
652                }
653                RCell::Exp { a } => {
654                    let Some(a) = src(a, &vals) else { return false };
655                    Iv {
656                        lo: a.lo.exp(),
657                        hi: a.hi.exp(),
658                    }
659                    .outward()
660                }
661                RCell::Log { a } => {
662                    let Some(a) = src(a, &vals) else { return false };
663                    if a.lo <= 0.0 {
664                        return false;
665                    }
666                    Iv {
667                        lo: a.lo.ln(),
668                        hi: a.hi.ln(),
669                    }
670                    .outward()
671                }
672                RCell::Tanh { a } => {
673                    let Some(a) = src(a, &vals) else { return false };
674                    Iv {
675                        lo: a.lo.tanh(),
676                        hi: a.hi.tanh(),
677                    }
678                    .outward()
679                }
680                RCell::Sqrt { a } => {
681                    let Some(a) = src(a, &vals) else { return false };
682                    if a.lo <= 0.0 {
683                        return false;
684                    }
685                    Iv {
686                        lo: a.lo.sqrt(),
687                        hi: a.hi.sqrt(),
688                    }
689                    .outward()
690                }
691                RCell::Div { a, b } => {
692                    let (Some(a), Some(b)) = (src(a, &vals), src(b, &vals)) else {
693                        return false;
694                    };
695                    if b.lo <= 0.0 && b.hi >= 0.0 {
696                        return false;
697                    }
698                    let ps = [a.lo / b.lo, a.lo / b.hi, a.hi / b.lo, a.hi / b.hi];
699                    let mut lo = ps[0];
700                    let mut hi = ps[0];
701                    for p in ps {
702                        lo = lo.min(p);
703                        hi = hi.max(p);
704                    }
705                    Iv { lo, hi }.outward()
706                }
707            };
708            if !v.finite() {
709                return false;
710            }
711            vals.push(v);
712        }
713        for (h, v) in hulls.iter_mut().zip(&vals) {
714            h.lo = h.lo.min(v.lo);
715            h.hi = h.hi.max(v.hi);
716        }
717        let mut next = Vec::with_capacity(n_state);
718        for o in outs {
719            let Some(v) = src(o, &vals) else { return false };
720            if !v.finite() {
721                return false;
722            }
723            next.push(v);
724        }
725        state = next;
726        if state
727            .iter()
728            .zip(&state_claim)
729            .any(|(s, c)| !s.subset_of(*c))
730        {
731            return false; // the trajectory left the claimed hull
732        }
733    }
734    if hulls.len() != claim.node_lo_v.len()
735        || hulls.len() != claim.node_hi_v.len()
736        || hulls.len() != claim.scale_log2.len()
737    {
738        return false;
739    }
740    if !claim.rail_half_span_v.is_finite() || claim.rail_half_span_v <= 0.0 {
741        return false;
742    }
743    let wall = Iv {
744        lo: -claim.rail_half_span_v,
745        hi: claim.rail_half_span_v,
746    };
747    for (i, h) in hulls.iter().enumerate() {
748        let h = if h.lo > h.hi {
749            Iv { lo: 0.0, hi: 0.0 }
750        } else {
751            *h
752        };
753        let s = claim.scale_log2[i];
754        if !(-1000..=1000).contains(&s) {
755            return false;
756        }
757        let volt = h.scale(f64::powi(2.0, s));
758        let claimed = Iv {
759            lo: claim.node_lo_v[i],
760            hi: claim.node_hi_v[i],
761        };
762        if !volt.finite()
763            || !claimed.finite()
764            || !volt.subset_of(claimed)
765            || !claimed.subset_of(wall)
766        {
767            return false;
768        }
769        if claim.noise_floor_v > 0.0 {
770            let full_scale = claimed.lo.abs().max(claimed.hi.abs());
771            if full_scale < claim.noise_floor_v * claim.min_full_scale_over_noise {
772                return false;
773            }
774        }
775    }
776    true
777}
778
779#[cfg(test)]
780#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/interval_replay/tests.rs"]
781mod tests;