Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,48 @@ All notable changes to pxpipe are documented here. This project adheres to
[Semantic Versioning](https://semver.org/) (pre-1.0: minor = features /
behavioral changes, patch = fixes).

## Unreleased — 2026-07-19

### Added
- **Adaptive chars-per-token: the profitability gate can learn text density from
telemetry instead of using hand-tuned constants.** Opt in with
`PXPIPE_ADAPTIVE_CPT=1`; unset, gate behavior is byte-identical to before. The
gate prices text as `chars / CHARS_PER_TOKEN` — a constant of 4 for
reminders/tool_results and 2.0 for slab/history — while telemetry
([plan](docs/ADAPTIVE_CPT_PLAN.md)) measured real marginal density near ~1.5 on
dense content, so the gate under-counted text cost and passed up profitable
compressions. A dependency-free 6-bucket least-squares fit
(`src/core/cpt-fit.ts`, pure/Workers-safe) over the `bucket_chars`,
`baseline_tokens` and `image_pixels` fields already logged to
`~/.pxpipe/events.jsonl` produces per-bucket rates; `src/cpt-store.ts`
(Node-only) reads the log, persists `~/.pxpipe/cpt-state.jsonl`, and serves the
gate a resolver. Image cost is subtracted on the 28×28 patch grid from
`anthropic-vision.ts` (exact for pxpipe's 1568×728 pages, both whole multiples
of 28). Learns both a per-`system_sha8` table and a pooled cross-project
fallback. Precedence: explicit host `charsPerToken` → learned → baked constant.
Every guard (min 20 samples, min 8 bucket appearances, plausible band 0.8–6.0,
condition-number check, non-positive slope, throwing resolver) falls back to
the constant **per bucket** — a wrong learned rate costs money, a missing one
is just today's behavior. Refits are throttled and run in the background, never
blocking a request. New `cpt_source` / `cpt_used` event fields record which
price each gate bucket ran with.

### Known limitations / evidence
- Reproduce without fixtures or screenshots:
`npx tsx eval/adaptive-cpt/demo-cpt.mts` seeds a synthetic event log at a known
density, recovers it, and prices one request three ways — showing the constant
declining a compression that a learned rate correctly takes.
- Ships **verified correct but not measured for savings.** The fit recovers CPTs
it was generated from within 5% (including under noise), every guard fails
closed, and a learned rate demonstrably changes a real gate verdict in both
directions. How much this saves on production traffic is **not** established —
that needs a shadow-mode A/B. No savings figure is claimed until that run
exists, which is why the feature is opt-in rather than default.
- Workers keeps the baked constants (no filesystem for the sidecar); only the
Node path learns.
- Cold start: baked constants apply until a project has ≥20 events and the first
background refit completes.

## 0.9.0 — 2026-07-14

### Changed
Expand Down
71 changes: 71 additions & 0 deletions eval/adaptive-cpt/demo-cpt.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Demo B — adaptive CPT: the gate learns, and the learned rate changes decisions.
* Self-contained: seeds a synthetic events log, fits it, and A/Bs the real gate.
* Run: npx tsx eval/adaptive-cpt/demo-cpt.mts
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildCptState, resolverFor, writeCptState } from '../../src/cpt-store.js';
import { transformRequest } from '../../src/core/transform.js';
import { ANTHROPIC_PATCH_PX } from '../../src/core/anthropic-vision.js';
const PIXELS_PER_VISUAL_TOKEN = ANTHROPIC_PATCH_PX * ANTHROPIC_PATCH_PX;

const TRUE_CPT = 1.5; // the density we secretly encode
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pxpipe-demo-'));
const log = path.join(dir, 'events.jsonl');

// ── 1. Seed a log that behaves as if this project's slab text is 1.5 chars/token
let s = 7; const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0x100000000);
const rows = Array.from({ length: 60 }, () => {
const chars = Math.floor(20000 + rnd() * 30000);
const px = Math.floor(rnd() * 400000);
return JSON.stringify({
ts: new Date().toISOString(), system_sha8: 'demo1234',
bucket_chars: { static_slab: chars }, image_pixels: px,
baseline_tokens: chars / TRUE_CPT + px / PIXELS_PER_VISUAL_TOKEN,
});
});
fs.writeFileSync(log, rows.join('\n') + '\n');
console.log(`1. Seeded ${rows.length} events encoding a TRUE slab CPT of ${TRUE_CPT}`);
console.log(` ${log}`);

// ── 2. Learn from it
const state = await buildCptState(log);
writeCptState(state, path.join(dir, 'cpt-state.jsonl'));
const fit = state.fits.get('demo1234')!;
console.log(`\n2. Learned from the log:`);
console.log(` static_slab CPT = ${fit.cpt.static_slab?.toFixed(4)} (truth ${TRUE_CPT}) ` +
`${Math.abs((fit.cpt.static_slab ?? 0) - TRUE_CPT) < 0.05 ? '✅ recovered' : '❌'}`);
console.log(` n_events = ${fit.nSamples}`);
console.log(` buckets with no data were rejected, with reasons:`);
for (const [b, why] of Object.entries(fit.rejected)) console.log(` - ${b}: ${why}`);

// ── 3. Show the learned rate changing a real gate decision
const enc = new TextEncoder();
const slabReq = (lines: number, width: number) => enc.encode(JSON.stringify({
model: 'claude-3-5-sonnet',
system: Array.from({ length: lines }, () => 'a'.repeat(width)).join('\n'),
messages: [{ role: 'user', content: 'hi' }],
}));

console.log(`\n3. The same request, priced three ways (sparse slab: 900 lines × 32 chars):`);
for (const [label, opts] of [
['baked constant (2.0)', { reflow: false as const }],
['learned dense (1.0)', { reflow: false as const, cptFor: () => 1.0 }],
['learned sparse (6.0)', { reflow: false as const, cptFor: () => 6.0 }],
] as const) {
const { info } = await transformRequest(slabReq(900, 32), opts as any);
console.log(` ${label.padEnd(22)} → imaged=${String(info.compressed).padEnd(5)} ` +
`cpt=${info.cptUsed?.static_slab} (${info.cptSource?.static_slab})` +
`${info.compressed ? '' : ` reason=${info.reason}`}`);
}
console.log(`\n ↑ The decision FLIPS purely from the price of text — that is the feature.`);

// ── 4. Per-project beats pooled
const resolve = resolverFor(state);
console.log(`\n4. Resolver: known project → ${resolve('static_slab', 'demo1234')?.toFixed(4)}` +
`, unknown project → ${resolve('static_slab', 'ffffffff')?.toFixed(4)} (pooled fallback)`);
console.log(` history bucket (no data) → ${resolve('history', 'demo1234') ?? 'undefined → baked constant 2.0'}`);

fs.rmSync(dir, { recursive: true, force: true });
218 changes: 218 additions & 0 deletions src/core/cpt-fit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* Adaptive chars-per-token (CPT) — the fit.
*
* The profitability gate compares `imageTokens` (exact, from pixel area) against
* `textTokens = chars / CPT`. CPT was a hand-tuned constant per call site (4 for
* reminders/tool_results, 2.0 for slab/history). Production telemetry showed the
* real marginal density is ~1.5 for dense content, so the constant under-counted
* text cost and biased the gate toward passthrough — leaving savings on the table.
*
* This module learns CPT from the events pxpipe already logs. Model: a request's
* TEXT token cost decomposes into a per-bucket marginal rate times that bucket's
* char count,
*
* textTokens ≈ Σ_b α_b · chars_b with CPT_b = 1 / α_b
*
* where `textTokens` is the observed `baseline_tokens` (a free count_tokens probe
* on the ORIGINAL uncompressed body) minus the image cost, priced off Anthropic's
* 28×28-patch grid (see `PIXELS_PER_VISUAL_TOKEN` in `src/cpt-store.ts`, which
* builds the samples). Solved by ordinary least squares
* via the normal equations `α = (XᵀX)⁻¹ Xᵀy`. XᵀX is at most 6×6, so a hand-rolled
* Gauss-Jordan inverse is microseconds and needs no dependency.
*
* Pure math — no `fs`, no `process`, no `Buffer`. Safe to import from the Workers
* build. Reading the event log and persisting state is `src/cpt-store.ts` (Node).
*
* The fit is deliberately conservative: every guard below fails a bucket CLOSED
* (back to the baked constant) rather than open. A wrong learned CPT would make
* the gate image unprofitably, which costs real money; a missing one just returns
* today's behavior.
*/

import type { BucketName } from './transform.js';

/** Column order for the design matrix. Stable + explicit so a fit is reproducible. */
export const CPT_BUCKETS: readonly BucketName[] = [
'static_slab',
'reminder',
'tool_result_json',
'tool_result_log',
'tool_result_prose',
'history',
];

/** Minimum events before any fit is trusted. Below this, a slope is noise. */
export const MIN_SAMPLES = 20;
/** A bucket must actually appear this many times to get its own column;
* an all-but-empty column makes XᵀX singular and the slope meaningless. */
export const MIN_BUCKET_PRESENCE = 8;
/** Plausible CPT band. Real content runs ~1.2 (dense JSON) to ~4 (prose);
* anything outside this is a fit artifact, not a measurement. */
export const CPT_PLAUSIBLE_MIN = 0.8;
export const CPT_PLAUSIBLE_MAX = 6.0;
/** Reject the whole fit above this pivot ratio — the buckets are too collinear
* to separate (e.g. reminders that always grow with tool_results). */
export const MAX_CONDITION = 1e8;

/** One request's regressors + target. */
export interface CptSample {
/** Pre-compression TEXT chars per bucket (the logged `bucket_chars`). */
bucketChars: Partial<Record<BucketName, number>>;
/** Observed text tokens: `baseline_tokens − imagePixels / 750`. */
textTokens: number;
}

export interface CptFitResult {
/** Learned chars-per-token, per bucket. Absent = use the baked default. */
cpt: Partial<Record<BucketName, number>>;
nSamples: number;
/** Why each bucket was NOT learned. Surfaced so a null result is explainable. */
rejected: Partial<Record<BucketName, string>>;
/** Pivot ratio of XᵀX. Higher = less trustworthy; > MAX_CONDITION rejects. */
conditionEstimate: number;
/** Buckets that got a column in this fit. */
active: BucketName[];
}

/**
* Invert a square matrix by Gauss-Jordan elimination with partial pivoting.
* Returns `null` when the matrix is singular. `condition` is the ratio of the
* largest to smallest pivot — a cheap stand-in for the true condition number,
* good enough to detect "these columns are not independent".
*/
function invertMatrix(src: readonly number[][]): { inv: number[][]; condition: number } | null {
const n = src.length;
if (n === 0) return null;
// Augment [A | I] and reduce the left half to identity.
const a: number[][] = src.map((row, i) => {
const aug = new Array<number>(2 * n).fill(0);
for (let j = 0; j < n; j++) aug[j] = row[j] ?? 0;
aug[n + i] = 1;
return aug;
});

let minPivot = Infinity;
let maxPivot = 0;

for (let col = 0; col < n; col++) {
let best = col;
for (let r = col + 1; r < n; r++) {
if (Math.abs(a[r]![col]!) > Math.abs(a[best]![col]!)) best = r;
}
const pivot = Math.abs(a[best]![col]!);
if (!Number.isFinite(pivot) || pivot === 0) return null;
minPivot = Math.min(minPivot, pivot);
maxPivot = Math.max(maxPivot, pivot);

if (best !== col) {
const tmp = a[best]!;
a[best] = a[col]!;
a[col] = tmp;
}

const d = a[col]![col]!;
for (let j = 0; j < 2 * n; j++) a[col]![j] = a[col]![j]! / d;

for (let r = 0; r < n; r++) {
if (r === col) continue;
const f = a[r]![col]!;
if (f === 0) continue;
for (let j = 0; j < 2 * n; j++) a[r]![j] = a[r]![j]! - f * a[col]![j]!;
}
}

const inv = a.map((row) => row.slice(n));
return { inv, condition: minPivot > 0 ? maxPivot / minPivot : Infinity };
}

/** Reject every bucket with one reason. Used for the whole-fit bailouts. */
function rejectAll(reason: string): Partial<Record<BucketName, string>> {
const out: Partial<Record<BucketName, string>> = {};
for (const b of CPT_BUCKETS) out[b] = reason;
return out;
}

/**
* Fit per-bucket CPT from samples. Never throws; a fit it cannot trust comes back
* with an empty `cpt` and a populated `rejected` explaining why.
*/
export function fitCpt(samples: readonly CptSample[]): CptFitResult {
const n = samples.length;
if (n < MIN_SAMPLES) {
return {
cpt: {},
nSamples: n,
rejected: rejectAll(`n=${n} < MIN_SAMPLES=${MIN_SAMPLES}`),
conditionEstimate: Infinity,
active: [],
};
}

const rejected: Partial<Record<BucketName, string>> = {};

// Only give a column to buckets that actually show up. An all-zero (or
// nearly-all-zero) column is exactly collinear with nothing and makes the
// normal equations singular.
const active: BucketName[] = [];
for (const b of CPT_BUCKETS) {
let present = 0;
for (const s of samples) if ((s.bucketChars[b] ?? 0) > 0) present++;
if (present < MIN_BUCKET_PRESENCE) {
rejected[b] = `present in ${present}/${n} samples < ${MIN_BUCKET_PRESENCE}`;
} else {
active.push(b);
}
}
if (active.length === 0) {
return { cpt: {}, nSamples: n, rejected, conditionEstimate: Infinity, active };
}

// Accumulate XᵀX and Xᵀy in one pass.
const k = active.length;
const xtx: number[][] = Array.from({ length: k }, () => new Array<number>(k).fill(0));
const xty = new Array<number>(k).fill(0);
for (const s of samples) {
if (!Number.isFinite(s.textTokens)) continue;
const row = new Array<number>(k);
for (let i = 0; i < k; i++) row[i] = s.bucketChars[active[i]!] ?? 0;
for (let i = 0; i < k; i++) {
const ri = row[i]!;
xty[i] = xty[i]! + ri * s.textTokens;
for (let j = 0; j < k; j++) xtx[i]![j] = xtx[i]![j]! + ri * row[j]!;
}
}

const inverted = invertMatrix(xtx);
if (!inverted) {
for (const b of active) rejected[b] = 'singular XᵀX (collinear buckets)';
return { cpt: {}, nSamples: n, rejected, conditionEstimate: Infinity, active };
}
if (inverted.condition > MAX_CONDITION) {
for (const b of active) {
rejected[b] = `ill-conditioned (${inverted.condition.toExponential(1)} > ${MAX_CONDITION.toExponential(0)})`;
}
return { cpt: {}, nSamples: n, rejected, conditionEstimate: inverted.condition, active };
}

// α = (XᵀX)⁻¹ Xᵀy, then CPT = 1/α with a plausibility band.
const cpt: Partial<Record<BucketName, number>> = {};
for (let i = 0; i < k; i++) {
const bucket = active[i]!;
let alpha = 0;
for (let j = 0; j < k; j++) alpha += inverted.inv[i]![j]! * xty[j]!;

if (!Number.isFinite(alpha) || alpha <= 0) {
rejected[bucket] = `non-positive slope (${Number.isFinite(alpha) ? alpha.toExponential(2) : 'NaN'})`;
continue;
}
const value = 1 / alpha;
if (value < CPT_PLAUSIBLE_MIN || value > CPT_PLAUSIBLE_MAX) {
rejected[bucket] =
`cpt ${value.toFixed(2)} outside [${CPT_PLAUSIBLE_MIN}, ${CPT_PLAUSIBLE_MAX}]`;
continue;
}
cpt[bucket] = value;
}

return { cpt, nSamples: n, rejected, conditionEstimate: inverted.condition, active };
}
12 changes: 12 additions & 0 deletions src/core/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export interface TrackEvent {
'history',
number
>>;
/** Adaptive CPT: which chars-per-token source each gate bucket used this request
* (`host` = caller-pinned, `learned` = fitted from telemetry, `default` = baked
* constant), and the value it ran with. Lets a shadow-mode/eval run attribute a
* decision change to the learned rate. Absent when no gate bucket fired. */
cpt_source?: Partial<Record<string, 'host' | 'learned' | 'default'>>;
cpt_used?: Partial<Record<string, number>>;
/** TEXT chars that fed the history-image renderer; separate from bucket_chars because it credits a synthetic message. */
history_text_chars?: number;
/** sha8 of the collapsed history image. Unchanged across turns proves the prompt cache is hitting (cache_read).
Expand Down Expand Up @@ -262,6 +268,12 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent {
// Omit empty object so noop-pass requests stay lean; presence means at least one gate fired.
out.bucket_chars = info.bucketChars;
}
// Adaptive CPT: only emitted when a learned rate actually reached a gate, so
// default-constant traffic keeps byte-identical rows.
if (info.cptSource && Object.values(info.cptSource).some((s) => s === 'learned')) {
out.cpt_source = info.cptSource;
if (info.cptUsed) out.cpt_used = info.cptUsed;
}
if (info.historyTextChars !== undefined && info.historyTextChars > 0) {
out.history_text_chars = info.historyTextChars;
}
Expand Down
Loading