Skip to content

Commit 27a5468

Browse files
gglucassclaudeJerrettDavis
authored
fix(learn): aggregate verbosity baselines across projects instead of overwriting (headroomlabs-ai#1288)
## Description `headroom learn --verbosity --apply --all` was building the output-shaper's savings baseline from only **one** project. `_run_verbosity` wrote the savings ledger *inside* the per-project loop (`ledger.baseline = baseline; ledger.save(...)`), so each project replaced the previous baseline and only the last project processed survived — frequently a near-empty one. The synthetic-control estimate that `/stats` exposes (`savings.by_layer.output_shaping`) was then computed against a tiny, unrepresentative sample. This PR makes `--all` aggregate across every targeted project and write the ledger **once**, after the loop. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `BaselineModel.merge()` / `_Accum.merge()` (`headroom/proxy/output_savings.py`): fold one baseline into another. The accumulators hold additive online stats (`n` / `sum` / `sumsq`), so merging is element-wise and order-independent — identical to having observed both corpora against a single model. - `_run_verbosity` (`headroom/cli/learn.py`): accumulate a single `BaselineModel` across all targeted projects and persist it once after the loop, instead of overwriting per project. The applied verbosity level now comes from the project with the most samples (strongest signal) rather than whichever sorted last. Single-project runs are unchanged (an aggregate of one). When no transcripts are found, it prints a clear message and writes nothing. - Tests: unit test for `BaselineModel.merge`; CLI test that `--all --apply` across two projects aggregates both strata (totals summed, not last-wins) and applies the busier project's level. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_output_savings.py tests/test_cli_learn.py tests/test_verbosity_learn.py -q tests/test_output_savings.py ............................... [ 54%] tests/test_cli_learn.py ........... [ 73%] tests/test_verbosity_learn.py ............... [100%] ============================== 57 passed in 0.51s ============================== $ uv run ruff check headroom/cli/learn.py headroom/proxy/output_savings.py All checks passed! $ uv run mypy headroom/cli/learn.py headroom/proxy/output_savings.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Python 3.12.13, this branch off `upstream/main`. - Exact command / steps: `headroom learn --verbosity --apply --all` (run across a multi-project transcript corpus), then inspect `~/.headroom/output_savings.json` (`baseline.glob.n`); compared against `headroom learn --verbosity --apply` for a single busy project. - Symptom (pre-fix, installed build): `headroom learn --verbosity --apply --all` across a multi-project transcript corpus wrote `~/.headroom/output_savings.json` with `baseline.glob.n = 2` (the last project processed was a near-empty `…/venv/bin` dir), while targeting a single busy project gave `baseline.glob.n = 15658`. - With this change: the new CLI test (`test_verbosity_all_apply_aggregates_baselines_across_projects`) drives `--all --apply` over two projects (3 samples + 1 sample) and asserts the persisted ledger has `total_samples == 4` with both strata present, plus the busier project's level applied. - Observed result: aggregated baseline persisted once; both strata retained; level taken from the higher-sample project. - Not tested: re-running the patched `--all` end-to-end on a live multi-project machine (covered instead by the unit merge-math test and the faked-`analyze` CLI test). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — CLI/behavioral change. ## Additional Notes - No linked issue (`Closes #` left blank intentionally). - Documentation checklist item is N/A — no user-facing docs describe the per-project overwrite behavior. - Level-selection note: for `--all`, the applied verbosity level is now deterministic (most-samples project) instead of last-processed; this is the intended improvement, not a behavior to preserve. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
1 parent 46dede3 commit 27a5468

5 files changed

Lines changed: 173 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434

3535
### Bug Fixes
3636

37+
* **learn (verbosity):** `--verbosity --apply --all` now aggregates the savings baseline across every project instead of overwriting it per project (last-project-wins), which previously left the output shaper with a tiny, unrepresentative baseline. The applied verbosity level comes from the project with the most samples ([#1288](https://github.com/headroomlabs-ai/headroom/pull/1288)).
3738
* **proxy/anthropic:** restore token-mode compression on continued Claude Code turns with a frozen prefix and deferred CCR tool injection. Token mode now runs request-side compression even when the client did not pre-register `headroom_retrieve`, relying on the existing marker-triggered injection override to keep emitted CCR markers redeemable ([#1487](https://github.com/headroomlabs-ai/headroom/issues/1487)).
3839
* **subscription:** stop zeroing the 5-hour headroom contribution counters on every poll. The rollover check compared `five_hour.resets_at` with a bare `!=`, but the usage API reports that timestamp with second-level jitter (observed flapping between `01:59:59Z` and `02:00:00Z` on consecutive polls within the same window), so a spurious "5h window rolled over" reset fired every poll interval (~5 min) and the dashboard's per-window savings stuck near 0%. Only a forward jump larger than `_ROLLOVER_MIN_ADVANCE` (1 minute) now counts as a real rollover.
3940
* **transforms/content_router:** stop replacing `role="tool"` output with a lossy-unrecoverable summary on the live compression path (refs [#1307](https://github.com/chopratejas/headroom/issues/1307)). `ContentRouter.apply()` routed OpenAI-style `role="tool"` string messages — `Bash`/`grep`/`ls`/`cat` output — through the ML/word-drop summarizers; when the result carried no CCR retrieve marker (CCR off, ratio >= 0.8, or the size-gate fallback) the original was unrecoverable and the agent acted on a fabricated summary. Tool-role string content is now kept verbatim unless the compressed form is CCR-recoverable. Assistant/user text is unaffected, and structurally-lossless passes (SmartCrusher/Log/Search) still apply. The Anthropic `tool_result` block path is tracked separately.

headroom/cli/learn.py

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,6 @@ def learn(
170170
raise click.UsageError("--all and --project are mutually exclusive.")
171171
if llm_judge and not verbosity_mode:
172172
raise click.UsageError("--llm-judge only applies with --verbosity.")
173-
if verbosity_mode and analyze_all and apply:
174-
raise click.UsageError(
175-
"--verbosity persists a single global level, so --all --apply would keep "
176-
"only the last project's level. Re-run with one --project (or drop --apply "
177-
"to preview every project)."
178-
)
179173

180174
max_workers = workers if workers is not None else min(os.cpu_count() or 4, 8)
181175

@@ -445,7 +439,7 @@ def _run_verbosity(
445439
from ..learn.registry import auto_detect_plugins, get_plugin
446440
from ..learn.verbosity import analyze
447441
from ..paths import ensure_workspace_dir
448-
from ..proxy.output_savings import SavingsLedger
442+
from ..proxy.output_savings import BaselineModel, SavingsLedger
449443

450444
# Verbosity mining reads Claude Code transcripts; restrict to that plugin.
451445
if agent == "auto":
@@ -484,12 +478,26 @@ def _run_verbosity(
484478

485479
judge = _make_llm_judge(model or "claude-sonnet-4-6") if llm_judge else None
486480

481+
# Aggregate across all targeted projects. The baseline accumulates so the
482+
# synthetic control reflects every project's transcripts (not just whichever
483+
# one happens to be processed last). The applied verbosity level comes from
484+
# the project with the most samples — the strongest, least noisy signal.
485+
aggregated = BaselineModel()
486+
best_profile = None
487+
best_profile_samples = -1
488+
analyzed_count = 0
489+
487490
for proj in targets:
488491
session_paths = sorted(proj.data_path.glob("*.jsonl"))
489492
if not session_paths:
490493
continue
491494
profile, baseline = analyze(session_paths, str(proj.project_path), llm_judge=judge)
492495
sig = profile.signals
496+
analyzed_count += 1
497+
aggregated.merge(baseline)
498+
if baseline.total_samples > best_profile_samples:
499+
best_profile_samples = baseline.total_samples
500+
best_profile = profile
493501

494502
click.echo(f"\n{'=' * 60}")
495503
click.echo(f"Verbosity — {proj.name}")
@@ -516,45 +524,49 @@ def _run_verbosity(
516524
f"(confidence: {profile.confidence})"
517525
)
518526

519-
if apply:
520-
ws = ensure_workspace_dir()
521-
from datetime import datetime, timezone
522-
523-
profile.learned_at = datetime.now(timezone.utc).isoformat()
524-
profile.save(ws / "verbosity.json")
525-
# Seed the savings baseline: replace baseline, preserve any live
526-
# treatment/control already accumulated.
527-
ledger_path = ws / "output_savings.json"
528-
ledger = SavingsLedger.load(ledger_path)
529-
ledger.baseline = baseline
530-
ledger.save(ledger_path)
531-
click.echo(f"\n [WROTE] {ws / 'verbosity.json'} (level {profile.level})")
527+
if analyzed_count == 0 or best_profile is None:
528+
click.echo("\n No transcripts found in the selected project(s); nothing learned.")
529+
return
530+
531+
if apply:
532+
ws = ensure_workspace_dir()
533+
from datetime import datetime, timezone
534+
535+
best_profile.learned_at = datetime.now(timezone.utc).isoformat()
536+
best_profile.save(ws / "verbosity.json")
537+
# Seed the savings baseline: replace baseline, preserve any live
538+
# treatment/control already accumulated.
539+
ledger_path = ws / "output_savings.json"
540+
ledger = SavingsLedger.load(ledger_path)
541+
ledger.baseline = aggregated
542+
ledger.save(ledger_path)
543+
click.echo(f"\n [WROTE] {ws / 'verbosity.json'} (level {best_profile.level})")
544+
click.echo(
545+
f" [WROTE] {ledger_path} (baseline: {aggregated.total_samples} samples, "
546+
f"{len(aggregated.strata)} strata across {analyzed_count} project(s))"
547+
)
548+
# Writing the level is not enough — the shaper is off by default.
549+
# Make --apply actually take effect: hot-enable a running proxy, and
550+
# otherwise tell the user exactly how to turn it on.
551+
status, shaper_port = _activate_output_shaper()
552+
if status == "live":
532553
click.echo(
533-
f" [WROTE] {ledger_path} (baseline: {baseline.total_samples} samples, "
534-
f"{len(baseline.strata)} strata)"
554+
f"\n ✓ Output shaper enabled on the running proxy (port {shaper_port}); "
555+
f"level {best_profile.level} is live now (while HEADROOM_VERBOSITY_LEVEL is unset)."
556+
)
557+
click.echo(
558+
" To keep it on across restarts: export HEADROOM_OUTPUT_SHAPER=1 "
559+
"before `headroom wrap ...` (wrap pushes it to the proxy)."
535560
)
536-
# Writing the level is not enough — the shaper is off by default.
537-
# Make --apply actually take effect: hot-enable a running proxy, and
538-
# otherwise tell the user exactly how to turn it on.
539-
status, shaper_port = _activate_output_shaper()
540-
if status == "live":
541-
click.echo(
542-
f"\n ✓ Output shaper enabled on the running proxy (port {shaper_port}); "
543-
f"level {profile.level} is live now (while HEADROOM_VERBOSITY_LEVEL is unset)."
544-
)
545-
click.echo(
546-
" To keep it on across restarts: export HEADROOM_OUTPUT_SHAPER=1 "
547-
"before `headroom wrap ...` (wrap pushes it to the proxy)."
548-
)
549-
else:
550-
click.echo(
551-
"\n ⚠ Level written, but the output shaper is OFF by default — it is "
552-
"NOT shaping output yet."
553-
)
554-
click.echo(
555-
" Enable it: export HEADROOM_OUTPUT_SHAPER=1 then `headroom wrap ...` "
556-
"(or start `headroom proxy` with it set). The learned level is then used "
557-
"automatically while HEADROOM_VERBOSITY_LEVEL is unset."
558-
)
559561
else:
560-
click.echo("\n Dry run — use --apply to persist the level and baseline.")
562+
click.echo(
563+
"\n ⚠ Level written, but the output shaper is OFF by default — it is "
564+
"NOT shaping output yet."
565+
)
566+
click.echo(
567+
" Enable it: export HEADROOM_OUTPUT_SHAPER=1 then `headroom wrap ...` "
568+
"(or start `headroom proxy` with it set). The learned level is then used "
569+
"automatically while HEADROOM_VERBOSITY_LEVEL is unset."
570+
)
571+
else:
572+
click.echo("\n Dry run — use --apply to persist the level and baseline.")

headroom/proxy/output_savings.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,16 @@ def var(self) -> float:
162162
return 0.0
163163
return max(0.0, (self.sumsq - self.sum * self.sum / self.n) / (self.n - 1))
164164

165+
def merge(self, other: _Accum) -> None:
166+
"""Fold another accumulator's observations into this one.
167+
168+
n / sum / sumsq are additive, so merging is element-wise addition and
169+
is exactly equivalent to having ``add``-ed both observation streams.
170+
"""
171+
self.n += other.n
172+
self.sum += other.sum
173+
self.sumsq += other.sumsq
174+
165175
def to_dict(self) -> dict[str, float]:
166176
return {"n": self.n, "sum": self.sum, "sumsq": self.sumsq}
167177

@@ -190,6 +200,19 @@ def observe(self, key: str, output_tokens: int) -> None:
190200
self.strata.setdefault(key, _Accum()).add(output_tokens)
191201
self.glob.add(output_tokens)
192202

203+
def merge(self, other: BaselineModel) -> None:
204+
"""Fold another baseline's observations into this one.
205+
206+
Per-stratum and global accumulators are additive, so merging is
207+
element-wise and order-independent — the result is identical to having
208+
observed both corpora against a single model. Used to aggregate a
209+
cross-project baseline from per-project ``analyze`` results without
210+
re-reading transcripts.
211+
"""
212+
for key, acc in other.strata.items():
213+
self.strata.setdefault(key, _Accum()).merge(acc)
214+
self.glob.merge(other.glob)
215+
193216
def lookup(self, key: str) -> tuple[float, float, int]:
194217
"""Return ``(mean, var, n)`` for *key* with hierarchical back-off.
195218

tests/test_cli_learn.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,74 @@ def test_learn_project_lookup_and_apply_flow(
175175
assert plugin.writer.calls[0][2] is False
176176

177177

178+
def test_verbosity_all_apply_aggregates_baselines_across_projects(
179+
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
180+
) -> None:
181+
import json as _json
182+
183+
from headroom.proxy.output_savings import BaselineModel, SavingsLedger
184+
185+
# Two projects, each with a transcript dir holding a dummy session file
186+
# (analyze is faked, so contents are irrelevant — only presence matters).
187+
proj_a_dir = tmp_path / "a"
188+
proj_b_dir = tmp_path / "b"
189+
for d in (proj_a_dir, proj_b_dir):
190+
d.mkdir()
191+
(d / "s.jsonl").write_text("{}")
192+
proj_a = SimpleNamespace(name="a", project_path=tmp_path / "src-a", data_path=proj_a_dir)
193+
proj_b = SimpleNamespace(name="b", project_path=tmp_path / "src-b", data_path=proj_b_dir)
194+
plugin = FakePlugin("claude", "Claude Code", [proj_a, proj_b])
195+
196+
# Per-project synthetic baselines. Project A has more samples, so its level
197+
# must be the one applied.
198+
base_a = BaselineModel()
199+
for v in (100, 200, 300):
200+
base_a.observe("opus|new_user_ask|s|tools", v)
201+
base_b = BaselineModel()
202+
base_b.observe("sonnet|unknown|m|notools", 50)
203+
204+
class _Profile:
205+
def __init__(self, level: int) -> None:
206+
self.level = level
207+
self.confidence = "high"
208+
self.source = "heuristic"
209+
self.rationale = "test"
210+
self.signals: dict[str, object] = {}
211+
self.learned_at: str | None = None
212+
213+
def save(self, path: object) -> None:
214+
Path(str(path)).write_text(_json.dumps({"level": self.level}))
215+
216+
results = {
217+
str(proj_a.project_path): (_Profile(1), base_a),
218+
str(proj_b.project_path): (_Profile(3), base_b),
219+
}
220+
221+
def fake_analyze(session_paths, project_path, llm_judge=None): # noqa: ANN001, ANN201
222+
return results[project_path]
223+
224+
monkeypatch.setattr("headroom.learn.registry.get_plugin", lambda name: plugin)
225+
monkeypatch.setattr("headroom.learn.verbosity.analyze", fake_analyze)
226+
monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(tmp_path / "ws"))
227+
228+
result = runner.invoke(
229+
main,
230+
["learn", "--agent", "claude", "--verbosity", "--all", "--apply"],
231+
catch_exceptions=False,
232+
)
233+
assert result.exit_code == 0, result.output
234+
235+
ledger = SavingsLedger.load(tmp_path / "ws" / "output_savings.json")
236+
# Aggregated, not last-project-wins: both strata present and totals summed.
237+
assert ledger.baseline.total_samples == 4
238+
assert "opus|new_user_ask|s|tools" in ledger.baseline.strata
239+
assert "sonnet|unknown|m|notools" in ledger.baseline.strata
240+
assert "across 2 project(s)" in result.output
241+
# The applied level comes from the project with the most samples (A → 1).
242+
verbosity = _json.loads((tmp_path / "ws" / "verbosity.json").read_text())
243+
assert verbosity["level"] == 1
244+
245+
178246
def test_learn_reports_missing_requested_project_and_lists_discovered(
179247
monkeypatch: pytest.MonkeyPatch, runner: CliRunner, tmp_path: Path
180248
) -> None:

tests/test_output_savings.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@ def test_roundtrip_serialization(self):
129129
assert m2.lookup("k|a|s|tools") == m.lookup("k|a|s|tools")
130130
assert m2.total_samples == 3
131131

132+
def test_merge_is_equivalent_to_observing_both_streams(self):
133+
# Merging two baselines must equal observing every value against one
134+
# model — same totals per stratum and same global fallback.
135+
a = BaselineModel()
136+
for v in (100, 200):
137+
a.observe("opus|new_user_ask|s|tools", v)
138+
b = BaselineModel()
139+
b.observe("opus|new_user_ask|s|tools", 300)
140+
b.observe("sonnet|unknown|m|notools", 50)
141+
142+
a.merge(b)
143+
144+
mean, _, n = a.lookup("opus|new_user_ask|s|tools")
145+
assert n == 3
146+
assert mean == 200.0 # (100 + 200 + 300) / 3
147+
assert a.total_samples == 4 # 3 + 1 across both strata
148+
149+
reference = BaselineModel()
150+
for v in (100, 200, 300):
151+
reference.observe("opus|new_user_ask|s|tools", v)
152+
reference.observe("sonnet|unknown|m|notools", 50)
153+
assert a.to_dict() == reference.to_dict()
154+
132155

133156
# ---------------------------------------------------------------------------
134157
# synthetic-control estimate

0 commit comments

Comments
 (0)