chore(transforms): retire dead text_compressor module (Phase 3e.3)

`headroom/transforms/text_compressor.py` was a regex-line-sampling
fallback that nothing in the runtime called. ContentRouter routes
`CompressionStrategy.TEXT` straight to the Kompress ML compressor at
`content_router.py:1046` — the comment there literally says 'Prefer
Kompress ML compressor for text'. The Python file was orphaned but
still imported by its own test class, making it look live in the 3e
queue.

Drops the 3e.3 port from the queue: there's nothing to port.

# Removed
* `headroom/transforms/text_compressor.py` (255 LOC, unused)
* `tests/test_text_compressors.py::TestTextCompressor` (3 tests)
* `text_compressor` mention in `error_detection.py` shim docstring
* `text_compressor` mention in `test_signals_keyword_parity.py` docstring
* `TextCompressor` mention in `bench_latency.py` scenario comment

# Kept (defensive)
The legacy marker regex in `ccr/tool_injection.py:213` stays — it
parses an even older TextCompressor output format (pre-2026), is
purely defensive, and removal buys nothing. Test references to that
format in `test_ccr_tool_injection.py` document the regex contract
and stay too.

# Test plan
* `make ci-precheck` clean
* `tests/test_text_compressors.py` 19 passes (was 22, dropped 3)
This commit is contained in:
chopratejas 2026-04-29 21:14:36 -07:00
parent 2228d1b019
commit 0161cdb386
5 changed files with 9 additions and 306 deletions

View file

@ -28,7 +28,7 @@ Usage:
Scenarios:
json - JSON arrays via SmartCrusher (100-5K items)
code - Python source via CodeCompressor (50-1000 lines)
text - Plain text/RAG via TextCompressor (1K-50K tokens)
text - Plain text/RAG via Kompress fallback (1K-50K tokens)
logs - Structured logs via LogCompressor (100-5K entries)
agentic - Multi-turn agent conversations (10-100 turns)
rag - RAG conversations with large context (5K-50K tokens)
@ -536,7 +536,7 @@ def generate_scenarios(content_types: list[str] | None = None) -> list[Scenario]
)
)
# --- Plain text (TextCompressor path) ---
# --- Plain text (Kompress fallback path) ---
if "text" in types:
for tokens, label in [
(1_000, "1K tokens"),

View file

@ -10,9 +10,9 @@ Phase 3e.1 ported the keyword data + scoring logic to
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.
) so the existing callers in ``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.

View file

@ -1,255 +0,0 @@
"""Generic text compressor for plain text content.
This module provides a fallback compressor for plain text that doesn't match
any specialized format (search results, logs, code, diffs). Uses line-based
sampling with anchor preservation.
Compression Strategy:
1. Identify anchor lines (contain context keywords)
2. Keep first N and last M lines
3. Sample from middle based on line importance
4. Add summary of omitted content
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class TextCompressorConfig:
"""Configuration for text compression."""
# Line limits
keep_first_lines: int = 10
keep_last_lines: int = 10
max_total_lines: int = 50
# Sampling
sample_every_n_lines: int = 10
# Anchor detection
anchor_keywords: list[str] = field(default_factory=list)
boost_pattern_lines: bool = True
# CCR integration
enable_ccr: bool = True
min_lines_for_ccr: int = 100
class TextCompressor:
"""Compresses generic plain text.
Example:
>>> compressor = TextCompressor()
>>> result = compressor.compress(large_text, context="find errors")
>>> print(result.compressed)
"""
# Patterns that indicate important lines (centralized in error_detection module)
from headroom.transforms.error_detection import PRIORITY_PATTERNS_TEXT
_IMPORTANT_PATTERNS = PRIORITY_PATTERNS_TEXT
def __init__(self, config: TextCompressorConfig | None = None):
"""Initialize text compressor.
Args:
config: Compression configuration.
"""
self.config = config or TextCompressorConfig()
def compress(self, content: str, context: str = "") -> TextCompressionResult:
"""Compress text content.
Args:
content: Raw text content.
context: User query context for anchor detection.
Returns:
TextCompressionResult with compressed output.
"""
lines = content.split("\n")
if len(lines) <= self.config.max_total_lines:
return TextCompressionResult(
compressed=content,
original=content,
original_line_count=len(lines),
compressed_line_count=len(lines),
compression_ratio=1.0,
)
# Score lines by importance
scored_lines = self._score_lines(lines, context)
# Select lines
selected = self._select_lines(scored_lines, lines)
# Format output
compressed = self._format_output(selected, len(lines))
ratio = len(compressed) / max(len(content), 1)
# Store in CCR if significant compression
cache_key = None
if self.config.enable_ccr and len(lines) >= self.config.min_lines_for_ccr and ratio < 0.7:
cache_key = self._store_in_ccr(content, compressed, len(lines))
if cache_key:
# Use consistent CCR marker format for CCRToolInjector detection
compressed += f"\n[{len(lines)} lines compressed to {len(selected)}. Retrieve more: hash={cache_key}]"
return TextCompressionResult(
compressed=compressed,
original=content,
original_line_count=len(lines),
compressed_line_count=len(selected),
compression_ratio=ratio,
cache_key=cache_key,
)
def _score_lines(self, lines: list[str], context: str) -> list[tuple[int, str, float]]:
"""Score lines by importance."""
context_lower = context.lower()
context_words = set(context_lower.split()) if context else set()
anchor_keywords = {k.lower() for k in self.config.anchor_keywords}
scored: list[tuple[int, str, float]] = []
for i, line in enumerate(lines):
score = 0.0
line_lower = line.lower()
# Boost if contains context words
for word in context_words:
if len(word) > 2 and word in line_lower:
score += 0.3
# Boost if contains anchor keywords
for keyword in anchor_keywords:
if keyword in line_lower:
score += 0.4
# Boost if matches important patterns
if self.config.boost_pattern_lines:
for pattern in self._IMPORTANT_PATTERNS:
if pattern.search(line):
score += 0.2
break
# Small boost for non-empty lines
if line.strip():
score += 0.1
scored.append((i, line, min(1.0, score)))
return scored
def _select_lines(
self, scored_lines: list[tuple[int, str, float]], original_lines: list[str]
) -> list[tuple[int, str]]:
"""Select lines to keep."""
total = len(scored_lines)
selected_indices: set[int] = set()
# Always keep first N lines
for i in range(min(self.config.keep_first_lines, total)):
selected_indices.add(i)
# Always keep last M lines
for i in range(max(0, total - self.config.keep_last_lines), total):
selected_indices.add(i)
# Add high-scoring lines
high_score_lines = [
(idx, line, score)
for idx, line, score in scored_lines
if score >= 0.3 and idx not in selected_indices
]
high_score_lines.sort(key=lambda x: x[2], reverse=True)
remaining_slots = self.config.max_total_lines - len(selected_indices)
for idx, _line, _score in high_score_lines[:remaining_slots]:
selected_indices.add(idx)
remaining_slots -= 1
if remaining_slots <= 0:
break
# Sample from remaining middle lines
if remaining_slots > 0:
middle_start = self.config.keep_first_lines
middle_end = total - self.config.keep_last_lines
for i in range(middle_start, middle_end, self.config.sample_every_n_lines):
if i not in selected_indices:
selected_indices.add(i)
remaining_slots -= 1
if remaining_slots <= 0:
break
# Sort by line number and return
selected = sorted(selected_indices)
return [(i, original_lines[i]) for i in selected]
def _format_output(self, selected: list[tuple[int, str]], total_lines: int) -> str:
"""Format selected lines with ellipsis markers."""
if not selected:
return f"[{total_lines} lines omitted]"
output_lines: list[str] = []
prev_idx = -1
for idx, line in selected:
# Add ellipsis if there's a gap
if prev_idx >= 0 and idx - prev_idx > 1:
gap = idx - prev_idx - 1
output_lines.append(f"[... {gap} lines omitted ...]")
output_lines.append(line)
prev_idx = idx
# Add trailing ellipsis if needed
if selected and selected[-1][0] < total_lines - 1:
gap = total_lines - selected[-1][0] - 1
output_lines.append(f"[... {gap} lines omitted ...]")
return "\n".join(output_lines)
def _store_in_ccr(self, original: str, compressed: str, original_count: int) -> str | None:
"""Store original in CCR for later retrieval."""
try:
from ..cache.compression_store import get_compression_store
store = get_compression_store()
return store.store(
original,
compressed,
original_item_count=original_count,
)
except ImportError:
return None
except Exception:
return None
@dataclass
class TextCompressionResult:
"""Result of text compression."""
compressed: str
original: str
original_line_count: int
compressed_line_count: int
compression_ratio: float
cache_key: str | None = None
@property
def tokens_saved_estimate(self) -> int:
"""Estimate tokens saved."""
chars_saved = len(self.original) - len(self.compressed)
return max(0, chars_saved // 4)
@property
def lines_omitted(self) -> int:
"""Number of lines omitted."""
return self.original_line_count - self.compressed_line_count

View file

@ -5,7 +5,7 @@ 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).
rely on (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).

View file

@ -1,6 +1,6 @@
"""Tests for text-based compressors (coding task support).
Tests content detection, search compressor, log compressor, and text compressor.
Tests content detection, search compressor, and log compressor.
"""
from headroom.transforms import (
@ -11,7 +11,6 @@ from headroom.transforms import (
SearchCompressorConfig,
detect_content_type,
)
from headroom.transforms.text_compressor import TextCompressor, TextCompressorConfig
class TestContentDetector:
@ -241,55 +240,14 @@ INFO: Done
assert result.compression_ratio == 1.0
class TestTextCompressor:
"""Tests for generic text compression."""
def test_compress_large_text(self):
"""Large text is compressed."""
lines = [f"Line {i}: This is some content that repeats" for i in range(200)]
content = "\n".join(lines)
compressor = TextCompressor()
result = compressor.compress(content)
assert result.compression_ratio < 0.5
assert "Line 0:" in result.compressed # First line kept
assert "lines omitted" in result.compressed
def test_preserves_context_matches(self):
"""Lines matching context are preserved."""
lines = [f"Line {i}: normal content" for i in range(100)]
lines[50] = "Line 50: IMPORTANT error message"
content = "\n".join(lines)
compressor = TextCompressor(
config=TextCompressorConfig(
anchor_keywords=["error", "important"],
)
)
result = compressor.compress(content, context="find errors")
assert "IMPORTANT error message" in result.compressed
def test_small_text_unchanged(self):
"""Small text passes through unchanged."""
content = "Short text\nthat fits easily"
compressor = TextCompressor()
result = compressor.compress(content)
assert result.compression_ratio == 1.0
assert result.compressed == content
class TestSmartCrusherTextIntegration:
"""Tests for SmartCrusher behavior with different content types.
NOTE: SmartCrusher is designed for JSON compression only.
Plain text content (search results, logs, etc.) passes through UNCHANGED.
Text compression utilities (SearchCompressor, LogCompressor, TextCompressor)
are available as standalone tools for applications to use explicitly.
Text compression utilities (SearchCompressor, LogCompressor) are
available as standalone tools for applications to use explicitly.
This is intentional - text compression is opt-in, not automatic.
"""