Skip to main content

perspt_coding/
lib.rs

1//! # Perspt Coding Domain Package
2//!
3//! `perspt-coding` is the first domain package built on [`perspt_sdk`] (PSP-8).
4//! It exercises the SDK's Phase-0/1 contracts with a real consumer: it declares
5//! the coding residual schema, supplies the coding [`EnergyModel`] (weights,
6//! `rho_gate`, tolerance, correction budget), and maps dominant residuals into
7//! coding correction directions.
8//!
9//! The coding domain operates on discrete verifier residuals (compiler, LSP,
10//! AST, tests) and exposes no continuous embedding-space coordinate, so its
11//! analytic constants `alpha, beta, delta, L, eta` remain `NotClaimed`; only the
12//! measured discrete gate and the spectral `mu` apply.
13//!
14//! Language adapters (Rust, Python, TypeScript) will grow into full SDK
15//! verifier-suite providers in later phases; this crate currently provides the
16//! domain-level residual schema, energy model, and Rust correction mappers for
17//! the unresolved-import / missing-module cases (PSP-8 Reference Implementation
18//! step 10).
19
20#![forbid(unsafe_code)]
21
22pub mod lang;
23pub mod runtime;
24pub mod symbols;
25
26pub use runtime::{crash_marker, SmokeInvocation};
27pub use symbols::{defined_symbols, expected_symbols};
28
29use perspt_sdk::{
30    AgentDomainPackage, CorrectionDirection, DomainDetection, DomainId, DomainScope,
31    EnergyComponent, EnergyModel, ResidualClass, ResidualEvent, ResidualSchema, ResidualWeight,
32    StabilityClaim, WorkspaceSnapshot,
33};
34
35/// The language an adapter targets, used to specialize correction directions.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum CodingLanguage {
38    Rust,
39    Python,
40    TypeScript,
41}
42
43/// The coding domain package.
44#[derive(Debug, Clone, Default)]
45pub struct CodingDomain;
46
47impl CodingDomain {
48    pub fn new() -> Self {
49        Self
50    }
51
52    /// The residual classes the coding domain can emit.
53    fn classes() -> Vec<ResidualClass> {
54        vec![
55            ResidualClass::Syntax,
56            ResidualClass::Type,
57            ResidualClass::Build,
58            ResidualClass::TestFailure,
59            ResidualClass::Lint,
60            ResidualClass::Format,
61            ResidualClass::Runtime,
62            ResidualClass::Dependency,
63            ResidualClass::Manifest,
64            ResidualClass::ImportGraph,
65            ResidualClass::SymbolMismatch,
66            ResidualClass::InterfaceMismatch,
67            ResidualClass::OwnershipViolation,
68            ResidualClass::Policy,
69            ResidualClass::Regression,
70            ResidualClass::SensorUnavailable,
71            ResidualClass::ToolFailure,
72            ResidualClass::SheafInconsistency,
73        ]
74    }
75}
76
77impl AgentDomainPackage for CodingDomain {
78    fn domain_id(&self) -> DomainId {
79        DomainId::new("coding")
80    }
81
82    fn detect(&self, workspace: &WorkspaceSnapshot) -> DomainDetection {
83        let mut evidence = Vec::new();
84        for marker in ["Cargo.toml", "pyproject.toml", "package.json", "go.mod"] {
85            if workspace.has_file_named(marker) {
86                evidence.push(format!("found {marker}"));
87            }
88        }
89        let activated = !evidence.is_empty();
90        DomainDetection {
91            domain: self.domain_id(),
92            activated,
93            confidence: if activated { 0.95 } else { 0.0 },
94            evidence,
95        }
96    }
97
98    fn residual_schema(&self, _scope: &DomainScope) -> ResidualSchema {
99        ResidualSchema::new(Self::classes())
100    }
101
102    fn energy_model(&self, scope: &DomainScope) -> EnergyModel {
103        use EnergyComponent::*;
104        use ResidualClass::*;
105        // Compiler/type errors weigh heavily (they block everything downstream);
106        // structural and behavioral residuals weigh moderately; degraded
107        // sensors are V_boot. Every class carries an explicit weight — no class
108        // defaults to an implicit weight of 1.
109        let weights = vec![
110            ResidualWeight::new(Syntax, Syn, 4.0).with_hard_threshold(0.0),
111            ResidualWeight::new(Type, Syn, 3.0).with_hard_threshold(0.0),
112            ResidualWeight::new(Build, Syn, 3.0).with_hard_threshold(0.0),
113            ResidualWeight::new(ImportGraph, Str, 2.0),
114            ResidualWeight::new(SymbolMismatch, Str, 2.0),
115            ResidualWeight::new(InterfaceMismatch, Str, 2.5),
116            ResidualWeight::new(OwnershipViolation, Str, 2.0),
117            ResidualWeight::new(Policy, Str, 1.0),
118            ResidualWeight::new(Dependency, Str, 1.5),
119            ResidualWeight::new(Manifest, Str, 1.5),
120            ResidualWeight::new(Lint, Str, 0.5),
121            ResidualWeight::new(Format, Str, 0.25),
122            ResidualWeight::new(TestFailure, Log, 2.0),
123            ResidualWeight::new(Runtime, Log, 2.0),
124            ResidualWeight::new(Regression, Log, 3.0),
125            ResidualWeight::new(SensorUnavailable, Boot, 1.0),
126            ResidualWeight::new(ToolFailure, Boot, 1.0),
127            ResidualWeight::new(SheafInconsistency, Sheaf, 2.0),
128        ];
129
130        let mut model = EnergyModel::new("coding", 0.5).with_correction_budget(4);
131        model.residual_weights = weights;
132        model.energy_tolerance = 0.0;
133        // The coding domain is measured-only: analytic constants are NotClaimed.
134        model.stability_claim = Some(StabilityClaim::not_claimed(format!(
135            "coding scope: {}",
136            scope.label
137        )));
138        model
139    }
140
141    fn correction_directions(&self, residuals: &[ResidualEvent]) -> Vec<CorrectionDirection> {
142        let mut directions = Vec::new();
143        for r in residuals {
144            if let Some(d) = correction_for(r) {
145                directions.push(d);
146            }
147        }
148        directions
149    }
150}
151
152/// Map a single residual to a coding correction direction, or `None` when there
153/// is no honest direction (the runtime then escalates rather than retrying
154/// blindly).
155fn correction_for(residual: &ResidualEvent) -> Option<CorrectionDirection> {
156    match residual.class {
157        ResidualClass::ImportGraph => {
158            // Rust unresolved-import / missing-module direction (PSP-8 ref step 10).
159            let symbol = residual
160                .affected_symbols
161                .first()
162                .map(|s| s.name.clone())
163                .unwrap_or_else(|| "the missing item".to_string());
164            Some(
165                CorrectionDirection::new(
166                    ResidualClass::ImportGraph,
167                    format!(
168                        "resolve the unresolved import for `{symbol}`: add the missing `use` path \
169                         or declare the missing `mod`, do not regenerate unrelated code"
170                    ),
171                )
172                .with_paths(residual.affected_paths.clone())
173                .with_rationale(
174                    "unresolved imports are structural; the fix is an import/module \
175                     declaration, not a behavioral rewrite",
176                ),
177            )
178        }
179        ResidualClass::Type => Some(
180            CorrectionDirection::new(
181                ResidualClass::Type,
182                "reconcile the type mismatch at the reported span; adjust the expression or the \
183                 declared signature, keeping the public interface stable",
184            )
185            .with_paths(residual.affected_paths.clone()),
186        ),
187        ResidualClass::Dependency | ResidualClass::Manifest => Some(
188            CorrectionDirection::new(
189                residual.class,
190                "repair the dependency/manifest: add or pin the missing crate/package and sync \
191                 the lockfile through an approved dependency-mutation effect",
192            )
193            .with_paths(residual.affected_paths.clone()),
194        ),
195        ResidualClass::TestFailure => Some(
196            CorrectionDirection::new(
197                ResidualClass::TestFailure,
198                "address the failing test by fixing the implementation it attributes to; do not \
199                 weaken or delete the assertion",
200            )
201            .with_paths(residual.affected_paths.clone()),
202        ),
203        // No honest direction for degraded sensors or pure tool failures: these
204        // are bootstrap problems the runtime escalates, never a code retry.
205        ResidualClass::SensorUnavailable | ResidualClass::ToolFailure => None,
206        _ => None,
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use perspt_sdk::{score_candidate, IndependenceRoute, ResidualSeverity, SensorRef, SymbolRef};
214
215    fn lsp_import_residual() -> ResidualEvent {
216        let mut r = ResidualEvent::new(
217            "n1",
218            0,
219            ResidualClass::ImportGraph,
220            ResidualSeverity::Error,
221            1.0,
222            SensorRef::new("rust-analyzer", IndependenceRoute::Lsp),
223        )
224        .unwrap();
225        r.affected_symbols = vec![SymbolRef {
226            name: "Bar".into(),
227            container: Some("crate::foo".into()),
228        }];
229        r.affected_paths = vec!["src/main.rs".into()];
230        r
231    }
232
233    #[test]
234    fn detects_coding_domain_from_cargo_toml() {
235        let domain = CodingDomain::new();
236        let ws = WorkspaceSnapshot::new("/repo", vec!["Cargo.toml".into(), "src/main.rs".into()]);
237        let detection = domain.detect(&ws);
238        assert!(detection.activated);
239        assert_eq!(detection.domain, DomainId::new("coding"));
240    }
241
242    #[test]
243    fn energy_model_validates_and_is_measured_only() {
244        let domain = CodingDomain::new();
245        let model = domain.energy_model(&DomainScope::default());
246        assert!(model.validate().is_ok());
247        let claim = model.stability_claim.unwrap();
248        assert!(
249            !claim.claims_floor(),
250            "coding domain must remain NotClaimed"
251        );
252    }
253
254    #[test]
255    fn rust_unresolved_import_yields_import_direction_not_retry() {
256        let domain = CodingDomain::new();
257        let directions = domain.correction_directions(&[lsp_import_residual()]);
258        assert_eq!(directions.len(), 1);
259        assert_eq!(directions[0].addresses, ResidualClass::ImportGraph);
260        assert!(directions[0].instruction.contains("Bar"));
261        assert!(directions[0].instruction.contains("use"));
262    }
263
264    #[test]
265    fn degraded_sensor_has_no_correction_direction() {
266        let domain = CodingDomain::new();
267        let r = ResidualEvent::new(
268            "n1",
269            0,
270            ResidualClass::SensorUnavailable,
271            ResidualSeverity::Blocking,
272            1.0,
273            SensorRef::new("cargo", IndependenceRoute::DeterministicTool),
274        )
275        .unwrap();
276        assert!(domain.correction_directions(&[r]).is_empty());
277    }
278
279    #[test]
280    fn coding_energy_model_scores_real_residuals() {
281        let domain = CodingDomain::new();
282        let model = domain.energy_model(&DomainScope::default());
283        // ImportGraph weight 2.0, score 1.0 -> 2.0 * 1^2 = 2.0 into V_str.
284        let score = score_candidate(&model, &[lsp_import_residual()]).unwrap();
285        assert_eq!(score.total, 2.0);
286        assert_eq!(score.components.v_str, 2.0);
287    }
288}