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.

resources/components/MissionWorkspace.stxmodified+3-3
Changes to resources/components/MissionWorkspace.stx
@@ -78,9 +78,9 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
7878 <p class="mt-3 max-w-3xl leading-relaxed text-neutral-400">Choose a vehicle, record its modeled mission and inspect the same decision timeline. Recorded vehicle positions come from the owned estimator.</p>
7979 <p class="mt-3 text-emerald-200 text-sm">Simulation only. Physical outputs remain inhibited.</p>
8080 <div class="flex flex-wrap gap-4 items-end mt-8">
81 <div class="min-w-0 max-w-full"><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div>
82 <div class="min-w-0 max-w-full"><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div>
83 <div :if="vtolDefinition()?.phases.length" class="min-w-0 max-w-full"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div>
81 <div class="max-w-full min-w-0"><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div>
82 <div class="max-w-full min-w-0"><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div>
83 <div :if="vtolDefinition()?.phases.length" class="max-w-full min-w-0"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div>
8484 <label :if="profile() === 'vtol' && !vtolDefinition()?.plannedCapture" for="mission-vtol-electrical" class="flex gap-3 items-center py-3 text-sm"><input id="mission-vtol-electrical" type="checkbox" x-model="electrical" class="accent-emerald-200">Modeled electrical load</label>
8585 <p :if="vtolDefinition()?.plannedCapture" class="py-3 text-neutral-400 text-sm">This plan includes a modeled 100 Wh battery.</p>
8686 <button type="button" @click="execute()" :disabled="busy()" class="px-4 py-3 font-medium text-neutral-950 whitespace-nowrap bg-emerald-200 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4 disabled:opacity-60">{{ busy() ? 'Recording…' : 'Run scenario' }}</button>
resources/functions/drone/cargo/coordinator.tsadded+161-0
Changes to resources/functions/drone/cargo/coordinator.ts
@@ -0,0 +1,161 @@
1import type { GeodeticOrigin } from '../estimation/frames'
2import type { SensorVector } from '../sensors/types'
3import type { VehicleControllerContext, VehiclePayloadAck, VehiclePayloadCommand, VehiclePayloadPort } from '../vehicles/types'
4import { geodeticToEcef } from '../estimation/frames'
5import { authorityPolicy } from '../authority/supervisor'
6import { createVehicleMeasurementQualifier, vehicleReference } from '../vehicles/measurements'
7
8export interface CargoDeliveryDeclaration {
9 origin: GeodeticOrigin
10 site: { id: string, recipientId: string, containerId: string, positionM: SensorVector, radiusM: number }
11 releaseHeightM: number
12}
13export interface CargoDeliveryCustody {
14 outcome: 'not-released' | 'in-transit' | 'received' | 'uncertain'
15 containerId: string
16 siteId: string
17 recipientId: string
18 requestedAtMs: number | null
19 mechanismCapturedAtMs: number | null
20 receiptCapturedAtMs: number | null
21 acknowledgedAtMs: number | null
22 reason: string | null
23 mechanismOutcome: 'not-requested' | 'requested' | 'open' | 'jammed'
24}
25export interface ModeledCargoMechanism {
26 evidence: 'modeled-cargo-mechanism'
27 request(command: VehiclePayloadCommand): void
28 cancel(eventId: string): void
29}
30function record(input: unknown, fields: string[]): Record<string, unknown> {
31 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== fields.length) throw new TypeError('cargo-delivery-declaration')
32 const result: Record<string, unknown> = {}
33 for (const field of fields) {
34 const d = Object.getOwnPropertyDescriptor(input, field)
35 if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError('cargo-delivery-declaration')
36 result[field] = d.value
37 }
38 return result
39}
40function captureDeclaration(input: unknown): CargoDeliveryDeclaration {
41 const top = record(input, ['origin', 'site', 'releaseHeightM']), site = record(top.site, ['id', 'recipientId', 'containerId', 'positionM', 'radiusM'])
42 const origin = record(top.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM'])
43 if (!['id', 'recipientId', 'containerId'].every(key => typeof site[key] === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(site[key] as string))
44 || typeof site.radiusM !== 'number' || !Number.isFinite(site.radiusM) || site.radiusM < .1 || site.radiusM > 2
45 || typeof top.releaseHeightM !== 'number' || !Number.isFinite(top.releaseHeightM) || top.releaseHeightM < .3 || top.releaseHeightM > 3
46 || !Object.values(origin).every(value => typeof value === 'number' && Number.isFinite(value)) || !Array.isArray(site.positionM) || site.positionM.length !== 3 || Reflect.ownKeys(site.positionM).length !== 4) throw new TypeError('cargo-delivery-declaration')
47 const positionM = [0, 1, 2].map(i => {
48 const d = Object.getOwnPropertyDescriptor(site.positionM, String(i))
49 if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || Math.abs(d.value) > 5000) throw new TypeError('cargo-delivery-declaration')
50 return d.value
51 }) as SensorVector
52 const capturedOrigin = origin as unknown as GeodeticOrigin
53 geodeticToEcef(capturedOrigin)
54 return { origin: { ...capturedOrigin }, site: { id: site.id as string, recipientId: site.recipientId as string, containerId: site.containerId as string, positionM, radiusM: site.radiusM }, releaseHeightM: top.releaseHeightM }
55}
56function finiteTuple(value: unknown, size: number) {
57 return Array.isArray(value) && value.length === size && Array.from({ length: size }, (_, i) => i).every(i => Object.hasOwn(value, i) && typeof value[i] === 'number' && Number.isFinite(value[i]))
58}
59
60/** Captured modeled release/custody policy behind the shared vehicle payload port. No physical IO. */
61export function createCargoDeliveryCoordinator(input: unknown, mechanism: ModeledCargoMechanism) {
62 const declaration = captureDeclaration(input), { site } = declaration
63 if (!mechanism || mechanism.evidence !== 'modeled-cargo-mechanism' || typeof mechanism.request !== 'function' || typeof mechanism.cancel !== 'function') throw new TypeError('cargo-delivery-modeled-mechanism')
64 const delivery: CargoDeliveryCustody = { 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' }
65 const measurements = createVehicleMeasurementQualifier('quadcopter')
66 let pending: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null
67 let closed = false, requested = false, lastPollMs = -1, lastSequence = -1, readyCount = 0, readyStartMs: number | null = null, readyReference: string | null = null, refused: string | null = null
68 let lastObservedAtMs: number | null = null, releaseAttempted = false, readyCaptureStartMs: number | null = null
69 function ready(context: VehicleControllerContext) {
70 const e = context.estimate, m = context.measurement, p = e.localPosition, a = e.attitude, q = a?.quaternionWxyz, rate = context.controlRate
71 if (!context.enabled || context.estimate.evidence !== 'fixture' || !context.estimate.health.localPosition || !context.estimate.health.tilt || !m || !p || !q || !rate
72 || e.physicalFlightEnabled !== false || e.nowMs !== context.nowMs || a?.reference !== 'GRAVITY_ALIGNED_RELATIVE_YAW' || p.frame !== 'LOCAL_NED' || p.altitudeDatum !== 'WGS84_ELLIPSOID'
73 || p.capturedAtMs === null || !Number.isFinite(p.capturedAtMs) || p.capturedAtMs < 0 || p.capturedAtMs > context.nowMs || context.nowMs - p.capturedAtMs > 200 || p.predictedToMs !== context.nowMs
74 || !finiteTuple(p.positionM, 3) || !finiteTuple(p.velocityMps, 3) || !finiteTuple(q, 4) || Math.abs(Math.hypot(...q) - 1) > 1e-6 || !finiteTuple(rate.angularRateRadS, 3)
75 || !Array.isArray(p.covarianceBlocks) || p.covarianceBlocks.length !== 3 || !p.covarianceBlocks.every(b => b && [b.positionM2, b.positionVelocityM2S, b.velocityM2S2].every(Number.isFinite) && b.positionM2 >= 0 && b.velocityM2S2 >= 0 && b.positionVelocityM2S ** 2 <= b.positionM2 * b.velocityM2S2 + 1e-12)
76 || m.reference !== vehicleReference(context.estimate) || m.capturedAtMs > context.nowMs || context.nowMs - m.capturedAtMs > 80 || m.receivedAtMs > context.nowMs
77 || rate.evidence !== 'modeled-qualified-imu' || rate.reference !== m.reference || !Number.isSafeInteger(rate.capturedAtMs) || rate.capturedAtMs > context.nowMs || context.nowMs - rate.capturedAtMs > 60 || rate.capturedAtMs !== context.estimate.attitude?.capturedAtMs
78 || m.values.siteId !== site.id || m.values.recipientId !== site.recipientId || m.values.siteClear !== true || m.values.recipientPresent !== true || m.values.loadAttached !== true
79 || !p.origin || p.origin.latitudeDeg !== declaration.origin.latitudeDeg || p.origin.longitudeDeg !== declaration.origin.longitudeDeg || p.origin.ellipsoidHeightM !== declaration.origin.ellipsoidHeightM) return false
80 const speed = Math.hypot(...p.velocityMps), sigma = Math.sqrt(Math.max(...p.covarianceBlocks.map(block => block.positionM2)))
81 const horizontal = Math.hypot(p.positionM[0]! - site.positionM[0], p.positionM[1]! - site.positionM[1]), heightM = site.positionM[2] - p.positionM[2]!
82 return speed <= .18 && horizontal + 3 * sigma + speed * .08 <= site.radiusM && Math.abs(heightM - declaration.releaseHeightM) <= .2
83 && Math.acos(Math.max(-1, Math.min(1, 1 - 2 * (q[1] ** 2 + q[2] ** 2)))) <= .2 && Math.hypot(...rate.angularRateRadS) <= .5
84 }
85 function cancel() {
86 const command = pending
87 pending = null
88 if (command && releaseAttempted) {
89 try { mechanism.cancel(command.eventId) }
90 catch { delivery.reason ??= 'mechanism-cancel-unavailable' }
91 }
92 }
93 const port: VehiclePayloadPort = {
94 observe(input) {
95 const context = structuredClone(input)
96 if (lastObservedAtMs !== null && context.nowMs - lastObservedAtMs > authorityPolicy.schedulerGapMs) { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null }
97 lastObservedAtMs = context.nowMs
98 try { context.measurement = measurements.qualify(context.measurement, context.nowMs, vehicleReference(context.estimate)) }
99 catch { context.measurement = null; context.measurementPose = null }
100 observed = context
101 if (pending && (context.mission.payloadFailure?.eventId === pending.eventId || !context.enabled)) {
102 cancel()
103 if (delivery.mechanismOutcome === 'open' || delivery.outcome === 'uncertain') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' }
104 else { delivery.outcome = 'not-released'; delivery.reason ??= 'receipt-not-confirmed' }
105 }
106 const sequence = context.measurement?.sequence ?? -1, reference = context.measurement?.reference ?? null
107 const qualified = ready(context) && (readyReference === null || reference === readyReference)
108 if (!qualified) { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null }
109 if (sequence === lastSequence && reference === readyReference) return
110 if (qualified && (lastSequence < 0 || sequence > lastSequence)) { readyCount++; readyStartMs ??= context.nowMs; readyCaptureStartMs ??= context.measurement!.capturedAtMs }
111 else { readyCount = 0; readyStartMs = null; readyCaptureStartMs = null }
112 lastSequence = sequence; readyReference = reference
113 },
114 request(command) {
115 if (closed || requested || pending !== null) throw new TypeError('cargo-delivery-command-replayed')
116 requested = true; pending = structuredClone(command); delivery.requestedAtMs = command.issuedAtMs; delivery.mechanismOutcome = 'requested'
117 const c = observed, scoped = c?.mission.payload
118 if (!c || !scoped || c.mission.version !== 2 || c.mission.vehicle !== 'quadcopter' || c.mission.evidence !== 'owned-vehicle-mission-simulation' || c.mission.physicalFlightEnabled !== false || c.mission.state !== 'running' || c.mission.taskKind !== 'payload' || command.taskId !== c.mission.taskId || command.operation !== 'material-drop' || command.containerId !== site.containerId
119 || typeof command.eventId !== 'string' || command.eventId.length < 1 || command.eventId.length > 160 || !Number.isSafeInteger(command.expiresAtMs) || command.expiresAtMs - c.nowMs > 10000
120 || !finiteTuple(command.positionM, 3) || !c.estimate.localPosition || !command.positionM.every((value, i) => value === c.estimate.localPosition!.positionM[i])
121 || JSON.stringify(scoped) !== JSON.stringify(command) || command.reference !== vehicleReference(c.estimate) || command.issuedAtMs !== c.nowMs || command.expiresAtMs <= c.nowMs
122 || !ready(c) || readyCount < 26 || readyStartMs === null || c.nowMs - readyStartMs < 500 || readyCaptureStartMs === null || !c.measurement || c.measurement.capturedAtMs - readyCaptureStartMs < 500) { refused = 'release-evidence-unqualified'; delivery.reason = refused }
123 else {
124 releaseAttempted = true
125 delivery.outcome = 'uncertain'
126 try { mechanism.request(structuredClone(command)) }
127 catch { refused = 'mechanism-transport-unavailable'; delivery.reason = refused }
128 }
129 },
130 poll(nowMs): VehiclePayloadAck | null {
131 if (!Number.isSafeInteger(nowMs) || nowMs < lastPollMs) throw new RangeError('cargo-delivery-clock')
132 lastPollMs = nowMs
133 const c = observed, m = c?.measurement, pose = c?.measurementPose
134 if (!pending || closed || !c?.enabled || c.nowMs !== nowMs || !m || m.reference !== pending.reference || m.capturedAtMs < pending.issuedAtMs || m.capturedAtMs >= pending.expiresAtMs
135 || nowMs >= pending.expiresAtMs || nowMs - m.capturedAtMs > 80 || m.receivedAtMs > nowMs || !pose || pose.reference !== m.reference || pose.capturedAtMs !== m.capturedAtMs || pose.positionPredictedToMs !== m.capturedAtMs || !finiteTuple(pose.positionM, 3) || !pose.positionM.every(value => Math.abs(value) <= 1e6)) return null
136 if (releaseAttempted && m.values.mechanismEventId === pending.eventId && m.values.mechanismJammed === true && m.values.loadAttached === true) {
137 delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'jammed'; delivery.outcome = 'not-released'; refused = 'mechanism-jammed'; delivery.reason = refused
138 }
139 if (releaseAttempted && m.values.mechanismEventId === pending.eventId && m.values.mechanismOpen === true && m.values.loadAttached === false) { delivery.mechanismCapturedAtMs ??= m.capturedAtMs; delivery.mechanismOutcome = 'open'; delivery.outcome = 'in-transit' }
140 const received = releaseAttempted && delivery.mechanismOutcome === 'open' && delivery.mechanismCapturedAtMs !== null && m.values.mechanismEventId === pending.eventId && m.values.mechanismOpen === true && m.values.mechanismJammed === false && m.values.loadAttached === false
141 && m.values.receiptEventId === pending.eventId && m.values.receiptContainerId === pending.containerId
142 && m.values.recipientId === site.recipientId && m.values.siteId === site.id && m.values.recipientPresent === true && m.values.siteClear === true
143 if (releaseAttempted && m.values.receiptEventId !== '' && !received) { refused = 'receipt-scope-mismatch'; delivery.reason = refused; delivery.outcome = 'uncertain' }
144 if (!refused && !received) return null
145 const command = pending
146 if (refused) cancel()
147 else pending = null
148 delivery.acknowledgedAtMs = nowMs
149 if (refused && releaseAttempted && delivery.outcome !== 'not-released') delivery.outcome = 'uncertain'
150 if (received && !refused) { delivery.receiptCapturedAtMs = m.capturedAtMs; delivery.outcome = 'received' }
151 return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference, capturedAtMs: m.capturedAtMs,
152 positionM: [...pose.positionM], outcome: refused ? delivery.outcome === 'uncertain' ? 'uncertain' : 'rejected' : 'confirmed',
153 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' } }
154 },
155 close() {
156 closed = true; cancel(); observed = null
157 if (delivery.outcome === 'in-transit' || delivery.outcome === 'uncertain') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' }
158 },
159 }
160 return { port, snapshot: () => structuredClone({ evidence: 'modeled-cargo-delivery-coordinator' as const, physicalFlightEnabled: false as const, identityAuthenticated: false as const, declaration, delivery, readiness: { distinctCaptures: readyCount, sinceMs: readyStartMs, sinceCaptureMs: readyCaptureStartMs, reference: readyReference } }) }
161}
resources/functions/drone/cargo/delivery.tsmodified+15-78
Changes to resources/functions/drone/cargo/delivery.ts
@@ -1,29 +1,18 @@
11import type { SensorVector } from '../sensors/types'
22import type { CargoBodySeparation, CargoLoadSnapshot } from './types'
3import type { VehicleControllerContext, VehicleMissionPlan, VehiclePayloadAck, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types'
3import type { VehicleControllerContext, VehicleMissionPlan, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types'
44import { createRigidBody } from '../controls/physics'
55import { multirotorModel } from '../controls/model'
66import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile'
77import { runVehicleSimulation } from '../vehicles/runtime'
8import { vehicleReference } from '../vehicles/measurements'
98import { createRedundantVehicleLinks } from '../vehicles/links'
9import type { CargoDeliveryCustody } from './coordinator'
10import { createCargoDeliveryCoordinator } from './coordinator'
1011
1112const flightFaults = ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss'] as const
1213export 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
1314type CargoDeliveryScenario = typeof cargoDeliveryScenarios[number]
1415interface 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}
2716type DeliveryRelease = CargoBodySeparation & { atMs: number, eventId: string }
2817type DeliveryLinkSnapshot = ReturnType<ReturnType<typeof createRedundantVehicleLinks>['snapshot']>
2918export interface CargoDeliveryResult {
@@ -34,7 +23,7 @@ export interface CargoDeliveryResult {
3423 physicalFlightEnabled: false
3524 boardOutputsInhibited: true
3625 identityAuthenticated: false
37 delivery: Delivery
26 delivery: CargoDeliveryCustody
3827 initialLoad: CargoLoadSnapshot
3928 finalLoad: CargoLoadSnapshot
4029 release: DeliveryRelease | null
@@ -85,11 +74,14 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult
8574 const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, massKg: .1, forwardM: .08 } })
8675 const initialLoad = body.loadSnapshot(), adapted = createMultirotorPlant(body)
8776 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
77 let armed: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null
78 let receiptAtMs: number | null = null
9179 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' }
80 const coordinator = createCargoDeliveryCoordinator({ origin: profile.mission.origin, site: { id: site.id, recipientId: site.recipientId, containerId: site.containerId, positionM: [site.northM, site.eastM, site.downM], radiusM: site.radiusM }, releaseHeightM: 1 }, {
81 evidence: 'modeled-cargo-mechanism',
82 request(command) { armed = structuredClone(command) },
83 cancel(eventId) { if (armed?.eventId === eventId) armed = null },
84 })
9385 const linkScope = { vehicleId: 'carrier-a', bootId: 55, sessionId: 123 }, linkMonitor = createRedundantVehicleLinks(linkScope)
9486 const linkQueue: { sourceId: 'primary' | 'backup', sequence: number, capturedAtMs: number, receivedAtMs: number }[] = [], linkEvents: DeliveryLinkSnapshot[] = []
9587 let links = linkMonitor.snapshot(0), linkSequence = 0
@@ -111,28 +103,17 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult
111103 }
112104 }
113105 if (armed && mechanismAttempt === null && physicalAtMs > armed.issuedAtMs) {
114 if (physicalAtMs >= armed.expiresAtMs) { delivery.outcome = 'uncertain'; delivery.reason = 'mechanism-command-expired'; armed = null }
106 if (physicalAtMs >= armed.expiresAtMs) { armed = null }
115107 else {
116108 mechanismAttempt = { eventId: armed.eventId, atMs: physicalAtMs, jammed: scenario === 'mechanism-jam' }
117109 if (mechanismAttempt.jammed) { armed = null; return snapshot() }
118110 const event = body.separateCarriedLoad()
119111 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 }
112 if (event.outcome === 'separated') parcel = { ...event.parcel, onSurface: false, capturedAtMs: physicalAtMs }
122113 }
123114 }
124115 return snapshot()
125116 } }
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 }
136117 profile.createSession = (): VehicleSession => {
137118 const base = baseSession()
138119 return { ...base, createPlant: () => plant,
@@ -166,55 +147,11 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult
166147 siteClear: scenario !== 'site-blocked', recipientPresent: scenario !== 'recipient-absent' && !scenario.startsWith('diversion-'), mechanismOpen: release?.outcome === 'separated', mechanismJammed: mechanismAttempt?.jammed ?? false, mechanismEventId: mechanismAttempt?.eventId ?? '',
167148 receiptEventId: receiptCaptured && scenario !== 'receipt-loss' ? release?.eventId ?? '' : '', receiptContainerId: site.containerId } }
168149 },
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 }),
150 createPayload: () => ({ ...coordinator.port, observe(context) { observed = structuredClone(context); coordinator.port.observe!(context) } }),
214151 }
215152 }
216153 const runtime = runVehicleSimulation(profile, { seconds: 100 })
217 if (delivery.outcome === 'in-transit') { delivery.outcome = 'uncertain'; delivery.reason ??= 'receipt-not-confirmed' }
154 const delivery = coordinator.snapshot().delivery
218155 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,
219156 delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime })
220157}
tests/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -24,6 +24,7 @@
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
2626 "./unit/cargo-delivery.test.ts",
27 "./unit/cargo-delivery-coordinator.test.ts",
2728 "./unit/cargo-delivery-cli.test.ts",
2829 "./unit/cargo-load-cli.test.ts",
2930 "./fixtures/estimation/**/*.ts",
tests/unit/cargo-delivery-coordinator.test.tsadded+279-0
Changes to tests/unit/cargo-delivery-coordinator.test.ts
@@ -0,0 +1,279 @@
1import { expect, test } from 'bun:test'
2import type { SensorVector } from '../../resources/functions/drone/sensors/types'
3import type { VehicleControllerContext, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
4import { createCargoDeliveryCoordinator } from '../../resources/functions/drone/cargo/coordinator'
5import { vehicleReference } from '../../resources/functions/drone/vehicles/measurements'
6import { observation } from '../fixtures/authority/context'
7
8const declaration = { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'receiver-west', recipientId: 'recipient-west', containerId: 'parcel-west', positionM: [2, -1, 0] as SensorVector, radiusM: .35 }, releaseHeightM: 1 }
9function context(nowMs: number, sequence: number): VehicleControllerContext {
10 const estimate = observation(nowMs).estimate
11 estimate.localPosition!.positionM = [2, -1, -1]
12 estimate.localPosition!.covarianceBlocks = [0, 1, 2].map(() => ({ positionM2: .0001, positionVelocityM2S: 0, velocityM2S2: .0001 }))
13 const reference = vehicleReference(estimate), capturedAtMs = nowMs - 40
14 return { nowMs, enabled: true, estimate,
15 measurement: { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'quadcopter', sequence, reference, capturedAtMs, receivedAtMs: nowMs,
16 values: { onSurface: false, stopped: false, siteId: 'receiver-west', recipientId: 'recipient-west', siteClear: true, recipientPresent: true, loadAttached: true, mechanismOpen: false, mechanismJammed: false, mechanismEventId: '', receiptEventId: '', receiptContainerId: 'parcel-west' } },
17 measurementPose: { capturedAtMs, reference, positionM: [2, -1, -1], quaternionWxyz: [1, 0, 0, 0], positionCapturedAtMs: capturedAtMs, positionPredictedToMs: capturedAtMs, attitudeCapturedAtMs: capturedAtMs, attitudePredictedToMs: capturedAtMs, gyroCapturedAtMs: capturedAtMs, attitudePrediction: 'qualified-gyro-hold' },
18 controlRate: { evidence: 'modeled-qualified-imu', capturedAtMs: nowMs, reference, angularRateRadS: [0, 0, 0] },
19 mission: { version: 2, vehicle: 'quadcopter', evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state: 'running', taskId: 'release', taskKind: 'payload', targetM: [2, -1, -1], payload: null, reason: 'task-entered', elapsedMs: 0 } }
20}
21function command(c: VehicleControllerContext): VehiclePayloadCommand {
22 return { eventId: 'delivery-west:release', taskId: 'release', operation: 'material-drop', containerId: 'parcel-west', issuedAtMs: c.nowMs, expiresAtMs: c.nowMs + 1500, positionM: [2, -1, -1], reference: vehicleReference(c.estimate) }
23}
24
25// Public policy seam, with declared capture fixtures as transport observations.
26// Actual raw sensors/physics remain covered by the delivery runtime campaign.
27test('a caller-selected delivery site releases once and requires captured mechanism and recipient evidence', () => {
28 const sent: VehiclePayloadCommand[] = []
29 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(structuredClone(value)), cancel() {} })
30 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
31 const ready = context(1500, 26), request = command(ready)
32 ready.mission.payload = request; coordinator.port.observe!(ready); coordinator.port.request(request)
33 expect(sent).toEqual([request])
34 expect(coordinator.snapshot().delivery.outcome).toBe('uncertain')
35 expect(coordinator.port.poll(1500)).toBeNull()
36 const opened = context(1560, 29); opened.mission.payload = request
37 Object.assign(opened.measurement!.values, { loadAttached: false, mechanismOpen: true, mechanismEventId: request.eventId })
38 coordinator.port.observe!(opened)
39 expect(coordinator.port.poll(1560)).toBeNull()
40 expect(coordinator.snapshot().delivery.outcome).toBe('in-transit')
41 const received = context(1580, 30); received.mission.payload = request
42 Object.assign(received.measurement!.values, { loadAttached: false, mechanismOpen: true, mechanismEventId: request.eventId, receiptEventId: request.eventId })
43 coordinator.port.observe!(received)
44 expect(coordinator.port.poll(1580)).toMatchObject({ outcome: 'confirmed', capturedAtMs: 1540, positionM: [2, -1, -1], metadata: { expectedSiteId: 'receiver-west', expectedRecipientId: 'recipient-west' } })
45 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'received', siteId: 'receiver-west', recipientId: 'recipient-west', mechanismCapturedAtMs: 1520, receiptCapturedAtMs: 1540 })
46 expect(coordinator.port.poll(1600)).toBeNull()
47 expect(() => coordinator.port.request(request)).toThrow('cargo-delivery-command-replayed')
48 expect(sent).toHaveLength(1)
49})
50
51test('an aging retained packet breaks readiness even when its sequence has not changed', () => {
52 const sent: VehiclePayloadCommand[] = []
53 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
54 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
55 const retained = context(1560, 26), previous = context(1500, 26)
56 retained.measurement = previous.measurement; retained.measurementPose = previous.measurementPose
57 coordinator.port.observe!(retained)
58 expect(coordinator.snapshot().readiness.distinctCaptures).toBe(0)
59 const fresh = context(1580, 27), request = command(fresh)
60 fresh.mission.payload = request; coordinator.port.observe!(fresh); coordinator.port.request(request)
61 expect(sent).toHaveLength(0)
62 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'not-released', reason: 'release-evidence-unqualified' })
63})
64
65test('release readiness rejects gyro evidence from another estimator reference', () => {
66 const sent: VehiclePayloadCommand[] = []
67 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
68 for (let i = 0; i <= 25; i++) {
69 const c = context(1000 + i * 20, i + 1); c.controlRate!.reference = 'another-reference'
70 coordinator.port.observe!(c)
71 }
72 const c = context(1500, 26), request = command(c); c.controlRate!.reference = 'another-reference'; c.mission.payload = request
73 coordinator.port.observe!(c); coordinator.port.request(request)
74 expect(sent).toHaveLength(0)
75 expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified')
76})
77
78test('retiming a packet with an unchanged sequence cannot preserve a release window', () => {
79 const sent: VehiclePayloadCommand[] = []
80 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
81 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
82 const retimed = context(1540, 26), request = command(retimed)
83 retimed.mission.payload = request; coordinator.port.observe!(retimed); coordinator.port.request(request)
84 expect(sent).toHaveLength(0)
85 expect(coordinator.snapshot().readiness.distinctCaptures).toBe(0)
86})
87
88test('a fresh packet after an unobserved scheduling gap must rebuild the readiness window', () => {
89 const sent: VehiclePayloadCommand[] = []
90 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
91 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
92 const afterGap = context(1660, 27), request = command(afterGap)
93 afterGap.mission.payload = request; coordinator.port.observe!(afterGap); coordinator.port.request(request)
94 expect(sent).toHaveLength(0)
95 expect(coordinator.snapshot().readiness).toMatchObject({ distinctCaptures: 1, sinceMs: 1660 })
96})
97
98test('a receiver claim cannot turn captured jammed and attached cargo into received custody', () => {
99 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })
100 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
101 const c = context(1500, 26), request = command(c); c.mission.payload = request
102 coordinator.port.observe!(c); coordinator.port.request(request)
103 const contradictory = context(1560, 29); contradictory.mission.payload = request
104 Object.assign(contradictory.measurement!.values, { mechanismEventId: request.eventId, mechanismJammed: true, receiptEventId: request.eventId })
105 coordinator.port.observe!(contradictory)
106 expect(coordinator.port.poll(1560)?.outcome).not.toBe('confirmed')
107 expect(coordinator.snapshot().delivery.outcome).not.toBe('received')
108 expect(coordinator.snapshot().delivery.receiptCapturedAtMs).toBeNull()
109})
110
111test('ambient mechanism and receipt fields cannot confirm a release that the policy refused to dispatch', () => {
112 const sent: VehiclePayloadCommand[] = []
113 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
114 const unready = context(1500, 1), request = command(unready); unready.mission.payload = request
115 coordinator.port.observe!(unready); coordinator.port.request(request)
116 expect(sent).toHaveLength(0)
117 const claimed = context(1560, 2); claimed.mission.payload = request
118 Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId })
119 coordinator.port.observe!(claimed)
120 expect(coordinator.port.poll(1560)?.outcome).toBe('rejected')
121 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'not-released', mechanismCapturedAtMs: null, receiptCapturedAtMs: null, reason: 'release-evidence-unqualified' })
122})
123
124const invalidEstimates: [string, (c: VehicleControllerContext) => void][] = [
125 ['zero attitude quaternion', c => { c.estimate.attitude!.quaternionWxyz = [0, 0, 0, 0] }],
126 ['non-unit attitude quaternion', c => { c.estimate.attitude!.quaternionWxyz = [2, 0, 0, 0] }],
127 ['future position capture', c => { c.estimate.localPosition!.capturedAtMs = c.nowMs + 1 }],
128 ['stale position capture', c => { c.estimate.localPosition!.capturedAtMs = c.nowMs - 201 }],
129 ['unpredicted position', c => { c.estimate.localPosition!.predictedToMs = c.nowMs - 20 }],
130 ['wrong estimator time', c => { c.estimate.nowMs = c.nowMs - 20 }],
131 ['negative velocity variance', c => { c.estimate.localPosition!.covarianceBlocks[0]!.velocityM2S2 = -1 }],
132 ['indefinite covariance', c => { c.estimate.localPosition!.covarianceBlocks[0]!.positionVelocityM2S = 1 }],
133]
134for (const [label, corrupt] of invalidEstimates) test(`release readiness refuses ${label}`, () => {
135 const sent: VehiclePayloadCommand[] = []
136 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
137 for (let i = 0; i <= 25; i++) { const c = context(1000 + i * 20, i + 1); corrupt(c); coordinator.port.observe!(c) }
138 const c = context(1500, 26), request = command(c); corrupt(c); c.mission.payload = request
139 coordinator.port.observe!(c); coordinator.port.request(request)
140 expect(sent).toHaveLength(0)
141 expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified')
142})
143
144const invalidCommands: [string, (request: VehiclePayloadCommand) => void][] = [
145 ['another task', request => { request.taskId = 'another-task' }],
146 ['a different issue-time position', request => { request.positionM = [100, 0, -1] }],
147 ['an unbounded lease', request => { request.expiresAtMs = Infinity }],
148 ['a lease beyond the native task ceiling', request => { request.expiresAtMs += 10000 }],
149 ['a fractional deadline', request => { request.expiresAtMs += .5 }],
150 ['an empty event', request => { request.eventId = '' }],
151]
152for (const [label, corrupt] of invalidCommands) test(`release request refuses ${label}`, () => {
153 const sent: VehiclePayloadCommand[] = []
154 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
155 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
156 const c = context(1500, 26), request = command(c); corrupt(request); c.mission.payload = request
157 coordinator.port.observe!(c); coordinator.port.request(request)
158 expect(sent).toHaveLength(0)
159 expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified')
160})
161
162test('a throwing mechanism retains dispatch uncertainty and is cancelled once without a release retry', () => {
163 const sent: VehiclePayloadCommand[] = [], cancelled: string[] = []
164 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request(value) { sent.push(value); throw new Error('private adapter detail') }, cancel: eventId => { cancelled.push(eventId) } })
165 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
166 const c = context(1500, 26), request = command(c); c.mission.payload = request
167 coordinator.port.observe!(c); coordinator.port.request(request)
168 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', reason: 'mechanism-transport-unavailable' })
169 coordinator.port.close(); coordinator.port.close()
170 expect(sent).toHaveLength(1)
171 expect(cancelled).toEqual([request.eventId])
172 expect(coordinator.snapshot().delivery.outcome).toBe('uncertain')
173 expect(() => coordinator.port.request(request)).toThrow('cargo-delivery-command-replayed')
174})
175
176test('a captured receipt cannot silently clear a mechanism dispatch failure', () => {
177 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() { throw new Error('private transport detail') }, cancel() {} })
178 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
179 const c = context(1500, 26), request = command(c); c.mission.payload = request
180 coordinator.port.observe!(c); coordinator.port.request(request)
181 const claimed = context(1560, 29); claimed.mission.payload = request
182 Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId })
183 coordinator.port.observe!(claimed)
184 expect(coordinator.port.poll(1560)?.outcome).toBe('uncertain')
185 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', receiptCapturedAtMs: null, reason: 'mechanism-transport-unavailable' })
186})
187
188test('loss of authority after dispatch preserves uncertain custody and cancels the pending mechanism', () => {
189 const cancelled: string[] = []
190 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel: eventId => { cancelled.push(eventId) } })
191 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
192 const c = context(1500, 26), request = command(c); c.mission.payload = request
193 coordinator.port.observe!(c); coordinator.port.request(request)
194 const revoked = context(1520, 27); revoked.enabled = false; revoked.mission.payload = request
195 coordinator.port.observe!(revoked)
196 expect(coordinator.port.poll(1520)).toBeNull()
197 expect(cancelled).toEqual([request.eventId])
198 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', reason: 'receipt-not-confirmed', receiptCapturedAtMs: null })
199})
200
201test('configuration and snapshots are detached and getter declarations never run', () => {
202 const input = structuredClone(declaration)
203 const coordinator = createCargoDeliveryCoordinator(input, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })
204 input.site.id = 'mutated-site'; input.site.positionM[0] = 900
205 const snapshot = coordinator.snapshot(); snapshot.declaration.site.positionM[0] = 800; snapshot.delivery.outcome = 'received'
206 expect(coordinator.snapshot().declaration).toEqual(declaration)
207 expect(coordinator.snapshot().delivery.outcome).toBe('not-released')
208 let reads = 0
209 const getter = { ...declaration, get releaseHeightM() { reads++; return 1 } }
210 expect(() => createCargoDeliveryCoordinator(getter, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })).toThrow('cargo-delivery-declaration')
211 expect(reads).toBe(0)
212 expect(() => createCargoDeliveryCoordinator({ ...declaration, extra: true }, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })).toThrow('cargo-delivery-declaration')
213})
214
215test('terminal uncertainty cancels a possibly queued release before clearing the event', () => {
216 let queued: VehiclePayloadCommand | null = null
217 const cancelled: string[] = []
218 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request(value) { queued = value; throw new Error('queued before failure') }, cancel(eventId) { cancelled.push(eventId); if (queued?.eventId === eventId) queued = null } })
219 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
220 const c = context(1500, 26), request = command(c); c.mission.payload = request
221 coordinator.port.observe!(c); coordinator.port.request(request)
222 const fresh = context(1560, 29); fresh.mission.payload = request
223 coordinator.port.observe!(fresh)
224 expect(coordinator.port.poll(1560)?.outcome).toBe('uncertain')
225 expect(queued).toBeNull()
226 const revoked = context(1580, 30); revoked.enabled = false
227 coordinator.port.observe!(revoked); coordinator.port.close()
228 expect(cancelled).toEqual([request.eventId])
229 expect(coordinator.snapshot().delivery.outcome).toBe('uncertain')
230})
231
232test('receipt admission refuses a non-finite captured ACK position', () => {
233 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request() {}, cancel() {} })
234 for (let i = 0; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1))
235 const c = context(1500, 26), request = command(c); c.mission.payload = request
236 coordinator.port.observe!(c); coordinator.port.request(request)
237 const claimed = context(1560, 29); claimed.mission.payload = request; claimed.measurementPose!.positionM = [NaN, 0, -1]
238 Object.assign(claimed.measurement!.values, { mechanismOpen: true, loadAttached: false, mechanismEventId: request.eventId, receiptEventId: request.eventId })
239 coordinator.port.observe!(claimed)
240 expect(coordinator.port.poll(1560)).toBeNull()
241 expect(coordinator.snapshot().delivery.receiptCapturedAtMs).toBeNull()
242 expect(coordinator.snapshot().delivery.outcome).not.toBe('received')
243})
244
245test('variable transport lag cannot shorten the required source-capture readiness interval', () => {
246 const sent: VehiclePayloadCommand[] = []
247 const coordinator = createCargoDeliveryCoordinator(declaration, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
248 let last = context(1000, 1)
249 for (let i = 0; i <= 25; i++) {
250 last = context(1000 + i * 20, i + 1)
251 last.measurement!.capturedAtMs = last.nowMs - 20 - i * 2
252 coordinator.port.observe!(last)
253 }
254 // 26 fresh captures span only450ms even though receipt observations span500ms.
255 const request = command(last); last.mission.payload = request
256 coordinator.port.observe!(last); coordinator.port.request(request)
257 expect(sent).toHaveLength(0)
258 expect(coordinator.snapshot().delivery.reason).toBe('release-evidence-unqualified')
259})
260
261test('a declared non-default origin, elevated site and release height use their own metric geometry', () => {
262 const selected = { origin: { latitudeDeg: 50, longitudeDeg: 8, ellipsoidHeightM: 500 }, site: { id: 'receiver-north', recipientId: 'recipient-north', containerId: 'parcel-north', positionM: [4, 3, 2] as SensorVector, radiusM: .4 }, releaseHeightM: .6 }
263 const sent: VehiclePayloadCommand[] = []
264 const coordinator = createCargoDeliveryCoordinator(selected, { evidence: 'modeled-cargo-mechanism', request: value => sent.push(value), cancel() {} })
265 function selectedContext(nowMs: number, sequence: number) {
266 const c = context(nowMs, sequence)
267 c.estimate.localPosition!.origin = { ...selected.origin }; c.estimate.localPosition!.positionM = [4, 3, 1.4]
268 Object.assign(c.measurement!.values, { siteId: selected.site.id, recipientId: selected.site.recipientId, receiptContainerId: selected.site.containerId })
269 c.measurementPose!.positionM = [4, 3, 1.4]; c.mission.targetM = [4, 3, 1.4]
270 return c
271 }
272 for (let i = 0; i <= 25; i++) coordinator.port.observe!(selectedContext(1000 + i * 20, i + 1))
273 const c = selectedContext(1500, 26), request = command(c)
274 request.positionM = [4, 3, 1.4]; request.containerId = selected.site.containerId; c.mission.payload = request
275 coordinator.port.observe!(c); coordinator.port.request(request)
276 expect(sent).toEqual([request])
277 expect(coordinator.snapshot().declaration).toEqual(selected)
278 expect(coordinator.snapshot().delivery).toMatchObject({ outcome: 'uncertain', siteId: 'receiver-north', recipientId: 'recipient-north', containerId: 'parcel-north' })
279})