Skip to main content

logicaffeine_compile/codegen_ana/
dynamics.rs

1//! The relaxation lane — continuous-time state evolution as a compute primitive
2//! (`Solve A·x = b` by settling).
3//!
4//! The four-fabric thesis, mechanized at its first rung: the SAME Sum tiles that compute
5//! results in the dataflow lane here generate FORCES — `ẋ = b − A·x` is one weighted-sum tile
6//! per state variable, and capacitor state integrates it. Gradient flow on the strongly convex
7//! quadratic `½xᵀAx − bᵀx` converges for admissible `A` (symmetric + positive definite;
8//! Hirsch–Smale, gradient systems), and the admission gate PROVES membership over exact ℚ —
9//! strict diagonal dominance with a positive diagonal (Gershgorin ⇒ SPD for symmetric `A`) or
10//! all leading principal minors positive (Sylvester's criterion). Anything else refuses TYPED.
11//!
12//! The trust covenant: the settle (here, a deterministic explicit-Euler behavioral model with
13//! an optional fixed-seed noise injection — the future chip replaces it wholesale) is
14//! UNTRUSTED. Only the settled answer counts: the readout is ADC-quantized on the target's
15//! converter grid, the exact residual `r = A·x̂ − b` is computed in ℚ, and the emitted
16//! [`RelaxClaim`] is judged by the INDEPENDENT `logicaffeine_proof::relax_replay` checker.
17//! Settling time is a performance knob, not a correctness one: an under-settled run exceeds
18//! the demanded budget and refuses — it cannot emit a certificate it does not meet.
19
20use logicaffeine_base::numeric::{rational_from_f64_exact, Rational};
21use logicaffeine_proof::relax_replay::{IsingClaim, MinimizeClaim, RelaxClaim};
22
23use super::cell::{ASrc, AnalogCell};
24use super::target::AnalogTarget;
25use super::AnaLowerError;
26
27/// A configured dynamical system: one force tile per state variable, built from the EXISTING
28/// tile vocabulary (`AnalogCell::Sum` — the tiles stop computing results and start generating
29/// the forces that move state).
30#[derive(Clone, Debug, PartialEq)]
31pub struct DynSystem {
32    pub n: usize,
33    /// `force[i]` computes `ẋ_i = b_i − Σ_j a_ij·x_j`; state taps are `ASrc::In(j)`, the
34    /// constant leg carries `b_i`.
35    pub force: Vec<AnalogCell>,
36    pub init: Vec<f64>,
37}
38
39/// Build the gradient-flow force network for `ẋ = b − A·x`. Purely structural — admission is
40/// [`solve_by_settling`]'s job.
41pub fn gradient_flow_system(a: &[Vec<f64>], b: &[f64]) -> DynSystem {
42    let n = a.len();
43    let force = (0..n)
44        .map(|i| {
45            let mut inputs: Vec<ASrc> = (0..n).map(ASrc::In).collect();
46            let mut weights: Vec<f64> = a[i].iter().map(|w| -w).collect();
47            inputs.push(ASrc::Const(1.0));
48            weights.push(b[i]);
49            AnalogCell::Sum { inputs, weights }
50        })
51        .collect();
52    DynSystem {
53        n,
54        force,
55        init: vec![0.0; n],
56    }
57}
58
59/// The deterministic settle knobs. `eps_required` is the demanded certificate budget — the
60/// hard admission line for the emitted claim; `noise_amp`/`seed` inject a fixed-seed
61/// disturbance per step (the model's stand-in for the physical noise floor, and R3's future
62/// annealing ramp).
63#[derive(Clone, Copy, Debug, PartialEq)]
64pub struct SettleOpts {
65    pub steps: usize,
66    pub rate: f64,
67    pub eps_required: f64,
68    pub noise_amp: f64,
69    pub seed: u64,
70}
71
72/// One settled, certified solve.
73#[derive(Clone, Debug, PartialEq)]
74pub struct RelaxOutcome {
75    /// The ADC-quantized readout (math domain).
76    pub x_hat: Vec<f64>,
77    pub steps: usize,
78    /// The neutral exact claim, judged by `logicaffeine_proof::relax_replay::check_relax_claim`.
79    pub claim: RelaxClaim,
80}
81
82/// `Solve A·x = b` by settling the gradient-flow system, fail-closed end to end.
83pub fn solve_by_settling(
84    a: &[Vec<f64>],
85    b: &[f64],
86    target: &AnalogTarget,
87    opts: &SettleOpts,
88) -> Result<RelaxOutcome, AnaLowerError> {
89    let aq = exact_matrix(a)?;
90    let n = aq.len();
91    if b.len() != n {
92        return Err(AnaLowerError::ConvergenceUnprovable {
93            reason: format!("shape mismatch: A is {n}×{n} but b has {} entries", b.len()),
94        });
95    }
96    admit(&aq)?;
97
98    // The untrusted settle: explicit Euler over the force network, fixed-seed noise optional.
99    let sys = gradient_flow_system(a, b);
100    let mut x = sys.init.clone();
101    let mut noise_state = opts.seed ^ 0x5E77_1E5A_1755_A17E;
102    for _ in 0..opts.steps {
103        let forces: Vec<f64> = sys.force.iter().map(|cell| cell.eval(&x, &[])).collect();
104        for i in 0..n {
105            x[i] += opts.rate * forces[i];
106            if opts.noise_amp > 0.0 {
107                x[i] += opts.noise_amp * centered_uniform(&mut noise_state);
108            }
109        }
110    }
111
112    // Readout through the converter grid at unit block scale — quantization lands honestly
113    // in the residual.
114    let half_span = target.half_span_v();
115    let adc_lsb = 2.0 * half_span / f64::powi(2.0, i32::from(target.adc_bits));
116    let x_hat: Vec<f64> = x.iter().map(|v| (v / adc_lsb).round() * adc_lsb).collect();
117
118    // The exact residual and the claim. Every f64 is finite here or conversion refuses.
119    let bq = exact_vec(b)?;
120    let xq = exact_vec(&x_hat)?;
121    let mut linf = Rational::zero();
122    for i in 0..n {
123        let mut acc = bq[i].negated();
124        for j in 0..n {
125            acc = acc.add(&aq[i][j].mul(&xq[j]));
126        }
127        let mag = acc.abs();
128        if mag > linf {
129            linf = mag;
130        }
131    }
132    let linf_f = linf.to_f64();
133    if !(linf_f <= opts.eps_required) {
134        return Err(AnaLowerError::ResidualExceedsBudget {
135            linf: linf_f,
136            required: opts.eps_required,
137        });
138    }
139    Ok(RelaxOutcome {
140        x_hat,
141        steps: opts.steps,
142        claim: RelaxClaim {
143            a: aq,
144            b: bq,
145            x_hat: xq,
146            eps: linf,
147        },
148    })
149}
150
151/// One settled, certified quadratic minimization.
152#[derive(Clone, Debug, PartialEq)]
153pub struct MinimizeOutcome {
154    pub x_hat: Vec<f64>,
155    pub steps: usize,
156    /// The theorem-checked claim (`proof::relax_replay::check_minimize_claim`).
157    pub claim: MinimizeClaim,
158    /// f64 view of the exact suboptimality bound.
159    pub subopt: f64,
160}
161
162/// `minimize ½xᵀQx + bᵀx` by settling `ẋ = −(Q·x + b)` — the SAME force network with the
163/// signs flipped (the tiles generate the negative gradient). The admission gate is R1's; the
164/// CERTIFICATE is R2's theorem: for spectrum ≥ μ > 0 (exact Gershgorin row bound over ℚ),
165/// `f(x̂) − f* ≤ ‖∇f(x̂)‖²/(2μ)` — a gradient residual becomes a distance-to-optimum bound.
166/// `Candidate` semantics: the bound is the claim; no optimality field exists to lie with.
167pub fn minimize_by_settling(
168    q: &[Vec<f64>],
169    b: &[f64],
170    target: &AnalogTarget,
171    opts: &SettleOpts,
172) -> Result<MinimizeOutcome, AnaLowerError> {
173    let qq = exact_matrix(q)?;
174    let n = qq.len();
175    if b.len() != n {
176        return Err(AnaLowerError::ConvergenceUnprovable {
177            reason: format!("shape mismatch: Q is {n}×{n} but b has {} entries", b.len()),
178        });
179    }
180    admit(&qq)?;
181    // The certificate needs strict Gershgorin positivity — a Sylvester-only admission has no
182    // exact spectral lower bound to certify with.
183    let mut mu: Option<Rational> = None;
184    for i in 0..n {
185        let off = (0..n)
186            .filter(|j| *j != i)
187            .fold(Rational::zero(), |acc, j| acc.add(&qq[i][j].abs()));
188        let row = qq[i][i].sub(&off);
189        mu = Some(match mu {
190            None => row,
191            Some(m) => {
192                if row < m {
193                    row
194                } else {
195                    m
196                }
197            }
198        });
199    }
200    let mu = mu.expect("n > 0");
201    if !mu.is_positive() {
202        return Err(AnaLowerError::ConvergenceUnprovable {
203            reason: "no certified spectral floor: Q is not strictly diagonally dominant, so \
204                     the Gershgorin bound gives no positive μ for the suboptimality theorem"
205                .to_string(),
206        });
207    }
208
209    // Settle the negative gradient flow: reuse the linear machinery with b' = −b.
210    let neg_b: Vec<f64> = b.iter().map(|x| -x).collect();
211    let sys = gradient_flow_system(q, &neg_b);
212    let mut x = sys.init.clone();
213    let mut noise_state = opts.seed ^ 0x5E77_1E5A_1755_A17E;
214    for _ in 0..opts.steps {
215        let forces: Vec<f64> = sys.force.iter().map(|cell| cell.eval(&x, &[])).collect();
216        for i in 0..n {
217            x[i] += opts.rate * forces[i];
218            if opts.noise_amp > 0.0 {
219                x[i] += opts.noise_amp * centered_uniform(&mut noise_state);
220            }
221        }
222    }
223    let half_span = target.half_span_v();
224    let adc_lsb = 2.0 * half_span / f64::powi(2.0, i32::from(target.adc_bits));
225    let x_hat: Vec<f64> = x.iter().map(|v| (v / adc_lsb).round() * adc_lsb).collect();
226
227    // The exact gradient and the exact bound.
228    let bq = exact_vec(b)?;
229    let xq = exact_vec(&x_hat)?;
230    let mut norm2 = Rational::zero();
231    for i in 0..n {
232        let mut g = bq[i].clone();
233        for j in 0..n {
234            g = g.add(&qq[i][j].mul(&xq[j]));
235        }
236        norm2 = norm2.add(&g.mul(&g));
237    }
238    let subopt = norm2.div(&Rational::from_i64(2).mul(&mu)).expect("mu > 0");
239    let subopt_f = subopt.to_f64();
240    if !(subopt_f <= opts.eps_required) {
241        return Err(AnaLowerError::ResidualExceedsBudget {
242            linf: subopt_f,
243            required: opts.eps_required,
244        });
245    }
246    Ok(MinimizeOutcome {
247        x_hat,
248        steps: opts.steps,
249        subopt: subopt_f,
250        claim: MinimizeClaim {
251            q: qq,
252            b: bq,
253            x_hat: xq,
254            mu,
255            subopt,
256        },
257    })
258}
259
260/// One Ising candidate: an attractor of the soft-spin flow, certified for WHAT IT IS —
261/// its exact energy and its exact 1-flip stability — and nothing more (the claim type has
262/// no optimality field to lie with).
263#[derive(Clone, Debug, PartialEq)]
264pub struct IsingOutcome {
265    pub spins: Vec<i8>,
266    pub steps: usize,
267    pub claim: IsingClaim,
268    /// f64 view of the exact energy.
269    pub energy: f64,
270    pub flip_stable: bool,
271}
272
273/// The R3 attempt: `minimize H(s) = −Σ J_ij·s_i·s_j − Σ h_i·s_i` by SOFT-SPIN relaxation
274/// (Hopfield 1984): continuous states `x ∈ [−1,1]ⁿ` follow `ẋ = −x + tanh(β·(J·x + h))`
275/// with the annealing schedule as a NOISE-AMPLITUDE RAMP (`opts.noise_amp` decaying linearly
276/// to zero — the noise floor as a resource) while β ramps up. The trajectory and the tanh
277/// live in the UNTRUSTED model tier; the readout `sign(x̂)` is certified digitally and
278/// exactly: recomputed energy and 1-flip stability, judged by
279/// `proof::relax_replay::check_ising_claim`. A candidate that is not even 1-flip stable is
280/// polished greedily (each flip strictly lowers H — a certified descent, not a search).
281pub fn ising_by_soft_spin(
282    j: &[Vec<f64>],
283    h: &[f64],
284    opts: &SettleOpts,
285    beta_max: f64,
286) -> Result<IsingOutcome, AnaLowerError> {
287    let jq = exact_matrix(j)?;
288    let n = jq.len();
289    if h.len() != n {
290        return Err(AnaLowerError::ConvergenceUnprovable {
291            reason: format!("shape mismatch: J is {n}×{n} but h has {} entries", h.len()),
292        });
293    }
294    for i in 0..n {
295        if !jq[i][i].is_zero() {
296            return Err(AnaLowerError::ConvergenceUnprovable {
297                reason: format!("nonzero self-coupling J[{i}][{i}] — not an Ising instance"),
298            });
299        }
300        for k in (i + 1)..n {
301            if jq[i][k] != jq[k][i] {
302                return Err(AnaLowerError::ConvergenceUnprovable {
303                    reason: format!("asymmetric coupling at ({i},{k})"),
304                });
305            }
306        }
307    }
308
309    // The untrusted soft-spin settle with the annealing ramp.
310    let mut x = vec![0.0_f64; n];
311    let mut noise_state = opts.seed ^ 0x1516_C0DE_5EED_0151;
312    for step in 0..opts.steps {
313        let progress = step as f64 / opts.steps.max(1) as f64;
314        let beta = beta_max * (0.1 + 0.9 * progress);
315        let amp = opts.noise_amp * (1.0 - progress);
316        for i in 0..n {
317            let mut field = h[i];
318            for k in 0..n {
319                field += j[i][k] * x[k];
320            }
321            let dx = -x[i] + (beta * field).tanh();
322            x[i] += opts.rate * dx;
323            if amp > 0.0 {
324                x[i] += amp * centered_uniform(&mut noise_state);
325            }
326            x[i] = x[i].clamp(-1.0, 1.0);
327        }
328    }
329    let mut spins: Vec<i8> = x.iter().map(|v| if *v < 0.0 { -1 } else { 1 }).collect();
330
331    // Certified greedy polish: flip any spin whose exact ΔH is negative until none is —
332    // every step strictly lowers H, so this terminates and lands 1-flip stable.
333    let hq = exact_vec(h)?;
334    loop {
335        let mut flipped = false;
336        for i in 0..n {
337            let mut field = hq[i].clone();
338            for k in 0..n {
339                if k != i {
340                    field = field.add(&jq[i][k].mul(&Rational::from_i64(spins[k] as i64)));
341                }
342            }
343            let delta = Rational::from_i64(2 * spins[i] as i64).mul(&field);
344            if delta.is_negative() {
345                spins[i] = -spins[i];
346                flipped = true;
347            }
348        }
349        if !flipped {
350            break;
351        }
352    }
353
354    // The exact energy of what we actually return.
355    let s_of = |i: usize| Rational::from_i64(spins[i] as i64);
356    let mut energy = Rational::zero();
357    for i in 0..n {
358        for k in (i + 1)..n {
359            energy = energy.sub(&jq[i][k].mul(&s_of(i)).mul(&s_of(k)));
360        }
361        energy = energy.sub(&hq[i].mul(&s_of(i)));
362    }
363    let energy_f = energy.to_f64();
364    let claim = IsingClaim {
365        j: jq,
366        h: hq,
367        spins: spins.clone(),
368        energy,
369        flip_stable: true,
370    };
371    Ok(IsingOutcome {
372        spins,
373        steps: opts.steps,
374        claim,
375        energy: energy_f,
376        flip_stable: true,
377    })
378}
379
380/// Fold one soft equality constraint `gᵀx = c` into the quadratic as a virtual spring:
381/// `f(x) + λ(gᵀx − c)²` ⇒ `Q += 2λ·ggᵀ`, `b −= 2λc·g`. Stiffer springs pull the settled
382/// answer closer to the constraint surface — the quadratic-penalty form (primal-dual
383/// dynamics are the ledgered future).
384pub fn with_penalty(
385    q: &[Vec<f64>],
386    b: &[f64],
387    g: &[f64],
388    c: f64,
389    lambda: f64,
390) -> (Vec<Vec<f64>>, Vec<f64>) {
391    let n = q.len();
392    let mut q2: Vec<Vec<f64>> = q.iter().map(|r| r.to_vec()).collect();
393    let mut b2 = b.to_vec();
394    for i in 0..n {
395        for j in 0..n {
396            q2[i][j] += 2.0 * lambda * g[i] * g[j];
397        }
398        b2[i] -= 2.0 * lambda * c * g[i];
399    }
400    (q2, b2)
401}
402
403/// The admission gate, over EXACT ℚ: symmetric AND (strictly diagonally dominant with a
404/// positive diagonal, OR all leading principal minors positive). Cited theorems: Gershgorin
405/// (dominance ⇒ definiteness for symmetric matrices), Sylvester (minors), and gradient-flow
406/// convergence on strongly convex quadratics.
407fn admit(a: &[Vec<Rational>]) -> Result<(), AnaLowerError> {
408    let n = a.len();
409    let refuse = |reason: String| Err(AnaLowerError::ConvergenceUnprovable { reason });
410    if n == 0 {
411        return refuse("empty system".to_string());
412    }
413    for i in 0..n {
414        for j in (i + 1)..n {
415            if a[i][j] != a[j][i] {
416                return refuse(format!(
417                    "A is not symmetric at ({i},{j}) — gradient flow solves ∇(½xᵀAx − bᵀx) = 0, \
418                     which is only A·x = b for symmetric A"
419                ));
420            }
421        }
422    }
423    let dominant = (0..n).all(|i| {
424        let off: Rational = (0..n)
425            .filter(|j| *j != i)
426            .fold(Rational::zero(), |acc, j| acc.add(&a[i][j].abs()));
427        a[i][i].is_positive() && a[i][i] > off
428    });
429    if dominant {
430        return Ok(());
431    }
432    if (1..=n).all(|k| leading_minor_det(a, k).is_positive()) {
433        return Ok(());
434    }
435    refuse(
436        "A is symmetric but neither strictly diagonally dominant (positive diagonal) nor \
437         Sylvester-positive (all leading principal minors > 0) — convergence unproven"
438            .to_string(),
439    )
440}
441
442/// Determinant of the k×k leading principal minor by exact fraction Gaussian elimination.
443fn leading_minor_det(a: &[Vec<Rational>], k: usize) -> Rational {
444    let mut m: Vec<Vec<Rational>> = (0..k).map(|i| a[i][..k].to_vec()).collect();
445    let mut det = Rational::one();
446    for col in 0..k {
447        let Some(p) = (col..k).find(|r| !m[*r][col].is_zero()) else {
448            return Rational::zero();
449        };
450        if p != col {
451            m.swap(p, col);
452            det = det.negated();
453        }
454        det = det.mul(&m[col][col]);
455        let pivot = m[col][col].clone();
456        for r in (col + 1)..k {
457            let factor = m[r][col].div(&pivot).expect("pivot is nonzero");
458            for c in col..k {
459                let sub = factor.mul(&m[col][c]);
460                m[r][c] = m[r][c].sub(&sub);
461            }
462        }
463    }
464    det
465}
466
467fn exact_matrix(a: &[Vec<f64>]) -> Result<Vec<Vec<Rational>>, AnaLowerError> {
468    let n = a.len();
469    a.iter()
470        .map(|row| {
471            if row.len() != n {
472                return Err(AnaLowerError::ConvergenceUnprovable {
473                    reason: format!(
474                        "ragged matrix: a row has {} entries in an n={n} system",
475                        row.len()
476                    ),
477                });
478            }
479            exact_vec(row)
480        })
481        .collect()
482}
483
484fn exact_vec(v: &[f64]) -> Result<Vec<Rational>, AnaLowerError> {
485    v.iter()
486        .map(|x| {
487            rational_from_f64_exact(*x).ok_or_else(|| AnaLowerError::ConvergenceUnprovable {
488                reason: format!("non-finite entry {x}"),
489            })
490        })
491        .collect()
492}
493
494/// SplitMix64-driven uniform in (−1, 1) — deterministic forever under a fixed seed.
495fn centered_uniform(state: &mut u64) -> f64 {
496    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
497    let mut z = *state;
498    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
499    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
500    z ^= z >> 31;
501    ((z >> 11) as f64 + 0.5) / (1u64 << 52) as f64 - 1.0
502}