chopratejas commited on
Commit
9efd75a
·
1 Parent(s): 3b38650

Add adaptive compression sizing and fix cost tracking bugs

Browse files

- Fix LiteLLM pricing for claude-opus-4-6 by adding provider prefix
fallback (_resolve_litellm_model) when model name is unrecognized
- Fix cost savings double-counting: remove cache_read/cache_write
tokens from original_cost calculation at all 3 call sites, which
was inflating reported savings ~425x
- Add information saturation engine (adaptive_sizer.py) using Kneedle
algorithm on unique bigram coverage curves to statistically determine
optimal compression K instead of hardcoded thresholds
- Add CompressionProfile with per-tool bias multipliers (conservative,
moderate, aggressive) configurable via --tool-profile CLI flag and
HEADROOM_TOOL_PROFILES env var
- Integrate adaptive K into SmartCrusher, SearchCompressor, and
LogCompressor, threading bias through ContentRouter
- Remove Grep and Bash from DEFAULT_EXCLUDE_TOOLS so their outputs
are now compressed (only Read and Glob excluded)

headroom/config.py CHANGED
@@ -348,24 +348,60 @@ class AnchorConfig:
348
 
349
 
350
  # Default tools to exclude from compression (local file/code tools)
351
- # These tools return precise content (line numbers, paths, code) where
352
- # exact fidelity matters. Web tools are NOT excluded - they benefit from
353
- # compression and can use CCR for retrieval if needed.
 
 
 
 
354
  DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
355
  {
356
  "Read",
357
  "Glob",
358
- "Grep",
359
- "Bash",
360
  # Lowercase variants for case-insensitive matching
361
  "read",
362
  "glob",
363
- "grep",
364
- "bash",
365
  }
366
  )
367
 
368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  @dataclass
370
  class SmartCrusherConfig:
371
  """Configuration for smart statistical crusher (DEFAULT).
 
348
 
349
 
350
  # Default tools to exclude from compression (local file/code tools)
351
+ # Read: Returns exact file content needed for Edit tool's old_string matching.
352
+ # Compressing would break the edit workflow.
353
+ # Glob: Returns compact file path lists used for navigation. Low token count,
354
+ # not worth compressing.
355
+ # Grep/Bash are NOT excluded - their outputs (search results, build logs,
356
+ # test output) are ideal compression targets for SearchCompressor/LogCompressor,
357
+ # and CCR provides safe retrieval if the LLM needs more detail.
358
  DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
359
  {
360
  "Read",
361
  "Glob",
 
 
362
  # Lowercase variants for case-insensitive matching
363
  "read",
364
  "glob",
 
 
365
  }
366
  )
367
 
368
 
369
+ @dataclass
370
+ class CompressionProfile:
371
+ """Per-tool compression bias applied to statistically-determined K.
372
+
373
+ Instead of hardcoding max_items=15, the adaptive sizer computes the optimal K
374
+ via information saturation (Kneedle on unique bigram coverage). This profile
375
+ applies a bias multiplier: >1 keeps more items (conservative), <1 keeps fewer
376
+ (aggressive).
377
+ """
378
+
379
+ bias: float = 1.0 # 0.7=aggressive, 1.0=moderate, 1.5=conservative
380
+ min_k: int = 3 # Never keep fewer than this
381
+ max_k: int | None = None # Cap (None = no cap, let statistics decide)
382
+
383
+
384
+ # Named presets for convenience
385
+ PROFILE_PRESETS: dict[str, CompressionProfile] = {
386
+ "conservative": CompressionProfile(bias=1.5, min_k=5),
387
+ "moderate": CompressionProfile(bias=1.0, min_k=3),
388
+ "aggressive": CompressionProfile(bias=0.7, min_k=3),
389
+ }
390
+
391
+ # Default per-tool profiles: tools not listed here use moderate (bias=1.0)
392
+ DEFAULT_TOOL_PROFILES: dict[str, CompressionProfile] = {
393
+ # Search results: keep more matches for accuracy
394
+ "Grep": PROFILE_PRESETS["conservative"],
395
+ "grep": PROFILE_PRESETS["conservative"],
396
+ # Logs/output: balanced compression
397
+ "Bash": PROFILE_PRESETS["moderate"],
398
+ "bash": PROFILE_PRESETS["moderate"],
399
+ # Web pages are verbose, compress aggressively
400
+ "WebFetch": PROFILE_PRESETS["aggressive"],
401
+ "webfetch": PROFILE_PRESETS["aggressive"],
402
+ }
403
+
404
+
405
  @dataclass
406
  class SmartCrusherConfig:
407
  """Configuration for smart statistical crusher (DEFAULT).
headroom/proxy/server.py CHANGED
@@ -247,6 +247,9 @@ class ProxyConfig:
247
  # Code-aware compression (ON by default if installed)
248
  code_aware_enabled: bool = True # Enable AST-based code compression
249
 
 
 
 
250
  # Smart content routing (routes each message to optimal compressor)
251
  smart_routing: bool = True # Use ContentRouter for intelligent compression
252
 
@@ -536,6 +539,39 @@ class CostTracker:
536
  self._total_savings_usd: float = 0
537
  self._last_prune_time: datetime = datetime.now()
538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  def estimate_cost(
540
  self,
541
  model: str,
@@ -558,6 +594,9 @@ class CostTracker:
558
  return None
559
 
560
  try:
 
 
 
561
  # cost_per_token returns (total_input_cost, total_output_cost) for the given token counts
562
  # Despite the name, it returns total cost not per-token cost
563
 
@@ -570,14 +609,14 @@ class CostTracker:
570
 
571
  # Get cost for regular (non-cached) input tokens
572
  input_cost, _ = litellm.cost_per_token(
573
- model=model,
574
  prompt_tokens=regular_input,
575
  completion_tokens=0,
576
  )
577
 
578
  # Get cost for output tokens
579
  _, output_cost = litellm.cost_per_token(
580
- model=model,
581
  prompt_tokens=0,
582
  completion_tokens=output_tokens,
583
  )
@@ -585,7 +624,7 @@ class CostTracker:
585
  # Get model info for cache pricing
586
  model_info: dict[str, Any] = {}
587
  try:
588
- model_info = dict(litellm.get_model_info(model))
589
  except Exception:
590
  pass
591
 
@@ -598,7 +637,7 @@ class CostTracker:
598
  else:
599
  # Fallback: most providers charge ~10% of input price for cache reads
600
  cache_read_full_cost, _ = litellm.cost_per_token(
601
- model=model,
602
  prompt_tokens=cache_read_tokens,
603
  completion_tokens=0,
604
  )
@@ -613,7 +652,7 @@ class CostTracker:
613
  else:
614
  # Fallback: most providers charge ~125% of input price for cache writes
615
  cache_write_full_cost, _ = litellm.cost_per_token(
616
- model=model,
617
  prompt_tokens=cache_write_tokens,
618
  completion_tokens=0,
619
  )
@@ -999,6 +1038,7 @@ class HeadroomProxy:
999
  router_config = ContentRouterConfig(
1000
  enable_llmlingua=config.llmlingua_enabled,
1001
  enable_code_aware=config.code_aware_enabled,
 
1002
  )
1003
  transforms = [
1004
  CacheAligner(CacheAlignerConfig(enabled=True)),
@@ -2023,12 +2063,13 @@ class HeadroomProxy:
2023
  cache_read_tokens=cache_read_tokens,
2024
  cache_write_tokens=cache_write_tokens,
2025
  )
 
 
 
2026
  original_cost = self.cost_tracker.estimate_cost(
2027
  model,
2028
  original_tokens,
2029
  output_tokens,
2030
- cache_read_tokens=cache_read_tokens,
2031
- cache_write_tokens=cache_write_tokens,
2032
  )
2033
  if cost_usd and original_cost:
2034
  savings_usd = original_cost - cost_usd
@@ -4128,11 +4169,12 @@ class HeadroomProxy:
4128
  output_tokens,
4129
  cache_read_tokens=cache_read_tokens,
4130
  )
 
 
4131
  original_cost = self.cost_tracker.estimate_cost(
4132
  model,
4133
  original_tokens,
4134
  output_tokens,
4135
- cache_read_tokens=cache_read_tokens,
4136
  )
4137
  if cost_usd and original_cost:
4138
  savings_usd = original_cost - cost_usd
@@ -5183,11 +5225,12 @@ class HeadroomProxy:
5183
  output_tokens,
5184
  cache_read_tokens=cache_read_tokens,
5185
  )
 
 
5186
  original_cost = self.cost_tracker.estimate_cost(
5187
  model,
5188
  original_tokens,
5189
  output_tokens,
5190
- cache_read_tokens=cache_read_tokens,
5191
  )
5192
  if cost_usd and original_cost:
5193
  savings_usd = original_cost - cost_usd
@@ -6627,6 +6670,45 @@ def _get_env_str(name: str, default: str) -> str:
6627
  return os.environ.get(name, default)
6628
 
6629
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6630
  if __name__ == "__main__":
6631
  parser = argparse.ArgumentParser(description="Headroom Proxy Server")
6632
 
@@ -6690,6 +6772,13 @@ if __name__ == "__main__":
6690
  parser.add_argument("--no-optimize", action="store_true", help="Disable optimization")
6691
  parser.add_argument("--min-tokens", type=int, default=500, help="Min tokens to crush")
6692
  parser.add_argument("--max-items", type=int, default=50, help="Max items after crush")
 
 
 
 
 
 
 
6693
 
6694
  # Caching
6695
  parser.add_argument("--no-cache", action="store_true", help="Disable caching")
@@ -6783,6 +6872,9 @@ if __name__ == "__main__":
6783
  if hasattr(args, "openrouter_api_key") and args.openrouter_api_key:
6784
  os.environ["OPENROUTER_API_KEY"] = args.openrouter_api_key
6785
 
 
 
 
6786
  config = ProxyConfig(
6787
  host=_get_env_str("HEADROOM_HOST", args.host),
6788
  port=_get_env_int("HEADROOM_PORT", args.port),
@@ -6814,6 +6906,7 @@ if __name__ == "__main__":
6814
  max_connections=_get_env_int("HEADROOM_MAX_CONNECTIONS", args.max_connections),
6815
  max_keepalive_connections=_get_env_int("HEADROOM_MAX_KEEPALIVE", args.max_keepalive),
6816
  http2=not args.no_http2 and _get_env_bool("HEADROOM_HTTP2", True),
 
6817
  )
6818
 
6819
  # Get worker and concurrency settings
 
247
  # Code-aware compression (ON by default if installed)
248
  code_aware_enabled: bool = True # Enable AST-based code compression
249
 
250
+ # Per-tool compression profiles (parsed from CLI/env)
251
+ tool_profiles: dict[str, Any] | None = None
252
+
253
  # Smart content routing (routes each message to optimal compressor)
254
  smart_routing: bool = True # Use ContentRouter for intelligent compression
255
 
 
539
  self._total_savings_usd: float = 0
540
  self._last_prune_time: datetime = datetime.now()
541
 
542
+ @staticmethod
543
+ def _resolve_litellm_model(model: str) -> str:
544
+ """Resolve model name to one LiteLLM recognizes, adding provider prefix if needed."""
545
+ if not LITELLM_AVAILABLE:
546
+ return model
547
+
548
+ # Try as-is first
549
+ try:
550
+ litellm.cost_per_token(model=model, prompt_tokens=1, completion_tokens=0)
551
+ return model
552
+ except Exception:
553
+ pass
554
+
555
+ # Try with provider prefix
556
+ prefixes = {
557
+ "claude-": "anthropic/",
558
+ "gpt-": "openai/",
559
+ "o1-": "openai/",
560
+ "o3-": "openai/",
561
+ "o4-": "openai/",
562
+ "gemini-": "google/",
563
+ }
564
+ for pattern, prefix in prefixes.items():
565
+ if model.startswith(pattern):
566
+ prefixed = f"{prefix}{model}"
567
+ try:
568
+ litellm.cost_per_token(model=prefixed, prompt_tokens=1, completion_tokens=0)
569
+ return prefixed
570
+ except Exception:
571
+ break
572
+
573
+ return model
574
+
575
  def estimate_cost(
576
  self,
577
  model: str,
 
594
  return None
595
 
596
  try:
597
+ # Resolve model name (adds provider prefix if needed, e.g. claude-opus-4-6 → anthropic/claude-opus-4-6)
598
+ resolved_model = self._resolve_litellm_model(model)
599
+
600
  # cost_per_token returns (total_input_cost, total_output_cost) for the given token counts
601
  # Despite the name, it returns total cost not per-token cost
602
 
 
609
 
610
  # Get cost for regular (non-cached) input tokens
611
  input_cost, _ = litellm.cost_per_token(
612
+ model=resolved_model,
613
  prompt_tokens=regular_input,
614
  completion_tokens=0,
615
  )
616
 
617
  # Get cost for output tokens
618
  _, output_cost = litellm.cost_per_token(
619
+ model=resolved_model,
620
  prompt_tokens=0,
621
  completion_tokens=output_tokens,
622
  )
 
624
  # Get model info for cache pricing
625
  model_info: dict[str, Any] = {}
626
  try:
627
+ model_info = dict(litellm.get_model_info(resolved_model))
628
  except Exception:
629
  pass
630
 
 
637
  else:
638
  # Fallback: most providers charge ~10% of input price for cache reads
639
  cache_read_full_cost, _ = litellm.cost_per_token(
640
+ model=resolved_model,
641
  prompt_tokens=cache_read_tokens,
642
  completion_tokens=0,
643
  )
 
652
  else:
653
  # Fallback: most providers charge ~125% of input price for cache writes
654
  cache_write_full_cost, _ = litellm.cost_per_token(
655
+ model=resolved_model,
656
  prompt_tokens=cache_write_tokens,
657
  completion_tokens=0,
658
  )
 
1038
  router_config = ContentRouterConfig(
1039
  enable_llmlingua=config.llmlingua_enabled,
1040
  enable_code_aware=config.code_aware_enabled,
1041
+ tool_profiles=config.tool_profiles,
1042
  )
1043
  transforms = [
1044
  CacheAligner(CacheAlignerConfig(enabled=True)),
 
2063
  cache_read_tokens=cache_read_tokens,
2064
  cache_write_tokens=cache_write_tokens,
2065
  )
2066
+ # original_cost: what it would have cost without compression
2067
+ # Use only original_tokens at regular input rate — no cache params,
2068
+ # since caching is orthogonal to compression savings
2069
  original_cost = self.cost_tracker.estimate_cost(
2070
  model,
2071
  original_tokens,
2072
  output_tokens,
 
 
2073
  )
2074
  if cost_usd and original_cost:
2075
  savings_usd = original_cost - cost_usd
 
4169
  output_tokens,
4170
  cache_read_tokens=cache_read_tokens,
4171
  )
4172
+ # original_cost: what it would have cost without compression
4173
+ # No cache params — caching is orthogonal to compression savings
4174
  original_cost = self.cost_tracker.estimate_cost(
4175
  model,
4176
  original_tokens,
4177
  output_tokens,
 
4178
  )
4179
  if cost_usd and original_cost:
4180
  savings_usd = original_cost - cost_usd
 
5225
  output_tokens,
5226
  cache_read_tokens=cache_read_tokens,
5227
  )
5228
+ # original_cost: what it would have cost without compression
5229
+ # No cache params — caching is orthogonal to compression savings
5230
  original_cost = self.cost_tracker.estimate_cost(
5231
  model,
5232
  original_tokens,
5233
  output_tokens,
 
5234
  )
5235
  if cost_usd and original_cost:
5236
  savings_usd = original_cost - cost_usd
 
6670
  return os.environ.get(name, default)
6671
 
6672
 
6673
+ def _parse_tool_profiles(cli_profiles: list[str]) -> dict[str, Any]:
6674
+ """Parse tool profiles from CLI args and HEADROOM_TOOL_PROFILES env var.
6675
+
6676
+ Format: ToolName:level (e.g., Grep:conservative, Bash:moderate)
6677
+ Env var format: comma-separated (e.g., "Grep:conservative,Bash:moderate")
6678
+
6679
+ Returns:
6680
+ Dict mapping tool names to CompressionProfile instances.
6681
+ """
6682
+ from headroom.config import PROFILE_PRESETS, CompressionProfile
6683
+
6684
+ profiles: dict[str, CompressionProfile] = {}
6685
+ raw_entries: list[str] = list(cli_profiles)
6686
+
6687
+ # Also check env var
6688
+ env_val = os.environ.get("HEADROOM_TOOL_PROFILES", "")
6689
+ if env_val:
6690
+ raw_entries.extend(e.strip() for e in env_val.split(",") if e.strip())
6691
+
6692
+ for entry in raw_entries:
6693
+ if ":" not in entry:
6694
+ logger.warning("Invalid tool profile format (expected ToolName:level): %s", entry)
6695
+ continue
6696
+ tool_name, level = entry.split(":", 1)
6697
+ tool_name = tool_name.strip()
6698
+ level = level.strip().lower()
6699
+
6700
+ if level in PROFILE_PRESETS:
6701
+ profiles[tool_name] = PROFILE_PRESETS[level]
6702
+ else:
6703
+ logger.warning(
6704
+ "Unknown profile level '%s' for tool '%s'. Use: conservative, moderate, aggressive",
6705
+ level,
6706
+ tool_name,
6707
+ )
6708
+
6709
+ return profiles
6710
+
6711
+
6712
  if __name__ == "__main__":
6713
  parser = argparse.ArgumentParser(description="Headroom Proxy Server")
6714
 
 
6772
  parser.add_argument("--no-optimize", action="store_true", help="Disable optimization")
6773
  parser.add_argument("--min-tokens", type=int, default=500, help="Min tokens to crush")
6774
  parser.add_argument("--max-items", type=int, default=50, help="Max items after crush")
6775
+ parser.add_argument(
6776
+ "--tool-profile",
6777
+ action="append",
6778
+ default=[],
6779
+ help="Per-tool compression profile: ToolName:level (e.g., Grep:conservative, Bash:moderate, WebFetch:aggressive). "
6780
+ "Can be specified multiple times. Also settable via HEADROOM_TOOL_PROFILES env var.",
6781
+ )
6782
 
6783
  # Caching
6784
  parser.add_argument("--no-cache", action="store_true", help="Disable caching")
 
6872
  if hasattr(args, "openrouter_api_key") and args.openrouter_api_key:
6873
  os.environ["OPENROUTER_API_KEY"] = args.openrouter_api_key
6874
 
6875
+ # Parse per-tool compression profiles from CLI and env var
6876
+ tool_profiles = _parse_tool_profiles(args.tool_profile)
6877
+
6878
  config = ProxyConfig(
6879
  host=_get_env_str("HEADROOM_HOST", args.host),
6880
  port=_get_env_int("HEADROOM_PORT", args.port),
 
6906
  max_connections=_get_env_int("HEADROOM_MAX_CONNECTIONS", args.max_connections),
6907
  max_keepalive_connections=_get_env_int("HEADROOM_MAX_KEEPALIVE", args.max_keepalive),
6908
  http2=not args.no_http2 and _get_env_bool("HEADROOM_HTTP2", True),
6909
+ tool_profiles=tool_profiles if tool_profiles else None,
6910
  )
6911
 
6912
  # Get worker and concurrency settings
headroom/transforms/adaptive_sizer.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adaptive compression sizing via information saturation detection.
2
+
3
+ Instead of hardcoded max_items/max_matches, this module statistically determines
4
+ how many items to keep by finding the "knee point" — where adding more items
5
+ stops providing meaningful new information.
6
+
7
+ Algorithm: Track unique bigrams as items are added in importance order. Build a
8
+ cumulative coverage curve. Find the knee (Kneedle algorithm) where marginal
9
+ information gain drops sharply. That's the optimal K.
10
+
11
+ Per-tool profiles apply a bias multiplier on the statistically-determined K:
12
+ - conservative (bias=1.5): keep 50% more than mathematically needed
13
+ - moderate (bias=1.0): trust the statistics
14
+ - aggressive (bias=0.7): compress harder
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import logging
21
+ import zlib
22
+ from collections.abc import Sequence
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def compute_optimal_k(
28
+ items: Sequence[str],
29
+ bias: float = 1.0,
30
+ min_k: int = 3,
31
+ max_k: int | None = None,
32
+ ) -> int:
33
+ """Compute the optimal number of items to keep using information saturation.
34
+
35
+ Three-tier decision system:
36
+ Tier 1 (fast path): trivial cases, near-duplicate detection
37
+ Tier 2 (standard): Kneedle on unique bigram coverage curve
38
+ Tier 3 (validation): zlib compression ratio sanity check
39
+
40
+ Args:
41
+ items: Sequence of string representations of items (in importance order).
42
+ bias: Multiplier on the knee point. >1 = keep more, <1 = keep fewer.
43
+ min_k: Never return fewer than this.
44
+ max_k: Never return more than this (None = no cap).
45
+
46
+ Returns:
47
+ Optimal number of items to keep.
48
+ """
49
+ n = len(items)
50
+ effective_max = max_k if max_k is not None else n
51
+
52
+ # Tier 1: Fast path
53
+ if n <= 8:
54
+ return n
55
+
56
+ # Check for near-total redundancy
57
+ unique_count = count_unique_simhash(items)
58
+ if unique_count <= 3:
59
+ k = max(min_k, unique_count)
60
+ return min(k, effective_max)
61
+
62
+ # Tier 2: Kneedle on unique bigram coverage
63
+ curve = compute_unique_bigram_curve(items)
64
+ knee = find_knee(curve)
65
+
66
+ if knee is None:
67
+ # No clear knee — content is uniformly diverse, keep ~30% as heuristic
68
+ knee = max(min_k, int(n * 0.3))
69
+
70
+ # Apply bias multiplier
71
+ k = max(min_k, int(knee * bias))
72
+ k = min(k, effective_max)
73
+
74
+ # Tier 3: Validate with zlib compression ratio
75
+ k = _validate_with_zlib(items, k, effective_max)
76
+
77
+ k = max(min_k, min(k, effective_max))
78
+
79
+ logger.debug(
80
+ "adaptive_sizer: n=%d unique=%d knee=%s bias=%.1f → k=%d",
81
+ n,
82
+ unique_count,
83
+ knee,
84
+ bias,
85
+ k,
86
+ )
87
+ return k
88
+
89
+
90
+ def find_knee(curve: list[int]) -> int | None:
91
+ """Find the knee point in a monotonically increasing curve.
92
+
93
+ Uses the Kneedle algorithm: normalize to [0,1], compute the difference
94
+ from the y=x diagonal, return the index of maximum difference.
95
+
96
+ Args:
97
+ curve: List of cumulative values (e.g., unique bigram counts).
98
+
99
+ Returns:
100
+ Index of the knee point, or None if no clear knee exists.
101
+ """
102
+ n = len(curve)
103
+ if n < 3:
104
+ return None
105
+
106
+ # Normalize x and y to [0, 1]
107
+ x_min, x_max = 0, n - 1
108
+ y_min, y_max = curve[0], curve[-1]
109
+
110
+ if y_max == y_min:
111
+ # Flat curve — all items are identical
112
+ return 1
113
+
114
+ x_range = x_max - x_min
115
+ y_range = y_max - y_min
116
+
117
+ # Compute difference from the diagonal (y = x in normalized space)
118
+ max_diff = -1.0
119
+ knee_idx = None
120
+
121
+ for i in range(n):
122
+ x_norm = (i - x_min) / x_range
123
+ y_norm = (curve[i] - y_min) / y_range
124
+ diff = y_norm - x_norm # For concave curves, knee is where this is maximized
125
+ if diff > max_diff:
126
+ max_diff = diff
127
+ knee_idx = i
128
+
129
+ # Require a meaningful deviation from diagonal
130
+ if max_diff < 0.05:
131
+ return None
132
+
133
+ # Knee is at knee_idx, but we want to include items up to and including the knee
134
+ # Add 1 because we're converting from 0-indexed to count
135
+ return knee_idx + 1 if knee_idx is not None else None
136
+
137
+
138
+ def compute_unique_bigram_curve(items: Sequence[str]) -> list[int]:
139
+ """Build cumulative unique bigram coverage curve.
140
+
141
+ For each item (in order), extracts word-level bigrams, adds them to a
142
+ running set, and records the total unique count.
143
+
144
+ Args:
145
+ items: Sequence of string items in importance order.
146
+
147
+ Returns:
148
+ List where curve[k] = number of unique bigrams after seeing items[0:k+1].
149
+ """
150
+ seen_bigrams: set[tuple[str, str]] = set()
151
+ curve: list[int] = []
152
+
153
+ for item in items:
154
+ words = item.lower().split()
155
+ if len(words) < 2:
156
+ # For single-word items, use the word itself as a "unigram bigram"
157
+ seen_bigrams.add((words[0] if words else "", ""))
158
+ else:
159
+ for j in range(len(words) - 1):
160
+ seen_bigrams.add((words[j], words[j + 1]))
161
+ curve.append(len(seen_bigrams))
162
+
163
+ return curve
164
+
165
+
166
+ def _simhash(text: str) -> int:
167
+ """Compute a 64-bit SimHash fingerprint for a text string.
168
+
169
+ Uses character 4-grams hashed to 64-bit values, then aggregates
170
+ via weighted bit voting.
171
+
172
+ Args:
173
+ text: Input text.
174
+
175
+ Returns:
176
+ 64-bit integer fingerprint.
177
+ """
178
+ v = [0] * 64
179
+ text_lower = text.lower()
180
+
181
+ # Character 4-grams
182
+ for i in range(max(1, len(text_lower) - 3)):
183
+ gram = text_lower[i : i + 4]
184
+ h = int(hashlib.md5(gram.encode(), usedforsecurity=False).hexdigest()[:16], 16)
185
+ for j in range(64):
186
+ if h & (1 << j):
187
+ v[j] += 1
188
+ else:
189
+ v[j] -= 1
190
+
191
+ fingerprint = 0
192
+ for j in range(64):
193
+ if v[j] > 0:
194
+ fingerprint |= 1 << j
195
+ return fingerprint
196
+
197
+
198
+ def _hamming_distance(a: int, b: int) -> int:
199
+ """Count differing bits between two 64-bit integers."""
200
+ return bin(a ^ b).count("1")
201
+
202
+
203
+ def count_unique_simhash(items: Sequence[str], threshold: int = 3) -> int:
204
+ """Count items with distinct content using SimHash.
205
+
206
+ Groups items by SimHash fingerprint similarity (Hamming distance <= threshold).
207
+ Returns the number of distinct groups.
208
+
209
+ Args:
210
+ items: Sequence of string items.
211
+ threshold: Max Hamming distance to consider items as duplicates.
212
+
213
+ Returns:
214
+ Number of unique content groups.
215
+ """
216
+ if not items:
217
+ return 0
218
+
219
+ # Compute fingerprints
220
+ fingerprints = [_simhash(item) for item in items]
221
+
222
+ # Greedy clustering: assign each item to the first matching cluster
223
+ clusters: list[int] = [] # Representative fingerprint per cluster
224
+ for fp in fingerprints:
225
+ matched = False
226
+ for rep in clusters:
227
+ if _hamming_distance(fp, rep) <= threshold:
228
+ matched = True
229
+ break
230
+ if not matched:
231
+ clusters.append(fp)
232
+
233
+ return len(clusters)
234
+
235
+
236
+ def _validate_with_zlib(
237
+ items: Sequence[str],
238
+ k: int,
239
+ max_k: int,
240
+ tolerance: float = 0.15,
241
+ ) -> int:
242
+ """Validate K using zlib compression ratio comparison.
243
+
244
+ If the compression ratio of the selected subset differs significantly
245
+ from the full set, increase K.
246
+
247
+ Args:
248
+ items: All items.
249
+ k: Currently proposed K.
250
+ max_k: Maximum allowed K.
251
+ tolerance: Max allowed ratio difference (default 15%).
252
+
253
+ Returns:
254
+ Adjusted K (may be increased if validation fails).
255
+ """
256
+ if k >= len(items) or k >= max_k:
257
+ return k
258
+
259
+ full_text = "\n".join(items).encode()
260
+ subset_text = "\n".join(items[:k]).encode()
261
+
262
+ # Skip validation for very small content (zlib overhead dominates)
263
+ if len(full_text) < 200:
264
+ return k
265
+
266
+ full_compressed = len(zlib.compress(full_text, level=1))
267
+ subset_compressed = len(zlib.compress(subset_text, level=1))
268
+
269
+ full_ratio = full_compressed / len(full_text) if full_text else 1.0
270
+ subset_ratio = subset_compressed / len(subset_text) if subset_text else 1.0
271
+
272
+ # If subset compresses much better than full, it's missing diverse content
273
+ # A lower ratio means more redundancy. If subset ratio is much lower,
274
+ # it means the subset is more redundant than the full set — we're missing info.
275
+ ratio_diff = abs(full_ratio - subset_ratio)
276
+
277
+ if ratio_diff > tolerance:
278
+ # Increase K by 20% to capture more diversity
279
+ adjusted_k = min(int(k * 1.2), max_k)
280
+ logger.debug(
281
+ "zlib validation: ratio_diff=%.3f > %.3f, adjusting k=%d → %d",
282
+ ratio_diff,
283
+ tolerance,
284
+ k,
285
+ adjusted_k,
286
+ )
287
+ return adjusted_k
288
+
289
+ return k
headroom/transforms/content_router.py CHANGED
@@ -292,6 +292,10 @@ class ContentRouterConfig:
292
  # Set to None to use DEFAULT_EXCLUDE_TOOLS, or provide custom set
293
  exclude_tools: set[str] | None = None
294
 
 
 
 
 
295
 
296
  # Patterns for detecting mixed content
297
  _CODE_FENCE_PATTERN = re.compile(r"^```(\w*)\s*$", re.MULTILINE)
@@ -588,6 +592,7 @@ class ContentRouter(Transform):
588
  content: str,
589
  context: str = "",
590
  question: str | None = None,
 
591
  ) -> RouterCompressionResult:
592
  """Compress content using optimal strategy based on content detection.
593
 
@@ -596,6 +601,7 @@ class ContentRouter(Transform):
596
  context: Optional context for relevance-aware compression.
597
  question: Optional question for QA-aware compression. When provided,
598
  tokens relevant to answering this question are preserved.
 
599
 
600
  Returns:
601
  RouterCompressionResult with compressed content and routing metadata.
@@ -612,9 +618,9 @@ class ContentRouter(Transform):
612
  strategy = self._determine_strategy(content)
613
 
614
  if strategy == CompressionStrategy.MIXED:
615
- return self._compress_mixed(content, context, question)
616
  else:
617
- return self._compress_pure(content, strategy, context, question)
618
 
619
  def _determine_strategy(self, content: str) -> CompressionStrategy:
620
  """Determine the compression strategy from content analysis.
@@ -668,6 +674,7 @@ class ContentRouter(Transform):
668
  content: str,
669
  context: str,
670
  question: str | None = None,
 
671
  ) -> RouterCompressionResult:
672
  """Compress mixed content by splitting and routing sections.
673
 
@@ -675,6 +682,7 @@ class ContentRouter(Transform):
675
  content: Mixed content to compress.
676
  context: User context for relevance.
677
  question: Optional question for QA-aware compression.
 
678
 
679
  Returns:
680
  RouterCompressionResult with reassembled content.
@@ -698,7 +706,12 @@ class ContentRouter(Transform):
698
  # Compress section
699
  original_tokens = len(section.content.split())
700
  compressed_content, compressed_tokens = self._apply_strategy_to_content(
701
- section.content, strategy, context, section.language, question
 
 
 
 
 
702
  )
703
 
704
  # Preserve code fence markers
@@ -730,6 +743,7 @@ class ContentRouter(Transform):
730
  strategy: CompressionStrategy,
731
  context: str,
732
  question: str | None = None,
 
733
  ) -> RouterCompressionResult:
734
  """Compress pure (non-mixed) content.
735
 
@@ -738,6 +752,7 @@ class ContentRouter(Transform):
738
  strategy: Selected strategy.
739
  context: User context.
740
  question: Optional question for QA-aware compression.
 
741
 
742
  Returns:
743
  RouterCompressionResult.
@@ -745,7 +760,7 @@ class ContentRouter(Transform):
745
  original_tokens = len(content.split())
746
 
747
  compressed, compressed_tokens = self._apply_strategy_to_content(
748
- content, strategy, context, question=question
749
  )
750
 
751
  return RouterCompressionResult(
@@ -769,6 +784,7 @@ class ContentRouter(Transform):
769
  context: str,
770
  language: str | None = None,
771
  question: str | None = None,
 
772
  ) -> tuple[str, int]:
773
  """Apply a compression strategy to content.
774
 
@@ -778,6 +794,7 @@ class ContentRouter(Transform):
778
  context: User context.
779
  language: Language hint for code.
780
  question: Optional question for QA-aware compression.
 
781
 
782
  Returns:
783
  Tuple of (compressed_content, compressed_token_count).
@@ -804,14 +821,14 @@ class ContentRouter(Transform):
804
  if self.config.enable_smart_crusher:
805
  crusher = self._get_smart_crusher()
806
  if crusher:
807
- result = crusher.crush(content, query=context)
808
  return result.compressed, len(result.compressed.split())
809
 
810
  elif strategy == CompressionStrategy.SEARCH:
811
  if self.config.enable_search_compressor:
812
  compressor = self._get_search_compressor()
813
  if compressor:
814
- result = compressor.compress(content, context=context)
815
  compressed, compressed_tokens = (
816
  result.compressed,
817
  len(result.compressed.split()),
@@ -821,7 +838,7 @@ class ContentRouter(Transform):
821
  if self.config.enable_log_compressor:
822
  compressor = self._get_log_compressor()
823
  if compressor:
824
- result = compressor.compress(content)
825
  compressed, compressed_tokens = (
826
  result.compressed,
827
  result.compressed_line_count,
@@ -1184,11 +1201,17 @@ class ContentRouter(Transform):
1184
  for i, message in enumerate(messages):
1185
  role = message.get("role", "")
1186
  content = message.get("content", "")
 
1187
 
1188
  # Handle list content (Anthropic format with content blocks)
1189
  if isinstance(content, list):
1190
  transformed_message = self._process_content_blocks(
1191
- message, content, context, transforms_applied, excluded_tool_ids
 
 
 
 
 
1192
  )
1193
  transformed_messages.append(transformed_message)
1194
  continue
@@ -1205,6 +1228,9 @@ class ContentRouter(Transform):
1205
  transformed_messages.append(message)
1206
  transforms_applied.append("router:excluded:tool")
1207
  continue
 
 
 
1208
 
1209
  # Protection 1: Never compress user messages
1210
  if self.config.skip_user_messages and role == "user":
@@ -1239,7 +1265,9 @@ class ContentRouter(Transform):
1239
  continue
1240
 
1241
  # Route and compress based on content detection
1242
- result = self.compress(content, context=context)
 
 
1243
 
1244
  if result.compression_ratio < 0.9:
1245
  transformed_messages.append({**message, "content": result.compressed})
@@ -1261,6 +1289,27 @@ class ContentRouter(Transform):
1261
  warnings=warnings,
1262
  )
1263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1264
  def _process_content_blocks(
1265
  self,
1266
  message: dict[str, Any],
@@ -1268,6 +1317,7 @@ class ContentRouter(Transform):
1268
  context: str,
1269
  transforms_applied: list[str],
1270
  excluded_tool_ids: set[str],
 
1271
  ) -> dict[str, Any]:
1272
  """Process content blocks (Anthropic format) for tool_result compression.
1273
 
@@ -1279,6 +1329,8 @@ class ContentRouter(Transform):
1279
  content_blocks: List of content blocks.
1280
  context: Context for compression.
1281
  transforms_applied: List to append transform names to.
 
 
1282
 
1283
  Returns:
1284
  Transformed message with compressed content blocks.
@@ -1302,12 +1354,16 @@ class ContentRouter(Transform):
1302
  transforms_applied.append("router:excluded:tool")
1303
  continue
1304
 
 
 
 
 
1305
  tool_content = block.get("content", "")
1306
 
1307
  # Only process string content
1308
  if isinstance(tool_content, str) and len(tool_content) > 500:
1309
  # Compress using content detection (will auto-detect JSON arrays, etc.)
1310
- result = self.compress(tool_content, context=context)
1311
  if result.compression_ratio < 0.9:
1312
  new_blocks.append({**block, "content": result.compressed})
1313
  transforms_applied.append(
 
292
  # Set to None to use DEFAULT_EXCLUDE_TOOLS, or provide custom set
293
  exclude_tools: set[str] | None = None
294
 
295
+ # Per-tool compression profiles (tool_name → CompressionProfile)
296
+ # Set to None to use DEFAULT_TOOL_PROFILES from config
297
+ tool_profiles: dict[str, Any] | None = None
298
+
299
 
300
  # Patterns for detecting mixed content
301
  _CODE_FENCE_PATTERN = re.compile(r"^```(\w*)\s*$", re.MULTILINE)
 
592
  content: str,
593
  context: str = "",
594
  question: str | None = None,
595
+ bias: float = 1.0,
596
  ) -> RouterCompressionResult:
597
  """Compress content using optimal strategy based on content detection.
598
 
 
601
  context: Optional context for relevance-aware compression.
602
  question: Optional question for QA-aware compression. When provided,
603
  tokens relevant to answering this question are preserved.
604
+ bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
605
 
606
  Returns:
607
  RouterCompressionResult with compressed content and routing metadata.
 
618
  strategy = self._determine_strategy(content)
619
 
620
  if strategy == CompressionStrategy.MIXED:
621
+ return self._compress_mixed(content, context, question, bias=bias)
622
  else:
623
+ return self._compress_pure(content, strategy, context, question, bias=bias)
624
 
625
  def _determine_strategy(self, content: str) -> CompressionStrategy:
626
  """Determine the compression strategy from content analysis.
 
674
  content: str,
675
  context: str,
676
  question: str | None = None,
677
+ bias: float = 1.0,
678
  ) -> RouterCompressionResult:
679
  """Compress mixed content by splitting and routing sections.
680
 
 
682
  content: Mixed content to compress.
683
  context: User context for relevance.
684
  question: Optional question for QA-aware compression.
685
+ bias: Compression bias multiplier.
686
 
687
  Returns:
688
  RouterCompressionResult with reassembled content.
 
706
  # Compress section
707
  original_tokens = len(section.content.split())
708
  compressed_content, compressed_tokens = self._apply_strategy_to_content(
709
+ section.content,
710
+ strategy,
711
+ context,
712
+ section.language,
713
+ question,
714
+ bias=bias,
715
  )
716
 
717
  # Preserve code fence markers
 
743
  strategy: CompressionStrategy,
744
  context: str,
745
  question: str | None = None,
746
+ bias: float = 1.0,
747
  ) -> RouterCompressionResult:
748
  """Compress pure (non-mixed) content.
749
 
 
752
  strategy: Selected strategy.
753
  context: User context.
754
  question: Optional question for QA-aware compression.
755
+ bias: Compression bias multiplier.
756
 
757
  Returns:
758
  RouterCompressionResult.
 
760
  original_tokens = len(content.split())
761
 
762
  compressed, compressed_tokens = self._apply_strategy_to_content(
763
+ content, strategy, context, question=question, bias=bias
764
  )
765
 
766
  return RouterCompressionResult(
 
784
  context: str,
785
  language: str | None = None,
786
  question: str | None = None,
787
+ bias: float = 1.0,
788
  ) -> tuple[str, int]:
789
  """Apply a compression strategy to content.
790
 
 
794
  context: User context.
795
  language: Language hint for code.
796
  question: Optional question for QA-aware compression.
797
+ bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
798
 
799
  Returns:
800
  Tuple of (compressed_content, compressed_token_count).
 
821
  if self.config.enable_smart_crusher:
822
  crusher = self._get_smart_crusher()
823
  if crusher:
824
+ result = crusher.crush(content, query=context, bias=bias)
825
  return result.compressed, len(result.compressed.split())
826
 
827
  elif strategy == CompressionStrategy.SEARCH:
828
  if self.config.enable_search_compressor:
829
  compressor = self._get_search_compressor()
830
  if compressor:
831
+ result = compressor.compress(content, context=context, bias=bias)
832
  compressed, compressed_tokens = (
833
  result.compressed,
834
  len(result.compressed.split()),
 
838
  if self.config.enable_log_compressor:
839
  compressor = self._get_log_compressor()
840
  if compressor:
841
+ result = compressor.compress(content, bias=bias)
842
  compressed, compressed_tokens = (
843
  result.compressed,
844
  result.compressed_line_count,
 
1201
  for i, message in enumerate(messages):
1202
  role = message.get("role", "")
1203
  content = message.get("content", "")
1204
+ bias = 1.0 # Default bias, may be overridden for tool messages
1205
 
1206
  # Handle list content (Anthropic format with content blocks)
1207
  if isinstance(content, list):
1208
  transformed_message = self._process_content_blocks(
1209
+ message,
1210
+ content,
1211
+ context,
1212
+ transforms_applied,
1213
+ excluded_tool_ids,
1214
+ tool_name_map=tool_name_map,
1215
  )
1216
  transformed_messages.append(transformed_message)
1217
  continue
 
1228
  transformed_messages.append(message)
1229
  transforms_applied.append("router:excluded:tool")
1230
  continue
1231
+ # Look up tool-specific compression bias for OpenAI tool messages
1232
+ tool_name = tool_name_map.get(tool_call_id, "")
1233
+ bias = self._get_tool_bias(tool_name) if tool_name else 1.0
1234
 
1235
  # Protection 1: Never compress user messages
1236
  if self.config.skip_user_messages and role == "user":
 
1265
  continue
1266
 
1267
  # Route and compress based on content detection
1268
+ # Use tool-specific bias for tool messages, default 1.0 for others
1269
+ msg_bias = bias if role == "tool" else 1.0
1270
+ result = self.compress(content, context=context, bias=msg_bias)
1271
 
1272
  if result.compression_ratio < 0.9:
1273
  transformed_messages.append({**message, "content": result.compressed})
 
1289
  warnings=warnings,
1290
  )
1291
 
1292
+ def _get_tool_bias(self, tool_name: str) -> float:
1293
+ """Look up compression bias for a tool name.
1294
+
1295
+ Checks user-configured profiles first, then DEFAULT_TOOL_PROFILES.
1296
+ Returns 1.0 (moderate) if no profile is configured.
1297
+ """
1298
+ from ..config import DEFAULT_TOOL_PROFILES
1299
+
1300
+ # Check user-configured profiles
1301
+ if self.config.tool_profiles:
1302
+ profile = self.config.tool_profiles.get(tool_name)
1303
+ if profile:
1304
+ return float(profile.bias)
1305
+
1306
+ # Check default profiles
1307
+ profile = DEFAULT_TOOL_PROFILES.get(tool_name)
1308
+ if profile:
1309
+ return profile.bias
1310
+
1311
+ return 1.0 # Default: moderate
1312
+
1313
  def _process_content_blocks(
1314
  self,
1315
  message: dict[str, Any],
 
1317
  context: str,
1318
  transforms_applied: list[str],
1319
  excluded_tool_ids: set[str],
1320
+ tool_name_map: dict[str, str] | None = None,
1321
  ) -> dict[str, Any]:
1322
  """Process content blocks (Anthropic format) for tool_result compression.
1323
 
 
1329
  content_blocks: List of content blocks.
1330
  context: Context for compression.
1331
  transforms_applied: List to append transform names to.
1332
+ excluded_tool_ids: Tool IDs to skip compression for.
1333
+ tool_name_map: Mapping from tool_call_id to tool_name for profile lookup.
1334
 
1335
  Returns:
1336
  Transformed message with compressed content blocks.
 
1354
  transforms_applied.append("router:excluded:tool")
1355
  continue
1356
 
1357
+ # Look up tool-specific compression bias
1358
+ tool_name = (tool_name_map or {}).get(tool_use_id, "")
1359
+ bias = self._get_tool_bias(tool_name) if tool_name else 1.0
1360
+
1361
  tool_content = block.get("content", "")
1362
 
1363
  # Only process string content
1364
  if isinstance(tool_content, str) and len(tool_content) > 500:
1365
  # Compress using content detection (will auto-detect JSON arrays, etc.)
1366
+ result = self.compress(tool_content, context=context, bias=bias)
1367
  if result.compression_ratio < 0.9:
1368
  new_blocks.append({**block, "content": result.compressed})
1369
  transforms_applied.append(
headroom/transforms/log_compressor.py CHANGED
@@ -176,12 +176,13 @@ class LogCompressor:
176
  """
177
  self.config = config or LogCompressorConfig()
178
 
179
- def compress(self, content: str, context: str = "") -> LogCompressionResult:
180
  """Compress log output.
181
 
182
  Args:
183
  content: Raw log output.
184
  context: User query context (unused for now).
 
185
 
186
  Returns:
187
  LogCompressionResult with compressed output and metadata.
@@ -204,8 +205,8 @@ class LogCompressor:
204
  # Parse and categorize lines
205
  log_lines = self._parse_lines(lines)
206
 
207
- # Select important lines
208
- selected = self._select_lines(log_lines)
209
 
210
  # Format output with summaries
211
  compressed, stats = self._format_output(selected, log_lines)
@@ -319,10 +320,22 @@ class LogCompressor:
319
 
320
  return min(1.0, score)
321
 
322
- def _select_lines(self, log_lines: list[LogLine]) -> list[LogLine]:
323
- """Select important lines to keep."""
 
 
324
  selected: list[LogLine] = []
325
 
 
 
 
 
 
 
 
 
 
 
326
  # Group by category
327
  errors: list[LogLine] = []
328
  fails: list[LogLine] = []
@@ -381,11 +394,11 @@ class LogCompressor:
381
  # Sort by line number and dedupe
382
  selected = sorted(set(selected), key=lambda x: x.line_number)
383
 
384
- # Limit total lines
385
- if len(selected) > self.config.max_total_lines:
386
  # Keep most important lines
387
  selected = sorted(selected, key=lambda x: x.score, reverse=True)
388
- selected = selected[: self.config.max_total_lines]
389
  selected = sorted(selected, key=lambda x: x.line_number)
390
 
391
  return selected
 
176
  """
177
  self.config = config or LogCompressorConfig()
178
 
179
+ def compress(self, content: str, context: str = "", bias: float = 1.0) -> LogCompressionResult:
180
  """Compress log output.
181
 
182
  Args:
183
  content: Raw log output.
184
  context: User query context (unused for now).
185
+ bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
186
 
187
  Returns:
188
  LogCompressionResult with compressed output and metadata.
 
205
  # Parse and categorize lines
206
  log_lines = self._parse_lines(lines)
207
 
208
+ # Select important lines (with adaptive sizing)
209
+ selected = self._select_lines(log_lines, bias=bias)
210
 
211
  # Format output with summaries
212
  compressed, stats = self._format_output(selected, log_lines)
 
320
 
321
  return min(1.0, score)
322
 
323
+ def _select_lines(self, log_lines: list[LogLine], bias: float = 1.0) -> list[LogLine]:
324
+ """Select important lines to keep using adaptive sizing."""
325
+ from .adaptive_sizer import compute_optimal_k
326
+
327
  selected: list[LogLine] = []
328
 
329
+ # Compute adaptive line budget from the FULL log output
330
+ # This determines "how many lines of value exist" across the entire log
331
+ all_line_strings = [line.content for line in log_lines]
332
+ adaptive_max = compute_optimal_k(
333
+ all_line_strings,
334
+ bias=bias,
335
+ min_k=10,
336
+ max_k=self.config.max_total_lines,
337
+ )
338
+
339
  # Group by category
340
  errors: list[LogLine] = []
341
  fails: list[LogLine] = []
 
394
  # Sort by line number and dedupe
395
  selected = sorted(set(selected), key=lambda x: x.line_number)
396
 
397
+ # Apply adaptive line limit
398
+ if len(selected) > adaptive_max:
399
  # Keep most important lines
400
  selected = sorted(selected, key=lambda x: x.score, reverse=True)
401
+ selected = selected[:adaptive_max]
402
  selected = sorted(selected, key=lambda x: x.line_number)
403
 
404
  return selected
headroom/transforms/search_compressor.py CHANGED
@@ -106,12 +106,14 @@ class SearchCompressor:
106
  self,
107
  content: str,
108
  context: str = "",
 
109
  ) -> SearchCompressionResult:
110
  """Compress search results.
111
 
112
  Args:
113
  content: Raw grep/ripgrep output.
114
  context: User query context for relevance scoring.
 
115
 
116
  Returns:
117
  SearchCompressionResult with compressed output and metadata.
@@ -135,8 +137,8 @@ class SearchCompressor:
135
  # Score matches by relevance
136
  self._score_matches(file_matches, context)
137
 
138
- # Select top matches per file
139
- selected = self._select_matches(file_matches)
140
 
141
  # Format compressed output
142
  compressed, summaries = self._format_output(selected, file_matches)
@@ -235,8 +237,11 @@ class SearchCompressor:
235
  def _select_matches(
236
  self,
237
  file_matches: dict[str, FileMatches],
 
238
  ) -> dict[str, FileMatches]:
239
- """Select top matches per file and globally."""
 
 
240
  selected: dict[str, FileMatches] = {}
241
 
242
  # Sort files by total match score (highest first)
@@ -249,9 +254,22 @@ class SearchCompressor:
249
  # Limit number of files
250
  sorted_files = sorted_files[: self.config.max_files]
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  total_selected = 0
253
  for file_path, fm in sorted_files:
254
- if total_selected >= self.config.max_total_matches:
255
  break
256
 
257
  # Sort matches by score
@@ -261,7 +279,7 @@ class SearchCompressor:
261
  file_selected: list[SearchMatch] = []
262
  remaining_slots = min(
263
  self.config.max_matches_per_file,
264
- self.config.max_total_matches - total_selected,
265
  )
266
 
267
  # Always include first and last if configured
 
106
  self,
107
  content: str,
108
  context: str = "",
109
+ bias: float = 1.0,
110
  ) -> SearchCompressionResult:
111
  """Compress search results.
112
 
113
  Args:
114
  content: Raw grep/ripgrep output.
115
  context: User query context for relevance scoring.
116
+ bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
117
 
118
  Returns:
119
  SearchCompressionResult with compressed output and metadata.
 
137
  # Score matches by relevance
138
  self._score_matches(file_matches, context)
139
 
140
+ # Select top matches per file (with adaptive sizing)
141
+ selected = self._select_matches(file_matches, bias=bias)
142
 
143
  # Format compressed output
144
  compressed, summaries = self._format_output(selected, file_matches)
 
237
  def _select_matches(
238
  self,
239
  file_matches: dict[str, FileMatches],
240
+ bias: float = 1.0,
241
  ) -> dict[str, FileMatches]:
242
+ """Select top matches per file and globally using adaptive sizing."""
243
+ from .adaptive_sizer import compute_optimal_k
244
+
245
  selected: dict[str, FileMatches] = {}
246
 
247
  # Sort files by total match score (highest first)
 
254
  # Limit number of files
255
  sorted_files = sorted_files[: self.config.max_files]
256
 
257
+ # Compute adaptive total matches limit
258
+ all_match_strings = [
259
+ f"{file_path}:{m.line_number}:{m.content}"
260
+ for file_path, fm in sorted_files
261
+ for m in fm.matches
262
+ ]
263
+ adaptive_total = compute_optimal_k(
264
+ all_match_strings,
265
+ bias=bias,
266
+ min_k=5,
267
+ max_k=self.config.max_total_matches,
268
+ )
269
+
270
  total_selected = 0
271
  for file_path, fm in sorted_files:
272
+ if total_selected >= adaptive_total:
273
  break
274
 
275
  # Sort matches by score
 
279
  file_selected: list[SearchMatch] = []
280
  remaining_slots = min(
281
  self.config.max_matches_per_file,
282
+ adaptive_total - total_selected,
283
  )
284
 
285
  # Always include first and last if configured
headroom/transforms/smart_crusher.py CHANGED
@@ -1440,7 +1440,7 @@ class SmartCrusher(Transform):
1440
  CompressionStrategy.SMART_SAMPLE: AnchorDataPattern.GENERIC,
1441
  }.get(strategy, AnchorDataPattern.GENERIC)
1442
 
1443
- def crush(self, content: str, query: str = "") -> CrushResult:
1444
  """Crush content string directly (for use by ContentRouter).
1445
 
1446
  This is a simplified interface for compressing a single content string,
@@ -1449,12 +1449,13 @@ class SmartCrusher(Transform):
1449
  Args:
1450
  content: JSON string content to compress.
1451
  query: Query context for relevance-based compression.
 
1452
 
1453
  Returns:
1454
  CrushResult with compressed content and metadata.
1455
  """
1456
  compressed, was_modified, analysis_info = self._smart_crush_content(
1457
- content, query_context=query
1458
  )
1459
  return CrushResult(
1460
  compressed=compressed,
@@ -2088,7 +2089,11 @@ class SmartCrusher(Transform):
2088
  return " ".join(context_parts)
2089
 
2090
  def _smart_crush_content(
2091
- self, content: str, query_context: str = "", tool_name: str | None = None
 
 
 
 
2092
  ) -> tuple[str, bool, str]:
2093
  """
2094
  Apply smart crushing to content.
@@ -2100,6 +2105,7 @@ class SmartCrusher(Transform):
2100
  content: Content to crush (JSON or plain text).
2101
  query_context: Context string from user messages for relevance scoring.
2102
  tool_name: Name of the tool that produced this output.
 
2103
 
2104
  Returns:
2105
  Tuple of (crushed_content, was_modified, analysis_info).
@@ -2113,7 +2119,7 @@ class SmartCrusher(Transform):
2113
 
2114
  # Recursively process and crush arrays
2115
  crushed, info, ccr_markers = self._process_value(
2116
- parsed, query_context=query_context, tool_name=tool_name
2117
  )
2118
 
2119
  result = safe_json_dumps(crushed, indent=None)
@@ -2132,7 +2138,12 @@ class SmartCrusher(Transform):
2132
  return result, was_modified, info
2133
 
2134
  def _process_value(
2135
- self, value: Any, depth: int = 0, query_context: str = "", tool_name: str | None = None
 
 
 
 
 
2136
  ) -> tuple[Any, str, list[tuple[str, int, int]]]:
2137
  """Recursively process a value, crushing arrays where appropriate.
2138
 
@@ -2148,7 +2159,9 @@ class SmartCrusher(Transform):
2148
  # Must have enough items AND all items must be dicts (not mixed types)
2149
  all_dicts = value and all(isinstance(item, dict) for item in value)
2150
  if len(value) >= self.config.min_items_to_analyze and all_dicts:
2151
- crushed, strategy, ccr_hash = self._crush_array(value, query_context, tool_name)
 
 
2152
  info_parts.append(f"{strategy}({len(value)}->{len(crushed)})")
2153
 
2154
  # Track CCR marker for later injection
@@ -2161,7 +2174,7 @@ class SmartCrusher(Transform):
2161
  processed = []
2162
  for item in value:
2163
  p_item, p_info, p_markers = self._process_value(
2164
- item, depth + 1, query_context, tool_name
2165
  )
2166
  processed.append(p_item)
2167
  if p_info:
@@ -2174,7 +2187,7 @@ class SmartCrusher(Transform):
2174
  processed_dict: dict[str, Any] = {}
2175
  for k, v in value.items():
2176
  p_val, p_info, p_markers = self._process_value(
2177
- v, depth + 1, query_context, tool_name
2178
  )
2179
  processed_dict[k] = p_val
2180
  if p_info:
@@ -2186,7 +2199,11 @@ class SmartCrusher(Transform):
2186
  return value, "", []
2187
 
2188
  def _crush_array(
2189
- self, items: list[dict], query_context: str = "", tool_name: str | None = None
 
 
 
 
2190
  ) -> tuple[list, str, str | None]:
2191
  """Crush an array using statistical analysis and relevance scoring.
2192
 
@@ -2199,17 +2216,34 @@ class SmartCrusher(Transform):
2199
  Feedback-aware: Uses learned patterns to adjust compression aggressiveness.
2200
  High retrieval rate for a tool → compress less aggressively.
2201
 
 
 
 
 
 
 
2202
  Returns:
2203
  Tuple of (crushed_items, strategy_info, ccr_hash).
2204
  ccr_hash is the hash for retrieval if CCR is enabled, None otherwise.
2205
  """
2206
- # BOUNDARY CHECK: If already at or below max_items, no compression needed
2207
- if len(items) <= self.config.max_items_after_crush:
2208
- return items, "none:at_limit", None
 
 
 
 
 
 
 
 
 
 
 
2209
 
2210
  # Get feedback hints if enabled
2211
  # THREAD-SAFETY: Use a local effective_max_items instead of mutating shared config
2212
- effective_max_items = self.config.max_items_after_crush
2213
  hints_applied = False
2214
  toin_hint_applied = False
2215
 
 
1440
  CompressionStrategy.SMART_SAMPLE: AnchorDataPattern.GENERIC,
1441
  }.get(strategy, AnchorDataPattern.GENERIC)
1442
 
1443
+ def crush(self, content: str, query: str = "", bias: float = 1.0) -> CrushResult:
1444
  """Crush content string directly (for use by ContentRouter).
1445
 
1446
  This is a simplified interface for compressing a single content string,
 
1449
  Args:
1450
  content: JSON string content to compress.
1451
  query: Query context for relevance-based compression.
1452
+ bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
1453
 
1454
  Returns:
1455
  CrushResult with compressed content and metadata.
1456
  """
1457
  compressed, was_modified, analysis_info = self._smart_crush_content(
1458
+ content, query_context=query, bias=bias
1459
  )
1460
  return CrushResult(
1461
  compressed=compressed,
 
2089
  return " ".join(context_parts)
2090
 
2091
  def _smart_crush_content(
2092
+ self,
2093
+ content: str,
2094
+ query_context: str = "",
2095
+ tool_name: str | None = None,
2096
+ bias: float = 1.0,
2097
  ) -> tuple[str, bool, str]:
2098
  """
2099
  Apply smart crushing to content.
 
2105
  content: Content to crush (JSON or plain text).
2106
  query_context: Context string from user messages for relevance scoring.
2107
  tool_name: Name of the tool that produced this output.
2108
+ bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
2109
 
2110
  Returns:
2111
  Tuple of (crushed_content, was_modified, analysis_info).
 
2119
 
2120
  # Recursively process and crush arrays
2121
  crushed, info, ccr_markers = self._process_value(
2122
+ parsed, query_context=query_context, tool_name=tool_name, bias=bias
2123
  )
2124
 
2125
  result = safe_json_dumps(crushed, indent=None)
 
2138
  return result, was_modified, info
2139
 
2140
  def _process_value(
2141
+ self,
2142
+ value: Any,
2143
+ depth: int = 0,
2144
+ query_context: str = "",
2145
+ tool_name: str | None = None,
2146
+ bias: float = 1.0,
2147
  ) -> tuple[Any, str, list[tuple[str, int, int]]]:
2148
  """Recursively process a value, crushing arrays where appropriate.
2149
 
 
2159
  # Must have enough items AND all items must be dicts (not mixed types)
2160
  all_dicts = value and all(isinstance(item, dict) for item in value)
2161
  if len(value) >= self.config.min_items_to_analyze and all_dicts:
2162
+ crushed, strategy, ccr_hash = self._crush_array(
2163
+ value, query_context, tool_name, bias=bias
2164
+ )
2165
  info_parts.append(f"{strategy}({len(value)}->{len(crushed)})")
2166
 
2167
  # Track CCR marker for later injection
 
2174
  processed = []
2175
  for item in value:
2176
  p_item, p_info, p_markers = self._process_value(
2177
+ item, depth + 1, query_context, tool_name, bias=bias
2178
  )
2179
  processed.append(p_item)
2180
  if p_info:
 
2187
  processed_dict: dict[str, Any] = {}
2188
  for k, v in value.items():
2189
  p_val, p_info, p_markers = self._process_value(
2190
+ v, depth + 1, query_context, tool_name, bias=bias
2191
  )
2192
  processed_dict[k] = p_val
2193
  if p_info:
 
2199
  return value, "", []
2200
 
2201
  def _crush_array(
2202
+ self,
2203
+ items: list[dict],
2204
+ query_context: str = "",
2205
+ tool_name: str | None = None,
2206
+ bias: float = 1.0,
2207
  ) -> tuple[list, str, str | None]:
2208
  """Crush an array using statistical analysis and relevance scoring.
2209
 
 
2216
  Feedback-aware: Uses learned patterns to adjust compression aggressiveness.
2217
  High retrieval rate for a tool → compress less aggressively.
2218
 
2219
+ Args:
2220
+ items: List of dict items to compress.
2221
+ query_context: Context string from user messages for relevance scoring.
2222
+ tool_name: Name of the tool that produced this output.
2223
+ bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
2224
+
2225
  Returns:
2226
  Tuple of (crushed_items, strategy_info, ccr_hash).
2227
  ccr_hash is the hash for retrieval if CCR is enabled, None otherwise.
2228
  """
2229
+ # BOUNDARY CHECK: Use adaptive sizing instead of hardcoded limit
2230
+ # compute_optimal_k handles trivial cases (n <= 8 → keep all)
2231
+ from .adaptive_sizer import compute_optimal_k
2232
+
2233
+ item_strings = [json.dumps(item, default=str) for item in items]
2234
+ adaptive_k = compute_optimal_k(
2235
+ item_strings,
2236
+ bias=bias,
2237
+ min_k=3,
2238
+ max_k=self.config.max_items_after_crush if self.config.max_items_after_crush else None,
2239
+ )
2240
+
2241
+ if len(items) <= adaptive_k:
2242
+ return items, "none:adaptive_at_limit", None
2243
 
2244
  # Get feedback hints if enabled
2245
  # THREAD-SAFETY: Use a local effective_max_items instead of mutating shared config
2246
+ effective_max_items = adaptive_k
2247
  hints_applied = False
2248
  toin_hint_applied = False
2249