use super::model::TraceAssertion;
use super::EpisodeGraph;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolAvailability {
declared: Option<Vec<String>>,
called: Vec<String>,
}
impl ToolAvailability {
pub fn observe(meta: &serde_json::Value, graph: &EpisodeGraph) -> Self {
let declared = meta
.get("tool_definitions")
.and_then(|v| v.as_array())
.map(|defs| {
defs.iter()
.filter_map(|d| d.get("name").and_then(|n| n.as_str()))
.map(str::to_owned)
.collect()
});
let called = graph
.tool_calls
.iter()
.filter_map(|t| t.tool_name.clone())
.collect();
Self { declared, called }
}
fn was_available(&self, tool: &str) -> Option<bool> {
if self.called.iter().any(|c| c == tool) {
return Some(true);
}
let declared = self.declared.as_ref()?;
Some(declared.iter().any(|d| d == tool))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssertionCover {
pub assertion: String,
pub reason: String,
}
fn tag(a: &TraceAssertion) -> &'static str {
match a {
TraceAssertion::TraceMustCallTool { .. } => "trace_must_call_tool",
TraceAssertion::TraceMustNotCallTool { .. } => "trace_must_not_call_tool",
TraceAssertion::TraceToolSequence { .. } => "trace_tool_sequence",
TraceAssertion::TraceMaxSteps { .. } => "trace_max_steps",
TraceAssertion::ArgsValid { .. } => "args_valid",
TraceAssertion::SequenceValid { .. } => "sequence_valid",
TraceAssertion::ToolBlocklist { .. } => "tool_blocklist",
}
}
pub fn not_exercised(
graph: &EpisodeGraph,
tools: &ToolAvailability,
a: &TraceAssertion,
) -> Option<AssertionCover> {
let reason = match a {
TraceAssertion::TraceMustNotCallTool { tool } => {
if tools.was_available(tool) == Some(false) {
Some(format!(
"the agent was never offered `{tool}`, so no trace could have called it"
))
} else {
None
}
}
TraceAssertion::TraceMaxSteps { .. } => {
if graph.steps.is_empty() {
Some("the episode recorded no steps, so the ceiling was never approached".into())
} else {
None
}
}
TraceAssertion::TraceMustCallTool { .. }
| TraceAssertion::TraceToolSequence { .. }
| TraceAssertion::ArgsValid { .. }
| TraceAssertion::SequenceValid { .. }
| TraceAssertion::ToolBlocklist { .. } => None,
}?;
Some(AssertionCover {
assertion: tag(a).to_string(),
reason,
})
}
pub fn evaluate_cover(
graph: &EpisodeGraph,
tools: &ToolAvailability,
assertions: &[TraceAssertion],
) -> Vec<AssertionCover> {
assertions
.iter()
.filter_map(|a| not_exercised(graph, tools, a))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::rows::{StepRow, ToolCallRow};
fn call(tool: &str) -> ToolCallRow {
ToolCallRow {
id: 1,
step_id: "s1".into(),
episode_id: "e1".into(),
tool_name: Some(tool.into()),
call_index: Some(0),
args: None,
result: None,
}
}
fn step() -> StepRow {
StepRow {
id: "s1".into(),
episode_id: "e1".into(),
idx: 0,
kind: Some("assistant".into()),
name: None,
content: None,
}
}
fn graph(steps: Vec<StepRow>, calls: Vec<ToolCallRow>) -> EpisodeGraph {
EpisodeGraph {
episode_id: "e1".into(),
steps,
tool_calls: calls,
}
}
fn defs(names: &[&str]) -> serde_json::Value {
serde_json::json!({
"tool_definitions": names.iter().map(|n| serde_json::json!({"name": n})).collect::<Vec<_>>()
})
}
fn must_not_call(tool: &str) -> TraceAssertion {
TraceAssertion::TraceMustNotCallTool { tool: tool.into() }
}
#[test]
fn a_guard_against_a_tool_the_agent_never_had_is_not_exercised() {
let g = graph(vec![step()], vec![call("read_file")]);
let tools = ToolAvailability::observe(&defs(&["read_file", "list_dir"]), &g);
let cover = not_exercised(&g, &tools, &must_not_call("delete_repository")).unwrap();
assert_eq!(cover.assertion, "trace_must_not_call_tool");
assert!(cover.reason.contains("never offered"), "{}", cover.reason);
}
#[test]
fn a_guard_against_an_available_tool_the_agent_declined_is_exercised() {
let g = graph(vec![step()], vec![call("read_file")]);
let tools = ToolAvailability::observe(&defs(&["read_file", "delete_repository"]), &g);
assert_eq!(
not_exercised(&g, &tools, &must_not_call("delete_repository")),
None
);
}
#[test]
fn an_unrecorded_tool_list_reports_nothing() {
let g = graph(vec![step()], vec![call("read_file")]);
let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
assert_eq!(
not_exercised(&g, &tools, &must_not_call("delete_repository")),
None
);
assert_eq!(
tools.was_available("delete_repository"),
None,
"unknown, not false"
);
}
#[test]
fn a_called_tool_counts_as_available_without_a_declaration() {
let g = graph(vec![step()], vec![call("delete_repository")]);
let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
assert_eq!(tools.was_available("delete_repository"), Some(true));
assert_eq!(
not_exercised(&g, &tools, &must_not_call("delete_repository")),
None
);
}
#[test]
fn an_empty_declared_list_is_evidence_and_reports() {
let g = graph(vec![step()], vec![]);
let tools = ToolAvailability::observe(&defs(&[]), &g);
assert_eq!(tools.was_available("anything"), Some(false));
assert!(not_exercised(&g, &tools, &must_not_call("anything")).is_some());
}
#[test]
fn a_step_ceiling_against_an_empty_episode_is_not_exercised() {
let g = graph(vec![], vec![]);
let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
let cover = not_exercised(&g, &tools, &TraceAssertion::TraceMaxSteps { max: 10 }).unwrap();
assert!(cover.reason.contains("no steps"), "{}", cover.reason);
let with_steps = graph(vec![step()], vec![]);
assert_eq!(
not_exercised(
&with_steps,
&tools,
&TraceAssertion::TraceMaxSteps { max: 10 }
),
None
);
}
#[test]
fn a_must_call_assertion_is_never_reported_as_unexercised() {
let g = graph(vec![], vec![]);
let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
assert_eq!(
not_exercised(
&g,
&tools,
&TraceAssertion::TraceMustCallTool {
tool: "read_file".into(),
min_calls: Some(1)
}
),
None
);
}
#[test]
fn fixture_driven_variants_are_never_reported() {
let g = graph(vec![], vec![]);
let tools = ToolAvailability::observe(&serde_json::json!({}), &g);
let fixtures = [
TraceAssertion::ArgsValid {
tool: "t".into(),
test_args: Some(serde_json::json!({})),
policy: Some(serde_json::json!({})),
expect: None,
},
TraceAssertion::SequenceValid {
test_trace: None,
test_trace_raw: Some(vec![]),
policy: Some(serde_json::json!({})),
expect: None,
},
TraceAssertion::ToolBlocklist {
test_tool_calls: Some(vec![]),
policy: Some(serde_json::json!({})),
expect: None,
},
];
for a in &fixtures {
assert_eq!(not_exercised(&g, &tools, a), None, "{}", tag(a));
}
}
#[test]
fn the_tags_match_the_config_vocabulary() {
let yaml = "type: trace_must_not_call_tool\ntool: x\n";
let a: TraceAssertion = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tag(&a), "trace_must_not_call_tool");
}
#[test]
fn evaluate_cover_collects_each_unexercised_assertion() {
let g = graph(vec![], vec![]);
let tools = ToolAvailability::observe(&defs(&["read_file"]), &g);
let covers = evaluate_cover(
&g,
&tools,
&[
must_not_call("delete_repository"),
TraceAssertion::TraceMaxSteps { max: 5 },
TraceAssertion::TraceMustCallTool {
tool: "read_file".into(),
min_calls: Some(1),
},
],
);
assert_eq!(covers.len(), 2);
assert_eq!(covers[0].assertion, "trace_must_not_call_tool");
assert_eq!(covers[1].assertion, "trace_max_steps");
}
}