Skip to main content

logicaffeine_compile/codegen_ana/
integrator.rs

1//! The ZOH integrator lane — counted Euler loops as OTA-C integration.
2//!
3//! The recognized idiom is explicit Euler: `Set x to x + <h·f(state)>` inside a counted
4//! `Repeat`, possibly over SEVERAL mutable states (a cross-coupled oscillator is two). Each
5//! state lives in a sample-and-hold; each frame the OTA legs integrate the held inputs for the
6//! settling window and the S&H commits — zero-order hold, so the per-frame update is EXACT
7//! arithmetic and the certified semantics is the VM's Euler trajectory, not an ODE-solver
8//! approximation. The physical gate is the OTA rate identity: a programmed per-frame gain `c`
9//! over frame time `T` demands `gm/C = |c|/T`, which must sit in the target's realizable
10//! `integrator_k_range` — refused typed otherwise.
11//!
12//! Structurally this is the reduction lane without a stream: state parameters enter through the
13//! DAC once (frame 0), the trajectory never leaves the analog plane, and one ADC reads the
14//! result after the declared frame count.
15
16use std::collections::BTreeMap;
17
18use crate::ast::stmt::{Stmt, TypeExpr};
19use crate::codegen_sva::fabric_rtl::{loop_fabric_from_source, LoopCell, Src};
20
21use super::cell::{ASrc, AnalogCell, AnalogNetlist};
22use super::control::{ControlProgram, CtrlStep};
23use super::error_budget::{outward_sum, ErrorSource, ErrorTerm, OutputBudget};
24use super::gain::{
25    propagate_bounds, quantize_weights, select_scale, src_bound_of, GainSchedule, MathBound,
26    NetBound, RangeCertificate,
27};
28use super::lower::{map_float_cell, source_without_range_contracts, MappedCell, NetRef};
29use super::target::{AnaUnplaceable, AnalogCapability, AnalogCellKind, AnalogTarget};
30use super::{assumed_ranges_in, AnaLowerError, AssumedRange};
31
32/// A state's initial value: a function parameter (DAC-driven at frame 0) or a constant.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub enum InitSrc {
35    Param(usize),
36    Const(f64),
37}
38
39/// A lowered ZOH integrator. Tick-input convention: `In(s)` = held state `s`; `outs[s]` = the
40/// next value of state `s`; the declared result is `outs`-resolved `result_state`.
41#[derive(Clone, Debug, PartialEq)]
42pub struct AnalogIntegrator {
43    pub tick: AnalogNetlist,
44    pub outs: Vec<ASrc>,
45    pub init: Vec<InitSrc>,
46    pub frames: usize,
47    pub result_state: usize,
48    /// Declared ranges for the DAC-driven initial states, in parameter order.
49    pub param_ranges: Vec<AssumedRange>,
50    pub control: ControlProgram,
51}
52
53/// Lower a counted Euler loop over float state(s) to a ZOH integrator on `target`.
54pub fn analog_integrator_from_source(
55    src: &str,
56    fn_name: &str,
57    target: &AnalogTarget,
58) -> Result<AnalogIntegrator, AnaLowerError> {
59    let param_ranges = check_integrator_contracts(src, fn_name)?;
60
61    let twin = source_without_range_contracts(src);
62    let lf = loop_fabric_from_source(&twin, fn_name);
63
64    if lf.seq_input.is_some() || lf.seq_heap_ptr.is_some() {
65        return Err(AnaLowerError::UnloweredOp(
66            "a streamed loop belongs to the reduction lane".to_string(),
67        ));
68    }
69    if !lf.epilogue.is_empty() || !lf.prologue_heap.is_empty() || !lf.invariants.is_empty() {
70        return Err(AnaLowerError::UnloweredOp(
71            "loop epilogue/prologue/invariants are not yet integrated".to_string(),
72        ));
73    }
74    let frames = match (lf.lo, lf.hi) {
75        (Src::Const(lo), Src::Const(hi)) if hi >= lo => (hi - lo + 1) as usize,
76        _ => {
77            return Err(AnaLowerError::UnloweredOp(
78                "the trip count must be a compile-time constant (the frame schedule is static)"
79                    .to_string(),
80            ))
81        }
82    };
83    let n_state = lf.n_state;
84
85    // Initial values: parameters (DAC at frame 0) or constants.
86    let mut init = Vec::with_capacity(n_state);
87    for s in &lf.init {
88        init.push(match s {
89            Src::In(k) => InitSrc::Param(*k),
90            Src::Const(bits) => InitSrc::Const(f64::from_bits(*bits as u64)),
91            Src::Val(_) => {
92                return Err(AnaLowerError::UnloweredOp(
93                    "a computed initial state is not yet integrated".to_string(),
94                ))
95            }
96        });
97    }
98    for (s, i) in init.iter().enumerate() {
99        if let InitSrc::Param(k) = i {
100            if *k >= param_ranges.len() {
101                return Err(AnaLowerError::UnboundedInput(format!(
102                    "state {s} initializes from parameter {k}, which has no declared range"
103                )));
104            }
105        }
106    }
107
108    // Map the body: held states are the tick inputs; the induction variable must not be a value.
109    // Body-input convention: In(0..n_state) = states, In(n_state) = the induction variable.
110    for cell in &lf.body {
111        let illegal = |s: &Src| matches!(s, Src::In(k) if *k >= n_state);
112        let bad = match cell {
113            LoopCell::Arith { a, b, .. }
114            | LoopCell::Func { a, b, .. }
115            | LoopCell::VectorArith { a, b, .. } => illegal(a) || illegal(b),
116            LoopCell::Select { ca, cb, t, e, .. } => {
117                illegal(ca) || illegal(cb) || illegal(t) || illegal(e)
118            }
119            _ => true, // Mem/Heap in a counted integrator body has no analog home
120        };
121        if bad {
122            return Err(AnaLowerError::UnloweredOp(
123                "the induction variable / memory used inside an integrator body".to_string(),
124            ));
125        }
126    }
127    let mut map: BTreeMap<usize, NetRef> = BTreeMap::new();
128    let mut cells: Vec<AnalogCell> = Vec::new();
129    let resolve_with = |map: &BTreeMap<usize, NetRef>, s: &Src| -> ASrc {
130        match s {
131            Src::In(k) => ASrc::In(*k), // held state k
132            Src::Const(bits) => ASrc::Const(f64::from_bits(*bits as u64)),
133            Src::Val(v) => match map.get(v) {
134                Some(NetRef::Cell(c)) => ASrc::Val(*c),
135                Some(NetRef::Const(f)) => ASrc::Const(*f),
136                None => ASrc::Val(usize::MAX),
137            },
138        }
139    };
140    for (i, cell) in lf.body.iter().enumerate() {
141        let resolve = |s: &Src| resolve_with(&map, s);
142        let mapped = match map_float_cell(cell, &resolve)? {
143            MappedCell::Tile(t) => NetRef::Cell(push(&mut cells, t)),
144            MappedCell::Tiles(ts) => NetRef::Cell(super::lower::push_tiles(&mut cells, ts)),
145            MappedCell::Fold(v) => NetRef::Const(v),
146        };
147        map.insert(i, mapped);
148    }
149    let outs: Vec<ASrc> = lf.next.iter().map(|s| resolve_with(&map, s)).collect();
150    if outs.iter().any(|o| matches!(o, ASrc::Val(usize::MAX))) {
151        return Err(AnaLowerError::UnloweredOp(
152            "a state update references an unlowered value".to_string(),
153        ));
154    }
155    let tick = AnalogNetlist {
156        n_in: n_state,
157        cells,
158        out: outs[lf.result_state],
159    };
160
161    // The OTA rate identity: every programmed per-frame gain must be realizable as gm/C over
162    // the settling window.
163    let t_frame_s = target.settle_ns.0 * 1.0e-9;
164    for cell in &tick.cells {
165        if let AnalogCell::GainConst { k, .. } = cell {
166            if *k != 0.0 {
167                target
168                    .require_integrator_rate(k.abs() / t_frame_s)
169                    .map_err(AnaLowerError::Unplaceable)?;
170            }
171        }
172    }
173
174    gate_integrator_on_target(&tick, n_state, target)?;
175
176    let control = ControlProgram {
177        // Frame 0 loads the initial states; every frame settles and commits.
178        frame: vec![CtrlStep::Settle, CtrlStep::SampleHold],
179        frames,
180        epilogue: vec![CtrlStep::ReadAdc { ch: 0 }],
181        tick_ns: target.settle_ns.0,
182    };
183    Ok(AnalogIntegrator {
184        tick,
185        outs,
186        init,
187        frames,
188        result_state: lf.result_state,
189        param_ranges,
190        control,
191    })
192}
193
194fn push(cells: &mut Vec<AnalogCell>, c: AnalogCell) -> usize {
195    cells.push(c);
196    cells.len() - 1
197}
198
199/// Contract gates: every float parameter carries a declared range (they are the DAC-driven
200/// initial states).
201fn check_integrator_contracts(
202    src: &str,
203    fn_name: &str,
204) -> Result<Vec<AssumedRange>, AnaLowerError> {
205    crate::ui_bridge::with_parsed_program(src, |parsed, interner| {
206        let (stmts, _types, _pol) = match parsed {
207            Ok(t) => t,
208            Err(advice) => {
209                return Err(AnaLowerError::UnloweredOp(format!(
210                    "parse failed: {advice}"
211                )))
212            }
213        };
214        let Some((params, body)) = stmts.iter().find_map(|s| match s {
215            Stmt::FunctionDef {
216                name, params, body, ..
217            } if interner.resolve(*name) == fn_name => Some((params, *body)),
218            _ => None,
219        }) else {
220            return Err(AnaLowerError::UnloweredOp(format!(
221                "function `{fn_name}` not found"
222            )));
223        };
224        let is_float = |t: &TypeExpr<'_>| {
225            matches!(t, TypeExpr::Primitive(s) | TypeExpr::Named(s)
226                if matches!(interner.resolve(*s), "Float" | "Real"))
227        };
228        if !params.iter().all(|(_, t)| is_float(t)) || params.is_empty() {
229            return Err(AnaLowerError::NoAnalogRegion);
230        }
231        let ranges = assumed_ranges_in(body, interner);
232        let mut out = Vec::with_capacity(params.len());
233        for (p, _) in params.iter() {
234            let pname = interner.resolve(*p);
235            let Some(r) = ranges.iter().find(|r| r.var == pname) else {
236                return Err(AnaLowerError::UnboundedInput(format!(
237                    "parameter `{pname}` has no declared range (`Assume {pname} is between A and B.`)"
238                )));
239            };
240            out.push(r.clone());
241        }
242        Ok(out)
243    })
244}
245
246fn gate_integrator_on_target(
247    tick: &AnalogNetlist,
248    n_state: usize,
249    target: &AnalogTarget,
250) -> Result<(), AnaLowerError> {
251    let mut need = vec![
252        AnalogCapability::Cell(AnalogCellKind::Integrate),
253        AnalogCapability::Cell(AnalogCellKind::SampleHold),
254        AnalogCapability::DacChannels(n_state.max(1)),
255        AnalogCapability::AdcChannels(1),
256    ];
257    for cell in &tick.cells {
258        need.push(AnalogCapability::Cell(cell.kind()));
259    }
260    for cap in need {
261        if !target.provides(cap) {
262            return Err(AnaLowerError::Unplaceable(
263                AnaUnplaceable::MissingCapability {
264                    needed: cap,
265                    target: target.name,
266                },
267            ));
268        }
269    }
270    let n_mul = tick
271        .cells
272        .iter()
273        .filter(|c| matches!(c, AnalogCell::Mul { .. }))
274        .count();
275    if n_mul > 0 && !target.provides(AnalogCapability::MulTiles(n_mul)) {
276        return Err(AnaLowerError::Unplaceable(
277            AnaUnplaceable::MissingCapability {
278                needed: AnalogCapability::MulTiles(n_mul),
279                target: target.name,
280            },
281        ));
282    }
283    if tick.cells.len() > target.rows * target.cols {
284        return Err(AnaLowerError::Unplaceable(AnaUnplaceable::TooSmall {
285            needed_cells: tick.cells.len(),
286            target_rows: target.rows,
287            target_cols: target.cols,
288            target: target.name,
289        }));
290    }
291    Ok(())
292}
293
294/// One frame's next-state vector over given cells/outs (shared by the evaluators).
295fn step(cells_src: &AnalogNetlist, outs: &[ASrc], state: &[f64]) -> Vec<f64> {
296    let mut vals = Vec::with_capacity(cells_src.cells.len());
297    for cell in &cells_src.cells {
298        let v = cell.eval(state, &vals);
299        vals.push(v);
300    }
301    outs.iter().map(|o| o.eval(state, &vals)).collect()
302}
303
304fn init_state(r: &AnalogIntegrator, params: &[f64]) -> Vec<f64> {
305    r.init
306        .iter()
307        .map(|i| match i {
308            InitSrc::Param(k) => params[*k],
309            InitSrc::Const(c) => *c,
310        })
311        .collect()
312}
313
314/// Ideal trajectory: the VM's Euler loop, frame by frame (bit-exact — same f64 ops in order).
315pub fn eval_analog_integrated(r: &AnalogIntegrator, params: &[f64]) -> f64 {
316    let mut state = init_state(r, params);
317    for _ in 0..r.frames {
318        state = step(&r.tick, &r.outs, &state);
319    }
320    state[r.result_state]
321}
322
323/// The physical trajectory: initial states DAC-quantized once, the whole trajectory continuous
324/// in the S&H (weight-quantized tick), one ADC at the end.
325pub fn eval_analog_integrated_quantized(
326    r: &AnalogIntegrator,
327    schedule: &GainSchedule,
328    target: &AnalogTarget,
329    params: &[f64],
330) -> f64 {
331    let grid = super::eval::ConverterGrid::of(schedule, target);
332    let mut state: Vec<f64> = init_state(r, params)
333        .into_iter()
334        .zip(&r.init)
335        .map(|(v, i)| match i {
336            InitSrc::Param(_) => grid.dac_q(v),
337            InitSrc::Const(_) => v, // a programmed reference, not a DAC word
338        })
339        .collect();
340    for _ in 0..r.frames {
341        state = step(&schedule.quantized, &r.outs, &state);
342    }
343    grid.adc_q(state[r.result_state])
344}
345
346/// Schedule + certify: iterate the interval trajectory over the declared frames, hull per net
347/// and per state, choose the block scale over everything, and emit the certificate.
348pub fn integrator_scale_pass(
349    r: &AnalogIntegrator,
350    target: &AnalogTarget,
351) -> Result<(GainSchedule, RangeCertificate), AnaLowerError> {
352    let quantized = quantize_weights(&r.tick, target)?;
353    let mut state: Vec<MathBound> = r
354        .init
355        .iter()
356        .map(|i| match i {
357            InitSrc::Param(k) => MathBound {
358                lo: r.param_ranges[*k].lo,
359                hi: r.param_ranges[*k].hi,
360            },
361            InitSrc::Const(c) => MathBound { lo: *c, hi: *c },
362        })
363        .collect();
364    let mut state_hulls = state.clone();
365    let mut hulls: Vec<MathBound> = vec![
366        MathBound {
367            lo: f64::INFINITY,
368            hi: f64::NEG_INFINITY
369        };
370        quantized.cells.len()
371    ];
372    for _ in 0..r.frames {
373        let bounds = propagate_bounds(&quantized.cells, &state)?;
374        for (h, b) in hulls.iter_mut().zip(&bounds) {
375            h.lo = h.lo.min(b.lo);
376            h.hi = h.hi.max(b.hi);
377        }
378        let next: Vec<MathBound> = r
379            .outs
380            .iter()
381            .map(|o| src_bound_of(o, &state, &bounds))
382            .collect();
383        for (h, b) in state_hulls.iter_mut().zip(&next) {
384            h.lo = h.lo.min(b.lo);
385            h.hi = h.hi.max(b.hi);
386        }
387        if next.iter().any(|b| !(b.lo.is_finite() && b.hi.is_finite())) {
388            return Err(AnaLowerError::UnloweredOp(
389                "state range diverges over the declared frame count".to_string(),
390            ));
391        }
392        state = next;
393    }
394    for h in hulls.iter_mut() {
395        if h.lo > h.hi {
396            *h = MathBound { lo: 0.0, hi: 0.0 };
397        }
398    }
399
400    let has_mul = quantized
401        .cells
402        .iter()
403        .any(|c| matches!(c, AnalogCell::Mul { .. }));
404    let input_bounds = state_hulls;
405    let all: Vec<MathBound> = input_bounds.iter().chain(hulls.iter()).copied().collect();
406    let scale_log2 = select_scale(&all, has_mul, target)?;
407    let s = f64::powi(2.0, scale_log2);
408    let per_net = hulls
409        .iter()
410        .map(|b| NetBound {
411            math: *b,
412            volt_lo: (b.lo * s).next_down(),
413            volt_hi: (b.hi * s).next_up(),
414        })
415        .collect();
416    let per_net_scale_log2 = vec![scale_log2; quantized.cells.len()];
417    let schedule = GainSchedule {
418        scale_log2,
419        out_descale_log2: -scale_log2,
420        mul_vref_v: s,
421        quantized,
422        per_net_scale_log2,
423    };
424    let cert = RangeCertificate {
425        input_bounds,
426        per_net,
427        rail_half_span_v: target.half_span_v(),
428        noise_floor_v: 0.0,
429        min_full_scale_over_noise: 1.0,
430    };
431    Ok((schedule, cert))
432}
433
434/// The iterated integrator budget: the shared ε engine run once per frame over the held
435/// state vector — initial DAC errors on the parameter states carry through every frame, the
436/// weight-snap deviation injects per frame, one ADC half-LSB lands at the end.
437pub fn integrator_error_budget(
438    r: &AnalogIntegrator,
439    cert: &RangeCertificate,
440    schedule: &GainSchedule,
441    target: &AnalogTarget,
442) -> OutputBudget {
443    use super::error_budget::{eval_rounding_guard, half_lsb, out_lanes, propagate_eps_pass};
444    let dac_eps = half_lsb(cert, schedule, target.dac_bits);
445    let adc_eps = half_lsb(cert, schedule, target.adc_bits);
446    let bound_of = |src: &ASrc| -> MathBound {
447        match src {
448            ASrc::In(k) => cert.input_bounds[*k],
449            ASrc::Val(v) => cert.per_net[*v].math,
450            ASrc::Const(c) => MathBound { lo: *c, hi: *c },
451        }
452    };
453    // Lanes: 0 = DAC (parameter states), 1 = weight quant, 2 = Mul interaction — per state.
454    let mut state_eps: Vec<Vec<f64>> = r
455        .init
456        .iter()
457        .map(|i| match i {
458            InitSrc::Param(_) => vec![dac_eps, 0.0, 0.0],
459            InitSrc::Const(_) => vec![0.0; 3],
460        })
461        .collect();
462    for _ in 0..r.frames {
463        let carried = state_eps.clone();
464        let input_eps = |src: &ASrc, lane: usize| -> f64 {
465            match src {
466                ASrc::In(k) => carried[*k][lane],
467                _ => 0.0,
468            }
469        };
470        let input_total = |src: &ASrc| -> f64 {
471            match src {
472                ASrc::In(k) => outward_sum(carried[*k].iter().copied()),
473                _ => 0.0,
474            }
475        };
476        let pass = propagate_eps_pass(
477            &r.tick.cells,
478            &schedule.quantized.cells,
479            &bound_of,
480            3,
481            1,
482            2,
483            &input_eps,
484            &input_total,
485        );
486        state_eps = r
487            .outs
488            .iter()
489            .map(|o| out_lanes(&pass, o, 3, &input_eps))
490            .collect();
491    }
492
493    let out_eps = &state_eps[r.result_state];
494    let mut terms = Vec::new();
495    if out_eps[0] > 0.0 {
496        terms.push(ErrorTerm {
497            eps_math: out_eps[0],
498            source: ErrorSource::DacQuant {
499                input: 0,
500                bits: target.dac_bits,
501            },
502        });
503    }
504    if out_eps[1] > 0.0 {
505        terms.push(ErrorTerm {
506            eps_math: out_eps[1],
507            source: ErrorSource::WeightQuant,
508        });
509    }
510    if out_eps[2] > 0.0 {
511        terms.push(ErrorTerm {
512            eps_math: out_eps[2],
513            source: ErrorSource::MulInteraction,
514        });
515    }
516    terms.push(ErrorTerm {
517        eps_math: adc_eps,
518        source: ErrorSource::AdcQuant {
519            bits: target.adc_bits,
520        },
521    });
522    terms.push(ErrorTerm {
523        eps_math: eval_rounding_guard(cert, r.frames * schedule.quantized.cells.len()),
524        source: ErrorSource::EvalRounding,
525    });
526    let eps_math = outward_sum(terms.iter().map(|t| t.eps_math));
527    OutputBudget { eps_math, terms }
528}