Skip to main content

ferrum_types/
observability_profile.rs

1//! Product-path observability profile event schema.
2
3use crate::{requests::EngineTokenTimingEvidence, resource_trace::ResourceTraceEvent};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::path::PathBuf;
9
10pub const OBSERVABILITY_PROFILE_SCHEMA_VERSION: u32 = 1;
11pub const DEFAULT_OBSERVABILITY_PROFILE_SAMPLE_RATE: f64 = 0.01;
12pub const SYNTHETIC_RUNTIME_PRESET_HASH: &str =
13    "sha256:6c3b8d2c431c47cf612289b02a8c631c894f34f532508fc58841e572aedaa7bc";
14pub const ENGINE_RUNTIME_TRACE_PRESET_HASH: &str =
15    "sha256:30c1be62aa61858deca261ebcbfb4115918c1d6d0466f7ad5ffd7bc8d901e782";
16
17pub fn engine_token_timing_profile_attributes(
18    timing: &EngineTokenTimingEvidence,
19) -> BTreeMap<String, Value> {
20    let intervals = timing.inter_token_nanos();
21    let total_itl_nanos = intervals
22        .iter()
23        .fold(0_u128, |total, interval| total + u128::from(*interval));
24    let average_itl_nanos = if intervals.is_empty() {
25        0
26    } else {
27        u64::try_from(total_itl_nanos / intervals.len() as u128).unwrap_or(u64::MAX)
28    };
29    let mut attributes = BTreeMap::from([
30        (
31            "engine_token_clock_source".to_string(),
32            serde_json::json!(timing.clock_source),
33        ),
34        (
35            "engine_token_wall_anchor_unix_nanos".to_string(),
36            serde_json::json!(timing.wall_anchor_unix_nanos),
37        ),
38        (
39            "clock_conversion_max_error_nanos".to_string(),
40            serde_json::json!(timing.wall_anchor_max_error_nanos),
41        ),
42        (
43            "engine_token_commit_nanos_since_request_start".to_string(),
44            serde_json::json!(timing.token_commit_nanos_since_request_start),
45        ),
46        (
47            "engine_token_commit_count".to_string(),
48            serde_json::json!(timing.token_commit_nanos_since_request_start.len()),
49        ),
50        (
51            "engine_decode_stage_intervals".to_string(),
52            serde_json::json!(timing.decode_stage_intervals),
53        ),
54        (
55            "engine_decode_stage_interval_count".to_string(),
56            serde_json::json!(timing.decode_stage_intervals.len()),
57        ),
58        (
59            "itl_source".to_string(),
60            serde_json::json!("engine_token_commit"),
61        ),
62        (
63            "itl_interval_count".to_string(),
64            serde_json::json!(intervals.len()),
65        ),
66        ("itl_nanos".to_string(), serde_json::json!(intervals)),
67        (
68            "itl_us_avg".to_string(),
69            serde_json::json!(average_itl_nanos / 1_000),
70        ),
71    ]);
72    if let Some(ttft_nanos) = timing.ttft_nanos() {
73        attributes.insert("ttft_us".to_string(), serde_json::json!(ttft_nanos / 1_000));
74    }
75    if let Some(decode_ready_nanos) = timing.decode_ready_nanos_since_request_start {
76        attributes.insert(
77            "engine_decode_ready_nanos_since_request_start".to_string(),
78            serde_json::json!(decode_ready_nanos),
79        );
80    }
81    if let Some(decode_wall_nanos) = timing.decode_wall_nanos().filter(|value| *value > 0) {
82        let conversion_error_ppm = u64::try_from(
83            u128::from(timing.wall_anchor_max_error_nanos).saturating_mul(1_000_000)
84                / u128::from(decode_wall_nanos),
85        )
86        .unwrap_or(u64::MAX);
87        attributes.insert(
88            "engine_decode_wall_nanos".to_string(),
89            serde_json::json!(decode_wall_nanos),
90        );
91        attributes.insert(
92            "clock_conversion_error_ppm".to_string(),
93            serde_json::json!(conversion_error_ppm),
94        );
95        attributes.insert(
96            "decode_wall_timing_eligible".to_string(),
97            serde_json::json!(true),
98        );
99    } else {
100        let reason = if timing.token_commit_nanos_since_request_start.is_empty() {
101            "no_token_commits"
102        } else if timing.decode_ready_nanos_since_request_start.is_none() {
103            "decode_not_entered"
104        } else {
105            "no_positive_decode_commit_interval"
106        };
107        attributes.insert(
108            "decode_wall_timing_eligible".to_string(),
109            serde_json::json!(false),
110        );
111        attributes.insert(
112            "decode_wall_timing_unavailable_reason".to_string(),
113            serde_json::json!(reason),
114        );
115    }
116    attributes
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ProfileEntrypoint {
122    Run,
123    Serve,
124    BenchServe,
125    Synthetic,
126}
127
128impl ProfileEntrypoint {
129    pub fn parse(value: &str) -> Option<Self> {
130        match value.trim().to_ascii_lowercase().as_str() {
131            "run" => Some(Self::Run),
132            "serve" => Some(Self::Serve),
133            "bench_serve" | "bench-serve" | "benchserve" => Some(Self::BenchServe),
134            "synthetic" => Some(Self::Synthetic),
135            _ => None,
136        }
137    }
138
139    pub fn as_str(self) -> &'static str {
140        match self {
141            Self::Run => "run",
142            Self::Serve => "serve",
143            Self::BenchServe => "bench_serve",
144            Self::Synthetic => "synthetic",
145        }
146    }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ProfileEventKind {
152    Instant,
153    TimedSpan,
154    Resource,
155    Memory,
156    Error,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum ProfileStatus {
162    Ok,
163    Failure,
164    DiagnosticOnly,
165}
166
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum ObservabilityProfileDetail {
170    #[default]
171    Off,
172    Basic,
173    Resource,
174    Latency,
175    Kernel,
176    Debug,
177    Replay,
178    Verify,
179    Full,
180}
181
182impl ObservabilityProfileDetail {
183    pub fn parse(value: &str) -> Option<Self> {
184        match value.trim().to_ascii_lowercase().as_str() {
185            "off" => Some(Self::Off),
186            "basic" => Some(Self::Basic),
187            "resource" => Some(Self::Resource),
188            "latency" => Some(Self::Latency),
189            "kernel" => Some(Self::Kernel),
190            "debug" => Some(Self::Debug),
191            "replay" => Some(Self::Replay),
192            "verify" => Some(Self::Verify),
193            "full" => Some(Self::Full),
194            _ => None,
195        }
196    }
197
198    pub fn as_str(self) -> &'static str {
199        match self {
200            Self::Off => "off",
201            Self::Basic => "basic",
202            Self::Resource => "resource",
203            Self::Latency => "latency",
204            Self::Kernel => "kernel",
205            Self::Debug => "debug",
206            Self::Replay => "replay",
207            Self::Verify => "verify",
208            Self::Full => "full",
209        }
210    }
211
212    pub fn diagnostic_only(self) -> bool {
213        matches!(
214            self,
215            Self::Kernel | Self::Debug | Self::Replay | Self::Verify | Self::Full
216        )
217    }
218
219    pub fn captures_engine_token_timing(self) -> bool {
220        matches!(
221            self,
222            Self::Latency | Self::Kernel | Self::Replay | Self::Verify | Self::Full
223        )
224    }
225}
226
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct FerrumObservabilityConfig {
229    pub entrypoint: ProfileEntrypoint,
230    pub model: String,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub profile_jsonl: Option<PathBuf>,
233    pub profile_detail: ObservabilityProfileDetail,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub memory_profile_jsonl: Option<PathBuf>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub scheduler_trace_jsonl: Option<PathBuf>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub request_dump_dir: Option<PathBuf>,
240    pub profile_sample_rate: f64,
241}
242
243impl FerrumObservabilityConfig {
244    #[allow(clippy::too_many_arguments)]
245    pub fn new(
246        entrypoint: ProfileEntrypoint,
247        model: impl Into<String>,
248        profile_jsonl: Option<PathBuf>,
249        profile_detail: ObservabilityProfileDetail,
250        memory_profile_jsonl: Option<PathBuf>,
251        scheduler_trace_jsonl: Option<PathBuf>,
252        request_dump_dir: Option<PathBuf>,
253        profile_sample_rate: f64,
254    ) -> Self {
255        Self {
256            entrypoint,
257            model: model.into(),
258            profile_jsonl,
259            profile_detail,
260            memory_profile_jsonl,
261            scheduler_trace_jsonl,
262            request_dump_dir,
263            profile_sample_rate,
264        }
265    }
266
267    pub fn enabled(&self) -> bool {
268        self.profile_detail != ObservabilityProfileDetail::Off
269            || self.profile_jsonl.is_some()
270            || self.memory_profile_jsonl.is_some()
271            || self.scheduler_trace_jsonl.is_some()
272            || self.request_dump_dir.is_some()
273    }
274
275    pub fn synthetic_no_weight_enabled(&self) -> bool {
276        self.enabled() && self.model == "synthetic/no-weight"
277    }
278
279    pub fn unified_product_profile_enabled(&self) -> bool {
280        self.enabled()
281            && (self.profile_detail != ObservabilityProfileDetail::Off
282                || self.memory_profile_jsonl.is_some()
283                || self.scheduler_trace_jsonl.is_some()
284                || self.request_dump_dir.is_some())
285    }
286
287    pub fn validate(&self) -> std::result::Result<(), String> {
288        if !self.profile_sample_rate.is_finite()
289            || self.profile_sample_rate < 0.0
290            || self.profile_sample_rate > 1.0
291        {
292            return Err("profile_sample_rate must be between 0.0 and 1.0".to_string());
293        }
294        if self.enabled()
295            && self.profile_jsonl.is_none()
296            && self.memory_profile_jsonl.is_none()
297            && self.scheduler_trace_jsonl.is_none()
298            && self.request_dump_dir.is_none()
299        {
300            return Err(
301                "observability profile detail requires at least one artifact path".to_string(),
302            );
303        }
304        Ok(())
305    }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct MemorySnapshot {
310    pub scope: String,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub backend: Option<String>,
313    pub before_bytes: Option<u64>,
314    pub after_bytes: Option<u64>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub current_bytes: Option<u64>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub high_water_bytes: Option<u64>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub available_bytes: Option<i64>,
321}
322
323impl MemorySnapshot {
324    pub fn validate(&self) -> std::result::Result<(), String> {
325        if self.scope.trim().is_empty() {
326            return Err("memory scope must be non-empty".to_string());
327        }
328        if self.before_bytes.is_none() || self.after_bytes.is_none() {
329            return Err("memory before_bytes and after_bytes are required".to_string());
330        }
331        if self.high_water_bytes.is_none() {
332            return Err("memory high_water_bytes is required".to_string());
333        }
334        if self.current_bytes.is_none() {
335            return Err("memory current_bytes is required".to_string());
336        }
337        if self.available_bytes.is_some_and(|bytes| bytes < 0) {
338            return Err("memory available_bytes must be non-negative".to_string());
339        }
340        Ok(())
341    }
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct ProfileError {
346    pub kind: String,
347    pub message: String,
348    #[serde(default)]
349    pub blocking: bool,
350}
351
352impl ProfileError {
353    fn validate(&self) -> std::result::Result<(), String> {
354        if self.kind.trim().is_empty() {
355            return Err("error kind must be non-empty".to_string());
356        }
357        if self.message.trim().is_empty() {
358            return Err("error message must be non-empty".to_string());
359        }
360        Ok(())
361    }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365pub struct ReplayReference {
366    pub command: String,
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub bundle_dir: Option<String>,
369}
370
371impl ReplayReference {
372    fn validate(&self) -> std::result::Result<(), String> {
373        if self.command.trim().is_empty() {
374            return Err("replay command must be non-empty".to_string());
375        }
376        Ok(())
377    }
378}
379
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct FerrumProfileEvent {
382    pub schema_version: u32,
383    pub ts_unix_nanos: i64,
384    pub event_id: String,
385    pub request_id: String,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub correlation_id: Option<String>,
388    pub entrypoint: ProfileEntrypoint,
389    pub backend: String,
390    pub runtime_preset_hash: String,
391    pub phase: String,
392    pub event_kind: ProfileEventKind,
393    pub timestamp: DateTime<Utc>,
394    pub status: ProfileStatus,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub model: Option<String>,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub duration_us: Option<u64>,
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub memory: Option<MemorySnapshot>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub resource: Option<ResourceTraceEvent>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub error: Option<ProfileError>,
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub replay: Option<ReplayReference>,
407    #[serde(default)]
408    pub shape: BTreeMap<String, Value>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub backend_detail: Option<BTreeMap<String, Value>>,
411    #[serde(default)]
412    pub attributes: BTreeMap<String, Value>,
413}
414
415impl FerrumProfileEvent {
416    pub fn validate(&self) -> std::result::Result<(), String> {
417        if self.schema_version != OBSERVABILITY_PROFILE_SCHEMA_VERSION {
418            return Err(format!(
419                "schema_version must be {OBSERVABILITY_PROFILE_SCHEMA_VERSION}"
420            ));
421        }
422        if self.ts_unix_nanos <= 0 {
423            return Err("ts_unix_nanos must be positive".to_string());
424        }
425        if self.event_id.trim().is_empty() {
426            return Err("event_id must be non-empty".to_string());
427        }
428        if self.request_id.trim().is_empty() {
429            return Err("request_id must be non-empty".to_string());
430        }
431        if self
432            .correlation_id
433            .as_ref()
434            .is_none_or(|value| value.trim().is_empty())
435        {
436            return Err("correlation_id must be non-empty".to_string());
437        }
438        if self.backend.trim().is_empty() {
439            return Err("backend must be non-empty".to_string());
440        }
441        if self.runtime_preset_hash.trim().is_empty() {
442            return Err("runtime_preset_hash must be non-empty".to_string());
443        }
444        if self.phase.trim().is_empty() {
445            return Err("phase must be non-empty".to_string());
446        }
447        if self.shape.is_empty() {
448            return Err("shape must contain at least one field".to_string());
449        }
450        if self.event_kind == ProfileEventKind::TimedSpan && self.duration_us.is_none() {
451            return Err("duration_us is required for timed_span events".to_string());
452        }
453        if let Some(memory) = &self.memory {
454            memory.validate()?;
455        }
456        if let Some(resource) = &self.resource {
457            resource.validate()?;
458        }
459        if let Some(error) = &self.error {
460            error.validate()?;
461        }
462        if self.status == ProfileStatus::Failure && self.error.is_none() {
463            return Err("failure events must include error detail".to_string());
464        }
465        if let Some(replay) = &self.replay {
466            replay.validate()?;
467        }
468        Ok(())
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::requests::{EngineDecodeStage, EngineDecodeStageInterval};
476
477    fn base_event() -> FerrumProfileEvent {
478        FerrumProfileEvent {
479            schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
480            ts_unix_nanos: Utc::now()
481                .timestamp_nanos_opt()
482                .expect("test timestamp should fit i64 nanos"),
483            event_id: "evt-1".to_string(),
484            request_id: "req-1".to_string(),
485            correlation_id: Some("corr-1".to_string()),
486            entrypoint: ProfileEntrypoint::Synthetic,
487            backend: "synthetic".to_string(),
488            runtime_preset_hash: SYNTHETIC_RUNTIME_PRESET_HASH.to_string(),
489            phase: "request".to_string(),
490            event_kind: ProfileEventKind::TimedSpan,
491            timestamp: Utc::now(),
492            status: ProfileStatus::Ok,
493            model: Some("synthetic/no-weight".to_string()),
494            duration_us: Some(100),
495            memory: None,
496            resource: None,
497            error: None,
498            replay: None,
499            shape: BTreeMap::from([("batch_size".to_string(), Value::from(1))]),
500            backend_detail: None,
501            attributes: BTreeMap::new(),
502        }
503    }
504
505    #[test]
506    fn timed_span_requires_duration_and_request_id() {
507        base_event().validate().unwrap();
508
509        let mut missing_duration = base_event();
510        missing_duration.duration_us = None;
511        assert!(missing_duration.validate().is_err());
512
513        let mut missing_request = base_event();
514        missing_request.request_id.clear();
515        assert!(missing_request.validate().is_err());
516
517        let mut missing_correlation = base_event();
518        missing_correlation.correlation_id = None;
519        assert!(missing_correlation.validate().is_err());
520
521        let mut missing_runtime_preset = base_event();
522        missing_runtime_preset.runtime_preset_hash.clear();
523        assert!(missing_runtime_preset.validate().is_err());
524
525        let mut missing_shape = base_event();
526        missing_shape.shape.clear();
527        assert!(missing_shape.validate().is_err());
528    }
529
530    #[test]
531    fn replay_profile_detail_is_typed_and_diagnostic_only() {
532        assert_eq!(
533            ObservabilityProfileDetail::parse(" replay "),
534            Some(ObservabilityProfileDetail::Replay)
535        );
536        assert_eq!(ObservabilityProfileDetail::Replay.as_str(), "replay");
537        assert!(ObservabilityProfileDetail::Replay.diagnostic_only());
538        assert!(!ObservabilityProfileDetail::Basic.diagnostic_only());
539    }
540
541    #[test]
542    fn staged_product_profile_details_are_typed_with_distinct_claim_status() {
543        for (name, expected) in [
544            ("resource", ObservabilityProfileDetail::Resource),
545            ("latency", ObservabilityProfileDetail::Latency),
546            ("kernel", ObservabilityProfileDetail::Kernel),
547        ] {
548            assert_eq!(ObservabilityProfileDetail::parse(name), Some(expected));
549            assert_eq!(expected.as_str(), name);
550        }
551        assert!(!ObservabilityProfileDetail::Resource.diagnostic_only());
552        assert!(!ObservabilityProfileDetail::Latency.diagnostic_only());
553        assert!(ObservabilityProfileDetail::Kernel.diagnostic_only());
554        assert!(!ObservabilityProfileDetail::Resource.captures_engine_token_timing());
555        assert!(ObservabilityProfileDetail::Latency.captures_engine_token_timing());
556        assert!(ObservabilityProfileDetail::Kernel.captures_engine_token_timing());
557    }
558
559    #[test]
560    fn engine_token_timing_preserves_exact_commit_intervals() {
561        let timing = EngineTokenTimingEvidence {
562            clock_source: "rust_std_instant".to_string(),
563            wall_anchor_unix_nanos: 1_700_000_000_000_000_000,
564            wall_anchor_max_error_nanos: 400,
565            decode_ready_nanos_since_request_start: Some(2_000_000),
566            token_commit_nanos_since_request_start: vec![1_000_000, 2_500_000, 5_000_000],
567            decode_stage_intervals: vec![
568                EngineDecodeStageInterval {
569                    stage: EngineDecodeStage::DecodeScheduling,
570                    start_nanos_since_request_start: 2_000_000,
571                    end_nanos_since_request_start: 2_100_000,
572                },
573                EngineDecodeStageInterval {
574                    stage: EngineDecodeStage::DecodeExecution,
575                    start_nanos_since_request_start: 2_100_000,
576                    end_nanos_since_request_start: 4_900_000,
577                },
578                EngineDecodeStageInterval {
579                    stage: EngineDecodeStage::DecodePostprocess,
580                    start_nanos_since_request_start: 4_900_000,
581                    end_nanos_since_request_start: 5_000_000,
582                },
583            ],
584        };
585        timing.validate(3).unwrap();
586        assert!(timing.validate(2).is_err());
587
588        let attributes = engine_token_timing_profile_attributes(&timing);
589        assert_eq!(
590            attributes["engine_token_commit_count"],
591            serde_json::json!(3)
592        );
593        assert_eq!(
594            attributes["engine_decode_stage_intervals"],
595            serde_json::json!([
596                {
597                    "stage": "decode_scheduling",
598                    "start_nanos_since_request_start": 2_000_000,
599                    "end_nanos_since_request_start": 2_100_000,
600                },
601                {
602                    "stage": "decode_execution",
603                    "start_nanos_since_request_start": 2_100_000,
604                    "end_nanos_since_request_start": 4_900_000,
605                },
606                {
607                    "stage": "decode_postprocess",
608                    "start_nanos_since_request_start": 4_900_000,
609                    "end_nanos_since_request_start": 5_000_000,
610                },
611            ])
612        );
613        assert_eq!(
614            attributes["engine_decode_stage_interval_count"],
615            serde_json::json!(3)
616        );
617        assert_eq!(attributes["ttft_us"], serde_json::json!(1_000));
618        assert_eq!(attributes["itl_interval_count"], serde_json::json!(2));
619        assert_eq!(
620            attributes["itl_nanos"],
621            serde_json::json!([1_500_000, 2_500_000])
622        );
623        assert_eq!(attributes["itl_us_avg"], serde_json::json!(2_000));
624        assert_eq!(
625            attributes["engine_decode_wall_nanos"],
626            serde_json::json!(3_000_000)
627        );
628        assert_eq!(
629            attributes["clock_conversion_error_ppm"],
630            serde_json::json!(133)
631        );
632        assert_eq!(
633            attributes["decode_wall_timing_eligible"],
634            serde_json::json!(true)
635        );
636        assert_eq!(
637            attributes["itl_source"],
638            serde_json::json!("engine_token_commit")
639        );
640    }
641
642    #[test]
643    fn verification_profile_detail_is_typed_and_diagnostic_only() {
644        assert_eq!(
645            ObservabilityProfileDetail::parse(" verify "),
646            Some(ObservabilityProfileDetail::Verify)
647        );
648        assert_eq!(ObservabilityProfileDetail::Verify.as_str(), "verify");
649        assert!(ObservabilityProfileDetail::Verify.diagnostic_only());
650    }
651
652    #[test]
653    fn observability_config_requires_artifact_path_when_detail_enabled() {
654        let disabled = FerrumObservabilityConfig::new(
655            ProfileEntrypoint::Serve,
656            "model",
657            None,
658            ObservabilityProfileDetail::Off,
659            None,
660            None,
661            None,
662            DEFAULT_OBSERVABILITY_PROFILE_SAMPLE_RATE,
663        );
664        assert!(!disabled.enabled());
665        assert!(!disabled.unified_product_profile_enabled());
666        assert!(disabled.validate().is_ok());
667
668        let config = FerrumObservabilityConfig::new(
669            ProfileEntrypoint::Run,
670            "synthetic/no-weight",
671            None,
672            ObservabilityProfileDetail::Basic,
673            None,
674            None,
675            None,
676            DEFAULT_OBSERVABILITY_PROFILE_SAMPLE_RATE,
677        );
678        assert!(config.enabled());
679        assert!(config.synthetic_no_weight_enabled());
680        assert!(config.validate().is_err());
681
682        let with_artifact = FerrumObservabilityConfig::new(
683            ProfileEntrypoint::Serve,
684            "synthetic/no-weight",
685            Some(PathBuf::from("profile.jsonl")),
686            ObservabilityProfileDetail::Basic,
687            None,
688            None,
689            None,
690            DEFAULT_OBSERVABILITY_PROFILE_SAMPLE_RATE,
691        );
692        assert!(with_artifact.validate().is_ok());
693        assert!(with_artifact.unified_product_profile_enabled());
694
695        let invalid_sample_rate = FerrumObservabilityConfig::new(
696            ProfileEntrypoint::Run,
697            "model",
698            Some(PathBuf::from("profile.jsonl")),
699            ObservabilityProfileDetail::Off,
700            None,
701            None,
702            None,
703            1.1,
704        );
705        assert!(invalid_sample_rate.validate().is_err());
706    }
707}