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, orCronCreate. It retrieves external prior work once and synthesizes it into one advisory memo. Even though it is memo-only (the adjudicator caps it atinfo, 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·/scheduleare fire-control, not a judge.)
Adapted from ARIS
novelty-check, with one deliberate reframing and one deliberate downgrade. The reframing: ARISnovelty-checkasks "is MY idea novel — should I PROCEED / ABANDON?" and hands the author aScore: 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.pylistsnovelty-duplication-advisoryinMEMO_ONLY_SKILLSand caps anything it emits atinfo. 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:
- 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.mdrule 1). The paper's title may be read from the source as a search seed only — it is never used as an anchor. - 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 andcitation-discipline). - 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.pyowns the verdict and caps this skill atinfo(references/reviewer-independence.mdLayer 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/comparisonclaim, OR (because the deterministic extractor types most abstract/intro contribution sentences asnumber/citation/scope, so the three contribution types alone are too thin to anchor to on a real ledger) any claim located in theabstract/introsection. The candidate prior work — its title, arXiv id / DOI / DBLP url, overlap kind — lives in the memo table and the finding'sdescription, never as the anchor span: there is no ledger claim for an external paper (same shape ascitation-forensics, where the DBLP facts go indescriptionand 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.jsonof 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.jsonfrom itscwd, 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 onlycandidate_ids that exist incandidates.json. - Two fresh threads, serial, no
codex-reply. The duplicate axis and the combination axis are independentmcp__codex__codexcalls; never carry one axis's conclusion into the other (the per-dimension bias guard).codex-replyis intentionally absent fromallowed-tools. - Detect-only. No
Editinallowed-tools; the reviewer sandbox isread-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):
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-ledgerfirst. 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-ledgeron 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 atinforegardless.
Create the trace directory now (forensic; written before any reviewer/retrieval call):
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.
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_seedverbatim. 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 isNone, 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:
[
{
"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):
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)