@directory-builder/core 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,11 @@ sources/<name>/
16
16
  fetch.js # how to fetch this source
17
17
  clean.sparql # how to clean its lifted RDF
18
18
  static/ # the data itself, for static-file sources
19
+ registry/
20
+ identity.ttl # engine-maintained: minted entity IRIs and their
21
+ # source members — accumulated state, commit it
22
+ history.ttl # engine-maintained: append-only log of identity
23
+ # events (mint, member joined)
19
24
  webapp/
20
25
  content/about.md # optional: the webapp's About page prose
21
26
  exporters/<name>.js # optional: output adapters the webapp loads at runtime
@@ -78,6 +83,19 @@ aren't available yet.
78
83
  Engines journal their executed steps as p-plan RDF (`data/ingest/ingest-log.ttl`,
79
84
  `data/pipeline/federate-log.ttl`) — evidence of what ran, not a plan.
80
85
 
86
+ Minting is write-once: the match step keeps an identity registry
87
+ (`registry/identity.ttl`, created on the first run) assigning each source
88
+ record to its minted entity IRI. A cluster with a known member reuses the
89
+ registered IRI, so identities survive re-harvests however membership evolves;
90
+ only unseen entities mint fresh. Alongside it, `registry/history.ttl` is an
91
+ append-only log of identity events (mint, member joined) grouped under a
92
+ timestamped `:Revision` node per changing run — the registry's provenance,
93
+ where the snapshot in `identity.ttl` came from. Both are written only when
94
+ something changes, so a no-op harvest leaves them — and their git diff —
95
+ untouched, and the revision counter only advances when identity actually moves.
96
+ Unlike `data/`, the registry is accumulated state, not derived output — commit
97
+ it, and review its diff after each harvest.
98
+
81
99
  ## Run the webapp
82
100
 
83
101
  The webapp ships with the package; it fetches an instance's `config/` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directory-builder/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Use-case-agnostic engine for config-driven federation pipelines",
5
5
  "author": "Civic Data Lab",
6
6
  "repository": "github:foederierter-datenpool/directory-builder-core",
@@ -58,7 +58,7 @@ export async function federate(root = process.cwd()) {
58
58
  await writeTurtleFile(abs(PATHS.mapped), mappedQuads, { ...COMMON_PREFIXES, cdp: CDP })
59
59
  console.log(`map: wrote ${mappedQuads.length} triples → ${PATHS.mapped}`)
60
60
  })
61
- const matchStep = await journal.step("match", { after: [mapStep] }, () => runMatch(ctx, PATHS.matches))
61
+ const matchStep = await journal.step("match", { after: [mapStep] }, () => runMatch(ctx, PATHS.matches, PATHS.registry, PATHS.registryHistory))
62
62
  const mergeStep = await journal.step("merge", { after: [matchStep] }, () => runMerge(ctx, PATHS.merged, PATHS.provenance))
63
63
  await journal.step("resolve", { after: [mergeStep] }, () => runResolve(ctx, PATHS.final))
64
64
 
@@ -1,10 +1,11 @@
1
1
  import { sparqlSelect } from "@foerderfunke/sem-ops-utils"
2
2
  import { COMMON_PREFIXES, writeTurtleFile } from "../write-turtle.js"
3
3
  import { MAPPED_GRAPH } from "./map.js"
4
- import { CDP } from "../../utils.js"
4
+ import { CDP, parseTtl, shrink } from "../../utils.js"
5
5
  import { token_set_ratio } from "fuzzball"
6
6
  import { DataFactory } from "n3"
7
7
  import { createHash } from "crypto"
8
+ import fs from "fs"
8
9
 
9
10
  const df = DataFactory
10
11
 
@@ -22,7 +23,28 @@ const MATCH_CLUSTER = df.namedNode(CDP + "MatchCluster")
22
23
  const SIMILARITY_ALGORITHM = "token_set_ratio"
23
24
  const similarity = (a, b) => token_set_ratio(a ?? "", b ?? "") / 100
24
25
 
25
- export const runMatch = async ({ store, defStore, abs }, outPath) => {
26
+ export const runMatch = async ({ store, defStore, abs }, outPath, registryPath, historyPath) => {
27
+ // The identity registry (minted IRI :hasMember source IRI, one assignment
28
+ // per member) makes minting write-once: an entity's IRI is computed at
29
+ // most once — at first sight — recorded here, and afterwards only looked
30
+ // up, so membership can change without identity churn. Instance state to
31
+ // commit, neither config nor regenerable data; empty on a fresh instance.
32
+ const registry = new Map() // member source IRI → minted IRI
33
+ if (fs.existsSync(abs(registryPath))) {
34
+ for (const q of parseTtl(fs.readFileSync(abs(registryPath), "utf8"))) {
35
+ if (q.predicate.value === HAS_MEMBER.value) registry.set(q.object.value, q.subject.value)
36
+ }
37
+ }
38
+ const reserved = new Set(registry.values()) // every IRI ever minted — never mint one again
39
+ const known = new Set(registry.keys()) // members assigned in a prior run
40
+ const taken = new Set() // minted IRIs claimed by a cluster this run
41
+ let reusedCount = 0, mintedCount = 0
42
+ // Identity events this run, appended to history.ttl (the registry's
43
+ // provenance): when each entity was first minted, gained a member, or
44
+ // absorbed/split off another. Append-only and written only when non-empty,
45
+ // so a no-change harvest leaves the file — and its git diff — untouched.
46
+ const events = []
47
+
26
48
  // One match rule per target schema; each rule scores its own fields, mints
27
49
  // with its own prefix, and clusters only subjects of its :targetClass.
28
50
  const rules = await sparqlSelect(`
@@ -169,8 +191,39 @@ export const runMatch = async ({ store, defStore, abs }, outPath) => {
169
191
  let multiSource = 0
170
192
  const clusterIriByRoot = new Map()
171
193
  for (const members of clusterMembers) {
172
- const id = createHash("sha1").update(members.join("|")).digest("hex").slice(0, 12)
173
- const minted = df.namedNode(namespace + mintedPrefix + id)
194
+ // Reconcile against the registry: any member already known → its
195
+ // entity exists, reuse the IRI (clusters come largest-first, so on
196
+ // a split the larger fragment keeps the identity). Only unseen
197
+ // entities mint, seeded by their smallest member at mint time — a
198
+ // one-time uniqueness seed, not a content address: the registry
199
+ // pins the IRI afterwards, however membership evolves.
200
+ const prior = [...new Set(members.map(m => registry.get(m)).filter(Boolean))].sort()
201
+ const free = prior.filter(iri => !taken.has(iri))
202
+ let minted
203
+ // TODO: merge and split (prior carrying ≥2 IRIs in the reuse branch,
204
+ // or any prior in the mint branch) are reconciled correctly — a
205
+ // survivor keeps the IRI — but their history events (:Merged /
206
+ // :Split) and the tombstone they imply (the retired IRI preserved
207
+ // with :isReplacedBy, rather than silently vanishing from
208
+ // identity.ttl) are their own rung. For now they only warn.
209
+ if (free.length) {
210
+ minted = df.namedNode(free[0])
211
+ reusedCount++
212
+ const joined = members.filter(m => !known.has(m))
213
+ if (joined.length) events.push({ type: "MemberJoined", entity: free[0], member: joined })
214
+ if (prior.length > 1) console.warn(`match: clusters merged (${prior.join(" + ")}) — keeping ${free[0]}`)
215
+ } else {
216
+ if (prior.length) console.warn(`match: cluster split off ${prior.join(", ")} — minting fresh`)
217
+ let id = createHash("sha1").update(members[0]).digest("hex").slice(0, 12)
218
+ // Seed collision (e.g. a split remainder re-hashing its old anchor): re-hash until free.
219
+ while (taken.has(namespace + mintedPrefix + id) || reserved.has(namespace + mintedPrefix + id))
220
+ id = createHash("sha1").update(id).digest("hex").slice(0, 12)
221
+ minted = df.namedNode(namespace + mintedPrefix + id)
222
+ mintedCount++
223
+ if (!prior.length) events.push({ type: "Minted", entity: minted.value, member: members })
224
+ }
225
+ taken.add(minted.value)
226
+ for (const m of members) registry.set(m, minted.value)
174
227
  clusterIriByRoot.set(find(members[0]), minted)
175
228
  if (members.length > 1) multiSource++
176
229
  store.addQuad(df.quad(minted, RDF_TYPE, MATCH_CLUSTER, MATCH_GRAPH))
@@ -209,4 +262,42 @@ export const runMatch = async ({ store, defStore, abs }, outPath) => {
209
262
  const matchQuads = store.getQuads(null, null, null, MATCH_GRAPH)
210
263
  await writeTurtleFile(abs(outPath), matchQuads, { cdp: CDP, cdf: rules[0].ns, ...COMMON_PREFIXES })
211
264
  console.log(`match: wrote cluster log → ${outPath}`)
265
+
266
+ await writeTurtleFile(abs(registryPath), [...registry].map(([member, minted]) =>
267
+ df.quad(df.namedNode(minted), HAS_MEMBER, df.namedNode(member))), { cdp: CDP, cdf: rules[0].ns })
268
+ console.log(`match: identity registry ${reusedCount} reused, ${mintedCount} minted → ${registryPath}`)
269
+
270
+ // Append this run's events to the history (the registry's provenance) as
271
+ // one :Revision node carrying the timestamp, with each event hung off it as
272
+ // a nested [entity ; members] binding under a type predicate (cdp:minted /
273
+ // cdp:memberJoined). Revisions count only changing runs — a no-op harvest
274
+ // appends nothing — so the next number is one past the highest on file. The
275
+ // whole block is one append, so the named :Revision and its fresh blank
276
+ // nodes never collide with earlier revisions when the file is re-parsed.
277
+ if (events.length) {
278
+ const prefixes = { cdp: CDP, cdf: rules[0].ns }
279
+ const sh = (iri) => shrink(iri, prefixes)
280
+ const list = (arr) => arr.map(sh).join(", ")
281
+ const existing = fs.existsSync(abs(historyPath)) ? fs.readFileSync(abs(historyPath), "utf8") : ""
282
+ const rev = Math.max(0, ...[...existing.matchAll(/revision-(\d+)/g)].map(m => +m[1])) + 1
283
+
284
+ const byPredicate = new Map() // cdp:minted / cdp:memberJoined → binding strings
285
+ for (const e of events) {
286
+ const pred = "cdp:" + e.type[0].toLowerCase() + e.type.slice(1)
287
+ if (!byPredicate.has(pred)) byPredicate.set(pred, [])
288
+ byPredicate.get(pred).push(`[ cdp:entity ${sh(e.entity)} ; cdp:member ${list(e.member)} ]`)
289
+ }
290
+ const props = [...byPredicate].map(([pred, bindings]) =>
291
+ ` ${pred}\n ${bindings.join(" ,\n ")}`).join(" ;\n")
292
+ const block = `cdp:revision-${rev} a cdp:Revision ; prov:atTime "${new Date().toISOString()}"^^xsd:dateTime ;\n${props} .\n`
293
+
294
+ const header = `@prefix cdp: <${CDP}> .
295
+ @prefix cdf: <${rules[0].ns}> .
296
+ @prefix prov: <http://www.w3.org/ns/prov#> .
297
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
298
+
299
+ `
300
+ fs.appendFileSync(abs(historyPath), (existing ? "\n" : header) + block)
301
+ console.log(`match: revision ${rev} — ${events.length} identity event(s) → ${historyPath}`)
302
+ }
212
303
  }
package/src/utils.js CHANGED
@@ -56,6 +56,8 @@ export const LIFTED_FORMAT = "http://publications.europa.eu/resource/authority/f
56
56
  export const PATHS = {
57
57
  federation: "config/federation.ttl",
58
58
  matchKnowledge: "config/match-knowledge.ttl",
59
+ registry: "registry/identity.ttl",
60
+ registryHistory: "registry/history.ttl",
59
61
  about: "webapp/content/about.md",
60
62
  query: "webapp/content/query.sparql",
61
63
  fetchScript: (name) => `sources/${name}/fetch.js`,
@@ -1,4 +1,4 @@
1
- import { parseTtl, PATHS } from "@directory-builder/core/utils"
1
+ import { CDP, parseTtl, PATHS } from "@directory-builder/core/utils"
2
2
  import { Pipeline, validate } from "@directory-builder/core"
3
3
  import { makeInstance } from "./helpers/instance.js"
4
4
  import assert from "node:assert/strict"
@@ -6,10 +6,11 @@ import { test } from "node:test"
6
6
  import path from "path"
7
7
  import fs from "fs"
8
8
 
9
- // The ultra-minimal instance: federation.ttl + two static JSON sources,
10
- // nothing else — fetch, clean and resolve all run on engine defaults. The
11
- // sources share one record by name ("Entry One"), so the pipeline should
12
- // merge a1+b1 and leave a2 and b2 as their own entities.
9
+ // ---- Shared fixture: the ultra-minimal instance both tests run on ----------
10
+ // federation.ttl + two static JSON sources, nothing else — fetch, clean and
11
+ // resolve all run on engine defaults. The sources share one record by name
12
+ // ("Entry One"), so the pipeline should merge a1+b1 and leave a2 and b2 as
13
+ // their own entities.
13
14
 
14
15
  const federation = `
15
16
  @prefix : <https://civic-data.de/pipeline#> .
@@ -56,8 +57,7 @@ const beta = [
56
57
  { id: "b2", label: "Entry Three" },
57
58
  ]
58
59
 
59
- const root = makeInstance("tiny", { federation, sources: { alpha, beta } })
60
-
60
+ // The consumer-facing artifact the shared fixture resolves to (both tests).
61
61
  const expectedFinal = `@prefix schema: <http://schema.org/>.
62
62
  @prefix foaf: <http://xmlns.com/foaf/0.1/>.
63
63
  @prefix dct: <http://purl.org/dc/terms/>.
@@ -65,13 +65,16 @@ const expectedFinal = `@prefix schema: <http://schema.org/>.
65
65
 
66
66
  cdf:thing-5a45645edb31 a schema:Thing;
67
67
  schema:name "Entry Two".
68
- cdf:thing-616feb993283 a schema:Thing;
69
- schema:name "Entry One".
70
68
  cdf:thing-d1583c098826 a schema:Thing;
71
69
  schema:name "Entry Three".
70
+ cdf:thing-e427416d02ac a schema:Thing;
71
+ schema:name "Entry One".
72
72
  `
73
73
 
74
+ // ---- Test 1: the whole pipeline on defaults --------------------------------
75
+
74
76
  test("the tiny fixture validates and runs the whole pipeline on defaults", async () => {
77
+ const root = makeInstance("tiny", { federation, sources: { alpha, beta } })
75
78
  // the fixture satisfies the instance contract (folders, derivable defaults, shape)
76
79
  assert.deepEqual(await validate(root), [])
77
80
  await new Pipeline({ root }).run()
@@ -88,3 +91,92 @@ test("the tiny fixture validates and runs the whole pipeline on defaults", async
88
91
  // and the consumer-facing artifact as a whole
89
92
  assert.equal(finalTtl, expectedFinal)
90
93
  })
94
+
95
+ // ---- Test 2: periodic harvesting & the identity registry -------------------
96
+
97
+ // The identity registry the first harvest writes: each minted IRI's source
98
+ // members, the write-once record later runs reconcile against.
99
+ const expectedRegistry = `@prefix cdp: <https://civic-data.de/pipeline#>.
100
+ @prefix cdf: <urn:test:>.
101
+
102
+ cdf:thing-5a45645edb31 cdp:hasMember cdp:alpha-a2.
103
+ cdf:thing-d1583c098826 cdp:hasMember cdp:beta-b2.
104
+ cdf:thing-e427416d02ac cdp:hasMember cdp:alpha-a1, cdp:beta-b1.
105
+ `
106
+
107
+ // history.ttl events as {type, entity, member[], revision}: each event is a
108
+ // nested [entity ; members] binding hung off its :Revision node under a type
109
+ // predicate (cdp:minted / cdp:memberJoined). Timestamps vary per run, so the
110
+ // test asserts structure, not bytes.
111
+ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
112
+ const EVENT_PREDS = { minted: "Minted", memberJoined: "MemberJoined" }
113
+ const parseEvents = (ttl) => {
114
+ const quads = parseTtl(ttl)
115
+ const events = []
116
+ for (const [local, type] of Object.entries(EVENT_PREDS)) {
117
+ for (const q of quads.filter((x) => x.predicate.value === CDP + local)) {
118
+ const node = q.object.value // the [entity ; members] binding's blank node
119
+ events.push({
120
+ type,
121
+ entity: quads.find((x) => x.subject.value === node && x.predicate.value === CDP + "entity")?.object.value,
122
+ member: quads.filter((x) => x.subject.value === node && x.predicate.value === CDP + "member")
123
+ .map((x) => x.object.value).toSorted(),
124
+ revision: q.subject.value, // the :Revision node the binding hangs off
125
+ })
126
+ }
127
+ }
128
+ return events
129
+ }
130
+ const revisionNodes = (ttl) => parseTtl(ttl)
131
+ .filter((q) => q.predicate.value === RDF_TYPE && q.object.value === CDP + "Revision")
132
+ .map((q) => q.subject.value).toSorted()
133
+
134
+ test("harvest rounds keep minted IRIs stable (write-once identity registry)", async () => {
135
+ const root = makeInstance("harvest", { federation, sources: { alpha, beta } })
136
+ const pipeline = new Pipeline({ root })
137
+ const writeSource = (name, records) =>
138
+ fs.writeFileSync(path.join(root, PATHS.staticDir(name), "data.json"), JSON.stringify(records, null, 4))
139
+ const artifact = (p) => fs.readFileSync(path.join(root, p), "utf8")
140
+ const id = (local) => "urn:test:" + local
141
+ const src = (local) => CDP + local
142
+
143
+ // round 1 — the first harvest mints the three identities into the registry,
144
+ // and opens the history with one Minted event apiece (the genesis record).
145
+ await pipeline.run()
146
+ assert.equal(artifact(PATHS.final), expectedFinal)
147
+ assert.equal(artifact(PATHS.registry), expectedRegistry)
148
+ const history1 = artifact(PATHS.registryHistory)
149
+ assert.deepEqual(parseEvents(history1).toSorted((a, b) => a.entity.localeCompare(b.entity)), [
150
+ { type: "Minted", entity: id("thing-5a45645edb31"), member: [src("alpha-a2")], revision: src("revision-1") },
151
+ { type: "Minted", entity: id("thing-d1583c098826"), member: [src("beta-b2")], revision: src("revision-1") },
152
+ { type: "Minted", entity: id("thing-e427416d02ac"), member: [src("alpha-a1"), src("beta-b1")], revision: src("revision-1") },
153
+ ])
154
+ assert.deepEqual(revisionNodes(history1), [src("revision-1")], "genesis opens revision 1")
155
+
156
+ // round 2 — harmless upstream edit: b2 renames to "Entry Drei", membership
157
+ // unchanged. The directory carries the new name under the same IRI, and both
158
+ // registry and history stay byte-identical (a no-change harvest, clean diff).
159
+ writeSource("beta", [beta[0], { id: "b2", label: "Entry Drei" }])
160
+ await pipeline.run()
161
+ const expectedRenamed = expectedFinal.replace(`"Entry Three"`, `"Entry Drei"`)
162
+ assert.equal(artifact(PATHS.final), expectedRenamed)
163
+ assert.equal(artifact(PATHS.registry), expectedRegistry)
164
+ assert.equal(artifact(PATHS.registryHistory), history1, "no event appended for a no-op harvest")
165
+
166
+ // round 3 — a new alpha record joins b2's cluster. alpha-a3 sorts before
167
+ // beta-b2, so a stateless smallest-member seed would re-mint here — only the
168
+ // registry lookup preserves the identity: the directory is unchanged, the
169
+ // entity just gained its second member, and history records exactly that.
170
+ writeSource("alpha", [...alpha, { id: "a3", name: "Entry Drei" }])
171
+ await pipeline.run()
172
+ assert.equal(artifact(PATHS.final), expectedRenamed)
173
+ assert.equal(artifact(PATHS.registry),
174
+ expectedRegistry.replace("cdp:beta-b2.", "cdp:beta-b2, cdp:alpha-a3."))
175
+ assert.ok(artifact(PATHS.registryHistory).startsWith(history1), "history only appends, never rewrites")
176
+ const inRev2 = parseEvents(artifact(PATHS.registryHistory)).filter((e) => e.revision === src("revision-2"))
177
+ assert.deepEqual(inRev2, [
178
+ { type: "MemberJoined", entity: id("thing-d1583c098826"), member: [src("alpha-a3")], revision: src("revision-2") },
179
+ ])
180
+ assert.deepEqual(revisionNodes(artifact(PATHS.registryHistory)), [src("revision-1"), src("revision-2")],
181
+ "the changing harvest opens revision 2; the no-op round 2 added none")
182
+ })