Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 137 additions & 1 deletion api/src/np/constellation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
<https://w3id.org/sciencelive/np/RAclaim00000000000000000000000000000000000>
<https://w3id.org/sciencelive/o/terms/asAidaStatement>
<http://purl.org/aida/Some%20atomic%20finding%20about%20X.> .
}`;
expect(extractAidaStatementIris(trig)).toEqual([
"http://purl.org/aida/Some%20atomic%20finding%20about%20X.",
]);
});

it("returns [] when there is no AIDA-statement IRI", () => {
expect(
extractAidaStatementIris(
`<https://w3id.org/sciencelive/np/RAx0000000000000000000000000000000000000000> a <http://example.org/Thing> .`,
),
).toEqual([]);
});

it("de-duplicates repeated IRIs", () => {
const iri = "http://purl.org/aida/Repeated%20finding.";
const trig = `<a> <p> <${iri}> . <b> <q> <${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<string, string> = {
[resolver(CLAIM)]: trigFor(
CLAIM,
`<${CLAIM}> <https://w3id.org/sciencelive/o/terms/asAidaStatement> <${AIDA_IRI}> .`,
TPL_CLAIM,
),
[resolver(AIDA)]: trigFor(
AIDA,
`<${AIDA_IRI}> a <http://purl.org/petapico/o/hycl#AIDA-Sentence> ;
<http://www.w3.org/2004/02/skos/core#related> <${QUOTE}> .`,
TPL_AIDA,
),
[resolver(QUOTE)]: trigFor(
QUOTE,
`<${QUOTE}> a <https://example.org/Quote> .`,
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);
});
});
54 changes: 54 additions & 0 deletions api/src/np/constellation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AIDA_STATEMENT_NANOPUB,
bindUri,
NANOPUB_SPARQL_ENDPOINT_FULL,
REFERENCES_FROM,
Expand Down Expand Up @@ -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/<sentence>` (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) };
Expand All @@ -352,6 +360,52 @@ async function discoverNeighbours(
return [...out];
}

/**
* AIDA-statement IRIs are content-derived `http://purl.org/aida/<sentence>`
* identifiers. A FORRT Claim references its AIDA only as the object of
* `sciencelive:asAidaStatement <aida-IRI>`, 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<string>();
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<string[]> {
const iris = extractAidaStatementIris(trig);
if (iris.length === 0) return [];
const out = new Set<string>();
for (const iri of iris) {
let rows: Record<string, string>[] = [];
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<string, string>,
Expand Down
50 changes: 47 additions & 3 deletions api/src/np/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sentence>`), i.e. the AIDA Sentence nanopub.
*
* A FORRT Claim links to its AIDA only through this shared statement IRI
* (`sciencelive:asAidaStatement <aida-IRI>`), 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: <http://www.w3.org/2000/01/rdf-schema#>
prefix np: <http://www.nanopub.org/nschema#>
prefix npa: <http://purl.org/nanopub/admin/>
prefix npx: <http://purl.org/nanopub/x/>
prefix dct: <http://purl.org/dc/terms/>
prefix nt: <https://w3id.org/np/o/ntemplate/>
prefix hycl: <http://purl.org/petapico/o/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}>`);
}
40 changes: 40 additions & 0 deletions frontend/src/lib/queries/aida-statement-nanopub.rq
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Get the nanopub(s) that assert a given AIDA-statement IRI
# (`http://purl.org/aida/<sentence>`), i.e. the AIDA Sentence nanopub.
#
# A FORRT Claim links to its AIDA only through this shared statement IRI
# (`sciencelive:asAidaStatement <aida-IRI>`), 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: <http://www.w3.org/2000/01/rdf-schema#>
prefix np: <http://www.nanopub.org/nschema#>
prefix npa: <http://purl.org/nanopub/admin/>
prefix npx: <http://purl.org/nanopub/x/>
prefix dct: <http://purl.org/dc/terms/>
prefix nt: <https://w3id.org/np/o/ntemplate/>
prefix hycl: <http://purl.org/petapico/o/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
Loading