Skip to content

Commit 50830a4

Browse files
authored
Merge pull request #33 from diegosouzapw/eval/port-guards-ablation
eval(guards): harness to isolate the IDS block's read value on the Fable path
2 parents ed76ba0 + 2ee83ce commit 50830a4

4 files changed

Lines changed: 267 additions & 0 deletions

File tree

eval/ab/guards-3arm/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
work/

eval/ab/guards-3arm/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Guard ablation — do IDS rows earn their pixels on the Fable read path?
2+
3+
**Question.** OmniGlyph appends two guards to imaged content: a text **fact
4+
sheet** (rides beside the image, `factSheetText`) and an in-image **IDS block**
5+
(`appendIdsBlock`, ~17 rows / ~290 image tokens per page). The fact sheet's
6+
recall value is measured. The IDS block's is not — this harness isolates it.
7+
8+
**Design.** Three arms over the same synthetic session-log corpus (12-hex ids +
9+
unique `dur_ms`), rendered through the real production dense path
10+
(`renderTextToPngsWithCharLimit` at `DENSE_CONTENT_COLS` /
11+
`DENSE_CONTENT_CHARS_PER_IMAGE`, `DENSE_RENDER_STYLE`):
12+
13+
| arm | image | prompt |
14+
|---|---|---|
15+
| A | `appendIdsBlock(text)` rendered in | + fact sheet (production) |
16+
| B | plain text rendered in | + fact sheet |
17+
| C | plain text rendered in | no fact sheet |
18+
19+
The query ("which id has `dur_ms=X`") forces the association to come from the
20+
image; a guard can only help as exact-spelling correction. **A vs B** (paired,
21+
same golds) isolates the IDS block; **B vs C** isolates the fact sheet.
22+
23+
## Run
24+
25+
```bash
26+
npx tsx eval/ab/guards-3arm/gen.mts # deterministic corpus + PNGs ($0)
27+
eval/ab/guards-3arm/run.sh A 1 # score arm A via the Claude Code CLI ($0)
28+
eval/ab/guards-3arm/run.sh B 1
29+
eval/ab/guards-3arm/run.sh C 1
30+
```
31+
32+
`run.sh` scores through the local `claude` CLI (subscription, $0). It takes a
33+
parallelism argument — **use `1`**. See the throttling note below.
34+
35+
`work/` (corpus, PNGs, factsheets, per-arm logs) is git-ignored: generated
36+
scratch and, until a clean run exists, must never masquerade as a receipt.
37+
38+
## Status — no OmniGlyph receipt yet; production unchanged
39+
40+
Upstream's synthetic run (n=24) reported **A 14/24 · B 14/24 · C 8/24**: the
41+
fact sheet delivers the recall lift, the IDS block shows no measurable effect.
42+
That is upstream evidence, not ours.
43+
44+
Our own run in this environment was **inconclusive**: the Claude Code CLI
45+
throttles under sustained sequential load, so arms B and C returned empty for
46+
every trial while arm A (run first) scored 10/24 with 8 of its own trials also
47+
empty — an environment artifact, not a reading signal (a manual single-shot arm-B
48+
call read the image and returned a candidate id; repeated probes then stalled).
49+
50+
**Because measurement precedes claims here, no production change was made.**
51+
`appendIdsBlock` still runs on every imaged leg. Dropping or gating the IDS
52+
block requires a clean pass of this harness (arms B and C non-empty) on
53+
OmniGlyph's own numbers — run it where the CLI is not rate-limited, or via an
54+
API key / OmniRoute leg, and record the three scores before touching
55+
`src/core/factsheet.ts` or the render lanes.

eval/ab/guards-3arm/gen.mts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* 3-arm guard eval generator (task: do IDS rows earn their ~290 image tokens/block
3+
* on the Fable path, on top of the text fact sheet?).
4+
*
5+
* Arms (rendered here; queried by run.sh):
6+
* A = production: PNG rendered from appendIdsBlock(text), fact sheet in prompt
7+
* B = fact-sheet only: plain PNG, fact sheet in prompt
8+
* C = none: plain PNG, no fact sheet
9+
*
10+
* Corpus: synthetic session-log JSONL (12-hex ids + unique dur_ms), 2 pages,
11+
* each < DENSE_CONTENT_CHARS_PER_IMAGE so one PNG per page. Query mirrors
12+
* eval/verbatim-15: "which id has dur_ms=X" — association must come from the
13+
* image; guards can only help as exact-spelling correction, which is exactly
14+
* the mechanism under test.
15+
*
16+
* Strata per page (4 golds sampled from each):
17+
* ids+sheet — id is in the in-image IDS block (and therefore in the sheet)
18+
* sheet-only — id is in the 96-token fact sheet but not the 16-row IDS block
19+
*
20+
* No "uncovered" stratum: Fable dense pages cap at 91 rows (728px / 8px cells), so a
21+
* single-image block holds ≤91 log lines and the 96-token sheet budget covers every id
22+
* in it. Uncovered ids only exist on multi-image blocks (one sheet per block, >96 ids).
23+
* Body is capped at 72 lines/page so arm A (body + 17 IDS rows) stays single-image.
24+
*
25+
* Known confound (noted, accepted): tier-0 ranking is length-desc then lexical-asc,
26+
* so strata correlate with leading hex chars. The decision-relevant contrast
27+
* (A vs B, paired per trial) is unaffected — same golds, same strata.
28+
*
29+
* Deterministic: seeded PRNG, no Date/random in emitted content.
30+
* Run: npx tsx eval/ab/guards-3arm/gen.mts
31+
*/
32+
import { writeFileSync } from 'node:fs';
33+
import { dirname, join } from 'node:path';
34+
import { fileURLToPath } from 'node:url';
35+
import {
36+
appendIdsBlock,
37+
extractFactSheetEntries,
38+
factSheetText,
39+
} from '../../../src/core/factsheet.js';
40+
import {
41+
DENSE_CONTENT_CHARS_PER_IMAGE,
42+
DENSE_CONTENT_COLS,
43+
DENSE_RENDER_STYLE,
44+
renderTextToPngsWithCharLimit,
45+
shrinkColsToContent,
46+
} from '../../../src/core/render.js';
47+
48+
const OUT = join(dirname(fileURLToPath(import.meta.url)), 'work');
49+
50+
function mulberry32(seed: number): () => number {
51+
let a = seed >>> 0;
52+
return () => {
53+
a = (a + 0x6d2b79f5) | 0;
54+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
55+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
56+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
57+
};
58+
}
59+
const rnd = mulberry32(0x3a7f00d);
60+
const randInt = (lo: number, hi: number) => lo + Math.floor(rnd() * (hi - lo + 1));
61+
const HEX = '0123456789abcdef';
62+
/** 12-hex id guaranteed to contain ≥1 digit (extraction pattern requires it). */
63+
function id12(): string {
64+
for (;;) {
65+
let s = '';
66+
for (let i = 0; i < 12; i++) s += HEX[Math.floor(rnd() * 16)];
67+
if (/\d/.test(s)) return s;
68+
}
69+
}
70+
function shuffle<T>(xs: T[]): T[] {
71+
const a = xs.slice();
72+
for (let i = a.length - 1; i > 0; i--) {
73+
const j = Math.floor(rnd() * (i + 1));
74+
[a[i], a[j]] = [a[j], a[i]];
75+
}
76+
return a;
77+
}
78+
79+
const OPS = ['render', 'upload', 'fetch', 'decode', 'verify', 'commit', 'snap', 'probe', 'merge', 'flush'];
80+
const PAGES = 3;
81+
const LINES_PER_PAGE = 72; // 72 body + 17 IDS rows ≤ 91-row page → arm A stays single-image
82+
const GOLDS_PER_STRATUM = 4;
83+
84+
interface Rec { page: number; id: string; dur: number }
85+
const durs = new Set<number>();
86+
function uniqDur(): number {
87+
for (;;) {
88+
const d = randInt(500, 9499);
89+
if (!durs.has(d)) { durs.add(d); return d; }
90+
}
91+
}
92+
93+
const trials: Array<{ i: number; page: number; stratum: string; dur: number; gold: string }> = [];
94+
const meta: Record<string, unknown>[] = [];
95+
96+
for (let page = 0; page < PAGES; page++) {
97+
const recs: Rec[] = [];
98+
const lines: string[] = [];
99+
let secs = 4 * 3600 + page * 1200;
100+
for (let n = 0; n < LINES_PER_PAGE; n++) {
101+
const id = id12();
102+
const dur = uniqDur();
103+
secs += randInt(1, 9);
104+
const hh = String(Math.floor(secs / 3600)).padStart(2, '0');
105+
const mm = String(Math.floor((secs % 3600) / 60)).padStart(2, '0');
106+
const ss = String(secs % 60).padStart(2, '0');
107+
const ms = String(randInt(0, 999)).padStart(3, '0');
108+
const line =
109+
`{"ts": "2026-07-12T${hh}:${mm}:${ss}.${ms}Z", "id": "${id}", "op": "${OPS[randInt(0, OPS.length - 1)]}", ` +
110+
`"dur_ms": ${dur}, "ok": ${rnd() < 0.9}, "lane": ${randInt(1, 8)}, "bytes": ${randInt(1000, 99999)}}`;
111+
lines.push(line);
112+
recs.push({ page, id, dur });
113+
}
114+
const pageText = lines.join('\n');
115+
116+
// Guard sets, computed with the real production code paths.
117+
const armAText = appendIdsBlock(pageText);
118+
const idsIdx = armAText.indexOf('\nIDS\n');
119+
if (idsIdx < 0) throw new Error(`page ${page}: appendIdsBlock added no IDS block`);
120+
const idsTokens = new Set(
121+
armAText.slice(idsIdx + 5).trim().split('\n').map((l) => l.trim().split(/\s+/).pop()!),
122+
);
123+
const sheetTokens = new Set(extractFactSheetEntries(pageText).map((e) => e.token));
124+
for (const t of idsTokens) {
125+
if (!sheetTokens.has(t)) console.warn(`page ${page}: IDS token not in sheet (unexpected): ${t}`);
126+
}
127+
const sheet = factSheetText(pageText);
128+
129+
// Strata.
130+
const s1 = recs.filter((r) => idsTokens.has(r.id));
131+
const s2 = recs.filter((r) => sheetTokens.has(r.id) && !idsTokens.has(r.id));
132+
const s3 = recs.filter((r) => !sheetTokens.has(r.id));
133+
for (const [name, pool] of [['ids+sheet', s1], ['sheet-only', s2], ['uncovered', s3]] as const) {
134+
for (const r of shuffle(pool).slice(0, GOLDS_PER_STRATUM)) {
135+
trials.push({ i: trials.length, page, stratum: name, dur: r.dur, gold: r.id });
136+
}
137+
}
138+
139+
// Render both variants with the exact production dense single-col path
140+
// (transform.ts:1362 minus/plus the appendIdsBlock wrap).
141+
const renderOne = async (text: string, file: string) => {
142+
const imgs = await renderTextToPngsWithCharLimit(
143+
text,
144+
shrinkColsToContent(text, DENSE_CONTENT_COLS),
145+
DENSE_CONTENT_CHARS_PER_IMAGE,
146+
DENSE_RENDER_STYLE,
147+
);
148+
if (imgs.length !== 1) throw new Error(`${file}: expected 1 png, got ${imgs.length}`);
149+
writeFileSync(join(OUT, file), imgs[0].png);
150+
return { w: imgs[0].width, h: imgs[0].height };
151+
};
152+
const dimA = await renderOne(armAText, `pageA${page}.png`);
153+
const dimP = await renderOne(pageText, `pageP${page}.png`);
154+
155+
writeFileSync(join(OUT, `factsheet${page}.txt`), sheet);
156+
writeFileSync(join(OUT, `corpus${page}.txt`), pageText);
157+
meta.push({
158+
page,
159+
lines: lines.length,
160+
chars: pageText.length,
161+
idsTokens: [...idsTokens],
162+
idsHexCount: [...idsTokens].filter((t) => /^[0-9a-f]{12}$/.test(t)).length,
163+
sheetTokenCount: sheetTokens.size,
164+
sheetHexCount: [...sheetTokens].filter((t) => /^[0-9a-f]{12}$/.test(t)).length,
165+
sheetChars: sheet.length,
166+
strata: { 'ids+sheet': s1.length, 'sheet-only': s2.length, uncovered: s3.length },
167+
dims: { A: dimA, P: dimP },
168+
});
169+
}
170+
171+
writeFileSync(join(OUT, 'trials.json'), JSON.stringify(trials, null, 1));
172+
writeFileSync(join(OUT, 'meta.json'), JSON.stringify(meta, null, 1));
173+
console.log(JSON.stringify(meta, null, 1));
174+
console.log(`trials: ${trials.length}`);

eval/ab/guards-3arm/run.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/bin/bash
2+
# 3-arm guard eval runner. usage: ./run.sh A|B|C [parallelism]
3+
# A = IDS rows in image + fact sheet in prompt (production)
4+
# B = plain image + fact sheet in prompt
5+
# C = plain image, no fact sheet
6+
set -u
7+
cd "$(dirname "$0")/work"
8+
ARM=${1:?arm A|B|C}
9+
PAR=${2:-3}
10+
# Claude Code CLI ($0 on a subscription). Override with CLAUDE_BIN.
11+
CLAUDE=${CLAUDE_BIN:-$(command -v claude || echo "$HOME/.claude/local/claude")}
12+
python3 -c "
13+
import json
14+
for t in json.load(open('trials.json')):
15+
print(t['i'], t['page'], t['stratum'], t['dur'], t['gold'])
16+
" > trials.txt
17+
run_one() {
18+
local i=$1 page=$2 stratum=$3 dur=$4 gold=$5
19+
local img prompt ans
20+
if [ "$ARM" = "A" ]; then img="$PWD/pageA${page}.png"; else img="$PWD/pageP${page}.png"; fi
21+
prompt="Read the image at ${img}. Find the JSON line whose dur_ms is exactly ${dur} and report ONLY its 'id' field value (12 hex chars), nothing else. Read it visually from the image; do not use code."
22+
if [ "$ARM" != "C" ]; then
23+
# Fact sheet exactly as production emits it (factSheetText), no extra coaching.
24+
prompt="${prompt}
25+
$(cat "factsheet${page}.txt")"
26+
fi
27+
ans=$("$CLAUDE" -p --model claude-fable-5 "$prompt" 2>/dev/null | tr -d '[:space:]' | grep -oE '[0-9a-f]{12}' | head -1)
28+
if [ "$ans" = "$gold" ]; then
29+
echo "HIT arm=$ARM trial=$i page=$page stratum=$stratum dur=$dur gold=$gold"
30+
else
31+
echo "MISS arm=$ARM trial=$i page=$page stratum=$stratum dur=$dur gold=$gold got=${ans:-EMPTY}"
32+
fi
33+
}
34+
export -f run_one
35+
export ARM CLAUDE
36+
xargs -P "$PAR" -L 1 bash -c 'run_one "$@"' _ < trials.txt | tee "results-${ARM}.log"
37+
echo "arm ${ARM}: $(grep -c '^HIT' "results-${ARM}.log")/$(wc -l < "results-${ARM}.log" | tr -d ' ') hits"

0 commit comments

Comments
 (0)