mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 08:31:10 +00:00
ccf929af69
Batch 15 ships the four foundation tickets sitting on top of AZ-675 (gRPC server) and AZ-667 (mapobjects_store hydrate): * AZ-676: telemetry_stream video path (rtsp_forward + bytes_inline) with ai_locked atomic + session counter, SubscribeVideo RPC. * AZ-677: MapObjects snapshot-on-subscribe + diff broadcast + reconnect-resync (StartThen stream-prepend pattern). * AZ-678: HmacOperatorValidator with per-session monotonic seq, in-process session registry + TTL, constant-time HMAC compare, rejection-reason counters, sliding 60 s sig-failure red-health gate. Trait OperatorCommandValidator in shared::contracts::operator_auth. * AZ-679: PoiSurfaceMapper produces OperatorPoiEvent per architecture §7.10; PoiDequeued events on rotate/age-out/complete; pushed via new TelemetrySink::push_operator_event extension on Topic::OperatorEvent. Cross-task wiring: TelemetrySink trait extended with push_operator_event; OperatorBridge gets optional builder methods with_telemetry_sink / with_validator (composition root wires in AZ-680). Workspace deps: hmac = "0.12"; per-crate adds bytes, serde_json, parking_lot, chrono, uuid, sha2, thiserror. Tests: 14/14 ACs verified locally (4 + 3 + 5 + 3 by AC) plus 6 supporting unit tests + 7 integration tests + 2 shared serde roundtrips. cargo clippy clean on touched crates. Cumulative review for batches 13-15 produced; verdict PASS_WITH_WARNINGS (0 Critical, 0 High, 1 Medium, 4 Low — all carry-overs or deferred-producer notes for AZ-680/AZ-684). Co-authored-by: Cursor <cursoragent@cursor.com>
324 lines
11 KiB
Rust
324 lines
11 KiB
Rust
//! 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<std::collections::VecDeque<std::time::Instant>>,
|
|
}
|
|
|
|
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<dyn TelemetrySink>,
|
|
pois_surfaced_total: AtomicU64,
|
|
pois_dequeued_total: AtomicU64,
|
|
rate: SurfaceRateWindow,
|
|
}
|
|
|
|
impl PoiSurfaceMapper {
|
|
pub fn new(sink: Arc<dyn TelemetrySink>) -> 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<PhotoMetadata>) -> 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<PhotoMetadata>,
|
|
) -> Result<OperatorPoiEvent> {
|
|
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<Mutex<Vec<OperatorEvent>>>,
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|