//! **M3 — the defensibility gate.** Run the agentic clock against the *fair*
//! windowed baseline (and the constant-rate strawmen) on **real recorded agent
//! traces**, with **pre-registered** thresholds and an **honestly defined**
//! event-to-predict.
//!
//! ```bash
//! cargo run -p emergent-time --example real_trace_eval
//! ```
//!
//! ## Why this example exists
//!
//! M1 made the crate honest: on the *synthetic* failing-workflow trace the
//! agentic clock does **not** beat the fair `WindowedDeltaClock` baseline (the
//! belief-shift windowed detector fires at least as early). The ADR's
//! "Baseline dominance" invariant (§4) is therefore an **unmet gate**, and the
//! only legitimate way to meet (or fail) it is a **real trace vs the fair
//! baseline**. This is that test.
//!
//! ## THE HARD RULE (do not manufacture a win)
//!
//! This whole effort replaces a circular benchmark. If the agentic clock does
//! **not** beat the fair baseline on real data, that is the reported finding and
//! the crate stays honest. A truthful null/mixed result is the success
//! condition here; a manufactured win re-commits the original sin.
//!
//! Everything below the "PRE-REGISTRATION" banner is fixed *before* any lead is
//! computed and is not tuned to the outcome.
//!
//! ## Data source
//!
//! The **Claude Code session transcripts** for this repo:
//! `~/.claude/projects/C--Users-ruv-ruvector/*.jsonl`. These are real agent runs
//! — tool-use sequences, retries, errors, plan changes recorded by Claude Code
//! itself, not generated by this crate.
//!
//! We deliberately do **not** use `.ruvector/intelligence.json`: inspection shows
//! its 51 "trajectories" are flat single-event *success* records (every one has
//! `outcome = completed`, reward ∈ [0.8, 1.0], no multi-step structure and no
//! failure events). It cannot faithfully expose the agentic channels nor define
//! an event-to-predict, so using it would be dishonest. The session JSONL is
//! noisier but real and rich; we map it faithfully and document every proxy.
//!
//! ## Channel mapping (real signal → agentic channel; why each is a fair proxy)
//!
//! For each *step* (one assistant turn that issues ≥1 tool call, with its
//! results), we observe:
//!
//! | Agentic channel | Real signal extracted from the transcript | Why it is a fair, non-circular proxy |
//! |---|---|---|
//! | `belief`  (ΔB) | TF vector over the **tool *types*** invoked this step (Read/Edit/Write/Bash/…) | The agent's working hypothesis about *what kind of work this is*; a shift from reading→editing→running is a genuine belief move, not the planted target. |
//! | `memory`  (ΔM) | cumulative count of **distinct files touched** (Read/Edit/Write paths) | Working-memory growth; monotone context accumulation. |
//! | `retrieval` (ΔR) | volume of **Read / Grep / search** activity this step | Information arriving from outside; the retrieval channel by definition. |
//! | `goal_graph` (ΔG) | a **new user prompt** since the last step (open-goal mass) | A new human instruction reopens the goal graph — a real goal-graph movement. |
//! | `contradiction` (ΔE) | the **`is_error` rate** of this step's tool results | The genuine error signal recorded by the harness. **This is also what defines the event-to-predict**, so it is *excluded* from the honest agentic variant (see below). |
//! | `plan`    (ΔP) | step's **assistant text/thinking length** + **same-tool repetition** | Plan articulation + plan thrash (repeating the same tool = re-trying the same plan branch). |
//!
//! ## The event to predict (defined INDEPENDENTLY of the agentic channels)
//!
//! A real **error cascade**: the first step at/after which the transcript shows a
//! *cluster* of harness `is_error` tool-results (≥ 2 errors inside a short span).
//! The errors come straight from Claude Code's `is_error` flag — **not** from any
//! belief/plan/contradiction delta the agentic clock computes. So predicting it is
//! a genuine forecast, not a tautology.
//!
//! ## The circularity guard (the honest variant)
//!
//! Because the `contradiction` channel is derived from the **same** `is_error`
//! flag that defines the event, letting the agentic clock *see* contradiction
//! would let it "predict" the event by reading the answer. We therefore evaluate
//! **two** agentic variants and report both:
//!
//! * `agentic-full` — all six channels (contradiction included). Diagnostic only;
//!   it can trivially fire *on* the error and is **not** the gate.
//! * `agentic-honest` — contradiction weight set to **0**. The clock must predict
//!   the error cascade from belief/memory/retrieval/goal/plan movement *alone*,
//!   with no sight of the error signal. **This is the real M3 test.**
//!
//! If `agentic-honest` cannot beat the fair baseline, the honest conclusion is
//! that the primitive's value is diagnostic/explainable, not a raw lead win — and
//! we say exactly that.

use std::fs;
use std::path::{Path, PathBuf};

use emergent_time::adaptive::{adaptive_alarm_step, adaptive_early_warning_lead, PageHinkley};
use emergent_time::agentic_time::{
    alarm_step, early_warning_lead, AgentClock, AgentState, AgentWallClock, AgenticTime,
    AgenticWeights, StepCountClock, TokenCountClock, WindowedDeltaClock,
};

/// Summary statistics of a clock's increments BEFORE the event, plus the alarm
/// threshold its own early baseline implies. This exposes *why* an honest clock
/// may never fire: if the agent churns hard early, the `mean + k·σ` bar is set
/// high and later (genuine) movement never clears it — a real property of real
/// traces, distinct from a degenerate flat signal.
struct ClockDiag {
    inc_max: f64,
    inc_mean: f64,
    baseline_threshold: f64,
    max_after_baseline_before_event: f64,
}

fn diagnose(
    clock: &dyn AgentClock,
    states: &[AgentState],
    event: usize,
    bw: usize,
    k: f64,
) -> ClockDiag {
    let inc = clock.increments(states);
    let n = inc.len();
    let inc_max = inc.iter().cloned().fold(0.0_f64, f64::max);
    let inc_mean = if n > 1 {
        inc[1..].iter().sum::<f64>() / (n as f64 - 1.0)
    } else {
        0.0
    };
    // Same baseline the alarm uses: increments [1..=bw].
    let (threshold, max_after) = if n > bw + 1 {
        let base = &inc[1..=bw];
        let mean = base.iter().sum::<f64>() / base.len() as f64;
        let var = base.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / base.len() as f64;
        let threshold = mean + k * var.sqrt();
        let end = event.min(n);
        let max_after = if end > bw + 1 {
            inc[(bw + 1)..end].iter().cloned().fold(0.0_f64, f64::max)
        } else {
            0.0
        };
        (threshold, max_after)
    } else {
        (f64::INFINITY, 0.0)
    };
    ClockDiag {
        inc_max,
        inc_mean,
        baseline_threshold: threshold,
        max_after_baseline_before_event: max_after,
    }
}

// ===========================================================================
//  ░░░  PRE-REGISTRATION  ░░░
//  These constants are fixed BEFORE any lead is computed and are NOT tuned to
//  the outcome. Changing them to chase a win would void the protocol.
// ===========================================================================

/// Trailing window for both the fair baseline's z-score AND the alarm baseline.
const BASELINE_WINDOW: usize = 10;
/// Alarm sensitivity: fire when a clock's increment exceeds `mean + K_SIGMA·σ`
/// of its own trailing baseline. The same `k` the M1 synthetic benchmark uses.
const K_SIGMA: f64 = 3.0;
/// Error-cascade definition: ≥ `CASCADE_MIN_ERRORS` `is_error` results inside a
/// span of `CASCADE_SPAN` steps marks the event-to-predict. Fixed up front.
const CASCADE_MIN_ERRORS: usize = 2;
const CASCADE_SPAN: usize = 4;
/// A trace must have at least this many steps for an alarm to be meaningful
/// (needs a baseline window + room for the event to follow it).
const MIN_STEPS: usize = BASELINE_WINDOW + 6;

// --- M3b: the ADAPTIVE detector (Page–Hinkley) -----------------------------
// M3 got an honest null with the fixed-window mean+kσ alarm and DIAGNOSED the
// cause: the agent's early-exploration churn poisons a *frozen* baseline, so
// later genuine movement can't clear it. M3 proposed the fix — an ADAPTIVE
// (running-reference) detector. M3b implements that exact fix as a Page–Hinkley
// test (Page 1954; Hinkley 1970) and applies it EQUALLY to the agentic clock
// AND the fair baseline. Its δ, λ are PRE-REGISTERED here, before any adaptive
// lead is computed, and are NOT tuned to the outcome. THE HARD RULE STANDS: the
// same detector runs on both sides; a win is only a win if it is fair.
//
// δ (tolerance): set to a small fraction of a typical honest-agentic increment
//   (mean per-step increment on real traces ≈ 1.5; we tolerate ~10% as "normal"
//   jitter). λ (threshold): a few honest-increment-units of *sustained* drift.
//   Both fixed up front to round, defensible values; not swept against leads.
const PH_DELTA: f64 = 0.15;
const PH_LAMBDA: f64 = 5.0;

/// The pre-registered adaptive detector (upward form: the agentic-drift event is
/// a *rise* in structural movement).
fn adaptive_detector() -> PageHinkley {
    PageHinkley::upward(PH_DELTA, PH_LAMBDA)
}

// ===========================================================================
//  Real-trace adapter: parse Claude Code session JSONL → per-step AgentState.
// ===========================================================================

/// One observed step (an assistant turn that issued ≥1 tool call, plus the
/// results that came back) reduced to the raw, *documented* real signals.
#[derive(Default, Clone)]
struct RawStep {
    /// Tool names invoked this step (e.g. "Read", "Edit", "PowerShell").
    tools: Vec<String>,
    /// Distinct file paths touched this step (Read/Edit/Write).
    files: Vec<String>,
    /// Read/Grep/search tool invocations this step (retrieval volume).
    retrieval_calls: usize,
    /// `is_error` tool-results observed this step (the genuine error signal).
    errors: usize,
    /// A new user prompt arrived just before this step (goal-graph movement).
    new_user_prompt: bool,
    /// Assistant text + thinking length this step (plan articulation, chars).
    text_len: usize,
    /// Cumulative tokens (proxied by cumulative transcript bytes) for the
    /// token-count clock and the fair token-delta baseline.
    cumulative_tokens: u64,
}

/// A parsed real trace: the ordered raw steps and the honestly-derived event.
struct RealTrace {
    name: String,
    steps: Vec<RawStep>,
    /// First step index of the error cascade (the event-to-predict), if any.
    event_index: Option<usize>,
    /// Total `is_error` results in the whole transcript (for reporting).
    total_errors: usize,
}

/// Minimal, dependency-free JSON string scan: count occurrences of a quoted key
/// followed by a given quoted value, and pull simple fields. We avoid a JSON
/// crate (the crate core is dependency-free) and parse line-by-line; each line
/// is one transcript event. We only need a few robust signals, extracted with
/// targeted substring logic rather than a full parser.
fn parse_session(path: &Path) -> Option<RealTrace> {
    let content = fs::read_to_string(path).ok()?;
    let name = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("session")
        .chars()
        .take(8)
        .collect::<String>();

    let mut steps: Vec<RawStep> = Vec::new();
    let mut cumulative_bytes: u64 = 0;
    // Did we see a fresh user prompt since the last tool-issuing step?
    let mut pending_user_prompt = false;

    for line in content.lines() {
        if line.is_empty() {
            continue;
        }
        cumulative_bytes += line.len() as u64;

        // Classify the line by its top-level "type". Real transcript events are
        // one JSON object per line. We use cheap substring tests; they are
        // robust because the harness emits stable key spellings.
        let is_user = line.contains("\"type\":\"user\"");
        let is_assistant = line.contains("\"type\":\"assistant\"");

        // A genuine user *prompt* (not a tool_result-bearing user turn and not a
        // meta/command line): user line that carries no tool_result and is not a
        // slash-command echo. Heuristic but documented.
        if is_user
            && !line.contains("\"tool_result\"")
            && !line.contains("\"isMeta\":true")
            && !line.contains("<command-name>")
            && !line.contains("<local-command-stdout>")
        {
            pending_user_prompt = true;
        }

        if !is_assistant {
            // User tool_result lines carry the error signal for the *current*
            // (most recent) step.
            if is_user && line.contains("\"tool_result\"") {
                if let Some(step) = steps.last_mut() {
                    step.errors += count_occurrences(line, "\"is_error\":true");
                }
            }
            continue;
        }

        // Assistant line. Does it issue any tool calls?
        let tool_uses = extract_tool_names(line);
        if tool_uses.is_empty() {
            // Pure reasoning/answer turn — not a tool-issuing step. We still let
            // its prose contribute to the *next* step's plan signal implicitly by
            // ignoring it here (keeps steps = tool-issuing turns, the natural
            // action cadence).
            continue;
        }

        let mut step = RawStep {
            cumulative_tokens: cumulative_bytes / 4, // ~4 bytes/token, real proxy
            new_user_prompt: pending_user_prompt,
            text_len: line.len(),
            ..Default::default()
        };
        pending_user_prompt = false;

        for t in tool_uses {
            if matches!(
                t.as_str(),
                "Read" | "Grep" | "Glob" | "WebSearch" | "WebFetch"
            ) {
                step.retrieval_calls += 1;
            }
            if matches!(t.as_str(), "Read" | "Edit" | "Write" | "NotebookEdit") {
                if let Some(p) = extract_file_path(line) {
                    if !step.files.contains(&p) {
                        step.files.push(p);
                    }
                }
            }
            step.tools.push(t);
        }
        steps.push(step);
    }

    if steps.len() < MIN_STEPS {
        return None;
    }

    let total_errors: usize = steps.iter().map(|s| s.errors).sum();
    let event_index = find_error_cascade(&steps);

    Some(RealTrace {
        name,
        steps,
        event_index,
        total_errors,
    })
}

/// First step index `i` such that the window `[i, i+CASCADE_SPAN)` contains at
/// least `CASCADE_MIN_ERRORS` error results — the honestly defined event.
fn find_error_cascade(steps: &[RawStep]) -> Option<usize> {
    for i in 0..steps.len() {
        let end = (i + CASCADE_SPAN).min(steps.len());
        let errs: usize = steps[i..end].iter().map(|s| s.errors).sum();
        if errs >= CASCADE_MIN_ERRORS && steps[i].errors >= 1 {
            return Some(i);
        }
    }
    None
}

fn count_occurrences(haystack: &str, needle: &str) -> usize {
    if needle.is_empty() {
        return 0;
    }
    let mut n = 0;
    let mut start = 0;
    while let Some(pos) = haystack[start..].find(needle) {
        n += 1;
        start += pos + needle.len();
    }
    n
}

/// Extract the `name` of every `tool_use` block on an assistant line.
fn extract_tool_names(line: &str) -> Vec<String> {
    let marker = "\"type\":\"tool_use\"";
    let name_key = "\"name\":\"";
    // Tool name and the `tool_use` marker live in the same small object; real
    // transcripts emit them in EITHER order — historically
    // `{"type":"tool_use",…,"name":"Bash"}` but current Claude-Code transcripts
    // emit `{"name":"Bash","type":"tool_use",…}` (name BEFORE type). Searching
    // only *after* the marker silently misses the name-before-type spelling and
    // drops the whole step. So we pair each marker to the *nearest* name within a
    // bounded window, in either direction.
    const WINDOW: usize = 160;

    // All `"name":"VALUE"` occurrences with the byte offset of the marker key.
    let mut name_positions: Vec<(usize, String)> = Vec::new();
    let mut s = 0;
    while let Some(p) = line[s..].find(name_key) {
        let abs = s + p;
        let vstart = abs + name_key.len();
        if let Some(vend) = line[vstart..].find('"') {
            name_positions.push((abs, line[vstart..vstart + vend].to_string()));
            s = vstart + vend + 1;
        } else {
            break;
        }
    }

    let mut names = Vec::new();
    let mut claimed = vec![false; name_positions.len()];
    let mut start = 0;
    while let Some(pos) = line[start..].find(marker) {
        let abs = start + pos;
        // Closest unclaimed name within WINDOW of this marker (either side).
        let mut best: Option<usize> = None;
        let mut best_dist = usize::MAX;
        for (i, (npos, _)) in name_positions.iter().enumerate() {
            if claimed[i] {
                continue;
            }
            let dist = if *npos > abs { npos - abs } else { abs - npos };
            if dist <= WINDOW && dist < best_dist {
                best_dist = dist;
                best = Some(i);
            }
        }
        if let Some(i) = best {
            claimed[i] = true;
            names.push(name_positions[i].1.clone());
        }
        start = abs + marker.len();
    }
    names
}

/// Pull the first `file_path` value on a line (best-effort; one per step is
/// enough for the distinct-files memory signal).
fn extract_file_path(line: &str) -> Option<String> {
    let key = "\"file_path\":\"";
    let pos = line.find(key)?;
    let start = pos + key.len();
    let rest = &line[start..];
    let end = rest.find('"')?;
    Some(rest[..end].to_string())
}

// ===========================================================================
//  Map raw steps → AgentState channel vectors (the documented proxy mapping).
// ===========================================================================

/// Stable ordering of the tool vocabulary so the belief TF-vector dimensions are
/// consistent across steps and traces.
const TOOL_VOCAB: &[&str] = &[
    "Read",
    "Edit",
    "Write",
    "PowerShell",
    "Bash",
    "Grep",
    "Glob",
    "Agent",
    "Task",
    "TodoWrite",
    "WebFetch",
    "WebSearch",
    "NotebookEdit",
];

fn tool_index(name: &str) -> Option<usize> {
    TOOL_VOCAB.iter().position(|&t| t == name)
}

/// Build the per-step `AgentState` series from the raw steps using ONLY the
/// documented proxy mapping above.
fn to_agent_states(steps: &[RawStep]) -> Vec<AgentState> {
    let dim = TOOL_VOCAB.len();
    let mut out = Vec::with_capacity(steps.len());
    let mut cumulative_files: Vec<String> = Vec::new();
    let mut prev_tools: Vec<String> = Vec::new();

    for step in steps {
        // belief: normalized TF vector over tool types this step.
        let mut belief = vec![0.0; dim];
        for t in &step.tools {
            if let Some(i) = tool_index(t) {
                belief[i] += 1.0;
            }
        }
        let norm: f64 = belief.iter().sum();
        if norm > 0.0 {
            for b in &mut belief {
                *b /= norm;
            }
        }

        // memory: cumulative distinct files touched (scalar, embedded as a
        // 1-vector so the L2 distance picks up monotone growth).
        for f in &step.files {
            if !cumulative_files.contains(f) {
                cumulative_files.push(f.clone());
            }
        }
        let memory = vec![cumulative_files.len() as f64];

        // retrieval: retrieval-call volume this step (1-vector).
        let retrieval = vec![step.retrieval_calls as f64];

        // goal_graph: open-goal mass — bumps when a new user prompt arrived.
        let goal_graph = if step.new_user_prompt { 1.0 } else { 0.0 };

        // contradiction: this step's error rate, clamped to [0,1].
        let contradiction = (step.errors as f64 / (step.tools.len().max(1) as f64)).min(1.0);

        // plan: [normalized text length, same-tool-repetition score]. Repetition
        // = fraction of this step's tools that also appeared last step (plan
        // thrash: re-trying the same plan branch).
        let repeat = if prev_tools.is_empty() || step.tools.is_empty() {
            0.0
        } else {
            let shared = step.tools.iter().filter(|t| prev_tools.contains(t)).count();
            shared as f64 / step.tools.len() as f64
        };
        let plan = vec![(step.text_len as f64).ln_1p() / 10.0, repeat];
        prev_tools = step.tools.clone();

        out.push(AgentState {
            belief,
            memory,
            retrieval,
            goal_graph,
            contradiction,
            plan,
            tokens: step.cumulative_tokens,
        });
    }
    out
}

// ===========================================================================
//  Evaluation: run every clock on a real trace and collect leads.
// ===========================================================================

struct ClockLeads {
    wall: usize,
    step: usize,
    token: usize,
    base_token: usize,
    base_belief: usize,
    agentic_full: usize,
    agentic_honest: usize,
    /// alarm steps (for context), event index.
    alarm_full: Option<usize>,
    alarm_honest: Option<usize>,
    alarm_base_belief: Option<usize>,
    event: usize,
    /// Why the honest agentic clock fired or didn't (alive-vs-degenerate check).
    diag_honest: ClockDiag,

    // --- M3b: ADAPTIVE (Page–Hinkley) detector, SAME detector on both sides ---
    /// Adaptive lead for agentic-honest (THE adaptive gate).
    adaptive_agentic_honest: usize,
    /// Adaptive lead for the fair belief-shift baseline.
    adaptive_base_belief: usize,
    /// Adaptive lead for the fair token-delta baseline.
    adaptive_base_token: usize,
    /// Adaptive lead for agentic-full (diagnostic; sees the error channel).
    adaptive_agentic_full: usize,
    /// Adaptive alarm steps (context).
    adaptive_alarm_honest: Option<usize>,
    adaptive_alarm_base_belief: Option<usize>,
    adaptive_alarm_base_token: Option<usize>,
}

/// Honest agentic weights: the standard weights, but contradiction = 0 so the
/// clock cannot read the very signal that defines the event.
fn honest_weights() -> AgenticWeights {
    AgenticWeights {
        contradiction: 0.0,
        ..AgenticWeights::default()
    }
}

fn evaluate_trace(states: &[AgentState], event: usize) -> ClockLeads {
    let bw = BASELINE_WINDOW;
    let k = K_SIGMA;

    let full = AgenticTime::new(AgenticWeights::default());
    let honest = AgenticTime::new(honest_weights());
    let base_token = WindowedDeltaClock::token_delta(bw);
    let base_belief = WindowedDeltaClock::belief_shift(bw);

    // M3b: the SAME adaptive detector, pre-registered, applied to every clock.
    let ph = adaptive_detector();

    ClockLeads {
        wall: early_warning_lead(&AgentWallClock, states, event, bw, k),
        step: early_warning_lead(&StepCountClock, states, event, bw, k),
        token: early_warning_lead(&TokenCountClock, states, event, bw, k),
        base_token: early_warning_lead(&base_token, states, event, bw, k),
        base_belief: early_warning_lead(&base_belief, states, event, bw, k),
        agentic_full: early_warning_lead(&full, states, event, bw, k),
        agentic_honest: early_warning_lead(&honest, states, event, bw, k),
        alarm_full: alarm_step(&full, states, bw, k),
        alarm_honest: alarm_step(&honest, states, bw, k),
        alarm_base_belief: alarm_step(&base_belief, states, bw, k),
        event,
        diag_honest: diagnose(&honest, states, event, bw, k),

        // Adaptive (Page–Hinkley) leads — identical detector on both sides.
        adaptive_agentic_honest: adaptive_early_warning_lead(&honest, states, event, &ph),
        adaptive_base_belief: adaptive_early_warning_lead(&base_belief, states, event, &ph),
        adaptive_base_token: adaptive_early_warning_lead(&base_token, states, event, &ph),
        adaptive_agentic_full: adaptive_early_warning_lead(&full, states, event, &ph),
        adaptive_alarm_honest: adaptive_alarm_step(&honest, states, &ph),
        adaptive_alarm_base_belief: adaptive_alarm_step(&base_belief, states, &ph),
        adaptive_alarm_base_token: adaptive_alarm_step(&base_token, states, &ph),
    }
}

// ===========================================================================
//  Candidate data locations (degrade gracefully if absent).
// ===========================================================================

fn candidate_dirs() -> Vec<PathBuf> {
    let mut dirs = Vec::new();
    if let Some(home) = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")) {
        let home = PathBuf::from(home);
        dirs.push(
            home.join(".claude")
                .join("projects")
                .join("C--Users-ruv-ruvector"),
        );
    }
    // Allow an explicit override for CI / other machines.
    if let Some(custom) = std::env::var_os("EMERGENT_TIME_TRACE_DIR") {
        dirs.push(PathBuf::from(custom));
    }
    dirs
}

/// Outcome of scanning the candidate directories: the parsed traces plus how
/// many `.jsonl` files were *seen* but produced no usable trace. The latter lets
/// `main` distinguish "no transcript files at all" from "files present but the
/// parser could not turn them into a scoreable trace" — an honesty distinction
/// (a silent parse failure must not masquerade as absence).
struct LoadOutcome {
    traces: Vec<RealTrace>,
    files_seen: usize,
    parse_failures: usize,
}

fn load_traces() -> LoadOutcome {
    for dir in candidate_dirs() {
        let Ok(entries) = fs::read_dir(&dir) else {
            continue;
        };
        let mut paths: Vec<PathBuf> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
            .collect();
        paths.sort();
        if paths.is_empty() {
            continue;
        }
        // First directory that actually contains transcript files wins.
        let files_seen = paths.len();
        let mut traces = Vec::new();
        let mut parse_failures = 0;
        for p in paths {
            match parse_session(&p) {
                Some(t) => traces.push(t),
                None => parse_failures += 1,
            }
        }
        return LoadOutcome {
            traces,
            files_seen,
            parse_failures,
        };
    }
    LoadOutcome {
        traces: Vec::new(),
        files_seen: 0,
        parse_failures: 0,
    }
}

fn main() {
    println!("emergent-time : M3 real-trace defensibility gate");
    println!("================================================\n");

    print_preregistration();

    let LoadOutcome {
        traces,
        files_seen,
        parse_failures,
    } = load_traces();
    if traces.is_empty() {
        if files_seen > 0 {
            // Files were present but none parsed — surface it as a parse failure,
            // NOT as absence. Masquerading a parser gap as "no data" would hide a
            // real defect (and inflate confidence in the gate).
            println!(
                "\n[skip] Found {files_seen} transcript file(s) but parsed 0 usable trace(s) \
                 ({parse_failures} parse failure(s))."
            );
            println!("       The files exist but did not yield enough tool-issuing steps");
            println!("       (schema mismatch, or a very short session). This is a PARSE");
            println!("       gap, not missing data — fix the adapter rather than trusting");
            println!("       a silent skip.");
        } else {
            println!("\n[skip] No real session transcripts found.");
            println!("       Looked in: ~/.claude/projects/C--Users-ruv-ruvector/*.jsonl");
            println!("       (or $EMERGENT_TIME_TRACE_DIR). This is expected on CI / a fresh");
            println!("       checkout without recorded sessions — the harness degrades");
            println!("       gracefully and exits 0 rather than panicking.");
        }
        println!("\n       The protocol, adapter, and pre-registered thresholds above are");
        println!("       the reusable deliverable; supply traces to produce numbers.");
        return;
    }

    if parse_failures > 0 {
        println!(
            "\n[note] {parse_failures} of {files_seen} transcript file(s) did not parse \
             (reported, not hidden)."
        );
    }
    println!("\nLoaded {} real session transcript(s).\n", traces.len());

    let mut all: Vec<ClockLeads> = Vec::new();
    let mut descriptive_only = 0usize;

    for tr in &traces {
        let states = to_agent_states(&tr.steps);
        println!("--- trace {} ---", tr.name);
        println!(
            "  steps: {:>4}   total is_error results: {:>3}",
            states.len(),
            tr.total_errors
        );
        match tr.event_index {
            None => {
                descriptive_only += 1;
                println!(
                    "  event-to-predict: NONE (no error cascade of ≥{} errors within {} steps)",
                    CASCADE_MIN_ERRORS, CASCADE_SPAN
                );
                println!("  -> descriptive only: cannot score a lead on this trace.\n");
            }
            Some(ev) => {
                if ev < MIN_STEPS {
                    println!(
                        "  event-to-predict: step {ev} (too early — inside the baseline \
                         window; no room to lead). Descriptive only.\n"
                    );
                    descriptive_only += 1;
                    continue;
                }
                let l = evaluate_trace(&states, ev);
                print_trace_leads(&l);
                all.push(l);
            }
        }
    }

    print_summary(&all, descriptive_only, traces.len());
}

fn print_preregistration() {
    println!("PRE-REGISTERED (fixed before any lead was computed):");
    println!("  • metric            : early-warning lead = steps between first alarm");
    println!("                        and the error-cascade event (0 = no/late alarm)");
    println!("  • baseline window   : {BASELINE_WINDOW}");
    println!("  • alarm k·sigma     : {K_SIGMA}");
    println!(
        "  • event definition  : ≥{CASCADE_MIN_ERRORS} is_error results within {CASCADE_SPAN} steps"
    );
    println!("  • event is defined from the harness is_error flag ONLY — independent");
    println!("    of every agentic channel (non-circular).");
    println!("  • CIRCULARITY GUARD : the honest agentic variant sets contradiction");
    println!("    weight = 0, so it CANNOT see the error signal that defines the event.");
    println!("  • fair baseline     : WindowedDeltaClock (windowed z-score change-point");
    println!("    detector) on token-delta and on belief-shift (same family as ADWIN).");
    println!("  • M3b ADAPTIVE alarm : Page–Hinkley (Page 1954 / Hinkley 1970), upward,");
    println!("    delta = {PH_DELTA}, lambda = {PH_LAMBDA}  (PRE-REGISTERED, not tuned to leads).");
    println!("    Running-mean reference instead of a frozen early window — the exact");
    println!("    fix M3 proposed for the early-churn-poisons-the-baseline failure.");
    println!("    Applied IDENTICALLY to the agentic clock AND the fair baseline.");
}

fn fmt_alarm(a: Option<usize>) -> String {
    match a {
        Some(s) => format!("step {s}"),
        None => "never".to_string(),
    }
}

fn print_trace_leads(l: &ClockLeads) {
    println!(
        "  event-to-predict: error cascade starts at step {}",
        l.event
    );
    println!("  early-warning leads (steps before the event):");
    println!(
        "    wall / step / token (constant-rate) : {} / {} / {}",
        l.wall, l.step, l.token
    );
    println!(
        "    FAIR windowed baseline (token-delta) : {}",
        l.base_token
    );
    println!(
        "    FAIR windowed baseline (belief-shift): {}   [alarm {}]",
        l.base_belief,
        fmt_alarm(l.alarm_base_belief)
    );
    println!(
        "    agentic-honest (NO contradiction)    : {}   [alarm {}]   <-- THE GATE",
        l.agentic_honest,
        fmt_alarm(l.alarm_honest)
    );
    println!(
        "    agentic-full (diagnostic, sees error): {}   [alarm {}]",
        l.agentic_full,
        fmt_alarm(l.alarm_full)
    );

    // Alive-vs-degenerate diagnostic: is the honest signal moving, and why did
    // (or didn't) it clear its own alarm bar?
    let d = &l.diag_honest;
    println!(
        "  [diag] honest-agentic signal: max-inc {:.2}, mean-inc {:.2} (NOT flat); \
         early-baseline alarm bar = mean+{}σ = {:.2}; biggest move before event = {:.2}",
        d.inc_max,
        d.inc_mean,
        K_SIGMA as usize,
        d.baseline_threshold,
        d.max_after_baseline_before_event
    );
    if l.alarm_honest.is_none() && d.inc_max > 0.0 {
        println!(
            "         -> the composite signal is alive but never clears the bar: the agent's \
             early exploratory churn sets a high mean+{}σ threshold, so later genuine movement \
             doesn't trip it. A real property of real traces, not a degenerate flat clock.",
            K_SIGMA as usize
        );
    }

    let fair = l.base_belief.max(l.base_token);
    let verdict = verdict_str(l.agentic_honest, fair);
    println!("    => FIXED-WINDOW: {verdict}");

    // --- M3b: the adaptive (Page–Hinkley) block, same detector both sides ---
    println!(
        "  ADAPTIVE Page–Hinkley leads (delta={PH_DELTA}, lambda={PH_LAMBDA}; running-mean ref):"
    );
    println!(
        "    FAIR adaptive baseline (token-delta) : {}   [alarm {}]",
        l.adaptive_base_token,
        fmt_alarm(l.adaptive_alarm_base_token)
    );
    println!(
        "    FAIR adaptive baseline (belief-shift): {}   [alarm {}]",
        l.adaptive_base_belief,
        fmt_alarm(l.adaptive_alarm_base_belief)
    );
    println!(
        "    agentic-honest-adaptive (NO contra.) : {}   [alarm {}]   <-- THE ADAPTIVE GATE",
        l.adaptive_agentic_honest,
        fmt_alarm(l.adaptive_alarm_honest)
    );
    println!(
        "    agentic-full-adaptive (sees error)   : {}",
        l.adaptive_agentic_full
    );
    let fair_adapt = l.adaptive_base_belief.max(l.adaptive_base_token);
    let adapt_verdict = verdict_str(l.adaptive_agentic_honest, fair_adapt);
    println!("    => ADAPTIVE:     {adapt_verdict}\n");
}

/// Win/tie/loss string for an agentic lead vs the best fair-baseline lead.
fn verdict_str(agentic: usize, fair: usize) -> &'static str {
    if agentic > fair {
        "agentic-honest WINS vs fair baseline"
    } else if agentic == fair {
        "TIE vs fair baseline"
    } else {
        "agentic-honest LOSES vs fair baseline"
    }
}

fn print_summary(all: &[ClockLeads], descriptive_only: usize, total: usize) {
    println!("================================================");
    println!("SUMMARY across {total} real trace(s)");
    println!("  scoreable (had a valid event)      : {}", all.len());
    println!("  descriptive only (no/early event)  : {descriptive_only}");

    if all.is_empty() {
        println!("\nHONEST CONCLUSION: real error-cascade events were too sparse / too");
        println!("early in the available traces to score an early-warning lead. The");
        println!("deliverable is the harness + pre-registered protocol + descriptive run;");
        println!("NO competitive claim is made. This is a legitimate null result, not a");
        println!("failure of the protocol.");
        return;
    }

    let mean =
        |f: fn(&ClockLeads) -> usize| all.iter().map(f).sum::<usize>() as f64 / all.len() as f64;
    let tally = |sel: fn(&ClockLeads) -> (usize, usize)| {
        let mut w = 0usize;
        let mut t = 0usize;
        for l in all {
            let (ag, fair) = sel(l);
            if ag > fair {
                w += 1;
            } else if ag == fair {
                t += 1;
            }
        }
        (w, t, all.len() - w - t)
    };
    let (wins, ties, losses) = tally(|l| (l.agentic_honest, l.base_belief.max(l.base_token)));
    let (a_wins, a_ties, a_losses) = tally(|l| {
        (
            l.adaptive_agentic_honest,
            l.adaptive_base_belief.max(l.adaptive_base_token),
        )
    });

    println!("\n  mean early-warning lead (steps) — FIXED-WINDOW (M3) vs ADAPTIVE (M3b):");
    println!(
        "    fair baseline (belief-shift)       : fixed {:.2}   adaptive {:.2}",
        mean(|l| l.base_belief),
        mean(|l| l.adaptive_base_belief)
    );
    println!(
        "    fair baseline (token-delta)        : fixed {:.2}   adaptive {:.2}",
        mean(|l| l.base_token),
        mean(|l| l.adaptive_base_token)
    );
    println!(
        "    agentic-honest (THE GATE)          : fixed {:.2}   adaptive {:.2}",
        mean(|l| l.agentic_honest),
        mean(|l| l.adaptive_agentic_honest)
    );
    println!(
        "    agentic-full (diagnostic)          : fixed {:.2}   adaptive {:.2}",
        mean(|l| l.agentic_full),
        mean(|l| l.adaptive_agentic_full)
    );
    println!("\n  agentic-honest vs fair baseline (per-trace win/tie/loss):");
    println!("    FIXED-WINDOW (M3)  : {wins} win / {ties} tie / {losses} loss");
    println!("    ADAPTIVE   (M3b)   : {a_wins} win / {a_ties} tie / {a_losses} loss");

    println!("\nHONEST CONCLUSION (did the M3-proposed adaptive fix rescue the claim?):");
    let fixed_beats = wins > losses && wins >= ties;
    let adaptive_beats = a_wins > a_losses && a_wins >= a_ties;
    if adaptive_beats && !fixed_beats {
        println!("  The ADAPTIVE (Page–Hinkley) detector CHANGES the verdict: with the");
        println!("  running-mean reference the agentic-honest clock beats the fair baseline");
        println!("  (same detector on BOTH sides) where the fixed-window detector did not.");
        println!("  This is a FAIR result — the fix M3 proposed (an adaptive window not");
        println!("  poisoned by early churn) does rescue the early-warning claim on these");
        println!("  traces. It is NOT an artifact: the identical detector ran on the");
        println!("  baseline and lost. STRONG CAVEAT: n is tiny and NOT significance-tested;");
        println!("  this warrants a larger PRE-REGISTERED corpus before any general claim.");
    } else if adaptive_beats && fixed_beats {
        println!("  Both detectors favour the agentic-honest clock; the adaptive detector");
        println!("  does not change the direction of the verdict. Fair result on both,");
        println!("  same caveats (tiny n, not significance-tested).");
    } else if !adaptive_beats && fixed_beats {
        println!("  The fixed-window detector favoured the agentic clock but the ADAPTIVE");
        println!("  detector does NOT — so the apparent fixed-window edge does not survive");
        println!("  a more robust detector. No defensible adaptive win.");
    } else {
        println!("  The ADAPTIVE (Page–Hinkley) detector does NOT rescue the claim: with the");
        println!("  same running-mean detector on BOTH sides, the agentic-honest clock still");
        println!("  does not beat the fair baseline. The honest null from M3 STANDS, and is");
        println!("  now MORE ROBUST: we implemented the exact fix M3 proposed (adaptive");
        println!("  window instead of a frozen early baseline) and it did not change the");
        println!("  verdict. The primitive's defensible value remains EXPLAINABLE/DIAGNOSTIC");
        println!("  (per-channel attribution + health classifier), not a raw early-warning");
        println!("  lead win. Reporting a fix that didn't work is a real, valuable result.");
    }
    println!("\n  CAVEATS (always travel with the numbers above):");
    println!(
        "    • n = {} scoreable real trace(s) — tiny sample; not significance-tested.",
        all.len()
    );
    println!("    • every channel is a documented HEURISTIC PROXY (see module docs), not");
    println!("      an instrumented agent-state stream; a planted signal is ruled out by");
    println!("      the contradiction-free honest variant, but proxy noise is real.");
    println!("    • the event is one operationalization (error cascade) of 'trouble'.");
    println!("    • thresholds were pre-registered and NOT tuned to these outcomes.");
}

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

    /// The parser must recover tool names regardless of whether `name` precedes
    /// or follows the `tool_use` type tag. Current Claude-Code transcripts emit
    /// name-before-type; the original parser only handled type-before-name and
    /// silently dropped real steps (which made the whole real-trace gate report
    /// "no transcripts found" on a transcript that was actually present).
    #[test]
    fn extracts_tool_names_in_either_key_order() {
        // name BEFORE type (the real, current transcript spelling)
        let name_first = r#"{"content":[{"name":"Bash","type":"tool_use","id":"t1","input":{}}]}"#;
        assert_eq!(extract_tool_names(name_first), vec!["Bash".to_string()]);

        // type BEFORE name (the historical spelling)
        let type_first = r#"{"content":[{"type":"tool_use","id":"t1","name":"Grep","input":{}}]}"#;
        assert_eq!(extract_tool_names(type_first), vec!["Grep".to_string()]);

        // multiple parallel tool_use blocks on one line, name-before-type
        let multi = r#"[{"name":"Read","type":"tool_use","id":"a"},{"name":"Edit","type":"tool_use","id":"b"}]"#;
        assert_eq!(
            extract_tool_names(multi),
            vec!["Read".to_string(), "Edit".to_string()]
        );

        // no tool_use → no names
        let none = r#"{"type":"text","text":"hello name: world"}"#;
        assert!(extract_tool_names(none).is_empty());
    }
}
