Files
autopilot/crates/mapobjects_store/tests/classify.rs
T
Oleksandr Bezdieniezhnykh 69c0629350
ci/woodpecker/push/build-arm Pipeline failed
[AZ-643] [AZ-665] [AZ-672] mavlink+mapobjects+vlm batch 4
AZ-643 mavlink_layer:
- ack demux on COMMAND_LONG/COMMAND_ACK with oneshot dispatch and
  configurable deadline; MavlinkHandle::send_command + SendCommandError
- MAVLink-2 signing: Signer/Verifier built on SHA-256, key + timestamp
  source, incompat-flag wiring in encoder, reject + counter in decoder
- new tests: tests/ack_demux.rs (3) + tests/signing.rs (5)

AZ-665 mapobjects_store:
- internal/h3_index.rs (h3o wrapper, cell_of, grid_disk, haversine)
- internal/store.rs (in-memory (cell -> Vec<MapObject>) hashmap with
  k-ring classify and class-group resolution)
- public API: MapObjectsStoreHandle::classify(ClassifyInput) ->
  Classification {New|Moved|Existing}
- AC1-4 in tests/classify.rs; AC5 perf gate (#[ignore], passes in
  --release)

AZ-672 vlm_client + autopilot:
- DisabledVlmProvider in shared::contracts; VlmProvider::name() for
  composition-root diagnostics
- vlm_client::VlmClient gated behind feature = "vlm"; placeholder
  until AZ-673 lands the real NanoLLM IPC
- autopilot: vlm_client is now optional = true under feature vlm;
  Runtime::select_vlm_provider picks DisabledVlmProvider when feature
  off OR config.vlm.enabled = false

Workspace deps: +sha2 (mavlink signing), +h3o (mapobjects index).
Batch report: _docs/03_implementation/batch_04_cycle1_report.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 13:31:42 +03:00

272 lines
9.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 (560 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)",
);
}