metrale_governance/
ledger.rs1use std::collections::BTreeSet;
12use std::io::{BufRead, BufReader, Write};
13use std::path::Path;
14
15use anyhow::{Context, Result};
16use lattice_core::{CollectionConfig, CollectionEngine, Distance, HnswConfig, Point, VectorConfig};
17
18use crate::event::Event;
19
20#[derive(Debug, Clone, Default)]
22pub struct Journey {
23 pub events: Vec<Event>,
24}
25
26impl Journey {
27 pub fn deduplicated(mut self) -> Self {
30 let mut seen = BTreeSet::new();
31 self.events.retain(|e| seen.insert(e.identity()));
32 self
33 }
34
35 pub fn gate_history<'a>(&'a self, gate: &'a str) -> impl Iterator<Item = &'a Event> {
37 self.events.iter().filter(
38 move |e| matches!(&e.kind, crate::event::EventKind::Gate { id, .. } if id == gate),
39 )
40 }
41}
42
43pub fn append(path: &Path, event: &Event) -> Result<()> {
47 if let Some(parent) = path.parent() {
48 std::fs::create_dir_all(parent)
49 .with_context(|| format!("creating {}", parent.display()))?;
50 }
51 let line = serde_json::to_string(event).context("encoding journey event")?;
52 let mut file = std::fs::OpenOptions::new()
53 .create(true)
54 .append(true)
55 .open(path)
56 .with_context(|| format!("opening {} for append", path.display()))?;
57 writeln!(file, "{line}").with_context(|| format!("appending to {}", path.display()))
58}
59
60pub fn read_all(path: &Path) -> Result<Journey> {
66 let file = std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
67 let mut events = Vec::new();
68 for (n, line) in BufReader::new(file).lines().enumerate() {
69 let line = line.with_context(|| format!("reading {} line {}", path.display(), n + 1))?;
70 if line.trim().is_empty() {
71 continue;
72 }
73 events.push(
74 serde_json::from_str(&line)
75 .with_context(|| format!("parsing {} line {}", path.display(), n + 1))?,
76 );
77 }
78 Ok(Journey { events })
79}
80
81const PLACEHOLDER_DIM: usize = 1;
85
86pub fn materialize(journey: &Journey) -> Result<CollectionEngine> {
90 let config = CollectionConfig::new(
91 "journey",
92 VectorConfig::new(PLACEHOLDER_DIM, Distance::Cosine),
93 HnswConfig {
94 m: 16,
95 m0: 32,
96 ml: HnswConfig::recommended_ml(16),
97 ef: 100,
98 ef_construction: 200,
99 },
100 )
101 .with_relation("observed", 0)
102 .with_relation("precedes", 1);
103
104 let mut engine =
105 CollectionEngine::new(config).map_err(|e| anyhow::anyhow!("creating collection: {e}"))?;
106
107 let mut commits: Vec<&str> = journey.events.iter().map(|e| e.head_sha.as_str()).collect();
110 commits.sort_unstable();
111 commits.dedup();
112
113 let mut points = Vec::new();
114 for (i, sha) in commits.iter().enumerate() {
115 points.push(
116 Point::new_vector(i as u64, vec![1.0; PLACEHOLDER_DIM])
117 .with_field("label", br#""commit""#.to_vec())
118 .with_field("sha", serde_json::to_vec(sha).unwrap_or_default()),
119 );
120 }
121 let commit_base = commits.len() as u64;
122 for (i, event) in journey.events.iter().enumerate() {
123 points.push(
124 Point::new_vector(commit_base + i as u64, vec![1.0; PLACEHOLDER_DIM])
125 .with_field(
126 "label",
127 serde_json::to_vec(event.node_label()).unwrap_or_default(),
128 )
129 .with_field("at", serde_json::to_vec(&event.at).unwrap_or_default())
130 .with_field("kind", serde_json::to_vec(&event.kind).unwrap_or_default()),
131 );
132 }
133 engine
134 .upsert_points(points)
135 .map_err(|e| anyhow::anyhow!("upserting journey points: {e}"))?;
136
137 for (i, event) in journey.events.iter().enumerate() {
138 let Ok(commit_idx) = commits.binary_search(&event.head_sha.as_str()) else {
139 continue;
140 };
141 engine
142 .add_edge(commit_idx as u64, commit_base + i as u64, "observed", 1.0)
143 .map_err(|e| anyhow::anyhow!("adding observed edge: {e}"))?;
144 }
145
146 Ok(engine)
147}
148
149pub fn path_for(root: &Path, pr: u64) -> std::path::PathBuf {
151 root.join("governance").join(format!("pr-{pr}.jsonl"))
152}