feat(review): blind reviewer and adjudication tool with an append-only label ledger (#26)
Quality gates / quality (pull_request) Canceled after 0s

The corpus manifest records what was ingested and whether the person still allows it; it does not
record whether the labels are right. This adds the labeling control the data-flow threat model
named: scripts/review_corpus.py, a command-line tool with no app screen.

- The review queue shows the contributor's proposal and carries no model field at all: blindness is
  structural, because build_queue() never receives predictions.
- reveal() refuses a contribution with no committed review, so the model answer cannot steer the
  human who labels it. Revealing appends nothing.
- A review is one append-only line carrying the proposal it judged; a correction must change it. An
  adjudication is a second reviewer's line that names the record it resolves; the review it
  overrules survives untouched.
- A withdrawn contribution cannot be queued, reviewed, revealed, adjudicated, or trained on, and the
  refusal says it was withdrawn rather than that it is already reviewed.
- trainable_records() returns only active samples with a human decision that was not an exclusion,
  so unreviewed proposals are never training data.

Tests are written first against every refusal (tests/corpus-review.test.py, 21 cases); the release
builder runs the suite and gates four on-camera captions, and the demo now runs the real queue ->
review -> reveal -> adjudicate -> withdraw sequence against the committed synthetic sample and draws
the evidence card from that output. docs/REVIEWER-TOOL.md is the contract; no model was measured.
This commit is contained in:
Timmy committed 2026-10-27 11:43:47 +00:00
1 parent 2533f83af3
commit c5fa2123e7
10 files changed
+1296 -109

No files matched your search

+12 -2
View File
@@ -68,6 +68,10 @@ DEMO_REQUIRED_CAPTIONS = (
"An older backup is upgraded on import, and the notice says which version the file came from",
"A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched",
"A restored log still says it began as an AI suggestion you corrected — provenance is part of the record",
"The review tool is a command line, not a screen in the app — this runs it against the committed synthetic sample",
"The queue shows the proposal and hides the model answer; revealing it is refused until a reviewer commits",
"The model said Type 6 and the reviewer said Type 5: the disagreement is recorded, not smoothed over",
"Withdrawing the contribution empties the queue and the training set, and the tool refuses to review it at all",
)
@@ -166,6 +170,9 @@ def main() -> int:
run(["python3", "scripts/build_vision_corpus.py", "--verify"], tree)
# Issue #25's corpus pipeline is a CLI, so its own suite is a release gate, not just CI.
run(["python3", "tests/corpus-manifest.test.py", "-v"], tree)
# Issue #26's review tool decides which samples a trainer may read, so the blindness and
# withdrawal rules it enforces are a release gate too.
run(["python3", "tests/corpus-review.test.py", "-v"], tree)
# Issue #29's evaluation harness decides GO/BLOCKED for a model, so its refusal and gate
# arithmetic are exercised before this release is allowed to exist.
run(["python3", "tests/calibration-harness.test.py", "-v"], tree)
@@ -261,7 +268,7 @@ def main() -> int:
run(["bash", "-n", "deploy/worker/entrypoint.sh"], tree)
run(["bash", "-n", "deploy/worker/build_image.sh"], tree)
run(["bash", "-n", "deploy/worker/run_worker_container.sh"], tree)
run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py", "scripts/build_vision_corpus.py", "scripts/corpus_manifest.py"], tree)
run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py", "scripts/build_vision_corpus.py", "scripts/corpus_manifest.py", "scripts/review_corpus.py"], tree)
run(["git", "diff", "--check", commit], tree)
names = tracked_files(tree)
@@ -297,6 +304,8 @@ def main() -> int:
"- Training contribution is a separate opt-in (issue #24): a journal save records no contribution, the consent version and the receipt are on screen before the control can be pressed, the receipt is queued on this device only (this build transmits nothing) and carries no note text, symptom answers, or photo bytes, and a withdrawal keeps a tombstone naming every derived artifact by contribution id\n"
"- Auditable corpus pipeline (issue #25): every ingested training sample now records its consent version and timestamp, source kind and the raw-input digest (the caller's file path is never stored), both the raw and derived hashes, the derivation pipeline, the human review status, the split, and its lifecycle state in an append-only manifest; withdrawing a contribution deletes the derived image and any raw pre-derivation copy from the active dataset, keeps the original record as its own history, and appends a tombstone that names every removed artifact by contribution id and digest while dropping labels and the subject key; illegal lifecycle moves (re-ingesting a contribution, re-labelling a derived image, revoking twice, revoking an unknown contribution, reviving a revoked one, revoking without a reason) are refused before any byte is written\n"
"- Scope note: the corpus pipeline is a command-line tool. There is no corpus screen in the app, and this release does not imply one — the recorded demo runs the real ingest and revoke commands against the committed synthetic fixture and shows their actual output\n"
"- Blind reviewer and adjudication tool (issue #26): the review queue shows the proposal and withholds every model field, revealing a model answer is refused until a human has committed their own labels, a correction must actually change the proposal, the ledger is append-only, and a second reader's adjudication is appended beside the review it resolves rather than replacing it; a withdrawn contribution cannot be queued, reviewed, revealed, adjudicated, or trained on, and unreviewed or excluded samples are never training data\n"
"- Scope note: the review tool is a command line, not a screen in the app. There is no reviewer screen, and this release does not imply one — the recorded demo runs the real queue, review, reveal, adjudicate, and trainable commands against the committed synthetic sample and shows their actual output, with a synthetic answer file that no model produced\n"
"- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n"
"- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n"
)
@@ -341,7 +350,7 @@ def main() -> int:
"codec": "h264",
"pixel_format": "yuv420p",
"recorded_from_working_app": True,
"fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, a confident reply about a poor-quality photo abstaining, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources, plus the opt-in training contribution path where nothing is queued after ordinary saves, the consent version and receipt precede the confirm control, one receipt is queued on the device only, and withdrawal leaves a tombstone naming each derived artifact, plus a versioned backup that carries the logs and the withdrawn receipt, an older version 1 file upgraded with the version named on screen, a file from a newer Timmy refused without changing the ledger, and a restored log still showing it began as a corrected AI suggestion, plus the corpus audit card, where the real ingest and revoke commands run against the committed synthetic fixture and the card shows their actual output: the provenance record with consent, source digest, both hashes, derivation, review and lifecycle; the derived image deleted from disk; the original record kept as history beside a tombstone that drops labels and the subject key; zero active samples for a trainer; and a second revocation refused without appending anything",
"fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, a confident reply about a poor-quality photo abstaining, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources, plus the opt-in training contribution path where nothing is queued after ordinary saves, the consent version and receipt precede the confirm control, one receipt is queued on the device only, and withdrawal leaves a tombstone naming each derived artifact, plus a versioned backup that carries the logs and the withdrawn receipt, an older version 1 file upgraded with the version named on screen, a file from a newer Timmy refused without changing the ledger, and a restored log still showing it began as a corrected AI suggestion, plus the corpus audit card, where the real ingest and revoke commands run against the committed synthetic fixture and the card shows their actual output: the provenance record with consent, source digest, both hashes, derivation, review and lifecycle; the derived image deleted from disk; the original record kept as history beside a tombstone that drops labels and the subject key; zero active samples for a trainer; and a second revocation refused without appending anything, plus the review ledger card, where the real review commands run against the committed synthetic sample and the card shows their actual output: one pending proposal with the model answer withheld, a refused reveal before commitment, a committed Type 5 correction beside the Type 4 proposal, the revealed synthetic Type 6 answer recorded as a three-field disagreement, a second reader's adjudication pointing at the review it resolved, one trainable sample, and a withdrawal that empties the queue and the training set and makes review refused",
},
"gates": {
"unit_security": "passed",
@@ -359,6 +368,7 @@ def main() -> int:
"grounded_chat_citations": "passed",
"training_contribution_consent": "passed",
"corpus_manifest_provenance": "passed",
"reviewer_adjudication_blind": "passed",
"calibration_harness_gates": "passed",
"portable_backup_restore": "passed",
"dependency_audit_high": "passed",
+261 -104
View File
@@ -221,6 +221,67 @@ async function indicate(selector) {
await sleep(650);
}
// Command-line evidence is shown on the same card chrome in three scenes (corpus audit, review
// ledger, calibration gate). One renderer keeps the readability gate identical for all of them.
async function evidenceCard({ id, title, subtitle, rows, font = '600 10.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace', keyWidth = 62, gap = '1px', rowGap = '7px' }) {
return page.evaluate(params => {
document.querySelector(`#${params.id}`)?.remove();
const panel = document.createElement('div');
panel.id = params.id;
Object.assign(panel.style, {
position: 'fixed', inset: '96px 14px 40px 14px', zIndex: '15000',
background: '#181412', color: '#f6f1e6', borderRadius: '18px',
border: '1px solid rgba(245,201,91,.45)', padding: '13px 13px 11px',
font: params.font,
display: 'flex', flexDirection: 'column', gap: params.gap, overflow: 'hidden',
boxShadow: '0 18px 40px rgba(0,0,0,.42)',
});
const head = document.createElement('div');
head.textContent = params.title;
Object.assign(head.style, { font: '800 14px/1.2 system-ui', letterSpacing: '.14em', color: '#f5c95b', marginBottom: '2px' });
const sub = document.createElement('div');
sub.textContent = params.subtitle;
Object.assign(sub.style, { font: '700 9.5px/1.3 system-ui', color: '#c9bfae', marginBottom: '9px' });
panel.append(head, sub);
for (const [key, value] of params.rows) {
const row = document.createElement('div');
if (key === '—') { row.style.height = params.rowGap; panel.append(row); continue; }
Object.assign(row.style, { display: 'flex', gap: '7px', alignItems: 'baseline' });
const k = document.createElement('span');
k.textContent = key;
k.style.cssText = `flex:0 0 ${params.keyWidth}px;color:#8fd7c8;text-transform:uppercase;font-size:9px;letter-spacing:.08em`;
const v = document.createElement('span');
v.textContent = value;
v.style.cssText = 'flex:1 1 auto;word-break:break-word;color:#f6f1e6';
row.append(k, v);
panel.append(row);
}
document.body.append(panel);
panel.animate([{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 320, fill: 'forwards' });
return { height: panel.clientHeight, scroll: panel.scrollHeight, text: panel.innerText };
}, { id, title, subtitle, rows, font, keyWidth, gap, rowGap });
}
// A card is only evidence if it is actually legible: opaque, inside the frame, not covered, and not
// clipped. A translucent or scrolled card reads as a sloppy overlay on the thinnest part of a film.
async function evidenceCardProof(id) {
return page.evaluate(cardId => {
const panel = document.querySelector(`#${cardId}`);
if (!panel) return { ok: false, reason: 'panel missing' };
const rect = panel.getBoundingClientRect();
const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2));
const background = getComputedStyle(panel).backgroundColor;
const opaque = !/^rgba\(.*,\s*(0?\.\d+|0)\)$/.test(background);
return {
ok: opaque && rect.top >= 60 && rect.bottom <= window.innerHeight && rect.width > 300 && rect.height > 300
&& (topmost === panel || panel.contains(topmost)) && panel.scrollHeight <= panel.clientHeight + 1,
top: Math.round(rect.top), height: Math.round(rect.height), width: Math.round(rect.width),
scroll: panel.scrollHeight, client: panel.clientHeight, background, opaque,
covered: topmost === panel || panel.contains(topmost),
};
}, id);
}
await caption(`TIMMY ${version} • RELEASE DEMO`, 1200);
await caption(`This release: ${releaseDelta}`, 2600);
await caption('Automated checks replay this synthetic path before review', 1200);
@@ -661,60 +722,14 @@ const auditRows = [
['refused', audit.refusal],
['manifest', 'unchanged at 2 lines'],
];
const panelOk = await page.evaluate(rows => {
document.querySelector('#corpus-audit')?.remove();
const panel = document.createElement('div');
panel.id = 'corpus-audit';
Object.assign(panel.style, {
position: 'fixed', inset: '96px 14px 40px 14px', zIndex: '15000',
background: '#181412', color: '#f6f1e6', borderRadius: '18px',
border: '1px solid rgba(245,201,91,.45)', padding: '13px 13px 11px',
font: '600 10.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace',
display: 'flex', flexDirection: 'column', gap: '1px', overflow: 'hidden',
boxShadow: '0 18px 40px rgba(0,0,0,.42)',
});
const head = document.createElement('div');
head.textContent = 'CORPUS AUDIT';
Object.assign(head.style, { font: '800 14px/1.2 system-ui', letterSpacing: '.14em', color: '#f5c95b', marginBottom: '2px' });
const sub = document.createElement('div');
sub.textContent = 'command line, not app UI — real run, synthetic fixture';
Object.assign(sub.style, { font: '700 9.5px/1.3 system-ui', color: '#c9bfae', marginBottom: '9px' });
panel.append(head, sub);
for (const [key, value] of rows) {
const row = document.createElement('div');
if (key === '—') { row.style.height = '7px'; panel.append(row); continue; }
Object.assign(row.style, { display: 'flex', gap: '7px', alignItems: 'baseline' });
const k = document.createElement('span');
k.textContent = key;
k.style.cssText = 'flex:0 0 62px;color:#8fd7c8;text-transform:uppercase;font-size:9px;letter-spacing:.08em';
const v = document.createElement('span');
v.textContent = value;
v.style.cssText = 'flex:1 1 auto;word-break:break-word;color:#f6f1e6';
row.append(k, v);
panel.append(row);
}
document.body.append(panel);
panel.animate([{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 320, fill: 'forwards' });
return { height: panel.clientHeight, scroll: panel.scrollHeight, text: panel.innerText };
}, auditRows);
await sleep(420);
const panelProof = await page.evaluate(() => {
const panel = document.querySelector('#corpus-audit');
if (!panel) return { ok: false, reason: 'panel missing' };
const rect = panel.getBoundingClientRect();
const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2));
const background = getComputedStyle(panel).backgroundColor;
// A translucent card lets the nav bar and body copy ghost through its empty rows, which reads as a
// sloppy overlay on the thinnest part of the frame. The card must be fully opaque.
const opaque = !/^rgba\(.*,\s*(0?\.\d+|0)\)$/.test(background);
return {
ok: opaque && rect.top >= 60 && rect.bottom <= window.innerHeight && rect.width > 300 && rect.height > 300
&& (topmost === panel || panel.contains(topmost)) && panel.scrollHeight <= panel.clientHeight + 1,
top: Math.round(rect.top), height: Math.round(rect.height), width: Math.round(rect.width),
scroll: panel.scrollHeight, client: panel.clientHeight, background, opaque,
covered: topmost === panel || panel.contains(topmost),
};
const panelOk = await evidenceCard({
id: 'corpus-audit',
title: 'CORPUS AUDIT',
subtitle: 'command line, not app UI — real run, synthetic fixture',
rows: auditRows,
});
await sleep(420);
const panelProof = await evidenceCardProof('corpus-audit');
if (!panelProof.ok) throw new Error(`Corpus audit card is not fully readable on screen: ${JSON.stringify(panelProof)}`);
// The card is drawn from live command output; prove the values on it are the ones the run produced.
if (!panelOk.text.includes(audit.record.imageId.slice(0, 12))) throw new Error('the card must show the derived digest it recorded');
@@ -724,6 +739,188 @@ await caption('Every ingested sample records consent, source digest, both hashes
await caption('Withdrawing deletes the derived image and any raw copy, keeps the original record, and appends a tombstone — the trainer sees zero active samples', 3800);
await caption('Revoking the same contribution twice is refused and appends nothing: the manifest only ever grows', 3200);
await page.evaluate(() => document.querySelector('#corpus-audit')?.remove());
// Issue #26 — the reviewer and adjudication tool is a command line, not a screen in the app. This
// scene runs the real commands against the committed synthetic sample in a throwaway dataset and
// shows the order the tool enforces: the queue hides the model answer, revealing it is refused
// until a human commits, and a withdrawal removes the sample from the queue, the review path, and
// the training set. Every value on the card is read back from those runs. The answer file is a
// synthetic fixture; no model measured anything here.
async function reviewCli(args) {
try {
const result = await execFileAsync('python3', ['scripts/review_corpus.py', ...args], {
cwd: process.cwd(),
env: { ...process.env, TIMMY_DATASET_SALT: 'demo-only-salt-not-a-secret' },
});
return { code: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
return {
code: typeof error.code === 'number' ? error.code : 1,
stdout: error.stdout || '',
stderr: error.stderr || '',
};
}
}
function lastLine(output) {
return String(output).trim().split('\n').filter(Boolean).pop() || '';
}
async function reviewLedger() {
const dataset = await mkdtemp(path.join(os.tmpdir(), 'timmy-review-demo-'));
const contributionId = 'demo-review-0001-abcd';
const predictionsPath = path.join(dataset, 'synthetic-answers.json');
const manifestPath = path.join(dataset, 'manifest.jsonl');
const reviewPath = path.join(dataset, 'reviews.jsonl');
const lines = async file => (await readFile(file, 'utf8').catch(() => ''))
.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
try {
const ingest = await corpusCli([
'scripts/ingest_training_photo.py',
'--input', 'tests/fixtures/synthetic-type4.jpg',
'--output-dir', dataset,
'--subject-id', 'demo-subject',
'--consent-version', 'training-consent-v1',
'--contribution-id', contributionId,
'--bristol-type', '4', '--color', 'brown', '--quality', 'good',
'--reviewer', 'user-confirmed',
]);
if (JSON.parse(ingest.stdout).lifecycle?.state !== 'active') {
throw new Error('the review scene needs one active synthetic sample');
}
const before = await reviewCli(['queue', '--dataset', dataset]);
const queue = JSON.parse(before.stdout);
if (queue.count !== 1) throw new Error('one unreviewed sample must queue exactly once');
if (queue.items[0].proposed.bristolType !== 4) throw new Error('the queue must show the proposal');
if ('confidence' in queue.items[0] || /"model"/.test(JSON.stringify(queue.items[0]))) {
throw new Error('the queue must not carry the model answer');
}
const early = await reviewCli(['reveal', '--dataset', dataset, '--contribution', contributionId]);
if (early.code === 0) throw new Error('revealing before a review must be refused');
const blindRefusal = lastLine(early.stderr);
if (!/stays hidden until a review is committed/.test(blindRefusal)) {
throw new Error(`the blind refusal was not legible: ${blindRefusal}`);
}
await writeFile(predictionsPath, JSON.stringify({
model: 'synthetic answer file',
answers: { [contributionId]: { bristolType: 6, color: 'brown', quality: 'good', confidence: 0.71 } },
}, null, 2));
const committed = await reviewCli([
'review', '--dataset', dataset, '--contribution', contributionId,
'--reviewer', 'reviewer-a', '--bristol', '5', '--color', 'green', '--quality', 'fair',
]);
if (committed.code !== 0) throw new Error(`the review was refused: ${lastLine(committed.stderr)}`);
const review = JSON.parse(committed.stdout);
if (review.decision !== 'corrected' || review.labels.bristolType !== 5) {
throw new Error('the committed review must record the corrected label');
}
if (review.proposed.bristolType !== 4) throw new Error('the committed review must keep the proposal it judged');
const revealed = await reviewCli(['reveal', '--dataset', dataset, '--contribution', contributionId, '--predictions', predictionsPath]);
if (revealed.code !== 0) throw new Error(`reveal failed after a committed review: ${lastLine(revealed.stderr)}`);
const reveal = JSON.parse(revealed.stdout);
if (reveal.model?.labels.bristolType !== 6) throw new Error('the revealed answer must be the one that was withheld');
if (!reveal.disagreement || reveal.disagreeFields.length !== 3) {
throw new Error('a Type 6 answer against a Type 5 review must be recorded as a disagreement');
}
const ruled = await reviewCli([
'adjudicate', '--dataset', dataset, '--contribution', contributionId,
'--reviewer', 'reviewer-b', '--upheld',
]);
if (ruled.code !== 0) throw new Error(`adjudication was refused: ${lastLine(ruled.stderr)}`);
const ruling = JSON.parse(ruled.stdout);
if (ruling.resolvesRecord !== 1) throw new Error('the ruling must point at the record it resolves');
const ledgerAfterRuling = await lines(reviewPath);
if (ledgerAfterRuling.length !== 2) throw new Error('adjudication must append, not replace');
if (ledgerAfterRuling[0].reviewer !== 'reviewer-a' || ledgerAfterRuling[0].decision !== 'corrected') {
throw new Error('the review being adjudicated must survive untouched');
}
const trainableBefore = JSON.parse((await reviewCli(['trainable', '--dataset', dataset])).stdout);
if (trainableBefore.count !== 1) throw new Error('one reviewed sample must be trainable');
if (trainableBefore.items[0].labels.bristolType !== 5) {
throw new Error('the trainer must read the reviewed label, not the proposal');
}
const revoke = await corpusCli([
'scripts/ingest_training_photo.py', '--output-dir', dataset,
'--revoke', contributionId, '--reason', 'participant withdrew consent',
]);
if (JSON.parse(revoke.stdout).recordType !== 'tombstone') throw new Error('the withdrawal must be recorded');
const afterRevoke = JSON.parse((await reviewCli(['queue', '--dataset', dataset])).stdout);
const trainableAfter = JSON.parse((await reviewCli(['trainable', '--dataset', dataset])).stdout);
if (afterRevoke.count !== 0 || trainableAfter.count !== 0) {
throw new Error('a withdrawn sample must leave the queue and the training set');
}
const refused = await reviewCli([
'review', '--dataset', dataset, '--contribution', contributionId, '--reviewer', 'reviewer-c', '--agree',
]);
if (refused.code === 0) throw new Error('reviewing a withdrawn contribution must be refused');
const withdrawnRefusal = lastLine(refused.stderr);
if (!/was withdrawn and cannot be reviewed/.test(withdrawnRefusal)) {
throw new Error(`the withdrawal refusal was not legible: ${withdrawnRefusal}`);
}
if ((await lines(reviewPath)).length !== 2) throw new Error('a refused review must append nothing');
if ((await lines(manifestPath)).length !== 2) throw new Error('the manifest must hold the sample and its tombstone');
return { queue, blindRefusal, review, reveal, ruling, trainableBefore, afterRevoke, trainableAfter, withdrawnRefusal };
} finally {
await rm(dataset, { recursive: true, force: true });
}
}
const reviewEvidence = await reviewLedger();
await tap('[data-view="home"]', 450);
const reviewRows = [
['command', 'python3 scripts/review_corpus.py queue --dataset <tmp>'],
['queue', `${reviewEvidence.queue.count} pending · proposal Type ${reviewEvidence.queue.items[0].proposed.bristolType} · ${reviewEvidence.queue.items[0].proposed.color} · ${reviewEvidence.queue.items[0].proposed.quality}`],
['blind', 'model answer withheld from the reviewer'],
['—', ''],
['command', 'reveal --contribution <id> (before any review)'],
['refused', reviewEvidence.blindRefusal],
['—', ''],
['command', 'review --reviewer reviewer-a --bristol 5 --color green --quality fair'],
['committed', `${reviewEvidence.review.decision} · proposal ${reviewEvidence.review.proposed.bristolType}/${reviewEvidence.review.proposed.color}/${reviewEvidence.review.proposed.quality} kept beside it`],
['command', 'reveal --predictions <synthetic answer file>'],
['model', `Type ${reviewEvidence.reveal.model.labels.bristolType} · ${reviewEvidence.reveal.model.labels.color} · ${reviewEvidence.reveal.model.labels.quality} · confidence ${reviewEvidence.reveal.model.confidence}`],
['disagree', reviewEvidence.reveal.disagreeFields.join(' · ')],
['—', ''],
['command', 'adjudicate --reviewer reviewer-b --upheld'],
['ledger', `2 lines · resolves record ${reviewEvidence.ruling.resolvesRecord} · reviewer-a's review untouched`],
['trainer', `trainable samples: ${reviewEvidence.trainableBefore.count}`],
['—', ''],
['command', 'ingest --revoke <id> --reason "participant withdrew consent"'],
['after', `queue ${reviewEvidence.afterRevoke.count} · trainable ${reviewEvidence.trainableAfter.count}`],
['review', reviewEvidence.withdrawnRefusal],
['data', 'synthetic answer file · no model was measured'],
];
const reviewCard = await evidenceCard({
id: 'review-audit',
title: 'REVIEW LEDGER',
subtitle: 'command line, not app UI — real run, synthetic sample',
rows: reviewRows,
});
await sleep(420);
const reviewProof = await evidenceCardProof('review-audit');
if (!reviewProof.ok) throw new Error(`Review card is not fully readable on screen: ${JSON.stringify(reviewProof)}`);
if (!reviewCard.text.includes(`Type ${reviewEvidence.reveal.model.labels.bristolType}`)) {
throw new Error('the card must show the model answer the run revealed');
}
if (!reviewCard.text.includes('was withdrawn and cannot be reviewed')) {
throw new Error('the card must show the withdrawn refusal the run earned');
}
if (!reviewCard.text.includes(`trainable samples: ${reviewEvidence.trainableBefore.count}`)) {
throw new Error('the card must show the trainer count the run produced');
}
await caption('The review tool is a command line, not a screen in the app — this runs it against the committed synthetic sample', 3600);
await caption('The queue shows the proposal and hides the model answer; revealing it is refused until a reviewer commits', 3800);
await caption('The model said Type 6 and the reviewer said Type 5: the disagreement is recorded, not smoothed over', 3600);
await caption('A second reviewer adjudicates, and the review being overruled is still in the ledger', 3400);
await caption('Withdrawing the contribution empties the queue and the training set, and the tool refuses to review it at all', 3800);
await page.evaluate(() => document.querySelector('#review-audit')?.remove());
// Issue #29 — the release gate is a command-line harness, not a screen in the app. This scene runs
// the real harness over the committed synthetic prediction fixtures and shows the receipt it wrote:
// a blocked comparison, a passing fixture, and a refused leaked corpus. Every number and every
@@ -799,58 +996,18 @@ gateRows.push(['—', '']);
gateRows.push(['engineered', 'GO — gate-plumbing-fixture clears every gate (fixture, not a result)']);
gateRows.push(['leaked', gate.leak.stderr.trim().replace(/^calibration harness refused:\s*/, '')]);
gateRows.push(['data', 'synthetic predictions · no model was measured']);
const gateCard = await page.evaluate(rows => {
document.querySelector('#calibration-report')?.remove();
const panel = document.createElement('div');
panel.id = 'calibration-report';
Object.assign(panel.style, {
position: 'fixed', inset: '96px 14px 40px 14px', zIndex: '15000',
background: '#181412', color: '#f6f1e6', borderRadius: '18px',
border: '1px solid rgba(245,201,91,.45)', padding: '13px 13px 11px',
font: '600 9.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace',
display: 'flex', flexDirection: 'column', gap: '1px', overflow: 'hidden',
boxShadow: '0 18px 40px rgba(0,0,0,.42)',
});
const head = document.createElement('div');
head.textContent = 'CALIBRATION HARNESS';
Object.assign(head.style, { font: '800 13.5px/1.2 system-ui', letterSpacing: '.12em', color: '#f5c95b', marginBottom: '2px' });
const sub = document.createElement('div');
sub.textContent = 'command line, not app UI — real run, synthetic predictions';
Object.assign(sub.style, { font: '700 9px/1.3 system-ui', color: '#c9bfae', marginBottom: '8px' });
panel.append(head, sub);
for (const [key, value] of rows) {
const row = document.createElement('div');
if (key === '—') { row.style.height = '6px'; panel.append(row); continue; }
Object.assign(row.style, { display: 'flex', gap: '6px', alignItems: 'baseline' });
const k = document.createElement('span');
k.textContent = key;
k.style.cssText = 'flex:0 0 74px;color:#8fd7c8;text-transform:uppercase;font-size:8.5px;letter-spacing:.06em';
const v = document.createElement('span');
v.textContent = value;
v.style.cssText = 'flex:1 1 auto;word-break:break-word;color:#f6f1e6';
row.append(k, v);
panel.append(row);
}
document.body.append(panel);
panel.animate([{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 320, fill: 'forwards' });
return { height: panel.clientHeight, scroll: panel.scrollHeight, text: panel.innerText };
}, gateRows);
await sleep(420);
const gateProof = await page.evaluate(() => {
const panel = document.querySelector('#calibration-report');
if (!panel) return { ok: false, reason: 'panel missing' };
const rect = panel.getBoundingClientRect();
const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2));
// Same rule as the corpus card: a translucent card lets the screen behind it ghost through.
const opaque = !/^rgba\(.*,\s*(0?\.\d+|0)\)$/.test(getComputedStyle(panel).backgroundColor);
return {
ok: opaque && rect.top >= 60 && rect.bottom <= window.innerHeight && rect.width > 300 && rect.height > 300
&& (topmost === panel || panel.contains(topmost)) && panel.scrollHeight <= panel.clientHeight + 1,
top: Math.round(rect.top), height: Math.round(rect.height), width: Math.round(rect.width),
scroll: panel.scrollHeight, client: panel.clientHeight, opaque,
covered: topmost === panel || panel.contains(topmost),
};
const gateCard = await evidenceCard({
id: 'calibration-report',
title: 'CALIBRATION HARNESS',
subtitle: 'command line, not app UI — real run, synthetic predictions',
rows: gateRows,
font: '600 9.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace',
keyWidth: 74,
gap: '1px',
rowGap: '6px',
});
await sleep(420);
const gateProof = await evidenceCardProof('calibration-report');
if (!gateProof.ok) throw new Error(`Calibration card is not fully readable on screen: ${JSON.stringify(gateProof)}`);
// The card is drawn from the report; prove the values on it are the ones the harness measured.
for (const row of gate.report.models) {
+462
View File
@@ -0,0 +1,462 @@
#!/usr/bin/env python3
"""Blind reviewer and adjudication tool for the Timmy training corpus (issue #26).
A reviewer reads one active sample, commits their own label without seeing what the model
said, and only then can the model answer be revealed. Adjudication is a second reader's
ruling, appended after the review it resolves; nothing in the review ledger is ever rewritten.
A withdrawn contribution cannot be queued, reviewed, revealed, adjudicated, or trained on.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
# The tool sits beside corpus_manifest.py and is also imported by path from the test suite.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from corpus_manifest import ( # noqa: E402
COLORS,
QUALITIES,
CorpusError,
active_records,
load_manifest,
)
SCHEMA_VERSION = 1
REVIEW = "review"
ADJUDICATION = "adjudication"
DECISIONS = ("agreed", "corrected", "excluded")
ADJUDICATIONS = ("upheld", "corrected", "excluded")
LABEL_FIELDS = ("bristolType", "color", "quality")
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T")
class ReviewError(Exception):
"""A review action that must never happen is being attempted."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _manifest_path(dataset_root: Path) -> Path:
return Path(dataset_root) / "manifest.jsonl"
def review_ledger_path(dataset_root: Path) -> Path:
return Path(dataset_root) / "reviews.jsonl"
def load_reviews(dataset_root: Path) -> list[dict]:
"""Read the append-only review ledger in order. A missing ledger is an unreviewed corpus."""
path = review_ledger_path(dataset_root)
if not path.exists():
return []
with path.open("r", encoding="utf-8") as ledger:
return [json.loads(line) for line in ledger.read().splitlines() if line.strip()]
def _history(reviews: list[dict], contribution_id: str) -> list[dict]:
return [record for record in reviews if record.get("contributionId") == contribution_id]
def _append_review(dataset_root: Path, record: dict) -> dict:
path = review_ledger_path(dataset_root)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as ledger:
ledger.write(json.dumps(record, separators=(",", ":"), sort_keys=False) + "\n")
return record
def _active_sample(dataset_root: Path, contribution_id: str) -> dict:
"""The one active sample for this contribution, or a refusal that names why there is none."""
records = load_manifest(_manifest_path(dataset_root))
for sample in active_records(records):
if sample.get("contributionId") == contribution_id:
return sample
if any(record.get("contributionId") == contribution_id for record in records):
raise ReviewError(
f"contribution {contribution_id} was withdrawn and cannot be reviewed, revealed, or trained on"
)
raise ReviewError(f"no active sample carries contribution {contribution_id}")
def _validate_labels(labels: object) -> dict:
if not isinstance(labels, dict) or set(labels) != set(LABEL_FIELDS):
raise ReviewError("a decided label must carry bristolType, color, and quality")
if labels["bristolType"] not in range(1, 8):
raise ReviewError("bristolType must be 1-7")
if labels["color"] not in COLORS or labels["quality"] not in QUALITIES:
raise ReviewError("color and quality must be from the documented vocabularies")
return {field: labels[field] for field in LABEL_FIELDS}
def _decide(
*,
reviewer: str,
decision: str,
labels,
exclusion_reason,
proposed: dict,
allowed: tuple[str, ...],
change_subject: str = "proposed label",
) -> dict:
"""Validate one human decision and return the fields it contributes to the ledger."""
reviewer_id = reviewer.strip() if isinstance(reviewer, str) else ""
if not reviewer_id:
raise ReviewError("reviewer is required")
if decision not in allowed:
raise ReviewError(f"decision must be one of {', '.join(allowed)}")
if decision in ("agreed", "upheld"):
return {"reviewer": reviewer_id, "decision": decision, "labels": dict(proposed)}
if decision == "excluded":
if not isinstance(exclusion_reason, str) or not exclusion_reason.strip():
raise ReviewError("exclusion needs a recorded reason")
return {
"reviewer": reviewer_id,
"decision": decision,
"exclusionReason": exclusion_reason.strip(),
}
decided = _validate_labels(labels)
if decided == proposed:
raise ReviewError(f"a corrected decision must change the {change_subject}")
return {"reviewer": reviewer_id, "decision": decision, "labels": decided}
def commit_review(
dataset_root: Path,
contribution_id: str,
reviewer: str,
decision: str,
labels: dict | None = None,
exclusion_reason: str | None = None,
at: str | None = None,
) -> dict:
"""Append one human label decision for an active sample, before any model answer is shown."""
dataset_root = Path(dataset_root)
reviews = load_reviews(dataset_root)
# A withdrawal outranks every other refusal: nothing may be reviewed out of a corpus that no
# longer holds the sample, and the refusal has to say so.
sample = _active_sample(dataset_root, contribution_id)
if _history(reviews, contribution_id):
raise ReviewError(
f"contribution {contribution_id} is already reviewed; "
"adjudication is the only way to change a decision"
)
proposed = dict(sample["labels"])
committed = _decide(
reviewer=reviewer,
decision=decision,
labels=labels,
exclusion_reason=exclusion_reason,
proposed=proposed,
allowed=DECISIONS,
)
stamped = at or _now()
if not _TIMESTAMP.match(stamped):
raise ReviewError("committed timestamp must be ISO-8601")
record = {
"schemaVersion": SCHEMA_VERSION,
"recordType": REVIEW,
"contributionId": contribution_id,
"imageId": sample["imageId"],
"proposed": proposed,
**committed,
"committedAt": stamped,
}
return _append_review(dataset_root, record)
def load_predictions(path: Path) -> dict:
"""Read a model answer file: one JSON document, or one JSON object per line.
A pretty-printed object is a document, not a set of lines, so it is parsed whole; only a file
that is not valid JSON as a document is read line by line.
"""
text = Path(path).read_text(encoding="utf-8")
if not text.strip():
return {"answers": {}}
try:
document = json.loads(text)
except json.JSONDecodeError:
document = None
if isinstance(document, dict):
if "answers" in document:
return document
if document and all(isinstance(value, dict) for value in document.values()):
return {"answers": document}
raise ReviewError("every prediction needs a contributionId or imageId")
answers: dict[str, dict] = {}
for line in text.splitlines():
if not line.strip():
continue
entry = json.loads(line)
key = entry.get("contributionId") or entry.get("imageId")
if not key:
raise ReviewError("every prediction needs a contributionId or imageId")
answers[key] = entry
return {"answers": answers}
def _model_answer(predictions: dict | None, contribution_id: str, image_id: str) -> dict | None:
if not isinstance(predictions, dict):
return None
answers = predictions.get("answers", predictions)
for key in (contribution_id, image_id):
entry = answers.get(key)
if isinstance(entry, dict):
labels = {field: entry.get(field) for field in LABEL_FIELDS}
if all(value is not None for value in labels.values()):
answer = {"labels": labels}
if "confidence" in entry:
answer["confidence"] = entry["confidence"]
if "model" in predictions:
answer["model"] = predictions["model"]
return answer
return None
def reveal(dataset_root: Path, contribution_id: str, predictions: dict | None = None) -> dict:
"""Show the model's answer for a sample, but only after a human has committed their own.
The order is the whole point: the reviewer's label is already fixed in the ledger when this
runs, so a model answer can be read for disagreement but cannot steer the human into agreeing.
"""
dataset_root = Path(dataset_root)
sample = _active_sample(dataset_root, contribution_id)
history = _history(load_reviews(dataset_root), contribution_id)
if not history:
raise ReviewError(
f"the model answer for contribution {contribution_id} stays hidden until a review is committed"
)
reviewed = history[0]
human_labels = dict(reviewed["labels"]) if "labels" in reviewed else None
answer = _model_answer(predictions, contribution_id, sample["imageId"])
disagree = []
if answer and human_labels:
disagree = [field for field in LABEL_FIELDS if answer["labels"][field] != human_labels[field]]
return {
"contributionId": contribution_id,
"human": {"reviewer": reviewed["reviewer"], "decision": reviewed["decision"], "labels": human_labels},
"model": answer,
"disagreeFields": disagree,
"disagreement": bool(disagree),
}
def adjudicate(
dataset_root: Path,
contribution_id: str,
reviewer: str,
decision: str,
labels: dict | None = None,
exclusion_reason: str | None = None,
at: str | None = None,
) -> dict:
"""Append a second reader's ruling on a review. The review it resolves stays in the ledger."""
dataset_root = Path(dataset_root)
reviews = load_reviews(dataset_root)
sample = _active_sample(dataset_root, contribution_id)
history = _history(reviews, contribution_id)
if not history:
raise ReviewError(f"no review to adjudicate for contribution {contribution_id}")
if any(record.get("recordType") == ADJUDICATION for record in history):
raise ReviewError(f"contribution {contribution_id} is already adjudicated")
reviewed = history[0]
if isinstance(reviewer, str) and reviewer.strip() == reviewed["reviewer"]:
raise ReviewError("adjudication needs a second reviewer, not the reviewer who decided")
reviewed_labels = dict(reviewed.get("labels") or {})
ruling = _decide(
reviewer=reviewer,
decision=decision,
labels=labels,
exclusion_reason=exclusion_reason,
proposed=reviewed_labels,
allowed=ADJUDICATIONS,
change_subject="reviewed label",
)
stamped = at or _now()
if not _TIMESTAMP.match(stamped):
raise ReviewError("committed timestamp must be ISO-8601")
record = {
"schemaVersion": SCHEMA_VERSION,
"recordType": ADJUDICATION,
"contributionId": contribution_id,
"imageId": sample["imageId"],
"resolvesRecord": reviews.index(reviewed) + 1,
"resolves": {"reviewer": reviewed["reviewer"], "decision": reviewed["decision"]},
**ruling,
"adjudicatedAt": stamped,
}
return _append_review(dataset_root, record)
def trainable_records(dataset_root: Path) -> list[dict]:
"""The training set this corpus is allowed to produce right now.
A sample is trainable only if it is still active, a human reviewed it, and the final decision
was not an exclusion. Unreviewed proposals are never training data, and an adjudication
replaces the label it resolved without deleting it.
"""
dataset_root = Path(dataset_root)
reviews = load_reviews(dataset_root)
trainable = []
for sample in active_records(load_manifest(_manifest_path(dataset_root))):
history = _history(reviews, sample["contributionId"])
if not history:
continue
final = history[-1]
if final["decision"] == "excluded":
continue
entry = {
"contributionId": sample["contributionId"],
"imageId": sample["imageId"],
"relativePath": sample["relativePath"],
"split": sample["split"],
"labels": dict(final["labels"]),
"reviewedBy": history[0]["reviewer"],
}
if final.get("recordType") == ADJUDICATION:
entry["adjudicatedBy"] = final["reviewer"]
trainable.append(entry)
return trainable
def _decision_from(args: argparse.Namespace, *, uphold: bool) -> tuple[str, dict | None, str | None]:
"""Translate the flag shape into one decision, refusing a half-specified review."""
label_flags = [args.bristol, args.color, args.quality]
if args.exclude:
if any(flag is not None for flag in label_flags):
raise ReviewError("an exclusion cannot carry labels")
return "excluded", None, args.exclude
if uphold and args.upheld:
if any(flag is not None for flag in label_flags):
raise ReviewError("an upheld review keeps the reviewed label; pass no labels with it")
return "upheld", None, None
if not uphold and args.agree:
if any(flag is not None for flag in label_flags):
raise ReviewError("agreeing keeps the proposed label; pass no labels with it")
return "agreed", None, None
if all(flag is not None for flag in label_flags):
return "corrected", {"bristolType": args.bristol, "color": args.color, "quality": args.quality}, None
if any(flag is not None for flag in label_flags):
raise ReviewError("a corrected decision needs --bristol, --color, and --quality together")
hint = "--upheld, a corrected label, or --exclude" if uphold else "--agree, a corrected label, or --exclude"
raise ReviewError(f"a decision is required: pass {hint}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Blind review and adjudication for the Timmy training corpus.",
)
commands = parser.add_subparsers(dest="command", required=True)
queue = commands.add_parser("queue", help="list the blind review queue")
queue.add_argument("--dataset", required=True)
queue.add_argument("--limit", type=int)
review = commands.add_parser("review", help="commit a human label decision")
review.add_argument("--dataset", required=True)
review.add_argument("--contribution", required=True)
review.add_argument("--reviewer", required=True)
review.add_argument("--agree", action="store_true", help="the proposed label is correct")
review.add_argument("--bristol", type=int, choices=range(1, 8))
review.add_argument("--color", choices=COLORS)
review.add_argument("--quality", choices=QUALITIES)
review.add_argument("--exclude", metavar="REASON")
reveal = commands.add_parser("reveal", help="show the model answer after the human commits")
reveal.add_argument("--dataset", required=True)
reveal.add_argument("--contribution", required=True)
reveal.add_argument("--predictions", help="JSON or JSONL of model answers; omit if none exist")
adjudicate_parser = commands.add_parser("adjudicate", help="append a second reader's ruling")
adjudicate_parser.add_argument("--dataset", required=True)
adjudicate_parser.add_argument("--contribution", required=True)
adjudicate_parser.add_argument("--reviewer", required=True)
adjudicate_parser.add_argument("--bristol", type=int, choices=range(1, 8))
adjudicate_parser.add_argument("--color", choices=COLORS)
adjudicate_parser.add_argument("--quality", choices=QUALITIES)
adjudicate_parser.add_argument("--exclude", metavar="REASON")
adjudicate_parser.add_argument("--upheld", action="store_true", help="keep the reviewed label")
trainable = commands.add_parser("trainable", help="list what a trainer is allowed to read")
trainable.add_argument("--dataset", required=True)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "queue":
items = build_queue(Path(args.dataset), limit=args.limit)
print(json.dumps({"count": len(items), "items": items}, indent=2))
return 0
if args.command == "review":
decision, labels, exclusion_reason = _decision_from(args, uphold=False)
record = commit_review(
Path(args.dataset), args.contribution, args.reviewer,
decision, labels, exclusion_reason,
)
print(json.dumps(record, indent=2))
return 0
if args.command == "reveal":
predictions = load_predictions(args.predictions) if args.predictions else None
print(json.dumps(reveal(Path(args.dataset), args.contribution, predictions), indent=2))
return 0
if args.command == "adjudicate":
decision, labels, exclusion_reason = _decision_from(args, uphold=True)
record = adjudicate(
Path(args.dataset), args.contribution, args.reviewer,
decision, labels, exclusion_reason,
)
print(json.dumps(record, indent=2))
return 0
if args.command == "trainable":
items = trainable_records(Path(args.dataset))
print(json.dumps({"count": len(items), "items": items}, indent=2))
return 0
except (ReviewError, CorpusError) as error:
print(str(error), file=sys.stderr)
return 2
return 0
def build_queue(dataset_root: Path, limit: int | None = None) -> list[dict]:
"""Blind review queue: what a reviewer must judge, and nothing about what a model thinks.
This function deliberately takes no predictions. Blindness is structural here: the queue
cannot leak a model answer because it never receives one.
"""
dataset_root = Path(dataset_root)
reviews = load_reviews(dataset_root)
reviewed = {record.get("contributionId") for record in reviews}
queue: list[dict] = []
for sample in active_records(load_manifest(_manifest_path(dataset_root))):
contribution_id = sample["contributionId"]
if contribution_id in reviewed:
continue
image = dataset_root / sample["relativePath"]
if not image.is_file():
raise ReviewError(f"the dataset lists an active sample whose image is missing: {sample['relativePath']}")
queue.append({
"schemaVersion": SCHEMA_VERSION,
"contributionId": contribution_id,
"imageId": sample["imageId"],
"relativePath": sample["relativePath"],
"split": sample["split"],
"proposed": dict(sample["labels"]),
"blindToModel": True,
})
if limit is not None and len(queue) >= limit:
break
return queue
if __name__ == "__main__":
sys.exit(main())