Skip to main content

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}