Skip to main content

agentstategraph_core/
query.rs

1//! Unified query interface — one API for querying state, commits, intents, and epochs.
2//!
3//! All filters are optional and combined with AND. Simple queries use
4//! one or two filters. Complex queries combine many.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::commit::Commit;
10
11/// What to query — the primary dimension.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum QueryTarget {
14    /// Current state values.
15    State,
16    /// Commit history.
17    Commits,
18    /// Intent metadata.
19    Intents,
20    /// Agent activity.
21    Agents,
22    /// Epoch records.
23    Epochs,
24}
25
26/// Composable query filters. All optional, combined with AND.
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct QueryFilters {
29    /// Path pattern (e.g., "/nodes/*", "/config/network/**").
30    pub path: Option<String>,
31    /// Agent ID filter.
32    pub agent_id: Option<String>,
33    /// Intent category filter.
34    pub intent_category: Option<String>,
35    /// Intent tags (all must match).
36    pub tags: Option<Vec<String>>,
37    /// Authority principal filter.
38    pub authority_principal: Option<String>,
39    /// Full-text search in reasoning traces.
40    pub reasoning_contains: Option<String>,
41    /// Confidence range [min, max].
42    pub confidence_range: Option<(f64, f64)>,
43    /// Intent status filter.
44    pub intent_status: Option<String>,
45    /// Outcome filter.
46    pub outcome: Option<String>,
47    /// Date range [start, end].
48    pub date_from: Option<DateTime<Utc>>,
49    pub date_to: Option<DateTime<Utc>>,
50    /// Only results with deviations.
51    pub has_deviations: Option<bool>,
52}
53
54/// Output control for queries.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct QueryOptions {
57    /// Max results.
58    pub limit: Option<usize>,
59    /// Pagination offset.
60    pub offset: Option<usize>,
61    /// Sort field.
62    pub order_by: Option<String>,
63}
64
65/// A query request.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Query {
68    pub target: QueryTarget,
69    pub ref_name: Option<String>,
70    pub filters: QueryFilters,
71    pub options: QueryOptions,
72}
73
74/// Apply filters to a list of commits, returning only matches.
75pub fn filter_commits(commits: &[Commit], filters: &QueryFilters) -> Vec<Commit> {
76    commits
77        .iter()
78        .filter(|c| matches_filters(c, filters))
79        .cloned()
80        .collect()
81}
82
83/// Check if a commit matches all specified filters.
84pub fn matches_filters(commit: &Commit, filters: &QueryFilters) -> bool {
85    // Agent filter
86    if let Some(ref agent) = filters.agent_id
87        && &commit.agent_id != agent
88    {
89        return false;
90    }
91
92    // Intent category filter
93    if let Some(ref category) = filters.intent_category {
94        let commit_cat = format!("{:?}", commit.intent.category);
95        if !commit_cat.eq_ignore_ascii_case(category) {
96            return false;
97        }
98    }
99
100    // Tags filter (all must match)
101    if let Some(ref tags) = filters.tags {
102        for tag in tags {
103            if !commit.intent.tags.contains(tag) {
104                return false;
105            }
106        }
107    }
108
109    // Authority principal filter
110    if let Some(ref principal) = filters.authority_principal
111        && &commit.authority.principal != principal
112    {
113        return false;
114    }
115
116    // Reasoning contains (full-text search)
117    if let Some(ref query) = filters.reasoning_contains {
118        let query_lower = query.to_lowercase();
119        let matches = commit
120            .reasoning
121            .as_ref()
122            .map(|r| r.to_lowercase().contains(&query_lower))
123            .unwrap_or(false)
124            || commit
125                .intent
126                .description
127                .to_lowercase()
128                .contains(&query_lower);
129        if !matches {
130            return false;
131        }
132    }
133
134    // Confidence range
135    if let Some((min, max)) = filters.confidence_range {
136        match commit.confidence {
137            Some(c) if c >= min && c <= max => {}
138            Some(_) => return false,
139            None => return false,
140        }
141    }
142
143    // Intent status filter
144    if let Some(ref status) = filters.intent_status {
145        let commit_status = format!("{:?}", commit.intent.lifecycle.status);
146        if !commit_status.eq_ignore_ascii_case(status) {
147            return false;
148        }
149    }
150
151    // Date range
152    if let Some(from) = filters.date_from
153        && commit.timestamp < from
154    {
155        return false;
156    }
157    if let Some(to) = filters.date_to
158        && commit.timestamp > to
159    {
160        return false;
161    }
162
163    // Has deviations
164    if let Some(true) = filters.has_deviations {
165        let has = commit
166            .intent
167            .lifecycle
168            .resolution
169            .as_ref()
170            .map(|r| !r.deviations.is_empty())
171            .unwrap_or(false);
172        if !has {
173            return false;
174        }
175    }
176
177    true
178}
179
180/// Blame entry — who last modified a value and why.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct BlameEntry {
183    pub path: String,
184    pub commit_id: String,
185    pub agent_id: String,
186    pub intent_category: String,
187    pub intent_description: String,
188    pub reasoning: Option<String>,
189    pub timestamp: DateTime<Utc>,
190    /// True if this commit's timestamp is less than or equal to at least
191    /// one of its parents' timestamps. Indicates a possible clock rewind
192    /// (or a legitimate concurrent commit in a DAG merge). Always check
193    /// alongside the commit id, not instead of it — a well-intentioned
194    /// skew is not an attack, but a persistent pattern is a signal.
195    /// (security threat model v3+, V4)
196    #[serde(default)]
197    pub timestamp_anomaly: bool,
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::commit::CommitBuilder;
204    use crate::intent::{Authority, Intent, IntentCategory};
205    use crate::object::ObjectId;
206
207    fn test_commit(agent: &str, category: IntentCategory, desc: &str) -> Commit {
208        CommitBuilder::new(
209            ObjectId::hash(b"state"),
210            agent,
211            Authority::simple(agent),
212            Intent::new(category, desc),
213        )
214        .build()
215    }
216
217    fn test_commit_with_reasoning(agent: &str, desc: &str, reasoning: &str) -> Commit {
218        CommitBuilder::new(
219            ObjectId::hash(b"state"),
220            agent,
221            Authority::simple(agent),
222            Intent::new(IntentCategory::Explore, desc),
223        )
224        .reasoning(reasoning)
225        .confidence(0.8)
226        .build()
227    }
228
229    #[test]
230    fn test_filter_by_agent() {
231        let commits = vec![
232            test_commit("agent/a", IntentCategory::Explore, "by a"),
233            test_commit("agent/b", IntentCategory::Explore, "by b"),
234            test_commit("agent/a", IntentCategory::Fix, "fix by a"),
235        ];
236
237        let filtered = filter_commits(
238            &commits,
239            &QueryFilters {
240                agent_id: Some("agent/a".to_string()),
241                ..Default::default()
242            },
243        );
244        assert_eq!(filtered.len(), 2);
245    }
246
247    #[test]
248    fn test_filter_by_category() {
249        let commits = vec![
250            test_commit("agent/a", IntentCategory::Explore, "explore"),
251            test_commit("agent/a", IntentCategory::Fix, "fix"),
252            test_commit("agent/a", IntentCategory::Explore, "explore 2"),
253        ];
254
255        let filtered = filter_commits(
256            &commits,
257            &QueryFilters {
258                intent_category: Some("Explore".to_string()),
259                ..Default::default()
260            },
261        );
262        assert_eq!(filtered.len(), 2);
263    }
264
265    #[test]
266    fn test_filter_by_reasoning_contains() {
267        let commits = vec![
268            test_commit_with_reasoning("a", "storage", "NFS is better for small clusters"),
269            test_commit_with_reasoning("a", "network", "10GbE bonding configured"),
270            test_commit_with_reasoning("a", "gpu", "Memory controller issue on node 3"),
271        ];
272
273        let filtered = filter_commits(
274            &commits,
275            &QueryFilters {
276                reasoning_contains: Some("memory controller".to_string()),
277                ..Default::default()
278            },
279        );
280        assert_eq!(filtered.len(), 1);
281        assert!(filtered[0].intent.description.contains("gpu"));
282    }
283
284    #[test]
285    fn test_filter_by_confidence_range() {
286        let commits = vec![
287            {
288                let mut c = test_commit("a", IntentCategory::Explore, "high");
289                c.confidence = Some(0.9);
290                c
291            },
292            {
293                let mut c = test_commit("a", IntentCategory::Explore, "low");
294                c.confidence = Some(0.3);
295                c
296            },
297            {
298                let mut c = test_commit("a", IntentCategory::Explore, "mid");
299                c.confidence = Some(0.6);
300                c
301            },
302        ];
303
304        let filtered = filter_commits(
305            &commits,
306            &QueryFilters {
307                confidence_range: Some((0.0, 0.5)),
308                ..Default::default()
309            },
310        );
311        assert_eq!(filtered.len(), 1);
312        assert_eq!(filtered[0].intent.description, "low");
313    }
314
315    #[test]
316    fn test_combined_filters() {
317        let commits = vec![
318            test_commit_with_reasoning("agent/planner", "storage explore", "trying NFS"),
319            test_commit_with_reasoning("agent/planner", "network fix", "fixing DNS"),
320            test_commit_with_reasoning("agent/monitor", "health check", "node healthy"),
321        ];
322
323        let filtered = filter_commits(
324            &commits,
325            &QueryFilters {
326                agent_id: Some("agent/planner".to_string()),
327                reasoning_contains: Some("NFS".to_string()),
328                ..Default::default()
329            },
330        );
331        assert_eq!(filtered.len(), 1);
332    }
333
334    // --- Empty filters pass everything ---
335
336    #[test]
337    fn test_empty_filters_returns_all() {
338        let commits = vec![
339            test_commit("a", IntentCategory::Explore, "x"),
340            test_commit("b", IntentCategory::Fix, "y"),
341            test_commit("c", IntentCategory::Checkpoint, "z"),
342        ];
343        let filtered = filter_commits(&commits, &QueryFilters::default());
344        assert_eq!(filtered.len(), 3);
345    }
346
347    // --- Tags filter ---
348
349    #[test]
350    fn test_filter_by_tags_all_must_match() {
351        use crate::intent::Intent;
352        let make = |tags: &[&str]| {
353            CommitBuilder::new(
354                ObjectId::hash(b"s"),
355                "a",
356                Authority::simple("a"),
357                Intent::new(IntentCategory::Explore, "x")
358                    .with_tags(tags.iter().map(|s| s.to_string()).collect()),
359            )
360            .build()
361        };
362
363        let commits = vec![
364            make(&["gpu", "node-3"]),
365            make(&["gpu"]),
366            make(&["cpu", "node-3"]),
367        ];
368
369        // Both tags must match — only the first commit qualifies
370        let filtered = filter_commits(
371            &commits,
372            &QueryFilters {
373                tags: Some(vec!["gpu".to_string(), "node-3".to_string()]),
374                ..Default::default()
375            },
376        );
377        assert_eq!(filtered.len(), 1);
378    }
379
380    // --- Authority principal filter ---
381
382    #[test]
383    fn test_filter_by_authority_principal() {
384        let commits = vec![
385            CommitBuilder::new(
386                ObjectId::hash(b"s"),
387                "a",
388                Authority::simple("human/alice"),
389                Intent::new(IntentCategory::Explore, "by alice"),
390            )
391            .build(),
392            CommitBuilder::new(
393                ObjectId::hash(b"s"),
394                "a",
395                Authority::simple("human/bob"),
396                Intent::new(IntentCategory::Explore, "by bob"),
397            )
398            .build(),
399        ];
400
401        let filtered = filter_commits(
402            &commits,
403            &QueryFilters {
404                authority_principal: Some("human/alice".to_string()),
405                ..Default::default()
406            },
407        );
408        assert_eq!(filtered.len(), 1);
409        assert!(filtered[0].intent.description.contains("alice"));
410    }
411
412    // --- Confidence range: None confidence excluded ---
413
414    #[test]
415    fn test_confidence_range_excludes_none_confidence() {
416        let commits = vec![
417            {
418                let mut c = test_commit("a", IntentCategory::Explore, "has confidence");
419                c.confidence = Some(0.8);
420                c
421            },
422            // No confidence set — should be excluded by any confidence_range filter
423            test_commit("a", IntentCategory::Explore, "no confidence"),
424        ];
425
426        let filtered = filter_commits(
427            &commits,
428            &QueryFilters {
429                confidence_range: Some((0.0, 1.0)),
430                ..Default::default()
431            },
432        );
433        assert_eq!(filtered.len(), 1);
434        assert_eq!(filtered[0].intent.description, "has confidence");
435    }
436
437    // --- Intent category filter: case-insensitive ---
438
439    #[test]
440    fn test_category_filter_case_insensitive() {
441        let commits = vec![
442            test_commit("a", IntentCategory::Explore, "x"),
443            test_commit("a", IntentCategory::Fix, "y"),
444        ];
445
446        // "explore" lowercase should match "Explore" category
447        let filtered = filter_commits(
448            &commits,
449            &QueryFilters {
450                intent_category: Some("explore".to_string()),
451                ..Default::default()
452            },
453        );
454        assert_eq!(filtered.len(), 1);
455    }
456
457    // --- Date range ---
458
459    #[test]
460    fn test_filter_by_date_range() {
461        use chrono::Duration;
462        let now = Utc::now();
463
464        let mut past = test_commit("a", IntentCategory::Explore, "old");
465        past.timestamp = now - Duration::days(10);
466
467        let mut recent = test_commit("a", IntentCategory::Explore, "recent");
468        recent.timestamp = now - Duration::days(1);
469
470        let mut future = test_commit("a", IntentCategory::Explore, "future");
471        future.timestamp = now + Duration::days(1);
472
473        let commits = vec![past.clone(), recent.clone(), future.clone()];
474
475        // Only commits within last 7 days
476        let from = now - Duration::days(7);
477        let to = now + Duration::minutes(1);
478
479        let filtered = filter_commits(
480            &commits,
481            &QueryFilters {
482                date_from: Some(from),
483                date_to: Some(to),
484                ..Default::default()
485            },
486        );
487        assert_eq!(filtered.len(), 1);
488        assert_eq!(filtered[0].intent.description, "recent");
489    }
490
491    // --- has_deviations ---
492
493    #[test]
494    fn test_filter_by_has_deviations() {
495        use crate::intent::{Deviation, DeviationImpact, Intent, Outcome, Resolution};
496
497        let make_with_deviation = |desc: &str| {
498            let mut intent = Intent::new(IntentCategory::Explore, desc);
499            intent.lifecycle.resolution = Some(Resolution {
500                summary: "completed with deviation".into(),
501                outcome: Outcome::PartiallyFulfilled,
502                deviations: vec![Deviation {
503                    description: "unexpected value".into(),
504                    reason: "state diverged".into(),
505                    impact: DeviationImpact::Medium,
506                    follow_up: None,
507                }],
508                commits: Vec::new(),
509                branches_explored: Vec::new(),
510                confidence: 0.7,
511            });
512            CommitBuilder::new(
513                ObjectId::hash(desc.as_bytes()),
514                "a",
515                Authority::simple("a"),
516                intent,
517            )
518            .build()
519        };
520
521        let commits = vec![
522            make_with_deviation("has deviation"),
523            test_commit("a", IntentCategory::Explore, "no deviation"),
524        ];
525
526        let filtered = filter_commits(
527            &commits,
528            &QueryFilters {
529                has_deviations: Some(true),
530                ..Default::default()
531            },
532        );
533        assert_eq!(filtered.len(), 1);
534        assert_eq!(filtered[0].intent.description, "has deviation");
535    }
536
537    // --- Serialization ---
538
539    #[test]
540    fn test_query_filters_serializes_and_deserializes() {
541        let f = QueryFilters {
542            path: Some("/nodes/*".into()),
543            agent_id: Some("agent/a".into()),
544            confidence_range: Some((0.5, 0.9)),
545            ..Default::default()
546        };
547        let json = serde_json::to_string(&f).unwrap();
548        let back: QueryFilters = serde_json::from_str(&json).unwrap();
549        assert_eq!(back.path.as_deref(), Some("/nodes/*"));
550        assert_eq!(back.confidence_range, Some((0.5, 0.9)));
551    }
552
553    #[test]
554    fn test_query_target_round_trips() {
555        for t in [
556            QueryTarget::State,
557            QueryTarget::Commits,
558            QueryTarget::Intents,
559            QueryTarget::Agents,
560            QueryTarget::Epochs,
561        ] {
562            let j = serde_json::to_value(&t).unwrap();
563            let back: QueryTarget = serde_json::from_value(j).unwrap();
564            assert_eq!(back, t);
565        }
566    }
567}