Skip to main content

rustev_eval/
dynamic.rs

1//! Dynamic-question evaluation (spec 019, 3.7): offline reproduction of a
2//! retained outcome with zero backend calls, and a dedicated dataset,
3//! evaluator configuration and report for one dynamic task family.
4//!
5//! Nothing here calls a backend or reads a clock: there is no backend
6//! parameter anywhere in this module. A changed request, or a retained
7//! outcome without a raw response, is `incomparable`, never permission to
8//! ask again. Dynamic evidence never becomes a registered-plan replay
9//! bundle, and evidence for one dynamic task family, question schema,
10//! label order, template or artifact does not transfer to another.
11//!
12//! Reports state coverage and abstention first, then accepted error and
13//! class-appropriate metrics, each with its denominator; an empty
14//! denominator is `unknown`, never a pass. Distributions stay uncalibrated:
15//! this module fits no calibration and reports none. A report on synthetic
16//! data measures mechanics only, and every gate over it is `unknown`.
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::fmt;
20
21use rustev_contract::Identified;
22use rustev_contract::dynamic::{
23    DynamicAnswer, DynamicCapability, DynamicOperation, DynamicOutcome, DynamicOutputKind,
24    DynamicQuestion, DynamicRaw, DynamicResult, TaskFamily, TemplateRef,
25};
26use rustev_contract::eval_report::{DatasetProvenance, Metric, MetricValue, Split};
27use rustev_contract::ids::{
28    ArtifactId, DatasetId, DynamicCapabilityId, DynamicRequestId, EvaluatorConfigId,
29};
30use rustev_contract::limits::{RECORD_V1, REPLAY_V1};
31use rustev_core::dynamic as core;
32use serde::{Deserialize, Serialize};
33
34use crate::dataset::Label;
35use crate::doc::eval_document;
36
37pub const DYNAMIC_DATASET: &str = "rustev.dynamic-dataset/1";
38pub const DYNAMIC_EVALUATOR_CONFIG: &str = "rustev.dynamic-evaluator-config/1";
39pub const DYNAMIC_REPORT: &str = "rustev.dynamic-eval-report/1";
40
41// ---------------------------------------------------------------------------
42// Offline reproduction
43
44/// Why a retained outcome cannot be compared (3.7).
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case", deny_unknown_fields)]
47pub enum DynamicIncomparable {
48    /// The supplied request fails validation.
49    RequestInvalid { detail: String },
50    /// The canonical request identity differs from the retained one (the
51    /// question, label order, context, scope, task family, template,
52    /// preprocessing, artifact, privacy or output kind changed).
53    RequestChanged,
54    /// The capability declaration differs from the one retained.
55    CapabilityChanged,
56    /// The outcome holds no raw response: nothing was dispatched, the
57    /// adapter failed, or no answer arrived.
58    RawResponseUnavailable,
59}
60
61impl DynamicIncomparable {
62    pub fn name(&self) -> &'static str {
63        match self {
64            DynamicIncomparable::RequestInvalid { .. } => "request_invalid",
65            DynamicIncomparable::RequestChanged => "request_changed",
66            DynamicIncomparable::CapabilityChanged => "capability_changed",
67            DynamicIncomparable::RawResponseUnavailable => "raw_response_unavailable",
68        }
69    }
70}
71
72/// The result of reproducing one retained outcome.
73#[derive(Debug, Clone, PartialEq)]
74pub enum DynamicReproduction {
75    /// Validation, abstention and result are exactly as retained.
76    Reproduced,
77    /// The same raw response judges differently now.
78    Diverged {
79        detail: String,
80    },
81    Incomparable(DynamicIncomparable),
82}
83
84/// Reproduce a retained outcome from the request, the capability and the
85/// retained raw response, with zero backend calls (3.7).
86pub fn reproduce(
87    retained: &DynamicOutcome,
88    question: &DynamicQuestion,
89    capability: &DynamicCapability,
90) -> DynamicReproduction {
91    let q = match core::validate(
92        question.clone(),
93        std::slice::from_ref(&question.task_family),
94    ) {
95        Ok(q) => q,
96        Err(e) => {
97            return DynamicReproduction::Incomparable(DynamicIncomparable::RequestInvalid {
98                detail: e.to_string().chars().take(200).collect(),
99            });
100        }
101    };
102    let Ok(identity) = core::request_identity(&q, capability) else {
103        return DynamicReproduction::Incomparable(DynamicIncomparable::RequestChanged);
104    };
105    if retained.request.as_ref() != Some(&identity) {
106        return DynamicReproduction::Incomparable(DynamicIncomparable::RequestChanged);
107    }
108    if retained.capability != capability.id().ok() {
109        return DynamicReproduction::Incomparable(DynamicIncomparable::CapabilityChanged);
110    }
111    let DynamicRaw::Output(raw) = &retained.raw else {
112        return DynamicReproduction::Incomparable(DynamicIncomparable::RawResponseUnavailable);
113    };
114    let Some(attempt) = &retained.attempt else {
115        return DynamicReproduction::Incomparable(DynamicIncomparable::RawResponseUnavailable);
116    };
117    let judged = core::judge(&q, capability, &retained.served, raw);
118    let result = match judged.result {
119        Ok(answer) => DynamicResult::Proposal(core::proposal(
120            &q,
121            identity,
122            capability,
123            &attempt.attempt_id,
124            answer,
125        )),
126        Err(u) => DynamicResult::Unresolved(u),
127    };
128    let mut differs = vec![];
129    if judged.validation != retained.validation {
130        differs.push("validation");
131    }
132    if judged.abstention != retained.abstention {
133        differs.push("abstention");
134    }
135    if result != retained.result {
136        differs.push("result");
137    }
138    if differs.is_empty() {
139        DynamicReproduction::Reproduced
140    } else {
141        DynamicReproduction::Diverged {
142            detail: format!("{} differ", differs.join(", ")),
143        }
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Dataset
149
150/// The exact question and label schema a dataset and a configuration bind:
151/// results never transfer to another question, label order or output kind.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct QuestionSchema {
155    pub operation: DynamicOperation,
156    pub question: String,
157    pub labels: Vec<String>,
158    pub output: DynamicOutputKind,
159}
160
161impl QuestionSchema {
162    fn matches(&self, q: &DynamicQuestion) -> bool {
163        self.operation == q.operation
164            && self.question == q.question
165            && self.labels == q.labels
166            && self.output == q.output
167    }
168}
169
170/// A dynamic dataset, `rustev.dynamic-dataset/1` (3.7).
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct DynamicDataset {
174    pub schema: String,
175    pub name: String,
176    pub task_family: TaskFamily,
177    pub question_schema: QuestionSchema,
178    /// The context sources cases may draw from, by name.
179    pub context_sources: Vec<String>,
180    pub artifact: ArtifactId,
181    pub template: TemplateRef,
182    /// Synthetic provenance prevents every quality claim (R-04, R-37).
183    pub provenance: DatasetProvenance,
184    pub license: String,
185    /// How the labels were assigned.
186    pub labeling: String,
187    /// What was excluded, and why.
188    pub exclusions: Vec<String>,
189    pub limitations: String,
190    pub cases: Vec<DynamicCase>,
191}
192
193eval_document!(DynamicDataset, DYNAMIC_DATASET, REPLAY_V1, DatasetId);
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct DynamicCase {
198    pub id: String,
199    /// Opaque identity of where the case came from.
200    pub source: String,
201    pub split: Split,
202    pub question: DynamicQuestion,
203    /// The expected label or level, or an explicit statement there is none.
204    pub label: Label,
205    /// Instruction, role, tool, URL or schema-like text placed in a
206    /// caller-controlled field on purpose.
207    pub adversarial: bool,
208    pub subgroups: Vec<String>,
209}
210
211/// Why a dataset was refused.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum DynamicDatasetError {
214    Schema,
215    Empty,
216    /// Name, license, labeling or limitations are empty, or real provenance
217    /// lacks a field.
218    Provenance,
219    DuplicateCase {
220        case: String,
221    },
222    /// The same source or request id appears in more than one split.
223    Leakage {
224        case: String,
225        identity: String,
226    },
227    /// No injection-adversarial case exists.
228    NoAdversarialCase,
229    /// A case's request is invalid or does not match the dataset's task
230    /// family, question schema or artifact.
231    Case {
232        case: String,
233        detail: String,
234    },
235}
236
237impl fmt::Display for DynamicDatasetError {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        write!(f, "{self:?}")
240    }
241}
242
243impl std::error::Error for DynamicDatasetError {}
244
245impl DynamicDataset {
246    /// Validate the dataset (3.7): its bindings, provenance, unique cases,
247    /// no leakage across splits, at least one adversarial case, and every
248    /// case's request valid for the bound task family, question schema,
249    /// artifact and context sources.
250    pub fn check(&self) -> Result<(), DynamicDatasetError> {
251        if self.schema != DYNAMIC_DATASET {
252            return Err(DynamicDatasetError::Schema);
253        }
254        if self.cases.is_empty() {
255            return Err(DynamicDatasetError::Empty);
256        }
257        if [&self.name, &self.license, &self.labeling, &self.limitations]
258            .iter()
259            .any(|s| s.trim().is_empty())
260        {
261            return Err(DynamicDatasetError::Provenance);
262        }
263        if let DatasetProvenance::Real {
264            source,
265            license,
266            labeling,
267            limitations,
268        } = &self.provenance
269            && [source, license, labeling, limitations]
270                .iter()
271                .any(|s| s.trim().is_empty())
272        {
273            return Err(DynamicDatasetError::Provenance);
274        }
275        if !self.cases.iter().any(|c| c.adversarial) {
276            return Err(DynamicDatasetError::NoAdversarialCase);
277        }
278        let mut ids = BTreeSet::new();
279        let mut split_of: BTreeMap<String, Split> = BTreeMap::new();
280        for c in &self.cases {
281            if !ids.insert(&c.id) {
282                return Err(DynamicDatasetError::DuplicateCase { case: c.id.clone() });
283            }
284            for identity in [
285                format!("source:{}", c.source),
286                format!("request:{}", c.question.request_id),
287            ] {
288                match split_of.get(&identity) {
289                    Some(s) if *s != c.split => {
290                        return Err(DynamicDatasetError::Leakage {
291                            case: c.id.clone(),
292                            identity,
293                        });
294                    }
295                    _ => {
296                        split_of.insert(identity, c.split);
297                    }
298                }
299            }
300            let case = |detail: String| DynamicDatasetError::Case {
301                case: c.id.clone(),
302                detail,
303            };
304            core::validate(c.question.clone(), std::slice::from_ref(&self.task_family))
305                .map_err(|e| case(e.to_string()))?;
306            if !self.question_schema.matches(&c.question) {
307                return Err(case("the request differs from the question schema".into()));
308            }
309            if c.question.artifact != self.artifact {
310                return Err(case("the request requires another artifact".into()));
311            }
312            if let Some(f) = c
313                .question
314                .context
315                .iter()
316                .find(|f| !self.context_sources.contains(&f.source))
317            {
318                return Err(case(format!("undeclared context source {:?}", f.source)));
319            }
320            if let Label::Value(v) = &c.label
321                && !self.question_schema.labels.contains(v)
322            {
323                return Err(case(format!("label {v:?} is not declared")));
324            }
325        }
326        Ok(())
327    }
328}
329
330// ---------------------------------------------------------------------------
331// Evaluator configuration
332
333/// A dynamic evaluator configuration, `rustev.dynamic-evaluator-config/1`
334/// (3.7): what a report's numbers mean anything under.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(deny_unknown_fields)]
337pub struct DynamicEvaluatorConfig {
338    pub schema: String,
339    pub task_family: TaskFamily,
340    pub question_schema: QuestionSchema,
341    pub artifact: ArtifactId,
342    pub template: TemplateRef,
343    /// What a label means, in words.
344    pub label_interpretation: String,
345    /// Gates over the report's metrics. Each is reported `unknown`: a
346    /// quality gate needs separately governed real labeled evidence (spec
347    /// 023), which no dynamic dataset binds yet.
348    pub gates: Vec<DynamicGate>,
349}
350
351eval_document!(
352    DynamicEvaluatorConfig,
353    DYNAMIC_EVALUATOR_CONFIG,
354    REPLAY_V1,
355    EvaluatorConfigId
356);
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(deny_unknown_fields)]
360pub struct DynamicGate {
361    pub metric: String,
362    pub direction: GateDirection,
363    pub threshold: rustev_contract::decimal::Decimal,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(rename_all = "snake_case")]
368pub enum GateDirection {
369    AtLeast,
370    AtMost,
371}
372
373// ---------------------------------------------------------------------------
374// Report
375
376/// A dynamic evaluation report, `rustev.dynamic-eval-report/1` (3.7).
377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub struct DynamicEvalReport {
380    pub schema: String,
381    pub dataset: DatasetId,
382    pub evaluator_config: EvaluatorConfigId,
383    pub task_family: TaskFamily,
384    pub question_schema: QuestionSchema,
385    pub artifact: ArtifactId,
386    pub template: TemplateRef,
387    pub capability: DynamicCapabilityId,
388    pub split: Split,
389    pub provenance: DatasetProvenance,
390    /// Distributions are uncalibrated; no calibration artifact is bound.
391    pub calibration: CalibrationStatus,
392    pub claim: QualityClaim,
393    /// Coverage first, then abstention by reason, then accepted error, then
394    /// class-appropriate metrics; the same again over adversarial cases.
395    pub metrics: Vec<Metric>,
396    /// Cases not compared, by case id, with the reason.
397    pub incomparable: Vec<IncomparableCase>,
398    pub gates: Vec<GateResult>,
399    pub limitations: Vec<String>,
400}
401
402eval_document!(DynamicEvalReport, DYNAMIC_REPORT, RECORD_V1);
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(rename_all = "snake_case")]
406pub enum CalibrationStatus {
407    Uncalibrated,
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case", deny_unknown_fields)]
412pub enum QualityClaim {
413    /// Synthetic provenance: the report shows mechanics, never quality.
414    MechanicsOnly,
415    /// Real provenance: measured on the named data only, uncalibrated, and
416    /// transferring to no other task family, schema, template or artifact.
417    MeasuredOnNamedData,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(deny_unknown_fields)]
422pub struct IncomparableCase {
423    pub case: String,
424    pub reason: String,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct GateResult {
430    pub metric: String,
431    pub status: GateStatus,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case", deny_unknown_fields)]
436pub enum GateStatus {
437    Unknown { reason: String },
438}
439
440/// Why a report could not be produced.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub enum DynamicEvalError {
443    Dataset(DynamicDatasetError),
444    /// The configuration binds another task family, question schema,
445    /// artifact or template than the dataset, or the capability another
446    /// artifact or template.
447    Binding(String),
448    Identity(String),
449}
450
451impl fmt::Display for DynamicEvalError {
452    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
453        write!(f, "{self:?}")
454    }
455}
456
457impl std::error::Error for DynamicEvalError {}
458
459/// One compared case.
460struct Compared<'a> {
461    case: &'a DynamicCase,
462    outcome: &'a DynamicOutcome,
463}
464
465fn measured(num: f64, n: u64) -> MetricValue {
466    if n == 0 || !num.is_finite() {
467        MetricValue::Unknown {
468            reason: "empty denominator".into(),
469        }
470    } else {
471        MetricValue::Measured {
472            value: num / n as f64,
473            n,
474        }
475    }
476}
477
478fn metric(name: impl Into<String>, value: MetricValue) -> Metric {
479    Metric {
480        name: name.into(),
481        value,
482    }
483}
484
485/// Masses by label or level, in declared order.
486type Masses<'a> = Vec<(&'a str, f64)>;
487
488/// The top label or level of an accepted answer, and its masses if any.
489fn answered(o: &DynamicOutcome) -> Option<(&str, Option<Masses<'_>>)> {
490    let DynamicResult::Proposal(p) = &o.result else {
491        return None;
492    };
493    Some(match &p.answer {
494        DynamicAnswer::Label { label } => (label.as_str(), None),
495        DynamicAnswer::UncalibratedDistribution { label, masses }
496        | DynamicAnswer::OrdinalDistribution {
497            level: label,
498            masses,
499        } => (
500            label.as_str(),
501            Some(masses.iter().map(|m| (m.label.as_str(), m.mass)).collect()),
502        ),
503    })
504}
505
506fn cohort_metrics(prefix: &str, cases: &[Compared<'_>], schema: &QuestionSchema) -> Vec<Metric> {
507    let n = cases.len() as u64;
508    let accepted: Vec<&Compared<'_>> = cases
509        .iter()
510        .filter(|c| answered(c.outcome).is_some())
511        .collect();
512    let mut out = vec![metric(
513        format!("{prefix}coverage"),
514        measured(accepted.len() as f64, n),
515    )];
516    // Abstention by reason, over every compared case.
517    let mut reasons: BTreeMap<&'static str, u64> = BTreeMap::new();
518    for c in cases {
519        if let DynamicResult::Unresolved(u) = &c.outcome.result {
520            *reasons.entry(u.name()).or_default() += 1;
521        }
522    }
523    let total: u64 = reasons.values().sum();
524    out.push(metric(
525        format!("{prefix}abstention"),
526        measured(total as f64, n),
527    ));
528    for (reason, k) in &reasons {
529        out.push(metric(
530            format!("{prefix}abstention.{reason}"),
531            measured(*k as f64, n),
532        ));
533    }
534    // Accepted error: among accepted cases with a label.
535    let labeled: Vec<(&str, &str, Option<Masses<'_>>)> = accepted
536        .iter()
537        .filter_map(|c| {
538            let label = c.case.label.value()?;
539            let (top, masses) = answered(c.outcome)?;
540            Some((label, top, masses))
541        })
542        .collect();
543    let wrong = labeled.iter().filter(|(l, t, _)| l != t).count();
544    out.push(metric(
545        format!("{prefix}accepted_error"),
546        measured(wrong as f64, labeled.len() as u64),
547    ));
548    // Class-appropriate metrics over accepted, labeled cases.
549    match schema.output {
550        DynamicOutputKind::Label => {}
551        DynamicOutputKind::Distribution => {
552            // Scored as uncalibrated mass, never as calibrated probability.
553            let (mut brier, mut log_loss, mut infinite) = (0.0, 0.0, false);
554            for (label, _, masses) in &labeled {
555                let masses = masses.as_deref().unwrap_or_default();
556                for (l, m) in masses {
557                    let y = if l == label { 1.0 } else { 0.0 };
558                    brier += (m - y) * (m - y);
559                }
560                let p = masses
561                    .iter()
562                    .find(|(l, _)| l == label)
563                    .map(|(_, m)| *m)
564                    .unwrap_or(0.0);
565                if p > 0.0 {
566                    log_loss -= rustev_core::math::ln(p);
567                } else {
568                    infinite = true;
569                }
570            }
571            let k = labeled.len() as u64;
572            out.push(metric(
573                format!("{prefix}uncalibrated_brier"),
574                measured(brier, k),
575            ));
576            out.push(metric(
577                format!("{prefix}uncalibrated_log_loss"),
578                if infinite {
579                    MetricValue::Unknown {
580                        reason: "infinite loss: zero mass on a labeled class".into(),
581                    }
582                } else {
583                    measured(log_loss, k)
584                },
585            ));
586        }
587        DynamicOutputKind::OrdinalDistribution => {
588            // Level distance by index: an ordinal summary, not an interval
589            // score.
590            let index = |l: &str| schema.labels.iter().position(|x| x == l);
591            let mut dist = 0.0;
592            for (label, top, _) in &labeled {
593                if let (Some(a), Some(b)) = (index(label), index(top)) {
594                    dist += a.abs_diff(b) as f64;
595                }
596            }
597            out.push(metric(
598                format!("{prefix}mean_level_distance"),
599                measured(dist, labeled.len() as u64),
600            ));
601        }
602    }
603    out
604}
605
606/// Evaluate retained outcomes of one split against a dynamic dataset (3.7).
607/// `outcomes` maps case ids to retained outcomes; a missing outcome or one
608/// that does not reproduce is listed as incomparable and left out of every
609/// denominator. Nothing is dispatched.
610pub fn evaluate(
611    dataset: &DynamicDataset,
612    config: &DynamicEvaluatorConfig,
613    capability: &DynamicCapability,
614    split: Split,
615    outcomes: &BTreeMap<String, DynamicOutcome>,
616) -> Result<DynamicEvalReport, DynamicEvalError> {
617    dataset.check().map_err(DynamicEvalError::Dataset)?;
618    if config.schema != DYNAMIC_EVALUATOR_CONFIG {
619        return Err(DynamicEvalError::Binding(
620            "not a dynamic evaluator config".into(),
621        ));
622    }
623    if config.task_family != dataset.task_family
624        || config.question_schema != dataset.question_schema
625        || config.artifact != dataset.artifact
626        || config.template != dataset.template
627    {
628        return Err(DynamicEvalError::Binding(
629            "the configuration binds another task family, question schema, artifact or template"
630                .into(),
631        ));
632    }
633    if capability.artifact != dataset.artifact || capability.template != dataset.template {
634        return Err(DynamicEvalError::Binding(
635            "the capability declares another artifact or template".into(),
636        ));
637    }
638    let id =
639        |e: rustev_contract::canonical::CanonicalError| DynamicEvalError::Identity(e.to_string());
640    let mut compared = vec![];
641    let mut incomparable = vec![];
642    for case in dataset.cases.iter().filter(|c| c.split == split) {
643        let Some(outcome) = outcomes.get(&case.id) else {
644            incomparable.push(IncomparableCase {
645                case: case.id.clone(),
646                reason: "outcome_missing".into(),
647            });
648            continue;
649        };
650        // An outcome with a raw response must reproduce; one without (a
651        // refusal, a failure, a deadline) is compared by its retained
652        // result only when its request identity still matches.
653        let reason = match reproduce(outcome, &case.question, capability) {
654            DynamicReproduction::Reproduced => None,
655            DynamicReproduction::Diverged { .. } => Some("diverged".to_string()),
656            DynamicReproduction::Incomparable(DynamicIncomparable::RawResponseUnavailable) => {
657                match retained_identity_matches(outcome, &case.question, capability) {
658                    true => None,
659                    false => Some(DynamicIncomparable::RequestChanged.name().to_string()),
660                }
661            }
662            DynamicReproduction::Incomparable(i) => Some(i.name().to_string()),
663        };
664        match reason {
665            None => compared.push(Compared { case, outcome }),
666            Some(reason) => incomparable.push(IncomparableCase {
667                case: case.id.clone(),
668                reason,
669            }),
670        }
671    }
672    let mut metrics = cohort_metrics("", &compared, &dataset.question_schema);
673    let adversarial: Vec<Compared<'_>> = compared
674        .iter()
675        .filter(|c| c.case.adversarial)
676        .map(|c| Compared {
677            case: c.case,
678            outcome: c.outcome,
679        })
680        .collect();
681    metrics.extend(cohort_metrics(
682        "adversarial.",
683        &adversarial,
684        &dataset.question_schema,
685    ));
686    let synthetic = matches!(dataset.provenance, DatasetProvenance::Synthetic { .. });
687    let gates = config
688        .gates
689        .iter()
690        .map(|g| GateResult {
691            metric: g.metric.clone(),
692            status: gate(&metrics, g, synthetic),
693        })
694        .collect();
695    let mut limitations = vec![
696        "distributions are uncalibrated masses, not calibrated probabilities".to_string(),
697        "results do not transfer to another task family, question, label order, template, \
698         artifact, population or registered-plan task"
699            .to_string(),
700        dataset.limitations.clone(),
701    ];
702    if synthetic {
703        limitations.insert(
704            0,
705            "synthetic provenance: evidence of mechanics only, never of semantic quality".into(),
706        );
707    }
708    if !core::qualifiable(capability) {
709        limitations.push(
710            "the capability declares hidden output-affecting state: not eligible for qualification"
711                .into(),
712        );
713    }
714    Ok(DynamicEvalReport {
715        schema: DYNAMIC_REPORT.into(),
716        dataset: dataset.id().map_err(id)?,
717        evaluator_config: config.id().map_err(id)?,
718        task_family: dataset.task_family.clone(),
719        question_schema: dataset.question_schema.clone(),
720        artifact: dataset.artifact.clone(),
721        template: dataset.template.clone(),
722        capability: capability.id().map_err(id)?,
723        split,
724        provenance: dataset.provenance.clone(),
725        calibration: CalibrationStatus::Uncalibrated,
726        claim: if synthetic {
727            QualityClaim::MechanicsOnly
728        } else {
729            QualityClaim::MeasuredOnNamedData
730        },
731        metrics,
732        incomparable,
733        gates,
734        limitations,
735    })
736}
737
738fn retained_identity_matches(
739    outcome: &DynamicOutcome,
740    question: &DynamicQuestion,
741    capability: &DynamicCapability,
742) -> bool {
743    let Ok(q) = core::validate(
744        question.clone(),
745        std::slice::from_ref(&question.task_family),
746    ) else {
747        return false;
748    };
749    let identity: Option<DynamicRequestId> = core::request_identity(&q, capability).ok();
750    identity.is_some() && outcome.request == identity && outcome.capability == capability.id().ok()
751}
752
753/// Every gate is `unknown` here: over synthetic data a gate would claim
754/// quality from mechanics (R-04), and over real data it needs separately
755/// governed admission of that data (spec 023), which no dynamic dataset
756/// binds yet.
757fn gate(metrics: &[Metric], g: &DynamicGate, synthetic: bool) -> GateStatus {
758    let known = metrics
759        .iter()
760        .any(|m| m.name == g.metric && matches!(m.value, MetricValue::Measured { .. }));
761    GateStatus::Unknown {
762        reason: if synthetic {
763            "synthetic provenance: a quality gate needs separately governed real labeled evidence"
764        } else if known {
765            "no separately governed admission of this real data is bound (spec 023)"
766        } else {
767            "the metric is unknown or absent"
768        }
769        .into(),
770    }
771}