[AZ-666] [AZ-673] [AZ-648] ignored set + UDS VLM + mission FSM batch 5
ci/woodpecker/push/build-arm Pipeline failed

AZ-666 mapobjects_store:
- internal/ignored.rs (HashSet<(mgrs, class_group)> for O(1) suppression)
- internal/passes.rs (per-region PassTracker with observed-id set and
  end-of-pass removed-candidate sweep)
- Classification::Ignored wired into classify; apply_decline +
  is_ignored + pass_start + end_of_pass on MapObjectsStoreHandle
- new tests/ignored_and_sweep.rs (3 AC + 2 supplementary)

AZ-673 vlm_client:
- internal/peer_cred.rs (Linux SO_PEERCRED via libc getsockopt;
  PeerCredOutcome::SkippedNonLinux on macOS dev hosts per
  description.md §8)
- internal/prompt.rs (pre-send ROI size + format + prompt
  non-emptiness validation)
- internal/wire.rs (length-prefixed JSON envelope with base64 ROI)
- internal/uds_client.rs (tokio UnixStream client; bounded
  reconnect; hard-stop on peer-cred mismatch; per-request deadline)
- VlmClient with both eager (open/connect) and lazy (new) ctor
- workspace Cargo.toml: base64 + libc as workspace deps

AZ-648 mission_executor:
- internal/types.rs (Variant, MissionState, TransitionKey,
  Telemetry, TransitionEvent, StepOutcome)
- internal/driver.rs (MissionDriver trait + DriverError +
  DriverAction)
- internal/fsm.rs (variant-agnostic Transition + FsmCore + step_one
  with per-transition retry budget keyed by TransitionKey)
- internal/multirotor.rs + internal/fixed_wing.rs (typed transition
  tables; multirotor has Armed/TakeOff, fixed-wing parks in
  WaitAuto for operator AUTO)
- public API: MissionExecutor::run spawns the FSM task and returns
  a clone-safe MissionExecutorHandle (state, health, subscribe,
  paused_reason, retry_count)
- new tests/state_machine.rs (AC-1..AC-4 via ScriptedDriver fake;
  SITL conformance lands with AZ-649 telemetry forwarding)

Workspace: cargo fmt + clippy -D warnings clean; full
cargo test --workspace --all-features green (1 ignored = AZ-665
perf gate). Tasks moved todo/ → done/, autodev state set to batch
6 selection.

Refs: _docs/03_implementation/batch_05_cycle1_report.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 16:54:00 +03:00
parent 69c0629350
commit b5cc0c321c
30 changed files with 3343 additions and 111 deletions
+107 -8
View File
@@ -15,9 +15,12 @@ use std::collections::HashMap;
use chrono::{DateTime, Utc};
use h3o::CellIndex;
use shared::error::Result;
use shared::models::mapobject::IgnoredItem;
use uuid::Uuid;
use super::h3_index::{cell_of, grid_disk, haversine_m, DEFAULT_K_RING, DEFAULT_RESOLUTION};
use super::ignored::IgnoredSet;
use super::passes::{bbox_contains, PassTracker, RegionBbox};
/// Per-detection input to `classify`. This bundles the georeferenced
/// payload the architecture-level "detection" carries (gps, class, conf,
@@ -86,14 +89,26 @@ pub enum Classification {
Existing {
id: Uuid,
},
/// Reserved for AZ-666 end-of-pass sweep.
RemovedCandidate {
id: Uuid,
},
/// Reserved for AZ-666 ignored-suppression.
/// Suppressed because the `(mgrs, class_group)` pair is in the
/// `IgnoredSet` — the operator previously declined this POI.
/// `scan_controller` must drop the detection without queueing it.
Ignored,
}
/// Object that the store knew about at pass start but did not see
/// re-observed before `end_of_pass`. See `system-flows.md §F7`
/// "end-of-pass sweep" — operator (not device) decides removal.
#[derive(Debug, Clone, PartialEq)]
pub struct RemovedCandidate {
pub id: Uuid,
pub mgrs: String,
pub class: String,
pub class_group: String,
pub gps_lat: f64,
pub gps_lon: f64,
pub last_seen: DateTime<Utc>,
}
/// Stored shape. Fields beyond what `classify` reads are kept for the
/// next batch in the same component (AZ-666 ignored-suppression / sweep,
/// AZ-667 hydrate / dump_pending) which will surface them via the engine
@@ -122,6 +137,8 @@ pub struct Store {
by_cell: HashMap<CellIndex, Vec<StoredMapObject>>,
/// Total object count, maintained alongside `by_cell` for O(1) metrics.
len: usize,
ignored: IgnoredSet,
passes: PassTracker,
}
impl Store {
@@ -130,6 +147,8 @@ impl Store {
config,
by_cell: HashMap::new(),
len: 0,
ignored: IgnoredSet::new(),
passes: PassTracker::new(),
}
}
@@ -137,12 +156,79 @@ impl Store {
self.len
}
/// Exposed for AZ-666/AZ-667 engine plug-points (`internal::engine::*`).
/// Forward-use hook for AZ-667 / AZ-668 engine plug-points.
#[allow(dead_code)]
pub fn config(&self) -> &MapObjectsStoreConfig {
&self.config
}
/// Suppression query used by `scan_controller`'s POI gate.
pub fn is_ignored(&self, mgrs: &str, class_group: &str) -> bool {
self.ignored.is_ignored(mgrs, class_group)
}
/// Append an `IgnoredItem` (operator declined a POI, or a hydrate
/// from `mission_client` pulled it down).
pub fn append_ignored(&mut self, item: IgnoredItem) {
self.ignored.append(item);
}
/// Number of distinct ignored `(mgrs, class_group)` pairs.
pub fn ignored_len(&self) -> usize {
self.ignored.len()
}
/// Open a scan pass over `bbox`. `scan_controller` / `mission_executor`
/// call this when entering a region; the matching `end_of_pass`
/// returns un-observed objects as `RemovedCandidate`s.
pub fn pass_start(&mut self, bbox: RegionBbox, started_at: DateTime<Utc>) {
self.passes.pass_start(bbox, started_at);
}
/// Close the pass over `bbox` and return objects in the region that
/// were not observed since the pass started, excluding ignored
/// objects. Returns an empty vec if no pass was open.
pub fn end_of_pass(&mut self, bbox: &RegionBbox) -> Vec<RemovedCandidate> {
let Some(result) = self.passes.pass_end(bbox) else {
return Vec::new();
};
let mut out = Vec::new();
for objects in self.by_cell.values() {
for obj in objects {
if !bbox_contains(bbox, obj.gps_lat, obj.gps_lon) {
continue;
}
if result.observed.contains(&obj.id) {
continue;
}
// Filter out ignored — operator already said "no" on
// this pair; surfacing it again would be noise.
if self.ignored.is_ignored(&obj.mgrs, &obj.class_group) {
continue;
}
// Pass started after the object's last_seen → object
// was known at pass start.
if obj.last_seen > result.started_at {
continue;
}
out.push(RemovedCandidate {
id: obj.id,
mgrs: obj.mgrs.clone(),
class: obj.class.clone(),
class_group: obj.class_group.clone(),
gps_lat: obj.gps_lat,
gps_lon: obj.gps_lon,
last_seen: obj.last_seen,
});
}
}
out
}
pub fn open_passes(&self) -> usize {
self.passes.open_passes()
}
/// Resolve a raw class string to its canonical group key.
///
/// The first class listed in a `similar_classes` group is the group
@@ -162,11 +248,20 @@ impl Store {
/// Classify a single detection input. Mutates the store on `New` /
/// `Moved` / `Existing` (insert / position-update / last_seen-update
/// respectively). Returns the classification.
/// respectively). Returns `Ignored` and DOES NOT mutate when the
/// resolved `(mgrs, class_group)` is in the ignored set.
///
/// Also notes the matched id into every open pass whose bbox
/// contains the input GPS so end-of-pass sweeps see this object
/// as observed.
pub fn classify(&mut self, input: ClassifyInput) -> Result<Classification> {
let query_cell = cell_of(input.gps_lat, input.gps_lon, self.config.h3_resolution)?;
let group = self.group_key(&input.class);
if self.ignored.is_ignored(&input.mgrs, &group) {
return Ok(Classification::Ignored);
}
// Find the nearest matching object across the k-ring.
let mut best: Option<(CellIndex, usize, f64)> = None;
let disk = grid_disk(query_cell, self.config.k_ring);
@@ -217,6 +312,7 @@ impl Store {
..moved
});
}
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
Ok(Classification::Moved {
id,
from_mgrs,
@@ -231,7 +327,9 @@ impl Store {
.expect("cell present during best-match scan");
let obj = &mut bucket[idx];
obj.last_seen = input.observed_at;
Ok(Classification::Existing { id: obj.id })
let id = obj.id;
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
Ok(Classification::Existing { id })
}
None => {
// NEW — insert.
@@ -253,6 +351,7 @@ impl Store {
};
self.by_cell.entry(query_cell).or_default().push(stored);
self.len += 1;
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
Ok(Classification::New { id })
}
}