Skip to main content

perspt_sdk/
exploration.rs

1//! Read-only exploration (PSP-8 System 3).
2//!
3//! Before durable mutations, the agent needs a cheap, bounded, read-only
4//! understanding of the repository. Exploration runs with read/search/list/LSP
5//! capabilities only; if it discovers that a mutation may be required it emits a
6//! residual, graph hint, or capability request rather than performing the
7//! mutation. Exploration evidence is advisory unless backed by deterministic
8//! tool output, and a cheap model summarizing it never becomes a correctness
9//! barrier.
10
11use serde::{Deserialize, Serialize};
12
13use crate::capability::{ActorId, Capability, EffectKind};
14use crate::routing::ModelRoute;
15
16/// Independent budgets for exploration (PSP-8 System 3).
17#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
18pub struct ExplorationBudget {
19    pub max_files: u32,
20    pub max_tokens: u64,
21    pub max_tool_calls: u32,
22    pub max_wall_clock_secs: u64,
23    pub max_parallel_workers: u32,
24}
25
26impl Default for ExplorationBudget {
27    fn default() -> Self {
28        Self {
29            max_files: 500,
30            max_tokens: 50_000,
31            max_tool_calls: 200,
32            max_wall_clock_secs: 120,
33            max_parallel_workers: 8,
34        }
35    }
36}
37
38/// Running usage measured against an [`ExplorationBudget`].
39#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
40pub struct ExplorationUsage {
41    pub files: u32,
42    pub tokens: u64,
43    pub tool_calls: u32,
44    pub wall_clock_secs: u64,
45}
46
47impl ExplorationBudget {
48    /// Whether current usage is still within budget.
49    pub fn admits(&self, usage: &ExplorationUsage) -> bool {
50        usage.files <= self.max_files
51            && usage.tokens <= self.max_tokens
52            && usage.tool_calls <= self.max_tool_calls
53            && usage.wall_clock_secs <= self.max_wall_clock_secs
54    }
55}
56
57/// A structured map of the repository (PSP-8 System 3).
58#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
59pub struct ProjectMap {
60    pub languages: Vec<String>,
61    pub package_roots: Vec<String>,
62    pub build_systems: Vec<String>,
63    pub entry_points: Vec<String>,
64    pub risk_hotspots: Vec<String>,
65}
66
67/// A seed node / edge hint for the planner (PSP-8 System 3).
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct GraphHint {
70    pub goal: String,
71    pub suggested_outputs: Vec<String>,
72    pub rationale: String,
73}
74
75/// A read-only exploration report (PSP-8 `ExplorationReport`).
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct ExplorationReport {
78    pub report_id: String,
79    pub model_route: Option<ModelRoute>,
80    pub project_map: ProjectMap,
81    pub graph_hints: Vec<GraphHint>,
82    pub verifier_recommendations: Vec<String>,
83    /// Content hashes of the inputs this report observed, for provenance.
84    pub input_witnesses: Vec<String>,
85    /// Whether the report is backed by deterministic tool output (not just a
86    /// model summary). Advisory-only reports cannot act as a correctness barrier.
87    pub deterministically_backed: bool,
88}
89
90impl ExplorationReport {
91    pub fn new(project_map: ProjectMap) -> Self {
92        Self {
93            report_id: uuid::Uuid::new_v4().to_string(),
94            model_route: None,
95            project_map,
96            graph_hints: Vec::new(),
97            verifier_recommendations: Vec::new(),
98            input_witnesses: Vec::new(),
99            deterministically_backed: false,
100        }
101    }
102
103    /// Whether this report may be relied upon as a correctness barrier. A model
104    /// summary alone may not; only deterministically-backed evidence may.
105    pub fn is_barrier_eligible(&self) -> bool {
106        self.deterministically_backed
107    }
108}
109
110/// Build a read-only exploration capability for an actor. Exploration SHALL NOT
111/// write files, mutate dependencies, change graph policy, or apply patches.
112pub fn exploration_capability(actor: ActorId) -> Capability {
113    Capability::new(
114        actor,
115        vec![
116            EffectKind::ReadFile,
117            EffectKind::Search,
118            EffectKind::List,
119            EffectKind::LspQuery,
120            EffectKind::GitRead,
121        ],
122    )
123    .with_paths(vec!["*"])
124}
125
126/// Whether a capability is strictly read-only (the exploration invariant).
127pub fn is_read_only_capability(cap: &Capability) -> bool {
128    cap.effects.iter().all(|e| e.is_read_only())
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn exploration_capability_is_read_only() {
137        let cap = exploration_capability(ActorId::new("explorer"));
138        assert!(is_read_only_capability(&cap));
139        assert!(!cap.grants(EffectKind::WriteArtifact));
140        assert!(!cap.grants(EffectKind::ApplyPatch));
141        assert!(!cap.grants(EffectKind::MutateDependencies));
142    }
143
144    #[test]
145    fn budget_admits_within_limits_and_rejects_overflow() {
146        let budget = ExplorationBudget::default();
147        let ok = ExplorationUsage {
148            files: 10,
149            tokens: 1000,
150            tool_calls: 5,
151            wall_clock_secs: 10,
152        };
153        assert!(budget.admits(&ok));
154        let over = ExplorationUsage { files: 9999, ..ok };
155        assert!(!budget.admits(&over));
156    }
157
158    #[test]
159    fn model_summary_alone_is_not_a_barrier() {
160        let report = ExplorationReport::new(ProjectMap::default());
161        assert!(!report.is_barrier_eligible());
162    }
163
164    #[test]
165    fn deterministically_backed_report_is_barrier_eligible() {
166        let mut report = ExplorationReport::new(ProjectMap::default());
167        report.deterministically_backed = true;
168        assert!(report.is_barrier_eligible());
169    }
170}