1use crate::eval::{EvalCase, eval_case_from_trace};
4use crate::security::redact_secrets;
5use crate::trace::{TurnOutcome, TurnTrace};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub struct DatasetRow {
10 pub version: u32,
11 pub row_type: DatasetRowType,
12 pub task: String,
13 pub outcome: String,
14 pub tools: Vec<String>,
15 #[serde(default)]
16 pub capabilities: Vec<String>,
17 #[serde(default)]
18 pub mcp_tainted: bool,
19 pub verifier_passed: bool,
20 pub reward: f64,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum DatasetRowType {
26 PreferencePair,
27 NegativeExample,
28 ToolRouterTraining,
29 PermissionClassifier,
30 VerifierReward,
31}
32
33pub fn trace_to_dataset_rows(trace: &TurnTrace) -> Vec<DatasetRow> {
34 let tools = trace
35 .tool_calls
36 .iter()
37 .map(|call| call.invocation.tool_name.clone())
38 .collect::<Vec<_>>();
39 let verifier_passed = trace
40 .verifier_results
41 .iter()
42 .any(|verifier| verifier.passed);
43 let success = matches!(
44 trace.outcome,
45 TurnOutcome::Success | TurnOutcome::PartialSuccess
46 );
47 let task = redact_secrets(&trace.task);
48 let capabilities = trace
49 .capabilities
50 .iter()
51 .map(|entry| entry.capability.as_key())
52 .collect::<std::collections::BTreeSet<_>>()
53 .into_iter()
54 .collect::<Vec<_>>();
55 let mcp_tainted = trace.tool_calls.iter().any(|call| {
56 call.result
57 .output
58 .get("tainted")
59 .and_then(serde_json::Value::as_bool)
60 .unwrap_or(false)
61 || call
62 .result
63 .output
64 .get("provenance")
65 .and_then(|value| value.get("source"))
66 .and_then(serde_json::Value::as_str)
67 .is_some_and(|source| source == "mcp")
68 });
69 let mut rows = vec![
70 DatasetRow {
71 version: 1,
72 row_type: if success {
73 DatasetRowType::ToolRouterTraining
74 } else {
75 DatasetRowType::NegativeExample
76 },
77 task: task.clone(),
78 outcome: outcome_label(&trace.outcome),
79 tools: tools.clone(),
80 capabilities: capabilities.clone(),
81 mcp_tainted,
82 verifier_passed,
83 reward: if success && verifier_passed { 1.0 } else { 0.0 },
84 },
85 DatasetRow {
86 version: 1,
87 row_type: DatasetRowType::VerifierReward,
88 task,
89 outcome: outcome_label(&trace.outcome),
90 tools,
91 capabilities: capabilities.clone(),
92 mcp_tainted,
93 verifier_passed,
94 reward: if verifier_passed { 1.0 } else { -1.0 },
95 },
96 ];
97 if !capabilities.is_empty() {
98 rows.push(DatasetRow {
99 version: 1,
100 row_type: DatasetRowType::PermissionClassifier,
101 task: redact_secrets(&trace.task),
102 outcome: outcome_label(&trace.outcome),
103 tools: trace
104 .tool_calls
105 .iter()
106 .map(|call| call.invocation.tool_name.clone())
107 .collect(),
108 capabilities,
109 mcp_tainted,
110 verifier_passed,
111 reward: if success { 1.0 } else { -1.0 },
112 });
113 }
114 rows
115}
116
117pub fn traces_to_eval_candidates(traces: &[TurnTrace]) -> Vec<EvalCase> {
118 traces.iter().filter_map(eval_case_from_trace).collect()
119}
120
121pub fn export_jsonl(rows: &[DatasetRow]) -> String {
122 rows.iter()
123 .map(|row| serde_json::to_string(row).expect("dataset row serializes"))
124 .collect::<Vec<_>>()
125 .join("\n")
126}
127
128fn outcome_label(outcome: &TurnOutcome) -> String {
129 match outcome {
130 TurnOutcome::Success => "success".to_string(),
131 TurnOutcome::PartialSuccess => "partial_success".to_string(),
132 TurnOutcome::Stopped(reason) => format!("stopped:{reason}"),
133 TurnOutcome::Failed(reason) => format!("failed:{reason}"),
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn dataset_export_redacts_task_secrets() {
143 let mut trace = TurnTrace::new("t", "s", "p", "m", "token=sk-12345678901234567890");
144 trace.outcome = TurnOutcome::Failed("no".to_string());
145
146 let rows = trace_to_dataset_rows(&trace);
147 let jsonl = export_jsonl(&rows);
148
149 assert!(!jsonl.contains("sk-1234567890"));
150 assert!(jsonl.contains("negative_example"));
151 }
152
153 #[test]
154 fn dataset_export_includes_permission_classifier_rows() {
155 let mut trace = TurnTrace::new("t", "s", "p", "m", "read repo");
156 trace.record_capability(crate::capability::CapabilityLedgerEntry {
157 capability: crate::capability::Capability::RepoRead,
158 scope: crate::capability::CapabilityScope::Turn("t".to_string()),
159 decision: crate::capability::CapabilityDecision::Consumed,
160 at_ms: 1,
161 justification: "read".to_string(),
162 });
163
164 let rows = trace_to_dataset_rows(&trace);
165
166 assert!(
167 rows.iter()
168 .any(|row| row.row_type == DatasetRowType::PermissionClassifier
169 && row.capabilities == vec!["repo.read"])
170 );
171 }
172}