use crate::{Event, EventKind, MetricsTotals};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HarnessMetrics {
pub trajectory_efficiency: TrajectoryEfficiency,
pub verification_strength: VerificationStrength,
pub recovery: Recovery,
pub state_consistency: StateConsistency,
pub safety: Safety,
pub replayability: Replayability,
pub parse_errors: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_pass_rate: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_pass_denominator: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tasks_unrunnable: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TrajectoryEfficiency {
pub attempts_total: usize,
pub actions_succeeded: usize,
pub failed_attempts: usize,
pub total_tokens: u64,
pub total_cost_usd: f64,
pub wall_clock_ms: f64,
pub model_calls: usize,
pub success_rate: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct VerificationStrength {
pub actions_validated: usize,
pub actions_rejected: usize,
pub policy_violations: usize,
pub rejection_rate: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Recovery {
pub replan_attempts: usize,
pub replan_rejected: usize,
pub replan_exhausted: usize,
pub retries: usize,
pub branch_decisions: usize,
pub alternatives_rejected: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct StateConsistency {
pub state_changes: usize,
pub snapshots: usize,
pub rollbacks: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Safety {
pub permission_decisions: usize,
pub escalations: usize,
pub denials: usize,
pub approvals_recorded: usize,
#[serde(default)]
pub approvals_rejected: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Replayability {
pub total_events: usize,
pub state_effects_captured: bool,
}
pub fn compute_harness_metrics(events: &[Event]) -> HarnessMetrics {
let mut m = HarnessMetrics::default();
let totals: MetricsTotals = crate::metrics_totals_of(events);
let mut succeeded = 0usize;
let mut failed = 0usize;
let mut model_calls = 0usize;
let mut any_state_record = false;
for ev in events {
match ev.kind {
EventKind::InferenceMetered => model_calls += 1,
EventKind::ActionSucceeded => succeeded += 1,
EventKind::ActionFailed => failed += 1,
EventKind::ActionRetrying => m.recovery.retries += 1,
EventKind::ActionValidated => m.verification_strength.actions_validated += 1,
EventKind::ActionRejected => m.verification_strength.actions_rejected += 1,
EventKind::PolicyViolation => m.verification_strength.policy_violations += 1,
EventKind::ReplanAttempted => m.recovery.replan_attempts += 1,
EventKind::ReplanRejected => m.recovery.replan_rejected += 1,
EventKind::ReplanExhausted => m.recovery.replan_exhausted += 1,
EventKind::BranchDecision => m.recovery.branch_decisions += 1,
EventKind::AlternativeRejected => m.recovery.alternatives_rejected += 1,
EventKind::StateChanged => {
m.state_consistency.state_changes += 1;
any_state_record = true;
}
EventKind::StateSnapshot => {
m.state_consistency.snapshots += 1;
any_state_record = true;
}
EventKind::StateRollback => m.state_consistency.rollbacks += 1,
EventKind::PermissionDecision => {
m.safety.permission_decisions += 1;
match ev.data.get("gate_decision").and_then(|v| v.as_str()) {
Some("needs_approval") => m.safety.escalations += 1,
Some("deny") => m.safety.denials += 1,
_ => {}
}
}
EventKind::ApprovalRecorded => {
m.safety.approvals_recorded += 1;
if ev.data.get("approval").and_then(|v| v.as_str()) == Some("rejected") {
m.safety.approvals_rejected += 1;
}
}
_ => {}
}
}
m.trajectory_efficiency = TrajectoryEfficiency {
attempts_total: succeeded + failed,
actions_succeeded: succeeded,
failed_attempts: failed,
total_tokens: totals.tokens,
total_cost_usd: totals.cost_usd,
wall_clock_ms: totals.duration_ms,
model_calls,
success_rate: ratio(succeeded, succeeded + failed),
};
m.verification_strength.rejection_rate = ratio(
m.verification_strength.actions_rejected,
m.verification_strength.actions_validated + m.verification_strength.actions_rejected,
);
m.replayability = Replayability {
total_events: events.len(),
state_effects_captured: any_state_record,
};
m
}
fn ratio(num: usize, denom: usize) -> Option<f64> {
(denom > 0).then(|| num as f64 / denom as f64)
}
pub fn compute_from_jsonl(jsonl: &str) -> HarnessMetrics {
let mut parse_errors = 0usize;
let events: Vec<Event> = jsonl
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| match serde_json::from_str::<Event>(l) {
Ok(ev) => Some(ev),
Err(_) => {
parse_errors += 1;
None
}
})
.collect();
let mut m = compute_harness_metrics(&events);
m.parse_errors = parse_errors;
m
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EventLog, Metrics};
#[test]
fn efficiency_and_success_rate() {
let mut log = EventLog::new();
log.append_metered(
EventKind::ActionSucceeded,
Some("a1"),
None,
Default::default(),
Metrics::latency(50.0),
);
log.append_metered(
EventKind::InferenceMetered,
None,
None,
Default::default(),
Metrics::inference(100, 40, Some(0.01)),
);
log.append(
EventKind::ActionFailed,
Some("a2"),
None,
Default::default(),
);
let m = compute_harness_metrics(log.events());
assert_eq!(m.trajectory_efficiency.attempts_total, 2);
assert_eq!(m.trajectory_efficiency.actions_succeeded, 1);
assert_eq!(m.trajectory_efficiency.failed_attempts, 1);
assert_eq!(m.trajectory_efficiency.total_tokens, 140);
assert_eq!(m.trajectory_efficiency.wall_clock_ms, 50.0);
assert_eq!(m.trajectory_efficiency.success_rate, Some(0.5));
}
#[test]
fn retried_then_succeeded_lowers_attempt_success_but_counts_retries() {
let mut log = EventLog::new();
log.append(
EventKind::ActionFailed,
Some("a1"),
None,
Default::default(),
);
log.append(
EventKind::ActionRetrying,
Some("a1"),
None,
Default::default(),
);
log.append(
EventKind::ActionFailed,
Some("a1"),
None,
Default::default(),
);
log.append(
EventKind::ActionRetrying,
Some("a1"),
None,
Default::default(),
);
log.append(
EventKind::ActionSucceeded,
Some("a1"),
None,
Default::default(),
);
let m = compute_harness_metrics(log.events());
assert_eq!(m.trajectory_efficiency.failed_attempts, 2);
assert_eq!(m.recovery.retries, 2);
assert_eq!(m.trajectory_efficiency.success_rate, Some(1.0 / 3.0));
}
#[test]
fn model_calls_equals_inference_metered_event_count() {
let mut log = EventLog::new();
log.append_metered(
EventKind::InferenceMetered,
None,
None,
Default::default(),
Metrics::inference(100, 40, Some(0.01)),
);
log.append_metered(
EventKind::InferenceMetered,
None,
None,
Default::default(),
Metrics::inference(60, 10, None),
);
log.append_metered(
EventKind::InferenceMetered,
None,
None,
Default::default(),
Metrics::latency(12.0),
);
log.append_metered(
EventKind::ActionSucceeded,
Some("a1"),
None,
Default::default(),
Metrics::latency(50.0),
);
log.append(
EventKind::ActionFailed,
Some("a2"),
None,
Default::default(),
);
let m = compute_harness_metrics(log.events());
let metered = log
.events()
.iter()
.filter(|e| e.kind == EventKind::InferenceMetered)
.count();
assert_eq!(metered, 3, "fixture should hold three metered calls");
assert_eq!(m.trajectory_efficiency.model_calls, metered);
assert_eq!(m.trajectory_efficiency.total_tokens, 210);
assert_eq!(m.trajectory_efficiency.attempts_total, 2);
}
#[test]
fn no_inference_events_means_no_model_calls() {
let mut log = EventLog::new();
log.append(
EventKind::ActionSucceeded,
Some("a1"),
None,
Default::default(),
);
assert_eq!(
compute_harness_metrics(log.events())
.trajectory_efficiency
.model_calls,
0
);
assert_eq!(
compute_harness_metrics(&[])
.trajectory_efficiency
.model_calls,
0
);
}
#[test]
fn model_calls_survives_json_round_trip() {
let mut log = EventLog::new();
log.append_metered(
EventKind::InferenceMetered,
None,
None,
Default::default(),
Metrics::inference(7, 3, None),
);
let jsonl = serde_json::to_string(&log.events()[0]).unwrap();
let from_jsonl = compute_from_jsonl(&jsonl);
assert_eq!(from_jsonl.trajectory_efficiency.model_calls, 1);
let encoded = serde_json::to_string(&from_jsonl).unwrap();
let decoded: HarnessMetrics = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.trajectory_efficiency.model_calls, 1);
assert_eq!(decoded.trajectory_efficiency.total_tokens, 10);
}
#[test]
fn task_pass_rate_is_never_derived_from_events() {
let mut log = EventLog::new();
log.append_metered(
EventKind::InferenceMetered,
None,
None,
Default::default(),
Metrics::inference(100, 40, Some(0.01)),
);
log.append(
EventKind::ActionSucceeded,
Some("a1"),
None,
Default::default(),
);
assert_eq!(compute_harness_metrics(log.events()).task_pass_rate, None);
assert_eq!(compute_harness_metrics(&[]).task_pass_rate, None);
assert_eq!(compute_from_jsonl("").task_pass_rate, None);
let m = compute_harness_metrics(log.events());
assert_eq!(m.task_pass_denominator, None);
assert_eq!(m.tasks_unrunnable, None);
}
#[test]
fn task_pass_denominator_round_trips_and_is_backward_compatible() {
let mut m = compute_harness_metrics(&[]);
m.task_pass_rate = Some(0.75);
m.task_pass_denominator = Some(12);
m.tasks_unrunnable = Some(2);
let encoded = serde_json::to_string(&m).unwrap();
let decoded: HarnessMetrics = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.task_pass_denominator, Some(12));
assert_eq!(decoded.tasks_unrunnable, Some(2));
let old = r#"{"task_pass_rate":0.75}"#;
let decoded: HarnessMetrics = serde_json::from_str(old).unwrap();
assert_eq!(decoded.task_pass_rate, Some(0.75));
assert_eq!(decoded.task_pass_denominator, None);
assert_eq!(decoded.tasks_unrunnable, None);
let bare = serde_json::to_value(compute_harness_metrics(&[])).unwrap();
assert!(bare.get("task_pass_denominator").is_none());
assert!(bare.get("tasks_unrunnable").is_none());
}
#[test]
fn task_pass_rate_round_trips_and_is_backward_compatible() {
let mut m = compute_harness_metrics(&[]);
m.task_pass_rate = Some(0.75);
let encoded = serde_json::to_string(&m).unwrap();
let decoded: HarnessMetrics = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.task_pass_rate, Some(0.75));
let older = r#"{"trajectory_efficiency":{"model_calls":3}}"#;
let loaded: HarnessMetrics = serde_json::from_str(older).unwrap();
assert_eq!(loaded.task_pass_rate, None);
assert_eq!(loaded.trajectory_efficiency.model_calls, 3);
}
#[test]
fn approvals_and_rejections_are_counted_separately() {
let mut log = EventLog::new();
for decision in ["approved", "rejected", "rejected"] {
log.append(
EventKind::ApprovalRecorded,
None,
None,
[("approval".to_string(), decision.into())].into(),
);
}
let m = compute_harness_metrics(log.events());
assert_eq!(m.safety.approvals_recorded, 3, "every decision is recorded");
assert_eq!(
m.safety.approvals_rejected, 2,
"two of them were rejections"
);
}
#[test]
fn recovery_and_safety_counts() {
let mut log = EventLog::new();
log.append(EventKind::ReplanAttempted, None, None, Default::default());
log.append(EventKind::BranchDecision, None, None, Default::default());
log.append(
EventKind::AlternativeRejected,
None,
None,
Default::default(),
);
log.append(
EventKind::PermissionDecision,
None,
None,
[("gate_decision".to_string(), "needs_approval".into())].into(),
);
log.append(
EventKind::PermissionDecision,
None,
None,
[("gate_decision".to_string(), "deny".into())].into(),
);
log.append(EventKind::ApprovalRecorded, None, None, Default::default());
let m = compute_harness_metrics(log.events());
assert_eq!(m.recovery.replan_attempts, 1);
assert_eq!(m.recovery.branch_decisions, 1);
assert_eq!(m.recovery.alternatives_rejected, 1);
assert_eq!(m.safety.permission_decisions, 2);
assert_eq!(m.safety.escalations, 1);
assert_eq!(m.safety.denials, 1);
assert_eq!(m.safety.approvals_recorded, 1);
}
#[test]
fn state_effects_captured_tracks_state_records() {
let mut log = EventLog::new();
log.append(
EventKind::ActionSucceeded,
Some("a1"),
None,
Default::default(),
);
assert!(
!compute_harness_metrics(log.events())
.replayability
.state_effects_captured
);
log.append(
EventKind::StateChanged,
Some("a1"),
None,
Default::default(),
);
assert!(
compute_harness_metrics(log.events())
.replayability
.state_effects_captured
);
}
#[test]
fn empty_trajectory_has_no_success_rate() {
let m = compute_harness_metrics(&[]);
assert_eq!(m.trajectory_efficiency.success_rate, None);
assert_eq!(m.replayability.total_events, 0);
}
#[test]
fn jsonl_counts_parse_errors() {
let mut log = EventLog::new();
log.append(
EventKind::ActionSucceeded,
Some("a1"),
None,
Default::default(),
);
let good = serde_json::to_string(&log.events()[0]).unwrap();
let jsonl = format!("{good}\nnot json\n{{\"kind\":\"bogus\"}}\n");
let m = compute_from_jsonl(&jsonl);
assert_eq!(m.trajectory_efficiency.actions_succeeded, 1);
assert_eq!(m.parse_errors, 2);
}
}