Files
nucleic-purpose-classifier/review_contract.py
T

494 lines
17 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Fail-closed contract for purpose-classifier human review artifacts."""
from __future__ import annotations
import csv
import hashlib
import json
import math
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Sequence
from purpose_data import (
LABEL_SET,
SLICES,
DataError,
SourceRecord,
file_sha256,
normalize_prompt,
prompt_hash,
validate_source_record,
write_json,
)
REVIEW_CSV_FIELDS = (
"promptHash",
"source",
"line",
"prompt",
"generatedPurpose",
"generatedSecondary",
"generatedMixed",
"generatedDifficulty",
"generatedSlice",
"generatedLanguage",
"reviewedPurpose",
"reviewedSecondary",
"reviewedDifficulty",
"reviewedSlice",
"reviewStatus",
"reviewNotes",
)
REVIEW_STATUSES = frozenset({"accept", "relabel", "reject"})
NONE_SENTINELS = frozenset({"<none>", "none", "null"})
CHANGE_FIELDS = frozenset({"purpose", "secondary", "difficulty", "slice"})
DEFAULT_SAMPLE_SEED = 0xA11D17
DEFAULT_SAMPLE_FRACTION = 0.10
@dataclass(frozen=True)
class ReviewProgress:
records: int
accepted: int
relabeled: int
rejected: int
incomplete: int
decisions: list[dict[str, Any]]
@property
def completed(self) -> int:
return self.records - self.incomplete
@dataclass(frozen=True)
class HumanReviewResult:
records: list[SourceRecord]
summary: dict[str, Any]
def _stable_rank(seed: int, record: SourceRecord) -> str:
material = f"{seed}\0{prompt_hash(record.value['prompt'])}".encode("utf-8")
return hashlib.sha256(material).hexdigest()
def _review_stratum(record: SourceRecord) -> tuple[str, str, str]:
language = record.value["lang"].split("-", 1)[0].casefold()
return record.value["purpose"], record.value["slice"], language
def stratified_review_sample(
records: Sequence[SourceRecord],
*,
fraction: float,
seed: int,
) -> list[SourceRecord]:
"""Choose exactly round(N*fraction), apportioned by purpose/slice/language."""
if not records:
raise DataError("cannot sample an empty review population")
if not 0.0 < fraction <= 1.0:
raise DataError("review fraction must be in (0, 1]")
target = round(len(records) * fraction)
groups: dict[tuple[str, str, str], list[SourceRecord]] = defaultdict(list)
for record in records:
groups[_review_stratum(record)].append(record)
allocations: dict[tuple[str, str, str], int] = {}
remainders: list[tuple[float, str, tuple[str, str, str]]] = []
allocated = 0
for key in sorted(groups):
quota = len(groups[key]) * target / len(records)
base = math.floor(quota)
allocations[key] = base
allocated += base
tie_break = hashlib.sha256(f"{seed}\0{key}".encode("utf-8")).hexdigest()
remainders.append((quota - base, tie_break, key))
for _, _, key in sorted(remainders, reverse=True)[: target - allocated]:
allocations[key] += 1
selected: list[SourceRecord] = []
for key in sorted(groups):
ordered = sorted(groups[key], key=lambda record: _stable_rank(seed, record))
selected.extend(ordered[: allocations[key]])
return sorted(selected, key=lambda record: _stable_rank(seed + 1, record))
def _review_row(
record: SourceRecord,
source_formatter: Callable[[Path], str],
) -> dict[str, str]:
value = record.value
return {
"promptHash": prompt_hash(value["prompt"]),
"source": source_formatter(record.source),
"line": str(record.line),
"prompt": normalize_prompt(value["prompt"]),
"generatedPurpose": value["purpose"],
"generatedSecondary": value["secondary"] or "",
"generatedMixed": str(value["mixed"]).lower(),
"generatedDifficulty": str(value["difficulty"]),
"generatedSlice": value["slice"],
"generatedLanguage": value["lang"],
"reviewedPurpose": "",
"reviewedSecondary": "",
"reviewedDifficulty": "",
"reviewedSlice": "",
"reviewStatus": "",
"reviewNotes": "",
}
def write_review_csv(
path: Path,
records: Sequence[SourceRecord],
*,
source_formatter: Callable[[Path], str] = str,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=REVIEW_CSV_FIELDS)
writer.writeheader()
for record in records:
writer.writerow(_review_row(record, source_formatter))
def _parse_review_changes(
row: dict[str, str],
record: SourceRecord,
*,
row_number: int,
) -> dict[str, Any]:
value = record.value
changes: dict[str, Any] = {}
purpose = row["reviewedPurpose"].strip()
if purpose:
if purpose not in LABEL_SET:
raise DataError(f"review CSV row {row_number}: invalid reviewed purpose")
if purpose != value["purpose"]:
changes["purpose"] = purpose
secondary_text = row["reviewedSecondary"].strip()
secondary = value["secondary"]
if secondary_text:
if secondary_text.casefold() in NONE_SENTINELS:
secondary = None
elif secondary_text in LABEL_SET:
secondary = secondary_text
else:
raise DataError(f"review CSV row {row_number}: invalid reviewed secondary")
if secondary != value["secondary"]:
changes["secondary"] = secondary
difficulty_text = row["reviewedDifficulty"].strip()
if difficulty_text:
try:
difficulty = float(difficulty_text)
except ValueError as exc:
raise DataError(
f"review CSV row {row_number}: invalid reviewed difficulty"
) from exc
if not math.isfinite(difficulty) or not 0.0 <= difficulty <= 1.0:
raise DataError(
f"review CSV row {row_number}: reviewed difficulty must be in [0, 1]"
)
if difficulty != float(value["difficulty"]):
changes["difficulty"] = difficulty
slice_name = row["reviewedSlice"].strip()
if slice_name:
if slice_name not in SLICES:
raise DataError(f"review CSV row {row_number}: invalid reviewed slice")
if slice_name != value["slice"]:
changes["slice"] = slice_name
candidate = dict(value)
candidate.update(changes)
candidate["mixed"] = candidate["secondary"] is not None
validate_source_record(candidate, f"review CSV row {row_number}")
return changes
def inspect_review_csv(
path: Path,
expected_sample: Sequence[SourceRecord],
*,
source_formatter: Callable[[Path], str] = str,
) -> ReviewProgress:
try:
with path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
if tuple(reader.fieldnames or ()) != REVIEW_CSV_FIELDS:
raise DataError(
f"{path}: review CSV fields do not match the current schema; "
"regenerate the blank artifact before reviewing"
)
rows = list(reader)
except (OSError, UnicodeError, csv.Error) as exc:
raise DataError(f"{path}: cannot read review CSV: {exc}") from exc
expected_by_hash = {
prompt_hash(record.value["prompt"]): record for record in expected_sample
}
if len(expected_by_hash) != len(expected_sample):
raise DataError("human-review sample contains duplicate prompt hashes")
if len(rows) != len(expected_sample):
raise DataError(
f"{path}: expected {len(expected_sample)} review rows, found {len(rows)}"
)
seen: set[str] = set()
decisions: list[dict[str, Any]] = []
counts: Counter[str] = Counter()
incomplete = 0
for row_number, row in enumerate(rows, 2):
if None in row or any(row.get(field) is None for field in REVIEW_CSV_FIELDS):
raise DataError(f"{path}:{row_number}: malformed review CSV column count")
row_hash = row["promptHash"].strip()
if row_hash in seen:
raise DataError(f"{path}:{row_number}: duplicate promptHash {row_hash}")
seen.add(row_hash)
record = expected_by_hash.get(row_hash)
if record is None:
raise DataError(
f"{path}:{row_number}: promptHash is not in the deterministic sample"
)
expected = _review_row(record, source_formatter)
for field in REVIEW_CSV_FIELDS[:10]:
actual = row[field]
if field == "generatedDifficulty":
try:
matches = float(actual) == float(expected[field])
except ValueError:
matches = False
else:
matches = actual == expected[field]
if not matches:
raise DataError(
f"{path}:{row_number}: generated field {field} no longer "
"matches the curated corpus"
)
status = row["reviewStatus"].strip().casefold()
reviewer_values = [row[field].strip() for field in REVIEW_CSV_FIELDS[10:14]]
notes = row["reviewNotes"].strip()
if not status:
if any(reviewer_values) or notes:
raise DataError(
f"{path}:{row_number}: reviewer fields require a reviewStatus"
)
incomplete += 1
continue
if status not in REVIEW_STATUSES:
raise DataError(f"{path}:{row_number}: invalid reviewStatus {status!r}")
changes = _parse_review_changes(row, record, row_number=row_number)
if status == "accept":
if any(reviewer_values):
raise DataError(
f"{path}:{row_number}: accept must leave reviewed fields blank"
)
elif status == "reject":
if any(reviewer_values):
raise DataError(
f"{path}:{row_number}: reject must leave reviewed fields blank"
)
if not notes:
raise DataError(f"{path}:{row_number}: reject requires reviewNotes")
else:
if not changes:
raise DataError(
f"{path}:{row_number}: relabel must change at least one field"
)
if not notes:
raise DataError(f"{path}:{row_number}: relabel requires reviewNotes")
decision: dict[str, Any] = {
"promptHash": row_hash,
"status": status,
}
if changes:
decision["changes"] = changes
if notes:
decision["notes"] = notes
decisions.append(decision)
counts[status] += 1
missing = sorted(set(expected_by_hash) - seen)
if missing:
raise DataError(f"{path}: deterministic sample rows are missing")
return ReviewProgress(
records=len(rows),
accepted=counts["accept"],
relabeled=counts["relabel"],
rejected=counts["reject"],
incomplete=incomplete,
decisions=decisions,
)
def finalize_human_review(
csv_path: Path,
output_path: Path,
population: Sequence[SourceRecord],
*,
dataset_version: str,
fraction: float,
seed: int,
source_formatter: Callable[[Path], str] = str,
) -> dict[str, Any]:
sample = stratified_review_sample(population, fraction=fraction, seed=seed)
progress = inspect_review_csv(
csv_path,
sample,
source_formatter=source_formatter,
)
if progress.incomplete:
raise DataError(
f"{csv_path}: human review is incomplete: {progress.completed}/"
f"{progress.records} rows completed"
)
summary = {
"accepted": progress.accepted,
"relabeled": progress.relabeled,
"rejected": progress.rejected,
}
artifact = {
"schemaVersion": 1,
"datasetVersion": dataset_version,
"status": "complete",
"populationRecords": len(population),
"sampleFraction": fraction,
"sampleRecords": len(sample),
"seed": seed,
"sourceCSVSha256": file_sha256(csv_path),
"summary": summary,
"decisions": progress.decisions,
}
if output_path.exists():
try:
existing = json.loads(output_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise DataError(
f"{output_path}: cannot verify existing human-review ledger: {exc}"
) from exc
if existing != artifact:
raise DataError(
f"{output_path}: refusing to replace a different human-review ledger"
)
return artifact
write_json(output_path, artifact)
return artifact
def apply_completed_human_review(
path: Path,
population: Sequence[SourceRecord],
*,
dataset_version: str,
) -> HumanReviewResult:
try:
artifact = json.loads(path.read_text(encoding="utf-8"))
if artifact["schemaVersion"] != 1:
raise DataError(f"{path}: unsupported human-review schema")
if artifact["datasetVersion"] != dataset_version:
raise DataError(f"{path}: human-review dataset version does not match")
if artifact["status"] != "complete":
raise DataError(f"{path}: human review is not complete")
fraction = float(artifact["sampleFraction"])
seed = int(artifact["seed"])
decisions = artifact["decisions"]
except DataError:
raise
except (OSError, UnicodeError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
raise DataError(f"{path}: cannot read completed human review: {exc}") from exc
if not isinstance(decisions, list):
raise DataError(f"{path}: human-review decisions must be an array")
if artifact.get("populationRecords") != len(population):
raise DataError(f"{path}: reviewed population no longer matches the corpus")
sample = stratified_review_sample(population, fraction=fraction, seed=seed)
expected_hashes = {prompt_hash(record.value["prompt"]) for record in sample}
if artifact.get("sampleRecords") != len(sample):
raise DataError(f"{path}: reviewed sample size no longer matches the corpus")
by_hash: dict[str, dict[str, Any]] = {}
counts: Counter[str] = Counter()
for index, decision in enumerate(decisions, 1):
if not isinstance(decision, dict):
raise DataError(f"{path}: human-review decision {index} must be an object")
row_hash = decision.get("promptHash")
status = decision.get("status")
changes = decision.get("changes", {})
if not isinstance(row_hash, str) or not isinstance(status, str):
raise DataError(f"{path}: invalid human-review decision {index}")
if row_hash in by_hash:
raise DataError(f"{path}: duplicate human-review promptHash")
if row_hash not in expected_hashes or status not in REVIEW_STATUSES:
raise DataError(f"{path}: invalid human-review decision {index}")
if not isinstance(changes, dict) or not set(changes) <= CHANGE_FIELDS:
raise DataError(f"{path}: invalid changes in human-review decision {index}")
if status != "relabel" and changes:
raise DataError(f"{path}: only relabel decisions may contain changes")
if status == "relabel" and not changes:
raise DataError(f"{path}: relabel decision {index} has no changes")
if "purpose" in changes and (
not isinstance(changes["purpose"], str)
or changes["purpose"] not in LABEL_SET
):
raise DataError(f"{path}: invalid purpose in human-review decision {index}")
if "secondary" in changes and (
changes["secondary"] is not None
and (
not isinstance(changes["secondary"], str)
or changes["secondary"] not in LABEL_SET
)
):
raise DataError(f"{path}: invalid secondary in human-review decision {index}")
if "slice" in changes and (
not isinstance(changes["slice"], str) or changes["slice"] not in SLICES
):
raise DataError(f"{path}: invalid slice in human-review decision {index}")
by_hash[row_hash] = decision
counts[status] += 1
if set(by_hash) != expected_hashes:
raise DataError(f"{path}: decisions do not exactly cover the deterministic sample")
expected_summary = {
"accepted": counts["accept"],
"relabeled": counts["relabel"],
"rejected": counts["reject"],
}
if artifact.get("summary") != expected_summary:
raise DataError(f"{path}: human-review summary does not match its decisions")
result: list[SourceRecord] = []
for record in population:
decision = by_hash.get(prompt_hash(record.value["prompt"]))
if decision is None or decision["status"] == "accept":
result.append(record)
continue
if decision["status"] == "reject":
continue
value = dict(record.value)
value.update(decision["changes"])
value["mixed"] = value["secondary"] is not None
validate_source_record(value, f"{path}: {decision['promptHash']}")
result.append(SourceRecord(value=value, source=record.source, line=record.line))
return HumanReviewResult(
records=result,
summary={
**expected_summary,
"sampleRecords": len(sample),
"retainedRecords": len(result),
},
)