Skip to content

Commit eea667a

Browse files
authored
feat(transforms): adaptive Otsu KEEP/DROP threshold (+ land relevance split on main) (#1726)
## Description Lands the prompt-conditioned relevance split **on `main`** and makes its KEEP/DROP threshold **adaptive**. Context: the Stage B work (#1722) was merged into the feature branch `tejas/proxy-lossless-mode` rather than `main`, so `relevance_split.py` never reached `main`. This PR cherry-picks that work onto `main` and adds the adaptive threshold on top, in three commits: 1. Prompt-conditioned KEEP/DROP tail split (Stage B) — segment LOG/SEARCH output into records, score each against the request's information need (user prompt + triggering tool-call args) via `headroom/relevance/`, keep relevant records verbatim, Kompress the low-relevance tail. Mode-agnostic (marker-free in lossless, retrieval-marker in CCR). 2. On by default with hot-path rails — background embedding-model pre-warm (BM25 until warm, never blocks a request) + optional `relevance_max_records` cap (default 0 = no cap). 3. **Adaptive Otsu threshold** (this PR's new work) — see below. ### Adaptive threshold The keep/drop cut is no longer a fixed constant. For each output we compute the natural relevant/irrelevant break in *its own* score distribution via **Otsu's method** (parameter-free — candidate cuts are the data's own values, no bins or magic numbers), floored by `relevance.relevance_threshold` so absolutely irrelevant records are never kept verbatim. The bar therefore moves with the content + prompt: a highly-relevant output keeps its top cluster and compresses the merely-moderate tail; a mostly-irrelevant output drops almost everything. All-equal scores fall back to the floor. Toggle via `relevance_adaptive_threshold` (default `True`). Closes # ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes Made - `relevance_split.py`: `adaptive_threshold()` + `_otsu_threshold()`; `plan_relevance_split(..., adaptive=True)` uses the adaptive cut, floored by `threshold`. - `content_router.py`: `relevance_adaptive_threshold` config (default `True`), threaded into the split. (Plus the Stage B split + default-on rails from the cherry-picked commits.) - `tests/test_relevance_split.py`: adaptive-threshold cases (bimodal split, floored, all-equal, moves-with-distribution) on top of the Stage B suite. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added for new functionality ### Test Output ```text $ pytest tests/test_relevance_split.py tests/test_transforms_content_router.py tests/test_lossless_mode.py -q 80 passed, 1 warning in 3.41s $ ruff check headroom/transforms/relevance_split.py headroom/transforms/content_router.py tests/test_relevance_split.py All checks passed! $ ruff format --check <changed files> 3 files already formatted $ mypy headroom/transforms/relevance_split.py headroom/transforms/content_router.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - **Environment:** local, Python 3.12.6. - **Steps:** `adaptive_threshold()` exercised directly on synthetic score distributions; `plan_relevance_split(adaptive=True)` and the real `ContentRouter._apply_strategy_to_content` path driven with a deterministic scorer + Kompress-tail stub (offline). - **Observed:** - Bimodal scores `[0.92, 0.88, 0.12, 0.05]` → cut lands in the valley (`0.12 < t < 0.88`), keeping the high cluster. - Mostly-irrelevant `[0.30, 0.28, 0.05, 0.03]` → cut floored at `0.25`. - All-equal scores → floor. - Higher-scoring distribution yields a higher cut than a lower one (bar adapts). - Router split still fires in both lossless and CCR mode; DIFF stays pure lossless; disabling the flag is byte-identical. - **Not tested:** live embedding model warm/latency at scale; end-to-end `/v1/retrieve` resolution of the CCR tail marker (marker plumbing itself is covered upstream). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes - Supersedes the orphaned #1722 merge (which landed on the feature branch, not `main`); this PR is the canonical path onto `main`. - **Follow-ups discussed:** TEXT-strategy extension (relevance split for plain prose, currently whole-block Kompress); batch multiple DROP runs into one Kompress call; eval of savings/fidelity on live traffic. - N/A: CHANGELOG (feature not yet released).
1 parent c9d717c commit eea667a

4 files changed

Lines changed: 666 additions & 12 deletions

File tree

headroom/transforms/content_router.py

Lines changed: 229 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from ..config import (
5353
DEFAULT_EXCLUDE_TOOLS,
5454
ReadLifecycleConfig,
55+
RelevanceScorerConfig,
5556
TransformResult,
5657
is_tool_excluded,
5758
)
@@ -61,6 +62,7 @@
6162
from .content_detector import ContentType, DetectionResult, _try_detect_log, _try_detect_search
6263
from .content_detector import detect_content_type as _regex_detect_content_type
6364
from .error_detection import content_has_strong_error_indicators
65+
from .relevance_split import build_relevance_query, plan_relevance_split
6466

6567
logger = logging.getLogger(__name__)
6668

@@ -74,6 +76,23 @@ def _router_debug_dumps(value: Any) -> str:
7476
return json.dumps(value, ensure_ascii=False, default=str, separators=(",", ":"))
7577

7678

79+
def _tool_call_args_text(raw: Any) -> str:
80+
"""Compact, query-usable text from a tool call's args.
81+
82+
Anthropic passes ``input`` as a dict ({"command": "grep …"}); OpenAI passes
83+
``arguments`` as a JSON string. Either way we want the scalar values (the
84+
grep pattern, the read path) as a short query fragment. Capped so a giant
85+
arg blob can't dominate the relevance query.
86+
"""
87+
if isinstance(raw, str):
88+
text = raw
89+
elif isinstance(raw, dict):
90+
text = " ".join(str(v) for v in raw.values() if isinstance(v, (str, int, float, bool)))
91+
else:
92+
return ""
93+
return " ".join(text.split())[:300]
94+
95+
7796
def _log_router_debug(event: str, **payload: Any) -> None:
7897
if not logger.isEnabledFor(logging.DEBUG):
7998
return
@@ -739,7 +758,6 @@ class ContentRouterConfig:
739758
enable_tabular_compressor: Enable CSV/TSV/markdown-table compression.
740759
enable_image_optimizer: Enable image token optimization.
741760
prefer_code_aware_for_code: Use CodeAware over Kompress for code.
742-
mixed_content_threshold: Min distinct types to consider "mixed".
743761
min_section_tokens: Minimum tokens for a section to compress.
744762
fallback_strategy: Strategy when no compressor matches.
745763
skip_user_messages: Never compress user messages (they're the subject).
@@ -769,7 +787,6 @@ class ContentRouterConfig:
769787
# emits a `<<ccr:…>>` / `Retrieve …` retrieval marker. SmartCrusher is
770788
# additionally forced marker-free via smart_crusher_lossless_only.
771789
lossless: bool = False
772-
mixed_content_threshold: int = 2 # Min types to consider mixed
773790
min_section_tokens: int = 20 # Min tokens to compress a section
774791

775792
# Fallback: Kompress handles unknown/mixed content instead of passing through
@@ -816,12 +833,18 @@ class ContentRouterConfig:
816833
0.0 # 0.0 = protect ALL excluded-tool outputs (safest for coding agents)
817834
)
818835

819-
# Adaptive compression ratio: scales with context pressure.
820-
# At low pressure (<30% full), use the relaxed threshold (reject marginal).
821-
# At high pressure (>80% full), use the aggressive threshold (accept anything helpful).
822-
# Linearly interpolates between the two.
823-
min_ratio_relaxed: float = 0.85 # when context is mostly empty
824-
min_ratio_aggressive: float = 0.65 # when context is nearly full
836+
# Adaptive acceptance threshold, scaling with context pressure. The gate
837+
# accepts a compression when compression_ratio < min_ratio (ratio =
838+
# compressed/original, so LOWER ratio = bigger savings). Thus a HIGHER
839+
# min_ratio is MORE lenient (accepts marginal wins) and a LOWER one is
840+
# stricter (only big wins clear it). min_ratio is interpolated
841+
# 0.85 (empty context) -> 0.65 (full), i.e. acceptance gets STRICTER as
842+
# context fills, so only large savings justify busting the prefix cache
843+
# under pressure. (Direction is deliberate; whether a full context should
844+
# instead accept *more* to reclaim space is a design question flagged for
845+
# an eval — do not flip without measuring.)
846+
min_ratio_relaxed: float = 0.85 # low pressure: lenient, accept marginal wins
847+
min_ratio_aggressive: float = 0.65 # high pressure: strict, big wins only
825848

826849
# CCR (Compress-Cache-Retrieve) settings for SmartCrusher
827850
ccr_enabled: bool = True # Enable CCR marker injection for reversible compression
@@ -834,6 +857,28 @@ class ContentRouterConfig:
834857
# can run marker-free without constructing the crusher by hand.
835858
smart_crusher_lossless_only: bool | None = None
836859

860+
# Prompt-conditioned relevance split for the KEEP/DROP tail. When enabled,
861+
# LOG/SEARCH output is segmented into records, each scored against the
862+
# request's information need (user prompt + triggering tool-call args) via
863+
# `relevance` below; high-relevance records are kept verbatim and the
864+
# low-relevance tail is Kompressed. Works in both modes: in lossless mode
865+
# the tail is marker-free; in CCR mode it carries a retrieval marker (via
866+
# ccr_inject_marker) so dropped detail stays retrievable. On by default; the
867+
# embedding model is pre-warmed in the background (BM25 scores until it's
868+
# cached) so no request ever blocks on the download.
869+
relevance_split: bool = True
870+
relevance: RelevanceScorerConfig = field(default_factory=RelevanceScorerConfig)
871+
# Optional latency guard: skip the split when an output segments into more
872+
# than this many records, capping embedding work on the request thread.
873+
# 0 = no cap (default): every record is scored regardless of size. Set a
874+
# positive value to bound per-request embedding cost on very large outputs.
875+
relevance_max_records: int = 0
876+
# Adaptive KEEP/DROP cut: when True (default), the threshold is the natural
877+
# relevant/irrelevant break in each output's score distribution (Otsu),
878+
# floored by relevance.relevance_threshold — it moves with the content
879+
# instead of a fixed constant. False uses the fixed threshold exactly.
880+
relevance_adaptive_threshold: bool = True
881+
837882
# Tag protection: preserve custom/workflow XML tags from text compression.
838883
# When False (default), entire <custom-tag>content</custom-tag> blocks are
839884
# protected verbatim. When True, only the tag markers are protected and
@@ -1140,6 +1185,13 @@ def __init__(
11401185
self._html_extractor: Any = None
11411186
self._tabular_compressor: Any = None
11421187
self._kompress: Any = None
1188+
# Stage B relevance split (lazy; None until first use, sentinel-checked
1189+
# via _relevance_scorer_tried so a failed load isn't retried per call).
1190+
self._relevance_scorer: Any = None
1191+
self._relevance_scorer_tried: bool = False
1192+
self._relevance_prewarm_started: bool = False
1193+
# tool_call_id → compact args text, populated by _build_tool_name_map.
1194+
self._tool_call_args: dict[str, str] = {}
11431195

11441196
# Phase 0 (#1171): cap the input size handed to kompress (ModernBERT
11451197
# ONNX). Its inference scales O(tokens) and runs synchronously on the
@@ -1630,6 +1682,24 @@ def _apply_strategy_to_content(
16301682
strategy_chain: list[str] = [strategy.value]
16311683
error: str | None = None
16321684

1685+
# Stage B/C: prompt-conditioned relevance split for LOG/SEARCH — keep
1686+
# relevant records verbatim, compress the low-value tail. Runs in BOTH
1687+
# modes; the tail's marker behavior follows the mode automatically via
1688+
# _try_ml_compressor: lossless → marker-free Kompress; CCR → Kompress
1689+
# with a retrieval marker so the dropped detail stays retrievable (a
1690+
# safety net if the scorer is wrong). DIFF is excluded — Kompressing
1691+
# hunks breaks `git apply`. Returns None (falls through to the normal
1692+
# path) when disabled, unavailable, or no better than plain compression.
1693+
if self.config.relevance_split and strategy in (
1694+
CompressionStrategy.LOG,
1695+
CompressionStrategy.SEARCH,
1696+
):
1697+
kind = "log" if strategy is CompressionStrategy.LOG else "search"
1698+
split = self._relevance_split_compress(content, kind, context)
1699+
if split is not None:
1700+
label = f"lossless_{kind}" if self.config.lossless else kind
1701+
return split, len(split.split()), [label, "relevance_split"]
1702+
16331703
# No-CCR lossless mode: LOG/SEARCH/DIFF get format-native lossless
16341704
# compaction instead of the lossy Rust drop path, so the output stays
16351705
# marker-free (no `<<ccr:…>>` / `Retrieve …`) and fully recoverable.
@@ -2117,6 +2187,112 @@ def _get_log_compressor(self) -> Any:
21172187
logger.debug("LogCompressor not available")
21182188
return self._log_compressor
21192189

2190+
def _get_relevance_scorer(self) -> Any:
2191+
"""Get the relevance scorer for the split (lazy, cached, non-blocking).
2192+
2193+
Tier comes from ``config.relevance``. For ``bm25`` this is instant. For
2194+
``hybrid``/``embedding`` the scorer serves **BM25 immediately** and the
2195+
embedding model is warmed in a background thread; once it's cached the
2196+
scorer is swapped in (GIL-atomic ref write), so a request never blocks
2197+
on the ~30MB download. Returns None (cached) on failure. Never raises.
2198+
"""
2199+
if self._relevance_scorer is not None or self._relevance_scorer_tried:
2200+
return self._relevance_scorer
2201+
self._relevance_scorer_tried = True
2202+
tier = (self.config.relevance.tier or "hybrid").lower()
2203+
try:
2204+
from ..relevance import BM25Scorer
2205+
2206+
if tier == "bm25":
2207+
self._relevance_scorer = BM25Scorer()
2208+
else:
2209+
# Serve BM25 now; swap to the embedding-backed scorer once warm.
2210+
self._relevance_scorer = BM25Scorer()
2211+
self._start_relevance_prewarm(tier)
2212+
except Exception as exc: # noqa: BLE001
2213+
logger.debug("relevance scorer unavailable: %s", exc)
2214+
self._relevance_scorer = None
2215+
return self._relevance_scorer
2216+
2217+
def _start_relevance_prewarm(self, tier: str) -> None:
2218+
"""Warm the embedding model off the request thread, then swap it in.
2219+
2220+
Idempotent. On failure (fastembed missing, download error) the router
2221+
just stays on the BM25 scorer set by ``_get_relevance_scorer``.
2222+
"""
2223+
if getattr(self, "_relevance_prewarm_started", False):
2224+
return
2225+
self._relevance_prewarm_started = True
2226+
2227+
def _warm() -> None:
2228+
try:
2229+
from ..relevance import create_scorer
2230+
2231+
scorer = create_scorer(tier)
2232+
# Force the model download+load and a first embed here, in the
2233+
# background — so the first real request finds it warm.
2234+
scorer.score_batch(["warmup"], "warmup")
2235+
self._relevance_scorer = scorer # GIL-atomic ref swap
2236+
except Exception as exc: # noqa: BLE001
2237+
logger.debug("relevance model prewarm failed; staying on BM25: %s", exc)
2238+
2239+
threading.Thread(target=_warm, name="relevance-prewarm", daemon=True).start()
2240+
2241+
def _relevance_split_compress(self, content: str, kind: str, query: str) -> str | None:
2242+
"""Prompt-conditioned KEEP/DROP split for the compression tail.
2243+
2244+
Keeps high-relevance records byte-verbatim (lossless-compacted) and
2245+
Kompresses the low-relevance tail (identifiers pinned by Kompress
2246+
MUST_KEEP). Mode-agnostic: the tail's marker behavior is decided by
2247+
``_try_ml_compressor`` — marker-free in lossless mode, retrieval-marker
2248+
in CCR mode. Returns the spliced output, or None to fall back to the
2249+
normal path when the scorer is unavailable, the query is empty, nothing
2250+
is dropped, or the split doesn't beat plain compaction. Never raises.
2251+
2252+
Embedding cost is bounded two ways: the model is pre-warmed off the
2253+
request thread (BM25 until it's ready, see _get_relevance_scorer) and
2254+
outputs segmenting into more than ``relevance_max_records`` records skip
2255+
the split entirely.
2256+
"""
2257+
scorer = self._get_relevance_scorer()
2258+
if scorer is None or not query.strip():
2259+
return None
2260+
from .lossless_compaction import compact_lossless
2261+
2262+
try:
2263+
runs = plan_relevance_split(
2264+
content,
2265+
query,
2266+
scorer,
2267+
threshold=self.config.relevance.relevance_threshold,
2268+
adaptive=self.config.relevance_adaptive_threshold,
2269+
max_records=self.config.relevance_max_records,
2270+
)
2271+
except Exception as exc: # noqa: BLE001
2272+
logger.debug("relevance split failed (%s); falling back", exc)
2273+
return None
2274+
2275+
# No low-relevance tail → plain compaction is already optimal here.
2276+
if not any(not keep for keep, _ in runs):
2277+
return None
2278+
2279+
out_parts: list[str] = []
2280+
for keep, text in runs:
2281+
if keep:
2282+
out_parts.append(compact_lossless(text, kind))
2283+
continue
2284+
try:
2285+
compressed, _ = self._try_ml_compressor(text, query)
2286+
except Exception as exc: # noqa: BLE001
2287+
logger.debug("kompress tail failed (%s); keeping verbatim", exc)
2288+
compressed = compact_lossless(text, kind)
2289+
out_parts.append(compressed)
2290+
2291+
result = "".join(out_parts)
2292+
# Adopt only when it beats plain whole-block lossless compaction.
2293+
baseline = compact_lossless(content, kind)
2294+
return result if len(result) < len(baseline) else None
2295+
21202296
def _get_text_crusher(self) -> Any:
21212297
"""Get TextCrusher (Phase 2, lazy load). Returns None when disabled, or
21222298
when the native ``headroom._core`` extension is not built (mirrors the
@@ -2426,9 +2602,15 @@ def _build_tool_name_map(self, messages: list[dict[str, Any]]) -> dict[str, str]
24262602
"""Build mapping from tool_call_id to tool_name.
24272603
24282604
Scans assistant messages to find tool calls and extract their names.
2429-
Supports both OpenAI and Anthropic message formats.
2605+
Supports both OpenAI and Anthropic message formats. Also populates
2606+
``self._tool_call_args`` (id → compact args text) in the same scan, so
2607+
the relevance split can score a tool output against the *precise* ask
2608+
that triggered it (grep pattern, read path, …), not just the user
2609+
prompt. Read-only after build → safe to read from the parallel
2610+
compression pass.
24302611
"""
24312612
mapping: dict[str, str] = {}
2613+
args_map: dict[str, str] = {}
24322614

24332615
for msg in messages:
24342616
if msg.get("role") != "assistant":
@@ -2438,9 +2620,13 @@ def _build_tool_name_map(self, messages: list[dict[str, Any]]) -> dict[str, str]
24382620
for tc in msg.get("tool_calls", []):
24392621
if isinstance(tc, dict):
24402622
tc_id = tc.get("id", "")
2441-
name = tc.get("function", {}).get("name", "")
2623+
fn = tc.get("function", {})
2624+
name = fn.get("name", "")
24422625
if tc_id and name:
24432626
mapping[tc_id] = name
2627+
args = _tool_call_args_text(fn.get("arguments"))
2628+
if args:
2629+
args_map[tc_id] = args
24442630

24452631
# Anthropic format: content blocks with type=tool_use
24462632
content = msg.get("content", [])
@@ -2451,7 +2637,11 @@ def _build_tool_name_map(self, messages: list[dict[str, Any]]) -> dict[str, str]
24512637
name = block.get("name", "")
24522638
if tc_id and name:
24532639
mapping[tc_id] = name
2640+
args = _tool_call_args_text(block.get("input"))
2641+
if args:
2642+
args_map[tc_id] = args
24542643

2644+
self._tool_call_args = args_map
24552645
return mapping
24562646

24572647
def _net_cost_allows(
@@ -3350,6 +3540,15 @@ def _process_content_blocks(
33503540
tool_name = (tool_name_map or {}).get(tool_use_id, "")
33513541
bias = self._get_tool_bias(tool_name) if tool_name else 1.0
33523542

3543+
# Enrich the relevance query with the triggering tool call's
3544+
# args (grep pattern, read path, …) — the sharpest, per-output
3545+
# signal. Gated so default behavior is byte-identical.
3546+
block_context = context
3547+
if self.config.relevance_split and tool_use_id:
3548+
call_args = self._tool_call_args.get(tool_use_id, "")
3549+
if call_args:
3550+
block_context = build_relevance_query(context, tool_name, call_args)
3551+
33533552
tool_content = block.get("content", "")
33543553

33553554
# Protection: failed tool calls / error outputs stay verbatim
@@ -3394,7 +3593,7 @@ def _process_content_blocks(
33943593
content_key=hash(
33953594
(tool_content, getattr(self, "_runtime_target_ratio", None))
33963595
),
3397-
context=context,
3596+
context=block_context,
33983597
bias=bias,
33993598
min_ratio=min_ratio,
34003599
compressor_timing=compressor_timing,
@@ -3403,6 +3602,7 @@ def _process_content_blocks(
34033602
compressed_details=compressed_details,
34043603
strategy_label="tool_result",
34053604
details_prefix="tool",
3605+
enforce_reversibility=True,
34063606
)
34073607
if compressed_content is not None:
34083608
new_blocks.append({**block, "content": compressed_content})
@@ -3479,6 +3679,7 @@ def _compress_block_content(
34793679
compressed_details: list[str] | None,
34803680
strategy_label: str,
34813681
details_prefix: str,
3682+
enforce_reversibility: bool = False,
34823683
) -> tuple[str | None, bool]:
34833684
"""Apply two-tier cache lookup + compression to a single content string.
34843685
@@ -3543,6 +3744,23 @@ def _compress_block_content(
35433744
key = f"compressor:{result.strategy_used.value}"
35443745
compressor_timing[key] = compressor_timing.get(key, 0.0) + compress_ms
35453746
if result.compression_ratio < min_ratio:
3747+
# Tool ground truth must stay reversible: a lossy summarizer
3748+
# (kompress/text/code) that emitted no CCR retrieve marker is
3749+
# unrecoverable, so the agent would act on a fabricated summary
3750+
# (#1307). The string/`role=="tool"` path guards this; mirror it
3751+
# here for tool_result blocks (never cached, so the Tier-2 path
3752+
# above can't serve a poisoned entry).
3753+
if (
3754+
enforce_reversibility
3755+
and result.strategy_used in self.LOSSY_UNMARKED_STRATEGIES
3756+
and not CCR_RETRIEVAL_MARKER_RE.search(result.compressed)
3757+
):
3758+
self._cache.mark_skip(content_key)
3759+
if route_counts is not None:
3760+
route_counts["lossy_unrecoverable_skipped"] = (
3761+
route_counts.get("lossy_unrecoverable_skipped", 0) + 1
3762+
)
3763+
return None, False
35463764
# Compressed — store in result cache
35473765
self._cache.put(
35483766
content_key,

0 commit comments

Comments
 (0)