perf(analyst): yield area scans on a time budget

From review: a row inside a large area pays a ray cast over every edge, so
20,000 rows against a 6,000-vertex outline made ~200 ms long tasks. The scan
now also yields once ~8 ms have passed, checked every 256 rows. An ambiguous
region refusal also tells the model the way back: resolve_area with the
picked candidateId, then analyst_query over that area.
This commit is contained in:
Manjunath committed 2026-11-03 19:36:32 +05:30
1 parent 207373389a
commit 8366803c32
2 files changed
+40 -1

No files matched your search

+19 -1
View File
@@ -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`,
+21
View File
@@ -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`);
});