1use std::collections::BTreeMap;
33
34use serde::{Deserialize, Serialize};
35use serde_json::{json, Value as JsonValue};
36
37use super::super::{
38 now_unix_seconds_text, run_replay_oracle_trace, ReplayAllowlistRule, ReplayExpectation,
39 ReplayOracleTrace,
40};
41use super::api::{crystallize_traces, synthesize_candidate_from_trace};
42use super::types::{
43 CrystallizationAction, CrystallizationArtifacts, CrystallizationCost,
44 CrystallizationSideEffect, CrystallizationTrace, CrystallizeOptions, WorkflowCandidate,
45};
46use super::util::hash_bytes;
47use crate::value::VmError;
48
49pub const TRAJECTORY_SOURCE: &str = "agent_loop_trajectory";
54
55const DEFAULT_SIMILARITY_THRESHOLD: f64 = 0.5;
62
63const DEFAULT_MIN_SEGMENT_LEN: usize = 2;
68
69const DEFAULT_MAX_SEGMENT_LEN: usize = 12;
73
74const DEFAULT_DIVERGENCE_TOLERANCE: f64 = 0.0;
79
80#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(default)]
86pub struct AgentTurnRecord {
87 pub iteration: usize,
88 pub session_id: String,
89 pub started_at: Option<String>,
90 pub finished_at: Option<String>,
91 pub success: bool,
96 pub tool_calls: Vec<AgentTurnToolCall>,
97 pub provider: Option<String>,
98 pub model: Option<String>,
99 pub input_tokens: i64,
100 pub output_tokens: i64,
101 pub duration_ms: Option<i64>,
102 pub assistant_text: Option<String>,
106 pub metadata: BTreeMap<String, JsonValue>,
112}
113
114#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(default)]
119pub struct AgentTurnToolCall {
120 pub tool_call_id: String,
121 pub tool_name: String,
122 pub status: String,
126 pub raw_input: JsonValue,
127 pub raw_output: Option<JsonValue>,
128 pub capabilities: Vec<String>,
129 pub side_effects: Vec<CrystallizationSideEffect>,
130 pub duration_ms: Option<i64>,
131 pub parameters: BTreeMap<String, JsonValue>,
137}
138
139impl AgentTurnToolCall {
140 fn is_completed(&self) -> bool {
141 self.status.eq_ignore_ascii_case("completed")
142 }
143
144 fn signature(&self) -> String {
145 let mut parameter_keys = self
150 .parameters
151 .keys()
152 .cloned()
153 .chain(json_scalar_keys(&self.raw_input))
154 .collect::<Vec<_>>();
155 parameter_keys.sort();
156 parameter_keys.dedup();
157 format!("tool_call:{}:{}", self.tool_name, parameter_keys.join(","))
158 }
159}
160
161#[derive(Clone, Debug)]
165pub struct TrajectoryTap {
166 session_id: String,
167 workflow_id: Option<String>,
168 similarity_threshold: f64,
169 min_segment_len: usize,
170 max_segment_len: usize,
171 replay_allowlist: Option<Vec<ReplayAllowlistRule>>,
177}
178
179impl TrajectoryTap {
180 pub fn new(session_id: impl Into<String>) -> Self {
181 Self {
182 session_id: session_id.into(),
183 workflow_id: None,
184 similarity_threshold: DEFAULT_SIMILARITY_THRESHOLD,
185 min_segment_len: DEFAULT_MIN_SEGMENT_LEN,
186 max_segment_len: DEFAULT_MAX_SEGMENT_LEN,
187 replay_allowlist: None,
188 }
189 }
190
191 pub fn with_workflow_id(mut self, workflow_id: impl Into<String>) -> Self {
192 self.workflow_id = Some(workflow_id.into());
193 self
194 }
195
196 pub fn with_similarity_threshold(mut self, value: f64) -> Self {
197 self.similarity_threshold = value.clamp(0.0, 1.0);
198 self
199 }
200
201 pub fn with_segment_len(mut self, min: usize, max: usize) -> Self {
202 self.min_segment_len = min.max(1);
203 self.max_segment_len = max.max(self.min_segment_len);
204 self
205 }
206
207 pub fn with_replay_allowlist(mut self, rules: Vec<ReplayAllowlistRule>) -> Self {
211 self.replay_allowlist = Some(rules);
212 self
213 }
214
215 pub fn collect(&self, turns: &[AgentTurnRecord]) -> Vec<CrystallizationTrace> {
219 let mut traces = Vec::new();
220 for segment in self.segment_turns(turns) {
221 traces.push(self.trace_from_segment(segment));
222 }
223 traces
224 }
225
226 fn segment_turns<'a>(&self, turns: &'a [AgentTurnRecord]) -> Vec<&'a [AgentTurnRecord]> {
227 if turns.is_empty() {
228 return Vec::new();
229 }
230 let mut segments = Vec::new();
231 let mut cursor = 0;
232 while cursor < turns.len() {
233 if !turn_is_successful(&turns[cursor]) {
234 cursor += 1;
235 continue;
236 }
237 let mut end = cursor + 1;
238 while end < turns.len()
239 && end - cursor < self.max_segment_len
240 && turn_is_successful(&turns[end])
241 && self.adjacent_similarity(&turns[end - 1], &turns[end])
242 >= self.similarity_threshold
243 {
244 end += 1;
245 }
246 if end - cursor >= self.min_segment_len {
247 segments.push(&turns[cursor..end]);
248 }
249 cursor = end;
250 }
251 segments
252 }
253
254 fn adjacent_similarity(&self, left: &AgentTurnRecord, right: &AgentTurnRecord) -> f64 {
255 jaccard_similarity(
256 &tool_signature_multiset(left),
257 &tool_signature_multiset(right),
258 )
259 }
260
261 fn trace_from_segment(&self, turns: &[AgentTurnRecord]) -> CrystallizationTrace {
262 let segment_index = turns.first().map(|t| t.iteration).unwrap_or(0);
263 let id = format!(
264 "{}_trajectory_{}_{}",
265 self.session_id,
266 segment_index,
267 turns.last().map(|t| t.iteration).unwrap_or(segment_index),
268 );
269 let started_at = turns.first().and_then(|t| t.started_at.clone());
270 let finished_at = turns.last().and_then(|t| t.finished_at.clone());
271 let mut actions = Vec::with_capacity(turns.iter().map(|t| t.tool_calls.len() + 1).sum());
272 for turn in turns {
273 actions.push(model_call_action(turn));
274 for call in &turn.tool_calls {
275 actions.push(tool_call_action(turn.iteration, call));
276 }
277 }
278
279 let mut metadata = BTreeMap::new();
280 metadata.insert("source".to_string(), json!(TRAJECTORY_SOURCE));
281 metadata.insert("session_id".to_string(), json!(self.session_id));
282 metadata.insert(
283 "iteration_span".to_string(),
284 json!([
285 segment_index,
286 turns.last().map(|t| t.iteration).unwrap_or(segment_index)
287 ]),
288 );
289 metadata.insert("turn_count".to_string(), json!(turns.len()));
290
291 let payload = serde_json::to_vec(&actions).unwrap_or_default();
292 let replay_allowlist = self
293 .replay_allowlist
294 .clone()
295 .unwrap_or_else(default_trajectory_allowlist);
296 CrystallizationTrace {
297 version: 1,
298 id,
299 source: Some(TRAJECTORY_SOURCE.to_string()),
300 source_hash: Some(hash_bytes(&payload)),
301 workflow_id: self.workflow_id.clone(),
302 started_at,
303 finished_at,
304 actions,
305 replay_allowlist,
306 metadata,
307 ..CrystallizationTrace::default()
308 }
309 }
310}
311
312fn turn_is_successful(turn: &AgentTurnRecord) -> bool {
313 turn.success && turn.tool_calls.iter().all(AgentTurnToolCall::is_completed)
314}
315
316fn tool_signature_multiset(turn: &AgentTurnRecord) -> Vec<String> {
317 let mut sigs = turn
318 .tool_calls
319 .iter()
320 .map(AgentTurnToolCall::signature)
321 .collect::<Vec<_>>();
322 sigs.sort();
323 sigs
324}
325
326fn jaccard_similarity(left: &[String], right: &[String]) -> f64 {
327 if left.is_empty() && right.is_empty() {
328 return 1.0;
331 }
332 let mut union = left.to_vec();
333 union.extend(right.iter().cloned());
334 union.sort();
335 union.dedup();
336 let union_len = union.len();
337 if union_len == 0 {
338 return 1.0;
339 }
340 let mut intersection = 0usize;
341 let mut right_remaining = right.to_vec();
342 for sig in left {
343 if let Some(pos) = right_remaining.iter().position(|other| other == sig) {
344 right_remaining.swap_remove(pos);
345 intersection += 1;
346 }
347 }
348 intersection as f64 / union_len as f64
349}
350
351fn model_call_action(turn: &AgentTurnRecord) -> CrystallizationAction {
352 let mut metadata = turn.metadata.clone();
353 metadata.insert("source".to_string(), json!(TRAJECTORY_SOURCE));
354 metadata.insert("iteration".to_string(), json!(turn.iteration));
355 metadata.insert("session_id".to_string(), json!(turn.session_id));
356 if let Some(provider) = &turn.provider {
357 metadata.insert("provider".to_string(), json!(provider));
358 }
359 let output = turn.assistant_text.as_ref().map(|text| json!(text));
360 CrystallizationAction {
361 id: format!("turn_{}", turn.iteration),
362 kind: "model_call".to_string(),
363 name: turn
364 .model
365 .clone()
366 .unwrap_or_else(|| "agent_loop".to_string()),
367 timestamp: turn.started_at.clone(),
368 inputs: JsonValue::Null,
369 output: output.clone(),
370 observed_output: output,
371 parameters: BTreeMap::new(),
372 cost: CrystallizationCost {
373 model: turn.model.clone(),
374 model_calls: 1,
375 input_tokens: turn.input_tokens,
376 output_tokens: turn.output_tokens,
377 total_cost_usd: 0.0,
378 wall_ms: turn.duration_ms.unwrap_or_default(),
379 },
380 duration_ms: turn.duration_ms,
381 deterministic: Some(false),
382 fuzzy: Some(true),
383 metadata,
384 ..CrystallizationAction::default()
385 }
386}
387
388fn tool_call_action(iteration: usize, call: &AgentTurnToolCall) -> CrystallizationAction {
389 let mut parameters = call.parameters.clone();
390 if let JsonValue::Object(map) = &call.raw_input {
391 for (key, value) in map {
392 parameters
393 .entry(key.clone())
394 .or_insert_with(|| value.clone());
395 }
396 }
397 let mut metadata = BTreeMap::new();
398 metadata.insert("source".to_string(), json!(TRAJECTORY_SOURCE));
399 metadata.insert("iteration".to_string(), json!(iteration));
400 metadata.insert("tool_call_id".to_string(), json!(call.tool_call_id));
401 metadata.insert("status".to_string(), json!(call.status));
402 CrystallizationAction {
403 id: if call.tool_call_id.is_empty() {
404 format!("turn_{iteration}_{}", call.tool_name)
405 } else {
406 call.tool_call_id.clone()
407 },
408 kind: "tool_call".to_string(),
409 name: call.tool_name.clone(),
410 inputs: call.raw_input.clone(),
411 output: call.raw_output.clone(),
412 observed_output: call.raw_output.clone(),
413 parameters,
414 side_effects: call.side_effects.clone(),
415 capabilities: call.capabilities.clone(),
416 duration_ms: call.duration_ms,
417 deterministic: Some(true),
418 fuzzy: Some(false),
419 metadata,
420 ..CrystallizationAction::default()
421 }
422}
423
424fn json_scalar_keys(value: &JsonValue) -> Vec<String> {
425 match value {
426 JsonValue::Object(map) => map.keys().cloned().collect(),
427 _ => Vec::new(),
428 }
429}
430
431fn default_trajectory_allowlist() -> Vec<ReplayAllowlistRule> {
432 vec![
433 ReplayAllowlistRule {
434 path: "/run_id".to_string(),
435 reason: "trajectory replay assigns a fresh run id per regeneration".to_string(),
436 replacement: None,
437 },
438 ReplayAllowlistRule {
439 path: "/effect_receipts/*/iteration".to_string(),
440 reason: "trajectory regeneration may reseat iteration indices".to_string(),
441 replacement: None,
442 },
443 ]
444}
445
446pub fn verify_trajectory_candidate(
457 candidate: &WorkflowCandidate,
458 original: &CrystallizationTrace,
459) -> Result<(), String> {
460 verify_trajectory_candidate_with_tolerance(candidate, original, DEFAULT_DIVERGENCE_TOLERANCE)
461}
462
463fn verify_trajectory_candidate_with_tolerance(
464 candidate: &WorkflowCandidate,
465 original: &CrystallizationTrace,
466 tolerance: f64,
467) -> Result<(), String> {
468 let start_index = candidate
474 .examples
475 .iter()
476 .find(|example| example.trace_id == original.id)
477 .map(|example| example.start_index)
478 .or_else(|| super::shadow::find_sequence_start(original, &candidate.sequence_signature))
479 .ok_or_else(|| {
480 format!(
481 "trajectory verifier: candidate sequence not found in trace {}",
482 original.id
483 )
484 })?;
485
486 let end = start_index + candidate.steps.len();
487 if end > original.actions.len() {
488 return Err(format!(
489 "trajectory verifier: candidate sequence extends past trace {} actions",
490 original.id
491 ));
492 }
493
494 let mut deterministic_total = 0usize;
499 let mut deterministic_diverged = 0usize;
500 for (offset, step) in candidate.steps.iter().enumerate() {
501 if !matches!(step.segment, super::types::SegmentKind::Deterministic) {
502 continue;
503 }
504 deterministic_total += 1;
505 let Some(expected) = &step.expected_output else {
506 continue;
507 };
508 let actual = original.actions[start_index + offset]
509 .observed_output
510 .as_ref()
511 .or(original.actions[start_index + offset].output.as_ref());
512 if actual != Some(expected) {
513 deterministic_diverged += 1;
514 }
515 }
516 if deterministic_total > 0 {
517 let ratio = deterministic_diverged as f64 / deterministic_total as f64;
518 if ratio > tolerance {
519 return Err(format!(
520 "trajectory verifier: {deterministic_diverged}/{deterministic_total} deterministic \
521 steps diverged from trace {} (tolerance {:.2})",
522 original.id, tolerance
523 ));
524 }
525 }
526
527 let Some(first_run) = original.replay_run.as_ref() else {
533 return Ok(());
534 };
535 if first_run.effect_receipts.is_empty() && candidate.expected_receipts.is_empty() {
536 return Ok(());
537 }
538 let mut regenerated = first_run.clone();
539 regenerated.run_id = format!("trajectory_regen_{}", candidate.id);
540 regenerated.effect_receipts = candidate.expected_receipts.clone();
541 let oracle = ReplayOracleTrace {
542 name: format!("trajectory_verify_{}", candidate.id),
543 description: Some(
544 "trajectory tap regenerated-fixture replay check against the source trace".to_string(),
545 ),
546 expect: ReplayExpectation::Match,
547 allowlist: original.replay_allowlist.clone(),
548 first_run: first_run.clone(),
549 second_run: regenerated,
550 ..ReplayOracleTrace::default()
551 };
552 let report = run_replay_oracle_trace(&oracle).map_err(|error| {
553 format!(
554 "trajectory verifier: oracle error for {}: {error}",
555 candidate.id
556 )
557 })?;
558 if !report.passed {
559 let detail = report
560 .divergence
561 .as_ref()
562 .map(|div| format!("{}: {}", div.path, div.message))
563 .unwrap_or_else(|| "replay oracle reported failure with no divergence".to_string());
564 return Err(format!(
565 "trajectory verifier: regenerated fixture diverged for {}: {detail}",
566 candidate.id
567 ));
568 }
569 Ok(())
570}
571
572pub fn ingest_agent_loop_trajectory(
588 tap: &TrajectoryTap,
589 turns: &[AgentTurnRecord],
590 options: CrystallizeOptions,
591) -> Result<Option<TrajectoryIngestResult>, VmError> {
592 let traces = tap.collect(turns);
593 if traces.is_empty() {
594 return Ok(None);
595 }
596 let needs_synthesis = traces.len() < options.min_examples.max(2);
597 let (mut artifacts, trace_pool) = if needs_synthesis {
598 let trace_pool = traces.clone();
603 let mut iter = traces.into_iter();
604 let primary = iter.next().expect("non-empty by check above");
605 let dropped_from_synthesis: Vec<String> = iter.map(|t| t.id).collect();
606 if !dropped_from_synthesis.is_empty() {
607 tracing::warn!(
608 target: "harn_vm::crystallize::trajectory",
609 primary_trace_id = %primary.id,
610 dropped_trace_ids = ?dropped_from_synthesis,
611 min_examples = options.min_examples,
612 segment_count = trace_pool.len(),
613 "trajectory synthesis kept only the first trace; \
614 remaining traces are surfaced via TrajectoryIngestResult.traces \
615 but are not part of the synthesized candidate"
616 );
617 }
618 let artifacts = synthesize_candidate_from_trace(primary, options, Vec::new(), None, None)?;
619 (artifacts, trace_pool)
620 } else {
621 let trace_pool = traces.clone();
622 let artifacts = crystallize_traces(traces, options)?;
623 (artifacts, trace_pool)
624 };
625
626 apply_trajectory_verifier(&mut artifacts, &trace_pool);
627
628 Ok(Some(TrajectoryIngestResult {
629 artifacts,
630 traces: trace_pool,
631 }))
632}
633
634#[derive(Clone, Debug)]
639pub struct TrajectoryIngestResult {
640 pub artifacts: CrystallizationArtifacts,
641 pub traces: Vec<CrystallizationTrace>,
642}
643
644pub fn apply_trajectory_verifier(
648 artifacts: &mut CrystallizationArtifacts,
649 traces: &[CrystallizationTrace],
650) {
651 let mut moved_ids = Vec::new();
652 for candidate in &mut artifacts.report.candidates {
653 for example in candidate.examples.clone() {
656 let Some(trace) = traces.iter().find(|trace| trace.id == example.trace_id) else {
657 continue;
658 };
659 if let Err(reason) = verify_trajectory_candidate(candidate, trace) {
660 candidate.rejection_reasons.push(reason);
661 moved_ids.push(candidate.id.clone());
662 break;
663 }
664 }
665 }
666 if moved_ids.is_empty() {
667 return;
668 }
669 let mut keep = Vec::new();
670 for candidate in std::mem::take(&mut artifacts.report.candidates) {
671 if moved_ids.contains(&candidate.id) {
672 artifacts.report.rejected_candidates.push(candidate);
673 } else {
674 keep.push(candidate);
675 }
676 }
677 artifacts.report.candidates = keep;
678 if artifacts
679 .report
680 .selected_candidate_id
681 .as_ref()
682 .is_some_and(|id| moved_ids.contains(id))
683 {
684 artifacts.report.selected_candidate_id = artifacts
685 .report
686 .candidates
687 .first()
688 .map(|candidate| candidate.id.clone());
689 if let Some(candidate) = artifacts.report.candidates.first() {
690 artifacts.harn_code = super::codegen::generate_harn_code(candidate);
691 artifacts.eval_pack_toml = super::codegen::generate_eval_pack(candidate);
692 } else {
693 artifacts.harn_code =
694 super::codegen::rejected_workflow_stub(&artifacts.report.rejected_candidates);
695 artifacts.eval_pack_toml.clear();
696 }
697 }
698 super::skill::refresh_skill_candidates(&mut artifacts.report, traces);
699}
700
701pub fn turn_record(
705 iteration: usize,
706 session_id: impl Into<String>,
707 tool_calls: Vec<AgentTurnToolCall>,
708) -> AgentTurnRecord {
709 AgentTurnRecord {
710 iteration,
711 session_id: session_id.into(),
712 success: true,
713 tool_calls,
714 started_at: Some(now_unix_seconds_text()),
715 finished_at: Some(now_unix_seconds_text()),
716 ..AgentTurnRecord::default()
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 fn call(name: &str, params: &[(&str, JsonValue)]) -> AgentTurnToolCall {
725 let mut parameters = BTreeMap::new();
726 let mut raw = serde_json::Map::new();
727 for (key, value) in params {
728 parameters.insert((*key).to_string(), value.clone());
729 raw.insert((*key).to_string(), value.clone());
730 }
731 AgentTurnToolCall {
732 tool_call_id: format!("call_{name}"),
733 tool_name: name.to_string(),
734 status: "completed".to_string(),
735 raw_input: JsonValue::Object(raw),
736 raw_output: Some(json!({"ok": true})),
737 parameters,
738 duration_ms: Some(10),
739 ..AgentTurnToolCall::default()
740 }
741 }
742
743 fn turn(iteration: usize, calls: Vec<AgentTurnToolCall>) -> AgentTurnRecord {
744 AgentTurnRecord {
745 iteration,
746 session_id: "session-test".to_string(),
747 success: true,
748 tool_calls: calls,
749 input_tokens: 100,
750 output_tokens: 50,
751 duration_ms: Some(20),
752 assistant_text: Some("ok".to_string()),
753 ..AgentTurnRecord::default()
754 }
755 }
756
757 #[test]
758 fn collects_consecutive_successful_turns_into_segments() {
759 let turns = vec![
760 turn(1, vec![call("git_status", &[("path", json!("."))])]),
761 turn(2, vec![call("git_status", &[("path", json!("."))])]),
762 AgentTurnRecord {
763 success: false,
764 ..turn(3, vec![call("git_status", &[("path", json!("."))])])
765 },
766 turn(4, vec![call("git_log", &[("path", json!("."))])]),
767 turn(5, vec![call("git_log", &[("path", json!("."))])]),
768 ];
769 let tap = TrajectoryTap::new("s1");
770 let traces = tap.collect(&turns);
771 assert_eq!(traces.len(), 2);
772 assert!(traces
773 .iter()
774 .all(|trace| trace.source.as_deref() == Some(TRAJECTORY_SOURCE)));
775 assert!(traces
776 .iter()
777 .all(|trace| trace.metadata.get("source") == Some(&json!(TRAJECTORY_SOURCE))));
778 }
779
780 #[test]
781 fn splits_segment_when_signatures_diverge() {
782 let turns = vec![
786 turn(1, vec![call("git_status", &[("path", json!("."))])]),
787 turn(2, vec![call("git_status", &[("path", json!("."))])]),
788 turn(3, vec![call("git_diff", &[("path", json!("."))])]),
789 turn(4, vec![call("git_diff", &[("path", json!("."))])]),
790 ];
791 let tap = TrajectoryTap::new("s2").with_similarity_threshold(1.0);
792 let traces = tap.collect(&turns);
793 assert_eq!(traces.len(), 2, "expected one segment per signature group");
794 }
795
796 #[test]
797 fn segment_shorter_than_minimum_is_dropped() {
798 let turns = vec![turn(1, vec![call("git_status", &[("path", json!("."))])])];
799 let tap = TrajectoryTap::new("s3");
800 assert!(tap.collect(&turns).is_empty());
801 }
802
803 #[test]
804 fn collect_honors_custom_replay_allowlist() {
805 let turns = vec![
806 turn(1, vec![call("git_status", &[("path", json!("."))])]),
807 turn(2, vec![call("git_status", &[("path", json!("."))])]),
808 ];
809 let custom = vec![
810 ReplayAllowlistRule {
811 path: "/effect_receipts/*/timestamp".to_string(),
812 reason: "test override".to_string(),
813 replacement: None,
814 },
815 ReplayAllowlistRule {
816 path: "/custom_field".to_string(),
817 reason: "test override".to_string(),
818 replacement: None,
819 },
820 ];
821 let tap = TrajectoryTap::new("s-allowlist").with_replay_allowlist(custom.clone());
822 let traces = tap.collect(&turns);
823 assert_eq!(traces.len(), 1);
824 assert_eq!(
825 traces[0].replay_allowlist, custom,
826 "custom allowlist should be honored verbatim, not overridden by the default"
827 );
828
829 let default_tap = TrajectoryTap::new("s-default");
831 let default_traces = default_tap.collect(&turns);
832 assert_eq!(default_traces.len(), 1);
833 assert_eq!(
834 default_traces[0].replay_allowlist,
835 default_trajectory_allowlist()
836 );
837 }
838
839 #[test]
840 fn verifier_passes_on_clean_candidate() {
841 let turns = vec![
842 turn(1, vec![call("git_status", &[("path", json!("."))])]),
843 turn(2, vec![call("git_status", &[("path", json!("."))])]),
844 ];
845 let tap = TrajectoryTap::new("s4");
846 let result = ingest_agent_loop_trajectory(
847 &tap,
848 &turns,
849 CrystallizeOptions {
850 min_examples: 1,
851 workflow_name: Some("verifier_clean".to_string()),
852 ..CrystallizeOptions::default()
853 },
854 )
855 .expect("ingest")
856 .expect("at least one trace");
857 assert!(
858 !result.artifacts.report.candidates.is_empty(),
859 "expected at least one accepted candidate"
860 );
861 }
862}