Skip to main content

amql_engine/
pattern.rs

1//! Pattern extraction and delta diffing for annotation instances.
2//!
3//! Given all annotations of a tag, identifies the common structure (template)
4//! and what is unique per instance (delta). Agents call once to get the pattern
5//! plus per-instance differences, avoiding redundant source transmission.
6
7use crate::store::{Annotation, AnnotationStore};
8use crate::types::{AttrName, Binding, RelativePath, TagName};
9use rustc_hash::{FxHashMap, FxHashSet};
10use serde::Serialize;
11use serde_json::Value as JsonValue;
12
13/// Common structure across all annotations of a tag.
14#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
15#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
16#[cfg_attr(feature = "ts", ts(export))]
17#[cfg_attr(feature = "flow", flow(export))]
18#[derive(Debug, Clone, Serialize)]
19pub struct Pattern {
20    /// The tag these annotations share.
21    pub tag: TagName,
22    /// Summary of each attribute across all instances.
23    #[cfg_attr(
24        feature = "ts",
25        ts(as = "std::collections::HashMap<AttrName, AttrSummary>")
26    )]
27    #[cfg_attr(
28        feature = "flow",
29        flow(as = "std::collections::HashMap<AttrName, AttrSummary>")
30    )]
31    pub common_attrs: FxHashMap<AttrName, AttrSummary>,
32    /// Representative source snippet (when code elements are available).
33    pub template_source: Option<String>,
34    /// Number of annotation instances analyzed.
35    pub instance_count: usize,
36}
37
38/// Statistical summary of an attribute across annotation instances.
39#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
40#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
41#[cfg_attr(feature = "ts", ts(export))]
42#[cfg_attr(feature = "flow", flow(export))]
43#[derive(Debug, Clone, Serialize)]
44pub struct AttrSummary {
45    /// Whether this attribute is present in every instance.
46    pub always_present: bool,
47    /// Number of distinct values observed.
48    pub distinct_values: usize,
49    /// Up to 3 sample values for context.
50    pub sample_values: Vec<JsonValue>,
51}
52
53/// What is unique about a specific annotation relative to the pattern.
54#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
55#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
56#[cfg_attr(feature = "ts", ts(export))]
57#[cfg_attr(feature = "flow", flow(export))]
58#[derive(Debug, Clone, Serialize)]
59pub struct Delta {
60    /// Binding key of this specific instance.
61    pub binding: Binding,
62    /// Attributes whose values differ from the most common value, or are unique to this instance.
63    #[cfg_attr(
64        feature = "ts",
65        ts(as = "std::collections::HashMap<AttrName, serde_json::Value>")
66    )]
67    #[cfg_attr(
68        feature = "flow",
69        flow(as = "std::collections::HashMap<AttrName, serde_json::Value>")
70    )]
71    pub unique_attrs: FxHashMap<AttrName, JsonValue>,
72    /// File containing this annotation.
73    pub file: RelativePath,
74}
75
76/// Result of pattern extraction: the common pattern and per-instance deltas.
77#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
78#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
79#[cfg_attr(feature = "ts", ts(export))]
80#[cfg_attr(feature = "flow", flow(export))]
81#[derive(Debug, Clone, Serialize)]
82pub struct PatternResult {
83    /// Common structure, if any annotations of the tag exist.
84    pub pattern: Option<Pattern>,
85    /// Per-instance differences from the pattern.
86    pub deltas: Vec<Delta>,
87}
88
89const MAX_SAMPLE_VALUES: usize = 3;
90
91/// Analyze all annotations of a tag and return the common pattern.
92#[must_use = "pattern contains structural analysis of the tag"]
93pub fn extract_pattern(store: &AnnotationStore, tag: &TagName) -> Option<Pattern> {
94    let annotations = collect_by_tag(store, tag);
95    if annotations.is_empty() {
96        return None;
97    }
98
99    let common_attrs = build_attr_summaries(&annotations);
100
101    Some(Pattern {
102        tag: tag.clone(),
103        common_attrs,
104        template_source: None,
105        instance_count: annotations.len(),
106    })
107}
108
109/// Given a pattern and a specific annotation, return only what is unique.
110pub fn diff_from_pattern(pattern: &Pattern, annotation: &Annotation) -> Delta {
111    let mut unique_attrs = FxHashMap::default();
112
113    for (attr, value) in &annotation.attrs {
114        match pattern.common_attrs.get(attr) {
115            Some(summary) => {
116                // Attribute is unique if it has high cardinality (many distinct values)
117                // or is not always present
118                if summary.distinct_values > 1 {
119                    unique_attrs.insert(attr.clone(), value.clone());
120                }
121            }
122            None => {
123                // Attribute not in pattern at all — definitely unique
124                unique_attrs.insert(attr.clone(), value.clone());
125            }
126        }
127    }
128
129    Delta {
130        binding: annotation.binding.clone(),
131        unique_attrs,
132        file: annotation.file.clone(),
133    }
134}
135
136/// Extract the pattern and compute deltas for every instance of a tag.
137#[must_use = "pattern and deltas contain per-instance analysis"]
138pub fn pattern_with_deltas(
139    store: &AnnotationStore,
140    tag: &TagName,
141) -> Option<(Pattern, Vec<Delta>)> {
142    let pattern = extract_pattern(store, tag)?;
143    let annotations = collect_by_tag(store, tag);
144    let deltas = annotations
145        .iter()
146        .map(|ann| diff_from_pattern(&pattern, ann))
147        .collect();
148    Some((pattern, deltas))
149}
150
151/// Collect all annotations matching a tag (flat, including children).
152fn collect_by_tag<'a>(store: &'a AnnotationStore, tag: &TagName) -> Vec<&'a Annotation> {
153    let all = store.get_all_annotations();
154    all.iter()
155        .flat_map(|ann| collect_recursive(ann, tag))
156        .collect()
157}
158
159fn collect_recursive<'a>(ann: &'a Annotation, tag: &TagName) -> Vec<&'a Annotation> {
160    let mut out = Vec::new();
161    if ann.tag == *tag {
162        out.push(ann);
163    }
164    for child in &ann.children {
165        out.extend(collect_recursive(child, tag));
166    }
167    out
168}
169
170/// Build per-attribute summaries across all annotations.
171fn build_attr_summaries(annotations: &[&Annotation]) -> FxHashMap<AttrName, AttrSummary> {
172    let total = annotations.len();
173
174    // Collect all observed attribute names
175    let mut attr_names: FxHashSet<AttrName> = FxHashSet::default();
176    for ann in annotations {
177        for key in ann.attrs.keys() {
178            attr_names.insert(key.clone());
179        }
180    }
181
182    let mut summaries = FxHashMap::default();
183
184    for attr in attr_names {
185        let mut present_count = 0usize;
186        let mut distinct: FxHashSet<String> = FxHashSet::default();
187        let mut samples: Vec<JsonValue> = Vec::new();
188
189        for ann in annotations {
190            if let Some(value) = ann.attrs.get(&attr) {
191                present_count += 1;
192                let serialized = value.to_string();
193                if distinct.insert(serialized) && samples.len() < MAX_SAMPLE_VALUES {
194                    samples.push(value.clone());
195                }
196            }
197        }
198
199        summaries.insert(
200            attr,
201            AttrSummary {
202                always_present: present_count == total,
203                distinct_values: distinct.len(),
204                sample_values: samples,
205            },
206        );
207    }
208
209    summaries
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::path::Path;
216
217    fn make_annotation(
218        tag: &str,
219        binding: &str,
220        file: &str,
221        attrs: Vec<(&str, &str)>,
222    ) -> Annotation {
223        let mut attr_map = FxHashMap::default();
224        for (k, v) in attrs {
225            attr_map.insert(AttrName::from(k), JsonValue::String(v.to_string()));
226        }
227        Annotation {
228            tag: TagName::from(tag),
229            attrs: attr_map,
230            binding: Binding::from(binding),
231            file: RelativePath::from(file),
232            children: vec![],
233        }
234    }
235
236    fn test_store() -> AnnotationStore {
237        let mut store = AnnotationStore::new(Path::new("/project"));
238        store.inject_test_data(
239            &RelativePath::from("src/routes/users.ts"),
240            vec![make_annotation(
241                "route",
242                "getUsers",
243                "src/routes/users.ts",
244                vec![("method", "GET"), ("path", "/users")],
245            )],
246        );
247        store.inject_test_data(
248            &RelativePath::from("src/routes/posts.ts"),
249            vec![make_annotation(
250                "route",
251                "getPosts",
252                "src/routes/posts.ts",
253                vec![("method", "GET"), ("path", "/posts")],
254            )],
255        );
256        store.inject_test_data(
257            &RelativePath::from("src/routes/admin.ts"),
258            vec![make_annotation(
259                "route",
260                "createUser",
261                "src/routes/admin.ts",
262                vec![("method", "POST"), ("path", "/admin/users")],
263            )],
264        );
265        store
266    }
267
268    #[test]
269    fn extract_pattern_finds_common_attrs() {
270        // Arrange
271        let store = test_store();
272        let tag = TagName::from("route");
273
274        // Act
275        let pattern = extract_pattern(&store, &tag).unwrap();
276
277        // Assert
278        assert_eq!(pattern.instance_count, 3, "should find 3 route annotations");
279        assert_eq!(pattern.tag, "route", "tag should match");
280
281        let method_summary = pattern.common_attrs.get(&AttrName::from("method")).unwrap();
282        assert!(
283            method_summary.always_present,
284            "method should be always present"
285        );
286        assert_eq!(
287            method_summary.distinct_values, 2,
288            "GET and POST are distinct"
289        );
290
291        let path_summary = pattern.common_attrs.get(&AttrName::from("path")).unwrap();
292        assert!(path_summary.always_present, "path should be always present");
293        assert_eq!(path_summary.distinct_values, 3, "all paths are distinct");
294    }
295
296    #[test]
297    fn diff_from_pattern_identifies_unique_attrs() {
298        // Arrange
299        let store = test_store();
300        let tag = TagName::from("route");
301        let pattern = extract_pattern(&store, &tag).unwrap();
302        let ann = make_annotation(
303            "route",
304            "getUsers",
305            "src/routes/users.ts",
306            vec![("method", "GET"), ("path", "/users")],
307        );
308
309        // Act
310        let delta = diff_from_pattern(&pattern, &ann);
311
312        // Assert
313        assert_eq!(delta.binding, "getUsers", "binding should match");
314        assert!(
315            delta.unique_attrs.contains_key(&AttrName::from("method")),
316            "method has >1 distinct value, so it is unique per instance"
317        );
318        assert!(
319            delta.unique_attrs.contains_key(&AttrName::from("path")),
320            "path has >1 distinct value, so it is unique per instance"
321        );
322    }
323
324    #[test]
325    fn pattern_with_deltas_returns_all() {
326        // Arrange
327        let store = test_store();
328        let tag = TagName::from("route");
329
330        // Act
331        let (pattern, deltas) = pattern_with_deltas(&store, &tag).unwrap();
332
333        // Assert
334        assert_eq!(pattern.instance_count, 3, "should have 3 instances");
335        assert_eq!(deltas.len(), 3, "should have 3 deltas");
336    }
337
338    #[test]
339    fn missing_tag_returns_none() {
340        // Arrange
341        let store = test_store();
342        let tag = TagName::from("nonexistent");
343
344        // Act
345        let result = extract_pattern(&store, &tag);
346
347        // Assert
348        assert!(result.is_none(), "should return None for unknown tag");
349    }
350
351    #[test]
352    fn constant_attr_excluded_from_delta() {
353        // Arrange — all annotations share the same method
354        let mut store = AnnotationStore::new(Path::new("/project"));
355        store.inject_test_data(
356            &RelativePath::from("src/a.ts"),
357            vec![make_annotation(
358                "hook",
359                "useA",
360                "src/a.ts",
361                vec![("type", "effect")],
362            )],
363        );
364        store.inject_test_data(
365            &RelativePath::from("src/b.ts"),
366            vec![make_annotation(
367                "hook",
368                "useB",
369                "src/b.ts",
370                vec![("type", "effect")],
371            )],
372        );
373        let tag = TagName::from("hook");
374        let pattern = extract_pattern(&store, &tag).unwrap();
375        let ann = make_annotation("hook", "useA", "src/a.ts", vec![("type", "effect")]);
376
377        // Act
378        let delta = diff_from_pattern(&pattern, &ann);
379
380        // Assert
381        assert!(
382            delta.unique_attrs.is_empty(),
383            "constant attr should not appear in delta"
384        );
385    }
386}