swactor/tests/mvp_system/observability_surface_guarantees.rs

276 lines
10 KiB
Rust
Raw Normal View History

//! Black-box contract tests for the MVP observability surface.
//!
//! These tests intentionally know only the public event stream surface:
//!
//! - structured lifecycle and fault events emitted by components
//! - event identities, reason enums, and ordering observed by subscribers
//!
//! They assert the guarantees in
//! `specs/mvp_system/observability_surface_contract.md`.
use mvp_system::observability_surface as obs;
// The trace fixture contains one successful run from boot through teardown.
// Tests use structured events only; logs, transport, storage, and batching stay
// outside the contract.
fn successful_run_trace() -> Vec<obs::Event> {
obs::TraceBuilder::new(obs::RunId(7))
.node_started(obs::NodeId(10))
.node_available(obs::NodeId(10))
.pool_ready(vec![obs::NodeId(10)])
.run_planned()
.stage_provision_started(obs::StageIndex(0), obs::NodeId(10))
.weights_download_started(obs::StageIndex(0))
.weights_downloaded(obs::StageIndex(0))
.weights_loaded(obs::StageIndex(0))
.edge_provision_started(obs::EdgeId(7000))
.edge_ready(obs::EdgeId(7000))
.stage_ready(obs::StageIndex(0))
.readiness_barrier_passed()
.prompt_injected(obs::Sequence(0))
.object_loaded(obs::EdgeId(7000), obs::ObjectId(9000), obs::Sequence(0))
.execute_step_started(obs::StepId(77))
.object_produced(obs::EdgeId(7001), obs::ObjectId(9001), obs::Sequence(0))
.step_completed(obs::StepId(77))
.token_received(obs::ObjectId(9002), obs::Sequence(0))
.run_completed()
.stop_run_sent(obs::StageIndex(0))
.stage_stopped(obs::StageIndex(0))
.run_torn_down()
.finish()
}
// A fault trace gives the tests one stable reason enum and detecting component
// without relying on diagnostic log text.
fn fault_trace() -> Vec<obs::Event> {
obs::TraceBuilder::new(obs::RunId(7))
.node_started(obs::NodeId(10))
.node_available(obs::NodeId(10))
.pool_ready(vec![obs::NodeId(10)])
.run_planned()
.stage_provision_started(obs::StageIndex(0), obs::NodeId(10))
.stage_faulted(
obs::StageIndex(0),
obs::FaultReason::WorkerCrashed,
obs::Component::StageController,
)
.run_faulted(obs::FaultReason::WorkerCrashed, obs::Component::StageController)
.stop_run_sent(obs::StageIndex(0))
.stage_stopped(obs::StageIndex(0))
.run_torn_down()
.finish()
}
// This helper returns the position of an event kind in a trace. Ordering tests
// use positions so they prove causal ordering without depending on exact event
// batching or adjacent placement.
fn position_of_kind(events: &[obs::Event], kind: obs::EventKind) -> usize {
events
.iter()
.position(|event| event.kind() == kind)
.expect("event kind missing from trace")
}
// This helper checks structured identity fields directly. If callers have to
// scrape logs to recover an id, the event fails this contract test.
fn assert_required_identity(event: &obs::Event) {
match event {
obs::Event::RunScoped { run_id, .. } => assert_eq!(*run_id, obs::RunId(7)),
obs::Event::NodeScoped { node_id, .. } => assert_eq!(*node_id, obs::NodeId(10)),
obs::Event::StageScoped {
run_id,
stage_index,
..
} => {
assert_eq!(*run_id, obs::RunId(7));
assert_eq!(*stage_index, obs::StageIndex(0));
}
obs::Event::EdgeScoped { edge_id, .. } => {
assert!([obs::EdgeId(7000), obs::EdgeId(7001)].contains(edge_id));
}
obs::Event::RingScoped { ring_id, .. } => assert_eq!(*ring_id, obs::RingId(8000)),
obs::Event::ObjectScoped {
object_id,
sequence,
..
} => {
assert!([obs::ObjectId(9000), obs::ObjectId(9001), obs::ObjectId(9002)].contains(object_id));
assert_eq!(*sequence, obs::Sequence(0));
}
obs::Event::StepScoped { step_id, .. } => assert_eq!(*step_id, obs::StepId(77)),
obs::Event::WorkerScoped {
worker_generation,
..
} => assert_eq!(*worker_generation, obs::WorkerGeneration(1)),
}
}
// This proves required event identity fields are structured on the event itself
// for run, node, stage, edge, ring, object, step, and worker scopes.
#[test]
fn required_event_identity_is_structured_not_log_derived() {
// Build one trace that includes all required identity scopes.
let mut events = successful_run_trace();
events.push(obs::Event::RingScoped {
kind: obs::EventKind::RingReadable,
ring_id: obs::RingId(8000),
component: obs::Component::SharedRingHelper,
});
events.push(obs::Event::WorkerScoped {
kind: obs::EventKind::WorkerReady,
worker_generation: obs::WorkerGeneration(1),
component: obs::Component::GpuWorkerCtl,
});
// Every event exposes its required identity directly.
for event in &events {
assert_required_identity(event);
}
}
// This proves the lifecycle event stream covers the required successful-run
// milestones from node boot through run teardown.
#[test]
fn lifecycle_events_cover_successful_run_milestones() {
// Build the successful trace.
let events = successful_run_trace();
let observed = events
.iter()
.map(|event| event.kind())
.collect::<std::collections::BTreeSet<_>>();
// The required lifecycle event kinds must all be present, regardless of
// batching or transport.
let required = [
obs::EventKind::NodeStarted,
obs::EventKind::NodeAvailable,
obs::EventKind::PoolReady,
obs::EventKind::RunPlanned,
obs::EventKind::StageProvisionStarted,
obs::EventKind::WeightsDownloadStarted,
obs::EventKind::WeightsDownloaded,
obs::EventKind::WeightsLoaded,
obs::EventKind::EdgeProvisionStarted,
obs::EventKind::EdgeReady,
obs::EventKind::StageReady,
obs::EventKind::ReadinessBarrierPassed,
obs::EventKind::PromptInjected,
obs::EventKind::ObjectLoaded,
obs::EventKind::ExecuteStepStarted,
obs::EventKind::ObjectProduced,
obs::EventKind::StepCompleted,
obs::EventKind::TokenReceived,
obs::EventKind::RunCompleted,
obs::EventKind::StopRunSent,
obs::EventKind::StageStopped,
obs::EventKind::RunTornDown,
];
for kind in required {
assert!(observed.contains(&kind), "missing lifecycle event: {kind:?}");
}
}
// This proves fault events carry a stable reason enum and detecting component,
// and tests do not need free-form log text to determine lifecycle progress.
#[test]
fn fault_events_include_stable_reason_and_detecting_component() {
// Build a fault trace with a stage-detected worker crash.
let events = fault_trace();
// The structured stage fault carries the reason and detector.
assert!(events.iter().any(|event| {
matches!(
event,
obs::Event::StageScoped {
kind: obs::EventKind::StageFaulted,
reason: Some(obs::FaultReason::WorkerCrashed),
component: obs::Component::StageController,
..
}
)
}));
// The run fault carries the same structured reason.
assert!(events.iter().any(|event| {
matches!(
event,
obs::Event::RunScoped {
kind: obs::EventKind::RunFaulted,
reason: Some(obs::FaultReason::WorkerCrashed),
component: obs::Component::StageController,
..
}
)
}));
// Logs may exist, but they are not required to classify progress.
assert!(!obs::requires_log_scraping(&events));
}
// This proves observability ordering reflects component contracts:
// prompt_injected follows readiness_barrier_passed, stage_ready follows local
// readiness, run_torn_down follows teardown completion, and terminal run outcome
// is emitted exactly once.
#[test]
fn event_ordering_reflects_component_contracts_and_one_terminal_outcome() {
// Build the successful trace.
let events = successful_run_trace();
// Prompt injection cannot precede the global barrier.
assert!(
position_of_kind(&events, obs::EventKind::ReadinessBarrierPassed)
< position_of_kind(&events, obs::EventKind::PromptInjected)
);
// StageReady cannot precede required local readiness facts.
assert!(
position_of_kind(&events, obs::EventKind::WeightsLoaded)
< position_of_kind(&events, obs::EventKind::StageReady)
);
assert!(
position_of_kind(&events, obs::EventKind::EdgeReady)
< position_of_kind(&events, obs::EventKind::StageReady)
);
// RunTornDown cannot precede teardown completion.
assert!(
position_of_kind(&events, obs::EventKind::StageStopped)
< position_of_kind(&events, obs::EventKind::RunTornDown)
);
// Exactly one terminal run outcome is emitted.
let terminal_count = events
.iter()
.filter(|event| {
matches!(event.kind(), obs::EventKind::RunCompleted | obs::EventKind::RunFaulted)
})
.count();
assert_eq!(terminal_count, 1);
}
// This proves observability tests are independent of transport, storage, and
// batching policy by asserting the same event facts after batching is changed.
#[test]
fn event_contract_survives_transport_storage_and_batching_policy() {
// Build the same logical events under two batching policies.
let unbatched = obs::EventSubscriberHarness::collect(successful_run_trace(), obs::Batching::None);
let batched = obs::EventSubscriberHarness::collect(successful_run_trace(), obs::Batching::Fixed(8));
// Flattened public event facts must match as an ordered stream.
let unbatched_kinds = unbatched
.flattened_events()
.iter()
.map(|event| event.kind())
.collect::<Vec<_>>();
let batched_kinds = batched
.flattened_events()
.iter()
.map(|event| event.kind())
.collect::<Vec<_>>();
assert_eq!(batched_kinds, unbatched_kinds);
// Neither subscriber depends on transport or storage implementation names.
assert!(!unbatched.used_transport_specific_assertions());
assert!(!batched.used_storage_specific_assertions());
}