[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
@@ -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,
}
}