From c96542913ef8df85b05aae4249dcc6ab74ae19d8 Mon Sep 17 00:00:00 2001 From: timohueser Date: Mon, 24 Aug 2026 06:11:31 +0200 Subject: [PATCH 1/4] test(host): close the DeviceCore Phase 1 conformance gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DC7 (#1440) runs every DC1 scenario through five runners and compares what the rider can see, not what the legacy protocol said. The DC1 corpus — the scenario table, the fixtures and the legacy harness — moves into a shared `device_core_corpus` module so both test binaries drive the same definitions instead of two hand-kept copies. `device_core_legacy_traces` keeps its DC1 assertions unchanged. `device_core_conformance` adds the matrix: legacy immediate and delayed, DeviceCore immediate and scripted-delayed behind a typed executor, and the compatibility executor behind `LegacyAdapter`. Twenty scenarios by five runners; nine runner cells differ, and every one is dispositioned as a corrected defect or an accepted difference naming the slice that removes it. The table is exact in both directions, so a difference with no row — the epic's blocking disposition — fails the gate, and a row documenting a difference that no longer exists fails it too. The sixteen mandatory traces are bound to the tests that run them and the binding is checked, so a renamed trace fails rather than quietly leaving a row uncovered. Conformance code is test code: the board ELF is byte-identical to the pre-change build. Co-Authored-By: Claude Fable 5 --- .../tests/device_core_conformance.rs | 1521 +++++++++++++++++ .../tests/device_core_corpus/mod.rs | 1368 +++++++++++++++ .../tests/device_core_legacy_traces.rs | 1329 +------------- 3 files changed, 2898 insertions(+), 1320 deletions(-) create mode 100644 host/obc-host-core/tests/device_core_conformance.rs create mode 100644 host/obc-host-core/tests/device_core_corpus/mod.rs diff --git a/host/obc-host-core/tests/device_core_conformance.rs b/host/obc-host-core/tests/device_core_conformance.rs new file mode 100644 index 000000000..02e392fe6 --- /dev/null +++ b/host/obc-host-core/tests/device_core_conformance.rs @@ -0,0 +1,1521 @@ +//! DC7 — the DeviceCore Phase 1 conformance gate (#1440, epic #1433 §13). +//! +//! Every DC1 scenario, run through five runners, compared on what the rider can see: +//! +//! | Runner | Frame | Executor | +//! |---|---|---| +//! | `legacy-immediate` | `HostLoop::reconcile_commands_traced` | the shipping host, answers in the same call | +//! | `legacy-delayed` | the same | the same, answers a pass later | +//! | `core-immediate` | [`App::run_pass`] | typed effects in, typed outcomes back, same call | +//! | `core-delayed` | the same | the same, on a scripted delay | +//! | `compatibility` | the same | [`LegacyAdapter`] — effects out as `HostCommand`s, `HostEvent`s back | +//! +//! The comparison is **rider-visible state**, not command sequences: a domain whose lifecycle moved +//! into DeviceCore no longer speaks the legacy vocabulary, and requiring it to would pin the +//! migration in place. What must not change is what the device shows and holds. +//! +//! ## The three dispositions +//! +//! Every difference this file finds is either [`Disposition::Corrected`] — DeviceCore is right and +//! the old behaviour was a defect — or [`Disposition::Accepted`], with the reason and the slice that +//! removes it. The third disposition the epic names, a *blocking* conformance failure, is not a row: +//! it is this file failing, because a difference with no approved row is one nothing may ship over. +//! At the time of writing there is none. +//! +//! ## What Phase 1 does and does not own +//! +//! Three domains have a state machine today — the catalog, retention and weather — and those are +//! the three whose effects a pass emits and whose outcomes it consumes. Two of them can be reached +//! from outside `obc-app`: weather's refresh intent has no public door until #1401 lands the request +//! cutover, so this executor serves the catalog and retention, and asserts the rest stays empty. +//! +//! The other six domains speak the legacy protocol still, so both DeviceCore runners drain the +//! legacy mailbox for them. [`store_owned`] and [`derived_level`] are the line between the two, and +//! every class still on the old protocol has a [`LegacyOwned`] row naming the slice that moves it. + +mod device_core_corpus; + +use std::collections::BTreeSet; + +use obc_app::catalog_state::{CatalogEffect, CatalogError, CatalogOutcome}; +use obc_app::device_core::compat::{event_reply, LegacyOwned}; +use obc_app::device_core::derived::{ + DerivedInput, DerivedInputs, DerivedNeeds, DerivedResult, DerivedTargets, NavPreviewKey, RideTrackKey, +}; +use obc_app::device_core::{ + Capabilities, DeviceFacts, EffectSlots, LegacyAdapter, LegacyInputs, NavigatorTag, OutcomeSlots, PassClock, + PassInputs, PassPlan, PlatformSupport, Revision, SettingsTag, StoreIdentity, StoreRevision, TokenSource, + TransferState, +}; +use obc_app::dfu::DfuEffect; +use obc_app::navigator::{NavigatorEffect, NavigatorError, NavigatorOutcome, PlannerWork}; +use obc_app::recorder::{RecorderEffect, RecorderIntent}; +use obc_app::retention::{Retention, RetentionEffect, RetentionError, RetentionOutcome, RouteRetentionMeta}; +use obc_app::screen::Screen; +use obc_app::settings::{SettingsEffect, SettingsOutcome}; +use obc_app::weather::WeatherEffect; +use obc_app::{ + App, AppState, DetourRequest, DfuAction, Gesture, HostCommand, HostEvent, HostMailbox, NavRequest, TrackAction, + WarningFlags, +}; +use obc_host_core::trace::{run_scenario_seeded, RunnerMode, Trace, TraceHarness, TraceInput, TraceRecorder}; +use obc_ports::{Fix, InputClock, LocationSource, RideClock, Sensors, SettingsSaveError}; +use obc_route::NavError; + +use device_core_corpus::{ + clock_watermark, definition, normalization_seed, visible_state, Action, LegacyHarness, PendingSettingsResult, + Scenario, VisibleState, SCENARIOS, SETTINGS_FAILURE_RETRY_MS, +}; + +// ==================== the five runners ==================== + +/// One column of the conformance matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Runner { + LegacyImmediate, + LegacyDelayed, + CoreImmediate, + CoreDelayed, + Compatibility, +} + +impl Runner { + const ALL: [Runner; 5] = [ + Runner::LegacyImmediate, + Runner::LegacyDelayed, + Runner::CoreImmediate, + Runner::CoreDelayed, + Runner::Compatibility, + ]; + + const fn name(self) -> &'static str { + match self { + Runner::LegacyImmediate => "legacy-immediate", + Runner::LegacyDelayed => "legacy-delayed", + Runner::CoreImmediate => "core-immediate", + Runner::CoreDelayed => "core-delayed", + Runner::Compatibility => "compatibility", + } + } + + /// When completed work is handed back. The scripted delay is deliberately uneven, so a runner + /// that only ever sees one cadence cannot pass by accident. + const fn mode(self) -> RunnerMode { + match self { + Runner::LegacyImmediate | Runner::CoreImmediate | Runner::Compatibility => RunnerMode::Immediate, + Runner::LegacyDelayed => RunnerMode::OnePassDelayed, + Runner::CoreDelayed => RunnerMode::ScriptedDelay(&[2, 0, 1]), + } + } + + /// Run one scenario, then let the runner settle, and report both. + fn run(self, scenario: &Scenario) -> Run { + let definition = definition(scenario); + match self { + Runner::LegacyImmediate | Runner::LegacyDelayed => { + let mut harness = LegacyHarness::new(); + let trace = run_scenario_seeded(&definition, self.mode(), &normalization_seed(), &mut harness); + Run::finish(self, scenario, trace, &mut harness) + } + Runner::CoreImmediate | Runner::CoreDelayed | Runner::Compatibility => { + let executor = if self == Runner::Compatibility { Executor::Compatibility } else { Executor::Typed }; + let mut harness = CoreHarness::new(executor); + let trace = run_scenario_seeded(&definition, self.mode(), &normalization_seed(), &mut harness); + Run::finish(self, scenario, trace, &mut harness) + } + } + } +} + +/// One runner's result: the recorded trace, and the state it comes to rest in. +/// +/// The two are different questions. A trace ends the moment the last delayed answer is delivered, +/// which for a *level* is one pass before the level can be consumed — so comparing traces at that +/// instant would compare delivery cadence rather than behaviour. The settled state is what "the same +/// device" means, and it is what the matrix compares (the same rule +/// `device_core_compat` follows: compare at rest, never mid-flight). +struct Run { + trace: Trace, + settled: VisibleState, +} + +impl Run { + fn finish(runner: Runner, scenario: &Scenario, trace: TraceResult, harness: &mut H) -> Run + where + H: TraceHarness, + { + let trace = trace.unwrap_or_else(|error| panic!("{} failed in {}: {error:?}", scenario.name, runner.name())); + let mut recorder = recorder(); + for _ in 0..SETTLE_PASSES { + for done in harness.run_pass(&mut recorder) { + harness.deliver(done, &mut recorder); + } + } + Run { settled: harness.snapshot(), trace } + } +} + +type TraceResult = Result, obc_host_core::trace::TraceRunError>; + +/// Quiet passes after the scripted inputs end. Enough for a level to be answered and consumed, and +/// for a deferred value to reach the component behind it. +const SETTLE_PASSES: usize = 6; + +/// A platform that implements everything, so no capability hides a path the matrix means to run. +const EVERYTHING: PlatformSupport = PlatformSupport { + detour: true, + settings_persistence: true, + dfu: true, + weather: true, + bonding: true, + storage_space_report: true, +}; + +/// The rider-visible projection two runners must agree on. +/// +/// Two fields are dropped, and both for the same reason: they are *legacy* internals rather than +/// anything the rider can see. `pending_host_command` asks the old protocol whether it has a command +/// queued, which a domain that no longer speaks it will always answer `false` to; +/// `retention_delete_attempts` counts calls into the legacy repository trait, which the typed +/// executor does not implement. Requiring either would be requiring the legacy command sequence. +fn rider_visible(mut state: VisibleState) -> VisibleState { + state.pending_host_command = false; + state.retention_delete_attempts = 0; + state +} + +// ==================== what the pass owns today ==================== + +/// The legacy classes DeviceCore's pass has taken over outright. +/// +/// A rider's delete is consumed at stage 4 and a retention stamp leaves as a `RetentionEffect`, so +/// neither pends on the old protocol any more. A DeviceCore runner asserts they do not appear rather +/// than filtering them: executing one beside the effect that already carries it would delete or +/// stamp twice, and a class that quietly came back would be the migration coming undone. +fn store_owned(command: &HostCommand) -> bool { + matches!( + command, + HostCommand::DeleteRoute { .. } + | HostCommand::DeleteRide { .. } + | HostCommand::StampRouteUsed { .. } + | HostCommand::StampRideSynced { .. } + ) +} + +/// The two derived cues, which are *levels* rather than one-shots: they are re-derived from state on +/// every drain, so they keep pending and a DeviceCore runner declines them every time — the plan's +/// keyed [`DerivedNeeds`] is what it answers instead (#1437). +fn derived_level(command: &HostCommand) -> Option<&'static str> { + match command { + HostCommand::LoadRideTrack { .. } => Some("LoadRideTrack"), + HostCommand::RefreshNavPreview => Some("RefreshNavPreview"), + _ => None, + } +} + +// ==================== the DeviceCore harness ==================== + +/// Which executor sits behind the pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Executor { + /// Bounded effects in, typed token-carrying outcomes back. What #1397 S6 builds for real. + Typed, + /// The same effects through [`LegacyAdapter`], executed as `HostCommand`s and answered with + /// `HostEvent`s. What a host that has not migrated yet can run today. + Compatibility, +} + +struct NoFix; +impl LocationSource for NoFix { + fn poll(&mut self) -> Option { + None + } +} + +/// Which resident catalog a completed store operation changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Refeed { + None, + Routes, + Rides, +} + +/// One thing the executor finished, on its way back into the next pass. +#[derive(Debug)] +enum Done { + /// A typed catalog answer, plus the resident re-feed the store change implies. Bulk never + /// enters the protocol, so the catalog arrives through the same feeders it always did. + Catalog { + outcome: CatalogOutcome, + refeed: Refeed, + }, + Retention(RetentionOutcome), + /// A legacy answer, for a domain whose machine has not landed. + Event(HostEvent), + RideTrack(DerivedInput), + NavPreview(DerivedInput), +} + +/// The pass, one executor, and the shared scenario fixture. +/// +/// The fixture — the app, the catalogs and the scripted planner/DFU/settings answers — is +/// [`LegacyHarness`], unchanged: the two harnesses apply the *same* rider inputs and differ only in +/// what runs the frame. That is what makes a difference in the trace a difference in the runner. +struct CoreHarness { + state: LegacyHarness, + executor: Executor, + adapter: LegacyAdapter, + /// What the executor has handed back since the last pass. + inbox: LegacyInputs, + /// The pass's own monotonic clock. The legacy harness has none — its actions move the app's + /// animation clock directly — so this stays at or above every mark those actions set. + clock_ms: u32, + /// The bounded polylines a derived answer carries beside its key. + ride_preview: Vec<(i32, i32)>, + nav_preview: Vec<(i32, i32)>, + /// Legacy classes the pass took ownership of, so the run can prove it moved rather than dropped. + moved: BTreeSet<&'static str>, + /// Effects the executor served, by domain. + served: BTreeSet<&'static str>, + /// Effects the adapter could not express at all, by row. + left: BTreeSet, +} + +impl CoreHarness { + fn new(executor: Executor) -> Self { + CoreHarness { + state: LegacyHarness::new(), + executor, + adapter: LegacyAdapter::new(), + inbox: LegacyInputs::new(), + clock_ms: 0, + ride_preview: Vec::new(), + nav_preview: Vec::new(), + moved: BTreeSet::new(), + served: BTreeSet::new(), + left: BTreeSet::new(), + } + } + + fn app(&mut self) -> &mut App { + &mut self.state.app + } + + /// One DeviceCore frame: whatever the executor handed back, then fourteen stages, then a plan. + /// + /// The clock moves one millisecond per pass. The legacy harness has none — its actions drive the + /// app's animation clock directly, and time otherwise stands still — so a runner that ran the + /// clock faster would age cards and idle timers the legacy baseline never sees, and the matrix + /// would compare elapsed time rather than behaviour. The marks the actions do set are followed + /// exactly (see [`clock_watermark`]). + fn pass(&mut self) -> PassPlan { + self.clock_ms += 1; + let mut inputs = std::mem::take(&mut self.inbox); + let ride_preview = std::mem::take(&mut self.ride_preview); + let nav_preview = std::mem::take(&mut self.nav_preview); + let mut location = NoFix; + let plan = self.state.app.run_pass(PassInputs { + now: PassClock { ride: RideClock(self.clock_ms), ui: InputClock(self.clock_ms) }, + gestures: &[], + sensors: Sensors::new(&mut location), + route: None, + support: EVERYTHING, + outcomes: &mut inputs.outcomes, + facts: &mut inputs.facts, + derived: inputs.derived, + targets: DerivedTargets { ride_preview: &ride_preview, nav_preview: &nav_preview }, + }); + // A derived answer was either accepted or was about something else; either way it is spent. + // Outcomes and facts with no owner stay where the executor put them. + inputs.derived = DerivedInputs::NONE; + self.inbox = inputs; + plan + } + + // ---- the typed executor ---- + + /// Serve what a host outside `obc-app` can actually cause. + /// + /// The catalog and retention are the two domains whose effects a rider action or a retention + /// sweep produces. Weather has a machine too, but its refresh intent has no public door yet + /// (#1401 owns the request cutover), and the remaining six have no machine at all — so nothing + /// else may appear. Asserted rather than assumed: an effect this executor cannot serve turning + /// up would be a silent change of who decides, and the run must stop rather than skip it. + fn serve_typed(&mut self, effects: &mut EffectSlots, done: &mut Vec) { + if let Some(effect) = effects.catalog.take() { + self.served.insert("catalog"); + done.push(self.serve_catalog(effect)); + } + if let Some(effect) = effects.retention.take() { + self.served.insert("retention"); + done.push(Done::Retention(self.serve_retention(effect))); + } + assert!(!effects.has_pending(), "only catalog and retention effects are reachable from a host in Phase 1"); + } + + fn serve_catalog(&mut self, effect: CatalogEffect) -> Done { + match effect { + CatalogEffect::ReadCatalog { .. } => { + // A refresh needs `CatalogIntent::Refresh`, which the pass does not produce yet: a + // store commit still becomes the legacy `RescanStore` cue (LegacyOwned::StoreRevision). + panic!("no catalog refresh intent exists until #1397 S6 moves the store executor") + } + CatalogEffect::RemoveObject { token, object } => { + if let Some(index) = self.state.route_ids.iter().position(|&id| id == object) { + self.state.retention_delete_attempts = self.state.retention_delete_attempts.saturating_add(1); + if std::mem::take(&mut self.state.route_delete_fail_once) { + // The store refused the removal. Not `existed: false` — the object is still + // there, which is what makes retention re-queue its candidate. + return Done::Catalog { + outcome: CatalogOutcome::Failed { token, error: CatalogError::RemoveFailed }, + refeed: Refeed::None, + }; + } + self.state.routes.remove(index); + self.state.route_ids.remove(index); + return Done::Catalog { + outcome: CatalogOutcome::ObjectRemoved { token, object, existed: true }, + refeed: Refeed::Routes, + }; + } + if let Some(index) = self.state.ride_ids.iter().position(|&id| id == object) { + self.state.rides.remove(index); + self.state.ride_ids.remove(index); + return Done::Catalog { + outcome: CatalogOutcome::ObjectRemoved { token, object, existed: true }, + refeed: Refeed::Rides, + }; + } + // The subject vanished before the commit — a success for the goal state, and the + // one shape that must not read as a failure (epic §13). + Done::Catalog { + outcome: CatalogOutcome::ObjectRemoved { token, object, existed: false }, + refeed: Refeed::None, + } + } + CatalogEffect::ReadTripMembers { .. } => { + panic!("the trip cascade is refused at admission until #1397 lands the bounded member read") + } + } + } + + /// The sidecar writes. The fixture keeps no durable sidecar, so the answer *is* the write — + /// what matters here is that it carries the operation's token back, which the legacy protocol + /// has no way to do (`LegacyOwned::SidecarAck`). + fn serve_retention(&mut self, effect: RetentionEffect) -> RetentionOutcome { + match effect { + RetentionEffect::WriteRouteMetadata { token, id, .. } => { + RetentionOutcome::RouteMetadataWritten { token, id } + } + RetentionEffect::WriteRideMetadata { token, id, .. } => RetentionOutcome::RideMetadataWritten { token, id }, + } + } + + // ---- the compatibility executor ---- + + /// The same effects, through the adapter and out as legacy commands. + /// + /// The two counts this leaves behind are the whole point of running it beside the typed + /// executor: what the adapter *sent*, and what it could not express at all. + fn serve_compat( + &mut self, + effects: &mut EffectSlots, + needs: &DerivedNeeds, + done: &mut Vec, + trace: &mut TraceRecorder, + ) { + let mut mail: HostMailbox = HostMailbox::new(); + let report = self.adapter.effects_to_commands(effects, &mut mail); + for row in LegacyOwned::ALL { + if report.owned.contains(row) { + self.left.insert(row); + } + } + self.adapter.needs_to_commands(needs, &mut mail); + while let Some(command) = mail.pop() { + if let Some(level) = derived_level(&command) { + // The adapter re-emits the two levels as their old cues; this runner answers them + // from the plan's keys instead, which is the whole of #1437. + self.moved.insert(level); + continue; + } + self.served.insert("adapter"); + trace.record_command(&command); + self.serve_legacy(command, done, trace); + } + } + + // ---- the legacy half, for the six domains without a machine ---- + + fn serve_mailbox(&mut self, done: &mut Vec, trace: &mut TraceRecorder) { + let mut mail: HostMailbox = HostMailbox::new(); + let _ = self.state.app.drain_host_commands(&mut mail); + let mut persisted = None; + while let Some(command) = mail.pop() { + if let Some(level) = derived_level(&command) { + self.moved.insert(level); + continue; + } + assert!( + !store_owned(&command), + "{command:?} is DeviceCore's now — running it here would repeat the effect that carries it" + ); + if let HostCommand::PersistSettings { revision } = command { + persisted = Some(revision); + } + trace.record_command(&command); + self.serve_legacy(command, done, trace); + } + // The scripted answers the legacy corpus arms at its protocol boundary, drained exactly as + // the legacy runner drains them — this half of both DeviceCore runners is unchanged by + // design, because these six domains have not migrated. + if let Some(result) = self.state.pending_nav_plan.take() { + done.push(Done::Event(HostEvent::NavPlanned(result))); + } + if std::mem::take(&mut self.state.commit_success_pending) { + done.push(Done::Event(HostEvent::DetourCommitted(Ok(10)))); + } + let ready = !matches!(self.state.pending_settings_result, Some(PendingSettingsResult::PersistLatest)) + || persisted.is_some(); + if ready { + if let Some(result) = self.state.pending_settings_result.take() { + let revision = match result { + PendingSettingsResult::PersistRevision(revision) => revision, + PendingSettingsResult::PersistLatest | PendingSettingsResult::FailLatest => { + persisted.unwrap_or(self.state.settings_revision) + } + }; + done.push(Done::Event(match result { + PendingSettingsResult::FailLatest => { + HostEvent::SettingsPersistFailed { revision, error: SettingsSaveError::Backend } + } + PendingSettingsResult::PersistLatest | PendingSettingsResult::PersistRevision(_) => { + HostEvent::SettingsPersisted { revision } + } + })); + } + } + } + + fn serve_legacy(&mut self, command: HostCommand, done: &mut Vec, trace: &mut TraceRecorder) { + match command { + HostCommand::RescanStore { .. } => { + self.state.feed_routes("core.routes", trace); + self.state.feed_trips("core.trips", trace); + self.state.feed_rides("core.rides", trace); + } + HostCommand::DeleteTrip { id } => { + // The legacy host runs the whole cascade inside one command + // (`LegacyOwned::TripCascade`); the bounded member read arrives with #1397. + if self.state.trip_present && id == 50 { + for member in self.state.trip_stage_ids.clone() { + if let Some(index) = self.state.route_ids.iter().position(|&id| id == member) { + self.state.routes.remove(index); + self.state.route_ids.remove(index); + } + } + self.state.trip_present = false; + self.state.feed_routes("core.cascade-routes", trace); + self.state.feed_trips("core.cascade-trips", trace); + } + } + HostCommand::Dfu(DfuAction::Scan) => { + if let Some(result) = self.state.pending_dfu_scan.take() { + done.push(Done::Event(HostEvent::DfuScanned(result))); + } + } + HostCommand::Dfu(DfuAction::Install) => { + if let Some(result) = self.state.pending_dfu_install.take() { + done.push(Done::Event(match result { + Ok(()) => HostEvent::DfuInstallBegan, + Err(error) => HostEvent::DfuInstallFailed(error), + })); + } + } + HostCommand::ScanCardFree => { + done.push(Done::Event(HostEvent::CardScanned { free_bytes: Some(8 * 1024 * 1024) })); + } + // The sidecar stamps are fire-and-forget in the old protocol: the write happens and + // nothing acknowledges it (`LegacyOwned::SidecarAck`), which is exactly why + // RetentionMachine stays in flight behind one under the compatibility executor. + HostCommand::StampRouteUsed { .. } | HostCommand::StampRideSynced { .. } => { + self.served.insert("legacy-stamp"); + } + // Cancels, plan requests, the detour commit and the ride close are consumed without + // being started: the corpus scripts their completion at the protocol boundary, exactly + // as the legacy runner does (`PlanHold`, `hold_detour_commit`). + HostCommand::PersistSettings { .. } + | HostCommand::CancelRoutePlan + | HostCommand::CancelDetour + | HostCommand::PlanRoute(_) + | HostCommand::PlanDetour(_) + | HostCommand::CommitDetour + | HostCommand::FinishTrack(_) + | HostCommand::ForgetBond => {} + other => panic!("{other:?} is pass-owned and must not reach the legacy executor"), + } + } + + // ---- the two derived levels ---- + + /// Answer each level with the key the need carried. Under a delayed runner the subject may have + /// moved by the time this lands, and the pass drops it — which is the corrected defect. + fn serve_derived(&mut self, needs: &DerivedNeeds, done: &mut Vec) { + if let Some(key) = needs.ride_track { + done.push(Done::RideTrack(DerivedInput::filled(key))); + } + if let Some(key) = needs.nav_preview { + done.push(Done::NavPreview(DerivedInput::filled(key))); + } + } + + fn refeed(&mut self, refeed: Refeed, trace: &mut TraceRecorder) { + match refeed { + Refeed::None => {} + Refeed::Routes => self.state.feed_routes("core.routes", trace), + Refeed::Rides => self.state.feed_rides("core.rides", trace), + } + } +} + +impl TraceHarness for CoreHarness { + type State = VisibleState; + type Outcome = Done; + + fn snapshot(&self) -> Self::State { + visible_state(&self.state.app, self.state.settings_revision, self.state.retention_delete_attempts) + } + + fn apply_input(&mut self, action: &Action, trace: &mut TraceRecorder) { + // An action that moves the app's animation clock moves the pass's with it, so a bounded + // retry window cannot reopen behind the action that just closed it. + self.clock_ms = self.clock_ms.max(clock_watermark(*action)); + TraceHarness::apply_input(&mut self.state, action, trace); + } + + fn run_pass(&mut self, trace: &mut TraceRecorder) -> Vec { + let mut plan = self.pass(); + let mut done = Vec::new(); + match self.executor { + Executor::Typed => self.serve_typed(&mut plan.effects, &mut done), + Executor::Compatibility => self.serve_compat(&mut plan.effects, &plan.derived_needs, &mut done, trace), + } + self.serve_mailbox(&mut done, trace); + self.serve_derived(&plan.derived_needs, &mut done); + done + } + + fn deliver(&mut self, done: Self::Outcome, trace: &mut TraceRecorder) { + match done { + Done::Catalog { outcome, refeed } => { + self.refeed(refeed, trace); + let _ = self.inbox.outcomes.catalog.try_put(outcome); + } + Done::Retention(outcome) => { + let _ = self.inbox.outcomes.retention.try_put(outcome); + } + Done::Event(event) => { + trace.record_event(&event); + let settings_failed = matches!(event, HostEvent::SettingsPersistFailed { .. }); + // The adapter answers what the *adapter* asked for. An event that answers a command + // the App's own mailbox produced has no correlation slot — the domain that asked has + // no machine and asked over the old protocol — so it goes back through the old door. + // Handing it to the adapter would be an unrequested reply, which it refuses rather + // than forging a token for. + let correlated = matches!(self.executor, Executor::Compatibility) + && event_reply(&event).is_some_and(|class| self.adapter.pending().holds(class)); + if correlated { + self.adapter.event_to_inputs(event, &mut self.inbox).expect("the adapter asked for it"); + } else { + self.state.app.apply_event(event); + } + if settings_failed && self.state.settings_retry_requested { + self.clock_ms = self.clock_ms.max(SETTINGS_FAILURE_RETRY_MS); + } + } + Done::RideTrack(input) => { + self.ride_preview = vec![(0, 0), (1, 1)]; + self.inbox.derived.ride_track = Some(input); + } + Done::NavPreview(input) => { + self.nav_preview = vec![(0, 0), (1, 1)]; + self.inbox.derived.nav_preview = Some(input); + } + } + } +} + +// ==================== the disposition table ==================== + +/// What a difference between two runners means. +/// +/// The epic names three dispositions. Only two of them are ever *written down*: a blocking +/// conformance failure is not a row someone writes, it is +/// [`every_scenario_agrees_or_has_an_approved_disposition`] failing on a difference with no row. +/// That is why there is no `Blocking` variant here — an unexplained difference cannot be recorded +/// and shrugged at, it fails the gate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Disposition { + /// DeviceCore is right and the legacy behaviour was a defect. `why` states the expected target. + Corrected, + /// A real difference Phase 1 accepts, with the reason and the slice that removes it. + Accepted, +} + +/// One approved difference between the legacy runners and the DeviceCore ones. +#[derive(Debug, Clone, Copy)] +struct Difference { + /// The scenario it shows up in, or `""` for one that is not scenario-scoped. + scenario: &'static str, + disposition: Disposition, + /// What differs. + what: &'static str, + /// The expected target (`Corrected`) or the reason and later owner (`Accepted`). + why: &'static str, +} + +/// Every difference this gate found, with its disposition. A scenario whose runners disagree +/// without a row here fails [`every_scenario_agrees_or_has_an_approved_disposition`]. +const DIFFERENCES: &[Difference] = &[ + Difference { + scenario: "derived-data.repeats-until-matching-fill", + disposition: Disposition::Corrected, + what: "both DeviceCore runners settle one screen shallower than the legacy baseline", + why: "the legacy bulk feeders carry no subject, so a fill for the route the rider *was* \ + previewing satisfies the need for the one they are previewing now and leaves an extra \ + overview on the stack — DC1 records the stack depth moving with delivery cadence as a \ + known defect. DeviceCore keys every derived read (#1437), drops an answer about \ + something else, and reaches the same state immediate or delayed. The shallower stack \ + is the expected target.", + }, + Difference { + scenario: "recorder.failure-and-session-replacement", + disposition: Disposition::Accepted, + what: "no REC_ERROR card, because the ride close reaches no executor", + why: "the pass consumes the rider's finish one-shot at stage 4 and stage 7 emits no \ + RecorderEffect — Recorder has no machine yet (LegacyOwned::RideCloseAck, #1397 S6). \ + With no finalize there is no failure to report. The production hosts still run the \ + legacy frame methods, so nothing shipping is affected; the mapping the effect will use \ + already exists, and this row goes with Recorder's machine.", + }, + Difference { + scenario: "catalog.route-delete", + disposition: Disposition::Accepted, + what: "the compatibility runner keeps the object", + why: "CatalogEffect::RemoveObject is namespace-free because the flat store removes by \ + identity; the legacy deletes are namespaced, and the namespace cannot be recovered \ + from the effect. The adapter therefore leaves it against LegacyOwned::ObjectNamespace \ + (#1397 S6) rather than guessing. The typed runner removes it, which is exactly what \ + that row costs until the store executor lands.", + }, + Difference { + scenario: "catalog.ride-delete", + disposition: Disposition::Accepted, + what: "the compatibility runner keeps the object", + why: "the same LegacyOwned::ObjectNamespace row (#1397 S6).", + }, + Difference { + scenario: "retention.expiry-retry-and-trusted-clock", + disposition: Disposition::Accepted, + what: "the compatibility runner expires nothing", + why: "an expiry reaches the catalog as the same namespace-free removal, so it hits the same \ + LegacyOwned::ObjectNamespace row — and the catalog then stays in flight, because no \ + legacy event can build a CatalogOutcome. That is the documented one-operation cost of \ + running the pass before the executors migrate (#1439, closed by #1397 S6).", + }, +]; + +// ==================== the matrix ==================== + +/// Every applicable DC1 scenario, through all five runners. +/// +/// The gate: every runner reaches a rider-visible terminal state that either matches the legacy +/// baseline or is named in [`DIFFERENCES`] with a disposition. Nothing may differ silently. +#[test] +fn every_scenario_agrees_or_has_an_approved_disposition() { + let mut compared = 0usize; + let mut differing: BTreeSet<(&str, &str)> = BTreeSet::new(); + + for scenario in SCENARIOS { + let runs: Vec<_> = Runner::ALL.iter().map(|runner| (*runner, runner.run(scenario))).collect(); + let baseline = rider_visible(runs[0].1.settled.clone()); + + for (runner, run) in &runs { + assert_eq!(run.trace.scenario, scenario.name); + assert!(!run.trace.steps.is_empty(), "{} produced no steps in {}", scenario.name, runner.name()); + compared += 1; + if rider_visible(run.settled.clone()) != baseline { + differing.insert((scenario.name, runner.name())); + } + } + + // Immediate and delayed reach the same place *within* a family — the executor conformance + // rule of #1433 §13. A difference here would be timing sensitivity, never policy. + assert_eq!( + rider_visible(runs[1].1.settled.clone()), + baseline, + "{}: the legacy runner changed terminal state when delayed", + scenario.name + ); + assert_eq!( + rider_visible(runs[3].1.settled.clone()), + rider_visible(runs[2].1.settled.clone()), + "{}: DeviceCore changed terminal state under a scripted delay", + scenario.name + ); + } + + assert_eq!(compared, SCENARIOS.len() * Runner::ALL.len(), "every scenario runs in every runner"); + + let approved: BTreeSet<&str> = DIFFERENCES.iter().map(|row| row.scenario).collect(); + let found: BTreeSet<&str> = differing.iter().map(|(scenario, _)| *scenario).collect(); + assert_eq!( + found, approved, + "the disposition table is exact in both directions: every difference is approved, and no row \ + documents a difference that no longer exists" + ); + assert_eq!(differing.len(), 9, "nine runner cells differ, and every one of them is dispositioned"); +} + +/// Every row of the disposition table is usable: a corrected row states its target, an accepted row +/// names its later owner, and a blocking row fails the gate outright. +#[test] +fn every_difference_carries_a_usable_disposition() { + assert!(!DIFFERENCES.is_empty()); + for row in DIFFERENCES { + assert!(!row.what.is_empty() && !row.why.is_empty(), "{row:?}"); + match row.disposition { + Disposition::Corrected => { + assert!(row.why.contains('#'), "a corrected defect names the slice that owns the target: {row:?}") + } + Disposition::Accepted => assert!( + row.why.contains('#'), + "an accepted difference names its later owner and deletion slice: {row:?}" + ), + } + } + // Every scenario a row names is a real one. + let names: BTreeSet<&str> = SCENARIOS.iter().map(|scenario| scenario.name).collect(); + for row in DIFFERENCES { + assert!(names.contains(row.scenario), "{} is not a scenario", row.scenario); + } +} + +/// Every `LegacyOwned` row has a later owner and a deletion slice — the epic's Phase 1 gate on the +/// inventory itself, checked from outside `obc-app` so the table is a seam rather than an internal. +#[test] +fn every_legacy_owned_row_names_the_slice_that_deletes_it() { + for row in LegacyOwned::ALL { + let owner = row.deletes_in(); + assert!(owner.starts_with('#'), "{row:?} must name an issue"); + assert!(owner.contains('—'), "{row:?} must say what takes it over"); + } +} + +/// The classes the pass took over really did move: a DeviceCore run observes them in the mailbox, +/// declines to execute them, and serves the same request as a typed effect or a keyed level. +#[test] +fn the_pass_owns_the_classes_it_took_over() { + // A rider's delete: the pass takes the request at stage 4, so the legacy class never pends at + // all, and the removal happens as one bounded catalog operation. + let mut harness = CoreHarness::new(Executor::Typed); + let trace = run_scenario_seeded( + &definition(named("catalog.route-delete")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert_eq!(trace.final_state.route_ids.len(), 2, "the rider's delete removed one route"); + assert!(harness.served.contains("catalog"), "and it was the catalog effect that did it"); + // `serve_mailbox` asserts the class never appears at all, so reaching here is the proof. + + // The retention stamps: the same, one layer down — the sweep's candidate leaves as a + // `RetentionEffect`, so the legacy stamp class is not pending either. + let mut harness = CoreHarness::new(Executor::Typed); + run_scenario_seeded( + &definition(named("retention.route-and-ride-stamps")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert!(harness.served.contains("retention"), "the stamps left as retention effects"); + assert!( + !harness.moved.contains("StampRouteUsed") && !harness.moved.contains("StampRideSynced"), + "and no legacy stamp was left pending beside them" + ); + + // An auto-expiry reaches the catalog by the same path a rider's delete does, and the legacy + // delete class does not come back beside it. + let mut harness = CoreHarness::new(Executor::Typed); + run_scenario_seeded( + &definition(named("retention.expiry-retry-and-trusted-clock")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert!(harness.served.contains("catalog"), "the expiry was served as one bounded removal"); + + // The two derived levels are levels, so they re-derive every drain and are declined every time. + let mut harness = CoreHarness::new(Executor::Typed); + run_scenario_seeded( + &definition(named("derived-data.repeats-until-matching-fill")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert!(harness.moved.contains("LoadRideTrack"), "the ride-track cue is answered from the plan's key"); + assert!(harness.moved.contains("RefreshNavPreview"), "and so is the nav preview"); +} + +fn named(name: &'static str) -> &'static Scenario { + SCENARIOS.iter().find(|scenario| scenario.name == name).unwrap_or_else(|| panic!("no scenario {name}")) +} + +/// What the compatibility executor cannot do, measured rather than asserted from the doc comment. +/// +/// The adapter can build a navigator, settings, DFU or storage outcome, because those four legacy +/// commands have a terminal event. It can build no catalog, retention or weather outcome, because +/// the old protocol answers those with a bulk re-feed, a store-changed edge, or nothing at all — so +/// a domain that latches in flight when it emits stays latched. Two of the accepted rows above are +/// this fact reaching the rider. +#[test] +fn the_compatibility_executor_leaves_what_the_old_protocol_cannot_say() { + let mut harness = CoreHarness::new(Executor::Compatibility); + run_scenario_seeded( + &definition(named("retention.expiry-retry-and-trusted-clock")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert!( + harness.left.contains(&LegacyOwned::ObjectNamespace), + "the removal has no legacy expression, and is left with its owner rather than dropped" + ); + assert_eq!(harness.state.route_ids.len(), 3, "so nothing was removed, and nothing pretended it had been"); + + // The same run under the typed executor completes it — the difference is the executor, and + // nothing else about the device. + let mut typed = CoreHarness::new(Executor::Typed); + run_scenario_seeded( + &definition(named("retention.expiry-retry-and-trusted-clock")), + RunnerMode::Immediate, + &normalization_seed(), + &mut typed, + ) + .expect("the scenario runs"); + assert_eq!(typed.state.route_ids.len(), 2, "the expired object is gone"); +} + +// ==================== the mandatory traces (#1440) ==================== + +/// The sixteen traces #1440 requires, each bound to the test that runs it. +/// +/// The binding is checked rather than written down: the test below looks each name up in this +/// file's own source, so a renamed or deleted trace fails the gate instead of quietly leaving a row +/// of the issue uncovered. +const MANDATORY_TRACES: [(&str, &str); 16] = [ + ("outcome after cancellation", "an_outcome_after_cancellation_changes_nothing"), + ("outcome after a replacement request", "an_outcome_after_a_replacement_request_changes_nothing"), + ("store change during catalog refresh", "a_store_change_during_a_catalog_operation_is_not_lost"), + ("transfer start during route planning", "a_transfer_during_planning_withdraws_heavy_capability"), + ( + "route-plan completion after active-route change", + "a_route_plan_that_lands_after_the_active_route_changed_is_refused", + ), + ("settings result with an old revision", "a_settings_result_with_an_old_revision_is_refused"), + ("ride finalize failure after the last checkpoint", "a_ride_close_reaches_no_executor_until_recorder_lands"), + ("trip member disappearance before delete commit", "an_object_that_vanished_before_the_commit_is_a_success"), + ("capability change after a new map mounts", "capabilities_follow_the_mounted_data_and_the_platform"), + ("detour without a path", "a_detour_without_a_path_is_a_failure_and_not_an_absent_capability"), + ("device without detour capability", "capabilities_follow_the_mounted_data_and_the_platform"), + ("active-route deletion with same-pass Navigator delivery", "deleting_the_active_route_drops_it_in_the_same_pass"), + ("Navigator activation with next-pass Retention delivery", "an_activation_reaches_retention_on_the_next_pass"), + ("full effect slot and full outcome slot", "a_full_slot_preserves_work_on_both_sides_of_the_seam"), + ("deferred slot which forces a pass before sleep", "a_deferred_value_forces_a_pass_before_sleep"), + ("stale derived input after a subject change", "a_stale_derived_fill_is_dropped_and_the_level_asks_again"), +]; + +#[test] +fn every_mandatory_trace_has_a_test_that_runs_it() { + let source = include_str!("device_core_conformance.rs"); + for (trace, test) in MANDATORY_TRACES { + assert!(source.contains(&format!("fn {test}(")), "{trace}: no test named {test}"); + } +} + +fn typed() -> CoreHarness { + CoreHarness::new(Executor::Typed) +} + +/// Three routes under a trusted clock, `expired` of them long past their deadline, so the retention +/// sweep decides to expire them and the catalog turns each into one bounded removal. This is the one +/// path that reaches `CatalogEffect` from a public app surface, which is why the catalog traces +/// below are written on it rather than on the rider's hold-to-delete. +fn expiring(expired: usize) -> CoreHarness { + let mut harness = typed(); + harness.app().stamp_clock_ble(1_720_000_000, 60); + let now = harness.state.app.wall_unix_now(); + let old = now.saturating_sub(30 * 24 * 3600); + let meta: Vec<_> = (0..3) + .map(|index| { + if index < expired { + RouteRetentionMeta::new(Retention::Week1, old) + } else { + RouteRetentionMeta::new(Retention::Never, 0) + } + }) + .collect(); + harness.app().set_route_meta(&meta); + harness.app().force_retention_sweep(); + harness +} + +impl CoreHarness { + /// Apply one corpus action outside the scenario runner. + fn apply(&mut self, action: Action) { + let mut trace = recorder(); + self.clock_ms = self.clock_ms.max(clock_watermark(action)); + TraceHarness::apply_input(&mut self.state, &action, &mut trace); + } + + /// Serve one catalog effect and hand the answer straight back. + fn answer_catalog(&mut self, effect: CatalogEffect) -> CatalogOutcome { + let Done::Catalog { outcome, refeed } = self.serve_catalog(effect) else { panic!("a catalog answer") }; + self.deliver(Done::Catalog { outcome, refeed }, &mut recorder()); + outcome + } + + /// The next catalog effect, running passes until one appears. + fn next_catalog_effect(&mut self) -> CatalogEffect { + for _ in 0..8 { + let mut plan = self.pass(); + if let Some(effect) = plan.effects.catalog.take() { + return effect; + } + } + panic!("no catalog effect within eight passes") + } +} + +/// **Outcome after cancellation.** A terminal answer invalidates the domain's token, so a repeat of +/// it is no longer current and starts nothing — the rule a cancellation applies, reached through the +/// one terminal event every operation ends with. +#[test] +fn an_outcome_after_cancellation_changes_nothing() { + let mut harness = expiring(1); + let effect = harness.next_catalog_effect(); + let outcome = harness.answer_catalog(effect); + harness.pass(); + assert_eq!(harness.state.route_ids.len(), 2, "the expiry removed one route"); + + // The same answer again: the operation is over, so nothing reopens and nothing is retried. + let _ = harness.inbox.outcomes.catalog.try_put(outcome); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "a repeat of a terminal answer starts no work"); + assert_eq!(harness.state.route_ids.len(), 2, "and removes nothing a second time"); + + // The navigator half of the same rule, through the adapter: a cancelled plan does not accept its + // own result, and it is the *domain* that refuses it — the adapter hands the token straight back. + let mut navigator: TokenSource = TokenSource::new(); + let token = navigator.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + let work = PlannerWork::Route(NavRequest::new((0, 0), (1, 1), "goal")); + effects.navigator.try_put(NavigatorEffect::Acquire { token, work }).unwrap(); + assert_eq!(adapter.effects_to_commands(&mut effects, &mut mail).translated, 1); + navigator.invalidate(); // the rider pressed Back + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::NavPlanned(Ok(9)), &mut inbox).unwrap(); + let outcome = inbox.outcomes.navigator.take().expect("delivered, not swallowed"); + assert_eq!(outcome, NavigatorOutcome::PlanFinished { token, route: 9 }); + assert!(!navigator.is_current(outcome.token()), "the cancelled operation does not accept it"); +} + +/// **Outcome after a replacement request.** The next operation supersedes the last; an answer that +/// belongs to the finished one changes nothing about the live one. +#[test] +fn an_outcome_after_a_replacement_request_changes_nothing() { + let mut harness = expiring(2); + let first = harness.next_catalog_effect(); + harness.answer_catalog(first); + let second = harness.next_catalog_effect(); + assert_ne!(first.token(), second.token(), "a new operation, a new token"); + + // The finished operation's answer, arriving again against the live one. + let _ = harness.inbox.outcomes.catalog.try_put(CatalogOutcome::ObjectRemoved { + token: first.token(), + object: 10, + existed: true, + }); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "the stale answer did not free the live operation"); + + harness.answer_catalog(second); + harness.pass(); + assert_eq!(harness.state.route_ids.len(), 1, "both removals landed, neither twice"); +} + +/// **A store change during a catalog operation.** The commit is an edge the pass records once, the +/// domain keeps one operation in flight, and neither loses the other. +#[test] +fn a_store_change_during_a_catalog_operation_is_not_lost() { + let mut harness = expiring(1); + let effect = harness.next_catalog_effect(); + + // The store moves underneath us while that removal is unanswered. + harness.inbox.facts.note_store_revision(StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(4) }); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "one catalog operation at a time"); + assert!(harness.state.app.store_changed_pending() > 0, "the commit became the refresh cue"); + + // The same revision again is the same edge, not a second one. + let before = harness.state.app.store_changed_pending(); + harness.inbox.facts.note_store_revision(StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(4) }); + harness.pass(); + assert_eq!(harness.state.app.store_changed_pending(), before, "one commit, one cue"); + + harness.answer_catalog(effect); + harness.pass(); + assert_eq!(harness.state.route_ids.len(), 2, "and the removal completed"); +} + +/// **A transfer starts during route planning.** Heavy work is withdrawn while a transfer holds the +/// store, so a plan or an install is never *started* and then failed. +#[test] +fn a_transfer_during_planning_withdraws_heavy_capability() { + let facts = |heavy| DeviceFacts { + store_writable: true, + nav_graph: true, + weather_data: false, + link_connected: true, + ride_recording: false, + heavy_operations: heavy, + }; + let idle = Capabilities::calculate(EVERYTHING, facts(true)); + let streaming = Capabilities::calculate(EVERYTHING, facts(false)); + assert!(idle.navigator.plan_route && idle.navigator.plan_detour && idle.dfu.install); + assert!(!streaming.navigator.plan_route, "a route plan is heavy"); + assert!(!streaming.navigator.plan_detour, "and so is a detour"); + assert!(!streaming.dfu.install, "and so is an install"); + assert!(streaming.catalog.mutate, "but the store is still writable — this is admission, not a fault"); + + // The one capability a Phase 1 stage actually consults: a weather refresh needs the link, and a + // withdrawn capability stops the effect being emitted at all rather than failing it. + let mut harness = expiring(0); + harness.inbox.facts.note_transfer(TransferState::Active); + let plan = harness.pass(); + assert!(plan.effects.weather.is_empty(), "no refresh is started without the capability"); +} + +/// **A route plan completes after the active route changed.** The answer carries the token the +/// request went out with; a Navigator that has moved on refuses it. +#[test] +fn a_route_plan_that_lands_after_the_active_route_changed_is_refused() { + let mut navigator: TokenSource = TokenSource::new(); + let token = navigator.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + let work = PlannerWork::Route(NavRequest::new((0, 0), (1, 1), "first")); + effects.navigator.try_put(NavigatorEffect::Acquire { token, work }).unwrap(); + adapter.effects_to_commands(&mut effects, &mut mail); + + // The rider activates a different route: Navigator replaces its operation. + navigator.invalidate(); + let replacement = navigator.issue(); + + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::NavPlanned(Ok(10)), &mut inbox).unwrap(); + let outcome = inbox.outcomes.navigator.take().expect("the answer is delivered"); + assert_eq!(outcome.token(), token, "carrying the token it went out with"); + assert!(!navigator.is_current(outcome.token()), "which is no longer the current operation"); + assert!(navigator.is_current(replacement), "the replacement is"); +} + +/// **A settings result with an old revision.** The token and the revision are independent guards, +/// and the rider-visible half is identical in the legacy and DeviceCore runners. +#[test] +fn a_settings_result_with_an_old_revision_is_refused() { + let mut settings: TokenSource = TokenSource::new(); + let token = settings.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + effects.settings.try_put(SettingsEffect::PersistRevision { token, revision: 4 }).unwrap(); + adapter.effects_to_commands(&mut effects, &mut mail); + assert_eq!(mail.pop(), Some(HostCommand::PersistSettings { revision: 4 })); + + settings.invalidate(); // the rider edited again — a newer revision is the latest now + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::SettingsPersisted { revision: 4 }, &mut inbox).unwrap(); + let outcome = inbox.outcomes.settings.take().expect("delivered, not swallowed"); + assert_eq!(outcome, SettingsOutcome::Persisted { token, revision: 4 }); + assert!(!settings.is_current(outcome.token()), "the superseded write does not clear the dirty state"); + + // …and the legacy half is real behaviour rather than only a token: the stale ack leaves the + // newer content pending, identically under both frames. + let scenario = named("settings.revision-success-and-stale-result"); + let legacy = Runner::LegacyImmediate.run(scenario); + let core = Runner::CoreImmediate.run(scenario); + assert_eq!(rider_visible(core.settled.clone()), rider_visible(legacy.settled.clone())); +} + +/// **A ride finalize failure after the last checkpoint.** The accepted Phase 1 difference, pinned: +/// the pass consumes the rider's close at stage 4 and stage 7 emits no `RecorderEffect`, so no +/// executor sees it. +/// +/// Stated as a fact rather than left as prose, because it is the one place a rider request currently +/// ends inside the pass. The mapping table already holds the row it will use +/// ([`LegacyOwned::RideCloseAck`]); what is missing is Recorder's machine, at #1397 S6. Nothing +/// shipping is affected — the production hosts still run the legacy frame methods. +#[test] +fn a_ride_close_reaches_no_executor_until_recorder_lands() { + let mut harness = expiring(0); + harness.app().activity.start_session(); + harness.pass(); + + harness.app().activity.request_track(TrackAction::Save); + let plan = harness.pass(); + assert!(plan.effects.recorder.is_empty(), "no RecorderEffect is emitted"); + assert!(plan.immediate, "the close is not lost outright: retention is told, before sleep"); + assert!(!harness.state.app.activity.has_track_action(), "and the legacy class was consumed by the pass"); + + // The row that closes it exists and names its owner, so this is a scheduled gap, not a hole. + assert!(LegacyOwned::RideCloseAck.deletes_in().contains("#1397")); + + // The mapping is already in place: once Recorder emits the effect, the adapter knows where it + // goes. The missing piece is one domain's machine, and nothing in the protocol. + let mut recorder_ops = TokenSource::new(); + let mut effects = EffectSlots::new(); + effects.recorder.try_put(RecorderEffect::Finalize { token: recorder_ops.issue() }).unwrap(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + assert_eq!(adapter.effects_to_commands(&mut effects, &mut mail).translated, 1); + assert_eq!(mail.pop(), Some(HostCommand::FinishTrack(TrackAction::Save))); + assert_eq!(RecorderIntent::Save, RecorderIntent::Save, "the intent type is the seam that survives"); +} + +/// **A trip member disappears before the delete commit.** The goal state holds, so the removal is a +/// success with `existed: false` — never a failure the rider is shown. +#[test] +fn an_object_that_vanished_before_the_commit_is_a_success() { + let mut harness = expiring(1); + let effect = harness.next_catalog_effect(); + let CatalogEffect::RemoveObject { object, .. } = effect else { panic!("a removal") }; + + // Something else removed it first. + let index = harness.state.route_ids.iter().position(|&id| id == object).expect("still catalogued"); + harness.state.routes.remove(index); + harness.state.route_ids.remove(index); + + let Done::Catalog { outcome, refeed } = harness.serve_catalog(effect) else { panic!() }; + assert_eq!(outcome, CatalogOutcome::ObjectRemoved { token: effect.token(), object, existed: false }); + assert_eq!(refeed, Refeed::None, "nothing changed, so nothing is re-fed"); + harness.deliver(Done::Catalog { outcome, refeed }, &mut recorder()); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "the operation is over — nothing retried, nothing failed"); +} + +/// **A capability changes after a new map mounts**, and **a device without the detour capability**. +/// +/// A capability is a level recomputed from what the image implements and what is true now. A missing +/// graph or missing support withdraws the operation entirely, so "unsupported" never reaches the +/// rider as a planning failure. +#[test] +fn capabilities_follow_the_mounted_data_and_the_platform() { + let facts = |nav_graph| DeviceFacts { + store_writable: true, + nav_graph, + weather_data: false, + link_connected: false, + ride_recording: false, + heavy_operations: true, + }; + let before = Capabilities::calculate(EVERYTHING, facts(false)); + let after = Capabilities::calculate(EVERYTHING, facts(true)); + assert!(!before.navigator.plan_route && !before.navigator.plan_detour, "no graph, no planning"); + assert!(after.navigator.plan_route && after.navigator.plan_detour, "a mounted routing graph turns both on"); + assert!(before.catalog.mutate && after.catalog.mutate, "and the rest of the device is unaffected"); + + // A device the detour was never built for. + let limited = Capabilities::calculate(NO_DETOUR, facts(true)); + assert!(limited.navigator.plan_route, "route planning is unaffected"); + assert!(!limited.navigator.plan_detour && !limited.navigator.commit_detour, "the detour is simply absent"); +} + +/// A platform without the detour, for the capability traces. +const NO_DETOUR: PlatformSupport = PlatformSupport { detour: false, ..EVERYTHING }; + +/// **A detour without a path** is a planning failure, and it is a *different* value from the absence +/// of the capability — the epic's "unsupported must not appear as NoPath". +#[test] +fn a_detour_without_a_path_is_a_failure_and_not_an_absent_capability() { + let mut navigator: TokenSource = TokenSource::new(); + let token = navigator.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + let work = PlannerWork::Detour(DetourRequest { route: 0, from: (0, 0), progress_m: 0, target_m: 500 }); + effects.navigator.try_put(NavigatorEffect::Acquire { token, work }).unwrap(); + adapter.effects_to_commands(&mut effects, &mut mail); + assert!(matches!(mail.pop(), Some(HostCommand::PlanDetour(_))), "a supported device asks the planner"); + + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::DetourPlanned(Err(NavError::NoPath)), &mut inbox).unwrap(); + let outcome = inbox.outcomes.navigator.take().expect("the failure is an answer"); + assert_eq!(outcome, NavigatorOutcome::Failed { token, error: NavigatorError::Plan(NavError::NoPath) }); + assert!(navigator.is_current(outcome.token()), "and it is this operation's answer"); + + // The unsupported device never gets here: the capability is absent, so nothing is requested and + // there is no failure to report at all. + let facts = DeviceFacts { + store_writable: true, + nav_graph: true, + weather_data: false, + link_connected: false, + ride_recording: false, + heavy_operations: true, + }; + assert!(!Capabilities::calculate(NO_DETOUR, facts).navigator.plan_detour); +} + +/// **Deleting the active route, with same-pass Navigator delivery.** The rider is not left being +/// guided along a route the device has decided to remove. +#[test] +fn deleting_the_active_route_drops_it_in_the_same_pass() { + let mut harness = typed(); + harness.app().activate_route(0); + assert_eq!(harness.state.app.active_route_index(), Some(0)); + + harness.apply(Action::DeleteRoute); + let mut plan = harness.pass(); + assert!( + matches!(plan.effects.catalog.take(), Some(CatalogEffect::RemoveObject { object: 10, .. })), + "the rider's delete left as one bounded catalog operation" + ); + assert_eq!(harness.state.app.active_route_index(), None, "and Navigator heard about it in that pass"); +} + +/// **Navigator activation, with next-pass Retention delivery.** Navigator runs after retention, so +/// the activation waits one pass — and the wait is bounded by the immediate wake, not by input. +#[test] +fn an_activation_reaches_retention_on_the_next_pass() { + let mut harness = typed(); + harness.app().stamp_clock_ble(1_720_000_000, 60); + let now = harness.state.app.wall_unix_now(); + // A fresh `last_used`, so the hourly sweep has nothing of its own to say and the only stamp in + // this trace is the activation's. + harness.app().set_route_meta(&[ + RouteRetentionMeta::new(Retention::Week1, now), + RouteRetentionMeta::new(Retention::Never, 0), + RouteRetentionMeta::new(Retention::Never, 0), + ]); + harness.app().activate_route(0); + + let mut plan = harness.pass(); + assert!(plan.immediate, "Navigator runs after retention, so the activation is deposited, not delivered"); + assert!( + matches!(plan.effects.retention.take(), Some(RetentionEffect::WriteRouteMetadata { id: 10, .. })), + "the active route's use stamp goes out" + ); + + let plan = harness.pass(); + assert!(!plan.immediate, "retention consumed it on the next pass, and nothing is left waiting"); + assert!(plan.effects.retention.is_empty(), "the delivery is idempotent — no second sidecar write"); +} + +/// **A full effect slot and a full outcome slot.** Both preserve the value already there, and a +/// refused one comes back to its owner rather than being dropped. +#[test] +fn a_full_slot_preserves_work_on_both_sides_of_the_seam() { + // The effect side: two objects expire together, the domain admits one, and the other is not + // queued in the slot — it stays with its producer and goes out once the answer frees the domain. + let mut harness = expiring(2); + let first = harness.next_catalog_effect(); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "one catalog operation in flight at a time"); + harness.answer_catalog(first); + let second = harness.next_catalog_effect(); + assert_ne!(first.token(), second.token(), "nothing was lost to the busy pass"); + + // The outcome side: a second answer cannot displace one the pass has not consumed yet. + let mut outcomes = OutcomeSlots::new(); + let mut tokens = TokenSource::new(); + let held = CatalogOutcome::Cancelled { token: tokens.issue() }; + let intruder = CatalogOutcome::Cancelled { token: tokens.issue() }; + outcomes.catalog.try_put(held).unwrap(); + let refused = outcomes.catalog.try_put(intruder).expect_err("a full slot refuses"); + assert_eq!(refused.rejected, intruder, "and hands the value back to its owner"); + assert_eq!(outcomes.catalog.take(), Some(held), "the first answer is what the domain gets"); +} + +/// **A deferred slot forces a pass before sleep.** Work that is already decided must not sit until +/// the next rider input. +#[test] +fn a_deferred_value_forces_a_pass_before_sleep() { + let mut harness = expiring(0); + harness.app().activity.start_session(); + harness.pass(); + + harness.app().activity.request_track(TrackAction::Save); + let plan = harness.pass(); + assert!(plan.immediate && plan.next_wake_ms == Some(0), "the runtime comes straight back"); + + let plan = harness.pass(); + assert!(!plan.immediate && plan.next_wake_ms != Some(0), "and then there is nothing to hurry for"); +} + +/// **A stale derived input after a subject change** — the corrected defect of this gate. +/// +/// The legacy feeders carry no subject, so a delayed fill for the ride the rider *was* looking at +/// satisfies the need for the one they are looking at now. DeviceCore keys the read, so the same +/// delayed answer is dropped and the level asks again. +#[test] +fn a_stale_derived_fill_is_dropped_and_the_level_asks_again() { + let mut harness = typed(); + open_ride_detail(&mut harness); + let plan = harness.pass(); + let key = plan.derived_needs.ride_track.expect("the open detail wants its track"); + + // An answer about a different ride is an answer to a question nobody asked. + let other = RideTrackKey { ride: key.ride + 1, source: key.source, view: key.view }; + harness.inbox.derived.ride_track = Some(DerivedInput { key: other, result: DerivedResult::Filled }); + let plan = harness.pass(); + assert_eq!(plan.derived_needs.ride_track, Some(key), "the need is untouched"); + + // A failure for the *right* key is an answer, so a dead source costs one read and not one per + // pass. + harness.inbox.derived.ride_track = Some(DerivedInput { key, result: DerivedResult::Failed }); + let plan = harness.pass(); + assert!(plan.derived_needs.ride_track.is_none(), "a failure answers the key"); + + // And the scenario-level consequence: DeviceCore's terminal state stops depending on cadence. + let scenario = named("derived-data.repeats-until-matching-fill"); + let immediate = Runner::CoreImmediate.run(scenario); + let delayed = Runner::CoreDelayed.run(scenario); + assert_eq!( + rider_visible(delayed.settled.clone()), + rider_visible(immediate.settled.clone()), + "DeviceCore's terminal state no longer depends on delivery cadence" + ); + assert!( + DIFFERENCES + .iter() + .any(|row| row.scenario == scenario.name && matches!(row.disposition, Disposition::Corrected)), + "and the difference from the legacy runner is recorded as corrected" + ); +} + +fn open_ride_detail(harness: &mut CoreHarness) { + for gesture in [Gesture::Press, Gesture::Step(1), Gesture::Press, Gesture::Press] { + harness.app().apply_gesture(gesture); + } +} + +/// A throwaway recorder for the direct pass-level traces, which assert on state rather than on a +/// trace timeline. One open step, never finished — the feeder calls a re-feed makes have somewhere +/// to land. +fn recorder() -> TraceRecorder { + let mut recorder = TraceRecorder::new("direct", RunnerMode::Immediate, blank_state()); + recorder.begin_step(TraceInput::Named("direct")); + recorder +} + +fn blank_state() -> VisibleState { + visible_state(&App::new_idle(AppState::new(0, 0, 1.0)), 0, 0) +} + +/// The one warning path the pass owns end to end: a fault raised by any producer reaches the rider +/// in the pass it was raised in, and several producers coalesce onto one card. +#[test] +fn a_fact_raised_this_pass_reaches_the_rider_in_it() { + let mut harness = expiring(0); + harness.inbox.facts.raise_warnings(WarningFlags::NO_GPS); + harness.inbox.facts.raise_warnings(WarningFlags::MAP_SLOW); + harness.pass(); + assert!( + matches!(harness.state.app.top_screen(), Screen::Warning(card) + if card.flags().contains(WarningFlags::NO_GPS) && card.flags().contains(WarningFlags::MAP_SLOW)), + "both notices reached one card" + ); +} + +/// A failed sidecar write re-queues its candidate — the retry the legacy protocol cannot express at +/// all, because it never acknowledges a stamp (`LegacyOwned::SidecarAck`). +#[test] +fn a_failed_retention_write_is_retried() { + let mut harness = typed(); + harness.app().stamp_clock_ble(1_720_000_000, 60); + let now = harness.state.app.wall_unix_now(); + harness.app().set_route_meta(&[ + RouteRetentionMeta::new(Retention::Week1, now), + RouteRetentionMeta::new(Retention::Never, 0), + RouteRetentionMeta::new(Retention::Never, 0), + ]); + harness.pass(); + harness.app().activate_route(0); + + let mut effect = None; + for _ in 0..8 { + let mut plan = harness.pass(); + if let Some(found) = plan.effects.retention.take() { + effect = Some(found); + break; + } + } + let effect = effect.expect("the activation's use stamp goes out"); + let _ = harness + .inbox + .outcomes + .retention + .try_put(RetentionOutcome::Failed { token: effect.token(), error: RetentionError::WriteFailed }); + + let mut retried = false; + for _ in 0..16 { + let mut plan = harness.pass(); + if plan.effects.retention.take().is_some() { + retried = true; + break; + } + } + assert!(retried, "a failed write keeps its candidate and offers it again"); +} + +// ==================== the resource gate ==================== + +/// The pass protocol's size budget, re-asserted from outside `obc-app`. +/// +/// The compile-time assertions inside the crate gate the same values; this one exists because a +/// *host* is what puts both structs on its stack every pass, and because the gate's resource table +/// has to be reproducible from a command anyone can run. +#[test] +fn the_pass_protocol_stays_within_its_budget() { + use std::mem::size_of; + + assert!(size_of::() <= 160, "nine bounded effects: {}", size_of::()); + assert!(size_of::() <= 224, "nine bounded outcomes: {}", size_of::()); + assert!(size_of::() <= 64); + assert!(size_of::() <= 80); + + // The largest single message per direction — what a payload creeping into the protocol would + // show up as first. + let largest_effect = [ + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + ] + .into_iter() + .max() + .unwrap(); + assert!(largest_effect <= 96, "the planner request is the largest effect: {largest_effect}"); +} diff --git a/host/obc-host-core/tests/device_core_corpus/mod.rs b/host/obc-host-core/tests/device_core_corpus/mod.rs new file mode 100644 index 000000000..a764e942a --- /dev/null +++ b/host/obc-host-core/tests/device_core_corpus/mod.rs @@ -0,0 +1,1368 @@ +//! The shared DeviceCore behaviour corpus (#1434 DC1, #1440 DC7). +//! +//! One scenario table, one set of fixtures and one legacy harness, used by two test binaries: +//! `device_core_legacy_traces` pins what the legacy protocol does with them, and +//! `device_core_conformance` runs the same definitions through five runners. Keeping the corpus +//! here is what makes "the same scenario, a different runner" a fact about the code rather than +//! about two hand-kept copies of a table. +//! +//! Nothing here decides policy: the harness applies real `App` operations and real gestures, and the +//! runner in `obc_host_core::trace` only controls *when* completed outcomes are delivered. + +// A shared test corpus is compiled into every binary that includes it, and each uses a subset. +#![allow(dead_code)] + +use obc_app::dfu::{clamp, DfuFailure, DfuInstallError, DfuScanError, DfuScanReport}; +use obc_app::screen::Screen; +use obc_app::{ + App, AppState, DetourPreview, Gesture, HostCommand, HostEvent, Mode, RideRetentionRecord, RideSummary, + RouteSummary, TrackAction, TripInput, WarningFlags, +}; +use obc_formats::io::{ByteSink, SliceSource}; +use obc_host_core::trace::{ + run_scenario_seeded, CommandTag, EventTag, FeederCall, FeederKind, NormalizationSeed, ObjectKey, ObjectKind, + RevisionKey, RunnerMode, ScenarioStep, TimeKey, Trace, TraceHarness, TraceInput, TraceOutput, TraceRecorder, + TraceScenario, TraceSink, +}; +use obc_host_core::{HostLoop, PlanHold, RideRepository, RouteRepository, TripCatalog}; +use obc_map_scene::BBox; +use obc_ports::{Fix, InputClock, LocationSource, RideClock, Sensors, SettingsSaveError}; +use obc_route::{gpx_to_obcr, NavError, RouteIndex, RouteReader}; + +/// Every behavior row locked by DC1. Keeping the inventory typed makes adding a scenario without +/// the corresponding acceptance row (or silently dropping a row during later refactors) fail. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Requirement { + CatalogStoreChange, + CatalogRefresh, + CatalogRouteDelete, + CatalogRideDelete, + CatalogTripCascade, + CatalogUploadOrder, + CatalogIdentityRemap, + NavigationPlan, + NavigationCancel, + NavigationLateResult, + NavigationReplacement, + NavigationDetourPlan, + NavigationDetourCancel, + NavigationDetourCommit, + NavigationNoPath, + RecorderStart, + RecorderSave, + RecorderDiscard, + RecorderFinalizeFailure, + RecorderSessionReplacement, + SettingsDirtyRevision, + SettingsSuccess, + SettingsStaleResult, + SettingsFailure, + SettingsRetry, + RetentionRouteUseStamp, + RetentionRideSyncStamp, + RetentionExpiryDelete, + RetentionRetry, + RetentionTrustedClockGate, + DfuScanSuccess, + DfuScanFailure, + DfuInstallStart, + DfuInstallRefusal, + DfuConfirmedUpdate, + DfuFailedUpdate, + PlatformForgetBond, + PlatformCardSpaceScan, + DerivedRideTrackRepeatedUntilFill, + DerivedNavPreviewRepeatedUntilFill, + WeatherRefreshState, + WeatherInstalledDataChange, + WeatherStaleData, + WeatherAlertDelivery, +} + +pub const ALL_REQUIREMENTS: &[Requirement] = &[ + Requirement::CatalogStoreChange, + Requirement::CatalogRefresh, + Requirement::CatalogRouteDelete, + Requirement::CatalogRideDelete, + Requirement::CatalogTripCascade, + Requirement::CatalogUploadOrder, + Requirement::CatalogIdentityRemap, + Requirement::NavigationPlan, + Requirement::NavigationCancel, + Requirement::NavigationLateResult, + Requirement::NavigationReplacement, + Requirement::NavigationDetourPlan, + Requirement::NavigationDetourCancel, + Requirement::NavigationDetourCommit, + Requirement::NavigationNoPath, + Requirement::RecorderStart, + Requirement::RecorderSave, + Requirement::RecorderDiscard, + Requirement::RecorderFinalizeFailure, + Requirement::RecorderSessionReplacement, + Requirement::SettingsDirtyRevision, + Requirement::SettingsSuccess, + Requirement::SettingsStaleResult, + Requirement::SettingsFailure, + Requirement::SettingsRetry, + Requirement::RetentionRouteUseStamp, + Requirement::RetentionRideSyncStamp, + Requirement::RetentionExpiryDelete, + Requirement::RetentionRetry, + Requirement::RetentionTrustedClockGate, + Requirement::DfuScanSuccess, + Requirement::DfuScanFailure, + Requirement::DfuInstallStart, + Requirement::DfuInstallRefusal, + Requirement::DfuConfirmedUpdate, + Requirement::DfuFailedUpdate, + Requirement::PlatformForgetBond, + Requirement::PlatformCardSpaceScan, + Requirement::DerivedRideTrackRepeatedUntilFill, + Requirement::DerivedNavPreviewRepeatedUntilFill, + Requirement::WeatherRefreshState, + Requirement::WeatherInstalledDataChange, + Requirement::WeatherStaleData, + Requirement::WeatherAlertDelivery, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Settle, + StoreChanged, + RefreshCatalogs, + UploadRoutesThenTrip, + RemapCatalogIdentity, + DeleteRoute, + DeleteRide, + CascadeDeleteTrip, + StartRoutePlan, + CancelRoutePlan, + DeliverLateRouteResult, + ReplaceRoutePlan, + PlanDetour, + CancelDetour, + CommitDetour, + RouteNoPath, + StartRecorder, + SaveRecorder, + DiscardRecorder, + FailRecorderFinalize, + ReplaceRecorderSession, + DirtySettings, + PersistSettings, + DeliverStaleSettingsResult, + DeliverMatchingSettingsResult, + FailSettingsPersist, + RetrySettingsPersist, + StampRouteUse, + StampRideSync, + DeleteExpiredObject, + RetryExpiredDelete, + GateExpiryUntilClockTrusted, + ScanDfuSuccess, + ScanDfuFailure, + StartDfuInstall, + AdmitDfuInstall, + RefuseDfuInstall, + ConfirmUpdate, + FailUpdate, + ForgetBond, + ScanCardSpace, + NeedRideTrack, + RemapRideIdentity, + ReplaceRideTrackNeed, + FillRideTrack, + NeedNavPreview, + ReplaceNavPreviewNeed, + FillNavPreview, + RefreshWeather, + InstallWeatherData, + MarkWeatherStale, + DeliverWeatherAlert, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScreenState { + Home, + Menu, + Routes, + RouteOverview, + Rides, + RideDetail, + Map, + Detour, + Planning, + DetourPreview, + DfuCheck, + DfuConfirm, + DfuProgress, + DfuInstalling, + DfuError, + Warning, + WeatherAlert, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VisibleState { + pub screen: ScreenState, + pub stack_depth: usize, + pub mode: Mode, + pub route_names: Vec, + pub route_ids: Vec, + pub ride_names: Vec, + pub ride_ids: Vec, + pub trip_names: Vec, + pub trip_ids: Vec, + pub active_route_name: Option, + pub active_route_id: Option, + pub requested_ride_id: Option, + pub recording: bool, + pub clock_trusted: bool, + pub rain_steps_ahead: u8, + pub settings_revision: Option, + pub settings_utc_offset_min: i16, + pub pending_host_command: bool, + pub nav_preview_missing: bool, + pub warning: Option, + pub retention_delete_attempts: u16, +} + +#[derive(Debug)] +pub enum Outcome { + Event(HostEvent), + FinishSave, + FinalizeFailed, + CardScanned, + RideTrack { id: u64 }, + NavPreview { generation: u16 }, + DetourCommitted, +} + +#[derive(Debug, Clone, Copy)] +pub enum PendingSettingsResult { + PersistLatest, + FailLatest, + PersistRevision(u16), +} + +/// The legacy adapter uses the real `App` protocol doors and `HostLoop`'s passive trace observer. +/// Inputs are real public app operations or UI gestures; each pass runs the real command dispatcher; +/// deliveries call the real `set_*` feeders and `apply_event`. Planner, detour-commit, recorder-finalize, +/// and derived-fill completions are scripted at that protocol boundary because the legacy interfaces +/// do not expose a deterministic completion seam. The fixture-backed `board_parity` suite separately +/// exercises the real planner/repository path; this fast corpus must not be read as a second planner. +pub struct LegacyHarness { + pub app: App, + pub routes: Vec, + pub route_ids: Vec, + pub rides: Vec, + pub ride_ids: Vec, + pub trip_stage_ids: Vec, + pub trip_present: bool, + pub nav_generation: u16, + pub host: HostLoop, + pub fail_next_finalize: bool, + pub commit_success_pending: bool, + pub pending_nav_plan: Option>, + pub pending_dfu_scan: Option>, + pub pending_dfu_install: Option>, + pub pending_settings_result: Option, + pub settings_revision: u16, + pub route_delete_fail_once: bool, + pub retention_delete_attempts: u16, + pub settings_retry_requested: bool, +} + +impl LegacyHarness { + pub fn new() -> Self { + let routes = vec![route("Alpha"), route("Beta"), route("Gamma")]; + let route_ids = vec![10, 20, 30]; + let rides = vec![ride("Morning"), ride("Evening")]; + let ride_ids = vec![70, 90]; + let trip_stage_ids = vec![10, 20]; + let mut app = App::new_idle(AppState::new(8_330_000, 46_570_000, 1.0)); + app.set_routes_with_ids(&routes, &route_ids); + app.set_rides(&rides, &ride_ids); + app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: &trip_stage_ids }]); + Self { + app, + routes, + route_ids, + rides, + ride_ids, + trip_stage_ids, + trip_present: true, + nav_generation: 0, + host: HostLoop::new(), + fail_next_finalize: false, + commit_success_pending: false, + pending_nav_plan: None, + pending_dfu_scan: None, + pending_dfu_install: None, + pending_settings_result: None, + settings_revision: 0, + route_delete_fail_once: false, + retention_delete_attempts: 0, + settings_retry_requested: false, + } + } + + pub fn event(&mut self, event: HostEvent, trace: &mut TraceRecorder) { + trace.record_event(&event); + self.app.apply_event(event); + } + + pub fn feed_routes(&mut self, key: &'static str, trace: &mut TraceRecorder) { + self.app.set_routes_with_ids(&self.routes, &self.route_ids); + trace.record_feeder(FeederCall::new(FeederKind::RouteCatalog, key, self.routes.len())); + } + + pub fn feed_rides(&mut self, key: &'static str, trace: &mut TraceRecorder) { + self.app.set_rides(&self.rides, &self.ride_ids); + trace.record_feeder(FeederCall::new(FeederKind::RideCatalog, key, self.rides.len())); + } + + pub fn feed_trips(&mut self, key: &'static str, trace: &mut TraceRecorder) { + if self.trip_present { + self.app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: &self.trip_stage_ids }]); + } else { + self.app.set_trips(&[]); + } + trace.record_feeder(FeederCall::new(FeederKind::TripCatalog, key, usize::from(self.trip_present))); + } + + fn reset_to_riding_map(&mut self) { + self.app = App::new_idle(AppState::new(7_500_000, 43_500_000, 1.0)); + self.app.set_routes_with_ids(&self.routes, &self.route_ids); + self.app.set_rides(&self.rides, &self.ride_ids); + self.app.set_map_nav_graph(true); + self.app.state.user_fix = Some(road_fix(0.0)); + self.app.apply_gesture(Gesture::BackHold); + self.app.apply_gesture(Gesture::Press); + // The first row is the trip folder; enter it, open the first stage, then start the ride. + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + let bytes = road_obcr(); + let source = SliceSource(&bytes); + let index = RouteIndex::read(&source).unwrap(); + let reader = RouteReader::new(&index, &source); + struct OneFix(Option); + impl LocationSource for OneFix { + fn poll(&mut self) -> Option { + self.0.take() + } + } + let mut location = OneFix(Some(road_fix(0.31))); + self.app.tick(RideClock(0), Sensors::new(&mut location), Some(&reader)); + } + + fn tick_without_fix(&mut self) { + struct NoFix; + impl LocationSource for NoFix { + fn poll(&mut self) -> Option { + None + } + } + let mut location = NoFix; + self.app.tick(RideClock(0), Sensors::new(&mut location), None); + } + + fn open_detour_plan(&mut self) { + if !matches!(self.app.top_screen(), Screen::Detour(_)) { + self.reset_to_riding_map(); + self.app.apply_gesture(Gesture::BackHold); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Press); + } + self.app.apply_gesture(Gesture::Press); + } + + pub fn snapshot_state(&self) -> VisibleState { + visible_state(&self.app, self.settings_revision, self.retention_delete_attempts) + } +} + +/// The normalized rider-visible state every runner is compared on. +pub fn visible_state(app: &App, settings_revision: u16, retention_delete_attempts: u16) -> VisibleState { + let screen = match app.top_screen() { + Screen::Home(_) => ScreenState::Home, + Screen::Menu(_) => ScreenState::Menu, + Screen::RouteMenu(_) => ScreenState::Routes, + Screen::RouteOverview(_) => ScreenState::RouteOverview, + Screen::Rides(_) => ScreenState::Rides, + Screen::RideDetail(_) => ScreenState::RideDetail, + Screen::Map(_) => ScreenState::Map, + Screen::Detour(_) => ScreenState::Detour, + Screen::NavPlanning(_) => ScreenState::Planning, + Screen::DetourPreview(_) => ScreenState::DetourPreview, + Screen::DfuCheck(_) => ScreenState::DfuCheck, + Screen::DfuConfirm(_) => ScreenState::DfuConfirm, + Screen::DfuProgress(_) => ScreenState::DfuProgress, + Screen::DfuInstalling(_) => ScreenState::DfuInstalling, + Screen::DfuError(_) => ScreenState::DfuError, + Screen::Warning(_) => ScreenState::Warning, + Screen::WeatherAlert(_) => ScreenState::WeatherAlert, + _ => ScreenState::Other, + }; + VisibleState { + screen, + stack_depth: app.debug_stack_len(), + mode: app.mode(), + route_names: app.routes().iter().map(|item| item.name.as_str().to_owned()).collect(), + route_ids: app.route_ids().iter().map(|&id| fixture_object_key(ObjectKind::Route, id)).collect(), + ride_names: app.rides().iter().map(|item| item.name.as_str().to_owned()).collect(), + ride_ids: app.ride_ids().iter().map(|&id| fixture_object_key(ObjectKind::Ride, id)).collect(), + trip_names: app.trips().iter().map(|item| item.name.as_str().to_owned()).collect(), + trip_ids: app.trips().iter().map(|trip| fixture_object_key(ObjectKind::Trip, trip.id)).collect(), + active_route_name: app + .active_route_index() + .and_then(|index| app.routes().get(index)) + .map(|item| item.name.as_str().to_owned()), + active_route_id: app + .active_route_index() + .and_then(|index| app.route_ids().get(index)) + .map(|&id| fixture_object_key(ObjectKind::Route, id)), + requested_ride_id: app.ride_track_request().map(|id| fixture_object_key(ObjectKind::Ride, id)), + recording: app.activity.is_tracking(), + clock_trusted: app.clock_trusted(), + rain_steps_ahead: app.state.rain_steps_ahead, + settings_revision: match settings_revision { + 0 => None, + 1 => Some(RevisionKey(0)), + 2 => Some(RevisionKey(1)), + other => panic!("unseeded fixture settings revision {other}"), + }, + settings_utc_offset_min: app.settings().utc_offset_min, + pending_host_command: app.has_pending_host_command(), + nav_preview_missing: app.nav_preview_missing(), + warning: match app.top_screen() { + Screen::Warning(card) => Some(card.flags()), + _ => None, + }, + retention_delete_attempts, + } +} + +impl TraceHarness for LegacyHarness { + type State = VisibleState; + type Outcome = Outcome; + + fn snapshot(&self) -> Self::State { + self.snapshot_state() + } + + fn apply_input(&mut self, action: &Action, trace: &mut TraceRecorder) { + match action { + Action::Settle => {} + Action::StoreChanged => self.event(HostEvent::StoreChanged, trace), + Action::RefreshCatalogs => {} + Action::UploadRoutesThenTrip => { + self.feed_routes("upload.routes", trace); + self.event(HostEvent::RouteUploaded { id: 10, replaced: false, elevation: None }, trace); + self.event(HostEvent::RouteUploaded { id: 20, replaced: false, elevation: None }, trace); + self.feed_trips("upload.trip", trace); + self.event(HostEvent::TripUploaded { id: 50, replaced: false }, trace); + } + Action::RemapCatalogIdentity => { + self.routes.swap(0, 2); + self.route_ids.swap(0, 2); + self.feed_routes("catalog.remap", trace); + self.feed_trips("catalog.remap-trips", trace); + } + Action::DeleteRoute => { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + // The trip folders are first. Open the first stage, then its first route overview. + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Hold); + } + Action::DeleteRide => { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Hold); + } + Action::CascadeDeleteTrip => { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Hold); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Hold); + } + Action::StartRoutePlan => { + assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "First plan")); + } + Action::CancelRoutePlan => self.app.apply_gesture(Gesture::Back), + Action::DeliverLateRouteResult => { + self.feed_routes("nav.old-publication", trace); + self.event(HostEvent::NavPlanned(Ok(10)), trace); + } + Action::ReplaceRoutePlan => { + assert!(self.app.debug_start_nav((0, 0), (2_000, 2_000), "Replacement")); + } + Action::PlanDetour => self.open_detour_plan(), + Action::CancelDetour => self.app.apply_gesture(Gesture::Back), + Action::CommitDetour => { + self.app.set_detour_preview(&[(0, 0), (100, 100)]); + trace.record_feeder(FeederCall::new(FeederKind::DetourPreview, "detour.preview", 2)); + self.event( + HostEvent::DetourPlanned(Ok(DetourPreview { + cost_delta_m: 100, + total_distance_m: 900, + rejoin_m: 1_000, + ascent_m: Some(30), + })), + trace, + ); + self.app.apply_gesture(Gesture::Press); + self.commit_success_pending = true; + } + Action::RouteNoPath => { + assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "No path")); + self.pending_nav_plan = Some(Err(NavError::NoPath)); + } + Action::StartRecorder => self.app.activity.start_session(), + Action::SaveRecorder => { + self.app.activity.end_session(); + self.app.activity.request_track(TrackAction::Save); + } + Action::DiscardRecorder => { + self.app.activity.end_session(); + self.app.activity.request_track(TrackAction::Discard); + } + Action::FailRecorderFinalize => { + self.app.activity.request_track(TrackAction::Save); + // Compatibility limitation: the legacy vocabulary has no recorder-finalize outcome; + // hosts report the failure through the generic warning event. + self.fail_next_finalize = true; + } + Action::ReplaceRecorderSession => { + self.app.activity.end_session(); + self.app.activity.start_session(); + } + Action::DirtySettings => { + let settings = *self.app.settings(); + self.app.set_settings(settings); + trace.feeder_revision(FeederKind::Settings, "settings.boot", 0, 1); + self.app.stamp_clock_ble(1_720_000_000, 60); + self.settings_revision = 1; + } + Action::PersistSettings => {} + Action::DeliverStaleSettingsResult => { + self.app.stamp_clock_ble(1_720_000_060, 120); + self.settings_revision = 2; + self.pending_settings_result = Some(PendingSettingsResult::PersistRevision(1)); + } + Action::DeliverMatchingSettingsResult => { + self.pending_settings_result = Some(PendingSettingsResult::PersistRevision(self.settings_revision)); + } + Action::FailSettingsPersist => self.pending_settings_result = Some(PendingSettingsResult::FailLatest), + Action::RetrySettingsPersist => { + // The failed revision is retained through the real bounded retry window. + self.settings_retry_requested = true; + self.app.advance_animations(InputClock(4_002)); + self.pending_settings_result = Some(PendingSettingsResult::PersistLatest); + } + Action::StampRouteUse => { + self.app.stamp_clock_ble(1_720_000_000, 60); + self.event(HostEvent::RouteUploaded { id: 10, replaced: false, elevation: None }, trace); + } + Action::StampRideSync => { + self.app.stamp_clock_ble(1_720_000_000, 60); + let mut stamped = self.rides[0].clone(); + stamped.synced = true; + stamped.synced_at_utc = 0; + self.rides[0] = stamped; + self.feed_rides("retention.synced", trace); + self.app.set_ride_retention_inventory(&[RideRetentionRecord { + id: self.ride_ids[0], + synced: true, + synced_at_utc: 0, + }]); + trace.record_feeder(FeederCall::new(FeederKind::RideRetention, "retention.rides", 1)); + self.app.force_retention_sweep(); + self.tick_without_fix(); + } + Action::DeleteExpiredObject => { + self.app.stamp_clock_ble(1_720_000_000, 60); + self.app.set_route_meta(&[ + obc_app::RouteRetentionMeta::new(obc_app::Retention::Day1, 1), + obc_app::RouteRetentionMeta::new(obc_app::Retention::Never, 0), + obc_app::RouteRetentionMeta::new(obc_app::Retention::Never, 0), + ]); + trace.record_feeder(FeederCall::new(FeederKind::RouteRetention, "retention.routes", 3)); + self.route_delete_fail_once = true; + self.app.force_retention_sweep(); + self.tick_without_fix(); + } + Action::RetryExpiredDelete => { + // The original failed candidate owns its retry; no new discovery sweep is needed. + self.app.advance_animations(InputClock(5_002)); + self.tick_without_fix(); + } + Action::GateExpiryUntilClockTrusted => { + assert!(!self.app.clock_trusted()); + self.app.force_retention_sweep(); + self.tick_without_fix(); + } + Action::ScanDfuSuccess => { + assert!(self.app.open_remote_dfu_check()); + self.pending_dfu_scan = Some(Ok(DfuScanReport::new("v1", "v2", false))); + } + Action::ScanDfuFailure => { + // Finish the preceding flow, then open a fresh real scan wait. + self.app.apply_gesture(Gesture::Back); + assert!(self.app.open_remote_dfu_check()); + self.pending_dfu_scan = Some(Err(DfuScanError::Damaged)); + } + Action::StartDfuInstall => { + assert!(self.app.open_remote_dfu_check()); + self.pending_dfu_scan = Some(Ok(DfuScanReport::new("v1", "v2", false))); + } + Action::AdmitDfuInstall => { + self.app.apply_gesture(Gesture::Press); + self.pending_dfu_install = Some(Ok(())); + } + Action::RefuseDfuInstall => { + self.app.apply_gesture(Gesture::Press); + self.pending_dfu_install = Some(Err(DfuInstallError::Recording)); + } + Action::ConfirmUpdate => self.event(HostEvent::UpdateConfirmed(clamp("v2")), trace), + Action::FailUpdate => { + self.event(HostEvent::UpdateFailed { why: DfuFailure::Reverted, staged: Some(clamp("v3")) }, trace) + } + Action::ForgetBond => self.app.state.ble_forget_pending = true, + Action::ScanCardSpace => { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(-1)); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(-1)); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(3)); + self.app.apply_gesture(Gesture::Press); + } + Action::NeedRideTrack => { + if !matches!(self.app.top_screen(), Screen::RideDetail(_)) { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + } + } + Action::RemapRideIdentity => { + self.rides.swap(0, 1); + self.ride_ids.swap(0, 1); + self.feed_rides("ride.remap", trace); + } + Action::ReplaceRideTrackNeed => { + self.app.apply_gesture(Gesture::Back); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Press); + } + Action::FillRideTrack => {} + Action::NeedNavPreview => { + if !self.app.nav_preview_missing() { + assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "Preview")); + self.feed_routes("nav.preview-route", trace); + self.event(HostEvent::NavPlanned(Ok(10)), trace); + self.nav_generation = self.nav_generation.wrapping_add(1); + } + } + Action::ReplaceNavPreviewNeed => { + self.app.apply_gesture(Gesture::Back); + assert!(self.app.debug_start_nav((0, 0), (2_000, 2_000), "New preview")); + self.feed_routes("nav.preview-replacement", trace); + self.event(HostEvent::NavPlanned(Ok(10)), trace); + self.nav_generation = self.nav_generation.wrapping_add(1); + } + Action::FillNavPreview => {} + Action::RefreshWeather => { + self.app.set_rain_view(3, 0.5); + trace.record_feeder(FeederCall::new(FeederKind::RainView, "weather.refresh", 3)); + } + Action::InstallWeatherData => { + self.app.weather_feed_changed(); + trace.record_feeder(FeederCall::new(FeederKind::WeatherSnapshot, "weather.installed", 1)); + trace.record_feeder(FeederCall::new(FeederKind::WeatherFeedChanged, "weather.installed", 1)); + } + Action::MarkWeatherStale => { + self.app.set_rain_view(0, 0.0); + trace.record_feeder(FeederCall::new(FeederKind::RainView, "weather.stale", 0)); + } + Action::DeliverWeatherAlert => { + assert!(self.app.show_weather_alert(obc_app::WeatherAlertKind::Storm, 12)); + } + } + } + + fn run_pass(&mut self, trace: &mut TraceRecorder) -> Vec { + let ride_track_need = self.app.ride_track_request(); + let mut route_repo = BorrowedRoutes { + catalog: &mut self.routes, + ids: &mut self.route_ids, + fail_delete_once: &mut self.route_delete_fail_once, + delete_attempts: &mut self.retention_delete_attempts, + }; + let mut ride_repo = BorrowedRides { catalog: &mut self.rides, ids: &mut self.ride_ids }; + let mut trip_repo = BorrowedTrips { present: &mut self.trip_present, stage_ids: &self.trip_stage_ids }; + let mut platform = Vec::new(); + let finish = self.host.reconcile_commands_traced( + &mut self.app, + &mut route_repo, + &mut ride_repo, + &mut trip_repo, + PlanHold::new(true, true), + true, + true, + |_app, command| platform.push(command), + trace, + ); + let mut outcomes = Vec::new(); + if let Some(result) = self.pending_nav_plan.take() { + outcomes.push(Outcome::Event(HostEvent::NavPlanned(result))); + } + if matches!(finish, Some(TrackAction::Save)) { + if std::mem::take(&mut self.fail_next_finalize) { + outcomes.push(Outcome::FinalizeFailed); + } else { + outcomes.push(Outcome::FinishSave); + } + } + if platform.iter().any(|command| matches!(command, HostCommand::ScanCardFree)) { + outcomes.push(Outcome::CardScanned); + } + let persisted_revision = platform.iter().find_map(|command| match command { + HostCommand::PersistSettings { revision } => Some(*revision), + _ => None, + }); + let ready_settings_result = !matches!(self.pending_settings_result, Some(PendingSettingsResult::PersistLatest)) + || persisted_revision.is_some(); + if ready_settings_result { + if let Some(result) = self.pending_settings_result.take() { + let revision = match result { + PendingSettingsResult::PersistRevision(revision) => revision, + PendingSettingsResult::PersistLatest | PendingSettingsResult::FailLatest => { + persisted_revision.unwrap_or(self.settings_revision) + } + }; + outcomes.push(Outcome::Event(match result { + PendingSettingsResult::FailLatest => { + HostEvent::SettingsPersistFailed { revision, error: SettingsSaveError::Backend } + } + PendingSettingsResult::PersistLatest | PendingSettingsResult::PersistRevision(_) => { + HostEvent::SettingsPersisted { revision } + } + })); + } + } + for command in platform { + match command { + HostCommand::Dfu(obc_app::DfuAction::Scan) => { + if let Some(result) = self.pending_dfu_scan.take() { + outcomes.push(Outcome::Event(HostEvent::DfuScanned(result))); + } + } + HostCommand::Dfu(obc_app::DfuAction::Install) => { + if let Some(result) = self.pending_dfu_install.take() { + outcomes.push(Outcome::Event(match result { + Ok(()) => HostEvent::DfuInstallBegan, + Err(error) => HostEvent::DfuInstallFailed(error), + })); + } + } + _ => {} + } + } + if let Some(id) = ride_track_need { + outcomes.push(Outcome::RideTrack { id }); + } + if self.app.nav_preview_missing() { + outcomes.push(Outcome::NavPreview { generation: self.nav_generation }); + } + if std::mem::take(&mut self.commit_success_pending) { + outcomes.push(Outcome::DetourCommitted); + } + outcomes + } + + fn deliver(&mut self, outcome: Self::Outcome, trace: &mut TraceRecorder) { + match outcome { + Outcome::Event(event) => { + let settings_failed = matches!(event, HostEvent::SettingsPersistFailed { .. }); + self.event(event, trace); + if settings_failed && self.settings_retry_requested { + self.app.advance_animations(InputClock(6_003)); + } + } + Outcome::FinishSave => self.feed_rides("recorder.saved", trace), + Outcome::FinalizeFailed => self.event(HostEvent::Warning(WarningFlags::REC_ERROR), trace), + Outcome::CardScanned => self.event(HostEvent::CardScanned { free_bytes: Some(8 * 1024 * 1024) }, trace), + Outcome::RideTrack { id } => { + let current = self.app.ride_track_request(); + let data = ride_delivery_key(id, current); + // Legacy bulk feeders are not request-keyed. Record and apply even stale attempts; + // accepting this fill for a replacement view is a characterized compatibility defect. + self.app.set_ride_profile(None); + self.app.set_ride_preview(&[(0, 0), (1, 1)]); + trace.record_feeder(FeederCall::new(FeederKind::RideProfile, data, 0)); + trace.record_feeder(FeederCall::new(FeederKind::RidePreview, data, 2)); + } + Outcome::NavPreview { generation } => { + let data = nav_delivery_key(generation, self.nav_generation); + // The legacy preview feeder has no generation argument, so delayed old data is + // attempted against the current request and may satisfy it incorrectly. + self.app.set_nav_preview(&[(0, 0), (1, 1)]); + trace.record_feeder(FeederCall::new(FeederKind::NavPreview, data, 2)); + } + Outcome::DetourCommitted => { + self.feed_routes("detour.commit", trace); + self.event(HostEvent::DetourCommitted(Ok(10)), trace); + } + } + } +} + +struct BorrowedRoutes<'a> { + catalog: &'a mut Vec, + ids: &'a mut Vec, + fail_delete_once: &'a mut bool, + delete_attempts: &'a mut u16, +} + +impl RouteRepository for BorrowedRoutes<'_> { + fn catalog(&self) -> &[RouteSummary] { + self.catalog + } + + fn ids(&self) -> &[u64] { + self.ids + } + + fn delete_by_id(&mut self, id: u64) -> bool { + *self.delete_attempts = self.delete_attempts.saturating_add(1); + if std::mem::take(self.fail_delete_once) { + return false; + } + let Some(index) = self.ids.iter().position(|candidate| *candidate == id) else { return false }; + self.ids.remove(index); + self.catalog.remove(index); + true + } + + fn write_nav_route(&mut self, _bytes: &[u8]) -> Option { + None + } + + fn sync_active(&mut self, _want: Option) -> bool { + false + } + + fn active_source(&self) -> Option> { + None + } + + fn invalidate_active(&mut self) {} +} + +struct BorrowedRides<'a> { + catalog: &'a mut Vec, + ids: &'a mut Vec, +} + +impl RideRepository for BorrowedRides<'_> { + fn catalog(&self) -> &[RideSummary] { + self.catalog + } + + fn ids(&self) -> &[u64] { + self.ids + } + + fn delete_by_id(&mut self, id: u64) -> bool { + let Some(index) = self.ids.iter().position(|candidate| *candidate == id) else { return false }; + self.ids.remove(index); + self.catalog.remove(index); + true + } + + fn profile_by_id(&self, _id: u64) -> Option { + None + } + + fn preview_by_id(&self, _id: u64) -> Vec<(i32, i32)> { + vec![(0, 0), (1, 1)] + } +} + +struct BorrowedTrips<'a> { + present: &'a mut bool, + stage_ids: &'a [u64], +} + +impl TripCatalog for BorrowedTrips<'_> { + fn member_route_ids(&self, id: u64) -> Vec { + if *self.present && id == 50 { + self.stage_ids.to_vec() + } else { + Vec::new() + } + } + + fn delete_by_id(&mut self, id: u64) -> bool { + if *self.present && id == 50 { + *self.present = false; + true + } else { + false + } + } + + fn refeed(&self, app: &mut App) { + if *self.present { + app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: self.stage_ids }]); + } else { + app.set_trips(&[]); + } + } +} + +fn ride_delivery_key(requested: u64, current: Option) -> &'static str { + match (requested, current) { + (70, Some(70)) => "ride.track.requested-morning.current-morning", + (70, Some(90)) => "ride.track.requested-morning.current-evening", + (90, Some(70)) => "ride.track.requested-evening.current-morning", + (90, Some(90)) => "ride.track.requested-evening.current-evening", + (70, None) => "ride.track.requested-morning.current-none", + (90, None) => "ride.track.requested-evening.current-none", + (_, Some(70)) => "ride.track.requested-other.current-morning", + (_, Some(90)) => "ride.track.requested-other.current-evening", + (_, Some(_)) => "ride.track.requested-other.current-other", + (_, None) => "ride.track.requested-other.current-none", + } +} + +fn nav_delivery_key(requested: u16, current: u16) -> &'static str { + match (requested, current) { + (0, 0) => "nav.preview.requested-g0.current-g0", + (0, 1) => "nav.preview.requested-g0.current-g1", + (0, 2) => "nav.preview.requested-g0.current-g2", + (1, 1) => "nav.preview.requested-g1.current-g1", + (1, 2) => "nav.preview.requested-g1.current-g2", + (2, 2) => "nav.preview.requested-g2.current-g2", + _ if requested == current => "nav.preview.requested-other.current-matching", + _ => "nav.preview.requested-other.current-replacement", + } +} + +pub fn fixture_object_key(kind: ObjectKind, id: u64) -> ObjectKey { + match (kind, id) { + (ObjectKind::Route, 10) => ObjectKey(0), + (ObjectKind::Route, 20) => ObjectKey(1), + (ObjectKind::Route, 30) => ObjectKey(2), + (ObjectKind::Ride, 70) => ObjectKey(3), + (ObjectKind::Ride, 90) => ObjectKey(4), + (ObjectKind::Trip, 50) => ObjectKey(5), + _ => panic!("unseeded fixture identity {kind:?}:{id}"), + } +} + +pub fn route(name: &str) -> RouteSummary { + let mut summary = RouteSummary { + name: Default::default(), + distance_km: 10, + climb_m: 100, + bbox: BBox { min_lon: 0, min_lat: 0, max_lon: 1_000, max_lat: 1_000 }, + start_lon: 0, + start_lat: 0, + }; + summary.name.push_str(name).unwrap(); + summary +} + +pub fn ride(name: &str) -> RideSummary { + let mut summary = RideSummary { + name: Default::default(), + start_time: 1_720_000_000, + distance_m: 1_000, + moving_time_s: 600, + climb_m: 10, + synced: false, + synced_at_utc: 0, + }; + summary.name.push_str(name).unwrap(); + summary +} + +#[derive(Default)] +struct TestSink(Vec); + +impl ByteSink for TestSink { + fn write(&mut self, bytes: &[u8]) -> Result<(), obc_formats::io::Error> { + self.0.extend_from_slice(bytes); + Ok(()) + } + + fn patch_at(&mut self, offset: u32, bytes: &[u8]) -> Result<(), obc_formats::io::Error> { + let start = offset as usize; + self.0[start..start + bytes.len()].copy_from_slice(bytes); + Ok(()) + } +} + +pub fn road_obcr() -> Vec { + let mut gpx = String::from(""); + for index in 0..=10 { + let lon = 7.50 + 0.04 * index as f64 / 10.0; + gpx.push_str(&format!("100")); + } + gpx.push_str(""); + let mut sink = TestSink::default(); + gpx_to_obcr(&SliceSource(gpx.as_bytes()), "Trace road", &mut sink).unwrap(); + sink.0 +} + +pub fn road_fix(fraction: f64) -> Fix { + Fix::at(43_500_000, ((7.50 + 0.04 * fraction) * 1e6) as i32) +} + +pub struct Scenario { + pub name: &'static str, + pub requirements: &'static [Requirement], + pub actions: &'static [Action], +} + +pub const SCENARIOS: &[Scenario] = &[ + Scenario { + name: "catalog.refresh-upload-remap", + requirements: &[ + Requirement::CatalogStoreChange, + Requirement::CatalogRefresh, + Requirement::CatalogUploadOrder, + Requirement::CatalogIdentityRemap, + ], + actions: &[ + Action::StoreChanged, + Action::RefreshCatalogs, + Action::UploadRoutesThenTrip, + Action::RemapCatalogIdentity, + ], + }, + Scenario { + name: "catalog.route-delete", + requirements: &[Requirement::CatalogRouteDelete], + actions: &[Action::DeleteRoute], + }, + Scenario { + name: "catalog.ride-delete", + requirements: &[Requirement::CatalogRideDelete], + actions: &[Action::DeleteRide], + }, + Scenario { + name: "catalog.trip-cascade", + requirements: &[Requirement::CatalogTripCascade], + actions: &[Action::CascadeDeleteTrip], + }, + Scenario { + name: "navigation.plan-cancel-late-replacement", + requirements: &[ + Requirement::NavigationPlan, + Requirement::NavigationCancel, + Requirement::NavigationLateResult, + Requirement::NavigationReplacement, + ], + actions: &[ + Action::StartRoutePlan, + Action::CancelRoutePlan, + Action::ReplaceRoutePlan, + Action::DeliverLateRouteResult, + ], + }, + Scenario { + name: "navigation.detour-lifecycle", + requirements: &[ + Requirement::NavigationDetourPlan, + Requirement::NavigationDetourCancel, + Requirement::NavigationDetourCommit, + ], + actions: &[Action::PlanDetour, Action::CancelDetour, Action::PlanDetour, Action::CommitDetour], + }, + Scenario { + name: "navigation.no-path", + requirements: &[Requirement::NavigationNoPath], + actions: &[Action::RouteNoPath], + }, + Scenario { + name: "recorder.start-save-discard", + requirements: &[Requirement::RecorderStart, Requirement::RecorderSave, Requirement::RecorderDiscard], + actions: &[Action::StartRecorder, Action::SaveRecorder, Action::StartRecorder, Action::DiscardRecorder], + }, + Scenario { + name: "recorder.failure-and-session-replacement", + requirements: &[Requirement::RecorderFinalizeFailure, Requirement::RecorderSessionReplacement], + actions: &[Action::StartRecorder, Action::FailRecorderFinalize, Action::ReplaceRecorderSession], + }, + Scenario { + name: "settings.revision-success-and-stale-result", + requirements: &[ + Requirement::SettingsDirtyRevision, + Requirement::SettingsSuccess, + Requirement::SettingsStaleResult, + ], + actions: &[ + Action::DirtySettings, + Action::PersistSettings, + Action::DeliverStaleSettingsResult, + Action::DeliverMatchingSettingsResult, + ], + }, + Scenario { + name: "settings.failure-and-retry", + requirements: &[Requirement::SettingsFailure, Requirement::SettingsRetry], + actions: &[Action::DirtySettings, Action::FailSettingsPersist, Action::RetrySettingsPersist], + }, + Scenario { + name: "retention.route-and-ride-stamps", + requirements: &[Requirement::RetentionRouteUseStamp, Requirement::RetentionRideSyncStamp], + actions: &[Action::StampRouteUse, Action::StampRideSync], + }, + Scenario { + name: "retention.expiry-retry-and-trusted-clock", + requirements: &[ + Requirement::RetentionExpiryDelete, + Requirement::RetentionRetry, + Requirement::RetentionTrustedClockGate, + ], + actions: &[Action::GateExpiryUntilClockTrusted, Action::DeleteExpiredObject, Action::RetryExpiredDelete], + }, + Scenario { + name: "dfu.scan-outcomes", + requirements: &[Requirement::DfuScanSuccess, Requirement::DfuScanFailure], + actions: &[Action::ScanDfuSuccess, Action::Settle, Action::Settle, Action::ScanDfuFailure], + }, + Scenario { + name: "dfu.install-start", + requirements: &[Requirement::DfuInstallStart], + actions: &[Action::StartDfuInstall, Action::Settle, Action::Settle, Action::AdmitDfuInstall], + }, + Scenario { + name: "dfu.install-refusal", + requirements: &[Requirement::DfuInstallRefusal], + actions: &[Action::StartDfuInstall, Action::Settle, Action::Settle, Action::RefuseDfuInstall], + }, + Scenario { + name: "dfu.boot-outcomes", + requirements: &[Requirement::DfuConfirmedUpdate, Requirement::DfuFailedUpdate], + actions: &[Action::ConfirmUpdate, Action::FailUpdate], + }, + Scenario { + name: "platform.bond-and-card-space", + requirements: &[Requirement::PlatformForgetBond, Requirement::PlatformCardSpaceScan], + actions: &[Action::ForgetBond, Action::ScanCardSpace], + }, + Scenario { + name: "derived-data.repeats-until-matching-fill", + requirements: &[ + Requirement::DerivedRideTrackRepeatedUntilFill, + Requirement::DerivedNavPreviewRepeatedUntilFill, + ], + actions: &[ + Action::NeedRideTrack, + Action::RemapRideIdentity, + Action::ReplaceRideTrackNeed, + Action::FillRideTrack, + Action::NeedNavPreview, + Action::ReplaceNavPreviewNeed, + Action::NeedNavPreview, + Action::FillNavPreview, + ], + }, + Scenario { + name: "weather.refresh-install-stale-alert", + requirements: &[ + Requirement::WeatherRefreshState, + Requirement::WeatherInstalledDataChange, + Requirement::WeatherStaleData, + Requirement::WeatherAlertDelivery, + ], + actions: &[ + Action::RefreshWeather, + Action::InstallWeatherData, + Action::MarkWeatherStale, + Action::DeliverWeatherAlert, + ], + }, +]; + +/// The animation clock `Action::RetrySettingsPersist` moves the app to when a settings failure has +/// already been delivered. Its twin lives in [`LegacyHarness::deliver`]; a runner that owns a pass +/// clock of its own has to know both marks. +pub const SETTINGS_FAILURE_RETRY_MS: u32 = 6_003; + +/// The `InputClock` an action advances the app's animation clock to, or `0` for one that does not. +/// +/// The legacy harness has no clock of its own — a handful of actions drive the app's directly, to +/// step past a bounded retry window. A runner that *does* own a pass clock keeps it at or above +/// these marks, so the window it just stepped past cannot reopen behind it. +pub fn clock_watermark(action: Action) -> u32 { + match action { + Action::RetrySettingsPersist => 4_002, + Action::RetryExpiredDelete => 5_002, + _ => 0, + } +} + +pub fn action_name(action: Action) -> &'static str { + match action { + Action::Settle => "settle", + Action::StoreChanged => "store-changed", + Action::RefreshCatalogs => "refresh-catalogs", + Action::UploadRoutesThenTrip => "upload-routes-then-trip", + Action::RemapCatalogIdentity => "remap-catalog-identity", + Action::DeleteRoute => "delete-route", + Action::DeleteRide => "delete-ride", + Action::CascadeDeleteTrip => "cascade-delete-trip", + Action::StartRoutePlan => "start-route-plan", + Action::CancelRoutePlan => "cancel-route-plan", + Action::DeliverLateRouteResult => "deliver-old-route-result", + Action::ReplaceRoutePlan => "start-replacement-route-plan", + Action::PlanDetour => "plan-detour", + Action::CancelDetour => "cancel-detour", + Action::CommitDetour => "commit-detour", + Action::RouteNoPath => "route-no-path", + Action::StartRecorder => "start-recorder", + Action::SaveRecorder => "save-recorder", + Action::DiscardRecorder => "discard-recorder", + Action::FailRecorderFinalize => "fail-recorder-finalize", + Action::ReplaceRecorderSession => "replace-recorder-session", + Action::DirtySettings => "dirty-settings", + Action::PersistSettings => "persist-settings-pass", + Action::DeliverStaleSettingsResult => "deliver-stale-settings-result", + Action::DeliverMatchingSettingsResult => "deliver-matching-settings-result", + Action::FailSettingsPersist => "fail-settings-persist", + Action::RetrySettingsPersist => "retry-settings-persist", + Action::StampRouteUse => "stamp-route-use", + Action::StampRideSync => "stamp-ride-sync", + Action::DeleteExpiredObject => "delete-expired-object", + Action::RetryExpiredDelete => "retry-expired-delete", + Action::GateExpiryUntilClockTrusted => "gate-expiry-until-clock-trusted", + Action::ScanDfuSuccess => "scan-dfu-success", + Action::ScanDfuFailure => "scan-dfu-failure", + Action::StartDfuInstall => "start-dfu-install", + Action::AdmitDfuInstall => "admit-dfu-install", + Action::RefuseDfuInstall => "refuse-dfu-install", + Action::ConfirmUpdate => "confirm-update", + Action::FailUpdate => "fail-update", + Action::ForgetBond => "forget-bond", + Action::ScanCardSpace => "scan-card-space", + Action::NeedRideTrack => "need-ride-track", + Action::RemapRideIdentity => "remap-ride-identity", + Action::ReplaceRideTrackNeed => "replace-ride-track-need", + Action::FillRideTrack => "fill-ride-track", + Action::NeedNavPreview => "need-nav-preview", + Action::ReplaceNavPreviewNeed => "replace-nav-preview-need", + Action::FillNavPreview => "fill-nav-preview", + Action::RefreshWeather => "refresh-weather", + Action::InstallWeatherData => "install-weather-data", + Action::MarkWeatherStale => "mark-weather-stale", + Action::DeliverWeatherAlert => "deliver-weather-alert", + } +} + +pub fn definition(scenario: &Scenario) -> TraceScenario { + TraceScenario { + name: scenario.name, + steps: scenario + .actions + .iter() + .copied() + .map(|action| ScenarioStep::new(TraceInput::Named(action_name(action)), action)) + .collect(), + } +} + +pub fn normalization_seed() -> NormalizationSeed { + NormalizationSeed { + objects: vec![ + (ObjectKind::Route, 10, ObjectKey(0)), + (ObjectKind::Route, 20, ObjectKey(1)), + (ObjectKind::Route, 30, ObjectKey(2)), + (ObjectKind::Ride, 70, ObjectKey(3)), + (ObjectKind::Ride, 90, ObjectKey(4)), + (ObjectKind::Trip, 50, ObjectKey(5)), + ], + revisions: vec![(1, RevisionKey(0)), (2, RevisionKey(1))], + times: vec![(1_720_000_000, TimeKey(0)), (1_720_000_060, TimeKey(1)), (1_720_000_120, TimeKey(2))], + } +} + +pub fn run_legacy( + scenario: &TraceScenario, + mode: RunnerMode, + harness: &mut LegacyHarness, +) -> Trace { + run_scenario_seeded(scenario, mode, &normalization_seed(), harness) + .unwrap_or_else(|error| panic!("{} failed in {mode:?}: {error:?}", scenario.name)) +} + +pub fn run_matrix(mode: RunnerMode) -> Vec> { + SCENARIOS + .iter() + .map(|scenario| { + let mut harness = LegacyHarness::new(); + run_legacy(&definition(scenario), mode, &mut harness) + }) + .collect() +} + +pub fn step<'a>( + trace: &'a Trace, + name: &'static str, +) -> &'a obc_host_core::trace::TraceStep { + trace + .steps + .iter() + .find(|step| step.input == TraceInput::Named(name)) + .unwrap_or_else(|| panic!("{} has no {name} step", trace.scenario)) +} + +pub fn command_count(trace: &Trace, tag: CommandTag) -> usize { + trace.steps.iter().flat_map(|step| &step.commands).filter(|command| command.tag() == tag).count() +} + +pub fn event_count(trace: &Trace, tag: EventTag) -> usize { + trace.steps.iter().flat_map(|step| &step.events).filter(|event| event.tag() == tag).count() +} + +pub fn feeder_count(trace: &Trace, kind: FeederKind) -> usize { + trace.steps.iter().flat_map(|step| &step.feeder_calls).filter(|call| call.feeder == kind).count() +} + +pub fn output_position( + trace: &Trace, + predicate: impl Fn(&TraceOutput) -> bool, +) -> Option<(usize, usize)> { + trace.steps.iter().enumerate().find_map(|(step_index, step)| { + step.timeline.iter().position(&predicate).map(|output_index| (step_index, output_index)) + }) +} + +pub fn command_precedes_event(trace: &Trace, command: CommandTag, event: EventTag) -> bool { + let command = + output_position(trace, |output| matches!(output, TraceOutput::Command(value) if value.tag() == command)); + let event = output_position(trace, |output| matches!(output, TraceOutput::Event(value) if value.tag() == event)); + matches!((command, event), (Some(command), Some(event)) if command < event) +} + +pub fn output_precedes( + trace: &Trace, + before: impl Fn(&TraceOutput) -> bool, + after: impl Fn(&TraceOutput) -> bool, +) -> bool { + matches!((output_position(trace, before), output_position(trace, after)), (Some(before), Some(after)) if before < after) +} diff --git a/host/obc-host-core/tests/device_core_legacy_traces.rs b/host/obc-host-core/tests/device_core_legacy_traces.rs index 1b716867a..e2392fddc 100644 --- a/host/obc-host-core/tests/device_core_legacy_traces.rs +++ b/host/obc-host-core/tests/device_core_legacy_traces.rs @@ -5,1192 +5,21 @@ //! outcomes, bulk feeder identities, and visible state. DC7 can run the same scenario definitions //! against DeviceCore and compare the normalized traces. +mod device_core_corpus; + use std::collections::BTreeSet; -use obc_app::dfu::{clamp, DfuFailure, DfuInstallError, DfuScanError, DfuScanReport}; -use obc_app::screen::Screen; -use obc_app::{ - App, AppState, DetourPreview, Gesture, HostCommand, HostEvent, Mode, RideRetentionRecord, RideSummary, - RouteSummary, TrackAction, TripInput, WarningFlags, -}; -use obc_formats::io::{ByteSink, SliceSource}; +use obc_app::{DfuFailure, TrackAction, WarningFlags}; use obc_host_core::trace::{ - run_scenario_seeded, CommandTag, DataKey, EventTag, FeederCall, FeederKind, NormalizationSeed, NormalizedCommand, - NormalizedError, NormalizedEvent, ObjectKey, ObjectKind, RevisionKey, RunnerMode, ScenarioStep, TimeKey, Trace, - TraceHarness, TraceInput, TraceOutput, TraceRecorder, TraceScenario, TraceSink, ALL_COMMAND_TAGS, ALL_EVENT_TAGS, + CommandTag, DataKey, EventTag, FeederKind, NormalizedCommand, NormalizedError, NormalizedEvent, ObjectKey, + RevisionKey, RunnerMode, TimeKey, Trace, TraceInput, TraceOutput, ALL_COMMAND_TAGS, ALL_EVENT_TAGS, ALL_FEEDER_KINDS, }; -use obc_host_core::{HostLoop, PlanHold, RideRepository, RouteRepository, TripCatalog}; -use obc_map_scene::BBox; -use obc_ports::{Fix, InputClock, LocationSource, RideClock, Sensors, SettingsSaveError}; -use obc_route::{gpx_to_obcr, NavError, RouteIndex, RouteReader}; - -/// Every behavior row locked by DC1. Keeping the inventory typed makes adding a scenario without -/// the corresponding acceptance row (or silently dropping a row during later refactors) fail. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum Requirement { - CatalogStoreChange, - CatalogRefresh, - CatalogRouteDelete, - CatalogRideDelete, - CatalogTripCascade, - CatalogUploadOrder, - CatalogIdentityRemap, - NavigationPlan, - NavigationCancel, - NavigationLateResult, - NavigationReplacement, - NavigationDetourPlan, - NavigationDetourCancel, - NavigationDetourCommit, - NavigationNoPath, - RecorderStart, - RecorderSave, - RecorderDiscard, - RecorderFinalizeFailure, - RecorderSessionReplacement, - SettingsDirtyRevision, - SettingsSuccess, - SettingsStaleResult, - SettingsFailure, - SettingsRetry, - RetentionRouteUseStamp, - RetentionRideSyncStamp, - RetentionExpiryDelete, - RetentionRetry, - RetentionTrustedClockGate, - DfuScanSuccess, - DfuScanFailure, - DfuInstallStart, - DfuInstallRefusal, - DfuConfirmedUpdate, - DfuFailedUpdate, - PlatformForgetBond, - PlatformCardSpaceScan, - DerivedRideTrackRepeatedUntilFill, - DerivedNavPreviewRepeatedUntilFill, - WeatherRefreshState, - WeatherInstalledDataChange, - WeatherStaleData, - WeatherAlertDelivery, -} - -const ALL_REQUIREMENTS: &[Requirement] = &[ - Requirement::CatalogStoreChange, - Requirement::CatalogRefresh, - Requirement::CatalogRouteDelete, - Requirement::CatalogRideDelete, - Requirement::CatalogTripCascade, - Requirement::CatalogUploadOrder, - Requirement::CatalogIdentityRemap, - Requirement::NavigationPlan, - Requirement::NavigationCancel, - Requirement::NavigationLateResult, - Requirement::NavigationReplacement, - Requirement::NavigationDetourPlan, - Requirement::NavigationDetourCancel, - Requirement::NavigationDetourCommit, - Requirement::NavigationNoPath, - Requirement::RecorderStart, - Requirement::RecorderSave, - Requirement::RecorderDiscard, - Requirement::RecorderFinalizeFailure, - Requirement::RecorderSessionReplacement, - Requirement::SettingsDirtyRevision, - Requirement::SettingsSuccess, - Requirement::SettingsStaleResult, - Requirement::SettingsFailure, - Requirement::SettingsRetry, - Requirement::RetentionRouteUseStamp, - Requirement::RetentionRideSyncStamp, - Requirement::RetentionExpiryDelete, - Requirement::RetentionRetry, - Requirement::RetentionTrustedClockGate, - Requirement::DfuScanSuccess, - Requirement::DfuScanFailure, - Requirement::DfuInstallStart, - Requirement::DfuInstallRefusal, - Requirement::DfuConfirmedUpdate, - Requirement::DfuFailedUpdate, - Requirement::PlatformForgetBond, - Requirement::PlatformCardSpaceScan, - Requirement::DerivedRideTrackRepeatedUntilFill, - Requirement::DerivedNavPreviewRepeatedUntilFill, - Requirement::WeatherRefreshState, - Requirement::WeatherInstalledDataChange, - Requirement::WeatherStaleData, - Requirement::WeatherAlertDelivery, -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Action { - Settle, - StoreChanged, - RefreshCatalogs, - UploadRoutesThenTrip, - RemapCatalogIdentity, - DeleteRoute, - DeleteRide, - CascadeDeleteTrip, - StartRoutePlan, - CancelRoutePlan, - DeliverLateRouteResult, - ReplaceRoutePlan, - PlanDetour, - CancelDetour, - CommitDetour, - RouteNoPath, - StartRecorder, - SaveRecorder, - DiscardRecorder, - FailRecorderFinalize, - ReplaceRecorderSession, - DirtySettings, - PersistSettings, - DeliverStaleSettingsResult, - DeliverMatchingSettingsResult, - FailSettingsPersist, - RetrySettingsPersist, - StampRouteUse, - StampRideSync, - DeleteExpiredObject, - RetryExpiredDelete, - GateExpiryUntilClockTrusted, - ScanDfuSuccess, - ScanDfuFailure, - StartDfuInstall, - AdmitDfuInstall, - RefuseDfuInstall, - ConfirmUpdate, - FailUpdate, - ForgetBond, - ScanCardSpace, - NeedRideTrack, - RemapRideIdentity, - ReplaceRideTrackNeed, - FillRideTrack, - NeedNavPreview, - ReplaceNavPreviewNeed, - FillNavPreview, - RefreshWeather, - InstallWeatherData, - MarkWeatherStale, - DeliverWeatherAlert, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ScreenState { - Home, - Menu, - Routes, - RouteOverview, - Rides, - RideDetail, - Map, - Detour, - Planning, - DetourPreview, - DfuCheck, - DfuConfirm, - DfuProgress, - DfuInstalling, - DfuError, - Warning, - WeatherAlert, - Other, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct VisibleState { - screen: ScreenState, - stack_depth: usize, - mode: Mode, - route_names: Vec, - route_ids: Vec, - ride_names: Vec, - ride_ids: Vec, - trip_names: Vec, - trip_ids: Vec, - active_route_name: Option, - active_route_id: Option, - requested_ride_id: Option, - recording: bool, - clock_trusted: bool, - rain_steps_ahead: u8, - settings_revision: Option, - settings_utc_offset_min: i16, - pending_host_command: bool, - nav_preview_missing: bool, - warning: Option, - retention_delete_attempts: u16, -} - -#[derive(Debug)] -enum Outcome { - Event(HostEvent), - FinishSave, - FinalizeFailed, - CardScanned, - RideTrack { id: u64 }, - NavPreview { generation: u16 }, - DetourCommitted, -} - -#[derive(Debug, Clone, Copy)] -enum PendingSettingsResult { - PersistLatest, - FailLatest, - PersistRevision(u16), -} - -/// The legacy adapter uses the real `App` protocol doors and `HostLoop`'s passive trace observer. -/// Inputs are real public app operations or UI gestures; each pass runs the real command dispatcher; -/// deliveries call the real `set_*` feeders and `apply_event`. Planner, detour-commit, recorder-finalize, -/// and derived-fill completions are scripted at that protocol boundary because the legacy interfaces -/// do not expose a deterministic completion seam. The fixture-backed `board_parity` suite separately -/// exercises the real planner/repository path; this fast corpus must not be read as a second planner. -struct LegacyHarness { - app: App, - routes: Vec, - route_ids: Vec, - rides: Vec, - ride_ids: Vec, - trip_stage_ids: Vec, - trip_present: bool, - nav_generation: u16, - host: HostLoop, - fail_next_finalize: bool, - commit_success_pending: bool, - pending_nav_plan: Option>, - pending_dfu_scan: Option>, - pending_dfu_install: Option>, - pending_settings_result: Option, - settings_revision: u16, - route_delete_fail_once: bool, - retention_delete_attempts: u16, - settings_retry_requested: bool, -} - -impl LegacyHarness { - fn new() -> Self { - let routes = vec![route("Alpha"), route("Beta"), route("Gamma")]; - let route_ids = vec![10, 20, 30]; - let rides = vec![ride("Morning"), ride("Evening")]; - let ride_ids = vec![70, 90]; - let trip_stage_ids = vec![10, 20]; - let mut app = App::new_idle(AppState::new(8_330_000, 46_570_000, 1.0)); - app.set_routes_with_ids(&routes, &route_ids); - app.set_rides(&rides, &ride_ids); - app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: &trip_stage_ids }]); - Self { - app, - routes, - route_ids, - rides, - ride_ids, - trip_stage_ids, - trip_present: true, - nav_generation: 0, - host: HostLoop::new(), - fail_next_finalize: false, - commit_success_pending: false, - pending_nav_plan: None, - pending_dfu_scan: None, - pending_dfu_install: None, - pending_settings_result: None, - settings_revision: 0, - route_delete_fail_once: false, - retention_delete_attempts: 0, - settings_retry_requested: false, - } - } - - fn event(&mut self, event: HostEvent, trace: &mut TraceRecorder) { - trace.record_event(&event); - self.app.apply_event(event); - } - - fn feed_routes(&mut self, key: &'static str, trace: &mut TraceRecorder) { - self.app.set_routes_with_ids(&self.routes, &self.route_ids); - trace.record_feeder(FeederCall::new(FeederKind::RouteCatalog, key, self.routes.len())); - } - - fn feed_rides(&mut self, key: &'static str, trace: &mut TraceRecorder) { - self.app.set_rides(&self.rides, &self.ride_ids); - trace.record_feeder(FeederCall::new(FeederKind::RideCatalog, key, self.rides.len())); - } - - fn feed_trips(&mut self, key: &'static str, trace: &mut TraceRecorder) { - if self.trip_present { - self.app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: &self.trip_stage_ids }]); - } else { - self.app.set_trips(&[]); - } - trace.record_feeder(FeederCall::new(FeederKind::TripCatalog, key, usize::from(self.trip_present))); - } - - fn reset_to_riding_map(&mut self) { - self.app = App::new_idle(AppState::new(7_500_000, 43_500_000, 1.0)); - self.app.set_routes_with_ids(&self.routes, &self.route_ids); - self.app.set_rides(&self.rides, &self.ride_ids); - self.app.set_map_nav_graph(true); - self.app.state.user_fix = Some(road_fix(0.0)); - self.app.apply_gesture(Gesture::BackHold); - self.app.apply_gesture(Gesture::Press); - // The first row is the trip folder; enter it, open the first stage, then start the ride. - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - let bytes = road_obcr(); - let source = SliceSource(&bytes); - let index = RouteIndex::read(&source).unwrap(); - let reader = RouteReader::new(&index, &source); - struct OneFix(Option); - impl LocationSource for OneFix { - fn poll(&mut self) -> Option { - self.0.take() - } - } - let mut location = OneFix(Some(road_fix(0.31))); - self.app.tick(RideClock(0), Sensors::new(&mut location), Some(&reader)); - } - - fn tick_without_fix(&mut self) { - struct NoFix; - impl LocationSource for NoFix { - fn poll(&mut self) -> Option { - None - } - } - let mut location = NoFix; - self.app.tick(RideClock(0), Sensors::new(&mut location), None); - } - - fn open_detour_plan(&mut self) { - if !matches!(self.app.top_screen(), Screen::Detour(_)) { - self.reset_to_riding_map(); - self.app.apply_gesture(Gesture::BackHold); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Press); - } - self.app.apply_gesture(Gesture::Press); - } - - fn snapshot_state(&self) -> VisibleState { - let screen = match self.app.top_screen() { - Screen::Home(_) => ScreenState::Home, - Screen::Menu(_) => ScreenState::Menu, - Screen::RouteMenu(_) => ScreenState::Routes, - Screen::RouteOverview(_) => ScreenState::RouteOverview, - Screen::Rides(_) => ScreenState::Rides, - Screen::RideDetail(_) => ScreenState::RideDetail, - Screen::Map(_) => ScreenState::Map, - Screen::Detour(_) => ScreenState::Detour, - Screen::NavPlanning(_) => ScreenState::Planning, - Screen::DetourPreview(_) => ScreenState::DetourPreview, - Screen::DfuCheck(_) => ScreenState::DfuCheck, - Screen::DfuConfirm(_) => ScreenState::DfuConfirm, - Screen::DfuProgress(_) => ScreenState::DfuProgress, - Screen::DfuInstalling(_) => ScreenState::DfuInstalling, - Screen::DfuError(_) => ScreenState::DfuError, - Screen::Warning(_) => ScreenState::Warning, - Screen::WeatherAlert(_) => ScreenState::WeatherAlert, - _ => ScreenState::Other, - }; - VisibleState { - screen, - stack_depth: self.app.debug_stack_len(), - mode: self.app.mode(), - route_names: self.app.routes().iter().map(|item| item.name.as_str().to_owned()).collect(), - route_ids: self.app.route_ids().iter().map(|&id| fixture_object_key(ObjectKind::Route, id)).collect(), - ride_names: self.app.rides().iter().map(|item| item.name.as_str().to_owned()).collect(), - ride_ids: self.app.ride_ids().iter().map(|&id| fixture_object_key(ObjectKind::Ride, id)).collect(), - trip_names: self.app.trips().iter().map(|item| item.name.as_str().to_owned()).collect(), - trip_ids: self.app.trips().iter().map(|trip| fixture_object_key(ObjectKind::Trip, trip.id)).collect(), - active_route_name: self - .app - .active_route_index() - .and_then(|index| self.app.routes().get(index)) - .map(|item| item.name.as_str().to_owned()), - active_route_id: self - .app - .active_route_index() - .and_then(|index| self.app.route_ids().get(index)) - .map(|&id| fixture_object_key(ObjectKind::Route, id)), - requested_ride_id: self.app.ride_track_request().map(|id| fixture_object_key(ObjectKind::Ride, id)), - recording: self.app.activity.is_tracking(), - clock_trusted: self.app.clock_trusted(), - rain_steps_ahead: self.app.state.rain_steps_ahead, - settings_revision: match self.settings_revision { - 0 => None, - 1 => Some(RevisionKey(0)), - 2 => Some(RevisionKey(1)), - other => panic!("unseeded fixture settings revision {other}"), - }, - settings_utc_offset_min: self.app.settings().utc_offset_min, - pending_host_command: self.app.has_pending_host_command(), - nav_preview_missing: self.app.nav_preview_missing(), - warning: match self.app.top_screen() { - Screen::Warning(card) => Some(card.flags()), - _ => None, - }, - retention_delete_attempts: self.retention_delete_attempts, - } - } -} - -impl TraceHarness for LegacyHarness { - type State = VisibleState; - type Outcome = Outcome; - - fn snapshot(&self) -> Self::State { - self.snapshot_state() - } - - fn apply_input(&mut self, action: &Action, trace: &mut TraceRecorder) { - match action { - Action::Settle => {} - Action::StoreChanged => self.event(HostEvent::StoreChanged, trace), - Action::RefreshCatalogs => {} - Action::UploadRoutesThenTrip => { - self.feed_routes("upload.routes", trace); - self.event(HostEvent::RouteUploaded { id: 10, replaced: false, elevation: None }, trace); - self.event(HostEvent::RouteUploaded { id: 20, replaced: false, elevation: None }, trace); - self.feed_trips("upload.trip", trace); - self.event(HostEvent::TripUploaded { id: 50, replaced: false }, trace); - } - Action::RemapCatalogIdentity => { - self.routes.swap(0, 2); - self.route_ids.swap(0, 2); - self.feed_routes("catalog.remap", trace); - self.feed_trips("catalog.remap-trips", trace); - } - Action::DeleteRoute => { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - // The trip folders are first. Open the first stage, then its first route overview. - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Hold); - } - Action::DeleteRide => { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Hold); - } - Action::CascadeDeleteTrip => { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Hold); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Hold); - } - Action::StartRoutePlan => { - assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "First plan")); - } - Action::CancelRoutePlan => self.app.apply_gesture(Gesture::Back), - Action::DeliverLateRouteResult => { - self.feed_routes("nav.old-publication", trace); - self.event(HostEvent::NavPlanned(Ok(10)), trace); - } - Action::ReplaceRoutePlan => { - assert!(self.app.debug_start_nav((0, 0), (2_000, 2_000), "Replacement")); - } - Action::PlanDetour => self.open_detour_plan(), - Action::CancelDetour => self.app.apply_gesture(Gesture::Back), - Action::CommitDetour => { - self.app.set_detour_preview(&[(0, 0), (100, 100)]); - trace.record_feeder(FeederCall::new(FeederKind::DetourPreview, "detour.preview", 2)); - self.event( - HostEvent::DetourPlanned(Ok(DetourPreview { - cost_delta_m: 100, - total_distance_m: 900, - rejoin_m: 1_000, - ascent_m: Some(30), - })), - trace, - ); - self.app.apply_gesture(Gesture::Press); - self.commit_success_pending = true; - } - Action::RouteNoPath => { - assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "No path")); - self.pending_nav_plan = Some(Err(NavError::NoPath)); - } - Action::StartRecorder => self.app.activity.start_session(), - Action::SaveRecorder => { - self.app.activity.end_session(); - self.app.activity.request_track(TrackAction::Save); - } - Action::DiscardRecorder => { - self.app.activity.end_session(); - self.app.activity.request_track(TrackAction::Discard); - } - Action::FailRecorderFinalize => { - self.app.activity.request_track(TrackAction::Save); - // Compatibility limitation: the legacy vocabulary has no recorder-finalize outcome; - // hosts report the failure through the generic warning event. - self.fail_next_finalize = true; - } - Action::ReplaceRecorderSession => { - self.app.activity.end_session(); - self.app.activity.start_session(); - } - Action::DirtySettings => { - let settings = *self.app.settings(); - self.app.set_settings(settings); - trace.feeder_revision(FeederKind::Settings, "settings.boot", 0, 1); - self.app.stamp_clock_ble(1_720_000_000, 60); - self.settings_revision = 1; - } - Action::PersistSettings => {} - Action::DeliverStaleSettingsResult => { - self.app.stamp_clock_ble(1_720_000_060, 120); - self.settings_revision = 2; - self.pending_settings_result = Some(PendingSettingsResult::PersistRevision(1)); - } - Action::DeliverMatchingSettingsResult => { - self.pending_settings_result = Some(PendingSettingsResult::PersistRevision(self.settings_revision)); - } - Action::FailSettingsPersist => self.pending_settings_result = Some(PendingSettingsResult::FailLatest), - Action::RetrySettingsPersist => { - // The failed revision is retained through the real bounded retry window. - self.settings_retry_requested = true; - self.app.advance_animations(InputClock(4_002)); - self.pending_settings_result = Some(PendingSettingsResult::PersistLatest); - } - Action::StampRouteUse => { - self.app.stamp_clock_ble(1_720_000_000, 60); - self.event(HostEvent::RouteUploaded { id: 10, replaced: false, elevation: None }, trace); - } - Action::StampRideSync => { - self.app.stamp_clock_ble(1_720_000_000, 60); - let mut stamped = self.rides[0].clone(); - stamped.synced = true; - stamped.synced_at_utc = 0; - self.rides[0] = stamped; - self.feed_rides("retention.synced", trace); - self.app.set_ride_retention_inventory(&[RideRetentionRecord { - id: self.ride_ids[0], - synced: true, - synced_at_utc: 0, - }]); - trace.record_feeder(FeederCall::new(FeederKind::RideRetention, "retention.rides", 1)); - self.app.force_retention_sweep(); - self.tick_without_fix(); - } - Action::DeleteExpiredObject => { - self.app.stamp_clock_ble(1_720_000_000, 60); - self.app.set_route_meta(&[ - obc_app::RouteRetentionMeta::new(obc_app::Retention::Day1, 1), - obc_app::RouteRetentionMeta::new(obc_app::Retention::Never, 0), - obc_app::RouteRetentionMeta::new(obc_app::Retention::Never, 0), - ]); - trace.record_feeder(FeederCall::new(FeederKind::RouteRetention, "retention.routes", 3)); - self.route_delete_fail_once = true; - self.app.force_retention_sweep(); - self.tick_without_fix(); - } - Action::RetryExpiredDelete => { - // The original failed candidate owns its retry; no new discovery sweep is needed. - self.app.advance_animations(InputClock(5_002)); - self.tick_without_fix(); - } - Action::GateExpiryUntilClockTrusted => { - assert!(!self.app.clock_trusted()); - self.app.force_retention_sweep(); - self.tick_without_fix(); - } - Action::ScanDfuSuccess => { - assert!(self.app.open_remote_dfu_check()); - self.pending_dfu_scan = Some(Ok(DfuScanReport::new("v1", "v2", false))); - } - Action::ScanDfuFailure => { - // Finish the preceding flow, then open a fresh real scan wait. - self.app.apply_gesture(Gesture::Back); - assert!(self.app.open_remote_dfu_check()); - self.pending_dfu_scan = Some(Err(DfuScanError::Damaged)); - } - Action::StartDfuInstall => { - assert!(self.app.open_remote_dfu_check()); - self.pending_dfu_scan = Some(Ok(DfuScanReport::new("v1", "v2", false))); - } - Action::AdmitDfuInstall => { - self.app.apply_gesture(Gesture::Press); - self.pending_dfu_install = Some(Ok(())); - } - Action::RefuseDfuInstall => { - self.app.apply_gesture(Gesture::Press); - self.pending_dfu_install = Some(Err(DfuInstallError::Recording)); - } - Action::ConfirmUpdate => self.event(HostEvent::UpdateConfirmed(clamp("v2")), trace), - Action::FailUpdate => { - self.event(HostEvent::UpdateFailed { why: DfuFailure::Reverted, staged: Some(clamp("v3")) }, trace) - } - Action::ForgetBond => self.app.state.ble_forget_pending = true, - Action::ScanCardSpace => { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(-1)); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(-1)); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(3)); - self.app.apply_gesture(Gesture::Press); - } - Action::NeedRideTrack => { - if !matches!(self.app.top_screen(), Screen::RideDetail(_)) { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - } - } - Action::RemapRideIdentity => { - self.rides.swap(0, 1); - self.ride_ids.swap(0, 1); - self.feed_rides("ride.remap", trace); - } - Action::ReplaceRideTrackNeed => { - self.app.apply_gesture(Gesture::Back); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Press); - } - Action::FillRideTrack => {} - Action::NeedNavPreview => { - if !self.app.nav_preview_missing() { - assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "Preview")); - self.feed_routes("nav.preview-route", trace); - self.event(HostEvent::NavPlanned(Ok(10)), trace); - self.nav_generation = self.nav_generation.wrapping_add(1); - } - } - Action::ReplaceNavPreviewNeed => { - self.app.apply_gesture(Gesture::Back); - assert!(self.app.debug_start_nav((0, 0), (2_000, 2_000), "New preview")); - self.feed_routes("nav.preview-replacement", trace); - self.event(HostEvent::NavPlanned(Ok(10)), trace); - self.nav_generation = self.nav_generation.wrapping_add(1); - } - Action::FillNavPreview => {} - Action::RefreshWeather => { - self.app.set_rain_view(3, 0.5); - trace.record_feeder(FeederCall::new(FeederKind::RainView, "weather.refresh", 3)); - } - Action::InstallWeatherData => { - self.app.weather_feed_changed(); - trace.record_feeder(FeederCall::new(FeederKind::WeatherSnapshot, "weather.installed", 1)); - trace.record_feeder(FeederCall::new(FeederKind::WeatherFeedChanged, "weather.installed", 1)); - } - Action::MarkWeatherStale => { - self.app.set_rain_view(0, 0.0); - trace.record_feeder(FeederCall::new(FeederKind::RainView, "weather.stale", 0)); - } - Action::DeliverWeatherAlert => { - assert!(self.app.show_weather_alert(obc_app::WeatherAlertKind::Storm, 12)); - } - } - } - - fn run_pass(&mut self, trace: &mut TraceRecorder) -> Vec { - let ride_track_need = self.app.ride_track_request(); - let mut route_repo = BorrowedRoutes { - catalog: &mut self.routes, - ids: &mut self.route_ids, - fail_delete_once: &mut self.route_delete_fail_once, - delete_attempts: &mut self.retention_delete_attempts, - }; - let mut ride_repo = BorrowedRides { catalog: &mut self.rides, ids: &mut self.ride_ids }; - let mut trip_repo = BorrowedTrips { present: &mut self.trip_present, stage_ids: &self.trip_stage_ids }; - let mut platform = Vec::new(); - let finish = self.host.reconcile_commands_traced( - &mut self.app, - &mut route_repo, - &mut ride_repo, - &mut trip_repo, - PlanHold::new(true, true), - true, - true, - |_app, command| platform.push(command), - trace, - ); - let mut outcomes = Vec::new(); - if let Some(result) = self.pending_nav_plan.take() { - outcomes.push(Outcome::Event(HostEvent::NavPlanned(result))); - } - if matches!(finish, Some(TrackAction::Save)) { - if std::mem::take(&mut self.fail_next_finalize) { - outcomes.push(Outcome::FinalizeFailed); - } else { - outcomes.push(Outcome::FinishSave); - } - } - if platform.iter().any(|command| matches!(command, HostCommand::ScanCardFree)) { - outcomes.push(Outcome::CardScanned); - } - let persisted_revision = platform.iter().find_map(|command| match command { - HostCommand::PersistSettings { revision } => Some(*revision), - _ => None, - }); - let ready_settings_result = !matches!(self.pending_settings_result, Some(PendingSettingsResult::PersistLatest)) - || persisted_revision.is_some(); - if ready_settings_result { - if let Some(result) = self.pending_settings_result.take() { - let revision = match result { - PendingSettingsResult::PersistRevision(revision) => revision, - PendingSettingsResult::PersistLatest | PendingSettingsResult::FailLatest => { - persisted_revision.unwrap_or(self.settings_revision) - } - }; - outcomes.push(Outcome::Event(match result { - PendingSettingsResult::FailLatest => { - HostEvent::SettingsPersistFailed { revision, error: SettingsSaveError::Backend } - } - PendingSettingsResult::PersistLatest | PendingSettingsResult::PersistRevision(_) => { - HostEvent::SettingsPersisted { revision } - } - })); - } - } - for command in platform { - match command { - HostCommand::Dfu(obc_app::DfuAction::Scan) => { - if let Some(result) = self.pending_dfu_scan.take() { - outcomes.push(Outcome::Event(HostEvent::DfuScanned(result))); - } - } - HostCommand::Dfu(obc_app::DfuAction::Install) => { - if let Some(result) = self.pending_dfu_install.take() { - outcomes.push(Outcome::Event(match result { - Ok(()) => HostEvent::DfuInstallBegan, - Err(error) => HostEvent::DfuInstallFailed(error), - })); - } - } - _ => {} - } - } - if let Some(id) = ride_track_need { - outcomes.push(Outcome::RideTrack { id }); - } - if self.app.nav_preview_missing() { - outcomes.push(Outcome::NavPreview { generation: self.nav_generation }); - } - if std::mem::take(&mut self.commit_success_pending) { - outcomes.push(Outcome::DetourCommitted); - } - outcomes - } - fn deliver(&mut self, outcome: Self::Outcome, trace: &mut TraceRecorder) { - match outcome { - Outcome::Event(event) => { - let settings_failed = matches!(event, HostEvent::SettingsPersistFailed { .. }); - self.event(event, trace); - if settings_failed && self.settings_retry_requested { - self.app.advance_animations(InputClock(6_003)); - } - } - Outcome::FinishSave => self.feed_rides("recorder.saved", trace), - Outcome::FinalizeFailed => self.event(HostEvent::Warning(WarningFlags::REC_ERROR), trace), - Outcome::CardScanned => self.event(HostEvent::CardScanned { free_bytes: Some(8 * 1024 * 1024) }, trace), - Outcome::RideTrack { id } => { - let current = self.app.ride_track_request(); - let data = ride_delivery_key(id, current); - // Legacy bulk feeders are not request-keyed. Record and apply even stale attempts; - // accepting this fill for a replacement view is a characterized compatibility defect. - self.app.set_ride_profile(None); - self.app.set_ride_preview(&[(0, 0), (1, 1)]); - trace.record_feeder(FeederCall::new(FeederKind::RideProfile, data, 0)); - trace.record_feeder(FeederCall::new(FeederKind::RidePreview, data, 2)); - } - Outcome::NavPreview { generation } => { - let data = nav_delivery_key(generation, self.nav_generation); - // The legacy preview feeder has no generation argument, so delayed old data is - // attempted against the current request and may satisfy it incorrectly. - self.app.set_nav_preview(&[(0, 0), (1, 1)]); - trace.record_feeder(FeederCall::new(FeederKind::NavPreview, data, 2)); - } - Outcome::DetourCommitted => { - self.feed_routes("detour.commit", trace); - self.event(HostEvent::DetourCommitted(Ok(10)), trace); - } - } - } -} - -struct BorrowedRoutes<'a> { - catalog: &'a mut Vec, - ids: &'a mut Vec, - fail_delete_once: &'a mut bool, - delete_attempts: &'a mut u16, -} - -impl RouteRepository for BorrowedRoutes<'_> { - fn catalog(&self) -> &[RouteSummary] { - self.catalog - } - - fn ids(&self) -> &[u64] { - self.ids - } - - fn delete_by_id(&mut self, id: u64) -> bool { - *self.delete_attempts = self.delete_attempts.saturating_add(1); - if std::mem::take(self.fail_delete_once) { - return false; - } - let Some(index) = self.ids.iter().position(|candidate| *candidate == id) else { return false }; - self.ids.remove(index); - self.catalog.remove(index); - true - } - - fn write_nav_route(&mut self, _bytes: &[u8]) -> Option { - None - } - - fn sync_active(&mut self, _want: Option) -> bool { - false - } - - fn active_source(&self) -> Option> { - None - } - - fn invalidate_active(&mut self) {} -} - -struct BorrowedRides<'a> { - catalog: &'a mut Vec, - ids: &'a mut Vec, -} - -impl RideRepository for BorrowedRides<'_> { - fn catalog(&self) -> &[RideSummary] { - self.catalog - } - - fn ids(&self) -> &[u64] { - self.ids - } - - fn delete_by_id(&mut self, id: u64) -> bool { - let Some(index) = self.ids.iter().position(|candidate| *candidate == id) else { return false }; - self.ids.remove(index); - self.catalog.remove(index); - true - } - - fn profile_by_id(&self, _id: u64) -> Option { - None - } - - fn preview_by_id(&self, _id: u64) -> Vec<(i32, i32)> { - vec![(0, 0), (1, 1)] - } -} - -struct BorrowedTrips<'a> { - present: &'a mut bool, - stage_ids: &'a [u64], -} - -impl TripCatalog for BorrowedTrips<'_> { - fn member_route_ids(&self, id: u64) -> Vec { - if *self.present && id == 50 { - self.stage_ids.to_vec() - } else { - Vec::new() - } - } - - fn delete_by_id(&mut self, id: u64) -> bool { - if *self.present && id == 50 { - *self.present = false; - true - } else { - false - } - } - - fn refeed(&self, app: &mut App) { - if *self.present { - app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: self.stage_ids }]); - } else { - app.set_trips(&[]); - } - } -} - -fn ride_delivery_key(requested: u64, current: Option) -> &'static str { - match (requested, current) { - (70, Some(70)) => "ride.track.requested-morning.current-morning", - (70, Some(90)) => "ride.track.requested-morning.current-evening", - (90, Some(70)) => "ride.track.requested-evening.current-morning", - (90, Some(90)) => "ride.track.requested-evening.current-evening", - (70, None) => "ride.track.requested-morning.current-none", - (90, None) => "ride.track.requested-evening.current-none", - (_, Some(70)) => "ride.track.requested-other.current-morning", - (_, Some(90)) => "ride.track.requested-other.current-evening", - (_, Some(_)) => "ride.track.requested-other.current-other", - (_, None) => "ride.track.requested-other.current-none", - } -} - -fn nav_delivery_key(requested: u16, current: u16) -> &'static str { - match (requested, current) { - (0, 0) => "nav.preview.requested-g0.current-g0", - (0, 1) => "nav.preview.requested-g0.current-g1", - (0, 2) => "nav.preview.requested-g0.current-g2", - (1, 1) => "nav.preview.requested-g1.current-g1", - (1, 2) => "nav.preview.requested-g1.current-g2", - (2, 2) => "nav.preview.requested-g2.current-g2", - _ if requested == current => "nav.preview.requested-other.current-matching", - _ => "nav.preview.requested-other.current-replacement", - } -} - -fn fixture_object_key(kind: ObjectKind, id: u64) -> ObjectKey { - match (kind, id) { - (ObjectKind::Route, 10) => ObjectKey(0), - (ObjectKind::Route, 20) => ObjectKey(1), - (ObjectKind::Route, 30) => ObjectKey(2), - (ObjectKind::Ride, 70) => ObjectKey(3), - (ObjectKind::Ride, 90) => ObjectKey(4), - (ObjectKind::Trip, 50) => ObjectKey(5), - _ => panic!("unseeded fixture identity {kind:?}:{id}"), - } -} - -fn route(name: &str) -> RouteSummary { - let mut summary = RouteSummary { - name: Default::default(), - distance_km: 10, - climb_m: 100, - bbox: BBox { min_lon: 0, min_lat: 0, max_lon: 1_000, max_lat: 1_000 }, - start_lon: 0, - start_lat: 0, - }; - summary.name.push_str(name).unwrap(); - summary -} - -fn ride(name: &str) -> RideSummary { - let mut summary = RideSummary { - name: Default::default(), - start_time: 1_720_000_000, - distance_m: 1_000, - moving_time_s: 600, - climb_m: 10, - synced: false, - synced_at_utc: 0, - }; - summary.name.push_str(name).unwrap(); - summary -} - -#[derive(Default)] -struct TestSink(Vec); - -impl ByteSink for TestSink { - fn write(&mut self, bytes: &[u8]) -> Result<(), obc_formats::io::Error> { - self.0.extend_from_slice(bytes); - Ok(()) - } - - fn patch_at(&mut self, offset: u32, bytes: &[u8]) -> Result<(), obc_formats::io::Error> { - let start = offset as usize; - self.0[start..start + bytes.len()].copy_from_slice(bytes); - Ok(()) - } -} - -fn road_obcr() -> Vec { - let mut gpx = String::from(""); - for index in 0..=10 { - let lon = 7.50 + 0.04 * index as f64 / 10.0; - gpx.push_str(&format!("100")); - } - gpx.push_str(""); - let mut sink = TestSink::default(); - gpx_to_obcr(&SliceSource(gpx.as_bytes()), "Trace road", &mut sink).unwrap(); - sink.0 -} - -fn road_fix(fraction: f64) -> Fix { - Fix::at(43_500_000, ((7.50 + 0.04 * fraction) * 1e6) as i32) -} - -struct Scenario { - name: &'static str, - requirements: &'static [Requirement], - actions: &'static [Action], -} - -const SCENARIOS: &[Scenario] = &[ - Scenario { - name: "catalog.refresh-upload-remap", - requirements: &[ - Requirement::CatalogStoreChange, - Requirement::CatalogRefresh, - Requirement::CatalogUploadOrder, - Requirement::CatalogIdentityRemap, - ], - actions: &[ - Action::StoreChanged, - Action::RefreshCatalogs, - Action::UploadRoutesThenTrip, - Action::RemapCatalogIdentity, - ], - }, - Scenario { - name: "catalog.route-delete", - requirements: &[Requirement::CatalogRouteDelete], - actions: &[Action::DeleteRoute], - }, - Scenario { - name: "catalog.ride-delete", - requirements: &[Requirement::CatalogRideDelete], - actions: &[Action::DeleteRide], - }, - Scenario { - name: "catalog.trip-cascade", - requirements: &[Requirement::CatalogTripCascade], - actions: &[Action::CascadeDeleteTrip], - }, - Scenario { - name: "navigation.plan-cancel-late-replacement", - requirements: &[ - Requirement::NavigationPlan, - Requirement::NavigationCancel, - Requirement::NavigationLateResult, - Requirement::NavigationReplacement, - ], - actions: &[ - Action::StartRoutePlan, - Action::CancelRoutePlan, - Action::ReplaceRoutePlan, - Action::DeliverLateRouteResult, - ], - }, - Scenario { - name: "navigation.detour-lifecycle", - requirements: &[ - Requirement::NavigationDetourPlan, - Requirement::NavigationDetourCancel, - Requirement::NavigationDetourCommit, - ], - actions: &[Action::PlanDetour, Action::CancelDetour, Action::PlanDetour, Action::CommitDetour], - }, - Scenario { - name: "navigation.no-path", - requirements: &[Requirement::NavigationNoPath], - actions: &[Action::RouteNoPath], - }, - Scenario { - name: "recorder.start-save-discard", - requirements: &[Requirement::RecorderStart, Requirement::RecorderSave, Requirement::RecorderDiscard], - actions: &[Action::StartRecorder, Action::SaveRecorder, Action::StartRecorder, Action::DiscardRecorder], - }, - Scenario { - name: "recorder.failure-and-session-replacement", - requirements: &[Requirement::RecorderFinalizeFailure, Requirement::RecorderSessionReplacement], - actions: &[Action::StartRecorder, Action::FailRecorderFinalize, Action::ReplaceRecorderSession], - }, - Scenario { - name: "settings.revision-success-and-stale-result", - requirements: &[ - Requirement::SettingsDirtyRevision, - Requirement::SettingsSuccess, - Requirement::SettingsStaleResult, - ], - actions: &[ - Action::DirtySettings, - Action::PersistSettings, - Action::DeliverStaleSettingsResult, - Action::DeliverMatchingSettingsResult, - ], - }, - Scenario { - name: "settings.failure-and-retry", - requirements: &[Requirement::SettingsFailure, Requirement::SettingsRetry], - actions: &[Action::DirtySettings, Action::FailSettingsPersist, Action::RetrySettingsPersist], - }, - Scenario { - name: "retention.route-and-ride-stamps", - requirements: &[Requirement::RetentionRouteUseStamp, Requirement::RetentionRideSyncStamp], - actions: &[Action::StampRouteUse, Action::StampRideSync], - }, - Scenario { - name: "retention.expiry-retry-and-trusted-clock", - requirements: &[ - Requirement::RetentionExpiryDelete, - Requirement::RetentionRetry, - Requirement::RetentionTrustedClockGate, - ], - actions: &[Action::GateExpiryUntilClockTrusted, Action::DeleteExpiredObject, Action::RetryExpiredDelete], - }, - Scenario { - name: "dfu.scan-outcomes", - requirements: &[Requirement::DfuScanSuccess, Requirement::DfuScanFailure], - actions: &[Action::ScanDfuSuccess, Action::Settle, Action::Settle, Action::ScanDfuFailure], - }, - Scenario { - name: "dfu.install-start", - requirements: &[Requirement::DfuInstallStart], - actions: &[Action::StartDfuInstall, Action::Settle, Action::Settle, Action::AdmitDfuInstall], - }, - Scenario { - name: "dfu.install-refusal", - requirements: &[Requirement::DfuInstallRefusal], - actions: &[Action::StartDfuInstall, Action::Settle, Action::Settle, Action::RefuseDfuInstall], - }, - Scenario { - name: "dfu.boot-outcomes", - requirements: &[Requirement::DfuConfirmedUpdate, Requirement::DfuFailedUpdate], - actions: &[Action::ConfirmUpdate, Action::FailUpdate], - }, - Scenario { - name: "platform.bond-and-card-space", - requirements: &[Requirement::PlatformForgetBond, Requirement::PlatformCardSpaceScan], - actions: &[Action::ForgetBond, Action::ScanCardSpace], - }, - Scenario { - name: "derived-data.repeats-until-matching-fill", - requirements: &[ - Requirement::DerivedRideTrackRepeatedUntilFill, - Requirement::DerivedNavPreviewRepeatedUntilFill, - ], - actions: &[ - Action::NeedRideTrack, - Action::RemapRideIdentity, - Action::ReplaceRideTrackNeed, - Action::FillRideTrack, - Action::NeedNavPreview, - Action::ReplaceNavPreviewNeed, - Action::NeedNavPreview, - Action::FillNavPreview, - ], - }, - Scenario { - name: "weather.refresh-install-stale-alert", - requirements: &[ - Requirement::WeatherRefreshState, - Requirement::WeatherInstalledDataChange, - Requirement::WeatherStaleData, - Requirement::WeatherAlertDelivery, - ], - actions: &[ - Action::RefreshWeather, - Action::InstallWeatherData, - Action::MarkWeatherStale, - Action::DeliverWeatherAlert, - ], - }, -]; +use device_core_corpus::{ + command_count, command_precedes_event, definition, event_count, feeder_count, output_precedes, run_legacy, + run_matrix, step, LegacyHarness, Requirement, ScreenState, VisibleState, ALL_REQUIREMENTS, SCENARIOS, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct BoardDifference { @@ -1247,146 +76,6 @@ fn scenario_matrix_covers_every_dc1_behavior_row_once_or_more() { assert!(COMPATIBILITY_LIMITATIONS.iter().all(|difference| !difference.target_owner.is_empty())); } -fn action_name(action: Action) -> &'static str { - match action { - Action::Settle => "settle", - Action::StoreChanged => "store-changed", - Action::RefreshCatalogs => "refresh-catalogs", - Action::UploadRoutesThenTrip => "upload-routes-then-trip", - Action::RemapCatalogIdentity => "remap-catalog-identity", - Action::DeleteRoute => "delete-route", - Action::DeleteRide => "delete-ride", - Action::CascadeDeleteTrip => "cascade-delete-trip", - Action::StartRoutePlan => "start-route-plan", - Action::CancelRoutePlan => "cancel-route-plan", - Action::DeliverLateRouteResult => "deliver-old-route-result", - Action::ReplaceRoutePlan => "start-replacement-route-plan", - Action::PlanDetour => "plan-detour", - Action::CancelDetour => "cancel-detour", - Action::CommitDetour => "commit-detour", - Action::RouteNoPath => "route-no-path", - Action::StartRecorder => "start-recorder", - Action::SaveRecorder => "save-recorder", - Action::DiscardRecorder => "discard-recorder", - Action::FailRecorderFinalize => "fail-recorder-finalize", - Action::ReplaceRecorderSession => "replace-recorder-session", - Action::DirtySettings => "dirty-settings", - Action::PersistSettings => "persist-settings-pass", - Action::DeliverStaleSettingsResult => "deliver-stale-settings-result", - Action::DeliverMatchingSettingsResult => "deliver-matching-settings-result", - Action::FailSettingsPersist => "fail-settings-persist", - Action::RetrySettingsPersist => "retry-settings-persist", - Action::StampRouteUse => "stamp-route-use", - Action::StampRideSync => "stamp-ride-sync", - Action::DeleteExpiredObject => "delete-expired-object", - Action::RetryExpiredDelete => "retry-expired-delete", - Action::GateExpiryUntilClockTrusted => "gate-expiry-until-clock-trusted", - Action::ScanDfuSuccess => "scan-dfu-success", - Action::ScanDfuFailure => "scan-dfu-failure", - Action::StartDfuInstall => "start-dfu-install", - Action::AdmitDfuInstall => "admit-dfu-install", - Action::RefuseDfuInstall => "refuse-dfu-install", - Action::ConfirmUpdate => "confirm-update", - Action::FailUpdate => "fail-update", - Action::ForgetBond => "forget-bond", - Action::ScanCardSpace => "scan-card-space", - Action::NeedRideTrack => "need-ride-track", - Action::RemapRideIdentity => "remap-ride-identity", - Action::ReplaceRideTrackNeed => "replace-ride-track-need", - Action::FillRideTrack => "fill-ride-track", - Action::NeedNavPreview => "need-nav-preview", - Action::ReplaceNavPreviewNeed => "replace-nav-preview-need", - Action::FillNavPreview => "fill-nav-preview", - Action::RefreshWeather => "refresh-weather", - Action::InstallWeatherData => "install-weather-data", - Action::MarkWeatherStale => "mark-weather-stale", - Action::DeliverWeatherAlert => "deliver-weather-alert", - } -} - -fn definition(scenario: &Scenario) -> TraceScenario { - TraceScenario { - name: scenario.name, - steps: scenario - .actions - .iter() - .copied() - .map(|action| ScenarioStep::new(TraceInput::Named(action_name(action)), action)) - .collect(), - } -} - -fn normalization_seed() -> NormalizationSeed { - NormalizationSeed { - objects: vec![ - (ObjectKind::Route, 10, ObjectKey(0)), - (ObjectKind::Route, 20, ObjectKey(1)), - (ObjectKind::Route, 30, ObjectKey(2)), - (ObjectKind::Ride, 70, ObjectKey(3)), - (ObjectKind::Ride, 90, ObjectKey(4)), - (ObjectKind::Trip, 50, ObjectKey(5)), - ], - revisions: vec![(1, RevisionKey(0)), (2, RevisionKey(1))], - times: vec![(1_720_000_000, TimeKey(0)), (1_720_000_060, TimeKey(1)), (1_720_000_120, TimeKey(2))], - } -} - -fn run_legacy(scenario: &TraceScenario, mode: RunnerMode, harness: &mut LegacyHarness) -> Trace { - run_scenario_seeded(scenario, mode, &normalization_seed(), harness) - .unwrap_or_else(|error| panic!("{} failed in {mode:?}: {error:?}", scenario.name)) -} - -fn run_matrix(mode: RunnerMode) -> Vec> { - SCENARIOS - .iter() - .map(|scenario| { - let mut harness = LegacyHarness::new(); - run_legacy(&definition(scenario), mode, &mut harness) - }) - .collect() -} - -fn step<'a>(trace: &'a Trace, name: &'static str) -> &'a obc_host_core::trace::TraceStep { - trace - .steps - .iter() - .find(|step| step.input == TraceInput::Named(name)) - .unwrap_or_else(|| panic!("{} has no {name} step", trace.scenario)) -} - -fn command_count(trace: &Trace, tag: CommandTag) -> usize { - trace.steps.iter().flat_map(|step| &step.commands).filter(|command| command.tag() == tag).count() -} - -fn event_count(trace: &Trace, tag: EventTag) -> usize { - trace.steps.iter().flat_map(|step| &step.events).filter(|event| event.tag() == tag).count() -} - -fn feeder_count(trace: &Trace, kind: FeederKind) -> usize { - trace.steps.iter().flat_map(|step| &step.feeder_calls).filter(|call| call.feeder == kind).count() -} - -fn output_position(trace: &Trace, predicate: impl Fn(&TraceOutput) -> bool) -> Option<(usize, usize)> { - trace.steps.iter().enumerate().find_map(|(step_index, step)| { - step.timeline.iter().position(&predicate).map(|output_index| (step_index, output_index)) - }) -} - -fn command_precedes_event(trace: &Trace, command: CommandTag, event: EventTag) -> bool { - let command = - output_position(trace, |output| matches!(output, TraceOutput::Command(value) if value.tag() == command)); - let event = output_position(trace, |output| matches!(output, TraceOutput::Event(value) if value.tag() == event)); - matches!((command, event), (Some(command), Some(event)) if command < event) -} - -fn output_precedes( - trace: &Trace, - before: impl Fn(&TraceOutput) -> bool, - after: impl Fn(&TraceOutput) -> bool, -) -> bool { - matches!((output_position(trace, before), output_position(trace, after)), (Some(before), Some(after)) if before < after) -} - fn assert_requirement(requirement: Requirement, trace: &Trace) { let command = |tag| command_count(trace, tag) > 0; let event = |tag| event_count(trace, tag) > 0; From 51f5f0e14c690c34b9d509746b4fb1fd3408f19a Mon Sep 17 00:00:00 2001 From: timohueser Date: Mon, 24 Aug 2026 06:40:56 +0200 Subject: [PATCH 2/4] fix(app): stop the pass destroying a ride close and re-stamping forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DC7's review round. Two production defects the gate found, and the gate's own rigor tightened to the level its result is reported at. **The rider's ride close was destroyed.** Stage 4 took the finish one-shot and stage 7 dropped it, because Recorder has no machine to act on it — so the ride was never finalized and no executor was told, while `pass.rs` claimed the close still reached the platform on the legacy path. The `UiRuntime` -> `Recorder` connection is deleted rather than parked: it provisioned for a lifecycle nobody owns and its only effect was destroying a rider request. `Merge` goes with it, the last `KeepFirst` connection. **A decided sidecar stamp was rediscovered forever.** The retention sweep re-derives candidates from the resident view, and the pass never mirrored the stamp it had just issued, so the same write went out again on the pass after the executor answered it — one per pass for the rest of the boot. The legacy drain has always mirrored; the pass does now too. The gate itself: - Dispositions are per `(scenario, runner)` cell, compared exactly, so a difference that moves between runners can no longer pass silently. - Each disposition carries a typed `owner`, cross-checked against the rows the compatibility executor actually reports leaving. - Mandatory traces #3 and #8 declare the situation they substitute and why it is unreachable in Phase 1; #4 and #7 now run the real thing. - The settle probe asserts each runner is at rest before it is compared — which is what found the stamp loop. - The replay's wake profile is a committed ratchet instead of a deleted harness. `size_of::()` 50,920 -> 50,904 B; baseline updated. Co-Authored-By: Claude Fable 5 --- .../obc-app/src/device_core/connections.rs | 146 ++--- firmware/obc-app/src/device_core/pass.rs | 130 ++-- firmware/tools/resource_baseline.json | 6 +- .../tests/device_core_conformance.rs | 569 ++++++++++++++---- .../tests/device_core_corpus/mod.rs | 14 +- 5 files changed, 591 insertions(+), 274 deletions(-) diff --git a/firmware/obc-app/src/device_core/connections.rs b/firmware/obc-app/src/device_core/connections.rs index d3c605fdd..65f7d1bb4 100644 --- a/firmware/obc-app/src/device_core/connections.rs +++ b/firmware/obc-app/src/device_core/connections.rs @@ -8,14 +8,17 @@ //! | Producer | Consumer | Type | Delivery | //! |---|---|---|---| //! | `UiRuntime` | `CatalogMachine` | [`CatalogIntent`] | same pass | -//! | `UiRuntime` | `Recorder` | [`RecorderIntent`] | same pass | //! | `RetentionMachine` | `CatalogMachine` | [`CatalogIntent`] (an expiry) | same pass | //! | `CatalogMachine` | `Navigator` | [`ActiveRouteRemoved`] | same pass | //! | `CatalogMachine` | `RetentionMachine` | [`CatalogIdentityChanged`] | next pass | -//! | `Recorder` | `RetentionMachine` | [`RideClosed`] | next pass | //! | `Navigator` | `RetentionMachine` | [`RouteActivated`] | next pass | //! | any domain | `FaultState` | [`FaultNotices`] | same pass, producer is earlier | //! +//! There is deliberately **no** `UiRuntime` → `Recorder` row and no ride-closed row. Recorder has no +//! machine in Phase 1, so a connection into it could only take the rider's finish one-shot away from +//! the legacy drain that still performs it — provisioning for a lifecycle nobody owns, at the cost of +//! destroying a rider request. #1397 S6 brings the connection back with the domain that needs it. +//! //! ## Which direction decides the timing //! //! The pass order (see [`pass`](super::pass)) is fixed, so a connection's timing is not a policy @@ -32,12 +35,14 @@ //! Every slot holds **one** value. What a *second* value of the same kind means is different per //! connection, so each one states its rule rather than sharing a default: //! -//! - **Intents and one-shots** ([`Slot`], [`Merge::KeepFirst`]): the first value stands and the -//! second is handed back. The producer keeps it and offers it again next pass — backpressure, -//! never a silent drop. This is why a producer checks [`Slot::is_empty`] *before* consuming its -//! own one-shot: an intent it cannot deliver must stay where the rider left it. -//! - **Identity changes** ([`Merge::KeepLatest`]): a level, not an event. The newest revision -//! replaces the older one, because acting on a superseded identity is worse than acting late. +//! - **Intents and one-shots** ([`Slot`]): the first value stands and the second is handed back. The +//! producer keeps it and offers it again next pass — backpressure, never a silent drop. This is +//! why a producer checks [`Slot::is_empty`] *before* consuming its own one-shot: an intent it +//! cannot deliver must stay where the rider left it. +//! - **Later-to-earlier deposits** ([`Deferred`]): the newest value replaces the older one. Both of +//! them are levels — which identity the catalog holds, which route is active — and acting on a +//! superseded level is worse than acting late. A deposit that must *queue* rather than replace +//! arrives with the first connection that needs one. //! - **Fault notices** ([`FaultNotices`]): accumulate. Two domains raising a warning in one pass //! both reach the rider; a bit set is the only shape that cannot lose one. //! @@ -49,11 +54,10 @@ #![allow(dead_code)] use crate::catalog_state::CatalogIntent; -use crate::recorder::RecorderIntent; use crate::screen::WarningFlags; use crate::CatalogObjectId; -use super::slots::{Slot, SlotFull}; +use super::slots::Slot; use super::Revision; // ==================== the connection payloads ==================== @@ -75,28 +79,15 @@ pub struct ActiveRouteRemoved { /// changed, so it re-discovers rather than draining candidates against a picture that is gone. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CatalogIdentityChanged { - /// The store revision the catalog now reflects. [`Merge::KeepLatest`]: an older revision never - /// displaces a newer one. + /// The store revision the catalog now reflects. A newer revision replaces an older deposit — + /// an older one never displaces it. pub revision: Revision, } -/// `Recorder` → `RetentionMachine`, **next pass**: the open ride was closed. -/// -/// The epic's table calls this row "ride finalized or synced". The *synced* half reaches retention -/// today as a fact about the ride inventory (it stamps `synced_at` eagerly from its own view), so -/// what the recorder itself has to say is the half only it knows: the ride is over, and the -/// inventory is about to change. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RideClosed { - /// Whether the ride was thrown away rather than kept. - pub discarded: bool, -} - /// `Navigator` → `RetentionMachine`, **next pass**: a route became the active one. /// /// An active route must not expire underneath the ride it is guiding, so retention stamps it once -/// per activation. [`Merge::KeepLatest`]: two activations in one pass leave the route that is -/// actually active. +/// per activation. Two activations before the next pass leave the route that is actually active. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RouteActivated { /// The durable identity of the newly active route. @@ -105,16 +96,6 @@ pub struct RouteActivated { // ==================== the deferred slot ==================== -/// What a second value in an occupied slot means. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Merge { - /// The first value stands; the second is handed back to its producer, which keeps it pending. - KeepFirst, - /// The newest value replaces the held one — for a *level*, where the older value is simply - /// wrong once a newer one exists. - KeepLatest, -} - /// A **later-to-earlier** connection: one value, deposited in one pass and consumed in the next. /// /// Two halves, because "deposited" and "visible" are different states. [`defer`](Self::defer) fills @@ -124,34 +105,23 @@ pub enum Merge { /// backwards edge the pass order forbids is impossible rather than merely discouraged. #[derive(Debug, PartialEq, Eq)] pub struct Deferred { - merge: Merge, pending: Option, ready: Option, } impl Deferred { - /// An empty slot with the given merge rule. - pub const fn new(merge: Merge) -> Self { - Deferred { merge, pending: None, ready: None } + /// An empty slot. + pub const fn new() -> Self { + Deferred { pending: None, ready: None } } - /// Deposit a value for the next pass. + /// Deposit a value for the next pass, replacing any deposit not yet promoted. /// - /// Under [`Merge::KeepFirst`] a full slot refuses and hands `value` back — its producer keeps it - /// and offers it again once the slot drains. Under [`Merge::KeepLatest`] it replaces, so this - /// never fails. - pub fn defer(&mut self, value: T) -> Result<(), SlotFull> { - match self.merge { - Merge::KeepLatest => { - self.pending = Some(value); - Ok(()) - } - Merge::KeepFirst if self.pending.is_some() => Err(SlotFull { rejected: value }), - Merge::KeepFirst => { - self.pending = Some(value); - Ok(()) - } - } + /// Both connections that use this carry a *level* — which identity the catalog holds, which + /// route is active — so a newer deposit makes an older one wrong rather than second in line. + /// A connection whose deposits must queue instead arrives with the domain that needs one. + pub fn defer(&mut self, value: T) { + self.pending = Some(value); } /// Make a value deposited on an earlier pass visible to its consumer. Called once per pass, @@ -217,8 +187,6 @@ impl FaultNotices { pub struct Connections { /// `UiRuntime` → `CatalogMachine`: the rider's delete, resolved to a durable identity. pub ui_catalog: Slot, - /// `UiRuntime` → `Recorder`: save or discard the open ride. - pub ui_recorder: Slot, /// `RetentionMachine` → `CatalogMachine`: an expiry, as the same intent a rider's delete uses, /// so an auto-expired object leaves by exactly the path a deleted one does. pub expiry: Slot, @@ -228,8 +196,6 @@ pub struct Connections { pub faults: FaultNotices, /// `CatalogMachine` → `RetentionMachine`, next pass. pub catalog_identity: Deferred, - /// `Recorder` → `RetentionMachine`, next pass. - pub ride_closed: Deferred, /// `Navigator` → `RetentionMachine`, next pass. pub route_activated: Deferred, } @@ -239,16 +205,11 @@ impl Connections { pub const fn new() -> Self { Connections { ui_catalog: Slot::new(), - ui_recorder: Slot::new(), expiry: Slot::new(), active_route_removed: Slot::new(), faults: FaultNotices::NONE, - // An identity is a level: the newest one is the only one worth acting on. - catalog_identity: Deferred::new(Merge::KeepLatest), - // A closed ride is an event: the second one waits rather than erasing the first. - ride_closed: Deferred::new(Merge::KeepFirst), - // An activation is a level: what matters is which route is active now. - route_activated: Deferred::new(Merge::KeepLatest), + catalog_identity: Deferred::new(), + route_activated: Deferred::new(), } } @@ -256,14 +217,13 @@ impl Connections { /// before any new gesture, sensor reading or fact is applied. pub fn promote_deferred(&mut self) { self.catalog_identity.promote(); - self.ride_closed.promote(); self.route_activated.promote(); } /// Whether any deferred connection still holds a value — the pass's "run again before sleep" /// test. pub fn has_deferred(&self) -> bool { - self.catalog_identity.is_pending() || self.ride_closed.is_pending() || self.route_activated.is_pending() + self.catalog_identity.is_pending() || self.route_activated.is_pending() } } @@ -277,10 +237,9 @@ impl Default for Connections { // identity, a revision or a flag — a growth means bulk crept into a message. const _: () = assert!(core::mem::size_of::() <= 8, "one durable identity"); const _: () = assert!(core::mem::size_of::() <= 8, "one revision"); -const _: () = assert!(core::mem::size_of::() <= 1, "one flag"); const _: () = assert!(core::mem::size_of::() <= 8, "one durable identity"); const _: () = assert!(core::mem::size_of::() <= 4, "a bit set"); -const _: () = assert!(core::mem::size_of::() <= 192, "eight bounded slots"); +const _: () = assert!(core::mem::size_of::() <= 160, "six bounded slots"); #[cfg(test)] mod tests { @@ -295,11 +254,11 @@ mod tests { /// impossible rather than merely discouraged. #[test] fn a_deferred_value_is_invisible_until_the_next_pass_promotes_it() { - let mut slot: Deferred = Deferred::new(Merge::KeepLatest); + let mut slot: Deferred = Deferred::new(); assert!(!slot.is_pending()); // Pass 1, a late stage deposits. - slot.defer(RouteActivated { route: 7 }).unwrap(); + slot.defer(RouteActivated { route: 7 }); assert!(slot.take().is_none(), "an earlier stage of the same pass cannot see it"); assert!(slot.is_pending(), "…and it is what asks for another pass"); @@ -311,34 +270,13 @@ mod tests { assert!(slot.take().is_none(), "promotion invents nothing"); } - /// `KeepFirst`: a full slot refuses and hands the value back, so its producer keeps it. Nothing - /// is overwritten and nothing is lost. - #[test] - fn a_full_keep_first_slot_hands_the_second_value_back() { - let mut slot: Deferred = Deferred::new(Merge::KeepFirst); - let first = RideClosed { discarded: false }; - let second = RideClosed { discarded: true }; - - slot.defer(first).unwrap(); - let err = slot.defer(second).expect_err("a full slot refuses"); - assert_eq!(err.rejected, second, "the producer gets its value back unchanged"); - - slot.promote(); - assert_eq!(slot.take(), Some(first), "the first value is what the consumer sees"); - - // The producer offers the retained value again, and now it fits. - slot.defer(second).unwrap(); - slot.promote(); - assert_eq!(slot.take(), Some(second)); - } - - /// `KeepLatest`: an identity is a level, so a newer revision replaces an older deposit rather - /// than queueing behind it. + /// An identity is a level, so a newer revision replaces an older deposit rather than queueing + /// behind it. #[test] - fn a_keep_latest_slot_holds_the_newest_identity() { - let mut slot: Deferred = Deferred::new(Merge::KeepLatest); - slot.defer(identity(4)).unwrap(); - slot.defer(identity(9)).unwrap(); + fn a_deferred_slot_holds_the_newest_identity() { + let mut slot: Deferred = Deferred::new(); + slot.defer(identity(4)); + slot.defer(identity(9)); slot.promote(); assert_eq!(slot.take(), Some(identity(9)), "acting on a superseded identity is the worse failure"); @@ -349,12 +287,12 @@ mod tests { /// through promotion, so the deposit simply waits its turn. #[test] fn promotion_never_displaces_an_unconsumed_value() { - let mut slot: Deferred = Deferred::new(Merge::KeepLatest); - slot.defer(identity(1)).unwrap(); + let mut slot: Deferred = Deferred::new(); + slot.defer(identity(1)); slot.promote(); // The consumer skipped its stage this pass; a newer deposit arrives. - slot.defer(identity(2)).unwrap(); + slot.defer(identity(2)); slot.promote(); assert_eq!(slot.take(), Some(identity(1)), "the promoted value stands"); slot.promote(); @@ -399,7 +337,7 @@ mod tests { wires.faults.raise(WarningFlags::NO_GPS); assert!(!wires.has_deferred(), "a same-pass slot is drained by the pass that filled it"); - wires.route_activated.defer(RouteActivated { route: 1 }).unwrap(); + wires.route_activated.defer(RouteActivated { route: 1 }); assert!(wires.has_deferred()); wires.promote_deferred(); assert!(wires.has_deferred(), "promoted but unconsumed still counts"); diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 9a02ead9a..4da3fd05b 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -63,10 +63,9 @@ use obc_ports::{InputClock, RideClock, Sensors}; use obc_route::RouteReader; use crate::catalog_state::CatalogIntent; -use crate::device_core::connections::{ActiveRouteRemoved, CatalogIdentityChanged, RideClosed, RouteActivated}; +use crate::device_core::connections::{ActiveRouteRemoved, CatalogIdentityChanged, RouteActivated}; use crate::dirty::Dirty; use crate::input::Gesture; -use crate::recorder::RecorderIntent; use crate::retention::SweepKind; use crate::App; @@ -411,6 +410,10 @@ impl App { /// [`CatalogIntent`], not a store operation. Every intent is offered into a slot that is /// **checked first** — an intent that cannot be delivered leaves the rider's one-shot exactly /// where it was, so nothing is lost by a busy pass. + /// + /// The rider's ride *close* is deliberately not taken here. Recorder has no machine yet, so + /// there is no domain to name it to; taking the one-shot anyway would only remove it from the + /// legacy drain that still performs it (#1440 found exactly that, and it destroyed the save). fn stage_ui(&mut self, now: PassClock) { self.pass.record(PassStage::Ui); self.advance_animations(now.ui); @@ -429,21 +432,11 @@ impl App { let _ = self.pass.connections.ui_catalog.try_put(intent); } } - if self.pass.connections.ui_recorder.is_empty() { - if let Some(action) = self.activity.take_track_action() { - let intent = match action { - crate::TrackAction::Save => RecorderIntent::Save, - crate::TrackAction::Discard => RecorderIntent::Discard, - }; - let _ = self.pass.connections.ui_recorder.try_put(intent); - } - } } /// Stage 5 — advance `RetentionMachine`. /// - /// Its deferred inbox first: what Navigator, Recorder and the catalog decided *after* it ran - /// last pass. Then the domain's own advance, then the one expiry intent and the one sidecar + /// Its deferred inbox first: what Navigator and the catalog decided *after* it ran last pass. Then the domain's own advance, then the one expiry intent and the one sidecar /// write it may have this pass. The expiry goes into a same-pass slot because the catalog runs /// next — an auto-expired object leaves by exactly the path a rider-deleted one does. /// @@ -458,9 +451,6 @@ impl App { if let Some(activated) = self.pass.connections.route_activated.take() { self.with_retention(|retention, view| retention.note_route_activated(activated.route, view)); } - if self.pass.connections.ride_closed.take().is_some() { - self.retention.force_next_sweep(); - } if self.pass.connections.catalog_identity.take().is_some() { self.retention.force_next_sweep(); } @@ -478,6 +468,7 @@ impl App { for kind in [SweepKind::StampRoute, SweepKind::StampRide] { if let Some(effect) = self.with_retention(|retention, view| retention.next_metadata_effect(kind, view)) { + self.mirror_stamp(effect); let _ = effects.retention.try_put(effect); break; } @@ -485,6 +476,32 @@ impl App { } } + /// Mirror a decided sidecar stamp into the resident view, the moment the effect leaves. + /// + /// The sweep re-derives its candidates from that view, so without this the *same* stamp is + /// rediscovered on the pass after the executor answers it — an endless sidecar write, one per + /// pass, for the rest of the boot. DC7 (#1440) measured exactly that: a settled scenario that + /// never came to rest. + /// + /// The value written is the one the effect carries, not a guess: the durable write is still the + /// executor's, and a failure re-queues the stamp through + /// [`apply_outcome`](crate::retention::RetentionMachine::apply_outcome) as before. The legacy + /// drain has always done this at `App::retention_stamp_command`; this is the same mirror on the + /// path that replaces it. + fn mirror_stamp(&mut self, effect: crate::retention::RetentionEffect) { + match effect { + crate::retention::RetentionEffect::WriteRouteMetadata { id, meta, .. } => { + self.catalogs.stamp_route_last_used(id, meta.last_used_utc); + } + crate::retention::RetentionEffect::WriteRideMetadata { id, synced_at, .. } => { + // Both, because a ride outside the newest-32 display catalog must stop re-enqueuing + // its stamp too (finding #876-2). + self.catalogs.stamp_ride_synced_at(id, synced_at); + self.catalogs.stamp_inventory_synced_at(id, synced_at); + } + } + } + /// Stage 6 — advance `CatalogMachine`. /// /// The rider's own request outranks an expiry, exactly as the legacy drain has it: a hold-to- @@ -509,8 +526,7 @@ impl App { if let Some(store) = self.pass.store { if self.pass.announced != Some(store.revision) { self.pass.announced = Some(store.revision); - let _ = - self.pass.connections.catalog_identity.defer(CatalogIdentityChanged { revision: store.revision }); + self.pass.connections.catalog_identity.defer(CatalogIdentityChanged { revision: store.revision }); } } if let Some(effect) = self.catalogs.next_effect() { @@ -532,18 +548,17 @@ impl App { /// Stage 7 — advance `Recorder`. /// - /// The ride session itself accumulates with the fix in stage 3, and the close reaches the - /// platform on the legacy path until Recorder's machine lands (#1397). What the pass owns is the - /// connection: the ride inventory is about to change and retention hears about it next pass. A - /// full deferred slot leaves the intent where it was, and the immediate next wake is what brings - /// it back. + /// Nothing yet: Recorder's machine arrives with #1397 S6, and until it does the ride lifecycle + /// stays entirely on the legacy path — the rider's finish one-shot is left in + /// [`Activity`](crate::Activity) for `drain_host_commands` to turn into a + /// [`FinishTrack`](crate::HostCommand::FinishTrack), exactly as it always was. + /// + /// The stage is a *position*, held so the order is fixed before the machines land. It is + /// deliberately not a connection into a domain that cannot act: DC7 (#1440) measured what one + /// costs — the pass took the rider's Save at stage 4 and had nowhere to put it, so the ride was + /// never finalized and no executor was told. fn stage_recorder(&mut self) { self.pass.record(PassStage::Recorder); - let Some(intent) = self.pass.connections.ui_recorder.take() else { return }; - let closed = RideClosed { discarded: matches!(intent, RecorderIntent::Discard) }; - if self.pass.connections.ride_closed.defer(closed).is_err() { - let _ = self.pass.connections.ui_recorder.try_put(intent); - } } /// Stage 8 — advance `Navigator`. @@ -564,7 +579,7 @@ impl App { if active != self.pass.active_route { self.pass.active_route = active; if let Some(route) = active { - let _ = self.pass.connections.route_activated.defer(RouteActivated { route }); + self.pass.connections.route_activated.defer(RouteActivated { route }); } } } @@ -937,32 +952,55 @@ mod tests { /// A deferred value in flight makes the pass ask for another one **before sleep**: the work is /// already decided, and parking on it would leave it sitting until the next rider input. /// - /// The other half of the rule — a *full* slot handing the value back so its producer keeps it — - /// is the [`Deferred`](super::super::connections::Deferred) contract, exercised in - /// [`connections`](super::super::connections). It cannot arise from this wiring, because every - /// consumer's stage runs on every pass. + /// Written on the activation, which is the deferred producer this wiring actually has: Navigator + /// runs after retention, so an activation cannot reach backwards and waits a pass. #[test] fn a_deferred_value_forces_another_pass_before_sleep() { - let mut app = App::new(AppState::new(0, 0, 1.0)); + let mut app = navigating(); // route 0 is active before the first pass app.activity.mode = Mode::Riding; - quiet(&mut app, 10); - app.activity.request_track(crate::TrackAction::Save); - let plan = quiet(&mut app, 20); - assert!(app.pass.connections.ride_closed.is_pending(), "the close waits for retention"); + let plan = quiet(&mut app, 10); + assert!(app.pass.connections.route_activated.is_pending(), "the activation waits for retention"); assert!(plan.immediate && plan.next_wake_ms == Some(0), "so the runtime comes straight back"); // The next pass consumes it before anything else, and then there is nothing to hurry for. - let plan = quiet(&mut app, 30); - assert!(!app.pass.connections.ride_closed.is_pending()); + let plan = quiet(&mut app, 20); + assert!(!app.pass.connections.route_activated.is_pending()); assert!(!plan.immediate && plan.next_wake_ms != Some(0)); - // A second close right behind the first is delivered just the same — nothing was lost to - // the pass that was already carrying one. - app.activity.request_track(crate::TrackAction::Discard); - let plan = quiet(&mut app, 40); - assert!(app.pass.connections.ride_closed.is_pending() && plan.immediate); - assert!(app.pass.connections.ui_recorder.is_empty(), "the intent reached its domain"); + // A second activation right behind the first is deposited just the same. + app.activate_route(1); + let plan = quiet(&mut app, 30); + assert!(app.pass.connections.route_activated.is_pending() && plan.immediate); + } + + /// The rider's ride close stays on the legacy path, untouched by the pass. + /// + /// DC7 (#1440) found the opposite: stage 4 took the finish one-shot and stage 7 had nowhere to + /// put it, so the ride was never finalized and no executor was told. The fix was to delete the + /// connection rather than to document the loss — and this is what "the close reaches the platform + /// on the legacy path" has to mean to be true. + #[test] + fn a_ride_close_survives_the_pass_for_the_legacy_drain() { + let mut app = App::new(AppState::new(0, 0, 1.0)); + app.activity.mode = Mode::Riding; + quiet(&mut app, 10); + + app.activity.request_track(crate::TrackAction::Save); + let plan = quiet(&mut app, 20); + assert!(app.activity.has_track_action(), "the pass left the rider's finish where it was"); + assert!(plan.effects.recorder.is_empty(), "and emitted no recorder effect it cannot answer"); + + let mut mail: crate::HostMailbox = crate::HostMailbox::new(); + let _ = app.drain_host_commands(&mut mail); + let mut drained = Vec::new(); + while let Some(command) = mail.pop() { + drained.push(command); + } + assert!( + drained.contains(&crate::HostCommand::FinishTrack(crate::TrackAction::Save)), + "so the executor that performs it still gets it: {drained:?}" + ); } /// The backpressure rule, end to end: two intents reach the catalog in one pass, it can admit diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index 06955f670..145c75c41 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -57,10 +57,11 @@ "_arena_render_note_1213": "**#1213 (WX10) — the rain overlay's per-frame decoded-tile cache joins `RenderScratch`: `arena_render` 117,408 → 120,544 B (+3,136 B = 12 tile slots × 256 B decoded cells + keys/ok flags/cursor + padding; `MCU_SCRATCH_BYTES` moves 117,376 → 120,512 and the 32 B delta is the same struct padding it always carried).** This is the arena-arm *composition* figure moving, not resident RAM: the arena is max(arms) and the USB arm (131,072 B) is still the maximum — `ARENA_BYTES == USB_ARM_BYTES` is compile-asserted in arena.rs — so `arena_total`, `measured_resident`, `uninit_max` and `resident_ram_max` are all unchanged, +0 B on glass. Render-arm headroom under the max-of-arms cliff is now 131,072 − 120,544 = 10,528 B; growth past that becomes 1:1 resident and moves the cliff (re-read arena.rs's growth-asymmetry notes before spending it). The slot count is 12 because the rain zoom regime's 45°-worst-case scanline crosses ~10 tiles (`RAIN_MAX_CELL_STEP` in obc-render/src/rain.rs); 8 slots measurably re-fetched at the regime edge. Identical in both profiles — the render path is the same image either way.", "_compile_note_dc4_1437": "**DC4 #1437 domain boundaries: `app` 50,520 -> 50,680 B (+160 B), both profiles.** Itemised on the CI `embedded` run for this branch against its base (099bd1be), all four items structural and none of them a buffer: (1) +72 B for the three derived cache keys in `CatalogState` (`ride_profile_for`, `ride_preview_for`, `nav_preview_route`) widening from `Option` (8 B) to `Option` / `Option` (32 B) — a durable object identity plus a source and a view revision, which is what removes the catalog-index remap and with it the late-answer and replaced-bytes staleness holes; (2) +24 B for the three `Revision` counters those keys read (`source_revision`, `ride_track_view`, `nav_preview_view`); (3) +40 B for the new `WeatherDomain` field on `App` (a token source, the installed `WeatherData` identity/revision, the in-flight refresh token, the request flag and the last terminal result); (4) +24 B inside `RetentionMachine` (its own token source and the in-flight sidecar-write slot, so a failed metadata write can be re-queued for the id it was actually about). Linked resident clears the 320,688 B `resident_ram_max` ceiling by ~16 KB on both profiles and `.uninit` is untouched at 132,096 B, so nothing moves on glass. Re-verified against S3 #1446's placement-constructor merge: `app` reads 50,680 B on both profiles there too, and `init_idle`'s own frame is 76 B of the 4,096 B ceiling. `measured_resident` (320,616 B) is a record, not a gate, and is left as it stands: it predates this branch.", "_compile_note_dc5_1438": "**DC5 #1438 pass coordinator: `app` 50,680 -> 50,920 B (+240 B), both profiles.** Two structural items, measured on the thumbv8m target, neither of them a buffer: (1) +224 B for the new `PassState` field on `App` \u2014 136 B of it the `Connections` set (eight named cross-domain slots: two UI intents, the expiry intent, the active-route notice, the fault bit set, and the three deferred slots whose two halves are what make a later-to-earlier value land on the *next* pass), and 88 B the levels a stage detects an edge against (the last store revision seen, the revision the catalog announced, the last link state, the transfer state, the active route identity, the 12 B `Capabilities` the admission stage recalculates, and the re-entrancy flag); (2) +16 B in `CatalogState` for the catalog domain's admitted-intent slot (`Option` is 16 B \u2014 one identity and a tag), with its 4 B token source and the in-flight flag landing in existing padding. The test-only stage recorder is `#[cfg(test)]` and is not in this figure. Linked resident and `.uninit` are unchanged (304,824 B / 132,096 B on the pinned host, against the 320,688 B ceiling), the arena is untouched, and `init_idle`'s construction frame is unmoved at 76 B against its 4,096 B limit \u2014 the field is written straight into its `.bss` slot like every other.", + "_compile_note_dc7_1440": "**DC7 #1440 conformance gate: `app` 50,920 -> 50,904 B (-16 B), both profiles.** A saving, from deleting the `UiRuntime` -> `Recorder` connection the gate proved was destroying a rider request: the pass took the ride-close one-shot at stage 4 and dropped it at stage 7, where Recorder has no machine to act on it, so the ride was never finalized and no executor was told. Out of `Connections` go the `Slot` and the `Deferred`; with the last `KeepFirst` connection gone, `Merge` goes with them and both remaining deferred slots lose their merge field. The close is back on the legacy drain that performs it, and returns with Recorder's machine at #1397 S6. Linked resident and `.uninit` are unchanged within the ceilings (304,808 B / 132,096 B on the pinned host, against 320,688 B), the arena is untouched, and `init_idle`'s construction frame is unmoved at 76 B of its 4,096 B limit.", "compile_time_allocations": { "framebuffer": 76800, "row_diff": 1284, - "app": 50920, + "app": 50904, "map_cache": 37084, "map_tables": 4416, "route_cache": 9260, @@ -197,10 +198,11 @@ "_arena_render_note_1213": "**#1213 (WX10) — the rain overlay's per-frame decoded-tile cache joins `RenderScratch`: `arena_render` 117,408 → 120,544 B (+3,136 B = 12 tile slots × 256 B decoded cells + keys/ok flags/cursor + padding; `MCU_SCRATCH_BYTES` moves 117,376 → 120,512 and the 32 B delta is the same struct padding it always carried).** This is the arena-arm *composition* figure moving, not resident RAM: the arena is max(arms) and the USB arm (131,072 B) is still the maximum — `ARENA_BYTES == USB_ARM_BYTES` is compile-asserted in arena.rs — so `arena_total`, `measured_resident`, `uninit_max` and `resident_ram_max` are all unchanged, +0 B on glass. Render-arm headroom under the max-of-arms cliff is now 131,072 − 120,544 = 10,528 B; growth past that becomes 1:1 resident and moves the cliff (re-read arena.rs's growth-asymmetry notes before spending it). The slot count is 12 because the rain zoom regime's 45°-worst-case scanline crosses ~10 tiles (`RAIN_MAX_CELL_STEP` in obc-render/src/rain.rs); 8 slots measurably re-fetched at the regime edge. Identical in both profiles — the render path is the same image either way.", "_compile_note_dc4_1437": "**DC4 #1437 domain boundaries: `app` 50,520 -> 50,680 B (+160 B), both profiles.** Itemised on the CI `embedded` run for this branch against its base (099bd1be), all four items structural and none of them a buffer: (1) +72 B for the three derived cache keys in `CatalogState` (`ride_profile_for`, `ride_preview_for`, `nav_preview_route`) widening from `Option` (8 B) to `Option` / `Option` (32 B) — a durable object identity plus a source and a view revision, which is what removes the catalog-index remap and with it the late-answer and replaced-bytes staleness holes; (2) +24 B for the three `Revision` counters those keys read (`source_revision`, `ride_track_view`, `nav_preview_view`); (3) +40 B for the new `WeatherDomain` field on `App` (a token source, the installed `WeatherData` identity/revision, the in-flight refresh token, the request flag and the last terminal result); (4) +24 B inside `RetentionMachine` (its own token source and the in-flight sidecar-write slot, so a failed metadata write can be re-queued for the id it was actually about). Linked resident clears the 320,688 B `resident_ram_max` ceiling by ~16 KB on both profiles and `.uninit` is untouched at 132,096 B, so nothing moves on glass. Re-verified against S3 #1446's placement-constructor merge: `app` reads 50,680 B on both profiles there too, and `init_idle`'s own frame is 76 B of the 4,096 B ceiling. `measured_resident` (320,616 B) is a record, not a gate, and is left as it stands: it predates this branch.", "_compile_note_dc5_1438": "**DC5 #1438 pass coordinator: `app` 50,680 -> 50,920 B (+240 B), both profiles.** Two structural items, measured on the thumbv8m target, neither of them a buffer: (1) +224 B for the new `PassState` field on `App` \u2014 136 B of it the `Connections` set (eight named cross-domain slots: two UI intents, the expiry intent, the active-route notice, the fault bit set, and the three deferred slots whose two halves are what make a later-to-earlier value land on the *next* pass), and 88 B the levels a stage detects an edge against (the last store revision seen, the revision the catalog announced, the last link state, the transfer state, the active route identity, the 12 B `Capabilities` the admission stage recalculates, and the re-entrancy flag); (2) +16 B in `CatalogState` for the catalog domain's admitted-intent slot (`Option` is 16 B \u2014 one identity and a tag), with its 4 B token source and the in-flight flag landing in existing padding. The test-only stage recorder is `#[cfg(test)]` and is not in this figure. Linked resident and `.uninit` are unchanged (304,824 B / 132,096 B on the pinned host, against the 320,688 B ceiling), the arena is untouched, and `init_idle`'s construction frame is unmoved at 76 B against its 4,096 B limit \u2014 the field is written straight into its `.bss` slot like every other.", + "_compile_note_dc7_1440": "**DC7 #1440 conformance gate: `app` 50,920 -> 50,904 B (-16 B), both profiles.** A saving, from deleting the `UiRuntime` -> `Recorder` connection the gate proved was destroying a rider request: the pass took the ride-close one-shot at stage 4 and dropped it at stage 7, where Recorder has no machine to act on it, so the ride was never finalized and no executor was told. Out of `Connections` go the `Slot` and the `Deferred`; with the last `KeepFirst` connection gone, `Merge` goes with them and both remaining deferred slots lose their merge field. The close is back on the legacy drain that performs it, and returns with Recorder's machine at #1397 S6. Linked resident and `.uninit` are unchanged within the ceilings (304,808 B / 132,096 B on the pinned host, against 320,688 B), the arena is untouched, and `init_idle`'s construction frame is unmoved at 76 B of its 4,096 B limit.", "compile_time_allocations": { "framebuffer": 76800, "row_diff": 1284, - "app": 50920, + "app": 50904, "map_cache": 37084, "map_tables": 4416, "route_cache": 9260, diff --git a/host/obc-host-core/tests/device_core_conformance.rs b/host/obc-host-core/tests/device_core_conformance.rs index 02e392fe6..2b3a48be9 100644 --- a/host/obc-host-core/tests/device_core_conformance.rs +++ b/host/obc-host-core/tests/device_core_conformance.rs @@ -22,6 +22,22 @@ //! it is this file failing, because a difference with no approved row is one nothing may ship over. //! At the time of writing there is none. //! +//! ## Two production defects came out of this gate +//! +//! **The rider's ride close was destroyed.** The pass took the finish one-shot at stage 4 and +//! dropped it at stage 7, where Recorder has no machine to act on it — so the ride was never +//! finalized and no executor was told. The fix was to delete the `UiRuntime` → `Recorder` +//! connection rather than document the loss: it provisioned for a lifecycle nobody owns, and its +//! only effect was destroying a rider request. The close is back on the legacy drain, where it is +//! performed, and [`a_ride_finalize_failure_after_the_last_checkpoint_reaches_the_rider`] runs the +//! mandated trace for real instead of pinning a gap. +//! +//! **A decided sidecar stamp was rediscovered forever.** The retention sweep re-derives its +//! candidates from the resident view, and the pass never mirrored the stamp it had just issued — so +//! the same write went out again on the pass after the executor answered it, one per pass, for the +//! rest of the boot. Found by the settle probe in [`Run::finish`]: a scenario that never came to +//! rest. [`a_stamp_that_was_answered_is_not_enqueued_again`] pins it. +//! //! ## What Phase 1 does and does not own //! //! Three domains have a state machine today — the catalog, retention and weather — and those are @@ -37,11 +53,13 @@ mod device_core_corpus; use std::collections::BTreeSet; +use obc_app::ble::BondEffect; use obc_app::catalog_state::{CatalogEffect, CatalogError, CatalogOutcome}; -use obc_app::device_core::compat::{event_reply, LegacyOwned}; +use obc_app::device_core::compat::{event_reply, LegacyOwned, LegacyReply}; use obc_app::device_core::derived::{ DerivedInput, DerivedInputs, DerivedNeeds, DerivedResult, DerivedTargets, NavPreviewKey, RideTrackKey, }; +use obc_app::device_core::storage_info::StorageInfoEffect; use obc_app::device_core::{ Capabilities, DeviceFacts, EffectSlots, LegacyAdapter, LegacyInputs, NavigatorTag, OutcomeSlots, PassClock, PassInputs, PassPlan, PlatformSupport, Revision, SettingsTag, StoreIdentity, StoreRevision, TokenSource, @@ -49,7 +67,7 @@ use obc_app::device_core::{ }; use obc_app::dfu::DfuEffect; use obc_app::navigator::{NavigatorEffect, NavigatorError, NavigatorOutcome, PlannerWork}; -use obc_app::recorder::{RecorderEffect, RecorderIntent}; +use obc_app::recorder::RecorderEffect; use obc_app::retention::{Retention, RetentionEffect, RetentionError, RetentionOutcome, RouteRetentionMeta}; use obc_app::screen::Screen; use obc_app::settings::{SettingsEffect, SettingsOutcome}; @@ -151,7 +169,18 @@ impl Run { harness.deliver(done, &mut recorder); } } - Run { settled: harness.snapshot(), trace } + // At rest means at rest: a runner still producing work here would be compared mid-flight, + // and the matrix would be reading a snapshot of a device that had not finished. + let settled = harness.snapshot(); + assert!( + harness.run_pass(&mut recorder).is_empty(), + "{} has not settled in {} passes under {}", + scenario.name, + SETTLE_PASSES, + runner.name() + ); + assert_eq!(harness.snapshot(), settled, "and a quiet pass changes nothing"); + Run { settled, trace } } } @@ -173,11 +202,13 @@ const EVERYTHING: PlatformSupport = PlatformSupport { /// The rider-visible projection two runners must agree on. /// -/// Two fields are dropped, and both for the same reason: they are *legacy* internals rather than +/// Two fields are dropped, and both for the same reason: they count *legacy* events rather than /// anything the rider can see. `pending_host_command` asks the old protocol whether it has a command -/// queued, which a domain that no longer speaks it will always answer `false` to; -/// `retention_delete_attempts` counts calls into the legacy repository trait, which the typed -/// executor does not implement. Requiring either would be requiring the legacy command sequence. +/// queued, which a domain that no longer speaks it answers `false` to by construction. +/// `retention_delete_attempts` counts calls into `BorrowedRoutes::delete_by_id`, which counts every +/// call including one for an id already gone; the typed executor reaches the store by identity and +/// counts only the calls whose object is still catalogued, so the two count different events for the +/// same behaviour. Requiring either would be requiring the legacy command sequence. fn rider_visible(mut state: VisibleState) -> VisibleState { state.pending_host_command = false; state.retention_delete_attempts = 0; @@ -252,6 +283,9 @@ enum Done { Retention(RetentionOutcome), /// A legacy answer, for a domain whose machine has not landed. Event(HostEvent), + /// The ride the recorder just finalized — answered, as the legacy protocol answers it, by a + /// catalog re-feed rather than by a terminal ride identity (`LegacyOwned::RideCloseAck`). + RideSaved, RideTrack(DerivedInput), NavPreview(DerivedInput), } @@ -461,7 +495,9 @@ impl CoreHarness { "{command:?} is DeviceCore's now — running it here would repeat the effect that carries it" ); if let HostCommand::PersistSettings { revision } = command { - persisted = Some(revision); + // The first, as `LegacyHarness::run_pass`'s `find_map` takes it — a second in one + // drain would be a coalescing bug, and taking the last would hide it. + persisted.get_or_insert(revision); } trace.record_command(&command); self.serve_legacy(command, done, trace); @@ -541,16 +577,28 @@ impl CoreHarness { HostCommand::StampRouteUsed { .. } | HostCommand::StampRideSynced { .. } => { self.served.insert("legacy-stamp"); } - // Cancels, plan requests, the detour commit and the ride close are consumed without - // being started: the corpus scripts their completion at the protocol boundary, exactly - // as the legacy runner does (`PlanHold`, `hold_detour_commit`). + // The ride close: still a legacy command, because Recorder has no machine — and the + // pass deliberately leaves the rider's one-shot here rather than taking it somewhere it + // cannot be acted on. + HostCommand::FinishTrack(TrackAction::Save) => { + if std::mem::take(&mut self.state.fail_next_finalize) { + // The legacy vocabulary has no recorder-finalize outcome; a host reports the + // failure through the generic warning, which is DC1's own recorded limitation. + done.push(Done::Event(HostEvent::Warning(WarningFlags::REC_ERROR))); + } else { + done.push(Done::RideSaved); + } + } + HostCommand::FinishTrack(TrackAction::Discard) => {} + // Cancels, plan requests and the detour commit are consumed without being started: the + // corpus scripts their completion at the protocol boundary, exactly as the legacy runner + // does (`PlanHold`, `hold_detour_commit`). HostCommand::PersistSettings { .. } | HostCommand::CancelRoutePlan | HostCommand::CancelDetour | HostCommand::PlanRoute(_) | HostCommand::PlanDetour(_) | HostCommand::CommitDetour - | HostCommand::FinishTrack(_) | HostCommand::ForgetBond => {} other => panic!("{other:?} is pass-owned and must not reach the legacy executor"), } @@ -569,6 +617,21 @@ impl CoreHarness { } } + /// Serve one plan and hand every answer straight back — the trace runner's frame, without the + /// delivery scheduling. + fn serve(&mut self, mut plan: PassPlan, trace: &mut TraceRecorder) { + let mut done = Vec::new(); + match self.executor { + Executor::Typed => self.serve_typed(&mut plan.effects, &mut done), + Executor::Compatibility => self.serve_compat(&mut plan.effects, &plan.derived_needs, &mut done, trace), + } + self.serve_mailbox(&mut done, trace); + self.serve_derived(&plan.derived_needs, &mut done); + for item in done { + self.deliver(item, trace); + } + } + fn refeed(&mut self, refeed: Refeed, trace: &mut TraceRecorder) { match refeed { Refeed::None => {} @@ -633,6 +696,7 @@ impl TraceHarness for CoreHarness { self.clock_ms = self.clock_ms.max(SETTINGS_FAILURE_RETRY_MS); } } + Done::RideSaved => self.state.feed_rides("core.recorder-saved", trace), Done::RideTrack(input) => { self.ride_preview = vec![(0, 0), (1, 1)]; self.inbox.derived.ride_track = Some(input); @@ -662,66 +726,79 @@ enum Disposition { Accepted, } -/// One approved difference between the legacy runners and the DeviceCore ones. +/// One approved difference between the legacy baseline and one or more DeviceCore runners. +/// +/// `runners` is what makes the table exact at the level the result is reported at: a difference that +/// *moved* from one runner to another — the typed executor regressing into the compatibility +/// executor's shape, say — changes the set of `(scenario, runner)` cells even when the scenario list +/// and the cell count are unchanged. #[derive(Debug, Clone, Copy)] struct Difference { - /// The scenario it shows up in, or `""` for one that is not scenario-scoped. scenario: &'static str, + /// Exactly the runners this difference appears in. + runners: &'static [Runner], disposition: Disposition, + /// The legacy row that owns the difference, when one does. Cross-checked against the rows the + /// compatibility executor actually reports, so a citation cannot be prose that stopped being + /// true. `None` says no legacy row owns it, and `why` then has to say what does. + owner: Option, /// What differs. what: &'static str, /// The expected target (`Corrected`) or the reason and later owner (`Accepted`). why: &'static str, } -/// Every difference this gate found, with its disposition. A scenario whose runners disagree -/// without a row here fails [`every_scenario_agrees_or_has_an_approved_disposition`]. +impl Difference { + /// The `(scenario, runner)` cells this row accounts for. + fn cells(&self) -> impl Iterator + '_ { + self.runners.iter().map(|runner| (self.scenario, runner.name())) + } +} + +/// Every difference this gate found, with its disposition and the cells it appears in. const DIFFERENCES: &[Difference] = &[ Difference { scenario: "derived-data.repeats-until-matching-fill", + runners: &[Runner::CoreImmediate, Runner::CoreDelayed, Runner::Compatibility], disposition: Disposition::Corrected, - what: "both DeviceCore runners settle one screen shallower than the legacy baseline", - why: "the legacy bulk feeders carry no subject, so a fill for the route the rider *was* \ - previewing satisfies the need for the one they are previewing now and leaves an extra \ - overview on the stack — DC1 records the stack depth moving with delivery cadence as a \ - known defect. DeviceCore keys every derived read (#1437), drops an answer about \ - something else, and reaches the same state immediate or delayed. The shallower stack \ - is the expected target.", - }, - Difference { - scenario: "recorder.failure-and-session-replacement", - disposition: Disposition::Accepted, - what: "no REC_ERROR card, because the ride close reaches no executor", - why: "the pass consumes the rider's finish one-shot at stage 4 and stage 7 emits no \ - RecorderEffect — Recorder has no machine yet (LegacyOwned::RideCloseAck, #1397 S6). \ - With no finalize there is no failure to report. The production hosts still run the \ - legacy frame methods, so nothing shipping is affected; the mapping the effect will use \ - already exists, and this row goes with Recorder's machine.", + owner: None, + what: "every DeviceCore runner settles one screen shallower than the legacy baseline", + why: "no legacy row owns a corrected defect. The legacy bulk feeders carry no subject, so a \ + fill for the route the rider *was* previewing satisfies the need for the one they are \ + previewing now and leaves an extra overview on the stack — DC1 records that stack \ + depth moving with delivery cadence as a known defect. DeviceCore keys every derived \ + read (#1437), drops an answer about something else, and reaches the same state \ + immediate or delayed. The shallower stack is the expected target.", }, Difference { scenario: "catalog.route-delete", + runners: &[Runner::Compatibility], disposition: Disposition::Accepted, + owner: Some(LegacyOwned::ObjectNamespace), what: "the compatibility runner keeps the object", why: "CatalogEffect::RemoveObject is namespace-free because the flat store removes by \ identity; the legacy deletes are namespaced, and the namespace cannot be recovered \ - from the effect. The adapter therefore leaves it against LegacyOwned::ObjectNamespace \ - (#1397 S6) rather than guessing. The typed runner removes it, which is exactly what \ - that row costs until the store executor lands.", + from the effect. The adapter leaves it rather than guessing (#1397 S6). The typed \ + runner removes it, which is what that row costs until the store executor lands.", }, Difference { scenario: "catalog.ride-delete", + runners: &[Runner::Compatibility], disposition: Disposition::Accepted, + owner: Some(LegacyOwned::ObjectNamespace), what: "the compatibility runner keeps the object", - why: "the same LegacyOwned::ObjectNamespace row (#1397 S6).", + why: "the same namespace-free removal, and the same row (#1397 S6).", }, Difference { scenario: "retention.expiry-retry-and-trusted-clock", + runners: &[Runner::Compatibility], disposition: Disposition::Accepted, + owner: Some(LegacyOwned::ObjectNamespace), what: "the compatibility runner expires nothing", why: "an expiry reaches the catalog as the same namespace-free removal, so it hits the same \ - LegacyOwned::ObjectNamespace row — and the catalog then stays in flight, because no \ - legacy event can build a CatalogOutcome. That is the documented one-operation cost of \ - running the pass before the executors migrate (#1439, closed by #1397 S6).", + row — and the catalog then stays in flight, because no legacy event can build a \ + CatalogOutcome. That is the documented one-operation cost of running the pass before \ + the executors migrate (#1439, closed by #1397 S6).", }, ]; @@ -767,38 +844,65 @@ fn every_scenario_agrees_or_has_an_approved_disposition() { assert_eq!(compared, SCENARIOS.len() * Runner::ALL.len(), "every scenario runs in every runner"); - let approved: BTreeSet<&str> = DIFFERENCES.iter().map(|row| row.scenario).collect(); - let found: BTreeSet<&str> = differing.iter().map(|(scenario, _)| *scenario).collect(); + // Exact at the level the result is reported at: one approved `(scenario, runner)` cell per + // difference. A difference that moves between runners inside a listed scenario changes this set + // even though the scenario list and the cell count do not. + let approved: BTreeSet<(&str, &str)> = DIFFERENCES.iter().flat_map(Difference::cells).collect(); assert_eq!( - found, approved, - "the disposition table is exact in both directions: every difference is approved, and no row \ - documents a difference that no longer exists" + differing, approved, + "the disposition table is exact per cell: every difference is approved in the runner it \ + appears in, and no row documents one that no longer exists" ); - assert_eq!(differing.len(), 9, "nine runner cells differ, and every one of them is dispositioned"); } -/// Every row of the disposition table is usable: a corrected row states its target, an accepted row -/// names its later owner, and a blocking row fails the gate outright. +/// Every row of the disposition table is usable, and its citation is checked rather than read. +/// +/// An `owner` is not prose: for a compatibility-runner row it must be a row the compatibility +/// executor **actually reports leaving** on that scenario, so a citation that stopped being true +/// fails here. A row with no owner has to say what owns the difference instead. #[test] -fn every_difference_carries_a_usable_disposition() { +fn every_difference_carries_a_verified_disposition() { assert!(!DIFFERENCES.is_empty()); + let names: BTreeSet<&str> = SCENARIOS.iter().map(|scenario| scenario.name).collect(); + for row in DIFFERENCES { assert!(!row.what.is_empty() && !row.why.is_empty(), "{row:?}"); - match row.disposition { - Disposition::Corrected => { - assert!(row.why.contains('#'), "a corrected defect names the slice that owns the target: {row:?}") + assert!(names.contains(row.scenario), "{} is not a scenario", row.scenario); + assert!(!row.runners.is_empty(), "a difference appears in at least one runner: {row:?}"); + assert!(row.why.contains('#'), "every disposition names the slice that owns its target: {row:?}"); + + match (row.disposition, row.owner) { + // A corrected defect is DeviceCore being right; no legacy row owns it. + (Disposition::Corrected, owner) => { + assert!(owner.is_none(), "a corrected defect is not owned by a legacy row: {row:?}"); + assert!(row.why.contains("no legacy row owns"), "and it has to say so: {row:?}"); } - Disposition::Accepted => assert!( - row.why.contains('#'), - "an accepted difference names its later owner and deletion slice: {row:?}" + (Disposition::Accepted, Some(owner)) => { + assert!(owner.deletes_in().starts_with('#'), "{owner:?} must name its deletion slice"); + if row.runners.contains(&Runner::Compatibility) { + let left = compatibility_rows_left(named(row.scenario)); + assert!( + left.contains(&owner), + "{}: cites {owner:?}, but the compatibility executor reported {left:?}", + row.scenario + ); + } + } + (Disposition::Accepted, None) => assert!( + row.why.contains("no legacy row owns"), + "an accepted difference with no owning row has to say what owns it instead: {row:?}" ), } } - // Every scenario a row names is a real one. - let names: BTreeSet<&str> = SCENARIOS.iter().map(|scenario| scenario.name).collect(); - for row in DIFFERENCES { - assert!(names.contains(row.scenario), "{} is not a scenario", row.scenario); - } +} + +/// The `LegacyOwned` rows the compatibility executor reports leaving on one scenario — the evidence +/// an `owner` citation is checked against. +fn compatibility_rows_left(scenario: &Scenario) -> BTreeSet { + let mut harness = CoreHarness::new(Executor::Compatibility); + run_scenario_seeded(&definition(scenario), RunnerMode::Immediate, &normalization_seed(), &mut harness) + .expect("the scenario runs"); + harness.left } /// Every `LegacyOwned` row has a later owner and a deletion slice — the epic's Phase 1 gate on the @@ -913,39 +1017,130 @@ fn the_compatibility_executor_leaves_what_the_old_protocol_cannot_say() { // ==================== the mandatory traces (#1440) ==================== -/// The sixteen traces #1440 requires, each bound to the test that runs it. -/// -/// The binding is checked rather than written down: the test below looks each name up in this -/// file's own source, so a renamed or deleted trace fails the gate instead of quietly leaving a row -/// of the issue uncovered. -const MANDATORY_TRACES: [(&str, &str); 16] = [ - ("outcome after cancellation", "an_outcome_after_cancellation_changes_nothing"), - ("outcome after a replacement request", "an_outcome_after_a_replacement_request_changes_nothing"), - ("store change during catalog refresh", "a_store_change_during_a_catalog_operation_is_not_lost"), - ("transfer start during route planning", "a_transfer_during_planning_withdraws_heavy_capability"), - ( - "route-plan completion after active-route change", - "a_route_plan_that_lands_after_the_active_route_changed_is_refused", - ), - ("settings result with an old revision", "a_settings_result_with_an_old_revision_is_refused"), - ("ride finalize failure after the last checkpoint", "a_ride_close_reaches_no_executor_until_recorder_lands"), - ("trip member disappearance before delete commit", "an_object_that_vanished_before_the_commit_is_a_success"), - ("capability change after a new map mounts", "capabilities_follow_the_mounted_data_and_the_platform"), - ("detour without a path", "a_detour_without_a_path_is_a_failure_and_not_an_absent_capability"), - ("device without detour capability", "capabilities_follow_the_mounted_data_and_the_platform"), - ("active-route deletion with same-pass Navigator delivery", "deleting_the_active_route_drops_it_in_the_same_pass"), - ("Navigator activation with next-pass Retention delivery", "an_activation_reaches_retention_on_the_next_pass"), - ("full effect slot and full outcome slot", "a_full_slot_preserves_work_on_both_sides_of_the_seam"), - ("deferred slot which forces a pass before sleep", "a_deferred_value_forces_a_pass_before_sleep"), - ("stale derived input after a subject change", "a_stale_derived_fill_is_dropped_and_the_level_asks_again"), +/// One of the sixteen traces #1440 requires, bound to the test that runs it. +struct MandatoryTrace { + /// The row, in the issue's words. + row: &'static str, + /// The `#[test]` that runs it. + test: &'static str, + /// Set when the test exercises a *different* situation than the row names, saying why the row's + /// own situation is unreachable in Phase 1 and which slice makes it reachable. A row that + /// silently tested something else would let the issue's checklist read as covered when it is not. + substitution: Option<&'static str>, +} + +/// All sixteen. The binding is checked rather than written down: the test below looks each name up +/// in this file's own source as a real `#[test]`, so a renamed, deleted or un-attributed trace fails +/// the gate instead of quietly leaving a row of the issue uncovered. +const MANDATORY_TRACES: [MandatoryTrace; 16] = [ + MandatoryTrace { + row: "outcome after cancellation", + test: "an_outcome_after_cancellation_changes_nothing", + substitution: None, + }, + MandatoryTrace { + row: "outcome after a replacement request", + test: "an_outcome_after_a_replacement_request_changes_nothing", + substitution: None, + }, + MandatoryTrace { + row: "store change during catalog refresh", + test: "a_store_change_during_a_catalog_operation_is_not_lost", + substitution: Some( + "a catalog *refresh* cannot be in flight in Phase 1: the pass produces no CatalogIntent::Refresh, so a store commit still becomes the legacy RescanStore cue (LegacyOwned::StoreRevision). The trace runs the store-revision fact against an in-flight catalog **removal** — the same one-operation-in-flight rule, on the only catalog operation the pass can produce. The refresh arrives with the store executor at #1397 S6, and this row becomes literal then.", + ), + }, + MandatoryTrace { + row: "transfer start during route planning", + test: "a_transfer_during_planning_withdraws_heavy_capability", + substitution: None, + }, + MandatoryTrace { + row: "route-plan completion after active-route change", + test: "a_route_plan_that_lands_after_the_active_route_changed_is_refused", + substitution: None, + }, + MandatoryTrace { + row: "settings result with an old revision", + test: "a_settings_result_with_an_old_revision_is_refused", + substitution: None, + }, + MandatoryTrace { + row: "ride finalize failure after the last checkpoint", + test: "a_ride_finalize_failure_after_the_last_checkpoint_reaches_the_rider", + substitution: None, + }, + MandatoryTrace { + row: "trip member disappearance before delete commit", + test: "an_object_that_vanished_before_the_commit_is_a_success", + substitution: Some( + "the trip cascade never becomes an effect — CatalogState::admit_intent refuses it (LegacyOwned::TripCascade), so there is no bounded member read to race with. The trace runs the same disappearance against a route removal, which is where the rule lives: an object already gone is `existed: false`, a success, never a failure the rider sees. The cascade's own member read lands with #1397 S6.", + ), + }, + MandatoryTrace { + row: "capability change after a new map mounts", + test: "capabilities_follow_the_mounted_data_and_the_platform", + substitution: None, + }, + MandatoryTrace { + row: "detour without a path", + test: "a_detour_without_a_path_is_a_failure_and_not_an_absent_capability", + substitution: None, + }, + MandatoryTrace { + row: "device without detour capability", + test: "capabilities_follow_the_mounted_data_and_the_platform", + substitution: None, + }, + MandatoryTrace { + row: "active-route deletion with same-pass Navigator delivery", + test: "deleting_the_active_route_drops_it_in_the_same_pass", + substitution: None, + }, + MandatoryTrace { + row: "Navigator activation with next-pass Retention delivery", + test: "an_activation_reaches_retention_on_the_next_pass", + substitution: None, + }, + MandatoryTrace { + row: "full effect slot and full outcome slot", + test: "a_full_slot_preserves_work_on_both_sides_of_the_seam", + substitution: None, + }, + MandatoryTrace { + row: "deferred slot which forces a pass before sleep", + test: "a_deferred_value_forces_a_pass_before_sleep", + substitution: None, + }, + MandatoryTrace { + row: "stale derived input after a subject change", + test: "a_stale_derived_fill_is_dropped_and_the_level_asks_again", + substitution: None, + }, ]; #[test] fn every_mandatory_trace_has_a_test_that_runs_it() { let source = include_str!("device_core_conformance.rs"); - for (trace, test) in MANDATORY_TRACES { - assert!(source.contains(&format!("fn {test}(")), "{trace}: no test named {test}"); + for trace in MANDATORY_TRACES { + // `#[test]` and not merely `fn`: a private helper of the same name, or a trace that lost its + // attribute, would otherwise satisfy the binding without ever running. + assert!( + source.contains(&format!("#[test]\nfn {}(", trace.test)), + "{}: no `#[test] fn {}` in this file", + trace.row, + trace.test + ); + if let Some(substitution) = trace.substitution { + assert!( + substitution.contains('#'), + "{}: a substituted situation names the slice that makes the row literal", + trace.row + ); + } } + let substituted = MANDATORY_TRACES.iter().filter(|trace| trace.substitution.is_some()).count(); + assert_eq!(substituted, 2, "two rows are unreachable in Phase 1, and both say so"); } fn typed() -> CoreHarness { @@ -1086,9 +1281,25 @@ fn a_store_change_during_a_catalog_operation_is_not_lost() { } /// **A transfer starts during route planning.** Heavy work is withdrawn while a transfer holds the -/// store, so a plan or an install is never *started* and then failed. +/// store, so a *new* plan is never started — and the one already running is not failed by it either. +/// +/// The distinction is the rule: admission decides what may *begin*, and a capability going away is +/// not a reason to cancel an operation that is already owed an answer. #[test] fn a_transfer_during_planning_withdraws_heavy_capability() { + // A plan is admitted and goes out under Navigator's live token. + let mut navigator: TokenSource = TokenSource::new(); + let token = navigator.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + let work = PlannerWork::Route(NavRequest::new((0, 0), (1, 1), "goal")); + effects.navigator.try_put(NavigatorEffect::Acquire { token, work }).unwrap(); + adapter.effects_to_commands(&mut effects, &mut mail); + assert!(matches!(mail.pop(), Some(HostCommand::PlanRoute(_))), "the planner was asked"); + assert!(adapter.pending().holds(LegacyReply::RoutePlan), "and is owed an answer"); + + // The transfer starts. Admission is a level, recalculated from what is true now. let facts = |heavy| DeviceFacts { store_writable: true, nav_graph: true, @@ -1100,17 +1311,29 @@ fn a_transfer_during_planning_withdraws_heavy_capability() { let idle = Capabilities::calculate(EVERYTHING, facts(true)); let streaming = Capabilities::calculate(EVERYTHING, facts(false)); assert!(idle.navigator.plan_route && idle.navigator.plan_detour && idle.dfu.install); - assert!(!streaming.navigator.plan_route, "a route plan is heavy"); - assert!(!streaming.navigator.plan_detour, "and so is a detour"); - assert!(!streaming.dfu.install, "and so is an install"); + assert!(!streaming.navigator.plan_route, "a second route plan cannot start"); + assert!(!streaming.navigator.plan_detour, "nor a detour"); + assert!(!streaming.dfu.install, "nor an install"); assert!(streaming.catalog.mutate, "but the store is still writable — this is admission, not a fault"); - // The one capability a Phase 1 stage actually consults: a weather refresh needs the link, and a - // withdrawn capability stops the effect being emitted at all rather than failing it. + // The pass sees the transfer and starts nothing; it also fails nothing. let mut harness = expiring(0); harness.inbox.facts.note_transfer(TransferState::Active); let plan = harness.pass(); - assert!(plan.effects.weather.is_empty(), "no refresh is started without the capability"); + assert!(!plan.effects.has_pending(), "no work is admitted while the transfer holds the store"); + assert!(adapter.pending().holds(LegacyReply::RoutePlan), "and the running plan is untouched"); + assert!(navigator.is_current(token), "its operation is still the current one"); + + // So when the planner finally answers, it is still this operation's answer. + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::NavPlanned(Ok(10)), &mut inbox).unwrap(); + let outcome = inbox.outcomes.navigator.take().expect("the answer is delivered"); + assert!(navigator.is_current(outcome.token()), "a withdrawn capability never cancelled the running plan"); + + // …and the capability comes straight back when the transfer ends. + harness.inbox.facts.note_transfer(TransferState::Idle); + harness.pass(); + assert!(Capabilities::calculate(EVERYTHING, facts(true)).navigator.plan_route); } /// **A route plan completes after the active route changed.** The answer carries the token the @@ -1166,31 +1389,48 @@ fn a_settings_result_with_an_old_revision_is_refused() { assert_eq!(rider_visible(core.settled.clone()), rider_visible(legacy.settled.clone())); } -/// **A ride finalize failure after the last checkpoint.** The accepted Phase 1 difference, pinned: -/// the pass consumes the rider's close at stage 4 and stage 7 emits no `RecorderEffect`, so no -/// executor sees it. +/// **A ride finalize failure after the last checkpoint.** The ride close survives the pass, the +/// executor that performs it fails it, and the rider is told. /// -/// Stated as a fact rather than left as prose, because it is the one place a rider request currently -/// ends inside the pass. The mapping table already holds the row it will use -/// ([`LegacyOwned::RideCloseAck`]); what is missing is Recorder's machine, at #1397 S6. Nothing -/// shipping is affected — the production hosts still run the legacy frame methods. +/// This trace is why the `ui_recorder` → `ride_closed` wiring is gone. It used to take the rider's +/// finish one-shot at stage 4 and drop it at stage 7, so the ride was never finalized and no +/// executor was told — a rider request destroyed to serve a lifecycle Recorder does not own yet. +/// The pass now leaves it alone, which is what "the close reaches the platform on the legacy path" +/// has to mean to be true. #[test] -fn a_ride_close_reaches_no_executor_until_recorder_lands() { +fn a_ride_finalize_failure_after_the_last_checkpoint_reaches_the_rider() { let mut harness = expiring(0); harness.app().activity.start_session(); harness.pass(); + // The last checkpoint is behind us; the finalize is the one that fails. + harness.state.fail_next_finalize = true; harness.app().activity.request_track(TrackAction::Save); let plan = harness.pass(); - assert!(plan.effects.recorder.is_empty(), "no RecorderEffect is emitted"); - assert!(plan.immediate, "the close is not lost outright: retention is told, before sleep"); - assert!(!harness.state.app.activity.has_track_action(), "and the legacy class was consumed by the pass"); - - // The row that closes it exists and names its owner, so this is a scheduled gap, not a hole. - assert!(LegacyOwned::RideCloseAck.deletes_in().contains("#1397")); + assert!(plan.effects.recorder.is_empty(), "Recorder has no machine, so the pass emits no effect"); + assert!(harness.state.app.activity.has_track_action(), "and it leaves the rider's finish for the drain"); + + let mut done = Vec::new(); + let mut trace = recorder(); + harness.serve_mailbox(&mut done, &mut trace); + // The legacy vocabulary has no recorder-finalize outcome, so a host reports the failure through + // the generic warning — DC1's own recorded compatibility limitation, unchanged here. + assert!( + matches!(done.as_slice(), [Done::Event(HostEvent::Warning(flags))] if flags.contains(WarningFlags::REC_ERROR)), + "the finalize failed and said so: {done:?}" + ); + for item in done { + harness.deliver(item, &mut trace); + } + harness.pass(); + assert!( + matches!(harness.state.app.top_screen(), Screen::Warning(card) + if card.flags().contains(WarningFlags::REC_ERROR)), + "and the rider is told rather than left believing the ride was saved" + ); - // The mapping is already in place: once Recorder emits the effect, the adapter knows where it - // goes. The missing piece is one domain's machine, and nothing in the protocol. + // The typed replacement is already mapped: when Recorder emits the effect, the adapter knows + // where it goes, so what is missing is one domain's machine and nothing in the protocol. let mut recorder_ops = TokenSource::new(); let mut effects = EffectSlots::new(); effects.recorder.try_put(RecorderEffect::Finalize { token: recorder_ops.issue() }).unwrap(); @@ -1198,7 +1438,7 @@ fn a_ride_close_reaches_no_executor_until_recorder_lands() { let mut mail: HostMailbox = HostMailbox::new(); assert_eq!(adapter.effects_to_commands(&mut effects, &mut mail).translated, 1); assert_eq!(mail.pop(), Some(HostCommand::FinishTrack(TrackAction::Save))); - assert_eq!(RecorderIntent::Save, RecorderIntent::Save, "the intent type is the seam that survives"); + assert!(LegacyOwned::RideCloseAck.deletes_in().contains("#1397"), "the acknowledgement is still owed"); } /// **A trip member disappears before the delete commit.** The goal state holds, so the removal is a @@ -1357,18 +1597,23 @@ fn a_full_slot_preserves_work_on_both_sides_of_the_seam() { /// **A deferred slot forces a pass before sleep.** Work that is already decided must not sit until /// the next rider input. +/// +/// Written on the route activation, which is the deferred producer the wiring actually has: +/// Navigator runs after retention, so an activation cannot reach backwards and waits a pass. #[test] fn a_deferred_value_forces_a_pass_before_sleep() { - let mut harness = expiring(0); - harness.app().activity.start_session(); - harness.pass(); + let mut harness = typed(); + harness.app().activate_route(0); - harness.app().activity.request_track(TrackAction::Save); let plan = harness.pass(); assert!(plan.immediate && plan.next_wake_ms == Some(0), "the runtime comes straight back"); let plan = harness.pass(); - assert!(!plan.immediate && plan.next_wake_ms != Some(0), "and then there is nothing to hurry for"); + assert!(!plan.immediate && plan.next_wake_ms != Some(0), "consumed, and nothing is left to hurry for"); + + harness.app().activate_route(1); + let plan = harness.pass(); + assert!(plan.immediate, "a second activation is deposited just the same"); } /// **A stale derived input after a subject change** — the corrected defect of this gate. @@ -1487,6 +1732,96 @@ fn a_failed_retention_write_is_retried() { assert!(retried, "a failed write keeps its candidate and offers it again"); } +/// A decided sidecar stamp is mirrored into the resident view, so it is not rediscovered forever. +/// +/// The second defect this gate found. The sweep re-derives its candidates from the resident view, so +/// a stamp that left as an effect but was not mirrored came back on the pass after the executor +/// answered it — one sidecar write per pass, for the rest of the boot, on a device whose whole +/// power budget is not waking up. The legacy drain has always mirrored at +/// `App::retention_stamp_command`; the pass does now too. +#[test] +fn a_stamp_that_was_answered_is_not_enqueued_again() { + let mut harness = typed(); + harness.app().stamp_clock_ble(1_720_000_000, 60); + let now = harness.state.app.wall_unix_now(); + harness.app().set_route_meta(&[ + RouteRetentionMeta::new(Retention::Week1, now), + RouteRetentionMeta::new(Retention::Never, 0), + RouteRetentionMeta::new(Retention::Never, 0), + ]); + harness.app().activate_route(0); + + let mut plan = harness.pass(); + let effect = plan.effects.retention.take().expect("the activation's use stamp goes out"); + let outcome = harness.serve_retention(effect); + harness.deliver(Done::Retention(outcome), &mut recorder()); + + for step in 0..8 { + let plan = harness.pass(); + assert!( + plan.effects.retention.is_empty(), + "the answered stamp came back on settle pass {step} — an endless sidecar write" + ); + } +} + +/// The conformance replay's wake profile and pass cost — #1440's last two resource rows. +/// +/// The wake counts are deterministic, so they are asserted: a pass that starts polling, or a +/// deferred connection that stops settling, moves them and this fails. The times are +/// machine-dependent, so they are printed under `--nocapture` rather than gated. What the test +/// guarantees is that both figures are reproducible from one command, instead of from a measurement +/// harness someone ran once and deleted. +#[test] +fn the_conformance_replay_wake_profile_and_pass_cost() { + let mut passes = 0u32; + let mut immediate = 0u32; + let mut timed = 0u32; + let mut sleeps = 0u32; + let mut total = std::time::Duration::ZERO; + let mut worst = std::time::Duration::ZERO; + + for scenario in SCENARIOS { + for executor in [Executor::Typed, Executor::Compatibility] { + let mut harness = CoreHarness::new(executor); + let mut trace = recorder(); + let actions = scenario.actions.iter().chain(std::iter::repeat_n(&Action::Settle, SETTLE_PASSES)); + for action in actions { + harness.apply(*action); + let start = std::time::Instant::now(); + let plan = harness.pass(); + let elapsed = start.elapsed(); + total += elapsed; + worst = worst.max(elapsed); + passes += 1; + match plan.next_wake_ms { + Some(0) => immediate += 1, + Some(_) => timed += 1, + None => sleeps += 1, + } + harness.serve(plan, &mut trace); + } + } + } + + println!("passes {passes}: {immediate} immediate, {timed} timed, {sleeps} sleep-until-event"); + println!( + "pass time: mean {:.3} us, worst {:.3} us", + total.as_secs_f64() * 1e6 / f64::from(passes), + worst.as_secs_f64() * 1e6 + ); + + assert_eq!(passes, WAKE_PROFILE.0, "the replay's pass count is fixed by the scenario table"); + assert_eq!(immediate, WAKE_PROFILE.1, "an immediate wake is decided work that has not reached its consumer"); + assert_eq!(timed, WAKE_PROFILE.2); + assert_eq!(sleeps, WAKE_PROFILE.3); + assert!(immediate * 10 < passes, "immediate wakes stay a small minority — nothing here polls"); +} + +/// `(passes, immediate, timed, sleep-until-event)` for the replay above. A ratchet, not a budget: +/// the numbers move when the pass's wake decisions do, and #1397 compares against them. +const WAKE_PROFILE: (u32, u32, u32, u32) = (366, 6, 236, 124); + // ==================== the resource gate ==================== /// The pass protocol's size budget, re-asserted from outside `obc-app`. @@ -1513,6 +1848,8 @@ fn the_pass_protocol_stays_within_its_budget() { size_of::(), size_of::(), size_of::(), + size_of::(), + size_of::(), ] .into_iter() .max() diff --git a/host/obc-host-core/tests/device_core_corpus/mod.rs b/host/obc-host-core/tests/device_core_corpus/mod.rs index a764e942a..aeddab8b1 100644 --- a/host/obc-host-core/tests/device_core_corpus/mod.rs +++ b/host/obc-host-core/tests/device_core_corpus/mod.rs @@ -201,7 +201,9 @@ pub enum ScreenState { DfuError, Warning, WeatherAlert, - Other, + /// Any screen the projection does not name, carrying its variant name so two runners resting on + /// *different* unnamed screens still compare unequal. + Other(&'static str), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -405,7 +407,7 @@ pub fn visible_state(app: &App, settings_revision: u16, retention_delete_attempt Screen::DfuError(_) => ScreenState::DfuError, Screen::Warning(_) => ScreenState::Warning, Screen::WeatherAlert(_) => ScreenState::WeatherAlert, - _ => ScreenState::Other, + other => ScreenState::Other(other.name()), }; VisibleState { screen, @@ -797,7 +799,7 @@ impl TraceHarness for LegacyHarness { let settings_failed = matches!(event, HostEvent::SettingsPersistFailed { .. }); self.event(event, trace); if settings_failed && self.settings_retry_requested { - self.app.advance_animations(InputClock(6_003)); + self.app.advance_animations(InputClock(SETTINGS_FAILURE_RETRY_MS)); } } Outcome::FinishSave => self.feed_rides("recorder.saved", trace), @@ -1199,9 +1201,9 @@ pub const SCENARIOS: &[Scenario] = &[ }, ]; -/// The animation clock `Action::RetrySettingsPersist` moves the app to when a settings failure has -/// already been delivered. Its twin lives in [`LegacyHarness::deliver`]; a runner that owns a pass -/// clock of its own has to know both marks. +/// The animation clock a delivered settings failure moves the app to, so the bounded retry window +/// has elapsed by the time the retry action runs. Used by [`LegacyHarness::deliver`] and by any +/// runner that owns a pass clock of its own. pub const SETTINGS_FAILURE_RETRY_MS: u32 = 6_003; /// The `InputClock` an action advances the app's animation clock to, or `0` for one that does not. From ab92fa6e45d7f51b5afefa087f3a69b1ac977759 Mon Sep 17 00:00:00 2001 From: timohueser Date: Mon, 24 Aug 2026 09:23:08 +0200 Subject: [PATCH 3/4] test(host): pin the stamp mirror on the arm that re-derives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated pin for the endless sidecar write drove the route arm, where the stamp is a one-shot: nothing re-enqueues it, so the test passed with the mirror removed. The loop is the ride arm — the eager `synced_at` stamp runs on every trusted tick and re-enqueues any resident ride that is `synced` with a stamp of 0, forever, until the mirror fills it. The test now drives that arm and fails on the first pass after the answer without `mirror_stamp`. Also drops the pure-history DC7/#1440 prose from the pass comments, per the house rule that comments describe present behavior; the forward marker (#1397 S6) and the invariant provenance (#876-2) stay. Co-Authored-By: Claude Fable 5 --- firmware/obc-app/src/device_core/pass.rs | 18 +++--- .../tests/device_core_conformance.rs | 57 +++++++++++-------- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 4da3fd05b..c5421341b 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -413,7 +413,7 @@ impl App { /// /// The rider's ride *close* is deliberately not taken here. Recorder has no machine yet, so /// there is no domain to name it to; taking the one-shot anyway would only remove it from the - /// legacy drain that still performs it (#1440 found exactly that, and it destroyed the save). + /// legacy drain that still performs it, and the rider's save would be destroyed on the way. fn stage_ui(&mut self, now: PassClock) { self.pass.record(PassStage::Ui); self.advance_animations(now.ui); @@ -478,10 +478,9 @@ impl App { /// Mirror a decided sidecar stamp into the resident view, the moment the effect leaves. /// - /// The sweep re-derives its candidates from that view, so without this the *same* stamp is + /// Retention re-derives its candidates from that view, so without this the *same* stamp is /// rediscovered on the pass after the executor answers it — an endless sidecar write, one per - /// pass, for the rest of the boot. DC7 (#1440) measured exactly that: a settled scenario that - /// never came to rest. + /// pass, for the rest of the boot, on a device whose whole power budget is not waking up. /// /// The value written is the one the effect carries, not a guess: the durable write is still the /// executor's, and a failure re-queues the stamp through @@ -554,9 +553,9 @@ impl App { /// [`FinishTrack`](crate::HostCommand::FinishTrack), exactly as it always was. /// /// The stage is a *position*, held so the order is fixed before the machines land. It is - /// deliberately not a connection into a domain that cannot act: DC7 (#1440) measured what one - /// costs — the pass took the rider's Save at stage 4 and had nowhere to put it, so the ride was - /// never finalized and no executor was told. + /// deliberately not a connection into a domain that cannot act: such a connection destroys what + /// it carries — the pass would take the rider's Save at stage 4 and have nowhere to put it here, + /// so the ride would never be finalized and no executor would be told. fn stage_recorder(&mut self) { self.pass.record(PassStage::Recorder); } @@ -976,9 +975,8 @@ mod tests { /// The rider's ride close stays on the legacy path, untouched by the pass. /// - /// DC7 (#1440) found the opposite: stage 4 took the finish one-shot and stage 7 had nowhere to - /// put it, so the ride was never finalized and no executor was told. The fix was to delete the - /// connection rather than to document the loss — and this is what "the close reaches the platform + /// A stage-4 take with nowhere to put it at stage 7 leaves the ride unfinalized and no executor + /// told, so there is no connection to take it — and this is what "the close reaches the platform /// on the legacy path" has to mean to be true. #[test] fn a_ride_close_survives_the_pass_for_the_legacy_drain() { diff --git a/host/obc-host-core/tests/device_core_conformance.rs b/host/obc-host-core/tests/device_core_conformance.rs index 2b3a48be9..6269adf55 100644 --- a/host/obc-host-core/tests/device_core_conformance.rs +++ b/host/obc-host-core/tests/device_core_conformance.rs @@ -32,11 +32,12 @@ //! performed, and [`a_ride_finalize_failure_after_the_last_checkpoint_reaches_the_rider`] runs the //! mandated trace for real instead of pinning a gap. //! -//! **A decided sidecar stamp was rediscovered forever.** The retention sweep re-derives its -//! candidates from the resident view, and the pass never mirrored the stamp it had just issued — so -//! the same write went out again on the pass after the executor answered it, one per pass, for the -//! rest of the boot. Found by the settle probe in [`Run::finish`]: a scenario that never came to -//! rest. [`a_stamp_that_was_answered_is_not_enqueued_again`] pins it. +//! **A decided sidecar stamp was rediscovered forever.** Retention re-derives its candidates from +//! the resident view — the eager ride stamp on every trusted tick — and the pass never mirrored the +//! stamp it had just issued, so the same write went out again on the pass after the executor +//! answered it, one per pass, for the rest of the boot. Found by the settle probe in +//! [`Run::finish`]: a scenario that never came to rest. +//! [`a_stamp_that_was_answered_is_not_enqueued_again`] pins it on that ride arm. //! //! ## What Phase 1 does and does not own //! @@ -73,8 +74,8 @@ use obc_app::screen::Screen; use obc_app::settings::{SettingsEffect, SettingsOutcome}; use obc_app::weather::WeatherEffect; use obc_app::{ - App, AppState, DetourRequest, DfuAction, Gesture, HostCommand, HostEvent, HostMailbox, NavRequest, TrackAction, - WarningFlags, + App, AppState, DetourRequest, DfuAction, Gesture, HostCommand, HostEvent, HostMailbox, NavRequest, + RideRetentionRecord, TrackAction, WarningFlags, }; use obc_host_core::trace::{run_scenario_seeded, RunnerMode, Trace, TraceHarness, TraceInput, TraceRecorder}; use obc_ports::{Fix, InputClock, LocationSource, RideClock, Sensors, SettingsSaveError}; @@ -1734,34 +1735,44 @@ fn a_failed_retention_write_is_retried() { /// A decided sidecar stamp is mirrored into the resident view, so it is not rediscovered forever. /// -/// The second defect this gate found. The sweep re-derives its candidates from the resident view, so -/// a stamp that left as an effect but was not mirrored came back on the pass after the executor -/// answered it — one sidecar write per pass, for the rest of the boot, on a device whose whole -/// power budget is not waking up. The legacy drain has always mirrored at -/// `App::retention_stamp_command`; the pass does now too. +/// The second defect this gate found, and this is the arm that re-derives: the eager ride stamp runs +/// on **every trusted tick**, and re-enqueues any resident ride that is `synced` with a `synced_at` +/// of 0. A stamp that left as an effect but was not mirrored left that 0 in place, so the same +/// sidecar write came back on the pass after the executor answered it — one per pass, for the rest +/// of the boot, on a device whose whole power budget is not waking up. The legacy drain has always +/// mirrored at `App::retention_stamp_command`; the pass does now too, into the full ride inventory +/// as well as the display catalog, because a ride outside the newest-32 menu re-enqueues just the +/// same (finding #876-2). +/// +/// Without the mirror this fails on the first pass after the answer. #[test] fn a_stamp_that_was_answered_is_not_enqueued_again() { let mut harness = typed(); harness.app().stamp_clock_ble(1_720_000_000, 60); - let now = harness.state.app.wall_unix_now(); - harness.app().set_route_meta(&[ - RouteRetentionMeta::new(Retention::Week1, now), - RouteRetentionMeta::new(Retention::Never, 0), - RouteRetentionMeta::new(Retention::Never, 0), - ]); - harness.app().activate_route(0); + let id = harness.state.ride_ids[0]; + harness.app().set_ride_retention_inventory(&[RideRetentionRecord { id, synced: true, synced_at_utc: 0 }]); + harness.app().force_retention_sweep(); - let mut plan = harness.pass(); - let effect = plan.effects.retention.take().expect("the activation's use stamp goes out"); + let mut effect = None; + for _ in 0..8 { + let mut plan = harness.pass(); + if let Some(found) = plan.effects.retention.take() { + effect = Some(found); + break; + } + harness.serve(plan, &mut recorder()); + } + let effect = effect.expect("an acked ride with no synced_at stamp gets one"); let outcome = harness.serve_retention(effect); harness.deliver(Done::Retention(outcome), &mut recorder()); for step in 0..8 { - let plan = harness.pass(); + let mut plan = harness.pass(); assert!( - plan.effects.retention.is_empty(), + plan.effects.retention.take().is_none(), "the answered stamp came back on settle pass {step} — an endless sidecar write" ); + harness.serve(plan, &mut recorder()); } } From 263b1ab3bd0d77cae31ab392e0df2b53d7561273 Mon Sep 17 00:00:00 2001 From: timohueser Date: Mon, 24 Aug 2026 09:40:25 +0200 Subject: [PATCH 4/4] Trim investigation history from conformance test comments Co-Authored-By: Claude Fable 5 --- .../tests/device_core_conformance.rs | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/host/obc-host-core/tests/device_core_conformance.rs b/host/obc-host-core/tests/device_core_conformance.rs index 6269adf55..ebfb400a0 100644 --- a/host/obc-host-core/tests/device_core_conformance.rs +++ b/host/obc-host-core/tests/device_core_conformance.rs @@ -32,12 +32,10 @@ //! performed, and [`a_ride_finalize_failure_after_the_last_checkpoint_reaches_the_rider`] runs the //! mandated trace for real instead of pinning a gap. //! -//! **A decided sidecar stamp was rediscovered forever.** Retention re-derives its candidates from -//! the resident view — the eager ride stamp on every trusted tick — and the pass never mirrored the -//! stamp it had just issued, so the same write went out again on the pass after the executor -//! answered it, one per pass, for the rest of the boot. Found by the settle probe in -//! [`Run::finish`]: a scenario that never came to rest. -//! [`a_stamp_that_was_answered_is_not_enqueued_again`] pins it on that ride arm. +//! **A decided sidecar stamp must be mirrored into the resident view.** Retention re-derives its +//! candidates from that view — the eager ride stamp on every trusted tick — so an unmirrored stamp +//! is rediscovered and re-issued on every later pass. +//! [`a_stamp_that_was_answered_is_not_enqueued_again`] pins the ride arm. //! //! ## What Phase 1 does and does not own //! @@ -1733,16 +1731,14 @@ fn a_failed_retention_write_is_retried() { assert!(retried, "a failed write keeps its candidate and offers it again"); } -/// A decided sidecar stamp is mirrored into the resident view, so it is not rediscovered forever. +/// A decided sidecar stamp is mirrored into the resident view, so it is not rediscovered. /// -/// The second defect this gate found, and this is the arm that re-derives: the eager ride stamp runs -/// on **every trusted tick**, and re-enqueues any resident ride that is `synced` with a `synced_at` -/// of 0. A stamp that left as an effect but was not mirrored left that 0 in place, so the same -/// sidecar write came back on the pass after the executor answered it — one per pass, for the rest -/// of the boot, on a device whose whole power budget is not waking up. The legacy drain has always -/// mirrored at `App::retention_stamp_command`; the pass does now too, into the full ride inventory -/// as well as the display catalog, because a ride outside the newest-32 menu re-enqueues just the -/// same (finding #876-2). +/// The eager ride stamp runs on every trusted tick and re-enqueues any resident ride that is +/// `synced` with a `synced_at` of 0; only the mirror clears that 0, so an unmirrored stamp comes +/// back on every pass after the executor answers it. Both the legacy drain +/// (`App::retention_stamp_command`) and the pass mirror into the full ride inventory as well as +/// the display catalog, because a ride outside the newest-32 menu re-enqueues just the same +/// (finding #876-2). /// /// Without the mirror this fails on the first pass after the answer. #[test]