Skip to main content

perspt_sdk/
domain.rs

1//! Domain-package contract (PSP-8 System 1 / System 5).
2//!
3//! The SDK owns the domain-neutral control plane; a domain package provides
4//! task-specific semantics. This module defines the Phase-0/1 surface of the
5//! [`AgentDomainPackage`] trait — the part exercised by the energy/gate core and
6//! implemented by `perspt-coding` as the first consumer. Later phases extend the
7//! trait with exploration plans, verifier suites, hard gates, context packages,
8//! capability policies, and graph hints; those types are intentionally omitted
9//! here until their owning phases land, so every contract ships with a real
10//! consumer rather than ahead of one.
11
12use serde::{Deserialize, Serialize};
13
14use crate::energy::EnergyModel;
15use crate::residual::{CorrectionDirection, ResidualClass, ResidualEvent};
16
17/// Stable identifier for a domain (e.g. `"coding"`, `"research"`).
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct DomainId(pub String);
20
21impl DomainId {
22    pub fn new(id: impl Into<String>) -> Self {
23        Self(id.into())
24    }
25}
26
27impl std::fmt::Display for DomainId {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.write_str(&self.0)
30    }
31}
32
33/// A minimal read-only snapshot of the workspace used for domain detection.
34/// Richer snapshots (state witnesses, ledger head, capability set) arrive with
35/// the scheduler and capability phases.
36#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
37pub struct WorkspaceSnapshot {
38    pub root: String,
39    /// Workspace-relative paths discovered by a read-only scan.
40    pub files: Vec<String>,
41}
42
43impl WorkspaceSnapshot {
44    pub fn new(root: impl Into<String>, files: Vec<String>) -> Self {
45        Self {
46            root: root.into(),
47            files,
48        }
49    }
50
51    /// Whether any discovered file ends with the given suffix.
52    pub fn has_file_named(&self, name: &str) -> bool {
53        self.files
54            .iter()
55            .any(|f| f == name || f.ends_with(&format!("/{name}")))
56    }
57}
58
59/// Evidence that a domain package activates for a workspace.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct DomainDetection {
62    pub domain: DomainId,
63    pub activated: bool,
64    /// Confidence in `[0, 1]`.
65    pub confidence: f64,
66    pub evidence: Vec<String>,
67}
68
69/// The scope a domain operates over (e.g. a node, a package, a subtree).
70#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
71pub struct DomainScope {
72    pub label: String,
73    pub paths: Vec<String>,
74}
75
76/// The residual schema a domain declares: the classes it can emit and the
77/// allowed sensors per class. Normalization, weights, and rollup mapping live in
78/// the [`EnergyModel`].
79#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
80pub struct ResidualSchema {
81    pub classes: Vec<ResidualClass>,
82}
83
84impl ResidualSchema {
85    pub fn new(classes: Vec<ResidualClass>) -> Self {
86        Self { classes }
87    }
88
89    pub fn allows(&self, class: ResidualClass) -> bool {
90        self.classes.contains(&class)
91    }
92}
93
94/// The Phase-0/1 domain-package contract.
95///
96/// A domain package maps verifier evidence into residuals, declares the residual
97/// schema and energy model that gate acceptance, and derives correction
98/// directions from dominant residuals. `perspt-coding` is the first consumer.
99pub trait AgentDomainPackage: Send + Sync {
100    /// Stable domain identifier.
101    fn domain_id(&self) -> DomainId;
102
103    /// Detect whether this domain applies to a workspace.
104    fn detect(&self, workspace: &WorkspaceSnapshot) -> DomainDetection;
105
106    /// The residual classes this domain can emit for a scope.
107    fn residual_schema(&self, scope: &DomainScope) -> ResidualSchema;
108
109    /// The energy model (weights, `rho_gate`, tolerance, budget) for a scope.
110    fn energy_model(&self, scope: &DomainScope) -> EnergyModel;
111
112    /// Derive correction directions from dominant residuals. Returning an empty
113    /// vector for residuals that genuinely have no direction is honest; the
114    /// runtime then escalates rather than issuing an undirected retry.
115    fn correction_directions(&self, residuals: &[ResidualEvent]) -> Vec<CorrectionDirection>;
116}
117
118/// A registry of domain packages and the routing logic that selects one
119/// (PSP-8 System 1 / Phase 10). Domain selection is a routing decision; it does
120/// not bypass the SDK's residual, scheduler, capability, ledger, or dashboard
121/// contracts — every registered package implements the same trait.
122#[derive(Default)]
123pub struct DomainRegistry {
124    packages: Vec<Box<dyn AgentDomainPackage>>,
125}
126
127impl std::fmt::Debug for DomainRegistry {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("DomainRegistry")
130            .field(
131                "domains",
132                &self
133                    .packages
134                    .iter()
135                    .map(|p| p.domain_id())
136                    .collect::<Vec<_>>(),
137            )
138            .finish()
139    }
140}
141
142impl DomainRegistry {
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Register a domain package. The SDK admits any number of domains without
148    /// forking the control plane.
149    pub fn register(&mut self, package: Box<dyn AgentDomainPackage>) {
150        self.packages.push(package);
151    }
152
153    pub fn len(&self) -> usize {
154        self.packages.len()
155    }
156
157    pub fn is_empty(&self) -> bool {
158        self.packages.is_empty()
159    }
160
161    /// All registered domain ids.
162    pub fn domain_ids(&self) -> Vec<DomainId> {
163        self.packages.iter().map(|p| p.domain_id()).collect()
164    }
165
166    /// Look up a package by explicit domain id.
167    pub fn by_id(&self, id: &DomainId) -> Option<&dyn AgentDomainPackage> {
168        self.packages
169            .iter()
170            .find(|p| &p.domain_id() == id)
171            .map(|p| p.as_ref())
172    }
173
174    /// The activated package with the highest detection confidence.
175    pub fn detect_best(&self, workspace: &WorkspaceSnapshot) -> Option<&dyn AgentDomainPackage> {
176        self.packages
177            .iter()
178            .map(|p| (p, p.detect(workspace)))
179            .filter(|(_, d)| d.activated)
180            .max_by(|(_, a), (_, b)| {
181                a.confidence
182                    .partial_cmp(&b.confidence)
183                    .unwrap_or(std::cmp::Ordering::Equal)
184            })
185            .map(|(p, _)| p.as_ref())
186    }
187
188    /// Select a domain: an explicit id wins; otherwise detect the best match.
189    pub fn select(
190        &self,
191        explicit: Option<&DomainId>,
192        workspace: &WorkspaceSnapshot,
193    ) -> Option<&dyn AgentDomainPackage> {
194        match explicit {
195            Some(id) => self.by_id(id),
196            None => self.detect_best(workspace),
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::energy::EnergyModel;
205
206    struct StubDomain {
207        id: &'static str,
208        marker: &'static str,
209        confidence: f64,
210    }
211
212    impl AgentDomainPackage for StubDomain {
213        fn domain_id(&self) -> DomainId {
214            DomainId::new(self.id)
215        }
216        fn detect(&self, ws: &WorkspaceSnapshot) -> DomainDetection {
217            let activated = ws.has_file_named(self.marker);
218            DomainDetection {
219                domain: self.domain_id(),
220                activated,
221                confidence: if activated { self.confidence } else { 0.0 },
222                evidence: vec![],
223            }
224        }
225        fn residual_schema(&self, _: &DomainScope) -> ResidualSchema {
226            ResidualSchema::new(vec![])
227        }
228        fn energy_model(&self, _: &DomainScope) -> EnergyModel {
229            EnergyModel::new(self.id, 0.5)
230        }
231        fn correction_directions(&self, _: &[ResidualEvent]) -> Vec<CorrectionDirection> {
232            vec![]
233        }
234    }
235
236    fn registry() -> DomainRegistry {
237        let mut r = DomainRegistry::new();
238        r.register(Box::new(StubDomain {
239            id: "coding",
240            marker: "Cargo.toml",
241            confidence: 0.9,
242        }));
243        r.register(Box::new(StubDomain {
244            id: "research",
245            marker: "refs.bib",
246            confidence: 0.8,
247        }));
248        r
249    }
250
251    #[test]
252    fn explicit_selection_wins() {
253        let r = registry();
254        let ws = WorkspaceSnapshot::new("/r", vec!["Cargo.toml".into(), "refs.bib".into()]);
255        let chosen = r.select(Some(&DomainId::new("research")), &ws).unwrap();
256        assert_eq!(chosen.domain_id(), DomainId::new("research"));
257    }
258
259    #[test]
260    fn detection_selects_best_when_no_explicit() {
261        let r = registry();
262        let ws = WorkspaceSnapshot::new("/r", vec!["refs.bib".into()]);
263        let chosen = r.select(None, &ws).unwrap();
264        assert_eq!(chosen.domain_id(), DomainId::new("research"));
265    }
266
267    #[test]
268    fn registry_admits_multiple_domains() {
269        let r = registry();
270        assert_eq!(r.len(), 2);
271        assert!(r.by_id(&DomainId::new("coding")).is_some());
272        assert!(r.by_id(&DomainId::new("missing")).is_none());
273    }
274}