Commit Graph

132 Commits

Author SHA1 Message Date
teamchong 34c62b78cc fix(dashboard): baseline-% denominator + multi-image thumb strip
SessionSummary: divide saved_$ by baseline_input_$ (was actual_input + output,
which produced 719% on cheap sessions). Math now matches the user-stated
analogy: baseline=10, actual=5 -> saved=5, 50%.

RecentRequests: stamp full imagePngs[] in TransformInfo so each rendered PNG
gets a ring-buffer id. Row shows a strip of 28px thumbs (one per image)
instead of a single 'view' button, so multi-image requests are visible at
a glance. Thumbs are <button>-wrapped for keyboard accessibility.
2026-05-24 20:45:52 -04:00
teamchong 1ba74c2a83 WIP 2026-05-24 20:08:14 -04:00
teamchong 68826a9499 fix(build): npm run build now regenerates dashboard-bundle.ts first 2026-05-24 18:58:58 -04:00
teamchong 713ee6b1bc feat(dashboard): one-line per-session savings; drop deleted-UI SessionTotals fields (#22, #24) 2026-05-24 18:52:52 -04:00
teamchong f78acb18f2 fix(transform): honor existing cache_control markers; emit passthroughReasons on slab gate (#21, #23) 2026-05-24 18:52:52 -04:00
teamchong fb13de7c9b feat(dashboard): per-session dollar-weighted savings panel with bucket + passthrough breakdown 2026-05-24 13:59:57 -04:00
teamchong 0cf2680d62 fix(transform): never add cache_control; honor caller's markers (Task #21) 2026-05-24 09:58:59 -04:00
teamchong eaf00a9860 test(render): drop 7 stale it.skip blocks (deadcode, behavior gone) 2026-05-24 00:06:41 -04:00
teamchong f9a8d71f93 test(geometry): recalibrate render+history tests for 5x8 cell + pre-shrunk gate
After the new atlas geometry (CELL_W=5, CELL_H=8, LINES_PER_IMAGE=195,
MaxCharsPerImage(100)=19500) and the WIP shrinkWidth=true default in
transformRequest, a single 100-col image now holds ~19.5k chars of dense
ASCII at cpt=4 (vs ~15.6k previously). Combined with the gate hoisting
the width-shrink before prediction, the existing test fixtures (which
mostly used 'a'.repeat(N) with no newlines) now compress profitably at
every length above MIN_COMPRESS_CHARS=2000.

Per user direction ("use the actual gate function to compute new
expected values", "prefer rewrite over delete unless premise is
genuinely gone"):

- render.test.ts: 19 isCompressionProfitable() expectations rewritten.
  Numeric-literal callers (which used a removed back-compat overload)
  converted to 'a'.repeat(N) strings. Per-test comments updated to
  describe the new break-even framing under MaxCPI(100)=19500. Real-shape
  regression fixtures (PRODUCTION_SLAB_135H_DENSE, 169H_HEAVY) now
  ACCEPT under the larger atlas; comments note the conservative-side
  shift no longer rejects these dense shapes.

- history.test.ts: 5 expectations rewritten. The amortized-horizon and
  prior-warm-tokens gates no longer reject at the historical fixture
  sizes because image side cost has dropped well below text side cost
  even at cpt=4.5. Reasons updated from 'not_profitable' to 'collapsed'
  where the gate now collapses cleanly; amort() and cold() calls flipped
  from .toBe(false) to .toBe(true) with rationale.

src/ untouched. Final count: 315 passed, 7 skipped, 0 failed (was 24 failing).

npx vitest run: 100% green.
2026-05-23 23:45:58 -04:00
teamchong 6b4ecfee99 fix(transform): hoist width-shrink before gate so prediction matches render
The system slab's break-even gate was estimating image cost at the full
configured cols width while the renderer was shrinking the canvas to the
longest wrapped line. Gate and renderer now agree: shrink once up front,
pass the shrunk cols to both evalCompressionProfitability and
renderTextToPngsMulticol.
2026-05-23 22:31:19 -04:00
Steven Chong 08b1b43b1e fix(gate): content-aware image cost + width-shrinking (WIP tests)
Two fixes to the image-cost prediction that gates compression:

1. **Content-aware image cost.** The old gate used a fixed
   `TOKENS_PER_IMAGE_SINGLE_COL = 2500` constant per image, modelling
   every tool_result block as a full 1568px canvas. The renderer
   actually produces images sized to the wrapped line count, so a
   5,000-char tool_result becomes a ~80px-tall image costing ~54 tokens,
   not 2,500. Result: the break-even for small blocks was over-estimated
   by ~50x and the gate rejected anything below ~10k chars even though
   image-form was cheaper.

   Replace `estImages × effectiveTokensPerImage(numCols)` with a single
   content-aware `imageTokensCost(text, cols, numCols, imageCountCap)`
   that mirrors the renderer's height math:
   `tokens = (rows + 1) × overhead + fullImageRows × perRow`, derived
   from the cold-miss anchor and applied row-proportionally for partial
   final images. Calibrated against N=33 production cold-miss
   measurements (median multiplier 1.05, mean 1.32 — high-image case is
   ~1.05x of the documented `width × height / 750` rate).

2. **Width-shrinking on per-block paths.** For per-block call sites
   (tool_result, reminder) the renderer now narrows the canvas width to
   the longest wrapped line via `shrinkColsToContent`. Multi-col
   layouts already pack tightly; this captures the single-col savings
   that were previously paying for whitespace. The gate threads
   `shrinkWidth` through to `evalCompressionProfitability` /
   `isCompressionProfitable` / `isCompressionProfitableAmortized` so
   the prediction matches what the renderer will actually produce.
   System-slab and history-collapse paths pass `shrinkWidth=false` —
   they fill the configured canvas deliberately.

Knobs removed: `MinToolResultChars` and `MinReminderChars` now
default to 0 (let the math decide). The TransformOptions fields are kept
for env-override observability but no longer set a floor in production.
`isCompressionProfitable` / `evalCompressionProfitability` /
`isCompressionProfitableAmortized` are now string-only at the public
API; the numeric-length fallback is gone (it under-counted images and
silently leaked net-losing compressions through).

Tests: gate-flap and history.test updated for the new physics, but 24
tests in render.test.ts still assert the OLD over-estimation as truth
(e.g. "5,000 chars is not profitable", "7000 chars stays as text").
They need to be deleted or have their expected verdicts inverted — left
for the next session to finish the rewrite cleanly. Typecheck passes.

Measured impact: the production tail's $1.43 flap is gone and per-block
tool_result/reminder content is now genuinely compressed when it would
save tokens, not just when it crosses the (wrong) 10k-char threshold.
2026-05-23 21:26:36 -04:00
Steven Chong c340ac3da6 feat(dashboard): direct compressed-vs-passthrough cost split
Add per-path actual-billed cost counters to the dashboard alongside the
existing counterfactual baseline. The compressed-vs-passthrough split
sums what each code path actually cost on real traffic, partitioned by
`info.compressed`, with no probe gating or counterfactuals. Surfaces
sample counts in the UI so operators can judge sufficiency rather than
having the dashboard auto-claim significance.

Headline math also switched to share-of-all-paid-spend (counterfactual
denominator bounded at 100%) so the slice number never lies when
pixelpipe didn't run on every request. Regenerated dashboard-bundle.
2026-05-23 21:25:55 -04:00
Steven Chong 003b21adde fix(dashboard): bound share_of_spend at 100% via counterfactual denominator
The previous patch divided savings by all-rows ACTUAL spend, which is
not bounded — pixelpipe shrinks the denominator, so a single big
cold-miss compressed request showed '280% saved'. That's nonsense:
you can't save more than you would have paid.

Correct denominator is what the user WOULD have paid with no pixelpipe:
measured rows contribute their cache-aware baseline, unmeasured/probe-
failed/passthrough rows contribute their actual (pixelpipe either
didn't run there, or we can't measure the counterfactual, so actual ≈
baseline). Bounded at 100% by construction.

- Totals: rename allActualInputWeighted role to diagnostic only, add
  allBaselineEquivalentWeighted as the headline denominator input.
- update(): when haveBaseline, add baselineInputEff to the counterfactual
  pool; otherwise add actualInputEff. Output × 5 unchanged on both sides.
- serveStats(): pctAllSpend = saved / (allBaselineEquiv + allOutput).
- types.ts + StatsHeader.svelte: surface all_baseline_equivalent_weighted,
  rewrite the explainer math.

Single-request smoke (the case that surfaced the bug): saved=397k
input tokens, baseline_equivalent≈400k, output_equiv tiny → ratio ≈
99% saved, which is honest (one cold-miss compressed turn on an
otherwise empty session).
2026-05-23 13:21:05 -04:00
Steven Chong 62bcd4a0dc fix(dashboard): headline = share of ALL paid spend, not just measured rows
The 'share of total bill saved' headline divided savings by
baseline_input_weighted, which only accumulated on rows where the
count_tokens probe succeeded AND the request had upstream usage. Every
passthrough turn, every probe-failed turn, and every flap-polluted
untransformed turn was excluded from the denominator. That's a
cherry-pick: it answers 'did pixelpipe help on the rows where it ran'
instead of 'did pixelpipe move my real bill'.

Track allActualInputWeighted / allOutputWeighted ungated on probe
success (any request with a usage block contributes), expose
saved_pct_of_all_spend on /proxy-stats, and rewrite the headline card
to use it. Numerator is still measured-rows-only because that's the
only place we have a counterfactual baseline; you can't fabricate
savings for rows where the probe failed. But the denominator now
matches the user's actual bill.

The legacy saved_pct_of_total_bill stays on the wire for back-compat
(marked deprecated in types.ts). The headline card no longer reads it.

Card flips color: positive % stays green, negative % goes red, because
flap-driven net losses are real and should be visible.
2026-05-23 13:14:40 -04:00
Steven Chong d0c64d932b fix(gate): wire symmetric warm-cache burn into history-collapse gates
Production data (ocproxy claude-opus-4-7, 2026-05-23 09:19–09:21) showed
three-turn sessions still flapping AND paying cache_create on every
turn despite the symmetric burn shipped in 0cfe08a:

  09:19  APPLIED   cc=156k cr=6.7k  $0.99   cold start
  09:20  declined  cc=181k cr=0     $1.23   FLIPPED to text
  09:21  APPLIED   cc=156k cr=6.7k  $1.01   FLIPPED back to image

The slab gate's gateEval said image was profitable by 154k margin at
the flip turn, but applied=0 — the slab gate had decided to stay in
image mode, then the history-collapse gate (which got hardcoded 0 for
priorWarmTokens AND priorWarmImageTokens) flipped the verdict.

Wire o.priorWarmTokens + o.priorWarmImageTokens through to both
history-collapse call sites so the symmetric anti-flapping burn
applies to every gate that can change the session's bill shape, not
just the slab. Foundational follow-through on the same fix.
2026-05-23 09:24:47 -04:00
Steven Chong 0cfe08a868 feat(gate): symmetric warm-cache burn + gateEval telemetry (anti-flapping)
Production data (ocproxy Opus 4.7, 2026-05-22 23:15–23:44) showed the
slab break-even gate ping-ponging between text and image modes within a
single session, paying cache_create on whichever side it just flipped
away from. Two flips in 30 minutes cost ~$0.71 + ~$0.40 of avoidable
cache_create on a single conversation.

Root cause: priorWarmTokens was asymmetric. It penalised compressing
when the un-rewritten text path was warm, but did not penalise
decompressing when the rewritten image path was warm. Per-turn cost
favored flipping; the flip forced cache_create on the new side; next
turn flipped back.

This change adds the symmetric counterpart:

  text→image flip: warm text invalidated → burn applied to IMAGE side
                   (existing behavior, priorWarmTokens)
  image→text flip: warm image invalidated → burn applied to TEXT side
                   (NEW, priorWarmImageTokens)

Once a session commits to a mode, staying in that mode is cheaper than
flipping by the burn cost. Mode changes only happen when the per-turn
delta exceeds the burn, which is exactly when the flip is actually
worth the cache_create. Foundational fix — no thresholds.

Observability:
- New exported `evalCompressionProfitability()` decomposes the slab
  gate's evaluation into imageTokens, textTokens, burnImageSide,
  burnTextSide, and verdict. Same constants and helper as the gate
  itself, no drift risk.
- New `TransformInfo.gateEval` field stamps that decomposition into
  every applied or not_profitable event so hosts can persist the verdict
  margin and prove flap-prevention efficacy from telemetry.

Back-compat:
- New params default to 0; single-arg callers behave identically.
- 315 existing tests unchanged; +7 new tests pin the new semantics
  (back-compat, asymmetric ⇒ symmetric, both-sides-warm cancellation,
  eval verdict matches gate verdict, burn term math).
2026-05-22 23:54:05 -04:00
Steven Chong 82aa35cf87 chore(deps): enforce 3-day install quarantine + bump ws to patched 8.20.1+
- .npmrc: pin minimum-release-age=4320 (3 days) for supply-chain
  defence; new versions can't be installed until they've been
  on the registry long enough for typo-squat / compromise to be
  caught and yanked.
- minimum-release-age-exclude=@cloudflare/* so first-party
  packages don't block our own iteration.
- pnpm.overrides forces ws>=8.20.1 transitively via miniflare,
  closing GHSA-58qx-3vcg-4xpx (moderate, uninitialized memory
  disclosure in ws). Dev-only chain, no prod runtime impact, but
  worth keeping clean for dependabot.
2026-05-22 23:45:55 -04:00
Steven Chong 393f72ee7c docs(readme): reframe tagline — pixels instead of a transcript, context as UI 2026-05-22 23:38:20 -04:00
teamchong a8d2f62d63 docs(cell): update stale 7×10 references to current 5×8 production cell
The reflow-inimage finding (commit bb9c231) made 5×8 viable, so
DEFAULT_CELL_W_BONUS / DEFAULT_CELL_H_BONUS reverted to 0 — but many
comments and the README still said 7×10 was production.

Touched:
- README.md             — eval table, density bullet, sweep narrative,
                          cell-pitch section, measurement header,
                          Limitations section
- src/core/render.ts    — RenderStyle docstring + cell-bonus docstrings
                          + multicol intro comment
- src/core/transform.ts — TransformOptions.reflow docstring, DEFAULTS
                          comments (minReminderChars floor rationale,
                          maxImagesPerToolResult cap, reflow gate
                          rationale, anchor scaling formula, LINES_PER_IMAGE
                          worked example)

No code behavior changed — every constant still resolves through CELL_W /
CELL_H, and the eval harness still overrides the cell via cellWBonus /
cellHBonus when comparing variants. The 5×8 production path is what was
already shipped on 81550f6 (reflow + grayscale + L1/L2 harness) and
bb9c231 (reflow-inimage).
2026-05-22 22:59:20 -04:00
teamchong fc7cda8a11 fix(transform): drop trailing blank row after BEGIN banner 2026-05-22 22:29:00 -04:00
teamchong 87ea6166bc fix(transform): pack header prose to full row width, drop spurious ↵ markers 2026-05-22 22:24:59 -04:00
teamchong bb9c2315f3 feat(transform): drop standalone intro TextBlock — OCR header rides in the image 2026-05-22 22:19:11 -04:00
teamchong 4774aeb14c feat(eval): in-image instruction variant (reflow-inimage) — +1.04pp vs baseline
Co-render the OCR instruction into the same PNG as the content,
delimited by '===…===' bands, with the API system field dropped.

L1 OCR fidelity, Opus 4.7, 20 production blocks, 7×10 cell:
- baseline (text-only):       97.91% mean / 96.25% min
- reflow (separate system):   91.99% mean / 82.59% min  (-5.93pp)
- reflow-inimage:             98.95% mean / 96.42% min  (+1.04pp)

reflow-inimage wins on all 20/20 blocks vs reflow, and beats the
text-only baseline on 17/20 blocks. Three blocks hit 100%. The
-5.93pp reflow regression that the cell-pitch sweep partially
recovered disappears entirely when the instruction is co-rendered.

Mechanism: when system carries the instruction and the image
carries the content, the model does cross-modal binding to figure
out what the image is for. Co-rendering reduces it to a
single-modal task with an unambiguous parse rule.

Files:
- eval/eval-l1-ocr.mjs:        add reflow-inimage variant + prompt
- README.md:                   new section before history compression
- eval/EXPERIMENT_LOG.md:      attempt #2 writeup
- eval/results/{l1-report.md, l1-results.json}: regenerated
2026-05-22 22:04:10 -04:00
teamchong 81550f690e feat(render): pack reflow across newlines + grayscale atlas + L1/L2 eval harness
- wrapLines packs continuously; NL_SENTINEL stays as an inline marker, never breaks a row
- atlas-gray.ts: anti-aliased grayscale variant (gated via RenderStyle.aa, unused in production)
- gen-atlas.ts: optional ATLAS_GRAY=1 emission
- eval/: standalone Anthropic-client L1 OCR fidelity + L2 session replay harness, claude -p backed
- README: reflect packed reflow + measured Opus 4.7 numbers
2026-05-22 20:48:40 -04:00
teamchong 2b7b98f685 feat(render): add R3 reflow to recover line-end dead margin (~29% glyph fill → dense)
Pack text into a continuous sentinel-delimited stream (↵ = U+21B5) so
wrapLines fills every row to `cols` instead of leaving dead right-margin.
Adds reflow/dereflow, renderTextToPngsReflow{,MultiCol} variants, a full
test suite, and an A/B eval harness with L1 OCR + L2 session results.
2026-05-21 23:14:00 -04:00
teamchong f5496d6c24 feat(dashboard): last-50 image ring, image viewer, cut prune + session detail 2026-05-21 22:20:40 -04:00
teamchong 45c83dedb5 feat(dashboard): rewrite dashboard in Svelte (#25)
Replace the hand-rolled dashboard with a Svelte component tree (App +
Sessions / StatsTable / LatestPng / RecentRequests / StatsHeader /
Cleanup / CompressionToggle / ToastTray), move the API layer into
lib/, and split state into stores/.

Add a dedicated dashboard build (`build:dashboard-ui` + a separate
tsconfig.dashboard.json) so dashboard sources typecheck independently
of the proxy core, which previously broke the root `tsc` build.
2026-05-21 21:43:28 -04:00
teamchong a19ce30a48 feat(core): quantize history collapse boundary onto a fixed grid (#28)
Snap the collapse cutoff to a `collapseChunk`-sized grid (default 50) so
the rendered history image stays byte-identical for a full window of
turns. That lets Anthropic's prompt cache `cache_read` the history
prefix (0.1x) instead of re-billing `cache_create` (1.25x) every turn.
The cutoff floors at `minCollapsePrefix` (clamped to the raw cutoff) so
short conversations still collapse without the boundary drifting.

Also log `history_image_sha8` per request -- a sha8 of the concatenated
history-image base64. An unchanged hash across consecutive collapsed
events is ground-truth proof the prompt cache is being hit; a hash that
moves every turn signals the cache-key drift regression is back.
2026-05-21 21:43:22 -04:00
Steven Chong 2ae75db58e docs(render): clarify 5x8 atlas sizing 2026-05-21 17:45:35 -04:00
Steven Chong d87aecec95 feat(render): use 5x8 code-font atlas 2026-05-21 17:41:30 -04:00
Steven Chong 6b508c367d docs(readme): summarize VLM text-density research for renderer tuning
Capture the practical conclusions from recent VLM/OCR papers:
ReadBench (arXiv:2505.19091), typographic perturbation work
(arXiv:2604.12371 / 2604.25102), and typography-gap work
(arXiv:2603.08497).

The main guidance for pixelpipe is: don't increase DPI; margin tweaks
are tiny; the real foundational experiment is a denser-but-readable
bitmap atlas (e.g. 5x11 -> 4x8/4x7) with exact retrieval tests before
shipping.
2026-05-21 16:39:21 -04:00
Steven Chong 1a83d56379 feat(transform): priorWarmTokens cache-burn penalty in break-even gate
Production data (ocproxy 7d, Opus 4.7) shows pixelpipe HURTS short
conversations and helps long ones:

  input.count <10  :  +$0.17 (HURTS) over 14 rows
  input.count 10-19:  +$0.04 (HURTS) over 22 rows
  input.count 20-39:  -$0.56 (helps) over 29 rows
  input.count 40+  :  -$3.21 (helps) over 237 rows

Diagnosis: warm-cache share split.

  bucket   actual_warm_share  baseline_warm_share
  <10      60%                100%
  10-19    84%                97%
  20-39    88%                96%
  40+      92%                58%

For short conversations the un-rewritten path is already nearly fully
warm-cached. Rewriting the prefix bytes (slab compression / image
substitution / history collapse) gives the new prefix a different
cache key, so Anthropic charges cache_create (1.25×) on the new
prefix's first turn — destroying the prior warm cache. The
conversation often ends before the new cache amortizes.

Fix: add a 'priorWarmTokens' option to TransformOptions. When >0, the
break-even gate penalises the image side by

  burn = priorWarmTokens × (CACHE_CREATE_RATE − CACHE_READ_RATE)

Per-turn gate eats the full burn; amortized gate spreads textLifetime
across N turns so longer horizons re-accept once savings outweigh burn.
Default 0 preserves byte-identical behavior for callers that don't
plumb it through (cold-start safe).

Tests: 3 new (NaN clamp, per-turn flip, horizon flip). 302 total pass.
2026-05-21 15:48:51 -04:00
Steven Chong 679757f3df fix(measurement): pair orphan tool_use blocks in cacheable-prefix probe
count_tokens validates structural pairing and rejects truncated prefix
bodies that contain tool_use blocks whose tool_result was in the
dropped tail, with: 'messages.<N>: tool_use ids were found without
tool_result blocks'. That's been firing on ~15% of Opus 4.7 rows
(75/503 last 7d) and excluding them from baseline_probe_status='ok'.

Append a synthetic user message with minimal tool_result blocks for
any orphan ids. Adds ~5-10 tokens per orphan (within ~1% of truth)
and converts those rows from 'partial' to 'ok' so they contribute
to honest savings rollups.

Source: ocproxy daemon.log under OCPROXY_PIXELPIPE_DEBUG=1.
2026-05-21 15:13:23 -04:00
local 9ecd799092 feat(transform): lower SLAB/HISTORY_CHARS_PER_TOKEN 2.5 → 2.0 for Opus 4.7 tokenizer
N=391 Opus 4.7 measured rows (last 7d) show real chars-per-token = 1.91
(avg_outgoing_text_chars=231,925 / avg_real_input_tokens=115,893). The
older 2.5 constant was calibrated on Opus 4.5/4.6 and over-estimated cpt
by ~30%, under-counting text-token cost and causing the break-even gate
to reject most history-collapse opportunities (283/391 = 72% of measured
ok rows came back history_reason='not_profitable').

Resize one render.test.ts fixture (6060 → 4848 chars) so the 'low cpt
unlocks a previously-rejected slab' assertion still exercises the
intended before/after edge under the new default.
2026-05-21 14:34:06 -04:00
Steven Chong f14cbde8f6 chore: gitignore agent-collaboration scratch (CLAUDE.md, HANDOFF.md, etc.)
Per-session AI-assistant memory is local-only — useful for context
continuity across sessions but never appropriate to ship to the public
repo. CLAUDE.md and HANDOFF.md (which were previously tracked) have
been purged from git history via filter-repo in the same operation
that introduced this gitignore rule.

If you cloned before this commit, your existing CLAUDE.md / HANDOFF.md
will continue to live in your working tree but won't show in git
status, which is the intended steady state.
2026-05-21 12:52:19 -04:00
Steven Chong 3d5d27d0db fix: Opus 4.7 pricing is $5/$25/MTok, not $2.50/$12.50 — revert prior "fix"
Earlier commit a0f282f "corrected" Opus 4.7 rates downward to $2.50
input / $12.50 output based on a misread of the pricing source. Per
docs.claude.com/en/docs/about-claude/pricing (current), Opus 4.7 is:

  Base input:       $5    / MTok   (same as Opus 4.5, 4.6)
  Output:           $25   / MTok
  5m cache write:   $6.25 / MTok
  Cache hits/reads: $0.50 / MTok

Same dollar pricing as Opus 4.5 / 4.6 — what changed in 4.7 is the
tokenizer, not the per-token rate. The README now flags this so future
contributors don't assume hardcoded chars-per-token / image-token
constants tuned on earlier models are still safe on 4.7; only
count_tokens against the target model is honest.

Touches:
  - src/dashboard.ts: ASSUMED_INPUT_USD_PER_MTOK back to 5.0, with a
    long comment block citing the source and warning about tokenizer
    differences. Updated card subtext on the headline accordingly.
  - README.md: pricing table back to $5/$25/$6.25/$0.50, with a
    callout that the tokenizer changed in 4.7.
2026-05-21 12:38:10 -04:00
Steven Chong 09e4f635ea docs(readme): stop claiming '$ saved'; report token deltas only
The README headlined a '~76% fewer tokens' framing, a worked example
with a 'savings' column, and an Opus pricing table that used the old
Opus-4-base rates ($5/$25/MTok input/output) instead of Opus 4.7's
actual rates ($2.50/$12.50). Combined, it read like an aggregate
$-savings claim that the data does not yet support.

What's actually true and stays:
  - Pixelpipe ships fewer input tokens on cold-miss requests, measured
    by Anthropic's own count_tokens endpoint. The 173,783 -> 41,321
    anecdote is one real request, not a session average.
  - Image-tokens-vs-text is a real per-request encoding-density
    observation.

What was misleading and is removed/softened:
  - Opening tagline no longer claims '~76% fewer tokens, 100% quality'
    as if it generalizes. Replaced with an experimental-status note.
  - Worked-example 'savings' column renamed 'delta' and contextualized.
  - 'How we report numbers' section added that explains the
    baseline_probe_status gate, says we don't currently make an
    aggregate $ saved claim, and warns the reader to treat any host
    dashboard that surfaces $ saved without the gate as marketing.
  - Pricing table corrected to Opus 4.7 rates.
2026-05-21 12:26:26 -04:00
Steven Chong ed2746650f fix(dashboard): exclude unmeasured rows from "$ saved" rollup
When the cacheable-prefix count_tokens probe failed but the full-body
probe succeeded, the dashboard fell through to `baselineCacheableTokens
?? 0`. That silently treated the unproxied counterfactual as
cold-input-only — charging full $2.50/Mtok input rate on tokens that
demonstrably would have been cache-discounted at 10× cheaper. Net effect:
fabricated "$ saved" headline on every partial-probe row.

The fix:

1. New `TransformInfo.baselineProbeStatus: 'ok' | 'partial' | 'failed'`,
   set by the proxy after awaiting both probes. 'ok' means either both
   probes resolved OR the original body had no cache_control markers
   (cacheable=0 is then exact, not estimated). 'partial' means the
   full-body probe resolved but the cacheable-prefix probe didn't.
2. `tracker.ts` persists it as `baseline_probe_status` on the JSONL.
3. `dashboard.ts` gates its savings math on `status === 'ok'` (with a
   back-compat path for legacy rows that pre-date the field). Partial /
   failed rows now contribute to the request count but not to
   baselineInputWeighted / actualInputWeighted / outputWeighted.

ocproxy's host-side dashboard already records baseline_probe_status in
its SQLite schema and is being updated in parallel to honor the same
gating semantic. See ocproxy commit fixing dashboard/src/lib/snapshot.ts
for the matching change.
2026-05-21 12:16:38 -04:00
Steven Chong a0f282f262 fix(dashboard): correct Opus 4.7 input rate from $5/Mtok to $2.50/Mtok
The previous value was Opus 4.5/4.6 pricing. Opus 4.7 input is $2.50/Mtok,
output $12.50/Mtok. The dollar-saved headline was overstated by 2×.

OUTPUT_TOKEN_RATE (5×) and cache multipliers (1.25×/0.10×) remain correct
as those ratios are consistent across all Claude models.
2026-05-21 11:36:37 -04:00
Steven Chong a197f8729d feat(transform): horizon-aware history-collapse break-even gate
Adds isCompressionProfitableAmortized() and a new
historyAmortizationHorizon option (default 1 = current per-turn
behaviour). When the host opts in to horizon ≥ 2, the history-collapse
gate evaluates expected lifetime cost over N future turns instead of
the cold per-turn comparison:

  I × (CC + CR×(N-1))  <  T × CR × N
  where CC = 1.25 (cache_create), CR = 0.10 (cache_read)

At N=5 this accepts collapses with I/T < 0.30; at N=10 with I/T < 0.47.
The worst-case-for-image / best-case-for-text framing is on purpose —
we only collapse when the math wins under pessimistic warm-cache
assumptions, so a single-turn session can never pay a tax.

This is the 'try-then-decide-with-bounded-horizon' design from the
README's new 'unsolved part: multi-turn amortization' section. Picked
over JIT-style session state (rejected: introduces durable state),
always-collapse (rejected: dishonest per-request), and cache-bust-driven
(rejected: signal arrives one turn late).

- New option threaded through DEFAULTS, TransformOptions, PixelpipeOptions
- Both history call sites (early-exit helper + main success path) use the
  amortized gate when horizon > 1, fall back to the cold gate otherwise
- 4 new unit tests covering fallback, accept-on-long-history, reject-on-
  tiny-text, and the subset-relationship between cold and amortized gates
- 298 tests pass
2026-05-21 10:33:07 -04:00
Steven Chong 3d22a04356 docs(readme): document multi-turn amortization problem and design space
The break-even gate in collapseHistory is per-turn — it answers 'is the
image cheaper than the text on this single request?' That question has
the wrong answer once Anthropic has cached the prior text-based prefix,
because text-at-10% beats image-at-40%-cold. But the prefix's lifetime
cost is what matters, not the per-turn cost, and lifetime cost favours
the image once the cache eventually expires.

This is the same shape of decision a JIT compiler, a DB optimiser, or
ZFS block compression makes. Documenting the analogues, the four
credible designs we considered (try-then-decide / session-state /
always-collapse / cache-bust-driven), and why we picked try-then-decide
as v1.

The Why-it's-hard section gets a new paragraph framing the asymmetric
cache invalidation. The history-compression section gets a new
'The unsolved part: multi-turn amortization' subsection with the full
design space and the decision criteria.

No code changes — this is design documentation for future contributors
so they don't reinvent the analysis.
2026-05-21 10:13:00 -04:00
Steven Chong b9ea0a73b8 feat(transform): run history collapse on below_min/not_profitable exits
Pixelpipe's static-slab gate returns early when the system slab is below
minCompressChars or fails the slab break-even check. Both exits skipped
the always-on Variant C history-image collapse, so production traffic
with a tiny system prompt but a huge messages[] array (e.g. Codex with
no project instructions / CLAUDE.md / skills) never got any compression
even though the dynamic side was massive.

Trace on a real-shaped request (system=29 chars, 56 message turns,
~275k outgoing text chars) before this change:
  applied=false, reason=below_min_chars, imageCount=0, collapsedTurns=undefined
After:
  applied=true, reason=applied, imageCount=20, collapsedTurns=56,
  collapsedChars=256832, outgoingTextChars=19782

Implementation: extracted runHistoryCollapseAndFinalize() helper that
runs collapseHistory + countOutgoingTextChars + JSON.stringify. The two
early-exit paths now call the helper; if history collapses they flip
info.compressed = true (library.ts then returns reason='applied'),
otherwise they return the original body unchanged (preserving the
existing below_min_chars / not_profitable diagnostics for the slab).

Main success path unchanged — it already ran history collapse after
slab compression. No new flags, default-on for Opus 4.7 per host
gating. 294 tests pass.
2026-05-21 10:05:13 -04:00
Steven Chong 0aa2cce88f Expose pixelpipe library API 2026-05-20 23:47:32 -04:00
teamchong c1d111a083 feat(transform): per-bucket char attribution telemetry (Task #18 Phase 1)
Adds the data collection half of the adaptive chars-per-token plan
without touching the gate. Every isCompressionProfitable call site
now credits its chars to a named bucket on TransformInfo, and those
counts flow through toTrackEvent into the persisted JSONL.

transform.ts:
- New BucketName union: 'static_slab' | 'reminder' |
  'tool_result_{structured,log,prose}' | 'history'
- bumpBucket() helper lazily allocates info.bucketChars
- toolResultBucket() routes by classifyContent shape so the post-
  compaction text we credit matches the shape the gate evaluated
- Bump sites: static slab (combinedRaw), reminder (reminderRaw),
  tool_result single-string (innerRaw), tool_result multi-block
  per TextBlock (innerTextRaw), history (collapsedChars)
- Surfaces historyTextChars separately so history's no-collapse
  rejections still contribute a denominator

tracker.ts:
- TrackEvent gains bucket_chars?: Partial<Record<BucketName, number>>
  and history_text_chars?: number
- toTrackEvent copies them when populated, drops empty bucket maps
  so happy-path events stay small

tests/tracker.test.ts:
- Asserts snake_case field names and nested shape land correctly
- Asserts absent buckets and pass-through events stay clean

No gate logic changed. CHARS_PER_TOKEN / SLAB_CHARS_PER_TOKEN /
HISTORY_CHARS_PER_TOKEN still drive isCompressionProfitable. Phase 2
will fit per-bucket slopes from the captured events and wire a
bucket-aware override.

docs/ADAPTIVE_CPT_PLAN.md: full plan + empirical basis (slab-only
regression on 87 events recovered cpt≈1.50 vs the baked 2.5
constant — 40% on non-slab compressions ship today).

Verified: tsc --noEmit clean; 290/290 tests pass (+2 new);
build emits dist/node.js.
2026-05-20 21:21:34 -04:00
teamchong ba64b4c253 feat(dashboard): surface measured vs billed output chars
The SSE/JSON scanner from #22 was wiring up measurement on every event
but the dashboard wasn't showing the numbers. Now the token-equivalent
card's "show calculation" panel exposes the raw counts the scanner
produced (text_delta + thinking_delta + tool_use chars + redacted block
count) alongside events_with_measurement so the operator can weigh how
load-bearing the gap really is.

No new headline card — the gap belongs inside the existing total-bill
panel because it's a diagnostic on the total-bill number, not a new
metric the user has to learn. When events_with_measurement << requests
(scanner fell back on a lot of events) the operator can see it; when it
tracks requests the comparison is real.

Refs #22.
2026-05-20 19:01:55 -04:00
teamchong a4bc5c4690 polish(dashboard): self-explanatory ×5 framing on headline cards
Old labels were cryptic:
- 'total bill (input + output×5) · input-only: 71.1%' → wrong formula
- the m_pct_math block showed saved/baseline×100 but the card
  actually displays saved/(baseline+output×5)×100 — math didn't
  match the displayed number

New labels:
- m_pct card sub-line: '÷ (input + 5×output) — output billed at
  5× input rate · input-only: XX%'
- m_tokeq card sub-line: 'input + 5×output — output billed at
  5× input rate'
- m_tokeq tick sub-line: 'baseline N · input + 5×output'

m_pct_math now matches the displayed number:
- shows share_of_bill = saved / (baseline_input + output × 5) × 100
- breaks out saved, baseline_input, output × 5, baseline_total,
  share_of_bill, and input-only % as a labeled sub-line
- adds a 'why include output' note explaining the weekly-meter
  framing the README already documents

Pure label/formula-block edit. No math or wiring changes. 288 tests
pass, tsc --noEmit clean.
2026-05-20 16:33:10 -04:00
teamchong 6cbdea584b feat(proxy): SSE+JSON output measurement scanner
Adds a third tee branch in createProxy that walks the response body and
counts Unicode code units across text_delta / thinking_delta /
input_json_delta / tool_use blocks, plus a redacted_thinking block count.
Same shape on streaming (SSE) and non-stream (content[]) responses.

Numbers are independent of Anthropic's usage.output_tokens - they give a
real ruler against the redacted_thinking-inflated bill that surfaced in
the May-2026 weekly-meter audit (#22).

Wiring:
  OutputMeasurement type in proxy.ts (textChars, thinkingChars,
    toolUseChars, redactedBlockCount)
  teeForUsage now returns measurementPromise alongside usagePromise +
    errorBodyPromise; Promise.all in handle() awaits all three
  ProxyEvent gains optional measurement: OutputMeasurement
  TrackEvent gains text_chars_measured / thinking_chars_measured /
    tool_use_chars_measured / redacted_block_count_measured (optional,
    non-breaking JSONL shape)
  toTrackEvent() copies them through

Tests (6 new in proxy-usage.test.ts):
  text_delta count across multi-event SSE
  message_delta output_tokens overrides message_start placeholder
  thinking_delta + redacted_thinking block count
  tool_use input_json_delta reassembly
  non-stream content[] walks same shape
  5xx no-body path leaves measurement undefined

288 tests pass, tsc --noEmit clean, dist/node.js builds.

Dashboard surfacing lands in a follow-up so the wire-up stays reviewable.

Refs #22
2026-05-20 15:53:58 -04:00
teamchong 614d271b63 feat(dashboard): show token-equivalent total (input + output×5)
The fifth headline card surfaces the absolute number Anthropic's weekly
meter actually sees: input + output×5 in input-token-equivalents. Pairs
with the baseline counterfactual in the sub-line so a heavy-output day
shows as a real spike instead of hiding behind a flat 73% gauge.

Grid CSS bumps to 5 columns with a middle breakpoint at 1200px so the
cards don't crush at typical widths. No new computation — both fields
already lived in the /proxy-stats payload from #20.
2026-05-20 15:07:48 -04:00
teamchong 379813ca92 fix(baseline): correct cache-aware unproxied counterfactual on warm turns
The old single-rate weight (cr>0 ? 0.10 : cc>0 ? 1.25 : 1.0) collapsed
the entire cacheable prefix to one class. On warm turns the proxied path
bills cc * 1.25 for the new user-typed tail AND cr * 0.10 for the rest
of the prefix - but the formula attributed 100% to cr * 0.10, making the
unproxied path look ~12.5x cheaper than reality and producing negative
'saved' on every warm row.

The corrected per-event break-even (no compression, identical content):

  cold start (cr=0, cc>0): ccU = cacheable,                 crU = 0
  warm turn  (cc>0, cr>0): ccU = min(cc, cacheable),        crU = cacheable - ccU
  no caching (cc=0, cr=0): ccU = 0,                         crU = 0
  cache-read (cr>0, cc=0): ccU = 0,                         crU = cacheable

  baseline_eff = ccU*1.25 + crU*0.10 + coldTail*1.0

Centralized as computeBaselineInputEff() and computeActualInputEff() in
src/core/baseline.ts, used by the live dashboard (dashboard.ts), the
JSONL replay path (dashboard.ts), and per-session rollup (sessions.ts).

Verified on the May-2026 regression (7 events, mixed cc/cr, not negative):
pre-fix sum = -9,786 'saved' tokens, post-fix sum = +19,452 tokens,
matching the per-event compression delta (~+2,780/event) exactly.

Aggregate impact on the 914-event corpus: 'saved %' moves from 73.1% to
73.2% - the headline barely shifts because warm turns are a minority,
but per-event dashboard rows no longer show false negatives.

282 tests pass, tsc --noEmit clean.
2026-05-20 14:58:11 -04:00
teamchong 51c8f10012 docs(README): drop duplicate blockquote, change headline to "pixels instead of text"
- Headline: "pixels instead of tokens" → "pixels instead of text" (images
  bill as tokens too — the honest framing is pixels vs text)
- Removed the redundant "Why this works in 2026" blockquote since the
  Opus 4.7 explanation already lives in the intro paragraph
2026-05-20 13:38:49 -04:00