Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions submitqueue/extension/speculation/predictor/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "go_default_library",
srcs = ["predictor.go"],
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/predictor",
visibility = ["//visibility:public"],
deps = ["//submitqueue/entity:go_default_library"],
)
17 changes: 17 additions & 0 deletions submitqueue/extension/speculation/predictor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# predictor

A `Predictor` returns how likely a batch is to reach `Succeeded` with its changes landed, given both what it changes and what this speculate run has already observed. It is built over the queue's `Scorer`, which prices the change from content signals; the predictor revises that price with path-set evidence and batch state.

`Predict` is handed the batch identity and that batch's own `SpeculationPathSet` — zero-valued when nothing has speculated on it yet. Callers may predict every unresolved dependency a queue waits on, so anything expensive belongs behind the implementation's own cache.

Like the other extensions, a `Predictor` is selected **per queue** by the wiring layer through the `Config` (queue name) and `Factory` interface. The default `standard` `Speculator` composes its `Generator` over the queue's predictor, which in turn composes over the queue's scorer.

See [doc/rfc/submitqueue/outcome-predictor.md](../../../../doc/rfc/submitqueue/outcome-predictor.md) for the factor contract, evidence rules, and configuration shape.

## Implementations

**`evidence`** revises the scorer's price with YAML-configured factors for `pathPassed`, `pathFailed`, `merging`, and `cancelling`. A factor of `1` leaves the scorer's price alone; every factor defaults to `1` until someone sets one. Only paths that assume every dependency succeeds count as path evidence.

## Adding a backend

Create a package under `predictor/<backend>/` whose `New(...)` returns a `predictor.Predictor`, injecting whatever it needs at construction — typically the queue's `Scorer`, factor configuration, and a metrics scope. Do not add a `Config` or `Factory` implementation here; per-queue routing and the factory adapter live in the wiring layer.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = ["evidence.go"],
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/predictor/evidence",
visibility = ["//visibility:public"],
deps = [
"//platform/metrics:go_default_library",
"//submitqueue/entity:go_default_library",
"//submitqueue/extension/speculation/predictor:go_default_library",
"//submitqueue/extension/speculation/scorer:go_default_library",
"@com_github_uber_go_tally//:go_default_library",
],
)

go_test(
name = "go_default_test",
srcs = ["evidence_test.go"],
embed = [":go_default_library"],
deps = [
"//submitqueue/entity:go_default_library",
"//submitqueue/extension/speculation/predictor:go_default_library",
"//submitqueue/extension/speculation/scorer:go_default_library",
"@com_github_stretchr_testify//assert:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
"@com_github_uber_go_tally//:go_default_library",
],
)
168 changes: 168 additions & 0 deletions submitqueue/extension/speculation/predictor/evidence/evidence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2025 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package evidence revises a Scorer's price with factors for observed batch
// progress. See doc/rfc/submitqueue/outcome-predictor.md.
package evidence

import (
"fmt"
"math"

"context"

"github.com/uber-go/tally"
"github.com/uber/submitqueue/platform/metrics"
"github.com/uber/submitqueue/submitqueue/entity"
"github.com/uber/submitqueue/submitqueue/extension/speculation/predictor"
"github.com/uber/submitqueue/submitqueue/extension/speculation/scorer"
)

// Factors revise the scorer's price, one per piece of evidence. A factor of 1
// leaves the price alone. Named fields make unknown evidence fail to compile.
type Factors struct {
// PathPassed applies once when a build has passed on the batch's
// all-succeed path.
PathPassed float64
// PathFailed applies once when the all-succeed path has failed.
PathFailed float64
// Merging applies while the batch is merging.
Merging float64
// Cancelling applies while the batch is cancelling.
Cancelling float64
}

// AllOnes is the neutral set: the prediction is the scorer's price.
func AllOnes() Factors {
return Factors{PathPassed: 1, PathFailed: 1, Merging: 1, Cancelling: 1}
}

// epsilon keeps exact certainty revisable while remaining close to the scorer.
const epsilon = 1e-6

// evidence is a predictor.Predictor that revises a scorer's price.
type evidence struct {
// cfg is the per-queue identity this predictor was built for.
cfg predictor.Config
// base prices the batch's change; its price is what the factors revise.
base scorer.Scorer
// factors revise the scorer's price with observed evidence.
factors Factors
// scope is the tally scope for emitting metrics.
scope tally.Scope
}

// New creates an evidence predictor bound to the queue named in cfg, revising
// base's price by factors.
//
// It rejects a nil base and factors that are non-finite or not positive.
func New(cfg predictor.Config, base scorer.Scorer, factors Factors, scope tally.Scope) (predictor.Predictor, error) {
if base == nil {
return nil, fmt.Errorf("evidence.New: base must not be nil")
}
for name, factor := range map[string]float64{
"PathPassed": factors.PathPassed,
"PathFailed": factors.PathFailed,
"Merging": factors.Merging,
"Cancelling": factors.Cancelling,
} {
// Zero would permanently pin matching batches to 0; negatives cannot
// represent either direction in the factor contract.
if !(factor > 0) || math.IsInf(factor, 0) {
return nil, fmt.Errorf("evidence.New: factor %s must be finite and positive, got %v", name, factor)
}
}
return &evidence{cfg: cfg, base: base, factors: factors, scope: scope}, nil
}

// Predict prices the batch's change, combines its evidence factors, and revises
// the scorer's price with the result.
func (r *evidence) Predict(ctx context.Context, batch entity.Batch, paths entity.SpeculationPathSet) (ret predictor.Probability, retErr error) {
op := metrics.Begin(r.scope, "predict", metrics.FastLatencyBuckets)
defer func() { op.Complete(retErr) }()

price, err := r.base.Score(ctx, batch)
if err != nil {
return 0, err
}
// A price that is not a probability is a broken scorer, not a low opinion of
// the batch. Saying so leaves the caller to fall back on its own default,
// where clamping would hand back a number that looks deliberate.
if !(price >= 0 && price <= 1) {
return 0, fmt.Errorf("base scorer returned %v, which is not a probability", price)
}

factor := 1.0
if hasPassedAllSucceedPath(paths) {
factor *= r.factors.PathPassed
}
if hasFailedAllSucceedPath(paths) {
factor *= r.factors.PathFailed
}
switch batch.State {
case entity.BatchStateMerging:
factor *= r.factors.Merging
case entity.BatchStateCancelling:
factor *= r.factors.Cancelling
}
if factor == 1 {
return predictor.Probability(price), nil
}
return revise(math.Min(math.Max(price, epsilon), 1-epsilon), factor), nil
}

// revise applies the combined factor while keeping the result a probability.
func revise(price, factor float64) predictor.Probability {
if math.IsInf(factor, 1) {
return 1 - epsilon
}
revised := price * factor / (1 - price + price*factor)
return predictor.Probability(math.Min(math.Max(revised, epsilon), 1-epsilon))
}

// hasPassedAllSucceedPath reports a passed build on the batch's all-succeed
// path. Only that path counts: one built without a dependency's changes says
// nothing about a candidate that assumes the dependency lands.
func hasPassedAllSucceedPath(paths entity.SpeculationPathSet) bool {
for _, entry := range paths.Paths {
if entry.Status != entity.SpeculationPathStatusPassed {
continue
}
if assumesAllSucceed(entry.Path) {
return true
}
}
return false
}

// assumesAllSucceed reports whether every dependency is assumed to succeed.
func assumesAllSucceed(path entity.SpeculationPath) bool {
for _, dep := range path.Dependencies {
if dep.Assumption != entity.DependencyAssumptionSucceeds {
return false
}
}
return true
}

// hasFailedAllSucceedPath reports a failed build on the batch's all-succeed
// path. Flip-subset failures were built under different assumptions.
func hasFailedAllSucceedPath(paths entity.SpeculationPathSet) bool {
for _, entry := range paths.Paths {
if entry.Status == entity.SpeculationPathStatusFailed && assumesAllSucceed(entry.Path) {
return true
}
}
return false
}
Loading
Loading