Skip to main content

agentstatedeveloper_cli/commands/
trace.rs

1//! `asd trace -- <cmd> [args...]` — run a Python program under the ASD
2//! runtime tracer (`tools/asd_tracer.py`), then ingest the resulting JSON
3//! report into ASG as Trace records. Updates each symbol's EffectDecl
4//! verification block to reflect what the tracer actually observed.
5
6use 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    /// Where the tracer should write its JSON report.
29    #[arg(long, default_value = ".asd-trace.json")]
30    pub out: PathBuf,
31
32    /// Command to run under the tracer (use `--` to separate).
33    #[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    /// t-013: caller→callee calls observed at runtime. Empty for old reports.
46    #[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    // Even if the traced program failed, we still try to ingest the report —
90    // the tracer writes it in a `finally` block.
91    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            // qname not in the ASG — user code wasn't indexed; skip silently.
106            continue;
107        };
108
109        traced_qnames += 1;
110
111        // 1. Write the raw Trace record at /asd/v1/traces/<symbol_id>/<trc_*>.
112        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        // 2. Diff declared vs observed, update the EffectDecl.verification.
131        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        // Fold this run into the accumulated runtime confidence.
187        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    // t-013: fold observed call edges into per-edge runtime confidence. A
225    // static edge gets confirmed (confidence rises); an observed edge absent
226    // from the static graph is recorded as runtime-only (a missed edge —
227    // dynamic dispatch the static walker couldn't resolve).
228    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    // Exit with the underlying command's status so CI can pick it up.
289    if !status.success() {
290        std::process::exit(status.code().unwrap_or(1));
291    }
292    Ok(())
293}
294
295/// Fold one runtime observation into a symbol's accumulated runtime evidence,
296/// updating its derived confidence in place. Returns the outcome label.
297///
298/// Outcomes (see [`RuntimeEvidence`] for the absence-safety rationale):
299///   - `"contradiction"` — `contradicted` is true: an effect was observed that
300///     wasn't declared. Demotes confidence.
301///   - `"confirmation"` — not contradicted AND there is positive evidence: a
302///     real (non-Pure) effect was observed, or a pure declaration ran and
303///     produced nothing (`!declared_real`). Promotes confidence.
304///   - `"neutral"` — not contradicted but no positive evidence (a declared
305///     effect simply wasn't exercised). Confidence is left unchanged — absence
306///     of observation is not evidence of absence.
307fn 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
344/// Locate `asd_tracer.py`. Tries:
345///   1. `./tools/asd_tracer.py` (relative to cwd)
346///   2. `<binary_dir>/../../tools/asd_tracer.py`
347///   3. `<binary_dir>/../tools/asd_tracer.py`
348fn 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    // Walk up from cwd too, in case user runs from a nested directory.
362    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    // --- classification truth table -------------------------------------
395
396    #[test]
397    fn contradiction_always_wins() {
398        let mut d = decl_with(None);
399        // Even with positive observation, an undeclared effect ⇒ contradiction.
400        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        // declared_real=false (pure), observed_real=false, not contradicted.
419        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        // declared_real=true but nothing observed and no contradiction ⇒ absence.
429        let outcome = fold_runtime_evidence(&mut d, true, false, false, "t");
430        assert_eq!(outcome, "neutral");
431        // Neutral must not create runtime evidence or move confidence.
432        assert!(d.runtime.is_none());
433        assert_eq!(d.confidence, Some(0.5));
434    }
435
436    // --- folding into confidence ----------------------------------------
437
438    #[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"); // confirm
442        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"); // contradict
448        assert!(d.confidence.unwrap() < after_confirm);
449        assert_eq!(d.runtime.as_ref().unwrap().contradictions, 1);
450        // Prior stays frozen across ingests.
451        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        // No static confidence ⇒ neutral 0.5 prior is captured on first evidence.
461        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        // A neutral run leaves everything untouched...
473        assert_eq!(
474            fold_runtime_evidence(&mut d, true, false, false, "t0"),
475            "neutral"
476        );
477        assert!(d.runtime.is_none());
478        // ...so the first decisive run still captures the static prior.
479        fold_runtime_evidence(&mut d, true, true, false, "t1");
480        assert_eq!(d.runtime.as_ref().unwrap().prior, 0.8);
481    }
482}