contracts.py python
195 lines 6.5 KB
Raw
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