Skip to content

Commit e9e54ca

Browse files
committed
feat(speculation): generator contract and bestfirst impl
Add submitqueue/extension/speculation/generator, the candidate-stream composition point (Generator/PathIterator) the standard Speculator pulls from, plus the bestfirst implementation and mocks. Open takes only the queue's batches. The generator offers every coherent path in the space, including paths whose builds already ran — suppressing finished paths belongs to the Allocator, the piece that already reconciles candidates against the stored path sets by ID. Keeping the generator ignorant of path sets leaves it a pure enumerator and removes the expand-before-skip subtlety from its iterator: Next pops, expands, returns, with no filtering loop. bestfirst ranks each path by the product of its dependencies' landing chances (scored through an injected scorer) and builds them lazily. A single heap holds every path built but not yet handed out, across all heads; handing one out builds only the one or two paths that come after it within that same head. Pulling k candidates builds O(k) paths however large the space behind them, so a head with twelve unresolved dependencies costs no more at the front of the queue than one with two. Within a head, the best path bets the preferred (likelier) way on every unresolved dependency, and its score is precomputed once; every other path applies some subset of the head's flips — a flip is the option to bet one unresolved dependency the unlikely way — and scores bestScore times the applied flips' penalties (alternative over preferred). The heap stores only paths: which flips a path applies is read back off its bets, so there is no parallel flip-set representation to keep in sync. Subsets are walked by a canonical expansion under which every subset has exactly one parent, so each path is built exactly once with no visited set, and a child never outranks its parent — which is what lets the heap hand paths out in true descending order. Equal scores break on path ID among candidates resident in the heap together, which makes a run repeatable rather than globally ID-sorted. There is no depth bound. It existed to cap 2^n enumeration, and lazy generation removed that cost, so a head may now have as many unresolved dependencies as it likes. A dependency absent from the live batches cannot be scored, and is assumed very likely to land rather than treated as a coin flip. The even-odds threshold used to pick which way to bet is a separate constant from that default, so the two move independently — sharing one constant would have silently inverted the preferred bet for every dependency below the default. Tests pin the exact emitted sequence across heads, the preferred bet on a below-even dependency, settled dependencies dropping out of the search, a head table over all eight batch states, the built-path count behind the laziness claim, deterministic tie-breaking, and a randomized cross-check of the whole walk against brute-force enumeration. The generator README carries a worked walkthrough tracing the heap pop by pop. Also documents on scorer.Scorer that a speculation run scores each batch at most once but does not carry results across runs, so anything expensive belongs behind the implementation's own cache.
1 parent d9aaca5 commit e9e54ca

12 files changed

Lines changed: 1325 additions & 3 deletions

File tree

submitqueue/extension/scorer/scorer.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ type Scorer interface {
2727
// Score returns a probability between 0.0 and 1.0 indicating the likelihood
2828
// of a successful land for the given batch. It is handed the batch identity
2929
// and resolves the batch's changes itself through an injected changeset.Resolver.
30+
//
31+
// Callers may score every batch a queue is waiting on, so implementations
32+
// should be cheap: a speculation run scores each batch at most once, but it
33+
// does not carry results over to the next run, so anything expensive to
34+
// compute belongs behind the implementation's own cache.
3035
Score(ctx context.Context, batch entity.Batch) (float64, error)
3136
}
3237

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["generator.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/generator",
7+
visibility = ["//visibility:public"],
8+
deps = ["//submitqueue/entity:go_default_library"],
9+
)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# generator
2+
3+
The `generator` package is a piece the `standard` `Speculator` is built from: a `Generator` produces the queue's candidate paths as one stream, best first, across all heads. It is **not** controller-facing — the speculate controller only knows the `Speculator` contract, and a different `Speculator` need not split its work this way. So there is no `Config` or `Factory` here; a `Generator` is chosen when the `standard` `Speculator` is constructed.
4+
5+
`Open` starts the stream over the queue's live batches and returns a `PathIterator`. The caller pulls one candidate at a time and the generator does only the work that answer needs. Candidates descend in score, never repeat, and never contradict a known fact. The generator offers every path in the space, including paths whose builds already ran — suppressing finished paths is the `Allocator`'s job, since it is the piece that reconciles candidates against the stored path sets. A candidate's score means something only for the current run; scores are never stored.
6+
7+
## `bestfirst`
8+
9+
A head waiting on unfinished dependencies has one path per combination of outcomes — 2ⁿ of them — while callers want the best few. `bestfirst` hands them out in score order without building the rest.
10+
11+
Dependencies that already finished carry a forced bet: landed means included, failed or cancelled means excluded. They stay in the path but drop out of the search. Each unresolved one is a two-way bet whose chance of landing comes from an injected `scorer.Scorer`, remembered per dependency so one shared by several heads is scored once. A dependency that is not a live batch cannot be scored at all, and is assumed very likely to land — nearly everything a queue is asked to land does. A path's score is its bets' chances multiplied together.
12+
13+
**The best path is not "bet everything lands."** Each bet takes the *likelier* outcome, so a dependency that will probably fail is excluded. Every other path flips some of those bets, and each flip costs a known factor. One consequence worth knowing: applying two cheap flips can beat applying one expensive flip, and the ordering handles that correctly.
14+
15+
**Laziness.** One heap holds every path built but not yet handed out, across all heads. Each path handed out is replaced by the one or two that come after it within the same head, so the heap always holds each head's current best and the top of it is the best path in the queue. Pulling *k* candidates builds about *k* paths, whatever the size of the set behind them.
16+
17+
`bestfirst` discards nothing: pull long enough and every path for every head comes out. It does no conflict relaxation (`dropped` bets) — that is the `Speculator`'s call, not the generator's.
18+
19+
### Worked example
20+
21+
Say a head waits on three dependencies with landing chances d0 0.9, d1 0.8, d2 0.6. All three are better than even, so its best path includes all three and scores 0.9 × 0.8 × 0.6 = 0.432. Sorted cheapest-first the flips come out f0=d2, f1=d1, f2=d0.
22+
23+
Every other path is the best path with some flips applied, written as the set of applied flips: `{f0}` applies only the cheapest, `{f0,f1}` the two cheapest, and so on. `expand` in `iterator.go` grows this space one step at a time — each path handed out queues the one or two that come just after it:
24+
25+
```
26+
{} -> {0}
27+
{0} -> {0,1}, {1}
28+
{1} -> {1,2}, {2}
29+
{0,1} -> {0,1,2}, {0,2}
30+
{2}, {0,2}, {1,2}, {0,1,2} have nothing left to flip above position 2
31+
```
32+
33+
Pulling all eight paths then goes:
34+
35+
```
36+
handed out still waiting afterwards
37+
{} .432 {f0}·.288
38+
{f0} .288 {f1}·.108 {f0,f1}·.072
39+
{f1} .108 {f0,f1}·.072 {f2}·.048 {f1,f2}·.012
40+
{f0,f1} .072 {f2}·.048 {f0,f2}·.032 {f1,f2}·.012 {f0,f1,f2}·.008
41+
{f2} .048 {f0,f2}·.032 {f1,f2}·.012 {f0,f1,f2}·.008
42+
{f0,f2} .032 {f1,f2}·.012 {f0,f1,f2}·.008
43+
{f1,f2} .012 {f0,f1,f2}·.008
44+
{f0,f1,f2} .008 —
45+
```
46+
47+
The scores come out in order even though nothing is ever sorted, and the waiting list never holds more than four paths for a set of eight. Stop after two and only three paths were ever built.
48+
49+
Look at the fourth row: `{f0,f1}` applies two flips yet still beats `{f2}`, which applies only one. That is correct — the two cheap flips together cost less than the expensive one.
50+
51+
The proof that this walk reaches every path exactly once, in score order, is on `expand` in `iterator.go`. The behavior is pinned by tests in `bestfirst_test.go`.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = [
6+
"bestfirst.go",
7+
"iterator.go",
8+
],
9+
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/generator/bestfirst",
10+
visibility = ["//visibility:public"],
11+
deps = [
12+
"//submitqueue/entity:go_default_library",
13+
"//submitqueue/extension/scorer:go_default_library",
14+
"//submitqueue/extension/speculation/generator:go_default_library",
15+
],
16+
)
17+
18+
go_test(
19+
name = "go_default_test",
20+
srcs = ["bestfirst_test.go"],
21+
embed = [":go_default_library"],
22+
deps = [
23+
"//submitqueue/entity:go_default_library",
24+
"//submitqueue/extension/scorer:go_default_library",
25+
"//submitqueue/extension/speculation/generator:go_default_library",
26+
"@com_github_stretchr_testify//assert:go_default_library",
27+
"@com_github_stretchr_testify//require:go_default_library",
28+
],
29+
)
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package bestfirst hands out speculation paths for a queue, best ones first.
16+
//
17+
// A batch we want to build (a "head") often depends on other batches that have
18+
// not finished yet. Each unfinished dependency could go either way, so there is
19+
// more than one sensible thing to build: one path per combination of outcomes.
20+
// A path's score is how likely its combination is to come true, which is just
21+
// the individual chances multiplied together.
22+
//
23+
// Three words carry the package: a bet is a path's answer on one dependency —
24+
// included or excluded, forced when the dependency already finished. A flip is
25+
// the option to answer one unresolved dependency the unlikely way; the head's
26+
// best path applies no flips, and every other path applies some subset of
27+
// them. A candidate is one complete set of answers — a path — waiting in the
28+
// heap with its score.
29+
//
30+
// The catch is the arithmetic. A head with n unresolved dependencies has 2^n
31+
// paths, and the caller normally wants only the best handful. So this package
32+
// never builds them all. It builds one path per head up front, and builds
33+
// another only when the caller takes one. Asking for three paths costs about
34+
// three paths of work, even when the head technically has 4096.
35+
package bestfirst
36+
37+
import (
38+
"context"
39+
"math"
40+
"sort"
41+
42+
"github.com/uber/submitqueue/submitqueue/entity"
43+
"github.com/uber/submitqueue/submitqueue/extension/scorer"
44+
"github.com/uber/submitqueue/submitqueue/extension/speculation/generator"
45+
)
46+
47+
const (
48+
// evenOdds is the midpoint between the two outcomes. A dependency at least
49+
// this likely to land is bet to land; below it, the bet goes the other
50+
// way. It is only ever a comparison threshold, never a chance in its own
51+
// right.
52+
evenOdds = 0.5
53+
54+
// defaultLandingChance is assumed for a dependency that cannot be scored
55+
// because it is not among the live batches. Nearly everything a queue is
56+
// asked to land does land, so assuming the dependency almost certainly holds
57+
// is closer to the truth than a coin flip, and it keeps an unscoreable
58+
// dependency from dragging down every path that includes it.
59+
defaultLandingChance = 0.95
60+
)
61+
62+
// gen builds best-first iterators. scorer says how likely a dependency is to
63+
// land.
64+
type gen struct {
65+
scorer scorer.Scorer
66+
}
67+
68+
// New returns a best-first generator.Generator. scorer scores each unresolved
69+
// dependency to get its chance of landing.
70+
func New(sc scorer.Scorer) generator.Generator {
71+
return gen{scorer: sc}
72+
}
73+
74+
// Open works out each head's flips and queues that head's best path, ready to
75+
// hand out. All the scoring happens here, which is why Next never calls the
76+
// scorer and never fails.
77+
func (g gen) Open(ctx context.Context, batches []entity.Batch) (generator.PathIterator, error) {
78+
batchByID := make(map[string]entity.Batch, len(batches))
79+
for _, b := range batches {
80+
batchByID[b.ID] = b
81+
}
82+
chances := newChanceCache(g.scorer, batchByID)
83+
84+
it := &iterator{}
85+
for _, batch := range batches {
86+
// Only Speculating heads get paths. Batches in any other state (created,
87+
// merging, cancelling, finished) still tell us how their dependencies
88+
// turned out, but we never propose work on them.
89+
if batch.State != entity.BatchStateSpeculating {
90+
continue
91+
}
92+
h, err := newHead(ctx, batch, batchByID, chances)
93+
if err != nil {
94+
return nil, err
95+
}
96+
// Queue the head's best path — no flips applied, every dependency bet
97+
// the likely way. It is the only path built up front; the rest grow out
98+
// of it as the caller pulls.
99+
it.push(h, nil)
100+
}
101+
return it, nil
102+
}
103+
104+
// chanceCache answers "how likely is this dependency to land", scoring each one
105+
// at most once. Several heads usually wait on the same dependency, and there is
106+
// no reason to score it once per head.
107+
type chanceCache struct {
108+
scorer scorer.Scorer
109+
batches map[string]entity.Batch
110+
chances map[string]float64
111+
}
112+
113+
func newChanceCache(sc scorer.Scorer, batches map[string]entity.Batch) *chanceCache {
114+
return &chanceCache{scorer: sc, batches: batches, chances: make(map[string]float64)}
115+
}
116+
117+
// of returns the chance the given dependency lands.
118+
func (c *chanceCache) of(ctx context.Context, batchID string) (float64, error) {
119+
if chance, ok := c.chances[batchID]; ok {
120+
return chance, nil
121+
}
122+
batch, ok := c.batches[batchID]
123+
if !ok {
124+
// Not a live batch, so there is nothing to score. Remember the default
125+
// too, so we don't look it up again.
126+
c.chances[batchID] = defaultLandingChance
127+
return defaultLandingChance, nil
128+
}
129+
chance, err := c.scorer.Score(ctx, batch)
130+
if err != nil {
131+
return 0, err
132+
}
133+
c.chances[batchID] = chance
134+
return chance, nil
135+
}
136+
137+
// flip is the option to bet one unresolved dependency the unlikely way.
138+
//
139+
// Each unresolved dependency has a likelier outcome (the "preferred" bet, what
140+
// the head's best path uses) and an unlikelier one (the "alternative"). The
141+
// best path applies no flips; every other path applies some subset of them,
142+
// each multiplying the path's score by that flip's penalty. A penalty near 1
143+
// means the flip barely costs anything (the dependency was close to a coin
144+
// flip); a penalty near 0 means it is very expensive.
145+
//
146+
// A flip holds no applied/unapplied state of its own: each path applies its
147+
// own subset, readable off the path's bets.
148+
type flip struct {
149+
// betIndex is this dependency's position in the head's bets (queue order).
150+
// The flips slice gets sorted by penalty, which scrambles that order, so
151+
// each flip carries the index it came from — that is how an applied flip's
152+
// bet is written back in queue order.
153+
betIndex int
154+
// alternativeBet is the bet a path that applies this flip carries.
155+
alternativeBet entity.DependencyBetType
156+
// penalty is alternative/preferred: the score multiplier for applying this
157+
// flip, always in [0, 1].
158+
penalty float64
159+
}
160+
161+
// head is one batch we might build, and everything needed to enumerate its
162+
// paths. It holds no heap of its own — every head's paths compete in the
163+
// iterator's single heap.
164+
type head struct {
165+
// id is the batch these paths are for.
166+
id string
167+
// bets is the best path's bets, in queue order: the known bet where the
168+
// dependency has already finished, the preferred bet everywhere else.
169+
bets []entity.DependencyBet
170+
// flips is the option to flip each unresolved dependency, cheapest first.
171+
flips []flip
172+
// bestScore is the best path's score: the preferred chances multiplied
173+
// together. Every other path's score is bestScore times its applied flips'
174+
// penalties.
175+
bestScore float64
176+
}
177+
178+
// newHead works out a head's search space. Dependencies that already finished
179+
// contribute a known bet and drop out of the search; each of the rest becomes
180+
// a flip, ordered so the cheapest one comes first.
181+
func newHead(
182+
ctx context.Context,
183+
batch entity.Batch,
184+
batchByID map[string]entity.Batch,
185+
chances *chanceCache,
186+
) (*head, error) {
187+
bets := make([]entity.DependencyBet, len(batch.Dependencies))
188+
flips := make([]flip, 0, len(batch.Dependencies))
189+
bestScore := 1.0
190+
for i, dep := range batch.Dependencies {
191+
if bet, settled := settledBet(batchByID[dep].State); settled {
192+
bets[i] = entity.DependencyBet{Batch: dep, Bet: bet}
193+
continue
194+
}
195+
chance, err := chances.of(ctx, dep)
196+
if err != nil {
197+
return nil, err
198+
}
199+
// Bet the likelier outcome. On an exact coin flip, bet it lands.
200+
preferredBet, alternativeBet := entity.BetIncluded, entity.BetExcluded
201+
if chance < evenOdds {
202+
preferredBet, alternativeBet = entity.BetExcluded, entity.BetIncluded
203+
}
204+
// chance is the likelihood the dependency lands, so whichever way we bet,
205+
// preferred is the bigger of chance and 1-chance.
206+
preferred, alternative := math.Max(chance, 1-chance), math.Min(chance, 1-chance)
207+
bets[i] = entity.DependencyBet{Batch: dep, Bet: preferredBet}
208+
bestScore *= preferred
209+
flips = append(flips, flip{
210+
betIndex: i,
211+
alternativeBet: alternativeBet,
212+
// preferred is at least 0.5, so this never divides by zero.
213+
penalty: alternative / preferred,
214+
})
215+
}
216+
// Cheapest flip first — the bet we'd mind changing least. expand depends on
217+
// this order: it only ever moves an applied flip to the next position along,
218+
// so having them sorted is what guarantees a path never scores above the one
219+
// it came from.
220+
sort.Slice(flips, func(i, j int) bool {
221+
if flips[i].penalty != flips[j].penalty {
222+
return flips[i].penalty > flips[j].penalty
223+
}
224+
return flips[i].betIndex < flips[j].betIndex
225+
})
226+
227+
return &head{id: batch.ID, bets: bets, flips: flips, bestScore: bestScore}, nil
228+
}
229+
230+
// settledBet reports the bet a dependency's state forces, if that state is
231+
// final. An unresolved dependency has no forced bet and stays in the search.
232+
func settledBet(state entity.BatchState) (entity.DependencyBetType, bool) {
233+
switch state {
234+
case entity.BatchStateSucceeded:
235+
// Already landed, so including it is a fact, not a bet at risk.
236+
return entity.BetIncluded, true
237+
case entity.BatchStateFailed, entity.BatchStateCancelled:
238+
// Never landing, so leaving it out is a fact too.
239+
return entity.BetExcluded, true
240+
default:
241+
return entity.BetUnknown, false
242+
}
243+
}
244+
245+
// pathFor builds the path that applies the given flips, and its score. applied
246+
// holds ascending positions in h.flips. It starts from the head's best path,
247+
// switches each applied flip's dependency to its alternative bet, and pays that
248+
// flip's penalty. Dependencies that already finished are certainties, so they
249+
// have no flip and never change the score.
250+
func (h *head) pathFor(applied []int) (entity.SpeculationPath, float64) {
251+
// Work on a copy. h.bets is the template every one of this head's paths
252+
// starts from, so writing a flip straight into it would corrupt every path
253+
// built after this one.
254+
bets := make([]entity.DependencyBet, len(h.bets))
255+
copy(bets, h.bets)
256+
257+
score := h.bestScore
258+
for _, i := range applied {
259+
f := h.flips[i]
260+
bets[f.betIndex].Bet = f.alternativeBet
261+
score *= f.penalty
262+
}
263+
return entity.SpeculationPath{Head: h.id, Bets: bets}, score
264+
}
265+
266+
// appliedFlips reads back which of h's flips a path applies, as ascending
267+
// positions in h.flips: exactly the ones whose dependency the path bets the
268+
// alternative way. It inverts pathFor, so the iterator needs no flip
269+
// bookkeeping on the paths it holds.
270+
func (h *head) appliedFlips(path entity.SpeculationPath) []int {
271+
var applied []int
272+
for i, f := range h.flips {
273+
if path.Bets[f.betIndex].Bet == f.alternativeBet {
274+
applied = append(applied, i)
275+
}
276+
}
277+
return applied
278+
}

0 commit comments

Comments
 (0)