Label-first Knowledge Graph (ctx.kg)¶
ctx.kg.node / edge / match / traverse is the label-first surface on
the per-invocation Context. It lets a capability write and read
nodes, edges, and properties without declaring a @node_type, a JSON
schema, or a SHACL shape. The design sits under
ADR-0021 — one surface,
additive features: start with labels, add typing when you want
validation, nothing before. The typed ORM (ActiveGraph ORM)
lives on the same ctx.kg handle, so moving from labels to typed
nodes is a local opt-in, not a namespace switch.
Quickstart¶
from trails import capability
@capability
def jot(ctx, title: str, body: str) -> dict:
iri = ctx.kg.node(
labels=["Note"],
properties={"title": title, "body": body, "tags": ["draft"]},
)
return {"id": iri}
@capability
def link_and_list(ctx, src: str, dst: str) -> dict:
ctx.kg.edge(subject=src, label="references", object=dst)
hits = ctx.kg.match(labels=["Note"], where={"title": "Hello"})
return {"refs": ctx.kg.traverse(subject=src, label="references"),
"matches": hits}
Every call lands through the kernel store bound to ctx, so provenance
still emits at the capability boundary per
ADR-0009.
When to use ctx.kg vs @node_type¶
Both surfaces coexist on the same Context handle. Pick by what the
data is for, not by which one you met first.
Reach for ctx.kg.node / edge / match when:
- The write is append-only — event log, observation stream, a scratch capture fine with "whatever shape arrived."
- The vocabulary is open — a scraper pulls labels from a source whose schema you do not control; typos should not crash the write.
- You are exploring and do not want to rewrite
@node_typeon every iteration. - Triples are meant to be untyped — bag-of-properties data you will SPARQL over directly, or inputs to a downstream reasoner.
Reach for @node_type + Model.where(...).fetch(ctx) when:
- The data is a domain entity with a stable shape (
Patient,Invoice,CareTeam). - You want validation — unknown fields rejected, scalars coerced,
list[T]element-checked, SHACL shapes enforced on write. - You query by suffix —
__icontains,__gte,__in, property paths,Q(...) | Q(...)— none of whichkg.matchoffers. - You want
Modelinstances back, not raw dicts of lexical strings.
The two layers are namespace-disjoint (see the IRI map below), so you
can stand up one today and layer the other on without migrating.
Adding @node_type("Note", ...) next week does not retype existing
label-first "Note" data.
kg.node(labels=, properties=)¶
iri = ctx.kg.node(
labels=["Evidence", "Archived"],
properties={"title": "scan-42", "tags": ["draft", "urgent"], "pages": 12},
)
Signature. node(*, labels, properties=None) -> str — keyword-only.
labels must be a non-empty list of non-empty strings; whitespace and
the IRI-breakers <, >, ", ', \ are rejected up front.
properties is an optional dict[str, value-or-list].
IRI minting. One INSERT DATA runs through the kernel store. The
subject is <prefix>node/<uuidv7>; <prefix> follows the same rule
@node_type uses ([project].base_iri from trails.toml if set, else
trails://<project_name>/). Each label adds one rdf:type triple at
<prefix>label/<Name>; each property adds one triple with predicate
<prefix>prop/<key>.
Multi-label. Every label emits its own rdf:type triple — a later
kg.match(labels=[...]) intersects, so multi-labelling makes a node
discoverable under each.
List-valued properties. A list value fans out to one triple per
element sharing the same predicate (set semantics — no rdf:List, no
order on read). None skips emission. Scalars are lowered via
_sparql_literal: str → string literal, int → xsd:integer,
float → xsd:decimal, bool → xsd:boolean, datetime →
xsd:dateTime.
No validation, no SHACL. A typo like labels=["Evidnece"] silently
mints a new label bucket — deliberate; a trails doctor lint for
singleton labels is planned.
kg.edge(subject=, label=, object=)¶
One triple: <src_iri> <prefix>edge/<label> <dst_iri>. Single-triple
semantics — no reverse edge, no auto-creation of src_iri / dst_iri
as nodes. subject and object are IRI strings (typically from
kg.node(...) or Model.id). The edge/ segment is disjoint from
prop/ and @node_type field IRIs, so edges never alias scalar
properties even when the names collide.
kg.match(labels=, types=, where=)¶
# Pure label-first — subjects carrying every listed label.
hits = ctx.kg.match(labels=["Note"], where={"title": "Hello"})
# By @node_type class — resolves to its minted type IRI.
hits = ctx.kg.match(types=[Note], where={"title": "Hello"})
# By @node_type IRI string — when the class isn't imported.
hits = ctx.kg.match(types=["trails://myapp/Note"])
# ANDed — subject must carry every label triple AND every type triple.
hits = ctx.kg.match(labels=["Archived"], types=[Note])
Discovery surface. labels= matches <prefix>label/<name> type
triples; types= matches a @node_type's minted <prefix>Name type
IRI. Both sets of constraints AND together. Passing neither raises —
a whole-graph dump is intentionally not supported.
where= predicate resolution. Filters are exact-equality patterns
and resolve type-first: when types= is set and the key matches a
field on any listed type, meta.field_iri(key) is used; otherwise the
filter falls back to <prefix>prop/<key>. Pure label-first calls
always use the prop/ shape. For inequalities, __icontains, __lte,
or property paths, declare @node_type and use
Model.where(...).fetch(ctx).
Return shape. list[dict], one per matching subject. Every dict
carries "iri" plus a name/value entry for each <prefix>prop/...
triple on that subject; when types= is set the type's field
predicates are hydrated under the same keys (typed data surfaces under
the declared field names). Multi-valued predicates collapse into a
list. Values come back as raw lexical strings — no type coercion.
Reach for Model.where(...).fetch(ctx) when Python-typed results
matter.
kg.traverse(subject=, label=)¶
One-hop neighbour lookup. Resolves <prefix>edge/<label> and returns
the object IRIs of <subject> <pred> ?o as list[str]. No inverse
traversal, no multi-hop paths — drop to ctx.kg.query(...) with a
SPARQL property path when the shape is more than one hop. Empty list
on missing subject or missing edge; no exception.
trails kg CLI¶
Ad-hoc graph queries against a running project. The Rails-console niche
for SPARQL — autoloads the project (registers @node_type /
@capability), reuses the kernel store singleton, no server boot.
Introduced in commit 246af12.
# SPARQL SELECT — table by default, --json for machine output.
trails kg query "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5"
trails kg q "SELECT ?s WHERE { ?s a <trails://myapp/label/Note> }" --json
# SPARQL ASK — prints true / false.
trails kg ask "ASK { ?s a <trails://myapp/label/Note> }"
# Dump — triples for a subject IRI, or every node under a label name.
trails kg dump Note # label-first bucket
trails kg dump trails://myapp/Note/0195… # one subject
# Count — total triples plus per-named-graph breakdown.
trails kg count
trails kg c --graph trails://myapp/graph/default
Every subcommand takes --json for machine output; query and dump
also take --limit N to truncate. query and ask take --graph IRI
to wrap the WHERE body in a GRAPH <iri> { ... } block. Short-form
aliases: q → query, c → count.
IRI namespace map¶
Label-first and @node_type mint into disjoint segments under the same
project prefix, so the two layers never cross-match on rdf:type.
| Kind | Shape (label-first) | Shape (@node_type) |
|---|---|---|
| Subject | <prefix>node/<uuid7> |
<prefix><Label>/<uuid7> |
| Type triple | <prefix>label/<Name> |
<prefix><Label> |
| Scalar predicate | <prefix>prop/<Name> |
<prefix><Label>/<field> |
| Edge predicate | <prefix>edge/<Name> |
<prefix><Label>/<ref-field> |
<prefix> is [project].base_iri (verbatim, slash-normalised) when set
in trails.toml, else trails://<project_name>/. The disjoint
segments are load-bearing: they let a @node_type("Note") class coexist
with label-first "Note" data without retyping. To promote a
label-first bucket to a typed class, run a one-time data move —
DELETE { ?s rdf:type <.../label/Note> } INSERT { ?s rdf:type
<.../Note> } WHERE { ... } plus per-property predicate renames.
Anti-patterns¶
Label-first as a cheap @node_type substitute when validation
matters. kg.node does not coerce, does not reject unknowns, does not
run SHACL. If the write path has any invariant you care about — a
required field, an enum, a cross-field rule — declare @node_type and
take the type check. Catching a typo with trails doctor six sprints
later is not the same.
Mixing the two layers in the same domain entity. Do not persist the
same conceptual entity half through kg.node(labels=["Patient"], ...)
and half through @node_type("Patient", ...). The two live in disjoint
IRI namespaces — reads split down the middle, kg.match and
Patient.where each see half the data, and the repair is a migration.
Pick one layer per entity; graduate whole at once.
Reference¶
Every public symbol on ctx.kg plus the trails kg CLI surface.
| Symbol | One-liner |
|---|---|
ctx.kg.node(*, labels, properties=None) -> str |
Create a label-first node; returns the minted subject IRI. |
ctx.kg.edge(*, subject, label, object) -> None |
Insert one edge triple <subject> <prefix>edge/<label> <object>. |
ctx.kg.match(*, labels=None, types=None, where=None) -> list[dict] |
Intersect label + @node_type type triples; exact-equality where= filters; returns hydrated dicts. |
ctx.kg.traverse(*, subject, label) -> list[str] |
One-hop neighbour IRIs reached by <prefix>edge/<label>. |
ctx.kg.add / save / find / where / query / update |
Typed ORM surface; see ActiveGraph ORM. |
trails kg query "<sparql>" (alias q) |
Run a SELECT; table by default, --json, --limit N, --graph IRI. |
trails kg ask "<sparql>" |
Run an ASK; prints true / false. --json, --graph IRI. |
trails kg dump <label-or-iri> |
Triples for a subject IRI, or every label-first node for a label name. --json, --limit N. |
trails kg count (alias c) |
Total triple count plus per-named-graph breakdown. --json, --graph IRI. |