1use std::collections::BTreeSet;
24
25use crate::error::Result;
26use crate::residual::{
27 CorrectionDirection, IndependenceRoute, ResidualClass, ResidualEvent, ResidualSeverity,
28 SensorRef, SymbolRef,
29};
30
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct GoalSpec {
38 pub node_id: String,
39 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 pub fn is_empty(&self) -> bool {
53 self.expected_symbols.is_empty()
54 }
55}
56
57pub 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
71pub fn goal_presence_sensor() -> SensorRef {
75 SensorRef::new("goal-presence", IndependenceRoute::DeterministicTool)
76}
77
78pub 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); }
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 assert_eq!(
193 ResidualClass::SymbolMismatch.default_component(),
194 crate::residual::EnergyComponent::Str
195 );
196 }
197}