Files
autopilot/crates/vlm_client/src/internal/parser.rs
T
Oleksandr Bezdieniezhnykh e56d428753 [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>
2026-05-19 17:40:43 +03:00

240 lines
7.5 KiB
Rust

//! 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"));
}
}