# Novelty Duplication Advisory

> MEMO-ONLY prior-work overlap advisory: surfaces the two ADVISORY taxonomy signals neither a tool nor a model can decide from the paper alone — ADV-TRIVIAL-COMBINATION (standard A+B+C / 缝合 stapling) and ADV-DUPLICATE-PUBLICATION (repackaged / duplicate submission). The executor RETRIEVES candidate prior work (DBLP fuzzy-title + boolean · WebSearch · WebFetch) from the paper's own title + contribution spans in the evidence ledger; TWO fresh cross-model codex reviewers (one per axis) LAY OUT the overlap side-by-side against each anchored contribution claim. It NEVER rules 'trivial' or 'duplicate' (that is a human judgment) and absence of a match is NOT evidence of originality. Emits novelty-duplication-advisory.memo.md + an info-only findings mirror; carries NO verdict weight — tools/adjudicate_findings.py lists it in MEMO_ONLY_SKILLS and caps it at info. Detect-only. Adapted from ARIS novelty-check, reframed from 'is MY idea novel' to 'here is the overlap a reviewer should weigh'. Triggers: "novelty advisory",

- Skill: `majiayu000/novelty-duplication-advisory` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds add majiayu000/novelty-duplication-advisory`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/novelty-duplication-advisory/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/novelty-duplication-advisory

---


# Novelty & Duplication Advisory — the overlap a reviewer should weigh

Lay out, for **$ARGUMENTS** (a paper-dir or a `claims.json` from `/evidence-ledger`), the
candidate prior-work overlap a human reviewer should weigh for two reviewer-judgment signals
— **trivial combination** ("standard A+B+C") and **duplicate publication** ("repackaged prior
work"). Retrieve candidates, map them side-by-side against the paper's ledger-anchored
contribution, and emit `novelty-duplication-advisory.memo.md`. Run AFTER `/evidence-ledger`
(so `claims.json` exists). This skill **decides nothing** — it never rules "trivial" or
"duplicate", and the deterministic adjudicator caps it at `info`.

> 🔒 **Do not wrap this skill in `/loop`, `/schedule`, or `CronCreate`.** It retrieves
> external prior work once and synthesizes it into one advisory memo. Even though it is
> **memo-only** (the adjudicator caps it at `info`, so it adds no verdict weight), the
> no-new-signal cadence rule still applies: its output changes only when the **ledger / the
> paper / the literature** change, never with the wall clock. It is tempting to re-fire on a
> timer "to catch newly-posted prior work," but a wall-clock loop burns real DBLP + web +
> cross-model budget on every tick for a paper that has not changed. Schedule the *work that
> precedes it* — ledger built → run this **once**. (Mirrors ARIS's external-cadence doctrine:
> `/loop`·`/schedule` are fire-control, not a judge.)

> Adapted from ARIS `novelty-check`, with **one deliberate reframing and one deliberate
> downgrade.** The reframing: ARIS `novelty-check` asks *"is MY idea novel — should I PROCEED
> / ABANDON?"* and hands the author a `Score: X/10` + a recommendation; this skill asks *"here
> is the overlap a third-party reviewer should weigh"* and hands the human candidates, not a
> verdict. The downgrade: it is **memo-only.** Novelty is the textbook example of a judgment
> that is **not decidable from the paper alone, and not decidable at any observability level**
> — it depends on a corpus you can never prove you searched exhaustively. So this skill
> **retrieves and lays out** overlap; it refuses to grade it. `tools/adjudicate_findings.py`
> lists `novelty-duplication-advisory` in `MEMO_ONLY_SKILLS` and caps anything it emits at
> `info`. The memo *informs*; the human *judges*; the deterministic adjudicator owns the
> report verdict — and this skill never moves it.

## Why this exists

Two complaints recur in real reviews of autoresearch (and rushed human) output, and neither
is an *internal*-consistency failure the other auditors catch — they are *relational* to the
wider literature:

- **"标准的 A + B + C，全是已知模块" / "缝合" (stapling)** — the paper bolts together three
  well-known techniques and presents the bundle as the contribution. Whether that bundle is a
  genuine advance or a trivial staple is a **reviewer judgment** — a surprising combination is
  publishable, an obvious one is not, and no tool can draw that line.
- **"这不就是 X 换了个壳" (repackaged / duplicate submission)** — the submission looks like a
  prior paper (often the authors' own) with a new title. An *exact* title/abstract/DOI match
  is reportable; the **absence** of a match proves nothing, because your search corpus is
  never complete.

Both are listed in `references/hack-pattern-taxonomy.md` (v0.4) under **Advisory signals (NOT
in the 39 · zero verdict weight · reviewer-judgment only)** — `ADV-TRIVIAL-COMBINATION` and
`ADV-DUPLICATE-PUBLICATION`. The taxonomy is explicit: *"Novelty is a reviewer judgment; the
tool can lay out the prior-work overlap, it cannot rule 'trivial'"* and *"the absence of a
match is **not** evidence of originality."*

So this skill does the one honest, high-leverage thing the other auditors do not: it **reaches
outside the paper** to *retrieve* candidate prior work, and **lays it out side-by-side**
against the paper's own contribution so a human can weigh novelty with the overlap in front of
them. It is the only auditor that consults an external corpus — which is exactly why it can
carry **no verdict weight**: the moment a tool grades novelty from an incomplete search, it
manufactures the "AI slop grading AI slop" failure this repo exists to refuse. It is the
literature-facing complement to `citation-forensics`: that skill audits the papers the
submission **does** cite; this one surfaces prior work it may **not** have cited at all.

## Core principle

**MEMO-ONLY · retrieve-don't-rule · ledger-anchored on the paper side · real-record-bound on
the prior-work side · cross-model, fresh thread per axis · reviewer ≠ adjudicator · never
rules novelty · absence ≠ originality.** Three honesty spines hold this skill up:

1. **Paper side is ledger-anchored.** The contribution being compared is pulled from
   `claims.json` (`claim_id` + verbatim span) — never re-invented from the raw PDF
   (`references/integrity-forensics-contract.md` rule 1). The paper's **title** may be read
   from the source *as a search seed only* — it is never used as an anchor.
2. **Prior-work side is real-record-bound.** Every candidate comes from a **real retrieval
   call** (DBLP / WebSearch / WebFetch) and carries a **verifiable identifier** (arXiv id /
   DOI / DBLP url). Nothing is recalled "from memory" — fabricating a prior paper here is the
   same sin as a hallucinated citation (mirrors ARIS `novelty-check`'s anti-hallucination rule
   and `citation-discipline`).
3. **No verdict — by design.** The cross-model reviewers **propose** an overlap map + the open
   questions; they are forbidden to conclude "trivial" or "duplicate." The executor
   **validates** anchors; `tools/adjudicate_findings.py` **owns the verdict** and caps this
   skill at `info` (`references/reviewer-independence.md` Layer 2). A retrieval that finds
   nothing is a valid output that says **nothing** about novelty — "no candidate overlap
   found" is not "the paper is original."

> **Deliberate exception to "the reviewer reads only the ledger."** The other auditors reason
> strictly inside the paper. This one must consult an external corpus, so the executor
> performs the retrieval and hands each reviewer a *structured candidates file* (real records,
> with identifiers) alongside the ledger. That file is retrieval output, **not** a Claude
> opinion or digest of the paper — so the spirit of reviewer-independence (no executor
> judgment leaks into the reviewer prompt) still holds.

> **The anchor is the contribution sentence, not the prior work.** Every surfaced item anchors
> to one of the submission's own **contribution claims** — a `scope` / `method` / `comparison`
> claim, OR (because the deterministic extractor types most abstract/intro contribution
> sentences as `number` / `citation` / `scope`, so the three contribution *types* alone are
> too thin to anchor to on a real ledger) any claim located in the `abstract` / `intro`
> section. The candidate prior work — its title, arXiv id / DOI / DBLP url, overlap kind —
> lives in the memo table and the finding's `description`, **never** as the anchor span: there
> is no ledger claim for an external paper (same shape as `citation-forensics`, where the DBLP
> facts go in `description` and the anchor is the citing sentence).

## How this differs from the other auditors (route correctly)

| Auditor | Question it answers | External lookup? | Verdict weight |
|---------|---------------------|:---:|:---:|
| `consistency-audit` | Does the paper contradict ITSELF / described method = evaluated method? | no | yes (via adjudicator) |
| `experiment-forensics` | Are reported numbers what the code computes? (fake GT, self-norm, phantom) | no | yes (L2) |
| `baseline-comparison-audit` | Right baselines present, tuned, "SOTA" earned? | profile only | yes |
| `citation-forensics` | Do the *cited* papers EXIST and support the claim they are used for? | yes (existence/context of *cited* works) | yes |
| `proof-derivation-forensics` | Does the written proof / derivation hold? | no | yes |
| `presentation-signals` | Surface "AI-flavor" hints (auxiliary) | no | capped at minor |
| `adversarial-case-builder` | Strongest *anchored* rejection memo + defense | no | none (memo-only) |
| **`novelty-duplication-advisory`** (this) | **What prior-work OVERLAP should a reviewer weigh for trivial-combination / duplicate?** | **YES — retrieves *uncited* prior work** | **none (memo-only, capped at info)** |

**Route, do not overreach.** `citation-forensics` checks the works the paper *already cites*;
this skill goes looking for prior work the paper *omits or overlaps*. A **wrong-context or
fabricated citation** belongs to `citation-forensics`. A **"SOTA / first / beats prior work"**
claim that needs a baseline comparison belongs to `baseline-comparison-audit` (this skill can
emit `needs_external_check` and hand it off). An **internal** scope-overclaim ("comprehensive"
on thin scope) belongs to `consistency-audit` (`HP-SCOPE-INFLATE`). This skill owns *only* the
two advisory overlap signals — and even those it only *surfaces*.

## How this differs from ARIS `novelty-check` (the parent)

| | ARIS `novelty-check` | `novelty-duplication-advisory` (forensics) |
|---|---|---|
| Frame | "is MY idea novel — PROCEED / ABANDON?" | "here is the overlap a REVIEWER should weigh" |
| Subject | the author's own prospective idea | a third party's submitted paper |
| Paper side | a free-text method description | **ledger claims** (`claim_id` + verbatim span) |
| Output | a `Score: X/10` + recommendation + "suggested positioning" | a side-by-side overlap memo — **no score, no recommendation** |
| Verdict | "Novelty: HIGH/MEDIUM/LOW" | **none** — never rules trivial/duplicate; capped at `info` |
| Empty retrieval | "looks novel, proceed" | "no candidate overlap found — this says **nothing** about novelty" |
| Prior-work anchoring | `verify_papers.py` (anti-hallucination) | every candidate a resolved record in `candidates.json`; unresolved → dropped |

The multi-source retrieval and the cross-model verification are kept *exactly* (they are the
load-bearing parts). What changes is the refusal to grade: a forensics tool that scored
novelty from an incomplete corpus would be precisely the over-claim this repo refuses.

## Constants & Reviewer Calling Convention

```
REVIEWER_MODEL       = gpt-5.5                # different family from the executor (Claude)
REVIEWER_REASONING   = xhigh                  # always; effort never lowers reviewer quality
REVIEWER_SANDBOX     = read-only              # detect-only; never mutate the paper
REVIEWER_CWD         = <paper-dir>            # so it reads claims.json + candidates.json from cwd
THREAD_POLICY        = TWO fresh mcp__codex__codex calls — ONE per axis (duplicate / combination);
                       NEVER mcp__codex__codex-reply across them (the per-dimension bias guard)
CONCURRENCY          = serial                 # Codex MCP hangs on concurrent calls — Thread 2 waits for Thread 1
AXES                 = duplicate (ADV-DUPLICATE-PUBLICATION) | combination (ADV-TRIVIAL-COMBINATION)
OBS_REQUIRED         = 0 for both             # decidable-as-advisory from text + the public corpus (no repo/results)
RETRIEVAL_SOURCES    = DBLP (fuzzy_title_search + boolean search) · WebSearch · WebFetch (abstracts)
ANTI_HALLUCINATION   = every candidate comes from a REAL retrieval call + carries a verifiable id +
                       is a record in candidates.json; the reviewer cites candidate_id ONLY (never "from memory")
ANCHOR_UNIVERSE      = contribution claims = type∈{scope,method,comparison} OR section∈{abstract,intro,introduction};
                       prior work goes in the memo/description, NOT the anchor span
DISPOSITION          = candidate_overlap_surfaced | no_candidate_overlap_found | retrieval_incomplete
                       (INFORMATIONAL, NOT a verdict; "no overlap found" ≠ "the paper is original")
ADVISORY_PATTERNS    = ADV-TRIVIAL-COMBINATION · ADV-DUPLICATE-PUBLICATION   (zero verdict weight)
TAXONOMY_VERSION     = 0.5                     # references/hack-pattern-taxonomy.md
MEMO_FILE            = novelty-duplication-advisory.memo.md            # canonical human-facing output
FINDINGS_FILE        = novelty-duplication-advisory.findings.json      # info-only mirror (or []); globbed, capped at info
PROFILE_FILE         = novelty-duplication-advisory.profile.json       # executor-built retrieval profile (from the ledger)
CANDIDATES_FILE      = novelty-duplication-advisory.candidates.json    # retrieved prior work (REAL records only)
TRACE_POLICY         = forensic (never silently dropped)
TRACE_DIR            = .aris/traces/novelty-duplication-advisory/<YYYY-MM-DD>_run<NN>/
```

- **Executor (Claude)** owns the **retrieval** and **none of the judgment**: it pulls the
  contribution spans from the ledger, reads the title from the source as a search seed, runs
  the external searches, assembles a structured `candidates.json` of **real returned records**,
  passes **the ledger + the candidates file + the per-axis checklist** to each reviewer,
  validates every anchor the reviewers return, and renders the memo. It never summarizes the
  paper, pre-judges overlap, or leaks an opinion into a prompt
  (`references/reviewer-independence.md`).
- **Reviewer (codex / gpt-5.5, xhigh, read-only)** reads `claims.json` + `candidates.json`
  from its `cwd`, lays each candidate beside the contribution span it overlaps with, and lists
  the open questions. It is the overlap-mapper, **not** the judge — and it is **forbidden** to
  output a "trivial" / "duplicate" / "not novel" verdict. It cites only `candidate_id`s that
  exist in `candidates.json`.
- **Two fresh threads, serial, no `codex-reply`.** The duplicate axis and the combination axis
  are independent `mcp__codex__codex` calls; never carry one axis's conclusion into the other
  (the per-dimension bias guard). `codex-reply` is intentionally absent from `allowed-tools`.
- **Detect-only.** No `Edit` in `allowed-tools`; the reviewer sandbox is `read-only`. This
  skill never touches the audited paper.

---

## Step 0 — Preconditions: locate the ledger, read the run level, open the trace

This skill reasons over the **ledger** (paper side) + **retrieved candidates** (prior-work
side) — never the raw PDF for structure. Resolve the ledger and read the observability level
**L**, `paper_id`, and the count of contribution claims (each Bash block is self-contained —
shell state does not persist between calls, so re-derive paths every step):

```bash
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
# $ARGUMENTS is a paper-dir OR a claims.json path:
LEDGER="$ARGUMENTS"; [ -d "$LEDGER" ] && LEDGER="$LEDGER/claims.json"
# Only the NO-ARGUMENT case defaults to the CWD ledger. An EXPLICIT argument that
# resolves to a missing claims.json must NOT silently fall back to $(pwd) — that could
# advise on the wrong paper; let the NO_LEDGER check below fire instead.
[ -z "$ARGUMENTS" ] && LEDGER="$(pwd)/claims.json"
python3 - "$LEDGER" <<'PY'
import json, sys, os
p = sys.argv[1]
if not os.path.isfile(p):
    sys.exit("NO_LEDGER: claims.json not found. Run /evidence-ledger FIRST "
             "(it writes artifact_manifest.json + claims.json).")
d = json.load(open(p, encoding="utf-8"))
CONTRIB_TYPES = {"scope", "method", "comparison"}
CONTRIB_SECT  = {"abstract", "intro", "introduction"}
def sect(c): return ((c.get("location") or {}).get("section") or "").lower()
contrib = [c for c in d.get("claims", [])
           if c.get("claim_id") and c.get("text_span")
           and (c.get("type") in CONTRIB_TYPES or sect(c) in CONTRIB_SECT)]
print("LEDGER         =", os.path.abspath(p))
print("PAPER_DIR      =", os.path.dirname(os.path.abspath(p)) or ".")
print("PAPER_ID       =", d.get("paper_id", "?"))
print("RUN_LEVEL_L    =", d.get("observability_level", 0))
print("CONTRIB_CLAIMS =", len(contrib), "(scope/method/comparison + abstract/intro — the anchor universe)")
PY
```

**Carry forward** the absolute `LEDGER` / `PAPER_DIR`, plus `L` and `PAPER_ID`, into every step
below.

**Failure / edge handling.**
- **`NO_LEDGER`** → stop and tell the user to run `/evidence-ledger` first. This skill never
  re-reads the raw PDF and invents its own structure (contract rule 1).
- **`CONTRIB_CLAIMS == 0`** → there is no contribution claim to anchor to. Recall scales with
  the ledger: an **L0 PDF-text** ledger extracts mostly number/scope spans, so contribution
  anchoring is thin; the richer abstract/intro spans enter via the **L1 LaTeX** path. Prefer
  re-running `/evidence-ledger` on the LaTeX source. If you cannot, skip the reviewer call,
  write the honest-null memo + an empty findings file directly (Step 4's honest-null snippet),
  and stop. Never invent a contribution claim.
- **Observability level does not gate *whether* this skill runs.** Retrieval needs only the
  title/contribution text, available at L0+. There is no graded verdict to gate — the judgment
  is not decidable at *any* level (it is a human call). The info-only mirror carries
  `observability_level_required: 0`, and the MEMO gate caps it at `info` regardless.

Create the trace directory now (forensic; written before any reviewer/retrieval call):

```bash
PAPER_DIR="<abs PAPER_DIR from Step 0>"
DATE=$(date +%F); N=1
while [ -d "$PAPER_DIR/.aris/traces/novelty-duplication-advisory/${DATE}_run$(printf %02d $N)" ]; do N=$((N+1)); done
TRACE="$PAPER_DIR/.aris/traces/novelty-duplication-advisory/${DATE}_run$(printf %02d $N)"
mkdir -p "$TRACE"; echo "TRACE = $TRACE"   # carry this absolute path into every later step
```

Write `"$TRACE/run.meta.json"` (via **Write**) =
`{"skill":"novelty-duplication-advisory","paper_id":"<PAPER_ID>","run_level_L":<L>,"taxonomy_version":"0.5","retrieval":{"duplicate":"pending","combination":"pending"},"generated_at":"<UTC ISO-8601>"}`.

## Step 1 — Build the retrieval profile from the ledger (executor, deterministic)

Pull the paper's **contribution** (the thing whose novelty a reviewer weighs) straight from the
ledger — `scope` / `method` / `comparison` claims plus anything in the `abstract` / `intro`
sections — and read the **title** from the source as a *search seed*. These spans are the
**paper-side anchors**; the executor never paraphrases them and never invents a contribution
the ledger does not contain.

```bash
LEDGER="<abs claims.json from Step 0>"
PAPER_DIR="$(dirname "$LEDGER")"
python3 - "$LEDGER" "$PAPER_DIR" <<'PY'
import json, sys, os, re
ledger_path, paper_dir = sys.argv[1], sys.argv[2]
d = json.load(open(ledger_path, encoding="utf-8"))
claims = d.get("claims", []); PID = d.get("paper_id", "?")
src = d.get("source_files", []) or []
def sect(c): return ((c.get("location") or {}).get("section") or "").lower()

CONTRIB_TYPES = {"scope", "method", "comparison"}
CONTRIB_SECT  = {"abstract", "intro", "introduction"}
PRI = {"abstract", "intro", "introduction"}
CUE = re.compile(r"\b(we\s+(propose|present|introduce|develop|design|show|demonstrate)|"
                 r"our\s+(approach|method|framework|model|contribution|key\s+idea)|"
                 r"in\s+this\s+(paper|work)|the\s+first\s+to|novel|contributions?\s+(are|of))\b", re.I)

ranked = []
for c in claims:
    if not c.get("claim_id") or not c.get("text_span"):
        continue
    s = sect(c)
    if not (c.get("type") in CONTRIB_TYPES or s in CONTRIB_SECT):
        continue
    span = c["text_span"]
    score = (2 if s in PRI else 0) + (2 if CUE.search(span) else 0) + (1 if c.get("type") in CONTRIB_TYPES else 0)
    ranked.append((score, {"claim_id": c["claim_id"], "type": c.get("type", "?"),
                           "section": s or "?", "text_span": span}))
ranked.sort(key=lambda x: -x[0])
contrib = [c for _, c in ranked][:10]

# title SEARCH SEED (never an anchor): prefer a title-section ledger claim, else \title{...}
# from a latex source (brace-matched), else the first substantive line of the pdf text.
def strip_tex(s):
    s = re.sub(r"\\thanks\{[^}]*\}", " ", s)
    s = re.sub(r"\\[a-zA-Z]+\*?", " ", s)
    return " ".join(s.replace("{", " ").replace("}", " ").replace("\\", " ").split())
def title_from_latex(txt):
    m = re.search(r"\\title\s*(\[[^\]]*\])?\s*\{", txt)
    if not m: return None
    i, depth = m.end(), 1
    while i < len(txt) and depth:
        depth += (txt[i] == "{") - (txt[i] == "}"); i += 1
    t = strip_tex(txt[m.end():i-1]); return t if len(t) >= 6 else None

title, title_src = None, None
for c in claims:
    if sect(c) == "title" and c.get("text_span"):
        title, title_src = c["text_span"], "ledger:title-claim"; break
if not title:
    for s in src:
        if s.get("kind") == "latex" and os.path.isfile(s.get("path", "")):
            t = title_from_latex(open(s["path"], encoding="utf-8", errors="replace").read())
            if t: title, title_src = t[:240], "source:" + os.path.basename(s["path"]); break
if not title:
    for s in src:
        if s.get("kind") in ("text", "pdf") and os.path.isfile(s.get("path", "")):
            for ln in open(s["path"], encoding="utf-8", errors="replace"):
                ln = ln.strip()
                if len(ln) >= 12 and not ln.lower().startswith(("arxiv", "http", "doi")):
                    title, title_src = ln[:240], "source:" + os.path.basename(s["path"]); break
        if title: break
if not title and contrib:
    title, title_src = contrib[0]["text_span"][:240], "fallback:abstract-claim"

prof = {"paper_id": PID, "title_seed": title, "title_seed_source": title_src,
        "title_seed_is_anchor": False, "contribution_claims": contrib,
        "n_contribution_claims": len(contrib)}
out = os.path.join(paper_dir, "novelty-duplication-advisory.profile.json")
json.dump(prof, open(out, "w", encoding="utf-8"), indent=2, ensure_ascii=False)
print("PROFILE  =", out)
print("TITLE    =", (title or "(none — combination axis only)")[:120], "| source:", title_src)
print("CONTRIB  =", len(contrib))
for c in contrib[:10]:
    print(f"  [{c['claim_id']}] ({c['section']}/{c['type']}) {c['text_span'][:100]}")
if not contrib:
    print("NO_CONTRIB: ledger has no scope/method/comparison/abstract/intro claim — "
          "cannot build a retrieval query. Treat like CONTRIB_CLAIMS==0 (Step 4 honest-null).")
PY
```

**Sanity gate (before searching).** `title_seed` should look like a paper title and `CONTRIB`
should be > 0 for any normal paper. If a span looks truncated or mis-sectioned, **Read**
`claims.json` and spot-check that contribution `claim_id`'s `text_span` before you build a
query from it — a malformed seed wastes the external-search budget. Never fabricate a title or
a contribution to fill a gap.

**Building the queries (literal terms only).** From `profile.json`:
- **Title (for the duplicate axis).** Use `title_seed` verbatim. It is a **query seed, not an
  anchor** (`title_seed_is_anchor: false`); re-opening a source to quote it is permitted
  because it is a query input, not a finding. If it is `None`, run only the combination axis.
- **Constituent techniques (for the combination axis).** Decompose the contribution into ≤4
  named techniques using **literal terms copied from the contribution spans** (the method
  names, the architecture, the training objective). Do **not** introduce vocabulary the spans
  do not contain — that would manufacture overlap. If the contribution is one atomic method (no
  decomposition into ≥2 known components), the combination axis is **N/A**; say so and run only
  the duplicate axis.

**Failure handling.** `NO_CONTRIB` → write the honest-null memo + empty findings (Step 4
snippet) and stop. A thin ledger may yield a weak query — note that limitation in the memo; do
not pad the query with guessed terms.

## Step 2 — Retrieve candidate prior work (executor; real records only)

The executor runs the searches and assembles `candidates.json`. **This step records FACTS,
never a ruling** — whether a candidate is "the same paper", a benign extended version, or
unrelated is the reviewer's lay-out (Step 3) and ultimately the human's call. **Every record
must come from a real call below and carry a verifiable identifier.** Never add a paper you
"remember."

**2a — Duplicate axis (DBLP fuzzy title + exact-phrase web).** A near-identical title / DOI to
a *different* paper is the strongest reportable duplicate-candidate (taxonomy: *"an exact
title/abstract/DOI match is reportable"*). Call DBLP with the profile title:

```
mcp__mcp-dblp__fuzzy_title_search:
  title: "<title_seed from profile.json>"
  similarity_threshold: 0.7
  max_results: 10
  include_bibtex: false
```

Then a web pass for an exact-phrase / preprint match (DBLP indexes venues, not all preprints):

```
WebSearch:
  query: "\"<the exact paper title>\""          # quoted: catch a repackaged/duplicate posting
WebSearch:
  query: "<paper title, unquoted> arxiv"        # catch a near-duplicate preprint
```

For the top title-similar hits (and any exact web hit), `WebFetch` the abstract to record a
snippet for the side-by-side (the abstract is what lets the reviewer judge *degree* of
overlap):

```
WebFetch:
  url: "<arxiv abs / DOI / DBLP ee url of the candidate>"
  prompt: "Return ONLY: the paper title, the author list, the venue+year, and the verbatim
           first 2-3 sentences of the abstract. No commentary."
```

**2b — Combination axis (per-technique prior work).** For each constituent technique from Step
1, find the canonical prior work that *establishes* it — the work a reviewer would cite to call
it "well-known." Use DBLP boolean search (terms joined by `and`; **parentheses are
unsupported**) and/or WebSearch:

```
mcp__mcp-dblp__search:
  query: "<technique-A literal terms> and <technique-A qualifier>"
  max_results: 8
  year_from: 2015
mcp__mcp-dblp__search:
  query: "<technique-B literal terms> and <technique-B qualifier>"
  max_results: 8
WebSearch:
  query: "<technique-C literal terms> method  (survey OR original)"
```

Record the 1–2 most representative prior works per technique. The goal is to lay out *"the
contribution = A [prior work] + B [prior work] + C [prior work]"* for the human — **not** to
conclude the staple is trivial.

**2c — Assemble + validate `candidates.json`.** Use **Write** to create
`<PAPER_DIR>/novelty-duplication-advisory.candidates.json` from the **actual returned records**
— one object per candidate, exactly these keys:

```json
[
  {
    "candidate_id": "K01",
    "source": "dblp_fuzzy_title | dblp_boolean | websearch | webfetch",
    "title": "<verbatim returned title>",
    "authors": ["<as returned, if available>"],
    "venue": "<as returned>",
    "year": 2024,
    "identifier": {"arxiv": "", "doi": "", "dblp_url": "", "url": "<at least ONE non-empty>"},
    "title_similarity": 0.83,
    "abstract_snippet": "<verbatim from WebFetch, if fetched>",
    "retrieved_for": "duplicate | combination:A | combination:B | combination:C",
    "query": "<the exact query string used>"
  }
]
```

Then run the validation + self-record gate (de-dups by identifier, flags the paper's own record
so it is never mis-reported as a duplicate, and refuses memory-sourced entries):

```bash
PAPER_DIR="<abs PAPER_DIR from Step 0>"
CAND="$PAPER_DIR/novelty-duplication-advisory.candidates.json"
PROF="$PAPER_DIR/novelty-duplication-advisory.profile.json"
python3 - "$CAND" "$PROF" <<'PY'
import json, sys, re
cand_path, prof_path = sys.argv[1], sys.argv[2]
prof = json.load(open(prof_path, encoding="utf-8"))
def norm(s): return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip()
ptitle = norm(prof.get("title_seed"))
pauthors = {norm(a) for a in (prof.get("authors") or []) if a}   # audited paper's authors (for self-record check)
try:
    arr = json.load(open(cand_path, encoding="utf-8"))
except Exception as e:
    sys.exit(f"CANDIDATES_PARSE_FAILED: {e} — fix candidates.json (assemble from REAL calls only).")
SRC = {"dblp_fuzzy_title", "dblp_boolean", "websearch", "webfetch"}
seen, clean, dropped = set(), [], 0
for c in arr:
    if not isinstance(c, dict): dropped += 1; continue
    ident = c.get("identifier") or {}
    idval = next((v for v in (ident.get("arxiv"), ident.get("doi"),
                              ident.get("dblp_url"), ident.get("url")) if v), None)
    if c.get("source") not in SRC or not c.get("title") or not idval:
        dropped += 1; continue                      # no real source / no identifier -> not a real record
    key = idval.strip().lower()
    if key in seen: continue                        # de-dup by identifier
    seen.add(key)
    c["candidate_id"] = "K%02d" % (len(clean) + 1)  # re-id deterministically
    # self-record guard: a near-identical title MAY be the AUDITED paper's own record (its
    # preprint/venue copy). But title-similarity ALONE is not enough — a real duplicate-
    # publication by DIFFERENT authors can share a near-identical title. Treat as the paper's
    # OWN record (and exclude) ONLY when title is near-identical AND authorship overlaps; if the
    # title matches but authorship can't be confirmed, SURFACE it flagged for human author-check
    # rather than silently dropping a possible real duplicate.
    sim = c.get("title_similarity")
    title_match = bool(ptitle) and (norm(c.get("title")) == ptitle
                                    or (isinstance(sim, (int, float)) and sim >= 0.95))
    cand_auth = {norm(a) for a in (c.get("authors") or []) if a}
    authors_overlap = bool(pauthors and cand_auth and (pauthors & cand_auth))
    c["self_record_suspected"]   = bool(title_match and authors_overlap)        # confirmed own record -> exclude
    c["self_record_unconfirmed"] = bool(title_match and not authors_overlap)    # near title, authorship unverified -> surface + flag
    clean.append(c)
json.dump(clean, open(cand_path, "w", encoding="utf-8"), indent=2, ensure_ascii=False)
dup = sum(1 for c in clean if c["retrieved_for"] == "duplicate" and not c["self_record_suspected"])
comb = sum(1 for c in clean if str(c["retrieved_for"]).startswith("combination"))
self_n = sum(1 for c in clean if c["self_record_suspected"])
print(f"candidates kept={len(clean)} (duplicate-axis usable={dup}, combination-axis={comb}, "
      f"self-record-suspected={self_n}) dropped(no-source/no-id/malformed)={dropped} -> {cand_path}")
if not clean:
    print("NO_CANDIDATES: retrieval surfaced nothing usable. This is a VALID result and says "
          "NOTHING about novelty (absence of a match is not evidence of originality). "
          "Step 4 -> disposition=no_candidate_overlap_found.")
PY
```

`candidates.json` lives in `PAPER_DIR` so each reviewer reads it from its `cwd`. When done,
update `"$TRACE/run.meta.json"` `retrieval` to record per-axis status (`"done"` /
`"unavailable"`). **Failure handling.** `CANDIDATES_PARSE_FAILED` → the assembled JSON is
malformed; rebuild it from the real returned records (never hand-fabricate). `NO_CANDIDATES` →
skip Step 3, go to Step 4's honest-null path. If a DBLP/web tool errors or web access is
unavailable for a whole axis, set that axis to `"unavailable"` in `run.meta.json` → the run
becomes `retrieval_incomplete` (Step 4), and the memo will say the search could not be
completed and therefore concludes **nothing** about originality. Do **not** backfill from
memory.

## Step 3 — Cross-model overlap mapping (TWO fresh per-axis threads; never rules)

Issue **two fresh** `mcp__codex__codex` calls — **one per axis, serial** (Codex MCP hangs on
concurrent calls; never `codex-reply`, never carry one axis into the other). Each reviewer reads
`claims.json` + `candidates.json` from its `cwd` and lays out overlap. Each is told —
repeatedly — that it must **not** conclude "trivial" or "duplicate."

### Thread 1 — duplicate axis (tag `001`)

```
mcp__codex__codex:
  model: gpt-5.5
  config: {"model_reasoning_effort": "xhigh"}
  sandbox: read-only
  cwd: <absolute PAPER_DIR from Step 0>
  prompt: |
    You are an integrity-forensics reviewer preparing a NEUTRAL, ADVISORY prior-work
    overlap brief for a human area chair on ONE question: where does this submission's
    contribution OVERLAP with a candidate that may be a repackaged / duplicate publication?
    You lay the overlap out side-by-side. You are FORBIDDEN to render a judgment: never
    output "duplicate", "plagiarized", "not novel", "derivative", or "reject". Duplication
    is the human's call; you only surface the overlap so they can weigh it.

    INPUTS — read these directly from your working directory:
      - claims.json — the evidence ledger. The contribution lives in its scope / method /
        comparison claims AND in any abstract/intro claim {claim_id, type, text_span
        (VERBATIM), location}. These contribution claims are the ONLY anchor universe; do
        NOT invent a claim that is not in it.
      - novelty-duplication-advisory.candidates.json — REAL retrieved prior work, one record
        per candidate {candidate_id (e.g. K01), title, authors, venue, year, identifier,
        title_similarity?, abstract_snippet?, retrieved_for, self_record_suspected}. These
        are FACTS, not a verdict. Consider candidates with retrieved_for=="duplicate". You
        may cite ONLY candidate_ids present here — never recall a paper from memory, never
        invent ids/titles. A candidate not in this file is DELETED downstream as a hallucination.
    RUN OBSERVABILITY LEVEL L = <L from Step 0>.

    WHAT TO DO — for each non-self candidate that genuinely overlaps the submission's
    title / contribution, emit ONE overlap item that:
      * anchors to the most specific contribution claim it overlaps with (a VERBATIM
        substring of that claim's text_span);
      * names the candidate by its EXACT candidate_id and states the overlap_kind
        (near_exact_title | abstract_overlap | same_core_contribution) using the file's
        title/abstract — never memory;
      * if self_record_suspected==true, the candidate is very likely THIS paper's own
        preprint/venue copy — EXCLUDE it from the duplicate axis (you may note it was excluded);
      * describes the RESIDUAL DELTA: what the submission still claims BEYOND the candidate
        (descriptive only — NOT a "the delta is too small" ruling).

    HARD RULES (an item that breaks any of these is worthless):
    1. ANCHOR. Every item carries >=1 anchor {claim_id, span} where claim_id is a
       contribution claim in claims.json and span is a VERBATIM, whitespace-normalized
       SUBSTRING of THAT claim's text_span (copy LaTeX escapes like \% exactly; do NOT
       unescape/paraphrase). The candidate goes in candidate_ids/the description, NEVER in
       span. No anchor -> drop the item.
    2. CANDIDATES FROM THE FILE ONLY. Every candidate_id must appear in candidates.json.
       Never fabricate a paper. A false "duplicate" is a serious error.
    3. NEVER RULE. Do NOT classify the paper duplicate/novel. reviewer_action is what a human
       should WEIGH or CHECK ("compare the method against K01 and judge whether the
       contribution is subsumed"), never "reject"/"duplicate".
    4. ABSENCE IS NOT ORIGINALITY. If no non-self candidate genuinely overlaps, emit an EMPTY
       array []. Do NOT conclude the paper is original — the memo states absence of a match
       is not evidence of originality.
    5. OBSERVABILITY = 0 for every item.
    6. pattern_id is exactly "ADV-DUPLICATE-PUBLICATION".

    OUTPUT: a single JSON array, and NOTHING ELSE (no prose, no code fence). Each element:
      {
        "id": "O1",
        "axis": "duplicate",
        "pattern_id": "ADV-DUPLICATE-PUBLICATION",
        "label": "short neutral label",
        "overlap_statement": "~30 words: what part of the contribution overlaps which candidate",
        "anchors": [{"claim_id": "C0xx", "span": "verbatim substring of that contribution claim"}],
        "candidate_ids": ["K01"],
        "overlap_kind": "near_exact_title | abstract_overlap | same_core_contribution",
        "residual_delta_note": "~40 words: what the submission still claims beyond the candidate (descriptive, NOT a ruling)",
        "reviewer_action": "what the human should WEIGH/CHECK — never 'duplicate'/'reject'"
      }
    An empty array [] is a valid, honest result.
```

### Thread 2 — combination axis (tag `002`, a SECOND fresh thread)

```
mcp__codex__codex:
  model: gpt-5.5
  config: {"model_reasoning_effort": "xhigh"}
  sandbox: read-only
  cwd: <absolute PAPER_DIR from Step 0>
  prompt: |
    You are an integrity-forensics reviewer preparing a NEUTRAL, ADVISORY prior-work brief
    for a human area chair on ONE question: the submission combines known techniques — which
    components are individually ESTABLISHED in prior work, and does the COMBINATION itself
    already appear somewhere? You lay this out side-by-side. You are FORBIDDEN to render a
    judgment: never output "trivial", "incremental", "mere stapling", "缝合", "not novel",
    or "reject". Whether a combination is a real contribution is the human's call.

    INPUTS — read directly from your working directory:
      - claims.json — the contribution lives in scope/method/comparison claims AND in any
        abstract/intro claim {claim_id, type, text_span (VERBATIM), location}. ONLY anchor
        universe; do not invent claims.
      - novelty-duplication-advisory.candidates.json — REAL retrieved prior work. Consider
        candidates with retrieved_for starting "combination". Cite ONLY candidate_ids
        present here; never recall a paper from memory.
    RUN OBSERVABILITY LEVEL L = <L from Step 0>.

    WHAT TO DO:
      1. From the contribution claims, identify the component techniques the paper combines
         (A, B, C ...). If it does not decompose into >=2 known components, emit [] (the
         combination axis is N/A — say nothing, do not force a decomposition).
      2. For EACH component the candidates show is established in prior work, emit one item
         anchored to the contribution claim that introduces it, citing the component's
         established prior work by candidate_id.
      3. Optionally emit one item for THE COMBINATION if the candidates show the same
         combination already exists. If no such candidate was retrieved, do NOT infer
         "novel" — simply omit it.
      4. In residual_delta_note, describe what the paper claims is NEW about the combination
         (mechanism / setting / result) — descriptive only.

    HARD RULES (same discipline as the duplicate axis):
    1. ANCHOR every item to a VERBATIM substring of a contribution claim. Prior work goes in
       candidate_ids/description, NEVER in span. No anchor -> drop the item.
    2. CANDIDATES FROM THE FILE ONLY — never fabricate prior work.
    3. NEVER RULE — no "trivial"/"incremental"/"novel"; reviewer_action is what the human
       should WEIGH ("assess whether combining K05 and K06 for this task is a contribution
       beyond the components"), never a verdict.
    4. ABSENCE IS NOT ORIGINALITY — if no component overlap is in the file, emit []. Do not
       conclude the paper is novel.
    5. OBSERVABILITY = 0 for every item.
    6. pattern_id is exactly "ADV-TRIVIAL-COMBINATION".

    OUTPUT: a single JSON array, NOTHING ELSE. Each element:
      {
        "id": "O1",
        "axis": "combination",
        "pattern_id": "ADV-TRIVIAL-COMBINATION",
        "label": "component or combination label",
        "overlap_statement": "~30 words: which component/combination overlaps which prior work",
        "anchors": [{"claim_id": "C0xx", "span": "verbatim substring of the contribution claim"}],
        "candidate_ids": ["K05"],
        "overlap_kind": "component_established | combination_appears",
        "residual_delta_note": "~40 words: what the paper claims is new about the combination (descriptive, NOT a ruling)",
        "reviewer_action": "what the human should WEIGH — never 'trivial'/'reject'"
      }
    An empty array [] is a valid, honest result.
```

**Persist immediately,

…(truncated)
