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 asConnectedTo(x, y)anywhere in the module. - Topic
mod— grouped with related concepts; accessed asfamily::ParentOf(a, b)afteruse family::*;. impl Type(impl Type { … }— grouping) — conceptually “about” the type, alongside its methods; accessed asPerson::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 hash — alice 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.