use serde_json::Value;
use super::model::{CompilePolicy, ContextAction, ContextFragment};
#[derive(Debug, Clone, Copy)]
pub(crate) struct RuleMatch {
pub id: &'static str,
pub action: ContextAction,
pub critical: bool,
pub reason: &'static str,
}
struct Rule {
id: &'static str,
action: ContextAction,
critical: bool,
reason: &'static str,
applies: fn(&ContextFragment, &CompilePolicy) -> bool,
}
const RULES: &[Rule] = &[
Rule {
id: "media.atomic",
action: ContextAction::Preserve,
critical: true,
reason: "media fragment packs as one atomic, unsplittable piece",
applies: is_media,
},
Rule {
id: "preserve.marked_verbatim",
action: ContextAction::Preserve,
critical: true,
reason: "caller marked this fragment verbatim",
applies: is_marked_verbatim,
},
Rule {
id: "cache.stable_prefix",
action: ContextAction::Cache,
critical: true,
reason: "caller marked this fragment cacheable; it forms the stable prefix",
applies: is_marked_cache,
},
Rule {
id: "preserve.code_fence",
action: ContextAction::Preserve,
critical: true,
reason: "code must survive verbatim",
applies: is_code,
},
Rule {
id: "preserve.negative_constraint",
action: ContextAction::Preserve,
critical: true,
reason: "negative constraints must never be weakened",
applies: has_negative_constraint,
},
Rule {
id: "abstract.log_dedup",
action: ContextAction::Abstract,
critical: false,
reason: "repeated log lines collapse into one annotated line",
applies: is_repetitive_log,
},
Rule {
id: "preserve.exact_values",
action: ContextAction::Preserve,
critical: true,
reason: "numbers, dates and identifiers must survive verbatim",
applies: is_value_dense,
},
Rule {
id: "preserve.url",
action: ContextAction::Preserve,
critical: true,
reason: "URLs must survive verbatim",
applies: has_url,
},
Rule {
id: "preserve.default",
action: ContextAction::Preserve,
critical: false,
reason: "prose kept subject to budget",
applies: |_, _| true,
},
];
const TERMINAL_RULE_ID: &str = "preserve.default";
pub(crate) fn classify(fragment: &ContextFragment, policy: &CompilePolicy) -> RuleMatch {
RULES
.iter()
.filter(|rule| {
rule.id == TERMINAL_RULE_ID || !policy.disabled_rules.iter().any(|d| d == rule.id)
})
.find(|rule| (rule.applies)(fragment, policy))
.map_or_else(|| to_match(&RULES[RULES.len() - 1]), to_match)
}
fn to_match(rule: &Rule) -> RuleMatch {
RuleMatch {
id: rule.id,
action: rule.action,
critical: rule.critical,
reason: rule.reason,
}
}
fn is_media(fragment: &ContextFragment, _policy: &CompilePolicy) -> bool {
fragment.media.is_some()
}
fn is_marked_verbatim(fragment: &ContextFragment, _policy: &CompilePolicy) -> bool {
bool_meta(fragment, "verbatim")
}
fn is_marked_cache(fragment: &ContextFragment, _policy: &CompilePolicy) -> bool {
bool_meta(fragment, "cache")
}
fn bool_meta(fragment: &ContextFragment, key: &str) -> bool {
fragment
.metadata
.as_ref()
.and_then(|meta| meta.get(key))
.is_some_and(|value| matches!(value, Value::Bool(true)))
}
fn is_code(fragment: &ContextFragment, _policy: &CompilePolicy) -> bool {
fragment.kind.as_deref() == Some("code") || fragment.content.contains("```")
}
fn has_negative_constraint(fragment: &ContextFragment, _policy: &CompilePolicy) -> bool {
const MARKERS: &[&str] = &[
"never ",
"must not",
"do not",
"don't",
"ne pas",
"ne jamais",
"jamais ",
];
fragment.content.lines().any(|line| {
let lowered = word_bounded(&line.to_lowercase());
MARKERS.iter().any(|marker| lowered.contains(marker))
})
}
fn word_bounded(line: &str) -> String {
let mut normalized: String = line
.chars()
.map(|c| {
if c.is_ascii_punctuation() && c != '\'' {
' '
} else {
c
}
})
.collect();
normalized.push(' ');
normalized
}
fn is_repetitive_log(fragment: &ContextFragment, policy: &CompilePolicy) -> bool {
if fragment.kind.as_deref() != Some("log") {
return false;
}
let mut seen = std::collections::BTreeSet::new();
fragment.content.lines().any(|line| {
!line.trim().is_empty() && !seen.insert(dedup_key(line, policy.normalize_log_timestamps))
})
}
fn is_value_dense(fragment: &ContextFragment, _policy: &CompilePolicy) -> bool {
fragment
.content
.split_whitespace()
.filter(|token| token.bytes().any(|byte| byte.is_ascii_digit()))
.count()
>= 3
}
fn has_url(fragment: &ContextFragment, _policy: &CompilePolicy) -> bool {
fragment.content.contains("http://") || fragment.content.contains("https://")
}
pub(crate) fn collapse_repeated_lines(content: &str, normalize_timestamps: bool) -> (String, bool) {
let counts = line_groups(content, normalize_timestamps);
let mut emitted: std::collections::BTreeSet<std::borrow::Cow<'_, str>> =
std::collections::BTreeSet::new();
let mut lines: Vec<String> = Vec::new();
for line in content.lines() {
let key = dedup_key(line, normalize_timestamps);
if emitted.insert(key.clone()) {
lines.push(annotated(line, counts[&key]));
}
}
let modified = normalize_timestamps && counts.len() < line_groups(content, false).len();
(lines.join("\n"), modified)
}
fn dedup_key(line: &str, normalize_timestamps: bool) -> std::borrow::Cow<'_, str> {
if normalize_timestamps {
if let Some(masked) = super::log_normalize::mask_volatile_prefix(line) {
return std::borrow::Cow::Owned(masked);
}
}
std::borrow::Cow::Borrowed(line)
}
fn line_groups(
content: &str,
normalize_timestamps: bool,
) -> std::collections::BTreeMap<std::borrow::Cow<'_, str>, usize> {
let mut counts = std::collections::BTreeMap::new();
for line in content.lines() {
*counts
.entry(dedup_key(line, normalize_timestamps))
.or_insert(0) += 1;
}
counts
}
fn annotated(line: &str, count: usize) -> String {
if count > 1 {
format!("{line} (x{count})")
} else {
line.to_owned()
}
}
pub(crate) const SCREENSHOT_SUPERSEDED_RULE_ID: &str = "retrieve.screenshot_superseded";
pub(crate) const SCREENSHOT_SUPERSEDED_REASON: &str =
"superseded by a newer screenshot of the same target";
const SCREENSHOT_TARGET_KEY: &str = "target";
fn screenshot_target(fragment: &ContextFragment) -> Option<&Value> {
if fragment.media.is_none() || fragment.kind.as_deref() != Some("screenshot") {
return None;
}
fragment.metadata.as_ref()?.get(SCREENSHOT_TARGET_KEY)
}
pub(crate) fn screenshot_supersession(fragments: &[ContextFragment]) -> Vec<bool> {
let targets: Vec<Option<&Value>> = fragments.iter().map(screenshot_target).collect();
(0..fragments.len())
.map(|seq| match targets[seq] {
Some(target) => targets[seq + 1..].contains(&Some(target)),
None => false,
})
.collect()
}
#[cfg(test)]
#[path = "classify_tests.rs"]
mod tests;