diff --git a/api/src/np/constellation.test.ts b/api/src/np/constellation.test.ts index 214185c..eb170de 100644 --- a/api/src/np/constellation.test.ts +++ b/api/src/np/constellation.test.ts @@ -17,7 +17,11 @@ * - SPARQL 503 retry stays correct under the BFS traversal load. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildConstellation, classifyStepKind } from "./constellation"; +import { + buildConstellation, + classifyStepKind, + extractAidaStatementIris, +} from "./constellation"; import { NANOPUB_SPARQL_ENDPOINT_FULL } from "./queries"; // Tiny FORRT-shaped graph used as fixture across the tests. @@ -1548,3 +1552,135 @@ sub:assertion { ); }); }); + +// ============================================================================= +// AIDA-statement bridge — Claim -> AIDA -> Quote across a content IRI +// ============================================================================= + +describe("extractAidaStatementIris", () => { + it("extracts purl.org/aida statement IRIs and ignores nanopub URIs", () => { + const trig = ` +sub:assertion { + + + . +}`; + expect(extractAidaStatementIris(trig)).toEqual([ + "http://purl.org/aida/Some%20atomic%20finding%20about%20X.", + ]); + }); + + it("returns [] when there is no AIDA-statement IRI", () => { + expect( + extractAidaStatementIris( + ` a .`, + ), + ).toEqual([]); + }); + + it("de-duplicates repeated IRIs", () => { + const iri = "http://purl.org/aida/Repeated%20finding."; + const trig = `

<${iri}> . <${iri}> .`; + expect(extractAidaStatementIris(trig)).toEqual([iri]); + }); +}); + +describe("buildConstellation — AIDA-statement bridge", () => { + // The Claim links to its AIDA ONLY via asAidaStatement -> purl.org/aida IRI + // (a content IRI, never a nanopub URI). Neither refersToNanopub nor TriG + // URI-mining can cross it; only the AIDA-statement SPARQL query bridges it. + // From the AIDA, the Quote follows via an ordinary mined nanopub URI. + const CLAIM = + "https://w3id.org/sciencelive/np/RAclaimAida0000000000000000000000000000000"; + const AIDA = + "https://w3id.org/sciencelive/np/RAaidaNode00000000000000000000000000000000"; + const QUOTE = + "https://w3id.org/sciencelive/np/RAquoteNode0000000000000000000000000000000"; + const AIDA_IRI = "http://purl.org/aida/Some%20atomic%20finding%20about%20X."; + const TPL_CLAIM = + "https://w3id.org/np/RAtplClaimB00000000000000000000000000000000000"; + const TPL_AIDA = + "https://w3id.org/np/RAtplAidaB000000000000000000000000000000000000"; + const TPL_QUOTE = + "https://w3id.org/np/RAtplQuoteB00000000000000000000000000000000000"; + + const resolver = (uri: string) => + `https://w3id.org/np/${uri.split("/").pop()}`; + + function makeMock() { + const trigMap: Record = { + [resolver(CLAIM)]: trigFor( + CLAIM, + `<${CLAIM}> <${AIDA_IRI}> .`, + TPL_CLAIM, + ), + [resolver(AIDA)]: trigFor( + AIDA, + `<${AIDA_IRI}> a ; + <${QUOTE}> .`, + TPL_AIDA, + ), + [resolver(QUOTE)]: trigFor( + QUOTE, + `<${QUOTE}> a .`, + TPL_QUOTE, + ), + [TPL_CLAIM]: tplTrigFor("Declaring an original claim according to FORRT"), + [TPL_AIDA]: tplTrigFor("Expressing a claim as an AIDA sentence"), + [TPL_QUOTE]: tplTrigFor( + "Annotating a paper quotation with personal interpretation", + ), + }; + + return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const u = typeof url === "string" ? url : url.toString(); + if (u === NANOPUB_SPARQL_ENDPOINT_FULL) { + const body = init?.body; + const queryText = + body instanceof URLSearchParams + ? (body.get("query") ?? "") + : String(body ?? ""); + // The AIDA-statement bridge query is the only one mentioning the + // AIDA-Sentence type; return the AIDA np when its IRI is bound in. + if (queryText.includes("hycl:AIDA-Sentence")) { + const m = /<(http:\/\/purl\.org\/aida\/[^>]+)>/.exec(queryText); + const rows = m && m[1] === AIDA_IRI ? [{ np: AIDA }] : []; + return new Response(sparqlBindings(rows), { + status: 200, + headers: { "content-type": "application/sparql-results+json" }, + }); + } + // refersToNanopub queries return nothing — the chain is TriG/bridge only. + return new Response(sparqlBindings([]), { + status: 200, + headers: { "content-type": "application/sparql-results+json" }, + }); + } + const trig = trigMap[u]; + if (trig) + return new Response(trig, { + status: 200, + headers: { "content-type": "application/trig" }, + }); + return new Response("nf", { status: 404 }); + }); + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("bridges asAidaStatement IRI to reach the AIDA nanopub and the Quote beyond it", async () => { + vi.stubGlobal("fetch", makeMock()); + const c = await buildConstellation(CLAIM, { + depthLimit: 5, + maxNodes: 80, + concurrency: 2, + }); + const uris = c.nodes.map((n) => n.uri); + expect(uris).toContain(AIDA); + // Quote is reachable only THROUGH the bridged AIDA — proves the bridge + // re-enables downstream TriG mining past the Claim terminus. + expect(uris).toContain(QUOTE); + }); +}); diff --git a/api/src/np/constellation.ts b/api/src/np/constellation.ts index d28be6c..08d2f9d 100644 --- a/api/src/np/constellation.ts +++ b/api/src/np/constellation.ts @@ -1,4 +1,5 @@ import { + AIDA_STATEMENT_NANOPUB, bindUri, NANOPUB_SPARQL_ENDPOINT_FULL, REFERENCES_FROM, @@ -330,6 +331,13 @@ async function processNode( for (const u of extractNanopubUris(trig)) { if (u !== uri) merged.add(u); } + // Bridge AIDA-statement IRIs: a FORRT Claim links to its AIDA only via + // `asAidaStatement -> http://purl.org/aida/` (a content IRI, not a + // nanopub URI), so without this the walk stops at the Claim and the upstream + // AIDA + Quote never surface. Resolve each such IRI to its AIDA nanopub. + for (const u of await discoverAidaStatementNeighbours(trig, signal)) { + if (u !== uri) merged.add(u); + } const neighbours = [...merged]; return { depth, node, neighbours, dois: extractDois(trig) }; @@ -352,6 +360,52 @@ async function discoverNeighbours( return [...out]; } +/** + * AIDA-statement IRIs are content-derived `http://purl.org/aida/` + * identifiers. A FORRT Claim references its AIDA only as the object of + * `sciencelive:asAidaStatement `, so the link to the AIDA + * *nanopublication* is invisible to both the `npa:refersToNanopub` index and + * TriG URI-mining. Pull every such IRI out of a node's TriG body so we can + * resolve it to its asserting nanopub. + */ +export function extractAidaStatementIris(trig: string): string[] { + const out = new Set(); + for (const m of trig.matchAll(/<(https?:\/\/purl\.org\/aida\/[^>\s]+)>/g)) { + out.add(m[1]); + } + return [...out]; +} + +/** + * Resolve the AIDA-statement IRIs in a node's TriG to the nanopub(s) that + * assert them (the AIDA Sentence nanopubs), bridging the Claim -> AIDA gap. + * Best-effort: a SPARQL failure for one IRI must not abort the BFS. + */ +async function discoverAidaStatementNeighbours( + trig: string, + signal?: AbortSignal, +): Promise { + const iris = extractAidaStatementIris(trig); + if (iris.length === 0) return []; + const out = new Set(); + for (const iri of iris) { + let rows: Record[] = []; + try { + rows = await executeSparql( + bindUri(AIDA_STATEMENT_NANOPUB, iri, "?_aidaStatementIri"), + signal, + ); + } catch { + rows = []; + } + for (const row of rows) { + const canon = canonicalNanopubUri(row.np ?? ""); + if (canon) out.add(canon); + } + } + return [...out]; +} + async function resolveTemplateLabel( templateUri: string, cache: Map, diff --git a/api/src/np/queries.ts b/api/src/np/queries.ts index 06bfa94..2e21fec 100644 --- a/api/src/np/queries.ts +++ b/api/src/np/queries.ts @@ -81,8 +81,52 @@ limit 100 `; /** - * Substitute the `?_nanopubUri` placeholder with a bracketed URI literal. + * Source: frontend/src/lib/queries/aida-statement-nanopub.rq + * Returns the nanopub(s) that assert a given AIDA-statement IRI + * (`http://purl.org/aida/`), i.e. the AIDA Sentence nanopub. + * + * A FORRT Claim links to its AIDA only through this shared statement IRI + * (`sciencelive:asAidaStatement `), never by the AIDA's nanopub URI. + * So neither the `npa:refersToNanopub` index nor TriG URI-mining can reach the + * AIDA nanopub from the Claim — without this query the constellation walk stops + * at the Claim and the upstream AIDA + Quote never surface. + */ +export const AIDA_STATEMENT_NANOPUB = ` +prefix rdfs: +prefix np: +prefix npa: +prefix npx: +prefix dct: +prefix nt: +prefix hycl: + +select ?np ?label ?date ?creator ?template where { + graph npa:graph { + ?np npa:hasValidSignatureForPublicKeyHash ?pubkey . + filter not exists { ?npx npx:invalidates ?np ; npa:hasValidSignatureForPublicKeyHash ?pubkey . } + optional { ?np rdfs:label ?label . } + ?np np:hasAssertion ?assertion ; + dct:created ?date ; + dct:creator ?creator . + } + graph ?assertion { + ?_aidaStatementIri a hycl:AIDA-Sentence . + } + optional { graph npa:networkGraph { ?np nt:wasCreatedFromTemplate ?template . } } +} +order by desc(?date) +limit 100 +`; + +/** + * Substitute a `?_…` placeholder with a bracketed URI literal. Defaults to the + * `?_nanopubUri` placeholder used by the reference queries; pass an explicit + * placeholder for other queries (e.g. `?_aidaStatementIri`). */ -export function bindUri(query: string, nanopubUri: string): string { - return query.replaceAll("?_nanopubUri", `<${nanopubUri}>`); +export function bindUri( + query: string, + uri: string, + placeholder = "?_nanopubUri", +): string { + return query.replaceAll(placeholder, `<${uri}>`); } diff --git a/frontend/src/lib/queries/aida-statement-nanopub.rq b/frontend/src/lib/queries/aida-statement-nanopub.rq new file mode 100644 index 0000000..bf666c3 --- /dev/null +++ b/frontend/src/lib/queries/aida-statement-nanopub.rq @@ -0,0 +1,40 @@ +# Get the nanopub(s) that assert a given AIDA-statement IRI +# (`http://purl.org/aida/`), i.e. the AIDA Sentence nanopub. +# +# A FORRT Claim links to its AIDA only through this shared statement IRI +# (`sciencelive:asAidaStatement `), never by the AIDA's nanopub URI. +# Neither the npa:refersToNanopub index nor TriG URI-mining can reach the AIDA +# nanopub from the Claim, so the constellation walk uses this query to bridge +# Claim -> AIDA (and from the AIDA, the upstream Quote follows via skos:related). +# +# Placeholder: `?_aidaStatementIri` - URI: the AIDA-statement IRI to resolve. + +prefix rdfs: +prefix np: +prefix npa: +prefix npx: +prefix dct: +prefix nt: +prefix hycl: + +select ?np ?label ?date ?creator ?template where { + # Find the nanopub whose assertion graph types the AIDA-statement IRI as an + # AIDA Sentence — that is the AIDA nanopub (a Claim only *references* the IRI + # as the object of sciencelive:asAidaStatement; it never types it). + graph npa:graph { + ?np npa:hasValidSignatureForPublicKeyHash ?pubkey . + filter not exists { ?npx npx:invalidates ?np ; npa:hasValidSignatureForPublicKeyHash ?pubkey . } + optional { ?np rdfs:label ?label . } + ?np np:hasAssertion ?assertion ; + dct:created ?date ; + dct:creator ?creator . + } + graph ?assertion { + ?_aidaStatementIri a hycl:AIDA-Sentence . + } + + # Optionally get the template this nanopub was created from + optional { graph npa:networkGraph { ?np nt:wasCreatedFromTemplate ?template . } } +} +order by desc(?date) +limit 100