Files
imbad0202__academic-researc…/scripts/citation_verification_summary.py
Edward Cheng-I Wu 682b30a200 feat: harden integrity and evaluation contracts (#685)
* feat: harden integrity and evaluation contracts

Refs #657, #664, #665, #671, #674, and #678.

* fix: close integrity review blockers

* fix: support frozen rows in shallow CI
2026-08-08 20:44:37 +08:00

86 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Citation verification summary reducer (Delta 4) — SINGLE SOURCE OF TRUTH.
Reduces per-resolver outcomes to the 3-class `lookup_verified` value using the
v3.11 narrowed-false definition (C-V6(a)): `false` requires at least one
ID-keyed unmatched (a DOI/arXiv ID that provably fails to resolve = fabrication
evidence); a title-only unmatched (no resolvable identifier to key on) is a
coverage gap and reduces to `unresolvable`, never `false`.
run_evals.py imports `reduce_lookup_verified` from here; there is exactly one
reducer implementation in the repo (the old un-narrowed copy in run_evals.py
was removed when this landed).
OQ-5 recall limit (user-facing surfacing deferred). The narrowed-false rule
means a fabricated citation that carries NO resolvable identifier (no DOI /
arXiv ID, only a bogus title) reduces to `unresolvable`, NOT `false`, so the
citation-existence gate does not block it — it is indistinguishable from a
legitimately unindexed (regional / non-English / pre-digital) paper. This is an
accepted recall limit, caught instead by the v3.8 claim-faithfulness audit +
human review. The user-facing explanation of this limit (in docs/ARCHITECTURE.md
and the user guide) and the policy that consumes this verdict
(`terminal_policies.citation_existence`, enum {advisory, strict}) both land with
the Delta 3 / C-V6 policy batch — this data-layer batch only computes the
verdict; it does not gate on it. Gold fixture 051 (the OQ-5 by-design
false-negative: a no-identifier fabrication) pins that a title-only unmatched
reduces to unresolvable, never false. The absent genuine all-resolver-unmatched
canary is #250's accepted corpus-representativeness limit, not a client-test
gap; #431/#432 cover title-match behavior directly and there is no current
gold-tuple action item.
Spec: docs/design/2026-05-21-v3.10-182-promote-citation-gate-spec.md
§2 Delta 4 + §0(4a) + INVARIANT C-V6(a).
"""
from __future__ import annotations
from typing import Any, Mapping
STATUS_MATCHED = "matched"
STATUS_UNMATCHED = "unmatched"
STATUS_UNREACHABLE = "unreachable"
STATUS_SKIPPED = "skipped"
QUERIED_BY_ID = "id"
def reduce_lookup_verified(resolver_outcomes: Mapping[str, Any]) -> str:
"""Reduce per-resolver outcomes to a 3-class lookup_verified value.
Each value in `resolver_outcomes` is a dict with at least `status`, and
(for unmatched rows) `queried_by` ∈ {'id', 'title', None}.
Rules (symmetric 3-class; `unresolvable` is NEVER collapsed into `false`):
* `skipped` outcomes are EXCLUDED from classification (resolver
applicability / policy, not adjudication). "applicable" = not skipped.
* `true` iff >=1 applicable resolver is `matched` (matched WINS — true even
if another applicable resolver is `unmatched`).
* `false` (v3.11 narrowed, C-V6(a)) iff NO applicable resolver is `matched`
AND >=1 applicable resolver is an **ID-keyed** `unmatched`
(status=unmatched AND queried_by='id'). A bogus DOI/arXiv ID that
provably fails to resolve is fabrication evidence. Anti-fabrication bias:
one ID-keyed unmatched stands even alongside an `unreachable` (a transient
outage does not cancel positive non-existence evidence).
* `unresolvable` otherwise — every applicable resolver `unreachable` (total
outage), OR every resolver `skipped` (empty adjudicating set, manual
exempt), OR the only negative signals are title-only `unmatched`
(queried_by != 'id' — no resolvable identifier to key on = the
coverage-safe class for a potentially real-but-unindexed paper;
C-V6(a)/(f) + OQ-5 by-design FN). The static fixture is not claimed to be
a representative genuine unindexed work (#250).
"""
outcomes = [v or {} for v in resolver_outcomes.values()]
applicable = [o for o in outcomes if o.get("status") != STATUS_SKIPPED]
if any(o.get("status") == STATUS_MATCHED for o in applicable):
return "true"
id_keyed_unmatched = any(
o.get("status") == STATUS_UNMATCHED and o.get("queried_by") == QUERIED_BY_ID
for o in applicable
)
if id_keyed_unmatched:
return "false"
return "unresolvable"