@onlooker-community/ecosystem 0.34.0 → 0.43.0
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/.claude/skills/writing-tests/SKILL.md +27 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +14 -14
- package/.release-please-manifest.json +7 -7
- package/AGENTS.md +116 -0
- package/CHANGELOG.md +175 -0
- package/CLAUDE.md +5 -0
- package/docs/lesson-promotion-pipeline.md +6 -6
- package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
- package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
- package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
- package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
- package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
- package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
- package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
- package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
- package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
- package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
- package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
- package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
- package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
- package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
- package/package.json +2 -2
- package/plugins/assayer/.claude-plugin/plugin.json +1 -1
- package/plugins/assayer/CHANGELOG.md +7 -0
- package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
- package/plugins/cartographer/CHANGELOG.md +54 -0
- package/plugins/cartographer/README.md +56 -1
- package/plugins/cartographer/config.json +7 -1
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
- package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
- package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
- package/plugins/cartographer/scripts/run-audit.sh +136 -53
- package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
- package/plugins/curator/.claude-plugin/plugin.json +1 -1
- package/plugins/curator/CHANGELOG.md +7 -0
- package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
- package/plugins/historian/.claude-plugin/plugin.json +1 -1
- package/plugins/historian/CHANGELOG.md +7 -0
- package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
- package/plugins/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +71 -0
- package/plugins/librarian/config.json +30 -1
- package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
- package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
- package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
- package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
- package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
- package/plugins/librarian/skills/librarian/SKILL.md +98 -1
- package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
- package/plugins/tribunal/CHANGELOG.md +63 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
- package/plugins/tribunal/config.json +1 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
- package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
- package/scripts/lib/prompt-rules.sh +6 -1
- package/test/bats/archivist-inject.bats +1 -1
- package/test/bats/assayer-extract.bats +2 -2
- package/test/bats/bursar-session-start.bats +3 -3
- package/test/bats/cartographer-config.bats +43 -0
- package/test/bats/cartographer-events.bats +267 -0
- package/test/bats/cartographer-filter.bats +169 -0
- package/test/bats/cartographer-lock.bats +3 -3
- package/test/bats/cartographer-omission.bats +202 -0
- package/test/bats/cartographer-resolve.bats +287 -0
- package/test/bats/cartographer-run-audit.bats +325 -0
- package/test/bats/compass-sanitizer.bats +11 -11
- package/test/bats/compass-transcript.bats +2 -2
- package/test/bats/config.bats +15 -15
- package/test/bats/curator-session-start.bats +10 -3
- package/test/bats/emit-payload-default.bats +52 -0
- package/test/bats/governor-ledger.bats +1 -1
- package/test/bats/historian-prompt-submit.bats +1 -1
- package/test/bats/inspector-post-write-hook.bats +4 -4
- package/test/bats/librarian-author-key.bats +477 -0
- package/test/bats/librarian-cli.bats +16 -16
- package/test/bats/librarian-lesson-judge.bats +884 -0
- package/test/bats/librarian-lesson-promote.bats +552 -0
- package/test/bats/librarian-lesson-review.bats +900 -0
- package/test/bats/librarian-lesson-transform.bats +17 -3
- package/test/bats/librarian-session-end.bats +161 -0
- package/test/bats/librarian-session-start.bats +36 -2
- package/test/bats/lineage-config.bats +1 -1
- package/test/bats/lineage-redact.bats +5 -5
- package/test/bats/session-tracker.bats +4 -4
- package/test/bats/tribunal-aggregate.bats +354 -1
- package/test/bats/tribunal-gate.bats +631 -0
- package/test/bats/tribunal-judge-agents.bats +119 -0
- package/test/bats/tribunal-jury.bats +1 -1
- package/test/bats/turn-tracker.bats +1 -1
- package/test/bats/warden-sanitizer.bats +3 -3
- package/test/bats/worktree-tracker.bats +2 -2
- package/test/node/lesson-validate-agreement.test.mjs +35 -6
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bats
|
|
2
2
|
|
|
3
|
+
# `run --separate-stderr` (used below) requires bats >= 1.5.0.
|
|
4
|
+
bats_require_minimum_version 1.5.0
|
|
5
|
+
|
|
3
6
|
setup() {
|
|
4
7
|
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
5
8
|
setup_test_env
|
|
@@ -12,6 +15,10 @@ setup() {
|
|
|
12
15
|
|
|
13
16
|
VERDICTS='[{"judge_id":"a","score":0.8},{"judge_id":"b","score":0.6},{"judge_id":"c","score":0.4}]'
|
|
14
17
|
|
|
18
|
+
# The rubric tribunal actually ships. Every criterion carries a min_pass, which
|
|
19
|
+
# is why a weight-fraction coverage guard alone cannot protect its floors.
|
|
20
|
+
RUBRIC_DEFAULT='{"criteria":[{"name":"correctness","weight":0.4,"min_pass":0.7},{"name":"completeness","weight":0.3,"min_pass":0.7},{"name":"safety","weight":0.2,"min_pass":0.8},{"name":"clarity","weight":0.1,"min_pass":0.5}]}'
|
|
21
|
+
|
|
15
22
|
@test "mean of [0.8, 0.6, 0.4] is 0.6" {
|
|
16
23
|
local v
|
|
17
24
|
v=$(tribunal_aggregate "mean" "$VERDICTS")
|
|
@@ -37,12 +44,80 @@ VERDICTS='[{"judge_id":"a","score":0.8},{"judge_id":"b","score":0.6},{"judge_id"
|
|
|
37
44
|
awk -v v="$v" 'BEGIN { exit !(v > 0.39 && v < 0.41) }'
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
@test "weighted_mean
|
|
47
|
+
@test "weighted_mean falls back to mean with neither rubric nor criterion scores" {
|
|
41
48
|
local v
|
|
42
49
|
v=$(tribunal_aggregate "weighted_mean" "$VERDICTS")
|
|
43
50
|
awk -v v="$v" 'BEGIN { exit !(v > 0.59 && v < 0.61) }'
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
@test "weighted_mean degrades to mean when the panel covered too little of the rubric" {
|
|
54
|
+
# The C1 regression: two judges rating work at 0.45/0.50 overall while
|
|
55
|
+
# scoring one cheap criterion at 0.9 produced an aggregate of 0.9 —
|
|
56
|
+
# renormalized over a fifth of the rubric and passing a gate that the
|
|
57
|
+
# judges' own scores would have blocked.
|
|
58
|
+
#
|
|
59
|
+
# `depth` deliberately carries NO min_pass, so every floored criterion in
|
|
60
|
+
# this rubric is scored. That is what keeps this test pinned to
|
|
61
|
+
# min_criterion_coverage: the unscored-floor guard below cannot fire here,
|
|
62
|
+
# so only the coverage guard can produce the plain mean.
|
|
63
|
+
local out
|
|
64
|
+
out=$(tribunal_aggregate "weighted_mean" '[
|
|
65
|
+
{"judge_id":"a","score":0.45,"passed":true,"criterion_scores":{"clarity":0.9}},
|
|
66
|
+
{"judge_id":"b","score":0.50,"passed":true,"criterion_scores":{"clarity":0.9}}
|
|
67
|
+
]' '{"criteria":[{"name":"clarity","weight":0.2,"min_pass":0.5},{"name":"depth","weight":0.8}]}')
|
|
68
|
+
# Must be the plain mean of .score (0.475), not the renormalized 0.9.
|
|
69
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.474 && a < 0.476) }'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@test "weighted_mean degrades to mean when a floored criterion went unscored" {
|
|
73
|
+
# min_criterion_coverage is a weight fraction, but what a floor protects is
|
|
74
|
+
# that one criterion. In the shipped default rubric correctness (0.4) plus
|
|
75
|
+
# completeness (0.3) clears the 0.6 guard while skipping safety — the
|
|
76
|
+
# highest floor in the rubric — so the very inversion the coverage guard
|
|
77
|
+
# was added to stop reappeared one criterion further along: judges rating
|
|
78
|
+
# the work 0.45 and 0.50 aggregated to 0.95 and passed.
|
|
79
|
+
local out
|
|
80
|
+
out=$(tribunal_aggregate "weighted_mean" '[
|
|
81
|
+
{"judge_id":"a","score":0.45,"passed":true,"criterion_scores":{"correctness":0.95,"completeness":0.95}},
|
|
82
|
+
{"judge_id":"b","score":0.50,"passed":true,"criterion_scores":{"correctness":0.95,"completeness":0.95}}
|
|
83
|
+
]' "$RUBRIC_DEFAULT")
|
|
84
|
+
# 0.7 of the weight is covered, so min_criterion_coverage is satisfied and
|
|
85
|
+
# only the unscored-floor guard can produce the plain mean (0.475) here.
|
|
86
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.474 && a < 0.476) }'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@test "weighted_mean trusts a panel that scored every floored criterion" {
|
|
90
|
+
local out
|
|
91
|
+
out=$(tribunal_aggregate "weighted_mean" '[
|
|
92
|
+
{"judge_id":"a","score":0.1,"passed":true,"criterion_scores":{"correctness":0.8,"completeness":0.8,"safety":0.8,"clarity":0.8}}
|
|
93
|
+
]' "$RUBRIC_DEFAULT")
|
|
94
|
+
# Nothing floored is missing, so the weighted value (0.8) must win over .score.
|
|
95
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.799 && a < 0.801) }'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@test "weighted_mean still renormalizes over an unscored criterion carrying no floor" {
|
|
99
|
+
# Absence is not a zero, and renormalizing over the scored weight stays
|
|
100
|
+
# correct when what went unscored was never a floor. Only a missing *floor*
|
|
101
|
+
# forces the degrade — otherwise the fix for the inversion above would
|
|
102
|
+
# collapse weighted_mean into mean for every partial panel.
|
|
103
|
+
local out
|
|
104
|
+
out=$(tribunal_aggregate "weighted_mean" '[
|
|
105
|
+
{"judge_id":"a","score":0.1,"passed":true,"criterion_scores":{"grounding":0.8}}
|
|
106
|
+
]' '{"criteria":[{"name":"grounding","weight":0.7,"min_pass":0.6},{"name":"polish","weight":0.3}]}')
|
|
107
|
+
# grounding is the only floor and it is scored; 0.7 coverage clears 0.6, so
|
|
108
|
+
# this renormalizes to 0.8 rather than degrading to .score.
|
|
109
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.799 && a < 0.801) }'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@test "an out-of-range criterion score is ignored, not trusted" {
|
|
113
|
+
local out
|
|
114
|
+
out=$(tribunal_aggregate "weighted_mean" '[
|
|
115
|
+
{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":100}}
|
|
116
|
+
]' '{"criteria":[{"name":"correctness","weight":1.0,"min_pass":0.7}]}')
|
|
117
|
+
# 100 is filtered, nothing is covered, so this degrades to the mean of .score.
|
|
118
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.499 && a < 0.501) }'
|
|
119
|
+
}
|
|
120
|
+
|
|
46
121
|
@test "unknown method falls back to mean with warning on stderr" {
|
|
47
122
|
run bash -c '
|
|
48
123
|
source "${REPO_ROOT}/plugins/tribunal/scripts/lib/tribunal-aggregate.sh"
|
|
@@ -75,3 +150,281 @@ VERDICTS='[{"judge_id":"a","score":0.8},{"judge_id":"b","score":0.6},{"judge_id"
|
|
|
75
150
|
d=$(tribunal_disagreement '[{"score":0.7}]')
|
|
76
151
|
[ "$d" = "0" ]
|
|
77
152
|
}
|
|
153
|
+
|
|
154
|
+
# Two criteria with deliberately unequal weights, so weighted_mean and mean
|
|
155
|
+
# cannot coincide. Judge A is strong on the heavy criterion, weak on the light
|
|
156
|
+
# one; judge B is the reverse.
|
|
157
|
+
# `clarity` carries NO min_pass on purpose, and that is load-bearing: the tests
|
|
158
|
+
# below leave it unscored to exercise the renormalization arithmetic, and an
|
|
159
|
+
# unscored *floor* degrades weighted_mean to the plain mean before any of that
|
|
160
|
+
# arithmetic runs. Re-adding a min_pass here does not fail anything loudly — it
|
|
161
|
+
# quietly converts five weight tests into five assertions about the mean of
|
|
162
|
+
# .score. `correctness` stays floored and stays scored, so the guard is still
|
|
163
|
+
# live in this fixture; it is just never tripped.
|
|
164
|
+
RUBRIC_UNEQUAL='{"criteria":[{"name":"correctness","weight":0.9,"min_pass":0.7},{"name":"clarity","weight":0.1}]}'
|
|
165
|
+
SCORED='[
|
|
166
|
+
{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":1.0,"clarity":0.0}},
|
|
167
|
+
{"judge_id":"b","score":0.5,"criterion_scores":{"correctness":1.0,"clarity":0.0}}
|
|
168
|
+
]'
|
|
169
|
+
|
|
170
|
+
@test "weighted_mean differs from mean when weights are unequal" {
|
|
171
|
+
# mean of .score is 0.5 for both judges. The weighted mean is
|
|
172
|
+
# 0.9*1.0 + 0.1*0.0 = 0.9. If these come out equal, weights are still inert.
|
|
173
|
+
local w m
|
|
174
|
+
w=$(tribunal_aggregate "weighted_mean" "$SCORED" "$RUBRIC_UNEQUAL")
|
|
175
|
+
m=$(tribunal_aggregate "mean" "$SCORED" "$RUBRIC_UNEQUAL")
|
|
176
|
+
awk -v a="$w" -v b="$m" 'BEGIN { exit !(a != b) }' || return 1
|
|
177
|
+
awk -v a="$w" 'BEGIN { exit !(a > 0.89 && a < 0.91) }'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@test "weighted_mean averages judges within a criterion before weighting" {
|
|
181
|
+
local out
|
|
182
|
+
out=$(tribunal_aggregate "weighted_mean" '[
|
|
183
|
+
{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":1.0,"clarity":1.0}},
|
|
184
|
+
{"judge_id":"b","score":0.5,"criterion_scores":{"correctness":0.0,"clarity":1.0}}
|
|
185
|
+
]' "$RUBRIC_UNEQUAL")
|
|
186
|
+
# correctness mean 0.5, clarity mean 1.0 → 0.9*0.5 + 0.1*1.0 = 0.55
|
|
187
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.549 && a < 0.551) }'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@test "weighted_mean degrades to mean when no verdict carries criterion_scores" {
|
|
191
|
+
# Every verdict emitted before Task 1 shipped looks like this.
|
|
192
|
+
local out
|
|
193
|
+
out=$(tribunal_aggregate "weighted_mean" \
|
|
194
|
+
'[{"judge_id":"a","score":0.8},{"judge_id":"b","score":0.6}]' "$RUBRIC_UNEQUAL")
|
|
195
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.699 && a < 0.701) }'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@test "an absent criterion is skipped, not counted as zero" {
|
|
199
|
+
# clarity is absent everywhere. If absence read as 0 the answer would be
|
|
200
|
+
# 0.9*1.0 + 0.1*0.0 = 0.9. Skipping it renormalizes to 0.9/0.9 = 1.0.
|
|
201
|
+
local out
|
|
202
|
+
out=$(tribunal_aggregate "weighted_mean" \
|
|
203
|
+
'[{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":1.0}}]' \
|
|
204
|
+
"$RUBRIC_UNEQUAL")
|
|
205
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.999 && a < 1.001) }'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@test "a criterion scored at zero is honored, not treated as absent" {
|
|
209
|
+
# The mirror of the previous test, and the one that catches a `// 0` fix
|
|
210
|
+
# that "passes" the absence test by accident.
|
|
211
|
+
local out
|
|
212
|
+
out=$(tribunal_aggregate "weighted_mean" \
|
|
213
|
+
'[{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":0.0}}]' \
|
|
214
|
+
"$RUBRIC_UNEQUAL")
|
|
215
|
+
awk -v a="$out" 'BEGIN { exit !(a >= 0 && a < 0.001) }'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@test "weights that do not sum to 1.0 are normalized" {
|
|
219
|
+
# tribunal_rubric_validate rejects such a rubric, but librarian's loader
|
|
220
|
+
# validates nothing and hands its rubric straight through. Normalizing here
|
|
221
|
+
# means the two paths cannot disagree.
|
|
222
|
+
local out
|
|
223
|
+
out=$(tribunal_aggregate "weighted_mean" \
|
|
224
|
+
'[{"judge_id":"a","score":0.1,"criterion_scores":{"correctness":1.0,"clarity":0.0}}]' \
|
|
225
|
+
'{"criteria":[{"name":"correctness","weight":1.8,"min_pass":0.7},{"name":"clarity","weight":0.2,"min_pass":0.5}]}')
|
|
226
|
+
# 1.8*1.0 + 0.2*0.0 = 1.8, over a weight sum of 2.0 → 0.9
|
|
227
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.899 && a < 0.901) }'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@test "a hyphenated criterion name scores correctly" {
|
|
231
|
+
# A dotted jq path would be a COMPILE error here: exit 3, empty stdout,
|
|
232
|
+
# which awk reads as 0.
|
|
233
|
+
local out
|
|
234
|
+
out=$(tribunal_aggregate "weighted_mean" \
|
|
235
|
+
'[{"judge_id":"a","score":0.2,"criterion_scores":{"path-traversal":1.0}}]' \
|
|
236
|
+
'{"criteria":[{"name":"path-traversal","weight":1.0,"min_pass":0.5}]}')
|
|
237
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.999 && a < 1.001) }'
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@test "weighted_mean falls back to mean when the rubric is absent" {
|
|
241
|
+
local out
|
|
242
|
+
out=$(tribunal_aggregate "weighted_mean" "$SCORED")
|
|
243
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.499 && a < 0.501) }'
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@test "a non-number criterion score is ignored rather than poisoning the mean" {
|
|
247
|
+
local out
|
|
248
|
+
out=$(tribunal_aggregate "weighted_mean" \
|
|
249
|
+
'[{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":1.0,"clarity":"n/a"}}]' \
|
|
250
|
+
"$RUBRIC_UNEQUAL")
|
|
251
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.999 && a < 1.001) }'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# --- Verdicts with no usable .score (ecosystem-up8) -------------------------
|
|
255
|
+
#
|
|
256
|
+
# A judge that returns parseable JSON omitting `score` is malformed, not
|
|
257
|
+
# unanimous. jq's `add` treats the missing key as null and `null + n == n`, so
|
|
258
|
+
# such a verdict used to contribute 0 to the numerator and 1 to the
|
|
259
|
+
# denominator — wrong rather than loud. It is dropped from the panel instead.
|
|
260
|
+
|
|
261
|
+
SCORELESS_RUBRIC='{"criteria":[{"name":"correctness","weight":1.0,"min_pass":0.7}]}'
|
|
262
|
+
|
|
263
|
+
@test "mean drops a scoreless verdict instead of counting it as zero" {
|
|
264
|
+
local out
|
|
265
|
+
out=$(tribunal_aggregate "mean" '[{"judge_id":"a","score":0.9},{"judge_id":"b"}]')
|
|
266
|
+
# Counting the missing score as 0 gives 0.45. Dropping it gives 0.9.
|
|
267
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.899 && a < 0.901) }'
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@test "weighted_mean does not count a scoreless judge's criterion scores" {
|
|
271
|
+
# The failure mode ecosystem-pht introduced: once criterion_scores cover the
|
|
272
|
+
# rubric, weighted_mean never reads .score, so a judge that returned no
|
|
273
|
+
# verdict at all was silently counted as a fully participating one — and it
|
|
274
|
+
# fails toward NOT blocking. Dropping happens before any method runs, so the
|
|
275
|
+
# malformed judge takes its criterion scores with it.
|
|
276
|
+
local out
|
|
277
|
+
out=$(tribunal_aggregate "weighted_mean" '[
|
|
278
|
+
{"judge_id":"a","score":0.9,"criterion_scores":{"correctness":0.9}},
|
|
279
|
+
{"judge_id":"b","criterion_scores":{"correctness":0.1}}
|
|
280
|
+
]' "$SCORELESS_RUBRIC")
|
|
281
|
+
# Averaging both criterion scores gives 0.5; dropping judge b gives 0.9.
|
|
282
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.899 && a < 0.901) }'
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@test "median drops a scoreless verdict" {
|
|
286
|
+
local out
|
|
287
|
+
out=$(tribunal_aggregate "median" '[{"score":0.4},{"score":0.8},{"judge_id":"c"}]')
|
|
288
|
+
# Two usable scores left, so the median is their mean: 0.6.
|
|
289
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.599 && a < 0.601) }'
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@test "min never returns null when a verdict has no score" {
|
|
293
|
+
# jq's `min` over [null, 0.9] is null, which the gate then feeds to awk as
|
|
294
|
+
# the string "null" and coerces to 0 — a non-numeric aggregate that reads
|
|
295
|
+
# as the worst possible score.
|
|
296
|
+
local out
|
|
297
|
+
out=$(tribunal_aggregate "min" '[{"judge_id":"a","score":0.9},{"judge_id":"b"}]')
|
|
298
|
+
[ "$out" != "null" ] || return 1
|
|
299
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.899 && a < 0.901) }'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@test "a panel where no verdict carries a score aggregates to 0, not a crash" {
|
|
303
|
+
# Fails closed, matching the empty-panel convention directly above it: 0 is
|
|
304
|
+
# below every threshold, so the gate blocks.
|
|
305
|
+
local none='[{"judge_id":"a"},{"judge_id":"b"}]'
|
|
306
|
+
local m
|
|
307
|
+
for m in mean weighted_mean median min; do
|
|
308
|
+
# --separate-stderr: the drop warning would otherwise land in $output.
|
|
309
|
+
run --separate-stderr tribunal_aggregate "$m" "$none" "$SCORELESS_RUBRIC"
|
|
310
|
+
[ "$status" -eq 0 ] || return 1
|
|
311
|
+
[ "$output" = "0" ] || return 1
|
|
312
|
+
done
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
@test "a scoreless verdict with criterion_scores still aggregates to 0 when it is the whole panel" {
|
|
316
|
+
# weighted_mean must not resurrect a panel that has no usable verdict just
|
|
317
|
+
# because the criterion map looks complete.
|
|
318
|
+
run --separate-stderr tribunal_aggregate "weighted_mean" \
|
|
319
|
+
'[{"judge_id":"a","criterion_scores":{"correctness":0.9}}]' "$SCORELESS_RUBRIC"
|
|
320
|
+
[ "$status" -eq 0 ] || return 1
|
|
321
|
+
[ "$output" = "0" ]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
@test "dropping a scoreless verdict is reported on stderr with its judge_id" {
|
|
325
|
+
run --separate-stderr tribunal_aggregate "mean" \
|
|
326
|
+
'[{"judge_id":"a","score":0.9},{"judge_id":"bad-judge"}]'
|
|
327
|
+
[ "$status" -eq 0 ] || return 1
|
|
328
|
+
local re='bad-judge'
|
|
329
|
+
[[ "$stderr" =~ $re ]]
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
@test "no dropped-verdict warning when every verdict carries a score" {
|
|
333
|
+
run --separate-stderr tribunal_aggregate "mean" '[{"score":0.8},{"score":0.6}]'
|
|
334
|
+
[ -z "$stderr" ]
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
@test "disagreement ignores a verdict with no score" {
|
|
338
|
+
local d
|
|
339
|
+
d=$(tribunal_disagreement '[{"score":0.2},{"score":0.8},{"judge_id":"c"}]')
|
|
340
|
+
awk -v d="$d" 'BEGIN { exit !(d > 0.599 && d < 0.601) }'
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
@test "disagreement is 0 when fewer than two verdicts carry a score" {
|
|
344
|
+
# It crashed on a single scoreless verdict, and a crash prints nothing to
|
|
345
|
+
# stdout — so the caller read an empty dissent score, awk coerced it to 0,
|
|
346
|
+
# and the gate's dissent short-circuit never fired. Fails toward not blocking.
|
|
347
|
+
local d
|
|
348
|
+
d=$(tribunal_disagreement '[{"score":0.9},{"judge_id":"b"}]')
|
|
349
|
+
[ "$d" = "0" ] || return 1
|
|
350
|
+
d=$(tribunal_disagreement '[{"judge_id":"a"},{"judge_id":"b"}]')
|
|
351
|
+
[ "$d" = "0" ]
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
# --- out-of-range verdict scores (ecosystem-7cl) --------------------------
|
|
355
|
+
#
|
|
356
|
+
# A .score outside [0,1] is malformed for the same reason a missing one is: it
|
|
357
|
+
# is not a verdict this scale can read. The realistic trigger is a judge
|
|
358
|
+
# emitting a percentage instead of a fraction — 95 rather than 0.95 — which is
|
|
359
|
+
# an ordinary LLM formatting slip, not an adversarial input. Nothing at the
|
|
360
|
+
# seam rejects it: TribunalVerdictPayload declares score in [0,1] but the
|
|
361
|
+
# runtime emitter fails open (ADR-005), so the aggregate has to re-check.
|
|
362
|
+
|
|
363
|
+
@test "an out-of-range verdict score is dropped, not averaged in" {
|
|
364
|
+
local out
|
|
365
|
+
out=$(tribunal_aggregate "mean" \
|
|
366
|
+
'[{"judge_id":"a","score":100},{"judge_id":"b","score":0.6}]' 2>/dev/null)
|
|
367
|
+
# Trusting 100 gives 50.3. Dropping it leaves b's 0.6.
|
|
368
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.599 && a < 0.601) }'
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@test "a negative verdict score is dropped" {
|
|
372
|
+
local out
|
|
373
|
+
out=$(tribunal_aggregate "mean" \
|
|
374
|
+
'[{"judge_id":"a","score":-5},{"judge_id":"b","score":0.6}]' 2>/dev/null)
|
|
375
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.599 && a < 0.601) }'
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
@test "a whole panel of out-of-range scores aggregates to 0, not a passing score" {
|
|
379
|
+
# THE fail-open case. The mixed panel above happens to block, but only
|
|
380
|
+
# incidentally — dissent 99.4 trips the gate's dissent short-circuit. Once
|
|
381
|
+
# the entire panel is out of range there is no disagreement left to catch
|
|
382
|
+
# it, so every threshold cleared and the gate passed on garbage.
|
|
383
|
+
local all='[{"judge_id":"a","score":100},{"judge_id":"b","score":95}]'
|
|
384
|
+
local m
|
|
385
|
+
for m in mean weighted_mean median min; do
|
|
386
|
+
# --separate-stderr: the drop warning would otherwise land in $output.
|
|
387
|
+
run --separate-stderr tribunal_aggregate "$m" "$all" "$SCORELESS_RUBRIC"
|
|
388
|
+
[ "$status" -eq 0 ] || return 1
|
|
389
|
+
[ "$output" = "0" ] || return 1
|
|
390
|
+
done
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
@test "the range bounds themselves stay usable" {
|
|
394
|
+
# [0,1] is inclusive: a judge that scored 0 rejected the work, and one that
|
|
395
|
+
# scored 1 is not malformed. Dropping either would be its own fail-open —
|
|
396
|
+
# a 0 is exactly the verdict that must survive to drag the mean down.
|
|
397
|
+
local out
|
|
398
|
+
out=$(tribunal_aggregate "mean" \
|
|
399
|
+
'[{"judge_id":"a","score":0},{"judge_id":"b","score":1}]' 2>/dev/null)
|
|
400
|
+
awk -v a="$out" 'BEGIN { exit !(a > 0.499 && a < 0.501) }'
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@test "weighted_mean does not count an out-of-range judge's criterion scores" {
|
|
404
|
+
# Same sharpest-form hazard as the scoreless case: once criterion_scores
|
|
405
|
+
# cover the rubric weighted_mean never reads .score, so a judge whose
|
|
406
|
+
# verdict was refused would otherwise still steer the aggregate.
|
|
407
|
+
local out
|
|
408
|
+
out=$(tribunal_aggregate "weighted_mean" '[
|
|
409
|
+
{"judge_id":"a","score":100,"criterion_scores":{"correctness":0.9}}
|
|
410
|
+
]' "$SCORELESS_RUBRIC" 2>/dev/null)
|
|
411
|
+
[ "$out" = "0" ]
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
@test "dropping an out-of-range verdict is reported on stderr with its judge_id" {
|
|
415
|
+
run --separate-stderr tribunal_aggregate "mean" \
|
|
416
|
+
'[{"judge_id":"a","score":0.9},{"judge_id":"percent-judge","score":95}]'
|
|
417
|
+
[ "$status" -eq 0 ] || return 1
|
|
418
|
+
local re='percent-judge'
|
|
419
|
+
[[ "$stderr" =~ $re ]]
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
@test "disagreement ignores an out-of-range verdict" {
|
|
423
|
+
# Verified pre-fix: two judges at 100 and 0.1 reported dissent 99.9, which
|
|
424
|
+
# blocked for the wrong reason. Dissent must describe the panel that was
|
|
425
|
+
# actually scored.
|
|
426
|
+
local d
|
|
427
|
+
d=$(tribunal_disagreement \
|
|
428
|
+
'[{"judge_id":"a","score":100},{"judge_id":"b","score":0.2},{"judge_id":"c","score":0.8}]' 2>/dev/null)
|
|
429
|
+
awk -v d="$d" 'BEGIN { exit !(d > 0.599 && d < 0.601) }'
|
|
430
|
+
}
|