Skip to main content

ai_session_search/
analysis_pipeline.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure, provider-neutral classification and relationship analysis.
5//!
6//! This module consumes [`AnalysisDocument`] values from the
7//! indexed service boundary. It does not discover providers, read session files, query SQLite,
8//! or publish artifacts. Keeping policy pure makes it reusable from Rust, Python, CLI, and MCP
9//! adapters without duplicating scanning or lifecycle behavior.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::num::NonZeroUsize;
13
14use anyhow::{anyhow, bail, Context, Result};
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17
18use crate::hashing::FramedSha256;
19use crate::models::{AnalysisDocument, SessionRecord};
20
21/// Document field inspected by one classification rule.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ClassificationTarget {
25    Title,
26    Summary,
27    FirstUserText,
28    UserText,
29    /// Test metadata fields first, followed by the selected session's user text.
30    Any,
31}
32
33/// Serializable input for one classification rule.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct ClassificationRuleSpec {
37    pub dimension: String,
38    pub label: String,
39    pub target: ClassificationTarget,
40    pub pattern: String,
41    pub weight: i64,
42}
43
44/// Semantic meaning of an explicit, name-derived relationship hint.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum RelationshipKind {
48    Branch,
49    Copy,
50    Version,
51}
52
53/// Serializable relationship rule. `pattern` must contain a named `parent` capture.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct RelationshipRuleSpec {
57    pub id: String,
58    pub kind: RelationshipKind,
59    pub pattern: String,
60}
61
62/// Validated, serialized policy for optional recurring-phrase aggregation.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct PhraseVocabularySpec {
65    widths: BTreeSet<NonZeroUsize>,
66    max_unique_phrases: NonZeroUsize,
67    min_document_tokens: usize,
68    excluded_tokens: BTreeSet<String>,
69    exclude_numeric_tokens: bool,
70    text_mode: PhraseTextMode,
71}
72
73/// Selects which normalized user content contributes to recurring phrases.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum PhraseTextMode {
77    /// Analyze all normalized user text, including code and configuration snippets.
78    #[default]
79    UserText,
80    /// Ignore fenced, indented, and structurally code-like lines before tokenization.
81    ProseOnly,
82}
83
84/// Serializable, uncompiled recurring-phrase policy.
85///
86/// This transport type keeps JSON/TOML, CLI, MCP, and language bindings independent from the
87/// validated [`PhraseVocabularySpec`] representation. Call [`Self::compile`] once at an adapter
88/// boundary, then reuse the compiled policy for every document.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct PhraseVocabularyPolicySpec {
92    pub widths: Vec<usize>,
93    pub max_unique_phrases: usize,
94    #[serde(default)]
95    pub min_document_tokens: usize,
96    #[serde(default)]
97    pub excluded_tokens: Vec<String>,
98    #[serde(default = "default_true")]
99    pub exclude_numeric_tokens: bool,
100    #[serde(default)]
101    pub text_mode: PhraseTextMode,
102}
103
104impl PhraseVocabularyPolicySpec {
105    /// Validate and compile this transport representation.
106    pub fn compile(&self) -> Result<PhraseVocabularySpec> {
107        let widths = self
108            .widths
109            .iter()
110            .copied()
111            .map(|width| {
112                NonZeroUsize::new(width)
113                    .ok_or_else(|| anyhow!("phrase widths must be greater than zero"))
114            })
115            .collect::<Result<Vec<_>>>()?;
116        let max_unique_phrases = NonZeroUsize::new(self.max_unique_phrases)
117            .ok_or_else(|| anyhow!("max_unique_phrases must be greater than zero"))?;
118        PhraseVocabularySpec::new(
119            widths,
120            max_unique_phrases,
121            self.min_document_tokens,
122            self.excluded_tokens.clone(),
123            self.exclude_numeric_tokens,
124        )
125        .map(|spec| spec.with_text_mode(self.text_mode))
126    }
127}
128
129fn default_true() -> bool {
130    true
131}
132
133impl PhraseVocabularySpec {
134    pub fn new(
135        widths: impl IntoIterator<Item = NonZeroUsize>,
136        max_unique_phrases: NonZeroUsize,
137        min_document_tokens: usize,
138        excluded_tokens: impl IntoIterator<Item = String>,
139        exclude_numeric_tokens: bool,
140    ) -> Result<Self> {
141        let widths = widths.into_iter().collect::<BTreeSet<_>>();
142        if widths.is_empty() {
143            bail!("phrase vocabulary must contain at least one n-gram width");
144        }
145        let mut normalized_exclusions = BTreeSet::new();
146        for token in excluded_tokens {
147            let normalized = crate::analytics::normalized_tokens(&token);
148            if normalized.len() != 1 {
149                bail!("excluded phrase token '{token}' must normalize to exactly one token");
150            }
151            let normalized = normalized
152                .into_iter()
153                .next()
154                .ok_or_else(|| anyhow!("excluded phrase token '{token}' normalized to nothing"))?;
155            normalized_exclusions.insert(normalized);
156        }
157        Ok(Self {
158            widths,
159            max_unique_phrases,
160            min_document_tokens,
161            excluded_tokens: normalized_exclusions,
162            exclude_numeric_tokens,
163            text_mode: PhraseTextMode::UserText,
164        })
165    }
166
167    pub fn with_text_mode(mut self, text_mode: PhraseTextMode) -> Self {
168        self.text_mode = text_mode;
169        self
170    }
171
172    pub fn widths(&self) -> impl ExactSizeIterator<Item = NonZeroUsize> + '_ {
173        self.widths.iter().copied()
174    }
175
176    pub const fn max_unique_phrases(&self) -> NonZeroUsize {
177        self.max_unique_phrases
178    }
179
180    pub const fn min_document_tokens(&self) -> usize {
181        self.min_document_tokens
182    }
183
184    pub fn excluded_tokens(&self) -> impl ExactSizeIterator<Item = &str> {
185        self.excluded_tokens.iter().map(String::as_str)
186    }
187
188    pub const fn exclude_numeric_tokens(&self) -> bool {
189        self.exclude_numeric_tokens
190    }
191
192    pub const fn text_mode(&self) -> PhraseTextMode {
193        self.text_mode
194    }
195}
196
197#[derive(Debug, Clone)]
198struct ClassificationRule {
199    spec: ClassificationRuleSpec,
200    regex: Regex,
201}
202
203#[derive(Debug, Clone)]
204struct RelationshipRule {
205    spec: RelationshipRuleSpec,
206    regex: Regex,
207}
208
209/// Validated, compiled analysis policy.
210///
211/// Construction rejects empty identifiers, duplicate semantic keys, invalid or empty-matching
212/// regexes, and relationship expressions without a named `parent` capture. Regex compilation is
213/// therefore paid once rather than once per document.
214#[derive(Debug, Clone)]
215pub struct AnalysisPolicy {
216    classifications: Vec<ClassificationRule>,
217    relationships: Vec<RelationshipRule>,
218    phrase_vocabulary: Option<PhraseVocabularySpec>,
219    max_classification_chars: Option<NonZeroUsize>,
220}
221
222/// Serializable, provider-neutral input for one compiled analysis run.
223#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(default, deny_unknown_fields)]
225pub struct AnalysisPolicySpec {
226    pub classification_rules: Vec<ClassificationRuleSpec>,
227    pub relationship_rules: Vec<RelationshipRuleSpec>,
228    pub phrase_vocabulary: Option<PhraseVocabularyPolicySpec>,
229    pub max_classification_chars: Option<usize>,
230}
231
232impl AnalysisPolicySpec {
233    /// Validate regexes, semantic identities, phrase bounds, and optional text bounds once.
234    pub fn compile(&self) -> Result<AnalysisPolicy> {
235        let mut policy = AnalysisPolicy::compile(
236            self.classification_rules.clone(),
237            self.relationship_rules.clone(),
238        )?;
239        if let Some(phrase_vocabulary) = &self.phrase_vocabulary {
240            policy = policy.with_phrase_vocabulary(phrase_vocabulary.compile()?);
241        }
242        if let Some(max_chars) = self.max_classification_chars {
243            let max_chars = NonZeroUsize::new(max_chars)
244                .ok_or_else(|| anyhow!("max_classification_chars must be greater than zero"))?;
245            policy = policy.with_max_classification_chars(max_chars);
246        }
247        Ok(policy)
248    }
249}
250
251impl AnalysisPolicy {
252    pub fn compile(
253        classification_specs: Vec<ClassificationRuleSpec>,
254        relationship_specs: Vec<RelationshipRuleSpec>,
255    ) -> Result<Self> {
256        let mut classification_keys = BTreeSet::new();
257        let mut classifications = Vec::with_capacity(classification_specs.len());
258        for spec in classification_specs {
259            require_name("classification dimension", &spec.dimension)?;
260            require_name("classification label", &spec.label)?;
261            if !classification_keys.insert((spec.dimension.clone(), spec.label.clone())) {
262                bail!(
263                    "duplicate classification rule for dimension '{}' and label '{}'",
264                    spec.dimension,
265                    spec.label
266                );
267            }
268            let regex = compile_nonempty_regex("classification", &spec.label, &spec.pattern)?;
269            classifications.push(ClassificationRule { spec, regex });
270        }
271        classifications.sort_by(|left, right| {
272            left.spec
273                .dimension
274                .cmp(&right.spec.dimension)
275                .then_with(|| left.spec.label.cmp(&right.spec.label))
276        });
277
278        let mut relationship_ids = BTreeSet::new();
279        let mut relationships = Vec::with_capacity(relationship_specs.len());
280        for spec in relationship_specs {
281            require_name("relationship rule id", &spec.id)?;
282            if !relationship_ids.insert(spec.id.clone()) {
283                bail!("duplicate relationship rule id '{}'", spec.id);
284            }
285            let regex = compile_nonempty_regex("relationship", &spec.id, &spec.pattern)?;
286            if !regex.capture_names().flatten().any(|name| name == "parent") {
287                bail!(
288                    "relationship rule '{}' must define a named 'parent' capture",
289                    spec.id
290                );
291            }
292            relationships.push(RelationshipRule { spec, regex });
293        }
294        relationships.sort_by(|left, right| left.spec.id.cmp(&right.spec.id));
295
296        Ok(Self {
297            classifications,
298            relationships,
299            phrase_vocabulary: None,
300            max_classification_chars: None,
301        })
302    }
303
304    /// Enable recurring-phrase aggregation with explicit memory and token policy.
305    pub fn with_phrase_vocabulary(mut self, spec: PhraseVocabularySpec) -> Self {
306        self.phrase_vocabulary = Some(spec);
307        self
308    }
309
310    pub const fn phrase_vocabulary(&self) -> Option<&PhraseVocabularySpec> {
311        self.phrase_vocabulary.as_ref()
312    }
313
314    /// Bound user-text characters examined by each classification rule.
315    ///
316    /// Session metadata remains unbounded because it is already bounded at ingestion.
317    pub fn with_max_classification_chars(mut self, max_chars: NonZeroUsize) -> Self {
318        self.max_classification_chars = Some(max_chars);
319        self
320    }
321
322    pub const fn max_classification_chars(&self) -> Option<NonZeroUsize> {
323        self.max_classification_chars
324    }
325
326    pub fn classification_specs(&self) -> impl ExactSizeIterator<Item = &ClassificationRuleSpec> {
327        self.classifications.iter().map(|rule| &rule.spec)
328    }
329
330    pub fn relationship_specs(&self) -> impl ExactSizeIterator<Item = &RelationshipRuleSpec> {
331        self.relationships.iter().map(|rule| &rule.spec)
332    }
333
334    /// Digest normalized executable policy semantics independently of source encoding and corpus.
335    ///
336    /// Rules are already sorted during compilation, so semantically equivalent input orderings
337    /// share one identity. Runtime is linear in normalized policy bytes with `O(1)` hash state.
338    pub fn policy_digest(&self) -> String {
339        let mut digest = FramedSha256::new(b"aise-analysis-policy-v1");
340        digest.update_u64(self.classifications.len() as u64);
341        for rule in &self.classifications {
342            digest.update_bytes(rule.spec.dimension.as_bytes());
343            digest.update_bytes(rule.spec.label.as_bytes());
344            digest.update_u8(classification_target_tag(rule.spec.target));
345            digest.update_bytes(rule.spec.pattern.as_bytes());
346            digest.update_i64(rule.spec.weight);
347        }
348        digest.update_u64(self.relationships.len() as u64);
349        for rule in &self.relationships {
350            digest.update_bytes(rule.spec.id.as_bytes());
351            digest.update_u8(relationship_kind_tag(rule.spec.kind));
352            digest.update_bytes(rule.spec.pattern.as_bytes());
353        }
354        if let Some(spec) = &self.phrase_vocabulary {
355            digest.update_u8(1);
356            digest.update_u64(spec.widths.len() as u64);
357            for width in &spec.widths {
358                digest.update_u64(width.get() as u64);
359            }
360            digest.update_u64(spec.max_unique_phrases.get() as u64);
361            digest.update_u64(spec.min_document_tokens as u64);
362            digest.update_u64(spec.excluded_tokens.len() as u64);
363            for token in &spec.excluded_tokens {
364                digest.update_bytes(token.as_bytes());
365            }
366            digest.update_u8(u8::from(spec.exclude_numeric_tokens));
367            digest.update_u8(match spec.text_mode {
368                PhraseTextMode::UserText => 0,
369                PhraseTextMode::ProseOnly => 1,
370            });
371        } else {
372            digest.update_u8(0);
373        }
374        if let Some(max_chars) = self.max_classification_chars {
375            digest.update_u8(1);
376            digest.update_u64(max_chars.get() as u64);
377        } else {
378            digest.update_u8(0);
379        }
380        digest.finish()
381    }
382
383    /// Analyze provider-normalized documents without retaining their aggregate user text.
384    ///
385    /// Results and candidates are sorted by canonical session ID. Runtime is linear in document
386    /// text times the configured rule count; relationship resolution uses an indexed title map
387    /// rather than an all-pairs comparison.
388    pub fn analyze(
389        &self,
390        documents: impl IntoIterator<Item = AnalysisDocument>,
391    ) -> Result<AnalysisResult> {
392        let mut accumulator = self.accumulator();
393        for document in documents {
394            accumulator.push(document)?;
395        }
396        accumulator.finish()
397    }
398
399    /// Start a bounded-memory analysis that accepts documents one at a time.
400    pub fn accumulator(&self) -> AnalysisAccumulator<'_> {
401        AnalysisAccumulator {
402            policy: self,
403            sessions: BTreeMap::new(),
404            titles: BTreeMap::new(),
405            vocabulary: BTreeMap::new(),
406            failed: false,
407        }
408    }
409
410    fn classify(&self, document: &AnalysisDocument) -> Vec<ClassificationMatch> {
411        self.classifications
412            .iter()
413            .filter(|rule| classification_matches(rule, document, self.max_classification_chars))
414            .map(|rule| ClassificationMatch {
415                dimension: rule.spec.dimension.clone(),
416                label: rule.spec.label.clone(),
417                target: rule.spec.target,
418                weight: rule.spec.weight,
419            })
420            .collect()
421    }
422}
423
424/// Streaming pure-policy state. Dropping it discards partial output without side effects.
425pub struct AnalysisAccumulator<'policy> {
426    policy: &'policy AnalysisPolicy,
427    sessions: BTreeMap<String, AnalyzedSession>,
428    titles: BTreeMap<String, Vec<String>>,
429    vocabulary: BTreeMap<String, PhraseFrequency>,
430    failed: bool,
431}
432
433impl AnalysisAccumulator<'_> {
434    /// Consume one normalized document and immediately release its aggregate user text.
435    pub fn push(&mut self, document: AnalysisDocument) -> Result<()> {
436        if self.failed {
437            bail!("analysis accumulator is unusable after a previous error");
438        }
439        if let Err(error) = self.try_push(document) {
440            self.failed = true;
441            return Err(error);
442        }
443        Ok(())
444    }
445
446    /// Consume one session's user text as an ordered stream of per-message chunks.
447    ///
448    /// This is exactly equivalent to pushing an [`AnalysisDocument`] whose `user_text` joins the
449    /// chunks with single spaces and whose `first_user_text` is the first chunk, without
450    /// materializing that joined text. The space joiner participates in classification character
451    /// bounds and prose line reconstruction, and phrase windows span chunk boundaries through
452    /// rolling token state, so results are byte-identical to the document path.
453    ///
454    /// Memory is bounded by the policy's explicit bounds (`max_unique_phrases`,
455    /// `min_document_tokens` × widths, `max_classification_chars`) plus one chunk, except when a
456    /// `user_text`/`any` classification rule runs without `max_classification_chars`: the joined
457    /// text is then retained for that rule, matching the document path.
458    pub fn push_session_text_stream(
459        &mut self,
460        session: SessionRecord,
461        message_count: i64,
462        user_message_count: i64,
463        chunks: impl IntoIterator<Item = Result<String>>,
464    ) -> Result<()> {
465        if self.failed {
466            bail!("analysis accumulator is unusable after a previous error");
467        }
468        if let Err(error) = self.try_push_stream(session, message_count, user_message_count, chunks)
469        {
470            self.failed = true;
471            return Err(error);
472        }
473        Ok(())
474    }
475
476    fn try_push_stream(
477        &mut self,
478        session: SessionRecord,
479        message_count: i64,
480        user_message_count: i64,
481        chunks: impl IntoIterator<Item = Result<String>>,
482    ) -> Result<()> {
483        let session_id = session.id.clone();
484        require_name("canonical session ID", &session_id)?;
485        if self.sessions.contains_key(&session_id) {
486            bail!("duplicate canonical session ID '{session_id}' in analysis input");
487        }
488        let mut state = StreamingDocumentText::new(self.policy);
489        for chunk in chunks {
490            state.push_chunk(&chunk?).with_context(|| {
491                format!("failed to aggregate phrases for session '{session_id}'")
492            })?;
493        }
494        let (text, phrase_delta) = state
495            .finish()
496            .with_context(|| format!("failed to aggregate phrases for session '{session_id}'"))?;
497        let document = AnalysisDocument {
498            session,
499            user_text: text.classification_text,
500            first_user_text: text.first_user_text,
501            message_count,
502            user_message_count,
503        };
504        let classifications = self.policy.classify(&document);
505        self.merge_session(
506            document.session,
507            classifications,
508            phrase_delta,
509            text.has_user_text,
510            message_count,
511            user_message_count,
512        )
513    }
514
515    fn try_push(&mut self, document: AnalysisDocument) -> Result<()> {
516        let session_id = document.session.id.clone();
517        require_name("canonical session ID", &session_id)?;
518        if self.sessions.contains_key(&session_id) {
519            bail!("duplicate canonical session ID '{session_id}' in analysis input");
520        }
521        let classifications = self.policy.classify(&document);
522        let phrase_delta = self
523            .policy
524            .phrase_vocabulary
525            .as_ref()
526            .map(|spec| {
527                phrase_delta(&document.user_text, spec).with_context(|| {
528                    format!("failed to aggregate phrases for session '{session_id}'")
529                })
530            })
531            .transpose()?;
532        let has_user_text = !document.user_text.trim().is_empty();
533        self.merge_session(
534            document.session,
535            classifications,
536            phrase_delta,
537            has_user_text,
538            document.message_count,
539            document.user_message_count,
540        )
541    }
542
543    fn merge_session(
544        &mut self,
545        session: SessionRecord,
546        classifications: Vec<ClassificationMatch>,
547        phrase_delta: Option<BTreeMap<String, u64>>,
548        has_user_text: bool,
549        message_count: i64,
550        user_message_count: i64,
551    ) -> Result<()> {
552        let session_id = session.id.clone();
553        let score = classifications.iter().try_fold(0_i64, |total, item| {
554            total
555                .checked_add(item.weight)
556                .ok_or_else(|| anyhow!("classification score overflow for session '{session_id}'"))
557        })?;
558        if let (Some(spec), Some(delta)) = (&self.policy.phrase_vocabulary, &phrase_delta) {
559            let new_phrases = delta
560                .keys()
561                .filter(|phrase| !self.vocabulary.contains_key(*phrase))
562                .count();
563            let combined = self
564                .vocabulary
565                .len()
566                .checked_add(new_phrases)
567                .ok_or_else(|| anyhow!("phrase vocabulary size overflow"))?;
568            if combined > spec.max_unique_phrases.get() {
569                bail!(
570                    "phrase vocabulary exceeded max_unique_phrases={} while analyzing session '{}'; increase the explicit bound or narrow the session scope",
571                    spec.max_unique_phrases,
572                    session_id
573                );
574            }
575            for (phrase, occurrences) in delta {
576                if let Some(existing) = self.vocabulary.get(phrase) {
577                    existing
578                        .occurrences
579                        .checked_add(*occurrences)
580                        .ok_or_else(|| {
581                            anyhow!("phrase occurrence count overflow for '{phrase}'")
582                        })?;
583                    existing
584                        .documents
585                        .checked_add(1)
586                        .ok_or_else(|| anyhow!("phrase document count overflow for '{phrase}'"))?;
587                }
588            }
589            for (phrase, occurrences) in delta {
590                let words = phrase.split_whitespace().count();
591                let entry = self
592                    .vocabulary
593                    .entry(phrase.clone())
594                    .or_insert(PhraseFrequency {
595                        phrase: phrase.clone(),
596                        words,
597                        documents: 0,
598                        occurrences: 0,
599                    });
600                entry.documents += 1;
601                entry.occurrences += *occurrences;
602            }
603        }
604        if let Some(title) = session.title.as_deref() {
605            self.titles
606                .entry(title.to_owned())
607                .or_default()
608                .push(session_id.clone());
609        }
610        self.sessions.insert(
611            session_id,
612            AnalyzedSession {
613                has_user_text,
614                session,
615                classifications,
616                score,
617                relationship_hints: Vec::new(),
618                message_count,
619                user_message_count,
620            },
621        );
622        Ok(())
623    }
624
625    /// Resolve relationship hints after every canonical ID/title is known.
626    pub fn finish(mut self) -> Result<AnalysisResult> {
627        if self.failed {
628            bail!("analysis accumulator is unusable after a previous error");
629        }
630        for candidates in self.titles.values_mut() {
631            candidates.sort();
632            candidates.dedup();
633        }
634
635        for (session_id, analyzed) in &mut self.sessions {
636            let Some(title) = analyzed.session.title.as_deref() else {
637                continue;
638            };
639            for rule in &self.policy.relationships {
640                let Some(captures) = rule.regex.captures(title) else {
641                    continue;
642                };
643                let parent_title = captures
644                    .name("parent")
645                    .map(|capture| capture.as_str().trim())
646                    .filter(|value| !value.is_empty())
647                    .with_context(|| {
648                        format!(
649                            "relationship rule '{}' captured an empty parent for session '{}'",
650                            rule.spec.id, session_id
651                        )
652                    })?
653                    .to_owned();
654                let candidates = self
655                    .titles
656                    .get(&parent_title)
657                    .into_iter()
658                    .flatten()
659                    .filter(|candidate| *candidate != session_id)
660                    .cloned()
661                    .collect::<Vec<_>>();
662                let resolution = match candidates.as_slice() {
663                    [] => RelationshipResolution::Unresolved,
664                    [candidate] => RelationshipResolution::Resolved {
665                        session_id: candidate.clone(),
666                    },
667                    _ => RelationshipResolution::Ambiguous {
668                        session_ids: candidates,
669                    },
670                };
671                analyzed.relationship_hints.push(RelationshipHint {
672                    rule_id: rule.spec.id.clone(),
673                    kind: rule.spec.kind,
674                    parent_title,
675                    resolution,
676                });
677            }
678        }
679
680        let mut vocabulary = self.vocabulary.into_values().collect::<Vec<_>>();
681        vocabulary.sort_by(|left, right| {
682            right
683                .occurrences
684                .cmp(&left.occurrences)
685                .then_with(|| right.documents.cmp(&left.documents))
686                .then_with(|| right.words.cmp(&left.words))
687                .then_with(|| left.phrase.cmp(&right.phrase))
688        });
689        Ok(AnalysisResult {
690            sessions: self.sessions,
691            vocabulary,
692        })
693    }
694}
695
696/// Text-derived facts produced by [`StreamingDocumentText::finish`].
697struct StreamedDocumentFacts {
698    /// The exact text classification rules must see: empty when no rule reads user text,
699    /// a `max_classification_chars` prefix of the joined text when bounded, or the full
700    /// joined text when a user-text rule runs unbounded.
701    classification_text: String,
702    first_user_text: Option<String>,
703    has_user_text: bool,
704}
705
706/// How much of the joined user text classification rules require.
707enum ClassificationTextState {
708    /// No classification rule reads user text: retain nothing.
709    Unused,
710    /// Retain at most the first `remaining_chars` characters of the joined text.
711    Bounded {
712        text: String,
713        remaining_chars: usize,
714    },
715    /// A user-text rule runs without `max_classification_chars`: retain the joined text.
716    Full { text: String },
717}
718
719impl ClassificationTextState {
720    fn push_chunk(&mut self, chunk: &str, joiner: bool) {
721        match self {
722            Self::Unused => {}
723            Self::Full { text } => {
724                if joiner {
725                    text.push(' ');
726                }
727                text.push_str(chunk);
728            }
729            Self::Bounded {
730                text,
731                remaining_chars,
732            } => {
733                if joiner && *remaining_chars > 0 {
734                    text.push(' ');
735                    *remaining_chars -= 1;
736                }
737                for character in chunk.chars() {
738                    if *remaining_chars == 0 {
739                        break;
740                    }
741                    text.push(character);
742                    *remaining_chars -= 1;
743                }
744            }
745        }
746    }
747
748    fn into_text(self) -> String {
749        match self {
750            Self::Unused => String::new(),
751            Self::Full { text } | Self::Bounded { text, .. } => text,
752        }
753    }
754}
755
756/// Streaming replica of the joined-`user_text` document semantics.
757///
758/// Feeding chunks `c1..cn` is exactly equivalent to evaluating the policy against
759/// `c1 + " " + c2 + ... + " " + cn` without materializing that string.
760struct StreamingDocumentText<'policy> {
761    saw_first_chunk: bool,
762    /// Whether the joined text is non-empty so far. The batch path only inserts the space
763    /// joiner when its accumulated text is non-empty, so leading empty chunks add nothing.
764    joined_nonempty: bool,
765    has_user_text: bool,
766    first_user_text: Option<String>,
767    classification: ClassificationTextState,
768    phrases: Option<StreamingPhraseAggregator<'policy>>,
769}
770
771impl<'policy> StreamingDocumentText<'policy> {
772    fn new(policy: &'policy AnalysisPolicy) -> Self {
773        let needs_user_text = policy.classifications.iter().any(|rule| {
774            matches!(
775                rule.spec.target,
776                ClassificationTarget::UserText | ClassificationTarget::Any
777            )
778        });
779        let classification = if !needs_user_text {
780            ClassificationTextState::Unused
781        } else if let Some(max_chars) = policy.max_classification_chars {
782            ClassificationTextState::Bounded {
783                text: String::new(),
784                remaining_chars: max_chars.get(),
785            }
786        } else {
787            ClassificationTextState::Full {
788                text: String::new(),
789            }
790        };
791        Self {
792            saw_first_chunk: false,
793            joined_nonempty: false,
794            has_user_text: false,
795            first_user_text: None,
796            classification,
797            phrases: policy
798                .phrase_vocabulary
799                .as_ref()
800                .map(StreamingPhraseAggregator::new),
801        }
802    }
803
804    fn push_chunk(&mut self, chunk: &str) -> Result<()> {
805        let joiner = self.joined_nonempty;
806        if !self.saw_first_chunk {
807            self.saw_first_chunk = true;
808            self.first_user_text = Some(chunk.to_owned());
809        }
810        self.joined_nonempty = self.joined_nonempty || !chunk.is_empty();
811        if !self.has_user_text {
812            self.has_user_text = chunk.chars().any(|character| !character.is_whitespace());
813        }
814        self.classification.push_chunk(chunk, joiner);
815        if let Some(phrases) = &mut self.phrases {
816            phrases.push_chunk(chunk, joiner)?;
817        }
818        Ok(())
819    }
820
821    fn finish(self) -> Result<(StreamedDocumentFacts, Option<BTreeMap<String, u64>>)> {
822        let phrase_delta = self
823            .phrases
824            .map(StreamingPhraseAggregator::finish)
825            .transpose()?;
826        Ok((
827            StreamedDocumentFacts {
828                classification_text: self.classification.into_text(),
829                first_user_text: self.first_user_text,
830                has_user_text: self.has_user_text,
831            },
832            phrase_delta,
833        ))
834    }
835}
836
837/// Streaming n-gram aggregation equivalent to [`phrase_delta`] over the joined text.
838///
839/// Windows span chunk (and, in prose mode, kept-line) boundaries through `recent_tokens`.
840/// The `max_unique_phrases` error is deferred until the `min_document_tokens` gate is
841/// known to pass, exactly matching the batch path's "short documents return an empty
842/// delta without error" behavior; once the unique bound is exceeded the counts map stops
843/// growing, so memory stays bounded by the policy's explicit bounds.
844struct StreamingPhraseAggregator<'policy> {
845    spec: &'policy PhraseVocabularySpec,
846    /// Prose mode only: incomplete final line of the text seen so far, plus fence state.
847    prose: Option<(String, ProseLineFilter)>,
848    recent_tokens: std::collections::VecDeque<String>,
849    max_width: usize,
850    total_tokens: usize,
851    counts: BTreeMap<String, u64>,
852    exceeded_unique_bound: bool,
853}
854
855impl<'policy> StreamingPhraseAggregator<'policy> {
856    fn new(spec: &'policy PhraseVocabularySpec) -> Self {
857        let max_width = spec
858            .widths()
859            .map(NonZeroUsize::get)
860            .max()
861            .expect("compiled phrase vocabulary always has at least one width");
862        Self {
863            spec,
864            prose: matches!(spec.text_mode, PhraseTextMode::ProseOnly)
865                .then(|| (String::new(), ProseLineFilter::default())),
866            recent_tokens: std::collections::VecDeque::with_capacity(max_width),
867            max_width,
868            total_tokens: 0,
869            counts: BTreeMap::new(),
870            exceeded_unique_bound: false,
871        }
872    }
873
874    fn push_chunk(&mut self, chunk: &str, joiner: bool) -> Result<()> {
875        if self.prose.is_some() {
876            // Rebuild the joined text's lines exactly: the space joiner extends the pending
877            // line, and `str::lines` semantics (split at '\n', strip one '\r' before it,
878            // optional final line) decide what the fence filter sees.
879            if joiner {
880                self.prose_pending().push(' ');
881            }
882            let mut parts = chunk.split('\n');
883            if let Some(first) = parts.next() {
884                self.prose_pending().push_str(first);
885            }
886            for part in parts {
887                let mut line = std::mem::take(self.prose_pending());
888                if line.ends_with('\r') {
889                    line.pop();
890                }
891                self.process_prose_line(&line)?;
892                self.prose_pending().push_str(part);
893            }
894        } else {
895            // Tokens are maximal alphanumeric runs, so the space joiner can never extend a
896            // token across chunks: per-chunk tokenization equals joined-text tokenization.
897            for token in crate::analytics::normalized_tokens(chunk) {
898                self.push_token(token)?;
899            }
900        }
901        Ok(())
902    }
903
904    fn prose_pending(&mut self) -> &mut String {
905        &mut self
906            .prose
907            .as_mut()
908            .expect("prose state is present in prose mode")
909            .0
910    }
911
912    fn process_prose_line(&mut self, line: &str) -> Result<()> {
913        let keep = self
914            .prose
915            .as_mut()
916            .expect("prose state is present in prose mode")
917            .1
918            .keep(line);
919        if keep {
920            // The batch path joins kept lines with '\n' before tokenizing; newlines never
921            // extend a token, so feeding per-line tokens into the shared rolling window is
922            // identical (windows still span kept lines through `recent_tokens`).
923            for token in crate::analytics::normalized_tokens(line) {
924                self.push_token(token)?;
925            }
926        }
927        Ok(())
928    }
929
930    fn push_token(&mut self, token: String) -> Result<()> {
931        self.total_tokens = self
932            .total_tokens
933            .checked_add(1)
934            .ok_or_else(|| anyhow!("phrase token count overflow"))?;
935        self.recent_tokens.push_back(token);
936        if self.recent_tokens.len() > self.max_width {
937            self.recent_tokens.pop_front();
938        }
939        for width in self.spec.widths() {
940            let width = width.get();
941            if self.recent_tokens.len() < width {
942                continue;
943            }
944            let window_start = self.recent_tokens.len() - width;
945            let window: Vec<&str> = self
946                .recent_tokens
947                .range(window_start..)
948                .map(String::as_str)
949                .collect();
950            if self.spec.exclude_numeric_tokens
951                && window
952                    .iter()
953                    .any(|token| token.chars().any(char::is_numeric))
954            {
955                continue;
956            }
957            if window
958                .first()
959                .is_some_and(|token| self.spec.excluded_tokens.contains(*token))
960                || window
961                    .iter()
962                    .all(|token| self.spec.excluded_tokens.contains(*token))
963            {
964                continue;
965            }
966            if self.exceeded_unique_bound {
967                continue;
968            }
969            let phrase = window.join(" ");
970            if !self.counts.contains_key(&phrase)
971                && self.counts.len() >= self.spec.max_unique_phrases.get()
972            {
973                // The batch path only reports this error for documents that pass the
974                // min_document_tokens gate; defer until that is certain.
975                self.exceeded_unique_bound = true;
976                continue;
977            }
978            let count = self.counts.entry(phrase.clone()).or_default();
979            *count = count
980                .checked_add(1)
981                .ok_or_else(|| anyhow!("phrase occurrence count overflow for '{phrase}'"))?;
982        }
983        if self.exceeded_unique_bound && self.total_tokens >= self.spec.min_document_tokens {
984            bail!(
985                "one document exceeded max_unique_phrases={}",
986                self.spec.max_unique_phrases
987            );
988        }
989        Ok(())
990    }
991
992    fn finish(mut self) -> Result<BTreeMap<String, u64>> {
993        if self.prose.is_some() {
994            // `str::lines` yields no final line for text ending in '\n' and does not strip a
995            // '\r' that is not followed by '\n'; the pending buffer replicates both.
996            let line = std::mem::take(self.prose_pending());
997            if !line.is_empty() {
998                self.process_prose_line(&line)?;
999            }
1000        }
1001        if self.total_tokens < self.spec.min_document_tokens {
1002            return Ok(BTreeMap::new());
1003        }
1004        if self.exceeded_unique_bound {
1005            bail!(
1006                "one document exceeded max_unique_phrases={}",
1007                self.spec.max_unique_phrases
1008            );
1009        }
1010        Ok(self.counts)
1011    }
1012}
1013
1014fn phrase_delta(content: &str, spec: &PhraseVocabularySpec) -> Result<BTreeMap<String, u64>> {
1015    let prose;
1016    let content = match spec.text_mode {
1017        PhraseTextMode::UserText => content,
1018        PhraseTextMode::ProseOnly => {
1019            prose = prose_only(content);
1020            &prose
1021        }
1022    };
1023    let tokens = crate::analytics::normalized_tokens(content);
1024    let mut counts = BTreeMap::<String, u64>::new();
1025    if tokens.len() < spec.min_document_tokens {
1026        return Ok(counts);
1027    }
1028    for width in spec.widths() {
1029        for window in tokens.windows(width.get()) {
1030            if spec.exclude_numeric_tokens
1031                && window
1032                    .iter()
1033                    .any(|token| token.chars().any(char::is_numeric))
1034            {
1035                continue;
1036            }
1037            if window
1038                .first()
1039                .is_some_and(|token| spec.excluded_tokens.contains(token))
1040                || window
1041                    .iter()
1042                    .all(|token| spec.excluded_tokens.contains(token))
1043            {
1044                continue;
1045            }
1046            let phrase = window.join(" ");
1047            if !counts.contains_key(&phrase) && counts.len() >= spec.max_unique_phrases.get() {
1048                bail!(
1049                    "one document exceeded max_unique_phrases={}",
1050                    spec.max_unique_phrases
1051                );
1052            }
1053            let count = counts.entry(phrase.clone()).or_default();
1054            *count = count
1055                .checked_add(1)
1056                .ok_or_else(|| anyhow!("phrase occurrence count overflow for '{phrase}'"))?;
1057        }
1058    }
1059    Ok(counts)
1060}
1061
1062fn prose_only(content: &str) -> String {
1063    let mut prose = String::with_capacity(content.len());
1064    let mut filter = ProseLineFilter::default();
1065    for line in content.lines() {
1066        if filter.keep(line) {
1067            prose.push_str(line);
1068            prose.push('\n');
1069        }
1070    }
1071    prose
1072}
1073
1074/// Stateful line classifier shared by the batch and streaming prose paths.
1075///
1076/// Both paths must agree on fence state, so the decision logic lives in exactly one place.
1077#[derive(Default)]
1078struct ProseLineFilter {
1079    in_fence: bool,
1080}
1081
1082impl ProseLineFilter {
1083    fn keep(&mut self, line: &str) -> bool {
1084        let trimmed = line.trim_start();
1085        if trimmed.starts_with("```") {
1086            self.in_fence = !self.in_fence;
1087            return false;
1088        }
1089        if self.in_fence || line.starts_with("    ") || line.starts_with('\t') {
1090            return false;
1091        }
1092        let first_token = trimmed
1093            .split(|character: char| !character.is_alphanumeric() && character != '_')
1094            .next()
1095            .unwrap_or_default()
1096            .to_ascii_lowercase();
1097        let keyword_code = matches!(
1098            first_token.as_str(),
1099            "import"
1100                | "from"
1101                | "def"
1102                | "class"
1103                | "return"
1104                | "if"
1105                | "elif"
1106                | "else"
1107                | "for"
1108                | "while"
1109                | "try"
1110                | "except"
1111                | "with"
1112                | "async"
1113                | "await"
1114        );
1115        let assignment = trimmed.split_once('=').is_some_and(|(left, _)| {
1116            let identifier = left.trim();
1117            !identifier.is_empty()
1118                && identifier
1119                    .chars()
1120                    .all(|character| character.is_alphanumeric() || character == '_')
1121                && identifier
1122                    .chars()
1123                    .next()
1124                    .is_some_and(|character| character.is_alphabetic() || character == '_')
1125        });
1126        let function_call = trimmed.split_once('(').is_some_and(|(left, _)| {
1127            let identifier = left.trim();
1128            !identifier.is_empty()
1129                && identifier
1130                    .chars()
1131                    .all(|character| character.is_alphanumeric() || character == '_')
1132        });
1133        let structural_code = trimmed.starts_with('{')
1134            || trimmed.starts_with('[')
1135            || trimmed.starts_with("</")
1136            || trimmed.starts_with('#')
1137            || assignment
1138            || function_call;
1139        !(keyword_code || structural_code)
1140    }
1141}
1142
1143pub(crate) fn require_name(label: &str, value: &str) -> Result<()> {
1144    if value.trim().is_empty() {
1145        bail!("{label} must not be empty");
1146    }
1147    Ok(())
1148}
1149
1150pub(crate) fn compile_nonempty_regex(kind: &str, id: &str, pattern: &str) -> Result<Regex> {
1151    require_name(&format!("{kind} rule pattern"), pattern)?;
1152    let regex =
1153        Regex::new(pattern).with_context(|| format!("invalid {kind} regex for rule '{id}'"))?;
1154    if regex.is_match("") {
1155        // Name the PATTERN, not only the rule it belongs to. A rule with several patterns reports
1156        // the same message for each of them otherwise, and the caller has to bisect by hand to
1157        // find which one matches everything.
1158        bail!(
1159            "{kind} rule '{id}' has a pattern that matches empty text, so it would label every \
1160             message: {pattern}. Anchor it with \\b word boundaries or a literal prefix"
1161        );
1162    }
1163    Ok(regex)
1164}
1165
1166fn classification_matches(
1167    rule: &ClassificationRule,
1168    document: &AnalysisDocument,
1169    max_user_chars: Option<NonZeroUsize>,
1170) -> bool {
1171    let matches = |value: Option<&str>| value.is_some_and(|value| rule.regex.is_match(value));
1172    let user_text = char_prefix(&document.user_text, max_user_chars);
1173    let first_user_text = document
1174        .first_user_text
1175        .as_deref()
1176        .map(|value| char_prefix(value, max_user_chars));
1177    match rule.spec.target {
1178        ClassificationTarget::Title => matches(document.session.title.as_deref()),
1179        ClassificationTarget::Summary => matches(document.session.summary.as_deref()),
1180        ClassificationTarget::FirstUserText => matches(first_user_text),
1181        ClassificationTarget::UserText => rule.regex.is_match(user_text),
1182        ClassificationTarget::Any => {
1183            matches(document.session.title.as_deref())
1184                || matches(document.session.summary.as_deref())
1185                || matches(first_user_text)
1186                || rule.regex.is_match(user_text)
1187        }
1188    }
1189}
1190
1191fn char_prefix(value: &str, max_chars: Option<NonZeroUsize>) -> &str {
1192    max_chars.map_or(value, |limit| {
1193        value
1194            .char_indices()
1195            .nth(limit.get())
1196            .map_or(value, |(end, _)| &value[..end])
1197    })
1198}
1199
1200/// One matched codebook rule. No source text is retained.
1201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1202pub struct ClassificationMatch {
1203    pub dimension: String,
1204    pub label: String,
1205    pub target: ClassificationTarget,
1206    pub weight: i64,
1207}
1208
1209/// Deterministic resolution of one explicit relationship hint.
1210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[serde(tag = "status", rename_all = "snake_case")]
1212pub enum RelationshipResolution {
1213    Unresolved,
1214    Resolved { session_id: String },
1215    Ambiguous { session_ids: Vec<String> },
1216}
1217
1218/// Name-derived evidence and its canonical-ID resolution.
1219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220pub struct RelationshipHint {
1221    pub rule_id: String,
1222    pub kind: RelationshipKind,
1223    pub parent_title: String,
1224    pub resolution: RelationshipResolution,
1225}
1226
1227/// Analysis metadata for one canonical session. Aggregate user text is intentionally absent.
1228#[derive(Debug, Clone, Serialize, Deserialize)]
1229pub struct AnalyzedSession {
1230    pub session: SessionRecord,
1231    pub has_user_text: bool,
1232    pub classifications: Vec<ClassificationMatch>,
1233    pub score: i64,
1234    pub relationship_hints: Vec<RelationshipHint>,
1235    pub message_count: i64,
1236    pub user_message_count: i64,
1237}
1238
1239/// One recurring normalized phrase aggregated without retaining source messages.
1240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1241pub struct PhraseFrequency {
1242    pub phrase: String,
1243    pub words: usize,
1244    pub documents: u64,
1245    pub occurrences: u64,
1246}
1247
1248/// Pure analysis result keyed by canonical session ID, never by mutable/display titles.
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1250pub struct AnalysisResult {
1251    pub sessions: BTreeMap<String, AnalyzedSession>,
1252    pub vocabulary: Vec<PhraseFrequency>,
1253}
1254
1255/// Canonical session metadata retained by the provider-neutral graph projection.
1256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1257pub struct SessionGraphNode {
1258    pub session_id: String,
1259    pub provider: String,
1260    pub title: Option<String>,
1261    pub cwd: Option<String>,
1262    pub repo_root: Option<String>,
1263    pub created_at: Option<String>,
1264    pub updated_at: Option<String>,
1265    pub score: i64,
1266    pub classifications: Vec<ClassificationMatch>,
1267}
1268
1269/// A resolved, directed provenance relationship between two canonical sessions.
1270#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1271pub struct SessionGraphEdge {
1272    pub source_session_id: String,
1273    pub target_session_id: String,
1274    pub kind: RelationshipKind,
1275    pub rule_id: String,
1276}
1277
1278/// Set-valued membership that must not be interpreted as parent-child lineage.
1279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1280pub struct SessionGraphGroup {
1281    pub dimension: String,
1282    pub key: String,
1283    pub session_ids: Vec<String>,
1284}
1285
1286/// Deterministic graph projection with canonical identity and no inferred all-pairs edges.
1287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1288pub struct SessionGraph {
1289    pub nodes: BTreeMap<String, SessionGraphNode>,
1290    pub edges: Vec<SessionGraphEdge>,
1291    pub groups: Vec<SessionGraphGroup>,
1292}
1293
1294impl AnalysisResult {
1295    /// Digest canonical analysis semantics independently of corpus, policy source, and publication.
1296    ///
1297    /// `AnalysisResult` uses ordered maps and deterministically sorted vectors. Its compact Serde
1298    /// projection is therefore a stable versioned digest input. A counting pass frames the exact
1299    /// byte length, then a second pass hashes serializer chunks directly. Time is
1300    /// `O(result bytes)` with a constant factor of two and additional memory is `O(1)`;
1301    /// publication formats never enter it.
1302    pub fn result_digest(&self) -> String {
1303        let mut digest = FramedSha256::new(b"aise-analysis-result-v1");
1304        digest
1305            .update_json(self)
1306            .expect("AnalysisResult contains only JSON-serializable domain values");
1307        digest.finish()
1308    }
1309
1310    /// Project analyzed sessions into provenance edges and project memberships.
1311    ///
1312    /// Only explicitly resolved relationship hints become edges. Ambiguous and unresolved hints
1313    /// remain available on [`AnalyzedSession`] as evidence and are never guessed into lineage.
1314    /// Working directories and repository roots are groups, not temporal relationships. Runtime
1315    /// is `O(N log N + E log E)` and does not compare every pair of sessions.
1316    pub fn session_graph(&self) -> SessionGraph {
1317        let nodes = self
1318            .sessions
1319            .iter()
1320            .map(|(session_id, analyzed)| {
1321                let session = &analyzed.session;
1322                (
1323                    session_id.clone(),
1324                    SessionGraphNode {
1325                        session_id: session_id.clone(),
1326                        provider: session.provider.as_str().to_owned(),
1327                        title: session.title.clone(),
1328                        cwd: session.cwd.clone(),
1329                        repo_root: session.repo_root.clone(),
1330                        created_at: session.created_at.map(|value| value.to_rfc3339()),
1331                        updated_at: session.updated_at.map(|value| value.to_rfc3339()),
1332                        score: analyzed.score,
1333                        classifications: analyzed.classifications.clone(),
1334                    },
1335                )
1336            })
1337            .collect();
1338
1339        let mut edges = self
1340            .sessions
1341            .iter()
1342            .flat_map(|(target_session_id, analyzed)| {
1343                analyzed.relationship_hints.iter().filter_map(move |hint| {
1344                    let RelationshipResolution::Resolved { session_id } = &hint.resolution else {
1345                        return None;
1346                    };
1347                    Some(SessionGraphEdge {
1348                        source_session_id: session_id.clone(),
1349                        target_session_id: target_session_id.clone(),
1350                        kind: hint.kind,
1351                        rule_id: hint.rule_id.clone(),
1352                    })
1353                })
1354            })
1355            .collect::<Vec<_>>();
1356        edges.sort();
1357        edges.dedup();
1358
1359        let mut memberships = BTreeMap::<(String, String), BTreeSet<String>>::new();
1360        for (session_id, analyzed) in &self.sessions {
1361            for (dimension, value) in [
1362                ("working_directory", analyzed.session.cwd.as_deref()),
1363                ("repository", analyzed.session.repo_root.as_deref()),
1364            ] {
1365                if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
1366                    memberships
1367                        .entry((dimension.to_owned(), value.to_owned()))
1368                        .or_default()
1369                        .insert(session_id.clone());
1370                }
1371            }
1372        }
1373        let groups = memberships
1374            .into_iter()
1375            .filter(|(_, session_ids)| session_ids.len() > 1)
1376            .map(|((dimension, key), session_ids)| SessionGraphGroup {
1377                dimension,
1378                key,
1379                session_ids: session_ids.into_iter().collect(),
1380            })
1381            .collect();
1382
1383        SessionGraph {
1384            nodes,
1385            edges,
1386            groups,
1387        }
1388    }
1389}
1390
1391const fn classification_target_tag(target: ClassificationTarget) -> u8 {
1392    match target {
1393        ClassificationTarget::Title => 0,
1394        ClassificationTarget::Summary => 1,
1395        ClassificationTarget::FirstUserText => 2,
1396        ClassificationTarget::UserText => 3,
1397        ClassificationTarget::Any => 4,
1398    }
1399}
1400
1401const fn relationship_kind_tag(kind: RelationshipKind) -> u8 {
1402    match kind {
1403        RelationshipKind::Branch => 0,
1404        RelationshipKind::Copy => 1,
1405        RelationshipKind::Version => 2,
1406    }
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411    use super::*;
1412    use crate::models::Provider;
1413    use crate::util::minimal_record;
1414    use std::path::Path;
1415
1416    fn document(id: &str, provider: Provider, title: &str, user_text: &str) -> AnalysisDocument {
1417        let mut parsed =
1418            minimal_record(provider, Path::new("/fixture/session.jsonl"), "test".into());
1419        parsed.session.id = id.into();
1420        parsed.session.title = Some(title.into());
1421        parsed.session.preview_text.clear();
1422        AnalysisDocument {
1423            session: parsed.session,
1424            user_text: user_text.into(),
1425            first_user_text: (!user_text.is_empty()).then(|| user_text.into()),
1426            message_count: usize::from(!user_text.is_empty()) as i64,
1427            user_message_count: usize::from(!user_text.is_empty()) as i64,
1428        }
1429    }
1430
1431    fn policy() -> AnalysisPolicy {
1432        AnalysisPolicy::compile(
1433            vec![
1434                ClassificationRuleSpec {
1435                    dimension: "technique".into(),
1436                    label: "tdd".into(),
1437                    target: ClassificationTarget::UserText,
1438                    pattern: "(?i)\\btdd\\b".into(),
1439                    weight: 7,
1440                },
1441                ClassificationRuleSpec {
1442                    dimension: "role".into(),
1443                    label: "maintainer".into(),
1444                    target: ClassificationTarget::Any,
1445                    pattern: "(?i)maintainer".into(),
1446                    weight: 11,
1447                },
1448            ],
1449            vec![RelationshipRuleSpec {
1450                id: "branch_of".into(),
1451                kind: RelationshipKind::Branch,
1452                pattern: "^Branch of (?P<parent>.+)$".into(),
1453            }],
1454        )
1455        .unwrap()
1456    }
1457
1458    /// The batch join the streaming path must replicate: a space joiner only when the
1459    /// accumulated text is already non-empty (mirrors `analysis_document_page`).
1460    fn joined(chunks: &[&str]) -> String {
1461        let mut text = String::new();
1462        for chunk in chunks {
1463            if !text.is_empty() {
1464                text.push(' ');
1465            }
1466            text.push_str(chunk);
1467        }
1468        text
1469    }
1470
1471    fn run_document_path(policy: &AnalysisPolicy, chunks: &[&str]) -> Result<AnalysisResult> {
1472        let mut reference = policy.accumulator();
1473        let mut doc = document(
1474            "claude:diff",
1475            Provider::Claude,
1476            "Differential",
1477            &joined(chunks),
1478        );
1479        doc.first_user_text = chunks.first().map(|chunk| (*chunk).to_string());
1480        doc.message_count = chunks.len() as i64;
1481        doc.user_message_count = chunks.len() as i64;
1482        reference.push(doc)?;
1483        reference.finish()
1484    }
1485
1486    fn run_streaming_path(policy: &AnalysisPolicy, chunks: &[&str]) -> Result<AnalysisResult> {
1487        let mut streaming = policy.accumulator();
1488        let session = document("claude:diff", Provider::Claude, "Differential", "").session;
1489        streaming.push_session_text_stream(
1490            session,
1491            chunks.len() as i64,
1492            chunks.len() as i64,
1493            chunks.iter().map(|chunk| Ok((*chunk).to_string())),
1494        )?;
1495        streaming.finish()
1496    }
1497
1498    #[test]
1499    fn analysis_policy_digest_uses_normalized_executable_semantics() {
1500        let left = AnalysisPolicySpec {
1501            classification_rules: vec![
1502                ClassificationRuleSpec {
1503                    dimension: "workflow".into(),
1504                    label: "tdd".into(),
1505                    target: ClassificationTarget::UserText,
1506                    pattern: "(?i)tdd".into(),
1507                    weight: 2,
1508                },
1509                ClassificationRuleSpec {
1510                    dimension: "architecture".into(),
1511                    label: "reuse".into(),
1512                    target: ClassificationTarget::Title,
1513                    pattern: "(?i)reuse".into(),
1514                    weight: 1,
1515                },
1516            ],
1517            relationship_rules: vec![],
1518            phrase_vocabulary: None,
1519            max_classification_chars: Some(200),
1520        }
1521        .compile()
1522        .unwrap();
1523        let reordered = AnalysisPolicySpec {
1524            classification_rules: vec![
1525                ClassificationRuleSpec {
1526                    dimension: "architecture".into(),
1527                    label: "reuse".into(),
1528                    target: ClassificationTarget::Title,
1529                    pattern: "(?i)reuse".into(),
1530                    weight: 1,
1531                },
1532                ClassificationRuleSpec {
1533                    dimension: "workflow".into(),
1534                    label: "tdd".into(),
1535                    target: ClassificationTarget::UserText,
1536                    pattern: "(?i)tdd".into(),
1537                    weight: 2,
1538                },
1539            ],
1540            relationship_rules: vec![],
1541            phrase_vocabulary: None,
1542            max_classification_chars: Some(200),
1543        }
1544        .compile()
1545        .unwrap();
1546        let changed = AnalysisPolicySpec {
1547            max_classification_chars: Some(201),
1548            ..AnalysisPolicySpec {
1549                classification_rules: vec![
1550                    ClassificationRuleSpec {
1551                        dimension: "architecture".into(),
1552                        label: "reuse".into(),
1553                        target: ClassificationTarget::Title,
1554                        pattern: "(?i)reuse".into(),
1555                        weight: 1,
1556                    },
1557                    ClassificationRuleSpec {
1558                        dimension: "workflow".into(),
1559                        label: "tdd".into(),
1560                        target: ClassificationTarget::UserText,
1561                        pattern: "(?i)tdd".into(),
1562                        weight: 2,
1563                    },
1564                ],
1565                relationship_rules: vec![],
1566                phrase_vocabulary: None,
1567                max_classification_chars: None,
1568            }
1569        }
1570        .compile()
1571        .unwrap();
1572
1573        assert_eq!(left.policy_digest(), reordered.policy_digest());
1574        assert_ne!(left.policy_digest(), changed.policy_digest());
1575    }
1576
1577    #[test]
1578    fn analysis_result_digest_is_deterministic_and_result_sensitive() {
1579        let result = policy()
1580            .analyze([document(
1581                "claude:digest",
1582                Provider::Claude,
1583                "Digest",
1584                "use TDD",
1585            )])
1586            .unwrap();
1587        let same = result.clone();
1588        let mut changed = result.clone();
1589        changed.sessions.get_mut("claude:digest").unwrap().score += 1;
1590
1591        assert_eq!(result.result_digest(), same.result_digest());
1592        assert_ne!(result.result_digest(), changed.result_digest());
1593    }
1594
1595    #[track_caller]
1596    fn assert_stream_matches_document(policy: &AnalysisPolicy, chunks: &[&str]) {
1597        let expected = run_document_path(policy, chunks).unwrap();
1598        let actual = run_streaming_path(policy, chunks).unwrap();
1599        assert_eq!(
1600            serde_json::to_value(&expected).unwrap(),
1601            serde_json::to_value(&actual).unwrap(),
1602            "streaming result diverged from joined-document result for chunks {chunks:?}"
1603        );
1604    }
1605
1606    fn phrase_policy(
1607        min_document_tokens: usize,
1608        max_unique: usize,
1609        text_mode: PhraseTextMode,
1610    ) -> AnalysisPolicy {
1611        AnalysisPolicy::compile(vec![], vec![])
1612            .unwrap()
1613            .with_phrase_vocabulary(
1614                PhraseVocabularySpec::new(
1615                    [NonZeroUsize::new(2).unwrap(), NonZeroUsize::new(3).unwrap()],
1616                    NonZeroUsize::new(max_unique).unwrap(),
1617                    min_document_tokens,
1618                    ["the".to_string()],
1619                    true,
1620                )
1621                .unwrap()
1622                .with_text_mode(text_mode),
1623            )
1624    }
1625
1626    #[test]
1627    fn streaming_chunks_match_joined_document_for_classification() {
1628        let unbounded = policy();
1629        let bounded = policy().with_max_classification_chars(NonZeroUsize::new(7).unwrap());
1630        for chunks in [
1631            &["use tdd across chunks", "tdd again maintainer"][..],
1632            &["", "", "leading empties then maintainer"],
1633            &["alpha beta", "", "gamma"],
1634            &["héllo wörld", "maintainer tdd"],
1635            &[""],
1636            &[],
1637        ] {
1638            assert_stream_matches_document(&unbounded, chunks);
1639            assert_stream_matches_document(&bounded, chunks);
1640        }
1641        // The 7-char prefix of "héllo maintainer" is "héllo m": the joiner and multibyte
1642        // characters count toward the bound on both paths, so neither matches "maintainer".
1643        let result = run_streaming_path(&bounded, &["héllo", "maintainer"]).unwrap();
1644        assert_eq!(result.sessions["claude:diff"].score, 0);
1645        let result = run_streaming_path(&unbounded, &["héllo", "maintainer"]).unwrap();
1646        assert_eq!(result.sessions["claude:diff"].score, 11);
1647    }
1648
1649    #[test]
1650    fn streaming_chunks_match_joined_document_for_phrase_windows() {
1651        let user_text = phrase_policy(0, 1000, PhraseTextMode::UserText);
1652        let prose = phrase_policy(0, 1000, PhraseTextMode::ProseOnly);
1653        for chunks in [
1654            // n-gram spanning the chunk junction ("chunks tdd").
1655            &["use tdd across chunks", "tdd again across chunks"][..],
1656            // Fence opened in one chunk and closed in the next: the junction line
1657            // "``` code hidden" toggles the fence exactly as in the joined text.
1658            &["prose one\n```", "code hidden\n```\nprose two"],
1659            // Fence marker merged mid-line at the junction must NOT toggle the fence.
1660            &["alpha beta", "``` gamma\ndelta"],
1661            // CRLF line endings and a carriage return absorbed into a joined line.
1662            &["line one\r\nline two\r", "tail words here"],
1663            // Excluded token at window start, numeric exclusion, empty chunks.
1664            &["the quick brown", "fox 99 jumps", "", "over the lazy dog"],
1665            &["", "leading empty"],
1666            &[""],
1667        ] {
1668            assert_stream_matches_document(&user_text, chunks);
1669            assert_stream_matches_document(&prose, chunks);
1670        }
1671    }
1672
1673    #[test]
1674    fn streaming_phrase_bounds_match_joined_document_errors_exactly() {
1675        let chunks = &["alpha beta gamma", "delta epsilon zeta"][..];
1676
1677        // Enough unique windows to exceed max_unique_phrases=2 once the token gate passes.
1678        let overflowing = phrase_policy(0, 2, PhraseTextMode::UserText);
1679        let expected = run_document_path(&overflowing, chunks).unwrap_err();
1680        let actual = run_streaming_path(&overflowing, chunks).unwrap_err();
1681        assert_eq!(format!("{expected:#}"), format!("{actual:#}"));
1682        assert!(format!("{actual:#}").contains("one document exceeded max_unique_phrases=2"));
1683
1684        // Below min_document_tokens both paths return an EMPTY delta without an error,
1685        // even though the unique bound was crossed along the way.
1686        let short_gate = phrase_policy(50, 2, PhraseTextMode::UserText);
1687        let expected = run_document_path(&short_gate, chunks).unwrap();
1688        let actual = run_streaming_path(&short_gate, chunks).unwrap();
1689        assert!(actual.vocabulary.is_empty());
1690        assert_eq!(
1691            serde_json::to_value(&expected).unwrap(),
1692            serde_json::to_value(&actual).unwrap()
1693        );
1694
1695        // Gate crossed only after the unique bound was exceeded: both paths still error.
1696        let late_gate = phrase_policy(6, 2, PhraseTextMode::UserText);
1697        let expected = run_document_path(&late_gate, chunks).unwrap_err();
1698        let actual = run_streaming_path(&late_gate, chunks).unwrap_err();
1699        assert_eq!(format!("{expected:#}"), format!("{actual:#}"));
1700    }
1701
1702    #[test]
1703    fn streaming_push_rejects_duplicates_and_poisons_like_document_push() {
1704        let policy = policy();
1705        let mut accumulator = policy.accumulator();
1706        let session = document("claude:dup", Provider::Claude, "Dup", "").session;
1707        accumulator
1708            .push_session_text_stream(session.clone(), 1, 1, [Ok("one".to_string())])
1709            .unwrap();
1710        let error = accumulator
1711            .push_session_text_stream(session.clone(), 1, 1, [Ok("two".to_string())])
1712            .unwrap_err();
1713        assert!(error
1714            .to_string()
1715            .contains("duplicate canonical session ID 'claude:dup'"));
1716        let error = accumulator
1717            .push_session_text_stream(session, 1, 1, [Ok("three".to_string())])
1718            .unwrap_err();
1719        assert!(error
1720            .to_string()
1721            .contains("analysis accumulator is unusable after a previous error"));
1722    }
1723
1724    #[test]
1725    fn classifies_any_provider_without_retaining_user_text() {
1726        let result = policy()
1727            .analyze([
1728                document("claude:a", Provider::Claude, "Maintainer task", "Use TDD"),
1729                document("codex:b", Provider::Codex, "Other", "Use TDD"),
1730            ])
1731            .unwrap();
1732
1733        assert_eq!(result.sessions["claude:a"].score, 18);
1734        assert_eq!(result.sessions["codex:b"].score, 7);
1735        assert_eq!(
1736            result
1737                .sessions
1738                .keys()
1739                .map(String::as_str)
1740                .collect::<Vec<_>>(),
1741            ["claude:a", "codex:b"]
1742        );
1743        let json = serde_json::to_string(&result).unwrap();
1744        assert!(!json.contains("Use TDD"));
1745    }
1746
1747    #[test]
1748    fn duplicate_parent_titles_are_reported_as_ambiguous_canonical_ids() {
1749        let result = policy()
1750            .analyze([
1751                document("codex:z", Provider::Codex, "Root", ""),
1752                document("claude:a", Provider::Claude, "Root", ""),
1753                document("gemini:child", Provider::GeminiCli, "Branch of Root", ""),
1754            ])
1755            .unwrap();
1756
1757        assert_eq!(
1758            result.sessions["gemini:child"].relationship_hints[0].resolution,
1759            RelationshipResolution::Ambiguous {
1760                session_ids: vec!["claude:a".into(), "codex:z".into()]
1761            }
1762        );
1763    }
1764
1765    #[test]
1766    fn relationship_resolution_never_creates_self_loops() {
1767        let self_policy = AnalysisPolicy::compile(
1768            vec![],
1769            vec![RelationshipRuleSpec {
1770                id: "identity".into(),
1771                kind: RelationshipKind::Version,
1772                pattern: "^(?P<parent>.+)$".into(),
1773            }],
1774        )
1775        .unwrap();
1776        let result = self_policy
1777            .analyze([document("claude:self", Provider::Claude, "Same", "")])
1778            .unwrap();
1779        assert_eq!(
1780            result.sessions["claude:self"].relationship_hints[0].resolution,
1781            RelationshipResolution::Unresolved
1782        );
1783    }
1784
1785    #[test]
1786    fn graph_uses_canonical_ids_resolved_edges_and_set_valued_groups() {
1787        let mut root = document("claude:root", Provider::Claude, "Root", "");
1788        root.session.cwd = Some("/repo/worktree".into());
1789        root.session.repo_root = Some("/repo".into());
1790        let mut child = document("gemini:child", Provider::GeminiCli, "Branch of Root", "");
1791        child.session.cwd = Some("/repo/worktree".into());
1792        child.session.repo_root = Some("/repo".into());
1793
1794        let result = policy().analyze([child, root]).unwrap();
1795        let graph = result.session_graph();
1796
1797        assert_eq!(
1798            graph.nodes.keys().map(String::as_str).collect::<Vec<_>>(),
1799            ["claude:root", "gemini:child"]
1800        );
1801        assert_eq!(
1802            graph.edges,
1803            [SessionGraphEdge {
1804                source_session_id: "claude:root".into(),
1805                target_session_id: "gemini:child".into(),
1806                kind: RelationshipKind::Branch,
1807                rule_id: "branch_of".into(),
1808            }]
1809        );
1810        assert_eq!(graph.groups.len(), 2);
1811        assert!(graph
1812            .groups
1813            .iter()
1814            .all(|group| { group.session_ids == ["claude:root", "gemini:child"] }));
1815    }
1816
1817    #[test]
1818    fn graph_never_promotes_ambiguous_relationship_evidence_to_an_edge() {
1819        let result = policy()
1820            .analyze([
1821                document("codex:z", Provider::Codex, "Root", ""),
1822                document("claude:a", Provider::Claude, "Root", ""),
1823                document("gemini:child", Provider::GeminiCli, "Branch of Root", ""),
1824            ])
1825            .unwrap();
1826
1827        assert!(result.session_graph().edges.is_empty());
1828        assert!(matches!(
1829            result.sessions["gemini:child"].relationship_hints[0].resolution,
1830            RelationshipResolution::Ambiguous { .. }
1831        ));
1832    }
1833
1834    #[test]
1835    fn serializable_policy_spec_compiles_once_and_rejects_invalid_bounds() {
1836        let spec: AnalysisPolicySpec = serde_json::from_value(serde_json::json!({
1837            "classification_rules": [{
1838                "dimension": "workflow",
1839                "label": "planning",
1840                "target": "user_text",
1841                "pattern": "(?i)plan",
1842                "weight": 3
1843            }],
1844            "relationship_rules": [],
1845            "phrase_vocabulary": {
1846                "widths": [3, 4],
1847                "max_unique_phrases": 100,
1848                "excluded_tokens": ["the"],
1849                "text_mode": "prose_only"
1850            },
1851            "max_classification_chars": 4096
1852        }))
1853        .unwrap();
1854        let policy = spec.compile().unwrap();
1855        assert_eq!(policy.classification_specs().len(), 1);
1856        assert_eq!(policy.max_classification_chars().unwrap().get(), 4096);
1857        let phrases = policy.phrase_vocabulary().unwrap();
1858        assert_eq!(
1859            phrases.widths().map(NonZeroUsize::get).collect::<Vec<_>>(),
1860            [3, 4]
1861        );
1862        assert_eq!(phrases.text_mode(), PhraseTextMode::ProseOnly);
1863        assert!(phrases.exclude_numeric_tokens());
1864
1865        let zero_bound = AnalysisPolicySpec {
1866            max_classification_chars: Some(0),
1867            ..AnalysisPolicySpec::default()
1868        };
1869        assert!(zero_bound.compile().is_err());
1870        assert!(
1871            serde_json::from_value::<AnalysisPolicySpec>(serde_json::json!({
1872                "unknown": true
1873            }))
1874            .is_err()
1875        );
1876    }
1877
1878    #[test]
1879    fn invalid_and_duplicate_rules_fail_before_analysis() {
1880        let duplicate = ClassificationRuleSpec {
1881            dimension: "role".into(),
1882            label: "maintainer".into(),
1883            target: ClassificationTarget::Title,
1884            pattern: "maintainer".into(),
1885            weight: 1,
1886        };
1887        let error = AnalysisPolicy::compile(vec![duplicate.clone(), duplicate], vec![])
1888            .unwrap_err()
1889            .to_string();
1890        assert!(error.contains("duplicate classification rule"));
1891
1892        let error = AnalysisPolicy::compile(
1893            vec![],
1894            vec![RelationshipRuleSpec {
1895                id: "broken".into(),
1896                kind: RelationshipKind::Copy,
1897                pattern: "Copy of (.+)".into(),
1898            }],
1899        )
1900        .unwrap_err()
1901        .to_string();
1902        assert!(error.contains("named 'parent' capture"));
1903    }
1904
1905    #[test]
1906    fn phrase_vocabulary_is_bounded_configurable_and_deterministic() {
1907        let spec = PhraseVocabularySpec::new(
1908            [NonZeroUsize::new(3).unwrap(), NonZeroUsize::new(4).unwrap()],
1909            NonZeroUsize::new(100).unwrap(),
1910            0,
1911            ["the".into()],
1912            true,
1913        )
1914        .unwrap();
1915        let result = policy()
1916            .with_phrase_vocabulary(spec)
1917            .analyze([
1918                document(
1919                    "claude:a",
1920                    Provider::Claude,
1921                    "One",
1922                    "the quick brown fox quick brown fox 2026",
1923                ),
1924                document("codex:b", Provider::Codex, "Two", "quick brown fox"),
1925            ])
1926            .unwrap();
1927
1928        let repeated = result
1929            .vocabulary
1930            .iter()
1931            .find(|item| item.phrase == "quick brown fox")
1932            .unwrap();
1933        assert_eq!(repeated.words, 3);
1934        assert_eq!(repeated.documents, 2);
1935        assert_eq!(repeated.occurrences, 3);
1936        assert_eq!(result.sessions["claude:a"].message_count, 1);
1937        assert_eq!(result.sessions["claude:a"].user_message_count, 1);
1938        assert!(result.sessions["claude:a"].has_user_text);
1939        assert!(!result
1940            .vocabulary
1941            .iter()
1942            .any(|item| item.phrase.starts_with("the ") || item.phrase.contains("2026")));
1943    }
1944
1945    #[test]
1946    fn phrase_limit_error_poisons_accumulator_without_partial_publication() {
1947        let spec = PhraseVocabularySpec::new(
1948            [NonZeroUsize::new(1).unwrap()],
1949            NonZeroUsize::new(1).unwrap(),
1950            0,
1951            Vec::new(),
1952            false,
1953        )
1954        .unwrap();
1955        let policy = policy().with_phrase_vocabulary(spec);
1956        let mut accumulator = policy.accumulator();
1957        accumulator
1958            .push(document("claude:a", Provider::Claude, "One", "alpha"))
1959            .unwrap();
1960        let error = accumulator
1961            .push(document("codex:b", Provider::Codex, "Two", "beta"))
1962            .unwrap_err()
1963            .to_string();
1964        assert!(error.contains("max_unique_phrases=1"));
1965        assert!(accumulator
1966            .finish()
1967            .unwrap_err()
1968            .to_string()
1969            .contains("unusable after a previous error"));
1970
1971        assert!(PhraseVocabularySpec::new(
1972            Vec::new(),
1973            NonZeroUsize::new(1).unwrap(),
1974            0,
1975            Vec::new(),
1976            false,
1977        )
1978        .unwrap_err()
1979        .to_string()
1980        .contains("at least one n-gram width"));
1981    }
1982
1983    #[test]
1984    fn phrase_prose_mode_excludes_code_and_classification_window_is_bounded() {
1985        let spec = PhraseVocabularySpec::new(
1986            [NonZeroUsize::new(2).unwrap()],
1987            NonZeroUsize::new(100).unwrap(),
1988            0,
1989            Vec::new(),
1990            false,
1991        )
1992        .unwrap()
1993        .with_text_mode(PhraseTextMode::ProseOnly);
1994        let result = policy()
1995            .with_phrase_vocabulary(spec)
1996            .with_max_classification_chars(NonZeroUsize::new(8).unwrap())
1997            .analyze([document(
1998                "claude:a",
1999                Provider::Claude,
2000                "One",
2001                "plain prose\nelsewhere text\nhttps://example.test?a=b remains prose\n```rust\nlet secret_code = true;\n```\ntdd after window",
2002            )])
2003            .unwrap();
2004
2005        assert!(result.sessions["claude:a"].classifications.is_empty());
2006        assert!(result
2007            .vocabulary
2008            .iter()
2009            .any(|item| item.phrase == "plain prose"));
2010        assert!(result
2011            .vocabulary
2012            .iter()
2013            .any(|item| item.phrase == "elsewhere text"));
2014        assert!(result
2015            .vocabulary
2016            .iter()
2017            .any(|item| item.phrase == "remains prose"));
2018        assert!(!result
2019            .vocabulary
2020            .iter()
2021            .any(|item| item.phrase.contains("secret") || item.phrase.contains("code")));
2022    }
2023
2024    #[test]
2025    fn duplicate_session_ids_and_score_overflow_are_actionable_errors() {
2026        let duplicate = document("claude:same", Provider::Claude, "One", "");
2027        let error = policy()
2028            .analyze([duplicate.clone(), duplicate])
2029            .unwrap_err()
2030            .to_string();
2031        assert!(error.contains("duplicate canonical session ID 'claude:same'"));
2032
2033        let overflow_policy = AnalysisPolicy::compile(
2034            vec![
2035                ClassificationRuleSpec {
2036                    dimension: "a".into(),
2037                    label: "max".into(),
2038                    target: ClassificationTarget::Title,
2039                    pattern: "One".into(),
2040                    weight: i64::MAX,
2041                },
2042                ClassificationRuleSpec {
2043                    dimension: "b".into(),
2044                    label: "one".into(),
2045                    target: ClassificationTarget::Title,
2046                    pattern: "One".into(),
2047                    weight: 1,
2048                },
2049            ],
2050            vec![],
2051        )
2052        .unwrap();
2053        let error = overflow_policy
2054            .analyze([document("claude:overflow", Provider::Claude, "One", "")])
2055            .unwrap_err()
2056            .to_string();
2057        assert!(error.contains("classification score overflow"));
2058    }
2059}