Skip to content

Commit dc3933b

Browse files
behinddwallsgithub-actions[bot]
authored andcommitted
feat(speculation): add probability path scorer
## Summary ### Why? The pathscorer seam has no real implementation — only mocks and a programmable fake — yet its output is the ranking currency the selector and prioritizer act on. Speculation needs a default scorer whose scores mean something from the start: inert while trees hold a single path, but ranking chain-vs-fallback paths correctly the moment an enumerator produces alternatives. It is split out from the parity-stub impls because it is the one seam implementation with an actual model, worth reviewing on its own. ### What? `pathscorer/probability` — scores each path as the probability that exactly that path's assumption holds: `Score = p(head) × Π p(d) for base deps × Π (1−p(d)) for non-base head deps`, returned as path-ID-keyed `PathScore`s per the seam contract (probabilities in [0, 1] by construction). Each batch's probability resolves from its state, so resolved outcomes override predictions: `succeeded` → 1, `failed`/`cancelled` → 0, otherwise the score stage's predicted `Batch.Score` (the score stage sets it before a batch reaches speculation; best-effort `cancelling` keeps its prediction). A dead dependency therefore zeroes every path built on it and boosts every path that excluded it by the full `(1−p)=1` factor — score mass shifts to the paths consistent with reality, with no cross-path coupling. Folding outcomes into path *status* stays the controller's reconcile job; the scorer only recomputes scores, over every path regardless of status. Dependencies are read one at a time through the injected `storage.BatchStore` per the store's key/value contract, each batch loaded at most once per call; store errors return wrapped and unclassified. The README walks a three-path worked example (chain / drop-B / alone) before and after a dependency failure. ## Test Plan ✅ `make gazelle && make fmt && bazel test //submitqueue/extension/speculation/...`. Unit coverage: probability-formula table including all state-resolution cases (succeeded/failed/cancelled in and out of base, cancelling keeps prediction), a three-path dependency-failure scenario pinning the score-mass shift, per-dep single store read, error propagation, empty-tree short-circuit.
1 parent b63cb36 commit dc3933b

4 files changed

Lines changed: 438 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["probability.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer/probability",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//submitqueue/entity:go_default_library",
10+
"//submitqueue/extension/speculation/pathscorer:go_default_library",
11+
"//submitqueue/extension/storage:go_default_library",
12+
],
13+
)
14+
15+
go_test(
16+
name = "go_default_test",
17+
srcs = ["probability_test.go"],
18+
embed = [":go_default_library"],
19+
deps = [
20+
"//submitqueue/entity:go_default_library",
21+
"//submitqueue/extension/storage/mock:go_default_library",
22+
"@com_github_stretchr_testify//assert:go_default_library",
23+
"@com_github_stretchr_testify//require:go_default_library",
24+
"@org_uber_go_mock//gomock:go_default_library",
25+
],
26+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Probability Path Scorer
2+
3+
The probability `pathscorer.Scorer` scores every path in a batch's speculation tree as the probability that exactly that path's assumption holds: its base dependencies all land, and every other active dependency of the head fails to land. Concretely, a path's score is the head batch's probability, multiplied by the probability of each base dependency, multiplied by one minus the probability of each of the head's dependencies not in the base.
4+
5+
Each batch's probability is resolved from its state, so a resolved outcome overrides the prediction. A batch that has succeeded contributes certainty 1; a batch that has failed or been cancelled contributes 0; a batch still in flight contributes `entity.Batch.Score`, the per-batch success probability the score stage sets before a batch ever reaches speculation. A batch in the best-effort `cancelling` state keeps its prediction — it may still succeed. This state resolution is what redistributes score when the world changes: a dead dependency zeroes every path that built on it and boosts every path that excluded it by the full `(1 − p) = 1` factor, with no cross-path coupling needed. A worked example of this redistribution lives in the package documentation.
6+
7+
## Scope
8+
9+
The model is deliberately simple: it treats dependency outcomes as independent, and does not weigh how long a batch has waited, its historical pass rate, or any correlation between siblings. It scores every path in the tree regardless of status, returning one path-ID-keyed score per path — the controller merges them into the tree and overwrites `Score` wholesale on every respeculate; nothing else about a path passes through the scorer. Folding resolved outcomes into path *status* (dead-pathing, cancellation) is the controller's reconcile job, not the scorer's; the scorer only makes the scores reflect them.
10+
11+
Batches are read one at a time through the injected `storage.BatchStore`, matching the store's key/value contract, and each batch referenced by the tree is loaded at most once per `Score` call. A store error, including a missing batch, is returned wrapped and unclassified — scorer implementations, like all extensions, leave user/infra classification to the controller's error classifier.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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 probability provides a pathscorer.Scorer that scores each path as
16+
// the probability that exactly that path's assumption holds: every base
17+
// dependency lands and every non-base dependency of the head does not. Each
18+
// dependency's probability is resolved from its batch's state — certainty
19+
// (1 or 0) once the batch is terminal, its predicted build-success score
20+
// while it is in flight — so a resolved dependency automatically kills the
21+
// paths that bet against the outcome and boosts the paths consistent with
22+
// it. Dependency outcomes are treated as independent; there is no adjustment
23+
// for wait time, historical pass rate, or correlation between outcomes.
24+
//
25+
// # Worked example
26+
//
27+
// Batch C has active dependencies A and B, with predicted success
28+
// probabilities p(C) = 0.8, p(A) = 0.9, p(B) = 0.6, and a three-path tree:
29+
//
30+
// path base formula score
31+
// chain [A B] 0.8 * 0.9 * 0.6 0.432
32+
// drop-B [A] 0.8 * 0.9 * (1 - 0.6) 0.288
33+
// alone [] 0.8 * (1 - 0.9) * (1 - 0.6) 0.032
34+
//
35+
// The chain path leads while everything looks healthy. When B's build fails,
36+
// p(B) resolves to 0:
37+
//
38+
// path base formula score
39+
// chain [A B] 0.8 * 0.9 * 0 0
40+
// drop-B [A] 0.8 * 0.9 * 1 0.72
41+
// alone [] 0.8 * 0.1 * 1 0.08
42+
//
43+
// The chain path — a bet on B landing — dies, and the drop-B path inherits
44+
// its score mass. If A then lands, p(A) resolves to 1: drop-B rises to 0.8
45+
// and alone falls to 0. The selector and prioritizer rank on these scores,
46+
// so builds follow the paths still consistent with reality.
47+
package probability
48+
49+
import (
50+
"context"
51+
"fmt"
52+
53+
"github.com/uber/submitqueue/submitqueue/entity"
54+
"github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer"
55+
"github.com/uber/submitqueue/submitqueue/extension/storage"
56+
)
57+
58+
// probabilityScorer computes each path's Score as the probability that
59+
// exactly its assumption holds: every base dependency lands and every other
60+
// active dependency of the head fails to land — so this exact path, and no
61+
// sibling, is the one that materializes. Batch probabilities come from
62+
// batchProbability, which prefers a terminal outcome over the prediction.
63+
// Folding resolved outcomes into path Status (dead-pathing, cancellation)
64+
// remains the controller's reconcile concern; this scorer only recomputes
65+
// Score.
66+
type probabilityScorer struct {
67+
// batches resolves a batch ID to its current state and predicted-success
68+
// probability (entity.Batch.State / entity.Batch.Score) and, for the
69+
// tree's head batch, its full active-dependency set
70+
// (entity.Batch.Dependencies).
71+
batches storage.BatchStore
72+
}
73+
74+
// New returns a pathscorer.Scorer implementing the path-probability
75+
// heuristic, reading batch states and success probabilities from batches.
76+
func New(batches storage.BatchStore) pathscorer.Scorer {
77+
return &probabilityScorer{batches: batches}
78+
}
79+
80+
// batchProbability resolves the probability that b lands. A terminal state
81+
// is certainty — 1 for succeeded, 0 for failed or cancelled — and overrides
82+
// any prediction. Otherwise it is b.Score, the predicted build-success
83+
// probability: the score stage sets it before a batch ever reaches
84+
// speculation, so every batch the scorer sees carries a real prediction.
85+
// Cancelling is deliberately not treated as terminal: cancellation is
86+
// best-effort and the batch may still succeed, so the prediction stands
87+
// until the state settles.
88+
func batchProbability(b entity.Batch) float64 {
89+
switch b.State {
90+
case entity.BatchStateSucceeded:
91+
return 1
92+
case entity.BatchStateFailed, entity.BatchStateCancelled:
93+
return 0
94+
}
95+
return b.Score
96+
}
97+
98+
// Score returns one PathScore per path in tree, regardless of Status, each
99+
// naming its path by ID. A path's score is:
100+
//
101+
// p(head) * Π p(d) for d in path.Base * Π (1 - p(d)) for d in head.Dependencies but not in path.Base
102+
//
103+
// where p(b) is 1 when b has succeeded, 0 when b has failed or been
104+
// cancelled, and otherwise b.Score — see batchProbability. Each batch
105+
// referenced by the tree is loaded from the store at most once. Any store
106+
// error (including not-found) is returned wrapped, unclassified — extensions
107+
// never classify errors as user or infra.
108+
func (s *probabilityScorer) Score(ctx context.Context, tree entity.SpeculationTree) ([]entity.PathScore, error) {
109+
if len(tree.Paths) == 0 {
110+
return nil, nil
111+
}
112+
113+
head, err := s.batches.Get(ctx, tree.BatchID)
114+
if err != nil {
115+
return nil, fmt.Errorf("probability: get head batch %q: %w", tree.BatchID, err)
116+
}
117+
118+
probabilities := map[string]float64{}
119+
probabilityOf := func(batchID string) (float64, error) {
120+
if p, ok := probabilities[batchID]; ok {
121+
return p, nil
122+
}
123+
b, err := s.batches.Get(ctx, batchID)
124+
if err != nil {
125+
return 0, fmt.Errorf("probability: get dependency batch %q: %w", batchID, err)
126+
}
127+
p := batchProbability(b)
128+
probabilities[batchID] = p
129+
return p, nil
130+
}
131+
132+
scores := make([]entity.PathScore, len(tree.Paths))
133+
for i, path := range tree.Paths {
134+
inBase := make(map[string]bool, len(path.Path.Base))
135+
score := batchProbability(head)
136+
for _, dep := range path.Path.Base {
137+
inBase[dep] = true
138+
p, err := probabilityOf(dep)
139+
if err != nil {
140+
return nil, err
141+
}
142+
score *= p
143+
}
144+
for _, dep := range head.Dependencies {
145+
if inBase[dep] {
146+
continue
147+
}
148+
p, err := probabilityOf(dep)
149+
if err != nil {
150+
return nil, err
151+
}
152+
score *= 1 - p
153+
}
154+
scores[i] = entity.PathScore{PathID: path.ID, Score: float32(score)}
155+
}
156+
157+
return scores, nil
158+
}

0 commit comments

Comments
 (0)