Skip to main content

perspt_agent/
realize.rs

1//! The workspace realizer (PSP-9 system 10, Paper I Definition 12.2).
2//!
3//! The gate is evaluated on the candidate workspace that would actually be
4//! checkpointed, never on the model's account of it. `WorkspaceState` is a
5//! cheap content-addressed handle — hashes and identifiers, never file
6//! contents — because the kernel clones every attempt into its trace.
7//!
8//! The kernel's `srbn::Realizer` is **synchronous**, while Perspt's
9//! realization re-reads an overlay on a tokio runtime. The split is
10//! deliberate: [`snapshot_workspace`] does the I/O *first*, and
11//! [`SnapshotRealizer`] is a pure sync adapter over already-materialized
12//! snapshots, used by the `stabilize_realized` conformance oracle. The live
13//! tool loop calls [`snapshot_workspace`] directly at its measure boundary.
14//!
15//! Coding claims no numeric `‖r_k‖`: the fraction of denied or rewritten
16//! edits is [`ProjectionMismatch`] telemetry, recorded but never summed into
17//! `V` and never labeled a realizability residual.
18
19use std::collections::BTreeMap;
20use std::path::Path;
21
22use anyhow::{Context, Result};
23use perspt_sdk::srbn;
24use sha2::{Digest, Sha256};
25
26/// Content-addressed view of the files a node generation touched.
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct WorkspaceState {
29    /// Workspace-relative path → SHA-256 of the file's bytes, or `None` for
30    /// a path that does not exist (deleted or never created).
31    pub files: BTreeMap<String, Option<String>>,
32}
33
34impl WorkspaceState {
35    /// A stable digest of the whole state, for ledger heads and witnesses.
36    pub fn root_hash(&self) -> String {
37        let mut hasher = Sha256::new();
38        for (path, hash) in &self.files {
39            hasher.update(path.as_bytes());
40            hasher.update([0u8]);
41            hasher.update(hash.as_deref().unwrap_or("absent").as_bytes());
42            hasher.update([0u8]);
43        }
44        hex_digest(hasher)
45    }
46}
47
48/// Re-read the named paths from the overlay root and hash what is actually
49/// on disk. This is the realization boundary: whatever the model claims,
50/// measurement runs against this snapshot.
51pub fn snapshot_workspace(root: &Path, paths: &[String]) -> Result<WorkspaceState> {
52    let mut files = BTreeMap::new();
53    for rel in paths {
54        let absolute = root.join(rel);
55        let hash = match std::fs::read(&absolute) {
56            Ok(bytes) => {
57                let mut hasher = Sha256::new();
58                hasher.update(&bytes);
59                Some(hex_digest(hasher))
60            }
61            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
62            Err(e) => return Err(e).with_context(|| format!("re-reading {rel:?}")),
63        };
64        files.insert(rel.clone(), hash);
65    }
66    Ok(WorkspaceState { files })
67}
68
69/// Projection telemetry (PSP-9 system 10): denied, stale, ambiguous, or
70/// normalized edits. Operational telemetry about the projection — **not**
71/// Paper I's `‖r_k‖`, which requires a proxy geometry coding does not have.
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct ProjectionMismatch {
74    pub denied_proposals: u32,
75    pub stale_witnesses: u32,
76    pub ambiguous_edits: u32,
77    pub formatter_rewrites: u32,
78    pub partial_applications: u32,
79}
80
81impl ProjectionMismatch {
82    /// Total mismatch events this turn.
83    pub fn total(&self) -> u32 {
84        self.denied_proposals
85            + self.stale_witnesses
86            + self.ambiguous_edits
87            + self.formatter_rewrites
88            + self.partial_applications
89    }
90}
91
92/// Sync adapter over pre-materialized snapshots for the kernel's
93/// `stabilize_realized` conformance oracle. Coding realization is
94/// unmeasured: the structural Definition 12.2 property holds (the barrier
95/// sees the realized state), but no numeric residual is claimed.
96pub struct SnapshotRealizer;
97
98impl srbn::Realizer<WorkspaceState> for SnapshotRealizer {
99    fn realize(
100        &mut self,
101        proposed: WorkspaceState,
102    ) -> srbn::SrbnResult<srbn::Realization<WorkspaceState>> {
103        // The proposed value is already a re-read snapshot; realization is
104        // the identity here and deliberately unmeasured.
105        Ok(srbn::Realization::unmeasured(proposed))
106    }
107}
108
109fn hex_digest(hasher: Sha256) -> String {
110    hasher
111        .finalize()
112        .iter()
113        .map(|b| format!("{b:02x}"))
114        .collect()
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn snapshot_reads_what_is_actually_on_disk() {
123        let dir = std::env::temp_dir().join(format!("perspt_realize_{}", uuid::Uuid::new_v4()));
124        std::fs::create_dir_all(&dir).unwrap();
125        std::fs::write(dir.join("a.rs"), "fn a() {}").unwrap();
126
127        let state =
128            snapshot_workspace(&dir, &["a.rs".to_string(), "missing.rs".to_string()]).unwrap();
129        assert!(state.files["a.rs"].is_some());
130        assert!(state.files["missing.rs"].is_none());
131
132        // An adversarial model claiming an edit changes nothing: the re-read
133        // snapshot is identical until the file actually changes (Gate V).
134        let again =
135            snapshot_workspace(&dir, &["a.rs".to_string(), "missing.rs".to_string()]).unwrap();
136        assert_eq!(state.root_hash(), again.root_hash());
137
138        std::fs::write(dir.join("a.rs"), "fn a() { changed() }").unwrap();
139        let changed = snapshot_workspace(&dir, &["a.rs".to_string()]).unwrap();
140        assert_ne!(state.files["a.rs"], changed.files["a.rs"]);
141        let _ = std::fs::remove_dir_all(&dir);
142    }
143
144    #[test]
145    fn root_hash_is_order_independent_by_construction() {
146        let mut a = WorkspaceState::default();
147        a.files.insert("x".into(), Some("h1".into()));
148        a.files.insert("y".into(), None);
149        let mut b = WorkspaceState::default();
150        b.files.insert("y".into(), None);
151        b.files.insert("x".into(), Some("h1".into()));
152        assert_eq!(a.root_hash(), b.root_hash());
153    }
154
155    #[test]
156    fn kernel_conformance_loop_runs_over_snapshots() {
157        // The restore-best oracle: a barrier over snapshot handles, driven
158        // through srbn::stabilize_realized with the sync adapter.
159        let params = perspt_sdk::StabilityParameters::measured(0.5, 0.0);
160        let initial = WorkspaceState::default();
161        let barrier = |state: &WorkspaceState| {
162            let v = state.files.len() as f64;
163            perspt_sdk::AgentBarrierResult::new(state.files.is_empty(), v * v)
164        };
165        let updater = |mut state: WorkspaceState,
166                       _b: &srbn::BarrierResult<
167            perspt_sdk::CorrectionDirectionSet,
168            perspt_sdk::Evidence,
169        >| {
170            state.files.pop_last();
171            Ok(state)
172        };
173        let mut start = initial;
174        for i in 0..3 {
175            start.files.insert(format!("f{i}"), None);
176        }
177        let result =
178            perspt_sdk::stabilize_realized(start, barrier, updater, SnapshotRealizer, &params, 8)
179                .unwrap();
180        assert_eq!(result.status, srbn::Status::Stable);
181        assert!(result.max_realizability_residual().is_none(), "unmeasured");
182    }
183}