ReviewOS

also looking at this

stacks/drone

feat: coordinate modeled two-carrier cargo handover

#103
Merged chris wants to merge codex/cargo-fleet-coordination into main
25 files +1654 -13

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.

.github/workflows/ci.ymlmodified+12-0
Changes to .github/workflows/ci.yml
@@ -304,6 +304,18 @@ jobs:
304304 ${{ runner.temp }}/cargo-delivery-cli/*.stderr
305305 ${{ runner.temp }}/cargo-delivery-cli/receipt.json
306306 ${{ runner.temp }}/cargo-delivery-cli/request.json
307 - name: Qualify copied two-carrier fleet custody and failure outcomes
308 run: bun scripts/qualify-cargo-fleet-cli.ts "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$RUNNER_TEMP/cargo-fleet-cli"
309 - uses: actions/upload-artifact@v4
310 if: always()
311 with:
312 name: cargo-fleet-cli-evidence
313 if-no-files-found: ignore
314 path: |
315 ${{ runner.temp }}/cargo-fleet-cli/*.stdout
316 ${{ runner.temp }}/cargo-fleet-cli/*.stderr
317 ${{ runner.temp }}/cargo-fleet-cli/receipt.json
318
307319 - name: Qualify compiled environmental datasets outside the checkout
308320 working-directory: ${{ runner.temp }}
309321 shell: bash
README.mdmodified+4-2
Changes to README.md
@@ -125,8 +125,10 @@ and exits 1. Its capability flags remain separate from the modeled delivery miss
125125The [cargo delivery guide](docs/cargo-delivery.md) covers explicit mass/CG, receiver, alternate
126126and power declarations, captured release/custody, redundant links and post-release failures.
127127Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing.
128Distinct-vehicle fleet handover, electrical/measured endurance and physical/operational
129qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
128The [two-carrier model](docs/cargo-fleet.md) and `./buddy cargo:fleet` now execute actual
129finite station handover and the receiver's second delivery under separate sensor/authority
130scopes. Six bounded cases retain radio/host loss and uncertain receipt. General fleet
131fault/recovery, electrical/measured endurance and physical/operational qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
130132
131133## Agriculture sensing
132134
app/Commands/CargoFleet.tsadded+25-0
Changes to app/Commands/CargoFleet.ts
@@ -0,0 +1,25 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { cargoFleetScenarios, cargoFleetSummary, simulateCargoFleet } from '../../resources/functions/drone/cargo/fleet'
4
5export default defineCommand({
6 name: 'cargo:fleet',
7 description: 'Run two owned modeled carriers through one finite station and captured intake, without physical dispatch',
8 options: {
9 '--scenario <name>': { description: cargoFleetScenarios.join(', '), default: 'nominal' },
10 },
11 async handle(options) {
12 try {
13 const summary = cargoFleetSummary(simulateCargoFleet({ scenario: options.scenario }))
14 if (summary.status !== 'completed') process.exitCode = 2
15 const wire = JSON.stringify(summary)
16 if (Buffer.byteLength(wire) + 1 > 16384) throw new RangeError('cargo-fleet-output-limit')
17 await printAsync(wire)
18 }
19 catch {
20 process.exitCode = 1
21 try { await printErrorAsync('Cargo fleet simulation unavailable: invalid scenario, execution or output failure') }
22 catch { /* A closed diagnostic pipe does not make the failed command succeed. */ }
23 }
24 },
25})
bin/drone.tsmodified+2-0
Changes to bin/drone.ts
@@ -13,6 +13,7 @@ import ControlsSimulate from '../app/Commands/ControlsSimulate'
1313import CargoLoadBench from '../app/Commands/CargoLoadBench'
1414import CargoDeliver from '../app/Commands/CargoDeliver'
1515import CargoMission from '../app/Commands/CargoMission'
16import CargoFleet from '../app/Commands/CargoFleet'
1617
1718import PayloadsBench from '../app/Commands/PayloadsBench'
1819import PayloadsCapture from '../app/Commands/PayloadsCapture'
@@ -96,6 +97,7 @@ ControlsSimulate(app)
9697CargoLoadBench(app)
9798CargoDeliver(app)
9899CargoMission(app)
100CargoFleet(app)
99101AuthoritySimulate(app)
100102DriversBench(app)
101103PayloadsBench(app)
config/docs.tsmodified+1-0
Changes to config/docs.ts
@@ -110,6 +110,7 @@ const config: BunPressOptions = {
110110 { text: 'Simulated carried-load characterization', link: '/cargo-load' },
111111 { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' },
112112 { text: 'Modeled cargo station handling', link: '/cargo-station' },
113 { text: 'Two-carrier modeled cargo handover', link: '/cargo-fleet' },
113114 { text: 'Simulated POI camera reframe', link: '/cinema' },
114115 ],
115116 },
docs/api.mdmodified+5-0
Changes to docs/api.md
@@ -67,6 +67,11 @@ through ballistic capture, finite rail transport, cancellation braking and a sin
6767departure. See the [station model and integration limits](cargo-station.md). It does not
6868grant vehicle authority or confirm custody.
6969
70`simulateCargoFleet({ scenario? })` and `cargoFleetSummary(run)` in `cargo/fleet.ts`
71execute [two-carrier handover](cargo-fleet.md) through actual retained source and receiver
72runtimes, a consumed station parcel, current-authority intake and captured custody. The
73six bounded cases retain incomplete outcomes and actual mass separately from receipts.
74
7075## Target sources
7176
7277`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/cargo-delivery.mdmodified+4-4
Changes to docs/cargo-delivery.md
@@ -214,10 +214,10 @@ blocks rather than reviving the original command.
214214`tests/unit/cargo-receive-mission.test.ts` covers the native task and an actually empty
215215receiving body in its own raw-sensor, estimator and authority runtime. With ACK withheld,
216216it stays at the declared pad with zero actual motor thrust and no phantom parcel mass.
217The port that verifies an actual point intake and emits captured custody evidence is
218still unimplemented. A caller-invented ACK is not proof of mechanical transfer. The
219two-vehicle coordinator, fresh mechanism authority, cancellation/uncertainty matrix and
220bounded fleet CLI remain open under #25.
217A caller-invented ACK is not proof of mechanical transfer. The
218[two-carrier model](cargo-fleet.md) now binds its receipt port to actual intake, same-epoch
219receiver authority and six bounded fleet cases through `cargo:fleet`. Its broader
220cancellation, fault/recovery and operational matrix remains open under #25.
221221
222222## BVLOS operations evidence
223223
docs/cargo-fleet.mdadded+117-0
Changes to docs/cargo-fleet.md
@@ -0,0 +1,117 @@
1# Two-carrier modeled cargo handover
2
3`simulateCargoFleet()` in `resources/functions/drone/cargo/fleet.ts` runs two actual
4retained vehicle sessions on the same 4 ms clock. One source point parcel falls onto
5a station, follows a finite powered rail, enters an initially empty receiving body,
6and then flies the receiver's own delivery and return route.
7
8This is a bounded fictional simulation, with physical outputs inhibited. It does not
9qualify an aircraft, station, recipient identity, electrical endurance or BVLOS service.
10The [single-carrier guide](cargo-delivery.md) retains the wider operations evidence case.
11
12## Native command and exit status
13
14```sh
15./buddy cargo:fleet
16./buddy cargo:fleet --scenario receiver-primary-link-loss
17./buddy cargo:fleet --scenario receiver-both-links-loss
18./buddy cargo:fleet --scenario receiver-host-loss-before-intake
19./buddy cargo:fleet --scenario intake-receipt-loss
20./buddy cargo:fleet --scenario receiver-joint-shift-before-intake
21```
22
23The command emits one JSON line, at most 16 KiB. Exit 0 means the modeled routes and
24captured custody completed. Exit 2 retains an incomplete modeled outcome. Exit 1 means
25invalid input, execution or output failure. The standalone `drone cargo:fleet` command
26uses the same native Stacks definition. `cargoFleetSummary(run)` projects observed
27results without serializing the full runtime journals.
28
29The library accepts only an optional own-data `scenario` declaration from this finite
30set. It does not yet accept arbitrary fleet geometry, vehicle count or hardware transports.
31
32## One world, two authority owners
33
34Carrier A uses raw boot 101 and authority session 201. Carrier B uses raw boot 102 and
35session 202. Both use the fixed synthetic WGS84 origin 0, 0, 100 and LOCAL_NED, with down
36positive. These are distinct model identities, not authenticated identities or distributed
37clock qualification. Each retains its own raw IMU/GNSS pipeline, estimator, mission,
38controller, output watchdog and primary/backup radio observer.
39
40A begins at [0, 0, -0.1] m with a 0.1 kg body-forward point at 0.08 m. B begins at
41[3, 2, -0.1] m with a declared empty joint. The first site is [3, 0, 0] m and the final
42recipient site is [6, 2, 0] m, each with a 0.35 m horizontal acceptance disc. Both bodies
43use the declared flat -0.1 m ground datum; the parcel capture plane is down 0. No landing
44gear, collision/contact geometry, wind/weather envelope or surveyed field is inferred.
45
46The receiving task holds its declared pad with neutral motor commands from its first
47frame. It does not preload mass. Flight policy consumes the owned estimator and modeled
48sensor captures. Physical joint geometry is used only inside the modeled mechanism's
49contact operation; it is not supplied as a flight-controller positioning oracle.
50
51## Transfer and custody
52
531. A's existing release coordinator requires current observed site/recipient/load evidence
54 and its own mission scope. The world checks actual same-epoch host availability,
55 supervisor boot/session, current lease, recent policy and pending command before separation.
562. The actual separation event supplies the point to the [station actor](cargo-station.md).
57 Free fall retains the within-step impact epoch, impulse and mechanical loss. Only after
58 A confirms station custody does the rail start toward B's declared current joint.
593. B's intake coordinator requires 26 distinct qualified captures spanning at least 500 ms,
60 its boot/reference, healthy estimator/gyro, conservative pad uncertainty, contact, stopped
61 motors, a clear site and the captured ready-station parcel/source event. It issues one
62 modeled grant, valid for at most 40 ms and within the pending task deadline.
634. On a later 4 ms epoch the mechanism independently checks B's actual host availability,
64 current supervisor boot/session/lease, recent policy, exact pending command and neutral
65 outputs. It consumes the station's departure once and calls actual `attachParcel` at
66 B's current joint. A refused point enters the world's separate loose-parcel actor and continues
67 ballistic motion to the declared capture plane. It is not copied back, repositioned
68 onto the joint or silently retried.
695. A later B sensor capture must report that actual intake event, source event, mass and
70 mechanism epoch. Its capture pose and command scope produce the receipt ACK. Requesting
71 or granting intake is not confirmation. Only then does B take off with changed mass/CG,
72 release the point at the final site and complete captured recipient custody and landing.
73
74The grant's session declaration alone cannot establish authority. The physical-world
75check uses the live retained runtime at the actual operation epoch. Host loss can leave
76an old supervisor snapshot saying `autonomous`; it still blocks the intake mechanism.
77
78Physical ownership and captured custody are separate records. The world mass ledger reads
79the actual body/actor masses after transitions and at 20 ms intervals. It retains one
800.1 kg point and 3.1 kg total mass, with reported maximum numerical error. Historical
81separation/departure records are evidence copies, not additional physical owners. The
82station reports mechanical work; each vehicle's reserve remains the shared longest-path
83**declared power budget**, not measured Wh or an electrical model.
84
85## Bounded model cases
86
87| Scenario | Expected result | Parcel and authority outcome |
88| --- | --- | --- |
89| `nominal` | Completed | A to station to B to final parcel, with three separate captured custody stages |
90| `receiver-primary-link-loss` | Completed | Receiver primary stops during station handling; its independently captured backup preserves authority |
91| `receiver-both-links-loss` | Incomplete | Receiver link freshness expires and authority fails; finite station braking retains the parcel while A continues |
92| `receiver-host-loss-before-intake` | Incomplete | Host stops 4 ms after the actual grant; no physical intake, independent board expiry and station retention |
93| `receiver-joint-shift-before-intake` | Incomplete | A 0.4 N force for one 4 ms step shifts the actual joint before intake; strict coincidence refuses it, the loose point falls, and B lands its counted alternate empty |
94| `intake-receipt-loss` | Incomplete | Actual intake occurs but its receipt fields are withheld; B retains the mass and lands its counted alternate without claiming custody or making a second delivery |
95
96Radio observations retain capture, receipt, sequence and scope, with 1 ms clock uncertainty
97and 20/40 ms primary/backup delays. Failure stops new packets rather than restamping a
98connected fixture. Link loss takes effect through the declared freshness contract.
99Vehicle host termination retains a 1,000 ms independent board/plant tail. The other peer
100continues its own mission. Both routes and their failure alternatives count toward the
101native finite graph/reserve budget; the world is bounded by the 180 s host limit and tails.
102
103## Evidence and remaining work
104
105`tests/unit/cargo-fleet.test.ts` exercises actual two-session physics, intake mass changes,
106source/receiver scopes, same-epoch admission, captured receipt ordering, retained mass,
107backup continuation, both-link braking, the host/grant gap and uncertain loaded diversion.
108`tests/unit/cargo-fleet-cli.test.ts` executes the native Buddy command and its bounded wire.
109`scripts/qualify-cargo-fleet-cli.ts` exercises all six cases through a copied standalone
110binary outside the checkout and retains output hashes and factual outcomes.
111
112These cases do not cover a complete fleet fault envelope. Receiver/source kill or manual
113intervention at every phase, navigation degradation and containment across the transfer,
114late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable
115whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning
116remain open under [#25](https://github.com/stacksjs/drone/issues/25) and its dependencies.
117The wider measured hardware, endurance and operational evidence requirements remain open.
docs/cargo-station.mdmodified+3-2
Changes to docs/cargo-station.md
@@ -104,5 +104,6 @@ This actor does not grant a mechanism lease, authenticate a recipient, produce a
104104ACK or coordinate two live vehicle mission engines. Those operations must use each
105105vehicle's own sensor, estimator and current authority scope on the common clock. The
106106[receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds
107a declared pad under the shared engine. Its physical-intake acknowledgement port, the
108two-vehicle custody protocol, cancellation/uncertainty matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25).
107a declared pad under the shared engine. The [two-carrier world and CLI](cargo-fleet.md) now connect physical intake, current
108authority and captured acknowledgement for six bounded cases. A complete cancellation,
109fault/recovery and general fleet matrix remains open under [#25](https://github.com/stacksjs/drone/issues/25).
docs/index.mdmodified+1-1
Changes to docs/index.md
@@ -84,7 +84,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa
8484| Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) |
8585| Public safety | Synthetic incident collection, authenticated records, review and local dataset management | [Public safety](public-safety.md) |
8686| Patrol and docking | Retained modeled vehicle, explicit decisions, finite recurrence and charge opt-in | [Patrol CLI](patrol-cli.md) |
87| Cargo and cinema | Configured modeled delivery, captured release/custody, alternates and redundant links; fleet handover remains open. Separate carried-load and POI reframe experiments | [Cargo delivery](cargo-delivery.md), [load bench](cargo-load.md), [cinema](cinema.md) |
87| Cargo and cinema | Configured modeled delivery, captured release/custody, alternates and redundant links; a bounded two-carrier handover with wider fleet work open. Separate carried-load and POI reframe experiments | [Cargo delivery](cargo-delivery.md), [fleet model](cargo-fleet.md), [load bench](cargo-load.md), [cinema](cinema.md) |
8888| Environmental sampling and antenna pointing | Synthetic sampling and a selected-vehicle mount model | [Environment](environment.md), [antenna](antenna.md) |
8989
9090Each guide distinguishes implemented behavior, recorded evidence and remaining
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is
2929| Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) |
3030| SAR, fire/disaster support and authorized police observation | Owned collection/runtime, authenticated incident roles, source-bound annotations/review, private datasets, recipient-confirmed handover, authorized workspace with live synthetic execution and deliberate source admission, and separately accepted default-disabled local purge | Sector/team responsibility and full physical/operational qualification remain open | [#23](https://github.com/stacksjs/drone/issues/23) |
3131| Perimeter patrol, scheduling, docks and charging | Partial: retained simulation, selected-pad contact, explicit partial charging, observed isolation, finite native recurrence, bounded input/summary replay and owner-only authenticated review/consumption metadata | `patrol:simulate`, `patrol:replay` with fresh consent and [four explicit review endpoints](../patrol-review.md); production scheduling, runtime handoff/recovery, weather and physical docking remain open | [#24](https://github.com/stacksjs/drone/issues/24) |
32| Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; distinct-vehicle fleet handover and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) |
32| Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; bounded two-carrier station/intake handover with captured custody and six model cases; general fleet fault/recovery and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md), plus the bounded [two-carrier model](../cargo-fleet.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) |
3333| Gimbal/camera control, smooth paths and broadcast timecode | Partial: owned hover POI angular reframe, lagged/quantized gimbal, exposure-bound modeled stills and latched target/camera/authority faults; translational paths, physical integration, lens/video and handoff remain open | Native `cinema:simulate` [simulation semantics and limits](../cinema.md); no physical/broadcast timing qualification | [#26](https://github.com/stacksjs/drone/issues/26) |
3434| Environment, wildlife, anti-poaching observation and atmospheric samples | Partial: owned two-height synthetic cell sampling, calibration/geotags, conditional uncertainty, collection-policy faults and unsigned five-file provenance/export; physical samples, wildlife/authority and actual geofence qualification remain open | Native `environment:simulate` [semantics and limits](../environment.md); human review required | [#27](https://github.com/stacksjs/drone/issues/27) |
3535| Deep feature audit, refresh and conformance gates | Pinned directory accounting and sampled behavior audit: 89 cases, including ten transport and ten camera-feedback/capture gaps; deeper review and implementation remain open | [Audit integrity and source provenance](../parity.md); 26 bounded claims, no general parity claim | [#28](https://github.com/stacksjs/drone/issues/28) |
resources/functions/drone/cargo/fleet.tsadded+237-0
Changes to resources/functions/drone/cargo/fleet.ts
@@ -0,0 +1,237 @@
1import type { SensorVector } from '../sensors/types'
2import type { PointLoadAttachment } from '../controls/physics'
3import type { VehiclePayloadCommand, VehiclePayloadPort, VehiclePlant, VehicleMissionPlan, VehicleSession } from '../vehicles/types'
4import type { ModeledCargoIntakeGrant } from './intake'
5import { createRigidBody, createReceivingRigidBody } from '../controls/physics'
6import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile'
7import { createRedundantVehicleLinks } from '../vehicles/links'
8import type { VehicleLinkScope, VehicleLinkSource } from '../vehicles/links'
9import { createVehicleSimulationSession } from '../vehicles/runtime'
10import { createCargoStationHandler } from './station'
11import { createCargoDeliveryCoordinator } from './coordinator'
12import { createCargoIntakeCoordinator } from './intake'
13import { planCargoDelivery } from './plan'
14
15type Progress = ReturnType<ReturnType<typeof createVehicleSimulationSession>['step']>
16type Station = ReturnType<typeof createCargoStationHandler>
17type Release = ReturnType<ReturnType<typeof createReceivingRigidBody>['separateCarriedLoad']>
18type Departure = Extract<ReturnType<Station['takeReadyParcel']>, { outcome: 'departed' }>
19type Owner = 'source-body' | 'station-actor' | 'receiver-body' | 'final-parcel-actor' | 'loose-parcel'
20export const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake'] as const
21export type CargoFleetScenario = typeof cargoFleetScenarios[number]
22function captureScenario(input: unknown): CargoFleetScenario {
23 try {
24 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario')) throw new TypeError()
25 const d = Object.getOwnPropertyDescriptor(input, 'scenario')
26 if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoFleetScenarios.some(name => name === d.value))) throw new TypeError()
27 return (d?.value ?? 'nominal') as CargoFleetScenario
28 }
29 catch { throw new TypeError('cargo-fleet-arguments') }
30}
31const origin = { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }
32const point = { id: 'parcel-a', massKg: .1, forwardM: .08 }
33const receiverPadM: SensorVector = [3, 2, -.1]
34const stationSite = { id: 'handover-station', recipientId: 'station-operator', containerId: point.id, positionM: [3, 0, 0] as SensorVector, radiusM: .35 }
35const finalSite = { id: 'final-site', recipientId: 'final-recipient', containerId: point.id, positionM: [6, 2, 0] as SensorVector, radiusM: .35 }
36function receiverMission(): VehicleMissionPlan {
37 const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 }
38 return { version: 2, vehicle: 'quadcopter', id: 'cargo-fleet-receiver', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE', origin, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [3, 2, -1], power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [
39 { id: 'start', kind: 'start', next: 'intake' },
40 { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: point.id, positionM: [...receiverPadM], timeoutMs: 60000, next: 'depart', onFailure: 'alternate-approach' },
41 { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'site' },
42 { id: 'site', kind: 'waypoint', positionM: [6, 2, -1], ...spatial, settleMs: 800, next: 'release' },
43 { id: 'release', kind: 'payload', operation: 'material-drop', containerId: point.id, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' },
44 { id: 'home', kind: 'return', ...spatial, next: 'land' },
45 { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial },
46 { id: 'alternate-approach', kind: 'waypoint', positionM: [5, 3, -1], ...spatial, next: 'alternate-land' },
47 { id: 'alternate-land', kind: 'land', positionM: [5, 3, 0], ...spatial },
48 ] }
49}
50function hasAuthority(p: Progress | null, command: VehiclePayloadCommand, nowMs: number, bootId: bigint, sessionId: bigint) {
51 return !!p && p.atMs === nowMs && p.hostAvailable && !p.terminal && p.phase === 'host' && p.policyAtMs !== null && p.policyAtMs <= nowMs && nowMs - p.policyAtMs < 20
52 && p.reference === command.reference && p.authority.bootId === bootId && p.authority.sessionId === sessionId && p.authority.state === 'autonomous'
53 && p.authority.lastCheckedAtMs !== null && nowMs - p.authority.lastCheckedAtMs < 20 && p.authority.leaseUntilMs !== null && p.authority.leaseUntilMs > nowMs && p.board.fault === null
54 && p.mission.state === 'running' && p.mission.payload?.eventId === command.eventId && JSON.stringify(p.mission.payload) === JSON.stringify(command) && command.expiresAtMs > nowMs
55}
56
57/** One modeled parcel, two retained raw-sensor/estimator/authority runtimes, and finite station physics. */
58export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
59 if (extra.length) throw new TypeError('cargo-fleet-arguments')
60 const scenario = captureScenario(input)
61 const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null }
62 const bodyA = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: point })
63 const bodyB = createReceivingRigidBody({ positionM: [...receiverPadM], groundDownM: -.1, expectedPointLoad: point })
64 const initialA = bodyA.loadSnapshot(), initialB = bodyB.loadSnapshot()
65 let station: Station | null = null, recipient: Station | null = null, loose: Station | null = null
66 const physical: { owner: Owner } = { owner: 'source-body' }
67 let maximumMassErrorKg = 0, massSamples = 0
68 const transitions: { atMs: number, from: Owner, to: Owner }[] = []
69 let sourceRelease: { atMs: number, eventId: string, event: Release } | null = null, finalRelease: { atMs: number, eventId: string, event: Release } | null = null
70 let stationDeparture: Departure | null = null
71 let intake: { atMs: number, grant: ModeledCargoIntakeGrant, attachment: PointLoadAttachment, source: Progress, receiver: Progress } | null = null
72 let intakeEvidence: { atMs: number, eventId: string, sourceEventId: string, outcome: string, massKg: number } | null = null
73 const commands: { source: VehiclePayloadCommand | null, receiver: VehiclePayloadCommand | null, intake: ModeledCargoIntakeGrant | null } = { source: null, receiver: null, intake: null }
74 let progressA: Progress | null = null, progressB: Progress | null = null
75 const sourceCoordinator = createCargoDeliveryCoordinator({ origin, site: stationSite, releaseHeightM: 1 }, {
76 evidence: 'modeled-cargo-mechanism', request(command) { commands.source = structuredClone(command) }, cancel(eventId) { if (commands.source?.eventId === eventId) commands.source = null },
77 })
78 const deliveryCoordinator = createCargoDeliveryCoordinator({ origin, site: finalSite, releaseHeightM: 1 }, {
79 evidence: 'modeled-cargo-mechanism', request(command) { commands.receiver = structuredClone(command) }, cancel(eventId) { if (commands.receiver?.eventId === eventId) commands.receiver = null },
80 })
81 const intakeCoordinator = createCargoIntakeCoordinator({ origin, padM: receiverPadM, receiverId: 'carrier-b', containerId: point.id, massKg: point.massKg, bootId: 102n, sessionId: 202n }, {
82 evidence: 'modeled-cargo-intake-mechanism', request(grant) { commands.intake = structuredClone(grant); if (faultInjection && ['receiver-host-loss-before-intake', 'receiver-joint-shift-before-intake'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs = grant.issuedAtMs }, cancel(eventId) { if (commands.intake?.command.eventId === eventId) commands.intake = null },
83 })
84 let activeBPort: VehiclePayloadPort | null = null
85 const receiverPort: VehiclePayloadPort = {
86 observe(c) { intakeCoordinator.port.observe!(c); deliveryCoordinator.port.observe!(c) },
87 request(command) { activeBPort = command.operation === 'material-receive' ? intakeCoordinator.port : deliveryCoordinator.port; activeBPort.request(command) },
88 poll(nowMs) { const ack = activeBPort?.poll(nowMs) ?? null; if (ack) activeBPort = null; return ack },
89 close() { activeBPort = null; intakeCoordinator.port.close(); deliveryCoordinator.port.close() },
90 }
91 function linksFor(scope: VehicleLinkScope, receiver: boolean) {
92 const monitor = createRedundantVehicleLinks(scope), queue: { sourceId: VehicleLinkSource, sequence: number, capturedAtMs: number, receivedAtMs: number }[] = []
93 let sequence = 0, last = monitor.snapshot(0)
94 const events: typeof last[] = []
95 return {
96 observe(nowMs: number) {
97 sequence++
98 if (receiver && faultInjection && ['receiver-primary-link-loss', 'receiver-both-links-loss'].includes(faultInjection.scenario) && faultInjection.anchoredAtMs === null && station?.snapshot().phase === 'held') faultInjection.anchoredAtMs = nowMs
99 for (const sourceId of ['primary', 'backup'] as const) {
100 if (receiver && faultInjection && ['receiver-primary-link-loss', 'receiver-both-links-loss'].includes(faultInjection.scenario) && (sourceId === 'primary' || faultInjection.scenario === 'receiver-both-links-loss') && faultInjection.anchoredAtMs !== null && nowMs >= faultInjection.anchoredAtMs) { faultInjection.triggeredAtMs ??= nowMs; continue }
101 if (queue.length >= 8) throw new RangeError('cargo-fleet-link-queue')
102 queue.push({ sourceId, sequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) })
103 }
104 queue.sort((a, b) => a.receivedAtMs - b.receivedAtMs)
105 while (queue[0] && queue[0].receivedAtMs <= nowMs) monitor.ingest({ ...scope, ...queue.shift()!, clockUncertaintyMs: 1, connected: true }, nowMs)
106 last = monitor.snapshot(nowMs)
107 const previous = events.at(-1)
108 if (!previous || last.selectedSource !== previous.selectedSource || last.latchedLoss !== previous.latchedLoss) { if (events.length >= 16) throw new RangeError('cargo-fleet-link-events'); events.push(structuredClone(last)) }
109 return { ...last.authority }
110 },
111 snapshot: () => structuredClone(last), events: () => structuredClone(events),
112 }
113 }
114 const linksA = linksFor({ vehicleId: 'carrier-a', bootId: 101, sessionId: 201 }, false), linksB = linksFor({ vehicleId: 'carrier-b', bootId: 102, sessionId: 202 }, true)
115 const profileA = createMultirotorProfile(), profileB = createMultirotorProfile(), createA = profileA.createSession, createB = profileB.createSession
116 profileA.mission = planCargoDelivery({ version: 1, id: 'cargo-fleet-source', delivery: { origin, site: stationSite, releaseHeightM: 1 }, load: { massKg: point.massKg, forwardM: point.forwardM }, alternateM: [1.5, -2, 0], power: { availableWh: 40, maxW: 600, reservePercent: 30 } }).mission
117 profileB.mission = receiverMission()
118 const baseFaultsB = profileB.faults
119 profileB.scenario = scenario; profileB.scenarios = [scenario]
120 profileB.faults = (selected, atMs, phase) => {
121 if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed')
122 const active = faultInjection?.scenario === 'receiver-host-loss-before-intake' && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 4
123 if (active && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
124 return { ...baseFaultsB('nominal', atMs, phase), hostLost: active }
125 }
126 const plantA = createMultirotorPlant(bodyA), adaptedB = createMultirotorPlant(bodyB)
127 let receiverPhysicalAtMs = 600
128 const plantB: VehiclePlant = { snapshot: adaptedB.snapshot, step(actuators, dtS, environment) {
129 if (dtS !== .004) throw new RangeError('cargo-fleet-receiver-step')
130 receiverPhysicalAtMs += 4
131 const shift = faultInjection?.scenario === 'receiver-joint-shift-before-intake' && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs === faultInjection.anchoredAtMs + 4
132 if (shift) faultInjection!.triggeredAtMs = receiverPhysicalAtMs
133 bodyB.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps, ...(shift ? { forceN: [.4, 0, 0] as SensorVector } : {}) })
134 return adaptedB.snapshot()
135 } }
136 function releaseValues(which: 'source' | 'receiver') {
137 const released = which === 'source' ? sourceRelease : finalRelease, target = which === 'source' ? station : recipient, site = which === 'source' ? stationSite : finalSite
138 const impact = target?.snapshot().impact
139 const captured = !!impact && impact.positionM[2] === site.positionM[2] && Math.hypot(impact.positionM[0] - site.positionM[0], impact.positionM[1] - site.positionM[1]) <= site.radiusM
140 return { siteId: site.id, recipientId: site.recipientId, siteClear: true, recipientPresent: true, loadAttached: which === 'source' ? bodyA.loadSnapshot().properties.phase === 'attached' : bodyB.loadSnapshot().properties.phase === 'attached', mechanismOpen: released?.event.outcome === 'separated', mechanismJammed: false, mechanismEventId: released?.eventId ?? '', receiptEventId: captured ? released?.eventId ?? '' : '', receiptContainerId: point.id }
141 }
142 profileA.createSession = (): VehicleSession => {
143 const base = createA()
144 return { ...base, linkObservation: linksA.observe, createPlant: () => plantA, createPayload: () => sourceCoordinator.port, renderMeasurement(state, atMs, reference) { const packet = base.renderMeasurement(state, atMs, reference); return { ...packet, values: { ...packet.values, ...releaseValues('source') } } } }
145 }
146 profileB.createSession = (): VehicleSession => {
147 const base = createB()
148 return { ...base, linkObservation: linksB.observe, createPlant: () => plantB, createPayload: () => receiverPort, renderMeasurement(state, atMs, reference) {
149 const packet = base.renderMeasurement(state, atMs, reference)
150 const suppressReceipt = faultInjection?.scenario === 'intake-receipt-loss' && intakeEvidence !== null
151 if (suppressReceipt) faultInjection!.triggeredAtMs ??= atMs
152 const evidence = suppressReceipt ? null : intakeEvidence
153 return { ...packet, values: { ...packet.values, ...releaseValues('receiver'), receiverId: 'carrier-b', stationReady: station?.snapshot().phase === 'ready', stationContainerId: point.id, stationSourceEventId: sourceRelease?.eventId ?? '',
154 intakeAtMs: evidence?.atMs ?? -1, intakeEventId: evidence?.eventId ?? '', intakeSourceEventId: evidence?.sourceEventId ?? '', intakeOutcome: evidence?.outcome ?? '', intakeMassKg: evidence?.massKg ?? 0 } }
155 } }
156 }
157 const a = createVehicleSimulationSession(profileA, { seconds: 180, neutralTailMs: 1000, scope: { bootId: 101n, sessionId: 201n } })
158 const b = createVehicleSimulationSession(profileB, { seconds: 180, neutralTailMs: 1000, scope: { bootId: 102n, sessionId: 202n } })
159 function changeOwner(next: Owner, nowMs: number) { transitions.push({ atMs: nowMs, from: physical.owner, to: next }); physical.owner = next }
160 function totalMass() { return bodyA.loadSnapshot().properties.totalMassKg + bodyB.loadSnapshot().properties.totalMassKg + (station?.snapshot().ownedMassKg ?? 0) + (recipient?.snapshot().ownedMassKg ?? 0) + (loose?.snapshot().ownedMassKg ?? 0) }
161 function sampleMass() { maximumMassErrorKg = Math.max(maximumMassErrorKg, Math.abs(totalMass() - 3.1)); massSamples++ }
162 sampleMass()
163 try {
164 for (let nowMs = 0; nowMs <= 181000; nowMs += 4) {
165 if (!progressA?.terminal) progressA = a.step(nowMs)
166 if (!progressB?.terminal) progressB = b.step(nowMs)
167 if (station && station.snapshot().atMs < nowMs) station.step(nowMs)
168 if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs)
169 if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs)
170 if (commands.source && nowMs > commands.source.issuedAtMs) {
171 const command = commands.source; commands.source = null
172 if (physical.owner === 'source-body' && hasAuthority(progressA, command, nowMs, 101n, 201n)) {
173 const event = bodyA.separateCarriedLoad(); sourceRelease = { atMs: nowMs, eventId: command.eventId, event }
174 if (event.outcome === 'separated') {
175 station = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: stationSite.positionM, radiusM: stationSite.radiusM }, destinationM: bodyB.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
176 changeOwner('station-actor', nowMs); sampleMass()
177 }
178 }
179 }
180 if (station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) station.start()
181 if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel()
182 if (commands.intake && nowMs > commands.intake.issuedAtMs) {
183 const grant = commands.intake; commands.intake = null
184 if (physical.owner === 'station-actor' && station?.snapshot().phase === 'ready' && sourceRelease?.eventId === grant.sourceEventId && grant.receiverId === 'carrier-b' && grant.bootId === 102n && grant.sessionId === 202n && nowMs < grant.expiresAtMs
185 && hasAuthority(progressB, grant.command, nowMs, grant.bootId, grant.sessionId) && progressB!.board.actuators.every(n => n === 0) && bodyB.snapshot().motorsN.every(n => n < .01)) {
186 const departure = station.takeReadyParcel()
187 if (departure.outcome === 'departed') {
188 stationDeparture = departure
189 const attachment = bodyB.attachParcel(departure.parcel)
190 intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) }
191 intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 }
192 if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection?.scenario === 'intake-receipt-loss') faultInjection.anchoredAtMs = nowMs }
193 else { loose = createCargoStationHandler({ atMs: nowMs, parcel: departure.parcel, pickup: { centerM: [3, 2, 0], radiusM: 2 }, destinationM: [3, 2, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }); changeOwner('loose-parcel', nowMs) }
194 sampleMass()
195 }
196 }
197 }
198 if (commands.receiver && nowMs > commands.receiver.issuedAtMs) {
199 const command = commands.receiver; commands.receiver = null
200 if (physical.owner === 'receiver-body' && hasAuthority(progressB, command, nowMs, 102n, 202n)) {
201 const event = bodyB.separateCarriedLoad(); finalRelease = { atMs: nowMs, eventId: command.eventId, event }
202 if (event.outcome === 'separated') {
203 recipient = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: finalSite.positionM, radiusM: finalSite.radiusM }, destinationM: finalSite.positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 })
204 changeOwner('final-parcel-actor', nowMs); sampleMass()
205 }
206 }
207 }
208 if (nowMs % 20 === 0) sampleMass()
209 if (progressA?.terminal && progressB?.terminal) break
210 }
211 const sourceRun = a.result(), receiverRun = b.result(), sourceDelivery = sourceCoordinator.snapshot().delivery, receiverDelivery = deliveryCoordinator.snapshot().delivery, receiverIntake = intakeCoordinator.snapshot()
212 const completed = sourceRun.status === 'completed' && receiverRun.status === 'completed' && sourceDelivery.outcome === 'received' && receiverIntake.custody.outcome === 'received' && receiverDelivery.outcome === 'received' && physical.owner === 'final-parcel-actor' && recipient?.snapshot().phase === 'held' && maximumMassErrorKg < 1e-12
213 return { version: 1, evidence: 'owned-cargo-fleet-simulation' as const, scenario, faultInjection, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const, status: completed ? 'completed' as const : 'unqualified' as const,
214 source: { links: linksA.snapshot(), linkEvents: linksA.events(), initialLoad: initialA, finalLoad: bodyA.loadSnapshot(), runtime: sourceRun, delivery: sourceDelivery }, receiver: { links: linksB.snapshot(), linkEvents: linksB.events(), initialLoad: initialB, finalLoad: bodyB.loadSnapshot(), runtime: receiverRun, intake: receiverIntake, delivery: receiverDelivery },
215 transfer: { sourceRelease, stationDeparture, intake, finalRelease }, station: station?.snapshot() ?? null, recipient: recipient?.snapshot() ?? null, loose: loose?.snapshot() ?? null, ownership: { owner: physical.owner, initialMassKg: 3.1, finalMassKg: totalMass(), maximumMassErrorKg, samples: massSamples, transitions } }
216 }
217 finally { a.close(); b.close() }
218}
219
220/** Compact actual observations for Buddy/standalone output; never serialize the full runtime journal. */
221export function cargoFleetSummary(run: ReturnType<typeof simulateCargoFleet>) {
222 function vehicle(runtime: typeof run.source.runtime) {
223 const power = runtime.trace.plan.power, usableWh = power.availableWh * (1 - power.reservePercent / 100)
224 return { scope: { bootId: String(runtime.board.bootId), sessionId: String(runtime.authority.sessionId) }, status: runtime.status, fault: runtime.fault, missionState: runtime.mission.state, missionReason: runtime.mission.reason, payloadFailure: runtime.mission.payloadFailure ?? null,
225 finalPositionM: runtime.finalTruthPositionM, sensors: runtime.navigationEvidence.observedSensors, board: { physicalActuationEnabled: runtime.board.physicalActuationEnabled, actuators: runtime.board.actuators, fault: runtime.board.fault, expiresAtMs: runtime.board.expiresAtMs },
226 energy: { evidence: 'declared-mission-power-budget', measured: false, electricalModel: false, availableWh: power.availableWh, maxW: power.maxW, reservePercent: power.reservePercent, worstCaseMs: runtime.budget.worstCaseMs, requiredWh: runtime.budget.requiredWh, marginAboveReserveWh: usableWh - runtime.budget.requiredWh } }
227 }
228 const capturedIntake = run.transfer.intake, attachment = capturedIntake?.attachment
229 const release = (event: typeof run.transfer.sourceRelease) => event ? { atMs: event.atMs, eventId: event.eventId, outcome: event.event.outcome } : null
230 return { version: run.version, evidence: run.evidence, scenario: run.scenario, status: run.status, physicalFlightEnabled: run.physicalFlightEnabled, boardOutputsInhibited: run.boardOutputsInhibited, identityAuthenticated: run.identityAuthenticated,
231 faultInjection: run.faultInjection, source: { links: run.source.links, linkEvents: run.source.linkEvents, ...vehicle(run.source.runtime), initialLoad: run.source.initialLoad.properties, finalLoad: run.source.finalLoad.properties, delivery: run.source.delivery },
232 receiver: { links: run.receiver.links, linkEvents: run.receiver.linkEvents, ...vehicle(run.receiver.runtime), initialLoad: run.receiver.initialLoad.properties, finalLoad: run.receiver.finalLoad.properties, intake: run.receiver.intake.custody, delivery: run.receiver.delivery },
233 transfer: { sourceRelease: release(run.transfer.sourceRelease), stationDeparture: run.transfer.stationDeparture, intake: capturedIntake ? { atMs: capturedIntake.atMs, outcome: attachment!.outcome, eventId: capturedIntake.grant.command.eventId, sourceEventId: capturedIntake.grant.sourceEventId,
234 receiverScope: { bootId: String(capturedIntake.receiver.authority.bootId), sessionId: String(capturedIntake.receiver.authority.sessionId), epoch: capturedIntake.receiver.authority.epoch, reference: capturedIntake.receiver.reference, policyAtMs: capturedIntake.receiver.policyAtMs },
235 massBeforeKg: attachment!.outcome === 'attached' ? attachment!.before.properties.totalMassKg : null, massAfterKg: attachment!.outcome === 'attached' ? attachment!.after.properties.totalMassKg : null } : null, finalRelease: release(run.transfer.finalRelease) },
236 station: run.station, recipient: run.recipient, loose: run.loose, ownership: run.ownership }
237}
resources/functions/drone/cargo/intake.tsadded+132-0
Changes to resources/functions/drone/cargo/intake.ts
@@ -0,0 +1,132 @@
1import type { GeodeticOrigin } from '../estimation/frames'
2import type { SensorVector } from '../sensors/types'
3import type { VehicleControllerContext, VehiclePayloadCommand, VehiclePayloadPort, VehiclePayloadAck } from '../vehicles/types'
4import { createVehicleMeasurementQualifier, vehicleReference } from '../vehicles/measurements'
5
6interface IntakeDeclaration { origin: GeodeticOrigin, padM: SensorVector, receiverId: string, containerId: string, massKg: number, bootId: bigint, sessionId: bigint }
7export interface ModeledCargoIntakeGrant { evidence: 'modeled-cargo-intake-grant-v1', receiverId: string, bootId: bigint, sessionId: bigint, command: VehiclePayloadCommand, sourceEventId: string, issuedAtMs: number, expiresAtMs: number }
8export interface ModeledCargoIntakeMechanism { evidence: 'modeled-cargo-intake-mechanism', request(grant: ModeledCargoIntakeGrant): void, cancel(eventId: string): void }
9const finiteTuple = (v: unknown, size: number): v is number[] => Array.isArray(v) && v.length === size && Array.from({ length: size }, (_, i) => i).every(i => Object.hasOwn(v, i) && typeof v[i] === 'number' && Number.isFinite(v[i]))
10function record(input: unknown, fields: string[]) {
11 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== fields.length) throw new TypeError()
12 const result: Record<string, unknown> = {}
13 for (const field of fields) { const d = Object.getOwnPropertyDescriptor(input, field); if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError(); result[field] = d.value }
14 return result
15}
16function capture(input: unknown): IntakeDeclaration {
17 try {
18 const v = record(input, ['origin', 'padM', 'receiverId', 'containerId', 'massKg', 'bootId', 'sessionId']), o = record(v.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM'])
19 if (o.latitudeDeg !== 0 || o.longitudeDeg !== 0 || o.ellipsoidHeightM !== 100 || !['receiverId', 'containerId'].every(key => typeof v[key] === 'string' && /^[a-z][a-z0-9-]{0,39}$/.test(v[key] as string))
20 || typeof v.massKg !== 'number' || !Number.isFinite(v.massKg) || v.massKg <= 0 || v.massKg > .25 || ![v.bootId, v.sessionId].every(n => typeof n === 'bigint' && n > 0n && n <= 0xffffffffffffffffn)) throw new TypeError()
21 if (!Array.isArray(v.padM) || Reflect.ownKeys(v.padM).length !== 4) throw new TypeError()
22 const padM = [0, 1, 2].map(i => { const d = Object.getOwnPropertyDescriptor(v.padM, String(i)); if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || Math.abs(d.value) > 12) throw new TypeError(); return d.value }) as SensorVector
23 if (padM[2] < -1 || padM[2] > 0) throw new TypeError()
24 return { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, padM, receiverId: v.receiverId as string, containerId: v.containerId as string, massKg: v.massKg, bootId: v.bootId as bigint, sessionId: v.sessionId as bigint }
25 }
26 catch { throw new TypeError('cargo-intake-declaration') }
27}
28
29/** Captured receipt policy. The modeled world separately gates every physical intake against live authority. */
30export function createCargoIntakeCoordinator(input: unknown, mechanism: ModeledCargoIntakeMechanism) {
31 const declaration = capture(input)
32 let requestMechanism: ModeledCargoIntakeMechanism['request'], cancelMechanism: ModeledCargoIntakeMechanism['cancel']
33 try {
34 const m = record(mechanism, ['evidence', 'request', 'cancel'])
35 if (m.evidence !== 'modeled-cargo-intake-mechanism' || typeof m.request !== 'function' || typeof m.cancel !== 'function') throw new TypeError()
36 requestMechanism = (m.request as ModeledCargoIntakeMechanism['request']).bind(mechanism)
37 cancelMechanism = (m.cancel as ModeledCargoIntakeMechanism['cancel']).bind(mechanism)
38 }
39 catch { throw new TypeError('cargo-intake-mechanism') }
40 const qualifier = createVehicleMeasurementQualifier('quadcopter')
41 let observed: VehicleControllerContext | null = null, pending: VehiclePayloadCommand | null = null, grant: ModeledCargoIntakeGrant | null = null
42 let closed = false, requested = false, attempted = false, lastNow = -1, lastPoll = -1, lastSequence = -1, captures = 0, sinceMs: number | null = null, sinceCaptureMs: number | null = null
43 let readyReference: string | null = null, sourceEventId: string | null = null
44 const custody: { outcome: 'not-requested' | 'pending' | 'uncertain' | 'received' | 'rejected', requestedAtMs: number | null, grantedAtMs: number | null, mechanismAtMs: number | null, receiptCapturedAtMs: number | null, acknowledgedAtMs: number | null, sourceEventId: string | null, reason: string | null } = { outcome: 'not-requested', requestedAtMs: null, grantedAtMs: null, mechanismAtMs: null, receiptCapturedAtMs: null, acknowledgedAtMs: null, sourceEventId: null, reason: null }
45 function clearReadiness() { captures = 0; sinceMs = null; sinceCaptureMs = null; sourceEventId = null }
46 function cancel() { if (grant) { const eventId = grant.command.eventId; grant = null; try { cancelMechanism(eventId) } catch { custody.reason ??= 'intake-cancel-unavailable' } } }
47 function qualified(c: VehicleControllerContext) {
48 const e = c.estimate, p = e.localPosition, a = e.attitude, m = c.measurement, rate = c.controlRate
49 if (!c.enabled || e.evidence !== 'fixture' || e.physicalFlightEnabled !== false || !e.health.localPosition || !e.health.tilt || e.nowMs !== c.nowMs || !p || !a || !m || !rate
50 || !vehicleReference(e).startsWith(`${declaration.bootId}:`) || m.reference !== vehicleReference(e) || p.frame !== 'LOCAL_NED' || p.altitudeDatum !== 'WGS84_ELLIPSOID'
51 || !p.origin || p.origin.latitudeDeg !== 0 || p.origin.longitudeDeg !== 0 || p.origin.ellipsoidHeightM !== 100 || p.capturedAtMs === null || !Number.isFinite(p.capturedAtMs) || p.capturedAtMs < 0 || c.nowMs - p.capturedAtMs > 200 || p.capturedAtMs > c.nowMs || p.predictedToMs !== c.nowMs
52 || !finiteTuple(p.positionM, 3) || !finiteTuple(p.velocityMps, 3) || !finiteTuple(a.quaternionWxyz, 4) || Math.abs(Math.hypot(...a.quaternionWxyz) - 1) > 1e-6 || a.reference !== 'GRAVITY_ALIGNED_RELATIVE_YAW'
53 || m.capturedAtMs > c.nowMs || c.nowMs - m.capturedAtMs > 80 || m.receivedAtMs > c.nowMs || rate.evidence !== 'modeled-qualified-imu' || !Number.isFinite(rate.capturedAtMs) || rate.capturedAtMs < 0 || rate.reference !== m.reference || rate.capturedAtMs !== a.capturedAtMs || rate.capturedAtMs > c.nowMs || c.nowMs - rate.capturedAtMs > 60 || !finiteTuple(rate.angularRateRadS, 3)
54 || m.values.receiverId !== declaration.receiverId || m.values.siteClear !== true || m.values.onSurface !== true || m.values.stopped !== true) return false
55 const blocks = p.covarianceBlocks
56 if (!Array.isArray(blocks) || blocks.length !== 3 || !blocks.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)) return false
57 const sigma = Math.sqrt(Math.max(...blocks.map(b => b.positionM2)))
58 return Math.hypot(...p.velocityMps) <= .1 && Math.hypot(...p.positionM.map((n, i) => n - declaration.padM[i]!)) + 3 * sigma <= .2
59 && Math.acos(Math.max(-1, Math.min(1, 1 - 2 * (a.quaternionWxyz[1] ** 2 + a.quaternionWxyz[2] ** 2)))) <= .15 && Math.hypot(...rate.angularRateRadS) <= .2
60 }
61 function ack(nowMs: number, outcome: VehiclePayloadAck['outcome']): VehiclePayloadAck | null {
62 const command = pending!, c = observed!, m = c.measurement!, pose = c.measurementPose!
63 const result: VehiclePayloadAck = { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference, capturedAtMs: m.capturedAtMs, positionM: [...pose.positionM], outcome,
64 metadata: { modeledOnly: true, receiverId: declaration.receiverId, sourceEventId: custody.sourceEventId ?? 'unavailable', reason: custody.reason ?? 'captured-intake' } }
65 // Consume the terminal command before calling the injected adapter. It may
66 // synchronously close or poll this port, but cannot consume the same ACK.
67 pending = null
68 if (outcome !== 'confirmed') cancel()
69 else grant = null
70 if (closed) return null
71 custody.acknowledgedAtMs = nowMs
72 return result
73 }
74 const port: VehiclePayloadPort = {
75 observe(input) {
76 if (closed) return
77 const c = structuredClone(input)
78 if (!Number.isSafeInteger(c.nowMs) || c.nowMs < 0 || c.nowMs < lastNow) throw new TypeError('cargo-intake-clock')
79 if (lastNow >= 0 && c.nowMs - lastNow > 40) clearReadiness()
80 lastNow = c.nowMs
81 try { c.measurement = qualifier.qualify(c.measurement, c.nowMs, vehicleReference(c.estimate)) } catch { c.measurement = null; c.measurementPose = null }
82 observed = c
83 if (pending && (!c.enabled || c.mission.payloadFailure?.eventId === pending.eventId || c.mission.state !== 'running' || c.mission.taskId !== pending.taskId || c.mission.payload?.eventId !== pending.eventId)) { cancel(); custody.outcome = attempted ? 'uncertain' : 'rejected'; custody.reason ??= 'intake-authority-unavailable' }
84 const m = c.measurement, event = m?.values.stationSourceEventId, sequence = m?.sequence ?? -1
85 const ready = qualified(c) && m?.values.loadAttached === false && m.values.stationReady === true && m.values.stationContainerId === declaration.containerId
86 && typeof event === 'string' && event.length > 0 && event.length <= 160 && (readyReference === null || readyReference === m.reference) && (sourceEventId === null || sourceEventId === event)
87 if (!ready) clearReadiness()
88 if (sequence !== lastSequence) {
89 if (ready && sequence > lastSequence) { captures++; sinceMs ??= c.nowMs; sinceCaptureMs ??= m!.capturedAtMs; sourceEventId = event as string; readyReference = m!.reference }
90 else clearReadiness()
91 lastSequence = sequence
92 }
93 },
94 request(input) {
95 if (closed || requested) throw new TypeError('cargo-intake-command-replayed')
96 requested = true; pending = structuredClone(input); custody.requestedAtMs = pending.issuedAtMs; custody.outcome = 'pending'
97 const c = observed
98 if (!c || c.mission.state !== 'running' || c.mission.vehicle !== 'quadcopter' || c.mission.taskKind !== 'payload' || !c.mission.payload || JSON.stringify(c.mission.payload) !== JSON.stringify(pending)
99 || pending.operation !== 'material-receive' || pending.containerId !== declaration.containerId || pending.reference !== vehicleReference(c.estimate) || pending.issuedAtMs !== c.nowMs || !Number.isSafeInteger(pending.expiresAtMs) || pending.expiresAtMs <= c.nowMs || pending.expiresAtMs - c.nowMs > 60000 || !qualified(c)) {
100 custody.outcome = 'rejected'; custody.reason = 'intake-command-unqualified'
101 }
102 },
103 poll(nowMs) {
104 if (!Number.isSafeInteger(nowMs) || nowMs < 0 || nowMs < lastPoll) throw new TypeError('cargo-intake-clock')
105 lastPoll = nowMs
106 const c = observed, m = c?.measurement, pose = c?.measurementPose
107 if (closed || !pending || !c || c.nowMs !== nowMs || !qualified(c) || !m || !pose || pose.reference !== pending.reference || m.reference !== pending.reference || pose.capturedAtMs !== m.capturedAtMs || pose.positionPredictedToMs !== m.capturedAtMs || !finiteTuple(pose.positionM, 3) || !pose.positionM.every(value => Math.abs(value) <= 1e6) || !finiteTuple(pending.positionM, 3) || !pending.positionM.every(value => Math.abs(value) <= 1e6) || Math.hypot(...pose.positionM.map((value, i) => value - pending!.positionM[i]!)) > 1
108 || m.capturedAtMs < pending.issuedAtMs || m.capturedAtMs >= pending.expiresAtMs || nowMs >= pending.expiresAtMs) return null
109 if (custody.outcome === 'rejected') return ack(nowMs, 'rejected')
110 if (custody.outcome === 'uncertain' && custody.reason !== null) return ack(nowMs, 'uncertain')
111 if (!attempted && captures >= 26 && sinceMs !== null && nowMs - sinceMs >= 500 && sinceCaptureMs !== null && m.capturedAtMs - sinceCaptureMs >= 500 && sourceEventId !== null) {
112 attempted = true; custody.outcome = 'uncertain'; custody.grantedAtMs = nowMs; custody.sourceEventId = sourceEventId
113 grant = { evidence: 'modeled-cargo-intake-grant-v1', receiverId: declaration.receiverId, bootId: declaration.bootId, sessionId: declaration.sessionId, command: structuredClone(pending), sourceEventId, issuedAtMs: nowMs, expiresAtMs: Math.min(nowMs + 40, pending.expiresAtMs) }
114 try { requestMechanism(structuredClone(grant)) } catch { custody.reason = 'intake-transport-unavailable' }
115 if (closed || !pending) return null
116 // An attempted mechanism is uncertain until a later captured observation, never this request.
117 return null
118 }
119 if (!attempted || !grant) return null
120 const atMs = m.values.intakeAtMs
121 const correlated = m.values.intakeEventId === pending.eventId && m.values.intakeSourceEventId === grant.sourceEventId
122 && typeof atMs === 'number' && Number.isSafeInteger(atMs) && atMs > grant.issuedAtMs && atMs < grant.expiresAtMs && atMs <= m.capturedAtMs
123 if (correlated && m.values.intakeOutcome === 'attached' && m.values.loadAttached === true && m.values.intakeMassKg === declaration.massKg) { custody.outcome = 'received'; custody.mechanismAtMs = atMs; custody.receiptCapturedAtMs = m.capturedAtMs; return ack(nowMs, 'confirmed') }
124 if (correlated && m.values.intakeOutcome === 'refused' && m.values.loadAttached === false && m.values.intakeMassKg === 0) { custody.outcome = 'rejected'; custody.mechanismAtMs = atMs as number; custody.receiptCapturedAtMs = m.capturedAtMs; custody.reason = 'point-joint-refused'; return ack(nowMs, 'rejected') }
125 if (m.values.intakeEventId !== '' || nowMs > grant.expiresAtMs + 120) { custody.outcome = 'uncertain'; custody.reason = 'intake-receipt-unqualified'; return ack(nowMs, 'uncertain') }
126 return null
127 },
128 close() { if (closed) return; closed = true; cancel(); observed = null; pending = null; if (custody.outcome === 'pending') { custody.outcome = 'rejected'; custody.reason ??= 'intake-not-attempted' }
129 else if (custody.outcome === 'uncertain') custody.reason ??= 'intake-receipt-not-confirmed' },
130 }
131 return { port, snapshot: () => structuredClone({ evidence: 'modeled-cargo-intake-coordinator' as const, physicalFlightEnabled: false as const, identityAuthenticated: false as const, declaration, custody, grant, readiness: { captures, sinceMs, sinceCaptureMs, reference: readyReference, sourceEventId } }) }
132}
scripts/qualify-cargo-fleet-cli.tsadded+40-0
Changes to scripts/qualify-cargo-fleet-cli.ts
@@ -0,0 +1,40 @@
1import { chmod, copyFile, mkdir } from 'node:fs/promises'
2import { join, resolve } from 'node:path'
3import { runBoundedHostProcess } from '../resources/functions/host/process'
4
5const args = process.argv.slice(2)
6if (args.length !== 2) throw new Error('Usage: qualify-cargo-fleet-cli <binary> <new-output-directory>')
7const source = resolve(args[0]!), output = resolve(args[1]!)
8await mkdir(output, { mode: 0o700 })
9const binary = join(output, 'drone')
10await copyFile(source, binary); await chmod(binary, 0o755)
11const hash = (raw: Uint8Array) => new Bun.CryptoHasher('sha256').update(raw).digest('hex')
12const rows: Record<string, unknown>[] = []
13for (const [scenario, status, owner, massKg, custody] of [
14 ['nominal', 'completed', 'final-parcel-actor', 1.5, 'received'],
15 ['receiver-primary-link-loss', 'completed', 'final-parcel-actor', 1.5, 'received'],
16 ['receiver-both-links-loss', 'unqualified', 'station-actor', 1.5, 'rejected'],
17 ['receiver-host-loss-before-intake', 'unqualified', 'station-actor', 1.5, 'uncertain'],
18 ['intake-receipt-loss', 'unqualified', 'receiver-body', 1.6, 'uncertain'],
19 ['receiver-joint-shift-before-intake', 'unqualified', 'loose-parcel', 1.5, 'rejected'],
20] as const) {
21 const r = await runBoundedHostProcess([binary, 'cargo:fleet', '--scenario', scenario], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet ' + scenario })
22 const bytes = Buffer.from(r.stdout, 'utf8')
23 await Bun.write(join(output, scenario + '.stdout'), bytes); await Bun.write(join(output, scenario + '.stderr'), r.stderr)
24 if (r.exitCode !== (status === 'completed' ? 0 : 2) || r.stderr !== '' || !r.stdout.endsWith('\n') || r.stdout.split('\n').length !== 2) throw new Error('Unqualified copied fleet command: ' + scenario)
25 const s = JSON.parse(r.stdout)
26 if (s.evidence !== 'owned-cargo-fleet-simulation' || s.scenario !== scenario || s.status !== status || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false
27 || s.ownership.owner !== owner || s.ownership.initialMassKg !== 3.1 || s.ownership.finalMassKg !== 3.1 || s.ownership.maximumMassErrorKg > 1e-12 || s.receiver.finalLoad.totalMassKg !== massKg || s.receiver.intake.outcome !== custody
28 || s.source.scope.bootId !== '101' || s.source.scope.sessionId !== '201' || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202'
29 || s.source.status !== 'completed' || s.source.delivery.outcome !== 'received' || s.receiver.delivery.outcome !== (status === 'completed' ? 'received' : 'not-released')) throw new Error('Unqualified copied fleet facts: ' + scenario)
30 for (const v of [s.source, s.receiver]) if (v.board.physicalActuationEnabled !== false || JSON.stringify(v.board.actuators) !== '[0,0,0,0]' || v.board.expiresAtMs !== null || v.energy.evidence !== 'declared-mission-power-budget' || v.energy.measured !== false || v.energy.electricalModel !== false || v.energy.marginAboveReserveWh <= 0) throw new Error('Unqualified copied fleet authority/budget: ' + scenario)
31 if (status === 'completed' && (s.transfer.intake.outcome !== 'attached' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6 || s.transfer.intake.receiverScope.sessionId !== '202' || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.transfer.stationDeparture.atMs <= s.transfer.sourceRelease.atMs + 1000 || s.station.ownedMassKg !== 0 || s.recipient.ownedMassKg !== .1 || s.recipient.phase !== 'held')) throw new Error('Unqualified copied physical transfer: ' + scenario)
32 if (scenario === 'receiver-primary-link-loss' && (s.receiver.links.selectedSource !== 'backup' || s.receiver.links.latchedLoss)) throw new Error('Unqualified copied backup continuation')
33 if (scenario === 'receiver-both-links-loss' && (!s.receiver.links.latchedLoss || s.station.phase !== 'cancelled' || s.receiver.status !== 'blocked')) throw new Error('Unqualified copied link expiry')
34 if (scenario === 'receiver-host-loss-before-intake' && (s.transfer.intake !== null || s.receiver.status !== 'host-lost' || s.receiver.board.fault !== 'output-lease-expired' || s.faultInjection.triggeredAtMs !== s.receiver.intake.grantedAtMs + 4 || s.station.ownedMassKg !== .1)) throw new Error('Unqualified copied host grant gap')
35 if (scenario === 'intake-receipt-loss' && (s.receiver.missionReason !== 'payload-diversion-landed' || s.receiver.payloadFailure.reason !== 'uncertain' || s.receiver.intake.receiptCapturedAtMs !== null || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied lost receipt')
36 if (scenario === 'receiver-joint-shift-before-intake' && (s.transfer.intake.outcome !== 'refused' || s.receiver.payloadFailure.reason !== 'rejected' || s.loose.ownedMassKg !== .1 || s.loose.phase !== 'held' || s.loose.parcel.positionM[2] !== 0 || s.loose.impact.atMs <= s.transfer.intake.atMs)) throw new Error('Unqualified copied stale joint')
37 rows.push({ scenario, exit: r.exitCode, status, owner, receiverMassKg: massKg, custody, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) })
38 console.log(scenario + ' PASS')
39}
40await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-cargo-fleet-cli-consistency', binarySha256: hash(new Uint8Array(await Bun.file(binary).arrayBuffer())), physicalFlightEnabled: false, identityAuthenticated: false, rows }, null, 2))
tests/tsconfig.runtime.jsonmodified+4-0
Changes to tests/tsconfig.runtime.json
@@ -24,8 +24,11 @@
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
2626 "./unit/cargo-intake.test.ts",
27 "./unit/cargo-intake-coordinator.test.ts",
2728 "./unit/cargo-station.test.ts",
2829 "./unit/cargo-receive-mission.test.ts",
30 "./unit/cargo-fleet.test.ts",
31 "./unit/cargo-fleet-cli.test.ts",
2932 "./unit/cargo-delivery.test.ts",
3033 "./unit/cargo-delivery-config.test.ts",
3134 "./unit/cargo-delivery-phase-faults.test.ts",
@@ -34,6 +37,7 @@
3437 "./unit/cargo-mission-cli.test.ts",
3538 "./unit/cargo-qualification-process.test.ts",
3639 "../scripts/qualify-cargo-cli.ts",
40 "../scripts/qualify-cargo-fleet-cli.ts",
3741 "./unit/cargo-load-cli.test.ts",
3842 "./fixtures/estimation/**/*.ts",
3943 "./unit/estimation.test.ts",
tests/unit/cargo-fleet-cli.test.tsadded+50-0
Changes to tests/unit/cargo-fleet-cli.test.ts
@@ -0,0 +1,50 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3
4test('native Buddy runs the actual two-carrier delivery and prints a bounded custody and mass report', async () => {
5 const result = await command(['cargo:fleet']).withTimeout(30000)
6 expect(result.timedOut).toBe(false)
7 expect(result.exitCode).toBe(0)
8 const report = JSON.parse(result.stdout)
9 expect(report.evidence).toBe('owned-cargo-fleet-simulation')
10 expect(report.scenario).toBe('nominal')
11 expect(report.status).toBe('completed')
12 expect(report.physicalFlightEnabled).toBe(false)
13 expect(report.identityAuthenticated).toBe(false)
14 expect(report.source.scope).toEqual({ bootId: '101', sessionId: '201' })
15 expect(report.receiver.scope).toEqual({ bootId: '102', sessionId: '202' })
16 expect(report.receiver.intake.outcome).toBe('received')
17 expect(report.receiver.delivery.outcome).toBe('received')
18 expect(report.transfer.intake.massBeforeKg).toBe(1.5)
19 expect(report.transfer.intake.massAfterKg).toBe(1.6)
20 expect(report.ownership.finalMassKg).toBe(3.1)
21 expect(report.source.energy.evidence).toBe('declared-mission-power-budget')
22 expect(report.receiver.energy.marginAboveReserveWh).toBeGreaterThan(0)
23 expect(report.receiver.energy.measured).toBe(false)
24 expect(report.receiver.energy.electricalModel).toBe(false)
25 expect(result.stdout.trim().split('\n')).toHaveLength(1)
26 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
27}, 40000)
28
29test('native fleet failures exit incomplete while retaining the actual parcel owner and withheld custody', async () => {
30 for (const [scenario, owner, receiverMassKg] of [['receiver-host-loss-before-intake', 'station-actor', 1.5], ['intake-receipt-loss', 'receiver-body', 1.6]] as const) {
31 const result = await command(['cargo:fleet', '--scenario', scenario]).withTimeout(30000)
32 expect(result.timedOut).toBe(false)
33 expect(result.exitCode).toBe(2)
34 const report = JSON.parse(result.stdout)
35 expect(report.status).toBe('unqualified')
36 expect(report.ownership.owner).toBe(owner)
37 expect(report.ownership.finalMassKg).toBe(3.1)
38 expect(report.receiver.finalLoad.totalMassKg).toBe(receiverMassKg)
39 expect(report.receiver.intake.outcome).toBe('uncertain')
40 expect(report.receiver.delivery.outcome).toBe('not-released')
41 expect(report.transfer.finalRelease).toBeNull()
42 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
43 expect(report.receiver.board.actuators).toEqual([0, 0, 0, 0])
44 }
45 const invalid = await command(['cargo:fleet', '--scenario', 'unknown-private-marker']).withTimeout(30000)
46 expect(invalid.exitCode).toBe(1)
47 expect(invalid.stdout).toBe('')
48 expect(invalid.stderr).toContain('Cargo fleet simulation unavailable')
49 expect(invalid.stderr).not.toContain('unknown-private-marker')
50}, 100000)
tests/unit/cargo-fleet.test.tsadded+173-0
Changes to tests/unit/cargo-fleet.test.ts
@@ -0,0 +1,173 @@
1import { expect, test } from 'bun:test'
2import { simulateCargoFleet } from '../../resources/functions/drone/cargo/fleet'
3
4// Public fleet seam: two actual raw-sensor/estimator/authority sessions share a
5// world clock. No mocked body, custody state, mechanism ACK or preloaded receiver.
6test('one source parcel crosses the finite station into a live receiver and completes its second delivery', () => {
7 const run = simulateCargoFleet()
8 expect(run.evidence).toBe('owned-cargo-fleet-simulation')
9 expect(run.physicalFlightEnabled).toBe(false)
10 expect(run.identityAuthenticated).toBe(false)
11 expect(run.status).toBe('completed')
12 expect(run.source.runtime.status).toBe('completed')
13 expect(run.receiver.runtime.status).toBe('completed')
14 expect(run.source.initialLoad.properties.totalMassKg).toBe(1.6)
15 expect(run.receiver.initialLoad.properties.totalMassKg).toBe(1.5)
16 expect(run.receiver.initialLoad.properties.phase).toBe('awaiting')
17 expect(run.source.runtime.board.bootId).toBe(101n)
18 expect(run.receiver.runtime.board.bootId).toBe(102n)
19 expect(run.source.runtime.authority.sessionId).toBe(201n)
20 expect(run.receiver.runtime.authority.sessionId).toBe(202n)
21 expect(run.transfer.sourceRelease!.event.outcome).toBe('separated')
22 expect(run.transfer.stationDeparture!.outcome).toBe('departed')
23 const intake = run.transfer.intake!
24 expect(intake.attachment.outcome).toBe('attached')
25 if (intake.attachment.outcome !== 'attached') throw new Error('Expected actual modeled intake')
26 expect(intake.attachment.before.properties.totalMassKg).toBe(1.5)
27 expect(intake.attachment.after.properties.totalMassKg).toBe(1.6)
28 expect(intake.receiver.atMs).toBe(intake.source.atMs)
29 expect(intake.receiver.policyAtMs).toBeGreaterThanOrEqual(intake.atMs - 20)
30 expect(intake.receiver.authority.state).toBe('autonomous')
31 expect(intake.receiver.authority.sessionId).toBe(202n)
32 expect(intake.source.hostAvailable).toBe(true)
33 expect(intake.source.reference.startsWith('101:')).toBe(true)
34 expect(intake.receiver.reference.startsWith('102:')).toBe(true)
35 expect(run.source.delivery.outcome).toBe('received')
36 expect(run.receiver.intake.custody.outcome).toBe('received')
37 expect(run.receiver.delivery.outcome).toBe('received')
38 expect(run.receiver.intake.custody.acknowledgedAtMs!).toBeGreaterThan(intake.atMs)
39 expect(run.receiver.intake.custody.receiptCapturedAtMs!).toBeGreaterThanOrEqual(intake.atMs)
40 expect(run.receiver.finalLoad.properties.phase).toBe('separated')
41 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5)
42 expect(run.ownership.transitions.map(row => [row.from, row.to])).toEqual([
43 ['source-body', 'station-actor'], ['station-actor', 'receiver-body'], ['receiver-body', 'final-parcel-actor'],
44 ])
45 expect(run.ownership.initialMassKg).toBe(3.1)
46 expect(run.ownership.finalMassKg).toBe(3.1)
47 expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12)
48 expect(run.ownership.samples).toBeGreaterThan(1000)
49 expect(run.station!.ownedMassKg).toBe(0)
50 expect(run.recipient!.ownedMassKg).toBe(.1)
51 expect(run.recipient!.phase).toBe('held')
52 expect(run.transfer.stationDeparture!.atMs).toBeGreaterThan(run.transfer.sourceRelease!.atMs + 1000)
53})
54
55test('receiver host loss after its grant but before actual intake retains the one parcel at the station', () => {
56 const run = simulateCargoFleet({ scenario: 'receiver-host-loss-before-intake' })
57 expect(run.status).toBe('unqualified')
58 expect(run.source.runtime.status).toBe('completed')
59 expect(run.source.delivery.outcome).toBe('received')
60 expect(run.receiver.runtime.status).toBe('host-lost')
61 expect(run.receiver.runtime.board.fault).toBe('output-lease-expired')
62 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
63 // The frozen supervisor snapshot alone still says autonomous. The physical
64 // mechanism must also check actual same-epoch host availability and lease age.
65 expect(run.receiver.runtime.authority.state).toBe('autonomous')
66 expect(run.transfer.intake).toBeNull()
67 expect(run.transfer.stationDeparture).toBeNull()
68 expect(run.receiver.finalLoad.properties.phase).toBe('awaiting')
69 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5)
70 expect(run.receiver.intake.custody.outcome).toBe('uncertain')
71 expect(run.receiver.delivery.outcome).toBe('not-released')
72 expect(run.faultInjection!.triggeredAtMs).toBe(run.receiver.intake.custody.grantedAtMs! + 4)
73 expect(run.receiver.runtime.path.at(-1)!.atMs).toBe(run.receiver.intake.custody.grantedAtMs!)
74 expect(run.station!.phase).toBe('cancelled')
75 expect(run.station!.ownedMassKg).toBe(.1)
76 expect(run.recipient).toBeNull()
77 expect(run.ownership.transitions.map(row => [row.from, row.to])).toEqual([['source-body', 'station-actor']])
78 expect(run.ownership.finalMassKg).toBe(3.1)
79 expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12)
80})
81
82test('lost intake receipt keeps actual mass on the receiver and follows its counted alternate without claiming custody', () => {
83 const run = simulateCargoFleet({ scenario: 'intake-receipt-loss' })
84 expect(run.status).toBe('unqualified')
85 expect(run.transfer.intake!.attachment.outcome).toBe('attached')
86 expect(run.transfer.finalRelease).toBeNull()
87 expect(run.receiver.intake.custody.outcome).toBe('uncertain')
88 expect(run.receiver.intake.custody.receiptCapturedAtMs).toBeNull()
89 expect(run.receiver.runtime.status).toBe('completed')
90 expect(run.receiver.runtime.mission.reason).toBe('payload-diversion-landed')
91 expect(run.receiver.runtime.mission.payloadFailure).toMatchObject({ taskId: 'intake', reason: 'uncertain', landingTaskId: 'alternate-land' })
92 expect(run.receiver.finalLoad.properties.phase).toBe('attached')
93 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.6)
94 expect(run.receiver.delivery.outcome).toBe('not-released')
95 // The declared alternate accepts a 0.18 m radius, not centimetre accuracy.
96 expect(Math.hypot(run.receiver.runtime.finalTruthPositionM[0] - 5, run.receiver.runtime.finalTruthPositionM[1] - 3)).toBeLessThanOrEqual(.18)
97 expect(run.ownership.transitions.map(row => row.to)).toEqual(['station-actor', 'receiver-body'])
98 expect(run.ownership.finalMassKg).toBe(3.1)
99 expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12)
100 expect(run.station!.ownedMassKg).toBe(0)
101 expect(run.recipient).toBeNull()
102 expect(run.faultInjection!.triggeredAtMs!).toBeGreaterThanOrEqual(run.transfer.intake!.atMs)
103 expect(run.receiver.intake.custody.grantedAtMs).toBe(run.transfer.intake!.grant.issuedAtMs)
104})
105
106test('fleet scenario admission rejects unknown fields and accessors before opening a world', () => {
107 let invoked = false
108 for (const input of [null, [], 'nominal', { scenario: 'unknown-private-marker' }, { scenario: undefined }, { physical: true }, { get scenario() { invoked = true; return 'nominal' } }]) expect(() => simulateCargoFleet(input)).toThrow('cargo-fleet-arguments')
109 expect(invoked).toBe(false)
110 expect(() => simulateCargoFleet({}, 'extra')).toThrow('cargo-fleet-arguments')
111})
112
113test('the receiving vehicle can lose its primary radio during station handling and finish on its own captured backup link', () => {
114 const run = simulateCargoFleet({ scenario: 'receiver-primary-link-loss' })
115 expect(run.status).toBe('completed')
116 expect(run.receiver.intake.custody.outcome).toBe('received')
117 expect(run.receiver.delivery.outcome).toBe('received')
118 expect(run.source.links.scope).toEqual({ vehicleId: 'carrier-a', bootId: 101, sessionId: 201 })
119 expect(run.receiver.links.scope).toEqual({ vehicleId: 'carrier-b', bootId: 102, sessionId: 202 })
120 expect(run.source.links.selectedSource).toBe('primary')
121 expect(run.receiver.links.selectedSource).toBe('backup')
122 expect(run.receiver.links.latchedLoss).toBe(false)
123 expect(run.receiver.linkEvents.some(row => row.selectedSource === 'backup')).toBe(true)
124 expect(run.faultInjection!.triggeredAtMs!).toBeGreaterThanOrEqual(run.faultInjection!.anchoredAtMs!)
125 expect(run.transfer.intake!.atMs).toBeGreaterThan(run.faultInjection!.triggeredAtMs!)
126 expect(run.ownership.finalMassKg).toBe(3.1)
127})
128
129test('loss of both receiver radios expires its own authority and brakes station transport while the source continues', () => {
130 const run = simulateCargoFleet({ scenario: 'receiver-both-links-loss' })
131 expect(run.status).toBe('unqualified')
132 expect(run.source.runtime.status).toBe('completed')
133 expect(run.source.links.latchedLoss).toBe(false)
134 expect(run.receiver.links.latchedLoss).toBe(true)
135 expect(run.receiver.links.selectedSource).toBeNull()
136 expect(run.receiver.runtime.status).toBe('blocked')
137 expect(run.receiver.runtime.authority.state).toBe('failsafe')
138 expect(run.receiver.runtime.authority.reason).toBe('link-loss')
139 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
140 expect(run.receiver.finalLoad.properties.phase).toBe('awaiting')
141 expect(run.transfer.intake).toBeNull()
142 expect(run.transfer.stationDeparture).toBeNull()
143 expect(run.station!.phase).toBe('cancelled')
144 expect(run.station!.startedAtMs).not.toBeNull()
145 expect(run.station!.cancelRequestedAtMs!).toBeGreaterThanOrEqual(run.station!.startedAtMs!)
146 expect(run.station!.stoppedAtMs!).toBeGreaterThanOrEqual(run.station!.cancelRequestedAtMs!)
147 expect(run.station!.ownedMassKg).toBe(.1)
148 expect(Math.hypot(...run.station!.parcel!.velocityMps)).toBe(0)
149 expect(run.ownership.owner).toBe('station-actor')
150 expect(run.ownership.finalMassKg).toBe(3.1)
151})
152
153test('a real receiver joint shift refuses stale rendezvous and the departed point falls without duplication', () => {
154 const run = simulateCargoFleet({ scenario: 'receiver-joint-shift-before-intake' })
155 expect(run.status).toBe('unqualified')
156 expect(run.transfer.intake!.attachment).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' })
157 expect(run.receiver.finalLoad.properties.phase).toBe('awaiting')
158 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5)
159 expect(run.receiver.intake.custody.outcome).toBe('rejected')
160 expect(run.receiver.intake.custody.mechanismAtMs).toBe(run.transfer.intake!.atMs)
161 expect(run.receiver.runtime.mission.payloadFailure!.reason).toBe('rejected')
162 expect(run.receiver.runtime.mission.reason).toBe('payload-diversion-landed')
163 expect(run.transfer.finalRelease).toBeNull()
164 expect(run.recipient).toBeNull()
165 expect(run.station!.ownedMassKg).toBe(0)
166 expect(run.loose!.ownedMassKg).toBe(.1)
167 expect(run.loose!.phase).toBe('held')
168 expect(run.loose!.parcel!.positionM[2]).toBe(0)
169 expect(run.loose!.impact!.atMs).toBeGreaterThan(run.transfer.intake!.atMs)
170 expect(run.ownership.owner).toBe('loose-parcel')
171 expect(run.ownership.finalMassKg).toBe(3.1)
172 expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12)
173})
tests/unit/cargo-intake-coordinator.test.tsadded+150-0
Changes to tests/unit/cargo-intake-coordinator.test.ts
@@ -0,0 +1,150 @@
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 { vehicleReference } from '../../resources/functions/drone/vehicles/measurements'
5import { observation } from '../fixtures/authority/context'
6import { createCargoIntakeCoordinator } from '../../resources/functions/drone/cargo/intake'
7
8const declaration = () => ({ origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, padM: [3, 2, -.1], receiverId: 'carrier-b', containerId: 'parcel-a', massKg: .1, bootId: 102n, sessionId: 202n })
9
10test('modeled intake captures its declaration and rejects mechanism accessors before invoking them', () => {
11 let invoked = 0
12 const unsafe = { evidence: 'modeled-cargo-intake-mechanism' as const, get request() { invoked++; return () => {} }, cancel() {} }
13 expect(() => createCargoIntakeCoordinator(declaration(), unsafe)).toThrow('cargo-intake-mechanism')
14 expect(invoked).toBe(0)
15 const input = declaration(), adapter = { evidence: 'modeled-cargo-intake-mechanism' as const, request() {}, cancel() {} }
16 const coordinator = createCargoIntakeCoordinator(input, adapter), first = coordinator.snapshot()
17 input.massKg = .2; input.padM[0] = 8
18 const detached = coordinator.snapshot(); detached.declaration.padM[0] = 77
19 expect(coordinator.snapshot()).toEqual(first)
20 expect(first.physicalFlightEnabled).toBe(false)
21 expect(first.identityAuthenticated).toBe(false)
22 expect(first.custody.outcome).toBe('not-requested')
23 coordinator.port.close(); coordinator.port.close()
24})
25
26// Public observed-input policy seam. Declared transport captures exercise receipt
27// admission; actual acquisition, bodies and mechanical authority use fleet tests.
28function context(nowMs: number, sequence: number, payload: VehiclePayloadCommand | null = null): VehicleControllerContext {
29 const estimate = observation(nowMs).estimate
30 estimate.bootId = 102n
31 estimate.localPosition!.origin = declaration().origin
32 estimate.localPosition!.positionM = [3, 2, -.1]
33 estimate.localPosition!.covarianceBlocks = [0, 1, 2].map(() => ({ positionM2: .0001, positionVelocityM2S: 0, velocityM2S2: .0001 }))
34 const reference = vehicleReference(estimate), capturedAtMs = nowMs - 40
35 return { nowMs, enabled: true, estimate,
36 measurement: { version: 1, evidence: 'modeled-vehicle-sensors', vehicle: 'quadcopter', sequence, reference, capturedAtMs, receivedAtMs: nowMs,
37 values: { onSurface: true, stopped: true, receiverId: 'carrier-b', siteClear: true, loadAttached: false, stationReady: true, stationContainerId: 'parcel-a', stationSourceEventId: 'source-delivery:release', intakeEventId: '' } },
38 measurementPose: { capturedAtMs, reference, positionM: [3, 2, -.1], quaternionWxyz: [1, 0, 0, 0], positionCapturedAtMs: capturedAtMs, positionPredictedToMs: capturedAtMs, attitudeCapturedAtMs: capturedAtMs, attitudePredictedToMs: capturedAtMs, gyroCapturedAtMs: capturedAtMs, attitudePrediction: 'qualified-gyro-hold' },
39 controlRate: { evidence: 'modeled-qualified-imu', capturedAtMs: nowMs, reference, angularRateRadS: [0, 0, 0] },
40 mission: { version: 2, vehicle: 'quadcopter', evidence: 'owned-vehicle-mission-simulation', physicalFlightEnabled: false, state: 'running', taskId: 'intake', taskKind: 'payload', targetM: [3, 2, -.1], payload, reason: 'task-entered', elapsedMs: 0 } }
41}
42function command(): VehiclePayloadCommand {
43 return { eventId: 'receiving:intake', taskId: 'intake', operation: 'material-receive', containerId: 'parcel-a', issuedAtMs: 1000, expiresAtMs: 5000, positionM: [3, 2, -.1], reference: vehicleReference(context(1000, 1).estimate) }
44}
45function ready(coordinator: ReturnType<typeof createCargoIntakeCoordinator>) {
46 const request = command()
47 coordinator.port.observe!(context(1000, 1, request)); coordinator.port.request(request)
48 for (let i = 1; i <= 25; i++) coordinator.port.observe!(context(1000 + i * 20, i + 1, request))
49 expect(coordinator.snapshot().readiness.captures).toBe(26)
50 expect(coordinator.port.poll(1500)).toBeNull()
51 expect(coordinator.snapshot().grant).not.toBeNull()
52 return request
53}
54function attached(request: VehiclePayloadCommand, positionM: SensorVector = [3, 2, -.1]) {
55 const c = context(1560, 27, request)
56 c.measurementPose!.positionM = positionM
57 Object.assign(c.measurement!.values, { intakeEventId: request.eventId, intakeSourceEventId: 'source-delivery:release', intakeAtMs: 1504, intakeOutcome: 'attached', loadAttached: true, intakeMassKg: .1 })
58 return c
59}
60
61test('captured attached receipt cannot commit custody with an out-of-bound or distant ACK position', () => {
62 for (const position of [[2000000, 2, -.1], [4.01, 2, -.1]] as SensorVector[]) {
63 const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request() {}, cancel() {} })
64 const request = ready(coordinator)
65 coordinator.port.observe!(attached(request, position))
66 expect(coordinator.port.poll(1560)).toBeNull()
67 expect(coordinator.snapshot().custody).toMatchObject({ outcome: 'uncertain', receiptCapturedAtMs: null, acknowledgedAtMs: null })
68 coordinator.port.close()
69 }
70})
71
72test('ordinary capture pose commits a correlated receipt exactly once after the intake event', () => {
73 const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request() {}, cancel() {} })
74 const request = ready(coordinator)
75 coordinator.port.observe!(attached(request))
76 expect(coordinator.port.poll(1560)).toMatchObject({ eventId: request.eventId, outcome: 'confirmed', capturedAtMs: 1520, positionM: [3, 2, -.1] })
77 expect(coordinator.snapshot().custody).toMatchObject({ outcome: 'received', mechanismAtMs: 1504, receiptCapturedAtMs: 1520, acknowledgedAtMs: 1560 })
78 expect(coordinator.port.poll(1560)).toBeNull()
79 coordinator.port.close()
80})
81
82test('nonfinite or negative navigation source epochs cannot accumulate readiness or issue an intake grant', () => {
83 for (const capturedAtMs of [NaN, Infinity, -Infinity, -1]) {
84 const sent: unknown[] = []
85 const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request: value => { sent.push(value) }, cancel() {} })
86 const request = command()
87 for (let i = 0; i <= 25; i++) {
88 const c = context(1000 + i * 20, i + 1, request)
89 c.estimate.localPosition!.capturedAtMs = capturedAtMs
90 coordinator.port.observe!(c)
91 if (i === 0) coordinator.port.request(request)
92 }
93 expect(coordinator.port.poll(1500)).toBeNull()
94 expect(coordinator.snapshot().readiness.captures).toBe(0)
95 expect(coordinator.snapshot().grant).toBeNull()
96 expect(sent).toEqual([])
97 coordinator.port.close()
98 }
99})
100
101test('valid fractional navigation capture epochs still permit the observed readiness window', () => {
102 const sent: unknown[] = []
103 const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request: value => { sent.push(value) }, cancel() {} })
104 const request = command()
105 for (let i = 0; i <= 25; i++) {
106 const c = context(1000 + i * 20, i + 1, request)
107 c.estimate.localPosition!.capturedAtMs = c.nowMs - .25
108 coordinator.port.observe!(c)
109 if (i === 0) coordinator.port.request(request)
110 }
111 expect(coordinator.port.poll(1500)).toBeNull()
112 expect(coordinator.snapshot().readiness.captures).toBe(26)
113 expect(sent).toHaveLength(1)
114 coordinator.port.close()
115})
116
117for (const reentry of ['close', 'poll'] as const) test(`terminal uncertainty revokes pending ownership before cancellation can reenter ${reentry}`, () => {
118 const cancelled: string[] = [], reentrantAcks: unknown[] = []
119 const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request() {}, cancel(eventId) {
120 cancelled.push(eventId)
121 if (reentry === 'close') coordinator.port.close()
122 else reentrantAcks.push(coordinator.port.poll(1560))
123 } })
124 const request = ready(coordinator), foreign = context(1560, 27, request)
125 foreign.measurement!.values.intakeEventId = 'another-vehicle:intake'
126 coordinator.port.observe!(foreign)
127 const ack = coordinator.port.poll(1560)
128 if (reentry === 'close') expect(ack).toBeNull()
129 else {
130 expect(ack).toMatchObject({ eventId: request.eventId, outcome: 'uncertain' })
131 expect(reentrantAcks).toEqual([null])
132 }
133 expect(cancelled).toEqual([request.eventId])
134 expect(coordinator.snapshot().custody).toMatchObject({ outcome: 'uncertain', reason: 'intake-receipt-unqualified', receiptCapturedAtMs: null, acknowledgedAtMs: reentry === 'close' ? null : 1560 })
135 expect(coordinator.snapshot().grant).toBeNull()
136 expect(coordinator.port.poll(1560)).toBeNull()
137 coordinator.port.close(); coordinator.port.close()
138 expect(cancelled).toEqual([request.eventId])
139 expect(() => coordinator.port.request(request)).toThrow('cargo-intake-command-replayed')
140})
141
142test('the intake port rejects a negative first clock without accepting or consuming any observation', () => {
143 const coordinator = createCargoIntakeCoordinator(declaration(), { evidence: 'modeled-cargo-intake-mechanism', request() {}, cancel() {} })
144 expect(() => coordinator.port.observe!(context(-1, 1))).toThrow('cargo-intake-clock')
145 expect(() => coordinator.port.poll(-1)).toThrow('cargo-intake-clock')
146 expect(coordinator.snapshot().readiness.captures).toBe(0)
147 const request = ready(coordinator)
148 expect(coordinator.snapshot().grant!.command).toEqual(request)
149 coordinator.port.close()
150})