Skip to main content

llmosafe/
control_types.rs

1//! DO-178C DAL A/E: Control theory types for cascade control architecture.
2//!
3//! # Control Signal Contract
4//!
5//! Every tier output implements `ControlSignal` with:
6//! - `setpoint()` — the reference value the loop tries to maintain
7//! - `error()` — the signed deviation from setpoint, normalised to `[0.0, 1.0]`
8//!
9//! # DAL Partitioning
10//!
11//! DAL (Design Assurance Level) safety overrides are gated behind the `dal` feature.
12//! With `dal` enabled, the following tiers apply:
13//!
14//! | DAL | Path | Description |
15//! |-----|------|-------------|
16//! | A | Halt | Catastrophic — bias/exhaustion/kernel → halt |
17//! | B | Escalate | Hazardous — detection flags modulate gains |
18//! | C | Warn | Major — advisory only |
19//! | D | Monitor | Minor — informational |
20//! | E | Proceed | No effect — pass-through |
21//!
22//! Without the `dal` feature, `apply_safety_overrides` is a passthrough — no
23//! hard limits are enforced on risk scores.
24//!
25//! # MC/DC
26//!
27//! All decision branches in `apply_safety_overrides` and
28//! `pid_risk_to_decision` must have independent condition coverage.
29//! See `mc_dc` annotations in the traceability matrix.
30
31/// Design Assurance Level per DO-178C.
32///
33/// # Runtime vs Compile-Time
34///
35/// Three enforcement layers operate simultaneously:
36///
37/// 1. **Compile-time** (conditional compilation): The `dal` feature gate controls
38///    whether `apply_safety_overrides` applies hard limits at all. Without the
39///    feature, overrides are a passthrough — no safety constraints are enforced
40///    in PID computation. This is the coarse control.
41///
42/// 2. **Runtime** (this enum): `EscalationPolicy.dal` gates the output-side
43///    escalation decisions (`decide_from_detection`, `decide_with_pressure`)
44///    regardless of the compile-time feature. Setting DAL E means "proceed
45///    always" even if `dal` feature is active. This is the fine control.
46///
47/// 3. **Static check** (design review): DAL A/B paths are traceable in the
48///    MC/DC matrix. Every path from detection to decision must have independent
49///    condition coverage. See `invariants.toml` §4.
50///
51/// The runtime DAL gates decisions AFTER PID computation. The compile-time
52/// feature gates safety overrides DURING PID computation. Both must pass
53/// for a Halt decision to reach the actuator.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56pub enum DesignAssuranceLevel {
57    /// Catastrophic — halt failure → system compromise.
58    A,
59    /// Hazardous — missed escalation → degraded safety.
60    B,
61    /// Major — false warn → user burden.
62    C,
63    /// Minor — informational only.
64    D,
65    /// No effect — proceed path.
66    E,
67}
68
69/// Control signal contract: every tier output must provide error and setpoint.
70pub trait ControlSignal {
71    /// Signed deviation from setpoint, normalised to `[0.0, 1.0]`.
72    fn error(&self) -> f32;
73    /// Reference value the loop maintains, normalised to `[0.0, 1.0]`.
74    fn setpoint(&self) -> f32;
75}
76
77/// Safety override flags applied AFTER PID computation.
78///
79/// Infusion pump pattern: PID computes pure risk from sensor fusion,
80/// safety supervisor applies hard limits before actuation.
81/// This prevents a PID bug from bypassing safety enforcement.
82///
83/// # DAL A Paths
84///
85/// - `BIAS`: [infusion pump override] forces risk ≥ halt_gain + 0.001
86/// - `EXHAUSTED`: [infusion pump override] forces risk = 1.0
87/// - `KERNEL_UNSTABLE`: [infusion pump override] forces risk ≥ halt_gain
88///
89/// # MC/DC
90///
91/// Each flag must independently force Halt regardless of PID output.
92/// Test: `apply_safety_overrides(0.0, BIAS) == halt_gain + 0.001`
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct OverrideFlags(u8);
95
96impl OverrideFlags {
97    /// Bias detection forces halt regardless of PID risk score.
98    pub const BIAS: Self = Self(0x01);
99    /// Resource exhaustion forces max risk (1.0).
100    pub const EXHAUSTED: Self = Self(0x02);
101    /// Kernel instability forces risk ≥ halt_gain.
102    pub const KERNEL_UNSTABLE: Self = Self(0x04);
103
104    /// Returns OverrideFlags(0) — a bitfield with no flags set.
105    pub const fn empty() -> Self {
106        Self(0)
107    }
108
109    /// Returns true if all bits set in `other` are also set in `self`, using bitwise AND equality check.
110    pub const fn contains(&self, other: Self) -> bool {
111        (self.0 & other.0) == other.0
112    }
113
114    /// Constructs OverrideFlags from raw u8 bits, masking to lower 3 bits (0x07).
115    /// Unknown upper bits are discarded.
116    pub const fn from_bits(bits: u8) -> Self {
117        Self(bits & 0x07)
118    }
119}
120
121/// Combines two OverrideFlags via bitwise OR of their inner u8 values.
122impl core::ops::BitOr for OverrideFlags {
123    type Output = Self;
124    fn bitor(self, rhs: Self) -> Self {
125        Self(self.0 | rhs.0)
126    }
127}
128
129/// Aggregate input to the PID composition loop.
130///
131/// Carries normalised error signals from all 4 tiers plus
132/// detection sidechain flags for gain modulation.
133///
134/// # Tier provenance
135///
136/// | Field            | Tier | Source                           |
137/// |------------------|------|----------------------------------|
138/// | `e_body`         | 0    | `BodyOutput.error_body`          |
139/// | `e_sift`         | 3    | `SifterOutput.error_sift`        |
140/// | `e_mem`          | 2    | `MemoryOutput.error_mem`         |
141/// | `e_kernel`       | 1    | `KernelOutput.error_kernel`      |
142/// | `trend`          | 2    | `WorkingMemory::trend()`         |
143/// | `classifier_prob`| 3    | `SifterOutput.classifier_prob`   |
144/// | `has_bias`       | 3    | `SifterOutput.has_bias`          |
145/// | `detection_flags`| DET  | Packed from 6 detectors          |
146/// | `pressure`       | 0    | `BodyOutput.pressure`            |
147///
148/// # DAL A
149///
150/// All fields must be finite and in expected ranges. PidConfig::validate()
151/// is called at construction. Signal normalisation uses saturating casts.
152///
153/// # MC/DC
154///
155/// Each error signal independently affects its corresponding PID term.
156/// has_bias independently forces Halt via apply_safety_overrides.
157#[derive(Debug, Clone, Copy)]
158pub struct PidInput {
159    /// Normalised body pressure error `[0.0, 1.0]`, from BodyOutput.error_body.
160    pub e_body: f32,
161    /// Normalised sifter error `[0.0, 1.0]`, from SifterOutput.error_sift.
162    pub e_sift: f32,
163    /// Normalised memory error `[0.0, 1.0]`, from MemoryOutput.error_mem.
164    pub e_mem: f32,
165    /// Normalised kernel error `[0.0, 1.0]`, from KernelOutput.error_kernel.
166    pub e_kernel: f32,
167    /// Raw entropy trend from WorkingMemory::trend().
168    pub trend: f64,
169    /// Classifier probability `[0.0, 1.0]`, from SifterOutput.classifier_prob.
170    pub classifier_prob: f32,
171    /// Bias flag from SifterOutput.has_bias.
172    pub has_bias: bool,
173    /// Packed detection flags for gain sidechain modulation.
174    pub detection_flags: u8,
175    /// Resource pressure percentage `[0, 100]`, from BodyOutput.pressure.
176    pub pressure: u8,
177}
178
179impl PidInput {
180    // Allow: PidInput is a flat data-transfer struct for the 4-tier PID
181    // cascade. A builder pattern would require allocation — inappropriate
182    // for no_std Tier-2 (see SYS-SPEC-602 §7.3.4).
183    /// Constructor taking 9 parameters and storing them directly into corresponding
184    /// struct fields. No validation or transformation performed — raw field assignment.
185    /// Uses #[allow(clippy::too_many_arguments)] because a builder pattern would
186    /// require allocation, inappropriate for no_std Tier-2.
187    #[allow(clippy::too_many_arguments)]
188    pub fn new(
189        e_body: f32,
190        e_sift: f32,
191        e_mem: f32,
192        e_kernel: f32,
193        trend: f64,
194        classifier_prob: f32,
195        has_bias: bool,
196        detection_flags: u8,
197        pressure: u8,
198    ) -> Self {
199        Self {
200            e_body,
201            e_sift,
202            e_mem,
203            e_kernel,
204            trend,
205            classifier_prob,
206            has_bias,
207            detection_flags,
208            pressure,
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_override_flags_empty() {
219        let flags = OverrideFlags::empty();
220        assert!(!flags.contains(OverrideFlags::BIAS));
221        assert!(!flags.contains(OverrideFlags::EXHAUSTED));
222        assert!(!flags.contains(OverrideFlags::KERNEL_UNSTABLE));
223    }
224
225    #[test]
226    fn test_override_flags_contains() {
227        let flags = OverrideFlags::BIAS | OverrideFlags::EXHAUSTED;
228        assert!(flags.contains(OverrideFlags::BIAS));
229        assert!(flags.contains(OverrideFlags::EXHAUSTED));
230        assert!(!flags.contains(OverrideFlags::KERNEL_UNSTABLE));
231    }
232
233    #[test]
234    fn test_dal_ordering() {
235        assert!(DesignAssuranceLevel::A as u8 == 0);
236        assert!(DesignAssuranceLevel::E as u8 == 4);
237    }
238}