main
John Lauer 0.2.5–0.2.8 source: verified foreground + visible demo bars; reaper reads ab's matches[] (was blind since 0.2.0), class… 195ea60 1mo ago
//! The MEASURED-progress contract (wiki issue #32), ported from `progress.py`. John's rule:
//! every slow verb reports measured progress, never "can take a couple of minutes". Estimates come
//! from THIS machine's own history — the store paths and JSON shapes are byte-compatible with the
//! Python bridge's, so a machine's measured history survives the engine swap.
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};

pub const PREFIX: &str = "altium";
const KEEP: usize = 10; // rolling window per key (fusion parity)
const RUNLOG_MAX_BYTES: u64 = 4 * 1024 * 1024;

static LOCK: Mutex<()> = Mutex::new(());

/// Shipped defaults, used ONLY to seed a machine with no history of its own — each a measured
/// figure from this fleet, not a guess.
fn default_seconds(phase: &str) -> Option<f64> {
    Some(match phase {
        "cold_start" => 60.0,
        "warm_start" => 1.0,
        "agent_start" => 8.0,
        "show_symbol" => 12.0,
        "show_footprint" => 20.0,
        "show_3d_chip" => 25.0,
        "show_library" => 12.0,
        "show_schematic" => 15.0,
        "show_2d_board" => 15.0,
        "show_3d_board" => 30.0,
        "show_project" => 10.0,
        "install_plugin" => 6.0,
        "run_outjob" => 60.0,
        "export" => 8.0,
        "demo" => 210.0,
        "demo_live_stage" => 12.0,
        _ => return None,
    })
}

fn store_dir() -> PathBuf {
    let base = std::env::var("USERPROFILE")
        .or_else(|_| std::env::var("HOME"))
        .unwrap_or_else(|_| ".".into());
    let d = PathBuf::from(base).join(".adom");
    let _ = std::fs::create_dir_all(&d);
    d
}

fn timings_path() -> PathBuf {
    store_dir().join("altium-bridge-timings.json")
}

fn runlog_path() -> PathBuf {
    store_dir().join("altium-verb-times.jsonl")
}

fn load() -> BTreeMap<String, Vec<f64>> {
    std::fs::read_to_string(timings_path())
        .ok()
        .and_then(|s| serde_json::from_str::<BTreeMap<String, Vec<f64>>>(&s).ok())
        .unwrap_or_default()
}

fn save(data: &BTreeMap<String, Vec<f64>>) {
    if let Ok(s) = serde_json::to_string_pretty(data) {
        let _ = std::fs::write(timings_path(), s);
    }
}

fn key(phase: &str, start_kind: &str) -> String {
    format!("{phase}:{}", if start_kind.is_empty() { "warm" } else { start_kind })
}

fn pct(samples: &[f64], q: f64) -> f64 {
    if samples.is_empty() {
        return 0.0;
    }
    let mut s = samples.to_vec();
    s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let i = ((q * (s.len() as f64 - 1.0)).round() as usize).min(s.len() - 1);
    (s[i] * 100.0).round() / 100.0
}

/// Add one real measurement. This is what makes confidence "measured".
pub fn record(phase: &str, seconds: f64, start_kind: &str) {
    if !(0.0..=7200.0).contains(&seconds) {
        return;
    }
    let _g = LOCK.lock();
    let mut data = load();
    let k = key(phase, start_kind);
    let samples = data.entry(k).or_default();
    samples.push((seconds * 100.0).round() / 100.0);
    let len = samples.len();
    if len > KEEP {
        samples.drain(..len - KEEP);
    }
    save(&data);
}

/// (estimatedSec, confidence) for this phase ON THIS MACHINE.
/// 3+ samples -> p50 "measured"; 1-2 -> blend with the shipped seed, "typical";
/// a bare seed -> "typical"; nothing at all -> (0, "unknown") = indeterminate marquee, never a
/// guessed number wearing a measurement's clothes.
pub fn estimate(phase: &str, start_kind: &str) -> (f64, String) {
    let data = load();
    let samples = data.get(&key(phase, start_kind)).cloned().unwrap_or_default();
    if samples.len() >= 3 {
        return (pct(&samples, 0.5), "measured".into());
    }
    let default = default_seconds(phase);
    if !samples.is_empty() {
        let base = default.unwrap_or(*samples.last().unwrap());
        let avg = samples.iter().sum::<f64>() / samples.len() as f64;
        return ((((avg + base) / 2.0) * 100.0).round() / 100.0, "typical".into());
    }
    if let Some(d) = default {
        return (d, "typical".into());
    }
    (0.0, "unknown".into())
}

/// Everything we know, for describe/status: p50 + p90 + sample counts.
pub fn history() -> Value {
    let mut out = serde_json::Map::new();
    for (k, samples) in load() {
        if samples.is_empty() {
            continue;
        }
        let (phase, kind) = k.split_once(':').unwrap_or((k.as_str(), "warm"));
        let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));
        entry[kind] = json!({"samples": samples.len(), "p50": pct(&samples, 0.5), "p90": pct(&samples, 0.9)});
    }
    Value::Object(out)
}

fn now_secs() -> f64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)
}

/// Build the progress block in the shape ab consumes verbatim. Formulas adopted from fusion's
/// timings.py (John: copy the method, do not redesign): 0->80 across the estimate, 80->95
/// asymptotic when overdue, the two meeting at elapsed == estimate so percent never walks
/// backwards at the crossover.
pub fn block(phase: &str, step_label: &str, started_at: f64, percent: Option<i64>,
             start_kind: &str, blocked_by: Option<&str>, done: bool) -> Value {
    let elapsed = (now_secs() - started_at).max(0.0);
    let (est0, mut confidence) = estimate(phase, start_kind);
    let mut est = est0;
    let overdue = est0 > 0.0 && elapsed > est0;
    if overdue {
        est = (elapsed * 1.25 * 10.0).round() / 10.0;
        if confidence == "measured" {
            confidence = "typical".into();
        }
        if elapsed > 2.0 * est0 {
            confidence = "unknown".into();
        }
    }
    let mut percent = percent;
    if done {
        percent = Some(100);
        est = (elapsed * 100.0).round() / 100.0;
    } else if percent.is_none() {
        percent = if est0 <= 0.0 {
            None
        } else if !overdue {
            Some((80.0 * elapsed / est0) as i64)
        } else {
            let over = elapsed - est0;
            Some(80 + (15.0 * (over / (over + est0))) as i64)
        };
    }
    let eta = percent.map(|_| ((est - elapsed).max(0.0) * 10.0).round() / 10.0);
    let mut step_label = step_label.to_string();
    if !done && est0 > 0.0 && overdue && confidence == "unknown" {
        step_label = format!("{step_label} (taking far longer than this machine ever has)");
    }
    if let Some(b) = blocked_by {
        step_label = format!("waiting for you: {b}");
    }
    let mut out = json!({
        "phase": format!("{PREFIX}.{phase}"),
        "stepLabel": step_label,
        "percent": percent,
        "elapsedSec": (elapsed * 10.0).round() / 10.0,
        "estimatedSec": (est * 10.0).round() / 10.0,
        "etaSec": eta,
        "confidence": confidence,
        "startKind": start_kind,
    });
    if !done && est0 > 0.0 {
        out["overdue"] = json!(overdue);
    }
    if let Some(b) = blocked_by {
        out["blockedBy"] = json!(b);
    }
    if done {
        out["measured"] = json!({"phase": format!("{PREFIX}.{phase}"), "seconds": (elapsed * 100.0).round() / 100.0, "startKind": start_kind});
    }
    out
}

// ---- live, in-flight progress (in-memory on purpose: a stale frame surviving a restart lies) --

struct Live {
    started_at: f64,
    start_kind: String,
    step_label: String,
    percent: Option<i64>,
    blocked_by: Option<String>,
    seq: u64,
}

static LIVE: Mutex<Vec<((String, String), Live)>> = Mutex::new(Vec::new());
static LIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

pub fn begin(phase: &str, start_kind: &str, step_label: &str, caller: &str) {
    let seq = LIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
    if let Ok(mut g) = LIVE.lock() {
        g.retain(|(k, _)| !(k.0 == caller && k.1 == phase));
        g.push(((caller.to_string(), phase.to_string()),
                Live { started_at: now_secs(), start_kind: start_kind.into(),
                       step_label: step_label.into(), percent: None, blocked_by: None, seq }));
    }
}

/// Advance the visible sub-step of an in-flight phase — the live streaming contract. Kept as a
/// public API for verbs that report sub-steps; not every verb does yet.
#[allow(dead_code)]
pub fn step(phase: &str, step_label: &str, percent: Option<i64>, blocked_by: Option<&str>, caller: &str) {
    if let Ok(mut g) = LIVE.lock() {
        let hit = g.iter_mut().find(|(k, _)| k.0 == caller && k.1 == phase).map(|(_, v)| v);
        let hit = match hit {
            Some(v) => Some(v),
            None => {
                // fall back to the only in-flight entry for this phase; never guess among several
                let mut it = g.iter_mut().filter(|(k, _)| k.1 == phase);
                match (it.next(), it.next()) {
                    (Some((_, v)), None) => Some(v),
                    _ => None,
                }
            }
        };
        if let Some(v) = hit {
            v.step_label = step_label.into();
            v.blocked_by = blocked_by.map(str::to_string);
            if let Some(p) = percent {
                // never let a bar go backwards: a bar that retreats reads as broken
                v.percent = Some(p.max(v.percent.unwrap_or(0)));
            }
        }
    }
}

pub fn end(phase: &str, caller: &str) {
    if let Ok(mut g) = LIVE.lock() {
        g.retain(|(k, _)| !(k.0 == caller && k.1 == phase));
    }
}

/// Every phase currently in flight, as full progress blocks (percent/ETA recomputed at read time;
/// a step's explicit percent is a monotonic FLOOR, so the bar always advances).
pub fn live(mine_only: bool, asker: &str) -> Vec<Value> {
    let snapshot: Vec<(String, String, f64, String, String, Option<i64>, Option<String>, u64)> =
        LIVE.lock()
            .map(|g| {
                g.iter()
                    .map(|((who, phase), v)| {
                        (who.clone(), phase.clone(), v.started_at, v.start_kind.clone(),
                         v.step_label.clone(), v.percent, v.blocked_by.clone(), v.seq)
                    })
                    .collect()
            })
            .unwrap_or_default();
    let mut out = Vec::new();
    for (who, phase, started_at, start_kind, step_label, floor, blocked_by, seq) in snapshot {
        if mine_only && who != asker {
            continue;
        }
        let mut b = block(&phase, &step_label, started_at, None, &start_kind, blocked_by.as_deref(), false);
        if let Some(f) = floor {
            let derived = b.get("percent").and_then(|p| p.as_i64());
            b["percent"] = json!(derived.map(|d| d.max(f)).unwrap_or(f));
        }
        b["seq"] = json!(seq);
        b["caller"] = json!(who);
        out.push(b);
    }
    out
}

// ---- per-verb run times (fusion parity) --------------------------------------

/// Append one verb run to the historical log AND the rolling window. Called at the dispatch
/// chokepoint for EVERY verb. Never fails loudly.
pub fn log_run(verb: &str, seconds: f64, ok: bool) {
    if !(0.0..=7200.0).contains(&seconds) {
        return; // a clock jump is not a sample
    }
    record(&format!("verb:{verb}"), seconds, "warm");
    let p = runlog_path();
    if let Ok(m) = std::fs::metadata(&p) {
        if m.len() > RUNLOG_MAX_BYTES {
            let _ = std::fs::rename(&p, p.with_extension("jsonl.1"));
        }
    }
    let line = json!({"at": now_secs() as i64, "verb": verb, "seconds": (seconds * 100.0).round() / 100.0, "ok": ok, "note": ""});
    use std::io::Write;
    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
        let _ = writeln!(f, "{line}");
    }
}

/// Aggregate the historical log: per verb count / p50 / p90 / max / failures, p90 descending —
/// p90, not p50, is the column for hunting slow paths.
pub fn verb_report(limit: usize) -> Value {
    let raw = match std::fs::read_to_string(runlog_path()) {
        Ok(s) => s,
        Err(_) => return json!({}),
    };
    let mut runs: BTreeMap<String, (Vec<f64>, u64)> = BTreeMap::new();
    for line in raw.lines() {
        if let Ok(r) = serde_json::from_str::<Value>(line) {
            let verb = r.get("verb").and_then(|v| v.as_str()).unwrap_or("?").to_string();
            let e = runs.entry(verb).or_default();
            e.0.push(r.get("seconds").and_then(|s| s.as_f64()).unwrap_or(0.0));
            if r.get("ok").and_then(|b| b.as_bool()) != Some(true) {
                e.1 += 1;
            }
        }
    }
    let mut rows: Vec<(String, Value, f64)> = runs
        .into_iter()
        .map(|(verb, (secs, fails))| {
            let p90 = pct(&secs, 0.9);
            let row = json!({"count": secs.len(), "p50": pct(&secs, 0.5), "p90": p90,
                             "max": secs.iter().cloned().fold(f64::NAN, f64::max), "failures": fails});
            (verb, row, p90)
        })
        .collect();
    rows.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
    let mut out = serde_json::Map::new();
    for (verb, row, _) in rows.into_iter().take(limit) {
        out.insert(verb, row);
    }
    Value::Object(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn percentile_and_estimate_ladder() {
        assert_eq!(pct(&[3.0, 1.0, 2.0], 0.5), 2.0);
        assert_eq!(pct(&[], 0.5), 0.0);
        // a phase with a seed but no samples is "typical", never "unknown"
        let (e, c) = estimate("show_symbol_never_recorded_xyz", "warm");
        assert_eq!((e, c.as_str()), (0.0, "unknown"));
    }

    #[test]
    fn block_percent_meets_at_crossover() {
        // with no estimate the bar is indeterminate, never 0%
        let b = block("no_such_phase_zz", "working", now_secs() - 5.0, None, "warm", None, false);
        assert!(b["percent"].is_null());
        // done carries the measured actuals
        let b = block("no_such_phase_zz", "done", now_secs() - 5.0, None, "warm", None, true);
        assert_eq!(b["percent"], json!(100));
        assert!(b["measured"]["seconds"].as_f64().unwrap() >= 4.9);
    }

    #[test]
    fn live_floor_is_monotonic() {
        begin("t_phase", "warm", "starting", "tester");
        step("t_phase", "half way", Some(50), None, "tester");
        step("t_phase", "later", Some(30), None, "tester"); // lower explicit % must not retreat
        let l = live(true, "tester");
        assert_eq!(l.len(), 1);
        assert!(l[0]["percent"].as_i64().unwrap() >= 50);
        end("t_phase", "tester");
        assert!(live(true, "tester").is_empty());
    }
}