• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanopub-query / 28523687845

01 Jul 2026 02:08PM UTC coverage: 62.734% (+0.6%) from 62.167%
28523687845

push

github

web-flow
Merge pull request #137 from knowledgepixels/feat/role-direction-pinning

Resolve role-predicate direction from pubinfo pins (#136 Part B)

603 of 1064 branches covered (56.67%)

Branch coverage included in aggregate %.

1742 of 2674 relevant lines covered (65.15%)

10.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.98
src/main/java/com/knowledgepixels/query/SpacesExtractor.java
1
package com.knowledgepixels.query;
2

3
import com.knowledgepixels.query.vocabulary.BackcompatRolePredicates;
4
import com.knowledgepixels.query.vocabulary.GEN;
5
import com.knowledgepixels.query.vocabulary.SpacesVocab;
6
import net.trustyuri.TrustyUriUtils;
7
import org.eclipse.rdf4j.model.*;
8
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
9
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
10
import org.eclipse.rdf4j.model.vocabulary.OWL;
11
import org.eclipse.rdf4j.model.vocabulary.RDF;
12
import org.nanopub.Nanopub;
13
import org.nanopub.NanopubUtils;
14
import org.nanopub.vocabulary.NPA;
15
import org.nanopub.vocabulary.NPX;
16
import org.slf4j.Logger;
17
import org.slf4j.LoggerFactory;
18

19
import java.util.*;
20

21
/**
22
 * Pure-logic extractor from a loaded {@link Nanopub} to the add-only summary
23
 * triples destined for {@code npa:spacesGraph}. Implements the per-type schema
24
 * from {@code doc/design-space-repositories.md}.
25
 *
26
 * <p>Dispatch is by nanopub type — {@link NanopubUtils#getTypes(Nanopub)} returns
27
 * both {@code rdf:type} / {@code npx:hasNanopubType} declarations and, for
28
 * single-predicate-assertion nanopubs, the predicate itself. That means the
29
 * four predefined types ({@link GEN#SPACE}, {@link GEN#HAS_ROLE},
30
 * {@link GEN#SPACE_MEMBER_ROLE}, {@link GEN#ROLE_INSTANTIATION}) and all 14
31
 * {@link BackcompatRolePredicates backwards-compat predicates} can be detected
32
 * with a single type-set lookup.
33
 *
34
 * <p>Output: a list of RDF4J {@link Statement}s, all in the
35
 * {@link SpacesVocab#SPACES_GRAPH} named graph, that the caller writes into the
36
 * {@code spaces} repo. Deterministic and idempotent — the same nanopub always
37
 * produces the same statement set.
38
 */
39
public final class SpacesExtractor {
40

41
    private static final Logger logger = LoggerFactory.getLogger(SpacesExtractor.class);
9✔
42

43
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
44

45
    private static final IRI GRAPH = SpacesVocab.SPACES_GRAPH;
6✔
46

47
    /**
48
     * The set of nanopub-level type/predicate IRIs that make a nanopub "space-relevant"
49
     * — i.e., that dispatch to one of the per-shape extractors in {@link #extract}.
50
     * Shared with {@link NanopubLoader} so the spaces-load gate and the invalidation
51
     * propagation paths agree on a single definition of "space-relevant" without
52
     * needing to re-run the extractor.
53
     *
54
     * <p>Membership is checked against {@link NanopubUtils#getTypes(Nanopub)}, which
55
     * includes both {@code rdf:type} / {@code npx:hasNanopubType} declarations and,
56
     * for single-predicate-assertion nanopubs, the predicate itself — so predicate
57
     * markers like {@link GEN#HAS_ROLE} and {@link GEN#IS_MAINTAINED_BY} can appear
58
     * as types here.
59
     */
60
    public static final Set<IRI> TRIGGER_TYPES;
61

62
    static {
63
        Set<IRI> s = new LinkedHashSet<>();
12✔
64
        s.add(GEN.SPACE);
12✔
65
        s.add(GEN.HAS_ROLE);
12✔
66
        s.add(GEN.SPACE_MEMBER_ROLE);
12✔
67
        s.add(GEN.ROLE_INSTANTIATION);
12✔
68
        s.add(GEN.IS_SUB_SPACE_OF);
12✔
69
        s.add(GEN.MAINTAINED_RESOURCE);
12✔
70
        s.add(GEN.IS_MAINTAINED_BY);
12✔
71
        s.add(GEN.PRESET);
12✔
72
        s.add(GEN.PRESET_ASSIGNMENT);
12✔
73
        s.add(GEN.REVOKED_ROLE_INSTANTIATION);
12✔
74
        s.add(GEN.DETACHED_ROLE);
12✔
75
        s.addAll(BackcompatRolePredicates.ALL);
12✔
76
        TRIGGER_TYPES = Collections.unmodifiableSet(s);
9✔
77
    }
3✔
78

79
    private SpacesExtractor() {
80
    }
81

82
    /**
83
     * Returns {@code true} iff at least one of {@code types} is in
84
     * {@link #TRIGGER_TYPES} — i.e., the nanopub carries a type that dispatches
85
     * to one of the extractor branches. Callers should typically pass
86
     * {@code NanopubUtils.getTypes(np)} so that single-predicate-assertion
87
     * auto-typing is included.
88
     */
89
    public static boolean isSpaceRelevant(Set<IRI> types) {
90
        for (IRI t : types) {
30✔
91
            if (TRIGGER_TYPES.contains(t)) {
12✔
92
                return true;
6✔
93
            }
94
        }
3✔
95
        return false;
6✔
96
    }
97

98
    /**
99
     * Bundles the information a single extraction needs beyond the nanopub itself.
100
     *
101
     * @param artifactCode trusty-URI artifact code of {@code np} (used for minting
102
     *                     {@code npari:}/{@code npara:}/{@code npard:}/{@code npadef:}
103
     *                     subject IRIs).
104
     * @param signedBy     signer agent IRI from pubinfo, or {@code null} if absent.
105
     * @param pubkeyHash   hash of the signing public key, or {@code null} if absent.
106
     * @param createdAt    creation timestamp, or {@code null} if the nanopub lacks one.
107
     */
108
    public record Context(String artifactCode, IRI signedBy, String pubkeyHash, Date createdAt) {
45✔
109
    }
110

111
    /**
112
     * Runs the extractor on a loaded nanopub. Returns an empty list if the nanopub is
113
     * not space-relevant.
114
     *
115
     * @param np  the nanopub to inspect
116
     * @param ctx the extraction context
117
     * @return statements to write into {@code npa:spacesGraph}
118
     */
119
    public static List<Statement> extract(Nanopub np, Context ctx) {
120
        Set<IRI> types = NanopubUtils.getTypes(np);
9✔
121
        if (!isSpaceRelevant(types)) {
9✔
122
            return Collections.emptyList();
6✔
123
        }
124

125
        List<Statement> out = new ArrayList<>();
12✔
126

127
        boolean isSpace = types.contains(GEN.SPACE);
12✔
128
        boolean isHasRole = types.contains(GEN.HAS_ROLE);
12✔
129
        boolean isSpaceMemberRole = types.contains(GEN.SPACE_MEMBER_ROLE);
12✔
130
        boolean isRoleInstantiation = types.contains(GEN.ROLE_INSTANTIATION)
18✔
131
                                      || anyMatch(types, BackcompatRolePredicates.ALL);
18✔
132
        boolean isSubSpaceOf = types.contains(GEN.IS_SUB_SPACE_OF);
12✔
133
        // Maintained-resource nanopubs use either the resource-class marker
134
        // (gen:MaintainedResource — what Nanodash currently writes) or the
135
        // predicate marker (gen:isMaintainedBy — single-predicate-assertion
136
        // auto-typing or explicit npx:hasNanopubType). Both shapes carry the
137
        // same <r> gen:isMaintainedBy <s> triple in the assertion.
138
        boolean isMaintainedResource = types.contains(GEN.MAINTAINED_RESOURCE)
18✔
139
                                       || types.contains(GEN.IS_MAINTAINED_BY);
18✔
140
        // Presets (Nanodash issue #302). A preset-defining nanopub is typed gen:Preset;
141
        // an assignment is typed gen:PresetAssignment (plus gen:Activated/Deactivated).
142
        // Both shapes are single-subject assertions, so NanopubUtils.getTypes promotes
143
        // the assertion type even without a pubinfo npx:hasNanopubType marker.
144
        boolean isPreset = types.contains(GEN.PRESET);
12✔
145
        boolean isPresetAssignment = types.contains(GEN.PRESET_ASSIGNMENT);
12✔
146
        // Role revocation (issue #129). RevokedRoleInstantiation is a typed node carrying
147
        // gen:forSpace/forAgent/hasRole; gen:detachedRole is a single-predicate-assertion
148
        // (auto-typed like gen:hasRole). Both emit key-level negatives, never state rows.
149
        boolean isRevokedRoleInstantiation = types.contains(GEN.REVOKED_ROLE_INSTANTIATION);
12✔
150
        boolean isDetachedRole = types.contains(GEN.DETACHED_ROLE);
12✔
151

152
        if (isSpace) {
6✔
153
            extractSpace(np, ctx, out);
12✔
154
        }
155
        if (isHasRole) {
6✔
156
            extractHasRole(np, ctx, out);
12✔
157
        }
158
        if (isSpaceMemberRole) {
6✔
159
            extractSpaceMemberRole(np, ctx, out);
12✔
160
        }
161
        if (isRoleInstantiation) {
6✔
162
            extractRoleInstantiation(np, ctx, types.contains(GEN.ROLE_INSTANTIATION), out);
21✔
163
        }
164
        if (isSubSpaceOf) {
6✔
165
            extractSubSpaceOf(np, ctx, out);
12✔
166
        }
167
        if (isMaintainedResource) {
6✔
168
            extractIsMaintainedBy(np, ctx, out);
12✔
169
        }
170
        if (isPreset) {
6✔
171
            extractPreset(np, ctx, out);
12✔
172
        }
173
        if (isPresetAssignment) {
6✔
174
            extractPresetAssignment(np, ctx, out);
12✔
175
        }
176
        if (isRevokedRoleInstantiation) {
6✔
177
            extractRevokedRoleInstantiation(np, ctx, out);
12✔
178
        }
179
        if (isDetachedRole) {
6✔
180
            extractDetachedRole(np, ctx, out);
12✔
181
        }
182

183
        return out;
6✔
184
    }
185

186
    // ---------------- gen:Space ----------------
187

188
    private static void extractSpace(Nanopub np, Context ctx, List<Statement> out) {
189
        // A single gen:Space nanopub may declare multiple Space IRIs, each via its own
190
        // gen:hasRootDefinition triple. We emit one SpaceRef + SpaceDefinition per
191
        // Space IRI. A nanopub missing any hasRootDefinition is accepted as its own
192
        // root for every Space IRI it declares (transition backcompat).
193
        Set<IRI> handled = new LinkedHashSet<>();
12✔
194
        List<IRI> adminAgents = collectAdminAgents(np);
9✔
195

196
        // Rooted case: gen:hasRootDefinition explicitly declared.
197
        for (Statement st : np.getAssertion()) {
33✔
198
            if (!st.getPredicate().equals(GEN.HAS_ROOT_DEFINITION)) {
15✔
199
                continue;
3✔
200
            }
201
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27!
202
                continue;
203
            }
204
            if (!(st.getObject() instanceof IRI rootUri)) {
27!
205
                continue;
206
            }
207
            String rootNanopubId = TrustyUriUtils.getArtifactCode(rootUri.stringValue());
12✔
208
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
209
                logger.warn("Ignoring space {}: gen:hasRootDefinition target is not a trusty URI: {}",
×
210
                        spaceIri, rootUri);
211
                continue;
×
212
            }
213
            if (!handled.add(spaceIri)) {
12!
214
                continue;
×
215
            }
216
            emitSpaceEntry(np, ctx, spaceIri, rootUri, rootNanopubId, adminAgents, out);
24✔
217
        }
3✔
218

219
        // Rootless transition case: any Space IRI in the assertion that didn't get a
220
        // hasRootDefinition triple is treated as if it were its own root. Detect by
221
        // looking for triples that reference a Space IRI we haven't handled yet —
222
        // typically via gen:hasAdmin subjects or the rdf:type gen:Space triple on a
223
        // blank-node assertion subject. The common template publishes the Space IRI
224
        // as the subject of at least one triple in the assertion, so we scan for that.
225
        for (Statement st : np.getAssertion()) {
33✔
226
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27!
227
                continue;
228
            }
229
            if (handled.contains(spaceIri)) {
12✔
230
                continue;
3✔
231
            }
232
            // Skip IRIs that clearly aren't Space IRIs (role IRIs embedded in this nanopub).
233
            if (spaceIri.stringValue().startsWith(np.getUri().stringValue())) {
21!
234
                continue;
×
235
            }
236
            // Require at least one structural signal that this is a Space IRI:
237
            // an rdf:type gen:Space, or a gen:hasAdmin triple with this as subject.
238
            if (!looksLikeSpaceIri(np, spaceIri)) {
12✔
239
                continue;
3✔
240
            }
241
            handled.add(spaceIri);
12✔
242
            String rootNanopubId = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
243
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
244
                continue;
×
245
            }
246
            emitSpaceEntry(np, ctx, spaceIri, np.getUri(), rootNanopubId, adminAgents, out);
27✔
247
        }
3✔
248
    }
3✔
249

250
    private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI rootUri,
251
                                       String rootNanopubId, List<IRI> adminAgents,
252
                                       List<Statement> out) {
253
        String spaceRef = rootNanopubId + "_" + Utils.createHash(spaceIri);
15✔
254
        IRI refIri = SpacesVocab.forSpaceRef(spaceRef);
9✔
255
        IRI defIri = SpacesVocab.forSpaceDefinition(ctx.artifactCode());
12✔
256

257
        // Aggregate entry: contributor-independent, reinforced on every contribution.
258
        out.add(vf.createStatement(refIri, RDF.TYPE, SpacesVocab.SPACE_REF, GRAPH));
27✔
259
        out.add(vf.createStatement(refIri, SpacesVocab.SPACE_IRI, spaceIri, GRAPH));
27✔
260
        out.add(vf.createStatement(refIri, SpacesVocab.ROOT_NANOPUB, rootUri, GRAPH));
27✔
261

262
        // Identity-derived path-prefix enumeration powering the URL-prefix sub-space
263
        // fallback in the materializer. Same triples on every contributor (RDF set
264
        // semantics dedups them).
265
        for (IRI prefix : enumerateIdPrefixes(spaceIri)) {
33✔
266
            out.add(vf.createStatement(refIri, SpacesVocab.HAS_ID_PREFIX, prefix, GRAPH));
27✔
267
        }
3✔
268

269
        // Embedded gen:isSubSpaceOf triples in this gen:Space nanopub: emit one
270
        // SubSpaceDeclaration per (spaceIri, parentIri) pair. Same shape as the
271
        // standalone path; downstream rules don't distinguish them.
272
        emitSubSpaceDeclarations(np, ctx, spaceIri, out);
15✔
273

274
        // Embedded gen:isMaintainedBy triples in this gen:Space nanopub: emit one
275
        // MaintainedResourceDeclaration per (resourceIri, spaceIri) pair where the
276
        // object equals the Space being defined. Same shape as the standalone path.
277
        emitMaintainedResourceDeclarations(np, ctx, spaceIri, out);
15✔
278

279
        // Embedded owl:sameAs triples: <spaceIri> owl:sameAs <aliasIri> declares that
280
        // <aliasIri> is an alias of the Space being defined. Emit one
281
        // SpaceAliasDeclaration per (spaceIri, aliasIri) pair so the materializer can
282
        // let this space's admin authority cover roles/members attached to the alias
283
        // (issue #113). Carries provenance — the materializer gates the edge on the
284
        // declaration's publisher being an admin of the canonical space.
285
        emitSpaceAliasDeclarations(np, ctx, spaceIri, out);
15✔
286

287
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
288
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27✔
289
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27✔
290
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
291
        addProvenance(defIri, ctx, out);
12✔
292

293
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
294
        boolean isOwnRoot = rootUri.equals(np.getUri());
15✔
295
        if (isOwnRoot) {
6✔
296
            for (IRI adminAgent : adminAgents) {
30✔
297
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27✔
298
            }
3✔
299
        }
300

301
        // gen:RoleInstantiation entry for the admins asserted in this gen:Space nanopub,
302
        // so admins show up in the same SPARQL pattern as ordinary admin instantiations.
303
        if (!adminAgents.isEmpty()) {
9!
304
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
305
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
306
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
307
            out.add(vf.createStatement(riIri, SpacesVocab.INVERSE_PROPERTY, GEN.HAS_ADMIN, GRAPH));
27✔
308
            for (IRI adminAgent : adminAgents) {
30✔
309
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, adminAgent, GRAPH));
27✔
310
            }
3✔
311
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
312
            addProvenance(riIri, ctx, out);
12✔
313
        }
314

315
        // Inline non-hasAdmin role triples: a gen:Space nanopub may also assert
316
        // <space> <pred> <agent> (INVERSE) or <agent> <pred> <space> (REGULAR)
317
        // for any of the back-compat role predicates (has-event-facilitator,
318
        // participatedAsParticipantIn, …). Without an extraction path here those
319
        // are silently dropped because gen:Space nanopubs are not auto-typed
320
        // with back-compat predicates (only single-triple-assertion nanopubs are).
321
        // Emit one RoleInstantiation per distinct predicate found, grouping
322
        // multi-agent like the admin case. The subject is disambiguated by a
323
        // hash of the predicate IRI so multiple predicates in one nanopub don't
324
        // collide on the same npari:<artifactCode> subject as the admin RI.
325
        emitInlineRoleInstantiations(np, ctx, spaceIri, out);
15✔
326
    }
3✔
327

328
    /**
329
     * Scans the assertion of a {@code gen:Space} nanopub for inline role triples
330
     * (excluding {@code gen:hasAdmin}, which is handled separately as the trust
331
     * seed), grouping by predicate and emitting one {@link GEN#ROLE_INSTANTIATION}
332
     * per (predicate, direction) pair with multi-valued {@code npa:forAgent}.
333
     */
334
    private static void emitInlineRoleInstantiations(Nanopub np, Context ctx, IRI spaceIri,
335
                                                     List<Statement> out) {
336
        Map<IRI, BackcompatRolePredicates.Direction> pins = directionPins(np);
9✔
337
        Map<IRI, BackcompatRolePredicates.Direction> directionByPred = new LinkedHashMap<>();
12✔
338
        Map<IRI, Set<IRI>> agentsByPred = new LinkedHashMap<>();
12✔
339
        for (Statement st : np.getAssertion()) {
33✔
340
            IRI predicate = st.getPredicate();
9✔
341
            if (GEN.HAS_ADMIN.equals(predicate)) {
12✔
342
                continue; // already emitted above
3✔
343
            }
344
            // Direction from the backcompat list or, for a user-defined predicate, a pubinfo
345
            // pin (issue #136 Part B); unresolved predicates are dropped.
346
            BackcompatRolePredicates.Direction direction = resolveDirection(predicate, pins);
12✔
347
            if (direction == null) {
6✔
348
                continue;
3✔
349
            }
350
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
351
                continue;
352
            }
353
            if (!(st.getObject() instanceof IRI objIri)) {
27!
354
                continue;
355
            }
356
            IRI agent;
357
            if (direction == BackcompatRolePredicates.Direction.INVERSE) {
9✔
358
                if (!spaceIri.equals(subjIri)) {
12!
359
                    continue;
×
360
                }
361
                agent = objIri;
9✔
362
            } else {
363
                if (!spaceIri.equals(objIri)) {
12!
364
                    continue;
×
365
                }
366
                agent = subjIri;
6✔
367
            }
368
            directionByPred.put(predicate, direction);
15✔
369
            agentsByPred.computeIfAbsent(predicate, k -> new LinkedHashSet<>()).add(agent);
36✔
370
        }
3✔
371
        for (Map.Entry<IRI, Set<IRI>> entry : agentsByPred.entrySet()) {
33✔
372
            IRI predicate = entry.getKey();
12✔
373
            BackcompatRolePredicates.Direction direction = directionByPred.get(predicate);
15✔
374
            String predHash = Utils.createHash(predicate.stringValue());
12✔
375
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode(), predHash);
15✔
376
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
377
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
378
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
379
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
380
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
381
            out.add(vf.createStatement(riIri, directionPredicate, predicate, GRAPH));
27✔
382
            for (IRI agent : entry.getValue()) {
36✔
383
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, agent, GRAPH));
27✔
384
            }
3✔
385
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
386
            addProvenance(riIri, ctx, out);
12✔
387
        }
3✔
388
    }
3✔
389

390
    /**
391
     * Heuristic: does {@code candidate} look like a Space IRI in {@code np}'s assertion,
392
     * independent of any {@code gen:hasRootDefinition} triple? We accept it if the
393
     * assertion contains {@code candidate rdf:type gen:Space} or
394
     * {@code candidate gen:hasAdmin ?x}.
395
     */
396
    private static boolean looksLikeSpaceIri(Nanopub np, IRI candidate) {
397
        for (Statement st : np.getAssertion()) {
33✔
398
            if (!candidate.equals(st.getSubject())) {
15✔
399
                continue;
3✔
400
            }
401
            if (st.getPredicate().equals(RDF.TYPE) && GEN.SPACE.equals(st.getObject())) {
30!
402
                return true;
6✔
403
            }
404
            if (st.getPredicate().equals(GEN.HAS_ADMIN)) {
15!
405
                return true;
×
406
            }
407
        }
3✔
408
        return false;
6✔
409
    }
410

411
    private static List<IRI> collectAdminAgents(Nanopub np) {
412
        Set<IRI> agents = new LinkedHashSet<>();
12✔
413
        for (Statement st : np.getAssertion()) {
33✔
414
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) {
15✔
415
                continue;
3✔
416
            }
417
            if (!(st.getObject() instanceof IRI agent)) {
27!
418
                continue;
419
            }
420
            agents.add(agent);
12✔
421
        }
3✔
422
        return new ArrayList<>(agents);
15✔
423
    }
424

425
    // ---------------- gen:hasRole (role attachment) ----------------
426

427
    private static void extractHasRole(Nanopub np, Context ctx, List<Statement> out) {
428
        // A gen:hasRole nanopub asserts <space> gen:hasRole <role>.
429
        for (Statement st : np.getAssertion()) {
33!
430
            if (!st.getPredicate().equals(GEN.HAS_ROLE)) {
15!
431
                continue;
×
432
            }
433
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27!
434
                continue;
435
            }
436
            if (!(st.getObject() instanceof IRI roleIri)) {
27!
437
                continue;
438
            }
439
            IRI subject = SpacesVocab.forRoleAssignment(ctx.artifactCode());
12✔
440
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_ASSIGNMENT, GRAPH));
27✔
441
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
442
            out.add(vf.createStatement(subject, GEN.HAS_ROLE, roleIri, GRAPH));
27✔
443
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
444
            addProvenance(subject, ctx, out);
12✔
445
            // One attachment per nanopub — the subject IRI is derived from the nanopub
446
            // artifact code so multiple hasRole triples in the same nanopub would collide.
447
            // If that case shows up in practice, we'll refine the subject-minting scheme.
448
            return;
3✔
449
        }
450
    }
×
451

452
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
453

454
    private static void extractSpaceMemberRole(Nanopub np, Context ctx, List<Statement> out) {
455
        // The role IRI is embedded in this nanopub, so look for an assertion statement
456
        // of the shape <roleIri> rdf:type gen:SpaceMemberRole where <roleIri> starts
457
        // with the nanopub IRI (valid embedded mint).
458
        IRI roleIri = null;
6✔
459
        for (Statement st : np.getAssertion()) {
33✔
460
            if (!st.getPredicate().equals(RDF.TYPE)) {
15!
461
                continue;
×
462
            }
463
            if (!GEN.SPACE_MEMBER_ROLE.equals(st.getObject())) {
15✔
464
                continue;
3✔
465
            }
466
            if (!(st.getSubject() instanceof IRI candidate)) {
27!
467
                continue;
468
            }
469
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) {
21✔
470
                continue;
3✔
471
            }
472
            roleIri = candidate;
6✔
473
            break;
3✔
474
        }
475
        if (roleIri == null) {
6✔
476
            return;
3✔
477
        }
478

479
        IRI roleType = findRoleTier(np, roleIri);
12✔
480
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15✔
481
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15✔
482

483
        IRI subject = SpacesVocab.forRoleDeclaration(ctx.artifactCode());
12✔
484
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DECLARATION, GRAPH));
27✔
485
        out.add(vf.createStatement(subject, SpacesVocab.ROLE, roleIri, GRAPH));
27✔
486
        out.add(vf.createStatement(subject, SpacesVocab.HAS_ROLE_TYPE, roleType, GRAPH));
27✔
487
        for (IRI reg : regulars) {
30✔
488
            out.add(vf.createStatement(subject, GEN.HAS_REGULAR_PROPERTY, reg, GRAPH));
27✔
489
        }
3✔
490
        for (IRI inv : inverses) {
30✔
491
            out.add(vf.createStatement(subject, GEN.HAS_INVERSE_PROPERTY, inv, GRAPH));
27✔
492
        }
3✔
493
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
494
        if (ctx.createdAt() != null) {
9!
495
            out.add(vf.createStatement(subject, DCTERMS.CREATED, vf.createLiteral(ctx.createdAt()), GRAPH));
36✔
496
        }
497
    }
3✔
498

499
    /**
500
     * Looks for a tier rdf:type ({@code gen:MaintainerRole} / {@code gen:MemberRole} /
501
     * {@code gen:ObserverRole}) on the role IRI in the assertion; defaults to
502
     * {@code gen:ObserverRole} if none is declared.
503
     */
504
    private static IRI findRoleTier(Nanopub np, IRI roleIri) {
505
        for (Statement st : np.getAssertion()) {
33✔
506
            if (!roleIri.equals(st.getSubject())) {
15!
507
                continue;
×
508
            }
509
            if (!st.getPredicate().equals(RDF.TYPE)) {
15!
510
                continue;
×
511
            }
512
            if (!(st.getObject() instanceof IRI type)) {
27!
513
                continue;
514
            }
515
            if (GEN.MAINTAINER_ROLE.equals(type) || GEN.MEMBER_ROLE.equals(type)
30!
516
                || GEN.OBSERVER_ROLE.equals(type)) {
6!
517
                return type;
6✔
518
            }
519
        }
3✔
520
        return GEN.OBSERVER_ROLE;
6✔
521
    }
522

523
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
524
        List<IRI> out = new ArrayList<>();
12✔
525
        for (Statement st : np.getAssertion()) {
33✔
526
            if (!roleIri.equals(st.getSubject())) {
15!
527
                continue;
×
528
            }
529
            if (!predicate.equals(st.getPredicate())) {
15✔
530
                continue;
3✔
531
            }
532
            if (!(st.getObject() instanceof IRI obj)) {
27!
533
                continue;
534
            }
535
            out.add(obj);
12✔
536
        }
3✔
537
        return out;
6✔
538
    }
539

540
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
541

542
    private static void extractRoleInstantiation(Nanopub np, Context ctx,
543
                                                 boolean explicitRoleInstantiation, List<Statement> out) {
544
        // Find the assignment triple. Directionality (matches the publisher convention
545
        // used by gen:hasRegularProperty / gen:hasInverseProperty in role-definition
546
        // nanopubs):
547
        //   REGULAR: <agent> <predicate> <space>  → npa:regularProperty.
548
        //   INVERSE: <space> <predicate> <agent>  → npa:inverseProperty.
549
        // gen:hasAdmin is hardcoded INVERSE (space-centric: <space> hasAdmin <agent>).
550
        // The 14 backwards-compat predicates are classified in
551
        // {@link BackcompatRolePredicates#DIRECTIONS}.
552
        for (Statement st : np.getAssertion()) {
33✔
553
            IRI predicate = st.getPredicate();
9✔
554
            BackcompatRolePredicates.Direction direction = directionFor(predicate);
9✔
555
            if (direction == null) {
6✔
556
                continue;
3✔
557
            }
558
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
559
                continue;
560
            }
561
            if (!(st.getObject() instanceof IRI objIri)) {
27!
562
                continue;
563
            }
564

565
            IRI spaceSide;
566
            IRI agentSide;
567
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
568
                agentSide = subjIri;
6✔
569
                spaceSide = objIri;
9✔
570
            } else {
571
                spaceSide = subjIri;
6✔
572
                agentSide = objIri;
6✔
573
            }
574

575
            // Deduplicate against the (possibly already emitted) admin instantiation
576
            // from the gen:Space path — a single nanopub can be typed gen:Space AND
577
            // have a gen:hasAdmin triple that the backcompat list also catches. The
578
            // subject IRI is the same (derived from artifact code) and the payload
579
            // would conflict if re-emitted. Skip if we already have a RoleInstantiation
580
            // entry on this subject.
581
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
582
            Statement typeSt = vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH);
21✔
583
            if (out.contains(typeSt)) {
12!
584
                return;
×
585
            }
586

587
            out.add(typeSt);
12✔
588
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
589
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
590
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
591
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
592
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
593
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27✔
594
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
595
            addProvenance(subject, ctx, out);
12✔
596
            return;
3✔
597
        }
598

599
        // No predicate with a known direction. For a nanopub explicitly typed
600
        // gen:RoleInstantiation, the assertion binds an agent to a space via a user-defined
601
        // role predicate whose direction is pinned in pubinfo (<pred> a
602
        // gen:InverseRoleProperty / gen:RegularRoleProperty; issue #136 Part B). We resolve
603
        // the direction from that pin and emit the normalized shape directly, so
604
        // get-space-members (which reads the raw spacesGraph) surfaces the member without a
605
        // materializer round-trip. A predicate with neither a known direction nor a pin is
606
        // DROPPED — no neutral binding is emitted (strict). Gated on the explicit type so we
607
        // don't mint entries for incidental IRI-valued triples in backcompat-only nanopubs.
608
        //
609
        // One nanopub may bind a predicate across several spaces, each its own assignment:
610
        // group by (space, predicate) with multi-valued forAgent and mint one subject per
611
        // (predicate, space). (The classified branch above keeps its legacy one-per-nanopub
612
        // shape for the drainable backcompat predicates.)
613
        if (!explicitRoleInstantiation) {
6!
614
            return;
×
615
        }
616
        Map<IRI, BackcompatRolePredicates.Direction> pins = directionPins(np);
9✔
617
        if (pins.isEmpty()) {
9✔
618
            return;
3✔
619
        }
620
        // Keyed by [predicate, spaceSide] (List equality is value-based).
621
        Map<List<IRI>, BackcompatRolePredicates.Direction> directionByKey = new LinkedHashMap<>();
12✔
622
        Map<List<IRI>, Set<IRI>> agentsByKey = new LinkedHashMap<>();
12✔
623
        for (Statement st : np.getAssertion()) {
33✔
624
            IRI predicate = st.getPredicate();
9✔
625
            if (predicate.equals(RDF.TYPE) || directionFor(predicate) != null) {
21!
626
                continue;
×
627
            }
628
            BackcompatRolePredicates.Direction direction = pins.get(predicate);
15✔
629
            if (direction == null) {
6!
630
                continue; // unpinned custom predicate → dropped
×
631
            }
632
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
633
                continue;
634
            }
635
            if (!(st.getObject() instanceof IRI objIri)) {
27!
636
                continue;
637
            }
638
            IRI spaceSide;
639
            IRI agentSide;
640
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
641
                agentSide = subjIri;
6✔
642
                spaceSide = objIri;
9✔
643
            } else {
644
                spaceSide = subjIri;
6✔
645
                agentSide = objIri;
6✔
646
            }
647
            List<IRI> key = List.of(predicate, spaceSide);
12✔
648
            directionByKey.put(key, direction);
15✔
649
            agentsByKey.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(agentSide);
36✔
650
        }
3✔
651
        for (Map.Entry<List<IRI>, Set<IRI>> entry : agentsByKey.entrySet()) {
33✔
652
            IRI predicate = entry.getKey().get(0);
21✔
653
            IRI spaceSide = entry.getKey().get(1);
21✔
654
            BackcompatRolePredicates.Direction direction = directionByKey.get(entry.getKey());
18✔
655
            // Discriminate by (predicate, space) so several predicates AND several spaces
656
            // in one nanopub each get a distinct entry.
657
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode(),
15✔
658
                    Utils.createHash(predicate.stringValue() + "\n" + spaceSide.stringValue()));
15✔
659
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
660
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
661
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
662
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
663
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
664
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
665
            for (IRI agent : entry.getValue()) {
36✔
666
                out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agent, GRAPH));
27✔
667
            }
3✔
668
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
669
            addProvenance(subject, ctx, out);
12✔
670
        }
3✔
671
    }
3✔
672

673
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
674
        if (GEN.HAS_ADMIN.equals(predicate)) {
12!
675
            return BackcompatRolePredicates.Direction.INVERSE;
×
676
        }
677
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
678
    }
679

680
    /**
681
     * Direction pins declared in the nanopub's pubinfo: {@code <pred> a
682
     * gen:InverseRoleProperty} or {@code <pred> a gen:RegularRoleProperty}. Lets the
683
     * extractor resolve a user-defined role predicate's direction from the assigning
684
     * nanopub alone (issue #136 Part B; see {@code doc/design-role-direction-pinning.md}).
685
     */
686
    private static Map<IRI, BackcompatRolePredicates.Direction> directionPins(Nanopub np) {
687
        Map<IRI, BackcompatRolePredicates.Direction> pins = new LinkedHashMap<>();
12✔
688
        for (Statement st : np.getPubinfo()) {
33✔
689
            if (!RDF.TYPE.equals(st.getPredicate()) || !(st.getSubject() instanceof IRI predicate)) {
42!
690
                continue;
691
            }
692
            if (GEN.INVERSE_ROLE_PROPERTY.equals(st.getObject())) {
15✔
693
                pins.put(predicate, BackcompatRolePredicates.Direction.INVERSE);
18✔
694
            } else if (GEN.REGULAR_ROLE_PROPERTY.equals(st.getObject())) {
15!
695
                pins.put(predicate, BackcompatRolePredicates.Direction.REGULAR);
15✔
696
            }
697
        }
3✔
698
        return pins;
6✔
699
    }
700

701
    /**
702
     * Resolves a role predicate's direction: a statically-known predicate
703
     * ({@link #directionFor}) wins; otherwise a pubinfo {@code pins} entry; otherwise
704
     * {@code null} (the predicate is dropped).
705
     */
706
    private static BackcompatRolePredicates.Direction resolveDirection(IRI predicate,
707
            Map<IRI, BackcompatRolePredicates.Direction> pins) {
708
        BackcompatRolePredicates.Direction known = directionFor(predicate);
9✔
709
        return (known != null) ? known : pins.get(predicate);
27✔
710
    }
711

712
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
713

714
    /**
715
     * Standalone {@code gen:isSubSpaceOf} nanopub: every
716
     * {@code <childIri> gen:isSubSpaceOf <parentIri>} triple in the assertion emits one
717
     * {@code npa:SubSpaceDeclaration}. Multi-triple assertions are allowed; one entry
718
     * per pair. Self-loops ({@code <X> gen:isSubSpaceOf <X>}) are rejected.
719
     */
720
    private static void extractSubSpaceOf(Nanopub np, Context ctx, List<Statement> out) {
721
        for (Statement st : np.getAssertion()) {
33✔
722
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) {
15!
723
                continue;
×
724
            }
725
            if (!(st.getSubject() instanceof IRI childIri)) {
27!
726
                continue;
727
            }
728
            if (!(st.getObject() instanceof IRI parentIri)) {
27!
729
                continue;
730
            }
731
            emitSubSpaceDeclaration(np, ctx, childIri, parentIri, out);
18✔
732
        }
3✔
733
    }
3✔
734

735
    /**
736
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
737
     * {@code <spaceIri> gen:isSubSpaceOf <parentIri>} triples (subject must equal the
738
     * Space IRI we're emitting an entry for, so the subspace declaration is bound to
739
     * this particular Space). Self-loops are rejected.
740
     */
741
    private static void emitSubSpaceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
742
                                                 List<Statement> out) {
743
        for (Statement st : np.getAssertion()) {
33✔
744
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) {
15✔
745
                continue;
3✔
746
            }
747
            if (!spaceIri.equals(st.getSubject())) {
15✔
748
                continue;
3✔
749
            }
750
            if (!(st.getObject() instanceof IRI parentIri)) {
27!
751
                continue;
752
            }
753
            emitSubSpaceDeclaration(np, ctx, spaceIri, parentIri, out);
18✔
754
        }
3✔
755
    }
3✔
756

757
    /**
758
     * Emits one {@code npa:SubSpaceDeclaration} entry, keyed by
759
     * {@code (artifactCode, parentHash)} so a single nanopub can declare multiple
760
     * parents without subject collision. Self-loops are silently dropped.
761
     */
762
    private static void emitSubSpaceDeclaration(Nanopub np, Context ctx, IRI childIri,
763
                                                IRI parentIri, List<Statement> out) {
764
        if (childIri.equals(parentIri)) {
12✔
765
            logger.debug("Ignoring self-loop sub-space declaration on {} in {}", childIri, np.getUri());
18✔
766
            return;
3✔
767
        }
768
        String parentHash = Utils.createHash(parentIri);
9✔
769
        IRI subject = SpacesVocab.forSubSpaceDeclaration(ctx.artifactCode(), parentHash);
15✔
770

771
        // Idempotence: the embedded and standalone paths can both fire on the same
772
        // (np, child, parent) combination if a gen:Space nanopub somehow ends up typed
773
        // gen:isSubSpaceOf as well. Skip if we've already emitted the type triple for
774
        // this subject.
775
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SUB_SPACE_DECLARATION, GRAPH);
21✔
776
        if (out.contains(typeSt)) {
12!
777
            return;
×
778
        }
779

780
        out.add(typeSt);
12✔
781
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27✔
782
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27✔
783
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
784
        addProvenance(subject, ctx, out);
12✔
785
    }
3✔
786

787
    // ---------------- gen:isMaintainedBy ----------------
788

789
    /**
790
     * Standalone {@code gen:isMaintainedBy} nanopub: every
791
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triple in the assertion emits
792
     * one {@code npa:MaintainedResourceDeclaration}. Multi-triple assertions are
793
     * allowed; one entry per pair. Self-loops ({@code <X> gen:isMaintainedBy <X>}) are
794
     * rejected.
795
     */
796
    private static void extractIsMaintainedBy(Nanopub np, Context ctx, List<Statement> out) {
797
        for (Statement st : np.getAssertion()) {
33✔
798
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) {
15✔
799
                continue;
3✔
800
            }
801
            if (!(st.getSubject() instanceof IRI resourceIri)) {
27!
802
                continue;
803
            }
804
            if (!(st.getObject() instanceof IRI spaceIri)) {
27!
805
                continue;
806
            }
807
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
808
        }
3✔
809
    }
3✔
810

811
    /**
812
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
813
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triples (object must equal
814
     * the Space IRI we're emitting an entry for, so the maintained-resource
815
     * declaration is bound to this particular Space). Self-loops are rejected.
816
     */
817
    private static void emitMaintainedResourceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
818
                                                           List<Statement> out) {
819
        for (Statement st : np.getAssertion()) {
33✔
820
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) {
15✔
821
                continue;
3✔
822
            }
823
            if (!spaceIri.equals(st.getObject())) {
15✔
824
                continue;
3✔
825
            }
826
            if (!(st.getSubject() instanceof IRI resourceIri)) {
27!
827
                continue;
828
            }
829
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
830
        }
3✔
831
    }
3✔
832

833
    /**
834
     * Emits one {@code npa:MaintainedResourceDeclaration} entry, keyed by
835
     * {@code (artifactCode, resourceHash)} so a single nanopub can declare multiple
836
     * maintained resources without subject collision. Self-loops are silently dropped.
837
     */
838
    private static void emitMaintainedResourceDeclaration(Nanopub np, Context ctx, IRI resourceIri,
839
                                                          IRI spaceIri, List<Statement> out) {
840
        if (resourceIri.equals(spaceIri)) {
12✔
841
            logger.debug("Ignoring self-loop maintained-resource declaration on {} in {}",
15✔
842
                    resourceIri, np.getUri());
3✔
843
            return;
3✔
844
        }
845
        String resourceHash = Utils.createHash(resourceIri);
9✔
846
        IRI subject = SpacesVocab.forMaintainedResourceDeclaration(ctx.artifactCode(), resourceHash);
15✔
847

848
        // Idempotence: the embedded (gen:Space) and standalone (gen:isMaintainedBy)
849
        // paths can both fire on the same (np, resource, space) combination if a
850
        // gen:Space nanopub somehow ends up typed gen:isMaintainedBy as well. Skip if
851
        // we've already emitted the type triple for this subject.
852
        Statement typeSt = vf.createStatement(subject, RDF.TYPE,
21✔
853
                SpacesVocab.MAINTAINED_RESOURCE_DECLARATION, GRAPH);
854
        if (out.contains(typeSt)) {
12!
855
            return;
×
856
        }
857

858
        out.add(typeSt);
12✔
859
        out.add(vf.createStatement(subject, SpacesVocab.RESOURCE_IRI, resourceIri, GRAPH));
27✔
860
        out.add(vf.createStatement(subject, SpacesVocab.MAINTAINER_SPACE, spaceIri, GRAPH));
27✔
861
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
862
        addProvenance(subject, ctx, out);
12✔
863
    }
3✔
864

865
    // ---------------- gen:Preset (preset declaration) ----------------
866

867
    /**
868
     * A {@code gen:Preset} nanopub bundles default views and roles. We extract only the
869
     * role half (views stay read-time in Nanodash; see
870
     * {@code doc/design-preset-role-materialization.md}): one
871
     * {@code npa:PresetDeclaration} carrying every {@code gen:hasRole} as
872
     * {@code npa:presetRole}, the {@code gen:appliesToInstancesOf} target(s), and the
873
     * preset's identity as {@code npa:ofPreset}.
874
     *
875
     * <p>Join robustness: the assignment's {@code gen:isAssignmentOfPreset} may name the
876
     * versioned preset node or the version-independent {@code dct:isVersionOf} kind
877
     * (Nanodash treats the kind as the canonical reference). We emit {@code npa:ofPreset}
878
     * for <em>both</em> so an assignment naming either joins to this declaration.
879
     */
880
    private static void extractPreset(Nanopub np, Context ctx, List<Statement> out) {
881
        // The preset IRI is embedded in this nanopub: <preset> rdf:type gen:Preset where
882
        // <preset> starts with the nanopub IRI (valid embedded mint), mirroring the
883
        // gen:SpaceMemberRole role-declaration rule.
884
        IRI presetIri = null;
6✔
885
        for (Statement st : np.getAssertion()) {
33✔
886
            if (!st.getPredicate().equals(RDF.TYPE)) {
15✔
887
                continue;
3✔
888
            }
889
            if (!GEN.PRESET.equals(st.getObject())) {
15!
890
                continue;
×
891
            }
892
            if (!(st.getSubject() instanceof IRI candidate)) {
27!
893
                continue;
894
            }
895
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) {
21✔
896
                continue;
3✔
897
            }
898
            presetIri = candidate;
6✔
899
            break;
3✔
900
        }
901
        if (presetIri == null) {
6✔
902
            return;
3✔
903
        }
904

905
        List<IRI> roles = collectObjects(np, presetIri, GEN.HAS_ROLE);
15✔
906
        List<IRI> appliesTo = collectObjects(np, presetIri, GEN.APPLIES_TO_INSTANCES_OF);
15✔
907
        List<IRI> kinds = collectObjects(np, presetIri, DCTERMS.IS_VERSION_OF);
15✔
908
        // Canonical kind: the dct:isVersionOf target, or the node IRI as fallback when the
909
        // preset declares no kind — same rule as Nanodash ViewDisplay.getViewKindIri().
910
        IRI presetKind = kinds.isEmpty() ? presetIri : kinds.get(0);
30✔
911

912
        IRI subject = SpacesVocab.forPresetDeclaration(ctx.artifactCode());
12✔
913
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.PRESET_DECLARATION, GRAPH));
27✔
914
        // Canonical version-independent grouping key (latest-declaration-per-kind resolution).
915
        out.add(vf.createStatement(subject, SpacesVocab.PRESET_KIND, presetKind, GRAPH));
27✔
916
        // Lookup keys: the preset's own node IRI plus its version-independent kind, so an
917
        // assignment naming either is mapped to this declaration's canonical kind.
918
        out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, presetIri, GRAPH));
27✔
919
        for (IRI kind : kinds) {
30✔
920
            out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, kind, GRAPH));
27✔
921
        }
3✔
922
        for (IRI role : roles) {
30✔
923
            out.add(vf.createStatement(subject, SpacesVocab.PRESET_ROLE, role, GRAPH));
27✔
924
        }
3✔
925
        for (IRI type : appliesTo) {
30✔
926
            out.add(vf.createStatement(subject, SpacesVocab.APPLIES_TO_INSTANCES_OF, type, GRAPH));
27✔
927
        }
3✔
928
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
929
        addProvenance(subject, ctx, out);
12✔
930
    }
3✔
931

932
    // ---------------- gen:PresetAssignment ----------------
933

934
    /**
935
     * A {@code gen:PresetAssignment} nanopub assigns a preset to a resource. Emits one
936
     * {@code npa:PresetAssignment} row recording the {@code (preset, resource)} pair and
937
     * its activation state. Activation is <em>active-by-default</em>: active unless the
938
     * assignment node is explicitly typed {@code gen:DeactivatedPresetAssignment} —
939
     * matching Nanodash's {@code PresetAssignment.isActive()}.
940
     *
941
     * <p>The {@code dct:created} timestamp emitted by {@link #addProvenance} is the
942
     * latest-wins key the validator uses to resolve same-pair assignments; it must be
943
     * present for the materialization to converge.
944
     */
945
    private static void extractPresetAssignment(Nanopub np, Context ctx, List<Statement> out) {
946
        // The assignment node is the subject of the gen:isAssignmentOfPreset triple.
947
        IRI assignmentNode = null;
6✔
948
        IRI presetIri = null;
6✔
949
        for (Statement st : np.getAssertion()) {
33!
950
            if (!st.getPredicate().equals(GEN.IS_ASSIGNMENT_OF_PRESET)) {
15✔
951
                continue;
3✔
952
            }
953
            if (!(st.getSubject() instanceof IRI subj)) {
27!
954
                continue;
955
            }
956
            if (!(st.getObject() instanceof IRI preset)) {
27!
957
                continue;
958
            }
959
            assignmentNode = subj;
6✔
960
            presetIri = preset;
6✔
961
            break;
3✔
962
        }
963
        if (assignmentNode == null) {
6!
964
            return;
×
965
        }
966

967
        IRI resource = null;
6✔
968
        for (Statement st : np.getAssertion()) {
33✔
969
            if (!assignmentNode.equals(st.getSubject())) {
15!
970
                continue;
×
971
            }
972
            if (!st.getPredicate().equals(GEN.IS_ASSIGNMENT_FOR)) {
15✔
973
                continue;
3✔
974
            }
975
            if (st.getObject() instanceof IRI res) {
27!
976
                resource = res;
6✔
977
                break;
3✔
978
            }
979
        }
×
980
        if (resource == null) {
6✔
981
            logger.warn("Ignoring preset assignment in {}: no gen:isAssignmentFor resource", np.getUri());
15✔
982
            return;
3✔
983
        }
984

985
        // Active-by-default: deactivated only if explicitly typed.
986
        boolean deactivated = false;
6✔
987
        for (Statement st : np.getAssertion()) {
33✔
988
            if (assignmentNode.equals(st.getSubject())
18!
989
                && st.getPredicate().equals(RDF.TYPE)
18✔
990
                && GEN.DEACTIVATED_PRESET_ASSIGNMENT.equals(st.getObject())) {
9✔
991
                deactivated = true;
6✔
992
                break;
3✔
993
            }
994
        }
3✔
995

996
        IRI subject = SpacesVocab.forPresetAssignment(ctx.artifactCode());
12✔
997
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.PRESET_ASSIGNMENT, GRAPH));
27✔
998
        out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, presetIri, GRAPH));
27✔
999
        out.add(vf.createStatement(subject, SpacesVocab.FOR_RESOURCE, resource, GRAPH));
27✔
1000
        out.add(vf.createStatement(subject, SpacesVocab.IS_ACTIVATED, vf.createLiteral(!deactivated), GRAPH));
45✔
1001
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
1002
        addProvenance(subject, ctx, out);
12✔
1003
    }
3✔
1004

1005
    // ---------------- gen:RevokedRoleInstantiation (role revocation, issue #129) ----------------
1006

1007
    /**
1008
     * A {@code gen:RevokedRoleInstantiation} nanopub asserts that an agent no longer holds a
1009
     * role in a space (a key-level negative on {@code (space, agent, role)}). The assertion
1010
     * shape is a typed node:
1011
     * <pre>{@code :r a gen:RevokedRoleInstantiation ; gen:forSpace <s> ; gen:forAgent <a> ; gen:hasRole <role> .}</pre>
1012
     * For an <em>admin</em> revocation the role is {@code gen:AdminRole} (the admin tier carries
1013
     * no per-role IRI), so the same shape covers every tier. Emits one {@code npa:RoleRevocation}
1014
     * row per revoked node; the {@code dct:created} from {@link #addProvenance} is the latest-wins
1015
     * key the materializer uses to decide whether this negative shadows the standing assertion.
1016
     * This negative never materializes as a state row — it is consumed as a suppression filter /
1017
     * displacement DELETE in the tier where the key is bound.
1018
     */
1019
    private static void extractRevokedRoleInstantiation(Nanopub np, Context ctx, List<Statement> out) {
1020
        // One pass over the assertion: index IRI-valued (subject -> predicate -> object) and
1021
        // collect the RevokedRoleInstantiation-typed nodes. Avoids the quadratic re-scan a
1022
        // per-node singleIriObject lookup would incur on a multi-revocation nanopub.
1023
        Map<IRI, Map<IRI, IRI>> bySubject = new LinkedHashMap<>();
12✔
1024
        List<IRI> revokedNodes = new ArrayList<>();
12✔
1025
        for (Statement st : np.getAssertion()) {
33✔
1026
            if (!(st.getSubject() instanceof IRI s) || !(st.getObject() instanceof IRI o)) {
54!
1027
                continue;
1028
            }
1029
            bySubject.computeIfAbsent(s, k -> new LinkedHashMap<>()).putIfAbsent(st.getPredicate(), o);
42✔
1030
            if (st.getPredicate().equals(RDF.TYPE) && GEN.REVOKED_ROLE_INSTANTIATION.equals(o)) {
27!
1031
                revokedNodes.add(s);
12✔
1032
            }
1033
        }
3✔
1034
        for (IRI node : revokedNodes) {
30✔
1035
            Map<IRI, IRI> props = bySubject.getOrDefault(node, Map.of());
18✔
1036
            IRI space = props.get(GEN.FOR_SPACE);
15✔
1037
            IRI agent = props.get(GEN.FOR_AGENT);
15✔
1038
            IRI role = props.get(GEN.HAS_ROLE);
15✔
1039
            if (space == null || agent == null || role == null) {
18!
1040
                logger.warn("Ignoring role revocation node {} in {}: missing forSpace/forAgent/hasRole",
15✔
1041
                        node, np.getUri());
3✔
1042
                continue;
3✔
1043
            }
1044
            // Separator that cannot appear in an IRI (newline) so distinct (agent, role) pairs
1045
            // in the same nanopub never collide onto one nparev: subject.
1046
            String discriminator = Utils.createHash(agent.stringValue() + "\n" + role.stringValue());
21✔
1047
            IRI subject = SpacesVocab.forRoleRevocation(ctx.artifactCode(), discriminator);
15✔
1048
            out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_REVOCATION, GRAPH));
27✔
1049
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, space, GRAPH));
27✔
1050
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agent, GRAPH));
27✔
1051
            out.add(vf.createStatement(subject, SpacesVocab.REVOKED_ROLE, role, GRAPH));
27✔
1052
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
1053
            addProvenance(subject, ctx, out);
12✔
1054
        }
3✔
1055
    }
3✔
1056

1057
    // ---------------- gen:detachedRole (role detachment, issue #129) ----------------
1058

1059
    /**
1060
     * A {@code gen:detachedRole} nanopub asserts {@code <space> gen:detachedRole <role>} — the
1061
     * stative antonym of {@code gen:hasRole}, a key-level negative on {@code (space, role)}.
1062
     * Emits one {@code npa:RoleDetachment} row per triple; latest-wins (by {@code dct:created})
1063
     * against direct <em>and</em> preset-derived attachments removes the role's availability in
1064
     * the space, cascading to the instantiations anchored on it. Never materializes as a state row.
1065
     */
1066
    private static void extractDetachedRole(Nanopub np, Context ctx, List<Statement> out) {
1067
        for (Statement st : np.getAssertion()) {
33✔
1068
            if (!st.getPredicate().equals(GEN.DETACHED_ROLE)
15!
1069
                || !(st.getSubject() instanceof IRI spaceIri)
27!
1070
                || !(st.getObject() instanceof IRI roleIri)) {
27!
1071
                continue;
1072
            }
1073
            String roleHash = Utils.createHash(roleIri.stringValue());
12✔
1074
            IRI subject = SpacesVocab.forRoleDetachment(ctx.artifactCode(), roleHash);
15✔
1075
            out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DETACHMENT, GRAPH));
27✔
1076
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
1077
            out.add(vf.createStatement(subject, SpacesVocab.REVOKED_ROLE, roleIri, GRAPH));
27✔
1078
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
1079
            addProvenance(subject, ctx, out);
12✔
1080
        }
3✔
1081
    }
3✔
1082

1083
    /** Collects all IRI objects of {@code subject predicate ?o} triples in the assertion. */
1084
    private static List<IRI> collectObjects(Nanopub np, IRI subject, IRI predicate) {
1085
        List<IRI> out = new ArrayList<>();
12✔
1086
        for (Statement st : np.getAssertion()) {
33✔
1087
            if (!subject.equals(st.getSubject())) {
15!
1088
                continue;
×
1089
            }
1090
            if (!predicate.equals(st.getPredicate())) {
15✔
1091
                continue;
3✔
1092
            }
1093
            if (st.getObject() instanceof IRI obj) {
27!
1094
                out.add(obj);
12✔
1095
            }
1096
        }
3✔
1097
        return out;
6✔
1098
    }
1099

1100
    // ---------------- owl:sameAs (space aliases) ----------------
1101

1102
    /**
1103
     * Scans a {@code gen:Space} nanopub's assertion for
1104
     * {@code <spaceIri> owl:sameAs <aliasIri>} triples (subject must equal the Space IRI
1105
     * being emitted, so the alias declaration is bound to this particular Space) and emits
1106
     * one {@code npa:SpaceAliasDeclaration} per {@code (spaceIri, aliasIri)} pair. The
1107
     * Space IRI is the canonical side; the {@code owl:sameAs} object is the alias.
1108
     * Self-aliases ({@code <X> owl:sameAs <X>}) are rejected.
1109
     */
1110
    private static void emitSpaceAliasDeclarations(Nanopub np, Context ctx, IRI spaceIri,
1111
                                                   List<Statement> out) {
1112
        for (Statement st : np.getAssertion()) {
33✔
1113
            if (!st.getPredicate().equals(OWL.SAMEAS)) {
15✔
1114
                continue;
3✔
1115
            }
1116
            if (!spaceIri.equals(st.getSubject())) {
15✔
1117
                continue;
3✔
1118
            }
1119
            if (!(st.getObject() instanceof IRI aliasIri)) {
27!
1120
                continue;
1121
            }
1122
            emitSpaceAliasDeclaration(np, ctx, spaceIri, aliasIri, out);
18✔
1123
        }
3✔
1124
    }
3✔
1125

1126
    /**
1127
     * Emits one {@code npa:SpaceAliasDeclaration} entry, keyed by
1128
     * {@code (artifactCode, aliasHash)} so a single nanopub can declare multiple aliases
1129
     * without subject collision. Self-aliases are silently dropped.
1130
     */
1131
    private static void emitSpaceAliasDeclaration(Nanopub np, Context ctx, IRI canonicalIri,
1132
                                                  IRI aliasIri, List<Statement> out) {
1133
        if (canonicalIri.equals(aliasIri)) {
12✔
1134
            logger.debug("Ignoring self-alias declaration on {} in {}", canonicalIri, np.getUri());
18✔
1135
            return;
3✔
1136
        }
1137
        String aliasHash = Utils.createHash(aliasIri);
9✔
1138
        IRI subject = SpacesVocab.forSpaceAliasDeclaration(ctx.artifactCode(), aliasHash);
15✔
1139

1140
        // Idempotence: a single (np, canonical, alias) combination should produce one entry
1141
        // even if emitSpaceAliasDeclarations somehow sees the triple twice.
1142
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SPACE_ALIAS_DECLARATION, GRAPH);
21✔
1143
        if (out.contains(typeSt)) {
12!
1144
            return;
×
1145
        }
1146

1147
        out.add(typeSt);
12✔
1148
        out.add(vf.createStatement(subject, SpacesVocab.CANONICAL_SPACE, canonicalIri, GRAPH));
27✔
1149
        out.add(vf.createStatement(subject, SpacesVocab.ALIAS_SPACE, aliasIri, GRAPH));
27✔
1150
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
1151
        addProvenance(subject, ctx, out);
12✔
1152
    }
3✔
1153

1154
    // ---------------- ID-prefix enumeration ----------------
1155

1156
    /**
1157
     * Returns the immediate URL-path parent of a Space IRI, after normalisation,
1158
     * for the URL-prefix sub-space fallback. Strips query / fragment / trailing
1159
     * slash, then drops the last path segment after the {@code ://} scheme
1160
     * separator. Returns at most one IRI; empty for inputs without a scheme
1161
     * separator or without any path beyond the host.
1162
     *
1163
     * <p>Direct-parent-only semantics matches Nanodash's existing
1164
     * {@code SpaceRepository.findSubspaces(...)} URL-regex behaviour. Multi-level
1165
     * containment queries should use SPARQL property paths
1166
     * ({@code <ancestor> npa:hasSubSpace+ ?descendant}) which walk the chain
1167
     * transitively, so deeper descendants remain reachable as long as the
1168
     * intermediate Spaces exist.
1169
     *
1170
     * <p>Examples:
1171
     * <pre>
1172
     *   https://example.org/a/b/c/space  →  [https://example.org/a/b/c]
1173
     *   https://example.org/space        →  [https://example.org]   (single segment → host)
1174
     *   https://example.org/x/           →  [https://example.org]   (trailing slash stripped)
1175
     *   https://example.org/a/space?q=1  →  [https://example.org/a] (query stripped)
1176
     *   https://example.org              →  []                       (no path to strip)
1177
     * </pre>
1178
     */
1179
    static List<IRI> enumerateIdPrefixes(IRI spaceIri) {
1180
        String s = spaceIri.stringValue();
9✔
1181
        int hash = s.indexOf('#');
12✔
1182
        if (hash >= 0) {
6✔
1183
            s = s.substring(0, hash);
15✔
1184
        }
1185
        int qmark = s.indexOf('?');
12✔
1186
        if (qmark >= 0) {
6✔
1187
            s = s.substring(0, qmark);
15✔
1188
        }
1189
        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
39✔
1190

1191
        int schemeEnd = s.indexOf("://");
12✔
1192
        if (schemeEnd < 0) {
6✔
1193
            return Collections.emptyList();
6✔
1194
        }
1195
        int hostStart = schemeEnd + 3;
12✔
1196
        int hostEnd = s.indexOf('/', hostStart);
15✔
1197
        if (hostEnd < 0) {
6✔
1198
            return Collections.emptyList();   // host-only, nothing to strip
6✔
1199
        }
1200

1201
        // Drop the last path segment. If that strips us back to the host (single-
1202
        // segment path), return the host-only IRI as the immediate parent.
1203
        int lastSlash = s.lastIndexOf('/');
12✔
1204
        String parent = (lastSlash <= hostEnd) ? s.substring(0, hostEnd) : s.substring(0, lastSlash);
39✔
1205
        return List.of(vf.createIRI(parent));
15✔
1206
    }
1207

1208
    // ---------------- shared helpers ----------------
1209

1210
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
1211
        if (ctx.signedBy() != null) {
9!
1212
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30✔
1213
        }
1214
        if (ctx.pubkeyHash() != null) {
9!
1215
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27✔
1216
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9✔
1217
        }
1218
        if (ctx.createdAt() != null) {
9!
1219
            Literal ts = vf.createLiteral(ctx.createdAt());
15✔
1220
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27✔
1221
        }
1222
    }
3✔
1223

1224
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
1225
        for (IRI c : candidates) {
30✔
1226
            if (types.contains(c)) {
12✔
1227
                return true;
6✔
1228
            }
1229
        }
3✔
1230
        return false;
6✔
1231
    }
1232

1233
    // ---------------- load-number stamping ----------------
1234

1235
    /**
1236
     * Stamps {@code <thisNP> npa:hasLoadNumber <N>} on the given nanopub. Intended to
1237
     * be called by the loader once per nanopub, in the same transaction as the
1238
     * extraction writes. Also bumps {@code npa:thisRepo npa:currentLoadCounter <N>}
1239
     * in the admin graph so the materializer's delta cycles know the horizon.
1240
     *
1241
     * @param npId       nanopub IRI
1242
     * @param loadNumber the load counter value
1243
     * @return two statements: load-number stamp + current-load-counter value
1244
     */
1245
    public static List<Statement> loadCounterStatements(IRI npId, long loadNumber) {
1246
        List<Statement> out = new ArrayList<>(2);
15✔
1247
        Literal lit = vf.createLiteral(loadNumber);
12✔
1248
        out.add(vf.createStatement(npId, NPA.HAS_LOAD_NUMBER, lit, NPA.GRAPH));
27✔
1249
        out.add(vf.createStatement(NPA.THIS_REPO, SpacesVocab.CURRENT_LOAD_COUNTER, lit, NPA.GRAPH));
27✔
1250
        return out;
6✔
1251
    }
1252

1253
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc