Files
gods-eye-view/src/data/contactMatch.js
T

130 lines
5.1 KiB
JavaScript

/**
* Shared identity matching for aircraft contact lookup (`findByQuery`).
*
* Voice resolves a spoken or model-supplied identity against the loaded fleet,
* and the analyst → track_entity handoff feeds it a DISPLAY label — callsign,
* else registration, else hex. Both aircraft layers scan their own maps, so
* the precedence and canonicalization live here: two copies of this ranking
* would drift, and the tie-breaking is exactly what makes the answer stable.
* @module contactMatch
*/
/**
* Match strength, strongest first. A lower tier ALWAYS wins regardless of feed
* order — that is the point: with callsign and registration ranked together, a
* registration that happened to equal another contact's callsign was resolved
* by upstream `Map` insertion order, so the same spoken query could follow a
* different aircraft between polls.
*
* Callsign outranks registration at equal strength because the callsign is the
* identity air traffic uses and the one the operator is most likely saying.
* @enum {number}
*/
export const CONTACT_MATCH_TIER = Object.freeze({
HEX_EXACT: 0,
CALLSIGN_EXACT: 1,
REGISTRATION_EXACT: 2,
CALLSIGN_PREFIX: 3,
REGISTRATION_PREFIX: 4,
CALLSIGN_SUBSTRING: 5,
REGISTRATION_SUBSTRING: 6,
NONE: Number.POSITIVE_INFINITY,
});
/**
* Reduce an identity to its comparable core: uppercase alphanumerics only.
*
* Registrations are written with separators the speaker does not say and the
* model does not always reproduce — `G-ABCD` / `GABCD`, `05-8152` / `058152`,
* `N123AB` / `N-123AB`. The DISPLAYED value is never rewritten; only the
* comparison is canonical.
* @param {*} value Raw identity text.
* @returns {string} Canonical form, or '' when there is nothing to compare.
*/
export function canonicalizeContactId(value) {
return String(value ?? '')
.replace(/[^a-z0-9]/gi, '')
.toUpperCase();
}
/**
* Rank one contact against a query.
*
* Hex is checked first and exactly: it is the unique key, so a hex query must
* never be out-competed by a substring hit elsewhere in the fleet.
* @param {object} params
* @param {string} params.query Caller's raw query text.
* @param {string} [params.hex] Contact's unique transponder address.
* @param {string} [params.callsign] Contact's callsign, if any.
* @param {string} [params.registration] Contact's registration, if any.
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
*/
export function rankContactMatch({
query,
hex = '',
callsign = '',
registration = '',
}) {
const rawQuery = String(query ?? '')
.trim()
.toLowerCase();
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
if (
String(hex ?? '')
.trim()
.toLowerCase() === rawQuery
)
return CONTACT_MATCH_TIER.HEX_EXACT;
const callsignText = String(callsign ?? '')
.trim()
.toLowerCase();
// Callsigns carry no separators, so they compare raw — canonicalizing them
// would silently widen the long-standing callsign contract.
if (callsignText && callsignText === rawQuery)
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
const canonicalQuery = canonicalizeContactId(rawQuery);
const canonicalRegistration = canonicalizeContactId(registration);
if (canonicalQuery && canonicalRegistration === canonicalQuery) {
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
}
if (callsignText && callsignText.startsWith(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
}
if (callsignText && callsignText.includes(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
}
return CONTACT_MATCH_TIER.NONE;
}
/**
* Should `candidate` replace the best match found so far?
*
* Ties are broken on the transponder hex, ascending. The tool this feeds
* (`track_entity`) is a MUTATION fulfilling "follow that one" — returning an
* ambiguity for the caller to resolve would cost a round-trip mid-demo, and
* the model's observed response to a non-ok track result is to retry with
* different guesses rather than to ask (owner field session 2026-08-21,
* 23:48). So the lookup always commits. What it owes the caller is STABILITY:
* hex is unique and always present, so the same query resolves to the same
* contact for as long as both are loaded, instead of flipping between polls
* with feed order. Ambiguity that matters is visible to the operator anyway —
* the camera moves to a named contact and the card says which.
* @param {{tier: number, id: string}} candidate Contact just ranked.
* @param {{tier: number, id: string}|null} incumbent Best match so far.
* @returns {boolean} True when the candidate should take over.
*/
export function contactMatchWins(candidate, incumbent) {
if (!candidate || candidate.tier === CONTACT_MATCH_TIER.NONE) return false;
if (!incumbent) return true;
if (candidate.tier !== incumbent.tier) return candidate.tier < incumbent.tier;
return String(candidate.id) < String(incumbent.id);
}