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}