testplan: Allow multiple board values in CTPv1 compatibility mode.
- The current test plans allow for rules that specify a list of
boards to choose one of, e.g. `only_keep_one_suite_from_each_group`
(https://source.chromium.org/chromium/chromiumos/infra/proto/+/HEAD:src/testplans/source_tree_test_config.proto;l=33;drc=212ee2d69622a9e9026548f7d789ee4e108c4893).
The choice is based off which boards produced a critical build
(i.e. choose a board that had a successful build, using criticality
to break ties). If there are multiple choices which produced a
critical build, choose randomly with probability proportional to
the priorties configured in a static priority list
(http://cs/chromeos_internal/infra/config/testingconfig/generated/board_priority.cfg).
- This is needed in the chromite example, see
http://cs/chromeos_internal/infra/config/testingconfig/source_tree_test_config_main.star;l=507;rcl=0c479a9332f94eac32c76dd16aeb924fbcac571e.
BUG=b:218319842
TEST=CQ
Change-Id: Ia83516ddeaa3961110f2078e448f20a2fd8a17d1
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/3511380
Reviewed-by: Tim Bain <tbain@google.com>
Commit-Queue: Andrew Lamb <andrewlamb@chromium.org>
Tested-by: Andrew Lamb <andrewlamb@chromium.org>
diff --git a/src/chromiumos/test/plan/cmd/testplan.go b/src/chromiumos/test/plan/cmd/testplan.go
index c121c3b..951cb80 100644
--- a/src/chromiumos/test/plan/cmd/testplan.go
+++ b/src/chromiumos/test/plan/cmd/testplan.go
@@ -13,9 +13,11 @@
"flag"
"fmt"
"io/ioutil"
+ "math/rand"
"os"
"regexp"
"strings"
+ "time"
"github.com/golang/glog"
"github.com/golang/protobuf/jsonpb"
@@ -150,6 +152,13 @@
"JSON or binary proto. Should be set iff ctpv1 is set.",
)
r.Flags.StringVar(
+ &r.boardPriorityListPath,
+ "boardprioritylist",
+ "",
+ "Path to a proto file containing a BoardPriorityList. Can be JSON"+
+ "or binary proto. Should be set iff ctpv1 is set.",
+ )
+ r.Flags.StringVar(
&r.out,
"out",
"",
@@ -177,6 +186,7 @@
flatConfigListPath string
ctpV1 bool
generateTestPlanReqPath string
+ boardPriorityListPath string
out string
textSummaryOut string
}
@@ -331,6 +341,10 @@
return errors.New("-generatetestplanreq must be set iff -out is set.")
}
+ if r.ctpV1 != (r.boardPriorityListPath != "") {
+ return errors.New("-boardprioritylist must be set iff -out is set.")
+ }
+
return nil
}
@@ -391,7 +405,15 @@
return err
}
- resp, err := compatibility.ToCTP1(hwTestPlans, generateTestPlanReq, dutAttributeList)
+ boardPriorityList := &testplans.BoardPriorityList{}
+ if err := readBinaryOrJSONPb(r.boardPriorityListPath, boardPriorityList); err != nil {
+ return err
+ }
+
+ resp, err := compatibility.ToCTP1(
+ rand.New(rand.NewSource(time.Now().Unix())),
+ hwTestPlans, generateTestPlanReq, dutAttributeList, boardPriorityList,
+ )
if err != nil {
return err
}
diff --git a/src/chromiumos/test/plan/internal/compatibility/convertv1.go b/src/chromiumos/test/plan/internal/compatibility/convertv1.go
index 8746123..102a7df 100644
--- a/src/chromiumos/test/plan/internal/compatibility/convertv1.go
+++ b/src/chromiumos/test/plan/internal/compatibility/convertv1.go
@@ -8,6 +8,9 @@
import (
"fmt"
+ "math/rand"
+
+ "chromiumos/test/plan/internal/compatibility/priority"
"github.com/golang/glog"
testpb "go.chromium.org/chromiumos/config/go/test/api"
@@ -22,9 +25,8 @@
)
// getAttrFromCriteria finds the DutCriterion with attribute id attr in
-// criteria. If the DutCriterion is not found or has more than one value, an
-// error is returned.
-func getAttrFromCriteria(criteria []*testpb.DutCriterion, attr *testpb.DutAttribute) (string, error) {
+// criteria. If the DutCriterion is not found an error is returned.
+func getAttrFromCriteria(criteria []*testpb.DutCriterion, attr *testpb.DutAttribute) ([]string, error) {
for _, criterion := range criteria {
isAttr := false
if criterion.GetAttributeId().GetValue() == attr.GetId().GetValue() {
@@ -39,15 +41,54 @@
}
if isAttr {
- if len(criterion.GetValues()) != 1 {
- return "", fmt.Errorf("only DutCriterion with exactly one value supported, got %q", criterion)
+ if len(criterion.GetValues()) == 0 {
+ return nil, fmt.Errorf("only DutCriterion with at least one value supported, got %q", criterion)
}
- return criterion.GetValues()[0], nil
+ return criterion.GetValues(), nil
}
}
- return "", fmt.Errorf("attribute %q not found in DutCriterion %q", attr.GetId().GetValue(), criteria)
+ return nil, fmt.Errorf("attribute %q not found in DutCriterion %q", attr.GetId().GetValue(), criteria)
+}
+
+// Chooses a program from the options in programs to test. The choice is
+// determined by:
+// 1. Choose a program with a critial completed build. If there are multiple
+// programs, choose with prioritySelector.
+// 2. Choose a program with a non-critial completed build. If there are multiple
+// programs, choose with prioritySelector.
+// 3. Choose a program with prioritySelector.
+func chooseProgramToTest(
+ pool string,
+ programs []string,
+ buildInfos map[string]*buildInfo,
+ prioritySelector *priority.RandomWeightedSelector,
+) (string, error) {
+ var criticalPrograms, completedPrograms []string
+ for _, program := range programs {
+ buildInfo, found := buildInfos[program]
+ if found {
+ completedPrograms = append(completedPrograms, program)
+
+ if buildInfo.criticality == bbpb.Trinary_YES {
+ criticalPrograms = append(criticalPrograms, program)
+ }
+ }
+ }
+
+ if len(criticalPrograms) > 0 {
+ glog.V(2).Infof("Choosing between critical programs: %q", criticalPrograms)
+ return prioritySelector.Select(pool, criticalPrograms)
+ }
+
+ if len(completedPrograms) > 0 {
+ glog.V(2).Infof("Choosing between completed programs: %q", completedPrograms)
+ return prioritySelector.Select(pool, completedPrograms)
+ }
+
+ glog.V(2).Info("No completed programs found.")
+ return prioritySelector.Select(pool, programs)
}
// extractFromProtoStruct returns the path pointed to by fields. For example,
@@ -100,6 +141,7 @@
type buildInfo struct {
buildTarget string
builderName string
+ criticality bbpb.Trinary
payload *testplans.BuildPayload
}
@@ -184,6 +226,7 @@
buildInfos = append(buildInfos, &buildInfo{
buildTarget: buildTarget,
builderName: build.GetBuilder().GetBuilder(),
+ criticality: build.GetCritical(),
payload: &testplans.BuildPayload{
ArtifactsGsBucket: artifacts_gs_bucket,
ArtifactsGsPath: artifacts_gs_path,
@@ -205,8 +248,11 @@
// extractSuiteInfos returns a map from program name to suiteInfos for the
// program. There is one suiteInfo per CoverageRule in hwTestPlans.
func extractSuiteInfos(
+ rnd *rand.Rand,
hwTestPlans []*test_api_v1.HWTestPlan,
dutAttributeList *testpb.DutAttributeList,
+ boardPriorityList *testplans.BoardPriorityList,
+ boardToBuildInfo map[string]*buildInfo,
) (map[string][]*suiteInfo, error) {
// Find the program and pool attributes in the DutAttributeList.
var programAttr, poolAttr *testpb.DutAttribute
@@ -227,6 +273,10 @@
}
programToSuiteInfos := map[string][]*suiteInfo{}
+ prioritySelector := priority.NewRandomWeightedSelector(
+ rnd,
+ boardPriorityList,
+ )
for _, hwTestPlan := range hwTestPlans {
for _, rule := range hwTestPlan.GetCoverageRules() {
@@ -236,20 +286,34 @@
dutTarget := rule.GetDutTargets()[0]
- program, err := getAttrFromCriteria(dutTarget.GetCriteria(), programAttr)
+ pools, err := getAttrFromCriteria(dutTarget.GetCriteria(), poolAttr)
if err != nil {
return nil, err
}
- if _, ok := programToSuiteInfos[program]; !ok {
- programToSuiteInfos[program] = []*suiteInfo{}
+ if len(pools) != 1 {
+ return nil, fmt.Errorf("only DutCriteria with exactly one \"pool\" argument are supported, got %q", pools)
}
- pool, err := getAttrFromCriteria(dutTarget.GetCriteria(), poolAttr)
+ pool := pools[0]
+
+ programs, err := getAttrFromCriteria(dutTarget.GetCriteria(), programAttr)
if err != nil {
return nil, err
}
+ chosenProgram, err := chooseProgramToTest(
+ pool, programs, boardToBuildInfo, prioritySelector,
+ )
+ if err != nil {
+ return nil, err
+ }
+ glog.V(2).Infof("chose program %q from possible programs %q", chosenProgram, programs)
+
+ if _, ok := programToSuiteInfos[chosenProgram]; !ok {
+ programToSuiteInfos[chosenProgram] = []*suiteInfo{}
+ }
+
if len(dutTarget.GetCriteria()) != 2 {
return nil, fmt.Errorf(
"expected DutTarget to use exactly criteria %q and %q, got %q",
@@ -264,10 +328,10 @@
}
for _, id := range testCaseIds.GetTestCaseIds() {
- programToSuiteInfos[program] = append(
- programToSuiteInfos[program],
+ programToSuiteInfos[chosenProgram] = append(
+ programToSuiteInfos[chosenProgram],
&suiteInfo{
- program: program,
+ program: chosenProgram,
pool: pool,
suite: id.Value,
})
@@ -288,24 +352,37 @@
// - Both the "attr-program" and "swarming-pool" DutAttributes must be used in
// each DutTarget, and only these DutAttributes are allowed, i.e. each
// DutTarget must use exactly these attributes.
-// - Only one value per DutCriterion is allowed, e.g. a rule that specifies a
-// test should run on either "programA" or "programB" is not allowed.
+// - Multiple values for "attr-program" are allowed, a program will be chosen
+// randomly proportional to the board's priority in boardPriorityList
+// (lowest priority is most likely to get chosen, negative priorities are
+// allowed, programs without a priority get priority 0).
// - Only TestCaseIds are supported (no tag-based testing).
//
// generateTestPlanReq is needed to provide Build protos for the builds being
// tested. dutAttributeList must contain the "attr-program" and "swarming-pool"
// DutAttributes.
func ToCTP1(
+ rnd *rand.Rand,
hwTestPlans []*test_api_v1.HWTestPlan,
generateTestPlanReq *testplans.GenerateTestPlanRequest,
dutAttributeList *testpb.DutAttributeList,
+ boardPriorityList *testplans.BoardPriorityList,
) (*testplans.GenerateTestPlanResponse, error) {
buildInfos, err := parseBuildProtos(generateTestPlanReq.GetBuildbucketProtos())
if err != nil {
return nil, err
}
- programToSuiteInfos, err := extractSuiteInfos(hwTestPlans, dutAttributeList)
+ // Form maps from board to buildInfo, which will be needed by calls to
+ // extractSuiteInfos.
+ boardToBuildInfo := map[string]*buildInfo{}
+ for _, buildInfo := range buildInfos {
+ boardToBuildInfo[buildInfo.buildTarget] = buildInfo
+ }
+
+ programToSuiteInfos, err := extractSuiteInfos(
+ rnd, hwTestPlans, dutAttributeList, boardPriorityList, boardToBuildInfo,
+ )
if err != nil {
return nil, err
}
@@ -336,6 +413,8 @@
},
},
})
+
+ glog.V(2).Infof("added HwTest %q", hwTests[len(hwTests)-1])
}
hwTestUnit := &testplans.HwTestUnit{
diff --git a/src/chromiumos/test/plan/internal/compatibility/convertv1_test.go b/src/chromiumos/test/plan/internal/compatibility/convertv1_test.go
index 6751db2..ae892a8 100644
--- a/src/chromiumos/test/plan/internal/compatibility/convertv1_test.go
+++ b/src/chromiumos/test/plan/internal/compatibility/convertv1_test.go
@@ -4,10 +4,12 @@
package compatibility_test
import (
- "chromiumos/test/plan/internal/compatibility"
+ "math/rand"
"strings"
"testing"
+ "chromiumos/test/plan/internal/compatibility"
+
"github.com/golang/protobuf/proto"
"github.com/google/go-cmp/cmp"
testpb "go.chromium.org/chromiumos/config/go/test/api"
@@ -71,13 +73,14 @@
AttributeId: &testpb.DutAttribute_Id{
Value: "attr-design",
},
- Values: []string{"boardA"},
+ // "boardA" will be chosen, since it is critical and has the lowest priority.
+ Values: []string{"boardC", "boardA", "boardB", "non-critical-board"},
},
{
AttributeId: &testpb.DutAttribute_Id{
Value: "swarming-pool",
},
- Values: []string{"testpool"},
+ Values: []string{"DUT_POOL_QUOTA"},
},
},
},
@@ -119,6 +122,7 @@
},
}),
},
+ Critical: bbpb.Trinary_YES,
}
build2 := &bbpb.Build{
@@ -143,10 +147,60 @@
},
}),
},
+ Critical: bbpb.Trinary_YES,
}
build3 := &bbpb.Build{
Builder: &bbpb.BuilderID{
+ Builder: "cq-builderC",
+ },
+ Input: &bbpb.Build_Input{
+ Properties: newStruct(t, map[string]interface{}{
+ "build_target": map[string]interface{}{
+ "name": "boardC",
+ },
+ }),
+ },
+ Output: &bbpb.Build_Output{
+ Properties: newStruct(t, map[string]interface{}{
+ "artifacts": map[string]interface{}{
+ "gs_bucket": "testgsbucket",
+ "gs_path": "testgspathC",
+ "files_by_artifact": map[string]interface{}{
+ "AUTOTEST_FILES": []interface{}{"file1", "file2"},
+ },
+ },
+ }),
+ },
+ }
+
+ build4 := &bbpb.Build{
+ Builder: &bbpb.BuilderID{
+ Builder: "non-critical-builder",
+ },
+ Input: &bbpb.Build_Input{
+ Properties: newStruct(t, map[string]interface{}{
+ "build_target": map[string]interface{}{
+ "name": "non-critical-board",
+ },
+ }),
+ },
+ Output: &bbpb.Build_Output{
+ Properties: newStruct(t, map[string]interface{}{
+ "artifacts": map[string]interface{}{
+ "gs_bucket": "testgsbucket",
+ "gs_path": "testgspath",
+ "files_by_artifact": map[string]interface{}{
+ "AUTOTEST_FILES": []interface{}{"file1", "file2"},
+ },
+ },
+ }),
+ },
+ Critical: bbpb.Trinary_NO,
+ }
+
+ build5 := &bbpb.Build{
+ Builder: &bbpb.BuilderID{
Builder: "pointless-build",
},
Output: &bbpb.Build_Output{
@@ -156,7 +210,7 @@
},
}
- build4 := &bbpb.Build{
+ build6 := &bbpb.Build{
Builder: &bbpb.BuilderID{
Builder: "no-build-target-build",
},
@@ -183,6 +237,8 @@
serializeOrFatal(t, build2),
serializeOrFatal(t, build3),
serializeOrFatal(t, build4),
+ serializeOrFatal(t, build5),
+ serializeOrFatal(t, build6),
}
}
@@ -202,12 +258,26 @@
},
}
+var boardPriorityList = &testplans.BoardPriorityList{
+ BoardPriorities: []*testplans.BoardPriority{
+ {
+ SkylabBoard: "boardA", Priority: -100,
+ },
+ {
+ SkylabBoard: "boardB", Priority: 100,
+ },
+ },
+}
+
func TestToCTP1(t *testing.T) {
req := &testplans.GenerateTestPlanRequest{
BuildbucketProtos: getSerializedBuilds(t),
}
- resp, err := compatibility.ToCTP1(hwTestPlans, req, dutAttributeList)
+ resp, err := compatibility.ToCTP1(
+ rand.New(rand.NewSource(7)),
+ hwTestPlans, req, dutAttributeList, boardPriorityList,
+ )
if err != nil {
t.Fatal(err)
}
@@ -237,7 +307,7 @@
},
Suite: "suite1",
SkylabBoard: "boardA",
- Pool: "testpool",
+ Pool: "DUT_POOL_QUOTA",
},
{
Common: &testplans.TestSuiteCommon{
@@ -246,7 +316,7 @@
},
Suite: "suite2",
SkylabBoard: "boardA",
- Pool: "testpool",
+ Pool: "DUT_POOL_QUOTA",
},
},
},
@@ -278,7 +348,7 @@
AttributeId: &testpb.DutAttribute_Id{
Value: "swarming-pool",
},
- Values: []string{"testpool"},
+ Values: []string{"DUT_POOL_QUOTA"},
},
},
},
@@ -316,6 +386,56 @@
err: "attribute \"swarming-pool\" not found in DutCriterion",
},
{
+
+ name: "missing pool DUT attribute",
+ hwTestPlans: []*test_api_v1.HWTestPlan{
+ {
+ CoverageRules: []*testpb.CoverageRule{
+ {
+ DutTargets: []*testpb.DutTarget{
+ {
+ Criteria: []*testpb.DutCriterion{
+ {
+ AttributeId: &testpb.DutAttribute_Id{
+ Value: "attr-program",
+ },
+ Values: []string{"programA"},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ dutAttributeList: dutAttributeList,
+ },
+ {
+ name: "criteria with no values",
+ hwTestPlans: []*test_api_v1.HWTestPlan{
+ {
+ CoverageRules: []*testpb.CoverageRule{
+ {
+ DutTargets: []*testpb.DutTarget{
+ {
+ Criteria: []*testpb.DutCriterion{
+ {
+ AttributeId: &testpb.DutAttribute_Id{
+ Value: "swarming-pool",
+ },
+ Values: []string{},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ dutAttributeList: dutAttributeList,
+ err: "only DutCriterion with at least one value supported",
+ },
+ {
name: "invalid DUT attribute",
hwTestPlans: []*test_api_v1.HWTestPlan{
{
@@ -334,7 +454,7 @@
AttributeId: &testpb.DutAttribute_Id{
Value: "swarming-pool",
},
- Values: []string{"testpool"},
+ Values: []string{"DUT_POOL_QUOTA"},
},
{
AttributeId: &testpb.DutAttribute_Id{
@@ -353,7 +473,7 @@
err: "expected DutTarget to use exactly criteria \"attr-program\" and \"swarming-pool\"",
},
{
- name: "multiple program values",
+ name: "multiple pool values",
hwTestPlans: []*test_api_v1.HWTestPlan{
{
CoverageRules: []*testpb.CoverageRule{
@@ -363,9 +483,15 @@
Criteria: []*testpb.DutCriterion{
{
AttributeId: &testpb.DutAttribute_Id{
+ Value: "swarming-pool",
+ },
+ Values: []string{"testpoolA", "testpoolB"},
+ },
+ {
+ AttributeId: &testpb.DutAttribute_Id{
Value: "attr-program",
},
- Values: []string{"programA", "programB"},
+ Values: []string{"boardA", "boardB"},
},
},
},
@@ -375,7 +501,7 @@
},
},
dutAttributeList: dutAttributeList,
- err: "only DutCriterion with exactly one value supported",
+ err: "only DutCriteria with exactly one \"pool\" argument are supported",
},
{
name: "test tags used",
@@ -396,7 +522,7 @@
AttributeId: &testpb.DutAttribute_Id{
Value: "swarming-pool",
},
- Values: []string{"testpool"},
+ Values: []string{"DUT_POOL_QUOTA"},
},
},
},
@@ -473,7 +599,10 @@
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- _, err := compatibility.ToCTP1(tc.hwTestPlans, req, tc.dutAttributeList)
+ _, err := compatibility.ToCTP1(
+ rand.New(rand.NewSource(7)),
+ tc.hwTestPlans, req, tc.dutAttributeList, boardPriorityList,
+ )
if err == nil {
t.Error("Expected error from ToCTP1")
}
diff --git a/src/chromiumos/test/plan/internal/compatibility/priority/randomweighted.go b/src/chromiumos/test/plan/internal/compatibility/priority/randomweighted.go
new file mode 100644
index 0000000..abebc1f
--- /dev/null
+++ b/src/chromiumos/test/plan/internal/compatibility/priority/randomweighted.go
@@ -0,0 +1,138 @@
+// Copyright 2022 The Chromium OS Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+package priority
+
+import (
+ "errors"
+ "fmt"
+ "math/rand"
+ "sort"
+
+ "github.com/golang/glog"
+ "go.chromium.org/chromiumos/infra/proto/go/testplans"
+)
+
+// RandomWeightedSelector selects from a list of boards, with a random choice
+// weighted by BoardPriorities. The algorithm is as follows:
+//
+// 1. The sign of priorities is flipped, so that the board with the most
+// negative configured priority now has the most positive priority.
+// 2. If a priority is not defined for a board, it is implicitly 0, as defined
+// in the BoardPriority proto comment.
+// 3. Priorities are shifted so that the minimum priority is 1.
+// 4. Priorities are divided by the sum of the shifted priorities. At this
+// point, the priorities form a probability distribution (each is in the
+// range (0, 1], and they sum to 1). Each board's probability is proportional
+// to its original configured priority, i.e. the board with the most negative
+// configured priority has the highest probability.
+// 5. Sample a board from the probability distribution.
+type RandomWeightedSelector struct {
+ rand *rand.Rand
+ boardToPriority map[string]*testplans.BoardPriority
+}
+
+// NewRandomWeightedSelector returns a RandomWeightedSelector based on
+// boardPriorityList.
+func NewRandomWeightedSelector(rand *rand.Rand, boardPriorityList *testplans.BoardPriorityList) *RandomWeightedSelector {
+ boardToPriority := make(map[string]*testplans.BoardPriority)
+ for _, boardPriority := range boardPriorityList.GetBoardPriorities() {
+ boardToPriority[boardPriority.GetSkylabBoard()] = boardPriority
+ }
+
+ return &RandomWeightedSelector{rand: rand, boardToPriority: boardToPriority}
+}
+
+// floatPriority is similar to the BoardPriority proto, but supports float
+// priorities for internal computations.
+type floatPriority struct {
+ board string
+ priority float64
+}
+
+// getPriority looks up the configured priority for a given (board, pool)
+// combination. The bool return value indicates whether a configured priority
+// was found for the combination (similar to a map's return values).
+//
+// Currently only DUT_POOL_QUOTA has priorities configured.
+// TODO(b/224916762): Modify this to look up by pool once the configuration is
+// available.
+func (rw *RandomWeightedSelector) getPriority(board, pool string) (*testplans.BoardPriority, bool) {
+ if pool != "DUT_POOL_QUOTA" {
+ glog.Warningf("no priority data configured for pool %q", pool)
+ return nil, false
+ }
+
+ pri, found := rw.boardToPriority[board]
+ return pri, found
+}
+
+// RandomWeightedSelector makes assertions that computed values fall within
+// expected ranges, e.g. probabilities are <= 1.0. Add some tolerance to avoid
+// panics due to possible small floating point imprecisions.
+const floatErrorTolerance = 0.001
+
+// Select randomly chooses a board from boards, with the probability a board is
+// chosen proportional to its configured probability.
+func (rw *RandomWeightedSelector) Select(pool string, boards []string) (string, error) {
+ if len(boards) == 0 {
+ return "", errors.New("boards must be non-empty")
+ }
+
+ var normalizedPriorities []floatPriority
+ // For each board in boards, look up its priority and store a floatPriority
+ // with the sign flipped from the configured priority. If a board is not
+ // found in the configured priorities, it gets priority 0.
+ for _, board := range boards {
+ if priority, found := rw.getPriority(board, pool); found {
+ normalizedPriorities = append(normalizedPriorities, floatPriority{
+ board: board, priority: float64(-priority.GetPriority()),
+ })
+ } else {
+ normalizedPriorities = append(normalizedPriorities, floatPriority{
+ board: board, priority: 0,
+ })
+ }
+ }
+
+ sort.SliceStable(normalizedPriorities, func(i, j int) bool {
+ return normalizedPriorities[i].priority < normalizedPriorities[j].priority
+ })
+
+ // Shift the priorities so the minimum is 1, and compute the sum of the
+ // shifted priorities.
+ minPriority := normalizedPriorities[0].priority
+ sumPriorities := 0.0
+ for i := range normalizedPriorities {
+ normalizedPriorities[i].priority -= (minPriority - 1)
+ sumPriorities += normalizedPriorities[i].priority
+ }
+
+ // Divide each priority by the sum, each priority must be in the range
+ // (0, 1] after.
+ for i := range normalizedPriorities {
+ normalizedPriorities[i].priority /= sumPriorities
+
+ if normalizedPriorities[i].priority <= 0-floatErrorTolerance || normalizedPriorities[i].priority > 1+floatErrorTolerance {
+ panic(fmt.Sprintf("normalized priorities must be in range (0, 1], got %f", normalizedPriorities[i].priority))
+ }
+ }
+
+ // randFloat is in the range [0.0, 1.0). Compute the cumulative probabilites
+ // and find the first value for which randFloat < cdf(priority).
+ randFloat := rw.rand.Float64()
+ cumulativePriority := 0.0
+ for _, priority := range normalizedPriorities {
+ cumulativePriority += priority.priority
+
+ if cumulativePriority > 1+floatErrorTolerance {
+ panic(fmt.Sprintf("cumulative priority must be <= 1, got %f", cumulativePriority))
+ }
+
+ if randFloat < cumulativePriority {
+ return priority.board, nil
+ }
+ }
+
+ panic(fmt.Sprintf("rand.Float64() is > cumulativePriority (%f > %f)", randFloat, cumulativePriority))
+}
diff --git a/src/chromiumos/test/plan/internal/compatibility/priority/randomweighted_test.go b/src/chromiumos/test/plan/internal/compatibility/priority/randomweighted_test.go
new file mode 100644
index 0000000..e6b736e
--- /dev/null
+++ b/src/chromiumos/test/plan/internal/compatibility/priority/randomweighted_test.go
@@ -0,0 +1,308 @@
+// Copyright 2022 The Chromium OS Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+package priority_test
+
+import (
+ "math"
+ "math/rand"
+ "testing"
+
+ "chromiumos/test/plan/internal/compatibility/priority"
+
+ "github.com/google/go-cmp/cmp"
+ "go.chromium.org/chromiumos/infra/proto/go/testplans"
+)
+
+func TestRandomWeighted_MixedPriorities(t *testing.T) {
+ boardPriorityList := &testplans.BoardPriorityList{
+ BoardPriorities: []*testplans.BoardPriority{
+ {
+ SkylabBoard: "boardA",
+ Priority: -100,
+ },
+ {
+ SkylabBoard: "boardB",
+ Priority: -99,
+ },
+ {
+ SkylabBoard: "boardC",
+ Priority: -50,
+ },
+ {
+ SkylabBoard: "boardD",
+ Priority: 100,
+ },
+ },
+ }
+
+ selector := priority.NewRandomWeightedSelector(
+ rand.New(rand.NewSource(7)), boardPriorityList,
+ )
+
+ counts := make(map[string]int)
+ for i := 0; i < 1000; i++ {
+ selected, err := selector.Select("DUT_POOL_QUOTA", []string{"boardA", "boardB", "boardC", "boardD", "undefinedBoard"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts[selected] += 1
+ }
+
+ // The probabilities are computed from configured priorities as follows:
+ // 1. Flip signs: 100, 99, 50, -100.
+ // 2. Shift so minimum is 1 (and add default probability for the undefined
+ // board): 201, 200, 151, 1, 101
+ // 3. Divide by the sum (654): 0.3073, 0.3058, 0.2309, 0.0015, 0.1544
+ expected := map[string]int{
+ "boardA": 322,
+ "boardB": 303,
+ "boardC": 221,
+ "boardD": 5,
+ "undefinedBoard": 149,
+ }
+ if diff := cmp.Diff(expected, counts); diff != "" {
+ t.Errorf("Unexpected counts of chosen boards (-want +got): %s", diff)
+ }
+}
+
+func TestRandomWeighted_AllPositivePriorities(t *testing.T) {
+ boardPriorityList := &testplans.BoardPriorityList{
+ BoardPriorities: []*testplans.BoardPriority{
+ {
+ SkylabBoard: "boardA",
+ Priority: 100,
+ },
+ {
+ SkylabBoard: "boardB",
+ Priority: 50,
+ },
+ {
+ SkylabBoard: "boardC",
+ Priority: 10,
+ },
+ },
+ }
+
+ selector := priority.NewRandomWeightedSelector(
+ rand.New(rand.NewSource(7)), boardPriorityList,
+ )
+
+ counts := make(map[string]int)
+ for i := 0; i < 1000; i++ {
+ selected, err := selector.Select("DUT_POOL_QUOTA", []string{"boardA", "boardB", "boardC", "undefinedBoard"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts[selected] += 1
+ }
+
+ // The probabilities are computed from configured priorities as follows:
+ // 1. Flip signs: -100, -50, -10
+ // 2. Shift so minimum is 1 (and add default probability for the undefined
+ // board): 1, 51, 91, 101
+ // 3. Divide by the sum (244): 0.0041, 0.2090, 0.3730, 0.4139
+ expected := map[string]int{
+ "boardA": 9,
+ "boardB": 202,
+ "boardC": 377,
+ "undefinedBoard": 412,
+ }
+ if diff := cmp.Diff(expected, counts); diff != "" {
+ t.Errorf("Unexpected counts of chosen boards (-want +got): %s", diff)
+ }
+}
+
+func TestRandomWeighted_AllNegativePriorities(t *testing.T) {
+ boardPriorityList := &testplans.BoardPriorityList{
+ BoardPriorities: []*testplans.BoardPriority{
+ {
+ SkylabBoard: "boardA",
+ Priority: -100,
+ },
+ {
+ SkylabBoard: "boardB",
+ Priority: -50,
+ },
+ {
+ SkylabBoard: "boardC",
+ Priority: -10,
+ },
+ },
+ }
+
+ selector := priority.NewRandomWeightedSelector(
+ rand.New(rand.NewSource(7)), boardPriorityList,
+ )
+
+ counts := make(map[string]int)
+ for i := 0; i < 1000; i++ {
+ selected, err := selector.Select("DUT_POOL_QUOTA", []string{"boardA", "boardB", "boardC", "undefinedBoard"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts[selected] += 1
+ }
+
+ // The probabilities are computed from configured priorities as follows:
+ // 1. Flip signs: 100, 50, 10
+ // 2. Shift so minimum is 1 (and add default probability for the undefined
+ // board): 101, 51, 11, 1
+ // 3. Divide by the sum (164): 0.6159, 0.3110, 0.0671, 0.0061
+ expected := map[string]int{
+ "boardA": 629,
+ "boardB": 292,
+ "boardC": 68,
+ "undefinedBoard": 11,
+ }
+ if diff := cmp.Diff(expected, counts); diff != "" {
+ t.Errorf("Unexpected counts of chosen boards (-want +got): %s", diff)
+ }
+}
+
+func TestRandomWeighted_SingleBoard(t *testing.T) {
+ boardPriorityList := &testplans.BoardPriorityList{
+ BoardPriorities: []*testplans.BoardPriority{
+ {
+ SkylabBoard: "boardA",
+ Priority: -100,
+ },
+ {
+ SkylabBoard: "boardB",
+ Priority: -50,
+ },
+ {
+ SkylabBoard: "boardC",
+ Priority: -10,
+ },
+ },
+ }
+
+ selector := priority.NewRandomWeightedSelector(
+ rand.New(rand.NewSource(7)), boardPriorityList,
+ )
+
+ selected, err := selector.Select("DUT_POOL_QUOTA", []string{"boardA"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if selected != "boardA" {
+ t.Errorf("expected \"boardA\" to be selected, got %q", selected)
+ }
+}
+
+func TestRandomWeighted_NoBoards(t *testing.T) {
+ boardPriorityList := &testplans.BoardPriorityList{
+ BoardPriorities: []*testplans.BoardPriority{
+ {
+ SkylabBoard: "boardA",
+ Priority: -100,
+ },
+ {
+ SkylabBoard: "boardB",
+ Priority: -50,
+ },
+ {
+ SkylabBoard: "boardC",
+ Priority: -10,
+ },
+ },
+ }
+
+ selector := priority.NewRandomWeightedSelector(
+ rand.New(rand.NewSource(7)), boardPriorityList,
+ )
+
+ _, err := selector.Select("DUT_POOL_QUOTA", []string{})
+ if err == nil {
+ t.Error("expected error from calling Select with no boards.")
+ }
+}
+
+func TestRandomWeighted_LargePriorities(t *testing.T) {
+ boardPriorityList := &testplans.BoardPriorityList{
+ BoardPriorities: []*testplans.BoardPriority{
+ {
+ SkylabBoard: "boardA",
+ Priority: -int32(math.Pow(2, 30)),
+ },
+ {
+ SkylabBoard: "boardB",
+ Priority: -int32(math.Pow(2, 29)),
+ },
+ {
+ SkylabBoard: "boardC",
+ Priority: int32(math.Pow(2, 21)),
+ },
+ },
+ }
+
+ selector := priority.NewRandomWeightedSelector(
+ rand.New(rand.NewSource(7)), boardPriorityList,
+ )
+
+ counts := make(map[string]int)
+ for i := 0; i < 1000; i++ {
+ selected, err := selector.Select("DUT_POOL_QUOTA", []string{"boardA", "boardB", "boardC", "undefinedBoard"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts[selected] += 1
+ }
+
+ // boardA should be chosen approximately twice as much as boardB. boardC
+ // should be chosen approximately 1 / 512 choices.
+ expected := map[string]int{
+ "boardA": 679,
+ "boardB": 318,
+ "undefinedBoard": 3,
+ }
+ if diff := cmp.Diff(expected, counts); diff != "" {
+ t.Errorf("Unexpected counts of chosen boards (-want +got): %s", diff)
+ }
+}
+
+func TestRandomWeighted_UnconfiguredPool(t *testing.T) {
+ boardPriorityList := &testplans.BoardPriorityList{
+ BoardPriorities: []*testplans.BoardPriority{
+ {
+ SkylabBoard: "boardA",
+ Priority: -500,
+ },
+ {
+ SkylabBoard: "boardB",
+ Priority: -100,
+ },
+ {
+ SkylabBoard: "boardC",
+ Priority: 1000,
+ },
+ },
+ }
+
+ selector := priority.NewRandomWeightedSelector(
+ rand.New(rand.NewSource(7)), boardPriorityList,
+ )
+
+ counts := make(map[string]int)
+ for i := 0; i < 1000; i++ {
+ selected, err := selector.Select("unconfiguredpool", []string{"boardA", "boardB", "boardC", "undefinedBoard"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts[selected] += 1
+ }
+
+ // Since there is no configuration for the pool, every board gets default
+ // priority, and should be chosen equally.
+ expected := map[string]int{
+ "boardA": 247,
+ "boardB": 258,
+ "boardC": 239,
+ "undefinedBoard": 256,
+ }
+ if diff := cmp.Diff(expected, counts); diff != "" {
+ t.Errorf("Unexpected counts of chosen boards (-want +got): %s", diff)
+ }
+}