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.
v3.7.3 §3.2 shipped two contamination signal vectors:
preprint_post_llm_inflection — heuristic (year ≥ 2024 ∧ venue ∈ closed 10-server preprint list)semantic_scholar_unmatched — single-index lookup against the Semantic Scholar API (per deep-research/references/semantic_scholar_api_protocol.md)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.
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:
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.
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.
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.
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.
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.
The following are out of v3.9.0 scope:
triangulation_policy passport field. Adding a policy field with only one valid value ("advisory") is meaningless. v3.10 introduces the field when multiple policies exist.venue_type schema field. v3.7.3 already carries a free-form venue string. Inferring a structured venue_type enum from free-form text or from OpenAlex primary_location.source.type / Crossref type fields creates fake precision — particularly fragile in the k=3 case, where OpenAlex / Crossref are themselves the unmatched indexes whose classification is therefore unavailable or untrusted. v3.10 will add venue_type as an explicit adapter-declared field with provenance tracking.venue_type_provenance schema field. Same reasoning — without an underlying venue_type field, provenance has nothing to track.CONTAMINATED-TRIANGULATION-UNMATCHED annotation in v3.9.0 stays advisory (HIGH-ADVISORY register, not HIGH-BLOCK). The terminal gate refusal list is not extended. The R-L3-2-A firm rule from v3.7.3 §3.2 is preserved without exception.strict, no strict_articles_only, no opt-in policy switch. v3.10 introduces these once venue_type + venue_type_provenance are in place./ars-mark-read behavior change. The acknowledge command continues to apply across all advisory contamination tiers as in v3.7.3. No new escape hatch required because no new block is introduced.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 sharedtriangulation_policyfield named below collides with the shipped v3.9.4 temporal spec's same-named field, and theR-L3-2-Arule ID was overloaded by an unrelated claim-manifest rule family. The v3.10 rescope replaces the sharedtriangulation_policyfield with a namespacedterminal_policiesobject (contamination_triangulation+ forward-reservedtemporal_integrity) and disambiguated the rule IDs (PR-A). The acceptance items below still describe v3.10's intent accurately (venue_type enum, no-_inferredprovenance, 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.
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):
openalex_unmatched not set but semantic_scholar_unmatched set): that specific index's check was not run, OR the API was degraded at ingest time and the field was omitted by graceful-degradation. Absence ≠ false. The triangulation count k is computed over present fields only.false: the index was queried and returned a match. Positive evidence that the reference exists in that index.true: the index was queried and returned no match per the matching rule.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.
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:
k_max = 0: no lookup fields present (all three APIs were degraded or skipped at ingest, OR the entry pre-dates v3.7.3). No triangulation annotation fires. The entry behaves as v3.6.8 / pre-contamination would.k_max = 1: one lookup present (typically v3.7.3 legacy entries with only semantic_scholar_unmatched, or new entries where 2 APIs were degraded). k ∈ {0, 1}.k_max = 2: two lookups present. k ∈ {0, 1, 2}.k_max = 3: all three lookups present (normal v3.9.0 ingest path). k ∈ {0, 1, 2, 3}.The annotation tier is determined by k AND k_max — see §3.3.
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):
*_unmatched fields that are present. Absent fields are excluded from the count and do not default to either true or false. k_max reflects how many lookups were successfully run; the (k, k_max) pair together determines the annotation tier.primary_location.source.type and Crossref's type fields, even when returned by the APIs for matched entries, MUST NOT be used to derive any classification (venue_type, scope category, hard-block eligibility) within v3.9.0. The k=3 case makes those classifications structurally unavailable; including them in any v3.9.0 logic creates fake precision. v3.10 will introduce adapter-declared venue_type with explicit provenance tracking.CONTAMINATED-COVERAGE-NOISE, CONTAMINATED-PARTIAL-UNMATCH, CONTAMINATED-TRIANGULATION-UNMATCHED, and their three preprint composition shapes); without this extension, formatter_agent's existing v3.7.3 pass-through rule (line 295 — explicitly allowlists only CONTAMINATED-PREPRINT, CONTAMINATED-UNMATCHED, CONTAMINATED-PREPRINT+UNMATCHED) would route unknown v3.9.0 suffixes through formatter rule 4 ("status neither ok nor LOW-WARN-acknowledged") and accidentally gate-refuse them — the exact opposite of advisory-only intent. The fix is pass-through-list expansion, not refusal-list change. /ars-mark-read semantics are unchanged. Any future hard-block behavior is v3.10 scope and requires a separate spec.New file: deep-research/references/openalex_api_protocol.md. Mirrors the structure of semantic_scholar_api_protocol.md. Key elements:
https://api.openalex.orgmailto=<user_email> query param for higher rate limits. Env var: OPENALEX_POLITE_EMAIL (optional).Query patterns:
DOI lookup (primary when DOI is available):
GET /works/doi:{doi}?select=id,title,authorships,publication_year,doi,primary_location — exact match on DOI.
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.
New file: deep-research/references/crossref_api_protocol.md. Mirrors the same structure. Key elements:
https://api.crossref.orgUser-Agent: ARS-bibliography_agent/v3.9.0 (mailto:<email>) per Crossref polite-pool convention. Env var: CROSSREF_POLITE_EMAIL (optional).Query patterns:
DOI lookup (primary when DOI is available):
GET /works/{doi} — exact DOI resolution. Crossref is the registry of record for DOIs.
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.
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):
*_unmatched field is set independently. Degradation of one index does not affect the others.*_unmatched fields absent. preprint_post_llm_inflection is still computed (pure heuristic, no lookup) — so a manual entry may exit with contamination_signals: { preprint_post_llm_inflection: true } and no *_unmatched fields.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:
--dry-run) — prints what would change without modifying the file. In dry-run, the report distinguishes "would-add" (eligible for fill) from "would-skip" (already populated or manual).obtained_via='manual' are skipped entirely for the lookup checks. The tool does NOT recompute preprint_post_llm_inflection for any entry (that field is owned by bibliography_agent at ingest time, not by the migration tool).openalex_unmatched, crossref_unmatched). No extra schema-extension scope creeps in.*_unmatched: true, how many carry false, how many remained absent due to degradation, and how many were skipped (manual or already-populated). Per-entry log line on degradation: [CORPUS MIGRATION INCOMPLETE: <index>] (aligned with §10 OQ-2 unified logging contract).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:
CONTAMINATED-COVERAGE-NOISE / CONTAMINATED-PARTIAL-UNMATCH / CONTAMINATED-TRIANGULATION-UNMATCHED strings appear in finalizer output only when (k, k_max) match the §3.3 table.CONTAMINATED-PREPRINT+<triangulation> not <triangulation>+PREPRINT.semantic_scholar_unmatched: true (k=1, k_max=1) still produces CONTAMINATED-UNMATCHED (legacy suffix), not CONTAMINATED-COVERAGE-NOISE. Backward-compat preserved.HIGH-BLOCK / BLOCK shapes appear in v3.9.0 output. Catches accidental introduction of policy-layer behavior.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.CONTAMINATED-* token appears in any refusal rule. Catches accidental promotion of advisory tier to gate-refusal..github/workflows/spec-consistency.yml per v3.7.3 precedent.ok / LOW-WARN annotation channel.CONTAMINATED-UNMATCHED legacy suffix: preserved as the marker for v3.7.3-era entries where only Semantic Scholar was queried. Migration tool can promote these to k_max=3 markers post-backfill.uncited_audit_failures[] is unrelated to triangulation counts.contamination_signals remains an AI-owned field per existing convention. Adapters MAY set the new fields but are not required to.shared/contracts/passport/literature_corpus_entry.schema.json — three changes:openalex_unmatched, crossref_unmatched) inside contamination_signals.properties. Update inline description with cross-reference to this spec doc.not: { required: ["semantic_scholar_unmatched"] }. Extend to forbid any of the three lookup fields: use not: { anyOf: [{ required: ["semantic_scholar_unmatched"] }, { required: ["openalex_unmatched"] }, { required: ["crossref_unmatched"] }] }. Manual entries that carry preprint_post_llm_inflection continue to pass (preprint is heuristic, not a lookup — see §3.1).contamination_signals to enumerate all four fields and note the manual-entry exemption applies to the three lookup fields only.scripts/check_literature_corpus_schema.py — accept the two new fields (additive; existing fixtures stay valid). Add fixtures covering: manual entry with preprint_post_llm_inflection: true (must pass), manual entry with any *_unmatched field set (must fail), non-manual entry with arbitrary combinations (must pass).scripts/check_v3_9_0_triangulation.py — new lint per §3.8..github/workflows/spec-consistency.yml — register the new lint.deep-research/references/openalex_api_protocol.md — new file per §3.4.deep-research/references/crossref_api_protocol.md — new file per §3.5.deep-research/agents/bibliography_agent.md — extend ## Contamination Signal Computation (v3.7.3) with ### Triangulation Extension (v3.9.0) subsection per §3.6. Existing v3.7.3 content stays byte-equivalent.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:
academic-pipeline/agents/pipeline_orchestrator_agent.md (the 5-cell base matrix + v3.7.3 contamination annotation derivation lives in this file's "Cite-Time Provenance Finalizer" section, around lines 640-780).academic-paper/agents/formatter_agent.md (the refusal-list + contamination pass-through allowlist lives around lines 260-300).v3.9.0 changes:
academic-pipeline/agents/pipeline_orchestrator_agent.md — extend the Cite-Time Provenance Finalizer section to compute k (per R-L3-2-C) and emit the v3.9.0 (k, k_max) suffix shapes per §3.3 table. v3.7.3 legacy CONTAMINATED-UNMATCHED suffix preserved for k=1 k_max=1 case where the present field is semantic_scholar_unmatched (per §3.3 row). Audit-trail / reporting columns within this finalizer section (if it currently enumerates only v3.7.3 contamination shapes) MUST be extended to include the 6 new v3.9.0 shapes, otherwise downstream reporting silently drops or misclassifies v3.9.0 markers. This is a content-level edit, not just a logic-level edit.academic-paper/agents/formatter_agent.md — extend the contamination pass-through allowlist (the v3.7.3 line ~295 "Contamination annotations ... DO NOT trigger refusal" sentence). Allowlist additions: CONTAMINATED-COVERAGE-NOISE, CONTAMINATED-PARTIAL-UNMATCH, CONTAMINATED-TRIANGULATION-UNMATCHED, CONTAMINATED-PREPRINT+COVERAGE-NOISE, CONTAMINATED-PREPRINT+PARTIAL-UNMATCH, CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED. The refusal list (rules 1-10 at lines 273-279) is NOT extended. This is what R-L3-2-E enforces: gate refusal list unchanged; gate pass-through allowlist must grow to accommodate the new advisory suffixes.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.
scripts/migrate_literature_corpus_to_v3_9_0.py — new file per §3.7.CHANGELOG.md — add v3.9.0 entry under ## [Unreleased]..claude/CLAUDE.md — add ## v3.9.0 Key Additions section.ARS v3.9.0 ships when:
literature_corpus_entry with contamination_signals: {openalex_unmatched: true, crossref_unmatched: false} passes check_literature_corpus_schema.py.{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.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.
<!--ref:slug LOW-WARN CONTAMINATED-TRIANGULATION-UNMATCHED--> marker.<!--ref:slug LOW-WARN CONTAMINATED-COVERAGE-NOISE-->.<!--ref:slug LOW-WARN CONTAMINATED-UNMATCHED--> (v3.7.3 legacy preserved).<!--ref:slug LOW-WARN CONTAMINATED-PARTIAL-UNMATCH--> (k=2, k_max=2).<!--ref:slug LOW-WARN CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED-->.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.
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.
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.
bibliography_agent contract: the new ### Triangulation Extension (v3.9.0) subsection documents the parallel-lookup pattern, per-API degradation, manual exemption.
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).
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.
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.
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.
contamination_signals. Defer — current AI-owned-field convention is simpler and per-entry latency is not currently a bottleneck.docs/design/2026-05-12-ars-v3.7.3-claim-faithfulness-and-contaminated-source-spec.md) — §3.2 contamination_signals object, finalizer annotation channel, manual-entry exemption, omit-on-API-degradation discipline.deep-research/references/semantic_scholar_api_protocol.md — structural template for openalex_api_protocol.md and crossref_api_protocol.md.feedback_codex_consult_before_user_options.md (2026-05-17, NEW) — design/option questions go through codex consult before reaching the user. v3.9.0 brainstorm validated this rule: codex 3-round consult shifted scope from (B hard-block proposal in issue #102) → (C opt-in profile) → (β measurement-only, v3.10 policy split). Each round caught a Claude-side blind spot.feedback_codex_workflow_consolidated.md — codex 9 rules total reference.feedback_cross_model_review_r1_r4_trajectory.md — dual-track schema-level PR convergence pattern (R1 4-7 → R4 0).feedback_complex_spec_review_inventory_pattern.md — invariants + state inventory discipline for review rounds (this spec's §8).feedback_invariants_self_check_before_writing_examples.md — INVARIANTS 12-check before example values (this spec's §8 self-check passes).feedback_writing_plans_grep_verify_real_api.md — Tier 1–3 verification before plan code skeletons; applied to OpenAlex / Crossref protocol docs.feedback_ars_public_repo_boundary.md — public-repo PII scan before push (this spec passes the personal-boundary deny-list scan; sensitive internal/public-repo-boundary tokens reviewed and not present in normative text).feedback_test_time_bomb_freeze_clock.md — no hardcoded current-date "past time" tests in v3.9.0 test surface.feedback_complex_spec_review_inventory_pattern.md)primary_location.source.type / Crossref type field used for v3.9.0 classification logic. (§3.4, §3.5)pipeline_orchestrator_agent.md also extend to surface the 6 new v3.9.0 shapes (per §4 Step T-4).CONTAMINATED-UNMATCHED legacy suffix. (§3.3, §3.8 lint #3)venue_type schema field. v3.10 scope. (§2.2, §2.3)triangulation_policy schema field. v3.10 scope. (§2.2).github/workflows/spec-consistency.yml. (§4 Step T-1)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 |
feedback_invariants_self_check_before_writing_examples.md)Each illustrative example below was verified against the §3.3 table:
CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED": preprint suffix table row matches; composition order PREPRINT-first respected.| 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. |
The following remain genuinely open for the spec review phase:
triangulation_unmatched_count (k) be exposed as an optional persistent field in contamination_signals, or stay finalizer-derived? Codex consult (2026-05-17, round 3) noted the choice depends on whether ARS persists derived annotations elsewhere. Default in this draft: derived, not persisted. Revisit during codex review.contamination_signals_schema_version: "v3.9.0")? Default: no — keeping the schema additive without version fields preserves the v3.6.4 design philosophy. Revisit if downstream tooling needs to distinguish.The following were originally listed as OQ but resolved during R1 / R2:
[CORPUS MIGRATION INCOMPLETE: <index>] + aggregate summary at end of run. §3.7, Appendix A.3, and check_v3_9_0_triangulation.py all use the same prefix.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.
obtained_via='manual'Migration tool skips entirely. Entry unchanged.
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).
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.
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.
d9280bf) — 15 unique findings closedLIKELY-FABRICATED → TRIANGULATION-UNMATCHED via codex consult round 2.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.