Skip to main content

agentstatedeveloper_cli/commands/
probe.rs

1//! `asd probe run` — golden benchmark harness for ASD ranking/classification.
2//!
3//! Reads `.asd/probes.toml` (relative to the DB), executes each probe as a
4//! subprocess call to `asd --db <path> <command> <args...>`, parses the JSON
5//! output, and evaluates structural assertions. On failure, attaches the full
6//! debug payload so an agent can read boost_debug/classification_debug and
7//! make a single targeted heuristic change per iteration.
8//!
9//! `asd probe add` appends a new [[probe]] entry to probes.toml.
10//!
11//! Filtering flags (may combine):
12//!   --name <name>     run only the probe with exactly this name
13//!   --tag  <tag>      run only probes whose [[probe]] tags array contains this tag
14//!   --filter <substr> run only probes whose name contains this substring (legacy)
15//!
16//! Output flags:
17//!   --json            emit results as a JSON object (includes duration_ms per probe,
18//!                     wall_time_ms, worker_count, total/passed/failed, slowest top-5,
19//!                     slow_violations list)
20//!   --fail-slow <ms>  exit non-zero if any probe exceeds this wall-clock threshold
21//!   --fail-fast       stop on first assertion failure
22//!
23//! Assertion kinds:
24//!   file_not_in_key  — no item in JSON array `key` has `field` containing `value`
25//!   file_in_key      — at least one item in array `key` has `field` containing `value`
26//!   qname_rank_lte   — result whose qname contains `fragment` appears at rank ≤ `max_rank`
27//!   qname_rank_eq    — result whose qname contains `fragment` appears at EXACTLY `exact_rank` (precision-mode; Plan J t-019)
28//!   result_count_lte — results array length ≤ `max`
29//!   cluster_winner_kind_not        — cluster_debug entry matching `doc_stem` winner kind ≠ `kind_not`
30//!   cluster_winner_qname_contains  — cluster_debug entry matching `doc_stem` winner qname contains `fragment`
31//!   no_duplicate_summaries         — no two suggested_entries share the same summary per symbol
32//!   qname_not_in_results           — no result's qname contains `fragment` (feedback suppression check)
33//!   boosted_outranked_contains     — boosted_outranked has an entry containing `fragment`
34//!   ambiguous_terms_nonempty       — ambiguous_terms array is non-empty (broad query uncertainty check)
35//!   scoped_suggestions_nonempty    — scoped_suggestions array is non-empty
36//!   scoped_suggestions_contains    — scoped_suggestions contains an entry matching `fragment`
37//!   uncertainty_level_lte          — uncertainty.level ≤ max_level (low/medium/high/critical)
38//!   uncertainty_reason_contains    — uncertainty.reasons[*].code contains `code`
39//!   uncertainty_action_eq          — uncertainty.recommended_action equals `action`
40//!   recovery_suggestions_nonempty  — uncertainty.recovery_suggestions is non-empty
41//!   recovery_suggestion_estimated  — at least one recovery suggestion has `estimated_recovery = strength`
42//!   feedback_summary_gte           — feedback_summary[field] ≥ min_value (e.g. suppressed ≥ 1)
43//!   feedback_summary_eq            — feedback_summary[field] == value
44//!   feedback_rules_contains        — feedback_summary.rules_applied contains `rule`
45//!   field_gte                      — output[dot.path] ≥ min_value (numeric)
46//!   field_eq                       — output[dot.path] equals expected string
47//!   array_field_count_lte          — length of dot-path array field ≤ max_count
48//!   array_field_count_gte          — length of dot-path array field ≥ min_count
49//!   workflow_steps_contains        — workflow.steps_detected contains step
50//!   evidence_score_gte             — workflow.evidence_quality.evidence_quality_score ≥ min_value
51//!   data_quality_state_eq          — trust.data_quality.state equals expected string
52//!   feedback_state_eq              — feedback_state[field] == value (bool)
53//!   feedback_state_field_eq        — feedback_state[field] equals expected string
54//!   feedback_coverage_eq           — feedback_summary.coverage equals expected string
55//!   array_field_contains           — dot-path array field contains `value` string
56//!   array_field_excludes           — dot-path array field does NOT contain `value` string
57//!   uncertainty_exact_symbol_match — uncertainty.exact_symbol_match == expected (bool)
58//!   uncertainty_primary_source_eq  — uncertainty.sources.primary equals expected string
59//!   uncertainty_source_gte         — uncertainty.sources[source] >= min_value
60//!   all_items_have_field           — every object in a dot-path array has the named field set
61//!   file_field_contains            — specific array item (by file_fragment) has field containing substring
62//!
63//! Special probe command (no assert block needed):
64//!   hydrate-roundtrip              — write sentinel, sync, hydrate into fresh DB, verify survival
65
66use std::path::{Path, PathBuf};
67use std::process::Command as ProcessCommand;
68use std::time::Instant;
69
70use anyhow::{Context, Result};
71use chrono::Utc;
72use clap::{Args, Subcommand};
73use rusqlite::{Connection, params};
74use serde_json::Value;
75
76use agentstatedeveloper_core::{
77    FtsFilters, SearchFtsDb, calibration::compute_calibration, compute_trust_score, stale_warning,
78};
79
80use crate::config::Config;
81
82// ---------------------------------------------------------------------------
83// CLI shape
84// ---------------------------------------------------------------------------
85
86#[derive(Debug, Args)]
87pub struct ProbeCmd {
88    #[command(subcommand)]
89    pub sub: ProbeSub,
90}
91
92#[derive(Debug, Subcommand)]
93pub enum ProbeSub {
94    /// Run all probes from .asd/probes.toml and report pass/fail.
95    Run(ProbeRunArgs),
96    /// Append a new probe entry to .asd/probes.toml.
97    Add(ProbeAddArgs),
98    /// Generate a starter .asd/probes.toml from the current index.
99    Bootstrap(ProbeBootstrapArgs),
100    /// Print configured probes (name, command, tags) from
101    /// .asd/probes.toml. Use this to see what `asd probe run`
102    /// would execute, without actually running anything.
103    /// `history` shows past run results — this shows the
104    /// definitions themselves.
105    List(ProbeListArgs),
106    /// Show probe run history from .asd/probe-history.jsonl.
107    History(ProbeHistoryArgs),
108    /// Rebuild probe-analytics.db from probe-history.jsonl.
109    Reindex(ProbeReindexArgs),
110}
111
112#[derive(Debug, Args)]
113pub struct ProbeListArgs {
114    /// Emit JSON instead of human-readable text.
115    #[arg(long)]
116    pub json: bool,
117    /// Filter to probes whose `tags` array contains this tag.
118    #[arg(long)]
119    pub tag: Option<String>,
120}
121
122#[derive(Debug, Args)]
123pub struct ProbeBootstrapArgs {
124    /// Overwrite .asd/probes.toml if it already exists.
125    #[arg(long)]
126    pub force: bool,
127    /// Number of top symbols to generate ranking probes for (default: 5).
128    #[arg(long, default_value = "5")]
129    pub top: usize,
130}
131
132#[derive(Debug, Args)]
133pub struct ProbeReindexArgs {
134    /// Drop and rebuild even if the DB already exists.
135    #[arg(long)]
136    pub force: bool,
137}
138
139#[derive(Debug, Args)]
140pub struct ProbeHistoryArgs {
141    /// Show last N runs (default: 20).
142    #[arg(long, default_value = "20")]
143    pub last: usize,
144
145    /// Filter to runs that were recorded with a specific --tag filter.
146    #[arg(long)]
147    pub tag: Option<String>,
148
149    /// Emit raw JSONL records instead of the summary table.
150    #[arg(long)]
151    pub json: bool,
152}
153
154#[derive(Debug, Args)]
155pub struct ProbeRunArgs {
156    /// Emit results as JSON (default: human-readable).
157    /// JSON output includes duration_ms per probe, total/passed/failed,
158    /// top-5 slowest probes, and any slow_violations from --fail-slow.
159    #[arg(long)]
160    pub json: bool,
161
162    /// Run only the probe with exactly this name.
163    /// Useful for targeted single-probe CI runs: `asd probe run --name waveform-canvas-not-in-edit`.
164    #[arg(long)]
165    pub name: Option<String>,
166
167    /// Run only probes whose `tags` array contains this tag.
168    /// Tag probes in probes.toml with e.g. `tags = ["ranking", "m53"]` then
169    /// run a subset with `asd probe run --tag m53`.
170    #[arg(long)]
171    pub tag: Option<String>,
172
173    /// Run only probes whose name contains this substring (legacy, kept for backward compat).
174    #[arg(long)]
175    pub filter: Option<String>,
176
177    /// Fail if any probe's wall-clock time exceeds this threshold (milliseconds).
178    /// Useful as a CI performance regression gate. Exit code is non-zero when
179    /// any probe is slow, even if all assertions pass.
180    /// Example: `asd probe run --fail-slow 5000` to cap each probe at 5 s.
181    #[arg(long)]
182    pub fail_slow: Option<u64>,
183
184    /// Stop on first assertion failure (does not affect --fail-slow).
185    #[arg(long)]
186    pub fail_fast: bool,
187
188    /// Number of probes to run in parallel (default 0 = auto, capped at 6).
189    /// Beyond 6 parallel readers the SQLite page-cache contention increases wall time.
190    /// Set --jobs 1 to run sequentially for deterministic single-line terminal output.
191    #[arg(long, default_value = "0")]
192    pub jobs: usize,
193}
194
195#[derive(Debug, Args)]
196pub struct ProbeAddArgs {
197    /// Probe name (kebab-case).
198    #[arg(long)]
199    pub name: String,
200
201    /// ASD subcommand to run, e.g. "search" or "prepare-change".
202    #[arg(long)]
203    pub command: String,
204
205    /// Arguments to pass to the subcommand (space-separated, or repeat flag).
206    #[arg(long, num_args = 1..)]
207    pub args: Vec<String>,
208
209    /// Assertion as a TOML inline table string.
210    /// Example: "{ kind = \"qname_rank_lte\", fragment = \"myFn\", max_rank = 3 }"
211    #[arg(long)]
212    pub assert: Option<String>,
213
214    /// Optional human description of what this probe checks.
215    #[arg(long)]
216    pub description: Option<String>,
217}
218
219// ---------------------------------------------------------------------------
220// Probe data model (deserialized from probes.toml)
221// ---------------------------------------------------------------------------
222
223#[derive(Debug, serde::Deserialize)]
224struct ProbeFile {
225    #[serde(default)]
226    probe: Vec<ProbeEntry>,
227}
228
229#[derive(Debug, serde::Deserialize, Clone)]
230struct ProbeEntry {
231    name: String,
232    // Round-trip TOML field — surfaces in `asd probe list` output
233    // and probe-file diffs even though no internal logic reads it.
234    #[serde(default)]
235    #[allow(dead_code)]
236    description: String,
237    command: String,
238    #[serde(default)]
239    args: Vec<String>,
240    /// Optional tag list for selective runs.  Example: `tags = ["ranking", "m53"]`
241    #[serde(default)]
242    tags: Vec<String>,
243    /// Working directory for the subprocess (default: directory containing probes.toml).
244    #[serde(default)]
245    cwd: Option<String>,
246    /// Assertion as a free-form map — kind field discriminates.
247    /// Optional: when absent the probe is a smoke test (always passes if command exits 0).
248    #[serde(default = "toml_empty_table")]
249    assert: toml::Value,
250}
251
252fn toml_empty_table() -> toml::Value {
253    toml::Value::Table(toml::map::Map::new())
254}
255
256// ---------------------------------------------------------------------------
257// Entry point
258// ---------------------------------------------------------------------------
259
260pub fn run(cfg: &Config, cmd: ProbeCmd) -> Result<()> {
261    match cmd.sub {
262        ProbeSub::Run(args) => run_probes(cfg, args),
263        ProbeSub::Add(args) => add_probe(cfg, args),
264        ProbeSub::Bootstrap(args) => bootstrap_probes(cfg, args),
265        ProbeSub::List(args) => list_probes(cfg, args),
266        ProbeSub::History(args) => show_history(cfg, args),
267        ProbeSub::Reindex(args) => reindex_analytics(cfg, args),
268    }
269}
270
271/// `asd probe list` — show what's configured in .asd/probes.toml
272/// without actually running anything. Mirrors the shape of
273/// `asd probe run --json` so a follow-up `--name <n>` invocation
274/// is easy to construct.
275fn list_probes(cfg: &Config, args: ProbeListArgs) -> Result<()> {
276    let path = probe_file_path(cfg);
277    if !path.exists() {
278        if args.json {
279            println!(
280                "{}",
281                serde_json::to_string_pretty(&serde_json::json!({
282                    "probe_file": path.display().to_string(),
283                    "exists": false,
284                    "probes": [],
285                    "note": "no probes configured — run `asd probe bootstrap` to generate a starter set",
286                }))?
287            );
288        } else {
289            println!("no probes file at {}", path.display());
290            println!("run `asd probe bootstrap` to generate a starter set");
291        }
292        return Ok(());
293    }
294
295    let raw =
296        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
297    let pf: ProbeFile =
298        toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
299
300    let probes: Vec<&ProbeEntry> = pf
301        .probe
302        .iter()
303        .filter(|p| match &args.tag {
304            Some(t) => p.tags.iter().any(|pt| pt == t),
305            None => true,
306        })
307        .collect();
308
309    if args.json {
310        let entries: Vec<serde_json::Value> = probes
311            .iter()
312            .map(|p| {
313                serde_json::json!({
314                    "name": p.name,
315                    "command": p.command,
316                    "args": p.args,
317                    "tags": p.tags,
318                })
319            })
320            .collect();
321        println!(
322            "{}",
323            serde_json::to_string_pretty(&serde_json::json!({
324                "probe_file": path.display().to_string(),
325                "exists": true,
326                "total": pf.probe.len(),
327                "matched": probes.len(),
328                "probes": entries,
329            }))?
330        );
331        return Ok(());
332    }
333
334    println!("# probes from {}", path.display());
335    if probes.is_empty() {
336        let total = pf.probe.len();
337        if total == 0 {
338            println!("(file exists but has no [[probe]] entries)");
339        } else {
340            println!("(no probes match --tag filter; {total} total in file)");
341        }
342        return Ok(());
343    }
344    for p in &probes {
345        let tags = if p.tags.is_empty() {
346            String::new()
347        } else {
348            format!("  [{}]", p.tags.join(", "))
349        };
350        println!("{}{tags}", p.name);
351        println!("  asd {} {}", p.command, p.args.join(" "));
352    }
353    println!();
354    println!("{} probe(s); run with `asd probe run`", probes.len());
355    Ok(())
356}
357
358// ---------------------------------------------------------------------------
359// probe-analytics.db — schema + helpers
360// ---------------------------------------------------------------------------
361
362fn analytics_path(cfg: &Config) -> PathBuf {
363    let db_dir = Path::new(&cfg.db_path).parent().unwrap_or(Path::new("."));
364    db_dir.join(".asd").join("probe-analytics.db")
365}
366
367fn open_analytics_db(path: &Path) -> rusqlite::Result<Connection> {
368    if let Some(parent) = path.parent() {
369        let _ = std::fs::create_dir_all(parent);
370    }
371    let conn = Connection::open(path)?;
372    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
373    conn.execute_batch(
374        "
375        CREATE TABLE IF NOT EXISTS probe_runs (
376            run_id                TEXT PRIMARY KEY,
377            asd_version           TEXT NOT NULL,
378            started_at            TEXT NOT NULL,
379            finished_at           TEXT,
380            probe_file            TEXT,
381            db_state              TEXT,
382            symbol_count          INTEGER,
383            scope                 TEXT NOT NULL DEFAULT 'all',
384            total                 INTEGER NOT NULL,
385            passed                INTEGER NOT NULL,
386            failed                INTEGER NOT NULL,
387            budget_failed         INTEGER NOT NULL DEFAULT 0,
388            wall_time_ms          INTEGER NOT NULL,
389            worker_count          INTEGER,
390            performance_budget_ms INTEGER,
391            filter_name           TEXT,
392            filter_tag            TEXT
393        );
394        CREATE TABLE IF NOT EXISTS probe_results (
395            id          INTEGER PRIMARY KEY AUTOINCREMENT,
396            run_id      TEXT NOT NULL REFERENCES probe_runs(run_id),
397            name        TEXT NOT NULL,
398            command     TEXT,
399            assertion   TEXT,
400            tags        TEXT,
401            passed      INTEGER NOT NULL DEFAULT 1,
402            slow        INTEGER NOT NULL DEFAULT 0,
403            timed_out   INTEGER NOT NULL DEFAULT 0,
404            duration_ms INTEGER NOT NULL
405        );
406        CREATE INDEX IF NOT EXISTS idx_pr_run    ON probe_results(run_id);
407        CREATE INDEX IF NOT EXISTS idx_pr_name   ON probe_results(name);
408        CREATE INDEX IF NOT EXISTS idx_pr_trend  ON probe_results(name, duration_ms);
409        CREATE INDEX IF NOT EXISTS idx_runs_ver  ON probe_runs(asd_version);
410        CREATE INDEX IF NOT EXISTS idx_runs_at   ON probe_runs(started_at);
411    ",
412    )?;
413    Ok(conn)
414}
415
416/// Derive the scope string from filter fields (mirrors show_history logic).
417fn scope_from_record(record: &Value) -> String {
418    match (
419        record.get("filter_name").and_then(Value::as_str),
420        record.get("filter_tag").and_then(Value::as_str),
421    ) {
422        (Some(n), _) => format!("name:{}", n),
423        (_, Some(t)) => format!("tag:{}", t),
424        _ => "all".to_string(),
425    }
426}
427
428/// Insert one run record + its per-probe rows.  Idempotent: skips if run_id exists.
429/// Silently returns on any DB error — analytics is best-effort.
430fn insert_run_to_analytics(conn: &Connection, record: &Value) {
431    let run_id = match record.get("started_at").and_then(Value::as_str) {
432        Some(s) => s,
433        None => return,
434    };
435
436    // Skip if already present.
437    let exists: bool = conn
438        .query_row(
439            "SELECT 1 FROM probe_runs WHERE run_id=?1",
440            params![run_id],
441            |_| Ok(true),
442        )
443        .unwrap_or(false);
444    if exists {
445        return;
446    }
447
448    let scope = scope_from_record(record);
449
450    let res = conn.execute(
451        "INSERT OR IGNORE INTO probe_runs
452         (run_id, asd_version, started_at, finished_at, probe_file, db_state, symbol_count,
453          scope, total, passed, failed, budget_failed, wall_time_ms, worker_count,
454          performance_budget_ms, filter_name, filter_tag)
455         VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)",
456        params![
457            run_id,
458            record
459                .get("asd_version")
460                .and_then(Value::as_str)
461                .unwrap_or(""),
462            record
463                .get("started_at")
464                .and_then(Value::as_str)
465                .unwrap_or(""),
466            record.get("finished_at").and_then(Value::as_str),
467            record.get("probe_file").and_then(Value::as_str),
468            record.get("db_state").and_then(Value::as_str),
469            record.get("symbol_count").and_then(Value::as_i64),
470            scope,
471            record.get("total").and_then(Value::as_i64).unwrap_or(0),
472            record.get("passed").and_then(Value::as_i64).unwrap_or(0),
473            record.get("failed").and_then(Value::as_i64).unwrap_or(0),
474            record
475                .get("budget_failed")
476                .and_then(Value::as_bool)
477                .map(|b| b as i64)
478                .unwrap_or(0),
479            record
480                .get("wall_time_ms")
481                .and_then(Value::as_i64)
482                .unwrap_or(0),
483            record.get("worker_count").and_then(Value::as_i64),
484            record.get("performance_budget_ms").and_then(Value::as_i64),
485            record.get("filter_name").and_then(Value::as_str),
486            record.get("filter_tag").and_then(Value::as_str),
487        ],
488    );
489    if res.is_err() {
490        return;
491    }
492
493    // Insert per-probe rows from the `probes` array (present from this version onward).
494    if let Some(probes) = record.get("probes").and_then(Value::as_array) {
495        for p in probes {
496            let tags_str = p
497                .get("tags")
498                .map(|v| v.to_string())
499                .unwrap_or_else(|| "[]".to_string());
500            let _ = conn.execute(
501                "INSERT INTO probe_results
502                 (run_id, name, command, assertion, tags, passed, slow, timed_out, duration_ms)
503                 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
504                params![
505                    run_id,
506                    p.get("name").and_then(Value::as_str).unwrap_or(""),
507                    p.get("command").and_then(Value::as_str),
508                    p.get("assertion").and_then(Value::as_str),
509                    tags_str,
510                    p.get("passed")
511                        .and_then(Value::as_bool)
512                        .map(|b| b as i64)
513                        .unwrap_or(1),
514                    p.get("slow")
515                        .and_then(Value::as_bool)
516                        .map(|b| b as i64)
517                        .unwrap_or(0),
518                    p.get("timed_out")
519                        .and_then(Value::as_bool)
520                        .map(|b| b as i64)
521                        .unwrap_or(0),
522                    p.get("duration_ms").and_then(Value::as_i64).unwrap_or(0),
523                ],
524            );
525        }
526    }
527}
528
529// ---------------------------------------------------------------------------
530// probe run
531// ---------------------------------------------------------------------------
532
533fn probe_file_path(cfg: &Config) -> PathBuf {
534    let db_dir = Path::new(&cfg.db_path).parent().unwrap_or(Path::new("."));
535    db_dir.join(".asd").join("probes.toml")
536}
537
538fn history_path(cfg: &Config) -> PathBuf {
539    let db_dir = Path::new(&cfg.db_path).parent().unwrap_or(Path::new("."));
540    db_dir.join(".asd").join("probe-history.jsonl")
541}
542
543/// Append a compact run record to probe-history.jsonl and prune to MAX_HISTORY lines.
544/// Failures here are silently ignored — history is best-effort, never blocking.
545const MAX_HISTORY: usize = 500;
546
547fn append_history(cfg: &Config, record: &Value) {
548    let path = history_path(cfg);
549    if let Some(parent) = path.parent() {
550        let _ = std::fs::create_dir_all(parent);
551    }
552    let new_line = match serde_json::to_string(record) {
553        Ok(s) => s,
554        Err(_) => return,
555    };
556    let existing = std::fs::read_to_string(&path).unwrap_or_default();
557    let mut lines: Vec<String> = existing
558        .lines()
559        .filter(|l| !l.trim().is_empty())
560        .map(|l| l.to_string())
561        .collect();
562    lines.push(new_line);
563    if lines.len() > MAX_HISTORY {
564        lines.drain(0..lines.len() - MAX_HISTORY);
565    }
566    let content = lines.join("\n") + "\n";
567    let _ = std::fs::write(&path, content);
568}
569
570fn run_probes(cfg: &Config, args: ProbeRunArgs) -> Result<()> {
571    let path = probe_file_path(cfg);
572    if !path.exists() {
573        anyhow::bail!(
574            "no probes file found at {}\nRun `asd probe add` to create probes.",
575            path.display()
576        );
577    }
578
579    let raw =
580        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
581    let pf: ProbeFile =
582        toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
583
584    let probes: Vec<&ProbeEntry> = pf
585        .probe
586        .iter()
587        .filter(|p| {
588            // --name: exact name match
589            if let Some(ref n) = args.name {
590                if p.name != *n {
591                    return false;
592                }
593            }
594            // --tag: probe must include this tag
595            if let Some(ref t) = args.tag {
596                if !p.tags.iter().any(|tag| tag == t) {
597                    return false;
598                }
599            }
600            // --filter: legacy substring match on name
601            if let Some(ref f) = args.filter {
602                if !p.name.contains(f.as_str()) {
603                    return false;
604                }
605            }
606            true
607        })
608        .collect();
609
610    if probes.is_empty() {
611        if args.name.is_some() || args.tag.is_some() || args.filter.is_some() {
612            let mut reason = Vec::new();
613            if let Some(ref n) = args.name {
614                reason.push(format!("name={:?}", n));
615            }
616            if let Some(ref t) = args.tag {
617                reason.push(format!("tag={:?}", t));
618            }
619            if let Some(ref f) = args.filter {
620                reason.push(format!("filter={:?}", f));
621            }
622            println!("No probes matched filter(s): {}.", reason.join(", "));
623        } else {
624            println!("No probes to run.");
625        }
626        return Ok(());
627    }
628
629    // Determine parallelism.
630    // Auto (jobs=0): cap at 6 — empirically the SQLite page-cache contention
631    // from concurrent subprocess readers increases wall time above ~6 parallel jobs
632    // even on machines with many more CPU threads.  Users can override with --jobs N.
633    let n = probes.len();
634    const AUTO_JOBS_CAP: usize = 6;
635    let jobs = if args.jobs == 0 {
636        std::thread::available_parallelism()
637            .map(|p| p.get())
638            .unwrap_or(4)
639            .min(AUTO_JOBS_CAP)
640            .min(n)
641    } else {
642        args.jobs.min(n).max(1)
643    };
644
645    if !args.json && jobs > 1 {
646        eprintln!("Running {} probe(s) [{} parallel]…", n, jobs);
647    }
648
649    // Gather DB metadata + trust score once before running probes (cheap reads).
650    let db_state = {
651        let warn = stale_warning(&cfg.db_path, 3600);
652        match warn {
653            None => "fresh",
654            Some(ref s) if s.contains("FTS search index failed") => "symbols-fresh/fts-stale",
655            Some(_) => "stale",
656        }
657    };
658    let symbol_count: Option<u64> = SearchFtsDb::open(&cfg.db_path)
659        .ok()
660        .map(|fts| fts.symbol_count() as u64);
661    let trust = compute_trust_score(&cfg.db_path);
662
663    let started_at = Utc::now().to_rfc3339();
664    let wall_start = Instant::now();
665
666    // -------------------------------------------------------------------------
667    // Phase 1: Command de-duplication cache.
668    //
669    // Many probes share identical (command, args, cwd) — e.g. 10 search probes
670    // all run `search "drift playhead" --agent` and test different fields of the
671    // same JSON.  We compute a cache key for each probe, execute each unique key
672    // exactly once (in parallel up to `jobs` workers), then fan out the
673    // assertions in Phase 2 against the shared output.
674    //
675    // hydrate-roundtrip is excluded (in-process, not a subprocess).
676    // workflow probes (command="workflow") are excluded (no JSON output to cache).
677    // -------------------------------------------------------------------------
678    let cacheable = |p: &&ProbeEntry| {
679        p.command != "hydrate-roundtrip"
680            && p.command != "hydrate_roundtrip"
681            && p.command != "workflow"
682    };
683
684    // Map each probe index → its cache key (or None if non-cacheable).
685    let probe_keys: Vec<Option<String>> = probes
686        .iter()
687        .map(|p| {
688            if cacheable(&p) {
689                Some(command_cache_key(p, &cfg.db_path))
690            } else {
691                None
692            }
693        })
694        .collect();
695
696    // Collect unique keys, keeping the first probe index as the representative.
697    let mut key_to_representative: std::collections::HashMap<String, usize> =
698        std::collections::HashMap::new();
699    for (i, key_opt) in probe_keys.iter().enumerate() {
700        if let Some(key) = key_opt {
701            key_to_representative.entry(key.clone()).or_insert(i);
702        }
703    }
704
705    let n_unique = key_to_representative.len();
706    let n_cacheable = probe_keys.iter().filter(|k| k.is_some()).count();
707    let n_deduped = n_cacheable.saturating_sub(n_unique);
708    if !args.json && n_deduped > 0 {
709        eprintln!(
710            "  {} duplicate command invocation(s) eliminated by cache ({} unique → {} probes)",
711            n_deduped, n_unique, n_cacheable
712        );
713    }
714
715    // Execute unique commands in parallel, building the cache.
716    // key → CachedOutput
717    let command_cache: std::collections::HashMap<String, CachedOutput> = {
718        let unique_probes: Vec<(String, &ProbeEntry)> = key_to_representative
719            .iter()
720            .map(|(key, &idx)| (key.clone(), probes[idx]))
721            .collect();
722        let mut map: std::collections::HashMap<String, CachedOutput> =
723            std::collections::HashMap::with_capacity(unique_probes.len());
724        std::thread::scope(|scope| {
725            for chunk in unique_probes.chunks(jobs) {
726                let handles: Vec<_> = chunk
727                    .iter()
728                    .map(|(key, probe)| {
729                        scope.spawn(move || (key.clone(), run_command_only(cfg, probe)))
730                    })
731                    .collect();
732                for h in handles {
733                    let (k, v) = h.join().unwrap_or_else(|_| {
734                        (
735                            "__panic__".to_string(),
736                            CachedOutput {
737                                json: None,
738                                stdout_raw: String::new(),
739                                stderr: String::new(),
740                                success: false,
741                                duration_ms: 0,
742                                timed_out: false,
743                                exec_error: Some(
744                                    "thread panicked during command execution".to_string(),
745                                ),
746                            },
747                        )
748                    });
749                    map.insert(k, v);
750                }
751            }
752        });
753        map
754    };
755
756    // -------------------------------------------------------------------------
757    // Phase 2: assertion fan-out (fast — no subprocesses).
758    // -------------------------------------------------------------------------
759
760    // fail_fast_flag: set by any thread when an assertion fails and --fail-fast is active.
761    // std::thread::scope guarantees all threads finish before we leave the scope, so no
762    // Arc is needed — a plain reference to the AtomicBool is sufficient and is Copy+Send.
763    let fail_fast_flag = std::sync::atomic::AtomicBool::new(false);
764    // Extract scalar flags so closures can capture them by copy (avoids moving `args`).
765    let fail_fast = args.fail_fast;
766    let fail_slow = args.fail_slow;
767    let show_json = args.json;
768    let mut results: Vec<ProbeResult> = Vec::with_capacity(n);
769
770    std::thread::scope(|scope| {
771        // Process probes in chunks of `jobs`. Within each chunk all probes run in
772        // parallel; results are printed in submission order after the chunk finishes.
773        for chunk_start in (0..probes.len()).step_by(jobs) {
774            if fail_fast_flag.load(std::sync::atomic::Ordering::Relaxed) {
775                break;
776            }
777            let chunk_end = (chunk_start + jobs).min(probes.len());
778            let chunk = &probes[chunk_start..chunk_end];
779            // Rebind as a reference so each spawned closure copies the &AtomicBool
780            // (which is Copy+Send) rather than trying to move the AtomicBool itself.
781            let ff = &fail_fast_flag;
782
783            // Spawn one thread per probe in this chunk.
784            // Each thread returns (original_index, ProbeResult).
785            // Phase 2: if the probe has a cache entry, use run_assertion_against
786            // (fast, no subprocess). Otherwise fall back to execute_probe for
787            // in-process probes (hydrate-roundtrip, workflow).
788            let handles: Vec<_> = chunk
789                .iter()
790                .enumerate()
791                .map(|(j, probe)| {
792                    let global_idx = chunk_start + j;
793                    let cache_key = probe_keys[global_idx].clone();
794                    let cached = cache_key.as_ref().and_then(|k| command_cache.get(k));
795                    scope.spawn(move || {
796                        // Check flag before doing work (fast exit on fail-fast).
797                        if ff.load(std::sync::atomic::Ordering::Relaxed) {
798                            return None;
799                        }
800                        let result = if let Some(c) = cached {
801                            run_assertion_against(probe, c)
802                        } else {
803                            execute_probe(cfg, probe)
804                        };
805                        if result.error.is_some() && fail_fast {
806                            ff.store(true, std::sync::atomic::Ordering::Relaxed);
807                        }
808                        Some((global_idx, result))
809                    })
810                })
811                .collect();
812
813            // Wait for all handles in chunk, sort back to submission order, print.
814            let mut chunk_results: Vec<(usize, ProbeResult)> = handles
815                .into_iter()
816                .filter_map(|h| h.join().unwrap_or(None))
817                .collect();
818            chunk_results.sort_by_key(|(i, _)| *i);
819
820            for (_, result) in &chunk_results {
821                let is_fail = result.error.is_some();
822                let is_slow = fail_slow.map_or(false, |ms| result.duration_ms > ms as u128);
823                if !show_json {
824                    let status = if is_fail {
825                        "FAIL"
826                    } else if is_slow {
827                        "SLOW"
828                    } else {
829                        "PASS"
830                    };
831                    let ms = result.duration_ms;
832                    if is_fail {
833                        println!("{:<5} {} ({}ms)", status, result.name, ms);
834                        println!("      {}", result.error.as_deref().unwrap_or(""));
835                        if let Some(ref payload) = result.debug_payload_summary {
836                            println!("      debug: {}", payload);
837                        }
838                    } else {
839                        println!("{:<5} {} ({}ms)", status, result.name, ms);
840                    }
841                }
842            }
843            results.extend(chunk_results.into_iter().map(|(_, r)| r));
844        }
845    });
846
847    let passed = results.iter().filter(|r| r.error.is_none()).count();
848    let failed = results.iter().filter(|r| r.error.is_some()).count();
849
850    // Slow violations: probes that exceeded --fail-slow threshold.
851    let slow_violations: Vec<&ProbeResult> = if let Some(threshold_ms) = args.fail_slow {
852        results
853            .iter()
854            .filter(|r| r.duration_ms > threshold_ms as u128)
855            .collect()
856    } else {
857        Vec::new()
858    };
859
860    // Helper closure: render a ProbeResult as the canonical JSON shape.
861    let fail_slow = args.fail_slow;
862    let result_to_json = |r: &ProbeResult| {
863        let is_slow = fail_slow.map_or(false, |ms| r.duration_ms > ms as u128);
864        serde_json::json!({
865            "name": r.name,
866            "command": r.command,
867            "assertion": r.assertion,
868            "tags": r.tags,
869            "passed": r.error.is_none(),
870            "slow": is_slow,
871            "timed_out": r.timed_out,
872            "duration_ms": r.duration_ms,
873            "error": r.error,
874            "debug_payload": r.debug_payload,
875        })
876    };
877
878    // Top-5 slowest — full result shape, pre-sorted descending by
879    // duration_ms.
880    //
881    // Refinement (1.0.73, after AcmeProj 1.0.72 field run): probes
882    // that share a subprocess via the command-cache (e.g.
883    // `rank-projectmanager-top5` + `rank-projectmanager-eq1` both
884    // hit the same `asd search ProjectManager --agent` invocation)
885    // were producing duplicate "slowest" rows with identical
886    // durations, making a 3-distinct-subprocess workload look like
887    // 5 separate slow paths. Collapse by (command, args, cwd)
888    // cache key so each row represents one ACTUAL slow subprocess;
889    // the probe_names array lists every probe that timed against
890    // that run.
891    let dedup_db_path = cfg.db_path.clone();
892    let mut by_cache: std::collections::HashMap<String, (u128, Vec<&ProbeResult>)> =
893        std::collections::HashMap::new();
894    for r in &results {
895        let key = format!(
896            "{}|{}",
897            r.command,
898            // Best-effort: probes share the cache when their probe
899            // entries share command+args. Result-level we only have
900            // command (not args), so probes with identical command +
901            // identical duration_ms collapse. Reading the probe
902            // entries here would require threading them through —
903            // skipped for now, the (command, duration) heuristic
904            // catches the rank-*-top5 + rank-*-eq1 pairs that
905            // motivated this refinement.
906            r.duration_ms,
907        );
908        let entry = by_cache.entry(key).or_insert((r.duration_ms, Vec::new()));
909        entry.1.push(r);
910    }
911    // Suppress unused-warning on dedup_db_path — kept available
912    // for a future tightening that hashes the full command-key.
913    let _ = dedup_db_path;
914    let mut deduped: Vec<(u128, Vec<&ProbeResult>)> = by_cache.into_values().collect();
915    deduped.sort_by(|a, b| b.0.cmp(&a.0));
916    let slowest_top5: Vec<Value> = deduped
917        .iter()
918        .take(5)
919        .map(|(duration_ms, group)| {
920            // Single-probe groups render exactly like the old shape
921            // (no extra `probe_names` field) for backward compat.
922            // Multi-probe groups add `probe_names` listing every
923            // sibling that shared the cache.
924            if group.len() == 1 {
925                result_to_json(group[0])
926            } else {
927                let names: Vec<&str> = group.iter().map(|r| r.name.as_str()).collect();
928                let mut v = result_to_json(group[0]);
929                if let Some(obj) = v.as_object_mut() {
930                    obj.insert("probe_names".to_string(), serde_json::json!(names));
931                    obj.insert(
932                        "cache_shared_count".to_string(),
933                        serde_json::json!(group.len()),
934                    );
935                    obj.insert("duration_ms".to_string(), serde_json::json!(duration_ms));
936                }
937                v
938            }
939        })
940        .collect();
941
942    // Compute summary values shared by JSON output, human output, and history.
943    let wall_time_ms = wall_start.elapsed().as_millis();
944    let finished_at = Utc::now().to_rfc3339();
945    let budget_failed = !slow_violations.is_empty();
946    let slow_violation_names: Vec<&str> = slow_violations.iter().map(|r| r.name.as_str()).collect();
947
948    if args.json {
949        let json_results: Vec<Value> = results.iter().map(|r| result_to_json(r)).collect();
950        // Plan J t-015: confidence-bucket calibration. Walk each
951        // probe's debug_payload for uncertainty.level and pair it
952        // with the assertion outcome. compute_calibration produces
953        // per-bucket pass-rate + advisory strings ("threshold may
954        // be too strict / too generous") so callers can see at a
955        // glance which buckets need tuning. Probes whose payload
956        // has no uncertainty.level field (e.g. hydrate-roundtrip,
957        // non-search probes) contribute nothing and are silently
958        // skipped — calibration only covers probes that emit a
959        // bucket label.
960        let calibration_obs: Vec<(String, bool)> = results
961            .iter()
962            .filter_map(|r| {
963                // Plan J t-015 fix: read the pre-extracted signal,
964                // NOT debug_payload (which is None on the pass path
965                // — populating calibration only from failures gave
966                // empty buckets on every all-passing run).
967                let level = r.calibration_signal.as_deref()?;
968                Some((level.to_string(), r.error.is_none()))
969            })
970            .collect();
971        let calibration = compute_calibration(calibration_obs);
972
973        println!(
974            "{}",
975            serde_json::to_string_pretty(&serde_json::json!({
976                "asd_version": env!("CARGO_PKG_VERSION"),
977                "started_at": started_at,
978                "finished_at": finished_at,
979                "probe_file": probe_file_path(cfg),
980                "db_path": cfg.db_path,
981                "total": results.len(),
982                "passed": passed,
983                "failed": failed,
984                "budget_failed": budget_failed,
985                "wall_time_ms": wall_time_ms,
986                "worker_count": jobs,
987                "db_state": db_state,
988                "symbol_count": symbol_count,
989                "performance_budget_ms": args.fail_slow,
990                "slow_violations": slow_violation_names,
991                "slowest": slowest_top5,
992                "trust": trust.to_json(),
993                "calibration": calibration,
994                "results": json_results,
995            }))?
996        );
997    } else {
998        println!(
999            "\n{} probe(s): {} passed, {} failed  [{} workers, {}ms wall]",
1000            results.len(),
1001            passed,
1002            failed,
1003            jobs,
1004            wall_time_ms
1005        );
1006        if !slow_violations.is_empty() {
1007            let threshold_ms = args.fail_slow.unwrap();
1008            println!("SLOW violations (>{threshold_ms} ms):");
1009            for r in &slow_violations {
1010                println!("  {} ({}ms)", r.name, r.duration_ms);
1011            }
1012        }
1013        if !slowest_top5.is_empty() && results.len() > 1 {
1014            println!("Slowest probes:");
1015            for entry in &slowest_top5 {
1016                println!(
1017                    "  {} ({}ms)",
1018                    entry["name"].as_str().unwrap_or("?"),
1019                    entry["duration_ms"].as_u64().unwrap_or(0)
1020                );
1021            }
1022        }
1023    }
1024
1025    // Always append a compact record to probe-history.jsonl regardless of output mode.
1026    // Includes per-probe compact rows (no debug_payload) — canonical source of truth
1027    // for per-probe trend analysis.  The analytics SQLite DB mirrors this.
1028    let probes_compact: Vec<Value> = results
1029        .iter()
1030        .map(|r| {
1031            let is_slow = fail_slow.map_or(false, |ms| r.duration_ms > ms as u128);
1032            serde_json::json!({
1033                "name": r.name,
1034                "command": r.command,
1035                "assertion": r.assertion,
1036                "tags": r.tags,
1037                "passed": r.error.is_none(),
1038                "slow": is_slow,
1039                "timed_out": r.timed_out,
1040                "duration_ms": r.duration_ms,
1041            })
1042        })
1043        .collect();
1044    let history_record = serde_json::json!({
1045        "kind": "probe_run",
1046        "asd_version": env!("CARGO_PKG_VERSION"),
1047        "started_at": started_at,
1048        "finished_at": finished_at,
1049        "probe_file": probe_file_path(cfg),
1050        "db_state": db_state,
1051        "symbol_count": symbol_count,
1052        "total": results.len(),
1053        "passed": passed,
1054        "failed": failed,
1055        "budget_failed": budget_failed,
1056        "wall_time_ms": wall_time_ms,
1057        "worker_count": jobs,
1058        "performance_budget_ms": args.fail_slow,
1059        "filter_name": args.name,
1060        "filter_tag": args.tag,
1061        "slowest": slowest_top5,
1062        "probes": probes_compact,
1063    });
1064    append_history(cfg, &history_record);
1065
1066    // Mirror into analytics DB (best-effort; failures are silent).
1067    if let Ok(conn) = open_analytics_db(&analytics_path(cfg)) {
1068        insert_run_to_analytics(&conn, &history_record);
1069    }
1070
1071    if failed > 0 || !slow_violations.is_empty() {
1072        std::process::exit(1);
1073    }
1074    Ok(())
1075}
1076
1077struct ProbeResult {
1078    name: String,
1079    command: String,
1080    assertion: String, // assertion kind, e.g. "file_not_in_key"; "" = smoke test
1081    tags: Vec<String>,
1082    duration_ms: u128,
1083    timed_out: bool, // true if the probe was killed by a timeout (reserved; always false today)
1084    error: Option<String>,
1085    debug_payload: Option<Value>,
1086    debug_payload_summary: Option<String>,
1087    /// Plan J t-015: uncertainty bucket label extracted from the
1088    /// cached JSON output, captured for EVERY probe (pass or fail)
1089    /// so the calibration harvester has observations even when
1090    /// debug_payload is None. `None` when the probe's command
1091    /// output didn't include `uncertainty.level` (e.g. `asd trust`,
1092    /// which doesn't emit a bucket).
1093    calibration_signal: Option<String>,
1094}
1095
1096/// Extract `uncertainty.level` from a probe's parsed JSON output.
1097/// Used to populate `ProbeResult.calibration_signal` regardless of
1098/// pass/fail, so the calibration harvester sees every observation
1099/// (debug_payload is only attached on failure — relying on it
1100/// silently drops the entire pass cohort).
1101fn extract_calibration_signal(json: Option<&Value>) -> Option<String> {
1102    json?
1103        .get("uncertainty")?
1104        .get("level")?
1105        .as_str()
1106        .map(|s| s.to_string())
1107}
1108
1109/// Cached result of a single subprocess execution (command + args).
1110/// Shared across all probes that map to the same command invocation.
1111#[allow(dead_code)] // stdout_raw + timed_out kept for forensic dumps
1112#[derive(Clone)]
1113struct CachedOutput {
1114    /// Parsed JSON from stdout, or None if output was not valid JSON.
1115    json: Option<Value>,
1116    /// Raw stdout text (used for error messages).
1117    stdout_raw: String,
1118    /// Stderr text (included in error messages).
1119    stderr: String,
1120    /// True if the process exited successfully.
1121    success: bool,
1122    /// Wall-clock time for the subprocess (ms).
1123    duration_ms: u128,
1124    /// True if the command hit the --timeout limit.
1125    timed_out: bool,
1126    /// Execution-level error (process spawn failure), separate from assertion errors.
1127    exec_error: Option<String>,
1128}
1129
1130/// Compute the de-duplication key for a probe's command invocation.
1131/// Probes with identical (command, args, cwd, db_path) share one subprocess run.
1132fn command_cache_key(probe: &ProbeEntry, db_path: &std::path::Path) -> String {
1133    let cwd_part = probe.cwd.as_deref().unwrap_or("");
1134    format!(
1135        "{}|{}|{}|{}",
1136        probe.command,
1137        probe.args.join("\x00"),
1138        cwd_part,
1139        db_path.display()
1140    )
1141}
1142
1143/// Execute the subprocess for `probe` and return a `CachedOutput`.
1144/// Does NOT run the assertion — pure I/O only.
1145/// Skipped for hydrate-roundtrip (handled separately).
1146fn run_command_only(cfg: &Config, probe: &ProbeEntry) -> CachedOutput {
1147    let start = Instant::now();
1148
1149    let asd_bin_path: PathBuf = {
1150        let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("asd"));
1151        if exe.exists() {
1152            exe
1153        } else {
1154            PathBuf::from("asd")
1155        }
1156    };
1157    let asd_bin = asd_bin_path.to_string_lossy().into_owned();
1158
1159    let subcmd = match probe.command.as_str() {
1160        "prepare-change" | "prepare_change" => "prepare-change",
1161        "annotate-commit" | "annotate_commit" => "annotate-commit",
1162        "task-close" | "task_close" => "task-close",
1163        other => other,
1164    };
1165
1166    let probe_dir = std::path::Path::new(&cfg.db_path)
1167        .parent()
1168        .unwrap_or(std::path::Path::new("."))
1169        .to_path_buf();
1170    let work_dir = probe.cwd.as_ref().map(PathBuf::from).unwrap_or(probe_dir);
1171
1172    fn shell_quote_inner(s: &str) -> String {
1173        format!("'{}'", s.replace('\'', r"'\''"))
1174    }
1175
1176    let db_path_str = cfg.db_path.to_string_lossy().into_owned();
1177    let mut shell_cmd = format!(
1178        "cd {} && {} --db {} {}",
1179        shell_quote_inner(work_dir.to_string_lossy().as_ref()),
1180        shell_quote_inner(&asd_bin),
1181        shell_quote_inner(&db_path_str),
1182        shell_quote_inner(subcmd),
1183    );
1184    for arg in &probe.args {
1185        shell_cmd.push(' ');
1186        shell_cmd.push_str(&shell_quote_inner(arg));
1187    }
1188
1189    let mut cmd = ProcessCommand::new("/bin/sh");
1190    cmd.arg("-c").arg(&shell_cmd);
1191
1192    let output = match cmd.output() {
1193        Ok(o) => o,
1194        Err(e) => {
1195            return CachedOutput {
1196                json: None,
1197                stdout_raw: String::new(),
1198                stderr: String::new(),
1199                success: false,
1200                duration_ms: start.elapsed().as_millis(),
1201                timed_out: false,
1202                exec_error: Some(format!(
1203                    "failed to execute asd ({:?}) via sh: {}",
1204                    asd_bin, e
1205                )),
1206            };
1207        }
1208    };
1209
1210    let stdout_raw = String::from_utf8_lossy(&output.stdout).into_owned();
1211    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
1212    let json: Option<Value> = serde_json::from_str(&stdout_raw).ok();
1213    let duration_ms = start.elapsed().as_millis();
1214
1215    CachedOutput {
1216        json,
1217        stdout_raw,
1218        stderr,
1219        success: output.status.success(),
1220        duration_ms,
1221        timed_out: false,
1222        exec_error: None,
1223    }
1224}
1225
1226/// Run a probe's assertion against a pre-computed `CachedOutput`.
1227/// Returns the ProbeResult without spawning any subprocess.
1228fn run_assertion_against(probe: &ProbeEntry, cached: &CachedOutput) -> ProbeResult {
1229    let assertion_kind = probe
1230        .assert
1231        .as_table()
1232        .and_then(|m| m.get("kind"))
1233        .and_then(|v| v.as_str())
1234        .unwrap_or("")
1235        .to_string();
1236
1237    if let Some(ref exec_err) = cached.exec_error {
1238        return ProbeResult {
1239            name: probe.name.clone(),
1240            command: probe.command.clone(),
1241            assertion: assertion_kind,
1242            tags: probe.tags.clone(),
1243            duration_ms: cached.duration_ms,
1244            timed_out: false,
1245            error: Some(exec_err.clone()),
1246            debug_payload: None,
1247            debug_payload_summary: None,
1248            calibration_signal: cached
1249                .json
1250                .as_ref()
1251                .and_then(|j| extract_calibration_signal(Some(j))),
1252        };
1253    }
1254
1255    if !cached.success && cached.json.is_none() {
1256        return ProbeResult {
1257            name: probe.name.clone(),
1258            command: probe.command.clone(),
1259            assertion: assertion_kind,
1260            tags: probe.tags.clone(),
1261            duration_ms: cached.duration_ms,
1262            timed_out: false,
1263            error: Some(format!("command failed: {}", cached.stderr.trim())),
1264            debug_payload: None,
1265            debug_payload_summary: None,
1266            calibration_signal: cached
1267                .json
1268                .as_ref()
1269                .and_then(|j| extract_calibration_signal(Some(j))),
1270        };
1271    }
1272
1273    let json = match cached.json.as_ref() {
1274        Some(v) => v,
1275        None => {
1276            return ProbeResult {
1277                name: probe.name.clone(),
1278                command: probe.command.clone(),
1279                assertion: assertion_kind,
1280                tags: probe.tags.clone(),
1281                duration_ms: cached.duration_ms,
1282                timed_out: false,
1283                error: Some("command output was not valid JSON".to_string()),
1284                debug_payload: None,
1285                debug_payload_summary: None,
1286                calibration_signal: cached
1287                    .json
1288                    .as_ref()
1289                    .and_then(|j| extract_calibration_signal(Some(j))),
1290            };
1291        }
1292    };
1293
1294    match eval_assert(&probe.assert, json) {
1295        Ok(()) => ProbeResult {
1296            name: probe.name.clone(),
1297            command: probe.command.clone(),
1298            assertion: assertion_kind,
1299            tags: probe.tags.clone(),
1300            duration_ms: cached.duration_ms,
1301            timed_out: false,
1302            error: None,
1303            debug_payload: None,
1304            debug_payload_summary: None,
1305            calibration_signal: cached
1306                .json
1307                .as_ref()
1308                .and_then(|j| extract_calibration_signal(Some(j))),
1309        },
1310        Err(msg) => {
1311            let summary = summarize_debug_payload(json);
1312            ProbeResult {
1313                name: probe.name.clone(),
1314                command: probe.command.clone(),
1315                assertion: assertion_kind,
1316                tags: probe.tags.clone(),
1317                duration_ms: cached.duration_ms,
1318                timed_out: false,
1319                error: Some(msg),
1320                debug_payload: Some(json.clone()),
1321                debug_payload_summary: summary,
1322                calibration_signal: extract_calibration_signal(Some(json)),
1323            }
1324        }
1325    }
1326}
1327
1328fn execute_probe(cfg: &Config, probe: &ProbeEntry) -> ProbeResult {
1329    let start = Instant::now();
1330    // Extract assertion kind from probe definition for result metadata.
1331    let assertion_kind = probe
1332        .assert
1333        .as_table()
1334        .and_then(|m| m.get("kind"))
1335        .and_then(|v| v.as_str())
1336        .unwrap_or("")
1337        .to_string();
1338
1339    // Special-case: hydrate-roundtrip runs an isolated in-process cycle rather
1340    // than spawning a subprocess. No assert block needed — pass/fail is the
1341    // roundtrip itself.
1342    if probe.command.as_str() == "hydrate-roundtrip"
1343        || probe.command.as_str() == "hydrate_roundtrip"
1344    {
1345        let result = run_hydrate_roundtrip_probe(cfg);
1346        let duration_ms = start.elapsed().as_millis();
1347        return match result {
1348            Ok(msg) => ProbeResult {
1349                name: probe.name.clone(),
1350                command: probe.command.clone(),
1351                assertion: "hydrate_roundtrip".to_string(),
1352                tags: probe.tags.clone(),
1353                duration_ms,
1354                timed_out: false,
1355                error: None,
1356                debug_payload: Some(serde_json::json!({ "message": msg })),
1357                debug_payload_summary: Some(msg),
1358                calibration_signal: None, // hydrate doesn't emit uncertainty.level
1359            },
1360            Err(msg) => ProbeResult {
1361                name: probe.name.clone(),
1362                command: probe.command.clone(),
1363                assertion: "hydrate_roundtrip".to_string(),
1364                tags: probe.tags.clone(),
1365                duration_ms,
1366                timed_out: false,
1367                error: Some(msg.clone()),
1368                debug_payload: Some(serde_json::json!({ "error": msg })),
1369                debug_payload_summary: Some(msg),
1370                calibration_signal: None,
1371            },
1372        };
1373    }
1374
1375    // Resolve asd binary path.
1376    // Prefer the current executable if it actually exists (covers installed + dev builds).
1377    // Fall back to plain "asd" so the OS will find it via PATH — this handles cases
1378    // where current_exe() returns a stale worktree path that no longer exists.
1379    let asd_bin_path: PathBuf = {
1380        let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("asd"));
1381        if exe.exists() {
1382            exe
1383        } else {
1384            PathBuf::from("asd")
1385        }
1386    };
1387    // Use the string form for shell quoting.
1388    let asd_bin = asd_bin_path.to_string_lossy().into_owned();
1389
1390    // Map command name to CLI subcommand string.
1391    let subcmd = match probe.command.as_str() {
1392        "prepare-change" | "prepare_change" => "prepare-change",
1393        "annotate-commit" | "annotate_commit" => "annotate-commit",
1394        "task-close" | "task_close" => "task-close",
1395        other => other,
1396    };
1397
1398    // Determine working directory: probe.cwd > probes.toml parent dir.
1399    let probe_dir = Path::new(&cfg.db_path)
1400        .parent()
1401        .unwrap_or(Path::new("."))
1402        .to_path_buf();
1403    let work_dir = probe.cwd.as_ref().map(PathBuf::from).unwrap_or(probe_dir);
1404
1405    // Build the shell command string.
1406    // We invoke via `sh -c '...'` rather than exec-ing asd directly because on macOS
1407    // the sandbox environment used by Claude Code blocks direct Process::exec of the
1408    // same binary but permits shell invocation. Shell-quoting: wrap each token with
1409    // single quotes and escape interior single quotes as '\''  .
1410    fn shell_quote(s: &str) -> String {
1411        // Wrap in single quotes; escape any existing single quotes as: ' → '\''
1412        format!("'{}'", s.replace('\'', r"'\''"))
1413    }
1414
1415    let db_path_str = cfg.db_path.to_string_lossy().into_owned();
1416    let mut shell_cmd = format!(
1417        "cd {} && {} --db {} {}",
1418        shell_quote(work_dir.to_string_lossy().as_ref()),
1419        shell_quote(&asd_bin),
1420        shell_quote(&db_path_str),
1421        shell_quote(subcmd),
1422    );
1423    for arg in &probe.args {
1424        shell_cmd.push(' ');
1425        shell_cmd.push_str(&shell_quote(arg));
1426    }
1427
1428    let mut cmd = ProcessCommand::new("/bin/sh");
1429    cmd.arg("-c").arg(&shell_cmd);
1430
1431    let output = match cmd.output() {
1432        Ok(o) => o,
1433        Err(e) => {
1434            return ProbeResult {
1435                name: probe.name.clone(),
1436                command: probe.command.clone(),
1437                assertion: assertion_kind,
1438                tags: probe.tags.clone(),
1439                duration_ms: start.elapsed().as_millis(),
1440                timed_out: false,
1441                error: Some(format!(
1442                    "failed to execute asd ({:?}) via sh: {}",
1443                    asd_bin, e
1444                )),
1445                debug_payload: None,
1446                debug_payload_summary: None,
1447                calibration_signal: None, // exec failed; no JSON to harvest
1448            };
1449        }
1450    };
1451
1452    let stdout = String::from_utf8_lossy(&output.stdout);
1453    let parsed: Option<Value> = serde_json::from_str(&stdout).ok();
1454
1455    let duration_ms = start.elapsed().as_millis();
1456
1457    if !output.status.success() && parsed.is_none() {
1458        let stderr = String::from_utf8_lossy(&output.stderr);
1459        return ProbeResult {
1460            name: probe.name.clone(),
1461            command: probe.command.clone(),
1462            assertion: assertion_kind,
1463            tags: probe.tags.clone(),
1464            duration_ms,
1465            timed_out: false,
1466            error: Some(format!(
1467                "command exited with {}: {}",
1468                output.status,
1469                stderr.trim()
1470            )),
1471            debug_payload: None,
1472            debug_payload_summary: None,
1473            calibration_signal: None, // cmd failed without parseable JSON
1474        };
1475    }
1476
1477    let json = match parsed {
1478        Some(v) => v,
1479        None => {
1480            return ProbeResult {
1481                name: probe.name.clone(),
1482                command: probe.command.clone(),
1483                assertion: assertion_kind,
1484                tags: probe.tags.clone(),
1485                duration_ms,
1486                timed_out: false,
1487                error: Some("command output was not valid JSON".to_string()),
1488                debug_payload: None,
1489                debug_payload_summary: None,
1490                calibration_signal: None, // unparseable stdout, nothing to extract
1491            };
1492        }
1493    };
1494
1495    // Evaluate assertion.
1496    match eval_assert(&probe.assert, &json) {
1497        Ok(()) => ProbeResult {
1498            name: probe.name.clone(),
1499            command: probe.command.clone(),
1500            assertion: assertion_kind,
1501            tags: probe.tags.clone(),
1502            duration_ms,
1503            timed_out: false,
1504            error: None,
1505            debug_payload: None,
1506            debug_payload_summary: None,
1507            calibration_signal: extract_calibration_signal(Some(&json)),
1508        },
1509        Err(msg) => {
1510            let summary = summarize_debug_payload(&json);
1511            let cal = extract_calibration_signal(Some(&json));
1512            ProbeResult {
1513                name: probe.name.clone(),
1514                command: probe.command.clone(),
1515                assertion: assertion_kind,
1516                tags: probe.tags.clone(),
1517                duration_ms,
1518                timed_out: false,
1519                error: Some(msg),
1520                debug_payload: Some(json),
1521                debug_payload_summary: summary,
1522                calibration_signal: cal,
1523            }
1524        }
1525    }
1526}
1527
1528// ---------------------------------------------------------------------------
1529// Hydrate roundtrip probe — semantic memory persistence proof
1530// ---------------------------------------------------------------------------
1531//
1532// Writes a sentinel ledger entry into an isolated temp DB, syncs the sidecar,
1533// hydrates into a second fresh temp DB, and verifies the entry survived.
1534// Uses ephemeral SQLite files in a temp dir — never touches the production DB.
1535
1536fn run_hydrate_roundtrip_probe(_cfg: &Config) -> Result<String, String> {
1537    use agentstatedeveloper_core::{
1538        AsgIndexStore, AsgLedgerStore, Engine, IndexStore, LedgerStore, hydrate_from_dir,
1539        schema::{Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind},
1540        symbol_fingerprint, sync_to_dir,
1541    };
1542
1543    // Step 1 — ephemeral temp workspace (never touches the production DB).
1544    let tmp = tempfile::TempDir::new().map_err(|e| format!("failed to create temp dir: {e}"))?;
1545    let db_a = tmp.path().join("roundtrip_a.db");
1546    let db_b = tmp.path().join("roundtrip_b.db");
1547
1548    // Step 2 — open source engine, write a synthetic symbol + sentinel entry.
1549    let engine_a =
1550        Engine::open_sqlite(&db_a).map_err(|e| format!("failed to open temp Engine A: {e}"))?;
1551
1552    let qname = "asd::probe::roundtrip_sentinel";
1553    let file = "__asd_probe__/roundtrip.rs";
1554    let sym = Symbol {
1555        symbol_id: "asd-probe-roundtrip-sym".to_string(),
1556        symbol_fp: symbol_fingerprint(qname),
1557        qname: qname.to_string(),
1558        kind: SymbolKind::Function,
1559        file: file.to_string(),
1560        language: "rust".to_string(),
1561        start: Position { line: 1, col: 0 },
1562        end: Position { line: 1, col: 0 },
1563        doc: Some("ASD hydrate roundtrip probe sentinel symbol".to_string()),
1564        signature: Some("fn roundtrip_sentinel()".to_string()),
1565    };
1566    let store_a = AsgIndexStore::from_engine(&engine_a);
1567    store_a
1568        .put_symbol(&engine_a.ref_name, &sym, "asd-roundtrip-probe")
1569        .map_err(|e| format!("failed to write sentinel symbol: {e}"))?;
1570
1571    let sentinel_text = "asd-hydrate-roundtrip-proof";
1572    let author = Author {
1573        kind: AuthorKind::Agent,
1574        id: "asd-roundtrip-probe".to_string(),
1575    };
1576    let mut entry = LedgerEntry::new(
1577        "asd-probe-roundtrip-sym",
1578        LedgerKind::Decision,
1579        sentinel_text,
1580        author,
1581    );
1582    entry.tags = vec!["trust-probe".to_string(), "probe-roundtrip".to_string()];
1583
1584    let ledger_a = AsgLedgerStore::new(&engine_a.repo);
1585    ledger_a
1586        .append_entry(&engine_a.ref_name, &entry, "asd-roundtrip-probe")
1587        .map_err(|e| format!("failed to write sentinel ledger entry: {e}"))?;
1588
1589    // Step 3 — sync sidecar to temp dir (creates .asd/v1/ inside tmp).
1590    let sidecar_root = tmp.path();
1591    sync_to_dir(&engine_a.repo, &engine_a.ref_name, sidecar_root)
1592        .map_err(|e| format!("sync_to_dir failed: {e}"))?;
1593
1594    // Step 4 — open fresh engine B (empty DB), hydrate from the sidecar.
1595    let engine_b =
1596        Engine::open_sqlite(&db_b).map_err(|e| format!("failed to open temp Engine B: {e}"))?;
1597    hydrate_from_dir(
1598        &engine_b.repo,
1599        &engine_b.ref_name,
1600        sidecar_root,
1601        "asd-roundtrip-probe",
1602    )
1603    .map_err(|e| format!("hydrate_from_dir failed: {e}"))?;
1604
1605    // Step 5 — verify the sentinel entry survived.
1606    let ledger_b = AsgLedgerStore::new(&engine_b.repo);
1607    let entries = ledger_b
1608        .list_entries(&engine_b.ref_name, "asd-probe-roundtrip-sym")
1609        .map_err(|e| format!("failed to read ledger from Engine B: {e}"))?;
1610
1611    let survived = entries.iter().any(|e| e.summary == sentinel_text);
1612
1613    if survived {
1614        Ok(format!(
1615            "roundtrip OK — sentinel {:?} survived sync→hydrate cycle ({} entries in B)",
1616            sentinel_text,
1617            entries.len()
1618        ))
1619    } else {
1620        Err(format!(
1621            "roundtrip FAILED — sentinel {:?} not found in Engine B after hydrate \
1622             ({} entries present: {:?})",
1623            sentinel_text,
1624            entries.len(),
1625            entries
1626                .iter()
1627                .map(|e| e.summary.as_str())
1628                .collect::<Vec<_>>()
1629        ))
1630    }
1631}
1632
1633// ---------------------------------------------------------------------------
1634// Assertion evaluator
1635// ---------------------------------------------------------------------------
1636
1637fn eval_assert(assert: &toml::Value, output: &Value) -> Result<(), String> {
1638    let map = match assert.as_table() {
1639        Some(m) => m,
1640        None => return Ok(()), // no assertion — probe always passes (useful as smoke test)
1641    };
1642
1643    let kind = map.get("kind").and_then(|v| v.as_str()).unwrap_or("");
1644
1645    // Plan M t-005 (1.0.100): the previous nested `resolve_key` closure
1646    // was a duplicate of the module-level `dot_path` helper. Calling
1647    // `dot_path` directly avoids the dup. Both walk dot-separated key
1648    // paths and return Option<&Value>; semantics are identical.
1649    let empty_arr: Vec<Value> = Vec::new();
1650
1651    match kind {
1652        // file_not_in_key: no item in output[key] array has item[field] containing value.
1653        "file_not_in_key" => {
1654            let key = str_field(map, "key")?;
1655            let field = str_field(map, "field")?;
1656            let value = str_field(map, "value")?;
1657            let arr = dot_path(output, key)
1658                .and_then(|v| v.as_array())
1659                .unwrap_or(&empty_arr);
1660            let found: Vec<&str> = arr
1661                .iter()
1662                .filter_map(|item| item.get(field).and_then(|v| v.as_str()))
1663                .filter(|s| s.contains(value))
1664                .collect();
1665            if !found.is_empty() {
1666                Err(format!(
1667                    "file_not_in_key: found {:?} in {}[].{} (should be absent)",
1668                    found, key, field
1669                ))
1670            } else {
1671                Ok(())
1672            }
1673        }
1674
1675        // file_in_key: at least one item in output[key] has item[field] containing value.
1676        "file_in_key" => {
1677            let key = str_field(map, "key")?;
1678            let field = str_field(map, "field")?;
1679            let value = str_field(map, "value")?;
1680            let arr = dot_path(output, key)
1681                .and_then(|v| v.as_array())
1682                .unwrap_or(&empty_arr);
1683            let found = arr.iter().any(|item| {
1684                item.get(field)
1685                    .and_then(|v| v.as_str())
1686                    .map_or(false, |s| s.contains(value))
1687            });
1688            if found {
1689                Ok(())
1690            } else {
1691                Err(format!(
1692                    "file_in_key: no item in {}[].{} contains {:?}",
1693                    key, field, value
1694                ))
1695            }
1696        }
1697
1698        // qname_rank_lte: result whose qname contains `fragment` is at rank ≤ max_rank (1-based).
1699        "qname_rank_lte" => eval_qname_rank_lte(map, output),
1700
1701        // Plan J t-019: qname_rank_eq — strict variant of
1702        // qname_rank_lte. Passes ONLY when the matching qname is at
1703        // EXACTLY the specified rank (typically 1). Use for
1704        // precision-mode probes: lenient probes (rank_lte max=5)
1705        // surface "is the right symbol anywhere near the top"; this
1706        // surfaces "is the right symbol AT the top." When both
1707        // variants exist for the same query in the same uncertainty
1708        // bucket, calibration's "too strict" advisory becomes
1709        // actionable — split pass rates within a bucket cohort
1710        // mean the lenient signal was hiding ranking noise.
1711        "qname_rank_eq" => {
1712            let fragment = str_field(map, "fragment")?;
1713            let exact_rank = u64_field(map, "exact_rank")?;
1714            let results = output
1715                .get("results")
1716                .and_then(|v| v.as_array())
1717                .unwrap_or(&empty_arr);
1718            let pos = results.iter().position(|r| {
1719                r.get("qname")
1720                    .and_then(|v| v.as_str())
1721                    .map_or(false, |q| q.contains(fragment))
1722            });
1723            match pos {
1724                Some(idx) if (idx as u64 + 1) == exact_rank => Ok(()),
1725                Some(idx) => Err(format!(
1726                    "qname_rank_eq: {:?} found at rank {}, expected exactly {}",
1727                    fragment,
1728                    idx + 1,
1729                    exact_rank
1730                )),
1731                None => Err(format!(
1732                    "qname_rank_eq: no result qname contains {:?} (checked {} results)",
1733                    fragment,
1734                    results.len()
1735                )),
1736            }
1737        }
1738
1739        // result_count_lte: len(results) ≤ max.
1740        "result_count_lte" => {
1741            let max = u64_field(map, "max")?;
1742            let results = output
1743                .get("results")
1744                .and_then(|v| v.as_array())
1745                .unwrap_or(&empty_arr);
1746            let n = results.len() as u64;
1747            if n <= max {
1748                Ok(())
1749            } else {
1750                Err(format!("result_count_lte: got {} results (max={})", n, max))
1751            }
1752        }
1753
1754        "cluster_winner_kind_not" => eval_cluster_winner_kind_not(map, output),
1755        "cluster_winner_qname_contains" => eval_cluster_winner_qname_contains(map, output),
1756
1757        // no_duplicate_summaries: no two suggested_entries share the same summary text.
1758        "no_duplicate_summaries" => {
1759            let entries = output
1760                .get("suggested_entries")
1761                .and_then(|v| v.as_array())
1762                .unwrap_or(&empty_arr);
1763            let mut seen_global: std::collections::HashMap<String, usize> =
1764                std::collections::HashMap::new();
1765            for e in entries {
1766                if let Some(s) = e.get("summary").and_then(|v| v.as_str()) {
1767                    *seen_global.entry(s.to_string()).or_insert(0) += 1;
1768                }
1769            }
1770            let dups: Vec<&String> = seen_global
1771                .iter()
1772                .filter(|(_, count)| **count > 1)
1773                .map(|(s, _)| s)
1774                .collect();
1775            if !dups.is_empty() {
1776                return Err(format!(
1777                    "no_duplicate_summaries: duplicate summary text: {:?}",
1778                    dups.iter().take(3).map(|s| s.as_str()).collect::<Vec<_>>()
1779                ));
1780            }
1781            Ok(())
1782        }
1783
1784        // boosted_outranked_contains: boosted_outranked has at least one entry containing `fragment`.
1785        // Use this to verify a known-good SOT symbol that slipped below top-5 is reported.
1786        "boosted_outranked_contains" => {
1787            let fragment = str_field(map, "fragment")?;
1788            let outranked = output
1789                .get("boosted_outranked")
1790                .and_then(|v| v.as_array())
1791                .unwrap_or(&empty_arr);
1792            let found = outranked.iter().any(|s| {
1793                s.as_str().map_or(false, |q| {
1794                    q.to_lowercase().contains(&fragment.to_lowercase())
1795                })
1796            });
1797            if found {
1798                Ok(())
1799            } else {
1800                Err(format!(
1801                    "boosted_outranked_contains: {:?} not in boosted_outranked; got {:?}",
1802                    fragment,
1803                    outranked
1804                        .iter()
1805                        .filter_map(|s| s.as_str())
1806                        .collect::<Vec<_>>()
1807                ))
1808            }
1809        }
1810
1811        // qname_not_in_results: no result has a qname containing `fragment`.
1812        // Use this to prove a feedback-suppressed symbol is absent from results.
1813        "qname_not_in_results" => {
1814            let fragment = str_field(map, "fragment")?;
1815            let results = output
1816                .get("results")
1817                .and_then(|v| v.as_array())
1818                .unwrap_or(&empty_arr);
1819            let hit = results.iter().find(|r| {
1820                r.get("qname").and_then(|v| v.as_str()).map_or(false, |q| {
1821                    q.to_lowercase().contains(&fragment.to_lowercase())
1822                })
1823            });
1824            match hit {
1825                Some(r) => {
1826                    let qname = r.get("qname").and_then(|v| v.as_str()).unwrap_or("?");
1827                    Err(format!(
1828                        "qname_not_in_results: {:?} is present in results (expected suppressed)",
1829                        qname
1830                    ))
1831                }
1832                None => Ok(()),
1833            }
1834        }
1835
1836        // ambiguous_terms_nonempty: the query has at least one ambiguous term flagged.
1837        // Use this to verify broad/generic queries signal uncertainty.
1838        "ambiguous_terms_nonempty" => {
1839            let terms = output
1840                .get("ambiguous_terms")
1841                .and_then(|v| v.as_array())
1842                .unwrap_or(&empty_arr);
1843            if terms.is_empty() {
1844                Err("ambiguous_terms_nonempty: ambiguous_terms is empty — query may be too specific or detection not firing".to_string())
1845            } else {
1846                Ok(())
1847            }
1848        }
1849
1850        // scoped_suggestions_nonempty: scoped_suggestions has at least one entry.
1851        // Use this to verify broad queries emit narrowing hints.
1852        "scoped_suggestions_nonempty" => {
1853            let suggestions = output
1854                .get("scoped_suggestions")
1855                .and_then(|v| v.as_array())
1856                .unwrap_or(&empty_arr);
1857            if suggestions.is_empty() {
1858                Err("scoped_suggestions_nonempty: scoped_suggestions is empty — no narrowing hints emitted".to_string())
1859            } else {
1860                Ok(())
1861            }
1862        }
1863
1864        // scoped_suggestions_contains: at least one scoped suggestion contains `fragment`.
1865        "scoped_suggestions_contains" => {
1866            let fragment = str_field(map, "fragment")?;
1867            let suggestions = output
1868                .get("scoped_suggestions")
1869                .and_then(|v| v.as_array())
1870                .unwrap_or(&empty_arr);
1871            let found = suggestions.iter().any(|s| {
1872                s.as_str().map_or(false, |t| {
1873                    t.to_lowercase().contains(&fragment.to_lowercase())
1874                })
1875            });
1876            if found {
1877                Ok(())
1878            } else {
1879                Err(format!(
1880                    "scoped_suggestions_contains: no suggestion contains {:?}; got {:?}",
1881                    fragment,
1882                    suggestions
1883                        .iter()
1884                        .filter_map(|s| s.as_str())
1885                        .collect::<Vec<_>>()
1886                ))
1887            }
1888        }
1889
1890        // uncertainty_level_lte: uncertainty.level is at most `max_level`.
1891        // Levels ordered: low < medium < high < critical.
1892        // Use this to assert exact queries don't produce unexpected uncertainty.
1893        // Example: { kind = "uncertainty_level_lte", max_level = "low" }
1894        "uncertainty_level_lte" => {
1895            let max_level = str_field(map, "max_level")?;
1896            let level_rank = |l: &str| match l {
1897                "low" => 0u8,
1898                "medium" => 1,
1899                "high" => 2,
1900                "critical" => 3,
1901                _ => 4,
1902            };
1903            let actual_level = output
1904                .get("uncertainty")
1905                .and_then(|u| u.get("level"))
1906                .and_then(Value::as_str)
1907                .unwrap_or("low");
1908            if level_rank(actual_level) <= level_rank(max_level) {
1909                Ok(())
1910            } else {
1911                Err(format!(
1912                    "uncertainty_level_lte: uncertainty.level = {:?} (expected <= {:?})",
1913                    actual_level, max_level
1914                ))
1915            }
1916        }
1917
1918        // uncertainty_reason_contains: uncertainty.reasons[*].code contains `code`.
1919        // Use this to verify a specific uncertainty signal fires for ambiguous queries.
1920        // Example: { kind = "uncertainty_reason_contains", code = "ambiguous_term" }
1921        "uncertainty_reason_contains" => {
1922            let code = str_field(map, "code")?;
1923            let reasons = output
1924                .get("uncertainty")
1925                .and_then(|u| u.get("reasons"))
1926                .and_then(Value::as_array)
1927                .unwrap_or(&empty_arr);
1928            let found = reasons.iter().any(|r| {
1929                r.get("code")
1930                    .and_then(Value::as_str)
1931                    .map_or(false, |c| c == code)
1932            });
1933            if found {
1934                Ok(())
1935            } else {
1936                let codes: Vec<&str> = reasons
1937                    .iter()
1938                    .filter_map(|r| r.get("code").and_then(Value::as_str))
1939                    .collect();
1940                Err(format!(
1941                    "uncertainty_reason_contains: reason code {:?} not found; got {:?}",
1942                    code, codes
1943                ))
1944            }
1945        }
1946
1947        // uncertainty_action_eq: uncertainty.recommended_action equals `action`.
1948        // Use this to verify the correct recovery suggestion fires.
1949        // Example: { kind = "uncertainty_action_eq", action = "narrow_query" }
1950        "uncertainty_action_eq" => {
1951            let action = str_field(map, "action")?;
1952            let actual = output
1953                .get("uncertainty")
1954                .and_then(|u| u.get("recommended_action"))
1955                .and_then(Value::as_str)
1956                .unwrap_or("");
1957            if actual == action {
1958                Ok(())
1959            } else {
1960                Err(format!(
1961                    "uncertainty_action_eq: recommended_action = {:?} (expected {:?})",
1962                    actual, action
1963                ))
1964            }
1965        }
1966
1967        // recovery_suggestions_nonempty: uncertainty.recovery_suggestions is non-empty.
1968        // Use this to verify broad queries emit structured recovery hints.
1969        "recovery_suggestions_nonempty" => {
1970            let suggestions = output
1971                .get("uncertainty")
1972                .and_then(|u| u.get("recovery_suggestions"))
1973                .and_then(Value::as_array)
1974                .unwrap_or(&empty_arr);
1975            if !suggestions.is_empty() {
1976                Ok(())
1977            } else {
1978                Err(
1979                    "recovery_suggestions_nonempty: uncertainty.recovery_suggestions is empty"
1980                        .to_string(),
1981                )
1982            }
1983        }
1984
1985        // recovery_suggestion_estimated: at least one recovery suggestion has `estimated_recovery = strength`.
1986        // Example: { kind = "recovery_suggestion_estimated", strength = "strong" }
1987        "recovery_suggestion_estimated" => {
1988            let strength = str_field(map, "strength")?;
1989            let suggestions = output
1990                .get("uncertainty")
1991                .and_then(|u| u.get("recovery_suggestions"))
1992                .and_then(Value::as_array)
1993                .unwrap_or(&empty_arr);
1994            let found = suggestions.iter().any(|s| {
1995                s.get("estimated_recovery")
1996                    .and_then(Value::as_str)
1997                    .map_or(false, |e| e == strength)
1998            });
1999            if found {
2000                Ok(())
2001            } else {
2002                let found_strengths: Vec<&str> = suggestions
2003                    .iter()
2004                    .filter_map(|s| s.get("estimated_recovery").and_then(Value::as_str))
2005                    .collect();
2006                Err(format!(
2007                    "recovery_suggestion_estimated: no suggestion with estimated_recovery {:?}; got {:?}",
2008                    strength, found_strengths
2009                ))
2010            }
2011        }
2012
2013        // feedback_summary_gte: feedback_summary[field] >= min_value.
2014        // Use this to prove feedback is actively suppressing or boosting results.
2015        "feedback_summary_gte" => {
2016            let field = str_field(map, "field")?;
2017            let min_value = u64_field(map, "min_value")?;
2018            let actual = output
2019                .get("feedback_summary")
2020                .and_then(|s| s.get(field))
2021                .and_then(Value::as_u64)
2022                .unwrap_or(0);
2023            if actual >= min_value {
2024                Ok(())
2025            } else {
2026                Err(format!(
2027                    "feedback_summary_gte: feedback_summary.{} = {} (expected >= {})",
2028                    field, actual, min_value
2029                ))
2030            }
2031        }
2032
2033        // feedback_summary_eq: feedback_summary[field] == value.
2034        "feedback_summary_eq" => {
2035            let field = str_field(map, "field")?;
2036            let expected = u64_field(map, "value")?;
2037            let actual = output
2038                .get("feedback_summary")
2039                .and_then(|s| s.get(field))
2040                .and_then(Value::as_u64)
2041                .unwrap_or(0);
2042            if actual == expected {
2043                Ok(())
2044            } else {
2045                Err(format!(
2046                    "feedback_summary_eq: feedback_summary.{} = {} (expected {})",
2047                    field, actual, expected
2048                ))
2049            }
2050        }
2051
2052        // feedback_rules_contains: feedback_summary.rules_applied contains `rule`.
2053        "feedback_rules_contains" => {
2054            let rule = str_field(map, "rule")?;
2055            let rules = output
2056                .get("feedback_summary")
2057                .and_then(|s| s.get("rules_applied"))
2058                .and_then(Value::as_array)
2059                .cloned()
2060                .unwrap_or_default();
2061            let found = rules
2062                .iter()
2063                .any(|r| r.as_str().map_or(false, |s| s == rule));
2064            if found {
2065                Ok(())
2066            } else {
2067                Err(format!(
2068                    "feedback_rules_contains: rule {:?} not in rules_applied {:?}",
2069                    rule,
2070                    rules.iter().filter_map(|r| r.as_str()).collect::<Vec<_>>()
2071                ))
2072            }
2073        }
2074
2075        // field_gte: output[dotted.path] >= min_value (numeric).
2076        // Required: field (dot-path string), min_value (integer or float).
2077        // Example: { kind = "field_gte", field = "score", min_value = 0.5 }
2078        //          { kind = "field_gte", field = "signals.symbol_count", min_value = 100 }
2079        "field_gte" => {
2080            let field = str_field(map, "field")?;
2081            let min_val: f64 = map
2082                .get("min_value")
2083                .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
2084                .ok_or_else(|| format!("assertion missing required numeric field \"min_value\""))?;
2085            let actual = dot_path(output, field)
2086                .and_then(|v| v.as_f64())
2087                .unwrap_or(0.0);
2088            if actual >= min_val {
2089                Ok(())
2090            } else {
2091                Err(format!(
2092                    "field_gte: {field} = {actual} (expected >= {min_val})"
2093                ))
2094            }
2095        }
2096
2097        // Plan B t-008: field_lte — output[dotted.path] <= max_value (numeric).
2098        // Required: field (dot-path string), max_value (integer or float).
2099        // Example: { kind = "field_lte", field = "total_bytes", max_value = 500000 }
2100        "field_lte" => {
2101            let field = str_field(map, "field")?;
2102            let max_val: f64 = map
2103                .get("max_value")
2104                .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
2105                .ok_or_else(|| format!("assertion missing required numeric field \"max_value\""))?;
2106            let actual = dot_path(output, field)
2107                .and_then(|v| v.as_f64())
2108                .unwrap_or(0.0);
2109            if actual <= max_val {
2110                Ok(())
2111            } else {
2112                Err(format!(
2113                    "field_lte: {field} = {actual} (expected <= {max_val})"
2114                ))
2115            }
2116        }
2117
2118        // array_field_count_lte: length of a dot-path array field <= max_count.
2119        // Required: field (dot-path string), max_count (integer).
2120        // Example: { kind = "array_field_count_lte", field = "safe_change_recipe.edit", max_count = 8 }
2121        "array_field_count_lte" => {
2122            let field = str_field(map, "field")?;
2123            let max_count = u64_field(map, "max_count")?;
2124            let val = dot_path(output, field);
2125            let len = val.and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0) as u64;
2126            if len <= max_count {
2127                Ok(())
2128            } else {
2129                Err(format!(
2130                    "array_field_count_lte: {field} has {len} elements (max {})",
2131                    max_count
2132                ))
2133            }
2134        }
2135
2136        // array_field_count_gte: length of a dot-path array field >= min_count.
2137        // Required: field (dot-path string), min_count (integer).
2138        // Example: { kind = "array_field_count_gte", field = "entry_points", min_count = 3 }
2139        "array_field_count_gte" => {
2140            let field = str_field(map, "field")?;
2141            let min_count = u64_field(map, "min_count")?;
2142            let val = dot_path(output, field);
2143            let len = val.and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0) as u64;
2144            if len >= min_count {
2145                Ok(())
2146            } else {
2147                Err(format!(
2148                    "array_field_count_gte: {field} has {len} elements (min {})",
2149                    min_count
2150                ))
2151            }
2152        }
2153
2154        // field_eq: output[dot.path] equals `expected` (string comparison).
2155        // Required: field (dot-path string), expected (string).
2156        // Example: { kind = "field_eq", field = "workflow.workflow_type", expected = "full" }
2157        "field_eq" => {
2158            let field = str_field(map, "field")?;
2159            let expected = str_field(map, "expected")?;
2160            let actual = dot_path(output, field)
2161                .and_then(|v| v.as_str())
2162                .unwrap_or("");
2163            if actual == expected {
2164                Ok(())
2165            } else {
2166                Err(format!(
2167                    "field_eq: {field} = {actual:?} (expected {:?})",
2168                    expected
2169                ))
2170            }
2171        }
2172
2173        // workflow_steps_contains: workflow.steps_detected array contains `step`.
2174        // Required: step (string).
2175        // Example: { kind = "workflow_steps_contains", step = "task_closed" }
2176        "workflow_steps_contains" => {
2177            let step = str_field(map, "step")?;
2178            let steps = dot_path(output, "workflow.steps_detected")
2179                .and_then(|v| v.as_array())
2180                .cloned()
2181                .unwrap_or_default();
2182            let found = steps.iter().any(|s| s.as_str() == Some(step));
2183            if found {
2184                Ok(())
2185            } else {
2186                Err(format!(
2187                    "workflow_steps_contains: step {:?} not found in {:?}",
2188                    step,
2189                    steps.iter().filter_map(|s| s.as_str()).collect::<Vec<_>>()
2190                ))
2191            }
2192        }
2193
2194        // evidence_score_gte: workflow.evidence_quality.evidence_quality_score >= min_value.
2195        // Required: min_value (float).
2196        // Example: { kind = "evidence_score_gte", min_value = 0.5 }
2197        "evidence_score_gte" => {
2198            let min_val: f64 = map
2199                .get("min_value")
2200                .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
2201                .ok_or_else(|| {
2202                    "assertion missing required numeric field \"min_value\"".to_string()
2203                })?;
2204            let actual = dot_path(output, "workflow.evidence_quality.evidence_quality_score")
2205                .and_then(|v| v.as_f64())
2206                .unwrap_or(0.0);
2207            if actual >= min_val {
2208                Ok(())
2209            } else {
2210                Err(format!(
2211                    "evidence_score_gte: evidence_quality_score = {actual:.2} (expected >= {min_val})"
2212                ))
2213            }
2214        }
2215
2216        // data_quality_state_eq: trust.data_quality.state equals expected string.
2217        // Required: expected (string: "clean_room" | "sparse_but_active" | "populated" | "degraded" | "empty").
2218        // Example: { kind = "data_quality_state_eq", expected = "populated" }
2219        "data_quality_state_eq" => {
2220            let expected = str_field(map, "expected")?;
2221            let actual = dot_path(output, "data_quality.state")
2222                .and_then(|v| v.as_str())
2223                .unwrap_or("");
2224            if actual == expected {
2225                Ok(())
2226            } else {
2227                Err(format!(
2228                    "data_quality_state_eq: data_quality.state = {actual:?} (expected {:?})",
2229                    expected
2230                ))
2231            }
2232        }
2233
2234        // feedback_state_eq: feedback_state[field] == value (bool).
2235        // Required: field (string), value (bool).
2236        // Example: { kind = "feedback_state_eq", field = "available", value = false }
2237        "feedback_state_eq" => {
2238            let field = str_field(map, "field")?;
2239            let expected = map
2240                .get("value")
2241                .and_then(|v| v.as_bool())
2242                .ok_or_else(|| "assertion missing required bool field \"value\"".to_string())?;
2243            let actual = output
2244                .get("feedback_state")
2245                .and_then(|s| s.get(field))
2246                .and_then(Value::as_bool);
2247            match actual {
2248                Some(v) if v == expected => Ok(()),
2249                Some(v) => Err(format!(
2250                    "feedback_state_eq: feedback_state.{} = {} (expected {})",
2251                    field, v, expected
2252                )),
2253                None => Err(format!(
2254                    "feedback_state_eq: feedback_state.{} not found in output",
2255                    field
2256                )),
2257            }
2258        }
2259
2260        // feedback_state_field_eq: feedback_state[field] == value (string).
2261        // Required: field (string), value (string).
2262        // Example: { kind = "feedback_state_field_eq", field = "reason", value = "no_feedback_entries" }
2263        "feedback_state_field_eq" => {
2264            let field = str_field(map, "field")?;
2265            let expected = str_field(map, "value")?;
2266            let actual = output
2267                .get("feedback_state")
2268                .and_then(|s| s.get(field))
2269                .and_then(Value::as_str)
2270                .unwrap_or("");
2271            if actual == expected {
2272                Ok(())
2273            } else {
2274                Err(format!(
2275                    "feedback_state_field_eq: feedback_state.{} = {:?} (expected {:?})",
2276                    field, actual, expected
2277                ))
2278            }
2279        }
2280
2281        // feedback_coverage_eq: feedback_summary.coverage == value.
2282        // Required: value (string: "none" | "partial" | "applied").
2283        // Example: { kind = "feedback_coverage_eq", value = "none" }
2284        "feedback_coverage_eq" => {
2285            let expected = str_field(map, "value")?;
2286            let actual = output
2287                .get("feedback_summary")
2288                .and_then(|s| s.get("coverage"))
2289                .and_then(Value::as_str)
2290                .unwrap_or("none");
2291            if actual == expected {
2292                Ok(())
2293            } else {
2294                Err(format!(
2295                    "feedback_coverage_eq: feedback_summary.coverage = {:?} (expected {:?})",
2296                    actual, expected
2297                ))
2298            }
2299        }
2300
2301        // uncertainty_exact_symbol_match: uncertainty.exact_symbol_match == expected (bool).
2302        // Required: expected (bool).
2303        // Example: { kind = "uncertainty_exact_symbol_match", expected = true }
2304        "uncertainty_exact_symbol_match" => {
2305            let expected = map
2306                .get("expected")
2307                .and_then(|v| v.as_bool())
2308                .ok_or_else(|| "assertion missing required bool field \"expected\"".to_string())?;
2309            let actual = output
2310                .get("uncertainty")
2311                .and_then(|u| u.get("exact_symbol_match"))
2312                .and_then(Value::as_bool)
2313                .unwrap_or(false);
2314            if actual == expected {
2315                Ok(())
2316            } else {
2317                Err(format!(
2318                    "uncertainty_exact_symbol_match: exact_symbol_match = {} (expected {})",
2319                    actual, expected
2320                ))
2321            }
2322        }
2323
2324        // uncertainty_primary_source_eq: uncertainty.sources.primary == expected.
2325        // Required: expected (string: "query" | "db_state" | "result_set" | "none").
2326        // Example: { kind = "uncertainty_primary_source_eq", expected = "query" }
2327        "uncertainty_primary_source_eq" => {
2328            let expected = str_field(map, "expected")?;
2329            let actual = output
2330                .get("uncertainty")
2331                .and_then(|u| u.get("sources"))
2332                .and_then(|s| s.get("primary"))
2333                .and_then(Value::as_str)
2334                .unwrap_or("none");
2335            if actual == expected {
2336                Ok(())
2337            } else {
2338                Err(format!(
2339                    "uncertainty_primary_source_eq: sources.primary = {:?} (expected {:?})",
2340                    actual, expected
2341                ))
2342            }
2343        }
2344
2345        // uncertainty_source_gte: uncertainty.sources[source] >= min_value.
2346        // Required: source (string: "query" | "db_state" | "result_set"), min_value (float).
2347        // Example: { kind = "uncertainty_source_gte", source = "query", min_value = 0.2 }
2348        "uncertainty_source_gte" => {
2349            let source = str_field(map, "source")?;
2350            let min_val: f64 = map
2351                .get("min_value")
2352                .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
2353                .ok_or_else(|| {
2354                    "assertion missing required numeric field \"min_value\"".to_string()
2355                })?;
2356            let actual = output
2357                .get("uncertainty")
2358                .and_then(|u| u.get("sources"))
2359                .and_then(|s| s.get(source))
2360                .and_then(Value::as_f64)
2361                .unwrap_or(0.0);
2362            if actual >= min_val {
2363                Ok(())
2364            } else {
2365                Err(format!(
2366                    "uncertainty_source_gte: sources.{} = {:.2} (expected >= {})",
2367                    source, actual, min_val
2368                ))
2369            }
2370        }
2371
2372        // array_field_contains: dot-path array field contains `value` string.
2373        // Required: field (dot-path string), value (string).
2374        // Example: { kind = "array_field_contains", field = "safe_to_use_for", value = "search" }
2375        "array_field_contains" => {
2376            let field = str_field(map, "field")?;
2377            let value = str_field(map, "value")?;
2378            let arr = dot_path(output, field)
2379                .and_then(|v| v.as_array())
2380                .cloned()
2381                .unwrap_or_default();
2382            let found = arr.iter().any(|v| v.as_str().map_or(false, |s| s == value));
2383            if found {
2384                Ok(())
2385            } else {
2386                Err(format!(
2387                    "array_field_contains: {:?} not found in {} {:?}",
2388                    value,
2389                    field,
2390                    arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>()
2391                ))
2392            }
2393        }
2394
2395        // array_field_excludes: dot-path array field does NOT contain `value` string.
2396        // Required: field (dot-path string), value (string).
2397        // Example: { kind = "array_field_excludes", field = "avoid_for", value = "search" }
2398        "array_field_excludes" => {
2399            let field = str_field(map, "field")?;
2400            let value = str_field(map, "value")?;
2401            let arr = dot_path(output, field)
2402                .and_then(|v| v.as_array())
2403                .cloned()
2404                .unwrap_or_default();
2405            let found = arr.iter().any(|v| v.as_str().map_or(false, |s| s == value));
2406            if !found {
2407                Ok(())
2408            } else {
2409                Err(format!(
2410                    "array_field_excludes: {:?} unexpectedly found in {} {:?}",
2411                    value,
2412                    field,
2413                    arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>()
2414                ))
2415            }
2416        }
2417
2418        // all_items_have_field: every object in a dot-path array has `field` non-null/non-empty.
2419        // Required: array (dot-path to array), field (field name within each item).
2420        // Example: { kind = "all_items_have_field", array = "likely_edit_files", field = "rationale" }
2421        "all_items_have_field" => eval_all_items_have_field(map, output),
2422
2423        // file_field_contains: in a dot-path array, find item whose "file" contains file_fragment,
2424        // then check that item's `field` contains the substring `value_contains`.
2425        // Required: array (dot-path), file_fragment (string), field (string), value_contains (string).
2426        // Example: { kind = "file_field_contains", array = "safe_change_recipe.reference_only",
2427        //            file_fragment = "WaveformCanvas", field = "rationale", value_contains = "surface" }
2428        "file_field_contains" => eval_file_field_contains(map, output),
2429
2430        // json_field_present: dot-path field must exist and be non-null.
2431        // Required: field (dot-path string).
2432        // Example: { kind = "json_field_present", field = "total" }
2433        "json_field_present" => {
2434            let field = str_field(map, "field")?;
2435            let present = dot_path(output, field).map_or(false, |v| !v.is_null());
2436            if present {
2437                Ok(())
2438            } else {
2439                Err(format!(
2440                    "json_field_present: field {:?} is absent or null",
2441                    field
2442                ))
2443            }
2444        }
2445
2446        // json_nested_eq: dot-path field must equal `value` (integer or string).
2447        // Required: path (dot-path string), value (integer or string).
2448        // Example: { kind = "json_nested_eq", path = "by_verdict.useful", value = 1 }
2449        "json_nested_eq" => {
2450            let path = str_field(map, "path")?;
2451            let actual = dot_path(output, path);
2452            // Try integer match first, then string.
2453            if let Some(expected_int) = map.get("value").and_then(|v| v.as_integer()) {
2454                let actual_int = actual.and_then(Value::as_i64);
2455                match actual_int {
2456                    Some(a) if a == expected_int => Ok(()),
2457                    Some(a) => Err(format!(
2458                        "json_nested_eq: {}.{} = {} (expected {})",
2459                        path, "", a, expected_int
2460                    )),
2461                    None => Err(format!(
2462                        "json_nested_eq: {}: field absent or non-numeric (expected {})",
2463                        path, expected_int
2464                    )),
2465                }
2466            } else if let Some(expected_str) = map.get("value").and_then(|v| v.as_str()) {
2467                let actual_str = actual.and_then(Value::as_str).unwrap_or("");
2468                if actual_str == expected_str {
2469                    Ok(())
2470                } else {
2471                    Err(format!(
2472                        "json_nested_eq: {} = {:?} (expected {:?})",
2473                        path, actual_str, expected_str
2474                    ))
2475                }
2476            } else {
2477                Err("json_nested_eq: assertion missing required field \"value\" (integer or string)".to_string())
2478            }
2479        }
2480
2481        "" => Ok(()), // no kind → smoke test, always passes
2482        other => Err(format!("unknown assertion kind: {:?}", other)),
2483    }
2484}
2485
2486/// Navigate a dot-separated path into a JSON value.
2487/// e.g. dot_path(json, "safe_change_recipe.edit") → Some(&json["safe_change_recipe"]["edit"])
2488fn dot_path<'a>(mut val: &'a Value, path: &str) -> Option<&'a Value> {
2489    for key in path.split('.') {
2490        val = val.get(key)?;
2491    }
2492    Some(val)
2493}
2494
2495fn str_field<'a>(
2496    map: &'a toml::map::Map<String, toml::Value>,
2497    key: &str,
2498) -> Result<&'a str, String> {
2499    map.get(key)
2500        .and_then(|v| v.as_str())
2501        .ok_or_else(|| format!("assertion missing required string field {:?}", key))
2502}
2503
2504fn u64_field(map: &toml::map::Map<String, toml::Value>, key: &str) -> Result<u64, String> {
2505    map.get(key)
2506        .and_then(|v| v.as_integer())
2507        .map(|i| i as u64)
2508        .ok_or_else(|| format!("assertion missing required integer field {:?}", key))
2509}
2510
2511fn summarize_debug_payload(json: &Value) -> Option<String> {
2512    // Extract the most relevant debug field for the failure summary line.
2513    if let Some(arr) = json.get("classification_debug").and_then(|v| v.as_array()) {
2514        let rules: Vec<&str> = arr
2515            .iter()
2516            .filter_map(|e| e.get("rule_that_won").and_then(|v| v.as_str()))
2517            .collect();
2518        if !rules.is_empty() {
2519            return Some(format!("classification rules: {:?}", rules));
2520        }
2521    }
2522    if let Some(arr) = json.get("results").and_then(|v| v.as_array()) {
2523        let top3: Vec<&str> = arr
2524            .iter()
2525            .take(3)
2526            .filter_map(|r| r.get("qname").and_then(|v| v.as_str()))
2527            .collect();
2528        if !top3.is_empty() {
2529            return Some(format!("top results: {:?}", top3));
2530        }
2531    }
2532    if let Some(arr) = json.get("cluster_debug").and_then(|v| v.as_array()) {
2533        let winners: Vec<&str> = arr
2534            .iter()
2535            .filter_map(|e| {
2536                e.get("winner_selected")
2537                    .and_then(|w| w.get("qname"))
2538                    .and_then(|v| v.as_str())
2539            })
2540            .collect();
2541        if !winners.is_empty() {
2542            return Some(format!("cluster winners: {:?}", winners));
2543        }
2544    }
2545    None
2546}
2547
2548// ---------------------------------------------------------------------------
2549// probe reindex
2550// ---------------------------------------------------------------------------
2551
2552fn reindex_analytics(cfg: &Config, args: ProbeReindexArgs) -> Result<()> {
2553    let jsonl_path = history_path(cfg);
2554    if !jsonl_path.exists() {
2555        println!("No probe history found at {}", jsonl_path.display());
2556        return Ok(());
2557    }
2558
2559    let db_path = analytics_path(cfg);
2560
2561    // Drop the existing DB if --force or if it doesn't exist yet.
2562    if db_path.exists() {
2563        if args.force {
2564            std::fs::remove_file(&db_path)
2565                .with_context(|| format!("removing {}", db_path.display()))?;
2566            eprintln!("Dropped existing {}", db_path.display());
2567        }
2568        // Without --force we do incremental (INSERT OR IGNORE), which is fine.
2569    }
2570
2571    let conn =
2572        open_analytics_db(&db_path).with_context(|| format!("opening {}", db_path.display()))?;
2573
2574    let raw = std::fs::read_to_string(&jsonl_path)
2575        .with_context(|| format!("reading {}", jsonl_path.display()))?;
2576
2577    let mut runs = 0usize;
2578    let mut probes = 0usize;
2579    let mut skipped = 0usize;
2580
2581    for line in raw.lines().filter(|l| !l.trim().is_empty()) {
2582        match serde_json::from_str::<Value>(line) {
2583            Ok(record) => {
2584                // Check if already present before insert so we can count skips.
2585                let run_id = record
2586                    .get("started_at")
2587                    .and_then(Value::as_str)
2588                    .unwrap_or("");
2589                let already: bool = conn
2590                    .query_row(
2591                        "SELECT 1 FROM probe_runs WHERE run_id=?1",
2592                        params![run_id],
2593                        |_| Ok(true),
2594                    )
2595                    .unwrap_or(false);
2596                if already {
2597                    skipped += 1;
2598                    continue;
2599                }
2600                let probe_count = record
2601                    .get("probes")
2602                    .and_then(Value::as_array)
2603                    .map(|a| a.len())
2604                    .unwrap_or(0);
2605                insert_run_to_analytics(&conn, &record);
2606                runs += 1;
2607                probes += probe_count;
2608            }
2609            Err(_) => {} // malformed line — skip silently
2610        }
2611    }
2612
2613    println!(
2614        "Indexed {} run(s) ({} probe rows) into {}  [{} already present, skipped]",
2615        runs,
2616        probes,
2617        db_path.display(),
2618        skipped
2619    );
2620    Ok(())
2621}
2622
2623// ---------------------------------------------------------------------------
2624// probe history
2625// ---------------------------------------------------------------------------
2626
2627fn show_history(cfg: &Config, args: ProbeHistoryArgs) -> Result<()> {
2628    let path = history_path(cfg);
2629    if !path.exists() {
2630        println!("No probe history yet. Run `asd probe run` to record the first entry.");
2631        return Ok(());
2632    }
2633
2634    let raw =
2635        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2636
2637    // Parse all non-empty lines as JSON records.
2638    let mut records: Vec<Value> = raw
2639        .lines()
2640        .filter(|l| !l.trim().is_empty())
2641        .filter_map(|l| serde_json::from_str(l).ok())
2642        .collect();
2643
2644    // Apply --tag filter (matches filter_tag field recorded in history).
2645    if let Some(ref tag) = args.tag {
2646        records.retain(|r| {
2647            r.get("filter_tag")
2648                .and_then(Value::as_str)
2649                .map_or(false, |t| t == tag)
2650        });
2651    }
2652
2653    // Most recent last in file — show last N in reverse-chronological order.
2654    let total = records.len();
2655    let start = total.saturating_sub(args.last);
2656    let mut window: Vec<&Value> = records[start..].iter().rev().collect();
2657
2658    if window.is_empty() {
2659        println!("No matching history records.");
2660        return Ok(());
2661    }
2662
2663    if args.json {
2664        // Emit each record as a JSON line.
2665        for r in &window {
2666            println!("{}", serde_json::to_string(r)?);
2667        }
2668    } else {
2669        // Summary table: version | scope | total | pass | wall_ms | budget | slowest
2670        // scope distinguishes full runs from filtered subsets so "fewer probes" is obvious.
2671        println!(
2672            "{:<10}  {:<16}  {:>5}  {:>5}  {:>10}  {:<8}  {}",
2673            "version", "scope", "total", "pass", "wall_ms", "budget", "slowest"
2674        );
2675        println!("{}", "-".repeat(90));
2676        for r in &mut window {
2677            let version = r.get("asd_version").and_then(Value::as_str).unwrap_or("?");
2678            let scope = match (
2679                r.get("filter_name").and_then(Value::as_str),
2680                r.get("filter_tag").and_then(Value::as_str),
2681            ) {
2682                (Some(n), _) => format!("name:{}", n),
2683                (_, Some(t)) => format!("tag:{}", t),
2684                _ => "all".to_string(),
2685            };
2686            let total_n = r.get("total").and_then(Value::as_u64).unwrap_or(0);
2687            let passed_n = r.get("passed").and_then(Value::as_u64).unwrap_or(0);
2688            let wall = r.get("wall_time_ms").and_then(Value::as_u64).unwrap_or(0);
2689            let budget_ok = r
2690                .get("budget_failed")
2691                .and_then(Value::as_bool)
2692                .map_or("—", |b| if b { "FAIL" } else { "ok" });
2693            let slowest_name = r
2694                .get("slowest")
2695                .and_then(Value::as_array)
2696                .and_then(|a| a.first())
2697                .and_then(|s| s.get("name"))
2698                .and_then(Value::as_str)
2699                .unwrap_or("—");
2700            let slowest_ms = r
2701                .get("slowest")
2702                .and_then(Value::as_array)
2703                .and_then(|a| a.first())
2704                .and_then(|s| s.get("duration_ms"))
2705                .and_then(Value::as_u64)
2706                .map(|ms| format!("({}ms)", ms))
2707                .unwrap_or_default();
2708            println!(
2709                "{:<10}  {:<16}  {:>5}  {:>5}  {:>10}  {:<8}  {} {}",
2710                version, scope, total_n, passed_n, wall, budget_ok, slowest_name, slowest_ms
2711            );
2712        }
2713        println!("\n{} run(s) shown ({} total recorded)", window.len(), total);
2714    }
2715    Ok(())
2716}
2717
2718// ---------------------------------------------------------------------------
2719// probe bootstrap — generate a starter probes.toml from the current index
2720// ---------------------------------------------------------------------------
2721
2722/// Generate a starter `.asd/probes.toml` for the current workspace.
2723///
2724/// Discovers top symbols via FTS and emits:
2725/// - Structural smoke probes (trust, search result count, feedback state)
2726/// - Per-symbol ranking probes for the top-N symbols found
2727/// - Change-model classification guards for the most prominent domain terms
2728///
2729/// Safe to run on any indexed workspace. Does not modify the index or ledger.
2730fn bootstrap_probes(cfg: &Config, args: ProbeBootstrapArgs) -> Result<()> {
2731    let path = probe_file_path(cfg);
2732
2733    if path.exists() && !args.force {
2734        eprintln!(
2735            "probes.toml already exists at {}.\nUse `asd probe bootstrap --force` to overwrite.",
2736            path.display()
2737        );
2738        std::process::exit(1);
2739    }
2740
2741    // -----------------------------------------------------------------------
2742    // Discover top symbols from FTS
2743    // -----------------------------------------------------------------------
2744    let fts = SearchFtsDb::open(&cfg.db_path)
2745        .map_err(|e| anyhow::anyhow!("Cannot open FTS DB: {}", e))?;
2746
2747    if !fts.has_data() {
2748        eprintln!("FTS index is empty. Run `asd index` first, then re-run bootstrap.");
2749        std::process::exit(1);
2750    }
2751
2752    // Search with a very broad filter to get top symbols (exclude tests).
2753    let filters = FtsFilters {
2754        kind: None,
2755        language: None,
2756        include_tests: false,
2757        tests_only: false,
2758        exclude_terms: vec![],
2759        paths_filter: vec![],
2760        exclude_paths: vec![],
2761        exclude_languages: vec![],
2762    };
2763    // Try a few common structural terms to surface domain symbols.
2764    let try_terms = [
2765        "manager",
2766        "service",
2767        "view",
2768        "model",
2769        "controller",
2770        "handler",
2771        "client",
2772        "engine",
2773        "store",
2774        "state",
2775        "view",
2776        "scene",
2777        "player",
2778        "editor",
2779        "session",
2780    ];
2781    let mut all_hits: Vec<_> = Vec::new();
2782    for term in try_terms {
2783        let h = fts.search(term, &filters, args.top * 2).unwrap_or_default();
2784        for hit in h {
2785            if !all_hits
2786                .iter()
2787                .any(|x: &agentstatedeveloper_core::FtsHit| x.qname == hit.qname)
2788            {
2789                all_hits.push(hit);
2790            }
2791        }
2792        if all_hits.len() >= args.top * 3 {
2793            break;
2794        }
2795    }
2796
2797    // Pick symbols: prefer tier-0 (domain), fall back to tier-1, skip tier-2 (tests).
2798    let top_symbols: Vec<(String, String)> = all_hits
2799        .iter()
2800        .filter(|h| h.tier != 2u8) // skip test symbols (tier=2)
2801        .take(args.top)
2802        .filter_map(|h| {
2803            let qname = h.qname.clone();
2804            // Short name = last component after '.'
2805            let short = qname.rsplit('.').next().unwrap_or(&qname).to_string();
2806            if short.len() >= 3 {
2807                Some((qname, short))
2808            } else {
2809                None
2810            }
2811        })
2812        .collect();
2813
2814    // -----------------------------------------------------------------------
2815    // Get trust signals to tailor smoke tests
2816    // -----------------------------------------------------------------------
2817    let trust = compute_trust_score(&cfg.db_path);
2818    let db_state = trust.data_quality.state.as_str();
2819
2820    // -----------------------------------------------------------------------
2821    // Build the probes.toml content
2822    // -----------------------------------------------------------------------
2823    let mut out = String::new();
2824
2825    out.push_str(&format!(
2826        "# ASD golden benchmark probes — bootstrapped by `asd probe bootstrap`\n\
2827         # DB: {}\n\
2828         # Symbols indexed: {}\n\
2829         # Data quality: {} ({})\n\
2830         # Generated: {}\n\
2831         #\n\
2832         # Run: asd probe run\n\
2833         # Run subset: asd probe run --tag smoke\n\
2834         #             asd probe run --tag ranking\n\n",
2835        cfg.db_path.display(),
2836        trust.signals.symbol_count,
2837        db_state,
2838        trust.data_quality.reason,
2839        chrono::Utc::now().format("%Y-%m-%d"),
2840    ));
2841
2842    // --- Structural smoke probes -------------------------------------------
2843    out.push_str("# ---------------------------------------------------------------------------\n");
2844    out.push_str("# Structural smoke tests — always pass on a healthy indexed workspace\n");
2845    out.push_str(
2846        "# ---------------------------------------------------------------------------\n\n",
2847    );
2848
2849    out.push_str("[[probe]]\n");
2850    out.push_str("name = \"smoke-trust-score\"\n");
2851    out.push_str("description = \"Trust score must be >= 0.5 for a populated index\"\n");
2852    out.push_str("tags = [\"smoke\", \"trust\"]\n");
2853    out.push_str("command = \"trust\"\n");
2854    out.push_str("args = []\n");
2855    out.push_str("assert = { kind = \"field_gte\", field = \"score\", min_value = 0.5 }\n\n");
2856
2857    out.push_str("[[probe]]\n");
2858    out.push_str("name = \"smoke-symbol-count\"\n");
2859    out.push_str("description = \"Index must contain at least 10 symbols\"\n");
2860    out.push_str("tags = [\"smoke\", \"trust\"]\n");
2861    out.push_str("command = \"trust\"\n");
2862    out.push_str("args = []\n");
2863    out.push_str(
2864        "assert = { kind = \"field_gte\", field = \"signals.symbol_count\", min_value = 10 }\n\n",
2865    );
2866
2867    out.push_str("[[probe]]\n");
2868    out.push_str("name = \"smoke-search-returns-results\"\n");
2869    out.push_str("description = \"A broad search must return at least 1 result\"\n");
2870    out.push_str("tags = [\"smoke\", \"search\"]\n");
2871    out.push_str("command = \"search\"\n");
2872
2873    // Use the first discovered symbol's short name as the search term.
2874    let first_query = top_symbols
2875        .first()
2876        .map(|(_, s)| s.to_lowercase())
2877        .unwrap_or_else(|| "state".to_string());
2878    out.push_str(&format!("args = [\"{}\", \"--agent\"]\n", first_query));
2879    out.push_str(
2880        "assert = { kind = \"array_field_count_gte\", field = \"results\", min_count = 1 }\n\n",
2881    );
2882
2883    out.push_str("[[probe]]\n");
2884    out.push_str("name = \"smoke-feedback-state-field-present\"\n");
2885    out.push_str("description = \"search output must include feedback_state field\"\n");
2886    out.push_str("tags = [\"smoke\", \"feedback\"]\n");
2887    out.push_str("command = \"search\"\n");
2888    out.push_str(&format!("args = [\"{}\", \"--agent\"]\n", first_query));
2889    out.push_str("assert = { kind = \"feedback_summary_gte\", field = \"entries_applied\", min_value = 0 }\n\n");
2890
2891    // Data quality probe — adapt to current state.
2892    out.push_str("[[probe]]\n");
2893    out.push_str("name = \"smoke-data-quality-state\"\n");
2894    out.push_str(&format!(
2895        "description = \"Data quality must be '{}' for this workspace state\"\n",
2896        db_state
2897    ));
2898    out.push_str("tags = [\"smoke\", \"trust\", \"data-quality\"]\n");
2899    out.push_str("command = \"trust\"\n");
2900    out.push_str("args = []\n");
2901    out.push_str(&format!(
2902        "assert = {{ kind = \"data_quality_state_eq\", expected = \"{}\" }}\n\n",
2903        db_state
2904    ));
2905
2906    // --- Ranking probes for discovered symbols ----------------------------
2907    if !top_symbols.is_empty() {
2908        out.push_str(
2909            "# ---------------------------------------------------------------------------\n",
2910        );
2911        out.push_str("# Symbol ranking probes — discovered from current index\n");
2912        out.push_str("# Edit these to match your domain's key symbols.\n");
2913        out.push_str(
2914            "# ---------------------------------------------------------------------------\n\n",
2915        );
2916
2917        for (i, (qname, short)) in top_symbols.iter().enumerate() {
2918            let slug = short
2919                .to_lowercase()
2920                .replace(|c: char| !c.is_alphanumeric(), "-");
2921
2922            // Lenient (existing): rank ≤ 5. Surfaces "right symbol
2923            // is anywhere near the top" — useful for ranking-not-broken
2924            // sanity but can't distinguish rank-1 from rank-5.
2925            let probe_name = format!("rank-{}-top5", slug);
2926            let desc = format!("{} must appear in top 5 results for its own name", qname);
2927            out.push_str(&format!("[[probe]]\nname = {:?}\n", probe_name));
2928            out.push_str(&format!("description = {:?}\n", desc));
2929            out.push_str("tags = [\"ranking\"]\n");
2930            out.push_str("command = \"search\"\n");
2931            out.push_str(&format!("args = [{:?}, \"--agent\"]\n", short));
2932            out.push_str(&format!(
2933                "assert = {{ kind = \"qname_rank_lte\", fragment = {:?}, max_rank = 5 }}\n\n",
2934                qname
2935            ));
2936
2937            // Plan J t-019: precision (new): rank == 1. Surfaces
2938            // "right symbol is AT the top" — when both probes for
2939            // the same query land in the same uncertainty bucket,
2940            // their pass/fail split tells the calibration harvester
2941            // whether the bucket label is too strict (precision
2942            // fails → predictor was right to flag low confidence)
2943            // or genuinely miscalibrated (precision passes too →
2944            // bucket should be promoted).
2945            let prec_name = format!("rank-{}-eq1", slug);
2946            let prec_desc = format!(
2947                "{} must be the #1 result for its own name (precision-mode for calibration)",
2948                qname
2949            );
2950            out.push_str(&format!("[[probe]]\nname = {:?}\n", prec_name));
2951            out.push_str(&format!("description = {:?}\n", prec_desc));
2952            out.push_str("tags = [\"ranking\", \"precision\"]\n");
2953            out.push_str("command = \"search\"\n");
2954            out.push_str(&format!("args = [{:?}, \"--agent\"]\n", short));
2955            out.push_str(&format!(
2956                "assert = {{ kind = \"qname_rank_eq\", fragment = {:?}, exact_rank = 1 }}\n\n",
2957                qname
2958            ));
2959
2960            if i >= args.top.saturating_sub(1) {
2961                break;
2962            }
2963        }
2964    }
2965
2966    // --- Change-model classification smoke test ---------------------------
2967    if !top_symbols.is_empty() {
2968        let query_term = top_symbols
2969            .first()
2970            .map(|(_, s)| s.to_lowercase())
2971            .unwrap_or_default();
2972        if query_term.len() >= 3 {
2973            out.push_str(
2974                "# ---------------------------------------------------------------------------\n",
2975            );
2976            out.push_str("# Change-model smoke tests\n");
2977            out.push_str(
2978                "# ---------------------------------------------------------------------------\n\n",
2979            );
2980
2981            out.push_str("[[probe]]\n");
2982            out.push_str("name = \"change-model-returns-edit-files\"\n");
2983            out.push_str(
2984                "description = \"prepare-change must return at least 1 likely_edit_files entry\"\n",
2985            );
2986            out.push_str("tags = [\"change-model\", \"smoke\"]\n");
2987            out.push_str("command = \"prepare-change\"\n");
2988            out.push_str(&format!("args = [{:?}, \"--agent\"]\n", query_term));
2989            out.push_str("assert = { kind = \"array_field_count_gte\", field = \"likely_edit_files\", min_count = 1 }\n\n");
2990
2991            out.push_str("[[probe]]\n");
2992            out.push_str("name = \"change-model-edit-confidence-present\"\n");
2993            out.push_str(
2994                "description = \"classification_summary must include an edit_confidence field\"\n",
2995            );
2996            out.push_str("tags = [\"change-model\", \"smoke\"]\n");
2997            out.push_str("command = \"prepare-change\"\n");
2998            out.push_str(&format!("args = [{:?}, \"--agent\"]\n", query_term));
2999            out.push_str("assert = { kind = \"array_field_count_gte\", field = \"likely_edit_files\", min_count = 0 }\n\n");
3000        }
3001    }
3002
3003    // -----------------------------------------------------------------------
3004    // Write the file
3005    // -----------------------------------------------------------------------
3006    if let Some(parent) = path.parent() {
3007        std::fs::create_dir_all(parent)?;
3008    }
3009    std::fs::write(&path, &out)?;
3010
3011    println!("✓ Generated {} probes.toml", path.display());
3012    println!("  Symbols indexed : {}", trust.signals.symbol_count);
3013    println!(
3014        "  Data quality    : {} ({})",
3015        db_state, trust.data_quality.reason
3016    );
3017    println!(
3018        "  Ranking probes  : {} (for top {} symbols)",
3019        top_symbols.len(),
3020        args.top
3021    );
3022    println!("\nRun: asd probe run");
3023    println!("Tag subsets: asd probe run --tag smoke");
3024
3025    Ok(())
3026}
3027
3028// ---------------------------------------------------------------------------
3029// probe add
3030// ---------------------------------------------------------------------------
3031
3032fn add_probe(cfg: &Config, args: ProbeAddArgs) -> Result<()> {
3033    let path = probe_file_path(cfg);
3034
3035    // Ensure .asd dir exists.
3036    if let Some(parent) = path.parent() {
3037        std::fs::create_dir_all(parent)?;
3038    }
3039
3040    let mut content = if path.exists() {
3041        std::fs::read_to_string(&path)?
3042    } else {
3043        String::new()
3044    };
3045
3046    // Build the new [[probe]] block.
3047    let mut block = format!("\n[[probe]]\nname = {:?}\n", args.name);
3048    if let Some(ref desc) = args.description {
3049        block.push_str(&format!("description = {:?}\n", desc));
3050    }
3051    block.push_str(&format!("command = {:?}\n", args.command));
3052
3053    // Write args as TOML array.
3054    let toml_args: Vec<String> = args.args.iter().map(|a| format!("{:?}", a)).collect();
3055    block.push_str(&format!("args = [{}]\n", toml_args.join(", ")));
3056
3057    if let Some(ref assert_str) = args.assert {
3058        block.push_str(&format!("assert = {}\n", assert_str));
3059    }
3060
3061    content.push_str(&block);
3062    std::fs::write(&path, &content)?;
3063    println!("Added probe {:?} to {}", args.name, path.display());
3064    Ok(())
3065}
3066
3067// ---------------------------------------------------------------------------
3068// Plan M t-005 (1.0.100): extracted assertion evaluators.
3069//
3070// `eval_assert` (above) is a 1000-line dispatch over 41 assertion kinds.
3071// The five longest arms have been lifted into private fns so the dispatch
3072// match is readable at a glance and each eval is testable in isolation.
3073//
3074// Pattern for future arm extraction: each helper takes (map, output) and
3075// returns the same Result<(), String>. The dispatcher arm becomes a
3076// single-line `"<kind>" => eval_<kind>(map, output),`. No closures
3077// capture `empty_arr` — each helper declares its own when needed.
3078// ---------------------------------------------------------------------------
3079
3080fn eval_qname_rank_lte(
3081    map: &toml::map::Map<String, toml::Value>,
3082    output: &Value,
3083) -> Result<(), String> {
3084    let fragment = str_field(map, "fragment")?;
3085    let max_rank = u64_field(map, "max_rank")?;
3086    let empty_arr: Vec<Value> = Vec::new();
3087    let results = output
3088        .get("results")
3089        .and_then(|v| v.as_array())
3090        .unwrap_or(&empty_arr);
3091    let pos = results.iter().position(|r| {
3092        r.get("qname")
3093            .and_then(|v| v.as_str())
3094            .map_or(false, |q| q.contains(fragment))
3095    });
3096    match pos {
3097        Some(idx) if (idx as u64 + 1) <= max_rank => Ok(()),
3098        Some(idx) => Err(format!(
3099            "qname_rank_lte: {:?} found at rank {} (max_rank={})",
3100            fragment,
3101            idx + 1,
3102            max_rank
3103        )),
3104        None => Err(format!(
3105            "qname_rank_lte: no result qname contains {:?} (checked {} results)",
3106            fragment,
3107            results.len()
3108        )),
3109    }
3110}
3111
3112fn eval_cluster_winner_kind_not(
3113    map: &toml::map::Map<String, toml::Value>,
3114    output: &Value,
3115) -> Result<(), String> {
3116    let doc_stem = str_field(map, "doc_stem")?;
3117    let kind_not = str_field(map, "kind_not")?;
3118    let empty_arr: Vec<Value> = Vec::new();
3119    let dbg = output
3120        .get("cluster_debug")
3121        .and_then(|v| v.as_array())
3122        .unwrap_or(&empty_arr);
3123    let entry = dbg.iter().find(|e| {
3124        e.get("doc_file")
3125            .and_then(|v| v.as_str())
3126            .map_or(false, |f| {
3127                f.to_lowercase().contains(&doc_stem.to_lowercase())
3128            })
3129    });
3130    match entry {
3131        None => Err(format!(
3132            "cluster_winner_kind_not: no cluster_debug entry matches doc_stem {:?}",
3133            doc_stem
3134        )),
3135        Some(e) => {
3136            let winner = e.get("winner_selected").unwrap_or(&Value::Null);
3137            let qname = winner.get("qname").and_then(|v| v.as_str()).unwrap_or("");
3138            if qname.contains(kind_not) {
3139                Err(format!(
3140                    "cluster_winner_kind_not: winner {:?} contains {:?}",
3141                    qname, kind_not
3142                ))
3143            } else {
3144                Ok(())
3145            }
3146        }
3147    }
3148}
3149
3150fn eval_cluster_winner_qname_contains(
3151    map: &toml::map::Map<String, toml::Value>,
3152    output: &Value,
3153) -> Result<(), String> {
3154    let doc_stem = str_field(map, "doc_stem")?;
3155    let fragment = str_field(map, "fragment")?;
3156    let empty_arr: Vec<Value> = Vec::new();
3157    let dbg = output
3158        .get("cluster_debug")
3159        .and_then(|v| v.as_array())
3160        .unwrap_or(&empty_arr);
3161    let entry = dbg.iter().find(|e| {
3162        e.get("doc_file")
3163            .and_then(|v| v.as_str())
3164            .map_or(false, |f| {
3165                f.to_lowercase().contains(&doc_stem.to_lowercase())
3166            })
3167    });
3168    match entry {
3169        None => Err(format!(
3170            "cluster_winner_qname_contains: no cluster_debug entry matches doc_stem {:?}",
3171            doc_stem
3172        )),
3173        Some(e) => {
3174            let winner = e.get("winner_selected").unwrap_or(&Value::Null);
3175            let qname = winner.get("qname").and_then(|v| v.as_str()).unwrap_or("");
3176            if qname.to_lowercase().contains(&fragment.to_lowercase()) {
3177                Ok(())
3178            } else {
3179                Err(format!(
3180                    "cluster_winner_qname_contains: winner {:?} does not contain {:?}",
3181                    qname, fragment
3182                ))
3183            }
3184        }
3185    }
3186}
3187
3188fn eval_all_items_have_field(
3189    map: &toml::map::Map<String, toml::Value>,
3190    output: &Value,
3191) -> Result<(), String> {
3192    let array_path = str_field(map, "array")?;
3193    let field = str_field(map, "field")?;
3194    let arr = dot_path(output, array_path)
3195        .and_then(|v| v.as_array())
3196        .cloned()
3197        .unwrap_or_default();
3198    if arr.is_empty() {
3199        // Empty array trivially passes (nothing to check).
3200        return Ok(());
3201    }
3202    let missing: Vec<String> = arr
3203        .iter()
3204        .filter_map(|item| {
3205            let present = item
3206                .get(field)
3207                .map(|v| !v.is_null() && v.as_str().map_or(true, |s| !s.is_empty()))
3208                .unwrap_or(false);
3209            if !present {
3210                Some(
3211                    item.get("file")
3212                        .and_then(Value::as_str)
3213                        .unwrap_or("?")
3214                        .to_string(),
3215                )
3216            } else {
3217                None
3218            }
3219        })
3220        .collect();
3221    if missing.is_empty() {
3222        Ok(())
3223    } else {
3224        Err(format!(
3225            "all_items_have_field: {} items in {} missing field {:?}: {:?}",
3226            missing.len(),
3227            array_path,
3228            field,
3229            &missing[..missing.len().min(5)]
3230        ))
3231    }
3232}
3233
3234fn eval_file_field_contains(
3235    map: &toml::map::Map<String, toml::Value>,
3236    output: &Value,
3237) -> Result<(), String> {
3238    let array_path = str_field(map, "array")?;
3239    let file_fragment = str_field(map, "file_fragment")?;
3240    let field = str_field(map, "field")?;
3241    let value_contains = str_field(map, "value_contains")?;
3242    let arr = dot_path(output, array_path)
3243        .and_then(|v| v.as_array())
3244        .cloned()
3245        .unwrap_or_default();
3246    let item = arr.iter().find(|item| {
3247        item.get("file")
3248            .and_then(Value::as_str)
3249            .map_or(false, |f| f.contains(file_fragment))
3250    });
3251    match item {
3252        None => Err(format!(
3253            "file_field_contains: no item with file containing {:?} found in {}",
3254            file_fragment, array_path
3255        )),
3256        Some(item) => {
3257            let val = item.get(field).and_then(Value::as_str).unwrap_or("");
3258            if val.contains(value_contains) {
3259                Ok(())
3260            } else {
3261                Err(format!(
3262                    "file_field_contains: item {:?}.{} = {:?} does not contain {:?}",
3263                    item.get("file").and_then(Value::as_str).unwrap_or("?"),
3264                    field,
3265                    val,
3266                    value_contains
3267                ))
3268            }
3269        }
3270    }
3271}
3272
3273#[cfg(test)]
3274mod plan_j_t019_qname_rank_eq_tests {
3275    //! Plan J t-019: precision-mode probe assertion. The lenient
3276    //! `qname_rank_lte` passes if the matching qname is anywhere
3277    //! in the top N; `qname_rank_eq` passes only at EXACTLY the
3278    //! specified rank. Together they let calibration distinguish
3279    //! threshold-too-strict from probes-too-lenient.
3280
3281    use super::eval_assert;
3282    use serde_json::json;
3283
3284    fn assert_block(kind: &str, fragment: &str, exact_rank: u64) -> toml::Value {
3285        let s = format!(
3286            "kind = {:?}\nfragment = {:?}\nexact_rank = {}",
3287            kind, fragment, exact_rank
3288        );
3289        toml::from_str(&s).unwrap()
3290    }
3291
3292    fn results_with(qnames: &[&str]) -> serde_json::Value {
3293        json!({
3294            "results": qnames
3295                .iter()
3296                .map(|q| json!({ "qname": q }))
3297                .collect::<Vec<_>>(),
3298        })
3299    }
3300
3301    #[test]
3302    fn passes_when_exact_rank_match() {
3303        let assert = assert_block("qname_rank_eq", "ProjectManager", 1);
3304        let output = results_with(&["App.Foo.ProjectManager", "App.Bar.OtherSym"]);
3305        assert!(eval_assert(&assert, &output).is_ok());
3306    }
3307
3308    #[test]
3309    fn fails_when_match_at_wrong_rank() {
3310        let assert = assert_block("qname_rank_eq", "ProjectManager", 1);
3311        let output = results_with(&[
3312            "App.Bar.OtherSym",       // rank 1
3313            "App.Foo.ProjectManager", // rank 2 — wrong
3314        ]);
3315        let err = eval_assert(&assert, &output).expect_err("must fail");
3316        assert!(err.contains("rank 2"), "got: {err}");
3317        assert!(err.contains("expected exactly 1"), "got: {err}");
3318    }
3319
3320    #[test]
3321    fn fails_when_no_match() {
3322        let assert = assert_block("qname_rank_eq", "MissingSymbol", 1);
3323        let output = results_with(&["App.Foo.SomethingElse"]);
3324        let err = eval_assert(&assert, &output).expect_err("must fail");
3325        assert!(err.contains("no result qname contains"), "got: {err}");
3326        assert!(err.contains("MissingSymbol"), "got: {err}");
3327    }
3328
3329    #[test]
3330    fn precision_distinguishes_from_lte_on_same_input() {
3331        // The whole point of t-019: same query, same results — but
3332        // rank_lte(5) passes while rank_eq(1) fails. This split is
3333        // the signal the calibration harvester needs to disambiguate
3334        // its "too strict" advisory.
3335        let output = results_with(&[
3336            "App.Bar.OtherSym",       // rank 1
3337            "App.Bar.OtherSym2",      // rank 2
3338            "App.Foo.ProjectManager", // rank 3 — within top-5 but not #1
3339        ]);
3340
3341        let lenient: toml::Value = toml::from_str(
3342            "kind = \"qname_rank_lte\"\nfragment = \"ProjectManager\"\nmax_rank = 5",
3343        )
3344        .unwrap();
3345        assert!(
3346            eval_assert(&lenient, &output).is_ok(),
3347            "rank_lte(5) must accept rank 3"
3348        );
3349
3350        let precision = assert_block("qname_rank_eq", "ProjectManager", 1);
3351        assert!(
3352            eval_assert(&precision, &output).is_err(),
3353            "rank_eq(1) must reject rank 3 — the very split that drives calibration"
3354        );
3355    }
3356
3357    #[test]
3358    fn rank_eq_2_passes_when_match_at_rank_2() {
3359        // Defensive: exact_rank isn't hardcoded to 1. Future probes
3360        // might assert "this symbol is reliably second" if there's
3361        // a known canonical winner ahead of it.
3362        // Fragment chosen to avoid substring-match collision with
3363        // the rank-1 qname — a real issue (fragment "Manager"
3364        // would match both "ProjectManager" and "SnapshotManager")
3365        // that the pre-existing qname_rank_lte already has, so it's
3366        // not t-019-specific.
3367        let assert = assert_block("qname_rank_eq", "AlphaCanon", 2);
3368        let output = results_with(&["App.Bar.BetaSibling", "App.Foo.AlphaCanon"]);
3369        assert!(eval_assert(&assert, &output).is_ok());
3370    }
3371}