choir_queue/speculate.rs
1//! The speculation seam (D5): what it means to put one change on top of
2//! another.
3//!
4//! [`MergeQueue`](crate::MergeQueue) is a policy — a window that grows
5//! by one and halves on failure, a dependency-aware blast radius, a
6//! refusal to land the same change twice, and a rule that only a real
7//! test failure evicts anybody. None of that policy is about text. It
8//! was written against text anyway, because the queue called
9//! [`choir_merge::Pipeline`] directly and [`crate::Change`] carried two
10//! file bodies, which is the Phase-0 abstraction its doc comment always
11//! named. The consequence was structural rather than cosmetic: nothing
12//! holding a repository could construct a queue, so the queue was
13//! composed into nothing and its test numbers were a simulation of a
14//! train rather than a train.
15//!
16//! A state here is an opaque [`String`]. The text implementation reads
17//! it as file content; the git one reads it as a commit id. The queue
18//! reads it as neither — it moves states around, hands them to CI, and
19//! never looks inside one. That is the whole trick, and it is why this
20//! seam needs no generic parameter and no change to
21//! [`crate::Change`].
22//!
23//! Per the house rule, a seam is real when a conformance suite and a
24//! second implementation both pass: [`TextSpeculator`] is the extracted
25//! original and [`crate::git::GitSpeculator`] is the second.
26
27use choir_hash::ContentHash;
28use choir_merge::{safety, MergeOutcome, Pipeline};
29
30use crate::Change;
31
32/// What a speculator did with one change.
33#[derive(Debug)]
34pub enum Step {
35 /// The change applied. This is the new speculative state.
36 Advanced(String),
37 /// First-class conflict: the change is evicted for its author to
38 /// resolve, and the train continues without it (D6).
39 Conflict,
40 /// A resolution applied edits the change never proposed.
41 ///
42 /// Distinct from [`Step::Conflict`] because the author's change may
43 /// be perfectly fine and the *strategy* at fault, which is a
44 /// different escalation. An implementation with a single
45 /// non-negotiable merge rule — git's — can never return this, and
46 /// saying so is more useful than pretending the case is shared.
47 Unsafe {
48 /// The strategy whose resolution violated the invariant.
49 strategy: &'static str,
50 /// The unattributable lines, as evidence for escalation.
51 violation: safety::Violation,
52 },
53 /// The speculator could not form an opinion: a broken worktree, a
54 /// state that is not a state, git not on the path.
55 ///
56 /// Not a conflict and not a failure. Blaming an author for our own
57 /// inability to run a merge is the mistake D18's four verdicts
58 /// exist to prevent, one level up; the queue stalls on this exactly
59 /// as it stalls on a provider fault, and nobody is evicted.
60 Unavailable(String),
61}
62
63/// How the queue puts one change on top of a speculative state.
64///
65/// Implementations own three things the queue deliberately does not
66/// know: what a merge is, when two changes are the same change, and how
67/// a state is named to CI.
68pub trait Speculator: Send {
69 /// Stable identifier, for reports and errors.
70 fn name(&self) -> &'static str;
71
72 /// Merges `proposed` — authored against `base` — onto `onto`.
73 ///
74 /// `base` is passed rather than derived because a 3-way text merge
75 /// has no way to find it, and merging against the train's advanced
76 /// tip instead would misread the proposal as reverting everything
77 /// merged ahead of it. An implementation that can find its own
78 /// merge base is free to ignore the argument, and git's does.
79 fn step(&mut self, base: &str, onto: &str, proposed: &str) -> Step;
80
81 /// A position-independent identity for `change`.
82 ///
83 /// The same logical edit authored against two different bases —
84 /// before and after the train rewrote the tip under it — must
85 /// produce the same identity, or a rebased resubmission lands
86 /// twice.
87 fn identity(&self, change: &Change) -> String;
88
89 /// The content hash naming `state`.
90 ///
91 /// This is what CI is asked about and what a landing records as the
92 /// workspace head, so the two cannot disagree about which tree a
93 /// verdict was for. It is not a hash of *our* choosing: a git state
94 /// must be named by its git oid, or every check written down is
95 /// about an id no git client can resolve.
96 fn subject(&self, state: &str) -> ContentHash;
97}
98
99/// The original speculator: one file's content, merged 3-way.
100///
101/// Every state is a full file body, and `base`/`onto`/`proposed` are
102/// the three sides of [`choir_merge::Pipeline::merge`]. The safety
103/// check that policed strategies stays here, because it is a statement
104/// about lines and has no meaning for an implementation whose states
105/// are not text.
106pub struct TextSpeculator {
107 pipeline: Pipeline,
108}
109
110impl TextSpeculator {
111 /// A speculator over the given strategy pipeline — the D4/D19
112 /// widening seam, with structured or LLM slots appended by the
113 /// caller. Every resolution is still safety-checked here, which is
114 /// what makes the non-deterministic slots admissible at all.
115 #[must_use]
116 pub fn new(pipeline: Pipeline) -> Self {
117 Self { pipeline }
118 }
119}
120
121impl Default for TextSpeculator {
122 fn default() -> Self {
123 Self::new(Pipeline::default_v1())
124 }
125}
126
127impl Speculator for TextSpeculator {
128 fn name(&self) -> &'static str {
129 "text"
130 }
131
132 fn step(&mut self, base: &str, onto: &str, proposed: &str) -> Step {
133 let resolution = self.pipeline.merge(base, onto, proposed);
134 match resolution.outcome {
135 MergeOutcome::Resolved(next) => {
136 // A resolution may only apply edits the change
137 // proposed. A strategy that quietly reverts work
138 // already in the speculative state is evicted like a
139 // conflict, before CI ever sees it.
140 match safety::check(base, onto, proposed, &next) {
141 safety::SafetyVerdict::Upholds => Step::Advanced(next),
142 safety::SafetyVerdict::Violation(violation) => Step::Unsafe {
143 strategy: resolution.strategy,
144 violation,
145 },
146 }
147 }
148 MergeOutcome::Conflict { .. } => Step::Conflict,
149 // `Pipeline::merge` returns the last conflict rather than
150 // an unavailable strategy; it panics before it can hand one
151 // back.
152 MergeOutcome::Unavailable(_) => unreachable!(),
153 }
154 }
155
156 fn identity(&self, change: &Change) -> String {
157 crate::identity::change_identity(change)
158 }
159
160 fn subject(&self, state: &str) -> ContentHash {
161 ContentHash::blake3(state.as_bytes())
162 }
163}