//! In-memory hashmap of known map objects, keyed by H3 cell. //! //! Classification logic (NEW / MOVED / EXISTING) lives here. Per //! `architecture.md §7.12` the on-device map keeps the full per-mission //! state in memory; persistence (AZ-668) lands later. //! //! Concurrency: this module is intentionally single-threaded and not //! `Sync`. The public `MapObjectsStoreHandle` wraps it in an `Arc>` //! so the lock surface is a single owned mutex instead of fine-grained //! per-cell locking. With p99 ≤ 1 ms and detection rates < 30 Hz the //! single mutex is comfortably within budget. use std::collections::HashMap; use chrono::{DateTime, Utc}; use h3o::CellIndex; use serde::{Deserialize, Serialize}; use shared::error::Result; use shared::models::mapobject::{ BundleFreshness, DiffKind, IgnoredItem, IgnoredItemSource, MapObject, MapObjectObservation, MapObjectsBundle, }; 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}; use super::snapshot::{Snapshot, SnapshotMapObject}; /// Sync state machine surfaced to `scan_controller` + health aggregator. /// /// See `_docs/02_document/components/mapobjects_store/description.md §3`. /// `Failed` is the bounded-retries-exhausted terminal state for the /// post-flight push (Frozen choice 7 / `description.md §7`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SyncState { /// Initial state at process boot; no hydrate has run yet. FreshBoot, /// Last pull / push succeeded against the central API. Synced, /// Last pull failed but the on-device cache was applied as a /// fallback. `scan_controller` MUST gate this on operator /// acknowledgement before takeoff. CachedFallback, /// Stale cache or transient push failure; new MapObject diff /// classifications are suppressed by `scan_controller`. Degraded, /// Bounded retries exhausted (post-flight push). Operator-visible /// warning; mission's central data integrity at risk until /// manually replayed. Failed, } /// Per-detection input to `classify`. This bundles the georeferenced /// payload the architecture-level "detection" carries (gps, class, conf, /// size — see `system-flows.md §F7`) without forcing the shared /// `Detection` model to grow geolocation fields. `scan_controller` builds /// this from `Detection` + GPS / MGRS context at the call site. #[derive(Debug, Clone)] pub struct ClassifyInput { pub gps_lat: f64, pub gps_lon: f64, pub mgrs: String, pub class: String, pub size_width_m: f32, pub size_length_m: f32, pub confidence: f32, pub mission_id: String, pub observed_at: DateTime, /// Airframe identifier the detection originated from. Threaded into /// `MapObjectObservation::uav_id` for the post-flight push log /// (AZ-667). Empty string is acceptable for single-UAV deployments /// and unit tests; production callers (`scan_controller`) supply /// the configured UAV id. #[doc(alias = "uav")] pub uav_id: String, /// Monotonic clock reading at detection time. Threaded into /// `MapObjectObservation::observed_at_monotonic_ns` so observation /// ordering survives wallclock skew. `0` is acceptable when the /// caller has no monotonic source (e.g. unit tests). pub observed_at_monotonic_ns: u64, } /// Configuration for the spatial-index + classification policy. #[derive(Debug, Clone)] pub struct MapObjectsStoreConfig { /// H3 cell resolution. Default 10 (~15 m edge). pub h3_resolution: u8, /// K-ring radius for boundary-safe lookups. Default 2. pub k_ring: u32, /// Maximum distance (m) between input and stored object for the pair /// to be considered a possible match. Beyond this → `NEW`. pub distance_threshold_m: f64, /// Above this delta (m) between input position and the matched /// object's stored position, classification flips to `MOVED`. pub move_threshold_m: f64, /// Class-similarity groups. Each inner vec is one group; classes in /// the same group are considered equivalent for matching (e.g. /// `tree` and `shrub` collapsed). A class not listed in any group /// is its own group of one. pub similar_classes: Vec>, } impl Default for MapObjectsStoreConfig { fn default() -> Self { Self { h3_resolution: DEFAULT_RESOLUTION, k_ring: DEFAULT_K_RING, // Defaults follow `system-flows.md §F7` (distance 50 m, // move 10 m). The task brief lists different per-AC values // (30 m / 50 m) — callers override per scenario. distance_threshold_m: 50.0, move_threshold_m: 10.0, similar_classes: Vec::new(), } } } /// Outcome of `MapObjectsStore::classify`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Classification { New { id: Uuid, }, Moved { id: Uuid, from_mgrs: String, to_mgrs: String, }, Existing { id: Uuid, }, /// 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, } /// 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 /// API. The lint allow is scoped to those forward-use fields. #[allow(dead_code)] #[derive(Debug, Clone)] struct StoredMapObject { id: Uuid, h3_cell: CellIndex, mgrs: String, class: String, class_group: String, gps_lat: f64, gps_lon: f64, size_width_m: f32, size_length_m: f32, confidence: f32, first_seen: DateTime, last_seen: DateTime, mission_id: String, } /// In-memory spatial index of known map objects. pub struct Store { config: MapObjectsStoreConfig, by_cell: HashMap>, /// Total object count, maintained alongside `by_cell` for O(1) metrics. len: usize, ignored: IgnoredSet, passes: PassTracker, /// Append-only log of NEW / MOVED / EXISTING / REMOVED-CANDIDATE /// events for the post-flight push (AZ-667). Drained by /// `mission_client::push_mapobjects_diff` after landing — central /// writes mid-flight are forbidden (Frozen choice 6). pending_observations: Vec, /// Append-only log of locally-appended `IgnoredItem`s for the /// post-flight push (AZ-667). pending_ignored: Vec, sync_state: SyncState, last_pull_ts: Option>, last_push_ts: Option>, } impl Store { pub fn new(config: MapObjectsStoreConfig) -> Self { Self { config, by_cell: HashMap::new(), len: 0, ignored: IgnoredSet::new(), passes: PassTracker::new(), pending_observations: Vec::new(), pending_ignored: Vec::new(), sync_state: SyncState::FreshBoot, last_pull_ts: None, last_push_ts: None, } } pub fn len(&self) -> usize { self.len } /// 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). When the item is /// `LocalAppended` it ALSO joins `pending_ignored` so the /// post-flight push surfaces it to central. pub fn append_ignored(&mut self, item: IgnoredItem) { if matches!(item.source, IgnoredItemSource::LocalAppended) { self.pending_ignored.push(item.clone()); } 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) { 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. /// /// Each returned `RemovedCandidate` is also appended to the /// `pending_observations` log as a `DiffKind::RemovedCandidate` /// event so the post-flight push surfaces it to central. pub fn end_of_pass(&mut self, bbox: &RegionBbox) -> Vec { 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, }); } } // Mirror each removed candidate into the pending observation // log; lookup of the stored object's mission_id keeps the // observation traceable end-to-end. let ended_at = Utc::now(); for r in &out { let mission_id = self.find_mission_id(r.id).unwrap_or_default(); self.pending_observations.push(MapObjectObservation { id: r.id, h3_cell: u64::from( cell_of(r.gps_lat, r.gps_lon, self.config.h3_resolution) .expect("H3 cell lookup must succeed for stored coordinates"), ), class: r.class.clone(), class_group: r.class_group.clone(), mission_id, uav_id: String::new(), observed_at_monotonic_ns: 0, observed_at_wallclock: ended_at, gps_lat: r.gps_lat, gps_lon: r.gps_lon, mgrs: r.mgrs.clone(), size_width_m: 0.0, size_length_m: 0.0, confidence: 0.0, diff_kind: DiffKind::RemovedCandidate, photo_ref: None, raw_evidence: None, }); } out } fn find_mission_id(&self, id: Uuid) -> Option { self.by_cell.values().flatten().find_map(|o| { if o.id == id { Some(o.mission_id.clone()) } else { None } }) } pub fn open_passes(&self) -> usize { self.passes.open_passes() } /// Number of unpushed local observations. pub fn pending_observations_count(&self) -> usize { self.pending_observations.len() } /// Number of unpushed locally-declined items. pub fn pending_ignored_count(&self) -> usize { self.pending_ignored.len() } pub fn sync_state(&self) -> SyncState { self.sync_state } pub fn last_pull_ts(&self) -> Option> { self.last_pull_ts } pub fn last_push_ts(&self) -> Option> { self.last_push_ts } pub fn set_sync_state(&mut self, state: SyncState) { self.sync_state = state; } /// Load the in-memory map from a central-pulled bundle. Replaces /// any existing entries (the bundle is authoritative). The /// sync_state moves to `Synced` for a fresh bundle or /// `CachedFallback` for a `Stale` one. `last_pull_ts` is set to /// `bundle.as_of`. pub fn hydrate(&mut self, bundle: MapObjectsBundle) -> Result<()> { self.by_cell.clear(); self.len = 0; // Replace the IgnoredSet entirely — central is authoritative. self.ignored = IgnoredSet::new(); let MapObjectsBundle { map_objects, ignored_items, as_of, freshness, .. } = bundle; for mo in map_objects { self.insert_hydrated(mo)?; } for item in ignored_items { self.ignored.append(item); } self.sync_state = match freshness { Some(BundleFreshness::Stale) => SyncState::CachedFallback, _ => SyncState::Synced, }; self.last_pull_ts = Some(as_of); Ok(()) } fn insert_hydrated(&mut self, mo: MapObject) -> Result<()> { let cell = cell_of(mo.gps_lat, mo.gps_lon, self.config.h3_resolution)?; self.by_cell.entry(cell).or_default().push(StoredMapObject { id: Uuid::new_v4(), h3_cell: cell, mgrs: mo.mgrs_key, class: mo.class, class_group: mo.class_group, gps_lat: mo.gps_lat, gps_lon: mo.gps_lon, size_width_m: mo.size_width_m, size_length_m: mo.size_length_m, confidence: mo.confidence, first_seen: mo.first_seen, last_seen: mo.last_seen, mission_id: mo.mission_id, }); self.len += 1; Ok(()) } /// Drain and return all pending observations + ignored items. The /// store's pending counts return to 0. Called by /// `mission_client::push_mapobjects_diff` post-flight. pub fn drain_pending(&mut self) -> (Vec, Vec) { ( std::mem::take(&mut self.pending_observations), std::mem::take(&mut self.pending_ignored), ) } /// Cascade-delete every object, ignored entry, and pending log /// row whose `mission_id` matches. Mirrors the central /// `DELETE /missions/{id}` semantics. pub fn cascade_mission(&mut self, mission_id: &str) { let mut empty_cells = Vec::new(); let mut removed = 0usize; for (cell, bucket) in self.by_cell.iter_mut() { let before = bucket.len(); bucket.retain(|o| o.mission_id != mission_id); removed += before - bucket.len(); if bucket.is_empty() { empty_cells.push(*cell); } } for c in empty_cells { self.by_cell.remove(&c); } self.len = self.len.saturating_sub(removed); self.ignored.drop_by_mission(mission_id); self.pending_observations .retain(|o| o.mission_id != mission_id); self.pending_ignored.retain(|i| i.mission_id != mission_id); } /// Mark a post-flight push as acknowledged. Resets sync_state to /// `Synced` and records the push timestamp. pub fn mark_pushed_ok(&mut self) { self.sync_state = SyncState::Synced; self.last_push_ts = Some(Utc::now()); } /// Materialise the in-memory state into a serializable [`Snapshot`]. /// Open passes are intentionally NOT captured — they are transient /// in-flight state and should restart after a process restart. pub fn to_snapshot(&self, mission_id: String) -> Snapshot { let map_objects: Vec = self .by_cell .values() .flatten() .map(|o| SnapshotMapObject { id: o.id, h3_cell: u64::from(o.h3_cell), mgrs: o.mgrs.clone(), class: o.class.clone(), class_group: o.class_group.clone(), gps_lat: o.gps_lat, gps_lon: o.gps_lon, size_width_m: o.size_width_m, size_length_m: o.size_length_m, confidence: o.confidence, first_seen: o.first_seen, last_seen: o.last_seen, mission_id: o.mission_id.clone(), }) .collect(); let ignored_items: Vec = self.ignored.items().cloned().collect(); Snapshot { schema_version: Snapshot::CURRENT_SCHEMA_VERSION, mission_id, as_of: Utc::now(), map_objects, ignored_items, pending_observations: self.pending_observations.clone(), pending_ignored: self.pending_ignored.clone(), sync_state: self.sync_state, last_pull_ts: self.last_pull_ts, last_push_ts: self.last_push_ts, } } /// Rehydrate from a [`Snapshot`]. Re-keys map objects into their /// canonical H3 buckets using the supplied config's resolution /// (so a snapshot taken at one resolution can be loaded into a /// store configured differently — the spatial buckets are rebuilt /// either way). pub fn from_snapshot(config: MapObjectsStoreConfig, snapshot: Snapshot) -> Result { let mut store = Self::new(config); for mo in snapshot.map_objects { let cell = cell_of(mo.gps_lat, mo.gps_lon, store.config.h3_resolution)?; store .by_cell .entry(cell) .or_default() .push(StoredMapObject { id: mo.id, h3_cell: cell, mgrs: mo.mgrs, class: mo.class, class_group: mo.class_group, gps_lat: mo.gps_lat, gps_lon: mo.gps_lon, size_width_m: mo.size_width_m, size_length_m: mo.size_length_m, confidence: mo.confidence, first_seen: mo.first_seen, last_seen: mo.last_seen, mission_id: mo.mission_id, }); store.len += 1; } for item in snapshot.ignored_items { store.ignored.append(item); } store.pending_observations = snapshot.pending_observations; store.pending_ignored = snapshot.pending_ignored; store.sync_state = snapshot.sync_state; store.last_pull_ts = snapshot.last_pull_ts; store.last_push_ts = snapshot.last_push_ts; Ok(store) } /// Resolve a raw class string to its canonical group key. /// /// The first class listed in a `similar_classes` group is the group /// key. A class absent from all groups is its own group. fn group_key(&self, class: &str) -> String { for group in &self.config.similar_classes { if group.iter().any(|c| c == class) { // group[0] is guaranteed by Vec invariants once we filter // empty groups out (see new). But be defensive. if let Some(first) = group.first() { return first.clone(); } } } class.to_string() } /// Classify a single detection input. Mutates the store on `New` / /// `Moved` / `Existing` (insert / position-update / last_seen-update /// 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 { 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); for cell in &disk { if let Some(objects) = self.by_cell.get(cell) { for (idx, obj) in objects.iter().enumerate() { if obj.class_group != group { continue; } let d = haversine_m(input.gps_lat, input.gps_lon, obj.gps_lat, obj.gps_lon); if d > self.config.distance_threshold_m { continue; } if best.is_none_or(|(_, _, prev_d)| d < prev_d) { best = Some((*cell, idx, d)); } } } } let classification = match best { Some((cell, idx, delta_m)) if delta_m >= self.config.move_threshold_m => { // MOVED — update stored position to the new observation. let bucket = self .by_cell .get_mut(&cell) .expect("cell present during best-match scan"); let obj = &mut bucket[idx]; let from_mgrs = obj.mgrs.clone(); let id = obj.id; let class_group = obj.class_group.clone(); let class = obj.class.clone(); obj.gps_lat = input.gps_lat; obj.gps_lon = input.gps_lon; obj.mgrs = input.mgrs.clone(); obj.last_seen = input.observed_at; obj.confidence = input.confidence; // If the new GPS sits in a different H3 cell, re-bucket. if cell != query_cell { let moved = bucket.remove(idx); if bucket.is_empty() { self.by_cell.remove(&cell); } self.by_cell .entry(query_cell) .or_default() .push(StoredMapObject { h3_cell: query_cell, ..moved }); } self.passes.note_observed(id, input.gps_lat, input.gps_lon); self.append_observation( id, query_cell, &class, &class_group, &input, DiffKind::Moved, ); Classification::Moved { id, from_mgrs, to_mgrs: input.mgrs.clone(), } } Some((cell, idx, _)) => { // EXISTING — just refresh last_seen. let bucket = self .by_cell .get_mut(&cell) .expect("cell present during best-match scan"); let obj = &mut bucket[idx]; obj.last_seen = input.observed_at; let id = obj.id; let class_group = obj.class_group.clone(); let class = obj.class.clone(); self.passes.note_observed(id, input.gps_lat, input.gps_lon); self.append_observation(id, cell, &class, &class_group, &input, DiffKind::Existing); Classification::Existing { id } } None => { // NEW — insert. let id = Uuid::new_v4(); let stored = StoredMapObject { id, h3_cell: query_cell, mgrs: input.mgrs.clone(), class: input.class.clone(), class_group: group.clone(), gps_lat: input.gps_lat, gps_lon: input.gps_lon, size_width_m: input.size_width_m, size_length_m: input.size_length_m, confidence: input.confidence, first_seen: input.observed_at, last_seen: input.observed_at, mission_id: input.mission_id.clone(), }; self.by_cell.entry(query_cell).or_default().push(stored); self.len += 1; self.passes.note_observed(id, input.gps_lat, input.gps_lon); self.append_observation( id, query_cell, &input.class, &group, &input, DiffKind::New, ); Classification::New { id } } }; Ok(classification) } /// Build and append a `MapObjectObservation` to the post-flight /// push log. Called on every NEW / MOVED / EXISTING classification /// (the REMOVED-CANDIDATE variant is appended by `end_of_pass`). fn append_observation( &mut self, id: Uuid, cell: CellIndex, class: &str, class_group: &str, input: &ClassifyInput, diff_kind: DiffKind, ) { self.pending_observations.push(MapObjectObservation { id, h3_cell: u64::from(cell), class: class.to_string(), class_group: class_group.to_string(), mission_id: input.mission_id.clone(), uav_id: input.uav_id.clone(), observed_at_monotonic_ns: input.observed_at_monotonic_ns, observed_at_wallclock: input.observed_at, gps_lat: input.gps_lat, gps_lon: input.gps_lon, mgrs: input.mgrs.clone(), size_width_m: input.size_width_m, size_length_m: input.size_length_m, confidence: input.confidence, diff_kind, photo_ref: None, raw_evidence: None, }); } } #[cfg(test)] mod tests { use super::*; fn input(lat: f64, lon: f64, class: &str) -> ClassifyInput { ClassifyInput { gps_lat: lat, gps_lon: lon, mgrs: format!("MGRS({lat},{lon})"), class: class.into(), size_width_m: 1.0, size_length_m: 1.0, confidence: 0.9, mission_id: "m1".into(), observed_at: Utc::now(), uav_id: "uav1".into(), observed_at_monotonic_ns: 0, } } #[test] fn group_key_returns_class_when_unknown() { // Arrange let s = Store::new(MapObjectsStoreConfig::default()); // Act + Assert assert_eq!(s.group_key("tank"), "tank"); } #[test] fn group_key_collapses_similar_classes() { // Arrange let cfg = MapObjectsStoreConfig { similar_classes: vec![vec!["tree".into(), "shrub".into()]], ..MapObjectsStoreConfig::default() }; let s = Store::new(cfg); // Assert assert_eq!(s.group_key("tree"), "tree"); assert_eq!(s.group_key("shrub"), "tree"); assert_eq!(s.group_key("rock"), "rock"); } #[test] fn empty_store_has_zero_len() { // Arrange let s = Store::new(MapObjectsStoreConfig::default()); // Assert assert_eq!(s.len(), 0); } #[test] fn first_classify_is_new() { // Arrange let mut s = Store::new(MapObjectsStoreConfig::default()); // Act let c = s.classify(input(50.45, 30.52, "tank")).unwrap(); // Assert assert!(matches!(c, Classification::New { .. })); assert_eq!(s.len(), 1); } }