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