1use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use agentstategraph::CommitOptions;
11use agentstategraph_core::IntentCategory;
12use anyhow::{Context, Result, anyhow};
13use chrono::{DateTime, Utc};
14use clap::Args;
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17use uuid::Uuid;
18
19use agentstatedeveloper_core::{
20 AsgEffectStore, AsgIndexStore, EdgeEvidence, EffectCategory, EffectStore, Engine, IndexStore,
21 Mismatch, RuntimeEvidence, Verification, VerificationSource, VerificationStatus, paths,
22};
23
24use crate::config::Config;
25
26#[derive(Debug, Args)]
27pub struct TraceArgs {
28 #[arg(long, default_value = ".asd-trace.json")]
30 pub out: PathBuf,
31
32 #[arg(last = true, required = true)]
34 pub command: Vec<String>,
35}
36
37#[derive(Debug, Deserialize)]
38struct Report {
39 #[serde(default)]
40 #[allow(dead_code)]
41 command: Vec<String>,
42 started_at: DateTime<Utc>,
43 finished_at: DateTime<Utc>,
44 observations: Vec<Observation>,
45 #[serde(default)]
47 observed_edges: Vec<ObservedEdge>,
48}
49
50#[derive(Debug, Deserialize)]
51struct ObservedEdge {
52 caller: String,
53 callee: String,
54 #[allow(dead_code)]
55 count: u64,
56}
57
58#[derive(Debug, Deserialize)]
59struct Observation {
60 qname: String,
61 observed_effects: Vec<ObservedEffect>,
62 call_count: u64,
63}
64
65#[derive(Debug, Deserialize, Serialize, Clone)]
66struct ObservedEffect {
67 effect: EffectCategory,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 note: Option<String>,
70}
71
72pub fn run(cfg: &Config, args: TraceArgs) -> Result<()> {
73 if args.command.is_empty() {
74 return Err(anyhow!(
75 "asd trace: no command given (use `-- <cmd> [args...]`)"
76 ));
77 }
78
79 let tracer = locate_tracer()?;
80
81 let status = Command::new("python3")
82 .arg(&tracer)
83 .arg("--")
84 .args(&args.command)
85 .env("ASD_TRACE_OUT", &args.out)
86 .status()
87 .with_context(|| "failed to invoke python3 asd_tracer.py")?;
88
89 let report_bytes = std::fs::read(&args.out)
92 .with_context(|| format!("read trace report at {}", args.out.display()))?;
93 let report: Report = serde_json::from_slice(&report_bytes)
94 .with_context(|| format!("parse trace report at {}", args.out.display()))?;
95
96 let engine = Engine::open_sqlite(&cfg.db_path)?;
97 let index_store = AsgIndexStore::from_engine(&engine);
98 let effect_store = AsgEffectStore::from_engine(&engine);
99
100 let mut traced_qnames: usize = 0;
101 let mut updates: Vec<serde_json::Value> = Vec::new();
102
103 for obs in &report.observations {
104 let Some(symbol) = index_store.get_symbol_by_qname(&engine.ref_name, &obs.qname)? else {
105 continue;
107 };
108
109 traced_qnames += 1;
110
111 let trace_id = format!("trc_{}", Uuid::new_v4().simple());
113 let trace_path = paths::traces_path(&symbol.symbol_id, &trace_id);
114 let trace_value = json!({
115 "qname": obs.qname,
116 "observed_effects": obs.observed_effects,
117 "call_count": obs.call_count,
118 "started_at": report.started_at,
119 "finished_at": report.finished_at,
120 });
121 let opts = CommitOptions::new(
122 &cfg.agent_id,
123 IntentCategory::Explore,
124 format!("runtime trace for {}", obs.qname),
125 );
126 engine
127 .repo
128 .set_json(&engine.ref_name, &trace_path, &trace_value, opts)?;
129
130 let mut decl = effect_store
132 .get_effects(&engine.ref_name, &symbol.symbol_id)?
133 .unwrap_or_else(|| agentstatedeveloper_core::EffectDecl {
134 symbol_id: symbol.symbol_id.clone(),
135 declared: Vec::new(),
136 transitive: Vec::new(),
137 verification: None,
138 confidence: None,
139 runtime: None,
140 matched_policy: None,
141 });
142
143 let declared_set: HashSet<EffectCategory> =
144 decl.declared.iter().map(|e| e.effect.clone()).collect();
145 let observed_set: HashSet<EffectCategory> = obs
146 .observed_effects
147 .iter()
148 .map(|e| e.effect.clone())
149 .collect();
150
151 let mut mismatches: Vec<Mismatch> = Vec::new();
152 for obs_e in &obs.observed_effects {
153 if !declared_set.contains(&obs_e.effect) && obs_e.effect != EffectCategory::Pure {
154 mismatches.push(Mismatch {
155 kind: "undeclared".to_string(),
156 effect: obs_e.effect.clone(),
157 detected_in: Some(obs.qname.clone()),
158 note: obs_e.note.clone(),
159 });
160 }
161 }
162 for declared in &decl.declared {
163 if !observed_set.contains(&declared.effect) && declared.effect != EffectCategory::Pure {
164 mismatches.push(Mismatch {
165 kind: "unobserved".to_string(),
166 effect: declared.effect.clone(),
167 detected_in: Some(obs.qname.clone()),
168 note: Some("declared but not seen at runtime".to_string()),
169 });
170 }
171 }
172
173 let status = if mismatches.is_empty() {
174 VerificationStatus::Ok
175 } else {
176 VerificationStatus::Mismatch
177 };
178
179 decl.verification = Some(Verification {
180 by: VerificationSource::RuntimeTracer,
181 at: Utc::now(),
182 status,
183 mismatches: mismatches.clone(),
184 });
185
186 let contradicted = mismatches.iter().any(|m| m.kind == "undeclared");
188 let observed_real = obs
189 .observed_effects
190 .iter()
191 .any(|e| e.effect != EffectCategory::Pure);
192 let declared_real = decl
193 .declared
194 .iter()
195 .any(|e| e.effect != EffectCategory::Pure);
196 let outcome = fold_runtime_evidence(
197 &mut decl,
198 declared_real,
199 observed_real,
200 contradicted,
201 &trace_id,
202 );
203
204 effect_store.put_effects(&engine.ref_name, &symbol.symbol_id, &decl, &cfg.agent_id)?;
205
206 updates.push(json!({
207 "qname": obs.qname,
208 "status": match status {
209 VerificationStatus::Ok => "ok",
210 VerificationStatus::Mismatch => "mismatch",
211 VerificationStatus::Unverified => "unverified",
212 },
213 "runtime_outcome": outcome,
214 "confidence": decl.confidence,
215 "runtime_evidence": decl.runtime.as_ref().map(|r| json!({
216 "confirmations": r.confirmations,
217 "contradictions": r.contradictions,
218 "prior": r.prior,
219 })),
220 "mismatches": mismatches,
221 }));
222 }
223
224 let mut edges_confirmed = 0usize;
229 let mut runtime_only_edges = 0usize;
230 if !report.observed_edges.is_empty() {
231 let edge_trace_id = format!("trc_{}", Uuid::new_v4().simple());
232 let now = Utc::now();
233 for oe in &report.observed_edges {
234 let (Some(caller), Some(callee)) = (
235 index_store.get_symbol_by_qname(&engine.ref_name, &oe.caller)?,
236 index_store.get_symbol_by_qname(&engine.ref_name, &oe.callee)?,
237 ) else {
238 continue;
239 };
240 let static_known = index_store
241 .get_callees(&engine.ref_name, &caller.symbol_id)?
242 .iter()
243 .any(|c| c == &callee.symbol_id);
244
245 let path = paths::edge_evidence_path(&caller.symbol_id, &callee.symbol_id);
246 let mut ev = engine
247 .repo
248 .get_tree(&engine.ref_name, &path)
249 .ok()
250 .and_then(|v| serde_json::from_value::<EdgeEvidence>(v).ok())
251 .unwrap_or_else(|| {
252 if static_known {
253 EdgeEvidence::static_edge(&caller.symbol_id, &callee.symbol_id)
254 } else {
255 EdgeEvidence::runtime_only_edge(&caller.symbol_id, &callee.symbol_id)
256 }
257 });
258 if static_known {
259 ev.mark_static_known();
260 }
261 ev.confirm(&edge_trace_id, now);
262 if ev.static_known {
263 edges_confirmed += 1;
264 } else {
265 runtime_only_edges += 1;
266 }
267 let opts = CommitOptions::new(
268 &cfg.agent_id,
269 IntentCategory::Explore,
270 format!("runtime edge {} -> {}", oe.caller, oe.callee),
271 );
272 engine
273 .repo
274 .set_json(&engine.ref_name, &path, &serde_json::to_value(&ev)?, opts)?;
275 }
276 }
277
278 let summary = json!({
279 "exit_code": status.code(),
280 "report_path": args.out.display().to_string(),
281 "traced_qnames": traced_qnames,
282 "edges_confirmed": edges_confirmed,
283 "runtime_only_edges": runtime_only_edges,
284 "updates": updates,
285 });
286 println!("{}", serde_json::to_string_pretty(&summary)?);
287
288 if !status.success() {
290 std::process::exit(status.code().unwrap_or(1));
291 }
292 Ok(())
293}
294
295fn fold_runtime_evidence(
308 decl: &mut agentstatedeveloper_core::EffectDecl,
309 declared_real: bool,
310 observed_real: bool,
311 contradicted: bool,
312 trace_id: &str,
313) -> &'static str {
314 let outcome = if contradicted {
315 "contradiction"
316 } else if observed_real || !declared_real {
317 "confirmation"
318 } else {
319 "neutral"
320 };
321
322 if outcome != "neutral" {
323 let mut rt = decl.runtime.take().unwrap_or_else(|| RuntimeEvidence {
324 confirmations: 0,
325 contradictions: 0,
326 prior: decl.confidence.unwrap_or(RuntimeEvidence::NEUTRAL_PRIOR),
327 last_trace_id: None,
328 last_observed_at: Utc::now(),
329 });
330 if outcome == "confirmation" {
331 rt.confirmations += 1;
332 } else {
333 rt.contradictions += 1;
334 }
335 rt.last_trace_id = Some(trace_id.to_string());
336 rt.last_observed_at = Utc::now();
337 decl.confidence = Some(rt.confidence());
338 decl.runtime = Some(rt);
339 }
340
341 outcome
342}
343
344fn locate_tracer() -> Result<PathBuf> {
349 let cwd_candidate = PathBuf::from("tools/asd_tracer.py");
350 if cwd_candidate.exists() {
351 return Ok(cwd_candidate);
352 }
353 if let Ok(exe) = std::env::current_exe() {
354 for ancestor in exe.ancestors().skip(1) {
355 let candidate = ancestor.join("tools").join("asd_tracer.py");
356 if candidate.exists() {
357 return Ok(candidate);
358 }
359 }
360 }
361 if let Ok(cwd) = std::env::current_dir() {
363 let mut cur: Option<&Path> = Some(cwd.as_path());
364 while let Some(dir) = cur {
365 let candidate = dir.join("tools").join("asd_tracer.py");
366 if candidate.exists() {
367 return Ok(candidate);
368 }
369 cur = dir.parent();
370 }
371 }
372 Err(anyhow!(
373 "could not find tools/asd_tracer.py — run from the repo root or install the tracer alongside the binary"
374 ))
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use agentstatedeveloper_core::EffectDecl;
381
382 fn decl_with(prior: Option<f64>) -> EffectDecl {
383 EffectDecl {
384 symbol_id: "sym".into(),
385 declared: Vec::new(),
386 transitive: Vec::new(),
387 verification: None,
388 confidence: prior,
389 runtime: None,
390 matched_policy: None,
391 }
392 }
393
394 #[test]
397 fn contradiction_always_wins() {
398 let mut d = decl_with(None);
399 assert_eq!(
401 fold_runtime_evidence(&mut d, true, true, true, "t"),
402 "contradiction"
403 );
404 }
405
406 #[test]
407 fn observed_real_effect_is_confirmation() {
408 let mut d = decl_with(None);
409 assert_eq!(
410 fold_runtime_evidence(&mut d, true, true, false, "t"),
411 "confirmation"
412 );
413 }
414
415 #[test]
416 fn pure_declared_pure_observed_is_confirmation() {
417 let mut d = decl_with(None);
418 assert_eq!(
420 fold_runtime_evidence(&mut d, false, false, false, "t"),
421 "confirmation"
422 );
423 }
424
425 #[test]
426 fn declared_effect_not_exercised_is_neutral() {
427 let mut d = decl_with(Some(0.5));
428 let outcome = fold_runtime_evidence(&mut d, true, false, false, "t");
430 assert_eq!(outcome, "neutral");
431 assert!(d.runtime.is_none());
433 assert_eq!(d.confidence, Some(0.5));
434 }
435
436 #[test]
439 fn confirmation_raises_contradiction_lowers_from_prior() {
440 let mut d = decl_with(Some(0.5));
441 fold_runtime_evidence(&mut d, true, true, false, "t1"); let after_confirm = d.confidence.unwrap();
443 assert!(after_confirm > 0.5);
444 assert_eq!(d.runtime.as_ref().unwrap().confirmations, 1);
445 assert_eq!(d.runtime.as_ref().unwrap().prior, 0.5);
446
447 fold_runtime_evidence(&mut d, true, false, true, "t2"); assert!(d.confidence.unwrap() < after_confirm);
449 assert_eq!(d.runtime.as_ref().unwrap().contradictions, 1);
450 assert_eq!(d.runtime.as_ref().unwrap().prior, 0.5);
452 assert_eq!(
453 d.runtime.as_ref().unwrap().last_trace_id.as_deref(),
454 Some("t2")
455 );
456 }
457
458 #[test]
459 fn prior_seeds_from_static_confidence_else_neutral() {
460 let mut d = decl_with(None);
462 fold_runtime_evidence(&mut d, true, true, false, "t");
463 assert_eq!(
464 d.runtime.as_ref().unwrap().prior,
465 RuntimeEvidence::NEUTRAL_PRIOR
466 );
467 }
468
469 #[test]
470 fn neutral_then_confirmation_still_seeds_prior_from_static() {
471 let mut d = decl_with(Some(0.8));
472 assert_eq!(
474 fold_runtime_evidence(&mut d, true, false, false, "t0"),
475 "neutral"
476 );
477 assert!(d.runtime.is_none());
478 fold_runtime_evidence(&mut d, true, true, false, "t1");
480 assert_eq!(d.runtime.as_ref().unwrap().prior, 0.8);
481 }
482}