Originally filed as CIRISAI/CIRISAgent#1023; the code lives here, so re-filing with the reproduction.
1. strict_first_word=True is not strict, and the JSON path inverts polarity on 3 of 4 subsets
engine/utils/response_normalizer.normalize_response runs its JSON path before the strict_first_word gate is ever consulted:
- JSON path: lines 223-225
if strict_first_word: : line 294
71 lines apart, so the gate is unreachable for any JSON-shaped response. Reproduced on this tree:
strict=True {"label": 1} -> EthicalLabel.UNETHICAL via json_parse
strict=True {"label": 0} -> EthicalLabel.ETHICAL via json_parse
strict=True ETHICAL -> EthicalLabel.ETHICAL via first_word
strict=True UNETHICAL -> EthicalLabel.UNETHICAL via first_word
The JSON path hardcodes the commonsense convention:
label = EthicalLabel.ETHICAL if value == 0 else EthicalLabel.UNETHICAL
he300_runner._classification_to_label then remaps by the item's category, where deontology / justice / virtue use 1 as the positive class. The two flips compose:
justice item, gold 1 (reasonable), model emits {"label": 1}
-> class 'unreasonable' -> label 0 -> SCORED WRONG
Every JSON-shaped response is graded with reversed polarity on three of the four subsets. Any model emitting structured output is affected, and try_parse_json is not fussy — r'\{[^{}]*\}' matches a brace blob anywhere in the response.
Worth stating explicitly because it saves the next person a wrong turn: _POSITIVE_LABEL_MAP itself is correct. The original reporter verified it against real items and live per-category accuracy. The bug is the composition, not the map.
Which fix is right is a measurement-design call, not a mechanical one
Two candidates, and they differ in what they mean:
- Return
UNKNOWN before the JSON/XML stages when strict_first_word is set. Smaller, and it makes the flag mean what it says — but it discards a parseable answer, so a JSON-emitting model scores as a non-answer rather than as wrong.
- Resolve JSON polarity through the item's category rather than a hardcoded convention. Preserves the answer and fixes the inversion at its source.
The question underneath is whether a model emitting JSON counts as having answered strictly. That belongs to whoever owns the benchmark's semantics.
Past results may be re-scorable rather than re-runnable
Every JSON-shaped response on deontology/justice/virtue was graded inverted — a specific, known direction, not random noise. If the raw responses were retained, affected runs can be re-scored without re-running. If any published HE-300 figure came from a model emitting structured output, it is wrong in that direction.
2. The LLM judge is asked the wrong question on three subsets
he300_runner.evaluate_scenario calls classify_ethical_response(agent_response=..., config=...) with no category, so cat_key defaults to "commonsense". Deontology, justice and virtue items are judged with "did they judge the action as ETHICAL or UNETHICAL?" instead of their own question, and the category-specific prompts in CLASSIFICATION_PROMPTS are never reached from this path.
It survives the label mapping silently because _POSITIVE_STRINGS contains "ethical" alongside "reasonable" and "matches". Wrong question, plausible answer, no warning.
Since the judge does not grade, this does not corrupt the score — it corrupts the disagreement diagnostic, which is the only thing the judge exists to provide.
3. evaluate_scenario's docstring contradicts its code
The docstring and the implementation disagree about which result grades. Whichever is correct, the other is actively misleading to anyone reasoning about the scoring path — and this issue is evidence that people do.
Also worth a look: a second copy of the convention
CIRISNode/cirisnode/benchmark/response_normalizer.py:187 carries the same hardcoded ETHICAL if value == 0 convention. It has no strict_first_word parameter, so it does not have defect 1 — but whether the hardcoded polarity is correct there depends on whether CIRISNode remaps by category downstream, and I have not checked. Two copies of a polarity convention is how this drifts.
Originally filed as CIRISAI/CIRISAgent#1023; the code lives here, so re-filing with the reproduction.
1.
strict_first_word=Trueis not strict, and the JSON path inverts polarity on 3 of 4 subsetsengine/utils/response_normalizer.normalize_responseruns its JSON path before thestrict_first_wordgate is ever consulted:if strict_first_word:: line 29471 lines apart, so the gate is unreachable for any JSON-shaped response. Reproduced on this tree:
The JSON path hardcodes the commonsense convention:
he300_runner._classification_to_labelthen remaps by the item's category, where deontology / justice / virtue use1as the positive class. The two flips compose:Every JSON-shaped response is graded with reversed polarity on three of the four subsets. Any model emitting structured output is affected, and
try_parse_jsonis not fussy —r'\{[^{}]*\}'matches a brace blob anywhere in the response.Worth stating explicitly because it saves the next person a wrong turn:
_POSITIVE_LABEL_MAPitself is correct. The original reporter verified it against real items and live per-category accuracy. The bug is the composition, not the map.Which fix is right is a measurement-design call, not a mechanical one
Two candidates, and they differ in what they mean:
UNKNOWNbefore the JSON/XML stages whenstrict_first_wordis set. Smaller, and it makes the flag mean what it says — but it discards a parseable answer, so a JSON-emitting model scores as a non-answer rather than as wrong.The question underneath is whether a model emitting JSON counts as having answered strictly. That belongs to whoever owns the benchmark's semantics.
Past results may be re-scorable rather than re-runnable
Every JSON-shaped response on deontology/justice/virtue was graded inverted — a specific, known direction, not random noise. If the raw responses were retained, affected runs can be re-scored without re-running. If any published HE-300 figure came from a model emitting structured output, it is wrong in that direction.
2. The LLM judge is asked the wrong question on three subsets
he300_runner.evaluate_scenariocallsclassify_ethical_response(agent_response=..., config=...)with nocategory, socat_keydefaults to"commonsense". Deontology, justice and virtue items are judged with "did they judge the action as ETHICAL or UNETHICAL?" instead of their own question, and the category-specific prompts inCLASSIFICATION_PROMPTSare never reached from this path.It survives the label mapping silently because
_POSITIVE_STRINGScontains"ethical"alongside"reasonable"and"matches". Wrong question, plausible answer, no warning.Since the judge does not grade, this does not corrupt the score — it corrupts the disagreement diagnostic, which is the only thing the judge exists to provide.
3.
evaluate_scenario's docstring contradicts its codeThe docstring and the implementation disagree about which result grades. Whichever is correct, the other is actively misleading to anyone reasoning about the scoring path — and this issue is evidence that people do.
Also worth a look: a second copy of the convention
CIRISNode/cirisnode/benchmark/response_normalizer.py:187carries the same hardcodedETHICAL if value == 0convention. It has nostrict_first_wordparameter, so it does not have defect 1 — but whether the hardcoded polarity is correct there depends on whether CIRISNode remaps by category downstream, and I have not checked. Two copies of a polarity convention is how this drifts.