mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 13:01:11 +00:00
[AZ-676] [AZ-677] [AZ-678] [AZ-679] telemetry+operator foundation
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>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user