[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
@@ -0,0 +1,139 @@
//! `IgnoredSet` — operator-declined POIs are suppressed before they reach
//! the scan controller's POI queue (see `system-flows.md §F7` and the
//! `mapobjects_store` component description §"Ignored set").
//!
//! Keyed by `(mgrs, class_group)` because that is the literal call shape
//! the AC mandates (`is_ignored(mgrs, class_group)`). The `IgnoredItem`
//! payload also carries an H3 cell + retention metadata; we keep the
//! full payload separately so callers can read it later (e.g. for
//! pending-upload sync in AZ-667) without re-fetching from the central
//! service.
use std::collections::{HashMap, HashSet};
use shared::models::mapobject::IgnoredItem;
/// In-memory ignored-suppression index.
#[derive(Debug, Default)]
pub struct IgnoredSet {
/// O(1) suppression lookup. Multiple `IgnoredItem`s may share the
/// same `(mgrs, class_group)` key — the set still answers `true`
/// for the pair.
keys: HashSet<(String, String)>,
/// Full payloads, keyed by their UUID, retained for sync /
/// pending-upload paths in AZ-667.
items: HashMap<uuid::Uuid, IgnoredItem>,
}
impl IgnoredSet {
pub fn new() -> Self {
Self::default()
}
/// Append an `IgnoredItem`. Re-appending the same UUID overwrites
/// the prior payload (e.g. when the central sync echoes back a
/// record the device just appended locally).
pub fn append(&mut self, item: IgnoredItem) {
self.keys
.insert((item.mgrs.clone(), item.class_group.clone()));
self.items.insert(item.id, item);
}
/// O(1) suppression check used by `scan_controller`'s POI gate.
pub fn is_ignored(&self, mgrs: &str, class_group: &str) -> bool {
// HashSet does not support borrowed-tuple lookup against
// `(String, String)` keys, so build the lookup tuple. The
// strings are short (MGRS ≤ ~15 chars, class_group ≤ ~32) so
// the clone cost is well inside the ≤ 1 ms p99 budget.
self.keys
.contains(&(mgrs.to_string(), class_group.to_string()))
}
/// Number of distinct `(mgrs, class_group)` pairs currently
/// suppressed. Useful for health surfaces and tests.
pub fn len(&self) -> usize {
self.keys.len()
}
/// Clippy companion to `len`. Kept available because callers
/// (health surfaces, sync paths) may want a quick empty check
/// instead of a length comparison.
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
/// Full payload iterator. Reserved for AZ-667 (pending-upload
/// dump) and AZ-668 (persistence snapshot).
#[allow(dead_code)]
pub fn items(&self) -> impl Iterator<Item = &IgnoredItem> {
self.items.values()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use shared::models::mapobject::{IgnoredItemSource, RetentionScope};
use uuid::Uuid;
fn ignored(mgrs: &str, class_group: &str) -> IgnoredItem {
IgnoredItem {
id: Uuid::new_v4(),
mgrs: mgrs.into(),
h3_cell: 0,
class_group: class_group.into(),
decline_time: Utc::now(),
operator_id: None,
mission_id: "m1".into(),
retention_scope: RetentionScope::Mission,
expires_at: None,
source: IgnoredItemSource::LocalAppended,
pending_upload: true,
}
}
#[test]
fn appended_pair_is_ignored() {
// Arrange
let mut s = IgnoredSet::new();
// Act
s.append(ignored("38TUL12345", "concealed_position_group"));
// Assert
assert!(s.is_ignored("38TUL12345", "concealed_position_group"));
assert!(!s.is_ignored("38TUL12345", "movement_candidate"));
assert!(!s.is_ignored("38TUL99999", "concealed_position_group"));
assert_eq!(s.len(), 1);
}
#[test]
fn re_append_does_not_inflate_distinct_count() {
// Arrange
let mut s = IgnoredSet::new();
let it = ignored("38TUL12345", "concealed_position_group");
let id = it.id;
// Act
s.append(it);
s.append(IgnoredItem {
id,
..ignored("38TUL12345", "concealed_position_group")
});
// Assert
assert_eq!(s.len(), 1);
assert_eq!(s.items.len(), 1);
}
#[test]
fn empty_set_returns_false() {
// Assert
let s = IgnoredSet::new();
assert!(!s.is_ignored("foo", "bar"));
assert_eq!(s.len(), 0);
assert!(s.is_empty());
}
}
@@ -1,4 +1,6 @@
//! Internal-only modules. Not part of the public `mapobjects_store` API.
pub mod h3_index;
pub mod ignored;
pub mod passes;
pub mod store;
@@ -0,0 +1,194 @@
//! Per-region pass tracker for `end_of_pass(region)` sweeps.
//!
//! `scan_controller` / `mission_executor` open a pass with `pass_start`
//! when the UAV begins traversing a region; classify calls that fall
//! inside any open pass automatically register the matched MapObject
//! `id` as "observed during this pass". When the pass closes, the
//! `end_of_pass` sweep returns objects in the region that were known
//! at pass start but *not* observed during the pass — they become
//! `RemovedCandidate`s that the operator (not the device) decides on.
//!
//! Bounding-box test uses the half-open WGS-84 rectangle `[NW, SE]`
//! convention used everywhere else in the project (see
//! `data_model.md`).
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use shared::models::mission::Coordinate;
use uuid::Uuid;
/// Operator-supplied region bounding box. `corners[0]` is NW, `corners[1]`
/// is SE — same orientation as `MapObjectsBundle.bbox`.
pub type RegionBbox = [Coordinate; 2];
/// Generate the deterministic per-region key from the bbox corners.
///
/// We rely on bit-for-bit equality of the `f64` corners because the
/// `scan_controller` always re-uses the exact same region descriptor
/// across `pass_start` / `end_of_pass`. Floating-point equality is
/// fine for that producer; callers that round-trip through JSON should
/// re-use the same struct.
fn key(bbox: &RegionBbox) -> RegionKey {
let nw = bbox[0];
let se = bbox[1];
RegionKey {
nw_lat_bits: nw.latitude.to_bits(),
nw_lon_bits: nw.longitude.to_bits(),
se_lat_bits: se.latitude.to_bits(),
se_lon_bits: se.longitude.to_bits(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct RegionKey {
nw_lat_bits: u64,
nw_lon_bits: u64,
se_lat_bits: u64,
se_lon_bits: u64,
}
#[derive(Debug, Clone)]
struct OpenPass {
bbox: RegionBbox,
started_at: DateTime<Utc>,
observed: HashSet<Uuid>,
}
#[derive(Debug, Default)]
pub struct PassTracker {
open: HashMap<RegionKey, OpenPass>,
}
impl PassTracker {
pub fn new() -> Self {
Self::default()
}
/// Open a new pass over `bbox`. If a pass is already open over the
/// same bbox we restart it (the `scan_controller` is the only
/// producer and this matches its retry behaviour).
pub fn pass_start(&mut self, bbox: RegionBbox, started_at: DateTime<Utc>) {
let k = key(&bbox);
self.open.insert(
k,
OpenPass {
bbox,
started_at,
observed: HashSet::new(),
},
);
}
/// Mark `id` as observed during every open pass whose bbox
/// contains `(lat, lon)`. The classify path calls this on every
/// `Existing` / `Moved` / `New` outcome so the caller does not
/// have to thread the bbox through.
pub fn note_observed(&mut self, id: Uuid, lat: f64, lon: f64) {
for pass in self.open.values_mut() {
if bbox_contains(&pass.bbox, lat, lon) {
pass.observed.insert(id);
}
}
}
/// Close the pass over `bbox` and return the observed ids that the
/// caller needs to compare against the store's known-in-region set.
/// Returns `None` if no pass was open over that bbox.
pub fn pass_end(&mut self, bbox: &RegionBbox) -> Option<PassResult> {
let k = key(bbox);
let open = self.open.remove(&k)?;
Some(PassResult {
started_at: open.started_at,
observed: open.observed,
})
}
/// Number of currently-open passes (health surface).
pub fn open_passes(&self) -> usize {
self.open.len()
}
}
#[derive(Debug)]
pub struct PassResult {
pub started_at: DateTime<Utc>,
pub observed: HashSet<Uuid>,
}
/// Half-open bbox containment: lat in `[se_lat, nw_lat]`, lon in `[nw_lon, se_lon]`.
///
/// `NW` is north-west (highest lat, lowest lon), `SE` is south-east
/// (lowest lat, highest lon). We use closed-closed because tests
/// commonly place points exactly on a corner and there is no risk of
/// double-counting in this code path (a point on a shared boundary
/// belongs to every covering pass, by design).
pub fn bbox_contains(bbox: &RegionBbox, lat: f64, lon: f64) -> bool {
let nw = bbox[0];
let se = bbox[1];
let (lat_min, lat_max) = (se.latitude.min(nw.latitude), se.latitude.max(nw.latitude));
let (lon_min, lon_max) = (
nw.longitude.min(se.longitude),
nw.longitude.max(se.longitude),
);
(lat_min..=lat_max).contains(&lat) && (lon_min..=lon_max).contains(&lon)
}
#[cfg(test)]
mod tests {
use super::*;
fn bbox(nw_lat: f64, nw_lon: f64, se_lat: f64, se_lon: f64) -> RegionBbox {
[
Coordinate {
latitude: nw_lat,
longitude: nw_lon,
altitude_m: 0.0,
},
Coordinate {
latitude: se_lat,
longitude: se_lon,
altitude_m: 0.0,
},
]
}
#[test]
fn bbox_contains_inside_point() {
// Arrange
let b = bbox(51.0, 30.0, 50.0, 31.0);
// Assert
assert!(bbox_contains(&b, 50.5, 30.5));
assert!(!bbox_contains(&b, 49.9, 30.5));
assert!(!bbox_contains(&b, 50.5, 31.1));
}
#[test]
fn note_observed_only_inside_open_pass() {
// Arrange
let mut t = PassTracker::new();
let b = bbox(51.0, 30.0, 50.0, 31.0);
t.pass_start(b, Utc::now());
let inside = Uuid::new_v4();
let outside = Uuid::new_v4();
// Act
t.note_observed(inside, 50.5, 30.5);
t.note_observed(outside, 49.5, 30.5);
// Assert
let result = t.pass_end(&b).expect("pass open");
assert!(result.observed.contains(&inside));
assert!(!result.observed.contains(&outside));
assert_eq!(t.open_passes(), 0);
}
#[test]
fn pass_end_returns_none_when_no_pass_open() {
// Arrange
let mut t = PassTracker::new();
let b = bbox(51.0, 30.0, 50.0, 31.0);
// Assert
assert!(t.pass_end(&b).is_none());
}
}
+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 })
}
}