From 9370e7fb49f4ed38cdda62661fd322e5fe038cc2 Mon Sep 17 00:00:00 2001 From: annefou Date: Mon, 15 Jun 2026 21:06:43 +0200 Subject: [PATCH 1/2] feat(frontend): public replication-summary page (/np/replications) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public page that, given a paper DOI, shows every independent replication of its claims on the nanopub network and the verdict each reached — grouped by the claim tested, with each study's scope/methodology/deviations, conclusion, materials and signed Outcome nanopub. Design: - Client-side, unauthenticated: reads only public nanopub-network data via SPARQL (frontend/src/lib/sparql.ts), so the page works for anyone. The authenticated API stays for programmatic/registered use — no backend or auth change. - Enumerates via the CiTO->DOI verdict-citation method, NOT /np/constellation graph BFS: verified empirically that BFS bleeds into adjacent papers (it pulled a lizard paper into a bumble-bee chain) and misses disconnected replications, while CiTO->DOI returns the correct set (e.g. Soroye 2020 -> its 5 replications). - Retraction/supersession-aware: drops any outcome retracted/invalidated/superseded by a nanopub from the same creator. Run as a scoped guard query (an inline FILTER NOT EXISTS times the endpoint out); best-effort so a guard failure never hides everything. Route: /np/replications?doi=10.1126/science.aax8591 --- frontend/src/App.tsx | 2 + frontend/src/lib/replications.ts | 210 ++++++++++++++ frontend/src/pages/np/ReplicationSummary.tsx | 276 +++++++++++++++++++ 3 files changed, 488 insertions(+) create mode 100644 frontend/src/lib/replications.ts create mode 100644 frontend/src/pages/np/ReplicationSummary.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f883c8a..85b2887 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import EmailVerified from "./pages/EmailVerified"; import { Home } from "./pages/Home"; import NotFound from "./pages/NotFound"; import CreateNanopub from "./pages/np/create/CreateNanopub"; +import ReplicationSummary from "./pages/np/ReplicationSummary"; import ViewNanopub from "./pages/np/ViewNanopub"; import Policies from "./pages/Policies"; import AccountSettings from "./pages/settings/AccountSettings"; @@ -57,6 +58,7 @@ function App() { {/* Main App Pages */} } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/lib/replications.ts b/frontend/src/lib/replications.ts new file mode 100644 index 0000000..4f4287d --- /dev/null +++ b/frontend/src/lib/replications.ts @@ -0,0 +1,210 @@ +/** + * Replication summary — given a paper DOI, find every independent replication of its + * claims on the nanopub network and the verdict each reached. + * + * This is the data behind the public "all replications of a paper" page. It is deliberately + * client-side and unauthenticated: it reads only public nanopub-network data via SPARQL, so the + * page works for anyone (the authenticated API stays for programmatic/registered use). + * + * Enumeration uses the CiTO→DOI method (a verdict-citation from a replication Outcome to the + * original paper's DOI), NOT graph BFS — the latter bleeds across adjacent chains and misses + * disconnected replications. Validity is then checked against retraction/supersession. + */ +import { executeSparql, NANOPUB_SPARQL_ENDPOINT_FULL } from "@/lib/sparql"; + +const TPL_CITO = + "https://w3id.org/np/RA43F9EoOuzF0xoNUnCMNyFsfIqlsuWDdPHCnN0wCdCAw"; + +// CiTO relations that carry a verdict ON the paper (as opposed to method/data provenance). +const VERDICT_RELS = new Set([ + "confirms", + "qualifies", + "disputes", + "critiques", + "extends", + "supports", + "refutes", +]); + +export type Replication = { + outcomeNp: string; + verdict: string; // Validated | PartiallySupported | Contradicted | NotSupported | … + relation: string; // confirms | qualifies | disputes | … + confidence: string; // HighConfidence | … + conclusion: string; + repo: string; + study: { + uri: string; + label: string; + scope: string; + method: string; + deviation: string; + }; + claim: { uri: string; label: string; aida: string; type: string }; +}; + +export type ReplicationSummary = { + doi: string; + count: number; + byVerdict: { validated: number; partial: number; contradicted: number }; + replications: Replication[]; +}; + +const npHash = (u: string) => (u || "").replace(/.*\/np\//, "").split("/")[0]; + +// AIDA statement IRI → the atomic claim sentence (mixed +/%20 encoding in the wild). +const decodeAida = (u: string): string => { + if (!u) return ""; + try { + return decodeURIComponent(u.replace(/.*\/aida\//, "").replace(/\+/g, " ")); + } catch { + return ""; + } +}; + +// ".../terms/model_performance-FORRT-Claim" → "model performance" +const claimTypeLabel = (u: string): string => + u + ? u + .replace(/.*\/terms\//, "") + .replace(/-FORRT-Claim$/, "") + .replace(/_/g, " ") + : ""; + +export const isContradicted = (v: string) => + /contradict|notsupport|refut/i.test(v); +export const isPartial = (v: string) => /partial/i.test(v); +export const isConfirmed = (v: string) => + /validat|confirm|support/i.test(v) && !isContradicted(v) && !isPartial(v); + +const DOI_RE = /^10\.\d{3,}\/\S+$/; + +// One query, anchored on the paper DOI: every Outcome that makes a verdict-citation to the DOI, +// walked up its chain (Outcome → Study → Claim) for the display fields. Fast (~0.3s) because the +// DOI anchors it; the validity guard is a separate scoped query (an inline FILTER NOT EXISTS here +// times the endpoint out). +function chainQuery(doiUri: string): string { + return `PREFIX np: +PREFIX ntpl: +PREFIX cito: +PREFIX slt: +PREFIX rdfs: +SELECT DISTINCT ?outcome ?status ?rel ?confidence ?conclusion ?repo + ?study ?studyLabel ?scope ?method ?deviation + ?claim ?claimLabel ?aida ?ctype WHERE { + GRAPH ?citog { ?citoNp ntpl:wasCreatedFromTemplate <${TPL_CITO}> . } + ?citoNp np:hasAssertion ?ca . + GRAPH ?ca { ?outcome ?relP <${doiUri}> . FILTER(STRSTARTS(STR(?relP), STR(cito:))) } + BIND(STRAFTER(STR(?relP), "http://purl.org/spar/cito/") AS ?rel) + ?outcome np:hasAssertion ?oa . + GRAPH ?oa { ?oc slt:hasValidationStatus ?s ; slt:isOutcomeOf ?study . + OPTIONAL { ?oc slt:hasConfidenceLevel ?cf . } + OPTIONAL { ?oc slt:hasConclusionDescription ?conclusion . } + OPTIONAL { ?oc slt:hasOutcomeRepository ?repo . } } + BIND(STRAFTER(STR(?s), "/terms/") AS ?status) + BIND(STRAFTER(STR(?cf), "/terms/") AS ?confidence) + OPTIONAL { GRAPH ?sg { ?study rdfs:label ?studyLabel . } + OPTIONAL { GRAPH ?sg { ?study slt:hasScopeDescription ?scope . } } + OPTIONAL { GRAPH ?sg { ?study slt:hasMethodologyDescription ?method . } } + OPTIONAL { GRAPH ?sg { ?study slt:hasDeviationDescription ?deviation . } } + OPTIONAL { GRAPH ?sg { ?study slt:targetsClaim ?claim . } GRAPH ?clg { ?claim rdfs:label ?claimLabel . } + OPTIONAL { GRAPH ?clg { ?claim slt:asAidaStatement ?aida . } } + OPTIONAL { GRAPH ?clg { ?claim a ?ctype . FILTER(CONTAINS(STR(?ctype), "-FORRT-Claim")) } } } } +}`; +} + +// Validity guard, scoped to just the outcomes we are about to show (so it stays instant): which of +// them have been retracted / invalidated / superseded by a nanopub from the SAME creator? Only the +// original author can retract their own work — a third-party `retracts` must not suppress it. +// Disapproval is intentionally not here (disagreement ≠ retraction). +function guardQuery(outcomeUris: string[]): string { + const values = outcomeUris.map((u) => `<${u}>`).join(" "); + return `PREFIX npx: +PREFIX dct: +SELECT DISTINCT ?np WHERE { + VALUES ?np { ${values} } + GRAPH ?supg { ?sup ?act ?np . } VALUES ?act { npx:retracts npx:invalidates npx:supersedes } + GRAPH ?cga { ?sup dct:creator ?cc . } GRAPH ?cgb { ?np dct:creator ?cc . } +}`; +} + +export async function fetchReplications( + doi: string, + signal?: AbortSignal, +): Promise { + const clean = doi.trim().replace(/^https?:\/\/doi\.org\//i, ""); + if (!DOI_RE.test(clean)) { + throw new Error(`"${doi}" is not a valid DOI (expected 10.xxxx/…).`); + } + + const rows = await executeSparql( + chainQuery(`https://doi.org/${clean}`), + NANOPUB_SPARQL_ENDPOINT_FULL, + signal, + ); + + // Dedupe by outcome and keep only verdict-bearing citations. + const byNp = new Map(); + for (const r of rows) { + const np = r.outcome; + if (!np || byNp.has(np)) continue; + if (r.rel && !VERDICT_RELS.has(r.rel)) continue; + byNp.set(np, { + outcomeNp: np, + verdict: r.status || "", + relation: r.rel || "", + confidence: r.confidence || "", + conclusion: r.conclusion || "", + repo: r.repo || "", + study: { + uri: r.study || "", + label: r.studyLabel || "", + scope: r.scope || "", + method: r.method || "", + deviation: r.deviation || "", + }, + claim: { + uri: r.claim || "", + label: r.claimLabel || "", + aida: decodeAida(r.aida || ""), + type: claimTypeLabel(r.ctype || ""), + }, + }); + } + + // Drop retracted/superseded outcomes (best-effort: a guard failure must not hide everything). + if (byNp.size > 0) { + try { + const invalid = new Set( + ( + await executeSparql( + guardQuery([...byNp.keys()]), + NANOPUB_SPARQL_ENDPOINT_FULL, + signal, + ) + ).map((r) => npHash(r.np)), + ); + for (const np of [...byNp.keys()]) { + if (invalid.has(npHash(np))) byNp.delete(np); + } + } catch { + /* keep all on guard failure */ + } + } + + const replications = [...byNp.values()].sort((a, b) => + a.claim.type.localeCompare(b.claim.type), + ); + + return { + doi: clean, + count: replications.length, + byVerdict: { + validated: replications.filter((r) => isConfirmed(r.verdict)).length, + partial: replications.filter((r) => isPartial(r.verdict)).length, + contradicted: replications.filter((r) => isContradicted(r.verdict)) + .length, + }, + replications, + }; +} diff --git a/frontend/src/pages/np/ReplicationSummary.tsx b/frontend/src/pages/np/ReplicationSummary.tsx new file mode 100644 index 0000000..163a000 --- /dev/null +++ b/frontend/src/pages/np/ReplicationSummary.tsx @@ -0,0 +1,276 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Spinner } from "@/components/ui/spinner"; +import { + fetchReplications, + isConfirmed, + isContradicted, + isPartial, + type Replication, + type ReplicationSummary as Summary, +} from "@/lib/replications"; +import { + CheckCircle2, + CircleAlert, + ExternalLink, + FileBadge, + HelpCircle, + XCircle, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +const VIEWER = "https://platform.sciencelive4all.org/np/?uri="; + +function VerdictIcon({ verdict }: { verdict: string }) { + if (isContradicted(verdict)) + return ; + if (isPartial(verdict)) + return ; + if (isConfirmed(verdict)) + return ; + return ; +} + +/** A labelled paragraph in the per-replication detail, only when there is content. */ +function Field({ label, value }: { label: string; value: string }) { + if (!value) return null; + return ( +
+
+ {label} +
+

{value}

+
+ ); +} + +function ReplicationCard({ rep }: { rep: Replication }) { + const repoHref = rep.repo + ? rep.repo.startsWith("http") + ? rep.repo + : `https://doi.org/${rep.repo}` + : null; + return ( + + +
+ + {rep.study.label || "Replication study"} + + + + {rep.verdict || "Outcome"} + +
+ {rep.confidence && ( + + Confidence: {rep.confidence.replace(/([a-z])([A-Z])/g, "$1 $2")} + + )} +
+ + + {(rep.study.scope || rep.study.method || rep.study.deviation) && ( + + + + How it was replicated + + + + + + + + + )} + + +
+ ); +} + +/** Group replications by the claim they tested — a paper may have several claims. */ +function groupByClaim(reps: Replication[]) { + const groups = new Map< + string, + { claim: Replication["claim"]; reps: Replication[] } + >(); + for (const r of reps) { + const key = r.claim.aida || r.claim.label || r.claim.uri || "—"; + const g = groups.get(key) ?? { claim: r.claim, reps: [] }; + g.reps.push(r); + groups.set(key, g); + } + return [...groups.values()]; +} + +export default function ReplicationSummary() { + const [searchParams] = useSearchParams(); + const doi = (searchParams.get("doi") || "").trim(); + + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!doi) return; + const ac = new AbortController(); + setLoading(true); + setError(null); + setData(null); + fetchReplications(doi, ac.signal) + .then((d) => { + if (!ac.signal.aborted) setData(d); + }) + .catch((e) => { + if (!ac.signal.aborted) setError(e?.message || String(e)); + }) + .finally(() => { + if (!ac.signal.aborted) setLoading(false); + }); + return () => ac.abort(); + }, [doi]); + + const groups = data ? groupByClaim(data.replications) : []; + + return ( +
+
+

+ Independent replications +

+ {doi ? ( +

+ Every signed replication of{" "} + + {doi} + {" "} + on the nanopub network, with the verdict each reached. + Author-agnostic, retraction-aware, read live from the public + network. +

+ ) : ( +

+ Add a ?doi= parameter, e.g.{" "} + /np/replications?doi=10.1126/science.aax8591. +

+ )} +
+ + {loading && ( +
+ Searching the nanopub network… +
+ )} + + {error && ( + + + {error} + + + )} + + {data && !loading && data.count === 0 && ( + + + No independent replications of this paper have been recorded on the + nanopub network yet. + + + )} + + {data && data.count > 0 && ( + <> +
+ + {data.count} replication{data.count === 1 ? "" : "s"} + + {data.byVerdict.validated > 0 && ( + + + {data.byVerdict.validated} confirmed + + )} + {data.byVerdict.partial > 0 && ( + + + {data.byVerdict.partial} partial + + )} + {data.byVerdict.contradicted > 0 && ( + + + {data.byVerdict.contradicted} contradicted + + )} +
+ + {groups.map((g, i) => ( +
+ +
+
+ Claim tested + {g.claim.type ? ` · ${g.claim.type}` : ""} +
+

+ {g.claim.aida || g.claim.label || "—"} +

+
+
+ {g.reps.map((r) => ( + + ))} +
+
+ ))} + + )} +
+ ); +} From 7748832bcce4021799cc8124d18ad9a023d814b1 Mon Sep 17 00:00:00 2001 From: annefou Date: Mon, 15 Jun 2026 23:25:50 +0200 Subject: [PATCH 2/2] refactor(frontend): move replications SPARQL into a .rq query file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the house SPARQL convention (agent-docs/SPARQL.md): the query now lives in queries/replications-by-paper.rq with a header comment + ?_doiUri placeholder, its types are generated (npm run generate:query-types), and replications.ts runs it via executeBindSparql() with the AbortSignal — no more hand-built query strings in the TS. Also adopts the admin-graph validity guard idiom from aida-statement-nanopub.rq: filter not exists { ?inv npx:invalidates ?outcome ; npa:hasValidSignatureForPublicKeyHash ?pubkey } — npx:invalidates covers retract+supersede and the matching pubkey hash is the cryptographic signer, so it runs inline in one efficient query (no separate scoped guard, no endpoint timeout). Verdict relations are selected via VALUES in the query. --- frontend/src/lib/queries/index.ts | 1 + .../src/lib/queries/replications-by-paper.rq | 62 ++++++++++ .../lib/queries/replications-by-paper.rq.d.ts | 25 +++++ frontend/src/lib/replications.ts | 106 ++---------------- 4 files changed, 98 insertions(+), 96 deletions(-) create mode 100644 frontend/src/lib/queries/replications-by-paper.rq create mode 100644 frontend/src/lib/queries/replications-by-paper.rq.d.ts diff --git a/frontend/src/lib/queries/index.ts b/frontend/src/lib/queries/index.ts index b65c65c..4662ad4 100644 --- a/frontend/src/lib/queries/index.ts +++ b/frontend/src/lib/queries/index.ts @@ -15,6 +15,7 @@ export { default as NANOPUB_REFERENCES } from "./nanopub-references.rq"; export { default as NANOPUB_REFERS_TO } from "./nanopub-refers-to.rq"; export { default as NANOPUB_STATUS } from "./nanopub-status.rq"; export { default as NANOPUB_TYPES } from "./nanopub-types.rq"; +export { default as REPLICATIONS_BY_PAPER } from "./replications-by-paper.rq"; export { default as SEARCH_NANOPUBS } from "./search-nanopubs.rq"; export { default as SEARCH_NANOPUBS_BY_TEMPLATES } from "./search-nanopubs-by-templates.rq"; export { default as SEARCH_NANOPUBS_BY_TYPE } from "./search-nanopubs-by-type.rq"; diff --git a/frontend/src/lib/queries/replications-by-paper.rq b/frontend/src/lib/queries/replications-by-paper.rq new file mode 100644 index 0000000..3fe7c88 --- /dev/null +++ b/frontend/src/lib/queries/replications-by-paper.rq @@ -0,0 +1,62 @@ +# Every independent replication of a paper's claims, with the verdict each reached. +# +# Enumerates by the CiTO->DOI method: a replication Outcome makes a verdict-citation +# (cito:confirms / qualifies / disputes / …) to the original paper's DOI. This is the +# reliable "all replications of paper X" set — unlike a graph BFS from the paper, which +# bleeds into adjacent chains and misses disconnected replications. +# +# Each matching Outcome is then walked up its FORRT chain for display: +# Outcome -> verdict, confidence, conclusion, deposit repository +# isOutcomeOf -> Study -> label, scope, methodology, deviations +# targetsClaim -> Claim -> label, AIDA statement (the atomic claim sentence), FORRT type +# +# Outcomes that have been retracted / invalidated / superseded by their own signer are +# excluded via the admin graph (npx:invalidates covers retract + supersede; the matching +# public-key hash ensures only the original signer can retract their own work — a third +# party cannot suppress someone else's outcome). Disapproval is deliberately not a filter. +# +# Placeholder: `?_doiUri` - URI: the paper DOI as an IRI, e.g. https://doi.org/10.1126/science.aax8591 + +prefix np: +prefix nt: +prefix npa: +prefix npx: +prefix cito: +prefix slt: +prefix rdfs: + +select distinct ?outcome ?status ?rel ?confidence ?conclusion ?repo + ?study ?studyLabel ?scope ?method ?deviation + ?claim ?claimLabel ?aida ?ctype where { + # A CiTO nanopub whose assertion verdict-cites the paper DOI; its subject is the Outcome. + values ?relP { cito:confirms cito:qualifies cito:disputes cito:critiques cito:extends cito:supports cito:refutes } + graph ?ca { ?outcome ?relP ?_doiUri . } + ?citoNp np:hasAssertion ?ca . + graph npa:networkGraph { ?citoNp nt:wasCreatedFromTemplate . } + bind(strafter(str(?relP), "http://purl.org/spar/cito/") as ?rel) + + # The Outcome must be valid: not invalidated/superseded by a nanopub from the same signer. + graph npa:graph { + ?outcome npa:hasValidSignatureForPublicKeyHash ?pubkey ; np:hasAssertion ?oa . + filter not exists { ?inv npx:invalidates ?outcome ; npa:hasValidSignatureForPublicKeyHash ?pubkey . } + } + graph ?oa { + ?oc slt:hasValidationStatus ?s ; slt:isOutcomeOf ?study . + optional { ?oc slt:hasConfidenceLevel ?cf . } + optional { ?oc slt:hasConclusionDescription ?conclusion . } + optional { ?oc slt:hasOutcomeRepository ?repo . } + } + bind(strafter(str(?s), "/terms/") as ?status) + bind(strafter(str(?cf), "/terms/") as ?confidence) + + optional { graph ?sg { ?study rdfs:label ?studyLabel . } } + optional { graph ?sg2 { ?study slt:hasScopeDescription ?scope . } } + optional { graph ?sg3 { ?study slt:hasMethodologyDescription ?method . } } + optional { graph ?sg4 { ?study slt:hasDeviationDescription ?deviation . } } + optional { + graph ?sg5 { ?study slt:targetsClaim ?claim . } + graph ?clg { ?claim rdfs:label ?claimLabel . } + optional { graph ?clg { ?claim slt:asAidaStatement ?aida . } } + optional { graph ?clg { ?claim a ?ctype . filter(contains(str(?ctype), "-FORRT-Claim")) } } + } +} diff --git a/frontend/src/lib/queries/replications-by-paper.rq.d.ts b/frontend/src/lib/queries/replications-by-paper.rq.d.ts new file mode 100644 index 0000000..5b989ea --- /dev/null +++ b/frontend/src/lib/queries/replications-by-paper.rq.d.ts @@ -0,0 +1,25 @@ +/** + * Every independent replication of a paper's claims, with the verdict each reached. Enumerates by the CiTO->DOI method: a replication Outcome makes a verdict-citation (cito:confirms / qualifies / disputes / …) to the original paper's DOI. This is the reliable "all replications of paper X" set — unlike a graph BFS from the paper, which bleeds into adjacent chains and misses disconnected replications. Each matching Outcome is then walked up its FORRT chain for display: Outcome -> verdict, confidence, conclusion, deposit repository isOutcomeOf -> Study -> label, scope, methodology, deviations targetsClaim -> Claim -> label, AIDA statement (the atomic claim sentence), FORRT type Outcomes that have been retracted / invalidated / superseded by their own signer are excluded via the admin graph (npx:invalidates covers retract + supersede; the matching public-key hash ensures only the original signer can retract their own work — a third party cannot suppress someone else's outcome). Disapproval is deliberately not a filter. + * @see ./replications-by-paper.rq + * @generator Generated by `npm run generate:query-types` + */ +declare const query: import("../sparql").SparqlQuery<{ + doiUri: "uri"; +}, { + outcome: "string"; + status: "string"; + rel: "string"; + confidence: "string"; + conclusion: "string"; + repo: "string"; + study: "string"; + studyLabel: "string"; + scope: "string"; + method: "string"; + deviation: "string"; + claim: "string"; + claimLabel: "string"; + aida: "string"; + ctype: "string"; +}>; +export default query; diff --git a/frontend/src/lib/replications.ts b/frontend/src/lib/replications.ts index 4f4287d..ba4e6ac 100644 --- a/frontend/src/lib/replications.ts +++ b/frontend/src/lib/replications.ts @@ -2,29 +2,14 @@ * Replication summary — given a paper DOI, find every independent replication of its * claims on the nanopub network and the verdict each reached. * - * This is the data behind the public "all replications of a paper" page. It is deliberately - * client-side and unauthenticated: it reads only public nanopub-network data via SPARQL, so the - * page works for anyone (the authenticated API stays for programmatic/registered use). + * Deliberately client-side and unauthenticated: it reads only public nanopub-network data, + * so the page works for anyone (the authenticated API stays for programmatic/registered use). * - * Enumeration uses the CiTO→DOI method (a verdict-citation from a replication Outcome to the - * original paper's DOI), NOT graph BFS — the latter bleeds across adjacent chains and misses - * disconnected replications. Validity is then checked against retraction/supersession. + * The SPARQL lives in `queries/replications-by-paper.rq` (CiTO→DOI enumeration + chain walk + + * admin-graph validity guard); here we just bind the DOI, run it, and shape the rows. */ -import { executeSparql, NANOPUB_SPARQL_ENDPOINT_FULL } from "@/lib/sparql"; - -const TPL_CITO = - "https://w3id.org/np/RA43F9EoOuzF0xoNUnCMNyFsfIqlsuWDdPHCnN0wCdCAw"; - -// CiTO relations that carry a verdict ON the paper (as opposed to method/data provenance). -const VERDICT_RELS = new Set([ - "confirms", - "qualifies", - "disputes", - "critiques", - "extends", - "supports", - "refutes", -]); +import { REPLICATIONS_BY_PAPER } from "@/lib/queries"; +import { executeBindSparql, NANOPUB_SPARQL_ENDPOINT_FULL } from "@/lib/sparql"; export type Replication = { outcomeNp: string; @@ -50,8 +35,6 @@ export type ReplicationSummary = { replications: Replication[]; }; -const npHash = (u: string) => (u || "").replace(/.*\/np\//, "").split("/")[0]; - // AIDA statement IRI → the atomic claim sentence (mixed +/%20 encoding in the wild). const decodeAida = (u: string): string => { if (!u) return ""; @@ -79,55 +62,6 @@ export const isConfirmed = (v: string) => const DOI_RE = /^10\.\d{3,}\/\S+$/; -// One query, anchored on the paper DOI: every Outcome that makes a verdict-citation to the DOI, -// walked up its chain (Outcome → Study → Claim) for the display fields. Fast (~0.3s) because the -// DOI anchors it; the validity guard is a separate scoped query (an inline FILTER NOT EXISTS here -// times the endpoint out). -function chainQuery(doiUri: string): string { - return `PREFIX np: -PREFIX ntpl: -PREFIX cito: -PREFIX slt: -PREFIX rdfs: -SELECT DISTINCT ?outcome ?status ?rel ?confidence ?conclusion ?repo - ?study ?studyLabel ?scope ?method ?deviation - ?claim ?claimLabel ?aida ?ctype WHERE { - GRAPH ?citog { ?citoNp ntpl:wasCreatedFromTemplate <${TPL_CITO}> . } - ?citoNp np:hasAssertion ?ca . - GRAPH ?ca { ?outcome ?relP <${doiUri}> . FILTER(STRSTARTS(STR(?relP), STR(cito:))) } - BIND(STRAFTER(STR(?relP), "http://purl.org/spar/cito/") AS ?rel) - ?outcome np:hasAssertion ?oa . - GRAPH ?oa { ?oc slt:hasValidationStatus ?s ; slt:isOutcomeOf ?study . - OPTIONAL { ?oc slt:hasConfidenceLevel ?cf . } - OPTIONAL { ?oc slt:hasConclusionDescription ?conclusion . } - OPTIONAL { ?oc slt:hasOutcomeRepository ?repo . } } - BIND(STRAFTER(STR(?s), "/terms/") AS ?status) - BIND(STRAFTER(STR(?cf), "/terms/") AS ?confidence) - OPTIONAL { GRAPH ?sg { ?study rdfs:label ?studyLabel . } - OPTIONAL { GRAPH ?sg { ?study slt:hasScopeDescription ?scope . } } - OPTIONAL { GRAPH ?sg { ?study slt:hasMethodologyDescription ?method . } } - OPTIONAL { GRAPH ?sg { ?study slt:hasDeviationDescription ?deviation . } } - OPTIONAL { GRAPH ?sg { ?study slt:targetsClaim ?claim . } GRAPH ?clg { ?claim rdfs:label ?claimLabel . } - OPTIONAL { GRAPH ?clg { ?claim slt:asAidaStatement ?aida . } } - OPTIONAL { GRAPH ?clg { ?claim a ?ctype . FILTER(CONTAINS(STR(?ctype), "-FORRT-Claim")) } } } } -}`; -} - -// Validity guard, scoped to just the outcomes we are about to show (so it stays instant): which of -// them have been retracted / invalidated / superseded by a nanopub from the SAME creator? Only the -// original author can retract their own work — a third-party `retracts` must not suppress it. -// Disapproval is intentionally not here (disagreement ≠ retraction). -function guardQuery(outcomeUris: string[]): string { - const values = outcomeUris.map((u) => `<${u}>`).join(" "); - return `PREFIX npx: -PREFIX dct: -SELECT DISTINCT ?np WHERE { - VALUES ?np { ${values} } - GRAPH ?supg { ?sup ?act ?np . } VALUES ?act { npx:retracts npx:invalidates npx:supersedes } - GRAPH ?cga { ?sup dct:creator ?cc . } GRAPH ?cgb { ?np dct:creator ?cc . } -}`; -} - export async function fetchReplications( doi: string, signal?: AbortSignal, @@ -137,18 +71,18 @@ export async function fetchReplications( throw new Error(`"${doi}" is not a valid DOI (expected 10.xxxx/…).`); } - const rows = await executeSparql( - chainQuery(`https://doi.org/${clean}`), + const rows = await executeBindSparql( + REPLICATIONS_BY_PAPER, + { doiUri: `https://doi.org/${clean}` }, NANOPUB_SPARQL_ENDPOINT_FULL, signal, ); - // Dedupe by outcome and keep only verdict-bearing citations. + // One row per (outcome, optional-binding) — collapse to one Replication per outcome. const byNp = new Map(); for (const r of rows) { const np = r.outcome; if (!np || byNp.has(np)) continue; - if (r.rel && !VERDICT_RELS.has(r.rel)) continue; byNp.set(np, { outcomeNp: np, verdict: r.status || "", @@ -172,26 +106,6 @@ export async function fetchReplications( }); } - // Drop retracted/superseded outcomes (best-effort: a guard failure must not hide everything). - if (byNp.size > 0) { - try { - const invalid = new Set( - ( - await executeSparql( - guardQuery([...byNp.keys()]), - NANOPUB_SPARQL_ENDPOINT_FULL, - signal, - ) - ).map((r) => npHash(r.np)), - ); - for (const np of [...byNp.keys()]) { - if (invalid.has(npHash(np))) byNp.delete(np); - } - } catch { - /* keep all on guard failure */ - } - } - const replications = [...byNp.values()].sort((a, b) => a.claim.type.localeCompare(b.claim.type), );