Skip to content
Merged
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
14 changes: 14 additions & 0 deletions extension/scorer/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
load("@rules_go//go:def.bzl", "go_library")

exports_files(
["scorer.go"],
visibility = ["//extension/scorer/mock:__pkg__"],
)

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

Vendor-agnostic interface for computing success probability scores for code changes.

## Interface

### Scorer

Computes a success probability for a given change.

```go
type Scorer interface {
Score(ctx context.Context, change entity.Change) (float64, error)
}
```

- **change**: A `entity.Change` identifying the code change to score.
- **Score**: Returns a probability between 0.0 and 1.0 indicating the likelihood of a successful land. Returns an error if scoring fails.

## Implementations

### Heuristic

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.

```go
s := heuristic.New(
[]heuristic.Bucket{
{Min: 0, Max: 5, Score: 0.95},
{Min: 6, Max: 20, Score: 0.75},
{Min: 21, Max: 100, Score: 0.5},
},
func(ctx context.Context, change entity.Change) (int, error) {
// resolve the change into a numeric metric
return filesChanged, nil
},
)

score, err := s.Score(ctx, change)
```

### Composite

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.

Built-in reduce functions: `Min`, `Max`, `Avg`.

```go
s := composite.New(
map[string]scorer.Scorer{
"files": fileScorer,
"deps": depScorer,
},
composite.Min,
)

score, err := s.Score(ctx, change)
```

## Implementing a Backend

1. Create `extension/scorer/{backend}/` directory
2. Implement the `Scorer` interface
3. Accept `entity.Change` and resolve it into whatever data the implementation needs
24 changes: 24 additions & 0 deletions extension/scorer/composite/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")
Comment thread
behinddwalls marked this conversation as resolved.

go_library(
name = "composite",
srcs = ["scorer.go"],
importpath = "github.com/uber/submitqueue/extension/scorer/composite",
visibility = ["//visibility:public"],
deps = [
"//entity",
"//extension/scorer",
],
)

go_test(
name = "composite_test",
srcs = ["scorer_test.go"],
embed = [":composite"],
deps = [
"//entity",
"//extension/scorer",
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)
84 changes: 84 additions & 0 deletions extension/scorer/composite/scorer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package composite

import (
"context"

"github.com/uber/submitqueue/entity"
"github.com/uber/submitqueue/extension/scorer"
)

// ReduceFunc combines named scores into a single score.
type ReduceFunc func(map[string]float64) float64

// Min returns the minimum value from scores.
func Min(scores map[string]float64) float64 {
var min float64
first := true
for _, v := range scores {
if first || v < min {
min = v
first = false
}
}
return min
}

// Max returns the maximum value from scores.
func Max(scores map[string]float64) float64 {
var max float64
first := true
for _, v := range scores {
if first || v > max {
max = v
first = false
}
}
return max
}

// Avg returns the arithmetic mean of scores.
func Avg(scores map[string]float64) float64 {
var sum float64
for _, v := range scores {
sum += v
}
return sum / float64(len(scores))
}

// compositeScorer combines multiple named scorers into a single score using a reduce function.
type compositeScorer struct {
// scorers maps scorer names to their implementations.
scorers map[string]scorer.Scorer
// reduce combines named scores into a single value.
reduce ReduceFunc
}

// New creates a composite Scorer that evaluates all named child scorers and combines
// their results using the given reduce function.
// Panics if scorers is empty or reduce is nil.
func New(scorers map[string]scorer.Scorer, reduce ReduceFunc) scorer.Scorer {
if len(scorers) == 0 {
panic("composite.New: scorers must not be empty")
}
if reduce == nil {
Comment thread
behinddwalls marked this conversation as resolved.
panic("composite.New: reduce must not be nil")
}
return &compositeScorer{
scorers: scorers,
reduce: reduce,
}
}

// Score evaluates all child scorers and combines their results using the reduce function.
// If any child scorer returns an error, that error is returned immediately.
func (c *compositeScorer) Score(ctx context.Context, change entity.Change) (float64, error) {
scores := make(map[string]float64, len(c.scorers))
for name, s := range c.scorers {
score, err := s.Score(ctx, change)
if err != nil {
return 0, err
}
scores[name] = score
}
return c.reduce(scores), nil
}
132 changes: 132 additions & 0 deletions extension/scorer/composite/scorer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package composite

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uber/submitqueue/entity"
"github.com/uber/submitqueue/extension/scorer"
)

// fixedScorer always returns a fixed score.
type fixedScorer struct {
score float64
}

func (f *fixedScorer) Score(_ context.Context, _ entity.Change) (float64, error) {
return f.score, nil
}

// errorScorer always returns an error.
type errorScorer struct{}

func (e *errorScorer) Score(_ context.Context, _ entity.Change) (float64, error) {
return 0, fmt.Errorf("scorer failed")
}

func TestScorer_Score(t *testing.T) {
tests := []struct {
name string
scorers map[string]scorer.Scorer
reduce ReduceFunc
want float64
}{
{
name: "min of two scorers",
scorers: map[string]scorer.Scorer{
"files": &fixedScorer{0.9},
"deps": &fixedScorer{0.6},
},
reduce: Min,
want: 0.6,
},
{
name: "max of two scorers",
scorers: map[string]scorer.Scorer{
"files": &fixedScorer{0.9},
"deps": &fixedScorer{0.6},
},
reduce: Max,
want: 0.9,
},
{
name: "avg of two scorers",
scorers: map[string]scorer.Scorer{
"files": &fixedScorer{0.9},
"deps": &fixedScorer{0.6},
},
reduce: Avg,
want: 0.75,
},
{
name: "single scorer passthrough",
scorers: map[string]scorer.Scorer{
"files": &fixedScorer{0.9},
},
reduce: Avg,
want: 0.9,
},
{
name: "avg of three scorers",
scorers: map[string]scorer.Scorer{
"files": &fixedScorer{0.9},
"deps": &fixedScorer{0.95},
"lines": &fixedScorer{0.85},
},
reduce: Avg,
want: 0.9,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := New(tt.scorers, tt.reduce)
got, err := s.Score(context.Background(), entity.Change{})
require.NoError(t, err)
assert.InDelta(t, tt.want, got, 1e-9)
})
}
}

func TestScorer_Score_ChildError(t *testing.T) {
s := New(map[string]scorer.Scorer{
"error": &errorScorer{},
"files": &fixedScorer{0.9},
}, Min)
_, err := s.Score(context.Background(), entity.Change{})
require.Error(t, err)
}

func TestNew_EmptyScorers(t *testing.T) {
assert.Panics(t, func() {
New(map[string]scorer.Scorer{}, Min)
})
}

func TestNew_NilReduce(t *testing.T) {
assert.Panics(t, func() {
New(map[string]scorer.Scorer{"files": &fixedScorer{0.9}}, nil)
})
}

func TestReduceFunc_ReceivesNames(t *testing.T) {
var receivedNames []string
custom := func(scores map[string]float64) float64 {
for name := range scores {
receivedNames = append(receivedNames, name)
}
return scores["files"]
}

s := New(map[string]scorer.Scorer{
"files": &fixedScorer{0.9},
"deps": &fixedScorer{0.95},
}, custom)
got, err := s.Score(context.Background(), entity.Change{})
require.NoError(t, err)
assert.Equal(t, 0.9, got)
assert.ElementsMatch(t, []string{"files", "deps"}, receivedNames)
}
23 changes: 23 additions & 0 deletions extension/scorer/heuristic/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "heuristic",
srcs = ["scorer.go"],
importpath = "github.com/uber/submitqueue/extension/scorer/heuristic",
visibility = ["//visibility:public"],
deps = [
"//entity",
"//extension/scorer",
],
)

go_test(
name = "heuristic_test",
srcs = ["scorer_test.go"],
embed = [":heuristic"],
deps = [
"//entity",
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)
Loading