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
38/// A scored intent with confidence and the structural signals that contributed.
39///
40/// Produced by the structural analysis engine (Phase 89). Confidence ranges
41/// from 0.0 (no signal) to 1.0 (strong structural match).
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
43pub struct IntentScore {
44    /// The classified intent.
45    pub intent: Intent,
46    /// Confidence score from 0.0 to 1.0.
47    pub confidence: f64,
48    /// Structural signals that contributed to this classification.
49    pub matching_signals: Vec<String>,
50}
51
52/// Manual override for intent derivation when structural analysis is wrong.
53///
54/// `Primary` forces an intent as the top classification.
55/// `Exclude` removes an intent from consideration entirely.
56///
57/// Serializes as externally tagged: `{"primary": "browse"}` or `{"exclude": "process"}`.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
59#[serde(rename_all = "snake_case")]
60pub enum IntentHint {
61    /// Force this intent as the primary classification.
62    Primary(Intent),
63    /// Exclude this intent from consideration.
64    Exclude(Intent),
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    // -- Intent construction and serde --
72
73    #[test]
74    fn intent_known_variants_serde_round_trip() {
75        let known = [
76            Intent::Browse,
77            Intent::Focus,
78            Intent::Collect,
79            Intent::Process,
80            Intent::Summarize,
81            Intent::Analyze,
82            Intent::Track,
83        ];
84        for intent in known {
85            let json = serde_json::to_string(&intent).unwrap();
86            let parsed: Intent = serde_json::from_str(&json).unwrap();
87            assert_eq!(intent, parsed);
88        }
89    }
90
91    #[test]
92    fn intent_custom_fallback() {
93        let parsed: Intent = serde_json::from_str(r#""dashboard""#).unwrap();
94        assert_eq!(parsed, Intent::Custom("dashboard".to_string()));
95    }
96
97    #[test]
98    fn intent_custom_round_trip() {
99        let custom = Intent::Custom("my_intent".into());
100        let json = serde_json::to_string(&custom).unwrap();
101        let parsed: Intent = serde_json::from_str(&json).unwrap();
102        assert_eq!(parsed, Intent::Custom("my_intent".into()));
103    }
104
105    #[test]
106    fn intent_known_not_custom() {
107        // "browse" must match Browse variant, not Custom("browse")
108        let parsed: Intent = serde_json::from_str(r#""browse""#).unwrap();
109        assert_eq!(parsed, Intent::Browse);
110        assert_ne!(parsed, Intent::Custom("browse".into()));
111    }
112
113    #[test]
114    fn intent_snake_case_serialization() {
115        assert_eq!(
116            serde_json::to_string(&Intent::Browse).unwrap(),
117            r#""browse""#
118        );
119        assert_eq!(
120            serde_json::to_string(&Intent::Summarize).unwrap(),
121            r#""summarize""#
122        );
123    }
124
125    #[test]
126    fn intent_eq_and_hash() {
127        use std::collections::HashSet;
128        let mut set = HashSet::new();
129        set.insert(Intent::Browse);
130        set.insert(Intent::Browse);
131        set.insert(Intent::Custom("x".into()));
132        assert_eq!(set.len(), 2);
133    }
134
135    // -- IntentScore construction and serde --
136
137    #[test]
138    fn intent_score_serde_round_trip() {
139        let score = IntentScore {
140            intent: Intent::Browse,
141            confidence: 0.85,
142            matching_signals: vec!["has_many_relationships".into(), "entity_name_fields".into()],
143        };
144        let json = serde_json::to_string(&score).unwrap();
145        let parsed: IntentScore = serde_json::from_str(&json).unwrap();
146        assert_eq!(score, parsed);
147    }
148
149    #[test]
150    fn intent_score_with_custom_intent() {
151        let score = IntentScore {
152            intent: Intent::Custom("reporting".into()),
153            confidence: 0.6,
154            matching_signals: vec!["date_range_fields".into()],
155        };
156        let json = serde_json::to_string(&score).unwrap();
157        let parsed: IntentScore = serde_json::from_str(&json).unwrap();
158        assert_eq!(score, parsed);
159    }
160
161    // -- IntentHint construction and serde --
162
163    #[test]
164    fn intent_hint_primary_serde_round_trip() {
165        let hint = IntentHint::Primary(Intent::Browse);
166        let json = serde_json::to_string(&hint).unwrap();
167        let parsed: IntentHint = serde_json::from_str(&json).unwrap();
168        assert_eq!(hint, parsed);
169    }
170
171    #[test]
172    fn intent_hint_exclude_serde_round_trip() {
173        let hint = IntentHint::Exclude(Intent::Process);
174        let json = serde_json::to_string(&hint).unwrap();
175        let parsed: IntentHint = serde_json::from_str(&json).unwrap();
176        assert_eq!(hint, parsed);
177    }
178
179    #[test]
180    fn intent_hint_json_structure() {
181        // Primary serializes as {"primary": "browse"}
182        let primary = IntentHint::Primary(Intent::Browse);
183        let json = serde_json::to_string(&primary).unwrap();
184        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
185        assert!(value.get("primary").is_some());
186        assert_eq!(value["primary"], "browse");
187
188        // Exclude serializes as {"exclude": "process"}
189        let exclude = IntentHint::Exclude(Intent::Process);
190        let json = serde_json::to_string(&exclude).unwrap();
191        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
192        assert!(value.get("exclude").is_some());
193        assert_eq!(value["exclude"], "process");
194    }
195
196    #[test]
197    fn intent_hint_with_custom_intent() {
198        let hint = IntentHint::Primary(Intent::Custom("wizard".into()));
199        let json = serde_json::to_string(&hint).unwrap();
200        let parsed: IntentHint = serde_json::from_str(&json).unwrap();
201        assert_eq!(hint, parsed);
202    }
203
204    // -- JSON Schema --
205
206    #[test]
207    fn intent_json_schema_has_description() {
208        let schema = schemars::schema_for!(Intent);
209        let value = schema.to_value();
210        let desc = value
211            .get("description")
212            .expect("Intent schema must have description");
213        let desc_str = desc.as_str().unwrap();
214        assert!(
215            desc_str.contains("Known variants"),
216            "description should document known variants, got: {desc_str}"
217        );
218    }
219
220    #[test]
221    fn intent_score_json_schema() {
222        let schema = schemars::schema_for!(IntentScore);
223        let value = schema.to_value();
224        let props = value
225            .get("properties")
226            .expect("IntentScore schema must have properties");
227        let obj = props.as_object().unwrap();
228        assert!(obj.contains_key("intent"), "missing 'intent' property");
229        assert!(
230            obj.contains_key("confidence"),
231            "missing 'confidence' property"
232        );
233        assert!(
234            obj.contains_key("matching_signals"),
235            "missing 'matching_signals' property"
236        );
237    }
238
239    #[test]
240    fn intent_hint_json_schema() {
241        let schema = schemars::schema_for!(IntentHint);
242        let value = schema.to_value();
243        // IntentHint is an externally tagged enum, so it should have oneOf
244        let one_of = value.get("oneOf");
245        assert!(one_of.is_some(), "IntentHint schema must have oneOf");
246    }
247
248    // -- IntentScore construction --
249
250    #[test]
251    fn intent_score_construction() {
252        let score = IntentScore {
253            intent: Intent::Process,
254            confidence: 0.72,
255            matching_signals: vec!["guarded_transitions".into(), "state_progression".into()],
256        };
257        assert_eq!(score.intent, Intent::Process);
258        assert!((score.confidence - 0.72).abs() < f64::EPSILON);
259        assert_eq!(score.matching_signals.len(), 2);
260        assert_eq!(score.matching_signals[0], "guarded_transitions");
261        assert_eq!(score.matching_signals[1], "state_progression");
262    }
263
264    #[test]
265    fn intent_score_empty_signals() {
266        let score = IntentScore {
267            intent: Intent::Focus,
268            confidence: 0.5,
269            matching_signals: vec![],
270        };
271        let json = serde_json::to_string(&score).unwrap();
272        let parsed: IntentScore = serde_json::from_str(&json).unwrap();
273        assert_eq!(score, parsed);
274        assert!(parsed.matching_signals.is_empty());
275    }
276
277    // -- IntentHint with Custom intents --
278
279    #[test]
280    fn intent_hint_exclude_custom() {
281        let hint = IntentHint::Exclude(Intent::Custom("niche".into()));
282        let json = serde_json::to_string(&hint).unwrap();
283        let parsed: IntentHint = serde_json::from_str(&json).unwrap();
284        assert_eq!(hint, parsed);
285    }
286
287    // -- Equality edge cases --
288
289    #[test]
290    fn intent_eq_known_vs_custom() {
291        // Browse and Custom("browse") must be distinct values
292        assert_ne!(Intent::Browse, Intent::Custom("browse".into()));
293        assert_ne!(Intent::Focus, Intent::Custom("focus".into()));
294        assert_ne!(Intent::Track, Intent::Custom("track".into()));
295    }
296}