Skip to main content

eredu_core/
inspection.rs

1//! Portable model-artifact inspection results.
2
3use crate::{
4    artifact::ArtifactError, ArtifactFormat, ArtifactInspection, GgufCompanionRole,
5    InputModalities, ModelResourceProfile, Observed, PreparationAdmission,
6    PreparationAdmissionError, SessionCapabilities,
7};
8use serde::{Deserialize, Serialize};
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    path::{Path, PathBuf},
12};
13
14/// A readiness result that preserves distinct failure modes.
15#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum InspectionReadiness {
18    /// The inspected artifacts establish this capability.
19    Ready,
20    /// The artifact omits a required component.
21    Missing,
22    /// The selected backend does not implement the combination.
23    Unsupported,
24    /// Relevant artifact data is malformed.
25    Invalid,
26    /// A concrete request is needed before deciding.
27    RequestDependent,
28    /// The check necessarily occurs during preparation or execution.
29    Unverified,
30    /// The capability does not apply.
31    NotApplicable,
32}
33
34/// Mechanism observations required to finalize an admitted inspection report.
35#[derive(Debug, Clone, Copy, Eq, PartialEq)]
36pub struct RealizedInspectionOutcomes {
37    /// The admitted artifact remained authoritative through construction.
38    pub artifact_authority: bool,
39    /// Canonical bindings were published into the constructed module.
40    pub binding: bool,
41    /// Native-independent model construction completed.
42    pub construction: bool,
43    /// The selected communication realization was exercised.
44    pub communication: bool,
45    /// At least one session operation completed and was observed.
46    pub execution: bool,
47    /// Facilities actually observed on the constructed session.
48    pub session: SessionCapabilities,
49}
50
51/// Finalizes readiness from actual construction and execution observations.
52///
53/// Artifact identity and descriptive fields remain those admitted by the
54/// portable report. Readiness is recomputed from realized mechanisms, so a
55/// backend adapter cannot prove realization by replaying artifact inspection.
56pub fn finalize_realized_model_inspection(
57    admitted: &ModelInspectionReport,
58    outcomes: RealizedInspectionOutcomes,
59) -> ModelInspectionReport {
60    let mut report = admitted.clone();
61    let admission = admitted.preparation_admission;
62    let session_matches =
63        admission.is_some_and(|value| value.session_capabilities() == outcomes.session);
64    report.container = if outcomes.artifact_authority {
65        InspectionReadiness::Ready
66    } else {
67        InspectionReadiness::Invalid
68    };
69    report.architecture_support = if outcomes.construction {
70        InspectionReadiness::Ready
71    } else {
72        InspectionReadiness::Invalid
73    };
74    report.structural_binding = if outcomes.binding {
75        InspectionReadiness::Ready
76    } else {
77        InspectionReadiness::Invalid
78    };
79    report.model_loadability = if outcomes.artifact_authority
80        && outcomes.binding
81        && outcomes.construction
82        && outcomes.communication
83        && outcomes.execution
84    {
85        InspectionReadiness::Ready
86    } else {
87        InspectionReadiness::Invalid
88    };
89    report.requested_load =
90        if report.model_loadability == InspectionReadiness::Ready && session_matches {
91            InspectionReadiness::Ready
92        } else {
93            InspectionReadiness::Unsupported
94        };
95    report
96}
97
98/// Assembles the portable portion of a model inspection report from one
99/// authoritative artifact admission and its exact preparation admission.
100///
101/// This is the canonical adapter surface for backend-independent callers: all
102/// readiness derived from artifact headers, architecture capabilities, load
103/// policy, and portable media facilities is recorded here rather than rebuilt
104/// by individual backends or integration adapters.
105pub fn assemble_portable_model_inspection<P>(
106    inspection: &ArtifactInspection<P>,
107    admission: PreparationAdmission,
108    modalities: InputModalities,
109    embedded_draft_layers: Option<usize>,
110    processor: Option<(bool, MediaFeatureAvailability)>,
111) -> ModelInspectionReport {
112    let mut report = ModelInspectionReport::unverified(inspection.path(), inspection.format());
113    report.record_artifact_inspection(inspection);
114    report.record_architecture_capabilities(modalities, embedded_draft_layers);
115    if let Some((has_processor, availability)) = processor {
116        record_processor_inspection(&mut report, has_processor, availability);
117    }
118    report.record_preparation_admission(admission);
119    report
120}
121
122/// Records one failed portable artifact inspection without backend policy.
123pub fn reject_portable_artifact_inspection(
124    report: &mut ModelInspectionReport,
125    path: &Path,
126    error: &ArtifactError,
127) {
128    let detail = error.to_string();
129    let missing_media_projector = matches!(
130        error,
131        ArtifactError::MissingRequiredGgufCompanion {
132            role: GgufCompanionRole::MediaProjector,
133            ..
134        }
135    );
136    let (code, container, architecture, structural, type_code) = match error {
137        ArtifactError::UnsupportedGgufArchitecture(name) => {
138            report.architecture = Some(name.clone());
139            (
140                InspectionIssueCode::UnsupportedArchitecture,
141                InspectionReadiness::Ready,
142                InspectionReadiness::Unsupported,
143                InspectionReadiness::Unsupported,
144                None,
145            )
146        }
147        ArtifactError::MissingGgufArchitecture => (
148            InspectionIssueCode::InvalidConfiguration,
149            InspectionReadiness::Ready,
150            InspectionReadiness::Invalid,
151            InspectionReadiness::Invalid,
152            None,
153        ),
154        ArtifactError::MissingRequiredGgufCompanion {
155            role: GgufCompanionRole::MediaProjector,
156            ..
157        } => (
158            InspectionIssueCode::MissingMediaProjector,
159            InspectionReadiness::Ready,
160            InspectionReadiness::Ready,
161            InspectionReadiness::Unverified,
162            None,
163        ),
164        ArtifactError::InvalidArtifact(_)
165        | ArtifactError::InvalidArchitecturePlan(_)
166        | ArtifactError::DuplicateTensor(_)
167        | ArtifactError::Catalog(_) => (
168            InspectionIssueCode::InvalidConfiguration,
169            InspectionReadiness::Ready,
170            InspectionReadiness::Unverified,
171            InspectionReadiness::Invalid,
172            None,
173        ),
174        ArtifactError::Gguf(error) => {
175            let type_code = error.unsupported_tensor_type_code();
176            (
177                if type_code.is_some() {
178                    InspectionIssueCode::UnsupportedTensorEncoding
179                } else {
180                    InspectionIssueCode::InvalidContainer
181                },
182                InspectionReadiness::Invalid,
183                InspectionReadiness::Unverified,
184                InspectionReadiness::Unverified,
185                type_code,
186            )
187        }
188        _ => (
189            InspectionIssueCode::InvalidContainer,
190            InspectionReadiness::Invalid,
191            InspectionReadiness::Unverified,
192            InspectionReadiness::Unverified,
193            None,
194        ),
195    };
196    report.container = container;
197    report.architecture_support = architecture;
198    report.structural_binding = structural;
199    report.model_loadability = if missing_media_projector {
200        InspectionReadiness::Missing
201    } else if architecture == InspectionReadiness::Unsupported {
202        InspectionReadiness::Unsupported
203    } else {
204        InspectionReadiness::Invalid
205    };
206    report.requested_load = report.model_loadability;
207    if missing_media_projector {
208        report.multimodal = InspectionReadiness::Missing;
209        report.requirements.push(InspectionRequirement {
210            code: InspectionIssueCode::MissingMediaProjector,
211            readiness: InspectionReadiness::Missing,
212            detail: detail.clone(),
213            path: None,
214        });
215    }
216    report.issues.push(InspectionIssue {
217        code,
218        severity: InspectionSeverity::Error,
219        detail,
220        path: Some(path.to_path_buf()),
221        metadata_key: None,
222        tensor_name: None,
223        tensor_type_code: type_code,
224    });
225}
226
227/// Severity attached to an inspection issue.
228#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum InspectionSeverity {
231    /// Prevents the stated readiness or requested preparation route.
232    Error,
233    /// Limits a capability without preventing selected preparation.
234    Warning,
235    /// Actionable context that is neither rejection nor warning.
236    Info,
237}
238
239/// Stable machine-readable inspection issue category.
240#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
241#[serde(rename_all = "snake_case")]
242#[non_exhaustive]
243pub enum InspectionIssueCode {
244    /// Artifact path or container structure is invalid.
245    InvalidContainer,
246    /// Configuration or architecture metadata is invalid.
247    InvalidConfiguration,
248    /// Architecture dispatch has no backend implementation.
249    UnsupportedArchitecture,
250    /// A referenced checkpoint shard is absent or contradictory.
251    MissingCheckpointShard,
252    /// A tensor storage encoding cannot be consumed.
253    UnsupportedTensorEncoding,
254    /// An architecture-required tensor is absent.
255    MissingRequiredTensor,
256    /// Tensor aliases or layouts conflict after translation.
257    ConflictingTensorLayout,
258    /// A catalog tensor has the wrong rank or dimensions.
259    TensorShapeMismatch,
260    /// Packed quantization metadata and companion tensors disagree.
261    QuantizationCompanionMismatch,
262    /// Configured layer, attention, or expert geometry is invalid.
263    InvalidLayerOrExpertCount,
264    /// No usable tokenizer is available.
265    MissingTokenizer,
266    /// No checkpoint or sidecar chat template is available.
267    MissingChatTemplate,
268    /// A required multimodal projector is absent or ambiguous.
269    MissingMediaProjector,
270    /// A media processor or its build feature is unavailable.
271    MissingProcessor,
272    /// Requested on-load quantization is incompatible.
273    UnsupportedQuantizationRequest,
274    /// Requested weight-residency route is incompatible.
275    UnsupportedResidencyPolicy,
276    /// Requested parallel topology cannot use this loader.
277    UnsupportedParallelTopology,
278    /// No fail-closed semantic streaming protocol was recognized.
279    UnsupportedSemanticProtocol,
280    /// No fail-closed native-tool protocol was recognized.
281    UnsupportedToolProtocol,
282    /// EOS metadata is absent.
283    MissingEosMetadata,
284    /// Exact binding requires preparation-time module validation.
285    ValidationUnavailableUntilLoad,
286    /// Request data or runtime state still needs validation.
287    RequestSpecificValidation,
288    /// An ordinary local I/O operation failed.
289    Io,
290}
291
292/// One structured diagnostic produced by inspection.
293#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
294pub struct InspectionIssue {
295    /// Stable category for routing and UI behavior.
296    pub code: InspectionIssueCode,
297    /// Diagnostic severity.
298    pub severity: InspectionSeverity,
299    /// Human-readable actionable detail.
300    pub detail: String,
301    /// Relevant artifact or sidecar path.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub path: Option<PathBuf>,
304    /// Relevant metadata key.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub metadata_key: Option<String>,
307    /// Relevant logical or physical tensor name.
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub tensor_name: Option<String>,
310    /// Relevant numeric tensor type code.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub tensor_type_code: Option<u32>,
313}
314
315/// One tensor storage encoding observed in artifact headers.
316#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
317pub struct ArtifactTensorEncoding {
318    /// Stable textual representation.
319    pub name: String,
320    /// GGML type code for GGUF encodings.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub ggml_type_code: Option<u32>,
323}
324
325/// Input modality advertised by the resolved architecture.
326#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
327#[serde(rename_all = "snake_case")]
328pub enum ArtifactModality {
329    /// Text tokens.
330    Text,
331    /// Still images.
332    Image,
333    /// Video frame sequences.
334    Video,
335    /// Audio waveforms or features.
336    Audio,
337}
338
339/// A sidecar or companion requirement discovered during inspection.
340#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
341pub struct InspectionRequirement {
342    /// Machine-readable issue category.
343    pub code: InspectionIssueCode,
344    /// Current readiness of the requirement.
345    pub readiness: InspectionReadiness,
346    /// Human-readable explanation.
347    pub detail: String,
348    /// Expected or selected path.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub path: Option<PathBuf>,
351}
352
353/// Structured pre-preparation compatibility report for a local artifact.
354#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
355pub struct ModelInspectionReport {
356    /// Submitted local artifact path.
357    pub path: PathBuf,
358    /// Detected artifact container.
359    pub artifact_format: ArtifactFormat,
360    /// Resolved high-level model family.
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub model_family: Option<String>,
363    /// Submitted model type or architecture value.
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub architecture: Option<String>,
366    /// GGUF versions observed across validated shards.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub gguf_versions: Option<Vec<u32>>,
369    /// Number of checkpoint payload shards.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub checkpoint_shards: Option<usize>,
372    /// Number of cataloged logical tensors.
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub tensor_count: Option<usize>,
375    /// Header-only resource accounting.
376    pub resources: ModelResourceProfile,
377    /// Distinct storage encodings observed in headers.
378    pub tensor_encodings: Vec<ArtifactTensorEncoding>,
379    /// Expected input modalities.
380    pub expected_modalities: Vec<ArtifactModality>,
381    /// Container/config/header validity.
382    pub container: InspectionReadiness,
383    /// Whether architecture dispatch selects a backend implementation.
384    pub architecture_support: InspectionReadiness,
385    /// Exact header/catalog binding to that implementation.
386    pub structural_binding: InspectionReadiness,
387    /// Model preparation readiness independent of text sidecars.
388    pub model_loadability: InspectionReadiness,
389    /// Compatibility with backend preparation options.
390    pub requested_load: InspectionReadiness,
391    /// Exact portable preparation admission selected from immutable cold facts.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub preparation_admission: Option<PreparationAdmission>,
394    /// Combined model and tokenizer readiness for raw text generation.
395    pub text_generation: InspectionReadiness,
396    /// Tokenizer reconstruction/readiness.
397    pub tokenizer: InspectionReadiness,
398    /// Chat-template availability and parseability.
399    pub chat_template: InspectionReadiness,
400    /// Behavioral structured semantic-streaming readiness.
401    pub semantic_streaming: InspectionReadiness,
402    /// Behavioral native-tool readiness.
403    pub native_tools: InspectionReadiness,
404    /// Processor/projector readiness for non-text modalities.
405    pub multimodal: InspectionReadiness,
406    /// Discovered sidecar and request requirements.
407    pub requirements: Vec<InspectionRequirement>,
408    /// Structured rejection reasons, warnings, and limitations.
409    pub issues: Vec<InspectionIssue>,
410}
411
412impl ModelInspectionReport {
413    /// Creates an initially unverified report for backend-specific enrichment.
414    pub fn unverified(path: &Path, artifact_format: ArtifactFormat) -> Self {
415        Self {
416            path: path.to_path_buf(),
417            artifact_format,
418            model_family: None,
419            architecture: None,
420            gguf_versions: None,
421            checkpoint_shards: None,
422            tensor_count: None,
423            resources: ModelResourceProfile::unmeasured(path.to_path_buf(), artifact_format),
424            tensor_encodings: Vec::new(),
425            expected_modalities: Vec::new(),
426            container: InspectionReadiness::Unverified,
427            architecture_support: InspectionReadiness::Unverified,
428            structural_binding: InspectionReadiness::Unverified,
429            model_loadability: InspectionReadiness::Unverified,
430            requested_load: InspectionReadiness::Unverified,
431            preparation_admission: None,
432            text_generation: InspectionReadiness::Unverified,
433            tokenizer: InspectionReadiness::Unverified,
434            chat_template: InspectionReadiness::Unverified,
435            semantic_streaming: InspectionReadiness::Unverified,
436            native_tools: InspectionReadiness::Unverified,
437            multimodal: InspectionReadiness::Unverified,
438            requirements: Vec::new(),
439            issues: Vec::new(),
440        }
441    }
442
443    /// Returns whether artifact and requested backend policy passed preflight.
444    pub fn is_loadable(&self) -> bool {
445        self.container == InspectionReadiness::Ready
446            && self.architecture_support == InspectionReadiness::Ready
447            && self.structural_binding == InspectionReadiness::Ready
448            && self.model_loadability == InspectionReadiness::Ready
449            && self.requested_load == InspectionReadiness::Ready
450            && !self
451                .issues
452                .iter()
453                .any(|issue| issue.code == InspectionIssueCode::ValidationUnavailableUntilLoad)
454    }
455
456    /// Applies reusable header/catalog facts from one admitted portable inspection.
457    pub fn record_artifact_inspection<P>(&mut self, inspection: &ArtifactInspection<P>) {
458        self.path = inspection.path().to_owned();
459        self.artifact_format = inspection.format();
460        self.model_family = Some(inspection.configuration().family().to_owned());
461        self.architecture = Some(inspection.configuration().effective_model_type().to_owned());
462        self.container = InspectionReadiness::Ready;
463        self.architecture_support = InspectionReadiness::Ready;
464        self.structural_binding = InspectionReadiness::Ready;
465        self.model_loadability = InspectionReadiness::Ready;
466        self.tensor_count = Some(inspection.tensors().len());
467
468        let (shards, encodings, stored_bytes, largest_bytes, gguf_versions) =
469            if let Some(validated) = inspection.validated_gguf() {
470                let checkpoint = validated.checkpoint();
471                let encodings = checkpoint
472                    .tensors()
473                    .map(|tensor| tensor.descriptor().ggml_type)
474                    .map(|encoding| (encoding.code(), format!("{encoding:?}")))
475                    .collect::<BTreeMap<_, _>>()
476                    .into_iter()
477                    .map(|(code, name)| ArtifactTensorEncoding {
478                        name,
479                        ggml_type_code: Some(code),
480                    })
481                    .collect();
482                let mut total = Some(0_u64);
483                let mut largest = 0_u64;
484                for tensor in checkpoint.tensors() {
485                    let bytes = tensor.descriptor().byte_len;
486                    total = total.and_then(|value| value.checked_add(bytes));
487                    largest = largest.max(bytes);
488                }
489                let versions = checkpoint
490                    .shards()
491                    .iter()
492                    .map(|shard| shard.version())
493                    .collect::<BTreeSet<_>>()
494                    .into_iter()
495                    .collect();
496                (
497                    checkpoint.shards().len(),
498                    encodings,
499                    total,
500                    largest,
501                    Some(versions),
502                )
503            } else {
504                let mut shards = BTreeSet::new();
505                let mut encodings = BTreeSet::new();
506                let mut total = Some(0_u64);
507                let mut largest = 0_u64;
508                for tensor in inspection.tensors().descriptors() {
509                    encodings.insert(safetensors_encoding_name(&tensor.dtype));
510                    if let Some(storage) = &tensor.storage {
511                        shards.insert(storage.member.clone());
512                        total = total.and_then(|value| value.checked_add(storage.length));
513                        largest = largest.max(storage.length);
514                    } else {
515                        total = None;
516                    }
517                }
518                (
519                    shards.len(),
520                    encodings
521                        .into_iter()
522                        .map(|name| ArtifactTensorEncoding {
523                            name,
524                            ggml_type_code: None,
525                        })
526                        .collect(),
527                    total,
528                    largest,
529                    None,
530                )
531            };
532        self.checkpoint_shards = Some(shards);
533        self.tensor_encodings = encodings;
534        self.gguf_versions = gguf_versions;
535        self.resources.model_family = self.model_family.clone();
536        self.resources.architecture = self.architecture.clone();
537        self.resources.tensor_count = self.tensor_count;
538        self.resources.checkpoint_shards = self.checkpoint_shards;
539        match stored_bytes {
540            Some(total) => {
541                self.resources.stored_tensor_bytes =
542                    Observed::exact(total, "authoritative portable tensor catalog");
543                self.resources.largest_stored_tensor_bytes =
544                    Observed::exact(largest_bytes, "authoritative portable tensor catalog");
545            }
546            None => {
547                self.resources.stored_tensor_bytes =
548                    Observed::unavailable("portable payload-byte catalog was incomplete");
549                self.resources.largest_stored_tensor_bytes =
550                    Observed::unavailable("portable payload-byte catalog was incomplete");
551            }
552        }
553    }
554
555    /// Records the exact successful cold admission used by later realization.
556    pub fn record_preparation_admission(&mut self, admission: PreparationAdmission) {
557        self.preparation_admission = Some(admission);
558        self.requested_load = InspectionReadiness::Ready;
559    }
560
561    /// Records architecture-owned modality and embedded-drafting facts.
562    pub fn record_architecture_capabilities(
563        &mut self,
564        modalities: InputModalities,
565        embedded_draft_layers: Option<usize>,
566    ) {
567        self.expected_modalities = artifact_modalities(modalities);
568        self.resources.embedded_draft_layers = embedded_draft_layers.map_or_else(
569            || Observed::unsupported("artifact convention does not expose embedded drafting"),
570            |layers| Observed::exact(layers, "normalized architecture configuration"),
571        );
572    }
573
574    /// Records one stable portable admission rejection.
575    pub fn reject_preparation_admission(&mut self, error: PreparationAdmissionError) {
576        use PreparationAdmissionError as Admission;
577        self.preparation_admission = None;
578        self.requested_load = InspectionReadiness::Unsupported;
579        let code = match error {
580            Admission::UnsupportedQuantization(_) => {
581                InspectionIssueCode::UnsupportedQuantizationRequest
582            }
583            Admission::UnsupportedResidency(_) | Admission::ArchitectureParameterBanks => {
584                InspectionIssueCode::UnsupportedResidencyPolicy
585            }
586            Admission::ArchitectureParallelAxis(_) | Admission::BackendParallelAxis(_) => {
587                InspectionIssueCode::UnsupportedParallelTopology
588            }
589            Admission::ArchitectureInputModality(_) | Admission::BackendInputModality(_) => {
590                InspectionIssueCode::MissingProcessor
591            }
592            _ => InspectionIssueCode::UnsupportedArchitecture,
593        };
594        self.issue(
595            code,
596            InspectionSeverity::Error,
597            error.to_string(),
598            Some(self.path.clone()),
599        );
600    }
601
602    /// Adds a structured issue with an optional artifact path.
603    pub fn issue(
604        &mut self,
605        code: InspectionIssueCode,
606        severity: InspectionSeverity,
607        detail: impl Into<String>,
608        path: Option<PathBuf>,
609    ) {
610        self.issues.push(InspectionIssue {
611            code,
612            severity,
613            detail: detail.into(),
614            path,
615            metadata_key: None,
616            tensor_name: None,
617            tensor_type_code: None,
618        });
619    }
620}
621
622fn safetensors_encoding_name(dtype: &crate::checkpoint::TensorDtype) -> String {
623    use crate::checkpoint::TensorDtype;
624    match dtype {
625        TensorDtype::Bf16 => "BF16".into(),
626        TensorDtype::Complex64 => "C64".into(),
627        TensorDtype::Encoded(name) if name == "F8_E4M3" => "F8E4M3".into(),
628        TensorDtype::Encoded(name) if name == "F4" => "F4".into(),
629        TensorDtype::Encoded(name) if name == "F8_E8M0" => "F8E8M0".into(),
630        TensorDtype::Encoded(name) if name == "F8_E5M2" => "F8E5M2".into(),
631        TensorDtype::Encoded(name) => format!("Other({name:?})"),
632        dtype => format!("{dtype:?}"),
633    }
634}
635
636/// Converts portable modality flags into stable inspection report values.
637pub fn artifact_modalities(modalities: InputModalities) -> Vec<ArtifactModality> {
638    [
639        (modalities.text, ArtifactModality::Text),
640        (modalities.image, ArtifactModality::Image),
641        (modalities.video, ArtifactModality::Video),
642        (modalities.audio, ArtifactModality::Audio),
643    ]
644    .into_iter()
645    .filter_map(|(enabled, modality)| enabled.then_some(modality))
646    .collect()
647}
648
649/// Portable availability of optional host-media processors.
650#[derive(Debug, Clone, Copy, Eq, PartialEq)]
651pub struct MediaFeatureAvailability {
652    /// Image and video host processing is linked.
653    pub image: bool,
654    /// Audio host processing is linked.
655    pub audio: bool,
656}
657
658/// Architecture-owned requirement for a GGUF media projector.
659#[derive(Debug, Clone, Copy, Eq, PartialEq)]
660pub enum MediaProjectorRequirement {
661    /// No projector applies.
662    NotApplicable,
663    /// Text remains available when the projector is absent.
664    Optional,
665    /// Model loading requires a projector.
666    Required,
667}
668
669/// Computes portable host-feature readiness for declared modalities.
670pub fn media_feature_readiness(
671    expected: &[ArtifactModality],
672    availability: MediaFeatureAvailability,
673) -> InspectionReadiness {
674    if expected == [ArtifactModality::Text] {
675        return InspectionReadiness::NotApplicable;
676    }
677    if expected.iter().all(|modality| match modality {
678        ArtifactModality::Text => true,
679        ArtifactModality::Image | ArtifactModality::Video => availability.image,
680        ArtifactModality::Audio => availability.audio,
681    }) {
682        InspectionReadiness::Ready
683    } else {
684        InspectionReadiness::Unsupported
685    }
686}
687
688/// Records portable processor and host-feature readiness in one report.
689pub fn record_processor_inspection(
690    report: &mut ModelInspectionReport,
691    has_processor: bool,
692    availability: MediaFeatureAvailability,
693) {
694    let feature_readiness = media_feature_readiness(&report.expected_modalities, availability);
695    if feature_readiness == InspectionReadiness::NotApplicable {
696        report.multimodal = feature_readiness;
697        return;
698    }
699    if has_processor {
700        report.multimodal = feature_readiness;
701        report.requirements.push(InspectionRequirement {
702            code: InspectionIssueCode::MissingProcessor,
703            readiness: feature_readiness,
704            detail: if feature_readiness == InspectionReadiness::Ready {
705                "authoritative processor plan and required host-media features are available".into()
706            } else {
707                "authoritative processor plan is available, but required host-media features are not enabled".into()
708            },
709            path: None,
710        });
711    } else {
712        report.multimodal = InspectionReadiness::Missing;
713        report.issue(
714            InspectionIssueCode::MissingProcessor,
715            InspectionSeverity::Warning,
716            "authoritative architecture inspection admitted no media processor",
717            Some(report.path.clone()),
718        );
719    }
720}
721
722/// Records a validated or absent GGUF projector and returns whether it was selected.
723pub fn record_media_projector_inspection(
724    report: &mut ModelInspectionReport,
725    artifact_path: &Path,
726    requirement: MediaProjectorRequirement,
727    projector: Option<PathBuf>,
728) -> bool {
729    match (requirement, projector) {
730        (MediaProjectorRequirement::NotApplicable, _) => {
731            report.multimodal = InspectionReadiness::NotApplicable;
732            false
733        }
734        (_, Some(path)) => {
735            report.requirements.push(InspectionRequirement {
736                code: InspectionIssueCode::MissingMediaProjector,
737                readiness: InspectionReadiness::Ready,
738                detail: "portable admission validated the architecture-declared media projector"
739                    .into(),
740                path: Some(path),
741            });
742            true
743        }
744        (MediaProjectorRequirement::Optional, None) => {
745            report.multimodal = InspectionReadiness::Missing;
746            report.requirements.push(InspectionRequirement {
747                code: InspectionIssueCode::MissingMediaProjector,
748                readiness: InspectionReadiness::Missing,
749                detail: "text loading is available, but media input requires an architecture-declared sibling projector GGUF".into(),
750                path: None,
751            });
752            report.issue(
753                InspectionIssueCode::MissingMediaProjector,
754                InspectionSeverity::Warning,
755                "no sibling media projector was admitted; text loading remains available",
756                Some(artifact_path.to_path_buf()),
757            );
758            false
759        }
760        (MediaProjectorRequirement::Required, None) => {
761            report.multimodal = InspectionReadiness::Missing;
762            report.model_loadability = InspectionReadiness::Invalid;
763            report.requested_load = InspectionReadiness::Invalid;
764            report.requirements.push(InspectionRequirement {
765                code: InspectionIssueCode::MissingMediaProjector,
766                readiness: InspectionReadiness::Missing,
767                detail:
768                    "architecture preparation requires a validated sibling media projector GGUF"
769                        .into(),
770                path: None,
771            });
772            report.issue(
773                InspectionIssueCode::MissingMediaProjector,
774                InspectionSeverity::Error,
775                "portable admission omitted an architecture-required media projector",
776                Some(artifact_path.to_path_buf()),
777            );
778            false
779        }
780    }
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    #[test]
788    fn report_schema_round_trips_without_a_backend() {
789        let report =
790            ModelInspectionReport::unverified(Path::new("model.gguf"), ArtifactFormat::Gguf);
791        let json = serde_json::to_string(&report).unwrap();
792        let decoded: ModelInspectionReport = serde_json::from_str(&json).unwrap();
793        assert_eq!(decoded, report);
794    }
795
796    #[test]
797    fn neutral_admission_rejection_has_a_stable_report_code() {
798        let mut report =
799            ModelInspectionReport::unverified(Path::new("model"), ArtifactFormat::SafeTensors);
800        report.reject_preparation_admission(PreparationAdmissionError::BackendParallelAxis(
801            crate::ParallelAxis::Pipeline,
802        ));
803        assert_eq!(report.requested_load, InspectionReadiness::Unsupported);
804        assert!(report.preparation_admission.is_none());
805        assert_eq!(
806            report.issues[0].code,
807            InspectionIssueCode::UnsupportedParallelTopology
808        );
809    }
810}