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,
}
#[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 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,
}
#[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 any_state_record = false;
for ev in events {
match ev.kind {
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,
_ => {}
}
}
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,
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 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);
}
}