Merge pull request #317 from chopratejas/rust-stage-3e-1-signals

feat(rust): signals trait module + KeywordDetector (Phase 3e.1)
This commit is contained in:
Tejas Chopra 2026-04-29 17:02:22 -07:00 committed by GitHub
commit cf3877de38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1210 additions and 95 deletions

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.14.2"
"version": "0.15.0"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.14.2",
"version": "0.15.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.14.2"
"version": "0.15.0"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.14.2",
"version": "0.15.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

1
Cargo.lock generated
View file

@ -1320,6 +1320,7 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
name = "headroom-core"
version = "0.1.0"
dependencies = [
"aho-corasick",
"bytes",
"criterion",
"dashmap",

View file

@ -183,6 +183,45 @@ so they don't regress further or get forgotten.
| Adaptive context windows | Honored byte-for-byte (parity fixture-locked). |
| TOIN integration | Never had one — DiffCompressor records via `_record_to_toin` in ContentRouter, which already runs for non-SmartCrusher strategies. No regression. |
### Phase 3e.1 — `signals/` trait module + KeywordDetector (2026-04-29)
The Python `error_detection.py` regex registry was retired and reborn as a
trait + tier system in `crates/headroom-core/src/signals/`. See
`signals/README.md` for the full architecture; the highlights:
- **Per-granularity traits.** `LineImportanceDetector` ships today; future
`ContentTypeDetector` and `ItemImportanceDetector<I>` will follow as their
consumers get touched.
- **`Tiered<T>` combinator.** Composition, not inheritance. Future ML
detectors slot in as new tiers without changes to `KeywordDetector` or
any caller.
- **One concrete impl.** `KeywordDetector` (aho-corasick) is the only tier
registered today. **No NoOp/stub impls** — per project no-silent-fallbacks
rule, future tiers land with their real implementations.
- **Bug fixes baked in.** `ERROR_KEYWORDS` regex now includes
`timeout|abort|denied|rejected` (previously drifted from the keyword set);
`token` dropped from `SECURITY_KEYWORDS` (false-positived on every LLM
metric reference). Both fixed in the Python regex too via the shim that
recompiles patterns from the Rust-exposed keyword tables.
- **Companion canonical extension path.** `signals/README.md` documents
the BGE classifier head — a 384-dim → 4-class softmax on top of the
already-loaded `bge-small-en-v1.5` embedder — as the natural ML tier.
Two alternatives kept open: distilled tinyBERT in ONNX, logistic
regression on lexical features.
### Phase 3g (queued) — Compression Pipeline Formalization (issue #315)
Strategic decision 2026-04-29: after Phase 3e (compressor ports) and
Phase 3f (Rust MCP scaffold) wrap, formalize the lossless-then-lossy-
then-CCR ordering as a cross-cutting `CompressionPipeline` orchestrator
+ `LosslessTransform` / `LossyTransform` traits in
`crates/headroom-core/src/pipeline/`. Existing compressors get
refactored as compositions of pluggable transforms. The crucial design
choice — **parsers for structure, models at the prose/structure
boundary** — is captured in issue #315 and
`memory/project_lossless_first_pipeline.md`. Do NOT start coding before
3e/3f finish.
### Watch list (potential regressions, not yet audited)
- `CCRConfig.enabled=False` end-to-end — **closed 2026-04-29**. Both `enabled=False` and `inject_retrieval_marker=False` collapse to the same Rust `enable_ccr_marker=False` gate (no marker, no store write). See the SmartCrusher table above.

View file

@ -69,6 +69,13 @@ magika = "1"
# bring `encoding_rs` for non-UTF8 sniffing — we keep them on for
# compatibility with arbitrary tool outputs.
unidiff = "0.4"
# `aho-corasick` powers the Tier-3 KeywordDetector in `signals/`.
# A single deterministic-finite-automaton scan finds every keyword
# in a line in O(n + m) — orders of magnitude faster than running N
# regex .search() calls and harder to misuse. Word-boundary checks
# happen as a post-filter on the byte offsets the automaton returns.
# Default features (std, perf-literal) keep the build small.
aho-corasick = "1"
[dev-dependencies]
proptest = "1"

View file

@ -2,6 +2,7 @@
pub mod ccr;
pub mod relevance;
pub mod signals;
pub mod tokenizer;
pub mod transforms;

View file

@ -0,0 +1,58 @@
# `signals/` — detection traits
Cross-cutting classifiers used by transforms. Lives at the crate root because the same classifier feeds many transforms; nesting under `transforms/` would imply ownership by one consumer.
## Trait family
| Trait | Granularity | Status |
|---|---|---|
| `LineImportanceDetector` | one line at a time | shipped (Phase 3e.1) |
| `ContentTypeDetector` | whole blob | future generalization of `transforms::detection` |
| `ItemImportanceDetector<I>` | `&[I]` ranking | future, for SmartCrusher cells / search hits |
## Tiering — composition, not inheritance
`Tiered<dyn Trait>` chains an ordered stack. The first tier whose signal exceeds `ESCALATE_THRESHOLD` (0.7) confidence wins; lower-confidence tiers fall through. If nothing crosses the threshold, the highest-confidence signal seen is returned so the caller still gets the best guess.
Today `KeywordDetector` is the only tier registered. The tier API is the seam where future ML detectors slot in.
## How to add a new detector
1. **Confirm granularity.** A line classifier implements `LineImportanceDetector`. A blob classifier gets a new trait. Don't shoehorn cross-granularity work into one trait.
2. **Implement `score(&self, ...) -> ImportanceSignal`.** Set `confidence` honestly: 0.7+ if the detector is the right authority for this input, lower if you want the next tier to override on disagreement.
3. **No silent fallbacks.** Per project conventions, return `ImportanceSignal::neutral()` when you have no information — never fabricate a positive answer with low confidence to "fail open".
4. **Wire into `Tiered` at the consumer**, not in this module. The detector itself doesn't know about other tiers.
5. **Add parity fixtures** if the detector replaces or augments an existing one. Mark divergence lines with `// fixed_in_<phase>` markers.
## Canonical future ML extension — BGE classifier head
The most likely next tier is a classification head on the existing `bge-small-en-v1.5` embedder loaded by `relevance::EmbeddingScorer`:
```rust
pub struct BgeClassifierDetector {
embedder: Arc<dyn Embedder>, // shared with relevance scoring
classifier: LogisticRegression, // 384-dim → 4-class softmax
threshold: f32, // calibrated on validation set
}
impl LineImportanceDetector for BgeClassifierDetector { ... }
```
Why this is the cheapest path:
- The embedder is already loaded for SmartCrusher relevance scoring. A classification head adds ~1.5 KB of weights and ~1 ms inference per line (batchable).
- No new ONNX runtime, no new model file, no new download.
- Calibrated confidence lets the head short-circuit `KeywordDetector` on high-confidence positives but step aside on borderlines (where the keyword automaton is reliable anyway).
Two alternatives kept open in case BGE-head underfits:
- **Distilled tinyBERT (ONNX)** — more accurate, +1020 MB model, +3 ms latency, new `ort` dependency.
- **Logistic regression on lexical features** — caps ratio, line length, structural markers, stack-frame heuristics. ~5 KB model, fastest of the three. Good A/B baseline.
The trait shape accepts all three without changes.
## What does NOT live here
- Concrete transforms — they go in `crates/headroom-core/src/transforms/`.
- Static keyword data tables — they're configuration for `KeywordDetector`, not detection logic. They live alongside the detector that consumes them (`signals/keyword_detector.rs::KeywordRegistry`).
- Tag protection (`<headroom:keep>` markers) — that's user intent, not classification.

View file

@ -0,0 +1,433 @@
//! Tier-3 pattern-based [`super::LineImportanceDetector`] backed by
//! `aho-corasick`.
//!
//! Replaces the Python `error_detection.py` regex registry. A single
//! deterministic-finite-automaton scan finds every keyword on a line in
//! `O(n + m)` — much faster than `len(patterns)` independent regex
//! searches, and it's harder to misuse (one source of truth for the
//! keyword set, no drift between sets and compiled patterns).
//!
//! # Bug fixes vs Python (2026-04-29)
//!
//! Python's `error_detection.py` had two bugs the parity fixtures
//! lock against:
//!
//! 1. `ERROR_KEYWORDS` listed `{abort, timeout, denied, rejected}` but
//! `ERROR_PATTERN` regex omitted all four. Lines saying
//! "Connection timeout" therefore never flagged as errors despite
//! the keyword being canonical. **Fixed here**: the four keywords
//! are part of the error set the automaton consumes.
//! 2. `SECURITY_KEYWORDS` included `token`, which false-positives on
//! every reference to LLM tokens (`input_tokens`,
//! `tokens_saved`, …). In an LLM-token-saturated codebase the
//! security signal was uselessly noisy. **Fixed here**: `token` is
//! dropped from the security set.
//!
//! Parity fixtures (in `tests/`) carry explicit `// fixed_in_3e1`
//! markers on each diverging line so the audit trail is clear.
use std::collections::BTreeMap;
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
use super::line_importance::{
ImportanceCategory, ImportanceContext, ImportanceSignal, LineImportanceDetector,
};
/// Confidence used by the keyword tier. Below the
/// [`super::tiered::ESCALATE_THRESHOLD`] used by [`super::tiered::Tiered`]
/// so a future ML tier can override on borderline cases — but high
/// enough that an unambiguous keyword match isn't second-guessed.
const KEYWORD_CONFIDENCE: f32 = 0.7;
/// Priority returned for a confirmed match. Compressors use this as the
/// score they sort by; tweak per category if a future caller wants
/// errors to outrank importance markers in routing decisions.
const ERROR_PRIORITY: f32 = 0.95;
const WARNING_PRIORITY: f32 = 0.75;
const SECURITY_PRIORITY: f32 = 0.85;
const IMPORTANCE_PRIORITY: f32 = 0.6;
const MARKDOWN_PRIORITY: f32 = 0.45;
/// Static keyword data for each importance category.
///
/// Exported so the Python shim can reflect on it for legacy regex
/// re-export. A `BTreeMap` keeps iteration order deterministic without
/// extra allocations.
#[derive(Debug, Clone)]
pub struct KeywordRegistry {
pub error: Vec<&'static str>,
pub warning: Vec<&'static str>,
pub importance: Vec<&'static str>,
pub security: Vec<&'static str>,
/// Per-context line prefixes that count as importance signals (e.g.
/// markdown headers `# `, blockquotes `> `). Matched as
/// *prefix-only*, not whole-line keywords.
pub markdown_prefixes: Vec<&'static str>,
/// Substring indicators used by [`KeywordDetector::contains_error_indicator`]
/// for fast triage (no word-boundary requirement). Distinct from
/// `error` because the triage callsite (e.g. message-signature
/// classification) cares about Python tracebacks specifically.
pub error_indicators: Vec<&'static str>,
}
impl KeywordRegistry {
/// The default Headroom keyword set — superset of Python's pre-3e.1
/// `error_detection.py` minus the dropped `token` security keyword
/// and plus the four error keywords the Python regex was missing.
pub fn default_set() -> Self {
Self {
error: vec![
"error",
"exception",
"fail",
"failed",
"failure",
"fatal",
"critical",
"crash",
"panic",
"abort",
"timeout",
"denied",
"rejected",
],
warning: vec!["warn", "warning"],
importance: vec![
"important",
"note",
"todo",
"fixme",
"hack",
"xxx",
"bug",
"fix",
],
security: vec!["security", "auth", "password", "secret"],
markdown_prefixes: vec!["# ", "## ", "### ", "#### ", "**", "> "],
error_indicators: vec![
"error",
"fail",
"exception",
"traceback",
"fatal",
"panic",
"crash",
],
}
}
/// Snapshot for Python-side reflection. `BTreeMap` so iteration is
/// deterministic across PyO3 calls.
pub fn as_map(&self) -> BTreeMap<&'static str, Vec<&'static str>> {
let mut m = BTreeMap::new();
m.insert("error", self.error.clone());
m.insert("warning", self.warning.clone());
m.insert("importance", self.importance.clone());
m.insert("security", self.security.clone());
m.insert("markdown_prefixes", self.markdown_prefixes.clone());
m.insert("error_indicators", self.error_indicators.clone());
m
}
}
/// One automaton + the parallel category lookup table. The automaton is
/// built case-insensitively; word-boundary checks happen as a post-filter
/// on the byte offsets it returns.
struct CategoryAutomaton {
automaton: AhoCorasick,
categories: Vec<ImportanceCategory>,
}
impl CategoryAutomaton {
fn build(entries: &[(ImportanceCategory, &[&'static str])]) -> Self {
let mut patterns = Vec::new();
let mut categories = Vec::new();
for (cat, words) in entries {
for w in *words {
patterns.push(*w);
categories.push(*cat);
}
}
let automaton = AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.match_kind(MatchKind::LeftmostLongest)
.build(&patterns)
.expect("keyword automaton must build (static input)");
Self {
automaton,
categories,
}
}
/// Highest-priority category whose keyword appears as a *whole word*
/// in `line`, or `None` if nothing matched.
fn first_word_match(&self, line: &str) -> Option<ImportanceCategory> {
let bytes = line.as_bytes();
for m in self.automaton.find_iter(line) {
if is_word_boundary(bytes, m.start(), m.end()) {
return Some(self.categories[m.pattern().as_usize()]);
}
}
None
}
}
/// Pattern-based [`LineImportanceDetector`] backed by aho-corasick.
///
/// Construct with [`KeywordDetector::new`] for the default Headroom
/// keyword set, or [`KeywordDetector::with_registry`] for a custom one.
pub struct KeywordDetector {
registry: KeywordRegistry,
/// Categories that fire across all contexts (error/importance).
universal: CategoryAutomaton,
/// Warning fires in Search/Log/Text contexts but is omitted in
/// Diff (matches Python's `PRIORITY_PATTERNS_DIFF` shape).
warning: CategoryAutomaton,
/// Security fires in Diff context only.
security: CategoryAutomaton,
/// Substring-only indicators for fast triage; deliberately separate
/// from `universal` because (a) it matches without word boundaries
/// and (b) the indicator set diverges from the line-scoring set
/// (carries `traceback`, omits the four extras like `timeout`).
indicators: AhoCorasick,
}
impl KeywordDetector {
pub fn new() -> Self {
Self::with_registry(KeywordRegistry::default_set())
}
pub fn with_registry(registry: KeywordRegistry) -> Self {
let universal = CategoryAutomaton::build(&[
(ImportanceCategory::Error, &registry.error),
(ImportanceCategory::Importance, &registry.importance),
]);
let warning = CategoryAutomaton::build(&[(ImportanceCategory::Warning, &registry.warning)]);
let security =
CategoryAutomaton::build(&[(ImportanceCategory::Security, &registry.security)]);
let indicators = AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.match_kind(MatchKind::LeftmostLongest)
.build(&registry.error_indicators)
.expect("indicator automaton must build (static input)");
Self {
registry,
universal,
warning,
security,
indicators,
}
}
/// Fast keyword-presence check used by callers that only want
/// "does this contain anything error-shaped?" (the legacy
/// `content_has_error_indicators` callsite).
///
/// Substring match — no word-boundary requirement — to preserve
/// the lax semantics Python had. Distinct keyword set from
/// [`Self::score`] (carries `traceback`, omits the four 3e1 extras
/// like `timeout`) because the triage callsite cares about
/// Python-style exception output more than connection states.
pub fn contains_error_indicator(&self, text: &str) -> bool {
self.indicators.is_match(text)
}
pub fn registry(&self) -> &KeywordRegistry {
&self.registry
}
fn match_in_context(
&self,
line: &str,
ctx: ImportanceContext,
) -> Option<(ImportanceCategory, f32)> {
if let Some(cat) = self.universal.first_word_match(line) {
let priority = priority_for(cat);
return Some((cat, priority));
}
match ctx {
ImportanceContext::Diff => {
if let Some(cat) = self.security.first_word_match(line) {
return Some((cat, priority_for(cat)));
}
}
ImportanceContext::Text | ImportanceContext::Search | ImportanceContext::Log => {
if let Some(cat) = self.warning.first_word_match(line) {
return Some((cat, priority_for(cat)));
}
}
}
// Markdown structural prefixes only count in Text context.
if matches!(ctx, ImportanceContext::Text) {
if let Some(prefix) = self
.registry
.markdown_prefixes
.iter()
.find(|p| line.starts_with(*p))
{
let _ = prefix;
return Some((ImportanceCategory::Markdown, MARKDOWN_PRIORITY));
}
}
None
}
}
impl Default for KeywordDetector {
fn default() -> Self {
Self::new()
}
}
impl LineImportanceDetector for KeywordDetector {
fn score(&self, line: &str, ctx: ImportanceContext) -> ImportanceSignal {
match self.match_in_context(line, ctx) {
Some((category, priority)) => {
ImportanceSignal::matched(category, priority, KEYWORD_CONFIDENCE)
}
None => ImportanceSignal::neutral(),
}
}
}
const fn priority_for(category: ImportanceCategory) -> f32 {
match category {
ImportanceCategory::Error => ERROR_PRIORITY,
ImportanceCategory::Warning => WARNING_PRIORITY,
ImportanceCategory::Security => SECURITY_PRIORITY,
ImportanceCategory::Importance => IMPORTANCE_PRIORITY,
ImportanceCategory::Markdown => MARKDOWN_PRIORITY,
}
}
/// True when `[start..end)` in `bytes` is bounded by a non-word-character
/// (or string boundary) on each side. ASCII word characters: `[A-Za-z0-9_]`.
fn is_word_boundary(bytes: &[u8], start: usize, end: usize) -> bool {
let left_ok = start == 0 || !is_word_byte(bytes[start - 1]);
let right_ok = end == bytes.len() || !is_word_byte(bytes[end]);
left_ok && right_ok
}
#[inline]
fn is_word_byte(b: u8) -> bool {
matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_')
}
#[cfg(test)]
mod tests {
use super::*;
fn detect(line: &str, ctx: ImportanceContext) -> ImportanceSignal {
KeywordDetector::new().score(line, ctx)
}
#[test]
fn fires_on_uppercase_error_in_search() {
let s = detect("ERROR: connection refused", ImportanceContext::Search);
assert_eq!(s.category, Some(ImportanceCategory::Error));
assert!(s.priority > 0.9);
}
#[test]
fn timeout_now_classified_as_error_in_diff() {
// fixed_in_3e1: Python's ERROR_PATTERN regex omitted "timeout",
// so this line was misclassified as neutral despite being
// canonical in ERROR_KEYWORDS.
let s = detect(
"FATAL: timeout connecting upstream",
ImportanceContext::Diff,
);
assert_eq!(s.category, Some(ImportanceCategory::Error));
}
#[test]
fn rejected_now_classified_as_error() {
// fixed_in_3e1: parity gap with Python.
let s = detect("auth request rejected", ImportanceContext::Diff);
assert_eq!(s.category, Some(ImportanceCategory::Error));
}
#[test]
fn token_no_longer_flags_security_in_llm_proxy_context() {
// fixed_in_3e1: dropping "token" from the security set means an
// LLM-metric line stops false-positively routing as a security
// signal.
let s = detect(
"input_tokens=512 output_tokens=256",
ImportanceContext::Diff,
);
assert!(!s.is_match());
}
#[test]
fn auth_still_flags_security_in_diff() {
let s = detect("missing auth header", ImportanceContext::Diff);
assert_eq!(s.category, Some(ImportanceCategory::Security));
}
#[test]
fn warning_fires_in_search_but_not_diff() {
let in_search = detect("warning: deprecated API", ImportanceContext::Search);
assert_eq!(in_search.category, Some(ImportanceCategory::Warning));
// Python's PRIORITY_PATTERNS_DIFF excluded WARNING_PATTERN; we
// preserve that.
let in_diff = detect(
"warning: deprecated API alone with no errors",
ImportanceContext::Diff,
);
assert_ne!(in_diff.category, Some(ImportanceCategory::Warning));
}
#[test]
fn markdown_header_fires_only_in_text() {
let in_text = detect("# Important section", ImportanceContext::Text);
// "important" is itself an importance keyword, so this line
// fires as Importance (universal) before we reach the markdown
// prefix check. Drop the keyword to isolate the prefix path.
let _ = in_text;
let prefix_only = detect("# Section", ImportanceContext::Text);
assert_eq!(prefix_only.category, Some(ImportanceCategory::Markdown));
let same_line_in_diff = detect("# Section", ImportanceContext::Diff);
assert!(!same_line_in_diff.is_match());
}
#[test]
fn word_boundary_excludes_substring_matches() {
// Without word boundaries, "preferred" would match "fail" via
// the substring "fer" -> not a real risk, but
// "tokenize" must NOT be misread as the error keyword "token"
// (we dropped that one anyway), and "panicker" must not match
// "panic" inside a normal English word.
let s = detect("the panicker showed up late", ImportanceContext::Search);
assert!(!s.is_match());
}
#[test]
fn neutral_line_returns_zero_confidence() {
let s = detect("the quick brown fox", ImportanceContext::Text);
assert!(!s.is_match());
assert_eq!(s.confidence, 0.0);
}
#[test]
fn contains_error_indicator_is_lax_substring_match() {
// Preserves Python `content_has_error_indicators` semantics:
// "errored" -> matches "error". This is intentional for fast
// triage; the strict version is `score()`.
let det = KeywordDetector::new();
assert!(det.contains_error_indicator("the request errored out"));
assert!(det.contains_error_indicator("traceback follows"));
assert!(!det.contains_error_indicator("everything is fine"));
}
#[test]
fn registry_snapshot_has_token_dropped() {
let reg = KeywordRegistry::default_set();
assert!(!reg.security.contains(&"token"));
assert!(reg.security.contains(&"auth"));
assert!(reg.error.contains(&"timeout"));
assert!(reg.error.contains(&"abort"));
}
}

View file

@ -0,0 +1,84 @@
//! Line-level importance detection trait.
//!
//! Compressors call this when deciding which lines to drop under a token
//! budget. The signal carries category, priority, and confidence — never
//! a bare bool — so future tiers can short-circuit on high confidence
//! and lower-priority callers can fall through.
/// Where the line came from. Determines which pattern set fires (e.g.
/// markdown headers count as priority signals in prose, but not in diff
/// hunks).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ImportanceContext {
/// Free-form prose (text_compressor) — markdown structure matters.
Text,
/// grep/ripgrep output (search_compressor) — error/warn keywords win.
Search,
/// git diff (diff_compressor) — error + security + importance keywords.
Diff,
/// Log output (log_compressor) — error/warn keywords + level prefixes.
Log,
}
/// Why a line earned its priority.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ImportanceCategory {
Error,
Warning,
Importance,
Security,
/// Markdown structure — headers, bold, blockquotes. Only meaningful
/// in `ImportanceContext::Text`.
Markdown,
}
/// Output of a single detector for a single line.
///
/// `priority` is what compressors rank by; `confidence` is what the
/// [`super::tiered::Tiered`] combinator uses to decide whether to keep
/// asking the next tier.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ImportanceSignal {
/// The category the detector matched on, if any.
pub category: Option<ImportanceCategory>,
/// 0.0 = drop first, 1.0 = keep at all costs.
pub priority: f32,
/// 0.0 = no information, 1.0 = the detector is sure.
pub confidence: f32,
}
impl ImportanceSignal {
/// "I have no opinion on this line." Returned when nothing matched.
pub const fn neutral() -> Self {
Self {
category: None,
priority: 0.0,
confidence: 0.0,
}
}
/// A fired detection with explicit category and priority.
pub const fn matched(category: ImportanceCategory, priority: f32, confidence: f32) -> Self {
Self {
category: Some(category),
priority,
confidence,
}
}
/// True when the detector saw something it recognized.
pub fn is_match(&self) -> bool {
self.category.is_some()
}
}
/// Single-line importance classifier.
///
/// Implementations are expected to be cheap (keyword automaton, lexical
/// features) or amortizable (embedding+classifier head with batched
/// inference). They MUST be `Send + Sync` because compressors share
/// detector instances across tokio worker threads.
pub trait LineImportanceDetector: Send + Sync {
/// Score a single line in the given context.
fn score(&self, line: &str, ctx: ImportanceContext) -> ImportanceSignal;
}

View file

@ -0,0 +1,61 @@
//! Detection-trait module — cross-cutting classifiers used by transforms.
//!
//! # Why a top-level module
//!
//! Transforms in [`crate::transforms`] mutate data; signals in this module
//! *classify* it. The same classifier feeds many transforms (e.g. line
//! importance scoring is consumed by `text_compressor`, `search_compressor`,
//! `diff_compressor`, and `log_compressor`), so the layering belongs at the
//! crate root, not nested under any one transform.
//!
//! # The shape we follow
//!
//! Detection in Headroom matures along a known curve:
//!
//! 1. **Pattern fallback** — keyword/regex scanning. Cheap, brittle, the
//! starting point for every detector. This is what
//! [`keyword_detector::KeywordDetector`] gives us today.
//! 2. **Structured parser** — when the input has grammar (diffs, JSON,
//! code), parse it. Already done for `unidiff` (content type) and
//! `tree-sitter` (language).
//! 3. **ML model** — for fuzzy categories (line importance, anchor cells,
//! HTML extraction), a small classifier trained on labeled traffic
//! outperforms keywords. The canonical extension path here is a
//! classification head on the existing `bge-small-en-v1.5` embedder
//! (already loaded for `relevance`); see `signals/README.md`.
//!
//! All three live behind the same per-granularity trait. Tiering is
//! *composition* via [`tiered::Tiered`] — never inheritance. A future ML
//! detector slots in as a new tier without touching the keyword detector
//! or any caller.
//!
//! # Per-granularity, not per-domain
//!
//! Different inputs warrant different trait signatures:
//!
//! - [`line_importance::LineImportanceDetector`] — single line → priority
//! - (future) `ContentTypeDetector` — whole blob → category
//! - (future) `ItemImportanceDetector<I>` — `&[I]` → ranking
//!
//! Cramming everything into one `Detector<Any>` would force callers to
//! match on input shape at every site. Three traits keep each callsite
//! type-checked.
//!
//! # No silent fallbacks
//!
//! Per project conventions, every concrete impl in this module is real.
//! No `NoOpDetector`, no stub-ML impl that returns zeros, no
//! "fallback" classifier that quietly degrades. If a tier is registered,
//! it does the work; if no tier confidently matches, the signal carries
//! that fact in its `confidence` field rather than being silently
//! coerced to a positive answer.
pub mod keyword_detector;
pub mod line_importance;
pub mod tiered;
pub use keyword_detector::{KeywordDetector, KeywordRegistry};
pub use line_importance::{
ImportanceCategory, ImportanceContext, ImportanceSignal, LineImportanceDetector,
};
pub use tiered::Tiered;

View file

@ -0,0 +1,141 @@
//! Composition combinator for layered detectors.
//!
//! `Tiered<dyn Trait>` chains an ordered list of detectors. The first
//! tier whose signal exceeds [`ESCALATE_THRESHOLD`] confidence wins;
//! lower-confidence tiers are skipped past. If no tier exceeds the
//! threshold, the highest-confidence signal seen is returned (so the
//! caller still gets the best guess, with the confidence score
//! reflecting how unsure the stack is).
//!
//! Tiering is *composition*, not inheritance. `KeywordDetector` knows
//! nothing about a future ML detector; the ML detector knows nothing
//! about the keyword detector. They both implement the trait and the
//! `Tiered` wrapper orders them.
use super::line_importance::{ImportanceContext, ImportanceSignal, LineImportanceDetector};
/// Confidence at which `Tiered` accepts a tier's signal without
/// consulting later tiers. KeywordDetector emits 0.7, so it wins by
/// default; an ML tier with calibrated confidence ≥ 0.8 (high-precision
/// region) would short-circuit the keyword tier.
pub const ESCALATE_THRESHOLD: f32 = 0.7;
pub struct Tiered<T: ?Sized> {
tiers: Vec<Box<T>>,
}
impl<T: ?Sized> Tiered<T> {
pub fn new() -> Self {
Self { tiers: Vec::new() }
}
/// Push a tier onto the stack. Order matters: most-precise first.
pub fn with(mut self, tier: Box<T>) -> Self {
self.tiers.push(tier);
self
}
pub fn len(&self) -> usize {
self.tiers.len()
}
pub fn is_empty(&self) -> bool {
self.tiers.is_empty()
}
}
impl<T: ?Sized> Default for Tiered<T> {
fn default() -> Self {
Self::new()
}
}
impl LineImportanceDetector for Tiered<dyn LineImportanceDetector> {
fn score(&self, line: &str, ctx: ImportanceContext) -> ImportanceSignal {
let mut best = ImportanceSignal::neutral();
for tier in &self.tiers {
let signal = tier.score(line, ctx);
if signal.confidence >= ESCALATE_THRESHOLD {
return signal;
}
if signal.confidence > best.confidence {
best = signal;
}
}
best
}
}
impl Tiered<dyn LineImportanceDetector> {
/// Convenience: take an owned detector, box it, coerce it to the
/// trait object. Keeps callsites free of `as Box<dyn …>` clutter.
pub fn with_detector<D: LineImportanceDetector + 'static>(self, detector: D) -> Self {
self.with(Box::new(detector) as Box<dyn LineImportanceDetector>)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signals::keyword_detector::KeywordDetector;
use crate::signals::line_importance::ImportanceCategory;
/// Synthetic high-confidence detector for testing short-circuit
/// behavior. Always asserts a specific signal so we can prove
/// `Tiered` consults it before the keyword tier.
struct AlwaysFiresHigh;
impl LineImportanceDetector for AlwaysFiresHigh {
fn score(&self, _line: &str, _ctx: ImportanceContext) -> ImportanceSignal {
ImportanceSignal::matched(ImportanceCategory::Security, 0.99, 0.95)
}
}
/// Synthetic low-confidence detector. Confidence 0.5 is below the
/// escalate threshold so `Tiered` MUST fall through to the next
/// tier.
struct AlwaysFiresLow;
impl LineImportanceDetector for AlwaysFiresLow {
fn score(&self, _line: &str, _ctx: ImportanceContext) -> ImportanceSignal {
ImportanceSignal::matched(ImportanceCategory::Importance, 0.4, 0.5)
}
}
#[test]
fn high_confidence_tier_short_circuits() {
let tiered: Tiered<dyn LineImportanceDetector> = Tiered::new()
.with_detector(AlwaysFiresHigh)
.with_detector(KeywordDetector::new());
let s = tiered.score("ERROR: connection refused", ImportanceContext::Diff);
// AlwaysFiresHigh asserts Security; if the keyword detector ran
// it would have asserted Error.
assert_eq!(s.category, Some(ImportanceCategory::Security));
}
#[test]
fn low_confidence_tier_falls_through_to_keyword() {
let tiered: Tiered<dyn LineImportanceDetector> = Tiered::new()
.with_detector(AlwaysFiresLow)
.with_detector(KeywordDetector::new());
let s = tiered.score("ERROR: connection refused", ImportanceContext::Diff);
assert_eq!(s.category, Some(ImportanceCategory::Error));
}
#[test]
fn no_tier_matches_returns_best_seen() {
let tiered: Tiered<dyn LineImportanceDetector> = Tiered::new()
.with_detector(AlwaysFiresLow)
.with_detector(KeywordDetector::new());
let s = tiered.score("the quick brown fox", ImportanceContext::Text);
// Keyword detector returns neutral (confidence 0.0); AlwaysFiresLow
// returned Importance @ 0.5 so that wins as best-seen.
assert_eq!(s.category, Some(ImportanceCategory::Importance));
assert_eq!(s.confidence, 0.5);
}
#[test]
fn empty_stack_returns_neutral() {
let tiered: Tiered<dyn LineImportanceDetector> = Tiered::new();
let s = tiered.score("anything", ImportanceContext::Text);
assert!(!s.is_match());
}
}

View file

@ -15,6 +15,9 @@
use std::collections::BTreeMap;
use headroom_core::signals::{
ImportanceCategory, ImportanceContext, KeywordDetector, KeywordRegistry, LineImportanceDetector,
};
use headroom_core::transforms::smart_crusher::compaction::DocumentCompactor;
use headroom_core::transforms::smart_crusher::{
CrushResult as RustCrushResult, SmartCrusher as RustSmartCrusher,
@ -926,6 +929,91 @@ const _: fn() = || {
let _ = RustContentType::PlainText;
};
// ─── signals: line-importance detector bridge ────────────────────────────
//
// One process-wide [`KeywordDetector`] is shared via `OnceLock` because
// the underlying aho-corasick automaton is stateless and cheap to clone
// nothing on call. The Python shim re-exports the keyword tables and a
// pair of thin functions; that's enough surface for the legacy
// `error_detection` callers without dragging the trait into Python.
use std::sync::OnceLock;
fn shared_keyword_detector() -> &'static KeywordDetector {
static DETECTOR: OnceLock<KeywordDetector> = OnceLock::new();
DETECTOR.get_or_init(KeywordDetector::new)
}
/// Returns `Some(ctx)` for known names and `None` otherwise — caller
/// converts to PyValueError. Avoids the pyo3-0.22 + clippy
/// `useless_conversion` false positive that fires when `?` propagates a
/// `PyResult<_>` through another `PyResult<_>`.
fn ctx_from_str(name: &str) -> Option<ImportanceContext> {
match name {
"text" => Some(ImportanceContext::Text),
"search" => Some(ImportanceContext::Search),
"diff" => Some(ImportanceContext::Diff),
"log" => Some(ImportanceContext::Log),
_ => None,
}
}
fn category_to_str(cat: ImportanceCategory) -> &'static str {
match cat {
ImportanceCategory::Error => "error",
ImportanceCategory::Warning => "warning",
ImportanceCategory::Importance => "importance",
ImportanceCategory::Security => "security",
ImportanceCategory::Markdown => "markdown",
}
}
/// Score a line against the default Headroom keyword detector.
///
/// Returns `Some((category | None, priority, confidence))` for known
/// contexts (`text|search|diff|log`) and `None` for an unknown context
/// — the Python shim translates `None` into `ValueError` for the
/// caller. Returning `Option` instead of `PyResult` dodges the
/// pyo3-0.22 + clippy `useless_conversion` false positive that the
/// `#[pyfunction]` macro triggers when its inner result-shape carries
/// `PyErr`. The bridge layer is the right place for this conversion;
/// keeping the Rust signature panic-free and `PyResult`-free is worth
/// a one-line shim on the Python side.
#[pyfunction]
#[pyo3(signature = (line, context = "text"))]
fn score_line(line: &str, context: &str) -> Option<(Option<&'static str>, f32, f32)> {
let ctx = ctx_from_str(context)?;
let signal = shared_keyword_detector().score(line, ctx);
Some((
signal.category.map(category_to_str),
signal.priority,
signal.confidence,
))
}
/// Lax substring check: does `text` contain any error indicator? Mirrors
/// Python `error_detection.content_has_error_indicators`.
#[pyfunction]
fn content_has_error_indicators(text: &str) -> bool {
shared_keyword_detector().contains_error_indicator(text)
}
/// Snapshot of the default keyword sets, exposed as a dict so the Python
/// shim can recompile the legacy `re.Pattern` objects without
/// re-declaring keyword data on the Python side. Uses `.unwrap()` on
/// `set_item` because keys are static str literals and values are
/// `Vec<&'static str>`, which can't fail — and avoids the pyo3-0.22
/// `useless_conversion` clippy false positive.
#[pyfunction]
fn keyword_registry_snapshot(py: Python<'_>) -> Py<PyDict> {
let registry = KeywordRegistry::default_set();
let dict = PyDict::new_bound(py);
for (key, words) in registry.as_map() {
dict.set_item(key, words).unwrap();
}
dict.unbind()
}
// ─── Module init ───────────────────────────────────────────────────────────
#[pymodule]
@ -941,5 +1029,8 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyDetectionResult>()?;
m.add_function(wrap_pyfunction!(detect_content_type, m)?)?;
m.add_function(wrap_pyfunction!(is_json_array_of_dicts, m)?)?;
m.add_function(wrap_pyfunction!(score_line, m)?)?;
m.add_function(wrap_pyfunction!(content_has_error_indicators, m)?)?;
m.add_function(wrap_pyfunction!(keyword_registry_snapshot, m)?)?;
Ok(())
}

View file

@ -1,89 +1,114 @@
"""Centralized error/importance detection for all transforms.
"""Centralized error/importance detection — thin Python shim over Rust.
Design principle: Keywords serve as a FALLBACK safety net for error detection.
When TOIN field semantics are available, they take priority over keywords.
Phase 3e.1 ported the keyword data + scoring logic to
``crates/headroom-core/src/signals/`` (see the trait architecture in
``signals/README.md``). This module is now a compatibility surface that:
This module prevents each transform from maintaining its own hardcoded keyword
list, ensuring consistency and a single place to evolve detection logic.
1. Pulls the keyword tables out of Rust via
``headroom._core.keyword_registry_snapshot()`` so the Python side
never re-declares them and cannot drift from the Rust source of
truth.
2. Re-exports the legacy ``frozenset`` and compiled-regex names
(``ERROR_KEYWORDS``, ``ERROR_PATTERN``, ``PRIORITY_PATTERNS_TEXT``,
) so the existing callers in ``text_compressor``,
``search_compressor``, ``diff_compressor``, and
``intelligent_context`` keep working without same-PR refactors.
3. Delegates ``content_has_error_indicators`` to the Rust
aho-corasick automaton.
Caller migration to the trait API happens in the per-compressor port
PRs that follow (Phase 3e.2 onward); this shim is the bridge until
those land.
# Bug fixes baked in
The Rust implementation fixes two bugs the Python originals carried:
* ``ERROR_KEYWORDS`` listed ``timeout``/``abort``/``denied``/
``rejected`` but ``ERROR_PATTERN`` regex omitted them. The
recompiled pattern below now includes all four lines like
``"FATAL: timeout connecting upstream"`` now flag as errors via
the regex too.
* ``token`` was dropped from ``SECURITY_KEYWORDS`` (it false-positived
on every reference to LLM tokens input_tokens, tokens_saved, ).
"""
from __future__ import annotations
import re
from typing import cast
# ─── Canonical keyword sets ──────────────────────────────────────────────────
# These are the FALLBACK when TOIN semantics aren't available yet.
# They are intentionally broad to avoid missing errors.
ERROR_KEYWORDS: frozenset[str] = frozenset(
{
"error",
"exception",
"failed",
"failure",
"critical",
"fatal",
"crash",
"panic",
"abort",
"timeout",
"denied",
"rejected",
}
from headroom._core import (
content_has_error_indicators as _rust_content_has_error_indicators,
)
from headroom._core import (
keyword_registry_snapshot as _rust_keyword_registry_snapshot,
)
from headroom._core import (
score_line as _rust_score_line,
)
# Broader importance keywords (for line-level scoring, not item preservation)
def score_line(line: str, context: str = "text") -> tuple[str | None, float, float]:
"""Score `line` against the default Rust keyword detector.
Returns ``(category | None, priority, confidence)``. ``category`` is
one of ``error|warning|importance|security|markdown`` or ``None`` if
nothing matched.
Raises :class:`ValueError` for unknown context names. The Rust
binding returns ``None`` for unknown contexts to dodge a
pyo3-0.22 + clippy false positive on ``PyResult``-returning
``#[pyfunction]``s; this shim translates that into the explicit
Python error every caller would expect.
"""
result = _rust_score_line(line, context)
if result is None:
raise ValueError(f"unknown importance context: {context}")
return cast("tuple[str | None, float, float]", result)
_REGISTRY: dict[str, list[str]] = _rust_keyword_registry_snapshot()
def _alternation(words: list[str]) -> str:
"""Compile a `\b(w1|w2|…)\b` regex source from the Rust-supplied list.
The keywords are static (compiled once on import) so we don't need
`re.escape` for the current set, but using it keeps the shim
correct if a future Rust update adds a regex meta-character.
"""
escaped = [re.escape(w) for w in words]
return r"\b(" + "|".join(escaped) + r")\b"
# ─── Canonical keyword sets (pulled from Rust at import time) ───────────────
ERROR_KEYWORDS: frozenset[str] = frozenset(_REGISTRY["error"])
# Importance keywords historically included the error set — preserve that
# union so consumers iterating the set get the same membership as before.
IMPORTANCE_KEYWORDS: frozenset[str] = frozenset(
ERROR_KEYWORDS
| {
"warning",
"warn",
"todo",
"fixme",
"hack",
"xxx",
"bug",
"fix",
"important",
"note",
}
list(_REGISTRY["error"]) + list(_REGISTRY["importance"]) + list(_REGISTRY["warning"])
)
# Security-related keywords (for diff/search prioritization)
SECURITY_KEYWORDS: frozenset[str] = frozenset(
{
"security",
"auth",
"password",
"secret",
"token",
}
)
SECURITY_KEYWORDS: frozenset[str] = frozenset(_REGISTRY["security"])
# ─── Compiled patterns (for line-level matching) ────────────────────────────
# Shared across text_compressor, diff_compressor, search_compressor
ERROR_INDICATOR_KEYWORDS: tuple[str, ...] = tuple(_REGISTRY["error_indicators"])
ERROR_PATTERN: re.Pattern[str] = re.compile(
r"\b(error|exception|fail(?:ed|ure)?|fatal|critical|crash|panic)\b",
re.IGNORECASE,
)
WARNING_PATTERN: re.Pattern[str] = re.compile(
r"\b(warn(?:ing)?)\b",
re.IGNORECASE,
)
# ─── Compiled patterns ──────────────────────────────────────────────────────
ERROR_PATTERN: re.Pattern[str] = re.compile(_alternation(_REGISTRY["error"]), re.IGNORECASE)
WARNING_PATTERN: re.Pattern[str] = re.compile(_alternation(_REGISTRY["warning"]), re.IGNORECASE)
IMPORTANCE_PATTERN: re.Pattern[str] = re.compile(
r"\b(important|note|todo|fixme|hack|xxx|bug|fix)\b",
re.IGNORECASE,
_alternation(_REGISTRY["importance"]), re.IGNORECASE
)
SECURITY_PATTERN: re.Pattern[str] = re.compile(_alternation(_REGISTRY["security"]), re.IGNORECASE)
SECURITY_PATTERN: re.Pattern[str] = re.compile(
r"\b(security|auth|password|secret|token)\b",
re.IGNORECASE,
)
# Pre-built pattern lists for each compressor context
# ─── Per-context priority pattern lists ─────────────────────────────────────
PRIORITY_PATTERNS_SEARCH: list[re.Pattern[str]] = [
ERROR_PATTERN,
WARNING_PATTERN,
@ -96,33 +121,42 @@ PRIORITY_PATTERNS_DIFF: list[re.Pattern[str]] = [
SECURITY_PATTERN,
]
# Markdown structural prefixes: matched on whole lines, anchored with `^`.
# Pulled from Rust so the prefix table can't drift either.
PRIORITY_PATTERNS_TEXT: list[re.Pattern[str]] = [
ERROR_PATTERN,
IMPORTANCE_PATTERN,
re.compile(r"^#+\s"), # Markdown headers
re.compile(r"^\*\*"), # Bold text
re.compile(r"^>\s"), # Quotes
*(re.compile("^" + re.escape(prefix)) for prefix in _REGISTRY["markdown_prefixes"]),
]
# ─── Quick check for message-level error indicators ─────────────────────────
# Used by intelligent_context.py for message signature creation
ERROR_INDICATOR_KEYWORDS: tuple[str, ...] = (
"error",
"fail",
"exception",
"traceback",
"fatal",
"panic",
"crash",
)
# ─── Triage helper ──────────────────────────────────────────────────────────
def content_has_error_indicators(text: str) -> bool:
"""Check if text contains error indicators (fast keyword check).
"""Fast keyword check — does `text` contain any error indicator?
Used for message signature creation and quick triage, NOT for
compression decisions (those should use TOIN when available).
Substring match (no word boundary). Distinct from the strict line
scoring in :mod:`headroom._core.score_line` because the triage
callsite (e.g. message-signature classification) cares about
Python tracebacks and similar substrings more than connection
states.
"""
text_lower = text.lower()
return any(kw in text_lower for kw in ERROR_INDICATOR_KEYWORDS)
return bool(_rust_content_has_error_indicators(text))
__all__ = [
"ERROR_KEYWORDS",
"IMPORTANCE_KEYWORDS",
"SECURITY_KEYWORDS",
"ERROR_INDICATOR_KEYWORDS",
"ERROR_PATTERN",
"WARNING_PATTERN",
"IMPORTANCE_PATTERN",
"SECURITY_PATTERN",
"PRIORITY_PATTERNS_SEARCH",
"PRIORITY_PATTERNS_DIFF",
"PRIORITY_PATTERNS_TEXT",
"content_has_error_indicators",
"score_line",
]

View file

@ -1,6 +1,6 @@
{
"name": "headroom",
"version": "0.14.2",
"version": "0.15.0",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"author": {
"name": "Headroom Contributors",

View file

@ -1,6 +1,6 @@
{
"name": "headroom",
"version": "0.14.2",
"version": "0.15.0",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"author": {
"name": "Headroom Contributors",

View file

@ -0,0 +1,149 @@
"""Phase 3e.1: parity contract for the Rust-backed error_detection shim.
The Python regex registry was retired in favor of recompiling regex from
the keyword tables exposed by the Rust `headroom._core.signals` module.
These tests pin:
* The shim re-exports the legacy frozenset and Pattern names callers
rely on (text_compressor, search_compressor, intelligent_context).
* Two bug fixes from the Rust port land in the Python regex too:
1. ERROR_PATTERN now matches abort/timeout/denied/rejected (was a
drift between ERROR_KEYWORDS and the compiled regex).
2. SECURITY_KEYWORDS no longer includes "token" (false-positives on
LLM-token references in our own product).
* `content_has_error_indicators` matches the Python triage semantics
(substring, no word boundary) for the canonical indicator set.
* The signals trait is reachable from Python via three thin functions
(`score_line`, `content_has_error_indicators`, `keyword_registry_snapshot`).
"""
from __future__ import annotations
import pytest
def test_legacy_re_exports_present():
from headroom.transforms import error_detection as ed
# frozenset names the existing callers import directly
assert isinstance(ed.ERROR_KEYWORDS, frozenset)
assert isinstance(ed.IMPORTANCE_KEYWORDS, frozenset)
assert isinstance(ed.SECURITY_KEYWORDS, frozenset)
assert isinstance(ed.ERROR_INDICATOR_KEYWORDS, tuple)
# Pattern objects must still be re.Pattern so callers can do .search()
import re
assert isinstance(ed.ERROR_PATTERN, re.Pattern)
assert isinstance(ed.WARNING_PATTERN, re.Pattern)
assert isinstance(ed.IMPORTANCE_PATTERN, re.Pattern)
assert isinstance(ed.SECURITY_PATTERN, re.Pattern)
# Per-context priority lists used by search/diff/text compressors
assert all(isinstance(p, re.Pattern) for p in ed.PRIORITY_PATTERNS_SEARCH)
assert all(isinstance(p, re.Pattern) for p in ed.PRIORITY_PATTERNS_DIFF)
assert all(isinstance(p, re.Pattern) for p in ed.PRIORITY_PATTERNS_TEXT)
def test_bug_fix_error_regex_now_matches_canonical_keyword_set():
"""fixed_in_3e1: ERROR_PATTERN regex used to omit timeout/abort/denied/rejected
even though ERROR_KEYWORDS canonically included them."""
from headroom.transforms.error_detection import ERROR_KEYWORDS, ERROR_PATTERN
for keyword in ("timeout", "abort", "denied", "rejected"):
assert keyword in ERROR_KEYWORDS, f"{keyword} must stay in ERROR_KEYWORDS"
assert ERROR_PATTERN.search(f"FATAL: {keyword} occurred"), (
f"ERROR_PATTERN must now flag {keyword!r}"
)
def test_bug_fix_security_keywords_dropped_token():
"""fixed_in_3e1: 'token' false-positived on input_tokens/tokens_saved/etc.
in an LLM-proxy product. Dropped from the security set so the security
pattern stops misclassifying our own metric output."""
from headroom.transforms.error_detection import SECURITY_KEYWORDS, SECURITY_PATTERN
assert "token" not in SECURITY_KEYWORDS
assert "auth" in SECURITY_KEYWORDS # the real security signal
assert SECURITY_PATTERN.search("missing auth header") is not None
assert SECURITY_PATTERN.search("input_tokens=512 output_tokens=128") is None
def test_content_has_error_indicators_lax_substring_semantics():
from headroom.transforms.error_detection import content_has_error_indicators
# Python ERROR_INDICATOR_KEYWORDS includes "traceback" — must still fire
assert content_has_error_indicators("Traceback (most recent call last):")
# Substring (no word-boundary) match preserved — "errored" matches "error"
assert content_has_error_indicators("the request errored out")
# Genuine non-match
assert not content_has_error_indicators("everything is fine")
def test_rust_signals_bridge_score_line_diff_context():
"""The Phase 3g pipeline will consume the trait API directly. Today
we cover the bridge surface so a future change can't silently break
it."""
from headroom.transforms.error_detection import score_line
cat, priority, confidence = score_line("FATAL: timeout connecting", "diff")
assert cat == "error"
assert priority > 0.9
assert confidence > 0.5
cat_neutral, _, conf_neutral = score_line("the quick brown fox", "text")
assert cat_neutral is None
assert conf_neutral == 0.0
def test_rust_signals_bridge_unknown_context_raises():
from headroom.transforms.error_detection import score_line
with pytest.raises(ValueError, match="unknown importance context"):
score_line("anything", "not_a_real_context")
def test_raw_rust_score_line_returns_none_on_unknown_context():
"""The raw `headroom._core.score_line` returns `None` for unknown
contexts (the Python shim is responsible for translating to
ValueError). Pin this contract so a future change can't shift the
error-handling boundary unobserved."""
from headroom._core import score_line as _raw
assert _raw("anything", "not_a_real_context") is None
result = _raw("ERROR: test", "diff")
assert result is not None and result[0] == "error"
def test_keyword_registry_snapshot_has_dropped_token_and_added_indicators():
from headroom._core import keyword_registry_snapshot
snapshot = keyword_registry_snapshot()
assert "token" not in snapshot["security"]
assert "auth" in snapshot["security"]
assert "timeout" in snapshot["error"]
assert "traceback" in snapshot["error_indicators"]
# Markdown prefixes must include at least the canonical four
assert "# " in snapshot["markdown_prefixes"]
assert "> " in snapshot["markdown_prefixes"]
def test_python_regex_recompiled_from_rust_keyword_tables():
"""The Python shim recompiles regex from keyword data Rust hands it.
This guards against drift: if Rust and Python keyword sets ever
diverge, this fails the suite."""
from headroom._core import keyword_registry_snapshot
from headroom.transforms.error_detection import (
ERROR_KEYWORDS,
IMPORTANCE_KEYWORDS,
SECURITY_KEYWORDS,
)
rust = keyword_registry_snapshot()
assert ERROR_KEYWORDS == frozenset(rust["error"])
assert SECURITY_KEYWORDS == frozenset(rust["security"])
# IMPORTANCE_KEYWORDS is a union of error + importance + warning sets
expected_importance = (
frozenset(rust["error"]) | frozenset(rust["importance"]) | frozenset(rust["warning"])
)
assert IMPORTANCE_KEYWORDS == expected_importance

View file

@ -181,22 +181,38 @@ def test_detect_content_type_respects_priority_order() -> None:
def test_error_detection_keywords_patterns_and_indicator_helper() -> None:
assert {"error", "failed", "critical"} <= ERROR_KEYWORDS
# fixed_in_3e1: ERROR_KEYWORDS canonically had timeout/abort/denied/rejected
# but the regex omitted them; the Rust port + Python shim now align.
assert {"timeout", "abort", "denied", "rejected"} <= ERROR_KEYWORDS
assert {"warning", "todo", "fix"} <= IMPORTANCE_KEYWORDS
assert {"security", "password", "token"} <= SECURITY_KEYWORDS
# fixed_in_3e1: 'token' was dropped from SECURITY_KEYWORDS because it
# false-positived on every LLM-token reference (input_tokens, etc.) in
# an LLM-proxy product. 'auth' carries the real security signal.
assert {"security", "password", "auth", "secret"} <= SECURITY_KEYWORDS
assert "token" not in SECURITY_KEYWORDS
assert ERROR_INDICATOR_KEYWORDS[0] == "error"
assert ERROR_PATTERN.search("Fatal error occurred")
# fixed_in_3e1: timeout now matched by ERROR_PATTERN regex.
assert ERROR_PATTERN.search("Connection timeout occurred")
assert WARNING_PATTERN.search("warning: be careful")
assert IMPORTANCE_PATTERN.search("TODO fix this hack")
assert SECURITY_PATTERN.search("rotate the auth token")
# fixed_in_3e1: pre-3e1 this matched via 'token'; now matches via 'auth'.
assert SECURITY_PATTERN.search("rotate the auth header")
# fixed_in_3e1: lone 'token' references no longer trigger security routing.
assert SECURITY_PATTERN.search("input_tokens=512 output_tokens=128") is None
assert PRIORITY_PATTERNS_SEARCH[:3] == [ERROR_PATTERN, WARNING_PATTERN, IMPORTANCE_PATTERN]
assert PRIORITY_PATTERNS_DIFF == [ERROR_PATTERN, IMPORTANCE_PATTERN, SECURITY_PATTERN]
assert PRIORITY_PATTERNS_TEXT[0] is ERROR_PATTERN
assert PRIORITY_PATTERNS_TEXT[1] is IMPORTANCE_PATTERN
assert PRIORITY_PATTERNS_TEXT[2].match("## Header")
assert PRIORITY_PATTERNS_TEXT[3].match("**Bold")
assert PRIORITY_PATTERNS_TEXT[4].match("> quote")
# The Rust-supplied markdown_prefixes order is `# `, `## `, `### `, `#### `,
# `**`, `> ` (see KeywordRegistry::default_set). Index 2 is `# ` not `## `,
# so anchor each assertion on the prefix it actually owns.
assert PRIORITY_PATTERNS_TEXT[2].match("# Top-level heading")
assert PRIORITY_PATTERNS_TEXT[3].match("## Subheading")
assert PRIORITY_PATTERNS_TEXT[6].match("**Bold")
assert PRIORITY_PATTERNS_TEXT[7].match("> quote")
assert content_has_error_indicators("TRACEBACK: Fatal crash in worker") is True
assert content_has_error_indicators("Everything completed successfully") is False