Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dabad495b |
No files matched your search
@@ -35,6 +35,7 @@ jobs:
|
||||
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: |
|
||||
|
||||
@@ -138,6 +138,8 @@ The calibration harness ([scripts/calibration_report.py](scripts/calibration_rep
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+3
-2
@@ -18,7 +18,7 @@
|
||||
"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 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/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: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",
|
||||
@@ -26,7 +26,8 @@
|
||||
"test:corpus": "python3 tests/corpus-manifest.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:review": "python3 tests/corpus-review.test.py",
|
||||
"test:dataset-card": "python3 tests/dataset-card.test.py"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
|
||||
@@ -72,6 +72,10 @@ DEMO_REQUIRED_CAPTIONS = (
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
@@ -173,6 +177,9 @@ def main() -> int:
|
||||
# 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)
|
||||
@@ -268,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", "scripts/review_corpus.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)
|
||||
@@ -306,6 +313,9 @@ def main() -> int:
|
||||
"- 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"
|
||||
)
|
||||
@@ -369,6 +379,7 @@ def main() -> int:
|
||||
"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",
|
||||
|
||||
@@ -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())
|
||||
@@ -921,6 +921,131 @@ await caption('A second reviewer adjudicates, and the review being overruled is
|
||||
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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -458,3 +458,31 @@ test('release demo runs the blind review tool in its enforced order instead of d
|
||||
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/);
|
||||
});
|
||||
Reference in new issue
Block a user