use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Args, Subcommand};
use tga::core::config::Config;
use tga::eval::{self, RepredictParams, SampleParams, ScoreParams, Stratum, SubsampleParams};
const PRIVACY: &str = "PRIVACY: every file this command writes contains commit text \
(subjects, bodies, paths, PR titles). Store the output directory privately, outside any \
repository, and delete it when the evaluation is done. Nothing is written unless you \
name the directory with --out.";
#[derive(Args, Debug)]
#[command(after_help = PRIVACY)]
pub struct EvalArgs {
#[command(subcommand)]
pub step: EvalSubcommand,
}
#[derive(Subcommand, Debug)]
#[non_exhaustive]
pub enum EvalSubcommand {
#[command(after_help = PRIVACY)]
Sample(SampleArgs),
#[command(after_help = PRIVACY)]
Subsample(SubsampleArgs),
#[command(after_help = PRIVACY)]
Repredict(RepredictArgs),
#[command(after_help = PRIVACY)]
Score(ScoreArgs),
}
#[derive(Args, Debug)]
pub struct SampleArgs {
#[arg(long)]
pub db: PathBuf,
#[arg(long, default_value_t = 26)]
pub weeks: u32,
#[arg(long, default_value_t = 400)]
pub size: usize,
#[arg(long)]
pub seed: u64,
#[arg(long, default_value_t = 5)]
pub cap: usize,
#[arg(long)]
pub salt: Option<String>,
#[arg(long)]
pub out: PathBuf,
}
#[derive(Args, Debug)]
pub struct SubsampleArgs {
#[arg(long)]
pub from: PathBuf,
#[arg(long)]
pub strata: Option<PathBuf>,
#[arg(long)]
pub size: usize,
#[arg(long)]
pub seed: u64,
#[arg(long)]
pub db: Option<PathBuf>,
#[arg(long)]
pub out: PathBuf,
}
#[derive(Args, Debug)]
pub struct RepredictArgs {
#[arg(long)]
pub sample: PathBuf,
#[arg(long)]
pub db: PathBuf,
#[arg(long)]
pub out: PathBuf,
}
#[derive(Args, Debug)]
pub struct ScoreArgs {
#[arg(long)]
pub sample: PathBuf,
#[arg(long, required = true, num_args = 1)]
pub labels: Vec<PathBuf>,
#[arg(long)]
pub adjudicated: Option<PathBuf>,
#[arg(long)]
pub strata: Option<PathBuf>,
#[arg(long)]
pub db: Option<PathBuf>,
#[arg(long)]
pub out: PathBuf,
}
fn warn_if_in_repo(out: &Path) {
let probe = out
.ancestors()
.find(|p| p.exists())
.unwrap_or(Path::new("."));
if let Ok(repo) = git2::Repository::discover(probe) {
let root = repo.workdir().unwrap_or(repo.path());
eprintln!(
"warning: {} is inside the git work tree {}; these files contain commit text — \
keep them out of version control",
out.display(),
root.display()
);
}
}
pub fn run(
args: EvalArgs,
config: Config,
config_path: &Path,
config_explicit: bool,
) -> Result<()> {
match args.step {
EvalSubcommand::Sample(a) => run_sample(a, config, config_path),
EvalSubcommand::Subsample(a) => run_subsample(a),
EvalSubcommand::Repredict(a) => run_repredict(a, config, config_path, config_explicit),
EvalSubcommand::Score(a) => run_score(a, config, config_explicit),
}
}
fn run_sample(a: SampleArgs, config: Config, config_path: &Path) -> Result<()> {
if !config_path.exists() {
bail!(
"config file {} not found — `tga eval sample` evaluates a config's rules, pass it with --config",
config_path.display()
);
}
warn_if_in_repo(&a.out);
let summary = eval::run_sample(&SampleParams {
db: a.db,
config,
weeks: a.weeks,
size: a.size,
seed: a.seed,
cap: a.cap,
salt: a.salt,
out: a.out.clone(),
})
.context("tga eval sample")?;
let s = &summary.strata;
println!(
"Window {} → {} ({} weeks): {} commits, seed {}, cap {}",
s.window_start, s.window_end, s.weeks, s.population, s.seed, s.cap
);
println!(
"Excluded {} merge commits (2+ parents) from the window.",
s.merges_excluded
);
println!(
"{:<14} {:>10} {:>8} {:>10}",
"stratum", "population", "sampled", "weight"
);
let mut total = 0;
for stratum in Stratum::ALL {
let c = s.strata.get(stratum.as_str()).cloned().unwrap_or_default();
total += c.sampled;
let weight = if c.sampled > 0 {
format!("{:.2}", c.population as f64 / c.sampled as f64)
} else {
"—".into()
};
println!(
"{:<14} {:>10} {:>8} {:>10}",
stratum.as_str(),
c.population,
c.sampled,
weight
);
}
println!("Sampled {total} of {} requested.", s.requested_size);
if total < s.requested_size && total < s.population {
println!(
"Note: the per-repo and per-author --cap limited the sample; with few repositories or authors, raise --cap."
);
}
if summary.drifted > 0 {
println!(
"Note: {} stored verdicts differ from the current rules; the sample measures the current rules.",
summary.drifted
);
}
if summary.bad_timestamps > 0 {
println!(
"Note: {} commits skipped for an unparseable timestamp.",
summary.bad_timestamps
);
}
for f in &summary.files {
println!("wrote {}", f.display());
}
if let Some(salt) = &summary.salt_file {
println!(
"Generated salt saved to {}; pass it with --salt to redraw identical author hashes.",
salt.display()
);
}
println!(
"Valid labels: {}, {}",
s.categories.join(", "),
eval::score::NO_ANSWER_LABELS.join(", ")
);
println!("{PRIVACY}");
Ok(())
}
fn run_subsample(a: SubsampleArgs) -> Result<()> {
warn_if_in_repo(&a.out);
let summary = eval::run_subsample(&SubsampleParams {
from: a.from,
strata: a.strata,
size: a.size,
seed: a.seed,
db: a.db,
out: a.out,
})
.context("tga eval subsample")?;
let s = &summary.strata;
let origin = s.subsample.clone().unwrap_or_default();
println!(
"Subset of {} rows from {}, seed {}",
origin.size, origin.source_size, origin.seed
);
println!("{:<14} {:>10} {:>8}", "stratum", "population", "subset");
for stratum in Stratum::ALL {
let c = s.strata.get(stratum.as_str()).cloned().unwrap_or_default();
if c.sampled > 0 {
println!(
"{:<14} {:>10} {:>8}",
stratum.as_str(),
c.population,
c.sampled
);
}
}
for f in &summary.files {
println!("wrote {}", f.display());
}
println!("{PRIVACY}");
Ok(())
}
fn run_repredict(
a: RepredictArgs,
config: Config,
config_path: &Path,
config_explicit: bool,
) -> Result<()> {
if !config_explicit || !config_path.exists() {
bail!(
"`tga eval repredict` applies a config's rules; name an existing config file with --config"
);
}
warn_if_in_repo(&a.out);
let summary = eval::run_repredict(&RepredictParams {
sample: a.sample,
db: a.db,
config,
config_path: config_path.to_path_buf(),
out: a.out,
})
.context("tga eval repredict")?;
let p = &summary.provenance;
println!(
"Re-predicted {} rows under {} (blake3 {}): {} changed, {} abstain, {} carried from the \
database {:?}, {} stored verdicts superseded",
p.rows,
p.config.path,
&p.config.blake3[..16],
p.changed,
p.abstentions,
p.carried.values().sum::<u64>(),
p.carried,
p.superseded
);
for f in &summary.files {
println!("wrote {}", f.display());
}
println!(
"Score it with `tga eval score --sample {} --strata <the source strata.json> --config <config>`.",
summary.files[0].display()
);
println!("{PRIVACY}");
Ok(())
}
fn run_score(a: ScoreArgs, config: Config, config_explicit: bool) -> Result<()> {
if a.labels.len() > 2 {
bail!("pass at most two --labels files");
}
warn_if_in_repo(&a.out);
let categories = if config_explicit {
Some(eval::config_categories(&config).context("loading categories from --config")?)
} else {
None
};
let report = eval::run_score(&ScoreParams {
sample: a.sample,
strata: a.strata,
labels: a.labels,
adjudicated: a.adjudicated,
categories,
db: a.db,
out: a.out.clone(),
})
.context("tga eval score")?;
println!(
"Sample {} · labelled {} · scored {} · unclear {} · mixed {} · release_merge {} · unresolved {}",
report.sample_size,
report.labelled,
report.scored,
report.unclear,
report.mixed,
report.release_merge,
report.unresolved_disagreements
);
println!("{} rows excluded as merges", report.merges_excluded);
if report.window_merges_estimated > 0 || report.window_merges_exact.is_some() {
println!(
"Window merges: {} estimated from the sample's strata, {} exact from --db",
report.window_merges_estimated,
report
.window_merges_exact
.map_or_else(|| "—".to_string(), |n| n.to_string())
);
}
if let Some(w) = &report.weighted_accuracy {
println!(
"Stratum-weighted accuracy {:.1}% [{:.1}%, {:.1}%]",
w.estimate * 100.0,
w.ci_low * 100.0,
w.ci_high * 100.0
);
}
println!("Abstention share {:.1}%", report.abstention.share * 100.0);
if let Some(k) = report.kappa.as_ref().and_then(|k| k.kappa) {
println!("Cohen's kappa {k:.3}");
}
println!("wrote {}", a.out.join("report.md").display());
println!("wrote {}", a.out.join("report.json").display());
Ok(())
}