#!/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 fixtures 051 (FN) + 052 (legit-unindexed)
pin the symmetric treatment.

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 = coverage gap,
      the real-but-unindexed paper; C-V6(a)/(f) + OQ-5 by-design FN).
    """
    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"
