Commit Graph

63 Commits

Author SHA1 Message Date
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
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 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 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 d432b56dbc feat(proxy): capture missing usage fields (5m/1h cache split + server_tool_use)
Anthropic's /v1/messages usage block returns more fields than we were
capturing. The flat 4-field view (input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens) historically dropped:

- cache_creation.ephemeral_5m_input_tokens (1.25x rate price tier)
- cache_creation.ephemeral_1h_input_tokens (2x rate price tier)
- server_tool_use.web_search_requests (billed per-request, not per-token)

Extends:
- Usage interface (types.ts): nested cache_creation + server_tool_use
- TrackEvent (tracker.ts): cache_create_5m_tokens, cache_create_1h_tokens,
  web_search_requests as optional fields
- toTrackEvent copy logic: passes through when defined, omits when absent
  (a real 0 stays 0 - the dashboard needs to tell "missing" from "zero")

Also adds .gitignore entries for draumode MCP scratch files
(*.draumode.ts, *.excalidraw) so exploratory diagrams don't leak into
the repo.

Pure data collection. No behavior change. Sets up the dashboard fixes
(tasks #20 and #21) where the data will actually get rendered.

282 tests pass, typecheck clean, build clean.
2026-05-20 13:25:44 -04:00
teamchong 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.
2026-05-20 10:50:16 -04:00
teamchong 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.
2026-05-20 09:58:31 -04:00
teamchong 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 c36ec3f.

Why this matters more than the slab fix:
  • The slab is cached (cache_create×1.25 once, cache_read×0.10 after).
    Slab compression saves bytes mostly on cold misses + 2k tok/warm hit.
  • History content is NOT cached. Once turns leave the cache breakpoint
    budget (4 max per request), every subsequent request pays 100% for
    them. A long agentic session re-bills the same closed-prefix turns
    on every turn — exactly the workload Variant C was designed to
    compress, and exactly the path the gate was silently rejecting.

Apply same fix as c36ec3f: HISTORY_CHARS_PER_TOKEN=2.5 (upper bound of
empirical data, identical safety property to SLAB_CHARS_PER_TOKEN since
history content shape ⊆ slab content shape in tool-heavy sessions).

Host can still override via TransformOptions.charsPerToken. The
override-gate `o.charsPerToken !== CHARS_PER_TOKEN` means unspecified
requests (DEFAULTS.charsPerToken=4) get the empirical cpt while explicit
non-default host values win — same intentional pattern as the slab path.

Test: regression fixture pinning a 14-turn × 1500 chars/turn conversation
where:
  • cpt=4 path → 'not_profitable' rejection (image cost 5k > text 4k)
  • cpt=2.5 path → 'collapsed' with 10 turns merged (text 6.4k > image 5k)
Counterfactual half uses cpt=4.5 to bypass the override-gate (passing
exactly 4 collides with the DEFAULTS sentinel).

273 tests pass, typecheck clean, build clean.
2026-05-20 07:52:58 -04:00
teamchong 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.
2026-05-20 00:15:55 -04:00
teamchong 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.
2026-05-20 00:07:42 -04:00
teamchong 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 c3506e3 era);
that wasn't a "marginal upside" problem, it was a math bug. This commit
fixes the bug and turns the feature on.

Root cause of the -250% loss
----------------------------
`isCompressionProfitable()` has two paths: a row-aware path (string input,
matches `renderTextToPngs` image budgeting exactly) and a looser chars-only
path (number input, assumes dense lines). History.ts was calling it with
`text.length` (number) → loose chars-only estimate. History text is
newline-heavy (`--- role ---` headers, JSON arg blocks, [tool_use] /
[tool_result] labels), so the chars-only estimate under-counted images by
5-10× and let net-losers through the gate.

Secondary contributor: tool_use args were serialised with
`JSON.stringify(input, null, 2)` — pretty-printed, one short row per JSON
field. At cols=100 each field claimed a full row instead of wrapping at
~100 chars, inflating image count for tool-heavy histories.

Changes
-------
- src/core/history.ts
  - `ProfitableFn` signature: `(text: string, cols: number)` (was `textLen: number`).
    Documented the asymmetry so callers can't regress this.
  - `blocksToText` tool_use: compact `JSON.stringify(input)`, no indent.
  - Pass `text` (not `text.length`) into the profitability check.
- src/core/transform.ts
  - Delete `compressHistory`, `historyKeepTail`, `historyMinPrefix` from
    TransformOptions and DEFAULTS.
  - History collapse is now unconditional when `messages[]` is non-empty.
  - Wrap the gate in a closure that pins `numCols=1` + per-request `cpt`,
    so the gate's row-aware decision is byte-identical to the single-col
    renderer's actual image count.
- src/worker.ts: drop `COMPRESS_HISTORY`, `HISTORY_KEEP_TAIL`,
  `HISTORY_MIN_PREFIX` env vars from `Env` + transform options.
- src/node.ts: stale comment refresh — no toggles, period.
- tests/history.test.ts:
  - Rename describe "transformRequest + compressHistory" →
    "transformRequest history compression (always-on)".
  - Remove every `compressHistory: true, historyKeepTail: X, historyMinPrefix: Y`
    from transformRequest calls — options no longer exist.
  - Bump fixtures for default `keepTail=4` / `minCollapsePrefix=10`
    (14-turn body, 3500 chars/turn) so the now-honest row-aware gate
    accepts. Renamed "8-closed + 2-live" → "10-closed + 4-live" to match
    the new defaults.
  - Bump straddle-test per-turn body 2500→3500 chars: tool block labels
    add ~65 chars of header overhead that pushed the tighter fixture
    under the row-aware boundary.
  - Rename "JSON-pretty args" test → "COMPACT JSON args"; assert no
    pretty indentation.

Verification
------------
- npm test → 263/263 green
- npm run typecheck → clean
- npm run build → dist/node.js builds clean
2026-05-19 23:54:11 -04:00
teamchong 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.
2026-05-19 23:33:22 -04:00
teamchong 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.
2026-05-19 22:29:07 -04:00
teamchong 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
2026-05-19 22:00:14 -04:00
teamchong 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.
2026-05-19 21:28:26 -04:00
teamchong 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.
2026-05-19 17:01:05 -04:00
teamchong 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.
2026-05-19 16:02:47 -04:00
teamchong 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.
2026-05-19 15:44:07 -04:00
teamchong 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.
2026-05-19 15:09:17 -04:00
teamchong 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.
2026-05-19 14:56:30 -04:00
teamchong 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.
2026-05-19 14:56:30 -04:00
teamchong 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.
2026-05-19 14:56:30 -04:00
teamchong 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.
2026-05-19 14:56:30 -04:00
teamchong 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
2026-05-19 13:39:28 -04:00
teamchong 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'
2026-05-19 13:34:24 -04:00
teamchong 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.
2026-05-19 13:24:49 -04:00
teamchong 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.
2026-05-19 13:24:34 -04:00
teamchong 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.
2026-05-19 12:59:40 -04:00
teamchong 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.
2026-05-19 12:45:47 -04:00
teamchong 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.
2026-05-19 11:45:37 -04:00
teamchong 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.
2026-05-19 11:39:40 -04:00
teamchong 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 (f0176c1), those rejected as
not_profitable — no savings on the user's typical workload.

Synthetic measurement on 30 realistic tool schemas:
- pretty: 320,843 chars / 14,074 lines → 100 images
- compact: 124,720 chars / 179 lines → 10 images
- 2.57× char shrink, 78× row shrink, 10× image cost reduction

Compact JSON is unambiguous: descriptions are already stripped under
compressSchemas, so only structural keys (type/properties/required/
enum/items) remain — those read fluently on one wrapped row. Frontier
models handle compact JSON natively; it's the default wire format for
tool_use blocks anyway.

Test pins the density gain on a representative schema.
2026-05-19 08:27:41 -04:00
teamchong 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.
2026-05-19 06:39:05 -04:00
teamchong 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.
2026-05-18 23:01:12 -04:00
teamchong 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.
2026-05-18 22:46:13 -04:00
teamchong 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.
2026-05-18 22:38:15 -04:00
teamchong 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.
2026-05-18 22:14:00 -04:00
teamchong e7489ca1fb transform: per-block compression break-even check + honest dashboard
URGENT production-cost fix. Empirical data (orig_chars=169779, image_count=88)
showed we were paying ~220k image tokens to replace ~42k text tokens — a
net LOSS of ~178k tokens per request. Dashboard hid this via Math.max(0,...).

Two changes ship together:

1. isCompressionProfitable(textLen) gate in transform.ts. Before any
   per-block image encode (static slab, reminder, tool_result), compute:
     estImages × TOKENS_PER_IMAGE  vs  textLen / CHARS_PER_TOKEN
   Compress only when the image side wins. Constants:
     CHARS_PER_IMAGE  = 14100  (Unifont 10px, cell 5×11, 100 cols)
     CHARS_PER_TOKEN  = 4
     TOKENS_PER_IMAGE = 2500   (empirical, history-researcher round 3)
   Result: blocks under ~10k chars stay as text. Static slab still
   compresses (always above break-even).

2. Coarse thresholds bumped as fast-path:
     minReminderChars   1000 → 5000
     minToolResultChars 5000 → 10000
   (Cover the obvious-no cases without invoking the precise check.)

3. Dashboard honesty: removed Math.max(0,...) clamp in baselineCost().
   Negative savings now display as negative (no more hidden cost-bleed).

4. Telemetry: passthrough_reasons.below_threshold + .not_profitable
   counters surface how often each gate fires.

After restart, dashboard 'saved' should jump from clamped-0 to a real
positive number (~1-3% on aggregate; static slab compression is the
only block consistently profitable at our current cell).
2026-05-18 22:13:10 -04:00
teamchong e328c93d52 atlas: bit-pack pixel storage (1 bit/pixel) for 3.15× bundle shrink
Unifont glyphs are inherently 1-bit (every pixel is 0 or 255). Storing as
8-bit bytes wasted 7 bits per pixel.

Changes:
- scripts/gen-atlas.ts: emit ATLAS_PIXELS as bit-packed MSB-first. OFFSETS
  now indexes bits not bytes. Per-cell paint thresholds R-channel at 128
  defensively (Unifont is already 0/255).
- src/core/render.ts:blitGlyph: byte read replaced with bit extraction
  (~3 extra ops per pixel; negligible). max() blending removed since bit
  data is binary, no AA edges, no glyph overlap.
- src/core/atlas.ts: regenerated with the new format. Header comment
  documents the bit-extraction formula.
- tests/render.test.ts: +1 structural assertion that ATLAS_PIXELS.byteLength
  matches the bit-packed total.

Bundle size:
  atlas.ts raw:    5.47 MB → 1.06 MB  (5.2× shrink)
  atlas.ts gzip:   1.55 MB → 479 KB   (3.2× shrink)
  dist gzipped:    1.57 MB → 488 KB   (3.15× shrink, well under Workers
                                       free-tier 1 MB cap)

Pre-deflate shrink is 8× (theoretical max). The 3.15× post-deflate
result reflects that deflate was already exploiting long runs of 0/255
in the 8-bit data; the bit-packed bytes are denser and less compressible.

Render output is byte-identical to pre-bit-pack (storage format change
only). 108/108 vitest, 4/4 shell tests, typecheck + build clean.
2026-05-18 21:54:07 -04:00
teamchong efcd2a5e3e render: R1 whitespace minify before wrap
Adds minifyForRender() as step 1 of wrapLines() pipeline. Strips trailing
whitespace per line and collapses 4+ consecutive newlines to 3 (= 2 blank
lines). Leading whitespace + mid-line spaces preserved (structural).

HANDOFF item R1 predicted ~1.5-2× chars per image. Empirical measurement
on real corpora (v3 corpus, all .md docs, all .ts source, 2.3 MB live
conversation jsonl) showed 0% reduction — modern editors strip trailing
whitespace on save by default. R1 is shipped as defensive infrastructure
that fires when log-dump-heavy / unminified content arrives.

+9 tests covering trailing-strip, blank-line collapse, structural-space
preservation, and combined-with-tab-expansion pipeline. 107/107 vitest.
2026-05-18 21:40:01 -04:00
teamchong cc7f0ed4b8 tests: align tab-expansion test expectations with the visible-arrow shape
Previous test expectations had been written against an older silent-space
tab expansion. The arrow form (\t → "→" + padding to tab stop) was already
in src/core/render.ts; this just brings the tests into agreement.

Includes 7 expandTabsInLine tests covering col 0/1/2/3 tab boundaries, the
fast-path for tab-free lines, and EAW-aware width counting for tabs after
CJK glyphs. Total: 98/98 vitest passing.
2026-05-18 21:32:57 -04:00