harness-lock-core 0.1.0

Core library for harness-lock: manifest, lockfile, store, tuning.
Documentation
//! The lockfile: `harness.lock`.

use std::collections::BTreeMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::{Error, Result};
use crate::{LOCK_VERSION, LOCKFILE_PATH};

pub const HEADER: &str = "# This file is generated by harness-lock. Do not edit by hand.";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LLM {
    pub role: String,
    pub provider: String,
    pub model: String,
    #[serde(rename = "resolved-from")]
    pub resolved_from: Option<String>,
    #[serde(default)]
    pub params: BTreeMap<String, serde_json::Value>,
    pub fingerprint: Option<String>,
}

impl Default for LLM {
    fn default() -> Self {
        LLM {
            role: String::new(),
            provider: "reference".into(),
            model: String::new(),
            resolved_from: None,
            params: BTreeMap::new(),
            fingerprint: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Decision {
    pub kind: String,
    pub model: Option<String>,
    pub revision: Option<String>,
    #[serde(default)]
    pub decisions: Vec<String>,
    #[serde(default)]
    pub thresholds: BTreeMap<String, f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Harness {
    pub name: String,
    pub version: Option<String>,
    pub source: Option<String>,
    pub integrity: Option<String>,
    pub adapter: Option<String>,
    pub config: Option<String>,
    pub pattern: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Tool {
    pub name: String,
    pub kind: String,
    pub source: Option<String>,
    pub integrity: Option<String>,
    #[serde(rename = "schema")]
    pub tool_schema: Option<String>,
    #[serde(default)]
    pub permissions: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Skill {
    pub name: String,
    pub origin: String,
    pub source: Option<String>,
    pub digest: Option<String>,
    pub registry: Option<String>,
    #[serde(rename = "provided-by")]
    pub provided_by: Option<String>,
    #[serde(default = "default_true")]
    pub enabled: bool,
    #[serde(default)]
    pub editable: bool,
    #[serde(rename = "evidence-digest")]
    pub evidence_digest: Option<String>,
    #[serde(default)]
    pub requires: Vec<String>,
}

fn default_true() -> bool {
    true
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Instructions {
    pub name: String,
    pub digest: Option<String>,
    pub scope: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Policy {
    pub retries: u32,
    pub tool_timeout_seconds: Option<u64>,
    pub max_steps: u32,
    pub max_cost_usd_per_task: Option<f64>,
    pub sandbox: BTreeMap<String, serde_json::Value>,
    pub human_approval: String,
    pub risky_actions: Vec<String>,
    pub tool_allowlist: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Context {
    pub isolation: String,
    pub compaction: BTreeMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Evidence {
    pub task_pack: Option<String>,
    pub task_pack_version: Option<String>,
    pub split: String,
    pub trials: usize,
    pub success_rate: BTreeMap<String, f64>,
    pub cost_usd_per_task: BTreeMap<String, f64>,
    pub latency_s_per_task: BTreeMap<String, f64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Verify {
    pub canary_tasks: usize,
    pub min_success_rate: f64,
    pub fingerprint_tolerance: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Provenance {
    pub backend: String,
    pub run: Option<String>,
    #[serde(default)]
    pub search: BTreeMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Lockfile {
    pub lock_version: i64,
    pub generated_by: String,
    pub generated_at: String,
    pub manifest_hash: Option<String>,
    #[serde(default)]
    pub stack: BTreeMap<String, serde_json::Value>,
    #[serde(default)]
    pub llm: Vec<LLM>,
    #[serde(default)]
    pub decision: Decision,
    #[serde(default)]
    pub harness: Harness,
    #[serde(default)]
    pub tool: Vec<Tool>,
    #[serde(default)]
    pub skill: Vec<Skill>,
    #[serde(default)]
    pub instructions: Vec<Instructions>,
    #[serde(default)]
    pub policy: Policy,
    #[serde(default)]
    pub context: Context,
    #[serde(default)]
    pub evidence: Evidence,
    #[serde(default)]
    pub verify: Verify,
    #[serde(default)]
    pub provenance: Provenance,
    /// Optimization-only fields (see §5.9 / `harness.opt.lock`).
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "base-lock")]
    pub base_lock: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub optimize: Option<serde_json::Value>,
}

impl Default for Lockfile {
    fn default() -> Self {
        Lockfile {
            lock_version: LOCK_VERSION,
            generated_by: format!("harness-lock {}", env!("CARGO_PKG_VERSION")),
            generated_at: String::new(),
            manifest_hash: None,
            stack: BTreeMap::new(),
            llm: Vec::new(),
            decision: Decision::default(),
            harness: Harness::default(),
            tool: Vec::new(),
            skill: Vec::new(),
            instructions: Vec::new(),
            policy: Policy::default(),
            context: Context::default(),
            evidence: Evidence::default(),
            verify: Verify::default(),
            provenance: Provenance::default(),
            base_lock: None,
            kind: None,
            optimize: None,
        }
    }
}

impl Lockfile {
    pub fn load(path: &Path) -> Result<Lockfile> {
        let text = std::fs::read_to_string(path)
            .map_err(|e| Error::Lockfile(format!("cannot read {}: {e}", path.display())))?;
        Lockfile::from_str(&text)
    }

    pub fn from_str(text: &str) -> Result<Lockfile> {
        // strip the leading comment header before parsing
        let body = text
            .lines()
            .filter(|l| !l.starts_with('#'))
            .collect::<Vec<_>>()
            .join("\n");
        let lf: Lockfile =
            toml::from_str(&body).map_err(|e| Error::Lockfile(format!("invalid harness.lock: {e}")))?;
        if lf.lock_version != LOCK_VERSION {
            return Err(Error::Lockfile(format!(
                "unsupported lock-version {} (this build supports {})",
                lf.lock_version, LOCK_VERSION
            )));
        }
        Ok(lf)
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let body = toml::to_string(self).map_err(|e| Error::Lockfile(e.to_string()))?;
        std::fs::write(path, format!("{HEADER}\n{body}"))?;
        Ok(())
    }

    pub fn default_path(&self) -> &str {
        LOCKFILE_PATH
    }

    /// Vector of manifest-drift violation strings (empty means clean).
    pub fn check_manifest(&self, live_hash: &str) -> Vec<String> {
        match &self.manifest_hash {
            None => vec!["lock has no manifest-hash".to_string()],
            Some(pinned) => {
                let pinned_hex = pinned.split(':').nth(1).unwrap_or(pinned);
                let live_hex = live_hash.split(':').nth(1).unwrap_or(live_hash);
                if pinned_hex == live_hex {
                    Vec::new()
                } else {
                    vec![format!(
                        "manifest drift: lock pinned {pinned:?}, live {live_hash:?}"
                    )]
                }
            }
        }
    }

    pub fn summary(&self) -> String {
        let model = self.llm.first().map(|l| l.model.as_str()).unwrap_or("?");
        let sr = self.evidence.success_rate.get("mean").unwrap_or(&f64::NAN);
        format!(
            "stack={:?} harness={} llm={} decision={} success-rate={:.2} trials={}",
            self.stack.get("name").and_then(serde_json::Value::as_str).unwrap_or("agent"),
            self.harness.name,
            model,
            self.decision.kind,
            sr,
            self.evidence.trials
        )
    }
}