mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
440ca7f4480c19382e4bfc5a57a7ff62b0a258f3
80 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
440ca7f448 |
test: real-shape regression fixtures from production events.jsonl
Fragility #3 from end-of-session audit. The previous gate tests pinned isCompressionProfitable(slab, 100, 2, 2.5) === true against 'A'.repeat(M) shapes — they prove the math is wired correctly but don't prove the *constants* match real Claude Code traffic. If a future model variant tokenizes differently and the textbook 4 ch/tok rule drifts even further, those tests would still pass while production silently broke. Anonymized 6 production shapes from events.jsonl (2026-05-19..2026-05-20): - PRODUCTION_SLAB_161K (multi-col, 52 chars/row, 11 ings/141 lines/2 cols) - PRODUCTION_SLAB_135K_DENSE (neuline-heavy, 19 chars/row, 24 ings) - PRODUCTION_SLAB_169K_HEAVY (very dense, 16 chars/row — actually rejected) - BELOW_MIN_CHARS_TINY (142 chars, pre-filter) - BELOW_MIN_CHARS_BORDERLINE (1123 chars, pre-filter) - HISTORY_COLLAPSED_LONG_SESSION (collapsed path captured) Each fixture stores its real captured decision plus the shape parameters (origChars, numCols, approxCharsPerRow). synthesizeText() pads 'A'.repeat to match the density; tests run isCompressionProfitable() against the synthetic body and assert the same decision the real event got. \#\# Notable: 135K_DENSE divergence The real 135K neuline-heavy event was ACCEPTED (compressed=true) but the synthetic 'A'.repeat(19) reconstruction REJECTS even at cpt=2.5. Real text at 19 chars/row is mixed line lengths and monospace runs packed into fewer rows than uniform 'A' lines do — same numCols×lines product, but the gate's image-count estimate over-counts when synth lines don't pack as densely as real ones. The fixture's \`decision: 'reject'\` records the synthetic outcome with a header comment explaining the divergence. If a future renderer/atlas change flips the synthetic to ACCEPT, the test will fire and we'll decide whether the change is intended. \#\# Export changes SLAB_CHARS_PER_TOKEN and HISTORY_CHARS_PER_TOKEN exported from transform.ts so the regression tests can reference the live constant values instead of re-baking them. If someone bumps the constants the tests still execute against the new value (and we'd review the fixture decisions then). \#\# Tests 8 files / 280 tests pass, typecheck clean, build clean. 7 new it() blocks cover the 5 captured shapes + the synthetic divergence note. |
||
|
|
3c759ed662 |
docs(README): cite Opus 4.7 vision upgrade as what makes this possible
Pre-4.7 vision couldn't reliably OCR dense monospace glyphs - errors would corrupt the prompt before the model read it. Opus 4.7 bumped the long-edge image cap 1568->2576px (3.3x pixels) and document-OCR benchmarks (DocVQA 87->94%, ChartQA 80->88%) per Anthropic's release notes. Pixelpipe still renders at 1568x1568 - this is a model fidelity change, not a renderer change. But it's why this approach works now when it didn't a year ago. Cites anthropic.com/news/claude-opus-4-7. |
||
|
|
ea32dd2ed5 |
fix(gate): discriminate raw opts to fix override-gate default-value collision
The override-gate previously used `o.charsPerToken !== CHARS_PER_TOKEN` to detect "host pinning". After CHARS_PER_TOKEN=4 was replaced with slab/history-specific 2.5 constants, a host that deliberately passes the literal `4` (the same as DEFAULTS) was silently swapped to 2.5 - the gate couldn't distinguish "host passed nothing" from "host passed 4". Fix: check the raw opts (\`opts.charsPerToken !== undefined\`) instead of comparing the merged value against a sentinel. Host-supplied `4` now flows through as `4`, even though it matches the default. Tests pin both directions: - slab path (render.test.ts): explicit cpt=4 keeps the 161k slab REJECTED at cpt=4 (would be ACCEPTED at the built-in 2.5) - history path (history.test.ts): explicit cpt=4 keeps a 14-turn closed conversation REJECTED at cpt=4 (would collapse at the built-in 2.5), with companion cpt=2.5 test proving the fixture actually straddles 275 tests pass, typecheck clean, build clean. |
||
|
|
3173e8ad92 |
docs(README): drop Opus 4 column from rate table
Proxy targets Opus 4.7. Input pricing has been flat across Opus 4.5/4.6/4.7 ($5/$25 per MTok), so the historical Opus 4 column ($15/$75) was framing noise unrelated to actual cost. |
||
|
|
699ce3023f |
docs(README): explain history compression (Variant C)
~80-line section covering the 4-breakpoint cache cliff that history
collapse addresses, the closed-prefix walk, the 4 honesty gates, and
today's HISTORY_CHARS_PER_TOKEN=2.5 fix wired in
|
||
|
|
d65cc1bf5c |
chore(deps): bump wrangler 3.114.17 → 4.92.0
Addresses GHSA-vmjp-67rh-pgjc (undici via wrangler <4.x). Major jump 3 → 4, but wrangler.toml schema unchanged (compatibility_date, compatibility_flags, vars all still valid) and build/typecheck/test all clean. Held at 4.92.0 (4.93.0 is inside the 3-day minimumReleaseAge gate). |
||
|
|
01037ed0a5 |
fix(deps): actually track pnpm-lock.yaml
Earlier commit 26fbb67 "swap to pnpm-lock.yaml as source of truth" untracked package-lock.json but pnpm-lock.yaml never made it in — a global ~/.gitignore excludes pnpm-lock.yaml across all repos. The lockfile was silently un-tracked the whole time. Fix: add !pnpm-lock.yaml to the repo's .gitignore to override the global rule, then stage the lockfile. Verified: git check-ignore now reports .gitignore:11:!pnpm-lock.yaml. |
||
|
|
b14a7d981b |
chore(deps): bump vitest 1.6.1 → 4.1.6 (+ explicit vite 8.0.13)
Addresses GHSA-9crc-q9x8-hgqq, GHSA-jcfh-c3jc-r2q6 (vitest <2.1.9). Vitest 4 requires Vite 6+, so add vite as an explicit dev dep (was previously transitive). 273 tests still pass clean. |
||
|
|
d309914d8f |
chore(deps): bump esbuild 0.21.5 → 0.28.0
Addresses GHSA-67mh-4wv8-2f99 (esbuild dev server allowed any website to send requests and read responses, patched in ≥0.25.0). Dev-only — we use esbuild as the bundler in scripts/build.mjs, never as a server. Build, typecheck, and 273 tests all clean. |
||
|
|
9288c99b5c |
chore(deps): add minimumReleaseAge=3d to pnpm-workspace.yaml
Refuses to install any package version published less than 3 days ago. Catches the typical malicious-package lifecycle (publish → discovered → unpublished within hours/days). Legitimate security patches also wait 3 days; deliberate trade-off for a manually-updated repo. |
||
|
|
b2d24f242b |
chore(deps): swap to pnpm-lock.yaml as source of truth
The repo uses pnpm (packageManager pinned in package.json, pnpm-lock.yaml tracked, pnpm-workspace.yaml present) but package-lock.json was also tracked from an earlier npm install. Dependabot reads stale npm metadata from it that doesn't match what pnpm actually installs. - Untrack and delete package-lock.json - Add it to .gitignore so a stray npm install can't reintroduce it |
||
|
|
8cdda62779 |
docs(README): name every input bucket the proxy touches
Previous version only listed system+history+tool_result. Real input shape also includes: - tools field (built-in schemas + every MCP server's tool definitions - usually the largest single bucket; grows with each MCP server wired up) - skills loaded into the system prompt (SKILL.md per skill) - subagent definitions - <system-reminder> blocks Claude Code injects into user messages Headline math unchanged - all of these land in the same static slab or closed-prefix history that pixelpipe image-compresses. |
||
|
|
b563751cf6 |
fix(gate): history call site uses empirically-grounded cpt=2.5 (was 4)
Production telemetry (N=10 events with history_reason="not_profitable", 2026-05-18..05-20) shows body-level chars-per-token on rejected history events: median 1.09, max 1.10. The gate at cpt=4 was estimating text as 3.7× cheaper than reality on every history-collapse decision in the sampled traffic — same shape of bug as the slab path fixed in |
||
|
|
c36ec3fd39 |
fix(gate): slab call site uses empirically-grounded cpt=2.5 (was 4)
Production data (N=354 cold-miss count_tokens probes, 2026-05-18..05-20):
real body-level chars-per-token is 1.17 median, 2.62 max — never near
the English-prose 4 baked into CHARS_PER_TOKEN. The gate has been
estimating text-token cost at 3.4× cheaper than reality and silently
rejecting every realistic Claude Code system slab.
Concrete production case (orig_chars=161,101, multi-col=2):
• OLD gate (cpt=4): text = 161101/4 = 40,275 tok
image = 8 × 5500 = 44,000 tok
→ reject (margin: -3,726)
• NEW gate (cpt=2.5): text = 161101/2.5 = 64,440 tok
image = 8 × 5500 = 44,000 tok
→ ACCEPT (margin: +20,440)
• Reality (cpt=1.17): text = 137,694 tok, savings = 93,694 tok/request
SLAB_CHARS_PER_TOKEN=2.5 is the upper bound of observed cpt across the
sample — picking the upper bound keeps the prime-directive guarantee
intact: the text-token estimate is a LOWER bound on real text cost, so
any `imageCost < textTokens` decision is also `imageCost < realTextCost`
for any future workload with cpt ≤ 2.5.
Scope: this is slab-specific. Reminders and tool_result content have
unknown shape (could be raw English prose with cpt~4), so those gate
call sites still use the conservative CHARS_PER_TOKEN=4. Host can
override per-request via TransformOptions.charsPerToken (e.g., to plug
a live empirical fit in front of the static default).
Tests: 2 new regression tests pinning (a) the production-shape 161k slab
compresses end-to-end without an explicit cpt override, and (b) the gate
math at cpt=2.5 vs cpt=4 produces accept-vs-reject on the same input.
272 tests pass, typecheck clean, build clean.
|
||
|
|
e433b26caa |
feat(transform): lossless whitespace compactor on slab + reminder + tool_result
Production data (2026-05-20): real 161,101-char system slab being rejected by the row-aware profitability gate as `not_profitable`. multiCol=2 packs 282 rows/image, but 2000+ newline-bounded lines need ~10 images, and at 2500 tok × 10 × 1.10 = 27,500 tok the gate (correctly) refuses since the text baseline is cheaper. Fix: strip per-line trailing whitespace and collapse 3+ consecutive newlines to exactly 2 *before* feeding the renderer. Both transforms are lossless for the model (whitespace at line-end carries no signal; blank- line runs longer than 2 are visual noise). This drops visual-row count without changing meaning, flipping borderline-rejected slabs profitable. Accounting uses raw (pre-compaction) length for `origChars` and `compressedChars`, so the savings credit reflects what Anthropic would have billed — not the post-compaction figure. Applied at four sites: - combined static slab (system + tool docs) - system-reminder block - tool_result string content - tool_result array-of-blocks (joined text) 8 new unit tests for `compactSlabWhitespace`: empty input, trailing whitespace stripping, 3+ newline collapse, paragraph-break preservation, indent preservation, idempotency, realistic markdown shrink. 270 tests pass, typecheck clean, build clean. |
||
|
|
59a416cb09 |
feat(history): always-on history compression + fix gate-vs-renderer asymmetry
Rip the compressHistory opt-in and run Variant C history-image collapse on
every request that has a closed-prefix run. There is now ONE codepath: no
toggle, no env switch, no per-request opt-in. The reason the option was
defaulted-off was a -250% live measurement on 2026-05-19 (commit
|
||
|
|
178fd4af6f |
fix(gate): scale per-image token cost by numCols (close multi-col loss-risk)
Production runs multiCol=2 by default. The pre-flight gate
`isCompressionProfitable()` was using a flat TOKENS_PER_IMAGE=2500
constant at every numCols, so it believed multi-col images were ~2x
cheaper than they actually are and would let net-loss compressions
through on wider canvases.
Replace the flat constant with `effectiveTokensPerImage(numCols)`:
numCols=1: 2500 (empirical, N=33 cold-miss measurement)
numCols>1: 2500 * n * 1.10 (linear extrapolation + 10% margin)
The 10% margin absorbs extrapolation noise — we don't yet have
empirical cost measurements at numCols>=2. Per user constraint
"ok pre-flight gate is ok to be not correct as long as we don't
lose money," every uncertainty resolves in favor of pass-through.
At worst we miss opportunities; we never burn money on net-loss
images.
Test fixtures bumped to match the new break-even (~22k chars at
n=2). Two obsolete cpt-flip tests REWRITTEN to assert the safe
behavior: cpt override flips at numCols=1, but at numCols=2 the
safety margin holds and the slab stays rejected.
Old break-even at default cpt=4:
n=1: ~10k chars (1 image @ 2500 tok == 10k/4 = 2500 text tok)
n=2 [old/wrong]: ~10k chars (same flat cost)
n=2 [new/safe]: ~22k chars (1 image @ 5500 tok == 22k/4 text tok)
Verified: 263/263 tests pass, typecheck clean, build clean.
|
||
|
|
dfd3bf514f |
feat(proxy): cache-aware baseline via second count_tokens probe
The baseline_tokens probe alone reports the COLD cost of the unproxied
body — but the user's request actually carries `cache_control` markers
that would have cached on the unproxied path too. Comparing cold-
baseline against warm-actual was an apples-to-oranges inflation of
saved%.
Fix: fire a SECOND count_tokens probe on the body TRUNCATED at the last
`cache_control` marker. Difference between the two is (cacheable_prefix,
cold_tail). Dashboard weights the prefix by the SAME cache class the
actual response landed in:
cacheable = baseline_cacheable_tokens or 0
cold_tail = baseline_tokens − cacheable
weight = cr>0 ? 0.10 (warm hit — both paths cache)
: cc>0 ? 1.25 (cold create — both paths build cache)
: 1.0 (no caching — marker rejected or absent)
baseline_input_eff = cacheable·weight + cold_tail·1.0
actual_input_eff = input + cache_create·1.25 + cache_read·0.10
saved = baseline_input_eff − actual_input_eff
Now the headline number is the exact unproxied counterfactual, not a
cold-every-time approximation. No estimation, no α, no assumptions —
every input is a measurement.
Wiring:
- src/core/proxy.ts: buildCountTokensCacheablePrefix() walks messages
backward → system backward → tools backward (reverse cache order) for
the last cache_control marker, truncates, attaches a sentinel user
message when needed. Second probe fires in parallel; null/missing
leaves the field absent.
- src/core/transform.ts + tracker.ts: new baselineCacheableTokens /
baseline_cacheable_tokens fields, emitted only when > 0.
- src/dashboard.ts: per-event cache-aware math in update() and
replay(); Totals renamed to baselineInputWeighted; /proxy-stats
payload field is `baseline_input_weighted`.
- src/sessions.ts: per-session tokensSavedEst uses the same formula so
session and global rollups agree.
Tests: 2 new in proxy-usage covering (a) body with cache_control →
two probes fire with different bodies and both numbers land on info,
(b) cacheable-prefix probe failure doesn't poison the full probe.
260→262 tests, all green; typecheck and build clean.
|
||
|
|
424f84172a | docs(sessions): fix stale field docs that still claimed estimation | ||
|
|
f8902732bb |
feat(dashboard): real saved% from count_tokens, rip α-regression dead code
The headline saved%/saved$ numbers are now MEASURED, not estimated:
baseline = sum of /v1/messages/count_tokens(originalBody).input_tokens
actual = sum of (input + cache_create×1.25 + cache_read×0.10)
saved = baseline − actual
Output is excluded from both sides — it's identical with or without
compression (same prompt → same response), so it cancels in the
numerator and would only drag the percentage toward zero. The proxy
now makes one parallel side call to /v1/messages/count_tokens (free,
no billing) before forwarding /v1/messages, and the result lands on
TransformInfo.baselineTokens → TrackEvent.baseline_tokens → the
DashboardState rollup.
Removed (~280 lines of dead code):
- FALLBACK_ALPHA, FALLBACK_ALPHA_LOW, FALLBACK_ALPHA_HIGH constants
- estImageTokens(), baselineCost(), effectiveCost() helpers
- FitSample interface + FIT_SAMPLE_CAP + fitSamples ring buffer
- DashboardState.fitCosts() (~218 lines of OLS regression)
- Three-mode ladder (joint / constrained / stale) and all UI for it
- saved_pct_low/high, saved_effective_tokens_low/high range fields
- renderSavingsMath() range/regime branches
- m_pct_regime DOM node + its color/text wiring
- cost_fit payload field + all frontend consumers
/proxy-stats payload is now: requests, compressed_requests,
baseline_input_tokens, actual_input_weighted, saved_input_tokens,
saved_pct, saved_usd, pricing_assumptions, uptime_sec,
compression_enabled. Nothing else.
The recent-requests table now shows baseline / actual / saved per
row (input-side, matching the cards) instead of the old img-tok
estimate + effective_actual (which mixed output × 5 in inconsistently).
Sessions panel: per-session tokensSavedEst now uses the same real
baseline−actual formula. Events without both a count_tokens probe
AND an upstream usage block don't contribute to the rollup — no
synthetic 4-char/tok fallback.
Tests:
- dashboard-fit.test.ts: deleted (fitCosts is gone)
- proxy-usage.test.ts: inverted the privacy regression — count_tokens
is now explicitly expected as the one whitelisted side path; added
baselineTokens-attachment and count_tokens-failure-survives tests
- sessions.test.ts: rewrote the two savings tests against the new
baseline_tokens + usage formula
|
||
|
|
1d46b72221 |
rip(proxy): remove count_tokens probe path — leaks original text
Sending the pre-compression body to /v1/messages/count_tokens means
Anthropic sees the original text, which is exactly what pixelpipe is
built to prevent. Probe results were 'free' in dollars but not in
privacy; the whole point of imaging the slab is that Anthropic does
NOT receive the slab text.
Removed:
- countTokensUpstream + buildCountTokensBody in src/core/proxy.ts
- measurePromise wiring + finalize await
- baselineTokensMeasured / actualTokensMeasured on TransformInfo
- baseline_tokens_measured / actual_tokens_measured on TrackEvent
- measuredEvents / effectiveCostActualMeasured / *Baseline*
fields on DashboardState
- measured_events / saved_pct_measured / effective_cost_*_measured
/ saved_effective_tokens_measured on /proxy-stats payload
- HTML headline branch + 'show calculation' rows for measured path
- 2 dashboard-fit tests asserting the measurement-path behavior
Added regression test: proxy must NOT hit any upstream path other than
/v1/messages.
Real-savings path now: self-derive α from billed usage + own char
walker. Coming next.
|
||
|
|
e5bad22cd5 |
fix(proxy): strip non-count_tokens fields before probing
count_tokens accepts only model/messages/system/tools/tool_choice/thinking/ mcp_servers. Forwarding the verbatim /v1/messages body included stream, max_tokens, temperature, metadata etc, which the endpoint rejected with 400. Probe returned null silently → measured_events stuck at 0. Strip to the accepted fields before posting. Now both probes succeed, the dashboard headline can use saved_pct_measured = (Σ baseline − Σ actual) / Σ baseline — exact, no regression, no priors. Diagnostic logging kept on the probe path: failures surface in stderr instead of disappearing. |
||
|
|
00b7c80e84 |
feat(dashboard): runtime passthrough toggle + consistent range UI
Three changes addressing operator feedback after seeing the math panels:
1. Passthrough kill switch (the main ask). When upstream is unhealthy
or the proxy is misbehaving, the operator needs to disable
compression instantly without restart. Adds:
- DashboardState.compressionEnabled (in-memory, defaults true)
- POST /api/compression {enabled: bool}
- compression_enabled field on /proxy-stats
- Red banner "PASSTHROUGH MODE" + toggle button at top of dashboard
- node.ts transform fn returns {compress:false} when off, so the
proxy forwards /v1/messages unchanged to upstream
- In-memory only — restart resets to enabled. No CLI flag, no env
var. Runtime control via dashboard.
2. $ saved card now shows a range when one is available, mirroring
the % card. Previously the % was "0.3%–11.7%" but $ was just
"$0.2569" — visually inconsistent. New behavior: compute usdLo/usdHi
from saved_effective_tokens_{low,high} × input_per_mtok; show range
when spread ≥ 1¢, point otherwise. Both cards now feel like the
same headline.
3. Math-panel jargon replaced with plain English. The previous text
said "p10/p90 of per-sample α across fit ring" — incomprehensible
without statistics background. Now reads "low/high bounds from
recent traffic's chars-per-token spread" on both savings-tokens and
reduction-% panels.
Tests: 277/277. Typecheck + build clean.
|
||
|
|
c3506e3a2c |
fix(transform): flip compressHistory default to false
Round-3 measurement on 2026-05-19 with 128-turn history showed the feature loses money: 21K chars of text replaced with 140K tokens of imagery, net -250% savings per request on this traffic shape. The MARGINAL warning has been in the code comments since the feature shipped - it was opt-in via --history / COMPRESS_HISTORY=1 originally for exactly this reason. The recent flag-removal commit flipped it on as default without re-verifying the savings claim against current traffic. The math in baselineCost() correctly reports negative savings when imgTokensEst > txtReplaced, so the dashboard was honest about the loss - the bug is shipping the feature on by default before measuring a positive delta on representative traffic. Re-enable per-deployment via env var when measurement confirms a positive saved_pct on the specific shape. |
||
|
|
328ea7e833 |
feat(dashboard): inline math panels under every savings card
Each headline card (tokens saved / $ saved / reduction %) gains a
collapsible "show calculation" panel that lays out the exact formula
plus the live numbers feeding it. Every digit on the headline can be
derived from what the panel shows — no estimation hidden behind a
black box.
Panels:
- tokens saved: actual_paid, baseline_est, saved formula
weights: input ×1.0, cc ×1.25, cr ×0.10, output ×5.0
- $ saved: formula = saved_tokens × input_per_mtok / 1e6,
exposes pricing_assumptions (rate ratios + source URL)
- reduction %:
- measured path (when count_tokens succeeded on ≥1 event):
baseline_tokens, actual_tokens, saved, saved_pct, n samples
labeled "ground truth, no estimation"
- estimated path (no measured events yet):
α-regression baseline, actual, saved, range from p10/p90
labeled "estimated · α-regression from cold-miss events"
- image-cost formula reference: tokens ≈ width × height / 750
Implementation:
- Native <details> elements, no JS framework. summary is a small
blue toggle that opens an inline formula block styled like a code
fence (dark background, monospaced).
- renderSavingsMath(s) is called once per /proxy-stats tick from the
existing fast-tick path. fmtRow builds key-value-note lines so the
three panels share a layout.
- pricing_assumptions block (input_per_mtok, output_multiplier,
cache_*_multiplier, source URL) already exists on /proxy-stats from
the earlier pricing-correction commit; this surfaces it inline.
No new endpoints, no new fields. The transparency comes entirely from
re-using existing /proxy-stats data and laying it out plainly.
Tests: 277/277. Typecheck + build clean.
|
||
|
|
a66a558b55 |
refactor: remove all CLI flags and behavior env vars — single codepath
The proxy used to expose ~20 behavior toggles (--no-compress, --no-tools, --no-schemas, --no-reminders, --no-tool-results, --history, --no-history, --history-keep-tail N, --history-min-prefix N, --min-chars N, --min-reminder-chars N, --min-tool-result-chars N, --cols N, --multi-col N, --no-track, --events-file PATH, --port N, --upstream URL) plus matching env vars. Each one was a dead branch in the common case and a configuration knob nobody had a principled reason to pick a value for. Replaced with: exactly one way to run the proxy. Every compression mode on, every tuning parameter at its measured-best value, history compression always active (was opt-in dead code). The only adjustable surface is deployment concerns — where to listen, what to forward, where to log — env-var only: PORT default 47821 ANTHROPIC_UPSTREAM default https://api.anthropic.com PIXELPIPE_LOG default ~/.pixelpipe/events.jsonl CLI accepts only --help and --version. Anything else exits with non-zero and a one-line error. Notable behavior change: - DEFAULTS.compressHistory flipped false → true. Variant-C history- image compression now runs on every request (was opt-in via --history / COMPRESS_HISTORY=1). The static-slab cache_control placement is on the system image, not on the history-replacement chain, so the published cache-topology risk does not apply. Touched: - src/node.ts: CliOpts → RuntimeConfig (3 fields). parseCli is now ~25 lines. printHelp reduced to env-var-only doc. - src/core/transform.ts: DEFAULTS.compressHistory = true. Comment updated. - scripts/restart.sh: drops PROXY_ARGS array entirely. Port read from PORT= env var. Unknown args rejected. - tests/restart.test.sh: replaces flag-passthrough test with a reject-unknown-args test. 4/4 pass. - tests/history.test.ts: renames the "compressHistory:false (default)" test to reflect the new always-on default; asserts the input-not- mutated invariant remains. - README.md: configuration table reduced to 3 env vars; restart examples drop CLI flag passthrough. Tests: 277/277. Typecheck + build clean. Restart script tests: 4/4. |
||
|
|
c4aa5af342 |
refactor(proxy): remove measureSavings flag — count_tokens always runs
The measurement code was previously gated behind a measureSavings flag (--measure-savings CLI, PIXELPIPE_MEASURE_SAVINGS env var, optional ProxyConfig field). That's a knob nobody asked for and a second codepath whose only purpose is to leave the dashboard reporting estimates instead of ground truth. The flag is wrong for this codebase: - Single codepath is the convention. The proxy either measures or doesn't — there's no useful "sometimes" mode. - count_tokens is free, runs in parallel with the main upstream forward, and doesn't add user-visible latency. - An opt-in default means the headline dashboard number stays as an estimate forever in 99% of deployments — defeats the whole feature. Removed: - ProxyConfig.measureSavings field - CliOpts.measureSavings field - --no-measure-savings CLI handler - PIXELPIPE_MEASURE_SAVINGS env var - The if (config.measureSavings) wrapper in proxy.ts - Help-text entry - Stale comment references in tracker.ts, transform.ts, dashboard.ts The proxy now unconditionally fires count_tokens on both bodies for every /v1/messages request. Failure is silent — when count_tokens returns null the event still emits without the measured fields and the dashboard falls back to the regression estimate per existing fallback logic. Tests: 277/277. Typecheck + build clean. No behavioral test changes; the existing measurement-path test exercises the synthetic event shape directly, not the flag. |
||
|
|
6f0d1a9914 |
feat(proxy): ground-truth saved_pct via count_tokens on both bodies
Replace estimation with measurement. Anthropic's /v1/messages/count_tokens
returns the exact tokenizer output for any request body, free of charge.
Calling it on the pre-transform body and the post-transform body gives
the EXACT delta in input tokens, with no α/β regression, no fallback
constants, no uncertainty band needed.
Opt-in (default off, since it adds two parallel HTTP roundtrips per
/v1/messages — count_tokens itself is free but the latency is real):
CLI flag: --measure-savings
Env var: PIXELPIPE_MEASURE_SAVINGS=1
When enabled the proxy fires both count_tokens calls in parallel with
the main upstream forward (no critical-path latency hit) and the event
is held for emit until both probes complete. Failures are silent — the
event still emits without the measured fields and the dashboard falls
back to the estimate.
Per-event new fields (events.jsonl):
baseline_tokens_measured exact pre-transform input tokens
actual_tokens_measured exact post-transform input tokens
Dashboard:
/proxy-stats gains: measured_events, effective_cost_actual_measured,
effective_cost_baseline_measured, saved_effective_tokens_measured,
saved_pct_measured. All null when no measured events exist; non-null
once ≥1 lands.
HTML headline switches to the measured number whenever measured_events
≥ 1. Subtitle reads "measured · count_tokens on N requests" to make
the source explicit. Falls back to the estimated range otherwise.
Measurement math:
delta_tokens = baseline_measured - actual_measured
bill_rate = (cc/total)·1.25 + (cr/total)·0.10 [same cache mix
as the actual call]
baseline_eff = actual_eff + delta_tokens · bill_rate
saved_pct_measured = (baseline_eff - actual_eff) / baseline_eff × 100
No α. No β. No FALLBACK_*. The numerator (delta_tokens) is the exact
output of Anthropic's tokenizer on both bodies; the multiplier is the
billed cache mix that already happened.
Tests: 275 → 277 (+2). Ground-truth path: 3 events with known token
deltas yield the expected saved_pct_measured. Fallback path: when no
event carries measurement, all saved_*_measured fields are null.
Typecheck + build clean.
|
||
|
|
5b5f48919d |
fix(dashboard): correct pricing/image constants from docs.anthropic.com
Audit against published Anthropic docs (pricing + vision pages,
verified 2026-05-19 via WebFetch) found three constants in this file
that were either wrong or hand-picked without citation. Replace them
with documented values and add a PROVENANCE block so the lineage is
auditable.
Corrections:
1. saved_usd headline rate: $15/Mtok → $5/Mtok.
Opus 4.7 base input rate per docs is $5/Mtok, not $15. The old
value was Opus 3 pricing repurposed by the field name "_opus47".
The headline dollar number was therefore overstated by 3× on any
Opus 4.x deployment. Renamed field to saved_usd_estimated and
exposed pricing_assumptions{input_per_mtok, multipliers, source}
on /proxy-stats so the operator can verify what we multiplied by.
2. Per-image token cost: drop fixed OPUS_IMAGE_TOKEN_COST=2500 in
favor of the published formula tokens ≈ width × height / 750
(docs-vision). estImageTokens now prefers (in order): live β fit
→ published 1/750 → FALLBACK_IMAGE_TOKEN_COST (=1690, computed
from the formula at our default 808×1568 render). The old 2,500
constant traced to an in-house measurement that didn't reconcile
with the docs; pending a fresh per-renderer-config measurement
under post-walker-fix code.
3. FALLBACK_ALPHA point estimate: 0.25 → 0.33. Opus 4.7's tokenizer
note in [docs-pricing] explicitly states ~35% denser than older
models for the same text. CHARS_PER_TOKEN=4 (the older English
average) is therefore biased for Opus 4.7 traffic; 3 chars/tok
matches the documented behavior. The low/high brackets (0.15 /
0.50) span both tokenizer regimes and are left alone.
Added:
- PROVENANCE comment block at the top of the pricing/image-cost
section citing the three doc URLs and the verification date, with
each downstream constant footnoting back to one of the labels.
- pricing_assumptions object in /proxy-stats payload exposing every
multiplier and the source URL.
- Test pinning the pricing_assumptions shape and the
saved_usd_estimated = saved_tokens × input_per_mtok / 1e6 identity.
What this does NOT yet correct:
- Live α and per-renderer β still require fresh measurement post-
walker-fix. The fallback brackets keep saved_pct conservative
until the regression converges.
- 1-hour cache writes (2× input) are exposed in pricing_assumptions
but not yet distinguished from 5-minute writes in the cost math —
needs an upstream signal we don't currently capture per-event.
Tests: 274 → 275 (+1). Typecheck + build clean.
|
||
|
|
d02a0f9baf |
feat(dashboard): fold output tokens into saved_pct (full-bill framing)
effectiveCost() previously summed only input + cache_create·1.25 + cache_read·0.10, leaving output tokens out of the cost total. The headline saved_pct was therefore "% saved on the input-side bill" rather than "% saved on the full upstream bill." Output is small in absolute terms on typical Claude Code traffic (≈0.4% of cost in the sample events.jsonl), but the framing is what matters. Changes: - effectiveCost gains an outputTokens parameter and sums output × OUTPUT_TOKEN_RATE (=5.0, the Opus/Sonnet output:input rate ratio). - Totals fields renamed effectiveInput* → effectiveCost* to match the new full-bill semantics. Same on the JSON wire: effective_input_* → effective_cost_*. - update() now also passes the event's output_tokens into effectiveCost so both actual and baseline grow consistently. - replay() effective_actual computation includes output_tokens too, so historical rows align with the live formula. - Headline subtitles updated to "share of total bill saved" and "effective tokens (full bill)" so the unit is self-documenting. Math invariant: output is identical in actual and baseline (the model produces the same response regardless of prompt compression), so it cancels in the numerator saved = baseline - actual but inflates the denominator. saved_pct therefore moves SMALLER and more conservative when output is non-trivial — honest whole-bill terms. New test pins the invariant: two identical input-side runs with 100× different output_tokens give the SAME saved_effective_tokens but strictly different saved_pct, with the higher-output run lower. Tests: 273 → 274 (+1). Typecheck + build clean. |
||
|
|
e27c19c2a3 |
feat(transform): live-α charsPerToken closes not_profitable max-savings leak
The break-even gate (isCompressionProfitable) used a hard-coded CHARS_PER_TOKEN=4 (Anthropic's English avg). Live Claude Code traffic has α≈0.5-0.9 (1.1-2 chars/tok) — JSON-dense tool docs, structured CLAUDE.md slabs, etc. Off-by ~3.5× on the text side at α=0.88. Production trace (events.jsonl, 2026-05-19): 200+ requests with system slabs 113K-170K chars hit reason="not_profitable" because the gate's textTokensEq = len/4 was a fraction of the row-aware image-token cost. Example event: slab=169_632 chars, cache_create_tokens=148_891. Gate's textEq=42K rejected vs imgCost=127K. With live α (1.14 ch/tok), textEq=149K → ACCEPT. We were leaving real cache_create money on the table on every one of those calls. - TransformOptions gains charsPerToken?: number. Default 4 (back-compat). - isCompressionProfitable() takes charsPerToken as a 5th optional param; defensive clamp on ≤0 / NaN / Infinity falls back to CHARS_PER_TOKEN. - All 4 internal gate call sites (static slab + reminder + tool_result string + tool_result array) thread o.charsPerToken through. - ProxyConfig.transform now also accepts a function: () => TransformOptions. Resolved per-request inside createProxy so the host can inject dynamic values. Static-object form unchanged for Workers / tests. - node.ts wraps the static transform options in a closure that asks dashboardState.fitCosts() each request and passes the live chars_per_token. Fit-null falls through to the baked-in default — no chicken-and-egg deadlock since normal-sized slabs still feed the fit ring even before the huge-slab cases get unblocked. - 4 new gate tests: stale-default rejects the 170K newline-heavy slab; live α=1.14 flips the same slab to profitable; defensive clamp on bogus charsPerToken; end-to-end transformRequest passes through. Tests: 269 → 273 (+4). Typecheck + build clean. |
||
|
|
9c860f02dd |
feat(dashboard): sessions bulk select + delete (Gmail/GitHub pattern)
The sessions table previously only supported per-row deletion via the "del" button — no way to clear many at once. Add an industry-standard multi-select pattern. Pattern follows Gmail / GitHub / Linear / Vercel: - Leftmost cell on each row is a checkbox; data-id binds to session id. - Header checkbox is tri-state (empty / indeterminate / all-on-page) driven by a single source of truth (selectedSessionIds: Set<string>). - Shift-click on a row checkbox does range-select from the last-clicked anchor to the current row, applying the new checked state to every row in between (macOS Finder / Gmail convention). - Contextual action bar slides in above the table when ≥1 row is selected, showing "N selected · Delete selected · Clear (Esc)". - Confirm modal shows count + first 3 session id prefixes + "and N more" for sanity-check before destructive action. - Esc key clears the selection (no-op when an input is focused). - Selection is keyed on session id and reapplied after each diff render — innerHTML rewrites no longer clobber checked state. - Vanished sessions auto-drop from selection. Backend: - PruneOptions gains sessionIds?: string[] alongside the existing single sessionId. Unknown ids are silently skipped (race-safe with concurrent prunes). Composes with sessionId if both are set. - handlePrune plumbs sessionIds through; the bulk-delete action calls /api/sessions/prune once with all ids. - 3 new selectSessionsToRemove tests: bulk select, unknown-id skip, sessionId+sessionIds composition. Tests: 266 → 269 (+3). Typecheck + build clean. |
||
|
|
0a25aeb51d |
feat(dashboard): honest saved_pct uncertainty band (saved_pct_low..saved_pct_high)
The headline used to publish a precise saved_pct (e.g. "68.2%") even
when α was thin / fallback. Switch to a range whenever uncertainty is
wide enough to matter — point estimate only when the spread is tight.
- fitCosts() returns alpha_low / alpha_high (and chars_per_token bounds)
from p10/p90 of per-sample residual α = (tokens - β·pixels) / chars
across the fit-sample ring. For n≤3 uses min/max; for larger n uses
10th/90th percentile. Order-clamped against the central point so a
noisy sample can't put low > point or high < point.
- baselineCost() gains an optional alphaOverride knob that varies ONLY
the chars-per-token side while keeping the image-cost regime stable
(β·pixels when fit is present, 2500/img fallback otherwise). Without
this knob, an earlier draft mixed regimes and produced
saved_pct_low > saved_pct — silently incoherent.
- Totals tracks three parallel baselines: effectiveInputBaselineEst
(point), …Low, …High. Every event updates all three.
- /proxy-stats exposes saved_pct, saved_pct_low, saved_pct_high plus
the matching effective_input_baseline_est_{low,high} and
saved_effective_tokens_{low,high}. Bounds clamped ≥ 0.
- HTML headline collapses to point estimate when (high − low) < 5 pp;
otherwise renders "lo–hi%" as the headline with point as subtitle.
- When fit is null (n<3), wide fallback brackets (0.15 / 0.25 / 0.50)
produce a deliberately wide range so the headline shows a calibrating
state instead of a fake-precise point estimate.
- 5 new tests: alpha_low ≤ alpha ≤ alpha_high invariant, chars/tok
bound inversion, serveStats bound monotonicity, null-fit range shape,
and warmup-baseline ordering.
Tests: 261 → 266 (+5). Typecheck + build clean.
|
||
|
|
17b77c7492 |
fix(transform): count tools[], tool_use, thinking blocks in outgoingTextChars
The walker was missing three big text-bearing paths the upstream tokenizer sees: top-level `tools[]` (name + description + JSON schema), `tool_use` blocks (name + serialized input), and `thinking` blocks. Under-counting these inflates α in `tokens ≈ α·textChars + β·pixels`, which biases the dashboard's `saved_pct` HIGH — the honesty cost HANDOFF item #1 called out. - Walker now sums: system + tools[].{name,description,input_schema} + per-block text/tool_use.input/tool_result.{tool_use_id,content}/ thinking. JSON.stringify via safeStringifyLen() for object fields so the count matches what the API actually serializes. - Lift the walker call out of the compression-success path: also run it on the below_min_chars and not_profitable early returns. Without this, α was sampled only from "big enough to compress" requests. - New `outgoingTextChars walker (denominator honesty)` describe block in render.test.ts: 7 tests covering baseline, tools[], tool_use, tool_result (string + array forms with image skip), thinking, unknown-block skipping, and a JSON.stringify(req).length upper bound asserting walker ≥ 50% of envelope on a content-dense request. - Confirms walker stays strictly below the JSON envelope (structural keys/braces still excluded — that's intentional). Tests: 254 → 261 (+7). Typecheck + build clean. |
||
|
|
269ae4503a |
dashboard: drop constrained CV gate to 0.1% + surface text_cv on badge
Live diagnosis: real steady-state single-session traffic has 1-2% text
CV turn-to-turn (verified 2026-05-19: six consecutive turns at 132,738
/ 133,021 / 133,610 / 134,105 / 134,718 / 138,097 chars = 1.33% CV).
The previous 2% gate I'd set was still too tight — the constrained fit
never activated, dashboard stayed on stale-constants regime indefinitely.
The CV gate was over-protective. With β pinned, α = Σ(r·x)/Σ(x²) is
mathematically well-defined and numerically stable at any cvX > 0. The
gate only matters for splitting two collinear parameters (joint mode).
For constrained mode I'm replacing the gate with a confidence display:
- MIN_CV_CONSTRAINED = 0.001 (0.1% — purely defensive against
byte-identical sample collapse)
- text_cv + pixels_cv added to cost_fit JSON response
- badge surfaces 'text CV X.X%' so operator sees confidence directly
- constrained-mode badge color tracks CV:
blue = robust (CV ≥ 5%)
amber = thin but measured (1% ≤ CV < 5%)
gray = mean-ratio estimator (CV < 1%)
This is the right shape: don't gate, show the number. The operator can
judge whether '38% saved at text CV 1.3%' is trustworthy enough.
Tests:
+ new: 6-sample 1.33% CV fixture activates constrained mode + reports
cv fields
+ replaced: 'below 2% floor rejects' → 'byte-identical (cv=0) rejects'
unchanged: joint mode + collinear-pixels constrained mode
|
||
|
|
91c969b593 |
dashboard: split CV threshold — 5% joint / 2% constrained
Live-traffic diagnosis: typical single-session warm-cache load has the same cached image across turns (pixel CV = 0%) AND text variance of 3-4% (verified 2026-05-19: 117k/127k/128k chars across three turns = 3.84% CV). Both columns failed the original 5%-everywhere gate, so the constrained fallback I just shipped never actually activated — the dashboard fell straight through to wandering stale constants. The 5% gate exists to stop joint OLS from splitting two indistinguishable parameters. In constrained mode β is pinned (no split to make), so we're fitting one parameter through the origin — 1-D OLS is numerically stable at much lower variance. 2% is conservative; even 1% would converge. New thresholds: MIN_CV_JOINT = 0.05 (unchanged — both columns must vary) MIN_CV_CONSTRAINED = 0.02 (NEW — text only, β-pinned through origin) Real-world impact: live single-session traffic now activates constrained mode after 3 compressed turns instead of waiting for cross-session variance that may never arrive. Dashboard label flips from amber 'stale constants' to blue 'constrained · α-only' — operator sees a measured α within ~30 seconds of starting a fresh proxy. Tests: + new: live-traffic-shape sample activates constrained mode (3.84% CV) + new: below-2% floor still rejects (no garbage 1-D fits) unchanged: 2-D collinear cases still null, joint-fit at high CV still returns mode='joint' |
||
|
|
9d3984fd03 |
render: multicol gutter divider (light-gray boundary cue for OCR)
Paint a 1-pixel-wide mid-gray (191/255) vertical line in the center of
each gutter at multicol >= 2. Same 'visible whitespace' principle as the
U+2192 tab arrow — surfaces structure that pure whitespace alone leaves
ambiguous, at near-zero pixel-token cost (DEFLATE collapses identical
mid-gray runs to ~5 bytes per gutter).
Why now: the user noted gutters relied on whitespace alone, which is
exactly the OCR-reordering risk HANDOFF flagged for multi-col 2 (lines
322-326). An explicit boundary cue lets us push wider multicol or wider
cols in future without spooking the vision encoder into reading
left-to-right across columns.
Tests:
- byte-determinism (existing test still passes)
- inflate IDAT + assert mid-gray pixels at expected divider X
- numCols=1 path stays byte-identical to renderTextToPngs (deterministic
cache_control story for single-col deployments)
Cost: ink=64 pre-invert → 191 post-invert. Doesn't compete with glyph
ink (which lands at 0). At β=0.001776, the divider's ~1568 pixels cost
~3 tokens total per multi-col image. Trivial vs the OCR-clarity win.
|
||
|
|
3893fa6726 |
dashboard: constrained-fit fallback (β=1/750) + regime label on HTML
When the live OLS can't identify β separately (pixels column collinear because a single cached image dominates the 50-sample ring), fall back to α-only fit with β pinned to Anthropic's published rate. Returns mode='joint' | 'constrained' so the HTML can tell the operator which regime the headline saved_pct came from. Three-state badge under the reduction card: - green 'empirical · joint OLS (n≥10)' = both rates measured, confident - amber 'empirical · joint OLS (n<10, tentative)' = both measured, thin - blue 'constrained · α-only (β pinned 1/750)' = α measured, β=Anthropic - amber 'stale constants (no fit yet)' = pre-fit, treat skeptically Closes the 'wandering ±15pp until the fit fires' gap from HANDOFF: even under single-session traffic the dashboard now shows a measured α rather than oscillating between joint-OLS-degenerate and stale-constants. |
||
|
|
36ce17ebb6 |
dashboard: collinearity guard — refuse to activate fit when α/β unidentifiable
# The user-visible bug
Showed wandering saved_pct from oscillating OLS:
n=4: α=0.509, β=0.00387, saved_pct=20.1%
n=8: α=0.879, β=0.00104, saved_pct=25.0% (same session, 4 more samples)
# The root cause
A single Claude Code session sends warm hits with the SAME cached image
on every turn → `pixels` column has ZERO variance across samples. The
regression `tokens ≈ α·text + β·pixels` only identifies α and β
SEPARATELY when both columns vary. With pixels constant the model
degenerates to `tokens ≈ α·text + c` where c = β·p̄ — OLS distributes
the per-sample cost between α and β however the residual noise pushes,
and the split flips wildly as samples accumulate.
The det != 0 numerical check missed this because text has tiny non-zero
variance, so the matrix isn't EXACTLY singular — just near-singular,
which is enough for the headline to wander by ±15 pp.
# The fix
Add a coefficient-of-variation guard. Both columns must have CV (stdev
÷ mean) > 5% before fitCosts() returns non-null. Below that, the fit
is statistically meaningless and we fall back to the stale-constants
baseline. Better to show a stable known-wrong number than an unstable
unidentifiable one.
This is the OPPOSITE direction from the earlier 'cache_read===0' gate
relaxation (which was the right call — warm hits DO contribute to the
sample ring), but at a different layer:
- Gate at sample ingestion: liberal (any compressed request with
full usage triple)
- Gate at fit ACTIVATION: strict (require non-collinear samples)
# Known remaining biases (HANDOFF documents)
1. `outgoing_text_chars` undercounts — doesn't include `req.tools`
text. Measured slope α≈1.1 across collinear-pixels samples implies
~0.9 chars/token, vs Opus 4.7 reality 2.7-3.6. Probably ~half the
tokenized chars are missing from our walker.
2. Even with #1 fixed, β identification needs cross-session variance
(samples with different cached images). Either wait for cache TTL
expiry, drive multi-session traffic, or add a constrained-fit
fallback that pins β = 1/750 (Anthropic's published rate) and
solves α only.
# Tests
tests/dashboard-fit.test.ts: replaced the warm-hit-with-constant-pixels
test (which passed only because the old code had no CV guard) with two
new tests:
- returns null when pixels column is constant (single-session case)
- returns null when text_chars is constant (mirror case)
- activates the fit when both columns vary > 5% (cross-session-style
samples reconstruct α≈0.286, β≈1.5e-3 to within ~10%)
248 tests pass (up from 246).
# Milestone status
The dashboard is now HONEST (refuses to claim numbers it can't
measure) but NOT yet CALIBRATED (still on stale constants until cross-
session samples land). HANDOFF.md captures the path to calibration.
|
||
|
|
f14a159530 |
dashboard: drop the bogus 'cache_read===0' fit-sample gate
The earlier gate (`cache_read === 0 && cache_create > 1000`) was wrong.
Anthropic's tokenizer is deterministic on the request bytes — cache state
changes BILLING (cache_read is discounted), not token count. The total
tokenization of the request body is always:
total_tokens = input + cache_create + cache_read
…regardless of how it was split across cached/uncached portions. So warm
hits carry the same body-token information as cold misses; the regression
`tokens ≈ α·text_chars + β·pixels` is solvable from either.
The old gate locked the fit out of all normal traffic: warm hits ARE the
steady state once a Claude Code session has run a few turns. The user
correctly noticed that cost_fit stayed null forever even with the proxy
processing real requests, because every request after the first hits the
prompt cache.
# The fix
- Remove the `cache_read === 0` requirement.
- LHS becomes `input + cache_create + cache_read` (was `input + cache_create`).
- Keep the 1000-token floor (filters trivial no-system requests where
tokenizer overhead dominates).
- Rename `coldMisses` / `ColdMissSample` / `COLD_MISS_CAP` → `fitSamples`
/ `FitSample` / `FIT_SAMPLE_CAP` since the samples are no longer cold-
miss-gated.
# Collinearity guard already handles the single-session case
The OLS no-intercept solver uses det = sxx·syy − sxy². With pixels held
constant at p̄ (same cached image across a session's warm hits) but
text_chars varying turn-to-turn, det = p̄²·(n·Σx² − (Σx)²) which is
positive by Cauchy-Schwarz whenever text varies. So a single Claude Code
session can populate the fit on its own — no need to spawn N sessions.
# Tests
New tests/dashboard-fit.test.ts (5 cases) locks in:
- null below 3 samples
- warm cache hits DO seed the ring (the regression we're guarding)
- LHS = input + cache_create + cache_read (cold vs warm with identical
body lands on the same regression line)
- 1000-token floor still filters trivial no-system traffic
- compressed=false passthrough requests don't contaminate the fit
246 tests pass (up from 241), typecheck + build clean.
|
||
|
|
937f0f8817 |
prune: drop dead 'placement' + 'compressSystem' knobs (system-field branch was API-rejected)
The `placement === 'system'` branch attached image blocks to the `system`
field, which Anthropic 400s with `system.N.type: Input should be 'text'`.
The existing CLI help text already warned about this. No tests exercised
the dead branch (grep for `placement.*'system'` finds zero call sites).
`compressSystem` was only consulted inside that same dead branch, so it
went out with it. The other granular `compress{Tools,Schemas,Reminders,
ToolResults}` flags + the `compress` master switch stay — they're useful
debugging toggles with active call sites.
Touched:
- transform.ts: drop both fields from TransformOptions + DEFAULTS;
collapse the if/else around system-rewriting into a bare block (no
re-indentation noise, no behavior change).
- node.ts: drop --placement CLI flag, PLACEMENT env, help text entries,
startup log column, and the TransformOptions wire-up.
- worker.ts: drop PLACEMENT from Env + transform options.
- render.test.ts: refresh three comments that referenced the now-gone
"default placement is 'user'" language; the test bodies were already
correct and didn't reference the removed knobs.
20-line net reduction, 241 tests pass, typecheck + build clean.
|
||
|
|
a52423870b |
dashboard: wire empirical cost fit into headline savings math + handoff
`DashboardState.fitCosts()` runs OLS over a rolling 50-event ring of
cold-miss (image_pixels, outgoing_text_chars, cache_create_tokens)
samples. `baselineCost()` and `estImageTokens()` consult the fit on
every event:
- `cost_fit != null` (N ≥ 3 samples) → headline saved_pct switches to
the live empirical rates: txtReplaced = compressedChars × α,
imgTokens = imagePixels × β.
- `cost_fit == null` → falls back to the stale constants
(OPUS_IMAGE_TOKEN_COST = 2500, CHARS_PER_TOKEN = 4) — same behavior
as before, so nothing breaks while data accumulates.
# Why a rolling ring, not seeded from history
Old events may carry the new instrumented fields but come from a
different renderer config (cell size, multiCol, atlas profile) or a
different model (Opus 4.6 vs 4.7 tokenizer). Mixing them in gives a
misleading α/β that lags reality. Each restart starts the fit empty
and refills from live traffic, so flipping `multiCol`, swapping
fonts, or upgrading the model auto-flushes stale samples.
# Endpoint
`/proxy-stats` JSON payload now carries:
"cost_fit": {
"alpha": 0.3, "beta": 0.0053,
"chars_per_token": 3.3, "pixels_per_token": 189,
"single_col_tokens_per_img": 4180,
"multicol2_tokens_per_img": 8500,
"n": 12
}
null until ≥3 cold-miss samples land. Guards against degenerate fits
(collinear data, negative rates) by returning null rather than feeding
the dashboard noise.
# Math
Solves `tokens ≈ α · text_chars + β · pixels` via 2×2 normal
equations on the centered design matrix. Rounds α to 4 sig figs, keeps
β at 6 sig figs (β is tiny ~5e-3 so it needs the resolution).
# Open
Once N stabilizes at ~10+, re-grade the hardcoded
`OPUS_IMAGE_TOKEN_COST` and `CHARS_PER_TOKEN` constants to match the
empirical numbers. That just makes the fall-back path agree with
reality, doesn't change live behavior. HANDOFF.md captures the
verification recipe and priority queue for the next session.
|
||
|
|
f1688e9f46 |
transform+render: R2 multi-col default-on + per-request pixel/char instrumentation
Two pieces of work landing together because they're interleaved in transform.ts and need to ship at the same time for the dashboard fit-wiring in the next commit to work. # R2 multi-column rendering (default `multiCol=2`) Single-col packing wastes ~68% of Anthropic's 1568×1568 image budget on real Claude Code tool docs (~38 chars/row). The row-aware break- even gate correctly rejects compression on those slabs — proxy ends up doing nothing on the heaviest requests. Multi-col packs N text columns side-by-side per image so each image covers `N×LINES_PER_IMAGE` wrapped lines, dropping image count by ~N and crossing break-even. - render.ts: new `renderTextToPngsMultiCol(text, cols, numCols)` with column-major layout (col 0 fills top-to-bottom, then col 1, …). Plus `multiColWidth` / `maxFittingCols` helpers. CLI auto-clamps numCols so the canvas can never exceed the 1568 px width cap. - transform.ts: `multiCol` option threaded through; gate, image-count estimator, paging budget, and all render paths take `numCols`. Intro text now describes column reading order when numCols>1 so the OCR pass doesn't read row-wise across columns. - Host wiring: node.ts (--multi-col / MULTI_COL=N, default 2), worker.ts (MULTI_COL env, default 2). Startup log shows multi_col=N. - numCols=1 stays a byte-identical passthrough to renderTextToPngs so flipping back can't regress the cache_control hit rate. # Empirical-cost instrumentation The dashboard's `OPUS_IMAGE_TOKEN_COST = 2500` and `CHARS_PER_TOKEN = 4` are stale pre-Opus-4.7 constants. Fact-checked: 4.7 changed the TEXT tokenizer only (~1.20× inflation on English, ~1.36× on TypeScript); image cost is unchanged for same-size images. Direction of error in the dashboard headline: it UNDERSTATES text savings. Ditched the "guess from one cold-miss" approach in favor of real instrumentation that pins both axes at once: - TransformInfo.imagePixels — Σ width×height across every image this request (static slab + reminders + tool_results + history collapse). Threaded through textToImageBlocks, collapseHistory. - TransformInfo.outgoingTextChars — every char of TEXT remaining in the transformed outgoing body, computed in one walk before serialization (countOutgoingTextChars). - Tracker event schema picks up image_pixels + outgoing_text_chars. - history.ts: HistoryCollapseInfo.collapsedImagePixels accumulates. The regression `tokens ≈ α·outgoing_text_chars + β·image_pixels` over N cold-miss events solves for empirical chars/token (α) and pixels/token (β) under the live model — no assumed constants. # Tests - 8 new render tests covering the multi-col passthrough contract (numCols=1 byte-identical to renderTextToPngs), canvas dimensions, image-count halving, determinism, charsRendered sum, estimator parity, CLI clamp, and CJK wide-glyph wrap. - paging.test.ts "counts multiple tool_results" threshold loosened from >770k to >700k to cover both single-col and multi-col budgets at the same maxImagesPerToolResult cap. - All 241 tests pass, typecheck + build clean. |
||
|
|
aed76dbdc2 |
transform: compact JSON in tool docs (kills the sparse-fill crisis)
renderToolDoc pretty-printed every input_schema with 2-space indent,
putting each property on its own line. At cols=100 this wasted ~70% of
horizontal space — schemas rendered to many short rows instead of a few
dense ones.
Live measurement on 2026-05-19: 150 KB of pretty-printed tool docs in a
177 KB combined slab → 31 static-slab images per request at 40% fill.
With the new row-aware break-even gate (
|
||
|
|
f0176c1432 |
transform: row-aware break-even gate stops net-loss compressions
isCompressionProfitable previously estimated image count as
ceil(chars / charsPerImage), which assumes uniform full-width line-fill.
renderTextToPngs actually budgets by wrapped visual rows (141 per image),
so newline-heavy code/logs and short-line content render to many more
images than the chars-based estimate predicts.
Live impact: dashboard reported -69% reduction (net loss). 33 compressions
averaged 5494 chars/image (39% fill ratio) — gate under-counted cost by
~9× on sparse content and let net-losing compressions through.
Fix:
- isCompressionProfitable now accepts the full text string; uses
estimateImageCount (the same row-aware math renderTextToPngs uses).
- Adds an optional imageCountCap for paths that truncate before rendering
(tool_results). Cost bounded by cap; savings still measured against
the full pre-truncation length, so 500KB logs still profit.
- All 4 production call sites in transform.ts pass the string. The
tool_result sites also pass maxImagesPerToolResult as the cap.
- Number-arg form retained for back-compat with the existing
isCompressionProfitable(N) unit tests.
Tests:
- 3 new unit tests pin the row-aware regression: sparse content rejected,
dense content accepted, capped content (paging) accepted.
- 7 integration tests previously used 'claude.md\n'.repeat(5000) — sparse
fixtures the new gate correctly rejects. Updated to dense fixtures
('x'.repeat(40000) or single-line repeats without embedded newlines)
so they still exercise the cache_control / determinism / reminder /
tool_result code paths they're meant to.
|
||
|
|
8fbd53ea3e |
dashboard: fix bogus savings math + emit compressed_chars
The dashboard headline was showing 0% saved because the math was
apples-to-oranges:
- orig_chars = static slab + tool docs ONLY (~135 KB)
- image_count = ALL images including reminders + tool_results (~33)
- txtReplaced = 135k/4 = 33.7k tokens
- imgTokensEst = 33 × 2500 = 82.5k tokens
- extraText = max(0, 33.7k - 82.5k) = 0 ← clamped negative
- baseline == paid → 'saved 0%' headline despite real savings happening
Two fixes:
1. Track 'compressedChars' separately from 'origChars'. It accumulates
only when a block actually image-renders (passes the break-even gate
AND any per-block min thresholds). Includes the static slab + all
compressed reminders + all compressed tool_results.
2. Remove the Math.max(0,...) clamp in baselineCost(). Negative savings
surface honestly — they're the signal that small-block compressions
are cost-bleeding, which the break-even gate is supposed to prevent.
Wires:
- TransformInfo.compressedChars (new field, mandatory).
- All 4 image-render success paths accumulate (static slab + reminder
+ tool_result string branch + tool_result array branch).
- TrackEvent.compressed_chars (new optional field, emitted when > 0).
- dashboard.baselineCost() takes compressedChars now; clamp removed.
- sessions.aggregateSessions() also uses compressed_chars + honest math.
229/229 tests passing. Bundle stays at 496 KB gzipped.
Post-restart, the dashboard 'tokens saved' will show real numbers
including negatives. Empirically expect ~1-5% net positive in
steady-state cache-hit traffic.
|
||
|
|
4f5b10aaee |
host: wire Variant C history-image opts through node + worker CLI
Threads compressHistory / historyKeepTail / historyMinPrefix through CliOpts, env vars (COMPRESS_HISTORY, HISTORY_KEEP_TAIL, HISTORY_MIN_PREFIX), and CLI flags (--history, --no-history, --history-keep-tail N, --history-min-prefix N). Same wiring on Workers host. Startup log reports history=on/off. TrackEvent extended with 4 new fields (collapsed_turns, collapsed_chars, collapsed_images, history_reason) — emitted only when non-zero/defined. Defaults: compressHistory=false (opt-in given round-3 MARGINAL rating). 229/229 tests green, 496 KB gzipped. |
||
|
|
97fdee8421 |
history: Variant C closed-tool-sequence collapse + honest per-session savings
Two distinct improvements in one slice:
1. History-image compression (Variant C — opt-in, default OFF).
New src/core/history.ts module:
- findClosedPrefixBoundary(): walks tool_use_id openSet, returns largest
fully-closed prefix index. Mid-flight tool_use blocks bail the collapse.
- blocksToText(): linearises content blocks for the history image. Drops
thinking blocks (per Opus 4.7+ docs: only most-recent must be bit-perfect),
renders tool_use args + tool_result inner text, emits [image] placeholder
for nested images.
- collapseHistory(): orchestrates. Applies isCompressionProfitable() gate.
Returns a synthesized {role:'user', content:[text,image,text]} prepended
message + the elided turns dropped from messages[].
New TransformOptions: compressHistory (default false), historyKeepTail (4),
historyMinPrefix (10). Wired through src/node.ts + src/worker.ts as
COMPRESS_HISTORY / HISTORY_KEEP_TAIL / HISTORY_MIN_PREFIX env vars.
Cache_control: history-image carries NO cache_control on this first cut.
Static slab keeps its 1 breakpoint. Conservative — telemetry will reveal
whether the cache-miss rate on history images warrants the round-3
rebalance in a follow-up slice.
29 new tests in tests/history.test.ts: closed-prefix detection (parallel
tool calls, mid-flight, openSet straddling), block linearisation, full
collapse flow, isCompressionProfitable gate firing on small histories.
2. Per-session savings: honest formula (fixes task #63).
Previous src/sessions.ts computed saved = orig_chars - image_bytes — a
nonsense comparison of chars to PNG bytes, then clamped via Math.max(0,...).
Result: charsSaved/tokensSavedEst was 0 for every session including ones
with hundreds of MB of compressed traffic.
New formula mirrors src/dashboard.ts:
textTokens = orig_chars / 4
imageTokens = image_count × 2500
tokensSaved = textTokens − imageTokens (CAN be negative)
Negatives are NOT clamped — they surface the cost-bleed on small-block
compressions that the per-block break-even check is supposed to prevent.
2 new tests: positive-net case, all-negative case (must report negative).
229/229 tests passing. Bundle ~500 KB gzipped.
|
||
|
|
04b1526bd5 |
transform: large-tool_result paging + adaptive break-even derivation
Two related improvements:
1. Per-tool_result paging / truncation. When a single tool_result would
render to more than maxImagesPerToolResult images (default 10), the
source text is truncated head+marker+tail BEFORE rendering. Classifier
picks the truncation strategy:
- structured (JSON/YAML/diff): head-only, tail is repeating elements
- log (timestamps / [LEVEL] prefixes): 60% head + 40% tail
- other (prose, dumps): same head/tail split
The marker tells the model what was elided so it can reason about the
gap: '[ pixelpipe paging: omitted N lines (K chars) ... ]'. Keeps
requests under Anthropic's 100-image-per-request cap even when a single
tool dumps a multi-megabyte log.
2. Adaptive CHARS_PER_IMAGE. Previously hardcoded at 14_100 (Unifont 10px
@ cell 5×11). Now derived at module load from ATLAS_CELL_H + render.ts
constants. When gen-atlas regenerates with a different font/size, the
break-even threshold automatically updates: at Cozette 4×7 the chars/
image jumps from 14.1k → 22.2k and the break-even drops from ~10k → ~3k.
Single source of truth in src/core/.
Wires:
- TransformOptions.maxImagesPerToolResult (default 10)
- TransformInfo.truncatedToolResults + omittedChars
- TrackEvent.truncated_tool_results + omitted_chars (emit when > 0)
- Two new exported helpers: estimateImageCount, classifyContent, truncateForBudget
- Paging fires before image-encoding at both tool_result branches
(string content and array-of-text-blocks content)
Tests: +28 paging-specific (paging.test.ts), 199/199 total green.
Bundle delta: negligible (~3 KB raw, ~1 KB gzipped). 488 KB dist gzipped.
Closes the paging-implementer worktree merge that was deferred earlier.
|
||
|
|
b5d768fcdc |
dashboard: sessions UI + stats panel + smooth updates + cleanup
Pivot from prior CLI subcommands (stats, sessions) to dashboard-based UI. bin/cli.js becomes a minimal proxy starter; all functionality lives at http://127.0.0.1:47821/. New endpoints: GET /api/sessions.json — session list + Claude Code mapping GET /api/sessions/:id.json — session detail (bodies redacted by default) POST /api/sessions/prune — dry-run-first cleanup with atomic rewrite GET /api/disk.json — events.jsonl + 4xx-bodies disk usage GET /api/stats.json — full-history aggregate (replaces CLI 'stats') GET /sessions/:id — HTML detail page Dashboard HTML extended with sessions panel + stats panel + cleanup panel. Smooth-poll: 2s tick for legacy live counters, 5s tick for sessions/stats/disk. Session rows diff-rendered (no full-table flash). Destructive actions show window.confirm() with dry-run preview of impact. Session mapping: synthetic ID from first_user_sha8 (already on every event) + opportunistic enrichment by fingerprinting against ~/.claude/projects/*.jsonl. Falls back gracefully if Claude Code's session dir is missing. Atomic events.jsonl rewrite (write to .tmp, fsync, rename) preserved from the prior CLI design — trigger is now HTTP POST. +51 new tests (33 sessions + 18 dashboard-api). 149/149 total. |