use super::{ConfidenceModeArg, ObjectiveArg, SplitBy};
use anyhow::{Context, Result};
use clap::{Args, ValueEnum};
use lineprior::{
BuildConfig, EvalConfig, TuneConstraints, TuneObjective, TuneOutput, TuneParam,
build_candidate_result, evaluate, expand_grid,
};
use std::fs::File;
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Args)]
pub struct TuneArgs {
input: PathBuf,
#[arg(long, value_enum, default_value_t = SplitBy::Sequence)]
split_by: SplitBy,
#[arg(long, default_value_t = 0.8)]
train_ratio: f64,
#[arg(long = "param")]
params: Vec<String>,
#[arg(long, value_enum, default_value_t = ObjectiveArg::CoveredMrr)]
objective: ObjectiveArg,
#[arg(long)]
min_covered_fraction: Option<f64>,
#[arg(long)]
max_fallback_rate: Option<f64>,
#[arg(long)]
min_top1_hit_rate: Option<f64>,
#[arg(long)]
time_decay_reference_unix_seconds: Option<i64>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
save_best_config: Option<PathBuf>,
#[arg(long)]
strict: bool,
}
fn parse_param(raw: &str) -> Result<TuneParam> {
let (key, values_str) = raw.split_once('=').ok_or_else(|| {
anyhow::anyhow!("invalid --param {raw:?}, expected key=value1,value2,...")
})?;
let raw_values: Vec<&str> = values_str.split(',').collect();
if raw_values.iter().any(|v| v.is_empty()) {
anyhow::bail!("invalid --param {raw:?}: empty value in list");
}
match key {
"confidence-mode" => {
let values = raw_values
.iter()
.map(|v| {
ConfidenceModeArg::from_str(v, true)
.map(lineprior::ConfidenceMode::from)
.map_err(|_| anyhow::anyhow!("invalid confidence-mode value {v:?}"))
})
.collect::<Result<Vec<_>>>()?;
Ok(TuneParam::ConfidenceMode(values))
}
"min-confidence" => Ok(TuneParam::MinConfidence(parse_floats(&raw_values)?)),
"smoothing-alpha" => Ok(TuneParam::SmoothingAlpha(parse_floats(&raw_values)?)),
"confidence-k" => Ok(TuneParam::ConfidenceK(parse_floats(&raw_values)?)),
"confidence-z" => Ok(TuneParam::ConfidenceZ(parse_floats(&raw_values)?)),
"min-count" => Ok(TuneParam::MinCount(parse_uints(&raw_values)?)),
"min-weighted-count" => Ok(TuneParam::MinWeightedCount(parse_floats(&raw_values)?)),
"draw-value" => Ok(TuneParam::DrawValue(parse_floats(&raw_values)?)),
"time-decay-half-life-days" => {
let values = raw_values
.iter()
.map(|v| {
if *v == "none" {
Ok(None)
} else {
v.parse::<f64>().map(Some).map_err(|_| {
anyhow::anyhow!("invalid time-decay-half-life-days value {v:?}")
})
}
})
.collect::<Result<Vec<_>>>()?;
Ok(TuneParam::TimeDecayHalfLifeDays(values))
}
"default-source-weight" => Ok(TuneParam::DefaultSourceWeight(parse_floats(&raw_values)?)),
other => anyhow::bail!(
"unknown --param key {other:?}; supported: confidence-mode, min-confidence, \
smoothing-alpha, confidence-k, confidence-z, min-count, min-weighted-count, \
draw-value, time-decay-half-life-days, default-source-weight"
),
}
}
fn parse_floats(raw_values: &[&str]) -> Result<Vec<f64>> {
raw_values
.iter()
.map(|v| {
v.parse::<f64>()
.map_err(|_| anyhow::anyhow!("invalid numeric value {v:?}"))
})
.collect()
}
fn parse_uints(raw_values: &[&str]) -> Result<Vec<u64>> {
raw_values
.iter()
.map(|v| {
v.parse::<u64>()
.map_err(|_| anyhow::anyhow!("invalid integer value {v:?}"))
})
.collect()
}
pub fn run(args: TuneArgs) -> Result<ExitCode> {
let SplitBy::Sequence = args.split_by;
if matches!(args.objective, ObjectiveArg::Top1AtMinCoverage)
&& args.min_covered_fraction.is_none()
{
eprintln!("error: --objective top1-at-min-coverage requires --min-covered-fraction");
return Ok(ExitCode::from(3));
}
let params: Vec<TuneParam> = match args.params.iter().map(|p| parse_param(p)).collect() {
Ok(params) => params,
Err(err) => {
eprintln!("error: {err}");
return Ok(ExitCode::from(3));
}
};
let sweeps_enabled_decay = params.iter().any(|p| {
matches!(p, TuneParam::TimeDecayHalfLifeDays(values) if values.iter().any(|v| v.is_some()))
});
if sweeps_enabled_decay && args.time_decay_reference_unix_seconds.is_none() {
eprintln!(
"error: --time-decay-reference-unix-seconds is required when a swept \
time-decay-half-life-days value is not `none`"
);
return Ok(ExitCode::from(3));
}
let candidates = expand_grid(&BuildConfig::default(), ¶ms);
let eval_config = EvalConfig {
train_ratio: args.train_ratio,
top_k: vec![1],
calibration_bins: None,
thresholds: Vec::new(),
};
let constraints = TuneConstraints {
min_covered_fraction: args.min_covered_fraction,
max_fallback_rate: args.max_fallback_rate,
min_top1_hit_rate: args.min_top1_hit_rate,
};
let objective: TuneObjective = args.objective.into();
let mut all_results = Vec::new();
let mut warnings = Vec::new();
let mut skipped_config_count = 0usize;
for (config_id, mut config) in candidates {
if let Some(reference) = args.time_decay_reference_unix_seconds {
config.time_decay_reference_unix_seconds = Some(reference);
}
let train_file = match File::open(&args.input) {
Ok(f) => f,
Err(err) => {
eprintln!("error: opening {}: {err}", args.input.display());
return Ok(ExitCode::from(3));
}
};
let test_file = match File::open(&args.input) {
Ok(f) => f,
Err(err) => {
eprintln!("error: opening {}: {err}", args.input.display());
return Ok(ExitCode::from(3));
}
};
match evaluate(train_file, test_file, args.strict, &config, &eval_config) {
Ok(output) => {
let result = build_candidate_result(
config_id,
config,
&output.report,
objective,
&constraints,
);
all_results.push(result);
}
Err(err) => {
skipped_config_count += 1;
warnings.push(format!("{config_id}: {err}"));
}
}
}
let tune_output = TuneOutput::from_results(
all_results,
objective,
constraints,
skipped_config_count,
warnings,
);
if let Some(path) = &args.save_best_config {
match &tune_output.best {
Some(best) => {
let json = serde_json::to_string_pretty(&best.build_config)?;
std::fs::write(path, json)
.with_context(|| format!("writing {}", path.display()))?;
}
None => {
eprintln!("error: no candidate config satisfied the constraints; nothing to save");
return Ok(ExitCode::from(2));
}
}
}
let json = serde_json::to_string_pretty(&tune_output)?;
match args.out {
Some(path) => {
std::fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?;
}
None => println!("{json}"),
}
if tune_output.evaluated_config_count == 0 {
eprintln!("error: no usable data (every candidate config failed to evaluate)");
return Ok(ExitCode::from(2));
}
if !tune_output.warnings.is_empty() || tune_output.skipped_config_count > 0 {
return Ok(ExitCode::from(1));
}
Ok(ExitCode::from(0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_confidence_mode_values() {
let param = parse_param("confidence-mode=heuristic,wilson-lower-bound,hybrid").unwrap();
assert!(matches!(
param,
TuneParam::ConfidenceMode(values) if values == vec![
lineprior::ConfidenceMode::Heuristic,
lineprior::ConfidenceMode::WilsonLowerBound,
lineprior::ConfidenceMode::Hybrid,
]
));
}
#[test]
fn parses_numeric_and_uint_params() {
assert!(matches!(
parse_param("min-confidence=0.0,0.5").unwrap(),
TuneParam::MinConfidence(values) if values == vec![0.0, 0.5]
));
assert!(matches!(
parse_param("min-count=1,3,5").unwrap(),
TuneParam::MinCount(values) if values == vec![1, 3, 5]
));
}
#[test]
fn none_is_accepted_only_for_time_decay_half_life_days() {
assert!(matches!(
parse_param("time-decay-half-life-days=none,30,90").unwrap(),
TuneParam::TimeDecayHalfLifeDays(values) if values == vec![None, Some(30.0), Some(90.0)]
));
assert!(parse_param("min-confidence=none").is_err());
}
#[test]
fn unknown_param_key_is_rejected() {
let err = parse_param("not-a-real-key=1,2").unwrap_err();
assert!(err.to_string().contains("unknown --param key"));
}
#[test]
fn invalid_numeric_value_is_rejected() {
assert!(parse_param("min-confidence=oops").is_err());
assert!(parse_param("min-count=1.5").is_err()); }
#[test]
fn missing_equals_sign_is_rejected() {
assert!(parse_param("min-confidence").is_err());
}
}