Skip to content

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_type on 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 which kg.match offers.
  • You want Model instances 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, intxsd:integer, floatxsd:decimal, boolxsd:boolean, datetimexsd: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=)

ctx.kg.edge(subject=src_iri, label="references", object=dst_iri)

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=)

neighbors = ctx.kg.traverse(subject=note_iri, label="references")

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: qquery, ccount.

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.