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