Files
Timmy c5fa2123e7
Quality gates / quality (pull_request) Canceled after 0s
feat(review): blind reviewer and adjudication tool with an append-only label ledger (#26)
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.
2026-10-27 11:43:47 +00:00

420 lines
19 KiB
Python

#!/usr/bin/env python3
"""Tests for the blind reviewer and adjudication tool (issue #26).
The tool decides what a training sample is allowed to say, so these tests attack the two
properties that make the labels trustworthy:
* a reviewer never sees the model's answer before committing their own, and
* a withdrawn contribution can never be shown, reviewed, or trained on.
Every fixture is the committed synthetic image; no real photo bytes are read or written.
"""
from __future__ import annotations
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INGEST = ROOT / "scripts" / "ingest_training_photo.py"
FIXTURE = ROOT / "tests" / "fixtures" / "synthetic-type4.jpg"
FIRST = "6f1d0c9e-2c4a-4f3b-9d1e-8a7b6c5d4e3f"
SECOND = "0b9a8c7d-6e5f-4a3b-2c1d-0e9f8a7b6c5d"
def load_tool():
spec = importlib.util.spec_from_file_location("review_corpus", ROOT / "scripts" / "review_corpus.py")
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
tool = load_tool()
def ingest(dataset: Path, contribution: str, *, bristol: str = "4", color: str = "brown", quality: str = "good") -> dict:
done = subprocess.run(
[
sys.executable, str(INGEST),
"--input", str(FIXTURE),
"--output-dir", str(dataset),
"--subject-id", "subject-123",
"--consent-version", "research-v1",
"--contribution-id", contribution,
"--bristol-type", bristol,
"--color", color,
"--quality", quality,
"--reviewer", "user-confirmed",
],
cwd=ROOT,
capture_output=True,
text=True,
env={**os.environ, "TIMMY_DATASET_SALT": "review-test-salt"},
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def revoke(dataset: Path, contribution: str) -> dict:
done = subprocess.run(
[sys.executable, str(INGEST), "--output-dir", str(dataset), "--revoke", contribution, "--reason", "participant withdrew consent"],
cwd=ROOT,
capture_output=True,
text=True,
env={**os.environ, "TIMMY_DATASET_SALT": "review-test-salt"},
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def ledger(dataset: Path) -> list[dict]:
path = dataset / "reviews.jsonl"
if not path.exists():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
PREDICTIONS = {
"version": "synthetic-model-placeholder",
"answers": {
FIRST: {"bristolType": 6, "color": "brown", "quality": "good", "confidence": 0.71},
SECOND: {"bristolType": 4, "color": "brown", "quality": "good", "confidence": 0.9},
},
}
class QueueBlindnessTests(unittest.TestCase):
def test_the_queue_shows_the_proposal_and_withholds_every_model_field(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
sample = ingest(dataset, FIRST)
queue = tool.build_queue(dataset)
self.assertEqual(len(queue), 1)
packet = queue[0]
self.assertEqual(packet["contributionId"], FIRST)
self.assertEqual(packet["imageId"], sample["imageId"])
self.assertEqual(packet["relativePath"], sample["relativePath"])
self.assertEqual(packet["proposed"], {"bristolType": 4, "color": "brown", "quality": "good"})
self.assertTrue(packet["blindToModel"])
# Not a naming convention: nothing model-shaped and nothing identity-bearing may ride along.
text = json.dumps(packet)
for forbidden in ("confidence", "modelSuggestion", "prediction", "subjectKey", "data:image", "base64"):
self.assertNotIn(forbidden, text)
def test_resolved_samples_leave_the_queue(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
self.assertEqual(len(tool.build_queue(dataset)), 1)
tool.commit_review(dataset, FIRST, "reviewer-a", "agreed")
self.assertEqual(tool.build_queue(dataset), [])
def test_the_queue_refuses_an_active_sample_whose_image_is_gone(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
sample = ingest(dataset, FIRST)
(dataset / sample["relativePath"]).unlink()
with self.assertRaises(tool.ReviewError) as raised:
tool.build_queue(dataset)
self.assertRegex(str(raised.exception), "image is missing")
class BlindRevealTests(unittest.TestCase):
def test_the_model_answer_stays_hidden_until_a_review_is_committed(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
with self.assertRaises(tool.ReviewError) as raised:
tool.reveal(dataset, FIRST, PREDICTIONS)
self.assertRegex(str(raised.exception), "hidden until a review is committed")
def test_reveal_reports_the_disagreement_after_the_human_commits(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
tool.commit_review(dataset, FIRST, "reviewer-a", "corrected", {"bristolType": 5, "color": "green", "quality": "fair"})
revealed = tool.reveal(dataset, FIRST, PREDICTIONS)
self.assertEqual(revealed["human"]["labels"], {"bristolType": 5, "color": "green", "quality": "fair"})
self.assertEqual(revealed["model"]["labels"], {"bristolType": 6, "color": "brown", "quality": "good"})
self.assertEqual(revealed["disagreeFields"], ["bristolType", "color", "quality"])
self.assertTrue(revealed["disagreement"])
# Revealing is a read: it must not quietly rewrite the review ledger.
self.assertEqual(len(ledger(dataset)), 1)
class CommitTests(unittest.TestCase):
def test_a_committed_review_keeps_the_proposal_and_the_human_decision(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
sample = ingest(dataset, FIRST)
record = tool.commit_review(dataset, FIRST, "reviewer-a", "corrected", {"bristolType": 2, "color": "brown", "quality": "poor"}, at="2026-10-27T09:00:00+00:00")
self.assertEqual(record["recordType"], "review")
self.assertEqual(record["contributionId"], FIRST)
self.assertEqual(record["imageId"], sample["imageId"])
self.assertEqual(record["reviewer"], "reviewer-a")
self.assertEqual(record["decision"], "corrected")
self.assertEqual(record["labels"], {"bristolType": 2, "color": "brown", "quality": "poor"})
self.assertEqual(record["proposed"], {"bristolType": 4, "color": "brown", "quality": "good"})
self.assertEqual(record["committedAt"], "2026-10-27T09:00:00+00:00")
self.assertEqual(ledger(dataset), [record])
self.assertNotIn("subjectKey", json.dumps(record))
def test_a_contribution_cannot_be_reviewed_twice(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
tool.commit_review(dataset, FIRST, "reviewer-a", "agreed")
history = ledger(dataset)
with self.assertRaises(tool.ReviewError) as raised:
tool.commit_review(dataset, FIRST, "reviewer-b", "corrected", {"bristolType": 2, "color": "black", "quality": "poor"})
self.assertRegex(str(raised.exception), "already reviewed")
self.assertEqual(ledger(dataset), history)
def test_a_correction_that_changes_nothing_is_refused(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
with self.assertRaises(tool.ReviewError) as raised:
tool.commit_review(dataset, FIRST, "reviewer-a", "corrected", {"bristolType": 4, "color": "brown", "quality": "good"})
self.assertRegex(str(raised.exception), "must change the proposed label")
self.assertEqual(ledger(dataset), [])
def test_invalid_labels_and_blank_reviewers_are_refused(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
for decision, labels, reviewer, expected in (
("corrected", {"bristolType": 9, "color": "brown", "quality": "good"}, "reviewer-a", "bristolType must be 1-7"),
("corrected", {"bristolType": 4, "color": "purple", "quality": "good"}, "reviewer-a", "color and quality"),
("agreed", None, " ", "reviewer is required"),
("excluded", None, "reviewer-a", "exclusion needs a recorded reason"),
("approved", None, "reviewer-a", "decision must be one of"),
):
with self.assertRaises(tool.ReviewError) as raised:
tool.commit_review(dataset, FIRST, reviewer, decision, labels)
self.assertRegex(str(raised.exception), expected)
self.assertEqual(ledger(dataset), [])
def test_reviewing_an_unknown_contribution_is_refused(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
with self.assertRaises(tool.ReviewError) as raised:
tool.commit_review(dataset, SECOND, "reviewer-a", "agreed")
self.assertRegex(str(raised.exception), "no active sample carries contribution")
self.assertEqual(ledger(dataset), [])
class WithdrawnContributionTests(unittest.TestCase):
def test_a_withdrawn_contribution_never_reaches_the_queue_review_or_the_trainer(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
tool.commit_review(dataset, FIRST, "reviewer-a", "corrected", {"bristolType": 5, "color": "brown", "quality": "good"})
self.assertEqual(len(tool.trainable_records(dataset)), 1)
revoke(dataset, FIRST)
self.assertEqual(tool.build_queue(dataset), [])
self.assertEqual(tool.trainable_records(dataset), [])
for call in (
lambda: tool.commit_review(dataset, FIRST, "reviewer-b", "agreed"),
lambda: tool.adjudicate(dataset, FIRST, "reviewer-b", "upheld"),
lambda: tool.reveal(dataset, FIRST, PREDICTIONS),
):
with self.assertRaises(tool.ReviewError) as raised:
call()
self.assertRegex(str(raised.exception), "withdrawn")
class AdjudicationTests(unittest.TestCase):
def test_adjudication_is_appended_and_the_first_review_is_preserved(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
first = tool.commit_review(dataset, FIRST, "reviewer-a", "corrected", {"bristolType": 5, "color": "brown", "quality": "good"}, at="2026-10-27T09:00:00+00:00")
ruling = tool.adjudicate(dataset, FIRST, "reviewer-b", "corrected", {"bristolType": 4, "color": "brown", "quality": "fair"}, at="2026-10-27T10:00:00+00:00")
self.assertEqual(ruling["recordType"], "adjudication")
self.assertEqual(ruling["decision"], "corrected")
self.assertEqual(ruling["labels"], {"bristolType": 4, "color": "brown", "quality": "fair"})
self.assertEqual(ruling["resolvesRecord"], 1)
self.assertEqual(ruling["resolves"], {"reviewer": "reviewer-a", "decision": "corrected"})
history = ledger(dataset)
self.assertEqual(len(history), 2)
self.assertEqual(history[0], first, "the review being overruled must survive untouched")
self.assertEqual(history[1], ruling)
# The trainer follows the ruling, not the review it replaced.
trainable = tool.trainable_records(dataset)
self.assertEqual(len(trainable), 1)
self.assertEqual(trainable[0]["labels"], {"bristolType": 4, "color": "brown", "quality": "fair"})
self.assertEqual(trainable[0]["adjudicatedBy"], "reviewer-b")
def test_an_upheld_review_keeps_the_label_and_records_the_second_reader(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
tool.commit_review(dataset, FIRST, "reviewer-a", "agreed")
ruling = tool.adjudicate(dataset, FIRST, "reviewer-b", "upheld")
self.assertEqual(ruling["labels"], {"bristolType": 4, "color": "brown", "quality": "good"})
self.assertEqual(ruling["resolves"], {"reviewer": "reviewer-a", "decision": "agreed"})
def test_a_reviewer_cannot_adjudicate_their_own_review(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
tool.commit_review(dataset, FIRST, "reviewer-a", "agreed")
with self.assertRaises(tool.ReviewError) as raised:
tool.adjudicate(dataset, FIRST, "reviewer-a", "corrected", {"bristolType": 2, "color": "brown", "quality": "good"})
self.assertRegex(str(raised.exception), "second reviewer")
self.assertEqual(len(ledger(dataset)), 1)
def test_a_second_adjudication_is_refused(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
tool.commit_review(dataset, FIRST, "reviewer-a", "agreed")
tool.adjudicate(dataset, FIRST, "reviewer-b", "upheld")
history = ledger(dataset)
with self.assertRaises(tool.ReviewError) as raised:
tool.adjudicate(dataset, FIRST, "reviewer-c", "excluded", exclusion_reason="unclear")
self.assertRegex(str(raised.exception), "already adjudicated")
self.assertEqual(ledger(dataset), history)
def test_adjudicating_an_unreviewed_contribution_is_refused(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
with self.assertRaises(tool.ReviewError) as raised:
tool.adjudicate(dataset, FIRST, "reviewer-b", "upheld")
self.assertRegex(str(raised.exception), "no review to adjudicate")
self.assertEqual(ledger(dataset), [])
class TrainableSetTests(unittest.TestCase):
def test_only_human_reviewed_and_unexcluded_samples_reach_the_trainer(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
ingest(dataset, SECOND)
# Unreviewed samples are not training data, however confident the proposal is.
self.assertEqual(tool.trainable_records(dataset), [])
tool.commit_review(dataset, FIRST, "reviewer-a", "agreed")
tool.commit_review(dataset, SECOND, "reviewer-a", "excluded", exclusion_reason="cannot tell stool from photo edge")
trainable = tool.trainable_records(dataset)
self.assertEqual([item["contributionId"] for item in trainable], [FIRST])
self.assertEqual(trainable[0]["labels"], {"bristolType": 4, "color": "brown", "quality": "good"})
self.assertEqual(trainable[0]["reviewedBy"], "reviewer-a")
# Excluded is a labeling judgement, not a withdrawal: the provenance record still exists.
history = [json.loads(line) for line in (dataset / "manifest.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()]
self.assertEqual(len(history), 2)
class PredictionFileTests(unittest.TestCase):
def test_a_pretty_printed_answer_file_is_read_whole_not_line_by_line(self):
with tempfile.TemporaryDirectory() as raw:
path = Path(raw) / "answers.json"
path.write_text(json.dumps({"model": "synthetic answer file", "answers": {FIRST: {"bristolType": 6, "color": "brown", "quality": "good"}}}, indent=2), encoding="utf-8")
loaded = tool.load_predictions(path)
self.assertEqual(loaded["answers"][FIRST]["bristolType"], 6)
def test_a_line_delimited_answer_file_is_read_by_contribution(self):
with tempfile.TemporaryDirectory() as raw:
path = Path(raw) / "answers.jsonl"
path.write_text(
json.dumps({"contributionId": FIRST, "bristolType": 6, "color": "brown", "quality": "good"}) + "\n"
+ json.dumps({"contributionId": SECOND, "bristolType": 2, "color": "black", "quality": "poor"}) + "\n",
encoding="utf-8",
)
loaded = tool.load_predictions(path)
self.assertEqual(loaded["answers"][FIRST]["bristolType"], 6)
self.assertEqual(loaded["answers"][SECOND]["bristolType"], 2)
def test_an_answer_without_an_identifier_is_refused(self):
with tempfile.TemporaryDirectory() as raw:
path = Path(raw) / "answers.jsonl"
path.write_text(json.dumps({"bristolType": 6}) + "\n", encoding="utf-8")
with self.assertRaises(tool.ReviewError) as raised:
tool.load_predictions(path)
self.assertRegex(str(raised.exception), "contributionId or imageId")
class CliTests(unittest.TestCase):
def run_cli(self, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(ROOT / "scripts" / "review_corpus.py"), *args],
cwd=ROOT, capture_output=True, text=True,
)
def test_the_cli_keeps_the_blind_order_and_reports_refusals_non_zero(self):
with tempfile.TemporaryDirectory() as raw:
dataset = Path(raw)
ingest(dataset, FIRST)
queue = self.run_cli("queue", "--dataset", str(dataset))
self.assertEqual(queue.returncode, 0, queue.stderr)
packet = json.loads(queue.stdout)
self.assertEqual(packet["count"], 1)
self.assertNotIn("confidence", queue.stdout)
early = self.run_cli("reveal", "--dataset", str(dataset), "--contribution", FIRST)
self.assertNotEqual(early.returncode, 0)
self.assertRegex(early.stderr, "hidden until a review is committed")
committed = self.run_cli(
"review", "--dataset", str(dataset), "--contribution", FIRST,
"--reviewer", "reviewer-a", "--bristol", "5", "--color", "green", "--quality", "fair",
)
self.assertEqual(committed.returncode, 0, committed.stderr)
self.assertEqual(json.loads(committed.stdout)["decision"], "corrected")
reveal = self.run_cli("reveal", "--dataset", str(dataset), "--contribution", FIRST)
self.assertEqual(reveal.returncode, 0, reveal.stderr)
self.assertEqual(json.loads(reveal.stdout)["model"], None, "no prediction was supplied, so none is invented")
trainable = self.run_cli("trainable", "--dataset", str(dataset))
self.assertEqual(trainable.returncode, 0, trainable.stderr)
self.assertEqual(json.loads(trainable.stdout)["count"], 1)
if __name__ == "__main__":
unittest.main(verbosity=2)