Skip to main content

perspt_sdk/
observability.rs

1//! Observability projections (PSP-8 System 12).
2//!
3//! The UX exposes SRBN evidence rather than hiding it. All dashboard and TUI
4//! views are *read-only projections over the event ledger*, so monitoring cannot
5//! mutate the running session. This module computes those projections — the
6//! backlog gauge `Phi(W)`, the observed-vs-accepted trajectory, the residual
7//! heatmap, and the capability audit — from ledgered events and residual
8//! vectors. Rendering (axum/ratatui) belongs to `perspt-dashboard` / `perspt-tui`.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14use crate::ledger::{Ledger, LedgerEvent};
15use crate::residual::{EnergyComponent, ResidualClass, ResidualEvent};
16
17/// Per-workflow potential `phi_i = 1 + V_i/rho_gate + B_i` (PSP-8 System 2).
18pub fn phi(accepted_energy: f64, rho_gate: f64, remaining_budget: u32) -> f64 {
19    1.0 + accepted_energy / rho_gate + remaining_budget as f64
20}
21
22/// A backlog/remaining-work gauge entry (PSP-8 `WorkflowPotential`).
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct WorkflowPotential {
25    pub workflow_id: String,
26    pub accepted_energy: f64,
27    pub rho_gate: f64,
28    pub remaining_budget: u32,
29    pub potential: f64,
30}
31
32impl WorkflowPotential {
33    pub fn new(
34        workflow_id: impl Into<String>,
35        accepted_energy: f64,
36        rho_gate: f64,
37        remaining_budget: u32,
38    ) -> Self {
39        Self {
40            workflow_id: workflow_id.into(),
41            accepted_energy,
42            rho_gate,
43            remaining_budget,
44            potential: phi(accepted_energy, rho_gate, remaining_budget),
45        }
46    }
47}
48
49/// Aggregate backlog gauge `Phi(W) = sum_i phi_i` (PSP-8 System 2 / Gate H).
50pub fn backlog_gauge(workflows: &[WorkflowPotential]) -> f64 {
51    workflows.iter().map(|w| w.potential).sum()
52}
53
54/// Theorem 9's sub-criticality load `α`, computed from arriving **potential**
55/// per step, not arrival count (PSP-9 system 17): a single arriving workflow
56/// with a large `V_a/ρ_gate` can exceed the bound on its own. The gauge is
57/// labeled as arriving potential wherever it is displayed; positive
58/// recurrence is reported only when every Theorem 9 hypothesis is evidenced.
59pub fn arriving_potential_per_step(arrivals_per_step: &[Vec<WorkflowPotential>]) -> f64 {
60    if arrivals_per_step.is_empty() {
61        return 0.0;
62    }
63    let total: f64 = arrivals_per_step
64        .iter()
65        .map(|arrivals| backlog_gauge(arrivals))
66        .sum();
67    total / arrivals_per_step.len() as f64
68}
69
70/// Observed-vs-accepted trajectory projection over the ledger.
71#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
72pub struct TrajectoryProjection {
73    pub accepted: usize,
74    pub rejected: usize,
75    pub certificates: usize,
76    pub graph_revisions: usize,
77    /// Accepted-energy timeline (node_id, generation, energy), in ledger order.
78    pub energy_timeline: Vec<(String, u32, f64)>,
79}
80
81impl TrajectoryProjection {
82    /// Build the projection from ledger events (read-only).
83    pub fn from_ledger(ledger: &Ledger) -> Self {
84        let mut p = TrajectoryProjection::default();
85        for rec in ledger.records() {
86            match &rec.event {
87                LedgerEvent::CandidateAccepted {
88                    node_id,
89                    generation,
90                    energy,
91                } => {
92                    p.accepted += 1;
93                    p.energy_timeline
94                        .push((node_id.clone(), *generation, *energy));
95                }
96                LedgerEvent::CandidateRejected { .. } => p.rejected += 1,
97                LedgerEvent::ResidualCertificateIssued { .. } => p.certificates += 1,
98                LedgerEvent::GraphRevisionAccepted { .. } => p.graph_revisions += 1,
99                _ => {}
100            }
101        }
102        p
103    }
104}
105
106/// A residual heatmap by energy component and class (PSP-8 System 12).
107#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
108pub struct ResidualHeatmap {
109    pub by_component: BTreeMap<EnergyComponent, usize>,
110    pub by_class: BTreeMap<ResidualClass, usize>,
111}
112
113/// Build a residual heatmap from a residual vector.
114pub fn residual_heatmap(residuals: &[ResidualEvent]) -> ResidualHeatmap {
115    let mut heatmap = ResidualHeatmap::default();
116    for r in residuals {
117        *heatmap.by_component.entry(r.component).or_default() += 1;
118        *heatmap.by_class.entry(r.class).or_default() += 1;
119    }
120    heatmap
121}
122
123/// Capability audit projection over the ledger.
124#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
125pub struct CapabilityAudit {
126    pub grants: usize,
127    pub revocations: usize,
128    pub denials: usize,
129}
130
131impl CapabilityAudit {
132    pub fn from_ledger(ledger: &Ledger) -> Self {
133        let mut a = CapabilityAudit::default();
134        for rec in ledger.records() {
135            match &rec.event {
136                LedgerEvent::CapabilityGranted { .. } => a.grants += 1,
137                LedgerEvent::CapabilityRevoked { .. } => a.revocations += 1,
138                LedgerEvent::EffectDenied { .. } => a.denials += 1,
139                _ => {}
140            }
141        }
142        a
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::residual::{IndependenceRoute, ResidualSeverity, SensorRef};
150
151    #[test]
152    fn phi_matches_formula() {
153        // 1 + 10/0.5 + 3 = 1 + 20 + 3 = 24.
154        assert_eq!(phi(10.0, 0.5, 3), 24.0);
155    }
156
157    #[test]
158    fn backlog_gauge_sums_potentials() {
159        let workflows = vec![
160            WorkflowPotential::new("w1", 10.0, 0.5, 3), // 24
161            WorkflowPotential::new("w2", 5.0, 0.5, 1),  // 1 + 10 + 1 = 12
162        ];
163        assert_eq!(backlog_gauge(&workflows), 36.0);
164    }
165
166    #[test]
167    fn trajectory_projection_counts_ledger_events() {
168        let mut ledger = Ledger::new();
169        ledger
170            .append(LedgerEvent::CandidateAccepted {
171                node_id: "a".into(),
172                generation: 0,
173                energy: 5.0,
174            })
175            .unwrap();
176        ledger
177            .append(LedgerEvent::CandidateRejected {
178                node_id: "a".into(),
179                generation: 1,
180            })
181            .unwrap();
182        ledger
183            .append(LedgerEvent::CandidateAccepted {
184                node_id: "a".into(),
185                generation: 2,
186                energy: 0.0,
187            })
188            .unwrap();
189        ledger
190            .append(LedgerEvent::GraphRevisionAccepted {
191                revision_id: "r1".into(),
192                sequence: 1,
193            })
194            .unwrap();
195        let p = TrajectoryProjection::from_ledger(&ledger);
196        assert_eq!(p.accepted, 2);
197        assert_eq!(p.rejected, 1);
198        assert_eq!(p.graph_revisions, 1);
199        assert_eq!(p.energy_timeline.len(), 2);
200    }
201
202    #[test]
203    fn residual_heatmap_groups_by_component_and_class() {
204        let sensor = SensorRef::new("c", IndependenceRoute::Compiler);
205        let residuals = vec![
206            ResidualEvent::new(
207                "n",
208                0,
209                ResidualClass::Type,
210                ResidualSeverity::Error,
211                1.0,
212                sensor.clone(),
213            )
214            .unwrap(),
215            ResidualEvent::new(
216                "n",
217                0,
218                ResidualClass::Type,
219                ResidualSeverity::Error,
220                1.0,
221                sensor.clone(),
222            )
223            .unwrap(),
224            ResidualEvent::new(
225                "n",
226                0,
227                ResidualClass::TestFailure,
228                ResidualSeverity::Error,
229                1.0,
230                sensor,
231            )
232            .unwrap(),
233        ];
234        let heatmap = residual_heatmap(&residuals);
235        assert_eq!(heatmap.by_component[&EnergyComponent::Syn], 2);
236        assert_eq!(heatmap.by_component[&EnergyComponent::Log], 1);
237        assert_eq!(heatmap.by_class[&ResidualClass::Type], 2);
238    }
239
240    #[test]
241    fn capability_audit_counts_grants_and_denials() {
242        let mut ledger = Ledger::new();
243        ledger
244            .append(LedgerEvent::CapabilityGranted {
245                capability_id: "c1".into(),
246                holder: "a".into(),
247            })
248            .unwrap();
249        ledger
250            .append(LedgerEvent::EffectDenied {
251                proposal_id: "p1".into(),
252                reason: "scope".into(),
253            })
254            .unwrap();
255        ledger
256            .append(LedgerEvent::EffectDenied {
257                proposal_id: "p2".into(),
258                reason: "budget".into(),
259            })
260            .unwrap();
261        let audit = CapabilityAudit::from_ledger(&ledger);
262        assert_eq!(audit.grants, 1);
263        assert_eq!(audit.denials, 2);
264    }
265
266    #[test]
267    fn alpha_is_arriving_potential_not_arrival_count() {
268        // Step 1: three trivial arrivals. Step 2: one heavy arrival whose
269        // potential alone exceeds the three combined — a count-based gauge
270        // would rank the steps backwards.
271        let light = vec![
272            WorkflowPotential::new("a", 0.0, 0.5, 0),
273            WorkflowPotential::new("b", 0.0, 0.5, 0),
274            WorkflowPotential::new("c", 0.0, 0.5, 0),
275        ];
276        let heavy = vec![WorkflowPotential::new("d", 50.0, 0.5, 3)];
277        assert!(backlog_gauge(&heavy) > backlog_gauge(&light));
278        let alpha = arriving_potential_per_step(&[light, heavy]);
279        assert!((alpha - (3.0 + 104.0) / 2.0).abs() < 1e-9, "{alpha}");
280    }
281}