Skip to content

Commit 92500ce

Browse files
behinddwallsclaude
andauthored
feat(scorer): add scorer extension with heuristic and composite implementations (#97)
## Why? SubmitQueue needs a way to estimate the success probability of a code change before landing it. Different heuristics (file count, dependency count, lines changed) each provide a signal, but no single metric is sufficient. We need a pluggable scoring system that can combine multiple signals into a single probability. ## What? Adds a new `extension/scorer` package with: - **`scorer.Scorer` interface** — defines `Score(ctx, ChangeStats) (float64, error)` returning a probability between 0.0 and 1.0 - **`scorer.ChangeStats` entity** — aggregates code change metrics (files changed, lines added/deleted/modified, build targets added/removed/changed, dependency count) - **Heuristic scorer** (`extension/scorer/heuristic/`) — buckets a single extracted metric into probability ranges using configurable `Bucket` definitions and `StatFunc` extractors - **Composite scorer** (`extension/scorer/composite/`) — wraps multiple `Scorer` instances and combines their results with a `ReduceFunc` (built-in: `Min`, `Max`, `Avg`) - **Gomock setup** (`extension/scorer/mock/`) — build-time generated mocks for the `Scorer` interface Both constructors return the `scorer.Scorer` interface and validate inputs (nil functions, empty scorers) by returning errors. ## Test Plan - [x] `bazel build //extension/scorer/...` — all 5 targets compile - [x] `bazel test //extension/scorer/heuristic:heuristic_test` — table-driven tests covering bucket matching, boundary conditions, multiple stat functions, no-match errors, and nil statFunc validation - [x] `bazel test //extension/scorer/composite:composite_test` — table-driven tests covering Min/Max/Avg reduce, single scorer passthrough, child error propagation, three-scorer average, empty scorers error, and nil reduce error --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 85a6901 commit 92500ce

10 files changed

Lines changed: 562 additions & 0 deletions

File tree

extension/scorer/BUILD.bazel

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
exports_files(
4+
["scorer.go"],
5+
visibility = ["//extension/scorer/mock:__pkg__"],
6+
)
7+
8+
go_library(
9+
name = "scorer",
10+
srcs = ["scorer.go"],
11+
importpath = "github.com/uber/submitqueue/extension/scorer",
12+
visibility = ["//visibility:public"],
13+
deps = ["//entity"],
14+
)

extension/scorer/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Scorer
2+
3+
Vendor-agnostic interface for computing success probability scores for code changes.
4+
5+
## Interface
6+
7+
### Scorer
8+
9+
Computes a success probability for a given change.
10+
11+
```go
12+
type Scorer interface {
13+
Score(ctx context.Context, change entity.Change) (float64, error)
14+
}
15+
```
16+
17+
- **change**: A `entity.Change` identifying the code change to score.
18+
- **Score**: Returns a probability between 0.0 and 1.0 indicating the likelihood of a successful land. Returns an error if scoring fails.
19+
20+
## Implementations
21+
22+
### Heuristic
23+
24+
Scores a change by extracting a numeric value via a `ValueFunc` and matching it against ordered buckets. Each bucket maps a `[Min, Max]` range to a probability.
25+
26+
```go
27+
s := heuristic.New(
28+
[]heuristic.Bucket{
29+
{Min: 0, Max: 5, Score: 0.95},
30+
{Min: 6, Max: 20, Score: 0.75},
31+
{Min: 21, Max: 100, Score: 0.5},
32+
},
33+
func(ctx context.Context, change entity.Change) (int, error) {
34+
// resolve the change into a numeric metric
35+
return filesChanged, nil
36+
},
37+
)
38+
39+
score, err := s.Score(ctx, change)
40+
```
41+
42+
### Composite
43+
44+
Combines multiple named scorers into a single score using a reduce function. The reduce function receives a `map[string]float64` mapping scorer names to their scores, enabling domain-aware aggregation.
45+
46+
Built-in reduce functions: `Min`, `Max`, `Avg`.
47+
48+
```go
49+
s := composite.New(
50+
map[string]scorer.Scorer{
51+
"files": fileScorer,
52+
"deps": depScorer,
53+
},
54+
composite.Min,
55+
)
56+
57+
score, err := s.Score(ctx, change)
58+
```
59+
60+
## Implementing a Backend
61+
62+
1. Create `extension/scorer/{backend}/` directory
63+
2. Implement the `Scorer` interface
64+
3. Accept `entity.Change` and resolve it into whatever data the implementation needs
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "composite",
5+
srcs = ["scorer.go"],
6+
importpath = "github.com/uber/submitqueue/extension/scorer/composite",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//entity",
10+
"//extension/scorer",
11+
],
12+
)
13+
14+
go_test(
15+
name = "composite_test",
16+
srcs = ["scorer_test.go"],
17+
embed = [":composite"],
18+
deps = [
19+
"//entity",
20+
"//extension/scorer",
21+
"@com_github_stretchr_testify//assert",
22+
"@com_github_stretchr_testify//require",
23+
],
24+
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package composite
2+
3+
import (
4+
"context"
5+
6+
"github.com/uber/submitqueue/entity"
7+
"github.com/uber/submitqueue/extension/scorer"
8+
)
9+
10+
// ReduceFunc combines named scores into a single score.
11+
type ReduceFunc func(map[string]float64) float64
12+
13+
// Min returns the minimum value from scores.
14+
func Min(scores map[string]float64) float64 {
15+
var min float64
16+
first := true
17+
for _, v := range scores {
18+
if first || v < min {
19+
min = v
20+
first = false
21+
}
22+
}
23+
return min
24+
}
25+
26+
// Max returns the maximum value from scores.
27+
func Max(scores map[string]float64) float64 {
28+
var max float64
29+
first := true
30+
for _, v := range scores {
31+
if first || v > max {
32+
max = v
33+
first = false
34+
}
35+
}
36+
return max
37+
}
38+
39+
// Avg returns the arithmetic mean of scores.
40+
func Avg(scores map[string]float64) float64 {
41+
var sum float64
42+
for _, v := range scores {
43+
sum += v
44+
}
45+
return sum / float64(len(scores))
46+
}
47+
48+
// compositeScorer combines multiple named scorers into a single score using a reduce function.
49+
type compositeScorer struct {
50+
// scorers maps scorer names to their implementations.
51+
scorers map[string]scorer.Scorer
52+
// reduce combines named scores into a single value.
53+
reduce ReduceFunc
54+
}
55+
56+
// New creates a composite Scorer that evaluates all named child scorers and combines
57+
// their results using the given reduce function.
58+
// Panics if scorers is empty or reduce is nil.
59+
func New(scorers map[string]scorer.Scorer, reduce ReduceFunc) scorer.Scorer {
60+
if len(scorers) == 0 {
61+
panic("composite.New: scorers must not be empty")
62+
}
63+
if reduce == nil {
64+
panic("composite.New: reduce must not be nil")
65+
}
66+
return &compositeScorer{
67+
scorers: scorers,
68+
reduce: reduce,
69+
}
70+
}
71+
72+
// Score evaluates all child scorers and combines their results using the reduce function.
73+
// If any child scorer returns an error, that error is returned immediately.
74+
func (c *compositeScorer) Score(ctx context.Context, change entity.Change) (float64, error) {
75+
scores := make(map[string]float64, len(c.scorers))
76+
for name, s := range c.scorers {
77+
score, err := s.Score(ctx, change)
78+
if err != nil {
79+
return 0, err
80+
}
81+
scores[name] = score
82+
}
83+
return c.reduce(scores), nil
84+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package composite
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"github.com/uber/submitqueue/entity"
11+
"github.com/uber/submitqueue/extension/scorer"
12+
)
13+
14+
// fixedScorer always returns a fixed score.
15+
type fixedScorer struct {
16+
score float64
17+
}
18+
19+
func (f *fixedScorer) Score(_ context.Context, _ entity.Change) (float64, error) {
20+
return f.score, nil
21+
}
22+
23+
// errorScorer always returns an error.
24+
type errorScorer struct{}
25+
26+
func (e *errorScorer) Score(_ context.Context, _ entity.Change) (float64, error) {
27+
return 0, fmt.Errorf("scorer failed")
28+
}
29+
30+
func TestScorer_Score(t *testing.T) {
31+
tests := []struct {
32+
name string
33+
scorers map[string]scorer.Scorer
34+
reduce ReduceFunc
35+
want float64
36+
}{
37+
{
38+
name: "min of two scorers",
39+
scorers: map[string]scorer.Scorer{
40+
"files": &fixedScorer{0.9},
41+
"deps": &fixedScorer{0.6},
42+
},
43+
reduce: Min,
44+
want: 0.6,
45+
},
46+
{
47+
name: "max of two scorers",
48+
scorers: map[string]scorer.Scorer{
49+
"files": &fixedScorer{0.9},
50+
"deps": &fixedScorer{0.6},
51+
},
52+
reduce: Max,
53+
want: 0.9,
54+
},
55+
{
56+
name: "avg of two scorers",
57+
scorers: map[string]scorer.Scorer{
58+
"files": &fixedScorer{0.9},
59+
"deps": &fixedScorer{0.6},
60+
},
61+
reduce: Avg,
62+
want: 0.75,
63+
},
64+
{
65+
name: "single scorer passthrough",
66+
scorers: map[string]scorer.Scorer{
67+
"files": &fixedScorer{0.9},
68+
},
69+
reduce: Avg,
70+
want: 0.9,
71+
},
72+
{
73+
name: "avg of three scorers",
74+
scorers: map[string]scorer.Scorer{
75+
"files": &fixedScorer{0.9},
76+
"deps": &fixedScorer{0.95},
77+
"lines": &fixedScorer{0.85},
78+
},
79+
reduce: Avg,
80+
want: 0.9,
81+
},
82+
}
83+
84+
for _, tt := range tests {
85+
t.Run(tt.name, func(t *testing.T) {
86+
s := New(tt.scorers, tt.reduce)
87+
got, err := s.Score(context.Background(), entity.Change{})
88+
require.NoError(t, err)
89+
assert.InDelta(t, tt.want, got, 1e-9)
90+
})
91+
}
92+
}
93+
94+
func TestScorer_Score_ChildError(t *testing.T) {
95+
s := New(map[string]scorer.Scorer{
96+
"error": &errorScorer{},
97+
"files": &fixedScorer{0.9},
98+
}, Min)
99+
_, err := s.Score(context.Background(), entity.Change{})
100+
require.Error(t, err)
101+
}
102+
103+
func TestNew_EmptyScorers(t *testing.T) {
104+
assert.Panics(t, func() {
105+
New(map[string]scorer.Scorer{}, Min)
106+
})
107+
}
108+
109+
func TestNew_NilReduce(t *testing.T) {
110+
assert.Panics(t, func() {
111+
New(map[string]scorer.Scorer{"files": &fixedScorer{0.9}}, nil)
112+
})
113+
}
114+
115+
func TestReduceFunc_ReceivesNames(t *testing.T) {
116+
var receivedNames []string
117+
custom := func(scores map[string]float64) float64 {
118+
for name := range scores {
119+
receivedNames = append(receivedNames, name)
120+
}
121+
return scores["files"]
122+
}
123+
124+
s := New(map[string]scorer.Scorer{
125+
"files": &fixedScorer{0.9},
126+
"deps": &fixedScorer{0.95},
127+
}, custom)
128+
got, err := s.Score(context.Background(), entity.Change{})
129+
require.NoError(t, err)
130+
assert.Equal(t, 0.9, got)
131+
assert.ElementsMatch(t, []string{"files", "deps"}, receivedNames)
132+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "heuristic",
5+
srcs = ["scorer.go"],
6+
importpath = "github.com/uber/submitqueue/extension/scorer/heuristic",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//entity",
10+
"//extension/scorer",
11+
],
12+
)
13+
14+
go_test(
15+
name = "heuristic_test",
16+
srcs = ["scorer_test.go"],
17+
embed = [":heuristic"],
18+
deps = [
19+
"//entity",
20+
"@com_github_stretchr_testify//assert",
21+
"@com_github_stretchr_testify//require",
22+
],
23+
)

0 commit comments

Comments
 (0)