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"
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)
# ---------------------------------------------------------------------------