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.
| 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.02 → math.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_*.ipynb … notebooks/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.
-
Free Play wiring fix — flight pattern / path now reach the sim + start-of-window slider thumb grabbable (
ui/packages/app/src/sim/scenario.worker.tsattachFlightPatternandattachPathdrop theif (spec.planner) returnguard; call site inbuild()now doesif (flightPattern) attachFlightPattern(...) else attachPath(...)so pattern wins cleanly over path;ui/packages/app/src/lib/primitives.cssnew.composer-range-thumbruleset —pointer-events: noneon the input track,pointer-events: autoon the::-webkit-slider-thumb/::-moz-range-thumbpseudo-elements + 14px round accent-colored thumbs with focus-visible outline;ui/packages/app/src/domain/freeplay/ScheduleBlock.tsxtwo-thumb slider inputs get aria-labels and dynamicz-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 throughbuildSwarm()indata/scenarios.ts:64which attacheswheelPlannertospec.planner.composeBundlescarries that planner through. The worker's two motion-attach helpers each bailed withif (spec.planner) return— designed to "preserve bespoke lesson planners," but lesson fixtures don't reach this worker at all (they take thesliceFromFixtureshort-circuit insimStore.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 withwidth: 100%, the end input rendered second and on top of the entire track; no CSS rule forcomposer-range-thumbexisted anywhere (className was referenced but unstyled) so the default full-width track of the end input intercepted every pointer event. Quality gates:just ui-typecheckclean (sim-core + app),just ui-test286 passed (sim-core suite unchanged; apppassWithNoTests),just ui-buildsucceeds,just lintclean (no Python touched). Other Free Play wiring issues called out in the same audit (PresetRowno-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. -
Free Play Compose-then-Watch — Wave 6: Interactive map editor + preset save/load (
ui/packages/app/src/domain/freeplay/MapEditor.tsxnew — interactive replacement for the Wave 5MapPreview.tsx(which is deleted): pointer-event SVG (onPointerDown/onPointerMove/onPointerUpwithsetPointerCapture) gated bysimStore.mapTool: "MOVE" | "BEACON" | "WAYPOINT" | "ERASE" | null;MOVEfinds the nearest beacon within a 4 m hit radius and drags it viasetBeaconPositions,BEACONappends the click point tobeaconPositions,WAYPOINTappends topathWaypointsand forcespathPreset = "FREEHAND",ERASEremoves the nearest beacon-or-waypoint within hit radius;RESETchip clears bothbeaconPositionsandpathWaypointsback 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.tsnew —ComposerPresetshape ({ id, name, attackIds, attackSchedules, flightPattern, pathPreset, pathWaypoints, mapPresetId, beaconCount, beaconLayoutName, beaconPositions, builtIn? }) +loadUserPresets()/saveUserPresets(presets)round-trip throughlocalStorage["specter1.freeplay.presets.v1"]with a versioned envelope{ version: 1, presets: [...] }; defensive on corrupt/missing JSON (returns[]);newPresetId()generatesuser-<base36-time>-<base36-rand>;ui/packages/app/src/domain/freeplay/presets.tsnew —BUILT_IN_PRESETSwithL02 BETA RECOVERY(liar_then_healswindowed 60→260 onWAREHOUSE_40x40) andL05 MID-MISSION FLIP(sleeper_pose_liarwindowed 300→700) flaggedbuiltIn: trueso the delete button is suppressed;ui/packages/app/src/domain/freeplay/PresetRow.tsxnew — preset chip row (built-ins first, then user presets, thenCUSTOM), inline naming popover forSAVE PRESET(Enter saves, Escape cancels, blank name disabled), per-user-preset×delete button; selecting a chip callsloadComposerPreset(preset);ui/packages/app/src/sim/simStore.tsnew state fieldmapTool+ new actionssetMapTool(tool)andloadComposerPreset(preset)(deep-clones all arrays so subsequent edits don't mutate the saved preset object);ui/packages/app/src/domain/freeplay/Composer.tsxnow renders<PresetRow />between the header and the two-column body and uses<MapEditor />in the right column instead of the deletedMapPreview;ui/packages/app/src/domain/freeplay/MissionBlock.tsxre-enables the FREEHAND path chip with a tooltip directing users to the+WAYPOINTmap 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+BEACONand click to add a beacon, orMOVEand drag an existing one, or+WAYPOINTand click points to draw a freehand path → header shows "custom · unsaved" →SAVE PRESETopens 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:loadComposerPresetdeep-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 flippingmapPresetIdtonullwhen beacons are customized; we instead keepmapPresetIdpopulated (the editor needs the bounds context to know what map you're customizing FROM) and usebeaconPositions !== null || pathWaypoints !== nullas the customization signal. The Wave 4setMapPreset(id)contract — which clearsbeaconPositionsso 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 currentCUSTOMchip just deactivates any active preset selection. Quality gates:just ui-typecheckclean (sim-core + app),just ui-test286 passed (full sim-core suite still green; apppassWithNoTests),just ui-buildsucceeds. Wave 6 of 6 of the Free Play Compose-then-Watch implementation (seedocs/FREE_PLAY_PLAN.md) — every wave's checklist is now ticked. -
Free Play Compose-then-Watch — Wave 5: Compose card UI (read-only map preview, isRunning toggle) (
ui/packages/app/src/domain/freeplay/Composer.tsxnew — centered max-width 1180 card onSurface level={2}with header, two-column grid body (grid-template-columns: minmax(0,1fr) minmax(280px,420px)), recap line +LAUNCH ▶button in footer;ScheduleBlock.tsxnew — chip pool of selectable attacks (filters outhonest+trustLayerNoop) feedingtoggleAttack, each active attack renders anAttackRowwith 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) drivingsetAttackSchedule(id, partial)per-thumb, start→end readout + duration tag +×remove control per row;MissionBlock.tsxnew — flight-pattern chips (DEFAULT + LAWNMOWER/ORBIT/RENDEZVOUS/RANDOM_WALK) drivingsetFlightPattern, path-preset chips (NONE + LOOP/LINEAR/FIGURE-8/FREEHAND) drivingsetPathPresetwith pattern-wins semantics (path chips disabled whenflightPattern !== null; FREEHAND always disabled — Wave 6 enables);EnvironmentBlock.tsxnew — map preset chips drivingsetMapPreset, beacon layout chips (PERIMETER/CORNERS/DENSE) drivingsetBeaconLayoutName, count range slider (0–24) drivingsetBeaconCount, CORNERS disables the count slider and pins display to 4;MapPreview.tsxnew — read-only SVG (360×360 with 16px padding) showing bounds rect (dashed--text-low), beacon dots + 10px range halos at thebeaconLayout(name, sizeM, count)positions (orbeaconPositionswhen user-customized), waypoint path as connected<line>segments (closes the loop with an extra line whenpath.closed) fromdefaultPathFor(preset, bounds)orfreehand(pathWaypoints);SummaryChip.tsxnew — TopBar chip that self-gates onmode==="freeplay" && isRunning, click →exitRun(), shows attack count + motion recap + map preset;ui/packages/app/src/domain/TopBar.tsxrenders<SummaryChip />between mode tabs and the right cluster;ui/packages/app/src/screens/WorkshopConsole.tsxFreePlayLayoutrewritten — when!isRunningrenders<Composer />, else rendersSwarmCanvas+TickScrubber+TrustPanel(no more CurriculumRail / SceneBriefing / AttackDock / AgentRepList in Free Play);ui/packages/app/src/sim/simStore.tsisRunning: booleanstate (defaultfalse) +launch()action (setsisRunning=true, threads the full current config throughstreamForAttacks) +exitRun()action (setsisRunning=false+ pauses playback);setModeresetsisRunning=falseon entry tofreeplayandtrueotherwise (only freeplay reads the flag);streamForAttacksearly-returns afterset({ attackIds: [...ids] })whenmode==="freeplay" && !isRunningso 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✎ EDITto 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_COUNTis hardcoded across every attack constructor indata/scenarios.ts, making it variable is its own slice; MissionBlock showsAgents · fixed at 8for now; (b) Composer preset row +SAVE PRESETbutton — lands with Wave 6 alongsidepresetSchema.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 settingpathWaypoints; (d)Composer.test.tsx— the app package has no jsdom/RTL setup yet (vitest runs withpassWithNoTests), spinning that up is its own slice; (e) seed selector —swarmOpts.seedexists inScenarioSpecbut isn't exposed insimStore, trivial follow-up. Quality gates:just ui-typecheckclean (sim-core + app),just ui-test286 passed (full sim-core suite unchanged; apppassWithNoTests),just ui-buildsucceeds (production bundle generated for both worker + main). Wave 5 of 6 of the Free Play Compose-then-Watch implementation (seedocs/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. -
Free Play Compose-then-Watch — Wave 4: map preset + beacon layout factory (TS + Python + simStore + worker) (
ui/packages/sim-core/src/maps.tsnew —MAP_PRESETStuple of three (WAREHOUSE_40x404 perimeter /WAREHOUSE_60x406 perimeter /OPEN_FIELD_100x1008 perimeter) +mapBounds(preset)centered on (0,0) +beaconLayout(name, sizeM, count)factory with four named modes,ui/packages/sim-core/src/index.tsexportsMAP_PRESETS/mapBounds/beaconLayout/BeaconLayoutName/MapPreset/MapSize/MapBounds,ui/packages/sim-core/tests/maps.test.ts12 tests (preset id order, default-layout-beacons-within-bounds for all 3 presets,mapBoundscentered 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.pynew — Python parity module (constant-for-constant mirror includingDENSE_INSET_FRACTION=0.1and the four-edge perimeter walk),tests/test_maps.py15 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.tsMapConfig = { presetId, beaconCount, beaconLayoutName, beaconPositions? }+WorkerRequest.mapConfig?+WorkerSliceTail.beaconPositions?,ui/packages/app/src/sim/scenarioWorker.tsthreadsmapConfigthroughstreamSliceInWorkeras a new optional fifth arg,ui/packages/app/src/sim/scenario.worker.tsresolveBeaconPositions(mc)usesbeaconLayoutto compute effective positions when no explicitbeaconPositionsprovided (falls back to preset default layout ifcustomis requested without explicit positions — defensive worker),ui/packages/app/src/sim/simStore.tsfour new state fields (mapPresetIddefaults toWAREHOUSE_40x40,beaconCount+beaconLayoutNameinitialized 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/setAttackScheduleupdated),cacheKeyextended with a~<presetId>/<layout>x<count>[:positions]suffix so cache busts on map edit,docs/FREE_PLAY_PLAN.mdWave 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 nomapConfigis supplied (the legacy lesson + scenario path), the worker takes the existingAO_WORLDcode path byte-for-byte andWorkerSliceTail.beaconPositionsisundefined. The factory itself is purely additive — no existing wire shape, no existing parity fixture, and no existingrunScenariosemantics are touched. Math callout — perimeter spacing is arc-length, not Euclidean: equally spaced along a rectangle's perimeter means steps = 2(w+h)/countwalked 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 atcount=4this happens to land exactly on the four corners, which is exercised as a sanity case in both languages. Thecornersmode is documented (and tested forcount ∈ {1, 4, 9, 99}) as ignoringcount— it's a fixed four-corner placement.denseism = 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).customthrows ("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-typecheckclean (both packages),just ui-test265 passed (was 253, +12 maps tests),uv run pytest tests/test_maps.py -v15 passed,uv run ruff check src testsclean. Wave 4 of 6 of the Free Play Compose-then-Watch implementation (seedocs/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 samemain. The Wave 5MapPreview.tsxcomponent will read the new store fields directly; the Wave 6 map editor will setbeaconPositionsto a non-null override (the "last one wins" contract from the plan). -
Free Play Compose-then-Watch — Wave 3: path / waypoint geometry (TS + Python + simStore + worker) (
ui/packages/sim-core/src/paths.tsnew —Pathtype +loopWaypoints/linearPath(open-by-default, optionalbounce) /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 thePlannersignature exported fromscenario.ts:62-66/defaultPathFor(preset, bounds)(LOOP → inset regular pentagon at 20% inset, LINEAR → bounds diagonal, FIGURE-8 → centered ata=width/3, b=height/3, 32samples, FREEHAND → throws),ui/packages/sim-core/src/index.tsexports the new symbols +Path/PathPreset/Point2/Boundstypes,ui/packages/sim-core/tests/paths.test.ts14 tests (4 constructors + 4defaultPathFor+ 6 follower cases: closed wrap, bounce reversal, open halt, freehand halt, deterministic re-run identical, arrival-threshold honored),src/specter/sim/paths.pynew — same math constant-for-constant (Pathfrozen dataclass,Boundsfrozen 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.pymirror suite 14 tests,ui/packages/app/src/sim/scenarioWorker.types.tsPathPresetId = "LOOP"|"LINEAR"|"FIGURE-8"|"FREEHAND"+WorkerRequest.pathPreset?+pathWaypoints?,ui/packages/app/src/sim/scenarioWorker.tsthreads both throughstreamSliceInWorker,ui/packages/app/src/sim/scenario.worker.tsbuilds the path (FREEHAND →freehand(pathWaypoints), elsedefaultPathFor(preset, AO_BOUNDS)derived fromAO_WORLD.bounds) and wraps inpathFollower(path, {speed: 1.0})attached tocomposed.spec.planneronly when no planner was supplied already (lesson fixtures keep their own motion),ui/packages/app/src/sim/simStore.tspathPreset: PathPresetId | null+pathWaypoints: ReadonlyArray<[number, number]> | nullstate (default null),setPathPreset(preset)andsetPathWaypoints(waypoints)actions both re-stream viastreamForAttacks,cacheKeybusts on path-config edit, every existing re-stream call site now threads the currentpathPreset/pathWaypointsfrom 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-typecheckclean (sim-core + app),just ui-test267 passed (was 253, +14 new path tests),uv run pytest tests/test_paths.py -v14 passed,uv run ruff check src testsclean. Wave 3 of 6 of the Free Play Compose-then-Watch implementation (seedocs/FREE_PLAY_PLAN.md); Wave 5 (Compose card UI) is the consumer that turns the chip row into LAUNCH-button behavior. -
Free Play Compose-then-Watch — Wave 2: motion planner library (TS + Python + simStore + worker) (
ui/packages/sim-core/src/planners.tsnew —lawnmower({ bounds, stripeM, speed })per-stripe sweep with closure-held per-agent direction state,orbit({ center, radius, omegaRad })closed-form next-stepdesired = center + radius·(cos(θ+ωdt), sin(θ+ωdt))self-corrects radial error in one tick,rendezvous({ target, speed })converge-then-hold withstopThreshold = speed·dt,randomWalk({ rng, speed, turnSigma })per-agent heading diffuses byrng.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.tsexportsFlightPattern+ factories + opts types;ui/packages/sim-core/tests/planners.test.ts7 new tests (lawnmower flip-at-xmax + stripe separation, orbit radial settle + CCW sweep matchesθ = π/10after 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.pynew —lawnmower/orbit/rendezvous/random_walk/default_planner_formatching TS math constant-for-constant, frozen-dataclassBounds/VelocityCommand/PlannerContext,random.Random.gausssubstituted for TS Box-Muller (behavioral parity only, not byte-exact);tests/test_planners.py7 tests mirroring TS suite;ui/packages/app/src/sim/simStore.tsflightPattern: FlightPattern | nullstate +setFlightPatternaction threaded throughstreamForAttacks,cacheKeybusts on pattern change;ui/packages/app/src/sim/scenarioWorker.types.ts+scenarioWorker.tsWorkerRequest.flightPattern?plumbed;ui/packages/app/src/sim/scenario.worker.tsattachFlightPattern(spec, pattern)invoked aftercomposeBundles— only attaches whenspec.planneris already undefined so lesson-supplied planners stay intact, usesAO_WORLD.boundsmapped from{minX,minY,maxX,maxY}→{xmin,ymin,xmax,ymax}, RANDOM_WALK seeds anRngfromspec.swarmOpts?.seed ?? 0for run-determinism sincedefaultPlannerForthrows 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-typecheckclean (sim-core + app),just ui-test253 passed in 25 files (full sim-core suite green, +7 planner tests),uv run pytest tests/test_planners.py -v7 passed,uv run ruff check src testsclean. Wave 2 of 6 of the Free Play Compose-then-Watch implementation (seedocs/FREE_PLAY_PLAN.md); Waves 3 (paths) and 4 (maps) are being built in parallel worktrees. -
Free Play Compose-then-Watch — Wave 1: per-attack scheduling (TS + Python + simStore + worker) (
ui/packages/sim-core/src/attacks.tsAttacker.startTick?/endTick?+PoseAttacker.startTick?/endTick?+ per-attacker filtering inapplyAttacks/applyPoseAttacks,ui/packages/sim-core/src/scenario.tsrunScenario+runSecureScenarioresolve per-attacker windows fromspec.attackStartTick/attackEndTickdefaults and gate per-attacker (the globalattackArmedflag still gates scenario-level injectors),tests/eval/runner.pyAttackEvent.end_tick+ per-tickdisarms_by_tick+_disarmhelper 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.pyliar_with_end_tick+dual_windowed_attacks,tests/eval/test_attack_battery.py4 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.ts4 new tests (single-window, non-overlapping pair, overlapping pair, spec-level default back-compat),ui/packages/app/src/sim/scenarioWorker.types.tsAttackSchedule+WorkerRequest.attackSchedules?,ui/packages/app/src/sim/scenarioWorker.tsschedules param threaded,ui/packages/app/src/sim/scenario.worker.tsapplySchedule()tags every bundle's attackers with their requested[startTick, endTick)before composing,ui/packages/app/src/sim/simStore.tsattackSchedules: Record<id, AttackSchedule>state +setAttackSchedule(id, partial)action +toggleAttackauto-allocates a default{0, DEFAULT_END_TICK=900}window on add and drops it on remove,cacheKeyincludes schedules so cache busts on window edit,docs/FREE_PLAY_PLAN.mdnew 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/attackEndTickremain 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 concurrentsensor_fuzzattackers 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 bothend_tickdisarms fired (β decay alone can't lift rep that high while either attacker is still corrupting observations). Quality gates:just ui-typecheckclean (sim-core + app),just ui-test253 passed (was 249, +4 concurrent-attack parity tests),uv run pytest tests/eval/test_attack_battery.py -q19 passed, 6 xfailed (was 15+6, +4 windowed-attack tests),uv run ruff check src tests/evalclean. Wave 1 of 6 of the Free Play Compose-then-Watch implementation (seedocs/FREE_PLAY_PLAN.md); Waves 2–6 (planners, paths, map presets, Compose UI, map editor + presets) follow. -
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.pyper-cell reporter set + first-seen +set_current_tick+enable_singleton_cap/enable_singleton_fade+cell_reportersaccessor,ui/packages/sim-core/src/poseGraph.tsmirror port +factorWeightpublic accessor +enableSingletonCap/enableSingletonFade+setCurrentTick+factorProvenance+SINGLETON_INFO_SCALE/T_CORROBORATE/T_FADE+Provenanceinterface re-exports,ui/packages/sim-core/src/index.tsexports,tests/test_pose_graph_rep_tracking_gamma.py5 tests,tests/test_map_merger_singleton.py7 tests,ui/packages/sim-core/tests/poseGraph.singleton.test.ts7 tests,docs/THREAT_MODEL.mdmeasured-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_FLOORwithGAMMA_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)pullss → 1, so closed-forms* = γ/(‖r‖² + γ)means low γ frees the switch to drop under residual evidence. Testtest_rep_collapse_drives_switch_lower_than_fixed_gammameasured: a lying-landmark factor (truth at 5m, liar reports 10m) atr_now = 0.01convergess_liar = 0.0009under fixed γ=1.0 vss_liar ≈ 0under 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 acrossmerge()calls;merge(fragments, *, tick=...)accepts tick;_apply_singleton_scalepost-scalesocc/freegrids bySINGLETON_INFO_SCALE × fade(Δt)for cells with cardinality-1 reporter sets._castwalks each ray, calling_touch_cellper 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. Newcell_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+ privatesingletonScalecomposed intoweightOf. PublicfactorWeightaccessor added for tests (mirrors Python's_weight_ofaccessibility pattern). ConstantsSINGLETON_INFO_SCALE/T_CORROBORATE/T_FADE/Provenancere-exported fromindex.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 forparity.json. Slice 8 — THREAT_MODEL row: new "Singleton-trusted-window lie" row in §Measured guarantees citingtest_singleton_cap_composes_with_reputation_collapse. Measured bound:Ω_eff = 0.003 × Ω_basefor a singleton-territory lie from a rep-collapsed reporter (cap 0.3 × rep 0.01) — vs0.01 × Ω_basewithout 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*.py157 passed (+12 vs pre-slice substrate baseline),pytest -q --ignore=tests/eval377 passed, 10 skipped, 0 regressions, ruff + mypy clean (pose_graph.py+map_merger.py),just ui-typecheckclean,just ui-test249 passed (+7 singleton tests),just ui-buildsucceeds 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. -
ADR 0022 — retroactive trust + singleton-uniqueness, substrate slice (
docs/adr/0022-retroactive-trust-and-singleton-uniqueness.md,src/specter/slam/pose_graph.pyProvenance+_record_provenance+factor_provenance+set_current_tick+enable_singleton_cap+enable_singleton_fade+_singleton_scale+_landmark_reportersbookkeeping +_weight_ofcomposition,tests/test_pose_graph_singleton.py7 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_sourceper LM step, plus a reputation-tracking switch priorγ_i(t)forweight_mode="switchable"(deferred to Slice 5). P2 uniqueness defense lands here: singleton confidence cap (landmarks with one distinct reporter scaled bySINGLETON_INFO_SCALE=0.3— 1σ inflation1/√0.3 ≈ 1.83×, equivalent to bumping a 5° bearing-noise model to 9°) + stale-singleton fade (linear ramp from full cap to zero overT_CORROBORATE=200 → T_FADE=200ticks 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 onadd_landmark_factor/add_inter_robot_factor/add_loop_closure_factorwhen caller passestick=;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 omittick=) get byte-exact parity —_singleton_scalereturns 1.0 when the cap is disabled. Eval-first contract met:tests/test_pose_graph_singleton.pywas committed first with 4xfail(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_*.py140 passed (+7 vs pre-slice),pytest -q --ignore=tests/eval365 passed zero regressions, ruff + mypy clean onpose_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 6OccupancyMapMergerper-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. -
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.tsSlamView+TickSnapshot.slam,ui/packages/sim-core/src/index.tsre-export,ui/packages/app/src/sim/slamLessons.tsnew,ui/packages/app/src/sim/simStore.tsSLAM branch inselectLesson+prefetchLessons,ui/packages/app/src/sim/{SlamCanvas,CooperativeSlamCanvas,ClosedLoopSlamCanvas}.tsxrewritten as store-driven,ui/packages/app/src/domain/SlamMetricsPanel.tsxnew,ui/packages/app/src/screens/WorkshopConsole.tsx+domain/RightPanel.tsxSLAM-aware rail swap,ui/packages/app/src/domain/SceneBriefing.tsxSLAM phase label,ui/packages/app/src/data/{lessonLayouts,sceneCopy,fixtures/index}.ts,ui/packages/app/scripts/generate-lesson-fixtures.mtsskip SLAM ids,ui/packages/app/public/fixtures/slam_*.jsondeleted) — Closed the placeholder follow-on Wave 5 left inlessonLayouts.ts:19–21. Before: each SLAM canvas computed 2–5 stage snapshots in-component (useMemo(() => runCooperativeSlamDemo(...))) and exposed an in-canvas row of primary/secondaryButtons for stage selection, while a parallel swarm scenario (slam_loop_closureetc.) emitted 764 ticks of unrelated reputations thatAgentDetailsPanel/AgentRepList/TrustPanelanimated through — two views, one screen, never talking. After:TickSnapshotcarries an optionalslam?: SlamViewpayload;simStore.selectLesson(09..12)callsbuildSlamSlicewhich runs the demo synchronously and lands N stage-snapshots inresult.snapshots; the existingTickScrubber+ spacebar play/pause +[/]lesson nav drive the SLAM lessons identically to swarm lessons. NewSlamMetricsPanel(cost/iter/factor/closure counts + cost sparkline + SLAM-layer rep bars) replacesTrustPanel/AgentDetailsPanel/AgentRepListin bothWorkshopLayoutandRightPanelwhenlesson.trunk === "slam".SceneBriefingswaps theHONEST/WARMUP/ATTACK LIVEphase chip forSTAGE N/M · stageLabelwhen the snapshot carriesslam. 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 MBslam_*.jsonfixtures removed;generate-lesson-fixtures.mtsskips 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 swarmBetaTrustEvaluatorinto 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-typecheckclean (sim-core + app),just ui-test241 sim-core parity tests passed + app vitest passWithNoTests (no app-level test suite yet),just ui-buildclean,pnpm generate-fixturesproduces 8 swarm fixtures + 4 SLAM in-process lessons (was 12 swarm fixtures). -
Coordinated-colluder baseline test — empirical gate on the trust↔SLAM decoupling claim (
tests/eval/test_baselines.pycolluder section,docs/BASELINES.md§ Coordinated colluders,docs/adr/0018-reputation-as-factor-information-prior.mdarchitectural-gate note,docs/SYSTEM_ASSESSMENT.md§5 + §6 baselines update,docs/THREAT_MODEL.mdBaselines 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 -q425 passed, 28 skipped, 6 xfailed (4 colluder tests included). -
SLAM_PLAN Polishes A + B + C — Right Jacobian / sparse Cholesky / SLAM scenario (
src/specter/slam/pose_graph.pyright_jacobian_se2(),ui/packages/sim-core/src/poseGraph.tsumeyamaSim2()+detectLoopClosures()+runSingleAgentLoopClosureDemo(),ui/packages/sim-core/src/sparseLinalg.tscscCholesky()+cscForwardSolve()+cscBackwardSolve()+cscCholeskySolve(),tests/test_pose_graph_se2.pyright-Jacobian suite,ui/packages/sim-core/tests/poseGraph.parity.test.tsright-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 asR(-θ) · (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 newparity.jsonright_jacobianblock. The Jacobian sits next toexp_se2/log_se2ready 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 againstcholeskySolve()dense reference (already shipped inposeGraph.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. ThecscCholeskySolvecomposes behind the samePoseGraphSlamABC, 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 PythonMIN_KEYFRAME_GAP=10/MIN_SHARED_LANDMARKS=2/RESIDUAL_TAU=0.5msemantics.runSingleAgentLoopClosureDemo()ships the L09 scene: 12-pose rectangular loop with 4 landmarks (two nearp0/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),pytest87 passed (+10 vs pre-polish: right-Jacobian SE(2) suite),just ui-typecheckclean,just ui-test200 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-buildsucceeds. 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): ReactSlamCanvasintegration consumingrunSingleAgentLoopClosureDemosnapshots 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. -
Coverage Parity Wave 4 — identity-lifecycle rail symmetry (
bad_keyrail 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): newbad_keyAttackEntry — hardcoded rejection-event stream modeled onforged_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 — runssingle_bad_keyand 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 4slam_*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-typecheckclean,just ui-buildclean, notebook 02 nbconvert-executes; existingtest_single_bad_key_collapses_immediatelyalready covers the Python side. -
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.mdpost-v3 reality-check,ui/packages/sim-core/tests/poseGraph*.test.ts,tests/test_pose_graph_*.py,ui/packages/app/src/data/lessons.tsL09–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 inad(ξ)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_informationwith 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 infor·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 unchangedPoseGraphSlamABC when needed. Wave 1.6 cross-platform optimizer parity gate:parity.jsonoptimizersection 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,LoopClosureFactoralgebraically identical toOdometryFactor. 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 tolessons.ts. Full UI integration (canvas overlay,runScenarioSLAM dispatch, lesson scenes) is documented follow-on. Quality gates final: ruff + mypy clean (20 source files),pytest tests/test_pose_graph_*.py68 passed (SE(2) 24 + factors 12 + LM 6 + GNC 5 + loop closure 6 + inter-robot 9 + reputation 6),just ui-typecheckclean,just ui-test168 passed across 14 files,just ui-buildsucceeds. Architecture preserved: existingDeadReckoningSlam/ScanMatchSlam/OccupancyMapMerger/LocalSlamABC unchanged — pose graph is fully additive.PoseGraphSlamsibling withfactor_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 behindsparseLinalg.ts, analytic SE(2) right Jacobian → 10/8-decimal cross-platform parity tightening. -
Coverage Parity Wave 3.5 —
forged_envelopePython 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 sealedObservationenvelope; every receiver'sopen_enveloperaisesVerificationError("unknown_sender")before the payload reaches the trust evaluator. Matches rail L01'sforged_envelopesemantics conceptually (different layer: TS rail injects via mapAttacks-style hook; Python runner uses the real envelope plumbing with no roster registration). New scenarioforged_envelopeinscenarios.py— single foreign emitterx0, n_ticks=80; the wire layer is the only attack surface and it's signed. New testtest_forged_envelope_rejected_at_wire_boundaryasserts: 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 betweensingle_bad_keyandrange_lie. Cross-reference table innotebooks/README.mdupdates 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_corruptrail, 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_boundarygreen; broader battery (15 tests + 6 xfailed) still passes. -
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)insrc/specter/sim/world.py— frozen dataclass for reportable point features distinguished fromLandmark(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)insrc/specter/messages.py+KIND_CONTACT_REPORT = "contact_report"wired into_DECODER; world-frame position (operator-facing), distinct fromLandmarkObservation(range-bearing measurement). Eval runner additions:MapAttack(reporter_id, phantom_contacts, suppressed_item_ids)(eval-only adversary primitive, not insrc/specter/);CopEntrydataclass for COP entries;Scenario.items+Scenario.map_attacks+Scenario.cop_sensor_radius_mfields (all default-empty so non-COP scenarios pay zero overhead); per-tick contact emission loop inrun_scenario(honest peers within radius report visible items, MapAttack rewrites the honest set per reporter); single shared_make_contact_collectorsubscriber +contact_logaggregator;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 TScopView()inui/packages/app/src/sim/contacts.ts);EvaluationResult.cop+EvaluationResult.contact_logexposed. Four new scenarios inscenarios.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 onerange_liewrapper, mirrors the TS rail's L08 composite). Four new tests intest_attack_battery.pyasserting: phantom absence in COP, real-item presence with reporter set, attackermax(honest_views) < 0.4, real-item weight > 0.85 from 3-honest-peer redundancy. Notebook 07 live cell added before "Limit" — runscop_corruption_full, prints attacker rep + COP survival table (real vs phantom × surfaced vs dropped); CI-compatible viasys.pathsetup. ADR 0019 documents the full design: wire flow, trust-weighting hook (avg-across-honest-viewers consensus), composition boundary withOccupancyMapMerger(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 theOccupancyMapMergerkept its independence (ADR 0019 §"Composition with the OccupancyMapMerger" boundary). Docs: 4 new rows inTHREAT_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.mdupdates markingcop_mislocate+cop_cosigned_phantomas scenario-construction tasks (mechanism shipped, just need new MapAttack flavors); cross-reference table innotebooks/README.mdgraduates 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. -
Coverage Parity Wave 2 —
partition_gossipPython eval + notebook 04 live cell + runnerlink_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 (seedocs/COVERAGE_PARITY_PRD.md). Runner extension: optionallink_predicate: Callable[[observer_id, receiver_id], bool] | NoneonScenario, threaded into_make_handlervia theobservationbus subscription. Gates onlyKIND_OBSERVATIONenvelopes; 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 scenariopartition_gossipintests/eval/scenarios.py: 4-agent four_corners with cliques{alpha, bravo}and{charlie, delta},range_lieattack 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 withGOSSIP_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. Newtest_partition_gossip_asymmetric_detectionasserts: bravo < 0.4, cross-clique views in (0.5, 0.85), gap > 0.25. Notebook 04 live cell added before the "Limit" cell — runspartition_gossipand renders per-viewer alpha rep with clique labeling. CI-compatible (uses the sameos.chdir(_root)+sys.path.insertpattern as notebook 08 to reachtests.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_detectiongreen; notebook 04 nbconvert-executes cleanly; broader eval suite carries from Wave 1. -
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 ofdocs/SLAM_PLAN.mdlands 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 withfield(default_factory=tuple)so pre-ADR-0016 callers keep working;box_world(..., landmarks=())kwarg. (b)LandmarkObservationwire type (messages.py): observer_id, landmark_id, range_m, bearing_rad, timestamp_ns, nlos flag;KIND_LANDMARK_OBSERVATION = "landmark_observation"; wired into_DECODERfor round-trip. The nlos flag rides in the payload so the Wave 1 pose-graph factor can applyσ_r × NLOS_INFLATE = 3.0per ADR 0016 §5 without needing to re-classify at ingest. (c)PoseGraphSlamABC +FactorResidualdataclass (interfaces.py): sibling toLocalSlam(not replacement — composition rule documented in docstring + ADR 0006 addendum: "consumes odometry fromLocalSlam.pose()"). Seven abstract methods:add_odometry,add_landmark_observation,add_inter_robot_observation,optimize,trajectory(),landmarks(),factor_residuals(). Thefactor_residuals()method is the preservation hook for Wave 6+ bidirectional trust↔SLAM coupling per ADR 0016 §2 — emits per-factorFactorResidual(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):Landmarktype matches Python shape;gridLandmarks(minX, minY, maxX, maxY, spacingM)generator produces deterministiclm-{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 existinglinalg.ts):CSCMatrixtype matchingscipy.sparse.csc_matrixstorage 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.pyadds landmark + CSC sections): 3 landmark cases (workshop_5m, workshop_2m, offset_origin) — TSgridLandmarksbyte-equal to Python reference at 12 decimals; 3 CSC cases (identity_3x3, tridiag_4x4 representing 1D pose-chain Hessian, mixed_5x5) —denseToCscmatchesscipy.sparse.csc_matrixindptr/indices/data byte-equal,cscMatVecmatchesA @ xto 10 decimals,cscTransposeinvolutive at 12 decimals. Quality gates: TS —just ui-typecheckclean,just ui-test103 passed (was 85; +18 new for landmark + CSC parity),just ui-buildsucceeds (worker 27.70 KB + lazy fixture chunks unchanged). Python —uv run ruff check src testsclean,uv run mypy src/specter/{trust,sim,slam,interfaces.py,messages.py}clean (19 source files),uv run pytest -q --ignore=sros2,integration,scale262 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). -
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 newsparseLinalg.ts(sibling to existinglinalg.tswhich keeps Jacobi for trust-layer MDS). Python usesscipy.sparse+ AMD orsksparse.cholmodif 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 onPoseGraphSlaminterface,source_tagfield on Beta evidence (already inBetaTrustEvaluatorAPI),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 +PoseGraphSlaminterface + sparse infra skeleton, 4 days). -
Coverage Parity Wave 1 —
late_range_lie+beacon_spoof+liar_then_healsupgrade (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 (seedocs/COVERAGE_PARITY_PRD.md) bringing the Python notebook curriculum and the React UI rail to redundant 100% live-demo coverage.late_range_liePython scenario added — mid-mission Byzantine flip on the range layer (attacker honest throughwake_tick, then inflatesrange_mbyRANGE_LIE_BIAS_M). Reuses the existingrange_lieAttackEvent kind with a delayedattack_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 insceneCopy.ts: prior claim "detection latency is identical to vanilla range_lie" was empirically false; new copy names the bound and citestest_late_range_lie_detected_after_wake. THREAT_MODEL.md row added forlate_range_liewith the measured latency curve.liar_then_healsbody 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'srecovery_after_liescenario. The skippedtest_liar_recovers_after_healing_via_decayis 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_spoofReact rail chip added —AttackEntryinATTACK_CATALOGwiring the already-exportedbeaconSpooffrom@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 indocs/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. -
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σ × Δtcompounding — 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 becomesr · 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 +PoseGraphSlaminterface, 3 days). -
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 — newPoseGraphSlamABC sibling toLocalSlam, consumesLocalSlam.pose()as odometry;DeadReckoningSlam/ScanMatchSlam/OccupancyMapMergerall 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, extendslinalg.ts(Jacobi already there);numpy.linalg.choleskyon 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 forpose_lienow flags the forthcoming second SLAM-layer detection path (pose-graph residuals composing with the existingOccupancyMapMergerpath per ADR 0015's SLAM-layer split); table row unchanged (no measurement until Wave 4 landstests/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; baselineuv run pytest -qre-run for sanity. Next: Wave 0.5 (landmark world primitive +PoseGraphSlaminterface, 3 days). -
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×honestopener for L01–03, 3×colluder_pairfor L05/08/09) and two whole defense layers (V3 transitive presence, trust-weighted COP filter) absent from the curriculum despite shipping insim-coreandATTACK_CATALOG. Type shape change:Lesson.attackId: string→attackIds: ReadonlyArray<string>so a single lesson can compose multiple attacks;selectLessonand 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" composesforged_envelope + replay_storm(defenses become visible — wire-layer drops scroll in the rejection feed instead of runninghonest); L02 "Reputation that forgives" runsrecovery_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" = newcop_corruption_fullcomposite. New scenario (cop_corruption_fullinscenarios.ts): onerangeLie(A0, 4.0)attacker + oneMapAttackunioning all three map-layer flavors (phantom UXO γ at (75,75), phantom HOSTILE FOB CLAIM at (88,88), suppresseduxo-bravo, suppressedfob-stalwart) — built as a composite because composing the three existingcop_*AttackEntry bundles would naively flatten threerangeLie(A0)wrappers and compound the inflation to 12m. Originalcop_phantom/cop_suppress/cop_fob_corruptchips remain in the AttackDock for hands-on exploration. L08's briefing closes with thepose_lie/drift_poseout-of-scope paragraph per ADR 0015 — declared limits get airtime without their own slot. Copy rewrite: everyLESSON_SCENEbody 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 dynamicNN/NNfrom active index +LESSONS.length;lesson.index <= 2(status dot) →=== 1(only L01 is rep-strip-silent under the new rail); LessonDots "of 09" → paddedLESSONS.length; WorkshopConsoleDEFAULT_LESSON = "03"→"02"(lands on the first lesson with visible Beta movement);lessonTitleShortmap fully rewritten. Layout config:LESSON_LAYOUTSrewritten — L01 hides the rep strip (wire-layer drops don't move Beta), L02–L08 all show it; tier indicators per defense;layoutForfallback 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.mdrewritten 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_corruptand mid-mission compromise tagged shipped). Quality gates:just ui-typecheckclean,just ui-test85/85 sim-core parity tests pass (no sim-core changes — pure app-package edit),just ui-buildsucceeds (7 fixtures + 1 streamed lesson). Out of scope (deferred): no01.jsonlesson-id-keyed fixture for the composed L01 (streams adequately); nocop_mislocate/cop_cosigned_phantom(still §3 gaps); no new ADR (curriculum design, not architecture decision). -
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-runsrunScenariowith a newattackStartTickeach change. Measured baseline (vite-node, honest scenario, 700 ticks): runScenario = 33s, derived passes = 12ms. Per-call profiling localized 99.9% of cost torecordObservation— specifically the unconditional_voteClosedCohorts()call (which[...this._pending.keys()]-spreads andBigInt(tsStr)-parses on every observation × 8 evaluators × 39200 observations = 314K invocations). D — sim-core targeted fixes: gate_voteClosedCohortson actual_decayClockNsadvance (skip when same-tick observations don't advance the clock — was firing redundantly), drop the keys-spread in favor of directMap.keys()iteration in both_voteClosedCohortsandflush, cacherangeSigma()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?workerimport) hostsrunScenario+ 3 derived passes.scenarioWorker.tsis the main-thread API. Main bundle dropped 245 KB → 234 KB becauserunScenario+composeBundlesno 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 optionalchunkSize+onChunktorunScenario(backward-compatible — no callback = no chunking). Worker invokes callback every 50 ticks, forwards viapostMessageas{kind:"chunk", chunk, soFar, total}; emits final{kind:"done", tail}with derived passes after the full run. Store accumulates snapshots as chunks arrive, setsisPlaying=trueonce ≥30 snapshots are available so playback starts before compute finishes.loadingProgressexposed viauseLoadingProgress()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 finaldonemessage — 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-typecheckclean,just ui-test85/85 parity tests pass,just ui-buildsucceeds (main 74.24 KB gzip, worker 27.70 KB, fixtures 5 chunks lazy-loaded). -
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) — EliminatesrunScenariocost for the 9 workshop lessons by pre-rendering scenarios at build time and lazy-loading the result. Generator (vite-node-runnable): walksLESSONS, dedupes byattackId(lessons 01/02/03 sharehonest, 05/08/09 sharecolluder_pair→ 5 unique fixtures for 9 lessons), runsrunScenario+ serializes snapshots / coverageGrid / mapAttacks / rejections tosrc/data/fixtures/<attackId>.json. Custom marker encoding for non-JSON types:{$bigint: "…"}forObservation.timestamp_ns,{$i32: […]}forCoverageGrid.firstSeenInt32Array. 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.globwith?rawquery → 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 inrequestIdleCallbackwithsetTimeoutfallback) to prefetch all 5 chunks during idle. Store wiring:selectLessontries fixture path first viasliceFromFixture()(synthesizes a stubScenarioSpeccarrying onlyattackStartTick+ a sentinel attackers length, plus a{snapshots}cast that satisfiesScenarioResult— the app reads neitherevaluatornoragentEvaluators); falls back tobuildSlice([attackId])only if the fixture is missing. Freeplay mode (setActiveAttacks,toggleAttack,setAttackStartTick) still runs the liverunScenario— fixture path is workshop-only by design. Discipline: regen fixtures whenever sim-core orscenarios.tschanges (analogous to the existing Python→TS parity fixture pattern).just ui-buildnow depends onjust ui-fixtures-lessons; fixtures gitignored to keep the repo lean. Quality gates:just ui-typecheckclean,just ui-test85/85 sim-core parity tests pass,just ui-buildsucceeds — 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, whererunScenariocost still hits. -
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.memoonSectorGrid,AOOutline,Legend(zero-prop static layers — now skip 100% of ticks),Fog,SensorFootprints,GossipMesh,Phantoms,Rejections(skip on hover-only re-renders), andAgentNodewith 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.useTickLoopswapped fromsetInterval(1000/hz)torequestAnimationFrame+ period accumulator — playback now aligns to display refresh, pauses automatically when tab hidden, no callback stacking when a tick exceeds budget.startTransitionwrapsset(...)inloadScenario/setActiveAttacks/setAttackStartTick/selectLessonso 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-typecheckclean,just ui-test85/85 sim-core parity tests pass,just ui-build242.58 kB bundle (unchanged from pre-slice). -
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; thegeometric_inconsistencyanomaly category is renamedrange_inconsistency. Wave 2 — Tier 2: newsrc/specter/trust/mds.py(pure-numpy, ~120 LOC) withembeddability_score,per_point_residuals,lying_edge_residuals(triangle-inequality-based blame localization). Whenk ≥ 3andembeddability_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) andcolluder_pair(a, b, bias_m=3.0)(Tier 2). Wave 3 — scale: procedural N-bot scenarios intests/eval/test_scale.pywith mixed kinematics (half rotating, half translation-only).min(rep across all viewer→peer pairs) > 0.5gated at B ∈ {4, 16}; B ∈ {64, 200} and the per-tick wall-clock baseline opt-in viaSPECTER_SCALE=1(mirrorsSPECTER_BATTERY_MULTIPROCESS=1pattern).just scale-checkruns the full sweep. Wave 4 — curriculum + docs:THREAT_MODEL.mdrewritten (pose-lie reclassified to trust-layer no-op;range_lie+colluder_pairrows added; Tier 1/Tier 2 mechanism described);RUNBOOK.mdanomaly category swap (outlier_observation+geometric_inconsistency→range_inconsistency); ADR 0015 status: Accepted; ADR 0003 amendment. Subject self-pose exits the trust path entirely —_self_posesretained insnapshot.pyfor 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 absentSPECTER_SCALE). ruff + mypy clean. -
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 foundation —adapters.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-runproduces parseable canonical-JSON-signed body). Wave 1 agent-node —SpecterAgentNodecomposes per-robot trust + SLAM stack (ScanMatchSlam+OccupancyMapMerger+BetaTrustEvaluator+ReplayWindow) onto anyMessageBus; deployment usesSros2Bus, headless smoke usesInProcessBus;identity_store.load_identityreads PEM private key from disk and refuses world/group-readable files.--dry-runpushes one forged envelope through the receive chain and assertsbad_signatureclassification — no rclpy required. Wave 1 dashboard —SpecterDashboardNodeis 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 underSDL_VIDEODRIVER=dummyfor headless smoke. Render helpers (to_screen,draw_history_overlay,draw_merged_grid,draw_anomaly_panel) lifted tosrc/specter/viz/dashboard.pyso demo + dashboard share one implementation. Wave 2 launch —launch/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 viamultiprocessing.Process, runs scenarios over real DDS, asserts detection within2 × InProcessBus baseline + 5ticks; gated behindSPECTER_BATTERY_MULTIPROCESS=1opt-in flag andRCLPY_AVAILABLEso dev-machinepytest -qskips cleanly).justfile:gen-roster,agent-node,dashboard,gazebo,battery-multiprocesstargets. Wave 3 runbook —docs/RUNBOOK.mdv0: 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. -
Workshop curriculum landed —
src/specter/demo/,viz/notebook.py, 9 notebooks, CI from scratch (W0–W10, ADR 0014) — Decomposesexamples/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 tosrc/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 viarecord_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 intests/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 amatplotlib.figure.Figure; smoke-tested underMPLBACKEND=Agg. 15 tests intests/test_viz_notebook.py.docs/abc_seams.svgstatic 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 runsMPLBACKEND=Agg jupyter nbconvert --to notebook --execute notebooks/*.ipynbend-to-end.justfileadditions:workshop,workshop-check,demo-smoke. mypy overrides for pre-existing pygame_viz + yaml stubs (methodology-noted ignorable) sojust lintreturns clean. ADR 0014 codifies the import-only + intuition/claim/limit cell discipline. 224 tests pass + 10 skipped (rclpy absent in CI). ruff + mypy clean. -
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/beaconBEST_EFFORT depth-1 with 50ms lifespan (latest-wins, drop stale);observationRELIABLE depth-20 with 100ms lifespan (cohort vote needs the full set);reputationRELIABLE 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 rclpyQoSProfiles; 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): runssingle_pose_liarunderLossyBus(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 inexamples/unified_demo.py:ReputationTraceinstantiated 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. -
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_envelopecarry the canonical-JSONenvelope_to_wire(env)bytes insidestd_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.msgwould 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) beforerclpy.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_timestamp→roster.lookup→RevocationList.is_revoked→AttestationProvider→ handler, with typedAnomalyEventemitted 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 overSros2Bus:single_pose_liar,replay_storm,sybil_flood (4:3),sybil_flood_mutual (4:3); assertions require detection within2 × baseline + 5ticks of the InProcessBus baseline. rclpy is an optional dependency — marshal-only tests (4) use a_StubByteMultiArraystand-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 onsrc/specter/transport. -
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, accumulatespeer_weights[agent_id]-scaled free votes (cells traversed) and occupied votes (endpoint cell), and emits a binary grid whereocc > freeper cell. Pose travels in the fragment as an additiveposefield — the lockedLocalSlam.map_fragmentABC is unchanged;encode_fragmenthelper builds the merger-compatible payload at fragment-publish time. Honest 4-peer eval (tests/eval/test_map_convergence.py) onfour_corners.yamlfor 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 moffset walls, walking a 4×4 grid of poses to spread fake votes; withpeer_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 onsrc/specter/slam. -
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 thatInProcessBushides, 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. Optionalorder_keycallable preserves per-key FIFO (used in the battery test withsender_idas 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()runsSimulation.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; raisesclock_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 throughLossyBus(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 within2 × baseline + 5ticks. 19 owned tests pass; full suite (190 tests) green; ruff + mypy clean onsrc/specter/transport. -
Identity layer — runtime roster, rotation, revocation, attestation (
src/specter/identity/, ADR 0009) — Closes the V2 sybil residual identified inTHREAT_MODEL.mdand lifts the "frozen roster at swarm formation" Phase 1 limitation. Four composable pieces, all drop-in at the bus boundary without modifyingsecure_bus.py:MutableRosterwrapsRosterwith timestamped add/remove/rotate + append-only audit log;KeyRotationAnnouncement+verify_rotation+apply_rotationimplement standard sign-the-new-key-with-the-old-key rotation, with explicitkey_revoked_post_rotationrejection for envelopes signed by rotated-out keys;RevocationListis forward-only key blocklist (is_revoked(t_ns));AttestationProviderABC +MockAttestationProvider(allowlist)+AttestationRequiredFilterclose the V2 residual in software (mock impl; real TPM/SE integration is Phase 4 per ADR 0009). 21 tests acrosstests/test_identity.py+tests/eval/test_identity_attacks.pycover: 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. -
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: wrotetests/eval/test_slam_drift.pyfirst 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.DeadReckoningSlamretained 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). -
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 mutualObservations 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: whenself_idis 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-enveloperecord_acceptα so high-volume mutual fake-corroboration can't outpace the rule on count. New scenariosybil_flood_mutualin 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) whenself_idis None. -
Beacon-presence Sybil defense V1 (
src/specter/trust/evaluator.py) — Per-evaluator state_seen_by: dict[subject, dict[observer, ts]], populated on everyrecord_observation. Two enforcement points: (a)record_pose_reportaccumulates β=1 when the peer has no recent beacon corroboration (window=2s) and the evaluator has at least one observation on file (bootstrap-aware); (b)_votezero-weights observers without presence so the weighted median tips toward physically-corroborated peers. Sybils have no body — honest robots' real beacons never list them assubject_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 mutualObservations of each other still escape — observer-rep-weighted presence (V2) is the proper fix and is now the next defensive slice. Unit tests use aseed_presencehelper to mirror the mutual-beacon pattern that real eval scenarios get for free. -
Sybil-scale eval (
tests/eval/test_sybil_scale.py) — Parametric sweep oversybil_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. -
Local SLAM + sensor realism (6-slice plan, this work) — Replaced sim ground-truth pose with
DeadReckoningSlamper 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-truthsubject_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. -
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.
-
sybil flood scenario —
SybilSpecadds 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. -
35f9137expanded attack battery — three new attack kinds intests/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. -
6de52c0voting-order fix (cohort-close gating) — Replaced early-fireMIN_OBSERVERS=3voting with cohort-close: a(subject, ts)cohort votes only when an envelope with latertsarrives (orflush()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 testtest_vote_waits_for_cohort_close_before_firing. -
df23ca2eval harness —tests/eval/withScenario/AttackEvent/EvaluationResultandrun_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. -
7c3f65bweighted voting + agreement rewards — Weighted median uses_first_hand_score(observer)as weight, abstains belowMIN_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. -
be164dareputation gossip —ReputationGossippayload, per-agent evaluators, trust-weighted gossip integration withGOSSIP_DISCOUNT=0.1. DemoVkey cycles viewer perspective. -
9b83a11decay — 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. DemoHheals a liar; rehabilitation visibly climbs over a few half-lives. -
30bbff3voting + triangulation — Median-of-claims consensus disambiguates liar from framed peer. Buckets observations by (subject, ts), abstains belowMIN_OBSERVERS=3, blames outliers and lying subjects independently. -
c438ad9geometric cross-validation — Per-event geometric rule (now superseded by voting). Introducedgeometric_inconsistencyanomaly category and the pose-cache. -
fdf9838trust evaluator (Beta core) —BetaTrustEvaluatorimplements the ABC, classifiesVerificationErrorstrings into four anomaly categories, emits typed events on aTelemetrysink. Reputation bars in the demo. -
575c566unified demo — Single pygame window: corridor sim + signed bus + scrolling envelope log.Xkeypress to swap a keypair. -
539c664signed bus — ECDSA + nonce replay window + roster + version. Five rejection categories tested. -
5d41d62wall-bounce physics — Optional bouncing for visually richer scenarios. -
f64a7bdYAML scenarios — Worlds editable without Python. -
15e8420Phase 0 + Phase 1 minimal — ABCs locked, deterministic 2D sim ticking.
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 inscenarios/(defaultfour_agents_corridor.yaml).SPECTER_DEMO_BUS=inproc|lossy|sros2— wrapsInProcessBuswithLossyBus(drop=5%, jitter=10ms), or constructsSros2Busover real DDS (falls back toinprocwith 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.
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.
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)In rough priority order:
Scan-match SLAM upgrade— Done (Recent slices #1, ADR 0007). Drift bounded under bouncing-wall scenarios.- MapMerger — trust-weighted fusion of
map_fragment()outputs across peers. Low-rep peers' fragments down-weighted in the merge. Larger-scale Sybil eval— Done. Ceiling was 4:3.Beacon-presence Sybil defense V1— Done. Ceiling broken on the no-mutual variant.Beacon-presence Sybil defense V2 — self-anchored— Done (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.- SROS2 transport swap — InProcessBus → SROS2 over the same envelope contract. Unblocks running on real ROS2 nodes (target platform per ADR 0006).
- Hardware-in-loop bridge — Gazebo + TurtleBot4 model running the same code, then real Crazyflie+UWB swarm.
The list above is rough-priority by impact. The recommended next-up sequence trades against risk and unlock value:
Larger-scale Sybil eval— Done.Sybil defense V1 (beacon-presence, any-grantor)— Done.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.Scan-match SLAM upgrade— Done. ADR 0007.- 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." Touchessrc/specter/slam/+ a new module. - 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."
- Hardware-in-loop is the largest commitment; defer until both MapMerger and SROS2 hold end-to-end in sim + Gazebo.
- 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.
LossyBuswrapper deferred until the SROS2 swap exposes real packet loss. - Nonholonomic motion model —
Agent.stepis holonomic. Revisit when targeting differential-drive hardware. - Persistent telemetry sink —
ListTelemetryis 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 rotation— Done (Recent slices #1).identity.MutableRoster+identity.rotationship runtime add/remove and the standard sign-with-old-key rotation protocol; old-key envelopes post-rotation are rejected with the explicitkey_revoked_post_rotationcategory.