devrites 5.14.0 → 5.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +6 -3
- package/docs/adr/0033-rite-fast-own-agents.md +45 -0
- package/docs/adr/README.md +2 -1
- package/docs/architecture.md +6 -4
- package/docs/command-map.md +2 -1
- package/docs/flow.md +3 -2
- package/docs/orchestration.md +3 -0
- package/docs/quick-reference.md +2 -1
- package/docs/skills.md +13 -8
- package/docs/usage.md +2 -2
- package/engine/internal/install/apply.go +5 -3
- package/engine/internal/install/binary.go +3 -1
- package/engine/internal/install/install.go +0 -25
- package/engine/internal/lib/claims.go +15 -0
- package/engine/internal/lib/claims_test.go +28 -0
- package/engine/internal/lib/context_next_test.go +53 -0
- package/engine/internal/lib/dispatch.go +28 -7
- package/engine/internal/lib/manifest_union.go +14 -0
- package/engine/internal/overhaul/admit/admit_test.go +9 -0
- package/engine/internal/overhaul/admit/run.go +9 -0
- package/engine/internal/overhaul/bench/run.go +327 -38
- package/engine/internal/overhaul/bench/run_test.go +131 -0
- package/engine/internal/overhaul/bench/stats.go +154 -0
- package/engine/internal/overhaul/ovio/ovio.go +4 -0
- package/engine/internal/overhaul/records/check.go +3 -0
- package/engine/internal/overhaul/records/consistency.go +217 -0
- package/engine/internal/overhaul/records/records_test.go +81 -0
- package/engine/internal/overhaul/records/run.go +1 -1
- package/engine/internal/overhaul/render/run.go +2 -0
- package/engine/internal/overhaul/render/run_test.go +5 -1
- package/engine/internal/overhaul/score/run.go +58 -9
- package/engine/internal/overhaul/score/run_test.go +84 -8
- package/engine/internal/parallel/ops.go +5 -0
- package/engine/internal/state/write.go +8 -2
- package/engine/internal/version/version_test.go +27 -0
- package/engine/main.go +2 -2
- package/pack/.claude/agents/fast-builder.md +90 -0
- package/pack/.claude/agents/fast-checker.md +60 -0
- package/pack/.claude/agents/fast-critic.md +70 -0
- package/pack/.claude/agents/fast-planner.md +95 -0
- package/pack/.claude/agents/overhaul-backend-engineer.md +5 -4
- package/pack/.claude/agents/overhaul-boundary.md +2 -2
- package/pack/.claude/agents/overhaul-craft.md +1 -1
- package/pack/.claude/agents/overhaul-frontend-engineer.md +5 -3
- package/pack/.claude/agents/overhaul-implementer.md +9 -5
- package/pack/.claude/agents/overhaul-specialist.md +6 -3
- package/pack/.claude/agents/overhaul-verifier.md +3 -3
- package/pack/.claude/settings.json +1 -1
- package/pack/.claude/skills/devrites-lib/reference/intent-map.md +2 -0
- package/pack/.claude/skills/overhaul/SKILL.md +2 -1
- package/pack/.claude/skills/overhaul/references/audit-domains.md +139 -12
- package/pack/.claude/skills/overhaul/references/lifecycle.md +2 -0
- package/pack/.claude/skills/overhaul/references/orchestration.md +13 -5
- package/pack/.claude/skills/overhaul/references/records.md +12 -3
- package/pack/.claude/skills/overhaul/references/repair-and-measurement.md +108 -5
- package/pack/.claude/skills/overhaul/references/scope-and-safety.md +17 -2
- package/pack/.claude/skills/overhaul/references/scoring.md +36 -7
- package/pack/.claude/skills/overhaul/references/stack-profiles.md +52 -0
- package/pack/.claude/skills/rite/SKILL.md +4 -1
- package/pack/.claude/skills/rite-doctor/SKILL.md +2 -0
- package/pack/.claude/skills/rite-fast/SKILL.md +183 -0
- package/pack/.claude/skills/rite-fast/reference/artifacts.md +107 -0
- package/pack/.claude/skills/rite-fast/reference/check-and-triage.md +51 -0
- package/pack/.claude/skills/rite-quick/SKILL.md +2 -0
- package/pack/.claude/skills/rite-spec/reference/state-workspace.md +1 -0
- package/pack/generated/claude/agents/fast-builder.md +90 -0
- package/pack/generated/claude/agents/fast-checker.md +60 -0
- package/pack/generated/claude/agents/fast-critic.md +70 -0
- package/pack/generated/claude/agents/fast-planner.md +95 -0
- package/pack/generated/claude/agents/overhaul-backend-engineer.md +5 -4
- package/pack/generated/claude/agents/overhaul-boundary.md +2 -2
- package/pack/generated/claude/agents/overhaul-craft.md +1 -1
- package/pack/generated/claude/agents/overhaul-frontend-engineer.md +5 -3
- package/pack/generated/claude/agents/overhaul-implementer.md +9 -5
- package/pack/generated/claude/agents/overhaul-specialist.md +6 -3
- package/pack/generated/claude/agents/overhaul-verifier.md +3 -3
- package/pack/generated/claude/settings.json +1 -1
- package/pack/generated/claude/skills/devrites-lib/reference/intent-map.md +2 -0
- package/pack/generated/claude/skills/overhaul/SKILL.md +2 -1
- package/pack/generated/claude/skills/overhaul/references/audit-domains.md +139 -12
- package/pack/generated/claude/skills/overhaul/references/lifecycle.md +2 -0
- package/pack/generated/claude/skills/overhaul/references/orchestration.md +13 -5
- package/pack/generated/claude/skills/overhaul/references/records.md +12 -3
- package/pack/generated/claude/skills/overhaul/references/repair-and-measurement.md +108 -5
- package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +17 -2
- package/pack/generated/claude/skills/overhaul/references/scoring.md +36 -7
- package/pack/generated/claude/skills/overhaul/references/stack-profiles.md +52 -0
- package/pack/generated/claude/skills/rite/SKILL.md +4 -1
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +2 -0
- package/pack/generated/claude/skills/rite-fast/SKILL.md +183 -0
- package/pack/generated/claude/skills/rite-fast/reference/artifacts.md +107 -0
- package/pack/generated/claude/skills/rite-fast/reference/check-and-triage.md +51 -0
- package/pack/generated/claude/skills/rite-quick/SKILL.md +2 -0
- package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +1 -0
- package/pack/generated/codex/AGENTS.md +1 -0
- package/pack/generated/codex/agents/fast-builder.toml +90 -0
- package/pack/generated/codex/agents/fast-checker.toml +60 -0
- package/pack/generated/codex/agents/fast-critic.toml +70 -0
- package/pack/generated/codex/agents/fast-planner.toml +95 -0
- package/pack/generated/codex/agents/overhaul-backend-engineer.toml +5 -4
- package/pack/generated/codex/agents/overhaul-boundary.toml +2 -2
- package/pack/generated/codex/agents/overhaul-craft.toml +1 -1
- package/pack/generated/codex/agents/overhaul-frontend-engineer.toml +5 -3
- package/pack/generated/codex/agents/overhaul-implementer.toml +9 -5
- package/pack/generated/codex/agents/overhaul-specialist.toml +6 -3
- package/pack/generated/codex/agents/overhaul-verifier.toml +3 -3
- package/pack/generated/codex/skills/devrites-lib/reference/intent-map.md +2 -0
- package/pack/generated/codex/skills/overhaul/SKILL.md +2 -1
- package/pack/generated/codex/skills/overhaul/references/audit-domains.md +139 -12
- package/pack/generated/codex/skills/overhaul/references/lifecycle.md +2 -0
- package/pack/generated/codex/skills/overhaul/references/orchestration.md +13 -5
- package/pack/generated/codex/skills/overhaul/references/records.md +12 -3
- package/pack/generated/codex/skills/overhaul/references/repair-and-measurement.md +108 -5
- package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +17 -2
- package/pack/generated/codex/skills/overhaul/references/scoring.md +36 -7
- package/pack/generated/codex/skills/overhaul/references/stack-profiles.md +52 -0
- package/pack/generated/codex/skills/rite/SKILL.md +4 -1
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +2 -0
- package/pack/generated/codex/skills/rite-fast/SKILL.md +183 -0
- package/pack/generated/codex/skills/rite-fast/agents/openai.yaml +2 -0
- package/pack/generated/codex/skills/rite-fast/reference/artifacts.md +107 -0
- package/pack/generated/codex/skills/rite-fast/reference/check-and-triage.md +51 -0
- package/pack/generated/codex/skills/rite-quick/SKILL.md +2 -0
- package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +1 -0
- package/pack/generated/devin/AGENTS.md +1 -0
- package/pack/generated/devin/agents/fast-builder.md +96 -0
- package/pack/generated/devin/agents/fast-checker.md +63 -0
- package/pack/generated/devin/agents/fast-critic.md +73 -0
- package/pack/generated/devin/agents/fast-planner.md +98 -0
- package/pack/generated/devin/agents/overhaul-backend-engineer.md +5 -4
- package/pack/generated/devin/agents/overhaul-boundary.md +2 -2
- package/pack/generated/devin/agents/overhaul-craft.md +1 -1
- package/pack/generated/devin/agents/overhaul-frontend-engineer.md +5 -3
- package/pack/generated/devin/agents/overhaul-implementer.md +9 -5
- package/pack/generated/devin/agents/overhaul-specialist.md +6 -3
- package/pack/generated/devin/agents/overhaul-verifier.md +3 -3
- package/pack/generated/devin/skills/devrites-lib/reference/intent-map.md +2 -0
- package/pack/generated/devin/skills/overhaul/SKILL.md +2 -1
- package/pack/generated/devin/skills/overhaul/references/audit-domains.md +139 -12
- package/pack/generated/devin/skills/overhaul/references/lifecycle.md +2 -0
- package/pack/generated/devin/skills/overhaul/references/orchestration.md +13 -5
- package/pack/generated/devin/skills/overhaul/references/records.md +12 -3
- package/pack/generated/devin/skills/overhaul/references/repair-and-measurement.md +108 -5
- package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +17 -2
- package/pack/generated/devin/skills/overhaul/references/scoring.md +36 -7
- package/pack/generated/devin/skills/overhaul/references/stack-profiles.md +52 -0
- package/pack/generated/devin/skills/rite/SKILL.md +4 -1
- package/pack/generated/devin/skills/rite-doctor/SKILL.md +2 -0
- package/pack/generated/devin/skills/rite-fast/SKILL.md +183 -0
- package/pack/generated/devin/skills/rite-fast/reference/artifacts.md +107 -0
- package/pack/generated/devin/skills/rite-fast/reference/check-and-triage.md +51 -0
- package/pack/generated/devin/skills/rite-quick/SKILL.md +2 -0
- package/pack/generated/devin/skills/rite-spec/reference/state-workspace.md +1 -0
- package/pack/generated/omp/agents/fast-builder.md +89 -0
- package/pack/generated/omp/agents/fast-checker.md +59 -0
- package/pack/generated/omp/agents/fast-critic.md +69 -0
- package/pack/generated/omp/agents/fast-planner.md +94 -0
- package/pack/generated/omp/agents/overhaul-backend-engineer.md +5 -4
- package/pack/generated/omp/agents/overhaul-boundary.md +2 -2
- package/pack/generated/omp/agents/overhaul-craft.md +1 -1
- package/pack/generated/omp/agents/overhaul-frontend-engineer.md +5 -3
- package/pack/generated/omp/agents/overhaul-implementer.md +9 -5
- package/pack/generated/omp/agents/overhaul-specialist.md +6 -3
- package/pack/generated/omp/agents/overhaul-verifier.md +3 -3
- package/pack/generated/omp/commands/rite-fast.md +5 -0
- package/pack/generated/omp/skills/devrites-lib/reference/intent-map.md +2 -0
- package/pack/generated/omp/skills/overhaul/SKILL.md +2 -1
- package/pack/generated/omp/skills/overhaul/references/audit-domains.md +139 -12
- package/pack/generated/omp/skills/overhaul/references/lifecycle.md +2 -0
- package/pack/generated/omp/skills/overhaul/references/orchestration.md +13 -5
- package/pack/generated/omp/skills/overhaul/references/records.md +12 -3
- package/pack/generated/omp/skills/overhaul/references/repair-and-measurement.md +108 -5
- package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +17 -2
- package/pack/generated/omp/skills/overhaul/references/scoring.md +36 -7
- package/pack/generated/omp/skills/overhaul/references/stack-profiles.md +52 -0
- package/pack/generated/omp/skills/rite/SKILL.md +4 -1
- package/pack/generated/omp/skills/rite-doctor/SKILL.md +2 -0
- package/pack/generated/omp/skills/rite-fast/SKILL.md +183 -0
- package/pack/generated/omp/skills/rite-fast/reference/artifacts.md +107 -0
- package/pack/generated/omp/skills/rite-fast/reference/check-and-triage.md +51 -0
- package/pack/generated/omp/skills/rite-quick/SKILL.md +2 -0
- package/pack/generated/omp/skills/rite-spec/reference/state-workspace.md +1 -0
- package/pack/generated/pi/AGENTS.md +1 -0
- package/pack/generated/pi/agents/fast-builder.md +90 -0
- package/pack/generated/pi/agents/fast-checker.md +60 -0
- package/pack/generated/pi/agents/fast-critic.md +70 -0
- package/pack/generated/pi/agents/fast-planner.md +95 -0
- package/pack/generated/pi/agents/overhaul-backend-engineer.md +5 -4
- package/pack/generated/pi/agents/overhaul-boundary.md +2 -2
- package/pack/generated/pi/agents/overhaul-craft.md +1 -1
- package/pack/generated/pi/agents/overhaul-frontend-engineer.md +5 -3
- package/pack/generated/pi/agents/overhaul-implementer.md +9 -5
- package/pack/generated/pi/agents/overhaul-specialist.md +6 -3
- package/pack/generated/pi/agents/overhaul-verifier.md +3 -3
- package/pack/generated/pi/prompts/rite-fast.md +5 -0
- package/pack/generated/pi/skills/devrites-lib/reference/intent-map.md +2 -0
- package/pack/generated/pi/skills/overhaul/SKILL.md +2 -1
- package/pack/generated/pi/skills/overhaul/references/audit-domains.md +139 -12
- package/pack/generated/pi/skills/overhaul/references/lifecycle.md +2 -0
- package/pack/generated/pi/skills/overhaul/references/orchestration.md +13 -5
- package/pack/generated/pi/skills/overhaul/references/records.md +12 -3
- package/pack/generated/pi/skills/overhaul/references/repair-and-measurement.md +108 -5
- package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +17 -2
- package/pack/generated/pi/skills/overhaul/references/scoring.md +36 -7
- package/pack/generated/pi/skills/overhaul/references/stack-profiles.md +52 -0
- package/pack/generated/pi/skills/rite/SKILL.md +4 -1
- package/pack/generated/pi/skills/rite-doctor/SKILL.md +2 -0
- package/pack/generated/pi/skills/rite-fast/SKILL.md +183 -0
- package/pack/generated/pi/skills/rite-fast/reference/artifacts.md +107 -0
- package/pack/generated/pi/skills/rite-fast/reference/check-and-triage.md +51 -0
- package/pack/generated/pi/skills/rite-quick/SKILL.md +2 -0
- package/pack/generated/pi/skills/rite-spec/reference/state-workspace.md +1 -0
- package/package.json +1 -1
- package/scripts/codex-generate.sh +2 -1
- package/scripts/devin-generate.sh +4 -3
- package/scripts/omp-generate.sh +3 -3
- package/scripts/pi-generate.sh +4 -3
|
@@ -240,6 +240,15 @@ func receipt(run, path, observed string, hasObserved bool, stdout io.Writer) (in
|
|
|
240
240
|
if ovio.Str(rc["outcome"]) == "no-findings" && !ovio.Truthy(rc["inspected"]) {
|
|
241
241
|
why = append(why, "no-findings without inspected ranges is malformed")
|
|
242
242
|
}
|
|
243
|
+
checked := ovio.List(rc["domains_checked"])
|
|
244
|
+
if ovio.Str(rc["outcome"]) == "no-findings" && len(checked) == 0 {
|
|
245
|
+
why = append(why, "no-findings must name its domains_checked")
|
|
246
|
+
}
|
|
247
|
+
for _, d := range checked {
|
|
248
|
+
if !slices.Contains(ovio.Domains, ovio.Str(d)) {
|
|
249
|
+
why = append(why, "domains_checked has unknown domain "+records.Repr(d))
|
|
250
|
+
}
|
|
251
|
+
}
|
|
243
252
|
if writers[ovio.Str(x["role"])] {
|
|
244
253
|
v, err := keyed(rj, "revisions")
|
|
245
254
|
if err == nil {
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Package bench implements the /overhaul bench tool: ratio of medians with a
|
|
2
|
-
// seeded percentile-bootstrap 95% confidence interval
|
|
2
|
+
// seeded percentile-bootstrap 95% confidence interval, checked against the
|
|
3
|
+
// distribution-free Hodges-Lehmann ratio bounds.
|
|
3
4
|
//
|
|
4
5
|
// Input: {"direction": "lower_is_better"|"higher_is_better", "baseline": [...],
|
|
5
6
|
// "candidate": [...], "order": ["A","B",...], "target": 2.0, "materiality": 1.05,
|
|
6
|
-
// "resamples": 10000, "seed": 1, "resolution": 0}
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// "resamples": 10000, "seed": 1, "resolution": 0}, plus optional "quantile"
|
|
8
|
+
// with "requests_per_invocation" (tail metrics), "tolerance" (non-inferiority
|
|
9
|
+
// guardrail), "budget" (absolute target), "error_rates" and "load". The unit of
|
|
10
|
+
// resampling is one independent invocation per sample; never pass inner
|
|
11
|
+
// iterations. A k-fold claim is supported only when both lower bounds are >= k.
|
|
9
12
|
package bench
|
|
10
13
|
|
|
11
14
|
import (
|
|
@@ -16,6 +19,7 @@ import (
|
|
|
16
19
|
"math"
|
|
17
20
|
"math/rand/v2"
|
|
18
21
|
"reflect"
|
|
22
|
+
"slices"
|
|
19
23
|
"sort"
|
|
20
24
|
"strconv"
|
|
21
25
|
"strings"
|
|
@@ -52,6 +56,8 @@ type result struct {
|
|
|
52
56
|
Verdict string `json:"verdict"`
|
|
53
57
|
Ratio float64 `json:"ratio"`
|
|
54
58
|
CI95 [2]float64 `json:"ci95"`
|
|
59
|
+
HodgesLehmann *interval `json:"hodges_lehmann,omitempty"`
|
|
60
|
+
Quantile float64 `json:"quantile,omitempty"`
|
|
55
61
|
BaselineMedian float64 `json:"baseline_median"`
|
|
56
62
|
CandidateMedian float64 `json:"candidate_median"`
|
|
57
63
|
AbsoluteDelta float64 `json:"absolute_delta"`
|
|
@@ -59,20 +65,53 @@ type result struct {
|
|
|
59
65
|
N [2]int `json:"n"`
|
|
60
66
|
Target float64 `json:"target"`
|
|
61
67
|
Materiality float64 `json:"materiality"`
|
|
68
|
+
Guardrail *guardrail `json:"guardrail,omitempty"`
|
|
69
|
+
Budget *budget `json:"budget,omitempty"`
|
|
70
|
+
ErrorRate *errorRate `json:"error_rate,omitempty"`
|
|
62
71
|
Notes []string `json:"notes"`
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
type interval struct {
|
|
75
|
+
Ratio float64 `json:"ratio"`
|
|
76
|
+
CI95 [2]float64 `json:"ci95"`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type guardrail struct {
|
|
80
|
+
Verdict string `json:"verdict"`
|
|
81
|
+
Tolerance float64 `json:"tolerance"`
|
|
82
|
+
Floor float64 `json:"floor"`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type budget struct {
|
|
86
|
+
Verdict string `json:"verdict"`
|
|
87
|
+
Value float64 `json:"value"`
|
|
88
|
+
Quantile float64 `json:"quantile"`
|
|
89
|
+
Bounds [2]any `json:"bounds95"`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type errorRate struct {
|
|
93
|
+
Verdict string `json:"verdict"`
|
|
94
|
+
Tolerance float64 `json:"tolerance"`
|
|
95
|
+
Delta float64 `json:"delta"`
|
|
96
|
+
CI95 [2]float64 `json:"ci95"`
|
|
97
|
+
}
|
|
98
|
+
|
|
65
99
|
type noRatio struct {
|
|
66
|
-
Verdict string
|
|
67
|
-
Reason string
|
|
68
|
-
BaselineMedian float64
|
|
69
|
-
CandidateMedian float64
|
|
70
|
-
|
|
100
|
+
Verdict string `json:"verdict"`
|
|
101
|
+
Reason string `json:"reason"`
|
|
102
|
+
BaselineMedian float64 `json:"baseline_median"`
|
|
103
|
+
CandidateMedian float64 `json:"candidate_median"`
|
|
104
|
+
Budget *budget `json:"budget,omitempty"`
|
|
105
|
+
ErrorRate *errorRate `json:"error_rate,omitempty"`
|
|
106
|
+
Notes []string `json:"notes"`
|
|
71
107
|
}
|
|
72
108
|
|
|
73
109
|
const (
|
|
74
|
-
noteFew
|
|
75
|
-
noteBlocked
|
|
110
|
+
noteFew = "fewer than 10 invocations per arm: exploratory only"
|
|
111
|
+
noteBlocked = "arms not recorded as interleaved: contamination risk, verdict capped at inconclusive"
|
|
112
|
+
noteThinTail = "tail quantile rests on too few requests per invocation: verdict capped at inconclusive"
|
|
113
|
+
noteSaturated = "completed load below 98% of offered load: saturated run, verdict capped at inconclusive"
|
|
114
|
+
noteNoHL = "no Hodges-Lehmann bounds (non-positive samples or arms too small): verdict capped at inconclusive"
|
|
76
115
|
)
|
|
77
116
|
|
|
78
117
|
func compare(d map[string]any) (any, error) {
|
|
@@ -81,18 +120,28 @@ func compare(d map[string]any) (any, error) {
|
|
|
81
120
|
return nil, fmt.Errorf("direction must be lower_is_better or higher_is_better, got %#v", dir)
|
|
82
121
|
}
|
|
83
122
|
lower := dir == "lower_is_better"
|
|
84
|
-
|
|
123
|
+
q, err := number(d, "quantile", 0)
|
|
124
|
+
if err != nil {
|
|
125
|
+
return nil, err
|
|
126
|
+
}
|
|
127
|
+
if q < 0 || q >= 1 {
|
|
128
|
+
return nil, fmt.Errorf("quantile must be in (0, 1), got %v", q)
|
|
129
|
+
}
|
|
130
|
+
notes := []string{}
|
|
131
|
+
a, thinA, err := samples(d, "baseline", q)
|
|
85
132
|
if err != nil {
|
|
86
133
|
return nil, err
|
|
87
134
|
}
|
|
88
|
-
b, err := samples(d, "candidate")
|
|
135
|
+
b, thinB, err := samples(d, "candidate", q)
|
|
89
136
|
if err != nil {
|
|
90
137
|
return nil, err
|
|
91
138
|
}
|
|
139
|
+
if thinA || thinB {
|
|
140
|
+
notes = append(notes, noteThinTail)
|
|
141
|
+
}
|
|
92
142
|
if len(a) == 0 || len(b) == 0 {
|
|
93
143
|
return nil, errors.New("no median for empty data")
|
|
94
144
|
}
|
|
95
|
-
notes := []string{}
|
|
96
145
|
n := min(len(a), len(b))
|
|
97
146
|
if n < 10 {
|
|
98
147
|
notes = append(notes, noteFew)
|
|
@@ -107,22 +156,25 @@ func compare(d map[string]any) (any, error) {
|
|
|
107
156
|
if len(order) != len(a)+len(b) || switches < n/2 {
|
|
108
157
|
notes = append(notes, noteBlocked)
|
|
109
158
|
}
|
|
159
|
+
saturated, err := saturation(d)
|
|
160
|
+
if err != nil {
|
|
161
|
+
return nil, err
|
|
162
|
+
}
|
|
163
|
+
if saturated {
|
|
164
|
+
notes = append(notes, noteSaturated)
|
|
165
|
+
}
|
|
166
|
+
bud, err := checkBudget(d, b, lower, saturated || thinB)
|
|
167
|
+
if err != nil {
|
|
168
|
+
return nil, err
|
|
169
|
+
}
|
|
110
170
|
ma, mb := median(a), median(b)
|
|
111
171
|
floor, err := number(d, "resolution", 0)
|
|
112
172
|
if err != nil {
|
|
113
173
|
return nil, err
|
|
114
174
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
seed := int64(1)
|
|
119
|
-
if v, ok := d["seed"]; ok {
|
|
120
|
-
num, isNum := v.(json.Number)
|
|
121
|
-
s, err := num.Int64()
|
|
122
|
-
if !isNum || err != nil {
|
|
123
|
-
return nil, fmt.Errorf("seed must be an integer, got %v", v)
|
|
124
|
-
}
|
|
125
|
-
seed = s
|
|
175
|
+
rng, err := seeded(d)
|
|
176
|
+
if err != nil {
|
|
177
|
+
return nil, err
|
|
126
178
|
}
|
|
127
179
|
resamples, err := number(d, "resamples", 10000)
|
|
128
180
|
if err != nil {
|
|
@@ -131,7 +183,13 @@ func compare(d map[string]any) (any, error) {
|
|
|
131
183
|
if int(resamples) < 1 {
|
|
132
184
|
return nil, fmt.Errorf("resamples must be at least 1, got %v", resamples)
|
|
133
185
|
}
|
|
134
|
-
|
|
186
|
+
er, err := checkErrors(d, rng, int(resamples), saturated)
|
|
187
|
+
if err != nil {
|
|
188
|
+
return nil, err
|
|
189
|
+
}
|
|
190
|
+
if min(ma, mb) <= floor {
|
|
191
|
+
return noRatio{"NO_RATIO", "median at or below measurement resolution", ma, mb, bud, er, notes}, nil
|
|
192
|
+
}
|
|
135
193
|
boots := make([]float64, int(resamples))
|
|
136
194
|
ra, rb := make([]float64, len(a)), make([]float64, len(b))
|
|
137
195
|
for i := range boots {
|
|
@@ -148,7 +206,8 @@ func compare(d map[string]any) (any, error) {
|
|
|
148
206
|
if hiIdx < 0 { // Python's boots[-1]
|
|
149
207
|
hiIdx += len(boots)
|
|
150
208
|
}
|
|
151
|
-
|
|
209
|
+
bootLo, bootHi := boots[int(0.025*float64(len(boots)))], boots[hiIdx]
|
|
210
|
+
lo, hi := bootLo, bootHi
|
|
152
211
|
target, err := number(d, "target", 1)
|
|
153
212
|
if err != nil {
|
|
154
213
|
return nil, err
|
|
@@ -157,6 +216,19 @@ func compare(d map[string]any) (any, error) {
|
|
|
157
216
|
if err != nil {
|
|
158
217
|
return nil, err
|
|
159
218
|
}
|
|
219
|
+
// Claims need both methods: the lower bound is the smaller of the two, and
|
|
220
|
+
// either method's upper bound below 1 is a regression.
|
|
221
|
+
num, den := a, b // the ratio is baseline/candidate when lower is better
|
|
222
|
+
if !lower {
|
|
223
|
+
num, den = b, a
|
|
224
|
+
}
|
|
225
|
+
var hl *interval
|
|
226
|
+
if est, hlLo, hlHi, ok := hlRatio(num, den, 0.05); ok {
|
|
227
|
+
hl = &interval{round(est, 4), [2]float64{round(hlLo, 4), round(hlHi, 4)}}
|
|
228
|
+
lo, hi = min(lo, hlLo), min(hi, hlHi)
|
|
229
|
+
} else {
|
|
230
|
+
notes = append(notes, noteNoHL)
|
|
231
|
+
}
|
|
160
232
|
var verdict string
|
|
161
233
|
switch {
|
|
162
234
|
case hi < 1:
|
|
@@ -173,15 +245,198 @@ func compare(d map[string]any) (any, error) {
|
|
|
173
245
|
if len(notes) > 0 && verdict != "REGRESSION" {
|
|
174
246
|
verdict = "INCONCLUSIVE"
|
|
175
247
|
}
|
|
248
|
+
var guard *guardrail
|
|
249
|
+
if _, ok := d["tolerance"]; ok {
|
|
250
|
+
tol, err := number(d, "tolerance", 0)
|
|
251
|
+
if err != nil || tol < 0 {
|
|
252
|
+
return nil, fmt.Errorf("tolerance must be a number >= 0, got %v", d["tolerance"])
|
|
253
|
+
}
|
|
254
|
+
fl := 1 / (1 + tol)
|
|
255
|
+
guard = &guardrail{"INCONCLUSIVE", tol, round(fl, 4)}
|
|
256
|
+
switch {
|
|
257
|
+
case hi < fl:
|
|
258
|
+
guard.Verdict = "REGRESSION"
|
|
259
|
+
case lo >= fl && len(notes) == 0:
|
|
260
|
+
guard.Verdict = "NON_INFERIOR"
|
|
261
|
+
}
|
|
262
|
+
}
|
|
176
263
|
delta := mb - ma
|
|
177
264
|
return result{
|
|
178
|
-
Verdict: verdict, Ratio: round(ratio(ma, mb, lower), 4), CI95: [2]float64{round(
|
|
265
|
+
Verdict: verdict, Ratio: round(ratio(ma, mb, lower), 4), CI95: [2]float64{round(bootLo, 4), round(bootHi, 4)},
|
|
266
|
+
HodgesLehmann: hl, Quantile: q,
|
|
179
267
|
BaselineMedian: ma, CandidateMedian: mb, AbsoluteDelta: delta,
|
|
180
268
|
PercentChange: round(100*delta/ma, 2), N: [2]int{len(a), len(b)},
|
|
181
|
-
Target: target, Materiality: mat, Notes: notes,
|
|
269
|
+
Target: target, Materiality: mat, Guardrail: guard, Budget: bud, ErrorRate: er, Notes: notes,
|
|
182
270
|
}, nil
|
|
183
271
|
}
|
|
184
272
|
|
|
273
|
+
func seeded(d map[string]any) (*rand.Rand, error) {
|
|
274
|
+
seed := int64(1)
|
|
275
|
+
if v, ok := d["seed"]; ok {
|
|
276
|
+
num, isNum := v.(json.Number)
|
|
277
|
+
s, err := num.Int64()
|
|
278
|
+
if !isNum || err != nil {
|
|
279
|
+
return nil, fmt.Errorf("seed must be an integer, got %v", v)
|
|
280
|
+
}
|
|
281
|
+
seed = s
|
|
282
|
+
}
|
|
283
|
+
return rand.New(rand.NewPCG(uint64(seed), 0)), nil // #nosec G404 -- bootstrap resampling needs a seeded, reproducible PRNG, not secrecy
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// saturation reports whether any arm completed less than 98% of its offered
|
|
287
|
+
// load; latency from a saturated run supports no claim.
|
|
288
|
+
func saturation(d map[string]any) (bool, error) {
|
|
289
|
+
l := ovio.Obj(d["load"])
|
|
290
|
+
if l == nil {
|
|
291
|
+
return false, nil
|
|
292
|
+
}
|
|
293
|
+
for _, arm := range []string{"baseline", "candidate"} {
|
|
294
|
+
off, err := toFloat(ovio.Obj(l["offered"])[arm], "load.offered."+arm)
|
|
295
|
+
if err != nil {
|
|
296
|
+
return false, err
|
|
297
|
+
}
|
|
298
|
+
done, err := toFloat(ovio.Obj(l["completed"])[arm], "load.completed."+arm)
|
|
299
|
+
if err != nil {
|
|
300
|
+
return false, err
|
|
301
|
+
}
|
|
302
|
+
if done < 0.98*off {
|
|
303
|
+
return true, nil
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return false, nil
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// checkBudget tests the candidate against an absolute target with one-sided
|
|
310
|
+
// 95% distribution-free bounds on the chosen quantile of its invocations; a
|
|
311
|
+
// deterministic metric (bytes, counts) must repeat exactly and needs no bound.
|
|
312
|
+
func checkBudget(d map[string]any, b []float64, lower, capped bool) (*budget, error) {
|
|
313
|
+
m := ovio.Obj(d["budget"])
|
|
314
|
+
if m == nil {
|
|
315
|
+
return nil, nil
|
|
316
|
+
}
|
|
317
|
+
v, err := toFloat(m["value"], "budget.value")
|
|
318
|
+
if err != nil {
|
|
319
|
+
return nil, err
|
|
320
|
+
}
|
|
321
|
+
p := 0.5
|
|
322
|
+
if _, ok := m["quantile"]; ok {
|
|
323
|
+
if p, err = toFloat(m["quantile"], "budget.quantile"); err != nil || p <= 0 || p >= 1 {
|
|
324
|
+
return nil, fmt.Errorf("budget.quantile must be in (0, 1), got %v", m["quantile"])
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
out := &budget{Value: v, Quantile: p}
|
|
328
|
+
lcb, ucb := quantileBounds(b, p, 0.05)
|
|
329
|
+
if ovio.Truthy(m["deterministic"]) {
|
|
330
|
+
lcb, ucb = b[0], b[0]
|
|
331
|
+
for _, x := range b {
|
|
332
|
+
if x != b[0] {
|
|
333
|
+
out.Verdict = "NONDETERMINISTIC"
|
|
334
|
+
return out, nil
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
out.Bounds = [2]any{bound(lcb), bound(ucb)}
|
|
339
|
+
good, bad := ucb <= v, lcb > v // lower is better
|
|
340
|
+
if !lower {
|
|
341
|
+
good, bad = lcb >= v, ucb < v
|
|
342
|
+
}
|
|
343
|
+
switch {
|
|
344
|
+
case bad:
|
|
345
|
+
out.Verdict = "FAIL"
|
|
346
|
+
case capped:
|
|
347
|
+
out.Verdict = "INCONCLUSIVE"
|
|
348
|
+
case good:
|
|
349
|
+
out.Verdict = "PASS"
|
|
350
|
+
case math.IsInf(lcb, 0) || math.IsInf(ucb, 0):
|
|
351
|
+
out.Verdict = "INSUFFICIENT_DATA"
|
|
352
|
+
default:
|
|
353
|
+
out.Verdict = "INCONCLUSIVE"
|
|
354
|
+
}
|
|
355
|
+
return out, nil
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// bound renders an infinite bound as null.
|
|
359
|
+
func bound(x float64) any {
|
|
360
|
+
if math.IsInf(x, 0) {
|
|
361
|
+
return nil
|
|
362
|
+
}
|
|
363
|
+
return x
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// checkErrors compares per-invocation error rates (candidate minus baseline)
|
|
367
|
+
// against an absolute tolerance. The interval is the wider of a bootstrap over
|
|
368
|
+
// invocations and, with request totals, the Newcombe hybrid score interval.
|
|
369
|
+
func checkErrors(d map[string]any, rng *rand.Rand, resamples int, saturated bool) (*errorRate, error) {
|
|
370
|
+
m := ovio.Obj(d["error_rates"])
|
|
371
|
+
if m == nil {
|
|
372
|
+
return nil, nil
|
|
373
|
+
}
|
|
374
|
+
ea, _, err := samples(m, "baseline", 0)
|
|
375
|
+
if err != nil {
|
|
376
|
+
return nil, err
|
|
377
|
+
}
|
|
378
|
+
eb, _, err := samples(m, "candidate", 0)
|
|
379
|
+
if err != nil {
|
|
380
|
+
return nil, err
|
|
381
|
+
}
|
|
382
|
+
if len(ea) == 0 || len(eb) == 0 {
|
|
383
|
+
return nil, errors.New("error_rates needs baseline and candidate rates")
|
|
384
|
+
}
|
|
385
|
+
for _, x := range append(slices.Clone(ea), eb...) {
|
|
386
|
+
if x < 0 || x > 1 {
|
|
387
|
+
return nil, fmt.Errorf("error_rates must be proportions in [0, 1], got %v", x)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
tol, err := toFloat(m["tolerance"], "error_rates.tolerance")
|
|
391
|
+
if err != nil {
|
|
392
|
+
return nil, err
|
|
393
|
+
}
|
|
394
|
+
mean := func(xs []float64) float64 {
|
|
395
|
+
s := 0.0
|
|
396
|
+
for _, x := range xs {
|
|
397
|
+
s += x
|
|
398
|
+
}
|
|
399
|
+
return s / float64(len(xs))
|
|
400
|
+
}
|
|
401
|
+
boots := make([]float64, resamples)
|
|
402
|
+
ra, rb := make([]float64, len(ea)), make([]float64, len(eb))
|
|
403
|
+
for i := range boots {
|
|
404
|
+
for j := range ra {
|
|
405
|
+
ra[j] = ea[rng.IntN(len(ea))]
|
|
406
|
+
}
|
|
407
|
+
for j := range rb {
|
|
408
|
+
rb[j] = eb[rng.IntN(len(eb))]
|
|
409
|
+
}
|
|
410
|
+
boots[i] = mean(rb) - mean(ra)
|
|
411
|
+
}
|
|
412
|
+
sort.Float64s(boots)
|
|
413
|
+
lo, hi := boots[int(0.025*float64(len(boots)))], boots[max(0, int(0.975*float64(len(boots)))-1)]
|
|
414
|
+
pa, pb := mean(ea), mean(eb)
|
|
415
|
+
if req := ovio.Obj(m["requests"]); req != nil {
|
|
416
|
+
na, err := toFloat(req["baseline"], "error_rates.requests.baseline")
|
|
417
|
+
if err != nil {
|
|
418
|
+
return nil, err
|
|
419
|
+
}
|
|
420
|
+
nb, err := toFloat(req["candidate"], "error_rates.requests.candidate")
|
|
421
|
+
if err != nil {
|
|
422
|
+
return nil, err
|
|
423
|
+
}
|
|
424
|
+
if na <= 0 || nb <= 0 {
|
|
425
|
+
return nil, fmt.Errorf("error_rates.requests must be positive, got %v and %v", na, nb)
|
|
426
|
+
}
|
|
427
|
+
nl, nu := newcombe(pb, nb, pa, na, 1.96)
|
|
428
|
+
lo, hi = min(lo, nl), max(hi, nu)
|
|
429
|
+
}
|
|
430
|
+
out := &errorRate{"INCONCLUSIVE", tol, round(pb-pa, 6), [2]float64{round(lo, 6), round(hi, 6)}}
|
|
431
|
+
switch {
|
|
432
|
+
case lo > tol:
|
|
433
|
+
out.Verdict = "FAIL"
|
|
434
|
+
case hi <= tol && !saturated:
|
|
435
|
+
out.Verdict = "PASS"
|
|
436
|
+
}
|
|
437
|
+
return out, nil
|
|
438
|
+
}
|
|
439
|
+
|
|
185
440
|
func ratio(base, cand float64, lowerBetter bool) float64 {
|
|
186
441
|
if lowerBetter {
|
|
187
442
|
return base / cand
|
|
@@ -204,24 +459,58 @@ func round(x float64, places int) float64 {
|
|
|
204
459
|
return math.Round(x*p) / p
|
|
205
460
|
}
|
|
206
461
|
|
|
207
|
-
|
|
462
|
+
// samples reads one value per invocation. With a quantile q, an invocation may
|
|
463
|
+
// be given as its raw request latencies (reduced to their q-quantile) or as its
|
|
464
|
+
// q-quantile with requests_per_invocation; thin reports an invocation with
|
|
465
|
+
// fewer than 10/(1-q) requests, too few for a stable tail.
|
|
466
|
+
func samples(d map[string]any, key string, q float64) (out []float64, thin bool, err error) {
|
|
208
467
|
v, ok := d[key]
|
|
209
468
|
if !ok {
|
|
210
|
-
return nil, fmt.Errorf("missing %q", key)
|
|
469
|
+
return nil, false, fmt.Errorf("missing %q", key)
|
|
211
470
|
}
|
|
212
471
|
list, ok := v.([]any)
|
|
213
472
|
if !ok {
|
|
214
|
-
return nil, fmt.Errorf("%s must be an array", key)
|
|
473
|
+
return nil, false, fmt.Errorf("%s must be an array", key)
|
|
474
|
+
}
|
|
475
|
+
need := 0.0
|
|
476
|
+
nested := slices.ContainsFunc(list, func(x any) bool { _, ok := x.([]any); return ok })
|
|
477
|
+
if nested && slices.ContainsFunc(list, func(x any) bool { _, ok := x.([]any); return !ok }) {
|
|
478
|
+
return nil, false, fmt.Errorf("%s mixes raw request arrays with per-invocation values", key)
|
|
479
|
+
}
|
|
480
|
+
if q > 0 {
|
|
481
|
+
need = math.Ceil(10/(1-q) - 1e-9)
|
|
482
|
+
if !nested {
|
|
483
|
+
n, err := toFloat(d["requests_per_invocation"], "requests_per_invocation")
|
|
484
|
+
if err != nil {
|
|
485
|
+
return nil, false, fmt.Errorf("a quantile over per-invocation values needs requests_per_invocation: %w", err)
|
|
486
|
+
}
|
|
487
|
+
thin = n < need
|
|
488
|
+
}
|
|
215
489
|
}
|
|
216
|
-
out
|
|
490
|
+
out = make([]float64, len(list))
|
|
217
491
|
for i, x := range list {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
492
|
+
if inner, isList := x.([]any); isList {
|
|
493
|
+
if q == 0 {
|
|
494
|
+
return nil, false, fmt.Errorf("%s: raw request arrays need a quantile", key)
|
|
495
|
+
}
|
|
496
|
+
reqs := make([]float64, len(inner))
|
|
497
|
+
for j, r := range inner {
|
|
498
|
+
if reqs[j], err = toFloat(r, key); err != nil {
|
|
499
|
+
return nil, false, err
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if len(reqs) == 0 {
|
|
503
|
+
return nil, false, fmt.Errorf("%s: invocation %d has no requests", key, i)
|
|
504
|
+
}
|
|
505
|
+
thin = thin || float64(len(reqs)) < need
|
|
506
|
+
out[i] = quantile7(reqs, q)
|
|
507
|
+
continue
|
|
508
|
+
}
|
|
509
|
+
if out[i], err = toFloat(x, key); err != nil {
|
|
510
|
+
return nil, false, err
|
|
221
511
|
}
|
|
222
|
-
out[i] = f
|
|
223
512
|
}
|
|
224
|
-
return out, nil
|
|
513
|
+
return out, thin, nil
|
|
225
514
|
}
|
|
226
515
|
|
|
227
516
|
func number(d map[string]any, key string, def float64) (float64, error) {
|
|
@@ -3,11 +3,15 @@ package bench
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
5
|
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"math"
|
|
6
8
|
"math/rand/v2"
|
|
7
9
|
"os"
|
|
8
10
|
"path/filepath"
|
|
9
11
|
"strings"
|
|
10
12
|
"testing"
|
|
13
|
+
|
|
14
|
+
"github.com/devrites/devrites/internal/overhaul/ovio"
|
|
11
15
|
)
|
|
12
16
|
|
|
13
17
|
func gauss(rng *rand.Rand, mean, sd float64, n int) []float64 {
|
|
@@ -137,3 +141,130 @@ func TestBench(t *testing.T) {
|
|
|
137
141
|
}
|
|
138
142
|
})
|
|
139
143
|
}
|
|
144
|
+
|
|
145
|
+
func TestStats(t *testing.T) {
|
|
146
|
+
if k := tailCount(10, 10, 0.05); k != 24 {
|
|
147
|
+
t.Fatalf("n=m=10 alpha .05: k=%d, want 24 (Hollander-Wolfe D_(24), D_(77))", k)
|
|
148
|
+
}
|
|
149
|
+
if k := tailCount(30, 30, 1e-3); 2*k != 464 {
|
|
150
|
+
t.Fatalf("n=m=30 misrate 1e-3: margin %d, want 464 (Pragmastat PairwiseMargin)", 2*k)
|
|
151
|
+
}
|
|
152
|
+
if k := tailCount(3, 3, 0.05); k != 0 {
|
|
153
|
+
t.Fatalf("n=m=3 has no finite 95%% interval, got k=%d", k)
|
|
154
|
+
}
|
|
155
|
+
xs := []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
|
156
|
+
if lcb, ucb := quantileBounds(xs, 0.5, 0.05); lcb != 2 || ucb != 9 {
|
|
157
|
+
t.Fatalf("median bounds n=10: [%v, %v], want [2, 9]", lcb, ucb)
|
|
158
|
+
}
|
|
159
|
+
if _, ucb := quantileBounds(xs, 0.99, 0.05); !math.IsInf(ucb, 1) {
|
|
160
|
+
t.Fatalf("p99 with n=10 has no upper bound, got %v", ucb)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func TestBenchExtensions(t *testing.T) {
|
|
165
|
+
fast, rng := fixture()
|
|
166
|
+
if _, res := verdict(t, fast); res["hodges_lehmann"] == nil {
|
|
167
|
+
t.Fatalf("no Hodges-Lehmann bounds: %v", res)
|
|
168
|
+
}
|
|
169
|
+
raw := func(mean float64, invocations, requests int) []any {
|
|
170
|
+
var arm []any
|
|
171
|
+
for range invocations {
|
|
172
|
+
arm = append(arm, gauss(rng, mean, mean/10, requests))
|
|
173
|
+
}
|
|
174
|
+
return arm
|
|
175
|
+
}
|
|
176
|
+
tail := map[string]any{"direction": "lower_is_better", "quantile": 0.99, "order": interleaved(12), "target": 2.0,
|
|
177
|
+
"baseline": raw(100, 12, 1000), "candidate": raw(30, 12, 1000)}
|
|
178
|
+
|
|
179
|
+
t.Run("tail quantile from raw requests", func(t *testing.T) {
|
|
180
|
+
if v, res := verdict(t, tail); v != "SUPPORTS_TARGET" || res["quantile"] != 0.99 {
|
|
181
|
+
t.Fatalf("got %v", res)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
t.Run("thin tail is inconclusive", func(t *testing.T) {
|
|
185
|
+
thin := with(tail, "baseline", raw(100, 12, 50), "candidate", raw(30, 12, 50))
|
|
186
|
+
if v, res := verdict(t, thin); v != "INCONCLUSIVE" || !strings.Contains(fmt.Sprint(res["notes"]), "too few requests") {
|
|
187
|
+
t.Fatalf("got %v", res)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
t.Run("per-invocation quantiles need request counts", func(t *testing.T) {
|
|
191
|
+
code, _, stderr := run(t, with(fast, "quantile", 0.95))
|
|
192
|
+
if code != 2 || !strings.Contains(stderr, "requests_per_invocation") {
|
|
193
|
+
t.Fatalf("exit %d %q", code, stderr)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
t.Run("guardrail", func(t *testing.T) {
|
|
197
|
+
same := with(fast, "candidate", gauss(rng, 30, 1, 20), "tolerance", 0.1)
|
|
198
|
+
if _, res := verdict(t, same); ovio.Get(res, "guardrail.verdict") != "NON_INFERIOR" {
|
|
199
|
+
t.Fatalf("equal arms: %v", res["guardrail"])
|
|
200
|
+
}
|
|
201
|
+
slow := with(fast, "candidate", gauss(rng, 45, 1, 20), "tolerance", 0.1)
|
|
202
|
+
if _, res := verdict(t, slow); ovio.Get(res, "guardrail.verdict") != "REGRESSION" {
|
|
203
|
+
t.Fatalf("1.5x slower: %v", res["guardrail"])
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
t.Run("budget", func(t *testing.T) {
|
|
207
|
+
bytes := func(xs ...float64) []float64 { return xs }
|
|
208
|
+
for name, tc := range map[string]struct {
|
|
209
|
+
cand []float64
|
|
210
|
+
budget map[string]any
|
|
211
|
+
want string
|
|
212
|
+
}{
|
|
213
|
+
"deterministic pass": {bytes(100, 100), map[string]any{"value": 120, "deterministic": true}, "PASS"},
|
|
214
|
+
"deterministic fail": {bytes(130, 130), map[string]any{"value": 120, "deterministic": true}, "FAIL"},
|
|
215
|
+
"nondeterministic": {bytes(100, 101), map[string]any{"value": 120, "deterministic": true}, "NONDETERMINISTIC"},
|
|
216
|
+
"p75 under budget": {gauss(rng, 10, 0.4, 20), map[string]any{"value": 12, "quantile": 0.75}, "PASS"},
|
|
217
|
+
"p75 over budget": {gauss(rng, 10, 0.4, 20), map[string]any{"value": 8, "quantile": 0.75}, "FAIL"},
|
|
218
|
+
"p99 needs more runs": {gauss(rng, 10, 0.4, 20), map[string]any{"value": 12, "quantile": 0.99}, "INSUFFICIENT_DATA"},
|
|
219
|
+
"budget straddles p75": {bytes(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20), map[string]any{"value": 15, "quantile": 0.75}, "INCONCLUSIVE"},
|
|
220
|
+
} {
|
|
221
|
+
if _, res := verdict(t, with(fast, "candidate", tc.cand, "budget", tc.budget)); ovio.Get(res, "budget.verdict") != tc.want {
|
|
222
|
+
t.Fatalf("%s: %v", name, res["budget"])
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
t.Run("error-rate guardrail", func(t *testing.T) {
|
|
227
|
+
zeros := make([]float64, 20)
|
|
228
|
+
clean := map[string]any{"baseline": zeros, "candidate": zeros, "tolerance": 0.001,
|
|
229
|
+
"requests": map[string]any{"baseline": 20000, "candidate": 20000}}
|
|
230
|
+
if _, res := verdict(t, with(fast, "error_rates", clean)); ovio.Get(res, "error_rate.verdict") != "PASS" {
|
|
231
|
+
t.Fatalf("clean: %v", res["error_rate"])
|
|
232
|
+
}
|
|
233
|
+
few := with(clean, "requests", map[string]any{"baseline": 200, "candidate": 200})
|
|
234
|
+
if _, res := verdict(t, with(fast, "error_rates", few)); ovio.Get(res, "error_rate.verdict") != "INCONCLUSIVE" {
|
|
235
|
+
t.Fatalf("zero errors in 200 requests proves nothing at 0.1%%: %v", res["error_rate"])
|
|
236
|
+
}
|
|
237
|
+
worse := with(clean, "candidate", gauss(rng, 0.05, 0.005, 20))
|
|
238
|
+
if _, res := verdict(t, with(fast, "error_rates", worse)); ovio.Get(res, "error_rate.verdict") != "FAIL" {
|
|
239
|
+
t.Fatalf("5%% errors: %v", res["error_rate"])
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
t.Run("saturated load caps every verdict", func(t *testing.T) {
|
|
243
|
+
load := map[string]any{"offered": map[string]any{"baseline": 1000, "candidate": 1000},
|
|
244
|
+
"completed": map[string]any{"baseline": 1000, "candidate": 900}}
|
|
245
|
+
clean := map[string]any{"baseline": make([]float64, 20), "candidate": make([]float64, 20), "tolerance": 0.01}
|
|
246
|
+
v, res := verdict(t, with(fast, "load", load, "error_rates", clean))
|
|
247
|
+
if v != "INCONCLUSIVE" || !strings.Contains(fmt.Sprint(res["notes"]), "saturated") || ovio.Get(res, "error_rate.verdict") != "INCONCLUSIVE" {
|
|
248
|
+
t.Fatalf("got %v", res)
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
t.Run("thin tail caps the budget", func(t *testing.T) {
|
|
252
|
+
thin := with(tail, "baseline", raw(100, 12, 50), "candidate", raw(30, 12, 50), "budget", map[string]any{"value": 1000})
|
|
253
|
+
if _, res := verdict(t, thin); ovio.Get(res, "budget.verdict") != "INCONCLUSIVE" {
|
|
254
|
+
t.Fatalf("got %v", res["budget"])
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
t.Run("invalid inputs are errors", func(t *testing.T) {
|
|
258
|
+
rates := func(x float64) []float64 { return []float64{x, x, x} }
|
|
259
|
+
for want, d := range map[string]map[string]any{
|
|
260
|
+
"proportions in [0, 1]": with(fast, "error_rates", map[string]any{"baseline": rates(1), "candidate": rates(1.5), "tolerance": 1}),
|
|
261
|
+
"must be positive": with(fast, "error_rates", map[string]any{"baseline": rates(0), "candidate": rates(0), "tolerance": 0.01,
|
|
262
|
+
"requests": map[string]any{"baseline": 0, "candidate": 1000}}),
|
|
263
|
+
"mixes raw request arrays": with(tail, "candidate", append(raw(30, 11, 1000), 30.0)),
|
|
264
|
+
} {
|
|
265
|
+
if code, _, stderr := run(t, d); code != 2 || !strings.Contains(stderr, want) {
|
|
266
|
+
t.Fatalf("%s: exit %d %q", want, code, stderr)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
}
|