Skip to main content

logicaffeine_compile/codegen_ana/
thermal.rs

1//! The certified thermal map — `G·ΔT = P` over exact ℚ, judged independently.
2//!
3//! The stack becomes a steady-state conductance network at tile granularity: analog tiers
4//! coarsen to a small thermal grid (a MODELED fidelity statement, not physics — the knob is
5//! in [`ThermalOpts`]), digital and interposer tiers are single slabs, lateral conductance is
6//! the sourced bulk `k` per square (`k·t`), the gap between tiers carries the bond-layer
7//! background PLUS the provisioned vertical interconnect — with the crossings the placement
8//! ACTUALLY consumed attached at their mapped cells, so cooling is placement-dependent (the
9//! TSV-as-heat-lever, measured by the lock, not asserted). Cooling classes ground the top
10//! and bottom faces through the dossier's carried film coefficients; the digital tier
11//! injects its SOURCED signoff power; occupied analog tiles inject the MODELED bias power.
12//!
13//! The solve is exact Gaussian elimination over ℚ, and the emitted
14//! [`ThermalClaim`] is judged by `logicaffeine_proof::thermal_replay::check_thermal_claim`,
15//! which reassembles the Laplacian itself — the same one-residual-checker trust pattern as
16//! the relaxation lane. A network whose heat has nowhere to go refuses
17//! [`AnaLowerError::ThermalModelDisconnected`], never a fantasy equilibrium.
18
19use logicaffeine_base::numeric::Rational;
20use logicaffeine_proof::thermal_replay::ThermalClaim;
21
22use super::cell::AnalogNetlist;
23use super::stack::{CoolingClass, StackTarget, TierFabric};
24use super::stack_place::StackPlacement;
25use super::AnaLowerError;
26
27/// The modeled-fidelity knobs. Every figure is a design parameter of OUR model, labeled in
28/// [`THERMAL_MODEL_PROVENANCE`] and anchored in `benchmarks/silicon/analog/thermal-constants.md`.
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct ThermalOpts {
31    /// Analog tiers coarsen to this thermal grid (rows, cols).
32    pub analog_grid: (usize, usize),
33    /// Thermal cell edge, µm.
34    pub cell_edge_um: f64,
35    /// Bond-layer thickness, µm.
36    pub bond_thickness_um: f64,
37    /// Bias power per occupied analog tile, watts.
38    pub tile_bias_w: f64,
39}
40
41/// The V1 defaults.
42pub fn thermal_default_opts() -> ThermalOpts {
43    ThermalOpts {
44        analog_grid: (4, 4),
45        cell_edge_um: 500.0,
46        bond_thickness_um: 10.0,
47        tile_bias_w: 1.0e-4,
48    }
49}
50
51/// Provenance of the model's figures — pinned by `thermal_provenance_lock`.
52pub const THERMAL_MODEL_PROVENANCE: &str =
53    "Sourced: bulk k per Material (Incropera/CRC, benchmarks/silicon/analog/thermal-constants.md); digital \
54     tier power from benchmarks/silicon/sky130_power.json (OpenLane signoff, carried verbatim); \
55     TSV per-via g DERIVED from sourced k_Cu. Modeled (OUR design parameters, never silicon): \
56     film-coefficient carried points h = 10 / 100 / 2000 W/(m2.K) for natural / forced-air / \
57     cold-plate (Incropera Table 1.1 RANGES are the sourced part), analog-tier coarsening grid, \
58     500 um thermal cell edge, 10 um bond thickness, underfill-class bond k, 1e-4 W per-tile \
59     bias power, cross-tier via attachment at mapped coarse cells. Full dossier: \
60     benchmarks/silicon/analog/thermal-constants.md";
61
62/// The carried film-coefficient point for a cooling class, W/(m²·K).
63pub fn film_coefficient_w_m2k(class: CoolingClass) -> f64 {
64    match class {
65        CoolingClass::NaturalConvection => 10.0,
66        CoolingClass::ForcedAir => 100.0,
67        CoolingClass::ColdPlate => 2000.0,
68    }
69}
70
71/// A certified-solvable thermal map: the exact claim plus f64 conveniences for consumers.
72#[derive(Clone, Debug, PartialEq)]
73pub struct ThermalMap {
74    pub claim: ThermalClaim,
75    /// ΔT above ambient per node, °C (== K), f64 view of the exact claim.
76    pub delta_t_c: Vec<f64>,
77    /// Which stack tier each node belongs to.
78    pub node_tier: Vec<usize>,
79    pub hottest: usize,
80}
81
82/// The model states its figures on an exact picoscale grid (10⁻¹² W/K, W): these are MODELED
83/// design parameters, and the snap IS part of the model's statement — the checker certifies
84/// the STATED network exactly, and small shared denominators keep the ℚ elimination fast.
85fn rat(x: f64) -> Result<Rational, AnaLowerError> {
86    if !x.is_finite() {
87        return Err(AnaLowerError::ThermalModelDisconnected);
88    }
89    let scaled = (x * 1.0e12).round() as i64;
90    Ok(Rational::from_i64(scaled)
91        .div(&Rational::from_i64(1_000_000_000_000))
92        .expect("nonzero denominator"))
93}
94
95/// Build and exactly solve the stack's steady-state thermal network for one placed region.
96pub fn thermal_map(
97    stack: &StackTarget,
98    netlist: &AnalogNetlist,
99    placement: &StackPlacement,
100    opts: &ThermalOpts,
101) -> Result<ThermalMap, AnaLowerError> {
102    if let Err(e) = stack.validate() {
103        return Err(AnaLowerError::UnloweredOp(format!(
104            "malformed stack: {e:?}"
105        )));
106    }
107    let (gr, gc) = opts.analog_grid;
108    assert!(
109        gr > 0 && gc > 0,
110        "a zero-size thermal grid is not a coarsening"
111    );
112    let cell_edge_m = opts.cell_edge_um * 1.0e-6;
113    let cell_area_m2 = cell_edge_m * cell_edge_m;
114
115    // ── Node layout: analog tiers get gr×gc grids, other tiers one slab node. ──
116    let mut base = Vec::with_capacity(stack.tiers.len());
117    let mut node_tier = Vec::new();
118    for (ti, tier) in stack.tiers.iter().enumerate() {
119        base.push(node_tier.len());
120        let count = match tier.fabric {
121            TierFabric::Analog(_) => gr * gc,
122            _ => 1,
123        };
124        node_tier.extend(std::iter::repeat(ti).take(count));
125    }
126    let n = node_tier.len();
127    let grid_node = |ti: usize, r: usize, c: usize| base[ti] + r * gc + c;
128    let coarse_of = |ti: usize, row: usize, col: usize| -> (usize, usize) {
129        let TierFabric::Analog(a) = &stack.tiers[ti].fabric else {
130            return (0, 0);
131        };
132        (
133            (row * gr / a.rows.max(1)).min(gr - 1),
134            (col * gc / a.cols.max(1)).min(gc - 1),
135        )
136    };
137    let tier_rep_node = |ti: usize| -> usize {
138        match stack.tiers[ti].fabric {
139            TierFabric::Analog(_) => grid_node(ti, gr / 2, gc / 2),
140            _ => base[ti],
141        }
142    };
143
144    let mut edges: Vec<(usize, usize, Rational)> = Vec::new();
145    let mut grounds: Vec<(usize, Rational)> = Vec::new();
146    let mut injections: Vec<(usize, Rational)> = Vec::new();
147
148    // ── Lateral spreading: k·t per square between adjacent grid cells. ──
149    for (ti, tier) in stack.tiers.iter().enumerate() {
150        if let TierFabric::Analog(_) = tier.fabric {
151            let g_lat = rat(tier.material.k_w_per_m_k * tier.thickness_um * 1.0e-6)?;
152            for r in 0..gr {
153                for c in 0..gc {
154                    if r + 1 < gr {
155                        edges.push((grid_node(ti, r, c), grid_node(ti, r + 1, c), g_lat.clone()));
156                    }
157                    if c + 1 < gc {
158                        edges.push((grid_node(ti, r, c), grid_node(ti, r, c + 1), g_lat.clone()));
159                    }
160                }
161            }
162        }
163    }
164
165    // ── Vertical: bond background per overlapping cell pair + provisioned interconnect. ──
166    for (gi, gap) in stack.gaps.iter().enumerate() {
167        let (lo, hi) = (gi, gi + 1);
168        let k_bond = super::stack::Material::underfill_class().k_w_per_m_k;
169        let g_bond_cell = rat(k_bond * cell_area_m2 / (opts.bond_thickness_um * 1.0e-6))?;
170        let pair_nodes: Vec<(usize, usize)> = match (
171            matches!(stack.tiers[lo].fabric, TierFabric::Analog(_)),
172            matches!(stack.tiers[hi].fabric, TierFabric::Analog(_)),
173        ) {
174            (true, true) => (0..gr)
175                .flat_map(|r| (0..gc).map(move |c| (r, c)))
176                .map(|(r, c)| (grid_node(lo, r, c), grid_node(hi, r, c)))
177                .collect(),
178            (true, false) => (0..gr)
179                .flat_map(|r| (0..gc).map(move |c| (r, c)))
180                .map(|(r, c)| (grid_node(lo, r, c), base[hi]))
181                .collect(),
182            (false, true) => (0..gr)
183                .flat_map(|r| (0..gc).map(move |c| (r, c)))
184                .map(|(r, c)| (base[lo], grid_node(hi, r, c)))
185                .collect(),
186            (false, false) => vec![(base[lo], base[hi])],
187        };
188        for (a, b) in &pair_nodes {
189            edges.push((*a, *b, g_bond_cell.clone()));
190        }
191        // Provisioned vertical interconnect: the crossings the placement consumed attach at
192        // their mapped cells; the unconsumed remainder spreads uniformly. Copper conducts
193        // whether or not a signal uses it — total conductance is conserved.
194        let per_via_g: f64 = gap.first().map(|v| v.g_w_per_k).unwrap_or(0.0);
195        let provisioned: usize = gap.iter().map(|v| v.count).sum();
196        let consumed = placement
197            .vertical_used
198            .get(gi)
199            .copied()
200            .unwrap_or(0)
201            .min(provisioned);
202        if per_via_g > 0.0 && provisioned > 0 {
203            let mut attached = 0usize;
204            for (i, cell_site) in placement.site.iter().enumerate() {
205                for o in super::stack_place::operand_cells_of(&netlist.cells[i]) {
206                    let so = placement.site[o];
207                    let (t1, t2) = (cell_site.tier.min(so.tier), cell_site.tier.max(so.tier));
208                    if t1 <= lo && hi <= t2 && t1 != t2 && attached < consumed {
209                        let na = match stack.tiers[lo].fabric {
210                            TierFabric::Analog(_) => {
211                                let (r, c) = coarse_of(lo, cell_site.row, cell_site.col);
212                                grid_node(lo, r, c)
213                            }
214                            _ => base[lo],
215                        };
216                        let nb = match stack.tiers[hi].fabric {
217                            TierFabric::Analog(_) => {
218                                let (r, c) = coarse_of(hi, so.row, so.col);
219                                grid_node(hi, r, c)
220                            }
221                            _ => base[hi],
222                        };
223                        edges.push((na, nb, rat(per_via_g)?));
224                        attached += 1;
225                    }
226                }
227            }
228            let dark = provisioned - attached;
229            if dark > 0 {
230                let g_dark_total = rat(per_via_g * dark as f64)?;
231                let share = g_dark_total
232                    .div(&Rational::from_i64(pair_nodes.len() as i64))
233                    .expect("pair_nodes is non-empty");
234                for (a, b) in &pair_nodes {
235                    edges.push((*a, *b, share.clone()));
236                }
237            }
238        }
239    }
240
241    // ── Cooling boundaries: top face of the top tier, bottom face of the bottom tier. ──
242    let h_top = film_coefficient_w_m2k(stack.cooling.top);
243    let h_bot = film_coefficient_w_m2k(stack.cooling.bottom);
244    let top = stack.tiers.len() - 1;
245    match stack.tiers[top].fabric {
246        TierFabric::Analog(_) => {
247            let g = rat(h_top * cell_area_m2)?;
248            for r in 0..gr {
249                for c in 0..gc {
250                    grounds.push((grid_node(top, r, c), g.clone()));
251                }
252            }
253        }
254        _ => grounds.push((base[top], rat(h_top * cell_area_m2 * (gr * gc) as f64)?)),
255    }
256    match stack.tiers[0].fabric {
257        TierFabric::Analog(_) => {
258            let g = rat(h_bot * cell_area_m2)?;
259            for r in 0..gr {
260                for c in 0..gc {
261                    grounds.push((grid_node(0, r, c), g.clone()));
262                }
263            }
264        }
265        _ => grounds.push((base[0], rat(h_bot * cell_area_m2 * (gr * gc) as f64)?)),
266    }
267
268    // ── Injections: sourced digital power; modeled bias per occupied analog tile. ──
269    for (ti, tier) in stack.tiers.iter().enumerate() {
270        if let TierFabric::Digital(d) = &tier.fabric {
271            injections.push((tier_rep_node(ti), rat(d.power_w)?));
272        }
273    }
274    let bias = rat(opts.tile_bias_w)?;
275    for s in &placement.site {
276        if matches!(stack.tiers[s.tier].fabric, TierFabric::Analog(_)) {
277            let (r, c) = coarse_of(s.tier, s.row, s.col);
278            injections.push((grid_node(s.tier, r, c), bias.clone()));
279        }
280    }
281
282    // ── Assemble the Laplacian and solve exactly. ──
283    let mut g_mat = vec![vec![Rational::zero(); n]; n];
284    for (a, b, g) in &edges {
285        g_mat[*a][*a] = g_mat[*a][*a].add(g);
286        g_mat[*b][*b] = g_mat[*b][*b].add(g);
287        g_mat[*a][*b] = g_mat[*a][*b].sub(g);
288        g_mat[*b][*a] = g_mat[*b][*a].sub(g);
289    }
290    for (node, g) in &grounds {
291        g_mat[*node][*node] = g_mat[*node][*node].add(g);
292    }
293    let mut p = vec![Rational::zero(); n];
294    for (node, w) in &injections {
295        p[*node] = p[*node].add(w);
296    }
297    let delta_t = exact_solve(&mut g_mat, p).ok_or(AnaLowerError::ThermalModelDisconnected)?;
298
299    let delta_t_c: Vec<f64> = delta_t.iter().map(|t| t.to_f64()).collect();
300    let hottest = (0..n)
301        .max_by(|a, b| delta_t[*a].cmp(&delta_t[*b]))
302        .expect("n > 0 — a stack has tiers");
303    Ok(ThermalMap {
304        claim: ThermalClaim {
305            n,
306            edges,
307            grounds,
308            injections,
309            delta_t,
310        },
311        delta_t_c,
312        node_tier,
313        hottest,
314    })
315}
316
317/// The name of a cooling class, for refusal messages.
318pub fn cooling_class_name(class: CoolingClass) -> &'static str {
319    match class {
320        CoolingClass::NaturalConvection => "natural-convection",
321        CoolingClass::ForcedAir => "forced-air",
322        CoolingClass::ColdPlate => "cold-plate",
323    }
324}
325
326/// Judge the CERTIFIED map against the stack's DECLARED cooling budget: every junction must
327/// sit under `t_junction_max_c` at the stated ambient. The refusal names the hottest node,
328/// its tier, both temperatures, and the cooling class — the same program may compile under a
329/// stronger class; that verdict belongs to the checker's map, not the producer's optimism.
330pub fn check_cooling(map: &ThermalMap, stack: &StackTarget) -> Result<(), AnaLowerError> {
331    let t_junction_c = stack.cooling.ambient_c + map.delta_t_c[map.hottest];
332    if t_junction_c > stack.cooling.t_junction_max_c {
333        return Err(AnaLowerError::ThermalBudgetExceeded {
334            node: map.hottest,
335            tier: map.node_tier[map.hottest],
336            t_junction_c,
337            t_max_c: stack.cooling.t_junction_max_c,
338            cooling: cooling_class_name(stack.cooling.top),
339        });
340    }
341    Ok(())
342}
343
344/// Exact solve by FRACTION-FREE Bareiss elimination. The model states every figure on the
345/// 10⁻¹² grid, so clearing that denominator makes the working matrix integer-valued and
346/// Bareiss keeps it that way (entries bounded by minor determinants — no fraction blow-up,
347/// which is the difference between milliseconds and minutes at stack scale). `None` iff
348/// singular — for a Laplacian-plus-grounds matrix that means a floating island.
349fn exact_solve(a: &mut [Vec<Rational>], b: Vec<Rational>) -> Option<Vec<Rational>> {
350    let n = b.len();
351    let grid = Rational::from_i64(1_000_000_000_000);
352    let mut m: Vec<Vec<Rational>> = a
353        .iter()
354        .map(|row| row.iter().map(|x| x.mul(&grid)).collect())
355        .collect();
356    let mut rhs: Vec<Rational> = b.iter().map(|x| x.mul(&grid)).collect();
357
358    let mut prev = Rational::one();
359    for k in 0..n {
360        let p = (k..n).find(|r| !m[*r][k].is_zero())?;
361        m.swap(p, k);
362        rhs.swap(p, k);
363        let pivot = m[k][k].clone();
364        for i in (k + 1)..n {
365            for j in (k + 1)..n {
366                let num = m[i][j].mul(&pivot).sub(&m[i][k].mul(&m[k][j]));
367                m[i][j] = num.div(&prev).expect("Bareiss division is exact");
368            }
369            let num = rhs[i].mul(&pivot).sub(&m[i][k].mul(&rhs[k]));
370            rhs[i] = num.div(&prev).expect("Bareiss division is exact");
371            m[i][k] = Rational::zero();
372        }
373        prev = pivot;
374    }
375    let mut x = vec![Rational::zero(); n];
376    for row in (0..n).rev() {
377        let mut acc = rhs[row].clone();
378        for c in (row + 1)..n {
379            acc = acc.sub(&m[row][c].mul(&x[c]));
380        }
381        x[row] = acc.div(&m[row][row])?;
382    }
383    Some(x)
384}