/** * Analyst query engine — answers spoken questions over data ALREADY sitting * client-side in the layers ("how many flights over Texas?", "biggest fire * near LA?", "which ships are headed to Oakland?"). * * Design (owner-ratified, docs/voice-engine-evaluation-2026-07-23.md §5.3): * - ENGINE (this module) is pure query logic over plain record arrays; it * renders nothing. SURFACES (voice narration, panels, detection brackets) * consume the returned result set — the engine/surface seam is the * `items` array with stable {layerKey, id} identities. * - v1 scope is CLIENT-SIDE DATA ONLY. The one enrichment path (flight * routes) reads the already-cached adsbdb results surfaced by the layer * accessor; the engine never fetches. Fleet-wide route search is * explicitly out of scope. * - Follow-up memory: the previous result set can be re-queried ("which of * those is closest?") via `followUp: true`. Held per engine instance, * cleared by `reset()` (layer toggles should reset via the caller). * * Providers (injected — keeps the engine pure and node-testable): * getRecords(layerKey) → Array (layer accessor snapshot) * resolveRegionRing(name, {signal}?) → Promise<{name, geometry|ring, prepared?, source?} * |{error:'region-timeout'}|{needsClarification, candidates}|null> * (geometry = MultiPolygon coordinates; islands and holes all count) * resolveAreaScope(scope) → Promise<{ok:true, area:{areaId, name, prepared, * source, approximate}}|{ok:false, code, error}> (area/drawn/annotation) * getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived) * * @module data/analystEngine */ import { feedProvenanceEnvelope } from './layerSnapshot.js'; import { pointInRing } from './naturalEarthRegions.js'; import { pointInPreparedArea, prepareArea } from './areaGeometry.js'; import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js'; import { greatCircleKm } from '../geoDistance.js'; /** * Layers the engine understands, with the fields queries may reference, * derived from the voice layer manifest (one source for the tool enums, the * runner and this engine). */ export const ANALYST_LAYERS = Object.freeze( Object.fromEntries( VOICE_LAYER_MANIFEST.filter((entry) => entry.query).map((entry) => { const byType = (type) => Object.entries(entry.query.fields) .filter(([, spec]) => spec.type === type) .map(([name]) => name); return [ entry.id, Object.freeze({ numeric: byType('number'), text: byType('text'), flags: byType('flag'), time: byType('time'), window: entry.query.window, timeField: entry.query.timeField, caveat: entry.query.caveat, }), ]; }), ), ); /** Fields every record carries regardless of layer. */ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']); /** Derived per-record field for layers with a time field (hours before now). */ const AGE_FIELD = 'ageHours'; /** Spatial scope kinds `query()` accepts. */ const ANALYST_SCOPE_KINDS = Object.freeze([ 'view', 'region', 'radius', 'anywhere', 'area', 'drawn', 'annotation', ]); /** * Every field a query over these layers may filter or sort on. * @param {string[]} layerKeys Queried layer ids. * @returns {string[]} Field names, common fields first. */ function analystFieldsFor(layerKeys) { const fields = new Set(COMMON_FIELDS); for (const key of layerKeys) { const layer = ANALYST_LAYERS[key]; if (!layer) continue; for (const name of [ ...layer.numeric, ...layer.text, ...layer.flags, ...layer.time, ]) fields.add(name); if (layer.timeField) fields.add(AGE_FIELD); } return [...fields]; } /** Epoch ms from a number or an ISO/date string; NaN when neither. */ function toEpochMs(value) { if (typeof value === 'number') return value; const text = String(value ?? '').trim(); if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text); return Date.parse(text); } /** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */ export function haversineKm(lat1, lon1, lat2, lon2) { return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } /** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere'|'area', …}. */ export function applyScope(records, scope, resolved) { if (!scope || scope.kind === 'anywhere') return records; if (resolved?.prepared) { return records.filter((r) => pointInPreparedArea(resolved.prepared, r.lat, r.lon), ); } if (scope.kind === 'region' && resolved?.ring) { return records.filter( (r) => Number.isFinite(r.lat) && Number.isFinite(r.lon) && pointInRing(resolved.ring, r.lat, r.lon), ); } if (scope.kind === 'radius' || scope.kind === 'view') { const c = resolved?.center; const km = resolved?.km; if (!c || !Number.isFinite(km)) return records; return records.filter( (r) => Number.isFinite(r.lat) && Number.isFinite(r.lon) && haversineKm(c.lat, c.lon, r.lat, r.lon) <= km, ); } return records; } const OPS_BY_TYPE = Object.freeze({ number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'], time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'], text: ['eq', 'neq', 'contains'], flag: ['eq', 'neq'], }); const COMMON_TYPES = Object.freeze({ id: 'text', lat: 'number', lon: 'number', [AGE_FIELD]: 'number', }); const COMMON_UNITS = Object.freeze({ lat: 'deg', lon: 'deg', [AGE_FIELD]: 'h', }); /** * The type a field has across the queried layers, or null when unknown. * @param {string[]} layerKeys Queried layer ids. * @param {string} field Field name. * @returns {'number'|'text'|'flag'|'time'|null} Field type. */ function analystFieldType(layerKeys, field) { if (!analystFieldsFor(layerKeys).includes(field)) return null; if (COMMON_TYPES[field]) return COMMON_TYPES[field]; for (const key of layerKeys) { const layer = ANALYST_LAYERS[key]; if (!layer) continue; if (layer.numeric.includes(field)) return 'number'; if (layer.text.includes(field)) return 'text'; if (layer.flags.includes(field)) return 'flag'; if (layer.time.includes(field)) return 'time'; } return null; } function analystFieldUnit(layerKeys, field) { if (COMMON_UNITS[field]) return COMMON_UNITS[field]; for (const key of layerKeys) { const unit = voiceLayer(key)?.query?.fields?.[field]?.unit; if (unit) return unit; } return null; } /** Spoken/written unit spellings → canonical unit. */ const UNIT_ALIASES = Object.freeze({ m: 'm', meter: 'm', meters: 'm', metre: 'm', metres: 'm', ft: 'ft', foot: 'ft', feet: 'ft', km: 'km', kilometers: 'km', kilometres: 'km', mi: 'mi', mile: 'mi', miles: 'mi', nmi: 'nmi', 'm/s': 'm/s', mps: 'm/s', kn: 'kn', kt: 'kn', kts: 'kn', knot: 'kn', knots: 'kn', 'km/h': 'km/h', kmh: 'km/h', kph: 'km/h', mph: 'mph', h: 'h', hr: 'h', hrs: 'h', hour: 'h', hours: 'h', min: 'min', minutes: 'min', s: 's', sec: 's', seconds: 's', deg: 'deg', degrees: 'deg', }); /** Canonical unit → [dimension, size in the dimension's base unit]. */ const UNIT_SCALE = Object.freeze({ m: ['length', 1], ft: ['length', 0.3048], km: ['length', 1000], mi: ['length', 1609.344], nmi: ['length', 1852], 'm/s': ['speed', 1], kn: ['speed', 1852 / 3600], 'km/h': ['speed', 1 / 3.6], mph: ['speed', 0.44704], h: ['time', 3600], min: ['time', 60], s: ['time', 1], }); const canonicalUnit = (unit) => { const text = String(unit).trim().toLowerCase(); return UNIT_ALIASES[text] || text; }; /** * Factor turning a value in `given` into the field's own unit, or null when * the two cannot be converted ("altitudeM(ft)" → 0.3048). */ function unitFactor(given, fieldUnit) { if (!fieldUnit) return null; const from = canonicalUnit(given); const to = canonicalUnit(fieldUnit); if (from === to) return 1; const a = UNIT_SCALE[from]; const b = UNIT_SCALE[to]; if (!a || !b || a[0] !== b[0]) return null; return a[1] / b[1]; } /** Split "altitudeM(ft)" into the field and its written unit. */ function splitUnit(name) { if (typeof name !== 'string') return { field: name, unit: null }; const match = /^(.*?)\s*\(([^)]*)\)\s*$/.exec(name); return match ? { field: match[1], unit: match[2].trim() || null } : { field: name, unit: null }; } const NUMERIC_TEXT = /^\s*[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?\s*$/; /** A filter value in its field's type, or undefined when it is not one. */ function typedValue(type, value) { if (type === 'number') { if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; if (typeof value === 'string' && NUMERIC_TEXT.test(value)) return Number(value); return undefined; } if (type === 'flag') return typeof value === 'boolean' ? value : undefined; if (type === 'time') { if (typeof value === 'boolean') return undefined; const ms = toEpochMs(value); return Number.isFinite(ms) ? ms : undefined; } if (typeof value === 'string' && value.trim() !== '') return value; if (typeof value === 'number' && Number.isFinite(value)) return String(value); return undefined; } const VALUE_WORDS = Object.freeze({ number: 'a number', flag: 'true or false', time: 'an ISO time or epoch milliseconds', text: 'text', }); function refusal(code, error, extra = {}) { return { ok: false, code, error, ...extra }; } const validLat = (v) => typeof v === 'number' && v >= -90 && v <= 90; const validLon = (v) => typeof v === 'number' && v >= -180 && v <= 180; /** * Validate a query against the manifest before touching any records, so a * typo can never read as a confident zero or a silently widened answer. * Returns the normalized spec (bare field names, typed values in each * field's own unit) or a refusal. * @returns {{spec?: object, refusal?: object}} One of the two. */ function normalizeSpec(spec, layerKeys) { const scope = spec.scope; if (scope !== undefined && scope !== null) { const kind = String(scope.kind ?? ''); if (!ANALYST_SCOPE_KINDS.includes(kind)) return { refusal: refusal( 'UNKNOWN_SCOPE', `Unknown scope kind "${kind}". Allowed: ${ANALYST_SCOPE_KINDS.join(', ')}.`, { allowed: [...ANALYST_SCOPE_KINDS] }, ), }; if (kind === 'region' && !String(scope.name ?? '').trim()) return { refusal: refusal('BAD_SCOPE', 'A region scope needs a place name.'), }; if ( kind === 'area' && (typeof scope.areaId !== 'string' || !scope.areaId.trim()) ) return { refusal: refusal( 'BAD_SCOPE', 'An area scope needs the areaId resolve_area returned.', ), }; if ( kind === 'annotation' && scope.id !== undefined && (typeof scope.id !== 'string' || !scope.id.trim()) ) return { refusal: refusal('BAD_SCOPE', 'scope.id must be an annotation id.'), }; if (scope.center !== undefined && scope.center !== null) { if (!validLat(scope.center.lat) || !validLon(scope.center.lon)) return { refusal: refusal( 'BAD_SCOPE', 'scope.center needs lat in -90..90 and lon in -180..180.', ), }; } else if (kind === 'radius' && String(scope.name ?? '').trim()) { // A radius never geocodes: a name without a centre would silently // measure around the camera while the answer names the place. return { refusal: refusal( 'BAD_SCOPE', `A radius around "${scope.name}" needs center {lat, lon}; or use kind=region for a named area.`, ), }; } if ( scope.km !== undefined && !( typeof scope.km === 'number' && Number.isFinite(scope.km) && scope.km > 0 ) ) return { refusal: refusal('BAD_SCOPE', 'scope.km must be a positive number.'), }; } const allowed = analystFieldsFor(layerKeys); const badUnit = (field, unit, fieldUnit) => refusal( 'BAD_UNIT', fieldUnit ? `${field} is in ${fieldUnit}; "${unit}" does not convert to it.` : `${field} has no unit; drop "(${unit})".`, { field, expected: fieldUnit }, ); const filters = []; for (const filter of spec.filters || []) { const { field, unit } = splitUnit(String(filter?.field ?? '')); const type = analystFieldType(layerKeys, field); if (!type) return { refusal: refusal( 'UNKNOWN_FIELD', `Unknown field "${field}" for ${layerKeys.join(', ')}. Allowed: ${allowed.join(', ')}.`, { field, allowed }, ), }; const ops = OPS_BY_TYPE[type]; if (!ops.includes(filter.op)) return { refusal: refusal( 'BAD_OPERATOR', `Operator "${filter.op}" does not apply to ${field}. Allowed: ${ops.join(', ')}.`, { field, allowed: [...ops] }, ), }; let value = typedValue(type, filter.value); if (value === undefined) return { refusal: refusal( 'BAD_VALUE', `${field} expects ${VALUE_WORDS[type]}.`, { field, expected: type, }, ), }; if (unit) { const fieldUnit = analystFieldUnit(layerKeys, field); const factor = type === 'number' ? unitFactor(unit, fieldUnit) : null; if (factor === null) return { refusal: badUnit(field, unit, fieldUnit) }; value *= factor; } filters.push({ field, op: filter.op, value, type }); } let sortBy = spec.sortBy || null; if (sortBy) { const { field, unit } = splitUnit(sortBy); sortBy = field; if (sortBy !== 'distance' && !allowed.includes(sortBy)) return { refusal: refusal( 'UNKNOWN_FIELD', `Unknown sort field "${sortBy}". Allowed: distance, ${allowed.join(', ')}.`, { field: sortBy, allowed: ['distance', ...allowed] }, ), }; if (unit) { const fieldUnit = sortBy === 'distance' ? 'km' : analystFieldUnit(layerKeys, sortBy); if (unitFactor(unit, fieldUnit) === null) return { refusal: badUnit(sortBy, unit, fieldUnit) }; } } if (spec.sortDir !== undefined && !['asc', 'desc'].includes(spec.sortDir)) return { refusal: refusal('BAD_SORT', 'sortDir must be asc or desc.', { allowed: ['asc', 'desc'], }), }; return { spec: { ...spec, filters, sortBy } }; } /** * The validated, normalized form of analyst_query arguments (bare field * names, typed values in each field's own unit), or null when invalid. * @param {object} spec Tool arguments. * @param {string[]} layerKeys Queried layer ids. * @returns {object|null} Normalized spec. */ export function normalizeAnalystSpec(spec, layerKeys) { return normalizeSpec(spec || {}, layerKeys).spec || null; } /** * Where a queried layer stands, so "off", "loading", "feed down" and a real * zero never read alike. * @returns {'off'|'loading'|'error'|'empty'|'ok'} Layer status. */ function layerStatus(snapshot, rowCount, warming) { if (snapshot && snapshot.enabled === false) return 'off'; if (rowCount > 0) return 'ok'; if (warming || snapshot?.feedState === 'loading') return 'loading'; if (snapshot?.feedState === 'unavailable' || snapshot?.error) return 'error'; return 'empty'; } /** A layer in one of these states can answer, including with a real zero. */ 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). * @returns {(key: string, row: object) => boolean} Predicate. */ function compileFilters(filters, read) { const tests = filters.map(({ field, op, value, type }) => { const lower = type === 'text' ? String(value).toLowerCase() : null; return (key, row) => { let got = read(key, row, field); if (got === null || got === undefined) return false; if (type === 'time') { got = toEpochMs(got); if (!Number.isFinite(got)) return false; } if (type === 'flag') { if (op === 'eq') return Boolean(got) === value; return Boolean(got) !== value; } if (type === 'text') { const text = String(got).toLowerCase(); if (op === 'contains') return text.includes(lower); return (text === lower) === (op === 'eq'); } const n = Number(got); if (!Number.isFinite(n)) return false; switch (op) { case 'gt': return n > value; case 'gte': return n >= value; case 'lt': return n < value; case 'lte': return n <= value; case 'eq': return n === value; case 'neq': return n !== value; default: return false; } }; }); return (key, row) => tests.every((test) => test(key, row)); } /** Comparable form of a sort value; missing values sort last either way. */ function sortKey(type, value) { if (value === null || value === undefined || value === '') return null; if (type === 'text' || type === 'flag') return String(value); const n = type === 'time' ? toEpochMs(value) : Number(value); return Number.isFinite(n) ? n : null; } /** Negative when a ranks before b. */ function rankOrder(a, b, dir) { if (a.v === null || b.v === null) return (a.v === null) - (b.v === null); const order = typeof a.v === 'string' ? a.v.localeCompare(b.v) : a.v - b.v; return order * dir || String(a.row.id).localeCompare(String(b.row.id)); } /** * Keep the best `limit` candidates without sorting the whole set. * @param {Array} top Current best, ranked; mutated. */ function offerTop(top, candidate, limit, dir) { if (top.length === limit && rankOrder(candidate, top[limit - 1], dir) >= 0) return; let lo = 0; let hi = top.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (rankOrder(candidate, top[mid], dir) < 0) hi = mid; else lo = mid + 1; } top.splice(lo, 0, candidate); if (top.length > limit) top.pop(); } /** * Count, filter and rank loaded rows grouped by layer. Shared by the general * engine and the Contacts-window path so both format and rank alike. * @param {Array<{key: string, rows: object[]}>} groups Candidate rows. * @param {object} options * @returns {Promise} Selection, or null when cancelled. */ export async function selectAnalystRows( groups, { filters = [], sortBy = null, sortDir, limit = 10, layerKeys = groups.map((group) => group.key), ref = null, inScope = null, now = Date.now(), isCurrent = () => true, distanceOf = null, } = {}, ) { const timeFieldOf = (key) => ANALYST_LAYERS[key]?.timeField || null; const ageOf = (key, row) => { const field = timeFieldOf(key); const t = field ? row[field] : null; return Number.isFinite(t) ? Math.round(((now - t) / 3_600_000) * 10) / 10 : null; }; const distance = (row) => { const own = distanceOf?.(row); if (Number.isFinite(own)) return own; return ref && Number.isFinite(row.lat) && Number.isFinite(row.lon) ? Math.round(haversineKm(ref.lat, ref.lon, row.lat, row.lon) * 10) / 10 : null; }; const read = (key, row, field) => field === AGE_FIELD ? ageOf(key, row) : field === 'distanceKm' ? distance(row) : row[field]; const passes = compileFilters(filters, read); const sortField = sortBy === 'distance' ? 'distanceKm' : sortBy; const sortType = sortBy === 'distance' ? 'number' : sortBy ? analystFieldType(layerKeys, sortBy) : null; const dir = (sortDir || (sortBy === 'distance' ? 'asc' : 'desc')) === 'asc' ? 1 : -1; const cap = Math.max(1, Math.min(50, Number(limit) || 10)); const matched = []; const top = []; let count = 0; 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) { 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; kept.push(row); count++; if (sortField) { const raw = read(key, row, sortField); const v = sortKey(sortType, raw); if (sortBy !== 'distance' && typeof v === 'number') { if (v < min) min = v; if (v > max) max = v; } offerTop(top, { key, row, v }, cap, dir); } else if (top.length < cap) top.push({ key, row }); } matched.push({ key, rows: kept }); } const summary = { count }; if (sortField && sortBy !== 'distance' && Number.isFinite(min)) { summary[`${sortBy}Min`] = min; summary[`${sortBy}Max`] = max; } // Fresh copies: callers may annotate items freely without touching the // remembered rows. const items = top.map(({ key, row }) => { const item = { layerKey: key, ...row }; const age = ageOf(key, row); if (age !== null) item[AGE_FIELD] = age; if (sortBy === 'distance') { const km = distance(row); item.distanceKm = Number.isFinite(km) ? Math.round(km * 10) / 10 : km; } return item; }); return { count, items, matched, summary }; } /** * Create an engine bound to live providers. All spatial/text/number logic is * in the pure helpers above; this closure only sequences and remembers. */ export function createAnalystEngine(providers) { let lastResult = null; /** * Run one query. * @param {object} spec Query arguments (see the analyst_query tool). * @param {{signal?: AbortSignal, isCurrent?: () => boolean, beforeCommit?: (result: object) => * Promise}} [options] A query that is no longer current * (cancelled or superseded while awaiting a boundary or between scan * slices) returns CANCELLED and never replaces the follow-up memory. * `beforeCommit` lets a caller finish required async projection work and * optionally supply the exact cohort that was presented before memory is * changed. * @returns {Promise} Result or refusal. */ async function query( spec = {}, { signal = null, isCurrent = () => true, beforeCommit = null } = {}, ) { const cancelled = () => refusal('CANCELLED', 'This query was superseded.', { cancelled: true }); if (spec.followUp && !lastResult) return refusal( 'NO_RESULT_CONTEXT', 'There is no previous answer to follow up on. Ask the full question.', { coverage: { layersQueried: [], scope: 'none' } }, ); const followUp = Boolean(spec.followUp); const inheritsRememberedScope = followUp && spec.scope == null; // A follow-up re-filters the remembered set. Layers named explicitly // must be that set: "those hospitals" after an aircraft answer is a new // question, never a filter over the old aircraft. if (followUp && Array.isArray(spec.layers) && spec.layers.length) { const remembered = new Set(lastResult.layerKeys); const named = new Set(spec.layers); const same = remembered.size === named.size && [...named].every((key) => remembered.has(key)); if (!same) return refusal( 'FOLLOW_UP_MISMATCH', `The last answer was about ${lastResult.layerKeys.join(', ')}, not ${spec.layers.join(', ')}. Ask again without followUp.`, { layers: [...spec.layers], remembered: [...lastResult.layerKeys], coverage: { layersQueried: [], scope: 'none' }, }, ); } const layers = followUp ? lastResult.layerKeys : Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']; if (!followUp) { const unknown = layers.filter((k) => !ANALYST_LAYERS[k]); if (unknown.length) { return refusal( 'UNSUPPORTED_LAYER', `I can't query ${unknown .map((key) => { const why = voiceLayer(key)?.noQuery; return why ? `${key} (${why})` : key; }) .join( ', ', )} — queryable layers: ${Object.keys(ANALYST_LAYERS).join(', ')}.`, { allowed: Object.keys(ANALYST_LAYERS), coverage: { layersQueried: [], scope: 'unsupported-layer' }, }, ); } } const normalized = normalizeSpec(spec, layers); if (normalized.refusal) return normalized.refusal; spec = normalized.spec; // 1) Source records, grouped by layer and never copied. let groups; let layersQueried; let queriedSnapshots; let unanswered = []; if (followUp) { groups = lastResult.matched; layersQueried = lastResult.coverage.layersQueried; queriedSnapshots = lastResult.coverage.feedProvenance?.layers || []; unanswered = [...(lastResult.unanswered || [])]; } else { groups = []; layersQueried = []; queriedSnapshots = []; for (const key of layers) { const rows = providers.getRecords(key) || []; const snapshot = providers.getLayerSnapshot?.(key); if (snapshot) queriedSnapshots.push(snapshot); const coverage = providers.getRecordCoverage?.(key, rows) || {}; // Unknown when a capped layer cannot say how many it holds. const total = Number.isFinite(coverage.total) ? coverage.total : coverage.truncated ? null : rows.length; const layer = ANALYST_LAYERS[key]; const status = layerStatus( snapshot, rows.length, providers.isWarming?.(key) === true, ); // Why an enabled layer holds nothing here ("fly below 400 km…"). const emptyNote = status === 'empty' ? providers.getEmptyNote?.(key) || null : null; layersQueried.push({ layerKey: key, status, ...(emptyNote ? { note: emptyNote } : {}), returned: rows.length, total, truncated: coverage.truncated === true || (total !== null && total > rows.length), ...(layer.window ? { window: layer.window } : {}), ...(snapshot ? { feedState: snapshot.feedState, source: snapshot.source, lastUpdate: snapshot.lastUpdate, enabled: snapshot.enabled, error: snapshot.error, } : {}), }); groups.push({ key, rows }); } // Nothing can answer: say why instead of answering zero. const statuses = layersQueried.map((layer) => layer.status); if (!statuses.some((status) => ANSWERABLE.has(status))) { const detail = { layers: [...layers], layerStatus: layersQueried.map(({ layerKey, status }) => ({ layerKey, status, })), coverage: { layersQueried, scope: 'none' }, }; const names = `${layers.join(', ')} ${layers.length > 1 ? 'are' : 'is'}`; if (statuses.every((status) => status === 'off')) return refusal( 'LAYER_OFF', `${names} off. Offer to turn it on.`, detail, ); if (statuses.includes('loading')) return refusal( 'NOT_READY', `${names} still loading or off — no records yet.`, detail, ); return refusal( 'FEED_UNAVAILABLE', `${names} unavailable or off right now.`, detail, ); } unanswered = layersQueried .filter((layer) => !ANSWERABLE.has(layer.status)) .map((layer) => layer.layerKey); } // 2) Spatial scope const rememberedPresentation = inheritsRememberedScope ? lastResult.presentation || null : null; const rememberedCoverageNote = rememberedPresentation?.coverageNote || (inheritsRememberedScope ? lastResult.coverage?.note : null); const rememberedDistanceOf = inheritsRememberedScope ? lastResult.distanceOf || null : null; let resolvedScope = null; let scopeNote = rememberedPresentation?.coverageScope || (inheritsRememberedScope ? lastResult.coverage?.scope : null) || 'anywhere'; let scopeSource = rememberedPresentation?.coverageScopeSource || (inheritsRememberedScope ? lastResult.coverage?.scopeSource : null) || null; // Human phrasing for the same scope, so every spoken count can name what it // measured ("8 in view", "about 30 within 250 km of Austin") instead of // arriving as a bare number that contradicts the panel. let scopeLabel = rememberedPresentation?.scopeLabel || 'anywhere in the loaded data'; let scopeDetail = rememberedPresentation?.display?.scope || scopeLabel; // A follow-up re-filters a set that was already scoped, so it only narrows // further when a new scope is given. const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' }; if (scope.kind === 'region') { let region; try { region = await providers.resolveRegionRing(scope.name, { signal }); } catch (error) { // Cancellation belongs to the query owner, not an error's name: a // resource-local AbortError from a current lookup still propagates. if (signal?.aborted || !isCurrent()) return cancelled(); throw error; } if (signal?.aborted || !isCurrent()) return cancelled(); if (region?.error === 'region-timeout') { return { ok: false, code: 'region-timeout', error: `Looking up the boundary for "${scope.name}" is taking too long — ask again in a moment.`, coverage: { layersQueried, scope: `region:${scope.name}:timeout` }, }; } if (region?.needsClarification) { return refusal( 'NEEDS_CLARIFICATION', region.error || `"${scope.name}" names more than one place — ask which one.`, { 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`, }, }, ); } const prepared = region?.prepared || (region?.geometry ? prepareArea(region.geometry) : region?.ring ? prepareArea([[region.ring]]) : null); if (!prepared) { return { ok: false, code: 'REGION_UNRESOLVED', error: `I couldn't resolve a boundary for "${scope.name}" — try a state, country, or a named natural region.`, coverage: { layersQueried, scope: `region:${scope.name}:unresolved` }, }; } resolvedScope = { ...region, prepared }; scopeNote = `region:${region.name}`; scopeSource = region.source || null; scopeLabel = `over ${region.name}`; const regionSource = region.sourceLabel || region.source; scopeDetail = `${scopeLabel}${region.approximate ? ' (approximate outline)' : ''}${regionSource ? ` · ${regionSource}` : ''}`; } else if ( scope.kind === 'area' || scope.kind === 'drawn' || scope.kind === 'annotation' ) { const found = providers.resolveAreaScope ? await providers.resolveAreaScope(scope) : { ok: false, code: 'AREA_UNAVAILABLE', error: 'Area scopes are not available here.', }; if (!isCurrent()) return cancelled(); if (!found?.ok || !found.area?.prepared) { return refusal( found?.code || 'AREA_UNKNOWN', found?.error || 'That area is not available.', { coverage: { layersQueried, scope: `${scope.kind}:unresolved` } }, ); } const area = found.area; resolvedScope = { prepared: area.prepared, name: area.name }; scopeNote = `area:${area.areaId || scope.kind}`; scopeLabel = `inside ${area.name}`; const sourceLabel = area.sourceLabel || area.source; scopeDetail = `${scopeLabel}${area.approximate ? ' (approximate outline)' : ''}${sourceLabel ? ` · ${sourceLabel}` : ''}`; if (area.areaId) resolvedScope.areaId = area.areaId; } else if (scope.kind === 'radius') { // An explicit center always wins. Otherwise, when Contacts is active its // SUBJECT is the centre the operator is actually reasoning about: the // panel counts a contact-centred window, so centring the radius on the // camera made the two disagree — a parked, high-altitude camera answered // "46 within 250 km" while the panel showed a far larger contact-centred // count. Same question, two numbers. const explicitCenter = scope.center || null; const subject = explicitCenter ? null : providers.getContextSubject?.() || null; const view = providers.getViewContext(); // Only a subject that actually SUPPLIED the centre may name it. A // subject present but without usable coordinates silently fell back to // the camera while the label still read "within 250 km of " — // a count centred on one place, reported as centred on another, with // nothing in the payload to show which. const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon) ? { lat: subject.lat, lon: subject.lon } : null; const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon }; resolvedScope = { center, km: Number(scope.km) || 100 }; if (subjectCenter) resolvedScope.centeredOn = subject.label || null; scopeNote = resolvedScope.centeredOn ? `radius:${resolvedScope.km}km@${resolvedScope.centeredOn}` : `radius:${resolvedScope.km}km`; scopeLabel = resolvedScope.centeredOn ? `within ${resolvedScope.km} km of ${resolvedScope.centeredOn}` : `within ${resolvedScope.km} km`; scopeDetail = scopeLabel; } else if (scope.kind === 'view') { const view = providers.getViewContext(); resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm, }; scopeNote = `view:${Math.round(view.viewRadiusKm)}km`; // TODO: "in view" is a radius around the camera centre, not the visible // footprint; the display detail says so until a footprint scope lands. scopeLabel = 'in view'; scopeDetail = `within ${Math.round(view.viewRadiusKm)} km of the view centre`; } const inScope = resolvedScope?.prepared ? (row) => pointInPreparedArea(resolvedScope.prepared, row.lat, row.lon) : (scope.kind === 'radius' || scope.kind === 'view') && resolvedScope?.center && Number.isFinite(resolvedScope.km) ? (row) => Number.isFinite(row.lat) && Number.isFinite(row.lon) && haversineKm( resolvedScope.center.lat, resolvedScope.center.lon, row.lat, row.lon, ) <= resolvedScope.km : null; // 3) Filter, count and rank in one pass, yielding between slices. const selection = await selectAnalystRows(groups, { filters: spec.filters, sortBy: spec.sortBy, sortDir: spec.sortDir, limit: spec.limit, layerKeys: layers, distanceOf: rememberedDistanceOf, ref: spec.sortBy === 'distance' ? resolvedScope?.center || providers.getViewContext() : null, inScope, now: providers.now?.() ?? Date.now(), isCurrent, }); if (!selection || !isCurrent()) return cancelled(); const totals = layersQueried.reduce( (sum, layer) => ({ returned: sum.returned + (layer.returned ?? 0), total: sum.total === null || layer.total === null ? null : sum.total + (layer.total ?? layer.returned ?? 0), truncated: sum.truncated || layer.truncated === true, }), { returned: 0, total: 0, truncated: false }, ); const windows = [ ...new Set(layersQueried.map((layer) => layer.window).filter(Boolean)), ]; const caveats = []; if (totals.truncated) caveats.push( totals.total === null ? `counted the first ${totals.returned.toLocaleString('en-US')} loaded records` : `counted ${totals.returned.toLocaleString('en-US')} of ${totals.total.toLocaleString('en-US')} loaded records`, ); for (const layer of layersQueried) { if (layer.note) caveats.push(`${layer.layerKey}: ${layer.note}`); else if (!ANSWERABLE.has(layer.status)) caveats.push(`${layer.layerKey} ${layer.status}`); } const distanceScoped = spec.sortBy === 'distance' || scope.kind === 'view' || scope.kind === 'radius'; for (const key of layers) { const caveat = ANALYST_LAYERS[key]?.caveat; if (caveat && distanceScoped) caveats.push(`${key}: ${caveat}`); } const result = { ok: true, count: selection.count, // False when a layer was capped: the count is then a floor and the // ranking covers only the records examined. complete: !totals.truncated, ...(unanswered.length ? { partial: true, unanswered } : {}), items: selection.items, summary: selection.summary, scopeLabel, // For on-screen text; the model speaks the count and scopeLabel only. display: { scope: scopeDetail, ...(rememberedPresentation?.display?.window || windows.length ? { window: rememberedPresentation?.display?.window || windows.join('; '), } : {}), ...(rememberedPresentation?.display?.caveat || caveats.length ? { caveat: rememberedPresentation?.display?.caveat || caveats.join('; '), } : {}), }, coverage: { layersQueried, records: totals, scope: scopeNote, ...(scopeSource ? { scopeSource } : {}), ...(rememberedCoverageNote ? { note: rememberedCoverageNote } : {}), ...(queriedSnapshots.length ? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) } : {}), followUp, }, // Surfaced so the narration can name the centre it measured from rather // than implying a view-centred answer. ...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : rememberedPresentation?.centeredOn ? { centeredOn: rememberedPresentation.centeredOn } : {}), ...(rememberedPresentation?.window ? { window: structuredClone(rememberedPresentation.window) } : {}), ...(resolvedScope?.areaId ? { areaId: resolvedScope.areaId } : rememberedPresentation?.areaId ? { areaId: rememberedPresentation.areaId } : {}), }; const memoryOverride = typeof beforeCommit === 'function' ? (await beforeCommit(result)) || null : null; if (!isCurrent()) return cancelled(); lastResult = { matched: memoryOverride?.matched || selection.matched, distanceOf: memoryOverride?.distanceOf || rememberedDistanceOf, layerKeys: [...(memoryOverride?.layerKeys || layers)], coverage: memoryOverride?.coverage || result.coverage, unanswered: [ ...(memoryOverride?.unanswered === undefined ? unanswered : memoryOverride.unanswered), ], presentation: structuredClone( memoryOverride?.presentation || { scopeLabel: result.scopeLabel, display: result.display, coverageScope: result.coverage?.scope, ...(result.coverage?.scopeSource ? { coverageScopeSource: result.coverage.scopeSource } : {}), ...(result.coverage?.note ? { coverageNote: result.coverage.note } : {}), ...(result.centeredOn ? { centeredOn: result.centeredOn } : {}), ...(result.window ? { window: result.window } : {}), ...(result.areaId ? { areaId: result.areaId } : {}), }, ), }; return result; } return { query, /** * Make another tool's result set "the last answer" (osm_query's places), * so a follow-up filters exactly what the user just heard about. * @param {string} layerKey Queryable layer the rows belong to. * @param {Array} rows Records in that layer's analyst shape. * @param {{scope?: string, presentation?: object|null, truncated?: boolean}} [options] * `presentation` is how the set was scoped ("inside Kathmandu"), so a * follow-up names the same scope instead of "anywhere". `truncated` * says the set is only the first part of a larger one, so follow-ups * stay incomplete (a count is a floor) rather than claiming the whole. */ remember( layerKey, rows, { scope = 'handoff', presentation = null, truncated = false } = {}, ) { if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false; const capped = truncated === true; // A capped set's true size is unknown. const total = capped ? null : rows.length; lastResult = { ...(presentation ? { presentation: structuredClone(presentation) } : {}), matched: [{ key: layerKey, rows: rows.slice() }], layerKeys: [layerKey], coverage: { layersQueried: [ { layerKey, status: rows.length ? 'ok' : 'empty', returned: rows.length, total, truncated: capped, }, ], records: { returned: rows.length, total, truncated: capped, }, scope, followUp: false, }, }; return true; }, reset() { lastResult = null; }, hasMemory() { return Boolean(lastResult); }, }; }