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/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 new file mode 100644 index 0000000..ba4e6ac --- /dev/null +++ b/frontend/src/lib/replications.ts @@ -0,0 +1,124 @@ +/** + * Replication summary — given a paper DOI, find every independent replication of its + * claims on the nanopub network and the verdict each reached. + * + * 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). + * + * 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 { REPLICATIONS_BY_PAPER } from "@/lib/queries"; +import { executeBindSparql, NANOPUB_SPARQL_ENDPOINT_FULL } from "@/lib/sparql"; + +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[]; +}; + +// 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+$/; + +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 executeBindSparql( + REPLICATIONS_BY_PAPER, + { doiUri: `https://doi.org/${clean}` }, + NANOPUB_SPARQL_ENDPOINT_FULL, + signal, + ); + + // 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; + 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 || ""), + }, + }); + } + + 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) => ( + + ))} +
+
+ ))} + + )} +
+ ); +}