perspt-sdk 0.6.6

Domain-neutral SRBN agent SDK for Perspt: residual energy, measured acceptance gate, spectral diagnostics, and stabilization-kernel adapter (PSP-8)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! Domain-package contract (PSP-8 System 1 / System 5).
//!
//! The SDK owns the domain-neutral control plane; a domain package provides
//! task-specific semantics. This module defines the Phase-0/1 surface of the
//! [`AgentDomainPackage`] trait — the part exercised by the energy/gate core and
//! implemented by `perspt-coding` as the first consumer. Later phases extend the
//! trait with exploration plans, verifier suites, hard gates, context packages,
//! capability policies, and graph hints; those types are intentionally omitted
//! here until their owning phases land, so every contract ships with a real
//! consumer rather than ahead of one.

use serde::{Deserialize, Serialize};

use crate::energy::EnergyModel;
use crate::residual::{CorrectionDirection, ResidualClass, ResidualEvent};

/// Stable identifier for a domain (e.g. `"coding"`, `"research"`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DomainId(pub String);

impl DomainId {
    pub fn new(id: impl Into<String>) -> Self {
        Self(id.into())
    }
}

impl std::fmt::Display for DomainId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// A minimal read-only snapshot of the workspace used for domain detection.
/// Richer snapshots (state witnesses, ledger head, capability set) arrive with
/// the scheduler and capability phases.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceSnapshot {
    pub root: String,
    /// Workspace-relative paths discovered by a read-only scan.
    pub files: Vec<String>,
}

impl WorkspaceSnapshot {
    pub fn new(root: impl Into<String>, files: Vec<String>) -> Self {
        Self {
            root: root.into(),
            files,
        }
    }

    /// Whether any discovered file ends with the given suffix.
    pub fn has_file_named(&self, name: &str) -> bool {
        self.files
            .iter()
            .any(|f| f == name || f.ends_with(&format!("/{name}")))
    }
}

/// Evidence that a domain package activates for a workspace.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DomainDetection {
    pub domain: DomainId,
    pub activated: bool,
    /// Confidence in `[0, 1]`.
    pub confidence: f64,
    pub evidence: Vec<String>,
}

/// The scope a domain operates over (e.g. a node, a package, a subtree).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DomainScope {
    pub label: String,
    pub paths: Vec<String>,
}

/// The residual schema a domain declares: the classes it can emit and the
/// allowed sensors per class. Normalization, weights, and rollup mapping live in
/// the [`EnergyModel`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ResidualSchema {
    pub classes: Vec<ResidualClass>,
}

impl ResidualSchema {
    pub fn new(classes: Vec<ResidualClass>) -> Self {
        Self { classes }
    }

    pub fn allows(&self, class: ResidualClass) -> bool {
        self.classes.contains(&class)
    }
}

/// A bounded adaptive verification cadence (PSP-9 system 6).
///
/// Three boundaries are normative: the effect boundary runs at every
/// mutation and is never debounced; the interleaved evidence boundary runs
/// after at most `max_mutations_between_checks` admitted mutations (`H`);
/// the gate boundary runs the complete suite before every gate decision.
/// Runtime tuning may optimize *within* these bounds; it can never create an
/// unbounded unchecked interval (Paper II Theorem 3).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationCadence {
    /// `H`: admitted mutations before touched-scope diagnostics must run.
    pub max_mutations_between_checks: u32,
    /// `H` for high/critical-risk effects; the PSP fixes this at 1.
    pub high_risk_mutations_between_checks: u32,
}

impl VerificationCadence {
    /// Validate the bounds: both must be at least 1 and finite by type.
    pub fn validate(&self) -> Result<(), crate::error::SdkError> {
        if self.max_mutations_between_checks == 0 || self.high_risk_mutations_between_checks == 0 {
            return Err(crate::error::SdkError::Domain(
                "verification cadence bounds must be at least 1".into(),
            ));
        }
        Ok(())
    }
}

impl Default for VerificationCadence {
    fn default() -> Self {
        Self {
            max_mutations_between_checks: 4,
            high_risk_mutations_between_checks: 1,
        }
    }
}

/// The hard predicates a candidate must pass for `HardPass` (PSP-9
/// system 10). A model validator can be an *additional* necessary condition,
/// never a sufficient one.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HardGatePolicy {
    /// Verifier stage names that must all pass (e.g. "build", "test").
    pub required_stages: Vec<String>,
}

/// The verifier suite a domain declares for a scope (PSP-9 system 6).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifierSuiteSpec {
    /// Ordered stage names the runtime binds to plugin commands.
    pub stages: Vec<String>,
    pub cadence: VerificationCadence,
}

/// One versioned operational safety channel `h_j` (PSP-9 system 12).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BarrierChannel {
    pub name: String,
    pub policy_version: String,
}

/// A registered operational safety barrier.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BarrierSpec {
    pub channels: Vec<BarrierChannel>,
}

/// A domain's barrier position — required even when it is `NotClaimed`, so
/// no domain inherits a vacuous barrier default (PSP-9).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SafetyBarrierDisposition {
    /// The domain declares no barrier; autonomous chance-safety certification
    /// is unavailable and the reason is recorded.
    NotClaimed { reason: String },
    /// The domain registers versioned channels (system 12).
    Registered(BarrierSpec),
}

/// The domain-package contract.
///
/// A domain package maps verifier evidence into residuals, declares the
/// residual schema and energy model that gate acceptance, derives correction
/// directions from dominant residuals, and — since PSP-9 — declares its
/// governed tools, hard-gate policy, verifier suite, and barrier position.
pub trait AgentDomainPackage: Send + Sync {
    /// Stable domain identifier.
    fn domain_id(&self) -> DomainId;

    /// Detect whether this domain applies to a workspace.
    fn detect(&self, workspace: &WorkspaceSnapshot) -> DomainDetection;

    /// The residual classes this domain can emit for a scope.
    fn residual_schema(&self, scope: &DomainScope) -> ResidualSchema;

    /// The energy model (weights, `rho_gate`, tolerance, budget) for a scope.
    fn energy_model(&self, scope: &DomainScope) -> EnergyModel;

    /// Derive correction directions from dominant residuals. Returning an empty
    /// vector for residuals that genuinely have no direction is honest; the
    /// runtime then escalates rather than issuing an undirected retry.
    fn correction_directions(&self, residuals: &[ResidualEvent]) -> Vec<CorrectionDirection>;

    // --- PSP-9 agent contract. These are required so a domain cannot
    // silently claim agent support while exposing no governed tools or
    // hard-gate policy. ---

    /// The domain's tool entries for a scope (joined with the base catalog).
    fn tool_entries(&self, scope: &DomainScope) -> Vec<crate::toolset::ToolEntry>;

    /// The hard predicates required for `HardPass`.
    fn hard_gate_policy(&self, scope: &DomainScope) -> HardGatePolicy;

    /// The verifier suite and its bounded cadence.
    fn verifier_suite(&self, scope: &DomainScope) -> VerifierSuiteSpec;

    /// The barrier position — `NotClaimed { reason }` is a legal, honest
    /// answer; a vacuous default is not.
    fn safety_barrier(&self, scope: &DomainScope) -> SafetyBarrierDisposition;

    /// The optional analytic-path extension (Paper I §12). Coding returns
    /// `None` until a true proxy geometry exists.
    fn proxy_geometry(&self, _scope: &DomainScope) -> Option<&dyn crate::stability::ProxyGeometry> {
        None
    }

    // --- PSP-10 search and prompt contract. Defaulted so existing domains
    // compile unchanged until they opt in. ---

    /// The domain's compiled prompt section library.
    fn prompt_library(&self) -> &dyn DomainPromptLibrary {
        &EMPTY_PROMPT_LIBRARY
    }

    /// Deliberately diverse branch strategies for a failed attempt
    /// (PSP-10 system 20). A strategy is a search prior, never authority.
    fn search_strategies(
        &self,
        _context: &crate::search::SearchContext,
    ) -> Vec<crate::search::SearchStrategy> {
        Vec::new()
    }

    /// Fold verifier residuals into one coherent typed correction packet
    /// (PSP-10 system 26). `None` means the domain has no direction — the
    /// caller expands or escalates, never blind-retries.
    fn correction_packet(
        &self,
        _residuals: &[crate::residual::ResidualEvent],
    ) -> Option<crate::residual::CorrectionPacket> {
        None
    }

    /// Compare branch candidates against the accepted root under the
    /// domain's measurement semantics (advisory; Proposition 5's
    /// deterministic rule selects).
    fn compare_branch_measurements(
        &self,
        _root: &crate::search::DomainMeasurement,
        _candidates: &[crate::search::BranchMeasurement],
    ) -> crate::search::BranchSelection {
        crate::search::BranchSelection::NoneEligible
    }
}

/// A domain's compiled prompt sections, keyed by stage directory name.
/// Object-safe; the empty default serves domains without a section library.
pub trait DomainPromptLibrary: Send + Sync {
    /// The base section templates for one stage, in declared order.
    fn stage_templates(&self, stage: &str) -> Vec<crate::prompt::SectionTemplate> {
        let _ = stage;
        Vec::new()
    }
}

/// The shared empty library behind the trait default.
#[derive(Debug)]
pub struct EmptyDomainPromptLibrary;

impl DomainPromptLibrary for EmptyDomainPromptLibrary {}

static EMPTY_PROMPT_LIBRARY: EmptyDomainPromptLibrary = EmptyDomainPromptLibrary;

/// A registry of domain packages and the routing logic that selects one
/// (PSP-8 System 1 / Phase 10). Domain selection is a routing decision; it does
/// not bypass the SDK's residual, scheduler, capability, ledger, or dashboard
/// contracts — every registered package implements the same trait.
#[derive(Default)]
pub struct DomainRegistry {
    packages: Vec<std::sync::Arc<dyn AgentDomainPackage>>,
}

impl std::fmt::Debug for DomainRegistry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DomainRegistry")
            .field(
                "domains",
                &self
                    .packages
                    .iter()
                    .map(|p| p.domain_id())
                    .collect::<Vec<_>>(),
            )
            .finish()
    }
}

impl DomainRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a domain package. The SDK admits any number of domains without
    /// forking the control plane.
    pub fn register(&mut self, package: std::sync::Arc<dyn AgentDomainPackage>) {
        self.packages.push(package);
    }

    pub fn len(&self) -> usize {
        self.packages.len()
    }

    pub fn is_empty(&self) -> bool {
        self.packages.is_empty()
    }

    /// All registered domain ids.
    pub fn domain_ids(&self) -> Vec<DomainId> {
        self.packages.iter().map(|p| p.domain_id()).collect()
    }

    /// Look up a package by explicit domain id.
    pub fn by_id(&self, id: &DomainId) -> Option<std::sync::Arc<dyn AgentDomainPackage>> {
        self.packages.iter().find(|p| &p.domain_id() == id).cloned()
    }

    /// The activated package with the highest detection confidence.
    pub fn detect_best(
        &self,
        workspace: &WorkspaceSnapshot,
    ) -> Option<std::sync::Arc<dyn AgentDomainPackage>> {
        self.packages
            .iter()
            .map(|p| (p, p.detect(workspace)))
            .filter(|(_, d)| d.activated)
            .max_by(|(_, a), (_, b)| {
                a.confidence
                    .partial_cmp(&b.confidence)
                    .unwrap_or(std::cmp::Ordering::Equal)
            })
            .map(|(p, _)| p.clone())
    }

    /// Select a domain: an explicit id wins; otherwise detect the best match.
    pub fn select(
        &self,
        explicit: Option<&DomainId>,
        workspace: &WorkspaceSnapshot,
    ) -> Option<std::sync::Arc<dyn AgentDomainPackage>> {
        match explicit {
            Some(id) => self.by_id(id),
            None => self.detect_best(workspace),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::energy::EnergyModel;

    struct StubDomain {
        id: &'static str,
        marker: &'static str,
        confidence: f64,
    }

    impl AgentDomainPackage for StubDomain {
        fn domain_id(&self) -> DomainId {
            DomainId::new(self.id)
        }
        fn detect(&self, ws: &WorkspaceSnapshot) -> DomainDetection {
            let activated = ws.has_file_named(self.marker);
            DomainDetection {
                domain: self.domain_id(),
                activated,
                confidence: if activated { self.confidence } else { 0.0 },
                evidence: vec![],
            }
        }
        fn residual_schema(&self, _: &DomainScope) -> ResidualSchema {
            ResidualSchema::new(vec![])
        }
        fn energy_model(&self, _: &DomainScope) -> EnergyModel {
            EnergyModel::new(self.id, 0.5)
        }
        fn correction_directions(&self, _: &[ResidualEvent]) -> Vec<CorrectionDirection> {
            vec![]
        }
        fn tool_entries(&self, _: &DomainScope) -> Vec<crate::toolset::ToolEntry> {
            vec![]
        }
        fn hard_gate_policy(&self, _: &DomainScope) -> HardGatePolicy {
            HardGatePolicy::default()
        }
        fn verifier_suite(&self, _: &DomainScope) -> VerifierSuiteSpec {
            VerifierSuiteSpec::default()
        }
        fn safety_barrier(&self, _: &DomainScope) -> SafetyBarrierDisposition {
            SafetyBarrierDisposition::NotClaimed {
                reason: "stub".into(),
            }
        }
    }

    fn registry() -> DomainRegistry {
        let mut r = DomainRegistry::new();
        r.register(std::sync::Arc::new(StubDomain {
            id: "coding",
            marker: "Cargo.toml",
            confidence: 0.9,
        }));
        r.register(std::sync::Arc::new(StubDomain {
            id: "research",
            marker: "refs.bib",
            confidence: 0.8,
        }));
        r
    }

    #[test]
    fn explicit_selection_wins() {
        let r = registry();
        let ws = WorkspaceSnapshot::new("/r", vec!["Cargo.toml".into(), "refs.bib".into()]);
        let chosen = r.select(Some(&DomainId::new("research")), &ws).unwrap();
        assert_eq!(chosen.domain_id(), DomainId::new("research"));
    }

    #[test]
    fn detection_selects_best_when_no_explicit() {
        let r = registry();
        let ws = WorkspaceSnapshot::new("/r", vec!["refs.bib".into()]);
        let chosen = r.select(None, &ws).unwrap();
        assert_eq!(chosen.domain_id(), DomainId::new("research"));
    }

    #[test]
    fn registry_admits_multiple_domains() {
        let r = registry();
        assert_eq!(r.len(), 2);
        assert!(r.by_id(&DomainId::new("coding")).is_some());
        assert!(r.by_id(&DomainId::new("missing")).is_none());
    }
}