From 2e8ec4dda2e7875ac26b02fb9a6c85564019cfab Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:07:54 -0800 Subject: [PATCH 01/11] fix(cadecon): prevent rise time collapse via best-residual tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iterative kernel solver was driving tau_rise toward 0 because sharper kernels reduce reconstruction error, even past the true rise time. The bi-exponential fit residual has a U-shape — it reaches a minimum at the correct kernel then increases as tau_rise overshoots. Track the best residual across iterations and early-stop (patience=3) when the residual rises consecutively. Finalization always uses the best-residual kernel parameters regardless of how the loop exits. Co-Authored-By: Claude Opus 4.6 --- apps/cadecon/src/lib/iteration-manager.ts | 49 ++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/cadecon/src/lib/iteration-manager.ts b/apps/cadecon/src/lib/iteration-manager.ts index be3c8e8..45500de 100644 --- a/apps/cadecon/src/lib/iteration-manager.ts +++ b/apps/cadecon/src/lib/iteration-manager.ts @@ -295,6 +295,18 @@ export async function startRun(): Promise { let prevTraceCounts: Map | undefined; let prevKernels: Float32Array[] | undefined; + // Best-residual tracking: remember the kernel parameters from the iteration + // whose bi-exponential fit residual was lowest. This prevents the rise time + // from collapsing toward 0 — the residual has a U-shape, so the minimum + // corresponds to the correct kernel even though the optimizer keeps pushing + // tau_rise down on subsequent iterations. + let bestResidual = Infinity; + let bestTauR = tauR; + let bestTauD = tauD; + let bestIteration = 0; + const RESIDUAL_PATIENCE = 3; // stop after this many consecutive increases + let residualIncreaseCount = 0; + // Iteration 0: record initial kernel state and alpha=1 baseline addConvergenceSnapshot({ iteration: 0, @@ -518,7 +530,32 @@ export async function startRun(): Promise { })), }); - // Step 4: Convergence check + // Step 4: Best-residual tracking & early stop + // The bi-exponential fit residual measures how well the parametric kernel + // matches the free-form estimate. When tau_rise overshoots past the true + // value, this residual increases — so the minimum marks the best kernel. + if (medResidual < bestResidual) { + bestResidual = medResidual; + bestTauR = tauR; + bestTauD = tauD; + bestIteration = iter + 1; + residualIncreaseCount = 0; + } else { + residualIncreaseCount++; + } + + // Early stop: if residual has risen for RESIDUAL_PATIENCE consecutive + // iterations, the optimizer has overshot — revert to best and stop. + if (residualIncreaseCount >= RESIDUAL_PATIENCE) { + tauR = bestTauR; + tauD = bestTauD; + setCurrentTauRise(tauR); + setCurrentTauDecay(tauD); + setConvergedAtIteration(bestIteration); + break; + } + + // Step 5: Convergence check (relative change in tau values) const relChangeTauR = Math.abs(tauR - prevTauR) / (prevTauR + 1e-20); const relChangeTauD = Math.abs(tauD - prevTauD) / (prevTauD + 1e-20); const maxRelChange = Math.max(relChangeTauR, relChangeTauD); @@ -528,6 +565,16 @@ export async function startRun(): Promise { } } + // Use the best-residual kernel for finalization. If the loop ran to maxIter + // without early-stopping, the current tauR/tauD may have overshot. Revert to + // the iteration that produced the lowest bi-exponential fit residual. + if (bestResidual < Infinity) { + tauR = bestTauR; + tauD = bestTauD; + setCurrentTauRise(tauR); + setCurrentTauDecay(tauD); + } + // Finalization: re-run trace inference on ALL cells with converged kernel if (runState() !== 'stopping') { setRunPhase('finalization'); From 0eeb51b7699f4f1ba750edd26a84e9ab536e6f55 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:11:17 -0800 Subject: [PATCH 02/11] feat(cadecon): add ground truth lines to kernel convergence chart Draw horizontal dashed lines at the true tau_rise and tau_decay values on the kernel iteration plot when "Show Ground Truth" is enabled. Uses the same pink/magenta color as the kernel shape overlay. Co-Authored-By: Claude Opus 4.6 --- .../components/charts/KernelConvergence.tsx | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/cadecon/src/components/charts/KernelConvergence.tsx b/apps/cadecon/src/components/charts/KernelConvergence.tsx index dbff483..ca7db68 100644 --- a/apps/cadecon/src/components/charts/KernelConvergence.tsx +++ b/apps/cadecon/src/components/charts/KernelConvergence.tsx @@ -10,6 +10,12 @@ import 'uplot/dist/uPlot.min.css'; import '@calab/ui/chart/chart-theme.css'; import { convergenceHistory, convergedAtIteration } from '../../lib/iteration-store.ts'; import { viewedIteration } from '../../lib/viz-store.ts'; +import { + groundTruthVisible, + isDemo, + groundTruthTauRise, + groundTruthTauDecay, +} from '../../lib/data-store.ts'; import { wheelZoomPlugin, AXIS_TEXT, AXIS_GRID, AXIS_TICK } from '@calab/ui/chart'; import { convergenceMarkerPlugin } from '../../lib/chart/convergence-marker-plugin.ts'; import { viewedIterationPlugin } from '../../lib/chart/viewed-iteration-plugin.ts'; @@ -19,6 +25,49 @@ const TAU_DECAY_COLOR = '#ef5350'; const RESIDUAL_COLOR = '#9e9e9e'; const TAU_RISE_FAINT = 'rgba(66, 165, 245, 0.3)'; const TAU_DECAY_FAINT = 'rgba(239, 83, 80, 0.3)'; +const GT_COLOR = 'rgba(233, 30, 99, 0.8)'; + +/** Plugin that draws horizontal dashed lines at ground truth tau_rise and tau_decay. */ +function groundTruthPlugin(): uPlot.Plugin { + return { + hooks: { + draw(u: uPlot) { + if (!groundTruthVisible() || !isDemo()) return; + const gtTauR = groundTruthTauRise(); + const gtTauD = groundTruthTauDecay(); + if (gtTauR == null && gtTauD == null) return; + + const ctx = u.ctx; + const dpr = devicePixelRatio; + const left = u.bbox.left; + const width = u.bbox.width; + + ctx.save(); + ctx.strokeStyle = GT_COLOR; + ctx.lineWidth = 1.5 * dpr; + ctx.setLineDash([6 * dpr, 4 * dpr]); + + if (gtTauR != null) { + const yPx = u.valToPos(gtTauR * 1000, 'y', true); + ctx.beginPath(); + ctx.moveTo(left, yPx); + ctx.lineTo(left + width, yPx); + ctx.stroke(); + } + + if (gtTauD != null) { + const yPx = u.valToPos(gtTauD * 1000, 'y', true); + ctx.beginPath(); + ctx.moveTo(left, yPx); + ctx.lineTo(left + width, yPx); + ctx.stroke(); + } + + ctx.restore(); + }, + }, + }; +} /** Plugin that draws faint per-subset scatter circles behind the main lines. */ function subsetScatterPlugin(): uPlot.Plugin { @@ -57,9 +106,10 @@ function subsetScatterPlugin(): uPlot.Plugin { export function KernelConvergence(): JSX.Element { const [uplotRef, setUplotRef] = createSignal(null); - // Redraw when viewedIteration changes so the overlay marker updates + // Redraw when viewedIteration or ground truth visibility changes so overlay markers update createEffect(() => { viewedIteration(); // track + groundTruthVisible(); // track uplotRef()?.redraw(); }); @@ -124,6 +174,7 @@ export function KernelConvergence(): JSX.Element { const plugins = [ subsetScatterPlugin(), + groundTruthPlugin(), convergenceMarkerPlugin(() => convergedAtIteration()), viewedIterationPlugin(() => viewedIteration()), wheelZoomPlugin(), From 2113f5a4e69bba8c1517b7128ab241403322af2d Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:17:43 -0800 Subject: [PATCH 03/11] refactor(cadecon): replace bottom distributions with PVE + Event Rate tabs - Remove Row 3 (Alpha/PVE/Event Rate distributions + Subset Variance) - Add PVE and Event Rate tabs to the convergence panel (now 5 tabs: Kernel | Alpha | Threshold | PVE | Event Rate) - Fix ground truth overlay lines to use color-matched dashes (blue for tau_rise, red for tau_decay) instead of uniform pink - Remove unused distribution/drilldown CSS and imports from layout Co-Authored-By: Claude Opus 4.6 --- apps/cadecon/src/App.tsx | 26 +-------------- .../components/charts/ConvergencePanel.tsx | 33 +++++++++++++++++-- .../src/components/charts/EventRateTrends.tsx | 30 +++++++++++++++++ .../components/charts/KernelConvergence.tsx | 7 ++-- .../src/components/charts/PveTrends.tsx | 18 ++++++++++ apps/cadecon/src/styles/layout.css | 25 -------------- 6 files changed, 83 insertions(+), 56 deletions(-) create mode 100644 apps/cadecon/src/components/charts/EventRateTrends.tsx create mode 100644 apps/cadecon/src/components/charts/PveTrends.tsx diff --git a/apps/cadecon/src/App.tsx b/apps/cadecon/src/App.tsx index 590a15a..35f5286 100644 --- a/apps/cadecon/src/App.tsx +++ b/apps/cadecon/src/App.tsx @@ -23,11 +23,6 @@ import { ConvergencePanel } from './components/charts/ConvergencePanel.tsx'; import { KernelDisplay } from './components/kernel/KernelDisplay.tsx'; import { TraceInspector } from './components/traces/TraceInspector.tsx'; import { IterationScrubber } from './components/traces/IterationScrubber.tsx'; -import { AlphaDistribution } from './components/distributions/AlphaDistribution.tsx'; -import { PVEDistribution } from './components/distributions/PVEDistribution.tsx'; -import { EventRateDistribution } from './components/distributions/EventRateDistribution.tsx'; -import { SubsetVariance } from './components/distributions/SubsetVariance.tsx'; -import { SubsetDrillDown } from './components/drilldown/SubsetDrillDown.tsx'; import { SubmitPanel } from './components/community/SubmitPanel.tsx'; import { CommunityBrowser } from './components/community/CommunityBrowser.tsx'; import { @@ -38,16 +33,14 @@ import { loadFromBridge, bridgeUrl, } from './lib/data-store.ts'; -import { selectedSubsetIdx, setSeed } from './lib/subset-store.ts'; +import { setSeed } from './lib/subset-store.ts'; import { isRunLocked } from './lib/iteration-store.ts'; import './styles/controls.css'; import './styles/layout.css'; -import './styles/distributions.css'; import './styles/trace-inspector.css'; import './styles/iteration-scrubber.css'; import './styles/kernel-display.css'; -import './styles/drilldown.css'; import './styles/community.css'; function DiceIcon(): JSX.Element { @@ -181,23 +174,6 @@ const App: Component = () => { - - {/* Row 3: Distribution Cards OR Subset Drill-Down */} -
- - - - - -
- } - > - - - diff --git a/apps/cadecon/src/components/charts/ConvergencePanel.tsx b/apps/cadecon/src/components/charts/ConvergencePanel.tsx index 8e8f085..c055023 100644 --- a/apps/cadecon/src/components/charts/ConvergencePanel.tsx +++ b/apps/cadecon/src/components/charts/ConvergencePanel.tsx @@ -1,5 +1,6 @@ /** - * Tabbed panel switching between Kernel Convergence, Alpha Trends, and Threshold Trends. + * Tabbed panel switching between Kernel Convergence, Alpha Trends, Threshold Trends, + * PVE Trends, and Event Rate Trends. * All charts remain mounted (display toggled) to preserve uPlot state across tab switches. */ @@ -7,8 +8,10 @@ import { createSignal, type JSX } from 'solid-js'; import { KernelConvergence } from './KernelConvergence.tsx'; import { AlphaTrends } from './AlphaTrends.tsx'; import { ThresholdTrends } from './ThresholdTrends.tsx'; +import { PveTrends } from './PveTrends.tsx'; +import { EventRateTrends } from './EventRateTrends.tsx'; -type ConvergenceTab = 'kernel' | 'alpha' | 'threshold'; +type ConvergenceTab = 'kernel' | 'alpha' | 'threshold' | 'pve' | 'event-rate'; export function ConvergencePanel(): JSX.Element { const [activeTab, setActiveTab] = createSignal('kernel'); @@ -37,6 +40,20 @@ export function ConvergencePanel(): JSX.Element { > Threshold + +
+
+ +
+
+ +
); } diff --git a/apps/cadecon/src/components/charts/EventRateTrends.tsx b/apps/cadecon/src/components/charts/EventRateTrends.tsx new file mode 100644 index 0000000..1fc26c1 --- /dev/null +++ b/apps/cadecon/src/components/charts/EventRateTrends.tsx @@ -0,0 +1,30 @@ +/** + * Event Rate Trends chart: shows per-cell spike rate (Hz) evolving over iterations. + * + * Event rate = sum(sCounts) / (sCounts.length / fs). When sCounts is empty or + * fs is unavailable the rate is 0. + */ + +import { createMemo, type JSX } from 'solid-js'; +import { PerCellTrendsChart } from './PerCellTrendsChart.tsx'; +import { samplingRate } from '../../lib/data-store.ts'; + +export function EventRateTrends(): JSX.Element { + const fs = createMemo(() => samplingRate() ?? 1); + + return ( + { + const n = entry.sCounts.length; + if (n === 0) return 0; + let sum = 0; + for (let i = 0; i < n; i++) sum += entry.sCounts[i]; + return sum / (n / fs()); + }} + yLabel="Hz" + medianLabel="Median Event Rate" + medianColor="#ff7f0e" + emptyMessage="Run deconvolution to see event rate trends." + /> + ); +} diff --git a/apps/cadecon/src/components/charts/KernelConvergence.tsx b/apps/cadecon/src/components/charts/KernelConvergence.tsx index ca7db68..702b801 100644 --- a/apps/cadecon/src/components/charts/KernelConvergence.tsx +++ b/apps/cadecon/src/components/charts/KernelConvergence.tsx @@ -25,9 +25,7 @@ const TAU_DECAY_COLOR = '#ef5350'; const RESIDUAL_COLOR = '#9e9e9e'; const TAU_RISE_FAINT = 'rgba(66, 165, 245, 0.3)'; const TAU_DECAY_FAINT = 'rgba(239, 83, 80, 0.3)'; -const GT_COLOR = 'rgba(233, 30, 99, 0.8)'; - -/** Plugin that draws horizontal dashed lines at ground truth tau_rise and tau_decay. */ +/** Plugin that draws horizontal dashed lines at ground truth tau_rise (blue) and tau_decay (red). */ function groundTruthPlugin(): uPlot.Plugin { return { hooks: { @@ -43,11 +41,11 @@ function groundTruthPlugin(): uPlot.Plugin { const width = u.bbox.width; ctx.save(); - ctx.strokeStyle = GT_COLOR; ctx.lineWidth = 1.5 * dpr; ctx.setLineDash([6 * dpr, 4 * dpr]); if (gtTauR != null) { + ctx.strokeStyle = TAU_RISE_COLOR; const yPx = u.valToPos(gtTauR * 1000, 'y', true); ctx.beginPath(); ctx.moveTo(left, yPx); @@ -56,6 +54,7 @@ function groundTruthPlugin(): uPlot.Plugin { } if (gtTauD != null) { + ctx.strokeStyle = TAU_DECAY_COLOR; const yPx = u.valToPos(gtTauD * 1000, 'y', true); ctx.beginPath(); ctx.moveTo(left, yPx); diff --git a/apps/cadecon/src/components/charts/PveTrends.tsx b/apps/cadecon/src/components/charts/PveTrends.tsx new file mode 100644 index 0000000..8292fa3 --- /dev/null +++ b/apps/cadecon/src/components/charts/PveTrends.tsx @@ -0,0 +1,18 @@ +/** + * PVE Trends chart: shows per-cell percent variance explained evolving over iterations. + */ + +import type { JSX } from 'solid-js'; +import { PerCellTrendsChart } from './PerCellTrendsChart.tsx'; + +export function PveTrends(): JSX.Element { + return ( + entry.pve} + yLabel="PVE" + medianLabel="Median PVE" + medianColor="#2ca02c" + emptyMessage="Run deconvolution to see PVE trends." + /> + ); +} diff --git a/apps/cadecon/src/styles/layout.css b/apps/cadecon/src/styles/layout.css index ce6f068..d1db04b 100644 --- a/apps/cadecon/src/styles/layout.css +++ b/apps/cadecon/src/styles/layout.css @@ -24,11 +24,6 @@ min-height: 180px; } -.viz-grid__row--bottom { - flex: 0 0 auto; - min-height: 130px; -} - /* Column sizing within rows */ .viz-grid__col--raster { flex: 6 1 0; @@ -50,17 +45,6 @@ min-width: 0; } -/* Distribution card row */ -.viz-grid__distributions { - display: flex; - gap: var(--space-sm, 8px); -} - -.viz-grid__distributions > * { - flex: 1 1 0; - min-width: 0; -} - /* Responsive: stack columns at narrow widths */ @media (max-width: 900px) { .viz-grid__row { @@ -70,13 +54,4 @@ .viz-grid__col--kernel { flex: 0 0 auto; } - - .viz-grid__distributions { - flex-wrap: wrap; - } - - .viz-grid__distributions > * { - flex: 1 1 calc(50% - 4px); - min-width: 120px; - } } From f7c5d456b8590b351bfd24553595301fbc41565f Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:23:46 -0800 Subject: [PATCH 04/11] chore(cadecon): use conservative initial kernel defaults (200ms rise, 1s decay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iterative solver can only refine time constants downward — a too-fast kernel is compensated by extra spikes, so there is no error signal to push it slower. Starting conservatively slow ensures the optimizer passes through the optimum on the way down. Co-Authored-By: Claude Opus 4.6 --- apps/cadecon/src/lib/algorithm-store.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/cadecon/src/lib/algorithm-store.ts b/apps/cadecon/src/lib/algorithm-store.ts index fff2d77..71e4af6 100644 --- a/apps/cadecon/src/lib/algorithm-store.ts +++ b/apps/cadecon/src/lib/algorithm-store.ts @@ -3,8 +3,14 @@ import { samplingRate } from './data-store.ts'; // --- Algorithm parameter signals --- -const [tauRiseInit, setTauRiseInit] = createSignal(0.1); -const [tauDecayInit, setTauDecayInit] = createSignal(0.6); +// Initial kernel time constants (seconds). These MUST start at or above the +// true values. The iterative solver can only refine time constants downward — +// a too-fast kernel is compensated by placing extra spikes, so the free-form +// kernel never receives an error signal pushing it slower. Starting from +// conservatively slow values ensures the optimizer passes through the optimum +// on the way down, where best-residual tracking (iteration-manager.ts) catches it. +const [tauRiseInit, setTauRiseInit] = createSignal(0.2); +const [tauDecayInit, setTauDecayInit] = createSignal(1.0); const [upsampleTarget, setUpsampleTarget] = createSignal(300); const [weightingEnabled, setWeightingEnabled] = createSignal(false); const [hpFilterEnabled, setHpFilterEnabled] = createSignal(true); From 8c90e41d0ff4c06676b7802b39b839bd7f5b824d Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:26:14 -0800 Subject: [PATCH 05/11] fix(cadecon): skip iteration 0 in per-cell trend charts Iteration 0 contains placeholder values (alpha=1, threshold=0, pve=0, empty sCounts) before any computation runs. Filter it out by default in PerCellTrendsChart so Alpha, Threshold, PVE, and Event Rate tabs all start from iteration 1. Co-Authored-By: Claude Opus 4.6 --- apps/cadecon/src/components/charts/PerCellTrendsChart.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx index 8946eb4..299cc4b 100644 --- a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx +++ b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx @@ -258,12 +258,18 @@ export interface PerCellTrendsChartProps { medianColor: string; /** Fallback message when no data. */ emptyMessage: string; + /** Skip iteration 0 (placeholder values before any computation). Default true. */ + skipZero?: boolean; } export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element { const [uplotRef, setUplotRef] = createSignal(null); - const trendsData = createMemo(() => deriveTrendsData(iterationHistory(), props.accessor)); + const trendsData = createMemo(() => { + const skip = props.skipZero !== false; // default true + const history = skip ? iterationHistory().filter((h) => h.iteration > 0) : iterationHistory(); + return deriveTrendsData(history, props.accessor); + }); // Redraw when overlay state changes createEffect(() => { From 39f64f7d891938a267221156c23d9c705392481b Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:28:24 -0800 Subject: [PATCH 06/11] fix(cadecon): filter out iteration 0 from all convergence charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 0 contains placeholder values before any computation runs. Filter it from all charts — Kernel convergence (tau lines, residual, subset scatter) and all per-cell trends (Alpha, Threshold, PVE, Event Rate) now start at iteration 1. Co-Authored-By: Claude Opus 4.6 --- apps/cadecon/src/components/charts/KernelConvergence.tsx | 4 ++-- apps/cadecon/src/components/charts/PerCellTrendsChart.tsx | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/cadecon/src/components/charts/KernelConvergence.tsx b/apps/cadecon/src/components/charts/KernelConvergence.tsx index 702b801..047447c 100644 --- a/apps/cadecon/src/components/charts/KernelConvergence.tsx +++ b/apps/cadecon/src/components/charts/KernelConvergence.tsx @@ -80,7 +80,7 @@ function subsetScatterPlugin(): uPlot.Plugin { const dpr = devicePixelRatio; for (const snap of history) { - if (!snap.subsets) continue; + if (!snap.subsets || snap.iteration === 0) continue; const xPx = u.valToPos(snap.iteration, 'x', true); for (const sub of snap.subsets) { @@ -113,7 +113,7 @@ export function KernelConvergence(): JSX.Element { }); const chartData = createMemo((): uPlot.AlignedData => { - const h = convergenceHistory(); + const h = convergenceHistory().filter((s) => s.iteration > 0); if (h.length === 0) return [[], [], [], []]; return [ h.map((s) => s.iteration), diff --git a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx index 299cc4b..643d917 100644 --- a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx +++ b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx @@ -258,16 +258,13 @@ export interface PerCellTrendsChartProps { medianColor: string; /** Fallback message when no data. */ emptyMessage: string; - /** Skip iteration 0 (placeholder values before any computation). Default true. */ - skipZero?: boolean; } export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element { const [uplotRef, setUplotRef] = createSignal(null); const trendsData = createMemo(() => { - const skip = props.skipZero !== false; // default true - const history = skip ? iterationHistory().filter((h) => h.iteration > 0) : iterationHistory(); + const history = iterationHistory().filter((h) => h.iteration > 0); return deriveTrendsData(history, props.accessor); }); From ab0374b2c5b4b1ed5b3faee356c4100b7b8ef139 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:31:47 -0800 Subject: [PATCH 07/11] style(cadecon): increase IQR band visibility in per-cell trend charts Bump IQR fill opacity from 0.08 to 0.18 and Q-line opacity from 0.3 to 0.4. Show Q25/Q75 dashed boundary lines (previously hidden). Co-Authored-By: Claude Opus 4.6 --- apps/cadecon/src/components/charts/PerCellTrendsChart.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx index 643d917..8dd0425 100644 --- a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx +++ b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx @@ -28,8 +28,8 @@ import { import { convergenceMarkerPlugin } from '../../lib/chart/convergence-marker-plugin.ts'; import { viewedIterationPlugin } from '../../lib/chart/viewed-iteration-plugin.ts'; -const IQR_FILL = 'rgba(31, 119, 180, 0.08)'; -const Q_COLOR = 'rgba(31, 119, 180, 0.3)'; +const IQR_FILL = 'rgba(31, 119, 180, 0.18)'; +const Q_COLOR = 'rgba(31, 119, 180, 0.4)'; export interface TrendsData { iterations: number[]; @@ -290,8 +290,8 @@ export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element width: 2, points: { show: true, size: 5 }, }, - { label: 'Q25', stroke: Q_COLOR, width: 1, dash: [4, 2], show: false }, - { label: 'Q75', stroke: Q_COLOR, width: 1, dash: [4, 2], show: false }, + { label: 'Q25', stroke: Q_COLOR, width: 1, dash: [4, 2] }, + { label: 'Q75', stroke: Q_COLOR, width: 1, dash: [4, 2] }, ]; const scales = createMemo((): uPlot.Scales => { From 741b06d50242664d6f66cb99128872dcd65d6884 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:36:17 -0800 Subject: [PATCH 08/11] fix(cadecon): restore kernel convergence chart, only skip residual at iter 0 The iteration 0 filter caused the chart to initialize with empty data before iteration 1 completed, breaking uPlot rendering. Restore full convergenceHistory for the kernel chart (initial tau values are meaningful) and only null out the residual at iteration 0. Co-Authored-By: Claude Opus 4.6 --- apps/cadecon/src/components/charts/KernelConvergence.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cadecon/src/components/charts/KernelConvergence.tsx b/apps/cadecon/src/components/charts/KernelConvergence.tsx index 047447c..13bf6ab 100644 --- a/apps/cadecon/src/components/charts/KernelConvergence.tsx +++ b/apps/cadecon/src/components/charts/KernelConvergence.tsx @@ -80,7 +80,7 @@ function subsetScatterPlugin(): uPlot.Plugin { const dpr = devicePixelRatio; for (const snap of history) { - if (!snap.subsets || snap.iteration === 0) continue; + if (!snap.subsets) continue; const xPx = u.valToPos(snap.iteration, 'x', true); for (const sub of snap.subsets) { @@ -113,13 +113,13 @@ export function KernelConvergence(): JSX.Element { }); const chartData = createMemo((): uPlot.AlignedData => { - const h = convergenceHistory().filter((s) => s.iteration > 0); + const h = convergenceHistory(); if (h.length === 0) return [[], [], [], []]; return [ h.map((s) => s.iteration), h.map((s) => s.tauRise * 1000), h.map((s) => s.tauDecay * 1000), - h.map((s) => s.residual), + h.map((s) => (s.iteration === 0 ? null : s.residual)), ]; }); From f680847e112513f038e25f6635bd8cfa74002530 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:37:56 -0800 Subject: [PATCH 09/11] fix(cadecon): skip iteration 0 in kernel chart, integer-only x-axis ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defer rendering the kernel convergence chart until iteration 1 completes (avoids uPlot initializing with empty data). Filter iteration 0 from chart data since it's just initial params. Show only integer tick labels on the x-axis (Iteration) for all convergence tabs — suppresses fractional ticks like 1.5. Co-Authored-By: Claude Opus 4.6 --- .../src/components/charts/KernelConvergence.tsx | 10 +++++++--- .../src/components/charts/PerCellTrendsChart.tsx | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/cadecon/src/components/charts/KernelConvergence.tsx b/apps/cadecon/src/components/charts/KernelConvergence.tsx index 13bf6ab..9f09019 100644 --- a/apps/cadecon/src/components/charts/KernelConvergence.tsx +++ b/apps/cadecon/src/components/charts/KernelConvergence.tsx @@ -112,14 +112,16 @@ export function KernelConvergence(): JSX.Element { uplotRef()?.redraw(); }); + const filteredHistory = createMemo(() => convergenceHistory().filter((s) => s.iteration > 0)); + const chartData = createMemo((): uPlot.AlignedData => { - const h = convergenceHistory(); + const h = filteredHistory(); if (h.length === 0) return [[], [], [], []]; return [ h.map((s) => s.iteration), h.map((s) => s.tauRise * 1000), h.map((s) => s.tauDecay * 1000), - h.map((s) => (s.iteration === 0 ? null : s.residual)), + h.map((s) => s.residual), ]; }); @@ -150,6 +152,8 @@ export function KernelConvergence(): JSX.Element { label: 'Iteration', labelSize: 10, labelFont: '10px sans-serif', + values: (_u: uPlot, splits: number[]) => + splits.map((v) => (Number.isInteger(v) ? String(v) : '')), }, { stroke: AXIS_TEXT, @@ -185,7 +189,7 @@ export function KernelConvergence(): JSX.Element { return ( 0} + when={filteredHistory().length > 0} fallback={
Run deconvolution to see kernel convergence. diff --git a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx index 8dd0425..d9ccf85 100644 --- a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx +++ b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx @@ -311,6 +311,8 @@ export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element label: 'Iteration', labelSize: 10, labelFont: '10px sans-serif', + values: (_u: uPlot, splits: number[]) => + splits.map((v) => (Number.isInteger(v) ? String(v) : '')), }, { stroke: AXIS_TEXT, From 565dcbb973e6ac741dfb204cf95b567ea000af23 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:43:30 -0800 Subject: [PATCH 10/11] chore(cadecon): add TODO about improving stopping criteria The kernel fit residual (bi-exponential shape mismatch) doesn't always produce a reliable stopping signal. Note that trace-reconstruction residual would be a more robust alternative. Co-Authored-By: Claude Opus 4.6 --- apps/cadecon/src/lib/iteration-manager.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/cadecon/src/lib/iteration-manager.ts b/apps/cadecon/src/lib/iteration-manager.ts index 45500de..d11216e 100644 --- a/apps/cadecon/src/lib/iteration-manager.ts +++ b/apps/cadecon/src/lib/iteration-manager.ts @@ -531,9 +531,15 @@ export async function startRun(): Promise { }); // Step 4: Best-residual tracking & early stop - // The bi-exponential fit residual measures how well the parametric kernel - // matches the free-form estimate. When tau_rise overshoots past the true - // value, this residual increases — so the minimum marks the best kernel. + // + // TODO: The current stopping criterion uses the bi-exponential fit residual + // (||h_free - β·template||²), which measures kernel shape mismatch. This + // doesn't always work — it can be noisy or non-monotonic depending on the + // data. A more robust approach would use the trace-reconstruction residual + // (||y - α·(K*s) - b||² across cells), which directly measures how well + // the model explains the data. That's more expensive to compute but would + // be a stronger signal for when the kernel has overshot. + // if (medResidual < bestResidual) { bestResidual = medResidual; bestTauR = tauR; From 5ce17740babe18128abaab5f6025af857a65388e Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 28 Feb 2026 12:49:27 -0800 Subject: [PATCH 11/11] refactor(cadecon): simplify convergence UI and remove redundant logic Data-driven tabs in ConvergencePanel, extract drawHLine helper in KernelConvergence, remove redundant early-stop revert in iteration manager, fix PerCellTrendsChart Show guard, and correct stale CSS comment. Co-Authored-By: Claude Opus 4.6 --- .../components/charts/ConvergencePanel.tsx | 102 +++++++----------- .../components/charts/KernelConvergence.tsx | 35 +++--- .../components/charts/PerCellTrendsChart.tsx | 2 +- apps/cadecon/src/lib/iteration-manager.ts | 7 +- apps/cadecon/src/styles/layout.css | 2 +- 5 files changed, 54 insertions(+), 94 deletions(-) diff --git a/apps/cadecon/src/components/charts/ConvergencePanel.tsx b/apps/cadecon/src/components/charts/ConvergencePanel.tsx index c055023..bb68f54 100644 --- a/apps/cadecon/src/components/charts/ConvergencePanel.tsx +++ b/apps/cadecon/src/components/charts/ConvergencePanel.tsx @@ -4,7 +4,7 @@ * All charts remain mounted (display toggled) to preserve uPlot state across tab switches. */ -import { createSignal, type JSX } from 'solid-js'; +import { createSignal, For, type JSX } from 'solid-js'; import { KernelConvergence } from './KernelConvergence.tsx'; import { AlphaTrends } from './AlphaTrends.tsx'; import { ThresholdTrends } from './ThresholdTrends.tsx'; @@ -13,78 +13,48 @@ import { EventRateTrends } from './EventRateTrends.tsx'; type ConvergenceTab = 'kernel' | 'alpha' | 'threshold' | 'pve' | 'event-rate'; +interface TabEntry { + id: ConvergenceTab; + label: string; + content: () => JSX.Element; +} + +const TABS: TabEntry[] = [ + { id: 'kernel', label: 'Kernel', content: () => }, + { id: 'alpha', label: 'Alpha', content: () => }, + { id: 'threshold', label: 'Threshold', content: () => }, + { id: 'pve', label: 'PVE', content: () => }, + { id: 'event-rate', label: 'Event Rate', content: () => }, +]; + export function ConvergencePanel(): JSX.Element { const [activeTab, setActiveTab] = createSignal('kernel'); return (
- - - - - -
-
- -
-
- -
-
- -
-
- -
-
- + + {(tab) => ( + + )} +
+ + {(tab) => ( +
+ {tab.content()} +
+ )} +
); } diff --git a/apps/cadecon/src/components/charts/KernelConvergence.tsx b/apps/cadecon/src/components/charts/KernelConvergence.tsx index 9f09019..475e2b5 100644 --- a/apps/cadecon/src/components/charts/KernelConvergence.tsx +++ b/apps/cadecon/src/components/charts/KernelConvergence.tsx @@ -25,6 +25,17 @@ const TAU_DECAY_COLOR = '#ef5350'; const RESIDUAL_COLOR = '#9e9e9e'; const TAU_RISE_FAINT = 'rgba(66, 165, 245, 0.3)'; const TAU_DECAY_FAINT = 'rgba(239, 83, 80, 0.3)'; + +/** Draw a single horizontal line at `yVal` on scale `'y'`. Caller must save/restore ctx. */ +function drawHLine(ctx: CanvasRenderingContext2D, u: uPlot, yVal: number, color: string): void { + ctx.strokeStyle = color; + const yPx = u.valToPos(yVal, 'y', true); + ctx.beginPath(); + ctx.moveTo(u.bbox.left, yPx); + ctx.lineTo(u.bbox.left + u.bbox.width, yPx); + ctx.stroke(); +} + /** Plugin that draws horizontal dashed lines at ground truth tau_rise (blue) and tau_decay (red). */ function groundTruthPlugin(): uPlot.Plugin { return { @@ -35,32 +46,14 @@ function groundTruthPlugin(): uPlot.Plugin { const gtTauD = groundTruthTauDecay(); if (gtTauR == null && gtTauD == null) return; - const ctx = u.ctx; const dpr = devicePixelRatio; - const left = u.bbox.left; - const width = u.bbox.width; - + const ctx = u.ctx; ctx.save(); ctx.lineWidth = 1.5 * dpr; ctx.setLineDash([6 * dpr, 4 * dpr]); - if (gtTauR != null) { - ctx.strokeStyle = TAU_RISE_COLOR; - const yPx = u.valToPos(gtTauR * 1000, 'y', true); - ctx.beginPath(); - ctx.moveTo(left, yPx); - ctx.lineTo(left + width, yPx); - ctx.stroke(); - } - - if (gtTauD != null) { - ctx.strokeStyle = TAU_DECAY_COLOR; - const yPx = u.valToPos(gtTauD * 1000, 'y', true); - ctx.beginPath(); - ctx.moveTo(left, yPx); - ctx.lineTo(left + width, yPx); - ctx.stroke(); - } + if (gtTauR != null) drawHLine(ctx, u, gtTauR * 1000, TAU_RISE_COLOR); + if (gtTauD != null) drawHLine(ctx, u, gtTauD * 1000, TAU_DECAY_COLOR); ctx.restore(); }, diff --git a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx index d9ccf85..aa79e5a 100644 --- a/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx +++ b/apps/cadecon/src/components/charts/PerCellTrendsChart.tsx @@ -338,7 +338,7 @@ export function PerCellTrendsChart(props: PerCellTrendsChartProps): JSX.Element return ( 0} + when={trendsData().iterations.length > 0} fallback={
{props.emptyMessage} diff --git a/apps/cadecon/src/lib/iteration-manager.ts b/apps/cadecon/src/lib/iteration-manager.ts index d11216e..17460ff 100644 --- a/apps/cadecon/src/lib/iteration-manager.ts +++ b/apps/cadecon/src/lib/iteration-manager.ts @@ -551,12 +551,9 @@ export async function startRun(): Promise { } // Early stop: if residual has risen for RESIDUAL_PATIENCE consecutive - // iterations, the optimizer has overshot — revert to best and stop. + // iterations, the optimizer has overshot. The post-loop revert below will + // restore the best-residual kernel parameters before finalization. if (residualIncreaseCount >= RESIDUAL_PATIENCE) { - tauR = bestTauR; - tauD = bestTauD; - setCurrentTauRise(tauR); - setCurrentTauDecay(tauD); setConvergedAtIteration(bestIteration); break; } diff --git a/apps/cadecon/src/styles/layout.css b/apps/cadecon/src/styles/layout.css index d1db04b..6f75fb9 100644 --- a/apps/cadecon/src/styles/layout.css +++ b/apps/cadecon/src/styles/layout.css @@ -1,4 +1,4 @@ -/* 3-row visualization grid */ +/* 2-row visualization grid */ .viz-grid { display: flex;