1use 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#[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 Any,
31}
32
33#[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#[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#[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#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum PhraseTextMode {
77 #[default]
79 UserText,
80 ProseOnly,
82}
83
84#[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 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#[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#[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 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 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 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 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 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 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
424pub 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 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 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 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
696struct StreamedDocumentFacts {
698 classification_text: String,
702 first_user_text: Option<String>,
703 has_user_text: bool,
704}
705
706enum ClassificationTextState {
708 Unused,
710 Bounded {
712 text: String,
713 remaining_chars: usize,
714 },
715 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
756struct StreamingDocumentText<'policy> {
761 saw_first_chunk: bool,
762 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
837struct StreamingPhraseAggregator<'policy> {
845 spec: &'policy PhraseVocabularySpec,
846 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 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 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 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 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 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#[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 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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
1250pub struct AnalysisResult {
1251 pub sessions: BTreeMap<String, AnalyzedSession>,
1252 pub vocabulary: Vec<PhraseFrequency>,
1253}
1254
1255#[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#[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#[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#[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 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 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 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 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 &["use tdd across chunks", "tdd again across chunks"][..],
1656 &["prose one\n```", "code hidden\n```\nprose two"],
1659 &["alpha beta", "``` gamma\ndelta"],
1661 &["line one\r\nline two\r", "tail words here"],
1663 &["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 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 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 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}