Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Connecting concepts and relations

A relation is first-class, but where it is declared expresses its semantic association with the participating concepts. Three placements coexist; the modeler picks by intent:

  • Module level — a free predicate owned by no single endpoint: pub rel ConnectedTo(a: Node, b: Node) [0..*] [0..*];, accessed as ConnectedTo(x, y) anywhere in the module.
  • Topic mod — grouped with related concepts; accessed as family::ParentOf(a, b) after use family::*;.
  • impl Type (impl Type { … } — grouping) — conceptually “about” the type, alongside its methods; accessed as Person::ParentOf(a, b) only, not at module level.

Navigation from a concept value to its participations. Three mechanisms link an instance to the relations it participates in.

1. Field-form navigation view (cheapest, declarative):

pub type Person {
    parents:  [Person] from ParentOf.parent,    // alice.parents
    children: [Person] from ParentOf.child,     // alice.children
}

A field may be scalar when the relation is binary and the projected endpoint is functional — its declared cardinality caps it at one ([0..1] / [1..1]). For a binary relation the holder is the sole other endpoint, so a functional value endpoint is a holder→value dependency: one value per holder. The scalar view then yields a single value, not a collection:

pub type Person {
    manager: Person from HasManager.manager,    // a single Person, not [Person]
}

A scalar view over a non-functional endpoint is refused: the endpoint may back several tuples for one holder, so a single value is ill-defined. Declare the field as a collection ([T]) or constrain the endpoint:

pub type Person {
    parent: Person from ParentOf.parent,    // scalar over a [0..*] endpoint
}
pub rel ParentOf(parent: Person, child: Person) [0..*] [0..*];

A scalar view over an n-ary relation is refused even when the value endpoint is [0..1]: the bracket bounds the value per fixed combination of the other endpoints, not per holder, so it is not a holder→value functional dependency. Here [0..1] bounds salary per (person, company) pair, but one person may hold two employments and so two salaries — which a scalar projection would silently collapse. Project the whole set as a collection, or model the dependency with a binary relation:

pub type Company;
pub rel Employment(person: Person, company: Company, salary: Int) [0..*] [0..*] [0..1];
pub type Person {
    pay: Int from Employment.salary,    // scalar over a ternary relation
}

A collection view over an n-ary relation is admitted — it projects the whole endpoint set — provided the holder endpoint is unambiguous. The holder is the endpoint the declaring concept occupies, resolved by type: exactly one non-value endpoint whose type is comparable with the declaring concept. If no non-value endpoint is comparable the holder can never occupy the relation, so the view is statically empty; if two or more are, from Rel.endpoint cannot say which is the holder. Both are refused (OE1376). A binary relation is never ambiguous — its holder is the sole other endpoint — so this only constrains arity ≥ 3:

pub type Item;
pub rel Pairing(buyer: Company, seller: Company, item: Item);
pub type Company {
    deals: [Item] from Pairing.item,    // holder ambiguous: buyer or seller?
}

Each field is a derived view over the relation. The compiler verifies the field type matches the role’s endpoint type and that cardinality is consistent with the relation’s per-endpoint cardinality (a scalar field over a non-functional endpoint, or over any n-ary relation, is refused — OE1370; a collection field over an n-ary relation whose holder is not uniquely type-resolvable is refused — OE1376). The view materializes against the relation’s closed extent: because relation subsumption (Relations) makes a subsumed relation’s tuples tuples of its supersuming relation, a view over Rel.endpoint includes the endpoints contributed by every <:-subsumed relation — so a subtype that refines an inherited collection by projecting from a subsumed relation (SatisfactionAccount.records from satisfactionAccountRecords <: recordAccountRecords) sees exactly its narrower elements.

Filtering the view — where. A navigation view keeps only the projected elements that are iof the field’s element type; a where clause narrows it further. The clause is one expression, not a braced body, and it binds exactly one variable: the projected endpoint, named by the endpoint selector the from clause spells. Module-scope declarations are visible as usual, so a type test, a rule call and a field predicate all resolve:

pub type Drawing {
    circles:    [Circle] from Contains.shape where shape : Circle,      // type test
    bigCircles: [Circle] from Contains.shape where shape.radius > 10,   // field predicate
}

self is not bound here. A refinement body binds self to the individual whose membership is being decided (Refinement), but a navigation-view filter runs over the projected elements, so a filter written against self names nothing and is refused (OE0107) with the endpoint variable it should have named. Nor are the relation’s other endpoints bound — including the holder, the individual whose field is being computed — so a condition comparing a projected element against the holder’s own state cannot be written in the clause; express it as a rule and call the rule with the endpoint variable.

pub type Shape { radius: Int }
pub type Circle <: Shape;
pub rel Contains(drawing: Drawing, shape: Shape);
pub type Drawing {
    circles: [Circle] from Contains.shape where self.radius > 10,    // `self` is unbound
}

2. UFCS method via impl (richer navigation):

impl Person {
    pub query siblings(self) -> [Person] {
        select s from ParentOf(p, self), ParentOf(p, s) where s != self
    }
}

Methods are appropriate when navigation requires filtering, joining, ordering, or aggregation beyond a simple projection. Accessed as alice.siblings().

3. Direct rule-body invocation (always available):

pub derive grandparent(g: Person, c: Person) :- ParentOf(g, p), ParentOf(p, c);

Inside any rule body, the relation is a predicate addressable by name (or by qualified path: family::ParentOf(...), Person::ParentOf(...)).

Inverse declaration (when both sides are field-form):

pub type Country { citizens: [Citizen] }
pub type Citizen {
    #[inverse(Country.citizens)]
    countries: [Country],
}

The compiler verifies cardinality consistency and exposes both navigation directions over a single synthesized relation.

Naming convention. Relation names are CamelCase (per Identifiers) — ParentOf, Marriage, IsCitizenOf — symmetric with the CamelCase of metatype-introduced concepts. The name plus argument order encodes semantic direction; ParentOf(p, c) reads “p is the parent of c.” The convention is the modeler’s responsibility — the language does not enforce it. Use field-form views or methods to expose semantically-named navigation in the opposite direction.

Fact heads resolve in the declaring file’s scope; individuals travel by bare name. A pub fact P(...) head must resolve to a concept or relation in the file’s fact-head scope: the concepts and relations the file itself declares, plus imported ones its use/prelude scope reaches by module-qualified path (use vocab::Executable; and a pub use re-export hub both work). A head that resolves to nothing refuses with OE0220 FactReferencesUnknownPredicate; when the name is declared elsewhere in the workspace, the refusal names the declaring module and the working pattern. That pattern rests on identity: an individual’s identity is the global bare-name hashalice is the same individual from every module — so the canonical cross-module shape is to mint where the concept is declared (pub fact Person(alice); in the concept’s own module) and reference anywhere by bare name (in rule bodies, mutate bodies, and as relation-fact arguments in other modules).

Facts assert propositions, not events. A relation’s extent is a set: asserting the same pub fact twice — same relation, same arguments — adds nothing, because the two identical assertions are one tuple. A bare proposition is true once, however many times it is written. Because a literal in-file repeat is therefore almost always a copy-paste slip, the compiler warns on a same-file literal duplicate pub fact (duplicate-fact-in-file, a suppressible warning). The remedy is to delete the duplicate; or, if the repeats are meant to record distinct events (a marriage that happened twice, a payment repeated), give each fact its own identity — add an event-id endpoint so the arguments differ and both tuples survive. Duplicates are matched on the canonical encoding of the fact, not its source spelling — whitespace and formatting differences do not matter, and a relation reached through a renamed import (use pkg::Married as Wed;) is the same relation as the original name, so Married(a, b) and Wed(a, b) are one fact and warn. The check is deliberately narrow: it fires only within one file. The same fact asserted in two different files, or re-inserted by a mutate, is legitimate idempotent re-assertion and is never flagged.