//! AZ-665 — H3 indexing + k-ring classify acceptance tests. use chrono::Utc; use mapobjects_store::{Classification, ClassifyInput, MapObjectsStore, MapObjectsStoreConfig}; /// Approximate metres-per-degree of latitude. Good enough at all /// latitudes for the small per-test offsets used below (5–60 m). const M_PER_DEG_LAT: f64 = 111_320.0; /// Approximate metres-per-degree of longitude at a given latitude. fn m_per_deg_lon(lat_deg: f64) -> f64 { M_PER_DEG_LAT * lat_deg.to_radians().cos() } /// Shift a base point north by `dn` metres and east by `de` metres. /// Sufficiently accurate for the < 100 m offsets in these tests. fn shift_m(base_lat: f64, base_lon: f64, dn_m: f64, de_m: f64) -> (f64, f64) { let lat = base_lat + dn_m / M_PER_DEG_LAT; let lon = base_lon + de_m / m_per_deg_lon(base_lat); (lat, lon) } fn input(lat: f64, lon: f64, class: &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: "m-az665".into(), observed_at: Utc::now(), } } const ANCHOR_LAT: f64 = 50.450_000; const ANCHOR_LON: f64 = 30.520_000; // --------------------------------------------------------------------- // AC-1: New detection at unseen MGRS → Classification::New // --------------------------------------------------------------------- #[test] fn ac1_first_detection_returns_new() { // Arrange let h = MapObjectsStore::default().handle(); // Act let c = h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank")).unwrap(); // Assert assert!( matches!(c, Classification::New { .. }), "expected New, got {c:?}", ); assert_eq!(h.len().unwrap(), 1); } // --------------------------------------------------------------------- // AC-2: Existing within distance_threshold → Classification::Existing // distance_threshold_m = 30, move_threshold high enough that // delta < move_threshold yields Existing. // --------------------------------------------------------------------- #[test] fn ac2_within_distance_threshold_returns_existing() { // Arrange let cfg = MapObjectsStoreConfig { distance_threshold_m: 30.0, // Anything > distance_threshold guarantees the in-window match // never flips to Moved. move_threshold_m: 100.0, ..MapObjectsStoreConfig::default() }; let store = MapObjectsStore::new(cfg); let h = store.handle(); let first = h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank")).unwrap(); let original_id = match first { Classification::New { id } => id, other => panic!("setup: expected New, got {other:?}"), }; // Act — same class, 5 m north of the anchor. let (lat2, lon2) = shift_m(ANCHOR_LAT, ANCHOR_LON, 5.0, 0.0); let c = h.classify(input(lat2, lon2, "tank")).unwrap(); // Assert match c { Classification::Existing { id } => assert_eq!(id, original_id), other => panic!("expected Existing, got {other:?}"), } assert_eq!(h.len().unwrap(), 1, "no new objects should be inserted"); } // --------------------------------------------------------------------- // AC-3: Moved beyond move_threshold → Classification::Moved // distance_threshold large enough to admit the 60 m candidate. // --------------------------------------------------------------------- #[test] fn ac3_beyond_move_threshold_returns_moved() { // Arrange let cfg = MapObjectsStoreConfig { distance_threshold_m: 100.0, move_threshold_m: 50.0, ..MapObjectsStoreConfig::default() }; let store = MapObjectsStore::new(cfg); let h = store.handle(); let initial = input(ANCHOR_LAT, ANCHOR_LON, "tank"); let from_mgrs = initial.mgrs.clone(); let first = h.classify(initial).unwrap(); let original_id = match first { Classification::New { id } => id, other => panic!("setup: expected New, got {other:?}"), }; // Act — same class, 60 m north of the anchor. let (lat2, lon2) = shift_m(ANCHOR_LAT, ANCHOR_LON, 60.0, 0.0); let next = input(lat2, lon2, "tank"); let to_mgrs = next.mgrs.clone(); let c = h.classify(next).unwrap(); // Assert match c { Classification::Moved { id, from_mgrs: f, to_mgrs: t, } => { assert_eq!(id, original_id); assert_eq!(f, from_mgrs); assert_eq!(t, to_mgrs); } other => panic!("expected Moved, got {other:?}"), } assert_eq!(h.len().unwrap(), 1, "Moved is an update, not an insert"); } // --------------------------------------------------------------------- // AC-4: k-ring boundary lookup. A second detection in a *different* H3 // cell (boundary cell) must still match the original because k=2 widens // the lookup. We pick a delta (~12 m east) that crosses the ~15 m res-10 // cell boundary while staying well within distance_threshold. // --------------------------------------------------------------------- #[test] fn ac4_k_ring_finds_match_in_neighbour_cell() { // Arrange let cfg = MapObjectsStoreConfig { h3_resolution: 10, k_ring: 2, distance_threshold_m: 30.0, move_threshold_m: 100.0, ..MapObjectsStoreConfig::default() }; let store = MapObjectsStore::new(cfg); let h = store.handle(); h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank")).unwrap(); // Act — 12 m east. At res 10 (~15 m edge) this crosses to a // neighbouring cell with very high probability for arbitrary anchor. let (lat2, lon2) = shift_m(ANCHOR_LAT, ANCHOR_LON, 0.0, 12.0); let c = h.classify(input(lat2, lon2, "tank")).unwrap(); // Assert — the k-ring widen must catch it. assert!( matches!(c, Classification::Existing { .. }), "expected Existing (k-ring match), got {c:?}", ); assert_eq!(h.len().unwrap(), 1); } // --------------------------------------------------------------------- // Class-group similarity widens matching beyond exact-class equality. // Covers `similar_classes` configuration. // --------------------------------------------------------------------- #[test] fn similar_classes_collapse_to_same_group() { // Arrange let cfg = MapObjectsStoreConfig { distance_threshold_m: 30.0, move_threshold_m: 100.0, similar_classes: vec![vec!["tree".into(), "shrub".into()]], ..MapObjectsStoreConfig::default() }; let store = MapObjectsStore::new(cfg); let h = store.handle(); h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tree")).unwrap(); // Act — same place, different (but collapsed) class. let c = h.classify(input(ANCHOR_LAT, ANCHOR_LON, "shrub")).unwrap(); // Assert assert!(matches!(c, Classification::Existing { .. }), "got {c:?}"); } #[test] fn different_classes_do_not_collapse() { // Arrange let store = MapObjectsStore::default(); let h = store.handle(); h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tree")).unwrap(); // Act let c = h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank")).unwrap(); // Assert — disjoint classes must each get their own row. assert!(matches!(c, Classification::New { .. }), "got {c:?}"); assert_eq!(h.len().unwrap(), 2); } // --------------------------------------------------------------------- // AC-5: p99 ≤ 1 ms with 10 000 warm objects. // // Debug builds are 3-10× slower than release. Gate behind `--ignored` // so default `cargo test` stays fast and CI explicitly opts in via // `cargo test --release -- --ignored ac5_classify_p99`. Asserting on a // debug build would be flaky. // --------------------------------------------------------------------- #[test] #[ignore = "perf-only: run with `cargo test --release -p mapobjects_store -- --ignored`"] fn ac5_classify_p99_under_one_ms() { // Arrange — tight match window so seeded points placed on a 30 m grid // remain distinct rows. 100 × 100 grid → 3 km × 3 km area, 10 000 rows. let cfg = MapObjectsStoreConfig { h3_resolution: 10, k_ring: 2, distance_threshold_m: 5.0, move_threshold_m: 100.0, similar_classes: Vec::new(), }; let store = MapObjectsStore::new(cfg); let h = store.handle(); const GRID_STEP_M: f64 = 30.0; for i in 0..10_000_u32 { let row = i / 100; let col = i % 100; let dn = row as f64 * GRID_STEP_M; let de = col as f64 * GRID_STEP_M; let (lat, lon) = shift_m(ANCHOR_LAT, ANCHOR_LON, dn, de); h.classify(input(lat, lon, "tank")).unwrap(); } assert_eq!(h.len().unwrap(), 10_000); // Act — 1 000 classifications at points midway between grid nodes so // most queries land inside a populated k-ring without matching any // single row (worst-case lookup cost). let mut samples = Vec::with_capacity(1_000); for i in 0..1_000_u32 { let row = (i / 50) as f64; let col = (i % 50) as f64; let dn = row * GRID_STEP_M + GRID_STEP_M / 2.0; let de = col * GRID_STEP_M + GRID_STEP_M / 2.0; let (lat, lon) = shift_m(ANCHOR_LAT, ANCHOR_LON, dn, de); let t0 = std::time::Instant::now(); let _ = h.classify(input(lat, lon, "tank")).unwrap(); samples.push(t0.elapsed()); } // Assert — p99 ≤ 1 ms. samples.sort(); let p99 = samples[(samples.len() as f64 * 0.99) as usize]; assert!( p99 <= std::time::Duration::from_millis(1), "p99 was {p99:?} (expected ≤1 ms)", ); }