//! AZ-679 — POI surface event mapping + dequeue emission. //! //! `PoiSurfaceMapper::map(poi)` produces the //! [`OperatorPoiEvent`](shared::models::operator_event::OperatorPoiEvent) //! that the operator UI consumes (per `architecture.md §7.10` and the //! task spec's field list). On queue rotation / age-out / completion //! `emit_dequeued` produces a `PoiDequeued` event. //! //! Both events are pushed through `TelemetrySink::push_operator_event` //! — composition root supplies the sink (in production, the //! `telemetry_stream::TelemetryStreamHandle`). //! //! `pois_surfaced_per_min` counter exposed via [`PoiSurfaceMetrics`]. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use chrono::Utc; use parking_lot::Mutex; use shared::contracts::TelemetrySink; use shared::error::{AutopilotError, Result}; use shared::models::operator_event::{ DequeueReason, OperatorEvent, OperatorPoiEvent, PhotoMetadata, PoiDequeued, Tier2EvidenceSummary, }; use shared::models::poi::{Poi, VlmPipelineStatus}; use uuid::Uuid; /// Sliding 60 s window over POI-surfaced timestamps. Used by the /// `pois_surfaced_per_min` health metric. #[derive(Default)] struct SurfaceRateWindow { timestamps: Mutex>, } impl SurfaceRateWindow { fn record_and_count(&self) -> usize { let now = std::time::Instant::now(); let mut w = self.timestamps.lock(); w.push_back(now); while let Some(&t) = w.front() { if now.duration_since(t) > std::time::Duration::from_secs(60) { w.pop_front(); } else { break; } } w.len() } fn current_rate(&self) -> usize { let now = std::time::Instant::now(); let mut w = self.timestamps.lock(); while let Some(&t) = w.front() { if now.duration_since(t) > std::time::Duration::from_secs(60) { w.pop_front(); } else { break; } } w.len() } } #[derive(Debug, Clone)] pub struct PoiSurfaceMetrics { pub pois_surfaced_per_min: usize, pub pois_surfaced_total: u64, pub pois_dequeued_total: u64, } pub struct PoiSurfaceMapper { sink: Arc, pois_surfaced_total: AtomicU64, pois_dequeued_total: AtomicU64, rate: SurfaceRateWindow, } impl PoiSurfaceMapper { pub fn new(sink: Arc) -> Self { Self { sink, pois_surfaced_total: AtomicU64::new(0), pois_dequeued_total: AtomicU64::new(0), rate: SurfaceRateWindow::default(), } } /// Pure mapping — produces the wire-format event. Used by tests /// and by [`surface`] (which also pushes through the sink). /// Photo metadata is optional and may be supplied by the caller /// when the POI's source detection has a captured ROI snapshot; /// the `Poi` model itself does not carry photo bytes. pub fn map(poi: &Poi, photo_metadata: Option) -> OperatorPoiEvent { let tier2_evidence_summary = poi.tier2_evidence.as_ref().map(|t| Tier2EvidenceSummary { path_freshness: t.path_freshness, endpoint_score: t.endpoint_score, concealment_score: t.concealment_score, recommended_next_action: t.recommended_next_action, status: t.status, }); let vlm_label = match poi.vlm_status { // The Poi model does not carry the VLM label string — only // the pipeline status. The label is attached upstream // when the assessment lands in scan_controller; for now // we surface None and let scan_controller pass the label // through a richer overload once AZ-684 wires it. VlmPipelineStatus::Ok => None, _ => None, }; OperatorPoiEvent { poi_id: poi.id, mgrs: poi.mgrs.clone(), class_group: poi.class_group.clone(), confidence: poi.confidence, vlm_status: poi.vlm_status, vlm_label, tier2_evidence_summary, photo_metadata, deadline_unix_ms: poi.deadline.timestamp_millis(), } } /// Map + push. Returns the wire event so the caller can also /// attach it to the audit log if needed. pub async fn surface( &self, poi: &Poi, photo_metadata: Option, ) -> Result { let event = Self::map(poi, photo_metadata); self.sink .push_operator_event(OperatorEvent::PoiSurfaced(event.clone())) .await .map_err(|e| AutopilotError::Internal(format!("push_operator_event(poi): {e}")))?; self.pois_surfaced_total.fetch_add(1, Ordering::Relaxed); self.rate.record_and_count(); Ok(event) } /// Emit a `PoiDequeued` event. Called by `scan_controller` (via /// `operator_bridge`) when a POI is rotated, ages out, or /// completes (operator decided). pub async fn emit_dequeued(&self, poi_id: Uuid, reason: DequeueReason) -> Result<()> { let event = PoiDequeued { poi_id, reason, dequeued_at: Utc::now(), }; self.sink .push_operator_event(OperatorEvent::PoiDequeued(event)) .await .map_err(|e| AutopilotError::Internal(format!("push_operator_event(dequeue): {e}")))?; self.pois_dequeued_total.fetch_add(1, Ordering::Relaxed); Ok(()) } pub fn metrics(&self) -> PoiSurfaceMetrics { PoiSurfaceMetrics { pois_surfaced_per_min: self.rate.current_rate(), pois_surfaced_total: self.pois_surfaced_total.load(Ordering::Relaxed), pois_dequeued_total: self.pois_dequeued_total.load(Ordering::Relaxed), } } } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use chrono::{Duration, Utc}; use shared::models::detection::DetectionBatch; use shared::models::frame::Frame; use shared::models::tier2::{RecommendedNextAction, Tier2Evidence, Tier2Status}; /// Recording sink that captures every operator event pushed to it. /// Lets tests assert on the exact wire content without spinning /// up a real gRPC server. #[derive(Default, Clone)] struct RecordingSink { events: Arc>>, } #[async_trait] impl TelemetrySink for RecordingSink { async fn push_frame(&self, _frame: Frame) -> Result<()> { Ok(()) } async fn push_detections(&self, _batch: DetectionBatch) -> Result<()> { Ok(()) } async fn push_operator_event(&self, event: OperatorEvent) -> Result<()> { self.events.lock().push(event); Ok(()) } } fn poi_with_full_evidence() -> Poi { Poi { id: Uuid::new_v4(), confidence: 0.92, mgrs: "33UWP05".to_string(), class: "tank".to_string(), class_group: "vehicle".to_string(), source_detection_ids: vec![Uuid::new_v4()], enqueued_at: Utc::now(), priority: 0.92, decline_suppressed: false, vlm_status: VlmPipelineStatus::Ok, tier2_evidence: Some(Tier2Evidence { roi_id: Uuid::new_v4(), path_freshness: Some(0.7), endpoint_score: Some(0.5), concealment_score: Some(0.3), recommended_next_action: RecommendedNextAction::HoldEndpoint, source_detections: vec![], status: Tier2Status::Ok, }), deadline: Utc::now() + Duration::seconds(120), } } fn poi_vlm_disabled() -> Poi { Poi { vlm_status: VlmPipelineStatus::Disabled, tier2_evidence: None, ..poi_with_full_evidence() } } /// AC-1 — full POI maps with every required field populated; the /// optional `tier2_evidence_summary` is present when input has it. #[test] fn ac1_full_poi_maps_all_required_fields() { // Arrange let poi = poi_with_full_evidence(); let meta = PhotoMetadata { photo_ref: "snap/123.jpg".to_string(), width: 1920, height: 1080, captured_at_unix_ms: 1_700_000_000_000, }; // Act let evt = PoiSurfaceMapper::map(&poi, Some(meta.clone())); // Assert assert_eq!(evt.poi_id, poi.id); assert_eq!(evt.mgrs, "33UWP05"); assert_eq!(evt.class_group, "vehicle"); assert!((evt.confidence - 0.92).abs() < 1e-6); assert_eq!(evt.vlm_status, VlmPipelineStatus::Ok); let tier2 = evt .tier2_evidence_summary .as_ref() .expect("Tier2 evidence should be carried through"); assert_eq!( tier2.recommended_next_action, RecommendedNextAction::HoldEndpoint ); assert_eq!(tier2.status, Tier2Status::Ok); assert_eq!( evt.photo_metadata.as_ref().map(|p| &p.photo_ref), Some(&meta.photo_ref) ); assert_eq!(evt.deadline_unix_ms, poi.deadline.timestamp_millis()); } /// AC-2 — VLM-disabled POIs map to vlm_status = Disabled and /// vlm_label = None. #[test] fn ac2_vlm_disabled_carries_explicit_status() { // Arrange let poi = poi_vlm_disabled(); // Act let evt = PoiSurfaceMapper::map(&poi, None); // Assert assert_eq!(evt.vlm_status, VlmPipelineStatus::Disabled); assert!(evt.vlm_label.is_none()); // tier2 absence preserved. assert!(evt.tier2_evidence_summary.is_none()); assert!(evt.photo_metadata.is_none()); } /// AC-3 — Dequeue path emits a PoiDequeued event with the /// configured reason and the supplied poi_id. #[tokio::test] async fn ac3_dequeue_emits_event_through_sink() { // Arrange let sink = RecordingSink::default(); let captured = Arc::clone(&sink.events); let mapper = PoiSurfaceMapper::new(Arc::new(sink)); let poi = poi_with_full_evidence(); // Act — surface, then dequeue. mapper.surface(&poi, None).await.unwrap(); mapper .emit_dequeued(poi.id, DequeueReason::Rotated) .await .unwrap(); // Assert — sink saw both events in order. let events = captured.lock().clone(); assert_eq!(events.len(), 2); assert!(matches!(events[0], OperatorEvent::PoiSurfaced(_))); match &events[1] { OperatorEvent::PoiDequeued(d) => { assert_eq!(d.poi_id, poi.id); assert_eq!(d.reason, DequeueReason::Rotated); } _ => panic!("second event must be PoiDequeued"), } let m = mapper.metrics(); assert_eq!(m.pois_surfaced_total, 1); assert_eq!(m.pois_dequeued_total, 1); assert_eq!(m.pois_surfaced_per_min, 1); } }