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 barrier;
23pub mod diag;
24pub mod lang;
25pub mod prompts;
26pub mod registry;
27pub mod runtime;
28pub mod symbols;
29
30pub use barrier::OperationalSafetyBarrier;
31pub use registry::{CodingAdapterRegistry, LanguageId};
32pub use runtime::{crash_marker, SmokeInvocation};
33pub use symbols::{defined_symbols, expected_symbols};
34
35use perspt_sdk::{
36    AgentDomainPackage, BarrierChannel, BarrierSpec, CorrectionDirection, DomainDetection,
37    DomainId, DomainScope, EnergyComponent, EnergyModel, HardGatePolicy, ResidualClass,
38    ResidualEvent, ResidualSchema, ResidualWeight, SafetyBarrierDisposition, StabilityClaim,
39    VerificationCadence, VerifierSuiteSpec, WorkspaceSnapshot,
40};
41
42/// The coding domain package.
43#[derive(Debug, Clone, Default)]
44pub struct CodingDomain;
45
46impl CodingDomain {
47    pub fn new() -> Self {
48        Self
49    }
50
51    /// The residual classes the coding domain can emit.
52    fn classes() -> Vec<ResidualClass> {
53        vec![
54            ResidualClass::Syntax,
55            ResidualClass::Type,
56            ResidualClass::Build,
57            ResidualClass::TestFailure,
58            ResidualClass::Lint,
59            ResidualClass::Format,
60            ResidualClass::Runtime,
61            ResidualClass::Dependency,
62            ResidualClass::Manifest,
63            ResidualClass::ImportGraph,
64            ResidualClass::SymbolMismatch,
65            ResidualClass::InterfaceMismatch,
66            ResidualClass::OwnershipViolation,
67            ResidualClass::Policy,
68            ResidualClass::Regression,
69            ResidualClass::SensorUnavailable,
70            ResidualClass::ToolFailure,
71            ResidualClass::SheafInconsistency,
72        ]
73    }
74}
75
76impl AgentDomainPackage for CodingDomain {
77    fn domain_id(&self) -> DomainId {
78        DomainId::new("coding")
79    }
80
81    fn detect(&self, workspace: &WorkspaceSnapshot) -> DomainDetection {
82        let mut evidence = Vec::new();
83        for marker in ["Cargo.toml", "pyproject.toml", "package.json", "go.mod"] {
84            if workspace.has_file_named(marker) {
85                evidence.push(format!("found {marker}"));
86            }
87        }
88        let activated = !evidence.is_empty();
89        DomainDetection {
90            domain: self.domain_id(),
91            activated,
92            confidence: if activated { 0.95 } else { 0.0 },
93            evidence,
94        }
95    }
96
97    fn residual_schema(&self, _scope: &DomainScope) -> ResidualSchema {
98        ResidualSchema::new(Self::classes())
99    }
100
101    fn energy_model(&self, scope: &DomainScope) -> EnergyModel {
102        use EnergyComponent::*;
103        use ResidualClass::*;
104        // Compiler/type errors weigh heavily (they block everything downstream);
105        // structural and behavioral residuals weigh moderately; degraded
106        // sensors are V_boot. Every class carries an explicit weight — no class
107        // defaults to an implicit weight of 1.
108        let weights = vec![
109            ResidualWeight::new(Syntax, Syn, 4.0).with_hard_threshold(0.0),
110            ResidualWeight::new(Type, Syn, 3.0).with_hard_threshold(0.0),
111            ResidualWeight::new(Build, Syn, 3.0).with_hard_threshold(0.0),
112            ResidualWeight::new(ImportGraph, Str, 2.0),
113            ResidualWeight::new(SymbolMismatch, Str, 2.0),
114            ResidualWeight::new(InterfaceMismatch, Str, 2.5),
115            ResidualWeight::new(OwnershipViolation, Str, 2.0),
116            ResidualWeight::new(Policy, Str, 1.0),
117            ResidualWeight::new(Dependency, Str, 1.5),
118            ResidualWeight::new(Manifest, Str, 1.5),
119            ResidualWeight::new(Lint, Str, 0.5),
120            ResidualWeight::new(Format, Str, 0.25),
121            ResidualWeight::new(TestFailure, Log, 2.0),
122            ResidualWeight::new(Runtime, Log, 2.0),
123            ResidualWeight::new(Regression, Log, 3.0),
124            ResidualWeight::new(SensorUnavailable, Boot, 1.0),
125            ResidualWeight::new(ToolFailure, Boot, 1.0),
126            ResidualWeight::new(SheafInconsistency, Sheaf, 2.0),
127        ];
128
129        let mut model = EnergyModel::new("coding", 0.5).with_correction_budget(4);
130        model.residual_weights = weights;
131        model.energy_tolerance = 0.0;
132        // The coding domain is measured-only: analytic constants are NotClaimed.
133        model.stability_claim = Some(StabilityClaim::not_claimed(format!(
134            "coding scope: {}",
135            scope.label
136        )));
137        model
138    }
139
140    fn correction_directions(&self, residuals: &[ResidualEvent]) -> Vec<CorrectionDirection> {
141        // The two correction systems are one (PSP-10 system 26): the
142        // language adapter identified by the residual's sensor answers
143        // first with its richer per-language arms; the class-only mapping
144        // is the fallback for language-neutral residuals.
145        let registry = crate::CodingAdapterRegistry::with_builtins();
146        residuals
147            .iter()
148            .filter_map(|residual| {
149                language_for_sensor(&residual.sensor.id)
150                    .and_then(|language| registry.get(&language))
151                    .and_then(|adapter| adapter.correction_for(residual))
152                    .or_else(|| correction_for(residual))
153            })
154            .collect()
155    }
156
157    fn correction_packet(
158        &self,
159        residuals: &[ResidualEvent],
160    ) -> Option<perspt_sdk::CorrectionPacket> {
161        build_correction_packet(self, residuals)
162    }
163
164    fn tool_entries(&self, _scope: &DomainScope) -> Vec<perspt_sdk::ToolEntry> {
165        // The base catalog's run_test / run_build / run_formatter entries are
166        // bound to concrete commands by the plugin registry at catalog
167        // assembly; the coding domain contributes no extra entries yet.
168        Vec::new()
169    }
170
171    fn hard_gate_policy(&self, _scope: &DomainScope) -> HardGatePolicy {
172        HardGatePolicy {
173            required_stages: vec!["syntax".into(), "build".into(), "test".into()],
174        }
175    }
176
177    fn verifier_suite(&self, _scope: &DomainScope) -> VerifierSuiteSpec {
178        VerifierSuiteSpec {
179            stages: vec![
180                "syntax".into(),
181                "build".into(),
182                "test".into(),
183                "lint".into(),
184            ],
185            cadence: VerificationCadence::default(),
186        }
187    }
188
189    fn safety_barrier(&self, _scope: &DomainScope) -> SafetyBarrierDisposition {
190        // The versioned operational safety channels of PSP-9 system 12.
191        // Correctness evidence (compiler errors, test failures) stays in V;
192        // it is never duplicated into h.
193        SafetyBarrierDisposition::Registered(BarrierSpec {
194            channels: [
195                "protected-path-modification",
196                "sandbox-escape",
197                "secret-exposure",
198                "unapproved-network-reachability",
199                "dependency-policy-violation",
200                "resource-limits",
201            ]
202            .iter()
203            .map(|name| BarrierChannel {
204                name: (*name).to_string(),
205                policy_version: "1".to_string(),
206            })
207            .collect(),
208        })
209    }
210}
211
212/// Map a residual's sensor identity to the language adapter that owns it.
213fn language_for_sensor(sensor_id: &str) -> Option<crate::LanguageId> {
214    match sensor_id {
215        "rustc" | "cargo-test" => Some(crate::LanguageId::new("rust")),
216        "ty" | "pyright" | "pytest" | "python-check" => Some(crate::LanguageId::new("python")),
217        "tsc" => Some(crate::LanguageId::new("typescript")),
218        _ => None,
219    }
220}
221
222/// Fold all verifier residuals into one coherent typed correction packet
223/// (PSP-10 system 26). Every direction participates — the historic
224/// first-direction-only flattening is gone — and paths, symbols, spans,
225/// and rationale survive to the model.
226/// Fold every residual's structured evidence into the packet's diagnostic
227/// list and affected set.
228fn fold_evidence(
229    residuals: &[ResidualEvent],
230) -> (
231    Vec<perspt_sdk::StructuredDiagnosticRef>,
232    perspt_sdk::AffectedSet,
233) {
234    let mut diagnostics = Vec::new();
235    let mut affected = perspt_sdk::AffectedSet::default();
236    for residual in residuals {
237        if let Some(refs) = residual.evidence.structured.as_ref().and_then(|value| {
238            serde_json::from_value::<Vec<perspt_sdk::StructuredDiagnosticRef>>(value.clone()).ok()
239        }) {
240            for item in &refs {
241                if let (Some(path), Some(line)) = (&item.path, item.line) {
242                    affected
243                        .spans
244                        .push(format!("{path}:{line}:{}", item.column.unwrap_or(1)));
245                }
246                if residual.class == ResidualClass::TestFailure {
247                    if let Some(test) = &item.code {
248                        affected.tests.push(test.clone());
249                    }
250                }
251            }
252            diagnostics.extend(refs);
253        }
254        affected
255            .paths
256            .extend(residual.affected_paths.iter().cloned());
257        affected
258            .symbols
259            .extend(residual.affected_symbols.iter().cloned());
260    }
261    affected.paths.sort();
262    affected.paths.dedup();
263    (diagnostics, affected)
264}
265
266fn build_correction_packet(
267    domain: &CodingDomain,
268    residuals: &[ResidualEvent],
269) -> Option<perspt_sdk::CorrectionPacket> {
270    let dominant = residuals
271        .iter()
272        .filter(|residual| residual.class != ResidualClass::SensorUnavailable)
273        .max_by(|a, b| a.score.total_cmp(&b.score))?;
274    let (diagnostics, affected) = fold_evidence(residuals);
275    let operators: Vec<perspt_sdk::CorrectionOperator> = domain
276        .correction_directions(residuals)
277        .into_iter()
278        .map(|direction| perspt_sdk::CorrectionOperator {
279            operator_id: direction.direction_id.clone(),
280            instruction: direction.instruction.clone(),
281            addresses: direction.addresses,
282            rationale: direction.rationale.clone(),
283        })
284        .collect();
285    let uncertainty: Vec<perspt_sdk::MissingSensorDecl> = residuals
286        .iter()
287        .filter(|residual| residual.class == ResidualClass::SensorUnavailable)
288        .map(|residual| perspt_sdk::MissingSensorDecl {
289            sensor: residual.sensor.id.clone(),
290            reason: residual.evidence.summary.clone(),
291        })
292        .collect();
293    if operators.is_empty() && diagnostics.is_empty() {
294        return None;
295    }
296    let member_count = dominant
297        .evidence
298        .structured
299        .as_ref()
300        .and_then(|value| value.as_array().map(|items| items.len() as u32))
301        .unwrap_or(1);
302    Some(perspt_sdk::CorrectionPacket {
303        dominant_cluster: perspt_sdk::ResidualClusterRef {
304            cluster_id: dominant.residual_id.clone(),
305            class: dominant.class,
306            root_cause: dominant.evidence.summary.clone(),
307            member_count,
308            magnitude: dominant.score,
309        },
310        diagnostics,
311        affected,
312        operators,
313        expected_footprint: perspt_sdk::FootprintSpec::new(Vec::new()),
314        follow_up_stages: Vec::new(),
315        no_goods: Vec::new(),
316        uncertainty,
317    })
318}
319
320/// Map a single residual to a coding correction direction, or `None` when there
321/// is no honest direction (the runtime then escalates rather than retrying
322/// blindly).
323fn correction_for(residual: &ResidualEvent) -> Option<CorrectionDirection> {
324    match residual.class {
325        ResidualClass::ImportGraph => {
326            // Rust unresolved-import / missing-module direction (PSP-8 ref step 10).
327            let symbol = residual
328                .affected_symbols
329                .first()
330                .map(|s| s.name.clone())
331                .unwrap_or_else(|| "the missing item".to_string());
332            Some(
333                CorrectionDirection::new(
334                    ResidualClass::ImportGraph,
335                    format!(
336                        "resolve the unresolved import for `{symbol}`: add the missing `use` path \
337                         or declare the missing `mod`, do not regenerate unrelated code"
338                    ),
339                )
340                .with_paths(residual.affected_paths.clone())
341                .with_rationale(
342                    "unresolved imports are structural; the fix is an import/module \
343                     declaration, not a behavioral rewrite",
344                ),
345            )
346        }
347        ResidualClass::Type => Some(
348            CorrectionDirection::new(
349                ResidualClass::Type,
350                "reconcile the type mismatch at the reported span; adjust the expression or the \
351                 declared signature, keeping the public interface stable",
352            )
353            .with_paths(residual.affected_paths.clone()),
354        ),
355        ResidualClass::Dependency | ResidualClass::Manifest => Some(
356            CorrectionDirection::new(
357                residual.class,
358                "repair the dependency/manifest: add or pin the missing crate/package and sync \
359                 the lockfile through an approved dependency-mutation effect",
360            )
361            .with_paths(residual.affected_paths.clone()),
362        ),
363        ResidualClass::TestFailure => Some(
364            CorrectionDirection::new(
365                ResidualClass::TestFailure,
366                "address the failing test by fixing the implementation it attributes to; do not \
367                 weaken or delete the assertion",
368            )
369            .with_paths(residual.affected_paths.clone()),
370        ),
371        // No honest direction for degraded sensors or pure tool failures: these
372        // are bootstrap problems the runtime escalates, never a code retry.
373        ResidualClass::SensorUnavailable | ResidualClass::ToolFailure => None,
374        _ => None,
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use perspt_sdk::{score_candidate, IndependenceRoute, ResidualSeverity, SensorRef, SymbolRef};
382
383    fn lsp_import_residual() -> ResidualEvent {
384        let mut r = ResidualEvent::new(
385            "n1",
386            0,
387            ResidualClass::ImportGraph,
388            ResidualSeverity::Error,
389            1.0,
390            SensorRef::new("rust-analyzer", IndependenceRoute::Lsp),
391        )
392        .unwrap();
393        r.affected_symbols = vec![SymbolRef {
394            name: "Bar".into(),
395            container: Some("crate::foo".into()),
396        }];
397        r.affected_paths = vec!["src/main.rs".into()];
398        r
399    }
400
401    #[test]
402    fn detects_coding_domain_from_cargo_toml() {
403        let domain = CodingDomain::new();
404        let ws = WorkspaceSnapshot::new("/repo", vec!["Cargo.toml".into(), "src/main.rs".into()]);
405        let detection = domain.detect(&ws);
406        assert!(detection.activated);
407        assert_eq!(detection.domain, DomainId::new("coding"));
408    }
409
410    #[test]
411    fn energy_model_validates_and_is_measured_only() {
412        let domain = CodingDomain::new();
413        let model = domain.energy_model(&DomainScope::default());
414        assert!(model.validate().is_ok());
415        let claim = model.stability_claim.unwrap();
416        assert!(
417            !claim.claims_floor(),
418            "coding domain must remain NotClaimed"
419        );
420    }
421
422    #[test]
423    fn rust_unresolved_import_yields_import_direction_not_retry() {
424        let domain = CodingDomain::new();
425        let directions = domain.correction_directions(&[lsp_import_residual()]);
426        assert_eq!(directions.len(), 1);
427        assert_eq!(directions[0].addresses, ResidualClass::ImportGraph);
428        assert!(directions[0].instruction.contains("Bar"));
429        assert!(directions[0].instruction.contains("use"));
430    }
431
432    #[test]
433    fn degraded_sensor_has_no_correction_direction() {
434        let domain = CodingDomain::new();
435        let r = ResidualEvent::new(
436            "n1",
437            0,
438            ResidualClass::SensorUnavailable,
439            ResidualSeverity::Blocking,
440            1.0,
441            SensorRef::new("cargo", IndependenceRoute::DeterministicTool),
442        )
443        .unwrap();
444        assert!(domain.correction_directions(&[r]).is_empty());
445    }
446
447    #[test]
448    fn coding_energy_model_scores_real_residuals() {
449        let domain = CodingDomain::new();
450        let model = domain.energy_model(&DomainScope::default());
451        // ImportGraph weight 2.0, score 1.0 -> 2.0 * 1^2 = 2.0 into V_str.
452        let score = score_candidate(&model, &[lsp_import_residual()]).unwrap();
453        assert_eq!(score.total, 2.0);
454        assert_eq!(score.components.v_str, 2.0);
455    }
456}