From 1048b1dbbc93cfadd09a804cbed50511a4de2519 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Tue, 14 Jul 2026 17:47:21 +0200 Subject: [PATCH] feat(sim-control): add fail-closed intent parser for frequency-driven sim setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap item "frequency-sim-control": pilot speaks a self-service sim setup command on frequency ("set me up for an approach from 5000 ft to EDDF 07R", "change my altitude to 8000"). Rule-based, not an LLM call per transmission — a misparsed command changes the user's real sim state, and it must never be confused with real ATC phraseology routed through sttMatch.ts. parseSimControl() gates on explicit self-service anchors first ("set me up", "put me", "change/set my "); anything without one of those anchors returns no_intent, so regular readbacks and clearances can never be misrouted. Matched intents still refuse on any ambiguous slot (missing unit, out-of-range, invalid runway) with a typed reason instead of guessing. Parameter vocabulary follows NormalizedTelemetry (altitude_ft, ias_kts, heading_deg) since the command travels toward the bridge/sim, not the decision engine's DecisionNodeAutoTrigger vocabulary. Design doc covers the still-missing server→bridge write channel (piggyback on the existing telemetry POST response) — not implemented yet, this commit is the parser only. Co-Authored-By: Claude Sonnet 5 --- shared/utils/simControl.ts | 167 ++++++++++++++++++++++++++++++++ tests/shared/simControl.test.ts | 148 ++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 shared/utils/simControl.ts create mode 100644 tests/shared/simControl.test.ts diff --git a/shared/utils/simControl.ts b/shared/utils/simControl.ts new file mode 100644 index 0000000..0898fd3 --- /dev/null +++ b/shared/utils/simControl.ts @@ -0,0 +1,167 @@ +// Frequency-driven simulator control (roadmap key `frequency-sim-control`). +// +// Parses free-form SIM SETUP commands the pilot speaks on frequency ("set me +// up for an approach from 5000 ft to EDDF 07R", "change my altitude to 8000") +// into structured commands for the bridge. These are meta commands from the +// user to their OWN simulator instance — deliberately a different grammar from +// ICAO readback phraseology (which runs through sttMatch.ts) so an ordinary +// ATC readback can never be mistaken for a sim command. +// +// Fail-closed by design: anything ambiguous returns { matched: false } with a +// reason instead of a best-effort guess — a misparsed command changes the +// user's real sim state. Parameter names follow the NormalizedTelemetry +// convention (useRadioBackend.ts): the command travels toward the bridge/sim, +// so it speaks the sim-side vocabulary (altitude_ft, ias_kts, heading_deg). + +import { denormalizeSpokenAtc, normalizeForMatch } from './sttMatch' + +export type SimControlCommand = + | { type: 'set_altitude'; altitude_ft: number } + | { type: 'set_heading'; heading_deg: number } + | { type: 'set_speed'; ias_kts: number } + | { + type: 'setup_approach' + /** Uppercase 4-letter ICAO ("EDDF"). Existence is the caller's check. */ + airport_icao: string + /** "07R", "26L", "23" — zero-padded number 01–36 + optional L/R/C. */ + runway: string + altitude_ft?: number + final_distance_nm?: number + } + +export type SimControlNoMatchReason = + | 'no_intent' + | 'missing_value' + | 'missing_unit' + | 'out_of_range' + | 'invalid_runway' + | 'missing_runway' + | 'missing_airport' + +export type SimControlParseResult = + | { matched: true; command: SimControlCommand; text: string } + | { matched: false; reason: SimControlNoMatchReason; text: string } + +/** Hard value ranges; anything outside is a refusal, never a clamp. */ +export const SIM_CONTROL_LIMITS = { + altitude_ft: { min: 0, max: 45000 }, + heading_deg: { min: 1, max: 360 }, + ias_kts: { min: 60, max: 400 }, + final_distance_nm: { min: 1, max: 30 }, +} as const + +// 4-letter English words that can follow "to/at/for" in an approach phrase +// and must never be mistaken for an ICAO code. +const ICAO_STOPWORDS = new Set([ + 'left', 'right', 'feet', 'final', 'mile', 'nautical', 'from', 'with', 'land', +]) + +function noMatch(reason: SimControlNoMatchReason, text: string): SimControlParseResult { + return { matched: false, reason, text } +} + +function inRange(value: number, limit: { min: number; max: number }): boolean { + return value >= limit.min && value <= limit.max +} + +/** "flight level 100" / "fl100" → feet, or null when no FL phrase is present. */ +function flightLevelFeet(fragment: string): number | null { + const fl = fragment.match(/\b(?:flight level|fl) ?(\d{1,3})\b/) + return fl ? Number(fl[1]) * 100 : null +} + +function parseApproach(text: string): SimControlParseResult { + // Runway: keyword form ("runway 26 l" → "runway 26l" after denormalize) or a + // bare suffixed token ("25r"). A bare number WITHOUT suffix is only accepted + // behind the "runway" keyword — a lone "25" in the sentence is too ambiguous. + const keyword = text.match(/\brunway (\d{1,2}) ?([lrc])?\b/) + const bareToken = text.match(/(?:^|\s)(\d{2})([lrc])(?:\s|$)/) + const rwNumRaw = keyword?.[1] ?? bareToken?.[1] + const rwSuffix = (keyword ? keyword[2] : bareToken?.[2]) ?? '' + if (!rwNumRaw) return noMatch('missing_runway', text) + const rwNum = Number(rwNumRaw) + if (rwNum < 1 || rwNum > 36) return noMatch('invalid_runway', text) + const runway = `${rwNumRaw.padStart(2, '0')}${rwSuffix.toUpperCase()}` + + const airport = text.match(/\b(?:to|at|for|into) ([a-z]{4})\b/) + if (!airport || ICAO_STOPWORDS.has(airport[1]!)) return noMatch('missing_airport', text) + const airport_icao = airport[1]!.toUpperCase() + + // Altitude is optional, but WHEN a "from " is present it must carry + // an explicit unit (feet/ft) or be a flight level — a bare "from 5000" is + // ambiguous and refused rather than guessed. + let altitude_ft: number | undefined + const fromFl = text.match(/\bfrom (?:flight level|fl) ?(\d{1,3})\b/) + const fromFt = text.match(/\bfrom (\d{3,5}) ?(?:feet|ft)\b/) + if (fromFl) altitude_ft = Number(fromFl[1]) * 100 + else if (fromFt) altitude_ft = Number(fromFt[1]) + else if (/\bfrom \d/.test(text)) return noMatch('missing_unit', text) + if (altitude_ft !== undefined && !inRange(altitude_ft, SIM_CONTROL_LIMITS.altitude_ft)) { + return noMatch('out_of_range', text) + } + + let final_distance_nm: number | undefined + const dist = text.match(/\b(\d{1,2}) ?(?:miles?|nm|nautical miles?) final\b/) + if (dist) { + final_distance_nm = Number(dist[1]) + if (!inRange(final_distance_nm, SIM_CONTROL_LIMITS.final_distance_nm)) { + return noMatch('out_of_range', text) + } + } + + const command: SimControlCommand = { type: 'setup_approach', airport_icao, runway } + if (altitude_ft !== undefined) command.altitude_ft = altitude_ft + if (final_distance_nm !== undefined) command.final_distance_nm = final_distance_nm + return { matched: true, command, text } +} + +function parseParameter( + parameter: 'altitude' | 'heading' | 'speed', + rest: string, + text: string, +): SimControlParseResult { + if (parameter === 'altitude') { + // "altitude" names the unit context, so a bare number is unambiguous here + // (this is the literal roadmap wording "change my altitude to 8000"). + const fl = flightLevelFeet(rest) + const num = fl ?? Number(rest.match(/\b(\d{1,6})\b/)?.[1] ?? NaN) + if (!Number.isFinite(num)) return noMatch('missing_value', text) + if (!inRange(num, SIM_CONTROL_LIMITS.altitude_ft)) return noMatch('out_of_range', text) + return { matched: true, command: { type: 'set_altitude', altitude_ft: num }, text } + } + if (parameter === 'heading') { + const num = Number(rest.match(/\b(\d{1,3})\b/)?.[1] ?? NaN) + if (!Number.isFinite(num)) return noMatch('missing_value', text) + if (!inRange(num, SIM_CONTROL_LIMITS.heading_deg)) return noMatch('out_of_range', text) + return { matched: true, command: { type: 'set_heading', heading_deg: num }, text } + } + const num = Number(rest.match(/\b(\d{2,3})\b/)?.[1] ?? NaN) + if (!Number.isFinite(num)) return noMatch('missing_value', text) + if (!inRange(num, SIM_CONTROL_LIMITS.ias_kts)) return noMatch('out_of_range', text) + return { matched: true, command: { type: 'set_speed', ias_kts: num }, text } +} + +/** + * Parse a transmission into a sim-control command, or refuse. + * + * The intent gate is intentionally narrow: only explicit self-service anchors + * ("set me up …", "put me …", "reposition …" for approaches; "change/set my + * …" for single values) enter parsing at all. Regular phraseology + * ("descend to 5000 feet", "request descent …", "cleared to land …") carries + * none of these anchors and always returns `no_intent` — that property is the + * safety contract of this module and is covered by tests. + */ +export function parseSimControl(input: string): SimControlParseResult { + const text = normalizeForMatch(denormalizeSpokenAtc(input)) + if (!text) return noMatch('no_intent', text) + + const wantsApproach = /\b(?:set me up|put me|reposition(?: me)?)\b.*\b(?:approach|final)\b/.test(text) + if (wantsApproach) return parseApproach(text) + + const parameter = text.match(/\b(?:change|set) my (altitude|heading|speed)\b(.*)$/) + if (parameter) { + return parseParameter(parameter[1] as 'altitude' | 'heading' | 'speed', parameter[2] ?? '', text) + } + + return noMatch('no_intent', text) +} diff --git a/tests/shared/simControl.test.ts b/tests/shared/simControl.test.ts new file mode 100644 index 0000000..57cd8f6 --- /dev/null +++ b/tests/shared/simControl.test.ts @@ -0,0 +1,148 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { parseSimControl } from '~~/shared/utils/simControl' + +function expectCommand(input: string) { + const result = parseSimControl(input) + assert.equal(result.matched, true, `expected a command for "${input}", got ${JSON.stringify(result)}`) + return (result as Extract, { matched: true }>).command +} + +function expectNoMatch(input: string, reason?: string) { + const result = parseSimControl(input) + assert.equal(result.matched, false, `expected NO match for "${input}", got ${JSON.stringify(result)}`) + if (reason) { + assert.equal((result as Extract, { matched: false }>).reason, reason) + } +} + +describe('parseSimControl — approach setup', () => { + it('parses the canonical roadmap utterance (written form)', () => { + const cmd = expectCommand('set me up for an approach from 5000 ft to EDDF 07R') + assert.deepEqual(cmd, { + type: 'setup_approach', + airport_icao: 'EDDF', + runway: '07R', + altitude_ft: 5000, + }) + }) + + it('parses the same utterance in fully spoken Whisper form', () => { + const cmd = expectCommand( + 'set me up for an approach from five thousand feet to echo delta delta foxtrot zero seven right', + ) + assert.deepEqual(cmd, { + type: 'setup_approach', + airport_icao: 'EDDF', + runway: '07R', + altitude_ft: 5000, + }) + }) + + it('parses an approach without altitude ("runway 26 left" wording)', () => { + const cmd = expectCommand('set me up for an approach to EDDM runway 26 left') + assert.deepEqual(cmd, { + type: 'setup_approach', + airport_icao: 'EDDM', + runway: '26L', + }) + }) + + it('parses a final with a distance ("5 mile final")', () => { + const cmd = expectCommand('put me on a 5 mile final for EDDF 25R') + assert.deepEqual(cmd, { + type: 'setup_approach', + airport_icao: 'EDDF', + runway: '25R', + final_distance_nm: 5, + }) + }) + + it('parses a flight-level altitude in an approach setup', () => { + const cmd = expectCommand('set me up for an approach from flight level 100 to EDDL runway 23') + assert.deepEqual(cmd, { + type: 'setup_approach', + airport_icao: 'EDDL', + runway: '23', + altitude_ft: 10000, + }) + }) + + it('rejects an approach altitude without a unit (ambiguous)', () => { + expectNoMatch('set me up for an approach from 5000 to EDDF 07R', 'missing_unit') + }) + + it('rejects an invalid runway number (40)', () => { + expectNoMatch('set me up for an approach to EDDF runway 40', 'invalid_runway') + }) + + it('rejects an approach without a runway', () => { + expectNoMatch('set me up for an approach to EDDF', 'missing_runway') + }) + + it('rejects an approach without an airport', () => { + expectNoMatch('set me up for an approach runway 25', 'missing_airport') + }) +}) + +describe('parseSimControl — single-parameter changes', () => { + it('parses the roadmap altitude wording (bare number is OK — "altitude" names the unit context)', () => { + const cmd = expectCommand('change my altitude to 8000') + assert.deepEqual(cmd, { type: 'set_altitude', altitude_ft: 8000 }) + }) + + it('parses an altitude given as flight level', () => { + const cmd = expectCommand('set my altitude to flight level 100') + assert.deepEqual(cmd, { type: 'set_altitude', altitude_ft: 10000 }) + }) + + it('parses a heading change', () => { + const cmd = expectCommand('change my heading to 270') + assert.deepEqual(cmd, { type: 'set_heading', heading_deg: 270 }) + }) + + it('parses a speed change with unit', () => { + const cmd = expectCommand('set my speed to 210 knots') + assert.deepEqual(cmd, { type: 'set_speed', ias_kts: 210 }) + }) + + it('parses spoken digits ("change my heading to two seven zero")', () => { + const cmd = expectCommand('change my heading to two seven zero') + assert.deepEqual(cmd, { type: 'set_heading', heading_deg: 270 }) + }) + + it('rejects a parameter change without a value', () => { + expectNoMatch('change my altitude', 'missing_value') + }) + + it('rejects an out-of-range altitude', () => { + expectNoMatch('change my altitude to 80000 feet', 'out_of_range') + }) + + it('rejects an out-of-range heading', () => { + expectNoMatch('change my heading to 370', 'out_of_range') + }) +}) + +describe('parseSimControl — must NEVER match ATC dialogue', () => { + it('does not match a plain ATC readback ("descend to 5000 feet")', () => { + expectNoMatch('descend to 5000 feet', 'no_intent') + }) + + it('does not match a pilot request ("request descent to flight level 100")', () => { + expectNoMatch('request descent to flight level 100', 'no_intent') + }) + + it('does not match a clearance readback with runway and callsign', () => { + expectNoMatch('cleared to land runway 25R Lufthansa 359', 'no_intent') + }) + + it('does not match a squawk instruction ("set squawk 7000")', () => { + expectNoMatch('set squawk 7000', 'no_intent') + }) + + it('does not match an unanchored altitude mention ("set me up at 8000")', () => { + expectNoMatch('set me up at 8000') + }) +})