Skip to content

Commit 790491f

Browse files
daharoniclaude
andcommitted
refactor(cadecon): CSS grid layout, Nyquist floor, kernel/threshold improvements
- Switch viz layout to 3-column CSS grid (raster+kernel 1col, convergence+trace 2col) - Add Nyquist floor (2/fs) to biexp fitter grid search and golden-section refinement - Disable residual-patience early stopping (needs better stopping metric) - Extend kernel estimation and threshold search with additional parameters - Rebuild WASM with solver changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bdc1a6a commit 790491f

22 files changed

Lines changed: 390 additions & 102 deletions

apps/cadecon/src/App.tsx

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -146,34 +146,28 @@ const App: Component = () => {
146146
}
147147
>
148148
<div class="viz-grid">
149-
{/* Row 1: Raster + Kernel Convergence */}
150-
<div class="viz-grid__row viz-grid__row--top">
151-
<DashboardPanel id="raster" variant="data" class="viz-grid__col--raster raster-panel">
152-
<p class="panel-label">Raster Overview</p>
153-
<RasterOverview />
154-
</DashboardPanel>
149+
<DashboardPanel id="raster" variant="data" class="viz-grid__col--raster raster-panel">
150+
<p class="panel-label">Raster Overview</p>
151+
<RasterOverview />
152+
</DashboardPanel>
155153

156-
<DashboardPanel
157-
id="kernel-convergence"
158-
variant="data"
159-
class="viz-grid__col--convergence"
160-
>
161-
<ConvergencePanel />
162-
</DashboardPanel>
163-
</div>
154+
<DashboardPanel
155+
id="kernel-convergence"
156+
variant="data"
157+
class="viz-grid__col--convergence"
158+
>
159+
<ConvergencePanel />
160+
</DashboardPanel>
164161

165-
{/* Row 2: Kernel Display + Trace Viewer */}
166-
<div class="viz-grid__row viz-grid__row--middle">
167-
<DashboardPanel id="kernel-display" variant="data" class="viz-grid__col--kernel">
168-
<p class="panel-label">Kernel Shape</p>
169-
<KernelDisplay />
170-
</DashboardPanel>
162+
<DashboardPanel id="kernel-display" variant="data" class="viz-grid__col--kernel">
163+
<p class="panel-label">Kernel Shape</p>
164+
<KernelDisplay />
165+
</DashboardPanel>
171166

172-
<DashboardPanel id="trace-viewer" variant="data" class="viz-grid__col--trace">
173-
<p class="panel-label">Trace Inspector</p>
174-
<TraceInspector />
175-
</DashboardPanel>
176-
</div>
167+
<DashboardPanel id="trace-viewer" variant="data" class="viz-grid__col--trace">
168+
<p class="panel-label">Trace Inspector</p>
169+
<TraceInspector />
170+
</DashboardPanel>
177171
</div>
178172
<IterationScrubber />
179173
</VizLayout>

apps/cadecon/src/components/charts/ConvergencePanel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import { AlphaTrends } from './AlphaTrends.tsx';
1010
import { ThresholdTrends } from './ThresholdTrends.tsx';
1111
import { PveTrends } from './PveTrends.tsx';
1212
import { EventRateTrends } from './EventRateTrends.tsx';
13+
import { SpikeEfficiencyTrends } from './SpikeEfficiencyTrends.tsx';
1314

14-
type ConvergenceTab = 'kernel' | 'alpha' | 'threshold' | 'pve' | 'event-rate';
15+
type ConvergenceTab = 'kernel' | 'alpha' | 'threshold' | 'pve' | 'event-rate' | 'spike-eff';
1516

1617
interface TabEntry {
1718
id: ConvergenceTab;
@@ -25,6 +26,7 @@ const TABS: TabEntry[] = [
2526
{ id: 'threshold', label: 'Threshold', content: () => <ThresholdTrends /> },
2627
{ id: 'pve', label: 'PVE', content: () => <PveTrends /> },
2728
{ id: 'event-rate', label: 'Event Rate', content: () => <EventRateTrends /> },
29+
{ id: 'spike-eff', label: 'Spike Eff.', content: () => <SpikeEfficiencyTrends /> },
2830
];
2931

3032
export function ConvergencePanel(): JSX.Element {

apps/cadecon/src/components/charts/PerCellTrendsChart.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface TrendsData {
4444
/** Extract a numeric field from TraceResultEntry history into TrendsData. */
4545
export function deriveTrendsData(
4646
history: IterationHistoryEntry[],
47-
accessor: (entry: TraceResultEntry) => number,
47+
accessor: (entry: TraceResultEntry, historyEntry: IterationHistoryEntry) => number,
4848
): TrendsData {
4949
if (history.length === 0) {
5050
return {
@@ -85,7 +85,7 @@ export function deriveTrendsData(
8585

8686
for (const key of keys) {
8787
const entry = results[key];
88-
const val = entry != null ? accessor(entry) : null;
88+
const val = entry != null ? accessor(entry, history[i]) : null;
8989
perKey.get(key)!.push(val);
9090
if (val != null) {
9191
values.push(val);
@@ -249,7 +249,7 @@ function iqrBandPlugin(getData: () => TrendsData): uPlot.Plugin {
249249

250250
export interface PerCellTrendsChartProps {
251251
/** Field accessor to extract the value from each TraceResultEntry. */
252-
accessor: (entry: TraceResultEntry) => number;
252+
accessor: (entry: TraceResultEntry, historyEntry: IterationHistoryEntry) => number;
253253
/** Y-axis label (e.g. "Alpha", "Threshold"). */
254254
yLabel: string;
255255
/** Median series label (e.g. "Median Alpha"). */
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Spike Cost Trends chart: (1 + log(totalActivity)) / (PVE * kernelArea).
3+
*
4+
* U-shaped metric that should minimize at the correct kernel:
5+
* - Early (slow kernel): PVE is low → cost is high
6+
* - Correct kernel: PVE is high, kernel area right-sized, moderate spikes → minimum
7+
* - Overfit (collapsed kernel): kernel area shrinks, spikes explode → cost rises again
8+
*/
9+
10+
import type { JSX } from 'solid-js';
11+
import { PerCellTrendsChart } from './PerCellTrendsChart.tsx';
12+
import { computeNormalizedKernelArea } from '../../lib/math-utils.ts';
13+
14+
export function SpikeEfficiencyTrends(): JSX.Element {
15+
return (
16+
<PerCellTrendsChart
17+
accessor={(entry, historyEntry) => {
18+
const n = entry.sCounts.length;
19+
if (n === 0) return 0;
20+
let totalActivity = 0;
21+
for (let i = 0; i < n; i++) totalActivity += entry.sCounts[i];
22+
if (totalActivity < 1e-12) return 0;
23+
const kernelArea = computeNormalizedKernelArea(historyEntry.tauRise, historyEntry.tauDecay);
24+
const denom = entry.pve * kernelArea;
25+
if (denom < 1e-12) return 0;
26+
return (1 + Math.log(totalActivity)) / denom;
27+
}}
28+
yLabel="Spike Cost"
29+
medianLabel="Median Spike Cost"
30+
medianColor="#9467bd"
31+
emptyMessage="Run deconvolution to see spike cost trends."
32+
/>
33+
);
34+
}

apps/cadecon/src/components/drilldown/SubsetKernelFit.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'uplot/dist/uPlot.min.css';
99
import '@calab/ui/chart/chart-theme.css';
1010
import type { KernelSnapshot, SubsetKernelSnapshot } from '../../lib/iteration-store.ts';
1111
import { AXIS_TEXT, AXIS_GRID, AXIS_TICK, subsetColor } from '@calab/ui/chart';
12+
import { peakNormalize } from '../../lib/chart/series-config.ts';
1213

1314
export interface SubsetKernelFitProps {
1415
subsetIdx: number;
@@ -41,6 +42,8 @@ export function SubsetKernelFit(props: SubsetKernelFitProps): JSX.Element {
4142
const t = i / fs;
4243
fit[i] = beta * (Math.exp(-t / tauD) - Math.exp(-t / tauR));
4344
}
45+
peakNormalize(hFree);
46+
peakNormalize(fit);
4447

4548
return [xAxis, hFree, fit];
4649
});

apps/cadecon/src/components/kernel/KernelDisplay.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { selectedSubsetIdx } from '../../lib/subset-store.ts';
2121
import {
2222
createKernelFitSeries,
2323
createGroundTruthKernelSeries,
24+
peakNormalize,
2425
} from '../../lib/chart/series-config.ts';
2526
import {
2627
D3_CATEGORY10,
@@ -71,21 +72,24 @@ export function KernelDisplay(): JSX.Element {
7172
xAxis[i] = (i / fs) * 1000;
7273
}
7374

74-
// Per-subset h_free arrays (padded with null)
75+
// Per-subset h_free arrays (peak-normalized, padded with null)
7576
const subsetArrays: (number | null)[][] = snap.subsets.map((s) => {
77+
const raw = s.hFree.slice();
78+
peakNormalize(raw);
7679
const arr: (number | null)[] = new Array(maxLen).fill(null);
77-
for (let i = 0; i < s.hFree.length; i++) {
78-
arr[i] = s.hFree[i];
80+
for (let i = 0; i < raw.length; i++) {
81+
arr[i] = raw[i];
7982
}
8083
return arr;
8184
});
8285

83-
// Fitted bi-exp from merged params
86+
// Fitted bi-exp from merged params (peak-normalized)
8487
const fitArray = new Array(maxLen);
8588
for (let i = 0; i < maxLen; i++) {
8689
const t = i / fs;
8790
fitArray[i] = beta * (Math.exp(-t / tauD) - Math.exp(-t / tauR));
8891
}
92+
peakNormalize(fitArray);
8993

9094
const columns: (number | null)[][] = [...subsetArrays, fitArray];
9195

@@ -94,17 +98,11 @@ export function KernelDisplay(): JSX.Element {
9498
const gtTauR = groundTruthTauRise()!;
9599
const gtTauD = groundTruthTauDecay()!;
96100
const gtArray = new Array(maxLen);
97-
let gtPeak = 0;
98101
for (let i = 0; i < maxLen; i++) {
99102
const t = i / fs;
100103
gtArray[i] = Math.exp(-t / gtTauD) - Math.exp(-t / gtTauR);
101-
if (gtArray[i] > gtPeak) gtPeak = gtArray[i];
102-
}
103-
if (gtPeak > 1e-10) {
104-
for (let i = 0; i < maxLen; i++) {
105-
gtArray[i] /= gtPeak;
106-
}
107104
}
105+
peakNormalize(gtArray);
108106
columns.push(gtArray);
109107
}
110108

apps/cadecon/src/lib/algorithm-store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { samplingRate } from './data-store.ts';
1111
// on the way down, where best-residual tracking (iteration-manager.ts) catches it.
1212
const [tauRiseInit, setTauRiseInit] = createSignal(0.2);
1313
const [tauDecayInit, setTauDecayInit] = createSignal(1.0);
14-
const [upsampleTarget, setUpsampleTarget] = createSignal(300);
14+
const [upsampleTarget, setUpsampleTarget] = createSignal(120);
1515
const [weightingEnabled, setWeightingEnabled] = createSignal(false);
1616
const [hpFilterEnabled, setHpFilterEnabled] = createSignal(true);
1717
const [lpFilterEnabled, setLpFilterEnabled] = createSignal(false);

apps/cadecon/src/lib/cadecon-pool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ interface KernelJobFields {
3535
maxIters: number;
3636
tol: number;
3737
refine: boolean;
38+
smoothLambda: number;
3839
warmKernel?: Float32Array;
3940
onComplete(result: KernelResult): void;
4041
}
@@ -126,6 +127,7 @@ const caDeconRouter: MessageRouter<CaDeconPoolJob, CaDeconWorkerOutbound> = {
126127
maxIters: job.maxIters,
127128
tol: job.tol,
128129
refine: job.refine,
130+
smoothLambda: job.smoothLambda,
129131
warmKernel: warmCopy,
130132
},
131133
transfers,

apps/cadecon/src/lib/chart/series-config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ import { D3_CATEGORY10, subsetColor, withOpacity } from '@calab/ui/chart';
66

77
export { subsetColor, withOpacity };
88

9+
/** Divide every element by the array's peak value so the max becomes 1.0. */
10+
export function peakNormalize(arr: number[] | Float32Array): void {
11+
let peak = 0;
12+
for (let i = 0; i < arr.length; i++) {
13+
if (arr[i] > peak) peak = arr[i];
14+
}
15+
if (peak > 1e-10) {
16+
for (let i = 0; i < arr.length; i++) {
17+
arr[i] /= peak;
18+
}
19+
}
20+
}
21+
922
export function createRawTraceSeries(): uPlot.Series {
1023
return { label: 'Raw', stroke: '#1f77b4', width: 1 };
1124
}

apps/cadecon/src/lib/iteration-manager.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ const TRACE_FISTA_TOL = 1e-4;
5656
/** Per-subset kernel estimation solver parameters. */
5757
const KERNEL_FISTA_MAX_ITERS = 200;
5858
const KERNEL_FISTA_TOL = 1e-4;
59+
/** TV-L1 smoothness penalty for free-form kernel estimation. */
60+
const KERNEL_SMOOTH_LAMBDA = 0;
5961

6062
let pool: WorkerPool<CaDeconPoolJob> | null = null;
6163
let nextJobId = 0;
@@ -241,6 +243,7 @@ function dispatchKernelJobs(
241243
maxIters: KERNEL_FISTA_MAX_ITERS,
242244
tol: KERNEL_FISTA_TOL,
243245
refine: true,
246+
smoothLambda: KERNEL_SMOOTH_LAMBDA,
244247
warmKernel,
245248
onComplete(result: KernelResult) {
246249
kernelResults.push(result);
@@ -530,7 +533,7 @@ export async function startRun(): Promise<void> {
530533
})),
531534
});
532535

533-
// Step 4: Best-residual tracking & early stop
536+
// Step 4: Best-residual tracking & early stop (DISABLED)
534537
//
535538
// TODO: The current stopping criterion uses the bi-exponential fit residual
536539
// (||h_free - β·template||²), which measures kernel shape mismatch. This
@@ -540,6 +543,10 @@ export async function startRun(): Promise<void> {
540543
// the model explains the data. That's more expensive to compute but would
541544
// be a stronger signal for when the kernel has overshot.
542545
//
546+
// Disabled: with damped tau updates the residual-patience early stop fires
547+
// too aggressively before the damped parameters have had time to settle.
548+
// Re-enable once a better stopping metric is implemented.
549+
//
543550
if (medResidual < bestResidual) {
544551
bestResidual = medResidual;
545552
bestTauR = tauR;
@@ -550,14 +557,6 @@ export async function startRun(): Promise<void> {
550557
residualIncreaseCount++;
551558
}
552559

553-
// Early stop: if residual has risen for RESIDUAL_PATIENCE consecutive
554-
// iterations, the optimizer has overshot. The post-loop revert below will
555-
// restore the best-residual kernel parameters before finalization.
556-
if (residualIncreaseCount >= RESIDUAL_PATIENCE) {
557-
setConvergedAtIteration(bestIteration);
558-
break;
559-
}
560-
561560
// Step 5: Convergence check (relative change in tau values)
562561
const relChangeTauR = Math.abs(tauR - prevTauR) / (prevTauR + 1e-20);
563562
const relChangeTauD = Math.abs(tauD - prevTauD) / (prevTauD + 1e-20);

0 commit comments

Comments
 (0)