use crate::agent::CliError;
use saccade_core::evidence_quality::{self as eq, reference, trial};
use std::path::{Path, PathBuf};
#[derive(clap::Args)]
pub(crate) struct ReferenceArgs {
#[command(flatten)]
pub strict_arms: crate::arms_cmd::StrictArgs,
#[arg(long = "intended-variable")]
pub intended_variables: Vec<String>,
#[arg(long)]
pub config: Option<PathBuf>,
pub render: PathBuf,
pub reference: PathBuf,
#[arg(long = "seed-reference", conflicts_with = "variance")]
pub seeds: Vec<PathBuf>,
#[arg(long)]
pub variance: Option<PathBuf>,
#[arg(long)]
pub mask: Option<PathBuf>,
#[arg(long)]
pub policy: Option<PathBuf>,
#[arg(long)]
pub out: Option<PathBuf>,
#[arg(long)]
pub json: bool,
}
fn load(
path: &Path,
hashes: &mut std::collections::BTreeMap<String, String>,
) -> Result<image::DynamicImage, CliError> {
let bytes = eq::read(path, 128 << 20)?;
let image = eq::decode(&bytes, path)?;
hashes.insert(
path.to_string_lossy().into_owned(),
saccade_core::localized::digest(&bytes),
);
Ok(image)
}
pub(crate) fn reference_value(args: &ReferenceArgs) -> Result<reference::Report, CliError> {
if args.seeds.len() > 31 || (!args.seeds.is_empty() && args.variance.is_some()) {
return Err(CliError::usage(
"use at most 31 extra seeds, or a variance map",
));
}
let mut cfg = crate::load_config(args.config.as_deref())?;
args.strict_arms.apply(&mut cfg.meta);
cfg.meta
.intended
.extend(args.intended_variables.iter().cloned());
saccade_core::arms::enforce(&args.reference, &args.render, &cfg)?;
for seed in &args.seeds {
saccade_core::arms::enforce(&args.reference, seed, &cfg)?;
}
let mut hashes = Default::default();
let render = load(&args.render, &mut hashes)?;
let reference_image = load(&args.reference, &mut hashes)?;
let pixels = u64::from(reference_image.width()) * u64::from(reference_image.height());
if pixels.saturating_mul(args.seeds.len() as u64 + 2) > 16_777_216 {
return Err(CliError::usage("reference aggregate pixel budget exceeded"));
}
let seeds = args
.seeds
.iter()
.map(|p| load(p, &mut hashes))
.collect::<Result<Vec<_>, _>>()?;
let variance = args
.variance
.as_ref()
.map(|p| -> Result<Vec<f64>, CliError> {
let img = load(p, &mut hashes)?;
if (img.width(), img.height()) != (reference_image.width(), reference_image.height()) {
return Err(CliError::usage("variance map dimensions differ"));
}
Ok(img.to_rgb32f().pixels().map(|p| f64::from(p[0])).collect())
})
.transpose()?;
let excluded = args
.mask
.as_ref()
.map(|p| -> Result<Vec<bool>, CliError> {
let img = load(p, &mut hashes)?.to_luma8();
if img.dimensions() != (reference_image.width(), reference_image.height()) {
return Err(CliError::usage("mask dimensions differ"));
}
Ok(img.pixels().map(|p| p[0] == 0).collect())
})
.transpose()?;
let policy = if let Some(p) = &args.policy {
let bytes = eq::read(p, 1 << 20)?;
hashes.insert(
p.to_string_lossy().into_owned(),
saccade_core::localized::digest(&bytes),
);
serde_json::from_slice(&bytes)?
} else {
reference::Policy::default()
};
let noise = if let Some(v) = &variance {
reference::Noise::Variance(v)
} else if seeds.is_empty() {
reference::Noise::Estimate
} else {
reference::Noise::Seeds(&seeds)
};
let mut report = reference::compare(
&render,
&reference_image,
noise,
excluded.as_deref(),
&policy,
&Default::default(),
)?;
if cfg.meta.require_valid_arms {
report.arm_validation = Some(saccade_core::arms::check_paths(
&args.reference,
&args.render,
&cfg.meta,
)?);
}
report.input_sha256 = hashes;
Ok(report)
}
fn emit<T: serde::Serialize>(value: &T, json: bool) -> Result<(), CliError> {
let text = if json {
serde_json::to_string(value)?
} else {
serde_json::to_string_pretty(value)?
};
crate::emit(&format!("{text}\n"))
}
pub(crate) fn reference(args: ReferenceArgs) -> Result<u8, CliError> {
let report = reference_value(&args)?;
if let Some(p) = &args.out {
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(p)
.map_err(|e| CliError::io(e.to_string()))?;
let linked = saccade_core::report_links::decorate(&serde_json::to_value(&report)?)?;
f.write_all(&serde_json::to_vec_pretty(&linked)?)
.map_err(|e| CliError::io(e.to_string()))?;
saccade_core::report_links::index(p, &linked)?;
}
emit(&report, args.json)?;
Ok(if report.verdict == "within_noise_floor" {
0
} else {
1
})
}
#[derive(clap::Args)]
pub(crate) struct TrialArgs {
#[command(subcommand)]
pub operation: TrialOperation,
}
#[derive(clap::Subcommand)]
pub(crate) enum TrialOperation {
Register {
plan: PathBuf,
#[arg(long)]
out: PathBuf,
},
Start {
plan: PathBuf,
#[arg(long)]
out: PathBuf,
},
Vote {
plan: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
voter: String,
#[arg(long)]
item: String,
#[arg(long)]
answer: String,
},
Import {
plan: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
voter: String,
judgments: PathBuf,
},
}
pub(crate) fn trial(args: TrialArgs, json: bool) -> Result<u8, CliError> {
let receipt = match args.operation {
TrialOperation::Register { plan, out } => trial::register(&plan, &out)?,
TrialOperation::Start { plan, out } => trial::start(&plan, &out)?,
TrialOperation::Vote {
plan,
out,
voter,
item,
answer,
} => trial::vote(&plan, &out, &voter, &item, &answer)?,
TrialOperation::Import {
plan,
out,
voter,
judgments,
} => trial::import(&plan, &out, &voter, &eq::read(&judgments, 1 << 20)?)?,
};
emit(&receipt, json)?;
Ok(0)
}