@onlooker-community/ecosystem 0.34.1 → 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-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +14 -14
- package/.release-please-manifest.json +4 -4
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +168 -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/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/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +64 -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 +344 -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 +56 -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 -11
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +193 -15
- package/plugins/tribunal/skills/tribunal/SKILL.md +5 -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-omission.bats +202 -0
- package/test/bats/cartographer-resolve.bats +287 -0
- package/test/bats/cartographer-run-audit.bats +325 -0
- package/test/bats/librarian-author-key.bats +477 -0
- 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 +34 -0
- 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/node/lesson-validate-agreement.test.mjs +35 -6
|
@@ -220,13 +220,27 @@ _storage_setup() {
|
|
|
220
220
|
[ "$status" -eq 0 ]
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
@test "seen
|
|
223
|
+
@test "seen ignores the approved pool, which cannot identify an artifact" {
|
|
224
|
+
# This replaces a test that wrote {artifact_id: ...} into approved/ and
|
|
225
|
+
# asserted seen found it. That shape is unreachable — a pool entry is
|
|
226
|
+
# ZLesson, a strictObject with no artifact_id — so the old test fed the scan
|
|
227
|
+
# a file promote could never produce and reported coverage for a branch that
|
|
228
|
+
# never matched anything real. It was the reason the dead scan survived.
|
|
229
|
+
#
|
|
230
|
+
# ecosystem-d0m settled this: proposals/ is the sole dedup source. The
|
|
231
|
+
# end-to-end tripwire lives in librarian-lesson-promote.bats, against a real
|
|
232
|
+
# promoted entry; this one pins that a pool-shaped file is simply not
|
|
233
|
+
# consulted.
|
|
224
234
|
_storage_setup
|
|
225
235
|
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
226
|
-
jq -n '{
|
|
236
|
+
jq -n '{id: "01KZ45MKGQ7QZWMABQ4H12SHSV", schema_version: 2, claim: "c",
|
|
237
|
+
rationale: "r", evidence: [], applies_to: [], visibility: "public",
|
|
238
|
+
consensus: {judges: 2, agreed: 2, decided_at: "2026-01-01T00:00:00Z"},
|
|
239
|
+
status: "active", superseded_by: null, source: "s",
|
|
240
|
+
author_key: "k", promoted_at: "2026-01-01T00:00:00Z"}' \
|
|
227
241
|
> "${LESSONS_DIR}/approved/01KZ45MKGQ7QZWMABQ4H12SHSV.json"
|
|
228
242
|
run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
229
|
-
[ "$status" -eq
|
|
243
|
+
[ "$status" -eq 1 ]
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
@test "seen still finds a declined artifact when declined.jsonl has a truncated trailing line" {
|
|
@@ -42,10 +42,17 @@ setup() {
|
|
|
42
42
|
# based on the artifact's summary contents.
|
|
43
43
|
STUB_BIN="${BATS_TEST_TMPDIR}/bin"
|
|
44
44
|
mkdir -p "$STUB_BIN"
|
|
45
|
+
# Every invocation is recorded so budget tests can assert on how many model
|
|
46
|
+
# calls a scan actually made — the only evidence that distinguishes an
|
|
47
|
+
# analyzer skipped by the budget from one that ran and produced nothing.
|
|
48
|
+
export CLAUDE_CALL_LOG="${BATS_TEST_TMPDIR}/claude-calls"
|
|
49
|
+
: > "$CLAUDE_CALL_LOG"
|
|
50
|
+
|
|
45
51
|
cat > "${STUB_BIN}/claude" <<'STUB'
|
|
46
52
|
#!/usr/bin/env bash
|
|
47
53
|
# Read the prompt from stdin and decide which classifier response to emit.
|
|
48
54
|
prompt=$(cat)
|
|
55
|
+
[[ -n "${CLAUDE_CALL_LOG:-}" ]] && echo call >> "$CLAUDE_CALL_LOG"
|
|
49
56
|
if [[ "$prompt" == *"prefer-functional-stub"* ]]; then
|
|
50
57
|
printf '%s' '{"type":"feedback","title":"Prefer functional patterns","body":"User prefers functional patterns over class-based.\n\n**Why:** Stated explicitly during code review.\n**How to apply:** Default to plain functions and composition.","confidence":0.84}'
|
|
51
58
|
elif [[ "$prompt" == *"compliance-stub"* ]]; then
|
|
@@ -176,3 +183,157 @@ _hook_input() {
|
|
|
176
183
|
grep '"event_type":"librarian.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
|
|
177
184
|
| jq -e '.payload.outcome == "empty" and .payload.candidates_proposed == 0 and .payload.candidates_dropped >= 1' >/dev/null
|
|
178
185
|
}
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Stage 5 aggregate budget (ecosystem-qwi).
|
|
189
|
+
#
|
|
190
|
+
# Each lesson transform carries a 20s ceiling of its own, but nothing bounded
|
|
191
|
+
# KEPT_COUNT of them end to end, so a backlog could hold SessionEnd open for
|
|
192
|
+
# minutes. These assert on model call counts, because that is what separates
|
|
193
|
+
# "the budget skipped the loop" from "the loop ran and found nothing".
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
_llm_calls() {
|
|
197
|
+
wc -l < "$CLAUDE_CALL_LOG" | tr -d ' '
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_settings() {
|
|
201
|
+
cat > "${PROJECT_REPO}/.claude/settings.json"
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Carries the marker phrase the durability filter wants AND a version token,
|
|
205
|
+
# without which the lesson pregate rejects it before any model call and the
|
|
206
|
+
# budget tests could not tell the two states apart.
|
|
207
|
+
_seed_lessonable() {
|
|
208
|
+
_seed_artifact "decisions" "01LESSONBUDGETARTIFACT000" \
|
|
209
|
+
"User prefers functional patterns prefer-functional-stub" \
|
|
210
|
+
"User explicitly said: always prefer plain functions over classes. Pinned vite 5.4.21 to avoid the regression."
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@test "stage 5 runs a lesson transform when the budget allows" {
|
|
214
|
+
_seed_lessonable
|
|
215
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
216
|
+
[ "$status" -eq 0 ] || return 1
|
|
217
|
+
# One classifier call plus one lesson call.
|
|
218
|
+
[ "$(_llm_calls)" -ge 2 ]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# A zero budget trips on the first iteration, so stage 5 spends nothing. The
|
|
222
|
+
# classifier call still happens, which is what makes this a measurement of the
|
|
223
|
+
# stage 5 guard rather than of the scan being disabled.
|
|
224
|
+
@test "a zero budget skips stage 5 entirely" {
|
|
225
|
+
echo '{"librarian":{"lesson_transform":{"total_budget_ms":0}}}' | _settings
|
|
226
|
+
_seed_lessonable
|
|
227
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
228
|
+
[ "$status" -eq 0 ] || return 1
|
|
229
|
+
[ "$(_llm_calls)" = "1" ]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# The guard must fail toward skipping stage 5, never toward skipping the
|
|
233
|
+
# watermark advance — an unadvanced watermark re-scans the same backlog every
|
|
234
|
+
# session, turning a one-time cost into a permanent one.
|
|
235
|
+
@test "the watermark still advances when the budget trips" {
|
|
236
|
+
echo '{"librarian":{"lesson_transform":{"total_budget_ms":0}}}' | _settings
|
|
237
|
+
_seed_lessonable
|
|
238
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
239
|
+
[ "$status" -eq 0 ] || return 1
|
|
240
|
+
[ -f "${LIBRARIAN_DIR}/last_scan.json" ] || return 1
|
|
241
|
+
jq -e '.scanned_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T")' "${LIBRARIAN_DIR}/last_scan.json" >/dev/null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@test "scan.complete is still emitted when the budget trips" {
|
|
245
|
+
echo '{"librarian":{"lesson_transform":{"total_budget_ms":0}}}' | _settings
|
|
246
|
+
_seed_lessonable
|
|
247
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
248
|
+
[ "$status" -eq 0 ] || return 1
|
|
249
|
+
grep '"event_type":"librarian.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
|
|
250
|
+
| jq -e '.payload.outcome == "ok" or .payload.outcome == "empty"' >/dev/null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# Classification is upstream of stage 5, so a tripped lesson budget must not
|
|
254
|
+
# cost the user their proposals.
|
|
255
|
+
@test "a tripped lesson budget does not discard classifier proposals" {
|
|
256
|
+
echo '{"librarian":{"lesson_transform":{"total_budget_ms":0}}}' | _settings
|
|
257
|
+
_seed_lessonable
|
|
258
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
259
|
+
[ "$status" -eq 0 ] || return 1
|
|
260
|
+
grep '"event_type":"librarian.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
|
|
261
|
+
| jq -e '.payload.candidates_proposed >= 1' >/dev/null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
@test "the hook still exits 0 when the budget trips" {
|
|
265
|
+
echo '{"librarian":{"lesson_transform":{"total_budget_ms":0}}}' | _settings
|
|
266
|
+
_seed_lessonable
|
|
267
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
268
|
+
[ "$status" -eq 0 ]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Budget telemetry reaches the bus (ecosystem-1p1).
|
|
273
|
+
#
|
|
274
|
+
# librarian_emit swallows a validation failure and returns 0, appending to the
|
|
275
|
+
# log only when the emitter accepted the payload. So presence on the bus is
|
|
276
|
+
# proof the event validated — which is exactly the property that was missing:
|
|
277
|
+
# the classifier gate's outcome:"budget_exceeded" was rejected by the schema
|
|
278
|
+
# and silently dropped for as long as the gate has existed.
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
_scan_complete() {
|
|
282
|
+
grep '"event_type":"librarian.scan.complete"' "$ONLOOKER_EVENTS_LOG" | tail -1
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@test "a truncated stage 5 reports how many artifacts it skipped" {
|
|
286
|
+
echo '{"librarian":{"lesson_transform":{"total_budget_ms":0}}}' | _settings
|
|
287
|
+
_seed_lessonable
|
|
288
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
289
|
+
[ "$status" -eq 0 ] || return 1
|
|
290
|
+
_scan_complete | jq -e '.payload.lessons_skipped >= 1' >/dev/null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
# lessons_skipped accompanies a healthy outcome rather than replacing it: the
|
|
294
|
+
# scan completed, only the one stage stopped early.
|
|
295
|
+
@test "the skip count rides alongside a normal outcome" {
|
|
296
|
+
echo '{"librarian":{"lesson_transform":{"total_budget_ms":0}}}' | _settings
|
|
297
|
+
_seed_lessonable
|
|
298
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
299
|
+
[ "$status" -eq 0 ] || return 1
|
|
300
|
+
_scan_complete | jq -e '.payload.outcome == "ok" or .payload.outcome == "empty"' >/dev/null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@test "an untruncated scan reports zero skipped" {
|
|
304
|
+
_seed_lessonable
|
|
305
|
+
run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
|
|
306
|
+
[ "$status" -eq 0 ] || return 1
|
|
307
|
+
_scan_complete | jq -e '.payload.lessons_skipped == 0' >/dev/null
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
# The regression guard for the original bug. This payload is what the classifier
|
|
311
|
+
# gate builds verbatim; before schema 2.14.0 it failed validation and never
|
|
312
|
+
# appeared on the bus at all.
|
|
313
|
+
@test "the classifier gate's budget_exceeded payload reaches the bus" {
|
|
314
|
+
# shellcheck disable=SC1091
|
|
315
|
+
export _LIBRARIAN_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
|
|
316
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-emit.sh"
|
|
317
|
+
mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
|
|
318
|
+
|
|
319
|
+
librarian_emit "librarian.scan.complete" "sess-budget" "$(jq -cn \
|
|
320
|
+
'{ outcome: "budget_exceeded", duration_ms: 1200, candidates_proposed: 0,
|
|
321
|
+
candidates_dropped: 3, artifact_count_in_window: 5 }')"
|
|
322
|
+
|
|
323
|
+
_scan_complete | jq -e '.payload.outcome == "budget_exceeded"' >/dev/null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
@test "an outcome outside the enum is still refused" {
|
|
327
|
+
export _LIBRARIAN_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
|
|
328
|
+
# shellcheck disable=SC1091
|
|
329
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-emit.sh"
|
|
330
|
+
mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
|
|
331
|
+
|
|
332
|
+
librarian_emit "librarian.scan.complete" "sess-bad" \
|
|
333
|
+
'{"outcome":"gave_up","duration_ms":5}'
|
|
334
|
+
|
|
335
|
+
# A refused payload never reaches the log, so the file may not exist at all.
|
|
336
|
+
# grep on a missing file errors rather than printing 0, which is why this
|
|
337
|
+
# asserts absence directly instead of comparing a count.
|
|
338
|
+
! grep -q '"outcome":"gave_up"' "$ONLOOKER_EVENTS_LOG" 2>/dev/null
|
|
339
|
+
}
|
|
@@ -124,3 +124,37 @@ _seed_proposal() {
|
|
|
124
124
|
ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
|
|
125
125
|
[[ "$ctx" == *"Librarian has 3+ pending memory promotion proposals"* ]]
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
@test "session-start surfaces a pending lesson count as its own line" {
|
|
129
|
+
# Reuse this file's existing project/hook setup, then seed one pending
|
|
130
|
+
# lesson through the storage lib.
|
|
131
|
+
# shellcheck disable=SC1091
|
|
132
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
|
|
133
|
+
# shellcheck disable=SC1091
|
|
134
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
|
|
135
|
+
# shellcheck disable=SC1091
|
|
136
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
137
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
138
|
+
librarian_lesson_write_proposal "$PROJECT_KEY" \
|
|
139
|
+
"$(jq -cn '{claim: "c", rationale: "r"}')" "01KZ45MKAM734ZS7JK24D2DK0R" >/dev/null
|
|
140
|
+
|
|
141
|
+
run bash -c "printf '%s' '$(_input)' | '$HOOK'"
|
|
142
|
+
[ "$status" -eq 0 ]
|
|
143
|
+
[[ "$output" == *"lesson"* ]] || return 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@test "session-start surfaces lessons even with zero memory proposals" {
|
|
147
|
+
# shellcheck disable=SC1091
|
|
148
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
|
|
149
|
+
# shellcheck disable=SC1091
|
|
150
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
|
|
151
|
+
# shellcheck disable=SC1091
|
|
152
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
153
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
154
|
+
librarian_lesson_write_proposal "$PROJECT_KEY" \
|
|
155
|
+
"$(jq -cn '{claim: "c", rationale: "r"}')" "01KZ45MKAM734ZS7JK24D2DK0R" >/dev/null
|
|
156
|
+
|
|
157
|
+
run bash -c "printf '%s' '$(_input)' | '$HOOK'"
|
|
158
|
+
[ "$status" -eq 0 ]
|
|
159
|
+
[[ "$output" == *"lesson"* ]] || return 1
|
|
160
|
+
}
|
|
@@ -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
|
+
}
|