← Về thư mục
📄 / / academic-research-skills / docs / design / 2026-05-17-ars-v3.9.0-cross-index-triangulation-measurement-spec.md

ARS v3.9.0 — Cross-Index Triangulation Measurement Layer Spec

Status: Design draft (pre-codex-review) Date: 2026-05-17 Target release: v3.9.0 Scope: Extend v3.7.3 L3-2 contamination_signals from single-index (Semantic Scholar) to three-index triangulation (Semantic Scholar + OpenAlex + Crossref) as advisory evidence only. Terminal gate behavior is unchanged from v3.7.3. Policy-layer behaviors (strict mode, hard-block tier, venue-type-scoped strict) are explicitly out of v3.9.0 scope and deferred to v3.10. Precedent doc: 2026-05-12-ars-v3.7.3-claim-faithfulness-and-contaminated-source-spec.md (§3.2 L3-2) Originating issue: GitHub #102 (renamed from "v3.7.4" to v3.9.0 because main shipped v3.8.2 on the parallel claim-faithfulness audit track first; v3.7.x branch is closed). External motivation: Zhao, Wang, Stuart, De Vaan, Ginsparg, Yin (2026-05) "LLM hallucinations in the wild" (arXiv:2605.07723) §3 — cross-index triangulation across multiple bibliographic indexes is identified as a viable false-positive-reduction strategy for hallucinated-citation detection. Not ground truth.


1. Background

v3.7.3 §3.2 shipped two contamination signal vectors:

The Vector 2 design is structurally limited:

(a) Coverage asymmetry. Semantic Scholar coverage is uneven across humanities, social sciences, non-English venues, regional journals, conference proceedings, dissertations, and grey literature. A legitimate reference can be semantic_scholar_unmatched = true simply because S2 has not indexed it. The v3.7.3 spec acknowledges this with the LOW-WARN annotation tier — strong enough to surface, weak enough to acknowledge.

(b) False-positive inseparability. Single-index unmatched cannot distinguish "fabricated reference S2 correctly fails to find" from "real reference S2 has not indexed". The two failure modes look identical in v3.7.3's data model.

(c) External corroboration absent. Zhao et al. arXiv:2605.07723 §3 explicitly argues that hallucinated-citation detection benefits from triangulation across multiple bibliographic indexes — the more indexes return "not found," the stronger the non-match / verification-risk signal. The Zhao framing names the prior on fabrication; v3.9.0 adopts the weaker, observable framing — "non-match across N indexes" — because the three public indexes (S2 / OpenAlex / Crossref) share DOI / title metadata pipelines and are not fully independent (codex R1 consult, 2026-05-17). Single-index detection is a weaker version of the same instrument; the marker name CONTAMINATED-TRIANGULATION-UNMATCHED reflects the observation rather than the inferred cause.

v3.7.3 §6 Out-of-Scope already named this as v3.7.4 future work:

"v3.7.4 retrieval-side hardening: OpenAlex + Crossref triangulation as a second contamination signal. ARS today depends on Semantic Scholar only; adding a second independent index closes the single-source-of-truth gap on L3-2 Vector 2."

v3.9.0 closes that scope. The renaming from v3.7.4 to v3.9.0 reflects that main shipped v3.8.0 / v3.8.1 / v3.8.2 (claim-faithfulness audit agent track) before this work landed; v3.7.x is closed.


2. Goal & Non-Goals

2.1 Goal

Add two new ingestion-time contamination signal fields, two new API protocol references, and one extended finalizer annotation matrix. All v3.9.0 additions are advisory-only. The v3.7.3 R-L3-2-A firm rule ("Contamination signals never block emission on their own") is preserved verbatim.

Concretely:

  1. Schema additions. literature_corpus_entry.schema.json gains two optional boolean fields inside the existing contamination_signals object: openalex_unmatched and crossref_unmatched. Both follow v3.7.3's omit-on-API-degradation discipline (absent ≠ false). Total contamination_signals field count rises from 2 to 4.

  2. bibliography_agent extension. A new ## Contamination Signal Computation (v3.9.0 Triangulation Extension) subsection adds OpenAlex and Crossref lookups alongside the existing Semantic Scholar check. Per-API graceful degradation. Manual entry exemption (obtained_via='manual') preserved.

  3. Two new API protocol references. deep-research/references/openalex_api_protocol.md and deep-research/references/crossref_api_protocol.md mirror the structure of semantic_scholar_api_protocol.md. Each documents query patterns, matching rules, rate limits, degradation handling, and a *_unmatched boolean derivation rule.

  4. Finalizer 4-tier advisory annotation. The v3.7.3 single-vector CONTAMINATED-UNMATCHED suffix becomes a triangulation-count-aware suffix family. The number of indexes returning unmatched (call it k, computed over present fields only) maps to a tier of advisory severity. All four tiers stay advisory — the terminal gate decision is unchanged.

  5. Migration tool. A scripts/migrate_literature_corpus_to_v3_9_0.py script walks an existing literature_corpus[], calls OpenAlex and Crossref for each entry whose contamination_signals.semantic_scholar_unmatched is already set, and adds the two new boolean fields. Idempotent. Dry-run mode. Manual entries skipped. API degradation tolerant.

Target outcome: a corpus that today carries only semantic_scholar_unmatched flags surfaces a richer triangulation signal at cite-time. References that are S2-unmatched but found by OpenAlex / Crossref drop from CONTAMINATED-UNMATCHED to CONTAMINATED-COVERAGE-NOISE (k=1, lowest advisory tier). References missing from all three indexes carry the new CONTAMINATED-TRIANGULATION-UNMATCHED (k=3) advisory marker — surfaced clearly, ack-able via /ars-mark-read. The user retains discretion across all tiers.

2.2 Non-Goals (explicitly deferred to v3.10)

The following are out of v3.9.0 scope:

2.3 v3.10 Forward Scope (acceptance criteria for the policy layer)

Superseded (framing only) by docs/design/2026-05-31-ars-v3.10-policy-layer-rescope-spec.md. A first-party spec-collision audit (2026-05-31) found the single shared triangulation_policy field named below collides with the shipped v3.9.4 temporal spec's same-named field, and the R-L3-2-A rule ID was overloaded by an unrelated claim-manifest rule family. The v3.10 rescope replaces the shared triangulation_policy field with a namespaced terminal_policies object (contamination_triangulation + forward-reserved temporal_integrity) and disambiguated the rule IDs (PR-A). The acceptance items below still describe v3.10's intent accurately (venue_type enum, no-_inferred provenance, strict / strict_articles_only behavior, R-L3-2-A reword) — only the field-name framing changed. See the rescope spec §1 for the collision analysis and §3-5 for the shipped contract.

To allow v3.9.0 to ship a stable measurement baseline without leaving the policy story open-ended, the following becomes the explicit v3.10 scope:

Item v3.10 acceptance criterion
venue_type schema enum Closed enum (journal-article / conference-paper / book / chapter / dissertation / preprint / report / dataset / other). Adapter-declared.
venue_type_provenance enum adapter_declared / user_declared / trusted_source_declared / unknown. OpenAlex/Crossref-inferred provenance is NOT a v3.10 acceptance value — k=3 case makes those indexes' classification untrusted by definition.
triangulation_policy passport field Enum: advisory (default, current v3.9.0 behavior) / strict / strict_articles_only.
Strict mode behavior k=3 promoted to HIGH-BLOCK (terminal gate refuses, NOT /ars-mark-read ack-able). Manual-entry exemption preserved.
strict_articles_only behavior k=3 promoted to HIGH-BLOCK only when (DOI present ∧ venue_type ∈ {journal-article, conference-paper} ∧ venue_type_provenance ∈ {adapter_declared, user_declared, trusted_source_declared}). Unknown / conflict / out-of-scope stay advisory.
R-L3-2-A amendment Reword from "Contamination signals never block emission on their own" to "By default, contamination signals never block emission on their own; user-enabled strict policies may promote specified signals to terminal blockers."

v3.9.0 spec includes these acceptance criteria above (§2.3 itself, plus §6 Out-of-Scope) so v3.10 work has a concrete target.


3. Design

3.1 Schema extension

shared/contracts/passport/literature_corpus_entry.schema.json contamination_signals object expands from 2 to 4 optional booleans:

contamination_signals:
  preprint_post_llm_inflection: true | false   # v3.7.3 — year >= 2024 AND venue is preprint server
  semantic_scholar_unmatched:   true | false   # v3.7.3 — S2 returned no match
  openalex_unmatched:           true | false   # v3.9.0 — OpenAlex returned no match
  crossref_unmatched:           true | false   # v3.9.0 — Crossref returned no match

All four fields are optional within the object. The entire contamination_signals object is optional at the entry level.

Field semantics (v3.7.3 inherited, restated for v3.9.0 cross-reference):

The exemption rule from v3.7.3 §3.2 carries over and extends symmetrically: when obtained_via='manual', bibliography_agent SHALL NOT run any of the three lookup checks (Semantic Scholar / OpenAlex / Crossref) and SHALL NOT set any of the three *_unmatched fields (the user has vouched for the entry; no triangulation check fires). However, preprint_post_llm_inflection IS still computed for manual entries — it is a pure heuristic over year + venue, not a lookup, and v3.7.3 schema already treats it as exempt from the manual-entry exemption (per the v3.7.3 schema description: only semantic_scholar_unmatched carries the manual-exemption clause; preprint_post_llm_inflection does not). v3.9.0 preserves this asymmetry: manual entries may carry contamination_signals: { preprint_post_llm_inflection: true|false } but MUST NOT carry any *_unmatched field.

3.2 Triangulation count derivation

The cite-time finalizer computes k as:

k = count of *_unmatched fields where value is `true`, over fields that are present (not absent)

k operates only on the three lookup fields (semantic_scholar_unmatched, openalex_unmatched, crossref_unmatched). preprint_post_llm_inflection is a heuristic, not a lookup; it stays a separate annotation suffix per v3.7.3 §3.2.

k_max (the number of present lookup fields) ranges from 0 to 3:

The annotation tier is determined by k AND k_max — see §3.3.

3.3 Finalizer 4-tier advisory annotation matrix

When the v3.7.3 5-cell base matrix (anchor / source_acquired / source_verified / human_read) resolves to ok or LOW-WARN, AND the entry's contamination_signals has at least one signal active (i.e., preprint_post_llm_inflection: true OR any of semantic_scholar_unmatched / openalex_unmatched / crossref_unmatched set to true), the finalizer adds an annotation suffix. Entries with the object present but all fields false (computed-and-clean) produce no suffix — the v3.7.3 "signals computed, no contamination evidence" case is preserved. v3.9.0 extends the v3.7.3 suffix table:

v3.7.3 suffix table (preserved):

Base preprint_post_llm_inflection semantic_scholar_unmatched Suffix
ok / LOW-WARN true absent or false CONTAMINATED-PREPRINT
ok / LOW-WARN false or absent true (single-index, v3.7.3 only — see migration note) CONTAMINATED-UNMATCHED (legacy)
ok / LOW-WARN true true CONTAMINATED-PREPRINT+UNMATCHED

v3.9.0 triangulation suffix table (extends v3.7.3):

Base k (triangulation count) k_max Suffix (literal marker text) Tier label (documentation category)
ok / LOW-WARN 0 0 no triangulation suffix —
ok / LOW-WARN 0 1–3 no triangulation suffix —
ok / LOW-WARN 1 1, present field = semantic_scholar_unmatched CONTAMINATED-UNMATCHED (v3.7.3 legacy preserved) LOW-ADVISORY
ok / LOW-WARN 1 1, present field ∈ {openalex_unmatched, crossref_unmatched} CONTAMINATED-COVERAGE-NOISE (new v3.9.0 degraded-S2 case) LOW-ADVISORY
ok / LOW-WARN 1 2–3 CONTAMINATED-COVERAGE-NOISE LOW-ADVISORY
ok / LOW-WARN 2 2–3 CONTAMINATED-PARTIAL-UNMATCH MED-ADVISORY
ok / LOW-WARN 3 3 CONTAMINATED-TRIANGULATION-UNMATCHED HIGH-ADVISORY

Marker-vs-label semantics: the Suffix column shows the literal token appended to the marker (e.g., <!--ref:slug LOW-WARN CONTAMINATED-TRIANGULATION-UNMATCHED-->). The Tier label column is a documentation/spec category for grouping — it does NOT appear in the marker text and is NOT emitted by the finalizer. Tier labels exist to discuss severity-equivalence between markers in spec text, lint, and migration log output; they are conceptual scaffolding, not runtime artifacts.

Critical naming rule (codex consult, 2026-05-17): the v3.9.0 tier labels (LOW-ADVISORY / MED-ADVISORY / HIGH-ADVISORY) deliberately drop the WARN token. v3.7.3's base resolution names (LOW-WARN / MED-WARN / HIGH-WARN) imply gate-blocking semantics in the 5-cell base matrix; reusing the -WARN root for v3.9.0's advisory triangulation tiers would conflate two distinct mechanisms. v3.10's strict mode will introduce HIGH-BLOCK as the gate-blocking counterpart, leaving HIGH-ADVISORY as the default tier and preserving a clean WARN (base) ↔ ADVISORY (contamination) ↔ BLOCK (strict policy) lexical separation.

Preprint suffix interaction (v3.7.3 + v3.9.0 composition): when preprint_post_llm_inflection: true AND triangulation tier fires, the suffix concatenates:

Preprint flag Triangulation tier Combined suffix
true k=1 (any k_max) CONTAMINATED-PREPRINT+COVERAGE-NOISE (or CONTAMINATED-PREPRINT+UNMATCHED for k_max=1 legacy when present field = semantic_scholar_unmatched)
true k=2 CONTAMINATED-PREPRINT+PARTIAL-UNMATCH
true k=3 CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED

The order is fixed: PREPRINT first, triangulation second, joined by +. The ordering rule is anchored to an explicit ordered token list (not to JSON Schema declaration order, which is not semantically guaranteed):

CONTAMINATION_SUFFIX_TOKEN_ORDER = [
  "PREPRINT",
  "UNMATCHED" | "COVERAGE-NOISE" | "PARTIAL-UNMATCH" | "TRIANGULATION-UNMATCHED",
]

The three lookup fields (semantic_scholar_unmatched, openalex_unmatched, crossref_unmatched) are aggregated into a single triangulation token via the §3.3 (k, k_max) tier table, so no internal order among them is exposed. Any future fifth contamination field MUST be added to CONTAMINATION_SUFFIX_TOKEN_ORDER by explicit spec amendment (appended at end by default, but order is the spec contract — not a positional default). This composes cleanly with v3.7.3's existing PREPRINT+UNMATCHED shape.

Firm rules (extending v3.7.3 §3.2):

3.4 OpenAlex API protocol

New file: deep-research/references/openalex_api_protocol.md. Mirrors the structure of semantic_scholar_api_protocol.md. Key elements:

Query patterns:

  1. DOI lookup (primary when DOI is available): GET /works/doi:{doi}?select=id,title,authorships,publication_year,doi,primary_location — exact match on DOI.

  2. Title search (fallback): GET /works?search={url_encoded_title}&per-page=5&select=id,title,authorships,publication_year,doi — full-text search. Apply Levenshtein similarity ≥ 0.70 against canonical title (consistent with semantic_scholar_api_protocol.md threshold).

Matching rule: identical Levenshtein threshold (0.70) and prefer-matching-year tiebreaker as the S2 protocol. If multiple candidates ≥ 0.70, prefer matching year; if still tied, prefer the entry with a populated DOI.

DOI cross-check rule (mirrors S2 protocol §3 DOI_MISMATCH pattern): when DOI lookup returns a hit, the returned title field MUST pass the same Levenshtein 0.70 threshold against the entry's canonical title. If DOI hit succeeds but title cross-check fails (the known hallucination pattern where a fabricated DOI resolves to an unrelated paper), the DOI hit is treated as a DOI_MISMATCH and falls through to title search; openalex_unmatched=false is set only when either DOI hit passes title cross-check or title search finds a match.

openalex_unmatched derivation: true if and only if (a) when DOI is present: DOI lookup either misses or fails title cross-check, AND title search returns no match meeting the threshold; OR (b) when DOI is absent: title search alone returns no match meeting the threshold. The check fires only when obtained_via != 'manual'.

Degradation handling: mirrors S2 protocol exactly — HTTP 429 (rate limit): back off 2 seconds, retry up to 3 times. HTTP 5xx or timeout: skip OpenAlex for this entry. After exhausting retries, bibliography_agent omits openalex_unmatched from the entry. Absence ≠ false. The [CORPUS API DEGRADATION] note is logged in the ingest report. Other indexes continue independently.

Discovery semantics — v3.9.0 explicit boundary: OpenAlex returns primary_location.source.type for matched entries. v3.9.0 ignores this field per R-L3-2-D. It is not stored on the entry, not surfaced to the finalizer, and not used in any derivation.

3.5 Crossref API protocol

New file: deep-research/references/crossref_api_protocol.md. Mirrors the same structure. Key elements:

Query patterns:

  1. DOI lookup (primary when DOI is available): GET /works/{doi} — exact DOI resolution. Crossref is the registry of record for DOIs.

  2. Title search (fallback): GET /works?query.title={url_encoded_title}&rows=5 — Crossref's structured title search. Apply Levenshtein similarity ≥ 0.70 against the canonical title.

Matching rule: identical threshold (0.70) and prefer-matching-year tiebreaker as S2 / OpenAlex.

DOI cross-check rule (mirrors §3.4 + S2 protocol DOI_MISMATCH pattern): DOI hits MUST pass Levenshtein 0.70 title cross-check before counting as a match. DOI hit with failed title check falls through to title search.

crossref_unmatched derivation: true if and only if (a) when DOI is present: DOI lookup either misses or fails title cross-check, AND title search returns no match meeting the threshold; OR (b) when DOI is absent: title search alone returns no match meeting the threshold. Same manual-exemption rule as §3.4.

Degradation handling: mirrors S2 protocol exactly — HTTP 429: 2s back off, retry up to 3 times. HTTP 5xx or timeout: skip Crossref for this entry. After retries exhausted, crossref_unmatched is omitted.

Crossref-specific caveat: Crossref's coverage is strongest for journal articles with DOIs. Coverage of monographs / chapters is partial (depends on publisher participation in DOI registration). This is a known limitation, not a bug. v3.9.0 surfaces crossref_unmatched as one of three signals; the user retains discretion. R-L3-2-D explicitly forbids using Crossref's coverage profile to derive a venue_type classification.

3.6 bibliography_agent extension

deep-research/agents/bibliography_agent.md gains a new subsection within the existing ## Contamination Signal Computation (v3.7.3) heading, titled ### Triangulation Extension (v3.9.0):

3.7 Migration tool

New file: scripts/migrate_literature_corpus_to_v3_9_0.py.

Purpose: walks an existing literature_corpus[] (typically loaded from a passport YAML), finds entries where contamination_signals.semantic_scholar_unmatched is set (i.e., v3.7.3-generated entries) but openalex_unmatched / crossref_unmatched are absent, and backfills the two new fields by calling the OpenAlex and Crossref APIs.

Scope boundary: the migration tool targets v3.7.3-onward entries only — entries with contamination_signals.semantic_scholar_unmatched already set. Pre-v3.7.3 entries (lacking the entire contamination_signals object) are out of v3.9.0 migration scope; users wanting to bring those entries forward must first run the v3.7.3 backfill tool (scripts/migrate_literature_corpus_to_v3_7_3.py, per contamination_signals_backfilled_at schema field) and then re-run this v3.9.0 tool. Daisy-chained migration keeps each version's contract well-bounded. This boundary is explicit so users with mixed-vintage corpora are not surprised by silently-skipped pre-v3.7.3 entries.

Behavior contract:

3.8 Lint additions

scripts/check_v3_9_0_triangulation.py — validates the v3.9.0 annotation marker shapes against the finalizer output AND verifies the formatter pass-through allowlist statically. Specifically:

  1. Marker syntax: CONTAMINATED-COVERAGE-NOISE / CONTAMINATED-PARTIAL-UNMATCH / CONTAMINATED-TRIANGULATION-UNMATCHED strings appear in finalizer output only when (k, k_max) match the §3.3 table.
  2. Preprint composition order: CONTAMINATED-PREPRINT+<triangulation> not <triangulation>+PREPRINT.
  3. v3.7.3 legacy compatibility: an entry with only semantic_scholar_unmatched: true (k=1, k_max=1) still produces CONTAMINATED-UNMATCHED (legacy suffix), not CONTAMINATED-COVERAGE-NOISE. Backward-compat preserved.
  4. No HIGH-BLOCK / BLOCK shapes appear in v3.9.0 output. Catches accidental introduction of policy-layer behavior.
  5. Formatter pass-through allowlist verification (R-L3-2-E lint): parse academic-paper/agents/formatter_agent.md and locate the "Contamination annotations ... DO NOT trigger refusal" paragraph (anchored at line ~295). Extract the backtick-quoted suffix tokens from that paragraph as a set, then assert set equality against the canonical 9-suffix set: 3 v3.7.3 legacy (CONTAMINATED-PREPRINT, CONTAMINATED-UNMATCHED, CONTAMINATED-PREPRINT+UNMATCHED) + 6 v3.9.0 (CONTAMINATED-COVERAGE-NOISE, CONTAMINATED-PARTIAL-UNMATCH, CONTAMINATED-TRIANGULATION-UNMATCHED, CONTAMINATED-PREPRINT+COVERAGE-NOISE, CONTAMINATED-PREPRINT+PARTIAL-UNMATCH, CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED). Fails on missing token, on extra token (catches accidental introduction of non-v3.9.0 markers), or on substring collision (e.g., CONTAMINATED-PREPRINT matched inside CONTAMINATED-PREPRINT+...). Substring matching is rejected by construction because tokens are parsed from backtick-delimited code spans.
  6. Refusal-list-unchanged guard: parse the formatter refusal rules block (the numbered list at lines ~273-285 starting with "1. ... 2. ... etc."). Extract each rule's quoted/code-spanned token. Assert that no CONTAMINATED-* token appears in any refusal rule. Catches accidental promotion of advisory tier to gate-refusal.
  7. CI integration: added to .github/workflows/spec-consistency.yml per v3.7.3 precedent.

3.9 Interaction with v3.7.3 / v3.8.x


4. Implementation Steps

Step T-1 — Schema + lint

Step T-2 — API protocols

Step T-3 — Agent extension

Step T-4 — Finalizer annotation + formatter pass-through

Phantom-file correction: earlier drafts of this spec referenced deep-research/agents/cite_provenance_finalizer_agent.md and cite_provenance_terminal_gate_agent.md. Neither file exists in the repo — the v3.7.3 spec also mistakenly referenced them and that error was inherited. The actual finalizer + gate surfaces are:

v3.9.0 changes:

The v3.7.3 spec target deep-research/agents/cite_provenance_finalizer_agent.md is a documentation artifact that survived from a design draft never reified as a separate file. A future cleanup PR could rename the references in v3.7.3 spec for accuracy, but that is out of v3.9.0 scope.

Step T-5 — Migration tool

Step T-6 — Documentation


5. Acceptance Criteria

ARS v3.9.0 ships when:

  1. Schema validates (additive + extended manual not-rule):
  2. synthetic literature_corpus_entry with contamination_signals: {openalex_unmatched: true, crossref_unmatched: false} passes check_literature_corpus_schema.py.
  3. Entry with only v3.7.3 fields also passes (backward compat).
  4. Manual entry with any of {semantic_scholar_unmatched, openalex_unmatched, crossref_unmatched} set (true or false) FAILS schema validation — the extended not-rule not: { anyOf: [...] } triggers. Three negative fixtures cover the three fields.
  5. Manual entry with contamination_signals: {preprint_post_llm_inflection: true} (and no lookup fields) PASSES — preprint flag is not subject to manual exemption.

1b. DOI cross-check fixture (mirrors S2 DOI_MISMATCH pattern, §3.4 / §3.5): mock OpenAlex (and separately Crossref) to return a DOI hit whose title field fails Levenshtein 0.70 against the entry's canonical title. Verify: openalex_unmatched (or crossref_unmatched) is set to true (the failed cross-check falls through to title search, which also misses). Conversely: DOI hit + matching title → false. Conversely: DOI absent + title search hits → false.

  1. Finalizer annotation correctness:
  2. Entry with (S2: true, OpenAlex: true, Crossref: true) AND base LOW-WARN produces <!--ref:slug LOW-WARN CONTAMINATED-TRIANGULATION-UNMATCHED--> marker.
  3. Entry with (S2: true, OpenAlex: false, Crossref: false) AND base LOW-WARN produces <!--ref:slug LOW-WARN CONTAMINATED-COVERAGE-NOISE-->.
  4. Entry with (S2: true, OpenAlex: absent, Crossref: absent) AND base LOW-WARN produces <!--ref:slug LOW-WARN CONTAMINATED-UNMATCHED--> (v3.7.3 legacy preserved).
  5. Entry with (S2: true, OpenAlex: true, Crossref: absent) AND base LOW-WARN produces <!--ref:slug LOW-WARN CONTAMINATED-PARTIAL-UNMATCH--> (k=2, k_max=2).
  6. Entry with preprint_post_llm_inflection=true AND k=3 produces <!--ref:slug LOW-WARN CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED-->.
  7. Audit-trail / reporting columns at the finalizer section emit per-suffix counts for the 6 new v3.9.0 shapes (CONTAMINATED-COVERAGE-NOISE, CONTAMINATED-PARTIAL-UNMATCH, CONTAMINATED-TRIANGULATION-UNMATCHED, plus three preprint composition variants), not just the 3 v3.7.3 legacy shapes. At least one fixture covers a synthetic corpus exercising all 6 shapes.

  8. Terminal gate unchanged: synthetic corpus with one entry carrying CONTAMINATED-TRIANGULATION-UNMATCHED advisory marker AND base resolution ok — terminal gate emits the output (advisory does not block). Same input under v3.7.3 produces equivalent behavior; v3.9.0 changes annotation richness, not gate decision.

  9. API protocol completeness: openalex_api_protocol.md and crossref_api_protocol.md each document query patterns, matching rule, rate limit, degradation handling, and *_unmatched derivation — structurally equivalent to semantic_scholar_api_protocol.md.

  10. bibliography_agent contract: the new ### Triangulation Extension (v3.9.0) subsection documents the parallel-lookup pattern, per-API degradation, manual exemption.

  11. Migration tool (stable-fields idempotency per §3.7): running migrate_literature_corpus_to_v3_9_0.py --dry-run against a fixture corpus reports the expected backfill diff without writing. Running without --dry-run produces no destructive overwrite — populated fields stay byte-stable across re-runs. Absent-due-to-degradation fields remain fillable on later re-runs. The fixture-based AC test MUST cover two scenarios: (a) full-API-success run → re-run produces identical file (stable case); (b) partial-degradation run → mock API recovery → re-run fills previously absent fields without touching already-populated ones (eligibility case).

  12. Lint: check_v3_9_0_triangulation.py 15+ tests covering: marker syntax (§3.8 rule 1), preprint composition order (rule 2), legacy compat (rule 3), no-HIGH-BLOCK (rule 4), formatter pass-through allowlist contains all 9 suffixes (rule 5, R-L3-2-E lint), refusal-list-unchanged (rule 6). At least one fixture per rule.

  13. No regression: v3.7.3's 967-test baseline (per .claude/CLAUDE.md v3.7.3 status) all PASS. v3.6.7 PATTERN PROTECTION 12/12 + 58/58 mutation tests unchanged. v3.7.1 / v3.7.2 lints unchanged.

  14. Cross-model review: dual-track codex (gpt-5.5 xhigh) + Gemini 3.1-pro-preview, target convergence within 4 rounds → 0 P1 + 0 P2 on both tracks (per feedback_cross_model_review_r1_r4_trajectory.md schema-level pattern: R1 4-7 → R4 0 ship). v3.9.0 spec scope (two bool fields, no gate change, no new agent) is smaller than v3.7.3 (11 rounds) and v3.8.0 (8 rounds), so the 4-round target reflects scope-adjusted expectation rather than v3.7.3's empirical count. Round budget may stretch if codex 0.130 docs-review unstable behavior (per feedback_codex_0_130_docs_review_broken.md) fires; in that case fall back to Gemini-only convergence with codex as insurance.


6. Out-of-Scope (Future Work)


7. Cross-References

External motivation

ARS prior art this builds on

Memory / discipline


8. Invariants + State Inventory (self-review per feedback_complex_spec_review_inventory_pattern.md)

8.1 Invariants

  1. R-L3-2-A preserved. Contamination signals never block emission on their own. (§3.3, §3.9)
  2. R-L3-2-B preserved. No retroactive computation in cite-time finalizer. (§3.3)
  3. R-L3-2-C consistent. k computed over present fields only; absent ≠ false. (§3.2)
  4. R-L3-2-D applied. No OpenAlex primary_location.source.type / Crossref type field used for v3.9.0 classification logic. (§3.4, §3.5)
  5. R-L3-2-E applied. Refusal list untouched; formatter_agent pass-through allowlist extends from 3 v3.7.3 suffixes to 9 (3 legacy + 6 v3.9.0). No new marker shape triggers refusal. (§3.3 R-L3-2-E, §4 Step T-4) Audit-trail / reporting columns within the Cite-Time Provenance Finalizer section of pipeline_orchestrator_agent.md also extend to surface the 6 new v3.9.0 shapes (per §4 Step T-4).
  6. Manual exemption preserved across all three lookups. (§3.4, §3.5, §3.6, §3.7)
  7. Backward compat: v3.7.3 single-vector entries produce CONTAMINATED-UNMATCHED legacy suffix. (§3.3, §3.8 lint #3)
  8. No HIGH-BLOCK introduced. All v3.9.0 markers stay advisory. (§3.3 naming, §3.8 lint #4)
  9. No venue_type schema field. v3.10 scope. (§2.2, §2.3)
  10. No triangulation_policy schema field. v3.10 scope. (§2.2)
  11. Omit-on-degradation discipline. Absence ≠ false across all three indexes. (§3.4, §3.5, §3.7)
  12. CI lint registered. New v3.9.0 lint in .github/workflows/spec-consistency.yml. (§4 Step T-1)

8.2 State Inventory — what v3.9.0 introduces, modifies, leaves untouched

INTRODUCED (new state):

Surface New element
Schema 2 optional boolean fields (openalex_unmatched, crossref_unmatched)
API protocols 2 new reference files (openalex_api_protocol.md, crossref_api_protocol.md)
Agent docs ### Triangulation Extension (v3.9.0) subsection in bibliography_agent.md
Finalizer 3 new suffix shapes (CONTAMINATED-COVERAGE-NOISE, CONTAMINATED-PARTIAL-UNMATCH, CONTAMINATED-TRIANGULATION-UNMATCHED) + 3 preprint composition shapes
Firm rules R-L3-2-C / R-L3-2-D / R-L3-2-E (new)
Lint check_v3_9_0_triangulation.py (new file). CI registration is a modification to existing .github/workflows/spec-consistency.yml — see MODIFIED table.
Migration tool migrate_literature_corpus_to_v3_9_0.py
Spec doc this file

MODIFIED (existing state extended, semantics preserved):

Surface Modification
contamination_signals object Optional field count 2 → 4
academic-pipeline/agents/pipeline_orchestrator_agent.md (Cite-Time Provenance Finalizer section) Suffix derivation logic extends from 3 legacy shapes (UNMATCHED, PREPRINT, PREPRINT+UNMATCHED) to 3 legacy + 6 new = 9 total runtime suffix shapes (legacy preserved). Audit-trail / reporting columns in this file may need parallel extension to surface the new shapes correctly — see §4 T-4.
academic-paper/agents/formatter_agent.md (contamination pass-through allowlist, line ~295) Allowlist 3 v3.7.3 suffixes → 9 suffixes (3 v3.7.3 legacy + 6 v3.9.0). Refusal list (rules 1-10) NOT touched.
.github/workflows/spec-consistency.yml Registers the new check_v3_9_0_triangulation.py lint step alongside existing v3.7.3 lint.
Schema not-rule (manual-entry exemption) Extends from "MUST NOT carry semantic_scholar_unmatched" to "MUST NOT carry any of semantic_scholar_unmatched / openalex_unmatched / crossref_unmatched"
check_literature_corpus_schema.py Accepts 2 new optional fields (additive) + tests extended not-rule
CHANGELOG.md v3.9.0 entry
.claude/CLAUDE.md New ## v3.9.0 Key Additions section

EXPLICITLY UNCHANGED (preserved per invariants):

Surface Preserved
academic-paper/agents/formatter_agent.md refusal list (rules 1-10) NOT extended — no new marker shape triggers refusal
5-cell base matrix (anchor / source_acquired / source_verified / human_read) unchanged
/ars-mark-read semantics unchanged
R-L3-2-A wording preserved verbatim (§3.3)
R-L3-2-B wording preserved verbatim (§3.3)
v3.7.3 CONTAMINATED-UNMATCHED legacy suffix preserved for backward compat
v3.7.3 CONTAMINATED-PREPRINT legacy suffix preserved
v3.6.7 PATTERN PROTECTION byte-equivalent
v3.7.1 / v3.7.2 lints unchanged
v3.8.x audit track orthogonal

8.3 Example-value self-check (per feedback_invariants_self_check_before_writing_examples.md)

Each illustrative example below was verified against the §3.3 table:


9. Risks & Mitigations

Risk Mitigation
Three APIs queried per entry inflates ingest latency. Parallel lookups (§3.6). Polite-pool email param for higher rate limits. Per-entry latency dominated by network RTT, not by additional indexes — adding two more parallel queries is small. Migration tool serializes by default (--max-concurrency flag tunable) to avoid burning polite-pool budget on large legacy corpora.
Three indexes are not fully independent — they share DOI/title metadata pipelines. Acknowledged in spec. Codex consult (2026-05-17, round 1) raised this explicitly; R1 dual-track review surfaced this again as the strongest design assumption. k=3 marker is CONTAMINATED-TRIANGULATION-UNMATCHED — describes the observable condition (all three lookup indexes failed to match), not the inferred cause (fabrication). This naming avoids over-claiming for humanities / non-English / dissertation references that legitimately fail triangulation due to shared coverage gaps. The user retains discretion (R-L3-2-A). v3.10 strict mode adds DOI + venue_type scoping precisely to mitigate genre-asymmetric false-positive risk — but v3.9.0 stays advisory across all genres.
Legitimate humanities monographs / non-English journals / dissertations trigger k=3. This is the intended behavior at the advisory tier — surface the signal, let the user decide. Acknowledgeable via /ars-mark-read. v3.10 strict_articles_only mode will scope hard-block to article-class works (when venue_type is adapter-declared). v3.9.0 does not introduce hard-block at all, so false-positive cost is bounded by user attention.
Migration tool burns polite-pool budget on large corpora. Tool serializes by default. Documented --max-concurrency=1 recommendation for first run on 1000+ entry corpora. Per-API retry policy (2s backoff, up to 3 retries on 429; skip on 5xx/timeout — aligned to S2 protocol per §3.4 / §3.5) prevents pool exhaustion.
v3.7.3 entries (k_max=1, only S2) get misleading "COVERAGE-NOISE" annotation. Spec §3.3 row explicitly handles k=1, k_max=1 case → keeps CONTAMINATED-UNMATCHED legacy suffix. Lint test #3 enforces this. Migration tool offers a path to promote legacy entries to k_max=3 markers.
v3.9.0 ships and v3.10 policy layer is never delivered, leaving an advisory-only baseline indefinitely. §2.3 forward scope codifies v3.10 acceptance criteria so the policy gap is explicit and tracked. Worst case the advisory baseline is itself a valid permanent state — the v3.7.3 R-L3-2-A philosophy explicitly supports advisory-only as a coherent design. Not delivering v3.10 is not a regression.
OpenAlex / Crossref API contracts change. Each protocol doc explicitly documents the dependent endpoints and field shapes. Breaking changes from the upstream API surface as graceful degradation (omit-on-failure) — bibliography_agent logs but does not abort. Long-term fix is a protocol-doc update + regression test refresh.
triangulation_unmatched_count computed in finalizer creates derived state not persisted in passport. Acceptable — k is computed deterministically from the 3 booleans at cite-time. No persistence needed. If future tooling wants persistent k, v3.10 can add an optional triangulation_count field. v3.9.0 stays minimal.
Codex review round count exceeds budget (v3.7.3 needed 11 rounds). Spec §8 invariants + state inventory provide an explicit anchor for round-by-round drift checks. Dual-track codex + Gemini per feedback_cross_model_review_r1_r4_trajectory.md should converge in 4-6 rounds for this scope.

10. Open Questions

The following remain genuinely open for the spec review phase:

10.1 Resolved during review

The following were originally listed as OQ but resolved during R1 / R2:


Appendix A: Migration scenarios

Scenario A1: legacy v3.7.3 corpus with obtained_via='zotero-bbt-export'

Before:

contamination_signals:
  preprint_post_llm_inflection: false
  semantic_scholar_unmatched: true

After running migration tool (assuming OpenAlex match, Crossref no match):

contamination_signals:
  preprint_post_llm_inflection: false
  semantic_scholar_unmatched: true
  openalex_unmatched: false
  crossref_unmatched: true

Finalizer behavior change: CONTAMINATED-UNMATCHED (legacy, k_max=1) → CONTAMINATED-PARTIAL-UNMATCH (k=2, k_max=3). Marker is more informative but still advisory. Gate unchanged.

Scenario A2: legacy entry with obtained_via='manual'

Migration tool skips entirely. Entry unchanged.

Scenario A3: legacy entry where OpenAlex API is down during migration

Migration tool runs Crossref (assume matches), omits openalex_unmatched, exits 0 with [CORPUS MIGRATION INCOMPLETE: openalex] per-entry log line.

Before:

contamination_signals:
  preprint_post_llm_inflection: false
  semantic_scholar_unmatched: true

After (partial backfill):

contamination_signals:
  preprint_post_llm_inflection: false
  semantic_scholar_unmatched: true
  crossref_unmatched: false

Finalizer: k=1, k_max=2 → CONTAMINATED-COVERAGE-NOISE. User retains discretion. Tool can be re-run later when OpenAlex API recovers (idempotent — semantic_scholar / crossref fields unchanged on second run).

Scenario A4: new entry ingested via bibliography_agent under v3.9.0

bibliography_agent runs all three lookups in parallel. If all three succeed, all three *_unmatched fields set. If any fails after 3 retries, that field omitted. Manual entries: all three skipped.


Appendix B: Review trail

This spec passed through dual-track review (codex gpt-5.5 xhigh + Gemini 3.1-pro-preview) with the following round-by-round finding counts. Specific finding IDs are kept here rather than in normative spec text so the contract sections remain durable across future amendments.

R1 (commit d9280bf) — 15 unique findings closed

R2 (this commit) — 12 unique findings closed

Ship signal (post-R3)

R1 (commit d9280bf) and R2 (commit 7d51215) both returned NEEDS-R3. R3 (this post-Task-1 commit) returned 0 P0 / 0 P1 / 4 P2 unique findings on both codex (gpt-5.5 xhigh) and Gemini (3.1-pro-preview) tracks. Both tracks declared READY-FOR-IMPL. The 4 P2 findings are addressed by Task 1 of this implementation plan (no R4 review round). Trajectory: R1 15 → R2 12 → R3 4 = converged per feedback_cross_model_review_r1_r4_trajectory.md schema-level pattern.


End of v3.9.0 design draft.