Skip to main content

face_core/
strategy.rs

1//! Grouping axes and per-axis strategies (§5 and §7's `result.axes[]`).
2//!
3//! [`Axis`] describes one level of grouping: a field path, the strategy
4//! applied to that field, and whether the strategy was auto-picked.
5//! [`Strategy`] is the tagged enum of strategies the public surface
6//! recognizes today, including the explicit algorithmic similarity
7//! strategies from §5.4.
8
9use serde::{Deserialize, Serialize};
10
11/// One axis of grouping (§5, §7 `result.axes[]`).
12///
13/// Serializes with the strategy tag and payload flattened directly into
14/// the axis JSON object so the wire form matches §7's example:
15/// `{ "field": "data.path.text", "strategy": "exact", "auto": true }`.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[non_exhaustive]
18pub struct Axis {
19    /// Field path being grouped on (e.g. `data.path.text`).
20    pub field: String,
21    /// Strategy applied to this axis.
22    #[serde(flatten)]
23    pub strategy: Strategy,
24    /// `true` when the strategy was auto-picked rather than user-supplied.
25    pub auto: bool,
26}
27
28/// Strategy applied to one axis.
29///
30/// # Examples
31///
32/// ```
33/// use face_core::Strategy;
34///
35/// assert!(Strategy::Quantiles { count: 4 }.is_buffered());
36/// assert!(!Strategy::Exact.is_buffered());
37/// ```
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39#[serde(tag = "strategy", rename_all = "lowercase")]
40#[non_exhaustive]
41pub enum Strategy {
42    /// One cluster per distinct value (§5.3).
43    Exact,
44    /// Path / namespace grouping by directory depth (§5.3).
45    Prefix {
46        /// Optional fixed prefix depth; `None` means auto-pick.
47        #[serde(skip_serializing_if = "Option::is_none", default)]
48        depth: Option<u8>,
49    },
50    /// Top-N by frequency plus a synthetic `(other)` cluster (§5.3).
51    Top {
52        /// How many top buckets to keep.
53        n: u32,
54    },
55    /// Equal-width bands over the numeric range (§5.2).
56    Bands {
57        /// Number of bands.
58        count: u8,
59    },
60    /// Equal-population quantile buckets (§5.2). Buffered (§12).
61    Quantiles {
62        /// Number of quantile buckets.
63        count: u8,
64    },
65    /// One-dimensional Jenks natural breaks (§5.2). Buffered (§12).
66    Natural {
67        /// Number of natural-break buckets.
68        count: u8,
69    },
70    /// Algorithmic free-form string clustering (§5.4). Buffered.
71    Similar {
72        /// Similarity algorithm to use.
73        algorithm: SimilarAlgorithm,
74    },
75}
76
77/// Algorithm used by [`Strategy::Similar`].
78#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
79#[serde(rename_all = "lowercase")]
80#[non_exhaustive]
81pub enum SimilarAlgorithm {
82    /// Token-set Jaccard similarity.
83    Token,
84    /// Character trigram Jaccard similarity.
85    Ngram,
86    /// Normalized Levenshtein similarity.
87    Edit,
88    /// Token-set locality-sensitive bucketing approximation.
89    Lsh,
90    /// Token simhash near-duplicate detection.
91    Simhash,
92}
93
94impl SimilarAlgorithm {
95    /// Parse a CLI payload such as `token` or `simhash`.
96    pub fn from_name(name: &str) -> Option<Self> {
97        match name.to_ascii_lowercase().as_str() {
98            "token" => Some(Self::Token),
99            "ngram" => Some(Self::Ngram),
100            "edit" => Some(Self::Edit),
101            "lsh" => Some(Self::Lsh),
102            "simhash" => Some(Self::Simhash),
103            _ => None,
104        }
105    }
106
107    /// Lowercase wire-form name.
108    pub fn name(self) -> &'static str {
109        match self {
110            Self::Token => "token",
111            Self::Ngram => "ngram",
112            Self::Edit => "edit",
113            Self::Lsh => "lsh",
114            Self::Simhash => "simhash",
115            #[expect(
116                unreachable_patterns,
117                reason = "guard for `#[non_exhaustive]` variants added in future slices"
118            )]
119            _ => "unknown",
120        }
121    }
122
123    /// Whether `candidate` belongs with `representative` for this
124    /// algorithm. This is intentionally deterministic and local; §5.4
125    /// excludes neural/embedding clustering.
126    pub fn matches(self, candidate: &str, representative: &str) -> bool {
127        let candidate = normalize(candidate);
128        let representative = normalize(representative);
129        if candidate.is_empty() || representative.is_empty() {
130            return candidate == representative;
131        }
132        if candidate == representative {
133            return true;
134        }
135        match self {
136            Self::Token => token_jaccard(&candidate, &representative) >= 0.5,
137            Self::Ngram => ngram_jaccard(&candidate, &representative) >= 0.45,
138            Self::Edit => normalized_edit_similarity(&candidate, &representative) >= 0.8,
139            Self::Lsh => token_jaccard(&candidate, &representative) >= 0.5,
140            Self::Simhash => simhash_distance(&candidate, &representative) <= 12,
141            #[expect(
142                unreachable_patterns,
143                reason = "guard for `#[non_exhaustive]` variants added in future slices"
144            )]
145            _ => false,
146        }
147    }
148}
149
150impl Strategy {
151    /// Whether this strategy requires buffering all input before
152    /// emitting clusters (§12 memory model).
153    ///
154    /// `Quantiles` and `Natural` need the full distribution; everything
155    /// else can stream.
156    pub fn is_buffered(&self) -> bool {
157        matches!(
158            self,
159            Strategy::Quantiles { .. } | Strategy::Natural { .. } | Strategy::Similar { .. }
160        )
161    }
162
163    /// Lowercase wire-form name of this strategy, matching the serde
164    /// tag used in the §7 envelope (`exact`, `prefix`, `top`, `bands`,
165    /// `quantiles`, `natural`).
166    ///
167    /// `Strategy` is `#[non_exhaustive]`; future variants added before
168    /// their dispatch arms land here will return `"unknown"` until the
169    /// arm is filled in.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use face_core::Strategy;
175    ///
176    /// assert_eq!(Strategy::Exact.name(), "exact");
177    /// assert_eq!(Strategy::Bands { count: 5 }.name(), "bands");
178    /// ```
179    pub fn name(&self) -> &'static str {
180        match self {
181            Strategy::Exact => "exact",
182            Strategy::Prefix { .. } => "prefix",
183            Strategy::Top { .. } => "top",
184            Strategy::Bands { .. } => "bands",
185            Strategy::Quantiles { .. } => "quantiles",
186            Strategy::Natural { .. } => "natural",
187            Strategy::Similar { .. } => "similar",
188            // `#[non_exhaustive]` guard for variants added in future
189            // slices.
190            #[expect(
191                unreachable_patterns,
192                reason = "guard for `#[non_exhaustive]` variants added in future slices"
193            )]
194            _ => "unknown",
195        }
196    }
197}
198
199fn normalize(value: &str) -> String {
200    value
201        .chars()
202        .map(|ch| {
203            if ch.is_alphanumeric() {
204                ch.to_ascii_lowercase()
205            } else {
206                ' '
207            }
208        })
209        .collect::<String>()
210        .split_whitespace()
211        .collect::<Vec<_>>()
212        .join(" ")
213}
214
215fn tokens(value: &str) -> std::collections::BTreeSet<&str> {
216    value.split_whitespace().collect()
217}
218
219fn token_jaccard(a: &str, b: &str) -> f64 {
220    let a = tokens(a);
221    let b = tokens(b);
222    jaccard(&a, &b)
223}
224
225fn ngrams(value: &str) -> std::collections::BTreeSet<String> {
226    let chars = value.chars().collect::<Vec<_>>();
227    if chars.len() <= 3 {
228        return std::iter::once(value.to_string()).collect();
229    }
230    chars.windows(3).map(|w| w.iter().collect()).collect()
231}
232
233fn ngram_jaccard(a: &str, b: &str) -> f64 {
234    let a = ngrams(a);
235    let b = ngrams(b);
236    jaccard(&a, &b)
237}
238
239fn jaccard<T: Ord>(a: &std::collections::BTreeSet<T>, b: &std::collections::BTreeSet<T>) -> f64 {
240    if a.is_empty() && b.is_empty() {
241        return 1.0;
242    }
243    let intersection = a.intersection(b).count() as f64;
244    let union = a.union(b).count() as f64;
245    intersection / union
246}
247
248fn normalized_edit_similarity(a: &str, b: &str) -> f64 {
249    let a_chars = a.chars().collect::<Vec<_>>();
250    let b_chars = b.chars().collect::<Vec<_>>();
251    let max_len = a_chars.len().max(b_chars.len());
252    if max_len == 0 {
253        return 1.0;
254    }
255    let distance = levenshtein(&a_chars, &b_chars);
256    1.0 - (distance as f64 / max_len as f64)
257}
258
259fn levenshtein(a: &[char], b: &[char]) -> usize {
260    let mut prev = (0..=b.len()).collect::<Vec<_>>();
261    let mut curr = vec![0usize; b.len() + 1];
262    for (i, ca) in a.iter().enumerate() {
263        curr[0] = i + 1;
264        for (j, cb) in b.iter().enumerate() {
265            let cost = usize::from(ca != cb);
266            curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
267        }
268        std::mem::swap(&mut prev, &mut curr);
269    }
270    prev[b.len()]
271}
272
273fn simhash_distance(a: &str, b: &str) -> u32 {
274    (simhash(a) ^ simhash(b)).count_ones()
275}
276
277fn simhash(value: &str) -> u64 {
278    let mut weights = [0i32; 64];
279    for token in value.split_whitespace() {
280        let hash = stable_hash(token);
281        for (bit, weight) in weights.iter_mut().enumerate() {
282            if (hash >> bit) & 1 == 1 {
283                *weight += 1;
284            } else {
285                *weight -= 1;
286            }
287        }
288    }
289    let mut out = 0u64;
290    for (bit, weight) in weights.iter().enumerate() {
291        if *weight >= 0 {
292            out |= 1u64 << bit;
293        }
294    }
295    out
296}
297
298fn stable_hash(value: &str) -> u64 {
299    let mut hash = 0xcbf29ce484222325u64;
300    for byte in value.bytes() {
301        hash ^= u64::from(byte);
302        hash = hash.wrapping_mul(0x100000001b3);
303    }
304    hash
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn exact_serializes_to_strategy_tag_only() {
313        let s = serde_json::to_value(Strategy::Exact).unwrap();
314        assert_eq!(s, serde_json::json!({ "strategy": "exact" }));
315    }
316
317    #[test]
318    fn bands_serializes_with_count() {
319        let s = serde_json::to_value(Strategy::Bands { count: 5 }).unwrap();
320        assert_eq!(s, serde_json::json!({ "strategy": "bands", "count": 5 }));
321    }
322
323    #[test]
324    fn similar_serializes_with_algorithm() {
325        let s = serde_json::to_value(Strategy::Similar {
326            algorithm: SimilarAlgorithm::Token,
327        })
328        .unwrap();
329        assert_eq!(
330            s,
331            serde_json::json!({ "strategy": "similar", "algorithm": "token" })
332        );
333    }
334
335    #[test]
336    fn quantiles_is_buffered() {
337        assert!(Strategy::Quantiles { count: 4 }.is_buffered());
338        assert!(
339            Strategy::Similar {
340                algorithm: SimilarAlgorithm::Token
341            }
342            .is_buffered()
343        );
344        assert!(!Strategy::Exact.is_buffered());
345    }
346
347    #[test]
348    fn axis_round_trip_matches_design_example() {
349        let axis = Axis {
350            field: "data.path.text".into(),
351            strategy: Strategy::Exact,
352            auto: true,
353        };
354        let v = serde_json::to_value(&axis).unwrap();
355        assert_eq!(
356            v,
357            serde_json::json!({
358                "field": "data.path.text",
359                "strategy": "exact",
360                "auto": true,
361            })
362        );
363        let back: Axis = serde_json::from_value(v).unwrap();
364        assert_eq!(back, axis);
365    }
366
367    #[test]
368    fn token_similarity_matches_reordered_terms() {
369        assert!(SimilarAlgorithm::Token.matches("fix login bug", "login bug fix"));
370        assert!(!SimilarAlgorithm::Token.matches("fix login bug", "render chart"));
371    }
372}