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/// A bounded adaptive verification cadence (PSP-9 system 6).
95///
96/// Three boundaries are normative: the effect boundary runs at every
97/// mutation and is never debounced; the interleaved evidence boundary runs
98/// after at most `max_mutations_between_checks` admitted mutations (`H`);
99/// the gate boundary runs the complete suite before every gate decision.
100/// Runtime tuning may optimize *within* these bounds; it can never create an
101/// unbounded unchecked interval (Paper II Theorem 3).
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct VerificationCadence {
104    /// `H`: admitted mutations before touched-scope diagnostics must run.
105    pub max_mutations_between_checks: u32,
106    /// `H` for high/critical-risk effects; the PSP fixes this at 1.
107    pub high_risk_mutations_between_checks: u32,
108}
109
110impl VerificationCadence {
111    /// Validate the bounds: both must be at least 1 and finite by type.
112    pub fn validate(&self) -> Result<(), crate::error::SdkError> {
113        if self.max_mutations_between_checks == 0 || self.high_risk_mutations_between_checks == 0 {
114            return Err(crate::error::SdkError::Domain(
115                "verification cadence bounds must be at least 1".into(),
116            ));
117        }
118        Ok(())
119    }
120}
121
122impl Default for VerificationCadence {
123    fn default() -> Self {
124        Self {
125            max_mutations_between_checks: 4,
126            high_risk_mutations_between_checks: 1,
127        }
128    }
129}
130
131/// The hard predicates a candidate must pass for `HardPass` (PSP-9
132/// system 10). A model validator can be an *additional* necessary condition,
133/// never a sufficient one.
134#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
135pub struct HardGatePolicy {
136    /// Verifier stage names that must all pass (e.g. "build", "test").
137    pub required_stages: Vec<String>,
138}
139
140/// The verifier suite a domain declares for a scope (PSP-9 system 6).
141#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
142pub struct VerifierSuiteSpec {
143    /// Ordered stage names the runtime binds to plugin commands.
144    pub stages: Vec<String>,
145    pub cadence: VerificationCadence,
146}
147
148/// One versioned operational safety channel `h_j` (PSP-9 system 12).
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct BarrierChannel {
151    pub name: String,
152    pub policy_version: String,
153}
154
155/// A registered operational safety barrier.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct BarrierSpec {
158    pub channels: Vec<BarrierChannel>,
159}
160
161/// A domain's barrier position — required even when it is `NotClaimed`, so
162/// no domain inherits a vacuous barrier default (PSP-9).
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum SafetyBarrierDisposition {
166    /// The domain declares no barrier; autonomous chance-safety certification
167    /// is unavailable and the reason is recorded.
168    NotClaimed { reason: String },
169    /// The domain registers versioned channels (system 12).
170    Registered(BarrierSpec),
171}
172
173/// The domain-package contract.
174///
175/// A domain package maps verifier evidence into residuals, declares the
176/// residual schema and energy model that gate acceptance, derives correction
177/// directions from dominant residuals, and — since PSP-9 — declares its
178/// governed tools, hard-gate policy, verifier suite, and barrier position.
179pub trait AgentDomainPackage: Send + Sync {
180    /// Stable domain identifier.
181    fn domain_id(&self) -> DomainId;
182
183    /// Detect whether this domain applies to a workspace.
184    fn detect(&self, workspace: &WorkspaceSnapshot) -> DomainDetection;
185
186    /// The residual classes this domain can emit for a scope.
187    fn residual_schema(&self, scope: &DomainScope) -> ResidualSchema;
188
189    /// The energy model (weights, `rho_gate`, tolerance, budget) for a scope.
190    fn energy_model(&self, scope: &DomainScope) -> EnergyModel;
191
192    /// Derive correction directions from dominant residuals. Returning an empty
193    /// vector for residuals that genuinely have no direction is honest; the
194    /// runtime then escalates rather than issuing an undirected retry.
195    fn correction_directions(&self, residuals: &[ResidualEvent]) -> Vec<CorrectionDirection>;
196
197    // --- PSP-9 agent contract. These are required so a domain cannot
198    // silently claim agent support while exposing no governed tools or
199    // hard-gate policy. ---
200
201    /// The domain's tool entries for a scope (joined with the base catalog).
202    fn tool_entries(&self, scope: &DomainScope) -> Vec<crate::toolset::ToolEntry>;
203
204    /// The hard predicates required for `HardPass`.
205    fn hard_gate_policy(&self, scope: &DomainScope) -> HardGatePolicy;
206
207    /// The verifier suite and its bounded cadence.
208    fn verifier_suite(&self, scope: &DomainScope) -> VerifierSuiteSpec;
209
210    /// The barrier position — `NotClaimed { reason }` is a legal, honest
211    /// answer; a vacuous default is not.
212    fn safety_barrier(&self, scope: &DomainScope) -> SafetyBarrierDisposition;
213
214    /// The optional analytic-path extension (Paper I §12). Coding returns
215    /// `None` until a true proxy geometry exists.
216    fn proxy_geometry(&self, _scope: &DomainScope) -> Option<&dyn crate::stability::ProxyGeometry> {
217        None
218    }
219
220    // --- PSP-10 search and prompt contract. Defaulted so existing domains
221    // compile unchanged until they opt in. ---
222
223    /// The domain's compiled prompt section library.
224    fn prompt_library(&self) -> &dyn DomainPromptLibrary {
225        &EMPTY_PROMPT_LIBRARY
226    }
227
228    /// Deliberately diverse branch strategies for a failed attempt
229    /// (PSP-10 system 20). A strategy is a search prior, never authority.
230    fn search_strategies(
231        &self,
232        _context: &crate::search::SearchContext,
233    ) -> Vec<crate::search::SearchStrategy> {
234        Vec::new()
235    }
236
237    /// Fold verifier residuals into one coherent typed correction packet
238    /// (PSP-10 system 26). `None` means the domain has no direction — the
239    /// caller expands or escalates, never blind-retries.
240    fn correction_packet(
241        &self,
242        _residuals: &[crate::residual::ResidualEvent],
243    ) -> Option<crate::residual::CorrectionPacket> {
244        None
245    }
246
247    /// Compare branch candidates against the accepted root under the
248    /// domain's measurement semantics (advisory; Proposition 5's
249    /// deterministic rule selects).
250    fn compare_branch_measurements(
251        &self,
252        _root: &crate::search::DomainMeasurement,
253        _candidates: &[crate::search::BranchMeasurement],
254    ) -> crate::search::BranchSelection {
255        crate::search::BranchSelection::NoneEligible
256    }
257}
258
259/// A domain's compiled prompt sections, keyed by stage directory name.
260/// Object-safe; the empty default serves domains without a section library.
261pub trait DomainPromptLibrary: Send + Sync {
262    /// The base section templates for one stage, in declared order.
263    fn stage_templates(&self, stage: &str) -> Vec<crate::prompt::SectionTemplate> {
264        let _ = stage;
265        Vec::new()
266    }
267}
268
269/// The shared empty library behind the trait default.
270#[derive(Debug)]
271pub struct EmptyDomainPromptLibrary;
272
273impl DomainPromptLibrary for EmptyDomainPromptLibrary {}
274
275static EMPTY_PROMPT_LIBRARY: EmptyDomainPromptLibrary = EmptyDomainPromptLibrary;
276
277/// A registry of domain packages and the routing logic that selects one
278/// (PSP-8 System 1 / Phase 10). Domain selection is a routing decision; it does
279/// not bypass the SDK's residual, scheduler, capability, ledger, or dashboard
280/// contracts — every registered package implements the same trait.
281#[derive(Default)]
282pub struct DomainRegistry {
283    packages: Vec<std::sync::Arc<dyn AgentDomainPackage>>,
284}
285
286impl std::fmt::Debug for DomainRegistry {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        f.debug_struct("DomainRegistry")
289            .field(
290                "domains",
291                &self
292                    .packages
293                    .iter()
294                    .map(|p| p.domain_id())
295                    .collect::<Vec<_>>(),
296            )
297            .finish()
298    }
299}
300
301impl DomainRegistry {
302    pub fn new() -> Self {
303        Self::default()
304    }
305
306    /// Register a domain package. The SDK admits any number of domains without
307    /// forking the control plane.
308    pub fn register(&mut self, package: std::sync::Arc<dyn AgentDomainPackage>) {
309        self.packages.push(package);
310    }
311
312    pub fn len(&self) -> usize {
313        self.packages.len()
314    }
315
316    pub fn is_empty(&self) -> bool {
317        self.packages.is_empty()
318    }
319
320    /// All registered domain ids.
321    pub fn domain_ids(&self) -> Vec<DomainId> {
322        self.packages.iter().map(|p| p.domain_id()).collect()
323    }
324
325    /// Look up a package by explicit domain id.
326    pub fn by_id(&self, id: &DomainId) -> Option<std::sync::Arc<dyn AgentDomainPackage>> {
327        self.packages.iter().find(|p| &p.domain_id() == id).cloned()
328    }
329
330    /// The activated package with the highest detection confidence.
331    pub fn detect_best(
332        &self,
333        workspace: &WorkspaceSnapshot,
334    ) -> Option<std::sync::Arc<dyn AgentDomainPackage>> {
335        self.packages
336            .iter()
337            .map(|p| (p, p.detect(workspace)))
338            .filter(|(_, d)| d.activated)
339            .max_by(|(_, a), (_, b)| {
340                a.confidence
341                    .partial_cmp(&b.confidence)
342                    .unwrap_or(std::cmp::Ordering::Equal)
343            })
344            .map(|(p, _)| p.clone())
345    }
346
347    /// Select a domain: an explicit id wins; otherwise detect the best match.
348    pub fn select(
349        &self,
350        explicit: Option<&DomainId>,
351        workspace: &WorkspaceSnapshot,
352    ) -> Option<std::sync::Arc<dyn AgentDomainPackage>> {
353        match explicit {
354            Some(id) => self.by_id(id),
355            None => self.detect_best(workspace),
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::energy::EnergyModel;
364
365    struct StubDomain {
366        id: &'static str,
367        marker: &'static str,
368        confidence: f64,
369    }
370
371    impl AgentDomainPackage for StubDomain {
372        fn domain_id(&self) -> DomainId {
373            DomainId::new(self.id)
374        }
375        fn detect(&self, ws: &WorkspaceSnapshot) -> DomainDetection {
376            let activated = ws.has_file_named(self.marker);
377            DomainDetection {
378                domain: self.domain_id(),
379                activated,
380                confidence: if activated { self.confidence } else { 0.0 },
381                evidence: vec![],
382            }
383        }
384        fn residual_schema(&self, _: &DomainScope) -> ResidualSchema {
385            ResidualSchema::new(vec![])
386        }
387        fn energy_model(&self, _: &DomainScope) -> EnergyModel {
388            EnergyModel::new(self.id, 0.5)
389        }
390        fn correction_directions(&self, _: &[ResidualEvent]) -> Vec<CorrectionDirection> {
391            vec![]
392        }
393        fn tool_entries(&self, _: &DomainScope) -> Vec<crate::toolset::ToolEntry> {
394            vec![]
395        }
396        fn hard_gate_policy(&self, _: &DomainScope) -> HardGatePolicy {
397            HardGatePolicy::default()
398        }
399        fn verifier_suite(&self, _: &DomainScope) -> VerifierSuiteSpec {
400            VerifierSuiteSpec::default()
401        }
402        fn safety_barrier(&self, _: &DomainScope) -> SafetyBarrierDisposition {
403            SafetyBarrierDisposition::NotClaimed {
404                reason: "stub".into(),
405            }
406        }
407    }
408
409    fn registry() -> DomainRegistry {
410        let mut r = DomainRegistry::new();
411        r.register(std::sync::Arc::new(StubDomain {
412            id: "coding",
413            marker: "Cargo.toml",
414            confidence: 0.9,
415        }));
416        r.register(std::sync::Arc::new(StubDomain {
417            id: "research",
418            marker: "refs.bib",
419            confidence: 0.8,
420        }));
421        r
422    }
423
424    #[test]
425    fn explicit_selection_wins() {
426        let r = registry();
427        let ws = WorkspaceSnapshot::new("/r", vec!["Cargo.toml".into(), "refs.bib".into()]);
428        let chosen = r.select(Some(&DomainId::new("research")), &ws).unwrap();
429        assert_eq!(chosen.domain_id(), DomainId::new("research"));
430    }
431
432    #[test]
433    fn detection_selects_best_when_no_explicit() {
434        let r = registry();
435        let ws = WorkspaceSnapshot::new("/r", vec!["refs.bib".into()]);
436        let chosen = r.select(None, &ws).unwrap();
437        assert_eq!(chosen.domain_id(), DomainId::new("research"));
438    }
439
440    #[test]
441    fn registry_admits_multiple_domains() {
442        let r = registry();
443        assert_eq!(r.len(), 2);
444        assert!(r.by_id(&DomainId::new("coding")).is_some());
445        assert!(r.by_id(&DomainId::new("missing")).is_none());
446    }
447}