[AZ-649] [AZ-674] [AZ-667] telemetry + vlm schema + mapobjects hydrate batch 6

AZ-649 mission_executor telemetry forwarding:
- shared::models::telemetry::UavTelemetry canonical model
- TelemetryForwarder with atomic ArcSwap snapshot + 3 lossy
  tokio::sync::broadcast channels (MissionExecutor, ScanController,
  MavlinkUplink) + per-consumer drop counters
- MavlinkProjection::from_mavlink for HEARTBEAT/GLOBAL_POSITION_INT/
  ATTITUDE/SYS_STATUS
- spawn_mavlink_pump bridges mavlink_layer into the forwarder at the
  binary edge

AZ-674 vlm_client schema validation + model_version tracking:
- AssessmentParser owns schema validation + model-version state
- wire::read_response_raw splits raw bytes from parsing so invalid
  payloads can be logged size-capped
- VlmStatus gains an Inconclusive variant; exhaustive-match test
  guards downstream consumers
- VlmPipelineStatus mirrors the new variant in shared::models::poi

AZ-667 mapobjects_store hydrate + pending logs + cascade:
- SyncState enum aligned with description.md (FreshBoot, Synced,
  CachedFallback, Degraded, Failed)
- Store::hydrate(MapObjectsBundle) replaces in-memory map atomically;
  freshness=Stale -> CachedFallback
- classify() + end_of_pass append MapObjectObservation events to
  pending_observations (New/Moved/Existing/RemovedCandidate)
- apply_decline + LocalAppended ignored items append to pending_ignored
- drain_pending() returns and clears both logs
- cascade_mission(id) purges by_cell + IgnoredSet + pending logs
- Health surface reports sync_state, pending_obs, pending_ign

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 17:40:43 +03:00
parent b5cc0c321c
commit e56d428753
26 changed files with 2122 additions and 62 deletions
+1
View File
@@ -1,5 +1,6 @@
//! Internal modules used only by the feature-gated `vlm` build.
pub mod parser;
pub mod peer_cred;
pub mod prompt;
pub mod uds_client;
+239
View File
@@ -0,0 +1,239 @@
//! NanoLLM response → `VlmAssessment` parsing + model-version tracking.
//!
//! AZ-674 introduces a separation between the wire layer (which
//! returns raw bytes once the length prefix has been consumed) and
//! the parsing layer (this module), which:
//!
//! 1. Validates the JSON against the `VlmAssessment` schema. Missing
//! required fields, wrong types, or anything else that fails
//! `serde_json::from_slice` returns
//! `VlmAssessment { status: SchemaInvalid, … }` — **NOT** an
//! `Err`. Schema-invalid is a recoverable outcome, observable by
//! `scan_controller`.
//! 2. Logs the raw response (size-capped) at `warn` level whenever a
//! schema-invalid is returned. The cap is configurable; default
//! 4 KiB per AZ-674 §Scope.
//! 3. Tracks `model_version` across calls and emits a single
//! `info!` log line the first time a new version is observed.
//!
//! Required schema fields: `label`, `confidence`, `status`,
//! `model_version`, `latency_ms`. `evidence_spans` and `reason` are
//! optional (serde defaults to `Vec::new()` / `String::new()`).
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use serde::Deserialize;
use shared::models::vlm::{VlmAssessment, VlmLabel, VlmStatus};
/// Default size cap for the raw-response log on schema-invalid.
pub const DEFAULT_LOG_TRUNCATION_BYTES: usize = 4 * 1024;
/// Parser + model-version tracker. Cloneable via `Arc` if a single
/// instance must be shared across tasks; the inner state is internally
/// synchronised.
pub struct AssessmentParser {
last_model_version: Mutex<Option<String>>,
schema_invalid_count: AtomicU64,
model_version_changes: AtomicU64,
log_truncation_bytes: usize,
}
impl AssessmentParser {
pub fn new() -> Self {
Self::with_truncation_bytes(DEFAULT_LOG_TRUNCATION_BYTES)
}
pub fn with_truncation_bytes(bytes: usize) -> Self {
Self {
last_model_version: Mutex::new(None),
schema_invalid_count: AtomicU64::new(0),
model_version_changes: AtomicU64::new(0),
log_truncation_bytes: bytes,
}
}
/// Parse a raw response body into a `VlmAssessment`. A
/// schema-invalid response returns `VlmAssessment { status:
/// SchemaInvalid, … }`; never returns `Err`.
pub fn parse(&self, raw: &[u8]) -> VlmAssessment {
let assessment: VlmAssessment = match serde_json::from_slice::<VlmAssessmentWire>(raw) {
Ok(wire) => wire.into(),
Err(e) => {
self.schema_invalid_count.fetch_add(1, Ordering::Relaxed);
let excerpt = excerpt(raw, self.log_truncation_bytes);
tracing::warn!(
error = %e,
raw_excerpt = %excerpt,
raw_bytes = raw.len(),
"vlm_client schema-invalid response"
);
return schema_invalid(format!("json: {e}"));
}
};
self.track_model_version(&assessment.model_version);
assessment
}
/// Cumulative count of schema-invalid responses observed by this
/// parser instance. Used by the health surface.
pub fn schema_invalid_count(&self) -> u64 {
self.schema_invalid_count.load(Ordering::Relaxed)
}
/// Cumulative count of `model_version` change events emitted.
/// First successful parse counts as one change (None → "v1.0").
pub fn model_version_changes(&self) -> u64 {
self.model_version_changes.load(Ordering::Relaxed)
}
/// Latest seen `model_version` (`None` before the first
/// successful parse).
pub fn current_model_version(&self) -> Option<String> {
self.last_model_version
.lock()
.map(|g| g.clone())
.unwrap_or(None)
}
fn track_model_version(&self, current: &str) {
let mut guard = match self.last_model_version.lock() {
Ok(g) => g,
Err(_) => return,
};
let changed = !matches!(guard.as_deref(), Some(prev) if prev == current);
if changed {
let previous = guard.clone();
*guard = Some(current.to_string());
self.model_version_changes.fetch_add(1, Ordering::Relaxed);
tracing::info!(
previous = previous.as_deref().unwrap_or("<none>"),
current = current,
"vlm_client model_version changed"
);
}
}
}
impl Default for AssessmentParser {
fn default() -> Self {
Self::new()
}
}
/// Wire-side parse target. Matches the production NanoLLM envelope
/// per `description.md §8`. Required fields are non-`Option`; serde
/// will refuse to deserialise without them. Optional fields default
/// to empty.
#[derive(Debug, Deserialize)]
struct VlmAssessmentWire {
label: VlmLabel,
confidence: f32,
#[serde(default)]
evidence_spans: Vec<String>,
#[serde(default)]
reason: String,
status: VlmStatus,
latency_ms: u32,
model_version: String,
}
impl From<VlmAssessmentWire> for VlmAssessment {
fn from(w: VlmAssessmentWire) -> Self {
Self {
label: w.label,
confidence: w.confidence,
evidence_spans: w.evidence_spans,
reason: w.reason,
status: w.status,
latency_ms: w.latency_ms,
model_version: w.model_version,
}
}
}
fn schema_invalid(reason: impl Into<String>) -> VlmAssessment {
VlmAssessment {
label: VlmLabel::Inconclusive,
confidence: 0.0,
evidence_spans: Vec::new(),
reason: reason.into(),
status: VlmStatus::SchemaInvalid,
latency_ms: 0,
model_version: String::new(),
}
}
fn excerpt(raw: &[u8], cap: usize) -> String {
let cap = cap.min(raw.len());
let slice = &raw[..cap];
let mut s = String::from_utf8_lossy(slice).into_owned();
if raw.len() > cap {
s.push_str(&format!("…[truncated, {} more bytes]", raw.len() - cap));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn ok_response_bytes() -> Vec<u8> {
let s = r#"{
"label":"confirmed_concealed_position",
"confidence":0.85,
"evidence_spans":["foliage"],
"reason":"match",
"status":"ok",
"latency_ms":42,
"model_version":"VILA1.5-3B-int4"
}"#;
s.as_bytes().to_vec()
}
#[test]
fn parses_valid_payload() {
// Arrange
let parser = AssessmentParser::new();
// Act
let a = parser.parse(&ok_response_bytes());
// Assert
assert_eq!(a.status, VlmStatus::Ok);
assert_eq!(a.model_version, "VILA1.5-3B-int4");
assert_eq!(parser.schema_invalid_count(), 0);
}
#[test]
fn missing_required_field_returns_schema_invalid() {
// Arrange — drop `model_version` from the payload.
let raw = br#"{
"label":"confirmed_concealed_position",
"confidence":0.85,
"status":"ok",
"latency_ms":42
}"#;
let parser = AssessmentParser::new();
// Act
let a = parser.parse(raw);
// Assert
assert_eq!(a.status, VlmStatus::SchemaInvalid);
assert_eq!(parser.schema_invalid_count(), 1);
}
#[test]
fn excerpt_truncates_long_bodies() {
// Arrange
let raw = vec![b'a'; 8192];
// Act
let s = excerpt(&raw, 16);
// Assert
assert!(s.starts_with("aaaaaaaaaaaaaaaa"));
assert!(s.contains("truncated"));
}
}
+17 -4
View File
@@ -23,9 +23,10 @@ use tokio::net::UnixStream;
use tokio::sync::Mutex;
use tokio::time::timeout;
use super::parser::AssessmentParser;
use super::peer_cred::{check as check_peer, ExpectedPeer, PeerCredOutcome};
use super::prompt::{self, Limits};
use super::wire::{read_response, write_request, WireError};
use super::wire::{read_response_raw, write_request, WireError};
/// Errors returned from `connect`.
#[derive(Debug, thiserror::Error)]
@@ -83,6 +84,7 @@ impl NanoLlmClientOptions {
pub struct NanoLlmClient {
inner: Arc<Mutex<Inner>>,
options: Arc<NanoLlmClientOptions>,
parser: Arc<AssessmentParser>,
}
struct Inner {
@@ -118,6 +120,7 @@ impl NanoLlmClient {
Ok(Self {
inner: Arc::new(Mutex::new(inner)),
options: Arc::new(options),
parser: Arc::new(AssessmentParser::new()),
})
}
@@ -125,6 +128,12 @@ impl NanoLlmClient {
&self.options.socket_path
}
/// Shared parser. Exposes schema-invalid + model-version counters
/// for the health surface.
pub fn parser(&self) -> Arc<AssessmentParser> {
self.parser.clone()
}
/// Latency samples snapshot (cloned). Caller computes p50/p99.
pub async fn latency_samples(&self) -> Vec<Duration> {
self.inner.lock().await.latency_samples.clone()
@@ -179,7 +188,7 @@ impl NanoLlmClient {
.expect("stream present after reconnect");
match timeout(
self.options.request_deadline,
send_and_recv(stream, &prompt, &roi),
send_and_recv(stream, &prompt, &roi, &self.parser),
)
.await
{
@@ -270,10 +279,14 @@ async fn send_and_recv(
stream: &mut UnixStream,
prompt: &str,
roi: &[u8],
parser: &AssessmentParser,
) -> Result<VlmAssessment, WireError> {
write_request(stream, prompt, roi).await?;
let resp = read_response(stream).await?;
Ok(resp)
let body = read_response_raw(stream).await?;
// Schema validation lives in `AssessmentParser::parse`, not the
// wire layer. A JSON-broken or schema-invalid body returns
// `VlmAssessment{ status: SchemaInvalid }` — NOT an `Err`.
Ok(parser.parse(&body))
}
fn push_latency(samples: &mut Vec<Duration>, d: Duration) {
+14 -1
View File
@@ -14,6 +14,7 @@
use base64::Engine;
use serde::{Deserialize, Serialize};
#[cfg(test)]
use shared::models::vlm::VlmAssessment;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
@@ -64,7 +65,11 @@ pub async fn write_request<W: AsyncWrite + Unpin>(
Ok(())
}
pub async fn read_response<R: AsyncRead + Unpin>(r: &mut R) -> Result<VlmAssessment, WireError> {
/// Read one length-prefixed frame body. The body is returned as raw
/// bytes; JSON parsing is the [`crate::internal::parser`]'s job
/// (AZ-674 §AC-2 — schema-invalid responses must be observable as
/// `VlmAssessment{ status: SchemaInvalid }`, not as `Err`).
pub async fn read_response_raw<R: AsyncRead + Unpin>(r: &mut R) -> Result<Vec<u8>, WireError> {
let mut lenbuf = [0u8; 4];
r.read_exact(&mut lenbuf).await?;
let len = u32::from_be_bytes(lenbuf);
@@ -76,6 +81,14 @@ pub async fn read_response<R: AsyncRead + Unpin>(r: &mut R) -> Result<VlmAssessm
if n != body.len() {
return Err(WireError::UnexpectedEof);
}
Ok(body)
}
/// Legacy combined-read helper used by the in-tree wire-layer tests.
/// Production code calls `read_response_raw` + `AssessmentParser::parse`.
#[cfg(test)]
pub async fn read_response<R: AsyncRead + Unpin>(r: &mut R) -> Result<VlmAssessment, WireError> {
let body = read_response_raw(r).await?;
let assessment: VlmAssessment = serde_json::from_slice(&body)?;
Ok(assessment)
}
+2
View File
@@ -21,6 +21,8 @@ mod internal;
#[cfg(feature = "vlm")]
pub use enabled::VlmClient;
#[cfg(feature = "vlm")]
pub use internal::parser::{AssessmentParser, DEFAULT_LOG_TRUNCATION_BYTES};
#[cfg(feature = "vlm")]
pub use internal::peer_cred::ExpectedPeer;
#[cfg(feature = "vlm")]
pub use internal::prompt::Limits;
+203
View File
@@ -0,0 +1,203 @@
//! AZ-674 acceptance criteria.
//!
//! AC-1 — valid response parses successfully (round-trip through the
//! UDS fixture, verifying schema fields all survive).
//! AC-2 — schema-invalid response returns `status: SchemaInvalid` and
//! the schema-invalid counter increments.
//! AC-3 — model_version change logged once; subsequent identical
//! versions do NOT re-log (observed via the parser's `changes`
//! counter, which is incremented exactly once per change).
//! AC-4 — `VlmStatus` is exhaustive (compile-time check: this file
//! contains a `match` over every variant with no `_` arm).
#![cfg(feature = "vlm")]
use std::path::PathBuf;
use shared::contracts::VlmProvider;
use shared::models::vlm::{VlmLabel, VlmStatus};
use tempfile::tempdir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixListener;
use vlm_client::VlmClient;
async fn fixture_emitting(
path: PathBuf,
bodies: Vec<serde_json::Value>,
) -> tokio::task::JoinHandle<()> {
let listener = UnixListener::bind(&path).unwrap();
tokio::spawn(async move {
for body in bodies {
let (mut s, _) = listener.accept().await.unwrap();
let mut lenbuf = [0u8; 4];
if s.read_exact(&mut lenbuf).await.is_err() {
return;
}
let len = u32::from_be_bytes(lenbuf) as usize;
let mut req = vec![0u8; len];
if s.read_exact(&mut req).await.is_err() {
return;
}
let bytes = serde_json::to_vec(&body).unwrap();
let _ = s.write_all(&(bytes.len() as u32).to_be_bytes()).await;
let _ = s.write_all(&bytes).await;
let _ = s.flush().await;
}
})
}
#[tokio::test]
async fn ac1_valid_response_parses_successfully() {
// Arrange
let dir = tempdir().unwrap();
let path = dir.path().join("nanollm.sock");
let body = serde_json::json!({
"label": "confirmed_concealed_position",
"confidence": 0.91,
"evidence_spans": ["thicket"],
"reason": "match",
"status": "ok",
"latency_ms": 42,
"model_version": "VILA1.5-3B-int4"
});
let fixture = fixture_emitting(path.clone(), vec![body]).await;
let client = VlmClient::open(&path).await.expect("connect");
// Act
let a = client
.assess(b"\xff\xd8\xff".to_vec(), "describe".into())
.await
.unwrap();
// Assert
assert_eq!(a.status, VlmStatus::Ok);
assert_eq!(a.label, VlmLabel::ConfirmedConcealedPosition);
assert_eq!(a.model_version, "VILA1.5-3B-int4");
assert_eq!(a.latency_ms, 42);
assert_eq!(a.evidence_spans, vec!["thicket".to_string()]);
// Parser counters reflect the success path.
let parser = client.inner().unwrap().parser();
assert_eq!(parser.schema_invalid_count(), 0);
assert_eq!(parser.model_version_changes(), 1);
fixture.await.unwrap();
}
#[tokio::test]
async fn ac2_schema_invalid_response_returns_schema_invalid_and_increments_counter() {
// Arrange — fixture responds with valid JSON missing `model_version`.
let dir = tempdir().unwrap();
let path = dir.path().join("nanollm.sock");
let bad_body = serde_json::json!({
"label": "rejected",
"confidence": 0.4,
"status": "ok",
"latency_ms": 5
// model_version intentionally missing
});
let fixture = fixture_emitting(path.clone(), vec![bad_body]).await;
let client = VlmClient::open(&path).await.expect("connect");
// Act
let a = client.assess(b"r".to_vec(), "p".into()).await.unwrap();
// Assert
assert_eq!(a.status, VlmStatus::SchemaInvalid);
assert!(a.reason.starts_with("json:"), "got reason={}", a.reason);
let parser = client.inner().unwrap().parser();
assert_eq!(parser.schema_invalid_count(), 1);
assert_eq!(parser.model_version_changes(), 0);
fixture.await.unwrap();
}
/// AC-3 is exercised at the parser level — the model-version tracker
/// is a pure-state component that does not depend on the UDS layer.
/// The integration path is verified by AC-1 (one happy-path round
/// trip → parser sees one change event).
#[test]
fn ac3_model_version_change_logged_once_at_parser_level() {
use vlm_client::AssessmentParser;
// Arrange
let parser = AssessmentParser::new();
let mk = |v: &str| {
serde_json::to_vec(&serde_json::json!({
"label": "rejected",
"confidence": 0.5,
"status": "ok",
"latency_ms": 1,
"model_version": v
}))
.unwrap()
};
// Act — three responses: v1.0, v1.0 (no change), v1.1 (change).
let _ = parser.parse(&mk("v1.0"));
let _ = parser.parse(&mk("v1.0"));
let _ = parser.parse(&mk("v1.1"));
// Assert — exactly 2 change events: None→v1.0 and v1.0→v1.1.
assert_eq!(parser.model_version_changes(), 2);
assert_eq!(parser.current_model_version().as_deref(), Some("v1.1"));
}
/// Compile-time AC-4: this match must cover every `VlmStatus` variant
/// without a `_` arm. Adding a new variant breaks the build until
/// the consumer is updated.
#[test]
fn ac4_vlm_status_match_is_exhaustive() {
// Arrange — synthesise one of each variant.
let cases = [
VlmStatus::Ok,
VlmStatus::Inconclusive,
VlmStatus::Timeout,
VlmStatus::SchemaInvalid,
VlmStatus::IpcError,
VlmStatus::Disabled,
];
// Act / Assert — every variant must produce a labelled outcome.
for s in cases {
let label: &'static str = match s {
VlmStatus::Ok => "ok",
VlmStatus::Inconclusive => "inconclusive",
VlmStatus::Timeout => "timeout",
VlmStatus::SchemaInvalid => "schema_invalid",
VlmStatus::IpcError => "ipc_error",
VlmStatus::Disabled => "disabled",
};
assert!(!label.is_empty());
}
}
#[test]
fn schema_invalid_does_not_pollute_model_version_tracker() {
use vlm_client::AssessmentParser;
// Arrange — one valid body followed by one truncated/invalid body.
// The tracker must not regress to None on the second call.
let parser = AssessmentParser::new();
let good = serde_json::to_vec(&serde_json::json!({
"label": "rejected",
"confidence": 0.5,
"status": "ok",
"latency_ms": 1,
"model_version": "v1.0"
}))
.unwrap();
let bad = good[..good.len() - 10].to_vec();
// Act
let r1 = parser.parse(&good);
let r2 = parser.parse(&bad);
// Assert
assert_eq!(r1.status, VlmStatus::Ok);
assert_eq!(r2.status, VlmStatus::SchemaInvalid);
assert_eq!(parser.model_version_changes(), 1);
assert_eq!(parser.current_model_version().as_deref(), Some("v1.0"));
assert_eq!(parser.schema_invalid_count(), 1);
}