Skip to main content

perspt_sdk/
goal.rs

1//! Goal-presence sensor (PSP-8): the verifier that refuses false stability.
2//!
3//! The SRBN gate accepts a state once its energy `V` drops below the declared
4//! floor. But an empty or placeholder file *compiles*, has no failing tests, and
5//! no diagnostics — so every code-quality sensor reports `r_e = 0` and the gate
6//! would declare it stable even though the requested work was never done. That
7//! is *false stability*: convergence to a fixed point that does not satisfy the
8//! goal.
9//!
10//! PSP-8 closes this with a goal-presence sensor: a node carries a [`GoalSpec`]
11//! naming the symbols its work must bring into existence, and the sensor emits a
12//! **blocking** [`ResidualClass::SymbolMismatch`] residual for every expected
13//! symbol that is absent from the observed workspace. A blocking residual is a
14//! hard-gate failure (`hard(y)` is false), so the node cannot be accepted while
15//! its goal artifact is missing, regardless of how low the other energy
16//! components are.
17//!
18//! This module is domain-neutral: it compares *names*. Extracting the expected
19//! names from a task contract and the observed names from source is the domain
20//! package's job (see `perspt-coding`), keeping the mechanism/domain split of
21//! PSP-8 intact.
22
23use std::collections::BTreeSet;
24
25use crate::error::Result;
26use crate::residual::{
27    CorrectionDirection, IndependenceRoute, ResidualClass, ResidualEvent, ResidualSeverity,
28    SensorRef, SymbolRef,
29};
30
31/// The set of symbols a node's work is required to bring into existence.
32///
33/// A node with an empty spec has no goal-presence obligation: the sensor stays
34/// silent rather than guessing, so it never penalizes work whose success cannot
35/// be expressed as "these named symbols must exist".
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct GoalSpec {
38    pub node_id: String,
39    /// Symbol names that MUST be present once the node is done.
40    pub expected_symbols: Vec<String>,
41}
42
43impl GoalSpec {
44    pub fn new(node_id: impl Into<String>, expected_symbols: Vec<String>) -> Self {
45        Self {
46            node_id: node_id.into(),
47            expected_symbols,
48        }
49    }
50
51    /// No declared obligation — the sensor MUST NOT fire.
52    pub fn is_empty(&self) -> bool {
53        self.expected_symbols.is_empty()
54    }
55}
56
57/// The expected symbols absent from `observed`, preserving declared order and
58/// de-duplicating. Comparison is exact on the name string; the domain is
59/// responsible for normalizing names (e.g. stripping a `module::` prefix)
60/// before building the [`GoalSpec`] and the observed set.
61pub fn missing_symbols(spec: &GoalSpec, observed: &BTreeSet<String>) -> Vec<String> {
62    let mut seen = BTreeSet::new();
63    spec.expected_symbols
64        .iter()
65        .filter(|name| !observed.contains(*name))
66        .filter(|name| seen.insert((*name).clone()))
67        .cloned()
68        .collect()
69}
70
71/// The goal-presence sensor identifier and its independence route. It is a
72/// deterministic AST/source query, not a model critique, so it is full-weight
73/// eligible.
74pub fn goal_presence_sensor() -> SensorRef {
75    SensorRef::new("goal-presence", IndependenceRoute::DeterministicTool)
76}
77
78/// Emit a single blocking goal-presence residual when any expected symbol is
79/// absent from `observed`. Returns `None` when the spec is empty or the goal is
80/// already satisfied.
81///
82/// The residual's raw magnitude is the number of missing symbols, so a node
83/// that delivered nothing scores higher than one that is only partially short.
84/// Because the severity is [`ResidualSeverity::Blocking`], the gate treats it as
85/// a hard failure: `V` may be otherwise zero, but `hard(y)` is false, so the
86/// node is not accepted.
87pub fn goal_presence_residual(
88    spec: &GoalSpec,
89    generation: u32,
90    observed: &BTreeSet<String>,
91) -> Result<Option<ResidualEvent>> {
92    if spec.is_empty() {
93        return Ok(None);
94    }
95    let missing = missing_symbols(spec, observed);
96    if missing.is_empty() {
97        return Ok(None);
98    }
99
100    let summary = format!(
101        "goal not satisfied: {} expected symbol(s) absent from the workspace: {}",
102        missing.len(),
103        missing.join(", ")
104    );
105
106    let mut residual = ResidualEvent::new(
107        &spec.node_id,
108        generation,
109        ResidualClass::SymbolMismatch,
110        ResidualSeverity::Blocking,
111        missing.len() as f64,
112        goal_presence_sensor(),
113    )?;
114    residual.evidence.summary = summary;
115    residual.affected_symbols = missing
116        .iter()
117        .map(|name| SymbolRef {
118            name: name.clone(),
119            container: None,
120        })
121        .collect();
122    residual = residual.with_correction(
123        CorrectionDirection::new(
124            ResidualClass::SymbolMismatch,
125            format!(
126                "the requested work is missing: define {} so the goal is satisfied; \
127                 do not stop until each named symbol exists",
128                missing.join(", ")
129            ),
130        )
131        .with_rationale("an empty or placeholder file compiles but does not satisfy the goal"),
132    );
133
134    Ok(Some(residual))
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn observed(names: &[&str]) -> BTreeSet<String> {
142        names.iter().map(|s| s.to_string()).collect()
143    }
144
145    #[test]
146    fn empty_spec_never_fires() {
147        let spec = GoalSpec::new("n1", vec![]);
148        assert!(goal_presence_residual(&spec, 0, &observed(&[]))
149            .unwrap()
150            .is_none());
151    }
152
153    #[test]
154    fn satisfied_goal_produces_no_residual() {
155        let spec = GoalSpec::new("n1", vec!["multiply".into()]);
156        let r = goal_presence_residual(&spec, 0, &observed(&["multiply", "helper"])).unwrap();
157        assert!(r.is_none());
158    }
159
160    #[test]
161    fn missing_symbol_is_blocking_residual() {
162        let spec = GoalSpec::new("n1", vec!["is_even".into()]);
163        let r = goal_presence_residual(&spec, 0, &observed(&["unrelated"]))
164            .unwrap()
165            .expect("missing symbol must produce a residual");
166        assert_eq!(r.class, ResidualClass::SymbolMismatch);
167        assert_eq!(r.severity, ResidualSeverity::Blocking);
168        assert_eq!(r.score, 1.0);
169        assert_eq!(r.affected_symbols.len(), 1);
170        assert_eq!(r.affected_symbols[0].name, "is_even");
171        assert_eq!(r.correction_directions.len(), 1);
172    }
173
174    #[test]
175    fn score_counts_all_missing_symbols() {
176        let spec = GoalSpec::new("n1", vec!["a".into(), "b".into(), "c".into()]);
177        let r = goal_presence_residual(&spec, 0, &observed(&["b"]))
178            .unwrap()
179            .unwrap();
180        assert_eq!(r.score, 2.0); // a and c missing
181    }
182
183    #[test]
184    fn missing_symbols_dedup_and_order() {
185        let spec = GoalSpec::new("n1", vec!["a".into(), "a".into(), "b".into()]);
186        assert_eq!(missing_symbols(&spec, &observed(&[])), vec!["a", "b"]);
187    }
188
189    #[test]
190    fn symbol_mismatch_routes_to_structural_component() {
191        // A goal-presence failure is structural, not a syntax/test failure.
192        assert_eq!(
193            ResidualClass::SymbolMismatch.default_component(),
194            crate::residual::EnergyComponent::Str
195        );
196    }
197}