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

knowledgepixels / nanopub-query / 25426025052

06 May 2026 09:00AM UTC coverage: 57.984% (+1.6%) from 56.362%
25426025052

push

github

web-flow
Merge pull request #94 from knowledgepixels/feature/93-subspace-extraction

Extract gen:isSubSpaceOf declarations and id-prefix triples (#93, PR 1/3)

467 of 884 branches covered (52.83%)

Branch coverage included in aggregate %.

1247 of 2072 relevant lines covered (60.18%)

9.22 hits per line

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

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

3
import java.util.ArrayList;
4
import java.util.Collection;
5
import java.util.Collections;
6
import java.util.Date;
7
import java.util.LinkedHashSet;
8
import java.util.List;
9
import java.util.Set;
10

11
import org.eclipse.rdf4j.model.IRI;
12
import org.eclipse.rdf4j.model.Literal;
13
import org.eclipse.rdf4j.model.Resource;
14
import org.eclipse.rdf4j.model.Statement;
15
import org.eclipse.rdf4j.model.ValueFactory;
16
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
17
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
18
import org.eclipse.rdf4j.model.vocabulary.RDF;
19
import org.nanopub.Nanopub;
20
import org.nanopub.NanopubUtils;
21
import org.nanopub.vocabulary.NPA;
22
import org.nanopub.vocabulary.NPX;
23
import org.slf4j.Logger;
24
import org.slf4j.LoggerFactory;
25

26
import com.knowledgepixels.query.vocabulary.BackcompatRolePredicates;
27
import com.knowledgepixels.query.vocabulary.GEN;
28
import com.knowledgepixels.query.vocabulary.SpacesVocab;
29

30
import net.trustyuri.TrustyUriUtils;
31

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

52
    private static final Logger log = LoggerFactory.getLogger(SpacesExtractor.class);
9✔
53

54
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
55

56
    private static final IRI GRAPH = SpacesVocab.SPACES_GRAPH;
9✔
57

58
    private SpacesExtractor() {
59
    }
60

61
    /**
62
     * Bundles the information a single extraction needs beyond the nanopub itself.
63
     *
64
     * @param artifactCode trusty-URI artifact code of {@code np} (used for minting
65
     *                     {@code npari:}/{@code npara:}/{@code npard:}/{@code npadef:}
66
     *                     subject IRIs).
67
     * @param signedBy     signer agent IRI from pubinfo, or {@code null} if absent.
68
     * @param pubkeyHash   hash of the signing public key, or {@code null} if absent.
69
     * @param createdAt    creation timestamp, or {@code null} if the nanopub lacks one.
70
     */
71
    public record Context(String artifactCode, IRI signedBy, String pubkeyHash, Date createdAt) {
45✔
72
    }
73

74
    /**
75
     * Runs the extractor on a loaded nanopub. Returns an empty list if the nanopub is
76
     * not space-relevant.
77
     *
78
     * @param np  the nanopub to inspect
79
     * @param ctx the extraction context
80
     * @return statements to write into {@code npa:spacesGraph}
81
     */
82
    public static List<Statement> extract(Nanopub np, Context ctx) {
83
        Set<IRI> types = NanopubUtils.getTypes(np);
9✔
84
        List<Statement> out = new ArrayList<>();
12✔
85

86
        boolean isSpace = types.contains(GEN.SPACE);
12✔
87
        boolean isHasRole = types.contains(GEN.HAS_ROLE);
12✔
88
        boolean isSpaceMemberRole = types.contains(GEN.SPACE_MEMBER_ROLE);
12✔
89
        boolean isRoleInstantiation = types.contains(GEN.ROLE_INSTANTIATION)
18!
90
                || anyMatch(types, BackcompatRolePredicates.ALL);
18✔
91
        boolean isSubSpaceOf = types.contains(GEN.IS_SUB_SPACE_OF);
12✔
92

93
        if (!isSpace && !isHasRole && !isSpaceMemberRole && !isRoleInstantiation && !isSubSpaceOf) {
30✔
94
            return Collections.emptyList();
6✔
95
        }
96

97
        if (isSpace) extractSpace(np, ctx, out);
18✔
98
        if (isHasRole) extractHasRole(np, ctx, out);
18✔
99
        if (isSpaceMemberRole) extractSpaceMemberRole(np, ctx, out);
18✔
100
        if (isRoleInstantiation) extractRoleInstantiation(np, ctx, out);
18✔
101
        if (isSubSpaceOf) extractSubSpaceOf(np, ctx, out);
18✔
102

103
        return out;
6✔
104
    }
105

106
    /**
107
     * Emits the {@link SpacesVocab#INVALIDATION} entry for an invalidator nanopub
108
     * whose target has at least one space-relevant type. Caller (the loader's
109
     * invalidation-propagation loop) passes in the types of the invalidated
110
     * nanopub so we can check space-relevance without re-reading the meta repo.
111
     *
112
     * @param thisNp        the invalidator nanopub
113
     * @param invalidatedNp URI of the nanopub being invalidated
114
     * @param targetTypes   types of the invalidated nanopub (from the meta repo)
115
     * @param ctx           extraction context for the invalidator
116
     * @return the invalidation entry statements, or empty if no target type is space-relevant
117
     */
118
    public static List<Statement> extractInvalidation(Nanopub thisNp, IRI invalidatedNp,
119
                                                      Set<IRI> targetTypes, Context ctx) {
120
        if (!isSpaceRelevant(targetTypes)) return Collections.emptyList();
15✔
121
        IRI subject = SpacesVocab.forInvalidation(ctx.artifactCode());
12✔
122
        List<Statement> out = new ArrayList<>();
12✔
123
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.INVALIDATION, GRAPH));
27✔
124
        out.add(vf.createStatement(subject, SpacesVocab.INVALIDATES, invalidatedNp, GRAPH));
27✔
125
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, thisNp.getUri(), GRAPH));
30✔
126
        addProvenance(subject, ctx, out);
12✔
127
        return out;
6✔
128
    }
129

130
    /** True iff any type in {@code types} is a predefined type or a backwards-compat predicate. */
131
    public static boolean isSpaceRelevant(Set<IRI> types) {
132
        return types.contains(GEN.SPACE)
21✔
133
                || types.contains(GEN.HAS_ROLE)
12✔
134
                || types.contains(GEN.SPACE_MEMBER_ROLE)
12✔
135
                || types.contains(GEN.ROLE_INSTANTIATION)
12✔
136
                || types.contains(GEN.IS_SUB_SPACE_OF)
12✔
137
                || anyMatch(types, BackcompatRolePredicates.ALL);
15✔
138
    }
139

140
    // ---------------- gen:Space ----------------
141

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

150
        // Rooted case: gen:hasRootDefinition explicitly declared.
151
        for (Statement st : np.getAssertion()) {
33✔
152
            if (!st.getPredicate().equals(GEN.HAS_ROOT_DEFINITION)) continue;
18✔
153
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
154
            if (!(st.getObject() instanceof IRI rootUri)) continue;
27!
155
            String rootNanopubId = TrustyUriUtils.getArtifactCode(rootUri.stringValue());
12✔
156
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
157
                log.warn("Ignoring space {}: gen:hasRootDefinition target is not a trusty URI: {}",
×
158
                        spaceIri, rootUri);
159
                continue;
×
160
            }
161
            if (!handled.add(spaceIri)) continue;
12!
162
            emitSpaceEntry(np, ctx, spaceIri, rootUri, rootNanopubId, adminAgents, out);
24✔
163
        }
3✔
164

165
        // Rootless transition case: any Space IRI in the assertion that didn't get a
166
        // hasRootDefinition triple is treated as if it were its own root. Detect by
167
        // looking for triples that reference a Space IRI we haven't handled yet —
168
        // typically via gen:hasAdmin subjects or the rdf:type gen:Space triple on a
169
        // blank-node assertion subject. The common template publishes the Space IRI
170
        // as the subject of at least one triple in the assertion, so we scan for that.
171
        for (Statement st : np.getAssertion()) {
33✔
172
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
173
            if (handled.contains(spaceIri)) continue;
15✔
174
            // Skip IRIs that clearly aren't Space IRIs (role IRIs embedded in this nanopub).
175
            if (spaceIri.stringValue().startsWith(np.getUri().stringValue())) continue;
21!
176
            // Require at least one structural signal that this is a Space IRI:
177
            // an rdf:type gen:Space, or a gen:hasAdmin triple with this as subject.
178
            if (!looksLikeSpaceIri(np, spaceIri)) continue;
15✔
179
            handled.add(spaceIri);
12✔
180
            String rootNanopubId = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
181
            if (rootNanopubId == null || rootNanopubId.isEmpty()) continue;
15!
182
            emitSpaceEntry(np, ctx, spaceIri, np.getUri(), rootNanopubId, adminAgents, out);
27✔
183
        }
3✔
184
    }
3✔
185

186
    private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI rootUri,
187
                                       String rootNanopubId, List<IRI> adminAgents,
188
                                       List<Statement> out) {
189
        String spaceRef = rootNanopubId + "_" + Utils.createHash(spaceIri);
15✔
190
        IRI refIri = SpacesVocab.forSpaceRef(spaceRef);
9✔
191
        IRI defIri = SpacesVocab.forSpaceDefinition(ctx.artifactCode());
12✔
192

193
        // Aggregate entry: contributor-independent, reinforced on every contribution.
194
        out.add(vf.createStatement(refIri, RDF.TYPE, SpacesVocab.SPACE_REF, GRAPH));
27✔
195
        out.add(vf.createStatement(refIri, SpacesVocab.SPACE_IRI, spaceIri, GRAPH));
27✔
196
        out.add(vf.createStatement(refIri, SpacesVocab.ROOT_NANOPUB, rootUri, GRAPH));
27✔
197

198
        // Identity-derived path-prefix enumeration powering the URL-prefix sub-space
199
        // fallback in the materializer. Same triples on every contributor (RDF set
200
        // semantics dedups them).
201
        for (IRI prefix : enumerateIdPrefixes(spaceIri)) {
33✔
202
            out.add(vf.createStatement(refIri, SpacesVocab.HAS_ID_PREFIX, prefix, GRAPH));
27✔
203
        }
3✔
204

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

210
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
211
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27✔
212
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27✔
213
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
214
        addProvenance(defIri, ctx, out);
12✔
215

216
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
217
        boolean isOwnRoot = rootUri.equals(np.getUri());
15✔
218
        if (isOwnRoot) {
6✔
219
            for (IRI adminAgent : adminAgents) {
30✔
220
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27✔
221
            }
3✔
222
        }
223

224
        // gen:RoleInstantiation entry for the admins asserted in this gen:Space nanopub,
225
        // so admins show up in the same SPARQL pattern as ordinary admin instantiations.
226
        if (!adminAgents.isEmpty()) {
9!
227
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
228
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
229
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
230
            out.add(vf.createStatement(riIri, SpacesVocab.INVERSE_PROPERTY, GEN.HAS_ADMIN, GRAPH));
27✔
231
            for (IRI adminAgent : adminAgents) {
30✔
232
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, adminAgent, GRAPH));
27✔
233
            }
3✔
234
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
235
            addProvenance(riIri, ctx, out);
12✔
236
        }
237
    }
3✔
238

239
    /**
240
     * Heuristic: does {@code candidate} look like a Space IRI in {@code np}'s assertion,
241
     * independent of any {@code gen:hasRootDefinition} triple? We accept it if the
242
     * assertion contains {@code candidate rdf:type gen:Space} or
243
     * {@code candidate gen:hasAdmin ?x}.
244
     */
245
    private static boolean looksLikeSpaceIri(Nanopub np, IRI candidate) {
246
        for (Statement st : np.getAssertion()) {
33✔
247
            if (!candidate.equals(st.getSubject())) continue;
18✔
248
            if (st.getPredicate().equals(RDF.TYPE) && GEN.SPACE.equals(st.getObject())) return true;
36!
249
            if (st.getPredicate().equals(GEN.HAS_ADMIN)) return true;
15!
250
        }
3✔
251
        return false;
6✔
252
    }
253

254
    private static List<IRI> collectAdminAgents(Nanopub np) {
255
        Set<IRI> agents = new LinkedHashSet<>();
12✔
256
        for (Statement st : np.getAssertion()) {
33✔
257
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) continue;
18✔
258
            if (!(st.getObject() instanceof IRI agent)) continue;
27!
259
            agents.add(agent);
12✔
260
        }
3✔
261
        return new ArrayList<>(agents);
15✔
262
    }
263

264
    // ---------------- gen:hasRole (role attachment) ----------------
265

266
    private static void extractHasRole(Nanopub np, Context ctx, List<Statement> out) {
267
        // A gen:hasRole nanopub asserts <space> gen:hasRole <role>.
268
        for (Statement st : np.getAssertion()) {
33!
269
            if (!st.getPredicate().equals(GEN.HAS_ROLE)) continue;
15!
270
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
271
            if (!(st.getObject() instanceof IRI roleIri)) continue;
27!
272
            IRI subject = SpacesVocab.forRoleAssignment(ctx.artifactCode());
12✔
273
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_ASSIGNMENT, GRAPH));
27✔
274
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
275
            out.add(vf.createStatement(subject, GEN.HAS_ROLE, roleIri, GRAPH));
27✔
276
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
277
            addProvenance(subject, ctx, out);
12✔
278
            // One attachment per nanopub — the subject IRI is derived from the nanopub
279
            // artifact code so multiple hasRole triples in the same nanopub would collide.
280
            // If that case shows up in practice, we'll refine the subject-minting scheme.
281
            return;
3✔
282
        }
283
    }
×
284

285
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
286

287
    private static void extractSpaceMemberRole(Nanopub np, Context ctx, List<Statement> out) {
288
        // The role IRI is embedded in this nanopub, so look for an assertion statement
289
        // of the shape <roleIri> rdf:type gen:SpaceMemberRole where <roleIri> starts
290
        // with the nanopub IRI (valid embedded mint).
291
        IRI roleIri = null;
6✔
292
        for (Statement st : np.getAssertion()) {
33✔
293
            if (!st.getPredicate().equals(RDF.TYPE)) continue;
15!
294
            if (!GEN.SPACE_MEMBER_ROLE.equals(st.getObject())) continue;
18✔
295
            if (!(st.getSubject() instanceof IRI candidate)) continue;
27!
296
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) continue;
24✔
297
            roleIri = candidate;
6✔
298
            break;
3✔
299
        }
300
        if (roleIri == null) return;
9✔
301

302
        IRI roleType = findRoleTier(np, roleIri);
12✔
303
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15✔
304
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15✔
305

306
        IRI subject = SpacesVocab.forRoleDeclaration(ctx.artifactCode());
12✔
307
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DECLARATION, GRAPH));
27✔
308
        out.add(vf.createStatement(subject, SpacesVocab.ROLE, roleIri, GRAPH));
27✔
309
        out.add(vf.createStatement(subject, SpacesVocab.HAS_ROLE_TYPE, roleType, GRAPH));
27✔
310
        for (IRI reg : regulars) {
30✔
311
            out.add(vf.createStatement(subject, GEN.HAS_REGULAR_PROPERTY, reg, GRAPH));
27✔
312
        }
3✔
313
        for (IRI inv : inverses) {
30✔
314
            out.add(vf.createStatement(subject, GEN.HAS_INVERSE_PROPERTY, inv, GRAPH));
27✔
315
        }
3✔
316
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
317
        if (ctx.createdAt() != null) {
9!
318
            out.add(vf.createStatement(subject, DCTERMS.CREATED, vf.createLiteral(ctx.createdAt()), GRAPH));
36✔
319
        }
320
    }
3✔
321

322
    /**
323
     * Looks for a tier rdf:type ({@code gen:MaintainerRole} / {@code gen:MemberRole} /
324
     * {@code gen:ObserverRole}) on the role IRI in the assertion; defaults to
325
     * {@code gen:ObserverRole} if none is declared.
326
     */
327
    private static IRI findRoleTier(Nanopub np, IRI roleIri) {
328
        for (Statement st : np.getAssertion()) {
33✔
329
            if (!roleIri.equals(st.getSubject())) continue;
15!
330
            if (!st.getPredicate().equals(RDF.TYPE)) continue;
15!
331
            if (!(st.getObject() instanceof IRI type)) continue;
27!
332
            if (GEN.MAINTAINER_ROLE.equals(type) || GEN.MEMBER_ROLE.equals(type)
30!
333
                    || GEN.OBSERVER_ROLE.equals(type)) {
6!
334
                return type;
6✔
335
            }
336
        }
3✔
337
        return GEN.OBSERVER_ROLE;
6✔
338
    }
339

340
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
341
        List<IRI> out = new ArrayList<>();
12✔
342
        for (Statement st : np.getAssertion()) {
33✔
343
            if (!roleIri.equals(st.getSubject())) continue;
15!
344
            if (!predicate.equals(st.getPredicate())) continue;
18✔
345
            if (!(st.getObject() instanceof IRI obj)) continue;
27!
346
            out.add(obj);
12✔
347
        }
3✔
348
        return out;
6✔
349
    }
350

351
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
352

353
    private static void extractRoleInstantiation(Nanopub np, Context ctx, List<Statement> out) {
354
        // Find the assignment triple. Directionality (matches the publisher convention
355
        // used by gen:hasRegularProperty / gen:hasInverseProperty in role-definition
356
        // nanopubs):
357
        //   REGULAR: <agent> <predicate> <space>  → npa:regularProperty.
358
        //   INVERSE: <space> <predicate> <agent>  → npa:inverseProperty.
359
        // gen:hasAdmin is hardcoded INVERSE (space-centric: <space> hasAdmin <agent>).
360
        // The 14 backwards-compat predicates are classified in
361
        // {@link BackcompatRolePredicates#DIRECTIONS}. User-defined role predicates from
362
        // gen:SpaceMemberRole nanopubs aren't resolvable here without the role-declaration
363
        // registry; FIXME: the materializer in PR 2 should refine direction for the
364
        // typed-but-unknown-predicate case. For now we emit only triples whose predicate
365
        // we know the direction of.
366
        for (Statement st : np.getAssertion()) {
33!
367
            IRI predicate = st.getPredicate();
9✔
368
            BackcompatRolePredicates.Direction direction = directionFor(predicate);
9✔
369
            if (direction == null) continue;
6!
370
            if (!(st.getSubject() instanceof IRI subjIri)) continue;
27!
371
            if (!(st.getObject() instanceof IRI objIri)) continue;
27!
372

373
            IRI spaceSide;
374
            IRI agentSide;
375
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
376
                agentSide = subjIri;
6✔
377
                spaceSide = objIri;
9✔
378
            } else {
379
                spaceSide = subjIri;
6✔
380
                agentSide = objIri;
6✔
381
            }
382

383
            // Deduplicate against the (possibly already emitted) admin instantiation
384
            // from the gen:Space path — a single nanopub can be typed gen:Space AND
385
            // have a gen:hasAdmin triple that the backcompat list also catches. The
386
            // subject IRI is the same (derived from artifact code) and the payload
387
            // would conflict if re-emitted. Skip if we already have a RoleInstantiation
388
            // entry on this subject.
389
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
390
            Statement typeSt = vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH);
21✔
391
            if (out.contains(typeSt)) return;
12!
392

393
            out.add(typeSt);
12✔
394
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
395
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
396
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
397
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
398
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
399
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27✔
400
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
401
            addProvenance(subject, ctx, out);
12✔
402
            return;
3✔
403
        }
404
    }
×
405

406
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
407
        if (GEN.HAS_ADMIN.equals(predicate)) return BackcompatRolePredicates.Direction.INVERSE;
12!
408
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
409
    }
410

411
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
412

413
    /**
414
     * Standalone {@code gen:isSubSpaceOf} nanopub: every
415
     * {@code <childIri> gen:isSubSpaceOf <parentIri>} triple in the assertion emits one
416
     * {@code npa:SubSpaceDeclaration}. Multi-triple assertions are allowed; one entry
417
     * per pair. Self-loops ({@code <X> gen:isSubSpaceOf <X>}) are rejected.
418
     */
419
    private static void extractSubSpaceOf(Nanopub np, Context ctx, List<Statement> out) {
420
        for (Statement st : np.getAssertion()) {
33✔
421
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) continue;
15!
422
            if (!(st.getSubject() instanceof IRI childIri)) continue;
27!
423
            if (!(st.getObject() instanceof IRI parentIri)) continue;
27!
424
            emitSubSpaceDeclaration(np, ctx, childIri, parentIri, out);
18✔
425
        }
3✔
426
    }
3✔
427

428
    /**
429
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
430
     * {@code <spaceIri> gen:isSubSpaceOf <parentIri>} triples (subject must equal the
431
     * Space IRI we're emitting an entry for, so the subspace declaration is bound to
432
     * this particular Space). Self-loops are rejected.
433
     */
434
    private static void emitSubSpaceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
435
                                                 List<Statement> out) {
436
        for (Statement st : np.getAssertion()) {
33✔
437
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) continue;
18✔
438
            if (!spaceIri.equals(st.getSubject())) continue;
18✔
439
            if (!(st.getObject() instanceof IRI parentIri)) continue;
27!
440
            emitSubSpaceDeclaration(np, ctx, spaceIri, parentIri, out);
18✔
441
        }
3✔
442
    }
3✔
443

444
    /**
445
     * Emits one {@code npa:SubSpaceDeclaration} entry, keyed by
446
     * {@code (artifactCode, parentHash)} so a single nanopub can declare multiple
447
     * parents without subject collision. Self-loops are silently dropped.
448
     */
449
    private static void emitSubSpaceDeclaration(Nanopub np, Context ctx, IRI childIri,
450
                                                IRI parentIri, List<Statement> out) {
451
        if (childIri.equals(parentIri)) {
12✔
452
            log.debug("Ignoring self-loop sub-space declaration on {} in {}", childIri, np.getUri());
18✔
453
            return;
3✔
454
        }
455
        String parentHash = Utils.createHash(parentIri);
9✔
456
        IRI subject = SpacesVocab.forSubSpaceDeclaration(ctx.artifactCode(), parentHash);
15✔
457

458
        // Idempotence: the embedded and standalone paths can both fire on the same
459
        // (np, child, parent) combination if a gen:Space nanopub somehow ends up typed
460
        // gen:isSubSpaceOf as well. Skip if we've already emitted the type triple for
461
        // this subject.
462
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SUB_SPACE_DECLARATION, GRAPH);
21✔
463
        if (out.contains(typeSt)) return;
12!
464

465
        out.add(typeSt);
12✔
466
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27✔
467
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27✔
468
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
469
        addProvenance(subject, ctx, out);
12✔
470
    }
3✔
471

472
    // ---------------- ID-prefix enumeration ----------------
473

474
    /**
475
     * Enumerates all intermediate path-prefixes of a Space IRI, after normalisation,
476
     * for the URL-prefix sub-space fallback. Strips query / fragment / trailing slash,
477
     * then strips path segments one at a time after the {@code ://} scheme separator,
478
     * down to host-only. Returns an empty list for inputs without a scheme separator
479
     * or without any path beyond the host.
480
     *
481
     * <p>Examples:
482
     * <pre>
483
     *   https://example.org/a/b/c/space  →  [https://example.org/a/b/c,
484
     *                                        https://example.org/a/b,
485
     *                                        https://example.org/a,
486
     *                                        https://example.org]
487
     *   https://example.org/x/           →  [https://example.org]   (trailing slash stripped)
488
     *   https://example.org/space?q=1    →  [https://example.org]   (query stripped)
489
     *   https://example.org              →  []                       (no path to strip)
490
     * </pre>
491
     */
492
    static List<IRI> enumerateIdPrefixes(IRI spaceIri) {
493
        String s = spaceIri.stringValue();
9✔
494
        int hash = s.indexOf('#');
12✔
495
        if (hash >= 0) s = s.substring(0, hash);
21✔
496
        int qmark = s.indexOf('?');
12✔
497
        if (qmark >= 0) s = s.substring(0, qmark);
21✔
498
        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
39✔
499

500
        int schemeEnd = s.indexOf("://");
12✔
501
        if (schemeEnd < 0) return Collections.emptyList();
12✔
502
        int hostStart = schemeEnd + 3;
12✔
503
        int hostEnd = s.indexOf('/', hostStart);
15✔
504
        if (hostEnd < 0) return Collections.emptyList();   // host-only, nothing to strip
12✔
505

506
        List<IRI> prefixes = new ArrayList<>();
12✔
507
        // Walk back from the right, stripping one segment per step, until we hit host-only.
508
        String current = s.substring(0, s.lastIndexOf('/'));
21✔
509
        while (current.length() > hostEnd) {
12✔
510
            prefixes.add(vf.createIRI(current));
18✔
511
            int slash = current.lastIndexOf('/');
12✔
512
            if (slash <= hostEnd) break;
12✔
513
            current = current.substring(0, slash);
15✔
514
        }
3✔
515
        // Always include the host-only root.
516
        prefixes.add(vf.createIRI(s.substring(0, hostEnd)));
27✔
517
        return prefixes;
6✔
518
    }
519

520
    // ---------------- shared helpers ----------------
521

522
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
523
        if (ctx.signedBy() != null) {
9!
524
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30✔
525
        }
526
        if (ctx.pubkeyHash() != null) {
9!
527
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27✔
528
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9✔
529
        }
530
        if (ctx.createdAt() != null) {
9!
531
            Literal ts = vf.createLiteral(ctx.createdAt());
15✔
532
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27✔
533
        }
534
    }
3✔
535

536
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
537
        for (IRI c : candidates) {
30✔
538
            if (types.contains(c)) return true;
18✔
539
        }
3✔
540
        return false;
6✔
541
    }
542

543
    // ---------------- load-number stamping ----------------
544

545
    /**
546
     * Stamps {@code <thisNP> npa:hasLoadNumber <N>} on the given nanopub. Intended to
547
     * be called by the loader once per nanopub, in the same transaction as the
548
     * extraction writes. Also bumps {@code npa:thisRepo npa:currentLoadCounter <N>}
549
     * in the admin graph so the materializer's delta cycles know the horizon.
550
     *
551
     * @param npId        nanopub IRI
552
     * @param loadNumber  the load counter value
553
     * @return two statements: load-number stamp + current-load-counter value
554
     */
555
    public static List<Statement> loadCounterStatements(IRI npId, long loadNumber) {
556
        List<Statement> out = new ArrayList<>(2);
15✔
557
        Literal lit = vf.createLiteral(loadNumber);
12✔
558
        out.add(vf.createStatement(npId, NPA.HAS_LOAD_NUMBER, lit, NPA.GRAPH));
27✔
559
        out.add(vf.createStatement(NPA.THIS_REPO, SpacesVocab.CURRENT_LOAD_COUNTER, lit, NPA.GRAPH));
27✔
560
        return out;
6✔
561
    }
562

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