Skip to main content

quietset/
lib.rs

1//! quietset — filter datasets by label stability.
2//!
3//! # Quick start
4//!
5//! ```rust
6//! use quietset::{Observation, ScoreConfig, score_all};
7//!
8//! let obs = vec![
9//!     Observation { sample_id: "a".into(), label: Some("win".into()), score: Some(0.9), ..Default::default() },
10//!     Observation { sample_id: "a".into(), label: Some("win".into()), score: Some(0.88), ..Default::default() },
11//! ];
12//! let reports = score_all(obs, &ScoreConfig::default());
13//! assert_eq!(reports[0].decision, quietset::Decision::Keep);
14//! ```
15
16pub mod active_review;
17pub mod agreement;
18pub mod block;
19pub mod calibration;
20pub mod config;
21pub mod decision;
22pub mod error;
23pub mod group;
24pub mod latent_truth;
25pub mod observation;
26pub mod preflight;
27pub mod schema;
28pub mod scoring;
29pub mod stable_wrong;
30pub mod stream;
31pub mod trajectory_audit;
32pub mod weighting;
33
34pub use active_review::{
35    ActionTargets, ActiveReviewCosts, ActiveReviewEntry, ActiveReviewWeights, RankBy,
36    action_target_fields, rank_active_review, select_within_budget,
37};
38pub use agreement::{
39    compute_fleiss_kappa, compute_krippendorff_alpha, compute_spearman_rank_correlation,
40};
41pub use block::{
42    BlockClass, BlockStabilityReport, BlockThresholds, LossRecipeIssue, compute_block_report,
43    loss_recipe_comparable, loss_recipe_issues, score_all_blocks,
44};
45pub use calibration::{
46    CalibrationOptions, CalibrationResult, GroupKey, GroupLeakage, compute_calibration,
47    compute_calibration_with_options, group_leakage, leaked_group_keys,
48};
49pub use config::{
50    DecisionScore, LatentTruthSafety, MinRequirements, ScoreConfig, ScoreDispersion, ScoreWeights,
51};
52pub use decision::Thresholds;
53pub use error::{Error, Result};
54pub use latent_truth::{LatentTruthEstimate, compute_latent_truth};
55pub use observation::{Observation, parse_csv, parse_jsonl};
56pub use preflight::{
57    BlockRunIdIssue, CheckpointCorrespondence, FieldCoverage, PreflightReport, PreflightTarget,
58    SeedCoverage, SidePreflight, compute_preflight, required_fields,
59};
60pub use schema::{Decision, StabilityComponents, StabilityReport};
61pub use scoring::{compute_report, score_all};
62pub use stable_wrong::{
63    BudgetStableWrongRate, EvaluatorStableWrongRate, ModelStableWrongRate, StableWrongBreakdown,
64    stable_wrong_breakdown,
65};
66pub use stream::StreamingScorer;
67pub use trajectory_audit::{
68    ComparisonIssue, TrajectoryAuditEntry, compute_trajectory_audit, group_size_mismatches,
69};
70pub use weighting::{
71    compute_evaluator_label_weights, compute_evaluator_reliability, compute_evaluator_weights,
72    compute_weighted_majority, compute_weighted_majority_by_label,
73};
74
75use std::collections::HashMap;
76
77/// Score all samples with reliability-weighted majority voting (2-pass).
78///
79/// Pass 1: standard scoring to determine majority labels (or use gold_label if present).
80/// Pass 2: compute per-evaluator reliability weights, then fill weighted_majority_label
81/// and related fields on each report.
82pub fn score_all_weighted(
83    observations: Vec<Observation>,
84    config: &ScoreConfig,
85) -> Vec<StabilityReport> {
86    // Pass 1: standard scoring
87    let mut reports = scoring::score_all(observations.clone(), config);
88
89    // Build truth map: gold_label takes priority over majority_label
90    let majority_map: HashMap<String, String> = reports
91        .iter()
92        .filter_map(|r| r.majority_label.clone().map(|ml| (r.sample_id.clone(), ml)))
93        .collect();
94    let gold_map: HashMap<String, String> = observations
95        .iter()
96        .filter_map(|o| o.gold_label.clone().map(|g| (o.sample_id.clone(), g)))
97        .collect();
98    let truth: HashMap<String, String> = majority_map
99        .into_iter()
100        .map(|(id, ml)| {
101            let label = gold_map.get(&id).cloned().unwrap_or(ml);
102            (id, label)
103        })
104        .collect();
105
106    let evaluator_weights = weighting::compute_evaluator_weights(&observations, &truth);
107    let groups = group::group_by_sample_id(observations.into_iter());
108
109    // Pass 2: fill weighted_* fields
110    for report in &mut reports {
111        if let Some(obs) = groups.get(&report.sample_id) {
112            let (wml, wlc, wld, conflict) = weighting::compute_weighted_majority(
113                obs,
114                report.majority_label.as_deref(),
115                &evaluator_weights,
116            );
117            report.weighted_majority_label = wml;
118            report.weighted_label_confidence = wlc;
119            report.weighted_label_distribution = wld;
120            report.majority_weighted_conflict = conflict;
121        }
122    }
123    reports
124}
125
126/// Score all samples with the gold-label-reliability-weighted vote driving `stability_score`/
127/// `decision` (2-pass). See [`DecisionScore::GoldWeighted`].
128///
129/// Pass 1: standard scoring to determine majority labels.
130/// Pass 2: build a truth map from `gold_label` only (unlike [`score_all_weighted`], which also
131/// falls back to `majority_label`), compute per-(evaluator, predicted label) reliability weights
132/// from it (see [`compute_evaluator_label_weights`]), then re-score each sample with the
133/// resulting weighted-label confidence baked into `stability_score`/`decision`. When no
134/// `gold_label` is present anywhere in the batch, the truth map is empty and every evaluator
135/// gets the neutral default weight, so the result is identical to [`score_all`] with
136/// `DecisionScore::Raw`.
137pub fn score_all_gold_weighted(
138    observations: Vec<Observation>,
139    config: &ScoreConfig,
140) -> Vec<StabilityReport> {
141    let mut reports = scoring::score_all(observations.clone(), config);
142
143    let truth: HashMap<String, String> = observations
144        .iter()
145        .filter_map(|o| o.gold_label.clone().map(|g| (o.sample_id.clone(), g)))
146        .collect();
147    let label_weights = weighting::compute_evaluator_label_weights(&observations, &truth);
148    let groups = group::group_by_sample_id(observations.into_iter());
149
150    for report in &mut reports {
151        if let Some(obs) = groups.get(&report.sample_id) {
152            let (wml, wlc, wld, conflict) = weighting::compute_weighted_majority_by_label(
153                obs,
154                report.majority_label.as_deref(),
155                &label_weights,
156            );
157            *report = scoring::compute_report_inner(&report.sample_id, obs, config, wlc);
158            report.weighted_majority_label = wml;
159            report.weighted_label_confidence = wlc;
160            report.weighted_label_distribution = wld;
161            report.majority_weighted_conflict = conflict;
162        }
163    }
164    reports
165}
166
167/// Score all samples with the Dawid-Skene EM latent-truth vote driving `stability_score`/
168/// `decision` (2-pass). See [`DecisionScore::LatentTruth`].
169///
170/// Pass 1: standard scoring to determine majority labels. Pass 2: run
171/// [`latent_truth::compute_latent_truth`] over all observations (no `gold_label` involved),
172/// then re-score each sample with the resulting posterior confidence baked into
173/// `stability_score`/`decision`. When EM doesn't run at all (fewer than 2 distinct
174/// evaluators or labels in the batch), every sample is left exactly as [`score_all`] with
175/// `DecisionScore::Raw` produced it.
176pub fn score_all_latent_truth(
177    observations: Vec<Observation>,
178    config: &ScoreConfig,
179) -> Vec<StabilityReport> {
180    let mut reports = scoring::score_all(observations.clone(), config);
181
182    let Some(estimates) = latent_truth::compute_latent_truth(&observations) else {
183        return reports;
184    };
185    let groups = group::group_by_sample_id(observations.into_iter());
186
187    for report in &mut reports {
188        let Some(obs) = groups.get(&report.sample_id) else {
189            continue;
190        };
191        if let Some(estimate) = estimates.get(&report.sample_id) {
192            *report = scoring::compute_report_inner(
193                &report.sample_id,
194                obs,
195                config,
196                Some(estimate.confidence),
197            );
198            report.majority_latent_conflict = report
199                .majority_label
200                .as_deref()
201                .map(|m| m != estimate.label.as_str());
202            report.latent_truth_label = Some(estimate.label.clone());
203            report.latent_truth_confidence = Some(estimate.confidence);
204            report.latent_truth_label_distribution = Some(estimate.distribution.clone());
205            report.evaluator_effective_n = Some(estimate.evaluator_effective_n);
206            report.correlated_evaluator_warning = Some(estimate.correlated_evaluator_warning);
207            report.latent_truth_converged = Some(estimate.converged);
208            report.latent_truth_iterations = Some(estimate.iterations);
209            report.latent_truth_convergence_delta = Some(estimate.convergence_delta);
210            // Only ever demotes Keep -> Review, never to Drop: correlated_evaluator_warning,
211            // a low evaluator_effective_n, and non-convergence are all uncertainty signals
212            // about EM's own fit, not evidence that the label itself is wrong. Dropping here
213            // would treat "the model isn't sure" the same as "the model is sure it's wrong",
214            // which would discard potentially-correct samples based on a diagnostic that's
215            // deliberately conservative by design.
216            if report.decision == Decision::Keep
217                && let Some(reason) = latent_truth::latent_truth_demotion_reason(
218                    estimate,
219                    &config.latent_truth_safety,
220                )
221            {
222                report.decision = Decision::Review;
223                report.latent_truth_demotion_reason = Some(reason.to_string());
224            }
225        }
226    }
227    reports
228}