use crate::{DenoiseStats, DnoiseError, FilterParams, Result, RunOptions, Stages};
use serde_json::{Value, json};
use std::io::{Read, Write};
use std::path::Path;
pub const FILE_NAME: &str = "dnoise.provenance.json";
pub const CONFIG_NAME: &str = "dnoise.config.toml";
pub use dnoise_core::neighbor::NeighborUsage;
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
pub struct ActiveGates {
pub ms1_neighbors: bool,
pub prm_neighbors: bool,
pub dia_neighbors: bool,
pub prm_per_event: bool,
pub ms1_polygon: bool,
pub dia_ms1: bool,
pub dia_window: bool,
pub dda_window: bool,
pub dia_scan_varying: bool,
pub dia_per_window: bool,
}
pub fn read(input: &Path) -> Result<Option<Value>> {
let file = match std::fs::File::open(input.join(FILE_NAME)) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let mut bytes = Vec::new();
file.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
if bytes.len() > 16 * 1024 * 1024 {
return Err(DnoiseError::InvalidInput(
"provenance exceeds 16 MiB".into(),
));
}
let value: Value = serde_json::from_slice(&bytes)
.map_err(|e| DnoiseError::InvalidInput(format!("invalid dnoise provenance: {e}")))?;
if value["schema_version"] != 1
|| value["tool"] != "dnoise"
|| value["history"].as_array().is_none_or(|h| h.is_empty())
{
return Err(DnoiseError::InvalidInput(
"unsupported or incomplete dnoise provenance".into(),
));
}
Ok(Some(value))
}
pub fn report(
input: &Path,
params: &FilterParams,
stages: &Stages,
options: &RunOptions,
stats: &DenoiseStats,
) -> Value {
let mut warnings = Vec::new();
let acquisition = crate::detect_acquisition(input);
if matches!(
acquisition,
Ok(crate::Acquisition::PrmPasef | crate::Acquisition::Mixed)
) {
warnings.push("PRM/mixed acquisition: discovery acquisition gates disabled; explicit crops can change MS/MS data");
}
if stats.active_gates.prm_per_event {
warnings.push("experimental prm-PASEF MS/MS denoising; target boundaries enforced; downstream quantitative fidelity is unvalidated");
}
if stats.active_gates.ms1_neighbors
|| stats.active_gates.prm_neighbors
|| stats.active_gates.dia_neighbors
{
warnings.push("experimental neighbor support: summed evidence changes keep decisions; native output intensities preserved unless smoothing/centroiding enabled; quantitative fidelity is unvalidated");
}
if stats.active_gates.dia_scan_varying {
warnings.push("experimental scanning DIA filtering: continuous quadrupole steps grouped for mobility support; native scan-dependent geometry preserved; downstream identification and quantitative fidelity are unvalidated");
}
if stats.ms1_frames == 0
&& !options.crop_only
&& stages.denoise_msms.is_none()
&& !stages.filter_all_frames
{
warnings.push("no MS1 frames; MS1-only denoising has no points to filter");
}
if stats.multiple_calibrations {
warnings.push("multiple calibration references detected; physical gates/crops needing these conversions are rejected");
}
if stages.ms1_polygon.is_some() && !stats.active_gates.ms1_polygon && !options.crop_only {
warnings.push("MS1 polygon requested but no applicable polygon was active");
}
if stages.dia_ms1.is_some() && !stats.active_gates.dia_ms1 && !options.crop_only {
warnings.push("DIA MS1 gate requested but no applicable windows were active");
}
let mut value = json!({
"schema_version": 1, "tool": "dnoise", "software_version": env!("CARGO_PKG_VERSION"),
"build_revision": env!("DNOISE_BUILD_REVISION"),
"status": if stats.dry_run { "estimate" } else { "completed" },
"operation": if options.crop_only { "crop_only" } else { "denoise" },
"completed_unix_seconds": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
"input_name": input.file_name().map(|s|s.to_string_lossy()),
"acquisition": acquisition.map(|a|format!("{a:?}")).unwrap_or_else(|_|"unknown".into()),
"frame_batch_size": options.frame_batch_size.unwrap_or(2048),
"validation": if options.skip_validation { "structural" } else { "full" },
"config": {"filter": params, "stages": stages, "crop": options.crop, "crop_only": options.crop_only},
"sample": options.sample.map(|s| json!({"fraction":s.fraction,"seed":s.seed})),
"stats": stats, "warnings": warnings,
"intensity_basis": "stored_integer",
});
#[cfg(feature = "config")]
{
value["effective_config"] = serde_json::to_value(crate::config::Config::from_effective(
params, stages, options,
))
.expect("validated config is serializable");
}
#[cfg(feature = "config")]
{
value["effective_config"]["threads"] = json!(stats.worker_threads);
}
value["unit_equivalents"] = json!(
(!options.crop_only)
.then(|| crate::units::for_run(&input.join("analysis.tdf"), params, stages).ok())
.flatten()
);
value["dry_run"] = json!(stats.dry_run);
value["elapsed_seconds"] = json!(stats.elapsed_seconds);
value["stats"]["kept_pct"] = json!(if stats.raw_points > 0 {
100.0 * stats.kept_points as f64 / stats.raw_points as f64
} else {
0.0
});
value["stats"]["ms1_kept_pct"] = json!(if stats.raw_ms1_points > 0 {
100.0 * stats.kept_ms1_points as f64 / stats.raw_ms1_points as f64
} else {
0.0
});
let percent = |kept: u64, raw: u64| {
if raw == 0 {
None
} else {
Some(100.0 * kept as f64 / raw as f64)
}
};
value["stats"]["ms1_intensity_retained_pct"] = json!(percent(
stats.kept_ms1_summed_intensity,
stats.raw_ms1_summed_intensity
));
value["stats"]["msms_intensity_retained_pct"] = json!(percent(
stats.kept_msms_summed_intensity,
stats.raw_msms_summed_intensity
));
value
}
pub(crate) fn write(
input: &Path,
output: &Path,
params: &FilterParams,
stages: &Stages,
options: &RunOptions,
stats: &DenoiseStats,
) -> Result<()> {
let mut record =
read(input)?.unwrap_or_else(|| json!({"schema_version":1,"tool":"dnoise","history":[]}));
record["history"]
.as_array_mut()
.unwrap()
.push(report(input, params, stages, options, stats));
let bytes =
serde_json::to_vec_pretty(&record).map_err(|e| DnoiseError::InvalidInput(e.to_string()))?;
let mut file = std::fs::File::create(output.join(FILE_NAME))?;
file.write_all(&bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
#[cfg(feature = "config")]
crate::config::Config::from_effective(params, stages, options)
.save(&output.join(CONFIG_NAME))
.map_err(DnoiseError::InvalidInput)?;
#[cfg(not(feature = "config"))]
match std::fs::remove_file(output.join(CONFIG_NAME)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
Ok(())
}