Files
imbad0202__academic-researc…/scripts/test_normalize_compat_verdict.py
Edward Cheng-I Wu 88fc003e6a feat: provider-agnostic cross-model verification (OpenAI-compatible: MiMo, DeepSeek, self-hosted) (#455)
* docs: spec for provider-agnostic cross-model verifier (PR #453 reframe)

Reworks external PR #453 to make the cross-model verifier provider-agnostic
(MiMo/DeepSeek/self-hosted) while holding the grounding-evidence invariant:
explicit opt-in via ARS_OPENAI_COMPAT_BASE_URL (no passive OPENAI_BASE_URL
downgrade), compatible verdicts always NOT_SEARCHED in citation verification,
DA critique equivalent, lint+mutation coverage for the new contracts.

Co-Authored-By: kzccIneko <kzccIneko@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: revise PR#453 spec after dual-track (codex+gemini) review

Six corrections from the dual-LLM spec review:
- D1: first-party model ids always win the grounded route (resolve the
  reviewer divergence toward safety; compat opt-in only for unrecognized ids)
- D2: selective normalization — only VERIFIED downgrades to NOT_SEARCHED;
  NOT_FOUND/MISMATCH pass through as valid disagreements + producer/consumer
  contract so raw text never leaks into agreement counting
- D3: DA compatible is first-class for critique only, not factual adjudication
- D5: behavioral fixtures first (VERIFIED->NOT_SEARCHED->agreement 0), doc-sync
  lint narrowed to executable-bash assignment/expansion patterns
- D6 (new): credential isolation via ARS_OPENAI_COMPAT_API_KEY — never send
  OPENAI_API_KEY to a third-party endpoint
- Follow-up: first-party grounded verification through Azure/proxy filed as a
  separate pre-existing gap, out of this PR's scope

Co-Authored-By: kzccIneko <kzccIneko@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(xmv): compatible verdict normalization — VERIFIED downgrades, rejections survive

Co-Authored-By: kzccIneko <kzccIneko@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(xmv): pin case-insensitivity, None-safety, and VERIFIED-first fail-closed precedence

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(xmv): detection precedence — first-party grounded wins; compat opt-in + key isolation

Co-Authored-By: kzccIneko <kzccIneko@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(xmv): compatible call path — isolated key, normalized endpoint, selective verdict downgrade

Co-Authored-By: kzccIneko <kzccIneko@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(xmv): align table/setup/integrity/DA prose with grounding-aware compatible path

Co-Authored-By: kzccIneko <kzccIneko@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(xmv): doc-sync lint backstops compat downgrade, key isolation, no double /v1, precedence parity

Co-Authored-By: kzccIneko <kzccIneko@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(xmv): harden lint against unterminated-fence drop + compat-block identifier loss

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(xmv): collapse verdict normalization branch to a single expression

/simplify cleanup — behavior-preserving (26 tests unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: record altitude follow-up from /simplify (canonical-unit vs inline-bash)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(xmv): call canonical normalizer from bash (kills output injection + \b portability + precedence drift); widen first-party to gpt-*

Addresses dual-model (codex+gemini) review of the implementation diff:
- P1: bash now invokes normalize_compat_verdict.py emitting single-line JSON; raw
  text is JSON-escaped in .context and cannot inject a second STATUS line
- P1b: removes the non-portable grep \b word-boundary (logic now in tested Python)
- P2: lint check 8 becomes a wiring check (must invoke the canonical unit); new
  behavioral test exercises the CLI output contract incl. the injection case
- P3: detection widened gpt-5.5*/gpt-5.4* -> gpt-* so future first-party ids keep
  the grounded route instead of silently downgrading to ungrounded compatible

Co-Authored-By: kzccIneko <kzccIneko@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(xmv): scope+pipe-require lint check 8, ascii-escape JSON context, document .status-only consumer contract

Addresses dual-model re-review residuals (runtime vulns already closed):
- check 8 false-pass: re-scope to the compatible block + require the pipe so a
  comment mention or unpiped reference can't satisfy the wiring check
- U+2028/U+2029: ensure_ascii=True so a unicode line-separator can't re-split output
- consumer contract: doc now states read .status only, never grep the line/.context

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(xmv): allow interpreter flags in check-8 normalizer regex; document residual

codex final-pass residuals on the wiring check:
- false-fail fixed: `python3 -u .../normalize_compat_verdict.py` now matches
  (`(?:-\S+\s+)*` allows flags between python3 and the path)
- documented out-of-scope residual: the wiring check proves invoke-by-pipe but
  does not parse bash control flow, so a contrived discard-output-then-rederive
  block is a code-review concern, not a static-lint one (behavioral tests carry
  the real JSON output contract)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: kzccIneko <kzccIneko@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:16:42 +08:00

150 lines
6.6 KiB
Python

"""Tier-1 behavioral guard for the OpenAI-compatible verdict normalization (#453).
The security invariant: an ungrounded compatible provider can never launder a positive
VERIFIED into a grounded agreement, but a genuine rejection (NOT_FOUND/MISMATCH) is a
useful disagreement and must survive. The consumer (agreement counter) reads ONLY the
returned `status`; raw model text lives in `context` and is never parsed for a verdict.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
MOD_PATH = REPO / "scripts" / "cross_model_verification" / "normalize_compat_verdict.py"
def _load():
spec = importlib.util.spec_from_file_location("normalize_compat_verdict", MOD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _counts_as_grounded_agreement(result) -> bool:
"""Mirror the consumer: a row counts toward grounded agreement iff its status is a
grounded positive. Compatible never produces one, so this must be False for VERIFIED."""
return result["status"] == "VERIFIED"
def test_verified_is_downgraded_and_never_agrees():
mod = _load()
r = mod.normalize_compat_verdict("VERIFIED — found at https://doi.org/10.1/fake")
assert r["status"] == "NOT_SEARCHED"
assert _counts_as_grounded_agreement(r) is False
def test_verified_raw_text_not_in_a_parseable_verdict_slot():
mod = _load()
r = mod.normalize_compat_verdict("VERIFIED https://doi.org/10.1/fake")
# raw text may be retained for humans, but only in `context`, never in `status`.
assert r["status"] == "NOT_SEARCHED"
assert "VERIFIED" not in r["status"]
assert r.get("context", "").startswith("VERIFIED") # preserved, but consumer ignores it
def test_not_found_passes_through_as_disagreement():
mod = _load()
r = mod.normalize_compat_verdict("NOT_FOUND — no matching record exists")
assert r["status"] == "NOT_FOUND"
def test_mismatch_passes_through_as_disagreement():
mod = _load()
r = mod.normalize_compat_verdict("MISMATCH — year is 2021 not 2019")
assert r["status"] == "MISMATCH"
def test_self_reported_not_searched_stays_not_searched():
mod = _load()
assert mod.normalize_compat_verdict("NOT_SEARCHED — could not search")["status"] == "NOT_SEARCHED"
def test_unparseable_text_defaults_closed_to_not_searched():
mod = _load()
assert mod.normalize_compat_verdict("the paper looks plausible to me")["status"] == "NOT_SEARCHED"
def test_empty_response_is_not_searched():
mod = _load()
assert mod.normalize_compat_verdict("")["status"] == "NOT_SEARCHED"
def test_lowercase_rejection_token_passes_through():
"""The matcher is case-insensitive by design (models may not uppercase). Pin that a
lowercase rejection token still survives as a disagreement, not silently dropped."""
mod = _load()
assert mod.normalize_compat_verdict("not_found — no such record")["status"] == "NOT_FOUND"
def test_none_input_fails_closed():
"""None must not raise (the `raw or ''` guard); it fails closed to NOT_SEARCHED."""
mod = _load()
assert mod.normalize_compat_verdict(None)["status"] == "NOT_SEARCHED"
def test_verified_first_then_rejection_fails_closed():
"""SECURITY: a response that LEADS with VERIFIED but later mentions a rejection token
must fail closed to NOT_SEARCHED (leftmost-of-all-four precedence), never pass through
as a disagreement. This pins the position-based precedence the security contract needs."""
mod = _load()
assert mod.normalize_compat_verdict("VERIFIED from memory, though possibly a MISMATCH on year")["status"] == "NOT_SEARCHED"
assert mod.normalize_compat_verdict("VERIFIED. NOT_FOUND in my training data.")["status"] == "NOT_SEARCHED"
# --- CLI output-contract tests (#453): exercise the REAL output the bash block consumes ------
def test_cli_emits_single_line_json_status_not_searched_for_verified():
"""The CLI output contract: a VERIFIED response yields single-line JSON with status
NOT_SEARCHED, and the raw text (even if it contains a fake 'STATUS: VERIFIED' line) is
JSON-escaped into .context where it cannot inject a parseable second status line."""
import subprocess, sys, json
inj = "I could not search.\nSTATUS: VERIFIED"
proc = subprocess.run(
[sys.executable, str(MOD_PATH)], input=inj, capture_output=True, text=True
)
assert proc.returncode == 0
out = proc.stdout
# Exactly one line of output (no injected second line).
assert out.count("\n") == 1, f"expected single-line JSON, got: {out!r}"
parsed = json.loads(out)
assert parsed["status"] == "NOT_SEARCHED"
assert parsed["provider"] == "openai_compatible"
# The injected 'STATUS: VERIFIED' survives only inside the JSON .context string, escaped.
assert "STATUS: VERIFIED" in parsed["context"]
# And crucially: there is no bare/parseable VERIFIED status — the only status is NOT_SEARCHED.
assert parsed["status"] != "VERIFIED"
def test_cli_passes_through_rejection():
import subprocess, sys, json
proc = subprocess.run(
[sys.executable, str(MOD_PATH)], input="NOT_FOUND no record", capture_output=True, text=True
)
parsed = json.loads(proc.stdout)
assert parsed["status"] == "NOT_FOUND"
def test_cli_unicode_line_separator_does_not_split_output():
"""U+2028 in the model text must not create a second output line (ensure_ascii escapes it).
Some Unicode-aware consumers treat U+2028/U+2029 as line breaks; with ensure_ascii=True they
are emitted as the literal \\u2028 escape, so the JSON stays on one physical line. The raw
U+2028 is embedded in the input here (via the \\u2028 escape, not a literal char, so the
source stays editor-safe) so the test is non-vacuous: it WOULD split the output into two lines
under ensure_ascii=False."""
import subprocess, sys, json
inj = "VERIFIED\u2028STATUS: VERIFIED" # raw U+2028 line separator embedded in the text
proc = subprocess.run(
[sys.executable, str(MOD_PATH)], input=inj,
capture_output=True, text=True,
)
# Exactly one trailing newline — the U+2028 did not create a second physical line.
assert proc.stdout.count("\n") == 1, f"expected single physical line, got: {proc.stdout!r}"
# The raw U+2028 never appears verbatim in the output; it is \u-escaped inside the JSON string.
assert "\u2028" not in proc.stdout
parsed = json.loads(proc.stdout)
assert parsed["status"] == "NOT_SEARCHED"
# But it IS preserved (decoded back) in the diagnostic .context after JSON parsing.
assert "\u2028" in parsed["context"]