mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 07:37:48 +02:00
Add bounded harness evaluation and rollback loop (#2686)
* feat(ecc2): add bounded harness evaluation loop * fix(ecc2): preserve harness evidence and legacy IDs
This commit is contained in:
@@ -70,6 +70,21 @@ cargo run -- resume <session-id>
|
||||
cargo run -- daemon
|
||||
```
|
||||
|
||||
## Bounded Harness Evaluation
|
||||
|
||||
ECC2 now has an operator-driven configuration registry and promotion gate. Candidate JSON is canonicalized and addressed by its SHA-256 digest, with immutable trace/evidence references. Evaluation uses the same explicit unique seeds for candidate and active baseline through a pluggable Rust trait. The CLI exposes only a deterministic local recorded-measurements evaluator; it makes no network or process calls.
|
||||
|
||||
```bash
|
||||
cargo run -- harness-eval record --config candidate.json --trace-ref trace://run-1 --evidence-ref evidence://review-1
|
||||
cargo run -- harness-eval activate-initial <sha256> --evidence-ref evidence://baseline-approval
|
||||
cargo run -- harness-eval run --candidate <sha256> --baseline <sha256> --seed 1 --seed 2 --measurements measurements.json --evidence-ref evidence://evaluation-1 --min-samples 2 --min-mean-delta 0.05 --min-win-rate 0.5
|
||||
cargo run -- harness-eval audit
|
||||
```
|
||||
|
||||
`measurements.json` contains `{"evaluator":"recorded-v1","scores":{"<candidate>":{"1":0.9},"<baseline>":{"1":0.7}},"health":{"<candidate>":true}}` (with every requested seed present). Promotion requires minimum paired samples, arithmetic-mean delta, and per-seed win rate. SQLite transactions update the active pointer and append audit evidence atomically; a failed or errored candidate-keyed recorded health assertion restores the prior pointer and records rollback evidence. Database triggers reject update/deletion of candidate, evaluation, and audit rows.
|
||||
|
||||
Limitations: this performs one bounded deterministic comparison. It does not autonomously rewrite prompts or `ecc2.toml`, train/fine-tune a model, implement or claim reinforcement learning, call a network service, or run shell-command evaluators. It does not alter running sessions. Evidence references and scores are operator assertions, not authenticated truth. Arithmetic gates do not establish statistical significance. The active pointer is registry state only; it is not automatic deployment into a harness runtime.
|
||||
|
||||
## Validate
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn candidate_id_addresses_canonical_config_and_normalized_references() {
|
||||
let first = CandidateSpec::new(
|
||||
json!({"model": "fixed", "limits": {"steps": 3, "tools": ["read"]}}),
|
||||
vec![" trace://two ".into(), "trace://one".into()],
|
||||
vec!["evidence://two".into(), " evidence://one ".into()],
|
||||
)
|
||||
.unwrap();
|
||||
let second = CandidateSpec::new(
|
||||
json!({"limits": {"tools": ["read"], "steps": 3}, "model": "fixed"}),
|
||||
vec!["trace://one".into(), "trace://two".into()],
|
||||
vec!["evidence://one".into(), "evidence://two".into()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.id, second.id);
|
||||
assert_eq!(first.canonical_config, second.canonical_config);
|
||||
assert_eq!(first.trace_refs, vec!["trace://one", "trace://two"]);
|
||||
assert_eq!(
|
||||
first.evidence_refs,
|
||||
vec!["evidence://one", "evidence://two"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_id_changes_when_any_immutable_reference_changes() {
|
||||
let original = CandidateSpec::new(
|
||||
json!({"model": "fixed"}),
|
||||
vec!["trace://one".into()],
|
||||
vec!["evidence://one".into()],
|
||||
)
|
||||
.unwrap();
|
||||
let changed_trace = CandidateSpec::new(
|
||||
json!({"model": "fixed"}),
|
||||
vec!["trace://two".into()],
|
||||
vec!["evidence://one".into()],
|
||||
)
|
||||
.unwrap();
|
||||
let changed_evidence = CandidateSpec::new(
|
||||
json!({"model": "fixed"}),
|
||||
vec!["trace://one".into()],
|
||||
vec!["evidence://two".into()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(original.id, changed_trace.id);
|
||||
assert_ne!(original.id, changed_evidence.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_integrity_rejects_reference_tampering() {
|
||||
let mut candidate = CandidateSpec::new(
|
||||
json!({"model": "fixed"}),
|
||||
vec!["trace://one".into()],
|
||||
vec!["evidence://one".into()],
|
||||
)
|
||||
.unwrap();
|
||||
candidate.trace_refs = vec!["trace://tampered".into()];
|
||||
|
||||
assert!(candidate.verify_integrity().is_err());
|
||||
|
||||
let mut noncanonical = CandidateSpec::new(
|
||||
json!({"model": "fixed"}),
|
||||
vec!["trace://one".into(), "trace://two".into()],
|
||||
vec!["evidence://one".into()],
|
||||
)
|
||||
.unwrap();
|
||||
noncanonical.trace_refs.reverse();
|
||||
assert!(noncanonical.verify_integrity().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_candidate_integrity_accepts_only_exact_v1_or_v2_ids() {
|
||||
let candidate = CandidateSpec::new(
|
||||
json!({"model": "fixed", "limits": {"steps": 3}}),
|
||||
vec!["trace://one".into()],
|
||||
vec!["evidence://one".into()],
|
||||
)
|
||||
.unwrap();
|
||||
let legacy_id = candidate.legacy_id();
|
||||
|
||||
candidate.verify_persisted_id(&candidate.id).unwrap();
|
||||
candidate.verify_persisted_id(&legacy_id).unwrap();
|
||||
assert!(candidate
|
||||
.verify_persisted_id(&"a".repeat(64))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("content address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_requires_explicit_unique_seeds_and_minimum_samples() {
|
||||
let policy = PromotionPolicy {
|
||||
min_samples: 3,
|
||||
min_mean_delta: 0.05,
|
||||
min_win_rate: 2.0 / 3.0,
|
||||
};
|
||||
let duplicate = vec![
|
||||
paired(7, 1.0, 0.0),
|
||||
paired(7, 1.0, 0.0),
|
||||
paired(9, 1.0, 0.0),
|
||||
];
|
||||
assert!(policy.compare(&duplicate).is_err());
|
||||
|
||||
let too_few = vec![paired(7, 1.0, 0.0), paired(8, 1.0, 0.0)];
|
||||
let decision = policy.compare(&too_few).unwrap();
|
||||
assert!(!decision.passed);
|
||||
assert!(decision
|
||||
.failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("minimum sample")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thresholds_are_deterministic_and_all_must_pass() {
|
||||
let policy = PromotionPolicy {
|
||||
min_samples: 3,
|
||||
min_mean_delta: 0.1,
|
||||
min_win_rate: 0.75,
|
||||
};
|
||||
let samples = vec![
|
||||
paired(1, 0.9, 0.7),
|
||||
paired(2, 0.8, 0.7),
|
||||
paired(3, 0.6, 0.7),
|
||||
paired(4, 0.8, 0.7),
|
||||
];
|
||||
let first = policy.compare(&samples).unwrap();
|
||||
let second = policy.compare(&samples).unwrap();
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert!(!first.passed);
|
||||
assert_eq!(first.win_rate, 0.75);
|
||||
assert!(first
|
||||
.failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("mean delta")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluator_is_called_for_each_explicit_seed_in_order() {
|
||||
let mut evaluator = RecordedEvaluator::new(
|
||||
BTreeMap::from([
|
||||
(("candidate".into(), 4), 0.9),
|
||||
(("baseline".into(), 4), 0.5),
|
||||
(("candidate".into(), 2), 0.8),
|
||||
(("baseline".into(), 2), 0.6),
|
||||
]),
|
||||
true,
|
||||
);
|
||||
|
||||
let samples = evaluate_paired(&mut evaluator, "candidate", "baseline", &[4, 2]).unwrap();
|
||||
assert_eq!(samples, vec![paired(4, 0.9, 0.5), paired(2, 0.8, 0.6)]);
|
||||
assert_eq!(
|
||||
evaluator.calls(),
|
||||
&[
|
||||
("candidate".into(), 4),
|
||||
("baseline".into(), 4),
|
||||
("candidate".into(), 2),
|
||||
("baseline".into(), 2)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn paired(seed: u64, candidate_score: f64, baseline_score: f64) -> PairedSample {
|
||||
PairedSample {
|
||||
seed,
|
||||
candidate_score,
|
||||
baseline_score,
|
||||
}
|
||||
}
|
||||
}
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CandidateSpec {
|
||||
pub id: String,
|
||||
pub canonical_config: String,
|
||||
pub trace_refs: Vec<String>,
|
||||
pub evidence_refs: Vec<String>,
|
||||
}
|
||||
|
||||
impl CandidateSpec {
|
||||
pub fn new(config: Value, trace_refs: Vec<String>, evidence_refs: Vec<String>) -> Result<Self> {
|
||||
let trace_refs = normalize_refs("trace", trace_refs)?;
|
||||
let evidence_refs = normalize_refs("evidence", evidence_refs)?;
|
||||
let canonical_config = serde_json::to_string(&canonicalize(config))?;
|
||||
if canonical_config.len() > 1024 * 1024 {
|
||||
bail!("candidate configuration exceeds 1 MiB");
|
||||
}
|
||||
let artifact = serde_json::to_string(&CanonicalCandidateArtifact {
|
||||
config: serde_json::from_str(&canonical_config)?,
|
||||
trace_refs: &trace_refs,
|
||||
evidence_refs: &evidence_refs,
|
||||
})?;
|
||||
let id = sha256_hex(artifact.as_bytes());
|
||||
Ok(Self {
|
||||
id,
|
||||
canonical_config,
|
||||
trace_refs,
|
||||
evidence_refs,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_integrity(&self) -> Result<()> {
|
||||
self.verify_persisted_id(&self.id)?;
|
||||
if self.id != self.id_for_v2()? {
|
||||
bail!("candidate content address or canonical configuration is invalid");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn legacy_id(&self) -> String {
|
||||
sha256_hex(self.canonical_config.as_bytes())
|
||||
}
|
||||
|
||||
pub fn verify_persisted_id(&self, persisted_id: &str) -> Result<()> {
|
||||
let value: Value = serde_json::from_str(&self.canonical_config)?;
|
||||
let rebuilt = Self::new(value, self.trace_refs.clone(), self.evidence_refs.clone())?;
|
||||
let is_v1 = persisted_id == self.legacy_id();
|
||||
let is_v2 = persisted_id == rebuilt.id;
|
||||
if rebuilt.canonical_config != self.canonical_config
|
||||
|| (!is_v1 && !is_v2)
|
||||
|| (is_v2
|
||||
&& (rebuilt.trace_refs != self.trace_refs
|
||||
|| rebuilt.evidence_refs != self.evidence_refs))
|
||||
{
|
||||
bail!("candidate content address or canonical configuration is invalid");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn id_for_v2(&self) -> Result<String> {
|
||||
Ok(Self::new(
|
||||
serde_json::from_str(&self.canonical_config)?,
|
||||
self.trace_refs.clone(),
|
||||
self.evidence_refs.clone(),
|
||||
)?
|
||||
.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CanonicalCandidateArtifact<'a> {
|
||||
config: Value,
|
||||
trace_refs: &'a [String],
|
||||
evidence_refs: &'a [String],
|
||||
}
|
||||
|
||||
fn normalize_refs(kind: &str, refs: Vec<String>) -> Result<Vec<String>> {
|
||||
if refs.is_empty() || refs.iter().any(|reference| reference.trim().is_empty()) {
|
||||
bail!("at least one non-empty {kind} reference is required");
|
||||
}
|
||||
if refs.len() > 100 || refs.iter().any(|reference| reference.len() > 4096) {
|
||||
bail!("{kind} references exceed bounded limits");
|
||||
}
|
||||
let mut normalized = refs
|
||||
.into_iter()
|
||||
.map(|reference| reference.trim().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn canonicalize(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(entries) => Value::Object(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, canonicalize(value)))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(values) => Value::Array(values.into_iter().map(canonicalize).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Evaluator {
|
||||
fn name(&self) -> &str;
|
||||
fn evaluate(&mut self, candidate_id: &str, seed: u64) -> Result<f64>;
|
||||
fn health_check(&mut self, candidate_id: &str) -> Result<bool>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordedEvidence {
|
||||
pub evaluator: String,
|
||||
pub scores: BTreeMap<String, BTreeMap<u64, f64>>,
|
||||
pub health: BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HealthEvidenceSnapshot {
|
||||
pub schema_version: u8,
|
||||
pub evaluator: String,
|
||||
pub candidate_id: String,
|
||||
pub asserted_healthy: bool,
|
||||
}
|
||||
|
||||
impl HealthEvidenceSnapshot {
|
||||
pub fn new(evaluator: &str, candidate_id: &str, asserted_healthy: bool) -> Result<Self> {
|
||||
let snapshot = Self {
|
||||
schema_version: 1,
|
||||
evaluator: evaluator.to_string(),
|
||||
candidate_id: candidate_id.to_string(),
|
||||
asserted_healthy,
|
||||
};
|
||||
snapshot.verify()?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn canonical_json(&self) -> Result<String> {
|
||||
self.verify()?;
|
||||
Ok(serde_json::to_string(self)?)
|
||||
}
|
||||
|
||||
pub fn digest(&self) -> Result<String> {
|
||||
Ok(sha256_hex(self.canonical_json()?.as_bytes()))
|
||||
}
|
||||
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
if self.schema_version != 1
|
||||
|| self.evaluator != "recorded-v1"
|
||||
|| self.candidate_id.len() != 64
|
||||
|| !self
|
||||
.candidate_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
bail!("invalid canonical health evidence snapshot");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecordedEvaluator {
|
||||
name: String,
|
||||
scores: BTreeMap<(String, u64), f64>,
|
||||
health_ok: bool,
|
||||
health_candidate: Option<String>,
|
||||
calls: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
impl RecordedEvaluator {
|
||||
#[cfg(test)]
|
||||
pub fn new(scores: BTreeMap<(String, u64), f64>, health_ok: bool) -> Self {
|
||||
Self {
|
||||
name: "recorded-v1".into(),
|
||||
scores,
|
||||
health_ok,
|
||||
health_candidate: None,
|
||||
calls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_evidence(evidence: RecordedEvidence) -> Result<Self> {
|
||||
if evidence.evaluator != "recorded-v1" {
|
||||
bail!("CLI evidence evaluator must be recorded-v1");
|
||||
}
|
||||
let score_count = evidence.scores.values().map(BTreeMap::len).sum::<usize>();
|
||||
if score_count > 20_000 || evidence.scores.keys().any(|id| id.len() != 64) {
|
||||
bail!("recorded evidence exceeds bounded score or candidate limits");
|
||||
}
|
||||
if evidence.health.len() != 1 {
|
||||
bail!("exactly one candidate-keyed health assertion is required");
|
||||
}
|
||||
let (health_candidate, health_ok) = evidence
|
||||
.health
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("candidate-keyed health evidence is required")?;
|
||||
let scores = evidence
|
||||
.scores
|
||||
.into_iter()
|
||||
.flat_map(|(id, values)| {
|
||||
values
|
||||
.into_iter()
|
||||
.map(move |(seed, score)| ((id.clone(), seed), score))
|
||||
})
|
||||
.collect();
|
||||
Ok(Self {
|
||||
name: evidence.evaluator,
|
||||
scores,
|
||||
health_ok,
|
||||
health_candidate: Some(health_candidate),
|
||||
calls: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn health_evidence_snapshot(&self) -> Result<HealthEvidenceSnapshot> {
|
||||
HealthEvidenceSnapshot::new(
|
||||
&self.name,
|
||||
self.health_candidate
|
||||
.as_deref()
|
||||
.context("candidate-keyed health evidence is required")?,
|
||||
self.health_ok,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn calls(&self) -> &[(String, u64)] {
|
||||
&self.calls
|
||||
}
|
||||
}
|
||||
|
||||
impl Evaluator for RecordedEvaluator {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn evaluate(&mut self, candidate_id: &str, seed: u64) -> Result<f64> {
|
||||
self.calls.push((candidate_id.to_string(), seed));
|
||||
let score = *self
|
||||
.scores
|
||||
.get(&(candidate_id.to_string(), seed))
|
||||
.with_context(|| format!("missing recorded score for {candidate_id} seed {seed}"))?;
|
||||
if !score.is_finite() || !(0.0..=1.0).contains(&score) {
|
||||
bail!("score must be finite and between 0 and 1");
|
||||
}
|
||||
Ok(score)
|
||||
}
|
||||
|
||||
fn health_check(&mut self, candidate_id: &str) -> Result<bool> {
|
||||
if self
|
||||
.health_candidate
|
||||
.as_deref()
|
||||
.is_some_and(|expected| expected != candidate_id)
|
||||
{
|
||||
bail!("health evidence does not match promoted candidate");
|
||||
}
|
||||
Ok(self.health_ok)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PairedSample {
|
||||
pub seed: u64,
|
||||
pub candidate_score: f64,
|
||||
pub baseline_score: f64,
|
||||
}
|
||||
|
||||
pub fn evaluate_paired(
|
||||
evaluator: &mut dyn Evaluator,
|
||||
candidate_id: &str,
|
||||
baseline_id: &str,
|
||||
seeds: &[u64],
|
||||
) -> Result<Vec<PairedSample>> {
|
||||
if seeds.is_empty() {
|
||||
bail!("at least one explicit seed is required");
|
||||
}
|
||||
if seeds.len() > 10_000 {
|
||||
bail!("seed count exceeds 10000");
|
||||
}
|
||||
if seeds.iter().copied().collect::<BTreeSet<_>>().len() != seeds.len() {
|
||||
bail!("seeds must be unique");
|
||||
}
|
||||
seeds
|
||||
.iter()
|
||||
.map(|seed| {
|
||||
Ok(PairedSample {
|
||||
seed: *seed,
|
||||
candidate_score: evaluator.evaluate(candidate_id, *seed)?,
|
||||
baseline_score: evaluator.evaluate(baseline_id, *seed)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PromotionPolicy {
|
||||
pub min_samples: usize,
|
||||
pub min_mean_delta: f64,
|
||||
pub min_win_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Comparison {
|
||||
pub passed: bool,
|
||||
pub sample_count: usize,
|
||||
pub candidate_mean: f64,
|
||||
pub baseline_mean: f64,
|
||||
pub mean_delta: f64,
|
||||
pub win_rate: f64,
|
||||
pub failures: Vec<String>,
|
||||
}
|
||||
|
||||
impl PromotionPolicy {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if self.min_samples == 0 {
|
||||
bail!("minimum samples must be positive");
|
||||
}
|
||||
if !self.min_mean_delta.is_finite() {
|
||||
bail!("minimum mean delta must be finite");
|
||||
}
|
||||
if !self.min_win_rate.is_finite() || !(0.0..=1.0).contains(&self.min_win_rate) {
|
||||
bail!("minimum win rate must be between 0 and 1");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compare(self, samples: &[PairedSample]) -> Result<Comparison> {
|
||||
self.validate()?;
|
||||
if samples.is_empty() {
|
||||
bail!("samples cannot be empty");
|
||||
}
|
||||
if samples
|
||||
.iter()
|
||||
.map(|sample| sample.seed)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= samples.len()
|
||||
{
|
||||
bail!("sample seeds must be unique");
|
||||
}
|
||||
if samples.iter().any(|s| {
|
||||
!s.candidate_score.is_finite()
|
||||
|| !s.baseline_score.is_finite()
|
||||
|| !(0.0..=1.0).contains(&s.candidate_score)
|
||||
|| !(0.0..=1.0).contains(&s.baseline_score)
|
||||
}) {
|
||||
bail!("scores must be finite and between 0 and 1");
|
||||
}
|
||||
let count = samples.len();
|
||||
let candidate_mean = samples.iter().map(|s| s.candidate_score).sum::<f64>() / count as f64;
|
||||
let baseline_mean = samples.iter().map(|s| s.baseline_score).sum::<f64>() / count as f64;
|
||||
let mean_delta = candidate_mean - baseline_mean;
|
||||
let win_rate = samples
|
||||
.iter()
|
||||
.filter(|s| s.candidate_score > s.baseline_score)
|
||||
.count() as f64
|
||||
/ count as f64;
|
||||
let mut failures = Vec::new();
|
||||
if count < self.min_samples {
|
||||
failures.push(format!(
|
||||
"minimum sample count is {}, got {count}",
|
||||
self.min_samples
|
||||
));
|
||||
}
|
||||
if mean_delta < self.min_mean_delta {
|
||||
failures.push(format!(
|
||||
"mean delta {mean_delta:.6} is below {:.6}",
|
||||
self.min_mean_delta
|
||||
));
|
||||
}
|
||||
if win_rate < self.min_win_rate {
|
||||
failures.push(format!(
|
||||
"win rate {win_rate:.6} is below {:.6}",
|
||||
self.min_win_rate
|
||||
));
|
||||
}
|
||||
Ok(Comparison {
|
||||
passed: failures.is_empty(),
|
||||
sample_count: count,
|
||||
candidate_mean,
|
||||
baseline_mean,
|
||||
mean_delta,
|
||||
win_rate,
|
||||
failures,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod comms;
|
||||
mod config;
|
||||
mod harness_eval;
|
||||
mod notifications;
|
||||
mod observability;
|
||||
mod session;
|
||||
@@ -108,6 +109,11 @@ impl OptionalWorktreePolicyArgs {
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Run bounded, deterministic harness configuration evaluations
|
||||
HarnessEval {
|
||||
#[command(subcommand)]
|
||||
command: HarnessEvalCommands,
|
||||
},
|
||||
/// Launch the TUI dashboard
|
||||
Dashboard,
|
||||
/// Start a new agent session
|
||||
@@ -437,6 +443,46 @@ enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
enum HarnessEvalCommands {
|
||||
/// Record an immutable content-addressed candidate from a local JSON file
|
||||
Record {
|
||||
#[arg(long)]
|
||||
config: PathBuf,
|
||||
#[arg(long = "trace-ref", required = true)]
|
||||
trace_refs: Vec<String>,
|
||||
#[arg(long = "evidence-ref", required = true)]
|
||||
evidence_refs: Vec<String>,
|
||||
},
|
||||
/// Set the first baseline; subsequent changes require evaluation
|
||||
ActivateInitial {
|
||||
candidate_id: String,
|
||||
#[arg(long)]
|
||||
evidence_ref: String,
|
||||
},
|
||||
/// Evaluate paired scores and conditionally promote with a health gate
|
||||
Run {
|
||||
#[arg(long)]
|
||||
candidate: String,
|
||||
#[arg(long)]
|
||||
baseline: String,
|
||||
#[arg(long = "seed", required = true)]
|
||||
seeds: Vec<u64>,
|
||||
#[arg(long)]
|
||||
measurements: PathBuf,
|
||||
#[arg(long)]
|
||||
evidence_ref: String,
|
||||
#[arg(long)]
|
||||
min_samples: usize,
|
||||
#[arg(long)]
|
||||
min_mean_delta: f64,
|
||||
#[arg(long)]
|
||||
min_win_rate: f64,
|
||||
},
|
||||
/// Show append-only promotion audit entries
|
||||
Audit,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
enum MessageCommands {
|
||||
/// Send a structured message between sessions
|
||||
@@ -1345,6 +1391,37 @@ struct DotenvMemoryEntry {
|
||||
details: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn read_bounded_file(path: &Path, max_bytes: u64, label: &str) -> Result<Vec<u8>> {
|
||||
let mut options = File::options();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NONBLOCK);
|
||||
}
|
||||
let file = options
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open {}", path.display()))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.with_context(|| format!("Failed to inspect {}", path.display()))?;
|
||||
if !metadata.is_file() {
|
||||
anyhow::bail!("{label} must be a regular file");
|
||||
}
|
||||
|
||||
let read_limit = max_bytes
|
||||
.checked_add(1)
|
||||
.context("bounded input byte limit is too large")?;
|
||||
let mut content = Vec::new();
|
||||
file.take(read_limit)
|
||||
.read_to_end(&mut content)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
if content.len() as u64 > max_bytes {
|
||||
anyhow::bail!("{label} exceeds the {max_bytes}-byte limit");
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -1357,6 +1434,75 @@ async fn main() -> Result<()> {
|
||||
let db = session::store::StateStore::open(&cfg.db_path)?;
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::HarnessEval { command }) => match command {
|
||||
HarnessEvalCommands::Record {
|
||||
config,
|
||||
trace_refs,
|
||||
evidence_refs,
|
||||
} => {
|
||||
let value: serde_json::Value = serde_json::from_slice(&read_bounded_file(
|
||||
&config,
|
||||
1_048_576,
|
||||
"candidate configuration",
|
||||
)?)
|
||||
.with_context(|| format!("Invalid JSON in {}", config.display()))?;
|
||||
let candidate = harness_eval::CandidateSpec::new(value, trace_refs, evidence_refs)?;
|
||||
db.record_harness_candidate(&candidate)?;
|
||||
println!("{}", candidate.id);
|
||||
}
|
||||
HarnessEvalCommands::ActivateInitial {
|
||||
candidate_id,
|
||||
evidence_ref,
|
||||
} => {
|
||||
db.activate_initial_harness(&candidate_id, &evidence_ref)?;
|
||||
println!("Activated initial baseline: {candidate_id}");
|
||||
}
|
||||
HarnessEvalCommands::Run {
|
||||
candidate,
|
||||
baseline,
|
||||
seeds,
|
||||
measurements,
|
||||
evidence_ref,
|
||||
min_samples,
|
||||
min_mean_delta,
|
||||
min_win_rate,
|
||||
} => {
|
||||
use harness_eval::Evaluator;
|
||||
let evidence: harness_eval::RecordedEvidence = serde_json::from_slice(
|
||||
&read_bounded_file(&measurements, 8_388_608, "recorded measurements")?,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("Invalid recorded evidence in {}", measurements.display())
|
||||
})?;
|
||||
let mut evaluator = harness_eval::RecordedEvaluator::from_evidence(evidence)?;
|
||||
let evaluator_name = evaluator.name().to_string();
|
||||
let health_evidence = evaluator.health_evidence_snapshot()?;
|
||||
let samples =
|
||||
harness_eval::evaluate_paired(&mut evaluator, &candidate, &baseline, &seeds)?;
|
||||
let policy = harness_eval::PromotionPolicy {
|
||||
min_samples,
|
||||
min_mean_delta,
|
||||
min_win_rate,
|
||||
};
|
||||
let outcome = db.evaluate_promote_and_health_check(
|
||||
&candidate,
|
||||
&baseline,
|
||||
&evaluator_name,
|
||||
&samples,
|
||||
policy,
|
||||
&evidence_ref,
|
||||
&health_evidence,
|
||||
|id| evaluator.health_check(id),
|
||||
)?;
|
||||
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||
}
|
||||
HarnessEvalCommands::Audit => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&db.harness_audit_entries()?)?
|
||||
);
|
||||
}
|
||||
},
|
||||
Some(Commands::Dashboard) | None => {
|
||||
tui::app::run(db, cfg).await?;
|
||||
}
|
||||
@@ -8533,6 +8679,96 @@ mod tests {
|
||||
assert!(!policy.resolve(&cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harness_eval_cli_requires_explicit_bounded_inputs() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"ecc",
|
||||
"harness-eval",
|
||||
"run",
|
||||
"--candidate",
|
||||
"candidate",
|
||||
"--baseline",
|
||||
"baseline",
|
||||
"--seed",
|
||||
"1",
|
||||
"--seed",
|
||||
"2",
|
||||
"--measurements",
|
||||
"scores.json",
|
||||
"--evidence-ref",
|
||||
"evidence://run",
|
||||
"--min-samples",
|
||||
"2",
|
||||
"--min-mean-delta",
|
||||
"0.1",
|
||||
"--min-win-rate",
|
||||
"0.5",
|
||||
])
|
||||
.expect("valid harness evaluation command");
|
||||
match cli.command {
|
||||
Some(Commands::HarnessEval {
|
||||
command:
|
||||
HarnessEvalCommands::Run {
|
||||
seeds, min_samples, ..
|
||||
},
|
||||
}) => {
|
||||
assert_eq!(seeds, vec![1, 2]);
|
||||
assert_eq!(min_samples, 2);
|
||||
}
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
assert!(Cli::try_parse_from([
|
||||
"ecc",
|
||||
"harness-eval",
|
||||
"run",
|
||||
"--candidate",
|
||||
"c",
|
||||
"--baseline",
|
||||
"b"
|
||||
])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harness_eval_bounded_input_rejects_content_over_limit() -> Result<()> {
|
||||
let tempdir = TestDir::new("harness-eval-oversized-input")?;
|
||||
let input = tempdir.path().join("measurements.json");
|
||||
fs::write(&input, b"12345")?;
|
||||
|
||||
let error = read_bounded_file(&input, 4, "recorded measurements")
|
||||
.expect_err("input larger than the byte limit must fail");
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"recorded measurements exceeds the 4-byte limit"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn harness_eval_bounded_input_rejects_non_regular_file() -> Result<()> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let tempdir = TestDir::new("harness-eval-non-regular-input")?;
|
||||
let input = tempdir.path().join("measurements.fifo");
|
||||
let input_c = CString::new(input.as_os_str().as_bytes())?;
|
||||
// SAFETY: `input_c` is a valid, NUL-terminated path and the mode is valid.
|
||||
let result = unsafe { libc::mkfifo(input_c.as_ptr(), 0o600) };
|
||||
if result != 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
let error = read_bounded_file(&input, 4, "recorded measurements")
|
||||
.expect_err("non-regular input must fail");
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"recorded measurements must be a regular file"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_policy_explicit_flags_override_config_setting() {
|
||||
let mut cfg = Config::default();
|
||||
|
||||
+1236
-83
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user