1use 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#[derive(Debug, Args)]
87pub struct ProbeCmd {
88 #[command(subcommand)]
89 pub sub: ProbeSub,
90}
91
92#[derive(Debug, Subcommand)]
93pub enum ProbeSub {
94 Run(ProbeRunArgs),
96 Add(ProbeAddArgs),
98 Bootstrap(ProbeBootstrapArgs),
100 List(ProbeListArgs),
106 History(ProbeHistoryArgs),
108 Reindex(ProbeReindexArgs),
110}
111
112#[derive(Debug, Args)]
113pub struct ProbeListArgs {
114 #[arg(long)]
116 pub json: bool,
117 #[arg(long)]
119 pub tag: Option<String>,
120}
121
122#[derive(Debug, Args)]
123pub struct ProbeBootstrapArgs {
124 #[arg(long)]
126 pub force: bool,
127 #[arg(long, default_value = "5")]
129 pub top: usize,
130}
131
132#[derive(Debug, Args)]
133pub struct ProbeReindexArgs {
134 #[arg(long)]
136 pub force: bool,
137}
138
139#[derive(Debug, Args)]
140pub struct ProbeHistoryArgs {
141 #[arg(long, default_value = "20")]
143 pub last: usize,
144
145 #[arg(long)]
147 pub tag: Option<String>,
148
149 #[arg(long)]
151 pub json: bool,
152}
153
154#[derive(Debug, Args)]
155pub struct ProbeRunArgs {
156 #[arg(long)]
160 pub json: bool,
161
162 #[arg(long)]
165 pub name: Option<String>,
166
167 #[arg(long)]
171 pub tag: Option<String>,
172
173 #[arg(long)]
175 pub filter: Option<String>,
176
177 #[arg(long)]
182 pub fail_slow: Option<u64>,
183
184 #[arg(long)]
186 pub fail_fast: bool,
187
188 #[arg(long, default_value = "0")]
192 pub jobs: usize,
193}
194
195#[derive(Debug, Args)]
196pub struct ProbeAddArgs {
197 #[arg(long)]
199 pub name: String,
200
201 #[arg(long)]
203 pub command: String,
204
205 #[arg(long, num_args = 1..)]
207 pub args: Vec<String>,
208
209 #[arg(long)]
212 pub assert: Option<String>,
213
214 #[arg(long)]
216 pub description: Option<String>,
217}
218
219#[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 #[serde(default)]
235 #[allow(dead_code)]
236 description: String,
237 command: String,
238 #[serde(default)]
239 args: Vec<String>,
240 #[serde(default)]
242 tags: Vec<String>,
243 #[serde(default)]
245 cwd: Option<String>,
246 #[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
256pub 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
271fn 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
358fn 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
416fn 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
428fn 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 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 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
529fn 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
543const 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 if let Some(ref n) = args.name {
590 if p.name != *n {
591 return false;
592 }
593 }
594 if let Some(ref t) = args.tag {
596 if !p.tags.iter().any(|tag| tag == t) {
597 return false;
598 }
599 }
600 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 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 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 let cacheable = |p: &&ProbeEntry| {
679 p.command != "hydrate-roundtrip"
680 && p.command != "hydrate_roundtrip"
681 && p.command != "workflow"
682 };
683
684 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 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 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 let fail_fast_flag = std::sync::atomic::AtomicBool::new(false);
764 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 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 let ff = &fail_fast_flag;
782
783 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 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 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 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 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 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 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 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 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 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 let calibration_obs: Vec<(String, bool)> = results
961 .iter()
962 .filter_map(|r| {
963 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 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 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, tags: Vec<String>,
1082 duration_ms: u128,
1083 timed_out: bool, error: Option<String>,
1085 debug_payload: Option<Value>,
1086 debug_payload_summary: Option<String>,
1087 calibration_signal: Option<String>,
1094}
1095
1096fn 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#[allow(dead_code)] #[derive(Clone)]
1113struct CachedOutput {
1114 json: Option<Value>,
1116 stdout_raw: String,
1118 stderr: String,
1120 success: bool,
1122 duration_ms: u128,
1124 timed_out: bool,
1126 exec_error: Option<String>,
1128}
1129
1130fn 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
1143fn 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
1226fn 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 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 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, },
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 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 let asd_bin = asd_bin_path.to_string_lossy().into_owned();
1389
1390 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 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 fn shell_quote(s: &str) -> String {
1411 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, };
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, };
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, };
1492 }
1493 };
1494
1495 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
1528fn 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 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 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 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 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 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
1633fn eval_assert(assert: &toml::Value, output: &Value) -> Result<(), String> {
1638 let map = match assert.as_table() {
1639 Some(m) => m,
1640 None => return Ok(()), };
1642
1643 let kind = map.get("kind").and_then(|v| v.as_str()).unwrap_or("");
1644
1645 let empty_arr: Vec<Value> = Vec::new();
1650
1651 match kind {
1652 "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" => {
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" => eval_qname_rank_lte(map, output),
1700
1701 "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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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 "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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => {
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" => eval_all_items_have_field(map, output),
2422
2423 "file_field_contains" => eval_file_field_contains(map, output),
2429
2430 "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" => {
2450 let path = str_field(map, "path")?;
2451 let actual = dot_path(output, path);
2452 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(()), other => Err(format!("unknown assertion kind: {:?}", other)),
2483 }
2484}
2485
2486fn 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 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
2548fn 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 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 }
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 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(_) => {} }
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
2623fn 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 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 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 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 for r in &window {
2666 println!("{}", serde_json::to_string(r)?);
2667 }
2668 } else {
2669 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
2718fn 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 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 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 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 let top_symbols: Vec<(String, String)> = all_hits
2799 .iter()
2800 .filter(|h| h.tier != 2u8) .take(args.top)
2802 .filter_map(|h| {
2803 let qname = h.qname.clone();
2804 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 let trust = compute_trust_score(&cfg.db_path);
2818 let db_state = trust.data_quality.state.as_str();
2819
2820 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 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 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 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 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 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 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 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 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
3028fn add_probe(cfg: &Config, args: ProbeAddArgs) -> Result<()> {
3033 let path = probe_file_path(cfg);
3034
3035 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 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 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
3067fn 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 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 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", "App.Foo.ProjectManager", ]);
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 let output = results_with(&[
3336 "App.Bar.OtherSym", "App.Bar.OtherSym2", "App.Foo.ProjectManager", ]);
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 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}