Skip to main content

logicaffeine_compile/codegen_ana/
nonideal.rs

1//! The non-ideality model — sourced physics, seeded instances, and the instance evaluator.
2//!
3//! Every constant here is SOURCED or DERIVED-with-shown-arithmetic (see
4//! `benchmarks/silicon/analog/sourced-constants.md` for the full dossier with URLs and exact quotes) or
5//! loudly MODELED (a design parameter of our fabric awaiting the characterization ladder:
6//! schematic SPICE → PVT → Monte-Carlo mismatch → extraction → silicon). An invented physical
7//! constant is none of these and ships never; `analog_provenance_lock` pins both the values
8//! and their source strings.
9//!
10//! An [`InstanceSample`] is one manufactured die: per-leg gain errors (unit-element/programming
11//! accuracy plus the Pelgrom DISTANCE term over the placed operand separation) and per-tile
12//! output offsets and a frozen noise realization — drawn from fixed-seed SplitMix64 + Box–Muller,
13//! so every Monte-Carlo battery is deterministic forever.
14
15use logicaffeine_base::si::Volts;
16
17use super::cell::{ASrc, AnalogCell};
18use super::gain::GainSchedule;
19use super::place::AnalogPlacement;
20use super::target::AnalogTarget;
21
22/// The characterized non-ideality figures a physical target carries.
23#[derive(Clone, Debug, PartialEq)]
24pub struct NonIdealityModel {
25    /// Fractional 1σ error of a programmed weight (unit-element/programming accuracy class).
26    pub weight_gain_sigma: f64,
27    /// Additional fractional 1σ gain error per tile-pitch of operand separation — the Pelgrom
28    /// distance-gradient lever the placer optimizes against.
29    pub gm_distance_sigma_per_pitch: f64,
30    /// Per-tile output-referred offset 1σ, volts.
31    pub offset_sigma_v: Volts,
32    /// Sampled-noise floor (kT/C class), volts rms, per tile.
33    pub noise_floor_v: Volts,
34    /// Additional fractional 1σ gain error between tiles on SEPARATELY MANUFACTURED tiers —
35    /// a flat categorical ceiling, not a distance gradient: dies do not share a Pelgrom
36    /// field, so cross-tier matching gets the class figure, full stop.
37    pub cross_tier_gain_sigma: f64,
38    /// Fractional gain drift per kelvin of TEMPERATURE DIFFERENCE between a tile and its
39    /// operand tile — matching cares about gradients, so the coupled term prices
40    /// `|ΔT_cell − ΔT_operand|`, not absolute temperature.
41    pub gain_tempco_per_k: f64,
42    /// Absolute output-referred offset drift, volts per kelvin of a tile's rise.
43    pub offset_tempco_v_per_k: f64,
44    /// The per-tile offset RESIDUAL after chopping, volts 1σ — intra-pair drift over one
45    /// chop period (the frozen component cancels exactly; this is what survives).
46    pub chop_residual_v: Volts,
47    /// Sourcing statement — pinned verbatim by `analog_provenance_lock`.
48    pub provenance: &'static str,
49}
50
51/// The SKY130-class model.
52///
53/// SOURCE (each figure, per `benchmarks/silicon/analog/sourced-constants.md`):
54/// - `weight_gain_sigma = 0.01` — Hasler, "Large-Scale Field-Programmable Analog Arrays",
55///   Proc. IEEE 108(8) 2020: programmed analog parameters at "better than 1% accuracy"
56///   over six decades; we take the 1% envelope as the 1σ class (conservative round-up).
57/// - `offset_sigma_v = 2.8e-3` — DERIVED from the SkyWater PDK's own Monte-Carlo mismatch
58///   model (`sky130_fd_pr__nfet_01v8__vth0_slope = 3.356e-03 V·µm`, pfet 5.856e-03) applied to
59///   a stated 5T-OTA example (10µ/0.5µ pair, gm ratio 0.5): σ(V_os) ≈ 2.8 mV. A model-derived
60///   example, NOT a published silicon figure (none exists for sky130 — the dossier says so).
61/// - `noise_floor_v = 64.4e-6` — kT/C at C = 1 pF, T = 300 K: √(4.142e-21/1e-12) = 64.4 µV rms
62///   (Johnson–Nyquist, textbook formula, arithmetic in the dossier).
63/// - `gm_distance_sigma_per_pitch = 0.002` — MODELED design parameter (0.2%/pitch): the
64///   Pelgrom distance term exists (Pelgrom et al., JSSC 1989) but no sky130 distance
65///   coefficient is published; this figure parameterizes the placement machinery until the
66///   characterization ladder replaces it, and is labeled MODELED, never silicon.
67pub fn sky130_nonideal() -> NonIdealityModel {
68    NonIdealityModel {
69        weight_gain_sigma: 0.01,
70        gm_distance_sigma_per_pitch: 0.002,
71        offset_sigma_v: Volts(2.8e-3),
72        noise_floor_v: Volts(64.4e-6),
73        cross_tier_gain_sigma: 0.02,
74        gain_tempco_per_k: 1.0e-3,
75        offset_tempco_v_per_k: 1.0e-5,
76        chop_residual_v: Volts(50.0e-6),
77        provenance: "weight_gain_sigma: Hasler Proc. IEEE 2020, FG programming better than 1% \
78                     (SOURCED, conservative). offset_sigma_v: DERIVED from SkyWater sky130_fd_pr \
79                     vth0_slope mismatch model (3.356e-03 V.um nfet / 5.856e-03 pfet) on a stated \
80                     5T example — model-derived, not silicon. noise_floor_v: kT/C at 1 pF, 300 K \
81                     = 64.4 uV rms (Johnson-Nyquist). gm_distance_sigma_per_pitch: MODELED design \
82                     parameter (Pelgrom 1989 distance term; no published sky130 coefficient), \
83                     replaced by characterization, never presented as silicon. \
84                     cross_tier_gain_sigma: MODELED design parameter (2%): separately \
85                     manufactured tiers share no Pelgrom field — a flat categorical class \
86                     figure, never a distance law. gain_tempco_per_k (1e-3/K) and \
87                     offset_tempco_v_per_k (10 uV/K): MODELED drift-class design parameters \
88                     (amplifier-offset-drift textbook class), priced against the CERTIFIED \
89                     thermal map, never silicon. chop_residual_v (50 uV): MODELED intra-pair \
90                     drift class surviving the chop average — the frozen offset cancels \
91                     exactly by construction. Full dossier: \
92                     benchmarks/silicon/analog/sourced-constants.md",
93    }
94}
95
96/// The k·σ envelope every budget term and frozen draw uses. At k = 6 a fixed-seed battery is
97/// deterministically green forever (p(>6σ) ≈ 2e-9 per draw, evaluated once at authoring time).
98pub const KSIGMA: f64 = 6.0;
99
100fn splitmix(state: &mut u64) -> u64 {
101    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
102    let mut z = *state;
103    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
104    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
105    z ^ (z >> 31)
106}
107
108fn uniform(state: &mut u64) -> f64 {
109    ((splitmix(state) >> 11) as f64 + 0.5) / (1u64 << 53) as f64
110}
111
112/// Box–Muller over SplitMix64, CLAMPED to the ±KSIGMA envelope the budget covers (a beyond-
113/// envelope draw would be a budget bug, not a physics insight; clamping keeps fixed seeds
114/// deterministic AND inside the modeled class).
115fn gaussian(state: &mut u64) -> f64 {
116    let (u1, u2) = (uniform(state), uniform(state));
117    let g = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
118    g.clamp(-KSIGMA, KSIGMA)
119}
120
121/// One manufactured die: per-cell per-leg fractional gain errors, per-cell output offsets
122/// (volts), and a frozen per-cell noise realization (volts).
123#[derive(Clone, Debug)]
124pub struct InstanceSample {
125    pub leg_gain_err: Vec<Vec<f64>>,
126    pub offset_v: Vec<f64>,
127    pub noise_v: Vec<f64>,
128}
129
130fn legs_of(cell: &AnalogCell) -> usize {
131    match cell {
132        AnalogCell::Sum { inputs, .. } => inputs.len(),
133        AnalogCell::Mul { .. } | AnalogCell::GainConst { .. } => 1,
134        // A switch has no programmed-gain leg; its comparator offset and noise still apply.
135        AnalogCell::Select { .. } => 0,
136        // Translinear tiles carry one bias/gain-error leg (multiplicative on the output law).
137        AnalogCell::Exp { .. }
138        | AnalogCell::Log { .. }
139        | AnalogCell::Tanh { .. }
140        | AnalogCell::Sqrt { .. }
141        | AnalogCell::Div { .. } => 1,
142    }
143}
144
145/// Physical separation of a tile from its operand — the Pelgrom law's input. Same-tier
146/// separation is a distance (the gradient applies); separately manufactured tiers are a
147/// CATEGORY, not a distance.
148#[derive(Clone, Copy, Debug, PartialEq)]
149pub enum Separation {
150    SameTier { pitches: f64 },
151    CrossTier,
152}
153
154/// THE shared separation law every budget composes with: programming accuracy plus the
155/// distance gradient in-tier; programming accuracy plus the flat categorical ceiling across
156/// tiers. 2D [`leg_sigma`] and the 3D stacked budget are both thin wrappers over this.
157pub fn separation_sigma(model: &NonIdealityModel, sep: Separation) -> f64 {
158    match sep {
159        Separation::SameTier { pitches } => {
160            model.weight_gain_sigma + model.gm_distance_sigma_per_pitch * pitches
161        }
162        Separation::CrossTier => model.weight_gain_sigma + model.cross_tier_gain_sigma,
163    }
164}
165
166/// The operand cell a leg reads, if it is a placed cell (DAC inputs / references arrive on
167/// dedicated trunks and carry no separation).
168pub(crate) fn leg_operand(cells: &[AnalogCell], cell_idx: usize, leg: usize) -> Option<usize> {
169    let operand = match &cells[cell_idx] {
170        AnalogCell::Sum { inputs, .. } => inputs[leg],
171        AnalogCell::GainConst { a, .. } => *a,
172        AnalogCell::Mul { a, .. } => *a,
173        AnalogCell::Select { .. } => return None,
174        AnalogCell::Exp { a }
175        | AnalogCell::Log { a }
176        | AnalogCell::Tanh { a }
177        | AnalogCell::Sqrt { a }
178        | AnalogCell::Div { a, .. } => *a,
179    };
180    match operand {
181        ASrc::Val(v) => Some(v),
182        _ => None,
183    }
184}
185
186/// The per-leg 1σ gain-error class over a 2D placement — the shared law at `SameTier`.
187pub fn leg_sigma(
188    cell_idx: usize,
189    leg: usize,
190    cells: &[AnalogCell],
191    placement: &AnalogPlacement,
192    model: &NonIdealityModel,
193) -> f64 {
194    let pitches = leg_operand(cells, cell_idx, leg)
195        .map(|v| placement.pitch_distance(cell_idx, v))
196        .unwrap_or(0.0);
197    separation_sigma(model, Separation::SameTier { pitches })
198}
199
200/// Draw one die. Deterministic in `seed`.
201pub fn sample_instance(
202    cells: &[AnalogCell],
203    placement: &AnalogPlacement,
204    model: &NonIdealityModel,
205    seed: u64,
206) -> InstanceSample {
207    let mut st = seed ^ 0xD1E5_ED0F_ABBA_C0DE;
208    let mut leg_gain_err = Vec::with_capacity(cells.len());
209    let mut offset_v = Vec::with_capacity(cells.len());
210    let mut noise_v = Vec::with_capacity(cells.len());
211    for (i, cell) in cells.iter().enumerate() {
212        let legs = (0..legs_of(cell))
213            .map(|l| gaussian(&mut st) * leg_sigma(i, l, cells, placement, model))
214            .collect();
215        leg_gain_err.push(legs);
216        offset_v.push(gaussian(&mut st) * model.offset_sigma_v.0);
217        noise_v.push(gaussian(&mut st) * model.noise_floor_v.0);
218    }
219    InstanceSample {
220        leg_gain_err,
221        offset_v,
222        noise_v,
223    }
224}
225
226/// Evaluate one die: the volt-domain converter path over the QUANTIZED netlist with the
227/// instance's gain errors, offsets, and frozen noise applied per tile.
228pub fn eval_analog_instance(
229    schedule: &GainSchedule,
230    target: &AnalogTarget,
231    inst: &InstanceSample,
232    inputs: &[f64],
233) -> f64 {
234    let netlist = &schedule.quantized;
235    assert_eq!(inputs.len(), netlist.n_in);
236    let grid = super::eval::ConverterGrid::of(schedule, target);
237    let s = grid.s;
238    let vin: Vec<f64> = inputs.iter().map(|x| grid.dac_q(*x) * s).collect();
239    let mut vals: Vec<f64> = Vec::with_capacity(netlist.cells.len());
240    let src = |sc: &ASrc, vals: &[f64]| -> f64 {
241        match sc {
242            ASrc::In(k) => vin[*k],
243            ASrc::Val(v) => vals[*v],
244            ASrc::Const(c) => c * s,
245        }
246    };
247    for (i, cell) in netlist.cells.iter().enumerate() {
248        let disturb = inst.offset_v[i] + inst.noise_v[i];
249        let v = match cell {
250            AnalogCell::Sum {
251                inputs: xs,
252                weights,
253            } => {
254                xs.iter()
255                    .zip(weights)
256                    .enumerate()
257                    .map(|(l, (x, w))| w * (1.0 + inst.leg_gain_err[i][l]) * src(x, &vals))
258                    .sum::<f64>()
259                    + disturb
260            }
261            AnalogCell::Mul { a, b } => {
262                (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals) * src(b, &vals)
263                    / schedule.mul_vref_v
264                    + disturb
265            }
266            AnalogCell::GainConst { a, k } => {
267                (1.0 + inst.leg_gain_err[i][0]) * k * src(a, &vals) + disturb
268            }
269            AnalogCell::Select { c, t, f } => {
270                let decided = src(c, &vals) + inst.offset_v[i];
271                let branch = if decided < 0.0 {
272                    src(t, &vals)
273                } else {
274                    src(f, &vals)
275                };
276                branch + inst.noise_v[i]
277            }
278            AnalogCell::Exp { a } => {
279                (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals).exp() + disturb
280            }
281            AnalogCell::Log { a } => (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals).ln() + disturb,
282            AnalogCell::Tanh { a } => {
283                (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals).tanh() + disturb
284            }
285            AnalogCell::Sqrt { a } => {
286                (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals).sqrt() + disturb
287            }
288            AnalogCell::Div { a, b } => {
289                (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals) / src(b, &vals) + disturb
290            }
291        };
292        vals.push(v);
293    }
294    let out_v = src(&netlist.out, &vals);
295    grid.adc_q(out_v / s)
296}
297
298/// Evaluate one die CHOPPED: two frames — normal and polarity-swapped (each frozen offset
299/// enters negated) — with FRESH noise per frame (drawn from `frame_seed`), averaged by the
300/// digital side. The frozen offsets cancel EXACTLY; what survives is a deterministic
301/// intra-pair residual draw (the model's `chop_residual_v` class) and √2-averaged noise.
302pub fn eval_analog_instance_chopped(
303    schedule: &GainSchedule,
304    target: &AnalogTarget,
305    inst: &InstanceSample,
306    model: &NonIdealityModel,
307    inputs: &[f64],
308    frame_seed: u64,
309) -> f64 {
310    let netlist = &schedule.quantized;
311    assert_eq!(inputs.len(), netlist.n_in);
312    let grid = super::eval::ConverterGrid::of(schedule, target);
313    let s = grid.s;
314    let vin: Vec<f64> = inputs.iter().map(|x| grid.dac_q(*x) * s).collect();
315    let mut st = frame_seed ^ 0xC0FF_EEC0_5EED_0A11;
316    let run_frame = |polarity: f64, st: &mut u64| -> f64 {
317        let mut vals: Vec<f64> = Vec::with_capacity(netlist.cells.len());
318        let src = |sc: &ASrc, vals: &[f64]| -> f64 {
319            match sc {
320                ASrc::In(k) => vin[*k],
321                ASrc::Val(v) => vals[*v],
322                ASrc::Const(c) => c * s,
323            }
324        };
325        for (i, cell) in netlist.cells.iter().enumerate() {
326            // Frozen offset rides the polarity; residual drift and fresh noise do not chop.
327            let residual = gaussian(st) * model.chop_residual_v.0;
328            let noise = gaussian(st) * model.noise_floor_v.0;
329            let disturb = polarity * inst.offset_v[i] + residual + noise;
330            let v = match cell {
331                AnalogCell::Sum {
332                    inputs: xs,
333                    weights,
334                } => {
335                    xs.iter()
336                        .zip(weights)
337                        .enumerate()
338                        .map(|(l, (x, w))| w * (1.0 + inst.leg_gain_err[i][l]) * src(x, &vals))
339                        .sum::<f64>()
340                        + disturb
341                }
342                AnalogCell::Mul { a, b } => {
343                    (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals) * src(b, &vals)
344                        / schedule.mul_vref_v
345                        + disturb
346                }
347                AnalogCell::GainConst { a, k } => {
348                    (1.0 + inst.leg_gain_err[i][0]) * k * src(a, &vals) + disturb
349                }
350                AnalogCell::Select { c, t, f } => {
351                    let decided = src(c, &vals) + polarity * inst.offset_v[i];
352                    let branch = if decided < 0.0 {
353                        src(t, &vals)
354                    } else {
355                        src(f, &vals)
356                    };
357                    branch + noise
358                }
359                AnalogCell::Exp { a } => {
360                    (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals).exp() + disturb
361                }
362                AnalogCell::Log { a } => {
363                    (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals).ln() + disturb
364                }
365                AnalogCell::Tanh { a } => {
366                    (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals).tanh() + disturb
367                }
368                AnalogCell::Sqrt { a } => {
369                    (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals).sqrt() + disturb
370                }
371                AnalogCell::Div { a, b } => {
372                    (1.0 + inst.leg_gain_err[i][0]) * src(a, &vals) / src(b, &vals) + disturb
373                }
374            };
375            vals.push(v);
376        }
377        match netlist.out {
378            ASrc::In(k) => vin[k],
379            ASrc::Val(v) => vals[v],
380            ASrc::Const(c) => c * s,
381        }
382    };
383    let a = run_frame(1.0, &mut st);
384    let b = run_frame(-1.0, &mut st);
385    grid.adc_q((a + b) / 2.0 / s)
386}