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

knowledgepixels / nanopub-query / 28017810007

23 Jun 2026 09:53AM UTC coverage: 61.38% (+0.3%) from 61.075%
28017810007

push

github

web-flow
Merge pull request #124 from knowledgepixels/fix/custom-role-predicate-resolution

fix(spaces): resolve custom role-predicate direction+tier from the role declaration

541 of 974 branches covered (55.54%)

Branch coverage included in aggregate %.

1568 of 2462 relevant lines covered (63.69%)

9.6 hits per line

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

88.56
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.addAll(BackcompatRolePredicates.ALL);
12✔
74
        TRIGGER_TYPES = Collections.unmodifiableSet(s);
9✔
75
    }
3✔
76

77
    private SpacesExtractor() {
78
    }
79

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

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

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

123
        List<Statement> out = new ArrayList<>();
12✔
124

125
        boolean isSpace = types.contains(GEN.SPACE);
12✔
126
        boolean isHasRole = types.contains(GEN.HAS_ROLE);
12✔
127
        boolean isSpaceMemberRole = types.contains(GEN.SPACE_MEMBER_ROLE);
12✔
128
        boolean isRoleInstantiation = types.contains(GEN.ROLE_INSTANTIATION)
18✔
129
                                      || anyMatch(types, BackcompatRolePredicates.ALL);
18✔
130
        boolean isSubSpaceOf = types.contains(GEN.IS_SUB_SPACE_OF);
12✔
131
        // Maintained-resource nanopubs use either the resource-class marker
132
        // (gen:MaintainedResource — what Nanodash currently writes) or the
133
        // predicate marker (gen:isMaintainedBy — single-predicate-assertion
134
        // auto-typing or explicit npx:hasNanopubType). Both shapes carry the
135
        // same <r> gen:isMaintainedBy <s> triple in the assertion.
136
        boolean isMaintainedResource = types.contains(GEN.MAINTAINED_RESOURCE)
18✔
137
                                       || types.contains(GEN.IS_MAINTAINED_BY);
18✔
138
        // Presets (Nanodash issue #302). A preset-defining nanopub is typed gen:Preset;
139
        // an assignment is typed gen:PresetAssignment (plus gen:Activated/Deactivated).
140
        // Both shapes are single-subject assertions, so NanopubUtils.getTypes promotes
141
        // the assertion type even without a pubinfo npx:hasNanopubType marker.
142
        boolean isPreset = types.contains(GEN.PRESET);
12✔
143
        boolean isPresetAssignment = types.contains(GEN.PRESET_ASSIGNMENT);
12✔
144

145
        if (isSpace) {
6✔
146
            extractSpace(np, ctx, out);
12✔
147
        }
148
        if (isHasRole) {
6✔
149
            extractHasRole(np, ctx, out);
12✔
150
        }
151
        if (isSpaceMemberRole) {
6✔
152
            extractSpaceMemberRole(np, ctx, out);
12✔
153
        }
154
        if (isRoleInstantiation) {
6✔
155
            extractRoleInstantiation(np, ctx, types.contains(GEN.ROLE_INSTANTIATION), out);
21✔
156
        }
157
        if (isSubSpaceOf) {
6✔
158
            extractSubSpaceOf(np, ctx, out);
12✔
159
        }
160
        if (isMaintainedResource) {
6✔
161
            extractIsMaintainedBy(np, ctx, out);
12✔
162
        }
163
        if (isPreset) {
6✔
164
            extractPreset(np, ctx, out);
12✔
165
        }
166
        if (isPresetAssignment) {
6✔
167
            extractPresetAssignment(np, ctx, out);
12✔
168
        }
169

170
        return out;
6✔
171
    }
172

173
    // ---------------- gen:Space ----------------
174

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

183
        // Rooted case: gen:hasRootDefinition explicitly declared.
184
        for (Statement st : np.getAssertion()) {
33✔
185
            if (!st.getPredicate().equals(GEN.HAS_ROOT_DEFINITION)) {
15✔
186
                continue;
3✔
187
            }
188
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27!
189
                continue;
190
            }
191
            if (!(st.getObject() instanceof IRI rootUri)) {
27!
192
                continue;
193
            }
194
            String rootNanopubId = TrustyUriUtils.getArtifactCode(rootUri.stringValue());
12✔
195
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
196
                logger.warn("Ignoring space {}: gen:hasRootDefinition target is not a trusty URI: {}",
×
197
                        spaceIri, rootUri);
198
                continue;
×
199
            }
200
            if (!handled.add(spaceIri)) {
12!
201
                continue;
×
202
            }
203
            emitSpaceEntry(np, ctx, spaceIri, rootUri, rootNanopubId, adminAgents, out);
24✔
204
        }
3✔
205

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

237
    private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI rootUri,
238
                                       String rootNanopubId, List<IRI> adminAgents,
239
                                       List<Statement> out) {
240
        String spaceRef = rootNanopubId + "_" + Utils.createHash(spaceIri);
15✔
241
        IRI refIri = SpacesVocab.forSpaceRef(spaceRef);
9✔
242
        IRI defIri = SpacesVocab.forSpaceDefinition(ctx.artifactCode());
12✔
243

244
        // Aggregate entry: contributor-independent, reinforced on every contribution.
245
        out.add(vf.createStatement(refIri, RDF.TYPE, SpacesVocab.SPACE_REF, GRAPH));
27✔
246
        out.add(vf.createStatement(refIri, SpacesVocab.SPACE_IRI, spaceIri, GRAPH));
27✔
247
        out.add(vf.createStatement(refIri, SpacesVocab.ROOT_NANOPUB, rootUri, GRAPH));
27✔
248

249
        // Identity-derived path-prefix enumeration powering the URL-prefix sub-space
250
        // fallback in the materializer. Same triples on every contributor (RDF set
251
        // semantics dedups them).
252
        for (IRI prefix : enumerateIdPrefixes(spaceIri)) {
33✔
253
            out.add(vf.createStatement(refIri, SpacesVocab.HAS_ID_PREFIX, prefix, GRAPH));
27✔
254
        }
3✔
255

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

261
        // Embedded gen:isMaintainedBy triples in this gen:Space nanopub: emit one
262
        // MaintainedResourceDeclaration per (resourceIri, spaceIri) pair where the
263
        // object equals the Space being defined. Same shape as the standalone path.
264
        emitMaintainedResourceDeclarations(np, ctx, spaceIri, out);
15✔
265

266
        // Embedded owl:sameAs triples: <spaceIri> owl:sameAs <aliasIri> declares that
267
        // <aliasIri> is an alias of the Space being defined. Emit one
268
        // SpaceAliasDeclaration per (spaceIri, aliasIri) pair so the materializer can
269
        // let this space's admin authority cover roles/members attached to the alias
270
        // (issue #113). Carries provenance — the materializer gates the edge on the
271
        // declaration's publisher being an admin of the canonical space.
272
        emitSpaceAliasDeclarations(np, ctx, spaceIri, out);
15✔
273

274
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
275
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27✔
276
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27✔
277
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
278
        addProvenance(defIri, ctx, out);
12✔
279

280
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
281
        boolean isOwnRoot = rootUri.equals(np.getUri());
15✔
282
        if (isOwnRoot) {
6✔
283
            for (IRI adminAgent : adminAgents) {
30✔
284
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27✔
285
            }
3✔
286
        }
287

288
        // gen:RoleInstantiation entry for the admins asserted in this gen:Space nanopub,
289
        // so admins show up in the same SPARQL pattern as ordinary admin instantiations.
290
        if (!adminAgents.isEmpty()) {
9!
291
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
292
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
293
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
294
            out.add(vf.createStatement(riIri, SpacesVocab.INVERSE_PROPERTY, GEN.HAS_ADMIN, GRAPH));
27✔
295
            for (IRI adminAgent : adminAgents) {
30✔
296
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, adminAgent, GRAPH));
27✔
297
            }
3✔
298
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
299
            addProvenance(riIri, ctx, out);
12✔
300
        }
301

302
        // Inline non-hasAdmin role triples: a gen:Space nanopub may also assert
303
        // <space> <pred> <agent> (INVERSE) or <agent> <pred> <space> (REGULAR)
304
        // for any of the back-compat role predicates (has-event-facilitator,
305
        // participatedAsParticipantIn, …). Without an extraction path here those
306
        // are silently dropped because gen:Space nanopubs are not auto-typed
307
        // with back-compat predicates (only single-triple-assertion nanopubs are).
308
        // Emit one RoleInstantiation per distinct predicate found, grouping
309
        // multi-agent like the admin case. The subject is disambiguated by a
310
        // hash of the predicate IRI so multiple predicates in one nanopub don't
311
        // collide on the same npari:<artifactCode> subject as the admin RI.
312
        emitInlineRoleInstantiations(np, ctx, spaceIri, out);
15✔
313
    }
3✔
314

315
    /**
316
     * Scans the assertion of a {@code gen:Space} nanopub for inline role triples
317
     * (excluding {@code gen:hasAdmin}, which is handled separately as the trust
318
     * seed), grouping by predicate and emitting one {@link GEN#ROLE_INSTANTIATION}
319
     * per (predicate, direction) pair with multi-valued {@code npa:forAgent}.
320
     */
321
    private static void emitInlineRoleInstantiations(Nanopub np, Context ctx, IRI spaceIri,
322
                                                     List<Statement> out) {
323
        Map<IRI, BackcompatRolePredicates.Direction> directionByPred = new LinkedHashMap<>();
12✔
324
        Map<IRI, Set<IRI>> agentsByPred = new LinkedHashMap<>();
12✔
325
        for (Statement st : np.getAssertion()) {
33✔
326
            IRI predicate = st.getPredicate();
9✔
327
            if (GEN.HAS_ADMIN.equals(predicate)) {
12✔
328
                continue; // already emitted above
3✔
329
            }
330
            BackcompatRolePredicates.Direction direction = BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
331
            if (direction == null) {
6✔
332
                continue;
3✔
333
            }
334
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
335
                continue;
336
            }
337
            if (!(st.getObject() instanceof IRI objIri)) {
27!
338
                continue;
339
            }
340
            IRI agent;
341
            if (direction == BackcompatRolePredicates.Direction.INVERSE) {
9✔
342
                if (!spaceIri.equals(subjIri)) {
12!
343
                    continue;
×
344
                }
345
                agent = objIri;
9✔
346
            } else {
347
                if (!spaceIri.equals(objIri)) {
12!
348
                    continue;
×
349
                }
350
                agent = subjIri;
6✔
351
            }
352
            directionByPred.put(predicate, direction);
15✔
353
            agentsByPred.computeIfAbsent(predicate, k -> new LinkedHashSet<>()).add(agent);
36✔
354
        }
3✔
355
        for (Map.Entry<IRI, Set<IRI>> entry : agentsByPred.entrySet()) {
33✔
356
            IRI predicate = entry.getKey();
12✔
357
            BackcompatRolePredicates.Direction direction = directionByPred.get(predicate);
15✔
358
            String predHash = Utils.createHash(predicate.stringValue());
12✔
359
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode(), predHash);
15✔
360
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
361
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
362
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
363
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
364
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
365
            out.add(vf.createStatement(riIri, directionPredicate, predicate, GRAPH));
27✔
366
            for (IRI agent : entry.getValue()) {
36✔
367
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, agent, GRAPH));
27✔
368
            }
3✔
369
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
370
            addProvenance(riIri, ctx, out);
12✔
371
        }
3✔
372
    }
3✔
373

374
    /**
375
     * Heuristic: does {@code candidate} look like a Space IRI in {@code np}'s assertion,
376
     * independent of any {@code gen:hasRootDefinition} triple? We accept it if the
377
     * assertion contains {@code candidate rdf:type gen:Space} or
378
     * {@code candidate gen:hasAdmin ?x}.
379
     */
380
    private static boolean looksLikeSpaceIri(Nanopub np, IRI candidate) {
381
        for (Statement st : np.getAssertion()) {
33✔
382
            if (!candidate.equals(st.getSubject())) {
15✔
383
                continue;
3✔
384
            }
385
            if (st.getPredicate().equals(RDF.TYPE) && GEN.SPACE.equals(st.getObject())) {
30!
386
                return true;
6✔
387
            }
388
            if (st.getPredicate().equals(GEN.HAS_ADMIN)) {
15!
389
                return true;
×
390
            }
391
        }
3✔
392
        return false;
6✔
393
    }
394

395
    private static List<IRI> collectAdminAgents(Nanopub np) {
396
        Set<IRI> agents = new LinkedHashSet<>();
12✔
397
        for (Statement st : np.getAssertion()) {
33✔
398
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) {
15✔
399
                continue;
3✔
400
            }
401
            if (!(st.getObject() instanceof IRI agent)) {
27!
402
                continue;
403
            }
404
            agents.add(agent);
12✔
405
        }
3✔
406
        return new ArrayList<>(agents);
15✔
407
    }
408

409
    // ---------------- gen:hasRole (role attachment) ----------------
410

411
    private static void extractHasRole(Nanopub np, Context ctx, List<Statement> out) {
412
        // A gen:hasRole nanopub asserts <space> gen:hasRole <role>.
413
        for (Statement st : np.getAssertion()) {
33!
414
            if (!st.getPredicate().equals(GEN.HAS_ROLE)) {
15!
415
                continue;
×
416
            }
417
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27!
418
                continue;
419
            }
420
            if (!(st.getObject() instanceof IRI roleIri)) {
27!
421
                continue;
422
            }
423
            IRI subject = SpacesVocab.forRoleAssignment(ctx.artifactCode());
12✔
424
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_ASSIGNMENT, GRAPH));
27✔
425
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
426
            out.add(vf.createStatement(subject, GEN.HAS_ROLE, roleIri, GRAPH));
27✔
427
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
428
            addProvenance(subject, ctx, out);
12✔
429
            // One attachment per nanopub — the subject IRI is derived from the nanopub
430
            // artifact code so multiple hasRole triples in the same nanopub would collide.
431
            // If that case shows up in practice, we'll refine the subject-minting scheme.
432
            return;
3✔
433
        }
434
    }
×
435

436
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
437

438
    private static void extractSpaceMemberRole(Nanopub np, Context ctx, List<Statement> out) {
439
        // The role IRI is embedded in this nanopub, so look for an assertion statement
440
        // of the shape <roleIri> rdf:type gen:SpaceMemberRole where <roleIri> starts
441
        // with the nanopub IRI (valid embedded mint).
442
        IRI roleIri = null;
6✔
443
        for (Statement st : np.getAssertion()) {
33✔
444
            if (!st.getPredicate().equals(RDF.TYPE)) {
15!
445
                continue;
×
446
            }
447
            if (!GEN.SPACE_MEMBER_ROLE.equals(st.getObject())) {
15✔
448
                continue;
3✔
449
            }
450
            if (!(st.getSubject() instanceof IRI candidate)) {
27!
451
                continue;
452
            }
453
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) {
21✔
454
                continue;
3✔
455
            }
456
            roleIri = candidate;
6✔
457
            break;
3✔
458
        }
459
        if (roleIri == null) {
6✔
460
            return;
3✔
461
        }
462

463
        IRI roleType = findRoleTier(np, roleIri);
12✔
464
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15✔
465
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15✔
466

467
        IRI subject = SpacesVocab.forRoleDeclaration(ctx.artifactCode());
12✔
468
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DECLARATION, GRAPH));
27✔
469
        out.add(vf.createStatement(subject, SpacesVocab.ROLE, roleIri, GRAPH));
27✔
470
        out.add(vf.createStatement(subject, SpacesVocab.HAS_ROLE_TYPE, roleType, GRAPH));
27✔
471
        for (IRI reg : regulars) {
30✔
472
            out.add(vf.createStatement(subject, GEN.HAS_REGULAR_PROPERTY, reg, GRAPH));
27✔
473
        }
3✔
474
        for (IRI inv : inverses) {
30✔
475
            out.add(vf.createStatement(subject, GEN.HAS_INVERSE_PROPERTY, inv, GRAPH));
27✔
476
        }
3✔
477
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
478
        if (ctx.createdAt() != null) {
9!
479
            out.add(vf.createStatement(subject, DCTERMS.CREATED, vf.createLiteral(ctx.createdAt()), GRAPH));
36✔
480
        }
481
    }
3✔
482

483
    /**
484
     * Looks for a tier rdf:type ({@code gen:MaintainerRole} / {@code gen:MemberRole} /
485
     * {@code gen:ObserverRole}) on the role IRI in the assertion; defaults to
486
     * {@code gen:ObserverRole} if none is declared.
487
     */
488
    private static IRI findRoleTier(Nanopub np, IRI roleIri) {
489
        for (Statement st : np.getAssertion()) {
33✔
490
            if (!roleIri.equals(st.getSubject())) {
15!
491
                continue;
×
492
            }
493
            if (!st.getPredicate().equals(RDF.TYPE)) {
15!
494
                continue;
×
495
            }
496
            if (!(st.getObject() instanceof IRI type)) {
27!
497
                continue;
498
            }
499
            if (GEN.MAINTAINER_ROLE.equals(type) || GEN.MEMBER_ROLE.equals(type)
30!
500
                || GEN.OBSERVER_ROLE.equals(type)) {
6!
501
                return type;
6✔
502
            }
503
        }
3✔
504
        return GEN.OBSERVER_ROLE;
6✔
505
    }
506

507
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
508
        List<IRI> out = new ArrayList<>();
12✔
509
        for (Statement st : np.getAssertion()) {
33✔
510
            if (!roleIri.equals(st.getSubject())) {
15!
511
                continue;
×
512
            }
513
            if (!predicate.equals(st.getPredicate())) {
15✔
514
                continue;
3✔
515
            }
516
            if (!(st.getObject() instanceof IRI obj)) {
27!
517
                continue;
518
            }
519
            out.add(obj);
12✔
520
        }
3✔
521
        return out;
6✔
522
    }
523

524
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
525

526
    private static void extractRoleInstantiation(Nanopub np, Context ctx,
527
                                                 boolean explicitRoleInstantiation, List<Statement> out) {
528
        // Find the assignment triple. Directionality (matches the publisher convention
529
        // used by gen:hasRegularProperty / gen:hasInverseProperty in role-definition
530
        // nanopubs):
531
        //   REGULAR: <agent> <predicate> <space>  → npa:regularProperty.
532
        //   INVERSE: <space> <predicate> <agent>  → npa:inverseProperty.
533
        // gen:hasAdmin is hardcoded INVERSE (space-centric: <space> hasAdmin <agent>).
534
        // The 14 backwards-compat predicates are classified in
535
        // {@link BackcompatRolePredicates#DIRECTIONS}.
536
        for (Statement st : np.getAssertion()) {
33✔
537
            IRI predicate = st.getPredicate();
9✔
538
            BackcompatRolePredicates.Direction direction = directionFor(predicate);
9✔
539
            if (direction == null) {
6✔
540
                continue;
3✔
541
            }
542
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
543
                continue;
544
            }
545
            if (!(st.getObject() instanceof IRI objIri)) {
27!
546
                continue;
547
            }
548

549
            IRI spaceSide;
550
            IRI agentSide;
551
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
552
                agentSide = subjIri;
6✔
553
                spaceSide = objIri;
9✔
554
            } else {
555
                spaceSide = subjIri;
6✔
556
                agentSide = objIri;
6✔
557
            }
558

559
            // Deduplicate against the (possibly already emitted) admin instantiation
560
            // from the gen:Space path — a single nanopub can be typed gen:Space AND
561
            // have a gen:hasAdmin triple that the backcompat list also catches. The
562
            // subject IRI is the same (derived from artifact code) and the payload
563
            // would conflict if re-emitted. Skip if we already have a RoleInstantiation
564
            // entry on this subject.
565
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
566
            Statement typeSt = vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH);
21✔
567
            if (out.contains(typeSt)) {
12!
568
                return;
×
569
            }
570

571
            out.add(typeSt);
12✔
572
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
573
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
574
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
575
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
576
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
577
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27✔
578
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
579
            addProvenance(subject, ctx, out);
12✔
580
            return;
3✔
581
        }
582

583
        // No predicate with a known direction. For a nanopub explicitly typed
584
        // gen:RoleInstantiation, the assertion still binds an agent to a space via a
585
        // custom role predicate declared in a gen:SpaceMemberRole nanopub (e.g.
586
        // gen:hasMaintainer). We can't classify its direction here — that lives in the
587
        // role declaration, a different nanopub the extractor can't see — so emit a
588
        // neutral binding carrying the raw (subject, predicate, object). The materializer
589
        // resolves direction + tier by joining the predicate against the role declaration
590
        // attached to the space (see AuthorityResolver#nonAdminTierUpdate). Gated on the
591
        // explicit type so we don't mint inert entries for incidental IRI-valued triples
592
        // in nanopubs that only matched via a backcompat predicate.
593
        if (!explicitRoleInstantiation) {
6!
594
            return;
×
595
        }
596
        for (Statement st : np.getAssertion()) {
33✔
597
            IRI predicate = st.getPredicate();
9✔
598
            if (predicate.equals(RDF.TYPE) || directionFor(predicate) != null) {
21!
599
                continue;
×
600
            }
601
            if (!(st.getSubject() instanceof IRI subjIri)) {
27!
602
                continue;
603
            }
604
            if (!(st.getObject() instanceof IRI objIri)) {
27!
605
                continue;
606
            }
607
            // Discriminate the subject by predicate so multiple custom-predicate triples
608
            // in one nanopub don't collide on the artifact-code-derived subject.
609
            IRI subject = SpacesVocab.forRoleInstantiation(
9✔
610
                    ctx.artifactCode(), Utils.createHash(predicate.stringValue()));
12✔
611
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
612
            out.add(vf.createStatement(subject, SpacesVocab.ROLE_PREDICATE, predicate, GRAPH));
27✔
613
            out.add(vf.createStatement(subject, SpacesVocab.BINDING_SUBJECT, subjIri, GRAPH));
27✔
614
            out.add(vf.createStatement(subject, SpacesVocab.BINDING_OBJECT, objIri, GRAPH));
27✔
615
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
616
            addProvenance(subject, ctx, out);
12✔
617
        }
3✔
618
    }
3✔
619

620
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
621
        if (GEN.HAS_ADMIN.equals(predicate)) {
12!
622
            return BackcompatRolePredicates.Direction.INVERSE;
×
623
        }
624
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
625
    }
626

627
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
628

629
    /**
630
     * Standalone {@code gen:isSubSpaceOf} nanopub: every
631
     * {@code <childIri> gen:isSubSpaceOf <parentIri>} triple in the assertion emits one
632
     * {@code npa:SubSpaceDeclaration}. Multi-triple assertions are allowed; one entry
633
     * per pair. Self-loops ({@code <X> gen:isSubSpaceOf <X>}) are rejected.
634
     */
635
    private static void extractSubSpaceOf(Nanopub np, Context ctx, List<Statement> out) {
636
        for (Statement st : np.getAssertion()) {
33✔
637
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) {
15!
638
                continue;
×
639
            }
640
            if (!(st.getSubject() instanceof IRI childIri)) {
27!
641
                continue;
642
            }
643
            if (!(st.getObject() instanceof IRI parentIri)) {
27!
644
                continue;
645
            }
646
            emitSubSpaceDeclaration(np, ctx, childIri, parentIri, out);
18✔
647
        }
3✔
648
    }
3✔
649

650
    /**
651
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
652
     * {@code <spaceIri> gen:isSubSpaceOf <parentIri>} triples (subject must equal the
653
     * Space IRI we're emitting an entry for, so the subspace declaration is bound to
654
     * this particular Space). Self-loops are rejected.
655
     */
656
    private static void emitSubSpaceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
657
                                                 List<Statement> out) {
658
        for (Statement st : np.getAssertion()) {
33✔
659
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) {
15✔
660
                continue;
3✔
661
            }
662
            if (!spaceIri.equals(st.getSubject())) {
15✔
663
                continue;
3✔
664
            }
665
            if (!(st.getObject() instanceof IRI parentIri)) {
27!
666
                continue;
667
            }
668
            emitSubSpaceDeclaration(np, ctx, spaceIri, parentIri, out);
18✔
669
        }
3✔
670
    }
3✔
671

672
    /**
673
     * Emits one {@code npa:SubSpaceDeclaration} entry, keyed by
674
     * {@code (artifactCode, parentHash)} so a single nanopub can declare multiple
675
     * parents without subject collision. Self-loops are silently dropped.
676
     */
677
    private static void emitSubSpaceDeclaration(Nanopub np, Context ctx, IRI childIri,
678
                                                IRI parentIri, List<Statement> out) {
679
        if (childIri.equals(parentIri)) {
12✔
680
            logger.debug("Ignoring self-loop sub-space declaration on {} in {}", childIri, np.getUri());
18✔
681
            return;
3✔
682
        }
683
        String parentHash = Utils.createHash(parentIri);
9✔
684
        IRI subject = SpacesVocab.forSubSpaceDeclaration(ctx.artifactCode(), parentHash);
15✔
685

686
        // Idempotence: the embedded and standalone paths can both fire on the same
687
        // (np, child, parent) combination if a gen:Space nanopub somehow ends up typed
688
        // gen:isSubSpaceOf as well. Skip if we've already emitted the type triple for
689
        // this subject.
690
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SUB_SPACE_DECLARATION, GRAPH);
21✔
691
        if (out.contains(typeSt)) {
12!
692
            return;
×
693
        }
694

695
        out.add(typeSt);
12✔
696
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27✔
697
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27✔
698
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
699
        addProvenance(subject, ctx, out);
12✔
700
    }
3✔
701

702
    // ---------------- gen:isMaintainedBy ----------------
703

704
    /**
705
     * Standalone {@code gen:isMaintainedBy} nanopub: every
706
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triple in the assertion emits
707
     * one {@code npa:MaintainedResourceDeclaration}. Multi-triple assertions are
708
     * allowed; one entry per pair. Self-loops ({@code <X> gen:isMaintainedBy <X>}) are
709
     * rejected.
710
     */
711
    private static void extractIsMaintainedBy(Nanopub np, Context ctx, List<Statement> out) {
712
        for (Statement st : np.getAssertion()) {
33✔
713
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) {
15✔
714
                continue;
3✔
715
            }
716
            if (!(st.getSubject() instanceof IRI resourceIri)) {
27!
717
                continue;
718
            }
719
            if (!(st.getObject() instanceof IRI spaceIri)) {
27!
720
                continue;
721
            }
722
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
723
        }
3✔
724
    }
3✔
725

726
    /**
727
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
728
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triples (object must equal
729
     * the Space IRI we're emitting an entry for, so the maintained-resource
730
     * declaration is bound to this particular Space). Self-loops are rejected.
731
     */
732
    private static void emitMaintainedResourceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
733
                                                           List<Statement> out) {
734
        for (Statement st : np.getAssertion()) {
33✔
735
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) {
15✔
736
                continue;
3✔
737
            }
738
            if (!spaceIri.equals(st.getObject())) {
15✔
739
                continue;
3✔
740
            }
741
            if (!(st.getSubject() instanceof IRI resourceIri)) {
27!
742
                continue;
743
            }
744
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
745
        }
3✔
746
    }
3✔
747

748
    /**
749
     * Emits one {@code npa:MaintainedResourceDeclaration} entry, keyed by
750
     * {@code (artifactCode, resourceHash)} so a single nanopub can declare multiple
751
     * maintained resources without subject collision. Self-loops are silently dropped.
752
     */
753
    private static void emitMaintainedResourceDeclaration(Nanopub np, Context ctx, IRI resourceIri,
754
                                                          IRI spaceIri, List<Statement> out) {
755
        if (resourceIri.equals(spaceIri)) {
12✔
756
            logger.debug("Ignoring self-loop maintained-resource declaration on {} in {}",
15✔
757
                    resourceIri, np.getUri());
3✔
758
            return;
3✔
759
        }
760
        String resourceHash = Utils.createHash(resourceIri);
9✔
761
        IRI subject = SpacesVocab.forMaintainedResourceDeclaration(ctx.artifactCode(), resourceHash);
15✔
762

763
        // Idempotence: the embedded (gen:Space) and standalone (gen:isMaintainedBy)
764
        // paths can both fire on the same (np, resource, space) combination if a
765
        // gen:Space nanopub somehow ends up typed gen:isMaintainedBy as well. Skip if
766
        // we've already emitted the type triple for this subject.
767
        Statement typeSt = vf.createStatement(subject, RDF.TYPE,
21✔
768
                SpacesVocab.MAINTAINED_RESOURCE_DECLARATION, GRAPH);
769
        if (out.contains(typeSt)) {
12!
770
            return;
×
771
        }
772

773
        out.add(typeSt);
12✔
774
        out.add(vf.createStatement(subject, SpacesVocab.RESOURCE_IRI, resourceIri, GRAPH));
27✔
775
        out.add(vf.createStatement(subject, SpacesVocab.MAINTAINER_SPACE, spaceIri, GRAPH));
27✔
776
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
777
        addProvenance(subject, ctx, out);
12✔
778
    }
3✔
779

780
    // ---------------- gen:Preset (preset declaration) ----------------
781

782
    /**
783
     * A {@code gen:Preset} nanopub bundles default views and roles. We extract only the
784
     * role half (views stay read-time in Nanodash; see
785
     * {@code doc/design-preset-role-materialization.md}): one
786
     * {@code npa:PresetDeclaration} carrying every {@code gen:hasRole} as
787
     * {@code npa:presetRole}, the {@code gen:appliesToInstancesOf} target(s), and the
788
     * preset's identity as {@code npa:ofPreset}.
789
     *
790
     * <p>Join robustness: the assignment's {@code gen:isAssignmentOfPreset} may name the
791
     * versioned preset node or the version-independent {@code dct:isVersionOf} kind
792
     * (Nanodash treats the kind as the canonical reference). We emit {@code npa:ofPreset}
793
     * for <em>both</em> so an assignment naming either joins to this declaration.
794
     */
795
    private static void extractPreset(Nanopub np, Context ctx, List<Statement> out) {
796
        // The preset IRI is embedded in this nanopub: <preset> rdf:type gen:Preset where
797
        // <preset> starts with the nanopub IRI (valid embedded mint), mirroring the
798
        // gen:SpaceMemberRole role-declaration rule.
799
        IRI presetIri = null;
6✔
800
        for (Statement st : np.getAssertion()) {
33✔
801
            if (!st.getPredicate().equals(RDF.TYPE)) {
15✔
802
                continue;
3✔
803
            }
804
            if (!GEN.PRESET.equals(st.getObject())) {
15!
805
                continue;
×
806
            }
807
            if (!(st.getSubject() instanceof IRI candidate)) {
27!
808
                continue;
809
            }
810
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) {
21✔
811
                continue;
3✔
812
            }
813
            presetIri = candidate;
6✔
814
            break;
3✔
815
        }
816
        if (presetIri == null) {
6✔
817
            return;
3✔
818
        }
819

820
        List<IRI> roles = collectObjects(np, presetIri, GEN.HAS_ROLE);
15✔
821
        List<IRI> appliesTo = collectObjects(np, presetIri, GEN.APPLIES_TO_INSTANCES_OF);
15✔
822
        List<IRI> kinds = collectObjects(np, presetIri, DCTERMS.IS_VERSION_OF);
15✔
823
        // Canonical kind: the dct:isVersionOf target, or the node IRI as fallback when the
824
        // preset declares no kind — same rule as Nanodash ViewDisplay.getViewKindIri().
825
        IRI presetKind = kinds.isEmpty() ? presetIri : kinds.get(0);
30✔
826

827
        IRI subject = SpacesVocab.forPresetDeclaration(ctx.artifactCode());
12✔
828
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.PRESET_DECLARATION, GRAPH));
27✔
829
        // Canonical version-independent grouping key (latest-declaration-per-kind resolution).
830
        out.add(vf.createStatement(subject, SpacesVocab.PRESET_KIND, presetKind, GRAPH));
27✔
831
        // Lookup keys: the preset's own node IRI plus its version-independent kind, so an
832
        // assignment naming either is mapped to this declaration's canonical kind.
833
        out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, presetIri, GRAPH));
27✔
834
        for (IRI kind : kinds) {
30✔
835
            out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, kind, GRAPH));
27✔
836
        }
3✔
837
        for (IRI role : roles) {
30✔
838
            out.add(vf.createStatement(subject, SpacesVocab.PRESET_ROLE, role, GRAPH));
27✔
839
        }
3✔
840
        for (IRI type : appliesTo) {
30✔
841
            out.add(vf.createStatement(subject, SpacesVocab.APPLIES_TO_INSTANCES_OF, type, GRAPH));
27✔
842
        }
3✔
843
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
844
        addProvenance(subject, ctx, out);
12✔
845
    }
3✔
846

847
    // ---------------- gen:PresetAssignment ----------------
848

849
    /**
850
     * A {@code gen:PresetAssignment} nanopub assigns a preset to a resource. Emits one
851
     * {@code npa:PresetAssignment} row recording the {@code (preset, resource)} pair and
852
     * its activation state. Activation is <em>active-by-default</em>: active unless the
853
     * assignment node is explicitly typed {@code gen:DeactivatedPresetAssignment} —
854
     * matching Nanodash's {@code PresetAssignment.isActive()}.
855
     *
856
     * <p>The {@code dct:created} timestamp emitted by {@link #addProvenance} is the
857
     * latest-wins key the validator uses to resolve same-pair assignments; it must be
858
     * present for the materialization to converge.
859
     */
860
    private static void extractPresetAssignment(Nanopub np, Context ctx, List<Statement> out) {
861
        // The assignment node is the subject of the gen:isAssignmentOfPreset triple.
862
        IRI assignmentNode = null;
6✔
863
        IRI presetIri = null;
6✔
864
        for (Statement st : np.getAssertion()) {
33!
865
            if (!st.getPredicate().equals(GEN.IS_ASSIGNMENT_OF_PRESET)) {
15✔
866
                continue;
3✔
867
            }
868
            if (!(st.getSubject() instanceof IRI subj)) {
27!
869
                continue;
870
            }
871
            if (!(st.getObject() instanceof IRI preset)) {
27!
872
                continue;
873
            }
874
            assignmentNode = subj;
6✔
875
            presetIri = preset;
6✔
876
            break;
3✔
877
        }
878
        if (assignmentNode == null) {
6!
879
            return;
×
880
        }
881

882
        IRI resource = null;
6✔
883
        for (Statement st : np.getAssertion()) {
33✔
884
            if (!assignmentNode.equals(st.getSubject())) {
15!
885
                continue;
×
886
            }
887
            if (!st.getPredicate().equals(GEN.IS_ASSIGNMENT_FOR)) {
15✔
888
                continue;
3✔
889
            }
890
            if (st.getObject() instanceof IRI res) {
27!
891
                resource = res;
6✔
892
                break;
3✔
893
            }
894
        }
×
895
        if (resource == null) {
6✔
896
            logger.warn("Ignoring preset assignment in {}: no gen:isAssignmentFor resource", np.getUri());
15✔
897
            return;
3✔
898
        }
899

900
        // Active-by-default: deactivated only if explicitly typed.
901
        boolean deactivated = false;
6✔
902
        for (Statement st : np.getAssertion()) {
33✔
903
            if (assignmentNode.equals(st.getSubject())
18!
904
                && st.getPredicate().equals(RDF.TYPE)
18✔
905
                && GEN.DEACTIVATED_PRESET_ASSIGNMENT.equals(st.getObject())) {
9✔
906
                deactivated = true;
6✔
907
                break;
3✔
908
            }
909
        }
3✔
910

911
        IRI subject = SpacesVocab.forPresetAssignment(ctx.artifactCode());
12✔
912
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.PRESET_ASSIGNMENT, GRAPH));
27✔
913
        out.add(vf.createStatement(subject, SpacesVocab.OF_PRESET, presetIri, GRAPH));
27✔
914
        out.add(vf.createStatement(subject, SpacesVocab.FOR_RESOURCE, resource, GRAPH));
27✔
915
        out.add(vf.createStatement(subject, SpacesVocab.IS_ACTIVATED, vf.createLiteral(!deactivated), GRAPH));
45✔
916
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
917
        addProvenance(subject, ctx, out);
12✔
918
    }
3✔
919

920
    /** Collects all IRI objects of {@code subject predicate ?o} triples in the assertion. */
921
    private static List<IRI> collectObjects(Nanopub np, IRI subject, IRI predicate) {
922
        List<IRI> out = new ArrayList<>();
12✔
923
        for (Statement st : np.getAssertion()) {
33✔
924
            if (!subject.equals(st.getSubject())) {
15!
925
                continue;
×
926
            }
927
            if (!predicate.equals(st.getPredicate())) {
15✔
928
                continue;
3✔
929
            }
930
            if (st.getObject() instanceof IRI obj) {
27!
931
                out.add(obj);
12✔
932
            }
933
        }
3✔
934
        return out;
6✔
935
    }
936

937
    // ---------------- owl:sameAs (space aliases) ----------------
938

939
    /**
940
     * Scans a {@code gen:Space} nanopub's assertion for
941
     * {@code <spaceIri> owl:sameAs <aliasIri>} triples (subject must equal the Space IRI
942
     * being emitted, so the alias declaration is bound to this particular Space) and emits
943
     * one {@code npa:SpaceAliasDeclaration} per {@code (spaceIri, aliasIri)} pair. The
944
     * Space IRI is the canonical side; the {@code owl:sameAs} object is the alias.
945
     * Self-aliases ({@code <X> owl:sameAs <X>}) are rejected.
946
     */
947
    private static void emitSpaceAliasDeclarations(Nanopub np, Context ctx, IRI spaceIri,
948
                                                   List<Statement> out) {
949
        for (Statement st : np.getAssertion()) {
33✔
950
            if (!st.getPredicate().equals(OWL.SAMEAS)) {
15✔
951
                continue;
3✔
952
            }
953
            if (!spaceIri.equals(st.getSubject())) {
15✔
954
                continue;
3✔
955
            }
956
            if (!(st.getObject() instanceof IRI aliasIri)) {
27!
957
                continue;
958
            }
959
            emitSpaceAliasDeclaration(np, ctx, spaceIri, aliasIri, out);
18✔
960
        }
3✔
961
    }
3✔
962

963
    /**
964
     * Emits one {@code npa:SpaceAliasDeclaration} entry, keyed by
965
     * {@code (artifactCode, aliasHash)} so a single nanopub can declare multiple aliases
966
     * without subject collision. Self-aliases are silently dropped.
967
     */
968
    private static void emitSpaceAliasDeclaration(Nanopub np, Context ctx, IRI canonicalIri,
969
                                                  IRI aliasIri, List<Statement> out) {
970
        if (canonicalIri.equals(aliasIri)) {
12✔
971
            logger.debug("Ignoring self-alias declaration on {} in {}", canonicalIri, np.getUri());
18✔
972
            return;
3✔
973
        }
974
        String aliasHash = Utils.createHash(aliasIri);
9✔
975
        IRI subject = SpacesVocab.forSpaceAliasDeclaration(ctx.artifactCode(), aliasHash);
15✔
976

977
        // Idempotence: a single (np, canonical, alias) combination should produce one entry
978
        // even if emitSpaceAliasDeclarations somehow sees the triple twice.
979
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SPACE_ALIAS_DECLARATION, GRAPH);
21✔
980
        if (out.contains(typeSt)) {
12!
981
            return;
×
982
        }
983

984
        out.add(typeSt);
12✔
985
        out.add(vf.createStatement(subject, SpacesVocab.CANONICAL_SPACE, canonicalIri, GRAPH));
27✔
986
        out.add(vf.createStatement(subject, SpacesVocab.ALIAS_SPACE, aliasIri, GRAPH));
27✔
987
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
988
        addProvenance(subject, ctx, out);
12✔
989
    }
3✔
990

991
    // ---------------- ID-prefix enumeration ----------------
992

993
    /**
994
     * Returns the immediate URL-path parent of a Space IRI, after normalisation,
995
     * for the URL-prefix sub-space fallback. Strips query / fragment / trailing
996
     * slash, then drops the last path segment after the {@code ://} scheme
997
     * separator. Returns at most one IRI; empty for inputs without a scheme
998
     * separator or without any path beyond the host.
999
     *
1000
     * <p>Direct-parent-only semantics matches Nanodash's existing
1001
     * {@code SpaceRepository.findSubspaces(...)} URL-regex behaviour. Multi-level
1002
     * containment queries should use SPARQL property paths
1003
     * ({@code <ancestor> npa:hasSubSpace+ ?descendant}) which walk the chain
1004
     * transitively, so deeper descendants remain reachable as long as the
1005
     * intermediate Spaces exist.
1006
     *
1007
     * <p>Examples:
1008
     * <pre>
1009
     *   https://example.org/a/b/c/space  →  [https://example.org/a/b/c]
1010
     *   https://example.org/space        →  [https://example.org]   (single segment → host)
1011
     *   https://example.org/x/           →  [https://example.org]   (trailing slash stripped)
1012
     *   https://example.org/a/space?q=1  →  [https://example.org/a] (query stripped)
1013
     *   https://example.org              →  []                       (no path to strip)
1014
     * </pre>
1015
     */
1016
    static List<IRI> enumerateIdPrefixes(IRI spaceIri) {
1017
        String s = spaceIri.stringValue();
9✔
1018
        int hash = s.indexOf('#');
12✔
1019
        if (hash >= 0) {
6✔
1020
            s = s.substring(0, hash);
15✔
1021
        }
1022
        int qmark = s.indexOf('?');
12✔
1023
        if (qmark >= 0) {
6✔
1024
            s = s.substring(0, qmark);
15✔
1025
        }
1026
        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
39✔
1027

1028
        int schemeEnd = s.indexOf("://");
12✔
1029
        if (schemeEnd < 0) {
6✔
1030
            return Collections.emptyList();
6✔
1031
        }
1032
        int hostStart = schemeEnd + 3;
12✔
1033
        int hostEnd = s.indexOf('/', hostStart);
15✔
1034
        if (hostEnd < 0) {
6✔
1035
            return Collections.emptyList();   // host-only, nothing to strip
6✔
1036
        }
1037

1038
        // Drop the last path segment. If that strips us back to the host (single-
1039
        // segment path), return the host-only IRI as the immediate parent.
1040
        int lastSlash = s.lastIndexOf('/');
12✔
1041
        String parent = (lastSlash <= hostEnd) ? s.substring(0, hostEnd) : s.substring(0, lastSlash);
39✔
1042
        return List.of(vf.createIRI(parent));
15✔
1043
    }
1044

1045
    // ---------------- shared helpers ----------------
1046

1047
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
1048
        if (ctx.signedBy() != null) {
9!
1049
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30✔
1050
        }
1051
        if (ctx.pubkeyHash() != null) {
9!
1052
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27✔
1053
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9✔
1054
        }
1055
        if (ctx.createdAt() != null) {
9!
1056
            Literal ts = vf.createLiteral(ctx.createdAt());
15✔
1057
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27✔
1058
        }
1059
    }
3✔
1060

1061
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
1062
        for (IRI c : candidates) {
30✔
1063
            if (types.contains(c)) {
12✔
1064
                return true;
6✔
1065
            }
1066
        }
3✔
1067
        return false;
6✔
1068
    }
1069

1070
    // ---------------- load-number stamping ----------------
1071

1072
    /**
1073
     * Stamps {@code <thisNP> npa:hasLoadNumber <N>} on the given nanopub. Intended to
1074
     * be called by the loader once per nanopub, in the same transaction as the
1075
     * extraction writes. Also bumps {@code npa:thisRepo npa:currentLoadCounter <N>}
1076
     * in the admin graph so the materializer's delta cycles know the horizon.
1077
     *
1078
     * @param npId       nanopub IRI
1079
     * @param loadNumber the load counter value
1080
     * @return two statements: load-number stamp + current-load-counter value
1081
     */
1082
    public static List<Statement> loadCounterStatements(IRI npId, long loadNumber) {
1083
        List<Statement> out = new ArrayList<>(2);
15✔
1084
        Literal lit = vf.createLiteral(loadNumber);
12✔
1085
        out.add(vf.createStatement(npId, NPA.HAS_LOAD_NUMBER, lit, NPA.GRAPH));
27✔
1086
        out.add(vf.createStatement(NPA.THIS_REPO, SpacesVocab.CURRENT_LOAD_COUNTER, lit, NPA.GRAPH));
27✔
1087
        return out;
6✔
1088
    }
1089

1090
}
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