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 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