Skip to main content

logicaffeine_compile/codegen_ana/
gain.rs

1//! Gain scaling + the range-safety certificate — the compiler side of the analog trust story.
2//!
3//! Values on nets are MATH-domain quantities; voltage is `2^e · math` under a single power-of-two
4//! block scale `e` chosen so the largest net magnitude fits the usable rail half-span
5//! (`headroom · (rail_hi − rail_lo) / 2`, differential-symmetric around mid-rail). Powers of two
6//! keep every certificate multiplication exact in f64 and make the ADC descale a bit shift.
7//! With a common scale, `Sum` weights ride unchanged, a `GainConst` is the same ratio in both
8//! domains (gated against the target's realizable range), and the four-quadrant `Mul` divides by
9//! a programmed `V_ref = 2^e` so its output returns to the block scale — the standard
10//! translinear normalization.
11//!
12//! The produced [`RangeCertificate`] carries per-net math and volt bounds from directed-outward
13//! interval propagation over the DECLARED input ranges. It is judged by the INDEPENDENT replay
14//! checker in `logicaffeine_proof::interval_replay` (this producer converts to the checker's
15//! neutral vocabulary via [`to_range_claim`]) — the checker trusts nothing computed here.
16
17use super::cell::{ASrc, AnalogCell, AnalogNetlist};
18use super::target::{AnaUnplaceable, AnalogTarget};
19use super::{AnaLowerError, AssumedRange};
20
21/// A directed-outward f64 interval — the same arithmetic the independent checker replays.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct MathBound {
24    pub lo: f64,
25    pub hi: f64,
26}
27
28impl MathBound {
29    fn outward(self) -> MathBound {
30        MathBound {
31            lo: self.lo.next_down(),
32            hi: self.hi.next_up(),
33        }
34    }
35    fn add(self, o: MathBound) -> MathBound {
36        MathBound {
37            lo: self.lo + o.lo,
38            hi: self.hi + o.hi,
39        }
40        .outward()
41    }
42    fn scale(self, w: f64) -> MathBound {
43        let (a, b) = (w * self.lo, w * self.hi);
44        MathBound {
45            lo: a.min(b),
46            hi: a.max(b),
47        }
48        .outward()
49    }
50    fn mul(self, o: MathBound) -> MathBound {
51        let ps = [
52            self.lo * o.lo,
53            self.lo * o.hi,
54            self.hi * o.lo,
55            self.hi * o.hi,
56        ];
57        MathBound {
58            lo: ps.iter().copied().fold(f64::INFINITY, f64::min),
59            hi: ps.iter().copied().fold(f64::NEG_INFINITY, f64::max),
60        }
61        .outward()
62    }
63    pub fn max_abs(self) -> f64 {
64        self.lo.abs().max(self.hi.abs())
65    }
66}
67
68/// The per-program gain schedule: one block scale for every net, the exact ADC descale, the
69/// REALIZABLE multiplier reference, and the weight-quantized twin of the netlist (weights
70/// snapped to the target's unit elements — what the silicon actually programs).
71#[derive(Clone, Debug, PartialEq)]
72pub struct GainSchedule {
73    /// The block scale exponent: `volt = 2^scale_log2 · math` on every net.
74    pub scale_log2: i32,
75    /// The output descale exponent (`math = 2^out_descale_log2 · volt`); always `-scale_log2`.
76    pub out_descale_log2: i32,
77    /// The multiplier normalization reference in volts. When the graph carries a `Mul`, this is
78    /// a member of the target's `mul_vref_options_v` equal to the block scale — chosen FROM the
79    /// realizable set, never approximated into it.
80    pub mul_vref_v: f64,
81    /// The netlist with every programmed weight snapped to the target's unit-element grid —
82    /// the configuration the fabric actually realizes (PHYSICAL weights, which under a
83    /// per-net schedule include the exact power-of-two rescale between operand and cell
84    /// scales). The snap error is a budget term (`ErrorSource::WeightQuant`), never silently
85    /// absorbed.
86    pub quantized: AnalogNetlist,
87    /// Per-cell scale exponents (`volt_i = 2^{per_net_scale_log2[i]} · math_i`). Uniform
88    /// schedules carry `scale_log2` everywhere; a per-net schedule (O4) lets internal nets
89    /// diverge while inputs and the output stay on `scale_log2`, so the converter grid is
90    /// untouched. The independent range checker judges per-cell scales natively.
91    pub per_net_scale_log2: Vec<i32>,
92}
93
94impl GainSchedule {
95    /// The REALIZED math dataflow: each physical weight mapped back to the math domain by the
96    /// exact power-of-two rescale (`w_math_realized = w_phys · 2^{e_operand − e_cell}`).
97    /// Under a uniform schedule this is the quantized netlist verbatim. Certificates and
98    /// budgets judge THIS — the dataflow the silicon actually computes.
99    pub fn realized_math_cells(&self) -> Vec<AnalogCell> {
100        let e_io = self.scale_log2;
101        let e = &self.per_net_scale_log2;
102        let src_e = |s: &ASrc| -> i32 {
103            match s {
104                ASrc::Val(v) => e[*v],
105                _ => e_io,
106            }
107        };
108        self.quantized
109            .cells
110            .iter()
111            .enumerate()
112            .map(|(i, cell)| match cell {
113                AnalogCell::Sum { inputs, weights } => AnalogCell::Sum {
114                    inputs: inputs.clone(),
115                    weights: inputs
116                        .iter()
117                        .zip(weights)
118                        .map(|(x, w)| w * f64::powi(2.0, src_e(x) - e[i]))
119                        .collect(),
120                },
121                AnalogCell::GainConst { a, k } => AnalogCell::GainConst {
122                    a: *a,
123                    k: k * f64::powi(2.0, src_e(a) - e[i]),
124                },
125                AnalogCell::Mul { a, b } => AnalogCell::Mul { a: *a, b: *b },
126                AnalogCell::Select { c, t, f } => AnalogCell::Select {
127                    c: *c,
128                    t: *t,
129                    f: *f,
130                },
131                AnalogCell::Exp { a } => AnalogCell::Exp { a: *a },
132                AnalogCell::Log { a } => AnalogCell::Log { a: *a },
133                AnalogCell::Tanh { a } => AnalogCell::Tanh { a: *a },
134                AnalogCell::Sqrt { a } => AnalogCell::Sqrt { a: *a },
135                AnalogCell::Div { a, b } => AnalogCell::Div { a: *a, b: *b },
136            })
137            .collect()
138    }
139}
140
141/// One net's certified bounds.
142#[derive(Clone, Debug, PartialEq)]
143pub struct NetBound {
144    pub math: MathBound,
145    pub volt_lo: f64,
146    pub volt_hi: f64,
147}
148
149/// The range-safety certificate: per-net bounds + the physical envelope they were proven against.
150/// Independently judged by `logicaffeine_proof::interval_replay::check_range_claim` via
151/// [`to_range_claim`]; nothing here is trusted by the checker.
152#[derive(Clone, Debug, PartialEq)]
153pub struct RangeCertificate {
154    /// Declared math-domain input bounds, in plane-input order.
155    pub input_bounds: Vec<MathBound>,
156    /// Per-cell bounds (SSA order).
157    pub per_net: Vec<NetBound>,
158    /// The usable half-span in volts the bounds were proven inside.
159    pub rail_half_span_v: f64,
160    /// The noise floor the starvation check used (`0.0` on an ideal target).
161    pub noise_floor_v: f64,
162    /// The demanded full-scale/noise ratio.
163    pub min_full_scale_over_noise: f64,
164}
165
166/// Run the gain-scaling pass: propagate declared ranges, choose the block scale, gate realizable
167/// ratios, and emit the schedule + certificate. `ranges` is one `AssumedRange` per plane input,
168/// in input order (the caller resolves parameter names to input indices).
169pub fn scale_pass(
170    netlist: &AnalogNetlist,
171    ranges: &[AssumedRange],
172    target: &AnalogTarget,
173) -> Result<(GainSchedule, RangeCertificate), AnaLowerError> {
174    assert_eq!(
175        ranges.len(),
176        netlist.n_in,
177        "one declared range per plane input"
178    );
179    let input_bounds: Vec<MathBound> = ranges
180        .iter()
181        .map(|r| MathBound { lo: r.lo, hi: r.hi })
182        .collect();
183    for b in &input_bounds {
184        if !(b.lo.is_finite() && b.hi.is_finite() && b.lo <= b.hi) {
185            return Err(AnaLowerError::UnboundedInput(format!(
186                "declared range [{}, {}] is not a finite interval",
187                b.lo, b.hi
188            )));
189        }
190    }
191
192    // Weight quantization FIRST: the certificate must bound the netlist silicon actually runs —
193    // the unit-element-snapped twin — not the ideal weights. (The ideal ≡VM tier keeps the
194    // original netlist; the snap deviation is the WeightQuant budget term.)
195    let quantized = quantize_weights(netlist, target)?;
196
197    // Forward outward-interval propagation in the math domain, over the QUANTIZED cells.
198    let bounds = propagate_bounds(&quantized.cells, &input_bounds)?;
199
200    // The block scale: the largest magnitude anywhere (inputs included — the DAC drives them at
201    // the same scale) must fit the usable half-span, AND the scale must be one the fabric's
202    // references support (`scale_log2_range`; a `Mul`-bearing graph must additionally land on a
203    // realizable multiplier reference — rail safety does not imply Vref realizability).
204    let half_span = target.half_span_v();
205    let has_mul = netlist
206        .cells
207        .iter()
208        .any(|c| matches!(c, AnalogCell::Mul { .. }));
209    let has_translinear = netlist.cells.iter().any(|c| {
210        matches!(
211            c,
212            AnalogCell::Exp { .. }
213                | AnalogCell::Log { .. }
214                | AnalogCell::Tanh { .. }
215                | AnalogCell::Sqrt { .. }
216                | AnalogCell::Div { .. }
217        )
218    });
219    let all_bounds: Vec<MathBound> = input_bounds.iter().chain(bounds.iter()).copied().collect();
220    let scale_log2 = match select_scale_pinned(&all_bounds, has_mul, target, has_translinear) {
221        Ok(e) => e,
222        // The block-scale limiter: one net's magnitude was dragging every net's scale. The
223        // per-net pass (O4) decouples them; only if IT also refuses does the program refuse.
224        Err(uniform_refusal) => {
225            if has_translinear {
226                return Err(uniform_refusal);
227            }
228            return per_net_scale_pass(&input_bounds, &quantized, &bounds, target)
229                .map_err(|_| uniform_refusal);
230        }
231    };
232    let s = f64::powi(2.0, scale_log2);
233
234    // Certificate: volt bounds per net, then the starvation check against the noise floor. The
235    // rail refusal below is unreachable under a free block scale (the retry above guarantees the
236    // fit); it guards the future fixed-scale / real-noise-floor targets.
237    let noise_floor_v = 0.0; // the ideal-physics floor; the non-ideality phase supplies a real one
238    let min_ratio = 1.0;
239    let mut per_net = Vec::with_capacity(bounds.len());
240    for b in &bounds {
241        let (volt_lo, volt_hi) = ((b.lo * s).next_down(), (b.hi * s).next_up());
242        if volt_lo < -half_span || volt_hi > half_span {
243            let needed_db = 20.0 * (b.max_abs() * s / half_span).log10();
244            return Err(AnaLowerError::Unplaceable(
245                AnaUnplaceable::DynamicRangeExceeded {
246                    net: per_net.len(),
247                    needed_db: needed_db.max(0.0),
248                    available_db: 0.0,
249                    target: target.name,
250                },
251            ));
252        }
253        per_net.push(NetBound {
254            math: *b,
255            volt_lo,
256            volt_hi,
257        });
258    }
259
260    let per_net_scale_log2 = vec![scale_log2; quantized.cells.len()];
261    let schedule = GainSchedule {
262        scale_log2,
263        out_descale_log2: -scale_log2,
264        mul_vref_v: s,
265        quantized,
266        per_net_scale_log2,
267    };
268    let cert = RangeCertificate {
269        input_bounds,
270        per_net,
271        rail_half_span_v: half_span,
272        noise_floor_v,
273        min_full_scale_over_noise: min_ratio,
274    };
275    Ok((schedule, cert))
276}
277
278/// One step of directed-outward interval propagation over analog cells — shared by the DAG
279/// schedule (`scale_pass`) and the streamed-reduction iteration.
280pub(crate) fn propagate_bounds(
281    cells: &[AnalogCell],
282    input_bounds: &[MathBound],
283) -> Result<Vec<MathBound>, AnaLowerError> {
284    let src_bound = |s: &ASrc, vals: &[MathBound]| -> MathBound {
285        match s {
286            ASrc::In(k) => input_bounds[*k],
287            ASrc::Val(v) => vals[*v],
288            ASrc::Const(c) => MathBound { lo: *c, hi: *c },
289        }
290    };
291    let mut bounds: Vec<MathBound> = Vec::with_capacity(cells.len());
292    for cell in cells {
293        let b = match cell {
294            AnalogCell::Sum { inputs, weights } => {
295                let mut acc = MathBound { lo: 0.0, hi: 0.0 };
296                for (x, w) in inputs.iter().zip(weights) {
297                    acc = acc.add(src_bound(x, &bounds).scale(*w));
298                }
299                acc
300            }
301            AnalogCell::Mul { a, b } => src_bound(a, &bounds).mul(src_bound(b, &bounds)),
302            AnalogCell::GainConst { a, k } => src_bound(a, &bounds).scale(*k),
303            AnalogCell::Select { c, t, f } => {
304                // Decided boxes take their branch; a straddling condition takes the hull —
305                // the comparator's ambiguity is priced by the budget, not hidden here.
306                let cb = src_bound(c, &bounds);
307                let (tb, fb) = (src_bound(t, &bounds), src_bound(f, &bounds));
308                if cb.hi < 0.0 {
309                    tb
310                } else if cb.lo >= 0.0 {
311                    fb
312                } else {
313                    MathBound {
314                        lo: tb.lo.min(fb.lo),
315                        hi: tb.hi.max(fb.hi),
316                    }
317                    .outward()
318                }
319            }
320            // The translinear family: monotone image boxes, outward; domains fail closed.
321            AnalogCell::Exp { a } => {
322                let ab = src_bound(a, &bounds);
323                MathBound {
324                    lo: ab.lo.exp(),
325                    hi: ab.hi.exp(),
326                }
327                .outward()
328            }
329            AnalogCell::Log { a } => {
330                let ab = src_bound(a, &bounds);
331                if ab.lo <= 0.0 {
332                    return Err(AnaLowerError::UnloweredOp(format!(
333                        "log domain: operand box [{}, {}] touches the nonpositive axis",
334                        ab.lo, ab.hi
335                    )));
336                }
337                MathBound {
338                    lo: ab.lo.ln(),
339                    hi: ab.hi.ln(),
340                }
341                .outward()
342            }
343            AnalogCell::Tanh { a } => {
344                let ab = src_bound(a, &bounds);
345                MathBound {
346                    lo: ab.lo.tanh(),
347                    hi: ab.hi.tanh(),
348                }
349                .outward()
350            }
351            AnalogCell::Sqrt { a } => {
352                let ab = src_bound(a, &bounds);
353                if ab.lo <= 0.0 {
354                    return Err(AnaLowerError::UnloweredOp(format!(
355                        "sqrt domain: operand box [{}, {}] touches zero — the error slope \
356                         diverges (the budget's Lipschitz factor needs lo > 0)",
357                        ab.lo, ab.hi
358                    )));
359                }
360                MathBound {
361                    lo: ab.lo.sqrt(),
362                    hi: ab.hi.sqrt(),
363                }
364                .outward()
365            }
366            AnalogCell::Div { a, b } => {
367                let (ab, bb) = (src_bound(a, &bounds), src_bound(b, &bounds));
368                if bb.lo <= 0.0 && bb.hi >= 0.0 {
369                    return Err(AnaLowerError::Unplaceable(
370                        AnaUnplaceable::DivisorRangeSpansZero {
371                            net: bounds.len(),
372                            lo: bb.lo,
373                            hi: bb.hi,
374                        },
375                    ));
376                }
377                let ps = [ab.lo / bb.lo, ab.lo / bb.hi, ab.hi / bb.lo, ab.hi / bb.hi];
378                MathBound {
379                    lo: ps.iter().copied().fold(f64::INFINITY, f64::min),
380                    hi: ps.iter().copied().fold(f64::NEG_INFINITY, f64::max),
381                }
382                .outward()
383            }
384        };
385        if !(b.lo.is_finite() && b.hi.is_finite()) {
386            return Err(AnaLowerError::UnloweredOp(
387                "range propagation overflowed — the declared ranges do not bound this dataflow"
388                    .to_string(),
389            ));
390        }
391        bounds.push(b);
392    }
393    Ok(bounds)
394}
395
396/// The bound of an operand given already-propagated cell bounds.
397pub(crate) fn src_bound_of(
398    s: &ASrc,
399    input_bounds: &[MathBound],
400    bounds: &[MathBound],
401) -> MathBound {
402    match s {
403        ASrc::In(k) => input_bounds[*k],
404        ASrc::Val(v) => bounds[*v],
405        ASrc::Const(c) => MathBound { lo: *c, hi: *c },
406    }
407}
408
409/// Choose the block scale for a set of bounds: the largest realizable power of two that fits
410/// every OUTWARD-rounded endpoint inside the half-span — from the discrete Vref options when a
411/// `Mul` is present (the scale IS the reference), else from the scale window. Shared by the DAG
412/// and reduction passes.
413pub(crate) fn select_scale_pinned(
414    all_bounds: &[MathBound],
415    has_mul: bool,
416    target: &AnalogTarget,
417    pin_unit_scale: bool,
418) -> Result<i32, AnaLowerError> {
419    if pin_unit_scale {
420        let half_span = target.half_span_v();
421        let (lo_e, hi_e) = target.scale_log2_range;
422        let fits0 = all_bounds
423            .iter()
424            .all(|b| b.lo.next_down() >= -half_span && b.hi.next_up() <= half_span);
425        if lo_e <= 0 && 0 <= hi_e && fits0 && !has_mul {
426            return Ok(0);
427        }
428        if has_mul {
429            // A Mul's reference must equal the block scale; translinear pins it to 1 V —
430            // realizable only if the option set carries it.
431            if target.mul_vref_options_v.iter().any(|v| v.0 == 1.0) && fits0 {
432                return Ok(0);
433            }
434            return Err(AnaLowerError::Unplaceable(
435                AnaUnplaceable::MulReferenceUnrealizable {
436                    needed_v: 1.0,
437                    options_v: target.mul_vref_options_v.iter().map(|v| v.0).collect(),
438                    target: target.name,
439                },
440            ));
441        }
442        return Err(AnaLowerError::Unplaceable(
443            AnaUnplaceable::DynamicRangeExceeded {
444                net: 0,
445                needed_db: 0.0,
446                available_db: 0.0,
447                target: target.name,
448            },
449        ));
450    }
451    select_scale(all_bounds, has_mul, target)
452}
453
454pub(crate) fn select_scale(
455    all_bounds: &[MathBound],
456    has_mul: bool,
457    target: &AnalogTarget,
458) -> Result<i32, AnaLowerError> {
459    let half_span = target.half_span_v();
460    let global_max = all_bounds
461        .iter()
462        .map(|b| b.max_abs())
463        .fold(0.0_f64, f64::max);
464    let fits = |e: i32| -> bool {
465        let s = f64::powi(2.0, e);
466        all_bounds
467            .iter()
468            .all(|b| (b.lo * s).next_down() >= -half_span && (b.hi * s).next_up() <= half_span)
469    };
470    let (lo_e, hi_e) = target.scale_log2_range;
471    if has_mul {
472        let mut opts: Vec<f64> = target.mul_vref_options_v.iter().map(|v| v.0).collect();
473        opts.sort_by(|a, b| b.partial_cmp(a).unwrap());
474        for v in &opts {
475            let e = v.log2();
476            if e.fract() != 0.0 {
477                continue; // scales are powers of two; a non-p2 reference serves other schedules
478            }
479            let e = e as i32;
480            if e >= lo_e && e <= hi_e && (global_max == 0.0 || fits(e)) {
481                return Ok(e);
482            }
483        }
484        let needed = if global_max == 0.0 {
485            half_span
486        } else {
487            half_span / global_max
488        };
489        Err(AnaLowerError::Unplaceable(
490            AnaUnplaceable::MulReferenceUnrealizable {
491                needed_v: needed,
492                options_v: target.mul_vref_options_v.iter().map(|v| v.0).collect(),
493                target: target.name,
494            },
495        ))
496    } else {
497        let mut e = if global_max == 0.0 {
498            0
499        } else {
500            ((half_span / global_max).log2().floor() as i32).clamp(lo_e, hi_e)
501        }
502        .min(hi_e);
503        while global_max > 0.0 && !fits(e) && e > lo_e {
504            e -= 1;
505        }
506        if global_max > 0.0 && !fits(e) {
507            return Err(AnaLowerError::Unplaceable(
508                AnaUnplaceable::DynamicRangeExceeded {
509                    net: 0,
510                    needed_db: 20.0
511                        * (global_max * f64::powi(2.0, lo_e) / half_span)
512                            .log10()
513                            .max(0.0),
514                    available_db: 0.0,
515                    target: target.name,
516                },
517            ));
518        }
519        Ok(e)
520    }
521}
522
523/// Snap every programmed weight to the target's sign+magnitude unit-element grid and gate each
524/// nonzero snapped magnitude against the realizable range. Fan-in is checked here too (belt to
525/// the crossbar treeify): a `Sum` wider than the physical summing node refuses typed.
526pub(crate) fn quantize_weights(
527    netlist: &AnalogNetlist,
528    target: &AnalogTarget,
529) -> Result<AnalogNetlist, AnaLowerError> {
530    let lsb = target.weight_lsb();
531    let snap = |w: f64| -> f64 { (w / lsb).round() * lsb };
532    let gate = |wq: f64| -> Result<(), AnaLowerError> {
533        if wq != 0.0 {
534            target
535                .require_gain_ratio(wq.abs())
536                .map_err(AnaLowerError::Unplaceable)?;
537        }
538        Ok(())
539    };
540    let mut cells = Vec::with_capacity(netlist.cells.len());
541    for cell in &netlist.cells {
542        cells.push(match cell {
543            AnalogCell::Sum { inputs, weights } => {
544                if inputs.len() > target.sum_fanin {
545                    return Err(AnaLowerError::Unplaceable(
546                        AnaUnplaceable::SumFaninExceeded {
547                            needed: inputs.len(),
548                            max: target.sum_fanin,
549                            target: target.name,
550                        },
551                    ));
552                }
553                let wq: Vec<f64> = weights.iter().map(|w| snap(*w)).collect();
554                for w in &wq {
555                    gate(*w)?;
556                }
557                AnalogCell::Sum {
558                    inputs: inputs.clone(),
559                    weights: wq,
560                }
561            }
562            AnalogCell::GainConst { a, k } => {
563                let kq = snap(*k);
564                gate(kq)?;
565                AnalogCell::GainConst { a: *a, k: kq }
566            }
567            AnalogCell::Mul { a, b } => AnalogCell::Mul { a: *a, b: *b },
568            AnalogCell::Select { c, t, f } => AnalogCell::Select {
569                c: *c,
570                t: *t,
571                f: *f,
572            },
573            AnalogCell::Exp { a } => AnalogCell::Exp { a: *a },
574            AnalogCell::Log { a } => AnalogCell::Log { a: *a },
575            AnalogCell::Tanh { a } => AnalogCell::Tanh { a: *a },
576            AnalogCell::Sqrt { a } => AnalogCell::Sqrt { a: *a },
577            AnalogCell::Div { a, b } => AnalogCell::Div { a: *a, b: *b },
578        });
579    }
580    Ok(AnalogNetlist {
581        n_in: netlist.n_in,
582        cells,
583        out: netlist.out,
584    })
585}
586
587/// The per-net scale pass (O4): decouple net scales so one large intermediate no longer
588/// drags every net (and the multiplier reference) down with it.
589///
590/// V1 constraints, each a soundness line rather than a limitation apology:
591/// - inputs and the OUTPUT cell stay on one io scale (the converter grid is untouched);
592/// - every net touching a `Mul` (operands and output) shares one reference scale drawn from
593///   the target's realizable Vref set — the translinear normalization is exact only when all
594///   four ports agree;
595/// - a `Const`-fed cell stays on the io scale (constants are driven at the io reference);
596/// - every physical weight (math weight × exact power-of-two rescale) must still land on the
597///   realizable unit-element range after snapping — refused otherwise.
598fn per_net_scale_pass(
599    input_bounds: &[MathBound],
600    quantized_math: &AnalogNetlist,
601    bounds: &[MathBound],
602    target: &AnalogTarget,
603) -> Result<(GainSchedule, RangeCertificate), AnaLowerError> {
604    let half_span = target.half_span_v();
605    let (lo_e, hi_e) = target.scale_log2_range;
606    let n = quantized_math.cells.len();
607    let fits_one = |b: &MathBound, e: i32| -> bool {
608        let s = f64::powi(2.0, e);
609        (b.lo * s).next_down() >= -half_span && (b.hi * s).next_up() <= half_span
610    };
611    let best_for = |b: &MathBound| -> Option<i32> { (lo_e..=hi_e).rev().find(|e| fits_one(b, *e)) };
612
613    // The io scale serves the inputs and the output cell.
614    let out_cell = match quantized_math.out {
615        ASrc::Val(v) => Some(v),
616        _ => None,
617    };
618    let mut io_bounds: Vec<MathBound> = input_bounds.to_vec();
619    if let Some(o) = out_cell {
620        io_bounds.push(bounds[o]);
621    }
622    // Cells pinned to the io scale: the output and any Const-fed cell.
623    let mut pinned_io = vec![false; n];
624    if let Some(o) = out_cell {
625        pinned_io[o] = true;
626    }
627    for (i, cell) in quantized_math.cells.iter().enumerate() {
628        let has_const = match cell {
629            AnalogCell::Sum { inputs, .. } => inputs.iter().any(|x| matches!(x, ASrc::Const(_))),
630            AnalogCell::GainConst { a, .. } | AnalogCell::Mul { a, .. } => {
631                matches!(a, ASrc::Const(_))
632            }
633            AnalogCell::Select { c, t, f } => [c, t, f].iter().any(|x| matches!(x, ASrc::Const(_))),
634            AnalogCell::Exp { a }
635            | AnalogCell::Log { a }
636            | AnalogCell::Tanh { a }
637            | AnalogCell::Sqrt { a } => matches!(a, ASrc::Const(_)),
638            AnalogCell::Div { a, b } => [a, b].iter().any(|x| matches!(x, ASrc::Const(_))),
639        };
640        if has_const {
641            pinned_io[i] = true;
642        }
643        // A switch passes its branch voltage through: the cell and its branch operands must
644        // share a scale — V1 pins the whole select group to the io scale.
645        if let AnalogCell::Select { t, f, .. } = cell {
646            pinned_io[i] = true;
647            for x in [t, f] {
648                if let ASrc::Val(v) = x {
649                    pinned_io[*v] = true;
650                }
651            }
652        }
653    }
654    // Nets touching a Mul share the reference scale.
655    let mut mul_class = vec![false; n];
656    let mut mul_reads_input = false;
657    for (i, cell) in quantized_math.cells.iter().enumerate() {
658        if let AnalogCell::Mul { a, b } = cell {
659            mul_class[i] = true;
660            for x in [a, b] {
661                match x {
662                    ASrc::Val(v) => mul_class[*v] = true,
663                    ASrc::In(_) => mul_reads_input = true,
664                    ASrc::Const(_) => {}
665                }
666            }
667        }
668    }
669    let has_mul = mul_class.iter().any(|m| *m);
670
671    // Choose the reference scale from the realizable Vref set (largest first), covering every
672    // mul-class net — and the io bounds too when a Mul reads an input (io joins the class).
673    let e_m = if has_mul {
674        let mut opts: Vec<f64> = target.mul_vref_options_v.iter().map(|v| v.0).collect();
675        opts.sort_by(|a, b| b.partial_cmp(a).unwrap());
676        let candidate = opts.iter().filter_map(|v| {
677            let e = v.log2();
678            (e.fract() == 0.0).then_some(e as i32)
679        });
680        let mut chosen = None;
681        for e in candidate {
682            if e < lo_e || e > hi_e {
683                continue;
684            }
685            let class_ok = (0..n).all(|i| !mul_class[i] || fits_one(&bounds[i], e));
686            let io_ok = !mul_reads_input || io_bounds.iter().all(|b| fits_one(b, e));
687            if class_ok && io_ok {
688                chosen = Some(e);
689                break;
690            }
691        }
692        Some(chosen.ok_or_else(|| {
693            AnaLowerError::Unplaceable(AnaUnplaceable::MulReferenceUnrealizable {
694                needed_v: half_span,
695                options_v: target.mul_vref_options_v.iter().map(|v| v.0).collect(),
696                target: target.name,
697            })
698        })?)
699    } else {
700        None
701    };
702
703    // The io scale: the largest in-range fit for inputs + output (pinned to e_m if io joined
704    // the mul class).
705    let e_io = if mul_reads_input {
706        e_m.expect("mul_reads_input implies a Mul")
707    } else {
708        io_bounds
709            .iter()
710            .try_fold(hi_e, |acc, b| best_for(b).map(|e| acc.min(e)))
711            .ok_or_else(|| dynamic_range_refusal(&io_bounds, lo_e, half_span, target))?
712    };
713
714    // Per-cell assignment.
715    let mut per_net = vec![0i32; n];
716    for i in 0..n {
717        per_net[i] = if mul_class[i] {
718            let e = e_m.expect("mul class implies e_m");
719            if !fits_one(&bounds[i], e) {
720                return Err(dynamic_range_refusal(&[bounds[i]], lo_e, half_span, target));
721            }
722            e
723        } else if pinned_io[i] {
724            if !fits_one(&bounds[i], e_io) {
725                return Err(dynamic_range_refusal(&[bounds[i]], lo_e, half_span, target));
726            }
727            e_io
728        } else {
729            best_for(&bounds[i])
730                .ok_or_else(|| dynamic_range_refusal(&[bounds[i]], lo_e, half_span, target))?
731        };
732    }
733
734    // Physical weights: math weight × 2^{e_cell − e_operand}, snapped and gated.
735    let lsb = target.weight_lsb();
736    let snap = |w: f64| -> f64 { (w / lsb).round() * lsb };
737    let src_e = |x: &ASrc| -> i32 {
738        match x {
739            ASrc::Val(v) => per_net[*v],
740            _ => e_io,
741        }
742    };
743    let mut phys_cells = Vec::with_capacity(n);
744    for (i, cell) in quantized_math.cells.iter().enumerate() {
745        phys_cells.push(match cell {
746            AnalogCell::Sum { inputs, weights } => {
747                let wq: Vec<f64> = inputs
748                    .iter()
749                    .zip(weights)
750                    .map(|(x, w)| snap(w * f64::powi(2.0, per_net[i] - src_e(x))))
751                    .collect();
752                for w in &wq {
753                    if *w != 0.0 {
754                        target
755                            .require_gain_ratio(w.abs())
756                            .map_err(AnaLowerError::Unplaceable)?;
757                    }
758                }
759                AnalogCell::Sum {
760                    inputs: inputs.clone(),
761                    weights: wq,
762                }
763            }
764            AnalogCell::GainConst { a, k } => {
765                let kq = snap(k * f64::powi(2.0, per_net[i] - src_e(a)));
766                if kq != 0.0 {
767                    target
768                        .require_gain_ratio(kq.abs())
769                        .map_err(AnaLowerError::Unplaceable)?;
770                }
771                AnalogCell::GainConst { a: *a, k: kq }
772            }
773            AnalogCell::Mul { a, b } => AnalogCell::Mul { a: *a, b: *b },
774            AnalogCell::Select { c, t, f } => AnalogCell::Select {
775                c: *c,
776                t: *t,
777                f: *f,
778            },
779            AnalogCell::Exp { a } => AnalogCell::Exp { a: *a },
780            AnalogCell::Log { a } => AnalogCell::Log { a: *a },
781            AnalogCell::Tanh { a } => AnalogCell::Tanh { a: *a },
782            AnalogCell::Sqrt { a } => AnalogCell::Sqrt { a: *a },
783            AnalogCell::Div { a, b } => AnalogCell::Div { a: *a, b: *b },
784        });
785    }
786    let quantized = AnalogNetlist {
787        n_in: quantized_math.n_in,
788        cells: phys_cells,
789        out: quantized_math.out,
790    };
791
792    // The certificate: per-net volt bounds at per-net scales, judged (per-cell) by the
793    // untouched independent checker.
794    let mut per_net_bounds = Vec::with_capacity(n);
795    for (i, b) in bounds.iter().enumerate() {
796        let s_i = f64::powi(2.0, per_net[i]);
797        let (volt_lo, volt_hi) = ((b.lo * s_i).next_down(), (b.hi * s_i).next_up());
798        if volt_lo < -half_span || volt_hi > half_span {
799            return Err(dynamic_range_refusal(&[*b], lo_e, half_span, target));
800        }
801        per_net_bounds.push(NetBound {
802            math: *b,
803            volt_lo,
804            volt_hi,
805        });
806    }
807
808    let schedule = GainSchedule {
809        scale_log2: e_io,
810        out_descale_log2: -e_io,
811        mul_vref_v: f64::powi(2.0, e_m.unwrap_or(e_io)),
812        quantized,
813        per_net_scale_log2: per_net,
814    };
815    let cert = RangeCertificate {
816        input_bounds: input_bounds.to_vec(),
817        per_net: per_net_bounds,
818        rail_half_span_v: half_span,
819        noise_floor_v: 0.0,
820        min_full_scale_over_noise: 1.0,
821    };
822    Ok((schedule, cert))
823}
824
825fn dynamic_range_refusal(
826    bounds: &[MathBound],
827    lo_e: i32,
828    half_span: f64,
829    target: &AnalogTarget,
830) -> AnaLowerError {
831    let global_max = bounds.iter().map(|b| b.max_abs()).fold(0.0_f64, f64::max);
832    AnaLowerError::Unplaceable(AnaUnplaceable::DynamicRangeExceeded {
833        net: 0,
834        needed_db: (20.0 * (global_max * f64::powi(2.0, lo_e) / half_span).log10()).max(0.0),
835        available_db: 0.0,
836        target: target.name,
837    })
838}
839
840/// Convert a netlist + schedule + certificate into the INDEPENDENT checker's neutral vocabulary.
841/// The producer's only role here is faithful translation; the judgment belongs to
842/// `logicaffeine_proof::interval_replay::check_range_claim`.
843pub fn to_range_claim(
844    schedule: &GainSchedule,
845    cert: &RangeCertificate,
846) -> (
847    Vec<logicaffeine_proof::interval_replay::RCell>,
848    logicaffeine_proof::interval_replay::RangeClaim,
849) {
850    use logicaffeine_proof::interval_replay::{RCell, RSrc, RangeClaim};
851    // The checker judges the dataflow silicon actually COMPUTES — the realized-math twin
852    // (physical weights mapped back through the exact per-net rescales; identical to the
853    // quantized weights under a uniform schedule).
854    let realized = schedule.realized_math_cells();
855    let conv = |s: &ASrc| match s {
856        ASrc::In(k) => RSrc::In(*k),
857        ASrc::Val(v) => RSrc::Val(*v),
858        ASrc::Const(c) => RSrc::Const(*c),
859    };
860    let cells = realized
861        .iter()
862        .map(|c| match c {
863            AnalogCell::Sum { inputs, weights } => RCell::Sum {
864                inputs: inputs.iter().map(conv).collect(),
865                weights: weights.clone(),
866            },
867            AnalogCell::Mul { a, b } => RCell::Mul {
868                a: conv(a),
869                b: conv(b),
870            },
871            AnalogCell::GainConst { a, k } => RCell::Gain { a: conv(a), k: *k },
872            AnalogCell::Select { c, t, f } => RCell::Select {
873                c: conv(c),
874                t: conv(t),
875                f: conv(f),
876            },
877            AnalogCell::Exp { a } => RCell::Exp { a: conv(a) },
878            AnalogCell::Log { a } => RCell::Log { a: conv(a) },
879            AnalogCell::Tanh { a } => RCell::Tanh { a: conv(a) },
880            AnalogCell::Sqrt { a } => RCell::Sqrt { a: conv(a) },
881            AnalogCell::Div { a, b } => RCell::Div {
882                a: conv(a),
883                b: conv(b),
884            },
885        })
886        .collect();
887    let claim = RangeClaim {
888        input_lo: cert.input_bounds.iter().map(|b| b.lo).collect(),
889        input_hi: cert.input_bounds.iter().map(|b| b.hi).collect(),
890        scale_log2: schedule.per_net_scale_log2.clone(),
891        node_lo_v: cert.per_net.iter().map(|n| n.volt_lo).collect(),
892        node_hi_v: cert.per_net.iter().map(|n| n.volt_hi).collect(),
893        rail_half_span_v: cert.rail_half_span_v,
894        noise_floor_v: cert.noise_floor_v,
895        min_full_scale_over_noise: cert.min_full_scale_over_noise,
896    };
897    (cells, claim)
898}