Skip to content

Commit 80bb63d

Browse files
authored
refactor(speculation): drop snapshot validation from bestfirst (#524)
## Summary ### Why? `Generate` re-validated the queue snapshot on every run: seven checks covering empty and duplicate batch IDs, empty, duplicate, and self dependencies, dependencies missing from the snapshot, and unknown dependency states — plus a nil-scorer panic in `New`. Those are properties of a correctly assembled snapshot, established wherever the snapshot is built, so checking them again on the hot path of every run spreads one contract across two places and buys nothing a well-formed snapshot could ever trip. They were also the bulk of `Generate`'s branching. The scorer range check was different in kind: a score arrives from an injected extension, so no earlier stage can vet it. But sinking the whole run on one bad number is the wrong response when a default will do. ### What? Snapshot validation is gone. The `Generator` interface doc and the best-first RFC now state the well-formedness rules as a caller precondition — a generator may assume them, and a malformed snapshot yields undefined candidates rather than an error. Nothing consumes the generator yet, so whoever assembles the snapshot will own the check. One behavior worth flagging for reviewers: a head that repeats a dependency was previously rejected and now produces paths that assume the same batch both succeeds and fails. That is undefined input under the new contract, but it fails quietly rather than loudly. A score outside `[0, 1]`, or `NaN`, is now replaced with a default of 0.95 rather than rejected. The default is optimistic on purpose: a dependency nobody could estimate keeps its head's preferred path near the front instead of being buried. Both comparisons in `asProbability` are false for `NaN`, so it needs no separate case. `Generate` is now three linear steps — index, `speculatingHeads`, `score` — and seeding the global heap appends and heapifies once. Previously `heap.Init` ran on an empty slice and each head was pushed individually, costing a sift and an interface boxing per head; building the slice first is linear. `slices.Sorted(maps.Keys(...))` replaces the collect-then-sort loop, and `slices.Clone` replaces three `make`+`copy` pairs. Net 143 lines removed, 80 added. ## Test Plan ✅ `go test ./submitqueue/extension/speculation/...` ✅ `go vet ./submitqueue/extension/speculation/...` ✅ `gofmt -l submitqueue/extension/speculation/` — clean The malformed-snapshot table test is removed. The score-range test now asserts the default is applied and both of the head's paths still come out, ranked as the default and its complement, rather than asserting an error.
1 parent 2d0f019 commit 80bb63d

4 files changed

Lines changed: 82 additions & 146 deletions

File tree

doc/rfc/submitqueue/speculation-generator-best-first.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The default generator returns the most likely complete build path across the who
66

77
The code has one setup method, `Generate`, and one repeated method, `Next`:
88

9-
1. `Generate` validates the queue snapshot, then records a preferred assumption and the cost of flipping to its opposite for each unresolved direct dependency. It totals the best score for each eligible head; resolved dependencies remain fixed facts.
9+
1. `Generate` records a preferred assumption and the cost of flipping to its opposite for each unresolved direct dependency. It totals the best score for each eligible head; resolved dependencies remain fixed facts.
1010
2. `Generate` pushes one lightweight best-path candidate per head into a global heap. Each head also owns a stream that enumerates its remaining paths on demand; at this point the stream has done no work beyond that total.
1111
3. `Next` removes the highest-ranked candidate from the global heap, advances only that head's stream, inserts the head's next candidate, and constructs and returns the complete path that was removed.
1212

@@ -45,11 +45,11 @@ The scorer estimates:
4545

4646
The batch being built is written before its assumptions. For example, `C [A succeeds, B fails]` means “build C assuming A succeeds and B fails.” The code calls C the path's **head**.
4747

48-
## The snapshot is a strict contract
48+
## The snapshot is a caller precondition
4949

50-
`Generate` receives the queue's live batches as a snapshot and validates it before doing any other work. Every batch a head's direct dependencies reference must be present with a readable state, batch IDs must be unique and non-empty, and no head may repeat a dependency or depend on itself. A snapshot that breaks any of these is malformed input, and `Generate` returns an error instead of a stream.
50+
`Generate` receives the queue's live batches as a snapshot and takes it as given. A well-formed snapshot carries unique, non-empty batch IDs, includes every batch a head's direct dependencies reference, and gives no head an empty, duplicate, or self dependency. Those are preconditions the caller owns, established where the snapshot is assembled. The generator does not re-check them: it is on the hot path of every run, the checks it could make are the ones an assembled-correctly snapshot can never fail, and paying for them here only spreads the same contract across two places. A malformed snapshot yields undefined candidates rather than an error.
5151

52-
In particular, a missing dependency is never guessed about. Every unresolved dependency is scored by the injected scorer, and any defaulting for a batch that is hard to score belongs to the scorer implementationwhich knows what information it does and does not have — not to the generator.
52+
A score that is not a probability is the one bad input the generator absorbs, because it arrives from the injected scorer rather than from the caller and there is no earlier point that could catch it. A score outside `[0, 1]`, or `NaN`, is replaced with a default of 0.95 — optimistic on purpose, so a dependency nobody could estimate keeps its head's preferred path near the front instead of burying it or failing the whole run on one number. Any deliberate defaulting still belongs to the scorer implementation, which knows what information it does and does not have; this is only the floor under it.
5353

5454
## Step 1: `Generate` prepares each head
5555

@@ -420,8 +420,7 @@ A and D tie at 1.0, so batch ID puts A first. Other exact ties prefer fewer flip
420420

421421
`Generate` must eagerly:
422422

423-
- validate the snapshot;
424-
- score every unique unresolved direct dependency needed by an eligible head;
423+
- score every unique unresolved direct dependency needed by an eligible head, substituting the default for any score that is not a probability;
425424
- choose each unresolved dependency's preferred assumption and calculate its `flipCost`; and
426425
- total the best score for every head.
427426

submitqueue/extension/speculation/generator/bestfirst/bestfirst.go

Lines changed: 55 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import (
2525
"cmp"
2626
"container/heap"
2727
"context"
28-
"errors"
2928
"fmt"
29+
"maps"
3030
"math"
3131
"slices"
3232

@@ -47,72 +47,64 @@ var _ generator.Generator = (*bestFirst)(nil)
4747
// unresolved dependency assumption holds. The scorer is called at most once
4848
// per unresolved dependency batch in each Generate call.
4949
func New(s scorer.Scorer) generator.Generator {
50-
if s == nil {
51-
panic("bestfirst.New: scorer must not be nil")
52-
}
5350
return &bestFirst{scorer: s}
5451
}
5552

56-
// Generate validates the queue snapshot, scores the unresolved dependencies of
57-
// Speculating heads, and opens a lazy global best-first iterator.
53+
// Generate scores the unresolved dependencies of the snapshot's Speculating
54+
// heads and opens a lazy global best-first iterator. The snapshot is taken as
55+
// given: it is the caller's to keep well formed, and nothing here re-checks it.
5856
func (g *bestFirst) Generate(ctx context.Context, batches []entity.Batch) (generator.Iterator, error) {
5957
if err := ctx.Err(); err != nil {
6058
return nil, err
6159
}
6260

6361
batchByID := make(map[string]entity.Batch, len(batches))
6462
for _, batch := range batches {
65-
if batch.ID == "" {
66-
return nil, errors.New("batch has an empty ID")
67-
}
68-
if _, exists := batchByID[batch.ID]; exists {
69-
return nil, fmt.Errorf("duplicate batch ID %q", batch.ID)
70-
}
7163
batchByID[batch.ID] = batch
7264
}
65+
heads, unresolvedIDs := speculatingHeads(batches, batchByID)
66+
67+
probabilityByID, err := g.score(ctx, unresolvedIDs, batchByID)
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
// Seeding the heap by appending and then heapifying once is linear, where
73+
// pushing head by head would cost a sift per head.
74+
it := &candidateIterator{candidates: make(candidateHeap, 0, len(heads))}
75+
for _, head := range heads {
76+
stream := newPathStream(head, batchByID, probabilityByID)
77+
it.candidates = append(it.candidates, candidateItem{
78+
stream: stream,
79+
score: stream.bestScore,
80+
})
81+
}
82+
heap.Init(&it.candidates)
83+
return it, nil
84+
}
7385

74-
heads := make([]entity.Batch, 0)
75-
unresolvedDependencyIDs := make(map[string]struct{})
86+
// speculatingHeads picks out the batches worth proposing work on and the
87+
// distinct dependencies of theirs still awaiting an outcome. The dependency IDs
88+
// come back sorted, so scoring order does not vary with map iteration.
89+
func speculatingHeads(batches []entity.Batch, batchByID map[string]entity.Batch) (heads []entity.Batch, unresolvedIDs []string) {
90+
unresolved := make(map[string]struct{})
7691
for _, batch := range batches {
7792
if batch.State != entity.BatchStateSpeculating {
7893
continue
7994
}
8095
heads = append(heads, batch)
81-
82-
seen := make(map[string]struct{}, len(batch.Dependencies))
8396
for _, dependencyID := range batch.Dependencies {
84-
if dependencyID == "" {
85-
return nil, fmt.Errorf("head %q has an empty dependency ID", batch.ID)
86-
}
87-
if dependencyID == batch.ID {
88-
return nil, fmt.Errorf("head %q depends on itself", batch.ID)
89-
}
90-
if _, duplicate := seen[dependencyID]; duplicate {
91-
return nil, fmt.Errorf("head %q repeats dependency %q", batch.ID, dependencyID)
92-
}
93-
seen[dependencyID] = struct{}{}
94-
95-
dependency, exists := batchByID[dependencyID]
96-
if !exists {
97-
return nil, fmt.Errorf("head %q references dependency %q missing from the snapshot", batch.ID, dependencyID)
98-
}
99-
if dependency.State == entity.BatchStateUnknown {
100-
return nil, fmt.Errorf("dependency %q has an unknown state", dependencyID)
101-
}
102-
if _, resolved := resolvedAssumption(dependency.State); !resolved {
103-
unresolvedDependencyIDs[dependencyID] = struct{}{}
97+
if _, resolved := resolvedAssumption(batchByID[dependencyID].State); !resolved {
98+
unresolved[dependencyID] = struct{}{}
10499
}
105100
}
106101
}
102+
return heads, slices.Sorted(maps.Keys(unresolved))
103+
}
107104

108-
// Score each unique unresolved dependency once, in a stable order. A score
109-
// outside [0, 1] is rejected here because everything downstream treats it
110-
// as a probability, and a bad value would corrupt the ordering silently.
111-
ids := make([]string, 0, len(unresolvedDependencyIDs))
112-
for id := range unresolvedDependencyIDs {
113-
ids = append(ids, id)
114-
}
115-
slices.Sort(ids)
105+
// score asks the scorer for each unresolved dependency exactly once, however
106+
// many heads wait on it.
107+
func (g *bestFirst) score(ctx context.Context, ids []string, batchByID map[string]entity.Batch) (map[string]float64, error) {
116108
probabilityByID := make(map[string]float64, len(ids))
117109
for _, id := range ids {
118110
if err := ctx.Err(); err != nil {
@@ -122,22 +114,25 @@ func (g *bestFirst) Generate(ctx context.Context, batches []entity.Batch) (gener
122114
if err != nil {
123115
return nil, fmt.Errorf("score dependency %q: %w", id, err)
124116
}
125-
if math.IsNaN(probability) || probability < 0 || probability > 1 {
126-
return nil, fmt.Errorf("scorer returned %v for batch %q: want a probability in [0, 1]", probability, id)
127-
}
128-
probabilityByID[id] = probability
117+
probabilityByID[id] = asProbability(probability)
129118
}
119+
return probabilityByID, nil
120+
}
130121

131-
it := &candidateIterator{}
132-
heap.Init(&it.candidates)
133-
for _, head := range heads {
134-
stream := newPathStream(head, batchByID, probabilityByID)
135-
heap.Push(&it.candidates, candidateItem{
136-
stream: stream,
137-
score: stream.bestScore,
138-
})
122+
// defaultProbability stands in for a score that is not a probability. It is
123+
// optimistic on purpose: a dependency nobody could estimate is treated as very
124+
// likely to succeed, which keeps its head's preferred path near the front
125+
// rather than burying it or dropping the queue's whole snapshot on one bad
126+
// number.
127+
const defaultProbability = 0.95
128+
129+
// asProbability keeps a usable score and substitutes the default for anything
130+
// else. The comparisons are both false for NaN, so NaN takes the default too.
131+
func asProbability(score float64) float64 {
132+
if score >= 0 && score <= 1 {
133+
return score
139134
}
140-
return it, nil
135+
return defaultProbability
141136
}
142137

143138
// resolvedAssumption converts a terminal dependency outcome into the only
@@ -282,8 +277,7 @@ func (s *pathStream) scoreFor(flipped []int) float64 {
282277
// build constructs the path taking the given flips. The returned path owns its
283278
// dependencies.
284279
func (s *pathStream) build(flipped []int) entity.SpeculationPath {
285-
dependencies := make([]entity.PathDependency, len(s.base))
286-
copy(dependencies, s.base)
280+
dependencies := slices.Clone(s.base)
287281
for _, i := range flipped {
288282
at := s.variables[i].dependencyIndex
289283
dependencies[at].Assumption = opposite(dependencies[at].Assumption)
@@ -301,15 +295,11 @@ func opposite(assumption entity.DependencyAssumption) entity.DependencyAssumptio
301295
}
302296

303297
func appendCopy(values []int, value int) []int {
304-
result := make([]int, len(values)+1)
305-
copy(result, values)
306-
result[len(values)] = value
307-
return result
298+
return append(slices.Clone(values), value)
308299
}
309300

310301
func replaceLastCopy(values []int, value int) []int {
311-
result := make([]int, len(values))
312-
copy(result, values)
302+
result := slices.Clone(values)
313303
result[len(result)-1] = value
314304
return result
315305
}

submitqueue/extension/speculation/generator/bestfirst/bestfirst_test.go

Lines changed: 17 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -392,71 +392,6 @@ func TestBestFirst_PropagatesScorerError(t *testing.T) {
392392
assert.Nil(t, iter)
393393
}
394394

395-
func TestBestFirst_RejectsMalformedSnapshots(t *testing.T) {
396-
// The snapshot contract: every batch a head's direct dependencies reference
397-
// is present with a readable state, IDs are unique and non-empty, and no
398-
// head repeats a dependency or depends on itself. Anything else is
399-
// malformed input. In particular a missing dependency is never guessed
400-
// about — the scorer, not the generator, owns any defaulting for batches
401-
// that are hard to score.
402-
tests := []struct {
403-
name string
404-
batches []entity.Batch
405-
}{
406-
{
407-
name: "empty batch ID",
408-
batches: []entity.Batch{{State: entity.BatchStateSpeculating}},
409-
},
410-
{
411-
name: "duplicate batch ID",
412-
batches: []entity.Batch{
413-
{ID: "q/A", State: entity.BatchStateCreated},
414-
{ID: "q/A", State: entity.BatchStateSpeculating},
415-
},
416-
},
417-
{
418-
name: "empty dependency ID",
419-
batches: []entity.Batch{
420-
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{""}},
421-
},
422-
},
423-
{
424-
name: "self dependency",
425-
batches: []entity.Batch{
426-
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/H"}},
427-
},
428-
},
429-
{
430-
name: "duplicate dependency",
431-
batches: []entity.Batch{
432-
{ID: "q/A", State: entity.BatchStateCreated},
433-
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/A", "q/A"}},
434-
},
435-
},
436-
{
437-
name: "missing dependency",
438-
batches: []entity.Batch{
439-
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/ghost"}},
440-
},
441-
},
442-
{
443-
name: "unknown dependency state",
444-
batches: []entity.Batch{
445-
{ID: "q/A", State: entity.BatchStateUnknown},
446-
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/A"}},
447-
},
448-
},
449-
}
450-
451-
for _, tt := range tests {
452-
t.Run(tt.name, func(t *testing.T) {
453-
iter, err := New(scored(nil)).Generate(context.Background(), tt.batches)
454-
require.Error(t, err)
455-
assert.Nil(t, iter)
456-
})
457-
}
458-
}
459-
460395
func TestBestFirst_GeneratesOnlyWhatIsPulled(t *testing.T) {
461396
// 12 unresolved dependencies is an outcome space of 4096 paths.
462397
const deps, space = 12, 1 << 12
@@ -841,12 +776,14 @@ func TestBestFirst_HonorsCancelledContext(t *testing.T) {
841776
})
842777
}
843778

844-
func TestBestFirst_RejectsScoreOutsideUnitInterval(t *testing.T) {
779+
func TestBestFirst_DefaultsScoreOutsideUnitInterval(t *testing.T) {
845780
// Everything downstream treats a score as a probability: its log is the
846-
// ranking key and its complement is the other side's probability. An
847-
// out-of-range value would not fail loudly, it would quietly produce a
781+
// ranking key and its complement is the other side's probability. Carrying
782+
// an out-of-range value would not fail loudly, it would quietly produce a
848783
// positive log, an inverted ordering, or a NaN that makes every comparison
849-
// false — so it is rejected at the source instead.
784+
// false. The scorer is an injected extension and nothing earlier can vet
785+
// what it returns, so a value that is not a probability is replaced with an
786+
// optimistic default here rather than sinking the whole run.
850787
tests := []struct {
851788
name string
852789
score float64
@@ -855,6 +792,7 @@ func TestBestFirst_RejectsScoreOutsideUnitInterval(t *testing.T) {
855792
{name: "below zero", score: -0.1},
856793
{name: "not a number", score: math.NaN()},
857794
{name: "positive infinity", score: math.Inf(1)},
795+
{name: "negative infinity", score: math.Inf(-1)},
858796
}
859797

860798
batches := []entity.Batch{
@@ -865,8 +803,16 @@ func TestBestFirst_RejectsScoreOutsideUnitInterval(t *testing.T) {
865803
for _, tt := range tests {
866804
t.Run(tt.name, func(t *testing.T) {
867805
iter, err := New(constScorer{tt.score}).Generate(context.Background(), batches)
868-
require.Error(t, err)
869-
assert.Nil(t, iter)
806+
require.NoError(t, err)
807+
cands := drainAll(t, iter)
808+
809+
// The dependency is scored at the default, so the head still yields
810+
// both of its paths, ranked as that default and its complement.
811+
require.Len(t, cands, 2)
812+
assert.Equal(t, entity.DependencyAssumptionSucceeds, assumptionFor(cands[0].Path, "q/A"))
813+
assert.InDelta(t, math.Log(defaultProbability), cands[0].RankingScore, 1e-9)
814+
assert.Equal(t, entity.DependencyAssumptionFails, assumptionFor(cands[1].Path, "q/A"))
815+
assert.InDelta(t, math.Log(1-defaultProbability), cands[1].RankingScore, 1e-9)
870816
})
871817
}
872818
}

submitqueue/extension/speculation/generator/generator.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ type Generator interface {
4040
// queue that has moved on is a new snapshot and a new Generate, which is how
4141
// batches are revised anyway — they are replaced, not edited in place.
4242
//
43-
// The snapshot must include every batch a head's direct dependencies
44-
// reference. A snapshot that breaks that — or carries empty or duplicate
45-
// batch IDs, or a head with an empty, duplicate, or self dependency — is
46-
// malformed input and aborts with an error rather than a stream.
43+
// A well-formed snapshot carries unique, non-empty batch IDs, includes every
44+
// batch a head's direct dependencies reference, and gives no head an empty,
45+
// duplicate, or self dependency. That is a precondition the caller owns: a
46+
// generator may assume it and is not required to detect a breach, so a
47+
// malformed snapshot yields undefined candidates rather than an error.
4748
Generate(ctx context.Context, batches []entity.Batch) (Iterator, error)
4849
}
4950

0 commit comments

Comments
 (0)