ReviewOS

also looking at this

stacks/drone

feat: run configured modeled cargo delivery and phase failures

#97
Merged chris wants to merge codex/cargo-delivery-runtime into main
38 files +2114 -30

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.

app/Commands/CargoDeliver.tsadded+23-0
Changes to app/Commands/CargoDeliver.ts
@@ -0,0 +1,23 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { cargoDeliveryScenarios, cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
4
5export default defineCommand({
6 name: 'cargo:deliver',
7 description: 'Simulate owned cargo release, modeled recipient evidence and a finite alternate landing',
8 options: {
9 '--scenario <name>': { description: cargoDeliveryScenarios.join(', '), default: 'nominal' },
10 },
11 async handle(options) {
12 try {
13 const summary = cargoDeliverySummary(simulateCargoDelivery({ scenario: options.scenario }))
14 if (!summary.demonstrationMatched) process.exitCode = 1
15 await printAsync(JSON.stringify(summary))
16 }
17 catch {
18 process.exitCode = 1
19 try { await printErrorAsync('Cargo delivery simulation failed: invalid input, unavailable result or output failure') }
20 catch { /* Closed stderr cannot turn a failed output into success. */ }
21 }
22 },
23})
bin/drone.tsmodified+2-0
Changes to bin/drone.ts
@@ -11,6 +11,7 @@ import DriversBench from '../app/Commands/DriversBench'
1111import AuthoritySimulate from '../app/Commands/AuthoritySimulate'
1212import ControlsSimulate from '../app/Commands/ControlsSimulate'
1313import CargoLoadBench from '../app/Commands/CargoLoadBench'
14import CargoDeliver from '../app/Commands/CargoDeliver'
1415
1516import PayloadsBench from '../app/Commands/PayloadsBench'
1617import PayloadsCapture from '../app/Commands/PayloadsCapture'
@@ -92,6 +93,7 @@ SensorsInspect(app)
9293EstimatorReplay(app)
9394ControlsSimulate(app)
9495CargoLoadBench(app)
96CargoDeliver(app)
9597AuthoritySimulate(app)
9698DriversBench(app)
9799PayloadsBench(app)
resources/functions/drone/authority/core.tsmodified+5-4
Changes to resources/functions/drone/authority/core.ts
@@ -18,14 +18,14 @@ export function createAuthorityKernel<Output, Frame>(options: { sessionId: bigin
1818 let lastNow: number | null = null, lastGatedAt: number | null = null, leaseUntilMs: number | null = null, target: PositionTarget | null = null, reference: string | null = null
1919 let reason = 'startup', commandAccepted = false
2020 const events: { atMs: number | null, from: AuthorityState, to: AuthorityState, reason: string, epoch: number }[] = []
21 function transition(to: AuthorityState, why: string) {
21 function transition(to: AuthorityState, why: string, eventAtMs: number | null = lastNow) {
2222 reason = why
2323 if (to === state) return
2424 const from = state
2525 state = to
2626 epoch = Math.min(0xFFFFFFFF, epoch + 1)
2727 leaseUntilMs = null; target = null
28 events.push({ atMs: lastNow, from, to, reason: why, epoch })
28 events.push({ atMs: eventAtMs, from, to, reason: why, epoch })
2929 if (events.length > 64) events.shift()
3030 }
3131 function fault(why: string) { if (state !== 'killed') transition('failsafe', why) }
@@ -54,9 +54,10 @@ export function createAuthorityKernel<Output, Frame>(options: { sessionId: bigin
5454 update(o: AuthorityObservation, command?: AuthorityCommand) {
5555 commandAccepted = false
5656 const observedEpoch = epoch
57 const validNow = Number.isFinite(o.nowMs) && o.nowMs >= 0 && (lastNow === null || o.nowMs >= lastNow)
5758 // A modeled independent kill input always wins, including over malformed clocks.
58 if (o.interlocks.killActive === true) transition('killed', 'kill-input')
59 if (!Number.isFinite(o.nowMs) || o.nowMs < 0 || (lastNow !== null && o.nowMs < lastNow)) { if (active(state)) fault('clock-discontinuity'); reason = state === 'killed' ? reason : 'clock-discontinuity'; return snapshot() }
59 if (o.interlocks.killActive === true) transition('killed', 'kill-input', validNow ? o.nowMs : lastNow)
60 if (!validNow) { if (active(state)) fault('clock-discontinuity'); reason = state === 'killed' ? reason : 'clock-discontinuity'; return snapshot() }
6061 const gap = lastNow !== null && o.nowMs - lastNow > authorityPolicy.schedulerGapMs
6162 lastNow = o.nowMs
6263 if (active(state) && gap) fault('supervisor-schedule-gap')
resources/functions/drone/authority/vehicle.tsmodified+2-1
Changes to resources/functions/drone/authority/vehicle.ts
@@ -1,4 +1,5 @@
11import type { VehicleActuator, VehicleControlOutput, VehicleKind } from '../vehicles/types'
2import { vehicleKinds } from '../vehicles/types'
23import type { AuthorityFrameMetadata } from './core'
34import type { AuthorityCommand, AuthorityObservation } from './supervisor'
45import { createAuthorityKernel } from './core'
@@ -12,7 +13,7 @@ export function validateVehicleActuators(input: VehicleActuator[]): VehicleActua
1213export function validVehicleActuatorValues(input: unknown, declaration: VehicleActuator[]): input is number[] {
1314 return Array.isArray(input) && input.length === declaration.length && input.every((n, i) => typeof n === 'number' && Number.isFinite(n) && n >= declaration[i]!.min && n <= declaration[i]!.max)
1415}
15function vehicleKind(kind: VehicleKind) { if (!['fixed-wing', 'vtol', 'ground', 'surface', 'subsea'].includes(kind)) throw new TypeError('Unsupported simulated vehicle') }
16function vehicleKind(kind: VehicleKind) { if (!vehicleKinds.includes(kind)) throw new TypeError('Unsupported simulated vehicle') }
1617/** Profile-specific actuator adapter over the same arming, lease and failover lifecycle as quad-X. */
1718export function createVehicleAuthoritySupervisor(options: { bootId: bigint, sessionId: bigint, vehicle: VehicleKind, actuators: VehicleActuator[], fence: { radiusM: number, minDownM: number, maxDownM: number } }) {
1819 vehicleKind(options.vehicle)
resources/functions/drone/cargo/delivery.tsadded+264-0
Changes to resources/functions/drone/cargo/delivery.ts
@@ -0,0 +1,264 @@
1import type { SensorVector } from '../sensors/types'
2import type { CargoBodySeparation, CargoLoadSnapshot } from './types'
3import type { VehicleControllerContext, VehicleMissionPlan, VehiclePayloadAck, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types'
4import { createRigidBody } from '../controls/physics'
5import { multirotorModel } from '../controls/model'
6import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile'
7import { runVehicleSimulation } from '../vehicles/runtime'
8import { vehicleReference } from '../vehicles/measurements'
9import { createRedundantVehicleLinks } from '../vehicles/links'
10
11const flightFaults = ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss'] as const
12export const cargoDeliveryScenarios = ['nominal', 'recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt', 'primary-link-loss', 'both-links-loss', 'authority-containment', 'diversion-position-loss', 'diversion-kill', ...flightFaults] as const
13type CargoDeliveryScenario = typeof cargoDeliveryScenarios[number]
14interface Parcel { massKg: number, positionM: SensorVector, velocityMps: SensorVector, onSurface: boolean, capturedAtMs: number }
15interface Delivery {
16 outcome: 'not-released' | 'in-transit' | 'received' | 'uncertain'
17 containerId: string
18 siteId: string
19 recipientId: string
20 requestedAtMs: number | null
21 mechanismCapturedAtMs: number | null
22 receiptCapturedAtMs: number | null
23 acknowledgedAtMs: number | null
24 reason: string | null
25 mechanismOutcome: 'not-requested' | 'requested' | 'open' | 'jammed'
26}
27type DeliveryRelease = CargoBodySeparation & { atMs: number, eventId: string }
28type DeliveryLinkSnapshot = ReturnType<ReturnType<typeof createRedundantVehicleLinks>['snapshot']>
29export interface CargoDeliveryResult {
30 version: 1
31 evidence: 'owned-cargo-delivery-simulation'
32 scenario: CargoDeliveryScenario
33 status: ReturnType<typeof runVehicleSimulation>['status'] | 'diverted'
34 physicalFlightEnabled: false
35 boardOutputsInhibited: true
36 identityAuthenticated: false
37 delivery: Delivery
38 initialLoad: CargoLoadSnapshot
39 finalLoad: CargoLoadSnapshot
40 release: DeliveryRelease | null
41 parcel: Parcel | null
42 links: DeliveryLinkSnapshot
43 linkEvents: DeliveryLinkSnapshot[]
44 runtime: ReturnType<typeof runVehicleSimulation>
45}
46const site = Object.freeze({ id: 'fixture-receiver-site', recipientId: 'fixture-recipient', containerId: 'parcel-100g', northM: 3, eastM: 0, downM: 0, radiusM: .35 })
47function inputScenario(input: unknown): CargoDeliveryScenario {
48 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario')) throw new TypeError('cargo-delivery-input')
49 const d = Object.getOwnPropertyDescriptor(input, 'scenario')
50 if (d === undefined) return 'nominal'
51 if (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoDeliveryScenarios.some(value => value === d.value)) throw new TypeError('cargo-delivery-input')
52 return d.value
53}
54function deliveryMission(): VehicleMissionPlan {
55 const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 }
56 return { version: 2, vehicle: 'quadcopter', id: 'cargo-shared-delivery', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE',
57 origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -1],
58 power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [
59 { id: 'start', kind: 'start', next: 'depart' },
60 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -1], ...spatial, next: 'site' },
61 { id: 'site', kind: 'waypoint', positionM: [site.northM, site.eastM, -1], ...spatial, settleMs: 800, next: 'release' },
62 { id: 'release', kind: 'payload', operation: 'material-drop', containerId: site.containerId, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' },
63 { id: 'home', kind: 'return', ...spatial, next: 'land' },
64 { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial },
65 { id: 'alternate-approach', kind: 'waypoint', positionM: [1.5, -2, -1], ...spatial, next: 'alternate-land' },
66 { id: 'alternate-land', kind: 'land', positionM: [1.5, -2, 0], ...spatial },
67 ] }
68}
69
70/** Fictional local delivery with real modeled mass change and received, delayed evidence. No physical transport. */
71export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult {
72 const scenario = inputScenario(input), flightScenario = flightFaults.find(value => value === scenario) ?? 'nominal'
73 const profile = createMultirotorProfile(flightScenario), baseSession = profile.createSession, baseFaults = profile.faults
74 profile.mission = deliveryMission()
75 profile.scenario = scenario
76 profile.scenarios = [...cargoDeliveryScenarios]
77 profile.name = 'Owned modeled cargo delivery'
78 if (scenario === 'authority-containment') profile.authorityFence = { ...profile.authorityFence, radiusM: 2 }
79 profile.faults = (selected, atMs, phase) => {
80 if (selected !== scenario) throw new TypeError('cargo-delivery-scenario-changed')
81 const base = baseFaults(flightScenario, atMs, phase), failureAtMs = observed?.mission.payloadFailure?.triggeredAtMs
82 const diversionFault = failureAtMs !== undefined && atMs >= failureAtMs + 200
83 return { ...base, dropPosition: base.dropPosition || scenario === 'diversion-position-loss' && diversionFault, kill: base.kill || scenario === 'diversion-kill' && diversionFault }
84 }
85 const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, massKg: .1, forwardM: .08 } })
86 const initialLoad = body.loadSnapshot(), adapted = createMultirotorPlant(body)
87 let physicalAtMs = 600, parcel: Parcel | null = null, release: DeliveryRelease | null = null
88 let pending: VehiclePayloadCommand | null = null, armed: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null
89 let closed = false, requested = false, lastPollMs = -1, lastSequence = -1, readyCount = 0, readyStartMs: number | null = null, readyReference: string | null = null
90 let refused: string | null = null, receiptAtMs: number | null = null
91 let mechanismAttempt: { eventId: string, atMs: number, jammed: boolean } | null = null
92 const delivery: Delivery = { outcome: 'not-released', containerId: site.containerId, siteId: site.id, recipientId: site.recipientId, requestedAtMs: null, mechanismCapturedAtMs: null, receiptCapturedAtMs: null, acknowledgedAtMs: null, reason: null, mechanismOutcome: 'not-requested' }
93 const linkScope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, linkMonitor = createRedundantVehicleLinks(linkScope)
94 const linkQueue: { sourceId: 'primary' | 'backup', sequence: number, capturedAtMs: number, receivedAtMs: number }[] = [], linkEvents: DeliveryLinkSnapshot[] = []
95 let links = linkMonitor.snapshot(0), linkSequence = 0
96 function snapshot() {
97 const state = adapted.snapshot(), load = body.loadSnapshot()
98 return { ...state, details: { ...state.details, loadAttached: load.properties.phase === 'attached', loadMassKg: load.properties.totalMassKg } }
99 }
100 const plant: VehiclePlant = { snapshot, step(actuators, dtS, environment) {
101 if (dtS !== .004) throw new RangeError('cargo-delivery-physical-step')
102 adapted.step(actuators, dtS, environment)
103 physicalAtMs += 4
104 if (parcel && !parcel.onSurface) {
105 parcel.velocityMps[2] += multirotorModel.gravityMps2 * dtS
106 parcel.positionM = parcel.positionM.map((n, i) => n + parcel!.velocityMps[i]! * dtS) as SensorVector
107 parcel.capturedAtMs = physicalAtMs
108 if (parcel.positionM[2] >= site.downM) {
109 parcel.positionM[2] = site.downM; parcel.velocityMps = [0, 0, 0]; parcel.onSurface = true
110 if (Math.hypot(parcel.positionM[0] - site.northM, parcel.positionM[1] - site.eastM) <= site.radiusM) receiptAtMs = physicalAtMs
111 }
112 }
113 if (armed && mechanismAttempt === null && physicalAtMs > armed.issuedAtMs) {
114 if (physicalAtMs >= armed.expiresAtMs) { delivery.outcome = 'uncertain'; delivery.reason = 'mechanism-command-expired'; armed = null }
115 else {
116 mechanismAttempt = { eventId: armed.eventId, atMs: physicalAtMs, jammed: scenario === 'mechanism-jam' }
117 if (mechanismAttempt.jammed) { armed = null; return snapshot() }
118 const event = body.separateCarriedLoad()
119 release = { ...event, atMs: physicalAtMs, eventId: armed.eventId }; armed = null
120 if (event.outcome === 'separated') { parcel = { ...event.parcel, onSurface: false, capturedAtMs: physicalAtMs }; delivery.outcome = 'in-transit' }
121 else { refused = event.reason; delivery.reason = event.reason }
122 }
123 }
124 return snapshot()
125 } }
126 function ready(context: VehicleControllerContext) {
127 const m = context.measurement, p = context.estimate.localPosition, q = context.estimate.attitude?.quaternionWxyz, rate = context.controlRate
128 if (!context.enabled || context.estimate.evidence !== 'fixture' || !context.estimate.health.localPosition || !context.estimate.health.tilt || !m || !p || !q || !rate
129 || m.reference !== vehicleReference(context.estimate) || m.capturedAtMs > context.nowMs || context.nowMs - m.capturedAtMs > 80 || m.receivedAtMs > context.nowMs
130 || m.values.siteId !== site.id || m.values.recipientId !== site.recipientId || m.values.siteClear !== true || m.values.recipientPresent !== true || m.values.loadAttached !== true) return false
131 const speed = Math.hypot(...p.velocityMps), sigma = Math.sqrt(Math.max(...p.covarianceBlocks.map(block => block.positionM2)))
132 const horizontal = Math.hypot(p.positionM[0]! - site.northM, p.positionM[1]! - site.eastM)
133 return speed <= .18 && horizontal + 3 * sigma + speed * .08 <= site.radiusM && p.positionM[2]! >= -1.2 && p.positionM[2]! <= -.8
134 && Math.acos(Math.max(-1, Math.min(1, 1 - 2 * (q[1] ** 2 + q[2] ** 2)))) <= .2 && Math.hypot(...rate.angularRateRadS) <= .5
135 }
136 profile.createSession = (): VehicleSession => {
137 const base = baseSession()
138 return { ...base, createPlant: () => plant,
139 linkObservation(nowMs) {
140 linkSequence++
141 for (const sourceId of ['primary', 'backup'] as const) {
142 if (nowMs >= 5000 && (scenario === 'both-links-loss' || sourceId === 'primary' && scenario === 'primary-link-loss')) continue
143 linkQueue.push({ sourceId, sequence: linkSequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) })
144 }
145 linkQueue.sort((a, b) => a.receivedAtMs - b.receivedAtMs)
146 while (linkQueue[0] && linkQueue[0].receivedAtMs <= nowMs) {
147 const packet = linkQueue.shift()!
148 linkMonitor.ingest({ ...linkScope, ...packet, clockUncertaintyMs: 1, connected: true }, nowMs)
149 }
150 links = linkMonitor.snapshot(nowMs)
151 const previous = linkEvents.at(-1)
152 if (!previous || links.selectedSource !== previous.selectedSource || links.latchedLoss !== previous.latchedLoss) {
153 if (linkEvents.length >= 16) throw new RangeError('cargo-link-events-exhausted')
154 linkEvents.push(structuredClone(links))
155 }
156 return { ...links.authority }
157 },
158 observeIndependent(state, atMs) {
159 if (atMs !== physicalAtMs) throw new RangeError('cargo-delivery-world-epoch')
160 if (state.details.loadAttached !== (body.loadSnapshot().properties.phase === 'attached')) throw new TypeError('cargo-delivery-world-load')
161 },
162 renderMeasurement(state, atMs, reference) {
163 const m = base.renderMeasurement(state, atMs, reference)
164 const receiptCaptured = receiptAtMs !== null && atMs >= receiptAtMs + 100
165 return { ...m, values: { ...m.values, loadAttached: state.details.loadAttached === true, loadMassKg: Number(state.details.loadMassKg), siteId: site.id, recipientId: receiptCaptured && scenario === 'foreign-receipt' ? 'fixture-other-recipient' : site.recipientId,
166 siteClear: scenario !== 'site-blocked', recipientPresent: scenario !== 'recipient-absent' && !scenario.startsWith('diversion-'), mechanismOpen: release?.outcome === 'separated', mechanismJammed: mechanismAttempt?.jammed ?? false, mechanismEventId: mechanismAttempt?.eventId ?? '',
167 receiptEventId: receiptCaptured && scenario !== 'receipt-loss' ? release?.eventId ?? '' : '', receiptContainerId: site.containerId } }
168 },
169 createPayload: () => ({
170 observe(context) {
171 observed = structuredClone(context)
172 if (pending && context.mission.payloadFailure?.eventId === pending.eventId) {
173 pending = null; armed = null
174 delivery.outcome = delivery.mechanismOutcome === 'open' ? 'uncertain' : 'not-released'; delivery.reason ??= 'receipt-not-confirmed'
175 }
176 const sequence = context.measurement?.sequence ?? -1, reference = context.measurement?.reference ?? null
177 if (sequence === lastSequence && reference === readyReference) return
178 const qualified = ready(context) && (readyReference === null || reference === readyReference)
179 if (qualified && (lastSequence < 0 || sequence > lastSequence)) { readyCount++; readyStartMs ??= context.nowMs }
180 else { readyCount = 0; readyStartMs = null }
181 lastSequence = sequence; readyReference = reference
182 },
183 request(command) {
184 if (closed || requested || pending !== null) throw new TypeError('cargo-delivery-command-replayed')
185 requested = true; pending = structuredClone(command); delivery.requestedAtMs = command.issuedAtMs; delivery.mechanismOutcome = 'requested'
186 const c = observed, scoped = c?.mission.payload
187 if (!c || !scoped || c.mission.state !== 'running' || c.mission.taskKind !== 'payload' || command.operation !== 'material-drop' || command.containerId !== site.containerId
188 || JSON.stringify(scoped) !== JSON.stringify(command) || command.reference !== vehicleReference(c.estimate) || command.issuedAtMs !== c.nowMs || command.expiresAtMs <= c.nowMs
189 || !ready(c) || readyCount < 26 || readyStartMs === null || c.nowMs - readyStartMs < 500) { refused = 'release-evidence-unqualified'; delivery.reason = refused }
190 else armed = structuredClone(command)
191 },
192 poll(nowMs): VehiclePayloadAck | null {
193 if (!Number.isSafeInteger(nowMs) || nowMs < lastPollMs) throw new RangeError('cargo-delivery-clock')
194 lastPollMs = nowMs
195 const c = observed, m = c?.measurement, pose = c?.measurementPose
196 if (!pending || closed || !c?.enabled || c.nowMs !== nowMs || !m || m.reference !== pending.reference || m.capturedAtMs < pending.issuedAtMs || m.capturedAtMs >= pending.expiresAtMs
197 || nowMs >= pending.expiresAtMs || nowMs - m.capturedAtMs > 80 || m.receivedAtMs > nowMs || !pose || pose.reference !== m.reference || pose.capturedAtMs !== m.capturedAtMs || pose.positionPredictedToMs !== m.capturedAtMs) return null
198 if (m.values.mechanismEventId === pending.eventId && m.values.mechanismJammed === true && m.values.loadAttached === true) {
199 delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'jammed'; refused = 'mechanism-jammed'; delivery.reason = refused
200 }
201 if (m.values.mechanismEventId === pending.eventId && m.values.mechanismOpen === true && m.values.loadAttached === false) { delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'open' }
202 const received = delivery.mechanismCapturedAtMs !== null && m.values.receiptEventId === pending.eventId && m.values.receiptContainerId === pending.containerId
203 && m.values.recipientId === site.recipientId && m.values.siteId === site.id && m.values.recipientPresent === true && m.values.siteClear === true
204 if (m.values.receiptEventId !== '' && !received) { refused = 'receipt-scope-mismatch'; delivery.reason = refused; delivery.outcome = 'uncertain' }
205 if (!refused && !received) return null
206 const command = pending; pending = null; delivery.acknowledgedAtMs = nowMs
207 if (received) { delivery.receiptCapturedAtMs = m.capturedAtMs; delivery.outcome = 'received' }
208 return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference, capturedAtMs: m.capturedAtMs,
209 positionM: [...pose.positionM], outcome: refused ? delivery.mechanismOutcome === 'open' ? 'uncertain' : 'rejected' : 'confirmed',
210 metadata: { modeledOnly: true, expectedSiteId: site.id, expectedRecipientId: site.recipientId, observedSiteId: typeof m.values.siteId === 'string' ? m.values.siteId : 'unqualified', observedRecipientId: typeof m.values.recipientId === 'string' ? m.values.recipientId : 'unqualified' } }
211 },
212 close() { closed = true; armed = null; observed = null },
213 }),
214 }
215 }
216 const runtime = runVehicleSimulation(profile, { seconds: 100 })
217 if (delivery.outcome === 'in-transit') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' }
218 return structuredClone({ version: 1, evidence: 'owned-cargo-delivery-simulation' as const, scenario, status: runtime.status === 'completed' && runtime.mission.payloadFailure ? 'diverted' as const : runtime.status, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const,
219 delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime })
220}
221
222/** A bounded command projection. Matches describe these model cases, never physical delivery. */
223export function cargoDeliverySummary(run: CargoDeliveryResult) {
224 const rt = run.runtime, power = rt.trace.plan.power, measurements = rt.path.flatMap(row => row.measurement ? [row.measurement] : [])
225 const landingContact = rt.mission.state === 'completed' && rt.trace.rows.at(-1)?.observation.onGround === true
226 const neutral = rt.board.actuators.length === 4 && rt.board.actuators.every(n => n === 0) && rt.board.expiresAtMs === null
227 && (rt.authority.state === 'disarmed' || rt.authority.state === 'killed' || rt.authority.state === 'failsafe' || rt.status === 'host-lost' && rt.board.fault === 'output-lease-expired')
228 const separationSeen = run.release?.outcome === 'separated' && run.finalLoad.properties.phase === 'separated' && measurements.some(m => m.values.loadAttached === false && m.values.loadMassKg === 1.5)
229 const receiptEvent = run.release?.eventId
230 let worldMatched = false
231 if (run.scenario === 'nominal' || run.scenario === 'primary-link-loss') worldMatched = separationSeen && run.delivery.outcome === 'received' && run.status === 'completed'
232 && (run.scenario !== 'primary-link-loss' || run.linkEvents.some(event => event.selectedSource === 'backup') && run.links.selectedSource === 'backup' && !run.links.latchedLoss)
233 else if (run.scenario === 'recipient-absent') worldMatched = run.release === null && measurements.some(m => m.values.recipientPresent === false) && run.delivery.outcome === 'not-released'
234 else if (run.scenario === 'site-blocked') worldMatched = run.release === null && measurements.some(m => m.values.siteClear === false) && run.delivery.outcome === 'not-released'
235 else if (run.scenario === 'mechanism-jam') worldMatched = run.release === null && measurements.some(m => m.values.mechanismJammed === true) && run.delivery.mechanismOutcome === 'jammed'
236 else if (run.scenario === 'receipt-loss') worldMatched = separationSeen && run.parcel?.onSurface === true && Math.hypot(run.parcel.positionM[0] - site.northM, run.parcel.positionM[1] - site.eastM) <= site.radiusM
237 && measurements.every(m => m.values.receiptEventId === '') && run.delivery.outcome === 'uncertain'
238 else if (run.scenario === 'foreign-receipt') worldMatched = separationSeen && measurements.some(m => m.values.recipientId === 'fixture-other-recipient' && m.values.receiptEventId === receiptEvent) && run.delivery.outcome === 'uncertain'
239 else if (run.scenario === 'both-links-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && run.links.latchedLoss && !run.links.authority.connected && rt.authority.events.some(event => event.reason === 'link-loss')
240 else if (run.scenario === 'position-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason))
241 else if (run.scenario === 'imu-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'stale-interlocks') && rt.mission.reason === 'estimator-reference-changed'
242 else if (run.scenario === 'vehicle-sensor-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'stale-interlocks')
243 else if (run.scenario === 'rc-override') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.to === 'manual' && event.reason === 'rc-override')
244 else if (run.scenario === 'kill') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.state === 'killed'
245 else if (run.scenario === 'host-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.lastCheckedAtMs === 4980 && rt.board.fault === 'output-lease-expired'
246 else if (run.scenario === 'authority-containment') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'invalid-target-or-lease') && rt.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 2)
247 else if (run.scenario.startsWith('diversion-')) worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.mission.payloadFailure?.reason === 'rejected'
248 && rt.authority.events.some(event => event.atMs !== null && event.atMs >= rt.mission.payloadFailure!.triggeredAtMs + 200 && (run.scenario === 'diversion-kill' ? event.to === 'killed' : ['estimator-loss', 'stale-estimator'].includes(event.reason)))
249 const expectedStatus = run.scenario === 'nominal' || run.scenario === 'primary-link-loss' ? 'completed' : run.scenario === 'host-loss' ? 'host-lost' : run.scenario === 'both-links-loss' || run.scenario === 'authority-containment' || run.scenario.startsWith('diversion-') || flightFaults.some(value => value === run.scenario) ? 'blocked' : 'diverted'
250 const summary = structuredClone({ version: 1, evidence: run.evidence, scenario: run.scenario, status: run.status, demonstrationMatched: worldMatched && run.status === expectedStatus && (!['completed', 'diverted'].includes(expectedStatus) || landingContact) && neutral,
251 physicalFlightEnabled: false, boardOutputsInhibited: true, identityAuthenticated: false, delivery: run.delivery, landingContact,
252 load: { initial: run.initialLoad.properties, final: run.finalLoad.properties },
253 release: run.release ? { atMs: run.release.atMs, eventId: run.release.eventId, outcome: run.release.outcome, reason: run.release.reason } : null,
254 parcel: run.parcel, links: run.links, linkEvents: run.linkEvents,
255 mission: { state: rt.mission.state, taskId: rt.mission.taskId, reason: rt.mission.reason, payloadFailure: rt.mission.payloadFailure ?? null, traceRows: rt.trace.rows.length },
256 energy: { evidence: 'declared-mission-power-budget', measured: false, electricalModel: false, capacityWh: power.availableWh, maximumDeclaredW: power.maxW, reservePercent: power.reservePercent,
257 worstCaseMs: rt.budget.worstCaseMs, worstCaseRequiredWh: rt.budget.requiredWh, marginAboveReserveWh: power.availableWh * (1 - power.reservePercent / 100) - rt.budget.requiredWh },
258 board: { actuators: [...rt.board.actuators], expiresAtMs: rt.board.expiresAtMs, fault: rt.board.fault, physicalActuationEnabled: rt.board.physicalActuationEnabled },
259 authority: { hostAvailable: rt.status !== 'host-lost', state: rt.authority.state, reason: rt.authority.reason, lastCheckedAtMs: rt.authority.lastCheckedAtMs, leaseUntilMs: rt.authority.leaseUntilMs },
260 model: { loadMassKg: .1, loadForwardM: .08, separation: 'zero-ejection-point-mass', parcel: 'ballistic-point-with-plane-capture', receiver: 'fictional-100ms-receipt', measurementDelayMs: 40, headingAlignment: 'initial-simulation-north' },
261 })
262 if (new TextEncoder().encode(JSON.stringify(summary)).byteLength + 1 > 16384) throw new RangeError('cargo-delivery-summary-unavailable')
263 return summary
264}
resources/functions/drone/missions/vehicle.tsmodified+31-10
Changes to resources/functions/drone/missions/vehicle.ts
@@ -1,5 +1,6 @@
11import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types'
22import type { MissionState } from './engine'
3import { vehicleKinds } from '../vehicles/types'
34import { authorityStates } from '../authority/supervisor'
45import { validVector } from '../controls/model'
56import { missionGraphBudget } from './graph'
@@ -15,7 +16,7 @@ export function vehicleMissionContains(plan: VehicleMissionPlan, p: unknown) {
1516export function vehicleTaskTarget(plan: VehicleMissionPlan, task: VehicleMissionTask) { return task.kind === 'return' ? [...plan.rallyM] : 'positionM' in task ? [...task.positionM] : null }
1617export function validateVehicleMission(input: unknown): { plan: VehicleMissionPlan, worstCaseMs: number, requiredWh: number } {
1718 function invalid(): never { throw new TypeError('Invalid vehicle mission or unsupported profile') }
18 if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !['fixed-wing', 'vtol', 'ground', 'surface', 'subsea'].includes(input.vehicle as string) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid()
19 if (!object(input) || !keys(input, ['version', 'vehicle', 'id', 'frame', 'altitude', 'terrain', 'origin', 'fence', 'rallyM', 'power', 'start', 'tasks']) || input.version !== 2 || !vehicleKinds.some(kind => kind === input.vehicle) || !id(input.id) || input.frame !== 'LOCAL_NED' || input.altitude !== 'ORIGIN_RELATIVE_DOWN' || input.terrain !== 'DECLARED_MODEL_SURFACE' || !id(input.start)) invalid()
1920 const p = input as unknown as VehicleMissionPlan
2021 if (!object(p.origin) || !keys(p.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) || !number(p.origin.latitudeDeg, -90, 90) || !number(p.origin.longitudeDeg, -180, 180) || !number(p.origin.ellipsoidHeightM, -500, 10000)
2122 || !object(p.fence) || !keys(p.fence, ['radiusM', 'minDownM', 'maxDownM']) || !number(p.fence.radiusM, 1, 5000) || !number(p.fence.minDownM, -1000, 0) || !number(p.fence.maxDownM, 0, 10) || p.fence.minDownM >= p.fence.maxDownM || !vehicleMissionContains(p, p.rallyM)
@@ -38,17 +39,19 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
3839 edges = [t.then, t.otherwise]
3940 }
4041 else if (t.kind === 'payload') {
41 fields.push('operation', 'containerId', 'timeoutMs', 'next')
42 const supported = p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture']
42 fields.push('operation', 'containerId', 'timeoutMs', 'next', 'onFailure')
43 const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture']
4344 if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, 10000) || !id(t.next)) invalid()
44 durationMs = t.timeoutMs; edges = [t.next]
45 if (p.vehicle === 'quadcopter' && t.operation === 'material-drop' && !id(t.containerId)) invalid()
46 if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || t.operation !== 'material-drop' || !id(t.onFailure))) invalid()
47 durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure]
4548 }
4649 else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) {
4750 fields = [...fields, 'acceptanceM', 'maxArrivalSpeedMps', 'settleMs', 'timeoutMs', 'next']
4851 if (t.kind !== 'return') { fields.push('positionM'); if (!vehicleMissionContains(p, (t as { positionM?: unknown }).positionM)) invalid() }
4952 if (!('acceptanceM' in t) || !number(t.acceptanceM, 0.05, 150) || !number(t.maxArrivalSpeedMps, 0.05, 50) || !number(t.settleMs, 0, 10000) || !number(t.timeoutMs, 1000, 60000) || t.settleMs >= t.timeoutMs) invalid()
50 if ((t.kind === 'takeoff' || t.kind === 'land') && p.vehicle !== 'fixed-wing' && p.vehicle !== 'vtol') invalid()
51 if (t.kind === 'stop' && (p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' || t.maxArrivalSpeedMps > 0.5)) invalid()
53 if ((t.kind === 'takeoff' || t.kind === 'land') && !['quadcopter', 'fixed-wing', 'vtol'].includes(p.vehicle)) invalid()
54 if (t.kind === 'stop' && (['quadcopter', 'fixed-wing', 'vtol'].includes(p.vehicle) || t.maxArrivalSpeedMps > 0.5)) invalid()
5255 if (t.kind === 'land' && t.maxArrivalSpeedMps > 2) invalid()
5356 const terminal = t.kind === 'land' || t.kind === 'stop'
5457 if (!terminal && !id(t.next)) invalid()
@@ -60,6 +63,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl
6063 nodes.push({ id: t.id, durationMs, edges })
6164 }
6265 for (const task of p.tasks) {
66 if (task.kind === 'payload' && task.onFailure !== undefined) {
67 const approach = p.tasks.find(candidate => candidate.id === task.onFailure)
68 if (!approach || approach.kind !== 'waypoint' || !approach.next || approach.positionM[2] >= 0) invalid()
69 const land = p.tasks.find(candidate => candidate.id === approach.next)
70 if (!land || land.kind !== 'land' || land.next !== undefined || land.positionM[2] !== 0 || land.positionM[0] !== approach.positionM[0] || land.positionM[1] !== approach.positionM[1]) invalid()
71 if (p.tasks.some(candidate => 'next' in candidate && candidate.next === approach.id || candidate.kind === 'branch' && [candidate.then, candidate.otherwise].includes(approach.id))) invalid()
72 }
6373 if (task.kind !== 'vtol-mode' || task.onFailure === undefined) continue
6474 const hover = p.tasks.find(candidate => candidate.id === task.onFailure)
6575 if (!hover || hover.kind !== 'vtol-mode' || hover.mode !== 'hover' || hover.onFailure !== undefined) invalid()
@@ -98,9 +108,10 @@ export function createVehicleMissionEngine(input: unknown) {
98108 let targetM: VehicleMissionSnapshot['targetM'] = null, holdM: VehicleMissionSnapshot['targetM'] = null, pending: VehiclePayloadCommand | null = null
99109 let transition: VehicleVtolTransitionCommand | null = null
100110 let recovery: VehicleMissionSnapshot['recovery']
111 let payloadFailure: VehicleMissionSnapshot['payloadFailure']
101112 let transitionObservation: { capturedAtMs: number, measurementCapturedAtMs: number, signature: string } | null = null
102113 const rows: VehicleMissionTrace['rows'] = []
103 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 } : {}), ...(recovery ? { recovery: { ...recovery } } : {}) } }
114 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 } : {}), ...(recovery ? { recovery: { ...recovery } } : {}), ...(payloadFailure ? { payloadFailure: { ...payloadFailure } } : {}) } }
104115 function block(why: string) { state = 'blocked'; reason = why; targetM = null; pending = null; transition = null; transitionObservation = null }
105116 function advance(next: string) {
106117 taskId = next; taskElapsedMs = 0; settledMs = 0; pending = null; transition = null; transitionObservation = null; reason = 'task-entered'
@@ -133,6 +144,14 @@ export function createVehicleMissionEngine(input: unknown) {
133144 targetM = [...transition.positionM]; reason = 'vtol-transition-recovery-requested'
134145 return true
135146 }
147 function divertPayload(task: Extract<VehicleMissionTask, { kind: 'payload' }>, o: VehicleMissionObservation, cause: NonNullable<VehicleMissionSnapshot['payloadFailure']>['reason']) {
148 if (!task.onFailure || payloadFailure || !pending || !o.healthy || !o.positionM || !o.velocityMps || pending.reference !== o.reference) return false
149 const approach = tasks.get(task.onFailure)!
150 if (approach.kind !== 'waypoint' || !approach.next) return false
151 payloadFailure = { eventId: pending.eventId, taskId: task.id, operation: task.operation, containerId: task.containerId, reason: cause, triggeredAtMs: o.nowMs, approachTaskId: approach.id, landingTaskId: approach.next }
152 advance(approach.id); reason = 'payload-failure-diversion'
153 return true
154 }
136155 return {
137156 snapshot,
138157 trace(): VehicleMissionTrace { return structuredClone({ version: 2, evidence: 'owned-vehicle-mission-simulation', plan, rows }) },
@@ -189,7 +208,8 @@ export function createVehicleMissionEngine(input: unknown) {
189208 const t = tasks.get(taskId)!, executionDt = previousState === 'ready' || previousState === 'paused' ? 0 : dt
190209 taskElapsedMs += executionDt
191210 if ('timeoutMs' in t && taskElapsedMs >= t.timeoutMs) {
192 if (t.kind === 'vtol-mode' && transition && t.onFailure) {
211 if (t.kind === 'payload' && t.onFailure) { if (!divertPayload(t, o, 'timeout')) block('payload-outcome-uncertain') }
212 else if (t.kind === 'vtol-mode' && transition && t.onFailure) {
193213 readTransitionObservation(o)
194214 if (active(state) && !beginRecovery(t, o, 'timeout')) block('vtol-transition-outcome-uncertain')
195215 }
@@ -219,7 +239,8 @@ export function createVehicleMissionEngine(input: unknown) {
219239 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! }
220240 const ack = o.payloadAck
221241 if (ack) {
222 if (ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) > 1 || ack.outcome !== 'confirmed') block('payload-outcome-uncertain')
242 const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) > 1
243 if (scopeMismatch || ack.outcome !== 'confirmed') { if (!divertPayload(t, o, scopeMismatch ? 'scope-mismatch' : ack.outcome === 'rejected' ? 'rejected' : 'uncertain')) block('payload-outcome-uncertain') }
223244 else { advance(t.next); reason = 'payload-confirmed' }
224245 }
225246 }
@@ -233,7 +254,7 @@ export function createVehicleMissionEngine(input: unknown) {
233254 if (t.next) advance(t.next)
234255 else {
235256 const abortLanded = t.kind === 'land' && recovery?.abortRequestedAtMs !== undefined
236 state = abortLanded ? 'aborted' : 'completed'; reason = t.kind === 'land' ? abortLanded ? 'vtol-abort-landed' : recovery ? 'vtol-recovery-landed' : 'landed' : 'stopped'; targetM = null
257 state = abortLanded ? 'aborted' : 'completed'; reason = t.kind === 'land' ? payloadFailure ? 'payload-diversion-landed' : abortLanded ? 'vtol-abort-landed' : recovery ? 'vtol-recovery-landed' : 'landed' : 'stopped'; targetM = null
237258 }
238259 }
239260 }
resources/functions/drone/vehicles/links.tsadded+62-0
Changes to resources/functions/drone/vehicles/links.ts
@@ -0,0 +1,62 @@
1import { authorityPolicy } from '../authority/supervisor'
2
3export interface VehicleLinkScope { vehicleId: string, bootId: number, sessionId: number }
4export type VehicleLinkSource = 'primary' | 'backup'
5interface Packet extends VehicleLinkScope { sourceId: VehicleLinkSource, sequence: number, capturedAtMs: number, receivedAtMs: number, clockUncertaintyMs: number, connected: boolean }
6const fields = ['vehicleId', 'bootId', 'sessionId', 'sourceId', 'sequence', 'capturedAtMs', 'receivedAtMs', 'clockUncertaintyMs', 'connected']
7function captureData(input: unknown, keys: string[]): Record<string, unknown> | null {
8 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== keys.length) return null
9 const captured: Record<string, unknown> = {}
10 for (const key of keys) {
11 const d = Object.getOwnPropertyDescriptor(input, key)
12 if (!d || !d.enumerable || !Object.hasOwn(d, 'value')) return null
13 captured[key] = d.value
14 }
15 return captured
16}
17
18/** Two captured sources for one authority owner. Scope labels are not authentication. */
19export function createRedundantVehicleLinks(input: VehicleLinkScope) {
20 const data = captureData(input, ['vehicleId', 'bootId', 'sessionId'])
21 if (!data || typeof data.vehicleId !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(data.vehicleId) || typeof data.bootId !== 'number' || !Number.isSafeInteger(data.bootId) || data.bootId < 1 || typeof data.sessionId !== 'number' || !Number.isSafeInteger(data.sessionId) || data.sessionId < 1) throw new TypeError('Invalid vehicle link scope')
22 const scope = { vehicleId: data.vehicleId, bootId: data.bootId, sessionId: data.sessionId }, packets = new Map<VehicleLinkSource, Packet>()
23 let lastNow = -1, seenHealthy = false, latchedLoss = false
24 function clock(nowMs: number) {
25 if (!Number.isSafeInteger(nowMs) || nowMs < 0 || nowMs < lastNow) throw new RangeError('Invalid vehicle link clock')
26 lastNow = nowMs
27 }
28 function fresh(packet: Packet, nowMs: number) {
29 return packet.connected && nowMs - packet.capturedAtMs + packet.clockUncertaintyMs <= authorityPolicy.linkAgeMs
30 }
31 function checkLoss(nowMs: number) {
32 if (seenHealthy && ![...packets.values()].some(packet => fresh(packet, nowMs))) latchedLoss = true
33 }
34 return {
35 ingest(input: unknown, nowMs: number): boolean {
36 clock(nowMs)
37 checkLoss(nowMs)
38 const captured = captureData(input, fields)
39 if (latchedLoss || !captured) return false
40 if (captured.vehicleId !== scope.vehicleId || captured.bootId !== scope.bootId || captured.sessionId !== scope.sessionId || !['primary', 'backup'].includes(captured.sourceId as string)
41 || typeof captured.connected !== 'boolean' || !['sequence', 'capturedAtMs', 'receivedAtMs', 'clockUncertaintyMs'].every(key => Number.isSafeInteger(captured[key]) && Number(captured[key]) >= 0)) return false
42 const packet = captured as unknown as Packet, previous = packets.get(packet.sourceId)
43 if (packet.sequence < 1 || packet.clockUncertaintyMs > 10 || packet.capturedAtMs < packet.clockUncertaintyMs || packet.capturedAtMs + packet.clockUncertaintyMs > packet.receivedAtMs || packet.receivedAtMs > nowMs
44 || nowMs - packet.capturedAtMs + packet.clockUncertaintyMs > authorityPolicy.linkAgeMs || previous && (packet.sequence <= previous.sequence || packet.capturedAtMs <= previous.capturedAtMs || packet.receivedAtMs < previous.receivedAtMs)) return false
45 packets.set(packet.sourceId, { ...packet })
46 if (packet.connected) seenHealthy = true
47 return true
48 },
49 snapshot(nowMs: number) {
50 clock(nowMs)
51 checkLoss(nowMs)
52 const candidates = (['primary', 'backup'] as const).filter(id => { const packet = packets.get(id); return packet && fresh(packet, nowMs) })
53 if (candidates.length) seenHealthy = true
54 else if (seenHealthy) latchedLoss = true
55 const selectedSource = latchedLoss ? null : candidates[0] ?? null, selected = selectedSource ? packets.get(selectedSource)! : null
56 return { evidence: 'scoped-captured-link-selection' as const, scope: { ...scope }, observedAtMs: nowMs, selectedSource, latchedLoss,
57 authority: { capturedAtMs: selected ? selected.capturedAtMs - selected.clockUncertaintyMs : 0, connected: selected !== null },
58 sources: (['primary', 'backup'] as const).map(sourceId => { const packet = packets.get(sourceId); return { sourceId, sequence: packet?.sequence ?? null, capturedAtMs: packet?.capturedAtMs ?? null, receivedAtMs: packet?.receivedAtMs ?? null, clockUncertaintyMs: packet?.clockUncertaintyMs ?? null, connected: packet ? fresh(packet, nowMs) : false } }),
59 identityAuthenticated: false as const, physicalFlightEnabled: false as const }
60 },
61 }
62}
resources/functions/drone/vehicles/multirotor/profile.tsadded+74-0
Changes to resources/functions/drone/vehicles/multirotor/profile.ts
@@ -0,0 +1,74 @@
1import type { SensorVector } from '../../sensors/types'
2import type { VehicleMissionPlan, VehiclePlant, VehiclePlantState, VehicleProfile, VehicleSession } from '../types'
3import { createRigidBody } from '../../controls/physics'
4import { createSimulatedMultirotorController } from '../../controls/controller'
5import { multirotorModel } from '../../controls/model'
6import { vehicleReference } from '../measurements'
7
8export const multirotorScenarios = ['nominal', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss', 'link-loss'] as const
9export type MultirotorScenario = typeof multirotorScenarios[number]
10
11/** The original fictional quad-X body, now using the shared acknowledged mission contract. */
12export function multirotorMission(): VehicleMissionPlan {
13 const spatial = { acceptanceM: .2, maxArrivalSpeedMps: .18, settleMs: 200, timeoutMs: 20000 }
14 return {
15 version: 2, vehicle: 'quadcopter', id: 'quad-shared-mission', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE',
16 origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -1],
17 power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [
18 { id: 'start', kind: 'start', next: 'depart' },
19 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -1], ...spatial, next: 'visit' },
20 { id: 'visit', kind: 'waypoint', positionM: [2, 1, -1], ...spatial, next: 'home' },
21 { id: 'home', kind: 'return', ...spatial, next: 'land' },
22 { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial },
23 ],
24 }
25}
26
27/** Both bare and carried-load bodies use the same quad plant interface. */
28export function createMultirotorPlant(body: ReturnType<typeof createRigidBody>): VehiclePlant {
29 function snapshot(): VehiclePlantState {
30 const state = body.snapshot()
31 return { positionM: [...state.positionM], velocityMps: [...state.velocityMps], accelerationMps2: [...state.accelerationMps2], quaternionWxyz: [...state.quaternionWxyz], angularRateRadS: [...state.angularRateRadS], onSurface: state.groundContact || state.positionM[2] >= -.1 && state.velocityMps[2] >= 0, details: Object.fromEntries(state.motorsN.map((thrust, i) => ['motor' + i + 'N', thrust])) }
32 }
33 return { snapshot, step(actuators, dtS, environment) { body.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps }); return snapshot() } }
34}
35
36function createSession(): VehicleSession {
37 let sequence = 0, plant: VehiclePlant | null = null
38 return {
39 createPlant: () => plant ??= createMultirotorPlant(createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1 })),
40 createController() {
41 const controller = createSimulatedMultirotorController({ headingAlignment: 'initial-simulation-north' })
42 let fault: string | null = null
43 return { step(context) {
44 const rate = context.controlRate
45 if (context.enabled && (!rate || rate.evidence !== 'modeled-qualified-imu' || rate.reference !== vehicleReference(context.estimate)
46 || !Array.isArray(rate.angularRateRadS) || rate.angularRateRadS.length !== 3 || ![0, 1, 2].every(i => Object.hasOwn(rate.angularRateRadS, i) && Number.isFinite(rate.angularRateRadS[i])))) fault ??= 'quad-rate-unqualified'
47 const output = controller.step({ kind: 'simulation', nowMs: context.nowMs, enabled: context.enabled && fault === null, estimate: context.estimate,
48 angularRateRadS: rate?.angularRateRadS ?? [0, 0, 0], rateCapturedAtMs: rate?.capturedAtMs ?? -Infinity,
49 target: { positionM: context.mission.targetM ?? [0, 0, 0], yawRad: 0, issuedAtMs: context.nowMs } })
50 const measurement = context.measurement
51 const contact = !output.inhibited && context.mission.taskKind === 'land' && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors'
52 && measurement.reference === rate?.reference && measurement.capturedAtMs <= context.nowMs && context.nowMs - measurement.capturedAtMs <= 120 && measurement.receivedAtMs <= context.nowMs && measurement.values.onSurface === true
53 return { kind: 'simulated-vehicle-control', vehicle: 'quadcopter', atMs: context.nowMs, actuators: contact ? [0, 0, 0, 0] : [...output.motorsN], inhibited: fault !== null || output.inhibited, reason: fault ?? (contact ? 'quad-qualified-contact' : output.reason), physicalFlightEnabled: false, boardOutputsInhibited: true }
54 } }
55 },
56 renderMeasurement(state, atMs, reference) {
57 return { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'quadcopter', sequence: ++sequence, reference, capturedAtMs: atMs, receivedAtMs: atMs + 40,
58 values: { onSurface: state.onSurface, stopped: [0, 1, 2, 3].every(i => Number(state.details['motor' + i + 'N']) < .01), calibration: 'fictional-quad-x-v1', clockUncertaintyMs: 1 } }
59 },
60 }
61}
62
63export function createMultirotorProfile(scenario: MultirotorScenario = 'nominal'): VehicleProfile {
64 if (!multirotorScenarios.includes(scenario)) throw new RangeError('Unknown multirotor scenario')
65 return { scenario, vehicle: 'quadcopter', name: 'Owned quad-X shared mission', scenarios: [...multirotorScenarios], evidence: 'modeled-vehicle-profile', physicalFlightEnabled: false,
66 actuators: [0, 1, 2, 3].map(i => ({ name: 'motor-' + i, unit: 'newton', min: 0, max: multirotorModel.maxMotorThrustN, neutral: 0 })),
67 mission: multirotorMission(), authorityFence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, createSession,
68 faults(selectedScenario, atMs) {
69 if (selectedScenario !== scenario) throw new TypeError('Multirotor scenario changed after capture')
70 const active = atMs >= 5000
71 return { dropImu: active && scenario === 'imu-loss', dropPosition: active && scenario === 'position-loss', dropVehicleSensor: active && scenario === 'vehicle-sensor-loss', rcOverride: active && scenario === 'rc-override', kill: active && scenario === 'kill', hostLost: active && scenario === 'host-loss', linkLost: active && scenario === 'link-loss', windMps: [0, 0, 0] as SensorVector }
72 },
73 }
74}
resources/functions/drone/vehicles/runtime.tsmodified+16-2
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -24,6 +24,12 @@ function captureMissionActions(input: unknown, maximumMs: number): ScheduledMiss
2424 }
2525 return result
2626}
27function captureLinkObservation(input: unknown, nowMs: number): { capturedAtMs: number, connected: boolean } {
28 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== 2) throw new TypeError('Unqualified vehicle link observation')
29 const time = Object.getOwnPropertyDescriptor(input, 'capturedAtMs'), connected = Object.getOwnPropertyDescriptor(input, 'connected')
30 if (!time?.enumerable || !Object.hasOwn(time, 'value') || !Number.isSafeInteger(time.value) || time.value < 0 || time.value > nowMs || !connected?.enumerable || !Object.hasOwn(connected, 'value') || typeof connected.value !== 'boolean') throw new TypeError('Unqualified vehicle link observation')
31 return { capturedAtMs: time.value, connected: connected.value }
32}
2733/** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */
2834export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) {
2935 const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120
@@ -39,6 +45,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
3945 let transitionObservation: VehicleVtolTransitionObservation | null = null
4046 const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence)
4147 const session = profileInput.createSession(), navigation = session.createNavigation?.()
48 const linkProvider = session.linkObservation
4249 if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
4350 const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController()
4451 const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence })
@@ -98,6 +105,12 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
98105 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
99106 }
100107 if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs)
108 let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false }
109 if (typeof linkProvider === 'function') {
110 try { link = captureLinkObservation(linkProvider.call(session, nowMs), nowMs) }
111 catch { /* An installed adapter failure is disconnected, never a replacement fixture. */ }
112 }
113 if (faults.linkLost) link.connected = false
101114 if (nowMs < 600) continue
102115 const b = board.snapshot()
103116 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
@@ -105,7 +118,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
105118 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
106119 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
107120 interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true },
108 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link: { capturedAtMs: nowMs, connected: !faults.linkLost } }
121 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link }
109122 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
110123 const s = supervisor.snapshot()
111124 return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } })
@@ -113,7 +126,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
113126 supervisor.update(observation)
114127 if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) }
115128 const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission,
116 ...(profileInput.vehicle === 'vtol' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}),
129 ...(profileInput.vehicle === 'vtol' || profileInput.vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}),
117130 }
118131 payload?.observe?.(structuredClone(context))
119132 const ack = payload?.poll(nowMs) ?? null
@@ -123,6 +136,7 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
123136 const scheduled = missionActions[missionActionIndex]
124137 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
125138 mission = engine.step(missionObservation, action)
139 if (mission.payloadFailure?.triggeredAtMs === nowMs) payload?.observe?.(structuredClone({ ...context, mission }))
126140 if (mission.payload && !requested.has(mission.payload.eventId)) {
127141 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
128142 else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) }
resources/functions/drone/vehicles/types.tsmodified+7-2
Changes to resources/functions/drone/vehicles/types.ts
@@ -3,7 +3,8 @@ import type { GeodeticOrigin, Quaternion } from '../estimation/frames'
33import type { SensorVector } from '../sensors/types'
44import type { MissionAction, MissionObservation, MissionState } from '../missions/engine'
55
6export type VehicleKind = 'fixed-wing' | 'vtol' | 'ground' | 'surface' | 'subsea'
6export const vehicleKinds = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const
7export type VehicleKind = typeof vehicleKinds[number]
78export interface VehicleActuator { name: string, unit: 'normalized' | 'newton' | 'radian' | 'metres-per-second', min: number, max: number, neutral: number }
89export interface VehiclePlantState {
910 positionM: SensorVector
@@ -56,7 +57,7 @@ export type VehicleMissionTask =
5657 | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string }
5758 | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
5859 | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string }
59 | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string }
60 | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string, onFailure?: string }
6061 | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string }
6162 | { id: string, kind: 'abort' }
6263/** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */
@@ -89,6 +90,8 @@ export interface VehicleMissionSnapshot {
8990 transition?: VehicleVtolTransitionCommand | null
9091 /** Present after entering an explicitly planned transition-failure hover/landing route. */
9192 recovery?: { failedEventId: string | null, failedTaskId: string, hoverTaskId: string, triggeredAtMs: number, cause: 'timeout' | 'controller-aborted' | 'operator-abort' | 'operator-pause' | 'operator-recover', abortRequestedAtMs?: number }
93 /** A declared one-way payload failure route. Landing it does not confirm delivery. */
94 payloadFailure?: { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, reason: 'timeout' | 'rejected' | 'uncertain' | 'scope-mismatch', triggeredAtMs: number, approachTaskId: string, landingTaskId: string }
9295 reason: string
9396 elapsedMs: number
9497}
@@ -124,6 +127,8 @@ export interface VehicleFaults {
124127/** One execution owns its plant, controller, payload and measurement sequence. */
125128export interface VehicleNavigation { update(base: EstimatorSnapshot, measurement: VehicleMeasurement | null, nowMs: number): EstimatorSnapshot }
126129export interface VehicleSession {
130 /** Optional received link evidence on the runtime's monotonic clock, without receipt-time restamping. */
131 linkObservation?(nowMs: number): { capturedAtMs: number, connected: boolean }
127132 createNavigation?(): VehicleNavigation
128133 createPlant(): VehiclePlant
129134 createController(): VehicleController
tests/tsconfig.runtime.jsonmodified+4-0
Changes to tests/tsconfig.runtime.json
@@ -23,6 +23,8 @@
2323 "./unit/controls.test.ts",
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
26 "./unit/cargo-delivery.test.ts",
27 "./unit/cargo-delivery-cli.test.ts",
2628 "./unit/cargo-load-cli.test.ts",
2729 "./fixtures/estimation/**/*.ts",
2830 "./unit/estimation.test.ts",
@@ -71,6 +73,8 @@
7173 "./unit/vehicle-mission-checkpoints.test.ts",
7274 "./unit/vehicle-authority.test.ts",
7375 "./unit/vehicle-runtime.test.ts",
76 "./unit/multirotor-vehicle.test.ts",
77 "./unit/vehicle-links.test.ts",
7478 "./unit/air.test.ts",
7579 "./unit/vtol.test.ts",
7680 "./unit/vtol-control.test.ts",
tests/unit/authority.test.tsmodified+13-0
Changes to tests/unit/authority.test.ts
@@ -101,6 +101,19 @@ test('kill wins over invalid time, cannot be cleared by release or disarm and ne
101101 reset.interlocks.motorsStopped = true
102102 expect(s.update(reset, command(s, 'reset')).state).toBe('disarmed')
103103})
104
105test('a kill event records its valid observation time and retains the prior epoch for invalid clocks', () => {
106 for (const nowMs of [1020, NaN, 999]) {
107 const s = engaged(), kill = observation(nowMs)
108 kill.interlocks.killActive = true
109 const result = s.update(kill)
110 expect(result.state).toBe('killed')
111 expect(result.events.at(-1)!.reason).toBe('kill-input')
112 expect(result.events.at(-1)!.atMs).toBe(nowMs === 1020 ? 1020 : 1000)
113 expect(result.lastCheckedAtMs).toBe(nowMs === 1020 ? 1020 : 1000)
114 expect(s.gate(output(1020), 1020)).toBeNull()
115 }
116})
104117test('RC override revokes autonomous outputs and stays manual after override is released', () => {
105118 const s = engaged(), o = observation(1020); o.rc.override = true
106119 expect(s.update(o).state).toBe('manual')
tests/unit/cargo-delivery-cli.test.tsadded+95-0
Changes to tests/unit/cargo-delivery-cli.test.ts
@@ -0,0 +1,95 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { spawn } from 'node:child_process'
4
5test('native Buddy delivery prints bounded modeled custody, landing and reserve facts', async () => {
6 const result = await command(['cargo:deliver']).withTimeout(30000)
7 expect(result.exitCode).toBe(0)
8 expect(result.timedOut).toBe(false)
9 const report = JSON.parse(result.stdout)
10 expect(report.evidence).toBe('owned-cargo-delivery-simulation')
11 expect(report.status).toBe('completed')
12 expect(report.demonstrationMatched).toBe(true)
13 expect(report.delivery.outcome).toBe('received')
14 expect(report.landingContact).toBe(true)
15 expect(report.load.final.totalMassKg).toBe(1.5)
16 expect(report.board.actuators).toEqual([0, 0, 0, 0])
17 expect(report.energy.evidence).toBe('declared-mission-power-budget')
18 expect(report.energy.worstCaseMs).toBe(81520)
19 expect(report.physicalFlightEnabled).toBe(false)
20 expect(report.identityAuthenticated).toBe(false)
21 expect(result.stdout.trim().split('\n')).toHaveLength(1)
22 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
23}, 40000)
24
25
26test('native delivery keeps refused and uncertain custody distinct from its completed alternate', async () => {
27 for (const scenario of ['recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt']) {
28 const result = await command(['cargo:deliver', '--scenario', scenario]).withTimeout(30000)
29 expect(result.exitCode).toBe(0)
30 expect(result.timedOut).toBe(false)
31 const report = JSON.parse(result.stdout)
32 expect(report.status).toBe('diverted')
33 expect(report.demonstrationMatched).toBe(true)
34 expect(report.landingContact).toBe(true)
35 expect(report.delivery.outcome).toBe(scenario.includes('receipt') ? 'uncertain' : 'not-released')
36 expect(report.mission.payloadFailure.landingTaskId).toBe('alternate-land')
37 expect(report.board.actuators).toEqual([0, 0, 0, 0])
38 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
39 }
40}, 170000)
41
42test('native delivery reports backup continuity and the actual authority failure outcomes', async () => {
43 const cases = [
44 ['primary-link-loss', 'completed'], ['both-links-loss', 'blocked'], ['position-loss', 'blocked'], ['imu-loss', 'blocked'], ['vehicle-sensor-loss', 'blocked'],
45 ['rc-override', 'blocked'], ['kill', 'blocked'], ['host-loss', 'host-lost'], ['authority-containment', 'blocked'], ['diversion-position-loss', 'blocked'], ['diversion-kill', 'blocked'],
46 ]
47 for (const [scenario, status] of cases) {
48 const result = await command(['cargo:deliver', '--scenario', scenario!]).withTimeout(30000)
49 expect(result.timedOut).toBe(false)
50 expect(result.exitCode).toBe(0)
51 const report = JSON.parse(result.stdout)
52 expect(report.status).toBe(status)
53 expect(report.demonstrationMatched).toBe(true)
54 expect(report.delivery.outcome).toBe(scenario === 'primary-link-loss' ? 'received' : 'not-released')
55 expect(report.landingContact).toBe(scenario === 'primary-link-loss')
56 expect(report.board.actuators).toEqual([0, 0, 0, 0])
57 expect(report.board.expiresAtMs).toBeNull()
58 expect(report.authority.hostAvailable).toBe(scenario !== 'host-loss')
59 if (scenario === 'host-loss') {
60 expect(report.authority.state).toBe('autonomous')
61 expect(report.board.fault).toBe('output-lease-expired')
62 }
63 expect(report.links.scope).toEqual({ vehicleId: 'carrier-a', bootId: 55, sessionId: 123 })
64 expect(report.physicalFlightEnabled).toBe(false)
65 expect(report.identityAuthenticated).toBe(false)
66 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
67 }
68}, 340000)
69
70test('native delivery final options reject coercion without echoing caller details', async () => {
71 for (const flags of [['--scenario', '/private-delivery-sentinel'], ['--scenario', ''], ['--scenario=false'], ['--scenario', 'nominal', '--scenario', 'nominal']]) {
72 const result = await command(['cargo:deliver', ...flags]).withTimeout(30000)
73 expect(result.exitCode).toBe(1)
74 expect(result.stdout).toBe('')
75 expect(result.stderr).toBe('Cargo delivery simulation failed: invalid input, unavailable result or output failure\n')
76 expect(result.timedOut).toBe(false)
77 }
78}, 130000)
79
80test('a closed delivery stdout exits with an awaited fixed error and no success payload', async () => {
81 const child = spawn(process.execPath, ['bin/drone.ts', 'cargo:deliver'], { stdio: ['ignore', 'pipe', 'pipe'] })
82 child.stdout.destroy()
83 let stderr = '', timedOut = false
84 child.stderr.on('data', (chunk: Buffer) => {
85 stderr += chunk.toString('utf8')
86 if (Buffer.byteLength(stderr) > 8192) child.kill('SIGKILL')
87 })
88 const guard = setTimeout(() => { timedOut = true; child.kill('SIGKILL') }, 30000)
89 let code: number | null
90 try { code = await new Promise<number | null>((resolve, reject) => { child.once('error', reject); child.once('close', resolve) }) }
91 finally { clearTimeout(guard) }
92 expect(timedOut).toBe(false)
93 expect(code).toBe(1)
94 expect(stderr).toBe('Cargo delivery simulation failed: invalid input, unavailable result or output failure\n')
95}, 40000)
tests/unit/cargo-delivery.test.tsadded+219-0
Changes to tests/unit/cargo-delivery.test.ts
@@ -0,0 +1,219 @@
1import { expect, test } from 'bun:test'
2import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
3import { replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle'
4
5// Public delivery simulation: actual mass/CG plant, raw navigation, commanded
6// mechanism, delayed model observations and mission replay. No physical IO.
7test('a simulated parcel is released and received before the carrier returns and lands', () => {
8 const result = simulateCargoDelivery()
9 expect(result.evidence).toBe('owned-cargo-delivery-simulation')
10 expect(result.status).toBe('completed')
11 expect(result.delivery.outcome).toBe('received')
12 expect(result.initialLoad.properties.totalMassKg).toBe(1.6)
13 expect(result.finalLoad.properties.totalMassKg).toBe(1.5)
14 expect(result.release?.outcome).toBe('separated')
15 expect(result.delivery.mechanismCapturedAtMs).toBeGreaterThan(result.delivery.requestedAtMs!)
16 expect(result.delivery.receiptCapturedAtMs).toBeGreaterThan(result.delivery.mechanismCapturedAtMs!)
17 expect(result.parcel?.onSurface).toBe(true)
18 expect(result.runtime.mission.state).toBe('completed')
19 expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission)
20 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
21 expect(result.physicalFlightEnabled).toBe(false)
22 expect(result.identityAuthenticated).toBe(false)
23}, 30000)
24
25test('mechanism and receiver observations retain the executed command after its acknowledgement', () => {
26 const result = simulateCargoDelivery()
27 const request = result.runtime.trace.rows.find(row => row.result.payload !== null)!.result.payload!
28 const later = result.runtime.path.filter(row => row.atMs > result.delivery.acknowledgedAtMs! + 100)
29 expect(later.length).toBeGreaterThan(10)
30 for (const row of later) {
31 expect(row.measurement?.values.mechanismEventId).toBe(request.eventId)
32 expect(row.measurement?.values.receiptEventId).toBe(request.eventId)
33 expect(row.measurement?.values.loadAttached).toBe(false)
34 }
35}, 30000)
36
37for (const scenario of ['recipient-absent', 'site-blocked']) {
38 test('a delivery cannot release without current ' + scenario + ' evidence', () => {
39 const result = simulateCargoDelivery({ scenario })
40 expect(result.status).toBe('diverted')
41 expect(result.delivery.outcome).toBe('not-released')
42 expect(result.delivery.reason).toBe('release-evidence-unqualified')
43 expect(result.release).toBeNull()
44 expect(result.finalLoad.properties.totalMassKg).toBe(1.6)
45 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
46 expect(result.runtime.mission.reason).toBe('payload-diversion-landed')
47 }, 30000)
48}
49
50test('a captured mechanism jam refuses delivery without separating the actual carried mass', () => {
51 const result = simulateCargoDelivery({ scenario: 'mechanism-jam' })
52 expect(result.status).toBe('diverted')
53 expect(result.delivery.outcome).toBe('not-released')
54 expect(result.delivery.reason).toBe('mechanism-jammed')
55 expect(result.release).toBeNull()
56 expect(result.finalLoad.properties.totalMassKg).toBe(1.6)
57 const acknowledgements = result.runtime.trace.rows.flatMap(row => row.observation.payloadAck ? [row.observation.payloadAck] : [])
58 expect(acknowledgements).toHaveLength(1)
59 expect(acknowledgements[0]!.outcome).toBe('rejected')
60 expect(acknowledgements[0]!.capturedAtMs).toBe(result.delivery.mechanismCapturedAtMs!)
61 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
62}, 30000)
63
64for (const scenario of ['receipt-loss', 'foreign-receipt']) {
65 test('a separated parcel remains uncertain under ' + scenario, () => {
66 const result = simulateCargoDelivery({ scenario })
67 expect(result.status).toBe('diverted')
68 expect(result.release?.outcome).toBe('separated')
69 expect(result.finalLoad.properties.totalMassKg).toBe(1.5)
70 expect(result.delivery.mechanismOutcome).toBe('open')
71 expect(result.delivery.outcome).toBe('uncertain')
72 expect(result.delivery.receiptCapturedAtMs).toBeNull()
73 expect(result.runtime.mission.reason).toBe('payload-diversion-landed')
74 expect(result.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
75 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
76 }, 30000)
77}
78
79
80test('a refused release takes only the declared finite alternate and lands without claiming delivery', () => {
81 const result = simulateCargoDelivery({ scenario: 'site-blocked' })
82 expect(result.status).toBe('diverted')
83 expect(result.delivery.outcome).toBe('not-released')
84 expect(result.runtime.mission.state).toBe('completed')
85 expect(result.runtime.mission.payloadFailure?.taskId).toBe('release')
86 expect(result.runtime.mission.payloadFailure?.reason).toBe('rejected')
87 expect(result.runtime.mission.payloadFailure?.landingTaskId).toBe('alternate-land')
88 expect(result.runtime.trace.rows.at(-1)!.observation.onGround).toBe(true)
89 expect(result.runtime.path.some(row => row.mission.taskId === 'home')).toBe(false)
90 expect(result.runtime.trace.rows.filter(row => row.result.payload !== null).map(row => row.result.payload!.eventId).every(id => id === result.runtime.mission.payloadFailure!.eventId)).toBe(true)
91 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
92 expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission)
93}, 30000)
94
95
96test('declared reserve planning includes the entire alternate route before execution', () => {
97 const plan = simulateCargoDelivery().runtime.trace.plan
98 const approach = plan.tasks.find(task => task.id === 'alternate-approach')!
99 const land = plan.tasks.find(task => task.id === 'alternate-land')!
100 Object.assign(approach, { timeoutMs: 60000 })
101 Object.assign(land, { timeoutMs: 60000 })
102 const budget = validateVehicleMission(plan)
103 // 20 ms start + 20 s takeoff + 20 s site + 1.5 s release + two 60 s alternate tasks.
104 expect(budget.worstCaseMs).toBe(161520)
105 expect(budget.requiredWh).toBe(26.92)
106 plan.power.availableWh = 30
107 // Normal return fits 21 Wh usable, while the declared failure path does not.
108 expect(() => validateVehicleMission(plan)).toThrow('Vehicle mission cannot preserve energy reserve')
109}, 30000)
110
111test('a payload failure route cannot request another release or move its declared landing site', () => {
112 const plan = simulateCargoDelivery().runtime.trace.plan
113 const releaseAgain = structuredClone(plan)
114 releaseAgain.tasks[releaseAgain.tasks.findIndex(task => task.id === 'alternate-approach')] = { id: 'alternate-approach', kind: 'payload', operation: 'material-drop', containerId: 'another-parcel', timeoutMs: 1000, next: 'alternate-land' }
115 expect(() => validateVehicleMission(releaseAgain)).toThrow('Invalid vehicle mission')
116 const wrongSite = structuredClone(plan), land = wrongSite.tasks.find(task => task.id === 'alternate-land')!
117 if (land.kind !== 'land') throw new Error('Expected declared landing')
118 land.positionM[0] = 2
119 expect(() => validateVehicleMission(wrongSite)).toThrow('Invalid vehicle mission')
120}, 30000)
121
122test('delivery acknowledgement positions belong to the captured receiver packet epoch', () => {
123 const result = simulateCargoDelivery()
124 const row = result.runtime.trace.rows.find(row => row.observation.payloadAck !== null)!
125 const ack = row.observation.payloadAck!
126 const received = result.runtime.path.find(path => path.atMs === row.observation.nowMs)!
127 expect(received.measurement!.capturedAtMs).toBe(ack.capturedAtMs)
128 expect(received.measurementPose!.capturedAtMs).toBe(ack.capturedAtMs)
129 expect(ack.positionM).toEqual(received.measurementPose!.positionM)
130 expect(received.atMs - ack.capturedAtMs).toBe(40)
131}, 30000)
132
133test('a delivery continues on its captured backup link without changing vehicle authority or custody', () => {
134 const result = simulateCargoDelivery({ scenario: 'primary-link-loss' })
135 expect(result.status).toBe('completed')
136 expect(result.delivery.outcome).toBe('received')
137 const failover = result.linkEvents.find(event => event.selectedSource === 'backup')!
138 expect(failover).toBeDefined()
139 expect(failover.observedAtMs).toBeGreaterThan(5000)
140 expect(failover.authority.capturedAtMs).toBeLessThan(failover.observedAtMs)
141 expect(failover.sources.find(source => source.sourceId === 'primary')!.capturedAtMs).toBe(4980)
142 expect(result.links.selectedSource).toBe('backup')
143 expect(result.links.latchedLoss).toBe(false)
144 expect(result.links.scope).toEqual({ vehicleId: 'carrier-a', bootId: 55, sessionId: 123 })
145 expect(result.runtime.authority.bootId).toBe(55n)
146 expect(result.runtime.authority.sessionId).toBe(123n)
147 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
148}, 30000)
149
150test('loss of both delivery links latches loss and revokes the carrier before release', () => {
151 const result = simulateCargoDelivery({ scenario: 'both-links-loss' })
152 expect(result.status).toBe('blocked')
153 expect(result.links.latchedLoss).toBe(true)
154 expect(result.links.authority.connected).toBe(false)
155 expect(result.delivery.outcome).toBe('not-released')
156 expect(result.release).toBeNull()
157 expect(result.runtime.mission.state).not.toBe('completed')
158 expect(result.runtime.authority.events.some(event => event.reason === 'link-loss')).toBe(true)
159 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
160 expect(result.runtime.board.expiresAtMs).toBeNull()
161 expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission)
162}, 30000)
163
164test('degraded delivery navigation revokes authority without releasing the parcel', () => {
165 const result = simulateCargoDelivery({ scenario: 'position-loss' })
166 expect(result.status).toBe('blocked')
167 expect(result.delivery.outcome).toBe('not-released')
168 expect(result.release).toBeNull()
169 expect(result.runtime.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason))).toBe(true)
170 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
171 expect(result.runtime.board.expiresAtMs).toBeNull()
172}, 30000)
173
174for (const scenario of ['imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss']) {
175 test('delivery retains cargo and neutral output under ' + scenario, () => {
176 const result = simulateCargoDelivery({ scenario })
177 expect(result.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked')
178 expect(result.delivery.outcome).toBe('not-released')
179 expect(result.release).toBeNull()
180 expect(result.runtime.mission.state).not.toBe('completed')
181 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
182 expect(result.runtime.board.expiresAtMs).toBeNull()
183 expect(cargoDeliverySummary(result).demonstrationMatched).toBe(true)
184 if (scenario === 'host-loss') {
185 expect(result.runtime.board.fault).toBe('output-lease-expired')
186 expect(result.runtime.authority.state).toBe('autonomous')
187 }
188 if (scenario === 'rc-override') expect(result.runtime.authority.events.some(event => event.to === 'manual')).toBe(true)
189 if (scenario === 'kill') expect(result.runtime.authority.events.some(event => event.to === 'killed')).toBe(true)
190 }, 30000)
191}
192
193test('a delivery target outside the independent authority fence cannot escape containment', () => {
194 const result = simulateCargoDelivery({ scenario: 'authority-containment' })
195 expect(result.status).toBe('blocked')
196 expect(result.release).toBeNull()
197 expect(result.delivery.outcome).toBe('not-released')
198 expect(result.runtime.authority.events.some(event => event.reason === 'invalid-target-or-lease')).toBe(true)
199 expect(result.runtime.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 2)).toBe(true)
200 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
201}, 30000)
202
203for (const scenario of ['diversion-position-loss', 'diversion-kill']) {
204 test('an alternate route cannot override ' + scenario, () => {
205 const result = simulateCargoDelivery({ scenario })
206 expect(result.status).toBe('blocked')
207 expect(result.runtime.mission.payloadFailure?.reason).toBe('rejected')
208 expect(result.runtime.mission.state).not.toBe('completed')
209 expect(result.delivery.outcome).toBe('not-released')
210 expect(result.release).toBeNull()
211 const failureAtMs = result.runtime.mission.payloadFailure!.triggeredAtMs
212 expect(result.runtime.path.some(row => row.atMs > failureAtMs && row.atMs < failureAtMs + 200 && row.mission.taskId === 'alternate-approach' && row.actuators.some(n => n > 0))).toBe(true)
213 expect(result.runtime.board.actuators).toEqual([0, 0, 0, 0])
214 expect(result.runtime.board.expiresAtMs).toBeNull()
215 if (scenario === 'diversion-kill') expect(result.runtime.authority.state).toBe('killed')
216 else expect(result.runtime.authority.events.some(event => ['estimator-loss', 'stale-estimator'].includes(event.reason))).toBe(true)
217 expect(replayVehicleMission(result.runtime.trace).snapshot()).toEqual(result.runtime.mission)
218 }, 30000)
219}
tests/unit/multirotor-vehicle.test.tsadded+92-0
Changes to tests/unit/multirotor-vehicle.test.ts
@@ -0,0 +1,92 @@
1import { expect, test } from 'bun:test'
2import { createMultirotorProfile } from '../../resources/functions/drone/vehicles/multirotor/profile'
3import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime'
4import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
5
6// Existing public owned-runtime seam: actual raw sensors, estimator, mission,
7// controls and board. This verifies modeled execution, not firmware or flight.
8test('a four-motor profile executes the shared mission and lands with replayable observations', () => {
9 const run = runVehicleSimulation(createMultirotorProfile(), { seconds: 100 })
10 expect(run.vehicle).toBe('quadcopter')
11 expect(run.status).toBe('completed')
12 expect(run.fault).toBeNull()
13 expect(run.navigationEvidence.observedSensors.imu).toBeGreaterThan(100)
14 expect(run.navigationEvidence.observedSensors.gnss).toBeGreaterThan(20)
15 expect(run.path.some(row => row.positionM && Math.hypot(row.positionM[0] - 2, row.positionM[1] - 1, row.positionM[2] + 1) < .25)).toBe(true)
16 expect(run.path.some(row => row.actuators.some(n => n > 0))).toBe(true)
17 expect(run.trace.rows.at(-1)!.observation.onGround).toBe(true)
18 expect(run.board.actuators).toEqual([0, 0, 0, 0])
19 expect(run.board.expiresAtMs).toBeNull()
20 expect(run.authority.state).toBe('disarmed')
21 expect(replayVehicleMission(run.trace).snapshot()).toEqual(run.mission)
22 expect(run.physicalFlightEnabled).toBe(false)
23 expect(run.board.physicalActuationEnabled).toBe(false)
24}, 30000)
25
26for (const scenario of ['imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss', 'link-loss'] as const) {
27 test('the shared four-motor mission preserves independent authority under ' + scenario, () => {
28 const run = runVehicleSimulation(createMultirotorProfile(scenario), { seconds: 100 })
29 expect(run.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked')
30 expect(run.mission.state).not.toBe('completed')
31 expect(run.board.actuators).toEqual([0, 0, 0, 0])
32 expect(run.board.expiresAtMs).toBeNull()
33 expect(run.fault).not.toBeNull()
34 expect(run.board.physicalActuationEnabled).toBe(false)
35 if (scenario === 'host-loss') {
36 expect(run.board.fault).toBe('output-lease-expired')
37 expect(run.authority.state).toBe('autonomous')
38 }
39 else expect(run.authority.state).not.toBe('autonomous')
40 }, 30000)
41}
42
43test('a four-motor controller rejects rate evidence from a different estimator reference', () => {
44 const profile = createMultirotorProfile(), createSession = profile.createSession
45 let changed = false
46 profile.createSession = () => {
47 const session = createSession(), controller = session.createController()
48 return { ...session, createController: () => ({ step(context) {
49 if (context.enabled && context.nowMs === 2000 && context.controlRate) {
50 context.controlRate.reference = '55:99,99,99,99,99'
51 changed = true
52 }
53 return controller.step(context)
54 } }) }
55 }
56 const run = runVehicleSimulation(profile, { seconds: 100 })
57 expect(changed).toBe(true)
58 expect(run.status).toBe('blocked')
59 expect(run.fault).toBe('quad-rate-unqualified')
60 expect(run.board.actuators).toEqual([0, 0, 0, 0])
61}, 30000)
62
63test('an installed link adapter cannot become a fabricated healthy link when its result fails', () => {
64 let getterRead = false
65 const badResults: (() => unknown)[] = [() => null, () => undefined, () => { throw new Error('private-radio-detail') },
66 () => ({ capturedAtMs: 2001, connected: true }), () => ({ capturedAtMs: 2000, connected: 'yes' }),
67 () => ({ capturedAtMs: 2000, get connected() { getterRead = true; return true } })]
68 for (const badResult of badResults) {
69 const profile = createMultirotorProfile(), createSession = profile.createSession
70 profile.createSession = () => ({ ...createSession(), linkObservation(nowMs) {
71 return (nowMs < 2000 ? { capturedAtMs: nowMs, connected: true } : badResult()) as { capturedAtMs: number, connected: boolean }
72 } })
73 const run = runVehicleSimulation(profile, { seconds: 100 })
74 expect(run.status).toBe('blocked')
75 expect(run.authority.events.some(event => event.reason === 'link-loss')).toBe(true)
76 expect(run.board.actuators).toEqual([0, 0, 0, 0])
77 expect(run.board.expiresAtMs).toBeNull()
78 }
79 expect(getterRead).toBe(false)
80}, 30000)
81
82test('an installed link adapter preserves source age and cannot override declared link loss', () => {
83 for (const scenario of ['nominal', 'link-loss'] as const) {
84 const profile = createMultirotorProfile(scenario), createSession = profile.createSession
85 profile.createSession = () => ({ ...createSession(), linkObservation: nowMs => ({ capturedAtMs: scenario === 'nominal' ? Math.min(nowMs, 2000) : nowMs, connected: true }) })
86 const run = runVehicleSimulation(profile, { seconds: 100 })
87 expect(run.status).toBe('blocked')
88 expect(run.authority.events.some(event => event.reason === 'link-loss')).toBe(true)
89 expect(run.path.at(-1)!.atMs).toBe(scenario === 'nominal' ? 2160 : 5000)
90 expect(run.board.actuators).toEqual([0, 0, 0, 0])
91 }
92}, 30000)
tests/unit/vehicle-links.test.tsadded+69-0
Changes to tests/unit/vehicle-links.test.ts
@@ -0,0 +1,69 @@
1import { expect, test } from 'bun:test'
2import { createRedundantVehicleLinks } from '../../resources/functions/drone/vehicles/links'
3
4// Public captured-link adapter: modeled scope, source time and latched loss.
5// This does not verify radios, clock synchronization or authenticated identity.
6test('a fresh backup preserves the same authority scope when the primary becomes stale', () => {
7 const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }
8 const links = createRedundantVehicleLinks(scope)
9 expect(links.snapshot(0).selectedSource).toBeNull()
10 expect(links.ingest({ ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 10, receivedAtMs: 30, clockUncertaintyMs: 2, connected: true }, 30)).toBe(true)
11 expect(links.snapshot(30).selectedSource).toBe('primary')
12 expect(links.ingest({ ...scope, sourceId: 'backup', sequence: 1, capturedAtMs: 100, receivedAtMs: 120, clockUncertaintyMs: 2, connected: true }, 120)).toBe(true)
13 const failover = links.snapshot(162)
14 expect(failover.selectedSource).toBe('backup')
15 expect(failover.authority).toEqual({ capturedAtMs: 98, connected: true })
16 expect(failover.scope).toEqual(scope)
17 expect(failover.latchedLoss).toBe(false)
18})
19
20test('fresh packets cannot silently restore a scope after both sources have expired', () => {
21 const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }
22 const links = createRedundantVehicleLinks(scope)
23 const first = { ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 10, receivedAtMs: 30, clockUncertaintyMs: 2, connected: true }
24 expect(links.ingest(first, 30)).toBe(true)
25 expect(links.snapshot(30).authority.connected).toBe(true)
26 // No intervening snapshot is required to notice the lost interval.
27 expect(links.ingest({ ...first, sequence: 2, capturedAtMs: 190, receivedAtMs: 210 }, 210)).toBe(false)
28 expect(links.snapshot(210).latchedLoss).toBe(true)
29 expect(links.snapshot(210).authority.connected).toBe(false)
30})
31
32test('foreign, replayed, future, uncertain and executable link envelopes cannot refresh the captured source', () => {
33 const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, links = createRedundantVehicleLinks(scope)
34 const packet = { ...scope, sourceId: 'primary', sequence: 1, capturedAtMs: 20, receivedAtMs: 40, clockUncertaintyMs: 2, connected: true }
35 expect(links.ingest(packet, 40)).toBe(true)
36 let accessed = false
37 const getter = { ...packet, sequence: 2 }
38 Object.defineProperty(getter, 'capturedAtMs', { enumerable: true, get() { accessed = true; return 60 } })
39 for (const invalid of [packet, { ...packet, vehicleId: 'carrier-b', sequence: 2 }, { ...packet, bootId: 56, sequence: 2 }, { ...packet, sessionId: 124, sequence: 2 },
40 { ...packet, sourceId: 'third', sequence: 2 }, { ...packet, sequence: 2, capturedAtMs: 60, receivedAtMs: 80 }, { ...packet, sequence: 2, receivedAtMs: 70 },
41 { ...packet, sequence: 2, clockUncertaintyMs: 11 }, { ...packet, sequence: 2, unknown: true }, getter]) {
42 expect(links.ingest(invalid, 60)).toBe(false)
43 }
44 expect(accessed).toBe(false)
45 expect(links.snapshot(60).authority).toEqual({ capturedAtMs: 18, connected: true })
46 expect(links.snapshot(168).authority.connected).toBe(true)
47 expect(links.snapshot(169).latchedLoss).toBe(true)
48})
49
50test('a disconnected primary uses the backup without taking a new authority session', () => {
51 const scope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, links = createRedundantVehicleLinks(scope)
52 for (const sourceId of ['primary', 'backup']) expect(links.ingest({ ...scope, sourceId, sequence: 1, capturedAtMs: 20, receivedAtMs: 40, clockUncertaintyMs: 2, connected: true }, 40)).toBe(true)
53 expect(links.snapshot(40).selectedSource).toBe('primary')
54 expect(links.ingest({ ...scope, sourceId: 'primary', sequence: 2, capturedAtMs: 60, receivedAtMs: 80, clockUncertaintyMs: 2, connected: false }, 80)).toBe(true)
55 const result = links.snapshot(80)
56 expect(result.selectedSource).toBe('backup')
57 expect(result.authority.capturedAtMs).toBe(18)
58 expect(result.scope).toEqual(scope)
59 expect(result.identityAuthenticated).toBe(false)
60 expect(() => links.snapshot(79)).toThrow('Invalid vehicle link clock')
61})
62
63test('link ownership requires captured scalar vehicle, boot and session identities', () => {
64 expect(() => createRedundantVehicleLinks({ vehicleId: undefined as unknown as string, bootId: 55, sessionId: 123 })).toThrow('Invalid vehicle link scope')
65 let accessed = false
66 const input = { bootId: 55, sessionId: 123, get vehicleId() { accessed = true; return 'carrier-a' } }
67 expect(() => createRedundantVehicleLinks(input)).toThrow('Invalid vehicle link scope')
68 expect(accessed).toBe(false)
69})