feat(firmware): validate configuration and migrate sensor-driven flight APIs

Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
This commit is contained in:
Onur Özkan committed 2026-11-07 18:12:11 +03:00
1 parent fb1b8af2d1
commit f9a61a086f
211 files changed
+45657 -12740

No files matched your search

@@ -0,0 +1,155 @@
//! Bounded public-operation histories at the synchronous commit boundary.
use super::*;
fn healthy() -> SafetyInputs {
SafetyInputs {
estimator_healthy: true,
imu_fresh: true,
phase_authorized: true,
output_healthy: true,
}
}
fn any_inputs() -> SafetyInputs {
SafetyInputs {
estimator_healthy: kani::any(),
imu_fresh: kani::any(),
phase_authorized: kani::any(),
output_healthy: kani::any(),
}
}
fn supervisor() -> (SafetySupervisor, u64) {
let dwell: u64 = kani::any();
kani::assume(dwell > 0);
(
SafetySupervisor::new(SafetyConfig::new(dwell).unwrap()),
dwell,
)
}
#[kani::proof]
fn commit_requires_current_health_and_completed_dwell() {
let (mut safety, dwell) = supervisor();
let start: u64 = kani::any();
let now: u64 = kani::any();
safety.request_arm(Timestamp::from_microseconds(start), healthy());
let inputs = any_inputs();
let admitted = inputs == healthy()
&& now
.checked_sub(start)
.is_some_and(|elapsed| elapsed >= dwell);
let mut calls = 0_u8;
let value: u32 = kani::any();
let outcome = safety.evaluate_and_commit(Timestamp::from_microseconds(now), inputs, || {
calls += 1;
Ok::<_, ()>(value)
});
assert_eq!(calls, u8::from(admitted));
if admitted {
assert_eq!(outcome, CommitOutcome::Committed(value));
assert_eq!(safety.state(), SafetyState::Armed);
} else {
assert!(matches!(outcome, CommitOutcome::Inhibited(_)));
}
kani::cover!(admitted);
kani::cover!(safety.state() == SafetyState::PreArm);
kani::cover!(now < start);
kani::cover!(!inputs.imu_fresh && now >= start);
}
#[kani::proof]
fn failed_commit_preserves_error_and_latches_inhibition() {
let (mut safety, dwell) = supervisor();
let start: u64 = kani::any();
let Some(armed_at) = start.checked_add(dwell) else {
return;
};
safety.request_arm(Timestamp::from_microseconds(start), healthy());
let error: u32 = kani::any();
let mut calls = 0_u8;
let failed =
safety.evaluate_and_commit(Timestamp::from_microseconds(armed_at), healthy(), || {
calls += 1;
Err::<(), _>(error)
});
assert_eq!(calls, 1);
assert_eq!(failed, CommitOutcome::Failed(error));
assert_eq!(safety.state(), SafetyState::Failsafe);
assert_eq!(
safety.decision().failure(),
Some(SafetyFailure::CommitFailed)
);
// No disarm or epoch reset occurs between failure and this next evaluation.
let next = Timestamp::from_microseconds(kani::any());
let inputs = any_inputs();
let outcome = safety.evaluate_and_commit(next, inputs, || {
calls += 1;
Ok::<_, ()>(())
});
assert!(matches!(outcome, CommitOutcome::Inhibited(_)));
assert_eq!(calls, 1);
assert_eq!(safety.state(), SafetyState::Failsafe);
kani::cover!(inputs == healthy() && next.as_microseconds() >= armed_at);
}
#[kani::proof]
fn clock_regression_requires_successful_inhibited_reset() {
let (mut safety, dwell) = supervisor();
let first: u64 = kani::any();
let earlier: u64 = kani::any();
kani::assume(earlier < first);
safety.request_arm(Timestamp::from_microseconds(first), healthy());
let mut commits = 0_u8;
let regressed =
safety.evaluate_and_commit(Timestamp::from_microseconds(earlier), healthy(), || {
commits += 1;
Ok::<_, ()>(())
});
assert!(matches!(regressed, CommitOutcome::Inhibited(_)));
assert_eq!(
safety.decision().failure(),
Some(SafetyFailure::ClockRegression)
);
safety.request_disarm();
assert_eq!(
safety
.request_arm(Timestamp::from_microseconds(first), healthy())
.state(),
SafetyState::Failsafe
);
safety.request_disarm();
let inhibit_succeeds: bool = kani::any();
let error: u32 = kani::any();
let mut inhibitions = 0_u8;
let reset = safety.reset_time_epoch(|| {
inhibitions += 1;
if inhibit_succeeds { Ok(()) } else { Err(error) }
});
assert_eq!(inhibitions, 1);
if inhibit_succeeds {
assert_eq!(reset, Ok(()));
} else {
assert_eq!(reset, Err(ResetError::Inhibit(error)));
assert_eq!(safety.state(), SafetyState::Failsafe);
}
safety.request_disarm();
safety.request_arm(Timestamp::from_microseconds(0), healthy());
let outcome =
safety.evaluate_and_commit(Timestamp::from_microseconds(dwell), healthy(), || {
commits += 1;
Ok::<_, ()>(())
});
assert_eq!(commits, u8::from(inhibit_succeeds));
assert_eq!(
matches!(outcome, CommitOutcome::Committed(())),
inhibit_succeeds
);
kani::cover!(inhibit_succeeds);
kani::cover!(!inhibit_succeeds);
}
+180 -468
View File
@@ -1,79 +1,80 @@
//! Bounded safety supervisor for actuator authorization.
//! Bounded safety supervision with a synchronous actuator commit boundary.
//!
//! The platform turns estimator, command, and transport health into the
//! explicit inputs below. This crate does not authenticate a command or drive
//! hardware; a reviewed platform must evaluate it before every output commit
//! and make the output port validate the returned opaque permit.
//! Decisions are diagnostics. Only `evaluate_and_commit` invokes an enabled
//! output operation, while holding the issuing supervisor's exclusive borrow.
//! The callback must commit synchronously, not queue future authority.
//!
//! Callbacks must not panic. They must report operational failure with `Err`.
//! Panics do not run the returned-error transition or guarantee inhibition.
//! If a host catches an unwind, it must discard the affected supervisor and
//! callback-owned state, never resume them or use an epoch reset as recovery.
//! The platform owns abort/halt panic containment. Neither abort nor halt alone
//! establishes that physical outputs are zero.
//!
//! There is no transferable permission or cross-supervisor validation API.
//! ```compile_fail
//! use kaptan_safety::ActuatorPermit;
//! ```
//! ```compile_fail
//! use kaptan_safety::{SafetyConfig, SafetySupervisor};
//! let a = SafetySupervisor::new(SafetyConfig::new(1).unwrap());
//! let b = SafetySupervisor::new(SafetyConfig::new(1).unwrap());
//! b.permit_is_current(a.decision().permit().unwrap());
//! ```
#![no_std]
#![forbid(unsafe_code)]
use kaptan_err_reportable::KaptanError;
use kaptan_time::Timestamp;
/// Supervisor arming state.
#[cfg(kani)]
mod kani_proofs;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SafetyState {
/// Physical outputs inhibited.
/// No current arm transaction.
Disarmed,
/// Checks are continuously passing during the configured dwell period.
/// Continuously healthy evidence must satisfy the configured dwell.
PreArm,
/// Outputs may be enabled by a platform transport that validates a permit.
/// The next synchronous commit still requires evaluation of current inputs.
Armed,
/// Outputs are inhibited pending an explicit disarm and new arm request.
/// Inhibited until explicit disarm. Clock faults also require an epoch reset.
Failsafe,
}
/// Typed reason for refusing or ending actuator authorization.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum SafetyFailure {
/// Estimator is absent, stale, or non-finite.
#[kaptan_error(id = 9001)]
EstimatorUnhealthy,
/// Required inertial sensor stream is stale.
#[kaptan_error(id = 9002)]
ImuStale,
/// Required command/link stream is stale.
#[kaptan_error(id = 9003)]
LinkStale,
/// Actuator transport reports a fault.
PhaseUnauthorized,
#[kaptan_error(id = 9004)]
ActuatorFault,
/// No current explicit arm transaction exists.
#[kaptan_error(id = 9005)]
ArmNotRequested,
/// The platform supplied time that moved backwards.
#[kaptan_error(id = 9006)]
ClockRegression,
/// A non-wrapping actuator-permit epoch could not be allocated.
#[kaptan_error(id = 9007)]
PermitEpochExhausted,
/// The synchronous commit failed. Its error is returned to the caller.
#[kaptan_error(id = 9008)]
CommitFailed,
}
/// Invalid safety configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum SafetyConfigError {
/// A zero dwell would permit two evaluations at one timestamp to arm.
#[kaptan_error(id = 9101)]
ZeroPrearmDwell,
}
/// Explicit platform policy for the safety state machine.
///
/// This is intentionally not `Default`: a vehicle or simulator must choose
/// and review its own continuously-healthy prearm dwell.
///
/// TODO(configuration): this value is currently selected by the build-time
/// vehicle profile. A ground-station configuration service must validate it
/// as part of one persisted safety/output profile and only activate the new
/// revision while disarmed; a live duration change must not restart or extend
/// an arming transaction.
/// Validated build-time policy. Activate profile changes only while disarmed.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SafetyConfig {
prearm_dwell_microseconds: u64,
}
impl SafetyConfig {
/// Builds a policy with a strictly positive prearm dwell.
pub const fn new(prearm_dwell_microseconds: u64) -> Result<Self, SafetyConfigError> {
if prearm_dwell_microseconds == 0 {
Err(SafetyConfigError::ZeroPrearmDwell)
@@ -83,83 +84,65 @@ impl SafetyConfig {
})
}
}
/// Returns the required continuously-healthy dwell duration.
pub const fn prearm_dwell_microseconds(self) -> u64 {
self.prearm_dwell_microseconds
}
}
/// Platform-normalized bounded health input. Every `true` is a current
/// measurement, not a promise about sensor calibration or physical safety.
/// Current platform-normalized evidence, not authentication or physical safety.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SafetyInputs {
/// Estimator finite/initialized health is acceptable for the requested mode.
/// The estimator is initialized, finite, and fresh for the requested mode.
pub estimator_healthy: bool,
/// IMU receipt is within the static platform freshness limit.
/// The required inertial stream is within the configured freshness limit.
pub imu_fresh: bool,
/// Control command/link receipt is within its freshness limit.
///
/// TODO(flight-config): an external-control flight mode must map lease
/// expiry into a reviewed bounded hold or landing mode while estimator and
/// actuator health remain valid; it must not pass that expired lease here
/// as an immediate physical output inhibit. Fatal sensor/control/transport
/// faults remain inhibit conditions.
pub link_fresh: bool,
/// Output transport is initialized and fault-free.
pub actuator_healthy: bool,
/// Runtime has applied mode deadlines and admitted this phase's authority.
pub phase_authorized: bool,
/// The synchronous output transport is initialized and fault-free.
pub output_healthy: bool,
}
/// Authorization token that external code cannot construct or clone.
///
/// A retained token cannot authorize output after a supervisor transition.
#[derive(Debug, Eq, PartialEq)]
pub struct ActuatorPermit {
epoch: u64,
}
/// Observable supervisor transition decision.
///
/// The token is deliberately opaque and non-`Copy`. A transport must call
/// [`SafetySupervisor::permit_is_current`] immediately before a nonzero
/// output commit; the decision itself is not an output command.
#[derive(Debug, Eq, PartialEq)]
/// Diagnostic snapshot. It cannot authorize an output operation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SafetyDecision {
state: SafetyState,
failure: Option<SafetyFailure>,
permit: Option<ActuatorPermit>,
}
impl SafetyDecision {
/// Resulting supervisor state.
pub const fn state(&self) -> SafetyState {
pub const fn state(self) -> SafetyState {
self.state
}
/// Reason whenever physical output is inhibited.
pub const fn failure(&self) -> Option<SafetyFailure> {
pub const fn failure(self) -> Option<SafetyFailure> {
self.failure
}
/// Returns the authorization token only while the decision is armed.
pub const fn permit(&self) -> Option<&ActuatorPermit> {
self.permit.as_ref()
}
}
/// Small safety supervisor with no queues, heap, peripheral access, or hidden
/// clock. A platform supplies one monotonic timestamp to every transition.
#[derive(Debug, Eq, PartialEq)]
pub enum CommitOutcome<T, E> {
/// The callback was not invoked.
Inhibited(SafetyDecision),
Committed(T),
/// The callback ran once and failed. The supervisor is now in failsafe.
Failed(E),
}
#[derive(Debug, Eq, PartialEq)]
pub enum ResetError<E> {
NotDisarmed,
Inhibit(E),
}
/// No queues, heap, peripherals, hidden clock, or transferable authority.
/// Callbacks must not panic. A supervisor crossed by unwinding must be discarded.
/// Platform panic containment is required independently of this state machine.
pub struct SafetySupervisor {
config: SafetyConfig,
state: SafetyState,
last_failure: Option<SafetyFailure>,
prearm_since: Option<Timestamp>,
last_evaluation: Option<Timestamp>,
permit_epoch: u64,
clock_fault: bool,
}
impl SafetySupervisor {
/// Starts disarmed and inhibited irrespective of command availability.
pub const fn new(config: SafetyConfig) -> Self {
Self {
config,
@@ -167,449 +150,178 @@ impl SafetySupervisor {
last_failure: Some(SafetyFailure::ArmNotRequested),
prearm_since: None,
last_evaluation: None,
permit_epoch: 0,
clock_fault: false,
}
}
/// Consumes one explicit arm transaction.
///
/// Only a healthy request while disarmed enters prearm. A failed request is
/// never retained for a later healthy evaluation.
/// A failed request is never retained for subsequent health recovery.
pub fn request_arm(&mut self, now: Timestamp, inputs: SafetyInputs) -> SafetyDecision {
if !self.observe_time(now) {
if !self.observe_time(now) || self.state != SafetyState::Disarmed {
return self.decision();
}
if self.state != SafetyState::Disarmed {
return self.decision();
match preflight_failure(inputs) {
Some(SafetyFailure::ActuatorFault) => self.report_actuator_fault(),
Some(failure) => self.last_failure = Some(failure),
None => {
self.state = SafetyState::PreArm;
self.prearm_since = Some(now);
self.last_failure = None;
}
}
if let Some(failure) = preflight_failure(inputs) {
self.last_failure = Some(failure);
return self.decision();
}
self.state = SafetyState::PreArm;
self.prearm_since = Some(now);
self.last_failure = None;
self.decision()
}
/// Explicitly inhibits output and consumes any outstanding arm state.
/// Consumes arming state. A clock fault still requires an explicit epoch reset.
pub fn request_disarm(&mut self) {
if self.state == SafetyState::Armed && !self.invalidate_active_permit() {
return;
}
self.state = SafetyState::Disarmed;
self.prearm_since = None;
self.last_failure = Some(SafetyFailure::ArmNotRequested);
self.last_failure = Some(if self.clock_fault {
SafetyFailure::ClockRegression
} else {
SafetyFailure::ArmNotRequested
});
}
/// Begins a new platform clock epoch after output has been inhibited.
///
/// This is for a simulator restart or an explicitly reviewed platform
/// clock reset. It preserves the monotonically increasing permit epoch so
/// a token from before the reset can never validate after rearming.
pub fn reset_time_epoch(&mut self) {
self.request_disarm();
self.prearm_since = None;
/// Resets time only while disarmed and after synchronous inhibition succeeds.
/// A failed output leaves all clock history intact and latches actuator fault.
/// `inhibit` must return `Err` on failure, not panic. After unwinding, discard
/// the supervisor and callback-owned state instead of retrying this method.
pub fn reset_time_epoch<E>(
&mut self,
inhibit: impl FnOnce() -> Result<(), E>,
) -> Result<(), ResetError<E>> {
if self.state != SafetyState::Disarmed {
return Err(ResetError::NotDisarmed);
}
if let Err(error) = inhibit() {
self.report_actuator_fault();
return Err(ResetError::Inhibit(error));
}
self.last_evaluation = None;
self.clock_fault = false;
self.request_disarm();
Ok(())
}
/// Evaluates preflight/failsafe inputs supplied by a platform that owns
/// their timestamp and freshness policy. Platforms must call this before
/// every physical output commit.
pub fn evaluate(&mut self, now: Timestamp, inputs: SafetyInputs) -> SafetyDecision {
/// Evaluates current evidence and invokes `commit` at most once, only armed.
/// No permission escapes this exclusive borrow. Returned callback errors latch
/// failsafe before returning their original error for required cleanup.
/// `commit` must not panic. Unwinding skips this error transition and requires
/// discarding the supervisor and callback-owned state, not resuming execution.
///
/// A callback cannot re-enter or replace its issuing supervisor.
/// ```compile_fail
/// use kaptan_safety::{SafetyConfig, SafetyInputs, SafetySupervisor};
/// use kaptan_time::Timestamp;
/// let mut safety = SafetySupervisor::new(SafetyConfig::new(1).unwrap());
/// let inputs = SafetyInputs {
/// estimator_healthy: true, imu_fresh: true,
/// phase_authorized: true, output_healthy: true,
/// };
/// safety.evaluate_and_commit(Timestamp::from_microseconds(1), inputs, || {
/// safety.request_disarm();
/// Ok::<_, ()>(())
/// });
/// ```
pub fn evaluate_and_commit<T, E>(
&mut self,
now: Timestamp,
inputs: SafetyInputs,
commit: impl FnOnce() -> Result<T, E>,
) -> CommitOutcome<T, E> {
self.evaluate(now, inputs);
if self.state != SafetyState::Armed {
return CommitOutcome::Inhibited(self.decision());
}
match commit() {
Ok(value) => CommitOutcome::Committed(value),
Err(error) => {
self.fail(SafetyFailure::CommitFailed);
CommitOutcome::Failed(error)
}
}
}
/// Output failures, including failed inhibition, latch until explicit disarm.
pub fn report_actuator_fault(&mut self) {
self.fail(SafetyFailure::ActuatorFault);
}
pub const fn state(&self) -> SafetyState {
self.state
}
pub const fn decision(&self) -> SafetyDecision {
SafetyDecision {
state: self.state,
failure: self.last_failure,
}
}
fn evaluate(&mut self, now: Timestamp, inputs: SafetyInputs) {
if !self.observe_time(now) {
return self.decision();
return;
}
let failure = preflight_failure(inputs);
if failure == Some(SafetyFailure::ActuatorFault) {
self.report_actuator_fault();
return;
}
match self.state {
SafetyState::Disarmed => {
self.last_failure = failure.or(Some(SafetyFailure::ArmNotRequested));
}
SafetyState::PreArm => {
if let Some(reason) = failure {
self.state = SafetyState::Disarmed;
self.prearm_since = None;
self.request_disarm();
self.last_failure = Some(reason);
} else if self.prearm_since.is_some_and(|since| {
elapsed_at_least(now, since, self.config.prearm_dwell_microseconds)
now.as_microseconds() - since.as_microseconds()
>= self.config.prearm_dwell_microseconds
}) {
self.enter_armed();
self.state = SafetyState::Armed;
self.prearm_since = None;
self.last_failure = None;
}
}
SafetyState::Armed => {
if let Some(reason) = failure {
self.leave_armed(SafetyState::Failsafe, reason);
self.fail(reason);
}
}
SafetyState::Failsafe => {}
}
self.decision()
}
/// Checks an opaque token at the output boundary.
pub fn permit_is_current(&self, permit: &ActuatorPermit) -> bool {
self.state == SafetyState::Armed && self.permit_epoch == permit.epoch
}
/// Returns the current state without granting a bypass permission.
pub const fn state(&self) -> SafetyState {
self.state
}
fn observe_time(&mut self, now: Timestamp) -> bool {
if self.last_evaluation.is_some_and(|previous| now < previous) {
self.prearm_since = None;
if self.state == SafetyState::Armed {
self.leave_armed(SafetyState::Failsafe, SafetyFailure::ClockRegression);
} else if self.state == SafetyState::PreArm {
self.state = SafetyState::Failsafe;
self.last_failure = Some(SafetyFailure::ClockRegression);
} else {
self.last_failure = Some(SafetyFailure::ClockRegression);
}
self.clock_fault = true;
}
if self.clock_fault {
self.fail(SafetyFailure::ClockRegression);
return false;
}
self.last_evaluation = Some(now);
true
}
fn enter_armed(&mut self) {
let Some(next_epoch) = self.permit_epoch.checked_add(1) else {
self.state = SafetyState::Failsafe;
self.prearm_since = None;
self.last_failure = Some(SafetyFailure::PermitEpochExhausted);
return;
};
self.permit_epoch = next_epoch;
self.state = SafetyState::Armed;
fn fail(&mut self, failure: SafetyFailure) {
self.state = SafetyState::Failsafe;
self.prearm_since = None;
self.last_failure = None;
self.last_failure = Some(failure);
}
fn leave_armed(&mut self, next_state: SafetyState, failure: SafetyFailure) {
if self.invalidate_active_permit() {
self.state = next_state;
self.prearm_since = None;
self.last_failure = Some(failure);
}
}
fn invalidate_active_permit(&mut self) -> bool {
let Some(next_epoch) = self.permit_epoch.checked_add(1) else {
self.state = SafetyState::Failsafe;
self.prearm_since = None;
self.last_failure = Some(SafetyFailure::PermitEpochExhausted);
return false;
};
self.permit_epoch = next_epoch;
true
}
fn decision(&self) -> SafetyDecision {
SafetyDecision {
state: self.state,
failure: self.last_failure,
permit: if self.state == SafetyState::Armed {
Some(ActuatorPermit {
epoch: self.permit_epoch,
})
} else {
None
},
}
}
}
fn elapsed_at_least(now: Timestamp, since: Timestamp, duration_microseconds: u64) -> bool {
now.as_microseconds() - since.as_microseconds() >= duration_microseconds
}
fn preflight_failure(inputs: SafetyInputs) -> Option<SafetyFailure> {
if !inputs.estimator_healthy {
if !inputs.output_healthy {
Some(SafetyFailure::ActuatorFault)
} else if !inputs.estimator_healthy {
Some(SafetyFailure::EstimatorUnhealthy)
} else if !inputs.imu_fresh {
Some(SafetyFailure::ImuStale)
} else if !inputs.link_fresh {
Some(SafetyFailure::LinkStale)
} else if !inputs.actuator_healthy {
Some(SafetyFailure::ActuatorFault)
} else if !inputs.phase_authorized {
Some(SafetyFailure::PhaseUnauthorized)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
const DWELL_MICROSECONDS: u64 = 100;
fn supervisor() -> SafetySupervisor {
SafetySupervisor::new(SafetyConfig::new(DWELL_MICROSECONDS).unwrap())
}
fn time(microseconds: u64) -> Timestamp {
Timestamp::from_microseconds(microseconds)
}
fn healthy() -> SafetyInputs {
SafetyInputs {
estimator_healthy: true,
imu_fresh: true,
link_fresh: true,
actuator_healthy: true,
}
}
fn arm(supervisor: &mut SafetySupervisor) -> SafetyDecision {
assert_eq!(
supervisor.request_arm(time(10), healthy()).state(),
SafetyState::PreArm
);
supervisor.evaluate(time(10 + DWELL_MICROSECONDS), healthy())
}
#[test]
fn configuration_requires_nonzero_dwell() {
assert_eq!(
SafetyConfig::new(0),
Err(SafetyConfigError::ZeroPrearmDwell)
);
assert_eq!(
SafetyConfig::new(DWELL_MICROSECONDS)
.unwrap()
.prearm_dwell_microseconds(),
DWELL_MICROSECONDS
);
}
#[test]
fn failed_arm_is_not_latched_for_later_health() {
let mut safety = supervisor();
let mut unhealthy = healthy();
unhealthy.imu_fresh = false;
assert_eq!(
safety.request_arm(time(1), unhealthy).failure(),
Some(SafetyFailure::ImuStale)
);
let decision = safety.evaluate(time(1 + DWELL_MICROSECONDS), healthy());
assert_eq!(decision.state(), SafetyState::Disarmed);
assert!(decision.permit().is_none());
assert_eq!(decision.failure(), Some(SafetyFailure::ArmNotRequested));
}
#[test]
fn dwell_requires_elapsed_monotonic_time() {
let mut safety = supervisor();
assert_eq!(
safety.request_arm(time(10), healthy()).state(),
SafetyState::PreArm
);
assert_eq!(
safety.evaluate(time(10), healthy()).state(),
SafetyState::PreArm
);
assert_eq!(
safety
.evaluate(time(10 + DWELL_MICROSECONDS - 1), healthy())
.state(),
SafetyState::PreArm
);
let decision = safety.evaluate(time(10 + DWELL_MICROSECONDS), healthy());
assert_eq!(decision.state(), SafetyState::Armed);
assert!(decision.permit().is_some());
}
#[test]
fn prearm_fault_consumes_transaction() {
let mut safety = supervisor();
safety.request_arm(time(10), healthy());
let mut stale = healthy();
stale.link_fresh = false;
assert_eq!(
safety.evaluate(time(20), stale).state(),
SafetyState::Disarmed
);
assert_eq!(
safety
.evaluate(time(10 + DWELL_MICROSECONDS), healthy())
.state(),
SafetyState::Disarmed
);
}
#[test]
fn armed_fault_latches_failsafe() {
let mut safety = supervisor();
let armed = arm(&mut safety);
assert!(safety.permit_is_current(armed.permit().unwrap()));
let mut stale = healthy();
stale.link_fresh = false;
let decision = safety.evaluate(time(111), stale);
assert_eq!(decision.state(), SafetyState::Failsafe);
assert!(decision.permit().is_none());
assert_eq!(decision.failure(), Some(SafetyFailure::LinkStale));
assert_eq!(
safety.evaluate(time(112), healthy()).state(),
SafetyState::Failsafe
);
}
#[test]
fn time_regression_inhibits_and_latches_failsafe() {
let mut safety = supervisor();
let armed = arm(&mut safety);
let permit = armed.permit().unwrap();
assert!(safety.permit_is_current(permit));
let decision = safety.evaluate(time(109), healthy());
assert_eq!(decision.state(), SafetyState::Failsafe);
assert!(decision.permit().is_none());
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
assert!(!safety.permit_is_current(permit));
}
#[test]
fn failsafe_requires_explicit_disarm_before_a_fresh_arm_request() {
let mut safety = supervisor();
let _armed = arm(&mut safety);
let mut stale = healthy();
stale.link_fresh = false;
safety.evaluate(time(111), stale);
assert_eq!(
safety.request_arm(time(112), healthy()).state(),
SafetyState::Failsafe
);
safety.request_disarm();
assert_eq!(
safety.request_arm(time(113), healthy()).state(),
SafetyState::PreArm
);
}
#[test]
fn stale_permit_cannot_authorize_after_rearm() {
let mut safety = supervisor();
let first = arm(&mut safety);
let first_permit = first.permit().unwrap();
assert!(safety.permit_is_current(first_permit));
safety.request_disarm();
assert!(!safety.permit_is_current(first_permit));
assert_eq!(
safety.request_arm(time(120), healthy()).state(),
SafetyState::PreArm
);
let second = safety.evaluate(time(120 + DWELL_MICROSECONDS), healthy());
assert!(safety.permit_is_current(second.permit().unwrap()));
assert!(!safety.permit_is_current(first_permit));
}
#[test]
fn platform_epoch_reset_accepts_a_new_clock_without_revalidating_permits() {
let mut safety = supervisor();
let armed = arm(&mut safety);
let permit = armed.permit().unwrap();
safety.reset_time_epoch();
assert_eq!(safety.state(), SafetyState::Disarmed);
assert!(!safety.permit_is_current(permit));
assert_eq!(
safety.request_arm(time(1), healthy()).state(),
SafetyState::PreArm
);
let rearmed = safety.evaluate(time(1 + DWELL_MICROSECONDS), healthy());
assert!(safety.permit_is_current(rearmed.permit().unwrap()));
assert!(!safety.permit_is_current(permit));
}
#[test]
fn permit_epoch_exhaustion_latches_inhibition() {
let mut safety = supervisor();
safety.permit_epoch = u64::MAX;
safety.request_arm(time(10), healthy());
let decision = safety.evaluate(time(10 + DWELL_MICROSECONDS), healthy());
assert_eq!(decision.state(), SafetyState::Failsafe);
assert_eq!(
decision.failure(),
Some(SafetyFailure::PermitEpochExhausted)
);
assert!(decision.permit().is_none());
}
#[test]
fn preflight_priority_is_stable() {
assert_eq!(
preflight_failure(SafetyInputs {
estimator_healthy: false,
imu_fresh: false,
link_fresh: false,
actuator_healthy: false,
}),
Some(SafetyFailure::EstimatorUnhealthy)
);
assert_eq!(
preflight_failure(SafetyInputs {
estimator_healthy: true,
imu_fresh: false,
link_fresh: false,
actuator_healthy: false,
}),
Some(SafetyFailure::ImuStale)
);
assert_eq!(
preflight_failure(SafetyInputs {
estimator_healthy: true,
imu_fresh: true,
link_fresh: true,
actuator_healthy: false,
}),
Some(SafetyFailure::ActuatorFault)
);
}
#[test]
fn clock_regressions_and_permit_exhaustion_fail_closed() {
let mut disarmed = supervisor();
disarmed.evaluate(time(10), healthy());
let decision = disarmed.request_arm(time(9), healthy());
assert_eq!(decision.state(), SafetyState::Disarmed);
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
let mut prearm = supervisor();
prearm.request_arm(time(10), healthy());
let decision = prearm.evaluate(time(9), healthy());
assert_eq!(decision.state(), SafetyState::Failsafe);
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
let mut armed = supervisor();
let armed_decision = arm(&mut armed);
let permit = armed_decision.permit().unwrap();
assert!(armed.permit_is_current(permit));
assert_eq!(
armed.evaluate(time(111), healthy()).state(),
SafetyState::Armed
);
// This test-only corrupt state models a counter exhausted after a
// previously issued permit. Explicit disarm must still revoke output
// and latch the overflow rather than wrapping a token epoch.
armed.permit_epoch = u64::MAX;
armed.request_disarm();
assert_eq!(armed.state(), SafetyState::Failsafe);
assert_eq!(
armed.last_failure,
Some(SafetyFailure::PermitEpochExhausted)
);
// A failsafe transition uses the same non-wrapping revocation path.
let mut overflow_on_fault = supervisor();
overflow_on_fault.state = SafetyState::Armed;
overflow_on_fault.permit_epoch = u64::MAX;
let mut stale = healthy();
stale.link_fresh = false;
let decision = overflow_on_fault.evaluate(time(1), stale);
assert_eq!(decision.state(), SafetyState::Failsafe);
assert_eq!(
decision.failure(),
Some(SafetyFailure::PermitEpochExhausted)
);
}
}
+268
View File
@@ -0,0 +1,268 @@
use kaptan_safety::*;
use kaptan_time::Timestamp;
fn time(value: u64) -> Timestamp {
Timestamp::from_microseconds(value)
}
fn healthy() -> SafetyInputs {
SafetyInputs {
estimator_healthy: true,
imu_fresh: true,
phase_authorized: true,
output_healthy: true,
}
}
fn supervisor() -> SafetySupervisor {
SafetySupervisor::new(SafetyConfig::new(100).unwrap())
}
fn evaluate(s: &mut SafetySupervisor, now: u64, inputs: SafetyInputs) -> CommitOutcome<(), ()> {
s.evaluate_and_commit(time(now), inputs, || Ok(()))
}
#[test]
fn dwell_and_owner_local_commit() {
assert_eq!(
SafetyConfig::new(0),
Err(SafetyConfigError::ZeroPrearmDwell)
);
let mut a = supervisor();
let mut b = supervisor();
assert_eq!(
a.request_arm(time(10), healthy()).state(),
SafetyState::PreArm
);
for now in [10, 109] {
assert!(matches!(
a.evaluate_and_commit(time(now), healthy(), || -> Result<(), ()> {
panic!("prearm cannot commit")
}),
CommitOutcome::Inhibited(_)
));
}
let mut calls = 0;
assert_eq!(
a.evaluate_and_commit(time(110), healthy(), || {
calls += 1;
Ok::<_, ()>(42)
}),
CommitOutcome::Committed(42)
);
assert_eq!(calls, 1);
// Healthy armed evaluations keep committing without restarting the dwell.
assert_eq!(
evaluate(&mut a, 111, healthy()),
CommitOutcome::Committed(())
);
assert!(matches!(
b.evaluate_and_commit(time(110), healthy(), || -> Result<(), ()> {
panic!("another owner's armed state cannot commit here")
}),
CommitOutcome::Inhibited(_)
));
}
#[test]
fn failed_arm_and_prearm_are_consumed() {
let mut s = supervisor();
let mut stale = healthy();
stale.imu_fresh = false;
assert_eq!(
s.request_arm(time(1), stale).failure(),
Some(SafetyFailure::ImuStale)
);
evaluate(&mut s, 200, healthy());
assert_eq!(s.state(), SafetyState::Disarmed);
s.request_arm(time(201), healthy());
evaluate(&mut s, 202, stale);
evaluate(&mut s, 400, healthy());
assert_eq!(s.state(), SafetyState::Disarmed);
}
#[test]
fn failed_arm_health_priority_and_recovery_require_a_new_transaction() {
for (inputs, expected) in [
(
SafetyInputs {
estimator_healthy: false,
imu_fresh: false,
phase_authorized: false,
output_healthy: false,
},
SafetyFailure::ActuatorFault,
),
(
SafetyInputs {
estimator_healthy: false,
imu_fresh: false,
phase_authorized: false,
..healthy()
},
SafetyFailure::EstimatorUnhealthy,
),
(
SafetyInputs {
imu_fresh: false,
phase_authorized: false,
..healthy()
},
SafetyFailure::ImuStale,
),
(
SafetyInputs {
phase_authorized: false,
..healthy()
},
SafetyFailure::PhaseUnauthorized,
),
] {
let mut s = supervisor();
let expected_state = if expected == SafetyFailure::ActuatorFault {
SafetyState::Failsafe
} else {
SafetyState::Disarmed
};
let decision = s.request_arm(time(0), inputs);
assert_eq!(decision.failure(), Some(expected));
assert_eq!(decision.state(), expected_state);
assert!(matches!(
evaluate(&mut s, 100, healthy()),
CommitOutcome::Inhibited(_)
));
assert_eq!(s.state(), expected_state);
if expected == SafetyFailure::ActuatorFault {
assert_eq!(
s.request_arm(time(101), healthy()).state(),
SafetyState::Failsafe
);
s.request_disarm();
}
assert_eq!(
s.request_arm(time(102), healthy()).state(),
SafetyState::PreArm
);
assert_eq!(
evaluate(&mut s, 202, healthy()),
CommitOutcome::Committed(())
);
}
}
#[test]
fn regressing_arm_request_consumes_prearm_and_requires_explicit_reset() {
let mut s = supervisor();
assert_eq!(
s.request_arm(time(100), healthy()).state(),
SafetyState::PreArm
);
let decision = s.request_arm(time(99), healthy());
assert_eq!(decision.state(), SafetyState::Failsafe);
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
assert!(matches!(
evaluate(&mut s, 200, healthy()),
CommitOutcome::Inhibited(_)
));
s.request_disarm();
s.reset_time_epoch(|| Ok::<_, ()>(())).unwrap();
assert_eq!(
s.request_arm(time(0), healthy()).state(),
SafetyState::PreArm
);
}
#[test]
fn current_health_revokes_before_callback_and_recovery_cannot_rearm() {
let mut s = supervisor();
s.request_arm(time(0), healthy());
assert_eq!(
evaluate(&mut s, 100, healthy()),
CommitOutcome::Committed(())
);
let diagnostic = s.decision();
let mut stale = healthy();
stale.phase_authorized = false;
assert!(matches!(
s.evaluate_and_commit(time(101), stale, || -> Result<(), ()> {
panic!("stale evidence must inhibit before callback")
}),
CommitOutcome::Inhibited(_)
));
assert_eq!(diagnostic.state(), SafetyState::Armed); // retained diagnostic only
assert_eq!(
s.decision().failure(),
Some(SafetyFailure::PhaseUnauthorized)
);
assert!(matches!(
evaluate(&mut s, 102, healthy()),
CommitOutcome::Inhibited(_)
));
assert_eq!(
s.request_arm(time(103), healthy()).state(),
SafetyState::Failsafe
);
s.request_disarm();
assert_eq!(
s.request_arm(time(104), healthy()).state(),
SafetyState::PreArm
);
assert_eq!(
evaluate(&mut s, 204, healthy()),
CommitOutcome::Committed(())
);
}
#[test]
fn callback_error_and_actuator_health_require_disarm() {
let mut s = supervisor();
s.request_arm(time(0), healthy());
assert_eq!(
s.evaluate_and_commit(time(100), healthy(), || Err::<(), _>("commit")),
CommitOutcome::Failed("commit")
);
assert_eq!(s.state(), SafetyState::Failsafe);
evaluate(&mut s, 101, healthy());
assert_eq!(
s.request_arm(time(102), healthy()).state(),
SafetyState::Failsafe
);
s.request_disarm();
s.request_arm(time(103), healthy());
let mut broken = healthy();
broken.output_healthy = false;
evaluate(&mut s, 104, broken);
assert_eq!(s.decision().failure(), Some(SafetyFailure::ActuatorFault));
evaluate(&mut s, 300, healthy());
assert_eq!(s.state(), SafetyState::Failsafe);
}
#[test]
fn clock_fault_survives_disarm_and_failed_reset() {
let mut s = supervisor();
evaluate(&mut s, 100, healthy());
evaluate(&mut s, 99, healthy());
assert_eq!(s.decision().failure(), Some(SafetyFailure::ClockRegression));
assert_eq!(
s.reset_time_epoch(|| Ok::<_, ()>(())),
Err(ResetError::NotDisarmed)
);
s.request_disarm();
assert_eq!(
s.request_arm(time(101), healthy()).state(),
SafetyState::Failsafe
);
s.request_disarm();
assert_eq!(
s.reset_time_epoch(|| Err("inhibit")),
Err(ResetError::Inhibit("inhibit"))
);
s.request_disarm();
assert_eq!(
s.request_arm(time(102), healthy()).state(),
SafetyState::Failsafe
);
s.request_disarm();
s.reset_time_epoch(|| Ok::<_, ()>(())).unwrap();
assert_eq!(
s.request_arm(time(0), healthy()).state(),
SafetyState::PreArm
);
}