Skip to content

Commit d8d918e

Browse files
satraclaude
andcommitted
feat(026): wire year-aware density into the neuroscape backdrop
+page.svelte: a reactive densityCalibration memo (targetBudget = today's full-span base-sample size = points with lod_level ≤ cap; self-heals when the full corpus wave lands), and scatterBackdropForMap now delegates to yearAwareSample when a year filter is active — otherwise it keeps today's `lod_level ≤ cap` path VERBATIM, so the full-span landing, atlas-root, and /ohbm2026/ are byte-identical. UmapPanel, backdropFull (viewport detail, already year-filtered) and the result-list derivations are unchanged, so filtering semantics/counts are untouched. e2e neuroscape_year_density.spec.ts (runs against the deployed preview in CI): a fixed-width early-era vs recent-era window renders backdrop dot counts within a bounded ratio (compressed, B2/SC-001) while result-count still reflects true volume recent≫early (B4/FR-006); clear restores the full-span backdrop (B5). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f87ba6c commit d8d918e

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

site/src/routes/+page.svelte

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
} from '$lib/data_package/loader';
100100
import { selectIdsInGeometry, type LassoGeometry } from '$lib/geo/lasso_select';
101101
import { resolveAtlasSelection, type AtlasSelection } from '$lib/atlas/select';
102+
import { calibrate, yearAwareSample, type DensityCalibration } from '$lib/atlas/year_density';
102103
import { loadClusterCentroids } from '$lib/shards';
103104
// Spec 019 / FR-002 — full cluster-routed semantic ranker. Wired in
104105
// when the `neuroscape_vectors.parquet` sidecar URL is configured;
@@ -837,8 +838,38 @@
837838
// blue-noise representative tiers (lod_level <= cap) while the result
838839
// list keeps the full `filteredBackdrop`. Derived from `scatterBackdrop`
839840
// so facet filters still apply to the map.
841+
// Spec 026 — year-aware backdrop density. Calibrate the dots-per-√article
842+
// constant ONCE from the loaded corpus (targetBudget = today's full-span
843+
// base-sample size = points with lod_level ≤ cap). Reactive on
844+
// atlasBackdrop so it self-heals when the full corpus wave lands after the
845+
// coarse first paint; `null` until neuroscape corpus + cap are resident.
846+
$: densityCalibration = ((): DensityCalibration | null => {
847+
if (SITE_MODE !== 'neuroscape' || neuroscapeLodCap === null || atlasBackdrop.length === 0) {
848+
return null;
849+
}
850+
const cap = neuroscapeLodCap;
851+
let targetBudget = 0;
852+
for (const p of atlasBackdrop) {
853+
const lv = (p as { lod_level?: number }).lod_level;
854+
if (lv === undefined || lv <= cap) targetBudget++;
855+
}
856+
return calibrate(atlasBackdrop as unknown as { pubmed_id: number; year: number; lod_level?: number }[], targetBudget);
857+
})();
858+
859+
// The SCATTER base sample. Full span (no year filter) → today's spatial
860+
// `lod_level ≤ cap` cover, UNCHANGED. Year filter active → the
861+
// compressed-proportional per-year sample (√count, shape-preserving within
862+
// each year), so a fixed-width window shows comparable density as it slides
863+
// (spec 026, FR-001/004). `scatterBackdrop` is already year+cluster filtered.
840864
$: scatterBackdropForMap = (() => {
841865
if (SITE_MODE !== 'neuroscape' || neuroscapeLodCap === null) return scatterBackdrop;
866+
const yearActive = filterMinYear !== null || filterMaxYear !== null;
867+
if (yearActive && densityCalibration !== null) {
868+
return yearAwareSample(
869+
scatterBackdrop as unknown as { pubmed_id: number; year: number; lod_level?: number }[],
870+
densityCalibration
871+
) as unknown as typeof scatterBackdrop;
872+
}
842873
const cap = neuroscapeLodCap;
843874
return scatterBackdrop.filter((p) => {
844875
const lv = (p as { lod_level?: number }).lod_level;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Spec 026 — NeuroScape year-aware backdrop density e2e.
3+
*
4+
* Verifies the render-integration contract (B1–B5,
5+
* specs/026-neuroscape-year-density/contracts/render-integration.md)
6+
* against the deployed `/neuroscape/` preview:
7+
*
8+
* B1 full span: a baseline rendered backdrop dot count exists.
9+
* B2 a narrow window in the EARLY era and one in the RECENT era render
10+
* backdrop dot counts within a bounded ratio (compressed) — not the
11+
* order-of-magnitude swing a raw spatial sample gives (SC-001).
12+
* B4 the RESULT-COUNT still reflects TRUE volume (recent ≫ early), i.e.
13+
* the feature changes only backdrop rendering, not filtering
14+
* semantics (FR-006 / SC-004).
15+
* B5 clearing the year filter restores ~the full-span backdrop count.
16+
*
17+
* Like the other neuroscape specs this runs against the deployed preview
18+
* (the 461k corpus streams over several seconds), so we poll for a stable
19+
* result list before measuring, and use generous bands to stay robust
20+
* while still catching the 10×+ regression.
21+
*/
22+
import { test, expect, type Page } from '@playwright/test';
23+
24+
function neuroscapeUrl(): string {
25+
const raw = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:4173/ohbm2026/';
26+
const base = raw.endsWith('/') ? raw : `${raw}/`;
27+
return base.replace(/\/ohbm2026\/$/, '/neuroscape/');
28+
}
29+
30+
/** Total points rendered across the 2D scatter's traces (the backdrop base
31+
* sample dominates at default zoom; viewport-detail is off when zoomed out). */
32+
async function renderedBackdropCount(page: Page): Promise<number> {
33+
return page.evaluate(() => {
34+
const el = document.querySelector('[data-testid="umap-chart-2d"]') as unknown as {
35+
data?: Array<{ x?: unknown[] }>;
36+
} | null;
37+
if (!el?.data) return 0;
38+
return el.data.reduce((sum, tr) => sum + (Array.isArray(tr.x) ? tr.x.length : 0), 0);
39+
});
40+
}
41+
42+
async function resultCount(page: Page): Promise<number> {
43+
const t = (await page.getByTestId('result-count').textContent())?.trim() ?? '0';
44+
return Number.parseInt(t.replace(/[^0-9]/g, ''), 10) || 0;
45+
}
46+
47+
/** Drag a slider handle to an absolute fraction of the track width. */
48+
async function dragToFraction(page: Page, testid: string, frac: number): Promise<void> {
49+
const track = page.getByTestId('neuroscape-year-slider').locator('.track');
50+
const tbox = await track.boundingBox();
51+
const handle = page.getByTestId(testid);
52+
const hbox = await handle.boundingBox();
53+
if (!tbox || !hbox) throw new Error('slider not laid out');
54+
await page.mouse.move(hbox.x + hbox.width / 2, hbox.y + hbox.height / 2);
55+
await page.mouse.down();
56+
await page.mouse.move(tbox.x + tbox.width * frac, tbox.y + tbox.height / 2, { steps: 12 });
57+
await page.mouse.up();
58+
}
59+
60+
/** Poll until the rendered backdrop count settles (two equal reads). */
61+
async function stableBackdropCount(page: Page): Promise<number> {
62+
let prev = -1;
63+
await expect
64+
.poll(
65+
async () => {
66+
const cur = await renderedBackdropCount(page);
67+
const settled = cur > 0 && cur === prev;
68+
prev = cur;
69+
return settled ? 'stable' : 'moving';
70+
},
71+
{ timeout: 60_000, intervals: [500, 1000, 2000] }
72+
)
73+
.toBe('stable');
74+
return renderedBackdropCount(page);
75+
}
76+
77+
test.describe('Spec 026: /neuroscape/ year-aware backdrop density', () => {
78+
test.beforeEach(async ({ page }) => {
79+
test.setTimeout(240_000);
80+
await page.goto(neuroscapeUrl(), { waitUntil: 'domcontentloaded' });
81+
await page.getByTestId('search-input').waitFor({ state: 'visible', timeout: 30_000 });
82+
// Ensure the map is mounted (desktop shows it by default; on a narrow
83+
// project tap "Show map").
84+
const chart = page.getByTestId('umap-chart-2d');
85+
if (!(await chart.isVisible().catch(() => false))) {
86+
const showMap = page.getByTestId('toggle-map');
87+
if (await showMap.isVisible().catch(() => false)) await showMap.click();
88+
}
89+
await chart.waitFor({ state: 'visible', timeout: 60_000 });
90+
// Wait for the corpus + scatter to settle before measuring.
91+
await stableBackdropCount(page);
92+
});
93+
94+
test('B1/B2/B4/B5: compressed backdrop density while true counts reflect volume', async ({
95+
page
96+
}) => {
97+
// B1 — full-span baseline.
98+
const fullSpanDots = await stableBackdropCount(page);
99+
expect(fullSpanDots).toBeGreaterThan(0);
100+
101+
// EARLY-era narrow window (left ~15% of the track).
102+
await dragToFraction(page, 'neuroscape-year-handle-start', 0.02);
103+
await dragToFraction(page, 'neuroscape-year-handle-end', 0.17);
104+
const earlyDots = await stableBackdropCount(page);
105+
const earlyResults = await resultCount(page);
106+
107+
// RECENT-era narrow window (right ~15% of the track), same width.
108+
await dragToFraction(page, 'neuroscape-year-handle-start', 0.83);
109+
await dragToFraction(page, 'neuroscape-year-handle-end', 0.98);
110+
const recentDots = await stableBackdropCount(page);
111+
const recentResults = await resultCount(page);
112+
113+
// B4 / FR-006 — the RESULT list still reflects TRUE volume: recent
114+
// years have many more articles than early years.
115+
expect(recentResults).toBeGreaterThan(earlyResults);
116+
117+
// B2 / SC-001 — the rendered BACKDROP density is compressed: the two
118+
// same-width windows are within a bounded ratio (generous band for
119+
// e2e; the pre-feature swing was 10×+). Guard against divide-by-zero.
120+
expect(earlyDots).toBeGreaterThan(0);
121+
expect(recentDots).toBeGreaterThan(0);
122+
const ratio = recentDots / earlyDots;
123+
expect(ratio).toBeLessThanOrEqual(4);
124+
// And both windows render fewer dots than the full span.
125+
expect(recentDots).toBeLessThanOrEqual(fullSpanDots);
126+
127+
// B5 — clearing the year filter restores ~the full-span backdrop.
128+
await page.getByTestId('neuroscape-facets-clear').click();
129+
const restored = await stableBackdropCount(page);
130+
expect(Math.abs(restored - fullSpanDots) / fullSpanDots).toBeLessThan(0.1);
131+
});
132+
});

0 commit comments

Comments
 (0)