mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
commit
cf3877de38
17 changed files with 1210 additions and 95 deletions
|
|
@ -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"
|
||||
|
|
|
|||
4
.github/plugin/marketplace.json
vendored
4
.github/plugin/marketplace.json
vendored
|
|
@ -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
1
Cargo.lock
generated
|
|
@ -1320,6 +1320,7 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
|||
name = "headroom-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bytes",
|
||||
"criterion",
|
||||
"dashmap",
|
||||
|
|
|
|||
39
RUST_DEV.md
39
RUST_DEV.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub mod ccr;
|
||||
pub mod relevance;
|
||||
pub mod signals;
|
||||
pub mod tokenizer;
|
||||
pub mod transforms;
|
||||
|
||||
|
|
|
|||
58
crates/headroom-core/src/signals/README.md
Normal file
58
crates/headroom-core/src/signals/README.md
Normal 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, +10–20 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.
|
||||
433
crates/headroom-core/src/signals/keyword_detector.rs
Normal file
433
crates/headroom-core/src/signals/keyword_detector.rs
Normal 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, ®istry.error),
|
||||
(ImportanceCategory::Importance, ®istry.importance),
|
||||
]);
|
||||
let warning = CategoryAutomaton::build(&[(ImportanceCategory::Warning, ®istry.warning)]);
|
||||
let security =
|
||||
CategoryAutomaton::build(&[(ImportanceCategory::Security, ®istry.security)]);
|
||||
let indicators = AhoCorasickBuilder::new()
|
||||
.ascii_case_insensitive(true)
|
||||
.match_kind(MatchKind::LeftmostLongest)
|
||||
.build(®istry.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"));
|
||||
}
|
||||
}
|
||||
84
crates/headroom-core/src/signals/line_importance.rs
Normal file
84
crates/headroom-core/src/signals/line_importance.rs
Normal 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;
|
||||
}
|
||||
61
crates/headroom-core/src/signals/mod.rs
Normal file
61
crates/headroom-core/src/signals/mod.rs
Normal 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;
|
||||
141
crates/headroom-core/src/signals/tiered.rs
Normal file
141
crates/headroom-core/src/signals/tiered.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
149
tests/test_signals_keyword_parity.py
Normal file
149
tests/test_signals_keyword_parity.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue