1use crate::contracts::ITangleTypes;
4use alloc::borrow::ToOwned;
5use alloc::string::{String, ToString};
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value, json};
8use thiserror::Error;
9
10pub const METADATA_SCHEMA_V1: &str = "tangle.blueprint.metadata.v1";
12
13const EXECUTION_PROFILE_KEY: &str = "execution_profile";
14const JOB_PROFILES_BLOB_KEY: &str = "job_profiles_b64_gzip";
15
16#[derive(Debug, Error, Clone, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum ExecutionProfileError {
20 #[error("profiling_data must be valid JSON: {message}")]
22 InvalidJson {
23 message: String,
25 },
26 #[error("profiling_data must be a JSON object")]
28 MetadataNotObject,
29 #[error("execution_profile must be an object")]
31 ExecutionProfileNotObject,
32 #[error("execution_profile is invalid: {message}")]
34 InvalidExecutionProfile {
35 message: String,
37 },
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ConfidentialityPolicy {
44 Any,
46 TeeRequired,
48 StandardRequired,
50 TeePreferred,
52}
53
54impl Default for ConfidentialityPolicy {
55 fn default() -> Self {
56 Self::Any
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum GpuPolicy {
64 None,
66 Required,
68 Preferred,
70}
71
72impl Default for GpuPolicy {
73 fn default() -> Self {
74 Self::None
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[serde(deny_unknown_fields)]
81pub struct GpuRequirements {
82 #[serde(default)]
84 pub policy: GpuPolicy,
85 #[serde(default)]
87 pub min_count: u32,
88 #[serde(default)]
96 pub min_vram_gb: u32,
97}
98
99impl GpuRequirements {
100 #[must_use]
103 pub fn normalized(mut self) -> Self {
104 if !matches!(self.policy, GpuPolicy::None) && self.min_count == 0 {
105 self.min_count = 1;
106 }
107 self
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113pub struct ExecutionProfile {
114 #[serde(default)]
116 pub confidentiality: ConfidentialityPolicy,
117 #[serde(default)]
119 pub gpu: GpuRequirements,
120}
121
122impl ExecutionProfile {
123 #[must_use]
125 pub fn tee_required(self) -> bool {
126 matches!(self.confidentiality, ConfidentialityPolicy::TeeRequired)
127 }
128
129 #[must_use]
131 pub fn tee_supported(self) -> bool {
132 matches!(
133 self.confidentiality,
134 ConfidentialityPolicy::Any
135 | ConfidentialityPolicy::TeeRequired
136 | ConfidentialityPolicy::TeePreferred
137 )
138 }
139
140 #[must_use]
142 pub fn standard_required(self) -> bool {
143 matches!(
144 self.confidentiality,
145 ConfidentialityPolicy::StandardRequired
146 )
147 }
148
149 #[must_use]
151 pub fn gpu_required(self) -> bool {
152 matches!(self.gpu.policy, GpuPolicy::Required)
153 }
154
155 #[must_use]
157 pub fn gpu_preferred(self) -> bool {
158 matches!(self.gpu.policy, GpuPolicy::Preferred)
159 }
160
161 #[must_use]
163 pub fn needs_gpu(self) -> bool {
164 !matches!(self.gpu.policy, GpuPolicy::None)
165 }
166}
167
168#[derive(Debug, Deserialize)]
169#[serde(deny_unknown_fields)]
170struct RawExecutionProfile {
171 confidentiality: Option<ConfidentialityPolicy>,
172 #[serde(default)]
173 gpu: Option<GpuRequirements>,
174}
175
176pub fn resolve_execution_profile(
178 metadata: &ITangleTypes::BlueprintMetadata,
179) -> Result<Option<ExecutionProfile>, ExecutionProfileError> {
180 resolve_execution_profile_from_profiling_data(metadata.profilingData.as_str())
181}
182
183pub fn resolve_execution_profile_from_profiling_data(
189 profiling_data: &str,
190) -> Result<Option<ExecutionProfile>, ExecutionProfileError> {
191 let trimmed = profiling_data.trim();
192 if trimmed.is_empty() {
193 return Ok(None);
194 }
195
196 let root: Value =
197 serde_json::from_str(trimmed).map_err(|e| ExecutionProfileError::InvalidJson {
198 message: e.to_string(),
199 })?;
200 let Some(root_object) = root.as_object() else {
201 return Err(ExecutionProfileError::MetadataNotObject);
202 };
203
204 let Some(raw_profile_value) = root_object.get(EXECUTION_PROFILE_KEY) else {
205 return Ok(None);
206 };
207 let Some(raw_profile_object) = raw_profile_value.as_object() else {
208 return Err(ExecutionProfileError::ExecutionProfileNotObject);
209 };
210
211 let raw_profile: RawExecutionProfile =
212 serde_json::from_value(Value::Object(raw_profile_object.clone())).map_err(|e| {
213 ExecutionProfileError::InvalidExecutionProfile {
214 message: e.to_string(),
215 }
216 })?;
217
218 Ok(Some(ExecutionProfile {
219 confidentiality: raw_profile.confidentiality.unwrap_or_default(),
220 gpu: raw_profile.gpu.unwrap_or_default().normalized(),
221 }))
222}
223
224pub fn resolve_gpu_requirements(
226 metadata: &ITangleTypes::BlueprintMetadata,
227) -> Result<Option<GpuRequirements>, ExecutionProfileError> {
228 Ok(resolve_execution_profile(metadata)?.map(|profile| profile.gpu))
229}
230
231pub fn resolve_confidentiality_policy(
233 metadata: &ITangleTypes::BlueprintMetadata,
234) -> Result<Option<ConfidentialityPolicy>, ExecutionProfileError> {
235 Ok(resolve_execution_profile(metadata)?.map(|profile| profile.confidentiality))
236}
237
238#[must_use]
243pub fn inject_execution_profile(profiling_data: &str, profile: ExecutionProfile) -> String {
244 let trimmed = profiling_data.trim();
245 if trimmed.is_empty() {
246 return default_metadata_payload(profile).to_string();
247 }
248
249 if let Ok(mut value) = serde_json::from_str::<Value>(trimmed)
250 && let Some(root) = value.as_object_mut()
251 {
252 if let Some(schema) = root.get("schema").and_then(Value::as_str)
253 && schema != METADATA_SCHEMA_V1
254 {
255 return json!({
256 "schema": METADATA_SCHEMA_V1,
257 EXECUTION_PROFILE_KEY: profile_to_value(profile),
258 JOB_PROFILES_BLOB_KEY: trimmed,
259 })
260 .to_string();
261 }
262 upsert_execution_profile(root, profile);
263 return value.to_string();
264 }
265
266 json!({
267 "schema": METADATA_SCHEMA_V1,
268 EXECUTION_PROFILE_KEY: profile_to_value(profile),
269 JOB_PROFILES_BLOB_KEY: trimmed,
270 })
271 .to_string()
272}
273
274#[must_use]
276pub fn extract_job_profiles_blob(profiling_data: &str) -> Option<String> {
277 let trimmed = profiling_data.trim();
278 if trimmed.is_empty() {
279 return None;
280 }
281
282 let value: Value = serde_json::from_str(trimmed).ok()?;
283 value
284 .as_object()?
285 .get(JOB_PROFILES_BLOB_KEY)
286 .and_then(Value::as_str)
287 .map(ToOwned::to_owned)
288}
289
290fn default_metadata_payload(profile: ExecutionProfile) -> Value {
291 json!({
292 "schema": METADATA_SCHEMA_V1,
293 EXECUTION_PROFILE_KEY: profile_to_value(profile),
294 })
295}
296
297fn profile_to_value(profile: ExecutionProfile) -> Value {
298 let mut obj = Map::new();
299 obj.insert(
300 "confidentiality".to_string(),
301 serde_json::to_value(profile.confidentiality).unwrap_or_default(),
302 );
303 if !matches!(profile.gpu.policy, GpuPolicy::None) {
304 obj.insert(
305 "gpu".to_string(),
306 serde_json::to_value(profile.gpu).unwrap_or_default(),
307 );
308 }
309 Value::Object(obj)
310}
311
312fn upsert_execution_profile(root: &mut Map<String, Value>, profile: ExecutionProfile) {
313 root.insert(
314 "schema".to_string(),
315 Value::String(METADATA_SCHEMA_V1.to_string()),
316 );
317 root.insert(EXECUTION_PROFILE_KEY.to_string(), profile_to_value(profile));
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::contracts::ITangleTypes;
324
325 #[test]
326 fn resolves_required_profile() {
327 let mut metadata: ITangleTypes::BlueprintMetadata = Default::default();
328 metadata.profilingData =
329 r#"{"execution_profile":{"confidentiality":"tee_required"}}"#.into();
330 assert_eq!(
331 resolve_execution_profile(&metadata).unwrap(),
332 Some(ExecutionProfile {
333 confidentiality: ConfidentialityPolicy::TeeRequired,
334 ..Default::default()
335 })
336 );
337 }
338
339 #[test]
340 fn resolves_optional_profile() {
341 let mut metadata: ITangleTypes::BlueprintMetadata = Default::default();
342 metadata.profilingData =
343 r#"{"execution_profile":{"confidentiality":"tee_preferred"}}"#.into();
344 assert_eq!(
345 resolve_execution_profile(&metadata).unwrap(),
346 Some(ExecutionProfile {
347 confidentiality: ConfidentialityPolicy::TeePreferred,
348 ..Default::default()
349 })
350 );
351 }
352
353 #[test]
354 fn strict_parser_errors_on_non_json_payloads() {
355 let err =
356 resolve_execution_profile_from_profiling_data("tee").expect_err("expected parse error");
357 assert!(matches!(err, ExecutionProfileError::InvalidJson { .. }));
358 }
359
360 #[test]
361 fn strict_parser_errors_on_non_object_json_payloads() {
362 let err =
363 resolve_execution_profile_from_profiling_data("[]").expect_err("expected type error");
364 assert!(matches!(err, ExecutionProfileError::MetadataNotObject));
365 }
366
367 #[test]
368 fn strict_parser_errors_on_non_string_confidentiality() {
369 let err = resolve_execution_profile_from_profiling_data(
370 r#"{"execution_profile":{"confidentiality":true}}"#,
371 )
372 .expect_err("expected type error");
373 assert!(matches!(
374 err,
375 ExecutionProfileError::InvalidExecutionProfile { .. }
376 ));
377 }
378
379 #[test]
380 fn strict_parser_errors_on_unknown_fields() {
381 let err = resolve_execution_profile_from_profiling_data(
382 r#"{"execution_profile":{"confidentiality":"tee_required","bad":true}}"#,
383 )
384 .expect_err("expected schema error");
385 assert!(matches!(
386 err,
387 ExecutionProfileError::InvalidExecutionProfile { .. }
388 ));
389 }
390
391 #[test]
392 fn resolves_gpu_required_profile() {
393 let profile = resolve_execution_profile_from_profiling_data(
394 r#"{"execution_profile":{"confidentiality":"any","gpu":{"policy":"required","min_count":2,"min_vram_gb":40}}}"#,
395 )
396 .unwrap()
397 .unwrap();
398 assert!(profile.gpu_required());
399 assert_eq!(profile.gpu.min_count, 2);
400 assert_eq!(profile.gpu.min_vram_gb, 40);
401 }
402
403 #[test]
404 fn resolves_gpu_preferred_profile() {
405 let profile = resolve_execution_profile_from_profiling_data(
406 r#"{"execution_profile":{"gpu":{"policy":"preferred","min_count":1,"min_vram_gb":24}}}"#,
407 )
408 .unwrap()
409 .unwrap();
410 assert!(profile.gpu_preferred());
411 assert!(!profile.gpu_required());
412 assert_eq!(profile.gpu.min_count, 1);
413 }
414
415 #[test]
416 fn defaults_gpu_to_none_when_absent() {
417 let profile = resolve_execution_profile_from_profiling_data(
418 r#"{"execution_profile":{"confidentiality":"tee_required"}}"#,
419 )
420 .unwrap()
421 .unwrap();
422 assert!(!profile.needs_gpu());
423 assert_eq!(profile.gpu.policy, GpuPolicy::None);
424 }
425
426 #[test]
427 fn resolves_combined_tee_and_gpu() {
428 let profile = resolve_execution_profile_from_profiling_data(
429 r#"{"execution_profile":{"confidentiality":"tee_required","gpu":{"policy":"required","min_count":1,"min_vram_gb":80}}}"#,
430 )
431 .unwrap()
432 .unwrap();
433 assert!(profile.tee_required());
434 assert!(profile.gpu_required());
435 assert_eq!(profile.gpu.min_vram_gb, 80);
436 }
437
438 #[test]
439 fn injects_gpu_profile_into_empty_payload() {
440 let payload = inject_execution_profile(
441 "",
442 ExecutionProfile {
443 confidentiality: ConfidentialityPolicy::Any,
444 gpu: GpuRequirements {
445 policy: GpuPolicy::Required,
446 min_count: 1,
447 min_vram_gb: 24,
448 },
449 },
450 );
451 let value: Value = serde_json::from_str(&payload).unwrap();
452 let gpu = value
453 .get(EXECUTION_PROFILE_KEY)
454 .and_then(|v| v.get("gpu"))
455 .expect("gpu field should be present");
456 assert_eq!(gpu.get("policy").and_then(Value::as_str), Some("required"));
457 assert_eq!(gpu.get("min_count").and_then(Value::as_u64), Some(1));
458 }
459
460 #[test]
461 fn omits_gpu_from_profile_when_none() {
462 let payload = inject_execution_profile(
463 "",
464 ExecutionProfile {
465 confidentiality: ConfidentialityPolicy::Any,
466 gpu: GpuRequirements::default(),
467 },
468 );
469 let value: Value = serde_json::from_str(&payload).unwrap();
470 let profile = value.get(EXECUTION_PROFILE_KEY).unwrap();
471 assert!(
472 profile.get("gpu").is_none(),
473 "gpu field should be omitted when policy is none"
474 );
475 }
476
477 #[test]
478 fn injects_into_empty_payload() {
479 let payload = inject_execution_profile(
480 "",
481 ExecutionProfile {
482 confidentiality: ConfidentialityPolicy::Any,
483 ..Default::default()
484 },
485 );
486 let value: Value = serde_json::from_str(&payload).unwrap();
487 assert_eq!(
488 value
489 .get(EXECUTION_PROFILE_KEY)
490 .and_then(|v| v.get("confidentiality"))
491 .and_then(Value::as_str),
492 Some("any")
493 );
494 }
495
496 #[test]
497 fn updates_existing_object_payload() {
498 let payload = inject_execution_profile(
499 r#"{"job_profiles_b64_gzip":"abc"}"#,
500 ExecutionProfile {
501 confidentiality: ConfidentialityPolicy::TeeRequired,
502 ..Default::default()
503 },
504 );
505 let value: Value = serde_json::from_str(&payload).unwrap();
506 assert_eq!(
507 value
508 .get(EXECUTION_PROFILE_KEY)
509 .and_then(|v| v.get("confidentiality"))
510 .and_then(Value::as_str),
511 Some("tee_required")
512 );
513 assert_eq!(
514 value.get(JOB_PROFILES_BLOB_KEY).and_then(Value::as_str),
515 Some("abc")
516 );
517 }
518
519 #[test]
520 fn wraps_non_json_payload_as_job_profiles_blob() {
521 let payload = inject_execution_profile(
522 "H4sIAAAAAAAA/2NgYGBgBGIOAwA6rY+4BQAAAA==",
523 ExecutionProfile {
524 confidentiality: ConfidentialityPolicy::TeeRequired,
525 ..Default::default()
526 },
527 );
528 let value: Value = serde_json::from_str(&payload).unwrap();
529 assert_eq!(
530 value.get(JOB_PROFILES_BLOB_KEY).and_then(Value::as_str),
531 Some("H4sIAAAAAAAA/2NgYGBgBGIOAwA6rY+4BQAAAA==")
532 );
533 }
534
535 #[test]
536 fn extracts_profiles_blob_from_structured_payload() {
537 let payload = r#"{"execution_profile":{"confidentiality":"tee_required"},"job_profiles_b64_gzip":"abc"}"#;
538 assert_eq!(extract_job_profiles_blob(payload), Some("abc".to_string()));
539 }
540
541 #[test]
542 fn extract_profiles_blob_requires_structured_payload() {
543 assert_eq!(extract_job_profiles_blob("H4sIAAAAA..."), None);
544 }
545
546 #[test]
547 fn normalizes_required_with_zero_min_count() {
548 let profile = resolve_execution_profile_from_profiling_data(
549 r#"{"execution_profile":{"gpu":{"policy":"required"}}}"#,
550 )
551 .unwrap()
552 .unwrap();
553 assert!(profile.gpu_required());
554 assert_eq!(
555 profile.gpu.min_count, 1,
556 "Required policy must normalize min_count to at least 1"
557 );
558 }
559
560 #[test]
561 fn normalizes_preferred_with_zero_min_count() {
562 let profile = resolve_execution_profile_from_profiling_data(
563 r#"{"execution_profile":{"gpu":{"policy":"preferred"}}}"#,
564 )
565 .unwrap()
566 .unwrap();
567 assert!(profile.gpu_preferred());
568 assert_eq!(
569 profile.gpu.min_count, 1,
570 "Preferred policy must normalize min_count to at least 1"
571 );
572 }
573
574 #[test]
575 fn gpu_inject_then_resolve_round_trip() {
576 let original = ExecutionProfile {
577 confidentiality: ConfidentialityPolicy::Any,
578 gpu: GpuRequirements {
579 policy: GpuPolicy::Required,
580 min_count: 2,
581 min_vram_gb: 80,
582 },
583 };
584 let payload = inject_execution_profile("", original);
585 let resolved = resolve_execution_profile_from_profiling_data(&payload)
586 .unwrap()
587 .unwrap();
588 assert_eq!(resolved, original, "inject then resolve must round-trip");
589 }
590
591 #[test]
592 fn gpu_rejects_unknown_fields_in_requirements() {
593 let err = resolve_execution_profile_from_profiling_data(
594 r#"{"execution_profile":{"gpu":{"policy":"required","unknown_field":true}}}"#,
595 )
596 .expect_err("expected error for unknown GPU field");
597 assert!(matches!(
598 err,
599 ExecutionProfileError::InvalidExecutionProfile { .. }
600 ));
601 }
602}