Skip to content

Commit 61d1369

Browse files
committed
fix(speculation): overlay queue predictor factors
A queue predictor block now revises named factors instead of replacing the whole map, so defaults like pathFailed stay in force. Best-first tests rank pathFailed, cancelling, and merging through the evidence predictor rather than a stub.
1 parent 532a6e4 commit 61d1369

3 files changed

Lines changed: 101 additions & 30 deletions

File tree

service/submitqueue/orchestrator/server/config.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package main
1616

1717
import (
1818
"fmt"
19+
"maps"
1920
"math"
2021
"os"
2122
"time"
@@ -249,7 +250,7 @@ type bucketConfig struct {
249250
}
250251

251252
// speculatorConfig tunes how much CI a queue's speculation may occupy. It has no
252-
// `type`: there is one speculator, composed from the queue's scorer, and what
253+
// `type`: there is one speculator, composed from the queue's predictor, and what
253254
// varies between queues is what it is allowed to spend.
254255
type speculatorConfig struct {
255256
// BuildBudget caps how many builds this queue may have occupying CI at once,
@@ -264,8 +265,9 @@ type speculatorConfig struct {
264265
type predictorConfig struct {
265266
Type string `yaml:"type"`
266267
// Factors revise the scorer's price, one per piece of evidence and keyed by
267-
// evidence name. An omitted factor is neutral, so an omitted block ranks on
268-
// the scorer's price alone.
268+
// evidence name. An omitted key keeps the inherited value, or 1 if neither
269+
// defaults nor the queue named it. An omitted predictor block inherits the
270+
// whole default, so every factor stays 1 until someone sets one.
269271
Factors map[string]float64 `yaml:"factors"`
270272
}
271273

@@ -394,11 +396,30 @@ func (c profilesConfig) resolve(q namedQueueProfileConfig) queueProfileConfig {
394396
profile.Speculator = *q.Speculator
395397
}
396398
if q.Predictor != nil {
397-
profile.Predictor = *q.Predictor
399+
profile.Predictor = overlayPredictor(profile.Predictor, *q.Predictor)
398400
}
399401
return profile
400402
}
401403

404+
// overlayPredictor keeps default factors the queue did not name. A present
405+
// predictor block is otherwise a normal extension override: type replaces when
406+
// set, and named factor keys win.
407+
func overlayPredictor(base, override predictorConfig) predictorConfig {
408+
if override.Type != "" {
409+
base.Type = override.Type
410+
}
411+
if len(override.Factors) == 0 {
412+
return base
413+
}
414+
merged := maps.Clone(base.Factors)
415+
if merged == nil {
416+
merged = make(map[string]float64, len(override.Factors))
417+
}
418+
maps.Copy(merged, override.Factors)
419+
base.Factors = merged
420+
return base
421+
}
422+
402423
func (p *queueProfileConfig) normalizeAndValidate(where string) error {
403424
if err := p.ChangeProvider.normalizeAndValidate(where); err != nil {
404425
return err

service/submitqueue/orchestrator/server/config_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,3 +713,18 @@ func TestLoadProfilesConfig_ReadsPredictorFactors(t *testing.T) {
713713
assert.Equal(t, 12.0, factors.Merging)
714714
assert.Equal(t, 0.1, factors.Cancelling)
715715
}
716+
717+
func TestLoadProfilesConfig_QueuePredictorFactorsOverlayDefaults(t *testing.T) {
718+
cfg, err := loadProfilesConfig(writeProfiles(t,
719+
"defaults:\n predictor:\n factors: {pathPassed: 10, pathFailed: 0.3, merging: 12, cancelling: 0.1}\nqueues:\n - name: q\n predictor:\n factors: {pathPassed: 4}\n"))
720+
require.NoError(t, err)
721+
722+
factors := factorsFrom(cfg.resolve(cfg.Queues[0]).Predictor)
723+
assert.Equal(t, 4.0, factors.PathPassed)
724+
assert.Equal(t, 0.3, factors.PathFailed)
725+
assert.Equal(t, 12.0, factors.Merging)
726+
assert.Equal(t, 0.1, factors.Cancelling)
727+
728+
defaults := factorsFrom(cfg.Defaults.Predictor)
729+
assert.Equal(t, 10.0, defaults.PathPassed)
730+
}

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

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -461,38 +461,42 @@ type flatScorer struct{}
461461

462462
func (flatScorer) Score(context.Context, entity.Batch) (float64, error) { return 0.5, nil }
463463

464-
// PathPassed on a green all-succeed build is the join the generator exists to
465-
// consume: same scorer price, different evidence, different rank.
466-
func TestBestFirst_EvidencePathPassedRanksTheGreenDependencyFirst(t *testing.T) {
467-
batches := []entity.Batch{
468-
{ID: "q/built", State: entity.BatchStateSpeculating},
469-
{ID: "q/fresh", State: entity.BatchStateSpeculating},
470-
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/built", "q/fresh"}},
471-
}
472-
built := entity.SpeculationPathSet{
464+
func evidencePredictor(t *testing.T, factors evidence.Factors) predictor.Predictor {
465+
t.Helper()
466+
pred, err := evidence.New(predictor.Config{QueueName: "q"}, flatScorer{}, factors, tally.NoopScope)
467+
require.NoError(t, err)
468+
return pred
469+
}
470+
471+
func allSucceedSet(head string, status entity.SpeculationPathStatus) entity.SpeculationPathSet {
472+
return entity.SpeculationPathSet{
473473
Queue: "q",
474-
Head: "q/built",
474+
Head: head,
475475
Paths: []entity.SpeculationPathEntry{{
476476
ID: "p1",
477-
Status: entity.SpeculationPathStatusPassed,
477+
Status: status,
478478
Path: entity.SpeculationPath{
479-
Head: "q/built",
479+
Head: head,
480480
Dependencies: []entity.PathDependency{{
481481
Batch: "q/dep0",
482482
Assumption: entity.DependencyAssumptionSucceeds,
483483
}},
484484
},
485485
}},
486486
}
487-
pred, err := evidence.New(
488-
predictor.Config{QueueName: "q"},
489-
flatScorer{},
490-
evidence.Factors{PathPassed: 9, PathFailed: 1, Merging: 1, Cancelling: 1},
491-
tally.NoopScope,
492-
)
493-
require.NoError(t, err)
487+
}
494488

495-
iter, err := New(pred).Generate(context.Background(), batches, []entity.SpeculationPathSet{built})
489+
// PathPassed on a green all-succeed build is the join the generator exists to
490+
// consume: same scorer price, different evidence, different rank.
491+
func TestBestFirst_EvidencePathPassedRanksTheGreenDependencyFirst(t *testing.T) {
492+
batches := []entity.Batch{
493+
{ID: "q/built", State: entity.BatchStateSpeculating},
494+
{ID: "q/fresh", State: entity.BatchStateSpeculating},
495+
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/built", "q/fresh"}},
496+
}
497+
pred := evidencePredictor(t, evidence.Factors{PathPassed: 9, PathFailed: 1, Merging: 1, Cancelling: 1})
498+
499+
iter, err := New(pred).Generate(context.Background(), batches, []entity.SpeculationPathSet{allSucceedSet("q/built", entity.SpeculationPathStatusPassed)})
496500
require.NoError(t, err)
497501
cands := forHead(drainAll(t, iter), "q/H")
498502
require.NotEmpty(t, cands)
@@ -511,25 +515,56 @@ func TestBestFirst_EvidencePathPassedRanksTheGreenDependencyFirst(t *testing.T)
511515
assert.Greater(t, cands[0].RankingScore, failScore)
512516
}
513517

518+
func TestBestFirst_EvidencePathFailedPrefersTheFailedSide(t *testing.T) {
519+
batches := []entity.Batch{
520+
{ID: "q/failed", State: entity.BatchStateSpeculating},
521+
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/failed"}},
522+
}
523+
pred := evidencePredictor(t, evidence.Factors{PathPassed: 1, PathFailed: 0.25, Merging: 1, Cancelling: 1})
524+
525+
iter, err := New(pred).Generate(context.Background(), batches, []entity.SpeculationPathSet{allSucceedSet("q/failed", entity.SpeculationPathStatusFailed)})
526+
require.NoError(t, err)
527+
cands := forHead(drainAll(t, iter), "q/H")
528+
require.Len(t, cands, 2)
529+
assert.Equal(t, entity.DependencyAssumptionFails, assumptionFor(cands[0].Path, "q/failed"))
530+
assert.Equal(t, entity.DependencyAssumptionSucceeds, assumptionFor(cands[1].Path, "q/failed"))
531+
assert.Greater(t, cands[0].RankingScore, cands[1].RankingScore)
532+
}
533+
534+
func TestBestFirst_EvidenceCancellingPrefersTheFailedSide(t *testing.T) {
535+
batches := []entity.Batch{
536+
{ID: "q/stopping", State: entity.BatchStateCancelling},
537+
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/stopping"}},
538+
}
539+
pred := evidencePredictor(t, evidence.Factors{PathPassed: 1, PathFailed: 1, Merging: 1, Cancelling: 0.25})
540+
541+
iter, err := New(pred).Generate(context.Background(), batches, nil)
542+
require.NoError(t, err)
543+
cands := drainAll(t, iter)
544+
require.Len(t, cands, 2)
545+
assert.Equal(t, entity.DependencyAssumptionFails, assumptionFor(cands[0].Path, "q/stopping"))
546+
assert.Equal(t, entity.DependencyAssumptionSucceeds, assumptionFor(cands[1].Path, "q/stopping"))
547+
assert.Greater(t, cands[0].RankingScore, cands[1].RankingScore)
548+
}
549+
514550
// A merging dependency is still in progress — the merge can fail — so it stays
515-
// an open question here like any other. Whether a path betting against it is
516-
// worth funding is a matter of price, which is the scorer's to say, not a
517-
// state the search hard-codes.
551+
// an open question here like any other. How much it is worth is a predictor
552+
// price, not a fact the search hard-codes.
518553
func TestBestFirst_MergingDependencyStaysOpen(t *testing.T) {
519554
batches := []entity.Batch{
520555
{ID: "q/landing", State: entity.BatchStateMerging},
521556
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/landing"}},
522557
}
523-
sc := newCountingPredictor(map[string]float64{"q/landing": 0.9})
558+
pred := evidencePredictor(t, evidence.Factors{PathPassed: 1, PathFailed: 1, Merging: 19, Cancelling: 1})
524559

525-
iter, err := New(sc).Generate(context.Background(), batches, nil)
560+
iter, err := New(pred).Generate(context.Background(), batches, nil)
526561
require.NoError(t, err)
527562
cands := drainAll(t, iter)
528563

529-
assert.Equal(t, 1, sc.calls["q/landing"], "a merging dependency is priced like any other")
530564
require.Len(t, cands, 2, "both sides of a merge that has not landed yet")
531565
assert.Equal(t, entity.DependencyAssumptionSucceeds, assumptionFor(cands[0].Path, "q/landing"))
532566
assert.Equal(t, entity.DependencyAssumptionFails, assumptionFor(cands[1].Path, "q/landing"))
567+
assert.Greater(t, cands[0].RankingScore, cands[1].RankingScore)
533568
}
534569

535570
func TestBestFirst_GeneratesOnlyWhatIsPulled(t *testing.T) {

0 commit comments

Comments
 (0)