@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
|
|
@@ -93,3 +96,631 @@ NO_META='{}'
|
|
|
93
96
|
[ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
|
|
94
97
|
[ "$(printf '%s' "$out" | jq -r '.reason')" = "meta_override" ]
|
|
95
98
|
}
|
|
99
|
+
|
|
100
|
+
RUBRIC_FLOOR='{"criteria":[{"name":"correctness","weight":0.5,"min_pass":0.7},{"name":"safety","weight":0.5,"min_pass":0.8}]}'
|
|
101
|
+
|
|
102
|
+
@test "a criterion below its floor blocks even when score and jury both pass" {
|
|
103
|
+
# This is the property that does not exist today: aggregate 0.82 clears the
|
|
104
|
+
# 0.75 threshold, both judges passed, and the gate blocks anyway.
|
|
105
|
+
local out
|
|
106
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
107
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
108
|
+
{"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
109
|
+
]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
110
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
|
|
111
|
+
printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
|
|
112
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@test "meta_override accept cannot lift a criterion below its floor" {
|
|
116
|
+
# `accept` returned before the floor check ever ran, so one gate policy
|
|
117
|
+
# defeated every floor in the rubric. A floor is not the jury's opinion for
|
|
118
|
+
# the Meta-Judge to overrule — it is the rubric's hard constraint, and the
|
|
119
|
+
# function's own contract is that it blocks regardless of the policy.
|
|
120
|
+
local meta='{"override_recommendation":"accept","bias_detected":false}'
|
|
121
|
+
local out
|
|
122
|
+
out=$(tribunal_gate_decide "meta_override" '[
|
|
123
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
124
|
+
{"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
125
|
+
]' "0.82" "0.75" "$meta" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
126
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
|
|
127
|
+
printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
|
|
128
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@test "meta_override accept still overrules a failing jury when the floors hold" {
|
|
132
|
+
# The veto above must stay narrow: `accept` beating a jury that voted no is
|
|
133
|
+
# the whole purpose of the policy, and only a *violated* floor may stop it.
|
|
134
|
+
local meta='{"override_recommendation":"accept","bias_detected":false}'
|
|
135
|
+
local out
|
|
136
|
+
out=$(tribunal_gate_decide "meta_override" '[
|
|
137
|
+
{"judge_id":"a","score":0.30,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.95}},
|
|
138
|
+
{"judge_id":"b","score":0.40,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.95}}
|
|
139
|
+
]' "0.30" "0.75" "$meta" "0.10" "0.25" "$RUBRIC_FLOOR")
|
|
140
|
+
printf '%s' "$out" | jq -e '.passed == true' >/dev/null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@test "a criterion at exactly its floor passes" {
|
|
144
|
+
local out
|
|
145
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
146
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.7,"safety":0.8}},
|
|
147
|
+
{"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.7,"safety":0.8}}
|
|
148
|
+
]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
149
|
+
printf '%s' "$out" | jq -e '.passed == true' >/dev/null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@test "a verdict with no criterion_scores key at all never blocks" {
|
|
153
|
+
# Every verdict emitted before Task 1 shipped. Treating absence as violation
|
|
154
|
+
# would make every pre-upgrade judge fail every rubric carrying a floor.
|
|
155
|
+
# Caught by the outer `criterion_scores | type == "object"` guard — this
|
|
156
|
+
# case never reaches has(), so it does not pin the has() guard itself.
|
|
157
|
+
local out
|
|
158
|
+
out=$(tribunal_gate_decide "majority" "$ALL_PASSED" "0.82" "0.75" \
|
|
159
|
+
"$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
160
|
+
printf '%s' "$out" | jq -e '.passed == true' >/dev/null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@test "scores present but a floored criterion omitted does not block" {
|
|
164
|
+
# THE test that pins the has() guard. These verdicts DO carry
|
|
165
|
+
# criterion_scores, so they survive the outer type guard and reach the
|
|
166
|
+
# per-criterion lookup — but `safety`, whose floor is 0.8, is absent.
|
|
167
|
+
# Substituting `// 0` for has() makes safety read as 0.0 and blocks.
|
|
168
|
+
#
|
|
169
|
+
# Its own test because the case above cannot fail when has() is deleted:
|
|
170
|
+
# that fixture is filtered one layer earlier. Two different absences
|
|
171
|
+
# sharing one test is how an outer guard silently stands in for an inner.
|
|
172
|
+
local out
|
|
173
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
174
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9}},
|
|
175
|
+
{"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9}}
|
|
176
|
+
]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
177
|
+
printf '%s' "$out" | jq -e '.passed == true' >/dev/null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@test "a criterion scored exactly zero does block" {
|
|
181
|
+
# The mirror of the previous test. A fix that conflates absent with zero
|
|
182
|
+
# passes one of these two and fails the other.
|
|
183
|
+
local out
|
|
184
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
185
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.0}},
|
|
186
|
+
{"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.0}}
|
|
187
|
+
]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
188
|
+
printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
|
|
189
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@test "a hyphenated criterion name gates correctly" {
|
|
193
|
+
local out
|
|
194
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
195
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"path-traversal":0.1}},
|
|
196
|
+
{"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"path-traversal":0.1}}
|
|
197
|
+
]' "0.82" "0.75" "$NO_META" "0.05" "0.25" \
|
|
198
|
+
'{"criteria":[{"name":"path-traversal","weight":1.0,"min_pass":0.5}]}')
|
|
199
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "path-traversal"' >/dev/null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@test "low_score still wins over criterion_floor" {
|
|
203
|
+
# Precedence matters for the retry digest: if the aggregate missed the
|
|
204
|
+
# threshold, that is the more actionable thing to tell the Actor.
|
|
205
|
+
local out
|
|
206
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
207
|
+
{"judge_id":"a","score":0.20,"passed":true,"criterion_scores":{"correctness":0.1,"safety":0.1}},
|
|
208
|
+
{"judge_id":"b","score":0.20,"passed":true,"criterion_scores":{"correctness":0.1,"safety":0.1}}
|
|
209
|
+
]' "0.20" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
210
|
+
printf '%s' "$out" | jq -e '.reason == "low_score"' >/dev/null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@test "a floor uses the lowest judge score, not the mean" {
|
|
214
|
+
# A specialist's finding must not be dilutable by generalists who did not
|
|
215
|
+
# look. safety's floor is 0.8; the mean of 0.95/0.95/0.6 is 0.833 and used
|
|
216
|
+
# to pass.
|
|
217
|
+
local out
|
|
218
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
219
|
+
{"judge_id":"a","score":0.9,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.95}},
|
|
220
|
+
{"judge_id":"b","score":0.9,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.95}},
|
|
221
|
+
{"judge_id":"c","score":0.9,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.6}}
|
|
222
|
+
]' "0.9" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
223
|
+
printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
|
|
224
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@test "a low_score block still names the criterion that failed its floor" {
|
|
228
|
+
local out
|
|
229
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
230
|
+
{"judge_id":"a","score":0.2,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.1}},
|
|
231
|
+
{"judge_id":"b","score":0.2,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.1}}
|
|
232
|
+
]' "0.20" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
233
|
+
printf '%s' "$out" | jq -e '.reason == "low_score"' >/dev/null || return 1
|
|
234
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@test "a floor on a criterion no judge scored is reported on stderr" {
|
|
238
|
+
# The adversarial-judge gap: safety carries the highest floor and appeared
|
|
239
|
+
# in no agent contract. Silently passing a floor nobody scored is this
|
|
240
|
+
# design's own failure mode one layer down.
|
|
241
|
+
run --separate-stderr tribunal_gate_decide "majority" '[
|
|
242
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9}},
|
|
243
|
+
{"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9}}
|
|
244
|
+
]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR"
|
|
245
|
+
printf '%s' "$output" | jq -e '.passed == true' >/dev/null || return 1
|
|
246
|
+
local re='safety'
|
|
247
|
+
[[ "$stderr" =~ $re ]]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@test "no unscored-criterion warning when no judge scored anything" {
|
|
251
|
+
# The pre-upgrade fleet must not spew a warning on every single gate.
|
|
252
|
+
run --separate-stderr tribunal_gate_decide "majority" "$ALL_PASSED" "0.82" "0.75" \
|
|
253
|
+
"$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR"
|
|
254
|
+
[ -z "$stderr" ]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@test "the gate still works with no rubric at all" {
|
|
258
|
+
local out
|
|
259
|
+
out=$(tribunal_gate_decide "majority" "$ALL_PASSED" "0.82" "0.75" "$NO_META" "0.05" "0.25")
|
|
260
|
+
printf '%s' "$out" | jq -e '.passed == true' >/dev/null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
# --- Verdicts the aggregate refuses to score (ecosystem-y7y) ----------------
|
|
264
|
+
#
|
|
265
|
+
# tribunal_aggregate drops a verdict with no numeric .score (ecosystem-up8),
|
|
266
|
+
# but the jury counted .passed over the raw panel, so a verdict that
|
|
267
|
+
# contributed nothing to the aggregate could still vote.
|
|
268
|
+
#
|
|
269
|
+
# The fix denies the vote and KEEPS THE SEAT. Dropping the verdict outright —
|
|
270
|
+
# making both halves agree on membership, which is what this bead originally
|
|
271
|
+
# asked for — shrinks the panel, and a shrunken panel is a trivially satisfied
|
|
272
|
+
# majority. That is the hazard librarian's judge-type check exists to prevent,
|
|
273
|
+
# so the naive reading of "agree on membership" would have loosened the gate
|
|
274
|
+
# in three cases while fixing one. A judge that failed to return a verdict did
|
|
275
|
+
# not leave the panel; it failed.
|
|
276
|
+
|
|
277
|
+
@test "a verdict with no usable score cannot cast a passing vote" {
|
|
278
|
+
# The verified fail-open: the only judge that actually returned a verdict
|
|
279
|
+
# rejected the work, and two malformed verdicts supplied the majority.
|
|
280
|
+
#
|
|
281
|
+
# The fixture is asserted well-formed first. An earlier draft of this test
|
|
282
|
+
# built the panel by string-concatenating a fixture variable and produced
|
|
283
|
+
# invalid JSON, which made jq fail and passed_count fall back to 0 — so it
|
|
284
|
+
# passed while proving nothing, whether or not the guard existed.
|
|
285
|
+
local panel='[
|
|
286
|
+
{"judge_id":"real","score":0.9,"passed":false},
|
|
287
|
+
{"judge_id":"bad1","passed":true},
|
|
288
|
+
{"judge_id":"bad2","passed":true}
|
|
289
|
+
]'
|
|
290
|
+
printf '%s' "$panel" | jq -e 'length == 3' >/dev/null || return 1
|
|
291
|
+
|
|
292
|
+
local out
|
|
293
|
+
out=$(tribunal_gate_decide "majority" "$panel" "0.90" "0.75" "$NO_META" "0.05" "0.25")
|
|
294
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@test "a malformed verdict keeps its seat, so a thin panel is not a trivial majority" {
|
|
298
|
+
# One real approval plus two verdicts that never arrived is not a majority
|
|
299
|
+
# of a three-judge panel. Filtering them out would make it one.
|
|
300
|
+
local out
|
|
301
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
302
|
+
{"judge_id":"real","score":0.9,"passed":true},
|
|
303
|
+
{"judge_id":"bad1","passed":false},
|
|
304
|
+
{"judge_id":"bad2","passed":false}
|
|
305
|
+
]' "0.90" "0.75" "$NO_META" "0.05" "0.25")
|
|
306
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@test "strict blocks when any judge returned no usable verdict" {
|
|
310
|
+
# strict means every judge passed. A judge that returned nothing did not.
|
|
311
|
+
local out
|
|
312
|
+
out=$(tribunal_gate_decide "strict" '[
|
|
313
|
+
{"judge_id":"a","score":0.9,"passed":true},
|
|
314
|
+
{"judge_id":"b","score":0.8,"passed":true},
|
|
315
|
+
{"judge_id":"bad","passed":true}
|
|
316
|
+
]' "0.85" "0.75" "$NO_META" "0.05" "0.25")
|
|
317
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
@test "a malformed verdict does not block a genuine majority" {
|
|
321
|
+
# The guard must not overreach: two real approvals out of three still carry
|
|
322
|
+
# a majority gate, exactly as before.
|
|
323
|
+
local out
|
|
324
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
325
|
+
{"judge_id":"a","score":0.9,"passed":true},
|
|
326
|
+
{"judge_id":"b","score":0.8,"passed":true},
|
|
327
|
+
{"judge_id":"bad","passed":true}
|
|
328
|
+
]' "0.85" "0.75" "$NO_META" "0.05" "0.25")
|
|
329
|
+
printf '%s' "$out" | jq -e '.passed == true' >/dev/null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
@test "usable verdicts agree with tribunal_aggregate" {
|
|
333
|
+
# The gate and tribunal_aggregate each carry their own copy of "is this
|
|
334
|
+
# verdict usable", in different files, with nothing keeping them in step.
|
|
335
|
+
# This pins the equivalence behaviorally rather than by comparing source.
|
|
336
|
+
#
|
|
337
|
+
# A single-judge panel makes both answers observable: majority needs one
|
|
338
|
+
# passing vote, and the aggregate of a lone unusable verdict is 0.
|
|
339
|
+
# shellcheck disable=SC1091
|
|
340
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-aggregate.sh"
|
|
341
|
+
|
|
342
|
+
local shape agg out kept votable
|
|
343
|
+
for shape in \
|
|
344
|
+
'{"judge_id":"a","score":0.9,"passed":true}' \
|
|
345
|
+
'{"judge_id":"a","passed":true}' \
|
|
346
|
+
'{"judge_id":"a","score":"high","passed":true}' \
|
|
347
|
+
'{"judge_id":"a","score":null,"passed":true}' \
|
|
348
|
+
'{"judge_id":"a","score":100,"passed":true}' \
|
|
349
|
+
'{"judge_id":"a","score":-5,"passed":true}' \
|
|
350
|
+
'{"judge_id":"a","score":1,"passed":true}'
|
|
351
|
+
do
|
|
352
|
+
# Does the aggregate keep it? A kept verdict yields its own score.
|
|
353
|
+
agg=$(tribunal_aggregate "mean" "[$shape]" 2>/dev/null)
|
|
354
|
+
kept=$(awk -v a="$agg" 'BEGIN { print (a > 0.5) ? "yes" : "no" }')
|
|
355
|
+
|
|
356
|
+
# Does the gate let it vote? Handed an aggregate that clears the
|
|
357
|
+
# threshold, a one-judge majority turns entirely on the vote.
|
|
358
|
+
out=$(tribunal_gate_decide "majority" "[$shape]" "0.90" "0.75" \
|
|
359
|
+
"$NO_META" "0.0" "0.25" 2>/dev/null)
|
|
360
|
+
votable=$(printf '%s' "$out" | jq -r 'if .passed then "yes" else "no" end')
|
|
361
|
+
|
|
362
|
+
[ "$kept" = "$votable" ] || {
|
|
363
|
+
printf 'drift on %s: aggregate kept=%s, gate votable=%s\n' \
|
|
364
|
+
"$shape" "$kept" "$votable" >&2
|
|
365
|
+
return 1
|
|
366
|
+
}
|
|
367
|
+
done
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# --- out-of-range verdict scores (ecosystem-7cl) --------------------------
|
|
371
|
+
|
|
372
|
+
@test "a whole panel of out-of-range scores cannot pass the gate" {
|
|
373
|
+
# Three judges all reporting 95-instead-of-0.95. Pre-fix the aggregate
|
|
374
|
+
# trusted them, so every threshold cleared. The scores are deliberately
|
|
375
|
+
# IDENTICAL: a spread of out-of-range scores blocks on dissent instead, so
|
|
376
|
+
# the mixed panel would pass this test with the bug still in place. Only a
|
|
377
|
+
# unanimous out-of-range panel — dissent 0, nothing left to catch it — is
|
|
378
|
+
# the actual fail-open.
|
|
379
|
+
# shellcheck disable=SC1091
|
|
380
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-aggregate.sh"
|
|
381
|
+
|
|
382
|
+
local panel='[{"judge_id":"a","score":95,"passed":true},{"judge_id":"b","score":95,"passed":true},{"judge_id":"c","score":95,"passed":true}]'
|
|
383
|
+
local agg dissent out
|
|
384
|
+
agg=$(tribunal_aggregate "mean" "$panel" 2>/dev/null)
|
|
385
|
+
dissent=$(tribunal_disagreement "$panel" 2>/dev/null)
|
|
386
|
+
out=$(tribunal_gate_decide "majority" "$panel" "$agg" "0.75" "$NO_META" "$dissent" "0.25" 2>/dev/null)
|
|
387
|
+
|
|
388
|
+
[ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
@test "an out-of-range verdict keeps its seat, so it cannot be voted around" {
|
|
392
|
+
# Same rule y7y settled for scoreless verdicts: a judge whose verdict was
|
|
393
|
+
# refused did not leave the panel, it failed. Two real approvals beside one
|
|
394
|
+
# out-of-range verdict is 2-of-3, not 2-of-2.
|
|
395
|
+
local panel='[{"judge_id":"a","score":0.9,"passed":true},{"judge_id":"b","score":0.85,"passed":true},{"judge_id":"c","score":100,"passed":true}]'
|
|
396
|
+
local out
|
|
397
|
+
out=$(tribunal_gate_decide "strict" "$panel" "0.87" "0.75" "$NO_META" "0.05" "0.25" 2>/dev/null)
|
|
398
|
+
[ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ] || return 1
|
|
399
|
+
|
|
400
|
+
# ...and majority, which two of three genuinely satisfies, still passes.
|
|
401
|
+
out=$(tribunal_gate_decide "majority" "$panel" "0.87" "0.75" "$NO_META" "0.05" "0.25" 2>/dev/null)
|
|
402
|
+
[ "$(printf '%s' "$out" | jq -r '.passed')" = "true" ]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
@test "a verdict scored 0 still casts its vote" {
|
|
406
|
+
# [0,1] is inclusive at both ends. 0 is a real verdict — the judge scored
|
|
407
|
+
# the work and scored it badly — so it must not be swept up with the
|
|
408
|
+
# malformed ones. Pinned at the gate because the drift-guard loop below
|
|
409
|
+
# infers usability from the aggregate clearing 0.5 and cannot see this.
|
|
410
|
+
local out
|
|
411
|
+
out=$(tribunal_gate_decide "strict" \
|
|
412
|
+
'[{"judge_id":"a","score":0,"passed":true}]' "0.90" "0.75" \
|
|
413
|
+
"$NO_META" "0.0" "0.25" 2>/dev/null)
|
|
414
|
+
[ "$(printf '%s' "$out" | jq -r '.passed')" = "true" ]
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# --- out-of-range criterion scores (ecosystem-5fy) ------------------------
|
|
418
|
+
#
|
|
419
|
+
# A floor is the rubric's hard constraint: nobody may be below it. An
|
|
420
|
+
# unreadable report on a floored criterion means we cannot confirm that held,
|
|
421
|
+
# and "cannot confirm" on a hard constraint has to fail closed.
|
|
422
|
+
|
|
423
|
+
@test "an out-of-range criterion score cannot satisfy its floor" {
|
|
424
|
+
# THE verified case. safety 0.30 against an 0.8 floor blocks; the identical
|
|
425
|
+
# judgment written as 30 used to pass clean, because 30 < 0.8 is false.
|
|
426
|
+
local out
|
|
427
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
428
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}},
|
|
429
|
+
{"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}}
|
|
430
|
+
]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
431
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
|
|
432
|
+
printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
|
|
433
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
@test "one judge's unreadable floor score is not diluted by judges who scored it fine" {
|
|
437
|
+
# The dilution failure the floor design exists to prevent, reproduced
|
|
438
|
+
# through a different mechanism. A security specialist emitting 30 — which
|
|
439
|
+
# may well mean 0.30, a violation — must not be silently discarded so that
|
|
440
|
+
# two generalists at 0.95 carry the floor. Filtering the bad value alone
|
|
441
|
+
# gives min([0.95,0.95]) and passes, which is why filtering is not enough.
|
|
442
|
+
local out
|
|
443
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
444
|
+
{"judge_id":"sec","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}},
|
|
445
|
+
{"judge_id":"gen1","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.95}},
|
|
446
|
+
{"judge_id":"gen2","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.95}}
|
|
447
|
+
]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
448
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
|
|
449
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
@test "a non-numeric criterion score cannot satisfy its floor either" {
|
|
453
|
+
# Unreadable is unreadable — a string is no more confirmable than a 30.
|
|
454
|
+
local out
|
|
455
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
456
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":"high"}},
|
|
457
|
+
{"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":"high"}}
|
|
458
|
+
]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
459
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
|
|
460
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@test "an unreadable floor score is not reported as one nobody scored" {
|
|
464
|
+
# The two are different and must not be conflated. The stderr warning says
|
|
465
|
+
# the floor DID NOT APPLY — true when the criterion is absent, and a false
|
|
466
|
+
# statement here, where it applied and blocked. Conflating them also means
|
|
467
|
+
# the one signal for "a floor was skipped" is emitted by the same defect
|
|
468
|
+
# that skipped it.
|
|
469
|
+
run --separate-stderr tribunal_gate_decide "majority" '[
|
|
470
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}},
|
|
471
|
+
{"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}}
|
|
472
|
+
]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR"
|
|
473
|
+
printf '%s' "$output" | jq -e '.passed == false' >/dev/null || return 1
|
|
474
|
+
local re='did not apply'
|
|
475
|
+
[[ ! "$stderr" =~ $re ]]
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
@test "meta_override accept cannot lift a floor it cannot confirm" {
|
|
479
|
+
# The accept-veto added in #152 exists because a floor is not the jury's
|
|
480
|
+
# opinion to overrule. An unconfirmable floor must sit behind the same veto,
|
|
481
|
+
# or the policy becomes a way to launder a malformed score into a pass.
|
|
482
|
+
local meta='{"override_recommendation":"accept","bias_detected":false}'
|
|
483
|
+
local out
|
|
484
|
+
out=$(tribunal_gate_decide "meta_override" '[
|
|
485
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}},
|
|
486
|
+
{"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}}
|
|
487
|
+
]' "0.85" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
488
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
|
|
489
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
@test "the range bounds stay usable for criterion scores too" {
|
|
493
|
+
# [0,1] inclusive. A criterion scored exactly 0 already has its own test
|
|
494
|
+
# above (it blocks); this pins the other end, where 1.0 must read as a
|
|
495
|
+
# perfect score rather than as out-of-range garbage.
|
|
496
|
+
local out
|
|
497
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
498
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":1,"safety":1}},
|
|
499
|
+
{"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":1,"safety":1}}
|
|
500
|
+
]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
501
|
+
printf '%s' "$out" | jq -e '.passed == true' >/dev/null
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
@test "readable criterion scores agree with tribunal_aggregate" {
|
|
505
|
+
# The criterion-level twin of "usable verdicts agree with tribunal_aggregate".
|
|
506
|
+
# weighted_mean's filter and _tribunal_floor_failed's `readable` live in
|
|
507
|
+
# different files with nothing keeping them in step, and them disagreeing IS
|
|
508
|
+
# ecosystem-5fy: aggregation refused to score 30 while the floor accepted it.
|
|
509
|
+
#
|
|
510
|
+
# Every shape below sits at or above the 0.7 floor when readable, so the
|
|
511
|
+
# gate blocks on unreadability alone and the two answers are comparable:
|
|
512
|
+
# a criterion the aggregate USED is exactly one the gate did not reject.
|
|
513
|
+
# shellcheck disable=SC1091
|
|
514
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-aggregate.sh"
|
|
515
|
+
|
|
516
|
+
local rubric='{"criteria":[{"name":"correctness","weight":1.0,"min_pass":0.7}]}'
|
|
517
|
+
local shape agg out used blocked
|
|
518
|
+
for shape in '0.9' '1' '0.7' '100' '-1' '"high"' 'null'; do
|
|
519
|
+
local panel
|
|
520
|
+
panel="[{\"judge_id\":\"a\",\"score\":0.5,\"passed\":true,\"criterion_scores\":{\"correctness\":${shape}}}]"
|
|
521
|
+
|
|
522
|
+
# Did the aggregate use the criterion? If it did, the result is the
|
|
523
|
+
# criterion's own value; if it refused, weighted_mean degrades to the
|
|
524
|
+
# plain mean of .score, which is 0.5.
|
|
525
|
+
agg=$(tribunal_aggregate "weighted_mean" "$panel" "$rubric" 2>/dev/null)
|
|
526
|
+
used=$(awk -v a="$agg" 'BEGIN { print (a == 0.5) ? "no" : "yes" }')
|
|
527
|
+
|
|
528
|
+
# Did the gate reject it as unreadable? Handed an aggregate that clears
|
|
529
|
+
# the threshold, only the floor check can block.
|
|
530
|
+
out=$(tribunal_gate_decide "majority" "$panel" "0.90" "0.75" \
|
|
531
|
+
"$NO_META" "0.0" "0.25" "$rubric" 2>/dev/null)
|
|
532
|
+
blocked=$(printf '%s' "$out" | jq -r 'if .passed then "no" else "yes" end')
|
|
533
|
+
|
|
534
|
+
[ "$used" != "$blocked" ] || {
|
|
535
|
+
printf 'drift on correctness=%s: aggregate used=%s, gate blocked=%s\n' \
|
|
536
|
+
"$shape" "$used" "$blocked" >&2
|
|
537
|
+
return 1
|
|
538
|
+
}
|
|
539
|
+
done
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
# --- blocking-reason precedence (ecosystem-4d3) ---------------------------
|
|
543
|
+
#
|
|
544
|
+
# Nothing pinned the order of these arms, so it could be reshuffled silently —
|
|
545
|
+
# which is how criterion_floor came to sit ahead of the jury arms in #150 under
|
|
546
|
+
# a comment claiming precedence was unchanged. Librarian's sibling gate already
|
|
547
|
+
# settled this the other way ("lesson gate: jury policy still wins over
|
|
548
|
+
# criterion_floor"); tribunal now matches.
|
|
549
|
+
#
|
|
550
|
+
# The jury arms report WHY the panel failed. A floor still blocks, and the
|
|
551
|
+
# meta_override accept-veto still makes it unoverridable — those are about the
|
|
552
|
+
# OUTCOME. This chain only picks which reason to name, and a Meta-Judge
|
|
553
|
+
# rejection is the more actionable thing to hand the Actor on retry than which
|
|
554
|
+
# criterion sat low. The floor rides along as failed_criterion either way.
|
|
555
|
+
|
|
556
|
+
@test "a Meta-Judge rejection outranks a violated floor as the reason" {
|
|
557
|
+
# The bead's verified case. Both block; the question is what the Actor is
|
|
558
|
+
# told. Pre-fix this reported criterion_floor and the rejection vanished.
|
|
559
|
+
local meta='{"override_recommendation":"reject","bias_detected":false}'
|
|
560
|
+
local out
|
|
561
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
562
|
+
{"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
563
|
+
{"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
564
|
+
]' "0.85" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
565
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
|
|
566
|
+
printf '%s' "$out" | jq -e '.reason == "meta_override"' >/dev/null || return 1
|
|
567
|
+
# ...and the floor is not lost, it decorates. This is ecosystem-cs8: the
|
|
568
|
+
# suffix on this arm was unreachable dead code before the reorder.
|
|
569
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
@test "an unresolved jury outranks a violated floor as the reason" {
|
|
573
|
+
# The other jury arm, reached when no override was given and dissent sits
|
|
574
|
+
# below threshold. Same rule, and the same previously-dead suffix.
|
|
575
|
+
local out
|
|
576
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
577
|
+
{"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
578
|
+
{"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
579
|
+
]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
580
|
+
printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
|
|
581
|
+
printf '%s' "$out" | jq -e '.reason == "dissent_unresolved"' >/dev/null || return 1
|
|
582
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
@test "criterion_floor is still the reason when the jury has no complaint" {
|
|
586
|
+
# Demoting the arm must not make it unreachable. A floor violated while the
|
|
587
|
+
# score clears and every judge passed is exactly the case criterion_floor
|
|
588
|
+
# exists for, and it is the whole point of the rubric's hard constraint.
|
|
589
|
+
local out
|
|
590
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
591
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
592
|
+
{"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
593
|
+
]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
|
|
594
|
+
printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
|
|
595
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
@test "low_score still outranks both, and still names the floor" {
|
|
599
|
+
# The one arm whose position is NOT changing. Pinned so the reorder cannot
|
|
600
|
+
# quietly take it along.
|
|
601
|
+
local meta='{"override_recommendation":"reject","bias_detected":false}'
|
|
602
|
+
local out
|
|
603
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
604
|
+
{"judge_id":"a","score":0.30,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
605
|
+
{"judge_id":"b","score":0.30,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
606
|
+
]' "0.30" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
607
|
+
printf '%s' "$out" | jq -e '.reason == "low_score"' >/dev/null || return 1
|
|
608
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
# --- failed_criterion on the short-circuit arms (ecosystem-973) -----------
|
|
612
|
+
#
|
|
613
|
+
# floor_failed is computed near the top of the function, but floor_suffix was
|
|
614
|
+
# not built until well below these three early returns, so they could not
|
|
615
|
+
# reference it however much they wanted to. The result was two behaviors behind
|
|
616
|
+
# one reason string: dissent_unresolved carried the criterion from the final
|
|
617
|
+
# chain and dropped it from the short-circuit, which is the part most likely to
|
|
618
|
+
# mislead someone reading gate.blocked events.
|
|
619
|
+
#
|
|
620
|
+
# A missing diagnostic, never a wrong verdict — none of these change whether
|
|
621
|
+
# the gate blocks, only what it says about why.
|
|
622
|
+
|
|
623
|
+
@test "meta_override reject names a violated floor" {
|
|
624
|
+
local meta='{"override_recommendation":"reject","bias_detected":false}'
|
|
625
|
+
local out
|
|
626
|
+
out=$(tribunal_gate_decide "meta_override" '[
|
|
627
|
+
{"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
628
|
+
{"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
629
|
+
]' "0.85" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
630
|
+
printf '%s' "$out" | jq -e '.reason == "meta_override"' >/dev/null || return 1
|
|
631
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
@test "bias_detected names a violated floor" {
|
|
635
|
+
local meta='{"override_recommendation":"reject","bias_detected":true,"bias_types":["verbosity"]}'
|
|
636
|
+
local out
|
|
637
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
638
|
+
{"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
639
|
+
{"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
640
|
+
]' "0.85" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
641
|
+
printf '%s' "$out" | jq -e '.reason == "bias_detected"' >/dev/null || return 1
|
|
642
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
@test "the dissent short-circuit names a violated floor, like its chain twin" {
|
|
646
|
+
# THE inconsistency this bead is really about: the same reason string
|
|
647
|
+
# behaving two ways depending on which return produced it.
|
|
648
|
+
local out
|
|
649
|
+
out=$(tribunal_gate_decide "majority" '[
|
|
650
|
+
{"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
651
|
+
{"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
652
|
+
]' "0.85" "0.75" "$NO_META" "0.90" "0.25" "$RUBRIC_FLOOR")
|
|
653
|
+
printf '%s' "$out" | jq -e '.reason == "dissent_unresolved"' >/dev/null || return 1
|
|
654
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
@test "the short-circuit arms stay quiet when no floor was violated" {
|
|
658
|
+
# The suffix must remain a decoration, not become noise. Absent is still the
|
|
659
|
+
# right answer when there is nothing to name — and with the arms now
|
|
660
|
+
# reporting it, an absent field finally means what it says.
|
|
661
|
+
local out
|
|
662
|
+
local clean='[
|
|
663
|
+
{"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.95}},
|
|
664
|
+
{"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.95}}
|
|
665
|
+
]'
|
|
666
|
+
out=$(tribunal_gate_decide "meta_override" "$clean" "0.85" "0.75" \
|
|
667
|
+
'{"override_recommendation":"reject","bias_detected":false}' "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
668
|
+
printf '%s' "$out" | jq -e 'has("failed_criterion") | not' >/dev/null || return 1
|
|
669
|
+
|
|
670
|
+
out=$(tribunal_gate_decide "majority" "$clean" "0.85" "0.75" \
|
|
671
|
+
'{"override_recommendation":"reject","bias_detected":true}' "0.0" "0.25" "$RUBRIC_FLOOR")
|
|
672
|
+
printf '%s' "$out" | jq -e 'has("failed_criterion") | not' >/dev/null || return 1
|
|
673
|
+
|
|
674
|
+
out=$(tribunal_gate_decide "majority" "$clean" "0.85" "0.75" \
|
|
675
|
+
"$NO_META" "0.90" "0.25" "$RUBRIC_FLOOR")
|
|
676
|
+
printf '%s' "$out" | jq -e 'has("failed_criterion") | not' >/dev/null
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
@test "every blocking arm that can see a violated floor names it" {
|
|
680
|
+
# The property the individual tests add up to, asserted as one thing so a
|
|
681
|
+
# newly-added arm that forgets the suffix is caught by an existing test
|
|
682
|
+
# rather than needing someone to remember to write a new one.
|
|
683
|
+
local panel='[
|
|
684
|
+
{"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
685
|
+
{"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
686
|
+
]'
|
|
687
|
+
local passing='[
|
|
688
|
+
{"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}},
|
|
689
|
+
{"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}}
|
|
690
|
+
]'
|
|
691
|
+
local reject='{"override_recommendation":"reject","bias_detected":false}'
|
|
692
|
+
local biased='{"override_recommendation":"reject","bias_detected":true}'
|
|
693
|
+
|
|
694
|
+
# Args are passed positionally rather than packed into a delimited string.
|
|
695
|
+
# An earlier version packed them and unpacked with eval, which stripped the
|
|
696
|
+
# JSON's own quotes: meta parsed as empty, the meta_override policy fell
|
|
697
|
+
# through to the chain, and two arms reported PASS without ever reaching the
|
|
698
|
+
# return they were meant to exercise.
|
|
699
|
+
_arm_names_floor() {
|
|
700
|
+
local label="$1" policy="$2" verdicts="$3" agg="$4" meta="$5" dissent="$6"
|
|
701
|
+
local out reason
|
|
702
|
+
out=$(tribunal_gate_decide "$policy" "$verdicts" "$agg" "0.75" \
|
|
703
|
+
"$meta" "$dissent" "0.25" "$RUBRIC_FLOOR")
|
|
704
|
+
reason=$(printf '%s' "$out" | jq -r '.reason // "none"')
|
|
705
|
+
# Guard the premise too: an arm that silently stopped being reachable
|
|
706
|
+
# would otherwise "pass" this by never being the arm under test.
|
|
707
|
+
[ "$reason" = "$label" ] || {
|
|
708
|
+
printf 'expected reason %s, got %s: %s\n' "$label" "$reason" "$out" >&2
|
|
709
|
+
return 1
|
|
710
|
+
}
|
|
711
|
+
printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null || {
|
|
712
|
+
printf 'arm %s dropped failed_criterion: %s\n' "$reason" "$out" >&2
|
|
713
|
+
return 1
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
# final chain
|
|
718
|
+
_arm_names_floor low_score majority "$panel" 0.30 "$NO_META" 0.0 || return 1
|
|
719
|
+
_arm_names_floor meta_override majority "$panel" 0.85 "$reject" 0.0 || return 1
|
|
720
|
+
_arm_names_floor dissent_unresolved majority "$panel" 0.85 "$NO_META" 0.0 || return 1
|
|
721
|
+
_arm_names_floor criterion_floor majority "$passing" 0.85 "$NO_META" 0.0 || return 1
|
|
722
|
+
# short-circuit returns
|
|
723
|
+
_arm_names_floor meta_override meta_override "$panel" 0.85 "$reject" 0.0 || return 1
|
|
724
|
+
_arm_names_floor bias_detected majority "$panel" 0.85 "$biased" 0.0 || return 1
|
|
725
|
+
_arm_names_floor dissent_unresolved majority "$panel" 0.85 "$NO_META" 0.90
|
|
726
|
+
}
|