diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 50a17204e..ea65f35c9 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -487,7 +487,16 @@ const ANSWERABLE = new Set(['ok', 'empty']); /** Rows per scan slice before yielding to the page. */ const SCAN_CHUNK = 20_000; +/** + * A slice also ends after this long: a row inside a large area pays a ray + * cast over every edge (a 6,000-vertex outline takes ~200 ms per 20,000 rows), + * so the row count alone would still make long tasks. + */ +const SCAN_BUDGET_MS = 8; +/** Rows between clock checks. */ +const SCAN_CLOCK_EVERY = 256; const yieldToPage = () => new Promise((resolve) => setTimeout(resolve, 0)); +const clockMs = () => globalThis.performance?.now?.() ?? Date.now(); /** * Compile the filters into one predicate over (layerKey, row). @@ -628,12 +637,19 @@ export async function selectAnalystRows( let min = Infinity; let max = -Infinity; let seen = 0; + let sliceStart = clockMs(); for (const { key, rows } of groups) { const kept = []; for (const row of rows) { - if (++seen % SCAN_CHUNK === 0) { + seen += 1; + if ( + seen % SCAN_CHUNK === 0 || + (seen % SCAN_CLOCK_EVERY === 0 && + clockMs() - sliceStart >= SCAN_BUDGET_MS) + ) { await yieldToPage(); if (!isCurrent()) return null; + sliceStart = clockMs(); } if (inScope && !inScope(row)) continue; if (!passes(key, row)) continue; @@ -901,6 +917,8 @@ export function createAnalystEngine(providers) { needsClarification: true, query: scope.name, candidates: region.candidates || [], + // The way back once the user picks: count over that area. + then: 'After the user picks, call resolve_area with that candidateId, then analyst_query with scope {kind:"area", areaId}.', coverage: { layersQueried, scope: `region:${scope.name}:ambiguous`, diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 5a46c629c..51052f4cf 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -1097,6 +1097,7 @@ test('analyst: an ambiguous region asks instead of counting', async () => { assert.equal(r.ok, false); assert.equal(r.code, 'NEEDS_CLARIFICATION'); assert.equal(r.candidates.length, 1); + assert.match(r.then, /resolve_area.*candidateId.*analyst_query.*kind.*area/, 'the model gets a route back'); assert.equal(engine.hasMemory(), false); }); @@ -1202,3 +1203,23 @@ test('a follow-up on handed-off places keeps the area they were found in', async assert.equal(result.scopeLabel, 'inside Kathmandu'); assert.equal(result.coverage.scope, 'area:area-1'); }); + +test('a slow scan yields on a time budget, not only every 20,000 rows', async () => { + // 4,000 rows that each cost ~0.05 ms to read: ~200 ms of work in one slice + // under a row-count budget alone. + const slow = () => { + const until = performance.now() + 0.05; + while (performance.now() < until); + return 5; + }; + const rows = Array.from({ length: 4_000 }, (_, i) => ({ id: `S${i}`, lat: 0, lon: 0, get frp() { return slow(); } })); + const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) }); + // Count the scan's yields (other timers run only when it yields), not wall + // gaps, so a loaded machine cannot fail it. + let ticks = 0; + const timer = setInterval(() => ticks++, 0); + const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, filters: [{ field: 'frp', op: 'gt', value: 1 }] }); + clearInterval(timer); + assert.equal(result.count, 4_000); + assert.ok(ticks >= 5, `the scan yielded ${ticks} times in ~200 ms of work`); +});