contracts.py
python
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a
feat(slice-7): T3 dataset review lifecycle, job queue, prov…
Human
minor
⚠ breaking
42 days ago
| 1 | """Training API request contracts and schema-layer validation.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import hashlib |
| 6 | import json |
| 7 | import re |
| 8 | from dataclasses import dataclass, field |
| 9 | from enum import Enum |
| 10 | from types import MappingProxyType |
| 11 | from typing import Mapping |
| 12 | |
| 13 | from scooling_lab.errors import ApiError, ErrorCode |
| 14 | from scooling_lab.retention import ( |
| 15 | RetentionPolicy, |
| 16 | default_retention_policy, |
| 17 | retention_policy_from_mapping, |
| 18 | ) |
| 19 | |
| 20 | |
| 21 | class TrainingJobStatus(str, Enum): |
| 22 | """States allowed by the T2 training job state machine.""" |
| 23 | |
| 24 | QUEUED = "queued" |
| 25 | RUNNING = "running" |
| 26 | SUCCEEDED = "succeeded" |
| 27 | FAILED = "failed" |
| 28 | CANCELLED = "cancelled" |
| 29 | DELETED = "deleted" |
| 30 | |
| 31 | |
| 32 | ALLOWED_MODEL_IDS: frozenset[str] = frozenset({"fixture-tiny-llm"}) |
| 33 | ALLOWED_DATASET_IDS: frozenset[str] = frozenset({"fixture:synthetic-tiny-v1"}) |
| 34 | ALLOWED_REQUEST_KEYS: frozenset[str] = frozenset( |
| 35 | { |
| 36 | "idempotencyKey", |
| 37 | "datasetId", |
| 38 | "modelId", |
| 39 | "requestedBy", |
| 40 | "retentionPolicy", |
| 41 | "trainingParameters", |
| 42 | } |
| 43 | ) |
| 44 | FORBIDDEN_KEY_TERMS: tuple[str, ...] = ( |
| 45 | "url", |
| 46 | "uri", |
| 47 | "path", |
| 48 | "file", |
| 49 | "shell", |
| 50 | "command", |
| 51 | "callback", |
| 52 | "webhook", |
| 53 | "worker", |
| 54 | ) |
| 55 | SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9._:-]{3,96}$") |
| 56 | REQUESTER_RE = re.compile(r"^[A-Za-z0-9._:-]{3,80}$") |
| 57 | JOB_ID_RE = re.compile(r"^job_[a-f0-9]{24}$") |
| 58 | ARTIFACT_ID_RE = re.compile(r"^artifact_[a-f0-9]{24}$") |
| 59 | FORBIDDEN_STRING_RE = re.compile( |
| 60 | r"(?i)(https?://|file://|ssh://|[;&|`$<>]|\.\./|/\w|[A-Za-z]:\\)" |
| 61 | ) |
| 62 | |
| 63 | |
| 64 | @dataclass(frozen=True) |
| 65 | class TrainingJobRequest: |
| 66 | """Validated createTrainingJob payload. |
| 67 | |
| 68 | The schema only accepts inert fixture identifiers, bounded numeric training |
| 69 | parameters, and a caller-provided idempotency key. It rejects unknown keys so |
| 70 | browser-supplied worker URLs, callback URLs, file paths, and shell strings |
| 71 | fail before reaching the service layer. |
| 72 | """ |
| 73 | |
| 74 | idempotency_key: str |
| 75 | dataset_id: str |
| 76 | model_id: str |
| 77 | requested_by: str |
| 78 | retention_policy: RetentionPolicy = field(default_factory=default_retention_policy) |
| 79 | training_parameters: Mapping[str, int | float | bool] = field( |
| 80 | default_factory=lambda: MappingProxyType({}) |
| 81 | ) |
| 82 | |
| 83 | @classmethod |
| 84 | def from_mapping(cls, payload: Mapping[str, object]) -> "TrainingJobRequest": |
| 85 | """Validate and convert an untrusted JSON object into a request.""" |
| 86 | |
| 87 | reject_forbidden_keys(payload) |
| 88 | unknown_keys = set(payload).difference(ALLOWED_REQUEST_KEYS) |
| 89 | if unknown_keys: |
| 90 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 91 | |
| 92 | idempotency_key = require_safe_identifier(payload.get("idempotencyKey")) |
| 93 | dataset_id = require_safe_identifier(payload.get("datasetId")) |
| 94 | model_id = require_safe_identifier(payload.get("modelId")) |
| 95 | requested_by = require_requester(payload.get("requestedBy")) |
| 96 | retention_policy = retention_policy_from_mapping(payload.get("retentionPolicy")) |
| 97 | training_parameters = validate_training_parameters( |
| 98 | payload.get("trainingParameters", {}) |
| 99 | ) |
| 100 | |
| 101 | if model_id not in ALLOWED_MODEL_IDS: |
| 102 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 103 | |
| 104 | return cls( |
| 105 | idempotency_key=idempotency_key, |
| 106 | dataset_id=dataset_id, |
| 107 | model_id=model_id, |
| 108 | requested_by=requested_by, |
| 109 | retention_policy=retention_policy, |
| 110 | training_parameters=MappingProxyType(training_parameters), |
| 111 | ) |
| 112 | |
| 113 | def fingerprint(self) -> str: |
| 114 | """Return a stable hash input for idempotent job creation.""" |
| 115 | |
| 116 | return json.dumps( |
| 117 | { |
| 118 | "datasetId": self.dataset_id, |
| 119 | "idempotencyKey": self.idempotency_key, |
| 120 | "modelId": self.model_id, |
| 121 | "requestedBy": self.requested_by, |
| 122 | "retentionPolicy": self.retention_policy.to_public_dict(), |
| 123 | "trainingParameters": dict(sorted(self.training_parameters.items())), |
| 124 | }, |
| 125 | separators=(",", ":"), |
| 126 | sort_keys=True, |
| 127 | ) |
| 128 | |
| 129 | def stable_job_id(self) -> str: |
| 130 | """Return the deterministic job id for this request.""" |
| 131 | |
| 132 | digest = hashlib.sha256(self.fingerprint().encode("utf-8")).hexdigest()[:24] |
| 133 | return f"job_{digest}" |
| 134 | |
| 135 | def to_public_dict(self) -> dict[str, object]: |
| 136 | """Serialize only safe, non-private request fields.""" |
| 137 | |
| 138 | return { |
| 139 | "datasetId": self.dataset_id, |
| 140 | "modelId": self.model_id, |
| 141 | "requestedBy": self.requested_by, |
| 142 | "retentionPolicy": self.retention_policy.to_public_dict(), |
| 143 | "trainingParameters": dict(sorted(self.training_parameters.items())), |
| 144 | } |
| 145 | |
| 146 | |
| 147 | def reject_forbidden_keys(payload: Mapping[str, object]) -> None: |
| 148 | """Reject explicitly dangerous key shapes anywhere in a request payload.""" |
| 149 | |
| 150 | for key, value in payload.items(): |
| 151 | lowered = key.lower() |
| 152 | if any(term in lowered for term in FORBIDDEN_KEY_TERMS): |
| 153 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 154 | if isinstance(value, Mapping): |
| 155 | reject_forbidden_keys(value) |
| 156 | |
| 157 | |
| 158 | def require_safe_identifier(value: object) -> str: |
| 159 | """Validate compact fixture identifiers and reject path or URL strings.""" |
| 160 | |
| 161 | if not isinstance(value, str) or not SAFE_IDENTIFIER_RE.fullmatch(value): |
| 162 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 163 | if FORBIDDEN_STRING_RE.search(value): |
| 164 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 165 | return value |
| 166 | |
| 167 | |
| 168 | def require_requester(value: object) -> str: |
| 169 | """Validate the non-secret caller label used for fixture audit context.""" |
| 170 | |
| 171 | if not isinstance(value, str) or not REQUESTER_RE.fullmatch(value): |
| 172 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 173 | if FORBIDDEN_STRING_RE.search(value): |
| 174 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 175 | return value |
| 176 | |
| 177 | |
| 178 | def require_job_id(value: object) -> str: |
| 179 | """Validate a server-generated job id before any store lookup.""" |
| 180 | |
| 181 | if not isinstance(value, str) or not JOB_ID_RE.fullmatch(value): |
| 182 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 183 | return value |
| 184 | |
| 185 | |
| 186 | def require_artifact_id(value: object) -> str: |
| 187 | """Validate a server-generated artifact id before any mutation.""" |
| 188 | |
| 189 | if not isinstance(value, str) or not ARTIFACT_ID_RE.fullmatch(value): |
| 190 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 191 | return value |
| 192 | |
| 193 | |
| 194 | def validate_training_parameters(value: object) -> dict[str, int | float | bool]: |
| 195 | """Validate the bounded dry-run parameter set for the fake worker.""" |
| 196 | |
| 197 | if not isinstance(value, Mapping): |
| 198 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 199 | allowed_keys = {"epochs", "learningRate", "dryRun"} |
| 200 | if set(value).difference(allowed_keys): |
| 201 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 202 | |
| 203 | parameters: dict[str, int | float | bool] = {} |
| 204 | if "epochs" in value: |
| 205 | epochs = value["epochs"] |
| 206 | if not isinstance(epochs, int) or isinstance(epochs, bool) or not 1 <= epochs <= 3: |
| 207 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 208 | parameters["epochs"] = epochs |
| 209 | if "learningRate" in value: |
| 210 | learning_rate = value["learningRate"] |
| 211 | if ( |
| 212 | not isinstance(learning_rate, (int, float)) |
| 213 | or isinstance(learning_rate, bool) |
| 214 | or not 0 < float(learning_rate) <= 1 |
| 215 | ): |
| 216 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 217 | parameters["learningRate"] = float(learning_rate) |
| 218 | if "dryRun" in value: |
| 219 | dry_run = value["dryRun"] |
| 220 | if not isinstance(dry_run, bool) or dry_run is not True: |
| 221 | raise ApiError(ErrorCode.VALIDATION_ERROR, 400) |
| 222 | parameters["dryRun"] = dry_run |
| 223 | return parameters |
File History
1 commit
sha256:fc4c9ad652d1fff3dc508cb6ea02ee710ee6dfc4cb3761291d9900b5e029ea8a
feat(slice-7): T3 dataset review lifecycle, job queue, prov…
Human
minor
⚠
42 days ago