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

knowledgepixels / nanopub-query / 27336340710

11 Jun 2026 09:09AM UTC coverage: 59.604% (-0.3%) from 59.878%
27336340710

push

github

ashleycaselli
refactor(logging): replace 'log' with 'logger' for consistency across classes and improve logs in general

480 of 896 branches covered (53.57%)

Branch coverage included in aggregate %.

1416 of 2285 relevant lines covered (61.97%)

9.25 hits per line

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

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

75
    private SpacesExtractor() {
76
    }
77

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

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

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

121
        List<Statement> out = new ArrayList<>();
12 ✔
122

123
        boolean isSpace = types.contains(GEN.SPACE);
12 ✔
124
        boolean isHasRole = types.contains(GEN.HAS_ROLE);
12 ✔
125
        boolean isSpaceMemberRole = types.contains(GEN.SPACE_MEMBER_ROLE);
12 ✔
126
        boolean isRoleInstantiation = types.contains(GEN.ROLE_INSTANTIATION)
18 !
127
                                      || anyMatch(types, BackcompatRolePredicates.ALL);
18 ✔
128
        boolean isSubSpaceOf = types.contains(GEN.IS_SUB_SPACE_OF);
12 ✔
129
        // Maintained-resource nanopubs use either the resource-class marker
130
        // (gen:MaintainedResource — what Nanodash currently writes) or the
131
        // predicate marker (gen:isMaintainedBy — single-predicate-assertion
132
        // auto-typing or explicit npx:hasNanopubType). Both shapes carry the
133
        // same <r> gen:isMaintainedBy <s> triple in the assertion.
134
        boolean isMaintainedResource = types.contains(GEN.MAINTAINED_RESOURCE)
18 ✔
135
                                       || types.contains(GEN.IS_MAINTAINED_BY);
18 ✔
136

137
        if (isSpace) {
6 ✔
138
            extractSpace(np, ctx, out);
12 ✔
139
        }
140
        if (isHasRole) {
6 ✔
141
            extractHasRole(np, ctx, out);
12 ✔
142
        }
143
        if (isSpaceMemberRole) {
6 ✔
144
            extractSpaceMemberRole(np, ctx, out);
12 ✔
145
        }
146
        if (isRoleInstantiation) {
6 ✔
147
            extractRoleInstantiation(np, ctx, out);
12 ✔
148
        }
149
        if (isSubSpaceOf) {
6 ✔
150
            extractSubSpaceOf(np, ctx, out);
12 ✔
151
        }
152
        if (isMaintainedResource) {
6 ✔
153
            extractIsMaintainedBy(np, ctx, out);
12 ✔
154
        }
155

156
        return out;
6 ✔
157
    }
158

159
    // ---------------- gen:Space ----------------
160

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

169
        // Rooted case: gen:hasRootDefinition explicitly declared.
170
        for (Statement st : np.getAssertion()) {
33 ✔
171
            if (!st.getPredicate().equals(GEN.HAS_ROOT_DEFINITION)) {
15 ✔
172
                continue;
3 ✔
173
            }
174
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27 !
175
                continue;
176
            }
177
            if (!(st.getObject() instanceof IRI rootUri)) {
27 !
178
                continue;
179
            }
180
            String rootNanopubId = TrustyUriUtils.getArtifactCode(rootUri.stringValue());
12 ✔
181
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15 !
182
                logger.warn("Ignoring space {}: gen:hasRootDefinition target is not a trusty URI: {}",
×
183
                        spaceIri, rootUri);
184
                continue;
×
185
            }
186
            if (!handled.add(spaceIri)) {
12 !
187
                continue;
×
188
            }
189
            emitSpaceEntry(np, ctx, spaceIri, rootUri, rootNanopubId, adminAgents, out);
24 ✔
190
        }
3 ✔
191

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

223
    private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI rootUri,
224
                                       String rootNanopubId, List<IRI> adminAgents,
225
                                       List<Statement> out) {
226
        String spaceRef = rootNanopubId + "_" + Utils.createHash(spaceIri);
15 ✔
227
        IRI refIri = SpacesVocab.forSpaceRef(spaceRef);
9 ✔
228
        IRI defIri = SpacesVocab.forSpaceDefinition(ctx.artifactCode());
12 ✔
229

230
        // Aggregate entry: contributor-independent, reinforced on every contribution.
231
        out.add(vf.createStatement(refIri, RDF.TYPE, SpacesVocab.SPACE_REF, GRAPH));
27 ✔
232
        out.add(vf.createStatement(refIri, SpacesVocab.SPACE_IRI, spaceIri, GRAPH));
27 ✔
233
        out.add(vf.createStatement(refIri, SpacesVocab.ROOT_NANOPUB, rootUri, GRAPH));
27 ✔
234

235
        // Identity-derived path-prefix enumeration powering the URL-prefix sub-space
236
        // fallback in the materializer. Same triples on every contributor (RDF set
237
        // semantics dedups them).
238
        for (IRI prefix : enumerateIdPrefixes(spaceIri)) {
33 ✔
239
            out.add(vf.createStatement(refIri, SpacesVocab.HAS_ID_PREFIX, prefix, GRAPH));
27 ✔
240
        }
3 ✔
241

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

247
        // Embedded gen:isMaintainedBy triples in this gen:Space nanopub: emit one
248
        // MaintainedResourceDeclaration per (resourceIri, spaceIri) pair where the
249
        // object equals the Space being defined. Same shape as the standalone path.
250
        emitMaintainedResourceDeclarations(np, ctx, spaceIri, out);
15 ✔
251

252
        // Embedded owl:sameAs triples: <spaceIri> owl:sameAs <aliasIri> declares that
253
        // <aliasIri> is an alias of the Space being defined. Emit one
254
        // SpaceAliasDeclaration per (spaceIri, aliasIri) pair so the materializer can
255
        // let this space's admin authority cover roles/members attached to the alias
256
        // (issue #113). Carries provenance — the materializer gates the edge on the
257
        // declaration's publisher being an admin of the canonical space.
258
        emitSpaceAliasDeclarations(np, ctx, spaceIri, out);
15 ✔
259

260
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
261
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27 ✔
262
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27 ✔
263
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30 ✔
264
        addProvenance(defIri, ctx, out);
12 ✔
265

266
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
267
        boolean isOwnRoot = rootUri.equals(np.getUri());
15 ✔
268
        if (isOwnRoot) {
6 ✔
269
            for (IRI adminAgent : adminAgents) {
30 ✔
270
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27 ✔
271
            }
3 ✔
272
        }
273

274
        // gen:RoleInstantiation entry for the admins asserted in this gen:Space nanopub,
275
        // so admins show up in the same SPARQL pattern as ordinary admin instantiations.
276
        if (!adminAgents.isEmpty()) {
9 !
277
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12 ✔
278
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27 ✔
279
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27 ✔
280
            out.add(vf.createStatement(riIri, SpacesVocab.INVERSE_PROPERTY, GEN.HAS_ADMIN, GRAPH));
27 ✔
281
            for (IRI adminAgent : adminAgents) {
30 ✔
282
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, adminAgent, GRAPH));
27 ✔
283
            }
3 ✔
284
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30 ✔
285
            addProvenance(riIri, ctx, out);
12 ✔
286
        }
287

288
        // Inline non-hasAdmin role triples: a gen:Space nanopub may also assert
289
        // <space> <pred> <agent> (INVERSE) or <agent> <pred> <space> (REGULAR)
290
        // for any of the back-compat role predicates (has-event-facilitator,
291
        // participatedAsParticipantIn, …). Without an extraction path here those
292
        // are silently dropped because gen:Space nanopubs are not auto-typed
293
        // with back-compat predicates (only single-triple-assertion nanopubs are).
294
        // Emit one RoleInstantiation per distinct predicate found, grouping
295
        // multi-agent like the admin case. The subject is disambiguated by a
296
        // hash of the predicate IRI so multiple predicates in one nanopub don't
297
        // collide on the same npari:<artifactCode> subject as the admin RI.
298
        emitInlineRoleInstantiations(np, ctx, spaceIri, out);
15 ✔
299
    }
3 ✔
300

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

360
    /**
361
     * Heuristic: does {@code candidate} look like a Space IRI in {@code np}'s assertion,
362
     * independent of any {@code gen:hasRootDefinition} triple? We accept it if the
363
     * assertion contains {@code candidate rdf:type gen:Space} or
364
     * {@code candidate gen:hasAdmin ?x}.
365
     */
366
    private static boolean looksLikeSpaceIri(Nanopub np, IRI candidate) {
367
        for (Statement st : np.getAssertion()) {
33 ✔
368
            if (!candidate.equals(st.getSubject())) {
15 ✔
369
                continue;
3 ✔
370
            }
371
            if (st.getPredicate().equals(RDF.TYPE) && GEN.SPACE.equals(st.getObject())) {
30 !
372
                return true;
6 ✔
373
            }
374
            if (st.getPredicate().equals(GEN.HAS_ADMIN)) {
15 !
375
                return true;
×
376
            }
377
        }
3 ✔
378
        return false;
6 ✔
379
    }
380

381
    private static List<IRI> collectAdminAgents(Nanopub np) {
382
        Set<IRI> agents = new LinkedHashSet<>();
12 ✔
383
        for (Statement st : np.getAssertion()) {
33 ✔
384
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) {
15 ✔
385
                continue;
3 ✔
386
            }
387
            if (!(st.getObject() instanceof IRI agent)) {
27 !
388
                continue;
389
            }
390
            agents.add(agent);
12 ✔
391
        }
3 ✔
392
        return new ArrayList<>(agents);
15 ✔
393
    }
394

395
    // ---------------- gen:hasRole (role attachment) ----------------
396

397
    private static void extractHasRole(Nanopub np, Context ctx, List<Statement> out) {
398
        // A gen:hasRole nanopub asserts <space> gen:hasRole <role>.
399
        for (Statement st : np.getAssertion()) {
33 !
400
            if (!st.getPredicate().equals(GEN.HAS_ROLE)) {
15 !
401
                continue;
×
402
            }
403
            if (!(st.getSubject() instanceof IRI spaceIri)) {
27 !
404
                continue;
405
            }
406
            if (!(st.getObject() instanceof IRI roleIri)) {
27 !
407
                continue;
408
            }
409
            IRI subject = SpacesVocab.forRoleAssignment(ctx.artifactCode());
12 ✔
410
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_ASSIGNMENT, GRAPH));
27 ✔
411
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27 ✔
412
            out.add(vf.createStatement(subject, GEN.HAS_ROLE, roleIri, GRAPH));
27 ✔
413
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30 ✔
414
            addProvenance(subject, ctx, out);
12 ✔
415
            // One attachment per nanopub — the subject IRI is derived from the nanopub
416
            // artifact code so multiple hasRole triples in the same nanopub would collide.
417
            // If that case shows up in practice, we'll refine the subject-minting scheme.
418
            return;
3 ✔
419
        }
420
    }
×
421

422
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
423

424
    private static void extractSpaceMemberRole(Nanopub np, Context ctx, List<Statement> out) {
425
        // The role IRI is embedded in this nanopub, so look for an assertion statement
426
        // of the shape <roleIri> rdf:type gen:SpaceMemberRole where <roleIri> starts
427
        // with the nanopub IRI (valid embedded mint).
428
        IRI roleIri = null;
6 ✔
429
        for (Statement st : np.getAssertion()) {
33 ✔
430
            if (!st.getPredicate().equals(RDF.TYPE)) {
15 !
431
                continue;
×
432
            }
433
            if (!GEN.SPACE_MEMBER_ROLE.equals(st.getObject())) {
15 ✔
434
                continue;
3 ✔
435
            }
436
            if (!(st.getSubject() instanceof IRI candidate)) {
27 !
437
                continue;
438
            }
439
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) {
21 ✔
440
                continue;
3 ✔
441
            }
442
            roleIri = candidate;
6 ✔
443
            break;
3 ✔
444
        }
445
        if (roleIri == null) {
6 ✔
446
            return;
3 ✔
447
        }
448

449
        IRI roleType = findRoleTier(np, roleIri);
12 ✔
450
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15 ✔
451
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15 ✔
452

453
        IRI subject = SpacesVocab.forRoleDeclaration(ctx.artifactCode());
12 ✔
454
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DECLARATION, GRAPH));
27 ✔
455
        out.add(vf.createStatement(subject, SpacesVocab.ROLE, roleIri, GRAPH));
27 ✔
456
        out.add(vf.createStatement(subject, SpacesVocab.HAS_ROLE_TYPE, roleType, GRAPH));
27 ✔
457
        for (IRI reg : regulars) {
30 ✔
458
            out.add(vf.createStatement(subject, GEN.HAS_REGULAR_PROPERTY, reg, GRAPH));
27 ✔
459
        }
3 ✔
460
        for (IRI inv : inverses) {
30 ✔
461
            out.add(vf.createStatement(subject, GEN.HAS_INVERSE_PROPERTY, inv, GRAPH));
27 ✔
462
        }
3 ✔
463
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30 ✔
464
        if (ctx.createdAt() != null) {
9 !
465
            out.add(vf.createStatement(subject, DCTERMS.CREATED, vf.createLiteral(ctx.createdAt()), GRAPH));
36 ✔
466
        }
467
    }
3 ✔
468

469
    /**
470
     * Looks for a tier rdf:type ({@code gen:MaintainerRole} / {@code gen:MemberRole} /
471
     * {@code gen:ObserverRole}) on the role IRI in the assertion; defaults to
472
     * {@code gen:ObserverRole} if none is declared.
473
     */
474
    private static IRI findRoleTier(Nanopub np, IRI roleIri) {
475
        for (Statement st : np.getAssertion()) {
33 ✔
476
            if (!roleIri.equals(st.getSubject())) {
15 !
477
                continue;
×
478
            }
479
            if (!st.getPredicate().equals(RDF.TYPE)) {
15 !
480
                continue;
×
481
            }
482
            if (!(st.getObject() instanceof IRI type)) {
27 !
483
                continue;
484
            }
485
            if (GEN.MAINTAINER_ROLE.equals(type) || GEN.MEMBER_ROLE.equals(type)
30 !
486
                || GEN.OBSERVER_ROLE.equals(type)) {
6 !
487
                return type;
6 ✔
488
            }
489
        }
3 ✔
490
        return GEN.OBSERVER_ROLE;
6 ✔
491
    }
492

493
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
494
        List<IRI> out = new ArrayList<>();
12 ✔
495
        for (Statement st : np.getAssertion()) {
33 ✔
496
            if (!roleIri.equals(st.getSubject())) {
15 !
497
                continue;
×
498
            }
499
            if (!predicate.equals(st.getPredicate())) {
15 ✔
500
                continue;
3 ✔
501
            }
502
            if (!(st.getObject() instanceof IRI obj)) {
27 !
503
                continue;
504
            }
505
            out.add(obj);
12 ✔
506
        }
3 ✔
507
        return out;
6 ✔
508
    }
509

510
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
511

512
    private static void extractRoleInstantiation(Nanopub np, Context ctx, List<Statement> out) {
513
        // Find the assignment triple. Directionality (matches the publisher convention
514
        // used by gen:hasRegularProperty / gen:hasInverseProperty in role-definition
515
        // nanopubs):
516
        //   REGULAR: <agent> <predicate> <space>  → npa:regularProperty.
517
        //   INVERSE: <space> <predicate> <agent>  → npa:inverseProperty.
518
        // gen:hasAdmin is hardcoded INVERSE (space-centric: <space> hasAdmin <agent>).
519
        // The 14 backwards-compat predicates are classified in
520
        // {@link BackcompatRolePredicates#DIRECTIONS}. User-defined role predicates from
521
        // gen:SpaceMemberRole nanopubs aren't resolvable here without the role-declaration
522
        // registry; FIXME: the materializer in PR 2 should refine direction for the
523
        // typed-but-unknown-predicate case. For now we emit only triples whose predicate
524
        // we know the direction of.
525
        for (Statement st : np.getAssertion()) {
33 !
526
            IRI predicate = st.getPredicate();
9 ✔
527
            BackcompatRolePredicates.Direction direction = directionFor(predicate);
9 ✔
528
            if (direction == null) {
6 !
529
                continue;
×
530
            }
531
            if (!(st.getSubject() instanceof IRI subjIri)) {
27 !
532
                continue;
533
            }
534
            if (!(st.getObject() instanceof IRI objIri)) {
27 !
535
                continue;
536
            }
537

538
            IRI spaceSide;
539
            IRI agentSide;
540
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9 ✔
541
                agentSide = subjIri;
6 ✔
542
                spaceSide = objIri;
9 ✔
543
            } else {
544
                spaceSide = subjIri;
6 ✔
545
                agentSide = objIri;
6 ✔
546
            }
547

548
            // Deduplicate against the (possibly already emitted) admin instantiation
549
            // from the gen:Space path — a single nanopub can be typed gen:Space AND
550
            // have a gen:hasAdmin triple that the backcompat list also catches. The
551
            // subject IRI is the same (derived from artifact code) and the payload
552
            // would conflict if re-emitted. Skip if we already have a RoleInstantiation
553
            // entry on this subject.
554
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12 ✔
555
            Statement typeSt = vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH);
21 ✔
556
            if (out.contains(typeSt)) {
12 !
557
                return;
×
558
            }
559

560
            out.add(typeSt);
12 ✔
561
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27 ✔
562
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9 ✔
563
                    ? SpacesVocab.REGULAR_PROPERTY
6 ✔
564
                    : SpacesVocab.INVERSE_PROPERTY;
6 ✔
565
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27 ✔
566
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27 ✔
567
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30 ✔
568
            addProvenance(subject, ctx, out);
12 ✔
569
            return;
3 ✔
570
        }
571
    }
×
572

573
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
574
        if (GEN.HAS_ADMIN.equals(predicate)) {
12 !
575
            return BackcompatRolePredicates.Direction.INVERSE;
×
576
        }
577
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15 ✔
578
    }
579

580
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
581

582
    /**
583
     * Standalone {@code gen:isSubSpaceOf} nanopub: every
584
     * {@code <childIri> gen:isSubSpaceOf <parentIri>} triple in the assertion emits one
585
     * {@code npa:SubSpaceDeclaration}. Multi-triple assertions are allowed; one entry
586
     * per pair. Self-loops ({@code <X> gen:isSubSpaceOf <X>}) are rejected.
587
     */
588
    private static void extractSubSpaceOf(Nanopub np, Context ctx, List<Statement> out) {
589
        for (Statement st : np.getAssertion()) {
33 ✔
590
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) {
15 !
591
                continue;
×
592
            }
593
            if (!(st.getSubject() instanceof IRI childIri)) {
27 !
594
                continue;
595
            }
596
            if (!(st.getObject() instanceof IRI parentIri)) {
27 !
597
                continue;
598
            }
599
            emitSubSpaceDeclaration(np, ctx, childIri, parentIri, out);
18 ✔
600
        }
3 ✔
601
    }
3 ✔
602

603
    /**
604
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
605
     * {@code <spaceIri> gen:isSubSpaceOf <parentIri>} triples (subject must equal the
606
     * Space IRI we're emitting an entry for, so the subspace declaration is bound to
607
     * this particular Space). Self-loops are rejected.
608
     */
609
    private static void emitSubSpaceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
610
                                                 List<Statement> out) {
611
        for (Statement st : np.getAssertion()) {
33 ✔
612
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) {
15 ✔
613
                continue;
3 ✔
614
            }
615
            if (!spaceIri.equals(st.getSubject())) {
15 ✔
616
                continue;
3 ✔
617
            }
618
            if (!(st.getObject() instanceof IRI parentIri)) {
27 !
619
                continue;
620
            }
621
            emitSubSpaceDeclaration(np, ctx, spaceIri, parentIri, out);
18 ✔
622
        }
3 ✔
623
    }
3 ✔
624

625
    /**
626
     * Emits one {@code npa:SubSpaceDeclaration} entry, keyed by
627
     * {@code (artifactCode, parentHash)} so a single nanopub can declare multiple
628
     * parents without subject collision. Self-loops are silently dropped.
629
     */
630
    private static void emitSubSpaceDeclaration(Nanopub np, Context ctx, IRI childIri,
631
                                                IRI parentIri, List<Statement> out) {
632
        if (childIri.equals(parentIri)) {
12 ✔
633
            logger.debug("Ignoring self-loop sub-space declaration on {} in {}", childIri, np.getUri());
18 ✔
634
            return;
3 ✔
635
        }
636
        String parentHash = Utils.createHash(parentIri);
9 ✔
637
        IRI subject = SpacesVocab.forSubSpaceDeclaration(ctx.artifactCode(), parentHash);
15 ✔
638

639
        // Idempotence: the embedded and standalone paths can both fire on the same
640
        // (np, child, parent) combination if a gen:Space nanopub somehow ends up typed
641
        // gen:isSubSpaceOf as well. Skip if we've already emitted the type triple for
642
        // this subject.
643
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SUB_SPACE_DECLARATION, GRAPH);
21 ✔
644
        if (out.contains(typeSt)) {
12 !
645
            return;
×
646
        }
647

648
        out.add(typeSt);
12 ✔
649
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27 ✔
650
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27 ✔
651
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30 ✔
652
        addProvenance(subject, ctx, out);
12 ✔
653
    }
3 ✔
654

655
    // ---------------- gen:isMaintainedBy ----------------
656

657
    /**
658
     * Standalone {@code gen:isMaintainedBy} nanopub: every
659
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triple in the assertion emits
660
     * one {@code npa:MaintainedResourceDeclaration}. Multi-triple assertions are
661
     * allowed; one entry per pair. Self-loops ({@code <X> gen:isMaintainedBy <X>}) are
662
     * rejected.
663
     */
664
    private static void extractIsMaintainedBy(Nanopub np, Context ctx, List<Statement> out) {
665
        for (Statement st : np.getAssertion()) {
33 ✔
666
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) {
15 ✔
667
                continue;
3 ✔
668
            }
669
            if (!(st.getSubject() instanceof IRI resourceIri)) {
27 !
670
                continue;
671
            }
672
            if (!(st.getObject() instanceof IRI spaceIri)) {
27 !
673
                continue;
674
            }
675
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18 ✔
676
        }
3 ✔
677
    }
3 ✔
678

679
    /**
680
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
681
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triples (object must equal
682
     * the Space IRI we're emitting an entry for, so the maintained-resource
683
     * declaration is bound to this particular Space). Self-loops are rejected.
684
     */
685
    private static void emitMaintainedResourceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
686
                                                           List<Statement> out) {
687
        for (Statement st : np.getAssertion()) {
33 ✔
688
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) {
15 ✔
689
                continue;
3 ✔
690
            }
691
            if (!spaceIri.equals(st.getObject())) {
15 ✔
692
                continue;
3 ✔
693
            }
694
            if (!(st.getSubject() instanceof IRI resourceIri)) {
27 !
695
                continue;
696
            }
697
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18 ✔
698
        }
3 ✔
699
    }
3 ✔
700

701
    /**
702
     * Emits one {@code npa:MaintainedResourceDeclaration} entry, keyed by
703
     * {@code (artifactCode, resourceHash)} so a single nanopub can declare multiple
704
     * maintained resources without subject collision. Self-loops are silently dropped.
705
     */
706
    private static void emitMaintainedResourceDeclaration(Nanopub np, Context ctx, IRI resourceIri,
707
                                                          IRI spaceIri, List<Statement> out) {
708
        if (resourceIri.equals(spaceIri)) {
12 ✔
709
            logger.debug("Ignoring self-loop maintained-resource declaration on {} in {}",
15 ✔
710
                    resourceIri, np.getUri());
3 ✔
711
            return;
3 ✔
712
        }
713
        String resourceHash = Utils.createHash(resourceIri);
9 ✔
714
        IRI subject = SpacesVocab.forMaintainedResourceDeclaration(ctx.artifactCode(), resourceHash);
15 ✔
715

716
        // Idempotence: the embedded (gen:Space) and standalone (gen:isMaintainedBy)
717
        // paths can both fire on the same (np, resource, space) combination if a
718
        // gen:Space nanopub somehow ends up typed gen:isMaintainedBy as well. Skip if
719
        // we've already emitted the type triple for this subject.
720
        Statement typeSt = vf.createStatement(subject, RDF.TYPE,
21 ✔
721
                SpacesVocab.MAINTAINED_RESOURCE_DECLARATION, GRAPH);
722
        if (out.contains(typeSt)) {
12 !
723
            return;
×
724
        }
725

726
        out.add(typeSt);
12 ✔
727
        out.add(vf.createStatement(subject, SpacesVocab.RESOURCE_IRI, resourceIri, GRAPH));
27 ✔
728
        out.add(vf.createStatement(subject, SpacesVocab.MAINTAINER_SPACE, spaceIri, GRAPH));
27 ✔
729
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30 ✔
730
        addProvenance(subject, ctx, out);
12 ✔
731
    }
3 ✔
732

733
    // ---------------- owl:sameAs (space aliases) ----------------
734

735
    /**
736
     * Scans a {@code gen:Space} nanopub's assertion for
737
     * {@code <spaceIri> owl:sameAs <aliasIri>} triples (subject must equal the Space IRI
738
     * being emitted, so the alias declaration is bound to this particular Space) and emits
739
     * one {@code npa:SpaceAliasDeclaration} per {@code (spaceIri, aliasIri)} pair. The
740
     * Space IRI is the canonical side; the {@code owl:sameAs} object is the alias.
741
     * Self-aliases ({@code <X> owl:sameAs <X>}) are rejected.
742
     */
743
    private static void emitSpaceAliasDeclarations(Nanopub np, Context ctx, IRI spaceIri,
744
                                                   List<Statement> out) {
745
        for (Statement st : np.getAssertion()) {
33 ✔
746
            if (!st.getPredicate().equals(OWL.SAMEAS)) {
15 ✔
747
                continue;
3 ✔
748
            }
749
            if (!spaceIri.equals(st.getSubject())) {
15 ✔
750
                continue;
3 ✔
751
            }
752
            if (!(st.getObject() instanceof IRI aliasIri)) {
27 !
753
                continue;
754
            }
755
            emitSpaceAliasDeclaration(np, ctx, spaceIri, aliasIri, out);
18 ✔
756
        }
3 ✔
757
    }
3 ✔
758

759
    /**
760
     * Emits one {@code npa:SpaceAliasDeclaration} entry, keyed by
761
     * {@code (artifactCode, aliasHash)} so a single nanopub can declare multiple aliases
762
     * without subject collision. Self-aliases are silently dropped.
763
     */
764
    private static void emitSpaceAliasDeclaration(Nanopub np, Context ctx, IRI canonicalIri,
765
                                                  IRI aliasIri, List<Statement> out) {
766
        if (canonicalIri.equals(aliasIri)) {
12 ✔
767
            logger.debug("Ignoring self-alias declaration on {} in {}", canonicalIri, np.getUri());
18 ✔
768
            return;
3 ✔
769
        }
770
        String aliasHash = Utils.createHash(aliasIri);
9 ✔
771
        IRI subject = SpacesVocab.forSpaceAliasDeclaration(ctx.artifactCode(), aliasHash);
15 ✔
772

773
        // Idempotence: a single (np, canonical, alias) combination should produce one entry
774
        // even if emitSpaceAliasDeclarations somehow sees the triple twice.
775
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SPACE_ALIAS_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.CANONICAL_SPACE, canonicalIri, GRAPH));
27 ✔
782
        out.add(vf.createStatement(subject, SpacesVocab.ALIAS_SPACE, aliasIri, 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
    // ---------------- ID-prefix enumeration ----------------
788

789
    /**
790
     * Returns the immediate URL-path parent of a Space IRI, after normalisation,
791
     * for the URL-prefix sub-space fallback. Strips query / fragment / trailing
792
     * slash, then drops the last path segment after the {@code ://} scheme
793
     * separator. Returns at most one IRI; empty for inputs without a scheme
794
     * separator or without any path beyond the host.
795
     *
796
     * <p>Direct-parent-only semantics matches Nanodash's existing
797
     * {@code SpaceRepository.findSubspaces(...)} URL-regex behaviour. Multi-level
798
     * containment queries should use SPARQL property paths
799
     * ({@code <ancestor> npa:hasSubSpace+ ?descendant}) which walk the chain
800
     * transitively, so deeper descendants remain reachable as long as the
801
     * intermediate Spaces exist.
802
     *
803
     * <p>Examples:
804
     * <pre>
805
     *   https://example.org/a/b/c/space  →  [https://example.org/a/b/c]
806
     *   https://example.org/space        →  [https://example.org]   (single segment → host)
807
     *   https://example.org/x/           →  [https://example.org]   (trailing slash stripped)
808
     *   https://example.org/a/space?q=1  →  [https://example.org/a] (query stripped)
809
     *   https://example.org              →  []                       (no path to strip)
810
     * </pre>
811
     */
812
    static List<IRI> enumerateIdPrefixes(IRI spaceIri) {
813
        String s = spaceIri.stringValue();
9 ✔
814
        int hash = s.indexOf('#');
12 ✔
815
        if (hash >= 0) {
6 ✔
816
            s = s.substring(0, hash);
15 ✔
817
        }
818
        int qmark = s.indexOf('?');
12 ✔
819
        if (qmark >= 0) {
6 ✔
820
            s = s.substring(0, qmark);
15 ✔
821
        }
822
        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
39 ✔
823

824
        int schemeEnd = s.indexOf("://");
12 ✔
825
        if (schemeEnd < 0) {
6 ✔
826
            return Collections.emptyList();
6 ✔
827
        }
828
        int hostStart = schemeEnd + 3;
12 ✔
829
        int hostEnd = s.indexOf('/', hostStart);
15 ✔
830
        if (hostEnd < 0) {
6 ✔
831
            return Collections.emptyList();   // host-only, nothing to strip
6 ✔
832
        }
833

834
        // Drop the last path segment. If that strips us back to the host (single-
835
        // segment path), return the host-only IRI as the immediate parent.
836
        int lastSlash = s.lastIndexOf('/');
12 ✔
837
        String parent = (lastSlash <= hostEnd) ? s.substring(0, hostEnd) : s.substring(0, lastSlash);
39 ✔
838
        return List.of(vf.createIRI(parent));
15 ✔
839
    }
840

841
    // ---------------- shared helpers ----------------
842

843
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
844
        if (ctx.signedBy() != null) {
9 !
845
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30 ✔
846
        }
847
        if (ctx.pubkeyHash() != null) {
9 !
848
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27 ✔
849
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9 ✔
850
        }
851
        if (ctx.createdAt() != null) {
9 !
852
            Literal ts = vf.createLiteral(ctx.createdAt());
15 ✔
853
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27 ✔
854
        }
855
    }
3 ✔
856

857
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
858
        for (IRI c : candidates) {
30 ✔
859
            if (types.contains(c)) {
12 ✔
860
                return true;
6 ✔
861
            }
862
        }
3 ✔
863
        return false;
6 ✔
864
    }
865

866
    // ---------------- load-number stamping ----------------
867

868
    /**
869
     * Stamps {@code <thisNP> npa:hasLoadNumber <N>} on the given nanopub. Intended to
870
     * be called by the loader once per nanopub, in the same transaction as the
871
     * extraction writes. Also bumps {@code npa:thisRepo npa:currentLoadCounter <N>}
872
     * in the admin graph so the materializer's delta cycles know the horizon.
873
     *
874
     * @param npId       nanopub IRI
875
     * @param loadNumber the load counter value
876
     * @return two statements: load-number stamp + current-load-counter value
877
     */
878
    public static List<Statement> loadCounterStatements(IRI npId, long loadNumber) {
879
        List<Statement> out = new ArrayList<>(2);
15 ✔
880
        Literal lit = vf.createLiteral(loadNumber);
12 ✔
881
        out.add(vf.createStatement(npId, NPA.HAS_LOAD_NUMBER, lit, NPA.GRAPH));
27 ✔
882
        out.add(vf.createStatement(NPA.THIS_REPO, SpacesVocab.CURRENT_LOAD_COUNTER, lit, NPA.GRAPH));
27 ✔
883
        return out;
6 ✔
884
    }
885

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

© 2026 Coveralls, Inc