use super::steps::Changed;
use super::{rollup, round, series, sources, steps};
use crate::analyzer::Analyzer;
use crate::engine::RepositoryState;
use crate::operations::history::resolve_revision;
use crate::operations::history::revision::revision_graph;
use crate::operations::{arg_str, health, optional_str, optional_u64};
use blazingly_json::{Value, json};
use weavatrix_git::{ObjectId, Repository};
use weavatrix_graph::Graph;
const MODEL: &str = "each step pairs two measured revisions and lists the declarations that \
differ between them; a declaration is credited with the step delta divided by the number of \
declarations that changed with it. This is co-occurrence between a caller's measurement and \
static structural change, not profiler attribution: a step that moved many declarations is \
weak evidence for each of them, and a step inside the measured noise floor is evidence for \
none.";
#[derive(Clone, Copy, PartialEq, Eq)]
enum Direction {
LowerIsBetter,
HigherIsBetter,
}
impl Direction {
fn parse(args: &Value) -> Result<Self, String> {
match optional_str(args, "direction")?.unwrap_or("lower_is_better") {
"lower_is_better" => Ok(Self::LowerIsBetter),
"higher_is_better" => Ok(Self::HigherIsBetter),
other => Err(format!(
"direction {other:?} is invalid; expected lower_is_better or higher_is_better"
)),
}
}
fn as_str(self) -> &'static str {
match self {
Self::LowerIsBetter => "lower_is_better",
Self::HigherIsBetter => "higher_is_better",
}
}
fn verdict(self, delta: f64, percent: Option<f64>, flat_percent: f64) -> &'static str {
if percent.unwrap_or(f64::INFINITY).abs() <= flat_percent {
return "flat";
}
let better = match self {
Self::LowerIsBetter => delta < 0.0,
Self::HigherIsBetter => delta > 0.0,
};
if better { "improved" } else { "regressed" }
}
}
struct Settings {
direction: Direction,
flat_percent: f64,
top: usize,
scope: Option<String>,
}
struct Point {
row: usize,
revision: String,
resolved: ObjectId,
value: f64,
}
struct Walked {
steps: Vec<Value>,
by_symbol: Vec<Value>,
noise: Vec<Value>,
analyses: usize,
}
pub(in crate::operations) fn attribution(
state: &RepositoryState,
args: &Value,
) -> Result<Value, String> {
let metric = arg_str(args, "metric")?;
let file = arg_str(args, "measurements_file")?;
let revision_column = optional_str(args, "revision_column")?.unwrap_or("commit");
let max_revisions = usize::try_from(optional_u64(args, "max_revisions")?.unwrap_or(12))
.map_err(|_| "max_revisions is too large".to_owned())?;
if max_revisions < 2 {
return Err("max_revisions must be at least 2: one step needs two measurements".to_owned());
}
let settings = Settings {
direction: Direction::parse(args)?,
flat_percent: percent_band(args)?,
top: usize::try_from(optional_u64(args, "top_n")?.unwrap_or(20))
.map_err(|_| "top_n is too large".to_owned())?,
scope: health::paths::requested_path_scope(args)?,
};
let repository = Repository::open(state.root()).map_err(|error| error.to_string())?;
let mut parsed = series::read(state.root(), file, revision_column, metric)?;
let points = resolve(&repository, &mut parsed, max_revisions);
if points.len() < 2 {
return Err(format!(
"{file} yields {} usable revision(s) for column {metric}; one step needs two",
points.len()
));
}
let walked = walk(state, &repository, &points, args, &settings)?;
Ok(report(file, metric, &settings, &parsed, &points, &walked))
}
fn percent_band(args: &Value) -> Result<f64, String> {
let value = optional_u64(args, "min_delta_percent")?.unwrap_or(0);
Ok(f64::from(u32::try_from(value).map_err(|_| {
"min_delta_percent is too large".to_owned()
})?))
}
fn resolve(
repository: &Repository,
parsed: &mut series::Series,
max_revisions: usize,
) -> Vec<Point> {
let mut points = Vec::new();
for measurement in &parsed.measurements {
match resolve_revision(repository, &measurement.revision) {
Ok(resolved) => points.push(Point {
row: measurement.row,
revision: measurement.revision.clone(),
resolved,
value: measurement.value,
}),
Err(error) => parsed.skipped.push((measurement.row, error)),
}
}
if points.len() > max_revisions {
points.drain(..points.len() - max_revisions);
}
points
}
fn walk(
state: &RepositoryState,
repository: &Repository,
points: &[Point],
args: &Value,
settings: &Settings,
) -> Result<Walked, String> {
let analyzer = Analyzer::default();
let mut rollup = rollup::Rollup::new();
let mut walked = Walked {
steps: Vec::new(),
by_symbol: Vec::new(),
noise: Vec::new(),
analyses: 0,
};
let mut previous: Option<(&Point, Graph)> = None;
for point in points {
let Some((before, before_graph)) = previous.take() else {
walked.analyses += 1;
previous = Some((
point,
revision_graph(&analyzer, repository, state, point.resolved)?,
));
continue;
};
if before.resolved == point.resolved {
let (rendered, _) = step(before, point, &Changed::none(), settings, "same_revision");
walked.noise.push(rendered.clone());
walked.steps.push(rendered);
previous = Some((point, before_graph));
continue;
}
walked.analyses += 1;
let after_graph = revision_graph(&analyzer, repository, state, point.resolved)?;
let texts = sources::changed_files(repository, &before_graph, &after_graph);
let changed = steps::changed(
&before_graph,
&after_graph,
&texts,
args,
settings.scope.as_deref(),
);
let (rendered, verdict) = step(before, point, &changed, settings, "compared");
rollup.record(&changed.symbols, delta(before, point), verdict);
walked.steps.push(rendered);
previous = Some((point, after_graph));
}
walked.by_symbol = rollup.into_values(settings.top);
Ok(walked)
}
fn step(
before: &Point,
after: &Point,
changed: &Changed,
settings: &Settings,
kind: &'static str,
) -> (Value, &'static str) {
let delta = delta(before, after);
let percent = percent(before.value, delta);
let verdict = settings
.direction
.verdict(delta, percent, settings.flat_percent);
let rendered = json!({
"kind": kind,
"from": endpoint(before),
"to": endpoint(after),
"delta": round(delta),
"delta_percent": percent.map(round),
"verdict": verdict,
"changed_symbols": changed.symbols.len(),
"changed_files": changed.files,
"symbols": changed.symbols.iter().take(settings.top)
.map(steps::render).collect::<Vec<_>>()
});
(rendered, verdict)
}
fn endpoint(point: &Point) -> Value {
json!({
"revision": point.revision,
"resolved": point.resolved.to_string(),
"row": point.row,
"value": point.value
})
}
fn delta(before: &Point, after: &Point) -> f64 {
after.value - before.value
}
fn percent(baseline: f64, delta: f64) -> Option<f64> {
(baseline.abs() > f64::EPSILON).then(|| delta / baseline * 100.0)
}
fn report(
file: &str,
metric: &str,
settings: &Settings,
parsed: &series::Series,
points: &[Point],
walked: &Walked,
) -> Value {
json!({
"status": "COMPLETE",
"git_evidence": {"present": true},
"metric": metric,
"direction": settings.direction.as_str(),
"series": {
"file": file,
"columns": parsed.columns,
"data_rows": parsed.data_rows,
"measurements_used": points.len(),
"skipped_rows": parsed.skipped.len(),
"skipped": parsed.skipped.iter().take(settings.top)
.map(|(row, reason)| json!({"row": row, "reason": reason}))
.collect::<Vec<_>>()
},
"revisions_analyzed": walked.analyses,
"noise_floor": noise_floor(&walked.noise),
"steps": walked.steps,
"by_symbol": walked.by_symbol,
"model": MODEL,
"source_mutation": "NONE"
})
}
fn noise_floor(samples: &[Value]) -> Value {
if samples.is_empty() {
return json!({
"measured": false,
"reason": "no step repeated a revision; measure one revision twice to establish the harness noise band"
});
}
let worst = samples
.iter()
.filter_map(|sample| sample["delta_percent"].as_f64())
.fold(0.0_f64, |worst, value| worst.max(value.abs()));
json!({
"measured": true,
"samples": samples.len(),
"max_abs_percent": round(worst),
"note": "a step delta inside this band is not attributable to any declaration"
})
}