104 Commits

Author SHA1 Message Date
teamchong 6d1c46dd20 fix(cache): pin prefix-cache anchor to the byte-stable history image
Intermittent prefix-cache busts on long sessions (#11): the single cache
breakpoint relocated onto the LAST history image — the still-growing chunk
whose bytes change every window advance — so the slab+history prefix
re-created at 1.25x instead of being read.

- relocateAnchorToHistoryImage pins collapseHistory's carry-over ordinal
  (the last byte-stable chunk) instead of the last image.
- demoteProtectedHeadText keeps pxpipe's slab scaffolding (images +
  fact-sheet + the [End of rendered context.] boundary) verbatim and only
  quarantines the user's stale opening turn, so the relocator can still
  find the slab anchor (#14 demotion had clobbered the boundary it keys on).
- cachePrefixDigest joins parts with \x00 so block-boundary differences
  can't hash-collide into false prefix-stability.

520/520 green, tsc clean.
2026-06-26 00:17:10 -04:00
teamchong 2e8f89288b fix(factsheet): rank tokens by shape so short high-consequence ids survive
The 64-token budget was filled longest-first, so long low-risk doc-URLs evicted short zero-redundancy tokens (git SHAs, ports, CLI flags, CONST_IDS) — the strings a coding agent must reproduce verbatim. Allocate by priority tier (ids/flags/numbers > paths/versions > URLs, capped); preserve substring-collapse and byte-stable, cache-safe output. Adds an eviction regression test.
2026-06-25 19:58:54 -04:00
teamchong 78abd081e4 feat(factsheet): byte-stable fact-sheet of exact identifiers on Claude + OpenAI paths
Recovers precision-critical strings (paths/ids/versions) from the imaged
slab as cache-stable text so they survive OCR. Byte-identical across turns,
validated on both Claude and gpt-5.5 paths.
2026-06-25 17:23:03 -04:00
teamchong 8417380061 test: e2e guards for cache stability, dashboard honesty, and design behaviors
Test-only (no src changes). Adds fake-upstream + real-createProxy suites:

- cache-stability-e2e: byte-identical cacheable prefix across growth, marker
  conservation + relocation onto the history image, tool-pair integrity.
- savings-math-e2e: gate never images at a net loss; baselineImagedTokens
  cross-checked against the real o200k tokenizer; baselineTokens wired from
  the count_tokens probe.
- savings-honesty: categorical no-overclaim invariants (warm <= cold,
  saved==delta*weight, zero credit on passthrough) + per-model pricing guards
  (Anthropic 1.25/0.1 shared policy; GPT 0.1x model-gated).
- design-behavior-e2e: system-prompt imaging, history collapse (old imaged /
  recent legible), tools-in-recent-turns kept usable.
- dashboard-api: GPT cold-cache parity (full text delta, not 0.1x warm rate).

Each guard mutation-verified (broke source, confirmed red, reverted).
2026-06-23 12:30:16 -04:00
Steven Chong 9eb66f54f2 fix(history): anchor collapsed history recency 2026-06-23 06:37:44 -04:00
teamchong 08a2b0a118 feat(gpt): pin the live user request as text in history collapse
Autonomous GPT agents (OpenCode/gpt-5.5) send ONE human request then a long
run of tool turns. The lone request is the OLDEST turn, so history collapse
imaged it first and the model lost it — "I wonder what the user actually asked"
→ off-task drift (observed: agent edited compaction.ts instead of answering a
compare question). The live-request guard's "the request is the trailing user
message" heuristic assumes interactive chat and points at nothing here.

Fix: keep the most-recent user turn OVERALL as legible TEXT, spliced between
before-pin and after-pin history images inside the synthetic user message;
older user turns stay imaged (must not look live — snap-to-first-prompt guard).
The guard now echoes the request verbatim (capped). History is imaged on both
sides of the pin, so compression barely changes.

Cache safety (adversarially reviewed):
- Pin ONLY when the latest user turn is INSIDE the collapse range. If it's in
  the kept tail (ordinary interactive turn) it's already native text — pinning
  an older in-range turn would migrate the pin across collapse-chunk boundaries
  and re-image frozen history. Restricting to the latest turn fixes its position
  until the next prompt, so before/after sections stay byte-stable across a run.
- Undersized before-pin remainder merges into the previous before-section rather
  than emitting a sub-threshold (net-negative) image.

Both Chat Completions and Responses paths. 473 tests pass; tsc + build clean.
Not yet released — validate on the gpt-5.5 machine first.
2026-06-22 12:15:16 -04:00
teamchong 22d89ee37d fix(sessions): complete honest cache-math warmth gate
b9418bf repriced cr>0-but-no-prior turns warm in dashboard.ts (update + replay)
but left the identical gate in sessions.ts unfixed — so the Sessions panel still
fabricated inflated savings after a restart/eviction or on the first tracked turn
(a cr>0 turn priced cold bills the text counterfactual a 1.25x create on a prefix
we KNOW was cached). Mirror the dashboard fix so "honest cache math" holds on every
panel.

- sessions.ts: warm follows the observed read (cr>0) alone; with no fresh prior,
  assume the cacheable prefix was fully reused (prevCacheable = cacheable) rather
  than mispricing a known-warm read.
- sessions.test.ts: the "real prefix compression" case had cr=100 on its first
  turn (observably warm) yet asserted the cold-priced 22490 fabrication; corrected
  to the honest 1790 (warm) + 95 = 1885. Now a regression guard for this fix.
- dashboard.ts: drop the stale "no per-session state" replay comment — replay now
  mirrors update()'s per-session warmth.

Found by adversarial review of b9418bf. 462 tests pass; tsc clean.
2026-06-22 11:07:02 -04:00
Steven Chong b9418bfa6f fix(gpt): cap history images and honest cache math 2026-06-22 10:46:47 -04:00
Steven Chong f6be3d3f88 fix(openai): keep opening prompt collapsible 2026-06-21 17:37:51 -04:00
teamchong fe6b0ec2a8 feat: structure-through role attribution + turn-index recency anchor for history images
Collapsed-history images now carry who-said-what AND when, instead of leaving
the model to reconstruct it from a flattened, role-ambiguous blob (which led it
to resurface the opening turn as if it were the live request).

- Turn-index recency anchor: each collapsed turn serializes as <user t="N"> /
  <assistant t="N"> with an ABSOLUTE turn index (larger N = more recent), so the
  model can tell turn 1 from turn 60. Absolute (never "N ago"/"i of total") so
  frozen chunks stay byte-identical and keep hitting the prompt cache. Mirrored on
  the GPT path. (history.ts, openai-history.ts, openai.ts)
- colorByRole: role tags are tinted via a parallel "slot string" carried from
  serialize time (structure-through), replacing the parse-back that miscolored a
  body quoting a literal <tag>. (render.ts, history.ts)
- neutralizeSentinel: a pre-existing ↵ (U+21B5) in content is swapped to ⏎
  (U+23CE) in render-prep so reflow packs newlines instead of bailing to a raw,
  unpacked render — common when the content is about pxpipe itself. Render-only;
  originals preserved. (render.ts, transform.ts, history.ts, openai-history.ts)
- Per-model GPT vision/render profiles, retunable via PXPIPE_GPT_PROFILES,
  behavior-identical to the old hardcoded values. (gpt-model-profiles.ts)

Review fixes folded in:
- Banner intro/outro consolidated to a single source of truth; the GPT path now
  aliases the Anthropic constant instead of byte-copying it (can't drift).
- slotCopyBody neutralizes literal slot markers to a width-equivalent control
  char (U+0003), not a space the minifier would strip and misalign.

459 tests pass; tsc clean.
2026-06-21 17:17:08 -04:00
Steven Chong 47b5e412b3 feat: cache-stable history-collapse imaging for GPT (OpenAI) + Anthropic
Collapse old conversation history into rendered PNG sections so the model
reads a compact image instead of re-billed text, while preserving prompt
caching and tool-call behavior. Measures real vs compressed token/cost.

Core:
- GPT history collapse (openai-history.ts): append-only, o200k token-length
  sectioning. Sections seal only at a tool-closed boundary (open call-id set
  empty), so a function_call and its function_call_output never split across
  the collapse cut. Fixes the OpenAI 400 "No tool call found for function
  call output with call_id ..." that hit long Responses-API sessions.
- Anthropic cache contract (history.ts): append-only per-chunk rendering;
  cache_control markers are preserved/moved, never added; chunk boundaries
  align with caller marker seams for byte-stable prefix caching.
- GPT image budget (openai.ts): detail:'original' for gpt-5.x, flagship
  vision-multiplier fix, patch cap; schema-strip preserves real descriptions.
- Savings accounting (openai-savings.ts): cached_tokens + vision-token basis.

Model scope (applicability.ts):
- Default imaged scope = claude-fable-5 + gpt-5.6.
- gpt-5.5 and claude-opus-4-8 stay opt-in: same pipeline, but they degrade
  reading dense imaged history (gist drift), so silently imaging them by
  default is wrong. Promotion is gated on an OCR/recall threshold.

Dashboard: GPT + Anthropic rendering, per-family model toggles, persisted
metrics, thumbnail-expired session UI, reflow/newline handling.

Tests: cache-alignment (GPT + Anthropic), history sectioning + tool-boundary
invariants, savings, dashboard, sessions/restart-restore. 452 passing.
2026-06-20 21:11:24 -04:00
teamchong 8b15525cb9 fix(transform): relocate cache anchor onto history image (stop 1.25x re-create)
The history image sits after the slab in prefix order, but the single cache
breakpoint was anchored on the slab. So the largest block — the ~141k-token
history image — only cached when the caller's roaming downstream marker happened
to land after it. When it didn't, the whole history image re-created at the 1.25x
cache_create rate every turn instead of amortizing into 0.1x reads. On a warm
session that turned a real compression win into a net loss (the "411% more"
the dashboard correctly surfaced).

Fix is pure relocation: move pxpipe's single breakpoint from the slab onto the
LAST history image (which sits after the slab), so slab + history cache as one
stable prefix — created once, then read. Marker count is unchanged: pxpipe still
never ADDS a marker (Task #21), it only moves the caller's existing one. When no
slab imaged, the history path keeps its own relocation.

- transform.ts: relocateAnchorToHistoryImage on the collapse-success branch
- history.test.ts: two old "history carries no marker" invariants upgraded to
  assert the relocation (last history image carries it; exactly one marker total)
2026-06-19 16:39:46 -04:00
teamchong b20c976f61 fix(dashboard): restore Saved + Details on replayed rows after restart
replay() rebuilt RecentRow from the JSONL but never set
session_saved_so_far_delta, img_id, or contextHistory — so after a restart
old rows showed "-" in Saved and had no Details link, while live rows did.
Live update() does all three; replay() now matches.

- replay() sets session_saved_so_far_delta (baselineInputEff - actualInputEff,
  same cache-weighted math as live) so the Saved column survives a restart.
- replay() rebuilds the Context Map breakdown from persisted fields
  (baseline_tokens, bucket_chars, image_count, usage) with a synthetic
  nextImageId, and sets img_id/img_ids so the Details link resolves.
- The PNG ring is in-memory and not restorable; restored entries carry
  imageIds:[] and a `restored` flag, and the Details panel shows
  "thumbnails expired when the proxy restarted" instead of dead <img>s.
- +2 replay tests (restart-restore.test.ts).

376 tests pass; typecheck + build clean.

Claude-Session: https://claude.ai/code/session_01RV8uiMYNhrAdTUzJ9fkpcp
2026-06-19 16:06:59 -04:00
teamchong 48f82ffee2 fix(dashboard): hero uses cache-weighted tokens (match Details/Saved)
The session hero computed (1 - sent / raw_count_tokens_baseline) — the same
cache-blind math just removed from the Details panel. It priced the all-text
baseline at full rate, ignoring that most of it would have been cheap
cache-reads, so a warm net-loss session showed a big "fewer tokens" win up top
while Details/Saved showed a loss. Two panels, one session, opposite signs.

- renderSessionSummaryFragment now reads baselineInputWeighted /
  actualInputWeighted (the same cache-weighted pair as Details + the Saved
  column) instead of rawActualTokens / rawBaselineTokens.
- Headline is input-only; dropped the output-on-both-sides lumping (output is
  never "sent" and only dampened the %).
- Honest labels: "effective tokens … after normal cache discounts"; still no $
  assumptions (0.1x/1.25x are token multipliers, not $/Mtok).
- types.ts: corrected stale field comments (raw fields are math-drawer only).
- +4 hero tests pinning direction to the weighted pair and that output can't
  move the headline %.

374 tests pass; typecheck + build clean.
2026-06-19 15:58:22 -04:00
teamchong c899d7d836 fix(dashboard): Details headline uses cache-weighted tokens (match row)
The Details panel divided the raw count_tokens baseline by sent tokens
(cache-blind), over-claiming "X% smaller" even when the cache-aware row
showed a net loss — the two panels could flatly contradict each other.

- dashboard.ts: feed baselineInputEff/actualInputEff/haveBaseline into
  ContextMapData (same weighted pair the recent row uses); push after
  the eff-tokens are computed so the breakdown matches.
- fragments.ts: headline divides weighted baseline by weighted actual,
  says "bigger" on a net loss, with a clarifying sub-line; no savings
  claim when the baseline probe did not resolve.
- +4 tests (context-map.test.ts) pinning headline direction to the row.

370 tests pass; typecheck + build clean.
2026-06-19 14:38:17 -04:00
teamchong 97d50513aa feat(openai): GPT-5.x family + Responses API support
- Generalize isPxpipeSupportedGptModel to the GPT-5 family (gpt-5, 5.x,
  -mini/-nano), default-on; [variant] tags stripped.
- OpenAI render switches to the 768px portrait strip (GPT_STRIP_COLS=152)
  so the API's mandatory shortest-side->768 downscale never shrinks our
  5px glyphs; downscale-free in both tile and patch regimes.
- Add OpenAI vision-token cost model (resolveVisionCost/openAIVisionTokens)
  and rewire the Chat Completions profitability gate to use it instead of
  the Anthropic 750px/token model.
- Add transformOpenAIResponses for the /v1/responses (Responses API) shape
  used by Codex: instructions + input items + flat tools -> rendered
  input_image parts; wire it into the proxy (was passthrough before).
- Tests: +12 (gate family, vision-token math incl. downscale path, chat +
  responses transforms). 366 passing; Anthropic path unchanged.

README intentionally not updated (GPT OCR fidelity unverified until tested).
2026-06-19 13:47:44 -04:00
teamchong ef9ddac58a feat(library): caller fidelity + recoverable channel, edge-safe; trim comments
- keepSharp: caller predicate pins exact-match-critical blocks (IDs, hashes,
  paths) as text, overriding the chars/token heuristic. Reported via
  info.keptSharpBlocks. Never forces imaging; throwing predicate = false.
- emitRecoverable: optional recovery map (block -> original text + provenance)
  so a stateful caller can re-inject or re-fetch imaged content. The recover
  half of the fidelity contract; documented mitigation for lossy recall.
- edge/Workers-safe: process.env read is typeof-guarded; @napi-rs/canvas moved
  to devDependencies (atlas is build-time only, runtime PNG encoder is pure-JS).
- comments: trimmed ~1,640 lines of essay/derivation/changelog narration across
  src/ (transform.ts -73%), keeping one-line rationale; deep math points to docs/.
- New exports/types in index.ts + library.ts; +12 tests (keep-sharp, recoverable).

Build clean; 354/354 tests pass.
2026-06-19 13:02:30 -04:00
teamchong 716d9e0965 test: cap corpus L0 test to an 8MB per-file prefix
The real-corpus reflow check read whole transcript files; on a large ~/.claude/projects (multi-hundred-MB files) that blew the 30s timeout and aborted 'pnpm test' (hence prepublishOnly). Read only the first 8MB per file — enough for the per-file sample; a truncated trailing line just fails JSON.parse and is skipped. Corpus test now runs in ~5s.
2026-06-17 10:44:46 -04:00
teamchong 1cb62c1786 feat(dashboard): context-map transparency, flexible model chips, Opus default-off
- "How your context works" panel: per-request token flow (as-text -> real) plus
  the exact-char bucket breakdown of what became images and a gallery of every
  rendered page, opened via a "view" link on each recent-requests row.
- "compress models" chips are the union of a catalog (Fable 5, Opus 4.8/4.7,
  Sonnet 4.6, Haiku 4.5), the PXPIPE_MODELS env scope, and the active scope, so
  any env-enabled model stays toggleable (off <-> on); runtime-only override.
- Opus is OFF by default (Fable-5-only scope); opt-in via env or the chips.

Fixes from the code review:
- contextHistory cap aligned to the recent table (was 30 < 50, so older "view"
  links silently showed the latest request's breakdown); an evicted or
  unrecorded request now shows an explicit "no longer available" message.
- the session headline phrases a net loss as "N% more tokens" instead of
  rendering a garbled "-7% fewer tokens".
2026-06-17 10:01:33 -04:00
teamchong a9bda51210 feat(savings): honest negative accounting; drop the false >=0 clamp claim
Per-turn and per-session savings are the real baseline_eff - actual_eff with no
floor: a net-losing turn (cache_create-heavy image rewrite that costs more than
the text prefix it replaced) now reports the real loss instead of a fabricated
0. Updates baseline.ts and the caller comments that claimed a >=0 clamp the code
never applied -- the negative is intended and covered by tests/sessions.test.ts.
2026-06-17 10:01:33 -04:00
teamchong 8af9829872 feat(render): raise page ceiling to ~1932x1932 and align gate/paging geometry
Fable 5 / Opus 4.8 accept up to 2576px / 4784 visual tokens per image, but a
request with >20 images (pxpipe always sends many) is held to the stricter
<=2000px/side rule, so the real ceiling is ~1932x1932 (1928x1928 = 69x69 = 4761
tokens). MAX_HEIGHT_PX 1568->1932; dense tool/history pages now render at
DENSE_CONTENT_COLS=384 / DENSE_CONTENT_CHARS_PER_IMAGE=92160 (1928x1928 full
page) -- fewer image blocks at the same OCR-validated 5x8 cell. The static slab
is unchanged (313 cols / 1573x1280).

Also fixes a regression the code review caught: the break-even gate,
truncateForBudget, and estimateImageCount predicted against the slab geometry
(313 cols / 159 rows) while the dense renderer emits 384 cols / 240 rows, so
large tool_results were truncated far earlier than the 10-image cap required --
silently dropping output that would have rendered. A per-page char budget is
threaded through the prediction chain (defaults to READABLE so the slab and unit
tests are byte-identical) plus a denseGateGeometry helper, so the gate prices the
same page the renderer actually produces.
2026-06-17 10:01:33 -04:00
teamchong 514a4f0b74 feat(gate): config-driven model scope (PXPIPE_MODELS) + Opus 4.8 re-test
- applicability: isPxpipeSupportedModel reads PXPIPE_MODELS (comma-sep,
  default claude-fable-5 only) and strips bracket/variant tags like [1m];
  Opus becomes opt-in with no code change. Default behavior unchanged.
- FINDINGS: Opus 4.8 re-test - arithmetic 98/100 (-2pp), verbatim 6/15
  (was 93/100, 0/15): improved but still taxed -> default stays Fable-only.
- FINDINGS + eval/glyph-matrix/sweep: root cause of the read tax is per-glyph
  RESOLUTION, not font shape (confusions scale with px and vanish by 10x16).
  Opus needs ~4x glyph area to read reliably; the 1568px ceiling makes that
  break-even, so there is no profitable Opus operating point.
2026-06-16 12:22:55 -04:00
teamchong cb522a70fe feat(dashboard): replace Svelte bundle with AHA stack (htmx + Alpine, server-rendered fragments)
- src/dashboard/fragments.ts: server-side HTML fragment renderers reusing the
  same JSON payloads (HTML and JSON surfaces can't drift)
- node.ts/dashboard.ts: /fragments/<name> routes (header, session-summary,
  recent, latest, sessions, stats, toggle) with htmx polling + Alpine for
  local state; toggle is a POST-returning-fragment htmx swap
- vendored htmx 2.0.4 + Alpine 3.14.9 in src/dashboard/vendor.ts (offline,
  single-file npm package, no CDN)
- deleted Svelte components/stores/bundle build step; dropped svelte/vite
  devDeps; build is now just tsc + esbuild
- tests: +5 fragment contract tests (routing, toggle state, HTML escaping of
  source text, 404) - 339 total
2026-06-09 22:17:53 -04:00
teamchong 528034cd82 feat(proxy): cloudflare-ai-gateway provider mode
- PXPIPE_PROVIDER=cloudflare-ai-gateway + PXPIPE_GATEWAY_BASE_URL derive both
  family routes ({base}/anthropic, {base}/openai) from one configurable base
- PXPIPE_GATEWAY_HEADERS injects auth headers (JSON or k=v;k2=v2) on every
  upstream request incl. count_tokens probes
- OpenAI paths drop /v1 to match gateway shape; Anthropic paths unchanged;
  unknown paths pass through on the family route; streaming untouched
- no hardcoded hostnames; tests use fake gateway URLs/tokens (14 new)
2026-06-09 19:21:01 -04:00
teamchong 99bb931014 chore: rename all pixelpipe references to pxpipe
- code, tests, docs, README, wrangler.toml, help text
- data dir ~/.pixelpipe -> ~/.pxpipe (PXPIPE_LOG env var)
- rebuilt dist; 323/323 tests
2026-06-09 19:04:09 -04:00
teamchong 3d0f9ef08a scope: narrow model gate to fable-5, drop opus; switch dense render to bare 5x8 cell
- gate regex: fable-5 only (opus 4.7/4.8 read tax gone on fable: 100/100 novel-arithmetic, identical image billing)
- remove dead opus-4.6 cpt fork in transform.ts
- DENSE_RENDER_STYLE: 7x10 padded -> bare 5x8 cell (~42% fewer image tokens/page, recall flat in A/B)
- dashboard: compression defaults ON
- README/FINDINGS/TRANSFORM_INFO updated with 2026-06-09 measurements
2026-06-09 18:33:34 -04:00
Steven Chong 613ca3bc71 feat(proxy): support gpt 5.5 chat completions 2026-06-05 00:35:26 -04:00
teamchong 52f6b4a0e6 docs: apply FINDINGS reframe + ref fixes (follow-up to 2c38f30)
The prior commit captured only the rename (a stale pathspec aborted the
content staging). This applies the actual edits: retitle to FINDINGS,
'## Verdict' + superseded TL;DR, and the two cross-reference updates.
2026-05-29 22:47:12 -04:00
teamchong 378dd7f6c4 feat(applicability)!: scope to opus 4.7+ and enforce model gate at proxy
Widen the model gate to /^claude-opus-4-(?:[7-9]|[1-9]\d)(?:-|$)/ (Opus 4.7
and newer; previously 4.6/4.7). Wire isPixelpipeSupportedModel into the proxy
boundary in proxy.ts — the proxy previously gated on economics only and
ignored the model, so it compressed 4.8 traffic despite the docs claiming a
4.6/4.7 scope. Unsupported models now pass through with reason
'unsupported_model'.

Update public-api tests to the 4.7+ spec (320/320 pass).

Correct the docs to live measurement: reverse the POSTMORTEM "dead" verdict
(pixelpipe is a lossy gist-compressor saving ~68% on real dense Claude Code
traffic; the verbatim 0/15 needle finding stands as a caveat, not the
verdict), rewrite README Status/Limitations, and add a correction pointer to
the eval README. Original POSTMORTEM body preserved below the correction.

BREAKING CHANGE: Opus 4.6 and older are no longer supported.
Verbatim-risk guard (skip imaging unique IDs/hashes/exact values) still pending.
2026-05-29 22:34:55 -04:00
Steven Chong d3992c6bad feat(applicability): enable pixelpipe for opus 4.6 2026-05-26 15:35:12 -04:00
Steven Chong 36ac1faa96 fix(render): reduce dense page size 2026-05-26 13:15:54 -04:00
Steven Chong 63a7810b52 fix(history): preserve git status output as text 2026-05-26 11:05:59 -04:00
Steven Chong 09fb339b39 fix(render): use readable profile for dense images 2026-05-25 15:37:14 -04:00
Steven Chong 0e95b81728 fix(render): page dense tool and history images 2026-05-25 15:33:00 -04:00
Steven Chong d089f0d32b fix(transform): preserve system cache_control on slab image 2026-05-25 14:54:59 -04:00
Steven Chong be545b7ccd feat(render): full-canvas single-column rendering, 50k chars/page
Pixelpipe now always uses the full 1568px canvas (cols=313) and packs up
to 50,000 chars per image. The multi-column code path and the
shrink-to-content path are no longer used by transform.ts - both were
sacrificing token savings on dense content to make sparse content marginally
more readable, and the readability gain wasn't real (the renderer's
output was unreadable in either layout once the content was actually dense).

Key changes:
- READABLE_CHARS_PER_IMAGE: 6_000 -> 50_000
- DEFAULT_COLS: 100 -> 313 (full 1568px / 5px cell)
- shrinkColsToContent() is now a no-op (returns cols unchanged)
- MinToolResultChars / MinReminderChars default to 50k (was 6k)
- transform.ts always renders single-column at full canvas

Practical impact: a 6 KB tool_result that previously rendered as 4 narrow
508x488 pages (1324 image tokens, 86% of text cost) now renders as 1
full-canvas 1568x488 page (~331 image tokens, 22% of text cost) - 4x
more savings on the same content with no quality change.

Rebuilt dist/. Tests: 315/315 pass.
2026-05-25 14:20:43 -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
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 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
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 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 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