1use thiserror::Error;
4
5#[derive(Debug, Clone, PartialEq, Error)]
7pub enum ProfileValidationError {
8 #[error("profile field `{field}` cannot be empty")]
10 EmptyField {
11 field: &'static str,
13 },
14
15 #[error("bundle SHA-256 must contain exactly 64 lowercase hexadecimal characters")]
17 InvalidBundleSha256,
18
19 #[error("calibration temperature must be finite and greater than zero")]
21 InvalidCalibrationTemperature,
22
23 #[error("policy threshold must be finite and lie in [0, 1]")]
25 InvalidPolicyThreshold,
26
27 #[error("{field} must be finite and non-negative")]
29 InvalidTolerance {
30 field: &'static str,
32 },
33}
34
35fn required(
36 value: impl Into<String>,
37 field: &'static str,
38) -> Result<String, ProfileValidationError> {
39 let value = value.into();
40 if value.trim().is_empty() {
41 Err(ProfileValidationError::EmptyField { field })
42 } else {
43 Ok(value)
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ArtifactIdentity {
50 id: String,
51 revision: String,
52}
53
54impl ArtifactIdentity {
55 pub fn new(
57 id: impl Into<String>,
58 revision: impl Into<String>,
59 ) -> Result<Self, ProfileValidationError> {
60 Ok(Self {
61 id: required(id, "artifact.id")?,
62 revision: required(revision, "artifact.revision")?,
63 })
64 }
65
66 pub fn id(&self) -> &str {
68 &self.id
69 }
70
71 pub fn revision(&self) -> &str {
73 &self.revision
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ProfileSource {
80 profile_id: String,
81 repository: String,
82 repository_revision: String,
83 bundle_sha256: String,
84}
85
86impl ProfileSource {
87 pub fn new(
89 profile_id: impl Into<String>,
90 repository: impl Into<String>,
91 repository_revision: impl Into<String>,
92 bundle_sha256: impl Into<String>,
93 ) -> Result<Self, ProfileValidationError> {
94 let bundle_sha256 = bundle_sha256.into();
95 if bundle_sha256.len() != 64
96 || !bundle_sha256
97 .bytes()
98 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
99 {
100 return Err(ProfileValidationError::InvalidBundleSha256);
101 }
102
103 Ok(Self {
104 profile_id: required(profile_id, "profile_id")?,
105 repository: required(repository, "source.repository")?,
106 repository_revision: required(repository_revision, "source.repository_revision")?,
107 bundle_sha256,
108 })
109 }
110
111 pub fn profile_id(&self) -> &str {
113 &self.profile_id
114 }
115
116 pub fn repository(&self) -> &str {
118 &self.repository
119 }
120
121 pub fn repository_revision(&self) -> &str {
123 &self.repository_revision
124 }
125
126 pub fn bundle_sha256(&self) -> &str {
128 &self.bundle_sha256
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum ProbabilitySpace {
142 ConditionalOnOfferedOptions,
147 OfferedOptionsPlusSemanticNone,
152}
153
154impl ProbabilitySpace {
155 #[must_use]
157 pub const fn as_str(self) -> &'static str {
158 match self {
159 Self::ConditionalOnOfferedOptions => "conditional_on_offered_options",
160 Self::OfferedOptionsPlusSemanticNone => "offered_options_plus_semantic_none",
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ExecutionSemantics {
168 renderer: String,
169 head: String,
170 rejection: String,
171 probability_space: ProbabilitySpace,
172}
173
174impl ExecutionSemantics {
175 pub fn new(
178 renderer: impl Into<String>,
179 head: impl Into<String>,
180 rejection: impl Into<String>,
181 probability_space: ProbabilitySpace,
182 ) -> Result<Self, ProfileValidationError> {
183 Ok(Self {
184 renderer: required(renderer, "execution.renderer")?,
185 head: required(head, "execution.head")?,
186 rejection: required(rejection, "execution.rejection")?,
187 probability_space,
188 })
189 }
190
191 pub fn renderer(&self) -> &str {
193 &self.renderer
194 }
195
196 pub fn head(&self) -> &str {
198 &self.head
199 }
200
201 pub fn rejection(&self) -> &str {
203 &self.rejection
204 }
205
206 #[must_use]
208 pub const fn probability_space(&self) -> ProbabilitySpace {
209 self.probability_space
210 }
211}
212
213#[derive(Debug, Clone, PartialEq)]
215pub struct ParityContract {
216 calibration_temperature: f64,
217 policy_threshold: f64,
218 probability_tolerance: f64,
219 ordering_tolerance: f64,
220}
221
222impl ParityContract {
223 pub fn new(
225 calibration_temperature: f64,
226 policy_threshold: f64,
227 probability_tolerance: f64,
228 ordering_tolerance: f64,
229 ) -> Result<Self, ProfileValidationError> {
230 if !calibration_temperature.is_finite() || calibration_temperature <= 0.0 {
231 return Err(ProfileValidationError::InvalidCalibrationTemperature);
232 }
233 if !policy_threshold.is_finite() || !(0.0..=1.0).contains(&policy_threshold) {
234 return Err(ProfileValidationError::InvalidPolicyThreshold);
235 }
236 for (field, value) in [
237 ("probability_tolerance", probability_tolerance),
238 ("ordering_tolerance", ordering_tolerance),
239 ] {
240 if !value.is_finite() || value < 0.0 {
241 return Err(ProfileValidationError::InvalidTolerance { field });
242 }
243 }
244
245 Ok(Self {
246 calibration_temperature,
247 policy_threshold,
248 probability_tolerance,
249 ordering_tolerance,
250 })
251 }
252
253 pub fn calibration_temperature(&self) -> f64 {
255 self.calibration_temperature
256 }
257
258 pub fn policy_threshold(&self) -> f64 {
260 self.policy_threshold
261 }
262
263 pub fn probability_tolerance(&self) -> f64 {
265 self.probability_tolerance
266 }
267
268 pub fn ordering_tolerance(&self) -> f64 {
270 self.ordering_tolerance
271 }
272}
273
274#[derive(Debug, Clone, PartialEq)]
276pub struct ModelExecutionProfile {
277 source: ProfileSource,
278 backbone: ArtifactIdentity,
279 execution: ExecutionSemantics,
280 parity: ParityContract,
281}
282
283impl ModelExecutionProfile {
284 pub fn new(
286 source: ProfileSource,
287 backbone: ArtifactIdentity,
288 execution: ExecutionSemantics,
289 parity: ParityContract,
290 ) -> Self {
291 Self {
292 source,
293 backbone,
294 execution,
295 parity,
296 }
297 }
298
299 pub fn source(&self) -> &ProfileSource {
301 &self.source
302 }
303
304 pub fn backbone(&self) -> &ArtifactIdentity {
306 &self.backbone
307 }
308
309 pub fn execution(&self) -> &ExecutionSemantics {
311 &self.execution
312 }
313
314 pub fn parity(&self) -> &ParityContract {
316 &self.parity
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn profile_contract_is_constructed_from_valid_components() {
326 let source = ProfileSource::new(
327 "profile",
328 "owner/repository",
329 "revision",
330 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
331 )
332 .unwrap();
333 let backbone = ArtifactIdentity::new("model", "model-revision").unwrap();
334 let execution = ExecutionSemantics::new(
335 "state-first",
336 "head.safetensors",
337 "score-summary",
338 ProbabilitySpace::OfferedOptionsPlusSemanticNone,
339 )
340 .unwrap();
341 let parity = ParityContract::new(1.5, 0.98, 0.005, 0.00001).unwrap();
342
343 let profile = ModelExecutionProfile::new(source, backbone, execution, parity);
344
345 assert_eq!(profile.source().profile_id(), "profile");
346 assert_eq!(profile.backbone().id(), "model");
347 assert_eq!(profile.execution().renderer(), "state-first");
348 assert_eq!(profile.parity().policy_threshold(), 0.98);
349 assert_eq!(
350 profile.execution().probability_space(),
351 ProbabilitySpace::OfferedOptionsPlusSemanticNone
352 );
353 assert_eq!(
354 ProbabilitySpace::ConditionalOnOfferedOptions.as_str(),
355 "conditional_on_offered_options"
356 );
357 assert_eq!(
358 ProbabilitySpace::OfferedOptionsPlusSemanticNone.as_str(),
359 "offered_options_plus_semantic_none"
360 );
361 }
362
363 #[test]
364 fn profile_components_reject_invalid_values() {
365 assert!(matches!(
366 ArtifactIdentity::new("", "revision"),
367 Err(ProfileValidationError::EmptyField { .. })
368 ));
369 assert_eq!(
370 ProfileSource::new("profile", "repository", "revision", "ABC"),
371 Err(ProfileValidationError::InvalidBundleSha256)
372 );
373 assert_eq!(
374 ParityContract::new(0.0, 0.98, 0.005, 0.00001),
375 Err(ProfileValidationError::InvalidCalibrationTemperature)
376 );
377 assert_eq!(
378 ParityContract::new(1.0, 1.1, 0.005, 0.00001),
379 Err(ProfileValidationError::InvalidPolicyThreshold)
380 );
381 assert!(matches!(
382 ParityContract::new(1.0, 0.98, f64::NAN, 0.00001),
383 Err(ProfileValidationError::InvalidTolerance { .. })
384 ));
385 }
386}