Skip to main content

ferro_projections/
intent.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Structurally-derivable intent classification for a service.
5///
6/// Intents answer "what IS this service?" based on its structural shape
7/// (fields, relationships, state machine), not "what can a user DO?"
8///
9/// Known variants are tried first during deserialization; any unrecognized
10/// string falls through to `Custom(String)`.
11///
12/// `Custom(String)` must remain the last variant for correct serde deserialization.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
14#[serde(rename_all = "snake_case")]
15#[schemars(
16    description = "Structural intent classification. Known variants: browse, focus, collect, process, summarize, analyze, track. Any other string is a custom domain-specific intent."
17)]
18pub enum Intent {
19    /// Collection navigation: has_many relationships, EntityName fields.
20    Browse,
21    /// Single-entity deep view: FreeText/ImageUrl/Url fields.
22    Focus,
23    /// Data capture: many writable fields, write_only present.
24    Collect,
25    /// Workflow with state progression: guarded transitions.
26    Process,
27    /// Overview dashboard: read-only Money/Percentage/Quantity fields.
28    Summarize,
29    /// Time-series exploration: DateTime + numeric measures.
30    Analyze,
31    /// Timeline/audit trail: Status + temporal ordering.
32    Track,
33    /// Escape hatch for intents not structurally derivable.
34    #[serde(untagged)]
35    Custom(String),
36}
37
38impl Intent {
39    /// Stable, lowercase string label for this intent, decoupled from
40    /// `#[derive(Debug)]`. Known variants return the same snake_case string
41    /// serde produces; `Custom(s)` returns `s.as_str()`.
42    pub fn label(&self) -> &str {
43        match self {
44            Intent::Browse => "browse",
45            Intent::Focus => "focus",
46            Intent::Collect => "collect",
47            Intent::Process => "process",
48            Intent::Summarize => "summarize",
49            Intent::Analyze => "analyze",
50            Intent::Track => "track",
51            Intent::Custom(s) => s.as_str(),
52        }
53    }
54}
55
56/// A scored intent with confidence and the structural signals that contributed.
57///
58/// Produced by the structural analysis engine (Phase 89). Confidence ranges
59/// from 0.0 (no signal) to 1.0 (strong structural match).
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
61pub struct IntentScore {
62    /// The classified intent.
63    pub intent: Intent,
64    /// Confidence score from 0.0 to 1.0.
65    pub confidence: f64,
66    /// Structural signals that contributed to this classification.
67    pub matching_signals: Vec<String>,
68}
69
70/// Manual override for intent derivation when structural analysis is wrong.
71///
72/// `Primary` forces an intent as the top classification.
73/// `Exclude` removes an intent from consideration entirely.
74///
75/// Serializes as externally tagged: `{"primary": "browse"}` or `{"exclude": "process"}`.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
77#[serde(rename_all = "snake_case")]
78pub enum IntentHint {
79    /// Force this intent as the primary classification.
80    Primary(Intent),
81    /// Exclude this intent from consideration.
82    Exclude(Intent),
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    // -- Intent construction and serde --
90
91    #[test]
92    fn intent_known_variants_serde_round_trip() {
93        let known = [
94            Intent::Browse,
95            Intent::Focus,
96            Intent::Collect,
97            Intent::Process,
98            Intent::Summarize,
99            Intent::Analyze,
100            Intent::Track,
101        ];
102        for intent in known {
103            let json = serde_json::to_string(&intent).unwrap();
104            let parsed: Intent = serde_json::from_str(&json).unwrap();
105            assert_eq!(intent, parsed);
106        }
107    }
108
109    #[test]
110    fn intent_custom_fallback() {
111        let parsed: Intent = serde_json::from_str(r#""dashboard""#).unwrap();
112        assert_eq!(parsed, Intent::Custom("dashboard".to_string()));
113    }
114
115    #[test]
116    fn intent_custom_round_trip() {
117        let custom = Intent::Custom("my_intent".into());
118        let json = serde_json::to_string(&custom).unwrap();
119        let parsed: Intent = serde_json::from_str(&json).unwrap();
120        assert_eq!(parsed, Intent::Custom("my_intent".into()));
121    }
122
123    #[test]
124    fn intent_known_not_custom() {
125        // "browse" must match Browse variant, not Custom("browse")
126        let parsed: Intent = serde_json::from_str(r#""browse""#).unwrap();
127        assert_eq!(parsed, Intent::Browse);
128        assert_ne!(parsed, Intent::Custom("browse".into()));
129    }
130
131    #[test]
132    fn intent_snake_case_serialization() {
133        assert_eq!(
134            serde_json::to_string(&Intent::Browse).unwrap(),
135            r#""browse""#
136        );
137        assert_eq!(
138            serde_json::to_string(&Intent::Summarize).unwrap(),
139            r#""summarize""#
140        );
141    }
142
143    #[test]
144    fn intent_eq_and_hash() {
145        use std::collections::HashSet;
146        let mut set = HashSet::new();
147        set.insert(Intent::Browse);
148        set.insert(Intent::Browse);
149        set.insert(Intent::Custom("x".into()));
150        assert_eq!(set.len(), 2);
151    }
152
153    // -- IntentScore construction and serde --
154
155    #[test]
156    fn intent_score_serde_round_trip() {
157        let score = IntentScore {
158            intent: Intent::Browse,
159            confidence: 0.85,
160            matching_signals: vec!["has_many_relationships".into(), "entity_name_fields".into()],
161        };
162        let json = serde_json::to_string(&score).unwrap();
163        let parsed: IntentScore = serde_json::from_str(&json).unwrap();
164        assert_eq!(score, parsed);
165    }
166
167    #[test]
168    fn intent_score_with_custom_intent() {
169        let score = IntentScore {
170            intent: Intent::Custom("reporting".into()),
171            confidence: 0.6,
172            matching_signals: vec!["date_range_fields".into()],
173        };
174        let json = serde_json::to_string(&score).unwrap();
175        let parsed: IntentScore = serde_json::from_str(&json).unwrap();
176        assert_eq!(score, parsed);
177    }
178
179    // -- IntentHint construction and serde --
180
181    #[test]
182    fn intent_hint_primary_serde_round_trip() {
183        let hint = IntentHint::Primary(Intent::Browse);
184        let json = serde_json::to_string(&hint).unwrap();
185        let parsed: IntentHint = serde_json::from_str(&json).unwrap();
186        assert_eq!(hint, parsed);
187    }
188
189    #[test]
190    fn intent_hint_exclude_serde_round_trip() {
191        let hint = IntentHint::Exclude(Intent::Process);
192        let json = serde_json::to_string(&hint).unwrap();
193        let parsed: IntentHint = serde_json::from_str(&json).unwrap();
194        assert_eq!(hint, parsed);
195    }
196
197    #[test]
198    fn intent_hint_json_structure() {
199        // Primary serializes as {"primary": "browse"}
200        let primary = IntentHint::Primary(Intent::Browse);
201        let json = serde_json::to_string(&primary).unwrap();
202        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
203        assert!(value.get("primary").is_some());
204        assert_eq!(value["primary"], "browse");
205
206        // Exclude serializes as {"exclude": "process"}
207        let exclude = IntentHint::Exclude(Intent::Process);
208        let json = serde_json::to_string(&exclude).unwrap();
209        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
210        assert!(value.get("exclude").is_some());
211        assert_eq!(value["exclude"], "process");
212    }
213
214    #[test]
215    fn intent_hint_with_custom_intent() {
216        let hint = IntentHint::Primary(Intent::Custom("wizard".into()));
217        let json = serde_json::to_string(&hint).unwrap();
218        let parsed: IntentHint = serde_json::from_str(&json).unwrap();
219        assert_eq!(hint, parsed);
220    }
221
222    // -- JSON Schema --
223
224    #[test]
225    fn intent_json_schema_has_description() {
226        let schema = schemars::schema_for!(Intent);
227        let value = schema.to_value();
228        let desc = value
229            .get("description")
230            .expect("Intent schema must have description");
231        let desc_str = desc.as_str().unwrap();
232        assert!(
233            desc_str.contains("Known variants"),
234            "description should document known variants, got: {desc_str}"
235        );
236    }
237
238    #[test]
239    fn intent_score_json_schema() {
240        let schema = schemars::schema_for!(IntentScore);
241        let value = schema.to_value();
242        let props = value
243            .get("properties")
244            .expect("IntentScore schema must have properties");
245        let obj = props.as_object().unwrap();
246        assert!(obj.contains_key("intent"), "missing 'intent' property");
247        assert!(
248            obj.contains_key("confidence"),
249            "missing 'confidence' property"
250        );
251        assert!(
252            obj.contains_key("matching_signals"),
253            "missing 'matching_signals' property"
254        );
255    }
256
257    #[test]
258    fn intent_hint_json_schema() {
259        let schema = schemars::schema_for!(IntentHint);
260        let value = schema.to_value();
261        // IntentHint is an externally tagged enum, so it should have oneOf
262        let one_of = value.get("oneOf");
263        assert!(one_of.is_some(), "IntentHint schema must have oneOf");
264    }
265
266    // -- IntentScore construction --
267
268    #[test]
269    fn intent_score_construction() {
270        let score = IntentScore {
271            intent: Intent::Process,
272            confidence: 0.72,
273            matching_signals: vec!["guarded_transitions".into(), "state_progression".into()],
274        };
275        assert_eq!(score.intent, Intent::Process);
276        assert!((score.confidence - 0.72).abs() < f64::EPSILON);
277        assert_eq!(score.matching_signals.len(), 2);
278        assert_eq!(score.matching_signals[0], "guarded_transitions");
279        assert_eq!(score.matching_signals[1], "state_progression");
280    }
281
282    #[test]
283    fn intent_score_empty_signals() {
284        let score = IntentScore {
285            intent: Intent::Focus,
286            confidence: 0.5,
287            matching_signals: vec![],
288        };
289        let json = serde_json::to_string(&score).unwrap();
290        let parsed: IntentScore = serde_json::from_str(&json).unwrap();
291        assert_eq!(score, parsed);
292        assert!(parsed.matching_signals.is_empty());
293    }
294
295    // -- IntentHint with Custom intents --
296
297    #[test]
298    fn intent_hint_exclude_custom() {
299        let hint = IntentHint::Exclude(Intent::Custom("niche".into()));
300        let json = serde_json::to_string(&hint).unwrap();
301        let parsed: IntentHint = serde_json::from_str(&json).unwrap();
302        assert_eq!(hint, parsed);
303    }
304
305    // -- Equality edge cases --
306
307    #[test]
308    fn intent_eq_known_vs_custom() {
309        // Browse and Custom("browse") must be distinct values
310        assert_ne!(Intent::Browse, Intent::Custom("browse".into()));
311        assert_ne!(Intent::Focus, Intent::Custom("focus".into()));
312        assert_ne!(Intent::Track, Intent::Custom("track".into()));
313    }
314
315    // -- Intent::label() --
316
317    #[test]
318    fn intent_label_known_variants() {
319        assert_eq!(Intent::Browse.label(), "browse");
320        assert_eq!(Intent::Focus.label(), "focus");
321        assert_eq!(Intent::Collect.label(), "collect");
322        assert_eq!(Intent::Process.label(), "process");
323        assert_eq!(Intent::Summarize.label(), "summarize");
324        assert_eq!(Intent::Analyze.label(), "analyze");
325        assert_eq!(Intent::Track.label(), "track");
326    }
327
328    #[test]
329    fn intent_label_custom_returns_inner_string() {
330        assert_eq!(Intent::Custom("reporting".into()).label(), "reporting");
331    }
332}