use super::conditions::{resolve, TestConditions};
use super::hash::{canonical_hash, CanonicalHash};
use super::score::ScoringConfig;
use crate::iq::cli::FrontendParams;
use crate::iq::track::design::{Design, DesignFile};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const SCHEMA: &str = "kshana.campaign/1";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Inputs {
pub conditions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub designs: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub design_names: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrontendSpec {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bandpass: Option<[f64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bandpass_transition_hz: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bandpass_atten_db: Option<f64>,
#[serde(default)]
pub notch: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notch_r: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notch_mu: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blank: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blank_hold: Option<usize>,
#[serde(default)]
pub excise: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excise_fft: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excise_pfa: Option<f64>,
#[serde(default)]
pub agc: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agc_tau_s: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bits: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quant_step: Option<f64>,
#[serde(default)]
pub no_agc: bool,
}
impl FrontendSpec {
pub fn raw() -> Self {
Self {
name: "raw".into(),
bandpass: None,
bandpass_transition_hz: None,
bandpass_atten_db: None,
notch: false,
notch_r: None,
notch_mu: None,
blank: None,
blank_hold: None,
excise: false,
excise_fft: None,
excise_pfa: None,
agc: false,
agc_tau_s: None,
bits: None,
quant_step: None,
no_agc: false,
}
}
pub(crate) fn params(&self) -> FrontendParams {
let d = FrontendParams::default();
FrontendParams {
bandpass: self.bandpass.map(|[lo, hi]| (lo, hi)),
bandpass_transition_hz: self.bandpass_transition_hz,
bandpass_atten_db: self.bandpass_atten_db.unwrap_or(d.bandpass_atten_db),
notch: self.notch,
notch_r: self.notch_r.unwrap_or(d.notch_r),
notch_mu: self.notch_mu.unwrap_or(d.notch_mu),
blank: self.blank,
blank_hold: self.blank_hold.unwrap_or(d.blank_hold),
excise: self.excise,
excise_fft: self.excise_fft.unwrap_or(d.excise_fft),
excise_pfa: self.excise_pfa.unwrap_or(d.excise_pfa),
agc: self.agc,
agc_tau_s: self.agc_tau_s.unwrap_or(d.agc_tau_s),
bits: self.bits,
quant_step: self.quant_step,
no_agc: self.no_agc,
}
}
pub fn hash(&self) -> CanonicalHash {
let mut v = serde_json::to_value(self).unwrap_or_default();
if let Some(m) = v.as_object_mut() {
m.remove("name");
}
canonical_hash(&v)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EpochOutputKind {
#[default]
None,
Csv,
Jsonl,
Binary,
}
impl EpochOutputKind {
pub fn format(self) -> Option<(crate::iq::track::sink::EpochFormat, &'static str)> {
use crate::iq::track::sink::EpochFormat;
match self {
Self::None => None,
Self::Csv => Some((EpochFormat::Csv, "csv")),
Self::Jsonl => Some((EpochFormat::Jsonl, "jsonl")),
Self::Binary => Some((EpochFormat::Binary, "bin")),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DataClass {
Synthetic,
ClientConfidential,
}
impl DataClass {
pub fn as_str(self) -> &'static str {
match self {
Self::Synthetic => "synthetic",
Self::ClientConfidential => "client-confidential",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunConfig {
#[serde(default)]
pub max_seconds: f64,
#[serde(default)]
pub workers: usize,
#[serde(default)]
pub epochs: EpochOutputKind,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CampaignSpec {
pub schema: String,
pub name: String,
pub data_class: DataClass,
pub inputs: Inputs,
#[serde(default, rename = "frontend", skip_serializing_if = "Vec::is_empty")]
pub frontends: Vec<FrontendSpec>,
#[serde(default)]
pub run: RunConfig,
#[serde(default)]
pub scoring: ScoringConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monitors: Option<super::detect::MonitorsSpec>,
}
impl CampaignSpec {
pub fn parse(text: &str) -> Result<Self, String> {
let s: CampaignSpec = if text.trim_start().starts_with('{') {
serde_json::from_str(text).map_err(|e| format!("campaign (JSON): {e}"))?
} else {
toml::from_str(text).map_err(|e| format!("campaign (TOML): {e}"))?
};
if s.schema != SCHEMA {
return Err(format!(
"schema must be \"{SCHEMA}\" (got \"{}\")",
s.schema
));
}
if s.inputs.conditions.is_empty() {
return Err("inputs.conditions must list at least one file or glob".into());
}
if !(s.run.max_seconds.is_finite() && s.run.max_seconds >= 0.0) {
return Err("run.max_seconds must be >= 0".into());
}
let mut names = std::collections::BTreeSet::new();
for f in s.frontends() {
if f.name.trim().is_empty() || !names.insert(f.name.clone()) {
return Err(format!(
"frontend names must be non-empty and unique ('{}')",
f.name
));
}
}
s.scoring.validate()?;
if let Some(m) = &s.monitors {
m.validate()?;
}
Ok(s)
}
pub fn frontends(&self) -> Vec<FrontendSpec> {
if self.frontends.is_empty() {
vec![FrontendSpec::raw()]
} else {
self.frontends.clone()
}
}
pub fn run_hash(&self) -> CanonicalHash {
let mut v = serde_json::json!({
"max_seconds": self.run.max_seconds,
"epochs": self.run.epochs,
});
if let (Some(m), Some(o)) = (&self.monitors, v.as_object_mut()) {
o.insert(
"monitors".into(),
serde_json::to_value(m).unwrap_or_default(),
);
}
canonical_hash(&v)
}
pub fn scoring_hash(&self) -> CanonicalHash {
let mut v = serde_json::to_value(&self.scoring).unwrap_or_default();
if let Some(m) = v.as_object_mut() {
m.remove("bars");
}
canonical_hash(&v)
}
}
#[derive(Clone, Debug)]
pub struct LoadedCampaign {
pub spec: CampaignSpec,
pub conditions: Vec<(PathBuf, TestConditions)>,
pub designs: Vec<Design>,
pub frontends: Vec<FrontendSpec>,
}
pub type PathCheck<'a> = &'a dyn Fn(&Path) -> Result<(), String>;
impl LoadedCampaign {
pub fn load_text(text: &str, base: &Path) -> Result<Self, String> {
Self::load_text_checked(text, base, &|_| Ok(()))
}
pub fn load_text_checked(text: &str, base: &Path, check: PathCheck) -> Result<Self, String> {
let spec = CampaignSpec::parse(text)?;
let mut files = Vec::new();
for pat in &spec.inputs.conditions {
let pattern = resolve(base, pat);
check(pattern.parent().unwrap_or(Path::new(".")))?;
let found = expand_glob(&pattern)?;
if found.is_empty() {
return Err(format!("inputs.conditions: '{pat}' matches no file"));
}
files.extend(found);
}
files.sort();
files.dedup();
let mut conditions = Vec::new();
for f in files {
let tc = TestConditions::load_checked(&f, check)?;
conditions.push((f, tc));
}
conditions.sort_by(|a, b| a.1.recording.id.cmp(&b.1.recording.id));
for w in conditions.windows(2) {
if w[0].1.recording.id == w[1].1.recording.id {
return Err(format!(
"recording id '{}' appears in both {} and {}",
w[0].1.recording.id,
w[0].0.display(),
w[1].0.display()
));
}
}
let designs = match &spec.inputs.designs {
None => {
if !spec.inputs.design_names.is_empty() {
return Err("inputs.design_names needs inputs.designs".into());
}
vec![Design::builtin_default()]
}
Some(p) => {
let path = resolve(base, p);
check(&path)?;
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("{}: {e}", path.display()))?;
let file =
DesignFile::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?;
if spec.inputs.design_names.is_empty() {
file.designs().to_vec()
} else {
spec.inputs
.design_names
.iter()
.map(|n| {
file.get(n).cloned().ok_or_else(|| {
format!(
"inputs.design_names: no design '{n}' in {}",
path.display()
)
})
})
.collect::<Result<Vec<_>, _>>()?
}
}
};
let frontends = spec.frontends();
Ok(Self {
spec,
conditions,
designs,
frontends,
})
}
pub fn load(path: &Path) -> Result<Self, String> {
Self::load_checked(path, &|_| Ok(()))
}
pub fn load_checked(path: &Path, check: PathCheck) -> Result<Self, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
Self::load_text_checked(&text, path, check).map_err(|e| format!("{}: {e}", path.display()))
}
}
pub fn expand_glob(pattern: &Path) -> Result<Vec<PathBuf>, String> {
let name = pattern
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
if !name.contains(['*', '?']) {
return Ok(if pattern.is_file() {
vec![pattern.to_path_buf()]
} else {
Vec::new()
});
}
let dir = pattern.parent().unwrap_or(Path::new("."));
let dir = if dir.as_os_str().is_empty() {
Path::new(".")
} else {
dir
};
let rd = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut out = Vec::new();
for ent in rd.flatten() {
let n = ent.file_name().to_string_lossy().into_owned();
if wildcard(&name, &n) && ent.path().is_file() {
out.push(ent.path());
}
}
out.sort();
Ok(out)
}
fn wildcard(pat: &str, text: &str) -> bool {
let (p, t): (Vec<char>, Vec<char>) = (pat.chars().collect(), text.chars().collect());
let (mut pi, mut ti, mut star, mut mark) = (0, 0, None, 0);
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
p[pi..].iter().all(|&c| c == '*')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wildcards_match_file_names() {
assert!(wildcard("*.toml", "run1.toml"));
assert!(wildcard("run?.toml", "run1.toml"));
assert!(!wildcard("run?.toml", "run12.toml"));
assert!(wildcard("r*1*.t*", "run-1-a.toml"));
assert!(!wildcard("*.json", "x.toml"));
}
#[test]
fn a_campaign_parses_with_defaults_and_refuses_unknown_keys() {
let s = CampaignSpec::parse(
"schema = \"kshana.campaign/1\"\nname = \"c\"\ndata_class = \"synthetic\"\n[inputs]\nconditions = [\"*.toml\"]\n[scoring]\njs_bin_db = 2.0\n[scoring.bars]\nmax_reacq_s = 5.0\n",
)
.unwrap();
assert_eq!(s.frontends()[0].name, "raw");
assert_eq!(s.scoring.js_bin_db, 2.0);
assert_eq!(s.scoring.bars.as_ref().unwrap().max_reacq_s, Some(5.0));
let bad = CampaignSpec::parse(
"schema = \"kshana.campaign/1\"\nname = \"c\"\ndata_class = \"synthetic\"\n[inputs]\nconditions = [\"a\"]\n[run]\nthreads = 3\n",
);
assert!(bad.unwrap_err().contains("unknown field"));
}
#[test]
fn data_class_is_required_and_closed() {
let ok = "schema = \"kshana.campaign/1\"\nname = \"c\"\ndata_class = \"client-confidential\"\n[inputs]\nconditions = [\"a\"]\n";
assert_eq!(
CampaignSpec::parse(ok).unwrap().data_class,
DataClass::ClientConfidential
);
let missing = ok.replace("data_class = \"client-confidential\"\n", "");
assert!(CampaignSpec::parse(&missing)
.unwrap_err()
.contains("data_class"));
let typo = ok.replace("client-confidential", "client-confidental");
assert!(CampaignSpec::parse(&typo)
.unwrap_err()
.contains("unknown variant"));
}
#[test]
fn the_worker_count_does_not_change_the_run_hash() {
let a = CampaignSpec::parse(
"schema = \"kshana.campaign/1\"\nname = \"c\"\ndata_class = \"synthetic\"\n[inputs]\nconditions = [\"a\"]\n[run]\nworkers = 1\n",
)
.unwrap();
let mut b = a.clone();
b.run.workers = 8;
assert_eq!(a.run_hash(), b.run_hash());
b.run.max_seconds = 3.0;
assert_ne!(a.run_hash(), b.run_hash());
}
}