diff --git a/src/main/java/com/knowledgepixels/query/SpacesExtractor.java b/src/main/java/com/knowledgepixels/query/SpacesExtractor.java index 4ee63ba..8452a23 100644 --- a/src/main/java/com/knowledgepixels/query/SpacesExtractor.java +++ b/src/main/java/com/knowledgepixels/query/SpacesExtractor.java @@ -4,8 +4,10 @@ import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import org.eclipse.rdf4j.model.IRI; @@ -252,6 +254,65 @@ private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI ro out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH)); addProvenance(riIri, ctx, out); } + + // Inline non-hasAdmin role triples: a gen:Space nanopub may also assert + // (INVERSE) or (REGULAR) + // for any of the back-compat role predicates (has-event-facilitator, + // participatedAsParticipantIn, …). Without an extraction path here those + // are silently dropped because gen:Space nanopubs are not auto-typed + // with back-compat predicates (only single-triple-assertion nanopubs are). + // Emit one RoleInstantiation per distinct predicate found, grouping + // multi-agent like the admin case. The subject is disambiguated by a + // hash of the predicate IRI so multiple predicates in one nanopub don't + // collide on the same npari: subject as the admin RI. + emitInlineRoleInstantiations(np, ctx, spaceIri, out); + } + + /** + * Scans the assertion of a {@code gen:Space} nanopub for inline role triples + * (excluding {@code gen:hasAdmin}, which is handled separately as the trust + * seed), grouping by predicate and emitting one {@link GEN#ROLE_INSTANTIATION} + * per (predicate, direction) pair with multi-valued {@code npa:forAgent}. + */ + private static void emitInlineRoleInstantiations(Nanopub np, Context ctx, IRI spaceIri, + List out) { + Map directionByPred = new LinkedHashMap<>(); + Map> agentsByPred = new LinkedHashMap<>(); + for (Statement st : np.getAssertion()) { + IRI predicate = st.getPredicate(); + if (GEN.HAS_ADMIN.equals(predicate)) continue; // already emitted above + BackcompatRolePredicates.Direction direction = BackcompatRolePredicates.DIRECTIONS.get(predicate); + if (direction == null) continue; + if (!(st.getSubject() instanceof IRI subjIri)) continue; + if (!(st.getObject() instanceof IRI objIri)) continue; + IRI agent; + if (direction == BackcompatRolePredicates.Direction.INVERSE) { + if (!spaceIri.equals(subjIri)) continue; + agent = objIri; + } else { + if (!spaceIri.equals(objIri)) continue; + agent = subjIri; + } + directionByPred.put(predicate, direction); + agentsByPred.computeIfAbsent(predicate, k -> new LinkedHashSet<>()).add(agent); + } + for (Map.Entry> entry : agentsByPred.entrySet()) { + IRI predicate = entry.getKey(); + BackcompatRolePredicates.Direction direction = directionByPred.get(predicate); + String predHash = Utils.createHash(predicate.stringValue()); + IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode(), predHash); + out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH)); + out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH)); + IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR) + ? SpacesVocab.REGULAR_PROPERTY + : SpacesVocab.INVERSE_PROPERTY; + out.add(vf.createStatement(riIri, directionPredicate, predicate, GRAPH)); + for (IRI agent : entry.getValue()) { + out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, agent, GRAPH)); + } + out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH)); + addProvenance(riIri, ctx, out); + } } /** diff --git a/src/main/java/com/knowledgepixels/query/vocabulary/BackcompatRolePredicates.java b/src/main/java/com/knowledgepixels/query/vocabulary/BackcompatRolePredicates.java index b821ba3..fdcf93a 100644 --- a/src/main/java/com/knowledgepixels/query/vocabulary/BackcompatRolePredicates.java +++ b/src/main/java/com/knowledgepixels/query/vocabulary/BackcompatRolePredicates.java @@ -52,6 +52,7 @@ private static IRI iri(String s) { // FAIR 3pff Map.entry(iri("https://w3id.org/fair/3pff/has-event-assistant"), Direction.INVERSE), Map.entry(iri("https://w3id.org/fair/3pff/has-event-facilitator"), Direction.INVERSE), + Map.entry(iri("https://w3id.org/fair/3pff/has-event-organizer"), Direction.INVERSE), Map.entry(iri("https://w3id.org/fair/3pff/participatedAsFacilitatorAssistantIn"), Direction.REGULAR), Map.entry(iri("https://w3id.org/fair/3pff/participatedAsFacilitatorIn"), Direction.REGULAR), Map.entry(iri("https://w3id.org/fair/3pff/participatedAsImplementerAspirantIn"), Direction.REGULAR), diff --git a/src/main/java/com/knowledgepixels/query/vocabulary/SpacesVocab.java b/src/main/java/com/knowledgepixels/query/vocabulary/SpacesVocab.java index 5df811a..bbcc752 100644 --- a/src/main/java/com/knowledgepixels/query/vocabulary/SpacesVocab.java +++ b/src/main/java/com/knowledgepixels/query/vocabulary/SpacesVocab.java @@ -162,6 +162,17 @@ public static IRI forRoleInstantiation(String artifactCode) { return vf.createIRI(NPARI_NAMESPACE, artifactCode); } + /** + * Mints {@code npari:_} for a role-instantiation + * entry where a single nanopub emits multiple RIs (e.g. a {@code gen:Space} + * nanopub with inline role-predicate triples for several distinct roles). + * The hash is on the predicate IRI so RIs for different roles in the same + * nanopub get distinct subjects. + */ + public static IRI forRoleInstantiation(String artifactCode, String discriminatorHash) { + return vf.createIRI(NPARI_NAMESPACE, artifactCode + "_" + discriminatorHash); + } + /** Mints {@code npara:} for a role-attachment entry. */ public static IRI forRoleAssignment(String artifactCode) { return vf.createIRI(NPARA_NAMESPACE, artifactCode); diff --git a/src/test/java/com/knowledgepixels/query/SpacesExtractorTest.java b/src/test/java/com/knowledgepixels/query/SpacesExtractorTest.java index b1c5356..b23b7fc 100644 --- a/src/test/java/com/knowledgepixels/query/SpacesExtractorTest.java +++ b/src/test/java/com/knowledgepixels/query/SpacesExtractorTest.java @@ -151,6 +151,53 @@ void extract_spaceSelfRooted_emitsSpaceRefSpaceDefinitionAndAdminRoleInstantiati assertContains(out, riIri, VIA_NANOPUB, NP_URI); } + @Test + void extract_spaceWithInlineRoleTriples_emitsAdditionalRoleInstantiations() throws Exception { + // A gen:Space nanopub may also declare other role-predicate assignments + // inline alongside hasAdmin (event facilitators, participants, etc.). + // Each distinct predicate should mint its own RoleInstantiation entry + // — gen:hasAdmin keeps the unhashed subject npari: (existing + // behaviour); others use npari:_ to avoid + // collision. + IRI facilitatorPred = vf.createIRI("https://w3id.org/fair/3pff/has-event-facilitator"); + IRI participantPred = vf.createIRI("https://w3id.org/fair/3pff/participatedAsParticipantIn"); + IRI participantAgent = vf.createIRI("https://orcid.org/0000-0000-0000-0004"); + + Nanopub np = creator() + .type(GEN.SPACE) + .assertion(SPACE_IRI_1, RDF.TYPE, GEN.SPACE) + .assertion(SPACE_IRI_1, GEN.HAS_ROOT_DEFINITION, NP_URI) + .assertion(SPACE_IRI_1, GEN.HAS_ADMIN, ADMIN_AGENT_1) + .assertion(SPACE_IRI_1, facilitatorPred, ADMIN_AGENT_1) + .assertion(SPACE_IRI_1, facilitatorPred, ADMIN_AGENT_2) + .assertion(participantAgent, participantPred, SPACE_IRI_1) + .finalizeNanopub(); + + List out = SpacesExtractor.extract(np, defaultContext()); + IRI adminRi = forRoleInstantiation(ARTIFACT_CODE); + IRI facilitatorRi = forRoleInstantiation(ARTIFACT_CODE, Utils.createHash(facilitatorPred.stringValue())); + IRI participantRi = forRoleInstantiation(ARTIFACT_CODE, Utils.createHash(participantPred.stringValue())); + + // Admin RI unchanged (same subject as before). + assertContains(out, adminRi, RDF.TYPE, GEN.ROLE_INSTANTIATION); + assertContains(out, adminRi, INVERSE_PROPERTY, GEN.HAS_ADMIN); + assertContains(out, adminRi, FOR_AGENT, ADMIN_AGENT_1); + + // Facilitator RI: INVERSE (space-centric), multi-valued forAgent. + assertContains(out, facilitatorRi, RDF.TYPE, GEN.ROLE_INSTANTIATION); + assertContains(out, facilitatorRi, FOR_SPACE, SPACE_IRI_1); + assertContains(out, facilitatorRi, INVERSE_PROPERTY, facilitatorPred); + assertContains(out, facilitatorRi, FOR_AGENT, ADMIN_AGENT_1); + assertContains(out, facilitatorRi, FOR_AGENT, ADMIN_AGENT_2); + assertContains(out, facilitatorRi, VIA_NANOPUB, NP_URI); + + // Participant RI: REGULAR (agent-centric). + assertContains(out, participantRi, RDF.TYPE, GEN.ROLE_INSTANTIATION); + assertContains(out, participantRi, FOR_SPACE, SPACE_IRI_1); + assertContains(out, participantRi, REGULAR_PROPERTY, participantPred); + assertContains(out, participantRi, FOR_AGENT, participantAgent); + } + @Test void extract_spaceUpdate_emitsSpaceEntryButNoRootAdminSeed() throws Exception { // Update: gen:hasRootDefinition points at a different (original root) nanopub.