ReviewOS

also looking at this

stacks/drone

feat: correlate VTOL hover mode observations

#77
Merged chris wants to merge codex/owned-vtol-transitions into main
10 files +507 -28

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

resources/functions/drone/missions/vehicle.tsmodified+54-10
Changes to resources/functions/drone/missions/vehicle.ts
@@ -1,4 +1,4 @@
1import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand } from '../vehicles/types'
1import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types'
22import type { MissionState } from './engine'
33import { authorityStates } from '../authority/supervisor'
44import { validVector } from '../controls/model'
@@ -26,6 +26,11 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
2626 let fields = ['id', 'kind'], edges: string[] = [], durationMs = 20
2727 if (t.kind === 'start') { fields.push('next'); if (t.id !== p.start || !id(t.next)) invalid(); edges = [t.next] }
2828 else if (t.kind === 'abort') { /* Terminal release, never an emergency landing. */ }
29 else if (t.kind === 'vtol-mode') {
30 fields.push('mode', 'headingRad', 'timeoutMs', 'next')
31 if (p.vehicle !== 'vtol' || !['hover', 'cruise'].includes(t.mode) || !number(t.headingRad, -Math.PI, Math.PI) || !Number.isSafeInteger(t.timeoutMs) || !number(t.timeoutMs, 100, 15000) || !id(t.next)) invalid()
32 durationMs = t.timeoutMs; edges = [t.next]
33 }
2934 else if (t.kind === 'branch') {
3035 fields.push('batteryAtLeast', 'then', 'otherwise')
3136 if (!number(t.batteryAtLeast, p.power.reservePercent + 1, 100) || !id(t.then) || !id(t.otherwise)) invalid()
@@ -65,18 +70,29 @@ function validAck(value: unknown): value is VehiclePayloadAck {
6570 if (value.metadata !== undefined && (!object(value.metadata) || Object.keys(value.metadata).length > 32 || Object.entries(value.metadata).some(([key, item]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(key) || !(typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) || (typeof item === 'string' && item.length <= 160))))) return false
6671 return true
6772}
73function validTransition(value: unknown): value is VehicleVtolTransitionObservation {
74 return object(value) && keys(value, ['evidence', 'eventId', 'taskId', 'mode', 'reference', 'capturedAtMs', 'measurementCapturedAtMs', 'phase', 'outcome', 'reason'])
75 && value.evidence === 'modeled-vtol-controller-observation' && typeof value.eventId === 'string' && value.eventId.length <= 256 && id(value.taskId) && ['hover', 'cruise'].includes(value.mode as string)
76 && typeof value.reference === 'string' && value.reference.length <= 120 && Number.isSafeInteger(value.capturedAtMs) && (value.capturedAtMs as number) >= 0
77 && Number.isSafeInteger(value.measurementCapturedAtMs) && (value.measurementCapturedAtMs as number) >= 0 && (value.measurementCapturedAtMs as number) <= (value.capturedAtMs as number)
78 && ['hover', 'front-transition', 'cruise', 'back-transition'].includes(value.phase as string) && ['progress', 'confirmed', 'aborted'].includes(value.outcome as string)
79 && typeof value.reason === 'string' && /^[a-z][a-z0-9-]{0,79}$/.test(value.reason)
80}
6881/** V2 mission policy shared by all vehicle profiles. It has no transport or arming capability. */
6982export function createVehicleMissionEngine(input: unknown) {
7083 const { plan, worstCaseMs } = validateVehicleMission(input)
7184 const tasks = new Map(plan.tasks.map(t => [t.id, t]))
85 const hasTransitions = plan.tasks.some(t => t.kind === 'vtol-mode')
7286 let state: MissionState = 'ready', taskId = plan.start, reason = 'awaiting-operator-start'
7387 let lastNow: number | null = null, startedAt: number | null = null, reference: string | null = null, taskElapsedMs = 0, settledMs = 0
7488 let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null
89 let transition: VehicleVtolTransitionCommand | null = null
90 let transitionObservation: { capturedAtMs: number, measurementCapturedAtMs: number, signature: string } | null = null
7591 const rows: VehicleMissionTrace['rows'] = []
76 function snapshot(): VehicleMissionSnapshot { return { version: 2, vehicle: plan.vehicle, evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state, taskId, taskKind: tasks.get(taskId)!.kind, targetM: targetM ? [...targetM] : null, payload: pending ? structuredClone(pending) : null, reason, elapsedMs: startedAt === null || lastNow === null ? 0 : lastNow - startedAt } }
77 function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null }
92 function snapshot(): VehicleMissionSnapshot { return { version: 2, vehicle: plan.vehicle, evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state, taskId, taskKind: tasks.get(taskId)!.kind, targetM: targetM ? [...targetM] : null, payload: pending ? structuredClone(pending) : null, reason, elapsedMs: startedAt === null || lastNow === null ? 0 : lastNow - startedAt, ...(hasTransitions ? { transition: transition ? structuredClone(transition) : null } : {}) } }
93 function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null; transition = null; transitionObservation = null }
7894 function advance(next: string) {
79 taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; reason = 'task-entered'
95 taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; transition = null; transitionObservation = null; reason = 'task-entered'
8096 const task = tasks.get(next)!
8197 const goal = vehicleTaskTarget(plan, task)
8298 if (goal) targetM = goal as VehicleMissionSnapshot['targetM']
@@ -90,7 +106,7 @@ export function createVehicleMissionEngine(input: unknown) {
90106 let o: VehicleMissionObservation
91107 try { o = structuredClone(inputObservation) }
92108 catch { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') }
93 if (!object(o) || !keys(o, ['kind', 'nowMs', 'reference', 'origin', 'healthy', 'positionM', 'velocityMps', 'authority', 'batteryPercent', 'onGround', 'payloadAck']) || Object.keys(o).length !== 11 || o.kind !== 'simulation' || !Number.isSafeInteger(o.nowMs) || o.nowMs < 0 || (lastNow !== null && o.nowMs <= lastNow) || typeof o.reference !== 'string' || o.reference.length > 120 || !/^[1-9][0-9]{0,19}:[0-9]+,[0-9]+,[0-9]+,[0-9]+,[0-9]+$/.test(o.reference) || !object(o.origin) || !keys(o.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || o.origin.latitudeDeg !== plan.origin.latitudeDeg || o.origin.longitudeDeg !== plan.origin.longitudeDeg || o.origin.ellipsoidHeightM !== plan.origin.ellipsoidHeightM || typeof o.healthy !== 'boolean' || typeof o.onGround !== 'boolean' || !validVector(o.positionM) || !validVector(o.velocityMps) || !authorityStates.includes(o.authority) || !number(o.batteryPercent, 0, 100) || (o.payloadAck !== null && !validAck(o.payloadAck)) || (action !== undefined && !['start', 'pause', 'resume', 'abort', 'recover'].includes(action))) { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') }
109 if (!object(o) || !keys(o, ['kind', 'nowMs', 'reference', 'origin', 'healthy', 'positionM', 'velocityMps', 'authority', 'batteryPercent', 'onGround', 'payloadAck', ...(hasTransitions ? ['transition'] : [])]) || Object.keys(o).length !== (hasTransitions ? 12 : 11) || o.kind !== 'simulation' || !Number.isSafeInteger(o.nowMs) || o.nowMs < 0 || (lastNow !== null && o.nowMs <= lastNow) || typeof o.reference !== 'string' || o.reference.length > 120 || !/^[1-9][0-9]{0,19}:[0-9]+,[0-9]+,[0-9]+,[0-9]+,[0-9]+$/.test(o.reference) || !object(o.origin) || !keys(o.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || o.origin.latitudeDeg !== plan.origin.latitudeDeg || o.origin.longitudeDeg !== plan.origin.longitudeDeg || o.origin.ellipsoidHeightM !== plan.origin.ellipsoidHeightM || typeof o.healthy !== 'boolean' || typeof o.onGround !== 'boolean' || !validVector(o.positionM) || !validVector(o.velocityMps) || !authorityStates.includes(o.authority) || !number(o.batteryPercent, 0, 100) || (o.payloadAck !== null && !validAck(o.payloadAck)) || (hasTransitions && o.transition !== null && !validTransition(o.transition)) || (action !== undefined && !['start', 'pause', 'resume', 'abort', 'recover'].includes(action))) { block('invalid-observation-or-clock'); throw new TypeError('Invalid vehicle mission observation') }
94110 const dt = lastNow === null ? 0 : o.nowMs - lastNow, previousState = state
95111 lastNow = o.nowMs
96112 if (active(state)) {
@@ -102,15 +118,17 @@ export function createVehicleMissionEngine(input: unknown) {
102118 else if (o.batteryPercent <= plan.power.reservePercent) block('mission-battery-reserve')
103119 else if (o.nowMs - startedAt! > worstCaseMs) block('mission-endurance-exhausted')
104120 else if (o.payloadAck !== null && pending === null) block('unsolicited-payload-ack')
121 else if (o.transition && transition === null) block('unsolicited-vtol-transition')
105122 }
106 if (action === 'abort' && (active(state) || state === 'ready')) { state = 'aborted'; reason = 'operator-abort'; targetM = null; pending = null }
123 if (action === 'abort' && (active(state) || state === 'ready')) { state = 'aborted'; reason = 'operator-abort'; targetM = null; pending = null; transition = null; transitionObservation = null }
107124 else if (action === 'start' && state === 'ready') {
108125 const needed = plan.power.reservePercent + 100 * plan.power.maxW * worstCaseMs / 3600000 / plan.power.availableWh
109 if (!o.healthy || o.authority !== 'autonomous' || !o.onGround || Math.hypot(...o.velocityMps) > 0.1 || !vehicleMissionContains(plan, o.positionM) || o.batteryPercent < needed || o.payloadAck !== null) block('mission-start-interlocks')
126 if (!o.healthy || o.authority !== 'autonomous' || !o.onGround || Math.hypot(...o.velocityMps) > 0.1 || !vehicleMissionContains(plan, o.positionM) || o.batteryPercent < needed || o.payloadAck !== null || o.transition) block('mission-start-interlocks')
110127 else { state = 'running'; startedAt = o.nowMs; reference = o.reference; holdM = [...o.positionM]; targetM = [...o.positionM]; reason = 'operator-start' }
111128 }
112129 else if ((action === 'pause' && state === 'running') || (action === 'recover' && active(state))) {
113130 if (pending) block('payload-recovery-uncertain')
131 else if (transition) block('vtol-transition-recovery-uncertain')
114132 else { state = 'paused'; holdM = [...o.positionM]; settledMs = 0; reason = action === 'recover' ? 'checkpoint-recovered-awaiting-resume' : 'operator-pause' }
115133 }
116134 else if (action === 'resume' && state === 'paused') { state = 'running'; settledMs = 0; reason = 'operator-resume' }
@@ -118,10 +136,36 @@ export function createVehicleMissionEngine(input: unknown) {
118136 if (state === 'running') {
119137 const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt
120138 taskElapsedMs += executionDt
121 if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) block(t.kind === 'payload' ? 'payload-outcome-uncertain' : 'task-timeout')
139 if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) block(t.kind === 'payload' ? 'payload-outcome-uncertain' : t.kind === 'vtol-mode' ? 'vtol-transition-outcome-uncertain' : 'task-timeout')
122140 else if (t.kind === 'abort') { state = 'aborted'; reason = 'mission-abort-task'; targetM = null }
123141 else if (t.kind === 'start') advance(t.next)
124142 else if (t.kind === 'branch') { advance(o.batteryPercent >= t.batteryAtLeast ? t.then : t.otherwise); reason = 'battery-branch' }
143 else if (t.kind === 'vtol-mode') {
144 if (!transition) {
145 transition = { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, mode: t.mode, headingRad: t.headingRad, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
146 reason = 'vtol-transition-requested'
147 }
148 targetM = [...transition.positionM]
149 const observed = o.transition
150 if (observed) {
151 const phases = transition.mode === 'hover' ? ['hover', 'back-transition'] : ['front-transition', 'cruise']
152 if (observed.eventId !== transition.eventId || observed.taskId !== transition.taskId || observed.mode !== transition.mode || observed.reference !== transition.reference
153 || observed.capturedAtMs < transition.issuedAtMs || observed.capturedAtMs >= transition.expiresAtMs || observed.capturedAtMs > o.nowMs || o.nowMs - observed.capturedAtMs > 120
154 || observed.measurementCapturedAtMs < transition.issuedAtMs || o.nowMs - observed.measurementCapturedAtMs > 120
155 || observed.outcome === 'confirmed' && observed.phase !== transition.mode || observed.outcome === 'progress' && !phases.includes(observed.phase)) block('vtol-transition-outcome-uncertain')
156 else {
157 // Wire object order is not evidence identity; all accepted fields are scalar and closed above.
158 const signature = JSON.stringify(Object.entries(observed).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
159 if (transitionObservation && (observed.capturedAtMs < transitionObservation.capturedAtMs || observed.measurementCapturedAtMs < transitionObservation.measurementCapturedAtMs || observed.capturedAtMs === transitionObservation.capturedAtMs && signature !== transitionObservation.signature)) block('vtol-transition-observation-replayed')
160 else {
161 transitionObservation = { capturedAtMs: observed.capturedAtMs, measurementCapturedAtMs: observed.measurementCapturedAtMs, signature }
162 if (observed.outcome === 'aborted') block('vtol-transition-aborted')
163 else if (observed.outcome === 'confirmed') { advance(t.next); reason = 'vtol-transition-confirmed' }
164 else reason = 'vtol-transition-progress'
165 }
166 }
167 }
168 }
125169 else if (t.kind === 'payload') {
126170 pending ??= { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, operation: t.operation, containerId: t.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! }
127171 const ack = o.payloadAck
@@ -169,13 +213,13 @@ export function recoverVehicleMission(trace: VehicleMissionTrace, observation: V
169213 if (!active(engine.snapshot().state) || engine.step(observation, 'recover').state !== 'paused') throw new TypeError('Vehicle checkpoint cannot recover without supervised compatible authority')
170214 return engine
171215}
172/** Verify the whole recording before selecting an exact active, settled-payload prefix. */
216/** Verify the whole recording before selecting an active prefix without pending payload or transition work. */
173217export function checkpointVehicleMission(trace: VehicleMissionTrace, atMs: number): VehicleMissionTrace {
174218 const verified = replayVehicleMission(trace).trace()
175219 if (!Number.isSafeInteger(atMs) || atMs < 0 || !verified.rows.some(row => row.observation.nowMs === atMs)) throw new RangeError('Checkpoint epoch must match a recorded observation')
176220 verified.rows = verified.rows.filter(row => row.observation.nowMs <= atMs)
177221 const snapshot = replayVehicleMission(verified).snapshot()
178 if (!active(snapshot.state) || snapshot.payload !== null) throw new TypeError('Vehicle mission checkpoint is not resumable or has an uncertain payload')
222 if (!active(snapshot.state) || snapshot.payload !== null || snapshot.transition) throw new TypeError('Vehicle mission checkpoint is not resumable or has an uncertain payload')
179223 return verified
180224}
181225/** Operator resume never clears authority faults or retries a pending payload. */
resources/functions/drone/vehicles/types.tsmodified+7-1
Changes to resources/functions/drone/vehicles/types.ts
@@ -31,8 +31,12 @@ export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-
3131export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
3232export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> }
3333export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void }
34export type VehicleVtolMode = 'hover' | 'cruise'
35export interface VehicleVtolTransitionCommand { eventId: string, taskId: string, mode: VehicleVtolMode, headingRad: number, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string }
36export interface VehicleVtolTransitionObservation { evidence: 'modeled-vtol-controller-observation', eventId: string, taskId: string, mode: VehicleVtolMode, reference: string, capturedAtMs: number, measurementCapturedAtMs: number, phase: 'hover' | 'front-transition' | 'cruise' | 'back-transition', outcome: 'progress' | 'confirmed' | 'aborted', reason: string }
3437export type VehicleMissionTask =
3538 | { id: string, kind: 'start', next: string }
39 | { id: string, kind: 'vtol-mode', mode: VehicleVtolMode, headingRad: number, timeoutMs: number, next: string }
3640 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
3741 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
3842 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
@@ -54,7 +58,7 @@ export interface VehicleMissionPlan {
5458 start: string
5559 tasks: VehicleMissionTask[]
5660}
57export interface VehicleMissionObservation extends MissionObservation { payloadAck: VehiclePayloadAck | null }
61export interface VehicleMissionObservation extends MissionObservation { payloadAck: VehiclePayloadAck | null, transition?: VehicleVtolTransitionObservation | null }
5862export interface VehicleMissionSnapshot {
5963 version: 2
6064 vehicle: VehicleKind
@@ -65,6 +69,8 @@ export interface VehicleMissionSnapshot {
6569 taskKind: VehicleMissionTask['kind']
6670 targetM: SensorVector | null
6771 payload: VehiclePayloadCommand | null
72 /** Present only for plans with explicit VTOL mode tasks; legacy recording shapes stay unchanged. */
73 transition?: VehicleVtolTransitionCommand | null
6874 reason: string
6975 elapsedMs: number
7076}
tests/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -76,6 +76,7 @@
7676 "./unit/vtol-control.test.ts",
7777 "./unit/vtol-runtime.test.ts",
7878 "./unit/vtol-cli.test.ts",
79 "./unit/vtol-mode-mission.test.ts",
7980 "./unit/ground.test.ts",
8081 "./unit/surface.test.ts",
8182 "./unit/mission-workspace.test.ts",
tests/unit/vtol-mode-mission.test.tsadded+163-0
Changes to tests/unit/vtol-mode-mission.test.ts
@@ -0,0 +1,163 @@
1import { expect, test } from 'bun:test'
2import type { VehicleMissionObservation, VehicleMissionPlan, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../../resources/functions/drone/vehicles/types'
3import { checkpointVehicleMission, createVehicleMissionEngine, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
4import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol'
5
6// Public mission seam catches requested/observed ordering and replay, not controller dynamics or hardware.
7test('an explicit VTOL hover request waits for its correlated observed outcome before takeoff', () => {
8 const input = {
9 ...createVtolHoverProfile().mission,
10 id: 'vtol-mode-policy',
11 tasks: [
12 { id: 'start', kind: 'start', next: 'confirm-hover' },
13 { id: 'confirm-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 1000, next: 'depart' },
14 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -5], acceptanceM: 0.2, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 2000, next: 'land' },
15 { id: 'land', kind: 'land', positionM: [0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: 2000 },
16 ],
17 } satisfies VehicleMissionPlan
18 const reference = '55:0,0,0,0,0'
19 const observation = (nowMs: number): VehicleMissionObservation => ({ kind: 'simulation', nowMs, reference, origin: input.origin, healthy: true, positionM: [0, 0, 0], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 100, onGround: true, payloadAck: null, transition: null })
20 const engine = createVehicleMissionEngine(input)
21 expect(engine.step(observation(1000), 'start').taskId).toBe('confirm-hover')
22 const requested = engine.step(observation(1020))
23 expect(requested).toMatchObject({ taskId: 'confirm-hover', targetM: [0, 0, 0], transition: {
24 eventId: 'vtol-mode-policy:55:0,0,0,0,0:confirm-hover', taskId: 'confirm-hover', mode: 'hover', headingRad: 0,
25 issuedAtMs: 1020, expiresAtMs: 2000, positionM: [0, 0, 0], reference,
26 } })
27 expect(engine.step(observation(1040))).toMatchObject({ state: 'running', taskId: 'confirm-hover', targetM: [0, 0, 0], transition: requested.transition })
28 const confirmed = engine.step({ ...observation(1060), transition: {
29 evidence: 'modeled-vtol-controller-observation', eventId: 'vtol-mode-policy:55:0,0,0,0,0:confirm-hover', taskId: 'confirm-hover', mode: 'hover', reference,
30 capturedAtMs: 1060, measurementCapturedAtMs: 1040, phase: 'hover', outcome: 'confirmed', reason: 'hover-qualified',
31 } })
32 expect(confirmed).toMatchObject({ state: 'running', taskId: 'depart', reason: 'vtol-transition-confirmed', targetM: [0, 0, -5], transition: null, physicalFlightEnabled: false })
33 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(confirmed)
34})
35
36function awaitingHover() {
37 const input = createVtolHoverProfile().mission
38 if (input.tasks[0]!.kind !== 'start') throw new Error('Expected the declared VTOL start task')
39 input.tasks[0]!.next = 'confirm-hover'
40 input.tasks.splice(1, 0, { id: 'confirm-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 1000, next: 'depart' })
41 const reference = '55:0,0,0,0,0'
42 const observation = (nowMs: number): VehicleMissionObservation => ({ kind: 'simulation', nowMs, reference, origin: input.origin, healthy: true, positionM: [0, 0, 0], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 100, onGround: true, payloadAck: null, transition: null })
43 const engine = createVehicleMissionEngine(input)
44 engine.step(observation(1000), 'start'); engine.step(observation(1020))
45 const command = engine.snapshot().transition!
46 const confirmed = (capturedAtMs: number, measurementCapturedAtMs: number): VehicleVtolTransitionObservation => ({ evidence: 'modeled-vtol-controller-observation', eventId: command.eventId, taskId: command.taskId, mode: command.mode, reference: command.reference, capturedAtMs, measurementCapturedAtMs, phase: 'hover', outcome: 'confirmed', reason: 'hover-qualified' })
47 return { engine, observation, confirmed }
48}
49
50test('older VTOL phase evidence cannot confirm a request after newer progress or regain the blocked mission', () => {
51 const { engine, observation, confirmed } = awaitingHover()
52 expect(engine.step({ ...observation(1060), transition: { ...confirmed(1060, 1040), outcome: 'progress', reason: 'hover-qualifying' } }).taskId).toBe('confirm-hover')
53 const replayed = engine.step({ ...observation(1080), transition: confirmed(1040, 1020) })
54 expect(replayed).toMatchObject({ state: 'blocked', reason: 'vtol-transition-observation-replayed', targetM: null, transition: null })
55 expect(engine.step(observation(1100), 'resume').state).toBe('blocked')
56})
57
58test('VTOL mode outcomes require matching identity, reference, post-request capture and the requested phase', () => {
59 for (const change of [
60 { eventId: 'foreign-event' }, { taskId: 'foreign-task' }, { mode: 'cruise' }, { reference: '56:0,0,0,0,0' },
61 { capturedAtMs: 1000, measurementCapturedAtMs: 1000 }, { capturedAtMs: 1080 }, { measurementCapturedAtMs: 1000 }, { phase: 'cruise' },
62 ] satisfies Partial<VehicleVtolTransitionObservation>[]) {
63 const { engine, observation, confirmed } = awaitingHover()
64 const refused = engine.step({ ...observation(1060), transition: { ...confirmed(1060, 1040), ...change } })
65 expect(refused).toMatchObject({ state: 'blocked', reason: 'vtol-transition-outcome-uncertain', targetM: null, transition: null })
66 expect(engine.step(observation(1080), 'resume').state).toBe('blocked')
67 }
68})
69
70test('identical retained progress is order independent but altered same-epoch confirmation is a replay', () => {
71 const { engine, observation, confirmed } = awaitingHover()
72 const progress: VehicleVtolTransitionObservation = { ...confirmed(1060, 1040), outcome: 'progress', reason: 'hover-qualifying' }
73 engine.step({ ...observation(1060), transition: progress })
74 const reordered: VehicleVtolTransitionObservation = {
75 reason: progress.reason, outcome: progress.outcome, phase: progress.phase, measurementCapturedAtMs: progress.measurementCapturedAtMs, capturedAtMs: progress.capturedAtMs,
76 reference: progress.reference, mode: progress.mode, taskId: progress.taskId, eventId: progress.eventId, evidence: progress.evidence,
77 }
78 expect(engine.step({ ...observation(1080), transition: reordered }).taskId).toBe('confirm-hover')
79 expect(engine.step({ ...observation(1100), transition: confirmed(1060, 1040) })).toMatchObject({ state: 'blocked', reason: 'vtol-transition-observation-replayed', transition: null })
80})
81
82test('a pending VTOL transition expires at its original task deadline and cannot be checkpointed or silently recovered', () => {
83 for (const action of ['pause', 'recover'] as const) {
84 const { engine, observation } = awaitingHover()
85 expect(() => checkpointVehicleMission(engine.trace(), 1020)).toThrow('not resumable')
86 expect(engine.step(observation(1040), action)).toMatchObject({ state: 'blocked', reason: 'vtol-transition-recovery-uncertain', targetM: null, transition: null })
87 expect(engine.step(observation(1060), 'resume').state).toBe('blocked')
88 }
89 const { engine, observation, confirmed } = awaitingHover()
90 for (let now = 1040; now < 2000; now += 20) expect(engine.step(observation(now)).taskId).toBe('confirm-hover')
91 expect(engine.step({ ...observation(2000), transition: confirmed(1980, 1960) })).toMatchObject({ state: 'blocked', reason: 'vtol-transition-outcome-uncertain', targetM: null, transition: null })
92})
93
94test('malformed VTOL outcome packets release the mission rather than supplying a partial confirmation', () => {
95 for (const mutate of [
96 (p: VehicleVtolTransitionObservation) => { Reflect.deleteProperty(p, 'measurementCapturedAtMs') },
97 (p: VehicleVtolTransitionObservation) => { Reflect.set(p, 'capturedAtMs', NaN) },
98 (p: VehicleVtolTransitionObservation) => { Object.assign(p, { extra: true }) },
99 (p: VehicleVtolTransitionObservation) => { Object.assign(p, { extra: () => true }) },
100 (p: VehicleVtolTransitionObservation) => { p.reason = 'not a bounded reason' },
101 ]) {
102 const { engine, observation, confirmed } = awaitingHover(), packet = confirmed(1060, 1040)
103 mutate(packet)
104 expect(() => engine.step({ ...observation(1060), transition: packet })).toThrow('Invalid vehicle mission observation')
105 expect(engine.snapshot()).toMatchObject({ state: 'blocked', targetM: null, transition: null })
106 }
107})
108
109test('old hover recordings keep their exact observation and snapshot keys and other vehicle plans reject VTOL tasks', () => {
110 const input = createVtolHoverProfile().mission, engine = createVehicleMissionEngine(input)
111 const before = engine.snapshot()
112 const old: VehicleMissionObservation = { kind: 'simulation', nowMs: 1000, reference: '55:0,0,0,0,0', origin: input.origin, healthy: true, positionM: [0, 0, 0], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 100, onGround: true, payloadAck: null }
113 engine.step(old, 'start')
114 expect(Object.keys(before).sort()).toEqual(['version', 'vehicle', 'evidence', 'physicalFlightEnabled', 'state', 'taskId', 'taskKind', 'targetM', 'payload', 'reason', 'elapsedMs'].sort())
115 expect(Object.keys(engine.snapshot()).sort()).toEqual(Object.keys(before).sort())
116 expect(Object.keys(engine.trace().rows[0]!.observation).sort()).toEqual(['kind', 'nowMs', 'reference', 'origin', 'healthy', 'positionM', 'velocityMps', 'authority', 'batteryPercent', 'onGround', 'payloadAck'].sort())
117 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(engine.snapshot())
118 for (const vehicle of ['fixed-wing', 'ground', 'surface', 'subsea'] as const) {
119 const base: VehicleMissionPlan = { ...input, vehicle, tasks: [{ id: 'start', kind: 'start', next: 'abort' }, { id: 'abort', kind: 'abort' }] }
120 expect(() => validateVehicleMission(base)).not.toThrow()
121 base.tasks = [{ id: 'start', kind: 'start', next: 'hover' }, { id: 'hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 1000, next: 'abort' }, { id: 'abort', kind: 'abort' }]
122 expect(() => validateVehicleMission(base)).toThrow('unsupported profile')
123 }
124})
125
126test('front and back requests retain distinct identities and require their observed destination phases in replay', () => {
127 const input: VehicleMissionPlan = { ...createVtolHoverProfile().mission, tasks: [
128 { id: 'start', kind: 'start', next: 'depart' },
129 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -5], acceptanceM: 0.2, maxArrivalSpeedMps: 0.2, settleMs: 0, timeoutMs: 2000, next: 'front' },
130 { id: 'front', kind: 'vtol-mode', mode: 'cruise', headingRad: 0, timeoutMs: 2000, next: 'leg' },
131 { id: 'leg', kind: 'waypoint', positionM: [20, 0, -5], acceptanceM: 1, maxArrivalSpeedMps: 15, settleMs: 0, timeoutMs: 2000, next: 'back' },
132 { id: 'back', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: 2000, next: 'land' },
133 { id: 'land', kind: 'land', positionM: [20, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 0, timeoutMs: 2000 },
134 ] }
135 const observation = (nowMs: number, positionM: [number, number, number] = [0, 0, -5], onGround = false, velocityMps: [number, number, number] = [0, 0, 0]): VehicleMissionObservation => ({ kind: 'simulation', nowMs, reference: '55:0,0,0,0,0', origin: input.origin, healthy: true, positionM, velocityMps, authority: 'autonomous', batteryPercent: 100, onGround, payloadAck: null, transition: null })
136 const outcome = (request: VehicleVtolTransitionCommand, now: number, phase: VehicleVtolTransitionObservation['phase'], status: VehicleVtolTransitionObservation['outcome']): VehicleVtolTransitionObservation => ({ evidence: 'modeled-vtol-controller-observation', eventId: request.eventId, taskId: request.taskId, mode: request.mode, reference: request.reference, capturedAtMs: now, measurementCapturedAtMs: now - 20, phase, outcome: status, reason: status === 'confirmed' ? 'mode-qualified' : 'mode-progress' })
137 const engine = createVehicleMissionEngine(input)
138 engine.step(observation(1000, [0, 0, 0], true), 'start'); engine.step(observation(1020))
139 const front = engine.step(observation(1040)).transition!
140 expect(front.mode).toBe('cruise')
141 expect(engine.step({ ...observation(1060), transition: outcome(front, 1060, 'front-transition', 'progress') }).taskId).toBe('front')
142 expect(engine.step({ ...observation(1080), transition: outcome(front, 1080, 'cruise', 'confirmed') }).taskId).toBe('leg')
143 engine.step(observation(1100, [20, 0, -5], false, [13, 0, 0]))
144 const back = engine.step(observation(1120, [20.2, 0, -5], false, [13, 0, 0])).transition!
145 expect(back.mode).toBe('hover'); expect(back.eventId).not.toBe(front.eventId)
146 const foreign = replayVehicleMission(engine.trace())
147 expect(foreign.step({ ...observation(1140), transition: outcome(front, 1140, 'cruise', 'confirmed') }).reason).toBe('vtol-transition-outcome-uncertain')
148 expect(engine.step({ ...observation(1140), transition: outcome(back, 1140, 'back-transition', 'progress') }).taskId).toBe('back')
149 expect(engine.step({ ...observation(1160), transition: outcome(back, 1160, 'hover', 'confirmed') }).taskId).toBe('land')
150 expect(engine.step(observation(1180, [20, 0, 0], true)).state).toBe('completed')
151 expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(engine.snapshot())
152})
153
154test('observed transition aborts and confirmations reused after consumption cannot advance or retry the mission', () => {
155 const aborted = awaitingHover()
156 expect(aborted.engine.step({ ...aborted.observation(1060), transition: { ...aborted.confirmed(1060, 1040), outcome: 'aborted', reason: 'transition-rejected' } })).toMatchObject({ state: 'blocked', reason: 'vtol-transition-aborted', transition: null, targetM: null })
157 expect(aborted.engine.step(aborted.observation(1080), 'resume').state).toBe('blocked')
158 const consumed = awaitingHover(), packet = consumed.confirmed(1060, 1040)
159 expect(consumed.engine.step({ ...consumed.observation(1060), transition: packet }).taskId).toBe('depart')
160 expect(consumed.engine.step({ ...consumed.observation(1080), transition: packet })).toMatchObject({ state: 'blocked', reason: 'unsolicited-vtol-transition', transition: null, targetM: null })
161 const operator = awaitingHover()
162 expect(operator.engine.step(operator.observation(1040), 'abort')).toMatchObject({ state: 'aborted', reason: 'operator-abort', transition: null, targetM: null })
163})