Skip to main content

harn_vm/orchestration/crystallize/
trajectory.rs

1//! Trajectory tap: ingest `agent_loop` turn-level records as candidate
2//! crystallization sources, alongside the Code Mode composition snippets
3//! already handled by [`super::api::crystallize_traces`].
4//!
5//! Background. Closed harn#1622 fed repeated Code Mode composition
6//! snippets into the crystallization pipeline. Closed harn#2436 extends
7//! the same `bundle → codegen → shadow → normalize` machinery to a
8//! second candidate source: turn-level trajectories emitted by
9//! `agent_loop`. Trajectory candidates carry source field
10//! [`TRAJECTORY_SOURCE`] (`"agent_loop_trajectory"`) so downstream
11//! consumers (cloud importers, receipt viewers) can distinguish them
12//! from composition-derived candidates.
13//!
14//! The tap is intentionally narrow: it groups *consecutive successful
15//! turns* by their tool-call signature similarity into one or more
16//! candidate segments. Each segment becomes a
17//! [`CrystallizationTrace`] with `source = "agent_loop_trajectory"`
18//! and per-action metadata so the existing mining and shadow pipelines
19//! treat the trace identically to any other source. The replay
20//! verifier ([`verify_trajectory_candidate`]) re-derives a regenerated
21//! fixture from the candidate steps and runs the existing replay oracle
22//! against the original trace; if the deterministic outputs diverge the
23//! candidate is rejected before it reaches `bundle`.
24//!
25//! `agent_loop` itself emits per-turn events through the event log
26//! (`AgentEvent::IterationEnd`, `ToolCall`, `ToolCallUpdate`, etc.).
27//! A future patch can build [`AgentTurnRecord`] values directly from
28//! those events; this module takes them already projected so callers
29//! (replay drivers, CLI subcommands, integration tests) can construct
30//! synthetic trajectories without standing up an entire agent runtime.
31
32use 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
49/// Source marker stamped on trajectory-derived traces and bundle
50/// receipts. Consumers (cloud importers, receipt viewers) use this to
51/// distinguish trajectory candidates from `code_mode_composition`
52/// candidates and from release-fixture-derived single-trace candidates.
53pub const TRAJECTORY_SOURCE: &str = "agent_loop_trajectory";
54
55/// Default similarity threshold for grouping consecutive successful
56/// turns. Two adjacent turns are part of the same segment if their
57/// tool-call signature multisets overlap by at least this Jaccard
58/// coefficient. Tuned for merge-captain-style workloads where the same
59/// 2-3 tools recur across turns. Callers can override via
60/// [`TrajectoryTap::with_similarity_threshold`].
61const DEFAULT_SIMILARITY_THRESHOLD: f64 = 0.5;
62
63/// Default minimum length for a segment to be emitted as a trace. Below
64/// this we treat the run as too short to crystallize. The crystallize
65/// pipeline itself enforces `DEFAULT_MIN_EXAMPLES` across traces, but
66/// the per-segment floor keeps single-turn noise out of the trace pool.
67const DEFAULT_MIN_SEGMENT_LEN: usize = 2;
68
69/// Maximum length for a segment. Beyond this we split into two segments
70/// so a runaway trace can't blow the crystallization budget. Matches the
71/// cap in [`super::normalize::best_repeated_sequence`].
72const DEFAULT_MAX_SEGMENT_LEN: usize = 12;
73
74/// Tolerance for the replay verifier. We count how many deterministic
75/// steps diverge between the regenerated fixture and the original
76/// trace; if more than this fraction diverges the candidate is rejected
77/// rather than just warned about.
78const DEFAULT_DIVERGENCE_TOLERANCE: f64 = 0.0;
79
80/// Per-turn snapshot of an `agent_loop` round-trip. One record covers
81/// one model call plus the tool calls dispatched in response to it.
82/// Built by replay drivers or by walking
83/// [`crate::agent_events::AgentEvent`] streams.
84#[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    /// `true` when the turn produced no failed tool calls and no
92    /// terminal error from the loop. Failed turns split a segment: a
93    /// run of successful turns interrupted by a failure becomes two
94    /// candidate segments rather than one.
95    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    /// Final assistant text emitted on the turn, if any. Used as the
103    /// observed output for the synthesized `model_call` action so the
104    /// shadow oracle can compare deterministic prefixes across replays.
105    pub assistant_text: Option<String>,
106    /// Free-form metadata copied into the synthesized model_call
107    /// action. Trajectory callers commonly inject `goal`,
108    /// `success_criteria`, and any policy decisions taken during the
109    /// turn. The collector never overwrites the source field —
110    /// [`TRAJECTORY_SOURCE`] is always stamped on top regardless.
111    pub metadata: BTreeMap<String, JsonValue>,
112}
113
114/// Per-tool-call snapshot used by [`AgentTurnRecord`]. Mirrors the
115/// fields the existing [`CrystallizationAction`] pipeline already
116/// consumes so the trajectory tap stays a thin adapter.
117#[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    /// Status string from `ToolCallUpdate.status` (`completed`,
123    /// `failed`, …). Anything other than `completed` excludes the
124    /// containing turn from the success run.
125    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    /// Caller-provided scalar parameter map. The mining pipeline
132    /// extracts varying scalar values from `raw_input` automatically;
133    /// this map is for callers who already have a typed projection
134    /// (e.g. release identity fields, classifier labels) and want
135    /// them to surface as first-class workflow parameters.
136    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        // Match `action_signature` shape so two trajectory traces
146        // collapse onto the same sequence_signature inside
147        // `mine_candidates` when their tool name + parameter keys
148        // align.
149        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/// Collector that converts a slice of [`AgentTurnRecord`]s into
162/// trajectory-derived [`CrystallizationTrace`]s. Held as a struct so
163/// callers can configure thresholds without a long argument list.
164#[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    /// Caller-supplied replay allowlist. When `None` we fall back to
172    /// [`default_trajectory_allowlist`]; when `Some` the caller-supplied
173    /// value is honored verbatim. This lets richer policies (e.g. extra
174    /// per-receipt id paths) flow through without being silently dropped
175    /// by the trace builder.
176    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    /// Override the replay allowlist applied to traces produced by this
208    /// tap. Pass the full set of rules — the default rules are not
209    /// implicitly merged in.
210    pub fn with_replay_allowlist(mut self, rules: Vec<ReplayAllowlistRule>) -> Self {
211        self.replay_allowlist = Some(rules);
212        self
213    }
214
215    /// Group consecutive successful turns into one or more trajectory
216    /// traces. Returns an empty vec when no run of successful turns
217    /// reaches `min_segment_len`.
218    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        // Two turns with no tool calls (pure assistant chat) count as
329        // similar — they trivially share the empty signature set.
330        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
446/// Verifier gate for trajectory-derived candidates. Re-derives a
447/// "regenerated fixture" from the candidate's deterministic steps and
448/// runs the existing replay oracle against the original trace. The
449/// oracle catches divergence at the receipt level; this wrapper adds a
450/// tolerance check on the action-level outputs so a single noisy
451/// fuzzy step doesn't tip the whole candidate to rejected.
452///
453/// Returns `Ok(())` when the candidate is safe to keep, or an error
454/// string suitable for pushing onto
455/// [`WorkflowCandidate::rejection_reasons`].
456pub 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    // 1. Sequence-signature check: the candidate's signature must
469    //    appear inside the original trace at the recorded start index
470    //    (or anywhere, if no example points to this trace). If we can't
471    //    relocate the sequence the candidate isn't actually derived
472    //    from this trace and shouldn't be promoted from it.
473    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    // 2. Deterministic-output check with tolerance. The shadow path
495    //    already does a strict comparison; this is the trajectory
496    //    relaxation so an LLM-rewritten transient string doesn't fail
497    //    the whole gate.
498    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    // 3. Replay oracle on the regenerated fixture. We synthesize a
528    //    minimal `ReplayTraceRun` from the candidate's expected
529    //    receipts and compare it against the trace's recorded replay
530    //    run (when present). Traces with no replay run skip the
531    //    oracle — there's nothing to compare against.
532    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
572/// Top-level convenience: collect trajectories from `turns`, feed them
573/// through the existing crystallization pipeline, and run the
574/// trajectory replay verifier on every accepted candidate. Mirrors
575/// [`super::release_fixture::ingest_release_fixture`] in shape so the
576/// CLI / orchestrator can route trajectory ingestion through one
577/// surface.
578///
579/// Returns `Ok(None)` when no segments cleared `min_segment_len`. When
580/// fewer than `min_examples` segments are produced, falls back to
581/// [`synthesize_candidate_from_trace`] using the first trace so a
582/// short trajectory still yields a candidate. Any additional segments
583/// not picked up by synthesis are still returned in
584/// [`TrajectoryIngestResult::traces`] so callers / verifiers / bundle
585/// builders can see them, and a `tracing::warn!` is emitted listing the
586/// ids of the traces the synthesis path did not consume.
587pub 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        // Synthesis builds a single-trace candidate, but we must not
599        // silently discard the other traces — they still belong to
600        // the result so the bundle pipeline, verifier, and any
601        // downstream auditor can observe the full ingested set.
602        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/// Bundle of artifacts produced by [`ingest_agent_loop_trajectory`].
635/// `traces` is the same slice that should be passed to
636/// [`super::bundle::build_crystallization_bundle`] so the bundle's
637/// fixtures and source-trace references line up.
638#[derive(Clone, Debug)]
639pub struct TrajectoryIngestResult {
640    pub artifacts: CrystallizationArtifacts,
641    pub traces: Vec<CrystallizationTrace>,
642}
643
644/// Run [`verify_trajectory_candidate`] across every accepted candidate
645/// in `artifacts`. Candidates whose verifier fails move from
646/// `candidates` to `rejected_candidates` and gain a rejection reason.
647pub 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        // We verify against every trace the candidate references; the
654        // first failure is enough to disqualify the candidate.
655        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
701/// Convenience constructor for an [`AgentTurnRecord`] used by tests
702/// and replay drivers that have only the tool calls and want defaults
703/// for the rest.
704pub 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        // The signature of a tool call depends on its name AND its
783        // parameter keys; switching from `git_status` to `git_diff`
784        // drops similarity below the default threshold.
785        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        // Sanity: with no override the default is still used.
830        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}