Run Controls
diff --git a/apps/cadecon/src/components/controls/AlgorithmSettings.tsx b/apps/cadecon/src/components/controls/AlgorithmSettings.tsx
index 672a446..b82a4e1 100644
--- a/apps/cadecon/src/components/controls/AlgorithmSettings.tsx
+++ b/apps/cadecon/src/components/controls/AlgorithmSettings.tsx
@@ -9,6 +9,8 @@ import {
setHpFilterEnabled,
lpFilterEnabled,
setLpFilterEnabled,
+ noiseConstrained,
+ setNoiseConstrained,
maxIterations,
setMaxIterations,
convergenceTol,
@@ -87,6 +89,14 @@ export function AlgorithmSettings(): JSX.Element {
onChange={setLpFilterEnabled}
disabled={isRunLocked()}
/>
+
+
);
diff --git a/apps/cadecon/src/components/controls/DisplaySettings.tsx b/apps/cadecon/src/components/controls/DisplaySettings.tsx
new file mode 100644
index 0000000..a06d204
--- /dev/null
+++ b/apps/cadecon/src/components/controls/DisplaySettings.tsx
@@ -0,0 +1,26 @@
+import type { JSX } from 'solid-js';
+import { ToggleSwitch } from './ToggleSwitch.tsx';
+import { sparsityCompareEnabled, setSparsityCompareEnabled } from '../../lib/algorithm-store.ts';
+import { isRunLocked } from '../../lib/iteration-store.ts';
+
+/**
+ * Display / diagnostic options. These do not change the deconvolution result —
+ * they add extra material for visual inspection. The sparsity-comparison overlay
+ * must be enabled BEFORE a run because it is computed during the run, so it lives
+ * with the pre-run controls rather than being a purely post-run view toggle.
+ */
+export function DisplaySettings(): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/cadecon/src/components/traces/TraceInspector.tsx b/apps/cadecon/src/components/traces/TraceInspector.tsx
index 6188710..82a7634 100644
--- a/apps/cadecon/src/components/traces/TraceInspector.tsx
+++ b/apps/cadecon/src/components/traces/TraceInspector.tsx
@@ -53,9 +53,11 @@ import {
setShowGTCalcium,
showGTSpikes,
setShowGTSpikes,
+ showSparsityCompare,
+ setShowSparsityCompare,
viewedIteration,
} from '../../lib/viz-store.ts';
-import { upsampleFactor } from '../../lib/algorithm-store.ts';
+import { upsampleFactor, noiseConstrained } from '../../lib/algorithm-store.ts';
import { subsetRectangles, selectedSubsetIdx } from '../../lib/subset-store.ts';
import {
createGroundTruthCalciumSeries,
@@ -72,6 +74,8 @@ const RESID_GAP_FRAC = 0.05;
const RESID_SCALE = 0.25;
const TRANSIENT_TAU_MULTIPLIER = 2;
const TRACE_INSPECTOR_ZOOM_WINDOW_S = 60;
+// Distinct dashed color for the sparsity-comparison overlay (the OPPOSITE setting).
+const COMPARE_COLOR = '#e040fb';
interface BandLayout {
deconvTop: number;
@@ -211,6 +215,19 @@ export function TraceInspector(): JSX.Element {
(): Float32Array | null => effectiveResult()?.filteredTrace ?? null,
);
+ // Sparsity-comparison overlay: the opposite-setting spike counts, precomputed
+ // per iteration during the run (when the comparison option is enabled) and
+ // stored on the result. Read-only here — no solving on the browse path.
+ const comparisonDeconv = createMemo(
+ (): Float32Array | null => effectiveResult()?.comparisonSCounts ?? null,
+ );
+ const hasComparison = createMemo(() => comparisonDeconv() != null);
+
+ // Labels for the two deconv traces, by their actual setting.
+ const settingShort = (nc: boolean) => (nc ? 'noise-constr.' : 'standard');
+ const deconvLabel = () => `Deconv (${settingShort(noiseConstrained())})`;
+ const compareLabel = () => `Deconv (${settingShort(!noiseConstrained())})`;
+
// Zoom window state
const totalDuration = createMemo(() => {
const raw = fullRawTrace();
@@ -311,7 +328,7 @@ export function TraceInspector(): JSX.Element {
});
};
- const EMPTY_DATA: [number[], ...number[][]] = [[], [], [], [], [], [], [], []];
+ const EMPTY_DATA: [number[], ...number[][]] = [[], [], [], [], [], [], [], [], []];
const DOWNSAMPLE_BUCKETS = 600;
const zoomData = createMemo<[number[], ...number[][]]>(() => {
@@ -378,6 +395,17 @@ export function TraceInspector(): JSX.Element {
dsDeconv = new Array(dsX.length).fill(null) as number[];
}
+ // Sparsity comparison — opposite-setting deconv, same deconv band
+ const comp = comparisonDeconv();
+ let dsCompare: number[];
+ if (comp && comp.length >= endSample) {
+ const compSlice = comp.subarray(startSample, endSample);
+ const [, dsCompRaw] = downsampleMinMax(x, compSlice, DOWNSAMPLE_BUCKETS);
+ dsCompare = scaleToDeconvBand(dsCompRaw, rawMin, rawMax);
+ } else {
+ dsCompare = new Array(dsX.length).fill(null) as number[];
+ }
+
// Residual — compute against the working trace (what the solver actually fit)
const residSource = isFiltered ? (dsFiltered as number[]) : dsRaw;
const dsResid = computeResiduals(residSource, dsFit, rawMin, rawMax, dsX.length);
@@ -409,6 +437,7 @@ export function TraceInspector(): JSX.Element {
dsResid,
dsGTCalcium,
dsGTSpikes,
+ dsCompare,
];
});
@@ -424,10 +453,17 @@ export function TraceInspector(): JSX.Element {
{ label: 'Raw', stroke: TRACE_COLORS.raw, width: 1, show: showRaw() },
{ label: 'Filtered', stroke: TRACE_COLORS.filtered, width: 1.5, show: showFiltered() },
{ label: 'Fit', stroke: TRACE_COLORS.fit, width: 1.5, show: showFit() },
- { label: 'Deconv', stroke: TRACE_COLORS.deconv, width: 1, show: showDeconv() },
+ { label: deconvLabel(), stroke: TRACE_COLORS.deconv, width: 1, show: showDeconv() },
{ label: 'Residual', stroke: TRACE_COLORS.resid, width: 1, show: showResidual() },
gtCaSeries,
gtSpkSeries,
+ {
+ label: compareLabel(),
+ stroke: COMPARE_COLOR,
+ width: 1,
+ dash: [4, 3],
+ show: showSparsityCompare() && hasComparison(),
+ },
];
});
@@ -458,7 +494,7 @@ export function TraceInspector(): JSX.Element {
{
key: 'deconv',
color: TRACE_COLORS.deconv,
- label: 'Deconv',
+ label: deconvLabel(),
visible: showDeconv,
setVisible: setShowDeconv,
},
@@ -470,6 +506,16 @@ export function TraceInspector(): JSX.Element {
setVisible: setShowResidual,
},
];
+ if (hasComparison()) {
+ items.push({
+ key: 'compare',
+ color: COMPARE_COLOR,
+ label: compareLabel(),
+ visible: showSparsityCompare,
+ setVisible: setShowSparsityCompare,
+ dashed: true,
+ });
+ }
if (gtVisible()) {
items.push(
{
@@ -502,6 +548,10 @@ export function TraceInspector(): JSX.Element {
const r = effectiveResult();
return r ? r.sCounts.reduce((s, v) => s + v, 0).toFixed(0) : '--';
};
+ const comparisonSpikeCount = () => {
+ const c = comparisonDeconv();
+ return c ? c.reduce((s, v) => s + v, 0).toFixed(0) : null;
+ };
// Subset highlight zones for the minimap — show which time regions
// the algorithm operates on for the currently selected cell.
@@ -545,7 +595,15 @@ export function TraceInspector(): JSX.Element {
alpha: {alpha()}
PVE: {pve()}
- spikes: {spikeCount()}
+ spikes: {spikeCount()}}
+ >
+
+ spikes: {spikeCount()} ({settingShort(noiseConstrained())}) · {comparisonSpikeCount()}{' '}
+ ({settingShort(!noiseConstrained())})
+
+
diff --git a/apps/cadecon/src/lib/algorithm-store.ts b/apps/cadecon/src/lib/algorithm-store.ts
index d14c0f9..bb96c60 100644
--- a/apps/cadecon/src/lib/algorithm-store.ts
+++ b/apps/cadecon/src/lib/algorithm-store.ts
@@ -7,6 +7,14 @@ import { samplingRate } from './data-store.ts';
const [upsampleTarget, setUpsampleTarget] = createSignal(300);
const [hpFilterEnabled, setHpFilterEnabled] = createSignal(true);
const [lpFilterEnabled, setLpFilterEnabled] = createSignal(true);
+// Noise-constrained sparsity: pick the sparsest spike support whose residual
+// still reaches the data-derived noise floor (no tuning knob). Suppresses noise
+// fit as spurious spikes; benefit concentrates at low SNR. Off = current max-PVE.
+const [noiseConstrained, setNoiseConstrained] = createSignal(true);
+// Opt-in: during each iteration, also solve every trace with the OPPOSITE
+// noise-constrained setting and store it, so the Trace Inspector can overlay the
+// two instantly (no live solve). Doubles inference cost — off by default.
+const [sparsityCompareEnabled, setSparsityCompareEnabled] = createSignal(false);
const [maxIterations, setMaxIterations] = createSignal(20);
// Convergence is tested in kernel SHAPE space (peak time + FWHM). convergenceTol
@@ -53,6 +61,10 @@ export {
setHpFilterEnabled,
lpFilterEnabled,
setLpFilterEnabled,
+ noiseConstrained,
+ setNoiseConstrained,
+ sparsityCompareEnabled,
+ setSparsityCompareEnabled,
maxIterations,
setMaxIterations,
convergenceTol,
diff --git a/apps/cadecon/src/lib/cadecon-pool.ts b/apps/cadecon/src/lib/cadecon-pool.ts
index 4f86ba4..4f76261 100644
--- a/apps/cadecon/src/lib/cadecon-pool.ts
+++ b/apps/cadecon/src/lib/cadecon-pool.ts
@@ -26,6 +26,8 @@ interface TraceJobFields {
hpEnabled: boolean;
lpEnabled: boolean;
lambda: number;
+ noiseConstrained: boolean;
+ computeComparison: boolean;
warmCounts?: Float32Array;
onComplete(result: TraceResult): void;
}
@@ -120,6 +122,8 @@ const caDeconRouter: MessageRouter = {
hpEnabled: job.hpEnabled,
lpEnabled: job.lpEnabled,
lambda: job.lambda,
+ noiseConstrained: job.noiseConstrained,
+ computeComparison: job.computeComparison,
warmCounts: warmCopy,
},
transfers,
diff --git a/apps/cadecon/src/lib/iteration-manager.ts b/apps/cadecon/src/lib/iteration-manager.ts
index af68d46..1a510eb 100644
--- a/apps/cadecon/src/lib/iteration-manager.ts
+++ b/apps/cadecon/src/lib/iteration-manager.ts
@@ -43,6 +43,8 @@ import {
finalSelectionWindow,
hpFilterEnabled,
lpFilterEnabled,
+ noiseConstrained,
+ sparsityCompareEnabled,
traceFistaMaxIters,
traceFistaTol,
kernelFistaMaxIters,
@@ -164,6 +166,8 @@ function dispatchTraceJobs(
hpEnabled: boolean,
lpEnabled: boolean,
lambda: number,
+ noiseConstrained: boolean,
+ computeComparison: boolean,
prevResults?: Map,
): Promise>> {
return new Promise((resolve) => {
@@ -211,6 +215,8 @@ function dispatchTraceJobs(
hpEnabled,
lpEnabled,
lambda,
+ noiseConstrained,
+ computeComparison,
warmCounts,
onComplete(result: TraceResult) {
results[subsetIdx].set(cell, result);
@@ -447,6 +453,8 @@ export async function startRun(): Promise {
const hpOn = hpFilterEnabled();
const lpOn = lpFilterEnabled();
const sparsityLambda = 0.0;
+ const noiseConstrainedOn = noiseConstrained();
+ const computeComparison = sparsityCompareEnabled();
// Create pool
pool = createCaDeconWorkerPool();
@@ -583,6 +591,8 @@ export async function startRun(): Promise {
hpOn,
lpOn,
sparsityLambda,
+ noiseConstrainedOn,
+ computeComparison,
prevTraceCounts,
);
@@ -601,6 +611,8 @@ export async function startRun(): Promise {
>();
// Map cell → full-length filtered trace (stitched from subset windows)
const cellFiltered = new Map();
+ // Map cell → full-length opposite-setting counts (comparison overlay)
+ const cellComparison = new Map();
const batchEntries: Record = {};
for (let si = 0; si < rects.length; si++) {
const rect = rects[si];
@@ -621,6 +633,15 @@ export async function startRun(): Promise {
}
fullFilt.set(result.filteredTrace, rect.tStart);
}
+ // Stitch comparison counts subset windows into full-length arrays
+ if (result.comparisonSCounts) {
+ let fullCmp = cellComparison.get(cell);
+ if (!fullCmp) {
+ fullCmp = new Float32Array(nTp);
+ cellComparison.set(cell, fullCmp);
+ }
+ fullCmp.set(result.comparisonSCounts, rect.tStart);
+ }
cellScalars.set(cell, {
alpha: result.alpha,
baseline: result.baseline,
@@ -638,6 +659,7 @@ export async function startRun(): Promise {
baseline: result.baseline,
threshold: result.threshold,
pve: result.pve,
+ comparisonSCounts: result.comparisonSCounts,
};
}
}
@@ -656,6 +678,7 @@ export async function startRun(): Promise {
baseline: scalars.baseline,
threshold: scalars.threshold,
pve: scalars.pve,
+ comparisonSCounts: cellComparison.get(cell),
};
}
@@ -907,6 +930,8 @@ export async function startRun(): Promise {
hpEnabled: hpOn,
lpEnabled: lpOn,
lambda: sparsityLambda,
+ noiseConstrained: noiseConstrainedOn,
+ computeComparison,
warmCounts,
onComplete(result: TraceResult) {
batch(() => {
@@ -919,6 +944,7 @@ export async function startRun(): Promise {
baseline: result.baseline,
threshold: result.threshold,
pve: result.pve,
+ comparisonSCounts: result.comparisonSCounts,
});
finCompleted++;
setCompletedSubsetTraceJobs(finCompleted);
diff --git a/apps/cadecon/src/lib/iteration-store.ts b/apps/cadecon/src/lib/iteration-store.ts
index 34e8e63..79e131d 100644
--- a/apps/cadecon/src/lib/iteration-store.ts
+++ b/apps/cadecon/src/lib/iteration-store.ts
@@ -59,6 +59,8 @@ export interface TraceResultEntry {
baseline: number;
threshold: number;
pve: number;
+ /** Spike counts from the opposite noise-constrained setting (comparison overlay). */
+ comparisonSCounts?: Float32Array;
}
function cellSubsetKey(cellIndex: number, subsetIdx: number): string {
diff --git a/apps/cadecon/src/lib/tutorial/content/04-interpreting.ts b/apps/cadecon/src/lib/tutorial/content/04-interpreting.ts
index 88cfc58..09ce4ad 100644
--- a/apps/cadecon/src/lib/tutorial/content/04-interpreting.ts
+++ b/apps/cadecon/src/lib/tutorial/content/04-interpreting.ts
@@ -51,7 +51,17 @@ export const interpretingTutorial: Tutorial = {
side: 'top',
popoverClass: 'driver-popover--wide',
},
- // Step 6: When to change algorithm settings
+ // Step 6: Sparsity comparison overlay (display-only diagnostic)
+ {
+ element: '[data-tutorial="display-options"]',
+ title: 'Seeing the Sparsity’s Impact',
+ description:
+ 'CaDecon runs noise-constrained sparsity by default: it keeps only the spikes needed to explain the trace down to its noise floor, which cleans up spurious activity — the effect is largest on low-SNR cells. To see how much it is doing, enable Sparsity Comparison Overlay here before a run. The Trace Inspector then overlays the deconvolution computed both with and without the sparsity, and shows both spike counts, so you can judge the impact per cell.
' +
+ 'This is a visual-inspection aid only — it does not change the result. Because it solves every trace twice, it roughly doubles run time, so it is off by default. Leave it off for normal runs.',
+ side: 'right',
+ popoverClass: 'driver-popover--wide',
+ },
+ // Step 7: When to change algorithm settings
{
element: '[data-tutorial="algorithm-settings"]',
title: 'When to Adjust Settings',
diff --git a/apps/cadecon/src/lib/viz-store.ts b/apps/cadecon/src/lib/viz-store.ts
index 4103cb7..06014a0 100644
--- a/apps/cadecon/src/lib/viz-store.ts
+++ b/apps/cadecon/src/lib/viz-store.ts
@@ -19,6 +19,12 @@ const [showResidual, setShowResidual] = createSignal(false);
const [showGTCalcium, setShowGTCalcium] = createSignal(true);
const [showGTSpikes, setShowGTSpikes] = createSignal(true);
+// Sparsity comparison overlay (display toggle): when on, TraceInspector overlays
+// the opposite-noise-constrained deconvolution that was precomputed during the
+// run (requires `sparsityCompareEnabled`), so the impact of noise-constrained
+// sparsity is visible. Read-only on the browse path — no live solve here.
+const [showSparsityCompare, setShowSparsityCompare] = createSignal(true);
+
export {
viewedIteration,
setViewedIteration,
@@ -38,4 +44,6 @@ export {
setShowGTCalcium,
showGTSpikes,
setShowGTSpikes,
+ showSparsityCompare,
+ setShowSparsityCompare,
};
diff --git a/apps/cadecon/src/styles/layout.css b/apps/cadecon/src/styles/layout.css
index 7ca4962..c0f9c80 100644
--- a/apps/cadecon/src/styles/layout.css
+++ b/apps/cadecon/src/styles/layout.css
@@ -1,3 +1,12 @@
+/* Controls sidebar: keep each panel at its natural height and let the sidebar
+ scroll, instead of letting flexbox compress the panels together when the
+ viewport is short or the browser text size is large. Without this, the flex
+ column shrinks every panel (default flex-shrink: 1), squishing and overlapping
+ their contents even though a scrollbar is present. */
+.viz-layout__sidebar > * {
+ flex-shrink: 0;
+}
+
/* 2-column, 2-row resizable visualization grid */
.viz-grid {
diff --git a/apps/cadecon/src/workers/cadecon-types.ts b/apps/cadecon/src/workers/cadecon-types.ts
index 4041000..20a3ddd 100644
--- a/apps/cadecon/src/workers/cadecon-types.ts
+++ b/apps/cadecon/src/workers/cadecon-types.ts
@@ -10,6 +10,9 @@ export interface TraceResult {
pve: number;
iterations: number;
converged: boolean;
+ /** Spike counts from the OPPOSITE noise-constrained setting, for the comparison
+ * overlay. Present only when the trace job requested `computeComparison`. */
+ comparisonSCounts?: Float32Array;
}
/** Results from peak-seeded spike detection on a single trace. */
@@ -54,6 +57,12 @@ export type CaDeconWorkerInbound =
lpEnabled: boolean;
/** L1 sparsity penalty on spike solution. */
lambda: number;
+ /** Noise-constrained threshold selection: choose the sparsest spike support
+ * whose residual meets the data-derived noise floor (no tuning knob). */
+ noiseConstrained: boolean;
+ /** Also solve with the OPPOSITE noise-constrained setting and return it as
+ * comparisonSCounts (for the teaching/impact overlay). */
+ computeComparison: boolean;
/** Previous iteration's s_counts at original rate for warm-start. */
warmCounts?: Float32Array;
}
diff --git a/apps/cadecon/src/workers/cadecon-worker.ts b/apps/cadecon/src/workers/cadecon-worker.ts
index cd55fcb..860b49c 100644
--- a/apps/cadecon/src/workers/cadecon-worker.ts
+++ b/apps/cadecon/src/workers/cadecon-worker.ts
@@ -37,6 +37,7 @@ function handleTraceJob(req: Extract f64 {
+ let n = raw_trace.len();
+ if n < 8 {
+ return 0.0;
+ }
+ let mean = raw_trace.iter().map(|&v| v as f64).sum::() / n as f64;
+ let mut input: Vec = raw_trace.iter().map(|&v| v - mean as f32).collect();
+
+ let mut planner = RealFftPlanner::::new();
+ let r2c = planner.plan_fft_forward(n);
+ let mut spectrum = r2c.make_output_vec();
+ if r2c.process(&mut input, &mut spectrum).is_err() {
+ return 0.0;
+ }
+
+ let nyq = spectrum.len(); // n/2 + 1
+ let lo = nyq / 2;
+ let mut acc = 0.0_f64;
+ let mut cnt = 0usize;
+ for c in &spectrum[lo..nyq] {
+ acc += (c.re as f64 * c.re as f64 + c.im as f64 * c.im as f64) / n as f64;
+ cnt += 1;
+ }
+ if cnt == 0 {
+ return 0.0;
+ }
+ (acc / cnt as f64).max(0.0).sqrt()
+}
+
+fn variance(x: &[f32]) -> f64 {
+ let n = x.len();
+ if n == 0 {
+ return 0.0;
+ }
+ let mean = x.iter().map(|&v| v as f64).sum::() / n as f64;
+ x.iter().map(|&v| (v as f64 - mean).powi(2)).sum::() / n as f64
+}
+
+/// Per-sample noise std of the FILTERED trace at original-grid positions — the
+/// quantity the residual budget needs. Estimates the raw noise std from the
+/// unfiltered trace (LP-immune) and scales by the empirically-measured
+/// noise-variance gain of the actual upsample→HP/LP chain: a deterministic
+/// white probe is pushed through the same path and its grid-sample variance
+/// ratio gives the gain. This makes the budget track whatever noise survives
+/// the filters, regardless of where the (kernel-derived) LP cutoff falls.
+/// (The rolling-baseline subtraction is a mild high-pass and is not replicated
+/// in the probe; its effect on noise variance is negligible.)
+fn estimate_grid_noise_sigma(
+ raw_trace: &[f32],
+ upsample_factor: usize,
+ tau_r: f64,
+ tau_d: f64,
+ fs_up: f64,
+ hp: bool,
+ lp: bool,
+) -> f64 {
+ let sigma_raw = high_band_sigma(raw_trace);
+ if sigma_raw <= 0.0 {
+ return 0.0;
+ }
+ // No filtering: grid samples equal the raw samples, so gain is 1.
+ if !hp && !lp {
+ return sigma_raw;
+ }
+
+ // Deterministic unit-ish white probe (LCG); absolute scale cancels in the ratio.
+ let n = raw_trace.len();
+ let mut probe = vec![0.0_f32; n];
+ let mut state: u64 = 0x9E3779B97F4A7C15;
+ for v in probe.iter_mut() {
+ state = state
+ .wrapping_mul(6364136223846793005)
+ .wrapping_add(1442695040888963407);
+ *v = (((state >> 33) as f64) / ((1u64 << 31) as f64) - 1.0) as f32;
+ }
+
+ let up = upsample_trace(&probe, upsample_factor);
+ let mut solver = Solver::new();
+ solver.set_conv_mode(ConvMode::BandedAR2);
+ solver.set_params(tau_r, tau_d, 0.0, fs_up);
+ solver.set_trace(&up);
+ solver.set_hp_filter_enabled(hp);
+ solver.set_lp_filter_enabled(lp);
+ solver.apply_filter();
+ let filt = solver.get_trace();
+
+ let grid: Vec = filt
+ .iter()
+ .step_by(upsample_factor.max(1))
+ .copied()
+ .collect();
+ let var_probe = variance(&probe);
+ if var_probe <= 0.0 {
+ return sigma_raw;
+ }
+ let gain = (variance(&grid) / var_probe).max(0.0);
+ sigma_raw * gain.sqrt()
+}
#[cfg_attr(feature = "jsbindings", derive(serde::Serialize))]
pub struct InDecaResult {
@@ -195,6 +316,7 @@ fn interior_peak(s: &[f32], pad: usize) -> f32 {
/// `warm_counts`: optional spike counts from a previous iteration at the **original**
/// sampling rate. These are upsampled to a binary trace at the upsampled rate and
/// used as FISTA warm-start, which typically reduces iterations by 30-60%.
+#[allow(clippy::too_many_arguments)]
pub fn solve_trace(
trace: &[f32],
tau_r: f64,
@@ -207,6 +329,39 @@ pub fn solve_trace(
hp_enabled: bool,
lp_enabled: bool,
lambda: f64,
+) -> InDecaResult {
+ solve_trace_opts(
+ trace,
+ tau_r,
+ tau_d,
+ fs,
+ upsample_factor,
+ max_iters,
+ tol,
+ warm_counts,
+ hp_enabled,
+ lp_enabled,
+ lambda,
+ SolveOptions::default(),
+ )
+}
+
+/// See [`solve_trace`]; adds optional [`SolveOptions`] for noise-constrained
+/// threshold selection.
+#[allow(clippy::too_many_arguments)]
+pub fn solve_trace_opts(
+ trace: &[f32],
+ tau_r: f64,
+ tau_d: f64,
+ fs: f64,
+ upsample_factor: usize,
+ max_iters: u32,
+ tol: f64,
+ warm_counts: Option<&[f32]>,
+ hp_enabled: bool,
+ lp_enabled: bool,
+ lambda: f64,
+ opts: SolveOptions,
) -> InDecaResult {
let fs_up = fs * upsample_factor as f64;
let upsampled = upsample_trace(trace, upsample_factor);
@@ -257,6 +412,26 @@ pub fn solve_trace(
let banded = BandedAR2::new(tau_r, tau_d, fs_up);
+ // Noise-constrained threshold selection needs the per-sample noise std of the
+ // filtered trace at the original grid. Estimated LP-cutoff-agnostically from
+ // the raw trace's high band scaled by the filter chain's measured noise gain.
+ // Fully data-derived — no knob.
+ let selection = if opts.noise_constrained {
+ Selection::NoiseFloor {
+ sigma: estimate_grid_noise_sigma(
+ trace,
+ upsample_factor,
+ tau_r,
+ tau_d,
+ fs_up,
+ hp_enabled,
+ lp_enabled,
+ ),
+ }
+ } else {
+ Selection::MaxPve
+ };
+
// ── Step 3: Scale iteration loop ────────────────────────────────────
// Each round: prescale by alpha_est → Box[0,1] FISTA → threshold search
// against the *original* trace → lstsq recovers alpha directly.
@@ -265,6 +440,7 @@ pub fn solve_trace(
const SCALE_RTOL: f64 = 0.05;
let mut best_pve = f64::NEG_INFINITY;
+ let mut best_scale_err = f64::INFINITY;
let mut best_result: Option<(Vec, f64, f64, f64, f64, u32, bool)> = None;
// Pre-allocate scratch buffers reused across scale iterations.
@@ -325,7 +501,7 @@ pub fn solve_trace(
threshold,
pve,
..
- } = threshold_search(
+ } = threshold_search_opts(
s_norm_slice,
&working_trace,
&banded,
@@ -333,12 +509,33 @@ pub fn solve_trace(
fs_up,
upsample_factor,
f64::INFINITY,
+ selection,
);
- // Track the best result by PVE.
- // alpha_lstsq is already the true alpha (fit against original trace).
- if pve > best_pve {
+ // Scale-loop convergence error: how close the lstsq-recovered alpha is to
+ // the prescale used this round. This is the loop's own objective.
+ let scale_err = if alpha_est > 1e-10 {
+ (alpha_lstsq / alpha_est - 1.0).abs()
+ } else {
+ f64::INFINITY
+ };
+
+ // Select the best iterate across scale rounds. alpha_lstsq is already the
+ // true alpha (fit against the original trace).
+ //
+ // MaxPve keeps the highest-PVE iterate (historical behavior). Under
+ // NoiseFloor, ranking by PVE would defeat the criterion — the inner search
+ // deliberately stops at the noise floor (below max PVE), so a max-PVE outer
+ // pick would re-select the densest-fitting iteration and re-launder the
+ // sparsity. Instead select the best-calibrated prescale (smallest scale
+ // error) — the scale loop's own fixed point, which is criterion-neutral.
+ let is_better = match selection {
+ Selection::MaxPve => pve > best_pve,
+ Selection::NoiseFloor { .. } => scale_err < best_scale_err,
+ };
+ if is_better {
best_pve = pve;
+ best_scale_err = scale_err;
best_result = Some((
s_binary,
alpha_lstsq,
@@ -351,7 +548,7 @@ pub fn solve_trace(
}
// Converged: alpha_lstsq ≈ alpha_est means the prescale was correct.
- if alpha_est > 1e-10 && (alpha_lstsq / alpha_est - 1.0).abs() < SCALE_RTOL {
+ if scale_err < SCALE_RTOL {
break;
}
@@ -775,4 +972,95 @@ mod tests {
result.pve
);
}
+
+ /// Deterministic white-ish noise in [-amp, amp) (variance ≈ amp²/3).
+ fn lcg_noise(n: usize, amp: f32, seed: u64) -> Vec {
+ let mut state = seed;
+ (0..n)
+ .map(|_| {
+ state = state
+ .wrapping_mul(6364136223846793005)
+ .wrapping_add(1442695040888963407);
+ let u = ((state >> 32) as f64) / ((1u64 << 31) as f64) - 1.0;
+ (u as f32) * amp
+ })
+ .collect()
+ }
+
+ #[test]
+ fn high_band_sigma_recovers_white_noise_std() {
+ // On pure white noise the high-band periodogram mean estimates the noise
+ // variance, so its sqrt should recover the injected std.
+ let n = 4096;
+ let amp = 0.3_f32;
+ let noise = lcg_noise(n, amp, 0x1234_5678);
+ let sigma_true = (amp as f64) / 3.0_f64.sqrt();
+ let sigma_est = high_band_sigma(&noise);
+ let rel = (sigma_est - sigma_true).abs() / sigma_true;
+ assert!(
+ rel < 0.15,
+ "estimated sigma {:.4} should match injected {:.4} (rel err {:.3})",
+ sigma_est,
+ sigma_true,
+ rel
+ );
+ }
+
+ #[test]
+ fn noise_constrained_recovers_events_on_noisy_trace() {
+ // Exercise the noise-floor selection path end-to-end (solve_trace_opts →
+ // estimate_grid_noise_sigma → Selection::NoiseFloor) on a genuinely noisy
+ // trace. It should recover the real events with a reasonable fit and not
+ // wildly overcount. The per-trace count is NOT guaranteed to be below the
+ // max-PVE default — the two criteria use different search strategies — so
+ // the sparsity ordering is asserted deterministically in the threshold
+ // unit test `noise_floor_larger_budget_is_sparser` instead.
+ let spike_positions = [30usize, 100, 200, 260];
+ let alpha_true = 6.0_f32;
+ let baseline_true = 2.0_f32;
+ let kernel = build_kernel(0.02, 0.4, 30.0);
+ let n = 300;
+ let mut trace = vec![baseline_true; n];
+ for &pos in &spike_positions {
+ for (k, &kv) in kernel.iter().enumerate() {
+ if pos + k < n {
+ trace[pos + k] += alpha_true * kv;
+ }
+ }
+ }
+ let noise = lcg_noise(n, 0.4, 0x0BADC0DE);
+ for (t, &e) in trace.iter_mut().zip(&noise) {
+ *t += e;
+ }
+
+ let constrained = solve_trace_opts(
+ &trace,
+ 0.02,
+ 0.4,
+ 30.0,
+ 1,
+ 500,
+ 1e-4,
+ None,
+ false,
+ false,
+ 0.0,
+ SolveOptions {
+ noise_constrained: true,
+ },
+ );
+
+ assert_eq!(constrained.s_counts.len(), n);
+ let count: f32 = constrained.s_counts.iter().sum();
+ assert!(
+ (1.0..=(spike_positions.len() as f32 * 2.0)).contains(&count),
+ "should recover the events without gross overcounting, got {}",
+ count
+ );
+ assert!(
+ constrained.pve > 0.5,
+ "fit should be reasonable, pve {}",
+ constrained.pve
+ );
+ }
}
diff --git a/crates/solver/src/js_indeca.rs b/crates/solver/src/js_indeca.rs
index 001bd4f..036bbdf 100644
--- a/crates/solver/src/js_indeca.rs
+++ b/crates/solver/src/js_indeca.rs
@@ -34,6 +34,7 @@ pub fn indeca_solve_trace(
lp_enabled: bool,
warm_counts: &[f32],
lambda: f64,
+ noise_constrained: bool,
) -> Result {
if let Some(i) = crate::first_nonfinite(trace) {
return Err(JsError::new(&format!(
@@ -45,7 +46,7 @@ pub fn indeca_solve_trace(
} else {
Some(warm_counts)
};
- let result = indeca::solve_trace(
+ let result = indeca::solve_trace_opts(
trace,
tau_r,
tau_d,
@@ -57,6 +58,7 @@ pub fn indeca_solve_trace(
hp_enabled,
lp_enabled,
lambda,
+ indeca::SolveOptions { noise_constrained },
);
Ok(serde_wasm_bindgen::to_value(&result).unwrap_or(JsValue::NULL))
}
diff --git a/crates/solver/src/py_api.rs b/crates/solver/src/py_api.rs
index 6e74438..30e00a5 100644
--- a/crates/solver/src/py_api.rs
+++ b/crates/solver/src/py_api.rs
@@ -423,7 +423,8 @@ fn seed_kernel_estimate<'py>(
///
/// Returns (s_counts, alpha, baseline, threshold, pve, iterations, converged).
#[pyfunction]
-#[pyo3(signature = (trace, tau_rise, tau_decay, fs, upsample_factor=1, max_iters=500, tol=1e-4, hp_enabled=false, lp_enabled=false, warm_counts=None, lambda_=0.0))]
+#[pyo3(signature = (trace, tau_rise, tau_decay, fs, upsample_factor=1, max_iters=500, tol=1e-4, hp_enabled=false, lp_enabled=false, warm_counts=None, lambda_=0.0, noise_constrained=false))]
+#[allow(clippy::too_many_arguments)]
fn py_indeca_solve_trace<'py>(
py: Python<'py>,
trace: PyReadonlyArray1,
@@ -437,6 +438,7 @@ fn py_indeca_solve_trace<'py>(
lp_enabled: bool,
warm_counts: Option>,
lambda_: f64,
+ noise_constrained: bool,
) -> PyResult<(
Bound<'py, PyArray1>, // s_counts
f64, // alpha
@@ -449,7 +451,7 @@ fn py_indeca_solve_trace<'py>(
let trace_f32 = to_f32_vec(&trace)?;
let warm = optional_to_f32_vec(warm_counts)?;
- let result = indeca::solve_trace(
+ let result = indeca::solve_trace_opts(
&trace_f32,
tau_rise,
tau_decay,
@@ -461,6 +463,7 @@ fn py_indeca_solve_trace<'py>(
hp_enabled,
lp_enabled,
lambda_,
+ indeca::SolveOptions { noise_constrained },
);
Ok((
diff --git a/crates/solver/src/threshold.rs b/crates/solver/src/threshold.rs
index eda6052..b782eec 100644
--- a/crates/solver/src/threshold.rs
+++ b/crates/solver/src/threshold.rs
@@ -18,6 +18,20 @@ pub struct ThresholdResult {
pub error: f64,
}
+/// How the binarization threshold is chosen.
+#[derive(Clone, Copy)]
+pub enum Selection {
+ /// Current behavior: threshold that minimizes reconstruction error (max PVE).
+ /// This overfits — it drives the residual below the noise floor.
+ MaxPve,
+ /// Noise-constrained: the sparsest support (highest threshold) whose residual
+ /// at original-rate grid positions stays within the noise floor
+ /// `sigma^2 * n_grid_interior`. `sigma` is the noise std of the filtered trace
+ /// at original rate (data-derived, no tuning knob). Realizes an OASIS-style
+ /// "don't fit below the noise" sparsity at the stage where counts are decided.
+ NoiseFloor { sigma: f64 },
+}
+
/// Compute boundary padding for threshold search: ceil(2 * tau_d * fs_up).
/// Used to exclude edge effects from the error computation.
pub fn boundary_padding(tau_decay: f64, fs_up: f64) -> usize {
@@ -39,6 +53,30 @@ pub fn threshold_search(
fs_up: f64,
upsample_factor: usize,
max_alpha: f64,
+) -> ThresholdResult {
+ threshold_search_opts(
+ s_relaxed,
+ y,
+ banded,
+ tau_decay,
+ fs_up,
+ upsample_factor,
+ max_alpha,
+ Selection::MaxPve,
+ )
+}
+
+/// Threshold search with a selectable criterion. See [`Selection`].
+#[allow(clippy::too_many_arguments)]
+pub fn threshold_search_opts(
+ s_relaxed: &[f32],
+ y: &[f32],
+ banded: &BandedAR2,
+ tau_decay: f64,
+ fs_up: f64,
+ upsample_factor: usize,
+ max_alpha: f64,
+ selection: Selection,
) -> ThresholdResult {
let n = s_relaxed.len();
let pad = boundary_padding(tau_decay, fs_up).min(n / 4);
@@ -79,91 +117,111 @@ pub fn threshold_search(
error: f64::INFINITY,
};
- // Phase 1: Coarse search — ~50 evenly spaced thresholds
- let coarse_n = 50.min(vals.len());
- let coarse_step = if vals.len() > 1 {
- (vals.len() - 1) as f64 / (coarse_n - 1).max(1) as f64
- } else {
- 1.0
- };
-
- let mut coarse_thresholds: Vec = Vec::with_capacity(coarse_n);
- for i in 0..coarse_n {
- let idx = (i as f64 * coarse_step).round() as usize;
- let idx = idx.min(vals.len() - 1);
- coarse_thresholds.push(vals[idx] as f64);
- }
- coarse_thresholds.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
-
- // Enforce minimum threshold floor
- coarse_thresholds.retain(|&t| t >= min_threshold);
- if coarse_thresholds.is_empty() {
- // All candidates below minimum — use min_threshold as the only candidate
- coarse_thresholds.push(min_threshold);
- }
-
- let mut consecutive_increases = 0;
- for &thresh in &coarse_thresholds {
- let err = evaluate_threshold(
+ // Noise-constrained selection scans for the sparsest support within the
+ // noise floor; the max-PVE path keeps the original coarse→fine search.
+ if let Selection::NoiseFloor { sigma } = selection {
+ best.threshold = select_noise_floor_threshold(
s_relaxed,
y,
banded,
- thresh,
pad,
+ upsample_factor,
max_alpha,
+ sigma,
+ &vals,
+ min_threshold,
&mut s_bin,
&mut conv_buf,
);
- if err < best.error {
- best.error = err;
- best.threshold = thresh;
- consecutive_increases = 0;
+ } else {
+ // Phase 1: Coarse search — ~50 evenly spaced thresholds
+ let coarse_n = 50.min(vals.len());
+ let coarse_step = if vals.len() > 1 {
+ (vals.len() - 1) as f64 / (coarse_n - 1).max(1) as f64
} else {
- consecutive_increases += 1;
- if consecutive_increases >= 10 {
- break;
- }
+ 1.0
+ };
+
+ let mut coarse_thresholds: Vec = Vec::with_capacity(coarse_n);
+ for i in 0..coarse_n {
+ let idx = (i as f64 * coarse_step).round() as usize;
+ let idx = idx.min(vals.len() - 1);
+ coarse_thresholds.push(vals[idx] as f64);
}
- }
+ coarse_thresholds.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
- // Phase 2: Fine search — ~50 thresholds around the best coarse result
- let spread = if vals.len() > 1 {
- (vals[vals.len() - 1] - vals[0]) as f64 / coarse_n as f64 * 2.0
- } else {
- best.threshold * 0.2
- };
- let fine_lo = (best.threshold - spread).max(min_threshold);
- let fine_hi = best.threshold + spread;
- let fine_n = 50;
- let fine_step = (fine_hi - fine_lo) / (fine_n - 1).max(1) as f64;
-
- consecutive_increases = 0;
- for i in 0..fine_n {
- let thresh = fine_lo + i as f64 * fine_step;
- if thresh < 0.0 {
- continue;
+ // Enforce minimum threshold floor
+ coarse_thresholds.retain(|&t| t >= min_threshold);
+ if coarse_thresholds.is_empty() {
+ // All candidates below minimum — use min_threshold as the only candidate
+ coarse_thresholds.push(min_threshold);
}
- let err = evaluate_threshold(
- s_relaxed,
- y,
- banded,
- thresh,
- pad,
- max_alpha,
- &mut s_bin,
- &mut conv_buf,
- );
- if err < best.error {
- best.error = err;
- best.threshold = thresh;
- consecutive_increases = 0;
+
+ let mut consecutive_increases = 0;
+ for &thresh in &coarse_thresholds {
+ let err = evaluate_threshold(
+ s_relaxed,
+ y,
+ banded,
+ thresh,
+ pad,
+ 1,
+ max_alpha,
+ &mut s_bin,
+ &mut conv_buf,
+ );
+ if err < best.error {
+ best.error = err;
+ best.threshold = thresh;
+ consecutive_increases = 0;
+ } else {
+ consecutive_increases += 1;
+ if consecutive_increases >= 10 {
+ break;
+ }
+ }
+ }
+
+ // Phase 2: Fine search — ~50 thresholds around the best coarse result
+ let spread = if vals.len() > 1 {
+ (vals[vals.len() - 1] - vals[0]) as f64 / coarse_n as f64 * 2.0
} else {
- consecutive_increases += 1;
- if consecutive_increases >= 10 {
- break;
+ best.threshold * 0.2
+ };
+ let fine_lo = (best.threshold - spread).max(min_threshold);
+ let fine_hi = best.threshold + spread;
+ let fine_n = 50;
+ let fine_step = (fine_hi - fine_lo) / (fine_n - 1).max(1) as f64;
+
+ consecutive_increases = 0;
+ for i in 0..fine_n {
+ let thresh = fine_lo + i as f64 * fine_step;
+ if thresh < 0.0 {
+ continue;
+ }
+ let err = evaluate_threshold(
+ s_relaxed,
+ y,
+ banded,
+ thresh,
+ pad,
+ 1,
+ max_alpha,
+ &mut s_bin,
+ &mut conv_buf,
+ );
+ if err < best.error {
+ best.error = err;
+ best.threshold = thresh;
+ consecutive_increases = 0;
+ } else {
+ consecutive_increases += 1;
+ if consecutive_increases >= 10 {
+ break;
+ }
}
}
- }
+ } // end max-PVE search branch
// Final pass: compute full result at best threshold
binarize(s_relaxed, best.threshold, &mut s_bin);
@@ -213,13 +271,21 @@ fn binarize(s: &[f32], threshold: f64, s_bin: &mut [f32]) {
}
}
-/// Evaluate a single threshold: binarize → convolve → lstsq → error.
+/// Evaluate a single threshold: binarize → convolve → lstsq → residual SSE.
+///
+/// Alpha/baseline are always fit over the full interior; the SSE is accumulated
+/// over the interior sampling every `stride`-th position (`stride >= 1`).
+/// `stride == 1` sums the whole interior — the max-PVE search. A larger stride
+/// restricts the residual to original-rate grid positions (`stride == upsample_
+/// factor`), where the upsampled trace equals the real samples and the noise is
+/// uncorrelated — the quantity the noise-floor budget is calibrated against.
fn evaluate_threshold(
s_relaxed: &[f32],
y: &[f32],
banded: &BandedAR2,
threshold: f64,
pad: usize,
+ stride: usize,
max_alpha: f64,
s_bin: &mut [f32],
conv_buf: &mut [f32],
@@ -229,17 +295,89 @@ fn evaluate_threshold(
let (alpha, baseline) = lstsq_alpha_baseline(conv_buf, y, pad, max_alpha);
- // Error over the interior (excluding boundary padding)
let n = y.len();
let mut err = 0.0_f64;
- for i in pad..n.saturating_sub(pad) {
+ let mut i = pad;
+ while i < n.saturating_sub(pad) {
let pred = alpha * conv_buf[i] as f64 + baseline;
let d = y[i] as f64 - pred;
err += d * d;
+ i += stride.max(1);
}
err
}
+/// Scan candidate thresholds and return the highest (sparsest) one whose
+/// grid-residual stays within the noise budget `sigma^2 * n_grid_interior`.
+/// Residual increases as the threshold rises (fewer spikes → worse fit), so the
+/// highest feasible threshold is the sparsest support that still explains the
+/// signal down to — but not below — the noise floor. Falls back to the
+/// min-grid-residual threshold if none is feasible (budget tighter than the
+/// best achievable fit).
+#[allow(clippy::too_many_arguments)]
+fn select_noise_floor_threshold(
+ s_relaxed: &[f32],
+ y: &[f32],
+ banded: &BandedAR2,
+ pad: usize,
+ upsample_factor: usize,
+ max_alpha: f64,
+ sigma: f64,
+ vals: &[f32],
+ min_threshold: f64,
+ s_bin: &mut [f32],
+ conv_buf: &mut [f32],
+) -> f64 {
+ let n = y.len();
+ // Evaluate the noise constraint on original-rate grid positions only
+ // (stride = upsample_factor): there the upsampled trace equals the real
+ // samples, avoiding the correlated, reduced-variance noise at interpolated
+ // positions, so the budget can use a noise std estimated at the original rate.
+ let stride = upsample_factor.max(1);
+ let n_grid = (pad..n.saturating_sub(pad)).step_by(stride).count();
+ let budget = sigma * sigma * n_grid as f64;
+
+ // Up to `cap` candidate thresholds, evenly spaced through the sorted values.
+ // `cap` is a fixed search resolution (mirroring the coarse/fine counts in the
+ // max-PVE path), not a result-affecting tuning knob.
+ let cap = 256usize;
+ let step = if vals.len() > cap {
+ vals.len() as f64 / cap as f64
+ } else {
+ 1.0
+ };
+ let mut candidates: Vec = Vec::with_capacity(cap.min(vals.len()));
+ let mut idx = 0.0_f64;
+ while (idx as usize) < vals.len() {
+ let thr = vals[idx as usize] as f64;
+ idx += step;
+ if thr >= min_threshold {
+ candidates.push(thr);
+ }
+ }
+
+ // Grid residual rises as the threshold rises (fewer spikes → worse fit), so
+ // the sparsest feasible support is the *highest* threshold within budget.
+ // Scan top-down and return the first feasible one. If none is feasible
+ // (budget tighter than the best achievable fit), fall back to the
+ // min-residual threshold found over the same scan.
+ let mut best_effort_thr = min_threshold;
+ let mut best_effort_sse = f64::INFINITY;
+ for &thr in candidates.iter().rev() {
+ let sse = evaluate_threshold(
+ s_relaxed, y, banded, thr, pad, stride, max_alpha, s_bin, conv_buf,
+ );
+ if sse <= budget {
+ return thr;
+ }
+ if sse < best_effort_sse {
+ best_effort_sse = sse;
+ best_effort_thr = thr;
+ }
+ }
+ best_effort_thr
+}
+
/// Least-squares fit for alpha and baseline: y ≈ alpha * conv + baseline.
/// Solves the 2x2 normal equations over the inner region [pad..n-pad].
/// Alpha is constrained to [0, max_alpha]. When max_alpha is f64::INFINITY
@@ -430,4 +568,142 @@ mod tests {
assert_eq!(boundary_padding(0.2, 100.0), 40);
assert_eq!(boundary_padding(1.0, 10.0), 20);
}
+
+ /// Deterministic white-ish noise in [-amp, amp) via an LCG (tests must not
+ /// use real RNG). Zero-mean in expectation; variance ≈ amp²/3.
+ fn lcg_noise(n: usize, amp: f32, seed: u64) -> Vec {
+ let mut state = seed;
+ (0..n)
+ .map(|_| {
+ state = state
+ .wrapping_mul(6364136223846793005)
+ .wrapping_add(1442695040888963407);
+ let u = ((state >> 32) as f64) / ((1u64 << 31) as f64) - 1.0;
+ (u as f32) * amp
+ })
+ .collect()
+ }
+
+ /// Build a graded relaxed solution + noisy observed trace with three true
+ /// spikes whose relaxed values differ (1.0 / 0.85 / 0.7) plus weaker spurious
+ /// bumps (0.55), so the budget genuinely controls how many survive.
+ fn graded_case(banded: &BandedAR2, n: usize) -> (Vec, Vec, f64) {
+ let true_pos = [80usize, 250, 430];
+ let true_val = [1.0_f32, 0.85, 0.7];
+ let mut s_bin_true = vec![0.0_f32; n];
+ let mut s_relaxed = vec![0.0_f32; n];
+ for (&p, &v) in true_pos.iter().zip(&true_val) {
+ s_bin_true[p] = 1.0;
+ s_relaxed[p] = v;
+ }
+ for &p in &[40usize, 150, 320, 500, 560] {
+ s_relaxed[p] = 0.55;
+ }
+ let mut conv = vec![0.0_f32; n];
+ banded.convolve_forward(&s_bin_true, &mut conv);
+ let alpha = 8.0_f64;
+ let baseline = 1.0_f64;
+ let amp = 0.25_f32;
+ let noise = lcg_noise(n, amp, 0xABCDEF01);
+ let y: Vec = conv
+ .iter()
+ .zip(&noise)
+ .map(|(&c, &e)| (alpha * c as f64 + baseline) as f32 + e)
+ .collect();
+ let noise_std = (amp as f64) / 3.0_f64.sqrt();
+ (s_relaxed, y, noise_std)
+ }
+
+ #[test]
+ fn noise_floor_larger_budget_is_sparser() {
+ // A larger noise budget admits sparser (higher) thresholds, so it should
+ // never yield more spikes than a tight budget. With a huge budget the
+ // sparsest support (highest candidate) is immediately feasible; with a
+ // budget just above the noise floor, all three true spikes are required.
+ let banded = BandedAR2::new(0.02, 0.4, 30.0);
+ let n = 600;
+ let (s_relaxed, y, noise_std) = graded_case(&banded, n);
+
+ let tight = threshold_search_opts(
+ &s_relaxed,
+ &y,
+ &banded,
+ 0.4,
+ 30.0,
+ 1,
+ f64::INFINITY,
+ Selection::NoiseFloor {
+ sigma: 1.5 * noise_std,
+ },
+ );
+ let loose = threshold_search_opts(
+ &s_relaxed,
+ &y,
+ &banded,
+ 0.4,
+ 30.0,
+ 1,
+ f64::INFINITY,
+ Selection::NoiseFloor { sigma: 100.0 },
+ );
+
+ let tight_count: f32 = tight.s_binary.iter().sum();
+ let loose_count: f32 = loose.s_binary.iter().sum();
+ assert!(
+ loose.threshold >= tight.threshold,
+ "looser budget should pick a higher threshold ({} vs {})",
+ loose.threshold,
+ tight.threshold
+ );
+ assert!(
+ loose_count <= tight_count,
+ "looser budget should not add spikes ({} vs {})",
+ loose_count,
+ tight_count
+ );
+ assert!(
+ (tight_count - 3.0).abs() < 0.5,
+ "tight budget should keep all three true spikes, got {}",
+ tight_count
+ );
+ assert!(
+ loose_count < tight_count,
+ "huge budget should drop the weaker spikes ({} vs {})",
+ loose_count,
+ tight_count
+ );
+ }
+
+ #[test]
+ fn noise_floor_falls_back_when_infeasible() {
+ // sigma → 0 makes the budget unreachable, so no threshold is feasible and
+ // the scan must fall back to the minimum-residual threshold — which on
+ // this signal recovers the true spikes rather than returning garbage.
+ let banded = BandedAR2::new(0.02, 0.4, 30.0);
+ let n = 600;
+ let (s_relaxed, y, _) = graded_case(&banded, n);
+
+ let result = threshold_search_opts(
+ &s_relaxed,
+ &y,
+ &banded,
+ 0.4,
+ 30.0,
+ 1,
+ f64::INFINITY,
+ Selection::NoiseFloor { sigma: 1e-9 },
+ );
+ assert!(result.threshold.is_finite());
+ let count: f32 = result.s_binary.iter().sum();
+ assert!(
+ (count - 3.0).abs() < 0.5,
+ "fallback should recover the three true spikes, got {}",
+ count
+ );
+ assert!(
+ result.pve > 0.9,
+ "fallback fit should explain the signal, pve {}",
+ result.pve
+ );
+ }
}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a498406..761c7ff 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -3,83 +3,92 @@
Repo-level changelog for the CaLab monorepo. Uses [Keep a Changelog](https://keepachangelog.com/) format.
Versions correspond to git tags (`v*`) and apply to the entire monorepo.
-## [Unreleased]
+## [2.6.0]
-> This section accumulates every monorepo change since `v2.0.6` (PRs #58–#165);
-> there has been no version tag in this window. Entries for PRs #60–#155 were
-> reconstructed from git history (the changelog had lapsed) and consolidate
-> closely-related PRs into single bullets for readability.
+> Unreleased. Covers every change since `v2.5.0` (PR #168).
+
+### Added
+
+- **CaDecon** noise-constrained sparsity — an optional `noise_constrained`
+ spike-inference mode that picks the binarization threshold as the sparsest
+ spike support whose residual still reaches the data-derived noise floor,
+ instead of the fit-maximizing threshold. Knob-free and off by default;
+ suppresses spurious low-SNR spikes. Exposed through the WASM solver, the
+ CaDecon UI, and the `calab.solve_trace` Python binding (PR #168)
+
+## [2.5.0] - 2026-07-08
+
+> Covers PRs #153–#167 (all merged 2026-07-08).
### Added
-- **CaDecon** — a new app for automated calcium deconvolution (the InDeCa
- algorithm) that estimates the kernel and deconvolution parameters directly
- from the data, no manual tuning required: app scaffold + data loading +
- subset UI, the InDeCa compute engine with warm-start, visualization / QC
- distributions / drill-down, community-database integration, ground-truth
- overlay, and reaching functional parity with the reference InDeCa
- implementation (PRs #85, #86, #87, #88, #90, #91, #94)
-- CaDecon convergence redesign — converge in kernel **shape space** (peak time +
- FWHM asymptote) with median-tail kernel selection (PR #154), plus an
- **asymptote dashboard** charting the four convergence signals (PR #155);
- earlier migrated the kernel parameterization from (tau_rise, tau_decay) to
- (t_peak, FWHM) (PR #104)
- **CaDecon** bi-exponential fit outcome surfaced as `FitMode`
(`TwoComponent` / `SlowOnly` / `Degenerate` / `Empty`) on the kernel result;
Python `fit_biexponential` now returns an 8-tuple (trailing `fit_mode` string)
and `BiexpFitResult` gained a `fit_mode` field; KernelDisplay warns when
subset fits are degenerate (PR #162)
-- **`calab` Python package** greatly expanded — PyO3 bindings, CaImAn/Minian
- loaders, browser bridge, and a CLI (PR #66); CaDecon Python bridge with
- config, autorun, progress, and auto-export (PRs #108, #109); headless-browser
- batch mode + InDeCa PyO3 bindings (PR #110)
-- Shared Rust **simulation module** producing synthetic ground-truth traces,
- exposed to both Python and WASM (PR #113)
-- **Usage-analytics pipeline + admin dashboard** with breakdowns and bulk
- moderation (PRs #62, #69), extended to track CaDecon submissions (PR #93)
-- Shared **auth menu** in the header across all CaLab apps (PR #61)
-- Community: highlight your own submissions in the scatter plot (PR #63);
- DataSource tracking + bridge export button & heartbeat detection (PR #67)
-- Solver: banded AR(2) O(T) convolution + box constraint (PR #78); peak-seeded
- initial-kernel auto-estimation (PR #103); an independent fast component in the
- bi-exponential fit (PR #105); a `skip` parameter for bi-exponential fitting
- (PR #99)
-- Dynamic worker-pool scaling with a URL override (PR #77)
+- **CaDecon** convergence redesign — converge in kernel **shape space** (peak
+ time + FWHM asymptote) with median-tail kernel selection and both filters on
+ by default (PR #154), plus an **asymptote dashboard** charting the four
+ convergence signals (PR #155)
- Shared uPlot chart primitives in `@calab/ui/chart` — colorblind-safe
Okabe-Ito palette (`TRACE_COLORS`, `GROUND_TRUTH_COLORS`, `KERNEL_FIT_COLORS`,
`METRIC_COLORS`, `subsetColor`), viridis colormap (`VIRIDIS_LUT`,
`viridisRGB`/`viridisCss`), tick math (`niceTicks`), and axis/cursor/range
helpers (`chartAxis`, `labeledAxis`, `syncCursor`, `safeRange`) (PRs #158, #159, #160)
-- Chart/UX: transient-zone visual indicator (PR #81); draggable minimap edges on
- the trace overview (PR #145)
-- Tutorials: CaDecon tutorial set (PR #151); Python Package tutorial (PR #68);
- Python syntax highlighting in code blocks (PR #75)
-- Sphinx + ReadTheDocs documentation site for the Python package (PR #115)
-- Comprehensive README files across all packages and apps (PR #58)
### Changed
-- Moved the Rust solver to `crates/solver/` with dual WASM (`jsbindings`) /
- PyO3 (`pybindings`) Cargo features (PR #65)
-- Made `@calab/community` app-agnostic (PR #60)
- **CaDecon** raster overview uses the shared viridis colormap and drops the
intensity colorbar (activity is assumed to span 0→full; absolute values are
not meaningful) (PR #159)
-- Replaced the export-to-Python page with a dismissible modal (PR #79)
-- CaDecon left-sidebar layout/UX (PR #89); convergence-UI improvements and
- kernel-estimation groundwork (PR #94)
-- CaTune: log-scale DualRangeSlider, card-grid fix, tutorial baseline docs (PR #96)
-- Performance: FISTA pipeline (SIMD, loop fusion, Fenwick baseline) (PR #92);
- CaDecon iteration hot paths (PR #107); solver cleanup/dedup/optimize (PR #106);
- snappier Peak/FWHM slider drag (PR #134)
- `calab-solver` tuning-constant hygiene: introduced `SeedConfig`, shared
`baseline::DEFAULT_BASELINE_QUANTILE`, and a named `BASELINE_EMA_WEIGHT`;
deduplicated the bi-exponential fast-component grid bounds so the grid search
and golden-section refinement cannot drift (no behavior change) (PR #163)
+- Tooling: ignore local Python virtualenvs `.venv*/` (PR #156)
+- Documentation: reconciled repo docs with the CaDecon review series (PR #164),
+ aligned the CaDecon tutorials with it (PR #165), and backfilled the changelog
+ from git history (PR #167)
+
+### Fixed
+
+- `calab-solver` FFI boundaries (WASM and PyO3) reject non-finite (NaN/Inf)
+ input traces with an explicit error instead of returning garbage results
+ (PR #161)
+- Solver: banded AR(2) forward model aligned via a one-sample source delay so
+ the reconvolution matches the double-exponential kernel (PR #157)
+- CaDecon: correct per-subset kernel attribution + init/variance robustness
+ (PR #153)
+
+## [2.4.0] - 2026-03-20
+
+> Covers the entire 2.4.x line (PRs #99–#152). Reconstructed from git history;
+> closely-related PRs are consolidated into single bullets for readability.
+
+### Added
+
+- **`calab` Python package** — CaDecon Python bridge with config, autorun,
+ progress, and auto-export (PRs #108, #109); headless-browser batch mode +
+ InDeCa PyO3 bindings (PR #110)
+- Shared Rust **simulation module** producing synthetic ground-truth traces,
+ exposed to both Python and WASM (PR #113)
+- Solver: peak-seeded initial-kernel auto-estimation (PR #103); an independent
+ fast component in the bi-exponential fit (PR #105); a `skip` parameter for
+ bi-exponential fitting (PR #99)
+- Migrated the kernel parameterization from (tau_rise, tau_decay) to
+ (t_peak, FWHM) (PR #104)
+- CaDecon tutorial set (PR #151)
+- Draggable minimap edges on the trace overview (PR #145)
+- Sphinx + ReadTheDocs documentation site for the Python package (PR #115)
+
+### Changed
+
+- Performance: CaDecon iteration hot paths (PR #107); solver
+ cleanup/dedup/optimize (PR #106); snappier Peak/FWHM slider drag (PR #134)
- Tooling: ESLint/Prettier/lint-surface cleanup (PR #120); prune unused exports
and internalize test-only surface (PR #125); bump GHA for Node 24 and clear
- reactivity lint (PR #133); gitignore the whole `.claude/` directory (PR #135);
- ignore local Python virtualenvs `.venv*/` (PR #156)
+ reactivity lint (PR #133); gitignore the whole `.claude/` directory (PR #135)
- CI: Rust + Python lint/type jobs, a build matrix, and SHA-pinned actions
(PR #124)
- Tests: smoke / export-roundtrip / sub-frame-timing / warm-start quick-wins
@@ -88,29 +97,16 @@ Versions correspond to git tags (`v*`) and apply to the entire monorepo.
RLS policy matrix (PR #130); bridge timeout & mid-run crash detection (PR #131)
- Documentation: separated CaTune and CaDecon into dedicated guides (PR #117);
promoted CaDecon to stable + root README update (PR #118); reviewed/improved
- all Python docs (PR #116); improved tutorial terminology and scientific
- accuracy (PR #59); reconciled repo docs with the review series (PR #164) and
- aligned the CaDecon tutorials with it (PR #165)
+ all Python docs (PR #116)
### Fixed
-- Codebase-wide quality sweep — 26 fixes (PR #64)
- Address pre-merge audit findings — WASM drift, RLS PII, FFI panics, config,
tests (PR #150)
-- `calab-solver` FFI boundaries (WASM and PyO3) reject non-finite (NaN/Inf)
- input traces with an explicit error instead of returning garbage results
- (PR #161)
- Solver: corrected a binning-induced time offset in iterative kernel fitting
- (PR #102); golden-section refinement bug fix (PR #147); banded AR(2) forward
- model aligned via a one-sample source delay so the reconvolution matches the
- double-exponential kernel (PR #157)
-- CaDecon: correct per-subset kernel attribution + init/variance robustness
- (PR #153)
-- CaTune: minimap no longer pushes the zoom window off-screen (PRs #70, #82);
- clamp rise/decay sliders to prevent a negative kernel (PR #83); GT marker
- alignment + spectrum/zoom-window perf sweep (PR #142); repair tutorial
- highlighting after the Peak/FWHM migration (PR #143)
-- Analytics: reliable session-duration tracking via heartbeat (PR #84)
+ (PR #102); golden-section refinement bug fix (PR #147)
+- CaTune: GT marker alignment + spectrum/zoom-window perf sweep (PR #142);
+ repair tutorial highlighting after the Peak/FWHM migration (PR #143)
- Headless: prevent resource leaks on browser start/close failures (PR #121)
- Logic + UX polish — tau constraints, bridge errors, reactivity (PR #126)
- Community: show bridge/training submissions and hide demo presets under
@@ -123,6 +119,81 @@ Versions correspond to git tags (`v*`) and apply to the entire monorepo.
geo-session edge function (PR #122)
- Locked down analytics row-level security (PR #123)
+## [2.3.0] - 2026-02-26
+
+> Covers the entire 2.3.x line (PRs #85–#96). Reconstructed from git history.
+
+### Added
+
+- **CaDecon** — a new app for automated calcium deconvolution (the InDeCa
+ algorithm) that estimates the kernel and deconvolution parameters directly
+ from the data, no manual tuning required: app scaffold + data loading +
+ subset UI, the InDeCa compute engine with warm-start, visualization / QC
+ distributions / drill-down, community-database integration, and ground-truth
+ overlay (PRs #85, #86, #87, #88, #90, #91)
+- Usage analytics extended to track CaDecon submissions (PR #93)
+
+### Changed
+
+- CaDecon left-sidebar layout/UX (PR #89); convergence-UI improvements and
+ kernel-estimation groundwork, including rise-time-collapse mitigation (PR #94)
+- CaTune: log-scale DualRangeSlider, card-grid fix, tutorial baseline docs (PR #96)
+- Performance: FISTA pipeline (SIMD, loop fusion, Fenwick baseline) (PR #92)
+
+### Fixed
+
+- Solver: alpha/PVE double-counting and energy-pooling correctness (PR #91)
+
+## [2.2.0] - 2026-02-23
+
+> Covers the entire 2.2.x line (PRs #65–#84). Reconstructed from git history.
+
+### Added
+
+- **`calab` Python package** greatly expanded — PyO3 bindings, CaImAn/Minian
+ loaders, browser bridge, and a CLI (PR #66)
+- Community: DataSource tracking + bridge export button & heartbeat detection
+ (PR #67)
+- Solver: banded AR(2) O(T) convolution + box constraint (PR #78)
+- Dynamic worker-pool scaling with a URL override (PR #77)
+- Admin dashboard: analytics breakdowns and bulk moderation (PR #69)
+- Chart/UX: transient-zone visual indicator (PR #81)
+- Tutorials: Python Package tutorial (PR #68); Python syntax highlighting in
+ code blocks (PR #75)
+
+### Changed
+
+- Moved the Rust solver to `crates/solver/` with dual WASM (`jsbindings`) /
+ PyO3 (`pybindings`) Cargo features (PR #65)
+- Replaced the export-to-Python page with a dismissible modal (PR #79)
+
+### Fixed
+
+- CaTune: minimap no longer pushes the zoom window off-screen (PRs #70, #82);
+ clamp rise/decay sliders to prevent a negative kernel (PR #83)
+- Analytics: reliable session-duration tracking via heartbeat (PR #84)
+
+## [2.1.0] - 2026-02-20
+
+> Covers the 2.0.8, 2.0.9, and 2.1.x patch line (PRs #58–#64). Reconstructed
+> from git history.
+
+### Added
+
+- **Usage-analytics pipeline + admin dashboard** (PR #62)
+- Shared **auth menu** in the header across all CaLab apps (PR #61)
+- Community: highlight your own submissions in the scatter plot (PR #63)
+- Comprehensive README files across all packages and apps (PR #58)
+
+### Changed
+
+- Made `@calab/community` app-agnostic (PR #60)
+- Documentation: improved tutorial terminology and scientific accuracy (PR #59)
+
+### Fixed
+
+- Codebase-wide quality sweep — 26 fixes (PR #64)
+
## [2.0.6] - 2026-02-19
### Changed
@@ -214,7 +285,12 @@ Major restructuring into a monorepo with reusable packages.
- Renamed repo references from CaTune to CaLab
- Stabilized tooling and codified conventions (Prettier, ESLint, CI) (PR #41)
-[Unreleased]: https://github.com/miniscope/CaLab/compare/v2.0.6...HEAD
+[2.6.0]: https://github.com/miniscope/CaLab/compare/v2.5.0...HEAD
+[2.5.0]: https://github.com/miniscope/CaLab/compare/v2.4.10...v2.5.0
+[2.4.0]: https://github.com/miniscope/CaLab/compare/v2.3.8...v2.4.10
+[2.3.0]: https://github.com/miniscope/CaLab/compare/v2.2.7...v2.3.8
+[2.2.0]: https://github.com/miniscope/CaLab/compare/v2.1.2...v2.2.7
+[2.1.0]: https://github.com/miniscope/CaLab/compare/v2.0.6...v2.1.2
[2.0.6]: https://github.com/miniscope/CaLab/compare/v2.0.5...v2.0.6
[2.0.5]: https://github.com/miniscope/CaLab/compare/v2.0.4...v2.0.5
[2.0.4]: https://github.com/miniscope/CaLab/compare/v2.0.3...v2.0.4
diff --git a/python/docs/guides/cadecon.md b/python/docs/guides/cadecon.md
index eeb094d..713d278 100644
--- a/python/docs/guides/cadecon.md
+++ b/python/docs/guides/cadecon.md
@@ -205,22 +205,24 @@ calab.solve_trace(
lp_enabled: bool = False,
warm_counts: np.ndarray | None = None,
lambda_: float = 0.0,
+ noise_constrained: bool = False,
) -> SolveTraceResult
```
-| Parameter | Description |
-| ----------------- | ------------------------------------------------------ |
-| `trace` | 1-D calcium trace. |
-| `tau_rise` | Rise time constant in seconds. |
-| `tau_decay` | Decay time constant in seconds. |
-| `fs` | Sampling rate in Hz. |
-| `upsample_factor` | Upsampling multiplier (1 = no upsampling). |
-| `max_iters` | Maximum FISTA iterations. |
-| `tol` | Convergence tolerance. |
-| `hp_enabled` | Enable high-pass filtering. |
-| `lp_enabled` | Enable low-pass filtering. |
-| `warm_counts` | Spike counts from a previous iteration for warm-start. |
-| `lambda_` | L1 sparsity penalty (0 = auto). |
+| Parameter | Description |
+| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `trace` | 1-D calcium trace. |
+| `tau_rise` | Rise time constant in seconds. |
+| `tau_decay` | Decay time constant in seconds. |
+| `fs` | Sampling rate in Hz. |
+| `upsample_factor` | Upsampling multiplier (1 = no upsampling). |
+| `max_iters` | Maximum FISTA iterations. |
+| `tol` | Convergence tolerance. |
+| `hp_enabled` | Enable high-pass filtering. |
+| `lp_enabled` | Enable low-pass filtering. |
+| `warm_counts` | Spike counts from a previous iteration for warm-start. |
+| `lambda_` | L1 sparsity penalty (0 = auto). |
+| `noise_constrained` | Pick the binarization threshold as the sparsest spike support whose residual still reaches the data-derived noise floor, instead of the fit-maximizing threshold. Knob-free; suppresses spurious low-SNR spikes. Default `False`. |
Returns a `SolveTraceResult` namedtuple with fields: `s_counts`, `alpha`, `baseline`, `threshold`, `pve`, `iterations`, `converged`.
diff --git a/python/src/calab/_compute.py b/python/src/calab/_compute.py
index 0a3f714..713b557 100644
--- a/python/src/calab/_compute.py
+++ b/python/src/calab/_compute.py
@@ -355,6 +355,7 @@ def solve_trace(
lp_enabled: bool = False,
warm_counts: np.ndarray | None = None,
lambda_: float = 0.0,
+ noise_constrained: bool = False,
) -> SolveTraceResult:
"""Run the InDeCa pipeline on a single trace. Delegates to Rust.
@@ -378,6 +379,12 @@ def solve_trace(
Spike counts from a previous iteration (at original rate) for warm-start.
lambda_ : float
L1 sparsity penalty.
+ noise_constrained : bool
+ Choose the binarization threshold as the sparsest spike support whose
+ residual still reaches the data-derived noise floor, instead of the one
+ that maximizes fit. Suppresses noise fit as spurious spikes; the effect
+ concentrates at low SNR. Knob-free (the noise floor is measured from the
+ trace). Default False.
Returns
-------
@@ -392,6 +399,7 @@ def solve_trace(
trace_1d, tau_rise, tau_decay, fs,
upsample_factor, max_iters, tol,
hp_enabled, lp_enabled, warm, lambda_,
+ noise_constrained,
)
return SolveTraceResult(
s_counts=np.asarray(s_counts),
diff --git a/python/tests/test_solve_trace.py b/python/tests/test_solve_trace.py
index f82b3e7..369ddeb 100644
--- a/python/tests/test_solve_trace.py
+++ b/python/tests/test_solve_trace.py
@@ -86,6 +86,31 @@ def test_tuple_unpacking(self):
assert isinstance(alpha, float)
assert isinstance(converged, bool)
+ def test_noise_constrained_accepted(self):
+ # The noise_constrained knob is exposed through the binding and produces
+ # a valid result without changing output shape.
+ trace = _make_trace(0.02, 0.4, 30.0, 300, [30, 100, 200], alpha=10.0, baseline=2.0)
+ result = solve_trace(trace, 0.02, 0.4, 30.0, noise_constrained=True)
+ assert isinstance(result, SolveTraceResult)
+ assert result.s_counts.shape == (300,)
+ assert result.s_counts.sum() >= 0
+
+ def test_noise_constrained_detects_events_with_noise(self):
+ # Exercise the noise-floor selection path end-to-end on a genuinely noisy
+ # trace (a noiseless trace only hits the fallback branch). The option
+ # should still recover the real events and produce a valid fit. Note the
+ # per-trace spike count is not guaranteed to be <= the default: the two
+ # criteria use different search strategies (see the Rust
+ # `noise_floor_larger_budget_is_sparser` test for the sparsity invariant).
+ rng = np.random.default_rng(0)
+ clean = _make_trace(0.02, 0.4, 30.0, 400, [40, 150, 300], alpha=4.0, baseline=2.0)
+ trace = clean + rng.normal(0.0, 0.4, size=clean.shape)
+
+ result = solve_trace(trace, 0.02, 0.4, 30.0, noise_constrained=True)
+ assert result.s_counts.shape == (400,)
+ assert result.s_counts.sum() >= 1
+ assert result.pve > 0.5
+
# ---------------------------------------------------------------------------
# estimate_kernel