Skip to content
Open
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
445 changes: 347 additions & 98 deletions apps/cadecon/src/components/charts/AsymptoteTrends.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/cadecon/src/lib/__tests__/iteration-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe('iteration-manager: startRun dispatch sequence', () => {

it('converges early when the kernel shape stabilises (patience streak)', async () => {
// The fake pool always returns the same tauRise/tauDecay, so every
// iteration's (tPeak, FWHM) is identical → shapeDelta ~ 0 < convTol. After
// iteration's kernel is identical → kernelRmse ~ 0 < convTol. After
// `patience` consecutive stable iterations (past minIters) the loop stops and
// records the FIRST iteration of the confirming window.
seedMinimalRun();
Expand Down
6 changes: 3 additions & 3 deletions apps/cadecon/src/lib/__tests__/iteration-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ describe('iteration-store: derived memos', () => {
fs: 30,
tPeak: 0.05,
fwhm: 0.2,
shapeDelta: null,
kernelRmse: null,
riseUnresolved: false,
kernelFitR2: null,
medianPve: null,
Expand Down Expand Up @@ -250,7 +250,7 @@ describe('iteration-store: history actions', () => {
fs: 30,
tPeak: null,
fwhm: null,
shapeDelta: null,
kernelRmse: null,
riseUnresolved: false,
kernelFitR2: null,
medianPve: null,
Expand Down Expand Up @@ -355,7 +355,7 @@ describe('iteration-store: resetIterationState', () => {
fs: 30,
tPeak: null,
fwhm: null,
shapeDelta: null,
kernelRmse: null,
riseUnresolved: false,
kernelFitR2: null,
medianPve: null,
Expand Down
11 changes: 6 additions & 5 deletions apps/cadecon/src/lib/algorithm-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ const [noiseConstrained, setNoiseConstrained] = createSignal(true);
const [sparsityCompareEnabled, setSparsityCompareEnabled] = createSignal(false);
const [maxIterations, setMaxIterations] = createSignal(20);

// Convergence is tested in kernel SHAPE space (peak time + FWHM). convergenceTol
// is the relative change of BOTH peak and FWHM below which an iteration counts as
// stable; patience/minIters gate when convergence may be declared; the final
// kernel is the median of the last `finalSelectionWindow` iterates' shapes.
// Defaults live in @calab/core CONVERGENCE_RANGES (single source of truth).
// Convergence is tested with the peak-normalized RMSE between successive
// iterations' kernels. convergenceTol is the fraction-of-peak change below which
// an iteration counts as stable; patience/minIters gate when convergence may be
// declared; the final kernel is the median of the last `finalSelectionWindow`
// iterates' shapes. Defaults live in @calab/core CONVERGENCE_RANGES (single
// source of truth).
const [convergenceTol, setConvergenceTol] = createSignal<number>(
CONVERGENCE_RANGES.convergenceTol.default,
);
Expand Down
79 changes: 48 additions & 31 deletions apps/cadecon/src/lib/chart/vertical-marker-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,48 @@

import type uPlot from 'uplot';

interface VerticalMarkerStyle {
stroke: string;
labelColor: string;
label?: string;
dash?: number[];
}

/**
* Draw one labeled dashed vertical marker at data-x `value` (self-contained:
* saves/restores ctx; no-op if `value` is outside the x-scale). Shared by
* verticalMarkerPlugin and any plugin that draws several markers in one hook.
*/
export function drawVerticalMarker(u: uPlot, value: number, style: VerticalMarkerStyle): void {
const xMin = u.scales.x.min;
const xMax = u.scales.x.max;
if (xMin == null || xMax == null || value < xMin || value > xMax) return;

const ctx = u.ctx;
const dpr = devicePixelRatio;
const { top, height } = u.bbox;
const xPx = u.valToPos(value, 'x', true);

ctx.save();
ctx.strokeStyle = style.stroke;
ctx.lineWidth = 1.5 * dpr;
ctx.setLineDash((style.dash ?? []).map((d) => d * dpr));
ctx.beginPath();
ctx.moveTo(xPx, top);
ctx.lineTo(xPx, top + height);
ctx.stroke();

if (style.label) {
ctx.setLineDash([]);
ctx.font = `${9 * dpr}px sans-serif`;
ctx.fillStyle = style.labelColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(style.label, xPx, top - 2 * dpr);
}
ctx.restore();
}

interface VerticalMarkerOptions {
getValue: () => number | null;
label: (value: number) => string;
Expand All @@ -16,37 +58,12 @@ export function verticalMarkerPlugin(opts: VerticalMarkerOptions): uPlot.Plugin
draw(u: uPlot) {
const value = opts.getValue();
if (value == null) return;

const xMin = u.scales.x.min!;
const xMax = u.scales.x.max!;
if (value < xMin || value > xMax) return;

const ctx = u.ctx;
const dpr = devicePixelRatio;
const { top, height } = u.bbox;
const xPx = u.valToPos(value, 'x', true);

ctx.save();

// Vertical line
ctx.strokeStyle = opts.strokeColor;
ctx.lineWidth = 1.5 * dpr;
ctx.setLineDash((opts.dash ?? []).map((d) => d * dpr));
ctx.beginPath();
ctx.moveTo(xPx, top);
ctx.lineTo(xPx, top + height);
ctx.stroke();

// Label
const fontSize = 9 * dpr;
ctx.font = `${fontSize}px sans-serif`;
ctx.setLineDash([]);
ctx.fillStyle = opts.labelColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(opts.label(value), xPx, top - 2 * dpr);

ctx.restore();
drawVerticalMarker(u, value, {
stroke: opts.strokeColor,
labelColor: opts.labelColor,
label: opts.label(value),
dash: opts.dash,
});
},
},
};
Expand Down
81 changes: 52 additions & 29 deletions apps/cadecon/src/lib/iteration-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
// 5. On convergence/max iters: finalization pass on ALL cells

import { batch } from 'solid-js';
import { tauToShape, shapeToTau, type WorkerPool } from '@calab/compute';
import {
tauToShape,
shapeToTau,
kernelShapeRmse,
KERNEL_DURATION_MULTIPLE,
type WorkerPool,
} from '@calab/compute';
import { createCaDeconWorkerPool, type CaDeconPoolJob } from './cadecon-pool.ts';
import type {
TraceResult,
Expand Down Expand Up @@ -79,8 +85,6 @@ export const BIEXP_FIT_SKIP = 0;
*/
type KernelJobResult = KernelResult & { subsetIdx: number };

/** Denominator guard for relative shape deltas (peak/FWHM are in seconds). */
const SHAPE_EPS = 1e-9;
/** Absolute floor of the Rust tau_rise clamp (mirrors biexp_fit.rs tau_r_lo). */
const RISE_CLAMP_FLOOR_S = 0.005;
/** tau_rise within this factor of the clamp floor is flagged "rise unresolved". */
Expand All @@ -100,7 +104,14 @@ function sumSq(a: ArrayLike<number>): number {
/**
* Median normalized L2 change in per-cell activity between two iterations'
* stitched s_counts maps: median over cells present in both (and non-silent) of
* ||s_new - s_old|| / (||s_new|| + eps). Returns null if no comparable cells.
* ||s_new - s_old|| / (max(||s_new||, ||s_old||) + eps). Returns null if no
* comparable cells.
*
* The symmetric max(...) denominator keeps the ratio bounded to [0, sqrt(2)] and
* stops it from exploding when a cell's activity collapses toward zero between
* iterations (which a ||s_new||-only denominator would). s_counts are native-rate
* bin counts (the solver downsamples the upsampled train before returning), so
* this measures change at the resolution the data actually constrains.
*/
function computeTraceStability(
prev: Map<number, Float32Array> | undefined,
Expand All @@ -112,13 +123,15 @@ function computeTraceStability(
const sOld = prev.get(cell);
if (!sOld || sOld.length !== sNew.length) continue;
const normNew = Math.sqrt(sumSq(sNew));
if (normNew < STABILITY_MIN_ACTIVITY) continue;
const normOld = Math.sqrt(sumSq(sOld));
const denom = Math.max(normNew, normOld);
if (denom < STABILITY_MIN_ACTIVITY) continue;
let diff = 0;
for (let i = 0; i < sNew.length; i++) {
const d = sNew[i] - sOld[i];
diff += d * d;
}
deltas.push(Math.sqrt(diff) / (normNew + STABILITY_EPS));
deltas.push(Math.sqrt(diff) / (denom + STABILITY_EPS));
}
return deltas.length > 0 ? median(deltas) : null;
}
Expand Down Expand Up @@ -438,7 +451,8 @@ export async function startRun(): Promise<void> {
let tauD = TAU_DECAY_FALLBACK;
const upFactor = upsampleFactor();
const maxIter = maxIterations();
// Shape-space convergence controls (see algorithm-store / CONVERGENCE_RANGES).
// Convergence controls (see algorithm-store / CONVERGENCE_RANGES). convTol is
// the kernel-RMSE threshold; selWindow is still a shape-space (median) control.
const convTol = convergenceTol();
const patience = convergencePatience();
const minIters = convergenceMinIters();
Expand Down Expand Up @@ -502,22 +516,26 @@ export async function startRun(): Promise<void> {
return;
}

// Kernel length: 5x tau_decay in samples (matches CaTune's computeKernel convention)
const kernelLength = Math.max(10, Math.ceil(5.0 * tauD * fs));
// Kernel length: KERNEL_DURATION_MULTIPLE x tau_decay in samples (matches CaTune's computeKernel convention)
const kernelLength = Math.max(10, Math.ceil(KERNEL_DURATION_MULTIPLE * tauD * fs));

// Warm-start state carried between iterations
let prevTraceCounts: Map<number, Float32Array> | undefined;
let prevKernels: Float32Array[] | undefined;
let prevBiexpResults: WarmBiexp[] | undefined;

// Shape-space convergence tracking. (tau_rise, tau_decay) is a degenerate
// convergence coordinate (tau_rise <-> tau_decay thrash inflates the delta), so
// we test convergence in (tPeak, FWHM) space: an iteration is "stable" when both
// move less than convTol relative to the previous one, and we declare
// convergence after `patience` consecutive stable iterations. The final kernel
// is the median of the last `selWindow` iterates' shapes — not the argmin of the
// (bouncy, unreliable) bi-exponential residual, which the old revert used.
// Convergence tracking. We test convergence with the peak-normalized RMSE
// between successive iterations' bi-exponential kernels (kernelShapeRmse): an
// iteration is "stable" when that whole-waveform change is < convTol, and we
// declare convergence after `patience` consecutive stable iterations. RMSE on
// the waveform avoids the old (tPeak, FWHM) relative delta's over-sensitivity to
// t_peak jitter on the poorly-constrained rising edge. (tPeak/FWHM are still
// tracked for the Kernel-tab diagnostics and the final-selection step.) The
// final kernel is the median of the last `selWindow` iterates' shapes — not the
// argmin of the (bouncy, unreliable) bi-exponential residual.
let prevShape = tauToShape(tauR, tauD);
let prevTauR = tauR;
let prevTauD = tauD;
let stableCount = 0;
let firstStableIter: number | null = null;
const shapeTrail: Array<{ tauRise: number; tauDecay: number; tPeak: number; fwhm: number }> = [];
Expand All @@ -536,7 +554,7 @@ export async function startRun(): Promise<void> {
fs,
tPeak: prevShape?.tPeak ?? null,
fwhm: prevShape?.fwhm ?? null,
shapeDelta: null,
kernelRmse: null,
riseUnresolved: false,
kernelFitR2: null,
medianPve: null,
Expand Down Expand Up @@ -783,13 +801,13 @@ export async function startRun(): Promise<void> {
tauR = median(tauRises);
tauD = median(tauDecays);

// Convergence coordinate: kernel shape (peak time + FWHM).
// Convergence metric: peak-normalized RMSE between this iteration's kernel and
// the previous one (fraction of peak, → 0 at convergence). A degenerate shape
// (current or previous) leaves it null, which resets the stability streak.
const shape = tauToShape(tauR, tauD);
let shapeDelta: number | null = null;
let kernelRmse: number | null = null;
if (shape && prevShape) {
const dPeak = Math.abs(shape.tPeak - prevShape.tPeak) / (prevShape.tPeak + SHAPE_EPS);
const dFwhm = Math.abs(shape.fwhm - prevShape.fwhm) / (prevShape.fwhm + SHAPE_EPS);
shapeDelta = Math.max(dPeak, dFwhm);
kernelRmse = kernelShapeRmse(prevTauR, prevTauD, tauR, tauD, fs);
}
const riseUnresolved = tauR <= tauRiseFloor * RISE_FLOOR_MARGIN;
if (shape) {
Expand Down Expand Up @@ -834,7 +852,7 @@ export async function startRun(): Promise<void> {
fs,
tPeak: shape?.tPeak ?? null,
fwhm: shape?.fwhm ?? null,
shapeDelta,
kernelRmse,
riseUnresolved,
kernelFitR2,
medianPve,
Expand All @@ -855,18 +873,23 @@ export async function startRun(): Promise<void> {
});
});

// Step 4: Convergence check in shape space. An iteration is "stable" when
// both peak time and FWHM change less than convTol; convergence is declared
// after `patience` consecutive stable iterations, once past `minIters`. A
// degenerate (null) shape resets the streak — it can never count as stable.
if (shape && shapeDelta !== null && iter + 1 >= minIters && shapeDelta < convTol) {
// Step 4: Convergence check on kernel-shape RMSE. An iteration is "stable"
// when the peak-normalized kernel changed less than convTol vs the previous
// iteration; convergence is declared after `patience` consecutive stable
// iterations, once past `minIters`. A null RMSE (degenerate current/previous
// shape) resets the streak — it can never count as stable.
if (kernelRmse !== null && iter + 1 >= minIters && kernelRmse < convTol) {
if (stableCount === 0) firstStableIter = iter + 1;
stableCount++;
} else {
stableCount = 0;
firstStableIter = null;
}
if (shape) prevShape = shape;
if (shape) {
prevShape = shape;
prevTauR = tauR;
prevTauD = tauD;
}

if (stableCount >= patience) {
setConvergedAtIteration(firstStableIter);
Expand Down
12 changes: 8 additions & 4 deletions apps/cadecon/src/lib/iteration-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ export interface KernelSnapshot {
tauDecayFast: number;
betaFast: number;
fs: number;
/** Kernel peak time (s), the convergence coordinate. null if shape is degenerate. */
/** Kernel peak time (s), the final-selection coordinate / Kernel-tab diagnostic. null if degenerate. */
tPeak: number | null;
/** Kernel full-width-half-max (s), the convergence coordinate. null if degenerate. */
/** Kernel full-width-half-max (s), the final-selection coordinate / Kernel-tab diagnostic. null if degenerate. */
fwhm: number | null;
/** Max relative change of tPeak/FWHM vs the previous iteration. null on iter 0 / degenerate. */
shapeDelta: number | null;
/**
* Peak-normalized RMSE between this iteration's kernel and the previous one
* (fraction of peak, → 0 at convergence) — the convergence metric. null on
* iter 0 / degenerate shape.
*/
kernelRmse: number | null;
/** True when tau_rise is pinned at the sampling-rate clamp floor (rise unresolved at this fs). */
riseUnresolved: boolean;
// --- Asymptote diagnostics (per iteration) ---
Expand Down
2 changes: 1 addition & 1 deletion apps/cadecon/src/lib/tutorial/content/01-basics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const basicsTutorial: Tutorial = {
element: '[data-tutorial="kernel-convergence"]',
title: 'Watching It Converge',
description:
'As the run iterates, these tabbed charts track the learning process. <b>Asymptote</b> (shown first) is the at-a-glance health check \u2014 small charts of the four things that should <b>flatten out</b> as the run settles: kernel shape (peak time &amp; FWHM), kernel-fit quality (R\u00b2), reconstruction quality (PVE), and activity stability. <b>Kernel</b> shows the kernel shape evolving over iterations with the per-subset spread; <b>Distributions</b> shows per-cell histograms once the run finishes. A good run shows the Asymptote curves leveling off \u2014 don\u2019t judge a fit until the run has converged.',
'As the run iterates, these tabbed charts track the learning process. <b>Asymptote</b> (shown first) is the at-a-glance health check \u2014 small charts of the signals that should settle as the run converges: the kernel-change RMSE and activity change (both decaying toward 0 / the tolerance band), plus kernel-fit quality (R\u00b2) and reconstruction quality (PVE). <b>Kernel</b> shows the kernel shape evolving over iterations with the per-subset spread; <b>Distributions</b> shows per-cell histograms once the run finishes. A good run shows the Asymptote curves leveling off \u2014 don\u2019t judge a fit until the run has converged.',
side: 'bottom',
popoverClass: 'driver-popover--wide',
},
Expand Down
2 changes: 1 addition & 1 deletion apps/cadecon/src/lib/tutorial/content/03-theory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const theoryTutorial: Tutorial = {
{
title: 'How does CaDecon know when to stop?',
description:
'More iterations aren\u2019t always better. Once the kernel stops changing much from one pass to the next, extra iterations add little \u2014 and the fit can even drift after it has peaked. Rather than watch the raw time constants (which can trade off against each other), CaDecon judges convergence by the kernel\u2019s <b>shape</b> \u2014 its <b>rise-to-peak time</b> and its <b>width (FWHM)</b>. An iteration counts as <b>stable</b> when both change by less than a small tolerance, and CaDecon declares convergence only after <b>several consecutive stable iterations</b>, so one lucky pass can\u2019t stop it early. In practice a good solution usually emerges within just a handful of iterations.<br><br>' +
'More iterations aren\u2019t always better. Once the kernel stops changing much from one pass to the next, extra iterations add little \u2014 and the fit can even drift after it has peaked. Rather than watch the raw time constants (which can trade off against each other), CaDecon judges convergence by how much the kernel\u2019s overall <b>shape</b> changes from one pass to the next \u2014 the typical difference between the two kernel waveforms, measured as a small fraction of the kernel\u2019s peak. An iteration counts as <b>stable</b> when that change falls below a small tolerance, and CaDecon declares convergence only after <b>several consecutive stable iterations</b>, so one lucky pass can\u2019t stop it early. In practice a good solution usually emerges within just a handful of iterations.<br><br>' +
'To guard against any late drift, the <b>reported kernel is the consensus</b> (the median shape) of the last few iterations, not whatever the final pass happened to produce. You can make the run stop sooner or keep refining with the <b>Convergence Tolerance</b> setting. There is also a maximum-iteration limit, but that is only a <b>safety cap</b> for a run that never settles \u2014 not how a healthy run should normally end. With a stable kernel in hand, CaDecon runs one <b>final inference across every cell</b>, which is why results appear for all your cells, not just the subsets it learned from.',
},
// Step 12: Completion
Expand Down
2 changes: 1 addition & 1 deletion apps/cadecon/src/lib/tutorial/content/04-interpreting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const interpretingTutorial: Tutorial = {
element: '[data-tutorial="kernel-convergence"]',
title: 'Signs of Healthy Convergence',
description:
'Start on the <b>Asymptote</b> tab — its four small charts are your first check. Kernel shape (peak time &amp; FWHM), kernel-fit R², reconstruction PVE, and activity stability should all <b>flatten toward a plateau</b> as the run settles; the green marker shows the iteration where CaDecon declared convergence. The <b>Kernel</b> tab shows the shape itself over iterations, and <b>Distributions</b> shows the per-cell alpha, PVE, and event-rate spread. <b>Warning signs:</b> curves still moving when the run ends (the kernel never stabilized — usually low SNR or an unlucky subset draw; try higher coverage or re-tiling), or a sharp reset mid-run followed by divergence.',
'Start on the <b>Asymptote</b> tab — its four small charts are your first check. The kernel-change RMSE and activity change should <b>decay toward 0</b> (the RMSE toward its tolerance band), while kernel-fit R² and reconstruction PVE <b>rise to a plateau</b>, as the run settles; the green marker shows the iteration where CaDecon declared convergence. The <b>Kernel</b> tab shows the shape itself over iterations, and <b>Distributions</b> shows the per-cell alpha, PVE, and event-rate spread. <b>Warning signs:</b> curves still moving when the run ends (the kernel never stabilized — usually low SNR or an unlucky subset draw; try higher coverage or re-tiling), or a sharp reset mid-run followed by divergence.',
side: 'bottom',
popoverClass: 'driver-popover--wide',
},
Expand Down
Loading
Loading