Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/cadecon/src/components/kernel/KernelDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -273,6 +284,16 @@ export function KernelDisplay(): JSX.Element {
Fast: <strong>{fastRatio()!.toFixed(0)}%</strong>
</span>
</Show>
<Show when={degenerateFits()} keyed>
{(info) => (
<span
class="kernel-display__warning"
title="Subset fits with no usable bi-exponential (degenerate/empty); the displayed kernel is less reliable."
>
⚠ {info.bad}/{info.total} fits degenerate
</span>
)}
</Show>
<Show when={showGroundTruth() && gtShape()} keyed>
{(shape) => (
<>
Expand Down
1 change: 1 addition & 0 deletions apps/cadecon/src/lib/__tests__/iteration-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function completeJob(job: DispatchedJob): void {
tauRiseFast: 0.05,
tauDecayFast: 0.4,
betaFast: 1,
fitMode: 'TwoComponent',
});
} else {
// seed-trace
Expand Down
6 changes: 6 additions & 0 deletions apps/cadecon/src/lib/__tests__/iteration-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -253,6 +255,8 @@ describe('iteration-store: history actions', () => {
kernelFitR2: null,
medianPve: null,
traceStability: null,
degenerateSubsets: 0,
totalSubsetFits: 0,
subsets: [],
});
expect(convergenceHistory()).toHaveLength(1);
Expand Down Expand Up @@ -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));
Expand Down
11 changes: 11 additions & 0 deletions apps/cadecon/src/lib/iteration-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,8 @@ export async function startRun(): Promise<void> {
kernelFitR2: null,
medianPve: null,
traceStability: null,
degenerateSubsets: 0,
totalSubsetFits: 0,
subsets: [],
});
const initEntries: Record<string, import('./iteration-store.ts').TraceResultEntry> = {};
Expand Down Expand Up @@ -787,6 +789,13 @@ export async function startRun(): Promise<void> {
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);
Expand All @@ -807,6 +816,8 @@ export async function startRun(): Promise<void> {
kernelFitR2,
medianPve,
traceStability,
degenerateSubsets,
totalSubsetFits: kernelResults.length,
subsets: kernelResults.map((r) => ({
subsetIdx: r.subsetIdx,
tauRise: r.tauRise,
Expand Down
4 changes: 4 additions & 0 deletions apps/cadecon/src/lib/iteration-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand Down
6 changes: 6 additions & 0 deletions apps/cadecon/src/styles/kernel-display.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 6 additions & 2 deletions apps/cadecon/src/workers/cadecon-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<KernelResult, 'hFree'>;
/** Previous biexponential result for warm-starting the next fit (fit_mode not needed). */
export type WarmBiexp = Omit<KernelResult, 'hFree' | 'fitMode'>;

/** Messages sent TO a CaDecon worker. */
export type CaDeconWorkerInbound =
Expand Down
4 changes: 3 additions & 1 deletion apps/cadecon/src/workers/cadecon-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -129,6 +129,7 @@ function handleKernelJob(req: Extract<CaDeconWorkerInbound, { type: 'kernel-job'
tau_rise_fast: number;
tau_decay_fast: number;
beta_fast: number;
fit_mode: string;
};

post(
Expand All @@ -144,6 +145,7 @@ function handleKernelJob(req: Extract<CaDeconWorkerInbound, { type: 'kernel-job'
tauRiseFast: biexpJs.tau_rise_fast,
tauDecayFast: biexpJs.tau_decay_fast,
betaFast: biexpJs.beta_fast,
fitMode: biexpJs.fit_mode as FitMode,
},
},
[hFreeArr.buffer],
Expand Down
81 changes: 81 additions & 0 deletions crates/solver/src/biexp_fit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,37 @@
/// the ceiling logic in eval_two_component can be simplified back to a
/// plain `bs >= 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 {
Expand All @@ -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 {
Expand All @@ -104,13 +138,30 @@ impl BiexpResult {
tau_rise_fast: 0.0,
tau_decay_fast: 0.0,
beta_fast: 0.0,
fit_mode: FitMode::Empty,
}
}

/// Returns true if the fit includes a fast component.
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.
Expand Down Expand Up @@ -209,6 +260,7 @@ pub fn fit_biexponential(
best.residual = full_residual;
}

best.fit_mode = best.classify();
best
}

Expand Down Expand Up @@ -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,
};
}
}
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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,
};
}
}
Expand Down Expand Up @@ -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<f32> {
let dt = 1.0 / fs;
Expand Down
2 changes: 2 additions & 0 deletions crates/solver/src/js_indeca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/solver/src/peak_seed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion crates/solver/src/py_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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(),
))
}

Expand Down
5 changes: 5 additions & 0 deletions python/src/calab/_compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading
Loading