[AZ-649] [AZ-674] [AZ-667] telemetry + vlm schema + mapobjects hydrate batch 6

AZ-649 mission_executor telemetry forwarding:
- shared::models::telemetry::UavTelemetry canonical model
- TelemetryForwarder with atomic ArcSwap snapshot + 3 lossy
  tokio::sync::broadcast channels (MissionExecutor, ScanController,
  MavlinkUplink) + per-consumer drop counters
- MavlinkProjection::from_mavlink for HEARTBEAT/GLOBAL_POSITION_INT/
  ATTITUDE/SYS_STATUS
- spawn_mavlink_pump bridges mavlink_layer into the forwarder at the
  binary edge

AZ-674 vlm_client schema validation + model_version tracking:
- AssessmentParser owns schema validation + model-version state
- wire::read_response_raw splits raw bytes from parsing so invalid
  payloads can be logged size-capped
- VlmStatus gains an Inconclusive variant; exhaustive-match test
  guards downstream consumers
- VlmPipelineStatus mirrors the new variant in shared::models::poi

AZ-667 mapobjects_store hydrate + pending logs + cascade:
- SyncState enum aligned with description.md (FreshBoot, Synced,
  CachedFallback, Degraded, Failed)
- Store::hydrate(MapObjectsBundle) replaces in-memory map atomically;
  freshness=Stale -> CachedFallback
- classify() + end_of_pass append MapObjectObservation events to
  pending_observations (New/Moved/Existing/RemovedCandidate)
- apply_decline + LocalAppended ignored items append to pending_ignored
- drain_pending() returns and clears both logs
- cascade_mission(id) purges by_cell + IgnoredSet + pending logs
- Health surface reports sync_state, pending_obs, pending_ign

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 17:40:43 +03:00
parent b5cc0c321c
commit e56d428753
26 changed files with 2122 additions and 62 deletions
@@ -69,6 +69,20 @@ impl IgnoredSet {
pub fn items(&self) -> impl Iterator<Item = &IgnoredItem> {
self.items.values()
}
/// Drop every `IgnoredItem` whose `mission_id` matches the
/// supplied id. Used by the `DELETE /missions/{id}` cascade
/// (AZ-667 AC-5). The keyset is rebuilt from the surviving items
/// because a single `(mgrs, class_group)` pair may still appear
/// under a different mission.
pub fn drop_by_mission(&mut self, mission_id: &str) {
self.items.retain(|_, v| v.mission_id != mission_id);
self.keys.clear();
for item in self.items.values() {
self.keys
.insert((item.mgrs.clone(), item.class_group.clone()));
}
}
}
#[cfg(test)]
+292 -10
View File
@@ -15,13 +15,40 @@ use std::collections::HashMap;
use chrono::{DateTime, Utc};
use h3o::CellIndex;
use shared::error::Result;
use shared::models::mapobject::IgnoredItem;
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};
/// 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)]
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
@@ -38,6 +65,18 @@ pub struct ClassifyInput {
pub confidence: f32,
pub mission_id: String,
pub observed_at: DateTime<Utc>,
/// 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.
@@ -139,6 +178,17 @@ pub struct Store {
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<MapObjectObservation>,
/// Append-only log of locally-appended `IgnoredItem`s for the
/// post-flight push (AZ-667).
pending_ignored: Vec<IgnoredItem>,
sync_state: SyncState,
last_pull_ts: Option<DateTime<Utc>>,
last_push_ts: Option<DateTime<Utc>>,
}
impl Store {
@@ -149,6 +199,11 @@ impl Store {
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,
}
}
@@ -168,8 +223,13 @@ impl Store {
}
/// Append an `IgnoredItem` (operator declined a POI, or a hydrate
/// from `mission_client` pulled it down).
/// 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);
}
@@ -188,6 +248,10 @@ impl Store {
/// 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<RemovedCandidate> {
let Some(result) = self.passes.pass_end(bbox) else {
return Vec::new();
@@ -222,13 +286,173 @@ impl Store {
});
}
}
// 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<String> {
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<DateTime<Utc>> {
self.last_pull_ts
}
pub fn last_push_ts(&self) -> Option<DateTime<Utc>> {
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<MapObjectObservation>, Vec<IgnoredItem>) {
(
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());
}
/// Resolve a raw class string to its canonical group key.
///
/// The first class listed in a `similar_classes` group is the group
@@ -282,7 +506,7 @@ impl Store {
}
}
match best {
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
@@ -292,6 +516,8 @@ impl Store {
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();
@@ -313,11 +539,19 @@ impl Store {
});
}
self.passes.note_observed(id, input.gps_lat, input.gps_lon);
Ok(Classification::Moved {
self.append_observation(
id,
query_cell,
&class,
&class_group,
&input,
DiffKind::Moved,
);
Classification::Moved {
id,
from_mgrs,
to_mgrs: input.mgrs,
})
to_mgrs: input.mgrs.clone(),
}
}
Some((cell, idx, _)) => {
// EXISTING — just refresh last_seen.
@@ -328,8 +562,11 @@ impl Store {
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);
Ok(Classification::Existing { id })
self.append_observation(id, cell, &class, &class_group, &input, DiffKind::Existing);
Classification::Existing { id }
}
None => {
// NEW — insert.
@@ -339,7 +576,7 @@ impl Store {
h3_cell: query_cell,
mgrs: input.mgrs.clone(),
class: input.class.clone(),
class_group: group,
class_group: group.clone(),
gps_lat: input.gps_lat,
gps_lon: input.gps_lon,
size_width_m: input.size_width_m,
@@ -352,9 +589,52 @@ 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 })
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,
});
}
}
@@ -373,6 +653,8 @@ mod tests {
confidence: 0.9,
mission_id: "m1".into(),
observed_at: Utc::now(),
uav_id: "uav1".into(),
observed_at_monotonic_ns: 0,
}
}
+120 -37
View File
@@ -15,34 +15,24 @@
use std::sync::{Arc, Mutex};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use uuid::Uuid;
use shared::error::{AutopilotError, Result};
use shared::health::ComponentHealth;
use shared::models::mapobject::{IgnoredItem, IgnoredItemSource, MapObjectsBundle, RetentionScope};
use shared::models::mapobject::{
IgnoredItem, IgnoredItemSource, MapObjectObservation, MapObjectsBundle, RetentionScope,
};
use shared::models::poi::Poi;
mod internal;
pub use internal::passes::RegionBbox;
pub use internal::store::{Classification, ClassifyInput, MapObjectsStoreConfig, RemovedCandidate};
const NAME: &str = "mapobjects_store";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncState {
/// Bundle pulled centrally and applied.
Hydrated,
/// Local-observed records exist but have not been pushed.
Pending,
/// Push acknowledged centrally.
PushedOk,
/// Push failed; will retry from `pending_pushes/`.
PushDeferred,
}
pub use internal::passes::RegionBbox;
pub use internal::store::{
Classification, ClassifyInput, MapObjectsStoreConfig, RemovedCandidate, SyncState,
};
/// Owns the in-memory map. Construct once at the composition root and
/// share via the cloneable `MapObjectsStoreHandle`.
@@ -176,32 +166,122 @@ impl MapObjectsStoreHandle {
Ok(guard.end_of_pass(bbox))
}
pub async fn dump_pending(&self) -> Result<MapObjectsBundle> {
Err(AutopilotError::NotImplemented(
"mapobjects_store::dump_pending (AZ-667)",
))
/// Load the in-memory map from a central-pulled bundle. Replaces
/// any existing entries — central is authoritative on hydrate.
/// Sets `sync_state` to `Synced` for a fresh bundle or
/// `CachedFallback` for one tagged `Stale`. See AZ-667 AC-1 / AC-2.
pub fn hydrate(&self, bundle: MapObjectsBundle) -> Result<()> {
let mut guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
guard.hydrate(bundle)
}
pub async fn hydrate(&self, _bundle: MapObjectsBundle) -> Result<()> {
Err(AutopilotError::NotImplemented(
"mapobjects_store::hydrate (AZ-667)",
))
/// Drain the pending observation + ignored append logs for the
/// post-flight push. Counts return to zero. See AZ-667 AC-4.
pub fn drain_pending(&self) -> Result<(Vec<MapObjectObservation>, Vec<IgnoredItem>)> {
let mut guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
Ok(guard.drain_pending())
}
pub async fn set_sync_state(&self, _state: SyncState) -> Result<()> {
Err(AutopilotError::NotImplemented(
"mapobjects_store::set_sync_state (AZ-667)",
))
/// Drop every record (indexed object, ignored entry, pending log
/// row) whose `mission_id` matches the supplied id. Mirrors the
/// central `DELETE /missions/{id}` cascade. See AZ-667 AC-5.
pub fn cascade_mission(&self, mission_id: &str) -> Result<()> {
let mut guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
guard.cascade_mission(mission_id);
Ok(())
}
pub fn set_sync_state(&self, state: SyncState) -> Result<()> {
let mut guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
guard.set_sync_state(state);
Ok(())
}
pub fn sync_state(&self) -> Result<SyncState> {
let guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
Ok(guard.sync_state())
}
pub fn pending_observations_count(&self) -> Result<usize> {
let guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
Ok(guard.pending_observations_count())
}
pub fn pending_ignored_count(&self) -> Result<usize> {
let guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
Ok(guard.pending_ignored_count())
}
pub fn last_pull_ts(&self) -> Result<Option<DateTime<Utc>>> {
let guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
Ok(guard.last_pull_ts())
}
pub fn last_push_ts(&self) -> Result<Option<DateTime<Utc>>> {
let guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
Ok(guard.last_push_ts())
}
/// Record a successful post-flight push: sets sync_state to
/// `Synced` and stores the wallclock as `last_push_ts`.
pub fn mark_pushed_ok(&self) -> Result<()> {
let mut guard = self
.inner
.lock()
.map_err(|_| AutopilotError::Internal("mapobjects_store mutex poisoned".into()))?;
guard.mark_pushed_ok();
Ok(())
}
pub fn health(&self) -> ComponentHealth {
match self.inner.lock() {
Ok(guard) => ComponentHealth::green(NAME).with_detail(format!(
"indexed_objects={} ignored={} open_passes={}",
guard.len(),
guard.ignored_len(),
guard.open_passes(),
)),
Ok(guard) => {
let level = match guard.sync_state() {
SyncState::Degraded | SyncState::Failed => {
ComponentHealth::red(NAME, "sync state degraded")
}
SyncState::CachedFallback => {
ComponentHealth::yellow(NAME, "operating on cached fallback")
}
SyncState::FreshBoot | SyncState::Synced => ComponentHealth::green(NAME),
};
level.with_detail(format!(
"sync={:?} indexed={} ignored={} open_passes={} pending_obs={} pending_ign={}",
guard.sync_state(),
guard.len(),
guard.ignored_len(),
guard.open_passes(),
guard.pending_observations_count(),
guard.pending_ignored_count(),
))
}
Err(_) => ComponentHealth::red(NAME, "mutex poisoned"),
}
}
@@ -234,6 +314,8 @@ mod tests {
confidence: 0.9,
mission_id: "m1".into(),
observed_at: Utc::now(),
uav_id: "uav1".into(),
observed_at_monotonic_ns: 0,
}
}
@@ -270,8 +352,9 @@ mod tests {
// Assert
assert_eq!(health.level, shared::health::HealthLevel::Green);
let detail = health.detail.as_deref().unwrap();
assert!(detail.contains("indexed_objects=1"));
assert!(detail.contains("indexed=1"));
assert!(detail.contains("ignored=0"));
assert!(detail.contains("open_passes=0"));
assert!(detail.contains("pending_obs=1"));
}
}
@@ -31,6 +31,8 @@ fn input(lat: f64, lon: f64, class: &str) -> ClassifyInput {
confidence: 0.9,
mission_id: "m-az665".into(),
observed_at: Utc::now(),
uav_id: "uav-az665".into(),
observed_at_monotonic_ns: 0,
}
}
@@ -0,0 +1,360 @@
//! AZ-667 acceptance tests — pre-flight hydrate, sync_state machine,
//! pending observation/ignored append logs, mission cascade.
use chrono::Utc;
use mapobjects_store::{ClassifyInput, MapObjectsStore, MapObjectsStoreConfig, SyncState};
use shared::models::mapobject::{
BundleFreshness, IgnoredItem, IgnoredItemSource, MapObject, MapObjectSource, MapObjectsBundle,
RetentionScope,
};
use shared::models::mission::Coordinate;
use uuid::Uuid;
const ANCHOR_LAT: f64 = 50.450_000;
const ANCHOR_LON: f64 = 30.520_000;
fn input(lat: f64, lon: f64, class: &str, mission_id: &str) -> ClassifyInput {
ClassifyInput {
gps_lat: lat,
gps_lon: lon,
mgrs: format!("MGRS({lat:.6},{lon:.6})"),
class: class.into(),
size_width_m: 2.0,
size_length_m: 2.0,
confidence: 0.9,
mission_id: mission_id.into(),
observed_at: Utc::now(),
uav_id: "uav-az667".into(),
observed_at_monotonic_ns: 1_234_567_890,
}
}
fn map_object(lat: f64, lon: f64, class: &str, mission_id: &str) -> MapObject {
MapObject {
h3_cell: 0,
mgrs_key: format!("MGRS({lat:.6},{lon:.6})"),
class: class.into(),
class_group: class.into(),
gps_lat: lat,
gps_lon: lon,
size_width_m: 2.0,
size_length_m: 2.0,
confidence: 0.9,
first_seen: Utc::now(),
last_seen: Utc::now(),
mission_id: mission_id.into(),
source: MapObjectSource::CentralPulled,
pending_upload: false,
}
}
fn ignored(mgrs: &str, class_group: &str, mission_id: &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: mission_id.into(),
retention_scope: RetentionScope::Mission,
expires_at: None,
source: IgnoredItemSource::CentralPulled,
pending_upload: false,
}
}
fn bundle(
mission_id: &str,
map_objects: Vec<MapObject>,
ignored_items: Vec<IgnoredItem>,
freshness: Option<BundleFreshness>,
) -> MapObjectsBundle {
MapObjectsBundle {
schema_version: "1.0".into(),
mission_id: mission_id.into(),
bbox: [
Coordinate {
latitude: ANCHOR_LAT + 0.5,
longitude: ANCHOR_LON - 0.5,
altitude_m: 0.0,
},
Coordinate {
latitude: ANCHOR_LAT - 0.5,
longitude: ANCHOR_LON + 0.5,
altitude_m: 0.0,
},
],
map_objects,
observations: Vec::new(),
ignored_items,
as_of: Utc::now(),
freshness,
}
}
// ---------------------------------------------------------------------
// AC-1: Hydrate from bundle → store contains N + M entries, sync_state
// = synced.
// ---------------------------------------------------------------------
#[test]
fn ac1_hydrate_loads_bundle_and_sets_synced() {
// Arrange
let store = MapObjectsStore::default();
let h = store.handle();
let b = bundle(
"m-az667",
vec![
map_object(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667"),
map_object(ANCHOR_LAT + 0.001, ANCHOR_LON, "truck", "m-az667"),
],
vec![ignored("MGRS-X", "tank", "m-az667")],
Some(BundleFreshness::Fresh),
);
// Act
h.hydrate(b).unwrap();
// Assert
assert_eq!(h.len().unwrap(), 2);
assert_eq!(h.sync_state().unwrap(), SyncState::Synced);
assert!(h.is_ignored("MGRS-X", "tank").unwrap());
assert!(h.last_pull_ts().unwrap().is_some());
}
// ---------------------------------------------------------------------
// AC-2: Fallback bundle (freshness = Stale) → sync_state =
// CachedFallback.
// ---------------------------------------------------------------------
#[test]
fn ac2_stale_bundle_sets_cached_fallback() {
// Arrange
let store = MapObjectsStore::default();
let h = store.handle();
let b = bundle(
"m-az667",
vec![map_object(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667")],
Vec::new(),
Some(BundleFreshness::Stale),
);
// Act
h.hydrate(b).unwrap();
// Assert
assert_eq!(h.sync_state().unwrap(), SyncState::CachedFallback);
}
// ---------------------------------------------------------------------
// AC-3: Classify appends pending observation.
// ---------------------------------------------------------------------
#[test]
fn ac3_classify_appends_pending_observation() {
// Arrange
let cfg = MapObjectsStoreConfig {
distance_threshold_m: 5.0,
move_threshold_m: 50.0,
..MapObjectsStoreConfig::default()
};
let store = MapObjectsStore::new(cfg);
let h = store.handle();
let b = bundle(
"m-az667",
Vec::new(),
Vec::new(),
Some(BundleFreshness::Fresh),
);
h.hydrate(b).unwrap();
assert_eq!(h.pending_observations_count().unwrap(), 0);
// Act
let _ = h
.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667"))
.unwrap();
// Assert
assert_eq!(h.pending_observations_count().unwrap(), 1);
}
// ---------------------------------------------------------------------
// AC-3b: Operator decline appends to pending_ignored.
// ---------------------------------------------------------------------
#[test]
fn ac3b_local_decline_appends_to_pending_ignored() {
use chrono::Duration as ChronoDuration;
use shared::models::poi::{Poi, VlmPipelineStatus};
// Arrange
let store = MapObjectsStore::default();
let h = store.handle();
let now = Utc::now();
let poi = Poi {
id: Uuid::new_v4(),
confidence: 0.85,
mgrs: "MGRS-DECLINED".into(),
class: "concealed_position".into(),
class_group: "concealed_position_group".into(),
source_detection_ids: Vec::new(),
enqueued_at: now,
priority: 1.0,
decline_suppressed: false,
vlm_status: VlmPipelineStatus::NotRequested,
tier2_evidence: None,
deadline: now + ChronoDuration::seconds(60),
};
// Act
h.apply_decline(poi).unwrap();
// Assert
assert_eq!(h.pending_ignored_count().unwrap(), 1);
}
// ---------------------------------------------------------------------
// AC-4: drain_pending returns and clears pending.
// ---------------------------------------------------------------------
#[test]
fn ac4_drain_pending_clears_counts() {
// Arrange
let cfg = MapObjectsStoreConfig {
distance_threshold_m: 5.0,
move_threshold_m: 50.0,
..MapObjectsStoreConfig::default()
};
let store = MapObjectsStore::new(cfg);
let h = store.handle();
let b = bundle(
"m-az667",
Vec::new(),
Vec::new(),
Some(BundleFreshness::Fresh),
);
h.hydrate(b).unwrap();
h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667"))
.unwrap();
h.classify(input(ANCHOR_LAT + 0.001, ANCHOR_LON, "truck", "m-az667"))
.unwrap();
h.append_ignored(IgnoredItem {
source: IgnoredItemSource::LocalAppended,
..ignored("MGRS-Y", "tank", "m-az667")
})
.unwrap();
assert_eq!(h.pending_observations_count().unwrap(), 2);
assert_eq!(h.pending_ignored_count().unwrap(), 1);
// Act
let (obs, ign) = h.drain_pending().unwrap();
// Assert
assert_eq!(obs.len(), 2);
assert_eq!(ign.len(), 1);
assert_eq!(h.pending_observations_count().unwrap(), 0);
assert_eq!(h.pending_ignored_count().unwrap(), 0);
}
// ---------------------------------------------------------------------
// AC-5: cascade_mission drops mission-scoped objects but preserves
// objects belonging to a different mission.
// ---------------------------------------------------------------------
#[test]
fn ac5_cascade_mission_drops_only_matching_objects() {
// Arrange
let store = MapObjectsStore::default();
let h = store.handle();
let b = bundle(
"m-A",
vec![
map_object(ANCHOR_LAT, ANCHOR_LON, "tank", "m-A"),
map_object(ANCHOR_LAT + 0.001, ANCHOR_LON, "truck", "m-B"),
],
vec![
ignored("MGRS-A", "tank", "m-A"),
ignored("MGRS-B", "truck", "m-B"),
],
Some(BundleFreshness::Fresh),
);
h.hydrate(b).unwrap();
assert_eq!(h.len().unwrap(), 2);
// Act
h.cascade_mission("m-A").unwrap();
// Assert
assert_eq!(h.len().unwrap(), 1);
assert!(!h.is_ignored("MGRS-A", "tank").unwrap());
assert!(h.is_ignored("MGRS-B", "truck").unwrap());
}
// ---------------------------------------------------------------------
// End-of-pass removed candidates land in pending observations.
// ---------------------------------------------------------------------
#[test]
fn end_of_pass_appends_removed_candidate_to_pending() {
// Arrange
let cfg = MapObjectsStoreConfig {
distance_threshold_m: 5.0,
move_threshold_m: 50.0,
..MapObjectsStoreConfig::default()
};
let store = MapObjectsStore::new(cfg);
let h = store.handle();
let _ = h
.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667"))
.unwrap();
// Drain the NEW observation so the pass adds exactly one new row.
let _ = h.drain_pending().unwrap();
let region = [
Coordinate {
latitude: ANCHOR_LAT + 0.01,
longitude: ANCHOR_LON - 0.01,
altitude_m: 0.0,
},
Coordinate {
latitude: ANCHOR_LAT - 0.01,
longitude: ANCHOR_LON + 0.01,
altitude_m: 0.0,
},
];
// Act
std::thread::sleep(std::time::Duration::from_millis(2));
h.pass_start(region).unwrap();
let removed = h.end_of_pass(&region).unwrap();
// Assert
assert_eq!(removed.len(), 1);
let (obs, _) = h.drain_pending().unwrap();
assert_eq!(obs.len(), 1);
assert!(matches!(
obs[0].diff_kind,
shared::models::mapobject::DiffKind::RemovedCandidate
));
}
// ---------------------------------------------------------------------
// mark_pushed_ok records last_push_ts and resets to Synced.
// ---------------------------------------------------------------------
#[test]
fn mark_pushed_ok_records_timestamp() {
// Arrange
let store = MapObjectsStore::default();
let h = store.handle();
h.set_sync_state(SyncState::Degraded).unwrap();
assert!(h.last_push_ts().unwrap().is_none());
// Act
h.mark_pushed_ok().unwrap();
// Assert
assert_eq!(h.sync_state().unwrap(), SyncState::Synced);
assert!(h.last_push_ts().unwrap().is_some());
}
@@ -32,6 +32,8 @@ fn input(lat: f64, lon: f64, class: &str) -> ClassifyInput {
confidence: 0.9,
mission_id: "m-az666".into(),
observed_at: Utc::now(),
uav_id: "uav-az666".into(),
observed_at_monotonic_ns: 0,
}
}