Skip to content

Commit cc49cdf

Browse files
committed
docs(rfc): speculation path generation as a best-first tree walk
## Summary ### Why? The default Generator's design lives only in the bestfirst package README, expressed in the implementation's own flip-based vocabulary (preferred sides, flip costs, relative scores). We plan to rewrite the generator around a forward decision-tree formulation, and that design deserves review as a decision doc before any code changes — with the current flip-based implementation recorded as the alternative considered. ### What? Adds doc/rfc/submitqueue/speculation-generator.md: a plain-English RFC for path generation as a best-first walk over per-batch binary speculation trees. It fixes the vocabulary (DAG and edge for the input; tree, root, decision, node, leaf, and path for the search), grounds everything in one worked queue — a conflict chain A ← B ← C plus an independent D — with literal tree diagrams and raw path probabilities, derives each batch's ancestors by walking the DAG of direct conflict edges (batches store direct conflicts only, so tree levels come from the walk, not from a stored flattened list), states the algorithm as four plain steps and traces it, separates path probability from the stored logarithmic ranking value, walks the same queue across two runs to show known outcomes shrinking the trees while path identity survives, defines edge cases and the deterministic tie order, and records the current flip-based formulation as the alternative considered. speculation.md links to the new RFC from the Generator bullet and the extension-API pointers.
1 parent 93573ae commit cc49cdf

2 files changed

Lines changed: 173 additions & 2 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Speculation Path Generation
2+
3+
## Summary
4+
5+
The merge queue can build a batch early by assuming whether each of its dependencies will succeed or fail. These assumptions create many possible build paths, but CI can run only a few. The default generator returns the most likely paths first and stops when the build budget is full.
6+
7+
It does that by treating each batch's possible paths as a binary decision tree and always extending the most probable partial path, across all batches at once. Paths come out in probability order, and only the paths actually requested are ever created. In the architecture of [speculation.md](speculation.md), this is the Generator inside the default Speculator.
8+
9+
## The problem
10+
11+
A batch waiting on n undecided batches ahead of it has one possible path per combination of outcomes — 2ⁿ in all. A batch ten deep in a conflict chain has 1,024 possible futures, and CI can afford perhaps two or three of them. The rest of the system asks the generator for candidates one at a time, best first, and stops asking once the build budget is full.
12+
13+
So the generator must return paths in order of how likely they are to be useful, never return the same path twice, never return a path that contradicts a known outcome, and do work proportional to the number of paths requested — not to 2ⁿ.
14+
15+
## The queue in this doc
16+
17+
One worked example runs through the whole document. Four batches are in flight. A, B, and C form a conflict chain; D conflicts with nothing:
18+
19+
```
20+
main ◀── A ◀── B ◀── C B conflicts with A, and C conflicts with B —
21+
each batch records only its direct conflicts
22+
D no conflicts — D depends on nothing
23+
```
24+
25+
This is the **DAG**: the queue's batches and the dependency **edges** between them ("edge" is a DAG term only — a direct conflict making one batch depend on another). Each batch stores only its **direct** conflicts — C's stored list is just `[B]` — so longer chains exist only as chains of edges. The generator builds the DAG from those edges each run and walks it from the roots down to find each batch's **ancestors**: every batch that can reach it along dependency edges. For C that walk collects B directly and, through B, A. The DAG says *what is undecided*; it is the input, not the thing searched.
26+
27+
A pluggable scorer estimates how likely each batch's build is to succeed. Only batches that appear as someone's dependency are ever asked about — A and B here; nothing depends on C or D, so their probabilities never matter:
28+
29+
| dependency | P(succeeds) |
30+
| --- | --- |
31+
| A | 0.9 |
32+
| B | 0.8 |
33+
34+
## The decision: one tree per batch
35+
36+
Each batch being evaluated gets its own **speculation tree** — a binary tree with one level per *ancestor* in the DAG, in queue order. Walking the DAG from its roots downward is what produces the levels: a batch's ancestors are always earlier in the queue, so the walk visits them in the order the tree decides them. (speculation.md calls the batch a path builds the path's *head*.)
37+
38+
C makes the point: it conflicts directly only with B, yet its tree has a level for A. Which B there is to stack on depends on A — B built with A and B built without A are different changes — so A's outcome reaches C through the chain even though they never touch the same code. One assumption per ancestor is what makes a path self-contained: it pins the exact stack of changes the build sits on, with no reference to any other path.
39+
40+
- The **root** is the batch with no decisions taken yet. Its **path probability** is **1**: nothing has been assumed, so nothing can be wrong.
41+
- A **decision** is the binary choice at a level: the ancestor at that level *succeeds* or *fails*. Taking a decision multiplies the path probability by that side's chance — p for succeeds, 1−p for fails.
42+
- A **node** is a partial path: the batch plus the decisions taken so far.
43+
- A **leaf** is a node with every level decided.
44+
- A **path** is the root-to-leaf decision sequence — the tree-side term for what the code records as a `SpeculationPath`. Its path probability is the probability that every assumption on it holds.
45+
46+
The queue above has four trees, and their shapes fall straight out of each batch's ancestors:
47+
48+
```
49+
batch A — no ancestors batch B — ancestor A batch C — ancestors A and B
50+
51+
{} 1.0 {} 1.0 {} 1.0
52+
┌───────┴───────┐ ┌──────────┴──────────┐
53+
the root is already A✓ ×0.9 A✗ ×0.1 A✓ ×0.9 A✗ ×0.1
54+
a leaf: one path, │ │ │ │
55+
no assumptions 0.9 0.1 0.9 0.1
56+
leaf leaf ┌──────┴──────┐ ┌──────┴──────┐
57+
batch D — same as A: B✓ ×0.8 B✗ ×0.2 B✓ ×0.8 B✗ ×0.2
58+
its tree is one node, │ │ │ │
59+
one path, probability 1 0.72 0.18 0.08 0.02
60+
```
61+
62+
Read A's tree first, because it answers the question the notation invites. A has no ancestors, so there is nothing to decide: the root *is* a leaf, and A has exactly one path — build A directly, assume nothing, probability 1. The `{}` at every root means "no decisions taken *yet*", not "assumed empty dependencies"; for A the two coincide because nothing in the queue is ahead of it. D is the same.
63+
64+
B's tree has one level: build on top of A (`[A✓]`, 0.9) or build without A (`[A✗]`, 0.1). C's has two levels and four leaves — its four possible futures, from `[A✓ B✓]` at 0.72 down to `[A✗ B✗]` at 0.02. Nobody builds these trees up front — they exist so the next section can walk them.
65+
66+
## How generation works
67+
68+
The generator keeps one list of partial paths, ordered by path probability, holding every tree's frontier at once. It starts with just the four roots on the list, then repeats four plain steps each time a candidate is requested:
69+
70+
1. Take the most probable partial path off the list.
71+
2. If every one of its ancestors is decided, it is complete — return it as the next candidate.
72+
3. Otherwise, take its next undecided ancestor both ways: one copy assuming *succeeds*, one assuming *fails*, each with its probability multiplied in.
73+
4. Put both copies back on the list and go to step 1.
74+
75+
The ordered list is a priority queue in the implementation; nothing else is needed. Traced on the example queue (a node is written as its batch plus the decisions taken; the four roots tie at 1.0 and order by batch ID, per the ties section):
76+
77+
| step | taken (probability) | put back | returned |
78+
| --- | --- | --- | --- |
79+
| 1 | `A {}` (1.0) || **A: `[]`** |
80+
| 2 | `B {}` (1.0) | `B [A✓]` (0.9), `B [A✗]` (0.1) ||
81+
| 3 | `C {}` (1.0) | `C [A✓]` (0.9), `C [A✗]` (0.1) ||
82+
| 4 | `D {}` (1.0) || **D: `[]`** |
83+
| 5 | `B [A✓]` (0.9) || **B: `[A✓]`** |
84+
| 6 | `C [A✓]` (0.9) | `C [A✓B✓]` (0.72), `C [A✓B✗]` (0.18) ||
85+
| 7 | `C [A✓B✓]` (0.72) || **C: `[A✓ B✓]`** |
86+
87+
Four candidates out — the unconditional builds of A and D, B stacked on A, C stacked on both — in exactly the order a human would fund them. If the caller kept asking, the rest would follow in descending order: 0.18, 0.1, 0.08, 0.02.
88+
89+
The point is what happens when the caller *stops*. After seven steps the list holds `C [A✓B✗]` (0.18), `B [A✗]` (0.1), and `C [A✗]` (0.1), and nothing else exists. `C [A✗]` is a single uncreated-below node standing in for C's entire without-A subtree — two paths, neither created, both reachable later if the caller keeps asking and skipped forever if it does not. The succeed-or-fail choice at each level is made only when a partial path is actually taken off the list — as generation progresses, never speculatively ahead of it.
90+
91+
The guarantees, each with its one-line reason:
92+
93+
- **Candidates are returned in non-increasing probability.** Every decision multiplies by a probability ≤ 1, so extending a partial path never raises it; taking the most probable entry first means no later path can beat an earlier one.
94+
- **Every combination exactly once.** Each partial path is created exactly once (by extending its unique parent), so continuing until no candidates remain returns every tree's every leaf, none twice.
95+
- **Work is proportional to what is requested.** Returning one path costs at most one step per tree level; nothing below an untaken node is ever created.
96+
- **Impossible decisions are not silently dropped.** A side with probability 0 makes its whole subtree probability 0 — it sorts after every possible path, but is still returned if the caller keeps asking. Nothing is discarded.
97+
98+
## Path probability vs. the stored ranking value
99+
100+
Everything above ranks by path probability, and that ranking is exactly what the implementation preserves — but it cannot compute the products literally. A batch with 7,100 ancestors at 0.9 each has a best path probability of 0.9⁷¹⁰⁰ ≈ 10⁻³²⁵, which is below the smallest number float64 can represent: the product rounds to exactly 0, every path of that batch ties at zero, and the order is lost.
101+
102+
So the implementation never stores the probability itself. It stores the probability's **logarithm** as the **ranking value**, which turns products into sums:
103+
104+
```
105+
log(p₁ × p₂ × ⋯ × pₙ) = log p₁ + log p₂ + ⋯ + log pₙ
106+
```
107+
108+
Because log is strictly increasing, comparing ranking values orders paths *identically* to comparing the path probabilities themselves — and the sums stay comfortably finite: that wide batch's best ranking value is 7,100 × log 0.9 ≈ −748, an ordinary float64. Mapping C's tree once:
109+
110+
| node | path probability | ranking value (log) |
111+
| --- | --- | --- |
112+
| `C {}` (root) | 1.0 | 0 |
113+
| `C [A✓]` | 0.9 | −0.105 |
114+
| `C [A✓B✓]` | 0.72 | −0.328 |
115+
116+
Multiplying in a decision's probability becomes adding its log — a number ≤ 0, which restates "extending a partial path never raises it". A probability of 0 becomes −∞, which sorts below every finite value, matching the impossible-decision rule above.
117+
118+
The ranking value is what a candidate carries out of the generator. It orders candidates within one run and means nothing across runs — the next run rescores from scratch.
119+
120+
## Example across two runs
121+
122+
The generator keeps no state between runs: each run builds trees from the queue's current state, and what changed since last time shows up as smaller trees. (What the rest of the system does with the candidates — funding, cancelling, merging — is covered in [speculation.md](speculation.md).)
123+
124+
**Run 1.** The queue is as above; the four most probable paths are the ones the trace returned:
125+
126+
```
127+
A: [] 1.0 · D: [] 1.0 · B: [A✓] 0.9 · C: [A✓ B✓] 0.72
128+
```
129+
130+
**Between runs.** A's build fails. A had one unconditional path, so no other future exists in which it passes: A's outcome is now known.
131+
132+
**Run 2.** A is no longer a batch being evaluated, so it gets no tree. As an *ancestor* it is now a fact, not a guess: every path fixes A to the known outcome (*fails*), and A's level disappears from B's and C's trees:
133+
134+
```
135+
batch B — A fixed to fails batch C — A fixed to fails, one level left
136+
137+
{} 1.0 {} 1.0
138+
┌───────┴───────┐
139+
root is now a leaf: B✓ ×0.8 B✗ ×0.2
140+
the one path, [A✗], │ │
141+
probability 1.0 0.8 0.2
142+
leaf leaf
143+
```
144+
145+
The first candidates returned are `B: [A✗]` and `D: []`, both at probability 1.0, then `C: [A✗ B✓]` at 0.8. Look at B's closely: it is the *same path* that had probability 0.1 in run 1 — the assumptions are identical, so its identity is identical — but what was a long shot is now a certainty, because A's failure stopped being a probability and became a fact. Probabilities mean nothing across runs; a path's identity is what survives, which is how the rest of the system recognizes paths it already acted on. And no path returned in run 2 contradicts the known outcome: the `[A✓]` halves of both trees are simply gone.
146+
147+
Rebuilding from scratch each run is cheap by construction: every ancestor whose outcome becomes known shrinks the affected trees by a level, and within a run only the partial paths actually taken off the list ever exist.
148+
149+
## Edge cases and ties
150+
151+
Trees are built over what is genuinely undecided; everything else is settled before generation starts.
152+
153+
- **Ancestors are derived every run, never stored.** A batch's stored dependency list holds its direct conflicts only; the generator rebuilds the DAG from the run's snapshot and walks it to collect each batch's ancestors. Nothing persists a flattened chain — the DAG's edges are the single source of truth, and a chain that changes shape between runs (a batch resolving, a batch cancelled away) is simply walked differently next time.
154+
- **An ancestor whose outcome is known** is fixed to it — Succeeded fixes *succeeds*; Failed or Cancelled fixes *fails* — and its level drops out of the affected trees, exactly as A's did in run 2. An ancestor that is still being cancelled is not yet known and keeps its level.
155+
- **An ancestor absent from the run's snapshot** cannot be scored. Each run hands the generator the batches the controller read, and a referenced batch ID with no batch among them has nothing to score — and no edges of its own, so the walk also stops there. It contributes one level with a fixed default probability of succeeding, 0.95, a constant in the generator chosen on the observation that nearly every batch a queue accepts does build successfully; it only affects ranking, never which paths exist.
156+
- **Each unique ancestor is scored once per run**, however many trees it appears in. In the example, A is a level in both B's and C's trees but costs one scorer call; the whole queue costs two (A and B — nothing depends on C or D, so they are never scored). Returning candidates triggers no further scoring: by the first request, every probability the run needs is known.
157+
- **Exact ties need a fixed order**, or two runs over the same queue could propose different paths. Ties break, in order: higher probability, then fewer decisions taken, then the path that stays on likelier sides longer (at exactly 0.5, succeeds counts as likelier), then the batch's ID. The trace already used the last rule twice: the four roots tie at 1.0 and order as A, B, C, D; `B [A✓]` and `C [A✓]` tie at 0.9 and B goes first. The likelier-sides rule matters for batches whose decisions tie: two batches each with one ancestor at exactly 0.5 return as *first batch's likelier path, second batch's likelier path, first's other, second's other* — the batches interleave, so one batch's tied subtree cannot absorb the whole budget before the other is offered at all. Because each partial path is created exactly once, no two distinct entries ever compare fully equal: every step has a unique winner, so repeated runs agree.
158+
159+
## Alternative considered: flip-based generation
160+
161+
The current implementation reaches the same output from the opposite direction: it starts from each batch's single most likely path and produces every other path as a set of deviations ("flips") from it, ranked by what each flip costs in probability. It does slightly less priority-queue work per returned path. The tree was chosen because every piece of its state is directly meaningful — a partial path and its probability — while the flip formulation is legible only through derived bookkeeping (a preferred side per dependency, per-flip costs, rules to keep floating-point totals stable), which makes it harder to understand and maintain. Both return the same paths in the same order.
162+
163+
## Implementation impact
164+
165+
The generator package is rewritten around the tree vocabulary — root, decision, node, leaf, path — with no change to its interfaces, its place in the default Speculator, the entities, or the scorer contract. Ranking values are unchanged up to floating-point rounding (the same logs added in a different order), and the set of paths returned is identical; only the order of *exact* ties is governed by the rule above rather than the old formulation's tie rule.
166+
167+
Deriving ancestors from direct edges reaches slightly beyond the generator, because a path carries one assumption per ancestor and everything that reads paths must agree on what the ancestors are:
168+
169+
- One shared definition of "a batch's ancestors in this snapshot" is used both by the generator to shape trees and by the speculate controller to validate returned paths, so the two can never disagree.
170+
- The controller's read step follows chains of direct edges when collecting finalized batches that are still referenced, since a chain's older links no longer appear in any one batch's stored list.
171+
- The entity comment on the stored dependency list currently claims it holds the transitive closure; it holds direct conflicts, and the comment is corrected with the implementation.

0 commit comments

Comments
 (0)