What a model may legally say
A labeled property graph core, extended with an abstract label hierarchy, mixins, first-class identity, and stable element identifiers. This page is the whole surface.
File shape
A model is a YAML file named *.lpg.yaml or *.lpg.yml, validated
against a JSON Schema the extension contributes. Only namespace is required.
lpg: "1.0" # optional, format version
$schema: … # optional, points at the JSON Schema
namespace: { prefix: …, iri: … } # required
prefixes: { pfx: … } # optional
imports: [ { path: …, as: … } ] # optional
mixins: { Name: { props: … } } # optional
enums: { Name: { values: [ … ] } } # optional
nodes: { Name: { … } } # optional
edges: { NAME: { from: …, to: … } }
Format version
A file may declare the version of the format it is written against. A file that declares nothing is read as 1.0 — which is what every model written before the key existed is, so adding it is never required.
A major version this build does not know is a warning rather than an error: the keys it
understands still resolve, so a model from a future version stays readable instead of
becoming opaque. The optional $schema key points at the JSON Schema, so a
generic validator outside VS Code can find it — the schema is written against JSON Schema
2020-12.
Prefixes
Beyond its own namespace, a model may bind further prefixes to base IRIs. The shape is a JSON-LD context — prefix to base IRI, nothing else — and every binding is declared in the RDF documents generated from the model.
prefixes:
dct: http://purl.org/dc/terms/
Without this, a model that mentions a vocabulary by CURIE emits a document where that CURIE is unbound, which no RDF parser will read. Bindings resolve across a closure with the entry file winning, so a consumer can rebind a vendored vocabulary without editing it.
Namespace
Each model declares a prefix and a base IRI. The IRI is the global identity of every type the model defines — an import alias is only a local binding.
namespace:
prefix: social
iri: https://example.org/vocab/social#
Identity by IRI rather than by file path is what makes the diamond case resolve correctly when the same vocabulary is vendored at two different paths. It also means renaming a type changes its identity for RDF consumers, which is why a rename records the previous IRI — see renames.
Node types
nodes:
Person:
id: n_person # stable element id, written by the tool
abstract: false # abstract types get no table and no instances
extends: Party # single abstract parent
mixins: [Timestamped] # shared property sets
key: [email] # inherited when omitted
props:
email: { type: string, required: true, unique: true }
born: { type: date }
| Field | Meaning |
|---|---|
id | Stable element identifier. Assigned by the tool; never edit or copy it. |
abstract | An abstract type contributes properties and a key to its descendants but is never instantiated. It produces no Ladybug table. |
extends | A single abstract parent, optionally alias-qualified as alias:Type. Inheritance is single-parent; multi-label membership is expressed by the targets that support it. |
mixins | Trait-style shared property sets, applied by name. Use these instead of copy-pasting a property block across sibling types. |
key | The property names forming this type's key, single or composite. Inherited from the parent when omitted. |
props | The type's own properties. Inherited properties are shown on the canvas but are not repeated in the file. |
previousIri | Written on rename so ontology consumers keep resolving the old identity. |
Open and closed types
A node type is closed by default: an instance carries only the properties the type
declares. open: true admits others. Openness is never inherited — a subtype
that quietly widened its parent’s contract would give the reader of the parent no
way to see it.
Why this is a modelling concept and not an emitter detail. The
targets genuinely disagree. LadybugDB’s schema is mandatory and closed, so an open
type is a downgrade there. Neo4j is schema-optional and cannot enforce closure either
way. SHACL expresses it exactly, as sh:closed. PG-Schema has an
OPEN keyword. Without the concept, a model could not say which it meant.
Properties
email: { type: string, required: true, unique: true }
Eight scalar types, chosen because every target can carry all of them:
string int float boolean date datetime uuid json
Each one also answers to its GQL name, so a model can read the way the
schema it generates does. Matching ignores case and treats an underscore as a space, so
ZONED_DATETIME and zoned datetime are one type. Both spellings
mean exactly the same thing — this is vocabulary, not a second type system.
copies: { type: INTEGER } # same as int
pressed: { type: ZONED_DATETIME } # same as datetime
shipped: { type: BOOL } # same as boolean
The mapping is not total in the other direction: uuid and json
have no GQL value type and stay reported downgrades, as they already were for RDF.
Lists
A property may hold a list of its type rather than one value. Three spellings, all meaning the same thing:
tags: { type: string, list: true }
tags: { type: LIST<STRING> } # the GQL form
tags: { type: STRING[] } # the bracket form
Lists cost nothing to carry: LadybugDB stores a STRING[] column, Neo4j
stores arrays natively, GQL and PG-Schema spell the type LIST<…>, and
LinkML calls it multivalued. A list may not be part of a
key — a key identifies one node, and a list of values cannot.
Enums
An enum names a set of permitted string values. A property references one with
enum:, and its type must be string.
enums:
Status:
values: [active, retired]
nodes:
Driver:
props:
status: { type: string, enum: Status }
Only some targets can enforce a value set. SHACL turns it into sh:in, OWL
into a datatype definition with owl:oneOf, and LinkML into
permissible_values. LadybugDB, Neo4j, GQL and PG-Schema have nowhere to put
it, and each reports a downgrade rather than pretending the set holds.
required and unique both default to false. Whether they are
actually enforced depends on the target, and any target that cannot enforce one says so —
see Targets.
Identity
Every concrete node type declares exactly one key; validation fails without one. One
concept pays off three times: a Ladybug PRIMARY KEY, a Neo4j
NODE KEY constraint, and owl:hasKey in the ontology.
Two caveats worth knowing before you model. Ladybug primary keys are single-column, so a composite key is emitted as a synthesized concatenated column. And because Ladybug flattens a hierarchy to one table per concrete type, key uniqueness is enforced per table — two subtypes of the same abstract parent can hold the same key value.
Edge types
edges:
OWNS:
id: e_owns
from: Party # an abstract endpoint expands per concrete subtype
to: Car
props:
since: { type: date }
Edges are binary, typed, directed, and may carry properties. An endpoint may be an abstract type, in which case targets without inheritance expand it to a cross-product of concrete endpoint pairs.
Edges that are themselves endpoints of other edges — the metagraph case — are deliberately outside the core. Neo4j cannot represent them natively, so admitting them would force every generator to grow a silent reification path.
Cardinality
An edge type may declare endpoint multiplicity, read as
<from end>-to-<to end>. many-to-one says each source
node has at most one target. The default is many-to-many, which constrains
nothing.
edges:
DRIVES:
from: Driver
to: Vehicle
cardinality: many-to-one # MANY_ONE is accepted too
This one is genuinely enforced where it can be. LadybugDB rejects a violating write —
measured against a running instance, not assumed — and SHACL expresses both directions,
the forward bound as sh:maxCount and the reverse as a
sh:inversePath shape. Neo4j, GQL and OWL each report it as a downgrade.
Mixins
mixins:
Timestamped:
id: m_stamp
props:
createdAt: { type: datetime, required: true }
A mixin is a named property set with no identity of its own. It contributes properties to
every type that applies it, and contributes nothing to the type hierarchy — use
extends when you want subClassOf in the ontology.
Composition across files
imports:
- { path: ./common/party.lpg.yaml, as: common }
nodes:
Employee:
extends: common:Party
props:
badge: { type: string, required: true }
An importing model may subtype an imported label, apply an imported mixin, and declare
edges touching imported types. It may never mutate an imported definition — that is
reported as a sealed-import error.
Sealing keeps imports referentially transparent: a shared type means the same thing to every consumer, so generated output is deterministic. The diamond case — two modules importing a common vocabulary into a third — resolves by IRI identity rather than by merge.
Stable element ids
Every node type, edge type, property and mixin carries a short generated identifier,
written once by the tool and never edited by hand. Ids appear as n_,
e_, p_ and m_ prefixed strings.
They do two jobs:
- They make a rename distinguishable from a delete plus an add, which a structural diff alone cannot do.
- They give diagram layout a stable anchor, so renaming a type moves nothing on screen.
Because ids travel with the text, copy-pasting a block duplicates one — validation rejects
that. Run lpg ids to backfill any element that is missing one.
Renames
Renaming a type through the canvas first records its pre-rename IRI as
previousIri, so the ontology can assert equivalence to the identity consumers
already hold. Deleting a node type also deletes the edge types that reference it: leaving
the reference behind would produce a model that cannot resolve.
Views and layout
Two sidecars sit beside the model. Neither carries semantics, so neither one dirties a semantic diff.
# model.views.yaml — which types each diagram shows
views:
overview:
include: ["*"]
billing:
include: ["Invoice", "Account"]
expand: 1
// model.layout.json — coordinates, keyed by stable id
{ "billing": { "n_inv": { "x": 120, "y": 40 } } }
include takes type names or "*" for everything;
expand pulls in that many further hops of neighbouring types. Layout is
pruned on save, so positions for deleted elements do not accumulate.
Validation
Errors block generation. Warnings do not.
| Code | Severity | Meaning |
|---|---|---|
missing-namespace | error | The model declares no prefix and base IRI. |
missing-key | error | A concrete node type has no key, inherited or declared. |
key-unknown-property | error | A key names a property the type does not have. |
unresolved-parent | error | extends names a type that does not resolve. |
unresolved-mixin | error | A mixin name does not resolve. |
cyclic-inheritance | error | The extends chain forms a cycle. |
missing-endpoint | error | An edge omits from or to. |
unresolved-endpoint | error | An edge endpoint names a type that does not resolve. |
missing-import | error | An imported file cannot be read. |
sealed-import | error | The model attempts to mutate an imported definition. |
type-in-no-view | warning | A type appears on no diagram. Views drift as a model grows. |