logicaffeine_proof/thermal_replay.rs
1//! Thermal-map replay — the independent judge of a claimed steady-state temperature map.
2//!
3//! The crown unification: the thermal solve `G·ΔT = P` IS the linear-system mathematics the
4//! relaxation lane certifies ([`crate::relax_replay`]) — one trust pattern, two physical
5//! planes. The claim is NEUTRAL data: a conductance edge list, ground (boundary)
6//! conductances, power injections, and the claimed per-node temperature rise — all exact
7//! rationals. [`check_thermal_claim`] reassembles the Laplacian ITSELF from the primitive
8//! edges (it never trusts an assembled matrix), re-multiplies exactly, and demands
9//! `G·ΔT = P` as an EQUALITY — the producer solved exactly, so nothing less certifies.
10//! Fail-closed throughout: malformed shapes, non-positive conductances, negative injections,
11//! a node with no path to any ground (heat with nowhere to go), or a negative claimed rise
12//! are rejections, never a verdict.
13
14use logicaffeine_base::numeric::Rational;
15
16/// The neutral thermal claim a stack's thermal solve emits.
17#[derive(Clone, Debug, PartialEq)]
18pub struct ThermalClaim {
19 /// Node count (ΔT is per node, kelvin above ambient).
20 pub n: usize,
21 /// Undirected conductance edges `(a, b, g)` in W/K, `g > 0`.
22 pub edges: Vec<(usize, usize, Rational)>,
23 /// Boundary conductances to ambient `(node, g)` in W/K, `g > 0`.
24 pub grounds: Vec<(usize, Rational)>,
25 /// Power injections `(node, watts)`, non-negative.
26 pub injections: Vec<(usize, Rational)>,
27 /// The claimed steady-state rise above ambient, per node.
28 pub delta_t: Vec<Rational>,
29}
30
31/// What a TRUE map says. The junction-limit judgment belongs to the cooling phase's
32/// consumer — the verdict reports the physics, it does not editorialize.
33#[derive(Clone, Debug, PartialEq)]
34pub struct ThermalVerdict {
35 pub hottest_node: usize,
36 pub hottest_delta_t: Rational,
37}
38
39/// THE judge. `Some(verdict)` iff the claim is well-formed, physically coherent, and the
40/// reassembled `G·ΔT` equals `P` EXACTLY at every node.
41pub fn check_thermal_claim(claim: &ThermalClaim) -> Option<ThermalVerdict> {
42 let n = claim.n;
43 if n == 0 || claim.delta_t.len() != n {
44 return None;
45 }
46 for (a, b, g) in &claim.edges {
47 if *a >= n || *b >= n || a == b || !g.is_positive() {
48 return None;
49 }
50 }
51 for (node, g) in &claim.grounds {
52 if *node >= n || !g.is_positive() {
53 return None;
54 }
55 }
56 if claim.grounds.is_empty() {
57 return None; // heat with nowhere to go — no steady state exists
58 }
59 for (node, w) in &claim.injections {
60 if *node >= n || w.is_negative() {
61 return None;
62 }
63 }
64 // Positivity: with non-negative injections, no node sits below ambient.
65 if claim.delta_t.iter().any(|t| t.is_negative()) {
66 return None;
67 }
68
69 // Connectivity: every node must reach a grounded node through the edge list.
70 let mut adj = vec![Vec::new(); n];
71 for (a, b, _) in &claim.edges {
72 adj[*a].push(*b);
73 adj[*b].push(*a);
74 }
75 let mut reached = vec![false; n];
76 let mut queue: Vec<usize> = claim.grounds.iter().map(|(g, _)| *g).collect();
77 for g in &queue {
78 reached[*g] = true;
79 }
80 while let Some(v) = queue.pop() {
81 for w in &adj[v] {
82 if !reached[*w] {
83 reached[*w] = true;
84 queue.push(*w);
85 }
86 }
87 }
88 if reached.iter().any(|r| !r) {
89 return None;
90 }
91
92 // Reassemble the Laplacian row-action INDEPENDENTLY and demand exact balance:
93 // Σ_edges g·(ΔT_i − ΔT_j) + g_ground·ΔT_i = P_i at every node i.
94 let mut balance: Vec<Rational> = vec![Rational::zero(); n];
95 for (a, b, g) in &claim.edges {
96 let diff = claim.delta_t[*a].sub(&claim.delta_t[*b]);
97 balance[*a] = balance[*a].add(&g.mul(&diff));
98 balance[*b] = balance[*b].sub(&g.mul(&diff));
99 }
100 for (node, g) in &claim.grounds {
101 balance[*node] = balance[*node].add(&g.mul(&claim.delta_t[*node]));
102 }
103 let mut p: Vec<Rational> = vec![Rational::zero(); n];
104 for (node, w) in &claim.injections {
105 p[*node] = p[*node].add(w);
106 }
107 if balance.iter().zip(&p).any(|(bi, pi)| bi != pi) {
108 return None;
109 }
110
111 let hottest = (0..n).max_by(|a, b| claim.delta_t[*a].cmp(&claim.delta_t[*b]))?;
112 Some(ThermalVerdict {
113 hottest_node: hottest,
114 hottest_delta_t: claim.delta_t[hottest].clone(),
115 })
116}