dataset_review.py
python
sha256:ca1d0e687bff8686b37126eb8e4fb6d38b40e189352a4920d66ae89c7274e340
Add T4 job cancellation retry and validation
Human
minor
⚠ breaking
41 days ago
| 1 | """Dataset registration and review lifecycle for the Scooling Lab training API. |
| 2 | |
| 3 | Datasets move through a bounded state machine before any job can reference them: |
| 4 | registered → pending_review → approved | rejected |
| 5 | |
| 6 | Only approved datasets are eligible for job submission. Rejection is communicated |
| 7 | as a machine-readable ``RejectionReasonCode`` enum; no caller-supplied text is ever |
| 8 | echoed back in API responses or logs. |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import re |
| 14 | import threading |
| 15 | from dataclasses import dataclass, field |
| 16 | from enum import Enum |
| 17 | from types import MappingProxyType |
| 18 | from typing import Mapping |
| 19 | |
| 20 | from scooling_lab.errors import ApiError, ErrorCode |
| 21 | |
| 22 | |
| 23 | DATASET_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{3,96}$") |
| 24 | FORBIDDEN_STRING_RE = re.compile( |
| 25 | r"(?i)(https?://|file://|ssh://|[;&|`$<>]|\.\./|/\w|[A-Za-z]:\\)" |
| 26 | ) |
| 27 | FORBIDDEN_FIELD_TERMS: tuple[str, ...] = ( |
| 28 | "content", |
| 29 | "credential", |
| 30 | "document", |
| 31 | "email", |
| 32 | "file", |
| 33 | "instruction", |
| 34 | "message", |
| 35 | "password", |
| 36 | "path", |
| 37 | "prompt", |
| 38 | "secret", |
| 39 | "text", |
| 40 | "url", |
| 41 | ) |
| 42 | SYNTHETIC_ROW_COUNT_MIN = 1 |
| 43 | SYNTHETIC_ROW_COUNT_MAX = 10_000 |
| 44 | SYNTHETIC_DATASET_SCHEMA: MappingProxyType[str, str] = MappingProxyType( |
| 45 | { |
| 46 | "exampleId": "string", |
| 47 | "inputTokenCount": "integer", |
| 48 | "outputTokenCount": "integer", |
| 49 | "split": "string", |
| 50 | } |
| 51 | ) |
| 52 | ALLOWED_DATASET_REGISTRATION_KEYS: frozenset[str] = frozenset( |
| 53 | {"datasetId", "rowCount", "declaredSchema"} |
| 54 | ) |
| 55 | |
| 56 | # The fixture dataset is pre-approved so existing tests require no changes. |
| 57 | FIXTURE_APPROVED_DATASET_IDS: frozenset[str] = frozenset( |
| 58 | {"fixture:synthetic-tiny-v1"} |
| 59 | ) |
| 60 | |
| 61 | |
| 62 | class DatasetStatus(str, Enum): |
| 63 | """States in the dataset review lifecycle.""" |
| 64 | |
| 65 | REGISTERED = "registered" |
| 66 | PENDING_REVIEW = "pending_review" |
| 67 | APPROVED = "approved" |
| 68 | REJECTED = "rejected" |
| 69 | |
| 70 | |
| 71 | class RejectionReasonCode(str, Enum): |
| 72 | """Bounded machine-readable reason codes for dataset rejection. |
| 73 | |
| 74 | Free text is never accepted or echoed. Only one of these values may |
| 75 | appear in a review decision. |
| 76 | """ |
| 77 | |
| 78 | SYNTHETIC_LIMIT = "SYNTHETIC_LIMIT" |
| 79 | FORMAT_INVALID = "FORMAT_INVALID" |
| 80 | POLICY_VIOLATION = "POLICY_VIOLATION" |
| 81 | SCHEMA_MISMATCH = "SCHEMA_MISMATCH" |
| 82 | DUPLICATE_SUBMISSION = "DUPLICATE_SUBMISSION" |
| 83 | |
| 84 | |
| 85 | @dataclass(frozen=True) |
| 86 | class SyntheticDatasetShape: |
| 87 | """Content-free metadata used to validate a synthetic dataset submission.""" |
| 88 | |
| 89 | row_count: int |
| 90 | declared_schema: Mapping[str, str] |
| 91 | parse_reason: RejectionReasonCode | None = None |
| 92 | |
| 93 | |
| 94 | _DATASET_TRANSITIONS: MappingProxyType[ |
| 95 | DatasetStatus, frozenset[DatasetStatus] |
| 96 | ] = MappingProxyType( |
| 97 | { |
| 98 | DatasetStatus.REGISTERED: frozenset({DatasetStatus.PENDING_REVIEW}), |
| 99 | DatasetStatus.PENDING_REVIEW: frozenset( |
| 100 | {DatasetStatus.APPROVED, DatasetStatus.REJECTED} |
| 101 | ), |
| 102 | DatasetStatus.APPROVED: frozenset(), |
| 103 | DatasetStatus.REJECTED: frozenset(), |
| 104 | } |
| 105 | ) |
| 106 | |
| 107 | |
| 108 | def dataset_transition( |
| 109 | current: DatasetStatus, target: DatasetStatus |
| 110 | ) -> DatasetStatus: |
| 111 | """Apply the dataset review state machine or raise for invalid moves. |
| 112 | |
| 113 | Idempotent same-state replay is allowed for every state. |
| 114 | """ |
| 115 | |
| 116 | if current == target: |
| 117 | return current |
| 118 | if target in _DATASET_TRANSITIONS[current]: |
| 119 | return target |
| 120 | raise ApiError(ErrorCode.INVALID_TRANSITION, 409) |
| 121 | |
| 122 | |
| 123 | def require_dataset_id(value: object) -> str: |
| 124 | """Validate a dataset id against the safe-identifier contract.""" |
| 125 | |
| 126 | if not isinstance(value, str) or not DATASET_ID_RE.fullmatch(value): |
| 127 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 128 | if FORBIDDEN_STRING_RE.search(value): |
| 129 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 130 | return value |
| 131 | |
| 132 | |
| 133 | def require_rejection_reason(value: object) -> RejectionReasonCode: |
| 134 | """Validate a rejection reason code; reject unknown or free-text values.""" |
| 135 | |
| 136 | if not isinstance(value, str): |
| 137 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 138 | try: |
| 139 | return RejectionReasonCode(value) |
| 140 | except ValueError as exc: |
| 141 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) from exc |
| 142 | |
| 143 | |
| 144 | def dataset_shape_from_registration( |
| 145 | payload: Mapping[str, object], |
| 146 | ) -> tuple[str, SyntheticDatasetShape]: |
| 147 | """Validate dataset registration metadata without storing content. |
| 148 | |
| 149 | The dataset id is still schema-validated as a request contract. The optional |
| 150 | synthetic shape metadata is stored only as bounded counts and schema labels; |
| 151 | submit-time validation converts malformed or policy-unsafe shapes into |
| 152 | ``RejectionReasonCode`` values without echoing caller-provided text. |
| 153 | """ |
| 154 | |
| 155 | dataset_id = require_dataset_id(payload.get("datasetId")) |
| 156 | unknown_keys = set(payload).difference(ALLOWED_DATASET_REGISTRATION_KEYS) |
| 157 | if unknown_keys: |
| 158 | return dataset_id, default_dataset_shape(RejectionReasonCode.FORMAT_INVALID) |
| 159 | |
| 160 | has_shape_metadata = "rowCount" in payload or "declaredSchema" in payload |
| 161 | if not has_shape_metadata: |
| 162 | return dataset_id, default_dataset_shape() |
| 163 | |
| 164 | row_count = payload.get("rowCount") |
| 165 | declared_schema = payload.get("declaredSchema") |
| 166 | if ( |
| 167 | not isinstance(row_count, int) |
| 168 | or isinstance(row_count, bool) |
| 169 | or not isinstance(declared_schema, Mapping) |
| 170 | ): |
| 171 | return dataset_id, default_dataset_shape(RejectionReasonCode.FORMAT_INVALID) |
| 172 | |
| 173 | parsed_schema: dict[str, str] = {} |
| 174 | for field_name, field_type in declared_schema.items(): |
| 175 | if not isinstance(field_name, str) or not isinstance(field_type, str): |
| 176 | return dataset_id, default_dataset_shape(RejectionReasonCode.FORMAT_INVALID) |
| 177 | parsed_schema[field_name] = field_type |
| 178 | |
| 179 | return dataset_id, SyntheticDatasetShape( |
| 180 | row_count=row_count, |
| 181 | declared_schema=MappingProxyType(parsed_schema), |
| 182 | ) |
| 183 | |
| 184 | |
| 185 | def default_dataset_shape( |
| 186 | parse_reason: RejectionReasonCode | None = None, |
| 187 | ) -> SyntheticDatasetShape: |
| 188 | """Return the default valid synthetic shape used by compatibility fixtures.""" |
| 189 | |
| 190 | return SyntheticDatasetShape( |
| 191 | row_count=SYNTHETIC_ROW_COUNT_MIN, |
| 192 | declared_schema=SYNTHETIC_DATASET_SCHEMA, |
| 193 | parse_reason=parse_reason, |
| 194 | ) |
| 195 | |
| 196 | |
| 197 | def validate_synthetic_dataset_shape( |
| 198 | shape: SyntheticDatasetShape, |
| 199 | ) -> RejectionReasonCode | None: |
| 200 | """Return a bounded rejection code for invalid synthetic dataset metadata.""" |
| 201 | |
| 202 | if shape.parse_reason is not None: |
| 203 | return shape.parse_reason |
| 204 | if not SYNTHETIC_ROW_COUNT_MIN <= shape.row_count <= SYNTHETIC_ROW_COUNT_MAX: |
| 205 | return RejectionReasonCode.SYNTHETIC_LIMIT |
| 206 | for field_name, field_type in shape.declared_schema.items(): |
| 207 | if has_forbidden_field_term(field_name) or has_forbidden_field_term(field_type): |
| 208 | return RejectionReasonCode.POLICY_VIOLATION |
| 209 | if dict(shape.declared_schema) != dict(SYNTHETIC_DATASET_SCHEMA): |
| 210 | return RejectionReasonCode.SCHEMA_MISMATCH |
| 211 | return None |
| 212 | |
| 213 | |
| 214 | def has_forbidden_field_term(value: str) -> bool: |
| 215 | """Return true when a schema label carries unsafe or content-bearing terms.""" |
| 216 | |
| 217 | lowered = value.lower() |
| 218 | return FORBIDDEN_STRING_RE.search(value) is not None or any( |
| 219 | term in lowered for term in FORBIDDEN_FIELD_TERMS |
| 220 | ) |
| 221 | |
| 222 | |
| 223 | def _utc_now_iso() -> str: |
| 224 | """Return a UTC timestamp formatted for public metadata.""" |
| 225 | |
| 226 | from datetime import UTC, datetime |
| 227 | |
| 228 | return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") |
| 229 | |
| 230 | |
| 231 | @dataclass |
| 232 | class DatasetRecord: |
| 233 | """Internal record for one registered fixture dataset.""" |
| 234 | |
| 235 | dataset_id: str |
| 236 | status: DatasetStatus = DatasetStatus.REGISTERED |
| 237 | registered_at: str = field(default_factory=_utc_now_iso) |
| 238 | updated_at: str = field(default_factory=_utc_now_iso) |
| 239 | rejection_reason: RejectionReasonCode | None = None |
| 240 | synthetic_shape: SyntheticDatasetShape = field(default_factory=default_dataset_shape) |
| 241 | |
| 242 | def to_public_dict(self) -> dict[str, object]: |
| 243 | """Serialize the safe dataset status shape for API responses.""" |
| 244 | |
| 245 | payload: dict[str, object] = { |
| 246 | "datasetId": self.dataset_id, |
| 247 | "status": self.status.value, |
| 248 | "registeredAt": self.registered_at, |
| 249 | "updatedAt": self.updated_at, |
| 250 | } |
| 251 | if self.rejection_reason is not None: |
| 252 | payload["rejectionReasonCode"] = self.rejection_reason.value |
| 253 | return payload |
| 254 | |
| 255 | |
| 256 | class DatasetStore: |
| 257 | """Thread-safe, in-memory store for dataset review lifecycle records. |
| 258 | |
| 259 | The store is pre-populated with the fixture dataset IDs from |
| 260 | ``FIXTURE_APPROVED_DATASET_IDS`` so that existing job submission flows |
| 261 | continue to work without an explicit registration step. |
| 262 | """ |
| 263 | |
| 264 | def __init__(self) -> None: |
| 265 | """Create a store pre-approving all fixture dataset ids.""" |
| 266 | |
| 267 | self._lock = threading.RLock() |
| 268 | self._datasets: dict[str, DatasetRecord] = {} |
| 269 | ts = _utc_now_iso() |
| 270 | for dataset_id in FIXTURE_APPROVED_DATASET_IDS: |
| 271 | self._datasets[dataset_id] = DatasetRecord( |
| 272 | dataset_id=dataset_id, |
| 273 | status=DatasetStatus.APPROVED, |
| 274 | registered_at=ts, |
| 275 | updated_at=ts, |
| 276 | ) |
| 277 | |
| 278 | def register(self, dataset_id: str) -> DatasetRecord: |
| 279 | """Register a dataset; idempotent if already registered. |
| 280 | |
| 281 | Raises ``CONFLICT`` (409) if the same id has already been approved or |
| 282 | rejected so that callers cannot silently re-enter a terminal-path |
| 283 | dataset into review. |
| 284 | """ |
| 285 | |
| 286 | require_dataset_id(dataset_id) |
| 287 | return self.register_shape(dataset_id, default_dataset_shape()) |
| 288 | |
| 289 | def register_shape( |
| 290 | self, dataset_id: str, synthetic_shape: SyntheticDatasetShape |
| 291 | ) -> DatasetRecord: |
| 292 | """Register a dataset with bounded synthetic validation metadata.""" |
| 293 | |
| 294 | with self._lock: |
| 295 | existing = self._datasets.get(dataset_id) |
| 296 | if existing is not None: |
| 297 | if existing.status in {DatasetStatus.APPROVED, DatasetStatus.REJECTED}: |
| 298 | raise ApiError(ErrorCode.CONFLICT, 409) |
| 299 | return existing |
| 300 | record = DatasetRecord( |
| 301 | dataset_id=dataset_id, |
| 302 | synthetic_shape=synthetic_shape, |
| 303 | ) |
| 304 | self._datasets[dataset_id] = record |
| 305 | return record |
| 306 | |
| 307 | def submit_for_review(self, dataset_id: str) -> DatasetRecord: |
| 308 | """Validate a registered dataset and record the deterministic decision.""" |
| 309 | |
| 310 | require_dataset_id(dataset_id) |
| 311 | with self._lock: |
| 312 | record = self._get_locked(dataset_id) |
| 313 | record.status = dataset_transition( |
| 314 | record.status, DatasetStatus.PENDING_REVIEW |
| 315 | ) |
| 316 | rejection_reason = validate_synthetic_dataset_shape(record.synthetic_shape) |
| 317 | if rejection_reason is None: |
| 318 | record.status = dataset_transition(record.status, DatasetStatus.APPROVED) |
| 319 | record.rejection_reason = None |
| 320 | else: |
| 321 | record.status = dataset_transition(record.status, DatasetStatus.REJECTED) |
| 322 | record.rejection_reason = rejection_reason |
| 323 | record.updated_at = _utc_now_iso() |
| 324 | return record |
| 325 | |
| 326 | def approve(self, dataset_id: str) -> DatasetRecord: |
| 327 | """Approve a dataset that is pending review.""" |
| 328 | |
| 329 | require_dataset_id(dataset_id) |
| 330 | with self._lock: |
| 331 | record = self._get_locked(dataset_id) |
| 332 | record.status = dataset_transition(record.status, DatasetStatus.APPROVED) |
| 333 | record.updated_at = _utc_now_iso() |
| 334 | return record |
| 335 | |
| 336 | def reject( |
| 337 | self, dataset_id: str, reason_code: RejectionReasonCode |
| 338 | ) -> DatasetRecord: |
| 339 | """Reject a dataset that is pending review with a bounded reason code.""" |
| 340 | |
| 341 | require_dataset_id(dataset_id) |
| 342 | with self._lock: |
| 343 | record = self._get_locked(dataset_id) |
| 344 | if record.status == DatasetStatus.REJECTED: |
| 345 | return record |
| 346 | record.status = dataset_transition(record.status, DatasetStatus.REJECTED) |
| 347 | record.rejection_reason = reason_code |
| 348 | record.updated_at = _utc_now_iso() |
| 349 | return record |
| 350 | |
| 351 | def get(self, dataset_id: str) -> DatasetRecord: |
| 352 | """Return one dataset record or raise a safe not-found error.""" |
| 353 | |
| 354 | require_dataset_id(dataset_id) |
| 355 | with self._lock: |
| 356 | return self._get_locked(dataset_id) |
| 357 | |
| 358 | def is_approved(self, dataset_id: str) -> bool: |
| 359 | """Return True only if the dataset is in the approved state.""" |
| 360 | |
| 361 | try: |
| 362 | require_dataset_id(dataset_id) |
| 363 | except ApiError: |
| 364 | return False |
| 365 | with self._lock: |
| 366 | record = self._datasets.get(dataset_id) |
| 367 | return record is not None and record.status == DatasetStatus.APPROVED |
| 368 | |
| 369 | def _get_locked(self, dataset_id: str) -> DatasetRecord: |
| 370 | record = self._datasets.get(dataset_id) |
| 371 | if record is None: |
| 372 | raise ApiError(ErrorCode.NOT_FOUND, 404) |
| 373 | return record |
| 374 | |
| 375 | |
| 376 | def validate_review_request(payload: Mapping[str, object]) -> tuple[str, RejectionReasonCode | None]: |
| 377 | """Parse and validate a dataset review action payload. |
| 378 | |
| 379 | Returns ``(action, reason_code)`` where ``reason_code`` is non-None only |
| 380 | when ``action == "reject"``. No caller-supplied text is echoed. |
| 381 | """ |
| 382 | |
| 383 | allowed_keys = {"action", "reasonCode"} |
| 384 | if not isinstance(payload, Mapping): |
| 385 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 386 | unknown = set(payload) - allowed_keys |
| 387 | if unknown: |
| 388 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 389 | |
| 390 | action = payload.get("action") |
| 391 | if action not in {"approve", "reject"}: |
| 392 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 393 | |
| 394 | reason_code: RejectionReasonCode | None = None |
| 395 | if action == "reject": |
| 396 | raw_reason = payload.get("reasonCode") |
| 397 | if raw_reason is None: |
| 398 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 399 | reason_code = require_rejection_reason(raw_reason) |
| 400 | else: |
| 401 | if "reasonCode" in payload: |
| 402 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 403 | |
| 404 | return str(action), reason_code |
File History
1 commit
sha256:ca1d0e687bff8686b37126eb8e4fb6d38b40e189352a4920d66ae89c7274e340
Add T4 job cancellation retry and validation
Human
minor
⚠
41 days ago