Files
imbad0202__academic-researc…/scripts/check_preprint_venues_consistency.py
Edward Cheng-I Wu 34a461dc17 feat(#105): v3.7.3 contamination_signals backfill migration tool (audit-trail-shipped) (#114)
* docs(#105): contamination signals backfill migration design

Minimal-scope design per user-chosen open Q resolution (2026-05-15):
- Q1 backoff-only, no resumable checkpoint
- Q2 scalar `contamination_signals_backfilled_at` ISO-8601 field
- Q3 directory-scan only, no batch mode
- Q4 ruamel.yaml round-trip (preserve comments + key order)

3 pieces: resolver module / CLI migration tool / additive schema field.
8 files touched (5 new + 3 extend); 8 files explicitly frozen.

v3.7.3 spec §3.2 R-L3-2-B already promised this batch operation exists;
#105 delivers it. No code change to bibliography_agent / orchestrator /
adapters — migration is downstream + offline.

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

* feat(#105): v3.7.3 contamination_signals backfill migration tool

Delivers the deferred batch operation v3.7.3 §3.2 R-L3-2-B promised:
bibliography_agent computes contamination_signals at ingest time; this
tool re-runs the check post-hoc on pre-v3.7.3 literature_corpus[]
entries so legacy corpora get the v3.7.3 protection layer.

Three pieces (per design 2026-05-15):
- scripts/contamination_signals.py: pure resolvers + emission rules +
  SemanticScholarClient Protocol (dependency-injected; tests mock).
- scripts/migrate_literature_corpus_to_v3_7_3.py: CLI with --dry-run,
  --verbose, single-passport + directory-scan (non-recursive). Uses
  ruamel.yaml round-trip to preserve comments + key order. Idempotent.
- shared/contracts/passport/literature_corpus_entry.schema.json: new
  optional contamination_signals_backfilled_at ISO-8601 string field
  (strictly additive; existing entries stay valid).

Open Q resolutions (user-chosen, minimal scope):
- Q1 backoff-only, no resumable checkpoint (YAGNI)
- Q2 scalar timestamp (upgrade path to structured if v3.7.4 needs)
- Q3 directory-scan only, no --input-list
- Q4 ruamel.yaml (preserve user passport formatting)

Spec discipline enforced:
- Manual exemption: semantic_scholar_unmatched OMITTED on
  obtained_via=manual (matches existing schema allOf rule #4)
- API degradation: field OMITTED, not set to False
  (absence ≠ negative confirmation)
- Idempotency: skip if contamination_signals already present;
  re-runs are byte-equivalent
- No retroactive computation in bibliography_agent (offline batch only)

Tests (TDD discipline):
- 25 resolver unit tests (Signal 1: 15 cases / Signal 2: 6 / emission: 4)
- 9 migration tool tests (dry-run / full / idempotency / insufficient /
  empty / 3× directory / round-trip preservation)
- 3 schema tests for the additive field

Regression: 1053 #108 + 17 #111 + 37 new = 1107 unittest + 201 pytest
adapters + check_spec_consistency all green.

Files explicitly NOT touched:
- bibliography_agent.md (ingest behavior frozen)
- pipeline_orchestrator_agent.md (finalizer unchanged)
- Existing scripts/adapters/* (migration is downstream)

Closes #105

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

* chore(#105): /simplify pass — 5 findings fixed + PREPRINT_VENUES lint

Three-agent /simplify (reuse / quality / efficiency) findings:

F1 (reuse) Reuse existing now_iso() instead of local _now_utc_iso:
  scripts/adapters/_common.py:164 already provides the canonical
  RFC-3339 helper used by 3 adapters. Replace local duplicate with
  `from adapters._common import now_iso`. Single source of truth for
  timestamp format.

F2 (reuse) PREPRINT_VENUES drift risk across 2 sources of truth:
  bibliography_agent.md prose list (line 265-274) and Python
  contamination_signals.PREPRINT_VENUES frozenset. Added
  scripts/check_preprint_venues_consistency.py lint extracting both
  and asserting set equality. Wired into spec-consistency.yml CI.
  Detection now structural, not visual.

F3 (quality) _is_insufficient missing venue check:
  Original only checked year. An entry with year=2024 but missing
  venue would pass through, get preprint_post_llm_inflection=false
  emitted (since venue not in PREPRINT_VENUES) — half-truth violating
  spec §3.2's "computed and clean" vs "not computed" distinction.
  Fix: skip when EITHER year OR venue is missing. Added negative test
  test_insufficient_data_missing_venue_skipped.

F4 (quality) skipped_manual_unmatched_omit counter naming:
  Counter lived in skip family but manual entries DO get patched
  (with semantic_scholar_unmatched field omitted, not the entry).
  Renamed to manual_unmatched_omitted; updated test reference.

F5 (quality) main() crash on NotImplementedError:
  _build_default_ss_client raises NotImplementedError — every CLI
  invocation hits this. Wrapped in try/except returning exit-2 with
  stderr message instead of raw traceback. Honest deferral now
  user-facing.

False positives skipped: SemanticScholarClient Protocol signature
(real client needs full entry); ruamel.yaml factor-on-2nd-use;
schema description bloat (spec traceability); 4 TestCase classes
(orthogonal surfaces); fixture duplication (different shapes).

Tests: 434 unittest (1 new venue-missing case) + 201 pytest adapters
+ 2 lints (spec_consistency + preprint_venues) all green.

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

* fix(#105): codex R1 [P1]+[P2]×2 — wire SS client + venue + recovery

Codex round-1 review caught three real issues that survived /simplify:

R1-1 [P1] Wire CLI to real Semantic Scholar client:
The original _build_default_ss_client() raised NotImplementedError —
every CLI invocation crashed before reading the passport. Migration
guide documented an unrunnable tool. Fix:
- New scripts/semantic_scholar_client.py: minimal SemanticScholarClient
  implementing the protocol at deep-research/references/
  semantic_scholar_api_protocol.md (DOI-first then title-similarity
  with Levenshtein 0.70 threshold, 429 → 2s backoff × 3, S2_API_KEY
  env var optional, 5xx / network failures raise SemanticScholarUnavailable).
- 10 new SS client tests covering DOI match / title-mismatch DOI hit /
  title search above & below threshold / 429 retry recovery / 429
  exhaustion → unavailable / 5xx → unavailable / 404 → no-match / network
  error → unavailable / CLI wiring constructs real client.

R1-2 [P2] Do not treat missing optional venue as unmigratable:
Original _is_insufficient skipped entries missing venue. But venue is
schema-OPTIONAL — compute_preprint_signal correctly returns False on
missing venue (venue not in PREPRINT_VENUES), which is a DEFINED
emission ("computed; not a preprint venue"), not half-truth. Skipping
prevented Signal 2 (SS lookup) from running on schema-valid entries.
Fix: _is_insufficient now only checks year (the unconditional AND
gate). Test test_missing_venue_does_not_skip_entry confirms.

R1-3 [P2] Allow re-runs to complete partial SS backfills:
Original idempotency rule was "skip if contamination_signals present".
This blocked recovery from an earlier API-degraded run where only
preprint_post_llm_inflection got written. Re-running with a healthy
API would never fill in semantic_scholar_unmatched. Fix: new
_is_complete() helper distinguishes COMPLETE (every computable field
present, accounting for manual exemption) from PARTIAL. Partial entries
get the missing fields merged in WITHOUT overwriting the original
backfilled_at timestamp. 2 new tests:
- test_partial_fill_recovery_fills_unmatched_field
- test_manual_entry_with_only_preprint_signal_is_complete

Removed test_insufficient_data_missing_venue_skipped (behavior reversed
per R1-2; new test_missing_venue_does_not_skip_entry asserts the
correct migration semantics).

Tests: 447 unittest (+13 from previous /simplify pass) + 201 pytest
adapters + spec consistency + preprint_venues all green.

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

* fix(#105): codex R2 [P2]×2 + [P3] — source_pointer / DOI fallback / partial no-op

R2-1 [P2] Honor source_pointer hints for preprints:
v3.7.3 spec §3.2 Vector 1 says "venue (or, when venue is absent,
inference from source_pointer)" — but resolver required explicit
venue. Schema-valid legacy entries with year>=2024, no venue, and a
preprint-URL source_pointer (https://arxiv.org/abs/2401...) were
incorrectly emitting preprint_post_llm_inflection=false. Fix: new
_POINTER_VENUE_HINTS table (10 substring patterns mirroring the 10-
venue closed list) + _infer_venue_from_pointer() fallback. Explicit
venue still takes precedence. 6 new resolver tests.

R2-2 [P2] DOI 404 / mismatch falls back to title search:
v3.7.3 Vector 2 says unmatched=true only when NEITHER DOI nor title
yields a hit. Original SS client returned no-match immediately on
DOI 404 or DOI_MISMATCH, falsely marking legitimate DOI-bearing
entries as unmatched. Fix: lookup() now falls through to title search
after DOI-miss before declaring unmatched. 3 new SS client tests
(404 → title-hit, DOI_MISMATCH → title-hit, double-404 → unmatched).

R2-3 [P3] Partial-fill recovery only counts when a field is added:
With persistent API degradation, build_signals_object() on a partial
entry returns only fields already present, so the merge loop adds
nothing. Original code still incremented patched + set mutated=True,
causing dry-run misreport + non-dry-run byte-identical rewrite. Fix:
track added_any; on no-op partial, increment skipped_already_migrated
and continue (no mutation). 1 new test verifying byte-identical
output + correct report counts.

Tests: 456 unittest (+9 R2 cases) + 201 pytest adapters + spec_consistency
+ preprint_venues all green.

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

* fix(#105): codex R3 [P2]+[P3] — wire migration tests into CI + verbose flag

R3-1 [P2] Run new migration tests in CI:
spec-consistency.yml previously ran only the PREPRINT_VENUES lint;
the new contamination_signals / migrate / SS client unittest modules
were uncovered. Future regressions could land while required checks
still pass. Fix: add explicit "Run #105 migration unit tests" step
running the three new test modules with -v output.

R3-2 [P3] Wire the advertised --verbose flag:
CLI exposed --verbose but never read args.verbose. Output was
identical to non-verbose, making the documented usage misleading.
Fix:
- migrate_passport gains `verbose: bool = False` kwarg
- migrate_directory plumbs it through
- main() passes args.verbose into both
- Verbose mode emits per-entry stderr lines tagged with passport path
  + citation_key + decision (skip/patch with reason)
- 1 new test verifies all 3 sample entries surface in --verbose stderr

Tests: 457 unittest (+1 verbose case) + spec_consistency green.

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

* fix(#105): codex R4 [P2]×2 — title normalize + response-read timeout

R4-1 [P2] Strip punctuation before title matching:
Protocol §"Query Patterns" Pattern 1 says title matching is "case-
insensitive, stripped of punctuation". Raw lowercased comparison
penalized punctuation differences ('R.A.G.' vs 'RAG' scored below
the 0.70 threshold, falsely marking legitimate hits as unmatched).
Fix:
- New _normalize_title(): lowercase + translate punctuation to spaces
  + collapse whitespace runs.
- _similarity() now normalizes both inputs before SequenceMatcher.
- 3 new tests (acronym crosses threshold, trailing punctuation OK,
  whitespace collapse verified).

R4-2 [P2] Wrap response read timeouts as unavailable:
resp.read() can raise OSError (socket.timeout subclasses OSError) or
TimeoutError outside the URLError handler. Previously this propagated
through compute_ss_unmatched_signal and aborted the migration instead
of the documented "omit field on degradation" behavior. Fix:
- Add `except (OSError, TimeoutError)` branch that wraps as
  SemanticScholarUnavailable with diagnostic message.
- 2 new tests verify OSError + TimeoutError each get wrapped.

Tests: 462 unittest (+5 R4 cases) + spec_consistency green.

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

* fix(#105): codex R5 [P2] — record provenance on partial-fill recovery

R5-1 [P2] Record provenance when filling partial signals:
When migration fills missing fields on an entry whose existing
contamination_signals was written by ingest-time bibliography_agent
during S2 degradation (no backfilled_at present), the post-hoc
mutation must be distinguishable from ingest-time data. Original code
added fields silently. Fix: when added_any and entry lacks
backfilled_at, set now_iso(). When entry already has the timestamp
(R1-3 case: prior migration run filled it), preserve the original —
that's the canonical backfill record.

2 new tests verify:
- Ingest-time partial without backfilled_at gets one on fill
- Pre-existing backfilled_at is preserved across re-runs

Deferred to follow-up issue per architectural-inflection discipline
(memory feedback_isms_v1_2_cascade_stop_at_r3): R5-2 (SS client
throttle to 1 req/s) + R5-3 (latch SS client unavailable after URLError
to skip remaining batch). Both are SS-client maturity issues — real
but tooling-side accumulation, not contract drift. Will file as
separate issue for #105 follow-up.

Tests: 464 unittest (+2 R5 cases) + spec_consistency green.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:45:09 +08:00

124 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""#105 lint — PREPRINT_VENUES 10-server list must agree across files.
v3.7.3 spec §3.2 Vector 1 fixes the closed list at 10 preprint servers
(gemini review F6 / codex F13 expansion from initial 6). The list lives
in two places:
1. `deep-research/agents/bibliography_agent.md` § "Signal 1 —
preprint_post_llm_inflection" — bullet list (prose source of truth
the bibliography_agent reads at ingest time).
2. `scripts/contamination_signals.py` `PREPRINT_VENUES` frozenset — the
migration tool's Python constant.
If these drift, post-hoc migration would surface different CONTAMINATED-
PREPRINT advisories than ingest-time computation. This lint extracts
both lists, compares as sorted sets, and fails on mismatch.
Exit codes:
0 = lists agree
1 = mismatch (prints both sets + the diff)
2 = could not parse either file (file move / heading rename / regex break)
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PROSE_PATH = REPO_ROOT / "deep-research" / "agents" / "bibliography_agent.md"
PYTHON_PATH = REPO_ROOT / "scripts" / "contamination_signals.py"
# Venue strings in the spec carry mixed casing + spaces (e.g.,
# "Research Square", "OSF Preprints"); normalize for comparison.
def _normalize(s: str) -> str:
return " ".join(s.split())
def _extract_prose_venues(text: str) -> set[str]:
"""Pull the bullet list under § Signal 1.
The prose block looks like:
2. The entry's `venue` field ...:
- arXiv
- bioRxiv
- medRxiv
...
- TechRxiv
We slice from "preprint_post_llm_inflection" heading to "Otherwise
set to" sentinel, then pull every `- <name>` line whose name comes
before any inline comment.
"""
start = text.find("preprint_post_llm_inflection")
end = text.find("Otherwise set to", start) if start >= 0 else -1
if start < 0 or end < 0:
raise RuntimeError(
"could not locate Signal 1 section in bibliography_agent.md "
"(heading or 'Otherwise set to' sentinel changed)"
)
block = text[start:end]
venues: set[str] = set()
for line in block.splitlines():
m = re.match(r"^\s*-\s+([A-Za-z0-9][A-Za-z0-9 .]*?)(\s*\(.*)?$", line)
if m:
venues.add(_normalize(m.group(1)))
return venues
def _extract_python_venues(text: str) -> set[str]:
"""Pull the items from the `PREPRINT_VENUES = frozenset({ ... })` literal."""
m = re.search(
r"PREPRINT_VENUES\s*=\s*frozenset\(\{([^}]+)\}\)",
text,
flags=re.DOTALL,
)
if not m:
raise RuntimeError(
"could not locate PREPRINT_VENUES literal in contamination_signals.py"
)
body = m.group(1)
venues = {
_normalize(s.strip().strip('"').strip("'"))
for s in body.split(",")
if s.strip().strip('"').strip("'")
}
return venues
def main() -> int:
try:
prose = _extract_prose_venues(PROSE_PATH.read_text(encoding="utf-8"))
py = _extract_python_venues(PYTHON_PATH.read_text(encoding="utf-8"))
except (FileNotFoundError, RuntimeError) as e:
print(f"PREPRINT_VENUES lint ERROR: {e}", file=sys.stderr)
return 2
if prose == py:
print(
f"PREPRINT_VENUES lint OK: {len(prose)} venues agree across "
f"bibliography_agent.md and contamination_signals.py."
)
return 0
only_prose = sorted(prose - py)
only_py = sorted(py - prose)
print("PREPRINT_VENUES lint FAILED — venue list drift detected:", file=sys.stderr)
if only_prose:
print(f" only in bibliography_agent.md: {only_prose}", file=sys.stderr)
if only_py:
print(f" only in contamination_signals.py: {only_py}", file=sys.stderr)
print(
"Reconcile by updating both files in lockstep. Spec §3.2 Vector 1 + "
"schema description in literature_corpus_entry.schema.json must also agree.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())