Skip to main content

logicaffeine_compile/codegen_ana/
error_budget.rs

1//! The error budget — ε as a COMPILER OUTPUT, never a hand-tuned test tolerance.
2//!
3//! Every physical error source contributes a bounded math-domain term; the budget composes them
4//! through the dataflow with directed-outward rounding (sound linear-sum, the standard
5//! first-order perturbation bound plus the exact `Mul` cross term) and publishes the itemized
6//! decomposition. `recheck()` re-derives the total from the terms and demands bit-equality —
7//! dropping or editing a term is detected, mirroring `PlacementCertificate::recheck`.
8//!
9//! Phase scope: the IDEALIZED-physical sources — DAC/ADC quantization (half-LSB at the block
10//! scale), the multiplier interaction cross-term, and an explicit f64-evaluator-rounding guard.
11//! Mismatch / noise / drift terms arrive with the non-ideality model, each itemized and sourced.
12
13use super::cell::{ASrc, AnalogCell, AnalogNetlist};
14use super::gain::{GainSchedule, MathBound, RangeCertificate};
15use super::nonideal::{leg_sigma, NonIdealityModel, KSIGMA};
16use super::place::AnalogPlacement;
17use super::target::AnalogTarget;
18
19/// A named physical origin of error. The decomposition is user-facing spec: a refusal or a
20/// budget report says exactly which arrow of the architecture costs what.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub enum ErrorSource {
23    /// Half-LSB input quantization of plane input `input` at `bits` resolution.
24    DacQuant { input: usize, bits: u8 },
25    /// Half-LSB output quantization at `bits` resolution.
26    AdcQuant { bits: u8 },
27    /// Programmed weights snapped to the unit-element grid — the deviation of the realized
28    /// configuration from the ideal weights, propagated to the output.
29    WeightQuant,
30    /// The Σ εa·εb second-order interaction across all `Mul` tiles.
31    MulInteraction,
32    /// f64 evaluator rounding guard: the behavioral tiers compute in f64, not reals.
33    EvalRounding,
34    /// Per-leg programmed-gain error (programming accuracy + the Pelgrom distance gradient over
35    /// the PLACED operand separation), at the k·σ envelope.
36    TileGain,
37    /// Per-tile output-referred offset at the k·σ envelope.
38    TileOffset,
39    /// Per-tile sampled-noise floor (kT/C class) at the k·σ envelope.
40    Noise,
41    /// Gain drift from the CERTIFIED thermal map's tile-to-operand temperature gradient
42    /// (`gain_tempco · |ΔT_cell − ΔT_operand|`), propagated like any gain error.
43    ThermalGain,
44    /// Output-referred offset drift from a tile's absolute rise (`offset_tempco · ΔT`).
45    ThermalOffset,
46    /// A comparator whose certified decision box straddles its ±k·σ input-offset band: the
47    /// switch may take EITHER branch, so the full branch-hull width is priced — honest
48    /// uncertainty, never hidden.
49    CompareBand,
50    /// The offset residual surviving the chop average — intra-pair drift over one chop
51    /// period (the frozen component cancels exactly by construction).
52    ChopResidual,
53}
54
55/// The CHOPPED physical budget: the schedule runs every frame twice around a `ChopSwap` and
56/// averages, so the frozen per-tile offset CANCELS EXACTLY — `TileOffset` is replaced by the
57/// modeled `ChopResidual` drift class, and the noise term is √2-averaged (two independent
58/// draws per result, bookkept outward). Everything else is the unchopped budget verbatim.
59pub fn error_budget_chopped(
60    netlist: &AnalogNetlist,
61    cert: &RangeCertificate,
62    schedule: &GainSchedule,
63    target: &AnalogTarget,
64    model: &NonIdealityModel,
65    placement: &AnalogPlacement,
66) -> OutputBudget {
67    let unchopped = error_budget_nonideal(netlist, cert, schedule, target, model, placement);
68    let bound_of = |src: &ASrc| -> MathBound {
69        match src {
70            ASrc::In(k) => cert.input_bounds[*k],
71            ASrc::Val(v) => cert.per_net[*v].math,
72            ASrc::Const(c) => MathBound { lo: *c, hi: *c },
73        }
74    };
75    let realized = AnalogNetlist {
76        n_in: schedule.quantized.n_in,
77        cells: schedule.realized_math_cells(),
78        out: schedule.quantized.out,
79    };
80    let s_of = |i: usize| f64::powi(2.0, schedule.per_net_scale_log2[i]);
81    let n = realized.cells.len();
82    let resid_d: Vec<f64> = (0..n)
83        .map(|i| (KSIGMA * model.chop_residual_v.0 / s_of(i)).next_up())
84        .collect();
85    let tr = propagate_injected(&realized, &bound_of, &resid_d);
86
87    let mut terms = Vec::with_capacity(unchopped.terms.len() + 1);
88    for t in &unchopped.terms {
89        match t.source {
90            // The frozen offset cancels exactly; its residual class replaces it below.
91            ErrorSource::TileOffset => {}
92            // Two independent draws averaged: σ/√2, rounded outward.
93            ErrorSource::Noise => terms.push(ErrorTerm {
94                eps_math: (t.eps_math / std::f64::consts::SQRT_2).next_up(),
95                source: ErrorSource::Noise,
96            }),
97            _ => terms.push(*t),
98        }
99    }
100    if tr > 0.0 {
101        terms.push(ErrorTerm {
102            eps_math: tr,
103            source: ErrorSource::ChopResidual,
104        });
105    }
106    let eps_math = outward_sum(terms.iter().map(|t| t.eps_math));
107    OutputBudget { eps_math, terms }
108}
109
110/// One itemized contribution, in the MATH domain at the region output.
111#[derive(Clone, Copy, Debug, PartialEq)]
112pub struct ErrorTerm {
113    pub eps_math: f64,
114    pub source: ErrorSource,
115}
116
117/// The per-output budget: `eps_math` is the outward-rounded sum of `terms`, pinned by `recheck`.
118#[derive(Clone, Debug, PartialEq)]
119pub struct OutputBudget {
120    pub eps_math: f64,
121    pub terms: Vec<ErrorTerm>,
122}
123
124impl OutputBudget {
125    /// Deterministic replay: the total must be EXACTLY the outward-rounded sum of the itemized
126    /// terms, every term finite and non-negative. A dropped or edited term changes the sum and
127    /// fails — the tamper probe below proves it.
128    pub fn recheck(&self) -> bool {
129        if self.terms.is_empty() || !self.eps_math.is_finite() {
130            return false;
131        }
132        if self
133            .terms
134            .iter()
135            .any(|t| !t.eps_math.is_finite() || t.eps_math < 0.0)
136        {
137            return false;
138        }
139        outward_sum(self.terms.iter().map(|t| t.eps_math)) == self.eps_math
140    }
141
142    /// Tamper probe: the budget with term `idx` removed (total left untouched) — `recheck` on
143    /// the result must fail unless the removed term was exactly absorbed by rounding.
144    #[must_use]
145    pub fn with_term_removed(&self, idx: usize) -> OutputBudget {
146        let mut terms = self.terms.clone();
147        terms.remove(idx);
148        OutputBudget {
149            eps_math: self.eps_math,
150            terms,
151        }
152    }
153}
154
155pub(crate) fn outward_sum(terms: impl Iterator<Item = f64>) -> f64 {
156    let mut acc = 0.0_f64;
157    for t in terms {
158        // A zero term contributes nothing — bumping it would manufacture a phantom subnormal ε.
159        if t > 0.0 {
160            acc = (acc + t).next_up();
161        }
162    }
163    acc
164}
165
166/// One ULP of `x`'s magnitude scale.
167pub(crate) fn ulp_of(x: f64) -> f64 {
168    let a = x.abs().max(f64::MIN_POSITIVE);
169    a.next_up() - a
170}
171
172/// `acc + t`, outward — skipping the bump on a zero term (a phantom subnormal is not an error).
173pub(crate) fn bump_add(acc: f64, t: f64) -> f64 {
174    if t > 0.0 {
175        (acc + t).next_up()
176    } else {
177        acc
178    }
179}
180
181/// `a·b`, outward — zero products stay exactly zero.
182pub(crate) fn bump_mul(a: f64, b: f64) -> f64 {
183    let p = a * b;
184    if p > 0.0 {
185        p.next_up()
186    } else {
187        0.0
188    }
189}
190
191/// One pass of multi-lane ε propagation over the physical (quantized) cells — THE engine every
192/// budget composes with. Lane convention, uniform across the DAG, streamed-reduction, and
193/// integrator budgets: `[input lanes…, weight-quant, cross]`. Each cell composes its operand
194/// lanes (`Sum`/`GainConst` exactly, `Mul` by `|b|·εa + |a|·εb` over certificate bounds),
195/// injects its weight-snap delta into the wq lane, and adds its `Σ εa·εb` interaction (from
196/// the running totals) into the cross lane — so the second-order term is amplified downstream
197/// like any other error. Every accumulation rounds outward via the bump helpers.
198///
199/// The callers differ ONLY in the input-lane mapping (`input_eps`/`input_total`) and in how
200/// many passes they run (one for a DAG; one per frame with carried lanes for the streamed and
201/// integrated shapes). A new physical source (e.g. the mixed-signal SoC's surrogate rounding)
202/// is added HERE once and every budget inherits it.
203pub(crate) struct EpsPass {
204    pub cell_eps: Vec<Vec<f64>>,
205}
206
207#[allow(clippy::too_many_arguments)]
208pub(crate) fn propagate_eps_pass(
209    cells_orig: &[AnalogCell],
210    cells_q: &[AnalogCell],
211    bound_of: &dyn Fn(&ASrc) -> MathBound,
212    lanes: usize,
213    wq_lane: usize,
214    cross_lane: usize,
215    input_eps: &dyn Fn(&ASrc, usize) -> f64,
216    input_total: &dyn Fn(&ASrc) -> f64,
217) -> EpsPass {
218    let mut cell_eps: Vec<Vec<f64>> = Vec::with_capacity(cells_q.len());
219    let mut cell_total: Vec<f64> = Vec::with_capacity(cells_q.len());
220    let src_eps = |src: &ASrc, cell_eps: &[Vec<f64>], k: usize| -> f64 {
221        match src {
222            ASrc::Val(v) => cell_eps[*v][k],
223            ASrc::Const(_) => 0.0,
224            input => input_eps(input, k),
225        }
226    };
227    let src_total = |src: &ASrc, cell_total: &[f64]| -> f64 {
228        match src {
229            ASrc::Val(v) => cell_total[*v],
230            ASrc::Const(_) => 0.0,
231            input => input_total(input),
232        }
233    };
234    for (orig, phys) in cells_orig.iter().zip(cells_q) {
235        // The snap delta this tile injects (linear tiles only; Mul carries no weights).
236        let delta = match (orig, phys) {
237            (AnalogCell::Sum { inputs, weights: w }, AnalogCell::Sum { weights: wq, .. }) => {
238                outward_sum(
239                    inputs
240                        .iter()
241                        .zip(w.iter().zip(wq))
242                        .map(|(x, (a, b))| bump_mul((a - b).abs(), bound_of(x).max_abs())),
243                )
244            }
245            (AnalogCell::GainConst { a, k }, AnalogCell::GainConst { k: kq, .. }) => {
246                bump_mul((k - kq).abs(), bound_of(a).max_abs())
247            }
248            _ => 0.0,
249        };
250        let mut eps_k = vec![0.0_f64; lanes];
251        let total;
252        match phys {
253            AnalogCell::Sum { inputs, weights } => {
254                for k in 0..lanes {
255                    let mut acc = 0.0_f64;
256                    for (x, w) in inputs.iter().zip(weights) {
257                        acc = bump_add(acc, bump_mul(w.abs(), src_eps(x, &cell_eps, k)));
258                    }
259                    eps_k[k] = acc;
260                }
261                total = outward_sum(
262                    inputs
263                        .iter()
264                        .zip(weights)
265                        .map(|(x, w)| bump_mul(w.abs(), src_total(x, &cell_total))),
266                );
267            }
268            AnalogCell::GainConst { a, k: gain } => {
269                for k in 0..lanes {
270                    eps_k[k] = bump_mul(gain.abs(), src_eps(a, &cell_eps, k));
271                }
272                total = bump_mul(gain.abs(), src_total(a, &cell_total));
273            }
274            AnalogCell::Mul { a, b } => {
275                let (amax, bmax) = (bound_of(a).max_abs(), bound_of(b).max_abs());
276                for k in 0..lanes {
277                    let ea = src_eps(a, &cell_eps, k);
278                    let eb = src_eps(b, &cell_eps, k);
279                    eps_k[k] = bump_add(bump_mul(bmax, ea), bump_mul(amax, eb));
280                }
281                let (ta, tb) = (src_total(a, &cell_total), src_total(b, &cell_total));
282                eps_k[cross_lane] = bump_add(eps_k[cross_lane], bump_mul(ta, tb));
283                total = bump_add(
284                    bump_add(bump_mul(bmax, ta), bump_mul(amax, tb)),
285                    bump_mul(ta, tb),
286                );
287            }
288            AnalogCell::Select { t, f, .. } => {
289                // The output IS one branch: worst branch, lane by lane. The comparator's own
290                // ambiguity band is a separate itemized term (`CompareBand`), not smeared here.
291                for k in 0..lanes {
292                    eps_k[k] = src_eps(t, &cell_eps, k).max(src_eps(f, &cell_eps, k));
293                }
294                total = src_total(t, &cell_total).max(src_total(f, &cell_total));
295            }
296            // Translinear unaries: first-order Lipschitz amplification over the CERTIFIED
297            // operand box (outward). exp' ≤ e^hi; ln' ≤ 1/lo; tanh' ≤ 1; √' ≤ 1/(2√lo) —
298            // the domain gates (lo > 0) were enforced when the box was certified.
299            AnalogCell::Exp { a } => {
300                let l = bound_of(a).hi.exp().next_up();
301                for k in 0..lanes {
302                    eps_k[k] = bump_mul(l, src_eps(a, &cell_eps, k));
303                }
304                total = bump_mul(l, src_total(a, &cell_total));
305            }
306            AnalogCell::Log { a } => {
307                let l = (1.0 / bound_of(a).lo).next_up();
308                for k in 0..lanes {
309                    eps_k[k] = bump_mul(l, src_eps(a, &cell_eps, k));
310                }
311                total = bump_mul(l, src_total(a, &cell_total));
312            }
313            AnalogCell::Tanh { a } => {
314                for k in 0..lanes {
315                    eps_k[k] = src_eps(a, &cell_eps, k);
316                }
317                total = src_total(a, &cell_total);
318            }
319            AnalogCell::Sqrt { a } => {
320                let l = (0.5 / bound_of(a).lo.sqrt()).next_up();
321                for k in 0..lanes {
322                    eps_k[k] = bump_mul(l, src_eps(a, &cell_eps, k));
323                }
324                total = bump_mul(l, src_total(a, &cell_total));
325            }
326            AnalogCell::Div { a, b } => {
327                // |δ(a/b)| ≤ δa/min|b| + max|a|·δb/min|b|² first-order; the δa·δb/min|b|²
328                // second-order proxy rides the cross lane like Mul's.
329                let bb = bound_of(b);
330                let min_b = bb.lo.abs().min(bb.hi.abs());
331                let la = (1.0 / min_b).next_up();
332                let lb = (bound_of(a).max_abs() / (min_b * min_b)).next_up();
333                for k in 0..lanes {
334                    let ea = src_eps(a, &cell_eps, k);
335                    let eb = src_eps(b, &cell_eps, k);
336                    eps_k[k] = bump_add(bump_mul(la, ea), bump_mul(lb, eb));
337                }
338                let (ta, tb) = (src_total(a, &cell_total), src_total(b, &cell_total));
339                let cross = bump_mul(ta * tb, (1.0 / (min_b * min_b)).next_up());
340                eps_k[cross_lane] = bump_add(eps_k[cross_lane], cross);
341                total = bump_add(bump_add(bump_mul(la, ta), bump_mul(lb, tb)), cross);
342            }
343        }
344        if delta > 0.0 {
345            eps_k[wq_lane] = bump_add(eps_k[wq_lane], delta);
346        }
347        cell_eps.push(eps_k);
348        cell_total.push(if delta > 0.0 {
349            bump_add(total, delta)
350        } else {
351            total
352        });
353    }
354    EpsPass { cell_eps }
355}
356
357/// The out-lane readout of a pass for an arbitrary tap.
358pub(crate) fn out_lanes(
359    pass: &EpsPass,
360    out: &ASrc,
361    lanes: usize,
362    input_eps: &dyn Fn(&ASrc, usize) -> f64,
363) -> Vec<f64> {
364    (0..lanes)
365        .map(|k| match out {
366            ASrc::Val(v) => pass.cell_eps[*v][k],
367            ASrc::Const(_) => 0.0,
368            input => input_eps(input, k),
369        })
370        .collect()
371}
372
373/// The f64 evaluator-rounding guard: two behavioral f64 evaluations of `ops_evaluated` tile
374/// equations at the largest certified magnitude are being compared — bound their drift.
375pub(crate) fn eval_rounding_guard(cert: &RangeCertificate, ops_evaluated: usize) -> f64 {
376    let max_mag = cert
377        .per_net
378        .iter()
379        .map(|n| n.math.max_abs())
380        .chain(cert.input_bounds.iter().map(|b| b.max_abs()))
381        .fold(f64::MIN_POSITIVE, f64::max);
382    (16.0 * (ops_evaluated as f64 + 2.0) * ulp_of(max_mag)).next_up()
383}
384
385/// Math-domain half-LSB of a converter at the block scale.
386pub(crate) fn half_lsb(cert: &RangeCertificate, schedule: &GainSchedule, bits: u8) -> f64 {
387    cert.rail_half_span_v / f64::powi(2.0, i32::from(bits)) / f64::powi(2.0, schedule.scale_log2)
388}
389
390/// Compose the budget for a scheduled DAG netlist: one propagation pass with a lane per DAC
391/// input, the wq lane, and the cross lane; then the itemized terms.
392pub fn error_budget(
393    netlist: &AnalogNetlist,
394    cert: &RangeCertificate,
395    schedule: &GainSchedule,
396    target: &AnalogTarget,
397) -> OutputBudget {
398    let n_in = netlist.n_in;
399    let dac_eps = half_lsb(cert, schedule, target.dac_bits);
400    let adc_eps = half_lsb(cert, schedule, target.adc_bits);
401    let bound_of = |src: &ASrc| -> MathBound {
402        match src {
403            ASrc::In(k) => cert.input_bounds[*k],
404            ASrc::Val(v) => cert.per_net[*v].math,
405            ASrc::Const(c) => MathBound { lo: *c, hi: *c },
406        }
407    };
408    let (lanes, wq_lane, cross_lane) = (n_in + 2, n_in, n_in + 1);
409    let input_eps = |src: &ASrc, k: usize| -> f64 {
410        match src {
411            ASrc::In(i) if *i == k && k < n_in => dac_eps,
412            _ => 0.0,
413        }
414    };
415    let input_total = |_: &ASrc| dac_eps;
416    let realized = schedule.realized_math_cells();
417    let pass = propagate_eps_pass(
418        &netlist.cells,
419        &realized,
420        &bound_of,
421        lanes,
422        wq_lane,
423        cross_lane,
424        &input_eps,
425        &input_total,
426    );
427    let out = out_lanes(&pass, &netlist.out, lanes, &input_eps);
428
429    let mut terms: Vec<ErrorTerm> = out[..n_in]
430        .iter()
431        .enumerate()
432        .filter(|(_, e)| **e > 0.0)
433        .map(|(k, e)| ErrorTerm {
434            eps_math: *e,
435            source: ErrorSource::DacQuant {
436                input: k,
437                bits: target.dac_bits,
438            },
439        })
440        .collect();
441    terms.push(ErrorTerm {
442        eps_math: adc_eps,
443        source: ErrorSource::AdcQuant {
444            bits: target.adc_bits,
445        },
446    });
447    if out[wq_lane] > 0.0 {
448        terms.push(ErrorTerm {
449            eps_math: out[wq_lane],
450            source: ErrorSource::WeightQuant,
451        });
452    }
453    if out[cross_lane] > 0.0 {
454        terms.push(ErrorTerm {
455            eps_math: out[cross_lane],
456            source: ErrorSource::MulInteraction,
457        });
458    }
459    terms.push(ErrorTerm {
460        eps_math: eval_rounding_guard(cert, netlist.cells.len()),
461        source: ErrorSource::EvalRounding,
462    });
463
464    let eps_math = outward_sum(terms.iter().map(|t| t.eps_math));
465    OutputBudget { eps_math, terms }
466}
467
468/// Crate-internal alias for [`propagate_injected`] (the calibrated budget lives one module over).
469pub(crate) fn propagate_injected_pub(
470    netlist_q: &AnalogNetlist,
471    bound_of: &dyn Fn(&ASrc) -> MathBound,
472    deltas: &[f64],
473) -> f64 {
474    propagate_injected(netlist_q, bound_of, deltas)
475}
476
477/// Propagate a per-cell INJECTED error vector to the output: each cell adds its own `deltas[i]`
478/// at its output net, and downstream cells amplify it exactly like any other error lane.
479/// Shared by the physical (non-ideal) terms.
480fn propagate_injected(
481    netlist_q: &AnalogNetlist,
482    bound_of: &dyn Fn(&ASrc) -> MathBound,
483    deltas: &[f64],
484) -> f64 {
485    let mut cell_eps: Vec<f64> = Vec::with_capacity(netlist_q.cells.len());
486    let src_eps = |s: &ASrc, cell_eps: &[f64]| -> f64 {
487        match s {
488            ASrc::Val(v) => cell_eps[*v],
489            _ => 0.0,
490        }
491    };
492    for (i, cell) in netlist_q.cells.iter().enumerate() {
493        let carried = match cell {
494            AnalogCell::Sum { inputs, weights } => outward_sum(
495                inputs
496                    .iter()
497                    .zip(weights)
498                    .map(|(x, w)| bump_mul(w.abs(), src_eps(x, &cell_eps))),
499            ),
500            AnalogCell::GainConst { a, k } => bump_mul(k.abs(), src_eps(a, &cell_eps)),
501            AnalogCell::Select { t, f, .. } => src_eps(t, &cell_eps).max(src_eps(f, &cell_eps)),
502            AnalogCell::Exp { a } => {
503                bump_mul(bound_of(a).hi.exp().next_up(), src_eps(a, &cell_eps))
504            }
505            AnalogCell::Log { a } => {
506                bump_mul((1.0 / bound_of(a).lo).next_up(), src_eps(a, &cell_eps))
507            }
508            AnalogCell::Tanh { a } => src_eps(a, &cell_eps),
509            AnalogCell::Sqrt { a } => bump_mul(
510                (0.5 / bound_of(a).lo.sqrt()).next_up(),
511                src_eps(a, &cell_eps),
512            ),
513            AnalogCell::Div { a, b } => {
514                let bb = bound_of(b);
515                let min_b = bb.lo.abs().min(bb.hi.abs());
516                bump_add(
517                    bump_mul((1.0 / min_b).next_up(), src_eps(a, &cell_eps)),
518                    bump_mul(
519                        (bound_of(a).max_abs() / (min_b * min_b)).next_up(),
520                        src_eps(b, &cell_eps),
521                    ),
522                )
523            }
524            AnalogCell::Mul { a, b } => {
525                let (amax, bmax) = (bound_of(a).max_abs(), bound_of(b).max_abs());
526                bump_add(
527                    bump_mul(bmax, src_eps(a, &cell_eps)),
528                    bump_mul(amax, src_eps(b, &cell_eps)),
529                )
530            }
531        };
532        cell_eps.push(if deltas[i] > 0.0 {
533            bump_add(carried, deltas[i])
534        } else {
535            carried
536        });
537    }
538    match netlist_q.out {
539        ASrc::Val(v) => cell_eps[v],
540        _ => 0.0,
541    }
542}
543
544/// The PHYSICAL budget: the converter/quantization budget plus the non-ideality terms —
545/// per-leg gain error over the PLACED operand separations, per-tile offset, and the noise
546/// floor, each at the k·σ envelope and propagated through the dataflow. This is the ε the
547/// Monte-Carlo battery and (later) SPICE must live inside.
548pub fn error_budget_nonideal(
549    netlist: &AnalogNetlist,
550    cert: &RangeCertificate,
551    schedule: &GainSchedule,
552    target: &AnalogTarget,
553    model: &NonIdealityModel,
554    placement: &AnalogPlacement,
555) -> OutputBudget {
556    let q = schedule.realized_math_cells();
557    let sigma_of = move |i: usize, l: usize| leg_sigma(i, l, &q, placement, model);
558    nonideal_budget_with(netlist, cert, schedule, target, model, &sigma_of)
559}
560
561/// The shared non-ideality composer, parameterized over the per-leg sigma law — the 2D path
562/// feeds it `leg_sigma` (SameTier distances); the stacked path feeds the full separation law
563/// including the categorical cross-tier ceiling. ONE composition, every geometry.
564pub(crate) fn nonideal_budget_with(
565    netlist: &AnalogNetlist,
566    cert: &RangeCertificate,
567    schedule: &GainSchedule,
568    target: &AnalogTarget,
569    model: &NonIdealityModel,
570    sigma_of: &dyn Fn(usize, usize) -> f64,
571) -> OutputBudget {
572    let base = error_budget(netlist, cert, schedule, target);
573    let realized = AnalogNetlist {
574        n_in: schedule.quantized.n_in,
575        cells: schedule.realized_math_cells(),
576        out: schedule.quantized.out,
577    };
578    let s_of = |i: usize| f64::powi(2.0, schedule.per_net_scale_log2[i]);
579    let q = &realized;
580    let bound_of = |src: &ASrc| -> MathBound {
581        match src {
582            ASrc::In(k) => cert.input_bounds[*k],
583            ASrc::Val(v) => cert.per_net[*v].math,
584            ASrc::Const(c) => MathBound { lo: *c, hi: *c },
585        }
586    };
587
588    // Per-cell injected deltas, math domain.
589    let mut gain_d = Vec::with_capacity(q.cells.len());
590    let mut off_d = Vec::with_capacity(q.cells.len());
591    let mut noise_d = Vec::with_capacity(q.cells.len());
592    for (i, cell) in q.cells.iter().enumerate() {
593        let g = match cell {
594            AnalogCell::Sum { inputs, weights } => {
595                outward_sum(inputs.iter().zip(weights).enumerate().map(|(l, (x, w))| {
596                    bump_mul(KSIGMA * sigma_of(i, l), w.abs() * bound_of(x).max_abs())
597                }))
598            }
599            AnalogCell::GainConst { a, k } => {
600                bump_mul(KSIGMA * sigma_of(i, 0), k.abs() * bound_of(a).max_abs())
601            }
602            AnalogCell::Mul { a, b } => bump_mul(
603                KSIGMA * sigma_of(i, 0),
604                bound_of(a).max_abs() * bound_of(b).max_abs(),
605            ),
606            AnalogCell::Select { .. } => 0.0,
607            // Translinear bias-gain class: multiplicative on the output law — the injected
608            // delta is k·σ times the cell's own certified output magnitude.
609            AnalogCell::Exp { .. }
610            | AnalogCell::Log { .. }
611            | AnalogCell::Tanh { .. }
612            | AnalogCell::Sqrt { .. }
613            | AnalogCell::Div { .. } => {
614                bump_mul(KSIGMA * sigma_of(i, 0), cert.per_net[i].math.max_abs())
615            }
616        };
617        gain_d.push(g);
618        off_d.push((KSIGMA * model.offset_sigma_v.0 / s_of(i)).next_up());
619        noise_d.push((KSIGMA * model.noise_floor_v.0 / s_of(i)).next_up());
620    }
621
622    // CompareBand: a Select whose decision box cannot clear the comparator's offset band may
623    // take either branch — inject the branch-hull width at that cell, amplified downstream.
624    let mut band_d = vec![0.0_f64; q.cells.len()];
625    let mut any_band = false;
626    for (i, cell) in q.cells.iter().enumerate() {
627        if let AnalogCell::Select { c, t, f } = cell {
628            let s_c = match c {
629                ASrc::Val(v) => f64::powi(2.0, schedule.per_net_scale_log2[*v]),
630                _ => f64::powi(2.0, schedule.scale_log2),
631            };
632            let band_math = KSIGMA * model.offset_sigma_v.0 / s_c;
633            let cb = bound_of(c);
634            if cb.lo < band_math && cb.hi > -band_math {
635                let (tb, fb) = (bound_of(t), bound_of(f));
636                let hull_w = (tb.hi.max(fb.hi) - tb.lo.min(fb.lo)).next_up();
637                band_d[i] = hull_w;
638                any_band = true;
639            }
640        }
641    }
642    let mut terms = base.terms;
643    if any_band {
644        let tb = propagate_injected(q, &bound_of, &band_d);
645        if tb > 0.0 {
646            terms.push(ErrorTerm {
647                eps_math: tb,
648                source: ErrorSource::CompareBand,
649            });
650        }
651    }
652    let tg = propagate_injected(q, &bound_of, &gain_d);
653    if tg > 0.0 {
654        terms.push(ErrorTerm {
655            eps_math: tg,
656            source: ErrorSource::TileGain,
657        });
658    }
659    let to = propagate_injected(q, &bound_of, &off_d);
660    if to > 0.0 {
661        terms.push(ErrorTerm {
662            eps_math: to,
663            source: ErrorSource::TileOffset,
664        });
665    }
666    let tn = propagate_injected(q, &bound_of, &noise_d);
667    if tn > 0.0 {
668        terms.push(ErrorTerm {
669            eps_math: tn,
670            source: ErrorSource::Noise,
671        });
672    }
673    let eps_math = outward_sum(terms.iter().map(|t| t.eps_math));
674    OutputBudget { eps_math, terms }
675}