494 lines
17 KiB
Python
494 lines
17 KiB
Python
#!/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),
|
|
},
|
|
)
|