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.
Files changed (218) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +6 -3
  3. package/docs/adr/0033-rite-fast-own-agents.md +45 -0
  4. package/docs/adr/README.md +2 -1
  5. package/docs/architecture.md +6 -4
  6. package/docs/command-map.md +2 -1
  7. package/docs/flow.md +3 -2
  8. package/docs/orchestration.md +3 -0
  9. package/docs/quick-reference.md +2 -1
  10. package/docs/skills.md +13 -8
  11. package/docs/usage.md +2 -2
  12. package/engine/internal/install/apply.go +5 -3
  13. package/engine/internal/install/binary.go +3 -1
  14. package/engine/internal/install/install.go +0 -25
  15. package/engine/internal/lib/claims.go +15 -0
  16. package/engine/internal/lib/claims_test.go +28 -0
  17. package/engine/internal/lib/context_next_test.go +53 -0
  18. package/engine/internal/lib/dispatch.go +28 -7
  19. package/engine/internal/lib/manifest_union.go +14 -0
  20. package/engine/internal/overhaul/admit/admit_test.go +9 -0
  21. package/engine/internal/overhaul/admit/run.go +9 -0
  22. package/engine/internal/overhaul/bench/run.go +327 -38
  23. package/engine/internal/overhaul/bench/run_test.go +131 -0
  24. package/engine/internal/overhaul/bench/stats.go +154 -0
  25. package/engine/internal/overhaul/ovio/ovio.go +4 -0
  26. package/engine/internal/overhaul/records/check.go +3 -0
  27. package/engine/internal/overhaul/records/consistency.go +217 -0
  28. package/engine/internal/overhaul/records/records_test.go +81 -0
  29. package/engine/internal/overhaul/records/run.go +1 -1
  30. package/engine/internal/overhaul/render/run.go +2 -0
  31. package/engine/internal/overhaul/render/run_test.go +5 -1
  32. package/engine/internal/overhaul/score/run.go +58 -9
  33. package/engine/internal/overhaul/score/run_test.go +84 -8
  34. package/engine/internal/parallel/ops.go +5 -0
  35. package/engine/internal/state/write.go +8 -2
  36. package/engine/internal/version/version_test.go +27 -0
  37. package/engine/main.go +2 -2
  38. package/pack/.claude/agents/fast-builder.md +90 -0
  39. package/pack/.claude/agents/fast-checker.md +60 -0
  40. package/pack/.claude/agents/fast-critic.md +70 -0
  41. package/pack/.claude/agents/fast-planner.md +95 -0
  42. package/pack/.claude/agents/overhaul-backend-engineer.md +5 -4
  43. package/pack/.claude/agents/overhaul-boundary.md +2 -2
  44. package/pack/.claude/agents/overhaul-craft.md +1 -1
  45. package/pack/.claude/agents/overhaul-frontend-engineer.md +5 -3
  46. package/pack/.claude/agents/overhaul-implementer.md +9 -5
  47. package/pack/.claude/agents/overhaul-specialist.md +6 -3
  48. package/pack/.claude/agents/overhaul-verifier.md +3 -3
  49. package/pack/.claude/settings.json +1 -1
  50. package/pack/.claude/skills/devrites-lib/reference/intent-map.md +2 -0
  51. package/pack/.claude/skills/overhaul/SKILL.md +2 -1
  52. package/pack/.claude/skills/overhaul/references/audit-domains.md +139 -12
  53. package/pack/.claude/skills/overhaul/references/lifecycle.md +2 -0
  54. package/pack/.claude/skills/overhaul/references/orchestration.md +13 -5
  55. package/pack/.claude/skills/overhaul/references/records.md +12 -3
  56. package/pack/.claude/skills/overhaul/references/repair-and-measurement.md +108 -5
  57. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +17 -2
  58. package/pack/.claude/skills/overhaul/references/scoring.md +36 -7
  59. package/pack/.claude/skills/overhaul/references/stack-profiles.md +52 -0
  60. package/pack/.claude/skills/rite/SKILL.md +4 -1
  61. package/pack/.claude/skills/rite-doctor/SKILL.md +2 -0
  62. package/pack/.claude/skills/rite-fast/SKILL.md +183 -0
  63. package/pack/.claude/skills/rite-fast/reference/artifacts.md +107 -0
  64. package/pack/.claude/skills/rite-fast/reference/check-and-triage.md +51 -0
  65. package/pack/.claude/skills/rite-quick/SKILL.md +2 -0
  66. package/pack/.claude/skills/rite-spec/reference/state-workspace.md +1 -0
  67. package/pack/generated/claude/agents/fast-builder.md +90 -0
  68. package/pack/generated/claude/agents/fast-checker.md +60 -0
  69. package/pack/generated/claude/agents/fast-critic.md +70 -0
  70. package/pack/generated/claude/agents/fast-planner.md +95 -0
  71. package/pack/generated/claude/agents/overhaul-backend-engineer.md +5 -4
  72. package/pack/generated/claude/agents/overhaul-boundary.md +2 -2
  73. package/pack/generated/claude/agents/overhaul-craft.md +1 -1
  74. package/pack/generated/claude/agents/overhaul-frontend-engineer.md +5 -3
  75. package/pack/generated/claude/agents/overhaul-implementer.md +9 -5
  76. package/pack/generated/claude/agents/overhaul-specialist.md +6 -3
  77. package/pack/generated/claude/agents/overhaul-verifier.md +3 -3
  78. package/pack/generated/claude/settings.json +1 -1
  79. package/pack/generated/claude/skills/devrites-lib/reference/intent-map.md +2 -0
  80. package/pack/generated/claude/skills/overhaul/SKILL.md +2 -1
  81. package/pack/generated/claude/skills/overhaul/references/audit-domains.md +139 -12
  82. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +2 -0
  83. package/pack/generated/claude/skills/overhaul/references/orchestration.md +13 -5
  84. package/pack/generated/claude/skills/overhaul/references/records.md +12 -3
  85. package/pack/generated/claude/skills/overhaul/references/repair-and-measurement.md +108 -5
  86. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +17 -2
  87. package/pack/generated/claude/skills/overhaul/references/scoring.md +36 -7
  88. package/pack/generated/claude/skills/overhaul/references/stack-profiles.md +52 -0
  89. package/pack/generated/claude/skills/rite/SKILL.md +4 -1
  90. package/pack/generated/claude/skills/rite-doctor/SKILL.md +2 -0
  91. package/pack/generated/claude/skills/rite-fast/SKILL.md +183 -0
  92. package/pack/generated/claude/skills/rite-fast/reference/artifacts.md +107 -0
  93. package/pack/generated/claude/skills/rite-fast/reference/check-and-triage.md +51 -0
  94. package/pack/generated/claude/skills/rite-quick/SKILL.md +2 -0
  95. package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +1 -0
  96. package/pack/generated/codex/AGENTS.md +1 -0
  97. package/pack/generated/codex/agents/fast-builder.toml +90 -0
  98. package/pack/generated/codex/agents/fast-checker.toml +60 -0
  99. package/pack/generated/codex/agents/fast-critic.toml +70 -0
  100. package/pack/generated/codex/agents/fast-planner.toml +95 -0
  101. package/pack/generated/codex/agents/overhaul-backend-engineer.toml +5 -4
  102. package/pack/generated/codex/agents/overhaul-boundary.toml +2 -2
  103. package/pack/generated/codex/agents/overhaul-craft.toml +1 -1
  104. package/pack/generated/codex/agents/overhaul-frontend-engineer.toml +5 -3
  105. package/pack/generated/codex/agents/overhaul-implementer.toml +9 -5
  106. package/pack/generated/codex/agents/overhaul-specialist.toml +6 -3
  107. package/pack/generated/codex/agents/overhaul-verifier.toml +3 -3
  108. package/pack/generated/codex/skills/devrites-lib/reference/intent-map.md +2 -0
  109. package/pack/generated/codex/skills/overhaul/SKILL.md +2 -1
  110. package/pack/generated/codex/skills/overhaul/references/audit-domains.md +139 -12
  111. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +2 -0
  112. package/pack/generated/codex/skills/overhaul/references/orchestration.md +13 -5
  113. package/pack/generated/codex/skills/overhaul/references/records.md +12 -3
  114. package/pack/generated/codex/skills/overhaul/references/repair-and-measurement.md +108 -5
  115. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +17 -2
  116. package/pack/generated/codex/skills/overhaul/references/scoring.md +36 -7
  117. package/pack/generated/codex/skills/overhaul/references/stack-profiles.md +52 -0
  118. package/pack/generated/codex/skills/rite/SKILL.md +4 -1
  119. package/pack/generated/codex/skills/rite-doctor/SKILL.md +2 -0
  120. package/pack/generated/codex/skills/rite-fast/SKILL.md +183 -0
  121. package/pack/generated/codex/skills/rite-fast/agents/openai.yaml +2 -0
  122. package/pack/generated/codex/skills/rite-fast/reference/artifacts.md +107 -0
  123. package/pack/generated/codex/skills/rite-fast/reference/check-and-triage.md +51 -0
  124. package/pack/generated/codex/skills/rite-quick/SKILL.md +2 -0
  125. package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +1 -0
  126. package/pack/generated/devin/AGENTS.md +1 -0
  127. package/pack/generated/devin/agents/fast-builder.md +96 -0
  128. package/pack/generated/devin/agents/fast-checker.md +63 -0
  129. package/pack/generated/devin/agents/fast-critic.md +73 -0
  130. package/pack/generated/devin/agents/fast-planner.md +98 -0
  131. package/pack/generated/devin/agents/overhaul-backend-engineer.md +5 -4
  132. package/pack/generated/devin/agents/overhaul-boundary.md +2 -2
  133. package/pack/generated/devin/agents/overhaul-craft.md +1 -1
  134. package/pack/generated/devin/agents/overhaul-frontend-engineer.md +5 -3
  135. package/pack/generated/devin/agents/overhaul-implementer.md +9 -5
  136. package/pack/generated/devin/agents/overhaul-specialist.md +6 -3
  137. package/pack/generated/devin/agents/overhaul-verifier.md +3 -3
  138. package/pack/generated/devin/skills/devrites-lib/reference/intent-map.md +2 -0
  139. package/pack/generated/devin/skills/overhaul/SKILL.md +2 -1
  140. package/pack/generated/devin/skills/overhaul/references/audit-domains.md +139 -12
  141. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +2 -0
  142. package/pack/generated/devin/skills/overhaul/references/orchestration.md +13 -5
  143. package/pack/generated/devin/skills/overhaul/references/records.md +12 -3
  144. package/pack/generated/devin/skills/overhaul/references/repair-and-measurement.md +108 -5
  145. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +17 -2
  146. package/pack/generated/devin/skills/overhaul/references/scoring.md +36 -7
  147. package/pack/generated/devin/skills/overhaul/references/stack-profiles.md +52 -0
  148. package/pack/generated/devin/skills/rite/SKILL.md +4 -1
  149. package/pack/generated/devin/skills/rite-doctor/SKILL.md +2 -0
  150. package/pack/generated/devin/skills/rite-fast/SKILL.md +183 -0
  151. package/pack/generated/devin/skills/rite-fast/reference/artifacts.md +107 -0
  152. package/pack/generated/devin/skills/rite-fast/reference/check-and-triage.md +51 -0
  153. package/pack/generated/devin/skills/rite-quick/SKILL.md +2 -0
  154. package/pack/generated/devin/skills/rite-spec/reference/state-workspace.md +1 -0
  155. package/pack/generated/omp/agents/fast-builder.md +89 -0
  156. package/pack/generated/omp/agents/fast-checker.md +59 -0
  157. package/pack/generated/omp/agents/fast-critic.md +69 -0
  158. package/pack/generated/omp/agents/fast-planner.md +94 -0
  159. package/pack/generated/omp/agents/overhaul-backend-engineer.md +5 -4
  160. package/pack/generated/omp/agents/overhaul-boundary.md +2 -2
  161. package/pack/generated/omp/agents/overhaul-craft.md +1 -1
  162. package/pack/generated/omp/agents/overhaul-frontend-engineer.md +5 -3
  163. package/pack/generated/omp/agents/overhaul-implementer.md +9 -5
  164. package/pack/generated/omp/agents/overhaul-specialist.md +6 -3
  165. package/pack/generated/omp/agents/overhaul-verifier.md +3 -3
  166. package/pack/generated/omp/commands/rite-fast.md +5 -0
  167. package/pack/generated/omp/skills/devrites-lib/reference/intent-map.md +2 -0
  168. package/pack/generated/omp/skills/overhaul/SKILL.md +2 -1
  169. package/pack/generated/omp/skills/overhaul/references/audit-domains.md +139 -12
  170. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +2 -0
  171. package/pack/generated/omp/skills/overhaul/references/orchestration.md +13 -5
  172. package/pack/generated/omp/skills/overhaul/references/records.md +12 -3
  173. package/pack/generated/omp/skills/overhaul/references/repair-and-measurement.md +108 -5
  174. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +17 -2
  175. package/pack/generated/omp/skills/overhaul/references/scoring.md +36 -7
  176. package/pack/generated/omp/skills/overhaul/references/stack-profiles.md +52 -0
  177. package/pack/generated/omp/skills/rite/SKILL.md +4 -1
  178. package/pack/generated/omp/skills/rite-doctor/SKILL.md +2 -0
  179. package/pack/generated/omp/skills/rite-fast/SKILL.md +183 -0
  180. package/pack/generated/omp/skills/rite-fast/reference/artifacts.md +107 -0
  181. package/pack/generated/omp/skills/rite-fast/reference/check-and-triage.md +51 -0
  182. package/pack/generated/omp/skills/rite-quick/SKILL.md +2 -0
  183. package/pack/generated/omp/skills/rite-spec/reference/state-workspace.md +1 -0
  184. package/pack/generated/pi/AGENTS.md +1 -0
  185. package/pack/generated/pi/agents/fast-builder.md +90 -0
  186. package/pack/generated/pi/agents/fast-checker.md +60 -0
  187. package/pack/generated/pi/agents/fast-critic.md +70 -0
  188. package/pack/generated/pi/agents/fast-planner.md +95 -0
  189. package/pack/generated/pi/agents/overhaul-backend-engineer.md +5 -4
  190. package/pack/generated/pi/agents/overhaul-boundary.md +2 -2
  191. package/pack/generated/pi/agents/overhaul-craft.md +1 -1
  192. package/pack/generated/pi/agents/overhaul-frontend-engineer.md +5 -3
  193. package/pack/generated/pi/agents/overhaul-implementer.md +9 -5
  194. package/pack/generated/pi/agents/overhaul-specialist.md +6 -3
  195. package/pack/generated/pi/agents/overhaul-verifier.md +3 -3
  196. package/pack/generated/pi/prompts/rite-fast.md +5 -0
  197. package/pack/generated/pi/skills/devrites-lib/reference/intent-map.md +2 -0
  198. package/pack/generated/pi/skills/overhaul/SKILL.md +2 -1
  199. package/pack/generated/pi/skills/overhaul/references/audit-domains.md +139 -12
  200. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +2 -0
  201. package/pack/generated/pi/skills/overhaul/references/orchestration.md +13 -5
  202. package/pack/generated/pi/skills/overhaul/references/records.md +12 -3
  203. package/pack/generated/pi/skills/overhaul/references/repair-and-measurement.md +108 -5
  204. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +17 -2
  205. package/pack/generated/pi/skills/overhaul/references/scoring.md +36 -7
  206. package/pack/generated/pi/skills/overhaul/references/stack-profiles.md +52 -0
  207. package/pack/generated/pi/skills/rite/SKILL.md +4 -1
  208. package/pack/generated/pi/skills/rite-doctor/SKILL.md +2 -0
  209. package/pack/generated/pi/skills/rite-fast/SKILL.md +183 -0
  210. package/pack/generated/pi/skills/rite-fast/reference/artifacts.md +107 -0
  211. package/pack/generated/pi/skills/rite-fast/reference/check-and-triage.md +51 -0
  212. package/pack/generated/pi/skills/rite-quick/SKILL.md +2 -0
  213. package/pack/generated/pi/skills/rite-spec/reference/state-workspace.md +1 -0
  214. package/package.json +1 -1
  215. package/scripts/codex-generate.sh +2 -1
  216. package/scripts/devin-generate.sh +4 -3
  217. package/scripts/omp-generate.sh +3 -3
  218. 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}. The unit of resampling is one
7
- // independent invocation per sample; never pass inner iterations. A k-fold claim
8
- // is supported only when the CI lower bound >= k.
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 `json:"verdict"`
67
- Reason string `json:"reason"`
68
- BaselineMedian float64 `json:"baseline_median"`
69
- CandidateMedian float64 `json:"candidate_median"`
70
- Notes []string `json:"notes"`
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 = "fewer than 10 invocations per arm: exploratory only"
75
- noteBlocked = "arms not recorded as interleaved: contamination risk, verdict capped at inconclusive"
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
- a, err := samples(d, "baseline")
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
- if min(ma, mb) <= floor {
116
- return noRatio{"NO_RATIO", "median at or below measurement resolution", ma, mb, notes}, nil
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
- rng := rand.New(rand.NewPCG(uint64(seed), 0)) // #nosec G404 -- bootstrap resampling needs a seeded, reproducible PRNG, not secrecy
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
- lo, hi := boots[int(0.025*float64(len(boots)))], boots[hiIdx]
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(lo, 4), round(hi, 4)},
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
- func samples(d map[string]any, key string) ([]float64, error) {
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 := make([]float64, len(list))
490
+ out = make([]float64, len(list))
217
491
  for i, x := range list {
218
- f, err := toFloat(x, key)
219
- if err != nil {
220
- return nil, err
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
+ }