Skip to content

Commit 3e9f63c

Browse files
authored
Merge pull request #9 from djarecka/ref/linkml_ingestion
Ref/linkml ingestion
2 parents c8c9302 + 8d7cca5 commit 3e9f63c

12 files changed

Lines changed: 554 additions & 25 deletions

File tree

docs/ingestion.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# How schema ingestion works
2+
3+
> **Status: living document.** This describes the registry as of the
4+
> `ref/linkml_ingestion` rework (content-addressed identity). It will be
5+
> updated as ingestion continues to change — treat it as the current source
6+
> of truth, not a historical record.
7+
8+
## The problem this solves
9+
10+
Every neuroscience data standard (BIDS, NWB, DANDI, openMINDS, AIND, ...)
11+
defines its own vocabulary. The same concept — "age", "subject", "device
12+
manufacturer" — gets redefined independently in each one, usually under a
13+
different name, attached to a different class. The registry's job is to
14+
notice when two things from different schemas are actually the same concept,
15+
without a human manually saying so for every pair.
16+
17+
The previous design gave every ingested class/property a random,
18+
UUID-derived `hash_id` and tracked "is this the same as before" by looking
19+
up `(iri, source_label)` and diffing fields by hand. That only ever compared
20+
a schema against *its own* prior ingestions — it had no way to notice that
21+
two *different* schemas defined the same thing.
22+
23+
## The core idea: content-addressed identity
24+
25+
A `RegistryClass`/`RegistryProperty`'s `hash_id` is now a SHA-256 hash of its
26+
own content — nothing else. Two properties with the same `name`,
27+
`description`, `range`, and `units` get the **same** `hash_id`, regardless of
28+
which schema they came from, what it's called there, or when it was
29+
ingested. Identity is separate from provenance: instead of creating a second
30+
node for a second source, the *existing* node gets a second `ProvenanceEntry`
31+
recording that this source also attests to it.
32+
33+
```
34+
schema_a.yml: Subject.age (name=age, description=..., range=integer)
35+
schema_b.yml: Participant.age (same content, different class)
36+
37+
38+
ONE RegistryProperty node (hash_id = hash of the content)
39+
├── ProvenanceEntry{source: "schema_a", ...}
40+
└── ProvenanceEntry{source: "schema_b", ...}
41+
```
42+
43+
No alignment step, no manual annotation — pure hash equality gives you this
44+
for free. (Real alignment — noticing that *differently*-named/described
45+
concepts are related — is a separate, deliberately basic step for now; see
46+
[Alignment](#alignment) below.)
47+
48+
## The pipeline
49+
50+
```
51+
LinkML YAML
52+
│ parse_linkml() — SchemaView-based parsing
53+
54+
intermediate dict {classes: {...}, slots: {...}}
55+
│ build_registry_entities() — compute content hashes
56+
57+
RegistryProperty / RegistryClass instances (Pydantic, hash_id already set)
58+
│ write_registry_entities() — write-if-new, attach-provenance-if-new
59+
│ write_structural_edges() — HAS_PROPERTY, SUBCLASS_OF
60+
61+
LadybugDB graph
62+
```
63+
64+
### 1. `parse_linkml()` (`neuro_ghost/ingest_linkml.py`)
65+
66+
Loads the YAML via `linkml_runtime`'s `SchemaView`, not a hand-rolled YAML
67+
walk. This matters because `SchemaView.class_induced_slots()` resolves real
68+
LinkML semantics — a class's *effective* slot list includes everything
69+
inherited via `is_a` or `mixins`, and everything declared inline as
70+
`attributes:` — before ingestion ever sees the data. A class that declares no
71+
slots of its own but has `is_a: Device` still gets `Device`'s slots.
72+
73+
Output is a plain dict: `{"meta": {...}, "classes": {name: {iri, definition,
74+
is_a, is_abstract, slots}}, "slots": {name: {iri, definition, value_range,
75+
units, multivalued, required, pattern}}}`.
76+
77+
### 2. `build_registry_entities()`
78+
79+
Converts the dict into real, hash-identified objects:
80+
81+
- **Properties are built first.** Each one's `hash_id` is computed from
82+
`name`/`description`/`range`/`units` only — `slot_uri` and
83+
`registry_version` are stored on the object but excluded from the hash
84+
(they're provenance/operational metadata, not identity).
85+
- **Classes reference their properties by hash_id**, sorted. This is why a
86+
class's own hash depends on its full induced property set — two classes
87+
with the same properties (regardless of declaration order) hash the same.
88+
- **`is_a` is resolved recursively to the parent's hash_id**, not its name —
89+
so multi-level hierarchies resolve correctly regardless of the order
90+
classes appear in the file. A schema that parses at all is guaranteed to
91+
have every `is_a` target resolvable within its own import closure (that's
92+
`SchemaView`'s job), so this recursion always terminates cleanly.
93+
- **Every entity gets one `ProvenanceEntry`** for this ingestion — `source`,
94+
`attributed_to` (agent), `generated_at`, `activity` — entirely separate
95+
from the hash computation.
96+
97+
### 3. `write_registry_entities()` + `write_structural_edges()` (`neuro_ghost/db.py`)
98+
99+
For each entity: does a node with this `hash_id` already exist?
100+
- **No** → create it.
101+
- **Either way** → attach this ingestion's `ProvenanceEntry`, *unless this
102+
exact source already has one on that node* (idempotent re-ingestion: running
103+
the same file from the same source twice adds nothing the second time).
104+
105+
Then `HAS_PROPERTY` and `SUBCLASS_OF` edges get created from the resolved
106+
hash references. These two functions are shared between `ingest_linkml.py`
107+
and `seed.py` — schema.org is ingested through the exact same path, just with
108+
`source="schema.org"`.
109+
110+
## The data model
111+
112+
| Field | On | Notes |
113+
|---|---|---|
114+
| `hash_id` | every entity | Content-derived. `RegistryClass`/`RegistryProperty` only — `ProvenanceEntry` uses a random `uid` instead, since it's a per-attestation record, not a deduplicated concept. |
115+
| `name`, `description` | `RegistryClass`, `RegistryProperty` | Identity-defining (part of the hash). |
116+
| `range`, `units` | `RegistryProperty` | Identity-defining. |
117+
| `properties`, `is_a`, `mixins` | `RegistryClass` | Identity-defining — all stored as hash_id references. |
118+
| `class_uri` / `slot_uri` | `RegistryClass` / `RegistryProperty` | Ontology IRI preserved from the source. **Not** part of the hash — two schemas using different IRIs (or none) for the same content still collapse to one node. |
119+
| `registry_version` | both | Operational stamp. Not part of the hash. |
120+
| `provenance` | both | List of `ProvenanceEntry`. Accumulates, never affects `hash_id`. |
121+
| `source`, `attributed_to`, `generated_at`, `activity`, `derived_from` | `ProvenanceEntry` | PROV-O–grounded (`slot_uri: prov:wasAttributedTo` etc.) |
122+
123+
Field names were deliberately aligned with LinkML's own metamodel
124+
(`description`, `range`, `class_uri`/`slot_uri`, `is_a`, `abstract`) rather
125+
than inventing parallel terminology — e.g. `parse_linkml()` already produces
126+
`is_a` straight from `SchemaView`, so there's no translation step.
127+
128+
**Deliberately not modeled yet:** `required`/`multivalued` used to live on
129+
`RegistryProperty` directly, which meant a property required in one schema's
130+
usage and optional in another's could never share a hash. They've been
131+
removed entirely — that's a **`Rule`** concern (still a stub), not identity.
132+
133+
## Alignment
134+
135+
`neuro_ghost/align.py` runs *after* ingestion, computing a similarity score
136+
(IRI exact-match + embedding-based name/description similarity) between
137+
already-distinct `hash_id`s and writing `ALIGNED_TO` edges. It never merges
138+
identities — that's deliberate for now. Content-hashing already handles
139+
"these are byte-for-byte the same"; alignment's job is "these are *related*
140+
but not identical" (`age_years` vs `age_at_scan`), which needs real
141+
similarity judgment. Until that's built out, ordering it after commit (not
142+
before, the way some richer designs do) is the correct choice — there's
143+
nothing yet that could inform the hash before commit anyway.
144+
145+
## Testing: two layers, don't conflate them
146+
147+
`tests/test_ingest_linkml.py` tests two distinct steps against the same
148+
fixture (`tests/fixtures/comprehensive.yml`, which packs a mixin, an
149+
abstract base, `is_a` inheritance, both `slots:` and inline `attributes:`,
150+
prefix resolution from both the schema's own `prefixes:` and the built-in
151+
fallback map, `multivalued`/`required`/`pattern`, and a units-in-description
152+
extraction into one schema) — and it matters which one a given assertion is
153+
about:
154+
155+
- **`parse_linkml()`'s intermediate dict** legitimately has `multivalued`/
156+
`required`/`pattern` — that's a raw, general-purpose extraction of what the
157+
LinkML slot declares, independent of what the registry keeps.
158+
- **`build_registry_entities()`'s output** (`RegistryProperty`/`RegistryClass`)
159+
does **not** have those fields at all — `RegistryProperty.model_fields`
160+
doesn't even define them. Since `hash_id` is a pure content hash (no
161+
randomness), this layer's test hardcodes the exact expected hash strings —
162+
reproducible on any machine, and any change to the hash computation, the
163+
fields carried into the model, or the `is_a`/`properties` resolution shows
164+
up as a failure here.
165+
166+
Saying "parse_linkml extracts X" and "the registry stores X" are different
167+
claims — test each layer for what it actually is, not for what you assume
168+
the other layer does with it.
169+
170+
## Known gaps (as of this writing)
171+
172+
- **`index.html`** (frontend) still expects the pre-rework JSON shape
173+
(`source` singular; `multivalued`/`required` on properties). A minimal
174+
compat patch was written and then deliberately reverted — not worth fixing
175+
until a proper UI pass.
176+
- **`derived_from`** on `ProvenanceEntry` is never populated — nothing yet
177+
detects "this hash supersedes that one" (would need an anchor like
178+
`(name, source)` to correlate an edit against prior content).
179+
- **`Rule`/`Transform`/`ValueSet`** are stubs (`hash_id`/`name`/`description`
180+
only).
181+
- **`SemanticIdentity`/`PRIOR_VERSION*`** tables in `db.py`'s DDL are dead
182+
(superseded by content-hash identity) but not yet removed.
183+
- **`pandas`** isn't in `requirements.txt`, so `align.py`'s embedding cache
184+
silently no-ops (pre-existing gap).

neuro_ghost/db.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,15 @@ def write_provenance(conn, label: str, hash_id: str, prov) -> bool:
123123
conn.execute("""
124124
CREATE (:ProvenanceEntry {
125125
uid: $uid, source: $source, source_description: $source_description,
126+
registry_version: $registry_version,
126127
generated_at: $generated_at, attributed_to: $attributed_to,
127128
activity: $activity, derived_from: $derived_from
128129
})
129130
""", {
130131
"uid": uid,
131132
"source": prov.source,
132133
"source_description": prov.source_description,
134+
"registry_version": prov.registry_version,
133135
"generated_at": prov.generated_at.isoformat(),
134136
"attributed_to": prov.attributed_to,
135137
"activity": prov.activity,

neuro_ghost/ingest_linkml.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,11 +279,13 @@ def parse_linkml(path: Path) -> dict[str, Any]:
279279
# ---------------------------------------------------------------------------
280280

281281
def _make_provenance(source_label: str, agent: str, issue: str = "",
282+
registry_version: str = "",
282283
activity: str = "ingestion") -> ProvenanceEntry:
283284
attributed_to = f"{agent} (issue #{issue})" if issue else agent
284285
return ProvenanceEntry(
285286
uid=make_uid(),
286287
source=source_label,
288+
registry_version=registry_version or None,
287289
generated_at=now_iso(),
288290
attributed_to=attributed_to,
289291
activity=activity,
@@ -292,6 +294,7 @@ def _make_provenance(source_label: str, agent: str, issue: str = "",
292294

293295
def build_registry_entities(
294296
parsed: dict, source_label: str, agent: str, issue: str = "",
297+
registry_version: str = "",
295298
) -> tuple[dict[str, RegistryProperty], dict[str, RegistryClass]]:
296299
"""
297300
Convert parse_linkml()'s intermediate dict into content-hashed
@@ -328,7 +331,7 @@ def build_registry_entities(
328331
range=slot["value_range"],
329332
units=slot.get("units") or None,
330333
slot_uri=slot["iri"] or None,
331-
provenance=[_make_provenance(source_label, agent, issue)],
334+
provenance=[_make_provenance(source_label, agent, issue, registry_version)],
332335
)
333336
prop.hash_id = compute_hash_id(prop)
334337
properties[slot_name] = prop
@@ -357,7 +360,7 @@ def resolve_class(cls_name: str) -> RegistryClass | None:
357360
abstract=cls["is_abstract"],
358361
is_a=parent_hash_id,
359362
properties=prop_hash_ids,
360-
provenance=[_make_provenance(source_label, agent, issue)],
363+
provenance=[_make_provenance(source_label, agent, issue, registry_version)],
361364
)
362365
rc.hash_id = compute_hash_id(rc)
363366
registry_classes[cls_name] = rc
@@ -459,7 +462,7 @@ def insert_schema(conn, parsed: dict, source_label: str, agent: str = "anonymous
459462
meta = parsed["meta"]
460463

461464
properties, registry_classes = build_registry_entities(
462-
parsed, source_label, agent, issue,
465+
parsed, source_label, agent, issue, registry_version,
463466
)
464467

465468
stats = write_registry_entities(conn, properties, registry_classes, dry_run=dry_run)

neuro_ghost/seed.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,15 @@ def collect_classes(g: rdflib.Graph) -> dict[str, dict]:
151151
# Insert into LadybugDB
152152
# ---------------------------------------------------------------------------
153153

154-
def _provenance(agent: str = "system") -> ProvenanceEntry:
154+
def _provenance(agent: str = "system", registry_version: str = "") -> ProvenanceEntry:
155155
return ProvenanceEntry(
156-
uid=None, source="schema.org", generated_at=now_iso(),
157-
attributed_to=agent, activity="seeding",
156+
uid=None, source="schema.org", registry_version=registry_version or None,
157+
generated_at=now_iso(), attributed_to=agent, activity="seeding",
158158
)
159159

160160

161161
def build_registry_entities(
162-
classes: dict[str, dict],
162+
classes: dict[str, dict], registry_version: str = "",
163163
) -> tuple[dict[str, RegistryProperty], dict[str, RegistryClass]]:
164164
"""
165165
Convert collect_classes()'s output into content-hashed RegistryProperty/
@@ -186,7 +186,7 @@ def build_registry_entities(
186186
description=prop["comment"] or "",
187187
range=value_range,
188188
slot_uri=prop["iri"] or None,
189-
provenance=[_provenance()],
189+
provenance=[_provenance(registry_version=registry_version)],
190190
)
191191
p.hash_id = compute_hash_id(p)
192192
properties[prop["iri"]] = p
@@ -215,7 +215,7 @@ def resolve_class(name: str) -> RegistryClass | None:
215215
abstract=False,
216216
is_a=parent.hash_id if parent else None,
217217
properties=prop_hash_ids,
218-
provenance=[_provenance()],
218+
provenance=[_provenance(registry_version=registry_version)],
219219
)
220220
rc.hash_id = compute_hash_id(rc)
221221
registry_classes[name] = rc
@@ -260,7 +260,7 @@ def seed(db_path: str = "./registry.lbug",
260260
classes = collect_classes(g)
261261

262262
print(f"Building {len(classes)} classes …")
263-
properties, registry_classes = build_registry_entities(classes)
263+
properties, registry_classes = build_registry_entities(classes, registry_version)
264264

265265
if dry_run:
266266
print(f"\n[dry-run] Would insert:")
@@ -297,9 +297,11 @@ def seed(db_path: str = "./registry.lbug",
297297
help="Print what would be inserted without writing.")
298298
@click.option("--wipe", is_flag=True,
299299
help="Delete existing classes/properties before seeding.")
300-
def cli(db: str, dry_run: bool, wipe: bool) -> None:
300+
@click.option("--registry-version", default="1.0.0", show_default=True,
301+
help="Registry semver to stamp on seeded ProvenanceEntry records.")
302+
def cli(db: str, dry_run: bool, wipe: bool, registry_version: str) -> None:
301303
"""Seed the SenseIn Schema Registry from schema.org."""
302-
seed(db_path=db, dry_run=dry_run, wipe=wipe)
304+
seed(db_path=db, dry_run=dry_run, wipe=wipe, registry_version=registry_version)
303305

304306
if __name__ == "__main__":
305307
cli()

schema_registry_utils/hashing.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
_EXCLUDED_FIELDS = {
77
"hash_id", "provenance", "skos_mappings",
88
# Operational/origin metadata, not identity-defining content: an entity's
9-
# hash_id must stay the same across sources and registry versions.
10-
"class_uri", "slot_uri", "registry_version",
9+
# hash_id must stay the same regardless of which source(s) attest to it.
10+
"class_uri", "slot_uri",
1111
}
1212

1313

1414
def compute_hash_id(entity: RegistryClass | RegistryProperty) -> str:
1515
"""Compute a content-based hash_id for a RegistryClass or RegistryProperty.
1616
17-
Everything but hash_id, provenance, skos_mappings, class_uri/slot_uri,
18-
and registry_version is treated as identity-defining content.
17+
Everything but hash_id, provenance, skos_mappings, and class_uri/slot_uri
18+
is treated as identity-defining content.
1919
"""
2020
content = entity.model_dump(exclude=_EXCLUDED_FIELDS)
2121
canonical = json.dumps(_normalize(content), sort_keys=True, separators=(",", ":"))

schema_registry_utils/models.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class ProvenanceEntry(BaseModel):
2020
uid: Optional[str] = None
2121
source: str
2222
source_description: Optional[str] = None
23+
registry_version: Optional[str] = None
2324
generated_at: datetime # prov:generatedAtTime
2425
attributed_to: str # prov:wasAttributedTo
2526
activity: Optional[str] = None # prov:wasGeneratedBy
@@ -57,11 +58,9 @@ class RegistryClass(RegistryEntity):
5758
relations: List[str] = Field(default_factory=list)
5859
is_a: Optional[str] = None
5960
mixins: List[str] = Field(default_factory=list)
60-
registry_version: Optional[str] = None
6161

6262

6363
class RegistryProperty(RegistryEntity):
6464
slot_uri: Optional[str] = None
6565
range: str
6666
units: Optional[str] = None
67-
registry_version: Optional[str] = None

0 commit comments

Comments
 (0)