Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

417 lines
16 KiB
Python

"""Rust-backed search-results compressor.
Phase 3e.2 ported the implementation to
`crates/headroom-core/src/transforms/search_compressor.rs`. This module
is now a thin shim that:
1. Keeps the public dataclass surface (`SearchMatch`, `FileMatches`,
`SearchCompressorConfig`, `SearchCompressionResult`) so existing
call sites (`ContentRouter._get_search_compressor`) and tests don't
change.
2. Routes `SearchCompressor.compress()` entirely through the Rust
implementation, picking up the parser bug fixes and the
`signals::LineImportanceDetector` trait consumer pattern.
3. Implements legacy internals (`_parse_search_results`,
`_score_matches`, `_select_matches`, `_format_output`) on top of
the same Rust building blocks so the existing 49 unit tests keep
covering meaningful code paths.
# Bug fixes the Rust port carries (and this shim therefore inherits)
* **Windows paths.** Pre-3e.2 `_GREP_PATTERN`/`_RG_CONTEXT_PATTERN`
regexes treated the drive-letter colon (`C:\\Users\\…`) as the
line-number-marker separator and silently dropped every Windows-
formatted line from `file_matches`. The Rust parser detects the
drive prefix and starts the line-number scan after it.
* **Filenames with `-`.** Pre-3e.2 `_RG_CONTEXT_PATTERN` excluded
dashes from the path (`[^:-]+`), so legitimate names like
`pre-commit-config.yaml-42-line` parsed wrong. The Rust parser
anchors on the *line-number marker* — earliest `<sep>\\d+<sep>` in
the line — so paths can contain dashes.
* **CCR storage failures are loud.** The previous Python class
swallowed all exceptions from the compression store. Storage
failures now surface to logs.
# CCR plumbing note
The Rust crate carries an internal CCR store for unit testing, but
the production CCR path remains the Python `CompressionStore`. The
shim picks up the Rust-emitted `cache_key` and writes the original
through to the Python store, so retrievability semantics match
exactly what the previous Python implementation provided.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, cast
logger = logging.getLogger(__name__)
def _is_cjk_char(c: str) -> bool:
"""True for CJK ideographs, kana, and Hangul. Code-point ranges kept
byte-identical with the Rust `is_cjk_char` for search-compressor parity."""
o = ord(c)
return (
0x3040 <= o <= 0x30FF
or 0x3400 <= o <= 0x4DBF
or 0x4E00 <= o <= 0x9FFF
or 0xAC00 <= o <= 0xD7AF
or 0xF900 <= o <= 0xFAFF
)
def _cjk_bigrams(text: str) -> set[str]:
"""CJK character bigrams from the CJK runs of a (lowercased) query, so a
spaceless CJK query can match content. Mirrors the Rust `cjk_bigrams`."""
out: set[str] = set()
run: list[str] = []
for c in text:
if _is_cjk_char(c):
run.append(c)
else:
for i in range(len(run) - 1):
out.add(run[i] + run[i + 1])
run = []
for i in range(len(run) - 1):
out.add(run[i] + run[i + 1])
return out
# ─── Public dataclasses (preserve existing import surface) ──────────────────
@dataclass
class SearchMatch:
"""A single search match."""
file: str
line_number: int
content: str
score: float = 0.0
@dataclass
class FileMatches:
"""All matches in a single file."""
file: str
matches: list[SearchMatch] = field(default_factory=list)
@property
def first(self) -> SearchMatch | None:
return self.matches[0] if self.matches else None
@property
def last(self) -> SearchMatch | None:
return self.matches[-1] if self.matches else None
@dataclass
class SearchCompressorConfig:
"""Configuration for search result compression."""
max_matches_per_file: int = 5
always_keep_first: bool = True
always_keep_last: bool = True
max_total_matches: int = 30
max_files: int = 15
context_keywords: list[str] = field(default_factory=list)
boost_errors: bool = True
enable_ccr: bool = True
min_matches_for_ccr: int = 10
# Group output by file (`rg --heading` style): path emitted once per
# file, then `line:content` rows. Removes per-match path repetition.
# Default False (classic `file:line:content`); the proxy enables it
# in token mode.
group_by_file: bool = False
@dataclass
class SearchCompressionResult:
"""Result of search result compression."""
compressed: str
original: str
original_match_count: int
compressed_match_count: int
files_affected: int
compression_ratio: float
cache_key: str | None = None
summaries: dict[str, str] = field(default_factory=dict)
@property
def tokens_saved_estimate(self) -> int:
"""Estimate tokens saved (rough: 1 token per 4 chars)."""
chars_saved = len(self.original) - len(self.compressed)
return max(0, chars_saved // 4)
@property
def matches_omitted(self) -> int:
return self.original_match_count - self.compressed_match_count
# ─── Compressor (Rust-backed) ───────────────────────────────────────────────
class SearchCompressor:
"""Compresses grep/ripgrep search results via the Rust port.
Drop-in replacement for the retired Python class. The main
`compress()` method delegates to Rust end-to-end. The internal
helpers used by the existing test surface are preserved and route
through the same Rust parser so the bug fixes (Windows paths,
dashes-in-filename) land everywhere.
"""
def __init__(self, config: SearchCompressorConfig | None = None) -> None:
# Hard import — no fallback. If the wheel is missing, the user
# must build it (scripts/build_rust_extension.sh) or install a
# prebuilt one. Failing loudly here is better than silently
# degrading; see feedback memory `feedback_no_silent_fallbacks.md`.
from headroom._core import (
SearchCompressor as _RustSearchCompressor,
)
from headroom._core import (
SearchCompressorConfig as _RustSearchCompressorConfig,
)
cfg = config or SearchCompressorConfig()
self.config = cfg
# `min_compression_ratio_for_ccr` was inlined as 0.8 in the
# Python original; promoted to a config field on the Rust side
# but defaulted to 0.8 here so the existing Python config
# surface is unchanged.
self._rust = _RustSearchCompressor(
_RustSearchCompressorConfig(
max_matches_per_file=cfg.max_matches_per_file,
always_keep_first=cfg.always_keep_first,
always_keep_last=cfg.always_keep_last,
max_total_matches=cfg.max_total_matches,
max_files=cfg.max_files,
context_keywords=list(cfg.context_keywords),
boost_errors=cfg.boost_errors,
enable_ccr=cfg.enable_ccr,
min_matches_for_ccr=cfg.min_matches_for_ccr,
min_compression_ratio_for_ccr=0.8,
group_by_file=getattr(cfg, "group_by_file", False),
)
)
# ─── Public API ─────────────────────────────────────────────────────
def compress(
self,
content: str,
context: str = "",
bias: float = 1.0,
) -> SearchCompressionResult:
rust_result = self._rust.compress(content, context, bias)
cache_key: str | None = rust_result.cache_key
if cache_key is not None:
# Mirror the original Python: persist to the production CCR
# store. The Rust crate already wrote to its in-memory test
# store; promote that to the long-lived Python store so the
# marker remains retrievable beyond the request lifecycle.
self._persist_to_python_ccr(content, rust_result.compressed, cache_key)
summaries = dict(cast("dict[str, str]", rust_result.summaries))
return SearchCompressionResult(
compressed=rust_result.compressed,
original=content,
original_match_count=rust_result.original_match_count,
compressed_match_count=rust_result.compressed_match_count,
files_affected=rust_result.files_affected,
compression_ratio=rust_result.compression_ratio,
cache_key=cache_key,
summaries=summaries,
)
# ─── Legacy internal helpers (test surface compat) ──────────────────
def _parse_search_results(self, content: str) -> dict[str, FileMatches]:
"""Parse via the Rust parser, build legacy Python dataclasses."""
from headroom._core import parse_search_lines
out: dict[str, FileMatches] = {}
for file_path, line_no, body in parse_search_lines(content):
if file_path not in out:
out[file_path] = FileMatches(file=file_path)
out[file_path].matches.append(
SearchMatch(file=file_path, line_number=int(line_no), content=body)
)
return out
def _score_matches(
self,
file_matches: dict[str, FileMatches],
context: str,
) -> None:
"""Score matches by relevance to context.
Stays Python so the legacy direct-call test surface keeps
working without rebuilding through Rust on every test. The
scoring constants mirror Rust `SearchCompressor::score_matches`,
pinned by Rust unit tests and Python tests over the same inputs:
word-overlap and CJK-bigram scoring are byte-equal. (The error-
boost keyword set still diverges for a few terms fixed only on
the Rust side -- see keyword_detector; there is no cross-impl
assertion, so this equality is test-pinned, not mechanically
enforced.)
"""
from headroom.transforms.error_detection import PRIORITY_PATTERNS_SEARCH
context_lower = context.lower()
# Dedup whitespace words (len>2 by codepoints), and add CJK char bigrams
# so a spaceless CJK query can match content.
context_words = {w for w in context_lower.split() if len(w) > 2}
context_words |= _cjk_bigrams(context_lower)
for fm in file_matches.values():
for match in fm.matches:
score = 0.0
content_lower = match.content.lower()
for word in context_words:
if word in content_lower:
score += 0.3
if self.config.boost_errors:
for i, pattern in enumerate(PRIORITY_PATTERNS_SEARCH):
if pattern.search(match.content):
score += 0.5 - (i * 0.1)
break # only one priority boost per line, matches Rust
for keyword in self.config.context_keywords:
if keyword.lower() in content_lower:
score += 0.4
match.score = min(1.0, score)
def _select_matches(
self,
file_matches: dict[str, FileMatches],
bias: float = 1.0,
) -> dict[str, FileMatches]:
"""Select top matches per file and globally."""
from headroom.transforms.adaptive_sizer import compute_optimal_k
sorted_files = sorted(
file_matches.items(),
key=lambda x: sum(m.score for m in x[1].matches),
reverse=True,
)[: self.config.max_files]
all_match_strings = [
f"{file_path}:{m.line_number}:{m.content}"
for file_path, fm in sorted_files
for m in fm.matches
]
adaptive_total = compute_optimal_k(
all_match_strings,
bias=bias,
min_k=5,
max_k=self.config.max_total_matches,
)
selected: dict[str, FileMatches] = {}
total_selected = 0
for file_path, fm in sorted_files:
if total_selected >= adaptive_total:
break
sorted_matches = sorted(fm.matches, key=lambda m: m.score, reverse=True)
file_selected: list[SearchMatch] = []
remaining_slots = min(
self.config.max_matches_per_file,
adaptive_total - total_selected,
)
if self.config.always_keep_first and fm.first:
file_selected.append(fm.first)
remaining_slots -= 1
if (
self.config.always_keep_last
and fm.last
and fm.last is not fm.first
and remaining_slots > 0
):
file_selected.append(fm.last)
remaining_slots -= 1
for match in sorted_matches:
if remaining_slots <= 0:
break
if match not in file_selected:
file_selected.append(match)
remaining_slots -= 1
file_selected.sort(key=lambda m: m.line_number)
selected[file_path] = FileMatches(file=file_path, matches=file_selected)
total_selected += len(file_selected)
return selected
def _format_output(
self,
selected: dict[str, FileMatches],
original: dict[str, FileMatches],
) -> tuple[str, dict[str, str]]:
lines: list[str] = []
summaries: dict[str, str] = {}
for file_path, fm in sorted(selected.items()):
for match in fm.matches:
lines.append(f"{match.file}:{match.line_number}:{match.content}")
original_fm = original.get(file_path)
if original_fm and len(original_fm.matches) > len(fm.matches):
omitted = len(original_fm.matches) - len(fm.matches)
summary = f"[... and {omitted} more matches in {file_path}]"
lines.append(summary)
summaries[file_path] = summary
return "\n".join(lines), summaries
# ─── Internal CCR persistence ───────────────────────────────────────
def _persist_to_python_ccr(self, original: str, compressed: str, cache_key: str) -> None:
"""Promote the Rust-emitted cache_key into the production Python
`CompressionStore`. Failures are surfaced via logging instead of
being silently swallowed (see no-silent-fallbacks rule).
Note: the Rust path computes the hash and embeds it in the
emitted marker text — the Rust hash IS the canonical one
(MD5(original)[:24]). The store must be keyed by that exact
hash or the marker dangles.
"""
try:
from ..cache.compression_store import get_compression_store
except ImportError as e:
logger.warning("CCR store import failed; cache_key %s won't persist: %s", cache_key, e)
return
try:
store: Any = get_compression_store()
# The Rust-emitted marker embeds MD5(original)[:24], but
# store() has defaulted to SHA-256(original)[:24] since
# PR #395. Pass the marker's key explicitly so retrieving
# the marker hash actually finds the entry (issue #816).
store.store(original, compressed, explicit_hash=cache_key)
except Exception as e:
logger.warning(
"CCR store write failed; cache_key %s remains in-marker only: %s", cache_key, e
)
__all__ = [
"SearchCompressor",
"SearchCompressorConfig",
"SearchCompressionResult",
"SearchMatch",
"FileMatches",
]