Skip to content

Latest commit

 

History

History
240 lines (179 loc) · 134 KB

File metadata and controls

240 lines (179 loc) · 134 KB

SPECTER-1 — Progress

Goal: Byzantine-resilient cooperative SLAM demonstrator. A swarm of agents that can map together while detecting and surviving compromised peers.

Snapshot: 2026-05-05 · post-Phase-1-entry-kit · 257 tests passed (+10 skipped under absent rclpy) · ruff + mypy clean on src/specter/. Sybil ceiling broken at all measured ratios; SLAM xy drift bounded under bouncing-wall scenarios (200 ticks → 0.5 m vs dead-reckoning's 12.95 m); SROS2 transport ready for DDS swap (ADR 0011) with per-topic QoS profiles available (ADR 0012); decay-window inflection measured at ~100 ms jitter on single_pose_liar (ADR 0013); 9-notebook workshop curriculum landed (ADR 0014); Phase 1 hardware entry kit landed — sensor adapters, agent_node + dashboard_node ROS2 modules, signed-roster bootstrap, launch/systemd/chrony deployment scaffolding, RUNBOOK v0; Phase 1 EXIT criterion (tests/integration/test_battery_multiprocess.py) ready to run with rclpy installed.


Status by component

Component % What's shipped
Phase 0 — interface ABCs 100% interfaces.py locked: SensorAdapter, LocalSlam, MessageBus, TrustEvaluator, ConsensusEngine, MapMerger, Telemetry.
Sim + viz ~95% 2D world, YAML scenarios, walls, typed sensor outputs (RangeMeasurement, IMUSample, BeaconReturn), pygame viewer with ghost-pose overlay showing SLAM-vs-truth drift. Sensor realism: lidar dropouts (P_DROP=0.02math.inf), range-dependent σ (linear ramp 1+d/10), per-agent IMU bias drift (initial σ=0.01, random-walk σ=0.0005/tick), UWB-style range beacons (DWM1000-class noise, 5° PDOA bearing, 2% NLOS multipath).
Crypto trust path ~95% ECDSA-signed envelopes, replay window, roster, version check. Five threat categories rejected with categorized errors.
Trust engine — rules + reputation ~95% Beta(α,β) per peer, two-tier range-only voting (ADR 0015): Tier 1 reciprocal-range agreement (|r(O→S) − r(S→O)| ≤ 3σ_combined, fires at any k ≥ 1, source-attributes single-side disagreement via majority-of-reciprocals); Tier 2 eigenvalue-residual classical MDS on the cohort distance matrix when k ≥ 3 (triangle-inequality lying-edge residuals discriminate single-source vs colluder-pair attacks). Cohort-close gating + agreement rewards, exponential decay (10s half-life), trust-weighted reputation gossip across per-agent evaluators. Down-weighted record_accept α (0.1) so cryptographic-validity earns some trust but behavioral signal dominates. Subject self-pose exits the trust path entirely (visible in ghost-pose viz only). Architectural property gated in CI by tests/eval/test_scale.py (min(rep) > 0.5 at B ∈ {4, 16} default; {64, 200} opt-in). Survives 2-of-N collusion via Tier 2 MDS; sybil flood via V2 self-anchored beacon defense (4:3 → 25:3 measured).
Red-team / attack library ~95% Eval harness with 12 scripted scenarios + 1 SLAM-integration test covering classical, Sybil, AND SLAM-native attack classes. Seven attack kinds + Sybil identity injection: swap_key, pose_lie, drift_pose, sensor_fuzz, replay_storm, odometry_corrupt (broken IMU → SLAM theta drifts → observation rotation), beacon_spoof (range bias on all observations) (+ heal); SybilSpec for forged-identity flooding. All Byzantine attackers cross the 0.4 detection threshold; SLAM-native attacks detect within 3-50 ticks.
SLAM stack ~65% ScanMatchSlam (src/specter/slam/scan_match.py, default in eval/runner + demo): theta still gyro-driven; xy from radial-flow LSQ over consecutive lidar scans. Drift bounded under bouncing-wall scenarios (0.5 m at 200 ticks, 0.97 m at 400 ticks — vs dead-reckoning 12.95 m / 16.85 m). DeadReckoningSlam retained as the slice-1 estimator and as backing for the negative test. MapMerger, ConsensusEngine still ABC-only. ADRs 0004 + 0007 record the design choices.
Phase 3 — SROS2 transport ~95% Sros2Bus(MessageBus) ships envelope wire bytes through DDS via std_msgs/ByteMultiArray; make_secure_node configures SROS2 signed-node enclaves; receive path composes Wave-1 identity filters end-to-end. ADR 0011 locks the carrier + dual-layer rationale. rclpy optional — marshal round-trip verified without ROS2; rclpy-gated bus + auth + battery tests skip cleanly. Wave-3 polish (per-topic QoS profiles, decay-window calibration) deferred.
Workshop curriculum 100% 9 Jupyter notebooks (notebooks/01_*.ipynbnotebooks/09_*.ipynb) decompose examples/unified_demo.py into a layered narrative tied to tests/eval/ bounds. Three cell types per notebook (intuition / claim / limit). Orchestration extracted to src/specter/demo/ (470 LOC) so notebooks reuse the same primitives the demo runs. Visualization helpers in src/specter/viz/notebook.py (matplotlib + ipywidgets, no inlined math). Five small library accessors added (cohort_events, pending_cohorts, matched_pairs, merge_with_votes, run_scenario(traces=...)) covered by tests/test_workshop_accessors.py. ABC-seam diagram (docs/abc_seams.svg) is the hardware-integration handover artifact. CI from scratch (.github/workflows/ci.yml) gates pytest, ruff, mypy, headless unified_demo smoke, and nbconvert --execute on every notebook. ADR 0014 codifies the curriculum's discipline.
ADRs ~95% 0001 (pure-Python first), 0002 (ECDSA + Beta), 0003 (canonical JSON → protobuf), 0004 (dead-reckoning SLAM choice), 0005 (sensor realism budget), 0006 (target platform: ROS2 + Gazebo + TurtleBot4 / Crazyflie+UWB), 0007 (scan-match radial-flow upgrade), 0008 (time-sync skew), 0009 (hardware attestation interface), 0010 (map-merger occupancy voting), 0011 (SROS2 transport + marshalling), 0012 (per-topic QoS), 0013 (decay-window calibration), 0014 (workshop notebooks as audit surface).

Against the full demonstrator goal: ~75%. Two halves: (a) detect/survive compromised peers — trust resilience, ~95% done; (b) map together — SLAM stack at ~50% with dead-reckoning shipped, MapMerger + transport pending. The trust engine now protects an actual SLAM-derived signal — no more ground-truth pose injection — so detection metrics reflect what real hardware would produce.

Against "Phase 1 trust engine done": ~100%. Trust engine is structurally complete and the eval battery shows it survives 12 scripted attack classes (incl. SLAM-native) plus Sybil flood.


Recent slices (most recent first)

  1. Free Play wiring fix — flight pattern / path now reach the sim + start-of-window slider thumb grabbable (ui/packages/app/src/sim/scenario.worker.ts attachFlightPattern and attachPath drop the if (spec.planner) return guard; call site in build() now does if (flightPattern) attachFlightPattern(...) else attachPath(...) so pattern wins cleanly over path; ui/packages/app/src/lib/primitives.css new .composer-range-thumb ruleset — pointer-events: none on the input track, pointer-events: auto on the ::-webkit-slider-thumb / ::-moz-range-thumb pseudo-elements + 14px round accent-colored thumbs with focus-visible outline; ui/packages/app/src/domain/freeplay/ScheduleBlock.tsx two-thumb slider inputs get aria-labels and dynamic z-index (start input raised when its thumb is in the upper half of the range; end input raised when its thumb is in the lower half) so whichever thumb is in its "unusual" half stays on top and grabbable). Root cause of pattern/path silently no-op: every Free Play bundle runs through buildSwarm() in data/scenarios.ts:64 which attaches wheelPlanner to spec.planner. composeBundles carries that planner through. The worker's two motion-attach helpers each bailed with if (spec.planner) return — designed to "preserve bespoke lesson planners," but lesson fixtures don't reach this worker at all (they take the sliceFromFixture short-circuit in simStore.ts:261). So the guard was dead weight that always swallowed the user's pattern/path selection in Free Play. Root cause of unclickable start thumb: ScheduleBlock's two <input type="range"> were stacked absolutely with width: 100%, the end input rendered second and on top of the entire track; no CSS rule for composer-range-thumb existed anywhere (className was referenced but unstyled) so the default full-width track of the end input intercepted every pointer event. Quality gates: just ui-typecheck clean (sim-core + app), just ui-test 286 passed (sim-core suite unchanged; app passWithNoTests), just ui-build succeeds, just lint clean (no Python touched). Other Free Play wiring issues called out in the same audit (PresetRow no-selector subscribe; preset chip stays "active" after manual edits; reported "path selection resets attack selections" — could not reproduce from code trace, needs user repro) are deferred to a follow-up slice.

  2. Free Play Compose-then-Watch — Wave 6: Interactive map editor + preset save/load (ui/packages/app/src/domain/freeplay/MapEditor.tsx new — interactive replacement for the Wave 5 MapPreview.tsx (which is deleted): pointer-event SVG (onPointerDown/onPointerMove/onPointerUp with setPointerCapture) gated by simStore.mapTool: "MOVE" | "BEACON" | "WAYPOINT" | "ERASE" | null; MOVE finds the nearest beacon within a 4 m hit radius and drags it via setBeaconPositions, BEACON appends the click point to beaconPositions, WAYPOINT appends to pathWaypoints and forces pathPreset = "FREEHAND", ERASE removes the nearest beacon-or-waypoint within hit radius; RESET chip clears both beaconPositions and pathWaypoints back to preset-derived geometry; "custom · unsaved" tag appears when either override is non-null; tool chips toggle (click-active again to deselect; null tool = pure preview); ui/packages/app/src/domain/freeplay/presetSchema.ts new — ComposerPreset shape ({ id, name, attackIds, attackSchedules, flightPattern, pathPreset, pathWaypoints, mapPresetId, beaconCount, beaconLayoutName, beaconPositions, builtIn? }) + loadUserPresets() / saveUserPresets(presets) round-trip through localStorage["specter1.freeplay.presets.v1"] with a versioned envelope { version: 1, presets: [...] }; defensive on corrupt/missing JSON (returns []); newPresetId() generates user-<base36-time>-<base36-rand>; ui/packages/app/src/domain/freeplay/presets.ts new — BUILT_IN_PRESETS with L02 BETA RECOVERY (liar_then_heals windowed 60→260 on WAREHOUSE_40x40) and L05 MID-MISSION FLIP (sleeper_pose_liar windowed 300→700) flagged builtIn: true so the delete button is suppressed; ui/packages/app/src/domain/freeplay/PresetRow.tsx new — preset chip row (built-ins first, then user presets, then CUSTOM), inline naming popover for SAVE PRESET (Enter saves, Escape cancels, blank name disabled), per-user-preset × delete button; selecting a chip calls loadComposerPreset(preset); ui/packages/app/src/sim/simStore.ts new state field mapTool + new actions setMapTool(tool) and loadComposerPreset(preset) (deep-clones all arrays so subsequent edits don't mutate the saved preset object); ui/packages/app/src/domain/freeplay/Composer.tsx now renders <PresetRow /> between the header and the two-column body and uses <MapEditor /> in the right column instead of the deleted MapPreview; ui/packages/app/src/domain/freeplay/MissionBlock.tsx re-enables the FREEHAND path chip with a tooltip directing users to the +WAYPOINT map tool). Closes the Free Play Compose-then-Watch redesign at full scope. User flow: open Free Play → pick a built-in preset (or stay on CUSTOM) → optionally select +BEACON and click to add a beacon, or MOVE and drag an existing one, or +WAYPOINT and click points to draw a freehand path → header shows "custom · unsaved" → SAVE PRESET opens the inline naming popover, Enter persists to localStorage → preset chip appears alongside built-ins on next page load → click the chip on reload to restore the customized layout. Key contract: loadComposerPreset deep-copies every array field (attackIds, attackSchedules, pathWaypoints, beaconPositions) so the preset itself is immutable from the store's perspective — re-applying the same preset after edits yields the original snapshot. Engineering note on the "custom · unsaved" tag: the plan called for flipping mapPresetId to null when beacons are customized; we instead keep mapPresetId populated (the editor needs the bounds context to know what map you're customizing FROM) and use beaconPositions !== null || pathWaypoints !== null as the customization signal. The Wave 4 setMapPreset(id) contract — which clears beaconPositions so the layout factory re-derives — already encodes the semantics correctly; the tag is just a visual surface. Declared deferrals: (a) MapEditor.test.tsx + presetSchema.test.ts — same blocker as Wave 5 (no jsdom + RTL setup in the app package); deferred to a "test infrastructure" slice; (b) agent count and seed in the preset schema — neither is in the store yet (Wave 5 declared deferrals carry forward); presets capture everything the store actually exposes; (c) the design's preset-row entry for "CUSTOM" as an editable name — the current CUSTOM chip just deactivates any active preset selection. Quality gates: just ui-typecheck clean (sim-core + app), just ui-test 286 passed (full sim-core suite still green; app passWithNoTests), just ui-build succeeds. Wave 6 of 6 of the Free Play Compose-then-Watch implementation (see docs/FREE_PLAY_PLAN.md) — every wave's checklist is now ticked.

  3. Free Play Compose-then-Watch — Wave 5: Compose card UI (read-only map preview, isRunning toggle) (ui/packages/app/src/domain/freeplay/Composer.tsx new — centered max-width 1180 card on Surface level={2} with header, two-column grid body (grid-template-columns: minmax(0,1fr) minmax(280px,420px)), recap line + LAUNCH ▶ button in footer; ScheduleBlock.tsx new — chip pool of selectable attacks (filters out honest + trustLayerNoop) feeding toggleAttack, each active attack renders an AttackRow with a two-thumb range slider built from two overlaid <input type="range"> (min/max thumbs absolute-positioned on the same axis, colored fill segment between them) driving setAttackSchedule(id, partial) per-thumb, start→end readout + duration tag + × remove control per row; MissionBlock.tsx new — flight-pattern chips (DEFAULT + LAWNMOWER/ORBIT/RENDEZVOUS/RANDOM_WALK) driving setFlightPattern, path-preset chips (NONE + LOOP/LINEAR/FIGURE-8/FREEHAND) driving setPathPreset with pattern-wins semantics (path chips disabled when flightPattern !== null; FREEHAND always disabled — Wave 6 enables); EnvironmentBlock.tsx new — map preset chips driving setMapPreset, beacon layout chips (PERIMETER/CORNERS/DENSE) driving setBeaconLayoutName, count range slider (0–24) driving setBeaconCount, CORNERS disables the count slider and pins display to 4; MapPreview.tsx new — read-only SVG (360×360 with 16px padding) showing bounds rect (dashed --text-low), beacon dots + 10px range halos at the beaconLayout(name, sizeM, count) positions (or beaconPositions when user-customized), waypoint path as connected <line> segments (closes the loop with an extra line when path.closed) from defaultPathFor(preset, bounds) or freehand(pathWaypoints); SummaryChip.tsx new — TopBar chip that self-gates on mode==="freeplay" && isRunning, click → exitRun(), shows attack count + motion recap + map preset; ui/packages/app/src/domain/TopBar.tsx renders <SummaryChip /> between mode tabs and the right cluster; ui/packages/app/src/screens/WorkshopConsole.tsx FreePlayLayout rewritten — when !isRunning renders <Composer />, else renders SwarmCanvas + TickScrubber + TrustPanel (no more CurriculumRail / SceneBriefing / AttackDock / AgentRepList in Free Play); ui/packages/app/src/sim/simStore.ts isRunning: boolean state (default false) + launch() action (sets isRunning=true, threads the full current config through streamForAttacks) + exitRun() action (sets isRunning=false + pauses playback); setMode resets isRunning=false on entry to freeplay and true otherwise (only freeplay reads the flag); streamForAttacks early-returns after set({ attackIds: [...ids] }) when mode==="freeplay" && !isRunning so Composer edits update state without churning the worker — LAUNCH is the only path that streams). The user-visible milestone of the Compose-then-Watch redesign: open Free Play → Composer card is the only surface besides the TopBar → pick attacks, set per-attack windows on the dual-thumb slider, choose flight pattern XOR path, pick map preset + beacon layout + count → LAUNCH → card collapses to a SummaryChip in TopBar, canvas + trust panel become visible → click SummaryChip's ✎ EDIT to return to Composer with every field preserved. Engine surface fully exercised: Wave 1's per-attack scheduling (range slider), Wave 2's flight patterns (chip set), Wave 3's path geometry (LOOP/LINEAR/FIGURE-8 preset chips), Wave 4's map presets + beacon layouts (chip sets + count slider). Declared deferrals (not silently dropped): (a) agent stepper — DRONE_COUNT is hardcoded across every attack constructor in data/scenarios.ts, making it variable is its own slice; MissionBlock shows Agents · fixed at 8 for now; (b) Composer preset row + SAVE PRESET button — lands with Wave 6 alongside presetSchema.ts + presets.ts + localStorage so the row + button + persistence ship together rather than as dead UI; (c) FREEHAND path — chip is shown disabled ("Freehand requires the map editor (Wave 6)" tooltip), Wave 6's interactive map editor enables it by setting pathWaypoints; (d) Composer.test.tsx — the app package has no jsdom/RTL setup yet (vitest runs with passWithNoTests), spinning that up is its own slice; (e) seed selector — swarmOpts.seed exists in ScenarioSpec but isn't exposed in simStore, trivial follow-up. Quality gates: just ui-typecheck clean (sim-core + app), just ui-test 286 passed (full sim-core suite unchanged; app passWithNoTests), just ui-build succeeds (production bundle generated for both worker + main). Wave 5 of 6 of the Free Play Compose-then-Watch implementation (see docs/FREE_PLAY_PLAN.md); Wave 6 (interactive map editor + preset save/load) is the only remaining slice — adds drag-to-move beacons + drag-to-draw waypoints (FREEHAND enabler) + the deferred preset row from this wave.

  4. Free Play Compose-then-Watch — Wave 4: map preset + beacon layout factory (TS + Python + simStore + worker) (ui/packages/sim-core/src/maps.ts new — MAP_PRESETS tuple of three (WAREHOUSE_40x40 4 perimeter / WAREHOUSE_60x40 6 perimeter / OPEN_FIELD_100x100 8 perimeter) + mapBounds(preset) centered on (0,0) + beaconLayout(name, sizeM, count) factory with four named modes, ui/packages/sim-core/src/index.ts exports MAP_PRESETS / mapBounds / beaconLayout / BeaconLayoutName / MapPreset / MapSize / MapBounds, ui/packages/sim-core/tests/maps.test.ts 12 tests (preset id order, default-layout-beacons-within-bounds for all 3 presets, mapBounds centered on origin, perimeter arc-length gaps equal within 1e-9, perimeter starts top-left, perimeter count=4 on square lands exactly on corners, corners always returns 4 regardless of count, dense count=9 forms 3×3 grid at expected positions, dense count=5 stays within bounds, custom throws), src/specter/sim/maps.py new — Python parity module (constant-for-constant mirror including DENSE_INSET_FRACTION=0.1 and the four-edge perimeter walk), tests/test_maps.py 15 tests (preset ids, parametrized default-layout-within-bounds × 3 presets, bounds-centered-on-origin, perimeter arc-length gaps equal, perimeter count=4 on square lands exactly on corners, perimeter starts top-left, corners-always-4 parametrized × 4 counts, dense 3×3 grid, dense count=5 within bounds, custom throws), ui/packages/app/src/sim/scenarioWorker.types.ts MapConfig = { presetId, beaconCount, beaconLayoutName, beaconPositions? } + WorkerRequest.mapConfig? + WorkerSliceTail.beaconPositions?, ui/packages/app/src/sim/scenarioWorker.ts threads mapConfig through streamSliceInWorker as a new optional fifth arg, ui/packages/app/src/sim/scenario.worker.ts resolveBeaconPositions(mc) uses beaconLayout to compute effective positions when no explicit beaconPositions provided (falls back to preset default layout if custom is requested without explicit positions — defensive worker), ui/packages/app/src/sim/simStore.ts four new state fields (mapPresetId defaults to WAREHOUSE_40x40, beaconCount + beaconLayoutName initialized from preset defaults, beaconPositions: null) + four new actions (setMapPreset(id) resets count + layoutName to preset defaults and clears positions, setBeaconCount(n) clamps to non-negative integer + clears positions, setBeaconLayoutName(name) clears positions, setBeaconPositions(positions | null) writes through directly), currentMapConfig(state) helper threads the active map into every re-stream (toggleAttack/setAttackStartTick/setAttackSchedule updated), cacheKey extended with a ~<presetId>/<layout>x<count>[:positions] suffix so cache busts on map edit, docs/FREE_PLAY_PLAN.md Wave 4 boxes ticked). The blocker for the new Compose card's ENVIRONMENT block — MAP dropdown + BEACONS selector now have a typed data layer with cross-language parity (TS↔Python) that the Wave 5 preview component reads from. Back-compat contract preserved: when no mapConfig is supplied (the legacy lesson + scenario path), the worker takes the existing AO_WORLD code path byte-for-byte and WorkerSliceTail.beaconPositions is undefined. The factory itself is purely additive — no existing wire shape, no existing parity fixture, and no existing runScenario semantics are touched. Math callout — perimeter spacing is arc-length, not Euclidean: equally spaced along a rectangle's perimeter means step s = 2(w+h)/count walked clockwise from the top-left corner; pairs that wrap a corner have shorter Euclidean distance than pairs on a single edge, so the test asserts arc-length-gap equality (≤1e-9). On a square at count=4 this happens to land exactly on the four corners, which is exercised as a sanity case in both languages. The corners mode is documented (and tested for count ∈ {1, 4, 9, 99}) as ignoring count — it's a fixed four-corner placement. dense is m = ceil(sqrt(count)) row-major-truncated grid with 10% edge inset per axis (so count=9 on 100×100 → 3×3 at ±40/0/±40 on each axis). custom throws ("custom layout requires explicit positions") in both languages — the worker handles this defensively by falling back to the preset's default layout so a malformed UI state can never crash a sim. Quality gates: just ui-typecheck clean (both packages), just ui-test 265 passed (was 253, +12 maps tests), uv run pytest tests/test_maps.py -v 15 passed, uv run ruff check src tests clean. Wave 4 of 6 of the Free Play Compose-then-Watch implementation (see docs/FREE_PLAY_PLAN.md); Waves 2/3/5/6 (planners, paths, Compose UI surface, map editor + presets) remain outstanding — Waves 2 and 3 are being built in parallel against this same main. The Wave 5 MapPreview.tsx component will read the new store fields directly; the Wave 6 map editor will set beaconPositions to a non-null override (the "last one wins" contract from the plan).

  5. Free Play Compose-then-Watch — Wave 3: path / waypoint geometry (TS + Python + simStore + worker) (ui/packages/sim-core/src/paths.ts new — Path type + loopWaypoints / linearPath (open-by-default, optional bounce) / figure8({center, a, b, samples=32}) Lissajous (x = cx + a·sin(t), y = cy + b·sin(2t) evenly over [0, 2π)) / freehand (open polyline) / pathFollower(path, {speed, arrivalThresholdM=0.2}) returning the Planner signature exported from scenario.ts:62-66 / defaultPathFor(preset, bounds) (LOOP → inset regular pentagon at 20% inset, LINEAR → bounds diagonal, FIGURE-8 → centered at a=width/3, b=height/3, 32 samples, FREEHAND → throws), ui/packages/sim-core/src/index.ts exports the new symbols + Path/PathPreset/Point2/Bounds types, ui/packages/sim-core/tests/paths.test.ts 14 tests (4 constructors + 4 defaultPathFor + 6 follower cases: closed wrap, bounce reversal, open halt, freehand halt, deterministic re-run identical, arrival-threshold honored), src/specter/sim/paths.py new — same math constant-for-constant (Path frozen dataclass, Bounds frozen dataclass, loop_waypoints / linear_path / figure8 / freehand / default_path_for / path_follower(path, *, speed, arrival_threshold_m=0.2) returning (agents, t, dt) -> {id: (vx, vy)} per-agent waypoint-index state held in closure), tests/test_paths.py mirror suite 14 tests, ui/packages/app/src/sim/scenarioWorker.types.ts PathPresetId = "LOOP"|"LINEAR"|"FIGURE-8"|"FREEHAND" + WorkerRequest.pathPreset? + pathWaypoints?, ui/packages/app/src/sim/scenarioWorker.ts threads both through streamSliceInWorker, ui/packages/app/src/sim/scenario.worker.ts builds the path (FREEHAND → freehand(pathWaypoints), else defaultPathFor(preset, AO_BOUNDS) derived from AO_WORLD.bounds) and wraps in pathFollower(path, {speed: 1.0}) attached to composed.spec.planner only when no planner was supplied already (lesson fixtures keep their own motion), ui/packages/app/src/sim/simStore.ts pathPreset: PathPresetId | null + pathWaypoints: ReadonlyArray<[number, number]> | null state (default null), setPathPreset(preset) and setPathWaypoints(waypoints) actions both re-stream via streamForAttacks, cacheKey busts on path-config edit, every existing re-stream call site now threads the current pathPreset/pathWaypoints from state so path geometry survives attack-schedule edits). Closes the Wave 3 engine slice of the Free Play Compose-then-Watch redesign — the Wave 5 Composer card's PATH chip row now has a real engine to drive. Path geometry is decoupled from attacks (lessons and attack bundles supply their own planners; Free Play paths only fire when the composed spec is plannerless), so all existing lesson fixtures and attack parity tests stay byte-exact. Quality gates: just ui-typecheck clean (sim-core + app), just ui-test 267 passed (was 253, +14 new path tests), uv run pytest tests/test_paths.py -v 14 passed, uv run ruff check src tests clean. Wave 3 of 6 of the Free Play Compose-then-Watch implementation (see docs/FREE_PLAY_PLAN.md); Wave 5 (Compose card UI) is the consumer that turns the chip row into LAUNCH-button behavior.

  6. Free Play Compose-then-Watch — Wave 2: motion planner library (TS + Python + simStore + worker) (ui/packages/sim-core/src/planners.ts new — lawnmower({ bounds, stripeM, speed }) per-stripe sweep with closure-held per-agent direction state, orbit({ center, radius, omegaRad }) closed-form next-step desired = center + radius·(cos(θ+ωdt), sin(θ+ωdt)) self-corrects radial error in one tick, rendezvous({ target, speed }) converge-then-hold with stopThreshold = speed·dt, randomWalk({ rng, speed, turnSigma }) per-agent heading diffuses by rng.gauss(0, turnSigma) each tick + constant cruise speed, makePlanner(config) dispatcher + defaultPlannerFor(pattern, { bounds, agentCount, seed }) bounds-derived defaults; ui/packages/sim-core/src/index.ts exports FlightPattern + factories + opts types; ui/packages/sim-core/tests/planners.test.ts 7 new tests (lawnmower flip-at-xmax + stripe separation, orbit radial settle + CCW sweep matches θ = π/10 after 4 ticks at ω=π/4·dt=0.1, rendezvous converge-and-hold equality between t=100 and t=120, randomWalk deterministic replay at seed 7 to 12 decimals + constant speed magnitude); src/specter/sim/planners.py new — lawnmower/orbit/rendezvous/random_walk/default_planner_for matching TS math constant-for-constant, frozen-dataclass Bounds/VelocityCommand/PlannerContext, random.Random.gauss substituted for TS Box-Muller (behavioral parity only, not byte-exact); tests/test_planners.py 7 tests mirroring TS suite; ui/packages/app/src/sim/simStore.ts flightPattern: FlightPattern | null state + setFlightPattern action threaded through streamForAttacks, cacheKey busts on pattern change; ui/packages/app/src/sim/scenarioWorker.types.ts + scenarioWorker.ts WorkerRequest.flightPattern? plumbed; ui/packages/app/src/sim/scenario.worker.ts attachFlightPattern(spec, pattern) invoked after composeBundles — only attaches when spec.planner is already undefined so lesson-supplied planners stay intact, uses AO_WORLD.bounds mapped from {minX,minY,maxX,maxY}{xmin,ymin,xmax,ymax}, RANDOM_WALK seeds an Rng from spec.swarmOpts?.seed ?? 0 for run-determinism since defaultPlannerFor throws on RANDOM_WALK without an Rng). The substrate for the Compose card's MISSION → Flight pattern segmented control — a Free Play user can now pick lawnmower/orbit/rendezvous/random walk and the worker attaches the corresponding planner without touching lesson fixtures. Composition with paths (Wave 3) and map bounds (Wave 4) is a Wave 5 UI concern; engine layer here is self-contained. Quality gates: just ui-typecheck clean (sim-core + app), just ui-test 253 passed in 25 files (full sim-core suite green, +7 planner tests), uv run pytest tests/test_planners.py -v 7 passed, uv run ruff check src tests clean. Wave 2 of 6 of the Free Play Compose-then-Watch implementation (see docs/FREE_PLAY_PLAN.md); Waves 3 (paths) and 4 (maps) are being built in parallel worktrees.

  7. Free Play Compose-then-Watch — Wave 1: per-attack scheduling (TS + Python + simStore + worker) (ui/packages/sim-core/src/attacks.ts Attacker.startTick?/endTick? + PoseAttacker.startTick?/endTick? + per-attacker filtering in applyAttacks/applyPoseAttacks, ui/packages/sim-core/src/scenario.ts runScenario + runSecureScenario resolve per-attacker windows from spec.attackStartTick/attackEndTick defaults and gate per-attacker (the global attackArmed flag still gates scenario-level injectors), tests/eval/runner.py AttackEvent.end_tick + per-tick disarms_by_tick + _disarm helper covering pose_lie/drift_pose/sensor_fuzz/replay_storm/odometry_corrupt/beacon_spoof/range_lie/colluder_pair (swap_key rejected — keypair is overwritten), tests/eval/scenarios.py liar_with_end_tick + dual_windowed_attacks, tests/eval/test_attack_battery.py 4 new tests (test_liar_with_end_tick_equivalent_to_explicit_heal, test_dual_windowed_attacks_both_recover_after_their_windows_close, test_end_tick_rejected_on_swap_key, test_end_tick_must_exceed_tick), ui/packages/sim-core/tests/concurrentAttacks.test.ts 4 new tests (single-window, non-overlapping pair, overlapping pair, spec-level default back-compat), ui/packages/app/src/sim/scenarioWorker.types.ts AttackSchedule + WorkerRequest.attackSchedules?, ui/packages/app/src/sim/scenarioWorker.ts schedules param threaded, ui/packages/app/src/sim/scenario.worker.ts applySchedule() tags every bundle's attackers with their requested [startTick, endTick) before composing, ui/packages/app/src/sim/simStore.ts attackSchedules: Record<id, AttackSchedule> state + setAttackSchedule(id, partial) action + toggleAttack auto-allocates a default {0, DEFAULT_END_TICK=900} window on add and drops it on remove, cacheKey includes schedules so cache busts on window edit, docs/FREE_PLAY_PLAN.md new plan doc with full six-wave checklist). The blocker for the new Compose card's SCHEDULE block — selected attacks now own their own [startTick, endTick) so a realistic battery (e.g. range_lie 120→400 with colluder_pair 200→260 fully inside that window) is expressible at the engine layer. spec.attackStartTick/attackEndTick remain as fallback defaults for attackers that omit their own window, so every existing lesson fixture and parity test stays byte-exact. Functional proof on the dual-window Python scenario: two concurrent sensor_fuzz attackers on non-overlapping windows (ticks 1–30 and 80–110) both recover above the HEALED bound (0.85) after their windows close — recovery impossible unless both end_tick disarms fired (β decay alone can't lift rep that high while either attacker is still corrupting observations). Quality gates: just ui-typecheck clean (sim-core + app), just ui-test 253 passed (was 249, +4 concurrent-attack parity tests), uv run pytest tests/eval/test_attack_battery.py -q 19 passed, 6 xfailed (was 15+6, +4 windowed-attack tests), uv run ruff check src tests/eval clean. Wave 1 of 6 of the Free Play Compose-then-Watch implementation (see docs/FREE_PLAY_PLAN.md); Waves 2–6 (planners, paths, map presets, Compose UI, map editor + presets) follow.

  8. ADR 0022 — full implementation across Slices 5–8 (src/specter/slam/pose_graph.py _effective_gamma + enable_reputation_tracking_gamma + GAMMA_REP_SCALE/GAMMA_FLOOR + _source_id_of_key, src/specter/slam/map_merger.py per-cell reporter set + first-seen + set_current_tick + enable_singleton_cap/enable_singleton_fade + cell_reporters accessor, ui/packages/sim-core/src/poseGraph.ts mirror port + factorWeight public accessor + enableSingletonCap/enableSingletonFade + setCurrentTick + factorProvenance + SINGLETON_INFO_SCALE/T_CORROBORATE/T_FADE + Provenance interface re-exports, ui/packages/sim-core/src/index.ts exports, tests/test_pose_graph_rep_tracking_gamma.py 5 tests, tests/test_map_merger_singleton.py 7 tests, ui/packages/sim-core/tests/poseGraph.singleton.test.ts 7 tests, docs/THREAT_MODEL.md measured-bound row, docs/adr/0022-...md §4 math direction corrected). Closes Slices 5 (reputation-tracking γ for SC fork), 6 (occupancy merger per-cell singleton defenses), 7 (TS sim-core port), and 8 (THREAT_MODEL row + this PROGRESS entry). Slice 5 — reputation-tracking γ_i(t) (P1 §4): γ_eff = γ_base · r_now · GAMMA_REP_SCALE + GAMMA_FLOOR with GAMMA_REP_SCALE = 10.0, GAMMA_FLOOR = 0.01. Drafting caught a load-bearing sign error: ADR §4 v1 wrote (1 − r_now) (low-rep → high γ) but Sünderhauf's SC prior √γ·(1−s) pulls s → 1, so closed-form s* = γ/(‖r‖² + γ) means low γ frees the switch to drop under residual evidence. Test test_rep_collapse_drives_switch_lower_than_fixed_gamma measured: a lying-landmark factor (truth at 5m, liar reports 10m) at r_now = 0.01 converges s_liar = 0.0009 under fixed γ=1.0 vs s_liar ≈ 0 under tracking γ (γ_eff = 0.11, prior released). High-rep peer at r=1.0 gets γ_eff = 10.01 → switch anchored ≥ 0.9 even on noisy residuals — the desired Bayesian trust behavior. ADR §4 rewritten with the corrected formula + closed-form derivation + explicit note that this does not close the singleton-lie case alone (no residual evidence to push s_i past the prior in single-source territory) — singleton cap + fade from Slices 3+4 remain the operative defense there. Slice 6 — OccupancyMapMerger per-cell defenses (§6): per-cell reporter set + first-seen tick persist across merge() calls; merge(fragments, *, tick=...) accepts tick; _apply_singleton_scale post-scales occ/free grids by SINGLETON_INFO_SCALE × fade(Δt) for cells with cardinality-1 reporter sets. _cast walks each ray, calling _touch_cell per traversed coord to grow the persistent set. Cap and fade are opt-in (enable_singleton_cap / enable_singleton_fade) so the existing 15 merger tests stay byte-exact. New cell_reporters() accessor for inspection. 7 tests: default-off parity, cap on single-reporter cells (occ = 0.3), multi-reporter cells untouched (occ ≥ 1.9 at 2 reporters), full-decay fade to ≈0 at insertion + T_CORROBORATE + T_FADE + 100, half-decay (occ ≈ 0.15) at the midpoint, second-reporter-lifts-cap across calls (occ jumps 0.3 → ≥0.9), cumulative reporter set accessor. Slice 7 — TS sim-core port: PoseGraphTS.addLandmarkFactor(f, { tick }) + provenance Map<key, Provenance> + factorProvenance + enableSingletonCap + enableSingletonFade + setCurrentTick + private singletonScale composed into weightOf. Public factorWeight accessor added for tests (mirrors Python's _weight_of accessibility pattern). Constants SINGLETON_INFO_SCALE / T_CORROBORATE / T_FADE / Provenance re-exported from index.ts. 7 TS tests mirror the Python suite. SC and the rep-tracking γ from Slice 5 deliberately NOT ported — SC isn't in TS sim-core yet (it's a future TS port slice independent of ADR 0022), so γ has no consumer to track. Parity fixtures NOT regenerated — every new mechanism is opt-in and dormant on every existing scenario, so byte-exact behavior is preserved for parity.json. Slice 8 — THREAT_MODEL row: new "Singleton-trusted-window lie" row in §Measured guarantees citing test_singleton_cap_composes_with_reputation_collapse. Measured bound: Ω_eff = 0.003 × Ω_base for a singleton-territory lie from a rep-collapsed reporter (cap 0.3 × rep 0.01) — vs 0.01 × Ω_base without the ADR (3.33× heavier uncorroborated-lie weight at collapsed rep; the cap's 1/0.3 ≈ 3.33× information reduction is rep-independent and composes multiplicatively with whatever the reputation prior delivers). Quality gates: pytest tests/test_pose_graph_*.py tests/test_map_merger*.py 157 passed (+12 vs pre-slice substrate baseline), pytest -q --ignore=tests/eval 377 passed, 10 skipped, 0 regressions, ruff + mypy clean (pose_graph.py + map_merger.py), just ui-typecheck clean, just ui-test 249 passed (+7 singleton tests), just ui-build succeeds with all 9 lesson fixtures regenerated. Net post-ADR-0022 architecture: P1 retroactive re-weighting closed via composition of ADR 0020 §5 un-frozen rep (shipped) + reputation-tracking SC switch prior (Slice 5); P2 singleton-uniqueness closed via cap + fade in both the pose graph (Slices 3+4) and the occupancy merger (Slice 6); cross-language parity preserved via deliberate opt-in defaults. Declared deferrals: SC port to TS sim-core (separate slice, not blocked on ADR 0022); active re-observation policy (P2 Option D from the menu — requires path-planning integration deferred to Phase 3 hardware); iSAM2-style fluid relinearization (P1 Option 7 from the menu — explicit "expensive escape valve" per the menu, only triggered if reviewer pushback emerges on switch-prior dynamics). The full-form composition Ω_eff = fade · singleton_cap · r_now · w_gnc · Ω_base (per ADR §5) is now shipping and measured.

  9. ADR 0022 — retroactive trust + singleton-uniqueness, substrate slice (docs/adr/0022-retroactive-trust-and-singleton-uniqueness.md, src/specter/slam/pose_graph.py Provenance + _record_provenance + factor_provenance + set_current_tick + enable_singleton_cap + enable_singleton_fade + _singleton_scale + _landmark_reporters bookkeeping + _weight_of composition, tests/test_pose_graph_singleton.py 7 tests). Closes the residual gap left by ADRs 0018 + 0020: a peer that lies while still trusted about a landmark only they ever observe leaves a singleton factor whose χ² never spikes (no consensus to disagree with) and whose ADR 0018 prior weight tracks only the reporter's rep — so downstream consumers can't distinguish "low-rep peer" from "uncorroborated observation". Mechanism (full-form ADR shipped): P1 retroactive re-weighting uses ADR 0020 §5's already-shipped un-frozen _reputation_source per LM step, plus a reputation-tracking switch prior γ_i(t) for weight_mode="switchable" (deferred to Slice 5). P2 uniqueness defense lands here: singleton confidence cap (landmarks with one distinct reporter scaled by SINGLETON_INFO_SCALE=0.3 — 1σ inflation 1/√0.3 ≈ 1.83×, equivalent to bumping a 5° bearing-noise model to 9°) + stale-singleton fade (linear ramp from full cap to zero over T_CORROBORATE=200 → T_FADE=200 ticks past insertion, calibrated to a 50m perimeter at 0.5 m/s ≈ 200 ticks/circuit). Composition rule per ADR 0022 §5: Ω_eff = fade · singleton_cap · r · w_gnc · Ω_base. Provenance substrate: Provenance(reporter_id, insertion_tick, reputation_at_insertion) stamped on add_landmark_factor/add_inter_robot_factor/add_loop_closure_factor when caller passes tick=; factor_provenance(factor) accessor + set_current_tick(tick) for the "now" signal that drives fade. Opt-in by default: enable_singleton_cap(True) + enable_singleton_fade(True) gate the new behavior so legacy tests (which omit tick=) get byte-exact parity — _singleton_scale returns 1.0 when the cap is disabled. Eval-first contract met: tests/test_pose_graph_singleton.py was committed first with 4 xfail(strict=True) tests demonstrating the failure mode (provenance API absent, cap absent, fade absent, composition absent); implementation lands the substrate and the tests xpass. Final test suite 7 tests: provenance recording, cap on singletons, fade-to-zero, linear-ramp midpoint, composition with rep-collapse, default-off parity, second-reporter-lifts-cap. Quality gates: pytest tests/test_pose_graph_*.py 140 passed (+7 vs pre-slice), pytest -q --ignore=tests/eval 365 passed zero regressions, ruff + mypy clean on pose_graph.py. Declared deferrals (slices written into the ADR's §Implementation slices, not silently dropped): Slice 5 reputation-tracking γ_i(t) in the SC fork (math written in ADR §4); Slice 6 OccupancyMapMerger per-cell provenance + cap + fade (mirrors the pose-graph treatment); Slice 7 TS sim-core port (parity fixtures need regen); Slice 8 PROGRESS + THREAT_MODEL update with measured-bound rows once the bidirectional/SC composition lands.

  10. SLAM lessons L09–L12 on the tick rail — integration + restyle (docs/adr/0021-slam-lessons-on-tick-rail.md, ui/packages/sim-core/src/scenario.ts SlamView + TickSnapshot.slam, ui/packages/sim-core/src/index.ts re-export, ui/packages/app/src/sim/slamLessons.ts new, ui/packages/app/src/sim/simStore.ts SLAM branch in selectLesson + prefetchLessons, ui/packages/app/src/sim/{SlamCanvas,CooperativeSlamCanvas,ClosedLoopSlamCanvas}.tsx rewritten as store-driven, ui/packages/app/src/domain/SlamMetricsPanel.tsx new, ui/packages/app/src/screens/WorkshopConsole.tsx + domain/RightPanel.tsx SLAM-aware rail swap, ui/packages/app/src/domain/SceneBriefing.tsx SLAM phase label, ui/packages/app/src/data/{lessonLayouts,sceneCopy,fixtures/index}.ts, ui/packages/app/scripts/generate-lesson-fixtures.mts skip SLAM ids, ui/packages/app/public/fixtures/slam_*.json deleted) — Closed the placeholder follow-on Wave 5 left in lessonLayouts.ts:19–21. Before: each SLAM canvas computed 2–5 stage snapshots in-component (useMemo(() => runCooperativeSlamDemo(...))) and exposed an in-canvas row of primary/secondary Buttons for stage selection, while a parallel swarm scenario (slam_loop_closure etc.) emitted 764 ticks of unrelated reputations that AgentDetailsPanel/AgentRepList/TrustPanel animated through — two views, one screen, never talking. After: TickSnapshot carries an optional slam?: SlamView payload; simStore.selectLesson(09..12) calls buildSlamSlice which runs the demo synchronously and lands N stage-snapshots in result.snapshots; the existing TickScrubber + spacebar play/pause + [/] lesson nav drive the SLAM lessons identically to swarm lessons. New SlamMetricsPanel (cost/iter/factor/closure counts + cost sparkline + SLAM-layer rep bars) replaces TrustPanel/AgentDetailsPanel/AgentRepList in both WorkshopLayout and RightPanel when lesson.trunk === "slam". SceneBriefing swaps the HONEST/WARMUP/ATTACK LIVE phase chip for STAGE N/M · stageLabel when the snapshot carries slam. L12's bidirectional ON/OFF toggle removed — its OFF mode duplicated L11, breaking the one-interaction-model rule; the L12↔L11 comparison now lives across the lesson boundary, not inside L12. Bundle: four 10.9 MB slam_*.json fixtures removed; generate-lesson-fixtures.mts skips SLAM ids and emits · 09 (slam_loop_closure) → SLAM pose-graph stages (in-process) etc. Declared deferrals (per ADR 0021 §Consequences, not silently shortcut): per-iteration LM/GN streaming inside the optimizer stays at the 2–5 stage granularity already shipped (qualitative-distinction pedagogy is right as-is); wiring the swarm BetaTrustEvaluator into the L12 SLAM optimizer as a live DCS prior is a hardware-readiness item, L12 keeps the cooperative demo's self-contained α/β accumulator per ADR 0020 reference impl. Quality gates: just ui-typecheck clean (sim-core + app), just ui-test 241 sim-core parity tests passed + app vitest passWithNoTests (no app-level test suite yet), just ui-build clean, pnpm generate-fixtures produces 8 swarm fixtures + 4 SLAM in-process lessons (was 12 swarm fixtures).

  11. Coordinated-colluder baseline test — empirical gate on the trust↔SLAM decoupling claim (tests/eval/test_baselines.py colluder section, docs/BASELINES.md § Coordinated colluders, docs/adr/0018-reputation-as-factor-information-prior.md architectural-gate note, docs/SYSTEM_ASSESSMENT.md §5 + §6 baselines update, docs/THREAT_MODEL.md Baselines section). Three new tests gate the architectural claim that residual-only mechanisms are structurally blind to geometrically-consistent coordinated lies while exogenous-prior DCS is not. Scenario: 2 honest @ 5m vs 2 colluders @ 7m, uniform σ (colluders forge advertised uncertainty), landmark initialized at the LS midpoint (6, 0) to remove seed-bias confounds. At the midpoint, both clusters' residuals are exactly 1.0m — symmetric in the residual stream. Measured: gnc/dcs/switchable each settle at err = 1.0 m (test_baseline_residual_only_modes_fail_on_coordinated_colluder); exogenous + Tier 2 rep=0.1 recovers err = 0.0 m (test_baseline_exogenous_recovers_coordinated_colluder_with_tier2_rep); exogenous WITHOUT Tier 2 rep also err = 1.0 m (test_baseline_exogenous_without_tier2_rep_also_fails_on_colluder) — control proving the win is the evidence channel, not the weighting mode. The first iteration of this test (3 honest + 2 colluders, range-dependent σ, biased init at (4,0)) inadvertently let GNC win via μ-decay majority vote; the rewrite isolates the pure architectural failure by removing each confound (equal cluster sizes, uniform info, symmetric-residual seed) and is documented in the helper docstring so future readers see why each knob is set the way it is. Closes BASELINES.md open-gap 1b and SYSTEM_ASSESSMENT.md "no baselines" residual. Quality gates: ruff + mypy clean, uv run pytest -q 425 passed, 28 skipped, 6 xfailed (4 colluder tests included).

  12. SLAM_PLAN Polishes A + B + C — Right Jacobian / sparse Cholesky / SLAM scenario (src/specter/slam/pose_graph.py right_jacobian_se2(), ui/packages/sim-core/src/poseGraph.ts umeyamaSim2() + detectLoopClosures() + runSingleAgentLoopClosureDemo(), ui/packages/sim-core/src/sparseLinalg.ts cscCholesky() + cscForwardSolve() + cscBackwardSolve() + cscCholeskySolve(), tests/test_pose_graph_se2.py right-Jacobian suite, ui/packages/sim-core/tests/poseGraph.parity.test.ts right-Jacobian parity, ui/packages/sim-core/tests/sparseLinalg.cholesky.test.ts, ui/packages/sim-core/tests/slamScenario.test.ts) — Three polish slices closing the explicit follow-ons from SLAM_PLAN Waves 1–5: (1) the deferred analytic SE(2) right Jacobian, (2) the deferred sparse Cholesky on the CSC infrastructure, (3) a single-agent loop-closure demo for the L09 lesson scene. Polish A — analytic right Jacobian per Solà arXiv:1812.01537 §4.5: closed form derived as R(-θ) · (dV/dθ) · ρ for the SE(2) ρ-coupling block, with small-angle Taylor branch at |θ|<1e-5 derived from the limit (q_x → -ρ_y/2, q_y → +ρ_x/2 — opposite sign from the v1 deferred attempt, which is exactly why it was deferred). Validated against numeric central-difference ground truth at 7 representative ξ values to ~1e-9 absolute error per matrix entry. Continuity tested across the small-angle branch boundary. Python + TS parity at 12 decimals via new parity.json right_jacobian block. The Jacobian sits next to exp_se2/log_se2 ready for the Wave 1.3+ analytic-Jacobian factor rewrite (current LM uses numeric Jacobians; analytic substitution is now unblocked). Polish B — sparse Cholesky on CSC per ADR 0016 §11 (post-v3 reality-check): cscCholesky() (up-looking factorization on dense workspace at MVP scale per the reality-check), cscForwardSolve() / cscBackwardSolve() (true sparse triangular solves over CSC pattern), cscCholeskySolve() (composed end-to-end). Validated against choleskySolve() dense reference (already shipped in poseGraph.ts) on three SPD fixtures: 3×3, tridiagonal 5×5, arrow-pattern 12×12 (representative pose-chain Hessian) to 10 decimals. SPD violation rejected (non-positive diagonal → null). AMD reordering still deferred honestly — natural ordering at N=50–200 produces negligible fill-in; AMD is a follow-on per Davis 2006 Ch. 7. The cscCholeskySolve composes behind the same PoseGraphSlam ABC, so the LM linear-solve swap is mechanical when fill-in becomes measurable. Polish C — Umeyama Sim(2) + loop closure detection + L09 end-to-end demo: umeyamaSim2() TS port (translation + rotation + scale) validated against Python algebra on 5 canonical cases (identity, pure translation, pure rotation 90°, pure scale 2×, coincident-source null). detectLoopClosures() TS port matches Python MIN_KEYFRAME_GAP=10 / MIN_SHARED_LANDMARKS=2 / RESIDUAL_TAU=0.5m semantics. runSingleAgentLoopClosureDemo() ships the L09 scene: 12-pose rectangular loop with 4 landmarks (two near p0/p11) — emits 3 snapshots (pre-optimize → post-LM → post-closure), trajectory + landmark estimates + cost + iteration count + closure edges per snapshot. Tests gate snapshot shape (3, ticks 0/1/2), LM cost reduction, ≥1 detected closure in final snapshot, 12 poses + 4 landmark estimates populated. Quality gates: ruff + mypy clean (20 source files), pytest 87 passed (+10 vs pre-polish: right-Jacobian SE(2) suite), just ui-typecheck clean, just ui-test 200 passed across 16 files (+32 vs pre-polish: right-Jacobian parity 4 + sparse Cholesky 9 + SLAM scenario 9 + Umeyama 5 + closure detection 3 + L09 end-to-end 2 + parity-fixture coverage), just ui-build succeeds. Net SLAM substrate state: every non-negotiable from ADR 0016 has shipping math + parity gate + at least one polish-level follow-on closed. Remaining documented follow-ons (not in this slice): React SlamCanvas integration consuming runSingleAgentLoopClosureDemo snapshots into Workshop Console (currently exported but not yet wired to the canvas), analytic-Jacobian factor rewrite (substrate ready; LM rewire is a localized change), AMD reordering swap when sparse fill-in becomes measurable.

  13. Coverage Parity Wave 4 — identity-lifecycle rail symmetry (bad_key rail chip + new L13 + notebook 02 live cell) + L09–L12 placeholders (ui/packages/app/src/data/{scenarios,sceneCopy,lessons,lessonLayouts}.ts, ui/packages/app/src/screens/WorkshopConsole.tsx, notebooks/02_identity_lifecycle.ipynb, notebooks/README.md, docs/COVERAGE_PARITY_PRD.md) — Final wave of the Coverage Parity initiative. Scope correction first: ADR 0015 only constrains the trust voting layer's input — the rail's visualization surface was never range-only. Wave 4 did not need a new ADR re-opening 0015; the rail can show identity ceremonies and SLAM trajectories freely (the trust math just won't react to pose lies, which L08's closing paragraph already says). Identity-lifecycle rail (mine): new bad_key AttackEntry — hardcoded rejection-event stream modeled on forged_envelope / replay_storm (A0's keypair changes mid-mission; every envelope it broadcasts fails ECDSA verification at receivers, ✗ SIG INVALID · A0 key mismatch); ATTACK_SCENE entry; LESSON_SCENE + LESSON_LAYOUTS + lessonTitleShort for new lesson L13 "Who's allowed to talk" (trust trunk; rep strip hidden because wire-layer drops don't move Beta). L13's framing is the bookend to L01 (forged_envelope): L01 stops never-joined adversaries; L13 stops joined-but-key-corrupted adversaries; identity rotation discipline (notebook 02) is what makes the recovery safe. Notebook 02 live demo added — runs single_bad_key and prints per-viewer alpha rep showing the trust evaluator is untouched (envelopes rejected at bus boundary before payload-parsing). L09–L12 placeholders: minimal LESSON_SCENE + LESSON_LAYOUTS entries for the SLAM team's L09–L12 lessons (loop closure, cooperative SLAM, Byzantine SLAM, reputation-as-factor-prior) so the rail's briefing pane renders headings/blurbs rather than falling back to L08's body. ATTACK_CATALOG entries for the 4 slam_* scenarios remain SLAM_PLAN team's deliverable. Net coverage outcome: trust + COP + identity gaps fully closed on this side; the only remaining cross-surface asymmetry is the SLAM trunk territory (L09–L12), pending SLAM_PLAN PRD completion. Quality gates: just ui-typecheck clean, just ui-build clean, notebook 02 nbconvert-executes; existing test_single_bad_key_collapses_immediately already covers the Python side.

  14. SLAM_PLAN Waves 1–5 — production-grade pose-graph SLAM MVP (src/specter/slam/pose_graph.py ~1200 LOC, ui/packages/sim-core/src/poseGraph.ts ~600 LOC, docs/adr/0017-loop-closure-detection.md, docs/adr/0018-reputation-as-factor-information-prior.md, docs/adr/0016-pose-graph-substrate-and-trust-slam-contract.md post-v3 reality-check, ui/packages/sim-core/tests/poseGraph*.test.ts, tests/test_pose_graph_*.py, ui/packages/app/src/data/lessons.ts L09–L12 scaffolds, docs/SLAM_PLAN.md) — Substrate work for the project's SLAM half. Every non-negotiable per ADR 0016 (pose graph + LM + loop closure + jointly-estimated landmarks) shipped with math defensible to PhD review plus parity-gated TS port. Wave 1.1 SE(2) (Barfoot §7.3 + Solà arXiv:1812.01537 §3–4): right-perturbation conventions, small-angle Taylor branch at |θ|<1e-5 with continuity tested. Right Jacobian explicitly deferred — would-be-shipped Taylor expansion had a sign error in ad(ξ) only validatable against LM machinery; chose deferral over shipping unvalidated math. Wave 1.2 factors + correct IMU compounding per Forster RSS 2015 §4: σ_xy ∝ t^1.5/√3 (double-integrated bias random walk) + σ_θ ∝ t^0.5 — load-bearing correctness fix vs v1's wrong linear σ·Δt. landmark_information with range-dependent σ_r = σ_beacon · (1 + d/10) + NLOS_INFLATE=3.0. Wave 1.3 LM per ADR 0016 §7 (Nocedal-Wright §10.3): central-difference numeric Jacobians ε=1e-6, dense Cholesky, adaptive λ, composite convergence (||δ||∞<1e-6 AND relative cost reduction<1e-4 AND last step accepted), 10-iter cap. Pose-0 gauge-anchored by direct elimination per §12. Wave 1.4 GNC (Geman-McClure) per Yang/Antonante/Tzoumas/Carlone RA-L 2020 [R4] §IV.A: w_i = (μ/(μ+χ²))², μ-schedule μ_0 = 2·max(χ²)/c̄² decay 1.4× floor 1e-6, weight-class-stability termination. Composes multiplicatively under exogenous reputation prior: effective info r·w_gnc·Ω per ADR 0018 §2. Wave 1.5 sparse Cholesky + AMD — honestly deferred to polish per ADR 0016 post-v3 reality-check: dense at MVP scale (~150 vars) is ~0.3 ms (1% of per-tick UI budget); sparse + AMD cuts to 0.03 ms — invisible. CSC infrastructure (sparseLinalg.ts) in place; AMD + sparse Cholesky composes behind unchanged PoseGraphSlam ABC when needed. Wave 1.6 cross-platform optimizer parity gate: parity.json optimizer section with LM-chain + GNC-outlier cases; TS converges to Python trajectory + landmarks + cost + GNC weights within POSE_TOL=1e-4 / COST_TOL=1e-6 (tighter 10/8-decimal target is post-MVP polish — numeric-Jacobian + BLAS-difference floor at ~1e-4). Wave 2 loop closure per new ADR 0017: landmark-co-visibility (MIN_KEYFRAME_GAP=10, MIN_SHARED_LANDMARKS=2, RESIDUAL_TAU=0.5m), closed-form SE(2) alignment kernel, LoopClosureFactor algebraically identical to OdometryFactor. 6/6 tests including 12-pose closure-snap (cost drops >10×). Wave 3 inter-robot + Umeyama Sim(2): InterRobotFactor (range+bearing between agents, virtual-landmark adapter), umeyama_sim2() per Umeyama 1991 [R7] §3 — recovers translation + rotation + scale. 9/9 tests. Wave 4 exogenous-prior DCS reputation per new ADR 0018: set_reputation_weight() clamps [0.01, 1.0], frozen at insertion per ADR 0016 §4. Eval: 3 honest + 1 lying landmark observations — at rep=1.0 map dragged ~1.25m off truth; at rep=0.1 recovery within theoretical 0.16m (3·5 + 0.1·10)/3.1 ≈ 5.16; monotonic error reduction as rep drops. GNC composition tested explicitly. Wave 5 lesson scaffolds: L09 SLAM 101 / L10 Cooperative / L11 Byzantine SLAM pose_lie / L12 Reputation prior added to lessons.ts. Full UI integration (canvas overlay, runScenario SLAM dispatch, lesson scenes) is documented follow-on. Quality gates final: ruff + mypy clean (20 source files), pytest tests/test_pose_graph_*.py 68 passed (SE(2) 24 + factors 12 + LM 6 + GNC 5 + loop closure 6 + inter-robot 9 + reputation 6), just ui-typecheck clean, just ui-test 168 passed across 14 files, just ui-build succeeds. Architecture preserved: existing DeadReckoningSlam/ScanMatchSlam/OccupancyMapMerger/LocalSlam ABC unchanged — pose graph is fully additive. PoseGraphSlam sibling with factor_residuals() hook per ADR 0016 §2 preserving Wave 6+ bidirectional trust↔SLAM. Hardware path (Cartographer / Crazyflie UWB) sits behind unchanged ABC per ADR 0006 addendum. Documentation trail: 3 new ADRs cite 12 references (R1–R12) including Olson 2013, Agarwal 2013, Sünderhauf 2012, Yang 2020, Kaess 2012, Forster 2015, Umeyama 1991, Mangelson 2018, Barfoot 2017, Solà 2018, Davis 2006, Huber 1964. Project's contribution explicitly framed as exogenous-prior DCS — extension of [R2] with reputation as scaling source rather than residual statistics, sitting between [R2] and [R8]'s multi-robot Byzantine framing. Next polish slices: Wave 5 full UI integration, sparse Cholesky + AMD swap behind sparseLinalg.ts, analytic SE(2) right Jacobian → 10/8-decimal cross-platform parity tightening.

  15. Coverage Parity Wave 3.5 — forged_envelope Python eval (tests/eval/runner.py, tests/eval/scenarios.py, tests/eval/test_attack_battery.py, docs/THREAT_MODEL.md, notebooks/README.md, docs/COVERAGE_PARITY_PRD.md) — Closes the last notebook-side coverage gap before the Wave 4 (SLAM + identity in rail) ADR. Runner extension: Scenario.foreign_emitters: tuple[str, ...] field — entities with valid keypairs that never enter the roster. Per-tick each foreign emitter publishes a sealed Observation envelope; every receiver's open_envelope raises VerificationError("unknown_sender") before the payload reaches the trust evaluator. Matches rail L01's forged_envelope semantics conceptually (different layer: TS rail injects via mapAttacks-style hook; Python runner uses the real envelope plumbing with no roster registration). New scenario forged_envelope in scenarios.py — single foreign emitter x0, n_ticks=80; the wire layer is the only attack surface and it's signed. New test test_forged_envelope_rejected_at_wire_boundary asserts: no detection_ticks (no real attackers), no false_positive_ticks, every real-agent → real-agent reputation > 0.9, x0 never appears in any evaluator's view. THREAT_MODEL.md row added between single_bad_key and range_lie. Cross-reference table in notebooks/README.md updates the L01 row to add notebook 08 as a live-demo source. Coverage: 7 of 7 trust-trunk + COP-layer gaps closed (Waves 1–3.5). Only Wave 4 gaps remain (odometry_corrupt rail, identity-lifecycle rail), both gated behind a new ADR re-opening ADR 0015's range-only-rail scope. Quality gates: pytest tests/eval/test_attack_battery.py::test_forged_envelope_rejected_at_wire_boundary green; broader battery (15 tests + 6 xfailed) still passes.

  16. Coverage Parity Wave 3 — Python map-attack mechanism (Item / ContactReport / MapAttack / COP filter) + 4 cop_ scenarios + notebook 07 live cell + ADR 0019* (src/specter/sim/world.py, src/specter/messages.py, tests/eval/runner.py, tests/eval/scenarios.py, tests/eval/test_attack_battery.py, notebooks/07_cooperative_map_merge.ipynb, docs/adr/0019-python-map-attack-mechanism.md, docs/THREAT_MODEL.md, docs/COVERAGE_PARITY_PRD.md, docs/WORKSHOP_UX_GAPS.md, notebooks/README.md) — Third (and largest) wave of the Coverage Parity initiative, ported the React UI rail's map-layer adversary primitives to the Python eval so notebook 07 can run them live and THREAT_MODEL.md can name measured bounds. Library additions (production-quality): Item(id, kind, x, y) in src/specter/sim/world.py — frozen dataclass for reportable point features distinguished from Landmark (SLAM jointly-estimated) by being operator-facing contacts (UXOs, FOBs, vehicles); World.items: tuple[Item, ...] field added with default-empty factory so pre-Wave-3 callers keep working. ContactReport(reporter_id, contact_id, kind, x, y, timestamp_ns) in src/specter/messages.py + KIND_CONTACT_REPORT = "contact_report" wired into _DECODER; world-frame position (operator-facing), distinct from LandmarkObservation (range-bearing measurement). Eval runner additions: MapAttack(reporter_id, phantom_contacts, suppressed_item_ids) (eval-only adversary primitive, not in src/specter/); CopEntry dataclass for COP entries; Scenario.items + Scenario.map_attacks + Scenario.cop_sensor_radius_m fields (all default-empty so non-COP scenarios pay zero overhead); per-tick contact emission loop in run_scenario (honest peers within radius report visible items, MapAttack rewrites the honest set per reporter); single shared _make_contact_collector subscriber + contact_log aggregator; build_cop(contact_log, reputations, threshold=COP_TRUST_THRESHOLD=0.5) filter that groups by contact_id, averages reporter reps across distinct reporters, keeps entries ≥ threshold (filter logic matches TS copView() in ui/packages/app/src/sim/contacts.ts); EvaluationResult.cop + EvaluationResult.contact_log exposed. Four new scenarios in scenarios.py: cop_phantom (single-reporter phantom, dropped once attacker rep collapses), cop_suppress (real item surfaces via 3 honest peers despite attacker blackout), cop_fob_corrupt (spatial-anchor variant — phantom hostile FOB dropped, real FOB preserved), cop_corruption_full (composite of all three flavors under one range_lie wrapper, mirrors the TS rail's L08 composite). Four new tests in test_attack_battery.py asserting: phantom absence in COP, real-item presence with reporter set, attacker max(honest_views) < 0.4, real-item weight > 0.85 from 3-honest-peer redundancy. Notebook 07 live cell added before "Limit" — runs cop_corruption_full, prints attacker rep + COP survival table (real vs phantom × surfaced vs dropped); CI-compatible via sys.path setup. ADR 0019 documents the full design: wire flow, trust-weighting hook (avg-across-honest-viewers consensus), composition boundary with OccupancyMapMerger (ADR 0010 — independent trust-weighted consumers, no coupling), out-of-scope items (per-receiver COP views, per-link contact gating, bidirectional trust↔COP coupling, MapAttack production-promotion). PRD wave-3-shipped annotation: turned out one slice rather than the projected ~5 because the OccupancyMapMerger kept its independence (ADR 0019 §"Composition with the OccupancyMapMerger" boundary). Docs: 4 new rows in THREAT_MODEL.md (one per scenario, with measured bounds — real items keep weight > 0.85 from 3 honest reporters; phantoms drop cleanly once attacker rep < 0.5); WORKSHOP_UX_GAPS.md updates marking cop_mislocate + cop_cosigned_phantom as scenario-construction tasks (mechanism shipped, just need new MapAttack flavors); cross-reference table in notebooks/README.md graduates L08 from "rail-only live demo" to "✓ both". Wave 3 closes 4 cross-surface gaps (cop_phantom, cop_suppress, cop_fob_corrupt, cop_corruption_full): rail-only count drops from 4 → 0 for map-layer; rail-side gaps remaining at 2 (odometry_corrupt + identity lifecycle, both gated behind Wave 4's ADR-0015-scope-reopening). Quality gates: pytest tests/eval/test_attack_battery.py -k cop_ 4 passed; full battery still passes (no regression — 14 passed, 6 xfailed); notebook 07 nbconvert-executes cleanly; broader eval suite carries from Wave 2.

  17. Coverage Parity Wave 2 — partition_gossip Python eval + notebook 04 live cell + runner link_predicate (tests/eval/runner.py, tests/eval/scenarios.py, tests/eval/test_attack_battery.py, notebooks/04_voting_triangulation_gossip.ipynb, docs/THREAT_MODEL.md, docs/COVERAGE_PARITY_PRD.md, notebooks/README.md) — Second wave of the Coverage Parity initiative (see docs/COVERAGE_PARITY_PRD.md). Runner extension: optional link_predicate: Callable[[observer_id, receiver_id], bool] | None on Scenario, threaded into _make_handler via the observation bus subscription. Gates only KIND_OBSERVATION envelopes; pose-reports and gossip bypass per ADR 0015 (gossip = eventually-consistent multi-hop). None preserves existing behavior — all 46 previously-passing eval tests still green. New scenario partition_gossip in tests/eval/scenarios.py: 4-agent four_corners with cliques {alpha, bravo} and {charlie, delta}, range_lie attack on alpha at t=10, link_predicate gates cross-clique observations. Architectural finding (documented as measured bound): cross-clique reputation does not cross the 0.4 detection threshold from gossip alone — settles at ~0.73 with GOSSIP_DISCOUNT=0.1. Bravo (same clique, direct Tier 1) detects within tens of ticks; charlie/delta (cross-clique, gossip only) see meaningful perturbation below the uniform prior 0.85 but bounded by the discount factor. This is the limit of gossip-only reconciliation — gossip is corroboration, not substitute for direct observation. New test_partition_gossip_asymmetric_detection asserts: bravo < 0.4, cross-clique views in (0.5, 0.85), gap > 0.25. Notebook 04 live cell added before the "Limit" cell — runs partition_gossip and renders per-viewer alpha rep with clique labeling. CI-compatible (uses the same os.chdir(_root) + sys.path.insert pattern as notebook 08 to reach tests.eval). Docs: THREAT_MODEL.md row + COVERAGE_PARITY_PRD wave-2-shipped annotation + notebooks/README cross-reference table updates (gap struck through; new measured-limit framing). Wave 2 closes 1 more cross-surface gap (4 → 3 remaining): forged_envelope (Wave 3 candidate), cop_corruption_full + 3 cop variants (Wave 3), odometry_corrupt + identity lifecycle (Wave 4). Quality gates: pytest tests/eval/test_attack_battery.py::test_partition_gossip_asymmetric_detection green; notebook 04 nbconvert-executes cleanly; broader eval suite carries from Wave 1.

  18. SLAM_PLAN Wave 0.5 — Pose-graph substrate (landmarks + interfaces + sparse matrix infra) (src/specter/messages.py, src/specter/sim/world.py, src/specter/interfaces.py, ui/packages/sim-core/src/{messages,landmarks,sparseLinalg,index}.ts, ui/packages/sim-core/tests/{landmarks,sparseLinalg}.parity.test.ts, ui/packages/sim-core/tests/fixtures/_generate.py, tests/test_landmarks.py, tests/test_interfaces.py, docs/adr/0006-target-platform.md) — Wave 0.5 of docs/SLAM_PLAN.md lands the substrate layer Waves 1–4 build on. No optimizer code — that's Wave 1. Five additions, all additive (no existing test breaks, no existing API changes). (a) Landmark primitive in Python sim (world.py): Landmark(id, x, y) frozen dataclass, World.landmarks: tuple[Landmark, ...] field with field(default_factory=tuple) so pre-ADR-0016 callers keep working; box_world(..., landmarks=()) kwarg. (b) LandmarkObservation wire type (messages.py): observer_id, landmark_id, range_m, bearing_rad, timestamp_ns, nlos flag; KIND_LANDMARK_OBSERVATION = "landmark_observation"; wired into _DECODER for round-trip. The nlos flag rides in the payload so the Wave 1 pose-graph factor can apply σ_r × NLOS_INFLATE = 3.0 per ADR 0016 §5 without needing to re-classify at ingest. (c) PoseGraphSlam ABC + FactorResidual dataclass (interfaces.py): sibling to LocalSlam (not replacement — composition rule documented in docstring + ADR 0006 addendum: "consumes odometry from LocalSlam.pose()"). Seven abstract methods: add_odometry, add_landmark_observation, add_inter_robot_observation, optimize, trajectory(), landmarks(), factor_residuals(). The factor_residuals() method is the preservation hook for Wave 6+ bidirectional trust↔SLAM coupling per ADR 0016 §2 — emits per-factor FactorResidual(factor_id, source_id, residual_norm, mahalanobis, reputation_at_insertion, iteration) so a future bidirectional adapter can correlate each residual with the trust state at insertion without re-architecting the optimizer. Phase 1 won't consume this; the API is preserved so Phase 2+ can. (d) TS sim-core landmarks + LandmarkObservation (landmarks.ts, messages.ts): Landmark type matches Python shape; gridLandmarks(minX, minY, maxX, maxY, spacingM) generator produces deterministic lm-{ix}-{iy} IDs (zero-padded for lexicographic stability); landmarkVisible() predicate for sensor-radius checks. (e) Sparse matrix infrastructure skeleton (sparseLinalg.ts, ~150 LOC, sibling to existing linalg.ts): CSCMatrix type matching scipy.sparse.csc_matrix storage exactly ({nrows, ncols, indptr, indices, data}); operations: cscFromTriplets (coalesces duplicates, canonicalizes ascending-row within-column order), denseToCsc, cscToDense, cscTranspose, cscMatVec, cscNnz. Determinism contract per ADR 0016 §11 + §12: no RNG, pure-functional inputs, canonical CSC form. Full sparse Cholesky + AMD reordering lands in Wave 1. Parity fixtures (_generate.py adds landmark + CSC sections): 3 landmark cases (workshop_5m, workshop_2m, offset_origin) — TS gridLandmarks byte-equal to Python reference at 12 decimals; 3 CSC cases (identity_3x3, tridiag_4x4 representing 1D pose-chain Hessian, mixed_5x5) — denseToCsc matches scipy.sparse.csc_matrix indptr/indices/data byte-equal, cscMatVec matches A @ x to 10 decimals, cscTranspose involutive at 12 decimals. Quality gates: TS — just ui-typecheck clean, just ui-test 103 passed (was 85; +18 new for landmark + CSC parity), just ui-build succeeds (worker 27.70 KB + lazy fixture chunks unchanged). Python — uv run ruff check src tests clean, uv run mypy src/specter/{trust,sim,slam,interfaces.py,messages.py} clean (19 source files), uv run pytest -q --ignore=sros2,integration,scale 262 passed, 0 failures, no regressions (full 305-test collection includes 7 new from this slice: 4 from test_interfaces.py upgrade + 5 from new test_landmarks.py; SROS2-gated tests skip cleanly absent rclpy). ADR 0006 addendum records the composition rule in one sentence; SLAM_PLAN.md Wave 0.5 checklist all ticked. Next: Wave 1 (single-agent pose graph — SE(2) + LM + GNC + sparse Cholesky + AMD + correct IMU compounding + locked-10/8-decimal parity gate, 12 days, ~1100 LOC).

  19. SLAM_PLAN Wave 0 v3 — ADR 0016 stakeholder-driven production-grade upgrade (docs/adr/0016-pose-graph-substrate-and-trust-slam-contract.md, docs/SLAM_PLAN.md) — Same-day stakeholder review of v2 (audience framing: PhD-educated investors + educators) selected three upgrades from conservative MVP defaults to production-grade-from-day-one defaults. (a) Robust kernel Huber → GNC (Geman-McClure family per Yang/Antonante/Tzoumas/Carlone RA-L 2020 [R4]); v2 had GNC deferred to a conditional Wave 4.5; v3 §8 promotes to baseline (Wave 1) with [R4] §IV.A μ-schedule (μ_0 = 2·max(||r||²)/c̄², decay 1.4×, floor 1e-6, outer-loop terminates when no factor changes weight class). Geman-McClure chosen over truncated-LS for continuous differentiability + cleaner LM Jacobians. Cost: +150 LOC over Huber. Wave 4.5 deleted (GNC no longer conditional). (b) Linear solver dense Cholesky → sparse Cholesky + AMD reordering (Davis 2006 [R11] Ch. 7); v2 chose dense at MVP scale with sparse documented as production-scale alternative; v3 §11 adopts sparse + AMD from start because that's what every production SLAM system (g2o, GTSAM, Ceres) defaults to — matches reviewer expectations from the literature. Pipeline: CSC storage → AMD symbolic reordering with lexicographic tie-breaker (determinism contract) → cached symbolic factorization → left-looking numeric Cholesky → sparse triangular solve with permutation. Cost: ~500–700 LOC in new sparseLinalg.ts (sibling to existing linalg.ts which keeps Jacobi for trust-layer MDS). Python uses scipy.sparse + AMD or sksparse.cholmod if available. (c) Parity contract empirically-validated-then-lock → locked-as-gate at 10/8 decimals; v2 said "measure across (numpy macOS-arm64, numpy linux-x86_64, TS V8, TS JavaScriptCore), lock the worst case"; v3 §14 locks 10/8 as the contract and treats Wave 1 validation as a CI gate — if any platform fails, the implementation is fixed (FLOP ordering, AMD tie-breaker, Kahan summation in Cholesky inner products), the target is NOT relaxed. Justified because GNC's μ-schedule is structurally well-conditioned (Geman-McClure weight cap bounds residuals) and AMD's deterministic tie-breaker eliminates permutation ambiguity, leaving floating-point error dominated by triangular-solve back-substitution which stays within IEEE-754 bounds at 10 decimals for our problem sizes. Two scope choices explicitly re-tagged as MVP-speed tradeoffs, not principled long-term decisions per stakeholder request: frozen factor weights (§4) kept for demo speed + parity simplicity but ADR now states this is not the long-term answer (iSAM2-style re-linearization [R5] is); three revisit triggers documented (fast-flipping liar evades window marginalization; hardware sub-second adaptation; bidirectional coupling). One-way trust→SLAM coupling (§2) kept for Phase 1 but reframed as documented future research direction with concrete preservation hooks in the implementation: per-factor residual emission on PoseGraphSlam interface, source_tag field on Beta evidence (already in BetaTrustEvaluator API), record_external_evidence(peer_id, α, β, source_tag) method preserved — so a Wave 6+ bidirectional adapter can land without re-architecting either layer. SLAM_PLAN.md updated end-to-end: Wave 0 checklist re-annotated with §-refs to v3 ADR; Wave 0.5 expands to 4 days (sparse matrix infrastructure skeleton); Wave 1 expands to 12 days, ~1100 LOC (LM + GNC + sparse Cholesky + AMD + correct IMU compounding + parity validation as CI gate + per-iteration sparse-solver-correctness eval); Wave 4 references swap Huber→GNC throughout; Wave 4.5 deleted; total estimate 6–8w → 7–9w. v3 ADR's "v2 → v3 changes" section documents the three stakeholder-driven upgrades alongside the v1 → v2 literature-audit history (full audit trail preserved). No code lands — pre-implementation correctness + scope gate. Next: Wave 0.5 (landmark world primitive + PoseGraphSlam interface + sparse infra skeleton, 4 days).

  20. Coverage Parity Wave 1 — late_range_lie + beacon_spoof + liar_then_heals upgrade (tests/eval/scenarios.py, tests/eval/test_attack_battery.py, ui/packages/app/src/data/scenarios.ts, sceneCopy.ts, docs/THREAT_MODEL.md, docs/COVERAGE_PARITY_PRD.md, notebooks/README.md) — First wave of the four-wave coverage parity initiative (see docs/COVERAGE_PARITY_PRD.md) bringing the Python notebook curriculum and the React UI rail to redundant 100% live-demo coverage. late_range_lie Python scenario added — mid-mission Byzantine flip on the range layer (attacker honest through wake_tick, then inflates range_m by RANGE_LIE_BIAS_M). Reuses the existing range_lie AttackEvent kind with a delayed attack_tick, no runner changes needed. Architectural finding from the eval: detection latency scales with accrued α at wake_tick — wake=5 detects in ~50 ticks, wake=20 in ~175, wake=60 in ~350, wake=100 undetected within 500 ticks. Accrued honest history meaningfully insulates against brief perturbation, bounded by the 10s Beta decay half-life pulling α back toward the uniform prior. This is the price of the false-positive recovery property L02 teaches — same math, two sides. Rail L05 copy corrected in sceneCopy.ts: prior claim "detection latency is identical to vanilla range_lie" was empirically false; new copy names the bound and cites test_late_range_lie_detected_after_wake. THREAT_MODEL.md row added for late_range_lie with the measured latency curve. liar_then_heals body upgraded from pose_lie (which ADR 0015 Wave 1 reclassified to a trust-noop, making the old recovery test trivially pass) to range_lie+heal (t=30..50) matching the rail's recovery_after_lie scenario. The skipped test_liar_recovers_after_healing_via_decay is re-enabled — asserts the recovery property (min(healed_views) > HEALED); the 20-tick lie window does not cross detection threshold (consistent with the late_range_lie finding — accrued α insulates) so the test asserts only recovery, with the visual dip captured in notebook 08's small-multiples trajectory. beacon_spoof React rail chip added — AttackEntry in ATTACK_CATALOG wiring the already-exported beaconSpoof from @specter/sim-core (2m bias, milder than range_lie's 4m). Dock-toggleable; no new lesson slot (Waves 1–2 maintain the 8-lesson arc per PRD scope discipline). PRD scope — Wave 1 closes 3 of the 7 cross-surface coverage gaps (late_range_lie, beacon_spoof, recovery parity). Waves 2–4 (partition_gossip eval, map-layer scenarios, SLAM+identity rail expansion) tracked in docs/COVERAGE_PARITY_PRD.md. Quality gates: new + previously-skipped tests both green (pytest tests/eval/test_attack_battery.py::test_late_range_lie_detected_after_wake + ::test_liar_recovers_after_healing_via_decay); UI gates carried from prior slice.

  21. SLAM_PLAN Wave 0 v2 — ADR 0016 amendment (literature + math-correctness audit) (docs/adr/0016-pose-graph-substrate-and-trust-slam-contract.md, docs/SLAM_PLAN.md) — Same-day rewrite of ADR 0016 after a PhD-reviewer-grade audit flagged three authenticity gaps in v1: (a) the reputation-weighting decision was un-cited; v2 adds a Related Work section ([R1]–[R12]) and reframes Ω' = r · Ω explicitly as exogenous-prior Dynamic Covariance Scaling — an extension of Agarwal et al. ICRA 2013 [R2] / Mangelson et al. ICRA 2018 [R8] where the scaling factor comes from the Byzantine-resilient trust layer rather than from residual statistics. This is the project's specific contribution and it has to be legible against the literature it sits in. (b) v1's IMU error model used linear σ × Δt compounding — incorrect for random-walk bias drift. v2 §5 adopts the correct laws per Forster et al. RSS 2015 [R6] §4: position σ ∝ t^1.5 / √3 (double-integrated random walk), heading σ ∝ t^0.5 (single-integrated). Underestimated position uncertainty in v1 by ~√Δt / 3; cost to fix ~5 LOC per factor. (c) v1 specified bare Gauss-Newton; rescinded because GN diverges on large residuals which is exactly the regime the project's own threat model creates (loop closures, NLOS multipath, Byzantine factor injection). v2 §7 specifies Levenberg-Marquardt with adaptive λ schedule (Nocedal-Wright §10.3 conventions) and §8 adds a Huber robust kernel [R12] running underneath the exogenous reputation prior — effective per-factor information becomes r · w · Ω (reputation × Huber weight × sensor). LM + Huber is the standard pose-graph optimizer stack [R5], adopted because the workshop is both a production-grade Byzantine-resilient MVP and a teaching surface — and correct math is non-negotiable for both audiences. v2 also reframes the parity contract (§14) from "asserted 12 decimals" to "empirically validated tolerance across (numpy macOS-arm64, numpy linux-x86_64, TS V8, TS JavaScriptCore)" with Wave 1 landing the validation script; initial target 10/8 decimals, locked from measurement not assertion. Linear solver decision (§11) explicitly acknowledges pose-graph sparsity and the [R11] sparse-Cholesky-with-AMD alternative; dense chosen at MVP scale (n≈150 → 3.4M FLOPs → sub-millisecond) with the FLOP justification documented rather than hidden. Wave 3 inter-agent frame alignment now cites Umeyama 1991 [R7] closed-form Sim(2) given ≥2 shared landmark correspondences. SLAM_PLAN.md updated end-to-end: Guardrails section adds "math must be defensible to PhD reviewers" and "math + threat model must be internally consistent"; Wave 0 checklist re-annotated with §-refs to v2 ADR sections; Wave 1 LOC budget 400→600 and estimate 5d→7d (LM + Huber + IRLS + correct IMU compounding + empirical parity); Wave 3 adds Umeyama Sim(2) alignment + new eval; Wave 4 reframed as "exogenous-prior DCS" with explicit composition-order checks; new Wave 4.5 conditional (+5d) for GNC [R4] adoption if Huber leaves a measurable gap; total estimate 5–7w → 6–8w. v2 ADR's "v1 → v2 changes" section documents the three caught gaps as part of the audit trail. No code lands — pre-implementation correctness gate. Next: Wave 0.5 (landmark world primitive + PoseGraphSlam interface, 3 days).

  22. SLAM_PLAN Wave 0 — ADR 0016 + trust↔SLAM contract (v1, superseded same-day) (docs/SLAM_PLAN.md, docs/adr/0016-pose-graph-substrate-and-trust-slam-contract.md, docs/THREAT_MODEL.md) — Locks the design surface for the 5–7 week pose-graph SLAM substrate plan before any code lands. Eleven decisions fixed: (1) pose graph is additive — new PoseGraphSlam ABC sibling to LocalSlam, consumes LocalSlam.pose() as odometry; DeadReckoningSlam / ScanMatchSlam / OccupancyMapMerger all coexist unchanged. (2) Trust filtering stays at ingestion per ADR 0015; pose graph runs its own residual-based outlier handling with reputation as a prior. One-way coupling Phase 1 (trust → SLAM); bidirectional deferred to avoid double-counting evidence. (3) Reputation enters as Ω' = r · Ω on factor information; ADR 0018 (Wave 4) will fix the precise reputation → r mapping. (4) Frozen at insertion — factor weights set once when added; reputation evolution does not re-weight (forces re-linearization, blows up parity). Sliding window ages out bad factors naturally. (5) Sensor noise → factor information mapped concretely: odometry uses existing IMU bias drift × Δt; landmark/inter-robot factors use the existing σ_r = σ_beacon · (1 + d/10) range-dependent model from ADR 0005 plus 5° PDOA bearing noise; NLOS-tagged observations inflate σ_r by 3×. No hand-picked information matrices. (6) SE(2) right-perturbation with body-frame increments; T ← T · exp(ξ̂) everywhere; both Python and TS follow Barfoot §7.3 conventions (codified as runnable parity tests, not just text). (7) Dense Gauss-Newton, LM deferred (initialization from odometry is always close). (8) Iteration cap 10, convergence ||δ||_∞ < 1e-6; parity fixture freezes iteration count at cap regardless of convergence so Python↔TS produce identical residuals. (9) Keyframe policy: new node on 0.1 m / 5° / 10-tick threshold; sliding window 50 active nodes per agent with Schur-complement marginalization (deterministic oldest-first order). (10) Dense Cholesky solver, extends linalg.ts (Jacobi already there); numpy.linalg.cholesky on Python side; identical to 12 decimals. (11) Gauge freedom resolved per agent: pose-0 fixed (dropped from optimization variables, not Lagrange-anchored); inter-agent frames remain ambiguous until shared landmark anchors (handled explicitly in Wave 3). (12) Determinism contract: no RNG anywhere in the optimizer pipeline. (13) Parity contract shifts for SLAM code from byte-exact (envelopes) to tolerance-based (12-decimal residuals after fixed iteration cap). THREAT_MODEL.md updated: prose section for pose_lie now flags the forthcoming second SLAM-layer detection path (pose-graph residuals composing with the existing OccupancyMapMerger path per ADR 0015's SLAM-layer split); table row unchanged (no measurement until Wave 4 lands tests/eval/test_pose_lie_distortion.py). SLAM_PLAN.md Wave 0 checkboxes all ticked, each annotated with the ADR §-reference where the decision lives. No code lands in this wave — quality-gate checks deferred to Wave 0.5; baseline uv run pytest -q re-run for sanity. Next: Wave 0.5 (landmark world primitive + PoseGraphSlam interface, 3 days).

  23. UI curriculum rail — 9 lessons → 8, every redundancy dropped, two missing defenses surfaced (ui/packages/app/src/data/lessons.ts, scenarios.ts, sceneCopy.ts, lessonLayouts.ts, fixtures/index.ts, ui/packages/app/src/sim/simStore.ts, domain/CurriculumRail.tsx, domain/LessonDots.tsx, screens/WorkshopConsole.tsx, scripts/generate-lesson-fixtures.mts, docs/WORKSHOP_UX_GAPS.md) — Pedagogical rewrite of the React Workshop Console's lesson rail driven by a coverage-matrix audit that showed 62% concept-coverage with 6 of 9 lesson-slots burnt on duplicates (3× honest opener for L01–03, 3× colluder_pair for L05/08/09) and two whole defense layers (V3 transitive presence, trust-weighted COP filter) absent from the curriculum despite shipping in sim-core and ATTACK_CATALOG. Type shape change: Lesson.attackId: stringattackIds: ReadonlyArray<string> so a single lesson can compose multiple attacks; selectLesson and the fixture loader pass the array through directly (multi-attack lessons skip the per-attack JSON fixture and stream via the worker). New 8-lesson arc: L01 "The wire is hostile" composes forged_envelope + replay_storm (defenses become visible — wire-layer drops scroll in the rejection feed instead of running honest); L02 "Reputation that forgives" runs recovery_after_lie (Beta dip + 10s-half-life decay-driven recovery both visible on sparklines); L03 "When two liars agree" = colluder_pair (Tier 2); L04 "Bad data ≠ bad actor" = sensor_fuzz (Tier 1 as fault-detector); L05 "The sleeper agent" = late_range_lie (mid-mission Byzantine flip, previously absent from curriculum); L06 "When the swarm splits" = partition_gossip; L07 "Agents that never were" = sybil_cabal (V3 transitive presence — previously zero lessons); L08 "Attacks on the map itself" = new cop_corruption_full composite. New scenario (cop_corruption_full in scenarios.ts): one rangeLie(A0, 4.0) attacker + one MapAttack unioning all three map-layer flavors (phantom UXO γ at (75,75), phantom HOSTILE FOB CLAIM at (88,88), suppressed uxo-bravo, suppressed fob-stalwart) — built as a composite because composing the three existing cop_* AttackEntry bundles would naively flatten three rangeLie(A0) wrappers and compound the inflation to 12m. Original cop_phantom/cop_suppress/cop_fob_corrupt chips remain in the AttackDock for hands-on exploration. L08's briefing closes with the pose_lie/drift_pose out-of-scope paragraph per ADR 0015 — declared limits get airtime without their own slot. Copy rewrite: every LESSON_SCENE body leads with an operator-resonance question ("If a drone has one bad tick, is it banned forever?", "What if a drone is honest for an hour, then flips?") and surfaces the underlying math without glazing (10-second half-life, OBSERVATION_PRESENCE_BETA=0.2, gossip discount 0.1, MDS τ=0.05). Hardcoded ID/count cleanup: CurriculumRail's "2/9" replaced with dynamic NN/NN from active index + LESSONS.length; lesson.index <= 2 (status dot) → === 1 (only L01 is rep-strip-silent under the new rail); LessonDots "of 09" → padded LESSONS.length; WorkshopConsole DEFAULT_LESSON = "03""02" (lands on the first lesson with visible Beta movement); lessonTitleShort map fully rewritten. Layout config: LESSON_LAYOUTS rewritten — L01 hides the rep strip (wire-layer drops don't move Beta), L02–L08 all show it; tier indicators per defense; layoutFor fallback target updated from "09" to "08". Fixture generator (generate-lesson-fixtures.mts) updated to iterate single-attack lessons only (7 fixtures generated for L02–L08, L01 streams). Docs: WORKSHOP_UX_GAPS.md rewritten with old→new lesson-id mapping table; §2 curriculum holes substantially closed (sleeper agent, V3 presence, false-positive recovery, map-layer attacks all marked shipped); §3 attack-catalog gaps updated (cop_fob_corrupt and mid-mission compromise tagged shipped). Quality gates: just ui-typecheck clean, just ui-test 85/85 sim-core parity tests pass (no sim-core changes — pure app-package edit), just ui-build succeeds (7 fixtures + 1 streamed lesson). Out of scope (deferred): no 01.json lesson-id-keyed fixture for the composed L01 (streams adequately); no cop_mislocate / cop_cosigned_phantom (still §3 gaps); no new ADR (curriculum design, not architecture decision).

  24. UI freeplay perf — Worker + sim-core hot loop + streaming chunks (ui/packages/app/src/sim/scenario.worker.ts, ui/packages/app/src/sim/scenarioWorker.ts, ui/packages/app/src/sim/scenarioWorker.types.ts, ui/packages/app/src/sim/simStore.ts, ui/packages/sim-core/src/scenario.ts, ui/packages/sim-core/src/evaluator.ts) — Three layered optimizations targeting freeplay scenario-load latency, which fixtures (Option B) can't help because freeplay slider drag re-runs runScenario with a new attackStartTick each change. Measured baseline (vite-node, honest scenario, 700 ticks): runScenario = 33s, derived passes = 12ms. Per-call profiling localized 99.9% of cost to recordObservation — specifically the unconditional _voteClosedCohorts() call (which [...this._pending.keys()]-spreads and BigInt(tsStr)-parses on every observation × 8 evaluators × 39200 observations = 314K invocations). D — sim-core targeted fixes: gate _voteClosedCohorts on actual _decayClockNs advance (skip when same-tick observations don't advance the clock — was firing redundantly), drop the keys-spread in favor of direct Map.keys() iteration in both _voteClosedCohorts and flush, cache rangeSigma() constant, parallel _pendingTs: Map<key, bigint> so cohort timestamp lookups don't reparse the key. 10× speedup: 33s → 3.4s. Scenario parity tests dropped from 1088ms → 713ms confirming the win is real. All 85 sim-core parity tests still pass (semantically equivalent — votes still fire at the same logical-clock thresholds, just not redundantly between same-tick observations). A — Web Worker: scenario.worker.ts (compiled to its own 27 KB chunk via Vite's ?worker import) hosts runScenario + 3 derived passes. scenarioWorker.ts is the main-thread API. Main bundle dropped 245 KB → 234 KB because runScenario + composeBundles no longer reach main thread (tree-shaken). cancelAllWorkerRequests() terminates the worker on each new request so slider drag doesn't queue stale work (50ms respawn cost is invisible). LRU scenario cache stays on main thread for re-toggle hits without worker round-trip. C — Streaming chunks: added optional chunkSize + onChunk to runScenario (backward-compatible — no callback = no chunking). Worker invokes callback every 50 ticks, forwards via postMessage as {kind:"chunk", chunk, soFar, total}; emits final {kind:"done", tail} with derived passes after the full run. Store accumulates snapshots as chunks arrive, sets isPlaying=true once ≥30 snapshots are available so playback starts before compute finishes. loadingProgress exposed via useLoadingProgress() for UI affordance. Measured: first chunk at 220ms (vs 33s original blocking), full compute completes at 3.6s in the background while playback runs at 35Hz (worker produces ticks ~6× faster than playback consumes them, so no buffer underrun). Derived passes (detectionMap, contactReports, phantomWitnesses) deliberately deferred to the final done message — item-detection overlay shows "UNMAPPED" during streaming, fills in at ~3.6s. Acceptable since playback continues regardless. Lesson load path unchanged: fixture-loaded scenarios bypass the worker entirely (they were already instant). Net UX: freeplay attack toggle perceived load 33s → 220ms (150× improvement). Quality gates: just ui-typecheck clean, just ui-test 85/85 parity tests pass, just ui-build succeeds (main 74.24 KB gzip, worker 27.70 KB, fixtures 5 chunks lazy-loaded).

  25. UI load-time — pre-rendered lesson fixtures (ui/packages/app/scripts/generate-lesson-fixtures.mts, ui/packages/app/src/data/fixtures/, ui/packages/app/src/sim/simStore.ts, ui/packages/app/src/screens/WorkshopConsole.tsx, justfile) — Eliminates runScenario cost for the 9 workshop lessons by pre-rendering scenarios at build time and lazy-loading the result. Generator (vite-node-runnable): walks LESSONS, dedupes by attackId (lessons 01/02/03 share honest, 05/08/09 share colluder_pair → 5 unique fixtures for 9 lessons), runs runScenario + serializes snapshots / coverageGrid / mapAttacks / rejections to src/data/fixtures/<attackId>.json. Custom marker encoding for non-JSON types: {$bigint: "…"} for Observation.timestamp_ns, {$i32: […]} for CoverageGrid.firstSeen Int32Array. Derived passes (detectionMap / contactReports / phantomWitnesses) deliberately excluded — they're cheap relative to runScenario and recomputed at load-time from the cached snapshots. Loader (src/data/fixtures/index.ts): import.meta.glob with ?raw query → Vite code-splits each fixture into its own ~1MB-gzip chunk; loadLessonFixture(id) parses on first request and caches the promise. warmFixtureCache() fires from WorkshopConsole's mount effect (wrapped in requestIdleCallback with setTimeout fallback) to prefetch all 5 chunks during idle. Store wiring: selectLesson tries fixture path first via sliceFromFixture() (synthesizes a stub ScenarioSpec carrying only attackStartTick + a sentinel attackers length, plus a {snapshots} cast that satisfies ScenarioResult — the app reads neither evaluator nor agentEvaluators); falls back to buildSlice([attackId]) only if the fixture is missing. Freeplay mode (setActiveAttacks, toggleAttack, setAttackStartTick) still runs the live runScenario — fixture path is workshop-only by design. Discipline: regen fixtures whenever sim-core or scenarios.ts changes (analogous to the existing Python→TS parity fixture pattern). just ui-build now depends on just ui-fixtures-lessons; fixtures gitignored to keep the repo lean. Quality gates: just ui-typecheck clean, just ui-test 85/85 sim-core parity tests pass, just ui-build succeeds — main bundle 77.75 KB gzip (unchanged), 5 fixture chunks 820–1185 KB gzip each (lazy-loaded). Next: option A (Web Worker) and option C (streaming chunks) for Freeplay mode, where runScenario cost still hits.

  26. UI playback perf — Workshop Console memoization pass (ui/packages/app/src/sim/SwarmCanvas.tsx, ui/packages/app/src/sim/simStore.ts, ui/packages/app/src/sim/useTickLoop.ts) — Cuts per-tick React reconciliation work in SwarmCanvas. Stable empty refs (EMPTY_AGENTS, EMPTY_OBSERVATIONS, EMPTY_REPS, EMPTY_MAP_ATTACKS, EMPTY_PHANTOM_WITNESSES, EMPTY_REJECTIONS) replace inline ?? [] / ?? {} and prop default = [] — fixes the silent memo failure where every child got new []/{} refs each render. React.memo on SectorGrid, AOOutline, Legend (zero-prop static layers — now skip 100% of ticks), Fog, SensorFootprints, GossipMesh, Phantoms, Rejections (skip on hover-only re-renders), and AgentNode with custom equality on (agent.x, agent.y, agent.theta, agent.id, agent.phantom, rep, status, hovered, dimmed, onHover) — agent ref changes every tick so default shallow equality never skipped; custom eq enables N-1 agents to skip when hover moves between agents. useTickLoop swapped from setInterval(1000/hz) to requestAnimationFrame + period accumulator — playback now aligns to display refresh, pauses automatically when tab hidden, no callback stacking when a tick exceeds budget. startTransition wraps set(...) in loadScenario / setActiveAttacks / setAttackStartTick / selectLesson so input handlers stay responsive while React reconciles the new snapshot tree. No automated eval — perf is qualitative; verify with React DevTools Profiler before/after. Quality gates: just ui-typecheck clean, just ui-test 85/85 sim-core parity tests pass, just ui-build 242.58 kB bundle (unchanged from pre-slice).

  27. Range-only trust voting — Option B end-to-end (src/specter/trust/evaluator.py, src/specter/trust/mds.py, tests/eval/test_scale.py, ADR 0015, 5 waves) — Closes the architectural fault uncovered by the four-agents-corridor demo: world-absolute voting compares cross-frame projections of private SLAM coordinates that don't share an origin, so honest rotating peers (bravo, delta) collapse to rep ≈0.23. Replaces it with two-tier range-only voting. Wave 0 — wire format: Observation.rel_x, rel_y → range_m, bearing_rad (clean break, ADR 0003 amended); demo + ROS 2 + sybil paths ship raw beacon scalars (no SLAM projection). Wave 1 — Tier 1: reciprocal-range agreement |r(O→S) − r(S→O)| ≤ RANGE_RECIPROCAL_K_SIGMA · σ_combined (=3σ, with σ_combined = sqrt(2·σ_beacon² + σ_NLOS²)). Cohort restructured from claim-tuples to range-tuples (_obs_ranges, _subj_ranges). Source attribution via per-peer disagreement count; the geometric_inconsistency anomaly category is renamed range_inconsistency. Wave 2 — Tier 2: new src/specter/trust/mds.py (pure-numpy, ~120 LOC) with embeddability_score, per_point_residuals, lying_edge_residuals (triangle-inequality-based blame localization). When k ≥ 3 and embeddability_score(D) > MDS_EMBEDDABILITY_TAU = 0.05, MDS fires β proportional to lying-edge residual mass; single-source (peer in ≥2 lying edges, ≥2× others) vs colluder-pair (exactly one lying edge) attacks discriminated. Two new attacks land: range_lie(target, bias_m=3.0) (Tier 1) and colluder_pair(a, b, bias_m=3.0) (Tier 2). Wave 3 — scale: procedural N-bot scenarios in tests/eval/test_scale.py with mixed kinematics (half rotating, half translation-only). min(rep across all viewer→peer pairs) > 0.5 gated at B ∈ {4, 16}; B ∈ {64, 200} and the per-tick wall-clock baseline opt-in via SPECTER_SCALE=1 (mirrors SPECTER_BATTERY_MULTIPROCESS=1 pattern). just scale-check runs the full sweep. Wave 4 — curriculum + docs: THREAT_MODEL.md rewritten (pose-lie reclassified to trust-layer no-op; range_lie + colluder_pair rows added; Tier 1/Tier 2 mechanism described); RUNBOOK.md anomaly category swap (outlier_observation + geometric_inconsistencyrange_inconsistency); ADR 0015 status: Accepted; ADR 0003 amendment. Subject self-pose exits the trust path entirely_self_poses retained in snapshot.py for ghost-pose viz only. V2 self-anchored beacon sybil defense composes orthogonally (sybils filtered before Tier 1/2 fires). Side effect: four-agents-corridor scenario reputation collapse silently disappears (SLAM drift no longer enters trust). 260 tests passed (+10 skipped under absent rclpy + 3 skipped under absent SPECTER_SCALE). ruff + mypy clean.

  28. Phase 1 hardware entry kit — sensor adapters, agent_node, dashboard_node, launch + systemd + chrony + RUNBOOK (src/specter/ros2/, tools/gen_roster.py, launch/, infra/, docs/RUNBOOK.md, US-101 → US-142, commits 10d9dbf + acfd7c1+) — Closes the deployment-scaffolding gap between "sim 100%" and "Gazebo + ROS2 single robot". Five agents across four waves: Wave 0 foundationadapters.py (LidarAdapter / ImuAdapter / UwbAdapter; pure translators work without rclpy), roster_loader.py + tools/gen_roster.py (operator keygen ceremony → signed YAML manifest; mode-600 keystore enforcement; --dry-run produces parseable canonical-JSON-signed body). Wave 1 agent-nodeSpecterAgentNode composes per-robot trust + SLAM stack (ScanMatchSlam + OccupancyMapMerger + BetaTrustEvaluator + ReplayWindow) onto any MessageBus; deployment uses Sros2Bus, headless smoke uses InProcessBus; identity_store.load_identity reads PEM private key from disk and refuses world/group-readable files. --dry-run pushes one forged envelope through the receive chain and asserts bad_signature classification — no rclpy required. Wave 1 dashboardSpecterDashboardNode is a read-only operator station mirroring the demo's right pane (rep bars + sparkline + anomaly stream + per-robot belief thumbnails per US-122) without ground-truth dependencies; runs under SDL_VIDEODRIVER=dummy for headless smoke. Render helpers (to_screen, draw_history_overlay, draw_merged_grid, draw_anomaly_panel) lifted to src/specter/viz/dashboard.py so demo + dashboard share one implementation. Wave 2 launchlaunch/single_robot.launch.py (Gazebo TurtleBot4 + agent + dashboard + chronyd dependency), launch/swarm_gazebo.launch.py (4-robot multi-process), infra/chrony.conf (NTP pool + makestep), infra/systemd/specter-agent@.service (templated by agent_id, depends on chronyd, crash-loop guard), infra/systemd/specter-dashboard.service, tests/integration/test_battery_multiprocess.py (Phase 1 EXIT criterion: spawns N rclpy workers via multiprocessing.Process, runs scenarios over real DDS, asserts detection within 2 × InProcessBus baseline + 5 ticks; gated behind SPECTER_BATTERY_MULTIPROCESS=1 opt-in flag and RCLPY_AVAILABLE so dev-machine pytest -q skips cleanly). justfile: gen-roster, agent-node, dashboard, gazebo, battery-multiprocess targets. Wave 3 runbookdocs/RUNBOOK.md v0: bring-up procedure, anomaly category dictionary (12 categories with first-check + response per row), partition response, time-sync loss procedure, key rotation/revocation procedure. HARDWARE_READINESS Phase 1 row struck through with link to commit; operational artifacts checklist updated (5 of 6 items done; per-robot calibration deferred to Phase 2). 18 new unit tests (10 agent_node + 8 dashboard_node); pytest 257 passed / 10 skipped (was 224 / 10 pre-PRD). ruff + mypy clean. Out of scope (deferred to subsequent PRDs): policy decisions (watchdog/fail-safe/partition/time-sync-loss/leave-rejoin — 5 ADRs + scenario evals + runtime branches), Phase 2 single-TurtleBot4 calibration, Phase 3 small-swarm radio QoS finalization, Phase 4 Crazyflie+UWB C/Rust evaluator port + real TPM/SE attestation.

  29. Workshop curriculum landed — src/specter/demo/, viz/notebook.py, 9 notebooks, CI from scratch (W0–W10, ADR 0014) — Decomposes examples/unified_demo.py (1138 → 454 LOC main) into a layered narrative covering signed envelopes + bus, identity lifecycle, Beta + decay, voting/triangulation/gossip, sim + sensor realism, dead-reckoning vs scan-match SLAM, cooperative map merge, full attack-battery tour (12 scenarios), full Byzantine swarm composition + SROS2 appendix. Demo refactor (W0): orchestration helpers (build_swarm, step, apply_attack, snapshot, RenderSnapshot, StreamTelemetry) extracted to src/specter/demo/orchestration.py (648 LOC) so notebooks reuse the same primitives the demo runs — no parallel reimplementation; headless smoke (SPECTER_DEMO_MAX_TICKS=5 SDL_VIDEODRIVER=dummy) preserves behavior. Library accessor extensions (W1, eval-first): BetaTrustEvaluator.cohort_events() (CohortEvent dataclass), BetaTrustEvaluator.pending_cohorts(), ScanMatchSlam.matched_pairs() (opt-in via record_pairs=True, no allocation cost when off), OccupancyMapMerger.merge_with_votes() (returns binary + occ + free grids), run_scenario(traces=dict[viewer, ReputationTrace]). All additive; signatures unchanged. 6 tests in tests/test_workshop_accessors.py. Renderer module (W2): src/specter/viz/notebook.py (~340 LOC, matplotlib + ipywidgets + scipy). 11 helpers: world_figure, world_animation, beta_pdf, decay_trajectory, reputation_sparkline, cohort_timeline, triangulation_2d, slam_trajectory_overlay, radial_flow_diagram, merge_three_panel, merge_side_by_side, rep_smallmultiples. Each helper returns a matplotlib.figure.Figure; smoke-tested under MPLBACKEND=Agg. 15 tests in tests/test_viz_notebook.py. docs/abc_seams.svg static diagram — hardware-integrator handover artifact embedded in notebook 09. [workshop] extra in pyproject.toml (jupyterlab, matplotlib, ipywidgets, scipy, nbclient). CI from scratch (W9, .github/workflows/ci.yml): four jobs (test, lint, notebooks, demo-smoke). Notebooks job runs MPLBACKEND=Agg jupyter nbconvert --to notebook --execute notebooks/*.ipynb end-to-end. justfile additions: workshop, workshop-check, demo-smoke. mypy overrides for pre-existing pygame_viz + yaml stubs (methodology-noted ignorable) so just lint returns clean. ADR 0014 codifies the import-only + intuition/claim/limit cell discipline. 224 tests pass + 10 skipped (rclpy absent in CI). ruff + mypy clean.

  30. Wave-3 polish — per-topic QoS, decay-window calibration, reputation-history overlay (src/specter/transport/qos.py, tests/eval/test_calibration.py, ADR 0012, ADR 0013, US-050 → US-052) — Closes the Wave 3 polish PRD. Per-topic QoS (qos_for_topic(topic) -> QoSProfile): pose/beacon BEST_EFFORT depth-1 with 50ms lifespan (latest-wins, drop stale); observation RELIABLE depth-20 with 100ms lifespan (cohort vote needs the full set); reputation RELIABLE depth-10 with 1s lifespan (gossip is async, eventual delivery matters); unknown-topic fallback RELIABLE depth-10 (mirrors pre-Wave-3 default). Sros2Bus(qos_for_topic=qos_for_topic) materializes per-topic rclpy QoSProfiles; default behavior unchanged when omitted. ADR 0012 records the per-threat decomposition. Decay-window calibration sweep (tests/eval/test_calibration.py::test_decay_window_jitter_inflection): runs single_pose_liar under LossyBus(jitter_max_ms=ms) for ms ∈ {1, 5, 10, 25, 50, 100} at fixed seed 1729, measures detection-tick degradation vs no-jitter baseline (tick 2). Result: inflection at ~100 ms (detection latency 5 ticks, 2.5× baseline). Up to 50 ms absorbed without crossing the 2× budget. Per spec, no constants change in this PRD — the table is evidence for ADR 0013; constant tuning is a follow-on slice once Phase 1+ hardware produces real-radio jitter measurements. Reputation-history overlay in examples/unified_demo.py: ReputationTrace instantiated and fed each tick from per-agent gossip snapshots; lower-right 200×100 px panel renders the viewer's α/β trajectories per peer over the last 10s as colored sparklines. Existing controls / visuals untouched. Headless smoke (SDL_VIDEODRIVER=dummy) runs ~5s without exception. 204 tests pass + 10 skipped (rclpy absent in CI). ruff + mypy clean.

  31. SROS2 transport swap — Sros2Bus, marshalling, signed-node setup, battery smoke (src/specter/transport/sros2_marshal.py, src/specter/transport/sros2_bus.py, examples/ros2_demo.py, ADR 0011, US-040 → US-043) — Closes the largest hardware-direction delta in this PRD: the same trust + SLAM stack now runs over real ROS2/DDS without any envelope-contract changes. envelope_to_ros_msg / ros_msg_to_envelope carry the canonical-JSON envelope_to_wire(env) bytes inside std_msgs/ByteMultiArray.data (one-byte entries — rclpy's expected shape) so signature semantics survive transport unchanged; ADR 0011 records the carrier choice and why a typed .msg would invalidate signatures by reformatting bytes. make_secure_node(name, keystore_path) configures rclpy for SROS2 signed-node DDS (ROS_SECURITY_KEYSTORE, ROS_SECURITY_ENABLE=true, ROS_SECURITY_STRATEGY=Enforce) before rclpy.init(). Sros2Bus(MessageBus) ships publishers/subscribers per topic at default queue depth 10 (Wave-3 polish swaps per-topic QoS); receive path composes Wave-1 work end-to-end: validate_timestamproster.lookupRevocationList.is_revokedAttestationProvider → handler, with typed AnomalyEvent emitted on each rejection. Dual-layer signing kept (envelope + SROS2): per-threat decomposition in ADR 0011 shows neither layer subsumes the other — envelope catches bus-internal forgery and replay even with a valid SROS2 cert; SROS2 catches off-domain attackers without a node enclave. Smoke battery (examples/ros2_demo.py, tests/test_sros2.py::test_battery_under_sros2) runs 4 representative scenarios over Sros2Bus: single_pose_liar, replay_storm, sybil_flood (4:3), sybil_flood_mutual (4:3); assertions require detection within 2 × baseline + 5 ticks of the InProcessBus baseline. rclpy is an optional dependency — marshal-only tests (4) use a _StubByteMultiArray stand-in so wire round-trip is verified without ROS2 installed; rclpy-gated tests (8) skip cleanly when absent. 196 tests passed + 8 skipped (rclpy absent in CI). ruff + mypy clean on src/specter/transport.

  32. Trust-weighted map merger + loop closure (src/specter/slam/map_merger.py, src/specter/slam/loop_closure.py, ADR 0010, US-010 → US-013) — Closes the cooperative-SLAM half. OccupancyMapMerger(MapMerger) decodes per-peer fragments, ray-casts each non-dropout beam from the fragment's reported pose, accumulates peer_weights[agent_id]-scaled free votes (cells traversed) and occupied votes (endpoint cell), and emits a binary grid where occ > free per cell. Pose travels in the fragment as an additive pose field — the locked LocalSlam.map_fragment ABC is unchanged; encode_fragment helper builds the merger-compatible payload at fragment-publish time. Honest 4-peer eval (tests/eval/test_map_convergence.py) on four_corners.yaml for 200 ticks at 0.2 m/cell res with a 120-beam lidar: recall ≥ 90%, precision ≥ 95% (typically 92% / 100%, 0 FPs). Adversarial eval: 1 peer publishes 10x-amplified fragments computed against +2 m offset walls, walking a 4×4 grid of poses to spread fake votes; with peer_weights={liar: 0.1} the merge holds at recall ≥ 85% / precision ≥ 90%; with uniform weights the same input drops precision below 70% — the load-bearing demonstration that the trust scalar governs the merge. detect_loop_closure(scan, pose, merged_map, threshold_m=0.3) returns True iff ≥ 50% of valid scan endpoints land within threshold of an occupied cell; min-3-valid-beams guard prevents a dropout-only scan from triggering. Pose correction is explicitly out of scope for this slice — the detection signal is the deliverable; downstream code wires it into pose-graph optimization or visualization. 13 new tests; 190 total passing; ruff + mypy clean on src/specter/slam.

  33. Transport realism — LossyBus, async cadences, time-sync skew filter, battery under loss (src/specter/transport/, ADR 0008, US-020 → US-023) — Surfaces the radio-realism failure modes that InProcessBus hides, and proves the trust engine is bus-quality-aware before the SROS2 swap. LossyBus(MessageBus) wraps any inner bus with seeded-RNG drop / jitter / reorder; logical-clock heap with sequence tiebreaker yields deterministic flush order, so reproducibility holds even under stochastic loss. Optional order_key callable preserves per-key FIFO (used in the battery test with sender_id as key — models real radio TX-queue ordering so jitter doesn't induce within-sender nonce reorder that the replay window would correctly reject as a sender-side bug). cadence_dispatch() runs Simulation.tick() repeatedly and dispatches lidar (10 Hz), IMU (200 Hz), UWB (10 Hz) callbacks at configurable rates without modifying core sim — exercises the trust engine's logical-clock design against the real ROS2 sensor-rate matrix. validate_timestamp(envelope_ts_ns, wall_clock_ns, max_skew_ns=5_000_000_000) is an outer skew filter (wall-clock comparison; raises clock_skew_future/clock_skew_past) layered in front of the existing replay-window nonce check; not auto-wired into bus flows — sros2-agent integrates it at the DDS receive point in Wave 2. ADR 0008 records the 5s default rationale (covers normal ROS time-sync drift; tighter than ROS2's default but loose enough to forgive pre-NTP-converged peers). Battery under loss: existing scenarios (single_pose_liar, replay_storm, sybil_flood at 10:3, sybil_flood_mutual at 10:3) re-run through LossyBus(drop_prob=0.05, jitter_max_ms=10) via monkeypatch on the eval runner's bus factory; every attacker still crosses the 0.4 detection threshold and detection latency stays within 2 × baseline + 5 ticks. 19 owned tests pass; full suite (190 tests) green; ruff + mypy clean on src/specter/transport.

  34. Identity layer — runtime roster, rotation, revocation, attestation (src/specter/identity/, ADR 0009) — Closes the V2 sybil residual identified in THREAT_MODEL.md and lifts the "frozen roster at swarm formation" Phase 1 limitation. Four composable pieces, all drop-in at the bus boundary without modifying secure_bus.py: MutableRoster wraps Roster with timestamped add/remove/rotate + append-only audit log; KeyRotationAnnouncement + verify_rotation + apply_rotation implement standard sign-the-new-key-with-the-old-key rotation, with explicit key_revoked_post_rotation rejection for envelopes signed by rotated-out keys; RevocationList is forward-only key blocklist (is_revoked(t_ns)); AttestationProvider ABC + MockAttestationProvider(allowlist) + AttestationRequiredFilter close the V2 residual in software (mock impl; real TPM/SE integration is Phase 4 per ADR 0009). 21 tests across tests/test_identity.py + tests/eval/test_identity_attacks.py cover: time-aware roster lookup, remove-then-readd, old-key envelopes post-rotation rejected, forward-only revocation, attestation blocks sybil-minting from a compromised honest robot. Threat-model updated: hardware-key-compromise residual now has a partial fix; closed-roster assumption replaced with operator-controlled-roster.

  35. Scan-match SLAM upgrade (src/specter/slam/scan_match.py, ADR 0007) — Replaces dead-reckoning's held-velocity xy with radial-flow velocity recovery from consecutive lidar scans. For each beam matched between scans, Δr ≈ -(vx_body cos θ + vy_body sin θ) × dt; pool 16 beams via 2×2 closed-form LSQ, recover body-frame velocity, rotate to world frame, integrate. Theta still from gyro. Beam-jump filter (|Δr| > 0.5m) skips corner crossings. Eval-first: wrote tests/eval/test_slam_drift.py first to lock in the dead-reckoning breakdown empirically (200 ticks → 12.95m drift at first bounce); the negative test still gates that bound. Scan-match drift: 0.41m at 80t, 0.50m at 200t (26× better), 0.97m at 400t (17× better). Asserted bounds: <1.5m at 200t, <2.0m at 400t. DeadReckoningSlam retained for the negative test. eval/runner + unified_demo switched to ScanMatchSlam by default. All 12 attack-battery scenarios + 9 sybil-scale tests still pass without threshold changes (118 tests total).

  36. Beacon-presence Sybil defense V2 — self-anchored (src/specter/trust/evaluator.py) — V1 (any-grantor) fell to a smarter attacker that has sybils publish fake mutual Observations of each other; cabal-internal grants pass V1 trivially. Empirically confirmed: V1 at 4–10:3 mutual leaves sybils at rep ≈0.99 untouched. V2 swaps the rule: when self_id is set (per-agent eval mode), only the evaluator's own beacon Observations count as presence grants. Roboticist's anchor: I trust what my own sensor saw; I distrust what I didn't. Sybils have no body, so self's real beacons never list them as subject — caught from every honest agent's perspective regardless of how the cabal mutually corroborates. Plus a per-record_observation β rule (weight 0.2) penalizes observers claiming to see subjects without self-presence — balances per-envelope record_accept α so high-volume mutual fake-corroboration can't outpace the rule on count. New scenario sybil_flood_mutual in the eval harness: each sybil publishes fake Observations of every other sybil. Detection holds at 4:3, 5:3, 10:3, 25:3 (max sybil rep 0.16→0.18→0.23→0.28); 100:3 not asserted in CI due to runtime cost (~800k envelopes) but trajectory suggests it'd hold. Honest swarm: zero false positives. Existing 12-scenario battery: unchanged thresholds, all pass. Standalone-evaluator unit tests fall back to V1 logic (any non-self grantor) when self_id is None.

  37. Beacon-presence Sybil defense V1 (src/specter/trust/evaluator.py) — Per-evaluator state _seen_by: dict[subject, dict[observer, ts]], populated on every record_observation. Two enforcement points: (a) record_pose_report accumulates β=1 when the peer has no recent beacon corroboration (window=2s) and the evaluator has at least one observation on file (bootstrap-aware); (b) _vote zero-weights observers without presence so the weighted median tips toward physically-corroborated peers. Sybils have no body — honest robots' real beacons never list them as subject_id — so they fail both checks by construction. Result: all four xfails (5:3, 10:3, 25:3, 100:3) flip to pass; honest swarm has zero false positives; existing 12-scenario battery still passes. V1 limitation: smarter attackers that have sybils publish fake mutual Observations of each other still escape — observer-rep-weighted presence (V2) is the proper fix and is now the next defensive slice. Unit tests use a seed_presence helper to mirror the mutual-beacon pattern that real eval scenarios get for free.

  38. Sybil-scale eval (tests/eval/test_sybil_scale.py) — Parametric sweep over sybil_flood(n_sybils=N) for N ∈ {4, 5, 10, 25, 100}. Finding: ceiling is exactly 4:3. At 5:3 and beyond, every sybil holds reputation ≈0.99 from honest viewers — the structural majority wins decisively (no graceful degradation). The 4:3 case passes only because honest peers vote on each other in publish-order before alpha's cohort closes; with one extra sybil that head-start is overwhelmed. Encoded as 1 PASS (4:3) + 4 strict-XFAIL (5:3, 10:3, 25:3, 100:3) so the rows flip to xpass when real Sybil resistance lands. Confirms: no production change can assume current detection generalizes past 4:3.

  39. Local SLAM + sensor realism (6-slice plan, this work) — Replaced sim ground-truth pose with DeadReckoningSlam per agent (gyro from IMU drives theta, world-frame velocity held from init seed). Added sensor realism: lidar dropouts + range-dependent σ, per-agent IMU bias drift, UWB-style range beacons with NLOS multipath. Ground-truth subject_id-tagged observations replaced with beacon-derived range/bearing rotated through the observer's SLAM-estimated theta. Two new SLAM-native attacks: odometry_corrupt (compromised IMU → SLAM theta drifts → observations rotate around attacker → outlier-observer blame) detects at ~tick 49; beacon_spoof (constant +3m range bias) detects at tick 3. Demo gains a ghost-pose triangle showing SLAM-vs-truth drift in real time. ADRs 0004-0006 lock the design rationale and the ROS2/Gazebo/TurtleBot4 + Crazyflie+UWB target platform. Tests: 75 → 106; battery: 10 → 13. All existing scenarios still pass without threshold relaxation.

  40. record_accept α-tightening (0.1 default) — Down-weight the α-reward earned by simply having a valid signature, so behavioral voting evidence (1.0 per match) dominates the cryptographic signal. Side effects across the battery: pose-liar 0.56 → 0.11, fuzz 0.67 → 0.34, drift 0.58 → 0.16, replay 0.47 → 0.31, sybil 1.00 → 0.17 (sybils now blamed as outliers because honest peers accumulate weight faster and tip the weighted median against the sybil-numeric-majority). Most attackers now cross the 0.4 detection threshold within 1–10 ticks. Honest peers stay at ~0.997 — slightly below 1.0 in the absence of crypto-validity α, but unanimous-trust-equivalent.

  41. sybil flood scenarioSybilSpec adds forged identities to the roster mid-scenario (no evaluator, no sim agent — just keypair + roster entry + per-tick publishing). Sybils corroborate a target attacker's pose-lie. Originally documented as a vulnerability with sybils at ~1.00; subsequent record_accept tightening collapsed the attack and the test was tightened to assert detection.

  42. 35f9137 expanded attack battery — three new attack kinds in tests/eval/runner.py: replay_storm (re-publishes a captured envelope 10× per tick → bus replay-rejections accumulate β), sensor_fuzz (gaussian-noise observations → outlier votes), drift_pose (self-pose offset growing 0.05 m/tick, crosses geometric threshold ~tick 8). Each gets a scenario builder and a battery test. Replay storm crosses the detection threshold within 5 ticks; fuzz and drift pass via rank-ordering against honest peers.

  43. 6de52c0 voting-order fix (cohort-close gating) — Replaced early-fire MIN_OBSERVERS=3 voting with cohort-close: a (subject, ts) cohort votes only when an envelope with later ts arrives (or flush() is called at end of run). Removes the publish-order bias that let 2-of-N colluders carry the early median. Colluding-pair eval flipped from honest-peer false-flagging at 0.69 / colluder rep at 0.85 → honest peers 1.0 / colluders 0.54. New unit test test_vote_waits_for_cohort_close_before_firing.

  44. df23ca2 eval harnesstests/eval/ with Scenario / AttackEvent / EvaluationResult and run_scenario(). 6 pytest scenarios: honest baseline, single pose-liar, single bad-key, colluding pair, sleeper, healed-liar. Each builds N per-agent evaluators on a shared signed bus, applies scheduled attacks, tracks detection latency + false-positive ticks + final rep matrix. Surfaced the voting-order issue fixed in slice 2.

  45. 7c3f65b weighted voting + agreement rewards — Weighted median uses _first_hand_score(observer) as weight, abstains below MIN_TOTAL_WEIGHT=1.0. Subjects whose self-report matches consensus, and observers whose claim matches consensus, both earn α. Closes the symmetry loop so prior 0.5 → ~1.0 with ongoing corroboration.

  46. be164da reputation gossipReputationGossip payload, per-agent evaluators, trust-weighted gossip integration with GOSSIP_DISCOUNT=0.1. Demo V key cycles viewer perspective.

  47. 9b83a11 decay — Exponential decay of evidence above prior toward (1, 1) with 10s half-life. Logical clock from authenticated envelope timestamps; first contact doesn't decay the prior; out-of-order envelopes can't rewind the clock. Demo H heals a liar; rehabilitation visibly climbs over a few half-lives.

  48. 30bbff3 voting + triangulation — Median-of-claims consensus disambiguates liar from framed peer. Buckets observations by (subject, ts), abstains below MIN_OBSERVERS=3, blames outliers and lying subjects independently.

  49. c438ad9 geometric cross-validation — Per-event geometric rule (now superseded by voting). Introduced geometric_inconsistency anomaly category and the pose-cache.

  50. fdf9838 trust evaluator (Beta core)BetaTrustEvaluator implements the ABC, classifies VerificationError strings into four anomaly categories, emits typed events on a Telemetry sink. Reputation bars in the demo.

  51. 575c566 unified demo — Single pygame window: corridor sim + signed bus + scrolling envelope log. X keypress to swap a keypair.

  52. 539c664 signed bus — ECDSA + nonce replay window + roster + version. Five rejection categories tested.

  53. 5d41d62 wall-bounce physics — Optional bouncing for visually richer scenarios.

  54. f64a7bd YAML scenarios — Worlds editable without Python.

  55. 15e8420 Phase 0 + Phase 1 minimal — ABCs locked, deterministic 2D sim ticking.


Demo controls (current)

just demo opens the unified window.

Key Action
SPACE Pause / resume
N Single-step (when paused)
X Swap next agent's keypair → cryptographic compromise (signatures fail → bad_signature)
L Pose-lie next agent → publishes self-pose with +3,+3 offset. Trust-layer no-op as of ADR 0015 — rep stays high; map-merger flags the misaligned fragment.
H Heal one liar → honest publishing resumes; reputation rebuilds via decay
R Revoke next agent's pubkey → RevocationList.revoke; subsequent envelopes rejected as key_revoked
T Rotate next agent's key → KeyRotationAnnouncement + apply_rotation; new key accepted, old-key stragglers rejected as key_revoked_post_rotation
Y Mint sybil → new keypair + roster-add (NOT attestation-allowlisted); per-tick fake-pose corroborates current liar (or first agent if none lying)
A Toggle attestation-required → when ON, sybils flip from "low rep via V2 self-anchored beacon" to unattested_key rejection
K Toggle +6s clock skew on next agent → envelopes rejected as clock_skew_future (5s DEFAULT_MAX_SKEW_NS exceeded)
J Toggle JSONL telemetry sink → tees every emit through JsonlTelemetrySink to /tmp/specter-demo-{pid}-{ts}.jsonl
V Cycle viewer → reputation bars + merged-grid overlay switch to that agent's perspective
Q / Esc Quit

Env-var startup toggles:

  • SPECTER_DEMO_SCENARIO=path/to.yaml — pick any scenario in scenarios/ (default four_agents_corridor.yaml).
  • SPECTER_DEMO_BUS=inproc|lossy|sros2 — wraps InProcessBus with LossyBus(drop=5%, jitter=10ms), or constructs Sros2Bus over real DDS (falls back to inproc with a printed warning if rclpy is absent).
  • SPECTER_DEMO_CADENCES=1 — switches to async stride logic: IMU at 200 Hz internal, lidar+pose+observation+fragment at 10 Hz, gossip at 1 Hz. Off (default) keeps the synchronous tick.
  • SPECTER_DEMO_MAX_TICKS=N — auto-exit after N ticks (used by the headless smoke check).

Visual signatures of trust state:

  • Honest peer: reputation bar climbs to ~1.0 (bright green).
  • Pose-liar: bar pegs at ~0.5 (gray midpoint).
  • Crypto-compromised: bar drains to ~0.0 (red).
  • Healed liar: bar climbs from ~0.5 back toward ~1.0 over a few half-lives (~30s).
  • All four states distinguishable at a glance.

SLAM ghost overlay: Each agent renders a dim slate-colored outline triangle at its DeadReckoningSlam.pose(), behind the solid ground-truth triangle. The label includes Δ{drift:.2f}m showing real-time SLAM-vs-ground-truth offset. Honest swarm: drift stays under ~1m for 80-tick scenarios. Under odometry_corrupt attack the ghost rotates relative to the true heading.

Cooperative-SLAM merged-grid overlay: Each tick every agent publishes a KIND_FRAGMENT envelope (encode_fragment(pose, scans)); each viewer maintains an OccupancyMapMerger that re-merges every 10 ticks over the last 4×n_agents fragments. The viewer's merged grid renders as a translucent dim-ink overlay on the world canvas (resolution 0.2 m). When the viewer's own scan finds ≥50% endpoints near occupied cells in the merged map, detect_loop_closure fires and a gold ring flashes around the viewer's triangle for 12 frames. Status bar shows merge_age ticks since last merge.

Reputation history overlay (US-052): A 200×100 px panel in the lower-right corner of the log pane sparkliner the current viewer's α/β trajectory for each non-self peer over the last 10 seconds. Each peer gets a colored line; reputation α/(α+β) is plotted bottom-up (1.0 at the top of the panel, 0.0 at the bottom). Decay, healing, and attack-onset signatures are visible at a glance — a crypto-compromised peer's line plunges within a tick; a healed liar's climbs over a few half-lives. Backed by ReputationTrace (per-viewer ring buffer).

Anomaly-stream panel: Between the reputation bars and the envelope log, the last 8 typed AnomalyEvents render as category | sender | t. Color-bucketed: identity/signature/replay → red (bad_signature, key_revoked, key_revoked_post_rotation, unattested_key, unknown_sender, replay); voting/presence/clock → gold (no_beacon_presence, range_inconsistency, clock_skew_future, clock_skew_past). Backed by StreamTelemetry which tees every BetaTrustEvaluator.emit into the existing ListTelemetry, the in-memory ring, and (when J is on) a JSONL file.


Attack battery — measured outcomes

Final reputation of attacker(s) from honest viewers, end-of-scenario (post-SLAM, post-beacons):

Scenario Attackers Detection tick (thr 0.4) Final rep (attacker / honest)
honest_swarm 0.99 / 0.99
single_pose_liar 1 tick 2 0.11 / 0.99
single_bad_key 1 tick 1 0.00 / 0.98
colluding_pair_pose_liars 2 of 6 tick 2 0.11 / 0.99
sleeper_pose_liar 1 (wakes tick 60) not crossed 0.43 / 0.99
liar_then_heals 1 (heals tick 60) tick 2, recovers 0.89 / 0.99
replay_storm 1 tick 1 0.31 / 0.99
sensor_fuzz 1 tick 3 0.35 / 0.99
gradient_drift 1 tick 26 0.16 / 0.99
odometry_corrupt (SLAM-native) 1 tick 49 0.48 / 0.99
beacon_spoof (SLAM-native) 1 tick 3 0.33 / 0.99
sybil_flood 1 + 4 sybils tick 3 0.11 / 0.99 (sybils 0.17)
sybil_flood (5:3, 10:3, 25:3, 100:3) (V1 fix) 1 + N sybils tick 1 <0.40 / 0.99
sybil_flood_mutual (4:3) (V2 fix — sybils fake-observe each other) 1 + 4 sybils tick 2 0.11 / 0.99 (sybils 0.16)
sybil_flood_mutual (5:3, 10:3, 25:3) (V2 fix) 1 + N sybils tick 2 0.11 / 0.99 (sybils 0.18 → 0.28)

All 12 attack classes (+1 SLAM integration test = 13 battery tests total) produce attacker rep clearly below honest. SLAM noise + IMU bias do not produce false positives in the honest swarm. The two SLAM-native attacks exercise the new LocalSlam integration: odometry_corrupt is the slowest to detect (tick 49) because the attacker's xy stays correct under the held-velocity SLAM model — only their outgoing observations are wrong, so α from correct-subject voting keeps flowing until β from outlier-observations dominates.


Quality gates

uv run pytest -q                    # 75 tests
uv run pytest tests/eval -q         # 13 battery scenarios (12 attacks + 1 SLAM integration)
uv run ruff check src tests         # clean
uv run mypy src/specter/trust src/specter/sim src/specter/slam   # clean (1 pre-existing yaml-stub note in scenario.py)

Next slices on the table

In rough priority order:

  1. Scan-match SLAM upgradeDone (Recent slices #1, ADR 0007). Drift bounded under bouncing-wall scenarios.
  2. MapMerger — trust-weighted fusion of map_fragment() outputs across peers. Low-rep peers' fragments down-weighted in the merge.
  3. Larger-scale Sybil evalDone. Ceiling was 4:3.
  4. Beacon-presence Sybil defense V1Done. Ceiling broken on the no-mutual variant.
  5. Beacon-presence Sybil defense V2 — self-anchoredDone (Recent slices #1). Ceiling broken on the mutual-corroboration variant (4:3 through 25:3 measured; 100:3 expected to hold but not asserted in CI for runtime). The remaining attack class V2 doesn't address: a peer that's physically present in the swarm but compromised (e.g., one robot's keys leaked) can still mint sybils whose Observations carry the compromised peer's signature — but the SIM cannot represent this without a roster-level identity model. Hardware attestation (TPM/SE-bound keys) is the proper fix and is out of Phase 1 scope.
  6. SROS2 transport swap — InProcessBus → SROS2 over the same envelope contract. Unblocks running on real ROS2 nodes (target platform per ADR 0006).
  7. Hardware-in-loop bridge — Gazebo + TurtleBot4 model running the same code, then real Crazyflie+UWB swarm.

Recommended execution order

The list above is rough-priority by impact. The recommended next-up sequence trades against risk and unlock value:

  1. Larger-scale Sybil evalDone.
  2. Sybil defense V1 (beacon-presence, any-grantor)Done.
  3. Sybil defense V2 (self-anchored beacon-presence)Done. The landed rule chose self-anchoring over rep-weighting because, with all peers starting at the same Jeffreys prior, rep-weighted aggregation can't distinguish honest peers from a prior-symmetric mutual cabal at tick 1 — the cabal's count advantage saturates either rule. Self- anchoring exploits the structural fact that self's own physical sensor never returns sybils, regardless of cabal size or coordination.
  4. Scan-match SLAM upgradeDone. ADR 0007.
  5. MapMerger is now the natural next slice — map_fragment() already ships honest scan data per peer, scan-match keeps poses bounded over long runs, and trust-weighted fusion of fragments is the second half of "cooperative SLAM." Touches src/specter/slam/ + a new module.
  6. SROS2 transport is the alternate next track — biggest leverage toward hardware. Independent of MapMerger; pick based on whether the next milestone is "richer fusion" or "real ROS2 nodes."
  7. Hardware-in-loop is the largest commitment; defer until both MapMerger and SROS2 hold end-to-end in sim + Gazebo.

Deliberately deferred

  • Scan-match / particle-filter SLAM — covered by ADR 0004. Dead-reckoning is sufficient for slice 1; richer estimators wait on MapMerger.
  • Wall occlusion of beacons — ADR 0005. Radio propagation in the UWB band justifies the simplification.
  • Async sensor cadences — lidar 10 Hz, IMU 200 Hz. Trust engine's logical-clock design is already async-safe; sim is sync-tick for determinism. Revisit at SROS2 port.
  • Lossy / jitter bus — InProcessBus is zero-loss. LossyBus wrapper deferred until the SROS2 swap exposes real packet loss.
  • Nonholonomic motion modelAgent.step is holonomic. Revisit when targeting differential-drive hardware.
  • Persistent telemetry sinkListTelemetry is in-memory. No need for disk persistence until eval harness consumes streams.
  • Multi-rule registry — current rules (signature, replay, version, sender, range-reciprocal, MDS-multilateration per ADR 0015) are inline. A registry only earns its complexity once we have ~5+ active rules.
  • Roster updates / key rotationDone (Recent slices #1). identity.MutableRoster + identity.rotation ship runtime add/remove and the standard sign-with-old-key rotation protocol; old-key envelopes post-rotation are rejected with the explicit key_revoked_post_rotation category.