LPG Modeler
Reference

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 }
FieldMeaning
idStable element identifier. Assigned by the tool; never edit or copy it.
abstractAn abstract type contributes properties and a key to its descendants but is never instantiated. It produces no Ladybug table.
extendsA single abstract parent, optionally alias-qualified as alias:Type. Inheritance is single-parent; multi-label membership is expressed by the targets that support it.
mixinsTrait-style shared property sets, applied by name. Use these instead of copy-pasting a property block across sibling types.
keyThe property names forming this type's key, single or composite. Inherited from the parent when omitted.
propsThe type's own properties. Inherited properties are shown on the canvas but are not repeated in the file.
previousIriWritten 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.

Value constraints

A property may bound its own values. min and max apply to anything ordered — int, float, date, datetime. pattern, minLength and maxLength apply to string. Each is checked against the type, so a pattern on an integer is an error rather than a silent no-op.

age:   { type: int, min: 0, max: 130 }
email: { type: string, pattern: "^[^@]+@[^@]+$" }
phone: { type: string, minLength: 7, maxLength: 20 }

SHACL expresses all five and LinkML all but length. The other five targets can hold none of them — measured, not assumed: LadybugDB rejects CHECK outright. Each says so, at info rather than warning, so the constraint reports do not bury the downgrades that are genuinely surprising.

Constraints across properties

A node type may declare constraints spanning more than one property: a comparison, a choice, or a count over one of its edges. Each has a name, an assertion and an optional message.

nodes:
  Booking:
    constraints:
      - name: endAfterStart
        assert: { lessThan: [startDate, endDate] }
        message: a booking must end after it starts
      - name: reachable
        assert: { atLeastOne: [email, phone] }
      - name: oneLeadGuest
        assert: { count: { edge: BOOKED_BY, of: Guest, min: 1, max: 1 } }

Seven kinds, and no more: lessThan, lessThanOrEquals, equals, disjoint, atLeastOne, exactlyOne, and count. Constraints are not inherited.

Why the vocabulary is closed. It is the load-bearing decision. A closed vocabulary can be translated per target or honestly downgraded, where a raw expression could only ever be passed through to one — which would forfeit the one-model-many-artifacts story entirely. It also lets the canvas offer a form per kind, with every operand a dropdown of the type's own properties, instead of shipping an expression parser.

The escape hatch

Anything the vocabulary cannot say is written as raw SHACL and spliced into that type's shape verbatim.

    shacl: |
      sh:property [
        sh:path bk:nights ;
        sh:severity sh:Warning ;
        sh:maxInclusive 14 ;
      ] ;

It is deliberately unportable, and that is stated rather than hidden: only the shacl target uses it, and every other target reports that it ignored it. Widening the vocabulary is always the better fix — the hatch is what makes waiting for that bearable.

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, either as one of four named forms or as a bound per end. The bound written at an end says how many nodes at that end may relate to one node at the other — the UML reading. The default is unbounded at both ends, written by leaving the field out.

edges:
  DRIVES:
    from: Driver
    to: Vehicle
    cardinality: many-to-one          # MANY_ONE is accepted too

  HAS_PARENT:                          # exactly two parents
    from: Child
    to: Person
    cardinality: { to: "2" }

  HELD_BY:
    from: Passport
    to: Person
    cardinality: { from: "0..1", to: "1..*" }

A bound is * for unbounded, an exact count such as 2, or a range such as 1..2 or 1..*. The named forms are sugar: many-to-one is { from: "*", to: "0..1" }.

Why bounds replaced a four-value enum. The enum could not say the thing people actually ask for. “A child has exactly two parents” is { to: "2" }, and no combination of many and one expresses it. The named forms stayed because they read better for the common cases, and because every model written before bounds used them.

This one is genuinely enforced where it can be. SHACL carries it exactly, in both directions: the forward bound as sh:minCount and sh:maxCount, the reverse as the same counts under a sh:inversePath. LadybugDB rejects a violating write — measured against a running instance, not assumed — but its multiplicity keyword encodes only an upper bound of one per end, so it emits the strongest keyword that fits and reports whatever the keyword drops. Neo4j, GQL, OWL and PG-Schema report it whole.

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:

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.

CodeSeverityMeaning
missing-namespaceerrorThe model declares no prefix and base IRI.
missing-keyerrorA concrete node type has no key, inherited or declared.
key-unknown-propertyerrorA key names a property the type does not have.
unresolved-parenterrorextends names a type that does not resolve.
unresolved-mixinerrorA mixin name does not resolve.
cyclic-inheritanceerrorThe extends chain forms a cycle.
missing-endpointerrorAn edge omits from or to.
unresolved-endpointerrorAn edge endpoint names a type that does not resolve.
missing-importerrorAn imported file cannot be read.
sealed-importerrorThe model attempts to mutate an imported definition.
type-in-no-viewwarningA type appears on no diagram. Views drift as a model grows.

Where to go next