From 4e6887c03c05bc656aa147f7bd7916440cd79a26 Mon Sep 17 00:00:00 2001 From: daharoni Date: Wed, 8 Jul 2026 14:32:25 -0700 Subject: [PATCH 1/2] feat(solver): surface bi-exponential fit outcome (FitMode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bi-exponential fit could silently fall back to slow-only, produce a degenerate result (no positive slow amplitude — a noise/flat free kernel), or return the empty sentinel, and the caller had to infer this from beta_fast==0 or an infinite residual. Make the outcome explicit and carry it through the whole stack so a suspect kernel is reported, not guessed. Rust (biexp_fit.rs): add enum FitMode { TwoComponent, SlowOnly, Degenerate, Empty }; add a fit_mode field to BiexpResult, classified once on the returned result (residual !finite -> Empty; beta<=0 -> Degenerate; else two/slow by fast-component presence). Intermediate candidates carry a placeholder. FFI: WASM indeca_fit_biexponential serializes fit_mode (variant name) in its result object; PyO3 py_indeca_fit_biexponential appends it as a string to the return tuple (now 8-tuple); Python BiexpFitResult gains a fit_mode field. App (CaDecon): worker parses fitMode into KernelResult; iteration-manager counts Degenerate/Empty subset fits per iteration (degenerateSubsets / totalSubsetFits on the snapshot); KernelDisplay shows a vermillion "N/K fits degenerate" warning when the displayed kernel rests on untrustworthy subset fits. Tests: Rust (Empty on empty input, Degenerate on flat kernel, usable on a clean biexp); Python test_fit_mode covers all three via the public wrapper. Verified in-browser that a clean run shows no warning and no console errors. All suites green (131 Rust, 32 Python, 289 TS); fmt/clippy clean on both feature sets. Co-Authored-By: Claude Fable 5 --- .../src/components/kernel/KernelDisplay.tsx | 21 +++++ .../lib/__tests__/iteration-manager.test.ts | 1 + .../src/lib/__tests__/iteration-store.test.ts | 6 ++ apps/cadecon/src/lib/iteration-manager.ts | 11 +++ apps/cadecon/src/lib/iteration-store.ts | 4 + apps/cadecon/src/styles/kernel-display.css | 6 ++ apps/cadecon/src/workers/cadecon-types.ts | 8 +- apps/cadecon/src/workers/cadecon-worker.ts | 4 +- crates/solver/src/biexp_fit.rs | 81 +++++++++++++++++++ crates/solver/src/js_indeca.rs | 2 + crates/solver/src/peak_seed.rs | 1 + crates/solver/src/py_api.rs | 5 +- python/src/calab/_compute.py | 5 ++ python/tests/test_fit_mode.py | 31 +++++++ 14 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 python/tests/test_fit_mode.py diff --git a/apps/cadecon/src/components/kernel/KernelDisplay.tsx b/apps/cadecon/src/components/kernel/KernelDisplay.tsx index fa8eda1d..0e7abdfc 100644 --- a/apps/cadecon/src/components/kernel/KernelDisplay.tsx +++ b/apps/cadecon/src/components/kernel/KernelDisplay.tsx @@ -95,6 +95,17 @@ export function KernelDisplay(): JSX.Element { return (energyFast / total) * 100; }); + // How many subset kernel fits at this iteration were degenerate/empty + // (no usable bi-exponential). A nonzero count means the displayed kernel is + // built from at least one untrustworthy subset fit. + const degenerateFits = createMemo(() => { + const snap = snapshot(); + if (!snap || snap.totalSubsetFits === 0) return null; + return snap.degenerateSubsets > 0 + ? { bad: snap.degenerateSubsets, total: snap.totalSubsetFits } + : null; + }); + const gtShape = createMemo(() => { const tauR = groundTruthTauRise(); const tauD = groundTruthTauDecay(); @@ -273,6 +284,16 @@ export function KernelDisplay(): JSX.Element { Fast: {fastRatio()!.toFixed(0)}% + + {(info) => ( + + ⚠ {info.bad}/{info.total} fits degenerate + + )} + {(shape) => ( <> diff --git a/apps/cadecon/src/lib/__tests__/iteration-manager.test.ts b/apps/cadecon/src/lib/__tests__/iteration-manager.test.ts index a3e2f3f8..a75a17fa 100644 --- a/apps/cadecon/src/lib/__tests__/iteration-manager.test.ts +++ b/apps/cadecon/src/lib/__tests__/iteration-manager.test.ts @@ -106,6 +106,7 @@ function completeJob(job: DispatchedJob): void { tauRiseFast: 0.05, tauDecayFast: 0.4, betaFast: 1, + fitMode: 'TwoComponent', }); } else { // seed-trace diff --git a/apps/cadecon/src/lib/__tests__/iteration-store.test.ts b/apps/cadecon/src/lib/__tests__/iteration-store.test.ts index 089a56d7..6e028278 100644 --- a/apps/cadecon/src/lib/__tests__/iteration-store.test.ts +++ b/apps/cadecon/src/lib/__tests__/iteration-store.test.ts @@ -191,6 +191,8 @@ describe('iteration-store: derived memos', () => { kernelFitR2: null, medianPve: null, traceStability: null, + degenerateSubsets: 0, + totalSubsetFits: 3, // subsetIdx deliberately does not match array position — kernel results // arrive in worker-completion order, so subsetVarianceData must read the // subsetIdx field, not the array index. @@ -253,6 +255,8 @@ describe('iteration-store: history actions', () => { kernelFitR2: null, medianPve: null, traceStability: null, + degenerateSubsets: 0, + totalSubsetFits: 0, subsets: [], }); expect(convergenceHistory()).toHaveLength(1); @@ -356,6 +360,8 @@ describe('iteration-store: resetIterationState', () => { kernelFitR2: null, medianPve: null, traceStability: null, + degenerateSubsets: 0, + totalSubsetFits: 0, subsets: [], }); updateTraceResult(cellSubsetKey(0, 0), makeTraceEntry(0, 0, 1, 0.5)); diff --git a/apps/cadecon/src/lib/iteration-manager.ts b/apps/cadecon/src/lib/iteration-manager.ts index 8c6a30d2..af68d46f 100644 --- a/apps/cadecon/src/lib/iteration-manager.ts +++ b/apps/cadecon/src/lib/iteration-manager.ts @@ -533,6 +533,8 @@ export async function startRun(): Promise { kernelFitR2: null, medianPve: null, traceStability: null, + degenerateSubsets: 0, + totalSubsetFits: 0, subsets: [], }); const initEntries: Record = {}; @@ -787,6 +789,13 @@ export async function startRun(): Promise { if (hh > 0) r2s.push(1 - r.residual / hh); } const kernelFitR2 = r2s.length > 0 ? median(r2s) : null; + + // Defensibility: count subsets whose bi-exponential fit was untrustworthy + // (no positive slow amplitude) or empty, so the UI can flag a suspect kernel. + const degenerateSubsets = kernelResults.filter( + (r) => r.fitMode === 'Degenerate' || r.fitMode === 'Empty', + ).length; + batch(() => { setCurrentTauRise(tauR); setCurrentTauDecay(tauD); @@ -807,6 +816,8 @@ export async function startRun(): Promise { kernelFitR2, medianPve, traceStability, + degenerateSubsets, + totalSubsetFits: kernelResults.length, subsets: kernelResults.map((r) => ({ subsetIdx: r.subsetIdx, tauRise: r.tauRise, diff --git a/apps/cadecon/src/lib/iteration-store.ts b/apps/cadecon/src/lib/iteration-store.ts index 6c51a998..34e8e633 100644 --- a/apps/cadecon/src/lib/iteration-store.ts +++ b/apps/cadecon/src/lib/iteration-store.ts @@ -43,6 +43,10 @@ export interface KernelSnapshot { medianPve: number | null; /** Median normalized change in deconvolved activity vs the previous iteration (→ 0 as it stabilizes). */ traceStability: number | null; + /** Count of subsets whose bi-exp fit was Degenerate/Empty this iteration (untrustworthy kernel). */ + degenerateSubsets: number; + /** Total subset fits this iteration (denominator for degenerateSubsets). */ + totalSubsetFits: number; subsets: SubsetKernelSnapshot[]; } diff --git a/apps/cadecon/src/styles/kernel-display.css b/apps/cadecon/src/styles/kernel-display.css index a7e99bec..fde9acab 100644 --- a/apps/cadecon/src/styles/kernel-display.css +++ b/apps/cadecon/src/styles/kernel-display.css @@ -36,6 +36,12 @@ } /* Ground truth stat styling (pink) */ +.kernel-display__warning { + color: #d55e00; /* Okabe-Ito vermillion — matches the colorblind-safe palette */ + font-weight: 600; + cursor: help; +} + .kernel-display__gt-stat { color: rgba(233, 30, 99, 0.8); } diff --git a/apps/cadecon/src/workers/cadecon-types.ts b/apps/cadecon/src/workers/cadecon-types.ts index 58922a14..40410004 100644 --- a/apps/cadecon/src/workers/cadecon-types.ts +++ b/apps/cadecon/src/workers/cadecon-types.ts @@ -20,6 +20,9 @@ export interface SeedTraceResult { } /** Results from kernel estimation + bi-exponential fitting. */ +/** Outcome of the bi-exponential fit; mirrors the Rust FitMode enum. */ +export type FitMode = 'TwoComponent' | 'SlowOnly' | 'Degenerate' | 'Empty'; + export interface KernelResult { hFree: Float32Array; tauRise: number; @@ -29,10 +32,11 @@ export interface KernelResult { tauRiseFast: number; tauDecayFast: number; betaFast: number; + fitMode: FitMode; } -/** Previous biexponential result for warm-starting the next fit. */ -export type WarmBiexp = Omit; +/** Previous biexponential result for warm-starting the next fit (fit_mode not needed). */ +export type WarmBiexp = Omit; /** Messages sent TO a CaDecon worker. */ export type CaDeconWorkerInbound = diff --git a/apps/cadecon/src/workers/cadecon-worker.ts b/apps/cadecon/src/workers/cadecon-worker.ts index 1be39bc5..cd55fcb3 100644 --- a/apps/cadecon/src/workers/cadecon-worker.ts +++ b/apps/cadecon/src/workers/cadecon-worker.ts @@ -8,7 +8,7 @@ import { indeca_fit_biexponential, seed_trace, } from '@calab/core'; -import type { CaDeconWorkerInbound, CaDeconWorkerOutbound } from './cadecon-types.ts'; +import type { CaDeconWorkerInbound, CaDeconWorkerOutbound, FitMode } from './cadecon-types.ts'; let cancelled = false; const EMPTY_F32 = new Float32Array(0); @@ -129,6 +129,7 @@ function handleKernelJob(req: Extract= bf` gate. +/// Explicit outcome of a bi-exponential fit, so a degenerate / fallback fit is +/// reported rather than silently inferred by the caller from `beta_fast == 0`. +/// +/// Serializes to its variant name ("TwoComponent", ...) for the JS/Python FFI. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +#[cfg_attr(feature = "jsbindings", derive(serde::Serialize))] +pub enum FitMode { + /// Slow + fast bi-exponential — a distinct fast component was resolved. + TwoComponent, + /// Single (slow-only) bi-exponential — no fast component (the default fit). + #[default] + SlowOnly, + /// A fit was produced but has no positive slow amplitude (beta <= 0): + /// the free kernel had no real transient (noise/flat) — result is untrustworthy. + Degenerate, + /// No fit could be produced (empty input): sentinel defaults, residual = inf. + Empty, +} + +impl FitMode { + /// Stable string form for the PyO3 tuple return. + pub fn as_str(&self) -> &'static str { + match self { + FitMode::TwoComponent => "TwoComponent", + FitMode::SlowOnly => "SlowOnly", + FitMode::Degenerate => "Degenerate", + FitMode::Empty => "Empty", + } + } +} + #[derive(Clone)] #[cfg_attr(feature = "jsbindings", derive(serde::Serialize))] pub struct BiexpResult { @@ -92,6 +123,9 @@ pub struct BiexpResult { pub tau_rise_fast: f64, pub tau_decay_fast: f64, pub beta_fast: f64, + /// Outcome classification; authoritative only on the value returned by + /// `fit_biexponential` (intermediate candidates carry a placeholder). + pub fit_mode: FitMode, } impl BiexpResult { @@ -104,6 +138,7 @@ impl BiexpResult { tau_rise_fast: 0.0, tau_decay_fast: 0.0, beta_fast: 0.0, + fit_mode: FitMode::Empty, } } @@ -111,6 +146,22 @@ impl BiexpResult { pub fn has_fast_component(&self) -> bool { self.tau_rise_fast > 0.0 && self.tau_decay_fast > self.tau_rise_fast } + + /// Classify the fit outcome from the fitted parameters. Called once on the + /// final result so the reported mode reflects what was actually selected. + fn classify(&self) -> FitMode { + if !self.residual.is_finite() { + return FitMode::Empty; + } + if self.beta <= 0.0 { + return FitMode::Degenerate; + } + if self.has_fast_component() && self.beta_fast > 0.0 { + FitMode::TwoComponent + } else { + FitMode::SlowOnly + } + } } /// Fit a two-component bi-exponential model to a free-form kernel. @@ -209,6 +260,7 @@ pub fn fit_biexponential( best.residual = full_residual; } + best.fit_mode = best.classify(); best } @@ -240,6 +292,7 @@ fn refine_candidate( tau_rise_fast: refined_trf, tau_decay_fast: refined_tdf, beta_fast: beta_f, + fit_mode: candidate.fit_mode, }; } } @@ -328,6 +381,7 @@ fn cold_grid_search(h_free: &[f32], fs: f64, dt: f64, skip: usize) -> (BiexpResu tau_rise_fast: 0.0, tau_decay_fast: 0.0, beta_fast: 0.0, + fit_mode: FitMode::SlowOnly, }; } @@ -365,6 +419,7 @@ fn cold_grid_search(h_free: &[f32], fs: f64, dt: f64, skip: usize) -> (BiexpResu tau_rise_fast: tau_r_fast, tau_decay_fast: tau_d_fast, beta_fast: beta_f, + fit_mode: FitMode::TwoComponent, }; } } @@ -608,6 +663,32 @@ fn golden_section_refine( mod tests { use super::*; + #[test] + fn fit_mode_empty_on_empty_input() { + let r = fit_biexponential(&[], 30.0, false, 0, None); + assert_eq!(r.fit_mode, FitMode::Empty); + } + + #[test] + fn fit_mode_degenerate_on_flat_kernel() { + // A flat (no-transient) kernel has no positive slow amplitude. + let flat = vec![0.0_f32; 100]; + let r = fit_biexponential(&flat, 30.0, true, 0, None); + assert_eq!(r.fit_mode, FitMode::Degenerate); + } + + #[test] + fn fit_mode_real_kernel_is_not_degenerate() { + let kernel = make_biexp(0.02, 0.4, 1.0, 30.0, 200); + let r = fit_biexponential(&kernel, 30.0, true, 0, None); + assert!( + matches!(r.fit_mode, FitMode::SlowOnly | FitMode::TwoComponent), + "clean biexp kernel should fit to a usable mode, got {:?}", + r.fit_mode + ); + assert!(r.beta > 0.0); + } + /// Generate a bi-exponential kernel with known parameters. fn make_biexp(tau_r: f64, tau_d: f64, beta: f64, fs: f64, n: usize) -> Vec { let dt = 1.0 / fs; diff --git a/crates/solver/src/js_indeca.rs b/crates/solver/src/js_indeca.rs index 2ff020b5..001bd4fb 100644 --- a/crates/solver/src/js_indeca.rs +++ b/crates/solver/src/js_indeca.rs @@ -149,6 +149,8 @@ pub fn indeca_fit_biexponential( tau_rise_fast: warm_tau_rise_fast, tau_decay_fast: warm_tau_decay_fast, beta_fast: warm_beta_fast, + // Placeholder; fit_biexponential reclassifies the returned result. + fit_mode: biexp_fit::FitMode::default(), }) } else { None diff --git a/crates/solver/src/peak_seed.rs b/crates/solver/src/peak_seed.rs index 9ef39543..f496a3bc 100644 --- a/crates/solver/src/peak_seed.rs +++ b/crates/solver/src/peak_seed.rs @@ -282,6 +282,7 @@ pub fn seed_kernel_estimate( tau_rise_fast, tau_decay_fast, beta_fast, + fit_mode: _, } = fit_biexponential(&free_kernel, fs, true, 0, None); SeedKernelResult { diff --git a/crates/solver/src/py_api.rs b/crates/solver/src/py_api.rs index 2b3194ad..6e744380 100644 --- a/crates/solver/src/py_api.rs +++ b/crates/solver/src/py_api.rs @@ -557,7 +557,7 @@ fn py_indeca_fit_biexponential( warm_beta_fast: f64, warm_residual: f64, use_warm: bool, -) -> PyResult<(f64, f64, f64, f64, f64, f64, f64)> { +) -> PyResult<(f64, f64, f64, f64, f64, f64, f64, String)> { let h_f32 = to_f32_vec(&h_free)?; let warm_start = if use_warm { @@ -569,6 +569,8 @@ fn py_indeca_fit_biexponential( tau_rise_fast: warm_tau_rise_fast, tau_decay_fast: warm_tau_decay_fast, beta_fast: warm_beta_fast, + // Placeholder; fit_biexponential reclassifies the returned result. + fit_mode: biexp_fit::FitMode::default(), }) } else { None @@ -584,6 +586,7 @@ fn py_indeca_fit_biexponential( result.tau_rise_fast, result.tau_decay_fast, result.beta_fast, + result.fit_mode.as_str().to_string(), )) } diff --git a/python/src/calab/_compute.py b/python/src/calab/_compute.py index 4854bcf1..0a3f7145 100644 --- a/python/src/calab/_compute.py +++ b/python/src/calab/_compute.py @@ -326,6 +326,10 @@ class BiexpFitResult(NamedTuple): Fast-component decay time constant (seconds), 0 if single-component. beta_fast : float Fast-component amplitude, 0 if single-component. + fit_mode : str + Outcome of the fit: ``"TwoComponent"``, ``"SlowOnly"``, ``"Degenerate"`` + (a fit was produced but has no positive slow amplitude — the kernel had + no real transient), or ``"Empty"`` (no fit; empty input). """ tau_rise: float @@ -335,6 +339,7 @@ class BiexpFitResult(NamedTuple): tau_rise_fast: float tau_decay_fast: float beta_fast: float + fit_mode: str = "SlowOnly" def solve_trace( diff --git a/python/tests/test_fit_mode.py b/python/tests/test_fit_mode.py new file mode 100644 index 00000000..6d98545e --- /dev/null +++ b/python/tests/test_fit_mode.py @@ -0,0 +1,31 @@ +"""The bi-exponential fit reports an explicit outcome (fit_mode) so a degenerate +or fallback fit is surfaced rather than silently inferred from beta_fast == 0.""" + +from __future__ import annotations + +import numpy as np + +from calab import fit_biexponential + + +def _biexp(tau_r: float, tau_d: float, fs: float, n: int) -> np.ndarray: + t = np.arange(n) / fs + h = np.exp(-t / tau_d) - np.exp(-t / tau_r) + return h / np.max(h) + + +def test_fit_mode_usable_for_clean_kernel(): + h = _biexp(0.02, 0.4, 30.0, 200) + result = fit_biexponential(h, 30.0) + assert result.fit_mode in ("SlowOnly", "TwoComponent") + assert result.beta > 0.0 + + +def test_fit_mode_degenerate_on_flat_kernel(): + result = fit_biexponential(np.zeros(100), 30.0) + assert result.fit_mode == "Degenerate" + + +def test_fit_mode_empty_on_empty_input(): + result = fit_biexponential(np.array([]), 30.0) + assert result.fit_mode == "Empty" From 5713adcc27e702164d14bf6d55ed5c0b9e091e4e Mon Sep 17 00:00:00 2001 From: daharoni Date: Wed, 8 Jul 2026 14:36:18 -0700 Subject: [PATCH 2/2] test(python): unpack fit_mode in test_tuple_unpacking (8-tuple) fit_biexponential now returns an 8-tuple (added fit_mode); update the pre-existing tuple-unpacking test that hard-coded 7. Co-Authored-By: Claude Fable 5 --- python/tests/test_solve_trace.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/tests/test_solve_trace.py b/python/tests/test_solve_trace.py index 3574e074..f82b3e75 100644 --- a/python/tests/test_solve_trace.py +++ b/python/tests/test_solve_trace.py @@ -182,9 +182,10 @@ def test_tuple_unpacking(self): fs = 30.0 t = np.arange(50) / fs h = np.exp(-t / 0.4) - np.exp(-t / 0.02) - tau_r, tau_d, beta, residual, tau_rf, tau_df, beta_f = fit_biexponential(h, fs) + tau_r, tau_d, beta, residual, tau_rf, tau_df, beta_f, fit_mode = fit_biexponential(h, fs) assert isinstance(tau_r, float) assert isinstance(residual, float) + assert isinstance(fit_mode, str) # ---------------------------------------------------------------------------