Spaces:
Build error
Build error
tudragon154203 commited on
Commit ·
c8d4763
1
Parent(s): f7fead9
chore(transforms): remove twotrim files\n\n- Remove two_trim.py implementation file\n- Remove test_two_trim.py test file\n- Remove bench_two_trim.py benchmark file\n\nGenerated with [Claude Code](https://claude.ai/code)\nvia [Happy](https://happy.engineering)\n\nCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>\nCo-Authored-By: Happy <yesreply@happy.engineering>
Browse files- benchmarks/bench_two_trim.py +0 -572
- headroom/transforms/two_trim.py +0 -439
- tests/test_two_trim.py +0 -425
benchmarks/bench_two_trim.py
DELETED
|
@@ -1,572 +0,0 @@
|
|
| 1 |
-
"""TwoTrim Benchmark: compression impact across text types and modes.
|
| 2 |
-
|
| 3 |
-
Compares:
|
| 4 |
-
- Baseline: no compression
|
| 5 |
-
- TwoTrim: conservative / balanced / aggressive
|
| 6 |
-
- Full pipeline (CacheAligner → TwoTrim → RollingWindow)
|
| 7 |
-
|
| 8 |
-
Measures:
|
| 9 |
-
- Token savings (absolute + percentage)
|
| 10 |
-
- Latency (apply time in ms)
|
| 11 |
-
- Sentence retention (fraction kept)
|
| 12 |
-
- Edge: does rolling window need fewer drops when TwoTrim runs first?
|
| 13 |
-
|
| 14 |
-
Run as pytest:
|
| 15 |
-
pytest benchmarks/bench_two_trim.py -v
|
| 16 |
-
|
| 17 |
-
Run standalone (prints summary table):
|
| 18 |
-
python benchmarks/bench_two_trim.py
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
from __future__ import annotations
|
| 22 |
-
|
| 23 |
-
import json
|
| 24 |
-
import random
|
| 25 |
-
import statistics
|
| 26 |
-
import sys
|
| 27 |
-
import time
|
| 28 |
-
from copy import deepcopy
|
| 29 |
-
from dataclasses import dataclass
|
| 30 |
-
|
| 31 |
-
import pytest
|
| 32 |
-
|
| 33 |
-
from headroom.config import HeadroomConfig, TwoTrimConfig
|
| 34 |
-
from headroom.tokenizer import Tokenizer
|
| 35 |
-
from headroom.transforms.pipeline import TransformPipeline
|
| 36 |
-
from headroom.transforms.two_trim import TwoTrim
|
| 37 |
-
|
| 38 |
-
random.seed(42)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
# ---------------------------------------------------------------------------
|
| 42 |
-
# Tokenizer / fixtures (real tiktoken for accurate measurement)
|
| 43 |
-
# ---------------------------------------------------------------------------
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def _build_real_tokenizer() -> tuple[Tokenizer, object]:
|
| 47 |
-
"""Build a Tokenizer backed by tiktoken (or a close fallback)."""
|
| 48 |
-
try:
|
| 49 |
-
from headroom.tokenizers import TiktokenCounter
|
| 50 |
-
|
| 51 |
-
counter = TiktokenCounter(model="gpt-4o")
|
| 52 |
-
except Exception:
|
| 53 |
-
from headroom.tokenizers import EstimatingTokenCounter
|
| 54 |
-
|
| 55 |
-
counter = EstimatingTokenCounter()
|
| 56 |
-
return Tokenizer(token_counter=counter, model="gpt-4o"), counter
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
real_tokenizer, _tok_counter = _build_real_tokenizer()
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
# Minimal provider stub that returns the real token counter for any model.
|
| 63 |
-
# Used wherever TransformPipeline needs a provider. Avoids re-instantiating
|
| 64 |
-
# the anonymous type at every callsite.
|
| 65 |
-
class _MockProvider:
|
| 66 |
-
def get_token_counter(self, model: str):
|
| 67 |
-
return _tok_counter
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
mock_provider = _MockProvider()
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
# ---------------------------------------------------------------------------
|
| 74 |
-
# Synthetic plain-text scenarios
|
| 75 |
-
# ---------------------------------------------------------------------------
|
| 76 |
-
|
| 77 |
-
def _filler_sentences(n: int, seed: int = 0) -> list[str]:
|
| 78 |
-
"""Return *n* plain-English sentences with controlled relevance variance.
|
| 79 |
-
|
| 80 |
-
Some sentences contain query-relevant keywords; the rest are filler.
|
| 81 |
-
This ensures BM25/semantic scoring has signal to work with.
|
| 82 |
-
"""
|
| 83 |
-
random.seed(seed)
|
| 84 |
-
relevant = [
|
| 85 |
-
"The database migration requires a rollback strategy before deployment.",
|
| 86 |
-
"Connection pool exhaustion is the root cause of the timeout errors.",
|
| 87 |
-
"Index optimization reduced query latency from 800ms to 45ms.",
|
| 88 |
-
"The primary key constraint was violated during the bulk insert operation.",
|
| 89 |
-
"Read replicas are falling behind the write primary by several seconds.",
|
| 90 |
-
"Deadlock detection triggered and rolled back transaction XZ-7291.",
|
| 91 |
-
"The backup verification confirmed all snapshots are restorable.",
|
| 92 |
-
"Memory pressure caused the OOM killer to terminate the worker process.",
|
| 93 |
-
"Thread pool saturation prevented new requests from being accepted.",
|
| 94 |
-
"The connection string rotated but the old credentials are still cached.",
|
| 95 |
-
]
|
| 96 |
-
filler = [
|
| 97 |
-
"The weather today is unusually mild for this time of year in the valley.",
|
| 98 |
-
"Many people enjoy walking in the park during autumn afternoons.",
|
| 99 |
-
"The annual report from 2023 shows a modest increase in global revenue.",
|
| 100 |
-
"Birds migrate south when the temperature starts to drop significantly.",
|
| 101 |
-
"Did you remember to renew your gym membership this month?",
|
| 102 |
-
"Tomorrow's forecast predicts scattered showers across the entire region.",
|
| 103 |
-
"The library closes early on Sundays for scheduled maintenance work.",
|
| 104 |
-
"Some researchers prefer quantitative methods over qualitative approaches.",
|
| 105 |
-
"Pizza is a popular dish that originated in Italy many centuries ago.",
|
| 106 |
-
"The committee meets every other Thursday to discuss new policy changes.",
|
| 107 |
-
"Quantum entanglement puzzled physicists for decades before being resolved.",
|
| 108 |
-
"Backups should run nightly to prevent catastrophic data loss incidents.",
|
| 109 |
-
"The new highway interchange opens next month after years of construction.",
|
| 110 |
-
"Reading books is one of the best ways to expand your vocabulary.",
|
| 111 |
-
"The coffee shop on the corner serves excellent pastries every morning.",
|
| 112 |
-
"Regular exercise has been shown to improve cardiovascular health greatly.",
|
| 113 |
-
"The stock market experienced a minor correction during last week's trading.",
|
| 114 |
-
"Learning a second language opens up many professional opportunities.",
|
| 115 |
-
"The local museum is hosting an exhibition on ancient Roman artifacts.",
|
| 116 |
-
"Volunteering at the shelter is a rewarding way to spend weekend mornings.",
|
| 117 |
-
]
|
| 118 |
-
result: list[str] = []
|
| 119 |
-
while len(result) < n:
|
| 120 |
-
pick = random.choice(relevant if random.random() < 0.25 else filler)
|
| 121 |
-
result.append(pick)
|
| 122 |
-
return result
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def _make_user_messages(
|
| 126 |
-
text_chunks: list[str],
|
| 127 |
-
system: str = "You are a database reliability engineer.",
|
| 128 |
-
query: str = "What is causing the database timeout errors?",
|
| 129 |
-
) -> list[dict]:
|
| 130 |
-
return [
|
| 131 |
-
{"role": "system", "content": system},
|
| 132 |
-
{"role": "user", "content": query},
|
| 133 |
-
*(
|
| 134 |
-
{"role": "user", "content": chunk}
|
| 135 |
-
for chunk in text_chunks
|
| 136 |
-
),
|
| 137 |
-
]
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def _count_tokens(msgs: list[dict]) -> int:
|
| 141 |
-
return real_tokenizer.count_messages(msgs)
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
# ---------------------------------------------------------------------------
|
| 145 |
-
# Scenarios (name → (messages, description))
|
| 146 |
-
# ---------------------------------------------------------------------------
|
| 147 |
-
|
| 148 |
-
SCENARIOS: list[tuple[str, str, list[dict]]] = [
|
| 149 |
-
(
|
| 150 |
-
"short-plain",
|
| 151 |
-
"~2K tokens single user message (50 sentences)",
|
| 152 |
-
[
|
| 153 |
-
{"role": "system", "content": "You are a database reliability engineer."},
|
| 154 |
-
{"role": "user", "content": "What is causing the database timeout errors?"},
|
| 155 |
-
{"role": "user", "content": " ".join(_filler_sentences(50, seed=10))},
|
| 156 |
-
],
|
| 157 |
-
),
|
| 158 |
-
(
|
| 159 |
-
"medium-plain",
|
| 160 |
-
"~5K tokens single user message (150 sentences)",
|
| 161 |
-
[
|
| 162 |
-
{"role": "system", "content": "You are a database reliability engineer."},
|
| 163 |
-
{"role": "user", "content": "What is causing the database timeout errors?"},
|
| 164 |
-
{"role": "user", "content": " ".join(_filler_sentences(150, seed=20))},
|
| 165 |
-
],
|
| 166 |
-
),
|
| 167 |
-
(
|
| 168 |
-
"long-plain",
|
| 169 |
-
"~15K tokens single user message (450 sentences)",
|
| 170 |
-
[
|
| 171 |
-
{"role": "system", "content": "You are a database reliability engineer."},
|
| 172 |
-
{"role": "user", "content": "What is causing the database timeout errors?"},
|
| 173 |
-
{"role": "user", "content": " ".join(_filler_sentences(450, seed=30))},
|
| 174 |
-
],
|
| 175 |
-
),
|
| 176 |
-
(
|
| 177 |
-
"long-system-heavy",
|
| 178 |
-
"Long system prompt + 150-sentence user message",
|
| 179 |
-
[
|
| 180 |
-
{
|
| 181 |
-
"role": "system",
|
| 182 |
-
"content": (
|
| 183 |
-
"You are an expert database reliability engineer with 20 years "
|
| 184 |
-
"of experience managing high-traffic PostgreSQL clusters at scale. "
|
| 185 |
-
"You specialize in performance tuning, failover strategies, backup "
|
| 186 |
-
"planning, and disaster recovery. Always provide actionable advice. "
|
| 187 |
-
"Reference specific configuration parameters when possible. "
|
| 188 |
-
"Include rollback steps when recommending changes. "
|
| 189 |
-
"Flag risks before suggesting production modifications."
|
| 190 |
-
),
|
| 191 |
-
},
|
| 192 |
-
{"role": "user", "content": "What is causing the database timeout errors?"},
|
| 193 |
-
{"role": "user", "content": " ".join(_filler_sentences(150, seed=40))},
|
| 194 |
-
],
|
| 195 |
-
),
|
| 196 |
-
(
|
| 197 |
-
"multi-turn",
|
| 198 |
-
"3-turn conversation with interleaved large user messages",
|
| 199 |
-
[
|
| 200 |
-
{"role": "system", "content": "You are a helpful SRE."},
|
| 201 |
-
{"role": "user", "content": "Check the database health."},
|
| 202 |
-
{"role": "assistant", "content": "I'll check the connection pool status now."},
|
| 203 |
-
{"role": "user", "content": " ".join(_filler_sentences(80, seed=50))},
|
| 204 |
-
{"role": "assistant", "content": "The connection pool shows 45 active connections."},
|
| 205 |
-
{"role": "user", "content": " ".join(_filler_sentences(80, seed=60))},
|
| 206 |
-
],
|
| 207 |
-
),
|
| 208 |
-
(
|
| 209 |
-
"many-small-messages",
|
| 210 |
-
"30 small user messages (floor-effect stress test)",
|
| 211 |
-
[
|
| 212 |
-
{"role": "system", "content": "You are a helpful assistant."},
|
| 213 |
-
{"role": "user", "content": "What is causing the database timeout errors?"},
|
| 214 |
-
*(
|
| 215 |
-
{"role": "user", "content": s}
|
| 216 |
-
for s in _filler_sentences(30, seed=80)
|
| 217 |
-
),
|
| 218 |
-
],
|
| 219 |
-
),
|
| 220 |
-
(
|
| 221 |
-
"json-mixed",
|
| 222 |
-
"User text mixed with JSON blocks (should skip JSON)",
|
| 223 |
-
[
|
| 224 |
-
{"role": "system", "content": "You are a helpful assistant."},
|
| 225 |
-
{
|
| 226 |
-
"role": "user",
|
| 227 |
-
"content": (
|
| 228 |
-
"Here is the analysis:\n```json\n"
|
| 229 |
-
+ json.dumps(
|
| 230 |
-
[
|
| 231 |
-
{"id": i, "metric": round(i * 0.01, 2)}
|
| 232 |
-
for i in range(100)
|
| 233 |
-
]
|
| 234 |
-
)
|
| 235 |
-
+ "\n```\n"
|
| 236 |
-
+ " ".join(_filler_sentences(100, seed=70))
|
| 237 |
-
),
|
| 238 |
-
},
|
| 239 |
-
],
|
| 240 |
-
),
|
| 241 |
-
]
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
# ---------------------------------------------------------------------------
|
| 245 |
-
# Benchmark runner (standalone mode)
|
| 246 |
-
# ---------------------------------------------------------------------------
|
| 247 |
-
|
| 248 |
-
@dataclass
|
| 249 |
-
class RunResult:
|
| 250 |
-
name: str
|
| 251 |
-
tokens_before: int
|
| 252 |
-
tokens_after: int
|
| 253 |
-
tokens_saved: int
|
| 254 |
-
savings_pct: float
|
| 255 |
-
latency_ms: float
|
| 256 |
-
transforms_applied: list[str]
|
| 257 |
-
rolling_drops: int = 0 # messages dropped by RollingWindow (parsed from window_cap:N, pipeline mode only)
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
def _parse_window_cap_drops(transform_str: str) -> int:
|
| 261 |
-
"""Extract drop count from a 'window_cap:N' transform entry."""
|
| 262 |
-
if transform_str.startswith("window_cap:"):
|
| 263 |
-
try:
|
| 264 |
-
return int(transform_str.split(":", 1)[1])
|
| 265 |
-
except ValueError:
|
| 266 |
-
return 0
|
| 267 |
-
return 0
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
def _run_two_trim(
|
| 271 |
-
messages: list[dict],
|
| 272 |
-
mode: str,
|
| 273 |
-
*,
|
| 274 |
-
runs: int = 5,
|
| 275 |
-
) -> RunResult:
|
| 276 |
-
if runs < 1:
|
| 277 |
-
raise ValueError(f"runs must be >= 1, got {runs}")
|
| 278 |
-
cfg = TwoTrimConfig(
|
| 279 |
-
enabled=True,
|
| 280 |
-
mode=mode, # type: ignore[arg-type]
|
| 281 |
-
min_tokens_to_compress=50,
|
| 282 |
-
use_semantic_scoring=False,
|
| 283 |
-
)
|
| 284 |
-
t = TwoTrim(cfg)
|
| 285 |
-
baseline_tokens = _count_tokens(messages)
|
| 286 |
-
|
| 287 |
-
latencies: list[float] = []
|
| 288 |
-
last_result = None
|
| 289 |
-
for _ in range(runs):
|
| 290 |
-
t0 = time.perf_counter()
|
| 291 |
-
last_result = t.apply(deepcopy(messages), real_tokenizer)
|
| 292 |
-
latencies.append((time.perf_counter() - t0) * 1000)
|
| 293 |
-
|
| 294 |
-
avg_tokens_after = last_result.tokens_after # type: ignore[union-attr]
|
| 295 |
-
return RunResult(
|
| 296 |
-
name=f"two_trim:{mode}",
|
| 297 |
-
tokens_before=baseline_tokens,
|
| 298 |
-
tokens_after=avg_tokens_after,
|
| 299 |
-
tokens_saved=max(0, baseline_tokens - avg_tokens_after),
|
| 300 |
-
savings_pct=(
|
| 301 |
-
(baseline_tokens - avg_tokens_after) / baseline_tokens * 100
|
| 302 |
-
if baseline_tokens > 0
|
| 303 |
-
else 0.0
|
| 304 |
-
),
|
| 305 |
-
latency_ms=statistics.mean(latencies),
|
| 306 |
-
transforms_applied=last_result.transforms_applied, # type: ignore[union-attr]
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
def _run_pipeline_without_two_trim(
|
| 311 |
-
messages: list[dict],
|
| 312 |
-
*,
|
| 313 |
-
runs: int = 5,
|
| 314 |
-
) -> RunResult:
|
| 315 |
-
"""Run the pipeline with TwoTrim *disabled* (baseline pipeline)."""
|
| 316 |
-
if runs < 1:
|
| 317 |
-
raise ValueError(f"runs must be >= 1, got {runs}")
|
| 318 |
-
cfg = HeadroomConfig()
|
| 319 |
-
cfg.two_trim.enabled = False
|
| 320 |
-
|
| 321 |
-
pipeline = TransformPipeline(config=cfg, provider=mock_provider) # type: ignore[arg-type]
|
| 322 |
-
baseline_tokens = _count_tokens(messages)
|
| 323 |
-
|
| 324 |
-
model_limit = max(baseline_tokens - 100, 1000)
|
| 325 |
-
|
| 326 |
-
latencies: list[float] = []
|
| 327 |
-
last_result = None
|
| 328 |
-
for _ in range(runs):
|
| 329 |
-
t0 = time.perf_counter()
|
| 330 |
-
last_result = pipeline.apply(
|
| 331 |
-
deepcopy(messages), "gpt-4o", model_limit=model_limit, output_buffer=500,
|
| 332 |
-
)
|
| 333 |
-
latencies.append((time.perf_counter() - t0) * 1000)
|
| 334 |
-
|
| 335 |
-
tokens_after = last_result.tokens_after # type: ignore[union-attr]
|
| 336 |
-
rw_drops = sum(_parse_window_cap_drops(t) for t in last_result.transforms_applied) # type: ignore[union-attr]
|
| 337 |
-
return RunResult(
|
| 338 |
-
name="pipeline (no TwoTrim)",
|
| 339 |
-
tokens_before=baseline_tokens,
|
| 340 |
-
tokens_after=tokens_after,
|
| 341 |
-
tokens_saved=max(0, baseline_tokens - tokens_after),
|
| 342 |
-
savings_pct=(
|
| 343 |
-
(baseline_tokens - tokens_after) / baseline_tokens * 100
|
| 344 |
-
if baseline_tokens > 0
|
| 345 |
-
else 0.0
|
| 346 |
-
),
|
| 347 |
-
latency_ms=statistics.mean(latencies),
|
| 348 |
-
transforms_applied=last_result.transforms_applied, # type: ignore[union-attr]
|
| 349 |
-
rolling_drops=rw_drops,
|
| 350 |
-
)
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
def _run_pipeline_with_two_trim(
|
| 354 |
-
messages: list[dict],
|
| 355 |
-
mode: str,
|
| 356 |
-
*,
|
| 357 |
-
runs: int = 5,
|
| 358 |
-
) -> RunResult:
|
| 359 |
-
if runs < 1:
|
| 360 |
-
raise ValueError(f"runs must be >= 1, got {runs}")
|
| 361 |
-
cfg = HeadroomConfig()
|
| 362 |
-
cfg.two_trim.enabled = True
|
| 363 |
-
cfg.two_trim.mode = mode # type: ignore[assignment]
|
| 364 |
-
cfg.two_trim.min_tokens_to_compress = 50
|
| 365 |
-
cfg.two_trim.use_semantic_scoring = False
|
| 366 |
-
|
| 367 |
-
pipeline = TransformPipeline(config=cfg, provider=mock_provider) # type: ignore[arg-type]
|
| 368 |
-
baseline_tokens = _count_tokens(messages)
|
| 369 |
-
|
| 370 |
-
# Force RollingWindow to drop to measure interaction
|
| 371 |
-
# Use a model_limit that's tight enough for RW to activate
|
| 372 |
-
model_limit = max(baseline_tokens - 100, 1000)
|
| 373 |
-
|
| 374 |
-
latencies: list[float] = []
|
| 375 |
-
last_result = None
|
| 376 |
-
for _ in range(runs):
|
| 377 |
-
t0 = time.perf_counter()
|
| 378 |
-
last_result = pipeline.apply(
|
| 379 |
-
deepcopy(messages), "gpt-4o", model_limit=model_limit, output_buffer=500,
|
| 380 |
-
)
|
| 381 |
-
latencies.append((time.perf_counter() - t0) * 1000)
|
| 382 |
-
|
| 383 |
-
tokens_after = last_result.tokens_after # type: ignore[union-attr]
|
| 384 |
-
# Check if RollingWindow ran
|
| 385 |
-
rw_drops = sum(_parse_window_cap_drops(t) for t in last_result.transforms_applied) # type: ignore[union-attr]
|
| 386 |
-
return RunResult(
|
| 387 |
-
name=f"pipeline({mode})",
|
| 388 |
-
tokens_before=baseline_tokens,
|
| 389 |
-
tokens_after=tokens_after,
|
| 390 |
-
tokens_saved=max(0, baseline_tokens - tokens_after),
|
| 391 |
-
savings_pct=(
|
| 392 |
-
(baseline_tokens - tokens_after) / baseline_tokens * 100
|
| 393 |
-
if baseline_tokens > 0
|
| 394 |
-
else 0.0
|
| 395 |
-
),
|
| 396 |
-
latency_ms=statistics.mean(latencies),
|
| 397 |
-
transforms_applied=last_result.transforms_applied, # type: ignore[union-attr]
|
| 398 |
-
rolling_drops=rw_drops,
|
| 399 |
-
)
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
def run_all() -> list[tuple[str, list[RunResult]]]:
|
| 403 |
-
"""Run all benchmarks, return scenario → results."""
|
| 404 |
-
all_results: list[tuple[str, list[RunResult]]] = []
|
| 405 |
-
modes = ["conservative", "balanced", "aggressive"]
|
| 406 |
-
|
| 407 |
-
for scenario_name, description, messages in SCENARIOS:
|
| 408 |
-
baseline_tokens = _count_tokens(messages)
|
| 409 |
-
results: list[RunResult] = [
|
| 410 |
-
RunResult(
|
| 411 |
-
name="baseline (no compression)",
|
| 412 |
-
tokens_before=baseline_tokens,
|
| 413 |
-
tokens_after=baseline_tokens,
|
| 414 |
-
tokens_saved=0,
|
| 415 |
-
savings_pct=0.0,
|
| 416 |
-
latency_ms=0.0,
|
| 417 |
-
transforms_applied=[],
|
| 418 |
-
)
|
| 419 |
-
]
|
| 420 |
-
for mode in modes:
|
| 421 |
-
results.append(_run_two_trim(messages, mode))
|
| 422 |
-
results.append(_run_pipeline_without_two_trim(messages))
|
| 423 |
-
results.append(_run_pipeline_with_two_trim(messages, "balanced"))
|
| 424 |
-
all_results.append((f"{scenario_name}: {description}", results))
|
| 425 |
-
|
| 426 |
-
return all_results
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
def print_table(all_results: list[tuple[str, list[RunResult]]]) -> None:
|
| 430 |
-
"""Pretty-print results as a table."""
|
| 431 |
-
hdr = (
|
| 432 |
-
f"{'Scenario':<42} {'Approach':<28} {'Before':>8} {'After':>8} "
|
| 433 |
-
f"{'Saved':>8} {'%':>7} {'lat ms':>8} {'transforms'}"
|
| 434 |
-
)
|
| 435 |
-
sep = "-" * len(hdr)
|
| 436 |
-
print("\n" + sep)
|
| 437 |
-
print(" TWO-TRIM BENCHMARK RESULTS")
|
| 438 |
-
print(sep)
|
| 439 |
-
print(hdr)
|
| 440 |
-
print(sep)
|
| 441 |
-
|
| 442 |
-
for scenario_label, results in all_results:
|
| 443 |
-
first = True
|
| 444 |
-
for r in results:
|
| 445 |
-
scenario_col = scenario_label if first else ""
|
| 446 |
-
first = False
|
| 447 |
-
# Collapse consecutive duplicates to "name xN" form
|
| 448 |
-
compact: list[str] = []
|
| 449 |
-
i = 0
|
| 450 |
-
run = 1
|
| 451 |
-
transforms_applied = r.transforms_applied
|
| 452 |
-
while i < len(transforms_applied):
|
| 453 |
-
if i + 1 < len(transforms_applied) and transforms_applied[i + 1] == transforms_applied[i]:
|
| 454 |
-
run += 1
|
| 455 |
-
i += 1
|
| 456 |
-
continue
|
| 457 |
-
compact.append(f"{transforms_applied[i]} x{run}" if run > 1 else transforms_applied[i])
|
| 458 |
-
run = 1
|
| 459 |
-
i += 1
|
| 460 |
-
transforms = ", ".join(compact) if compact else "-"
|
| 461 |
-
if len(transforms) > 60:
|
| 462 |
-
transforms = transforms[:57] + "..."
|
| 463 |
-
print(
|
| 464 |
-
f"{scenario_col:<42} {r.name:<28} {r.tokens_before:>8,} "
|
| 465 |
-
f"{r.tokens_after:>8,} {r.tokens_saved:>8,} {r.savings_pct:>6.1f}% "
|
| 466 |
-
f"{r.latency_ms:>7.1f} {transforms}"
|
| 467 |
-
)
|
| 468 |
-
print(sep)
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
# ---------------------------------------------------------------------------
|
| 472 |
-
# pytest API
|
| 473 |
-
# ---------------------------------------------------------------------------
|
| 474 |
-
|
| 475 |
-
class TestTwoTrimBenchmarks:
|
| 476 |
-
"""Benchmark TwoTrim compression across scenarios (pytest-benchmark)."""
|
| 477 |
-
|
| 478 |
-
def test_short_plain_baseline(self, benchmark):
|
| 479 |
-
msgs = SCENARIOS[0][2]
|
| 480 |
-
t = TwoTrim(TwoTrimConfig(enabled=False))
|
| 481 |
-
benchmark(t.apply, deepcopy(msgs), real_tokenizer)
|
| 482 |
-
|
| 483 |
-
@pytest.mark.parametrize("mode", ["conservative", "balanced", "aggressive"])
|
| 484 |
-
def test_short_plain_two_trim(self, benchmark, mode: str):
|
| 485 |
-
msgs = SCENARIOS[0][2]
|
| 486 |
-
cfg = TwoTrimConfig(
|
| 487 |
-
enabled=True,
|
| 488 |
-
mode=mode, # type: ignore[arg-type]
|
| 489 |
-
min_tokens_to_compress=50,
|
| 490 |
-
use_semantic_scoring=False,
|
| 491 |
-
)
|
| 492 |
-
t = TwoTrim(cfg)
|
| 493 |
-
result = benchmark(t.apply, deepcopy(msgs), real_tokenizer)
|
| 494 |
-
assert result.tokens_saved > 0
|
| 495 |
-
|
| 496 |
-
@pytest.mark.parametrize("mode", ["conservative", "balanced", "aggressive"])
|
| 497 |
-
def test_long_plain_two_trim(self, benchmark, mode: str):
|
| 498 |
-
msgs = SCENARIOS[2][2]
|
| 499 |
-
cfg = TwoTrimConfig(
|
| 500 |
-
enabled=True,
|
| 501 |
-
mode=mode, # type: ignore[arg-type]
|
| 502 |
-
min_tokens_to_compress=50,
|
| 503 |
-
use_semantic_scoring=False,
|
| 504 |
-
)
|
| 505 |
-
t = TwoTrim(cfg)
|
| 506 |
-
result = benchmark(t.apply, deepcopy(msgs), real_tokenizer)
|
| 507 |
-
assert result.tokens_saved > 0
|
| 508 |
-
|
| 509 |
-
def test_long_system_heavy_two_trim(self, benchmark):
|
| 510 |
-
msgs = SCENARIOS[3][2]
|
| 511 |
-
cfg = TwoTrimConfig(
|
| 512 |
-
enabled=True,
|
| 513 |
-
mode="balanced",
|
| 514 |
-
min_tokens_to_compress=50,
|
| 515 |
-
use_semantic_scoring=False,
|
| 516 |
-
)
|
| 517 |
-
t = TwoTrim(cfg)
|
| 518 |
-
result = benchmark(t.apply, deepcopy(msgs), real_tokenizer)
|
| 519 |
-
assert result.tokens_saved > 0
|
| 520 |
-
|
| 521 |
-
def test_json_mixed_skips_json(self, benchmark):
|
| 522 |
-
msgs = SCENARIOS[6][2]
|
| 523 |
-
cfg = TwoTrimConfig(
|
| 524 |
-
enabled=True,
|
| 525 |
-
mode="aggressive",
|
| 526 |
-
min_tokens_to_compress=50,
|
| 527 |
-
use_semantic_scoring=False,
|
| 528 |
-
)
|
| 529 |
-
t = TwoTrim(cfg)
|
| 530 |
-
baseline = _count_tokens(msgs)
|
| 531 |
-
result = benchmark(t.apply, deepcopy(msgs), real_tokenizer)
|
| 532 |
-
# JSON portion should not be compressed — savings < 50% of baseline
|
| 533 |
-
# (the JSON block is large and skipped entirely)
|
| 534 |
-
assert result.tokens_saved < baseline * 0.5
|
| 535 |
-
|
| 536 |
-
def test_pipeline_with_two_trim(self, benchmark, long_conversation_messages):
|
| 537 |
-
msgs = long_conversation_messages
|
| 538 |
-
cfg = HeadroomConfig()
|
| 539 |
-
cfg.two_trim.enabled = True
|
| 540 |
-
cfg.two_trim.mode = "balanced" # type: ignore[assignment]
|
| 541 |
-
cfg.two_trim.min_tokens_to_compress = 50
|
| 542 |
-
cfg.two_trim.use_semantic_scoring = False
|
| 543 |
-
|
| 544 |
-
pipeline = TransformPipeline(config=cfg, provider=mock_provider) # type: ignore[arg-type]
|
| 545 |
-
baseline = _count_tokens(msgs)
|
| 546 |
-
|
| 547 |
-
result = benchmark(
|
| 548 |
-
pipeline.apply,
|
| 549 |
-
deepcopy(msgs),
|
| 550 |
-
"gpt-4o",
|
| 551 |
-
model_limit=100_000,
|
| 552 |
-
output_buffer=2000,
|
| 553 |
-
)
|
| 554 |
-
assert result.tokens_before == baseline
|
| 555 |
-
assert result.tokens_after < result.tokens_before
|
| 556 |
-
savings_pct = (baseline - result.tokens_after) / baseline * 100
|
| 557 |
-
assert savings_pct >= 10.0, f"expected >=10% savings, got {savings_pct:.1f}%"
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
# ---------------------------------------------------------------------------
|
| 561 |
-
# Standalone entry point
|
| 562 |
-
# ---------------------------------------------------------------------------
|
| 563 |
-
|
| 564 |
-
if __name__ == "__main__":
|
| 565 |
-
print("Running TwoTrim benchmarks (standalone mode)...")
|
| 566 |
-
results = run_all()
|
| 567 |
-
print_table(results)
|
| 568 |
-
print(
|
| 569 |
-
"\nFor more accurate latency numbers, run via pytest-benchmark:\n"
|
| 570 |
-
" pytest benchmarks/bench_two_trim.py -v"
|
| 571 |
-
)
|
| 572 |
-
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
headroom/transforms/two_trim.py
DELETED
|
@@ -1,439 +0,0 @@
|
|
| 1 |
-
"""TwoTrim extractive compression transform.
|
| 2 |
-
|
| 3 |
-
LongLLMLingua-style text compression:
|
| 4 |
-
1. Split eligible message text into sentences
|
| 5 |
-
2. Score each sentence against the conversational query (semantic or BM25)
|
| 6 |
-
3. Drop low-scoring filler (caps tied to mode + safety floors)
|
| 7 |
-
4. Optionally reorder survivors so the highest-scoring sit at the prompt
|
| 8 |
-
start and end ("lost-in-the-middle" mitigation)
|
| 9 |
-
|
| 10 |
-
Complements SmartCrusher/ContentRouter — runs on plain-text user/system
|
| 11 |
-
messages only. Structured content (JSON, code blocks) is skipped so the
|
| 12 |
-
dedicated compressors handle it upstream.
|
| 13 |
-
|
| 14 |
-
Reference: https://github.com/overseek944/twotrim
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
from __future__ import annotations
|
| 18 |
-
|
| 19 |
-
import logging
|
| 20 |
-
import re
|
| 21 |
-
import time
|
| 22 |
-
from functools import lru_cache
|
| 23 |
-
from typing import Any
|
| 24 |
-
|
| 25 |
-
from ..config import TransformResult, TwoTrimConfig
|
| 26 |
-
from ..tokenizer import Tokenizer
|
| 27 |
-
from ..utils import deep_copy_messages
|
| 28 |
-
from .base import Transform
|
| 29 |
-
from .observability import current_request_segments
|
| 30 |
-
|
| 31 |
-
logger = logging.getLogger(__name__)
|
| 32 |
-
|
| 33 |
-
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\"'\(])")
|
| 34 |
-
_CODE_FENCE_RE = re.compile(r"```")
|
| 35 |
-
_JSON_HINT_RE = re.compile(r"^\s*[\{\[]", re.MULTILINE)
|
| 36 |
-
|
| 37 |
-
# Mode presets — target sentence keep ratio (fraction of sentences retained).
|
| 38 |
-
# The floor (config.min_sentence_keep_ratio) is always respected.
|
| 39 |
-
_MODE_KEEP_RATIO = {
|
| 40 |
-
"conservative": 0.80,
|
| 41 |
-
"balanced": 0.60,
|
| 42 |
-
"aggressive": 0.35,
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
@lru_cache(maxsize=4)
|
| 47 |
-
def _load_sentence_model(model_name: str) -> Any:
|
| 48 |
-
"""Lazy-load sentence-transformers model (~80MB on first call).
|
| 49 |
-
|
| 50 |
-
Cached so repeated pipeline invocations share the same model. Returns
|
| 51 |
-
None if sentence-transformers not installed — caller falls back to BM25.
|
| 52 |
-
"""
|
| 53 |
-
try:
|
| 54 |
-
from sentence_transformers import SentenceTransformer # type: ignore[import-not-found]
|
| 55 |
-
except ImportError:
|
| 56 |
-
logger.warning(
|
| 57 |
-
"sentence-transformers not installed — TwoTrim falling back to BM25. "
|
| 58 |
-
"Install with: pip install 'headroom[relevance]'"
|
| 59 |
-
)
|
| 60 |
-
return None
|
| 61 |
-
try:
|
| 62 |
-
return SentenceTransformer(model_name)
|
| 63 |
-
except Exception as exc:
|
| 64 |
-
logger.warning("TwoTrim failed to load %s: %s — using BM25", model_name, exc)
|
| 65 |
-
return None
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def _split_sentences(text: str) -> list[str]:
|
| 69 |
-
"""Split text into sentences. Conservative regex — no NLP dep.
|
| 70 |
-
|
| 71 |
-
Preserves sentence-internal punctuation. Empty/whitespace-only results
|
| 72 |
-
are filtered out.
|
| 73 |
-
"""
|
| 74 |
-
if not text or not text.strip():
|
| 75 |
-
return []
|
| 76 |
-
parts = _SENTENCE_SPLIT_RE.split(text)
|
| 77 |
-
return [p.strip() for p in parts if p and p.strip()]
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def _looks_structured(text: str) -> bool:
|
| 81 |
-
"""True if content looks like JSON, array, or contains code fences."""
|
| 82 |
-
if not text:
|
| 83 |
-
return False
|
| 84 |
-
stripped = text.lstrip()
|
| 85 |
-
if stripped and stripped[0] in "[{":
|
| 86 |
-
return True
|
| 87 |
-
if _CODE_FENCE_RE.search(text):
|
| 88 |
-
return True
|
| 89 |
-
# Heuristic: many lines starting with JSON-ish chars
|
| 90 |
-
matches = _JSON_HINT_RE.findall(text)
|
| 91 |
-
if len(matches) >= 3:
|
| 92 |
-
return True
|
| 93 |
-
return False
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def _extract_query(messages: list[dict[str, Any]]) -> str:
|
| 97 |
-
"""Pull a scoring query from the conversation — last user message wins.
|
| 98 |
-
|
| 99 |
-
Falls back to concatenation of all user messages if only system prompts
|
| 100 |
-
are present. Returns empty string if no usable query (all-assistant
|
| 101 |
-
conversation — should not happen in practice).
|
| 102 |
-
"""
|
| 103 |
-
last_user = ""
|
| 104 |
-
all_user = []
|
| 105 |
-
for msg in messages:
|
| 106 |
-
if msg.get("role") != "user":
|
| 107 |
-
continue
|
| 108 |
-
content = msg.get("content")
|
| 109 |
-
if isinstance(content, str):
|
| 110 |
-
last_user = content
|
| 111 |
-
all_user.append(content)
|
| 112 |
-
elif isinstance(content, list):
|
| 113 |
-
# OpenAI-style multimodal — concatenate text blocks
|
| 114 |
-
parts = [
|
| 115 |
-
b.get("text", "")
|
| 116 |
-
for b in content
|
| 117 |
-
if isinstance(b, dict) and b.get("type") in ("text", "input_text")
|
| 118 |
-
]
|
| 119 |
-
joined = " ".join(p for p in parts if p)
|
| 120 |
-
if joined:
|
| 121 |
-
last_user = joined
|
| 122 |
-
all_user.append(joined)
|
| 123 |
-
if last_user:
|
| 124 |
-
return last_user
|
| 125 |
-
return " ".join(all_user) if all_user else ""
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
def _score_bm25(sentences: list[str], query: str) -> list[float]:
|
| 129 |
-
"""Lightweight BM25-ish score using existing headroom BM25Scorer."""
|
| 130 |
-
if not sentences or not query.strip():
|
| 131 |
-
return [1.0] * len(sentences)
|
| 132 |
-
try:
|
| 133 |
-
from ..relevance.bm25 import BM25Scorer
|
| 134 |
-
|
| 135 |
-
scorer = BM25Scorer()
|
| 136 |
-
scores = scorer.score_batch(sentences, query)
|
| 137 |
-
return [s.score for s in scores]
|
| 138 |
-
except Exception as exc:
|
| 139 |
-
logger.warning("TwoTrim BM25 scoring failed: %s — using word overlap fallback", exc)
|
| 140 |
-
# Last-resort fallback: word overlap count
|
| 141 |
-
query_tokens = set(query.lower().split())
|
| 142 |
-
return [
|
| 143 |
-
float(sum(1 for w in s.lower().split() if w in query_tokens))
|
| 144 |
-
for s in sentences
|
| 145 |
-
]
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def _score_semantic(
|
| 149 |
-
sentences: list[str], query: str, model_name: str
|
| 150 |
-
) -> tuple[list[float], bool]:
|
| 151 |
-
"""Score via cosine similarity of sentence embeddings to query embedding.
|
| 152 |
-
|
| 153 |
-
Returns (scores, used_semantic). If model unavailable, caller falls back.
|
| 154 |
-
"""
|
| 155 |
-
model = _load_sentence_model(model_name)
|
| 156 |
-
if model is None or not sentences:
|
| 157 |
-
return [], False
|
| 158 |
-
try:
|
| 159 |
-
import numpy as np # type: ignore[import-not-found]
|
| 160 |
-
|
| 161 |
-
all_vecs = model.encode([query] + sentences, normalize_embeddings=True)
|
| 162 |
-
query_vec = np.asarray(all_vecs[0])
|
| 163 |
-
sent_vecs = np.asarray(all_vecs[1:])
|
| 164 |
-
# cosine sim (already L2-normalized)
|
| 165 |
-
sims = (sent_vecs @ query_vec).tolist()
|
| 166 |
-
return sims, True
|
| 167 |
-
except Exception as exc:
|
| 168 |
-
logger.warning("TwoTrim semantic scoring failed: %s — using BM25", exc)
|
| 169 |
-
return [], False
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
def _select_and_reorder(
|
| 173 |
-
sentences: list[str],
|
| 174 |
-
scores: list[float],
|
| 175 |
-
keep_ratio: float,
|
| 176 |
-
min_keep_ratio: float,
|
| 177 |
-
max_drop_ratio: float,
|
| 178 |
-
reorder: bool,
|
| 179 |
-
) -> list[str]:
|
| 180 |
-
"""Select top sentences by score, optionally reorder for attention.
|
| 181 |
-
|
| 182 |
-
Guarantees:
|
| 183 |
-
- Always keep at least ceil(N * min_keep_ratio) sentences
|
| 184 |
-
- Never drop more than floor(N * max_drop_ratio) sentences
|
| 185 |
-
"""
|
| 186 |
-
n = len(sentences)
|
| 187 |
-
if n <= 1:
|
| 188 |
-
return sentences
|
| 189 |
-
|
| 190 |
-
# Target count to keep
|
| 191 |
-
target_keep = max(1, round(n * keep_ratio))
|
| 192 |
-
floor_keep = max(1, int(n * min_keep_ratio + 0.999)) # ceil
|
| 193 |
-
max_droppable = int(n * max_drop_ratio)
|
| 194 |
-
min_keep_from_cap = n - max_droppable
|
| 195 |
-
|
| 196 |
-
keep_count = max(target_keep, floor_keep, min_keep_from_cap)
|
| 197 |
-
keep_count = min(keep_count, n)
|
| 198 |
-
|
| 199 |
-
if keep_count >= n:
|
| 200 |
-
return sentences
|
| 201 |
-
|
| 202 |
-
# Rank by score (stable — ties keep original order)
|
| 203 |
-
ranked = sorted(
|
| 204 |
-
enumerate(scores), key=lambda iv: (-iv[1], iv[0])
|
| 205 |
-
)
|
| 206 |
-
keep_indices = {i for i, _ in ranked[:keep_count]}
|
| 207 |
-
|
| 208 |
-
# Preserve original ordering (don't shuffle) unless reorder=True
|
| 209 |
-
kept = [s for i, s in enumerate(sentences) if i in keep_indices]
|
| 210 |
-
|
| 211 |
-
if not reorder or len(kept) < 3:
|
| 212 |
-
return kept
|
| 213 |
-
|
| 214 |
-
# Lost-in-the-middle reorder: place highest-scoring at prompt edges
|
| 215 |
-
# (position 0 and position -1), where LLM attention is strongest.
|
| 216 |
-
kept_with_scores = [
|
| 217 |
-
(s, scores[i]) for i, s in enumerate(sentences) if i in keep_indices
|
| 218 |
-
]
|
| 219 |
-
# Sort by score desc (stable on ties via original index order)
|
| 220 |
-
kept_with_scores.sort(key=lambda sv: (-sv[1], 0))
|
| 221 |
-
|
| 222 |
-
# Interleave from ranked list: #1 → pos 0, #2 → pos -1, #3 → pos 1, #4 → pos -2, ...
|
| 223 |
-
n_kept = len(kept_with_scores)
|
| 224 |
-
reordered: list[str | None] = [None] * n_kept
|
| 225 |
-
left, right = 0, n_kept - 1
|
| 226 |
-
for i, (sent, _) in enumerate(kept_with_scores):
|
| 227 |
-
if i % 2 == 0:
|
| 228 |
-
reordered[left] = sent
|
| 229 |
-
left += 1
|
| 230 |
-
else:
|
| 231 |
-
reordered[right] = sent
|
| 232 |
-
right -= 1
|
| 233 |
-
return [s for s in reordered if s is not None]
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
class TwoTrim(Transform):
|
| 237 |
-
"""Extractive text compression via semantic scoring + attention reordering.
|
| 238 |
-
|
| 239 |
-
See module docstring for algorithm overview.
|
| 240 |
-
"""
|
| 241 |
-
|
| 242 |
-
name = "two_trim"
|
| 243 |
-
|
| 244 |
-
def __init__(self, config: TwoTrimConfig | None = None):
|
| 245 |
-
self.config = config or TwoTrimConfig()
|
| 246 |
-
|
| 247 |
-
def should_apply(
|
| 248 |
-
self,
|
| 249 |
-
messages: list[dict[str, Any]],
|
| 250 |
-
tokenizer: Tokenizer,
|
| 251 |
-
**kwargs: Any,
|
| 252 |
-
) -> bool:
|
| 253 |
-
if not self.config.enabled:
|
| 254 |
-
return False
|
| 255 |
-
# Need at least one target-role message with text
|
| 256 |
-
for msg in messages:
|
| 257 |
-
if msg.get("role") not in self.config.target_roles:
|
| 258 |
-
continue
|
| 259 |
-
text = self._msg_text(msg)
|
| 260 |
-
# Approximate tokens as words * 1.3 (same ratio as _compress_text
|
| 261 |
-
# uses) so the pre-filter cannot admit messages the compress step
|
| 262 |
-
# would immediately bail on.
|
| 263 |
-
if text and len(text.split()) * 1.3 >= self.config.min_tokens_to_compress:
|
| 264 |
-
return True
|
| 265 |
-
return False
|
| 266 |
-
|
| 267 |
-
def apply(
|
| 268 |
-
self,
|
| 269 |
-
messages: list[dict[str, Any]],
|
| 270 |
-
tokenizer: Tokenizer,
|
| 271 |
-
**kwargs: Any,
|
| 272 |
-
) -> TransformResult:
|
| 273 |
-
t0 = time.perf_counter()
|
| 274 |
-
tokens_before = tokenizer.count_messages(messages)
|
| 275 |
-
|
| 276 |
-
if not self.config.enabled:
|
| 277 |
-
# Direct-call safety: pipeline checks should_apply first, but users
|
| 278 |
-
# may call apply() directly. Respect the disabled flag.
|
| 279 |
-
return TransformResult(
|
| 280 |
-
messages=messages,
|
| 281 |
-
tokens_before=tokens_before,
|
| 282 |
-
tokens_after=tokens_before,
|
| 283 |
-
tokens_saved=0,
|
| 284 |
-
transforms_applied=[],
|
| 285 |
-
markers_inserted=[],
|
| 286 |
-
warnings=[],
|
| 287 |
-
timing={"two_trim": 0.0},
|
| 288 |
-
skip_reason="two_trim: disabled",
|
| 289 |
-
)
|
| 290 |
-
frozen_message_count = int(kwargs.get("frozen_message_count", 0) or 0)
|
| 291 |
-
messages = deep_copy_messages(messages)
|
| 292 |
-
|
| 293 |
-
query = _extract_query(messages)
|
| 294 |
-
|
| 295 |
-
warnings: list[str] = []
|
| 296 |
-
transforms_applied: list[str] = []
|
| 297 |
-
markers_inserted: list[str] = []
|
| 298 |
-
total_dropped = 0
|
| 299 |
-
total_kept = 0
|
| 300 |
-
used_semantic = False
|
| 301 |
-
|
| 302 |
-
for idx in range(frozen_message_count, len(messages)):
|
| 303 |
-
msg = messages[idx]
|
| 304 |
-
if msg.get("role") not in self.config.target_roles:
|
| 305 |
-
continue
|
| 306 |
-
|
| 307 |
-
content = msg.get("content")
|
| 308 |
-
if isinstance(content, str):
|
| 309 |
-
new_text, info = self._compress_text(content, query)
|
| 310 |
-
msg["content"] = new_text
|
| 311 |
-
if info.get("applied"):
|
| 312 |
-
used_semantic = used_semantic or info.get("semantic", False)
|
| 313 |
-
total_dropped += info.get("dropped", 0)
|
| 314 |
-
total_kept += info.get("kept", 0)
|
| 315 |
-
elif isinstance(content, list):
|
| 316 |
-
for block in content:
|
| 317 |
-
if not isinstance(block, dict):
|
| 318 |
-
continue
|
| 319 |
-
btype = block.get("type")
|
| 320 |
-
if btype not in ("text", "input_text"):
|
| 321 |
-
continue
|
| 322 |
-
text = block.get("text") or ""
|
| 323 |
-
new_text, info = self._compress_text(text, query)
|
| 324 |
-
block["text"] = new_text
|
| 325 |
-
if info.get("applied"):
|
| 326 |
-
used_semantic = used_semantic or info.get("semantic", False)
|
| 327 |
-
total_dropped += info.get("dropped", 0)
|
| 328 |
-
total_kept += info.get("kept", 0)
|
| 329 |
-
|
| 330 |
-
if total_dropped > 0:
|
| 331 |
-
tag = "semantic" if used_semantic else "bm25"
|
| 332 |
-
transforms_applied.append(f"two_trim:{self.config.mode}:{tag}")
|
| 333 |
-
markers_inserted.append(
|
| 334 |
-
f"twotrim:drops={total_dropped}:kept={total_kept}"
|
| 335 |
-
)
|
| 336 |
-
|
| 337 |
-
tokens_after = tokenizer.count_messages(messages)
|
| 338 |
-
elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
| 339 |
-
tokens_saved = max(0, tokens_before - tokens_after)
|
| 340 |
-
|
| 341 |
-
if transforms_applied and tokens_saved > 0:
|
| 342 |
-
segments = current_request_segments()
|
| 343 |
-
if segments is not None:
|
| 344 |
-
segments.append(
|
| 345 |
-
{
|
| 346 |
-
"strategy": "two_trim",
|
| 347 |
-
"original_tokens": tokens_before,
|
| 348 |
-
"compressed_tokens": tokens_after,
|
| 349 |
-
}
|
| 350 |
-
)
|
| 351 |
-
|
| 352 |
-
if not transforms_applied:
|
| 353 |
-
return TransformResult(
|
| 354 |
-
messages=messages,
|
| 355 |
-
tokens_before=tokens_before,
|
| 356 |
-
tokens_after=tokens_after,
|
| 357 |
-
tokens_saved=0,
|
| 358 |
-
transforms_applied=[],
|
| 359 |
-
markers_inserted=[],
|
| 360 |
-
warnings=warnings,
|
| 361 |
-
timing={"two_trim": elapsed_ms},
|
| 362 |
-
skip_reason="two_trim: no eligible text compressed",
|
| 363 |
-
)
|
| 364 |
-
|
| 365 |
-
return TransformResult(
|
| 366 |
-
messages=messages,
|
| 367 |
-
tokens_before=tokens_before,
|
| 368 |
-
tokens_after=tokens_after,
|
| 369 |
-
tokens_saved=tokens_saved,
|
| 370 |
-
transforms_applied=transforms_applied,
|
| 371 |
-
markers_inserted=markers_inserted,
|
| 372 |
-
warnings=warnings,
|
| 373 |
-
timing={"two_trim": elapsed_ms},
|
| 374 |
-
)
|
| 375 |
-
|
| 376 |
-
def _msg_text(self, msg: dict[str, Any]) -> str:
|
| 377 |
-
content = msg.get("content")
|
| 378 |
-
if isinstance(content, str):
|
| 379 |
-
return content
|
| 380 |
-
if isinstance(content, list):
|
| 381 |
-
parts = []
|
| 382 |
-
for b in content:
|
| 383 |
-
if isinstance(b, dict) and b.get("type") in ("text", "input_text"):
|
| 384 |
-
parts.append(b.get("text") or "")
|
| 385 |
-
return " ".join(parts)
|
| 386 |
-
return ""
|
| 387 |
-
|
| 388 |
-
def _compress_text(self, text: str, query: str) -> tuple[str, dict[str, Any]]:
|
| 389 |
-
"""Returns (new_text, info_dict). info_dict has: applied, dropped, kept, semantic."""
|
| 390 |
-
no_op = {"applied": False, "dropped": 0, "kept": 0, "semantic": False}
|
| 391 |
-
if not text or not text.strip():
|
| 392 |
-
return text, no_op
|
| 393 |
-
|
| 394 |
-
# Skip structured content (handled by upstream compressors)
|
| 395 |
-
if self.config.skip_structured_content and _looks_structured(text):
|
| 396 |
-
return text, no_op
|
| 397 |
-
|
| 398 |
-
sentences = _split_sentences(text)
|
| 399 |
-
if len(sentences) < 3:
|
| 400 |
-
# Too few to meaningfully compress — preserve verbatim
|
| 401 |
-
return text, no_op
|
| 402 |
-
|
| 403 |
-
# Token floor check
|
| 404 |
-
approx_tokens = sum(len(s.split()) for s in sentences) * 1.3
|
| 405 |
-
if approx_tokens < self.config.min_tokens_to_compress:
|
| 406 |
-
return text, no_op
|
| 407 |
-
|
| 408 |
-
# Score
|
| 409 |
-
scores: list[float] = []
|
| 410 |
-
used_semantic = False
|
| 411 |
-
if self.config.use_semantic_scoring:
|
| 412 |
-
scores, used_semantic = _score_semantic(
|
| 413 |
-
sentences, query, self.config.sentence_model
|
| 414 |
-
)
|
| 415 |
-
if not scores:
|
| 416 |
-
scores = _score_bm25(sentences, query)
|
| 417 |
-
used_semantic = False
|
| 418 |
-
|
| 419 |
-
keep_ratio = _MODE_KEEP_RATIO.get(self.config.mode, 0.60)
|
| 420 |
-
kept = _select_and_reorder(
|
| 421 |
-
sentences,
|
| 422 |
-
scores,
|
| 423 |
-
keep_ratio=keep_ratio,
|
| 424 |
-
min_keep_ratio=self.config.min_sentence_keep_ratio,
|
| 425 |
-
max_drop_ratio=self.config.max_compression_ratio,
|
| 426 |
-
reorder=self.config.reorder_for_attention,
|
| 427 |
-
)
|
| 428 |
-
|
| 429 |
-
if len(kept) == len(sentences):
|
| 430 |
-
return text, no_op
|
| 431 |
-
|
| 432 |
-
new_text = " ".join(kept)
|
| 433 |
-
info = {
|
| 434 |
-
"applied": True,
|
| 435 |
-
"dropped": len(sentences) - len(kept),
|
| 436 |
-
"kept": len(kept),
|
| 437 |
-
"semantic": used_semantic,
|
| 438 |
-
}
|
| 439 |
-
return new_text, info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_two_trim.py
DELETED
|
@@ -1,425 +0,0 @@
|
|
| 1 |
-
"""Tests for TwoTrim extractive compression transform.
|
| 2 |
-
|
| 3 |
-
These tests exercise the pure-Python path (BM25 fallback) by default
|
| 4 |
-
so they run without optional deps. Semantic-scoring path is tested
|
| 5 |
-
behind an import-skip gate.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import os
|
| 11 |
-
|
| 12 |
-
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 13 |
-
|
| 14 |
-
import pytest
|
| 15 |
-
|
| 16 |
-
from headroom.config import HeadroomConfig, TwoTrimConfig
|
| 17 |
-
from headroom.tokenizer import Tokenizer
|
| 18 |
-
from headroom.tokenizers import EstimatingTokenCounter
|
| 19 |
-
from headroom.transforms.two_trim import (
|
| 20 |
-
TwoTrim,
|
| 21 |
-
_extract_query,
|
| 22 |
-
_looks_structured,
|
| 23 |
-
_select_and_reorder,
|
| 24 |
-
_split_sentences,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
# =============================================================================
|
| 28 |
-
# Fixtures
|
| 29 |
-
# =============================================================================
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
@pytest.fixture
|
| 33 |
-
def tokenizer():
|
| 34 |
-
return Tokenizer(EstimatingTokenCounter(), model="gpt-4o")
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
@pytest.fixture
|
| 38 |
-
def long_user_text():
|
| 39 |
-
"""Plain-text user message with enough sentences + tokens to compress."""
|
| 40 |
-
filler = (
|
| 41 |
-
"The quick brown fox jumps over the lazy dog near the riverbank. "
|
| 42 |
-
"Meanwhile, the weather today is unusually mild for this time of year. "
|
| 43 |
-
"Many people enjoy walking in the park during autumn afternoons. "
|
| 44 |
-
"There are several coffee shops on the corner of main street. "
|
| 45 |
-
"The annual report from 2023 shows a modest increase in revenue. "
|
| 46 |
-
"Birds migrate south when the temperature starts to drop significantly. "
|
| 47 |
-
"Did you remember to renew your gym membership this month? "
|
| 48 |
-
"Tomorrow's forecast predicts scattered showers across the region. "
|
| 49 |
-
"The library closes early on Sundays for maintenance work. "
|
| 50 |
-
"Some researchers prefer quantitative methods while others choose qualitative. "
|
| 51 |
-
"The conference keynote speaker will talk about neural network optimization. "
|
| 52 |
-
"Pizza is a popular dish that originated in Italy centuries ago. "
|
| 53 |
-
"The committee meets every other Thursday to discuss policy changes. "
|
| 54 |
-
"Quantum entanglement puzzled physicists for decades before being resolved. "
|
| 55 |
-
"Backups should run nightly to prevent catastrophic data loss incidents. "
|
| 56 |
-
"The new highway interchange opens next month after years of construction. "
|
| 57 |
-
)
|
| 58 |
-
# Replicate to push token count well past min_tokens_to_compress
|
| 59 |
-
return filler * 3
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
# =============================================================================
|
| 63 |
-
# Unit tests: helpers
|
| 64 |
-
# =============================================================================
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
class TestSplitSentences:
|
| 68 |
-
def test_basic_split(self):
|
| 69 |
-
text = "Hello world. How are you? I am fine!"
|
| 70 |
-
sents = _split_sentences(text)
|
| 71 |
-
assert len(sents) == 3
|
| 72 |
-
assert sents[0].startswith("Hello")
|
| 73 |
-
|
| 74 |
-
def test_empty(self):
|
| 75 |
-
assert _split_sentences("") == []
|
| 76 |
-
assert _split_sentences(" ") == []
|
| 77 |
-
|
| 78 |
-
def test_no_terminator(self):
|
| 79 |
-
# Single sentence with no period — returns single element
|
| 80 |
-
sents = _split_sentences("just words no terminator here")
|
| 81 |
-
assert len(sents) == 1
|
| 82 |
-
|
| 83 |
-
def test_preserves_internal_punctuation(self):
|
| 84 |
-
text = "Visit Dr. Smith at 10 a.m. for the appointment."
|
| 85 |
-
sents = _split_sentences(text)
|
| 86 |
-
# The regex is conservative — should produce >=1 sentences, not crash
|
| 87 |
-
assert len(sents) >= 1
|
| 88 |
-
# Original words all present
|
| 89 |
-
joined = " ".join(sents)
|
| 90 |
-
for word in ("Visit", "Smith", "appointment"):
|
| 91 |
-
assert word in joined
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
class TestLooksStructured:
|
| 95 |
-
def test_json_object(self):
|
| 96 |
-
assert _looks_structured('{"key": "value"}') is True
|
| 97 |
-
|
| 98 |
-
def test_json_array(self):
|
| 99 |
-
assert _looks_structured("[1, 2, 3]") is True
|
| 100 |
-
|
| 101 |
-
def test_code_fence(self):
|
| 102 |
-
assert _looks_structured("```python\nprint('hi')\n```") is True
|
| 103 |
-
|
| 104 |
-
def test_plain_text(self):
|
| 105 |
-
assert _looks_structured("This is a normal sentence about cats.") is False
|
| 106 |
-
|
| 107 |
-
def test_empty(self):
|
| 108 |
-
assert _looks_structured("") is False
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
class TestExtractQuery:
|
| 112 |
-
def test_last_user_wins(self):
|
| 113 |
-
msgs = [
|
| 114 |
-
{"role": "user", "content": "old question"},
|
| 115 |
-
{"role": "assistant", "content": "response"},
|
| 116 |
-
{"role": "user", "content": "new question"},
|
| 117 |
-
]
|
| 118 |
-
assert _extract_query(msgs) == "new question"
|
| 119 |
-
|
| 120 |
-
def test_no_user_returns_empty(self):
|
| 121 |
-
msgs = [{"role": "system", "content": "sys"}, {"role": "assistant", "content": "a"}]
|
| 122 |
-
assert _extract_query(msgs) == ""
|
| 123 |
-
|
| 124 |
-
def test_list_content_text_blocks(self):
|
| 125 |
-
msgs = [
|
| 126 |
-
{
|
| 127 |
-
"role": "user",
|
| 128 |
-
"content": [
|
| 129 |
-
{"type": "text", "text": "hello "},
|
| 130 |
-
{"type": "text", "text": "world"},
|
| 131 |
-
],
|
| 132 |
-
}
|
| 133 |
-
]
|
| 134 |
-
assert "hello" in _extract_query(msgs)
|
| 135 |
-
assert "world" in _extract_query(msgs)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
class TestSelectAndReorder:
|
| 139 |
-
def test_keep_all_when_keep_ratio_one(self):
|
| 140 |
-
sents = ["a", "b", "c", "d"]
|
| 141 |
-
scores = [0.1, 0.9, 0.5, 0.2]
|
| 142 |
-
kept = _select_and_reorder(sents, scores, 1.0, 0.25, 0.75, reorder=False)
|
| 143 |
-
assert len(kept) == 4
|
| 144 |
-
|
| 145 |
-
def test_drops_lowest(self):
|
| 146 |
-
sents = ["a", "b", "c", "d", "e"]
|
| 147 |
-
scores = [0.1, 0.9, 0.5, 0.8, 0.2]
|
| 148 |
-
# 60% keep = 3 sentences
|
| 149 |
-
kept = _select_and_reorder(sents, scores, 0.6, 0.2, 0.7, reorder=False)
|
| 150 |
-
assert len(kept) == 3
|
| 151 |
-
# b, c, d kept (scores 0.9, 0.5, 0.8) — a and e dropped
|
| 152 |
-
kept_set = set(kept)
|
| 153 |
-
assert "b" in kept_set
|
| 154 |
-
assert "d" in kept_set
|
| 155 |
-
assert "a" not in kept_set
|
| 156 |
-
assert "e" not in kept_set
|
| 157 |
-
|
| 158 |
-
def test_floor_respected(self):
|
| 159 |
-
sents = ["a", "b", "c", "d"]
|
| 160 |
-
scores = [0.1, 0.1, 0.1, 0.1]
|
| 161 |
-
# Even with keep_ratio=0.1, floor of 0.5 keeps >=2
|
| 162 |
-
kept = _select_and_reorder(sents, scores, 0.1, 0.5, 0.7, reorder=False)
|
| 163 |
-
assert len(kept) >= 2
|
| 164 |
-
|
| 165 |
-
def test_reorder_puts_best_at_edges(self):
|
| 166 |
-
sents = ["low1", "HIGH1", "mid1", "mid2", "HIGH2", "low2"]
|
| 167 |
-
scores = [0.1, 0.99, 0.5, 0.5, 0.95, 0.1]
|
| 168 |
-
kept = _select_and_reorder(sents, scores, 0.5, 0.2, 0.7, reorder=True)
|
| 169 |
-
# Top 50% = 3 sentences: HIGH1 (0.99), HIGH2 (0.95), mid1 (0.5)
|
| 170 |
-
assert len(kept) == 3
|
| 171 |
-
# Best should be at start or end, not middle
|
| 172 |
-
assert kept[0] == "HIGH1" or kept[-1] == "HIGH1"
|
| 173 |
-
assert kept[0] == "HIGH2" or kept[-1] == "HIGH2"
|
| 174 |
-
|
| 175 |
-
def test_single_sentence_passthrough(self):
|
| 176 |
-
assert _select_and_reorder(["only"], [0.5], 0.5, 0.25, 0.75, False) == ["only"]
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
# =============================================================================
|
| 180 |
-
# Integration tests: Transform.apply
|
| 181 |
-
# =============================================================================
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
class TestTwoTrimApply:
|
| 185 |
-
def test_disabled_no_op(self, tokenizer, long_user_text):
|
| 186 |
-
cfg = TwoTrimConfig(enabled=False)
|
| 187 |
-
t = TwoTrim(cfg)
|
| 188 |
-
msgs = [{"role": "user", "content": long_user_text}]
|
| 189 |
-
result = t.apply(msgs, tokenizer)
|
| 190 |
-
# No-op path: skip_reason set, no transforms_applied
|
| 191 |
-
assert result.tokens_saved == 0
|
| 192 |
-
assert not result.transforms_applied
|
| 193 |
-
|
| 194 |
-
def test_should_apply_false_when_disabled(self, tokenizer, long_user_text):
|
| 195 |
-
cfg = TwoTrimConfig(enabled=False)
|
| 196 |
-
t = TwoTrim(cfg)
|
| 197 |
-
msgs = [{"role": "user", "content": long_user_text}]
|
| 198 |
-
assert t.should_apply(msgs, tokenizer) is False
|
| 199 |
-
|
| 200 |
-
def test_should_apply_true_when_enabled_with_text(self, tokenizer, long_user_text):
|
| 201 |
-
cfg = TwoTrimConfig(enabled=True, min_tokens_to_compress=50)
|
| 202 |
-
t = TwoTrim(cfg)
|
| 203 |
-
msgs = [{"role": "user", "content": long_user_text}]
|
| 204 |
-
assert t.should_apply(msgs, tokenizer) is True
|
| 205 |
-
|
| 206 |
-
def test_should_apply_false_for_short_text(self, tokenizer):
|
| 207 |
-
cfg = TwoTrimConfig(enabled=True, min_tokens_to_compress=2000)
|
| 208 |
-
t = TwoTrim(cfg)
|
| 209 |
-
msgs = [{"role": "user", "content": "Too short to bother."}]
|
| 210 |
-
assert t.should_apply(msgs, tokenizer) is False
|
| 211 |
-
|
| 212 |
-
def test_compresses_long_text(self, tokenizer, long_user_text):
|
| 213 |
-
cfg = TwoTrimConfig(
|
| 214 |
-
enabled=True,
|
| 215 |
-
mode="balanced",
|
| 216 |
-
min_tokens_to_compress=50,
|
| 217 |
-
use_semantic_scoring=False, # Force BM25 for CI
|
| 218 |
-
)
|
| 219 |
-
t = TwoTrim(cfg)
|
| 220 |
-
msgs = [
|
| 221 |
-
{"role": "system", "content": "You are helpful."},
|
| 222 |
-
{"role": "user", "content": long_user_text},
|
| 223 |
-
]
|
| 224 |
-
before = tokenizer.count_messages(msgs)
|
| 225 |
-
result = t.apply(msgs, tokenizer)
|
| 226 |
-
after = tokenizer.count_messages(result.messages)
|
| 227 |
-
# Should have compressed something (BM25 path, query has some overlap)
|
| 228 |
-
assert after < before
|
| 229 |
-
assert result.tokens_saved > 0
|
| 230 |
-
assert len(result.transforms_applied) == 1
|
| 231 |
-
assert result.transforms_applied[0].startswith("two_trim:balanced:bm25")
|
| 232 |
-
|
| 233 |
-
def test_skips_structured_content(self, tokenizer):
|
| 234 |
-
cfg = TwoTrimConfig(
|
| 235 |
-
enabled=True,
|
| 236 |
-
mode="aggressive",
|
| 237 |
-
min_tokens_to_compress=10,
|
| 238 |
-
use_semantic_scoring=False,
|
| 239 |
-
)
|
| 240 |
-
t = TwoTrim(cfg)
|
| 241 |
-
big_json = '{"data": [' + ", ".join(f'{{"x": {i}}}' for i in range(500)) + "]}"
|
| 242 |
-
msgs = [{"role": "user", "content": big_json}]
|
| 243 |
-
result = t.apply(msgs, tokenizer)
|
| 244 |
-
# JSON should be left untouched
|
| 245 |
-
assert result.tokens_saved == 0
|
| 246 |
-
assert not result.transforms_applied
|
| 247 |
-
|
| 248 |
-
def test_preserves_uncompressed_roles(self, tokenizer, long_user_text):
|
| 249 |
-
cfg = TwoTrimConfig(
|
| 250 |
-
enabled=True,
|
| 251 |
-
mode="balanced",
|
| 252 |
-
min_tokens_to_compress=50,
|
| 253 |
-
use_semantic_scoring=False,
|
| 254 |
-
)
|
| 255 |
-
t = TwoTrim(cfg)
|
| 256 |
-
assistant_text = "I should not be modified by TwoTrim. " * 100
|
| 257 |
-
msgs = [
|
| 258 |
-
{"role": "user", "content": long_user_text},
|
| 259 |
-
{"role": "assistant", "content": assistant_text},
|
| 260 |
-
]
|
| 261 |
-
result = t.apply(msgs, tokenizer)
|
| 262 |
-
# Assistant content should be unchanged (not in target_roles by default)
|
| 263 |
-
# Find assistant message — it should still contain all the original text
|
| 264 |
-
assistant_msg = next(m for m in result.messages if m["role"] == "assistant")
|
| 265 |
-
assert assistant_msg["content"] == assistant_text
|
| 266 |
-
|
| 267 |
-
def test_frozen_message_count_respected(self, tokenizer, long_user_text):
|
| 268 |
-
cfg = TwoTrimConfig(
|
| 269 |
-
enabled=True,
|
| 270 |
-
min_tokens_to_compress=50,
|
| 271 |
-
use_semantic_scoring=False,
|
| 272 |
-
)
|
| 273 |
-
t = TwoTrim(cfg)
|
| 274 |
-
msgs = [
|
| 275 |
-
{"role": "user", "content": long_user_text}, # would normally compress
|
| 276 |
-
{"role": "user", "content": "second message"},
|
| 277 |
-
]
|
| 278 |
-
result = t.apply(msgs, tokenizer, frozen_message_count=1)
|
| 279 |
-
# First message should be untouched
|
| 280 |
-
assert result.messages[0]["content"] == long_user_text
|
| 281 |
-
|
| 282 |
-
def test_list_content_blocks(self, tokenizer, long_user_text):
|
| 283 |
-
cfg = TwoTrimConfig(
|
| 284 |
-
enabled=True,
|
| 285 |
-
min_tokens_to_compress=50,
|
| 286 |
-
use_semantic_scoring=False,
|
| 287 |
-
)
|
| 288 |
-
t = TwoTrim(cfg)
|
| 289 |
-
msgs = [
|
| 290 |
-
{
|
| 291 |
-
"role": "user",
|
| 292 |
-
"content": [
|
| 293 |
-
{"type": "text", "text": long_user_text},
|
| 294 |
-
{"type": "image_url", "image_url": {"url": "data:..."}},
|
| 295 |
-
],
|
| 296 |
-
}
|
| 297 |
-
]
|
| 298 |
-
result = t.apply(msgs, tokenizer)
|
| 299 |
-
# text block compressed, image block untouched
|
| 300 |
-
text_block = result.messages[0]["content"][0]
|
| 301 |
-
image_block = result.messages[0]["content"][1]
|
| 302 |
-
assert image_block["type"] == "image_url"
|
| 303 |
-
# Text should be shorter than original
|
| 304 |
-
assert len(text_block["text"]) < len(long_user_text)
|
| 305 |
-
|
| 306 |
-
def test_warnings_clean_when_no_op(self, tokenizer):
|
| 307 |
-
cfg = TwoTrimConfig(enabled=True, min_tokens_to_compress=10_000)
|
| 308 |
-
t = TwoTrim(cfg)
|
| 309 |
-
msgs = [{"role": "user", "content": "short text"}]
|
| 310 |
-
result = t.apply(msgs, tokenizer)
|
| 311 |
-
assert result.tokens_saved == 0
|
| 312 |
-
assert result.warnings == []
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
# =============================================================================
|
| 316 |
-
# Integration: pipeline wiring
|
| 317 |
-
# =============================================================================
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
class TestPipelineIntegration:
|
| 321 |
-
def test_pipeline_picks_up_when_enabled(self, tokenizer, long_user_text):
|
| 322 |
-
from headroom.transforms.pipeline import TransformPipeline
|
| 323 |
-
|
| 324 |
-
cfg = HeadroomConfig()
|
| 325 |
-
cfg.two_trim.enabled = True
|
| 326 |
-
cfg.two_trim.min_tokens_to_compress = 50
|
| 327 |
-
cfg.two_trim.use_semantic_scoring = False
|
| 328 |
-
|
| 329 |
-
pipeline = TransformPipeline(config=cfg)
|
| 330 |
-
# Find the TwoTrim transform in the pipeline
|
| 331 |
-
names = [t.name for t in pipeline.transforms]
|
| 332 |
-
assert "two_trim" in names
|
| 333 |
-
|
| 334 |
-
def test_pipeline_skips_when_disabled(self, tokenizer):
|
| 335 |
-
from headroom.transforms.pipeline import TransformPipeline
|
| 336 |
-
|
| 337 |
-
cfg = HeadroomConfig()
|
| 338 |
-
cfg.two_trim.enabled = False
|
| 339 |
-
|
| 340 |
-
pipeline = TransformPipeline(config=cfg)
|
| 341 |
-
names = [t.name for t in pipeline.transforms]
|
| 342 |
-
assert "two_trim" not in names
|
| 343 |
-
|
| 344 |
-
def test_end_to_end_via_pipeline(self, long_user_text):
|
| 345 |
-
from headroom.transforms.pipeline import TransformPipeline
|
| 346 |
-
|
| 347 |
-
cfg = HeadroomConfig()
|
| 348 |
-
cfg.two_trim.enabled = True
|
| 349 |
-
cfg.two_trim.min_tokens_to_compress = 50
|
| 350 |
-
cfg.two_trim.use_semantic_scoring = False
|
| 351 |
-
|
| 352 |
-
pipeline = TransformPipeline(config=cfg)
|
| 353 |
-
msgs = [
|
| 354 |
-
{"role": "system", "content": "You are a helpful assistant."},
|
| 355 |
-
{"role": "user", "content": long_user_text},
|
| 356 |
-
]
|
| 357 |
-
result = pipeline.apply(msgs, model="gpt-4o", model_limit=128000)
|
| 358 |
-
# TwoTrim should appear in transforms_applied if it saved anything
|
| 359 |
-
tt_applied = [t for t in result.transforms_applied if t.startswith("two_trim")]
|
| 360 |
-
# If tokens saved, two_trim should be recorded
|
| 361 |
-
if result.tokens_saved > 0:
|
| 362 |
-
assert len(tt_applied) >= 1
|
| 363 |
-
|
| 364 |
-
def test_pipeline_records_two_trim_compression_segment(self, long_user_text):
|
| 365 |
-
from headroom.transforms.pipeline import TransformPipeline
|
| 366 |
-
|
| 367 |
-
cfg = HeadroomConfig()
|
| 368 |
-
cfg.two_trim.enabled = True
|
| 369 |
-
cfg.two_trim.min_tokens_to_compress = 50
|
| 370 |
-
cfg.two_trim.use_semantic_scoring = False
|
| 371 |
-
|
| 372 |
-
pipeline = TransformPipeline(config=cfg)
|
| 373 |
-
msgs = [{"role": "user", "content": long_user_text}]
|
| 374 |
-
result = pipeline.apply(msgs, model="gpt-4o", model_limit=128000)
|
| 375 |
-
|
| 376 |
-
assert result.compression_segments is not None
|
| 377 |
-
two_trim_segments = [
|
| 378 |
-
s for s in result.compression_segments if s["strategy"] == "two_trim"
|
| 379 |
-
]
|
| 380 |
-
assert two_trim_segments
|
| 381 |
-
segment = two_trim_segments[0]
|
| 382 |
-
assert segment["original_tokens"] == result.tokens_before
|
| 383 |
-
assert segment["compressed_tokens"] == result.tokens_after
|
| 384 |
-
assert segment["original_tokens"] > segment["compressed_tokens"]
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
# =============================================================================
|
| 388 |
-
# Mode behavior
|
| 389 |
-
# =============================================================================
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
class TestCompressionModes:
|
| 393 |
-
@pytest.mark.parametrize("mode", ["conservative", "balanced", "aggressive"])
|
| 394 |
-
def test_each_mode_runs(self, tokenizer, long_user_text, mode):
|
| 395 |
-
cfg = TwoTrimConfig(
|
| 396 |
-
enabled=True,
|
| 397 |
-
mode=mode,
|
| 398 |
-
min_tokens_to_compress=50,
|
| 399 |
-
use_semantic_scoring=False,
|
| 400 |
-
)
|
| 401 |
-
t = TwoTrim(cfg)
|
| 402 |
-
msgs = [{"role": "user", "content": long_user_text}]
|
| 403 |
-
result = t.apply(msgs, tokenizer)
|
| 404 |
-
assert result.tokens_saved > 0
|
| 405 |
-
assert f"two_trim:{mode}:bm25" in result.transforms_applied
|
| 406 |
-
|
| 407 |
-
def test_aggressive_saves_more_than_conservative(self, tokenizer, long_user_text):
|
| 408 |
-
# On the fixture text, aggressive should drop >= balanced >= conservative
|
| 409 |
-
def run(mode: str) -> int:
|
| 410 |
-
cfg = TwoTrimConfig(
|
| 411 |
-
enabled=True,
|
| 412 |
-
mode=mode,
|
| 413 |
-
min_tokens_to_compress=50,
|
| 414 |
-
use_semantic_scoring=False,
|
| 415 |
-
)
|
| 416 |
-
t = TwoTrim(cfg)
|
| 417 |
-
msgs = [{"role": "user", "content": long_user_text}]
|
| 418 |
-
return t.apply(msgs, tokenizer).tokens_saved
|
| 419 |
-
|
| 420 |
-
agg = run("aggressive")
|
| 421 |
-
bal = run("balanced")
|
| 422 |
-
con = run("conservative")
|
| 423 |
-
# Monotonic on this fixture (not a hard invariant in general, but
|
| 424 |
-
# the fixture is designed so it holds)
|
| 425 |
-
assert agg >= bal >= con
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|