choir_queue/memory.rs
1//! Resolution memory keyed by the conflict triple (rerere-shaped; DECISIONS.md D15: metadata on existing shapes, never a new
2//! merge substrate).
3//!
4//! A conflict's identity already exists in the model: the
5//! `(base, left, right)` manifest-address triple of a
6//! [`TreeEntry::Conflict`]. The memory key is the hash of that entry's
7//! canonical serialization — the exact bytes invariant 3 freezes — so
8//! keying adds no second identity scheme. Resolutions are found through
9//! item A's `Commit::resolves` links: a head-moving op whose commit
10//! links a conflicted commit contributes one remembered resolution per
11//! path that was conflicted there and is a plain file in the resolver.
12//!
13//! A recalled resolution is a **candidate, never a landing**: the queue
14//! replays it into the train, where it runs the same CI verdict as any
15//! other member. It deliberately skips the strategy safety check —
16//! that check polices *strategies* (a resolution must stay inside what
17//! the change proposed), while a genuine conflict resolution edits
18//! beyond both sides by construction and was already committed as a
19//! value by its author. What the replay skips is only the re-derivation
20//! of a resolution the log already holds.
21
22use std::collections::BTreeMap;
23
24use choir_hash::ContentHash;
25use choir_oplog::OpLog;
26use choir_store::{get_blob, put_blob, ChunkStore, ChunkerParams, MemStore};
27use choir_view::{Commit, OpKind, TreeEntry, ViewOp};
28
29/// Previously seen conflict resolutions, keyed by the conflict triple.
30#[derive(Debug, Default)]
31pub struct ResolutionMemory {
32 /// Triple key (hex of the hashed canonical `TreeEntry::Conflict`)
33 /// → the resolved file content.
34 map: BTreeMap<String, String>,
35}
36
37impl ResolutionMemory {
38 /// An empty memory: every lookup misses, the queue behaves as before.
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 /// Number of remembered resolutions.
44 pub fn len(&self) -> usize {
45 self.map.len()
46 }
47
48 /// Whether nothing has been remembered.
49 pub fn is_empty(&self) -> bool {
50 self.map.is_empty()
51 }
52
53 /// The conflict's identity: the hash of the canonical serialization
54 /// of the `(base, left, right)` triple, spelled as the
55 /// [`TreeEntry::Conflict`] those addresses came from. Reusing the
56 /// persisted shape's bytes means the key inherits invariant 3's
57 /// canonicalization for free and cannot drift from the store's own
58 /// identity for the same conflict.
59 ///
60 /// Contents are addressed with default chunker params; a resolution
61 /// recorded under different params keys differently and simply
62 /// misses — the memory is an optimization, a miss re-conflicts.
63 pub fn key(base: &str, left: &str, right: &str) -> String {
64 let mut scratch = MemStore::new();
65 let mut addr = |text: &str| {
66 put_blob(&mut scratch, text.as_bytes(), ChunkerParams::default())
67 .expect("MemStore writes cannot fail")
68 };
69 let entry = TreeEntry::Conflict {
70 base: Some(addr(base)),
71 left: addr(left),
72 right: addr(right),
73 };
74 let bytes = serde_json::to_vec(&entry).expect("TreeEntry always serializes");
75 ContentHash::blake3(&bytes).to_hex()
76 }
77
78 /// Builds the memory by walking `log` for head-moving ops whose
79 /// commit carries a `resolves` link, and reading both sides from
80 /// `store`. Anything the store cannot supply is skipped — an
81 /// incomplete memory only means fewer recalls.
82 pub fn from_log(log: &dyn OpLog, store: &dyn ChunkStore) -> Self {
83 let mut map = BTreeMap::new();
84 for seq in 0..log.len() {
85 let Some(entry) = log.get(seq) else { continue };
86 let Ok(op) = ViewOp::from_payload(&entry.payload) else {
87 continue;
88 };
89 let commit_id = match &op.kind {
90 OpKind::SetWorkspaceHead { commit, .. } | OpKind::SetRef { commit, .. } => commit,
91 _ => continue,
92 };
93 let Ok(resolver) = Commit::get(store, commit_id) else {
94 continue;
95 };
96 let Some(conflict_id) = &resolver.resolves else {
97 continue;
98 };
99 let Ok(conflicted) = Commit::get(store, conflict_id) else {
100 continue;
101 };
102 for (path, entry) in &conflicted.tree {
103 let TreeEntry::Conflict { .. } = entry else {
104 continue;
105 };
106 let Some(TreeEntry::File { blob }) = resolver.tree.get(path) else {
107 continue;
108 };
109 let Ok(bytes) = get_blob(store, blob) else {
110 continue;
111 };
112 let Ok(text) = String::from_utf8(bytes) else {
113 continue;
114 };
115 let key_bytes = serde_json::to_vec(entry).expect("TreeEntry always serializes");
116 map.insert(ContentHash::blake3(&key_bytes).to_hex(), text);
117 }
118 }
119 Self { map }
120 }
121
122 /// The remembered resolution for this triple, if any.
123 pub fn recall(&self, base: &str, left: &str, right: &str) -> Option<&str> {
124 self.map
125 .get(&Self::key(base, left, right))
126 .map(String::as_str)
127 }
128}