1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case", deny_unknown_fields)]
47pub enum DynamicIncomparable {
48 RequestInvalid { detail: String },
50 RequestChanged,
54 CapabilityChanged,
56 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#[derive(Debug, Clone, PartialEq)]
74pub enum DynamicReproduction {
75 Reproduced,
77 Diverged {
79 detail: String,
80 },
81 Incomparable(DynamicIncomparable),
82}
83
84pub 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#[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#[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 pub context_sources: Vec<String>,
180 pub artifact: ArtifactId,
181 pub template: TemplateRef,
182 pub provenance: DatasetProvenance,
184 pub license: String,
185 pub labeling: String,
187 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 pub source: String,
201 pub split: Split,
202 pub question: DynamicQuestion,
203 pub label: Label,
205 pub adversarial: bool,
208 pub subgroups: Vec<String>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum DynamicDatasetError {
214 Schema,
215 Empty,
216 Provenance,
219 DuplicateCase {
220 case: String,
221 },
222 Leakage {
224 case: String,
225 identity: String,
226 },
227 NoAdversarialCase,
229 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 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#[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 pub label_interpretation: String,
345 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#[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 pub calibration: CalibrationStatus,
392 pub claim: QualityClaim,
393 pub metrics: Vec<Metric>,
396 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 MechanicsOnly,
415 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#[derive(Debug, Clone, PartialEq, Eq)]
442pub enum DynamicEvalError {
443 Dataset(DynamicDatasetError),
444 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
459struct 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
485type Masses<'a> = Vec<(&'a str, f64)>;
487
488fn 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 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 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 match schema.output {
550 DynamicOutputKind::Label => {}
551 DynamicOutputKind::Distribution => {
552 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 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
606pub 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 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
753fn 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}