metrale_governance/
ledger.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Reading, appending and materialising a journey file.
4//!
5//! Owner: metrale-governance.
6//! Invariants:
7//! - [`append`] only appends; it never truncates or rewrites a file.
8//! - [`read_all`] returns every non-blank line or an error; it never drops a
9//!   line it cannot parse.
10
11use 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/// 2026-09-26: The events of one journey file, in file order.
21#[derive(Debug, Clone, Default)]
22pub struct Journey {
23    pub events: Vec<Event>,
24}
25
26impl Journey {
27    /// 2026-09-26: Deduplicate by [`Event::identity`], keeping the first
28    /// occurrence and the order.
29    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    /// 2026-09-26: The `Gate` events with this id, in `events` order.
36    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
43/// 2026-09-26: Append one event as a JSON line, creating the file and its
44/// parent directories if needed. Callers pass [`path_for`], one file per pull
45/// request.
46pub 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
60/// 2026-09-26: Read a journey, skipping blank lines. Does not deduplicate.
61///
62/// # Errors
63/// When the file cannot be opened or read, or any non-blank line does not
64/// parse as an [`Event`]; the message names the line number.
65pub 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
81/// 2026-09-26: Vector width of the materialised collection. Every point gets
82/// the constant vector `[1.0]`: only the graph is used, and the vectors are
83/// not embeddings.
84const PLACEHOLDER_DIM: usize = 1;
85
86/// 2026-09-26: Build an in-memory graph from a journey: one node per distinct
87/// head sha plus one per event, and an `observed` edge from each commit node to
88/// each of its events. Nothing is written to disk.
89pub 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    // 2026-09-26: Commits take ids `0..n` in sorted-sha order; events take
108    // `n..` in journey order. A new sha that sorts earlier shifts every id.
109    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
149/// 2026-09-26: `<root>/governance/pr-<pr>.jsonl`.
150pub fn path_for(root: &Path, pr: u64) -> std::path::PathBuf {
151    root.join("governance").join(format!("pr-{pr}.jsonl"))
152}