Quality gates / quality (pull_request) Successful in 13m1s
CI's diff-hygiene gate rejects a trailing blank line at EOF; the local check:diff run happened before the first commit existed, so it checked the previous range and missed it.
242 lines
9.9 KiB
Python
242 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Immutable, auditable corpus manifest for Timmy training contributions.
|
|
|
|
Every ingested sample is described by one append-only manifest record: who consented, under
|
|
which consent version, where the bytes came from, the hashes of the raw input and the derived
|
|
image, how the derived image was produced, who reviewed the labels, which split it belongs to,
|
|
and where it sits in its lifecycle. Nothing in this module rewrites history: records are only
|
|
ever appended, and a withdrawal is a tombstone, not a deletion of the trail.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
SCHEMA_VERSION = 2
|
|
PIPELINE = "timmy-ingest/2"
|
|
|
|
SAMPLE = "sample"
|
|
TOMBSTONE = "tombstone"
|
|
RECORD_TYPES = frozenset({SAMPLE, TOMBSTONE})
|
|
|
|
ACTIVE = "active"
|
|
REVOKED = "revoked"
|
|
STATES = frozenset({ACTIVE, REVOKED})
|
|
|
|
SPLITS = ("train", "validation", "test")
|
|
REVIEWERS = ("user-confirmed", "clinician-reviewed")
|
|
COLORS = ("brown", "green", "yellow", "pale", "red", "black")
|
|
QUALITIES = ("good", "fair", "poor")
|
|
|
|
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
_CONTRIBUTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
|
|
|
|
|
class CorpusError(Exception):
|
|
"""A record cannot be built or written as asked."""
|
|
|
|
|
|
class IllegalTransition(CorpusError):
|
|
"""A lifecycle change that must never happen is being attempted."""
|
|
|
|
|
|
def sha256_hex(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _require_sha256(value: object, field: str) -> str:
|
|
if not isinstance(value, str) or not _SHA256.match(value):
|
|
raise CorpusError(f"{field} must be a lowercase hex sha256 digest")
|
|
return value
|
|
|
|
|
|
def _require_iso(value: object, field: str) -> str:
|
|
if not isinstance(value, str) or not re.match(r"^\d{4}-\d{2}-\d{2}T", value):
|
|
raise CorpusError(f"{field} must be an ISO-8601 timestamp")
|
|
return value
|
|
|
|
|
|
def build_record(
|
|
*,
|
|
contribution_id: str,
|
|
consent_version: str,
|
|
subject_key: str,
|
|
split: str,
|
|
labels: dict,
|
|
review_status: str,
|
|
derived_sha256: str,
|
|
raw_sha256: str,
|
|
ingested_at: str,
|
|
source_kind: str = "cli-ingest",
|
|
) -> dict:
|
|
"""Build one active sample record carrying its full provenance."""
|
|
if not isinstance(contribution_id, str) or not _CONTRIBUTION_ID.match(contribution_id):
|
|
raise CorpusError("contribution id is required and must be a stable opaque identifier")
|
|
if not isinstance(consent_version, str) or not consent_version.strip():
|
|
raise CorpusError("consent version is required")
|
|
if not isinstance(subject_key, str) or not re.match(r"^[0-9a-f]{16}$", subject_key):
|
|
raise CorpusError("subject key must be the 16-character pseudonymous digest")
|
|
if split not in SPLITS:
|
|
raise CorpusError(f"split must be one of {', '.join(SPLITS)}")
|
|
if review_status not in REVIEWERS:
|
|
raise CorpusError(f"review status must be one of {', '.join(REVIEWERS)}")
|
|
if not isinstance(source_kind, str) or not source_kind.strip():
|
|
raise CorpusError("source kind is required")
|
|
if not isinstance(labels, dict) or set(labels) != {"bristolType", "color", "quality"}:
|
|
raise CorpusError("labels must carry bristolType, color, and quality")
|
|
if labels["bristolType"] not in range(1, 8):
|
|
raise CorpusError("bristolType must be 1-7")
|
|
if labels["color"] not in COLORS or labels["quality"] not in QUALITIES:
|
|
raise CorpusError("color and quality must be from the documented vocabularies")
|
|
derived = _require_sha256(derived_sha256, "derived sha256")
|
|
raw = _require_sha256(raw_sha256, "raw input sha256")
|
|
stamped = _require_iso(ingested_at, "ingestedAt")
|
|
|
|
return {
|
|
"schemaVersion": SCHEMA_VERSION,
|
|
"recordType": SAMPLE,
|
|
"contributionId": contribution_id,
|
|
"imageId": derived,
|
|
"relativePath": f"images/{derived}.jpg",
|
|
"subjectKey": subject_key,
|
|
"split": split,
|
|
"consentVersion": consent_version.strip(),
|
|
"consent": {"version": consent_version.strip(), "recordedAt": stamped},
|
|
"source": {"kind": source_kind, "inputSha256": raw},
|
|
"hashes": {"rawInput": raw, "derived": derived},
|
|
"derivation": {
|
|
"format": "jpeg",
|
|
"maxDimension": 1200,
|
|
"metadataStripped": True,
|
|
"pipeline": PIPELINE,
|
|
},
|
|
"labels": {
|
|
"bristolType": labels["bristolType"],
|
|
"color": labels["color"],
|
|
"quality": labels["quality"],
|
|
},
|
|
"review": {"status": review_status, "reviewedAt": stamped},
|
|
"lifecycle": {"state": ACTIVE, "updatedAt": stamped},
|
|
"ingestedAt": stamped,
|
|
}
|
|
|
|
|
|
def assert_admissible(manifest_path: Path, record: dict) -> None:
|
|
"""Refuse a sample that duplicates a contribution or re-labels the same derived image."""
|
|
if record.get("recordType", SAMPLE) != SAMPLE:
|
|
return
|
|
for prior in load_manifest(manifest_path):
|
|
if prior.get("recordType", SAMPLE) != SAMPLE:
|
|
continue
|
|
if prior.get("contributionId") == record.get("contributionId"):
|
|
raise IllegalTransition(f"contribution {record['contributionId']} was already ingested")
|
|
if prior.get("imageId") == record.get("imageId") and prior.get("labels") != record.get("labels"):
|
|
raise CorpusError("the same derived image cannot carry two different label sets")
|
|
|
|
|
|
def append_record(manifest_path: Path, record: dict) -> None:
|
|
"""Append one record to the manifest without touching what is already there."""
|
|
assert_admissible(manifest_path, record)
|
|
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with manifest_path.open("a", encoding="utf-8") as manifest:
|
|
manifest.write(json.dumps(record, separators=(",", ":"), sort_keys=False) + "\n")
|
|
|
|
|
|
def load_manifest(manifest_path: Path) -> list[dict]:
|
|
"""Read the append-only manifest in order. A missing manifest is an empty corpus."""
|
|
if not manifest_path.exists():
|
|
return []
|
|
with manifest_path.open("r", encoding="utf-8") as manifest:
|
|
return [json.loads(line) for line in manifest.read().splitlines() if line.strip()]
|
|
|
|
|
|
def transition(record: dict, state: str, at: str, reason: str | None = None) -> dict:
|
|
"""Return a copy of the record in the next lifecycle state, or refuse the move."""
|
|
current = record.get("lifecycle", {}).get("state")
|
|
if state not in STATES:
|
|
raise IllegalTransition(f"unknown lifecycle state: {state!r}")
|
|
if current not in STATES:
|
|
raise IllegalTransition(f"record carries no legal lifecycle state: {current!r}")
|
|
if current == state:
|
|
raise IllegalTransition(f"record is already {state}")
|
|
if current == REVOKED:
|
|
raise IllegalTransition("a revoked contribution cannot return to the active dataset")
|
|
_require_iso(at, "lifecycle timestamp")
|
|
if state == REVOKED and (not isinstance(reason, str) or not reason.strip()):
|
|
raise IllegalTransition("revocation needs a recorded reason")
|
|
updated = {**record, "lifecycle": {"state": state, "updatedAt": at}}
|
|
if state == REVOKED:
|
|
updated["lifecycle"]["reason"] = reason.strip()
|
|
return updated
|
|
|
|
|
|
def active_records(records: list[dict]) -> list[dict]:
|
|
"""Samples still usable for training: active, and never revoked afterwards."""
|
|
revoked = {
|
|
record.get("contributionId")
|
|
for record in records
|
|
if record.get("recordType") == TOMBSTONE or record.get("lifecycle", {}).get("state") == REVOKED
|
|
}
|
|
return [
|
|
record
|
|
for record in records
|
|
if record.get("recordType", SAMPLE) == SAMPLE
|
|
and record.get("lifecycle", {}).get("state") == ACTIVE
|
|
and record.get("contributionId") not in revoked
|
|
]
|
|
|
|
|
|
def _safe_dataset_path(dataset_root: Path, relative: str) -> Path:
|
|
path = (dataset_root / relative).resolve()
|
|
if not path.is_relative_to(dataset_root.resolve()):
|
|
raise CorpusError(f"refusing to touch a path outside the dataset: {relative}")
|
|
return path
|
|
|
|
|
|
def revoke(dataset_root: Path, contribution_id: str, at: str, reason: str) -> dict:
|
|
"""Revoke one contribution: append a tombstone and delete its bytes from the dataset."""
|
|
manifest_path = dataset_root / "manifest.jsonl"
|
|
records = load_manifest(manifest_path)
|
|
samples = [
|
|
record
|
|
for record in records
|
|
if record.get("recordType", SAMPLE) == SAMPLE and record.get("contributionId") == contribution_id
|
|
]
|
|
if not isinstance(reason, str) or not reason.strip():
|
|
raise IllegalTransition("revocation needs a recorded reason")
|
|
if any(
|
|
record.get("recordType") == TOMBSTONE and record.get("contributionId") == contribution_id
|
|
for record in records
|
|
):
|
|
raise IllegalTransition(f"contribution {contribution_id} is already revoked")
|
|
if not samples:
|
|
raise IllegalTransition(f"no ingested sample carries contribution {contribution_id}")
|
|
sample = samples[-1]
|
|
if sample.get("lifecycle", {}).get("state") != ACTIVE:
|
|
raise IllegalTransition(f"contribution {contribution_id} is not active")
|
|
|
|
deleted = []
|
|
candidates = [(sample["relativePath"], sample["hashes"]["derived"])]
|
|
# A raw pre-derivation copy is identified by the raw input digest, never by a user path.
|
|
for path in sorted((dataset_root / "raw").glob(f"{sample['hashes']['rawInput']}*")):
|
|
candidates.append((str(path.relative_to(dataset_root)), sample["hashes"]["rawInput"]))
|
|
for relative, digest in candidates:
|
|
target = _safe_dataset_path(dataset_root, relative)
|
|
existed = target.is_file()
|
|
if existed:
|
|
target.unlink()
|
|
deleted.append({"relativePath": relative, "sha256": digest, "deleted": existed})
|
|
|
|
tombstone = {
|
|
"schemaVersion": SCHEMA_VERSION,
|
|
"recordType": TOMBSTONE,
|
|
"contributionId": contribution_id,
|
|
"imageId": sample["imageId"],
|
|
"deletedArtifacts": deleted,
|
|
"lifecycle": {"state": REVOKED, "updatedAt": _require_iso(at, "lifecycle timestamp"), "reason": reason.strip()},
|
|
}
|
|
append_record(manifest_path, tombstone)
|
|
return tombstone
|