diff --git a/README.md b/README.md
index 7853311a7c00e52ed71b6c35e65e4a3de26742f1..e8534a57b5da9e8ca4b24aa68188b7d95adfae72 100644
--- a/README.md
+++ b/README.md
@@ -142,7 +142,11 @@ this part of the pipeline sends an email or registers anything on its own.
without an answer, then registers the DOI at Crossref, pushes the landing
page, publishes the Zenodo archive under the same DOI, and drafts the
author's "published" notice. Each step is checkpointed so a failed run
- resumes where it stopped instead of repeating anything.
+ resumes where it stopped instead of repeating anything. Until then the
+ reserved DOI resolves nowhere: check it at `api.crossref.org/works/`
+ (404 until it is registered), never at doi.org, where a lookup of an
+ unregistered DOI counts as a failed resolution in Crossref's monthly
+ report for the prefix.
4. **One follow-up** ([`intake/post_publication.py`](intake/post_publication.py)),
seven days after registration: a drafted invitation to join the
institute's reviewer pool. It is skipped when the author opted out of
diff --git a/action.py b/action.py
index 46e8c7d303120ed0d284cb48d07954dd20a8029a..59a1ac6c901c321734baaff3a5bb9d66897142bc 100644
--- a/action.py
+++ b/action.py
@@ -328,7 +328,7 @@ def _extract_registry_entry(record_id: str, metadata: dict,
last, after = [s.strip() for s in name.split(",", 1)]
name = f"{after} {last}".strip() if after else last
authors.append(name)
- return {
+ proto = {
"record_id": str(record_id),
"title": m.get("title", "Untitled"),
"authors": authors or ["Unknown"],
@@ -337,6 +337,10 @@ def _extract_registry_entry(record_id: str, metadata: dict,
"source": source,
"source_ref": f"https://zenodo.org/records/{record_id}",
}
+ kw = publications.clean_keywords(m.get("keywords"))
+ if kw:
+ proto["keywords"] = kw
+ return proto
def _publish_public_review(record_id: str) -> str | None:
diff --git a/arxiv_gate.py b/arxiv_gate.py
index 2c92e2342828b8e005329ca4e61424840fb8135e..665da365baa2f6e96f87d96ace228f6473624d44 100644
--- a/arxiv_gate.py
+++ b/arxiv_gate.py
@@ -3,8 +3,8 @@
arXiv's terms (https://info.arxiv.org/help/api/tou.html, read 2026-09-29):
"make no more than one request every three seconds, and limit requests to a
single connection at a time", applied to "all of the machines under your
-control as a whole". Machines that share one public IP share one budget, so
-that is one budget for all of them.
+control as a whole". Machines that share one public IP share that one
+budget.
Every call to the API goes through get(): a lock shared by every process and
thread on this host holds the single connection, calls start at least
diff --git a/citation_misattribution.py b/citation_misattribution.py
index ddf7d1cf6bdae282fe8a47cbe81bb6887713ed2c..36b2f79a946e33fb5812534f20c1a942adacf9a9 100644
--- a/citation_misattribution.py
+++ b/citation_misattribution.py
@@ -139,7 +139,11 @@ def select_load_bearing(citations: list[dict], full_text: str, max_n: int = 10)
"""
if not citations:
return []
- eligible = [c for c in citations if c.get("verified")]
+ # Only a reference whose identity is confirmed by its own identifier can be
+ # judged against the claim: a text-search match may be another work, and a
+ # verdict on it would be a verdict on the wrong paper.
+ import citation_verify
+ eligible = [c for c in citations if citation_verify.is_identity_confirmed(c)]
if not eligible:
return []
if len(eligible) <= max_n:
@@ -529,7 +533,10 @@ def _short_label(c: dict) -> str:
else:
base = f"{authors[0]} et al."
if year:
- return f"{base} {year}"
+ # "2019a" / "2019b": two works by the same authors in one year
+ # must not share a label in the report.
+ m = re.search(rf"\b{year}([a-z])\b", c.get("raw") or "")
+ return f"{base} {year}{m.group(1) if m else ''}"
return base
if c.get("title"):
t = c["title"]
diff --git a/citation_verify.py b/citation_verify.py
index 4c7ddea3be82c00df25b31de552541ad50db3c83..9c58cd6e52f4dea9093a96f68d6a7694249cb668 100644
--- a/citation_verify.py
+++ b/citation_verify.py
@@ -653,6 +653,12 @@ def _normalize_for_match(s: str) -> str:
return " ".join(s.split())
+def _main_title(s: str) -> str:
+ """The title before its subtitle ('Spin Glasses: Experimental Facts…'
+ -> 'spin glasses'), normalized."""
+ return _normalize_for_match(re.split(r"\s*[:—–?]\s*|\s+-\s+", s or "", maxsplit=1)[0])
+
+
def _title_matches(claimed: str | None, resolved: str) -> bool:
if not claimed or not resolved:
return False
@@ -662,6 +668,13 @@ def _title_matches(claimed: str | None, resolved: str) -> bool:
return False
if a == b:
return True
+ # A title of three words or fewer matches only the other side's main
+ # title. Word overlap let a two-word title ("Data Policy") match any
+ # longer title that happens to contain both words.
+ if len(a.split()) < 4:
+ return a == _main_title(resolved)
+ if len(b.split()) < 4:
+ return b == _main_title(claimed)
# Substring match in either direction (handles subtitle truncation)
if len(a) >= 20 and a in b:
return True
@@ -717,7 +730,47 @@ def _find_probable_record(c: dict) -> dict | None:
return candidates[0] if candidates else None
+_URL_RE = re.compile(r"https?://[^\s<>\"]+", re.I)
+_REGISTRY_URL_RE = re.compile(r"https?://(?:dx\.)?doi\.org/|https?://(?:export\.)?arxiv\.org/", re.I)
+
+
+def _cited_web_url(c: dict) -> str | None:
+ """The URL a reference gives as its handle when the extractor typed it a
+ web source ("url") and it carries no DOI or arXiv ID; else None."""
+ if c.get("type") != "url" or c.get("doi") or c.get("arxiv_id"):
+ return None
+ for m in _URL_RE.finditer(c.get("raw") or ""):
+ url = m.group(0).rstrip(".,;)")
+ if not _REGISTRY_URL_RE.match(url):
+ return url
+ return None
+
+
def verify_citation(c: dict) -> dict:
+ """A reference cited by URL (a policy page, documentation) is a web
+ source: the URL is its identifier, as a DOI is a paper's. The registry
+ searches may still find it as a scholarly work, but only on title AND
+ author; anything weaker is a neighbouring record, so the reference is
+ reported as cited by URL and nothing is matched to it. Otherwise a short
+ page title matches an unrelated paper whose title contains the same
+ words, and the panel judges that paper against the claim."""
+ out = _verify_against_registries(c)
+ url = _cited_web_url(c)
+ if url and out.get("confidence") != "title-author-match":
+ return {
+ "verified": False,
+ "resolver": "url",
+ "resolved_id": url,
+ "title": c.get("title") or "",
+ "abstract": "",
+ "confidence": "web-source",
+ "reason": "Cited by URL: a web page, not a registry work. Not matched to any registry "
+ "record and not opened by the verifier.",
+ }
+ return out
+
+
+def _verify_against_registries(c: dict) -> dict:
"""Single citation lookup — arXiv → Crossref/DataCite → Semantic Scholar.
Order matters. Exact-id resolvers (arXiv ID, DOI) get exact-id
@@ -978,10 +1031,12 @@ def verify_all(citations: list[dict], max_concurrent: int = 8) -> list[dict]:
# expect the registry title there); keep what the paper cited.
if "cited_title" not in merged:
merged["cited_title"] = citations[i].get("title")
- if merged.get("verified") and not v.get("verified") and v.get("doi_identity") not in ("dead", "mismatch"):
+ if (merged.get("verified") and not v.get("verified")
+ and v.get("doi_identity") not in ("dead", "mismatch") and v.get("confidence") != "web-source"):
# A recheck that could not confirm never downgrades a citation
# verified earlier (audit 2026-09-28 item 17) -- unless the
- # citation's own DOI now disagrees: that is a finding, not a miss.
+ # citation's own DOI now disagrees, or it is a web page an
+ # earlier fuzzy search matched to a paper: findings, not misses.
merged["recheck_reason"] = str(v.get("reason", ""))[:200]
else:
merged.update(v)
@@ -999,6 +1054,23 @@ def identity_counts(citations: list[dict]) -> dict:
return counts
+def is_identity_confirmed(c: dict) -> bool:
+ """CONFIRMED: the reference cites an identifier (DOI or arXiv ID) that resolves
+ to the cited work. Only this level can ground an integrity finding -- a
+ misattribution verdict, a citation-graph refutation, a deposited citation
+ link: a work nobody is sure is the cited one cannot be judged against a claim."""
+ return bool(c.get("verified")) and c.get("confidence") == "exact-id"
+
+
+def is_unconfirmed_match(c: dict) -> bool:
+ """A registry record found by text search for a reference that cites no
+ identifier. It may be the cited work or a neighbour of it (a review of the
+ book, one of its chapters, another paper with the same words in its title),
+ so it is UNCONFIRMED: kept in the JSON for the curator, shown to no
+ reviewer, never evidence for or against the paper."""
+ return bool(c.get("verified")) and c.get("confidence") not in ("exact-id", "web-source")
+
+
def build_verification_report(citations: list[dict]) -> str:
"""Render the verification report as a markdown block suitable for
prompt injection above the DEFENSIVE_PREAMBLE + submission block.
@@ -1006,10 +1078,11 @@ def build_verification_report(citations: list[dict]) -> str:
if not citations:
return ""
- confirmed = [c for c in citations if c.get("verified") and c.get("confidence") == "exact-id"]
- matched = [c for c in citations if c.get("verified") and c.get("confidence") != "exact-id"]
+ confirmed = [c for c in citations if is_identity_confirmed(c)]
+ unconfirmed = [c for c in citations if is_unconfirmed_match(c)]
disagree = [c for c in citations if not c.get("verified") and c.get("doi_identity") in ("mismatch", "dead")]
- unverifiable = [c for c in citations if not c.get("verified") and c not in disagree]
+ web = [c for c in citations if c.get("confidence") == "web-source"]
+ unverifiable = [c for c in citations if not c.get("verified") and c not in disagree and c not in web]
lines = [
"## Citation verification (independently verified before review)",
@@ -1021,12 +1094,13 @@ def build_verification_report(citations: list[dict]) -> str:
"- CONFIRMED [exact-id]: the cited identifier (DOI or arXiv ID) resolves and",
" its record matches the cited title (or the authors and year when no title",
" was cited). The work exists as cited. Do not call it fabricated.",
- "- MATCHED [title-author-match / title-year-match / author-year-match]: no",
- " identifier was cited; a registry record matching the citation's text was",
- " found. The work exists; the match is textual and could be a neighbouring",
- " work. Do not call it fabricated; you may question whether it is the work",
- " meant, and you must read it as an existence check, not as confirmation of",
- " the cited authors, venue or pages.",
+ "- UNCONFIRMED: no identifier was cited, so the reference's identity could",
+ " not be confirmed. A text search of the registries may have found a",
+ " possible record; it is deliberately not shown, because it may be a",
+ " different work (a review of the book, a chapter, a paper with similar",
+ " words in its title). UNCONFIRMED is not evidence of anything: do not call",
+ " the reference fabricated, misattributed or citation stuffing on its",
+ " account, and do not credit it as verified either. Judge it from the paper.",
"- DOI MISMATCH: the citation carries a DOI that resolves to a DIFFERENT work.",
" DOI DEAD: the cited DOI resolves nowhere. Both are citation-integrity",
" defects in their own right (a wrong identifier, wrong authors, or a",
@@ -1035,6 +1109,10 @@ def build_verification_report(citations: list[dict]) -> str:
" matching the cited title exists it is given, so the author can be pointed",
" to it.",
"- UNVERIFIABLE: no registry record was found. Not evidence of fabrication.",
+ "- WEB SOURCE: the reference is a web page cited by its URL (a policy page,",
+ " documentation). It is not a registry work, so no registry record was",
+ " matched to it and the verifier did not open it. Not evidence of",
+ " fabrication or of misattribution; the page is the one at that URL.",
"",
]
@@ -1058,10 +1136,14 @@ def build_verification_report(citations: list[dict]) -> str:
lines.extend(_line(c, "CONFIRMED") for c in confirmed)
lines.append("")
- if matched:
- lines.append("### Matched by text, no identifier cited (exists; identity not confirmed)")
+ if unconfirmed:
+ lines.append("### Unconfirmed: no identifier cited (not evidence of anything)")
lines.append("")
- lines.extend(_line(c, "MATCHED") for c in matched)
+ for c in unconfirmed:
+ claim = c.get("claim_context") or ""
+ tail = f" Submission claim context: \"{claim}\"" if claim else ""
+ lines.append(f"- **{_short_label(c)}** — UNCONFIRMED. No identifier was cited; "
+ f"not checked against any record.{tail}")
lines.append("")
if disagree:
@@ -1077,6 +1159,17 @@ def build_verification_report(citations: list[dict]) -> str:
lines.append(f"{head} {c.get('reason') or ''}".rstrip())
lines.append("")
+ if web:
+ lines.append("### Cited by URL (web pages, not registry works)")
+ lines.append("")
+ for c in web:
+ title = c.get("cited_title") or c.get("title") or ""
+ named = f" *{title}*," if title else ""
+ claim = c.get("claim_context") or ""
+ tail = f" Submission claim context: \"{claim}\"" if claim else ""
+ lines.append(f"- **{_short_label(c)}** — WEB SOURCE.{named} {c.get('resolved_id')}.{tail}")
+ lines.append("")
+
if unverifiable:
lines.append("### Unverifiable from public registries")
lines.append("")
@@ -1111,7 +1204,10 @@ def _short_label(c: dict) -> str:
else:
base = f"{authors[0]} et al."
if year:
- return f"{base} {year}"
+ # "2019a" / "2019b": two works by the same authors in one year
+ # must not share a label in the report.
+ m = re.search(rf"\b{year}([a-z])\b", c.get("raw") or "")
+ return f"{base} {year}{m.group(1) if m else ''}"
return base
if c.get("title"):
t = c["title"]
@@ -1141,6 +1237,8 @@ def save_citation_report(record_id: str, citations: list[dict], report: str) ->
"citation_count": len(citations),
"verified_count": sum(1 for c in citations if c.get("verified")),
"unverifiable_count": sum(1 for c in citations if not c.get("verified")),
+ "confirmed_count": sum(1 for c in citations if is_identity_confirmed(c)),
+ "unconfirmed_count": sum(1 for c in citations if is_unconfirmed_match(c)),
"citations": citations,
}
with open(json_path, "w") as f:
diff --git a/citestamp_check.py b/citestamp_check.py
index 0570c573ccd37e3b9356bee1b0a5c43aec0ad8a7..fc011691b5f65e250bd54068d4942e99b8f21759 100644
--- a/citestamp_check.py
+++ b/citestamp_check.py
@@ -137,7 +137,13 @@ def check_citations(citations: list[dict], *, log=print) -> dict:
return out
out["available"] = True
rpc_id = 10
+ import citation_verify
for c in citations:
+ # Only identity-confirmed references: a DOI that points to another work,
+ # or a record found by text search, would put that other work's
+ # refutations and supports in front of the panel.
+ if not citation_verify.is_identity_confirmed(c):
+ continue
doi = _doi_of(c)
if not doi:
continue
diff --git a/config.example.py b/config.example.py
index 8bb85c2128e00458ca74e85ad9ae89d122b813ef..e1854da40d68a39d6363314231d4c346555e00a6 100644
--- a/config.example.py
+++ b/config.example.py
@@ -96,6 +96,10 @@ HF_TOKEN = os.environ.get("HF_TOKEN", "")
# different model on the same provider absorbs it. Same trade-off as 04-27:
# a fallback can duplicate another slot's primary; reliability beats diversity.
OPENROUTER_MODELS = [
+ # 2026-10-02: no entry may route a manuscript to a provider that trains on
+ # prompts. OpenRouter lists its free nemotron routes as training ones, so
+ # they are out. The OR free routes kept here are listed as not training;
+ # Workers AI does not train; the HF upstreams' own terms are still to check.
# 2026-09-28 (the curator): a free direct provider first wherever one exists, the
# HF router second (a $0.10/month credit pool), OpenRouter :free last (a shared
# pool that 429s). `oai||` entries need the provider's key in
@@ -105,37 +109,40 @@ OPENROUTER_MODELS = [
# Slot 1: DeepSeek family.
[
"oai|sambanova|DeepSeek-V3.1",
- "hf|deepseek-ai/DeepSeek-V4-Flash:deepinfra",
- "hf|zai-org/GLM-4.7-Flash:deepinfra",
- "or|nvidia/nemotron-3-ultra-550b-a55b:free",
- "or|qwen/qwen3.8-27b:free",
+ # 2026-10-12: HF's deepinfra route stopped serving DeepSeek-V4-Flash and
+ # GLM-4.7-Flash. GLM-4.7-Flash now comes from Workers AI. Workers AI's own
+ # DeepSeek-V4-Flash is left out: on a full panel prompt it spent its whole
+ # output on hidden reasoning, and one call cost ~2,600 of the daily neurons
+ # the gpt-oss slot also draws on.
+ "oai|cloudflare|@cf/zai-org/glm-4.7-flash",
"or|google/gemma-4-31b-it:free",
],
- # Slot 2: OpenAI open-weights.
+ # Slot 2: OpenAI open-weights. Workers AI first (2026-10-02): free within
+ # the daily allocation, metered by review._neuron_reserve, and it does not
+ # train on customer content.
[
+ "oai|cloudflare|@cf/openai/gpt-oss-120b",
"oai|sambanova|gpt-oss-120b",
"hf|openai/gpt-oss-120b:deepinfra",
"hf|Qwen/Qwen3-235B-A22B-Instruct-2507:deepinfra",
- "or|nvidia/nemotron-3-super-120b-a12b:free",
- "or|qwen/qwen3.8-27b:free",
"or|google/gemma-4-26b-a4b-it:free",
],
- # Slot 3: Mistral / Qwen family (Mistral activates when MISTRAL_API_KEY exists).
+ # Slot 3: Mistral / Qwen family (Mistral activates when MISTRAL_API_KEY exists;
+ # medium, not large: the key's model list has no large model, 2026-10-02).
[
- "oai|mistral|mistral-large-latest",
+ "oai|mistral|mistral-medium-latest",
"hf|Qwen/Qwen3-235B-A22B-Instruct-2507:deepinfra",
"hf|google/gemma-4-31B-it:deepinfra",
- "or|nvidia/nemotron-3-ultra-550b-a55b:free",
"or|google/gemma-4-26b-a4b-it:free",
"or|google/gemma-4-31b-it:free",
],
- # Slot 4: Google family.
+ # Slot 4: Google family. Workers AI first (2026-10-12): free within the
+ # daily allocation, no training on customer content.
[
+ "oai|cloudflare|@cf/google/gemma-4-26b-a4b-it",
"hf|google/gemma-4-31B-it:deepinfra",
"or|google/gemma-4-31b-it:free",
"oai|sambanova|gemma-4-31B-it",
- "or|nvidia/nemotron-3-super-120b-a12b:free",
- "or|qwen/qwen3.8-27b:free",
"or|google/gemma-4-26b-a4b-it:free",
],
]
@@ -154,10 +161,57 @@ OPENROUTER_MODELS_API_URL = "https://openrouter.ai/api/v1/models"
# (2026-09-28). Keys come from the environment; an entry whose key is absent is
# skipped, not counted as a dead chain link.
OAI_COMPAT_PROVIDERS = {
- # free tier, no card: about 20 requests and 200K tokens per day, 128k context
+ # no longer free (checked 2026-10-02: a payment method and purchased credits
+ # are required); its entries stay inert while no key is set
"sambanova": {"base_url": "https://api.sambanova.ai/v1", "key_env": "SAMBANOVA_API_KEY"},
- # free "experiment" mode; its limits are shown only in the Mistral admin panel
- "mistral": {"base_url": "https://api.mistral.ai/v1", "key_env": "MISTRAL_API_KEY"},
+ # A card-backed account (2026-10-02) with a monthly allowance that must never
+ # be exceeded, and training on inputs switched off in the account. Every call
+ # is metered against monthly_cap_usd (ICSAC_MISTRAL_MONTHLY_CAP_USD, default
+ # 8) before it is sent. usd_rates are deliberately ABOVE the list price
+ # (mistral-medium-latest = Medium 3.5, listed at $1.5 / $7.5 per million
+ # input / output tokens), so the ledger over-counts and a price rise stays
+ # inside the margin; a model with no rate here is refused.
+ "mistral": {
+ "base_url": "https://api.mistral.ai/v1",
+ "key_env": "MISTRAL_API_KEY",
+ "monthly_cap_usd": float(os.environ.get("ICSAC_MISTRAL_MONTHLY_CAP_USD", "8")),
+ "usd_rates": {
+ "mistral-medium-latest": (2.0, 10.0),
+ },
+ },
+ # Cloudflare Workers AI (2026-10-02): a free daily allocation of 10,000
+ # neurons per account, and customer content is not used for training. On a
+ # Workers Paid account usage past the allocation is billed, so every call is
+ # metered against daily_neuron_cap before it is sent and refused when it
+ # could cross it (review._neuron_reserve). Rates are neurons per million
+ # input / output tokens from the Workers AI pricing page; a model with no
+ # rate here is refused.
+ "cloudflare": {
+ "base_url": ("https://api.cloudflare.com/client/v4/accounts/"
+ + os.environ.get("CLOUDFLARE_AI_ACCOUNT_ID", "") + "/ai/v1"),
+ "key_env": "CLOUDFLARE_AI_TOKEN",
+ "daily_neuron_cap": int(os.environ.get("ICSAC_CF_NEURON_CAP", "9000")),
+ "neuron_rates": {
+ "@cf/openai/gpt-oss-120b": (31818, 68182),
+ "@cf/meta/llama-4-scout-17b-16e-instruct": (24545, 77273),
+ "@cf/mistralai/mistral-small-3.1-24b-instruct": (31876, 50488),
+ "@cf/google/gemma-3-12b-it": (31371, 50560),
+ "@cf/zai-org/glm-4.7-flash": (5500, 36364),
+ "@cf/google/gemma-4-26b-a4b-it": (9091, 27273),
+ },
+ # Request overrides per model (default: max_tokens 4000, no JSON mode).
+ # Workers AI counts a reasoning model's hidden reasoning inside
+ # max_tokens: at 4000 both of these cut their review JSON off mid-object
+ # on a full-length panel prompt. Without JSON mode both wrote LaTeX
+ # backslash commands unescaped inside JSON strings, which no parser accepts.
+ # In JSON mode Gemma spent all 16000 tokens reasoning and returned no
+ # content, so its thinking is off. Measured 2026-10-12.
+ "model_params": {
+ "@cf/zai-org/glm-4.7-flash": {"max_tokens": 16000, "response_format": {"type": "json_object"}},
+ "@cf/google/gemma-4-26b-a4b-it": {"max_tokens": 8000, "response_format": {"type": "json_object"},
+ "chat_template_kwargs": {"enable_thinking": False}},
+ },
+ },
}
# panel_retry.py (batch-tick step 2b): a paper the panel could not staff is
# re-queued every tick and SCREAMS (Telegram + pain) after this many hours of
diff --git a/crossref_deposit.py b/crossref_deposit.py
index e740e70710a5e6d67c5c6529c95d40966bfea9f9..29f8c0bfea283cf9ac0bcd874cb5cba554cfd6af 100644
--- a/crossref_deposit.py
+++ b/crossref_deposit.py
@@ -390,15 +390,16 @@ def _doi_data(parent: ET.Element, doi: str, landing: str, pdf_url: Optional[str]
def _citations(parent: ET.Element, citations: list[dict]) -> None:
- """Verified DOIs go in as ; the rest as unstructured text. Crossref
- matches unstructured citations itself, so nothing verifiable is lost."""
+ """DOIs confirmed by the verifier (the cited DOI resolves to the cited work)
+ go in as ; the rest as unstructured text. Crossref matches unstructured
+ citations itself, so nothing verifiable is lost."""
if not citations:
return
cl = _sub(parent, "citation_list")
for i, c in enumerate(citations, 1):
cit = _sub(cl, "citation", attrib={"key": f"ref{i}"})
doi = (c.get("doi") or "").strip()
- if doi and c.get("verified"):
+ if doi and c.get("verified") and c.get("confidence") == "exact-id":
_sub(cit, "doi", doi)
else:
raw = " ".join((c.get("raw") or c.get("title") or "").split())
@@ -1004,6 +1005,9 @@ def _push_publications(sub_dir: Path, submission: dict, doi: str, *, log) -> dic
cdu = publications.code_data_url(submission)
if cdu:
proto["code_data_url"] = cdu
+ kw = publications.clean_keywords(submission.get("keywords"))
+ if kw:
+ proto["keywords"] = kw
pre = preprint_doi(submission)
if pre:
proto["preprint_doi"] = pre # the paper page links the preprint it was submitted from
diff --git a/email_send.py b/email_send.py
index 008dee86ead0df38ec27d5c6be1006c973b854da..9215a0109a429fb2bf27bf730bda36cb77090667 100644
--- a/email_send.py
+++ b/email_send.py
@@ -69,8 +69,12 @@ def _markdown_to_html(md: str) -> str:
The templates carry no raw HTML, so every `<` in the body came from a
substituted value (a title, a name, a note). It is rendered as text, not
- as a tag (audit 2026-09-29); the plain-text part is untouched."""
- inner = markdown.markdown(md.replace("<", "<"), extensions=["extra", "sane_lists"])
+ as a tag (audit 2026-09-29); the plain-text part is untouched.
+
+ A single line break is a line break (nl2br): every one in the templates
+ is meant (the header block, the sign-off), and without it the header
+ read as one run-on line in the HTML part (2026-10-16)."""
+ inner = markdown.markdown(md.replace("<", "<"), extensions=["extra", "sane_lists", "nl2br"])
inner = _box_response_section(inner)
return HTML_WRAPPER.format(logo=LOGO_URL, body=inner)
diff --git a/intake/apply_decision.py b/intake/apply_decision.py
index b4bbffccf595018fc15376c54e7d1d3925851dc0..982c0253fdd248125f15e6a42eb84c39cda26026 100644
--- a/intake/apply_decision.py
+++ b/intake/apply_decision.py
@@ -466,6 +466,7 @@ def main(argv: list[str]) -> int:
code_link_claims=code_link_claims, preprint_doi=preprint or "",
exclusivity_confirmed=form.get("exclusivity_acknowledged") is True,
curator_findings=curator_findings,
+ revision_of=state_pre.get("revision_of") or "",
)
if ok:
# Decision emails go to Gmail Drafts (curator-applied decision path).
diff --git a/intake/intake_server.py b/intake/intake_server.py
index dc97f0442942c7a52e2227fdbbef23eb45229288..ea14532ca5b9382f33a95839953981dd72358708 100644
--- a/intake/intake_server.py
+++ b/intake/intake_server.py
@@ -933,7 +933,9 @@ async def handle_test_pipeline_submission(
if has_doi:
title = "(deferred — resolving from DOI)"
abstract = ""
- keywords: list = []
+ # Optional on the DOI route (2026-10-02): the author's own keywords;
+ # left empty, the worker takes the source record's.
+ keywords: list = [k.strip() for k in re.split(r"[,;]+", str(form.get("keywords") or "")) if k.strip()]
license_id = article_license or ""
creators = [{"name": submitter["name"], "orcid": token}]
publication_date = _now_iso()[:10]
@@ -1360,7 +1362,9 @@ async def api_submit(request: Request):
# the deposit step is upload-only.
title = "(deferred — resolving from DOI)"
abstract = ""
- keywords: list = []
+ # Optional on the DOI route (2026-10-02): the author's own keywords;
+ # left empty, the worker takes the source record's.
+ keywords: list = [k.strip() for k in re.split(r"[,;]+", str(form.get("keywords") or "")) if k.strip()]
license_id = article_license or ""
creators = [{"name": submitter["name"], "orcid": submitter["orcid"]}]
publication_date = _now_iso()[:10]
diff --git a/intake/newsletter.py b/intake/newsletter.py
index 9cdd7c581df805652f73fe79243a83fdabdcfee3..f6c67b610a3f773ef1acdb3a350721efd4054c1f 100644
--- a/intake/newsletter.py
+++ b/intake/newsletter.py
@@ -7,8 +7,7 @@ tick is recorded here with the wording version the author saw and the time.
The list is ONE append-only JSONL file of events,
~/icsac-submissions/newsletter/subscribers.jsonl
-(inside the nightly encrypted backup,
-backup.sh:344). A row is either
+(covered by the nightly encrypted off-site backup). A row is either
{"event": "subscribe", "email", "name", "sub_id", "consented_at", "wording_version", "source"}
or {"event": "unsubscribe", "email", "at", "via"}
and the current list is the fold of the events, latest wins. Nothing is ever
diff --git a/intake/notify_author.py b/intake/notify_author.py
index b2868a0bf79b689c49c49ecdb05312aa94d4dde4..2a991a318b9610394820a5c481fe6403519d300f 100644
--- a/intake/notify_author.py
+++ b/intake/notify_author.py
@@ -375,6 +375,7 @@ def send_decision(*, to: str, sub_id: str, title: str, author_name: str,
preprint_doi: str = "",
exclusivity_confirmed: bool = False,
curator_findings: str = "",
+ revision_of: str = "",
) -> tuple[bool, str]:
"""Send the decision email with two PDF attachments (panel report + RQC).
@@ -385,6 +386,11 @@ def send_decision(*, to: str, sub_id: str, title: str, author_name: str,
recommended and the curator decided otherwise). Empty renders a loud bracket so
the draft cannot go out without it.
+ `revision_of` is the earlier submission ID this paper revises, as the state
+ records it (only a link verified under the same ORCID is recorded). The
+ letter's header names it, so the author can tie this decision to the earlier
+ letter; empty for a first submission.
+
verdict ∈ {accept, revise, scope_reject}; source ∈ {doi, upload}. Per-source
template variants live in templates/submission__.md.
The body references the attachments rather than inlining the panel
@@ -466,6 +472,7 @@ def send_decision(*, to: str, sub_id: str, title: str, author_name: str,
subject, body = _render(template, {
"icsac_submission_id": sub_id,
+ "revision_line": f"\n**Revision of:** {revision_of}" if revision_of else "",
"title": title, "author_name": author_name,
"source_ref": source_ref or "",
"deposit_doi": deposit_doi or "",
diff --git a/intake/scripts/replay.py b/intake/scripts/replay.py
new file mode 100644
index 0000000000000000000000000000000000000000..79d2f87efe35ecef345b4e626f6d0d725bb81be1
--- /dev/null
+++ b/intake/scripts/replay.py
@@ -0,0 +1,429 @@
+#!/usr/bin/env python3
+"""Replay real submissions through intake, up to the panel and never into it,
+and grade the evidence layer against a hand-checked answer key.
+
+The corpus lives OUTSIDE the repository (unpublished author content). Its path
+comes from --corpus or ICSAC_REPLAY_CORPUS; each paper is a directory with
+ paper.pdf, submission.json as received
+ extraction.json a frozen run of the reference extractor
+ key.json the answer key (format below)
+
+Everything runs in a shadow directory: config.REVIEWS_DIR and the CiteStamp
+coverage-gap log point into it, notifications and pain signals are recorded
+instead of sent, the misattribution check records which references it would
+judge but calls no model, and the first panel call raises before any request
+is made. Nothing is written to the live submission, its state or the public
+status page. The shadow directory is removed at the end unless --keep.
+
+Stages replayed (each from the code under --code, default this checkout):
+code availability -> reference extraction (frozen by default; --extraction
+live calls the extractor) -> citation verification -> misattribution
+selection -> CiteStamp -> code package digest -> blind-review compaction ->
+the panel prompt (captured, not sent).
+
+Answer key (key.json):
+ {"references": [{"find": , "level": L,
+ "id": , "alt_ids": [...], "ok_ids": [...],
+ "wrong_ids": [...], "suggest": , "note": ""}],
+ "extraction": {"count": n},
+ "code_availability": {"missing_link": bool},
+ "code_digest": {"fetched": bool, "must_contain": [...]},
+ "compaction": {"panel_must_not_contain": [...], "min_kept_ratio": 0.5},
+ "evidence": {"must_contain": [...], "must_not_contain": [...]}}
+ L is CONFIRMED, DOI MISMATCH, DOI DEAD, WEB SOURCE or UNCONFIRMED (no
+ identifier was cited: an abstention or a match to one of ok_ids is right).
+
+Check groups. A run is green when every HARD group passes:
+ identity HARD levels and record ids of references with an identifier
+ or a URL; a wrong record reported as the cited one fails
+ extraction HARD every key reference found (count checked in live mode)
+ code_avail HARD the code/data link precheck
+ code_digest HARD the digest fetched (or not) and names what it must
+ compaction HARD no author identifier reaches the panel prompt
+ evidence HARD the panel-facing citation block says what the key says
+ fail_closed --bar 2+ only identity-confirmed references can ground an
+ integrity finding: misattribution and CiteStamp see
+ confirmed references only, and a text-only match is
+ shown to the panel as unconfirmed, without the record
+ fuzzy --bar 3 no text-only match to a wrong record at all
+
+Usage (repo root, prod venv):
+ ICSAC_REPLAY_CORPUS=~/corpus .venv/bin/python intake/scripts/replay.py
+ ... --paper P1 --code /path/to/older/checkout --skip compaction,citestamp
+Exit 0 when every paper is green at the requested bar, 1 otherwise.
+"""
+from __future__ import annotations
+
+import argparse
+import copy
+import importlib
+import json
+import os
+import re
+import shutil
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+HERE = Path(__file__).resolve()
+DEFAULT_CODE = HERE.parents[2]
+STAGES = ("citestamp", "digest", "compaction")
+
+
+class StopBeforePanel(Exception):
+ """Raised by the first panel call; carries the prompt it would have sent."""
+
+ def __init__(self, prompt: str):
+ super().__init__("replay stops before the panel")
+ self.prompt = prompt
+
+
+# ── identifiers and levels (independent of the code under test) ──────────
+def norm_id(v) -> str:
+ s = str(v or "").strip().lower()
+ for p in ("https://doi.org/", "http://doi.org/", "https://dx.doi.org/", "doi:"):
+ if s.startswith(p):
+ s = s[len(p):]
+ m = re.fullmatch(r"(?:arxiv:|10\.48550/arxiv\.)(\d{4}\.\d{4,5})(v\d+)?", s)
+ return f"arxiv:{m.group(1)}" if m else s.rstrip("/.")
+
+
+def level_of(c: dict) -> str:
+ """The evidence level a verified citation record carries, read from its raw
+ fields so that the grader does not trust the labeller it is grading."""
+ if c.get("confidence") == "web-source":
+ return "WEB SOURCE"
+ ident = c.get("doi_identity")
+ if not c.get("verified") and ident == "mismatch":
+ return "DOI MISMATCH"
+ if not c.get("verified") and ident == "dead":
+ return "DOI DEAD"
+ if c.get("verified") and c.get("confidence") == "exact-id":
+ return "CONFIRMED"
+ if c.get("verified") or c.get("candidate_id"):
+ return "MATCHED"
+ return "UNVERIFIABLE"
+
+
+def record_id_of(c: dict) -> str:
+ return norm_id(c.get("resolved_id") or c.get("candidate_id"))
+
+
+# ── the run ──────────────────────────────────────────────────────────────
+def _load_code(code_dir: Path):
+ for name in list(sys.modules):
+ if name.split(".")[0] in ("config", "review", "citation_verify", "citation_misattribution",
+ "citestamp_check", "code_digest", "code_availability", "review_compaction",
+ "submission_intake", "notify", "intake", "redaction", "publications",
+ "review_quality_control", "panel_retry"):
+ del sys.modules[name]
+ sys.path[:] = [p for p in sys.path if Path(p or ".").resolve() != DEFAULT_CODE]
+ sys.path.insert(0, str(code_dir))
+ return importlib.import_module("config")
+
+
+def run_paper(paper_dir: Path, code_dir: Path, extraction: str, skip: set, shadow: Path, log) -> dict:
+ config = _load_code(code_dir)
+ config.REVIEWS_DIR = str(shadow / "reviews")
+ os.makedirs(config.REVIEWS_DIR, exist_ok=True)
+ out = {"events": [], "notifications": [], "stages": {}}
+ rid = "ICSAC-SUB-TEST-REPLAY-" + re.sub(r"[^A-Za-z0-9]", "", paper_dir.name)
+
+ def rec(name):
+ def _f(*a, **k):
+ out["notifications"].append(name)
+ return None
+ return _f
+
+ import notify
+ for name in dir(notify):
+ if not name.startswith("_") and callable(getattr(notify, name)) and name[0].islower():
+ setattr(notify, name, rec(f"notify.{name}"))
+ import review
+ for name in ("_fire_compaction_pain", "_fire_pain", "fire_pain"):
+ if hasattr(review, name):
+ setattr(review, name, rec(f"review.{name}"))
+
+ def _guard(prompt=None, *a, **k):
+ raise StopBeforePanel(prompt if isinstance(prompt, str) else "")
+ for name in ("_run_single_pass", "_run_panel_chain", "run_claude_review", "run_openrouter_review"):
+ if hasattr(review, name):
+ setattr(review, name, _guard)
+
+ import citation_verify
+ frozen = json.loads((paper_dir / "extraction.json").read_text())
+ if extraction == "frozen":
+ citation_verify.extract_citations = lambda text, record_id, *a, **k: copy.deepcopy(frozen)
+
+ try:
+ import citestamp_check
+ citestamp_check.COVERAGE_GAP_LOG = str(shadow / "reviews" / "citestamp_coverage_gaps.jsonl")
+ except ImportError:
+ citestamp_check = None
+ if "citestamp" in skip and hasattr(review, "_run_citestamp_check"):
+ review._run_citestamp_check = lambda record_id, citations, report, *a, **k: report
+
+ import citation_misattribution as cm
+ seen = {"eligible": None, "checked": None}
+ real_select = cm.select_load_bearing
+
+ def _select(citations, full_text, *a, **k):
+ eligible = real_select(citations, full_text, max_n=10 ** 6)
+ seen["eligible"] = [copy.deepcopy(c) for c in eligible]
+ return eligible
+
+ def _check(load_bearing, *a, **k):
+ seen["checked"] = [copy.deepcopy(c) for c in load_bearing]
+ return [] # no model is called in a replay
+ cm.select_load_bearing, cm.check_misattribution_batch = _select, _check
+
+ if "digest" in skip and hasattr(review, "_run_code_digest"):
+ review._run_code_digest = lambda review_data, *a, **k: "(code package digest skipped by replay)"
+ if "compaction" in skip:
+ import review_compaction
+ review_compaction.compact_paper = lambda text, *a, **k: (text, {
+ "original_chars": len(text), "redacted_chars": len(text), "reduction_pct": 0, "replay_skipped": True})
+
+ # review_data exactly as the worker builds it (a copy of the paper dir; the
+ # worker's builder only reads submission.json and paper.pdf)
+ sub_dir = shadow / "submission"
+ sub_dir.mkdir()
+ for f in ("paper.pdf", "submission.json"):
+ shutil.copyfile(paper_dir / f, sub_dir / f)
+ submission = json.loads((sub_dir / "submission.json").read_text())
+ try:
+ from intake import submission_worker
+ review_data = submission_worker._build_review_data(rid, sub_dir)
+ out["builder"] = "worker"
+ except Exception as exc:
+ import submission_intake
+ out["builder"] = f"harness ({type(exc).__name__})"
+ review_data = {"record_id": rid, "title": submission.get("title") or "", "creators": submission.get("creators") or [],
+ "description": submission.get("abstract") or "", "keywords": submission.get("keywords") or [],
+ "pdf_path": str(sub_dir / "paper.pdf"),
+ "full_text": submission_intake.extract_pdf_text(str(sub_dir / "paper.pdf")),
+ "submission": {"code_data": submission.get("code_data") or {},
+ "related_identifiers": submission.get("related_identifiers") or []}}
+ review_data["record_id"] = rid
+
+ t0 = time.time()
+ try:
+ import code_availability
+ out["code_availability"] = code_availability.assess(submission, review_data.get("full_text", ""))
+ except ImportError:
+ out["code_availability"] = None
+ out["stages"]["code_availability"] = round(time.time() - t0, 1)
+
+ prompt = None
+ t0 = time.time()
+ try:
+ markdown, aggregate = review.review_paper(review_data)
+ out["stopped"] = f"review_paper returned before the panel: {aggregate.get('recommendation')}"
+ out["aggregate"] = {k: aggregate.get(k) for k in ("recommendation", "reason")}
+ out["compaction_manifest"] = aggregate.get("compaction_manifest")
+ except StopBeforePanel as stop:
+ prompt = stop.prompt
+ out["stopped"] = "before the panel"
+ out["stages"]["review_paper_until_panel"] = round(time.time() - t0, 1)
+ out["prompt"] = prompt or ""
+ cj = shadow / "reviews" / f"{rid}_citations.json"
+ out["citations"] = json.loads(cj.read_text()) if cj.exists() else {}
+ cmd = shadow / "reviews" / f"{rid}_citations.md"
+ out["report"] = cmd.read_text() if cmd.exists() else ""
+ dg = shadow / "reviews" / f"{rid}_code_digest.md"
+ out["digest"] = dg.read_text() if dg.exists() else ""
+ out["misattribution_eligible"] = seen["eligible"]
+ out["full_text_chars"] = len(review_data.get("full_text") or "")
+ if out.get("compaction_manifest") is None and prompt is not None:
+ out["compaction_manifest"] = "(in prompt)"
+ return out
+
+
+# ── grading ──────────────────────────────────────────────────────────────
+def _find(cits: list, rx: str) -> list:
+ r = re.compile(rx, re.I)
+ return [c for c in cits if r.search(c.get("raw") or "")]
+
+
+def grade(key: dict, run: dict, bar: int, skip: set, extraction: str) -> dict:
+ groups = {g: [] for g in ("identity", "extraction", "code_avail", "code_digest", "compaction",
+ "evidence", "fail_closed", "fuzzy")}
+ cits = (run.get("citations") or {}).get("citations") or []
+ prompt = run.get("prompt") or ""
+ if not prompt:
+ groups["evidence"].append(f"no panel prompt captured ({run.get('stopped')})")
+
+ def fail(group, msg):
+ groups[group].append(msg)
+
+ keyed = []
+ for k in key.get("references", []):
+ hits = _find(cits, k["find"])
+ if len(hits) != 1:
+ fail("extraction", f"{k['find']!r}: {len(hits)} extracted references match (want 1)")
+ continue
+ c = hits[0]
+ keyed.append((k, c))
+ got, rid = level_of(c), record_id_of(c)
+ want = k["level"]
+ right = {norm_id(x) for x in [k.get("id")] + k.get("alt_ids", []) + k.get("ok_ids", []) if x}
+ wrong = {norm_id(x) for x in k.get("wrong_ids", [])}
+ tag = f"{k['find']!r}: want {want}, got {got}" + (f" -> {rid}" if rid else "")
+ if want == "CONFIRMED":
+ if got != "CONFIRMED" or rid not in right:
+ fail("identity", tag)
+ elif want in ("DOI MISMATCH", "DOI DEAD"):
+ if got != want:
+ fail("identity", tag + (" (a bad identifier reported as real)" if got in ("CONFIRMED", "MATCHED") else ""))
+ elif k.get("suggest") and norm_id(c.get("suggested_id")) != norm_id(k["suggest"]):
+ fail("fuzzy", f"{k['find']!r}: suggestion {c.get('suggested_id')} (want {k['suggest']})")
+ elif want == "WEB SOURCE":
+ if got in ("CONFIRMED", "MATCHED") and rid not in right:
+ fail("identity", tag + " (a web page reported as an unrelated registry work)")
+ elif want == "UNCONFIRMED":
+ if got == "CONFIRMED":
+ fail("identity", tag + " (no identifier was cited, nothing can be confirmed)")
+ elif got == "MATCHED" and rid not in right and not k.get("any_match_ok"):
+ fail("fuzzy", tag + (" (a known wrong record)" if rid in wrong else " (not the cited work)"))
+ else:
+ fail("identity", f"{k['find']!r}: unknown key level {want}")
+
+ ex = key.get("extraction") or {}
+ if extraction == "live" and ex.get("count") is not None and abs(len(cits) - ex["count"]) > ex.get("tolerance", 0):
+ fail("extraction", f"{len(cits)} references extracted (key: {ex['count']})")
+
+ ca = key.get("code_availability")
+ if ca is not None:
+ got = run.get("code_availability")
+ if got is None:
+ fail("code_avail", "the code under test has no code/data precheck")
+ elif bool(got.get("missing_link")) != ca["missing_link"]:
+ fail("code_avail", f"missing_link {got.get('missing_link')} (key {ca['missing_link']})")
+
+ cd = key.get("code_digest")
+ if cd is not None and "digest" not in skip:
+ dig = run.get("digest") or ""
+ fetched = bool(dig) and "not fetched" not in dig and "(none" not in dig and "unavailable" not in dig
+ if fetched != cd["fetched"]:
+ fail("code_digest", f"package fetched={fetched} (key {cd['fetched']})")
+ for s in cd.get("must_contain", []):
+ if s not in dig:
+ fail("code_digest", f"digest lacks {s!r}")
+ elif prompt and s not in prompt:
+ fail("code_digest", f"the panel prompt lacks {s!r}")
+
+ cp = key.get("compaction") or {}
+ if "compaction" not in skip and prompt:
+ for s in cp.get("panel_must_not_contain", []):
+ if re.search(re.escape(s), prompt, re.I):
+ fail("compaction", f"author identifier reaches the panel: {s!r}")
+ man = run.get("compaction_manifest")
+ if isinstance(man, dict) and man.get("_failure"):
+ fail("compaction", f"compaction failed: {man['_failure']}")
+ if run.get("full_text_chars") and len(prompt) < cp.get("min_kept_ratio", 0.5) * run["full_text_chars"]:
+ fail("compaction", f"panel prompt {len(prompt)} chars vs paper {run['full_text_chars']}")
+ elif "compaction" not in skip and not prompt:
+ fail("compaction", f"no panel prompt ({run.get('stopped')})")
+
+ ev = key.get("evidence") or {}
+ for s in ev.get("must_contain", []):
+ if s not in prompt:
+ fail("evidence", f"panel prompt lacks {s!r}")
+ for s in ev.get("must_not_contain", []):
+ if s in prompt:
+ fail("evidence", f"panel prompt carries {s!r}")
+
+ # fail closed: nothing below confirmed identity grounds an integrity finding
+ confirmed_raw = {c.get("raw") for k, c in keyed if level_of(c) == "CONFIRMED"} | \
+ {c.get("raw") for c in cits if level_of(c) == "CONFIRMED"}
+ elig = run.get("misattribution_eligible")
+ if elig is None:
+ fail("fail_closed", "misattribution selection never ran")
+ else:
+ for c in elig:
+ if c.get("raw") not in confirmed_raw:
+ fail("fail_closed", f"misattribution would judge a {level_of(c)} reference: {(c.get('raw') or '')[:70]!r}")
+ cs = (run.get("citations") or {}).get("citestamp") or {}
+ confirmed_ids = {record_id_of(c) for c in cits if level_of(c) == "CONFIRMED"}
+ for row in cs.get("results") or []:
+ if norm_id(row.get("doi")) not in confirmed_ids:
+ fail("fail_closed", f"CiteStamp queried an unconfirmed DOI {row.get('doi')}")
+ for c in cits:
+ if level_of(c) == "MATCHED":
+ rid = record_id_of(c)
+ if rid and rid in prompt.lower():
+ fail("fail_closed", f"the panel sees the unconfirmed candidate {rid} for {(c.get('raw') or '')[:60]!r}")
+
+ hard = ("identity", "extraction", "code_avail", "code_digest", "compaction", "evidence")
+ need = list(hard) + (["fail_closed"] if bar >= 2 else []) + (["fuzzy"] if bar >= 3 else [])
+ green = all(not groups[g] for g in need)
+ return {"green": green, "bar": bar, "groups": groups, "levels": {
+ k["find"]: f"{level_of(c)} {record_id_of(c)}".strip() for k, c in keyed}}
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ ap.add_argument("--corpus", default=os.environ.get("ICSAC_REPLAY_CORPUS"))
+ ap.add_argument("--paper", action="append", help="corpus subdirectory (repeatable; default all)")
+ ap.add_argument("--code", default=str(DEFAULT_CODE), help="pipeline checkout to replay")
+ ap.add_argument("--extraction", choices=("frozen", "live"), default="frozen")
+ ap.add_argument("--skip", default="", help=f"comma list of {','.join(STAGES)}")
+ ap.add_argument("--bar", type=int, default=1, choices=(1, 2, 3))
+ ap.add_argument("--keep", action="store_true", help="keep the shadow directory")
+ ap.add_argument("--out", help="write the full result JSON here")
+ a = ap.parse_args()
+ if not a.corpus or not Path(a.corpus).is_dir():
+ print("replay: no corpus (set ICSAC_REPLAY_CORPUS or --corpus)", file=sys.stderr)
+ return 2
+ skip = {s for s in a.skip.split(",") if s}
+ corpus = Path(a.corpus).expanduser()
+ papers = a.paper or sorted(p.name for p in corpus.iterdir() if (p / "key.json").exists())
+ code_dir = Path(a.code).expanduser().resolve()
+ results, all_green = {}, True
+ for name in papers:
+ pdir = corpus / name
+ key = json.loads((pdir / "key.json").read_text())
+ shadow = Path(tempfile.mkdtemp(prefix="icsac-replay-"))
+ cwd = os.getcwd()
+ try:
+ os.chdir(code_dir)
+ run = run_paper(pdir, code_dir, a.extraction, skip, shadow, log=print)
+ res = grade(key, run, a.bar, skip, a.extraction)
+ res["stages_s"] = run["stages"]
+ res["notifications"] = run["notifications"]
+ res["builder"] = run.get("builder")
+ if run.get("aggregate"):
+ man = run.get("compaction_manifest") if isinstance(run.get("compaction_manifest"), dict) else {}
+ res["stopped_early"] = f"{run['aggregate'].get('recommendation')}: {run['aggregate'].get('reason')}" + (
+ f" [compaction: {man.get('_failure')}]" if man.get("_failure") else "")
+ results[name] = res
+ if a.out:
+ res["run"] = {k: run.get(k) for k in ("stopped", "aggregate", "compaction_manifest", "code_availability",
+ "misattribution_eligible", "citations")}
+ except Exception as exc:
+ results[name] = {"green": False, "error": f"{type(exc).__name__}: {exc}"}
+ finally:
+ os.chdir(cwd)
+ if a.keep:
+ print(f" shadow kept: {shadow}")
+ else:
+ shutil.rmtree(shadow, ignore_errors=True)
+ r = results[name]
+ all_green &= bool(r.get("green"))
+ print(f"{'GREEN' if r.get('green') else 'RED '} {name} (code {code_dir.name}, bar {a.bar}, "
+ f"extraction {a.extraction}, skip {','.join(sorted(skip)) or '-'})")
+ if r.get("error"):
+ print(f" error: {r['error']}")
+ if r.get("stopped_early"):
+ print(f" stopped early: {r['stopped_early']}")
+ for g, msgs in (r.get("groups") or {}).items():
+ for m in msgs:
+ print(f" [{g}] {m}")
+ if a.out:
+ Path(a.out).write_text(json.dumps(results, indent=1, default=str))
+ return 0 if all_green else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/intake/scripts/sweep-test-tiers.sh b/intake/scripts/sweep-test-tiers.sh
index 425265a3008659292eddf759312170f1c248f023..ee1346c555b456d93339281a47747a2fa551d334 100755
--- a/intake/scripts/sweep-test-tiers.sh
+++ b/intake/scripts/sweep-test-tiers.sh
@@ -4,8 +4,8 @@
# LEAVES audit-log-test.jsonl untouched (durable history) and queue/
# untouched (active state).
#
-# Wired to cron: Sunday 04:00 ET.
-# Alert hook: a failure posts to the alert endpoint so a broken sweep doesn't go silent.
+# Runs weekly from cron. On failure it posts to the monitoring endpoint,
+# so a broken sweep doesn't go silent.
set -u
RETENTION_DAYS="${RETENTION_DAYS:-7}"
TEST_ROOT="${HOME}/icsac-submissions/test"
diff --git a/intake/scripts/test_audit_20260928.py b/intake/scripts/test_audit_20260928.py
index 45cbadb0aa81f8a329641a0fa52f21d0bf342db4..0789298bb034242ddec93d2d15bab3b5f634f44d 100644
--- a/intake/scripts/test_audit_20260928.py
+++ b/intake/scripts/test_audit_20260928.py
@@ -36,7 +36,7 @@ try:
check(not list(seq_file.parent.glob(".doi-seq.tmp-*")), "counter: no temp file left behind")
seq_file.unlink()
d = tmp / "subs" / "ICSAC-SUB-00042" / "crossref"; d.mkdir(parents=True)
- (d / "doi.json").write_text(json.dumps({"doi": "10.67697/icsac.2026.005"}))
+ (d / "doi.json").write_text(json.dumps({"doi": "10.5555/icsac.2026.005"}))
check(cd._next_seq(seq_file, 2026) == 6, "counter: a lost file resumes above the highest doi.json suffix")
check(cd._next_seq(seq_file, 2027) == 1, "counter: another year starts at 1")
seq_file.write_text("{not json")
@@ -51,11 +51,11 @@ try:
sub = tmp / "subs" / "ICSAC-SUB-00099"; (sub / "crossref").mkdir(parents=True)
shutil.copyfile(src, sub / "submission.json")
(sub / "crossref" / "staged.json").write_text(json.dumps(
- {"doi": "10.67697/icsac.2026.099", "checkpoints": {"crossref_batch_id": "b1", "crossref_deposited_at": "x"}}))
- cd.stage(sub, doi="10.67697/icsac.2026.099", log=lambda m: None)
+ {"doi": "10.5555/icsac.2026.099", "checkpoints": {"crossref_batch_id": "b1", "crossref_deposited_at": "x"}}))
+ cd.stage(sub, doi="10.5555/icsac.2026.099", log=lambda m: None)
st = json.loads((sub / "crossref" / "staged.json").read_text())
check(st.get("checkpoints", {}).get("crossref_batch_id") == "b1", "stage: an in-flight batch id survives a re-stage")
- check(st.get("doi") == "10.67697/icsac.2026.099" and (sub / "crossref" / "deposit.xml").exists(), "stage: XML + record written")
+ check(st.get("doi") == "10.5555/icsac.2026.099" and (sub / "crossref" / "deposit.xml").exists(), "stage: XML + record written")
else:
print(" skip stage() check (no SUB-00008 submission.json on this host)")
@@ -74,7 +74,7 @@ try:
check(out.get("sub_id") == "ICSAC-SUB-00001" and out.get("ssrn_id") == "123"
and out.get("license_url", "").endswith("by/4.0/"), "registry: an update keeps sub_id, ssrn_id, licence")
check(out.get("promotion_opt_out") is True, "registry: an update takes the new exclusion")
- new = pub.upsert_entry(approved=True, proto={"title": "New paper", "authors": ["B. Author"], "doi": "10.67697/icsac.2026.007",
+ new = pub.upsert_entry(approved=True, proto={"title": "New paper", "authors": ["B. Author"], "doi": "10.5555/icsac.2026.007",
"source": "submission-pdf", "sub_id": "ICSAC-SUB-00007",
"license_url": "https://creativecommons.org/licenses/by/4.0/",
"promotion_exclusions": ["social"], "persistence_opt_out": True})
diff --git a/intake/scripts/test_audit_20260929.py b/intake/scripts/test_audit_20260929.py
index 7a50e84c33a903b785a7acb12f15880c956aee0f..6cd68372edb1ffe8b019cd54431e2c4030bb200c 100644
--- a/intake/scripts/test_audit_20260929.py
+++ b/intake/scripts/test_audit_20260929.py
@@ -142,7 +142,7 @@ try:
sub3 = tmp / "ICSAC-SUB-00096"
(sub3 / "crossref").mkdir(parents=True)
(sub3 / "crossref" / "deposit.xml").write_bytes(b"")
- (sub3 / "crossref" / "staged.json").write_text(json.dumps({"doi": "10.67697/icsac.2026.999"}))
+ (sub3 / "crossref" / "staged.json").write_text(json.dumps({"doi": "10.5555/icsac.2026.999"}))
(sub3 / "submission.json").write_text(json.dumps({"title": "T"}))
(sub3 / "state.json").write_text(json.dumps({"state": "completed_email_failed", "decision": "accept"}))
os.environ.pop("ICSAC_REGISTER_WITHOUT_NOTICE", None)
diff --git a/intake/scripts/test_citation_identity.py b/intake/scripts/test_citation_identity.py
index 19e4356c5334b72aaab9c31ec665f57d26801562..ecd86354da11fbabbe55d9190ce40faed9dd5514 100644
--- a/intake/scripts/test_citation_identity.py
+++ b/intake/scripts/test_citation_identity.py
@@ -14,8 +14,9 @@ plus: a Zenodo dataset DOI confirmed through DataCite; a short registry title
("Less Is Enough") confirmed; HTML in a Crossref title; a DOI with no cited
title judged on authors + year; verify_all keeping the cited title and letting
a DOI disagreement downgrade an earlier fuzzy "verified"; the report's
-sections and wording; the header line; the CiteStamp coverage-gap log; and the
-code package digest on a synthetic archive (no network).
+sections and wording; the header line; the CiteStamp coverage-gap log; the
+code package digest on a synthetic archive (no network); and web pages cited
+by URL, which must never be matched to a registry record by a fuzzy search.
"""
from __future__ import annotations
@@ -211,8 +212,8 @@ try:
check(re.search(r"Quarrie[^\n]*— DOI DEAD\.", rep) and "10.0000/jpc.2023.0929" in rep, "Quarrie listed as DOI DEAD with the real record")
check("Brook 2026** — UNVERIFIABLE" in rep and "different work" in rep, "Brook unverifiable with the reason")
check("— REAL." not in rep, "no line says REAL any more")
- check("A. Author et al. 2025** — CONFIRMED" in rep and "Hollis 1972** — CONFIRMED" in rep, "confirmed lines")
- check("MATCHED" in rep and "existence check" in rep, "the fuzzy evidence level is explained")
+ check("A. Author et al. 2025b** — CONFIRMED" in rep and "Hollis 1972** — CONFIRMED" in rep, "confirmed lines (the cited 2025b keeps its letter)")
+ check("UNCONFIRMED" in rep and "not evidence of anything" in rep, "the text-match level is explained as no evidence")
check(re.search(r"Ferreira[^\n]*— CONFIRMED\.[^\n]*translated or variant title", rep) is not None, "the variant title is shown on the confirmed line")
cv.save_citation_report("ICSAC-SUB-TEST-1", rep_in, rep)
import review
@@ -281,15 +282,88 @@ try:
check("{{curator_findings}}" in raw and "Curation team findings" in raw, f"{tpl}: findings section present")
subj, body = na._render("submission_revise_upload.md", {
"icsac_submission_id": "ICSAC-SUB-TEST-1", "title": "T", "author_name": "A",
- "attachments_block": "(att)", "compaction_disclosure": "(disc)",
+ "attachments_block": "(att)", "compaction_disclosure": "(disc)", "revision_line": "",
"curator_findings": "1. Fix the unit in Table 2.\n2. Define the sample window."})
check("1. Fix the unit in Table 2." in body and "the decision is the curation team's" in body, "findings rendered")
subj, body = na._render("submission_revise_upload.md", {
"icsac_submission_id": "ICSAC-SUB-TEST-1", "title": "T", "author_name": "A",
- "attachments_block": "(att)", "compaction_disclosure": "(disc)",
+ "attachments_block": "(att)", "compaction_disclosure": "(disc)", "revision_line": "",
"curator_findings": "[CURATION TEAM FINDINGS - x]"})
check("[CURATION TEAM FINDINGS" in body, "empty findings leave the loud bracket")
+ # ── web pages cited by URL ───────────────────────────────────────
+ print("[11] web sources")
+ check(not cv._title_matches("Data Policy", "Research Data Policy and Practice: An Overview"),
+ "a two-word title no longer matches a longer title that contains its words")
+ check(not cv._title_matches("Code of Conduct", "A Code of Conduct for Research Integrity"), "nor one with a different main title")
+ check(cv._title_matches("Spin glasses", "Spin Glasses: Experimental Facts, Theoretical Concepts, and Open Questions"),
+ "a short cited title still matches its record's main title")
+ check(cv._title_matches("Less is enough: Small samples and the nature of field notes", "Less Is Enough"),
+ "a short registry title still matches the cited main title")
+ WEBREC = {
+ "Style Guide": {"resolver": "crossref", "resolved_id": "10.0000/journal.style",
+ "title": "Style Guide", "abstract": "", "year": 2026, "authors": []},
+ "Code of Conduct": {"resolver": "crossref", "resolved_id": "10.0000/book.conduct",
+ "title": "A Code of Conduct for Research Integrity", "abstract": "", "year": 2026, "authors": []},
+ "Tide tables": {"resolver": "crossref", "resolved_id": "10.0000/paper.tides",
+ "title": "Tide tables for small harbours", "abstract": "", "year": 2021,
+ "authors": ["Jane Smith"]},
+ }
+ S2REC = {"resolver": "semanticscholar", "resolved_id": "10.0000/data.policy",
+ "title": "Research Data Policy and Practice: An Overview",
+ "abstract": "", "year": 2026, "authors": ["Q. Other"]}
+ old_bib, old_s2 = cv._search_crossref_bibliographic, cv._search_semanticscholar
+ cv._search_crossref_bibliographic = lambda raw, year=None: next(
+ (dict(r) for k, r in WEBREC.items() if k in raw), None)
+ cv._search_semanticscholar = lambda q, year=None: dict(S2REC) if "Data Policy" in q else None
+ inst = "Example Society"
+
+ def web(org, year, page, slug, host="example.org"):
+ return {"raw": f'{org}. {year}. "{page}." https://{host}/{slug}/.',
+ "authors": [org], "year": int(str(year)[:4]), "title": page, "doi": None, "arxiv_id": None,
+ "type": "url", "claim_context": ""}
+ guide, proc = web(inst, "2025a", "Style Guide", "style-guide"), web(inst, "2025b", "Data Policy", "data-policy")
+ eth = web("Sample Board", 2024, "Code of Conduct", "code-of-conduct", "example.net")
+ rules = web("Sample Board", 2023, "Membership Rules", "rules", "example.net")
+ for name, c in (("same-titled journal page", guide), ("S2 title-only hit", proc), ("reordered-title hit", eth),
+ ("no registry hit at all", rules)):
+ v = cv.verify_citation(dict(c))
+ check(v["confidence"] == "web-source" and v["verified"] is False and v["resolved_id"].startswith("https://example.")
+ and "10.0000" not in json.dumps(v), f"{name}: WEB SOURCE, no DOI matched to it")
+ paper = {"raw": "Smith, J. 2021. Tide tables for small harbours. https://example.org/smith2021.pdf",
+ "authors": ["J. Smith"], "year": 2021, "title": "Tide tables for small harbours",
+ "doi": None, "arxiv_id": None, "type": "url", "claim_context": ""}
+ v = cv.verify_citation(dict(paper))
+ check(v["verified"] and v["confidence"] == "title-author-match" and v["resolved_id"] == "10.0000/paper.tides",
+ "a paper cited by URL that the registry matches on title AND author stays MATCHED")
+ check(cv._cited_web_url({"type": "url", "raw": "x https://doi.org/10.1/x"}) is None
+ and cv._cited_web_url({"type": "title-only", "raw": "x https://ssrn.com/abstract=1"}) is None,
+ "doi.org links and non-url types are not web sources")
+ stale = dict(proc, verified=True, confidence="title-only-match", resolved_id="10.0000/data.policy")
+ res = cv.verify_all([stale, dict(guide), dict(eth), dict(rules), dict(and_)])
+ check(res[0]["confidence"] == "web-source" and res[0]["verified"] is False,
+ "a re-check replaces an earlier fuzzy match on a web page")
+ rep = cv.build_verification_report(res)
+ check("### Cited by URL" in rep and "WEB SOURCE:" in rep, "web section + legend")
+ check(f"{inst} 2025b** — WEB SOURCE. *Data Policy*, https://example.org/data-policy/." in rep,
+ "the line names the cited page and its URL")
+ check("Practice: An Overview" not in rep and "10.0000/data.policy" not in rep and "MATCHED." not in rep,
+ "nothing unrelated is matched in the report")
+ check("### Unverifiable from public registries" not in rep, "web sources are not listed as unverifiable")
+ check(cv._short_label(guide) != cv._short_label(proc), "2025a and 2025b get distinct labels")
+ import citation_misattribution as cm
+ check(cm._short_label(guide).endswith("2025a"), "the misattribution labels carry the letter too")
+ check(not [c for c in res if c.get("confidence") == "web-source" and c.get("verified")],
+ "web sources are not verified, so the misattribution check never sees them")
+ cv.save_citation_report("ICSAC-SUB-TEST-2", res, rep)
+ line = review._citation_header_line("ICSAC-SUB-TEST-2")
+ check(line is not None and line.startswith("1/1 resolved · 4 cited by URL"), f"header line ({line})")
+ import review_quality_control as rqcm
+ ev = rqcm.build_evidence_block("ICSAC-SUB-TEST-2", config.REVIEWS_DIR)
+ check("0 UNVERIFIABLE, 4 WEB SOURCE" in ev and "— WEB SOURCE: https://example.org/data-policy/" in ev
+ and "every reference was confirmed" not in ev, "the RQC evidence names web sources as such")
+ cv._search_crossref_bibliographic, cv._search_semanticscholar = old_bib, old_s2
+
finally:
shutil.rmtree(TMP, ignore_errors=True)
diff --git a/intake/scripts/test_code_data_link.py b/intake/scripts/test_code_data_link.py
index f665e79f98ce2685e62dcd6eea1d7a0a29b20e2d..8e3fe64803369fcea7e411ae236f479e0cc4930e 100644
--- a/intake/scripts/test_code_data_link.py
+++ b/intake/scripts/test_code_data_link.py
@@ -281,7 +281,7 @@ try:
(site / "src" / "data" / "accepted.json").write_text("[]")
pub.WEBSITE_REPO = str(site); pub.REGISTRY_PATH = str(site / "src" / "data" / "accepted.json")
check(pub.code_data_url(sub) == "https://github.com/example/generator", "registry: helper reads the form link")
- e = pub.upsert_entry(approved=True, proto={"title": "T", "authors": ["A"], "doi": "10.67697/icsac.2026.999",
+ e = pub.upsert_entry(approved=True, proto={"title": "T", "authors": ["A"], "doi": "10.5555/icsac.2026.999",
"source": "submission-pdf", "code_data_url": pub.code_data_url(sub)})
check(e.get("code_data_url") == "https://github.com/example/generator", "registry: the entry keeps code_data_url")
check(pub.code_data_url({"code_data": {"available": False, "url": None}}) is None, "registry: 'no' gives no link")
diff --git a/intake/scripts/test_crossref_supplement.py b/intake/scripts/test_crossref_supplement.py
index 4cbaab3abdf14be414ca8d96be60fcfaa71c4825..b9281a3e5404f65021515c2c3b0b5aa04f616cf8 100644
--- a/intake/scripts/test_crossref_supplement.py
+++ b/intake/scripts/test_crossref_supplement.py
@@ -33,7 +33,7 @@ ZEN = "https://doi.org/10.5281/zenodo.23042786"
def build(sub: dict, content_type: str = "journal-article") -> str:
- xml = cd.build_deposit_xml(sub, doi="10.67697/icsac.2026.099",
+ xml = cd.build_deposit_xml(sub, doi="10.5555/icsac.2026.099",
landing_url="https://icsacinstitute.org/publications/a-test-paper",
pdf_url="https://icsacinstitute.org/papers/ICSAC-SUB-00099.pdf",
content_type=content_type)
@@ -95,12 +95,12 @@ try:
x = build(dict(BASE))
jm = x[x.find("")]
check(bool(x) and "")]
- check(bool(x) and "10.67697/persistence" in jm and "https://icsacinstitute.org/journal/" in jm,
+ check(bool(x) and "10.5555/persistence" in jm and "https://icsacinstitute.org/journal/" in jm,
"set: journal_metadata carries the title DOI and the journal page, and validates")
- check(x.count("10.67697/icsac.2026.099") == 1, "the article keeps its own DOI")
+ check(x.count("10.5555/icsac.2026.099") == 1, "the article keeps its own DOI")
finally:
if saved is None:
delattr(config, "CROSSREF_JOURNAL_DOI") if hasattr(config, "CROSSREF_JOURNAL_DOI") else None
@@ -110,14 +110,14 @@ finally:
print("4. the Zenodo archive copy follows the Crossref record")
import repository_deposit as rd # noqa: E402
sub = dict(BASE, resource_type="preprint", publication_date="2026-09-29")
-md = rd._build_metadata(sub, external_doi="10.67697/icsac.2026.099")
+md = rd._build_metadata(sub, external_doi="10.5555/icsac.2026.099")
check(md.get("upload_type") == "publication" and md.get("publication_type") == "article",
"with an ICSAC DOI, a form 'preprint' becomes a journal article")
check(md.get("journal_title") == getattr(config, "CROSSREF_JOURNAL_TITLE", "Persistence")
and md.get("journal_volume") == str(getattr(config, "CROSSREF_JOURNAL_VOLUME", "1")),
"it names the journal and the volume from config")
-check(md.get("doi") == "10.67697/icsac.2026.099", "the external DOI still rides on the draft")
-md = rd._build_metadata(dict(sub, resource_type="dataset"), external_doi="10.67697/icsac.2026.099")
+check(md.get("doi") == "10.5555/icsac.2026.099", "the external DOI still rides on the draft")
+md = rd._build_metadata(dict(sub, resource_type="dataset"), external_doi="10.5555/icsac.2026.099")
check(md.get("upload_type") == "publication" and md.get("publication_type") == "article",
"any form type is overridden once the work is an ICSAC-registered article")
md = rd._build_metadata(sub)
@@ -126,7 +126,7 @@ check(md.get("publication_type") == "preprint" and "journal_title" not in md,
saved_ct = getattr(config, "CROSSREF_CONTENT_TYPE", None)
try:
config.CROSSREF_CONTENT_TYPE = "report-paper"
- md = rd._build_metadata(sub, external_doi="10.67697/icsac.2026.099")
+ md = rd._build_metadata(sub, external_doi="10.5555/icsac.2026.099")
check(md.get("publication_type") == "report" and "journal_title" not in md,
"a report-paper deployment archives a report, with no journal fields")
finally:
diff --git a/intake/scripts/test_disclosure_wording.py b/intake/scripts/test_disclosure_wording.py
new file mode 100644
index 0000000000000000000000000000000000000000..59d42d5818367c0fe609b543da99d24fc11275d4
--- /dev/null
+++ b/intake/scripts/test_disclosure_wording.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+"""The author-facing compaction disclosure states only what was measured. The
+reference-entry count is a heuristic that undercounts author-year lists, so the
+letter names the removed references list by its size, never by an entry count.
+Offline, no files written. Run from the repo root with the venv:
+ .venv/bin/python intake/scripts/test_disclosure_wording.py
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+import review_compaction as rc # noqa: E402
+
+failures: list[str] = []
+
+
+def check(cond: bool, msg: str) -> None:
+ print(("ok " if cond else "FAIL ") + msg)
+ if not cond:
+ failures.append(msg)
+
+
+man = {"author_names": ["A. Author"], "references_count": 2, "references_section_chars": 6430}
+out = rc.render_manifest(man)
+ref = [line for line in out.splitlines() if "References list" in line]
+check(len(ref) == 1, "one references line")
+check(bool(ref) and "6430 characters" in ref[0], "the line gives the size of the removed list")
+check(bool(ref) and "item" not in ref[0], "the line carries no entry count")
+
+out0 = rc.render_manifest({"author_names": ["A. Author"]})
+check("References list" not in out0, "no references line when no list was removed")
+
+sys.exit(1 if failures else 0)
diff --git a/intake/scripts/test_doi_hygiene.py b/intake/scripts/test_doi_hygiene.py
new file mode 100644
index 0000000000000000000000000000000000000000..56719008f6dc1a5737c66dc0693bb9e3501ddf53
--- /dev/null
+++ b/intake/scripts/test_doi_hygiene.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""Checks that the system never looks up one of its own DOIs before Crossref
+has registered it. A doi.org lookup of a DOI that does not exist yet counts as
+a failed resolution in Crossref's monthly report for the prefix, so:
+
+ - curator pings go out with link previews off: Telegram would otherwise
+ fetch every link in a message, a doi.org link included;
+ - test fixtures never use the institute's live prefix: a fixture DOI under it
+ names a paper that does not exist, or a real one once the sequence gets there.
+
+Offline: the Telegram request is captured, never sent. Run from the repo root:
+ .venv/bin/python intake/scripts/test_doi_hygiene.py
+"""
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+failures: list[str] = []
+
+
+def check(cond: bool, name: str) -> None:
+ print((" ok " if cond else " FAIL ") + name)
+ if not cond:
+ failures.append(name)
+
+
+import config # noqa: E402
+import notify # noqa: E402
+
+# ── 1. Telegram link previews are off ────────────────────────────────────────
+sent: list[dict] = []
+
+
+class _Resp:
+ status = 200
+
+ def __init__(self, n: int):
+ self._n = n
+
+ def read(self) -> bytes:
+ return json.dumps({"ok": True, "result": {"message_id": self._n}}).encode()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+
+def fake_urlopen(req, timeout=None):
+ sent.append({"url": req.full_url, "body": json.loads(req.data.decode())})
+ return _Resp(len(sent))
+
+
+real_urlopen = notify.urllib.request.urlopen
+saved = {k: getattr(config, k, None) for k in ("TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_THREAD_ID")}
+notify.urllib.request.urlopen = fake_urlopen
+try:
+ config.TELEGRAM_TOKEN, config.TELEGRAM_CHAT_ID, config.TELEGRAM_THREAD_ID = "t0k", "-100", "7"
+ text = "DOI REGISTERED for ICSAC-SUB-TEST-1: https://doi.org/10.5555/icsac.2026.999"
+ check(notify.send_telegram(text, parse_mode=None) == 1, "plain ping: sent, message id returned")
+ check(notify.send_telegram(text) == 2, "Markdown ping: sent")
+ check(notify.send_telegram(text, parse_mode=None, chat_override="-200", thread_override="9") == 3,
+ "test-tier ping: sent to the override chat")
+ check(notify.send_telegram(text, chat_override="__suppress__") is None and len(sent) == 3,
+ "suppressed ping: nothing sent")
+ check(len(sent) == 3 and all(s["url"].endswith("/sendMessage") for s in sent),
+ "three requests, all sendMessage")
+ check(all(s["body"].get("link_preview_options") == {"is_disabled": True} for s in sent),
+ "every request turns link previews off")
+ check(all(s["body"].get("text") == text for s in sent), "the text goes out unchanged")
+ check(sent[1]["body"].get("parse_mode") == "Markdown" and "parse_mode" not in sent[0]["body"],
+ "parse mode still applies only when asked")
+ check(sent[0]["body"].get("message_thread_id") == 7
+ and sent[2]["body"].get("chat_id") == "-200" and sent[2]["body"].get("message_thread_id") == 9,
+ "the configured thread and the test-tier override still apply")
+finally:
+ notify.urllib.request.urlopen = real_urlopen
+ for k, v in saved.items():
+ setattr(config, k, v)
+
+# ── 2. no test fixture uses the live prefix ──────────────────────────────────
+prefix = str(getattr(config, "CROSSREF_PREFIX", "") or "")
+check(bool(re.fullmatch(r"10\.\d{4,9}", prefix)), f"the live prefix is configured ({prefix or 'unset'})")
+here = Path(__file__).resolve()
+hits = []
+for t in sorted(here.parent.glob("test_*.py")):
+ if t == here:
+ continue
+ for n, line in enumerate(t.read_text(errors="replace").splitlines(), 1):
+ if prefix and prefix + "/" in line:
+ hits.append(f"{t.name}:{n}")
+check(not hits, "no fixture DOI under the live prefix" + (f": {', '.join(hits[:8])}" if hits else ""))
+
+print()
+if failures:
+ print(f"FAILED: {len(failures)}")
+ for f in failures:
+ print(" - " + f)
+ sys.exit(1)
+print("ALL GREEN")
diff --git a/intake/scripts/test_doi_license.py b/intake/scripts/test_doi_license.py
index 1e72d8e7eb3a54184fd5ffac35f59048e1cdae41..64a2445837321c1f4c91ad8e779f3ae37718bb39 100644
--- a/intake/scripts/test_doi_license.py
+++ b/intake/scripts/test_doi_license.py
@@ -143,7 +143,7 @@ try:
sub = {"sub_id": "ICSAC-SUB-00099", "title": "T", "abstract": "A.", "source": "doi", "doi": "2401.12345",
"license": "cc-by-sa-4.0", "article_license": "cc-by-sa-4.0", "preprint_license": "",
"creators": [{"name": "Test, Author"}]}
- xml = cd.build_deposit_xml(sub, doi="10.67697/icsac.2026.099", landing_url="https://icsacinstitute.org/publications/t",
+ xml = cd.build_deposit_xml(sub, doi="10.5555/icsac.2026.099", landing_url="https://icsacinstitute.org/publications/t",
pdf_url=None, content_type="journal-article")
cd.validate_xml(xml)
check(b"creativecommons.org/licenses/by-sa/4.0" in xml and b"hasPreprint" in xml,
diff --git a/intake/scripts/test_doi_route.py b/intake/scripts/test_doi_route.py
index 9bb9a22a0e8407d3db7fddfb1454cc80fe58d963..9d3c9b38629516216816e3a4982172da8b77195f 100644
--- a/intake/scripts/test_doi_route.py
+++ b/intake/scripts/test_doi_route.py
@@ -53,7 +53,7 @@ try:
base = {"sub_id": "ICSAC-SUB-00099", "title": "A test paper", "abstract": "An abstract.",
"license": "cc-by-4.0", "creators": [{"name": "Test, Author", "orcid": "0000-0002-1825-0097"}]}
doi_sub = dict(base, source="doi", doi="10.5281/zenodo.123456")
- xml = cd.build_deposit_xml(doi_sub, doi="10.67697/icsac.2026.099",
+ xml = cd.build_deposit_xml(doi_sub, doi="10.5555/icsac.2026.099",
landing_url="https://icsacinstitute.org/publications/a-test-paper",
pdf_url="https://icsacinstitute.org/papers/ICSAC-SUB-00099.pdf",
content_type="journal-article")
@@ -67,12 +67,12 @@ try:
"the deposit links the preprint (hasPreprint)")
art = x[x.find(" None:
+ print(("ok " if cond else "FAIL ") + msg)
+ if not cond:
+ failures.append(msg)
+
+
+html = email_send._markdown_to_html(
+ "The review is complete.\n\n"
+ "**Submission ID:** ICSAC-SUB-00002\n**Revision of:** ICSAC-SUB-00001\n**Title:** T\n\n"
+ "Next paragraph.\n\n— ICSAC\nInstitute for Complexity Science and Advanced Computing")
+check("Submission ID: ICSAC-SUB-00002 \nRevision of: ICSAC-SUB-00001 \n"
+ "Title: T
" in html, "the header block is one item per line")
+check("— ICSAC \nInstitute for Complexity" in html, "the sign-off is one item per line")
+check("
The review is complete.
" in html and "
Next paragraph.
" in html,
+ "paragraphs stay separate paragraphs")
+check("<" in email_send._markdown_to_html("a x"), "a substituted < is still text")
+
+# Every soft line break in every template comes out as a .
+dirs = [ROOT / "intake" / "templates", ROOT / "templates"]
+n_templates = n_breaks = 0
+for d in dirs:
+ for f in sorted(d.glob("*.md")):
+ lines = [ln for ln in f.read_text().splitlines() if not ln.lower().startswith("subject:")]
+ body = "\n".join(lines)
+ out = email_send._markdown_to_html(body)
+ n_templates += 1
+ for a, b in zip(lines, lines[1:]):
+ if not a.strip() or not b.strip():
+ continue
+ if re.match(r"\s*([-*] |\d+\. |#|>|\||---)", a) or re.match(r"\s*([-*] |\d+\. |#|\||---)", b):
+ continue
+ n_breaks += 1
+ tail = re.sub(r"\{\{\w+\}\}|\*\*|\[|\]\([^)]*\)|\]", "", a).strip()[-12:]
+ check(bool(tail) and re.search(re.escape(tail) + r"[^\n]* ", out) is not None,
+ f"{d.name}/{f.name}: the break after ...{tail!r} is kept")
+check(n_templates >= 20 and n_breaks >= 30, f"looked at {n_templates} templates, {n_breaks} line breaks")
+
+sys.exit(1 if failures else 0)
diff --git a/intake/scripts/test_evidence_fail_closed.py b/intake/scripts/test_evidence_fail_closed.py
new file mode 100644
index 0000000000000000000000000000000000000000..084d504aef0e2cee747e4acef2c2123be8f427f5
--- /dev/null
+++ b/intake/scripts/test_evidence_fail_closed.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""Evidence fails closed. Offline: every registry, model and CiteStamp call is
+stubbed; one temporary directory, removed at the end, pass or fail. Run from
+the repo root with the venv:
+ .venv/bin/python intake/scripts/test_evidence_fail_closed.py
+
+Only a reference whose identity is confirmed by its own identifier can ground
+an integrity finding. A record found by text search for a reference with no
+identifier is UNCONFIRMED: the panel and the review-quality auditor see the
+level and never the record; the misattribution check never judges it;
+CiteStamp is never asked about it (nor about a DOI that points to another
+work); a deposit never links it as a . And blind-review compaction lets a
+manuscript through with no author spans only when none of the submission's own
+names, ORCID iDs, emails or affiliations occurs in its text.
+"""
+from __future__ import annotations
+
+import shutil
+import sys
+import tempfile
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+failures: list[str] = []
+
+
+def check(cond: bool, msg: str) -> None:
+ print((" ok " if cond else " FAIL ") + msg)
+ if not cond:
+ failures.append(msg)
+
+
+TMP = Path(tempfile.mkdtemp(prefix="icsac-failclosed-"))
+try:
+ import config
+ config.REVIEWS_DIR = str(TMP / "reviews")
+ import citation_verify as cv
+ import citation_misattribution as cm
+ import citestamp_check as cc
+ cc.COVERAGE_GAP_LOG = str(TMP / "reviews" / "gaps.jsonl")
+ cc.CALL_GAP = 0
+
+ confirmed = {"raw": "Holm, P. (1999). Ripples on sand. Field Notes, 3, 1-9.", "authors": ["P. Holm"], "year": 1999,
+ "title": "Ripples on sand", "cited_title": "Ripples on sand", "doi": "10.0000/fn.1999.1",
+ "verified": True, "confidence": "exact-id", "doi_identity": "confirmed",
+ "resolver": "crossref", "resolved_id": "10.0000/fn.1999.1", "abstract": "ripples", "claim_context": "ripples form"}
+ book = {"raw": "Moss, R. (2001). A Field Guide to Lichens. Example Press.", "authors": ["R. Moss"], "year": 2001,
+ "title": "Review of: A Field Guide to Lichens", "cited_title": "A Field Guide to Lichens", "doi": None,
+ "verified": True, "confidence": "title-year-match", "resolver": "crossref",
+ "resolved_id": "10.0000/rev.2002.77", "abstract": "a review", "claim_context": "lichens grow slowly"}
+ with_doi_fuzzy = dict(book, raw="Moss, R. (2001b). Lichen dates. Example Press.", doi="10.0000/lich.2001",
+ confidence="title-author-match", resolved_id="10.0000/other.9")
+ dead = {"raw": "Nobody, A. (2020). Gone. J. Lost, 1.", "authors": ["A. Nobody"], "year": 2020, "title": "Gone",
+ "doi": "10.0000/gone.1", "verified": False, "confidence": "doi-dead", "doi_identity": "dead",
+ "reason": "Cited DOI 10.0000/gone.1 does not exist at doi.org."}
+ mismatch = {"raw": "Nobody, B. (2021). Wrong. J. Else, 2.", "authors": ["B. Nobody"], "year": 2021, "title": "Wrong",
+ "doi": "10.0000/else.2", "verified": False, "confidence": "doi-mismatch", "doi_identity": "mismatch",
+ "reason": "DOI 10.0000/else.2 resolves to another work."}
+ web = {"raw": "Example Society. 2025. \"Rules.\" https://example.org/rules/.", "authors": ["Example Society"],
+ "year": 2025, "title": "Rules", "verified": False, "confidence": "web-source", "resolved_id": "https://example.org/rules/"}
+ cits = [confirmed, book, with_doi_fuzzy, dead, mismatch, web]
+
+ print("[1] levels")
+ check(cv.is_identity_confirmed(confirmed) and not cv.is_identity_confirmed(book)
+ and not cv.is_identity_confirmed(with_doi_fuzzy) and not cv.is_identity_confirmed(dead), "only exact-id is confirmed")
+ check(cv.is_unconfirmed_match(book) and cv.is_unconfirmed_match(with_doi_fuzzy)
+ and not cv.is_unconfirmed_match(web) and not cv.is_unconfirmed_match(confirmed), "text matches are unconfirmed; web sources are not")
+
+ print("[2] the panel's report")
+ rep = cv.build_verification_report(cits)
+ check("UNCONFIRMED" in rep and "not evidence of anything" in rep, "level + legend")
+ check("10.0000/rev.2002.77" not in rep and "Review of:" not in rep and "10.0000/other.9" not in rep,
+ "the text-search record is never shown")
+ check("MATCHED" not in rep, "no MATCHED level left")
+ check('Submission claim context: "lichens grow slowly"' in rep, "the claim context stays")
+ check("Moss 2001** — UNCONFIRMED" in rep, "the unconfirmed line names the reference")
+
+ print("[3] misattribution sees confirmed references only")
+ sel = cm.select_load_bearing(cits, "text")
+ check([c["raw"] for c in sel] == [confirmed["raw"]], f"eligible = the confirmed one ({len(sel)})")
+
+ print("[4] CiteStamp is asked about confirmed DOIs only")
+ asked = []
+ cc.server_info = lambda: {"name": "stub"}
+ def _tool(name, identifier, limit=25, rpc_id=2):
+ asked.append(identifier)
+ return {"groundings": [], "page": {"inferred_total": 0}}
+ cc.tool = _tool
+ res = cc.check_citations(cits, log=lambda m: None)
+ check(set(asked) == {"10.0000/fn.1999.1"} and res["queried"] == 1, f"queried {sorted(set(asked))}")
+
+ print("[5] a deposit links confirmed DOIs only")
+ import crossref_deposit as cd
+ root = ET.Element("x")
+ cd._citations(root, cits)
+ dois = [e.text for e in root.iter() if e.tag.endswith("doi")]
+ check(dois == ["10.0000/fn.1999.1"], f" links {dois}")
+
+ print("[6] the review-quality auditor's evidence block")
+ cv.save_citation_report("ICSAC-SUB-TEST-FC", cits, rep)
+ import review_quality_control as rqc
+ ev = rqc.build_evidence_block("ICSAC-SUB-TEST-FC", config.REVIEWS_DIR)
+ check("2 UNCONFIRMED (no identifier cited; not evidence)" in ev and "Moss 2001 — UNCONFIRMED" in ev, "counts + line")
+ check("10.0000/rev.2002.77" not in ev and "title-year-match" not in ev, "no record, no match mode")
+
+ print("[7] header + prompt")
+ import review
+ line = review._citation_header_line("ICSAC-SUB-TEST-FC")
+ check(line is not None and line.startswith("1/5 resolved · 2 unconfirmed · 1 cited by URL"), f"header ({line})")
+ check("UNCONFIRMED (no identifier cited" in review.REVIEW_PROMPT_TEMPLATE
+ and "MATCHED (found by text only" not in review.REVIEW_PROMPT_TEMPLATE, "panel prompt legend")
+ import json
+ payload = json.load(open(Path(config.REVIEWS_DIR) / "ICSAC-SUB-TEST-FC_citations.json"))
+ check(payload.get("confirmed_count") == 1 and payload.get("unconfirmed_count") == 2, "JSON counts")
+ print("[8] compaction: a manuscript anonymized at submission goes on; anything else still fails closed")
+ import review_compaction as rc
+ paper = ("Ripples on sand: a field study\n\nAbstract. We measure ripples.\n\n1. Introduction\n"
+ "Ripples form under flow (Holm 1999).\n\nReferences\nHolm, P. (1999). Ripples on sand.\n")
+ rc._claude_call = lambda manuscript, **k: ('{"author_names": [], "affiliations": [], "emails": [], "orcids": []}', "", 0)
+ who = {"names": ["Quill Ashdown"], "other": ["0000-0002-1825-0097", "q.ashdown@example.org", "Example Institute of Sand"]}
+ txt, man = rc.compact_paper(paper, log=lambda m: None, known_identity=who)
+ check(txt and not man.get("_failure") and man.get("anonymized_at_submission") is True,
+ "no spans + none of the submission's identifiers in the text -> proceeds, flagged")
+ check("anonymized at submission" in rc.render_manifest(man), "the author's disclosure says so")
+ for label, text in (("surname", paper + "\nThanks to Ashdown.\n"), ("ORCID", paper + "0000-0002-1825-0097\n"),
+ ("email", paper + "q.ashdown@example.org\n"), ("affiliation", paper + "Example Institute of Sand\n")):
+ txt, man = rc.compact_paper(text, log=lambda m: None, known_identity=who)
+ check(txt == "" and man.get("_failure") == "no identity spans identified", f"no spans + the {label} in the text -> fails closed")
+ txt, man = rc.compact_paper(paper, log=lambda m: None)
+ check(txt == "" and man.get("_failure"), "no spans and no known identity -> fails closed (unchanged)")
+ txt, man = rc.compact_paper(paper, log=lambda m: None, known_identity={"names": [], "other": ["x@example.org"]})
+ check(txt == "" and man.get("_failure"), "no spans and no known NAME -> fails closed")
+ import review
+ ki = review._known_identity({"creators": [{"name": "Quill Ashdown", "orcid": "0000-0002-1825-0097", "affiliation": "X"}, "Doe, Jane"],
+ "raw_metadata": {"submission": {"submitter_email": "q@example.org"}}})
+ check(ki == {"names": ["Quill Ashdown", "Doe, Jane"], "other": ["0000-0002-1825-0097", "X", "q@example.org"]}, f"known identity {ki}")
+ print("[9] no verification results is said as such, never as a clean result")
+ rd = {"title": "T", "creators": ["A"], "full_text": "x" * 300}
+ stub = "## Citation verification\n\nCitation verification was unavailable for this submission (X).\n"
+ for label, rep in (("no report", ""), ("unavailable stub", stub)):
+ pr = review.build_prompt(rd, verification_report=rep)
+ check("Nothing about its references is established" in pr
+ and pr.count("Do not write that any reference") == 1, f"{label}: the panel is told nothing was checked")
+finally:
+ shutil.rmtree(TMP, ignore_errors=True)
+
+print()
+if failures:
+ print(f"{len(failures)} FAILED:")
+ for f in failures:
+ print(" - " + f)
+ sys.exit(1)
+print("all checks passed")
diff --git a/intake/scripts/test_keywords_on_record.py b/intake/scripts/test_keywords_on_record.py
new file mode 100644
index 0000000000000000000000000000000000000000..d3190a4c75ba1bdd375552a69db3739bb3538ee3
--- /dev/null
+++ b/intake/scripts/test_keywords_on_record.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""Checks that a paper's keywords reach its public record (2026-10-02).
+Offline: no network, no mail, no pings; every write lands in one temporary
+directory that is removed at the end, pass or fail. Run from the repo root
+with the venv:
+ .venv/bin/python intake/scripts/test_keywords_on_record.py
+
+Covers: the cleaner; the registry keeping keywords; each of the four registry
+writers passing them (Zenodo community accept, DOI-route accept, Zenodo-route
+publish, Crossref-route register); the DOI route's optional form field winning
+over the source record; the backfill tool's sources and its write gate.
+"""
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+failures: list[str] = []
+
+
+def check(cond: bool, name: str) -> None:
+ print((" ok " if cond else " FAIL ") + name)
+ if not cond:
+ failures.append(name)
+
+
+tmp = Path(tempfile.mkdtemp(prefix="keywords-"))
+try:
+ import publications as pub
+ pub.READY_DIR = str(tmp / "ready-to-publish") # held entries stay inside the temp dir
+
+ # ── 1. the cleaner ───────────────────────────────────────────────────────
+ print("1. cleaner")
+ check(pub.clean_keywords(None) == [], "None -> []")
+ check(pub.clean_keywords("alpha, beta; gamma") == ["alpha", "beta", "gamma"], "string split on , and ;")
+ check(pub.clean_keywords(["alpha", "Alpha", " beta. ", ""]) == ["alpha", "beta"],
+ "trimmed, trailing full stop dropped, case-insensitive duplicate dropped")
+ check(pub.clean_keywords(["one, two", "three"]) == ["one", "two", "three"], "a pasted list inside one item is split")
+ check(pub.clean_keywords(["x" * 81, "ok"]) == ["ok"], "an item over 80 characters is dropped")
+ check(len(pub.clean_keywords([f"k{i}" for i in range(20)])) == pub.KEYWORD_LIMIT, "at most KEYWORD_LIMIT kept")
+ check(pub.clean_keywords(["en–dash term", "agent–environment"]) == ["en–dash term", "agent–environment"],
+ "non-ASCII punctuation kept as typed")
+
+ # ── 2. the registry keeps them ───────────────────────────────────────────
+ print("2. registry")
+ site = tmp / "site"; (site / "src" / "data").mkdir(parents=True)
+ reg = site / "src" / "data" / "accepted.json"
+ reg.write_text("[]")
+ pub.WEBSITE_REPO = str(site); pub.REGISTRY_PATH = str(reg)
+ e = pub.upsert_entry(approved=True, proto={"title": "T", "authors": ["A"], "doi": "10.5555/icsac.2026.990",
+ "source": "submission-pdf", "keywords": ["alpha", "beta"]})
+ check(e.get("keywords") == ["alpha", "beta"], "upsert writes keywords")
+ e = pub.upsert_entry(approved=True, proto={"title": "T", "authors": ["A"], "doi": "10.5555/icsac.2026.990",
+ "source": "submission-pdf"})
+ check(e.get("keywords") == ["alpha", "beta"], "a later upsert without keywords keeps them")
+ reg.write_text("[]")
+
+ # ── 3. the four writers pass them ────────────────────────────────────────
+ print("3. writers")
+ captured: list[dict] = []
+ real_upsert = pub.upsert_entry
+ pub.upsert_entry = lambda proto, **kw: (captured.append(proto), {})[1]
+ try:
+ import action
+ proto = action._extract_registry_entry("12345", {"metadata": {
+ "title": "T", "creators": [{"name": "Author, Test"}], "doi": "10.5281/zenodo.12345",
+ "keywords": ["alpha", "beta."]}})
+ check(proto.get("keywords") == ["alpha", "beta"], "Zenodo community accept: proto carries the record's keywords")
+ proto = action._extract_registry_entry("12345", {"metadata": {"title": "T", "creators": []}})
+ check("keywords" not in proto, "Zenodo community accept: no keywords -> no field")
+
+ sub_dir = tmp / "ICSAC-SUB-99990"; sub_dir.mkdir()
+ (sub_dir / "state.json").write_text("{}")
+ sub = {"title": "T", "abstract": "A", "keywords": ["alpha", "beta"],
+ "creators": [{"name": "Author, Test"}], "form": {"name": "Test Author"}, "license": "cc-by-4.0"}
+
+ import crossref_deposit
+ captured.clear()
+ crossref_deposit._push_publications(sub_dir, sub, "10.5555/icsac.2026.990", log=lambda *a: None)
+ check(captured and captured[0].get("keywords") == ["alpha", "beta"], "Crossref-route register: proto carries keywords")
+
+ import publish_watcher
+ captured.clear()
+ try:
+ publish_watcher._register_published(sub_dir, dict(sub, keywords=[]),
+ {"doi": "10.5281/zenodo.12345", "record_id": "12345",
+ "metadata": {"title": "T", "keywords": ["from", "record"]}})
+ except Exception:
+ pass # held entry: the function may stop after the upsert; the proto is what is checked
+ check(captured and captured[0].get("keywords") == ["from", "record"],
+ "Zenodo-route publish: falls back to the deposit's keywords")
+
+ from intake import submission_worker as sw
+ captured.clear()
+ sw._register_doi_accept("ICSAC-SUB-99990", sub_dir, dict(sub, doi="10.5555/icsac.2026.990"))
+ check(captured and captured[0].get("keywords") == ["alpha", "beta"], "DOI-route accept: proto carries keywords")
+ finally:
+ pub.upsert_entry = real_upsert
+
+ # ── 4. DOI route: the form field wins over the source record ─────────────
+ print("4. DOI route form field")
+ src = (ROOT / "intake" / "submission_worker.py").read_text()
+ check('keywords = submission.get("keywords") or review_meta.get("keywords") or []' in src,
+ "worker: author keywords first, source record second")
+ srv = (ROOT / "intake" / "intake_server.py").read_text()
+ check(srv.count('form.get("keywords") or ""') == 2, "server: both handlers read the optional DOI-route field")
+
+ # ── 5. the backfill tool ─────────────────────────────────────────────────
+ print("5. backfill tool")
+ spec = importlib.util.spec_from_file_location("backfill", ROOT / "tools" / "backfill-keywords.py")
+ bf = importlib.util.module_from_spec(spec); spec.loader.exec_module(bf)
+ subs = tmp / "subs"; (subs / "ICSAC-SUB-99991").mkdir(parents=True)
+ (subs / "ICSAC-SUB-99991" / "submission.json").write_text(json.dumps({"keywords": ["one", "two."]}))
+ bf.SUBMISSIONS_ROOT = subs
+ check(bf.from_submission("ICSAC-SUB-99991") == ["one", "two"], "reads and cleans a submission's keywords")
+ check(bf.from_submission("ICSAC-SUB-99999") == [], "a missing submission gives none")
+ reg.write_text(json.dumps([{"slug": "t", "sub_id": "ICSAC-SUB-99991", "title": "T", "authors": ["A"],
+ "doi": "10.5555/icsac.2026.991", "accepted_date": "2026-10-02",
+ "source": "submission-pdf"}]))
+ os.environ.pop("ICSAC_PUBLISH_APPROVED", None)
+ sys.argv = ["backfill", "--write"]
+ check(bf.main() == 2 and "keywords" not in json.loads(reg.read_text())[0], "--write refuses without approval")
+ sys.argv = ["backfill"]
+ check(bf.main() == 0 and "keywords" not in json.loads(reg.read_text())[0], "dry run writes nothing")
+ os.environ["ICSAC_PUBLISH_APPROVED"] = "1"
+ sys.argv = ["backfill", "--write"]
+ check(bf.main() == 0 and json.loads(reg.read_text())[0].get("keywords") == ["one", "two"],
+ "--write with approval fills the entry")
+ os.environ.pop("ICSAC_PUBLISH_APPROVED", None)
+finally:
+ shutil.rmtree(tmp, ignore_errors=True)
+
+print(f"\n{'PASS' if not failures else 'FAILED'}: {len(failures)} failure(s)")
+sys.exit(0 if not failures else 1)
diff --git a/intake/scripts/test_post_publication.py b/intake/scripts/test_post_publication.py
index d5272ddf0546f5b1085563334460a6504cdc0af0..f7d938a10d619dbb7c76c940f43a4a37562eba70 100644
--- a/intake/scripts/test_post_publication.py
+++ b/intake/scripts/test_post_publication.py
@@ -248,16 +248,16 @@ def main() -> int:
real_send_followup(sub_id="ICSAC-SUB-TEST-1", tier=3, **common)
real_send_followup(sub_id="ICSAC-SUB-TEST-1", tier=1, **common) # a test id can never go out plain
na.send_published(to="a@example.com", sub_id="ICSAC-SUB-00001", title="T", author_name="A",
- deposit_doi="10.67697/icsac.2026.001", deposit_url="https://doi.org/x",
+ deposit_doi="10.5555/icsac.2026.001", deposit_url="https://doi.org/x",
publications_url="https://icsacinstitute.org/publications/x", stay_involved=True)
na.send_published(to="a@example.com", sub_id="ICSAC-SUB-00001", title="T", author_name="A",
- deposit_doi="10.67697/icsac.2026.001", deposit_url="https://doi.org/x",
+ deposit_doi="10.5555/icsac.2026.001", deposit_url="https://doi.org/x",
publications_url="https://icsacinstitute.org/publications/x", stay_involved=False)
na.send_published(to="a@example.com", sub_id="ICSAC-SUB-TEST-1", title="T", author_name="A",
- deposit_doi="10.67697/TEST.x", deposit_url="https://doi.org/x",
+ deposit_doi="10.5555/TEST.x", deposit_url="https://doi.org/x",
publications_url="https://icsacinstitute.org/publications/x", tier=3)
na.send_published(to="a@example.com", sub_id="ICSAC-SUB-TEST-1", title="T", author_name="A",
- deposit_doi="10.67697/TEST.x", deposit_url="https://doi.org/x",
+ deposit_doi="10.5555/TEST.x", deposit_url="https://doi.org/x",
publications_url="https://icsacinstitute.org/publications/x") # tier omitted
check(len(sent) == 8, f"eight renders reached the transport ({len(sent)})")
check(all(not k.get("send") for k in sent), "no render ever asks the transport to SEND")
diff --git a/intake/scripts/test_preprint_doi.py b/intake/scripts/test_preprint_doi.py
index 67d86d74cfcec6cbf87ce2e853e31319b916cb72..a268ce64cae54e4d4f9fd83b6583c1a316274921 100644
--- a/intake/scripts/test_preprint_doi.py
+++ b/intake/scripts/test_preprint_doi.py
@@ -177,7 +177,7 @@ try:
import crossref_deposit as cd
check(cd.preprint_doi(up_rec) == "10.20944/preprints202607.0102.v1", "the pipeline reads the upload route's preprint")
sub = dict(up_rec, sub_id="ICSAC-SUB-00099")
- xml = cd.build_deposit_xml(sub, doi="10.67697/icsac.2026.099", landing_url="https://icsacinstitute.org/publications/t",
+ xml = cd.build_deposit_xml(sub, doi="10.5555/icsac.2026.099", landing_url="https://icsacinstitute.org/publications/t",
pdf_url=None, content_type="journal-article")
cd.validate_xml(xml)
check(b'relationship-type="hasPreprint"' in xml and b"10.20944/preprints202607.0102.v1" in xml,
diff --git a/intake/scripts/test_publish_gate.py b/intake/scripts/test_publish_gate.py
index 21f7d7dee6d1a84513a6713b0b18a5164ba073ac..751235cded12c94f369544c8f71269776bc092bc 100644
--- a/intake/scripts/test_publish_gate.py
+++ b/intake/scripts/test_publish_gate.py
@@ -51,7 +51,7 @@ try:
real_run = subprocess.run
pub.subprocess.run = lambda cmd, **k: (git_calls.append(cmd), subprocess.CompletedProcess(cmd, 0, "", ""))[1]
- proto = {"title": "A held paper", "authors": ["A. Author"], "doi": "10.67697/icsac.2026.990",
+ proto = {"title": "A held paper", "authors": ["A. Author"], "doi": "10.5555/icsac.2026.990",
"source": "submission-pdf", "sub_id": "ICSAC-SUB-00990"}
print("1. the writers refuse without approval")
diff --git a/intake/scripts/test_register_resume.py b/intake/scripts/test_register_resume.py
index 3fcb28609a04c32c73cb8bdce98e8ae831d4f556..ff2b33271ad54c949ef66808e6496607a18cebd3 100644
--- a/intake/scripts/test_register_resume.py
+++ b/intake/scripts/test_register_resume.py
@@ -36,7 +36,7 @@ try:
(sub / "submission.json").write_text(json.dumps({"sub_id": sub.name, "title": "T", "source": "upload"}))
(sub / "state.json").write_text(json.dumps({"decision": "accept", "state": "completed"}))
(sub / "crossref/deposit.xml").write_bytes(b"")
- (sub / "crossref/staged.json").write_text(json.dumps({"doi": "10.67697/icsac.2026.999", "registered": False}))
+ (sub / "crossref/staged.json").write_text(json.dumps({"doi": "10.5555/icsac.2026.999", "registered": False}))
calls = {"deposit": 0, "stage": 0}
cd.stage = lambda *a, **k: calls.__setitem__("stage", calls["stage"] + 1)
diff --git a/intake/scripts/test_revision_line.py b/intake/scripts/test_revision_line.py
new file mode 100644
index 0000000000000000000000000000000000000000..77055d9679b0d8e1792124978dccc577887f3fdd
--- /dev/null
+++ b/intake/scripts/test_revision_line.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""A decision letter for a revised paper names the earlier submission it
+revises, in the header under the submission ID; a first submission's letter
+carries no such line. Every decision template carries the slot, and both
+callers of send_decision pass the recorded link. Offline, mail call stubbed,
+no files written. Run from the repo root with the venv:
+ .venv/bin/python intake/scripts/test_revision_line.py
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+from intake import notify_author as na # noqa: E402
+
+failures: list[str] = []
+
+
+def check(cond: bool, msg: str) -> None:
+ print(("ok " if cond else "FAIL ") + msg)
+ if not cond:
+ failures.append(msg)
+
+
+DECISION_TEMPLATES = ("accept_doi", "accept_upload", "accept_upload_pending", "revise_codelink",
+ "revise_doi", "revise_upload", "scope_reject_doi", "scope_reject_upload")
+for t in DECISION_TEMPLATES:
+ raw = (na.TEMPLATES_DIR / f"submission_{t}.md").read_text()
+ check("**Submission ID:** {{icsac_submission_id}}{{revision_line}}\n" in raw,
+ f"submission_{t}.md: the revision slot sits on the submission ID line")
+for t in ("received", "intake_failure"):
+ raw = (na.TEMPLATES_DIR / f"submission_{t}.md").read_text()
+ check("{{revision_line}}" not in raw, f"submission_{t}.md: no slot (its sender fills no such key)")
+
+sent: list[dict] = []
+_orig = na.email_send.send_email
+
+
+def _stub(**kw):
+ sent.append(kw)
+ return True, "stub"
+
+
+na.email_send.send_email = _stub
+try:
+ common = dict(to="ada@example.com", sub_id="ICSAC-SUB-TEST-2000000002",
+ title="A Test Paper", author_name="Ada Example",
+ panel_report_md="", curator_findings="1. A finding.",
+ compaction_manifest={"author_names": ["Ada Example"]})
+ for verdict, source in (("revise", "upload"), ("revise", "doi"), ("scope_reject", "upload")):
+ sent.clear()
+ na.send_decision(verdict=verdict, source=source, revision_of="ICSAC-SUB-TEST-2000000001",
+ **common)
+ body = sent[-1]["body_md"] if sent else ""
+ check("**Submission ID:** ICSAC-SUB-TEST-2000000002\n**Revision of:** ICSAC-SUB-TEST-2000000001\n**Title:**"
+ in body, f"{verdict}/{source}: a revision's letter names the earlier submission under its ID")
+ sent.clear()
+ na.send_decision(verdict=verdict, source=source, **common)
+ body = sent[-1]["body_md"] if sent else ""
+ check(bool(body) and "Revision of" not in body
+ and "**Submission ID:** ICSAC-SUB-TEST-2000000002\n**Title:**" in body,
+ f"{verdict}/{source}: a first submission's letter carries no revision line")
+finally:
+ na.email_send.send_email = _orig
+
+for caller, expr in (("intake/apply_decision.py", 'revision_of=state_pre.get("revision_of") or ""'),
+ ("intake/submission_worker.py", 'revision_of=state.get("revision_of") or ""')):
+ src = (ROOT / caller).read_text()
+ check(src.count("send_decision(") == 1 and expr in src.split("send_decision(", 1)[1].split("\n )")[0],
+ f"{caller}: the send_decision call passes the recorded link")
+
+sys.exit(1 if failures else 0)
diff --git a/intake/scripts/test_rqc_v2.py b/intake/scripts/test_rqc_v2.py
index 306b64d9a2511c3d67fab47d9c47947fe1b8bca2..7c102322de2e37e5d133a0a0f855946f794a2d8c 100644
--- a/intake/scripts/test_rqc_v2.py
+++ b/intake/scripts/test_rqc_v2.py
@@ -57,9 +57,9 @@ try:
print("1. evidence block")
ev = rqc.build_evidence_block(rid, str(rdir))
- check("5 references; 1 CONFIRMED by identifier, 1 MATCHED by text only, 2 with a DOI that disagrees (mismatch or dead), 1 UNVERIFIABLE" in ev, "counts line")
+ check("5 references; 1 CONFIRMED by identifier, 1 UNCONFIRMED (no identifier cited; not evidence), 2 with a DOI that disagrees (mismatch or dead), 1 UNVERIFIABLE" in ev, "counts line")
check("A. Nobody et al. 2024 — DOI MISMATCH" in ev and "Moreau 2024 — DOI DEAD" in ev, "the lines the panel had to act on")
- check("Brook 2026 — UNVERIFIABLE" in ev and "Hale 1983 — MATCHED by text [title-author-match]" in ev, "unverifiable + matched lines")
+ check("Brook 2026 — UNVERIFIABLE" in ev and "Hale 1983 — UNCONFIRMED: no identifier cited" in ev, "unverifiable + matched lines")
check("CODE PACKAGE DIGEST" in ev and "a.py: compute_xrm" in ev and "a.py:5: xrm = a * b + c" in ev, "digest definitions + construct lines")
check("Files (2)" not in ev and "README head" not in ev, "file list and README are left out (the auditor needs definitions, not the inventory)")
ev_b = rqc.build_evidence_block(rid, str(rdir), {"author_names": ["Ada Author"], "affiliations": [], "emails": [], "orcids": []})
diff --git a/intake/scripts/test_workers_ai.py b/intake/scripts/test_workers_ai.py
new file mode 100644
index 0000000000000000000000000000000000000000..51410bc70d5eeace7b8a2da187131c37a2b0f1f1
--- /dev/null
+++ b/intake/scripts/test_workers_ai.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Workers AI and Mistral as panel providers, and the usage guard that keeps
+Workers AI inside its free daily allocation and Mistral under its monthly cap. Offline: the HTTP call is stubbed, nothing is sent, and the
+ledger lives in a temporary directory that is removed at the end, pass or fail.
+Run from the repo root: .venv/bin/python intake/scripts/test_workers_ai.py
+"""
+from __future__ import annotations
+
+import io
+import json
+import os
+import shutil
+import sys
+import tempfile
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(REPO))
+
+failures: list[str] = []
+
+
+def check(cond: bool, msg: str) -> None:
+ print((" ok " if cond else " FAIL ") + msg)
+ if not cond:
+ failures.append(msg)
+
+
+TMP = Path(tempfile.mkdtemp(prefix="icsac-wai-"))
+try:
+ os.environ["ICSAC_STATE_DIR"] = str(TMP)
+ os.environ["TEST_WAI_KEY"] = "test-key"
+ import config
+ import review
+ import redaction
+
+ RATES = {"@cf/x/model-a": (31818, 68182)}
+ SPEC = {"base_url": "https://example.invalid/ai/v1", "key_env": "TEST_WAI_KEY",
+ "daily_neuron_cap": 9000, "neuron_rates": RATES}
+ config.OAI_COMPAT_PROVIDERS["cftest"] = SPEC
+ ledger_path = Path(review._usage_ledger_path("cftest"))
+
+ sent = []
+ reply = {"usage": {"prompt_tokens": 7000, "completion_tokens": 900},
+ "choices": [{"message": {"content": "{}"}}]}
+ mode = {"http": None}
+
+ class _Resp(io.BytesIO):
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def fake_urlopen(req, timeout=None):
+ sent.append(req.full_url)
+ if mode["http"]:
+ raise urllib.error.HTTPError(req.full_url, mode["http"], "err", {}, io.BytesIO(b"{}"))
+ return _Resp(json.dumps(reply).encode())
+ urllib.request.urlopen = fake_urlopen
+
+ def ledger_today():
+ if not ledger_path.exists():
+ return 0
+ d = json.loads(ledger_path.read_text())
+ return d.get(review.datetime.now(review.timezone.utc).strftime("%Y-%m-%d"), 0)
+
+ prompt = "x" * 30000 # estimate: 30000/3 tokens in + 4000 out = ceil(318.18 + 272.73) = 591
+
+ print("[1] a call within the allocation is sent and settled to the reported usage")
+ r = review.run_oai_compat_review(prompt, "cftest", "@cf/x/model-a")
+ check(len(sent) == 1 and sent[0].endswith("/chat/completions"), "one request sent")
+ check(ledger_today() == 285, f"ledger = ceil(7000 in + 900 out at the model's rates) = 285 ({ledger_today()})")
+
+ print("[2] a call that could cross the cap is refused before anything is sent")
+ ledger_path.write_text(json.dumps({review.datetime.now(review.timezone.utc).strftime("%Y-%m-%d"): 8500}))
+ sent.clear()
+ r = review.run_oai_compat_review(prompt, "cftest", "@cf/x/model-a")
+ check(not sent and "free-allocation guard" in r.get("error", "") and "8500 used + 591 estimated" in r["error"],
+ f"refused, nothing sent ({r.get('error', '')[:90]})")
+ check(ledger_today() == 8500, "a refusal reserves nothing")
+
+ print("[3] a model with no rate on file is refused")
+ ledger_path.unlink()
+ r = review.run_oai_compat_review(prompt, "cftest", "@cf/x/unpriced")
+ check(not sent and "rate on file" in r.get("error", ""), "refused, nothing sent")
+
+ print("[4] an HTTP 4xx releases the reservation; a reply without usage keeps it")
+ mode["http"] = 401
+ r = review.run_oai_compat_review(prompt, "cftest", "@cf/x/model-a")
+ check(len(sent) == 1 and "HTTP 401" in r.get("error", "") and ledger_today() == 0, f"401: ledger back to 0 ({ledger_today()})")
+ mode["http"] = None
+ reply.pop("usage")
+ r = review.run_oai_compat_review(prompt, "cftest", "@cf/x/model-a")
+ check(ledger_today() == 591, f"no usage figures: the 591 reservation stands ({ledger_today()})")
+
+ print("[5] a provider without a cap is never metered; the ledger keeps 7 days")
+ check(review._usage_reserve("x", {"base_url": "u"}, "m", prompt, 4000) == (None, 0, ""), "no cap, no reservation")
+ old = {f"2026-09-{d:02d}": 1 for d in range(1, 21)}
+ ledger_path.write_text(json.dumps(old))
+ review._usage_ledger_update("cftest", lambda l: None, 7)
+ check(len(json.loads(ledger_path.read_text())) == 7, "pruned to the last 7 days")
+
+ print("[6] configuration")
+ import importlib.util
+ spec = importlib.util.spec_from_file_location("config_example", REPO / "config.example.py")
+ ex = importlib.util.module_from_spec(spec); spec.loader.exec_module(ex)
+ cf = ex.OAI_COMPAT_PROVIDERS.get("cloudflare") or {}
+ check(cf.get("key_env") == "CLOUDFLARE_AI_TOKEN" and cf.get("daily_neuron_cap") == 9000
+ and "/ai/v1" in cf.get("base_url", ""), "config.example: the cloudflare provider, cap 9000 by default")
+ roster_cf = [e.split("|", 2)[2] for slot in ex.OPENROUTER_MODELS for e in slot if e.startswith("oai|cloudflare|")]
+ check(all(m in cf.get("neuron_rates", {}) for m in roster_cf), f"every cloudflare roster entry has a rate ({roster_cf})")
+ mi = ex.OAI_COMPAT_PROVIDERS.get("mistral") or {}
+ check(mi.get("monthly_cap_usd") == 8.0 and mi.get("key_env") == "MISTRAL_API_KEY", "config.example: mistral capped at $8/month by default")
+ roster_mi = [e.split("|", 2)[2] for slot in ex.OPENROUTER_MODELS for e in slot if e.startswith("oai|mistral|")]
+ check(all(m in mi.get("usd_rates", {}) for m in roster_mi), f"every mistral roster entry has a USD rate ({roster_mi})")
+ check("@cf/" in redaction.FORBIDDEN_VENDOR_TOKENS, "the route prefix is a forbidden self-identification token")
+
+ print("[7] a monthly USD cap (Mistral): metered in its own ledger, refused before it can pass the cap")
+ reply["usage"] = {"prompt_tokens": 7000, "completion_tokens": 900}
+ config.OAI_COMPAT_PROVIDERS["mtest"] = {"base_url": "https://example.invalid/v1", "key_env": "TEST_WAI_KEY",
+ "monthly_cap_usd": 8.0, "usd_rates": {"m-medium": (2.0, 10.0)}}
+ month = review.datetime.now(review.timezone.utc).strftime("%Y-%m")
+ mpath = Path(review._usage_ledger_path("mtest"))
+ cf_before = ledger_path.read_text() if ledger_path.exists() else ""
+ sent.clear()
+ r = review.run_oai_compat_review(prompt, "mtest", "m-medium")
+ m = json.loads(mpath.read_text())
+ check(len(sent) == 1 and m == {month: 0.023}, f"settled to 7000 in + 900 out at $2/$10 per M = $0.023 ({m})")
+ check((ledger_path.read_text() if ledger_path.exists() else "") == cf_before and mpath.name == "mtest_usage.json",
+ "its own ledger file; the neuron ledger is untouched")
+ mpath.write_text(json.dumps({month: 7.95}))
+ sent.clear()
+ r = review.run_oai_compat_review(prompt, "mtest", "m-medium")
+ check(not sent and "monthly spending guard" in r.get("error", "") and "$7.9500 used + $0.0600 estimated > $8.00" in r["error"],
+ f"refused before sending ({r.get('error', '')[:100]})")
+ check(json.loads(mpath.read_text()) == {month: 7.95}, "a refusal reserves nothing")
+ check(review._model_identity("cloudflare:@cf/openai/gpt-oss-120b") == review._model_identity("hf|openai/gpt-oss-120b:deepinfra"),
+ "the same model served by two providers is one reviewer")
+finally:
+ shutil.rmtree(TMP, ignore_errors=True)
+
+print()
+if failures:
+ print(f"{len(failures)} FAILED:")
+ for f in failures:
+ print(" - " + f)
+ sys.exit(1)
+print("all checks passed")
diff --git a/intake/scripts/test_zenodo_community.py b/intake/scripts/test_zenodo_community.py
index a96d8efc6368ec383faf21592e84def31c00ee3a..4b7fab9945a6eeb7dc861cf2fdbbf8e5c392f448 100644
--- a/intake/scripts/test_zenodo_community.py
+++ b/intake/scripts/test_zenodo_community.py
@@ -75,7 +75,7 @@ try:
(sub / "submission.json").write_text(json.dumps({"sub_id": sub.name, "title": "T", "source": "upload"}))
(sub / "state.json").write_text(json.dumps({"decision": "accept", "state": "completed", "deposit_record_id": "555"}))
(sub / "crossref/deposit.xml").write_bytes(b"")
- (sub / "crossref/staged.json").write_text(json.dumps({"doi": "10.67697/icsac.2026.998", "registered": False}))
+ (sub / "crossref/staged.json").write_text(json.dumps({"doi": "10.5555/icsac.2026.998", "registered": False}))
cd.stage = lambda *a, **k: None
cd.validate_xml = lambda b: None
cd.deposit = lambda *a, **k: "batch-1"
diff --git a/intake/sponsor_logo.py b/intake/sponsor_logo.py
index 7f508a21f161aef89e7e28756fe99ed530ad51f4..556d53ce688e0fe1b08b1bd59c1ac10cd700c65d 100644
--- a/intake/sponsor_logo.py
+++ b/intake/sponsor_logo.py
@@ -45,9 +45,8 @@ from fastapi import HTTPException, Request, UploadFile
# sessions whose subscription items don't reference this product.
SPONSOR_PRODUCT_ID = "prod_UXiXkUln7XbrQz"
-# Website repo on this host. The intake server runs as `orangepi`; the
-# repo's SSH remote uses the github.com-3rwt alias which already has
-# the dedicated push key.
+# Website repo checkout on the intake host. Its SSH remote uses a host
+# alias that already carries a dedicated push key.
WEBSITE_REPO = Path.home() / "Desktop" / "icsac" / "icsacinstitute.org"
LOGO_DIR = WEBSITE_REPO / "public" / "supporter-logos"
SUPPORTERS_YAML = WEBSITE_REPO / "src" / "data" / "supporters.yaml"
diff --git a/intake/submission_worker.py b/intake/submission_worker.py
index d9fdcfae0218190a52645da4a1d8bac2adb3e282..1810446c3f971376e9ed9540a715ef563c879fba 100644
--- a/intake/submission_worker.py
+++ b/intake/submission_worker.py
@@ -220,7 +220,8 @@ def _resolve_pending_doi(sub_id: str, sub_dir: Path,
paper_path.unlink()
src.rename(paper_path)
- keywords = review_meta.get("keywords") or []
+ # Keywords the author typed on the DOI form win; the source record's fill in.
+ keywords = submission.get("keywords") or review_meta.get("keywords") or []
if not isinstance(keywords, list):
keywords = [str(keywords)]
license_id = ""
@@ -707,6 +708,7 @@ def _email_decision(sub_id: str, sub_dir: Path, verdict: str,
deposit_doi=deposit_doi, deposit_url=deposit_url,
publications_url=publications_url,
tier=tier,
+ revision_of=state.get("revision_of") or "",
)
if ok and tier == 1:
# Decision emails go to Gmail Drafts (per 2026-05-07 policy).
@@ -791,6 +793,9 @@ def _register_doi_accept(sub_id: str, sub_dir: Path,
cdu = publications.code_data_url(submission)
if cdu:
proto["code_data_url"] = cdu
+ kw = publications.clean_keywords(submission.get("keywords"))
+ if kw:
+ proto["keywords"] = kw
entry = publications.upsert_entry(proto)
if not entry:
diff --git a/intake/templates/submission_accept_doi.md b/intake/templates/submission_accept_doi.md
index df23b78077590256a85460005a52772535f7bdcc..29b7992cd479f719589691c797c1ff7c8f1612dc 100644
--- a/intake/templates/submission_accept_doi.md
+++ b/intake/templates/submission_accept_doi.md
@@ -4,7 +4,7 @@ Subject: ICSAC review complete — recommend — {{icsac_submission_id}}
The ICSAC reviewer panel has completed its evaluation of your submission.
-**Submission ID:** {{icsac_submission_id}}
+**Submission ID:** {{icsac_submission_id}}{{revision_line}}
**Title:** {{title}}
**Decision:** Recommend for publication
diff --git a/intake/templates/submission_accept_upload.md b/intake/templates/submission_accept_upload.md
index 4ce2c2d648590af337875872fce1af370d9a3478..39893557f19e7c5a5c94ae47ebe02e864aa0dcb5 100644
--- a/intake/templates/submission_accept_upload.md
+++ b/intake/templates/submission_accept_upload.md
@@ -4,7 +4,7 @@ Subject: ICSAC review complete — recommend — {{icsac_submission_id}}
The ICSAC reviewer panel has completed its evaluation of your submission.
-**Submission ID:** {{icsac_submission_id}}
+**Submission ID:** {{icsac_submission_id}}{{revision_line}}
**Title:** {{title}}
**Decision:** Recommend for publication
diff --git a/intake/templates/submission_accept_upload_pending.md b/intake/templates/submission_accept_upload_pending.md
index 6f11c379256c793d7589157036d8d9d34d7a67a2..37a88af79238944d5bec8e8322a5a03c1193b802 100644
--- a/intake/templates/submission_accept_upload_pending.md
+++ b/intake/templates/submission_accept_upload_pending.md
@@ -4,7 +4,7 @@ Subject: ICSAC review complete — accepted — {{icsac_submission_id}}
Your manuscript has been accepted for publication by the Institute for Complexity Science and Advanced Computing.
-**Submission ID:** {{icsac_submission_id}}
+**Submission ID:** {{icsac_submission_id}}{{revision_line}}
**Title:** {{title}}
**Received:** {{received_date}}
**Decision:** {{decided_date}} — accepted
diff --git a/intake/templates/submission_revise_codelink.md b/intake/templates/submission_revise_codelink.md
index 9c132c173b5872e258294592391d937fdcdbfda7..564b19112a5129a25f5e8271c64142ccbe0bad78 100644
--- a/intake/templates/submission_revise_codelink.md
+++ b/intake/templates/submission_revise_codelink.md
@@ -4,7 +4,7 @@ Subject: ICSAC submission — revise and resubmit — {{icsac_submission_id}}
Thank you for submitting your manuscript to the Institute for Complexity Science and Advanced Computing.
-**Submission ID:** {{icsac_submission_id}}
+**Submission ID:** {{icsac_submission_id}}{{revision_line}}
**Title:** {{title}}
**Decision:** Revise and resubmit
diff --git a/intake/templates/submission_revise_doi.md b/intake/templates/submission_revise_doi.md
index fe85e33fa8f6e220d9130a2cb717f6c5054bacba..2dd33fbd92dd9a847817108ba9c554b3bcaabc2f 100644
--- a/intake/templates/submission_revise_doi.md
+++ b/intake/templates/submission_revise_doi.md
@@ -4,7 +4,7 @@ Subject: ICSAC review complete — revise and resubmit — {{icsac_submission_id
The review of your submission is complete.
-**Submission ID:** {{icsac_submission_id}}
+**Submission ID:** {{icsac_submission_id}}{{revision_line}}
**Title:** {{title}}
**Decision:** Revise and resubmit
@@ -22,7 +22,7 @@ Revise the manuscript and submit the new version through the same form at [icsac
If you think a finding above is mistaken, leave the paper as it was on that point and say so in an email to [help@icsacinstitute.org](mailto:help@icsacinstitute.org) with this submission ID in the subject line. The curation team reads every such message, and a finding we got wrong is withdrawn.
-Resubmissions are evaluated by a fresh panel run; reviewer identities are not preserved across rounds, and there is no implicit credit for the original review. Each pass is independent.
+A resubmission gets a fresh panel run: the panel does not see this letter or the earlier review. The curation team does, and checks the new version against the findings in this letter.
There is no limit on the number of revise-and-resubmit rounds. ICSAC does not charge submission, review, or article-processing fees at any stage.
diff --git a/intake/templates/submission_revise_upload.md b/intake/templates/submission_revise_upload.md
index fe85e33fa8f6e220d9130a2cb717f6c5054bacba..2dd33fbd92dd9a847817108ba9c554b3bcaabc2f 100644
--- a/intake/templates/submission_revise_upload.md
+++ b/intake/templates/submission_revise_upload.md
@@ -4,7 +4,7 @@ Subject: ICSAC review complete — revise and resubmit — {{icsac_submission_id
The review of your submission is complete.
-**Submission ID:** {{icsac_submission_id}}
+**Submission ID:** {{icsac_submission_id}}{{revision_line}}
**Title:** {{title}}
**Decision:** Revise and resubmit
@@ -22,7 +22,7 @@ Revise the manuscript and submit the new version through the same form at [icsac
If you think a finding above is mistaken, leave the paper as it was on that point and say so in an email to [help@icsacinstitute.org](mailto:help@icsacinstitute.org) with this submission ID in the subject line. The curation team reads every such message, and a finding we got wrong is withdrawn.
-Resubmissions are evaluated by a fresh panel run; reviewer identities are not preserved across rounds, and there is no implicit credit for the original review. Each pass is independent.
+A resubmission gets a fresh panel run: the panel does not see this letter or the earlier review. The curation team does, and checks the new version against the findings in this letter.
There is no limit on the number of revise-and-resubmit rounds. ICSAC does not charge submission, review, or article-processing fees at any stage.
diff --git a/intake/templates/submission_scope_reject_doi.md b/intake/templates/submission_scope_reject_doi.md
index c39b78d7fb0d0ab8f06c862919c98b059a070f06..5ba007cb1347716ce1437a48b5062b505a4fe159 100644
--- a/intake/templates/submission_scope_reject_doi.md
+++ b/intake/templates/submission_scope_reject_doi.md
@@ -4,7 +4,7 @@ Subject: ICSAC review complete — out of scope — {{icsac_submission_id}}
The ICSAC reviewer panel has completed its evaluation of your submission.
-**Submission ID:** {{icsac_submission_id}}
+**Submission ID:** {{icsac_submission_id}}{{revision_line}}
**Title:** {{title}}
**Decision:** Scope reject
diff --git a/intake/templates/submission_scope_reject_upload.md b/intake/templates/submission_scope_reject_upload.md
index c39b78d7fb0d0ab8f06c862919c98b059a070f06..5ba007cb1347716ce1437a48b5062b505a4fe159 100644
--- a/intake/templates/submission_scope_reject_upload.md
+++ b/intake/templates/submission_scope_reject_upload.md
@@ -4,7 +4,7 @@ Subject: ICSAC review complete — out of scope — {{icsac_submission_id}}
The ICSAC reviewer panel has completed its evaluation of your submission.
-**Submission ID:** {{icsac_submission_id}}
+**Submission ID:** {{icsac_submission_id}}{{revision_line}}
**Title:** {{title}}
**Decision:** Scope reject
diff --git a/notify.py b/notify.py
index bfcf9a1450f9a6ed1636f3448fb33967bdf83dd9..4483c401c5c4574b0d722ab36a9a574ac1694741 100644
--- a/notify.py
+++ b/notify.py
@@ -51,6 +51,10 @@ def send_telegram(message: str, parse_mode: str | None = "Markdown",
payload = {
"chat_id": chat_id,
"text": message,
+ # No link previews: Telegram fetches every link in a message to build one,
+ # and a doi.org link fetched before Crossref registers the DOI counts as a
+ # failed resolution in the prefix's monthly report.
+ "link_preview_options": {"is_disabled": True},
}
if parse_mode:
payload["parse_mode"] = parse_mode
diff --git a/publications.py b/publications.py
index 31af8660fa1edb7721df784d5c9eaefc264d02ed..7b50176879320133ab2392e17c8cba0d4669a041 100644
--- a/publications.py
+++ b/publications.py
@@ -86,6 +86,32 @@ _OPTIONAL_KEYS = (
)
+KEYWORD_LIMIT = 12
+KEYWORD_MAX_CHARS = 80
+
+
+def clean_keywords(raw: Any) -> list[str]:
+ """Keywords as the paper page shows them: a list or a comma/semicolon
+ string in; each item split on commas and semicolons, trimmed, trailing
+ full stops dropped, case-insensitive duplicates and over-long items
+ removed, at most KEYWORD_LIMIT kept in the author's order."""
+ if raw is None:
+ return []
+ items = raw if isinstance(raw, (list, tuple)) else [raw]
+ out: list[str] = []
+ seen: set[str] = set()
+ for item in items:
+ for part in re.split(r"[,;]+", str(item)):
+ k = re.sub(r"\s+", " ", part).strip().rstrip(".").strip()
+ if not k or len(k) > KEYWORD_MAX_CHARS or k.casefold() in seen:
+ continue
+ seen.add(k.casefold())
+ out.append(k)
+ if len(out) >= KEYWORD_LIMIT:
+ return out
+ return out
+
+
def code_data_url(submission: dict) -> Optional[str]:
"""The author's public code and data link from the submission form, if
any. The paper page prints it as hosted by the author; ICSAC never hosts it."""
diff --git a/publish_watcher.py b/publish_watcher.py
index 5cead0c3e92e62599a3ac31799ba66565d9e2eb0..56cee598ab48125bcf877c9efbbde2977d267670 100644
--- a/publish_watcher.py
+++ b/publish_watcher.py
@@ -185,6 +185,9 @@ def _register_published(sub_dir: Path, submission: dict, deposit: dict) -> dict:
cdu = publications.code_data_url(submission)
if cdu:
proto["code_data_url"] = cdu
+ kw = publications.clean_keywords(submission.get("keywords") or metadata.get("keywords"))
+ if kw:
+ proto["keywords"] = kw
entry = publications.upsert_entry(proto)
if not entry:
diff --git a/redaction.py b/redaction.py
index 85d8e08bcd57352addf57b14f613672458e236d5..2f302b24450cb080c350540ccfa1b4daf0bf07e0 100644
--- a/redaction.py
+++ b/redaction.py
@@ -36,6 +36,7 @@ FORBIDDEN_VENDOR_TOKENS: tuple[str, ...] = (
# direct-provider labels from `oai|` panel entries (2026-09-28)
"sambanova",
"deepinfra",
+ "@cf/",
"mistral-large",
"mistral-medium",
"anthropic",
diff --git a/review.py b/review.py
index 238b329c0f004eb2589fcd1d80a37b756f46acd6..6820fea486ae8a890ab16cb254639757b20f9f80 100644
--- a/review.py
+++ b/review.py
@@ -161,13 +161,14 @@ REVIEW_PROMPT_TEMPLATE = textwrap.dedent("""\
(c) IDENTIFIER DISAGREEMENT. The citation verification (its legend above these
instructions, its per-reference CITATION VERIFICATION RESULTS inside the
submission block) checked every reference and marks each one CONFIRMED (identifier resolves
- and matches), MATCHED (found by text only; existence, not identity),
+ and matches), UNCONFIRMED (no identifier cited; identity not checked, no record shown),
DOI MISMATCH / DOI DEAD (the cited DOI points to a different work, or
nowhere), or UNVERIFIABLE. A MISMATCH or DEAD line is a citation-integrity
finding to score and to name in the justification: it shows a wrong
identifier, wrong authors, or a reference assembled from several works,
and it bears on the paper's statement about how its references were
- checked. A MATCHED line is not proof of the cited authors, venue or pages.
+ checked. An UNCONFIRMED line is not evidence of anything: it is neither a
+ finding against the paper nor a credit to it.
Score the dimension based on (a)+(b)+(c) combined. If you cannot verify (a) one
way or the other, weight (b) more heavily and explicitly say so in the
@@ -234,6 +235,16 @@ def _creator_display_names(creators) -> list[str]:
return out or ["Unknown"]
+# What the panel reads when there are no per-reference results. A bare "(none)"
+# was read by some models as "no problems found" and reported as a clean
+# verification; the empty case has to say that nothing was checked.
+NO_VERIFICATION_DATA = (
+ "(No per-reference citation results: verification was not run, or failed, for "
+ "this submission. Nothing about its references is established -- neither that "
+ "they exist nor that they are the works cited. Do not write that any reference "
+ "was confirmed, matched or found free of problems; call them unverified.)")
+
+
def split_verification_report(report: str) -> tuple[str, str]:
"""(legend, per-reference data). The legend is the fixed text before the
first '### ' section and carries nothing from the submission; everything
@@ -278,7 +289,7 @@ def build_prompt(review_data: dict, verification_report: str = "") -> str:
full_text=full_text,
related_identifiers=related_str,
code_digest=(review_data.get("code_digest") or "").strip() or "(none)",
- verification_data=verification_data or "(none)",
+ verification_data=verification_data or NO_VERIFICATION_DATA,
)
head = legend
if rubric_context:
@@ -637,6 +648,107 @@ def run_hf_router_review(prompt: str, hf_model: str, capture_path: str = None) -
return result
+def _usage_terms(spec: dict):
+ """The usage cap a provider carries, or None. Workers AI: a daily neuron cap
+ (a free daily allocation, billed past it on a paid account). Mistral: a
+ monthly USD cap (a card-backed account that must never pass its allowance).
+ Rates are units per million input / output tokens."""
+ if spec.get("daily_neuron_cap"):
+ return {"period": "day", "cap": spec["daily_neuron_cap"], "rates": spec.get("neuron_rates") or {},
+ "unit": "neurons", "keep": 7}
+ if spec.get("monthly_cap_usd"):
+ return {"period": "month", "cap": spec["monthly_cap_usd"], "rates": spec.get("usd_rates") or {},
+ "unit": "USD", "keep": 13}
+ return None
+
+
+def _usage_ledger_path(provider: str) -> str:
+ d = os.environ.get("ICSAC_STATE_DIR") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
+ return os.path.join(d, "cf_neurons.json" if provider == "cloudflare" else f"{provider}_usage.json")
+
+
+def _usage_ledger_update(provider: str, fn, keep: int = 7):
+ """Read-modify-write one provider's usage ledger under an exclusive lock
+ (panel slots call in parallel). `fn(ledger) -> result`; the ledger keeps the
+ last `keep` periods."""
+ import fcntl
+ path = _usage_ledger_path(provider)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path + ".lock", "a") as lk:
+ fcntl.flock(lk, fcntl.LOCK_EX)
+ try:
+ with open(path) as f:
+ ledger = json.load(f)
+ except (OSError, ValueError):
+ ledger = {}
+ result = fn(ledger)
+ for key in sorted(ledger)[:-keep]:
+ ledger.pop(key, None)
+ tmp = path + ".tmp"
+ with open(tmp, "w") as f:
+ json.dump(ledger, f)
+ os.replace(tmp, path)
+ return result
+
+
+def _usage_amount(terms: dict, tokens_in: float, tokens_out: float, rate) -> float:
+ import math
+ x = tokens_in * rate[0] / 1e6 + tokens_out * rate[1] / 1e6
+ return math.ceil(x) if terms["unit"] == "neurons" else math.ceil(x * 1e4) / 1e4
+
+
+def _usage_reserve(provider: str, spec: dict, model: str, prompt: str, max_tokens: int):
+ """A provider with a usage cap is metered BEFORE the call. Reserves a
+ deliberately high estimate (a token per 3 characters of prompt, every output
+ token used) and refuses the call when the period's total (UTC day or UTC
+ month) could cross the cap, or when the model has no rate on file.
+ Returns (period_key, reserved, error)."""
+ terms = _usage_terms(spec)
+ if not terms:
+ return None, 0, ""
+ rate = terms["rates"].get(model)
+ if not rate:
+ return None, 0, f"no {terms['unit']} rate on file for {model}; refused so the cap cannot be overrun"
+ est = _usage_amount(terms, len(prompt) / 3, max_tokens, rate)
+ now = datetime.now(timezone.utc)
+ key = now.strftime("%Y-%m-%d") if terms["period"] == "day" else now.strftime("%Y-%m")
+ cap = terms["cap"]
+
+ def _take(ledger):
+ used = ledger.get(key, 0)
+ if used + est > cap:
+ if terms["unit"] == "USD":
+ return (f"monthly spending guard: ${used:.4f} used + ${est:.4f} estimated > ${cap:.2f} "
+ f"(resets on the 1st, UTC)")
+ return (f"daily free-allocation guard: {used} used + {est} estimated > {cap} neurons "
+ f"(resets 00:00 UTC)")
+ ledger[key] = round(used + est, 4)
+ return ""
+ err = _usage_ledger_update(provider, _take, terms["keep"])
+ return (None, 0, err) if err else (key, est, "")
+
+
+def _usage_settle(provider: str, spec: dict, model: str, key, reserved, usage=None) -> None:
+ """Replace a reservation with what the reported token counts imply (or with
+ 0 when the call was refused before inference). Without usage figures the
+ reservation stands."""
+ terms = _usage_terms(spec)
+ if not terms or not key or not reserved:
+ return
+ if usage == 0:
+ actual = 0
+ elif isinstance(usage, dict) and usage.get("prompt_tokens") is not None:
+ rate = terms["rates"].get(model) or (0, 0)
+ actual = _usage_amount(terms, int(usage.get("prompt_tokens") or 0),
+ int(usage.get("completion_tokens") or 0), rate)
+ else:
+ return
+
+ def _fix(ledger):
+ ledger[key] = round(max(0, ledger.get(key, 0) - reserved + actual), 4)
+ _usage_ledger_update(provider, _fix, terms["keep"])
+
+
def run_oai_compat_review(prompt: str, provider: str, model: str, capture_path: str = None) -> dict:
"""Run review against an OpenAI-compatible provider called DIRECTLY with our
own key (2026-09-28): `oai||` panel entries. Providers and
@@ -660,6 +772,12 @@ def run_oai_compat_review(prompt: str, provider: str, model: str, capture_path:
"temperature": 0.3,
"max_tokens": 4000,
}
+ # Per-model request overrides (config: the provider's model_params), e.g. a
+ # larger max_tokens where hidden reasoning counts inside it, or JSON mode.
+ payload.update((spec.get("model_params") or {}).get(model) or {})
+ day, reserved, guard_err = _usage_reserve(provider, spec, model, prompt, payload["max_tokens"])
+ if guard_err:
+ return {"error": f"{provider}: {guard_err}", "model": label}
req = urllib.request.Request(spec["base_url"].rstrip("/") + "/chat/completions",
data=_json.dumps(payload).encode())
req.add_header("Authorization", f"Bearer {api_key}")
@@ -682,12 +800,16 @@ def run_oai_compat_review(prompt: str, provider: str, model: str, capture_path:
except urllib.error.HTTPError as e:
ex.shutdown(wait=False)
body = e.read()[:300].decode(errors="replace")
+ if 400 <= e.code < 500 and e.code != 408:
+ _usage_settle(provider, spec, model, day, reserved, 0) # refused before inference
return {"error": f"HTTP {e.code}: {body}", "model": label}
except Exception as e:
ex.shutdown(wait=False)
return {"error": str(e), "model": label}
ex.shutdown(wait=False)
+ if isinstance(data, dict):
+ _usage_settle(provider, spec, model, day, reserved, data.get("usage"))
if not isinstance(data, dict):
return {"error": f"unexpected {provider} response shape", "model": label}
if data.get("error"):
@@ -1605,8 +1727,14 @@ def _citation_header_line(record_id) -> str | None:
parts = []
cits = payload.get("citations") or []
if cits:
- parts.append(f"{sum(1 for c in cits if c.get('verified'))}/{len(cits)} resolved")
+ web = sum(1 for c in cits if c.get("confidence") == "web-source")
import citation_verify
+ parts.append(f"{sum(1 for c in cits if citation_verify.is_identity_confirmed(c))}/{len(cits) - web} resolved")
+ unconfirmed = sum(1 for c in cits if citation_verify.is_unconfirmed_match(c))
+ if unconfirmed:
+ parts.append(f"{unconfirmed} unconfirmed")
+ if web:
+ parts.append(f"{web} cited by URL")
idc = citation_verify.identity_counts(cits)
if idc["mismatch"] or idc["dead"]:
parts.append(f"DOI identity: {idc['mismatch']} mismatch, {idc['dead']} dead")
@@ -1720,7 +1848,8 @@ def _append_citation_verify_audit(record_id: str, citations: list[dict], error)
try:
import datetime, json as _json
verified = sum(1 for c in citations if c.get("verified"))
- unverifiable = sum(1 for c in citations if not c.get("verified"))
+ web = sum(1 for c in citations if c.get("confidence") == "web-source")
+ unverifiable = sum(1 for c in citations if not c.get("verified")) - web
entry = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"event": "citation_verify_completed",
@@ -1728,6 +1857,7 @@ def _append_citation_verify_audit(record_id: str, citations: list[dict], error)
"citation_count": len(citations),
"verified_count": verified,
"unverifiable_count": unverifiable,
+ "web_source_count": web,
"extraction_error": (
None if not error else f"{type(error).__name__}: {error}"
),
@@ -1771,6 +1901,25 @@ def _fire_compaction_pain(review_data: dict, reason: str) -> None:
pass
+def _known_identity(review_data: dict) -> dict:
+ """The submission's own identifiers, for compaction's anonymized-manuscript
+ check: creator names; creator ORCID iDs and affiliations and the submitter's
+ email and ORCID ("other", matched whole). No names -> compaction fails closed
+ on an empty extraction, as before."""
+ names, other = [], []
+ for c in review_data.get("creators") or []:
+ if isinstance(c, str):
+ names.append(c)
+ elif isinstance(c, dict):
+ if c.get("name"):
+ names.append(c["name"])
+ other += [c.get(k) for k in ("orcid", "affiliation") if c.get(k)]
+ sub = ((review_data.get("raw_metadata") or {}).get("submission") or {})
+ other += [sub.get(k) for k in ("submitter_email", "submitter_orcid") if sub.get(k)]
+ names = [n for n in names if "withheld" not in str(n)]
+ return {"names": names, "other": [o for o in other if str(o).strip()]}
+
+
def review_paper(review_data: dict) -> tuple[str, dict]:
"""Run full multi-model review with self-heal + multi-pass aggregation.
@@ -1803,6 +1952,7 @@ def review_paper(review_data: dict) -> tuple[str, dict]:
redacted_text, compaction_manifest = review_compaction.compact_paper(
original_text,
log=lambda m: print(m, file=__import__("sys").stderr),
+ known_identity=_known_identity(review_data),
)
if compaction_manifest.get("_failure"):
# FAIL CLOSED. Blind-review compaction failed, so the manuscript
diff --git a/review_compaction.py b/review_compaction.py
index 1b77453b56af7e4e01c90d02f8d53880255c062b..c605e91321df8f94ce648661838c98c633cfb16c 100644
--- a/review_compaction.py
+++ b/review_compaction.py
@@ -399,7 +399,24 @@ def _apply_removals(text: str, spans: dict) -> tuple[str, dict, list]:
return text, manifest, match_failures
-def compact_paper(paper_text: str, *, log=None) -> tuple[str, dict]:
+def identity_in_text(text: str, known_identity: dict | None) -> list[str]:
+ """Which of the submission's own identifiers occur in the text.
+ known_identity = {"names": [...], "other": [...]}: a name matches by any of
+ its words of three letters or more; ORCID iDs, emails and affiliations
+ ("other") match whole. Deterministic; over-matching only makes compaction
+ stricter."""
+ t = (text or "").lower()
+ hits: list[str] = []
+ known_identity = known_identity or {}
+ candidates = [p for n in known_identity.get("names") or [] for p in re.split(r"[\s,.]+", str(n)) if len(p) >= 3]
+ candidates += [str(v).strip() for v in known_identity.get("other") or [] if str(v).strip()]
+ for p in candidates:
+ if re.search(r"(? tuple[str, dict]:
"""Strip author/identifier metadata from a manuscript for blind review.
Returns (redacted_text, manifest). FAIL CLOSED: on any failure path the
@@ -408,6 +425,12 @@ def compact_paper(paper_text: str, *, log=None) -> tuple[str, dict]:
a "_failure" manifest as a hard stop — withhold the paper from the panel
and pause the submission — so author identity can never leak into a
"blind" review. Compaction failure BLOCKS the paper.
+
+ known_identity: the submission's own names, ORCID iDs, emails and
+ affiliations. When the extraction finds no author spans, the paper goes on
+ ONLY if none of these occurs anywhere in the text (a manuscript anonymized
+ at submission); without them, or with any of them in the text, it fails
+ closed as before.
"""
def _log(msg: str) -> None:
if log:
@@ -451,17 +474,28 @@ def compact_paper(paper_text: str, *, log=None) -> tuple[str, dict]:
manifest["_failure"] = "claude output unparseable"
return "", manifest
+ anonymized = False
if not isinstance(spans, dict) or not any(
spans.get(k) for k in ("author_names", "affiliations", "emails", "orcids")):
# The extraction identified no author spans at all: the panel would read
- # an un-blinded manuscript. FAIL CLOSED (audit 2026-09-28 item 2).
- _log(" compaction: extraction identified no author spans; FAILING CLOSED "
- "(paper withheld from panel)")
- manifest = dict(_EMPTY_MANIFEST)
- manifest["_failure"] = "no identity spans identified"
- return "", manifest
+ # an un-blinded manuscript. FAIL CLOSED (audit 2026-09-28 item 2) --
+ # unless the submission's own identifiers are known and none of them
+ # occurs in the text, i.e. the author anonymized the manuscript.
+ found = identity_in_text(paper_text, known_identity)
+ if not isinstance(spans, dict) or not (known_identity or {}).get("names") or found:
+ why = (f"; the text carries {len(found)} of the submission's own identifiers" if found else "")
+ _log(" compaction: extraction identified no author spans" + why +
+ "; FAILING CLOSED (paper withheld from panel)")
+ manifest = dict(_EMPTY_MANIFEST)
+ manifest["_failure"] = "no identity spans identified"
+ return "", manifest
+ _log(" compaction: no author spans, and none of the submission's names, ORCID iDs, "
+ "emails or affiliations occurs in the text: anonymized at submission")
+ anonymized = True
redacted, manifest, match_failures = _apply_removals(paper_text, spans)
+ if anonymized:
+ manifest["anonymized_at_submission"] = True
identity_failures = [f for f in match_failures
if f.get("category") in ("author_names", "emails", "orcids")]
@@ -513,11 +547,16 @@ def render_manifest(manifest: dict) -> str:
if manifest.get("funding_statements"):
lines.append(f" - Funding statements: {len(manifest['funding_statements'])} item(s)")
if manifest.get("references_count") or manifest.get("references_section_chars"):
+ # No entry count: the count is a heuristic (blank-line blocks for
+ # author-year lists), and PDF text puts no blank lines between entries,
+ # so a full list read as one or two items in the author's letter.
lines.append(
- f" - References list: {manifest.get('references_count', 0)} item(s) "
- f"({manifest.get('references_section_chars', 0)} characters)"
+ f" - References list ({manifest.get('references_section_chars', 0)} characters)"
)
+ if manifest.get("anonymized_at_submission"):
+ lines.insert(0, " - Nothing identifying: the manuscript was anonymized at submission (none of the "
+ "submission's names, ORCID iDs, emails or affiliations appears in it)")
if not lines:
lines.append(" (No identifying content detected to remove.)")
diff --git a/review_quality_control.py b/review_quality_control.py
index 2e9dd5f4fac1968abefd12b7455477096a6cbbb2..ba1140f9f4f92c5ec543b7425dc9b9675b6ee74f 100644
--- a/review_quality_control.py
+++ b/review_quality_control.py
@@ -207,12 +207,14 @@ def build_evidence_block(record_id: str, reviews_dir: str | None = None,
cits = payload.get("citations") or []
n = len(cits)
confirmed = [c for c in cits if c.get("verified") and c.get("confidence") == "exact-id"]
- matched = [c for c in cits if c.get("verified") and c.get("confidence") != "exact-id"]
+ matched = [c for c in cits if c.get("verified") and c.get("confidence") not in ("exact-id", "web-source")]
disagree = [c for c in cits if not c.get("verified") and c.get("doi_identity") in ("mismatch", "dead")]
- unver = [c for c in cits if not c.get("verified") and c not in disagree]
+ web = [c for c in cits if c.get("confidence") == "web-source"]
+ unver = [c for c in cits if not c.get("verified") and c not in disagree and c not in web]
lines = [f"CITATION VERIFICATION: {n} references; {len(confirmed)} CONFIRMED by identifier, "
- f"{len(matched)} MATCHED by text only, {len(disagree)} with a DOI that disagrees "
- f"(mismatch or dead), {len(unver)} UNVERIFIABLE."]
+ f"{len(matched)} UNCONFIRMED (no identifier cited; not evidence), {len(disagree)} with a DOI that disagrees "
+ f"(mismatch or dead), {len(unver)} UNVERIFIABLE, {len(web)} WEB SOURCE (cited by URL, "
+ f"not a registry work; nothing matched to it)."]
def _label(c):
a = c.get("authors") or []
base = (a[0] if len(a) == 1 else f"{a[0]} et al." if a else (c.get("cited_title") or c.get("title") or c.get("raw") or "")[:50])
@@ -225,8 +227,10 @@ def build_evidence_block(record_id: str, reviews_dir: str | None = None,
else None) or c.get("reason") or ""
lines.append(f"- {_label(c)} — UNVERIFIABLE: {why[:160]}")
for c in matched:
- lines.append(f"- {_label(c)} — MATCHED by text [{c.get('confidence')}], identity not confirmed")
- if not disagree and not unver and not matched:
+ lines.append(f"- {_label(c)} — UNCONFIRMED: no identifier cited; not evidence of anything")
+ for c in web:
+ lines.append(f"- {_label(c)} — WEB SOURCE: {c.get('resolved_id') or ''}")
+ if not disagree and not unver and not matched and not web:
lines.append("- every reference was confirmed by its identifier")
parts.append("\n".join(lines))
except Exception as exc:
diff --git a/rubrics/review_quality_control.md b/rubrics/review_quality_control.md
index 79f6f20dd1a66652abc701bfd28862b48c9e1d3d..8dfe5052ffbff130af66ffa4a38b159612f8bdf3 100644
--- a/rubrics/review_quality_control.md
+++ b/rubrics/review_quality_control.md
@@ -69,13 +69,13 @@ Scoring:
### 5. evidence_use (public)
-The panel is given the citation verification block (its legend above the instructions, its per-reference results inside the submission block) (every reference marked CONFIRMED, MATCHED, DOI MISMATCH, DOI DEAD or UNVERIFIABLE, with the reason) and the code package digest (the shipped code's file list, definitions and the lines defining the paper's named constructs). The auditor is given the same, condensed. Did the slot act on it?
+The panel is given the citation verification block (its legend above the instructions, its per-reference results inside the submission block) (every reference marked CONFIRMED, UNCONFIRMED, DOI MISMATCH, DOI DEAD, WEB SOURCE or UNVERIFIABLE, with the reason) and the code package digest (the shipped code's file list, definitions and the lines defining the paper's named constructs). The auditor is given the same, condensed. Did the slot act on it?
- **5** — Every claim the slot makes about references or code is anchored in the block: mismatched and dead DOIs are named under citation integrity; claims about what the code computes cite a file and line from the digest; anything the block cannot support is called unverified.
- **4** — Uses the block where it matters and contradicts nothing in it; one claim about references or code is loose.
- **3** — Uses part of the block: names the DOI findings but calls the analysis "reproducible" without the digest, or the reverse.
- **2** — Ignores the block: scores citation integrity or methodological transparency as if no verification existed.
-- **1** — Contradicts the block: "citations verified" against DOI MISMATCH or DOI DEAD lines; "implements the measure as described" when the digest shows a different definition; cites the digest for something it does not contain.
+- **1** — Contradicts the block: "citations verified" against DOI MISMATCH or DOI DEAD lines; "implements the measure as described" when the digest shows a different definition; cites the digest for something it does not contain; treats an UNCONFIRMED reference as a finding (misattributed, stuffed, fabricated) or as verified.
When the block says no evidence was supplied (no verification, no digest), score on whether the slot avoided asserting verification it could not have performed.
diff --git a/templates/revise-and-resubmit-comment.md b/templates/revise-and-resubmit-comment.md
index f0405a6e818288a8bdf31802f6dba53774860271..dda158909709f342b8bc2d86601d3942892b2e7f 100644
--- a/templates/revise-and-resubmit-comment.md
+++ b/templates/revise-and-resubmit-comment.md
@@ -10,7 +10,7 @@ Your submission **{{paper_title}}** has been returned with a **revise-and-resubm
## Resubmitting
-When you are ready, you may resubmit a revised version through [icsacinstitute.org/submit](https://icsacinstitute.org/submit). Resubmissions are reviewed fresh by a new panel — a prior R&R does not bias future evaluation, and there is no limit on the number of revision rounds.
+When you are ready, you may resubmit a revised version through [icsacinstitute.org/submit](https://icsacinstitute.org/submit). Resubmissions get a fresh panel run — a prior R&R does not bias future evaluation, and there is no limit on the number of revision rounds.
If you think a point above is mistaken, leave the paper as it was on that point and say so in an email to help@icsacinstitute.org with the link to your Zenodo record. The curation team reads every such message, and a point we got wrong is withdrawn.
diff --git a/templates/revise-and-resubmit.md b/templates/revise-and-resubmit.md
index a30dffeaee399fc03775d252579894adc511438c..7d913c274e134f0fa77e865d894fb83faa07e2b7 100644
--- a/templates/revise-and-resubmit.md
+++ b/templates/revise-and-resubmit.md
@@ -20,7 +20,7 @@ The panel has reviewed your work and is inviting a revised version. ICSAC's defa
## Resubmitting
-When you are ready, you may resubmit a revised version of the manuscript through [icsacinstitute.org/submit](https://icsacinstitute.org/submit). Resubmissions are reviewed fresh by a new panel — a prior revise-and-resubmit does not bias future evaluation, and there is no limit on the number of revision rounds.
+When you are ready, you may resubmit a revised version of the manuscript through [icsacinstitute.org/submit](https://icsacinstitute.org/submit). Resubmissions get a fresh panel run — a prior revise-and-resubmit does not bias future evaluation, and there is no limit on the number of revision rounds.
If a particular point of feedback is one you disagree with, you are welcome to address it in a cover note rather than changing the manuscript. The panel reads cover notes.
diff --git a/tools/backfill-keywords.py b/tools/backfill-keywords.py
new file mode 100755
index 0000000000000000000000000000000000000000..d3d7caade8b4ed9a252c1371d35cb578cccc1ad8
--- /dev/null
+++ b/tools/backfill-keywords.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""Fill the keywords on publications-registry entries (src/data/accepted.json).
+
+Where each entry's keywords come from, first hit wins:
+ 1. the submission's own keywords (sub_id -> //submission.json);
+ 2. the Zenodo record's keywords (record_id -> zenodo.org/api/records/).
+
+A dry run by default: it prints, per entry, the keywords it has and the ones it
+would get. --write saves accepted.json in ICSAC_WEBSITE_REPO and requires
+ICSAC_PUBLISH_APPROVED=1, because a pushed registry is a publication. It never
+commits or pushes; the curator does that. Entries that already carry keywords
+keep them unless --refresh is given.
+
+ ICSAC_WEBSITE_REPO= python3 tools/backfill-keywords.py [--refresh] [--write]
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import urllib.request
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+import publications # noqa: E402
+
+SUBMISSIONS_ROOT = Path(os.environ.get("ICSAC_SUBMISSIONS_ROOT", os.path.expanduser("~/icsac-submissions")))
+
+
+def from_submission(sub_id: str) -> list[str]:
+ path = SUBMISSIONS_ROOT / sub_id / "submission.json"
+ if not path.exists():
+ return []
+ return publications.clean_keywords(json.loads(path.read_text()).get("keywords"))
+
+
+def from_zenodo(record_id: str) -> list[str]:
+ req = urllib.request.Request(f"https://zenodo.org/api/records/{record_id}",
+ headers={"Accept": "application/json"})
+ with urllib.request.urlopen(req, timeout=30) as r:
+ meta = json.load(r).get("metadata") or {}
+ return publications.clean_keywords(meta.get("keywords"))
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ ap.add_argument("--write", action="store_true", help="save accepted.json (needs ICSAC_PUBLISH_APPROVED=1)")
+ ap.add_argument("--refresh", action="store_true", help="replace keywords an entry already has")
+ args = ap.parse_args()
+ if not publications.WEBSITE_REPO:
+ print("set ICSAC_WEBSITE_REPO to the website checkout", file=sys.stderr)
+ return 2
+ if args.write and not publications.publish_approved():
+ print("--write needs ICSAC_PUBLISH_APPROVED=1 (a pushed registry is a publication)", file=sys.stderr)
+ return 2
+
+ registry = publications._load_registry()
+ changed = 0
+ for entry in registry:
+ have = entry.get("keywords") or []
+ if have and not args.refresh:
+ print(f"= {entry['slug']}: keeps {have}")
+ continue
+ new: list[str] = []
+ where = ""
+ if entry.get("sub_id"):
+ new, where = from_submission(entry["sub_id"]), "submission"
+ if not new and entry.get("record_id"):
+ new, where = from_zenodo(entry["record_id"]), f"zenodo {entry['record_id']}"
+ if not new:
+ print(f"- {entry['slug']}: no keywords found")
+ continue
+ if new == have:
+ print(f"= {entry['slug']}: unchanged ({where})")
+ continue
+ print(f"+ {entry['slug']} ({where}): {have or '[]'} -> {new}")
+ entry["keywords"] = new
+ changed += 1
+
+ if args.write and changed:
+ publications._save_registry(registry)
+ print(f"\nwrote {changed} entr{'y' if changed == 1 else 'ies'} to {publications.REGISTRY_PATH}; "
+ "nothing committed or pushed")
+ else:
+ print(f"\n{changed} entr{'y' if changed == 1 else 'ies'} would change"
+ + ("" if args.write else " (dry run; --write to save)"))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())