Files
gods-eye-view/src/data/analystEngine.js
T
Manjunath 8366803c32 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.
2026-11-03 19:36:32 +05:30

1226 lines
42 KiB
JavaScript

/**
* 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<record> (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<object|null>} 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<object|void>}} [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<object>} 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 <contact>" —
// 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<object>} rows Records in that layer's analyst shape.
* @param {{scope?: string, presentation?: object|null}} [options]
* `presentation` is how the set was scoped ("inside Kathmandu"), so a
* follow-up names the same scope instead of "anywhere".
*/
remember(layerKey, rows, { scope = 'handoff', presentation = null } = {}) {
if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false;
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: rows.length,
truncated: false,
},
],
records: {
returned: rows.length,
total: rows.length,
truncated: false,
},
scope,
followUp: false,
},
};
return true;
},
reset() {
lastResult = null;
},
hasMemory() {
return Boolean(lastResult);
},
};
}