mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 15:11:09 +00:00
[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:
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user