rustev-eval 0.1.0

Rustev's offline evaluation: bounded replay bundles, historical reproduction, candidate comparison, datasets, metrics and calibration fitting.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
//! Dataset manifests, `rustev.dataset/1` (spec 004, 3.5.1).
//!
//! A manifest names each case once, with its source and snapshot identity,
//! one split, a label or an explicit missing label, and its subgroups. Its
//! identity is content-derived. Training, model-selection, calibration and
//! final-test membership are disjoint by construction, and a source or
//! snapshot identity repeated across splits is refused as leakage. Semantic
//! duplicates the manifest cannot identify (two sources with the same
//! content) remain a stated limit: Rustev does not detect them.

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;

use rustev_contract::Identified;
use rustev_contract::decimal::Decimal;
use rustev_contract::eval_report::{DatasetProvenance, Split};
use rustev_contract::ids::{ContentDigest, DatasetId, SnapshotId};
use rustev_contract::limits::REPLAY_V1;
use serde::{Deserialize, Serialize};

use crate::doc::eval_document;

pub const DATASET: &str = "rustev.dataset/1";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DatasetManifest {
    pub schema: String,
    pub name: String,
    /// Synthetic provenance always prevents empirical quality claims (R-04).
    pub provenance: DatasetProvenance,
    /// The task adapter whose label shape the cases use.
    pub adapter: AdapterRef,
    pub cases: Vec<DatasetCase>,
}

eval_document!(DatasetManifest, DATASET, REPLAY_V1, DatasetId);

/// A task adapter, by name and version.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdapterRef {
    pub name: String,
    pub version: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DatasetCase {
    pub id: String,
    /// Opaque identity of where the case came from.
    pub source: String,
    pub snapshot: SnapshotId,
    pub split: Split,
    pub label: Label,
    pub subgroups: Vec<String>,
}

/// A label, or an explicit statement that there is none.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Label {
    Missing,
    Value(String),
}

impl Label {
    pub fn value(&self) -> Option<&str> {
        match self {
            Label::Value(v) => Some(v),
            Label::Missing => None,
        }
    }
}

/// Why a manifest was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DatasetError {
    Schema,
    Empty,
    /// Real provenance needs source, license, labeling method and limits.
    Provenance,
    DuplicateCase {
        case: String,
    },
    /// The same source or snapshot appears in more than one split.
    Leakage {
        case: String,
        identity: String,
    },
    /// The adapter refused the label's shape.
    Label {
        case: String,
        detail: String,
    },
    /// The manifest names another adapter than the one checking it.
    Adapter,
}

impl fmt::Display for DatasetError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{self:?}")
    }
}

impl std::error::Error for DatasetError {}

impl DatasetManifest {
    /// Validate everything but label shape: schema, cases, provenance,
    /// unique ids and leakage across splits.
    pub fn check_structure(&self) -> Result<(), DatasetError> {
        let adapter = self.adapter.clone();
        self.check(&adapter, |_| Ok(()))
    }

    /// Validate the manifest; `check_label` is the named adapter's label
    /// shape check.
    pub fn check(
        &self,
        adapter: &AdapterRef,
        check_label: impl Fn(&str) -> Result<(), String>,
    ) -> Result<(), DatasetError> {
        if self.schema != DATASET {
            return Err(DatasetError::Schema);
        }
        if &self.adapter != adapter {
            return Err(DatasetError::Adapter);
        }
        if self.cases.is_empty() {
            return Err(DatasetError::Empty);
        }
        if let DatasetProvenance::Real {
            source,
            license,
            labeling,
            limitations,
        } = &self.provenance
            && [source, license, labeling, limitations]
                .iter()
                .any(|s| s.trim().is_empty())
        {
            return Err(DatasetError::Provenance);
        }
        let mut ids = BTreeSet::new();
        let mut split_of: BTreeMap<String, Split> = BTreeMap::new();
        for c in &self.cases {
            if !ids.insert(&c.id) {
                return Err(DatasetError::DuplicateCase { case: c.id.clone() });
            }
            for identity in [
                format!("source:{}", c.source),
                format!("snapshot:{}", c.snapshot),
            ] {
                match split_of.get(&identity) {
                    Some(s) if *s != c.split => {
                        return Err(DatasetError::Leakage {
                            case: c.id.clone(),
                            identity,
                        });
                    }
                    _ => {
                        split_of.insert(identity, c.split);
                    }
                }
            }
            if let Label::Value(v) = &c.label {
                check_label(v).map_err(|detail| DatasetError::Label {
                    case: c.id.clone(),
                    detail,
                })?;
            }
        }
        Ok(())
    }

    /// The cases of one split, in manifest order.
    pub fn split(&self, split: Split) -> impl Iterator<Item = &DatasetCase> {
        self.cases.iter().filter(move |c| c.split == split)
    }
}

// ---------------------------------------------------------------------------
// Dataset admission (spec 023, 3.1 and 3.2).
// ---------------------------------------------------------------------------

/// Schema of an owner-reviewed dataset admission record (spec 023, 3.1).
pub const DATASET_ADMISSION: &str = "rustev.dataset-admission/1";

/// The record that admits one dataset manifest as qualification input
/// (spec 023, 3.1). The owner writes and reviews it before any case is
/// evaluated; Rustev checks that it is complete and agrees with the
/// manifest, never that its statements are true. Its digest is the
/// admission identity a qualification and every claim name. It holds no
/// binary64 number: an agreement statistic is a Decimal string. The record
/// and the data stay with the host, outside this repository (spec 023, 6.4).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdmissionRecord {
    pub schema: String,
    /// The admitted manifest's identity.
    pub dataset: DatasetId,
    pub source: AdmissionSource,
    pub license: LicenseTerms,
    pub privacy: PrivacyBasis,
    pub labeling: LabelingMethod,
    /// The models whose quality this dataset may qualify. Label
    /// independence is judged against them (3.1.4).
    pub systems_under_test: Vec<ModelRef>,
    pub task_mapping: TaskMapping,
    pub splits: SplitAssignment,
    pub population: Population,
    pub exposure: Exposure,
    pub review: OwnerReview,
}

eval_document!(AdmissionRecord, DATASET_ADMISSION, REPLAY_V1, ContentDigest);

/// Where the data came from (3.1.1).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum AdmissionSource {
    /// A public dataset: publisher, exact version or retrieval date, and the
    /// digest of the content admitted.
    Public {
        publisher: String,
        version: String,
        content_digest: ContentDigest,
    },
    /// A commissioned set: who labeled it and under what instruction.
    Commissioned {
        labeled_by: String,
        instruction: String,
        content_digest: ContentDigest,
    },
}

/// The license and what it permits (3.1.2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LicenseTerms {
    /// The identity of the license text.
    pub license: String,
    pub may_commit: bool,
    pub may_retain_outside_git: bool,
    pub may_send_to_provider: bool,
    pub may_redistribute_in_report: bool,
}

/// The privacy basis (3.1.3).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrivacyBasis {
    pub personal_data: bool,
    /// Derived from real user data: refused without the owner's separate
    /// privacy decision (spec 012, 3.1.3).
    pub derived_from_real_user_data: bool,
    /// A reference to that decision.
    pub privacy_decision: Option<String>,
}

/// How labels were produced (3.1.4).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LabelingMethod {
    pub guidelines: String,
    pub annotators_per_case: u64,
    pub agreement: AgreementStatistic,
    pub adjudication: String,
    /// Everyone and everything that produced a label.
    pub producers: Vec<LabelProducer>,
}

/// An inter-annotator agreement statistic with its denominator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgreementStatistic {
    pub statistic: String,
    pub value: Decimal,
    pub denominator: u64,
}

/// A label producer. A model under test, or a model of the same family, is
/// never an independent labeler.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum LabelProducer {
    Human { description: String },
    Model(ModelRef),
}

/// A model, by family and exact identity.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelRef {
    pub family: String,
    pub identity: String,
}

impl ModelRef {
    /// The same model, or one of the same family.
    pub fn related(&self, other: &ModelRef) -> bool {
        self.family == other.family || self.identity == other.identity
    }
}

/// How the dataset's labels map to the task adapter's (3.1.5).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskMapping {
    pub adapter: AdapterRef,
    /// The adapter's rules digest (spec 014): the correctness every
    /// qualified report is computed under.
    pub rules: ContentDigest,
    /// Source label to adapter label.
    pub labels: Vec<LabelMapping>,
    /// Cases whose source label maps to no adapter label: kept in the
    /// manifest with a missing label, counted, never dropped.
    pub unmapped: u64,
    /// Cases the source left unlabeled.
    pub unlabeled: u64,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LabelMapping {
    pub from: String,
    pub to: String,
}

/// How cases were assigned to splits (3.1.6). Disjointness by source and
/// snapshot is the manifest's own check (spec 004, 3.5.1); the counts here
/// must equal the manifest's.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SplitAssignment {
    pub method: String,
    pub seed: String,
    pub training: u64,
    pub model_selection: u64,
    pub calibration: u64,
    pub final_test: u64,
    /// The stated limit on near-duplicates the manifest cannot identify.
    pub near_duplicates: String,
}

/// Who and what the data represents (3.1.7). A claim may name this
/// population and no other (3.5).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Population {
    /// A short, exact name a claim must repeat.
    pub id: String,
    pub description: String,
    pub limitations: String,
    /// Declared slices: manifest subgroups, with their whole-manifest size.
    pub slices: Vec<SliceSize>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SliceSize {
    pub name: String,
    pub cases: u64,
}

/// Contamination and exposure (3.2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Exposure {
    /// Whether a backend's training data may include this dataset: a stated
    /// limit Rustev cannot verify, required for a public dataset.
    pub training_data: String,
    /// Final-test cases whose content reached a provider before admission:
    /// reported separately and excluded from the headline figures.
    pub exposed_final_test: Vec<String>,
    /// For a ranking task, spec 008 4.2's caveat as it applies here.
    pub ranking_caveat: Option<String>,
}

/// The owner's review of this record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OwnerReview {
    pub reviewer: String,
    pub date: String,
}

/// Why a dataset was not admitted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdmissionError {
    Schema,
    /// The manifest itself is refused (spec 004, 3.5.1).
    Dataset(DatasetError),
    /// Synthetic or generated data is never independent evidence of
    /// semantic quality (R-04).
    Synthetic,
    /// The record admits another manifest.
    WrongDataset,
    /// A required statement is empty.
    Incomplete(&'static str),
    /// Derived from real user data without the owner's privacy decision.
    PrivacyDecisionMissing,
    /// A label producer is a system under test or of the same family.
    LabelsNotIndependent {
        producer: ModelRef,
    },
    /// The mapping names another adapter than the manifest.
    Adapter,
    /// A source label is mapped twice.
    DuplicateMapping {
        from: String,
    },
    /// Unmapped plus unlabeled cases differ from the manifest's missing
    /// labels: some case was dropped or invented.
    MissingLabels {
        declared: u64,
        manifest: u64,
    },
    /// A split count differs from the manifest's.
    SplitCount {
        split: &'static str,
    },
    /// A slice is declared twice, is not a manifest subgroup, or its size
    /// differs from the manifest's.
    Slice {
        name: String,
    },
    /// An exposed case is not a final-test case of the manifest, or is
    /// listed twice.
    Exposure {
        case: String,
    },
}

impl fmt::Display for AdmissionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{self:?}")
    }
}

impl std::error::Error for AdmissionError {}

/// A manifest with a checked admission record. Only [`admit`] makes one, so
/// holding it is the statement that the checks of 3.1 ran.
#[derive(Debug, Clone)]
pub struct Admitted {
    record: AdmissionRecord,
    id: ContentDigest,
}

impl Admitted {
    pub fn record(&self) -> &AdmissionRecord {
        &self.record
    }
    /// The admission record's digest.
    pub fn id(&self) -> &ContentDigest {
        &self.id
    }
    pub fn dataset(&self) -> &DatasetId {
        &self.record.dataset
    }
    /// A model label producer that is `model` or of its family, if any:
    /// `model`'s outputs would then not meet independent labels.
    pub fn related_labeler(&self, model: &ModelRef) -> Option<&ModelRef> {
        self.record.labeling.producers.iter().find_map(|p| match p {
            LabelProducer::Model(m) if m.related(model) => Some(m),
            _ => None,
        })
    }
    /// Whether the admission judged independence against this model.
    pub fn declares_system(&self, model: &ModelRef) -> bool {
        self.record.systems_under_test.contains(model)
    }
}

/// Admit a manifest under an owner-reviewed record (spec 023, 3.1, 3.2).
/// The record must be complete and agree with the manifest; labels must be
/// independent of every system under test; data derived from real user data
/// needs the owner's privacy decision. A synthetic manifest is never
/// admitted.
pub fn admit(
    manifest: &DatasetManifest,
    record: &AdmissionRecord,
) -> Result<Admitted, AdmissionError> {
    use AdmissionError as E;
    if record.schema != DATASET_ADMISSION {
        return Err(E::Schema);
    }
    manifest.check_structure().map_err(E::Dataset)?;
    if matches!(manifest.provenance, DatasetProvenance::Synthetic { .. }) {
        return Err(E::Synthetic);
    }
    if manifest.id().ok().as_ref() != Some(&record.dataset) {
        return Err(E::WrongDataset);
    }
    let blank = |s: &str| s.trim().is_empty();
    let need = |ok: bool, what: &'static str| {
        if ok { Ok(()) } else { Err(E::Incomplete(what)) }
    };
    match &record.source {
        AdmissionSource::Public {
            publisher, version, ..
        } => {
            need(!blank(publisher) && !blank(version), "source")?;
            need(
                !blank(&record.exposure.training_data),
                "training-data exposure",
            )?;
        }
        AdmissionSource::Commissioned {
            labeled_by,
            instruction,
            ..
        } => need(!blank(labeled_by) && !blank(instruction), "source")?,
    }
    need(!blank(&record.license.license), "license")?;
    if record.privacy.derived_from_real_user_data
        && record.privacy.privacy_decision.as_deref().is_none_or(blank)
    {
        return Err(E::PrivacyDecisionMissing);
    }
    let l = &record.labeling;
    need(
        !blank(&l.guidelines)
            && !blank(&l.adjudication)
            && l.annotators_per_case > 0
            && !l.producers.is_empty(),
        "labeling method",
    )?;
    need(
        !blank(&l.agreement.statistic)
            && l.agreement.denominator > 0
            && !l.agreement.value.is_negative(),
        "agreement statistic",
    )?;
    for p in &l.producers {
        let ok = match p {
            LabelProducer::Human { description } => !blank(description),
            LabelProducer::Model(m) => !blank(&m.family) && !blank(&m.identity),
        };
        need(ok, "label producer")?;
    }
    need(!record.systems_under_test.is_empty(), "systems under test")?;
    for s in &record.systems_under_test {
        need(
            !blank(&s.family) && !blank(&s.identity),
            "systems under test",
        )?;
        for p in &l.producers {
            if let LabelProducer::Model(m) = p
                && m.related(s)
            {
                return Err(E::LabelsNotIndependent {
                    producer: m.clone(),
                });
            }
        }
    }
    let m = &record.task_mapping;
    if m.adapter != manifest.adapter {
        return Err(E::Adapter);
    }
    let mut froms = BTreeSet::new();
    for x in &m.labels {
        need(!blank(&x.from) && !blank(&x.to), "label mapping")?;
        if !froms.insert(&x.from) {
            return Err(E::DuplicateMapping {
                from: x.from.clone(),
            });
        }
    }
    let missing = manifest
        .cases
        .iter()
        .filter(|c| c.label == Label::Missing)
        .count() as u64;
    let declared = m.unmapped.saturating_add(m.unlabeled);
    if declared != missing {
        return Err(E::MissingLabels {
            declared,
            manifest: missing,
        });
    }
    let s = &record.splits;
    need(
        !blank(&s.method) && !blank(&s.seed) && !blank(&s.near_duplicates),
        "split assignment",
    )?;
    for (split, n, name) in [
        (Split::Training, s.training, "training"),
        (Split::ModelSelection, s.model_selection, "model_selection"),
        (Split::Calibration, s.calibration, "calibration"),
        (Split::FinalTest, s.final_test, "final_test"),
    ] {
        if manifest.split(split).count() as u64 != n {
            return Err(E::SplitCount { split: name });
        }
    }
    let p = &record.population;
    need(
        !blank(&p.id) && !blank(&p.description) && !blank(&p.limitations),
        "population",
    )?;
    let mut names = BTreeSet::new();
    for slice in &p.slices {
        let size = manifest
            .cases
            .iter()
            .filter(|c| c.subgroups.contains(&slice.name))
            .count() as u64;
        if !names.insert(&slice.name) || size == 0 || size != slice.cases {
            return Err(E::Slice {
                name: slice.name.clone(),
            });
        }
    }
    let mut exposed = BTreeSet::new();
    for case in &record.exposure.exposed_final_test {
        let ok = manifest.split(Split::FinalTest).any(|c| &c.id == case);
        if !ok || !exposed.insert(case) {
            return Err(E::Exposure { case: case.clone() });
        }
    }
    if record.exposure.ranking_caveat.as_deref().is_some_and(blank) {
        return Err(E::Incomplete("ranking caveat"));
    }
    need(
        !blank(&record.review.reviewer) && !blank(&record.review.date),
        "owner review",
    )?;
    let id = record.id().map_err(|_| E::Incomplete("canonical form"))?;
    Ok(Admitted {
        record: record.clone(),
        id,
    })
}