use std::{
collections::BTreeMap,
process::{Command, Stdio},
};
use orfail::OrFail;
use crate::json::{JsonNumber, JsonObject, JsonObjectMemberPath, JsonValue};
#[derive(Debug)]
pub struct OptunaStudy {
study_name: String,
storage_url: String,
last_best_trials: Vec<BestTrial>,
}
impl OptunaStudy {
pub fn check_optuna_availability() -> orfail::Result<()> {
let output = Command::new("optuna")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output();
match output {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(-1);
let mut error_msg = format!(
"`$ optuna --version` command failed with exit code {}",
exit_code
);
if !stderr.trim().is_empty() {
error_msg.push_str(&format!("\nstderr: {}", stderr.trim()));
}
error_msg.push_str("\nPlease ensure optuna is properly installed and configured");
Err(orfail::Failure::new(error_msg))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(orfail::Failure::new(
"optuna command not found. Please install optuna and ensure it's in your PATH",
)),
Err(e) => Err(orfail::Failure::new(format!(
"failed to check optuna availability: {e}"
))),
}
}
pub fn new(study_name: String, storage_url: String) -> Self {
Self {
study_name,
storage_url,
last_best_trials: Vec::new(),
}
}
pub fn study_name(&self) -> &str {
&self.study_name
}
pub fn create_study(&self) -> orfail::Result<()> {
let output = Command::new("optuna")
.arg("create-study")
.arg("--study-name")
.arg(&self.study_name)
.arg("--storage")
.arg(&self.storage_url)
.arg("--skip-if-exists") .arg("--directions")
.arg("minimize") .arg("maximize") .stdout(Stdio::null())
.stderr(Stdio::inherit())
.output()
.or_fail_with(|e| format!("failed to execute `$ optuna create-study` command: {e}"))?;
output
.status
.success()
.or_fail_with(|()| "`$ optuna create-study` command failed".to_owned())?;
Ok(())
}
pub fn ask(&self, search_space: &SearchSpace) -> orfail::Result<Trial> {
let output = Command::new("optuna")
.arg("ask")
.arg("--storage")
.arg(&self.storage_url)
.arg("--study-name")
.arg(&self.study_name)
.arg("--search-space")
.arg(search_space.to_optuna_search_space())
.env("PYTHONWARNINGS", "ignore")
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.output()
.or_fail_with(|e| format!("failed to execute `$ optuna ask` command: {e}"))?;
output
.status
.success()
.or_fail_with(|()| "`$ optuna ask` command failed".to_owned())?;
let stdout = String::from_utf8(output.stdout).or_fail()?;
crate::json::parse_str(&stdout).or_fail()
}
pub fn tell(&self, trial_number: usize, values: &TrialValues) -> orfail::Result<()> {
let output = Command::new("optuna")
.arg("tell")
.arg("--storage")
.arg(&self.storage_url)
.arg("--study-name")
.arg(&self.study_name)
.arg("--trial-number")
.arg(trial_number.to_string())
.arg("--values")
.arg(values.elapsed_seconds.to_string())
.arg(values.vmaf_mean.to_string())
.arg("--state")
.arg("complete")
.env("PYTHONWARNINGS", "ignore")
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.output()
.or_fail_with(|e| format!("failed to execute `$ optuna tell` command: {e}"))?;
output
.status
.success()
.or_fail_with(|()| "`$ optuna tell` command failed".to_owned())?;
Ok(())
}
pub fn tell_fail(&self, trial_number: usize) -> orfail::Result<()> {
let output = Command::new("optuna")
.arg("tell")
.arg("--storage")
.arg(&self.storage_url)
.arg("--study-name")
.arg(&self.study_name)
.arg("--trial-number")
.arg(trial_number.to_string())
.arg("--state")
.arg("fail")
.env("PYTHONWARNINGS", "ignore")
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.output()
.or_fail_with(|e| format!("failed to execute `$ optuna tell` command: {e}"))?;
output
.status
.success()
.or_fail_with(|()| "`$ optuna tell` command failed".to_owned())?;
Ok(())
}
pub fn get_best_trials(&mut self) -> orfail::Result<(bool, Vec<BestTrial>)> {
let output = Command::new("optuna")
.arg("best-trials")
.arg("--storage")
.arg(&self.storage_url)
.arg("--study-name")
.arg(&self.study_name)
.arg("-f")
.arg("json")
.env("PYTHONWARNINGS", "ignore")
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.output()
.or_fail_with(|e| format!("failed to execute `$ optuna best-trials` command: {e}"))?;
output
.status
.success()
.or_fail_with(|()| "`$ optuna best-trials` command failed".to_owned())?;
let stdout = String::from_utf8(output.stdout).or_fail()?;
let trials: Vec<BestTrial> = crate::json::parse_str(&stdout).or_fail()?;
let updated = self.last_best_trials != trials;
self.last_best_trials = trials.clone();
Ok((updated, trials))
}
}
#[derive(Debug)]
pub struct Trial {
pub number: usize,
pub params: BTreeMap<JsonObjectMemberPath, JsonValue>,
}
impl Trial {
pub fn apply_params_to_layout(&self, layout: &mut JsonValue) -> orfail::Result<()> {
for (path, value) in &self.params {
*path.get_mut(layout).or_fail()? = value.clone();
}
Ok(())
}
}
impl<'text, 'raw> TryFrom<nojson::RawJsonValue<'text, 'raw>> for Trial {
type Error = nojson::JsonParseError;
fn try_from(value: nojson::RawJsonValue<'text, 'raw>) -> Result<Self, Self::Error> {
Ok(Self {
number: value.to_member("number")?.required()?.try_into()?,
params: value.to_member("params")?.required()?.try_into()?,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrialValues {
pub elapsed_seconds: f64,
pub vmaf_mean: f64,
}
#[derive(Debug)]
pub struct SearchSpace {
pub params: BTreeMap<JsonObjectMemberPath, ParameterDistribution>,
}
impl SearchSpace {
pub fn to_optuna_search_space(&self) -> String {
nojson::json(|f| {
f.object(|f| {
for (path, item) in &self.params {
f.member(path, nojson::json(|f| item.to_optuna_distribution(f)))?;
}
Ok(())
})
})
.to_string()
}
}
impl<'text, 'raw> TryFrom<nojson::RawJsonValue<'text, 'raw>> for SearchSpace {
type Error = nojson::JsonParseError;
fn try_from(value: nojson::RawJsonValue<'text, 'raw>) -> Result<Self, Self::Error> {
Ok(Self {
params: value.try_into()?,
})
}
}
#[derive(Debug)]
pub enum ParameterDistribution {
Numeric { min: JsonNumber, max: JsonNumber },
Categorical(Vec<JsonValue>),
}
impl ParameterDistribution {
fn to_optuna_distribution(&self, f: &mut nojson::JsonFormatter<'_, '_>) -> std::fmt::Result {
match self {
ParameterDistribution::Numeric {
min: JsonNumber::Integer(min),
max: JsonNumber::Integer(max),
} => {
f.object(|f| {
f.member("name", "IntDistribution")?;
f.member(
"attributes",
nojson::json(|f| {
f.object(|f| {
f.member("low", min)?;
f.member("high", max)
})
}),
)
})?;
}
ParameterDistribution::Numeric { min, max } => {
f.object(|f| {
f.member("name", "FloatDistribution")?;
f.member(
"attributes",
nojson::json(|f| {
f.object(|f| {
f.member("low", min)?;
f.member("high", max)
})
}),
)
})?;
}
ParameterDistribution::Categorical(choices) => {
f.object(|f| {
f.member("name", "CategoricalDistribution")?;
f.member(
"attributes",
nojson::json(|f| f.object(|f| f.member("choices", choices))),
)
})?;
}
}
Ok(())
}
}
impl<'text, 'raw> TryFrom<nojson::RawJsonValue<'text, 'raw>> for ParameterDistribution {
type Error = nojson::JsonParseError;
fn try_from(value: nojson::RawJsonValue<'text, 'raw>) -> Result<Self, Self::Error> {
if value.kind().is_array() {
Ok(Self::Categorical(value.try_into()?))
} else if let Ok(object) = JsonObject::new(value) {
Ok(Self::Numeric {
min: object.get_required("min")?,
max: object.get_required("max")?,
})
} else {
Err(value.invalid("not JSON array or JSON object"))
}
}
}
impl nojson::DisplayJson for ParameterDistribution {
fn fmt(&self, f: &mut nojson::JsonFormatter<'_, '_>) -> std::fmt::Result {
match self {
ParameterDistribution::Numeric { min, max } => f.object(|f| {
f.member("min", min)?;
f.member("max", max)
}),
ParameterDistribution::Categorical(choices) => f.value(choices),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BestTrial {
pub number: usize,
pub values: TrialValues,
pub params: BTreeMap<String, JsonValue>,
}
impl<'text, 'raw> TryFrom<nojson::RawJsonValue<'text, 'raw>> for BestTrial {
type Error = nojson::JsonParseError;
fn try_from(value: nojson::RawJsonValue<'text, 'raw>) -> Result<Self, Self::Error> {
let values: [f64; 2] = value.to_member("values")?.required()?.try_into()?;
Ok(Self {
number: value.to_member("number")?.required()?.try_into()?,
values: TrialValues {
elapsed_seconds: values[0],
vmaf_mean: values[1],
},
params: value.to_member("params")?.required()?.try_into()?,
})
}
}