swactor/tests/mvp_system/membership_pool_readiness_guarantees.rs
Zachery Aaron Shores-Chmielewski b5de4c9bd6 docs(mvp): lay out mvp-system spec and contracts
Add MVP_SYSTEM_SPEC plus per-component contract docs (arena manager, device bridge,
gpu worker ctl/ingress/egress/process-adapter, orchestrator run-fsm/token-endpoint,
run plan, stage controller, tx_rx edge, weights). Scaffold the guarantee tests against
the (empty) mvp-system crate.


Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-06-23 11:12:45 +04:00

223 lines
8.9 KiB
Rust

//! Black-box contract tests for MVP membership and pool readiness.
//!
//! These tests intentionally know only the public readiness-gate surface:
//!
//! - a fixed orchestrator-owned candidate pool
//! - membership, availability, and identity observations
//! - emitted pool readiness, planning, and run fault events
//!
//! They assert the guarantees in
//! `specs/mvp_system/membership_pool_readiness_contract.md`.
use mvp_system::membership_pool_readiness as membership;
// Three nodes are enough to prove all-node quantification without hiding behind
// a single-node special case. The orchestrator owns this pool; SWIM only reports
// liveness facts about it.
fn candidate_pool() -> Vec<membership::NodeId> {
vec![
membership::NodeId(10),
membership::NodeId(11),
membership::NodeId(12),
]
}
// The convergence window is short but non-zero so tests can prove PoolReady is
// not emitted at the instant the last fact arrives.
fn readiness_config() -> membership::ReadinessConfig {
membership::ReadinessConfig {
pool_id: membership::PoolId("mvp-test-pool".into()),
convergence_window_ms: 500,
}
}
// This helper provides every required public fact for one node. Tests use it to
// build complete and deliberately incomplete pool views without observing any
// private readiness bookkeeping.
fn report_node_ready(
gate: &mut membership::ReadinessGateHarness,
node_id: membership::NodeId,
) {
gate.observe(membership::Observation::NodeKnown { node_id });
gate.observe(membership::Observation::SwimLive { node_id });
gate.observe(membership::Observation::NodeAvailable { node_id });
gate.observe(membership::Observation::DataPlaneIdentityReady { node_id });
}
// The test harness records public events as a transcript. This helper counts a
// specific event so duplicate readiness or duplicate faults are visible at the
// contract boundary.
fn count_pool_ready(events: &[membership::ReadinessEvent]) -> usize {
events
.iter()
.filter(|event| matches!(event, membership::ReadinessEvent::PoolReady { .. }))
.count()
}
// This proves PoolReady requires every candidate node, every required fact, no
// suspect or faulted candidates, and a stable convergence window.
#[test]
fn pool_ready_requires_complete_stable_candidate_pool() {
// Build the readiness gate with an orchestrator-owned candidate pool.
let pool = candidate_pool();
let mut gate = membership::ReadinessGateHarness::new(readiness_config(), pool.clone());
// Report complete facts for every node except the final candidate.
report_node_ready(&mut gate, pool[0]);
report_node_ready(&mut gate, pool[1]);
gate.advance_time_ms(1_000);
// A partial pool must not become ready, even after time passes.
assert_eq!(count_pool_ready(gate.events()), 0);
// Report the final candidate, then prove the convergence window still
// matters by advancing less than the configured duration.
report_node_ready(&mut gate, pool[2]);
gate.advance_time_ms(499);
assert_eq!(count_pool_ready(gate.events()), 0);
// After the window, PoolReady must describe exactly the intended pool.
gate.advance_time_ms(1);
let ready = gate
.events()
.iter()
.find_map(|event| match event {
membership::ReadinessEvent::PoolReady { pool } => Some(pool),
_ => None,
})
.expect("complete stable pool must emit PoolReady");
// Compare as sets so ordering is not part of the behavioral contract.
let observed = ready.iter().copied().collect::<std::collections::BTreeSet<_>>();
let expected = pool.iter().copied().collect::<std::collections::BTreeSet<_>>();
assert_eq!(observed, expected);
}
// This proves suspect, faulted, missing, or identity-less candidates prevent
// PoolReady rather than being silently ignored.
#[test]
fn unavailable_candidate_prevents_pool_ready() {
// Each case withholds or poisons one readiness fact for node 12.
let cases = [
membership::Observation::SwimSuspect {
node_id: membership::NodeId(12),
},
membership::Observation::NodeFaulted {
node_id: membership::NodeId(12),
},
membership::Observation::DataPlaneIdentityMissing {
node_id: membership::NodeId(12),
},
];
for poisoned_fact in cases {
// Start each run from a fresh gate so one poisoned fact is responsible
// for the absence of readiness.
let pool = candidate_pool();
let mut gate = membership::ReadinessGateHarness::new(readiness_config(), pool.clone());
// Make every candidate otherwise known and available.
for node_id in &pool {
report_node_ready(&mut gate, *node_id);
}
// Poison the final candidate and wait past convergence.
gate.observe(poisoned_fact.clone());
gate.advance_time_ms(1_000);
// PoolReady must be absent because one required candidate is no longer
// available to the intended pool.
assert_eq!(count_pool_ready(gate.events()), 0);
}
}
// This proves run planning is gated by PoolReady. SWIM observations alone do
// not start placement, and loss before a committed RunPlan prevents planning
// from racing ahead.
#[test]
fn planning_starts_only_after_pool_ready_and_stops_if_readiness_is_lost() {
// Create a gate and ask the orchestrator to plan before readiness.
let pool = candidate_pool();
let mut gate = membership::ReadinessGateHarness::new(readiness_config(), pool.clone());
gate.request_run_planning(membership::RunRequest::new(membership::RunId(7)));
// With no PoolReady event, there must be no planning command.
assert!(!gate.commands().iter().any(|command| {
matches!(command, membership::ReadinessCommand::StartPlanning { .. })
}));
// Satisfy readiness, then immediately lose a required node before plan
// commit. The policy may wait or abort, but it must not commit placement.
for node_id in &pool {
report_node_ready(&mut gate, *node_id);
}
gate.advance_time_ms(500);
gate.observe(membership::Observation::SwimLost { node_id: pool[1] });
// No plan commitment command may be emitted from an unstable pool view.
assert!(!gate.commands().iter().any(|command| {
matches!(command, membership::ReadinessCommand::CommitRunPlan { .. })
}));
}
// This proves membership loss after provisioning is a run fault, not active
// re-placement. The MVP run keeps its committed topology until teardown.
#[test]
fn required_node_loss_after_provisioning_faults_without_replacement() {
// Drive the pool to ready and mark a run as provisioned from that pool.
let pool = candidate_pool();
let mut gate = membership::ReadinessGateHarness::new(readiness_config(), pool.clone());
for node_id in &pool {
report_node_ready(&mut gate, *node_id);
}
gate.advance_time_ms(500);
gate.observe(membership::Observation::RunProvisioned {
run_id: membership::RunId(7),
required_nodes: pool.clone(),
});
// Lose one required node during the active run.
gate.observe(membership::Observation::SwimLost { node_id: pool[1] });
// The run must fault with a membership reason.
assert!(gate.events().contains(&membership::ReadinessEvent::RunFaulted {
run_id: membership::RunId(7),
reason: membership::RunFaultReason::RequiredNodeLost {
node_id: pool[1],
},
}));
// Re-placement would violate the committed-plan authority boundary.
assert!(!gate.commands().iter().any(|command| {
matches!(command, membership::ReadinessCommand::RecomputePlacement { .. })
}));
}
// This proves SWIM authority is limited to membership and liveness. It may
// report facts, but it must not assign stages, edges, layers, or object specs.
#[test]
fn swim_observations_do_not_create_graph_assignments() {
// Feed a complete, stable membership view.
let pool = candidate_pool();
let mut gate = membership::ReadinessGateHarness::new(readiness_config(), pool.clone());
for node_id in &pool {
report_node_ready(&mut gate, *node_id);
}
gate.advance_time_ms(500);
// Walk emitted commands and reject any graph-assignment side effect.
for command in gate.commands() {
match command {
membership::ReadinessCommand::EmitPoolReady { .. }
| membership::ReadinessCommand::StartPlanning { .. }
| membership::ReadinessCommand::WaitForStability { .. }
| membership::ReadinessCommand::AbortPendingRun { .. } => {}
membership::ReadinessCommand::AssignStage { .. }
| membership::ReadinessCommand::AssignEdge { .. }
| membership::ReadinessCommand::AssignLayerRange { .. }
| membership::ReadinessCommand::AssignObjectSpec { .. } => {
panic!("membership gate emitted graph assignment: {command:?}")
}
}
}
}