| name | novelty-duplication-advisory |
| description | 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", "duplication check", "prior-work overlap", "is this stapling", "缝合", "查重", "重复发表", "duplicate submission". |
| argument-hint | ["paper-dir | claims.json"] |
| allowed-tools | Bash(*), Read, Write, WebSearch, WebFetch, mcp__codex__codex, mcp__mcp-dblp__fuzzy_title_search, mcp__mcp-dblp__search |
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:
- 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.
- 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).
- 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.6-sol # 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.6-sol, 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_ids 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):
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
LEDGER="$ARGUMENTS"; [ -d "$LEDGER" ] && LEDGER="$LEDGER/claims.json"
[ -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):
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"
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_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:
[
{
"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}
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
key = idval.strip().lower()
if key in seen: continue
seen.add(key)
c["candidate_id"] = "K%02d" % (len(clean) + 1)
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)
c["self_record_unconfirmed"] = bool(title_match and not authors_overlap)
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.6-sol
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.6-sol
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, then run the next thread. After EACH call returns, save the FULL raw
reply with Write to "$TRACE/<NNN>-<axis>.response.md" (001-duplicate.response.md,
002-combination.response.md), the exact prompt to "$TRACE/<NNN>-<axis>.request.json", and
"$TRACE/<NNN>-<axis>.meta.json"
({"model":"gpt-5.6-sol","reasoning":"xhigh","sandbox":"read-only","thread_id":"<id>"}). The
.response.md files are the immutable input to Step 4. Keep each thread_id.
Failure handling.
- MCP stall / hang (common in long sessions): re-invoke the identical prompt as a
fresh
mcp__codex__codex call (gpt-5.6-sol, xhigh) — never codex-reply.
- Returns prose, not a JSON array: Step 4 extracts the outermost
[...]; if there is none,
re-ask that one axis with "Output ONLY the JSON array, nothing else." Never hand-author
overlap items on the reviewer's behalf.
- Reviewer slips in a verdict ("this is trivial / a duplicate"): that text is advisory memo
content only; Step 4 strips leaked ruling words and the adjudicator caps the skill at
info.
Do not propagate it as a conclusion.
- (Optional fan-out,
— effort: max): run each combination component as a separate fresh
mcp__codex__codex probe for breadth, then concatenate the arrays. Separate fresh threads
are fine; carrying context via codex-reply is not. These are NOT Claude subagents and
there is deliberately no Agent grant — the reviewer must be cross-model (non-Claude),
and Codex MCP is serial (concurrent calls hang), so probes run sequentially (Tier-3
in the fan-out ladder).
Step 4 — Validate + anchor, render the memo + the info-only findings mirror
Everything the reviewers proposed is now validated deterministically by the executor: every
anchor must be a verbatim span of a real ledger contribution claim, and every
candidate_id must exist in candidates.json (the anti-hallucination gate — a candidate the
file does not contain is deleted, so the memo can never name a prior-work paper that was not
actually retrieved; a self-record cannot appear on the duplicate axis). Leaked ruling words are
neutralized. The adjudicator independently re-applies the span-anchor gate and the MEMO cap as
the authoritative verdict, so this memo can never out-rank it. This single command writes both
deliverables:
PAPER_DIR="<abs PAPER_DIR from Step 0>"
LEDGER="$PAPER_DIR/claims.json"
CAND="$PAPER_DIR/novelty-duplication-advisory.candidates.json"
TRACE="<abs TRACE dir from Step 0>"
RUNMETA="$TRACE/run.meta.json"
python3 - "$LEDGER" "$CAND" "$TRACE" "$PAPER_DIR" "$RUNMETA" <<'PY'
import json, re, sys, os, glob
ledger_path, cand_path, trace_dir, paper_dir, runmeta = sys.argv[1:6]
def nw(s): return " ".join((s or "").split())
led = json.load(open(ledger_path, encoding="utf-8"))
L = int(led.get("observability_level", 0)); PID = led.get("paper_id", "?")
CONTRIB_TYPES = {"scope", "method", "comparison"}
CONTRIB_SECT = {"abstract", "intro", "introduction"}
CONTRIB_CUE = re.compile(r"\b(we\s+(propose|present|introduce|develop|design|show|"
r"contribute)|our\s+(method|approach|model|framework|contribution)|"
r"novel|first\s+to|state[- ]of[- ]the[- ]art|key\s+(idea|contribution))\b", re.I)
def sect(c): return ((c.get("location") or {}).get("section") or "").lower()
def is_contrib(c):
if c.get("type") in CONTRIB_TYPES: return True # deterministic contribution types
return sect(c) in CONTRIB_SECT and bool(CONTRIB_CUE.search(c.get("text_span", "")))
claims = {c["claim_id"]: c for c in led.get("claims", [])
if c.get("claim_id") and is_contrib(c)}
cands = {}
if os.path.isfile(cand_path):
for c in json.load(open(cand_path, encoding="utf-8")):
if isinstance(c, dict) and c.get("candidate_id"):
cands[c["candidate_id"]] = c
retrieval_incomplete = False
try:
rm = json.load(open(runmeta, encoding="utf-8"))
rv = rm.get("retrieval", {})
retrieval_incomplete = isinstance(rv, dict) and any(v == "unavailable" for v in rv.values())
except Exception:
pass
AXES = {"duplicate": "ADV-DUPLICATE-PUBLICATION", "combination": "ADV-TRIVIAL-COMBINATION"}
RULE_WORDS = re.compile(
r"\b(not\s+novel|lacks?\s+novelty|trivial\w*|incremental|mere\s+stapl\w*|"
r"reject\w*|plagiar\w*|derivative|duplicate[sd]?\s+publication|repackag\w*)\b"
r"|\b(?:is|are|appears?|seems?|clearly|essentially|simply)\s+(?:a\s+|an\s+)?duplicate\b"
r"|\bduplicate\s+of\b|缝合", re.I)
n_scrubbed = 0
def scrub(s):
global n_scrubbed
out, n = RULE_WORDS.subn("[reviewer-judgment]", s or "")
n_scrubbed += n; return out
def claim_anchor(a):
if not isinstance(a, dict): return None
cid, span = a.get("claim_id"), nw(a.get("span", ""))
c = claims.get(cid)
if c and span and span in nw(c.get("text_span", "")):
return {"claim_id": cid, "span": span,
"location": c.get("location", {}), "artifact_hash": c.get("evidence_anchor", "")}
return None
items, dropped, n_halluc = [], [], 0
for fp in sorted(glob.glob(os.path.join(trace_dir, "*.response.md"))):
raw = open(fp, encoding="utf-8", errors="replace").read()
m = re.search(r"\[.*\]", raw, re.S)
if not m:
print(f" note: no JSON array in {os.path.basename(fp)} (treated as [])"); continue
try:
arr = json.loads(m.group(0))
except Exception as e:
print(f" WARN: unparseable JSON in {os.path.basename(fp)}: {e} (treated as [])"); continue
if isinstance(arr, dict): arr = arr.get("findings", arr.get("items", []))
for it in (arr or []):
if not isinstance(it, dict): dropped.append({"why": "malformed"}); continue
axis = it.get("axis")
if axis not in AXES:
axis = next((a for a, p in AXES.items() if p == it.get("pattern_id")), None)
if axis not in AXES:
dropped.append({"label": it.get("label", ""), "why": "unknown-axis"}); continue
anch = [v for v in (claim_anchor(a) for a in (it.get("anchors") or [])) if v]
good_ids, bad_ids, self_ids = [], [], []
for cid in (it.get("candidate_ids") or []):
c = cands.get(cid)
if not c:
bad_ids.append(cid)
elif axis == "duplicate" and c.get("self_record_suspected"):
self_ids.append(cid)
else:
good_ids.append(cid)
n_halluc += len(bad_ids)
rec = {"axis": axis, "pattern_id": AXES[axis], "label": scrub(nw(it.get("label", ""))),
"overlap_statement": scrub(nw(it.get("overlap_statement", ""))), "anchors": anch,
"candidate_ids": good_ids, "overlap_kind": nw(it.get("overlap_kind", "")),
"residual_delta_note": scrub(nw(it.get("residual_delta_note", ""))),
"reviewer_action": scrub(nw(it.get("reviewer_action", "")))}
if anch and good_ids:
items.append(rec)
else:
dropped.append({**rec, "why": ("no-anchor" if not anch else "no-resolved-candidate")})
for i, p in enumerate(items, 1): p["id"] = "O%d" % i
n_dup = sum(1 for p in items if p["axis"] == "duplicate")
n_comb = sum(1 for p in items if p["axis"] == "combination")
if retrieval_incomplete: disp = "retrieval_incomplete"
elif items: disp = "candidate_overlap_surfaced"
else: disp = "no_candidate_overlap_found"
def cstr(cid):
c = cands.get(cid, {}); idv = c.get("identifier", {}) or {}
ref = idv.get("arxiv") or idv.get("doi") or idv.get("dblp_url") or idv.get("url") or ""
flag = (" ⚠️ near-identical title, authorship unverified — confirm self-record vs real duplicate"
if c.get("self_record_unconfirmed") else "")
return f"**{c.get('title','?')}** ({c.get('venue','?')} {c.get('year','?')}) — `{ref}` [{cid}]{flag}"
def astr(a):
sec = (a.get("location") or {}).get("section", "")
return f"claim `{a['claim_id']}`{f' ({sec})' if sec else ''}: “{a['span']}”"
out = []
out.append(f"**Novelty / Duplication Advisory — {PID}** (retrieval-grounded, MEMO-ONLY — no verdict weight).")
out.append(f"Reviewer: gpt-5.6-sol xhigh, two fresh per-axis threads (no codex-reply) · Run level: L{L} "
f"· Disposition (informational, NOT a verdict): **{disp}**")
out.append(f"Overlap items surfaced: {n_dup} duplicate · {n_comb} combination · Candidates retrieved: {len(cands)} "
f"· Hallucinated candidate refs dropped: {n_halluc} · Ruling words neutralized: {n_scrubbed}")
out += ["", "> ⚠️ **This is not a novelty verdict.** It lays out prior work the contribution OVERLAPS with so a "
"human reviewer can weigh `ADV-TRIVIAL-COMBINATION` and `ADV-DUPLICATE-PUBLICATION` themselves. **It never "
"rules \"trivial\" or \"duplicate\", and the absence of a candidate match below is NOT evidence of "
"originality** — the search corpus is incomplete by construction (recent / non-indexed / paywalled / "
"differently-titled work is missed). A same-authors match is legitimate self-overlap (arXiv→venue, "
"workshop→conference, extended journal), not misconduct. `tools/adjudicate_findings.py` lists this skill in "
"`MEMO_ONLY_SKILLS` and caps it at `info`; it carries no verdict weight.", ""]
for axis, head in [("duplicate", "ADV-DUPLICATE-PUBLICATION — candidate near-duplicates (verify; never a verdict)"),
("combination", "ADV-TRIVIAL-COMBINATION — is the contribution a standard A+B+C? (reviewer judgment)")]:
rows = [p for p in items if p["axis"] == axis]
out += [f"### {head}", ""]
if not rows:
out += ["_No resolved candidate overlap surfaced on this axis. **Not** an originality finding — see the caveat above._", ""]
continue
for p in rows:
out.append(f"#### {p['id']} — {p['label'] or '(unlabeled)'} · _{p['overlap_kind'] or 'overlap'}_")
if p["overlap_statement"]: out.append(f"- **Overlap:** {p['overlap_statement']}")
if p["anchors"]: out.append("- **Submission contribution (anchor):** " + " ; ".join(astr(a) for a in p["anchors"]))
if p["candidate_ids"]: out.append("- **Candidate prior work:** " + " ; ".join(cstr(c) for c in p["candidate_ids"]))
if p["residual_delta_note"]: out.append(f"- **Residual delta the paper still claims (descriptive):** {p['residual_delta_note']}")
if p["reviewer_action"]: out.append(f"- **For the human reviewer to weigh:** {p['reviewer_action']}")
out.append("")
if disp == "no_candidate_overlap_found":
out += ["### No candidate overlap found (NOT an originality verdict)", "",
"The retrieval ran but surfaced no resolved prior-work overlap with the submission's title / "
"contribution at this search depth. This is **not** evidence the work is original: the corpus search is "
"necessarily incomplete, and the queries are bounded by the ledger's contribution spans. A human reviewer "
"should still judge novelty against their own knowledge of the field. No verdict is rendered.", ""]
elif disp == "retrieval_incomplete":
out += ["### Retrieval incomplete (inconclusive)", "",
"One or more lookups were unavailable, so the prior-work search could not be completed. This memo "
"concludes **nothing** about novelty or duplication; re-run with corpus access for a fuller picture.", ""]
if dropped:
out += ["### Dropped (no anchor / no resolved candidate — excluded from the brief)", ""]
out += [f"- {p.get('label') or '(no label)'}: {p.get('why','')}" for p in dropped]
out.append("")
out += ["### Retrieval & anchoring audit", "",
f"- overlap items surfaced: {len(items)} · dropped: {len(dropped)} · hallucinated/self candidate refs dropped: {n_halluc} · ruling words neutralized: {n_scrubbed}",
"- every surfaced candidate is a resolved record in candidates.json (real call + verifiable id); every anchor "
"is a verbatim span of a contribution ledger claim.", ""]
out += ["---",
"_Informational only. `tools/adjudicate_findings.py` lists `novelty-duplication-advisory` in "
"`MEMO_ONLY_SKILLS` and caps every finding it emits at `info`, so this memo contributes **no verdict "
"weight**. Novelty is a reviewer judgment; the deterministic adjudicator owns the report verdict, and neither "
"it nor this skill rules \"trivial\" or \"duplicate\"._"]
memo_path = os.path.join(paper_dir, "novelty-duplication-advisory.memo.md")
open(memo_path, "w", encoding="utf-8").write("\n".join(out) + "\n")
import os as _aris_os
RESOLVED_MODEL = _aris_os.environ["ARIS_RESOLVED_MODEL"]
RESOLVED_REASONING = _aris_os.environ["ARIS_RESOLVED_REASONING"]
mir = []
for k, p in enumerate(items, 1):
cdesc = "; ".join(cstr(c) for c in p["candidate_ids"])
ev = [{"claim_id": a["claim_id"], "span": a["span"], "location": a.get("location", {}),
"artifact_hash": a.get("artifact_hash", "")} for a in p["anchors"]]
mir.append({
"finding_id": "NDA%03d" % k, "skill": "novelty-duplication-advisory",
"pattern_id": p["pattern_id"],
"title": (p["label"] or "prior-work overlap")[:120],
"description": (p["overlap_statement"] + (" — candidates: " + cdesc if cdesc else "")
+ ((" Residual: " + p["residual_delta_note"]) if p["residual_delta_note"] else "")
+ " [ADVISORY: prior-work overlap for the reviewer to weigh — NOT a trivial/duplicate ruling.]").strip(),
"severity": "info", # memo-only: never verdict-bearing
"observability_level_required": 0,
"evidence": ev, # may be [] (schema permits empty evidence for info)
"verdict_local": "needs_external_check", "requires_external_check": True,
"false_positive_risk": "high",
"recommended_reviewer_action": p["reviewer_action"] or ("Weigh the contribution against: " + cdesc),
"reviewer": {"model": RESOLVED_MODEL, "reasoning": RESOLVED_REASONING, "deterministic": False},
})
find_path = os.path.join(paper_dir, "novelty-duplication-advisory.findings.json")
json.dump(mir, open(find_path, "w", encoding="utf-8"), indent=2, ensure_ascii=False)
print(f"disposition={disp} (informational, NOT a verdict) | surfaced={len(items)} (dup={n_dup}, comb={n_comb}) "
f"| dropped={len(dropped)} | hallucinated/self_dropped={n_halluc} | ruling_scrubbed={n_scrubbed}")
print("memo ->", memo_path)
print("findings ->", find_path, f"({len(mir)} info-only)")
PY
Scope of this gate: anchor validation (verbatim span of a contribution claim), candidate
validation (the anti-hallucination check — every candidate_id must exist in
candidates.json; self-records excluded from the duplicate axis), ruling-word neutralization,
the informational disposition, and rendering. It computes no verdict and prints no
novelty grade — the memo is advisory and the adjudicator decides (and caps this skill at
info).
CONTRIB_CLAIMS == 0 / NO_CONTRIB / NO_CANDIDATES — honest-null path. When Step 0/1/2 sent
you here, write the two files directly (no reviewer call) so the orchestrator's globs still find
them:
PAPER_DIR="<abs PAPER_DIR from Step 0>"
printf '[]\n' > "$PAPER_DIR/novelty-duplication-advisory.findings.json"
Always emit. Write novelty-duplication-advisory.findings.json even when it is [] —
silent skip is forbidden; the orchestrator and the standalone adjudicate command both expect
the file at a predictable path. Failure handling. All candidates dropped as hallucinated
AND the reviewer made strong overlap claims → the reviewer cited prior work not in the file;
re-run that axis's Step 3 once (it must cite only candidates.json ids). Do not ship a memo
naming a paper that was never retrieved. Empty items + complete retrieval →
no_candidate_overlap_found (a correct output; do not re-run to manufacture overlap). Lookups
unavailable → retrieval_incomplete (concludes nothing).
Step 5 — Trace (forensic; never silently dropped)
The trace dir from Step 0 must, after the run, contain both reviewer calls and the retrieval
inputs (this repo ships no save_trace.sh, so write the files directly with Write):
.aris/traces/novelty-duplication-advisory/<date>_run<NN>/
run.meta.json # {skill, paper_id, run_level_L, taxonomy_version, retrieval{duplicate,combination}, disposition, generated_at}
001-duplicate.request.json # the EXACT prompt + paths sent (ledger + candidates + checklist — the independence audit trail)
001-duplicate.response.md # the FULL raw reviewer array (input to Step 4)
001-duplicate.meta.json # {model:"gpt-5.6-sol", reasoning:"xhigh", thread_id, sandbox:"read-only"}
002-combination.request.json # the EXACT prompt sent
002-combination.response.md # the FULL raw reviewer array
002-combination.meta.json
The retrieval artifacts novelty-duplication-advisory.profile.json and
novelty-duplication-advisory.candidates.json live in PAPER_DIR (so each reviewer reads
candidates.json from its cwd); run.meta.json references both and records the per-axis
retrieval status. Each request.json must show the executor sent only paths + the ledger +
the candidates + the per-axis checklist — never a Claude-authored digest or hunch like "this
looks derivative" (reviewer-independence). The response.md files are the immutable inputs Step
4 consumes.
Step 6 — Hand off, or adjudicate standalone
Within /anti-autoresearch, stop here. The orchestrator globs every *.findings.json —
your info-only novelty-duplication-advisory.findings.json included, which the MEMO gate caps
at info — so the skill's presence reliably reaches the report without raising the verdict. The
canonical human-facing artifact is novelty-duplication-advisory.memo.md in PAPER_DIR,
surfaced to the human alongside REPORT.md as a standalone advisory. (The adjudicator's
single --memo slot carries the adversarial-case-builder memo; this advisory is presented
directly, not through --memo.)
Running this skill alone is fine — --ledger is required (it anchors every above-info
finding; without it everything fails closed to info). Adjudicating confirms the
no-verdict-weight property:
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
LEDGER="<abs path to claims.json>"; D="$(dirname "$LEDGER")"
PAPER_ID=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["paper_id"])' "$LEDGER")
L=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["observability_level"])' "$LEDGER")
python3 "$ROOT/tools/adjudicate_findings.py" \
--findings "$D"/*.findings.json \
--ledger "$LEDGER" \
--paper-id "$PAPER_ID" --observability-level "$L" --taxonomy-version 0.5 \
--out "$D/report.json" --md "$D/REPORT.md"
The adjudicator applies its gates in order (ANCHOR → OBSERVABILITY → FP-RISK → MEMO →
SURFACE) and computes overall_verdict ∈ {CLEAN_GIVEN_EVIDENCE, SOFT_FLAGS, HARD_FLAGS} from
the other auditors' findings. The MEMO gate caps every novelty-duplication-advisory
finding at info, and this skill is absent from SKILL_TO_DIMENSION, so it contributes no
dimension verdict and cannot move the overall verdict — by design. A standalone run with no
other findings therefore yields CLEAN_GIVEN_EVIDENCE; that is correct, not a miss — the
value of this skill is the memo; read it. Treat a single-skill report as a PREVIEW.
Output contract
This skill always writes, into the ledger's directory (PAPER_DIR):
novelty-duplication-advisory.memo.md — canonical. Core overlap brief per axis
(ADV-DUPLICATE-PUBLICATION candidate table + ADV-TRIVIAL-COMBINATION decomposition), the
mandatory absence-≠-originality caveat, the self-overlap caveat, the informational
disposition, and the retrieval/anchoring audit. Carries no verdict weight; surfaced to the
human alongside REPORT.md. Prints no novelty score and no trivial/duplicate verdict.
novelty-duplication-advisory.findings.json — info-only mirror (one entry per surfaced
overlap item; possibly []), conforming to schemas/finding.schema.json,
"skill":"novelty-duplication-advisory", pattern_id ∈ {ADV-DUPLICATE-PUBLICATION,
ADV-TRIVIAL-COMBINATION}, every entry severity:"info",
verdict_local:"needs_external_check", observability_level_required:0. Written even when
empty for predictable *.findings.json globbing; the MEMO gate makes it a no-op.
novelty-duplication-advisory.candidates.json — the retrieved prior work (real records, with
identifiers; self-records flagged). Each reviewer's prior-work universe; forensic.
novelty-duplication-advisory.profile.json — the ledger-derived retrieval profile.
.aris/traces/novelty-duplication-advisory/<date>_run<NN>/ — the two raw reviewer calls.
It writes no verdict and no report of its own — report.json / REPORT.md come only from
tools/adjudicate_findings.py (Step 6 / the orchestrator).
Key rules
- Never rules novelty — by design. The skill never outputs "trivial", "not novel",
"duplicate", a novelty score, or a recommendation. It surfaces overlap and questions; the
human judges; the adjudicator's
MEMO_ONLY_SKILLS cap pins it to info and it is absent from
SKILL_TO_DIMENSION. Leaked ruling words are neutralized in Step 4.
- Absence ≠ originality. "No candidate overlap found" / "retrieval incomplete" is a valid
result that says nothing about novelty — the corpus is incomplete by construction. The
memo states this explicitly; never let an empty retrieval read as "the paper is novel."
- Prior-work side real-record-bound (anti-hallucination). Every candidate comes from a real
DBLP/WebSearch/WebFetch call and carries a verifiable identifier; the reviewer cites only
candidate_ids in candidates.json. A fabricated prior paper is the same sin as a
hallucinated citation — forbidden, and dropped in Step 4.
- Paper side ledger-anchored. Every contribution reference cites a contribution
claim_id
(type∈{scope,method,comparison} or section∈{abstract,intro}) + a verbatim span (span in text_span, whitespace-normalized, never the reverse; LaTeX escapes quoted exactly). The
candidate goes in the table / description, never in the span. No anchor → dropped.
- The title is a seed, not an anchor.
\title{…} (or the PDF's first line) seeds the
duplicate search; it anchors nothing (title_seed_is_anchor: false). A duplicate overlap
anchors to a contribution claim_id.
- Exclude the paper's own record. A near-identical title flagged
self_record_suspected is
the audited paper's own preprint/venue copy, not a duplicate; it is excluded from the
duplicate axis (flagged, never silently dropped). Self-overlap (arXiv→venue,
workshop→conference, extended journal) is legitimate, never misconduct.
- Executor retrieves; reviewers map. WebSearch / DBLP / WebFetch lookups are the executor's
Step 2; each reviewer lays out overlap over those facts. The executor never pre-judges overlap
into the prompt — it hands raw retrieved records (structured, not a digest), preserving
reviewer-independence even though the corpus is external.
- Cross-model, two fresh serial per-axis threads. Reviewer is a different family (gpt-5.6-sol
xhigh, read-only); each axis is a new
mcp__codex__codex thread, run sequentially;
codex-reply is never used. Reviewer ≠ executor ≠ adjudicator.
- Hand off external truth it does not own. A "SOTA/first/beats prior work" claim →
baseline-comparison-audit; a wrong-context/fabricated cited work → citation-forensics.
This skill owns only the two advisory overlap signals.
- L0 advisory framing.
observability_level_required: 0 for both signals (decidable-as-
advisory from text + the public corpus). Output asks a reviewer to CHECK/WEIGH, never
"reject" / "plagiarism" / "the authors faked X." The tool audits overlap, not provenance.
- Detect-only. Never edit the audited paper (no
Edit; reviewer sandbox read-only).
- Taxonomy is a mapping layer (v0.4). The two
ADV-* ids are advisory signals with zero
verdict weight — set them on the info-only findings, never to claim a decision.
- Reproducible. Same ledger + same candidates snapshot + same reviewer outputs → same
validated memo + same disposition.
When NOT to use this skill
- No
claims.json yet → run /evidence-ledger first; this skill never invents structure
from the raw PDF, and it needs the contribution spans to build its query (the title is read
from the source only as a search seed).
- You want a novelty SCORE / a PROCEED-vs-ABANDON recommendation → that is the
author-facing ARIS
/novelty-check, not this forensics advisory. This skill never scores or
recommends; it only surfaces overlap for a human to weigh.
- You want a "this paper is trivial / a duplicate" verdict → impossible by design; novelty
is a reviewer judgment and this skill is capped at
info. Read the memo and decide yourself.
- You need to verify a cited reference exists / is used in the right context →
/citation-forensics (this skill is about overlap with work the paper need not cite).
- You need to verify an empirical "first / SOTA / beats prior work" claim →
/baseline-comparison-audit (a baseline-integrity verdict, not an overlap memo).
- You need intra-paper contradiction or method drift →
/consistency-audit
(HP-SCOPE-INFLATE for an internal scope-overclaim).
- You need code/result-level fraud (fake GT, self-normalization, phantom numbers) →
/experiment-forensics at L2.
- You want an AI-text / "looks machine-written" verdict → out of scope. Surface hints live
in
/presentation-signals (auxiliary, capped at minor); this repo is not an AI-text
classifier and not a plagiarism detector — it surfaces candidates to weigh.
- No corpus access → the retrieval cannot run; the honest output is
retrieval_incomplete
(concludes nothing), not a guessed "no duplication".
- On a timer → never
/loop / /schedule / CronCreate this skill; re-fire only when the
paper / ledger / literature change (see the fence at the top). Re-running burns external-search
budget for an identical memo.
Review tracing
Trace policy is forensic (never silently skipped) — see Step 5 for the exact layout:
run.meta.json + per-axis 001-duplicate.{request.json,response.md,meta.json} and
002-combination.{...} (and, under — effort: max, each combination-component probe). Write
each .response.md during Step 3, immediately after its reviewer call. Every request.json
must contain only the paths + the ledger + the candidates file + the per-axis checklist that
were sent — the reviewer-independence audit trail. The retrieved candidates.json and
profile.json are persisted in PAPER_DIR and referenced from run.meta.json so the
prior-work universe the memo rests on is reproducible.