use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::capability::{CapabilityMemoryRecord, CapabilityRequest, ToolMetrics};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PatternMetrics {
pub runs: u64,
pub successes: u64,
pub total_runtime_ms: u64,
pub total_input_bytes: usize,
pub total_output_bytes: usize,
pub consecutive_failures: u64,
}
impl PatternMetrics {
pub fn record(&mut self, metrics: &ToolMetrics) {
self.runs += 1;
if metrics.success {
self.successes += 1;
self.consecutive_failures = 0;
} else {
self.consecutive_failures += 1;
}
self.total_runtime_ms += metrics.runtime_ms;
self.total_input_bytes += metrics.input_bytes;
self.total_output_bytes += metrics.output_bytes;
}
pub fn success_rate(&self) -> f64 {
if self.runs == 0 {
return 0.0;
}
self.successes as f64 / self.runs as f64
}
pub fn avg_runtime_ms(&self) -> f64 {
if self.runs == 0 {
return 0.0;
}
self.total_runtime_ms as f64 / self.runs as f64
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryEntry {
pub record: CapabilityMemoryRecord,
pub metrics: PatternMetrics,
}
#[derive(Debug, Default)]
pub struct CapabilityMemory {
entries: Vec<MemoryEntry>,
}
impl CapabilityMemory {
pub fn new() -> Self {
Self::default()
}
pub fn lookup(&self, signature: &str) -> Option<&MemoryEntry> {
self.entries
.iter()
.find(|e| e.record.problem_signature == signature)
}
pub fn upsert(&mut self, record: CapabilityMemoryRecord, metrics: &ToolMetrics) {
if let Some(entry) = self
.entries
.iter_mut()
.find(|e| e.record.problem_signature == record.problem_signature)
{
entry.record = record;
entry.metrics.record(metrics);
} else {
let mut pm = PatternMetrics::default();
pm.record(metrics);
self.entries.push(MemoryEntry {
record,
metrics: pm,
});
}
}
pub fn save(&self, path: &Path) -> Result<(), String> {
let json = serde_json::to_string_pretty(&self.entries)
.map_err(|e| format!("serialize error: {e}"))?;
std::fs::write(path, json)
.map_err(|e| format!("failed to write memory to {}: {e}", path.display()))?;
Ok(())
}
pub fn load(path: &Path) -> Result<Self, String> {
if !path.exists() {
return Ok(Self::new());
}
let json = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read memory from {}: {e}", path.display()))?;
let entries: Vec<MemoryEntry> = serde_json::from_str(&json)
.map_err(|e| format!("failed to parse memory file {}: {e}", path.display()))?;
Ok(Self { entries })
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn derive_signature(req: &CapabilityRequest) -> String {
let cap = req.capability.to_lowercase().replace(' ', "_");
let inp = shape_token(&req.input_contract);
let out = shape_token(&req.output_contract);
format!("{cap}:{inp}:{out}")
}
}
fn shape_token(contract: &str) -> String {
contract
.split_whitespace()
.take(3)
.map(|w| {
w.to_lowercase()
.trim_matches(|c: char| !c.is_alphanumeric())
.to_string()
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::CapabilityConstraints;
fn make_record(sig: &str) -> CapabilityMemoryRecord {
CapabilityMemoryRecord {
problem_signature: sig.into(),
solution_pattern: "mock".into(),
input_shape: "utf8_lines".into(),
output_shape: "json_counts".into(),
constraints: CapabilityConstraints::default(),
}
}
fn ok_metrics() -> ToolMetrics {
ToolMetrics {
runtime_ms: 10,
input_bytes: 100,
output_bytes: 50,
success: true,
}
}
#[test]
fn lookup_returns_none_when_empty() {
let mem = CapabilityMemory::new();
assert!(mem.lookup("anything").is_none());
}
#[test]
fn upsert_then_lookup() {
let mut mem = CapabilityMemory::new();
mem.upsert(make_record("test:sig"), &ok_metrics());
assert!(mem.lookup("test:sig").is_some());
}
#[test]
fn upsert_accumulates_metrics() {
let mut mem = CapabilityMemory::new();
mem.upsert(make_record("sig"), &ok_metrics());
mem.upsert(make_record("sig"), &ok_metrics());
let entry = mem.lookup("sig").unwrap();
assert_eq!(entry.metrics.runs, 2);
assert_eq!(entry.metrics.successes, 2);
assert_eq!(entry.metrics.total_runtime_ms, 20);
}
#[test]
fn upsert_different_sigs_stored_separately() {
let mut mem = CapabilityMemory::new();
mem.upsert(make_record("a"), &ok_metrics());
mem.upsert(make_record("b"), &ok_metrics());
assert_eq!(mem.len(), 2);
}
#[test]
fn success_rate_correct() {
let mut pm = PatternMetrics::default();
pm.record(&ToolMetrics {
success: true,
..Default::default()
});
pm.record(&ToolMetrics {
success: false,
..Default::default()
});
assert!((pm.success_rate() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn derive_signature_is_stable() {
let req = CapabilityRequest {
kind: "capability_request".into(),
capability: "stream_parse_logs".into(),
input_contract: "UTF-8 log lines from stdin".into(),
output_contract: "JSON array of matching events".into(),
constraints: CapabilityConstraints::default(),
reason: "test".into(),
};
let s1 = CapabilityMemory::derive_signature(&req);
let s2 = CapabilityMemory::derive_signature(&req);
assert_eq!(s1, s2);
assert!(s1.starts_with("stream_parse_logs:"));
}
#[test]
fn derive_signature_different_contracts_differ() {
let req_a = CapabilityRequest {
kind: "capability_request".into(),
capability: "parse".into(),
input_contract: "utf8 text".into(),
output_contract: "json counts".into(),
constraints: CapabilityConstraints::default(),
reason: "r".into(),
};
let req_b = CapabilityRequest {
input_contract: "binary blob".into(),
..req_a.clone()
};
assert_ne!(
CapabilityMemory::derive_signature(&req_a),
CapabilityMemory::derive_signature(&req_b)
);
}
#[test]
fn save_and_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("memory.json");
let mut mem = CapabilityMemory::new();
mem.upsert(make_record("word_count:utf8:json"), &ok_metrics());
mem.upsert(
make_record("word_count:utf8:json"),
&ToolMetrics {
success: false,
..Default::default()
},
);
mem.save(&path).unwrap();
let loaded = CapabilityMemory::load(&path).unwrap();
assert_eq!(loaded.len(), 1);
let entry = loaded.lookup("word_count:utf8:json").unwrap();
assert_eq!(entry.metrics.runs, 2);
assert_eq!(entry.metrics.successes, 1);
assert_eq!(entry.metrics.consecutive_failures, 1);
}
#[test]
fn load_nonexistent_path_returns_empty() {
let path = std::path::Path::new("/tmp/sbh-memory-does-not-exist-xyz.json");
let mem = CapabilityMemory::load(path).unwrap();
assert!(mem.is_empty());
}
#[test]
fn load_corrupt_file_returns_err() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, b"not valid json [[{{").unwrap();
assert!(CapabilityMemory::load(&path).is_err());
}
#[test]
fn save_preserves_consecutive_failures() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mem.json");
let mut mem = CapabilityMemory::new();
let fail = ToolMetrics {
success: false,
..Default::default()
};
mem.upsert(make_record("sig"), &fail);
mem.upsert(make_record("sig"), &fail);
mem.save(&path).unwrap();
let loaded = CapabilityMemory::load(&path).unwrap();
assert_eq!(
loaded.lookup("sig").unwrap().metrics.consecutive_failures,
2
);
}
}