replication-radar 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {replication_radar-0.3.0 → replication_radar-0.3.2}/PKG-INFO +2 -2
  2. {replication_radar-0.3.0 → replication_radar-0.3.2}/docs/openaire-mcp.md +9 -0
  3. {replication_radar-0.3.0 → replication_radar-0.3.2}/docs/readiness-scoring-plan.md +5 -0
  4. {replication_radar-0.3.0 → replication_radar-0.3.2}/pyproject.toml +2 -2
  5. {replication_radar-0.3.0 → replication_radar-0.3.2}/site/app.js +276 -90
  6. replication_radar-0.3.2/site/curated.json +12 -0
  7. replication_radar-0.3.2/site/index.html +58 -0
  8. {replication_radar-0.3.0 → replication_radar-0.3.2}/site/style.css +69 -31
  9. {replication_radar-0.3.0 → replication_radar-0.3.2}/src/replication_radar/__init__.py +1 -1
  10. {replication_radar-0.3.0 → replication_radar-0.3.2}/src/replication_radar/network.py +25 -1
  11. replication_radar-0.3.0/site/index.html +0 -99
  12. {replication_radar-0.3.0 → replication_radar-0.3.2}/.github/workflows/publish-pypi.yml +0 -0
  13. {replication_radar-0.3.0 → replication_radar-0.3.2}/.gitignore +0 -0
  14. {replication_radar-0.3.0 → replication_radar-0.3.2}/CLAUDE.md +0 -0
  15. {replication_radar-0.3.0 → replication_radar-0.3.2}/LICENSE +0 -0
  16. {replication_radar-0.3.0 → replication_radar-0.3.2}/README.md +0 -0
  17. {replication_radar-0.3.0 → replication_radar-0.3.2}/STORY.md +0 -0
  18. {replication_radar-0.3.0 → replication_radar-0.3.2}/demo_sdm.py +0 -0
  19. {replication_radar-0.3.0 → replication_radar-0.3.2}/docs/app-ui.md +0 -0
  20. {replication_radar-0.3.0 → replication_radar-0.3.2}/docs/link-types.md +0 -0
  21. {replication_radar-0.3.0 → replication_radar-0.3.2}/docs/next-layers-plan.md +0 -0
  22. {replication_radar-0.3.0 → replication_radar-0.3.2}/netlify.toml +0 -0
  23. {replication_radar-0.3.0 → replication_radar-0.3.2}/scripts/build_verdicts.py +0 -0
  24. {replication_radar-0.3.0 → replication_radar-0.3.2}/site/README.md +0 -0
  25. {replication_radar-0.3.0 → replication_radar-0.3.2}/site/verdicts.json +0 -0
  26. {replication_radar-0.3.0 → replication_radar-0.3.2}/src/replication_radar/data/verdicts.json +0 -0
  27. {replication_radar-0.3.0 → replication_radar-0.3.2}/src/replication_radar/openaire.py +0 -0
  28. {replication_radar-0.3.0 → replication_radar-0.3.2}/src/replication_radar/radar.py +0 -0
  29. {replication_radar-0.3.0 → replication_radar-0.3.2}/src/replication_radar/server.py +0 -0
  30. {replication_radar-0.3.0 → replication_radar-0.3.2}/src/replication_radar/verdicts.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: replication-radar
3
- Version: 0.3.0
4
- Summary: MCP server that turns the OpenAIRE Graph into a ranked replication queue — impact-ranked targets, independent reusable tooling, and the Science Live verification overlay.
3
+ Version: 0.3.2
4
+ Summary: MCP server that turns the OpenAIRE Graph into a ranked replication queue — impact-ranked targets and the Science Live verification overlay (retraction/supersession-aware).
5
5
  Project-URL: Homepage, https://github.com/ScienceLiveHub/replication-radar
6
6
  Project-URL: Repository, https://github.com/ScienceLiveHub/replication-radar
7
7
  Project-URL: Science Live, https://sciencelive4all.org
@@ -9,6 +9,15 @@
9
9
  > So MCP-backed features belong in the **`src/replication_radar` MCP server / agentic exploration**,
10
10
  > **not** in the browser app. The browser app stays on public CORS APIs (nanopub SPARQL + the
11
11
  > public OpenAIRE Graph REST API). Keep this split in mind when reading the "direction" column.
12
+ >
13
+ > **Confirmed (live `_debug.api_urls_called`, 2026-06-14):** the MCP queries the **same open
14
+ > OpenAIRE Graph** as the app — `api.openaire.eu/graph/v2/researchProducts` (search) and
15
+ > `api.openaire.eu/graph/v1/researchProducts/links` (relationships). There is **no private/fuller
16
+ > dataset** behind the gateway; OAuth is access control, not a different graph. The app uses v1
17
+ > search and does **not** call `/links` — that relationship endpoint (public, ~1.2M paper↔software
18
+ > edges globally) is the one capability the app could adopt directly, no MCP needed. For
19
+ > biodiversity/EO papers it returns only paper→paper `cites` (verified), so it does not close the
20
+ > materials gap for this domain.
12
21
 
13
22
  Directions: **(i)** verified-knowledge graph (typed links between results) · **(ii)** CoARA
14
23
  (assess researchers/projects by output *diversity* + reproducibility + reuse, not citations) ·
@@ -79,6 +79,11 @@ readiness = 0.45 * materials // per-paper (2a); null→excluded & flagged,
79
79
  - **P1 · Status taxonomy (live):** compute & group by status; surface ⭐ Replication-ready.
80
80
  - **P2 · Curated demo enrichment (baked-to-static):** for the demo set resolve materials fully (repo
81
81
  from paper, FAIR, RO-Crate, citation-network "missing edge", transfer-headroom). See `app-ui.md` §8.
82
+ **▶ STARTED 2026-06-14** (`site/curated.json` + `app.js`): paper-resolved materials baked as just
83
+ the repo link (grounded, from the paper's Data/Code statement); app overrides materials, injects the
84
+ paper so it appears for its topic, and computes **FAIR live** via existing `assessSoftware()`.
85
+ **WiSDM lit**: `code ✓` + resolved-from-paper provenance + FAIR 3/5, readiness 0.4→0.49.
86
+ TODO: add Soroye/Bombus + a HEALPix-transfer entry; then the "missing edge" graph + transfer-headroom.
82
87
  - **P3 · Close the loop:** "Start a replication" → `forrt-replication-template` (scoped) → publish
83
88
  chain → re-search shows the paper flip to ✅ Validated.
84
89
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "replication-radar"
3
- version = "0.3.0"
4
- description = "MCP server that turns the OpenAIRE Graph into a ranked replication queue — impact-ranked targets, independent reusable tooling, and the Science Live verification overlay."
3
+ version = "0.3.2"
4
+ description = "MCP server that turns the OpenAIRE Graph into a ranked replication queue — impact-ranked targets and the Science Live verification overlay (retraction/supersession-aware)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = { text = "MIT" }
@@ -7,8 +7,9 @@ const CLASS_SCORE = { C1: 1, C2: 0.8, C3: 0.6, C4: 0.4, C5: 0.2 };
7
7
  const EXAMPLES = ["species distribution", "marine heatwave", "bumble bee climate", "presence-only", "range maps scale"];
8
8
 
9
9
  let VERDICTS = {}; // doi -> [verifications]
10
+ let CLAIMS = {}; // outcome-hash -> { label, aida, type } (what exactly was replicated)
10
11
  let VERIFIED = []; // enriched: {doi, title, citations, verifications}
11
- let chart = null;
12
+ let CURATED = {}; // doi -> paper-resolved materials (the links OpenAIRE lacks; from the paper)
12
13
 
13
14
  // ---------- OpenAIRE helpers (same shape as openaire.py) ----------
14
15
  const doiOf = (r) => {
@@ -56,11 +57,85 @@ const materialsOf = (r) => {
56
57
  // Transparent composite: materials is the largest term; when materials is UNKNOWN
57
58
  // (null) we DROP that term and renormalise the rest — never score a missing link as 0.
58
59
  const readinessFrom = (matScore, impactScore, momentum) => {
59
- const r = matScore == null
60
- ? (0.35 * impactScore + 0.20 * momentum) / 0.55
61
- : (0.45 * matScore + 0.35 * impactScore + 0.20 * momentum);
60
+ // No renormalisation when materials are unknown: you can't even reproduce a paper whose
61
+ // code/data aren't surfaced, so the materials term simply contributes 0 (worth caps at 0.55).
62
+ // A paper WITH materials therefore always out-ranks an equally-cited one without.
63
+ const r = 0.45 * (matScore || 0) + 0.35 * impactScore + 0.20 * momentum;
62
64
  return Math.round(r * 100) / 100;
63
65
  };
66
+ // Status taxonomy — "not replicated" is DISAMBIGUATED, not penalised.
67
+ const STATUS = {
68
+ robust: { label: "✅ Robustly validated", cls: "st-val", tip: "multiple independent replications, all confirmed — a settled, reliable result" },
69
+ validated: { label: "✅ Validated", cls: "st-val", tip: "independently replicated and it held up" },
70
+ contested: { label: "⚠️ Contested", cls: "st-con", tip: "independent replications DISAGREE (some confirm, some contradict/partial) — worth re-checking" },
71
+ refuted: { label: "❌ Refuted", cls: "st-con", tip: "independent replication(s) contradicted it, none confirmed" },
72
+ reproducible: { label: "🔁 Reproducible", cls: "st-ready", tip: "original code/data are available, so it can be RE-RUN (reproduced). Note: replication ≠ reproduction — replication tests the same claim with DIFFERENT data/methods (FORRT)." },
73
+ needs: { label: "❔ Needs check", cls: "st-needs", tip: "not yet replicated and OpenAIRE links no materials — unknown (not absent); resolve from the paper" },
74
+ dormant: { label: "💤 Dormant", cls: "st-dorm", tip: "no verdict, older, low momentum, no materials surfaced — likely dormant" },
75
+ };
76
+ // Distinct replication OUTCOMES for a paper — one signed nanopub per independent replication
77
+ // (deduped by outcome URI, since several replications target the same claim).
78
+ const outcomesFor = (doi) => {
79
+ const seen = new Set(), out = [];
80
+ (VERDICTS[doi] || []).forEach((v, i) => {
81
+ const key = v.outcome_np || `__${i}`;
82
+ if (seen.has(key)) return;
83
+ seen.add(key);
84
+ out.push({ np: v.outcome_np || null, verdict: v.verdict || "" });
85
+ });
86
+ return out;
87
+ };
88
+ // Colour an outcome chip by its verdict so the agreement pattern is legible at a glance.
89
+ const verdictClass = (v) => /contradict|notsupport|refut/i.test(v) ? "v-con"
90
+ : /partial/i.test(v) ? "v-part"
91
+ : /validat|confirm|support/i.test(v) ? "v-ok" : "v-other";
92
+ // Short, human verdict label for a chip (no arbitrary numbers — the verdict is the meaning).
93
+ const verdictLabel = (v) => /contradict/i.test(v) ? "Contradicted"
94
+ : /notsupport/i.test(v) ? "Not supported"
95
+ : /partial/i.test(v) ? "Partial"
96
+ : /validat|confirm|support/i.test(v) ? "Validated" : (v || "outcome");
97
+ // Each replication outcome is a verdict-labelled, colour-coded chip linking to its signed nanopub
98
+ // — navy = confirmed, amber = partial, red = contradicted. No cryptic index numbers.
99
+ const outcomeLinks = (outs) => {
100
+ if (!outs.length) return "";
101
+ const lead = outs.length === 1 ? "replication outcome:" : `${outs.length} replication outcomes:`;
102
+ return ` · ${lead} ` + outs.map((o) =>
103
+ `<a class="onp ${verdictClass(o.verdict)}" href="${o.np}" target="_blank" rel="noopener" title="${esc(o.verdict)} — open the signed nanopub">${verdictLabel(o.verdict)}</a>`).join(" ");
104
+ };
105
+ // Agreement pattern across the independent replication verdicts — many-agree ≠ disagree.
106
+ const agreementOf = (doi) => {
107
+ const vs = outcomesFor(doi).map((o) => o.verdict);
108
+ const contra = vs.filter((v) => /contradict|notsupport|refut/i.test(v)).length;
109
+ const partial = vs.filter((v) => /partial/i.test(v)).length;
110
+ const confirm = vs.filter((v) => /validat|confirm|support/i.test(v) && !/partial|contradict|notsupport|refut/i.test(v)).length;
111
+ const n = vs.length;
112
+ if (contra && (confirm || partial)) return { key: "contested", why: `${n} replications disagree — ${confirm} confirm · ${partial} partial · ${contra} contradict` };
113
+ if (contra) return { key: "refuted", why: `contradicted by ${contra} replication${contra > 1 ? "s" : ""}` };
114
+ if (confirm >= 2 && !partial) return { key: "robust", why: `${confirm} independent replications, all confirmed` };
115
+ if (confirm) return { key: "validated", why: partial ? `confirmed (${n} replications, ${partial} partial)` : (n > 1 ? `confirmed (${n} replications)` : "confirmed once") };
116
+ if (partial) return { key: "validated", why: `partially supported (${partial} of ${n})` };
117
+ return { key: "validated", why: "independently checked" };
118
+ };
119
+ const statusOf = (t) => {
120
+ if (t.status === "VERIFIED") return agreementOf(t.doi).key; // robust / validated / contested / refuted
121
+ if (t.mat && t.mat.score != null) return "reproducible"; // original materials present → can re-run
122
+ const old = t.year && (new Date().getFullYear() - t.year) >= 8; // had time to be replicated
123
+ const hot = t.impl === "C1" || t.impl === "C2"; // still gaining momentum
124
+ return (old && !hot) ? "dormant" : "needs";
125
+ };
126
+ // Replication PRIORITY (0..1) — how much this would benefit from (further) replication, so the
127
+ // number and the order agree. OPEN = worth × feasible (the readiness already computed). VERIFIED =
128
+ // impact modulated by the agreement: contested/unsettled rises, robustly-validated sinks (it's done).
129
+ const VERDICT_WEIGHT = { robust: 0.2, validated: 0.4, contested: 0.95, refuted: 0.55 };
130
+ const priorityOf = (t) => {
131
+ if (t.status !== "VERIFIED") {
132
+ // dormant = old, cold, no materials → low ACTIONABILITY, so it sinks below live targets
133
+ // however high its historic citation count, matching what the "💤 Dormant" badge signals.
134
+ return t.statusKey === "dormant" ? Math.round(t.readiness * 0.5 * 100) / 100 : t.readiness;
135
+ }
136
+ const imp = Math.max(CLASS_SCORE[t.infl] || 0.2, CLASS_SCORE[t.cls] || 0.2);
137
+ return Math.round(imp * (VERDICT_WEIGHT[t.statusKey] ?? 0.4) * 100) / 100;
138
+ };
64
139
 
65
140
  async function search(topic, type, size) {
66
141
  const u = `${API}/researchProducts?search=${encodeURIComponent(topic)}&type=${type}&pageSize=${size}`;
@@ -135,9 +210,18 @@ const NP_SPARQL = "https://query.knowledgepixels.com/repo/full";
135
210
  const TPL_OUTCOME = "https://w3id.org/np/RA2zljn0Nw9SadppOyxZoh-_Rxosslrq-vYG-p9SttnJE";
136
211
  const TPL_CITO = "https://w3id.org/np/RA43F9EoOuzF0xoNUnCMNyFsfIqlsuWDdPHCnN0wCdCAw";
137
212
  const VERDICT_RELS = new Set(["confirms", "qualifies", "disputes", "critiques", "extends", "supports", "refutes"]);
213
+ // CiTO relations that are METHOD/DATA/CREDIT provenance — they point to a SOURCE paper, not a
214
+ // verdict on it. A verdict must never attach via these: a study that `usesMethodIn` Phillips 2009
215
+ // and was Contradicted does NOT contradict Phillips 2009 (it reused its method). (lowercased compare)
216
+ const NONVERDICT_RELS = new Set(["usesmethodin", "usesdatafrom", "citesasdatasource", "citesasevidence", "credits", "citesforinformation", "obtainsbackgroundfrom", "obtainssupportfrom", "citesasauthority", "citesasrelated", "citesassourcedocument", "includesquotationfrom", "sharesauthorinstitutionwith"]);
138
217
  const npHash = (u) => (u || "").replace(/.*\/np\//, "");
139
218
  const doiPart = (u) => (u || "").replace(/.*doi\.org\//, "").toLowerCase();
140
219
  const cleanRepo = (r) => (!r ? null : r.includes("doi.org/") ? doiPart(r) : /^10\./.test(r) ? r.toLowerCase() : r);
220
+ // AIDA statement URI → the atomic claim sentence (mixed +/%20 encoding in the wild).
221
+ const aidaText = (u) => { if (!u) return ""; try { return decodeURIComponent(u.replace(/.*\/aida\//, "").replace(/\+/g, " ")); } catch (e) { return ""; } };
222
+ // e.g. ".../terms/model_performance-FORRT-Claim" → "model performance"
223
+ const claimType = (u) => (!u ? "" : u.replace(/.*\/terms\//, "").replace(/-FORRT-Claim$/, "").replace(/_/g, " "));
224
+ const claimFor = (outcome_np) => CLAIMS[npHash(outcome_np)] || null;
141
225
 
142
226
  async function sparqlCsv(query) {
143
227
  const r = await fetch(`${NP_SPARQL}?query=${encodeURIComponent(query)}`, { headers: { Accept: "text/csv" } });
@@ -155,8 +239,28 @@ async function buildIndexFromNetwork() {
155
239
  SELECT DISTINCT ?outcome ?status ?repo WHERE { GRAPH ?g { ?outcome ntpl:wasCreatedFromTemplate <${TPL_OUTCOME}> . } ?outcome np:hasAssertion ?oa . GRAPH ?oa { ?oc slt:hasValidationStatus ?s . OPTIONAL { ?oc slt:hasOutcomeRepository ?repo . } } BIND(STRAFTER(STR(?s),"/terms/") AS ?status) }`;
156
240
  const QB = `PREFIX np: <http://www.nanopub.org/nschema#> PREFIX ntpl: <https://w3id.org/np/o/ntemplate/> PREFIX cito: <http://purl.org/spar/cito/>
157
241
  SELECT DISTINCT ?cito ?subj ?rel ?orig WHERE { GRAPH ?g { ?cito ntpl:wasCreatedFromTemplate <${TPL_CITO}> . } ?cito np:hasAssertion ?ca . GRAPH ?ca { ?subj ?rel ?orig . } FILTER(STRSTARTS(STR(?rel),STR(cito:))) FILTER(CONTAINS(STR(?orig),"doi.org/10.")) } LIMIT 3000`;
242
+ // QC: what exactly was replicated — traverse Outcome →isOutcomeOf→ Study →targetsClaim→ Claim,
243
+ // pulling the claim label, its AIDA statement (the atomic claim sentence), and its FORRT type.
244
+ const QC = `PREFIX np: <http://www.nanopub.org/nschema#> PREFIX ntpl: <https://w3id.org/np/o/ntemplate/> PREFIX slt: <https://w3id.org/sciencelive/o/terms/> PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
245
+ SELECT DISTINCT ?outcome ?claimLabel ?aida ?ctype WHERE { GRAPH ?og { ?outcome ntpl:wasCreatedFromTemplate <${TPL_OUTCOME}> . } ?outcome np:hasAssertion ?oa . GRAPH ?oa { ?oc slt:isOutcomeOf ?study . } GRAPH ?sg { ?study slt:targetsClaim ?claim . } GRAPH ?cg { ?claim rdfs:label ?claimLabel . } OPTIONAL { GRAPH ?cg { ?claim slt:asAidaStatement ?aida . } } OPTIONAL { GRAPH ?cg { ?claim a ?ctype . FILTER(CONTAINS(STR(?ctype),"-FORRT-Claim")) } } } LIMIT 500`;
246
+ // QV: validity guard — OUR Outcomes retracted / invalidated / superseded by a nanopub from the
247
+ // SAME creator (only the original author can retract their own work; a third-party `retracts`
248
+ // must not suppress someone else's). Disapproval is deliberately excluded — that's disagreement,
249
+ // not retraction. Anchored on the Outcome template alone to stay under the endpoint timeout.
250
+ const QV = `PREFIX ntpl: <https://w3id.org/np/o/ntemplate/> PREFIX npx: <http://purl.org/nanopub/x/> PREFIX dct: <http://purl.org/dc/terms/>
251
+ SELECT DISTINCT ?np WHERE { GRAPH ?g { ?np ntpl:wasCreatedFromTemplate <${TPL_OUTCOME}> . } GRAPH ?supg { ?sup ?act ?np . } VALUES ?act { npx:retracts npx:invalidates npx:supersedes } GRAPH ?cg1 { ?sup dct:creator ?cc . } GRAPH ?cg2 { ?np dct:creator ?cc . } }`;
158
252
  const A = await sparqlCsv(QA); // sequential: concurrent queries truncate the endpoint
159
253
  const B = await sparqlCsv(QB);
254
+ let invalid = new Set();
255
+ try { invalid = new Set((await sparqlCsv(QV)).map((r) => npHash(r.np))); } // best-effort: never break verdicts
256
+ catch (e) { /* no guard this load */ }
257
+ CLAIMS = {};
258
+ try { // claim enrichment is best-effort — never break verdicts
259
+ for (const r of await sparqlCsv(QC)) {
260
+ const h = npHash(r.outcome);
261
+ if (!CLAIMS[h]) CLAIMS[h] = { label: r.claimLabel || "", aida: aidaText(r.aida), type: claimType(r.ctype) };
262
+ }
263
+ } catch (e) { /* claims stay empty; cards still show verdict + why */ }
160
264
  const byHash = {};
161
265
  for (const r of B) {
162
266
  const h = npHash(r.subj);
@@ -164,9 +268,11 @@ SELECT DISTINCT ?cito ?subj ?rel ?orig WHERE { GRAPH ?g { ?cito ntpl:wasCreatedF
164
268
  }
165
269
  const V = {};
166
270
  for (const o of A) {
271
+ if (invalid.has(npHash(o.outcome))) continue; // drop a superseded/retracted Outcome
167
272
  const cs = byHash[npHash(o.outcome)] || [];
168
273
  const verdictCitos = cs.filter((c) => VERDICT_RELS.has(c.rel) && !c.orig.startsWith("10.5281/"));
169
- const targets = verdictCitos.length ? verdictCitos : cs.filter((c) => !c.orig.startsWith("10.5281/"));
274
+ const targets = verdictCitos.length ? verdictCitos
275
+ : cs.filter((c) => !NONVERDICT_RELS.has((c.rel || "").toLowerCase()) && !c.orig.startsWith("10.5281/"));
170
276
  for (const c of targets) {
171
277
  (V[c.orig] = V[c.orig] || []).push({
172
278
  verdict: o.status || "Published", cito: [c.rel], repo_doi: cleanRepo(o.repo),
@@ -206,6 +312,23 @@ async function loadVerdicts() {
206
312
  }));
207
313
  }
208
314
 
315
+ // ---------- curated paper-resolved materials (the links OpenAIRE doesn't hold) ----------
316
+ // curated.json carries only the repo/RO-Crate link, taken from each paper's Data/Code
317
+ // statement (grounded). FAIR is still computed LIVE by assessSoftware() against the repo.
318
+ async function loadCurated() {
319
+ try { CURATED = (await (await fetch("curated.json")).json()).resolved || {}; }
320
+ catch (e) { CURATED = {}; return; }
321
+ // enrich each with its OpenAIRE node (title / impact / year) so it can be injected + ranked
322
+ await Promise.all(Object.entries(CURATED).map(async ([doi, c]) => {
323
+ const rec = await fetchByDoi(doi);
324
+ if (rec) {
325
+ c.title = rec.mainTitle || doi; c.citations = impact(rec).citationCount || 0;
326
+ c.cls = impact(rec).citationClass; c.infl = impact(rec).influenceClass;
327
+ c.impl = impact(rec).impulseClass; c.year = yearOf(rec);
328
+ } else { c.title = c.title || doi; }
329
+ }));
330
+ }
331
+
209
332
  // ---------- the radar ----------
210
333
  // OpenAIRE returns peer-review reports, comments, errata etc. as "publications" —
211
334
  // not replication targets. Drop them, and collapse versions/duplicates by title.
@@ -232,12 +355,12 @@ async function radar(topic) {
232
355
  const rank = (r) => [CLASS_SCORE[impact(r).influenceClass] || .2, CLASS_SCORE[impact(r).citationClass] || .2, (impact(r).citationCount || 0)];
233
356
  pubs.sort((a, b) => { const A = rank(a), B = rank(b); return (B[0] - A[0]) || (B[1] - A[1]) || (B[2] - A[2]); });
234
357
 
235
- const sw = await search(topic, "software", 25); // field-level reusable tooling (shown once, below)
236
-
237
358
  const targets = pubs.slice(0, 50).map((p) => {
238
359
  const doi = doiOf(p);
239
360
  const verified = doi && VERDICTS[doi];
240
- const mat = materialsOf(p); // per-paper, grounded (unknown != absent)
361
+ let mat = materialsOf(p); // per-paper, grounded (unknown != absent)
362
+ const cur = doi && CURATED[doi]; // paper-resolved materials override (P2)
363
+ if (cur) mat = { score: cur.state === "rocrate" ? 1.0 : 0.6, state: cur.state, code: cur.code || null, resolved: true, data: cur.data || [], source: cur.source || "the paper", lang: cur.lang };
241
364
  const impactScore = classScore(p), momentum = momentumScore(p);
242
365
  const readiness = readinessFrom(mat.score, impactScore, momentum); // computed for ALL (incl. verified)
243
366
  return {
@@ -289,17 +412,6 @@ async function radar(topic) {
289
412
  .map((v) => ({ title: v.title, citations: v.citations, status: "VERIFIED" }));
290
413
  const chartItems = [...poolItems, ...extra].sort((a, b) => (b.citations || 0) - (a.citations || 0)).slice(0, 12);
291
414
 
292
- // FIELD-LEVEL independent tooling — shown ONCE, not per paper (it isn't paper-specific).
293
- // Reuse-ranked, de-duplicated, and we drop repos merely named after the query.
294
- const slug = topic.toLowerCase().replace(/\s+/g, "-");
295
- const seenT = new Set();
296
- const tooling = sw
297
- .filter((s) => reuse(s) >= 2 && !(s.mainTitle || "").toLowerCase().includes(slug))
298
- .sort((a, b) => reuse(b) - reuse(a))
299
- .map((s) => ({ title: s.mainTitle || "", link: s.codeRepositoryUrl || urlOf(s), swh: swh(s), swhUrl: swhUrlOf(s) }))
300
- .filter((t) => { const k = t.link || t.title; if (!t.title || seenT.has(k)) return false; seenT.add(k); return true; })
301
- .slice(0, 5);
302
-
303
415
  // Inject matched VERIFIED papers that OpenAIRE's keyword search didn't return, so
304
416
  // the paper you replicated always appears in the table (at its replicability rank).
305
417
  const have = new Set(targets.map((t) => t.doi));
@@ -307,21 +419,50 @@ async function radar(topic) {
307
419
  if (!v.doi || have.has(v.doi)) continue;
308
420
  const cs = Math.max(CLASS_SCORE[v.infl] || 0.2, CLASS_SCORE[v.cls] || 0.2);
309
421
  const momentum = CLASS_SCORE[v.impl] || 0.2;
310
- const matScore = (v.repl && v.repl.code) ? 1.0 : (v.repo_doi ? 0.8 : null); // its replication deposit
422
+ // verified: the materials score is about reproducing the ORIGINAL (unknown here) — NOT the
423
+ // replication's own repo. Keep that repo only so the FAIR badge can assess it.
311
424
  targets.push({
312
425
  title: v.title, doi: v.doi, citations: v.citations, cls: v.cls, infl: v.infl,
313
426
  year: v.year || null, impl: v.impl || null,
314
- mat: { score: matScore, state: matScore == null ? "unknown" : "code", code: (v.repl && v.repl.code) || null },
315
- parts: { mat: matScore, impact: cs, momentum },
427
+ mat: { score: null, state: "unknown", code: (v.repl && v.repl.code) || null },
428
+ parts: { mat: null, impact: cs, momentum },
316
429
  status: "VERIFIED",
317
- readiness: readinessFrom(matScore, cs, momentum),
430
+ readiness: readinessFrom(null, cs, momentum),
318
431
  verification: [...new Set(v.verdicts)].join(", "),
319
432
  outcome_np: v.outcome_np,
320
433
  });
321
434
  }
322
- targets.sort((a, b) => (b.readiness || 0) - (a.readiness || 0) || (b.citations || 0) - (a.citations || 0));
435
+ // Inject curated, paper-resolved candidates that match the field but OpenAIRE search missed,
436
+ // so the fully-resolved (materials-verified) papers always appear for their topic.
437
+ for (const [doi, c] of Object.entries(CURATED)) {
438
+ if (targets.some((t) => t.doi === doi)) continue;
439
+ const tw = new Set((c.title || "").toLowerCase().split(/\W+/));
440
+ if (!terms.some((t) => tw.has(t))) continue; // only when it matches the search
441
+ const impactScore = Math.max(CLASS_SCORE[c.infl] || 0.2, CLASS_SCORE[c.cls] || 0.2);
442
+ const momentum = CLASS_SCORE[c.impl] || 0.2;
443
+ const matScore = c.state === "rocrate" ? 1.0 : 0.6;
444
+ targets.push({
445
+ title: c.title, doi, citations: c.citations || 0, cls: c.cls, infl: c.infl,
446
+ year: c.year || null, impl: c.impl || null,
447
+ mat: { score: matScore, state: c.state, code: c.code || null, resolved: true, data: c.data || [], source: c.source || "the paper", lang: c.lang },
448
+ parts: { mat: matScore, impact: impactScore, momentum },
449
+ status: VERDICTS[doi] ? "VERIFIED" : "OPEN",
450
+ readiness: readinessFrom(matScore, impactScore, momentum),
451
+ verification: VERDICTS[doi] ? [...new Set(VERDICTS[doi].map((v) => v.verdict))].join(", ") : null,
452
+ outcome_np: VERDICTS[doi] ? ((VERDICTS[doi].find((v) => v.outcome_np) || {}).outcome_np || null) : null,
453
+ });
454
+ }
455
+ targets.forEach((t) => { t.statusKey = statusOf(t); t.priority = priorityOf(t); });
456
+ // sort by replication PRIORITY — so the number and the order agree (contested/unchecked rise,
457
+ // robustly-validated sinks). No status-rank override.
458
+ targets.sort((a, b) => (b.priority || 0) - (a.priority || 0) || (b.citations || 0) - (a.citations || 0));
459
+
460
+ // FAIR computed LIVE (same assessSoftware the verified path uses) for paper-resolved repos
461
+ await Promise.all(targets.map(async (t) => {
462
+ if (t.mat && t.mat.resolved && t.mat.code && parseGitHub(t.mat.code)) t.fair = await assessSoftware(t.mat.code);
463
+ }));
323
464
 
324
- return { topic, targets, inField, chartItems, tooling };
465
+ return { topic, targets, inField, chartItems };
325
466
  }
326
467
 
327
468
  // ---------- rendering ----------
@@ -329,55 +470,90 @@ const el = (id) => document.getElementById(id);
329
470
  const esc = (s) => (s || "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
330
471
 
331
472
  const PER_PAGE = 10;
332
- let _targets = [], _tpage = 0;
473
+ let _targets = [], _tpage = 0, _tfilter = new Set();
333
474
 
334
475
  function targetRow(t) {
335
476
  const p = t.parts || {};
336
- const scoreTitle = `replicability = 0.45·materials + 0.35·impact + 0.20·momentum — `
337
- + `materials ${p.mat == null ? "unverified" : p.mat.toFixed(2)} · impact ${(p.impact || 0).toFixed(2)} · momentum ${(p.momentum || 0).toFixed(2)}`;
338
- const unv = t.mat && t.mat.score == null ? `<small class="unv">mat ?</small>` : "";
339
- const score = `<div class="score" title="${esc(scoreTitle)}"><span>${t.readiness != null ? t.readiness.toFixed(2) : "—"}</span><small>REPLIC.</small>${unv}</div>`;
340
- const badge = t.status === "VERIFIED"
341
- ? `<span class="badge verified">VERIFIED</span><span class="badge cls">${t.verification}</span>`
342
- : `<span class="badge open">OPEN</span>${t.cls ? `<span class="badge cls" title="OpenAIRE BIP! impact class — C1 = top 0.01% most-cited globally, C5 = the rest">${t.cls}</span>` : ""}`;
343
- const matMeta = !t.mat ? ""
344
- : t.mat.state === "rocrate" ? `<span class="badge mok" title="RO-Crate research object — code + data + provenance bundled">RO-Crate ✓</span>`
345
- : t.mat.state === "code" ? `<span class="badge mok" title="code repository linked to this paper">code ✓</span>`
346
- : `<span class="badge munk" title="OpenAIRE holds no code/data link for this paper — unknown, NOT absent (needs checking)">materials ?</span>`;
477
+ const scoreTitle = t.status === "VERIFIED"
478
+ ? `replication priority — already checked: impact modulated by agreement (${t.statusKey}). A robustly-validated result sinks (it's settled); a contested one rises (worth re-checking).`
479
+ : `replication priority = 0.45·materials + 0.35·impact + 0.20·momentum — materials ${p.mat == null ? "unverified" : p.mat.toFixed(2)} · impact ${(p.impact || 0).toFixed(2)} · momentum ${(p.momentum || 0).toFixed(2)}`;
480
+ const score = `<div class="score" title="${esc(scoreTitle)}"><span>${t.priority != null ? t.priority.toFixed(2) : "—"}</span><small>PRIORITY</small></div>`;
481
+ const st = STATUS[t.statusKey] || STATUS.needs;
482
+ const badge = `<span class="badge ${st.cls}" title="${esc(st.tip)}">${st.label}</span>`
483
+ + (t.cls ? `<span class="badge cls" title="OpenAIRE BIP! impact class — C1 = top 0.01% most-cited globally, C5 = the rest">${t.cls}</span>` : "");
484
+ // Materials badge ONLY when positively known. OpenAIRE rarely links code/data to a
485
+ // paper, so 'unknown' is the norm in live search and would be noise on every row —
486
+ // it's carried in the score breakdown tooltip, and resolved in the baked demo set.
487
+ const matMeta = (t.mat && t.mat.state === "rocrate") ? `<span class="badge mok" title="RO-Crate research object — code + data + provenance bundled">RO-Crate ✓</span>`
488
+ : (t.mat && t.mat.state === "code") ? `<span class="badge mok" title="code repository linked to this paper">code ✓</span>`
489
+ : "";
347
490
  const meta = `<div class="t-meta">`
348
491
  + (t.year ? `<span class="badge yr">${t.year}</span>` : "")
349
492
  + (t.impl ? `<span class="badge imp" title="OpenAIRE BIP! impulse class — early citation momentum (C1 highest)">impulse ${t.impl}</span>` : "")
350
493
  + matMeta + `</div>`;
351
494
  const link = t.doi ? `<a href="https://doi.org/${t.doi}" target="_blank" rel="noopener">${t.doi}</a>` : "";
352
- const verdictLink = (t.status === "VERIFIED" && t.outcome_np)
353
- ? `<div class="tverdict">independently checked by Science Live · <a href="${t.outcome_np}" target="_blank" rel="noopener">replication outcome →</a></div>` : "";
495
+ // what EXACTLY was replicated — the claim's AIDA statement (atomic sentence) + its FORRT type
496
+ const cl = t.status === "VERIFIED" ? claimFor(t.outcome_np) : null;
497
+ const claimLine = (cl && (cl.aida || cl.label))
498
+ ? `<div class="tclaim"><span class="claimlbl">claim:</span> <span class="claimq">“${esc(cl.aida || cl.label)}”</span>${cl.type ? ` <span class="badge ctype" title="FORRT claim type">${esc(cl.type)}</span>` : ""}</div>` : "";
499
+ const outs = t.status === "VERIFIED" ? outcomesFor(t.doi).filter((o) => o.np) : [];
500
+ const verdictLink = (t.status === "VERIFIED")
501
+ ? `<div class="tverdict">independently checked by Science Live — <b>${esc(agreementOf(t.doi).why)}</b>${outcomeLinks(outs)}</div>` : "";
502
+ const resolvedNote = (t.mat && t.mat.resolved)
503
+ ? `<div class="tresolved">↳ materials resolved from ${esc(t.mat.source || "the paper")} (not in OpenAIRE): <a href="${esc(t.mat.code || "")}" target="_blank" rel="noopener">code repo</a>${(t.mat.data && t.mat.data.length) ? ` · data: ${t.mat.data.map(esc).join(", ")}` : ""}</div>`
504
+ : "";
505
+ const fairNote = t.fair
506
+ ? `<div class="tfair">FAIR software <b>${t.fair.score}/5</b> · ⭐ ${t.fair.stars}${t.fair.swh ? " · in Software Heritage" : ""}</div>`
507
+ : "";
508
+ // OPEN targets get a next step: discovery here → the FORRT template handles the nanopub chain.
509
+ const replicateCTA = (t.status !== "VERIFIED")
510
+ ? `<div class="treplicate"><a href="https://github.com/ScienceLiveHub/forrt-replication-template" target="_blank" rel="noopener" title="Start a replication from the FORRT template — it scaffolds the repo and the signed nanopub chain (Claim · Study · Outcome)">▷ Replicate this with the template →</a></div>`
511
+ : "";
354
512
  return `<div class="target ${t.status === "VERIFIED" ? "verified" : ""}">
355
513
  ${score}
356
- <div class="t-main">${badge}<br><b>${esc(t.title)}</b>${meta}${verdictLink}</div>
514
+ <div class="t-main">${badge}<br><b>${esc(t.title)}</b>${meta}${claimLine}${verdictLink}${resolvedNote}${fairNote}${replicateCTA}</div>
357
515
  <div class="t-right">${t.citations.toLocaleString()} cites<br>${link}</div>
358
516
  </div>`;
359
517
  }
360
518
 
519
+ const FILTER_ORDER = ["reproducible", "robust", "validated", "contested", "refuted", "needs", "dormant"];
520
+ const visibleTargets = () => (_tfilter.size ? _targets.filter((t) => _tfilter.has(t.statusKey)) : _targets);
521
+
522
+ function paintFilters() {
523
+ const counts = {};
524
+ for (const t of _targets) counts[t.statusKey] = (counts[t.statusKey] || 0) + 1;
525
+ const present = FILTER_ORDER.filter((k) => counts[k]);
526
+ if (present.length < 2) { el("tfilters").innerHTML = ""; return; } // nothing to filter
527
+ const chip = (on, key, label, count) =>
528
+ `<button class="tfilter${on ? " on" : ""}" onclick="filterTargets(${key ? `'${key}'` : "null"})"${key ? ` title="${esc(STATUS[key].tip)}"` : ""}>${label} <span>${count}</span></button>`;
529
+ el("tfilters").innerHTML = chip(_tfilter.size === 0, null, "All", _targets.length)
530
+ + present.map((k) => chip(_tfilter.has(k), k, STATUS[k].label, counts[k])).join("");
531
+ }
532
+
361
533
  function paintTargets() {
362
- const pages = Math.max(1, Math.ceil(_targets.length / PER_PAGE));
534
+ paintFilters();
535
+ const list = visibleTargets();
536
+ const pages = Math.max(1, Math.ceil(list.length / PER_PAGE));
363
537
  if (_tpage >= pages) _tpage = pages - 1;
364
- el("tcount").textContent = `${_targets.filter((t) => t.status === "OPEN").length} open · ${_targets.filter((t) => t.status === "VERIFIED").length} verified`;
365
- el("targets").innerHTML = _targets.slice(_tpage * PER_PAGE, _tpage * PER_PAGE + PER_PAGE).map(targetRow).join("");
538
+ if (_tpage < 0) _tpage = 0;
539
+ el("tcount").textContent = `${_targets.length} candidate${_targets.length === 1 ? "" : "s"}`;
540
+ el("targets").innerHTML = list.length
541
+ ? list.slice(_tpage * PER_PAGE, _tpage * PER_PAGE + PER_PAGE).map(targetRow).join("")
542
+ : `<p class="hint" style="padding:10px 2px">No candidates in this category — <a href="#" onclick="filterTargets(null);return false">show all</a>.</p>`;
366
543
  el("tpager").innerHTML = pages > 1
367
544
  ? `<button ${_tpage === 0 ? "disabled" : ""} onclick="pageTargets(-1)">← Prev</button><span>Page ${_tpage + 1} of ${pages}</span><button ${_tpage >= pages - 1 ? "disabled" : ""} onclick="pageTargets(1)">Next →</button>`
368
545
  : "";
369
546
  }
370
547
 
371
- function renderTargets(targets) { _targets = targets; _tpage = 0; paintTargets(); }
548
+ function renderTargets(targets) { _targets = targets; _tpage = 0; _tfilter.clear(); paintTargets(); }
372
549
  window.pageTargets = (d) => { _tpage += d; paintTargets(); el("targets").scrollIntoView({ behavior: "smooth", block: "start" }); };
550
+ window.filterTargets = (k) => {
551
+ if (k === null) _tfilter.clear();
552
+ else if (_tfilter.has(k)) _tfilter.delete(k);
553
+ else _tfilter.add(k);
554
+ _tpage = 0; paintTargets();
555
+ };
373
556
 
374
- function renderTooling(tooling) {
375
- if (!tooling || !tooling.length) { el("fieldtools").innerHTML = ""; return; }
376
- const items = tooling.map((t) =>
377
- `${t.link ? `<a href="${t.link}" target="_blank" rel="noopener">${esc(t.title).slice(0, 40)}</a>` : esc(t.title).slice(0, 40)}${t.swh ? ` <a href="${t.swhUrl || t.link}" target="_blank" rel="noopener" class="swh" title="archived in Software Heritage">⬡</a>` : ""}`
378
- ).join(" &nbsp;·&nbsp; ");
379
- el("fieldtools").innerHTML = `<b>Independent reusable tooling in this area</b> — engines for replicating by a different route, not tied to any one paper below: ${items}`;
380
- }
381
557
 
382
558
  function renderVerified(inField) {
383
559
  const field = VERIFIED.filter((v) => inField.has(v.doi)).sort((a, b) => b.citations - a.citations);
@@ -389,7 +565,7 @@ function renderVerified(inField) {
389
565
  // rich card: the trust edge shown as TWO OpenAIRE nodes (original + replication)
390
566
  const matchCard = (v) => {
391
567
  const chips = [
392
- v.oa ? `<span class="ochip oa">${esc(v.oa)} OA</span>` : "",
568
+ v.oa ? `<span class="ochip oa" title="Open-access route (OpenAIRE) — how the paper is free to read: gold/diamond = OA journal, hybrid = OA in a subscription journal, green = self-archived copy, bronze = free on the publisher site with no open licence">${esc(v.oa)} OA</span>` : "",
393
569
  ...(v.fos || []).map((f) => `<span class="ochip">${esc(f).slice(0, 24)}</span>`),
394
570
  ...(v.sdg || []).map((s) => `<span class="ochip sdg">${esc(s).slice(0, 22)}</span>`),
395
571
  `<span class="ochip cites">${v.citations.toLocaleString()} cites</span>`,
@@ -406,54 +582,56 @@ function renderVerified(inField) {
406
582
  } else if (v.repo_doi) {
407
583
  repl = `<div class="vrepl muted">↳ replication deposit: <a href="${v.repo_doi.startsWith("http") ? esc(v.repo_doi) : "https://doi.org/" + esc(v.repo_doi)}" target="_blank" rel="noopener">${esc(v.repo_doi)}</a> <span class="ochip wait">awaiting OpenAIRE harvest</span></div>`;
408
584
  }
585
+ const vouts = outcomesFor(v.doi).filter((o) => o.np);
586
+ const vcl = claimFor(v.outcome_np);
587
+ const vClaimLine = (vcl && (vcl.aida || vcl.label))
588
+ ? `<div class="vclaim"><span class="claimlbl">claim replicated</span> <span class="claimq">“${esc(vcl.aida || vcl.label)}”</span>${vcl.type ? ` <span class="badge ctype" title="FORRT claim type">${esc(vcl.type)}</span>` : ""}</div>` : "";
409
589
  return `<li class="match">
410
590
  <span class="nodelabel">original paper · OpenAIRE</span>
411
591
  <span class="vt">${esc(v.title).slice(0, 82)}</span>
412
592
  <div class="ochips">${chips}</div>
413
- <div class="vline"><span class="vv ${partialOf(v) ? "partial" : ""}">${v.verdicts.join(", ")}</span> — independently checked by Science Live ${v.outcome_np ? `· <a href="${v.outcome_np}" target="_blank" rel="noopener">replication outcome →</a>` : (v.cito_np ? `· <a href="${v.cito_np}" target="_blank" rel="noopener">verdict chain →</a>` : "")}</div>
593
+ ${vClaimLine}
594
+ <div class="vline"><span class="vv ${partialOf(v) ? "partial" : ""}">${v.verdicts.join(", ")}</span> — independently checked by Science Live ${vouts.length ? outcomeLinks(vouts) : (v.cito_np ? `· <a href="${v.cito_np}" target="_blank" rel="noopener">verdict chain →</a>` : "")}</div>
414
595
  ${repl}
415
596
  </li>`;
416
597
  };
417
598
  const fieldHtml = field.length
418
599
  ? `<ul class="vlist">${field.map(matchCard).join("")}</ul>`
419
600
  : `<p class="vnone">No Science Live verdict matching your search yet — every paper on the left is an <b>open</b> replication opportunity.</p>`;
420
- const moreHtml = `<p class="vmore-stat">Verdicts are drawn from the Science Live verification index bundled with this Radar. <a href="https://github.com/ScienceLiveHub/replication-radar/blob/main/src/replication_radar/data/verdicts.json" target="_blank" rel="noopener">browse the index →</a></p>`;
601
+ const moreHtml = `<p class="vmore-stat">Verdicts are pulled <b>live</b> from the nanopub network (any signer), filtered for retracted/superseded versions. <a href="https://github.com/ScienceLiveHub/replication-radar/blob/main/src/replication_radar/data/verdicts.json" target="_blank" rel="noopener">offline fallback index →</a></p>`;
421
602
  el("verified").innerHTML = fieldHtml + moreHtml;
422
603
  }
423
604
 
424
- function renderChart(items) {
425
- const short = (s) => { s = s || ""; return s.length > 46 ? s.slice(0, 44) + "…" : s; };
426
- if (chart) chart.destroy();
427
- if (!items.length) { el("gap").parentElement.querySelector(".empty")?.remove(); return; }
428
- chart = new Chart(el("gap"), {
429
- type: "bar",
430
- data: {
431
- labels: items.map((i) => short(i.title)),
432
- datasets: [{
433
- data: items.map((i) => Math.max(1, i.citations || 0)),
434
- backgroundColor: items.map((i) => (i.status === "VERIFIED" ? "#11875a" : "#e6007e")),
435
- borderRadius: 5, barThickness: 16,
436
- }],
437
- },
438
- options: {
439
- indexAxis: "y",
440
- maintainAspectRatio: false,
441
- scales: {
442
- x: { type: "logarithmic", title: { display: true, text: "citation impact (count, log)" }, grid: { color: "#eef2f9" } },
443
- y: { ticks: { font: { size: 11 } }, grid: { display: false } },
444
- },
445
- plugins: {
446
- legend: { display: false },
447
- tooltip: { callbacks: {
448
- title: (ti) => items[ti[0].dataIndex].title, // full (untruncated) paper title on hover
449
- label: (c) => `${items[c.dataIndex].status === "VERIFIED" ? "✓ already checked" : "open — worth replicating"} · ${items[c.dataIndex].citations.toLocaleString()} cites`,
450
- } },
451
- },
452
- },
453
- });
605
+ // The replication-gap map: a status-composition bar over the ranked list (not a per-paper
606
+ // citation chart — the insight is how MUCH of the field has been checked, not who's most cited).
607
+ const GAP_ORDER = ["reproducible", "robust", "validated", "contested", "refuted", "needs", "dormant"];
608
+ // Distinguishable by hue AND lightness — the two "unchecked" states (needs/dormant) were
609
+ // previously near-identical light blue-greys; now a clear medium slate vs a light warm grey.
610
+ // Platform chart colormap: chart-1 pink, chart-3 green, chart-4 orange, destructive red,
611
+ // chart-5 slate + a light grey for the two "unchecked" states (which should recede).
612
+ // reproducible = brand pink · robust/validated (confirmed) = navy + brighter blue · contested =
613
+ // amber · refuted = red · the two unchecked states = DESATURATED neutral greys (no blue tint) so
614
+ // the navy/blue "checked" segments read clearly apart from them.
615
+ const GAP_COLOR = { reproducible: "#be2e78", robust: "#1f4d8f", validated: "#4a7bc0", contested: "#f59e09", refuted: "#ff6b6b", needs: "#9aa0a8", dormant: "#d7d9dc" };
616
+ const CHECKED = new Set(["robust", "validated", "contested", "refuted"]);
617
+ function renderChart() {
618
+ const counts = {};
619
+ for (const t of _targets) counts[t.statusKey] = (counts[t.statusKey] || 0) + 1;
620
+ const total = _targets.length;
621
+ if (!total) { el("gap").innerHTML = ""; return; }
622
+ const present = GAP_ORDER.filter((k) => counts[k]);
623
+ const checked = present.filter((k) => CHECKED.has(k)).reduce((s, k) => s + counts[k], 0);
624
+ const seg = present.map((k) =>
625
+ `<span class="gapseg" style="width:${(counts[k] / total * 100).toFixed(1)}%;background:${GAP_COLOR[k]}" title="${esc(STATUS[k].label)} — ${counts[k]}"></span>`).join("");
626
+ const key = present.map((k) =>
627
+ `<button class="gapkeyi" onclick="filterTargets('${k}')" title="filter the list to ${esc(STATUS[k].label)}"><i style="background:${GAP_COLOR[k]}"></i>${STATUS[k].label} <b>${counts[k]}</b></button>`).join("");
628
+ const head = checked === 0
629
+ ? `<b>0</b> of <b>${total}</b> independently checked — <span class="gapnone">the whole field is an open replication gap</span>`
630
+ : `<b>${checked}</b> of <b>${total}</b> independently checked — <span class="gapsub">the other ${total - checked} are the replication gap</span>`;
631
+ el("gap").innerHTML = `<div class="gaphead">${head}</div><div class="gapbar">${seg}</div><div class="gapkey">${key}</div>`;
454
632
  }
455
633
 
456
- async function run(topic) {
634
+ async function run(topic, isExample) {
457
635
  topic = (topic || "").trim();
458
636
  if (!topic) return;
459
637
  el("go").disabled = true;
@@ -462,12 +640,12 @@ async function run(topic) {
462
640
  const r = await radar(topic);
463
641
  el("results").hidden = false;
464
642
  renderTargets(r.targets);
465
- renderTooling(r.tooling);
466
643
  renderVerified(r.inField);
467
- renderChart(r.chartItems);
468
- el("status").textContent = r.inField.size
644
+ renderChart();
645
+ const note = isExample ? ` <i>— showing an example; search your own field above.</i>` : "";
646
+ el("status").innerHTML = (r.inField.size
469
647
  ? `“${topic}”: ${r.targets.length} candidates · ${r.inField.size} already checked, matching your search (green) — the rest are open.`
470
- : `“${topic}”: ${r.targets.length} candidates · none matching your search have been checked yet — every one is an open replication opportunity.`;
648
+ : `“${topic}”: ${r.targets.length} candidates · none matching your search have been checked yet — every one is an open replication opportunity.`) + note;
471
649
  } catch (e) {
472
650
  el("status").textContent = `Could not reach the OpenAIRE Graph (${e.message}). Try again or a shorter topic.`;
473
651
  } finally {
@@ -482,4 +660,12 @@ el("go").addEventListener("click", () => run(el("topic").value));
482
660
  el("topic").addEventListener("keydown", (e) => { if (e.key === "Enter") run(el("topic").value); });
483
661
 
484
662
  el("status").textContent = "Loading the Science Live verdict layer live from the nanopub network …";
485
- loadVerdicts().then(() => { el("status").textContent = "Type a research field and hit Scan — or try an example above."; });
663
+ Promise.all([loadVerdicts(), loadCurated()]).then(() => {
664
+ // Default landing state: populate the radar with a sample field so first-time visitors see the
665
+ // value immediately, not a blank page — but never clobber a user who has already started.
666
+ if (!el("topic").value.trim() && el("results").hidden) {
667
+ run("species distribution", true);
668
+ } else {
669
+ el("status").textContent = "Type a research field and hit Scan — or try an example above.";
670
+ }
671
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "_comment": "Paper-resolved materials that OpenAIRE does NOT hold as links (verified: search records + relationship layer carry only paper->paper cites for these). Each repo URL is taken from the paper's own Data/Code Availability statement (grounded, not guessed). FAIR is computed LIVE by the app's assessSoftware() against the repo; we only store the resolved link here. This is the P2 'bake-to-static' bridge.",
3
+ "resolved": {
4
+ "10.3389/fevo.2024.1148895": {
5
+ "state": "code",
6
+ "code": "https://github.com/trias-project/risk-modelling-and-mapping",
7
+ "lang": "R",
8
+ "data": ["GBIF", "CHELSA", "EURO-CORDEX", "CORINE"],
9
+ "source": "the paper's Data & Code Availability statement"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Replication Radar — what to replicate next, from the OpenAIRE Graph</title>
7
+ <meta name="description" content="A live tool that turns the OpenAIRE Graph into a ranked replication queue: impact-ranked targets, the claim each one makes, and whether it has already been independently checked — with what verdict." />
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+ <body>
11
+ <header>
12
+ <div class="wrap">
13
+ <h1>Replication&nbsp;Radar</h1>
14
+ <p class="tag">What's worth replicating next — read straight off the OpenAIRE&nbsp;Graph.</p>
15
+ <p class="sub">Science Live × OpenAIRE · impact-ranked targets · the claim each makes · already-checked overlay</p>
16
+ </div>
17
+ </header>
18
+
19
+ <main class="wrap">
20
+ <section class="search">
21
+ <input id="topic" type="text" placeholder="a topic — e.g. species distribution" autocomplete="off" />
22
+ <button id="go">Scan</button>
23
+ <div class="chips" id="chips"></div>
24
+ </section>
25
+
26
+ <p id="status" class="status"></p>
27
+
28
+ <section id="results" hidden>
29
+ <div class="card chartcard">
30
+ <h2>The replication-gap map</h2>
31
+ <p class="hint">How much of the high-impact work matching your search has actually been <b class="verified">independently checked</b> — the field at a glance. Everything <b class="open">not yet checked</b> is the replication gap.</p>
32
+ <div class="chartbox" id="gap"></div>
33
+ </div>
34
+ <aside class="card verifiedcard">
35
+ <h2>Verified, matching your search <span id="vcount" class="pill"></span></h2>
36
+ <p class="hint">The <b class="verified">checked</b> papers matching your search, with the claim that was tested and the verdict — the reliability signal the Graph can't hold. Click any outcome to open its signed nanopub.</p>
37
+ <div id="verified"></div>
38
+ </aside>
39
+ <div class="card">
40
+ <h2>Top replication targets <span class="pill" id="tcount"></span></h2>
41
+ <p class="hint">Candidates matching your search, ranked by <b>replication priority</b> (the PRIORITY score — the number and the order agree). OPEN = an opportunity; VERIFIED = already checked, with its agreement pattern. Paged, 10 at a time. Hover any badge or the score for what it means.</p>
42
+ <div id="tfilters" class="tfilters"></div>
43
+ <div id="targets" class="targets"></div>
44
+ <div id="tpager" class="tpager"></div>
45
+ </div>
46
+ </section>
47
+ </main>
48
+
49
+ <footer>
50
+ <div class="wrap">
51
+ <span>Live against <a href="https://graph.openaire.eu/">OpenAIRE Graph</a> · verdicts from <a href="https://sciencelive4all.org">Science Live</a> nanopublications</span>
52
+ <span><a href="https://github.com/ScienceLiveHub/replication-radar">code (MIT)</a> · <a href="https://pypi.org/project/replication-radar/">PyPI</a> · <a href="https://github.com/ScienceLiveHub/replication-radar/blob/main/STORY.md">story (CC-BY)</a></span>
53
+ </div>
54
+ </footer>
55
+
56
+ <script src="app.js"></script>
57
+ </body>
58
+ </html>
@@ -1,7 +1,12 @@
1
1
  :root{
2
- --navy:#0c1f3f; --navy2:#16305c; --ink:#1b2433; --mut:#5a6b85;
3
- --magenta:#e6007e; --magenta-soft:#fde4f1; --green:#11875a; --green-soft:#def0e7;
4
- --line:#e4e9f2; --bg:#f6f8fc; --card:#fff;
2
+ /* Science Live palette — matched to the platform.sciencelive4all.org / sciencelive4all.org
3
+ design tokens (primary pink, navy foreground, chart green/orange/slate, destructive red). */
4
+ --navy:#0c2c59; --navy2:#193967; --ink:#15294a; --mut:#505960;
5
+ --magenta:#be2e78; --magenta-soft:#f7e6ef;
6
+ /* "verified/confirmed" rides on navy (no green) — the ✅ glyph carries the positive cue.
7
+ --verified* keep the old --green* names only so existing rules need no churn. */
8
+ --green:#1f4d8f; --green-soft:#eaf0f9;
9
+ --line:#e2e5ea; --bg:#fafbfc; --card:#fff;
5
10
  }
6
11
  *{box-sizing:border-box}
7
12
  body{margin:0;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
@@ -18,7 +23,7 @@ header .sub{margin:0;color:#aebfdc;font-size:14px}
18
23
  #topic{flex:1;min-width:240px;padding:13px 16px;border:1.5px solid var(--line);border-radius:10px;font-size:16px;background:#fff}
19
24
  #topic:focus{outline:none;border-color:var(--magenta)}
20
25
  #go{padding:13px 26px;border:0;border-radius:10px;background:var(--magenta);color:#fff;font-size:16px;font-weight:600;cursor:pointer}
21
- #go:hover{background:#c40069}
26
+ #go:hover{background:#a32a6a}
22
27
  #go:disabled{opacity:.5;cursor:default}
23
28
  .chips{display:flex;gap:8px;flex-wrap:wrap;width:100%}
24
29
  .chip{padding:5px 12px;border:1px solid var(--line);border-radius:20px;background:#fff;color:var(--mut);font-size:13px;cursor:pointer}
@@ -26,29 +31,35 @@ header .sub{margin:0;color:#aebfdc;font-size:14px}
26
31
 
27
32
  .status{color:var(--mut);min-height:22px;margin:10px 0}
28
33
 
29
- .legend{background:#fff;border:1px solid var(--line);border-radius:12px;margin:16px 0 4px;padding:4px 18px}
30
- .legend>summary{cursor:pointer;font-weight:600;color:var(--navy);padding:11px 0;font-size:13.5px;list-style-position:inside}
31
- .legend>summary:hover{color:var(--magenta)}
32
- .legend-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 30px;padding:6px 0 16px;font-size:13px}
33
- @media(max-width:680px){.legend-grid{grid-template-columns:1fr}}
34
- .legend-item{display:flex;flex-direction:column;gap:5px}
35
- .legend-h{font-weight:700;color:var(--navy2);font-size:10.5px;text-transform:uppercase;letter-spacing:.5px}
36
- .legend-body{color:var(--mut);line-height:1.7}
37
- .legend-body em{color:#9aa7bd;font-size:12px}
38
- .vv.contra{color:#b3005f}
39
- .grid{display:grid;grid-template-columns:1.5fr 1fr;gap:18px;margin-bottom:18px}
34
+ .vv.contra{color:#cf3a3a}
35
+ .grid{display:grid;grid-template-columns:1.5fr 1fr;gap:18px;margin-bottom:18px;align-items:start}
40
36
  @media(max-width:820px){.grid{grid-template-columns:1fr}}
41
37
  .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin-bottom:18px}
42
38
  .card h2{margin:0 0 4px;font-size:17px;color:var(--navy)}
43
39
  .hint{margin:.2rem 0 14px;font-size:13px;color:var(--mut)}
44
40
  .hint .open{color:var(--magenta)} .hint .verified{color:var(--green)}
45
41
  .pill{display:inline-block;background:var(--navy);color:#fff;font-size:12px;border-radius:20px;padding:1px 9px;vertical-align:middle}
46
- .chartbox{position:relative;height:340px;margin-top:8px}
42
+ .chartbox{position:relative;margin-top:10px}
43
+ .gaphead{font-size:15px;color:var(--ink);margin-bottom:12px}
44
+ .gaphead b{font-size:22px;color:var(--navy);font-weight:800}
45
+ .gapsub{color:var(--mut);font-size:13px}
46
+ .gapnone{color:var(--magenta);font-weight:600;font-size:13px}
47
+ .gapbar{display:flex;height:28px;border-radius:8px;overflow:hidden;background:#eef2f9;border:1px solid var(--line)}
48
+ .gapseg{display:block;height:100%;min-width:7px}
49
+ .gapseg:not(:last-child){border-right:1.5px solid #fff}
50
+ .gapkey{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:14px}
51
+ .gapkeyi{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--mut);background:none;border:0;padding:0;cursor:pointer}
52
+ .gapkeyi:hover{color:var(--navy2)}
53
+ .gapkeyi i{width:11px;height:11px;border-radius:3px;display:inline-block}
54
+ .gapkeyi b{color:var(--navy2);font-weight:700}
47
55
 
48
- .fieldtools{font-size:12.5px;color:var(--mut);background:#f3f6fb;border:1px solid var(--line);border-radius:9px;padding:10px 13px;margin:0 0 14px;line-height:1.7}
49
- .fieldtools:empty{display:none}
50
- .fieldtools b{color:var(--navy2)}
51
- .fieldtools .swh{color:var(--green);text-decoration:none;font-size:13px}
56
+ .tfilters{display:flex;flex-wrap:wrap;gap:7px;margin:0 0 14px}
57
+ .tfilters:empty{display:none}
58
+ .tfilter{border:1px solid var(--line);background:#fff;border-radius:20px;padding:5px 12px;font-size:12.5px;font-weight:600;color:var(--mut);cursor:pointer;display:inline-flex;align-items:center;gap:6px;line-height:1.4}
59
+ .tfilter span{background:#eef2f9;color:var(--navy2);border-radius:10px;padding:0 7px;font-size:11px;font-weight:700}
60
+ .tfilter:hover{border-color:var(--magenta);color:var(--navy2)}
61
+ .tfilter.on{border-color:var(--navy2);background:var(--navy);color:#fff}
62
+ .tfilter.on span{background:rgba(255,255,255,.22);color:#fff}
52
63
  .targets{display:flex;flex-direction:column;gap:10px}
53
64
  .tpager{display:flex;align-items:center;justify-content:center;gap:16px;margin-top:16px;font-size:13px;color:var(--mut)}
54
65
  .tpager:empty{display:none}
@@ -56,22 +67,48 @@ header .sub{margin:0;color:#aebfdc;font-size:14px}
56
67
  .tpager button:hover:not(:disabled){border-color:var(--magenta);color:var(--magenta)}
57
68
  .tpager button:disabled{opacity:.4;cursor:default}
58
69
  .target{display:grid;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;padding:12px 14px;border:1px solid var(--line);border-radius:10px}
59
- .target.verified{background:var(--green-soft);border-color:#bfe3d2}
70
+ .target.verified{background:var(--green-soft);border-color:#cbd9f0}
60
71
  .score{width:54px;height:54px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px;color:#fff;background:var(--magenta);flex-direction:column;line-height:1}
61
72
  .score small{font-size:8px;font-weight:600;opacity:.85;letter-spacing:.4px}
62
73
  .target.verified .score{background:var(--green)}
63
74
  .t-main b{font-size:15px;color:var(--ink)}
64
75
  .t-meta{font-size:12.5px;color:var(--mut);margin-top:3px}
65
76
  .badge{display:inline-block;font-size:11px;font-weight:700;padding:1px 7px;border-radius:5px;margin-right:6px}
66
- .badge.open{background:var(--magenta-soft);color:#b3005f}
67
- .badge.verified{background:#cfeadd;color:var(--green)}
77
+ .badge.open{background:var(--magenta-soft);color:#a32a6a}
78
+ .badge.verified{background:#e7eefb;color:var(--green)}
68
79
  .badge.cls{background:#eef2f9;color:var(--navy2)}
69
80
  .badge.yr{background:#eef2f9;color:var(--mut)}
70
81
  .badge.imp{background:#e7edf6;color:var(--navy2)}
71
82
  .badge.mok{background:var(--green-soft);color:var(--green)}
72
- .badge.munk{background:#fde8cf;color:#8a5a1b}
83
+ .badge.munk{background:#fdecc8;color:#9a6206}
73
84
  .score .unv{font-size:7.5px;font-weight:700;letter-spacing:.2px;opacity:.95}
85
+ .badge.st-ready{background:var(--magenta);color:#fff}
86
+ .badge.st-val{background:#e7eefb;color:var(--green)}
87
+ .badge.st-con{background:#fdecc8;color:#9a6206}
88
+ .badge.st-needs{background:#eef2f9;color:var(--mut)}
89
+ .badge.st-dorm{background:#f0f1f4;color:#9aa7bd}
90
+ .tclaim{font-size:12.5px;color:var(--ink);margin-top:6px;line-height:1.5}
91
+ .claimlbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--green)}
92
+ .claimq{font-style:italic}
93
+ .badge.ctype{background:#e7edf6;color:var(--navy2);text-transform:capitalize}
74
94
  .tverdict{font-size:12px;color:var(--mut);margin-top:4px}
95
+ .onp{display:inline-block;min-width:18px;text-align:center;border:1px solid var(--line);border-radius:4px;padding:0 6px;margin:0 2px;font-weight:700;font-size:11px;line-height:1.6}
96
+ .onp:hover{text-decoration:none}
97
+ /* white body so the chip is visible on tinted (verified) cards; verdict lives in border+text+hover */
98
+ .onp.v-ok{border-color:#9db6df;color:var(--green);background:#fff}
99
+ .onp.v-ok:hover{background:var(--green);color:#fff;border-color:var(--green)}
100
+ .onp.v-part{border-color:#f0c06a;color:#9a6206;background:#fff}
101
+ .onp.v-part:hover{background:#f59e09;color:#fff;border-color:#f59e09}
102
+ .onp.v-con{border-color:#eaa6a0;color:#cf3a3a;background:#fff}
103
+ .onp.v-con:hover{background:#d64545;color:#fff;border-color:#d64545}
104
+ .onp.v-other{border-color:var(--line);color:var(--mut);background:#fff}
105
+ .tresolved{font-size:12px;color:var(--green);margin-top:4px}
106
+ .tresolved a{font-weight:600}
107
+ .tfair{font-size:12px;color:var(--mut);margin-top:3px}
108
+ .tfair b{color:var(--navy2)}
109
+ .treplicate{margin-top:7px}
110
+ .treplicate a{font-size:12px;font-weight:700;background:var(--magenta-soft);color:#a32a6a;padding:4px 11px;border-radius:7px;display:inline-block}
111
+ .treplicate a:hover{background:var(--magenta);color:#fff;text-decoration:none}
75
112
  .tool{font-size:12px;color:var(--mut)}
76
113
  .tool .swh{color:var(--green);font-weight:600}
77
114
  .t-right{text-align:right;font-size:12px;color:var(--mut);white-space:nowrap}
@@ -82,18 +119,19 @@ header .sub{margin:0;color:#aebfdc;font-size:14px}
82
119
  .vlist li:last-child{border:0}
83
120
  .vt{color:var(--ink);font-weight:600;display:block;margin-bottom:2px}
84
121
  .vv{color:var(--green);font-weight:700;font-size:12px}
85
- .vv.partial{color:#b5820a}
122
+ .vv.partial{color:#9a6206}
86
123
  #verified a{font-size:12px}
87
124
  .vlist li.match{background:var(--green-soft);margin:0 -8px 10px;padding:12px 13px;border-radius:8px;border:0}
88
125
  .nodelabel{display:block;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--green);margin-bottom:3px}
89
126
  .ochips{display:flex;flex-wrap:wrap;gap:5px;margin:6px 0}
90
127
  .ochip{font-size:11px;background:#e7edf6;color:var(--navy2);border-radius:5px;padding:1px 7px;white-space:nowrap}
91
- .ochip.oa{background:#d3ecdf;color:var(--green);text-transform:capitalize}
128
+ .ochip.oa{background:#e7edf6;color:var(--navy2);text-transform:capitalize}
92
129
  .ochip.sdg{background:#fff1d3;color:#8a6d1b}
93
130
  .ochip.type{background:var(--navy);color:#fff;text-transform:capitalize}
94
131
  .ochip.cites{background:transparent;color:var(--mut);padding-left:0}
132
+ .vclaim{font-size:12.5px;color:var(--ink);margin:7px 0 4px;line-height:1.5}
95
133
  .vline{font-size:12.5px;color:var(--mut);margin-top:5px}
96
- .vrepl{font-size:12.5px;margin-top:8px;padding-top:8px;border-top:1px dashed #bfe3d2;color:var(--ink)}
134
+ .vrepl{font-size:12.5px;margin-top:8px;padding-top:8px;border-top:1px dashed #cbd9f0;color:var(--ink)}
97
135
  .vrepl.muted{color:var(--mut);border-top-color:var(--line)}
98
136
  .vrepl a{font-weight:600}
99
137
  .fairline{display:flex;align-items:center;flex-wrap:wrap;gap:7px;font-size:12px;color:var(--mut);margin-top:6px}
@@ -101,13 +139,13 @@ header .sub{margin:0;color:#aebfdc;font-size:14px}
101
139
  .fairrecs{display:flex;flex-wrap:wrap;align-items:center;gap:3px 10px;font-size:11.5px;margin-top:5px}
102
140
  .fairrecs b{color:var(--navy2);font-size:12px}
103
141
  .fairrecs .rok{color:var(--green)}
104
- .fairrecs .rno{color:#b5820a}
142
+ .fairrecs .rno{color:#9a6206}
105
143
  .fairbar{display:inline-block;width:64px;height:7px;border-radius:4px;background:#e4e9f2;overflow:hidden}
106
144
  .fairbar i{display:block;height:100%;background:var(--green)}
107
145
  .swhok{color:var(--green);font-weight:600}
108
- .swhno{color:#b5820a}
109
- .ochip.wait{background:#fde8cf;color:#8a5a1b}
110
- .vnone{color:#9c0356;background:var(--magenta-soft);padding:12px 14px;border-radius:8px;font-size:13.5px}
146
+ .swhno{color:#9a6206}
147
+ .ochip.wait{background:#fdecc8;color:#9a6206}
148
+ .vnone{color:#a32a6a;background:var(--magenta-soft);padding:12px 14px;border-radius:8px;font-size:13.5px}
111
149
  .vmore-stat{margin-top:14px;padding-top:12px;border-top:1px solid var(--line);font-size:12.5px;color:var(--mut)}
112
150
 
113
151
  footer{border-top:1px solid var(--line);margin-top:30px;padding:20px 0;color:var(--mut);font-size:13px;background:#fff}
@@ -7,4 +7,4 @@ OpenAIRE impact + Software Heritage reuse signals + Science Live nanopub verdict
7
7
  from .radar import radar, find_independent_software, replication_status, verified_claims
8
8
 
9
9
  __all__ = ["radar", "find_independent_software", "replication_status", "verified_claims"]
10
- __version__ = "0.3.0"
10
+ __version__ = "0.3.1"
@@ -21,6 +21,12 @@ SPARQL = os.environ.get("RADAR_NANOPUB_SPARQL", "https://query.knowledgepixels.c
21
21
  TPL_OUTCOME = "https://w3id.org/np/RA2zljn0Nw9SadppOyxZoh-_Rxosslrq-vYG-p9SttnJE"
22
22
  TPL_CITO = "https://w3id.org/np/RA43F9EoOuzF0xoNUnCMNyFsfIqlsuWDdPHCnN0wCdCAw"
23
23
  VERDICT_RELS = {"confirms", "qualifies", "disputes", "critiques", "extends", "supports", "refutes"}
24
+ # CiTO relations that are METHOD/DATA/CREDIT provenance — they point to a SOURCE paper, not a
25
+ # verdict on it. A verdict must NEVER attach via these: a study that `usesMethodIn` Phillips 2009
26
+ # and was Contradicted does not contradict Phillips 2009 (it reused its method). (lowercased compare)
27
+ NONVERDICT_RELS = {"usesmethodin", "usesdatafrom", "citesasdatasource", "citesasevidence", "credits",
28
+ "citesforinformation", "obtainsbackgroundfrom", "obtainssupportfrom", "citesasauthority",
29
+ "citesasrelated", "citesassourcedocument", "includesquotationfrom", "sharesauthorinstitutionwith"}
24
30
  _CANON = {"validated": "Validated", "partiallysupported": "PartiallySupported", "contradicted": "Contradicted",
25
31
  "notsupported": "NotSupported", "mixed": "Mixed", "inconclusive": "Inconclusive"}
26
32
  _TIMEOUT = float(os.environ.get("RADAR_HTTP_TIMEOUT", "30"))
@@ -31,6 +37,18 @@ SELECT DISTINCT ?outcome ?status ?repo WHERE {{ GRAPH ?g {{ ?outcome ntpl:wasCre
31
37
  _QB = f"""PREFIX np: <http://www.nanopub.org/nschema#> PREFIX ntpl: <https://w3id.org/np/o/ntemplate/> PREFIX cito: <http://purl.org/spar/cito/>
32
38
  SELECT DISTINCT ?cito ?subj ?rel ?orig WHERE {{ GRAPH ?g {{ ?cito ntpl:wasCreatedFromTemplate <{TPL_CITO}> . }} ?cito np:hasAssertion ?ca . GRAPH ?ca {{ ?subj ?rel ?orig . }} FILTER(STRSTARTS(STR(?rel),STR(cito:))) FILTER(CONTAINS(STR(?orig),"doi.org/10.")) }} LIMIT 3000"""
33
39
 
40
+ # Validity guard, run as its OWN lightweight query (anchored on the Outcome template, so it stays
41
+ # fast — anchoring on two templates, or an inline FILTER NOT EXISTS on the full repo, times the
42
+ # endpoint out at 504). Returns OUR Outcomes that have been retracted / invalidated / superseded by
43
+ # a nanopub from the SAME creator. Same-creator is essential: only the original author can retract
44
+ # their own work — a third party publishing `retracts` must not be able to suppress someone else's.
45
+ # Disapproval (`disapprovesOf`) is deliberately NOT here: that is a third party disagreeing, not a
46
+ # retraction, so it must never hide a verdict. (Only Outcomes are affected in the current network;
47
+ # extend VALUES ?tpl to the CiTO template if a superseded CiTO ever appears — but keep it one
48
+ # query per template to stay under the endpoint timeout.)
49
+ _QV = f"""PREFIX ntpl: <https://w3id.org/np/o/ntemplate/> PREFIX npx: <http://purl.org/nanopub/x/> PREFIX dct: <http://purl.org/dc/terms/>
50
+ SELECT DISTINCT ?np WHERE {{ GRAPH ?g {{ ?np ntpl:wasCreatedFromTemplate <{TPL_OUTCOME}> . }} GRAPH ?supg {{ ?sup ?act ?np . }} VALUES ?act {{ npx:retracts npx:invalidates npx:supersedes }} GRAPH ?cg1 {{ ?sup dct:creator ?cc . }} GRAPH ?cg2 {{ ?np dct:creator ?cc . }} }}"""
51
+
34
52
 
35
53
  def _sparql(query: str) -> list[dict]:
36
54
  url = f"{SPARQL}?{urllib.parse.urlencode({'query': query})}"
@@ -64,6 +82,10 @@ def build_index() -> dict:
64
82
  """
65
83
  outcomes = _sparql(_QA) # sequential: concurrent queries truncate the endpoint
66
84
  citos = _sparql(_QB)
85
+ try: # best-effort: a guard timeout must not
86
+ invalid = {_hash(r.get("np")) for r in _sparql(_QV)} # take down live verdicts
87
+ except Exception:
88
+ invalid = set()
67
89
  by_hash: dict[str, list] = {}
68
90
  for r in citos:
69
91
  by_hash.setdefault(_hash(r.get("subj")), []).append({
@@ -73,9 +95,11 @@ def build_index() -> dict:
73
95
  })
74
96
  index: dict[str, list] = {}
75
97
  for o in outcomes:
98
+ if _hash(o.get("outcome")) in invalid: # drop a superseded/retracted Outcome
99
+ continue
76
100
  cs = by_hash.get(_hash(o.get("outcome")), [])
77
101
  verdict_citos = [c for c in cs if c["rel"] in VERDICT_RELS and not c["orig"].startswith("10.5281/")]
78
- targets = verdict_citos or [c for c in cs if not c["orig"].startswith("10.5281/")]
102
+ targets = verdict_citos or [c for c in cs if c["rel"].lower() not in NONVERDICT_RELS and not c["orig"].startswith("10.5281/")]
79
103
  repo_doi = _clean_repo(o.get("repo") or "")
80
104
  for c in targets:
81
105
  index.setdefault(c["orig"], []).append({
@@ -1,99 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Replication Radar — what to replicate next, from the OpenAIRE Graph</title>
7
- <meta name="description" content="A live tool that turns the OpenAIRE Graph into a ranked replication queue: impact-ranked targets, independent reusable tooling, and whether a claim has already been checked." />
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
10
- </head>
11
- <body>
12
- <header>
13
- <div class="wrap">
14
- <h1>Replication&nbsp;Radar</h1>
15
- <p class="tag">What's worth replicating next — read straight off the OpenAIRE&nbsp;Graph.</p>
16
- <p class="sub">Science Live × OpenAIRE · impact-ranked targets · independent tooling · already-checked overlay</p>
17
- </div>
18
- </header>
19
-
20
- <main class="wrap">
21
- <section class="search">
22
- <input id="topic" type="text" placeholder="a topic — e.g. species distribution" autocomplete="off" />
23
- <button id="go">Scan</button>
24
- <div class="chips" id="chips"></div>
25
- </section>
26
-
27
- <p id="status" class="status"></p>
28
-
29
- <section id="results" hidden>
30
- <div class="grid">
31
- <div class="card chartcard">
32
- <h2>The replication-gap map</h2>
33
- <p class="hint">High-impact papers matching your search, ranked by citations. <b class="verified">Green = already checked</b> by Science Live; <b class="open">pink = open</b> — a replication opportunity. All-pink means nothing here has been independently checked yet.</p>
34
- <div class="chartbox"><canvas id="gap"></canvas></div>
35
- </div>
36
- <aside class="card verifiedcard">
37
- <h2>Verified, matching your search <span id="vcount" class="pill"></span></h2>
38
- <p class="hint">Science Live replications matching your search — these are the <b class="verified">green bars</b>. The verdict is the reliability signal the Graph can't hold; click through to the signed nanopub.</p>
39
- <div id="verified"></div>
40
- </aside>
41
- </div>
42
-
43
- <details class="legend" open>
44
- <summary>Legend — what the labels mean</summary>
45
- <div class="legend-grid">
46
- <div class="legend-item">
47
- <span class="legend-h">Impact class · OpenAIRE BIP!</span>
48
- <span class="legend-body">
49
- <span class="ochip">C1</span> top 0.01% &nbsp;<span class="ochip">C2</span> top 0.1% &nbsp;<span class="ochip">C3</span> top 1% &nbsp;<span class="ochip">C4</span> top 10% &nbsp;<span class="ochip">C5</span> the rest
50
- <em>— global percentile of citations, across all of science</em>
51
- </span>
52
- </div>
53
- <div class="legend-item">
54
- <span class="legend-h">Status</span>
55
- <span class="legend-body"><span class="badge open">OPEN</span> a replication opportunity &nbsp; <span class="badge verified">VERIFIED</span> already independently checked</span>
56
- </div>
57
- <div class="legend-item">
58
- <span class="legend-h">Verdict · Science Live</span>
59
- <span class="legend-body"><span class="vv">Validated</span> held up &nbsp; <span class="vv partial">PartiallySupported</span> in part &nbsp; <span class="vv contra">Contradicted</span> did not hold</span>
60
- </div>
61
- <div class="legend-item">
62
- <span class="legend-h">Replicability (the REPLIC. score)</span>
63
- <span class="legend-body">a 0–1 potential = impact × independent tooling available × reference data — higher means more worth, and more feasible, to replicate. Shown for every paper, including already-verified ones.</span>
64
- </div>
65
- <div class="legend-item">
66
- <span class="legend-h">Open access · OpenAIRE</span>
67
- <span class="legend-body">All mean the paper is <b>free to read</b>; they differ in <b>who paid to publish</b>: <b>gold</b> OA journal (author/funder fee) · <b>hybrid</b> OA in a subscription journal (author fee) · <b>green</b> free self-archived copy · <b>bronze</b> free on the publisher site, no open licence · <b>diamond</b> no fee for authors or readers</span>
68
- </div>
69
- <div class="legend-item">
70
- <span class="legend-h">Subject tags · OpenAIRE</span>
71
- <span class="legend-body">auto-classified by OpenAIRE: research field (e.g. <em>biological sciences</em>) and UN Sustainable Development Goal (e.g. <em>13. Climate action</em>)</span>
72
- </div>
73
- <div class="legend-item">
74
- <span class="legend-h">FAIR-software · fair-software.eu</span>
75
- <span class="legend-body">a replication's code scored N/5 on the <b>fair-software.eu</b> recommendations — public repository · open license · in a registry · citable (CITATION.cff/DOI) · quality artefacts — computed live from GitHub + Software Heritage. Hover the score for the breakdown.</span>
76
- </div>
77
- </div>
78
- </details>
79
-
80
- <div class="card">
81
- <h2>Top replication targets <span class="pill" id="tcount"></span></h2>
82
- <p class="hint">Candidates matching your search, ranked by <b>replicability</b> potential (the REPLIC. score). OPEN = an opportunity; VERIFIED = already done (shown at its rank, not buried). Paged, 10 at a time. See the legend above for the badges.</p>
83
- <p id="fieldtools" class="fieldtools"></p>
84
- <div id="targets" class="targets"></div>
85
- <div id="tpager" class="tpager"></div>
86
- </div>
87
- </section>
88
- </main>
89
-
90
- <footer>
91
- <div class="wrap">
92
- <span>Live against <a href="https://graph.openaire.eu/">OpenAIRE Graph</a> · verdicts from <a href="https://sciencelive4all.org">Science Live</a> nanopublications</span>
93
- <span><a href="https://github.com/ScienceLiveHub/replication-radar">code (MIT)</a> · <a href="https://pypi.org/project/replication-radar/">PyPI</a> · <a href="https://github.com/ScienceLiveHub/replication-radar/blob/main/STORY.md">story (CC-BY)</a></span>
94
- </div>
95
- </footer>
96
-
97
- <script src="app.js"></script>
98
- </body>
99
- </html>