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/// Observed-vs-accepted trajectory projection over the ledger.
55#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
56pub struct TrajectoryProjection {
57    pub accepted: usize,
58    pub rejected: usize,
59    pub certificates: usize,
60    pub graph_revisions: usize,
61    /// Accepted-energy timeline (node_id, generation, energy), in ledger order.
62    pub energy_timeline: Vec<(String, u32, f64)>,
63}
64
65impl TrajectoryProjection {
66    /// Build the projection from ledger events (read-only).
67    pub fn from_ledger(ledger: &Ledger) -> Self {
68        let mut p = TrajectoryProjection::default();
69        for rec in ledger.records() {
70            match &rec.event {
71                LedgerEvent::CandidateAccepted {
72                    node_id,
73                    generation,
74                    energy,
75                } => {
76                    p.accepted += 1;
77                    p.energy_timeline
78                        .push((node_id.clone(), *generation, *energy));
79                }
80                LedgerEvent::CandidateRejected { .. } => p.rejected += 1,
81                LedgerEvent::ResidualCertificateIssued { .. } => p.certificates += 1,
82                LedgerEvent::GraphRevisionAccepted { .. } => p.graph_revisions += 1,
83                _ => {}
84            }
85        }
86        p
87    }
88}
89
90/// A residual heatmap by energy component and class (PSP-8 System 12).
91#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
92pub struct ResidualHeatmap {
93    pub by_component: BTreeMap<EnergyComponent, usize>,
94    pub by_class: BTreeMap<ResidualClass, usize>,
95}
96
97/// Build a residual heatmap from a residual vector.
98pub fn residual_heatmap(residuals: &[ResidualEvent]) -> ResidualHeatmap {
99    let mut heatmap = ResidualHeatmap::default();
100    for r in residuals {
101        *heatmap.by_component.entry(r.component).or_default() += 1;
102        *heatmap.by_class.entry(r.class).or_default() += 1;
103    }
104    heatmap
105}
106
107/// Capability audit projection over the ledger.
108#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109pub struct CapabilityAudit {
110    pub grants: usize,
111    pub revocations: usize,
112    pub denials: usize,
113}
114
115impl CapabilityAudit {
116    pub fn from_ledger(ledger: &Ledger) -> Self {
117        let mut a = CapabilityAudit::default();
118        for rec in ledger.records() {
119            match &rec.event {
120                LedgerEvent::CapabilityGranted { .. } => a.grants += 1,
121                LedgerEvent::CapabilityRevoked { .. } => a.revocations += 1,
122                LedgerEvent::EffectDenied { .. } => a.denials += 1,
123                _ => {}
124            }
125        }
126        a
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::residual::{IndependenceRoute, ResidualSeverity, SensorRef};
134
135    #[test]
136    fn phi_matches_formula() {
137        // 1 + 10/0.5 + 3 = 1 + 20 + 3 = 24.
138        assert_eq!(phi(10.0, 0.5, 3), 24.0);
139    }
140
141    #[test]
142    fn backlog_gauge_sums_potentials() {
143        let workflows = vec![
144            WorkflowPotential::new("w1", 10.0, 0.5, 3), // 24
145            WorkflowPotential::new("w2", 5.0, 0.5, 1),  // 1 + 10 + 1 = 12
146        ];
147        assert_eq!(backlog_gauge(&workflows), 36.0);
148    }
149
150    #[test]
151    fn trajectory_projection_counts_ledger_events() {
152        let mut ledger = Ledger::new();
153        ledger
154            .append(LedgerEvent::CandidateAccepted {
155                node_id: "a".into(),
156                generation: 0,
157                energy: 5.0,
158            })
159            .unwrap();
160        ledger
161            .append(LedgerEvent::CandidateRejected {
162                node_id: "a".into(),
163                generation: 1,
164            })
165            .unwrap();
166        ledger
167            .append(LedgerEvent::CandidateAccepted {
168                node_id: "a".into(),
169                generation: 2,
170                energy: 0.0,
171            })
172            .unwrap();
173        ledger
174            .append(LedgerEvent::GraphRevisionAccepted {
175                revision_id: "r1".into(),
176                sequence: 1,
177            })
178            .unwrap();
179        let p = TrajectoryProjection::from_ledger(&ledger);
180        assert_eq!(p.accepted, 2);
181        assert_eq!(p.rejected, 1);
182        assert_eq!(p.graph_revisions, 1);
183        assert_eq!(p.energy_timeline.len(), 2);
184    }
185
186    #[test]
187    fn residual_heatmap_groups_by_component_and_class() {
188        let sensor = SensorRef::new("c", IndependenceRoute::Compiler);
189        let residuals = vec![
190            ResidualEvent::new(
191                "n",
192                0,
193                ResidualClass::Type,
194                ResidualSeverity::Error,
195                1.0,
196                sensor.clone(),
197            )
198            .unwrap(),
199            ResidualEvent::new(
200                "n",
201                0,
202                ResidualClass::Type,
203                ResidualSeverity::Error,
204                1.0,
205                sensor.clone(),
206            )
207            .unwrap(),
208            ResidualEvent::new(
209                "n",
210                0,
211                ResidualClass::TestFailure,
212                ResidualSeverity::Error,
213                1.0,
214                sensor,
215            )
216            .unwrap(),
217        ];
218        let heatmap = residual_heatmap(&residuals);
219        assert_eq!(heatmap.by_component[&EnergyComponent::Syn], 2);
220        assert_eq!(heatmap.by_component[&EnergyComponent::Log], 1);
221        assert_eq!(heatmap.by_class[&ResidualClass::Type], 2);
222    }
223
224    #[test]
225    fn capability_audit_counts_grants_and_denials() {
226        let mut ledger = Ledger::new();
227        ledger
228            .append(LedgerEvent::CapabilityGranted {
229                capability_id: "c1".into(),
230                holder: "a".into(),
231            })
232            .unwrap();
233        ledger
234            .append(LedgerEvent::EffectDenied {
235                proposal_id: "p1".into(),
236                reason: "scope".into(),
237            })
238            .unwrap();
239        ledger
240            .append(LedgerEvent::EffectDenied {
241                proposal_id: "p2".into(),
242                reason: "budget".into(),
243            })
244            .unwrap();
245        let audit = CapabilityAudit::from_ledger(&ledger);
246        assert_eq!(audit.grants, 1);
247        assert_eq!(audit.denials, 2);
248    }
249}