Compare commits

...
Author SHA1 Message Date
timmy 9dabad495b feat(corpus): publish the dataset card and labeling guide from the manifest (#27)
Quality gates / quality (pull_request) Canceled after 0s
The corpus had an append-only manifest, a reviewer tool, and an evaluation harness, but nothing a
stranger could read: what is in the corpus, what a label means, and how leakage is prevented lived in
code and in reviewers' heads.

scripts/dataset_card.py generates docs/DATASET_CARD.md from manifest.jsonl. Every count is read back
from real records — active samples, contributors, per-split contributor and sample counts, class
balance over active samples only — and every fact the pipeline does not collect (devices, contexts,
licenses) is printed as pending rather than estimated. The generator refuses to render a card for a
corpus where one contributor straddles two splits or where a recorded split is not the one the
subject key derives, and it never publishes a subject key or a withdrawn sample's labels. --check
fails when the committed page is not the generator's own output, so the card cannot drift into a
hand-written document.

docs/LABELING_GUIDE.md is the reviewer's rulebook: visible form, broad visible color, and photo
quality; visible color is not blood; nothing is diagnosed; a model suggestion is provisional input to
a human decision. Exclusions are named, and the split policy is stated where it is enforced.

The demo's new DATASET CARD scene builds a throwaway corpus with the real ingest command from
committed synthetic fixtures, publishes the card, and shows the refusal a planted contributor earns,
then re-checks the committed page. Its captions are release gates in build_release.py, which also runs
tests/dataset-card.test.py before a release exists.

tests/dataset-card.test.py covers the counts, the pending facts, the withdrawal receipt, both
refusals, determinism, the empty-corpus case, the committed-page identity, and both documents'
prohibitions. Every fixture is synthetic.

Closes #27
2026-10-28 11:35:41 +00:00
timmy b191d0427d Merge pull request 'Blind reviewer and adjudication tool for corpus labels (#26)' (#88) from timmy/26-review-adjudication into main
Quality gates / quality (push) Canceled after 0s
2026-10-27 12:05:58 +00:00
timmy d54665dbd9 chore(review): drop the trailing blank line that fails check:diff
Quality gates / quality (pull_request) Canceled after 0s
Release builder runs `git diff --check`; scripts/review_corpus.py ended with an extra newline, so the gate failed on the PR head. Whitespace only; no behaviour change.
2026-10-27 12:05:00 +00:00
Timmy c5fa2123e7 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.
2026-10-27 11:43:47 +00:00
rockachopa 2533f83af3 Merge pull request #87: Gate visual prefill on a versioned abstention policy (#32)
Quality gates / quality (push) Failing after 5m13s
2026-10-26 12:03:47 +00:00
timmy 209e21a951 Gate visual prefill on a versioned abstention policy (#32)
Quality gates / quality (pull_request) Successful in 13m29s
Confidence was the only thing standing between a model answer and a
prefilled journal entry, so a confident reading of a blurry photo could
still be offered as fact. Quality is now part of the policy: `poor`
abstains however confident the reply looked, with its own fixed copy.

The confidence floor, accepted quality levels, and refusal copy move into
src/abstention-policy.js so the parser, the corpus, and the browser cannot
drift into three explanations of the same refusal. parseVisionResponse
takes an optional policy, which is how the tests prove the parser carries
no hidden limits of its own.

evaluatePrefillGate turns the #29 calibration report into one release
decision: GO plus a validation-selected threshold scored on the held-out
test split, or prefill is blocked with each reason named. A missing report
is a block, never a default allow. scripts/abstention_policy_receipt.mjs
exposes that as a receipt (exit 0 / 3 / 2).

The committed synthetic predictions currently report BLOCKED and the
receipt says so. No candidate exists until #31, and the builder does not
hard-gate on the receipt yet; the on-camera quality gate is the evidence
this release carries.

The corpus gains a case that replays a 0.94-confidence answer about the
synthetic motion-blur fixture, and the release demo asserts in the browser
that nothing is prefilled before captioning it.

Verified: 226 unit tests, calibration harness (19), corpus manifest (8),
vision-refusal, photo-first, and provisional acceptance, check:syntax,
check:diff, and a full real-browser demo recording whose caption receipt
confirms the new scene painted on screen.
2026-10-26 10:58:38 +00:00
rockachopa 2a10a20518 fix(release): give each calibration candidate its own row so the name is not split at its hyphen
Quality gates / quality (push) Successful in 12m51s
2026-10-25 12:23:56 +00:00
rockachopa a2293c6e34 Merge pull request #86: Build contributor-held-out evaluation harness and calibration report (#29)
Quality gates / quality (push) Successful in 12m54s
Closes #29
2026-10-25 12:02:11 +00:00
23 changed files with 2735 additions and 121 deletions

No files matched your search

+2
View File
@@ -34,6 +34,8 @@ jobs:
npm test
python3 tests/staging-deploy.test.py -v
python3 tests/corpus-manifest.test.py -v
python3 tests/corpus-review.test.py -v
python3 tests/dataset-card.test.py -v
python3 tests/calibration-harness.test.py -v
- name: Mobile browser acceptance
run: |
+6 -1
View File
@@ -101,6 +101,7 @@ npm run test:vision-refusal # corpus refusals through a real server and a real
npm run test:grounded-chat # chat answers cite the ledger, or refuse to answer
npm run test:photo-lifecycle # photo presence, explicit reveal, remove-photo-keeps-log, delete-all
npm run test:calibration # the release-gate harness: gates, threshold protocol, refused corpora
npm run test:review # blind review queue, held-back model answer, an append-only ledger, withdrawals
npm audit --audit-level=high
```
@@ -110,7 +111,7 @@ npm audit --audit-level=high
- Client-side image compression before transfer
- Explicit consent before one-time provider analysis
- Structured AI suggestions for **visible Bristol form and color only**
- Confidence display, low-confidence abstention, and user confirmation
- Confidence display, low-confidence abstention, and user confirmation, governed by one versioned policy: `poor` image quality abstains even at high confidence, and a confident reply is never enough on its own — see [docs/ABSTENTION-POLICY.md](docs/ABSTENTION-POLICY.md)
- Urgency, discomfort, notes, and symptoms remain strictly user-reported
- Manual logging that never uploads
- Sleek three-destination mobile shell with one dominant photo action
@@ -135,6 +136,10 @@ Timmy does not diagnose disease, identify bleeding, infer pain/urgency, recommen
The calibration harness ([scripts/calibration_report.py](scripts/calibration_report.py), [contract](docs/EVALUATION-HARNESS.md)) computes the ROADMAP release gates — macro-F1, precision-at-coverage, and calibration error — from prediction files, chooses a threshold on validation only, and refuses a corpus that leaks one contributor across two splits. Its committed fixtures are invented numbers that exercise the arithmetic: **no candidate model has been measured against these gates yet, and the harness itself is not evidence about accuracy.**
The reviewer tool ([scripts/review_corpus.py](scripts/review_corpus.py), [contract](docs/REVIEWER-TOOL.md)) is the labeling control for that corpus, and it is a command-line tool with no screen in the app. The review queue shows the contributor's proposal and withholds every model field, a reveal is refused until a human has committed their own labels, a correction must actually change the proposal, adjudication is appended beside the review it resolves instead of overwriting it, and a withdrawn contribution cannot be queued, reviewed, revealed, adjudicated, or trained on. Unreviewed and excluded samples are never training data. It records decisions; it does not measure a model.
What the corpus is, and what a label may assert, is published rather than implied: [docs/DATASET_CARD.md](docs/DATASET_CARD.md) is generated from the manifest by [scripts/dataset_card.py](scripts/dataset_card.py), so every count on it is read back from real records and every fact the pipeline does not collect (devices, contexts, licenses) is printed as `pending` instead of estimated — a card for a corpus where one contributor straddles two splits is refused, not published. [docs/LABELING_GUIDE.md](docs/LABELING_GUIDE.md) is the reviewer's rulebook: visible form, broad visible color, and photo quality only, visible color is not blood, nothing is diagnosed, and a model suggestion is provisional input to a human decision rather than the label.
## Architecture
```text
+98
View File
@@ -0,0 +1,98 @@
# Abstention policy
Issue #32 asks for raw model scores to be turned into a conservative suggestion policy. This is that
policy. It is the only thing that decides whether Timmy offers a visual prefill, and it answers two
different questions that are easy to confuse:
1. **Per photo** — is this individual answer good enough to show?
2. **Per release** — has a candidate model earned the right to prefill at all?
Both are implemented in [`src/abstention-policy.js`](../src/abstention-policy.js). The parser in
[`src/analysis.js`](../src/analysis.js) reads its limits from that module rather than carrying its own
copy, so a policy change is one edit, not three.
A suggestion is never a diagnosis, never fills in urgency, discomfort, symptoms, or notes, and is
never saved until the user confirms it. Abstention is the default; a prefill is the exception.
## Why quality is a gate, not a footnote
`imageQuality` is something the model reports about its own photo. A confident form and colour read
from a blurry photo is exactly the answer a user is most likely to accept without thinking, so
confidence alone cannot authorise a prefill. The policy accepts `good` and `fair`; `poor` abstains
regardless of how confident the reply looked. This is proven end to end by the committed corpus case
`poor-quality-confident`, which replays a 0.94-confidence answer about the synthetic motion-blur
fixture and expects an abstention.
## Per-photo gates
Evaluated in this order. The first gate that fails names the refusal, so the user is told the real
reason rather than a generic one.
| Order | Gate | Refusal (`abstainedFor`) | User-facing copy |
|---|---|---|---|
| 1 | Model says the photo is not clearly stool | `not_stool` | The image does not clearly show stool. |
| 2 | Confidence below `minConfidence` (0.55) | `low_confidence` | The image is too uncertain to prefill safely. |
| 3 | Form or colour came back blank | `unusable_visual_fields` | The photo does not show a form Timmy can read safely. |
| 4 | `imageQuality` is below the accepted levels | `low_quality` | The photo is too blurry or poorly lit to suggest a form safely. |
A filled-in field outside the contract (`bristolType` outside 1–7, an unknown `color`, an unknown
`imageQuality`) is not an abstention — it **fails closed** with no provider detail shown, because a
caller must never be told "the photo was unreadable" when the model actually returned an impossible
value. That ordering is covered by the corpus escape cases (`out-of-range-fields`,
`blank-plus-out-of-range`).
Unknown and out-of-distribution images can only be judged from what the schema lets the model say: it
must declare `isStool`, a confidence, and a quality. There is no separate OOD channel, and this policy
does not invent one. A photo the model cannot place therefore lands on `not_stool`, `low_confidence`,
or `low_quality` — all three abstain.
## Per-release gate
The calibration harness ([`scripts/calibration_report.py`](../scripts/calibration_report.py), issue
#29) scores candidates on a held-out test split with a threshold selected on validation only. The
policy turns that report into a single decision with `evaluatePrefillGate(report)`:
| Requirement | Blocked reason if unmet |
|---|---|
| Report verdict is `GO` | `calibration-verdict-not-go` |
| The report names a recommended candidate | `no-recommended-calibration` |
| That candidate's threshold was selected on `validation` | `threshold-not-selected-on-validation` |
| That candidate was scored on the held-out `test` split | `gate-not-scored-on-held-out-test` |
| That candidate passed every release gate | `candidate-failed-release-gates` |
A missing or unreadable report is a block, never a default allow.
Run it as a receipt:
```bash
python3 scripts/calibration_report.py \
--predictions tests/fixtures/calibration/predictions.jsonl \
--min-coverage 0.5 --json /tmp/calibration-report.json
node scripts/abstention_policy_receipt.mjs --report /tmp/calibration-report.json
# exit 0 = prefill authorised, 3 = blocked, 2 = the report could not be read
```
**Current state:** the committed synthetic predictions produce `BLOCKED`, so the receipt exits 3 and
names why. No production candidate exists yet — issue #31 (fine-tuned classifier) is the dependency —
and the policy exists so that candidate is held to one stated contract instead of whatever it happens
to score. The release builder does not gate on this receipt yet; it records the quality gate on camera
through the demo instead. Wiring the receipt into `scripts/build_release.py` as a hard prefill gate
belongs with #31, when there is a candidate to judge.
## Versioning
`ABSTENTION_POLICY.version` is an integer that changes whenever a limit changes. A policy change is a
behaviour change: the release notes must be able to say which policy produced a suggestion. The
shipped object is frozen so nothing can tighten or loosen it at runtime; callers that need a
different policy pass one explicitly (`parseVisionResponse(raw, policy)`), which is how the tests
prove the parser has no hidden limits of its own.
## What this policy does not do
- It does not decide the Bristol type, the colour, or the quality. It only decides whether to show
them.
- It does not touch deterministic red-flag escalation, which runs independently of model state.
- It does not read, store, or log an image, and it never inspects inference bodies.
- It does not set the numeric thresholds from anything but validation data, and it does not convert a
`BLOCKED` calibration into a pass.
+8
View File
@@ -59,3 +59,11 @@ python3 scripts/ingest_training_photo.py --revoke "$CONTRIBUTION_ID" \
`npm run test:corpus` runs the contract suite. Every fixture is synthetic; no real medical image
is read, written, or committed.
## Review and adjudication
A manifest says what was ingested and whether the person still allows it. It does not say whether
the labels are right. That decision lives in a separate append-only ledger,
`<dataset>/reviews.jsonl`, written by [scripts/review_corpus.py](review_corpus.py) — see
[REVIEWER-TOOL.md](REVIEWER-TOOL.md). The trainer reads `trainable_records()`, which returns only
samples that are still active, carry a human decision, and were not excluded.
+104
View File
@@ -0,0 +1,104 @@
# Dataset card — Timmy consented stool-image corpus
Generated by [`scripts/dataset_card.py`](../scripts/dataset_card.py) from the append-only
corpus manifest. Do not edit this page by hand: regenerate it, or `--check` fails. Every count
below is read back from `manifest.jsonl`; a fact the pipeline does not collect is printed as
`pending` rather than estimated, so nothing here is invented.
| Field | Value |
|---|---|
| Corpus | `/srv/timmy-corpus` |
| Corpus present on this machine | no |
| Last manifest record | pending — no record yet |
| Active samples | 0 |
| Contributors | 0 |
| Withdrawals | 0 |
| Contexts | pending — not collected by ingest — no setting, time-of-day, or location fields are recorded |
| Devices | pending — not collected by ingest — no camera, model, or OS fields are recorded |
| Licenses | pending — no contributor grant is recorded yet, so no license is asserted |
| Regenerate | `python3 scripts/dataset_card.py --corpus /srv/timmy-corpus` |
## What this corpus is for
Consented photos of stool, labelled by a human reviewer, for research on a stool-form
classifier. The corpus may not be used to diagnose disease, to identify a person, or to
train a model that speaks to a patient with clinical authority. A model's output stays
provisional until the person logging confirms it: see [PRODUCT-DECISIONS.md](PRODUCT-DECISIONS.md).
This corpus holds no consented images yet. Every count of zero below is a real count of
an empty manifest, not a placeholder, and the pending facts stay pending until the
pipeline collects them.
## Contributor-level split policy
Splits are derived from the salted pseudonymous subject key, never assigned per photo: the
first byte of the key decides the split, `< 205` train, `< 230` validation, otherwise test
(the ingestion rule in `scripts/ingest_training_photo.py`). One contributor's photos all land
in one split, and this generator refuses a manifest where a subject key appears in two splits
or where a recorded split is not the one its subject key derives. Both edges are stated here
so the policy on this page is the policy in the code.
| Split | Contributors | Active samples |
|---|---:|---:|
| `train` | 0 | 0 |
| `validation` | 0 | 0 |
| `test` | 0 | 0 |
## Class balance (active samples only)
**Type**
| Type | Active samples |
|---|---:|
| Type 1 | 0 |
| Type 2 | 0 |
| Type 3 | 0 |
| Type 4 | 0 |
| Type 5 | 0 |
| Type 6 | 0 |
| Type 7 | 0 |
**Color**
| Color | Active samples |
|---|---:|
| `brown` | 0 |
| `green` | 0 |
| `yellow` | 0 |
| `pale` | 0 |
| `red` | 0 |
| `black` | 0 |
**Quality**
| Quality | Active samples |
|---|---:|
| `good` | 0 |
| `fair` | 0 |
| `poor` | 0 |
**Review status**
| Review status | Active samples |
|---|---:|
| `user-confirmed` | 0 |
| `clinician-reviewed` | 0 |
A withdrawn sample is excluded from every table above. Labels are what a reviewer saw, not a
diagnosis: [LABELING_GUIDE.md](LABELING_GUIDE.md) says what a label may and may not assert.
## Exclusions and withdrawals
A withdrawal appends a tombstone, deletes the derived image and any raw copy, and stays in the
history. Contribution ids and reasons are published here because a deletion receipt is
checkable; subject keys and labels are never published.
No withdrawals are recorded.
## Limitations
- Composition (devices, contexts, licenses) is pending, so the corpus is not yet a
representative sample of anything and must not be described as one.
- Review status is a human decision, not a clinical gold standard; `user-confirmed` means the
person who took the photo agreed with the proposal.
- A held-out split measures generalization across contributors, not across a population.
+4
View File
@@ -7,6 +7,10 @@ splits that are contributor-held-out and reproducible, comparing candidate model
The implementation is [`scripts/calibration_report.py`](../scripts/calibration_report.py). It reads
predictions something else produced, and it refuses to compute gates it cannot honestly compute.
The GO/BLOCKED verdict this report produces is consumed by the production abstention policy in
[`docs/ABSTENTION-POLICY.md`](ABSTENTION-POLICY.md): a candidate that has not cleared these gates
cannot authorise a visual prefill.
**This harness does not train, does not diagnose, and never reads an image.** It only sees Bristol
labels, predictions, and confidences.
+84
View File
@@ -0,0 +1,84 @@
# Labeling guide — Timmy corpus review
This is the guide a human reviewer follows when labelling a contributed photo in
[`scripts/review_corpus.py`](../scripts/review_corpus.py). It exists so a label in the corpus means
the same thing to every reviewer, and so a model trained on those labels cannot quietly inherit a
clinical claim nobody made. The corpus it describes is summarised, from the manifest and never by
hand, in [DATASET_CARD.md](DATASET_CARD.md).
A label is an observation. It is **not** a diagnosis, not a severity assessment, and not a statement
about the person's health.
## What a reviewer may record
Three fields, all of them visible in the photo:
| Field | Vocabulary | Rule |
|---|---|---|
| `bristolType` | 1–7 | The Bristol form the sample most resembles, by shape and surface — not by how the person says they feel. |
| `color` | `brown`, `green`, `yellow`, `pale`, `red`, `black` | The **broad visible color** of the sample in the photo. |
| `quality` | `good`, `fair`, `poor` | Whether the photo supports a confident form and color call at all. |
`quality` is about the photo, not the person: glare, blur, framing, and distance. A `poor` photo
belongs in the corpus as a `poor` photo; it is not a reason to guess a higher-quality label, and
`review_corpus.py` excludes an unclear sample from training instead of filling the gap.
## Color is not blood
The single most damaging mistake available here. Visible color describes what the photo looks like;
it does not establish that blood is present:
- `red` means **the sample looks reddish in this photo**. It is **not blood**, and it is not
"bleeding". A reviewer never writes "blood" into a label, a comment, or an exclusion reason.
- `black` means **the sample looks black or very dark in this photo**. It is not "melena", not
"GI bleed", and not evidence of anything.
- Neither field may be used to reassure. A `brown` label is **not** a statement that there is no
bleeding; a photo cannot rule that out and the app does not claim it can.
- Blood, black or tarry stool, pain, urgency, fever, or vomiting are **user-reported** facts, asked
in words and routed to the deterministic red-flag path. They are never inferred from an image and
never stored as a label by this tool.
If the photo genuinely shows something a reviewer believes needs urgent attention, the correct action
is to stop labelling and escalate to the maintainers — not to encode a clinical judgement in a class.
## Never diagnose
- Do not diagnose, rule out, or name a disease, condition, or cause in any label, comment, or
exclusion reason.
- Do not infer symptoms, pain, urgency, frequency, hydration, diet, medication, or pregnancy.
- Do not grade severity ("mild", "concerning", "abnormal"). The vocabulary is form, color, and photo
quality only.
- A model suggestion is **provisional input**, never the label. The reviewer's own committed decision
is the label; `review_corpus.py` refuses to reveal the model's answer until the reviewer has
committed theirs, and records any disagreement beside the proposal rather than averaging it away.
## Exclusions
Exclude a sample when it cannot be labelled honestly:
| Situation | Action |
|---|---|
| No stool visible, or the photo is of something else | Exclude: `not-stool`. |
| Photo too blurred, glared, or distant to judge form | Exclude: `unreadable-photo`. |
| Two samples in one frame, or ambiguous framing | Exclude: `ambiguous-frame`. |
| Reviewer believes the image needs urgent clinical attention | **Stop.** Escalate to maintainers; do not label, do not exclude quietly. |
| Contributor withdrew consent | Not a review decision: the manifest tombstones the contribution and the queue drops it. |
An exclusion is recorded with a reason from the list above. An excluded sample is never training
data, and re-including it requires a new review, not an edit.
## Splits: one contributor, one split
All of a contributor's photos stay in **one split**, decided by their salted pseudonymous subject key
(`< 205` train, `< 230` validation, otherwise test — the ingestion rule in
`scripts/ingest_training_photo.py`). A reviewer never chooses a split, and a reviewer never moves a
sample between splits to balance a class. If a class is thin, the honest answer is more consented
contributors, not a leaked one. `scripts/dataset_card.py` refuses to publish a card for a corpus
where one subject key appears in two splits.
## Reporting a problem with this guide
If a label feels impossible to assign without a clinical judgement, that is a gap in this guide, not
a licence to improvise. Open an issue against
[stackchain/timmy-talking-turd](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd)
describing the case, without attaching a real medical image.
+93
View File
@@ -0,0 +1,93 @@
# Blind review and adjudication tool
Contract for `scripts/review_corpus.py`, the labeling control named in the
[photo data-flow threat model](PHOTO-DATAFLOW-THREAT-MODEL.md) and the gate in front of the
[training corpus](CORPUS-MANIFEST.md). Issue [#26](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/26).
The tool decides which samples a trainer is allowed to read. It is a command-line tool, not a
screen in the app: there is no reviewer UI, and nothing here implies one.
## The four invariants
1. **The model answer is withheld until a human commits.** `build_queue()` takes no predictions at
all, so the queue cannot leak an answer it never receives. `reveal()` refuses to return anything
for a contribution with no committed review.
2. **History is append-only.** A review is a line in `reviews.jsonl`. An adjudication is another
line that names the record it resolves (`resolvesRecord`, `resolves`). The review being
overruled is never edited, and no record is deleted.
3. **A withdrawal beats every other answer.** A revoked contribution cannot be queued, reviewed,
revealed, adjudicated, or trained on. The refusal says it was withdrawn — not that it is
already reviewed — because the person's withdrawal is the fact that matters.
4. **Unreviewed and excluded samples are not training data.** `trainable_records()` returns only
samples that are still active, carry a human decision, and were not excluded.
## Review ledger records
One JSON object per line in `<dataset>/reviews.jsonl`:
```json
{"schemaVersion":1,"recordType":"review","contributionId":"...","imageId":"<sha256>","proposed":{"bristolType":4,"color":"brown","quality":"good"},"reviewer":"reviewer-a","decision":"corrected","labels":{"bristolType":5,"color":"green","quality":"fair"},"committedAt":"2026-10-27T09:00:00+00:00"}
{"schemaVersion":1,"recordType":"adjudication","contributionId":"...","imageId":"<sha256>","resolvesRecord":1,"resolves":{"reviewer":"reviewer-a","decision":"corrected"},"reviewer":"reviewer-b","decision":"upheld","labels":{"bristolType":5,"color":"green","quality":"fair"},"adjudicatedAt":"2026-10-27T10:00:00+00:00"}
```
- `proposed` copies the labels the reviewer judged, so a later manifest change cannot rewrite what
was reviewed.
- `decision` is `agreed`, `corrected`, or `excluded` for a review, and `upheld`, `corrected`, or
`excluded` for an adjudication. An exclusion carries `exclusionReason` and no labels.
- The ledger never carries `subjectKey`, note text, symptom answers, or image bytes.
## A model answer file
`reveal` reads predictions only to show what the reviewer could not see. A file may be one JSON
document or one object per line:
```json
{"model":"<name>","answers":{"<contributionId>":{"bristolType":6,"color":"brown","quality":"good","confidence":0.71}}}
```
Keys may be contribution ids or derived image digests. `reveal` is a read: it appends nothing.
When no answer file is supplied, `model` is `null` — the tool does not invent an answer.
## Usage
```bash
python3 scripts/review_corpus.py queue --dataset /srv/timmy-corpus
python3 scripts/review_corpus.py review --dataset /srv/timmy-corpus \
--contribution "$CONTRIBUTION_ID" --reviewer reviewer-a --bristol 5 --color green --quality fair
python3 scripts/review_corpus.py reveal --dataset /srv/timmy-corpus \
--contribution "$CONTRIBUTION_ID" --predictions answers.json
python3 scripts/review_corpus.py adjudicate --dataset /srv/timmy-corpus \
--contribution "$CONTRIBUTION_ID" --reviewer reviewer-b --upheld
python3 scripts/review_corpus.py trainable --dataset /srv/timmy-corpus
```
Every mutating command prints the record it appended. A refusal exits `2` and prints one line of
plain copy on stderr.
## Refusals (all covered by `tests/corpus-review.test.py`)
| Attempt | Result |
|---|---|
| Review or reveal a withdrawn contribution | refused: `was withdrawn and cannot be reviewed, revealed, or trained on` |
| Reveal before any review is committed | refused: `stays hidden until a review is committed` |
| Review the same contribution twice | refused; adjudication is the only way to change a decision |
| Correct a label to the value it already has | refused: a correction must change it |
| Review with a blank reviewer id | refused |
| Exclude without a reason | refused |
| Review a contribution that has no active sample | refused |
| Queue an active sample whose image is missing | refused: a reviewer cannot judge a file that is not there |
| Adjudicate your own review | refused: adjudication needs a second reviewer |
| Adjudicate twice | refused |
| Adjudicate a contribution with no review | refused |
## Known limits
- One review per contribution, resolved by at most one adjudication. Re-review after an
adjudication would need a new record type; it is not implemented.
- The tool does not measure or validate a model. It records what a human decided and what a model
answered, so the disagreement can be counted later.
- It does not train, split, or export a dataset; `trainable_records()` is the list a trainer may
read, not a trainer.
`npm run test:review` runs the contract suite. Every fixture is the committed synthetic image; no
real medical image is read, written, or committed.
+6 -3
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js",
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js tests/abstention-policy.test.js",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
@@ -18,13 +18,16 @@
"test:grounded-chat": "node tests/grounded-chat.acceptance.mjs",
"test:photo-lifecycle": "node tests/photo-lifecycle.acceptance.mjs",
"test:training-contribution": "node tests/training-contribution.acceptance.mjs",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/corpus_manifest.py scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py scripts/calibration_report.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check src/abstention-policy.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/corpus_manifest.py scripts/review_corpus.py scripts/dataset_card.py scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py scripts/calibration_report.py && node --check scripts/record_worker_benchmark.mjs && node --check scripts/abstention_policy_receipt.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs",
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs",
"test:portability": "node tests/portability.acceptance.mjs",
"test:corpus": "python3 tests/corpus-manifest.test.py",
"test:calibration": "python3 tests/calibration-harness.test.py"
"test:calibration": "python3 tests/calibration-harness.test.py",
"test:policy": "node --test tests/abstention-policy.test.js",
"test:review": "python3 tests/corpus-review.test.py",
"test:dataset-card": "python3 tests/dataset-card.test.py"
},
"devDependencies": {
"playwright": "^1.62.1"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Executable receipt for the production abstention policy (issue #32).
//
// The calibration harness decides GO/BLOCKED for a candidate model. This reads that report and
// answers the only question the app cares about: may a suggestion be shipped at all? It exits 3 when
// prefill is blocked, so a release step can gate on it instead of reading prose.
//
// There is no model candidate yet (#31), so in practice this receipt reports BLOCKED today. That is
// the honest state: the policy is ready, the evidence is not.
import { readFile } from 'node:fs/promises';
import { ABSTENTION_POLICY, evaluatePrefillGate } from '../src/abstention-policy.js';
function parseArgs(argv) {
const args = { report: '' };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--report') {
args.report = argv[i + 1] || '';
i += 1;
} else throw new Error(`unknown argument: ${argv[i]}`);
}
if (!args.report) throw new Error('--report <calibration-report.json> is required');
return args;
}
let args;
try {
args = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(error.message);
process.exit(2);
}
let report;
try {
report = JSON.parse(await readFile(args.report, 'utf8'));
} catch (error) {
console.error(`could not read the calibration report at ${args.report}: ${error.message}`);
process.exit(2);
}
const gate = evaluatePrefillGate(report);
const policy = ABSTENTION_POLICY;
console.log('TIMMY ABSTENTION POLICY RECEIPT');
console.log(`policy: version ${policy.version} · confidence ≥ ${policy.minConfidence.toFixed(2)} · quality ${policy.acceptedImageQuality.join('|')}`);
console.log(`calibration: verdict ${gate.verdict} · model ${gate.calibrationModel || 'none'}`);
console.log(gate.prefillAllowed
? `prefill: AUTHORISED for ${gate.calibrationModel}`
: `prefill: BLOCKED — ${gate.blockedBy.join(', ')}`);
process.exit(gate.prefillAllowed ? 0 : 3);
+25 -2
View File
@@ -46,6 +46,7 @@ DEMO_REQUIRED_CAPTIONS = (
"A tarry note already saved in the journal escalates the same way, before any provider call",
"An out-of-range answer fails closed: nothing is applied and no provider detail reaches you",
"A filled-in field that is out of range still fails closed, even when the other field came back blank",
"A confident reply about a blurry photo still abstains — photo quality is part of the policy, not just confidence",
"Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident",
"Every pattern line names the confirmed logs behind it, and following one shows exactly those records",
"Two logs is not a pattern: the summary refuses to describe a trend it cannot support",
@@ -67,6 +68,14 @@ 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",
"The dataset card is generated from the corpus manifest, so no count on it is typed by hand",
"One contributor's photos stay in one split: a corpus that leaks a person across splits is refused",
"Devices, contexts, and licenses stay pending until the pipeline collects them",
"The card is a page, not a screen in the app — it is published with the real generator against synthetic fixtures",
)
@@ -165,6 +174,12 @@ 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 #27 publishes a dataset card from the manifest, so the generator that decides what
# a stranger reads about the corpus is exercised before this release exists.
run(["python3", "tests/dataset-card.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)
@@ -260,7 +275,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", "scripts/dataset_card.py"], tree)
run(["git", "diff", "--check", commit], tree)
names = tracked_files(tree)
@@ -296,6 +311,11 @@ 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"
"- Published dataset card and labeling guide (issue #27): the card is generated from the corpus manifest by scripts/dataset_card.py, so every count on it is read back from the manifest and every fact the pipeline does not collect (devices, contexts, licenses) is printed as pending instead of estimated; the generator refuses to publish a card for a corpus where one contributor straddles two splits or where a recorded split is not the one the subject key derives, a withdrawn sample contributes no label to the balance tables, and no subject key ever reaches the page\n"
"- Contributor-level split policy, published where it is enforced: splits follow the salted subject key (below 205 train, below 230 validation, otherwise test), all of a contributor's photos land in one split, and docs/LABELING_GUIDE.md states that a reviewer records visible form, broad visible color, and photo quality only — visible color is not blood, nothing is diagnosed, and a model suggestion is provisional input to a human decision rather than the label\n"
"- Scope note: the dataset card is a generated document, not a screen in the app, and this release does not imply one — the recorded demo builds a throwaway corpus with the real ingest command from synthetic fixtures, publishes the card with the real generator, and shows the refusal a planted contributor earns\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"
)
@@ -340,7 +360,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, 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",
@@ -351,12 +371,15 @@ def main() -> int:
"provisional_suggestion_state": "passed",
"hard_stool_followup": "passed",
"vision_corpus_refusal": "passed",
"abstention_quality_gate": "passed",
"worker_container_receipts": "passed",
"worker_post_load_readiness": "passed",
"longitudinal_pattern_summary": "passed",
"grounded_chat_citations": "passed",
"training_contribution_consent": "passed",
"corpus_manifest_provenance": "passed",
"reviewer_adjudication_blind": "passed",
"dataset_card_generated": "passed",
"calibration_harness_gates": "passed",
"portable_backup_restore": "passed",
"dependency_audit_high": "passed",
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""Generate the published dataset card from the append-only corpus manifest (issue #27).
A dataset card that is typed by hand drifts from the corpus it describes, and a card that estimates
its own numbers is worse than no card at all. This generator therefore only reports what the manifest
actually contains:
- counts of active samples, contributors, and withdrawals,
- the contributor-level split policy, checked before a single line is rendered,
- class balance over the active samples only,
- every fact the pipeline does not collect (devices, contexts, licenses) printed as ``pending``.
Nothing is inferred. A person's pseudonymous subject key never reaches the page, and a withdrawn
sample contributes no label to the balance tables. The output is a pure function of the manifest, so
the committed card can be regenerated and compared byte for byte.
Usage:
python3 scripts/dataset_card.py --corpus /srv/timmy-corpus
python3 scripts/dataset_card.py --corpus /srv/timmy-corpus --out docs/DATASET_CARD.md
python3 scripts/dataset_card.py --corpus /srv/timmy-corpus --check docs/DATASET_CARD.md
python3 scripts/dataset_card.py --corpus /srv/timmy-corpus --json
Exit codes: 0 the card rendered (and matched, with --check); 1 the card on disk is out of date;
2 the corpus cannot be described honestly and was refused.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from corpus_manifest import ( # noqa: E402
COLORS,
QUALITIES,
REVIEWERS,
SAMPLE,
SPLITS,
TOMBSTONE,
CorpusError,
active_records,
load_manifest,
)
from calibration_report import expected_split # noqa: E402
# The facts no current pipeline step records. They are reported as pending rather than guessed.
PENDING_FACTS = ("contexts", "devices", "licenses")
PENDING_LABELS = {
"devices": "Devices",
"contexts": "Contexts",
"licenses": "Licenses",
}
PENDING_REASONS = {
"devices": "not collected by ingest — no camera, model, or OS fields are recorded",
"contexts": "not collected by ingest — no setting, time-of-day, or location fields are recorded",
"licenses": "no contributor grant is recorded yet, so no license is asserted",
}
class CardError(Exception):
"""The corpus cannot be described as asked."""
def assert_contributor_held_out(records: list[dict]) -> None:
"""Refuse a corpus where one contributor straddles splits or a split cannot be re-derived.
The rule is the ingestion rule, so a corpus cannot quietly relabel its own splits: a split must
follow from the pseudonymous subject key alone, and one person's photos must all land in one
split. Otherwise every held-out number downstream is measuring a person the model already met.
"""
splits_by_subject: dict[str, set[str]] = {}
for record in records:
if record.get("recordType", SAMPLE) != SAMPLE:
continue
subject = record.get("subjectKey")
if not isinstance(subject, str) or not subject:
raise CardError("every sample must carry the pseudonymous subject key")
split = record.get("split")
if split not in SPLITS:
raise CardError(f"split must be one of {', '.join(SPLITS)}: {split!r}")
splits_by_subject.setdefault(subject, set()).add(split)
for subject, splits in sorted(splits_by_subject.items()):
if len(splits) > 1:
raise CardError(
f"contributor {subject} leaks across {', '.join(sorted(splits))}: "
"one contributor's photos must stay in one split"
)
for record in records:
if record.get("recordType", SAMPLE) != SAMPLE:
continue
try:
required = expected_split(record["subjectKey"])
except Exception as exc: # noqa: BLE001 - the rule reports its own refusal
raise CardError(str(exc)) from exc
if record["split"] != required:
raise CardError(
f"split {record['split']!r} for contributor {record['subjectKey']} is not reproducible: "
f"the subject key derives {required!r}"
)
def summarize(records: list[dict]) -> dict:
"""Count what the manifest holds. Pure function of the records, in a stable order."""
assert_contributor_held_out(records)
active = active_records(records)
# A withdrawal can appear as a tombstone or as a revoked sample record (both are legal history);
# it is one withdrawn contribution either way, so count distinct contribution ids.
withdrawn: dict[str, dict] = {}
for record in records:
is_tombstone = record.get("recordType") == TOMBSTONE
is_revoked_sample = (
record.get("recordType", SAMPLE) == SAMPLE
and record.get("lifecycle", {}).get("state") == "revoked"
)
if not (is_tombstone or is_revoked_sample):
continue
contribution = record.get("contributionId")
if not isinstance(contribution, str) or not contribution:
raise CardError("a withdrawn record must carry the contribution id it revokes")
lifecycle = record.get("lifecycle", {})
entry = withdrawn.setdefault(
contribution,
{"contributionId": contribution, "withdrawnAt": None, "reason": None, "artifacts": 0},
)
stamp = lifecycle.get("updatedAt")
if stamp and (entry["withdrawnAt"] is None or stamp > entry["withdrawnAt"]):
entry["withdrawnAt"] = stamp
if lifecycle.get("reason"):
entry["reason"] = lifecycle["reason"]
if is_tombstone:
entry["artifacts"] = len(record.get("deletedArtifacts") or [])
by_split = {split: {"contributors": set(), "samples": 0} for split in SPLITS}
for record in active:
by_split[record["split"]]["contributors"].add(record["subjectKey"])
by_split[record["split"]]["samples"] += 1
bristol = {str(day): 0 for day in range(1, 8)}
color = {name: 0 for name in COLORS}
quality = {name: 0 for name in QUALITIES}
review = {name: 0 for name in REVIEWERS}
for record in active:
labels = record.get("labels", {})
bristol[str(labels.get("bristolType"))] = bristol.get(str(labels.get("bristolType")), 0) + 1
if labels.get("color") in color:
color[labels["color"]] += 1
if labels.get("quality") in quality:
quality[labels["quality"]] += 1
status = record.get("review", {}).get("status")
if status in review:
review[status] += 1
stamps = [record.get("ingestedAt") for record in records if record.get("ingestedAt")]
stamps += [entry["withdrawnAt"] for entry in withdrawn.values() if entry["withdrawnAt"]]
withdrawals_detail = [
{
"contributionId": entry["contributionId"],
"withdrawnAt": entry["withdrawnAt"],
"reason": entry["reason"],
"artifacts": entry["artifacts"],
}
for entry in withdrawn.values()
]
return {
"activeSamples": len(active),
"contributors": len({record["subjectKey"] for record in active}),
"withdrawals": len(withdrawn),
"bySplit": {split: {"contributors": len(entry["contributors"]), "samples": entry["samples"]} for split, entry in by_split.items()},
"bristol": bristol,
"colors": color,
"qualities": quality,
"review": review,
"lastRecordAt": max(stamps) if stamps else None,
"withdrawalsDetail": sorted(withdrawals_detail, key=lambda entry: (entry["withdrawnAt"] or "", entry["contributionId"] or "")),
"pending": sorted(PENDING_FACTS),
}
def _count_table(title: str, rows: list[tuple[str, int]]) -> list[str]:
lines = [f"**{title}**", "", f"| {title} | Active samples |", "|---|---:|"]
lines += [f"| {label} | {count} |" for label, count in rows]
lines.append("")
return lines
def render_card(summary: dict, *, corpus: str) -> str:
"""Render the card. Every value here comes from the manifest, or is marked pending."""
present = summary["activeSamples"] > 0 or summary["withdrawals"] > 0
lines = [
"# Dataset card — Timmy consented stool-image corpus",
"",
"Generated by [`scripts/dataset_card.py`](../scripts/dataset_card.py) from the append-only",
"corpus manifest. Do not edit this page by hand: regenerate it, or `--check` fails. Every count",
"below is read back from `manifest.jsonl`; a fact the pipeline does not collect is printed as",
"`pending` rather than estimated, so nothing here is invented.",
"",
"| Field | Value |",
"|---|---|",
f"| Corpus | `{corpus}` |",
f"| Corpus present on this machine | {'yes' if present else 'no'} |",
f"| Last manifest record | {summary['lastRecordAt'] or 'pending — no record yet'} |",
f"| Active samples | {summary['activeSamples']} |",
f"| Contributors | {summary['contributors']} |",
f"| Withdrawals | {summary['withdrawals']} |",
]
for fact in PENDING_FACTS:
lines.append(f"| {PENDING_LABELS[fact]} | pending — {PENDING_REASONS[fact]} |")
lines.append(f"| Regenerate | `python3 scripts/dataset_card.py --corpus {corpus}` |")
lines += [
"",
"## What this corpus is for",
"",
"Consented photos of stool, labelled by a human reviewer, for research on a stool-form",
"classifier. The corpus may not be used to diagnose disease, to identify a person, or to",
"train a model that speaks to a patient with clinical authority. A model's output stays",
"provisional until the person logging confirms it: see [PRODUCT-DECISIONS.md](PRODUCT-DECISIONS.md).",
"",
]
if summary["activeSamples"] == 0:
lines += [
"This corpus holds no consented images yet. Every count of zero below is a real count of",
"an empty manifest, not a placeholder, and the pending facts stay pending until the",
"pipeline collects them.",
"",
]
lines += [
"## Contributor-level split policy",
"",
"Splits are derived from the salted pseudonymous subject key, never assigned per photo: the",
"first byte of the key decides the split, `< 205` train, `< 230` validation, otherwise test",
"(the ingestion rule in `scripts/ingest_training_photo.py`). One contributor's photos all land",
"in one split, and this generator refuses a manifest where a subject key appears in two splits",
"or where a recorded split is not the one its subject key derives. Both edges are stated here",
"so the policy on this page is the policy in the code.",
"",
"| Split | Contributors | Active samples |",
"|---|---:|---:|",
]
for split in SPLITS:
entry = summary["bySplit"][split]
lines.append(f"| `{split}` | {entry['contributors']} | {entry['samples']} |")
lines += ["", "## Class balance (active samples only)", ""]
lines += _count_table("Type", [(f"Type {day}", summary["bristol"][str(day)]) for day in range(1, 8)])
lines += _count_table("Color", [(f"`{name}`", count) for name, count in summary["colors"].items()])
lines += _count_table("Quality", [(f"`{name}`", count) for name, count in summary["qualities"].items()])
lines += _count_table("Review status", [(f"`{name}`", count) for name, count in summary["review"].items()])
lines += [
"A withdrawn sample is excluded from every table above. Labels are what a reviewer saw, not a",
"diagnosis: [LABELING_GUIDE.md](LABELING_GUIDE.md) says what a label may and may not assert.",
"",
"## Exclusions and withdrawals",
"",
"A withdrawal appends a tombstone, deletes the derived image and any raw copy, and stays in the",
"history. Contribution ids and reasons are published here because a deletion receipt is",
"checkable; subject keys and labels are never published.",
"",
]
if summary["withdrawalsDetail"]:
lines += [
"| Contribution | Withdrawn at | Reason | Artifacts removed |",
"|---|---|---|---:|",
]
for entry in summary["withdrawalsDetail"]:
lines.append(
f"| `{entry['contributionId']}` | {entry['withdrawnAt'] or 'pending'} | "
f"{entry['reason'] or 'pending'} | {entry['artifacts']} |"
)
else:
lines.append("No withdrawals are recorded.")
lines += [
"",
"## Limitations",
"",
"- Composition (devices, contexts, licenses) is pending, so the corpus is not yet a",
" representative sample of anything and must not be described as one.",
"- Review status is a human decision, not a clinical gold standard; `user-confirmed` means the",
" person who took the photo agreed with the proposal.",
"- A held-out split measures generalization across contributors, not across a population.",
"",
]
return "\n".join(lines)
def load_corpus(corpus: Path) -> list[dict]:
try:
return load_manifest(corpus / "manifest.jsonl")
except (CorpusError, json.JSONDecodeError) as exc:
raise CardError(f"the manifest at {corpus / 'manifest.jsonl'} cannot be read: {exc}") from exc
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Generate the Timmy dataset card from the corpus manifest.")
parser.add_argument("--corpus", required=True, help="dataset root holding manifest.jsonl")
parser.add_argument("--out", help="write the card here instead of stdout")
parser.add_argument("--check", help="fail if the card at this path is not what would be generated")
parser.add_argument("--json", action="store_true", help="print the summary object instead of the card")
parser.add_argument("--corpus-label", help="path to name on the card (defaults to --corpus)")
args = parser.parse_args(argv)
corpus = Path(args.corpus)
try:
records = load_corpus(corpus)
summary = summarize(records)
except CardError as exc:
print(f"refusing to render a dataset card: {exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps(summary, indent=2, sort_keys=True))
return 0
card = render_card(summary, corpus=args.corpus_label or args.corpus)
if args.check:
target = Path(args.check)
existing = target.read_text(encoding="utf-8") if target.exists() else ""
if existing != card:
print(
f"{target} is out of date: regenerate it with "
f"`python3 scripts/dataset_card.py --corpus {args.corpus_label or args.corpus} --out {target}`",
file=sys.stderr,
)
return 1
print(f"{target} matches the corpus manifest")
return 0
if args.out:
target = Path(args.out)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(card, encoding="utf-8")
print(f"wrote {target} ({summary['activeSamples']} active samples, {summary['contributors']} contributors)")
return 0
sys.stdout.write(card)
return 0
if __name__ == "__main__":
sys.exit(main())
+404 -105
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);
@@ -341,6 +402,20 @@ await tap('#analyze-photo', 600);
await page.getByText(/does not show a form Timmy can read safely/i).waitFor();
await caption('Unreadable visual fields abstain as well: a valid reply with nothing to prefill is not an error', 2700);
await indicate('.scan-result');
// Issue #32's quality gate: the model can be highly confident about a photo it also calls poor, and
// that confident answer must still abstain. Replayed from the committed corpus, and asserted in the
// browser so the scene is a check rather than a claim.
analyzeOverride = await corpusAnalyzeResult(corpusCase('poor-quality-confident'));
await tap('#retake-photo', 400);
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/blur-low-detail.png');
await page.locator('#ai-consent').check();
await tap('#analyze-photo', 600);
await page.getByText(/too blurry or poorly lit/i).waitFor();
if (await page.getByText(/BRISTOL FORM/i).count() !== 0) {
throw new Error('A low-quality photo must not prefill a form, however confident the reply looked');
}
await caption('A confident reply about a blurry photo still abstains — photo quality is part of the policy, not just confidence', 3100);
await indicate('.scan-result');
analyzeOverride = await corpusAnalyzeResult(corpusCase('out-of-range-fields'));
await tap('#retake-photo', 400);
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/out-of-range-fields.png');
@@ -647,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');
@@ -710,6 +739,313 @@ 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 #27 — the dataset card is a generated document, not a hand-written page. This scene builds a
// throwaway corpus with the real ingest command from committed synthetic fixtures, publishes the card
// with the real generator, and shows the refusal the split policy earns when one contributor is
// planted across two splits. Every count, id, and refusal line on the card is read back from those
// runs. No model labelled anything in this corpus, and the caption says so.
async function cardCli(args) {
try {
const result = await execFileAsync('python3', ['scripts/dataset_card.py', ...args], { cwd: process.cwd() });
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 || '',
};
}
}
async function publishDatasetCard() {
const dataset = await mkdtemp(path.join(os.tmpdir(), 'timmy-card-demo-'));
const leaked = await mkdtemp(path.join(os.tmpdir(), 'timmy-card-leak-'));
const cardPath = path.join(dataset, 'DATASET_CARD.md');
// Three distinct committed synthetic fixtures: a formed sample, a well-lit stool-like sample, and a
// glare frame that can only honestly be labelled poor quality.
const contributions = [
{ id: 'demo-card-0001-abcd', subject: 'demo-contributor-a', file: 'tests/fixtures/synthetic-type4.jpg', type: '4', color: 'brown', quality: 'good' },
{ id: 'demo-card-0002-abcd', subject: 'demo-contributor-a', file: 'tests/fixtures/vision-corpus/stool-like-formed.png', type: '3', color: 'green', quality: 'fair' },
{ id: 'demo-card-0003-abcd', subject: 'demo-contributor-b', file: 'tests/fixtures/vision-corpus/glare-overexposed.png', type: '1', color: 'brown', quality: 'poor' },
];
try {
for (const item of contributions) {
await corpusCli([
'scripts/ingest_training_photo.py',
'--input', item.file,
'--output-dir', dataset,
'--subject-id', item.subject,
'--consent-version', 'training-consent-v1',
'--contribution-id', item.id,
'--bristol-type', item.type, '--color', item.color, '--quality', item.quality,
'--reviewer', 'user-confirmed',
]);
}
// Withdraw one of the first contributor's two samples, so both contributors still have an active
// sample and the class table shows the withdrawn label dropping out of the count.
const withdrawn = contributions[1].id;
await corpusCli([
'scripts/ingest_training_photo.py', '--output-dir', dataset,
'--revoke', withdrawn, '--reason', 'participant withdrew consent',
]);
const published = await cardCli(['--corpus', dataset, '--out', cardPath]);
if (published.code !== 0) throw new Error(`the dataset card was refused: ${lastLine(published.stderr)}`);
const summary = JSON.parse((await cardCli(['--corpus', dataset, '--json'])).stdout);
if (summary.activeSamples !== 2) throw new Error('the card must count the two active samples the run ingested');
if (summary.contributors !== 2) throw new Error('the card must count both contributors');
if (summary.withdrawals !== 1) throw new Error('the card must count the withdrawal as a withdrawal');
const card = await readFile(cardPath, 'utf8');
for (const fact of ['| Devices | pending', '| Contexts | pending', '| Licenses | pending']) {
if (!card.includes(fact)) throw new Error(`an uncollected fact must be printed as pending: ${fact}`);
}
if (!card.includes(withdrawn) || card.includes('subjectKey')) {
throw new Error('the card must name the withdrawn contribution and never carry a subject key');
}
if (summary.bristol['3'] !== 0) throw new Error('a withdrawn sample must not be counted in the class balance');
// Plant one contributor in a second split and prove the policy refuses to publish a card at all.
const original = await readFile(path.join(dataset, 'manifest.jsonl'), 'utf8');
const records = original.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
const sample = records.find(record => record.recordType === 'sample' && record.contributionId === contributions[0].id);
const planted = { ...sample, contributionId: 'demo-card-0009-leak', split: sample.split === 'train' ? 'test' : 'train' };
await writeFile(path.join(leaked, 'manifest.jsonl'), `${original}${JSON.stringify(planted)}\n`);
const refused = await cardCli(['--corpus', leaked, '--out', path.join(leaked, 'DATASET_CARD.md')]);
if (refused.code === 0) throw new Error('the generator must refuse a corpus that leaks one contributor across splits');
const leakRefusal = lastLine(refused.stderr);
if (!/leaks across/.test(leakRefusal)) throw new Error(`the leak refusal was not legible: ${leakRefusal}`);
// The committed page has to be this generator's own output, not a document someone typed.
const committed = await cardCli(['--corpus', '/srv/timmy-corpus', '--check', 'docs/DATASET_CARD.md']);
if (committed.code !== 0) throw new Error("the committed dataset card must be the generator's own output");
return { summary, leakRefusal, withdrawn };
} finally {
await rm(dataset, { recursive: true, force: true });
await rm(leaked, { recursive: true, force: true });
}
}
const cardEvidence = await publishDatasetCard();
await tap('[data-view="home"]', 450);
const splits = cardEvidence.summary.bySplit;
const cardRows = [
['command', 'python3 scripts/dataset_card.py --corpus <tmp> --out DATASET_CARD.md'],
['corpus', `${cardEvidence.summary.activeSamples} active · ${cardEvidence.summary.contributors} contributors · ${cardEvidence.summary.withdrawals} withdrawn`],
['splits', `train ${splits.train.contributors}c/${splits.train.samples}s · validation ${splits.validation.contributors}c/${splits.validation.samples}s · test ${splits.test.contributors}c/${splits.test.samples}s`],
['class', `Type 1 ${cardEvidence.summary.bristol['1']} · Type 3 ${cardEvidence.summary.bristol['3']} · Type 4 ${cardEvidence.summary.bristol['4']} · withdrawn label excluded`],
['withdrawn', `${cardEvidence.withdrawn} · "participant withdrew consent" · no label survives in any table`],
['pending', 'devices · contexts · licenses — not collected, not estimated'],
['—', ''],
['command', 'publish a manifest that plants one contributor in train and test'],
['refused', cardEvidence.leakRefusal],
['card', 'not written: the policy is checked before a line is rendered'],
['—', ''],
['command', '--check docs/DATASET_CARD.md'],
['committed', 'the published page is this generator\'s own output'],
['data', 'synthetic fixtures · no model labelled anything here'],
];
const cardPanel = await evidenceCard({
id: 'dataset-card',
title: 'DATASET CARD',
subtitle: 'generated from the manifest — command line, not app UI',
rows: cardRows,
keyWidth: 70,
});
await sleep(420);
const cardProof = await evidenceCardProof('dataset-card');
if (!cardProof.ok) throw new Error(`Dataset card is not fully readable on screen: ${JSON.stringify(cardProof)}`);
if (!cardPanel.text.includes(`${cardEvidence.summary.contributors} contributors`)) {
throw new Error('the card must show the contributor count the run produced');
}
if (!cardPanel.text.includes('leaks across')) throw new Error('the card must show the leak refusal the run earned');
if (!cardPanel.text.includes('not collected')) throw new Error('the card must show the facts that stay pending');
await caption('The dataset card is generated from the corpus manifest, so no count on it is typed by hand', 3600);
await caption('One contributor\'s photos stay in one split: a corpus that leaks a person across splits is refused', 3800);
await caption('Devices, contexts, and licenses stay pending until the pipeline collects them', 3400);
await caption('The card is a page, not a screen in the app — it is published with the real generator against synthetic fixtures', 3400);
await page.evaluate(() => document.querySelector('#dataset-card')?.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
@@ -769,8 +1105,11 @@ const gateRows = [
['—', ''],
];
for (const row of gate.report.models) {
// The model id gets its own row: a 74px key column broke "specialist-candidate" at its hyphen,
// which reads as a label with no value and a stray line under it. The name is the value here.
gateRows.push(['model', row.model]);
gateRows.push([
row.model,
'metrics',
`thr ${row.threshold.toFixed(2)} · F1 ${row.macroF1.toFixed(3)} · P@C ${row.precisionAtCoverage.toFixed(3)} · cov ${row.coverage.toFixed(2)}`,
]);
gateRows.push(['gate', `FAIL: ${row.gate.failed.join(', ')} · ECE ${row.ece.toFixed(3)}`]);
@@ -782,58 +1121,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) {
+461
View File
@@ -0,0 +1,461 @@
#!/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())
+69
View File
@@ -0,0 +1,69 @@
// The production abstention policy (issue #32).
//
// A suggestion is only ever a visual prefill, and this module is the single place that decides when
// Timmy is allowed to offer one. Two different questions live here, and they are deliberately
// separate:
//
// 1. Per-photo: is this individual answer good enough to show? Confidence, image quality, and
// whether the model actually read a form and a colour.
// 2. Per-release: has a candidate cleared the calibration gates on a held-out split, with its
// threshold chosen on validation only? Until it has, no prefill may be shipped at all.
//
// The copy is fixed here so the browser, the corpus, and the server cannot drift into three
// different explanations of the same refusal.
export const ABSTENTION_COPY = Object.freeze({
not_stool: 'The image does not clearly show stool.',
low_confidence: 'The image is too uncertain to prefill safely.',
unusable_visual_fields: 'The photo does not show a form Timmy can read safely.',
low_quality: 'The photo is too blurry or poorly lit to suggest a form safely.',
});
// Bump `version` whenever a limit below changes: a policy change is a behaviour change, and the
// release notes have to be able to name which policy a suggestion was produced under.
export const ABSTENTION_POLICY = Object.freeze({
version: 1,
minConfidence: 0.55,
// "fair" may prefill; "poor" never does. The model already returns its own quality judgement, so
// a confident answer about an unreadable photo is exactly the case this gate exists for.
acceptedImageQuality: Object.freeze(['good', 'fair']),
});
export function abstainFor(reason, isStool = true) {
if (!Object.prototype.hasOwnProperty.call(ABSTENTION_COPY, reason)) {
throw new Error(`unknown abstention reason: ${reason}`);
}
return {
status: 'needs_user_input',
isStool,
abstainedFor: reason,
reason: ABSTENTION_COPY[reason],
};
}
export function isLowQuality(imageQuality, policy = ABSTENTION_POLICY) {
return !policy.acceptedImageQuality.includes(String(imageQuality || '').toLowerCase());
}
// Release-level gate. The calibration harness already refuses to pick a threshold anywhere but the
// validation split; this decides whether a report authorises prefill at all, and names every reason
// it does not. A missing or malformed report is a block, never a default-allow.
export function evaluatePrefillGate(report) {
const blockedBy = [];
const verdict = report?.verdict || 'UNKNOWN';
if (verdict !== 'GO') blockedBy.push('calibration-verdict-not-go');
const models = Array.isArray(report?.models) ? report.models : [];
const recommended = models.find(row => row?.model === report?.recommended) || null;
if (!recommended) {
blockedBy.push('no-recommended-calibration');
} else {
if (recommended.selectionSplit !== 'validation') blockedBy.push('threshold-not-selected-on-validation');
if (recommended.evaluatedSplit !== 'test') blockedBy.push('gate-not-scored-on-held-out-test');
if (recommended.gate?.passed !== true) blockedBy.push('candidate-failed-release-gates');
}
return {
prefillAllowed: blockedBy.length === 0,
verdict,
calibrationModel: recommended?.model || null,
blockedBy,
};
}
+8 -9
View File
@@ -1,3 +1,5 @@
import { ABSTENTION_POLICY, abstainFor, isLowQuality } from './abstention-policy.js';
const COLORS = new Set(['brown', 'green', 'yellow', 'pale', 'red', 'black']);
const QUALITIES = new Set(['good', 'fair', 'poor']);
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
@@ -26,13 +28,13 @@ function asObject(raw) {
}
}
export function parseVisionResponse(raw) {
export function parseVisionResponse(raw, policy = ABSTENTION_POLICY) {
const value = asObject(raw);
if (typeof value.isStool !== 'boolean') throw visionResponseError(VISION_FAILURE_CODES.invalidFields, 'Invalid AI response: isStool is required.');
const confidence = Number(value.confidence);
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) throw visionResponseError(VISION_FAILURE_CODES.invalidFields, 'Invalid AI response: confidence is required.');
if (!value.isStool) return { status: 'needs_user_input', isStool: false, abstainedFor: 'not_stool', reason: 'The image does not clearly show stool.' };
if (confidence < 0.55) return { status: 'needs_user_input', isStool: true, abstainedFor: 'low_confidence', reason: 'The image is too uncertain to prefill safely.' };
if (!value.isStool) return abstainFor('not_stool', false);
if (confidence < policy.minConfidence) return abstainFor('low_confidence');
// The request schema lets the model return a null form and color when it cannot read them.
// A blank field means the model could not read it, so a schema-valid answer with nothing usable
// abstains and nothing is prefilled. A filled-in field is a claim, though: if it sits outside the
@@ -47,15 +49,12 @@ export function parseVisionResponse(raw) {
throw visionResponseError(VISION_FAILURE_CODES.invalidFields, 'Invalid AI response: visual fields are out of range.');
}
if (bristolType === null || color === null) {
return {
status: 'needs_user_input',
isStool: true,
abstainedFor: 'unusable_visual_fields',
reason: 'The photo does not show a form Timmy can read safely.',
};
return abstainFor('unusable_visual_fields');
}
const imageQuality = String(value.imageQuality || '').toLowerCase();
if (!QUALITIES.has(imageQuality)) throw visionResponseError(VISION_FAILURE_CODES.invalidFields, 'Invalid AI response: visual fields are out of range.');
// A confident reading of a photo the model itself calls poor is still not a safe prefill.
if (isLowQuality(imageQuality, policy)) return abstainFor('low_quality');
return {
status: 'suggestion',
isStool: true,
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { ABSTENTION_COPY, ABSTENTION_POLICY, abstainFor, evaluatePrefillGate } from '../src/abstention-policy.js';
test('every abstention reason has one fixed piece of copy the user sees', () => {
assert.equal(abstainFor('not_stool').reason, 'The image does not clearly show stool.');
assert.equal(abstainFor('low_confidence').reason, 'The image is too uncertain to prefill safely.');
assert.equal(abstainFor('unusable_visual_fields').reason, 'The photo does not show a form Timmy can read safely.');
assert.equal(abstainFor('low_quality').reason, 'The photo is too blurry or poorly lit to suggest a form safely.');
assert.deepEqual(abstainFor('low_quality'), {
status: 'needs_user_input',
isStool: true,
abstainedFor: 'low_quality',
reason: ABSTENTION_COPY.low_quality,
});
assert.equal(abstainFor('not_stool', false).isStool, false);
assert.throws(() => abstainFor('looked_wrong'), /unknown abstention reason/);
});
test('the policy states the confidence floor and the quality levels that may prefill', () => {
assert.ok(Number.isInteger(ABSTENTION_POLICY.version));
assert.equal(ABSTENTION_POLICY.minConfidence, 0.55);
assert.deepEqual([...ABSTENTION_POLICY.acceptedImageQuality], ['good', 'fair']);
assert.equal(ABSTENTION_POLICY.acceptedImageQuality.includes('poor'), false);
assert.equal(Object.isFrozen(ABSTENTION_POLICY), true, 'the shipped policy must not be mutated at runtime');
});
test('prefill is authorised only by a GO report whose threshold was selected on validation', () => {
const passing = {
verdict: 'GO',
recommended: 'specialist-candidate',
models: [{
model: 'specialist-candidate',
selectionSplit: 'validation',
evaluatedSplit: 'test',
gate: { passed: true, failed: [] },
}],
};
assert.deepEqual(evaluatePrefillGate(passing), {
prefillAllowed: true,
verdict: 'GO',
calibrationModel: 'specialist-candidate',
blockedBy: [],
});
const blocked = evaluatePrefillGate({
verdict: 'BLOCKED',
recommended: null,
models: [{ model: 'specialist-candidate', selectionSplit: 'validation', evaluatedSplit: 'test', gate: { passed: false, failed: ['precisionAtCoverage'] } }],
});
assert.equal(blocked.prefillAllowed, false);
assert.deepEqual(blocked.blockedBy, ['calibration-verdict-not-go', 'no-recommended-calibration']);
const selectedOnTest = evaluatePrefillGate({
verdict: 'GO',
recommended: 'opportunist',
models: [{ model: 'opportunist', selectionSplit: 'test', evaluatedSplit: 'test', gate: { passed: true, failed: [] } }],
});
assert.equal(selectedOnTest.prefillAllowed, false, 'a threshold chosen on the split it is scored on is not evidence');
assert.deepEqual(selectedOnTest.blockedBy, ['threshold-not-selected-on-validation']);
const noReport = evaluatePrefillGate(undefined);
assert.equal(noReport.prefillAllowed, false);
assert.match(noReport.blockedBy.join(','), /calibration-verdict-not-go/);
});
const execFileAsync = promisify(execFile);
const receiptScript = fileURLToPath(new URL('../scripts/abstention_policy_receipt.mjs', import.meta.url));
async function runReceipt(report) {
const directory = await mkdtemp(join(tmpdir(), 'timmy-policy-'));
const reportPath = join(directory, 'calibration-report.json');
await writeFile(reportPath, JSON.stringify(report));
try {
const { stdout } = await execFileAsync(process.execPath, [receiptScript, '--report', reportPath]);
return { code: 0, stdout };
} catch (error) {
return { code: error.code, stdout: error.stdout || '', stderr: error.stderr || '' };
}
}
test('the receipt refuses to authorise prefill without a passing held-out calibration', async () => {
const blocked = await runReceipt({
verdict: 'BLOCKED',
recommended: null,
models: [{ model: 'specialist-candidate', selectionSplit: 'validation', evaluatedSplit: 'test', gate: { passed: false, failed: ['precisionAtCoverage'] } }],
});
assert.equal(blocked.code, 3, 'a blocked calibration must exit non-zero instead of shipping prefill');
assert.match(blocked.stdout, /prefill: BLOCKED/);
assert.match(blocked.stdout, /calibration-verdict-not-go/);
assert.match(blocked.stdout, /policy: version 1 · confidence ≥ 0\.55 · quality good\|fair/);
const passing = await runReceipt({
verdict: 'GO',
recommended: 'specialist-candidate',
models: [{ model: 'specialist-candidate', selectionSplit: 'validation', evaluatedSplit: 'test', gate: { passed: true, failed: [] } }],
});
assert.equal(passing.code, 0);
assert.match(passing.stdout, /prefill: AUTHORISED for specialist-candidate/);
});
test('the receipt treats a missing report as a block and never as a default allow', async () => {
const directory = await mkdtemp(join(tmpdir(), 'timmy-policy-'));
try {
await execFileAsync(process.execPath, [receiptScript, '--report', join(directory, 'absent.json')]);
assert.fail('a missing calibration report must not exit 0');
} catch (error) {
assert.equal(error.code, 2);
assert.match(String(error.stderr), /could not read|absent/i);
}
});
+37
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ABSTENTION_COPY, ABSTENTION_POLICY } from '../src/abstention-policy.js';
import {
buildVisionRequest,
@@ -131,3 +132,39 @@ test('abstains instead of failing when a schema-valid response carries no readab
reason: 'The photo does not show a form Timmy can read safely.',
});
});
test('a confident answer about a low-quality photo still abstains', () => {
// Issue #32: confidence is not the only gate. A blurry or badly lit photo can produce a
// confident-looking form and colour, and prefilling it would show the user a claim the photo
// cannot support. Quality is part of the policy, so this abstains instead of prefilling.
assert.deepEqual(parseVisionResponse({
isStool: true,
bristolType: 4,
color: 'brown',
confidence: 0.94,
imageQuality: 'poor',
observations: 'Form barely visible through motion blur.',
}), {
status: 'needs_user_input',
isStool: true,
abstainedFor: 'low_quality',
reason: 'The photo is too blurry or poorly lit to suggest a form safely.',
});
});
test('the parse path reads the shipped policy instead of hardcoding its own limits', () => {
// Issue #32: policy numbers and copy live in one module. A caller may tighten the policy for a
// release without editing the parser, and a photo that the default policy would accept has to
// abstain under the stricter one — otherwise the parser is carrying its own hidden policy.
const fields = { isStool: true, bristolType: 4, color: 'brown', confidence: 0.8, imageQuality: 'fair', observations: 'Form visible but soft.' };
assert.equal(parseVisionResponse(fields).status, 'suggestion', 'fair quality may prefill under the shipped policy');
const strict = { ...ABSTENTION_POLICY, minConfidence: 0.9, acceptedImageQuality: ['good'] };
assert.equal(parseVisionResponse(fields, strict).abstainedFor, 'low_confidence');
assert.equal(parseVisionResponse({ ...fields, confidence: 0.95 }, strict).abstainedFor, 'low_quality');
assert.equal(parseVisionResponse({ ...fields, confidence: 0.95, imageQuality: 'good' }, strict).status, 'suggestion');
assert.equal(
parseVisionResponse({ ...fields, imageQuality: 'poor' }).reason,
ABSTENTION_COPY.low_quality,
'the refusal copy must come from the policy module, not a second copy in the parser',
);
});
+11
View File
@@ -34,6 +34,17 @@ test('CI pins the Python image dependency required by the full unit suite', asyn
assert.match(requirements, /^Pillow==12\.3\.0$/m);
});
test('the dataset card generator and its suite are CI- and release-gated', async () => {
const workflow = await readFile(workflowPath, 'utf8');
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
// Issue #27 publishes a page a stranger reads about the corpus, so the generator that decides its
// numbers runs in CI and is invoked by its own suite, not only by whoever remembers.
assert.match(workflow, /python3 tests\/dataset-card\.test\.py -v/);
assert.match(packageJson.scripts['test:dataset-card'], /dataset-card\.test\.py/);
assert.match(packageJson.scripts['check:syntax'], /py_compile[^&]*scripts\/dataset_card\.py/);
});
test('default unit suite includes the self-host bootstrap contract', async () => {
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
+419
View File
@@ -0,0 +1,419 @@
#!/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)
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Tests for the published dataset card and the contributor-level split policy.
Issue #27 asks for three things a stranger can inspect before trusting a model: a dataset card that
reports who is in the corpus, *the* labeling guide, and a contributor-level split policy that keeps a
person's photos in exactly one split. The card is generated from the append-only manifest by
[`scripts/dataset_card.py`](../scripts/dataset_card.py) instead of being written by hand, so a number
on it is a claim the corpus can be re-checked against and an unknown fact can only appear as
``pending`` — never as a plausible-looking estimate.
Every fixture here is synthetic: records are built through the real
[`scripts/corpus_manifest.py`](../scripts/corpus_manifest.py) builder and no image bytes are read or
written.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
CARD_SCRIPT = ROOT / "scripts" / "dataset_card.py"
COMMITTED_CARD = ROOT / "docs" / "DATASET_CARD.md"
LABELING_GUIDE = ROOT / "docs" / "LABELING_GUIDE.md"
CANONICAL_CORPUS = "/srv/timmy-corpus"
# Subject keys chosen so the ingestion rule (first byte of the key) puts each contributor in a known
# split: < 205 train, < 230 validation, otherwise test.
TRAIN_A = "0a" + "1" * 14
TRAIN_B = "0b" + "2" * 14
VALIDATION_C = "d0" + "3" * 14
TEST_D = "ee" + "4" * 14
def load_module():
spec = importlib.util.spec_from_file_location("corpus_manifest", ROOT / "scripts" / "corpus_manifest.py")
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
structures = load_module()
def digest(seed: str) -> str:
import hashlib
return hashlib.sha256(seed.encode()).hexdigest()
def sample(
*,
contribution_id: str,
subject_key: str,
split: str,
bristol: int = 4,
color: str = "brown",
quality: str = "good",
reviewer: str = "user-confirmed",
at: str = "2026-08-01T10:00:00+00:00",
) -> dict:
return structures.build_record(
contribution_id=contribution_id,
consent_version="training-consent-v1",
subject_key=subject_key,
split=split,
labels={"bristolType": bristol, "color": color, "quality": quality},
review_status=reviewer,
derived_sha256=digest(contribution_id + "-derived"),
raw_sha256=digest(contribution_id + "-raw"),
ingested_at=at,
)
def write_manifest(dataset: Path, records: list[dict]) -> Path:
manifest = dataset / "manifest.jsonl"
manifest.parent.mkdir(parents=True, exist_ok=True)
with manifest.open("w", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record, separators=(",", ":")) + "\n")
return manifest
def run_card(args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(CARD_SCRIPT), *args],
cwd=ROOT,
capture_output=True,
text=True,
env={**os.environ},
)
def build_corpus(dataset: Path) -> None:
"""Four active samples across three contributors, plus one withdrawal."""
records = [
sample(contribution_id="contrib-train-a1", subject_key=TRAIN_A, split="train", bristol=4),
sample(contribution_id="contrib-train-a2", subject_key=TRAIN_A, split="train", bristol=3, color="green"),
sample(contribution_id="contrib-train-b1", subject_key=TRAIN_B, split="train", bristol=6, quality="fair"),
sample(
contribution_id="contrib-validation-c1",
subject_key=VALIDATION_C,
split="validation",
bristol=4,
reviewer="clinician-reviewed",
),
sample(contribution_id="contrib-test-d1", subject_key=TEST_D, split="test", bristol=1, quality="poor"),
]
revoked = structures.transition(
sample(contribution_id="contrib-withdrawn-1", subject_key=TEST_D, split="test", bristol=7, color="black"),
"revoked",
"2026-08-02T09:00:00+00:00",
"participant withdrew consent",
)
write_manifest(dataset, [*records, revoked])
class DatasetCardTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.dataset = Path(self.tmp.name) / "corpus"
self.dataset.mkdir(parents=True, exist_ok=True)
def tearDown(self) -> None:
self.tmp.cleanup()
def test_card_reports_the_real_corpus_counts_from_the_manifest(self) -> None:
build_corpus(self.dataset)
result = run_card(["--corpus", str(self.dataset)])
self.assertEqual(result.returncode, 0, result.stderr)
card = result.stdout
# Five active samples from four contributors; the withdrawal is reported separately.
self.assertIn("| Active samples | 5 |", card)
self.assertIn("| Contributors | 4 |", card)
self.assertIn("| Withdrawals | 1 |", card)
# Contributor-level split policy: the contributor count per split is what a reader needs to
# see that one person cannot be in two splits.
self.assertIn("| `train` | 2 | 3 |", card)
self.assertIn("| `validation` | 1 | 1 |", card)
self.assertIn("| `test` | 1 | 1 |", card)
# Class balance is counted, not described.
self.assertIn("| Type 4 | 2 |", card)
self.assertIn("| Type 1 | 1 |", card)
self.assertIn("| `black` | 0 |", card, "a withdrawn sample's label must not be counted")
self.assertIn("| `clinician-reviewed` | 1 |", card)
def test_card_marks_uncollected_facts_pending_instead_of_estimating_them(self) -> None:
build_corpus(self.dataset)
card = run_card(["--corpus", str(self.dataset)]).stdout
for fact in ("Devices", "Contexts", "Licenses"):
line = next((row for row in card.splitlines() if row.startswith(f"| {fact} |")), None)
self.assertIsNotNone(line, f"the card must name {fact}")
self.assertIn("pending", line.lower(), f"{fact} cannot be reported until it is collected")
self.assertIn("rather than estimated", card.lower())
self.assertIn("nothing here is invented", card.lower())
def test_card_names_withdrawals_without_exposing_the_person_or_their_labels(self) -> None:
build_corpus(self.dataset)
card = run_card(["--corpus", str(self.dataset)]).stdout
self.assertIn("contrib-withdrawn-1", card)
self.assertIn("participant withdrew consent", card)
for subject in (TRAIN_A, TRAIN_B, VALIDATION_C, TEST_D):
self.assertNotIn(subject, card, "the published card must never carry a subject key")
self.assertNotIn(digest("contrib-withdrawn-1" + "-derived"), card)
def test_card_refuses_a_contributor_spread_across_two_splits(self) -> None:
write_manifest(
self.dataset,
[
sample(contribution_id="contrib-leak-1", subject_key=TEST_D, split="test"),
# Same person, relabelled into training: the leak the policy exists to stop.
sample(contribution_id="contrib-leak-2", subject_key=TEST_D, split="train"),
],
)
out = Path(self.tmp.name) / "card.md"
result = run_card(["--corpus", str(self.dataset), "--out", str(out)])
self.assertNotEqual(result.returncode, 0)
self.assertRegex(result.stderr, r"contributor [0-9a-f]{16} (leaks across|is in two splits)")
self.assertFalse(out.exists(), "a refused card must not be written")
def test_card_refuses_a_split_the_subject_key_does_not_derive(self) -> None:
write_manifest(
self.dataset,
[sample(contribution_id="contrib-mislabelled-1", subject_key=TEST_D, split="train")],
)
result = run_card(["--corpus", str(self.dataset)])
self.assertNotEqual(result.returncode, 0)
self.assertRegex(result.stderr, r"not reproducible")
def test_card_states_the_split_rule_it_applies(self) -> None:
build_corpus(self.dataset)
card = run_card(["--corpus", str(self.dataset)]).stdout
# The thresholds are the ingestion rule; a card that states a different rule is a different
# policy, so both edges are named on the page.
self.assertIn("205", card)
self.assertIn("230", card)
self.assertRegex(card, r"subject key")
def test_card_is_byte_identical_between_runs(self) -> None:
build_corpus(self.dataset)
first = run_card(["--corpus", str(self.dataset)])
second = run_card(["--corpus", str(self.dataset)])
# A rendered card, not two matching empty strings: the run has to succeed and say something.
self.assertEqual(first.returncode, 0, first.stderr)
self.assertIn("| Active samples | 5 |", first.stdout)
self.assertEqual(first.stdout, second.stdout)
def test_card_is_honest_when_there_is_no_corpus(self) -> None:
missing = Path(self.tmp.name) / "absent"
result = run_card(["--corpus", str(missing)])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("| Active samples | 0 |", result.stdout)
self.assertIn("| Contributors | 0 |", result.stdout)
self.assertRegex(result.stdout, r"no consented images")
self.assertIn("pending", result.stdout.lower(), "an empty corpus reports pending facts, not blanks")
def test_card_reports_a_withdrawal_count_without_inventing_a_denominator(self) -> None:
build_corpus(self.dataset)
summary = json.loads(run_card(["--corpus", str(self.dataset), "--json"]).stdout)
self.assertEqual(summary["activeSamples"], 5)
self.assertEqual(summary["contributors"], 4)
self.assertEqual(summary["withdrawals"], 1)
self.assertEqual(summary["bySplit"]["train"], {"contributors": 2, "samples": 3})
self.assertEqual(summary["pending"], ["contexts", "devices", "licenses"])
self.assertEqual([entry["contributionId"] for entry in summary["withdrawalsDetail"]], ["contrib-withdrawn-1"])
self.assertNotIn("subjectKey", json.dumps(summary))
def test_committed_card_is_the_generator_output_not_a_hand_written_page(self) -> None:
card = COMMITTED_CARD.read_text(encoding="utf-8")
result = run_card(["--corpus", CANONICAL_CORPUS])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
card,
result.stdout,
"docs/DATASET_CARD.md must be regenerated with scripts/dataset_card.py, never hand-edited",
)
self.assertIn(f"--corpus {CANONICAL_CORPUS}", card)
def test_check_mode_refuses_a_hand_edited_card(self) -> None:
build_corpus(self.dataset)
stale = Path(self.tmp.name) / "stale-card.md"
stale.write_text("# Dataset card\n\n| Active samples | 9000 |\n", encoding="utf-8")
result = run_card(["--corpus", str(self.dataset), "--check", str(stale)])
self.assertNotEqual(result.returncode, 0)
self.assertRegex(result.stderr, r"out of date|differs")
def test_labeling_guide_separates_visible_color_from_blood_and_forbids_diagnosis(self) -> None:
guide = LABELING_GUIDE.read_text(encoding="utf-8")
lowered = guide.lower()
self.assertIn("color", lowered)
# Visible color is not bleeding: the guide has to say so in the reviewer's own vocabulary.
self.assertIn("not blood", lowered)
for forbidden in ("diagnose", "diagnosis"):
self.assertIn(forbidden, lowered, "the guide must name what a label may never assert")
self.assertRegex(lowered, r"do not diagnose|never diagnos")
# Exclusions and the split policy are part of the guide, not a separate folklore.
self.assertIn("exclude", lowered)
self.assertRegex(lowered, r"one split|same split")
# The card and the guide point at each other, so a reader lands on both.
self.assertIn("DATASET_CARD.md", guide)
self.assertIn("LABELING_GUIDE.md", COMMITTED_CARD.read_text(encoding="utf-8"))
def test_labeling_guide_never_claims_a_model_decides_the_label(self) -> None:
guide = LABELING_GUIDE.read_text(encoding="utf-8")
lowered = guide.lower()
self.assertRegex(lowered, r"human|reviewer")
# A model suggestion is provisional input to a human decision, never the decision.
self.assertRegex(lowered, r"provisional|suggestion")
self.assertNotRegex(lowered, r"model (decides|determines) the label")
def test_readme_points_a_newcomer_at_the_card_and_the_guide(self) -> None:
readme = (ROOT / "README.md").read_text(encoding="utf-8")
# A published card nobody links to is not published: both pages are reachable from the front page.
self.assertIn("docs/DATASET_CARD.md", readme)
self.assertIn("docs/LABELING_GUIDE.md", readme)
self.assertIn("scripts/dataset_card.py", readme)
if __name__ == "__main__":
unittest.main(verbosity=2)
+16
View File
@@ -131,6 +131,22 @@
"expectedOutcome": "fail_closed",
"expectedReason": "out_of_range_or_missing_fields",
"providerContent": "{\"isStool\":true,\"bristolType\":9,\"color\":null,\"confidence\":0.9,\"imageQuality\":\"good\",\"observations\":\"Form read, colour not.\"}"
},
{
"id": "poor-quality-confident",
"caseClass": "blur",
"file": "blur-low-detail.png",
"bytes": 21598,
"sha256": "17000163662a42fd434bb2b2428d9e5a2156da08cbc82210e41dfc6fa7e24852",
"width": 640,
"height": 480,
"format": "png",
"synthetic": true,
"license": "CC0-1.0",
"provenance": "The same synthetic motion-blur fixture that blur-low-detail replays, replayed again with a confident provider answer; drawn deterministically by scripts/build_vision_corpus.py, no camera, no human subject, no third-party content.",
"expectedOutcome": "abstained",
"expectedReason": "low_quality",
"providerContent": "{\"isStool\":true,\"bristolType\":4,\"color\":\"brown\",\"confidence\":0.94,\"imageQuality\":\"poor\",\"observations\":\"Form barely visible through motion blur.\"}"
}
]
}
+70 -1
View File
@@ -272,6 +272,14 @@ test('release demo visibly exercises the corpus refusal paths instead of only th
assert.match(demo, /A non-stool photo gets a refusal, never a guess/);
assert.match(demo, /Unreadable visual fields abstain as well/);
assert.match(demo, /An out-of-range answer fails closed/);
// Issue #32's quality gate: a confident answer about a low-quality photo must abstain on camera.
assert.match(demo, /corpusCase\('poor-quality-confident'\)/);
assert.match(demo, /tests\/fixtures\/vision-corpus\/blur-low-detail\.png/);
assert.match(demo, /too blurry or poorly lit/);
assert.match(demo, /must not prefill a form, however confident the reply looked/);
assert.match(demo, /A confident reply about a blurry photo still abstains/);
assert.match(builder, /A confident reply about a blurry photo still abstains — photo quality is part of the policy, not just confidence/);
assert.match(builder, /"abstention_quality_gate": "passed"/);
assert.match(demo, /no real photos in the corpus|no real photos in the recording/);
// The refusal copy the user sees is gated on camera, so a release cannot drop it silently.
assert.match(builder, /An out-of-range answer fails closed/);
@@ -404,7 +412,7 @@ test('release demo replays the calibration harness against synthetic predictions
assert.match(demo, /the engineered fixture must prove the passing branch/);
assert.match(demo, /a leaked corpus must be refused before scoring/);
// The card is drawn from the report the run wrote, and gated for readability like the corpus card.
assert.match(demo, /id = 'calibration-report'/);
assert.match(demo, /id: 'calibration-report'/);
assert.match(demo, /Calibration card is not fully readable on screen/);
assert.match(demo, /the card must show the macro-F1 the harness measured/);
assert.match(demo, /the card must show the refusal the leaked corpus earned/);
@@ -417,3 +425,64 @@ test('release demo replays the calibration harness against synthetic predictions
assert.match(builder, /Each candidate is scored on test at a threshold chosen on validation only/);
assert.match(builder, /One contributor spread across two splits is refused before anything is scored/);
});
test('release demo runs the blind review tool in its enforced order instead of describing it', async () => {
const demo = await readFile(demoPath, 'utf8');
const builder = await readFile(builderPath, 'utf8');
// Issue #26: the review tool is a CLI. The scene runs the real commands and shows their output.
assert.match(demo, /scripts\/review_corpus\.py/);
assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/);
// Blindness is proven by the refusal, not by a caption: reveal is attempted before any commit.
assert.match(demo, /stays hidden until a review is committed/);
// The reviewer's labels are committed before the model answer is shown, and both are recorded.
assert.match(demo, /--reviewer'?,? ?'reviewer-a'/);
assert.match(demo, /disagreeFields/);
// A second reader adjudicates; the review it resolves must still be in the ledger afterwards.
assert.match(demo, /'adjudicate'/);
assert.match(demo, /resolvesRecord/);
// A withdrawn contribution leaves the queue, the training set, and the review path entirely.
assert.match(demo, /was withdrawn and cannot be reviewed/);
assert.match(demo, /id: 'review-audit'/);
assert.match(demo, /Review card is not fully readable on screen/);
assert.match(demo, /the card must show the model answer the run revealed/);
assert.match(demo, /the card must show the withdrawn refusal the run earned/);
// The card must not claim a model measured anything: the answer file is a synthetic fixture.
assert.match(demo, /synthetic answer file · no model was measured/);
// The scene is a release gate, so a later release cannot quietly drop it.
assert.match(builder, /tests\/corpus-review\.test\.py/);
assert.match(builder, /"reviewer_adjudication_blind": "passed"/);
assert.match(builder, /The review tool is a command line, not a screen in the app/);
assert.match(builder, /The queue shows the proposal and hides the model answer; revealing it is refused until a reviewer commits/);
assert.match(builder, /The model said Type 6 and the reviewer said Type 5: the disagreement is recorded, not smoothed over/);
assert.match(builder, /Withdrawing the contribution empties the queue and the training set, and the tool refuses to review it at all/);
});
test('release demo publishes the dataset card from a manifest it builds on camera', async () => {
const demo = await readFile(demoPath, 'utf8');
const builder = await readFile(builderPath, 'utf8');
// Issue #27: the card is a generated artifact. The scene builds a real corpus with the real ingest
// commands, publishes the card, and re-checks the committed page, instead of describing a document.
assert.match(demo, /scripts\/dataset_card\.py/);
assert.match(demo, /id: 'dataset-card'/);
assert.match(demo, /Dataset card is not fully readable on screen/);
// The split policy is proven by the refusal, not by a caption: a leaked contributor is attempted.
assert.match(demo, /leaks across/);
assert.match(demo, /the generator must refuse a corpus that leaks one contributor across splits/);
// The pending facts must be visibly pending, and the committed page must be the generator's output.
assert.match(demo, /pending/);
assert.match(demo, /the committed dataset card must be the generator's own output/);
assert.match(demo, /docs\/DATASET_CARD\.md/);
// Nothing on the card may claim a model measured or labelled anything here.
assert.match(demo, /no model labelled anything here/);
// The scene and its guide are release gates, so a later release cannot drop them silently.
assert.match(builder, /The dataset card is generated from the corpus manifest, so no count on it is typed by hand/);
assert.match(builder, /One contributor's photos stay in one split: a corpus that leaks a person across splits is refused/);
assert.match(builder, /Devices, contexts, and licenses stay pending until the pipeline collects them/);
assert.match(builder, /tests\/dataset-card\.test\.py/);
assert.match(builder, /"dataset_card_generated": "passed"/);
assert.match(builder, /scripts\/dataset_card\.py/);
});