Skip to main content

slop_detector/
lib.rs

1//! Deterministic pattern detector for inbound received text.
2//!
3//! Text in, evidence report out. The library performs no I/O. `analyze` is a
4//! pure function of the input text. It reports pattern occurrences with cited
5//! spans. It attaches no judgment or score. A coupled agent skill interprets
6//! the report.
7//!
8//! `analyze_bundle` is the multi-document entry point. Per-file reports plus
9//! cross-file verbatim-duplication evidence for a set of related documents
10//! (deck variants, shared report copies). Each per-file report is identical
11//! to what `analyze` returns for that text alone.
12
13mod container;
14pub mod data;
15mod duplication;
16pub mod engine;
17pub mod report;
18
19pub use report::{
20    BundleFile, BundleReport, ClassDensity, Container, CrossFileDuplication, CrossFileOccurrence,
21    Densities, EvidenceReport, Finding, Stats,
22};
23
24/// Snippet cap in bytes. The span cites the full match. A longer match's
25/// snippet carries the first cap-aligned bytes.
26const SNIPPET_CAP: usize = 200;
27
28/// Density floor in words. Below it the per-1k figures are omitted: short
29/// texts quantize, and one register word in a 30-word note produces a huge
30/// number that means nothing.
31const DENSITY_FLOOR_WORDS: usize = 100;
32
33/// Caller-supplied reading context. Nothing here changes what fires. The
34/// options only label findings and shift the residual density figures,
35/// which always print beside the raw ones.
36#[derive(Debug, Clone, Default)]
37pub struct AnalyzeOptions {
38    /// Topic-vocabulary allowlist (the CLI's repeatable `--allow-term`):
39    /// Per-run, human-supplied context (`this paper is about flourishing`),
40    /// never shipped data. A finding whose matched text equals a term
41    /// (case-insensitive, whole-term) is labeled `topic_term` and leaves
42    /// the residual densities while staying in the raw ones.
43    pub allow_terms: Vec<String>,
44}
45
46/// Analyze one input text and return the evidence report.
47///
48/// Findings route to the report categories by each rule's `category` field
49/// in `data/inbound/inbound.toml`. Output is deterministic: same input,
50/// byte-identical report. Processes every input without panicking.
51pub fn analyze(text: &str) -> EvidenceReport {
52    analyze_with(text, &AnalyzeOptions::default())
53}
54
55/// `analyze` with caller-supplied reading context. `analyze(text)` is
56/// exactly `analyze_with(text, &AnalyzeOptions::default())`.
57pub fn analyze_with(text: &str, opts: &AnalyzeOptions) -> EvidenceReport {
58    let cp = engine::compiled();
59    let mut hits = engine::scan_all(cp, text);
60    hits.sort_by(|a, b| {
61        (a.span.start, a.span.end, cp.rules[a.rule].id.as_str()).cmp(&(
62            b.span.start,
63            b.span.end,
64            cp.rules[b.rule].id.as_str(),
65        ))
66    });
67
68    let containers = container::Containers::scan(text);
69    let allow: Vec<String> = opts.allow_terms.iter().map(|t| t.to_lowercase()).collect();
70
71    let mut report = EvidenceReport {
72        stats: report::Stats {
73            word_count: count_words(text),
74            byte_len: text.len(),
75            densities: report::Densities::default(),
76        },
77        ..EvidenceReport::default()
78    };
79    for hit in hits {
80        let rule = &cp.rules[hit.rule];
81        let span = widen_to_char_boundaries(text, hit.span);
82        if span.start >= span.end {
83            continue;
84        }
85        let (snippet, snippet_truncated) = snippet_of(text, &span);
86        let finding = Finding {
87            rule_id: rule.id.clone(),
88            span: (span.start, span.end),
89            snippet,
90            snippet_truncated,
91            container: containers.classify(span.start),
92            topic_term: !allow.is_empty() && allow.contains(&text[span.clone()].to_lowercase()),
93        };
94        // Same rule at the same span reports once.
95        fn bucket_of(report: &mut EvidenceReport, category: data::Category) -> &mut Vec<Finding> {
96            match category {
97                data::Category::PasteResidue => &mut report.paste_residue,
98                data::Category::QualityPatterns => &mut report.quality_patterns,
99                data::Category::Injection => &mut report.injection_patterns,
100            }
101        }
102        if bucket_of(&mut report, rule.category)
103            .last()
104            .map(|f| f.rule_id == finding.rule_id && f.span == finding.span)
105            .unwrap_or(false)
106        {
107            continue;
108        }
109        if rule.category == data::Category::QualityPatterns {
110            let class = rule.class.expect("quality rules carry a class (loader)");
111            let slot = match class {
112                data::Class::Spike => &mut report.stats.densities.spike,
113                data::Class::Background => &mut report.stats.densities.background,
114                data::Class::Individual => &mut report.stats.densities.individual,
115            };
116            slot.hits += 1;
117            if finding.container == Container::Prose && !finding.topic_term {
118                slot.residual_hits += 1;
119            }
120        }
121        bucket_of(&mut report, rule.category).push(finding);
122    }
123    finish_densities(&mut report.stats);
124    report
125}
126
127/// Fill the per-1k figures once the counts are final. Below the density
128/// floor the rates stay `None`: the counts are still reported, the rate is
129/// not pretended.
130fn finish_densities(stats: &mut report::Stats) {
131    let words = stats.word_count;
132    let d = &mut stats.densities;
133    for slot in [&mut d.spike, &mut d.background, &mut d.individual] {
134        if words >= DENSITY_FLOOR_WORDS {
135            slot.per_1k_words = Some(slot.hits as f64 * 1000.0 / words as f64);
136            slot.residual_per_1k_words = Some(slot.residual_hits as f64 * 1000.0 / words as f64);
137        }
138    }
139}
140
141/// Analyze a set of related documents: `(path, text)` pairs. Each file gets
142/// its own full `analyze` report. On top of them, one cross-file
143/// duplication pass (the SD-Q005 shingle machinery with the same order,
144/// floor, and cap) reports verbatim runs shared BETWEEN files. Within-file
145/// repeats stay in each file's own SD-Q005 findings and never appear at
146/// bundle level.
147pub fn analyze_bundle(docs: &[(String, String)]) -> BundleReport {
148    analyze_bundle_with(docs, &AnalyzeOptions::default())
149}
150
151/// `analyze_bundle` with caller-supplied reading context, applied to every
152/// per-file report.
153pub fn analyze_bundle_with(docs: &[(String, String)], opts: &AnalyzeOptions) -> BundleReport {
154    let files = docs
155        .iter()
156        .map(|(path, text)| BundleFile {
157            path: path.clone(),
158            report: analyze_with(text, opts),
159        })
160        .collect();
161    BundleReport {
162        files,
163        cross_file_duplication: cross_file_duplication(docs),
164    }
165}
166
167/// The cross-file pass. All files share one token table and one shingle
168/// map. The segment id bumps at every file boundary, so a run can never
169/// fuse across two files. Fenced content participates like everything
170/// else and each occurrence carries its container annotation. Grouping
171/// happens BEFORE the emission cap: runs that share an anchor (same
172/// earlier copy, same length) fold into one entry whose occurrences list
173/// the anchor and every later copy in file order, and the cap then keeps
174/// the longest entries. A passage shared by many files never silently
175/// drops later occurrences.
176fn cross_file_duplication(docs: &[(String, String)]) -> Vec<CrossFileDuplication> {
177    let cp = engine::compiled();
178    let Some(dr) = cp.duplication_rules.first() else {
179        return Vec::new();
180    };
181    let mut tokens = duplication::Tokens::new();
182    for (file, (_, text)) in docs.iter().enumerate() {
183        // A run never spans a file boundary. One segment per file.
184        duplication::tokenize_into(&mut tokens, text, file as u32, file as u32);
185    }
186    let runs = duplication::find_runs(&tokens, dr.shingle_words, dr.min_run_words, true);
187    // Group by anchor: (earlier index, length) identifies one duplicated
188    // text. Every later copy is an occurrence of it. Scan order is file
189    // order, which grouping preserves.
190    let mut entries: Vec<((usize, usize), Vec<usize>)> = Vec::new();
191    for run in &runs {
192        let key = (run.earlier, run.len);
193        match entries.iter_mut().find(|(k, _)| *k == key) {
194            Some((_, laters)) => laters.push(run.later),
195            None => entries.push((key, vec![run.later])),
196        }
197    }
198    // The cap applies to whole entries, longest first, anchor position as
199    // the deterministic tiebreak. An entry that survives keeps ALL its
200    // occurrences.
201    entries.sort_by_key(|&((earlier, len), _)| (std::cmp::Reverse(len), earlier));
202    entries.truncate(dr.max_reports);
203    let containers: Vec<container::Containers> = docs
204        .iter()
205        .map(|(_, text)| container::Containers::scan(text))
206        .collect();
207    // Occurrence construction resolves the owning document by the token's
208    // retained FILE INDEX, never by re-finding the path string: two bundle
209    // entries may share one path label, and a label collision must not
210    // slice one document with another document's span.
211    let occurrence = |tok: usize, len: usize| {
212        let toks = &tokens.toks;
213        let file = toks[tok].file as usize;
214        let text = &docs[file].1;
215        let span = widen_to_char_boundaries(text, toks[tok].start..toks[tok + len - 1].end);
216        let occ = CrossFileOccurrence {
217            path: docs[file].0.clone(),
218            span: (span.start, span.end),
219            container: containers[file].classify(span.start),
220        };
221        (file, occ)
222    };
223    let mut out: Vec<CrossFileDuplication> = entries
224        .into_iter()
225        .map(|((earlier, len), laters)| {
226            let (anchor_file, anchor) = occurrence(earlier, len);
227            let (snippet, snippet_truncated) =
228                snippet_of(&docs[anchor_file].1, &(anchor.span.0..anchor.span.1));
229            let mut occurrences = vec![anchor];
230            occurrences.extend(laters.iter().map(|&l| occurrence(l, len).1));
231            CrossFileDuplication {
232                snippet,
233                snippet_truncated,
234                occurrences,
235            }
236        })
237        .collect();
238    // Deterministic reading order: by first occurrence position.
239    out.sort_by(|a, b| {
240        (&a.occurrences[0].path, a.occurrences[0].span)
241            .cmp(&(&b.occurrences[0].path, b.occurrences[0].span))
242    });
243    out
244}
245
246/// Widen a span outward to character boundaries. Every pass emits
247/// boundary-aligned spans already. This is the defensive floor under the
248/// never-panic contract.
249pub(crate) fn widen_to_char_boundaries(
250    src: &str,
251    mut span: std::ops::Range<usize>,
252) -> std::ops::Range<usize> {
253    span.start = span.start.min(src.len());
254    span.end = span.end.min(src.len());
255    while span.start > 0 && !src.is_char_boundary(span.start) {
256        span.start -= 1;
257    }
258    while span.end < src.len() && !src.is_char_boundary(span.end) {
259        span.end += 1;
260    }
261    span
262}
263
264/// The snippet is the source slice at the span, verbatim. A slice over
265/// `SNIPPET_CAP` bytes is capped on a character boundary and flagged
266/// truncated. The span still covers the whole occurrence.
267fn snippet_of(src: &str, span: &std::ops::Range<usize>) -> (String, bool) {
268    let slice = &src[span.clone()];
269    if slice.len() <= SNIPPET_CAP {
270        return (slice.to_string(), false);
271    }
272    let mut end = SNIPPET_CAP;
273    while !slice.is_char_boundary(end) {
274        end -= 1;
275    }
276    (slice[..end].to_string(), true)
277}
278
279/// Count maximal `is_xid_continue` runs, the same word counter ai-slop uses.
280fn count_words(s: &str) -> usize {
281    let mut n = 0usize;
282    let mut in_word = false;
283    for c in s.chars() {
284        let w = unicode_ident::is_xid_continue(c);
285        if w && !in_word {
286            n += 1;
287        }
288        in_word = w;
289    }
290    n
291}