Skip to main content

animsmith_core/
evaluation.rs

1//! Typed check-evaluation records.
2//!
3//! Selection, configuration, applicability, evaluation coverage, content
4//! findings, and coverage gaps are independent dimensions. The types in this
5//! module are the single execution/result boundary for both CLI and embedded
6//! consumers.
7
8use std::borrow::Cow;
9use std::collections::BTreeSet;
10use std::fmt;
11
12use serde::ser::SerializeStruct;
13use serde::{Deserialize, Serialize, Serializer};
14
15use crate::check::{Check, CheckCtx};
16use crate::config::{ConfigValidationError, SeveritySetting};
17use crate::finding::Finding;
18use crate::prediction::{EnginePredictionV1, PredictionContractError};
19
20/// One authoritative built-in evidence-code definition.
21///
22/// The `builtin_codes!` rows below own each built-in code's serialized
23/// identity, meaning, and allowed emitters. Both runtime validation and the
24/// output-documentation contract consume these definitions.
25#[derive(Debug, Clone, Copy)]
26struct BuiltinEvidenceCode {
27    code: &'static str,
28    #[cfg_attr(not(test), allow(dead_code))]
29    meaning: &'static str,
30    emitted_by: &'static [&'static str],
31}
32
33macro_rules! builtin_codes {
34    (
35        $kind:ident, $registry:ident, $definitions:ident, $error:ident, $registry_doc:literal;
36        $($name:ident => $value:literal,
37            meaning = $meaning:literal,
38            emitted_by = [$($emitter:literal),+ $(,)?]),+ $(,)?
39    ) => {
40        impl $kind {
41            $(#[doc = $meaning] pub const $name: Self = Self::from_static($value);)+
42        }
43
44        #[doc = $registry_doc]
45        pub const $registry: &[$kind] = &[$($kind::$name),+];
46
47        const $definitions: &[BuiltinEvidenceCode] = &[
48            $(BuiltinEvidenceCode {
49                code: $value,
50                meaning: $meaning,
51                emitted_by: &[$($emitter),+],
52            }),+
53        ];
54
55        impl $kind {
56            #[allow(dead_code)]
57            fn builtin_definition(&self) -> Option<&'static BuiltinEvidenceCode> {
58                $definitions
59                    .iter()
60                    .find(|definition| definition.code == self.as_str())
61            }
62
63            #[allow(dead_code)]
64            fn validate_emitter(&self, check_id: &'static str) -> Result<(), EvaluationError> {
65                if self
66                    .builtin_definition()
67                    .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
68                {
69                    return Err(EvaluationError::$error {
70                        check_id,
71                        code: self.clone(),
72                    });
73                }
74                Ok(())
75            }
76        }
77    };
78}
79
80/// Whether a check was selected for this invocation.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum SelectionState {
84    /// Selected explicitly or through the default full catalog.
85    Selected,
86    /// Omitted by an explicit selection.
87    Unselected,
88}
89
90/// Whether configuration enabled the check.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ConfigurationState {
94    /// The check is enabled.
95    Enabled,
96    /// The check is opt-in without an enabling severity, or
97    /// `severity = "off"` disabled it.
98    Disabled,
99}
100
101/// Whether a check applies to the supplied document and declarations.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum Applicability {
105    /// The check has work for this document/configuration.
106    Applicable,
107    /// The check has no work for this document/configuration.
108    NotApplicable,
109}
110
111/// How much applicable work was evaluated.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum EvaluationState {
115    /// All modelled work completed.
116    Complete,
117    /// Some modelled work completed and some has a typed coverage gap.
118    Partial,
119    /// No applicable work was evaluated.
120    NotEvaluated,
121}
122
123/// Stable machine code for a unit of evaluated or missing work.
124///
125/// Built-in codes are constants. Custom checks may construct namespaced codes
126/// without forcing animsmith's built-in registry to become a closed enum.
127#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
128#[serde(transparent)]
129pub struct EvaluationScopeCode(Cow<'static, str>);
130
131builtin_codes!(
132    EvaluationScopeCode,
133    BUILTIN_EVALUATION_SCOPE_CODES,
134    BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
135    BuiltinEvaluationScopeEmitterMismatch,
136    "Complete built-in evaluation-scope vocabulary for consumers that need to inspect or allow-list animsmith's catalog codes. Custom checks may use additional namespaced codes.";
137    FIRST_FRAME_REST_DELTA => "first_frame_rest_delta",
138        meaning = "The named clip's first-frame/rest-pose rotation evidence was evaluated.",
139        emitted_by = ["bind-pose"],
140    LOOP_CLOSURE => "loop_closure",
141        meaning = "One named clip's per-bone model-space pose closure was measured.",
142        emitted_by = ["loop-closure"],
143    DUPLICATE_LOOP_ENDPOINT => "duplicate_loop_endpoint",
144        meaning = "One named clip's authored tracks were analyzed for redundant closing endpoint keys.",
145        emitted_by = ["duplicate-loop-endpoint"],
146    LOOP_SEAM => "loop_seam",
147        meaning = "One named clip's positional loop seam was measured.",
148        emitted_by = ["loop-seam"],
149    LOOP_SEAM_VELOCITY => "loop_seam_velocity",
150        meaning = "One named clip's per-bone model-space seam velocity continuity was measured.",
151        emitted_by = ["loop-seam-vel"],
152    LOOP_SEAM_ROTATION => "loop_seam_rotation",
153        meaning = "One named clip's per-bone model-space angular seam velocity continuity was measured.",
154        emitted_by = ["loop-seam-rot"],
155    FOOT_STANCE => "foot_stance",
156        meaning = "Whole-clip prerequisites for stance analysis were evaluated.",
157        emitted_by = ["foot-slide"],
158    LEFT_FOOT_STANCE => "left_foot_stance",
159        meaning = "The named clip's left foot/toe stance was evaluated.",
160        emitted_by = ["foot-slide"],
161    RIGHT_FOOT_STANCE => "right_foot_stance",
162        meaning = "The named clip's right foot/toe stance was evaluated.",
163        emitted_by = ["foot-slide"],
164    ROOT_MOTION_SPEED => "root_motion_speed",
165        meaning = "One named clip's root-motion speed was measured.",
166        emitted_by = ["root-motion-speed"],
167    MEMBER_EXISTENCE => "member_existence",
168        meaning = "Configured group members were checked for existence.",
169        emitted_by = ["gait-group", "sync-group", "time-complement"],
170    PHASE_MEASUREMENT => "phase_measurement",
171        meaning = "One named clip's gait phase was measured or lacked usable evidence.",
172        emitted_by = ["gait-group", "time-complement"],
173    PHASE_COHERENCE => "phase_coherence",
174        meaning = "One named group's measurable gait phases were compared.",
175        emitted_by = ["gait-group", "time-complement"],
176    SYNC_MEMBER_MEASUREMENT => "sync_member_measurement",
177        meaning = "One named same-time sync-group member's timing evidence was measured.",
178        emitted_by = ["sync-group"],
179    SYNC_COMPATIBILITY => "sync_compatibility",
180        meaning = "One named same-time sync group had compatible member timing evidence compared.",
181        emitted_by = ["sync-group"],
182    TRAVEL_MODE => "travel_mode",
183        meaning = "One named clip's XZ movement-owner declaration was judged.",
184        emitted_by = ["in-place"],
185    FRAME_GRID => "frame_grid",
186        meaning = "The named clip's declared frame grid was evaluated.",
187        emitted_by = ["fps"],
188    REQUIRED_BONE_PRESENCE => "required_bone_presence",
189        meaning = "Configured structural skeleton-bone presence requirements were evaluated.",
190        emitted_by = ["required-bones"],
191    SELECTED_NODE_REST_SCALE => "selected_node_rest_scale",
192        meaning = "One configured source-node selector resolved and its effective rest-world linear scale was evaluated.",
193        emitted_by = ["rest-world-scale"],
194    ANIMATION_ASSET_LABEL => "animation_asset_label",
195        meaning = "One source animation index was projected to the selected engine profile's canonical asset-label selector.",
196        emitted_by = ["engine-addressability"],
197    ANIMATION_ASSET_LABEL_INVENTORY => "animation_asset_label_inventory",
198        meaning = "Complete source-animation inventory required for asset-label prediction was unavailable.",
199        emitted_by = ["engine-addressability"],
200);
201
202// Private evidence-vocabulary authority for built-in emitters implemented
203// outside core. Owning crates publish their runnable check catalogs.
204#[cfg(test)]
205const EXTERNAL_BUILTIN_CHECK_IDS: &[&str] = &["engine-addressability"];
206
207impl EvaluationScopeCode {
208    const fn from_static(code: &'static str) -> Self {
209        Self(Cow::Borrowed(code))
210    }
211
212    /// Construct a stable custom scope code.
213    ///
214    /// Custom checks should use a namespaced value such as `acme:reference`.
215    pub const fn custom(code: &'static str) -> Self {
216        Self(Cow::Borrowed(code))
217    }
218
219    /// Return the serialized snake-case or namespaced code.
220    pub fn as_str(&self) -> &str {
221        &self.0
222    }
223}
224
225impl fmt::Display for EvaluationScopeCode {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        f.write_str(&self.0)
228    }
229}
230
231/// A stable identifier for work that completed or could not be evaluated.
232#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct EvaluationScope {
235    /// Consumer-neutral work-unit code such as `member_existence`.
236    pub code: EvaluationScopeCode,
237    /// Optional subject within the check, such as a group or clip name.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub subject: Option<String>,
240}
241
242impl EvaluationScope {
243    /// Construct a whole-check scope.
244    pub fn new(code: EvaluationScopeCode) -> Self {
245        Self {
246            code,
247            subject: None,
248        }
249    }
250
251    /// Attach a subject identifier.
252    pub fn subject(mut self, subject: impl Into<String>) -> Self {
253        self.subject = Some(subject.into());
254        self
255    }
256}
257
258/// Stable machine code for an evaluation-coverage gap.
259///
260/// Built-in codes are constants. Custom checks may construct their own code;
261/// embedders should namespace custom values so they cannot collide with future
262/// built-ins.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
264#[serde(transparent)]
265pub struct CoverageGapCode(&'static str);
266
267builtin_codes!(
268    CoverageGapCode,
269    BUILTIN_COVERAGE_GAP_CODES,
270    BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS,
271    BuiltinCoverageGapEmitterMismatch,
272    "Complete built-in coverage-gap vocabulary for consumers that need to inspect or allow-list animsmith's catalog codes. Custom checks may use additional namespaced codes.";
273    ROLES_UNRESOLVED => "roles_unresolved",
274        meaning = "Required semantic rig roles were not resolved.",
275        emitted_by = ["loop-seam", "root-motion-speed", "in-place", "foot-slide", "gait-group", "time-complement"],
276    MEASUREMENT_UNAVAILABLE => "measurement_unavailable",
277        meaning = "A required numeric measurement could not be produced or did not meet its evidence floor.",
278        emitted_by = ["loop-closure", "duplicate-loop-endpoint", "loop-seam", "loop-seam-vel", "loop-seam-rot", "root-motion-speed", "in-place", "foot-slide", "gait-group", "sync-group", "time-complement", "rest-world-scale"],
279    SKELETON_UNAVAILABLE => "skeleton_unavailable",
280        meaning = "Required skeleton presence work could not run because the file has no usable skeleton.",
281        emitted_by = ["required-bones"],
282    NODE_SELECTOR_NO_MATCH => "node_selector_no_match",
283        meaning = "A configured source-node selector matched no named source node.",
284        emitted_by = ["rest-world-scale"],
285    NODE_SELECTOR_AMBIGUOUS => "node_selector_ambiguous",
286        meaning = "A configured source-node selector matched more than one named source node.",
287        emitted_by = ["rest-world-scale"],
288    INSUFFICIENT_MEASURABLE_MEMBERS => "insufficient_measurable_members",
289        meaning = "Fewer than two configured group members produced usable comparison evidence.",
290        emitted_by = ["gait-group", "sync-group", "time-complement"],
291    MEMBERS_NOT_EVALUATED => "members_not_evaluated",
292        meaning = "Some configured group members did not produce usable comparison evidence.",
293        emitted_by = ["gait-group", "sync-group", "time-complement"],
294    INVALID_DECLARED_FPS => "invalid_declared_fps",
295        meaning = "A declared frame rate was zero, negative, or non-finite.",
296        emitted_by = ["fps"],
297    SYNC_FRAME_GRID_UNAVAILABLE => "sync_frame_grid_unavailable",
298        meaning = "A same-time sync-group member lacks usable declared frame-grid evidence.",
299        emitted_by = ["sync-group"],
300    INSUFFICIENT_ROTATION_EVIDENCE => "insufficient_rotation_evidence",
301        meaning = "Too few usable rotation tracks existed for a bind-pose comparison.",
302        emitted_by = ["bind-pose"],
303);
304
305impl CoverageGapCode {
306    const fn from_static(code: &'static str) -> Self {
307        Self(code)
308    }
309
310    /// Construct a stable custom code.
311    ///
312    /// Custom checks should use a namespaced value such as
313    /// `acme:reference_unavailable`.
314    pub const fn custom(code: &'static str) -> Self {
315        Self(code)
316    }
317
318    /// Return the serialized snake-case or namespaced code.
319    pub const fn as_str(self) -> &'static str {
320        self.0
321    }
322}
323
324impl fmt::Display for CoverageGapCode {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        f.write_str(self.0)
327    }
328}
329
330/// A typed reason applicable work could not be evaluated.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
332pub struct CoverageGap {
333    /// Stable machine code. Automation must not parse [`CoverageGap::message`].
334    pub code: CoverageGapCode,
335    /// Human-readable display text.
336    pub message: String,
337    /// Optional work scope affected by the gap.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub scope: Option<EvaluationScope>,
340}
341
342impl CoverageGap {
343    /// Construct a whole-check coverage gap.
344    pub fn new(code: CoverageGapCode, message: impl Into<String>) -> Self {
345        Self {
346            code,
347            message: message.into(),
348            scope: None,
349        }
350    }
351
352    /// Attach the affected work scope.
353    pub fn scope(mut self, scope: EvaluationScope) -> Self {
354        self.scope = Some(scope);
355        self
356    }
357}
358
359/// Output evidence from one selected, enabled, applicable check.
360///
361/// The shared evaluation boundary derives coverage from completed scopes and
362/// gaps and reports malformed evidence through [`EvaluationError`].
363#[derive(Debug, Clone)]
364pub struct CheckOutput {
365    findings: Vec<Finding>,
366    evaluated_scopes: Vec<EvaluationScope>,
367    gaps: Vec<CoverageGap>,
368    engine_prediction: Option<EnginePredictionV1>,
369}
370
371impl CheckOutput {
372    /// Collect findings, completed scopes, and coverage gaps through the one
373    /// check-output construction path.
374    ///
375    /// Classification and validation happen when [`evaluate_checks`] projects
376    /// this evidence into a [`CheckEvaluation`].
377    pub fn from_coverage(
378        findings: Vec<Finding>,
379        evaluated_scopes: Vec<EvaluationScope>,
380        gaps: Vec<CoverageGap>,
381    ) -> Self {
382        Self {
383            findings,
384            evaluated_scopes,
385            gaps,
386            engine_prediction: None,
387        }
388    }
389
390    /// Attach one engine-prediction record to this check output.
391    ///
392    /// The shared evaluation boundary validates facet/scope/finding
393    /// relationships after it knows the authoritative parent check id.
394    pub fn with_engine_prediction(mut self, prediction: EnginePredictionV1) -> Self {
395        self.engine_prediction = Some(prediction);
396        self
397    }
398
399    /// Content findings emitted by evaluated work.
400    pub fn findings(&self) -> &[Finding] {
401        &self.findings
402    }
403
404    /// Stable identifiers for work that completed.
405    pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
406        &self.evaluated_scopes
407    }
408
409    /// Typed reasons work did not complete.
410    pub fn gaps(&self) -> &[CoverageGap] {
411        &self.gaps
412    }
413
414    /// Engine-prediction facets emitted by this check, when any.
415    pub const fn engine_prediction(&self) -> Option<&EnginePredictionV1> {
416        self.engine_prediction.as_ref()
417    }
418
419    fn has_missing_work(&self) -> bool {
420        !self.gaps.is_empty()
421            || self
422                .engine_prediction
423                .as_ref()
424                .is_some_and(EnginePredictionV1::has_required_unavailable)
425    }
426}
427
428/// Borrowed coverage-gap fields used by the shared producer/readback
429/// evaluation validator.
430pub(crate) struct CheckEvaluationGapRef<'a> {
431    pub(crate) code: &'a str,
432    pub(crate) scope: Option<&'a EvaluationScope>,
433}
434
435pub(crate) struct CheckEvaluationValidationInput<'a> {
436    pub(crate) check_id: &'a str,
437    pub(crate) selection: SelectionState,
438    pub(crate) configuration: ConfigurationState,
439    pub(crate) applicability: Applicability,
440    pub(crate) finding_check_ids: &'a [&'a str],
441    pub(crate) evaluated_scopes: &'a [EvaluationScope],
442    pub(crate) gaps: &'a [CheckEvaluationGapRef<'a>],
443    pub(crate) prediction_scopes: &'a [&'a EvaluationScope],
444    pub(crate) has_prediction: bool,
445    pub(crate) prediction_has_required_unavailable: bool,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub(crate) enum CheckEvaluationValidationError {
450    InvalidCheckId,
451    InvalidOutput(&'static str),
452    FindingCheckIdMismatch { finding_index: usize },
453    EvaluatedScopeEmitterMismatch { scope_index: usize },
454    GapScopeEmitterMismatch { gap_index: usize },
455    GapEmitterMismatch { gap_index: usize },
456    PredictionScopeEmitterMismatch { facet_index: usize },
457}
458
459impl CheckEvaluationValidationError {
460    pub(crate) const fn reason(self) -> &'static str {
461        match self {
462            Self::InvalidCheckId => "check id cannot be empty",
463            Self::InvalidOutput(reason) => reason,
464            Self::FindingCheckIdMismatch { .. } => "finding check_id must match its parent check",
465            Self::EvaluatedScopeEmitterMismatch { .. } => {
466                "evaluated scope code is invalid for its parent check"
467            }
468            Self::GapScopeEmitterMismatch { .. } => {
469                "coverage gap scope code is invalid for its parent check"
470            }
471            Self::GapEmitterMismatch { .. } => "coverage gap code is invalid for its parent check",
472            Self::PredictionScopeEmitterMismatch { .. } => {
473                "prediction facet scope code is invalid for its parent check"
474            }
475        }
476    }
477}
478
479/// Validate the sole check-evaluation lifecycle and derive its serialized
480/// coverage state. Both producer construction and output-v10 readback use this
481/// authority.
482pub(crate) fn validate_and_derive_check_evaluation(
483    input: CheckEvaluationValidationInput<'_>,
484) -> Result<EvaluationState, CheckEvaluationValidationError> {
485    let CheckEvaluationValidationInput {
486        check_id,
487        selection,
488        configuration,
489        applicability,
490        finding_check_ids,
491        evaluated_scopes,
492        gaps,
493        prediction_scopes,
494        has_prediction,
495        prediction_has_required_unavailable,
496    } = input;
497    if check_id.is_empty() {
498        return Err(CheckEvaluationValidationError::InvalidCheckId);
499    }
500    if let Some(finding_index) = finding_check_ids
501        .iter()
502        .position(|finding_check_id| *finding_check_id != check_id)
503    {
504        return Err(CheckEvaluationValidationError::FindingCheckIdMismatch { finding_index });
505    }
506    for (scope_index, scope) in evaluated_scopes.iter().enumerate() {
507        if scope.code.as_str().is_empty()
508            || scope
509                .code
510                .builtin_definition()
511                .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
512        {
513            return Err(
514                CheckEvaluationValidationError::EvaluatedScopeEmitterMismatch { scope_index },
515            );
516        }
517    }
518    for (gap_index, gap) in gaps.iter().enumerate() {
519        if gap.code.is_empty()
520            || BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS
521                .iter()
522                .find(|definition| definition.code == gap.code)
523                .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
524        {
525            return Err(CheckEvaluationValidationError::GapEmitterMismatch { gap_index });
526        }
527        if gap.scope.is_some_and(|scope| {
528            scope.code.as_str().is_empty()
529                || scope
530                    .code
531                    .builtin_definition()
532                    .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
533        }) {
534            return Err(CheckEvaluationValidationError::GapScopeEmitterMismatch { gap_index });
535        }
536    }
537    for (facet_index, scope) in prediction_scopes.iter().enumerate() {
538        if scope.code.as_str().is_empty()
539            || scope
540                .code
541                .builtin_definition()
542                .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
543        {
544            return Err(
545                CheckEvaluationValidationError::PredictionScopeEmitterMismatch { facet_index },
546            );
547        }
548    }
549
550    let inactive = selection == SelectionState::Unselected
551        || configuration == ConfigurationState::Disabled
552        || applicability == Applicability::NotApplicable;
553    if inactive {
554        if !finding_check_ids.is_empty()
555            || !evaluated_scopes.is_empty()
556            || !gaps.is_empty()
557            || has_prediction
558        {
559            return Err(CheckEvaluationValidationError::InvalidOutput(
560                "inactive check must have empty output",
561            ));
562        }
563        return Ok(EvaluationState::NotEvaluated);
564    }
565
566    let missing = !gaps.is_empty() || prediction_has_required_unavailable;
567    let derived = if !missing {
568        EvaluationState::Complete
569    } else if evaluated_scopes.is_empty() {
570        EvaluationState::NotEvaluated
571    } else {
572        EvaluationState::Partial
573    };
574    if derived == EvaluationState::NotEvaluated && !finding_check_ids.is_empty() {
575        return Err(CheckEvaluationValidationError::InvalidOutput(
576            "not-evaluated output cannot carry content findings",
577        ));
578    }
579    Ok(derived)
580}
581
582/// Final output-v10 record for one catalog check.
583#[derive(Debug, Clone)]
584pub struct CheckEvaluation {
585    check_id: &'static str,
586    selection: SelectionState,
587    configuration: ConfigurationState,
588    applicability: Applicability,
589    output: CheckOutput,
590}
591
592impl CheckEvaluation {
593    /// Construct a selected, enabled, applicable evaluation from validated
594    /// check output.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error for an empty check id, malformed coverage codes,
599    /// built-in evidence emitted by an undeclared check, or when a nested
600    /// finding names a different check.
601    pub fn evaluated(check_id: &'static str, output: CheckOutput) -> Result<Self, EvaluationError> {
602        let gap_refs = output
603            .gaps
604            .iter()
605            .map(|gap| CheckEvaluationGapRef {
606                code: gap.code.as_str(),
607                scope: gap.scope.as_ref(),
608            })
609            .collect::<Vec<_>>();
610        let finding_check_ids = output
611            .findings
612            .iter()
613            .map(|finding| finding.check_id)
614            .collect::<Vec<_>>();
615        let prediction_scopes = output
616            .engine_prediction
617            .as_ref()
618            .into_iter()
619            .flat_map(EnginePredictionV1::facets)
620            .map(|facet| facet.scope())
621            .collect::<Vec<_>>();
622        validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
623            check_id,
624            selection: SelectionState::Selected,
625            configuration: ConfigurationState::Enabled,
626            applicability: Applicability::Applicable,
627            finding_check_ids: &finding_check_ids,
628            evaluated_scopes: &output.evaluated_scopes,
629            gaps: &gap_refs,
630            prediction_scopes: &prediction_scopes,
631            has_prediction: output.engine_prediction.is_some(),
632            prediction_has_required_unavailable: output.has_missing_work()
633                && output.gaps.is_empty(),
634        })
635        .map_err(|error| match error {
636            CheckEvaluationValidationError::InvalidCheckId => {
637                EvaluationError::InvalidCheckId(check_id)
638            }
639            CheckEvaluationValidationError::InvalidOutput(reason) => {
640                EvaluationError::InvalidCheckOutput { check_id, reason }
641            }
642            CheckEvaluationValidationError::FindingCheckIdMismatch { finding_index } => {
643                EvaluationError::FindingCheckIdMismatch {
644                    check_id,
645                    finding_check_id: output.findings[finding_index].check_id,
646                }
647            }
648            CheckEvaluationValidationError::EvaluatedScopeEmitterMismatch { scope_index } => {
649                let code = output.evaluated_scopes[scope_index].code.clone();
650                if code.as_str().is_empty() {
651                    EvaluationError::InvalidCheckOutput {
652                        check_id,
653                        reason: "evaluated scope code cannot be empty",
654                    }
655                } else {
656                    EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
657                }
658            }
659            CheckEvaluationValidationError::GapScopeEmitterMismatch { gap_index } => {
660                let code = output.gaps[gap_index]
661                    .scope
662                    .as_ref()
663                    .expect("the validator reports only present gap scopes")
664                    .code
665                    .clone();
666                if code.as_str().is_empty() {
667                    EvaluationError::InvalidCheckOutput {
668                        check_id,
669                        reason: "coverage gap scope code cannot be empty",
670                    }
671                } else {
672                    EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
673                }
674            }
675            CheckEvaluationValidationError::GapEmitterMismatch { gap_index } => {
676                let code = output.gaps[gap_index].code;
677                if code.as_str().is_empty() {
678                    EvaluationError::InvalidCheckOutput {
679                        check_id,
680                        reason: "coverage gap code cannot be empty",
681                    }
682                } else {
683                    EvaluationError::BuiltinCoverageGapEmitterMismatch { check_id, code }
684                }
685            }
686            CheckEvaluationValidationError::PredictionScopeEmitterMismatch { facet_index } => {
687                let code = output
688                    .engine_prediction
689                    .as_ref()
690                    .expect("the validator reports only present prediction scopes")
691                    .facets()[facet_index]
692                    .scope()
693                    .code
694                    .clone();
695                if code.as_str().is_empty() {
696                    EvaluationError::InvalidCheckOutput {
697                        check_id,
698                        reason: "prediction facet scope code cannot be empty",
699                    }
700                } else {
701                    EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
702                }
703            }
704        })?;
705        if let Some(prediction) = &output.engine_prediction {
706            prediction.validate_for_check(
707                check_id,
708                &output.evaluated_scopes,
709                &output.gaps,
710                &output.findings,
711            )?;
712        } else if output
713            .findings
714            .iter()
715            .any(|finding| finding.prediction_scope.is_some())
716        {
717            return Err(EvaluationError::InvalidCheckOutput {
718                check_id,
719                reason: "finding has prediction_scope without an engine prediction",
720            });
721        }
722        Ok(Self {
723            check_id,
724            selection: SelectionState::Selected,
725            configuration: ConfigurationState::Enabled,
726            applicability: Applicability::Applicable,
727            output,
728        })
729    }
730
731    /// Stable check id.
732    pub fn check_id(&self) -> &'static str {
733        self.check_id
734    }
735
736    /// Invocation selection state.
737    pub fn selection(&self) -> SelectionState {
738        self.selection
739    }
740
741    /// Configuration activation state.
742    pub fn configuration(&self) -> ConfigurationState {
743        self.configuration
744    }
745
746    /// Applicability to this document/configuration.
747    pub fn applicability(&self) -> Applicability {
748        self.applicability
749    }
750
751    /// Derive evaluation coverage from activation, completed scopes, ordinary
752    /// gaps, and required-unavailable prediction facets.
753    pub fn evaluation(&self) -> EvaluationState {
754        if self.selection == SelectionState::Unselected
755            || self.configuration == ConfigurationState::Disabled
756            || self.applicability == Applicability::NotApplicable
757        {
758            EvaluationState::NotEvaluated
759        } else if !self.output.has_missing_work() {
760            EvaluationState::Complete
761        } else if self.output.evaluated_scopes.is_empty() {
762            EvaluationState::NotEvaluated
763        } else {
764            EvaluationState::Partial
765        }
766    }
767
768    /// Content findings emitted by evaluated work.
769    pub fn findings(&self) -> &[Finding] {
770        self.output.findings()
771    }
772
773    /// Stable identifiers for work that completed.
774    pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
775        self.output.evaluated_scopes()
776    }
777
778    /// Typed reasons work was not evaluated.
779    pub fn gaps(&self) -> &[CoverageGap] {
780        self.output.gaps()
781    }
782
783    /// Engine-prediction facets attached to this check, when any.
784    pub const fn engine_prediction(&self) -> Option<&EnginePredictionV1> {
785        self.output.engine_prediction()
786    }
787
788    fn inactive(
789        check_id: &'static str,
790        selection: SelectionState,
791        configuration: ConfigurationState,
792        applicability: Applicability,
793    ) -> Self {
794        debug_assert!(
795            selection == SelectionState::Unselected
796                || configuration == ConfigurationState::Disabled
797                || applicability == Applicability::NotApplicable
798        );
799        Self {
800            check_id,
801            selection,
802            configuration,
803            applicability,
804            output: CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
805        }
806    }
807
808    fn override_severity(&mut self, severity: SeveritySetting) {
809        if let Some(severity) = severity.as_severity() {
810            for finding in &mut self.output.findings {
811                finding.severity = severity;
812            }
813        }
814    }
815}
816
817impl Serialize for CheckEvaluation {
818    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
819    where
820        S: Serializer,
821    {
822        let mut fields = 6;
823        fields += usize::from(!self.output.evaluated_scopes.is_empty());
824        fields += usize::from(!self.output.gaps.is_empty());
825        fields += usize::from(self.output.engine_prediction.is_some());
826        let mut state = serializer.serialize_struct("CheckEvaluation", fields)?;
827        state.serialize_field("check_id", &self.check_id)?;
828        state.serialize_field("selection", &self.selection)?;
829        state.serialize_field("configuration", &self.configuration)?;
830        state.serialize_field("applicability", &self.applicability)?;
831        state.serialize_field("evaluation", &self.evaluation())?;
832        state.serialize_field("findings", &self.output.findings)?;
833        if !self.output.evaluated_scopes.is_empty() {
834            state.serialize_field("evaluated_scopes", &self.output.evaluated_scopes)?;
835        }
836        if !self.output.gaps.is_empty() {
837            state.serialize_field("gaps", &self.output.gaps)?;
838        }
839        if let Some(prediction) = &self.output.engine_prediction {
840            state.serialize_field("prediction", prediction)?;
841        }
842        state.end()
843    }
844}
845
846/// Catalog-selection policy for [`evaluate_checks`].
847#[derive(Debug, Clone, Copy)]
848pub enum CheckSelection<'a> {
849    /// Select the whole supplied catalog.
850    All,
851    /// Select only the named ids.
852    Only(&'a BTreeSet<String>),
853}
854
855impl CheckSelection<'_> {
856    fn contains(self, id: &str) -> bool {
857        match self {
858            Self::All => true,
859            Self::Only(ids) => ids.contains(id),
860        }
861    }
862}
863
864/// Invalid catalog or check output supplied to [`evaluate_checks`].
865#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
866#[non_exhaustive]
867pub enum EvaluationError {
868    /// The supplied programmatic configuration contains an invalid value.
869    #[error("invalid configuration: {0}")]
870    InvalidConfiguration(#[from] ConfigValidationError),
871    /// Engine-prediction evidence violates its wire or lifecycle contract.
872    #[error("invalid engine prediction: {0}")]
873    InvalidPrediction(#[from] PredictionContractError),
874    /// A catalog or directly constructed check record used an empty id.
875    #[error("check id cannot be empty")]
876    InvalidCheckId(&'static str),
877    /// Two catalog entries used the same stable check id.
878    #[error("duplicate check id {0:?}")]
879    DuplicateCheckId(&'static str),
880    /// Explicit selection named an id absent from the supplied catalog.
881    #[error("unknown selected check id {0:?}")]
882    UnknownSelection(String),
883    /// Check evidence violates the derived coverage-state invariants.
884    #[error("check {check_id:?} emitted invalid output: {reason}")]
885    InvalidCheckOutput {
886        /// Stable id of the check that emitted malformed evidence.
887        check_id: &'static str,
888        /// Contract rule violated by the output.
889        reason: &'static str,
890    },
891    /// A nested finding claimed a different check id than its parent record.
892    #[error("check {check_id:?} emitted a finding for {finding_check_id:?}")]
893    FindingCheckIdMismatch {
894        /// Parent check id.
895        check_id: &'static str,
896        /// Mismatched nested finding id.
897        finding_check_id: &'static str,
898    },
899    /// A built-in completed or missing-work scope came from an undeclared check.
900    #[error("check {check_id:?} cannot emit built-in evaluation scope {code}")]
901    BuiltinEvaluationScopeEmitterMismatch {
902        /// Stable id of the check that emitted the scope.
903        check_id: &'static str,
904        /// Built-in scope code that does not declare this emitter.
905        code: EvaluationScopeCode,
906    },
907    /// A built-in coverage-gap code came from an undeclared check.
908    #[error("check {check_id:?} cannot emit built-in coverage gap {code}")]
909    BuiltinCoverageGapEmitterMismatch {
910        /// Stable id of the check that emitted the gap.
911        check_id: &'static str,
912        /// Built-in gap code that does not declare this emitter.
913        code: CoverageGapCode,
914    },
915}
916
917/// Apply the shared lint gate policy to validated check records.
918///
919/// Allowed check ids suppress content findings only. Required-unavailable
920/// prediction facets always block, independent of severity or allow policy.
921pub fn lint_requires_failure(
922    checks: &[CheckEvaluation],
923    fail_at: crate::finding::Severity,
924    allowed_content_check_ids: &BTreeSet<String>,
925) -> bool {
926    checks.iter().any(|check| {
927        check
928            .engine_prediction()
929            .is_some_and(EnginePredictionV1::has_required_unavailable)
930            || check.findings().iter().any(|finding| {
931                finding.severity >= fail_at && !allowed_content_check_ids.contains(finding.check_id)
932            })
933    })
934}
935
936/// Evaluate a full catalog into one record per check.
937///
938/// Selection and configuration are recorded independently. A check can be
939/// disabled by `severity = "off"` or by its opt-in default; an explicit
940/// `note`, `warn`, or `error` severity enables an opt-in check. Inactive checks
941/// never evaluate, but their cheap applicability predicate still establishes
942/// whether declared work exists. Coverage gaps are nonblocking evidence;
943/// callers own any stricter policy.
944///
945/// # Errors
946///
947/// Returns an error for invalid directly constructed configuration, empty or
948/// duplicate catalog ids, unknown explicitly selected ids, malformed coverage
949/// evidence, built-in evidence emitted by an undeclared check, or a nested
950/// finding whose id disagrees with its parent check. Configuration validation
951/// runs before catalog inspection or check evaluation.
952pub fn evaluate_checks(
953    ctx: &CheckCtx<'_>,
954    checks: &[Box<dyn Check + '_>],
955    selection: CheckSelection<'_>,
956) -> Result<Vec<CheckEvaluation>, EvaluationError> {
957    ctx.config.validate()?;
958
959    let mut catalog_ids = BTreeSet::new();
960    for check in checks {
961        if check.id().is_empty() {
962            return Err(EvaluationError::InvalidCheckId(check.id()));
963        }
964        if !catalog_ids.insert(check.id()) {
965            return Err(EvaluationError::DuplicateCheckId(check.id()));
966        }
967    }
968    if let CheckSelection::Only(selected) = selection
969        && let Some(unknown) = selected
970            .iter()
971            .find(|id| !catalog_ids.contains(id.as_str()))
972    {
973        return Err(EvaluationError::UnknownSelection(unknown.clone()));
974    }
975
976    let mut records = Vec::with_capacity(checks.len());
977    for check in checks {
978        let selection_state = if selection.contains(check.id()) {
979            SelectionState::Selected
980        } else {
981            SelectionState::Unselected
982        };
983        let setting = ctx.config.check_settings(check.id()).severity;
984        let configuration = match setting {
985            Some(SeveritySetting::Off) => ConfigurationState::Disabled,
986            Some(_) => ConfigurationState::Enabled,
987            None if check.enabled_by_default() => ConfigurationState::Enabled,
988            None => ConfigurationState::Disabled,
989        };
990        let applicability = check.applicability(ctx);
991
992        if selection_state == SelectionState::Unselected
993            || configuration == ConfigurationState::Disabled
994            || applicability == Applicability::NotApplicable
995        {
996            records.push(CheckEvaluation::inactive(
997                check.id(),
998                selection_state,
999                configuration,
1000                applicability,
1001            ));
1002            continue;
1003        }
1004
1005        let mut evaluation = CheckEvaluation::evaluated(check.id(), check.evaluate(ctx))?;
1006        if let Some(setting) = setting {
1007            evaluation.override_severity(setting);
1008        }
1009        records.push(evaluation);
1010    }
1011    Ok(records)
1012}
1013
1014#[cfg(test)]
1015mod authority_contract {
1016    use std::collections::BTreeSet;
1017    use std::path::{Path, PathBuf};
1018
1019    use super::{
1020        BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS, BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1021        BuiltinEvidenceCode, CheckEvaluation, CheckOutput, CoverageGap, CoverageGapCode,
1022        EXTERNAL_BUILTIN_CHECK_IDS, EvaluationError, EvaluationScope, EvaluationScopeCode,
1023        EvaluationState, lint_requires_failure,
1024    };
1025    use crate::InputIdentity;
1026    use crate::finding::{Finding, Severity};
1027    use crate::prediction::{
1028        EnginePredictionBasisV1, EnginePredictionFacetV1, EnginePredictionV1,
1029        PredictionBasisReferenceV1, PredictionProvenanceIdentityV1, PredictionScalarV1,
1030        PredictionUnavailableReasonV1,
1031    };
1032
1033    fn assert_reference_table(docs: &str, heading: &str, entries: &[BuiltinEvidenceCode]) {
1034        let section = docs
1035            .split_once(heading)
1036            .unwrap_or_else(|| panic!("missing reference heading {heading:?}"))
1037            .1;
1038        let documented = section
1039            .lines()
1040            .skip_while(|line| line.trim().is_empty())
1041            .take_while(|line| !line.trim().is_empty())
1042            .filter(|line| line.starts_with("| `"))
1043            .collect::<Vec<_>>();
1044        let expected = entries
1045            .iter()
1046            .map(|definition| {
1047                let BuiltinEvidenceCode {
1048                    code,
1049                    meaning,
1050                    emitted_by,
1051                } = definition;
1052                assert!(
1053                    !meaning.trim().is_empty() && !meaning.contains(['\r', '\n']),
1054                    "{code} must have a one-line meaning"
1055                );
1056                let emitters = emitted_by
1057                    .iter()
1058                    .map(|check_id| format!("`{check_id}`"))
1059                    .collect::<Vec<_>>()
1060                    .join(", ");
1061                format!("| `{code}` | {meaning} | {emitters} |")
1062            })
1063            .collect::<Vec<_>>();
1064
1065        assert_eq!(documented.len(), expected.len(), "row count for {heading}");
1066        let documented = documented.into_iter().collect::<BTreeSet<_>>();
1067        assert_eq!(
1068            documented.len(),
1069            expected.len(),
1070            "duplicate rows for {heading}"
1071        );
1072        let expected = expected.iter().map(String::as_str).collect::<BTreeSet<_>>();
1073        assert_eq!(documented, expected, "exact rows for {heading}");
1074    }
1075
1076    #[test]
1077    fn output_docs_match_registered_builtin_evidence_codes_exactly() {
1078        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1079        let Some(workspace_root) = source_workspace_root(manifest_dir) else {
1080            // Published crates intentionally exclude repository-level docs.
1081            return;
1082        };
1083        let docs_path = workspace_root.join("docs/output.md");
1084        let docs = std::fs::read_to_string(&docs_path)
1085            .unwrap_or_else(|error| panic!("cannot read {}: {error}", docs_path.display()));
1086        let crlf = docs.lines().collect::<Vec<_>>().join("\r\n");
1087        for line_endings in [docs.as_str(), crlf.as_str()] {
1088            assert_reference_table(
1089                line_endings,
1090                "Built-in gap codes are:",
1091                BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS,
1092            );
1093            assert_reference_table(
1094                line_endings,
1095                "Built-in completed/gap scope codes are:",
1096                BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1097            );
1098        }
1099    }
1100
1101    #[test]
1102    fn builtin_evidence_authority_has_unique_codes_and_known_emitters() {
1103        let catalog_ids = crate::all_checks()
1104            .into_iter()
1105            .map(|check| check.id())
1106            .chain(EXTERNAL_BUILTIN_CHECK_IDS.iter().copied())
1107            .collect::<BTreeSet<_>>();
1108        let authorities = [
1109            ("coverage-gap", BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS),
1110            (
1111                "evaluation-scope",
1112                BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1113            ),
1114        ];
1115
1116        for (kind, definitions) in authorities {
1117            assert!(
1118                definitions
1119                    .iter()
1120                    .all(|definition| !definition.code.is_empty()),
1121                "{kind} authority codes must be nonempty"
1122            );
1123            let defined_codes = definitions
1124                .iter()
1125                .map(|definition| definition.code)
1126                .collect::<BTreeSet<_>>();
1127            assert_eq!(
1128                defined_codes.len(),
1129                definitions.len(),
1130                "duplicate {kind} authority code"
1131            );
1132            for definition in definitions {
1133                let emitters = definition
1134                    .emitted_by
1135                    .iter()
1136                    .copied()
1137                    .collect::<BTreeSet<_>>();
1138                assert_eq!(
1139                    emitters.len(),
1140                    definition.emitted_by.len(),
1141                    "{} has duplicate emitters",
1142                    definition.code
1143                );
1144                for emitter in emitters {
1145                    assert!(
1146                        catalog_ids.contains(emitter),
1147                        "{} declares unknown emitter {emitter:?}",
1148                        definition.code
1149                    );
1150                }
1151            }
1152        }
1153    }
1154
1155    #[test]
1156    fn every_builtin_code_enforces_its_emitter_matrix() {
1157        let catalog_ids = crate::all_checks()
1158            .into_iter()
1159            .map(|check| check.id())
1160            .chain(EXTERNAL_BUILTIN_CHECK_IDS.iter().copied())
1161            .collect::<Vec<_>>();
1162
1163        for definition in BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS {
1164            for &check_id in &catalog_ids {
1165                let code = EvaluationScopeCode::custom(definition.code);
1166                let completed = CheckEvaluation::evaluated(
1167                    check_id,
1168                    CheckOutput::from_coverage(
1169                        Vec::new(),
1170                        vec![EvaluationScope::new(code.clone())],
1171                        Vec::new(),
1172                    ),
1173                );
1174                let gap_scope = CheckEvaluation::evaluated(
1175                    check_id,
1176                    CheckOutput::from_coverage(
1177                        Vec::new(),
1178                        Vec::new(),
1179                        vec![
1180                            CoverageGap::new(CoverageGapCode::custom("test:gap"), "gap")
1181                                .scope(EvaluationScope::new(code.clone())),
1182                        ],
1183                    ),
1184                );
1185                if definition.emitted_by.contains(&check_id) {
1186                    assert!(
1187                        completed.is_ok(),
1188                        "{} must allow {check_id:?}",
1189                        definition.code
1190                    );
1191                    assert!(
1192                        gap_scope.is_ok(),
1193                        "{} must allow {check_id:?}",
1194                        definition.code
1195                    );
1196                } else {
1197                    let expected =
1198                        EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code };
1199                    assert_eq!(completed.unwrap_err(), expected);
1200                    assert_eq!(gap_scope.unwrap_err(), expected);
1201                }
1202            }
1203        }
1204
1205        for definition in BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS {
1206            for &check_id in &catalog_ids {
1207                let code = CoverageGapCode::custom(definition.code);
1208                let gap = CheckEvaluation::evaluated(
1209                    check_id,
1210                    CheckOutput::from_coverage(
1211                        Vec::new(),
1212                        Vec::new(),
1213                        vec![CoverageGap::new(code, "test gap")],
1214                    ),
1215                );
1216                if definition.emitted_by.contains(&check_id) {
1217                    assert!(gap.is_ok(), "{} must allow {check_id:?}", definition.code);
1218                } else {
1219                    assert_eq!(
1220                        gap.unwrap_err(),
1221                        EvaluationError::BuiltinCoverageGapEmitterMismatch { check_id, code }
1222                    );
1223                }
1224            }
1225        }
1226    }
1227
1228    #[test]
1229    fn mixed_prediction_facets_use_the_existing_evaluation_and_exit_lifecycle() {
1230        const CHECK_ID: &str = "test:engine-prediction";
1231        let code = EvaluationScopeCode::custom("test:prediction-work");
1232        let available_scope = EvaluationScope::new(code.clone()).subject("available-clip");
1233        let unavailable_scope = EvaluationScope::new(code).subject("unavailable-clip");
1234        let available_basis = EnginePredictionBasisV1::new(vec![
1235            PredictionBasisReferenceV1::project_field(
1236                "project.mode",
1237                PredictionScalarV1::token("generic").unwrap(),
1238            )
1239            .unwrap(),
1240        ])
1241        .unwrap();
1242        let unavailable_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
1243        let prediction = EnginePredictionV1::new(
1244            PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1245                b"profile",
1246            )),
1247            vec![
1248                EnginePredictionFacetV1::available(available_scope.clone(), available_basis)
1249                    .unwrap(),
1250                EnginePredictionFacetV1::required_unavailable(
1251                    unavailable_scope,
1252                    unavailable_basis,
1253                    vec![PredictionUnavailableReasonV1::MeasurementUnavailable],
1254                )
1255                .unwrap(),
1256            ],
1257        )
1258        .unwrap();
1259        let finding = Finding::new(CHECK_ID, Severity::Note, "available subject finding")
1260            .prediction_scope(available_scope.clone());
1261        let check = CheckEvaluation::evaluated(
1262            CHECK_ID,
1263            CheckOutput::from_coverage(vec![finding], vec![available_scope], Vec::new())
1264                .with_engine_prediction(prediction),
1265        )
1266        .unwrap();
1267
1268        assert_eq!(check.evaluation(), EvaluationState::Partial);
1269        assert!(lint_requires_failure(
1270            &[check],
1271            Severity::Error,
1272            &BTreeSet::from([CHECK_ID.to_owned()]),
1273        ));
1274    }
1275
1276    #[test]
1277    fn available_only_prediction_does_not_fail_without_a_threshold_finding() {
1278        const CHECK_ID: &str = "test:available-prediction";
1279        let scope = EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-work"));
1280        let basis = EnginePredictionBasisV1::new(vec![
1281            PredictionBasisReferenceV1::project_field(
1282                "project.mode",
1283                PredictionScalarV1::token("generic").unwrap(),
1284            )
1285            .unwrap(),
1286        ])
1287        .unwrap();
1288        let prediction = EnginePredictionV1::new(
1289            PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1290                b"profile",
1291            )),
1292            vec![EnginePredictionFacetV1::available(scope.clone(), basis).unwrap()],
1293        )
1294        .unwrap();
1295        let finding = Finding::new(CHECK_ID, Severity::Note, "nonblocking available finding")
1296            .prediction_scope(scope.clone());
1297        let check = CheckEvaluation::evaluated(
1298            CHECK_ID,
1299            CheckOutput::from_coverage(vec![finding], vec![scope], Vec::new())
1300                .with_engine_prediction(prediction),
1301        )
1302        .unwrap();
1303
1304        assert_eq!(check.evaluation(), EvaluationState::Complete);
1305        assert!(!lint_requires_failure(
1306            &[check],
1307            Severity::Error,
1308            &BTreeSet::new(),
1309        ));
1310    }
1311
1312    #[test]
1313    fn required_unavailable_prediction_fails_despite_allow_and_severity() {
1314        const CHECK_ID: &str = "test:unavailable-prediction";
1315        let scope = EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-work"));
1316        let prediction = EnginePredictionV1::new(
1317            PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1318                b"profile",
1319            )),
1320            vec![
1321                EnginePredictionFacetV1::required_unavailable(
1322                    scope,
1323                    EnginePredictionBasisV1::new(Vec::new()).unwrap(),
1324                    vec![PredictionUnavailableReasonV1::MeasurementUnavailable],
1325                )
1326                .unwrap(),
1327            ],
1328        )
1329        .unwrap();
1330        let check = CheckEvaluation::evaluated(
1331            CHECK_ID,
1332            CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
1333                .with_engine_prediction(prediction),
1334        )
1335        .unwrap();
1336
1337        assert_eq!(check.evaluation(), EvaluationState::NotEvaluated);
1338        assert!(lint_requires_failure(
1339            &[check],
1340            Severity::Error,
1341            &BTreeSet::from([CHECK_ID.to_owned()]),
1342        ));
1343    }
1344
1345    #[test]
1346    fn source_workspace_detection_has_a_positive_checkout_control() {
1347        let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1348        let detected = source_workspace_root(manifest_dir);
1349        if manifest_dir.join(".cargo_vcs_info.json").is_file() {
1350            assert!(detected.is_none(), "published packages must skip repo docs");
1351            return;
1352        }
1353
1354        let expected = manifest_dir.join("../..");
1355        if expected.join("docs/output.md").is_file() {
1356            assert_eq!(
1357                detected.as_deref(),
1358                Some(expected.as_path()),
1359                "the exact source checkout must enforce its output docs"
1360            );
1361        }
1362    }
1363
1364    fn source_workspace_root(manifest_dir: &Path) -> Option<PathBuf> {
1365        if manifest_dir.join(".cargo_vcs_info.json").is_file() {
1366            return None;
1367        }
1368        let workspace_root = manifest_dir.join("../..");
1369        let current_manifest = manifest_dir.join("Cargo.toml").canonicalize().ok()?;
1370        let workspace_manifest = workspace_root
1371            .join("crates/animsmith-core/Cargo.toml")
1372            .canonicalize()
1373            .ok()?;
1374        (current_manifest == workspace_manifest).then_some(workspace_root)
1375    }
1376}