Skip to main content

rustev_eval/
dataset.rs

1//! Dataset manifests, `rustev.dataset/1` (spec 004, 3.5.1).
2//!
3//! A manifest names each case once, with its source and snapshot identity,
4//! one split, a label or an explicit missing label, and its subgroups. Its
5//! identity is content-derived. Training, model-selection, calibration and
6//! final-test membership are disjoint by construction, and a source or
7//! snapshot identity repeated across splits is refused as leakage. Semantic
8//! duplicates the manifest cannot identify (two sources with the same
9//! content) remain a stated limit: Rustev does not detect them.
10//!
11//! `rustev.dataset/2` (spec 041, 3.1.1) is version 1 with an optional gold
12//! record per case. A version 1 manifest is a version 2 manifest whose every
13//! case has no gold, and keeps its bytes and identity; each version's
14//! identity is tagged with its own schema.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::fmt;
18
19use rustev_contract::bounded::ParseLimits;
20use rustev_contract::canonical::{CanonicalError, tagged_digest};
21use rustev_contract::decimal::Decimal;
22use rustev_contract::eval_report::{DatasetProvenance, Split};
23use rustev_contract::ids::{ContentDigest, DatasetId, SnapshotId};
24use rustev_contract::limits::REPLAY_V1;
25use rustev_contract::{Document, DocumentError, Identified};
26use serde::{Deserialize, Serialize};
27
28use crate::doc::eval_document;
29use crate::gold::Gold;
30
31pub const DATASET: &str = "rustev.dataset/1";
32/// Version 2: per-case gold records (spec 041, 3.1.1).
33pub const DATASET_V2: &str = "rustev.dataset/2";
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct DatasetManifest {
38    pub schema: String,
39    pub name: String,
40    /// Synthetic provenance always prevents empirical quality claims (R-04).
41    pub provenance: DatasetProvenance,
42    /// The task adapter whose label shape the cases use.
43    pub adapter: AdapterRef,
44    pub cases: Vec<DatasetCase>,
45}
46
47/// Either version parses; `SCHEMA` names version 1 only because the trait
48/// holds one constant. The identity is tagged with the document's own
49/// schema, so a version 1 identity is what it always was.
50impl Document for DatasetManifest {
51    const SCHEMA: &'static str = DATASET;
52    const LIMITS: ParseLimits = REPLAY_V1;
53    fn schema(&self) -> &str {
54        &self.schema
55    }
56
57    fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
58        let doc: Self = rustev_contract::bounded::parse_bounded(bytes, &Self::LIMITS)
59            .map_err(DocumentError::Parse)?;
60        if doc.schema != DATASET && doc.schema != DATASET_V2 {
61            return Err(DocumentError::Schema {
62                expected: DATASET_V2,
63                found: doc.schema.chars().take(80).collect(),
64            });
65        }
66        Ok(doc)
67    }
68
69    fn record_digest(&self) -> Result<ContentDigest, CanonicalError> {
70        let raw = self.record_canonical()?;
71        Ok(ContentDigest::parse(&tagged_digest(&self.schema, &raw))
72            .expect("a tagged digest is a content digest"))
73    }
74}
75
76impl Identified for DatasetManifest {
77    type Id = DatasetId;
78    fn wrap(digest: String) -> DatasetId {
79        DatasetId::parse(&digest).expect("a tagged digest is an identity")
80    }
81
82    fn id(&self) -> Result<DatasetId, CanonicalError> {
83        Ok(Self::wrap(tagged_digest(&self.schema, &self.canonical()?)))
84    }
85}
86
87/// A task adapter, by name and version.
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct AdapterRef {
91    pub name: String,
92    pub version: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct DatasetCase {
98    pub id: String,
99    /// Opaque identity of where the case came from.
100    pub source: String,
101    pub snapshot: SnapshotId,
102    pub split: Split,
103    pub label: Label,
104    pub subgroups: Vec<String>,
105    /// The case's gold record; version 2 only (spec 041, 3.3).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub gold: Option<Gold>,
108}
109
110/// A label, or an explicit statement that there is none.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case", deny_unknown_fields)]
113pub enum Label {
114    Missing,
115    Value(String),
116}
117
118impl Label {
119    pub fn value(&self) -> Option<&str> {
120        match self {
121            Label::Value(v) => Some(v),
122            Label::Missing => None,
123        }
124    }
125}
126
127/// Why a manifest was refused.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum DatasetError {
130    Schema,
131    Empty,
132    /// Real provenance needs source, license, labeling method and limits.
133    Provenance,
134    DuplicateCase {
135        case: String,
136    },
137    /// The same source or snapshot appears in more than one split.
138    Leakage {
139        case: String,
140        identity: String,
141    },
142    /// The adapter refused the label's shape.
143    Label {
144        case: String,
145        detail: String,
146    },
147    /// The manifest names another adapter than the one checking it.
148    Adapter,
149    /// A malformed gold record, or gold in a version 1 manifest (spec 041,
150    /// 3.3.2).
151    Gold {
152        case: String,
153        detail: String,
154    },
155}
156
157impl fmt::Display for DatasetError {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(f, "{self:?}")
160    }
161}
162
163impl std::error::Error for DatasetError {}
164
165impl DatasetManifest {
166    /// Validate everything but label shape: schema, cases, provenance,
167    /// unique ids and leakage across splits.
168    pub fn check_structure(&self) -> Result<(), DatasetError> {
169        let adapter = self.adapter.clone();
170        self.check(&adapter, |_| Ok(()))
171    }
172
173    /// Validate the manifest; `check_label` is the named adapter's label
174    /// shape check.
175    pub fn check(
176        &self,
177        adapter: &AdapterRef,
178        check_label: impl Fn(&str) -> Result<(), String>,
179    ) -> Result<(), DatasetError> {
180        if self.schema != DATASET && self.schema != DATASET_V2 {
181            return Err(DatasetError::Schema);
182        }
183        if &self.adapter != adapter {
184            return Err(DatasetError::Adapter);
185        }
186        if self.cases.is_empty() {
187            return Err(DatasetError::Empty);
188        }
189        if let DatasetProvenance::Real {
190            source,
191            license,
192            labeling,
193            limitations,
194        } = &self.provenance
195            && [source, license, labeling, limitations]
196                .iter()
197                .any(|s| s.trim().is_empty())
198        {
199            return Err(DatasetError::Provenance);
200        }
201        let mut ids = BTreeSet::new();
202        let mut split_of: BTreeMap<String, Split> = BTreeMap::new();
203        for c in &self.cases {
204            if !ids.insert(&c.id) {
205                return Err(DatasetError::DuplicateCase { case: c.id.clone() });
206            }
207            for identity in [
208                format!("source:{}", c.source),
209                format!("snapshot:{}", c.snapshot),
210            ] {
211                match split_of.get(&identity) {
212                    Some(s) if *s != c.split => {
213                        return Err(DatasetError::Leakage {
214                            case: c.id.clone(),
215                            identity,
216                        });
217                    }
218                    _ => {
219                        split_of.insert(identity, c.split);
220                    }
221                }
222            }
223            if let Some(gold) = &c.gold {
224                let detail = if self.schema == DATASET {
225                    Err("a version 1 manifest holds no gold".to_owned())
226                } else {
227                    gold.check()
228                };
229                detail.map_err(|detail| DatasetError::Gold {
230                    case: c.id.clone(),
231                    detail,
232                })?;
233            }
234            if let Label::Value(v) = &c.label {
235                check_label(v).map_err(|detail| DatasetError::Label {
236                    case: c.id.clone(),
237                    detail,
238                })?;
239            }
240        }
241        Ok(())
242    }
243
244    /// The cases of one split, in manifest order.
245    pub fn split(&self, split: Split) -> impl Iterator<Item = &DatasetCase> {
246        self.cases.iter().filter(move |c| c.split == split)
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Dataset admission (spec 023, 3.1 and 3.2).
252// ---------------------------------------------------------------------------
253
254/// Schema of an owner-reviewed dataset admission record (spec 023, 3.1).
255pub const DATASET_ADMISSION: &str = "rustev.dataset-admission/1";
256
257/// The record that admits one dataset manifest as qualification input
258/// (spec 023, 3.1). The owner writes and reviews it before any case is
259/// evaluated; Rustev checks that it is complete and agrees with the
260/// manifest, never that its statements are true. Its digest is the
261/// admission identity a qualification and every claim name. It holds no
262/// binary64 number: an agreement statistic is a Decimal string. The record
263/// and the data stay with the host, outside this repository (spec 023, 6.4).
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct AdmissionRecord {
267    pub schema: String,
268    /// The admitted manifest's identity.
269    pub dataset: DatasetId,
270    pub source: AdmissionSource,
271    pub license: LicenseTerms,
272    pub privacy: PrivacyBasis,
273    pub labeling: LabelingMethod,
274    /// The models whose quality this dataset may qualify. Label
275    /// independence is judged against them (3.1.4).
276    pub systems_under_test: Vec<ModelRef>,
277    pub task_mapping: TaskMapping,
278    pub splits: SplitAssignment,
279    pub population: Population,
280    pub exposure: Exposure,
281    pub review: OwnerReview,
282}
283
284eval_document!(AdmissionRecord, DATASET_ADMISSION, REPLAY_V1, ContentDigest);
285
286/// Where the data came from (3.1.1).
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "snake_case", deny_unknown_fields)]
289pub enum AdmissionSource {
290    /// A public dataset: publisher, exact version or retrieval date, and the
291    /// digest of the content admitted.
292    Public {
293        publisher: String,
294        version: String,
295        content_digest: ContentDigest,
296    },
297    /// A commissioned set: who labeled it and under what instruction.
298    Commissioned {
299        labeled_by: String,
300        instruction: String,
301        content_digest: ContentDigest,
302    },
303}
304
305/// The license and what it permits (3.1.2).
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct LicenseTerms {
309    /// The identity of the license text.
310    pub license: String,
311    pub may_commit: bool,
312    pub may_retain_outside_git: bool,
313    pub may_send_to_provider: bool,
314    pub may_redistribute_in_report: bool,
315}
316
317/// The privacy basis (3.1.3).
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(deny_unknown_fields)]
320pub struct PrivacyBasis {
321    pub personal_data: bool,
322    /// Derived from real user data: refused without the owner's separate
323    /// privacy decision (spec 012, 3.1.3).
324    pub derived_from_real_user_data: bool,
325    /// A reference to that decision.
326    pub privacy_decision: Option<String>,
327}
328
329/// How labels were produced (3.1.4).
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(deny_unknown_fields)]
332pub struct LabelingMethod {
333    pub guidelines: String,
334    pub annotators_per_case: u64,
335    pub agreement: AgreementStatistic,
336    pub adjudication: String,
337    /// Everyone and everything that produced a label.
338    pub producers: Vec<LabelProducer>,
339}
340
341/// An inter-annotator agreement statistic with its denominator.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct AgreementStatistic {
345    pub statistic: String,
346    pub value: Decimal,
347    pub denominator: u64,
348}
349
350/// A label producer. A model under test, or a model of the same family, is
351/// never an independent labeler.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case", deny_unknown_fields)]
354pub enum LabelProducer {
355    Human { description: String },
356    Model(ModelRef),
357}
358
359/// A model, by family and exact identity.
360#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
361#[serde(deny_unknown_fields)]
362pub struct ModelRef {
363    pub family: String,
364    pub identity: String,
365}
366
367impl ModelRef {
368    /// The same model, or one of the same family.
369    pub fn related(&self, other: &ModelRef) -> bool {
370        self.family == other.family || self.identity == other.identity
371    }
372}
373
374/// How the dataset's labels map to the task adapter's (3.1.5).
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(deny_unknown_fields)]
377pub struct TaskMapping {
378    pub adapter: AdapterRef,
379    /// The adapter's rules digest (spec 014): the correctness every
380    /// qualified report is computed under.
381    pub rules: ContentDigest,
382    /// Source label to adapter label.
383    pub labels: Vec<LabelMapping>,
384    /// Cases whose source label maps to no adapter label: kept in the
385    /// manifest with a missing label, counted, never dropped.
386    pub unmapped: u64,
387    /// Cases the source left unlabeled.
388    pub unlabeled: u64,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct LabelMapping {
394    pub from: String,
395    pub to: String,
396}
397
398/// How cases were assigned to splits (3.1.6). Disjointness by source and
399/// snapshot is the manifest's own check (spec 004, 3.5.1); the counts here
400/// must equal the manifest's.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(deny_unknown_fields)]
403pub struct SplitAssignment {
404    pub method: String,
405    pub seed: String,
406    pub training: u64,
407    pub model_selection: u64,
408    pub calibration: u64,
409    pub final_test: u64,
410    /// The stated limit on near-duplicates the manifest cannot identify.
411    pub near_duplicates: String,
412}
413
414/// Who and what the data represents (3.1.7). A claim may name this
415/// population and no other (3.5).
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(deny_unknown_fields)]
418pub struct Population {
419    /// A short, exact name a claim must repeat.
420    pub id: String,
421    pub description: String,
422    pub limitations: String,
423    /// Declared slices: manifest subgroups, with their whole-manifest size.
424    pub slices: Vec<SliceSize>,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct SliceSize {
430    pub name: String,
431    pub cases: u64,
432}
433
434/// Contamination and exposure (3.2).
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(deny_unknown_fields)]
437pub struct Exposure {
438    /// Whether a backend's training data may include this dataset: a stated
439    /// limit Rustev cannot verify, required for a public dataset.
440    pub training_data: String,
441    /// Final-test cases whose content reached a provider before admission:
442    /// reported separately and excluded from the headline figures.
443    pub exposed_final_test: Vec<String>,
444    /// For a ranking task, spec 008 4.2's caveat as it applies here.
445    pub ranking_caveat: Option<String>,
446}
447
448/// The owner's review of this record.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct OwnerReview {
452    pub reviewer: String,
453    pub date: String,
454}
455
456/// Why a dataset was not admitted.
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum AdmissionError {
459    Schema,
460    /// The manifest itself is refused (spec 004, 3.5.1).
461    Dataset(DatasetError),
462    /// Synthetic or generated data is never independent evidence of
463    /// semantic quality (R-04).
464    Synthetic,
465    /// The record admits another manifest.
466    WrongDataset,
467    /// A required statement is empty.
468    Incomplete(&'static str),
469    /// Derived from real user data without the owner's privacy decision.
470    PrivacyDecisionMissing,
471    /// A label producer is a system under test or of the same family.
472    LabelsNotIndependent {
473        producer: ModelRef,
474    },
475    /// The mapping names another adapter than the manifest.
476    Adapter,
477    /// A source label is mapped twice.
478    DuplicateMapping {
479        from: String,
480    },
481    /// Unmapped plus unlabeled cases differ from the manifest's missing
482    /// labels: some case was dropped or invented.
483    MissingLabels {
484        declared: u64,
485        manifest: u64,
486    },
487    /// A split count differs from the manifest's.
488    SplitCount {
489        split: &'static str,
490    },
491    /// A slice is declared twice, is not a manifest subgroup, or its size
492    /// differs from the manifest's.
493    Slice {
494        name: String,
495    },
496    /// An exposed case is not a final-test case of the manifest, or is
497    /// listed twice.
498    Exposure {
499        case: String,
500    },
501}
502
503impl fmt::Display for AdmissionError {
504    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505        write!(f, "{self:?}")
506    }
507}
508
509impl std::error::Error for AdmissionError {}
510
511/// A manifest with a checked admission record. Only [`admit`] makes one, so
512/// holding it is the statement that the checks of 3.1 ran.
513#[derive(Debug, Clone)]
514pub struct Admitted {
515    record: AdmissionRecord,
516    id: ContentDigest,
517}
518
519impl Admitted {
520    pub fn record(&self) -> &AdmissionRecord {
521        &self.record
522    }
523    /// The admission record's digest.
524    pub fn id(&self) -> &ContentDigest {
525        &self.id
526    }
527    pub fn dataset(&self) -> &DatasetId {
528        &self.record.dataset
529    }
530    /// A model label producer that is `model` or of its family, if any:
531    /// `model`'s outputs would then not meet independent labels.
532    pub fn related_labeler(&self, model: &ModelRef) -> Option<&ModelRef> {
533        self.record.labeling.producers.iter().find_map(|p| match p {
534            LabelProducer::Model(m) if m.related(model) => Some(m),
535            _ => None,
536        })
537    }
538    /// Whether the admission judged independence against this model.
539    pub fn declares_system(&self, model: &ModelRef) -> bool {
540        self.record.systems_under_test.contains(model)
541    }
542}
543
544/// Admit a manifest under an owner-reviewed record (spec 023, 3.1, 3.2).
545/// The record must be complete and agree with the manifest; labels must be
546/// independent of every system under test; data derived from real user data
547/// needs the owner's privacy decision. A synthetic manifest is never
548/// admitted.
549pub fn admit(
550    manifest: &DatasetManifest,
551    record: &AdmissionRecord,
552) -> Result<Admitted, AdmissionError> {
553    use AdmissionError as E;
554    if record.schema != DATASET_ADMISSION {
555        return Err(E::Schema);
556    }
557    manifest.check_structure().map_err(E::Dataset)?;
558    if matches!(manifest.provenance, DatasetProvenance::Synthetic { .. }) {
559        return Err(E::Synthetic);
560    }
561    if manifest.id().ok().as_ref() != Some(&record.dataset) {
562        return Err(E::WrongDataset);
563    }
564    let blank = |s: &str| s.trim().is_empty();
565    let need = |ok: bool, what: &'static str| {
566        if ok { Ok(()) } else { Err(E::Incomplete(what)) }
567    };
568    match &record.source {
569        AdmissionSource::Public {
570            publisher, version, ..
571        } => {
572            need(!blank(publisher) && !blank(version), "source")?;
573            need(
574                !blank(&record.exposure.training_data),
575                "training-data exposure",
576            )?;
577        }
578        AdmissionSource::Commissioned {
579            labeled_by,
580            instruction,
581            ..
582        } => need(!blank(labeled_by) && !blank(instruction), "source")?,
583    }
584    need(!blank(&record.license.license), "license")?;
585    if record.privacy.derived_from_real_user_data
586        && record.privacy.privacy_decision.as_deref().is_none_or(blank)
587    {
588        return Err(E::PrivacyDecisionMissing);
589    }
590    let l = &record.labeling;
591    need(
592        !blank(&l.guidelines)
593            && !blank(&l.adjudication)
594            && l.annotators_per_case > 0
595            && !l.producers.is_empty(),
596        "labeling method",
597    )?;
598    need(
599        !blank(&l.agreement.statistic)
600            && l.agreement.denominator > 0
601            && !l.agreement.value.is_negative(),
602        "agreement statistic",
603    )?;
604    for p in &l.producers {
605        let ok = match p {
606            LabelProducer::Human { description } => !blank(description),
607            LabelProducer::Model(m) => !blank(&m.family) && !blank(&m.identity),
608        };
609        need(ok, "label producer")?;
610    }
611    need(!record.systems_under_test.is_empty(), "systems under test")?;
612    for s in &record.systems_under_test {
613        need(
614            !blank(&s.family) && !blank(&s.identity),
615            "systems under test",
616        )?;
617        for p in &l.producers {
618            if let LabelProducer::Model(m) = p
619                && m.related(s)
620            {
621                return Err(E::LabelsNotIndependent {
622                    producer: m.clone(),
623                });
624            }
625        }
626    }
627    let m = &record.task_mapping;
628    if m.adapter != manifest.adapter {
629        return Err(E::Adapter);
630    }
631    let mut froms = BTreeSet::new();
632    for x in &m.labels {
633        need(!blank(&x.from) && !blank(&x.to), "label mapping")?;
634        if !froms.insert(&x.from) {
635            return Err(E::DuplicateMapping {
636                from: x.from.clone(),
637            });
638        }
639    }
640    let missing = manifest
641        .cases
642        .iter()
643        .filter(|c| c.label == Label::Missing)
644        .count() as u64;
645    let declared = m.unmapped.saturating_add(m.unlabeled);
646    if declared != missing {
647        return Err(E::MissingLabels {
648            declared,
649            manifest: missing,
650        });
651    }
652    let s = &record.splits;
653    need(
654        !blank(&s.method) && !blank(&s.seed) && !blank(&s.near_duplicates),
655        "split assignment",
656    )?;
657    for (split, n, name) in [
658        (Split::Training, s.training, "training"),
659        (Split::ModelSelection, s.model_selection, "model_selection"),
660        (Split::Calibration, s.calibration, "calibration"),
661        (Split::FinalTest, s.final_test, "final_test"),
662    ] {
663        if manifest.split(split).count() as u64 != n {
664            return Err(E::SplitCount { split: name });
665        }
666    }
667    let p = &record.population;
668    need(
669        !blank(&p.id) && !blank(&p.description) && !blank(&p.limitations),
670        "population",
671    )?;
672    let mut names = BTreeSet::new();
673    for slice in &p.slices {
674        let size = manifest
675            .cases
676            .iter()
677            .filter(|c| c.subgroups.contains(&slice.name))
678            .count() as u64;
679        if !names.insert(&slice.name) || size == 0 || size != slice.cases {
680            return Err(E::Slice {
681                name: slice.name.clone(),
682            });
683        }
684    }
685    let mut exposed = BTreeSet::new();
686    for case in &record.exposure.exposed_final_test {
687        let ok = manifest.split(Split::FinalTest).any(|c| &c.id == case);
688        if !ok || !exposed.insert(case) {
689            return Err(E::Exposure { case: case.clone() });
690        }
691    }
692    if record.exposure.ranking_caveat.as_deref().is_some_and(blank) {
693        return Err(E::Incomplete("ranking caveat"));
694    }
695    need(
696        !blank(&record.review.reviewer) && !blank(&record.review.date),
697        "owner review",
698    )?;
699    let id = record.id().map_err(|_| E::Incomplete("canonical form"))?;
700    Ok(Admitted {
701        record: record.clone(),
702        id,
703    })
704}