Skip to content

Commit 9613fd0

Browse files
committed
Federation Phase 1: registry engine + lokf registry CLI
The offline core of meta-lokf (SPEC §11): resolve a cross-bundle IRI to its owning bundle without a central database. lokf.registry: - Registry / RepoEntry / Resolution; load_registry() parses lokf-registry.yaml with plain YAML (no LinkML on the read path), tolerant of null/hand-edited fields. - owner(iri): longest-base_iri-prefix routing — the exact inverse of Bundle.iri() minting — with an explicit-id exact-match *fallback* (prefix always wins, so a stale id_index can't hijack a prefix owner). resolve() returns owner + Concept ID + source URL, all offline string math. - add() guards: base_iri must end in a separator (/ or #) so routing respects segment boundaries (…/team/ never captures …/team-archive/), may not capture the vocabulary namespace, and may not duplicate or nest another member. - entry_for_bundle(): derive base_iri/title from a local bundle, compute a VoID index (triple + per-type counts) and an id_index of explicit ids that diverge from base_iri + Concept ID; validate every concept IRI is routable. CLI `lokf registry` sub-app: init, add, list, resolve — each with clean "run init first" / parent-dir handling instead of tracebacks. Docs: register lokf.registry in the API-reference generator; sync SPEC §11's id_index to the map the code uses and tighten rule 2 (boundaries + vocab precedence). Hardened against an adversarial review pass (null-manifest crashes, prefix boundary mis-routing, id_index precedence, bogus namespace-root URLs). 21 registry tests (156 total); docs site builds.
1 parent 3ce66c4 commit 9613fd0

5 files changed

Lines changed: 636 additions & 7 deletions

File tree

SPEC.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,8 @@ repos:
436436
rdf: https://acme.example/knowledge/graph.nt # SPARQL harvest source
437437
concepts: https://acme.example/knowledge/concepts.jsonld # offline document access
438438
void: { triples: 86, class_partition: { Metric: 1, Dataset: 1, GlossaryTerm: 1 } }
439-
id_index: [ ] # explicit `id:` IRIs outside base_iri
439+
id_index: # explicit `id:` IRIs outside base_iri → Concept ID
440+
https://acme.example/legacy/wau: metrics/weekly-active-users
440441
status: ok
441442
```
442443
@@ -452,12 +453,15 @@ target is *already* a correct triple. Three rules keep it trustworthy:
452453

453454
1. **Explicit-id index.** A concept's frontmatter `id:` may diverge from
454455
`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.
456+
`id_index` — a map from the explicit IRI to its Concept ID — and checked as
457+
an exact-match fallback before an IRI is declared external, so they still
458+
route (to the right source document).
459+
2. **Boundaries, namespace precedence & non-nesting.** A member `base_iri` must
460+
end in a path separator (`/` or `#`) so prefix routing respects segment
461+
boundaries — `…/team/` never captures `…/team-archive/`. It must be strictly
462+
longer than, and may not capture, the packaged vocabulary namespace
463+
(`https://w3id.org/lokf/`), which is reserved for the built-in schema; and no
464+
two `base_iri`s may nest, so at most one prefixes any IRI.
461465
3. **Ownership validation.** At registration a member's sampled concept IRIs must
462466
actually start with its declared `base_iri`, so a bundle cannot claim a
463467
namespace it does not own.

src/lokf/cli.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,120 @@ def mcp() -> None:
392392
run_mcp()
393393

394394

395+
# ---------------------------------------------------------------------------
396+
# registry — federate multiple bundles (meta-lokf); see SPEC §11
397+
# ---------------------------------------------------------------------------
398+
registry_app = typer.Typer(
399+
help="Federate bundles: a registry of LOKF bundles (meta-lokf).",
400+
no_args_is_help=True,
401+
)
402+
app.add_typer(registry_app, name="registry")
403+
404+
405+
@registry_app.command("init")
406+
def registry_init(
407+
registry: Path = typer.Option(
408+
Path("lokf-registry.yaml"), "--registry", "-r", help="Manifest path to create."
409+
),
410+
catalog_id: str = typer.Option("", "--id", help="Registry IRI (dcat:Catalog @id)."),
411+
title: str = typer.Option("", "--title", help="Registry title."),
412+
) -> None:
413+
"""Scaffold an empty registry manifest."""
414+
from lokf.registry import Registry
415+
416+
if registry.exists():
417+
_err(f"{registry} already exists")
418+
raise typer.Exit(1)
419+
Registry(path=registry, id=catalog_id, title=title).save()
420+
typer.echo(f"wrote {registry}")
421+
422+
423+
@registry_app.command("add")
424+
def registry_add(
425+
bundle_dir: Path = typer.Argument(
426+
..., exists=True, file_okay=False, help="A local LOKF bundle directory."
427+
),
428+
registry: Path = typer.Option(
429+
Path("lokf-registry.yaml"), "--registry", "-r", help="Manifest to append to."
430+
),
431+
source_base: Optional[str] = typer.Option(
432+
None,
433+
"--source-base",
434+
help="URL prefix for source .md files (defaults to the bundle's file:// path).",
435+
),
436+
) -> None:
437+
"""Register a local bundle: derive its base_iri, VoID index, and id_index."""
438+
from lokf.registry import entry_for_bundle, load_registry
439+
440+
if not registry.exists():
441+
_err(f"{registry} not found — run `lokf registry init` first")
442+
raise typer.Exit(1)
443+
reg = load_registry(registry)
444+
try:
445+
entry = entry_for_bundle(bundle_dir, source_base=source_base)
446+
reg.add(entry)
447+
except ValueError as exc:
448+
_err(f"cannot register {bundle_dir}: {exc}")
449+
raise typer.Exit(1)
450+
reg.save()
451+
typer.echo(
452+
f"registered {entry.base_iri} "
453+
f"({entry.void.get('triples', 0)} triples) in {registry}"
454+
)
455+
456+
457+
@registry_app.command("list")
458+
def registry_list(
459+
registry: Path = typer.Option(
460+
Path("lokf-registry.yaml"), "--registry", "-r", help="Manifest to read."
461+
),
462+
) -> None:
463+
"""Print the routing table: base_iri, title, triples, status."""
464+
from lokf.registry import load_registry
465+
466+
if not registry.exists():
467+
_err(f"{registry} not found — run `lokf registry init` first")
468+
raise typer.Exit(1)
469+
reg = load_registry(registry)
470+
if not reg.repos:
471+
typer.echo("(no members registered)")
472+
return
473+
for e in sorted(reg.repos, key=lambda e: e.base_iri):
474+
triples = e.void.get("triples", "?")
475+
typer.echo(f"{e.base_iri}\t{e.title}\t{triples} triples\t{e.status}")
476+
477+
478+
@registry_app.command("resolve")
479+
def registry_resolve(
480+
iri: str = typer.Argument(..., help="An absolute concept IRI to resolve."),
481+
registry: Path = typer.Option(
482+
Path("lokf-registry.yaml"), "--registry", "-r", help="Manifest to read."
483+
),
484+
) -> None:
485+
"""Resolve an IRI to its owning bundle, Concept ID, and source URL (offline).
486+
487+
Exits non-zero for an IRI no member owns — a tolerated dangling cross-link,
488+
not owned here.
489+
"""
490+
from lokf.registry import load_registry
491+
492+
if not registry.exists():
493+
_err(f"{registry} not found — run `lokf registry init` first")
494+
raise typer.Exit(1)
495+
res = load_registry(registry).resolve(iri)
496+
if res.external:
497+
typer.echo(f"{iri}\n external: not owned by any registered bundle")
498+
raise typer.Exit(1)
499+
typer.echo(iri)
500+
typer.echo(f" owner: {res.entry.base_iri} ({res.entry.title})")
501+
if res.concept_id:
502+
typer.echo(f" concept_id: {res.concept_id}")
503+
typer.echo(f" source_url: {res.source_url or '(none)'}")
504+
else:
505+
typer.echo(" concept_id: (namespace root — not a concept)")
506+
typer.echo(f" via: {res.via}")
507+
508+
395509
def main(argv: list[str] | None = None) -> int:
396510
"""Programmatic entry point (tests use ``typer.testing.CliRunner``).
397511

0 commit comments

Comments
 (0)