Skip to content

Commit 3ce66c4

Browse files
authored
Merge pull request #22 from nicholsn/feat/registry-phase0
Federation Phase 0: registry producer contract + SPEC §11
2 parents c4ea05b + e0bf45d commit 3ce66c4

4 files changed

Lines changed: 169 additions & 8 deletions

File tree

SPEC.md

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,122 @@ graph using the *same* vocabularies the public web already speaks.
388388

389389
---
390390

391-
## 11. Versioning
391+
## 11. Federation: registries of bundles
392+
393+
A single LOKF bundle is self-contained, but knowledge rarely is: one team's
394+
`Metric` `dependsOn` another team's `GlossaryTerm`, whose canonical definition
395+
lives in a *different* bundle in a *different* repository. A **registry**
396+
(*meta-lokf*) aggregates a set of independent bundles into one navigable graph
397+
**without a central database**, so an agent can follow a relation out of one
398+
bundle into another and read the target concept's source document. A registry is
399+
itself just linked data — a DCAT catalog of catalogs — so it is built with the
400+
same vocabularies and tooling as the bundles it indexes.
401+
402+
### 11.1 The producer contract
403+
404+
A bundle becomes federatable by publishing the artifacts `lokf export` writes to
405+
a location a registry can read (GitHub Pages, a w3id-fronted host, or a sibling
406+
checkout). Its **`base_iri`** (from the root `index.md`, §4) is its identity in
407+
the registry.
408+
409+
| Artifact | Role in federation |
410+
|--------------------|------------------------------------------------------------------------|
411+
| `graph.nt` | The whole bundle as N-Triples — the RDF a registry loads for SPARQL. |
412+
| `concepts.jsonld` | Every concept's frontmatter + body under one `@context`/`@graph` — offline access to a concept's *source document* without re-fetching markdown. |
413+
| `graph.json` | cytoscape.js elements — drives the (multi-bundle) graph explorer. |
414+
| `datasets.jsonld` | schema.org `Dataset` docs — discovery via Google Dataset Search. |
415+
416+
### 11.2 The registry manifest
417+
418+
A registry is a git-committed `lokf-registry.yaml` — a `dcat:Catalog` of
419+
`void:Dataset` entries, one per member bundle, keyed by `base_iri`
420+
(== `void:uriSpace`). Each entry records where the member's artifacts live, a
421+
lightweight `void` planning index (triple and per-type counts, so an agent can
422+
pick a bundle *without* dereferencing it), and harvest `status`. It parses with a
423+
plain YAML reader — no LinkML on the read path — and round-trips to a crawlable
424+
`registry.jsonld`.
425+
426+
```yaml
427+
lokf_registry_version: "0.1"
428+
type: dcat:Catalog
429+
id: https://w3id.org/lokf/registry/example
430+
repos:
431+
- base_iri: https://acme.example/knowledge/ # routing key == void:uriSpace
432+
title: Acme Knowledge Bundle
433+
repo: git+https://github.com/acme/knowledge.git@main
434+
source_base: https://raw.githubusercontent.com/acme/knowledge/main
435+
distribution:
436+
rdf: https://acme.example/knowledge/graph.nt # SPARQL harvest source
437+
concepts: https://acme.example/knowledge/concepts.jsonld # offline document access
438+
void: { triples: 86, class_partition: { Metric: 1, Dataset: 1, GlossaryTerm: 1 } }
439+
id_index: [ ] # explicit `id:` IRIs outside base_iri
440+
status: ok
441+
```
442+
443+
### 11.3 Cross-bundle resolution
444+
445+
The load-bearing operation is **`owner(iri)`**: the registered `base_iri` that is
446+
the **longest string prefix** of the IRI. This is the exact inverse of IRI
447+
minting (§7): a concept's IRI is `base_iri + concept_id`, so given any IRI,
448+
`concept_id = iri[len(base_iri):]` and the owning bundle is its longest-prefix
449+
match. Resolution is therefore **pure string arithmetic — no network, no shared
450+
database** — and a cross-bundle relation whose author wrote a full `https://…`
451+
target is *already* a correct triple. Three rules keep it trustworthy:
452+
453+
1. **Explicit-id index.** A concept's frontmatter `id:` may diverge from
454+
`base_iri + concept_id` (§5). Such IRIs are harvested into the entry's
455+
`id_index` and checked as an exact-match fallback before an IRI is declared
456+
external, so they still route.
457+
2. **Namespace precedence & non-nesting.** The packaged vocabulary namespace
458+
(`https://w3id.org/lokf/`) always resolves to the built-in schema; registered
459+
`base_iri`s must be strictly longer and may not nest inside one another, so
460+
routing is unambiguous.
461+
3. **Ownership validation.** At registration a member's sampled concept IRIs must
462+
actually start with its declared `base_iri`, so a bundle cannot claim a
463+
namespace it does not own.
464+
465+
An IRI owned by no entry is returned as a tolerated **dangling link**, not an
466+
error — preserving OKF's permissive stance on broken cross-references.
467+
468+
### 11.4 Traversal and access *(informative — delivered in phases)*
469+
470+
The intended runtime model, layered on the primitives above:
471+
472+
- **Federated graph.** A registry loads each member's `graph.nt` into a
473+
**named graph whose IRI is its `base_iri`**. Union queries make a cross-bundle
474+
edge resolve transparently once both members are loaded, while `GRAPH ?g`
475+
recovers *which* bundle asserted a triple for free (`?g` binds the `base_iri`).
476+
Members load **lazily** — only as a walk reaches into their namespace — so an
477+
agent never pays to materialize the whole federation to answer a local
478+
question.
479+
- **Document access.** A concept's source markdown is fetched by
480+
`source_base + concept_id + ".md"` through an offline-first chain: a local
481+
checkout, else the cached `concepts.jsonld`, else a live fetch — every path
482+
yielding the same `{frontmatter, body}` shape.
483+
- **Agent surface.** A `lokf registry` CLI and a `lokf-registry` MCP server
484+
expose `resolve_iri`, `neighbors`, `subgraph`, `federated_sparql`, and
485+
`read_document`, each depth/breadth-bounded so a cross-bundle hop is a single
486+
token-frugal call rather than a chatty chain.
487+
488+
### 11.5 Governance
489+
490+
- **Membership is public-artifact or local-checkout only** in v0.1; credentials
491+
never enter the shared manifest. Federating a private or perimeter-bound bundle
492+
is a future extension, not a v0.1 capability.
493+
- **Metadata only, never row-level data.** A registry aggregates schema- and
494+
concept-level knowledge; it does not move records. A per-entry `sensitivity`
495+
gate lets `harvest` refuse un-cleared members, because metadata each cleared
496+
*individually* can be *jointly* re-identifying and even `void` counts can leak
497+
small cells — so aggregating sensitive-domain bundles requires explicit
498+
clearance from the registry's owner.
499+
- **Freshness is best-effort.** The federated store reflects the last harvest,
500+
guarded by conditional requests and a surfaced per-entry `status`; there is no
501+
live-HEAD guarantee, and an unreachable member degrades to its last-good state
502+
rather than failing a walk.
503+
504+
---
505+
506+
## 12. Versioning
392507

393508
LOKF versions are `<major>.<minor>`, tracking OKF's scheme. A minor bump adds
394509
backward-compatible fields, types, relation predicates, or mappings; a major bump
@@ -419,6 +534,7 @@ README.md How the pieces fit and how to regenerate them.
419534
| `lokf` | `https://w3id.org/lokf/` |
420535
| `schema` | `http://schema.org/` |
421536
| `dcat` | `http://www.w3.org/ns/dcat#` |
537+
| `void` | `http://rdfs.org/ns/void#` |
422538
| `dcterms` | `http://purl.org/dc/terms/` |
423539
| `prov` | `http://www.w3.org/ns/prov#` |
424540
| `skos` | `http://www.w3.org/2004/02/skos/core#` |

src/lokf/cli.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -337,20 +337,32 @@ def export(
337337
..., exists=True, file_okay=False, help="A LOKF bundle directory."
338338
),
339339
out_dir: Path = typer.Option(
340-
..., "--out-dir", "-d", help="Directory to write graph.json + datasets.jsonld."
340+
...,
341+
"--out-dir",
342+
"-d",
343+
help="Directory to write graph.json, datasets.jsonld, graph.nt, concepts.jsonld.",
341344
),
342345
source_base: Optional[str] = typer.Option(
343346
None, "--source-base", help="URL prefix for a node's source file (graph meta)."
344347
),
345348
) -> None:
346-
"""Export a bundle's graph + Dataset JSON-LD for a static site to consume.
347-
348-
Writes ``graph.json`` (cytoscape.js elements, typed-relation edges, plus
349-
``meta.source_base``) and ``datasets.jsonld`` (schema.org Dataset docs for
350-
Google Dataset Search). This is the data step behind the docs site.
349+
"""Export a bundle's artifacts for a static site — and a registry — to consume.
350+
351+
Writes four files (the *producer contract* a meta-lokf registry harvests):
352+
353+
- ``graph.json`` — cytoscape.js elements (typed-relation edges) plus
354+
``meta.source_base``; drives the docs-site graph explorer.
355+
- ``datasets.jsonld`` — schema.org Dataset docs for Google Dataset Search.
356+
- ``graph.nt`` — the whole bundle as N-Triples; the RDF a registry loads
357+
into its federated store for cross-bundle SPARQL.
358+
- ``concepts.jsonld`` — every concept's frontmatter + body under one
359+
``@context``/``@graph``; lets a registry read a foreign concept's source
360+
document offline, without re-fetching the markdown.
351361
"""
362+
from lokf import rdf
352363
from lokf.export import dataset_search_jsonld, to_cytoscape
353364
from lokf.model import load_bundle
365+
from lokf.schema import load_context
354366

355367
bundle = load_bundle(bundle_dir)
356368
graph = to_cytoscape(bundle)
@@ -360,9 +372,15 @@ def export(
360372
(out_dir / "datasets.jsonld").write_text(
361373
json.dumps(dataset_search_jsonld(bundle), indent=2), encoding="utf-8"
362374
)
375+
(out_dir / "graph.nt").write_text(rdf.serialize(bundle_dir, "nt"), encoding="utf-8")
376+
(out_dir / "concepts.jsonld").write_text(
377+
json.dumps({"@context": load_context(), "@graph": bundle.docs()}, indent=2),
378+
encoding="utf-8",
379+
)
363380
typer.echo(
364381
f"wrote {out_dir}/graph.json ({len(graph['nodes'])} nodes, "
365-
f"{len(graph['edges'])} edges) and {out_dir}/datasets.jsonld"
382+
f"{len(graph['edges'])} edges), datasets.jsonld, graph.nt, "
383+
f"and concepts.jsonld ({len(bundle.concepts)} concepts)"
366384
)
367385

368386

tests/test_cli.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,28 @@ def test_export_writes_graph_and_datasets(tmp_path):
127127
assert graph["meta"]["source_base"] == "https://x/"
128128
datasets = json.loads((tmp_path / "datasets.jsonld").read_text())
129129
assert len(datasets) == 2 and all(d["@type"] == "Dataset" for d in datasets)
130+
131+
132+
def test_export_writes_registry_producer_contract(tmp_path):
133+
"""export also writes graph.nt + concepts.jsonld (the registry harvest source)."""
134+
result = runner.invoke(app, ["export", str(BUNDLE), "--out-dir", str(tmp_path)])
135+
assert result.exit_code == 0
136+
137+
# graph.nt is the whole bundle as N-Triples (one statement per line).
138+
nt = (tmp_path / "graph.nt").read_text()
139+
assert nt.strip() and all(
140+
line.endswith(" .") for line in nt.strip().splitlines()
141+
)
142+
143+
# concepts.jsonld is one @context/@graph document carrying frontmatter + body,
144+
# each concept keyed by its IRI, and it round-trips to the same triples as nt.
145+
doc = json.loads((tmp_path / "concepts.jsonld").read_text())
146+
assert set(doc) == {"@context", "@graph"}
147+
assert len(doc["@graph"]) == 6
148+
assert all(c.get("id", "").startswith("http") and "body" in c for c in doc["@graph"])
149+
150+
from rdflib import Graph
151+
152+
g_nt = Graph().parse(str(tmp_path / "graph.nt"), format="nt")
153+
g_jsonld = Graph().parse(str(tmp_path / "concepts.jsonld"), format="json-ld")
154+
assert len(g_nt) and g_nt.isomorphic(g_jsonld)

web/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pnpm-debug.log*
2323
# generated by `npm run sync` (lokf export)
2424
public/graph.json
2525
public/datasets.jsonld
26+
public/graph.nt
27+
public/concepts.jsonld
2628
src/generated/
2729
src/content/docs/specification.md
2830
src/content/docs/reference/api.md

0 commit comments

Comments
 (0)