also looking at this
feat: add retained cargo fleet cancellation and recovery
#105
32 files
+2496
-27
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.
| @@ -128,7 +128,10 @@ Exit 2 retains a configured mission that did not complete a matched delivery and | ||
| 128 | 128 | The [two-carrier model](docs/cargo-fleet.md) and `./buddy cargo:fleet` now execute actual |
| 129 | 129 | finite station handover and the receiver's second delivery under separate sensor/authority |
| 130 | 130 | scopes. Eleven bounded cases include radio/host loss, uncertain receipt, phase-bound kill, |
| 131 | GNSS loss, pad-motion interlocks and modeled fence-breach supervision. General fleet | |
| 131 | GNSS loss, pad-motion interlocks and modeled fence-breach supervision. Native | |
| 132 | `./buddy cargo:fleet-control --case cancel-resume` exercises a stopped-rail hold and | |
| 133 | explicit recovery on the same live receiving session and physical parcel; refused | |
| 134 | recovery retains attempted-intake uncertainty or lost authority. General fleet | |
| 132 | 135 | fault/recovery, electrical/measured endurance and physical/operational qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25). |
| 133 | 136 | |
| 134 | 137 | ## Agriculture sensing |
| @@ -0,0 +1,27 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { cargoFleetSummary } from '../../resources/functions/drone/cargo/fleet' | |
| 4 | import { cargoFleetOperatorCases, simulateCargoFleetControl } from '../../resources/functions/drone/cargo/fleet-control' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'cargo:fleet-control', | |
| 8 | description: 'Exercise explicit modeled transport cancellation and recovery over the retained two-carrier world', | |
| 9 | options: { | |
| 10 | '--case <name>': { description: cargoFleetOperatorCases.join(', '), default: 'cancel-resume' }, | |
| 11 | }, | |
| 12 | async handle(options) { | |
| 13 | try { | |
| 14 | const fixture = simulateCargoFleetControl({ case: options.case }) | |
| 15 | const summary = { ...cargoFleetSummary(fixture.run), operatorCase: fixture.case } | |
| 16 | if (summary.status !== 'completed') process.exitCode = 2 | |
| 17 | const wire = JSON.stringify(summary) | |
| 18 | if (Buffer.byteLength(wire) + 1 > 16384) throw new RangeError('cargo-fleet-control-output-limit') | |
| 19 | await printAsync(wire) | |
| 20 | } | |
| 21 | catch { | |
| 22 | process.exitCode = 1 | |
| 23 | try { await printErrorAsync('Cargo fleet control unavailable: invalid case, execution or output failure') } | |
| 24 | catch { /* A closed diagnostic pipe does not make the failed command succeed. */ } | |
| 25 | } | |
| 26 | }, | |
| 27 | }) | |
| @@ -14,6 +14,7 @@ import CargoLoadBench from '../app/Commands/CargoLoadBench' | ||
| 14 | 14 | import CargoDeliver from '../app/Commands/CargoDeliver' |
| 15 | 15 | import CargoMission from '../app/Commands/CargoMission' |
| 16 | 16 | import CargoFleet from '../app/Commands/CargoFleet' |
| 17 | import CargoFleetControl from '../app/Commands/CargoFleetControl' | |
| 17 | 18 | |
| 18 | 19 | import PayloadsBench from '../app/Commands/PayloadsBench' |
| 19 | 20 | import PayloadsCapture from '../app/Commands/PayloadsCapture' |
| @@ -98,6 +99,7 @@ CargoLoadBench(app) | ||
| 98 | 99 | CargoDeliver(app) |
| 99 | 100 | CargoMission(app) |
| 100 | 101 | CargoFleet(app) |
| 102 | CargoFleetControl(app) | |
| 101 | 103 | AuthoritySimulate(app) |
| 102 | 104 | DriversBench(app) |
| 103 | 105 | PayloadsBench(app) |
| @@ -70,7 +70,13 @@ grant vehicle authority or confirm custody. | ||
| 70 | 70 | `simulateCargoFleet({ scenario? })` and `cargoFleetSummary(run)` in `cargo/fleet.ts` |
| 71 | 71 | execute [two-carrier handover](cargo-fleet.md) through actual retained source and receiver |
| 72 | 72 | runtimes, a consumed station parcel, current-authority intake and captured custody. The |
| 73 | six bounded cases retain incomplete outcomes and actual mass separately from receipts. | |
| 73 | eleven bounded fault cases retain incomplete outcomes and actual mass separately from receipts. | |
| 74 | `createCargoFleetSession()` exposes exact-epoch `step`, detached `snapshot`/terminal `result`, | |
| 75 | scoped `decide` and idempotent `close`, with the exported `CargoFleetOperatorDecision` type. | |
| 76 | `simulateCargoFleetControl({ case? })` in `cargo/fleet-control.ts` supports four explicit | |
| 77 | transport cancellation/recovery cases. The [operator contract](cargo-fleet.md#retained-world-and-explicit-transport-decisions) | |
| 78 | retains current authority, a single physical point, original receive deadlines and | |
| 79 | attempted-intake uncertainty. It does not restore a failed vehicle session. | |
| 74 | 80 | |
| 75 | 81 | ## Target sources |
| 76 | 82 | |
| @@ -216,8 +216,10 @@ receiving body in its own raw-sensor, estimator and authority runtime. With ACK | ||
| 216 | 216 | it stays at the declared pad with zero actual motor thrust and no phantom parcel mass. |
| 217 | 217 | A caller-invented ACK is not proof of mechanical transfer. The |
| 218 | 218 | [two-carrier model](cargo-fleet.md) now binds its receipt port to actual intake, same-epoch |
| 219 | receiver authority and eleven bounded fleet cases through `cargo:fleet`. Its broader | |
| 220 | cancellation, fault/recovery and operational matrix remains open under #25. | |
| 219 | receiver authority and eleven bounded fleet cases through `cargo:fleet`. The retained | |
| 220 | world and `cargo:fleet-control` add same-session station cancellation and bounded | |
| 221 | recovery before intake is attempted. General vehicle abort/replan, uncertain-intake | |
| 222 | reconciliation and the wider fault/recovery and operational matrix remain open under #25. | |
| 221 | 223 | |
| 222 | 224 | ## BVLOS operations evidence |
| 223 | 225 | |
| @@ -249,7 +251,8 @@ establish an airspace detect-and-avoid system. | ||
| 249 | 251 | |
| 250 | 252 | ## Remaining issue scope |
| 251 | 253 | |
| 252 | [#25](https://github.com/stacksjs/drone/issues/25) remains open for actual distinct-vehicle | |
| 253 | fleet handover, richer endurance models/measurements, durable cargo/runtime reconciliation, | |
| 254 | [#25](https://github.com/stacksjs/drone/issues/25) remains open for general fleet | |
| 255 | handover/recovery beyond the bounded two-carrier model, richer endurance | |
| 256 | models/measurements, durable cargo/runtime reconciliation, | |
| 254 | 257 | physical release/recipient validation and the separate operational evidence case. |
| 255 | 258 | The original acceptance scope is not reduced to passing named demonstrations. |
| @@ -120,18 +120,99 @@ Vehicle host termination retains a 1,000 ms independent board/plant tail. The ot | ||
| 120 | 120 | continues its own mission. Both routes and their failure alternatives count toward the |
| 121 | 121 | native finite graph/reserve budget; the world is bounded by the 180 s host limit and tails. |
| 122 | 122 | |
| 123 | ## Retained world and explicit transport decisions | |
| 124 | ||
| 125 | `createCargoFleetSession()` exposes `step`, `snapshot`, `decide`, `result` and | |
| 126 | idempotent `close`. The one-shot `simulateCargoFleet()` uses that same world. Each | |
| 127 | step requires exactly the next 4 ms epoch, starting at zero. Both retained vehicle | |
| 128 | sessions, station and single physical point advance together. Snapshots are detached; | |
| 129 | `result()` requires a terminal world and returns a detached complete report, including | |
| 130 | initial loads, fault anchors and transfer history. Mutating a returned report cannot | |
| 131 | rewrite later reads. Closing early revokes both actual board models | |
| 132 | and reports their current neutral outputs without advancing the plant, changing the | |
| 133 | last policy/measurement epochs or claiming that the moving parcel instantly stopped. | |
| 134 | A closed world cannot resume. Closing is not a durable checkpoint or physical settling | |
| 135 | procedure. | |
| 136 | ||
| 137 | The exported `CargoFleetOperatorDecision` is a strict, data-only declaration: | |
| 138 | ||
| 139 | ```ts | |
| 140 | const state = world.snapshot() | |
| 141 | if (state.atMs === null || !state.receiver || !state.stationSourceEventId) | |
| 142 | throw new Error('Wait for an active receiver and station event') | |
| 143 | const outcome = world.decide({ | |
| 144 | version: 1, | |
| 145 | evidence: 'modeled-cargo-transport-decision-v1', | |
| 146 | contextId: state.operator.contextId, | |
| 147 | bootId: 102n, | |
| 148 | sessionId: 202n, | |
| 149 | sequence: 1, | |
| 150 | issuedAtMs: state.atMs, | |
| 151 | reference: state.receiver.reference, | |
| 152 | sourceEventId: state.stationSourceEventId, | |
| 153 | action: 'cancel', | |
| 154 | }) | |
| 155 | ``` | |
| 156 | ||
| 157 | Call after observing an active receiving task and the actual station source event. | |
| 158 | The fresh context UUID separates two otherwise identical worlds; it does not | |
| 159 | authenticate an operator. Admission requires the exact world epoch, receiving | |
| 160 | boot/session/reference and source event, and the next sequence from 1 through 32. | |
| 161 | Accessors, extra fields and extra arguments are rejected. Wrong scope, old clock or | |
| 162 | replayed sequence cannot touch the parcel or consume a sequence. A valid scoped | |
| 163 | operational refusal does consume its sequence and records its reason. Closure during | |
| 164 | declaration capture cannot admit later work. Decision history is bounded to 32 events. | |
| 165 | ||
| 166 | `cancel` consumes any queued physical intake request and asks the station to stop. | |
| 167 | Actual finite braking retains position, velocity, mass and work. The receiving mission | |
| 168 | keeps its existing task/event deadline; the other carrier continues independently. | |
| 169 | Cancellation does not reset an estimator, replace a body or renew a failed lease. | |
| 170 | ||
| 171 | A later fresh `resume` needs the same pending receive task, current native authority, | |
| 172 | captured source custody, a fully stopped station and no prior intake grant. The same | |
| 173 | point resumes from its actual stopped position, with a new bounded rail duration and | |
| 174 | cumulative mechanical accounting. At most eight station resumptions fit within the | |
| 175 | original 180 s lifetime. An attempted intake remains uncertain even if cancellation | |
| 176 | prevents its queued mechanism from executing; `intake-already-attempted` prevents a | |
| 177 | second attempt. No inferred custody repair is performed. | |
| 178 | ||
| 179 | Native Buddy and the copied standalone CLI expose four explicit fictional decision cases: | |
| 180 | ||
| 181 | ```sh | |
| 182 | ./buddy cargo:fleet-control --case cancel | |
| 183 | ./buddy cargo:fleet-control --case cancel-resume | |
| 184 | ./buddy cargo:fleet-control --case cancel-after-grant | |
| 185 | ./buddy cargo:fleet-control --case resume-after-navigation-loss | |
| 186 | ``` | |
| 187 | ||
| 188 | The default `cancel-resume` waits for actual moving transport, cancels it, waits at | |
| 189 | least 1 s after its physical stop, then resumes the same receiver session and parcel. | |
| 190 | It completes captured intake, final delivery and return. `cancel` retains the parcel | |
| 191 | and lets the original receive deadline lead to an alternate. `cancel-after-grant` | |
| 192 | withholds physical intake but retains attempted-intake uncertainty and refuses recovery. | |
| 193 | `resume-after-navigation-loss` refuses recovery after native navigation authority fails. | |
| 194 | These three cases exit 2. The completed case exits 0; invalid input, execution and output | |
| 195 | failures exit 1. Each emits one JSON line within 16 KiB, including explicit decision | |
| 196 | outcomes, source/receiver scopes and physical ownership. Identity authentication and | |
| 197 | physical outputs remain disabled. These are fixed simulation declarations, not an | |
| 198 | interactive control server or an arbitrary fleet mission interface. | |
| 199 | ||
| 123 | 200 | ## Evidence and remaining work |
| 124 | 201 | |
| 125 | 202 | `tests/unit/cargo-fleet.test.ts` exercises actual two-session physics, intake mass changes, |
| 126 | 203 | source/receiver scopes, same-epoch admission, captured receipt ordering, retained mass, |
| 127 | 204 | backup continuation, both-link braking, the host/grant gap and uncertain loaded diversion. |
| 128 | 205 | `tests/unit/cargo-fleet-cli.test.ts` executes the native Buddy command and its bounded wire. |
| 129 | `scripts/qualify-cargo-fleet-cli.ts` exercises all eleven cases through a copied standalone | |
| 130 | binary outside the checkout and retains output hashes and factual outcomes. | |
| 206 | `tests/unit/cargo-fleet-operator.test.ts` exercises same-session recovery, braking, | |
| 207 | foreign/stale/replayed decisions, close reentry, retained output observations and the | |
| 208 | grant/execution gap. `tests/unit/cargo-fleet-control-cli.test.ts` exercises native command | |
| 209 | exits and bounded reports. `scripts/qualify-cargo-fleet-cli.ts` exercises all eleven | |
| 210 | fault cases and four operator cases through a copied standalone binary outside the | |
| 211 | checkout and retains output hashes and factual outcomes. | |
| 131 | 212 | |
| 132 | 213 | These cases do not cover a complete fleet fault envelope. Receiver/source kill or manual |
| 133 | 214 | intervention at every phase, broader navigation degradation and containment campaigns, |
| 134 | wider late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable | |
| 135 | whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning | |
| 215 | wider late/foreign/replayed receipt campaigns, general vehicle abort/replan and | |
| 216 | uncertain-intake reconciliation, durable whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning | |
| 136 | 217 | remain open under [#25](https://github.com/stacksjs/drone/issues/25) and its dependencies. |
| 137 | 218 | The wider measured hardware, endurance and operational evidence requirements remain open. |
| @@ -38,8 +38,10 @@ The retained source qualification passed 38 tests and 5,553 assertions across co | ||
| 38 | 38 | Issue #25 remains open. The separate [modeled cargo mission](cargo-delivery.md) now implements |
| 39 | 39 | configured delivery, captured mechanism/receiver evidence and custody, declared reserve budgets, |
| 40 | 40 | alternates, two modeled links and phase-bound fault cases. Those capabilities do not change this |
| 41 | bench's characterization result or flags. Distinct-vehicle fleet handover, measured/electrical | |
| 42 | endurance, release hardware, operational authorization, detect-and-avoid and physical/HIL | |
| 41 | bench's characterization result or flags. The separate [two-carrier model](cargo-fleet.md) | |
| 42 | connects actual finite station transport, intake and captured custody, including bounded | |
| 43 | transport cancellation/recovery. General fleet recovery, measured/electrical endurance, | |
| 44 | release hardware, operational authorization, detect-and-avoid and physical/HIL | |
| 43 | 45 | qualification remain open. |
| 44 | 46 | |
| 45 | 47 | The result keeps `physicalFlightEnabled` and `identityAuthenticated` false, with physical board outputs inhibited. Its six delivery, mechanism verification, custody, recipient, mission and energy-margin capability flags remain false. This characterization does not authorize physical operation or long-range transport. |
| @@ -72,6 +72,13 @@ A falling parcel continues under gravity until plane contact. These requests ret | ||
| 72 | 72 | epoch, current phase and fractional stop epoch. Cancellation retains the parcel and |
| 73 | 73 | prevents transfer; it does not restore an aircraft's authority or land a vehicle. |
| 74 | 74 | |
| 75 | `resume()` accepts only a fully stopped cancelled parcel. It starts a fresh bounded | |
| 76 | quintic rail from the retained position, preserving mechanical work and the original | |
| 77 | 180 s lifetime. A missed pickup cannot be resumed into a successful pickup. Snapshots | |
| 78 | retain the previous start, cancellation and stop epochs plus the new position/duration. | |
| 79 | The detached history contains at most eight resumptions. Further requests are refused; | |
| 80 | this primitive does not itself restore vehicle authority or reconcile uncertain custody. | |
| 81 | ||
| 75 | 82 | `takeReadyParcel()` accepts only `ready`, returns a detached parcel and clears the |
| 76 | 83 | station's owned mass. Further departure attempts are refused. The receiving body's |
| 77 | 84 | `attachParcel()` still independently checks its actual current joint. A stale destination |
| @@ -105,5 +112,6 @@ ACK or coordinate two live vehicle mission engines. Those operations must use ea | ||
| 105 | 112 | vehicle's own sensor, estimator and current authority scope on the common clock. The |
| 106 | 113 | [receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds |
| 107 | 114 | a declared pad under the shared engine. The [two-carrier world and CLI](cargo-fleet.md) now connect physical intake, current |
| 108 | authority and captured acknowledgement for six bounded cases. A complete cancellation, | |
| 115 | authority and captured acknowledgement for eleven bounded fault cases and explicit | |
| 116 | transport cancellation/recovery. A complete cancellation, | |
| 109 | 117 | fault/recovery and general fleet matrix remains open under [#25](https://github.com/stacksjs/drone/issues/25). |
| @@ -24,8 +24,12 @@ companion application and simulator, not a native board autopilot. | ||
| 24 | 24 | evidence remain open in [#10](https://github.com/stacksjs/drone/issues/10). |
| 25 | 25 | - Configured modeled cargo delivery uses captured release/receiver custody, finite alternate landing, |
| 26 | 26 | declared reserves, scoped redundant links and post-release failures on the shared runtime. |
| 27 | Distinct-vehicle fleet handover and physical/electrical/operational qualification remain in | |
| 28 | [#25](https://github.com/stacksjs/drone/issues/25). See [cargo contracts](../cargo-delivery.md). | |
| 27 | The [two-carrier model](../cargo-fleet.md) connects finite station transport, actual intake, | |
| 28 | captured custody and a second delivery under separate retained sensor/authority sessions. | |
| 29 | Eleven fault cases and explicit station cancel/resume retain physical ownership and uncertainty. | |
| 30 | General fleet recovery, durable world reconciliation and physical/electrical/operational | |
| 31 | qualification remain in [#25](https://github.com/stacksjs/drone/issues/25). See | |
| 32 | [cargo contracts](../cargo-delivery.md). | |
| 29 | 33 | - TypeScript follow/behavior library, kinematic simulator, controller leases and envelope checks. |
| 30 | 34 | - Pi AI Camera acquisition bridge and explicit image-space target selection/loss policy, pending Pi hardware validation. |
| 31 | 35 | - An optional visual guard that holds metric follow logic on lost, unconfirmed, stale or changed visual selection. |
| @@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 29 | 29 | | 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) | |
| 30 | 30 | | 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) | |
| 31 | 31 | | 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; bounded two-carrier station/intake handover with captured custody and eleven model cases including phase-bound navigation, kill, pad-motion and fence-breach supervision; 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) | | |
| 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 eleven model cases including phase-bound navigation, kill, pad-motion and fence-breach supervision; explicit station cancellation and same-session recovery before attempted intake; 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) | | |
| 33 | 33 | | 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) | |
| 34 | 34 | | 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) | |
| 35 | 35 | | 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) | |
| @@ -766,20 +766,33 @@ | ||
| 766 | 766 | "implementation": { |
| 767 | 767 | "status": "partial", |
| 768 | 768 | "implemented": [ |
| 769 | "Owned carried-point-mass/CG physics through the existing multirotor loop", | |
| 770 | "Centered/forward carriage, prescribed zero-ejection separation and native link-loss characterization", | |
| 771 | "Strict typed library, bounded native CLI summary and exact legacy-output compatibility" | |
| 769 | "Owned carried-point-mass/CG dynamics, actual modeled release/intake, captured mechanism/site custody and counted return or alternate landing", | |
| 770 | "Configured delivery missions, declared power/reserve margins, scoped primary/backup links and observed release/receipt phase failures", | |
| 771 | "Two retained raw-sensor/estimator/authority carriers on one clock with finite station handling, globally single-parcel ownership, captured intake and a second delivery", | |
| 772 | "Eleven bounded fleet cases including link, host, navigation, kill, pad-motion and containment failures, with independent output expiry and retained uncertainty", | |
| 773 | "Explicit scoped station cancellation and stopped-point recovery before attempted intake, same-session receive deadlines and bounded decision/history admission", | |
| 774 | "Native Buddy and copied standalone commands, bounded reports, actual output compatibility and separate BVLOS evidence requirements" | |
| 772 | 775 | ], |
| 773 | 776 | "remaining": [ |
| 774 | "Delivery mission, site and recipient confirmation, release mechanism/ACK and custody", | |
| 775 | "Endurance reserves, alternates, redundant links, degraded navigation, containment, diversion and fleet handover", | |
| 776 | "Separate operational authorization/detect-and-avoid and physical/HIL qualification" | |
| 777 | "General fleet fault/recovery and vehicle abort/replan, uncertain-intake reconciliation, durable whole-world recovery and richer rendezvous/collision planning", | |
| 778 | "Electrical/measured endurance and physical release/intake/recipient qualification", | |
| 779 | "Operational authorization, detect-and-avoid and hardware/HIL qualification separate from software capability" | |
| 777 | 780 | ], |
| 778 | 781 | "evidence": [ |
| 779 | 782 | "docs/cargo-load.md", |
| 780 | "tests/unit/controls.test.ts", | |
| 783 | "docs/cargo-delivery.md", | |
| 784 | "docs/cargo-station.md", | |
| 785 | "docs/cargo-fleet.md", | |
| 781 | 786 | "tests/unit/cargo-load.test.ts", |
| 782 | "tests/unit/cargo-load-cli.test.ts" | |
| 787 | "tests/unit/cargo-delivery.test.ts", | |
| 788 | "tests/unit/cargo-delivery-phase-faults.test.ts", | |
| 789 | "tests/unit/cargo-intake-coordinator.test.ts", | |
| 790 | "tests/unit/cargo-station.test.ts", | |
| 791 | "tests/unit/cargo-fleet.test.ts", | |
| 792 | "tests/unit/cargo-fleet-operator.test.ts", | |
| 793 | "tests/unit/cargo-fleet-control-cli.test.ts", | |
| 794 | "scripts/qualify-cargo-cli.ts", | |
| 795 | "scripts/qualify-cargo-fleet-cli.ts" | |
| 783 | 796 | ] |
| 784 | 797 | } |
| 785 | 798 | }, |
| @@ -0,0 +1,37 @@ | ||
| 1 | import { createCargoFleetSession } from './fleet' | |
| 2 | ||
| 3 | export const cargoFleetOperatorCases = ['cancel', 'cancel-resume', 'cancel-after-grant', 'resume-after-navigation-loss'] as const | |
| 4 | type OperatorCase = typeof cargoFleetOperatorCases[number] | |
| 5 | function captureCase(input: unknown): OperatorCase { | |
| 6 | try { | |
| 7 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'case')) throw new TypeError() | |
| 8 | const d = Object.getOwnPropertyDescriptor(input, 'case') | |
| 9 | if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoFleetOperatorCases.some(name => name === d.value))) throw new TypeError() | |
| 10 | return (d?.value ?? 'cancel-resume') as OperatorCase | |
| 11 | } | |
| 12 | catch { throw new TypeError('cargo-fleet-control-arguments') } | |
| 13 | } | |
| 14 | ||
| 15 | /** Explicit fictional operator decisions over the retained world, without hardware dispatch. */ | |
| 16 | export function simulateCargoFleetControl(input: unknown = {}, ...extra: unknown[]) { | |
| 17 | if (extra.length) throw new TypeError('cargo-fleet-control-arguments') | |
| 18 | const name = captureCase(input), world = createCargoFleetSession({ scenario: name === 'resume-after-navigation-loss' ? 'receiver-navigation-loss-during-rail' : 'nominal' }) | |
| 19 | let cancelled = false, resumed = false | |
| 20 | try { | |
| 21 | for (let nowMs = 0; ; nowMs += 4) { | |
| 22 | const progress = world.step(nowMs), state = world.snapshot() | |
| 23 | if (progress.terminal) return { case: name, run: world.result() } | |
| 24 | const decide = (action: 'cancel' | 'resume', sequence: number) => world.decide({ version: 1, evidence: 'modeled-cargo-transport-decision-v1', contextId: state.operator.contextId, | |
| 25 | bootId: state.receiver!.authority.bootId, sessionId: state.receiver!.authority.sessionId, sequence, issuedAtMs: nowMs, reference: state.receiver!.reference, sourceEventId: state.stationSourceEventId, action }) | |
| 26 | const cancelNow = name === 'cancel-after-grant' ? state.intake.custody.grantedAtMs !== null | |
| 27 | : state.station?.phase === 'moving' && (name === 'resume-after-navigation-loss' || Math.hypot(...state.station.parcel!.velocityMps) > .1) | |
| 28 | if (!cancelled && cancelNow) { decide('cancel', 1); cancelled = true } | |
| 29 | else if (cancelled && !resumed && name !== 'cancel') { | |
| 30 | const resumeNow = name === 'cancel-after-grant' || (name === 'resume-after-navigation-loss' ? state.receiver?.authority.state === 'failsafe' | |
| 31 | : state.station?.phase === 'cancelled' && state.station.stoppedAtMs !== null && nowMs >= state.station.stoppedAtMs + 1000) | |
| 32 | if (resumeNow) { decide('resume', 2); resumed = true } | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | finally { world.close() } | |
| 37 | } | |
| @@ -17,6 +17,18 @@ type Station = ReturnType<typeof createCargoStationHandler> | ||
| 17 | 17 | type Release = ReturnType<ReturnType<typeof createReceivingRigidBody>['separateCarriedLoad']> |
| 18 | 18 | type Departure = Extract<ReturnType<Station['takeReadyParcel']>, { outcome: 'departed' }> |
| 19 | 19 | type Owner = 'source-body' | 'station-actor' | 'receiver-body' | 'final-parcel-actor' | 'loose-parcel' |
| 20 | export interface CargoFleetOperatorDecision { | |
| 21 | version: 1 | |
| 22 | evidence: 'modeled-cargo-transport-decision-v1' | |
| 23 | contextId: string | |
| 24 | bootId: bigint | |
| 25 | sessionId: bigint | |
| 26 | sequence: number | |
| 27 | issuedAtMs: number | |
| 28 | reference: string | |
| 29 | sourceEventId: string | |
| 30 | action: 'cancel' | 'resume' | |
| 31 | } | |
| 20 | 32 | export const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake', 'receiver-navigation-loss-during-rail', 'source-kill-after-station-receipt', 'receiver-kill-after-intake', 'receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'] as const |
| 21 | 33 | export type CargoFleetScenario = typeof cargoFleetScenarios[number] |
| 22 | 34 | function captureScenario(input: unknown): CargoFleetScenario { |
| @@ -55,7 +67,7 @@ function hasAuthority(p: Progress | null, command: VehiclePayloadCommand, nowMs: | ||
| 55 | 67 | } |
| 56 | 68 | |
| 57 | 69 | /** One modeled parcel, two retained raw-sensor/estimator/authority runtimes, and finite station physics. */ |
| 58 | export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | |
| 70 | export function createCargoFleetSession(input: unknown = {}, ...extra: unknown[]) { | |
| 59 | 71 | if (extra.length) throw new TypeError('cargo-fleet-arguments') |
| 60 | 72 | const scenario = captureScenario(input) |
| 61 | 73 | const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null, forceN?: SensorVector, forceRampMs?: number } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null, ...(['receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'].includes(scenario) ? { forceN: [scenario === 'receiver-pad-motion-during-rail' ? 4 : 8, 0, 0] as SensorVector, ...(scenario === 'receiver-containment-breach-after-intake-receipt' ? { forceRampMs: 2000 } : {}) } : {}) } |
| @@ -64,6 +76,9 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | ||
| 64 | 76 | const initialA = bodyA.loadSnapshot(), initialB = bodyB.loadSnapshot() |
| 65 | 77 | let station: Station | null = null, recipient: Station | null = null, loose: Station | null = null |
| 66 | 78 | const physical: { owner: Owner } = { owner: 'source-body' } |
| 79 | const operatorContextId = crypto.randomUUID() | |
| 80 | const operatorEvents: { atMs: number, sequence: number, action: 'cancel' | 'resume', outcome: 'accepted' | 'refused', reason: string }[] = [] | |
| 81 | let operatorSequence = 0, operatorHold = false, currentAtMs: number | null = null, nextAtMs = 0, terminal = false, closed = false, stepping = false | |
| 67 | 82 | let maximumMassErrorKg = 0, massSamples = 0 |
| 68 | 83 | const transitions: { atMs: number, from: Owner, to: Owner }[] = [] |
| 69 | 84 | let sourceRelease: { atMs: number, eventId: string, event: Release } | null = null, finalRelease: { atMs: number, eventId: string, event: Release } | null = null |
| @@ -177,72 +192,153 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | ||
| 177 | 192 | function totalMass() { return bodyA.loadSnapshot().properties.totalMassKg + bodyB.loadSnapshot().properties.totalMassKg + (station?.snapshot().ownedMassKg ?? 0) + (recipient?.snapshot().ownedMassKg ?? 0) + (loose?.snapshot().ownedMassKg ?? 0) } |
| 178 | 193 | function sampleMass() { maximumMassErrorKg = Math.max(maximumMassErrorKg, Math.abs(totalMass() - 3.1)); massSamples++ } |
| 179 | 194 | sampleMass() |
| 180 | try { | |
| 181 | for (let nowMs = 0; nowMs <= 181000; nowMs += 4) { | |
| 182 | if (!progressA?.terminal) progressA = a.step(nowMs) | |
| 183 | if (!progressB?.terminal) progressB = b.step(nowMs) | |
| 184 | if (faultInjection?.scenario === 'source-kill-after-station-receipt' && faultInjection.anchoredAtMs === null) { | |
| 185 | const receipt = sourceCoordinator.snapshot().delivery | |
| 186 | if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs | |
| 187 | } | |
| 188 | if (faultInjection?.scenario === 'receiver-containment-breach-after-intake-receipt' && faultInjection.anchoredAtMs === null) { | |
| 189 | const receipt = intakeCoordinator.snapshot().custody | |
| 190 | if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs | |
| 191 | } | |
| 192 | if (station && station.snapshot().atMs < nowMs) station.step(nowMs) | |
| 193 | if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs) | |
| 194 | if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs) | |
| 195 | if (commands.source && nowMs > commands.source.issuedAtMs) { | |
| 196 | const command = commands.source; commands.source = null | |
| 197 | if (physical.owner === 'source-body' && hasAuthority(progressA, command, nowMs, 101n, 201n)) { | |
| 198 | const event = bodyA.separateCarriedLoad(); sourceRelease = { atMs: nowMs, eventId: command.eventId, event } | |
| 199 | if (event.outcome === 'separated') { | |
| 200 | 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 }) | |
| 201 | changeOwner('station-actor', nowMs); sampleMass() | |
| 202 | } | |
| 195 | function advance(nowMs: number) { | |
| 196 | if (!progressA?.terminal) progressA = a.step(nowMs) | |
| 197 | if (!progressB?.terminal) progressB = b.step(nowMs) | |
| 198 | if (faultInjection?.scenario === 'source-kill-after-station-receipt' && faultInjection.anchoredAtMs === null) { | |
| 199 | const receipt = sourceCoordinator.snapshot().delivery | |
| 200 | if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs | |
| 201 | } | |
| 202 | if (faultInjection?.scenario === 'receiver-containment-breach-after-intake-receipt' && faultInjection.anchoredAtMs === null) { | |
| 203 | const receipt = intakeCoordinator.snapshot().custody | |
| 204 | if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs | |
| 205 | } | |
| 206 | if (station && station.snapshot().atMs < nowMs) station.step(nowMs) | |
| 207 | if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs) | |
| 208 | if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs) | |
| 209 | if (commands.source && nowMs > commands.source.issuedAtMs) { | |
| 210 | const command = commands.source; commands.source = null | |
| 211 | if (physical.owner === 'source-body' && hasAuthority(progressA, command, nowMs, 101n, 201n)) { | |
| 212 | const event = bodyA.separateCarriedLoad(); sourceRelease = { atMs: nowMs, eventId: command.eventId, event } | |
| 213 | if (event.outcome === 'separated') { | |
| 214 | 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 }) | |
| 215 | changeOwner('station-actor', nowMs); sampleMass() | |
| 203 | 216 | } |
| 204 | 217 | } |
| 205 | if (station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) { | |
| 206 | station.start() | |
| 207 | if (faultInjection && ['receiver-navigation-loss-during-rail', 'receiver-pad-motion-during-rail'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs ??= nowMs | |
| 218 | } | |
| 219 | if (!operatorHold && station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) { | |
| 220 | station.start() | |
| 221 | if (faultInjection && ['receiver-navigation-loss-during-rail', 'receiver-pad-motion-during-rail'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs ??= nowMs | |
| 222 | } | |
| 223 | if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel() | |
| 224 | if (commands.intake && nowMs > commands.intake.issuedAtMs) { | |
| 225 | const grant = commands.intake; commands.intake = null | |
| 226 | 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 | |
| 227 | && hasAuthority(progressB, grant.command, nowMs, grant.bootId, grant.sessionId) && progressB!.board.actuators.every(n => n === 0) && bodyB.snapshot().motorsN.every(n => n < .01)) { | |
| 228 | const departure = station.takeReadyParcel() | |
| 229 | if (departure.outcome === 'departed') { | |
| 230 | stationDeparture = departure | |
| 231 | const attachment = bodyB.attachParcel(departure.parcel) | |
| 232 | intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) } | |
| 233 | intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 } | |
| 234 | if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection && ['intake-receipt-loss', 'receiver-kill-after-intake'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs = nowMs } | |
| 235 | 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) } | |
| 236 | sampleMass() | |
| 237 | } | |
| 208 | 238 | } |
| 209 | if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel() | |
| 210 | if (commands.intake && nowMs > commands.intake.issuedAtMs) { | |
| 211 | const grant = commands.intake; commands.intake = null | |
| 212 | 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 | |
| 213 | && hasAuthority(progressB, grant.command, nowMs, grant.bootId, grant.sessionId) && progressB!.board.actuators.every(n => n === 0) && bodyB.snapshot().motorsN.every(n => n < .01)) { | |
| 214 | const departure = station.takeReadyParcel() | |
| 215 | if (departure.outcome === 'departed') { | |
| 216 | stationDeparture = departure | |
| 217 | const attachment = bodyB.attachParcel(departure.parcel) | |
| 218 | intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) } | |
| 219 | intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 } | |
| 220 | if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection && ['intake-receipt-loss', 'receiver-kill-after-intake'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs = nowMs } | |
| 221 | 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) } | |
| 222 | sampleMass() | |
| 223 | } | |
| 239 | } | |
| 240 | if (commands.receiver && nowMs > commands.receiver.issuedAtMs) { | |
| 241 | const command = commands.receiver; commands.receiver = null | |
| 242 | if (physical.owner === 'receiver-body' && hasAuthority(progressB, command, nowMs, 102n, 202n)) { | |
| 243 | const event = bodyB.separateCarriedLoad(); finalRelease = { atMs: nowMs, eventId: command.eventId, event } | |
| 244 | if (event.outcome === 'separated') { | |
| 245 | recipient = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: finalSite.positionM, radiusM: finalSite.radiusM }, destinationM: finalSite.positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 246 | changeOwner('final-parcel-actor', nowMs); sampleMass() | |
| 224 | 247 | } |
| 225 | 248 | } |
| 226 | if (commands.receiver && nowMs > commands.receiver.issuedAtMs) { | |
| 227 | const command = commands.receiver; commands.receiver = null | |
| 228 | if (physical.owner === 'receiver-body' && hasAuthority(progressB, command, nowMs, 102n, 202n)) { | |
| 229 | const event = bodyB.separateCarriedLoad(); finalRelease = { atMs: nowMs, eventId: command.eventId, event } | |
| 230 | if (event.outcome === 'separated') { | |
| 231 | recipient = createCargoStationHandler({ atMs: nowMs, parcel: { id: point.id, ...event.parcel }, pickup: { centerM: finalSite.positionM, radiusM: finalSite.radiusM }, destinationM: finalSite.positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 232 | changeOwner('final-parcel-actor', nowMs); sampleMass() | |
| 233 | } | |
| 249 | } | |
| 250 | if (nowMs % 20 === 0) sampleMass() | |
| 251 | } | |
| 252 | function close() { | |
| 253 | if (closed) return | |
| 254 | const interrupted = !terminal | |
| 255 | closed = true | |
| 256 | if (interrupted) { terminal = true; station?.cancel(); commands.intake = null } | |
| 257 | try { a.close() } | |
| 258 | finally { | |
| 259 | try { b.close() } | |
| 260 | finally { | |
| 261 | // Closing revokes the boards now. Keep the last observation/policy clocks | |
| 262 | // and physical point, but do not expose their previously powered output. | |
| 263 | if (interrupted) { | |
| 264 | const source = a.result(), receiver = b.result() | |
| 265 | if (progressA) progressA = { ...progressA, hostAvailable: false, terminal: true, board: source.board, authority: source.authority, mission: source.mission } | |
| 266 | if (progressB) progressB = { ...progressB, hostAvailable: false, terminal: true, board: receiver.board, authority: receiver.authority, mission: receiver.mission } | |
| 234 | 267 | } |
| 235 | 268 | } |
| 236 | if (nowMs % 20 === 0) sampleMass() | |
| 237 | if (progressA?.terminal && progressB?.terminal) break | |
| 238 | 269 | } |
| 270 | } | |
| 271 | function step(nowMs: number, ...extra: unknown[]) { | |
| 272 | if (closed || terminal) throw new Error('Cargo fleet session is closed') | |
| 273 | if (stepping) throw new Error('Cargo fleet session is already stepping') | |
| 274 | if (extra.length || !Number.isSafeInteger(nowMs) || nowMs !== nextAtMs || nowMs > 181000) throw new RangeError('cargo-fleet-clock') | |
| 275 | stepping = true | |
| 276 | try { | |
| 277 | currentAtMs = nowMs; advance(nowMs); nextAtMs += 4 | |
| 278 | terminal = !!progressA?.terminal && !!progressB?.terminal | |
| 279 | return { atMs: nowMs, terminal } | |
| 280 | } | |
| 281 | catch (error) { close(); throw error } | |
| 282 | finally { stepping = false } | |
| 283 | } | |
| 284 | function snapshot() { | |
| 285 | return structuredClone({ atMs: currentAtMs, terminal, closed, source: progressA, receiver: progressB, station: station?.snapshot() ?? null, stationSourceEventId: sourceRelease?.eventId ?? null, intake: intakeCoordinator.snapshot(), | |
| 286 | ownership: { owner: physical.owner, totalMassKg: totalMass() }, operator: { contextId: operatorContextId, identityAuthenticated: false as const, lastSequence: operatorSequence, hold: operatorHold, events: operatorEvents } }) | |
| 287 | } | |
| 288 | function decide(input: unknown, ...extra: unknown[]): { outcome: 'accepted' | 'refused', reason: string } { | |
| 289 | if (closed || terminal || stepping) return { outcome: 'refused', reason: 'session-unavailable' } | |
| 290 | let decision: Record<string, unknown> | |
| 291 | try { | |
| 292 | const fields = ['version', 'evidence', 'contextId', 'bootId', 'sessionId', 'sequence', 'issuedAtMs', 'reference', 'sourceEventId', 'action'] | |
| 293 | if (extra.length || !input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== fields.length) throw new TypeError() | |
| 294 | decision = {} | |
| 295 | for (const key of fields) { const d = Object.getOwnPropertyDescriptor(input, key); if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError(); decision[key] = d.value } | |
| 296 | if (decision.version !== 1 || decision.evidence !== 'modeled-cargo-transport-decision-v1' || typeof decision.contextId !== 'string' || decision.contextId.length > 80 | |
| 297 | || typeof decision.bootId !== 'bigint' || typeof decision.sessionId !== 'bigint' || !Number.isSafeInteger(decision.sequence) || (decision.sequence as number) < 1 || (decision.sequence as number) > 32 | |
| 298 | || !Number.isSafeInteger(decision.issuedAtMs) || (decision.issuedAtMs as number) < 0 || typeof decision.reference !== 'string' || decision.reference.length > 120 | |
| 299 | || typeof decision.sourceEventId !== 'string' || decision.sourceEventId.length > 160 || !['cancel', 'resume'].includes(decision.action as string)) throw new TypeError() | |
| 300 | } | |
| 301 | catch { throw new TypeError('cargo-fleet-operator-declaration') } | |
| 302 | if (closed || terminal || stepping) return { outcome: 'refused', reason: 'session-unavailable' } | |
| 303 | if (decision.contextId !== operatorContextId || decision.bootId !== 102n || decision.sessionId !== 202n || decision.reference !== progressB?.reference || decision.sourceEventId !== sourceRelease?.eventId) return { outcome: 'refused', reason: 'operator-scope' } | |
| 304 | if (decision.issuedAtMs !== currentAtMs) return { outcome: 'refused', reason: 'operator-clock' } | |
| 305 | if (decision.sequence !== operatorSequence + 1 || operatorEvents.length >= 32) return { outcome: 'refused', reason: 'operator-sequence' } | |
| 306 | operatorSequence = decision.sequence as number | |
| 307 | const action = decision.action as 'cancel' | 'resume' | |
| 308 | const record = (outcome: 'accepted' | 'refused', reason: string) => { operatorEvents.push({ atMs: currentAtMs!, sequence: operatorSequence, action, outcome, reason }); return { outcome, reason } } | |
| 309 | if (physical.owner !== 'station-actor' || !station) return record('refused', 'parcel-not-at-station') | |
| 310 | if (action === 'cancel') { | |
| 311 | const result = station.cancel() | |
| 312 | if (result.outcome === 'refused') return record('refused', 'station-' + result.reason) | |
| 313 | commands.intake = null; operatorHold = true | |
| 314 | return record('accepted', 'transport-cancellation-requested') | |
| 315 | } | |
| 316 | const command = progressB?.mission.payload | |
| 317 | if (!operatorHold || !command || command.operation !== 'material-receive' || !hasAuthority(progressB, command, currentAtMs!, 102n, 202n) || sourceCoordinator.snapshot().delivery.outcome !== 'received') return record('refused', 'recovery-authority-unavailable') | |
| 318 | if (intakeCoordinator.snapshot().custody.grantedAtMs !== null) return record('refused', 'intake-already-attempted') | |
| 319 | const result = station.resume() | |
| 320 | if (result.outcome === 'refused') return record('refused', 'station-' + result.reason) | |
| 321 | operatorHold = false | |
| 322 | return record('accepted', 'transport-resumed') | |
| 323 | } | |
| 324 | function result() { | |
| 325 | if (!terminal) throw new Error('Cargo fleet session is still active') | |
| 239 | 326 | const sourceRun = a.result(), receiverRun = b.result(), sourceDelivery = sourceCoordinator.snapshot().delivery, receiverDelivery = deliveryCoordinator.snapshot().delivery, receiverIntake = intakeCoordinator.snapshot() |
| 240 | 327 | 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 |
| 241 | 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, | |
| 328 | return structuredClone({ 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, | |
| 242 | 329 | 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 }, |
| 243 | 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 } } | |
| 330 | 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 }, ...(operatorEvents.length ? { operator: { contextId: operatorContextId, identityAuthenticated: false as const, events: operatorEvents } } : {}) }) | |
| 331 | } | |
| 332 | return { step, snapshot, decide, result, close } | |
| 333 | } | |
| 334 | ||
| 335 | /** Finite one-shot facade over the same retained shared world. */ | |
| 336 | export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) { | |
| 337 | const world = createCargoFleetSession(input, ...extra) | |
| 338 | try { | |
| 339 | for (let nowMs = 0; ; nowMs += 4) if (world.step(nowMs).terminal) return world.result() | |
| 244 | 340 | } |
| 245 | finally { a.close(); b.close() } | |
| 341 | finally { world.close() } | |
| 246 | 342 | } |
| 247 | 343 | |
| 248 | 344 | /** Compact actual observations for Buddy/standalone output; never serialize the full runtime journal. */ |
| @@ -261,5 +357,5 @@ export function cargoFleetSummary(run: ReturnType<typeof simulateCargoFleet>) { | ||
| 261 | 357 | 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, |
| 262 | 358 | 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 }, |
| 263 | 359 | massBeforeKg: attachment!.outcome === 'attached' ? attachment!.before.properties.totalMassKg : null, massAfterKg: attachment!.outcome === 'attached' ? attachment!.after.properties.totalMassKg : null } : null, finalRelease: release(run.transfer.finalRelease) }, |
| 264 | station: run.station, recipient: run.recipient, loose: run.loose, ownership: run.ownership } | |
| 360 | station: run.station, recipient: run.recipient, loose: run.loose, ownership: run.ownership, ...(run.operator ? { operator: run.operator } : {}) } | |
| 265 | 361 | } |
| @@ -23,6 +23,7 @@ export interface CargoStationSnapshot { | ||
| 23 | 23 | parcel: IntakeParcel | null |
| 24 | 24 | forceN: SensorVector |
| 25 | 25 | impact: { atMs: number, positionM: SensorVector, velocityBeforeMps: SensorVector, impulseNs: SensorVector, dissipationJ: number } | null |
| 26 | resumptions?: { atMs: number, previousStartedAtMs: number | null, cancelRequestedAtMs: number, stoppedAtMs: number, positionM: SensorVector, durationMs: number }[] | |
| 26 | 27 | energy: { initialJ: number, currentJ: number, departedJ: number, stationNetWorkJ: number, positiveWorkJ: number, negativeWorkJ: number, planeDissipationJ: number, residualJ: number } |
| 27 | 28 | } |
| 28 | 29 | function record(input: unknown, names: string[]) { |
| @@ -77,6 +78,7 @@ export function createCargoStationHandler(input: unknown) { | ||
| 77 | 78 | let forceN: SensorVector = [0, 0, 0], impact: CargoStationSnapshot['impact'] = null |
| 78 | 79 | const mechanical = (p: IntakeParcel) => .5 * p.massKg * p.velocityMps.reduce((sum, n) => sum + n * n, 0) - p.massKg * gravity * p.positionM[2] |
| 79 | 80 | const initialJ = mechanical(parcel) |
| 81 | const resumptions: NonNullable<CargoStationSnapshot['resumptions']> = [] | |
| 80 | 82 | let departedJ = 0, stationNetWorkJ = 0, positiveWorkJ = 0, negativeWorkJ = 0, planeDissipationJ = 0 |
| 81 | 83 | function capturePlane(atMs: number) { |
| 82 | 84 | const p = parcel!, before = [...p.velocityMps] as SensorVector |
| @@ -96,7 +98,7 @@ export function createCargoStationHandler(input: unknown) { | ||
| 96 | 98 | function snapshot(): CargoStationSnapshot { |
| 97 | 99 | const currentJ = parcel ? mechanical(parcel) : 0 |
| 98 | 100 | return structuredClone({ evidence: 'modeled-cargo-station-v1', physicalActuationEnabled: false, phase, atMs: nowMs, startedAtMs, durationMs: motionMs, cancelRequestedAtMs, stoppedAtMs, parcel, |
| 99 | ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ } }) | |
| 101 | ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ }, ...(resumptions.length ? { resumptions } : {}) }) | |
| 100 | 102 | } |
| 101 | 103 | return { |
| 102 | 104 | snapshot, |
| @@ -108,6 +110,17 @@ export function createCargoStationHandler(input: unknown) { | ||
| 108 | 110 | fromM = [...parcel!.positionM]; motionMs = duration; startedAtMs = nowMs; phase = 'moving' |
| 109 | 111 | return { outcome: 'started', atMs: nowMs, durationMs: duration } |
| 110 | 112 | }, |
| 113 | resume(...args: unknown[]): { outcome: 'resumed', atMs: number, durationMs: number } | { outcome: 'refused', reason: 'not-stopped' | 'pickup-missed' | 'recovery-limit' | 'deadline' } { | |
| 114 | if (args.length) throw new TypeError('cargo-station-arguments') | |
| 115 | if (phase !== 'cancelled' || !parcel || cancelRequestedAtMs === null || stoppedAtMs === null || nowMs < stoppedAtMs || Math.hypot(...parcel.velocityMps) !== 0) return { outcome: 'refused', reason: 'not-stopped' } | |
| 116 | if (startedAtMs === null && (!impact || Math.hypot(impact.positionM[0] - config.pickup.centerM[0], impact.positionM[1] - config.pickup.centerM[1]) > config.pickup.radiusM)) return { outcome: 'refused', reason: 'pickup-missed' } | |
| 117 | if (resumptions.length >= 8) return { outcome: 'refused', reason: 'recovery-limit' } | |
| 118 | const duration = durationMs(distance(parcel.positionM, config.destinationM), config.maxSpeedMps, config.maxAccelerationMps2) | |
| 119 | if (nowMs - config.atMs + duration > 180000) return { outcome: 'refused', reason: 'deadline' } | |
| 120 | resumptions.push({ atMs: nowMs, previousStartedAtMs: startedAtMs, cancelRequestedAtMs, stoppedAtMs, positionM: [...parcel.positionM], durationMs: duration }) | |
| 121 | fromM = [...parcel.positionM]; motionMs = duration; startedAtMs = nowMs; cancelRequestedAtMs = null; stoppedAtMs = null; brake = null; phase = 'moving' | |
| 122 | return { outcome: 'resumed', atMs: nowMs, durationMs: duration } | |
| 123 | }, | |
| 111 | 124 | cancel(...args: unknown[]): { outcome: 'cancelling' | 'cancelled', atMs: number } | { outcome: 'refused', reason: 'already-cancelled' | 'departed' } { |
| 112 | 125 | if (args.length) throw new TypeError('cargo-station-arguments') |
| 113 | 126 | if (phase === 'departed') return { outcome: 'refused', reason: 'departed' } |
| @@ -47,4 +47,33 @@ for (const [scenario, status, owner, massKg, custody] of [ | ||
| 47 | 47 | rows.push({ scenario, exit: r.exitCode, status, owner, receiverMassKg: massKg, custody, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) }) |
| 48 | 48 | console.log(scenario + ' PASS') |
| 49 | 49 | } |
| 50 | await 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)) | |
| 50 | const operatorRows: Record<string, unknown>[] = [] | |
| 51 | for (const name of ['cancel', 'cancel-resume', 'cancel-after-grant', 'resume-after-navigation-loss']) { | |
| 52 | const r = await runBoundedHostProcess([binary, 'cargo:fleet-control', '--case', name], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet control ' + name }) | |
| 53 | const bytes = Buffer.from(r.stdout, 'utf8') | |
| 54 | await Bun.write(join(output, 'operator-' + name + '.stdout'), bytes); await Bun.write(join(output, 'operator-' + name + '.stderr'), r.stderr) | |
| 55 | const completed = name === 'cancel-resume', s = JSON.parse(r.stdout) | |
| 56 | if (r.exitCode !== (completed ? 0 : 2) || r.stderr !== '' || !r.stdout.endsWith('\n') || r.stdout.split('\n').length !== 2 | |
| 57 | || s.evidence !== 'owned-cargo-fleet-simulation' || s.operatorCase !== name || s.status !== (completed ? 'completed' : 'unqualified') || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false | |
| 58 | || s.operator.identityAuthenticated !== false || !s.operator.contextId || s.operator.events[0]?.action !== 'cancel' || s.operator.events[0]?.outcome !== 'accepted' || s.operator.events[0]?.sequence !== 1 | |
| 59 | || s.ownership.finalMassKg !== 3.1 || s.ownership.maximumMassErrorKg > 1e-12 || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202') throw new Error('Unqualified copied operator report: ' + name) | |
| 60 | 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) throw new Error('Unqualified copied operator output inhibition: ' + name) | |
| 61 | if (completed) { | |
| 62 | const resume = s.station.resumptions?.[0] | |
| 63 | if (s.operator.events.length !== 2 || s.operator.events[1].action !== 'resume' || s.operator.events[1].outcome !== 'accepted' || s.operator.events[1].sequence !== 2 || !resume || s.station.resumptions.length !== 1 | |
| 64 | || resume.atMs - resume.stoppedAtMs < 1000 || s.operator.events[1].atMs !== resume.atMs || resume.cancelRequestedAtMs !== s.operator.events[0].atMs | |
| 65 | || s.ownership.owner !== 'final-parcel-actor' || s.receiver.intake.outcome !== 'received' || s.receiver.delivery.outcome !== 'received' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6 | |
| 66 | || s.transfer.intake.atMs <= resume.atMs || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.recipient.ownedMassKg !== .1 || s.ownership.transitions.length !== 3) throw new Error('Unqualified copied operator recovery: ' + name) | |
| 67 | } | |
| 68 | else { | |
| 69 | if (s.ownership.owner !== 'station-actor' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.station.resumptions !== undefined || s.transfer.intake !== null || s.transfer.finalRelease !== null || s.receiver.finalLoad.totalMassKg !== 1.5) throw new Error('Unqualified copied operator retention: ' + name) | |
| 70 | if (name === 'cancel' && s.operator.events.length !== 1) throw new Error('Unexpected copied recovery') | |
| 71 | if (name !== 'cancel' && (s.operator.events.length !== 2 || s.operator.events[1].action !== 'resume' || s.operator.events[1].outcome !== 'refused' || s.operator.events[1].sequence !== 2 | |
| 72 | || s.operator.events[1].reason !== (name === 'cancel-after-grant' ? 'intake-already-attempted' : 'recovery-authority-unavailable'))) throw new Error('Unqualified copied refused recovery: ' + name) | |
| 73 | if (name === 'cancel-after-grant' && s.receiver.intake.outcome !== 'uncertain') throw new Error('Lost attempted intake uncertainty') | |
| 74 | if (name === 'resume-after-navigation-loss' && s.receiver.fault !== 'estimator-loss') throw new Error('Lost operator authority failure') | |
| 75 | } | |
| 76 | operatorRows.push({ case: name, exit: r.exitCode, status: s.status, owner: s.ownership.owner, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) }) | |
| 77 | console.log('operator-' + name + ' PASS') | |
| 78 | } | |
| 79 | await 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, operatorRows }, null, 2)) | |
| @@ -29,6 +29,8 @@ | ||
| 29 | 29 | "./unit/cargo-receive-mission.test.ts", |
| 30 | 30 | "./unit/cargo-fleet.test.ts", |
| 31 | 31 | "./unit/cargo-fleet-cli.test.ts", |
| 32 | "./unit/cargo-fleet-operator.test.ts", | |
| 33 | "./unit/cargo-fleet-control-cli.test.ts", | |
| 32 | 34 | "./unit/cargo-delivery.test.ts", |
| 33 | 35 | "./unit/cargo-delivery-config.test.ts", |
| 34 | 36 | "./unit/cargo-delivery-phase-faults.test.ts", |
| @@ -0,0 +1,47 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | ||
| 4 | test('native fleet operator command resumes the same retained parcel and reports bounded decision evidence', async () => { | |
| 5 | const result = await command(['cargo:fleet-control', '--case', 'cancel-resume']).withTimeout(30000) | |
| 6 | expect(result.exitCode).toBe(0) | |
| 7 | const report = JSON.parse(result.stdout) | |
| 8 | expect(report.status).toBe('completed') | |
| 9 | expect(report.operator.identityAuthenticated).toBe(false) | |
| 10 | expect(report.operator.events.map((e: { action: string, outcome: string }) => [e.action, e.outcome])).toEqual([['cancel', 'accepted'], ['resume', 'accepted']]) | |
| 11 | expect(report.station.resumptions).toHaveLength(1) | |
| 12 | expect(report.station.resumptions[0].atMs - report.station.resumptions[0].stoppedAtMs).toBeGreaterThanOrEqual(1000) | |
| 13 | expect(report.receiver.scope).toEqual({ bootId: '102', sessionId: '202' }) | |
| 14 | expect(report.receiver.intake.outcome).toBe('received') | |
| 15 | expect(report.receiver.delivery.outcome).toBe('received') | |
| 16 | expect(report.ownership.finalMassKg).toBe(3.1) | |
| 17 | expect(report.physicalFlightEnabled).toBe(false) | |
| 18 | expect(report.source.board.actuators).toEqual([0, 0, 0, 0]) | |
| 19 | expect(report.receiver.board.actuators).toEqual([0, 0, 0, 0]) | |
| 20 | expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384) | |
| 21 | expect(result.stdout.split('\n')).toHaveLength(2) | |
| 22 | }, 40000) | |
| 23 | ||
| 24 | test('operator CLI preserves cancellation, attempted-intake uncertainty and failed authority as incomplete', async () => { | |
| 25 | for (const name of ['cancel', 'cancel-after-grant', 'resume-after-navigation-loss']) { | |
| 26 | const result = await command(['cargo:fleet-control', '--case', name]).withTimeout(30000) | |
| 27 | expect(result.exitCode).toBe(2) | |
| 28 | const report = JSON.parse(result.stdout) | |
| 29 | expect(report.status).toBe('unqualified') | |
| 30 | expect(report.ownership.owner).toBe('station-actor') | |
| 31 | expect(report.station.ownedMassKg).toBe(.1) | |
| 32 | expect(report.station.phase).toBe('cancelled') | |
| 33 | expect(report.station.resumptions).toBeUndefined() | |
| 34 | expect(report.transfer.intake).toBeNull() | |
| 35 | expect(report.ownership.finalMassKg).toBe(3.1) | |
| 36 | expect(report.receiver.finalLoad.totalMassKg).toBe(1.5) | |
| 37 | if (name === 'cancel-after-grant') expect(report.operator.events[1].reason).toBe('intake-already-attempted') | |
| 38 | if (name === 'resume-after-navigation-loss') expect(report.operator.events[1].reason).toBe('recovery-authority-unavailable') | |
| 39 | expect(result.stderr).toBe('') | |
| 40 | expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384) | |
| 41 | } | |
| 42 | const bad = await command(['cargo:fleet-control', '--case', 'private-invalid-marker']).withTimeout(30000) | |
| 43 | expect(bad.exitCode).toBe(1) | |
| 44 | expect(bad.stdout).toBe('') | |
| 45 | expect(bad.stderr).toContain('Cargo fleet control unavailable') | |
| 46 | expect(bad.stderr).not.toContain('private-invalid-marker') | |
| 47 | }, 130000) | |
| @@ -0,0 +1,210 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createCargoFleetSession } from '../../resources/functions/drone/cargo/fleet' | |
| 3 | import { simulateCargoFleetControl } from '../../resources/functions/drone/cargo/fleet-control' | |
| 4 | ||
| 5 | type World = ReturnType<typeof createCargoFleetSession> | |
| 6 | function decision(world: World, action: 'cancel' | 'resume', sequence: number) { | |
| 7 | const state = world.snapshot() | |
| 8 | return { version: 1, evidence: 'modeled-cargo-transport-decision-v1', contextId: state.operator.contextId, bootId: 102n, sessionId: 202n, reference: state.receiver!.reference, sourceEventId: state.stationSourceEventId!, sequence, issuedAtMs: state.atMs!, action } | |
| 9 | } | |
| 10 | ||
| 11 | // Public retained world seam. Both real runtime sessions and the same point/rail | |
| 12 | // keep advancing; operator transport decisions do not replace a body or estimator. | |
| 13 | test('scoped operator cancellation and later recovery finish the same parcel on the same receiving session', () => { | |
| 14 | const world = createCargoFleetSession() | |
| 15 | try { | |
| 16 | let nowMs = 0 | |
| 17 | for (; nowMs <= 60000; nowMs += 4) { | |
| 18 | world.step(nowMs) | |
| 19 | const station = world.snapshot().station | |
| 20 | if (station?.phase === 'moving' && Math.hypot(...station.parcel!.velocityMps) > .1) break | |
| 21 | } | |
| 22 | expect(nowMs).toBeLessThan(60000) | |
| 23 | const before = world.snapshot(), pendingId = before.receiver!.mission.payload!.eventId, epoch = before.receiver!.authority.epoch | |
| 24 | expect(world.decide(decision(world, 'cancel', 1)).outcome).toBe('accepted') | |
| 25 | expect(world.snapshot().station!.phase).toBe('braking') | |
| 26 | expect(Math.hypot(...world.snapshot().station!.parcel!.velocityMps)).toBeGreaterThan(0) | |
| 27 | while (world.snapshot().station!.phase === 'braking') { nowMs += 4; world.step(nowMs) } | |
| 28 | const stopped = world.snapshot() | |
| 29 | expect(stopped.ownership.owner).toBe('station-actor') | |
| 30 | expect(stopped.ownership.totalMassKg).toBe(3.1) | |
| 31 | expect(Math.hypot(...stopped.station!.parcel!.velocityMps)).toBe(0) | |
| 32 | const waitUntil = nowMs + 1000 | |
| 33 | for (nowMs += 4; nowMs <= waitUntil; nowMs += 4) { | |
| 34 | world.step(nowMs) | |
| 35 | expect(world.snapshot().receiver!.board.actuators).toEqual([0, 0, 0, 0]) | |
| 36 | expect(world.snapshot().receiver!.mission.payload!.eventId).toBe(pendingId) | |
| 37 | } | |
| 38 | nowMs -= 4 | |
| 39 | expect(world.decide(decision(world, 'resume', 2)).outcome).toBe('accepted') | |
| 40 | expect(world.snapshot().station!.parcel).toEqual(stopped.station!.parcel) | |
| 41 | expect(world.snapshot().receiver!.authority.epoch).toBe(epoch) | |
| 42 | while (!world.snapshot().terminal) { nowMs += 4; world.step(nowMs) } | |
| 43 | const run = world.result() | |
| 44 | expect(run.status).toBe('completed') | |
| 45 | expect(run.receiver.runtime.authority.sessionId).toBe(202n) | |
| 46 | expect(run.receiver.runtime.board.bootId).toBe(102n) | |
| 47 | expect(run.receiver.intake.custody.outcome).toBe('received') | |
| 48 | expect(run.receiver.delivery.outcome).toBe('received') | |
| 49 | expect(run.station!.resumptions).toHaveLength(1) | |
| 50 | expect(run.operator!.events.map(e => [e.action, e.outcome])).toEqual([['cancel', 'accepted'], ['resume', 'accepted']]) | |
| 51 | expect(run.ownership.transitions.map(e => [e.from, e.to])).toEqual([['source-body', 'station-actor'], ['station-actor', 'receiver-body'], ['receiver-body', 'final-parcel-actor']]) | |
| 52 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 53 | expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12) | |
| 54 | } | |
| 55 | finally { world.close() } | |
| 56 | }) | |
| 57 | ||
| 58 | function advanceUntil(world: World, predicate: (state: ReturnType<World['snapshot']>) => boolean, ceiling = 60000) { | |
| 59 | for (let nowMs = (world.snapshot().atMs ?? -4) + 4; nowMs <= ceiling; nowMs += 4) { | |
| 60 | const progress = world.step(nowMs), state = world.snapshot() | |
| 61 | if (predicate(state)) return nowMs | |
| 62 | if (progress.terminal) break | |
| 63 | } | |
| 64 | throw new Error('Expected bounded world phase') | |
| 65 | } | |
| 66 | const moving = (state: ReturnType<World['snapshot']>) => state.station?.phase === 'moving' && Math.hypot(...state.station.parcel!.velocityMps) > .1 | |
| 67 | ||
| 68 | test('operator scope, exact clock and sequence reject stale or foreign decisions before touching the parcel', () => { | |
| 69 | const world = createCargoFleetSession(), other = createCargoFleetSession() | |
| 70 | try { | |
| 71 | const nowMs = advanceUntil(world, moving) | |
| 72 | advanceUntil(other, moving) | |
| 73 | const packet = decision(world, 'cancel', 1), before = world.snapshot(), otherBefore = other.snapshot() | |
| 74 | expect(other.decide(packet)).toEqual({ outcome: 'refused', reason: 'operator-scope' }) | |
| 75 | expect(other.snapshot()).toEqual(otherBefore) | |
| 76 | for (const patch of [{ bootId: 103n }, { sessionId: 203n }, { reference: '102:1,0,0,0,0' }, { sourceEventId: 'foreign-source-event' }]) expect(world.decide({ ...packet, ...patch }).reason).toBe('operator-scope') | |
| 77 | expect(world.decide({ ...packet, issuedAtMs: nowMs - 4 }).reason).toBe('operator-clock') | |
| 78 | expect(world.snapshot()).toEqual(before) | |
| 79 | let invoked = 0 | |
| 80 | const unsafe = { ...packet, get action() { invoked++; return 'cancel' } } | |
| 81 | expect(() => world.decide(unsafe)).toThrow('cargo-fleet-operator-declaration') | |
| 82 | expect(invoked).toBe(0) | |
| 83 | expect(() => world.decide(packet, 'extra')).toThrow('cargo-fleet-operator-declaration') | |
| 84 | expect(() => world.step(nowMs + 8)).toThrow('cargo-fleet-clock') | |
| 85 | expect(world.snapshot()).toEqual(before) | |
| 86 | expect(world.decide(packet).outcome).toBe('accepted') | |
| 87 | expect(world.decide(packet).reason).toBe('operator-sequence') | |
| 88 | expect(world.snapshot().operator.events).toHaveLength(1) | |
| 89 | } | |
| 90 | finally { world.close(); other.close() } | |
| 91 | expect(world.decide({}).reason).toBe('session-unavailable') | |
| 92 | expect(() => world.step(0)).toThrow('Cargo fleet session is closed') | |
| 93 | expect(world.result().status).toBe('unqualified') | |
| 94 | expect(world.result().source.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 95 | expect(world.result().receiver.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 96 | }) | |
| 97 | ||
| 98 | test('a recovery requested while the parcel still brakes is refused without jumping to the old rail endpoint', () => { | |
| 99 | const world = createCargoFleetSession() | |
| 100 | try { | |
| 101 | advanceUntil(world, moving) | |
| 102 | world.decide(decision(world, 'cancel', 1)) | |
| 103 | const braking = world.snapshot().station! | |
| 104 | expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'station-not-stopped' }) | |
| 105 | expect(world.snapshot().station).toEqual(braking) | |
| 106 | advanceUntil(world, state => state.station?.phase === 'cancelled') | |
| 107 | expect(world.decide(decision(world, 'resume', 3)).outcome).toBe('accepted') | |
| 108 | const detached = world.snapshot(); detached.operator.events[0]!.reason = 'changed' | |
| 109 | expect(world.snapshot().operator.events[0]!.reason).toBe('transport-cancellation-requested') | |
| 110 | } | |
| 111 | finally { world.close() } | |
| 112 | }) | |
| 113 | ||
| 114 | test('cancellation between intake grant and mechanism consumes the grant and cannot be recovered into a second attempt', () => { | |
| 115 | const world = createCargoFleetSession() | |
| 116 | try { | |
| 117 | let nowMs = advanceUntil(world, state => state.intake.custody.grantedAtMs !== null) | |
| 118 | expect(world.snapshot().ownership.owner).toBe('station-actor') | |
| 119 | expect(world.decide(decision(world, 'cancel', 1)).outcome).toBe('accepted') | |
| 120 | expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'intake-already-attempted' }) | |
| 121 | while (!world.snapshot().terminal) { nowMs += 4; world.step(nowMs) } | |
| 122 | const run = world.result() | |
| 123 | expect(run.status).toBe('unqualified') | |
| 124 | expect(run.transfer.intake).toBeNull() | |
| 125 | expect(run.transfer.stationDeparture).toBeNull() | |
| 126 | expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 127 | expect(run.receiver.intake.custody.outcome).toBe('uncertain') | |
| 128 | expect(run.receiver.runtime.mission.reason).toBe('payload-diversion-landed') | |
| 129 | expect(run.station!.phase).toBe('cancelled') | |
| 130 | expect(run.station!.ownedMassKg).toBe(.1) | |
| 131 | expect(run.ownership.finalMassKg).toBe(3.1) | |
| 132 | } | |
| 133 | finally { world.close() } | |
| 134 | }) | |
| 135 | ||
| 136 | test('a current operator declaration cannot recover transport after receiver navigation authority expires', () => { | |
| 137 | const world = createCargoFleetSession({ scenario: 'receiver-navigation-loss-during-rail' }) | |
| 138 | try { | |
| 139 | advanceUntil(world, state => state.station?.phase === 'moving') | |
| 140 | world.decide(decision(world, 'cancel', 1)) | |
| 141 | advanceUntil(world, state => state.receiver?.authority.state === 'failsafe') | |
| 142 | const before = world.snapshot().station | |
| 143 | expect(world.decide(decision(world, 'resume', 2))).toEqual({ outcome: 'refused', reason: 'recovery-authority-unavailable' }) | |
| 144 | expect(world.snapshot().station).toEqual(before) | |
| 145 | expect(world.snapshot().ownership.owner).toBe('station-actor') | |
| 146 | } | |
| 147 | finally { world.close() } | |
| 148 | }) | |
| 149 | ||
| 150 | test('closing the retained world reports revoked current board output without pretending the parcel instantly stopped', () => { | |
| 151 | const world = createCargoFleetSession() | |
| 152 | advanceUntil(world, moving) | |
| 153 | const before = world.snapshot(), movingPoint = before.station!.parcel! | |
| 154 | world.close(); world.close() | |
| 155 | const closed = world.snapshot() | |
| 156 | expect(closed.closed).toBe(true) | |
| 157 | expect(closed.terminal).toBe(true) | |
| 158 | expect(closed.source!.hostAvailable).toBe(false) | |
| 159 | expect(closed.receiver!.hostAvailable).toBe(false) | |
| 160 | expect(closed.source!.board.actuators).toEqual([0, 0, 0, 0]) | |
| 161 | expect(closed.receiver!.board.actuators).toEqual([0, 0, 0, 0]) | |
| 162 | expect(closed.atMs).toBe(before.atMs) | |
| 163 | expect(closed.source!.policyAtMs).toBe(before.source!.policyAtMs) | |
| 164 | expect(closed.receiver!.policyAtMs).toBe(before.receiver!.policyAtMs) | |
| 165 | expect(closed.receiver!.reference).toBe(before.receiver!.reference) | |
| 166 | expect(closed.station!.phase).toBe('braking') | |
| 167 | expect(closed.station!.parcel).toEqual(movingPoint) | |
| 168 | expect(closed.ownership.totalMassKg).toBe(3.1) | |
| 169 | expect(world.decide({}).reason).toBe('session-unavailable') | |
| 170 | }) | |
| 171 | ||
| 172 | test('a declaration capture that closes the world cannot resume or consume an operator sequence', () => { | |
| 173 | const world = createCargoFleetSession() | |
| 174 | advanceUntil(world, moving) | |
| 175 | const packet = decision(world, 'cancel', 1) | |
| 176 | const proxy = new Proxy(packet, { getOwnPropertyDescriptor(target, key) { world.close(); return Reflect.getOwnPropertyDescriptor(target, key) } }) | |
| 177 | expect(world.decide(proxy)).toEqual({ outcome: 'refused', reason: 'session-unavailable' }) | |
| 178 | expect(world.snapshot().operator.lastSequence).toBe(0) | |
| 179 | expect(world.snapshot().operator.events).toHaveLength(0) | |
| 180 | expect(world.snapshot().closed).toBe(true) | |
| 181 | }) | |
| 182 | ||
| 183 | test('operator case admission rejects extra fields, accessors and extra arguments before creating a world', () => { | |
| 184 | let calls = 0 | |
| 185 | for (const declaration of [null, { case: 'unknown' }, { case: 'cancel', extra: true }, { get case() { calls++; return 'cancel' } }]) expect(() => simulateCargoFleetControl(declaration)).toThrow('cargo-fleet-control-arguments') | |
| 186 | expect(() => simulateCargoFleetControl({}, true)).toThrow('cargo-fleet-control-arguments') | |
| 187 | expect(calls).toBe(0) | |
| 188 | }) | |
| 189 | ||
| 190 | for (const completion of ['early-close', 'natural'] as const) { | |
| 191 | test(`fleet terminal results remain detached after ${completion}`, () => { | |
| 192 | const world = createCargoFleetSession({ scenario: completion === 'early-close' ? 'receiver-kill-after-intake' : 'nominal' }) | |
| 193 | try { | |
| 194 | if (completion === 'early-close') world.close() | |
| 195 | else for (let nowMs = 0; !world.step(nowMs).terminal; nowMs += 4) { /* Same finite native world. */ } | |
| 196 | const result = world.result(), expected = structuredClone(result) | |
| 197 | result.source.initialLoad.properties.totalMassKg = 0 | |
| 198 | result.receiver.initialLoad.properties.totalMassKg = 0 | |
| 199 | result.source.linkEvents.length = 0 | |
| 200 | result.ownership.transitions.length = 0 | |
| 201 | if (result.faultInjection) result.faultInjection.triggeredAtMs = 999 | |
| 202 | if (result.transfer.sourceRelease) result.transfer.sourceRelease.eventId = 'rewritten' | |
| 203 | if (result.transfer.intake) result.transfer.intake.receiver.board.actuators[0] = 77 | |
| 204 | expect(world.result()).toEqual(expected) | |
| 205 | world.close() | |
| 206 | expect(world.result()).toEqual(expected) | |
| 207 | } | |
| 208 | finally { world.close() } | |
| 209 | }) | |
| 210 | } | |
| @@ -190,5 +190,60 @@ test('invalid and exhausted clocks cannot change the parcel, and delayed starts | ||
| 190 | 190 | expect(() => station.step(180004)).toThrow('cargo-station-clock') |
| 191 | 191 | expect(station.snapshot()).toEqual(end) |
| 192 | 192 | expect(station.cancel()).toEqual({ outcome: 'cancelled', atMs: 180000 }) |
| 193 | expect(station.resume()).toEqual({ outcome: 'refused', reason: 'deadline' }) | |
| 193 | 194 | expect(station.takeReadyParcel().outcome).toBe('refused') |
| 194 | 195 | }) |
| 196 | ||
| 197 | test('an explicit station recovery preserves the braked parcel and bounded work before its single departure', () => { | |
| 198 | const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 199 | station.start() | |
| 200 | for (let atMs = 4; atMs <= 1000; atMs += 4) station.step(atMs) | |
| 201 | station.cancel() | |
| 202 | let nowMs = 1000 | |
| 203 | while (station.snapshot().phase === 'braking') { nowMs += 4; station.step(nowMs) } | |
| 204 | const stopped = station.snapshot() | |
| 205 | expect(stopped.phase).toBe('cancelled') | |
| 206 | expect(stopped.parcel!.positionM[0]).toBeGreaterThan(0) | |
| 207 | expect(stopped.parcel!.positionM[0]).toBeLessThan(1) | |
| 208 | expect(stopped.parcel!.velocityMps).toEqual([0, 0, 0]) | |
| 209 | expect(station.resume().outcome).toBe('resumed') | |
| 210 | const resumed = station.snapshot() | |
| 211 | expect(resumed.parcel).toEqual(stopped.parcel) | |
| 212 | expect(resumed.energy).toEqual(stopped.energy) | |
| 213 | expect(resumed.resumptions).toEqual([{ atMs: nowMs, previousStartedAtMs: 0, cancelRequestedAtMs: 1000, stoppedAtMs: stopped.stoppedAtMs!, positionM: stopped.parcel!.positionM, durationMs: resumed.durationMs! }]) | |
| 214 | while (station.snapshot().phase === 'moving') { | |
| 215 | nowMs += 4 | |
| 216 | const state = station.step(nowMs) | |
| 217 | expect(Math.hypot(...state.parcel!.velocityMps)).toBeLessThanOrEqual(.5 + 1e-12) | |
| 218 | expect(Math.hypot(state.forceN[0] / .1, state.forceN[1] / .1, state.forceN[2] / .1 + 9.80665)).toBeLessThanOrEqual(1 + 1e-12) | |
| 219 | expect(Math.abs(state.energy.residualJ)).toBeLessThan(1e-12) | |
| 220 | expect(nowMs).toBeLessThan(10000) | |
| 221 | } | |
| 222 | expect(station.snapshot().parcel!.positionM).toEqual([1, 0, 0]) | |
| 223 | expect(station.takeReadyParcel().outcome).toBe('departed') | |
| 224 | expect(station.resume().outcome).toBe('refused') | |
| 225 | expect(station.takeReadyParcel().outcome).toBe('refused') | |
| 226 | expect(station.snapshot().ownedMassKg).toBe(0) | |
| 227 | }) | |
| 228 | ||
| 229 | ||
| 230 | test('station recovery refuses missed pickup, motion, extra arguments and a ninth recovery without changing parcel ownership', () => { | |
| 231 | const declaration = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 } | |
| 232 | const station = createCargoStationHandler(declaration) | |
| 233 | expect(station.resume()).toEqual({ outcome: 'refused', reason: 'not-stopped' }) | |
| 234 | expect(() => station.resume('extra')).toThrow('cargo-station-arguments') | |
| 235 | station.start() | |
| 236 | for (let i = 0; i < 8; i++) { station.cancel(); expect(station.resume().outcome).toBe('resumed') } | |
| 237 | station.cancel() | |
| 238 | const stopped = station.snapshot() | |
| 239 | expect(station.resume()).toEqual({ outcome: 'refused', reason: 'recovery-limit' }) | |
| 240 | expect(station.snapshot()).toEqual(stopped) | |
| 241 | expect(station.snapshot().ownedMassKg).toBe(.1) | |
| 242 | expect(station.snapshot().resumptions).toHaveLength(8) | |
| 243 | const detached = station.snapshot(); detached.resumptions![0]!.positionM[0] = 77 | |
| 244 | expect(station.snapshot()).toEqual(stopped) | |
| 245 | const missed = createCargoStationHandler({ ...declaration, parcel: { ...declaration.parcel, positionM: [.2, 0, 0] } }) | |
| 246 | missed.cancel() | |
| 247 | expect(missed.resume()).toEqual({ outcome: 'refused', reason: 'pickup-missed' }) | |
| 248 | expect(missed.snapshot().parcel!.positionM).toEqual([.2, 0, 0]) | |
| 249 | }) | |