This part introduces the philosophical and foundational principles that underpin the Predica language.
Predica is a formal language grounded in predicate logic, but unlike the Relational Model (RM), it does not begin with relations of arbitrary arity. Instead, Predica models the world as a collection of domains whose properties are expressed solely through unary predicates. This shift in foundation has many benefits, including direct cardinality control, precise handling of partial information, reduced reliance on surrogate identifiers, simple mappings to efficient implementations, modular schema evolution, and a powerful, navigation-oriented query language.
Like the RM, Predica is defined entirely at the level of logic and semantics. Nothing in the language assumes a particular storage model, indexing strategy, or execution engine. This physical independence means that a Predica specification can be executed on top of any suitable backend. A Predica compiler may for example target SQL, Cypher, Datalog, or a custom engine, while the Predica schema, constraints, and queries remain unchanged. This separation of logical meaning from physical representation allows Predica to serve as a portable semantic layer above diverse data systems.
A domain is a subject of discourse: it is something about which predicates record information. A domain can be almost anything, for example a geometrical point in Euclidean space or an entire shipments system containing thousands of suppliers, parts, and shipments. A domain is simply whatever the predicates are about.
The term domain is often used differently in other literature, especially in the Relational Model and in Date & Darwen, where it refers to the set of values of an attribute type (for example, the set of integers or the set of all finite Unicode text strings). In this specification, we never use the term domain to refer to a type or to its carrier set. In particular, a Predica domain is not the mathematical set over which a logical variable ranges; that set is called the carrier of a type.
So to be clear, we use the first meaning of domain, not the second:
The world is modelled as a decomposition into domains, and all information about each domain is recorded using unary predicates.
In classical logic, unary, binary, and ternary predicates are sometimes called monadic, dyadic, and triadic. Predica uses the Latin-derived terms to avoid confusion with the unrelated notion of monads in programming and category theory.
In this specification, the word predicate refers to a symbol (or name). For this reason, we usually say predicate rather than predicate symbol. The semantic object associated with a predicate is its extension.
A circle value can be regarded as a domain, having two unary predicates centre and radius.
The following relate an atomic formula for each predicate to an English gloss which we call a natural language predicate:
centre(C) ⇔ The centre of the circle is C
radius(R) ⇔ The radius of the circle is R
In the atomic formula centre(C), the symbol C is a logical variable ranging over points.
In the atomic formula radius(R), the symbol R is a logical variable ranging over real numbers.
For unary predicates, the variable name isn't important because there is only one argument position.
An instantiation of a unary predicate is an application of that predicate to one value.
For example, radius(10) is a ground atomic formula obtained by instantiating radius(R)
with the value 10.
The extension of a unary predicate is the set of values for which its instantiations are true.
Thus, if radius(10) is true, then 10 is in the extension of radius.
The extension of centre is the set:
{ C ∈ ℝ2 | centre(C) }
A circle has exactly one centre so the cardinality of this extension is one. Similarly for the radius.
The modeling of a circle with unary predicates is associated with a value type Circle having attributes
centre and radius:
value Circle
{
Point centre;
Real radius;
}
In this specification, the word attribute means the name. As with predicates, we usually say attribute rather than attribute name. Since we also use the word predicate for the name, we can say that an attribute is a unary predicate. That means we can speak of the extension of an attribute.
The centre of a circle is itself a domain, i.e. a subdomain, with unary predicates for its x and y coordinates, giving rise to another value type:
value Point
{
Real x;
Real y;
}
with unary predicates:
x(X) ⇔ The x-coordinate of the point is X
y(Y) ⇔ The y-coordinate of the point is Y
This is an example of domain decomposition.
An attribute may be derived from other attributes rather than supplied as stored state. A derived
attribute declaration begins with an arrow (→):
value Circle
{
Point centre;
Real radius;
→ Real perimeter = 2 * pi * radius;
→ Real area = pi * radius ^ 2;
}
The derived attributes perimeter and area are also unary predicates. Their extensions are
determined by their defining expressions rather than recorded independently.
Predica allows every attribute to declare its own cardinality constraint directly and locally. The following example illustrates the syntax:
value S
{
Integer a; // exactly 1
Integer b?; // 0 or 1
Integer c*; // any finite cardinality
Integer d+; // any nonzero finite cardinality
Integer e[6]; // exactly 6
Integer f[7..9]; // 7, 8 or 9
Integer g[4..*]; // any finite cardinality of at least 4
Integer h[0..∞]; // any cardinality, including infinite
Integer i[1..∞]; // any nonzero cardinality, including infinite
}
All cardinality constraints are finite unless their upper bound is explicitly ∞. Thus *, +
and an upper bound of * permit arbitrarily large finite extensions, but not infinite extensions.
The ∞ upper bound permits finite, countably infinite or uncountable extensions, subject to the
carrier of the attribute type.
For example:
value Triangle
{
Point vertex[3];
}
This introduces the unary predicate:
vertex(V) ⇔ V is a vertex of the triangle
The syntax [3] on the attribute vertex specifies a cardinality constraint on its extension:
{ V:Point | vertex(V) }
Here V:Point means the logical variable V ranges over values of type Point.
More generally, the notation V:T means logical variable V
ranges over values of type T. This notation is used in set-builder expressions
and with existential and universal quantifiers, such as ∃V:T and ∀V:T.
Predica supports two parallel forms of recursive decomposition:
In these examples both forms ultimately bottom out at the predefined simple types
(Boolean, Integer, Real and String).
Predica allows self-recursive types, for example:
value List
{
Integer head;
List tail?;
}
A type's self-recursive structure describes how domains may reference one another, but domains themselves cannot be self-recursive. A domain may contain subdomains, but it cannot contain itself as a subdomain.
The examples above use values, such as circles, as domains. An entity is a domain that is not a value, such as a person. Information about entities is likewise recorded through unary predicates. For example:
entity Person
{
Integer id;
String name;
Email email*;
PhoneNumber phone+;
Person mother?;
Person father?;
→ Person parent* = mother | father;
→ Person child* = inverse parent;
→ Person grandparent* = parent.parent;
→ Person sibling* = let p = this in parent.child.[this ≠ p];
→ Person cousin* = parent.sibling.child;
→ Boolean hasEmail = email.Σexists;
key {id};
}
The derived attribute parent combines the extensions of mother and father, and child is its
inverse. Further navigation derives grandparent, sibling, and cousin. The derived attribute
hasEmail is true exactly when the extension of email is non-empty. Like base attributes, all
of these derived attributes are unary predicates on each Person domain.
Note that we have consistently used singular names for attributes, regardless of whether
their extensions may contain multiple values.
For example, we used vertex in the singular, and here the email, phone, parent, child,
grandparent, sibling, and cousin attributes are singular.
Typically an entity will declare key constraints, for example:
key {id};
It may initially seem simpler to declare Person as an ordinary value type. The reason for the
entity distinction is that entity occurrences can be shared within a model. Schema constraints can
establish that different paths reach the same person occurrence, and an update through any one of
those paths then updates the location shared by all of them. A key must be unique within each
applicable entity-typed attribute extension and helps the constraints identify the occurrence being
reached.
For example, the same person might be reached directly from a collection of people, as the mother
of one person, and as the mother of another. Where the schema constraints establish that these
paths reach the same person occurrence, an update to that person's email through one path is
observed through the others. Equal values held independently do not have this sharing merely because
they are equal.
This does not put physical people into the formal semantics and it does not require hidden object identity. A Predica entity type still has a mathematically defined carrier of values. The entity declaration allows those values to participate in constraint-induced shared occurrences whose locations can be reached and updated through multiple paths. Later chapters define carriers, keys, constraints, and location denotation precisely.
This is like independently applying the RM to each domain, because each domain has its own set of named relations that record the extensions of its predicates.
Note however:
Consider the following schema:
entity City
{
String name;
key {name};
}
entity Supplier
{
String id;
City city*;
key {id};
}
entity ShipmentsSystem
{
Supplier supplier*;
}
Applying the RM recursively through nested domains means that Predica attributes can have a dual role:
city is a predicate, and from the RM viewpoint resembles a relvar recording cities.city is an attribute, and from the RM viewpoint resembles an attribute of a relvar recording suppliers.What the RM separates into relvars and attributes, Predica unifies as named attributes/predicates within domains.
Predica query expressions correspond to logic formulas over the attributes, which are unary predicates.
For example, given a variable C of type Person the path expression C.mother.father.email has
the following logic denotation:
email=E:Email
where
∃M:Person ∃F:Person
C.mother(M)
∧ M.father(F)
∧ F.email(E)
Here the attributes mother, father, and email appear in the formula as unary predicates.
The logical variables are M, F, and E. The result binding exposes the logical variable E
under the output attribute email.
This is not classical first-order-logic syntax, because we have qualified predicates
such as C.mother.
Predica needs qualified predicates because the world is decomposed into many domains,
each with its own predicates, whereas classical FOL assumes a single global predicate namespace.
A formula with a qualified predicate, such as C.mother(M), is not a well-formed formula in
classical first-order logic.
It can be mapped to a global binary predicate in classical first-order logic,
such as mother(C,M):
Predica can be understood as reinterpreting such global binary predicates as families of unary predicates, each scoped to a particular domain instance. This preserves the familiar shape of unary predicate instantiation while avoiding global predicate names and enabling compositional path expressions.
This avoids global namespace pollution and solves a practical naming problem.
Multiple types of domains may each have an attribute named city, for example Supplier.city,
Warehouse.city, and Person.city.
In classical FOL, these would require distinct global predicates such as Supplier_city,
Warehouse_city, and Person_city, and they could not be unary predicates because they would
need to identify which supplier, warehouse, or person is involved. Predica’s qualified naming is
cleaner, more modular, and more readable.
Predica's approach to modelling the world gives rise to entity types such as:
Supplier, Part, and Shipment.
Each of these becomes a sort for variables in logic.
You can constrain variables by these sorts and quantify over them.
For example, in the following formula the variable P ranges over parts:
∃P:Part s.part(P)
This formula can be read in two ways:
P ranges over parts, and s.part(P) says that P belongs to the naive set
of parts in the shipments system s. The formula says that this set contains at least
one part.
P ranges over the carrier of the entity type Part. This carrier is a
mathematical set of Part values defined by the language semantics, which are themselves
defined entirely in terms of mathematical logic and set theory. The formula says that at
least one of those values is in the extension of s.part. Consequently, a conforming
Predica implementation can evaluate the formula in a given program state: it is true
exactly when the extension of s.part contains at least one Part value.
This distinction applies throughout the specification: informal entities and naive sets belong to the subject matter being modelled, whereas entity values, carriers, and predicate extensions belong to the formal language semantics.
The RM has no direct analogue of this.
It has relations and simple value types, but it does not have entity types such as Part
and cannot express "a part" as a logical sort.
This absence of entity types makes the RM more cumbersome where composite keys are involved. A change to a key can require updates to many relvars with foreign keys, and can affect all applications built on top of the database. For this reason, many RM practitioners introduce surrogate identifiers to simplify foreign keys and reduce the impact of key changes.
In Predica, composite keys are used exactly when they are appropriate because the choice of
key is localised to a type.
For example, a Part may have a natural composite key with three attributes:
entity Part
{
String manufacturer;
String catalogueNumber;
Integer revision;
key {manufacturer,catalogueNumber,revision};
}
Changing a key affects only that type and does not propagate changes to other parts of the
schema, so applications built on top of the database are largely unaffected by key changes.
For example, the ShipmentSystem contains a set of parts but does not need to repeat the
three key attributes, and it does not need to change if Part later changes its key:
entity ShipmentSystem
{
Part part*;
}
Queries are independent of the chosen key as well. An application can ask for the red parts shipped by suppliers located in London:
s.supplier.[city = "London"].shipment.part.[color = Color.red]
This query navigates to Part values and filters them by colour. It does not mention the
attributes that currently form the key of Part, so it does not need to be rewritten if the key of
Part changes.
Its frame denotation has Part as the result sort:
part=P:Part
where
∃S:Supplier ∃H:Shipment
s.supplier(S)
∧ S.city("London")
∧ S.shipment(H)
∧ H.part(P)
∧ P.color(Color.red)
Having Part available as a logical sort allows logic expressions to range over parts directly,
without mentioning the attributes that currently form the key of Part.
In the RM, a relvar that refers to parts normally carries foreign-key attributes for the current key of the part relvar. If that key changes, those foreign-key attributes, joins, views, constraints, and application queries can all need corresponding changes.
In conventional languages (SQL, OO, functional languages, JSON, UML), type and cardinality are tightly
linked. If something may occur zero times, one time, or many times, the type must change: T,
Optional<T>, List<T>, Set<T>, typed tuples, arrays, and so on.
Predica separates these two concepts entirely. For a given attribute the type describes what kind of value may occur in its extension and the cardinality constraint describes how many values may occur in its extension. This follows naturally from interpreting attributes as unary predicates.
For this reason the cardinality constraint is written on the attribute, not inside its type:
Email email*;
The attribute email is singular, and its declared type Email is singular too. Even so, the *
cardinality constraint allows the email predicate to hold for zero or more email address values.
Attaching cardinality to attributes rather than to types looks unconventional, yet it cleanly separates “what” from “how many”, enabling flexible modelling without proliferating container types.
The natural-language predicate is likewise phrased in the singular:
email(E) ⇔ The person has an email address E
In the formula email(E), the parameter E ranges over individual values of type Email. The
extension of the email predicate may contain many such values, but each value is still a
single Email value.
In this specification the following definition of 1NF is used:
This has nothing to do with whether attribute types are simple, and it has nothing to do with the declared cardinality constraints.
Under this definition this satisfies 1NF:
entity Person
{
String name;
Email email*;
key {name};
}
whereas this is a violation of 1NF:
entity Person
{
String name;
Set<Email> emails;
key {name};
}
Ironically, cardinality constraints that allow multiplicity might appear to violate 1NF and they indeed allow for multiple values in attribute extensions, yet the whole point is to preserve 1NF. They allow a predicate to hold for zero, one, or many values while keeping each value singular.
The following example appears in Chris Date's book An introduction to Database Systems. There are three relvars in the schema, used to represent suppliers, parts and shipments. It is a conventional normalised relational database schema.
Chris Date and Hugh Darwen defined a language named Tutorial D which is faithful to the Relational Model.
Here is a side by side comparison of the schema for the Supplier-and-parts database expressed in Tutorial D and Predica:
| Tutorial D | Predica |
|---|---|
|
|
The two schemas organise the same subject matter in different ways. The following table compares their database variables, nominal types, attributes, predicates, and constraints.
| Aspect | Tutorial D | Predica |
|---|---|---|
| Database variables | Three relvars named S, P, and SP. |
One database variable of type ShipmentsSystem. |
| Nominal types | No nominal types representing suppliers, parts, or shipments. | ShipmentsSystem, Supplier, Part, and Shipment. |
| Composition | No corresponding composition of S, P, and SP into a database type. |
The types Supplier, Part, and Shipment are composed to define ShipmentsSystem. |
| Attributes | 12 attributes across the relation types of S, P, and SP. |
17 attributes across the nominal entity types Supplier, Part, Shipment, and
ShipmentsSystem. |
| Predicates | Three n-ary predicates, represented by the relvars S, P, and SP. |
17 unary predicates, one for each attribute. |
| Constraints |
|
|
The following examples express the same queries in Tutorial D and Predica, and show the logical frame denoted by each Predica path expression. These frames are obtained by the formally specified, compositional mapping defined in Part XIV: Semantics, and in particular in 150 Denotation of Tuples, Filtering, and Composition.
| Description | Return the names of suppliers located in London. |
|---|---|
| Tutorial D | |
| Predica | |
| Logical denotation | |
| Description | Return the names of parts supplied by the supplier whose identifier is S1. |
|---|---|
| Tutorial D | |
| Predica | |
| Logical denotation | |
| Description | Return the supplier identifier, part identifier, and quantity for shipments whose quantity is greater than 300. |
|---|---|
| Tutorial D | |
| Predica | |
| Logical denotation | |
| Description | Return the names of suppliers that supply the part whose identifier is P2. |
|---|---|
| Tutorial D | |
| Predica | |
| Logical denotation | |
| Description | Return the suppliers that supply every red part. |
|---|---|
| Tutorial D | |
| Predica | |
| Logical denotation | |
| Description | Return the suppliers for which every part they ship is red. |
|---|---|
| Tutorial D | |
| Predica | |
| Logical denotation | |
| Description | Return the supplier city, part city, and quantity for every shipment. |
|---|---|
| Tutorial D | |
| Predica | |
| Logical denotation | |
The mapping into logic is independent of attribute cardinality constraints.
Each predicate in the formula of this denotation is unary, and yet the conjunction expresses exactly the same result as the relational algebra expression using n-ary predicates.
A conjunction of unary predicates over shared existential variables behaves as a relation with arbitrary:
The “n-ary-ness” is not in the predicates. It is in the pattern of shared variables. Unary predicates are local and simple. But when you conjoin them, you recover the full expressive power of n-ary predicates in a way that is:
This is one of the key conceptual advantages of Predica: it exposes the underlying logical structure directly, without requiring n-ary predicates to express n-ary relationships.
Decomposing a domain into unary predicates removes the rigid arity-coupling that binds attributes together in n-ary tuple structures. Each attribute becomes an independent predicate whose optionality, multiplicity, and evolution can be expressed without reshaping the surrounding domain. This aligns with Predica’s broader principle that domains decompose recursively into subdomains, producing models that are modular, deeply structured, and easier to extend.
The logical denotations above are reminiscent of graph-database pattern matching. Graph query languages (Cypher, SPARQL, Gremlin, GQL) are all, at their core, syntactic sugar over first-order logic with existential quantification and conjunction.
Each existential variable corresponds to a node, each attribute corresponds to an edge or property, and the conjunction of unary predicates forms a graph pattern. Predica achieves this expressiveness without requiring graph-database storage structures or pointer semantics.
Graph databases internally bind nodes, follow edges, constrain properties and return a projection. Predica expresses the same logical structure using unary predicates and attribute navigation.
This chapter explains the shift from the unnested n-ary relations of the conventional Relational Model to Predica's nested domains with unary predicate extensions.
When Codd introduced the RM he described First Normal Form (1NF), where there are no repeating groups, attribute types are "atomic" or simple, there are no nested relations and no hierarchical structure inside tuples.
The conventional RM is unnested in exactly this sense: hierarchical structure is not permitted inside tuples. This absence of nesting is core to the use of the RM as envisaged by Codd.
A conventional relational database has relation variables (relvars) which record n-ary relations which are the extensions of n-ary predicates about the world.
For example, for the relvar for suppliers:
There is an important change in thinking required to understand Predica for those familiar with the RM.
Predica does not reject hierarchical structure; it embraces it. This decomposes the n-ary predicates/relations into unary predicates/relations. Predicates are simpler, but there are more of them, allowing for recording the same information as the unnested RM.
The restriction to unary predicates and relations sounds severely limiting. One might surely think this is fatal for representing the kind of information that can be recorded in the RM. Although each Predica predicate is unary, their composition preserves the information represented by n-ary relations while allowing that information to be organised through nested domain structure.
The following ideas explain how Predica recovers the expressive power of the RM while adopting a radically different structure:
The decomposition is easiest to see in stages. Start with a single supplier
domain and one of its attributes. For that supplier, the attribute S# is a
unary predicate whose extension contains one value:
| S# |
|---|
| S1 |
Now add the other attributes for the same supplier. This gives four unary predicate extensions:
|
Supplier S1 |
At first glance, restricting all predicates to be unary seems to lose the
correlations that ordinary n-ary relations record. If all we kept were separate
global projections for S#, SNAME, STATUS, and CITY, the original supplier
relation would not be recoverable. The nesting is doing important work here:
these are not four independent global projections. They are correlated because
they belong to the same supplier element.
Now regard each supplier itself as an entity in the extension of the outer unary
predicate Supplier:
| Supplier |
|---|
|
Supplier S1 |
|
Supplier S2 |
|
Supplier S3 |
|
Supplier S4 |
|
Supplier S5 |
Expanding each supplier gives a picture that deliberately
resembles the ordinary supplier relation. But structurally it has been turned
into nesting: the outer unary predicate Supplier has five elements, and inside
each element the former columns appear as extensions of unary predicates:
| Supplier |
|---|
|
|
|
|
|
|
|
|
|
|
In this decomposition, each attribute of Supplier records its extension.
This is exactly the same information as the corresponding column in the unnested
4-ary relation. Nothing is lost: the supplier number, name, status, and city are all
represented as facts of unary predicates. What changes is the structural organisation of
those facts. Instead of being bundled together into a single n-ary tuple, they are
distributed across simpler predicates that can be composed through the domain structure.
This illustrates the central idea of Predica's approach to nesting: the expressive power of the relational model does not come from n-ary tuples, but from the logical relationships among predicates. By decomposing domains into subdomains and representing attributes as unary predicates, Predica preserves the same information while allowing domains and their types to be recursively composed—structure that the unnested RM deliberately excludes. The absence of nesting in the conventional RM is not a logical necessity but a design choice; Predica shows how to recover the same semantics in a compositional form.
Suppose that for the supplier-parts database we make small, natural cardinality adjustments to the supplier model: a supplier may have one or two names, the status is optional, and a supplier may be located in one or more cities. In Predica, these changes are expressed locally:
entity Supplier
{
Integer id;
String name[1..2];
Integer status?;
String city+;
key {id};
}
The nested picture has the same shape; only the permitted sizes of the unary extensions have changed:
| Supplier |
|---|
|
|
|
|
These cardinality changes affect the constraints on the attribute extensions, but not how queries are written, nor their denotations into logic.
To express the same simple cardinality changes that Predica handles locally, The RM is forced into a 6NF explosion. Tutorial D must:
S relvar into four relvars (S, S_NAME, S_STATUS, S_CITY)
S_NAME, S_STATUS, S_CITY)
SNAME, CITY, or STATUS
S or any of the attributes that were factored out
This illustrates the structural fragility that Predica avoids. In Predica, cardinality is a declarative property of each attribute. In the RM, the same information must be encoded by restructuring the schema, introducing new relvars, new keys, new foreign keys, and rewriting every query and constraint that touches the affected attributes.
| Before | After |
|---|---|
|
|
Predica provides a native, principled, and compositional way to model partial information. The key idea is simple: optional information is represented by empty attribute extensions, not by a special marker value inside a tuple.
An unnested, single-relvar representation becomes structurally unstable under partial information. The pure relational answer is usually to decompose optional or repeating attributes into separate relvars. SQL practice often takes more convenient shortcuts. If an entity has dozens of attributes, any of which may be unknown, practitioners typically reach for some mixture of:
Some of these approaches are principled in isolation, especially decomposition into separate relvars. But they are not local: changing the optionality or cardinality of an attribute changes the surrounding schema shape and forces queries, views, and constraints to be rewritten. The common SQL shortcuts are more convenient, but they introduce their own semantic complications around NULLs, outer joins, constraint checking, and type information.
Predica’s “attributes as unary predicates” implicitly handles partial information, because each attribute has its own extension, which can be empty independently of other attributes. In Predica:
? means “0 or 1”. Partial information is first-classThe important point is locality. In Predica, optionality is stated directly on the attribute:
Email email?;
The attribute remains part of the conceptual type, while its denotation remains relational:
email is a predicate that may hold for zero or one email values.
This yields a modelling space where every subset of attributes corresponds to a well-typed, well-formed predicate.
Consider using the ? cardinality qualifier on dozens of attributes of
a Person entity type:
entity Person
{
String name?;
Address address?;
Phone phone?;
Email email?;
Date birthdate?;
...
}
entity Company
{
Person person*;
}
// Database variable of type Company
var c = openDatabase<Company>("/path/to/database");
For any subset of attributes, say {name, email}, you can simply write:
c.person.(name, email).
Its logical denotation is:
name=N:String, email=E:Email
where
∃P:Person
c.person(P)
∧ P.name(N)
∧ P.email(E)
This yields a clean, well-typed population of:
Adding another projected attribute, as in c.person.(name, address, email), adds P.address(A) to
the conjunction, so the result contains exactly those people for whom all three attributes are
present.
This is much cleaner than the usual SQL alternatives: no NULL tests, no outer joins, no JSON unpacking, and no separate modelling pattern just because an attribute is optional.
Predica is sympathetic to the “no-information” interpretation of NULL associated with Carlo Zaniolo, but implements the idea differently. Zaniolo interprets NULLs inside relations. Predica avoids null markers altogether: optional attributes are predicates whose extensions may be empty for a given entity.
Projection over a set of attributes then restricts to those entities for which all projected attributes are present, producing a clean, total representation with no NULLs and no special cases. Missing information is represented by empty attribute extensions, not by special marker values.
In this way, Predica captures much of the motivation behind the no-information interpretation while remaining within classical two-valued logic and preserving strong typing, compositionality, and clean set-theoretic semantics.
none expression
The expression none denotes an empty result, not a value. It is valid only where the
surrounding expression form explicitly permits absence, especially for optional attributes and
conditional branches whose result cardinality permits absence.
entity Person
{
Email email?;
→ Boolean hasEmail = email ≠ none;
→ Email primaryEmail? = if hasEmail then email else none;
}
none is not a marker value stored inside the attribute, is not a member of any value domain,
and must not be passed to ordinary functions as an argument. For an optional path or attribute
expression E, the expression E = none tests that the extension of E is empty, while
E ≠ none tests that it is non-empty. Equivalently, E = none means E.Σempty, and
E ≠ none means E.Σexists. In an update to an optional location, assigning none clears
the corresponding attribute extension.
Although none is used where an application might otherwise use SQL NULL, it has a different
semantic role. SQL NULL is a marker that can appear in a column position and then affects
ordinary expressions through special three-valued logic. Predica none is not a value at all:
it denotes the absence of a result in a context that permits absence.
This avoids the usual NULL pathologies. Equality remains ordinary two-valued equality over actual
values; none is never an extra inhabitant of every type; arithmetic and ordinary functions do not
need null-propagation rules; and stored predicates do not contain a hidden marker. Presence tests
are explicit extension tests, written with none only as source shorthand for Σempty or Σexists.
Predica’s structural identity eliminates the need for surrogate keys in situations where the RM is forced to introduce them.
Consider a box of objects that are uniquely identified by their shape and colors. There are no other identifying attributes. The following Predica schema represents the required information and models the constraints:
variant Shape
{
{ Real radius } sphere;
{ Real side } cube;
{ Real radius, Real height } cone;
}
enum Color
{
red, green, blue
}
entity Object
{
Shape shape;
Color color+;
}
entity Box
{
Object object*;
}
This can be mapped to Tutorial D as follows:
| Predica | Tutorial D |
|---|---|
|
|
|
|
|
|
|
|
1NF forbids representing the multivalued color+ attribute as a value, so the RM must split it into a
separate relvar. That split destroys the natural identity of an object, forcing the introduction of a
surrogate key to maintain identity across relvars.
In predicate logic, a 0‑ary predicate is a proposition.
Predica deliberately avoids 0‑ary predicates for attributes:
every attribute corresponds to a unary predicate, even when the attribute’s type is Boolean.
This keeps the semantics uniform and avoids special‑case rules for Boolean attributes.
entity Supplier
{
Integer id;
Boolean certified;
}
The query s.supplier.certified is equivalent to:
certified=C:Boolean
where
∃S:Supplier
s.supplier(S)
∧ S.certified(C)
When certified is used in a filter in the query s.supplier.[certified], the Boolean-valued variable C
appears as a conjunct:
supplier=S:Supplier
where
∃C:Boolean
s.supplier(S)
∧ S.certified(C)
∧ C
In the following example there is no cardinality constraint on respondedThatlikesIcecream.
Therefore its extension can be {}, {false}, {true}, or {false,true}.
This is an example where a relaxed cardinality constraint allows for recording partial information
or conflicting information.
entity Person
{
String name;
Boolean respondedThatlikesIcecream*;
}
Predica largely accepts Date and Darwen’s value, type and variable semantics, logical interpretation, explicit identity and rejection of OO machinery. It departs from them by applying those principles to entity types and to the database as a whole. Where D&D retain relvars as variables outside the type system, Predica treats the database as a variable of a nominal type and obtains relvar-like pseudovariables through its attributes.
A type classifies mathematical values. It defines a carrier set of values and the operations that can be performed on those values.
A type has a set of attributes (i.e. names) which are available to be used in queries, and sometimes can be the target for update operations.
A structural type is described by a type expression such as:
{ Integer a; Real b*; String c?; }
A value of a structural type is a tuple that records an extension for each attribute. For example:
(
a = {-4},
b = {2.5, 3.7, 10.0},
c = {"hello"}
)
The extension of an attribute is a set of values of the attribute's declared type, regardless of its cardinality constraint.
An example of a value type is:
value Circle
{
Point centre;
Real radius where radius ≥ 0;
}
A value type is a nominal type. Its carrier is isomorphic to the carrier of the corresponding structural type using its non-derived attributes, after imposing the constraints.
An example of an entity type is:
entity Supplier
{
String id;
String name;
Integer status;
key {id};
}
As for value types, entity types have a carrier which is a set of abstract mathematical values, and that set is defined in exactly the same way as for value types. In the formal language semantics entity types are treated like value types. At the level of the semantic model, however, a value in an entity type's carrier records the extensions of attribute predicates about an entity.
Entity types may declare keys, but these are irrelevant to how the carrier set and equality of the
entity type are defined. Key constraints conveniently impose constraints on attributes that use the
entity type.
For example, the key constraint defined on Supplier is imposed on attribute supplier of
ShipmentsSystem:
entity ShipmentsSystem
{
Supplier supplier*;
}
At the level of the semantic model, the main purpose of distinguishing entity types from value types is to express a metaphysical distinction. Attributes express predicates about the things classified by those types, and all changing database state resides in their extensions.
In most cases, the intended modelling role makes it clear whether a type should be an entity type or a value type. This can readily be seen by considering the examples in the table.
| Kind | Examples | Metaphysical distinction |
|---|---|---|
| value | Boolean, Integer, Real, String, Point, Circle, Triangle,
Set<Integer>, List<Real>
|
Values are mathematical objects. They are elements of carrier sets of algebraic structures. They do not exist in time or space, and they have no identity beyond their structure. |
| entity | Person, Automobile, Company, Supplier, Part, Shipment, Marriage, Birth |
Entities are things that aren't values. They generally exist in time or space. |
The metaphysical distinction is only informal. It is useful in practise to think of entities as things that exist in time and space and can be referred to, albeit taking this very liberally. An entity need not be a physical object; it may also be an organisation, event, action, or relationship. A birth, a marriage, a shipment, or an ownership are all entities in this sense. Predica therefore has no separate category of relationship types.
The Entity–Relationship Model (ERM) distinguishes entity boxes from relationship diamonds. Predica has no corresponding category of relationship types.
Computer representations are not the values they denote or the entities they identify, and the same value or entity may have many representations. Predica permits types to classify both mathematical values and modelled entities while keeping them distinct. This allows a schema to express the kinds of things in its model directly.
Values have no identity beyond their structure, so two locations may independently hold equal values. Entities can instead have shared occurrences in a model: several paths may reach the same occurrence, and an update through any one of those paths updates the location shared by all of them. This sharing is established by the schema constraints; an entity type by itself does not make arbitrary occurrences shared. Location denotation is interpreted under those constraints and retains the entity witnesses needed to identify shared attribute locations. Sharing is therefore observable language behaviour, not merely a choice of physical representation. An implementation may realise shared locations using references and key-indexed collections, but it must preserve the behaviour defined by the constrained location semantics.
For example, consider a supplier-and-parts schema in which shipments are recorded under both their supplier and their part:
entity Supplier
{
Integer id;
Shipment shipment* where shipment.supplier = this;
key {id};
}
entity Part
{
Integer id;
Shipment shipment* where shipment.part = this;
key {id};
}
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier, part};
}
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
→ Shipment shipment* = either supplier.shipment, part.shipment;
}
The where constraints require a shipment under a supplier to name that supplier and a shipment
under a part to name that part. Together with the key on Shipment and the definition of
ShipmentsSystem.shipment, these constraints determine when the two paths reach the same shipment
occurrence. Thus a shipment h for supplier s and part p is visible through both s.shipment
and p.shipment as one shared occurrence, not as independent copies. The following targets
therefore denote the same Shipment.qty location:
s.shipment.[part = p].qty
p.shipment.[supplier = s].qty
After an assignment through either target, a query through the other path observes the assigned
quantity. The derived ShipmentsSystem.shipment attribute observes that same updated shipment as
well. A conforming implementation must exhibit this behaviour whether it represents the shipment
once and shares a reference, maintains indexes over it, or uses another physical representation.
TODO: Complete the location semantics by defining location denotation relative to the schema constraints and stating when those constraints entail that entity witnesses reached through different paths denote the same occurrence. In particular, the rules must derive from the schema above that the two targets produce the same concrete address.
TODO: Define how a write to an attribute of a shared entity occurrence updates every
base extension that contains that occurrence while preserving the constraints on the enclosing
values. This definition must cover an update to a key attribute as well as an update to a non-key
attribute such as qty.
TODO: Reconcile the general location-semantics rule that ∪= computes set union with
the keyed replacement semantics of ∪= on entity-typed extensions, including the unresolved case
where different candidate keys match different existing members.
If value types can declare keys, a further formal distinction arises between value and entity types.
| Kind | Formal semantics distinction |
|---|---|
| value | For every candidate key, given the values of the key attributes, the constraints uniquely determine the other attributes. |
| entity | It is not necessarily the case that, for every candidate key, given the values of the key attributes, the constraints uniquely determine the other attributes. |
Note: The formal semantics distinction is deontic:
The following value type satisfies the determinacy condition, but only because its constraints establish the internal isomorphism between its Cartesian and polar coordinates:
value Point
{
Real x;
Real y;
Real r;
Real θ;
key {x,y};
key {r,θ};
constraints
{
r = sqrt(x^2 + y^2);
θ = atan2(y,x);
x = r * cos θ;
y = r * sin θ;
}
}
Without these constraints, Point would violate the determinacy requirement for a value type.
Attributes are part of the type system, but they are also logical predicates. An attribute does not store a collection inside a value. It denotes a unary predicate from an instance of the containing type to an extension of values of the attribute type.
The cardinality marker constrains that extension:
entity Supplier
{
Integer id;
String name;
City city*;
key {id};
}
Here city* does not mean that the attribute type is “set of cities”. The attribute type is
City. The * says that the extension of the city predicate may contain zero or more city
values for a supplier. The same pattern covers single-valued, optional, non-empty, and bounded
attributes.
A type expression is syntax that denotes a type. It may refer to a nominal type by name, write a structural type directly, or form a type from existing types:
String
Set<String>
subtype Real where this > 0
union { EmailContact, PhoneContact }
intersection { Shape, Visible }
{ String name; Point point; }
For example, union { A, B } denotes the union of the carrier sets of A and B,
while intersection { A, B } denotes values that inhabit both. Union and intersection
of two types are only permitted when equality is defined across them.
Type expressions are recursive, so they can be nested inside each other:
Set<(subtype (union { EmailContact, PhoneContact }) where verified)>
A type declaration gives a name to a type expression. The name can then be
used in place of that type expression to mean the same thing; it does not
declare a new type:
type Names = Set<String>;
type PositiveReal = subtype Real where this > 0;
Subtyping is subset inclusion:
A is a subtype of B iff A ⊆ B
The subtype relation gives rise to a type graph.
An abstract type introduces a carrier set without providing constructors for all of its values. Concrete nominal types can select subsets of that carrier. Abstract types are useful when the model needs a common domain, but no single concrete representation covers every possible value.
abstract value Shape
{
Real area; // Abstract types can declare attributes.
}
value Circle isa Shape
{
Point centre;
Real radius;
→ area = pi * radius * radius; // Can implement inherited attributes.
}
Circle isa Shape declares Circle as a nominal subtype of Shape. The area attribute declared by
the abstract type is given a derived definition by Circle.
A refinement type denotes the subset of an existing type whose values satisfy a predicate.
type Adult = subtype Person where age ≥ 18;
Adult is a type alias for the subset of Person values whose age satisfies the refinement
predicate. It does not declare a new nominal type.
Equality is not automatically available between every pair of types. An equality expression is well-typed only when equality is defined for the relevant static types. Values of the same concrete value type usually have intrinsic equality. Structural, enum, and quotient types define equality by their own rules. Subtypes use equality from their base type.
value BadComparison
{
Point p;
Supplier s;
constraints
{
// Static type error.
// Equality is undefined between Point and Supplier.
p = s;
}
}
Two different types can also become comparable through a concrete common supertype with defined equality, through an explicit equality function, or through a disjointness declaration that makes cross-type equality always false. This is why type expressions and equality evidence are separate concerns.
Predica is statically typed by default. A variable, attribute, parameter, or expression has an element type known to the checker. Locations and path expressions also have cardinality information. These are separate checks:
For example, Integer id* has element type Integer and cardinality zero or more. It is not an
attribute of type Set<Integer>. Flow-sensitive checks may refine cardinality or type inside a
guarded scope, such as after testing that an optional path is not none, or after a positive
isa type test.
A Predica schema defines the type of a database. A database state is a value of that type, and a database is a variable whose successive values are database states. For example:
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
Shipment shipment*;
}
var db = openDatabase<ShipmentsSystem>("/path/to/database");
Here ShipmentsSystem is a nominal entity type, db is a database variable of that type, and the
current value of db is a complete database state. Expressions such as db.supplier designate
pseudovariables within that state.
A constraint is part of a nominal type and restricts the values belonging to that type. If S is the
unconstrained structural carrier of a database type and K is the conjunction of its applicable
constraints, then the carrier of the constrained database type is:
carrier(S with K) =
{
d ∈ carrier(S)
|
K(d)
}
A database state is consistent if and only if it belongs to this constrained carrier. Consistency is
therefore type membership at the level of the complete database value. The applicable constraints
include attribute types, attribute cardinalities, keys, derived definitions, where clauses, subtype
structure, and explicit constraint blocks.
This account does not require consistency to be checked one relvar at a time. A constraint may relate several predicate extensions in the same database value. The database type defines which combinations of extensions constitute valid states.
Consistency is not the same as correctness. A database can satisfy every declared constraint while containing a false statement about the modelled world. For example, a database might consistently record that a supplier is located in Paris even though the supplier is actually located in London. The constraints determine which database values are admissible; they do not establish that the base facts supplied to the database are true.
In Predica's descriptive interpretation, constraints classify admissible models. In its normative interpretation, they classify compliant worlds. In either interpretation, consistency means satisfaction of the specification. Whether the specification itself accurately expresses the intended domain is a separate question.
Checking a database value is also different from checking the specification itself. For a particular
structural database value d, consistency asks whether K(d) holds. Satisfiability asks whether any
such value exists:
∃d ∈ carrier(S). K(d)
If this formula is false, the constrained carrier is empty. The type definition is meaningful, but no database state can inhabit it. A consistency check can reject one proposed state; a satisfiability check detects that every proposed state must be rejected.
An update transaction begins with a consistent database state and constructs a proposed successor state. Individual update statements may pass through intermediate transaction states that violate constraints. The transaction boundary is the point at which the complete proposed state is required to satisfy the applicable constraints.
committed update : ShipmentsSystem -> ShipmentsSystem
The notation above states the semantic contract rather than the implementation strategy. A legal successful commit replaces one value of the database type with another. A program that attempts to commit a proposed final state outside the constrained carrier is illegal; the language does not prescribe the runtime behaviour of that attempt. See Constraint-Violating Updates.
This leaves implementations free to use mutation, logs, snapshots, copy-on-write pages, or other physical techniques. Those techniques do not change the logical account: the externally committed states are values of the declared database type.
Good schema design makes many consistency conditions follow compositionally from type definitions, keys, cardinalities, and correctly placed attributes. A constraint that initially appears global may indicate that a domain is missing or that a fact has been attached to the wrong subject. Normalisation uses dependencies as diagnostics for finding such designs.
Some business rules genuinely relate several independently meaningful predicate extensions. Such a rule remains an explicit constraint on the database type. It still contributes to the definition of the constrained carrier, but it is not thereby local to an individual attribute or entity type. The distinction between local structure and explicit global constraints remains important even though both participate in determining consistency.
As in the Relational Model, Predica adopts the closed world assumption (CWA). In Predica this follows directly from its treatment of predicates and from the fact that every attribute stores the extension of a unary predicate. Under the CWA, the extension of a predicate is taken to be complete: every fact that holds is recorded, and every fact not recorded is taken to not hold.
For a value type such as:
value Circle
{
Point centre;
Real radius;
}
the attribute centre is the unary predicate whose extension contains
all and only the centres of the circle. Likewise, radius records all and
only the radii of the circle. If a value does not appear in the extension of an
attribute, Predica interprets this as meaning that the value is not a centre
(or radius) of the circle.
This behaviour is fundamental to Predica’s logical operators. Negation, difference, and constraint subtraction all rely on the assumption that the recorded extensions are complete. Without the CWA, these operators would not have a well-defined meaning.
The closed world assumption does not restrict the kinds of predicates that may be defined. A predicate’s extension may record facts about the actual world, or it may record what is known, believed, permitted, required, possible, or otherwise modal in character. The closed world assumption applies only to the chosen extension: whatever belongs to that extension is taken to be completely recorded for the purpose of logical inference, regardless of the interpretation of the predicate itself.
In the Relational Model (RM), each relvar has an associated predicate. For the supplier-and-parts database, there is a relvar recording suppliers:
VAR S BASE RELATION
{
S# CHAR,
SNAME CHAR,
STATUS INTEGER,
CITY CHAR
}
KEY { S# };
and its predicate is:
S(S#,SNAME,STATUS,CITY)S# with name SNAME having
status STATUS and located in city CITY
In Predica this relvar is represented by entity Supplier together with a reference from entity ShipmentsSystem:
entity Supplier
{
String id;
String name;
Integer status;
String city;
key {id};
}
entity ShipmentsSystem
{
Supplier supplier*;
...
}
The Supplier attributes are predicates, simpler and more atomic than the relvar predicate of the RM:
id(I)Iname(N)Nstatus(S)Scity(C)C
and in the ShipmentsSystem there is a predicate for existence of a supplier:
supplier(S)S
Metadata is a sequence of source annotations attached to a schema declaration or member.
Each metadata item is written as @name or @name(arguments...). The arguments are ordinary
Predica expressions. The leading @ attaches the item to the declaration that follows; it is not
part of the metadata name.
Metadata may be attached where the grammar admits MetaData, including nominal type
declarations, type aliases, constants, functions, folds, procedures, generic parameters, attributes,
derived attributes, variant attributes, subtype embeddings, dropped attributes, and external
function declarations.
Unless a metadata form is given a meaning by the spec, metadata is preserved annotation data. It does not add declarations, constraints, predicates, or denotations by itself.
The Predica schema can be annotated with domain and predicate definitions in natural language. For example:
@domain("A supplier in the shipments system")
entity Supplier
{
@predicate("id(I) ⇔ The supplier has id I")
Integer id;
@predicate("name(N) ⇔ The supplier has name N")
String name;
@predicate("status(S) ⇔ The supplier has status S")
Integer status;
@predicate("city(C) ⇔ The supplier is located in city C")
String city;
key {id};
}
Notice that the domain definition uses "a supplier" whereas the predicates use "the supplier".
The predicates use the definite article because the domain is the subject of the predicates.
Predica can also express the supplier-and-parts database in a form that closely mirrors the classical RM structure:
entity Supplier
{
Integer id;
String name;
Integer status;
String city;
key {id};
→ shipment* = inverse shipment.supplier;
}
entity Part
{
Integer id;
String name;
Color color;
Real weight;
String city;
key {id};
→ shipment* = inverse shipment.part;
}
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier,part};
}
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
Shipment shipment*;
constraints
{
shipment.supplier.Σ ⊆ supplier.Σ;
shipment.part.Σ ⊆ part.Σ;
}
}
This mirrors the classical RM structure.
The base attribute ShipmentsSystem.shipment resembles a global shipments relvar with
two foreign keys supplier and part.
The foreign keys are expressed as inclusion dependencies:
constraints
{
shipment.supplier.Σ ⊆ supplier.Σ;
shipment.part.Σ ⊆ part.Σ;
}
There are two derived attributes Supplier.shipment and Part.shipment
declared using inverse:
entity Supplier
{
...
→ shipment* = inverse shipment.supplier;
}
entity Part
{
...
→ shipment* = inverse shipment.part;
}
These enable path-based navigation from suppliers to shipments and from parts to shipments. For example, the following expression returns the parts shipped by London suppliers:
system.supplier.[city = "London"].shipment.part;
This is a faithful RM-style design, and it works perfectly in Predica. But it carries the same structural implications as the RM:
ShipmentsSystem.shipment behaves like a global relationship base relvar.
inverse.
Predica supports this, but it does not require it.
The schema used in the introduction pushes the relationship down into the entities that
participate in it.
This gives a logically equivalent schema that maps directly to a more efficient physical
implementation, avoiding the need for inverse entirely:
entity Supplier
{
Integer id;
String name;
Integer status;
String city;
Shipment shipment* where shipment.supplier = this;
key {id};
}
entity Part
{
Integer id;
String name;
Color color;
Real weight;
String city;
Shipment shipment* where shipment.part = this;
key {id};
}
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier,part};
}
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
→ Shipment shipment* = either supplier.shipment, part.shipment;
}
This version is:
The key idea is simple: A relationship belongs with the entities that participate in it. In Predica, this is not just a stylistic preference, it is a semantic advantage.
A shipment is naturally “owned” by its supplier and its part. Predica lets you express this directly:
entity Supplier
{
...
Shipment shipment* where shipment.supplier = this;
}
entity Part
{
...
Shipment shipment* where shipment.part = this;
}
These are declarative constraints, not physical pointers.
For the supplier it means:
The shipments belonging to this supplier are exactly those whose supplier attribute equals this supplier.
This expresses the same information as a foreign key, but in a way that:
Because both Supplier and Part define their shipments, the system exhibits logical shared ownership:
This is shared ownership without:
It is a purely logical notion of sharing, not an OO one.
→ Shipment shipment* = either supplier.shipment, part.shipment;
This is a derived attribute, not a stored one. It declares two equivalent definitions, and the compiler may use whichever one is cheaper in a given query. This is the Predica analogue of:
but without the RM’s suggestion for a physical join table.
Consider the following query in Tutorial D which finds the parts shipped by London suppliers:
(SP JOIN S WHERE CITY='London'){PNO} JOIN P
The most direct counterpart in Predica uses the global shipment predicate:
s.shipment.[supplier.city = "London"].part
However, we can take advantage of the local navigation from supplier to shipment and instead write:
s.supplier.[city = "London"].shipment.part
The second is preferred because it follows the structure of the schema, avoids filtering a global relationship, and is suggestive of an efficient implementation using local navigation.
Chris Date’s “Information Principle”, originating in the work of E.F. Codd, states that all information in a relational database must be represented in exactly one way: as explicit data values within tuples in relations. The intent is to ensure a single, uniform representational mechanism for information, free of ad-hoc devices such as pointers, flags, or hidden structures.
However, the principle becomes less constraining once complex data values are permitted within tuples. Date and Darwen explicitly allow tuple-valued attributes, relation-valued attributes, and user-defined types with possreps. Under such a permissive notion of “value”, it becomes formally possible to embed an entire object-oriented database as the value of a single attribute in a single tuple of a single relvar, while still claiming to adhere to the Information Principle. This is clearly not what they intended, and they would certainly reject such a design as contrary to the spirit of the relational model.
Indeed, their aim was to rule out representational mechanisms such as:
For good reasons, Date and Darwen argue that the relational model should play a central role in any information system. But in practice, the strength of the Information Principle depends on how “simple” the permitted data values are. Once values may be arbitrarily complex, the principle relies on the informal and historically vague notions of First Normal Form and “atomicity” that Codd originally invoked. There seems to be no way around this: the Information Principle only has real force if the allowed values are sufficiently simple.
It is not immediately obvious whether Predica should be regarded as adhering to the Information Principle. Predica does not expose relations or tuples in its surface syntax, and so one might think it violates the principle by abandoning the tuple-based representation of information.
Predica retains the intent of the Information Principle but states it in terms of unary predicates:
The information principle: Every materialised value in a Predica database is used to instantiate a unary predicate, down to the level of simple values such as integers, strings, and values of enumerated types, yielding a true proposition about the modelled world.
Each attribute introduces a unary predicate, and the extension of the attribute is the set of values for which that predicate holds. Information is therefore represented as facts of unary predicates, without alternative representational mechanisms. In this sense, Predica preserves the original intent of the Information Principle at least as faithfully as the classical relational model, and arguably more so.
Several corollaries follow.
Another corollary of the information principle is that Predica does not use memory addresses, object identifiers, or other hidden handles as logical identity. Hidden identifiers are not values used to instantiate predicates about the modelled world; they are representational machinery.
Several consequences follow.
Predica’s entity types resemble OO classes in surface syntax, but they do not behave like OO object types. Their instances have explicit logical identity defined by keys, never hidden object identity.
This distinction matters. In OO, there are two very different notions of “class”:
Predica entity types are closer to the second category. They have explicit keys and:
The correct analogy is therefore:
Predica entity types are like immutable record types with explicit logical keys, not like OO objects.
Predica resembles OO only in notation, not in semantics. It embraces the clarity of class-like syntax while avoiding hidden identity, pointer semantics, and mutable object state.
Predica appears, at first glance, to violate every classical intuition about First Normal
Form and atomicity. It promotes rich, structured types such as Point, Circle, and
Polygon; it has entity types like Supplier with internal attributes; and it
permits cardinality constraints at any level. Superficially, this looks like a wholesale
abandonment of Codd's requirement that attribute types be "atomic".
The irony is that Predica is more faithful to 1NF than many conventional relational systems, because it requires:
Boolean, Integer, Real, and String
Together these imply that primitive values such as 5 or "Alice" that appear in the database
participate in the logical model through predicates, even when they occur inside structured values.
Circles, points, suppliers, and shipments are all values that represent facts, but they are not simple or primitive. Predica does not require them to be. For the values that appear in a database, Predica rejects:
value of attribute ⇒ value is primitive
and mandates only the converse:
value is primitive ⇒ value of attribute
In practice many relational systems violate 1NF. For example, it is common to store arrays, lists, tuples, JSON, XML, and spatial geometry types in a single column in PostgreSQL, MySQL, Oracle, and SQL Server.
In Predica, attributes with multivalued extensions do not violate 1NF: multiplicity is represented as multiple facts of a unary predicate, while the declared attribute type remains singular. Predica’s surface syntax resembles nested structures, but its semantics remain fully relational.
The conventional RM appears “atomic” but routinely violates 1NF in practice; Predica appears “non-atomic” but adheres to it in principle.
According to Date and Darwen, the first great blunder is the relvar = class equation: treating a relation variable as the counterpart of an object class, and treating each tuple in its current value as an object instance. This mistake, they argue, leads directly to a host of conceptual and practical problems:
This is why they attack systems that treat tuples as mutable objects:
The essence of the blunder is confusing the logical notion of a relation with the object-oriented notion of a class. In the relational model, tuples are values, not objects; they have no identity beyond their value. In OO, objects have identity independent of their state. Conflating these two worlds leads to the pathologies Date and Darwen warn against.
At its most literal, the equation relvar = class is already a category error: a relvar is a variable, whereas a class is a type. Predica does not make that identification. Indeed, Predica has no independently declared relvars. It has types and attributes, while a database is a variable whose declared type is an entity type.
Predica allows entity (and relationship) types in semantic modelling to be genuine types in the type system. For example:
entity Supplier
{
Integer id;
String name;
Integer status;
String city;
key {id};
}
In An Introduction to Database Systems, 8th edition, Chapter 14, C. J. Date says:
... the entity type concept as used in (e.g.) the E/R model is not at all the same thing as the type concept discussed in Chapter 5. To be more specific, such entity types will probably map to relvars in a relational design, so they certainly do not correspond to relational attribute types (domains).
Predica deliberately rejects that boundary. Supplier is simultaneously:
As a genuine type in the type system, Supplier is expected to occur as an attribute type:
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
Shipment shipment*;
}
This is not committing the first great blunder, i.e. the category error.
Let db be a database variable of type ShipmentsSystem:
// Database variable of type ShipmentsSystem
var db = openDatabase<ShipmentsSystem>("/path/to/database/on/file/system");
The attribute name supplier, when applied to db,
gives the pseudovariable db.supplier. This pseudovariable is a location whose current value is the
extension of the supplier predicate and can therefore correspond to a relvar:
// db.supplier is a pseudovariable recording extensions of type Supplier
assert( db.supplier isa Supplier );
print(db.supplier);
But Supplier is the type used in the declaration of that attribute;
it is neither the attribute supplier nor the pseudovariable db.supplier.
Predica therefore does not make the relvar = class identification.
Predica distinguishes between attributes, value types and entity types. The identifiers appearing in these examples can be partitioned into those three groups as follows:
id, name, status, city, supplier, part, shipment.
These are unary predicates.
They have extensions, and in this respect correspond to relvars, understood as predicate names.Integer, String.Supplier, Part, Shipment, ShipmentsSystem.
These are kinds of domains.
They have domain definitions, not predicate definitions.Going back to C. J. Date’s quotation above, there remains a substantive difference from Date’s conception, and this highlights a distinction between Predica and the standard and expected application of the Relational Model.
Allowing semantic entity types to be genuine types is a major advantage of Predica. It lets a Predica schema
directly perform the semantic modelling that a schema is supposed to perform. Supplier remains the type of
supplier entities throughout the model, rather than being translated into a relvar whose tuples stand for
suppliers.
This chapter summarises some of the design principles behind Predica and the language features that follow from them.
Predica is shaped by several design principles. The principles listed here are central examples: they keep the model logical, compositional, and independent of physical representation choices.
Logic defines the semantics of Predica constructs. This specifies what they mean, not how they operate or are implemented. The main example is the denotation of path expressions into logic frames.
This gives another corollary.
Together, these principles explain why Predica's type, query, and constraint constructs share the same predicate-based interpretation.
Predica offers:
Predica has a minimal mechanism:
The RM works directly with n-ary predicates whereas Predica uses unary predicates. This finer-grained approach has a wide range of significant benefits, including:
Person example in event sourcing.
This part introduces path expressions through examples. Path expressions are the main way Predica navigates, filters, names, combines, reshapes and summarises values within a domain.
This part uses a small university schema as its running example. The schema is deliberately light on constraints. Its purpose is to provide a familiar world for reading path expressions, not to model every rule of a university.
The main objects are students, courses, departments, enrolments, teaching sessions and assessments. An enrolment records that a student is taking a course. A department offers courses. A course may have prerequisites, scheduled sessions and assessments.
enum Day
{
monday,
tuesday,
wednesday,
thursday,
friday
}
value TimeSlot
{
Day day;
Integer startMinute;
Integer endMinute;
}
value Exam
{
Day day;
Integer durationMinutes;
Integer weight;
Integer maxMark;
}
value Assignment
{
Day dueDay;
Integer wordLimit;
Integer weight;
Integer maxMark;
}
value Project
{
Day dueDay;
Boolean groupWork;
Integer estimatedHours;
Integer weight;
Integer maxMark;
}
variant Assessment
{
Exam exam;
Assignment assignment;
Project project;
}
entity Department
{
String name;
Course course* where course.department = this;
key { name };
}
entity Course
{
String code;
String title;
Boolean advanced;
Department department;
Course prerequisite*;
Session session* where session.course = this;
Assessment assessment*;
Enrolment enrolment* where enrolment.course = this;
key { code };
}
entity Session
{
Course course;
TimeSlot time;
String room;
}
entity Student
{
String name;
Course completed*;
Enrolment enrolment* where enrolment.student = this;
→ Course enrolled* = enrolment.course;
key { name };
}
entity Enrolment
{
Student student;
Course course;
Boolean current;
Integer mark?;
}
entity University
{
Department department*;
Course course*;
Student student*;
Enrolment enrolment*;
}
Some relationships are stored directly. For example, an Enrolment stores its
student and course, and a Course stores its department. Other relationships
are derived for convenient navigation. For example, Student.enrolled is calculated
from the student's enrolments, and Department.course is the reverse direction of
Course.department.
Assessment is a variant type: an assessment is an exam, an assignment or a
project. Assessment attributes include numeric fields such as weight and
maxMark so later chapters can use them for calculations and folds. Later
examples also use Assessment to show that path expressions work over sum-shaped
values as well as ordinary product-shaped values.
The following chapters use this schema to build up path expressions step by step: simple navigation first, then filters, naming, reshaping, summaries and the common places where the wrong mental model leads to mistakes.
A path tail is any expression that can appear to the right of a dot in a Predica path expression. Path tails include attribute access, filters, tuple constructions, scalar expressions, and nested combinations of these.
We use the term path tail to highlight that such an expression has meaning in the context of a full path expression in which it appears.
For example, if there is no program variable named course, then an expression
like course.session is a path tail rather than a complete path expression by itself.
It is meaningful as the tail of a larger path expression such as:
u.course.session
Given a variable s of type ShipmentsSystem, consider the path expression:
s.supplier.[city = "London"].(name,status)
This expression contains the following path tails:
• supplier
• city
• name
• status
• (name,status)
• [city = "London"]
• supplier.[city = "London"]
• supplier.[city = "London"].(name,status)
• [city = "London"].(name,status)
Path tails can also include:
name as n)
supplier⇒order)
named ⊓ status)
qty * price)
42, "London" )
(name,(status,city)) )
shipment.part.name )
Square brackets are the filter form. A round tail constructs a tuple or scalar projection even when
the projected item is Boolean, so document.(kind = DocumentKind.customsEntry) is not the same path
as document.[kind = DocumentKind.customsEntry].
To catch this common mistake, a single predicate-shaped round projection followed directly by
Σexists is rejected by the source language. Predicate-shaped projections include comparisons,
logical connectives, cardinality tests, type tests, and logical negation. Write:
document.[kind = DocumentKind.customsEntry].Σexists
rather than:
document.(kind = DocumentKind.customsEntry).Σexists
A filter keeps the current values for which a condition is true. In relational-model terms, filtering is the restriction operation inside a path expression.
The following path reaches the advanced courses in the university:
u.course.[advanced]
The filter is applied to the current courses. After the filter, the path still contains courses, just fewer of them.
The path tail advanced is evaluated as C.advanced(A), and the filter condition
itself becomes the conjunct A in the frame:
course=C:Course
where
∃A:Boolean
u.course(C)
∧ C.advanced(A)
∧ A
Navigation can continue from the filtered courses in the usual way.
u.course.[advanced].code
u.course.[advanced].department.name
Filters can use ordinary comparisons. This expression reaches the enrolments whose mark is at least 50:
u.enrolment.[mark ≥ 50]
A filter can appear after navigation. In the next expression, the path first reaches each student's enrolments, then keeps only current enrolments, then reaches the corresponding course codes.
u.student.enrolment.[current].course.code
The position of the filter matters. A filter applies to the values reached at that
point in the path. Here [advanced] applies to courses:
u.student.enrolment.course.[advanced].code
Here [current] applies to enrolments:
u.student.enrolment.[current].course.code
In general, filter conditions are logic formulas whose terms are path tails. This is powerful because filtering composes with navigation, folds and the other forms of path expression.
For example, this path reaches courses offered by the mathematics department:
u.course.[department.name = "Mathematics"].code
An attribute is used as a value of its declared type. Its cardinality says how many values may be reached; it does not turn each reached value into a set.
For example, name has type String, so this is a type error:
u.department.[name ⊆ {"Mathematics", "Physics"}]
The operator ⊆ compares sets. The path tail name reaches strings, so membership
is the right test:
u.department.[name ∈ {"Mathematics", "Physics"}]
The same rule would apply if an entity could have several names. Each reached
name would still be a String, so the filter would still use ∈, not ⊆.
This mistake often appears with ⊂, ⊃, ⊆, ⊇, ⊄ and ⊅ in filters and
constraints.
A filter condition can be more substantial. This expression reaches current enrolments in advanced mathematics courses where the student has not already completed the course and either has a passing mark or no mark has been recorded:
u.enrolment.[
current
∧ course.advanced
∧ course.department.name = "Mathematics"
∧ course ∉ student.completed.Σ
∧ (mark ≥ 50 ∨ mark = none)
]
The condition inside a filter is a Boolean-valued path expression evaluated in the
current path context. It may itself contain path tails, such as course.department.name
and student.completed.Σ above. Existing knowledge about writing Boolean expressions
still applies. For example:
| Kind | Examples |
|---|---|
| Comparisons | =, ≠, <, >, ≤, ≥ |
| Set and membership tests | ⊂, ⊃, ⊆, ⊇, ⊄, ⊅, ∈, ∉ |
| Logical connectives | →, ←, ↔, ∧, ∨, ¬ |
| Boolean-valued functions | User-defined predicates and Boolean-valued functions |
Tuple construction picks several things and combines them to give the set of
attributes output by the frame. The following expression selects course and
department from the university as outputs:
u.(course, department)
Here course and department are both evaluated from the same university u.
Its denotation as a frame is:
course=C:Course, department=D:Department
where
u.course(C)
∧ u.department(D)
The same idea works after navigation. The following expression selects code and
title from each course as outputs:
u.course.(code, title)
The path tails inside the parentheses are evaluated from the same current value.
In this case, both code and title are evaluated from the same course.
The empty tuple () projects away all attributes. The following expression reaches
each course and then returns the empty tuple for each one:
u.course.()
This is the zero-attribute projection. It is useful when only the existence of a matching value matters, rather than any attributes of that value.
Tuple expressions can be nested:
u.course.((code, title), department.name)
Nesting does not create nested structure in the output frame. The output attributes are flattened:
code=C:String, title=T:String, name=N:String
where
∃K:Course ∃D:Department
u.course(K)
∧ K.code(C)
∧ K.title(T)
∧ K.department(D)
∧ D.name(N)
Empty tuple expressions also disappear when flattened. For example, a.(b,(),(()).())
is equivalent to a.b.
These examples have several relational-model analogies:
u.(course, department) is relational-product-like because course and department
are independent tails evaluated from the same university.u.course.(code, title) is relational-projection-like because it selects code and
title as outputs.u.course.(code, title) is also unwrapping-like because it selects parts of
each current course.Later chapters use the same parentheses with renaming, calculations, folds and nested path expressions.
In general, tuple construction contains path tails. This is powerful because tuple construction composes with navigation, filters, folds and the other forms of path expression.
For example, this expression selects the student's name and the course code from each enrolment as outputs:
u.enrolment.(student.name, course.code)
Both path tails are evaluated from the same enrolment. That is why the student name and course code remain associated with each other.
The path tails can use filters too:
u.course.(code, [advanced].title)
Here code and [advanced].title are both evaluated from the same course. The
second path tail begins with a filter before selecting the course title.
Tuple construction and filters can be combined in a way that resembles a theta join:
u.(enrolment, session).[enrolment.course = session.course]
The tuple construction forms enrolment-session combinations from the university. The filter keeps the combinations where the enrolment and the session refer to the same course.
as
The as operator changes the output attribute name of a path tail.
For example:
u.student.name as studentName
The path still reaches student names, but the output attribute is named studentName
instead of name.
Its denotation as a frame is:
studentName=N:String
where
∃S:Student
u.student(S)
∧ S.name(N)
as can be used
The as operator can be used wherever there is a single output attribute to rename.
This limits how it can be used after tuple construction:
a.() as x // wrong
a.(b) as x // allowed
a.(b, c) as x // wrong
Renaming is often used inside tuple construction, because path tails such as
student.name and course.code would otherwise have output names taken from their
final attributes.
u.enrolment.(student.name as studentName, course.code as courseCode)
The path tails are still evaluated from the same enrolment. The as clauses only
choose the output attribute names.
Renaming is also useful when two outputs would otherwise have the same name:
u.enrolment.(student.name as studentName, course.department.name as departmentName)
Both path tails would otherwise output an attribute named name. The as clauses
give them distinct output names.
The path tail this refers to the current value in the path. It is useful when
the current value itself is needed, rather than one of its attributes.
For example, the following expression outputs each session together with the code of the course it belongs to:
u.course.session.(course.code as courseCode, this)
Inside session.(...), the current value is a session, so this outputs that
session. The path tail this does not navigate anywhere; it uses the value
already reached at that point in the path.
A path is read from left to right, and this means the current value at the
point where it is written.
The output can give an explicit name to this. This expression reaches the
advanced courses and outputs the course value itself, together with some of its
attributes:
u.course.[advanced].(this as advancedCourse, code, title)
Inside [advanced].(...), the current value is still a course. The output named
advancedCourse is therefore the reached course value, while code and title
are attributes of the same course.
The same pattern is useful when a path has reached a value but the result should also show where it came from:
u.course.session.(this as scheduledSession, room, course)
Here this is the session reached by u.course.session, exposed under the result
name scheduledSession. The course attribute is the course attached to that
session.
A path tail can calculate an output attribute from values reached in the current path context. The following expression calculates the duration of each session:
u.course.session.time.(endMinute-startMinute as durationMinutes)
The calculation uses two path tails, endMinute and startMinute, both evaluated
from the same time slot. The as clause names the calculated output.
Calculations are path tails, so they can appear beside ordinary navigation in tuple construction:
u.course.session.(room, time.(day, endMinute-startMinute as durationMinutes))
This selects the room, the day, and the calculated duration for each session.
The assessment variant attributes have numeric fields that can be used in calculations. This expression calculates how many minutes of exam time are assigned to each mark:
u.course.assessment.exam.(durationMinutes/maxMark as minutesPerMark)
The same idea works for assignments:
u.course.assessment.assignment.(wordLimit/maxMark as wordsPerMark)
Products are ordinary calculations too:
u.course.assessment.project.(estimatedHours*weight as weightedHours)
Calculated path tails can also appear inside filters. The following expression reaches long sessions:
u.course.session.time.[endMinute-startMinute > 120]
The filter condition calculates the duration from the current time slot, then
compares it with 120.
Consider the following schema:
value X
{
Real v*;
}
Multiple occurrences of an attribute in a path expression can lead to Cartesian products,
which can be unexpected. For example, given a program variable x of type X the
path expression:
x.(v*v as product)
is equivalent to:
x.(v as v1, v as v2).(v1*v2 as product)
Each occurrence of v is a distinct path tail, so the two occurrences are evaluated
independently. The calculation therefore gives all combinations of V1*V2 where
x.v(V1) and x.v(V2).
This follows from the uniform rule that each path tail contributes its own frame;
treating two identical-looking tails as the same reached value would be an extra
special case.
One way to ask for all values of V*V where x.v(V) is to navigate to v first
and then use this:
x.v.(this*this as product)
Another approach is to give the calculation a name by defining a function:
func Real square(Real x) = x*x;
The path expression can then call the function on the current reached value:
x.(square(v) as product)
For larger calculations, named functions make path expressions easier to read and avoid repeating the same multivalued path tail inside the calculation.
When several path tails need values below the same prefix, navigate to the shared prefix first. Repeating a multivalued prefix in separate tails can create Cartesian products between independently reached values.
A let binding gives a name to a path tail so the named result can be used in the
expression that follows.
The previous chapter showed that this expression forms products of independently reached values:
x.(v*v as product)
If the intention is to square each value reached by v, bind the reached value once:
x.(let a = v in a*a as product)
The name a refers to the value reached by the binding. The calculation uses that
same named value twice.
let can also name an intermediate calculation:
u.course.session.time.(
let duration = endMinute-startMinute in duration as durationMinutes)
This is deliberately a small example. The calculation is simple enough to write
without let, but the shape becomes useful when the intermediate value is used more
than once.
The chapter on this showed that let can preserve an outer value before
navigation changes the current value. In the following expression, c names the
current course before the path enters its sessions:
u.course.(let c = this in session.(c.code as courseCode, room))
Inside session.(...), the current value is a session. The name c still refers
to the outer course.
This expression tries to compare each session with the other sessions for the same course:
u.course.session.(let s=this in
session.[this ≠ s])
The unqualified inner session is evaluated from the current session, not from the
course. Save the course first, then navigate back through the saved course:
u.course.(let c=this in
session.(let s=this in
c.session.[this ≠ s]))
Use let outer=this in ... before entering a path tail when the nested expression
still needs the outer object.
A let binding can appear inside a filter condition:
u.course.session.time.[let duration = endMinute-startMinute in duration > 120]
The filter keeps the time slots whose calculated duration is greater than 120.
The splat expression * denotes the set of attributes of the current value.
For example, the following expression unwraps each course:
u.course.(*)
The output frame has the attributes of Course, rather than a single output
attribute named course.
Its denotation as a frame has the same result attributes as the Course type:
code=Code:String,
title=Title:String,
advanced=Advanced:Boolean,
department=D:Department,
prerequisite=P:Course,
session=S:Session,
assessment=A:Assessment,
enrolment=E:Enrolment
where
u.course(C)
∧ C.code(Code)
∧ C.title(Title)
∧ C.advanced(Advanced)
∧ C.department(D)
∧ C.prerequisite(P)
∧ C.session(S)
∧ C.assessment(A)
∧ C.enrolment(E)
Attribute names are first-class values. The expression @title denotes the
attribute name title, and expressions such as {@code, @title} denote sets of
attribute names.
The splat expression * also evaluates to a set of attributes. Tuple construction
allows sets of attributes to be provided, so set operations can be used to manipulate
the output attributes.
The set operators ∩, ∪ and ∖, and the set tests ⊂, ⊃, ⊆, ⊇,
⊄, ⊅, ∈ and ∉, work as expected on sets of attributes.
Because * denotes a set of attributes, set operations can be used to shape the
output attributes. This expression unwraps each course except for title:
u.course.(* \ {@title})
The output has every Course attribute except title.
Splat can be combined with ordinary path tails in tuple construction:
u.course.(* \ {@department}, department.name as departmentName)
This unwraps each course, removes the department attribute, and adds the
department name under the output attribute departmentName.
Unwrapping can introduce name clashes. If two splatted values have attributes with the same name, rename or remove attributes before combining them:
u.enrolment.(* \ {@student}, student.name as studentName)
The splat exposes the enrolment attributes except student; the renamed path tail
adds the student's name.
A fold takes the values reached by a path and collapses them to one value. The following expression counts the sessions for each course:
u.course.(code, session.Σcount as sessionCount)
Without the fold, session reaches the sessions of a course one at a time. With
Σcount, those reached sessions are counted and the count is output as
sessionCount.
The same idea works for numeric values. The following expression sums the maximum marks available from a course's exams:
u.course.(code, assessment.exam.maxMark.Σsum as examMarks)
The path before the fold reaches the exam maximum marks for the current course. The fold turns those marks into one number.
These standard folds cover many common cases:
| Fold | Example | Use |
|---|---|---|
Σcount |
session.Σcount |
Count reached values. |
Σexists, Σempty |
prerequisite.Σempty |
Test whether any values were reached. |
Σsum, Σmean, Σstddev |
assessment.project.estimatedHours.Σmean |
Summarise numeric values. |
Σmin, Σmax |
session.time.startMinute.Σmin |
Find an extreme value. |
Σall, Σany |
enrolment.current.Σall |
Summarise Boolean values. |
The fold .Σ collects the reached values as a set. The following expression gives
each course with the set of codes for its prerequisites:
u.course.(code, prerequisite.code.Σ as prerequisiteCodes)
This is useful when the path should remain attached to the current course as one set-valued output, rather than producing one output for each prerequisite code.
Because filters are path tails, a fold can summarise the values that remain after filtering. The following expression counts long teaching sessions for each course:
u.course.(
code,
session.time.[endMinute-startMinute > 60].Σcount
as longSessionCount)
The filter reaches only the time slots whose duration is greater than sixty minutes. The fold counts those filtered time slots.
Named folds such as Σsum are ordinary fold definitions. An inline fold writes
the reduction directly:
u.course.(
code,
assessment.exam.durationMinutes.Σ{ 0 ; total d ↦ total + d }
as examMinutes)
The first part, 0, is the initial accumulator. The step
total d ↦ total + d says how to update the accumulator for each reached duration.
This example is equivalent to using Σsum, but inline folds are useful when the
reduction is specific to the query.
An inline fold can also use the first reached value as its seed:
u.course.(
code,
session.time.startMinute.Σ{
first m ↦ m ;
earliest m ↦ if m < earliest then m else earliest }
as firstStart)
This is the shape used by folds such as Σmin and Σmax.
Some folds have a value even when no values are reached. For example, Σcount
gives 0, Σexists gives false, and Σempty gives true.
Other folds need at least one reached value. For example, Σmean, Σmin and
Σmax are not defined on an empty input. When that matters, use a filter or a
separate test such as Σexists to say when the folded value is required.
Folds compose with the path tails from earlier chapters. Navigation chooses what
values can be reached, filters remove unwanted values, calculations can produce
values to fold, and as names the folded output.
u.course.(
code,
assessment.project.[groupWork].estimatedHours.Σsum
as groupProjectHours)
Here the path reaches each course, enters its project assessments, keeps only the group projects, reaches their estimated hours, sums those hours, and names the result.
Path operations combine whole path expressions. They are useful when the question is easier to write as two paths that are then combined.
For example, this expression reaches courses that are both advanced and offered by the mathematics department:
u.course.[advanced]
⊓ u.department.[name = "Mathematics"].course
Both sides output courses. The ⊓ operation keeps the course values that satisfy
both sides.
Use ⊓ when the thought is "both paths must hold".
u.course.[advanced]
⊓ u.course.[assessment.exam.Σexists]
This reaches courses that are advanced and have at least one exam assessment.
The two sides are ordinary path expressions; ⊓ combines their frame conditions
with logical conjunction.
In relational-model terms, ⊓ is relational-join-like. When both
sides expose the same output, it looks like intersection, as in the examples
above. When the sides expose different outputs while sharing some surrounding
condition, it looks more like a relational join.
u.course.(enrolment.student ⊓ session.room)
This combines students and rooms for each course. If a course has several enrolled students and several rooms, the expression can form combinations of those reached values, just as a join can.
This is the path-expression counterpart of relational natural join ⨝, but at a different level.
The relational operator joins relations. The path operator ⊓ combines path denotations by aligning
common result attributes and conjoining their frame conditions. When the path results have
relation-shaped tuples, that frame meet behaves like natural join.
(E₁ ⊓ E₂).Σ = E₁.Σ ⨝ E₂.Σ
This law holds when E₁ and E₂ are evaluated in the same context and their result attributes are
join-compatible. The left side performs the meet at the path/frame level and then collects the
resulting tuples; the right side collects each path result first and then joins those tuple sets.
Use ⊔ when the thought is "either path may hold".
u.course.[advanced]
⊔ u.department.[name = "Mathematics"].course
This reaches courses that are advanced, courses offered by the mathematics department, or both. When the two sides output the same attribute, the result is the ordinary union-like case.
(E₁ ⊔ E₂).Σ = E₁.Σ ∪ E₂.Σ
This ordinary set-union law holds when E₁ and E₂ are evaluated in the same context and have the
same result shape. More generally, path union is frame-level disjunction: it aligns compatible
result attributes and combines the branch conditions with ∨.
The operation can also combine paths that reach the same kind of output by different routes:
u.(student.name ⊔ department.name)
This exposes names reached through students or departments. That is a genuine path-level disjunction. It is not tuple construction; tuple construction would try to combine outputs side by side.
Use -- when the thought is "the left path, except where the right path also
holds".
u.course -- u.student.completed
This reaches courses that are not among the completed courses of any student. The left side supplies the result shape. The right side removes assignments that also satisfy its path.
(E₁ -- E₂).Σ = E₁.Σ ∖ E₂.Σ
This ordinary set-difference law holds when E₁ and E₂ are evaluated in the same context and have
the same result shape. In the general path case, -- is logical difference over frames: it keeps the
left condition and conjoins the negation of the right condition.
Path difference is written --. Set difference is written with the set-minus
operator, as in expressions over sets of attributes or values. Keeping those two
spellings separate helps distinguish path difference from set difference.
The postfix operator ! negates a path while preserving the path's output. It is
most intuitive on Boolean-valued paths:
u.course.(code, advanced! as notAdvanced)
Here advanced reaches the Boolean values recorded for the course. advanced!
reaches the Boolean values not reached by advanced. Since advanced is a
single-valued Boolean attribute in the example schema, this reads as the opposite
Boolean value.
This is different from filtering courses that are not advanced:
u.course.[¬advanced]
The filter keeps courses. The expression advanced! still outputs Boolean
values.
The postfix operator ~ negates the existence of a path and returns to the
current receiver. For example, this reaches courses with no prerequisites:
u.course.prerequisite~
The prerequisite path is tested from each course. If no prerequisite can be
reached, the course itself is retained.
The same idea can be written with a fold:
u.course.[prerequisite.Σempty]
The fold version is often easier to read when the thought is simply "there are no
values". The ~ form is useful when the expression is already being written as
a path and the desired result is the current receiver.
The current receiver is kept when the following path has no match. In relational-model terms this resembles semidifference or anti-join, but in Predica it is defined as path complement relative to the current receiver.
Path operations compose with the earlier path tails. Each side of an operation can use navigation, filters, tuple construction, renaming, folds and universal navigation.
(
u.course.[assessment.exam.Σexists]
⊔ u.course.[assessment.project.Σexists]
)
.[prerequisite.Σempty]
.(code, title)
This reaches courses that have an exam or a project, have no prerequisites, and then outputs the course code and title. The important habit is to read each operation as combining path expressions, not as changing what dots mean.
The alternative operator | combines result-producing expressions. It is most
useful when the same idea can be reached through several shapes.
For example, a variant type can define a calculated attribute by giving one expression for each case:
variant Shape
{
{ Real radius } sphere;
{ Real side } cube;
{ Real radius, Real height } cone;
→ Real volume =
sphere.(4/3*pi*radius^3)
| cube.(side^3)
| cone.(pi*radius^2*height/3);
}
For each shape, exactly one effective current variant base attribute applies, so the corresponding
branch contributes the volume. The result is a single Real value.
Similarly, an assessment may be an exam, an assignment or a project. Each kind of assessment has a maximum mark, but the path to the maximum mark depends on the variant attribute:
u.course.assessment.(
exam.maxMark
| assignment.maxMark
| project.maxMark
)
For each assessment, whichever branch applies contributes the maximum mark. The three branches all produce one result of the same type, so the alternative expression exposes one result.
The result can be named with as, just like any other single output:
u.course.assessment.(
(exam.maxMark | assignment.maxMark | project.maxMark)
as maxMark
)
The name belongs to the result of the whole alternative, not to only the final branch.
The operator | is different from ⊔. The ⊔ operator combines path frames.
The | operator combines single-result expressions and exposes one result.
u.course.assessment.(
exam.maxMark
| assignment.maxMark
| project.maxMark
)
This should be read as an expression-level choice of result. It is not asking for a tuple with three attributes, and it is not joining three paths side by side.
Alternatives can appear inside calculations. The following expression calculates the weighted maximum mark for each assessment:
u.course.assessment.(
(exam.maxMark | assignment.maxMark | project.maxMark)
* (exam.weight | assignment.weight | project.weight)
/ 100
as weightedMaxMark
)
The example schema makes this easy because every assessment variant attribute has
maxMark and weight. The path expression does not need a special case
statement; each branch is an ordinary expression component.
Branches do not have to keep the same output name. If branch names differ, the alternative still exposes one result, but the result is anonymous unless it is renamed:
u.course.assessment.(
(exam.durationMinutes | assignment.wordLimit | project.estimatedHours)
as size
)
This can be useful when the alternatives are deliberately being normalised into
one common measure. The name size is supplied by the outer as.
Separate alternatives are separate path tails. In the weighted mark example, the disjoint variant attributes make the intended pairing clear: an exam mark is paired with an exam weight, an assignment mark with an assignment weight, and a project mark with a project weight.
Do not generalise that into a rule that repeated alternatives always share the same witness. As with calculations, repeated multivalued path tails can create combinations. When several values must come from the same place, navigate to the shared prefix first, then pick the values below it.
Alternatives compose with filters, tuple construction and folds. The following expression sums the maximum marks available from all assessments in each course:
u.course.(
code,
assessment.(
exam.maxMark
| assignment.maxMark
| project.maxMark
).Σsum as totalMaxMark
)
The path reaches each course, reaches its assessments, chooses the appropriate maximum-mark path for each assessment, sums those marks, and names the result.
A conditional chooses between two result-producing expressions using a Boolean condition.
For example, this expression labels each course as advanced or introductory:
u.course.(
code,
(if advanced then "advanced" else "introductory") as level
)
The condition is advanced. If it is true for the current course, the result is
"advanced". Otherwise the result is "introductory".
The condition is an ordinary Boolean-valued expression, so it can use navigation, filters and folds:
u.course.(
code,
(if assessment.exam.Σexists then "exam" else "coursework")
as assessmentStyle
)
This labels a course as "exam" when it has at least one exam assessment, and
as "coursework" otherwise.
The then-branch and else-branch are also path expressions. The following expression uses a fold in the then-branch:
u.course.(
code,
(if advanced
then assessment.project.estimatedHours.Σsum
else 0)
as advancedProjectHours
)
For advanced courses, the result is the total estimated project hours. For other
courses, the result is 0.
The producing branches must have a common result type. This works because both branches produce strings:
u.course.(
code,
(if session.time.day.Σcount > 2
then "spread out"
else "compact")
as timetableShape
)
A conditional exposes one result. The result name can be supplied with as on
the whole conditional expression.
Conditionals and alternatives are both expression-level branching constructs, but
they answer different questions. Use if then else when there is an explicit
Boolean condition. Use | when several branches may produce the same kind of
result.
u.course.assessment.(
(if exam.Σexists
then exam.maxMark
else (assignment.maxMark | project.maxMark))
as maxMark
)
This is legal, but it is not the clearest expression of the idea. The variant case is better written with alternatives:
u.course.assessment.(
(exam.maxMark | assignment.maxMark | project.maxMark)
as maxMark
)
The first version asks a Boolean question and then chooses a branch. The second version simply says which paths can produce the maximum mark.
Conditionals compose with the earlier path tails. The condition can filter, count or navigate. The branches can calculate, fold, use alternatives, or return constants.
u.course.(
code,
(if advanced ∧ assessment.project.Σexists
then assessment.project.estimatedHours.Σsum
else 0)
as projectHoursForAdvancedCourses
)
The expression reaches each course, checks a Boolean condition about that course, and then chooses the value to output. Each part is still a path expression.
The fold .Σ collects reached values as a set. The list form Σlist collects
reached values as an ordered list.
For example, this expression gives the course codes in code order:
u.course.Σlist(code order by code)
The path u.course reaches courses. For each course, code produces the list
item, and the order by code clause says how to order the list.
The list item can be a tuple expression. The following expression gives a list of course summaries:
u.course.Σlist((code, title) order by code)
The tuple expression controls the shape of each list item. The order by
clause controls the order of the list items.
The ordering key does not have to be part of the list item:
u.course.Σlist(title order by code)
This lists course titles but orders them by course code.
A simple ordering key is enough when that key determines the intended order:
u.student.Σlist(name order by name)
Ordering direction can be written with ↑ for ascending and ↓ for descending.
If no direction is written, ascending order is assumed. Several ordering keys
may be supplied; they are compared from left to right:
u.course.Σlist((code, title) order by advanced↓, code↑)
A list expression can include an if clause. Only reached values satisfying
the condition contribute list items:
u.course.Σlist(code order by code if advanced)
This gives an ordered list of advanced course codes.
The filter condition can use path tails, just like an ordinary filter:
u.course.Σlist(
(code, title)
order by code
if assessment.exam.Σexists
)
This lists the courses that have at least one exam assessment.
Use .Σ when the result should be a set. Use Σlist when order is part of the
result.
u.course.code.Σ
u.course.Σlist(code order by code)
The first expression produces a set of course codes. The second produces a list
of course codes ordered by code. Set operations such as ⊆, ∈ and ∩ are
for sets; list operations are for ordered values.
The list item is a path expression evaluated for each reached value, so it can use tuple construction, calculations, alternatives and conditionals.
u.course.Σlist(
(
code,
(if advanced then "advanced" else "introductory") as level,
assessment.(exam.maxMark | assignment.maxMark | project.maxMark).Σsum
as totalMaxMark
)
order by code
)
This produces an ordered list of course summaries. Each summary contains the course code, a calculated level, and the total maximum mark across assessments.
Because Σlist is path-based, it can be used inside tuple construction to attach
an ordered list to each current value.
u.department.(
name,
course.Σlist((code, title) order by code) as courses
)
This reaches each department and attaches an ordered list of the courses offered by that department.
Many useful summaries do not need explicit grouping. When a fold appears inside tuple construction, it is evaluated relative to the current receiver.
For example, this expression counts enrolments for each course:
u.course.(
code,
enrolment.Σcount as enrolmentCount
)
There is no separate group by course clause. The current course already gives
the group: enrolment.Σcount counts the enrolments reached from that course.
Use explicit grouping when the groups are not already the current receiver. The following expression groups enrolments by course code:
u.enrolment.groupby(course.code)
This does not compute an aggregate. It groups the observed enrolments by the key
course.code. Aggregates are then written as folds over the group.
u.enrolment.groupby(course.code).(
code,
group.Σcount as enrolmentCount
)
The key course.code supplies the output code. The name group refers to
the enrolments in that group.
An explicit group label can make later path tails easier to read:
u.enrolment.group(e)by(course.code).(
code,
e.Σcount as enrolmentCount,
e.[current].Σcount as currentEnrolmentCount
)
Here e names the enrolments in each group. The second count counts all
enrolments for the course; the third count counts only the current enrolments.
The grouping key can navigate. This expression groups enrolments by department name:
u.enrolment.group(e)by(course.department.name).(
name as departmentName,
e.Σcount as enrolmentCount
)
Each group contains the enrolments whose courses are offered by the same department.
The key can be a tuple expression. This groups enrolments by department and by whether the enrolment is current:
u.enrolment.group(e)by(
(
course.department.name as departmentName,
current
)
).(
departmentName,
current,
e.Σcount as enrolmentCount
)
The tuple key gives one group for each department/current combination that is observed.
Explicit grouping is the path-expression counterpart of grouping before aggregation. In
Tutorial D, a summary query uses SUMMARIZE ... PER ... ADD .... In Predica,
grouping and aggregation remain separate path-expression steps:
u.enrolment.group(e)by(course.code).(
code,
e.Σcount as enrolmentCount
)
The group(e)by(course.code) part forms the groups. The e.Σcount part is the
aggregate over each group.
Groups compose with Σlist. The following expression gives each department
with an ordered list of its course enrolment counts:
u.enrolment.group(e)by(course.department.name).(
name as departmentName,
e.groupby(course.code).Σlist(
(
code,
group.Σcount as enrolmentCount
)
order by code
) as courseEnrolments
)
The outer grouping forms department groups. Inside each department group, the enrolments are grouped again by course code and collected into an ordered list.
Grouping does not replace the other path-expression tools. It gives a new current group, and then the usual operations can be used inside that group: filters, tuple construction, folds, lists, alternatives and conditionals.
u.enrolment.group(e)by(course.code).(
code,
e.[current].Σcount as currentCount,
e.[¬current].Σcount as pastCount,
(if e.[current].Σexists then "active" else "inactive")
as status
)
The expression groups enrolments by course, computes two filtered counts, and then uses a conditional to label the course group.
This chapter gives an informal way to think about type checking path expressions. The full definition is recursive: each path tail is checked in the context produced by the tails before it. Part XIV: Semantics formalises the propagation of type information recorded in frames, and therefore formalises the static typing of path expressions. Here the aim is only to make the typing intuition clear.
Attributes are generally named with singular nouns, such as course or name,
and have singular types like Course or String.
Predica path expressions have the singular type of each reached value.
A path may reach many values through such an attribute, but each reached
value still has the attribute's singular type. Set-valued expressions are available
when the set itself is needed, for example by writing course.Σ.
While reading a path from left to right, the type checker keeps track of the current type. A navigation step may be used when the current type has the named attribute. After the step, the current type is the declared type of that attribute.
For example, if course has type Course and code has type String, then:
u.course.code
is checked by first reaching courses from u, then checking code from each reached
course. The code step reaches String values.
An attribute's cardinality says how many values may be reached. It is not part
of the type itself. A plural attribute of type Real still reaches Real
values, one at a time.
For example:
value X
{
Real v*;
}
The attribute v can reach many values, but each reached value has type Real.
This is why the following expression type checks:
func Real square(Real x) = x*x;
x.(square(v) as product)
The function square takes one Real. The path tail v reaches Real values,
so square(v) applies the function to each reached value. The result may have
many product values, but each individual function call is still a call on a
Real value.
This is the same rule described earlier for filters: do not mistake a plural
attribute for a set-valued expression. If name has type String, then each
reached name is a String, even when many names can be reached.
u.department.[name ∈ {"Mathematics", "Physics"}]
The condition uses ∈ because each reached name is a String. It does not
use ⊆, because name is not a set-valued expression.
In tuple construction, each component is checked from the same current type. The
current value denoted by this does not change between components.
u.course.(code, title)
Both code and title are checked from Course, because both are inside the tuple
construction following u.course.
A filter condition is checked from the current type, and the filter keeps current values for which the condition holds. The condition must be a Boolean-valued expression. The filter does not change the type of the current value.
u.course.[advanced].code
The filter [advanced] is checked from Course and keeps courses. The following
.code is still checked from Course, not from Boolean.
A Boolean condition may be false for every current value. This is still a well-typed path expression:
u.[false]
The result has the same current type as u, namely University, but reaches no
values.
A calculated output is checked as an ordinary expression in the current path context.
The expression must be well typed. Its result type becomes the type of the output
attribute named by the as clause.
u.course.assessment.exam.(durationMinutes/maxMark as minutesPerMark)
Inside the parentheses, durationMinutes and maxMark are both checked from the
current exam assessment value. The output attribute minutesPerMark has the
result type of the / expression.
When a calculation really needs the set of all values reached by a plural path,
use a fold to make that set explicit. In the following example, v.Σ has type
Set<Real>, so it can be compared with a set:
x.((if v.Σ ⊆ {1, 2, 3} then 1 else 2) as a)
The condition in the if expression is Boolean-valued because ⊆ compares two
sets. This differs from using v directly: v reaches Real values one at a
time, while v.Σ collects the reached values into a set.
This part develops Predica's constraint language. Constraints can be read as ordinary database invariants, but the same expressions can also classify larger domains of possible situations.
Predica supports several forms of constraints, each governing different aspects of the values and relationships in a model.
X x; // Each value of x must be of type X
X x[1..3]; // Cardinality of extension of x must be between 1 and 3
entity X
{
Name name;
Integer value;
key { name }; // Instances of X with the same name are the same
}
value Rectangle
{
Real width;
Real height;
→ Real area = width*height; // constrained by expression
}
entity Supplier
{
String name;
Shipment shipment*
where shipment.supplier = this; // where constraint on shipment
key {name};
}
constraints block containing Boolean expressions, each terminated with ;.
entity Person
{
String name;
Boolean registered_to_vote;
Integer age;
constraints
{
¬name.empty();
registered_to_vote → age ≥ 18;
}
}
A constraint expression may appear in a where clause or a constraints block.
A constraint expression must be Boolean-valued. It may use:
¬, ∧, ∨ and →
true and false
path.())
<, ≤, >, ≥, = and ≠.
Predica uses a single expression grammar for both path expressions and logic expressions.
This allows a single expression to freely mix logic connectives (¬ ∧ ∨ →) with path constructs (. ⇒ ⊓ ⊔ | \ [] () ! ~).
The result is interpreted as a logical formula parameterised by the terms introduced by path expressions.
The overall expression may itself be a path expression, allowing shared path prefixes and compositional navigation.
For example:
entity A
{
Boolean x;
Boolean y;
}
entity B
{
A z*;
constraints
{
// ∀Z:A ∀X:Boolean ∀Y:Boolean (this.z(Z) ∧ Z.x(X) ∧ Z.y(Y)) → (X ∨ Y)
z.(x ∨ y);
}
}
Predica allows : for writing cardinality constraints on path expressions.
In the following example b:2 expresses the condition that the cardinality of the extension of b is exactly 2:
entity X
{
Boolean a;
Integer b*;
constraints
{
// Shorthand for a → b.Σcount=2
// ∀A:Boolean this.a(A) → (A → |{B:Integer|this.b(B)}| = 2)
a → b:2;
}
}
A cardinality constraint on a path expression uses the syntax:
path : k // exactly k
path : k..m // between k and m inclusive
path : ? // 0 or 1
path : + // 1 or more
path : k+ // k or more
where k and m are integer literals.
Cardinality binds looser than path composition, so it constrains the complete path expression to
its left. For example, licence.[valid]:1 is parsed as (licence.[valid]):1, not as
licence.([valid]:1).
This syntax mirrors attribute cardinality (x?, x+, x[k], x[k..m]).
A person may purchase a firearm only if they hold a valid licence.
This example shows how Predica expresses such legality conditions directly as
constraints on the FirearmPurchase entity.
The constraints are declarative: they state what must be true in any world
where a firearm purchase is legally permitted.
entity Person
{
String name;
Integer age;
}
entity FirearmLicence
{
Person holder;
Boolean valid;
}
entity FirearmPurchase
{
Person buyer;
FirearmLicence licence where licence.holder = buyer;
constraints
{
buyer.age ≥ 18;
licence.valid;
}
}
When mapped into logic, these constraints become:
// buyer.age ≥ 18
∀B:Person ∀A:Integer
(this.buyer(B) ∧ B.age(A)) → (A ≥ 18)
// licence.valid
∀L:FirearmLicence ∀V:Boolean
(this.licence(L) ∧ L.valid(V)) → V
// licence.holder = buyer (from the where constraint)
∀L:FirearmLicence ∀H:Person ∀B:Person
(this.licence(L) ∧ L.holder(H) ∧ this.buyer(B)) → (H=B)
This example shows how legality conditions can be expressed directly as constraints across a small network of related entities:
entity Person
{
String name;
Integer age;
}
entity BuilderLicence
{
Person holder;
Date validFrom;
Date validTo;
Boolean suspended;
constraints
{
// ∀A:Integer ∀H:Person
// (this.holder(H) ∧ H.age(A) → A ≥ 18)
holder.age ≥ 18;
// ∀V:Date ∀V1:Date
// (this.validFrom(V) ∧ this.validTo(V1) → V ≤ V1)
validFrom ≤ validTo;
}
}
entity ZoningRule
{
String zone;
Integer maxHeight;
Boolean residential;
Boolean commercial;
}
entity EnvironmentalClearance
{
String siteId;
Boolean approved;
Date expiry;
}
entity ConstructionProject
{
String id;
String siteId;
Person builder;
BuilderLicence licence;
ZoningRule zone;
Boolean residential;
Boolean commercial;
Integer height;
EnvironmentalClearance clearance;
Date startDate;
constraints
{
// 1. Builder must match the licence holder
// ∀H:Person ∀B:Person ∀L:BuilderLicence
// (this.licence(L) ∧ L.holder(H) ∧ this.builder(B) → H = B)
licence.holder = builder;
// 2. Licence must be valid at the start date
// ∀V:Date ∀S:Date ∀L:BuilderLicence
// (this.licence(L) ∧ L.validFrom(V) ∧ this.startDate(S) → V ≤ S)
licence.validFrom ≤ startDate;
// ∀S:Date ∀V:Date ∀L:BuilderLicence
// (this.startDate(S) ∧ this.licence(L) ∧ L.validTo(V) → S ≤ V)
startDate ≤ licence.validTo;
// 3. Licence must not be suspended
// ∀S:Boolean ∀L:BuilderLicence
// (this.licence(L) ∧ L.suspended(S) → ¬S)
¬licence.suspended;
// 4. Height must comply with zoning
// ∀H:Integer ∀M:Integer ∀Z:ZoningRule
// (this.height(H) ∧ this.zone(Z) ∧ Z.maxHeight(M) → H ≤ M)
height ≤ zone.maxHeight;
// 5. Environmental clearance must be approved and valid
// ∀A:Boolean ∀C:EnvironmentalClearance
// (this.clearance(C) ∧ C.approved(A) → A)
clearance.approved;
// ∀S:Date ∀E:Date ∀C:EnvironmentalClearance
// (this.startDate(S) ∧ this.clearance(C) ∧ C.expiry(E) → S ≤ E)
startDate ≤ clearance.expiry;
// 6. The clearance must be for this project site
// ∀S:String ∀S1:String ∀C:EnvironmentalClearance
// (this.clearance(C) ∧ C.siteId(S) ∧ this.siteId(S1) → S = S1)
clearance.siteId = siteId;
// 7. Residential/commercial compatibility
// (example: a commercial project cannot be built in a residential-only zone)
// ∀R:Boolean ∀R1:Boolean ∀Z:ZoningRule
// (this.residential(R) ∧ this.zone(Z) ∧ Z.residential(R1) ∧ R → R1)
residential → zone.residential;
// ∀C:Boolean ∀C1:Boolean ∀Z:ZoningRule
// (this.commercial(C) ∧ this.zone(Z) ∧ Z.commercial(C1) ∧ C → C1)
commercial → zone.commercial;
// ∀R:Boolean ∀C:Boolean
// (this.residential(R) ∧ this.commercial(C) → ¬(R ∧ C))
¬(residential ∧ commercial);
}
}
entity Violation
{
ConstructionProject project;
String code;
Boolean resolved;
}
entity LegalProject
{
ConstructionProject project;
Violation violation*;
constraints
{
// All violations must be resolved
// ∀R:Boolean ∀V:Violation
// (this.violation(V) ∧ V.resolved(R) → R)
violation.resolved;
// Every listed violation must belong to this legal project
// ∀P:ConstructionProject ∀P1:ConstructionProject ∀V:Violation
// (this.violation(V) ∧ V.project(P) ∧ this.project(P1) → P = P1)
violation.project = project;
}
}
entity Person
{
Address primaryResidence;
}
entity Company
{
Person director+;
}
A person may have at most one active passport.
entity Person
{
Passport passport?;
}
Sometimes a field is optional, but if present, it must satisfy further constraints. Example: A building project may have an environmental clearance, but if it does, it must be valid.
entity Project
{
EnvironmentalClearance clearance?;
constraints
{
// ∀C:EnvironmentalClearance ∀V:Boolean
// (this.clearance(C) ∧ C.valid(V)) → V
clearance.valid;
}
}
Example: A project with “commercial = true” must have at least one safety audit.
entity Project
{
Boolean commercial;
Audit audit*;
constraints
{
// if commercial, then there is at least 1 audit
// Shorthand for commercial → audit.Σexists;
// ∀C:Boolean (this.commercial(C) → ∃A:Audit this.audit(A))
commercial → audit:+;
}
}
A builder must hold exactly one valid licence at the time of a project.
entity BuilderLicence
{
...
Boolean valid;
}
entity Builder
{
BuilderLicence licence*;
constraints
{
// exactly one valid licence
// This is shorthand for licence.[valid].Σcount = 1
// |{ L:BuilderLicence | ∃V:Boolean this.licence(L) ∧ L.valid(V) ∧ V }| = 1
licence.[valid]:1;
}
}
This example keeps the model deliberately small. The main design choices are:
Enrolment is the source of truth for which student is taking which course.
Student.enrolled is derived from the student's enrolments.
Course.prerequisite is the source of truth for course ordering.
dependent attribute, so the model cannot drift into two inconsistent views of the same prerequisite relationship.
enum Day
{
monday,
tuesday,
wednesday,
thursday,
friday,
saturday,
sunday
}
value TimeSlot
{
Day day;
Integer startMinute;
Integer endMinute;
}
func Boolean overlaps(TimeSlot a, TimeSlot b) =
a.day = b.day
∧ a.startMinute < b.endMinute
∧ b.startMinute < a.endMinute;
entity Course
{
String code;
Boolean advanced;
TimeSlot timeslot;
Course prerequisite*;
// Navigation back to enrolments is derived from Enrolment.course.
Enrolment enrolment* where enrolment.course = this;
key { code };
}
entity Student
{
String name;
Course completed*;
// Navigation to enrolments is derived from Enrolment.student.
Enrolment enrolment* where enrolment.student = this;
// A student's current courses come from the enrolment records.
→ Course enrolled* = enrolment.course;
key { name };
constraints
{
// A course cannot be both completed and currently enrolled.
[completed = enrolled].Σempty;
// If an enrolled course is advanced, all its prerequisites
// must already be completed by this student.
let s=this in
enrolled.(advanced → prerequisite.Σ ⊆ s.completed.Σ);
// No two currently enrolled courses may overlap in time.
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall);
// Do not enrol in a prerequisite after completing a course
// that already depends on it.
let s=this in
enrolled.(let c=this in
s.completed.[c ∈ prerequisite.Σ].Σempty);
}
}
entity Enrolment
{
Student student;
Course course;
}
entity University
{
Student student*;
Course course*;
}
The first constraint uses an intensional filter as a relation. It says there is no course value that is
both in completed and in enrolled.
[completed = enrolled].Σempty
The second constraint keeps the current student in s before entering enrolled.(...).
Inside that path tail, this is the enrolled course, so prerequisite.Σ is the set of prerequisites
of that course, while s.completed.Σ is the set of courses completed by the original student.
let s=this in
enrolled.(advanced → prerequisite.Σ ⊆ s.completed.Σ)
The third constraint compares each enrolled course c with the other enrolled courses for the same
student. The filter .[this ≠ c] removes the course from its own comparison set.
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall)
The final constraint checks the opposite direction of the prerequisite relationship without storing a
separate dependent attribute. For each currently enrolled course c, it rejects any completed course
whose prerequisite set contains c.
let s=this in
enrolled.(let c=this in
s.completed.[c ∈ prerequisite.Σ].Σempty)
The student constraints above translate to the following logic:
// A course cannot be both completed and currently enrolled.
∀C:Course
this.completed(C) → ¬this.enrolled(C)
// Advanced enrolled courses require completed prerequisites.
∀A:Boolean ∀E:Course ∀P:Course
(this.enrolled(E) ∧ E.advanced(A) ∧ A ∧ E.prerequisite(P))
→ this.completed(P)
// Currently enrolled courses must not overlap.
∀T:TimeSlot ∀T1:TimeSlot ∀E1:Course ∀E:Course
(this.enrolled(E1)
∧ this.enrolled(E)
∧ E1 ≠ E
∧ E1.timeslot(T)
∧ E.timeslot(T1))
→ ¬overlaps(T, T1)
// Do not enrol in a prerequisite after completing a dependent course.
∀C:Course ∀E:Course
(this.completed(C) ∧ this.enrolled(E))
→ ¬(E ∈ { P:Course | C.prerequisite(P) })
The following examples are deliberately concrete. Each one shows a tempting expression, what it actually denotes, and the form that matches the intended constraint.
A common first attempt is to write the prerequisite rule without folds:
enrolled.(advanced → prerequisite ⊆ completed)
This has no logic denotation, because it is not well typed. Inside enrolled.(...),
both prerequisite and completed denote individual Course values in the current path context.
The operator ⊆ expects sets, so the type checker rejects the expression:
ERROR: No overload of ⊆ matches operand types ['Course', 'Course']
The correct expression explicitly folds both sides to sets. It also saves the original student in s,
because inside enrolled.(...) the receiver this is the enrolled course.
let s=this in
enrolled.(advanced → prerequisite.Σ ⊆ s.completed.Σ)
∀A:Boolean ∀E:Course ∀P:Course
(this.enrolled(E) ∧ E.advanced(A) ∧ A ∧ E.prerequisite(P))
→ this.completed(P)
Principle: use .Σ when the constraint is about a set of values. Use let before a path composition
when the inner expression still needs the outer receiver.
this changes inside a path tailThis schedule rule looks plausible, but it accidentally compares each enrolled course only with itself:
enrolled.(let c=this in
enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall)
Its denotation exposes the problem. The antecedent contains E ≠ E, which is impossible:
∀T:TimeSlot ∀T1:TimeSlot ∀E:Course
(this.enrolled(E) ∧ E ≠ E ∧ E.timeslot(T) ∧ E.timeslot(T1))
→ ¬overlaps(T, T1)
The issue is that the unqualified inner enrolled is evaluated relative to the current enrolled course,
not relative to the student. Save the student first, then navigate back through s.enrolled.
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall)
∀T:TimeSlot ∀T1:TimeSlot ∀E1:Course ∀E:Course
(this.enrolled(E1)
∧ this.enrolled(E)
∧ E1 ≠ E
∧ E1.timeslot(T)
∧ E.timeslot(T1))
→ ¬overlaps(T, T1)
Principle: after x.(...), unqualified paths in the parentheses are evaluated from each value of x.
Use let outer=this in ... when a nested path still needs the outer object.
If TimeSlot is an interval, this rule is too weak:
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].timeslot ≠ c.timeslot)
It only says the two enrolled courses cannot have exactly equal TimeSlot values:
∀T:TimeSlot ∀T1:TimeSlot ∀E1:Course ∀E:Course
(this.enrolled(E1)
∧ this.enrolled(E)
∧ E1 ≠ E
∧ E1.timeslot(T)
∧ E.timeslot(T1))
→ T ≠ T1
For interval times, equality is not the important domain concept. Use the domain predicate:
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall)
∀T:TimeSlot ∀T1:TimeSlot ∀E1:Course ∀E:Course
(this.enrolled(E1)
∧ this.enrolled(E)
∧ E1 ≠ E
∧ E1.timeslot(T)
∧ E.timeslot(T1))
→ ¬overlaps(T, T1)
Principle: write constraints in the vocabulary of the domain. If the domain concept is overlap, encode overlap directly instead of relying on value inequality.
This example refers to a variant of the model that stores a reverse navigation
Course dependent*. The tempting expression is:
course.(dependent ⇒ advanced)
It does not mean "all dependent courses are advanced". Universal navigation returns values that all
dependents agree on. Used as a Boolean constraint, it says that if every dependent agrees on some
advanced value A, then A must be true:
∀A:Boolean ∀C:Course
(this.course(C) ∧ (∀D:Course (C.dependent(D) → D.advanced(A))))
→ A
The direct constraint is to look for counterexamples: dependent courses that are not advanced. Then require that set to be empty.
course.dependent.[¬advanced].Σempty
∀D:Course ∀C:Course ∀A:Boolean
(this.course(C) ∧ C.dependent(D) ∧ D.advanced(A))
→ A
Principle: to say "all X satisfy P", filter X by the negation of P and require .Σempty.
This is often clearer than reaching for ⇒.
Again suppose the model stores dependent. This expression is easy to write backwards:
student.completed.dependent ≠ course
Its denotation says that the enrolment course must not be a dependent of any completed course:
∀D:Course ∀C1:Course ∀C:Course ∀S:Student
(this.student(S) ∧ S.completed(C) ∧ C.dependent(D) ∧ this.course(C1))
→ D ≠ C1
That is the reverse of "do not enrol in a prerequisite after completing a course that depends on it".
With a stored dependent navigation, the direction should be:
course.dependent ≠ student.completed
∀D:Course ∀C1:Course ∀C:Course ∀S:Student
(this.course(C) ∧ C.dependent(D) ∧ this.student(S) ∧ S.completed(C1))
→ D ≠ C1
The revised model avoids storing dependent at all. It asks whether the currently enrolled course c
appears in the prerequisite set of any completed course:
let s=this in
enrolled.(let c=this in
s.completed.[c ∈ prerequisite.Σ].Σempty)
∀C:Course ∀E:Course
(this.completed(C) ∧ this.enrolled(E))
→ ¬(E ∈ { P:Course | C.prerequisite(P) })
Principle: read a path left to right and name the relationship direction. If the prose says "completed courses that depend on the enrolled course", either navigate from the enrolled course to its dependents, or test whether the enrolled course appears in a completed course's prerequisite set.
Student.
.Σ when comparing sets of path values; otherwise a path usually denotes individual values.
let to preserve an outer receiver before entering a path composition that changes this.
.Σempty.
overlaps when equality or inequality does not capture the real business rule.
Predica can support traditional database applications, but its scope is broader. Its logical foundation provides a principled basis for expressing legality in laws, contracts, and other normative systems where clarity and unambiguous semantics are essential.
The preceding chapters developed the ordinary constraint reading first. The constraint forms and the examples on firearm purchases, construction projects, cardinality constraints and student enrolment can all be read as database invariants. They can also be read normatively, as conditions that a situation must satisfy in order to be compliant.
Predica supports two semantic modes:
In the normative mode, constraints are not merely descriptions of admissible models. Instead, they are interpreted as contractual conditions that classify possible worlds. A world that satisfies all constraints is compliant; a world that fails any constraint is in violation.
Predica uses a global normative interpretation. A legal specification is written as a collection of ordinary descriptive constraints, and the normative force is applied to the specification as a whole: the relevant world ought to satisfy the conjunction of its active constraints.
This avoids requiring every clause to carry its own normative language. Drafting each condition with words such as “shall”, “must”, “may”, “must not”, “is prohibited”, or “is required” is cumbersome, and small differences in wording can introduce ambiguity or accidental differences in legal effect. Predica instead lets the author state the condition directly using ordinary logical structure.
This makes the formal part of legal drafting more accessible to people trained in ordinary logic, mathematics, engineering, science, and software, without requiring them to work directly in deontic or modal logic. Normativity is supplied by the legal interpretation of the specification, not by special operators inside each constraint.
Predica does not require an explicit modal operator to express legality. In the normative (contract‑theoretic) mode, the shift from describing models to classifying worlds is achieved entirely by changing the interpretation of constraints, not by extending the language.
A constraint has the same logical form in both modes. What changes is what the formula is about:
Predica’s normative semantics is not modal in either syntax or semantics. It introduces no accessibility relation, modal frame, or deontic operators.
Because the syntax already expresses a precise condition, no additional modal operator is needed to say “this must hold in the world.” The normative force comes from the semantic stance: the constraint is taken as a requirement on reality rather than a filter on models. Predica’s existing logical constructs are sufficient; only the target of evaluation changes.
Up to now we have used the word ‘type’ informally to refer to the kinds of values introduced by nominal type declarations. We now give the semantic definition of a type and explain how types relate.
A type is a semantic classification of values. Each type has a carrier set: the set of values classified by that type. Predica types are introduced or described in the following ways:
Set<T>,
a subtype T where p refinement, a union, or an intersection.
Any type may have multiple names.
In the semantic model, type equality is equality of carrier sets. Consequently, two types are equal exactly when they have the same carrier set.
A type expression denotes a type, and therefore determines a carrier set drawn from a fixed background universe of sets. Type expressions provide the syntax for referring to types.
Some declarations, such as value and entity, introduce new nominal type identity. Other
declarations, such as type aliases, bind a name to a type expression without creating a new carrier
set. A subtype T where p type expression denotes a subset of an existing type.
The expression L isa T tests the relationship of its left operand L to the type denoted
by its right operand T. Its result type is Boolean. The left operand may be either a value
expression or a type expression.
When L is a value expression, L isa T tests whether the value denoted by L is a member
of T. The test is about the value of L, not the syntactic form of the expression that
produced it. When L is a type expression, L isa T tests whether the type denoted by L
is a subtype of T.
abstract entity Animal {}
entity Dog isa Animal
{
String bark;
}
entity Owner
{
Animal pet;
→ Boolean petIsDog = pet isa Dog;
→ String sound? = if pet isa Dog then pet.bark else none;
}
For a value left operand, a type test is invalid when the static type known for the value is
necessarily disjoint from T, because such a test cannot be satisfied. For a type left operand,
the test is false when the left type is not a subtype of T.
A positive type test narrows the static type known for the tested expression in the guarded
scope. For example, in if pet isa Dog then pet.bark else none, the then-branch may use
members of Dog through pet. In pet isa Dog ∧ pet.bark = "woof", the right-hand side of
the conjunction is checked with the same positive narrowing. The test does not introduce a
general negative narrowing rule for else-branches or disjunctions.
Predica defines a fixed set of built-in types. Built-in types are available without user declarations.
The Boolean type has exactly two values: false and true.
The Integer type denotes mathematical integers.
Predica also defines fixed-width signed and unsigned integer types:
Int8
Int16
Int32
Int64
Uint8
Uint16
Uint32
Uint64
Signed fixed-width integer types include negative and non-negative values within their declared bit width. Unsigned fixed-width integer types include only non-negative values within their declared bit width.
Byte is an alias for Uint8. The two names denote the same type; Byte does
not introduce a distinct carrier or require a conversion to or from Uint8.
The Real type denotes mathematical real numbers.
The Float32 and Float64 types contain the mathematical real numbers which
are exactly representable as finite values in the corresponding IEEE 754
binary formats. Their carriers exclude infinities and NaN values. They also
contain only one mathematical zero: the positive and negative IEEE zero
encodings do not denote distinct values of these types.
Float32 and Float64 are subtypes of Real. A value of either type can
therefore participate in operations inherited from Real, where it denotes
its exact mathematical value.
The IeeeFloat32 and IeeeFloat64 types expose the complete corresponding
IEEE 754 formats. Their carriers include finite values, positive and negative
zero, positive and negative infinity, and NaN values. They are not subtypes of
Real.
There are embeddings from Float32 to both Real and IeeeFloat32, and from
Float64 to both Real and IeeeFloat64. The embedding into an IEEE type
allows finite real-valued floats to participate in IEEE operations without
making the full IEEE type a subtype of Real.
TODO: Specify whether the embedding of the single Float32 or Float64 zero
into its IEEE type selects positive zero, is context-dependent, or requires an
explicit choice.
For operands of type Float32, both addition inherited from Real and addition
inherited through IeeeFloat32 are applicable. The corresponding ambiguity
also arises for Float64 and for other arithmetic operations. An
implementation may treat the expression as unambiguous and select the IEEE
operation, but this must be specified as part of Predica overload resolution:
IEEE addition is not merely an implementation of exact real addition because
the two operations have different denotations when rounding occurs.
TODO: Specify whether an unqualified arithmetic expression on Float32 or
Float64 operands prefers the corresponding IEEE operation, remains ambiguous,
or is resolved using the expected result type.
TODO: Specify the result types of floating-point arithmetic. In particular,
decide whether Float32 + Float32 returns IeeeFloat32, returns Float32 and
fails when the IEEE result is not a finite real value, or denotes exact addition
and returns Real. Make the corresponding decision for Float64 and the
other arithmetic operations.
TODO: Specify the carrier identity and equality of IeeeFloat32 and
IeeeFloat64, including whether distinct NaN encodings denote distinct Predica
values. Predica equality must be distinguished from IEEE numeric comparison,
under which NaN and signed zero have special behaviour.
TODO: Specify whether the IEEE types have an ordinary Predica ordering, expose
the IEEE totalOrder relation explicitly, or support only IEEE numeric
comparisons.
The UnicodeCodePoint type denotes Unicode code points.
The UnicodeScalar type denotes Unicode scalar values. These are
Unicode code points excluding surrogate code points.
The encoded string types are constrained vector types:
Utf8String is a Vector<Byte> containing well-formed UTF-8.Utf16String is a Vector<Uint16> containing well-formed UTF-16.Utf32String is a Vector<Uint32> in which every element is a Unicode
scalar value.
String is an alias for Utf8String. It therefore commits Predica's ordinary
string representation to UTF-8 while allowing representation-specific interfaces
to require Utf16String or Utf32String explicitly.
The elements of an encoded string are its code units. Consequently, indexing,
length, and ordinary vector iteration are code-unit operations. Indexing a
Utf8String returns a Byte, indexing a Utf16String returns a Uint16, and
indexing a Utf32String returns a Uint32. These operations do not pretend that
the \(i\)-th Unicode scalar can be found in constant time in a variable-width
encoding.
Unicode interpretation is provided by decoding operations on encoded strings.
Such operations can iterate over the UnicodeScalar values, or over those values
widened to UnicodeCodePoint. For well-formed UTF-8, every decoded code point is
a Unicode scalar because UTF-8 does not encode surrogate code points. Scalars,
code points, grapheme clusters, and similar textual views are therefore derived
from the encoded vector; they are not the stored elements of String.
Every encoded string can be used as its underlying vector of code units, but the
reverse direction requires validation. Not every Vector<Byte> is a
Utf8String, and not every Vector<Uint16> is a Utf16String. An operation
whose result is not known to preserve encoding validity returns the underlying
vector type. For example, an arbitrary byte slice of a Utf8String is not itself
known to be a Utf8String.
The carriers of built-in primitive types are not defined in terms of attributes. They are specified directly as follows:
| Type | Carrier |
|---|---|
Boolean |
{ false, true } |
Integer |
the set of mathematical integers |
Int8 |
{ n ∈ Integer | -27 ≤ n ≤ 27 - 1 } |
Int16 |
{ n ∈ Integer | -215 ≤ n ≤ 215 - 1 } |
Int32 |
{ n ∈ Integer | -231 ≤ n ≤ 231 - 1 } |
Int64 |
{ n ∈ Integer | -263 ≤ n ≤ 263 - 1 } |
Uint8 |
{ n ∈ Integer | 0 ≤ n ≤ 28 - 1 } |
Byte |
alias of Uint8 |
Uint16 |
{ n ∈ Integer | 0 ≤ n ≤ 216 - 1 } |
Uint32 |
{ n ∈ Integer | 0 ≤ n ≤ 232 - 1 } |
Uint64 |
{ n ∈ Integer | 0 ≤ n ≤ 264 - 1 } |
Real |
the set of mathematical real numbers |
Float32 |
{ x ∈ Real | x is exactly representable as a finite IEEE 754 binary32 value } |
Float64 |
{ x ∈ Real | x is exactly representable as a finite IEEE 754 binary64 value } |
IeeeFloat32 |
carrier(IEEE 754 binary32) |
IeeeFloat64 |
carrier(IEEE 754 binary64) |
UnicodeCodePoint |
{ n ∈ Integer | 0 ≤ n ≤ 0x10FFFF } |
UnicodeScalar |
{ n ∈ carrier(UnicodeCodePoint) | n < 0xD800 ∨ n > 0xDFFF } |
Utf8String |
{ v ∈ carrier(Vector<Byte>) | v is well-formed UTF-8 } |
Utf16String |
{ v ∈ carrier(Vector<Uint16>) | v is well-formed UTF-16 } |
Utf32String |
{ v ∈ carrier(Vector<Uint32>) | every element of v is in carrier(UnicodeScalar) } |
String |
alias of Utf8String |
The carriers of Float32 and Float64 are subsets of Real. The carriers of
IeeeFloat32 and IeeeFloat64 are instead defined by their adopted IEEE
floating-point formats.
Predica provides the following built-in collection types:
Optional<T> contains collections having either zero or one value of type
T.
Set<T> contains finite unordered sets of values of type T. A value occurs
at most once in a set.
Vector<T> contains finite sequences of values of type T. Its length is
determined at run time.
Array<T,N1,...,Nn> contains fixed-shape, \(n\)-dimensional arrays of
values of type T. The static integer arguments N1 through Nn give the
extent of each dimension and are part of the type. Array<T,N> is the
one-dimensional case.
MortonArray<T,N1,...,Nn> contains fixed-shape, \(n\)-dimensional arrays of
values of type T. The static integer arguments N1 through Nn give the
extent of each dimension and are part of the type. Elements are arranged in
Morton order.
OrderedMap<K,V> contains finite mappings from keys of type K to values
of type V. Each key occurs at most once, and entries are ordered by key.
The key type must therefore be ordered.
UnorderedMap<K,V> contains finite mappings from keys of type K to
values of type V. Each key occurs at most once, but the type defines no
ordering of its entries.
Optional<T> is a collection type and is distinct from the attribute cardinality
qualifier ?. An attribute declared T value? has an extension containing zero or one
value of type T; an attribute declared Optional<T> value has an extension
containing exactly one value, and that value is itself an optional collection.
Thus Set<T> represents membership without order, Vector<T> represents a
run-time-length sequence, and the array types represent collections whose extents are known
statically. MortonArray differs from Array by using Morton order to preserve spatial
locality across multiple dimensions. The map types associate unique keys with values and
differ in whether their entries have a defined key order.
Predica does not prescribe an implementation boundary between the core language and its standard library. The language specification covers both and specifies their types and behaviour as a single contract.
An implementation may provide a specified facility as a compiler primitive, as a predeclared library definition, as run-time library code, or as some combination of these. This demarcation is therefore principally an implementation detail. It must not change the meaning of a program or which specified facilities are available to it.
A structural type is a type whose identity is determined by the shape written in its type expression, rather than by a nominal declaration. Structural types are written as an ordered sequence of typed attributes:
{ Real x; Real y; }
{ Point centre; Real radius; }
{ Supplier supplier*; Part part*; Shipment shipment*; }
Structural types are also called tuple types. Formally, a structural value records an extension for each attribute. The extension of an attribute is a set of values of the attribute's declared type, even when the attribute has exactly-one cardinality.
A structural type is an ordered sequence of attribute declarations. Each attribute declaration has an attribute name, an attribute type, and a cardinality constraint.
Let S be a structural type with declarations:
S = ((A1, T1, C1), ..., (An, Tn, Cn))
where Ai is an attribute name, Ti is a type, and Ci is a cardinality constraint. Attribute order is part of the structural type. Attribute names are available for selection, but the carrier is defined from the ordered declarations.
For each attribute declaration, a value of the structural type assigns an extension to the attribute. The extension is a subset of the carrier of the attribute type, and its cardinality must satisfy the declared cardinality constraint.
carrier(S) =
{
((A1, E1), ..., (An, En))
|
Ei ⊆ carrier(Ti)
∧ Ci(|Ei|)
for each i ∈ {1, ..., n}
}
Thus a structural value is a tuple of attribute extensions. It is not obtained by multiplying those extensions out into a Cartesian product.
For example:
{
Integer a;
Real b*;
}
has carrier:
{
((a, A), (b, B))
|
A ⊆ Integer
∧ |A| = 1
∧ B ⊆ Real
}
The extension of a must contain exactly one integer. The extension of b
may contain any number of real values, including none.
This matters when an extension is empty. For example, the values:
((a, {3}), (b, ∅))
((a, {4}), (b, ∅))
are distinct.
Two structural type expressions denote the same type exactly when they have the same ordered sequence of attribute names, cardinality qualifiers, and attribute types.
type Point2D = { Real x; Real y; };
type AlsoPoint2D = { Real x; Real y; };
type SwappedPoint2D = { Real y; Real x; };
Point2D and AlsoPoint2D are names for the same structural type. SwappedPoint2D denotes a
different structural type because the attribute order is different. Attribute order is part of
structural type identity so that ABIs, serialisation formats, and other external representations
can rely on a stable positional layout.
Structural type equality is stricter than structural subtyping. A type may be structurally assignable or embeddable without being the same structural type. The subtype chapters define the subtyping rules.
The empty structural type is written {}. It has no attributes and is useful when a type position
requires a payload but the payload carries no data, for example in variant attributes.
variant Option<T>
{
T some;
{} absent;
}
Structural types may be nested. For example, a single structural type can describe the shape of a small supplier-parts database:
{
{
Integer id;
String name;
Integer status;
String city;
} supplier*;
{
Integer id;
String name;
Color color;
Real weight;
String city;
} part*;
{
Integer supplier_id;
Integer part_id;
Integer qty;
} shipment*
}
Deeply nested structural types are usually not the best way to model domain concepts. Nominal types are usually preferable when the shape has an intended meaning, is reused, or is expected to evolve.
A structural value may be created with a named-field structural literal when the expected type is structural:
{ Real x; Real y; } point = { x = 1, y = 3 };
Each required field must be initialised exactly once. Each initializer is checked against the corresponding field type. The literal matches fields by name; the structural type still records the declared field order as part of its identity.
A type expression is an expression that denotes a type.
Types can be nominal, structural, or formed from other types by type expressions such as
Set<T>, subtype T where p, union { ... }, and intersection { ... }.
Also, types can be types of values or types of entities.
A type name is a type expression. For example, the following type expressions denote value types:
Boolean
Integer
Float64
String
Color
Geometry.Point
The full list of built-in types is given in 46 Built-in Types.
The following type expressions denote nominal entity types:
Supplier
Part
Shipment
ShipmentsSystem
Iso.Author
Iso.ISBN
Iso.Book
The following type expressions denote structural types:
{ Real x; Real y; Real z; }
{ Point centre; Real radius; }
{ Supplier supplier*; Part part*; Shipment shipment*; }
Structural type identity, aliases, nesting, and structural values are described in 47 Structural types.
The following type expressions are formed from other types:
Set<String>
Point<Float64, 3>
subtype Real where this > 0
subtype Person where age ≥ 18
Set<(subtype Real where this > 0)>
subtype (intersection { Shape, Renderable }) where visible
union { EmailContact, PhoneContact }
union { Shape, Circle }
intersection { Shape, Texture }
Parentheses may be used to group type expressions and control precedence. Parentheses do not change the denoted type except by making the intended parse explicit. They are especially useful when a refinement type expression appears as a type argument or when a refinement is applied to a compound type expression:
type PositiveRealSet = Set<(subtype Real where this > 0)>;
type VisibleRenderableShape = subtype (intersection { Shape, Renderable }) where visible;
type Contact = subtype (union { EmailContact, PhoneContact }) where verified;
Two type expressions denote the same type if:
For structural types, equality uses the structural identity rule from
47 Structural types. For example, { Real x; String y; } and
{ String y; Real x; } denote different structural types.
The set of values or entities of a type is called its domain.
For example, Integer has domain ℤ.
The following is a type definition:
value Point
{
Real x;
Real y;
}
This construct defines the type Point.
The definition itself does not denote a type; only the type expression Point denotes the type that
has been defined.
Point is called a nominal type. A nominal type introduces a name whose identity is
distinct from any structural coincidence. Thus Point and { Real x; Real y; } denote
different types even though they share structure.
A type alias declaration gives a name to a type expression:
type Name = TypeExpression;
The declaration does not introduce a new nominal type and does not create a new carrier set. It binds the name on the left to the type denoted by the type expression on the right.
For example:
type Length = Real;
type Mass = Real;
type PointTuple = { Real x; Real y; };
type CircleTuple = { PointTuple centre; Real radius; };
type Names = Set<String>;
type PositiveReal = subtype Real where this > 0;
type ContactMethod = union { EmailContact, PhoneContact };
type ShapeTexture = intersection { Shape, Texture };
A type may have more than one name. For example, Length and Mass above are
additional names for Real; they do not introduce distinct types.
Aliases are transparent in type expressions. For example, if PointTuple is an
alias for { Real x; Real y; }, then using PointTuple in a larger type
expression has the same denotation as using the structural type expression
directly.
Equality in Predica is defined between pairs of static types. It must be reflexive, symmetric, and
transitive wherever it is defined, but it is not necessarily defined between every pair of types.
An expression such as x = y is well-typed only when equality is available for the static type of
x and the static type of y. When no such equality is available, the two types are incomparable
for equality and the comparison is a type error.
value B { Integer x; };
value C { Integer x; };
var b = B { x = 1 };
var c = C { x = 1 };
print b = c; // type error unless equality between B and C is defined
Every type defines equality on its own values, so values of the same static type can always be compared. Predefined scalar types, concrete value and entity types, enumerated types, structural types, and quotient types define equality according to their own rules. Equality on abstract types is specified in 71 Abstract types. Equality between two different static types still requires the types to be comparable.
Equality can also be available through a concrete common supertype. If two static types share a unique least common supertype with defined equality, then values of those two types can be compared using equality at that supertype.
Equality between two different types can be defined explicitly by declaring the equality function for that pair:
value B { Integer x; };
value C { Integer x; };
func Boolean =(B b, C c) = b.x = c.x;
The declaration defines symmetric equality between B and C: b = c and c = b use the same
predicate. The predicate must return Boolean, and it must be compatible with all other equality
definitions involving B and C so that equality remains reflexive, symmetric, and transitive
wherever it is defined.
Compound type expressions can require equality to be available across their members. The syntax of
the type expression is independent of this requirement: union { ... } and intersection { ... }
are parsed as ordinary type expressions, and the type checker then verifies the equality evidence
needed by the context.
For a union type expression such as:
type Contact = union { EmailContact, PhoneContact, PostalContact };
equality on Contact is available only when equality is defined for every relevant pair of member
types. Pairs that already share defined equality need no extra declaration. Other pairs require
an explicit equality definition or some other declaration that makes equality available.
Intersection types have the corresponding requirement that the member types be comparable with a
coherent equality. For example, intersection { A, B } denotes values that inhabit both member
types, so the identity of such values must not depend on which member type is used to observe them.
A disjointness declaration is the compact form for the common case where a set of types are known to have no values in common:
disjoint EmailContact, PhoneContact, PostalContact;
type Contact = union { EmailContact, PhoneContact, PostalContact };
The declaration asserts pairwise disjointness between the listed types. Therefore equality between values of distinct listed types is defined and always false. It is rejected if the type graph or intrinsic language rules already imply that any listed pair necessarily overlaps.
From the point of view of making equality available, disjoint A, B; behaves like shorthand for
defining cross-type equality as false. It is stronger than an equality function returning false,
because it also records the type-graph fact that the carrier sets of A and B do not overlap.
Literal and constructor syntax creates ordinary Predica values. The parser keeps delimiter forms
such as (...), {...}, and <...> syntactic; name resolution and expression typing then decide
whether a form is a function call, a nominal constructor, a structural literal, a finite set literal,
or a generic application.
Boolean, integer, real, and string literals denote scalar values. An expected type may refine the exact built-in type used for a literal, provided the literal can inhabit that type:
Boolean b = true;
Int32 n = 1;
Float32 x = 1.5;
String s = "London";
Integer literals are exact. A literal assigned to a bounded integer type must be in range.
Real literals are checked against the requested floating-point or real type. Enum values are named
values such as Color.red; they are not string literals.
Set<T> is the type of finite sets of values whose element type is T. The empty-set expression
∅ has no elements and is valid at any compatible set element type:
Set<Integer> numbers = ∅;
names ∩ ∅;
"London" ∈ ∅;
The expression ∅ does not have one fixed element type by itself. Its element type is supplied by
an expected type or by surrounding set operators. A standalone empty-set expression without enough
context is ambiguous.
A primary curly expression with ordinary expression items is a finite set literal:
{1, 2, 3}
{1, 2.5}
{Color.red, Color.green}
The element type is inferred from the items, using the least common element type accepted by the
type system. For example, {1, 2.5} has type Set<Real> if integer values are assignable to
Real. If no common element type exists, the literal is rejected. Duplicate values do not change
the denoted set.
An empty curly expression {} is an empty finite set literal only when an expected Set<T> type
or equivalent context supplies the element type. Prefer ∅ when the source is specifically naming
the empty set.
The range expression a..b denotes the finite set of consecutive integers from a to b,
inclusive:
1..4 // {1, 2, 3, 4}
3 ∈ 1..4 // true
Both bounds must be statically evaluable integer values. The lower bound must not exceed the upper bound. An implementation may reject a range that is too large to materialise as a finite set. Range expressions are not associative; write parentheses when a range is part of a larger set expression.
A curly expression with named field initializers may construct a structural value when the expected type is structural:
{ Real x; Real y; } point = { x = 1, y = 3 };
Each required structural field must be initialised exactly once, and each initializer expression is checked against the corresponding field type. Field order in the structural type remains part of the structural type identity, but the named-field literal is matched by field name.
A nominal value or entity type introduces constructor syntax using the type name. The preferred form names the initialised attributes:
value Point
{
Real x;
Real y;
}
Point { x = 1, y = 3 }
Box<Integer> { value = 1 }
The constructor target must resolve to a concrete nominal type. A complete constructor initializes every required base attribute. Optional attributes may be omitted. Every supplied initializer must be assignable to the corresponding attribute type, and a constructor must not initialize an unknown, duplicate, or dropped attribute.
Constructor syntax is a convenience for initialising a value. The value above may instead be constructed programmatically:
var Point point;
point.x := 1;
point.y := 3;
The two forms are alternative ways to initialise the same kind of value. The rules for partially initialising a value through its constituent locations are specified in Uninitialised Locations.
The positional shorthand supplies constructor arguments in declaration order:
Point(1, 3)
Box<Integer>(1)
This is shorthand for the named-field constructor and is rendered canonically as the named-field form. A call-shaped expression that can resolve both as a function call and as a nominal constructor shorthand is ambiguous and must be rejected.
The keyword var introduces a statically typed variable.
The element type of the variable is either supplied by an explicit type
annotation or determined by static type deduction applied to the expression used
to assign its initial value. Subsequent assignments must be compatible with
this static element type; otherwise a static type error is reported.
A variable declaration may also include a cardinality qualifier, such as * or
?. That qualifier is a constraint on the variable location, not part of the
element type. Assignment to the variable must satisfy both the static element
type and the location's cardinality constraint.
For example, var Integer id* = {}; declares a location named id whose
extension contains zero or more Integer values. The element type is still
Integer, not Set<Integer>.
These are separate static checks. Assigning a String expression to an
Integer variable is a static type check failure. Assigning an expression whose
static cardinality may be many to a cardinality-one variable is a static
cardinality constraint check failure.
// Introduces a statically typed variable named x. Its static type is Integer.
var x = 10;
// Gives an empty multi-valued variable an explicit element type.
var Integer id* = {};
// Static type check failure: String is not assignable to Integer.
x := "hello";
A common case is an optional path that has been tested before it is used:
value List
{
Integer head;
List tail?;
}
func Boolean memberOf(List list, Integer value)
{
if list.head = value {
return true;
}
if list.tail = none {
return false;
}
return list.tail.memberOf(value);
}
The attribute tail? makes list.tail optional, so the expression has static
cardinality zero or one. The final statement passes list.tail as the receiver
argument to memberOf, whose first parameter requires a single List.
The call is valid because the preceding test has refined the cardinality. The
test list.tail = none proves that the extension is empty in the guarded block.
Because that block returns, the following statements are checked in the
remaining case, where list.tail is non-empty. Since the original cardinality
was at most one, the remaining cardinality is exactly one.
This is cardinality flow. Static checking tracks both an expression's element type and its static cardinality. Flow-sensitive checks may refine cardinality in guarded scopes, just as a positive type test may refine the static type of an expression.
Similarly, inside the then-branch of if E ≠ none { ... }, an expression E
whose static cardinality is zero or one is narrowed to cardinality one. For an
expression whose static cardinality may be many, E ≠ none proves only that the
extension is non-empty; it does not make the expression single-valued.
Cardinality flow is a static analysis rule. It does not execute a query while checking the program. If a required cardinality fact cannot be proved from the static expression shape and the enclosing guards, the use is rejected as a static cardinality constraint check failure.
Dynamic variables are disabled by default. A file with no @dynamic
directive is in strict mode, so assigning to a name that has not been declared
with var is a static error.
A script or interactive file may opt into dynamic variables by putting
@dynamic = true; in the source-file prologue: after any
component declaration and before any using declaration, ordinary
declaration, or statement.
In a script with no component declaration, this is the first non-comment item in
the file. In that mode, assigning to an
undeclared name introduces a dynamically typed variable. A dynamically typed
variable can later be assigned values of any type. This mode is intended for
exploratory, interactive, or script-like code.
@dynamic = true;
// First assignment introduces a dynamic variable named y.
y := 10;
// y can be freely reassigned a value of any type.
y := "hello";
Reusable component source must use the default strict mode and must not enable
dynamic variables. In strict mode, dynamic variables are not permitted and every
variable must be introduced explicitly using var. Because strict mode is the default, source
normally omits the directive; @dynamic = false; is allowed as an explicit spelling of the default.
@dynamic = false;
// Static error: dynamic variables are disabled.
y := 10;
Predica is fundamentally a statically typed language designed for clarity, safety, and formal reasoning. Static typing ensures that errors are detected early and that programs remain predictable and analyzable.
However, practical development workflows also benefit from a lightweight,
flexible mechanism for experimentation, scripting, and interactive
exploration. Dynamic variables provide this flexibility without weakening
the core type system, because they are introduced only when the programmer
omits var in a file that explicitly enables dynamic mode.
This design keeps the language mathematically disciplined while still supporting rapid prototyping and REPL-style usage.
Predica supports declarations of isomorphisms between types.
value A
{
T v?;
}
variant B
{
T v;
Boolean unknown;
}
A a ← B b
{
a.v = if b.unknown then none else b.v;
}
B b ← A a
{
b = if a.v = none then unknown(true) else v(a.v);
}
namespace A
{
@domain("employee with known name")
entity R1
{
Integer id;
String name;
key {id};
}
@domain("employee with known address")
entity R2
{
Integer id;
String address;
key {id};
}
entity System
{
R1 r1*;
R2 r2*;
→ all* = r1 ∪ r2;
}
}
namespace B
{
@domain("employee with known name and unknown address")
entity S1
{
Integer id;
String name;
key {id};
}
@domain("employee with known address and unknown name")
entity S2
{
Integer id;
String address;
key {id};
}
@domain("employee with known name and known address")
entity S3
{
Integer id;
String name;
String address;
key {id};
}
entity System
{
S1 s1*;
S2 s2*;
S3 s3*;
→ all* = s1 ∪ s2 ∪ s3;
constraints
{
disjoint { s1, s2, s3 };
}
theorems
{
// Employees with both name and address known
s3 = (s1 ∪ s3) ∩ (s2 ∪ s3);
// These follow from the disjointness axiom
s1 ∩ s2 = ∅;
s1 ∩ s3 = ∅;
s2 ∩ s3 = ∅;
}
}
}
isomorphism employeeInfoRefinement
{
// {R1, R2} → {S1, S2, S3}
a:A.System → B.System
{
s3 = a.r1 ⨝ a.r2;
s1 = a.r1 ∖ s3.{id, name};
s2 = a.r2 ∖ s3.{id, address};
}
// {S1, S2, S3} → {R1, R2}
b:B.System → A.System
{
r1 = b.s1 ∪ b.s3.{id, name};
r2 = b.s2 ∪ b.s3.{id, address};
}
}
A has two prime factors and B has one. So for updating A is superior. B is more useful for read-only views. The bijection is nice to know because B is non-lossy.
In the above example there is an expression s1 ∪ s2 ∪ s3.
entity System
{
S1 s1*;
S2 s2*;
S3 s3*;
→ all* = s1 ∪ s2 ∪ s3;
}
At first glance, the expression s1 ∪ s2 ∪ s3 may appear ill-formed when viewed through the lens of
traditional Relational Algebra where this is taking the union of relvars which are not union compatible.
In Predica, however, each attribute, whether declared or derived, is a unary predicate. Its extension is simply the set of entity instances for which the attribute holds. Set-theoretic operations such as union, intersection, and difference therefore apply directly to attributes, irrespective of the internal structure or declared type of the associated entities. These operations are performed over their unary extensions, not over multi-attribute relational schemas.
The problem with the above isomorphism approach is that it doesn't actually give rise to a single type that simultaneously enjoys both representations. But that is desirable because it means you can read or update using either representation.
value Point
{
// cartesian representation
Real x;
Real y;
// polar representation
Real r where r ≥ 0;
Real θ where 0 ≤ θ < 2*pi;
constraints
{
r=0 → θ=0;
}
isomorphism {x,y} ↔ {r,θ}
{
r = sqrt(x^2 + y^2);
θ = atan2(y,x);
x = r * cos θ;
y = r * sin θ;
}
}
The keyword object may prefix a type expression:
object T
object T establishes a heap-allocation boundary. Rather than embedding the
representation of a T value directly in a containing value, the implementation
allocates it separately.
For data in volatile memory, the containing representation may hold a hidden pointer to the separate heap allocation. A database can be regarded, at this level, as a persistent heap. In persisted data, the corresponding physical link is a hidden persistent object identifier. Loading the value resolves that identifier to an in-memory location.
object specifies a representation boundary, not whether a value is persistent.
Like any other type, a type containing object T may be used for a variable that
exists only in memory. The qualifier does not introduce a new logical domain,
change the predicates of T, or give the value a new application-level identity.
Logically, object T is simply T: expressions observe a value of type T, not a
pointer, reference, address, or persistent identifier. All such links belong to
the physical representation and are hidden from Predica expressions.
Accessing an object T within persisted data may require its separately stored
representation to be loaded. An implementation may cache that representation in
memory.
For persisted values, an object boundary permits a value to be loaded independently of its container. Construction, ownership, sharing, deletion, and transaction behaviour require explicit rules for separately stored objects.
The object qualifier is orthogonal to the distinction between value and entity
types. Either kind of type may require a separate persistent representation. The
qualifier says how a value is stored; value and entity continue to say what kind
of thing it represents.
The following types model a JPEG encoding and its decoded RGB image:
// Encoding of an image using JPEG format
type JpegBytes = Vector<Byte>;
value RGB
{
Uint8 r;
Uint8 g;
Uint8 b;
}
value Image<Type T>
{
Vector<T> values;
Int32 width;
Int32 height;
}
value Point<Type T>
{
T x;
T y;
}
func Point<T> componentWiseProduct<Type T>(Point<T> a, Point<T> b) =
Point<T> { x = a.x * b.x, y = a.y * b.y };
// Pure function that decodes JPEG into an RGB image in memory
extern "C++:predica"
{
Image<RGB> decodeJpeg(JpegBytes jpeg);
}
// Single image with synchronous decoding
value Jpeg
{
JpegBytes bytes;
→ Image<RGB> image = decodeJpeg(bytes);
}
Jpeg.bytes is the encoded representation. The derived attribute image denotes the
decoded RGB image and is defined by the pure external function decodeJpeg. As written,
accessing image is synchronous. An implementation may materialise the derived value
as a cache, provided it remains consistent with bytes.
A tiled image may contain millions of tiles, each perhaps containing 256 × 256 pixels. Each tile can be stored as a separate object in the database. Predica allows these tile objects to be loaded on demand, so accessing part of the image does not require every tile to be loaded.
value TiledImage
{
const Point<Int32> numTiles;
const Point<Int32> tilePixelSize;
constraints
{
isPowerOfTwo(numTiles.x);
isPowerOfTwo(numTiles.y);
}
→ Int64 countTiles =
Int64(numTiles.x) * Int64(numTiles.y);
→ imageSize =
componentWiseProduct(numTiles, tilePixelSize);
MortonArray<object Jpeg?, numTiles.x, numTiles.y> tiles;
}
object-qualified element
The key declaration in TiledImage is:
MortonArray<object Jpeg?, numTiles.x, numTiles.y> tiles;
Here object qualifies the Jpeg element type. It instructs the compiler to
represent each present Jpeg as a separate database object. It does not qualify
the MortonArray itself: the array belongs to the containing TiledImage, while
its populated elements are stored independently.
A large TiledImage may contain millions of tiles. Such an image cannot be
written in a single transaction, so there is an inherent requirement for its
tiles to be added over time, across many transactions. Declaring the elements
as object Jpeg? allows each optional tile object to be populated independently.
Logically, the declaration remains a two-dimensional array of zero-or-one Jpeg
values. The object qualifier does not introduce reference semantics into the
logical model. In particular, it is not logically an array of storage identifiers,
pointers, or references. Such representations would expose storage mechanics to
ordinary expressions. With object Jpeg?, Predica expressions continue to observe
optional Jpeg values, while the compiler manages their independent storage.
MortonArray<T,X,Y> is a two-dimensional array whose extents are X and
Y. Its elements are arranged in Morton order, also called Z-order, so nearby
spatial coordinates tend to remain nearby in the one-dimensional representation.
The following image illustrates Morton order with one level of nesting (4 × 4):
The next image illustrates Morton order with two levels of nesting (8 × 8):
In TiledImage, numTiles.x and numTiles.y give the fixed extents of tiles.
Each cell has cardinality zero or one because its element declaration is
object Jpeg?. The array therefore describes a fixed spatial grid in which tiles
may be populated independently.
The following implementation scheme uses hidden database object identifiers, called OIDs here. An OID is not a Predica type and cannot be inspected or compared by Predica expressions. It is an implementation artefact used to locate a separately stored object.
When a TiledImage is constructed, its const attributes numTiles and
tilePixelSize must be supplied to the constructor. The dimensions of tiles
are therefore known when construction completes and cannot subsequently change.
This allows the implementation to preallocate the Morton array's object-identity
space.
Preallocation does not require the array or its elements to be materialised.
Because the elements are database objects, the implementation can reserve a
contiguous range of OIDs and record only its base OID in the TiledImage. The OID
for a tile at (x,y) is:
tileOid(x,y) = tilesBaseOid + morton2D(x,y)
morton2D(x,y) =
Σᵢ ((((x >> i) & 1) << (2i))
+ (((y >> i) & 1) << (2i + 1)))
Thus the bits of x occupy the even-numbered bits of the Morton index and the
bits of y occupy the odd-numbered bits. For example, (0,0), (1,0),
(0,1), and (1,1) have Morton indices 0, 1, 2, and 3 respectively. There is
no need to store either an array of OIDs or a separate index from coordinates to
OIDs.
The calculated OID can then be used to fetch the corresponding Jpeg, if it
exists. An empty cell has no logical Jpeg value and need not have any stored
object. A populated cell has a Jpeg stored under the OID derived from its
position.
Tiles may be populated over multiple transactions. Because every present tile is a separate database object, Predica can resolve and load an individual tile on demand without loading the other tiles in the image.
Logical absence must be distinguished from physical residency. An empty cell has cardinality zero. A populated cell has cardinality one whether its object is already cached in memory or must be loaded from the database.
Each tile object participates independently in snapshot evaluation. Its derived
attributes, including the decoded image, may be materialised and cached
independently. Updating a tile's encoded bytes invalidates the affected derived
cache without requiring unrelated tiles to be decoded or invalidated.
For x in 0..numTiles.x-1 and y in 0..numTiles.y-1, a tile can be reached by
ordinary array indexing:
tiles[x,y] // Jpeg, cardinality 0..1
tiles[x,y].image // Image<RGB>, cardinality 0..1
If the cell is empty, both expressions have empty extensions. If the cell is
populated, navigation to image resolves the tile object when necessary and then
evaluates or retrieves its derived decoded image.
There is no ambiguity between array indexing and a path filter when parsing a path.
Indexing uses brackets directly after the array expression, whereas a filter is
introduced by .[:
tiles[x,y]
s.supplier.[city = "London"].shipment.part.[color = Color.red]
This part first introduces the nominal type constructors: value types, entity types, enum types, variant types, and quotient types. Later chapters describe features shared across those constructors, such as attributes, parameterised attributes, derived attributes, keys, embedding, generics, ordering, and constraints.
Subtyping is treated separately in the Subtypes part.
The following declares a value type named Point, having two attributes x and y of type Real:
value Point
{
Real x;
Real y;
}
Note that Point and Real are types whereas x and y are attributes.
This is a nominal type declaration that does three things simultaneously:
Point.
var p = Point { x = 1, y = 2 };
This form is stable if attributes are reordered or if optional attributes are later added.
A positional shorthand is also available when every required base attribute is supplied in
declaration order:
var p = Point(1, 2);
The positional form is shorthand for the named-field constructor. It is not an ordinary
component function and it should not be used when the named form would be clearer.
x and y. In path expressions, these behave like projection or
navigation from a point to its coordinates:
x : Point → Realy : Point → Real
// Use the attributes as path expressions
print p1.x, p1.y;
In the logical interpretation, the same attributes are predicates. For example, p.x(X)
is a formula using x as a predicate, meaning that X is an x-coordinate of p.
value instead of struct or record
Although struct or record may seem more conventional, the keyword value is used for two
reasons:
struct and record suggest structural types.
Predica already has explicit structural types, described in 47 Structural types.
For example:
type Point = { Real x; Real y; };
declares Point as an alias for a structural type.
value and entity.
Their names reflect their intended metaphysical roles:
value types represent mathematical valuesentity types represent things that exist in time and spacevalue makes this distinction explicit and intuitive.
The Point type can be used in the definition of other types, such as the Circle type:
value Circle
{
Point centre;
Real radius where radius ≥ 0;
}
This may look like a tuple type, but it is not a tuple type. There are some important distinctions:
R^2, not a tuple.
A value type has these properties:
Value types are ordered by default when their base attributes provide a canonical comparison,
unless the declaration uses unordered. They can also supply an explicit order declaration.
The general rules are described in 66 Ordering.
The structural body of a value type is the structural type obtained from its attribute declarations, before applying any constraints. For example:
value Circle
{
Point centre;
Real radius where radius ≥ 0;
}
has structural body:
{
Point centre;
Real radius;
}
A value type declaration introduces a named type whose carrier is isomorphic to the constrained carrier of its structural body.
If S is the structural body of a value type V, and K is the conjunction of
its constraints, then:
carrier(V) ≅ { v ∈ carrier(S) | K(v) }
The constraints K include constraints written in the constraints block and
constraints introduced by where clauses on attributes. In the example above,
K includes radius ≥ 0.
The isomorphism is canonical: a value of V has the same observable
attribute extensions as the corresponding structural value. However, V is a
nominal type, so it is not the same type as its structural body.
A concrete domain represents an entity in the real world, with the attributes giving the properties of the entity. For example, the following are the entities associated with Chris Date's Supplier-and-parts database example appearing in his book An introduction to Database Systems.
entity Supplier
{
Integer id;
String name;
Integer status;
String city;
key {id};
}
entity Part
{
Integer id;
String name;
Color color;
Real weight;
String city;
key {id};
}
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier,part};
}
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
Shipment shipment*;
constraints
{
shipment.supplier.Σ ⊆ supplier.Σ;
shipment.part.Σ ⊆ part.Σ;
}
}
In Predica, an entity type may represent anything that exists in time and space and can be referred to. An entity need not be a physical object; it may also be an organisation, event, action, or relationship. A birth, a marriage, a shipment, or an ownership are all entities in this sense.
This can initially surprise people familiar with the Entity–Relationship Model (ERM), which draws a sharp but ultimately artificial line between entity boxes and relationship diamonds. Traditional ER modelling treats relationships as second-class constructs, and events, actions, and interactions are often forced into diagrammatic shapes that do not reflect their real-world status as things that occur and can be identified.
The structural body of an entity type is the structural type obtained from its attribute declarations, before applying any constraints and ignoring key declarations. For example:
entity Supplier
{
Integer id;
String name;
Integer status where status ≥ 0;
key {id};
}
has structural body:
{
Integer id;
String name;
Integer status;
}
An entity type declaration introduces a named type whose carrier is isomorphic to the constrained carrier of its structural body, just as for value types.
If S is the structural body of an entity type E, and K is the conjunction
of its constraints, then:
carrier(E) ≅ { v ∈ carrier(S) | K(v) }
The constraints K include constraints written in the constraints block and
constraints introduced by where clauses on attributes. In the example above,
K includes status ≥ 0.
Key declarations are ignored in this definition. A key does not change which values belong to the carrier of an entity type.
The distinction between value types and entity types is therefore not a difference in the mathematical construction of their carriers. Both are defined from their structural bodies and constraints. The distinction concerns how the type is interpreted and used by modellers.
Entity keys, including default keys, derived key attributes, and keys inherited from abstract entity types, are specified in 57 Keys.
A key declaration on an entity type names attributes that distinguish entities of that type within an extension. A key does not change the carrier of the entity type. Instead, it constrains every extension whose element type is that entity type.
For example, if Supplier declares key {id}, an extension of Supplier may not contain two
distinct suppliers with equal id extensions:
entity Supplier
{
Integer id;
key {id};
}
entity ShipmentsSystem
{
Supplier supplier*;
}
A key is a non-empty set of attribute names. Each name must resolve uniquely to a direct or inherited attribute of the entity type. Unknown and ambiguous names are static errors. Repeating an attribute name in one key is also a static error.
key {name, date}; // valid
key {missing}; // Error: unknown attribute
key {name, name}; // Error: repeated attribute
The order of names has no semantic significance: key {name, date} and key {date, name} denote
the same candidate key. Paths and arbitrary expressions are not permitted. A calculated key
component must first be given a name by declaring a derived attribute.
key {customer.id}; // Error: paths are not permitted
key {normalize(email)}; // Error: expressions are not permitted
→ String normalizedEmail = normalize(email);
key {normalizedEmail}; // valid
Predica defines = on every type: values of the same static type can always be compared for
equality. This includes abstract types. Equality on an abstract carrier is not determined by its
declared attributes; it preserves the identity of the concrete values which inhabit that carrier.
Every attribute type can therefore be used in a key.
Equality is applied separately to the extension of each key attribute. Equality between the element types of different key attributes is not required.
abstract value AbstractValue
{
String description;
}
entity Observation
{
AbstractValue subject;
key {subject}; // valid: equality is defined on AbstractValue
}
An attribute contributes its complete extension to a key. It does not contribute an arbitrarily selected member of that extension. There is therefore no restriction on the cardinality constraint of a key attribute: exactly-one, optional, multivalued, bounded, and infinite-capable attributes may all be used.
entity Person
{
String name;
String email+;
key {name, email};
}
Two Person values have equal key profiles exactly when their complete name extensions are equal
and their complete email extensions are equal. More generally, for a key K on entity type E,
the key profiles of x:E and y:E are equal iff:
∀a ∈ K, a(x) = a(y)
Extension equality is set equality. For an attribute a with element type T:
a(x) = a(y) iff ∀v:T (v ∈ a(x) ↔ v ∈ a(y))
An infinite extension is a mathematically valid key component. Whether its equality can be decided or indexed efficiently depends on its representation and is an implementation concern rather than a change to the key's denotation.
A key may name either a base or derived attribute, whether declared directly or inherited. A derived key component is compared using the extension produced by its definition.
entity Person
{
String givenName;
String familyName;
→ String normalizedName = normalizeName(givenName, familyName);
key {normalizedName};
}
If a subtype refines an inherited derived calculation, the inherited definition remains in force and the refined calculation must produce the same extension. The meaning of an inherited key is therefore unchanged.
Every abstract entity type must have at least one explicitly declared or inherited key. A root abstract entity must therefore declare a key. An abstract subtype may inherit a key and does not need to repeat it. A subtype cannot remove or replace an inherited key, although it may declare additional candidate keys. An abstract entity declaration with no effective key after inheritance is a static error.
An underived key attribute on an abstract entity is an outstanding attribute requirement. Every concrete subtype must implement it as a base or derived attribute. A derived key declared by an abstract entity already has an implementation and may also be inherited.
abstract entity Identified
{
String identifier;
key {identifier};
}
entity Customer isa Identified
{
String identifier;
String name;
}
entity Catalogue
{
Integer id;
Identified item*;
key {id};
}
The inherited key permits Catalogue.item to distinguish and index values uniformly, including
when its extension contains values of different concrete subtypes of Identified.
The inherited key provides operational identity within each Identified extension; it does not
define equality on the carrier of Identified. Keys constrain which values may coexist in an
extension and do not change the carrier or equality of the entity type.
An entity may declare multiple candidate keys. All candidate keys have equal logical status:
entity Book
{
Integer id;
Person author;
String title;
String description;
key {id};
key {author, title};
}
A database implementation may use the first candidate key as its primary physical index and the others as secondary indexes, but that choice does not alter their logical meaning.
If a concrete entity does not explicitly define or inherit a key, the system assumes one key consisting of all base attributes. This yields structural identity: two instances are identical exactly when all their base attribute extensions are equal.
Relationship entities such as ownership, enrolment, membership, and authorship commonly use this default:
entity Ownership
{
Person owner;
Vehicle vehicle;
}
An abstract entity does not receive the default all-base-attribute key because its attributes do not collectively identify its carrier values. It must instead have at least one explicit or inherited key.
An enumerated type specifies a set of mutually exclusive possible values. For example, a value
of type PrimaryColor can be red, green or blue:
enum PrimaryColor
{
red,
green,
blue
}
An enum attribute can have an explicit representation-value expression:
enum HttpStatus
{
ok = 200,
notFound = 404,
unavailable = 503
}
The expression must be evaluable at compile time and its value must be an Integer. It may use
anything available in its declaration context, including generic value parameters. For example:
enum Offset<Integer N>
{
first = N,
second,
last = N + 10
}
Explicit representation-value expressions are part of the enum's serialised representation. A
attribute without an explicit expression has the value of the preceding attribute plus one, or zero if there
is no preceding attribute; consequently second above has value N + 1. Attribute names are source labels
for those values and do not determine their representation.
Renaming an attribute does not change its serialised representation when its representation value remains
unchanged for every valid instantiation.
A generic enum introduces a family of enum types. Representation-value expressions are evaluated for each instantiation in that instantiation's generic environment. The resulting values must be distinct and valid for the enum's storage type. Generic constraints may restrict the arguments for which the enum can be instantiated.
An enum declaration introduces a finite named type whose carrier is the set of
declared enum values. For example PrimaryColor has carrier:
carrier(PrimaryColor) = { red, green, blue }
The declared enum values are values of the enum type. Enum values with the same spelling in different enum types are distinct unless the language defines an explicit coercion or identification between them.
{} attributes
PrimaryColor is formalised as syntactic sugar for:
variant PrimaryColor
{
{} red;
{} green;
{} blue;
}
So each value of an enumerated type is a base attribute of type {}, the empty structural type
described in 47 Structural types.
The attribute can be navigated as a unary path, such as color.red.
Since the result type of such a path is {}, Boolean contexts elaborate it as an existence test,
for example color.red.Σexists.
For every value of the enumerated type, exactly one effective current base attribute has a non-empty extension and every other current base attribute has an empty extension. The selected base attribute's extension contains the single empty structural value. Derived attributes do not participate in this constraint, and dropped base attributes are not current attributes.
Subtyping of enumerated types is possible. A subtype must have a subset of the attributes of its supertype. For example:
enum Color
{
red,
green,
blue,
magenta,
cyan,
yellow
}
enum PrimaryColor isa Color
{
red,
green,
blue
}
This is consistent with subtype as subset.
Every value of type PrimaryColor is a value of type Color.
Every enumerated type has an integer storage type that determines its representation in memory
as well as its serialisation format. The default storage type is Int32.
An enum declaration with no enum supertypes may specify a different fixed-width integer storage type. For example:
enum HttpStatus : Uint16
{
ok = 200,
notFound = 404,
unavailable = 503
}
For every valid instantiation, every representation value, whether explicit or implicit, must be representable by the enum's storage type. This requirement also applies to values retained for dropped attributes and to their replacement values. The storage type is part of the enum's representation contract and cannot be changed as part of 117 Schema evolution.
An enum declaration with one or more enum supertypes cannot specify its own storage type. It inherits the storage type from its supertypes, and all of its enum supertypes must have the same storage type. Consequently, a subtype value has the same physical representation as the corresponding value of each supertype.
The following is an example of a variant type:
variant Shape
{
Circle circle;
Triangle triangle;
Rectangle rectangle;
}
This represents a sum type. Although circle, triangle, and
rectangle can be regarded as alternatives, they are called attributes just as
for value and entity types, and they likewise are unary predicates having extensions.
The attributes do not exist conditionally: all three attributes exist for every Shape and always
have extensions. The variant constraint makes exactly one of those extensions non-empty.
Indeed, we could annotate the attributes with their natural language predicates using metadata:
variant Shape
{
@predicate("The shape is a circle value equal to [circle]")
Circle circle;
@predicate("The shape is a triangle value equal to [triangle]")
Triangle triangle;
@predicate("The shape is a rectangle value equal to [rectangle]")
Rectangle rectangle;
}
In this case the attribute types are nominal types. The attribute types may also be structural types, and a variant may mix these forms. For example:
variant Shape
{
{ Real radius; } sphere;
{ Real side; } cube;
{ Real radius; Real height; } cone;
→ Real volume = sphere.(4.0/3.0*pi*radius^3)
| cube.(side^3)
| cone.(pi*radius^2*height/3);
}
A variant type is closed. In any loaded schema version, the set of current base attributes is fixed by the declaration. To model an open variant, declare an abstract value or entity type and allow concrete types to subtype it.
Variant types do not have a value/entity distinction. A variant cannot declare a key, but it may
inherit one from an abstract entity supertype. A variant may declare abstract value types and
abstract entity types as supertypes using isa. The need to implement the inherited attributes
and key of an abstract entity is likely to limit the practical usefulness of abstract entity
supertypes for variants.
An inherited abstract attribute does not independently become another variant alternative. A directly declared base attribute may implement that inherited requirement; alternatively, an inherited requirement may be implemented by a derived attribute.
Variant types may declare derived attributes. The alternative operator | is often useful for
combining the results produced by different base attributes into one derived result, as in the
definition of volume above.
To support 117 Schema evolution in databases and serialisation formats, previously existing base attributes can be dropped, allowing for deserialising older versions. When we speak of the base attributes of a variant type we do not include the attributes that have been dropped.
Before applying declared and inherited constraints, the carrier induced by the variant representation is the labelled disjoint union of the carriers of its base attributes.
variant Shape
{
Circle circle;
Rectangle rectangle;
}
has carrier:
carrier(Shape) =
({circle} × carrier(Circle))
∪ ({rectangle} × carrier(Rectangle))
More generally, if:
W = variant { T1 A1; ...; Tn An; }
then:
carrier(W) =
⋃i=1n ({Ai} × carrier(Ti))
The attribute labels keep the summands distinct even when two attributes have the same underlying carrier.
A variant type induces a mutual exclusivity constraint across its base attributes. For any instance, exactly one of those base attributes has a non-empty extension and every other base attribute has an empty extension. Derived attributes do not participate in this constraint.
This applies equally to attributes with the empty structural type {}.
Additional constraints may be specified either with where on an attribute
or in a constraints block.
The effective constraint on a variant type is the conjunction of its implicit mutual-exclusivity
constraint, all inherited constraints, and all directly declared constraints. These constraints
may mention multiple base attributes, including attributes that implement inherited abstract
attribute requirements.
Consider the following example:
abstract value Solid
{
{ Real radius; } sphere?;
{ Real side; } cube?;
constraints
{
// ∀S:{ Real radius; } ∀R:Real
// (this.sphere(S) ∧ S.radius(R)) → R > 0
sphere.(radius > 0);
// ∀C:{ Real side; } ∀L:Real
// (this.cube(C) ∧ C.side(L)) → L > 0
cube.(side > 0);
}
}
variant BasicSolid isa Solid
{
{ Float64 radius; } sphere where sphere.radius > 1;
{ Float64 side; } cube;
}
This deliberately contrived example illustrates several aspects of subtyping and constraints:
BasicSolid even subtypes the
product type Solid.
Solid requires attributes named sphere and cube. For each
required attribute, a concrete subtype must:
?, so each predicate may have an empty or singleton extension.
In BasicSolid, each base attribute has declared cardinality 1 when selected, but effective
cardinality ? because it is empty when another alternative is selected. It can therefore
implement the corresponding abstract requirement without widening its cardinality. The implicit
variant constraint additionally requires exactly one base attribute to be selected.
BasicSolid implements the payload types covariantly: { Float64 radius; } and
{ Float64 side; } are subtypes of the corresponding structural types containing Real.
BasicSolid inherits both constraints declared by Solid. For each value, the constraint
on the selected alternative must be satisfied; the constraint on the absent alternative is
vacuously satisfied because its antecedent is false.
where clause on BasicSolid.sphere is an additional, directly declared constraint.
It strengthens the inherited requirement from radius > 0 to radius > 1.
A variant attribute which carries no application data may be written as a bare attribute name. For example:
variant PaymentResult
{
completed;
cancelled;
String declined;
{ String provider; String reference; } pending;
}
The declarations completed; and cancelled; are syntactic sugar for attributes whose payload
type is the empty
structural type {}. The declaration above is therefore equivalent to:
variant PaymentResult
{
{} completed;
{} cancelled;
String declined;
{ String provider; String reference; } pending;
}
The bare name is the attribute name and must be an identifier rather than a reserved
keyword. Although the source form omits a type, the attribute is not untyped: its effective
payload type is {}. When completed is selected its unary relation contains the single empty
structural value; otherwise that relation is empty. It can therefore be navigated and tested like
any other variant attribute.
In carrier-set terms, the payload-free summand contributes
{completed} × carrier({}) to carrier(PaymentResult). Its attribute label distinguishes it from
the other summands even though its payload carries no fields.
Cardinality constraints apply within each variant base attribute in the usual way. The inactive current base attributes have empty extension, and the selected base attribute is the one with non-empty extension. Therefore a variant base attribute's declared cardinality must have lower bound at least one. For example:
variant Shape
{
Circle circle;
Point point+;
}
allows a shape to be either a single circle or a non-empty point set.
This implicitly means:
circle has declared cardinality one when selected
point has declared cardinality one or more when selected
A variant base attribute cannot be declared with cardinality ? or *, because those constraints
allow an empty selected case. An empty extent already means the attribute is inactive.
To support an empty point set as a legitimate selected case, use a dedicated point set type and make the variant attribute a single value of that type:
variant Shape
{
Circle circle;
PointSet pointSet;
}
Algebraic Data Types are supported. For example:
@domain("A finite binary tree with integers at a subset of the leaf nodes")
variant BinaryTree
{
@predicate("The binary tree is an empty node")
{} empty;
@predicate("The binary tree is a leaf node holding the integer value [leaf]")
Integer leaf;
@predicate("The binary tree is a binary node equal to [node]")
{ BinaryTree left; BinaryTree right; } node;
}
Note:
{} when they have no payload.
These are ordinary unary attributes of type {}, similarly to enum attributes.
node has a unary predicate and holds a unary relation.
node is the structural type { BinaryTree left; BinaryTree right; }.
Definition (Quotient Type).
A construct of the form quotient T {...} defines a type named T whose values are equivalence classes
over a representation defined by a set of attributes of given types.
Two representatives x and y denote the same value iff the predicate in the equivalence
block holds for them.
quotient Rational
{
// Base attributes for the representation
Integer numerator;
Integer denominator where denominator > 0;
// Derived attribute
→ Boolean zero = numerator = 0;
equivalence
{
x ~ y where x.numerator * y.denominator = y.numerator * x.denominator;
}
}
The ordinary attributes of a quotient type are representation fields. where clauses and
constraint blocks restrict the allowed representatives. Derived attributes may be defined from
the representation fields and are available through values of the quotient type.
The carrier of a quotient type is the set of equivalence classes of its allowed representatives.
Let S be the structural body obtained from the quotient type's representation
fields, and let K be the conjunction of its representative constraints,
including where clauses on representation fields and constraints written in a
constraints block. The allowed representatives are:
Rep(T) = { r ∈ carrier(S) | K(r) }
If ~T is the equivalence relation declared by the equivalence block, then:
carrier(T) = Rep(T) / ~T
For the Rational example above, the representatives are pairs of integers
(numerator, denominator) with denominator > 0, and two representatives are
in the same carrier element exactly when cross-multiplication gives the same
rational value.
Derived attributes of a quotient type may also be representation-dependent. For example,
zero above is well-defined for rational values, but a derived attribute such as
→ hasNumeratorOne = numerator = 1 would observe the chosen representative unless the quotient
also guarantees a canonical representation. The base language allows both forms.
For a quotient type T, write Rep(T) for the type of its representation. For Rational,
Rep(Rational) has attributes numerator and denominator, subject to the declared
representative constraints such as denominator > 0.
The equivalence block defines an equivalence relation ~T on Rep(T). A value of type T
is an equivalence class of representatives under ~T. Equality of quotient values is equality
of those equivalence classes, not structural equality of the representative attributes.
Representation fields are ordinary attributes for navigation. They may be used in path expressions, queries, functions, and derived attributes without a special access rule. This is useful for debugging, rendering, efficient algorithms, and definitions that are naturally written against the representation.
The equivalence predicate is checked in a context where the two distinct variables denote
representatives with the declared representation fields. The predicate must have type Boolean.
An expression over quotient values is well-defined on quotient values when replacing any representative by an equivalent representative leaves the result equal, using the equality of the result type.
For a function
func B f(A x);
where A is a quotient type, well-definedness means:
x ~ y ⇒ f(x) = f(y)
Here ~ is the equivalence relation of A, and = is equality of the result type B.
If B is a quotient type, this result equality is its quotient equality. The same condition
extends to functions with more parameters by replacing each quotient-typed argument by an
equivalent representative.
Using representation fields can make an expression depend on the chosen representative. This is
not rejected by the base language: a query might deliberately ask for the stored numerator and
denominator of a rational value. However, an operation intended to be a property of the quotient
value should be well-defined on equivalence classes. In other words, if x ~ x' and y ~ y',
then such an operation should return the same result for (x, y) and (x', y'). Tools may warn
about representation-dependent definitions, or treat such warnings as errors in applications that
require a stricter abstraction boundary.
func Boolean ≤(Rational x, Rational y) =
x.numerator * y.denominator ≤ y.numerator * x.denominator;
For the Rational declaration above this comparison is well-defined because every allowed
representative has a positive denominator. Replacing either argument with an equivalent
representative preserves the truth of the cross-multiplication comparison. If negative
denominators were allowed as arbitrary representatives, this particular expression would not be
well-defined without first normalising the sign or otherwise accounting for it.
The block form is deliberately heavier than a single inline equation. A quotient type has one defining equivalence relation, but that relation may need associated representation policy.
TODO: the following canonical-representative clause is an experimental idea. There is a good chance it will not be supported by the base language. This example is non-normative.
equivalence
{
x ~ y where x.numerator * y.denominator = y.numerator * x.denominator;
canonical x:
gcd(abs(x.numerator), x.denominator) = 1;
}
If supported, a canonical clause would not define equality. It would select a preferred representative for each equivalence class when such a representative is available and unique. This could guide storage, rendering, indexing, and deterministic comparison, while the equivalence predicate remains the source of the quotient's equality.
Under the formalism all attributes are unary predicates that record their extensions (where an extension is a set of values).
Therefore, in the formalism all attributes hold a set of values. This is regardless of whether an attribute is constrained to hold exactly one value. That is a constraint on the set; it does not mean a set is not present under the formalism.
This includes a boolean-valued attribute. A Boolean attribute is still a unary
predicate whose values have type Boolean; when constrained to exactly one value,
its extension contains exactly one Boolean value.
Consider the following definition of type Supplier:
entity Supplier
{
Integer id;
@predicate("The supplier is located in [city]")
City city*;
}
The asterisk on attribute city means a supplier may have zero or more cities.
Accordingly, the extension of its predicate may contain zero or more values of type City.
The asterisk qualifies the attribute, not its type.
While a supplier may be located in several cities, each city is identified by
one value of type City.
The syntax should not be interpreted as: cities : City*. That would
correspond to the predicate:
The supplier is located in [cities].
which would always have exactly one tuple making it true, regardless of how many cities the supplier is located in - including none at all.
This interpretation is antithetical to how Codd envisaged the RM to be used. It breaks 1NF which requires attributes to be atomic, in other words not embedding collections inside attributes.
Proper use of Predica for constructing an information system grounded in logic is to
emphasise singular attribute names and avoid attribute types that give rise
to set-valued attributes.
This gives a more helpful meaning to the term atomic. It has nothing to do
with internal complexity of type City and everything to do with attribute city
being singular.
Predica has a dedicated mechanism for dealing with multiplicity: a cardinality constraint on the extension of the attribute.
It is important to understand that this mechanism handles multiplicity through constraints on attribute extensions, not through plural attribute names or by introducing an army of additional attribute types for “types of sets”.
Consider a value or entity type S containing attributes a, b, c, d, e and f as follows:
value S
{
T a;
T b?;
T c*;
T d+;
T e[2];
T f[4..10];
}
Every attribute of S denotes a function
\[
x : S \to \mathcal{P}(T),
\]
where the multiplicity annotation constrains the cardinality of each set \(x(s)\) for \(s \in S\).
The different annotations correspond to the following constraints:
a : T denotes a function \(a : S \to \mathcal{P}(T)\) such that \(|a(s)| = 1\) for all \(s \in S\).
This can be identified with a total function \(a : S \to T\).
b? : T denotes a function \(b : S \to \mathcal{P}(T)\) such that \(|b(s)| \in \{0,1\}\) for all \(s \in S\).
This can be identified with a partial function \(b : S \rightharpoonup T\).
c* : T denotes a function \(c : S \to \mathcal{P}(T)\) with no lower bound on \(|c(s)|\); the set may be empty.
d+ : T denotes a function \(d : S \to \mathcal{P}(T)\) such that \(|d(s)| \ge 1\) for all \(s \in S\);
that is, each instance of \(S\) is associated with at least one value of type \(T\).
e[2] : T denotes a function \(e : S \to \mathcal{P}(T)\) such that \(|e(s)| = 2\) for all \(s \in S\).
f[4..10] : T denotes a function \(f : S \to \mathcal{P}(T)\) such that \(4 \le |f(s)| \le 10\) for all \(s \in S\).
In all cases, the attribute name remains singular because it denotes a single function (or unary predicate) whose values are constrained only by the specified multiplicity.
A base attribute may be declared const. Its extension is established when an
instance is constructed and remains unchanged for the lifetime of that instance.
Different instances may have different extensions for the attribute.
entity Document
{
const Integer id;
String title;
}
var document1 = Document { id = 1, title = "First" };
var document2 = Document { id = 2, title = "Second" };
A const attribute cannot be assigned a value in its declaration:
entity Document
{
// Error: cannot give const attribute a value in its declaration
const Integer id = 1;
}
Its extension depends on the instance being constructed, so it must instead be supplied when that instance is initialised. Every constructor must initialise each required const attribute exactly once. After construction, an update whose target is that attribute is invalid.
Const attributes are therefore dynamic rather than static. Their values are determined at run time as instances are created, and may vary from one instance to another. What is constant is not a value at the level of the type, but the result of the attribute function for a particular instance after its construction. A constant definition denotes one fixed value independently of any receiver; a const attribute denotes instance-dependent information that becomes immutable once the receiver exists.
Derived attributes likewise cannot be the targets of update statements, but they are
not declared const. The extension of a derived attribute is determined by its defining
expression and may therefore vary over time as its inputs change, even though it cannot itself
be updated directly. This is a semantic distinction, not a storage
requirement: a compiler may materialise a derived attribute as a cache, provided the cached extension
is kept consistent with its defining expression. A const base attribute, by contrast, has an
extension established during construction and preserved thereafter. Thus not an update
target does not by itself imply immutable.
Within the type in which it is declared, a const attribute is available as though it were a static constant. It may therefore be used as a static value argument in the type of a subsequent attribute. The value is scoped to the instance: it is fixed during construction, but different instances may supply different values.
value Image<Type T>
{
const Uint32 width;
const Uint32 height;
Array<T, width, height> pixels;
}
For a particular Image<T>, width and height determine the two static extents
of pixels. For example, when an image is constructed with width 640 and height
480, its pixels attribute has type Array<T,640,480>. The constructor must
supply an array with those extents.
Because width and height are const, the dimensions cannot subsequently change and
invalidate the type of pixels. They behave as static constants while type-checking
the members of that image, even though their values are selected dynamically when the
image is constructed. This is instance-scoped static information, not one pair of
dimensions shared by every Image<T>.
An attribute may take parameters:
value ScalarField
{
Real val(Real x, Real y);
}
Although the syntax resembles a method in an object-oriented language, it does not
declare a method or function. val is a base parameterised
attribute. For each ScalarField instance and each pair of coordinates, it
introduces a base unary predicate whose extension forms part of the information held
by that instance.
Given s:ScalarField, x:Real and y:Real, the expression
s.val(x,y) denotes a unary predicate belonging to the domain s, whose extension
contains values of type Real. A parameterised attribute is therefore a family of
unary predicates indexed by its parameter values.
Cardinality constraints apply to parameterised attributes just as they apply to regular attributes:
entity Supplier
{
City city(Country country)*;
}
For each supplier and each country, the extension of city(country) may contain zero
or more cities. More generally, for each instance of the containing type and each
combination of parameter values, the resulting predicate extension must satisfy the
attribute's cardinality constraint.
With no cardinality qualifier, the extension has cardinality exactly one. Thus the
ScalarField declaration requires:
∀x:Real ∀y:Real |{ V:Real | s.val(x,y)(V) }| = 1
An enum type may be used as a parameter type:
enum Material
{
wood,
plastic,
metal
}
entity Object
{
Real mass(Material material);
}
Because Material is finite, mass(material) is extensionally like declaring
separate attributes massOfWood, massOfPlastic, and massOfMetal. The
parameterised form states the uniform structure without repeating the declaration.
A derived attribute may take parameters. Its parameters are in scope in its defining expression:
value TemperatureGrid
{
Real celsius(Integer x, Integer y);
→ Real fahrenheit(Integer x, Integer y) =
celsius(x, y) * 1.8 + 32;
}
For each temperature grid and pair of coordinates, the extension of fahrenheit(x,y)
is determined by the extension of celsius(x,y). A derived parameterised attribute
cannot be the target of an update statement.
There is no parsing ambiguity between tuple construction and parameterised attribute application in a path. Tuple construction is introduced by a dot followed immediately by parentheses, whereas a parameterised attribute has its name before the argument list:
s.supplier.(name, city) // tuple construction
a.f(x, y) // parameterised attribute application
Given a path object that reaches Object values, supplying a parameter selects the
corresponding predicate:
// Objects whose mass of wood is greater than 10.
object.[mass(Material.wood) > 10]
A query may instead quantify over the parameter:
// Objects having more than 10 units of some material.
object.[∃ material:Material (mass(material) > 10)]
Applying arguments to a base parameterised attribute selects an updateable predicate location:
object.mass(Material.wood) := 12;
The update changes only the extension selected by that object and parameter value, subject to the ordinary cardinality and constraint rules.
Exactly-one cardinality over a large or infinite parameter domain is a strong
requirement. The ScalarField declaration requires a result for every pair of real
numbers. Similarly:
value SampledImage<Type T>
{
T val(Integer x, Integer y);
}
requires a result for every pair of integers. When only a bounded or sparse set of indices is intended, the declaration should use suitably restricted parameter types or an appropriate non-total cardinality. The requirement is semantic and does not prescribe dense storage, sparse storage, a procedural representation, or any other implementation technique.
An embedding can define a parameterised base attribute pointwise. A constant image
can therefore subtype the concrete type SampledImage<Real> while storing only one
sample:
value ConstImage
{
Real sample;
}
embed ConstImage image isa SampledImage<Real> =
SampledImage<Real> {
val(x, y) = image.sample
};
In the proposed constructor syntax, the parameter names in val(x,y) are bound over
their declared parameter types. The initializer defines the complete indexed family
of predicate extensions without enumerating the integer coordinate pairs.
Through the embedding, val is inherited by ConstImage; it does not need to be
declared again. The embedding keeps the compact representation of ConstImage
explicit while making it a subtype of the concrete type SampledImage<Real>.
A derived attribute is an attribute whose extension is defined by an expression. It is a normal attribute for navigation and denotation; the difference is that its extension is calculated rather than supplied as stored state.
Every nominal type constructor supports derived attributes: value types, entity types, enum types, variant types, and quotient types. The rule is uniform because attributes are unary predicates on the values of the nominal type, and a derived attribute is just a unary predicate defined by an expression.
A derived attribute declaration begins with →. The marker distinguishes it from a base attribute
independently of whether the declaration includes an explicit type. Canonical formatting places one
space after the arrow.
Value and entity types use the same member syntax for derived attributes:
value Circle
{
Real radius where radius ≥ 0;
→ Real diameter = 2 * radius;
}
entity Person
{
String email*;
→ Boolean hasEmail = email.Σexists;
}
The derived attributes diameter and hasEmail are available through ordinary path navigation.
They do not introduce additional stored fields.
Enum types also support derived attributes. Enum base attributes have type {}, so a derived
attribute in an enum is evaluated against the selected enum value in the same way as any other
derived attribute.
enum TrafficLight
{
red,
amber,
green,
→ Boolean stop = red ∨ amber;
}
For a value t of type TrafficLight, t.stop is true for red and amber, and false for
green.
Derived attributes on variants are ordinary members of the variant type. They may test which variant attribute is active, inspect a payload, or expose a value calculated from the active case.
variant Payment
{
Cash cash;
Card card;
→ Boolean electronic = card.Σexists;
}
Quotient types may define derived attributes from their representation fields. A derived attribute intended to describe the quotient value should be well-defined on equivalence classes.
quotient Rational
{
Integer numerator;
Integer denominator where denominator > 0;
→ Boolean zero = numerator = 0;
equivalence
{
x ~ y where x.numerator * y.denominator = y.numerator * x.denominator;
}
}
The attribute zero is well-defined for rational values because replacing a representative by an
equivalent representative preserves whether the represented rational number is zero.
Suppose we have value types for Point and Circle:
value Point
{
Real x;
Real y;
}
value Circle
{
Point centre;
Real radius;
→ Real area = 3.14159 * radius * radius;
}
A wheel can be modelled as having the geometric properties of a circle, plus additional properties such as mass:
entity Wheel
{
embed Circle;
Real mass;
}
The declaration embed Circle; embeds a Circle value inside each Wheel. It also exposes the
members of Circle through Wheel, so a wheel has centre, radius, area, and mass
members.
This is an unnamed embedding. The embedded value is stored as part of the enclosing value, entity,
or quotient representation rather than referenced by identity. It is a member-declaration mechanism, not a subtype
declaration. Embedding Circle in Wheel does not make Wheel a subtype of Circle, and it
does not introduce implicit conversions between the two types. Top-level subtype embeddings use a
different declaration form, described in the subtyping chapters.
Forwarded members include both ordinary attributes and derived attributes. In the example above,
centre and radius are ordinary members of Circle, and area is a derived member of Circle;
all three are forwarded through Wheel. When a derived attribute is forwarded, its definition is
composed through the embedded value.
An embedding may also introduce a name for the embedded value:
entity Wheel
{
embed Circle as circle;
Real mass;
}
This introduces the named member circle : Circle and also forwards the members of Circle, so
circle, centre, radius, area, and mass are all available on Wheel.
For the named embedding above, the forwarded area is equivalent to circle.area.
If forwarded member names collide, unqualified lookup of those names is a static error. Named embeddings allow the model to keep the embedded values distinct and access their members through the embedding names.
{} on the enclosing type.
Embedding is supported in quotient type declarations. In that context, an embedding contributes representation attributes to the quotient in the same way as ordinary representation fields. This is useful when a quotient representation naturally reuses an existing value-like structure.
For example, the numerator and denominator representation can be factored into a reusable value
type and embedded in Rational:
value RationalRepresentation
{
Integer numerator;
Integer denominator;
}
quotient Rational
{
embed RationalRepresentation;
equivalence
{
x ~ y where
x.numerator * y.denominator = y.numerator * x.denominator;
}
constraints
{
denominator > 0;
}
}
The unnamed embedding forwards numerator and denominator, so they are representation attributes
of Rational. They are available to the equivalence predicate, representative constraints,
derived attributes, and ordinary navigation exactly as directly declared representation fields
would be. The embedding does not make Rational a subtype of RationalRepresentation.
A named embedding may also be used when the embedded representation should remain addressable as a whole. Its forwarded members still participate in the quotient representation, subject to the ordinary collision and construction rules for embedding.
Enumerated types are formalised as variants whose attributes have type {}, the empty structural
type described in 47 Structural types. Embedding an enum therefore forwards those attributes
with the same type on the enclosing type:
enum Gender
{
male,
female
}
entity Person
{
String name;
embed Gender;
}
The embedded enum exposes male and female on Person. Since these paths have result type {},
they may be used directly in Boolean contexts, where they elaborate to existence tests. For example,
within the receiver context of a Person, male elaborates to male.Σexists.
The enum's variant constraint ensures that exactly one of its effective current base attributes holds for each person. Derived and dropped attributes do not participate in this constraint.
Predica declarations may be parameterised by type parameters and by static value parameters. The same angle-bracket syntax is used at declaration sites and application sites:
value Point<Type T, Integer N>
{
T coordinate[N];
}
value Holder
{
Point<Real, 3> position;
}
A parameterised declaration introduces a generic declaration shell. A concrete declaration is
created when an application is needed, such as Point<Real, 3>. Repeated uses of the same
canonical application denote the same concrete declaration. For type declarations this means the
same concrete type; for constants and functions the concrete declaration may be an internal ordinary
constant or function used to encode the application.
The declaration spelling follows ordinary Predica declarations: Type T for a type parameter and
Integer N for a static value parameter. The parameter kind or value type is written first, and
the parameter name is written second. Untyped parameters, such as Point<T, N>, and name-first
parameters, such as T: Type, are invalid in declarations.
Parameters may carry metadata and trailing defaults. Constraints are written in a generic
where clause over the already-declared parameters:
value Vector<@role("coordinate") Type T = Float64,
@count Integer N = 3>
where T isa Real, N ∈ 1..4
{
T coordinate[N];
}
value Array2d<Type T, Integer N, Integer M>
where T isa Real, N > 0, M > 1, N + M < 10, isOdd(N)
{
}
Type T accepts a type argument.
Integer N accepts a statically evaluable integer value argument.
where T isa Real requires the type argument to be assignable to Real.
where N ∈ 1..4 requires the integer value to be in the inclusive range 1..4.
where predicates may mention more than one parameter, such as N + M < 10.
Boolean, String, the integer-family built-ins,
floating-point built-ins such as Float32 and Float64, and enum types.
Axis A.
An enum type may be used as the value type of a static value parameter. The selected enum attribute is part of the generic application's canonical identity:
enum Axis
{
x,
y,
z
}
value AxisTag<Axis A>
{
Integer id;
}
A generic application supplies type and value arguments in source order:
Point<Real, 3>
Vector<Integer>
Vector<Float64, 2+1>
AxisTag<Axis.x>
AxisTag<x>
Defaults are expanded before canonical identity is computed. Typed static value arguments must
produce a canonical static value compatible with the declared parameter type. Boolean, string,
integer, and floating-point arguments are statically evaluated to literal values. Integer evaluation
is exact and conservative: literals and previously-bound integer parameters may be combined with
unary +, unary -, +, -, *, exact /, and non-negative ^; non-integral division,
division by zero, and overflow are invalid. Therefore Vector<Float64, 2+1> and
Vector<Float64, 3> denote the same concrete type and may be rendered in canonical form as
Vector<Float64, 3>.
Enum-valued arguments must name an attribute of the declared enum type. The qualified spelling,
such as Axis.x, is always valid. In the expected-type context of a generic application, an
unqualified attribute such as x in AxisTag<x> is also valid when it names an attribute of
the expected enum type. It canonicalises to the qualified enum attribute, so AxisTag<x> and
AxisTag<Axis.x> denote the same concrete type.
Bare use of a generic type is invalid. If Box is declared as value Box<Type T>, write
Box<Integer> rather than Box.
Value, variant, entity, and enum declarations may be parameterised:
value Box<Type T>
{
T value;
}
variant Maybe<Type T>
{
T some;
absent;
}
entity Node<Type T>
{
Integer id;
T value;
key { id };
}
enum Offset<Integer N>
{
first = N,
second,
last = N + 10
}
An enum's representation-value expressions may depend on its generic parameters. They are evaluated separately for each instantiation, and the resulting values must be distinct and representable by the enum's storage type. Generic constraints may restrict the valid instantiations.
Constants and functions may also be parameterised:
const plus<Integer N = 2> = N + 1;
func T identity<Type T>(T value) = value;
extern "C++:math"
{
T clamp<Type T, Integer N = 3>(T value)
where T isa Real, N ∈ 1..4;
}
Generic constants require an explicit angle application, even when all parameters have defaults:
write plus<> rather than bare plus. Generic functions may be called with explicit generic
arguments:
identity<Integer>(1)
plus<2+1>
Function generic arguments may also be inferred when they are unambiguous from literal ordinary
arguments or trailing defaults. For example, identity(1) may infer identity<Int64>(1).
Inference is intentionally conservative: when a required parameter cannot be inferred, the call must
write explicit generic arguments.
Ordinary and generic functions may share a source name and participate in the same overload set. Each generic candidate is instantiated provisionally using explicit arguments, inference, and defaults before its viability and specificity are compared with the other candidates. The Function Overload Resolution chapter defines this process.
Generic instantiation substitutes canonical argument bindings into the declaration body and then uses the ordinary schema rules for attributes, keys, constraints, derived attributes, embeds, function bodies, and schema evolution. This keeps generic behaviour aligned with non-generic declarations and gives the same diagnostics after substitution wherever possible.
An embed declaration whose type mentions a generic parameter must instantiate to a named type. For
example embed T as item; is valid when T is supplied by a named value or enum type, but not when
the application supplies an anonymous structural type.
Recursive generic type and function references may cross an explicit schema reference boundary, such as an attribute type or function call. Generic constants do not have such a boundary and recursive generic constant instantiations are invalid.
Annotations for generic declarations should preserve the declaration parameters rather than choosing
an incidental concrete instantiation. Debug tooling may additionally show concrete instantiated
denotations, but the product-facing view should keep formulas such as T value parameterised.
Generic applications may refer to declarations imported from another component, either by selected import or by component alias:
using acme.Library { Box, identity };
using acme.Library as lib;
value Holder
{
Box<Integer> localName;
lib.Box<Real> qualifiedName;
Integer copied = identity<Integer>(1);
}
Concrete instantiations belong to the exported generic declaration, while renderers may qualify the application name as needed to keep the reference stable outside the exporting component.
Some Predica operations require a stable comparison on values, such as ordered comparison
operators, ordered indexes, deterministic rendering, and query operations such as orderBy.
A type is ordered when the language defines a standard order relation ≤ on
its values. This relation is the type's canonical comparison; it is total and
non-exceptional.
Ordering is a semantic property of a type. It does not change the type's carrier set, equality relation, subtype relationships, or identity rules. It only says how two values of the same type are compared when an ordered comparison is required.
For an ordered type T, the standard order relation ≤ is a binary relation on values of
type T. For all x, y, and z of type T, it must satisfy:
x ≤ x.
x ≤ y ∧ y ≤ x ⇒ x = y.
x ≤ y ∧ y ≤ z ⇒ x ≤ z.
x ≤ y ∨ y ≤ x.
The relation is also non-exceptional: for any two values of the same ordered type, x ≤ y
is defined and evaluates to a Boolean value.
The standard order operator ≤ is the primitive comparison associated with an ordered type.
The other ordered comparison operators are derived from ≤ and equality:
x < y iff x ≤ y ∧ x ≠ y.
x ≥ y iff y ≤ x.
x > y iff y ≤ x ∧ x ≠ y.
These derived operators do not add separate ordering semantics. They are available exactly
when the standard order relation ≤ is available for the compared values.
When a declaration kind has an implicit default order, the type is ordered by default. The
unordered modifier suppresses that default order and is written just before the nominal type
name. It is valid only when the declaration would otherwise have an implicit default order.
enum Month
{
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec
}
value Time
{
Integer hour where hour ∈ [0..23];
Integer minute where minute ∈ [0..59];
Integer second where second ∈ [0..59];
}
value unordered DebugTime
{
Integer hour where hour ∈ [0..23];
Integer minute where minute ∈ [0..59];
Integer second where second ∈ [0..59];
}
Predica may define an implicit order when the declaration contains enough ordered structure to compare values without adding policy. The default orders are:
≤ relation is the order with false ≤ true.
≤ relation is the standard mathematical order.
≤ relation is lexicographic order over Unicode scalar values. It is not
locale-sensitive collation and does not depend on the string's physical encoding.
Int8, Int16, ..., Float32, Float64,
and Integer inherit an order from Real.
≤
relation satisfying the order axioms on the quotient type.
If no implicit default order is available and no explicit order is supplied, the type is not
ordered. The unordered modifier cannot be combined with an explicit order. Sets remain
mathematical sets; use explicit ordering operations when an ordered list is required.
An order can be defined explicitly by implementing function Boolean ≤(T x, T y).
Value and entity types can also define a custom order with an order declaration. The order
keys are expressions evaluated in the scope of the value being compared. Keys are compared
lexicographically from left to right.
value Date
{
Integer year;
Month month;
Integer day;
order [year↓, month, day];
}
Order keys can be expressions, not only attribute names:
value Point
{
Real x;
Real y;
order [x*x + y*y, x, y];
}
The direction marker is written after the key expression: ↑ means ascending and ↓ means
descending. If no direction marker is written, ascending order is assumed. The postfix marker
keeps the key expression and the ordering direction separate; for example, year ↓ says to
compare the year key in descending order, not to compare the arithmetic expression -year.
Every order key must be single-valued and ordered. The declared keys must induce a total order
compatible with equality: if all order keys compare equal, the two values must be equal values
of the type. A partial sort key is appropriate for query-level orderBy, but not for the
canonical order of an ordered type.
An order can be defined explicitly on an enum by implementing ≤:
enum Color
{
red,
green,
blue
}
// Explicit order: blue < green < red.
func Boolean ≤(Color x, Color y) =
if x = y then
true
else if x = blue then
true
else if y = blue then
false
else if x = green then
true
else
false;
A quotient type has no implicit default order, but it can still define ≤ using an expression
whose truth is invariant under the quotient equivalence. For example, rational numbers can be
represented by integer numerator-denominator pairs with positive denominators:
quotient Rational
{
Integer numerator;
Integer denominator where denominator > 0;
equivalence
{
x ~ y where x.numerator * y.denominator = y.numerator * x.denominator;
}
}
The quotient can be embedded in Real by interpreting each rational value as its mathematical
real value:
embed Rational r isa Real =
r.numerator / r.denominator;
This embedding is well-defined on equivalence classes. Because Real is ordered, Rational
inherits Real's standard ≤ relation through the embedding. In terms of the numerator and
denominator representation, the inherited order is equivalent to cross multiplication:
func Boolean ≤(Rational x, Rational y) =
x.numerator * y.denominator ≤ y.numerator * x.denominator;
Because denominators are positive, the cross-multiplication form is invariant under the
equivalence relation: choosing different representatives of the same rational values does not
change the truth value of x ≤ y. It therefore describes a relation on rational values, not
merely on representation tuples, and that relation satisfies the order axioms.
A constraint is part of a nominal type. It is an axiom that restricts the values belonging to that type. The transaction-boundary application and enforcement rules are specified in Constraint-Violating Updates.
Constraints are written in a constraints block. Stored attribute declarations also
provide a where shorthand, described below.
Every constraint expression must have type Boolean.
A constraints block may be used in value type, entity type, variant type, and quotient type
declarations. It may not be used in an enum declaration. An enum defines a fixed set of attributes;
restricting that set should instead be expressed by declaring the intended enum or an appropriate
enum subtype.
where clauses
A where clause following a stored typed-attribute declaration is syntactic sugar for adding its
expression to the constraints block of the enclosing nominal type. For example:
value Point
{
Real x where x > 0;
Real y;
constraints
{
x < y;
}
}
is equivalent to:
value Point
{
Real x;
Real y;
constraints
{
x > 0;
x < y;
}
}
The where expression is evaluated in the same receiver context as an expression written directly
in the type's constraints block. It may therefore refer to other attributes of the enclosing
type; its placement after one attribute does not give it a narrower scope or make it a property of
that attribute's payload type.
Several constraints may follow one attribute by separating their expressions with commas. Each expression becomes a separate constraint on the enclosing type:
Real percentage where percentage ≥ 0, percentage ≤ 100;
The where shorthand is not permitted on a derived attribute or an embed declaration. A
constraint involving either must be written explicitly in the enclosing constraints block. This
keeps where narrowly associated with the validity of stored attribute values and avoids making a
derived definition look as though it carries a local result postcondition.
A constraint may refer to derived attributes as well as stored attributes. The derived expression is evaluated according to its definition, and the resulting value participates in the constraint like any other attribute value. For example:
value Rectangle
{
Real width where width ≥ 0;
Real height where height ≥ 0;
→ Real area = width * height;
constraints
{
area ≤ 100;
}
}
Here area is derived rather than stored, but it may still be named by the constraint. The
constraint restricts Rectangle values to those whose derived area is at most 100. This does not
cause area to be stored or independently assignable. Such a condition must be placed in the
explicit constraints block; it cannot be attached to the derived declaration with where.
Constraints refine carrier sets. If a type body has unconstrained structural
carrier carrier(S) and constraints K, then the constrained carrier is:
carrier(S with K) =
{
v ∈ carrier(S)
|
K(v)
}
Constraint expressions are interpreted over attribute extensions. For example:
value P
{
Real x*;
Real y*;
constraints
{
x < y;
}
}
has values of the form:
((x, X), (y, Y))
where X ⊆ Real, Y ⊆ Real, and:
∀x ∈ X. ∀y ∈ Y. x < y
Geometrically, the derived locus of such a value is X × Y in the
(x,y) plane, and the constraint requires the whole product locus to
lie in the open half-plane x < y. Arbitrary subsets of that
half-plane, such as curves or disks, are not values of this type merely because
their points satisfy x < y. Correlated point sets are represented by
repeating a structured point value, for example:
value Point
{
Real x;
Real y;
constraints
{
x < y;
}
}
value Q
{
Point p*;
}
In Q, the extension of p is a set of Point values. Each repeated
Point carries its own correlated x and y extensions.
Experimental: a constraint-level disjoint declaration is a possible shorthand for stating that several attribute paths have pairwise disjoint extensions. No decision has yet been made about whether this special syntax will remain part of Predica, and implementations are not currently required to support it.
For example, an assignment could require that no person occurs as both a supplier and a customer:
entity Assignment
{
Person supplier*;
Person customer*;
constraints
{
disjoint { supplier, customer };
}
}
This declaration can be regarded as syntactic sugar for an ordinary empty-intersection constraint:
supplier ∩ customer = ∅;
With more than two paths, the declaration means that every pair has an empty intersection. Thus:
disjoint { supplier, customer, reviewer };
is shorthand for:
supplier ∩ customer = ∅;
supplier ∩ reviewer = ∅;
customer ∩ reviewer = ∅;
This path-level constraint is distinct from a top-level type disjointness declaration such as
disjoint Supplier, Customer;. The latter records a fact about the carrier sets of types; the
experimental constraint form compares the extensions obtained from paths in the current receiver
context. Until the special form is adopted, programs can express the same property with the
ordinary expression constraints shown above.
The Supplier-and-parts database provides an example of how inclusion dependency constraints are written in Predica:
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
Shipment shipment*;
constraints
{
shipment.supplier.Σ ⊆ supplier.Σ;
shipment.part.Σ ⊆ part.Σ;
}
}
This relies on path expression syntax for writing queries, discussed in Part VII: Query.
The .Σ fold collects path values into sets so they can be compared by inclusion.
For example, the constraint shipment.supplier.Σ ⊆ supplier.Σ says the suppliers of shipments
must be a subset of the suppliers.
This formulation works directly at the level of domain objects, independently of the choice of keys. It continues to work unchanged even when keys are composite.
Predica’s key-independent formulation of inclusion dependencies contrasts with SQL, where referential integrity constraints are expressed in terms of foreign keys.
Experimental: theorem declarations are a possible future language feature. Their syntax and semantics are not yet settled, and implementations are not required to support them. This chapter records the main design possibilities rather than specifying current behaviour.
A theorem would state a property expected to follow from a type's definitions and constraints. It would differ from a constraint: a constraint restricts which values belong to a type, whereas a theorem claims something about values which already belong to it.
For example, a type could constrain a balance to be non-negative and state the same property as a theorem for a derived availability value:
value Account
{
Real balance where balance ≥ 0;
Real reserved where reserved ≥ 0;
→ Real available = balance - reserved;
constraints
{
reserved ≤ balance;
}
theorems
{
available ≥ 0;
}
}
The constraint reserved ≤ balance excludes invalid accounts. The proposed theorem
available ≥ 0 does not further define Account; it records a consequence which should follow
from the attribute definitions and constraints.
If theorem declarations become part of Predica, every nominal type constructor should support them: value types, entity types, enum types, variant types, and quotient types. There is little benefit in making theorem eligibility depend on the kind of nominal type. A theorem is simply a claim about values of its enclosing type, interpreted in that type's ordinary receiver context.
Some type kinds will naturally have more interesting theorems than others. Value and entity types can state consequences of their attributes and constraints. Variant types can state properties shared by their attributes. Quotient types can state properties which are independent of the chosen representative. Even enums can have useful theorems, particularly when they have derived attributes:
enum TrafficLight
{
red,
amber,
green,
→ Boolean stop = this = red;
→ Boolean proceed = this = amber ∨ this = green;
theorems
{
stop ≠ proceed;
}
}
An enum with no derived properties may have little need for a theorem block, but that is not a reason to forbid one. Uniform support keeps generic tooling and the language model simple.
Several useful interpretations are possible. They have different consequences and should not be treated as interchangeable merely through compiler policy.
The compiler could require every theorem to be proved from the type definition, its constraints, and other established facts. Compilation could fail when a theorem is false or cannot be proved. Alternatively, an inconclusive proof attempt could produce a warning. This interpretation gives a theorem a precise role without changing the values belonging to the type.
value Rectangle
{
Real width where width ≥ 0;
Real height where height ≥ 0;
→ Real area = width * height;
theorems
{
area ≥ 0;
}
}
Here the theorem is a simple proof obligation derived from the two attribute constraints and the
definition of area.
A theorem could instead be checked when a value is constructed or updated, much like a class invariant. A failed check would raise an exception or report a contract violation. Checks might be enabled only in diagnostic builds, or controlled by a deployment policy.
This interpretation requires rules defining exactly when checks occur. In particular, the language would need to say whether a sequence of updates may temporarily violate a theorem, how persisted or external values are checked, and what happens when evaluating a theorem is expensive. If a runtime theorem rejects values in every build, it is difficult to distinguish from a constraint.
A compiler could use an established theorem to simplify checks or generate more efficient code.
For example, after proving available ≥ 0, it might remove a redundant test of that condition.
Only proved facts are unconditionally safe for this purpose. A debug-only runtime check does not
justify assuming the theorem in a release build.
The language could allow the programmer to assert a theorem without proof and permit the compiler
to rely on it. This is powerful but dangerous: an incorrect assumption could produce incorrect
program behaviour rather than a useful diagnostic. If Predica eventually supports trusted
assumptions, an explicit construct such as assume may communicate that risk more clearly than a
theorems block.
Theorems could also serve as machine-readable claims for documentation or for a separate verification tool, without affecting compilation or runtime behaviour. This is simple for the compiler, but offers little advantage over Boolean functions or tests unless external tools have a defined way to consume the declarations.
The possible meanings may ultimately deserve separate language constructs:
constraints define the valid values of a type;
Predica need not provide every one of these constructs. Keeping their roles distinct avoids a theorem silently changing from a proof obligation into a runtime check or an unsafe optimisation assumption when build policy changes.
A conservative design would introduce theorems first as verification-only declarations. A compiler or verifier would classify each theorem as proved, disproved, or unknown. Project policy could decide whether an unknown result is informational, a warning, or an error. Only proved theorems would be made available to optimisation. Runtime assertions could be considered separately.
Before theorem syntax becomes normative, the language specification must decide what evidence
establishes a theorem, what happens when proof is inconclusive, where runtime checks occur (if
any), and whether theorem expressions must be pure and terminating. Until those decisions are
made, theorems remains experimental future syntax.
The types form a type graph under the subtype as subset relationship.
Definition: (subtype):
Type A is a subtype of B if A ⊆ B.
Note that every type is a subtype of itself.
The predefined types include intrinsic subtype edges. For example, Integer and the fixed-width
integer types are subtypes of Real, and UnicodeScalar is a subtype of UnicodeCodePoint,
which is a subtype of Integer.
Structural subtyping is componentwise. A structural type
{ S1 a1 q1; ...; Sk ak qk; } is a subtype of
{ T1 a1 r1; ...; Tk ak rk; } when the attribute names occur in the same order,
each Si is a subtype of the corresponding Ti, and each cardinality range qi is
contained in the corresponding range ri.
For example, { Integer x; } is a subtype of { Real x; }, and { Integer x?; }
is a subtype of { Real x*; }.
Structural types whose ordered attribute-name sequences differ are necessarily disjoint.
For example, { Integer x; String y; } and { String y; Integer x; } are distinct
structural types because the attribute order differs.
The following illustrate the six possible ways of establishing a subtype relationship between two types:
// 1) Declare concrete type A to be a subtype of an abstract type B as A is declared
value A isa B { ... };
// 2) Declare existing concrete type A to be a subtype of an existing abstract type B
A isa B;
// 3) Embed existing type A in existing type B, so that A ⊆ B
embed A a isa B = expressionOfTypeB;
// 4) Create new subtype A as an intersection of existing comparable types B and C
type A = intersection { B, C };
// 5) Define a supertype A of existing types B and C
type A = union { B, C };
// 6) Name the subtype of B satisfying a predicate
type A = subtype B where p(this);
Only the nominal declarations create a fresh carrier set. subtype T where p refinements,
intersections, and unions name or transform existing carrier sets. A subtype T where p
expression denotes the subset of its base type satisfying its predicate.
A disjointness declaration such as disjoint B, C; also records a type-graph fact, but it does not
establish a subtype relationship between B and C.
A declaration such as:
type A = subtype B where p(this);
binds the name A to the refinement type expression subtype B where p(this).
It does not introduce a nominal type, a fresh carrier set, or an additional semantic fact in the
type graph. In particular, the declaration does not assert that A is inhabited, and the mere
existence of the name A must not be used as evidence that other types necessarily overlap.
The subtype relationship A ⊆ B follows from the meaning of the refinement expression, not from
an added graph edge.
Given declarations of the form:
type A1 = subtype B where p1(x);
type A2 = subtype B where p2(x);
the compiler cannot, in general, determine whether the predicates p1 and p2 define the same
subset of B. Establishing extensional equality between arbitrary predicates would require
solving the predicate-equivalence problem, which is undecidable in the general case.
Therefore, an implementation is not required to prove that two refinement predicates define the
same subset. Two refinement type expressions are automatically equal when their base type
expressions are equal and their normalised predicate forms are identical. Otherwise, the
implementation must not infer equality between them unless the programmer explicitly establishes
equivalence.
Nominal declarations such as value and entity create fresh carrier sets.
A subtype B where p expression does not create new values; it denotes a subset of an
existing base type. The name introduced by type A1 = ... is an alias for that type expression,
not a nominal type.
Consider:
value A {Integer x};
type B = subtype A where p1(x);
type C = subtype A where p2(x);
void foo(b*:B)
{
print b;
}
var B b* = { A { x = 1 }, A { x = 2 } };
var C c* = { A { x = 2 }, A { x = 3 } };
foo(b ∩ c); // type error if static type of b ∩ c is A
When two values of refined types are combined using set-theoretic operations such as intersection, the static type of the result is the intersection of the operand types. For refined types, this corresponds to conjoining their predicates. The compiler must therefore support intersection types internally, even if they are not exposed as a surface-level type expression form.
Naming a refinement must not change the static meaning of set operations. For example, if
type B = subtype A where p1(x); and type C = subtype A where p2(x);, then an intersection may
have the refined type subtype A where p1(x) ∧ p2(x). A union of values of type B and C does
not acquire a special named union type merely because B and C have names; its principal type is
determined from the denoted type expressions. Likewise, a value-level difference operation does
not generally infer a complement refinement such as subtype A where ¬p1(x), because subtracting
one value does not prove that all values satisfying p1 have been removed.
In effect there is a (conjunctive) set of static types for any value-expression.
Every expression has a set of static types that is closed under intersection and subtyping. The compiler reports a principal type, which is the greatest lower bound (meet) of all types known to apply. For refined types, this corresponds to conjoining all predicates known to hold for the expression.
There is no need for a disjunctive set of static types because the union of two sets already requires existence of a unique least common supertype with defined equality.
For a union expression e1 ∪ e2 to be to be well-typed, the static types T1 of e1 and T2 of e2
must be comparable, meaning they share a unique least common supertype U, and equality is defined at U.
This ensures e1 ∪ e2 has a single static type U. There is no ambiguity for equality when
computing e1 ∪ e2, and no need to track either T1 or T2. So there is no need for a
disjunctive set of static types to represent the static type of e1 ∪ e2.
The idea that an expression inhabits many static types simultaneously, closed under intersection and subtyping, appears in the literature in:
The source type expression for explicit type intersections is intersection { T1, T2 }.
It denotes the semantic intersection of the two types. This form is purely extensional and
corresponds to conjoining refinement predicates when both operands are refined types. A type
declaration may bind a name to the intersection expression, but it does not introduce new type
identity.
value Rectangle
{
Real width;
Real height;
}
value Rhombus
{
Real side;
Real θ;
}
// Error: Rectangle and Rhombus are incomparable
type Square = intersection { Rectangle, Rhombus };
var a* = SomeRectangles();
var b* = SomeRhombuses();
// Error: need unique least common supertype of Rectangle and Rhombus with defined equality
var c* = a.Σ ∩ b.Σ;
Together, these forms of subtyping allow types to be organised into a semantic hierarchy. Abstract types define shared predicates, concrete types introduce structure, constrained subtypes refine domains, and intersection subtypes capture overlapping classifications. This provides a unified foundation for open variants, domain refinement, and compositional modelling.
Predica distinguishes nominal and structural types. Structural types are identified by their ordered sequence of attribute declarations; nominal types are identified by their declaration. The rules for necessary overlap and necessary disjointness formalise this distinction.
Predica uses the word necessary in a technical sense. When we say that two types necessarily overlap or are necessarily disjoint, the term necessary acts as a modal operator: the stated relationship holds in all interpretations consistent with the type graph and the intrinsic rules of the language. Necessary overlap is the smallest relation closed upward under supertyping, and necessary disjointness is the smallest relation closed downward under subtyping. This duality reflects the underlying semantics: overlap is an existential property that propagates to supertypes, while disjointness is a universal property that propagates to subtypes. The compiler uses these modal closures to determine when a disjointness declaration is permissible and when a common subtype is forbidden.
Formally:
overlap(A,B).
☐overlap(A,B).
disjoint(A,B).
☐disjoint(A,B).
The modal operator □ (“necessarily”) means: True in all models consistent with the type graph and intrinsic rules. In other words:
This is why:
These are exactly the modal closure rules.
Two types necessarily overlap if:
Integer and Real, or a componentwise
structural subtype.
Two types are necessarily disjoint if:
Integer,
the fixed-width integer types, finite floating-point types, UnicodeCodePoint, and
UnicodeScalar through intrinsic subtyping.
Null.
It is an error to declare a non-empty common subtype of two types which are necessarily disjoint.
Consider a concrete type such as:
value Code
{
Integer number;
}
We say this is concrete because its attributes uniquely determine its carrier.
For example, in this case the carrier for Code is:
{ Code{ number={N} } | N ∈ ℤ }
In Predica, a type may be uncountable, even though no finite representation can denote all
of its values. The predefined type Real is the canonical example.
Types with attributes of uncountable types are typically uncountable themselves. For example,
the following type is uncountable because its attributes range over Real:
value RealPoint { Real x; Real y; };
The type Real denotes the mathematical continuum. Implementations may approximate
Real using representational numeric types such as Float32, but such
approximations are not part of the denotation.
Although RealPoint is uncountable, we say it is concrete because its carrier is determined
by its attributes:
{ RealPoint{ x={X}, y={Y} } | X ∈ ℝ and Y ∈ ℝ }
The abstract keyword introduces an abstract type and may be used for both value and
entity types.
Informally, an abstract type has a domain, and its attributes are unary predicates on that domain. The domain and attributes may be described informally using metadata. For example:
@domain("A shape is a locus of points in Euclidean 2d space")
abstract value Shape
{
@predicate("The area of the shape is [area]")
Real area;
→ Real equivalentRadius = sqrt(area / pi);
}
Definition: The supertypes of a type are the type itself plus its directly declared supertypes and their supertypes and so on.
Definition: The direct and indirect attributes of a type means the attributes in its supertypes (including itself).
Definition: A value type or entity type which is declared abstract means its carrier is not determined by its direct and indirect attributes. The direct and indirect attributes describe properties of its values but do not collectively identify a value in its carrier. It is possible that distinct carrier values can have identical attribute extensions, for every direct and indirect attribute.
For example, Shape is abstract because the attribute area doesn't determine a shape
value.
Note that the metaphysical distinction between value and entity types is irrelevant. Both entity and value types have carriers which are mathematical sets, not naive sets. A concrete entity type has attributes that identity a value in its carrier. The attributes of a concrete (non-abstract) value or entity type define what the carrier is. The fact that entity types are intended to be associated with entities in the real world that have additional properties not modelled by the attributes is irrelevant.
Because the attributes of an abstract type do not collectively identify a value, they cannot be used with nominal constructor syntax to denote one.
Any type, whether abstract or not, may declare:
Variables may have an abstract type, but an abstract type cannot be used with
nominal constructor syntax to denote values. This is a direct consequence of what abstract means. The
following is therefore illegal:
var s = Shape { area = 10.0 }; // Error: cannot instantiate abstract type
Shape is not abstract because it is “conceptual”. There is nothing abstract about
shapes being subsets of ℝ². It is abstract because its attributes don't determine
a shape value.
Abstract types tend to be uncountable:
Circle, Rectangle, Polygon, etc.) captures only a subset.
but that is not the defining characteristic of "abstract". For example, RealPoint is
uncountable, but not abstract.
An abstract type conceptually introduces a carrier set but does not specify its representation, its elements, or its structure. It is a semantic placeholder. When a nominal type subtypes an abstract type, it selects a subset of that abstract carrier as its own carrier.
Predica defines equality on every type. For abstract types, however, equality and the implementation of attribute extensions must be considered separately for value types and entity types.
Every abstract entity type must have at least one explicitly declared or inherited key. An attribute extension whose element type is an abstract entity can therefore use the complete extensions of the key attributes for membership, indexing, duplicate detection and keyed replacement, including when its members have different concrete subtypes.
A key does not define equality on the abstract entity carrier. It is an extension-local constraint: two distinct carrier values with the same key profile may not coexist in an extension governed by that key. The existence of a key therefore does not imply that the abstract entity's attributes collectively determine its carrier values, and does not make the entity type concrete.
Abstract value types have no entity key available to implement their extensions. An attribute such
as Shape shape* therefore requires equality to be available between all concrete values which may
inhabit Shape. Declaring a new subtype is valid only when its values remain comparable with the
other values of the abstract type. That comparability may follow from an existing common concrete
supertype, an explicit equality definition, or a disjointness declaration.
This mutual-comparability requirement permits values of different concrete subtypes to coexist in
an extension of the abstract value type. Membership, set operations and folds such as Σcount
then remain well-defined on that extension.
TODO: Determine how mutual comparability is established in an open subtype hierarchy. For example,
independent third-party components may each declare a new subtype of Shape without depending on
one another, so no component is naturally responsible for declaring equality or disjointness
between every pair of subtypes. The language needs a practical rule which preserves separate
extensibility while ensuring that extensions of Shape still have well-defined equality.
An underived attribute declared by an abstract type is an interface requirement on its subtypes. The attribute and its predicate are inherited, but the abstract declaration does not provide a base or derived implementation of the attribute. A concrete subtype must provide an implementation. An abstract type may also declare a derived attribute; its definition is inherited as an implementation rather than as an outstanding requirement.
For example, RectangleShape.width and RectangleShape.height are attribute requirements, whereas
RectangleShape.area is a derived attribute with an implementation:
abstract value RectangleShape
{
Real width;
Real height;
→ Real area = width * height;
}
value ConcreteRectangle isa RectangleShape
{
Real width;
Real height;
}
ConcreteRectangle must implement width and height, but it does not need to implement area
because it inherits the derived definition. A subtype may optionally provide a refined derived
definition as described below.
An abstract subtype may leave an inherited attribute requirement unimplemented, passing the requirement on to its own subtypes. A concrete type must implement every direct and indirect abstract attribute requirement.
For example, the abstract subtype Quadrilateral may leave Shape.area unimplemented. The
requirement passes to IncompleteRectangle, which is illegal because it is concrete and still does not
implement it:
abstract value Quadrilateral isa Shape
{
}
value IncompleteRectangle isa Quadrilateral
{
Float64 width;
Float64 height;
}
The compiler reports the unimplemented requirement:
Concrete typeIncompleteRectangledoes not implement attributeareadefined in supertypeShape.
Constraints declared by an abstract type are inherited by its subtypes. A subtype does not need to repeat an inherited constraint and cannot remove or weaken it. It may declare additional constraints which further narrow its carrier.
For example, Length implements the magnitude attribute requirement and inherits the constraint
that its value is non-negative:
abstract value NonnegativeMeasure
{
Real magnitude;
constraints
{
magnitude ≥ 0;
}
}
value Length isa NonnegativeMeasure
{
Real magnitude;
}
var invalid = Length { magnitude = -1 }; // Error: inherited constraint is violated
Omitting the constraint from Length, or declaring a weaker constraint there, does not admit values
excluded by NonnegativeMeasure. All inherited and directly declared constraints apply
conjunctively.
Every abstract entity type must have at least one explicitly declared or inherited key. An abstract entity does not receive the default all-base-attribute key because its attributes do not collectively identify its carrier values. A root abstract entity must therefore declare a key. An abstract subtype may inherit a key without repeating it. The general rules for keys and key attributes are specified in 57 Keys.
abstract entity Identified
{
String identifier;
key {identifier};
}
entity Customer isa Identified
{
String identifier;
String name;
}
The key on Identified is inherited. Customer cannot remove or replace it, and must implement the
identifier requirement, but does not repeat the key declaration. The inherited key allows an
attribute of type Identified to distinguish and index values uniformly, including when its
extension contains values of different concrete subtypes. A subtype may declare additional
candidate keys.
A subtype may implement an abstract attribute as either a base attribute or a derived attribute. The implementing attribute may have the same type as the abstract attribute or a subtype of it. Every value permitted by the implementation must be permitted by the abstract attribute requirement. This rule is called covariance: the implementing attribute type is permitted to vary in the same direction as the subtype relation.
For example, the following Point and Circle types use Float64 attributes. Circle implements
the inherited Shape.area : Real requirement as a derived Float64 attribute. This is valid
because Float64 is a subtype of Real:
value Point
{
Float64 x;
Float64 y;
}
value Circle isa Shape
{
Point centre;
Float64 radius;
→ Float64 area = pi * radius * radius;
}
A subtype may provide a refined calculation for an inherited derived attribute. The refined calculation does not replace the inherited definition: both definitions constrain the same attribute extension and must agree for every value in the subtype carrier. The subtype may also narrow the attribute type when the refined result belongs to a subtype of the inherited type.
For example, Shape.equivalentRadius calculates the radius of a circle having the same area. The
area attribute does not identify a shape, but it provides enough information for this derived
calculation. For a disk, the equivalent radius is simply its radius:
abstract value Shape
{
Real area;
→ Real equivalentRadius = sqrt(area / pi);
}
type NonnegativeReal = subtype Real where this ≥ 0;
value Disk isa Shape
{
NonnegativeReal radius;
→ Real area = pi * radius * radius;
→ NonnegativeReal equivalentRadius = radius;
}
The inherited definition → equivalentRadius = sqrt(area / pi) remains part of Disk and must agree
with the refined definition → equivalentRadius = radius. The refined calculation is simpler and
narrows the static result type from Real to NonnegativeReal without changing the inherited
meaning.
A free function defined for an abstract type may be refined by declaring an overload whose parameter type is a subtype. Calls with the more specific static argument type select the refined overload.
func String describe(Shape shape) = "shape";
func String describe(Circle circle) = "circle";
Circle circle = ...;
Shape shape = circle;
describe(circle) // selects describe(Circle)
describe(shape) // selects describe(Shape)
This is static overload refinement, not method overriding or dynamic dispatch. Free functions are not members of a type and are not inherited. The subtype overload is selected because its parameter type is more specific, not because the runtime value has that subtype.
A subtype may implement an abstract attribute with a narrower cardinality constraint. Every cardinality permitted by the implementation must be permitted by the abstract attribute requirement.
For example, Path.point* permits any number of points, including none. NonemptyPath implements
the attribute as point+, narrowing its cardinality to one or more:
abstract value Path
{
Point point*;
}
value NonemptyPath isa Path
{
Point point+;
}
Abstract types provide a basis for open variants, because concrete types may declare themselves to be subtypes of an abstract type.
A subtype is declared using isa with a non-empty list of supertypes. For example:
value Circle isa Shape
{
Point centre;
Real radius;
→ perimeter = 2 * pi * radius;
→ area = pi * radius * radius;
}
A value type cannot subtype an entity type, and an entity type cannot subtype a value type.
A subtype inherits the predicates of its supertypes and may define additional structure.
An existing concrete type may be declared a subtype of an abstract type after its definition, even in a different component:
// Component A
value Polygon
{
Point vertex*;
}
// Component B
abstract value Shape {};
// Component C, with dependencies on A and B
using A { Polygon };
using B { Shape };
Polygon isa Shape;
Consider the following type, which defines both base and derived attributes:
value Rectangle
{
Real width;
Real height;
→ perimeter = 2 * (width + height);
→ area = width * height;
}
This type is concrete because it is not declared abstract.
Note however that Rectangle is uncountable : : concreteness does not imply finiteness or countability.
Because Rectangle is concrete, it cannot appear as a supertype in an
isa clause. The following is therefore illegal:
// illegal: cannot use isa with a concrete supertype
value Square isa Rectangle
{
→ height = width;
}
There are two valid ways to define Square as a subtype of
Rectangle:
In both cases, the subtype Square inherits the attributes of its
supertype (width, height, perimeter, and area).
Inheritance can produce name collisions. Predica applies a hiding rule:
A subtype T where p expression restricts the carrier of a single concrete supertype using an
additional predicate:
type Square = subtype Rectangle where width = height;
The where predicate may refer to the base type's attributes. Multiple constraints can be
expressed by conjunction:
type Square = subtype Rectangle where width = height ∧ area > 0;
The keyword this refers to the base value within the
where predicate. The first declaration above is syntactic sugar for:
type Square = subtype Rectangle where this.width = this.height;
The keyword this enables refinement of predefined types:
type PositiveReal = subtype Real where this > 0;
A separately represented nominal type may be made a subtype of a concrete type by
providing an embedding. In this alternative, Square must first declare the base
attribute used by the embedding:
value Square
{
Real side;
}
embed Square s isa Rectangle =
Rectangle { width = s.side; height = s.side };
The general form is embed Source x isa Target = expression;. The expression is evaluated
with x bound to the source value and must have type Target. The declaration defines an
embedding Source → Target, making Source a subtype of Target.
This Square is different from the refinement type in the preceding section. The
refinement denotes those existing Rectangle values whose width equals their height.
The nominal Square above instead has its own representation, consisting of one base
attribute side, and the embedding specifies the corresponding Rectangle value.
An embedding is a separate top-level declaration so that a subtype relationship can be provided after the source and target types have been declared, including when they are declared in different components. Keeping the embedding separate is useful for such retroactive relationships, although the source type and embedding may be placed together when they are designed as one unit.
The same form works for scalar or predefined supertypes:
embed Rational r isa Real =
r.numerator / r.denominator;
For quotient source types, the embedding expression must be well-defined on equivalence classes: equivalent representatives must produce the same target value. An embedding must also preserve the source type's equality, so distinct source values do not collapse to the same target value.
A subtype may provide a simpler calculation and a narrower type for an attribute of a concrete supertype. Because a nominal type can subtype a concrete type only through an embedding, the embedding must preserve the meaning of the attribute.
For example, a polygon calculates its area from its ordered vertices. An axis-aligned rectangle derives those vertices from a simpler representation and calculates the same area directly from its width and height:
value Polygon
{
List<Point> vertex;
→ Real area = polygonArea(this);
constraints
{
vertex.length ≥ 3;
}
}
func Real polygonArea(Polygon polygon)
{
var Integer count = polygon.vertex.length;
var Real twiceSignedArea = 0;
for Integer i in 0..count - 1 {
var Integer next = if i + 1 = count then 0 else i + 1;
var Point a = polygon.vertex[i];
var Point b = polygon.vertex[next];
twiceSignedArea += a.x * b.y - b.x * a.y;
}
return abs(twiceSignedArea) / 2;
}
value AxisAlignedRectangle
{
Point origin;
Float64 width;
Float64 height;
→ List<Point> vertex = rectangleVertices(origin, width, height);
→ Float64 area = width * height;
}
embed AxisAlignedRectangle rectangle isa Polygon =
Polygon { vertex = rectangle.vertex };
polygonArea applies the shoelace formula to consecutive vertices and wraps the final vertex back
to the first. Taking the absolute value makes the result independent of whether the vertices are
listed clockwise or anticlockwise.
Viewed as a Polygon, the area is defined by polygonArea(this). The rectangle's refined
calculation must agree with that inherited meaning. Once the agreement is established, an
implementation may use width * height, and the static type of AxisAlignedRectangle.area remains
the narrower type Float64.
Definition (disjoint):
Types A and B are disjoint if they are comparable and their carrier sets are disjoint.
Definition (incomparable):
Types A and B are incomparable if equality between them is undefined and any attempt to
compare them, implicitly or explicitly, produces a type error.
Definition (comparable):
Two types A and B are comparable iff they share a unique least common supertype T with
defined equality.
If multiple candidate supertypes exist and none is a subtype of the others, A and B are incomparable.
In the following example, types B and C have a unique least common
supertype A, but A is abstract and therefore does not define equality.
B and C also have a non-empty common subtype D. Nevertheless,
B and C are incomparable. This is based on static typing: attempting to
compare expressions of static types B and C produces a type error, even if
the expressions denote values lying in the common subtype D.
abstract value A {};
value B { Integer x; };
value C { Integer x; };
value D { Integer x; };
B,C isa A;
// D isa B,C;
B(D:d) { x = d.x; }
C(D:d) { x = d.x; }
var b = B { x=1 };
var c = C { x=1 };
print b = c; // type error: B and C are incomparable
Declaring disjointness for B and C, and naming their union type, records that their carrier
sets do not overlap. This makes cross-member equality available as false:
value B { Integer x; };
value C { Integer x; };
disjoint B, C;
type A = union { B, C };
var b = B { x=1 };
var c = C { x=1 };
print b = c; // prints false
Naming the same union type multiple times does not create distinct supertypes.
value B { Integer x; };
value C { Integer x; };
disjoint B, C;
type A1 = union { B, C };
type A2 = union { B, C };
var b = B { x=1 };
var c = C { x=1 };
print b = c; // prints false
A union type expression denotes the union of the carrier sets of its member types:
type Contact = union { EmailContact, PhoneContact };
type VisibleContact = subtype (union { EmailContact, PhoneContact }) where visible;
The syntax for union types is deliberately parallel to the syntax for intersection types:
type Contact = union { EmailContact, PhoneContact };
type ShapeTexture = intersection { Shape, Texture };
Both forms are recursive type expressions. A union may appear anywhere a type expression is accepted: in a type alias, an attribute declaration, a generic argument, a refinement base, or as a member of another union or intersection expression.
The declaration:
type A = union { B, C };
does not assert that B and C are disjoint. It only names the type whose carrier is the union of
the carriers of B and C.
Pairwise disjointness is a separate type-graph fact. It can be declared directly when intended:
disjoint B, C;
type A = union { B, C };
The disjointness declaration is rejected if it contradicts facts already known to the type graph. For example, two types with a known non-empty common subtype cannot also be declared disjoint.
value B { Integer x; };
value C { Integer x; };
value D { Integer x; };
B(D d) { x = d.x; }
C(D d) { x = d.x; }
disjoint B, C; // Error: B and C have a common subtype.
Likewise, intrinsic and structural rules may already imply that two types necessarily overlap, or that they are necessarily disjoint. The union expression itself does not add either fact.
For equality to be available on a union type, equality must be defined for all relevant member pairs. Those definitions are not part of the union type-expression syntax. They are declared separately, as described in the chapter on equality between types.
This keeps the syntax of union { ... } simple and recursive. The parser recognises the union
type expression without also parsing equality definitions, and the type checker later verifies that
the equality operations needed by a particular use are available.
Union types are non-nominal: the identity of a union type is determined solely by its member types. Therefore:
type A1 = union { B, C };
type A2 = union { B, C };
means A1 and A2 are aliases of the same union type, equivalent to:
type A1 = union { B, C };
type A2 = A1;
Predica queries navigate, filter, reshape, and aggregate values through paths in the model. This part introduces that surface notation from the user's point of view: how to ask for suppliers in London, how to move through relationships, how to expose a view, and how to collect or summarise the values reached by a path.
The technical account is given later in the Semantics part. In short, query expressions denote logic frames. When a query result is displayed as a table or relation, that display is an observed extension of the frame, not a separate relation-valued meaning for the query expression.
Constant declarations name pure values at component declaration scope. A constant may have an explicit declared type, or it may leave its type to be inferred from the defining expression:
const pi = 3.141592653589793;
const Real one = 1;
When no type is written, the constant's type is inferred from the expression. When a type is written, the expression must be assignable to that type, and the written type is the constant's static type. This is useful when a literal or expression could otherwise infer a narrower or less specific type than the source intends.
Constants may be parameterised with the same generic parameter syntax used by other declarations:
const Integer next<Integer N> = N + 1;
const Set<T> emptySet<Type T> = ∅;
A generic constant is referenced with an explicit generic application. This remains true when all generic parameters have defaults:
const Integer plus<Integer N = 2> = N + 1;
plus<3>
plus<>
Constants are not nullary functions. A constant reference evaluates to the named value, while a function declaration introduces an operation selected by a call expression or by a symbolic operator. Use a constant for a named value, and a function when ordinary value parameters or type-directed call resolution are part of the abstraction.
Functions name pure operations that may be called from expressions. They may be used from derived attributes, constraints, function bodies, fold definitions, program expressions, and other ordinary expression contexts. Operations that perform effects are procedures, specified in the Updates part.
Function declarations are written at component declaration scope, alongside types, constants, and folds. This chapter covers function signatures, expression-bodied function definitions, external function declarations, and calls. Block-bodied functions are specified in the Updates part because they use the statement language.
An expression-bodied function is declared with the func keyword, a result type, a
function name, zero or more ordinary parameters, and an expression body after =:
func Boolean overlaps(TimeSlot a, TimeSlot b) =
a.day = b.day
∧ a.startMinute < b.endMinute
∧ b.startMinute < a.endMinute;
Plain parameters are read-only and non-owning. They are in scope only inside the function body, are expression variables rather than attributes, and cannot be updated or consumed. A parameter name hides a same-named value or variable while the body is checked. The body is checked in the context of the declared result type. The encoded body type must be assignable to the declared result type.
An expression body is read-only by construction. It cannot update locations, introduce mutable state, perform I/O, or depend on statement evaluation order. A function with a block body can still be pure if its effect analysis proves that it touches only local variables and calls only pure functions; see Block-Bodied Functions.
A function parameter prefixed with ^ is an owned variable:
func String appendGreeting(^String x)
{
x += "hi";
return x;
}
An owned parameter may be read, updated, or consumed. Updating it is local
state and does not make the function impure. The caller may initialize it from
an ordinary value expression or explicitly move from a caller-owned variable.
Functions cannot declare & parameters because modifying a caller's
variable is an effect; procedures provide exclusive reference parameters. See
Parameter Access and Ownership.
An external function is declared inside an extern block. The block has a
linkage string that identifies the implementation domain. Function declarations
inside the block have read-only parameters, omit the func
keyword, and have no body:
extern "C++:std"
{
@domain("Sine")
Float64 sin(Float64 x);
Float64 atan2(Float64 y, Float64 x);
}
Metadata may be attached to individual external functions. Metadata is not attached to the
extern block itself. An external declaration contributes a typed function symbol to
the schema and records the linkage string; it does not provide a Predica expression body.
An external function declaration is a promise that the linked implementation behaves as a
pure function. External operations that perform effects are not functions.
A function call names a visible function and supplies exactly one ordinary argument for each ordinary parameter:
sin(angle)
Geometry.distance(a, b)
identity<Integer>(value)
Name resolution may find an overload set containing functions from ordinary lexical lookup, selected imports, qualification, or argument-dependent lookup. The Name Resolution and Function Overload Resolution chapters specify how the set is assembled and how a unique function is selected. A function name is call-only: a function cannot be used as a first-class value merely by writing its name.
A call-shaped expression whose name resolves to a concrete nominal type, such as
Point(1, 2), is the positional shorthand for a nominal constructor, not an
ordinary function call. If the same spelling could resolve both as a function
call and as a nominal constructor shorthand, the expression is ambiguous and must
be rejected statically.
Every call is checked against the resolved function signature. The argument count must match exactly. Each argument is checked in the context of the corresponding parameter type, and the argument type must be assignable to that parameter type. Ordinary function parameters are single-valued: an argument must have static cardinality one after any cardinality-flow refinements from the surrounding guards. The call expression has the declared result type of the resolved function and cardinality one.
A plain argument initializes a read-only parameter directly and initializes an
owned parameter by value. An argument of the form ^s may be used
only for an owned parameter; its argument expression must denote an initialised
caller-owned location. The value is moved from that location into the
parameter, leaving the caller's location uninitialised. The
^ marker is call syntax rather than a general unary expression.
func Boolean overlaps(TimeSlot a, TimeSlot b) =
a.day = b.day
∧ a.startMinute < b.endMinute
∧ b.startMinute < a.endMinute;
overlaps(slot, other)
slot.overlaps(other)
The expression slot.overlaps(other) is a receiver-call form of the ordinary
function call overlaps(slot, other). The name after the dot resolves to a
visible identifier-named function. The receiver expression supplies the first
ordinary argument, and the arguments inside the parentheses supply the remaining
ordinary arguments.
value Square { Real side; }
value Rectangle
{
Real width;
Real height;
}
func Real area(Rectangle r) =
r.width * r.height;
embed Square s isa Rectangle =
Rectangle { width = s.side; height = s.side };
var square = Square { side = 5 };
area(square)
square.area()
Both calls resolve to the same function. The receiver call square.area() is
equivalent to area(square): the receiver square supplies the first argument.
The call is valid because the embedding makes Square a subtype of Rectangle,
so a Square value is assignable to a parameter declared as Rectangle.
A receiver call is path-expression syntax, not a method declaration. Functions are still declared at component declaration scope; they are not stored inside nominal types, inherited by nominal types, overridden by subtypes, or dispatched dynamically. Resolution is static and type-directed. If no visible function matches the receiver and ordinary arguments, the receiver call is a static name-resolution error.
value TimeSlot
{
Boolean overlaps;
}
slot.overlaps
slot.overlaps(other)
The parentheses distinguish the two cases. The expression slot.overlaps
navigates to the overlaps attribute. The expression slot.overlaps(other)
is a receiver call and resolves only as a function call. The existence of an
attribute with the same name does not make the receiver call ambiguous, and the
existence of a function with that name does not make slot.overlaps denote a
function value.
A dotted call may have the token shape of either a qualified function call or a
receiver call. The expression Geometry.distance(a, b) is a qualified function
call when Geometry resolves as a component or namespace qualifier and
Geometry.distance resolves to a visible function. The expression s.area() is
a receiver call when s resolves as an expression and area resolves to a
visible function whose first parameter can accept the receiver. If both
interpretations are possible, the call is ambiguous and must be rejected
statically; the program must use a different alias or variable name to make the
intended meaning clear.
When the receiver expression reaches many values, the receiver-call segment is
applied once for each receiver, just like an attribute path segment. For a
single-valued receiver, R.f(A, B) is equivalent to f(R, A, B). For a
multi-valued receiver, the result is the extension produced by applying the
function to each receiver member. Each receiver member is supplied as an
exactly-one first argument to the function. The arguments written inside the
parentheses are ordinary function arguments and must themselves have cardinality
one after any cardinality-flow refinements.
Functions with the same source name form an overload set. The set may contain ordinary and generic functions and may be assembled from more than one namespace by imports and argument-dependent lookup. Two declarations in the same namespace are duplicates only when they declare the same name, generic parameter structure, and ordinary parameter types; differing result types or ownership markers alone do not distinguish overloads. Function Overload Resolution selects among distinct overloads using their instantiated ordinary parameter types.
A function name may be either an identifier or a symbolic function name. A symbolic function name is an expression operator token that names a pure function directly in the function-name position. Symbolic function names are needed for user-defined comparison operations and for type-directed pure functions whose natural spelling is an operator.
| Kind | Symbolic function names |
|---|---|
| Equality | =, ≠ |
| Ordering | <, >, ≤, ≥ |
| Subtype and membership predicates | ⊂, ⊃, ⊆, ⊇, ⊄, ⊅, ∈, ∉ |
| Arithmetic | +, -, *, /, ^ |
| Set operations | ∩, ∪, ∖ |
| Path, tuple, and alternative operations | ⊓, ⊔, ⨝, | |
| Implication and equivalence | ⇒, →, ←, ↔ |
| Logic | ∧, ∨, ¬ |
The set-difference name uses the canonical set-minus character ∖. As described
in the lexical grammar, ordinary keyboard backslash is accepted as an input alias.
value Rectangle
{
Real width;
Real height;
}
value Rhombus
{
Real side;
Real angle;
}
func Boolean =(Rectangle r, Rhombus h) =
r.width = r.height ∧ r.width = h.side ∧ h.angle = 90;
A symbolic function declaration contributes a type-directed implementation of the corresponding
operation for values of the declared parameter types. Several types may define the same symbolic
operation, such as =, provided overload resolution for a particular expression is unambiguous.
Equality on values of the same concrete type is normally predefined; explicit equality declarations
are most useful when they define equality between distinct types whose values may overlap.
In the example above, rectangles and rhombi overlap only at squares, so neither type is simply a
subtype of the other.
Once equality is available for every required pair of member types, union and intersection type
expressions over those members can be used coherently.
Expression operators keep their ordinary precedence and parsing rules. A declaration of a
symbolic function such as ≤ supplies the comparison operation selected by type
checking; it does not make ≤ an ordinary first-class value and it does not change how
comparison expressions are parsed.
For ordering, ≤ is the primitive comparison associated with an ordered type. The
ordering chapter specifies the order axioms, implicit ordering, explicit order
declarations, and the derived comparison operators <, >, and
≥.
Functions may have generic parameters and generic where constraints:
func T identity<Type T>(T value) = value;
func Integer addN<Integer N = 2>(Integer value) = value + N;
extern "C++:math"
{
T clamp<Type T, Integer N = 3>(T value)
where T isa Real, N ∈ 1..4;
}
The where clause after a function signature is a generic constraint clause. It is
valid only when the function has a generic parameter list. It is not an ordinary precondition on
the run-time arguments.
A generic function application creates or reuses a concrete function instantiation with canonical generic arguments. Expression-bodied native generic instantiations have a Predica expression body produced by substituting the generic arguments into the source body. Generic block bodies use the same generic substitution model before their statement body is checked. External generic instantiations preserve the external linkage and have no body.
Generic arguments may be written explicitly, as in identity<Integer>(1). They may
also be inferred when inference is unambiguous from ordinary argument types and trailing defaults.
When a required generic parameter cannot be inferred, the call must write explicit generic
arguments. The generic chapter specifies the shared generic parameter and canonicalisation rules.
Concrete function declarations created only to represent generic instantiations are schema-internal
implementation details. Rendered schema output preserves the generic declaration shell and renders
calls using the canonical generic application, such as identity<Integer>(1), rather
than emitting hidden concrete function definitions.
Function overloading provides ad-hoc polymorphism: several ordinary or generic functions may have the same name, including in the same namespace. The Name Resolution chapter specifies how ordinary lookup, selected imports, and argument-dependent lookup assemble the candidate set. This chapter selects a function from that set using the static argument types.
An ordinary function is viable when the number of arguments matches its ordinary parameter list, every argument has cardinality one after cardinality-flow refinement, and every argument type is assignable to the corresponding parameter type.
Parameter access is checked as part of viability. A plain argument is viable
for either a read-only or an owned parameter. An explicit ^s
argument is viable only for an owned parameter and requires s to be
an initialised variable owned by the caller. Function parameters cannot use
the exclusive-reference marker &. Access and ownership markers do
not participate in parameter-list specificity and do not distinguish overload
declarations whose parameter types are otherwise identical.
A generic function is first instantiated provisionally. Explicit generic arguments are bound
first; omitted arguments are inferred from the ordinary argument types and then trailing defaults
are applied. The resulting arguments must satisfy their declared kinds and the function's generic
where constraints. Substitution must produce a valid concrete signature, and that
signature must satisfy the ordinary viability rules. Failure of inference or a false constraint
removes that candidate; it does not by itself make the call ill-formed.
Given two viable candidates with instantiated parameter types
P1, ..., Pn and Q1, ..., Qn, the first parameter list is more specific than
the second when every Pi is a subtype of or equal to Qi, and at least one
Pi is a proper subtype of Qi. Specificity uses the subtype relation itself,
not the number of edges in an embedding path. It therefore remains well-defined when the type
graph contains more than one path between two types.
func String describe(Shape x) = "shape";
func String describe(Rectangle x) = "rectangle";
describe(rectangle) // selects describe(Rectangle)
If two viable candidates have identical instantiated parameter types, a non-generic declaration is preferred to a generic declaration. No other preference is given merely because a declaration is generic or non-generic, was found by ordinary lookup or ADL, appears earlier in a source file, or comes from the current component. Explicit and inferred generic arguments participate identically after substitution.
A viable candidate is better than another viable candidate when its instantiated parameter list is more specific, or when the lists are identical and it is non-generic while the other is generic. The selected overload is the unique viable candidate that is better than every other viable candidate. If there is no viable candidate, the call is invalid. If there is no unique best candidate, the call is ambiguous and must be qualified, supplied with explicit generic arguments, or otherwise rewritten to make the intended overload unique.
Return types do not participate in overload resolution. The expected type of the surrounding expression therefore cannot select between overloads. This keeps call resolution local and avoids changes in a distant result context silently selecting a different function.
Subtyping permits an argument to make several overloads viable. A strictly narrower parameter type wins when it is narrower in every parameter position. If candidates trade specificity between positions, neither is better and the call is ambiguous.
func Result combine(Rectangle a, Shape b) = ...;
func Result combine(Shape a, Rectangle b) = ...;
combine(r1, r2) // ambiguous: each overload is narrower in one position
The same rules apply to identifier-named functions, receiver-call syntax, and symbolic functions. For a receiver call, the receiver is the first ordinary argument before viability and specificity are tested. For an operator, its operands are the ordinary arguments.
The Foundations introduction introduces a simple
integer list as a recursive value type.
Each list cell has a head value and an optional tail holding the next cell.
This chapter uses the same shape to show how ordinary functions can be defined
over head-tail lists. Head-tail lists are only an example in this chapter; they
are not special syntax and these functions are not assumed to be part of the
standard library.
value List
{
Integer head;
List tail?;
}
This type represents non-empty finite lists. A one-element list has no tail.
Longer lists are built by placing another List in the tail.
var xs =
List {
head = 1,
tail = List {
head = 2,
tail = List {
head = 3,
tail = none
}
}
};
For membership tests, it is useful to expose the integers contained in the list as a derived extension:
value List
{
Integer head;
List tail?;
→ Integer member* = head | tail.member;
}
The derived attribute member contains the head value and all values reached
through the tail. This is a membership extension, not the list structure
itself: it does not preserve order or duplicates. The ordered head-tail
structure is still carried by head and tail.
The Boolean-valued membership function is then a direct membership test against that extension:
func Boolean memberOf(List list, Integer value) =
value ∈ list.member;
Because the list is the first parameter, the same function can also be called with receiver-call syntax:
memberOf(xs, 2)
xs.memberOf(2)
Both calls ask whether 2 is a member of the list xs. The receiver-call form
does not make memberOf a method stored in List; it is still the
ordinary function declared above, with xs supplied as its first argument.
The definition of memberOf does not require an implementation to materialise
the whole member extension before testing membership. Since member is
defined as head | tail.member, the expression value ∈ list.member is
equivalent to testing value = list.head or, when a tail exists, recursively
testing value ∈ list.tail.member.
func Boolean memberOf(List list, Integer value)
{
if list.head = value {
return true;
}
if list.tail = none {
return false;
}
return list.tail.memberOf(value);
}
This block-bodied version is an implementation strategy for the same function. A
compiler or query planner may use a demand-driven traversal, memoisation,
indexing, or materialisation, provided the observable result is the same as the
declarative definition. The second if statement also demonstrates cardinality
flow: after the list.tail = none branch returns, the final statement is checked
in the case where list.tail is exactly one.
This chapter gives a small tour of Predica query notation using the familiar Supplier/Parts model. The examples focus on how the notation reads. The Relations part gives the fuller correspondence with relational-model operators, and the Semantics part gives the logic-frame denotation.
entity Supplier
{
Integer id;
String name;
Integer status;
String city;
Shipment shipment* where shipment.supplier = this;
key {id};
}
entity Part
{
Integer id;
String name;
Color color;
Real weight;
String city;
Shipment shipment* where shipment.part = this;
key {id};
//constraints { shipment.part ⊆ { this }; }
}
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier,part};
}
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
→ Shipment shipment* = either supplier.shipment, part.shipment;
}
Assume a variable s whose type is ShipmentsSystem. Paths starting at s
navigate through suppliers, parts, and shipments.
| Question | Predica | Reading |
|---|---|---|
| All suppliers | |
Navigate from the system to its supplier values. |
| Supplier names | |
For each supplier reached, observe its name. |
| Supplier names and cities | |
Reshape each supplier into a result with two exposed labels. |
| Shipment rows with names | |
Use the shipment as the witness and expose values reached through it. |
| Question | Predica | Reading |
|---|---|---|
| Suppliers in London | |
Keep the current suppliers whose city satisfies the filter. |
| Names of suppliers in London | |
Filter suppliers first, then observe their names. |
| Parts heavier than 15 | |
Use a numeric condition in the context of each part. |
| Shipments of red parts | |
The filter may navigate from the current shipment to related values. |
If Supplier defines → shipment* = inverse shipment.supplier, then a supplier
can be queried directly for the shipments that refer to it.
| Question | Predica | Reading |
|---|---|---|
| Shipments for each supplier | |
Navigate backwards from supplier to referring shipments. |
| Part names shipped by each supplier | |
Combine forward and inverse navigation in one result shape. |
| Question | Predica | Reading |
|---|---|---|
| Suppliers with shipments | |
Fold the supplier's shipment path to a Boolean condition. |
| Suppliers with no shipments | |
Keep suppliers whose inverse shipment path is empty. |
| Number of shipments per supplier | |
Fold the reached shipments while preserving the supplier context. |
| Total quantity shipped by supplier | |
Navigate to quantities and aggregate them for each supplier. |
| Parts shipped by exactly one supplier | |
Use a folded path inside a filter condition. |
Queries often reach more than one value. Assigning such a result to a single-valued variable is invalid; the target must permit the cardinality of the query result.
var id1 = 0;
id1 := s.supplier.id; // static cardinality constraint check failure
var Integer id2* = {};
id2 := s.supplier.id; // ok
The .. notation resolves an existing value through a query while constructing
a literal. This lets a literal share values already present in the surrounding
object graph.
Shipment {
supplier = ..supplier[id = 1],
part = ..part[id = 2],
qty = 200
}
The resolved values are still ordinary Predica values. Any internal handles or indexes used to implement this lookup are not part of the user-visible semantics.
A filter is not a relation-valued expression. It is a Boolean condition translated relative to the current path frame. Applying a filter strengthens the frame condition and preserves the current result interface.
s.supplier.[city = "London"].name
In logic-frame display notation, the filter contributes an additional predicate to the frame:
name=N:String
where ∃S:Supplier ∃C:String (
s.supplier(S)
∧ S.city(C)
∧ C = "London"
∧ S.name(N)
)
The expression inside square brackets is interpreted in the context supplied by the receiver frame.
Names such as city are resolved through that context. The bracketed expression therefore does not
stand alone as a first-class relation, m-relation, or relation literal.
When the filtered frame is later observed and materialised as an s-relation, the result looks like an ordinary relational selection. That is an extensional view of the frame, not the primary denotation of the bracketed expression.
path frame --add filter condition--> filtered path frame
filtered path frame --observe under heading H--> selected s-relation extension
This is the same pattern used throughout the Query and Relations parts: Predica source expressions translate to logic frames; relation-shaped results are obtained by observing the extensions of those frames under chosen headings.
It is sometimes useful in logic and model theory to describe a predicate intensionally, before its extension in a particular model is known. Predica can use that vocabulary informally, but the surface filter syntax does not make bracketed predicates into relation values. In particular:
[a = b] is not a standalone binary relation with attributes a and b;
e.[a = b] is not defined by joining e with a first-class relation value;
Implementations may optimise filters using indexes, joins, semi-joins, factorised representations, or other relational techniques. Those are implementation strategies for preserving the logic-frame semantics, not additional surface meanings for square brackets.
Predica lets you navigate forward along attributes using paths such as
shipment.supplier or shipment.part.color. Inverse relationships
answer the complementary question:
Given a forward attribute, how do we navigate backwards to find all objects that refer to the current one?
Predica introduces the inverse keyword for this purpose. It inverts a
downward path. Informally, it selects the objects that map to
this along that path.
Suppose each Shipment has a supplier, and we want each
Supplier to expose all shipments that refer to it:
entity Supplier
{
Integer id;
String name;
Integer status;
String city;
key {id};
→ shipment* = inverse shipment.supplier;
}
The derived attribute shipment* is defined as
inverse shipment.supplier. Informally, this means:
This derived attribute is read-only unless an explicit view-update rule is specified. Recording a new shipment updates the base shipment collection from which the inverse is derived.
AllShipmentobjects whosesupplierattribute equals thisSupplier.
The expression inside inverse is a downward path:
shipment.supplier. Predica resolves this path starting from the parent
scope of Supplier, finds the collection of shipments, and then inverts
the supplier attribute relative to the current supplier.
Forward navigation follows declared attributes:
shipment.supplier: from a Shipment to its Suppliershipment.part: from a Shipment to its PartInverse navigation goes the other way, by inverting a path:
inverse shipment.supplier: from a Supplier to all Shipment objects that refer to itinverse shipment.part: from a Part to all Shipment objects that refer to it
The key idea is that inverse does not name an entity directly. It operates
on a path, and Predica uses the surrounding structure to resolve that path.
Here is a complete model of suppliers, parts, and shipments using inverse relationships:
entity Supplier
{
Integer id;
String name;
Integer status;
String city;
key {id};
→ shipment* = inverse shipment.supplier;
}
entity Part
{
Integer id;
String name;
Color color;
Real weight;
String city;
key {id};
→ shipment* = inverse shipment.part;
}
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier, part};
}
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
Shipment shipment*;
constraints
{
shipment.supplier.Σ ⊆ supplier.Σ;
shipment.part.Σ ⊆ part.Σ;
}
}
In Supplier, the expression inverse shipment.supplier is resolved
by looking up shipment in the parent scope ShipmentsSystem, then
following the supplier attribute inside Shipment, and finally
inverting that mapping relative to the current supplier.
inverseAn expression of the form:
inverse p
where p is a downward path, is interpreted as:
“The set of all objects at the start of pathpwhose value alongpequalsthis.”
In the supplier example:
p is shipment.supplierp is the collection of all Shipment objectsp is a SupplierSupplier, inverse shipment.supplier returns all shipments whose supplier is thisinverse
The inverse keyword is not a general function; it is a structural operator
with specific constraints. An expression inverse p is valid inside an entity
T only if all of the following hold:
p must be a downward path expression (a sequence of attribute selections).
p must have type T, the entity in which
inverse p appears. This ensures that the path “lands on” objects of the
current type.
p starts in the parent scope of T and
walks upward through ancestor scopes until a matching declaration is found. The nearest
matching scope wins.
These constraints guarantee that inverse is:
Inverse relationships let you express “backwards” navigation declaratively, without introducing explicit foreign-key style attributes or manual queries. They are especially useful when:
Shipment.supplier)By defining inverse attributes once in the schema, you make these queries available everywhere in the model as simple path expressions, such as:
supplier.shipment.part.color
Inverse navigation composes with ordinary query notation, so it can be filtered, reshaped, folded, and observed just like a forward path.
A view is a named, pure transformation over a model. It gives a stable read-only shape to a query result without making relations primary values in the denotational semantics.
Views are useful when one model should expose a controlled public interface over another. The view definition records the navigation and reshaping once, so clients can depend on the public shape rather than the internal storage shape.
Imagine an internal employee system that stores operational details, including fields that should not be exposed outside the organisation:
namespace Internal
{
entity Employee
{
Integer id;
String name;
String address;
Money salary;
key {id};
}
entity System
{
Employee employee*;
}
}
The public interface is intentionally narrower. It reveals only the attributes external consumers are permitted to observe:
namespace Public
{
entity Employee
{
Integer id;
String name;
key {id};
}
entity System
{
Employee employee*;
}
}
A one-way transformation from the internal model to the public model can then be defined as a pure function:
func Public.System directory(Internal.System i) =
Public.System {
employee = i.employee.(id, name)
};
The expression i.employee.(id, name) is ordinary query notation. It navigates
to the internal employees and observes only the selected attributes needed by
Public.Employee. The function body constructs a Public.System value whose
employee extension is that shaped result.
Without the view, each downstream system would need to understand the internal
schema, remove sensitive fields such as salary and address, and manually
reshape the result into the Public.System structure. The directory
function centralises that responsibility and gives consumers a stable
read-only contract while the internal model remains free to evolve.
A view does not introduce a new denotational category. Its body is interpreted using the same query semantics as any other expression: paths denote logic frames, and relation-shaped outputs are observed extensions of those frames.
async/awaitPredica applications use ordinary synchronous evaluations even when database access requires I/O or a derived value requires expensive computation. A function, query, derived attribute, or display evaluation runs from entry to completion. It does not suspend and is not resumed later.
This does not make the application block on unavailable information. Accessing a nonresident database object expresses demand for that object and can initiate asynchronous I/O. Accessing an unavailable expensive result likewise expresses demand for its calculation. The access reports the information currently available, including pending or partial state, and the current evaluation deals with that state and continues.
synchronous evaluation
+ demand-triggering access
+ asynchronously published information
+ later independent evaluations
= responsive application
Predica therefore does not make an asynchronous procedure merely look synchronous. Application evaluations are genuinely synchronous. It is the availability of the information they observe that changes asynchronously.
An application must distinguish the logical value of an expression from the physical availability of the information needed to evaluate it. A database object may exist in the reader's snapshot without its representation currently being resident in memory. A derived value may be demanded while its inputs or calculation are still pending.
Depending on the kind of value and the needs of its reader, an evaluation may observe states such as:
Pending is not an incomplete execution. It is a state from which ordinary
expressions can calculate another value. A display can derive a placeholder
from pending image data, a progress indicator from partial work, or the text
Calculating… from a demanded result that is not yet available.
Logical absence and physical nonresidency are particularly important to keep separate. An optional value with cardinality zero is absent from the database state. An object with cardinality one remains logically present when its bytes are nonresident. The latter access may initiate loading; it must not be misreported as logical absence. Object-qualified values and this distinction are described further in Objects.
From application code, navigation to a database object is an ordinary synchronous operation. Physically, every such access is potentially the start of asynchronous I/O. If the required rendition is resident for the reader's snapshot, the access can use it immediately. If it is nonresident, the access records demand, arranges or joins a request to load it, and reports pending availability without blocking the reader.
Demand belongs to the information, rather than to a suspended invocation. Several views that access the same object can share one resident rendition and one outstanding load. If a view ceases to require the object, no continuation owned by that view remains to be resumed. The runtime can reprioritise, coalesce, or abandon work according to current aggregate demand.
The same principle applies to expensive Derived Attributes. Reading a derived attribute demands its result. If all required inputs are available, the Dependency Graph System (DGS) can schedule its calculation. If some inputs are pending, the calculation is not started until it is ready. No procedure is started early merely so that it can suspend at a missing input.
required inputs incomplete → calculation is not runnable last required input arrives → calculation becomes runnable calculation runs → result is published
Work that can produce a useful partial result need not wait for every possible input. It runs with the availability states it accepts. Work that requires complete inputs starts only when those inputs are available. Neither case requires a continuation.
Consider a multiresolution image whose levels contain tiled JPEG objects. A level may include an array such as:
MortonArray<object Jpeg?, numTiles.x, numTiles.y> tiles;
A display pass must finish in its frame time. It cannot wait for a desired tile to be read or decoded. When it accesses the tile, the access may initiate or reinforce demand for the necessary I/O and decoding. If the decoded tile is available, the pass renders it. If it is pending, the pass can render an available tile from a parent level, retain an earlier rendition, or draw a placeholder. It then completes the frame.
desired child tile available → render child tile desired child tile pending → render best available parent tile desired child tile failed → render failure indication or another fallback
When the child tile later becomes resident, the original display pass is not resumed. A later pass makes its own decision. Its viewport, desired resolution, frame budget, snapshot, and available parent tiles may all differ. It may use the newly resident child, choose a different parent, or find that the tile is no longer visible.
The old pass expressed demand but left no suspended rendering intention behind. The durable state is the demand, loading status, resident rendition, and relevant versions—not the old pass's instruction position or local variables.
Each reader evaluation uses one coherent MVCC snapshot. All database values used by that evaluation belong to the same visible committed state. Concurrent writers can publish newer states without changing the world observed by the reader while it is calculating. The implementation requirements are described in Snapshot Isolation.
An evaluation that encounters pending information completes using that pending state. A later evaluation takes a later snapshot and recalculates using the information and application context then current:
displayₙ = derive(snapshotₙ, contextₙ) displayₙ₊₁ = derive(snapshotₙ₊₁, contextₙ₊₁)
The second evaluation is not a continuation of the first. This is important
for correctness as well as responsiveness. Retaining calculated local
variables from snapshotₙ and then combining them with database values from
snapshotₙ₊₁ could produce a result that corresponds to no consistent database
state. Alternatively, retaining snapshotₙ across indefinite I/O would keep
an old state and an old application intention alive after they had ceased to be
relevant.
A user interface continuously derives a presentation from current information. It does not wait for that information to become complete. The same model supports:
Calculating… while a demanded result is pending;
These are derived display states, not commands surrounding an awaited call. For
example, Calculating… is visible whenever the calculation is demanded and a
final result is not currently available. Completion publishes a changed state;
the next display evaluation derives that the message is no longer required.
The UI may evaluate on a regular frame clock or be awakened by a coarse signal that published information may have changed. Such a signal only causes a new evaluation. It does not carry a result into the UI or identify a suspended operation to resume.
The name Dependency Graph System describes the dependency semantics; it does not require the runtime to materialise a global bidirectional graph. In particular, writers need not maintain lists of readers or propagate dirty flags through an observer graph.
A writer can publish its changed object and increment a change count. A reader that caches a derived result records references to the inputs it used and the change counts it observed. When that result is demanded again, the reader compares the recorded counts with the current inputs. A mismatch makes the reader reconsider its result. Writers remain independent of readers, and readers perform validation when there is actual demand for their conclusions.
This pull-based arrangement avoids propagating invalidation through parts of an application that may never be observed again. Many writes can occur before a reader next runs; one comparison then discovers that reconsideration is needed. If an intermediate derived value recalculates to the same value, its own change count can remain unchanged and unnecessary downstream calculation can stop there.
async/await
async/await starts with a sequential invocation, allows it to discover an
unavailable result partway through execution, saves its instruction position
and local variables, and resumes it when the result arrives. That solves the
problem of preserving an unfinished invocation without occupying its original
thread.
Predica's application model does not create that problem. An opportunistic evaluation handles pending or partial input and completes. A strict calculation is started only when its required inputs are available. Later UI and query evaluations use their own snapshots and current contexts. There is no unfinished application invocation that ought to be resumed.
Preserving such an invocation would often preserve exactly the wrong state: values calculated from an older snapshot, an earlier viewport, a superseded selection, or demand that no longer exists. The information system should publish newly available facts and allow current readers to decide what those facts now mean.
This model also avoids dividing application functions into synchronous and asynchronous kinds. A derived attribute that navigates to database objects is written like any other derived attribute. Its accesses may cause I/O, shared calculation, or cache validation beneath the application model, while its own evaluation remains ordinary synchronous code.
Applications whose purpose is to view or edit data naturally fit this model. Editors, browsers, email clients, games, database applications, interactive command-line programs, and visualisation tools all repeatedly derive a current presentation from changing and partially available information. Their central operation is a fresh interpretation of current state, not continuation of an old interpretation that once encountered missing data.
The guiding rule is therefore:
Persist information, demand, and dependency evidence—not suspended intention.
Predica application code remains synchronous while database access and derived calculation are effectively asynchronous at every point where information may not yet be available.
A fold reduces the values reached by a path to a single value. Common examples are counting the values, testing whether any value exists, summing numeric values, or collecting the reached values as a set.
The standard library provides common folds:
| Fold | Description |
|---|---|
Σcount |
Counts the number of values in the set. |
Σexists |
True if non-empty. |
Σempty |
True if empty. |
Σsum |
Sums all numeric values in the set. |
Σmin |
Returns the minimum value in the set. |
Σmax |
Returns the maximum value in the set. |
Σmean |
Computes the arithmetic mean of the numeric values. |
Σvariance |
Computes the population variance of the numeric values. |
Σstddev |
Computes the population standard deviation of the numeric values. |
Σproduct |
Computes the product of all numeric values. |
Σconcat |
Concatenates string values in input order. |
Σall |
Returns true if all Boolean values are true. |
Σany |
Returns true if at least one Boolean value is true. |
Σ |
Collects all values into a set. This is primitive syntax, not a named library fold. |
Σgroup |
Groups values by key, producing a map from keys to sets of values. Its full declaration is a planned standard-library item. |
Entries in this table may appear before every supporting declaration form or result type has been
specified elsewhere in the language. Such entries are intentional specification todos, not
permission to remove the fold from the standard vocabulary. A planned entry does not create a
usable std declaration until the standard library chapter gives its declaration, typing, and
definedness rules.
Query folds operate over the observed extension of a unary path frame. Relation-view or m-relation operations such as factorised collapse are implementation techniques, not standard library query folds.
The spelling E.Σname applies the fold named name to the observed extension of E. The
collector E.Σ is primitive syntax and does not look up a fold name.
Standard folds are declared by the standard library component std. Every non-std component has
an implicit dependency on std, and every source file has a standard prelude scope. The prelude
makes common standard folds available unqualified and makes std available as an explicit component
qualifier. Therefore E.Σcount means the fold declaration std.count. The explicit qualified
form E.Σstd.count is also legal and denotes the same fold, but it is not the canonical spelling
used by examples or renderers.
The std qualifier only selects declarations that are actually present in the standard library
component. It is not legal to write E.Σstd.median or E.Σstd.collapse unless a future version
of std declares folds named median or collapse. The unqualified forms E.Σmedian and
E.Σcollapse are likewise not predefined standard folds; they are valid only when a visible
user-defined or imported fold declaration with that local name is in scope.
Fold names from other components may be referenced unqualified only when that local name is not a
standard prelude fold and the visible name is unambiguous. If a third-party component declares a
fold with a standard name such as count, exists, or sum, the declaration is legal, but
the third-party fold must be referenced with a component qualification or with an explicit local
alias that does not use a standard fold name.
using vendor.Stats as stats;
using vendor.Stats { count as weightedCount };
items.Σcount // std.count, even though vendor.Stats also declares count
items.Σstats.count // vendor.Stats.count
items.ΣweightedCount // vendor.Stats.count through an alias
A selected import that would bind a third-party fold to a standard fold name is invalid. For
example, if count and mean are standard folds, then this import is rejected:
using vendor.Stats { count, avg as mean };
The import must use distinct aliases, or the third-party folds must be referenced through a component alias:
using vendor.Stats as stats;
using vendor.Stats { count as statsCount, avg as statsMean };
items.Σcount // std.count
items.Σmean // std.mean
items.Σstats.count // vendor.Stats.count
items.Σstats.avg // vendor.Stats.avg
items.ΣstatsCount // vendor.Stats.count
items.ΣstatsMean // vendor.Stats.avg
If two non-standard components make the same unqualified fold name visible, an unqualified use of that fold name is ambiguous. The program must qualify the fold name with a component alias, or import one or both folds under distinct aliases.
For example, the following query is used to find the parts shipped by exactly one supplier:
s.part.[shipment.supplier.Σcount = 1]
For fold typing, the specification uses Extension<K,T> as metalanguage notation for an
observed extension with element type T and order kind K. It is not source-level Predica
syntax. The order kind is either unordered, for ordinary set-valued path observations, or
ordered, for list-valued observations produced by operators such as orderBy.
If E has observed extension type Extension<K,T>, then a named fold application
E.Σf<...>(args...) resolves the fold name f, instantiates the fold's generic parameters,
checks its constraints, checks each ordinary argument against the corresponding fold parameter
type, and gives the application the fold declaration's result type. The generic argument list and
ordinary argument list are both optional. When the generic argument list is omitted, the generic
parameters are inferred from the folded element type where possible; when it is present, the
explicit generic arguments are checked against the declaration. A qualified fold name such as
E.Σstats.count resolves through the component qualifier before the same instantiation and
argument-checking rules are applied.
An unseeded inline fold E.Σ{ z ; a v ↦ step } is well-typed when z has some accumulator type
A, a is bound as A, v is bound as T, and step has type A. The result type is A.
On an empty input extension, the result is z.
A seeded inline fold E.Σ{ first v ↦ seed ; a v ↦ step } is well-typed when v in the seed
clause has type T, seed has some accumulator type A, a is bound as A, the second v is
bound as T, and step has type A. The result type is A.
Seeded folds and folds whose defined where guard is false have no result. This is the same
no-result denotation used by none: it is not a marker value, and it is not a member of the fold's
result type. In a query, a no-result fold contributes no output value for the surrounding scalar
expression. A context that requires a total scalar value must either use a total fold or establish
the fold's definedness before using the result.
In a declaration of the form fold R name<...>(params...) over input ..., the input binder has
type Extension<K,T>, where T is the instantiated element type and K is the order kind of the
fold operand. Ordinary parameters in params... are bound as values in the body and in any
defined where guard. The body expression must have type R. The body may filter, order, and
fold input; if it uses an order-sensitive operation, the usual order-kind rules still apply.
A fold application is well-typed only when the fold is defined for the operand's order kind.
Order-insensitive folds may be applied to unordered or ordered extensions. Order-sensitive folds,
such as Σconcat and Σlist, require an ordered input extension. Applying an order-sensitive
fold directly to an unordered set extension is a compile-time error; use orderBy first.
For unordered input, the denotation of a fold must be independent of enumeration order and grouping. For an unseeded fold this requires the combining operation to be associative and order-insensitive with respect to the initial value. For a seeded fold it also requires independence from which element is chosen as the seed. Ordered input folds are evaluated in list order and may be order-sensitive.
Predica supports both named fold declarations and inline fold expressions. This unifies standard aggregates and user-defined reductions while keeping the logic-frame denotation primary.
In the general case, a fold consumes the values in an observed unary extension, an initial value z,
and a binary operation ⊕, where ⊕(a,v) combines an accumulated value a with the next value v.
The inline syntax for the general case is:
e.Σ{ z ; a v ↦ ⊕(a,v) }
The binder a names the accumulator and v names the current element from the observed extension.
The element is not a normal function argument; it is supplied by the path on the left of .Σ.
Some folds, such as min and max, need the initial accumulator to be one of the input values
rather than a fixed literal. A seeded general fold uses the first input value as the seed:
e.Σ{ first v ↦ seed(v) ; a v ↦ ⊕(a,v) }
The seeded form has no result on an empty input. For an ordered list input, the seed is the first list element. For unordered input, the seed choice is arbitrary, so the result must not depend on which element is chosen as the seed.
A component may introduce a reusable named fold with a top-level fold declaration:
fold ResultType name<GenericParams> where constraints =
Σ{ initial ; accumulator element ↦ step };
The declaration name is written without the leading Σ. Applying the fold uses the name after
Σ, as in E.Σname. A qualified application may put a component name or component alias before
the fold name, as in E.Σstats.count, when disambiguation is needed. A fold declaration may also
declare ordinary parameters after its generic parameter list:
fold ResultType name<GenericParams>(ParameterType parameter, ...) where constraints =
Σ{ initial ; accumulator element ↦ step };
The folded input is still the observed unary extension on the left-hand side of the fold application. Ordinary parameters are additional scalar values supplied by the call site:
E.Σname(argument, ...)
E.Σname<ExplicitGenericArgs>(argument, ...)
Each argument is typed in the caller's context and must be assignable to the corresponding parameter type after generic instantiation. Inside the declaration body, the parameter names are ordinary local value names. They are distinct from the fold element and accumulator binders, and ordinary lexical scoping applies inside nested expressions.
A named fold may also be declared directly in terms of the whole observed input extension. The
over binder introduces a local name for the extension being folded:
fold ResultType name<GenericParams>(params...) over input where constraints =
expression;
Inside the declaration body, input denotes the same observed extension that appears on the
left-hand side of the fold application. Ordinary parameters, if present, are also in scope in the
body expression. The input may be folded again, filtered, or used in ordinary extension-valued
expressions. This form is useful for folds defined from other folds, such as mean, which is
sum / count, and for parameterized folds such as a shifted mean:
fold Real shiftedMean<Type T>(Real offset) over input where T isa Real =
input.Σmean + offset;
items.ΣshiftedMean(2.5)
A fold parameter list may be empty or absent. When a parameter list is present, a fold
application must supply the same number of ordinary arguments. The over binder name must not
collide with an ordinary parameter name.
If a fold is only defined for some inputs, the declaration may include a defined where guard after
the body expression:
fold Real mean<Type T> over input where T isa Real =
input.Σsum / input.Σcount
defined where input.Σcount > 0;
When the guard is false, the fold has no result for that input. For example, Σmean, Σmin, and
Σmax have no result on an empty input.
The generic constraint ordered(T) requires the type argument T to have a standard order, as
defined by the ordering rules for nominal and structural types.
Fold declarations belong to their declaring component, not to a single global fold namespace. A
component may declare any local fold name, including a name also used by std or by another
component. Name clashes are handled at reference sites: use the unqualified form for standard folds
and unambiguous local folds, and use qualification or aliases for clashing third-party folds.
using vendor.Stats as stats;
items.Σmedian // valid only if a visible declaration named median exists
items.Σstats.median // vendor.Stats.median, if that component declares it
The standard library folds are ordinary declarations in the component std. For example:
// Cardinality folds
fold Integer count<Type T> =
Σ{ 0 ; n v ↦ n + 1 };
fold Boolean exists<Type T> =
Σ{ false ; found v ↦ true };
fold Boolean empty<Type T> =
Σ{ true ; empty v ↦ false };
// Boolean folds
fold Boolean all =
Σ{ true ; result v ↦ result ∧ v };
fold Boolean any =
Σ{ false ; result v ↦ result ∨ v };
// Numeric folds
fold T sum<Type T> where T isa Real =
Σ{ 0 ; total v ↦ total + v };
fold T product<Type T> where T isa Real =
Σ{ 1 ; total v ↦ total * v };
fold Real mean<Type T> over input where T isa Real =
input.Σsum / input.Σcount
defined where input.Σcount > 0;
fold Real variance<Type T> over input where T isa Real =
((input - input.Σmean)^2).Σmean
defined where input.Σcount > 0;
fold Real stddev<Type T> over input where T isa Real =
sqrt(input.Σvariance)
defined where input.Σcount > 0;
// Ordered folds
fold T min<Type T> where ordered(T) =
Σ{ first v ↦ v ; best v ↦ if v < best then v else best };
fold T max<Type T> where ordered(T) =
Σ{ first v ↦ v ; best v ↦ if best < v then v else best };
// Ordered-input folds
fold String concat =
Σ{ "" ; text v ↦ text + v };
The standard cardinality folds satisfy the following laws for finite inputs:
items.Σexists = (items.Σcount > 0)
items.Σempty = (items.Σcount = 0)
items.Σexists = not items.Σempty
These equations are laws, not definitions. Σexists and Σempty are defined directly so an
implementation can recognise their logical meaning and avoid counting every value when that is not
needed.
For folds over unordered sets, the combining function must be associative and order-insensitive to ensure a well-defined result. Folds over an explicitly ordered list may use an order-sensitive combining function.
The Σlist expression collects the values reached by a path into a list. Unlike Σ, which
collects an unordered set, Σlist specifies the order in which the values appear.
For example, the following expression lists part names ordered by part weight:
s.part.Σlist(name order by weight)
The list item may contain several output values by using a tuple expression:
s.part.Σlist((name, weight) order by weight)
When the list item is a tuple expression, the tuple expression controls the order and names of the
fields in each list item. The order by clause controls the order of the list items themselves.
These are independent: a report can display fields in one order while sorting rows by another
expression.
The output may use arbitrary expressions evaluated in the context of each value reached by the path:
s.part.Σlist((name, weight * 1000) order by weight)
Several ordering keys may be supplied. Keys are compared lexicographically from left to right:
s.part.Σlist(name order by weight, name)
Each key may specify a direction. ↑ means ascending and ↓ means descending. If no direction is
written, ascending order is assumed:
s.part.Σlist(name order by weight↓, name↑)
A filter may be supplied after the ordering clause. Only values satisfying the filter condition contribute list items:
s.supplier.Σlist(name order by status if city = "London")
s.supplier.Σlist(
(name, shipment.Σcount)
order by shipment.Σcount↓, name
if city = "London"
)
ListExpression =
"Σlist", "(", ListItem, [ ListOrderClause ], [ ListFilterClause ], ")";
ListItem =
Expression;
ListOrderClause =
"order", "by", ListOrderKey, { ",", ListOrderKey };
ListOrderKey =
Expression, [ ListOrderDirection ];
ListOrderDirection =
"↑"
| "↓";
ListFilterClause =
"if", Expression;
The zero-argument form E.Σlist is shorthand for E.Σlist(this order by this).
It is valid only when the element type of E is ordered.
Let E.Σlist(item order by k₁ d₁, ..., kₙ dₙ if c) be a list expression, where each dᵢ is
either ascending or descending. The expression E is evaluated as a path expression, and each
value reached by E is considered in turn as the current element. In the context of that element:
c is evaluated as a Boolean filter condition, if present.
item is evaluated to produce the list item.
kᵢ is evaluated to produce the ordering key for that item.
Values for which the filter condition is false do not contribute list items. The remaining
items are ordered lexicographically by their key values. For an ascending key, smaller values appear
earlier. For a descending key, larger values appear earlier. If no order by clause is written in
the parenthesised form, the list items are produced without a language-defined ordering.
Every ordering key must be single-valued and ordered. The filter condition must be Boolean. The
result type of the whole expression is List<T>, where T is the type of item.
Σlist is a built-in list-forming operation. It is conceptually fold-like because it consumes the
extension of a path and produces a value, but its item, ordering, and filter clauses are language
syntax evaluated in the element context.
Group aggregation is query-result shaping over logic-frame extensions. A fold consumes the observed values of a unary path frame, and tuple construction chooses which labels are exposed in the result. When the result is displayed as a relation, that relation is the observed extension of the frame, not the primary denotation of the path expression.
The examples in this chapter compare Predica idioms with Tutorial D SUMMARIZE. They should be
read extensionally: the Predica expression and the Tutorial D expression are being compared by the
materialised relation shape they produce.
s.supplier.(id, shipment.qty.Σsum)
has this materialised extension:
{
(id = 1, qty = 1300),
(id = 2, qty = 700),
(id = 3, qty = 200),
(id = 4, qty = 900),
(id = 5, qty = 0)
}
In the tuple constructor expression (id, shipment.qty.Σsum) there are two path tails.
Unless renamed, the attribute name to be used for a path tail is the final identifier
before any aggregation operator. The path expression still denotes a logic frame; the relation
display above is the observed extension of that frame.
Suppose the required result is the number of shipments per supplier. In Tutorial D this can be
written with SUMMARIZE on the shipments relation SP:
SUMMARIZE SP PER SP { S# } ADD COUNT(*) AS NUM_SHIPMENTS
In Predica, the direct object-centric expression is:
s.supplier.(id, shipment.Σcount as num_shipments)
In logic-frame display notation this means:
id=I:Integer, num_shipments=C:Integer
where
∃S:Supplier
s.supplier(S)
∧ S.id(I)
∧ C = |{ SP:Shipment | S.shipment(SP) }|
This expression includes suppliers having no shipments, with count zero. That is often the useful object-centric result, but it is not the same as summarising only the observed shipment extension. To match the Tutorial D expression above, filter to suppliers with at least one shipment:
s.supplier.[shipment.Σ ≠ ∅].(id, shipment.Σcount as num_shipments)
Explicit grouping groups the observed extension by a key expression. The key expression is evaluated for each member of the receiver, and members with the same key become a group.
s.shipment.groupby(supplier.id)
In the relation view, this can be displayed as a grouped result with a nested relation display, much
like Tutorial D's GROUP. The nested display is not a claim that relations are first-class Predica
values. It is a way to show the grouped observed extension.
groupby does not compute aggregates. It restructures the observed extension so later folds can
aggregate each group.
| s.shipment | s.shipment.groupby(supplier.id) |
|---|---|
|
|
|
The following Predica expression:
s.shipment.groupby(supplier.id).(id, group.qty.Σsum as total_qty)
or, with an explicit group label:
s.shipment.group(g)by(supplier.id).(id, g.qty.Σsum as total_qty)
has the same observed extension as the Tutorial D expression:
SUMMARIZE SP PER SP { S# } ADD SUM(QTY) AS TOTAL_QTY
The following examples are a comparison catalogue. The Predica column shows path expressions whose
observed extensions correspond to the Tutorial D results. Examples that collect nested results with
Σ should be read as producing set-valued result terms; any nested relation tables in the display are
relation-view renderings of those values, not a separate relation-valued denotational semantics.
| # | Description | Tutorial D (S, P, SP) | Predica |
|---|---|---|---|
| 1 | Total quantity shipped by supplier |
|
|
| 2 | Total quantity shipped by part |
|
|
| 3 | Number of shipments per supplier |
|
|
| 4 | Number of distinct parts shipped by supplier |
|
|
| 5 | Total quantity and number of shipments per supplier |
|
|
| 6 | Total quantity shipped per supplier–city |
|
|
| 7 | Total quantity and number of suppliers per part |
|
|
| 8 | Total weight of parts shipped per supplier |
|
|
| 9 | List of parts shipped by each supplier |
|
|
| 10 | Number of heavy parts shipped (weight > 15) per supplier |
|
|
The following examples illustrate nested grouping, multi-level grouping, tuple-valued grouping keys, and grouping with filters:
| # | Description | Tutorial D (S, P, SP) | Predica |
|---|---|---|---|
| 11 | Total quantity shipped per supplier per part (two-level grouping) |
|
|
| 12 | For each supplier, aggregates grouped by part (nested grouping) |
|
|
| 13 | For each city, total quantity shipped and number of suppliers |
|
|
| 14 | For each part, list of suppliers and total quantity per supplier |
|
|
| 15 | For each supplier, total quantity of red parts shipped |
|
|
| 16 | For each supplier, number of parts shipped grouped by color |
|
|
| 17 | For each city, list of suppliers and their total shipments |
|
|
| 18 | For each part, total quantity shipped by suppliers in London |
|
|
The following examples illustrate deeply nested grouping, multi-attribute tuple keys, aggregates over aggregates, and multi-level summarization:
| # | Description | Tutorial D (S, P, SP) | Predica |
|---|---|---|---|
| 19 | For each supplier, for each part, total quantity (deep nested grouping) |
|
|
| 20 | For each city, for each supplier, total shipments (multi-level grouping) |
|
|
| 21 | For each supplier, total quantity per part color (grouping by derived attribute) |
|
|
| 22 | For each part, total quantity per supplier city (tuple-valued grouping key) |
|
|
| 23 | For each supplier, average quantity per part (aggregate over aggregates) |
|
|
| 24 | For each city, number of suppliers and total shipments (multi-aggregate grouping) |
|
|
| 25 | For each supplier, summary of heavy vs light parts shipped |
|
|
| 26 | For each part, summary of shipments grouped by supplier status |
|
|
| 27 | For each supplier, total shipments and average shipment size |
|
|
Predica's denotational semantics is logic-first: path expressions denote logic frames, not relations directly. This part develops tuples and relations as a complementary point of view.
The purpose of this part is to connect Predica with relational-model concepts, describe useful extensional readings of logic frames, and record implementation-facing structures that may be used when materialising, indexing, optimising, or explaining query results.
In this part, tuples and relations are unheaded set-theoretic objects. A heading is an external result, type, or materialisation context. This distinction matters: there is only one empty relation, but that same relation may be placed under different headings.
The chapters move from the basic extension view and notation conventions, through headings and projection, to examples, relational-model correspondence, Supplier/Parts translations, materialised accessors, and implementation notes.
This part is explanatory. It does not replace the denotational semantics of path expressions with a relation-valued semantics, and it does not make relations first-class Predica values. The authoritative denotation of a path expression remains a logic frame:
⟨R for F where Ψ⟩
The relation corresponding to the frame is obtained from its extension: assignments to the typed
variables in F that satisfy Ψ are used to evaluate the result part R. It is therefore derived
from the logic denotation, not a replacement for it.
A frame is similar to a set comprehension, but it deliberately keeps more structure than a set.
The triple (R,F,Ψ) records the result terms, the free variables, and the formula that must hold.
The extension of a frame is the set of concrete results obtained by assigning values to the free
variables so that the formula is true, then evaluating the result terms.
Informally:
extension(⟨R for F where Ψ⟩)
= { evaluate(R, σ) | σ assigns F and σ satisfies Ψ }
The extension forgets information. It does not remember the formula Ψ, the free-variable set F,
the provenance of variables, or the particular result terms used in R. Distinct frames may have
the same extension. This is why relations are useful as an extensional view, but are not an adequate
replacement for frames as denotations.
A frame determines a relation, but a relation does not determine the frame that produced it: different choices of witnesses, formulas, and result expressions may have the same extension. The frame also determines a heading for its result; the relation alone does not.
An s-tuple is a finite function from attributes to single values. An attribute in an s-tuple is therefore single-valued.
{ id: 17, city: "London" }
An s-relation is a set of s-tuples. For example, under a heading {id, city}, an
s-relation may be:
{
{ id: 17, city: "London" },
{ id: 17, city: "Athens" },
{ id: 23, city: "Paris" }
}
Here {id, city} names the result attributes. When value domains matter, a heading for this result
is written with types, for example { id: Integer, city: String }.
An m-tuple is a finite function from attributes to sets of possible values. It is a compact representation of a rectangular set of s-tuples.
< id: {17}, city: {"London", "Athens"} >
Under the heading {id, city}, this m-tuple represents the two s-tuples:
{
{ id: 17, city: "London" },
{ id: 17, city: "Athens" }
}
An m-relation is a set of m-tuples. It represents the union of the rectangular regions represented by its m-tuples. M-relations are useful because they can avoid multiplying out Cartesian products until an implementation actually needs the s-relation extension.
It is important not to confuse two related views. A single object may be inspected as a bundle of predicate extensions:
< id: {17}, city: {"London", "Athens"}, status: {} >
This says that the supplier has one id, two cities, and no status. That is useful as an object-centric, set-valued-attribute view.
By contrast, when this chapter writes !_H, the set attached to each attribute is interpreted as a
set of alternatives to be multiplied out into s-tuples under heading H. If a result attribute has
the empty set of alternatives, then there are no s-tuples for that rectangle under a heading that
includes that attribute. If the intended result must still retain the supplier while showing that
there is no status, then the status extension should be returned as a single set-valued term rather
than multiplied out as a single-valued relation attribute.
The expansion operator, written !, is the boundary between an m-relation representation and the
s-relation semantics it represents. Expansion is always relative to a heading. This chapter writes
!_H for expansion under heading H.
For an m-tuple t and attribute a, define:
t↑a = t.a if a ∈ dom(t), otherwise the value domain of a
Then the heading-relative expansion of t is:
t!_H = { s | dom(s) = H and for every a ∈ H, s.a ∈ t↑a }
If some present attribute of t has no values, then t!_H is empty. The expansion of an m-relation
is the union of the expansions of its m-tuples:
M!_H = ⋃ { t!_H | t ∈ M }
Two m-relations are equivalent under heading H when they have the same expansion:
M ≡_H N iff M!_H = N!_H
This equivalence, not equality of representation, is the meaningful equality for m-relations used as implementation structures under that heading.
The governing rule is:
(op_m(M₁, ..., Mₙ))!_H = op_s(M₁!_H, ..., Mₙ!_H)
An m-relation operation is trusted only when it commutes with expansion in this sense. Elegance of an m-relation formula is not enough.
The union of m-relations is ordinary set union of their m-tuples. Under expansion it corresponds to union of the represented s-relations.
(M ∪ N)!_H = M!_H ∪ N!_H
The meet of m-tuples intersects their allowed value sets attribute by attribute. Pairwise meet of m-relations corresponds to s-relation join under expansion:
(M ∧ N)!_H = M!_H ⋈ N!_H
When both expanded s-relations are under the same heading H, join is intersection. In that
fixed-heading setting, meet behaves as conjunction of represented extensions.
Complement is relative to a heading and to the value domains of the attributes in that heading. For an m-tuple, the complement of the rectangle can be represented compactly as the union of the ways an s-tuple can escape the rectangle on at least one attribute.
For example, if each attribute has value domain {0,1}, then:
< a: {0}, b: {0} >!_{a,b}
= { {a: 0, b: 0} }
not < a: {0}, b: {0} >
expands to
{
{a: 0, b: 1},
{a: 1, b: 0},
{a: 1, b: 1}
}
This is not the same as attribute-wise negation, which would produce only the opposite corner
{a:1,b:1}. Semantic complement requires at least one attribute to escape, not every attribute.
Relation complement follows De Morgan's law: the complement of a union of rectangles is the meet of the complements of those rectangles. Difference is meet with complement:
(not M)!_H = universe(H) \ M!_H
(M -- N)!_H = M!_H \ N!_H
M -- N ≡_H M ∧ (not N)
Many different m-relations can represent the same s-relation. Implementations may replace one m-relation with another whenever expansion under the relevant heading is preserved.
A simple normalisation pass may:
For m-tuples, subsumption under heading H can be checked without multiplying out:
t subsumes u under H iff for every a ∈ H, t↑a ⊇ u↑a
Normalisation is not part of the logical denotation. It is an implementation freedom. Its obligation is simply:
normalize(M,H)!_H = M!_H
For a fixed heading H, normalised m-relations behave like a compressed representation of ordinary
s-relations under H, modulo expansion. The laws are extensional laws:
(M ∪ N)!_H = M!_H ∪ N!_H
(M ∧ N)!_H = M!_H ∩ N!_H
(not M)!_H = universe(H) \ M!_H
(M -- N)!_H = M!_H \ N!_H
(M ∪ not M)!_H = universe(H)
(M ∧ not M)!_H = {}
(not not M)!_H = M!_H
not (M ∪ N) ≡_H (not M) ∧ (not N)
not (M ∧ N) ≡_H (not M) ∪ (not N)
These laws should not be read as saying that the m-relation representations on both sides are
identical. They say that the representations expand to the same s-relation under !_H.
This chapter establishes the core correspondence from frames to relations: frame extensions, s-relations, m-relations, heading-relative expansion, and the commutation criterion for m-relation operations. Later chapters spell out notation conventions, headings, projection, examples, relational-model correspondence, materialised accessors, and implementation obligations.
The Relations part uses several kinds of notation. Some examples are Predica source syntax, some are logic-frame display notation, and some are mathematical notation for relation views or implementation structures. This chapter fixes how to read those notations.
| Layer | Used for | Examples |
|---|---|---|
| Predica syntax | Source expressions in the language. | |
| Predica logic display | Human-readable display of logic frames. | |
| Relation-view notation | Mathematical notation for relation extensions and implementation representations. | |
A code block marked language-predica is intended as Predica source syntax. A code block marked
language-predicalogic is usually a display of the logic-frame view; it may also be used for
relation-view laws when the same symbolic highlighting improves readability. The surrounding text,
not the highlighting class alone, determines which layer the notation belongs to. Unmarked code
blocks in this part are usually mathematical or explanatory notation for relations, tuples, headings,
and m-relations.
The following notation is used to describe relation views and does not by itself introduce Predica surface syntax:
{ id: 17, city: "London" };
{ { id: 17 }, { id: 23 } };
{ id: Integer, city: String };
π_A(R);
universe(H);
< id: {17}, city: {"London", "Athens"} >;
M!_H;
M ≡_H N;
M ∧ N, M ∪ N, not M, and M -- N.
These notations are part of the explanatory and implementation-facing relation view. They are useful for specifying what an implementation must preserve, especially through heading-relative expansion, but they should not be read as a promise that Predica has relation literals or first-class relation algebra in surface syntax.
Some symbols also occur in Predica source expressions. For example:
s.supplier -- s.shipment.supplier
is a Predica path expression using path difference. By contrast:
(M -- N)!_H = M!_H \ N!_H
is relation-view notation saying that an m-relation difference operation must expand to ordinary
s-relation difference under heading H.
The same caution applies to union, meet, complement, and projection notation. When the notation appears in a Predica source block, it is source syntax. When it appears in a relation law, it is mathematical notation for the relation view unless the surrounding chapter explicitly says otherwise.
The notations $set, $tup, and $val are treated specially in this part. They name checked,
lossy accessors from materialised relation-shaped results:
R.$set extracts a set from a unary materialised relation;
R.$tup extracts the single tuple from a singleton materialised relation;
R.$val extracts the single value from a singleton unary materialised relation.
This chapter does not decide whether these exact spellings are mandatory Predica surface syntax, library operations, debugger conveniences, or implementation API names. It specifies the relation view behaviour that any such accessor must have if exposed.
M-relation notation is implementation-facing. An implementation may store an m-relation-like structure, or it may use another factorised representation. The notation:
M!_H
M ≡_H N
normalize(M,H)
is used to state correctness obligations. It is not user-facing query syntax unless a later language chapter explicitly adopts it.
Examples in this part sometimes display relation bodies directly:
{
{ id: 17, city: "London" },
{ id: 23, city: "Paris" }
}
These displays are examples of relation extensions. They do not establish relation literals as Predica source syntax. If Predica later adopts relation literals, their syntax and typing rules should be specified in the Syntax and Query parts, not inferred from these explanatory displays.
Tuples and relations do not carry headings. A tuple is a finite mapping from attributes to values,
and a relation is a set of tuples. There is only one empty relation: {}.
A relation type has a heading: the result attributes and the value domain for each attribute. Predica's type system provides this relation-type context, so operations such as complement and heading-relative m-tuple expansion can be defined without making the heading part of the relation itself.
A heading is a finite mapping from attribute labels to value domains:
H = { id: Integer, city: String }
The labels id and city are result labels. They are not logic variables, and
they need not be the names of source predicates. They are the labels that remain after the frame's
result terms have been evaluated.
An s-tuple under heading H assigns exactly one value from the corresponding domain to each label:
{ id: 17, city: "London" }
An s-relation under H is a set of such s-tuples. The heading supplies the relevant result shape:
{} under { id: Integer }
{} under { id: Integer, city: String }
Both displays use the same empty relation {}. They place that relation under different headings,
and therefore give it different possible tuples.
This is analogous to empty sets in a strongly typed language. Predica may allow expressions such as
Set<Integer>{} and Set<Real>{}. The two values are equal as sets, but the expressions have
different static types. Likewise, the empty relation is a single value even when it appears under
different relation types.
The universe for a heading is the Cartesian product of the value domains assigned by the heading. For:
H = { id: Integer, city: String }
the abstract universe is:
universe(H) =
{
{ id: i, city: c }
| i ∈ Integer and c ∈ String
}
Every s-relation under H is a subset of universe(H).
R under H iff R ⊆ universe(H)
The universe is usually not something an implementation should materialise. It may be infinite, and even when it is finite it may be far too large. It is a semantic reference point for the relation view.
Complement is meaningful only after the heading and value domains have been fixed:
not_H(R) = universe(H) \ R
For example, suppose a materialisation context uses the finite domains:
D_id = {17, 23}
D_city = {"London", "Paris"}
H = { id: D_id, city: D_city }
and:
R =
{
{ id: 17, city: "London" }
}
Then:
not_H(R) =
{
{ id: 17, city: "Paris" },
{ id: 23, city: "London" },
{ id: 23, city: "Paris" }
}
If the city domain is extended to include "Athens", the complement changes. The relation body R
has not changed, but the universe against which it is complemented has changed. This is why
complement cannot be defined from the set of result rows alone.
The abstract value domain for a type may be very large or infinite. A materialised query result, test fixture, or implementation plan may instead use a finite active domain for a particular operation. That can be useful and sometimes necessary, but it is a choice of materialisation or implementation context.
The two readings should not be confused:
Integer means the whole integer domain.
When a chapter writes universe(H), the intended domain context must already be fixed. If the text
is discussing implementation algorithms or finite examples, the domain context may be a finite active
domain. If the text is discussing abstract semantics, the domain context is the full value domain
assigned by the heading.
Headings and domains also explain omitted attributes in m-tuples during expansion. If an m-tuple does not mention an attribute from the heading, that attribute is unrestricted by the m-tuple and ranges over the whole domain supplied by the heading:
t↑a = t.a if a ∈ dom(t), otherwise domain_H(a)
For example, under:
H = { id: {17, 23}, city: {"London", "Paris"} }
the m-tuple:
< id: {17} >
expands to:
{
{ id: 17, city: "London" },
{ id: 17, city: "Paris" }
}
The omitted city attribute does not mean that city is absent from the result. It means that
city is present in the heading but unrestricted by that rectangle. If city should not be in the
result, that is a different result heading.
A heading can be read as a type for a relation result: it fixes the attribute names and the value domain for each attribute. This is not an intrinsic type of the relation. A relation may have more than one possible heading, and therefore more than one possible relation type.
This non-unique typing is what makes subtype relationships between relation types possible. The same relation value may satisfy a narrower heading/type and also a wider or more general one. If relations carried a unique heading as part of their identity, those subtype relationships would collapse into equality of differently headed objects rather than inclusion between possible interpretations.
This should also not be confused with the denotational type of a Predica path expression. The path expression still denotes a logic frame. The heading describes one way to materialise that frame's extension.
Projection is the relation view of keeping fewer result attributes. In the logic-first semantics, the primary operation is still on frames: choose which result bindings to expose, and let the hidden result values become hidden witnesses.
Given a frame:
⟨R for F where Ψ⟩
Keeping only attributes A means retaining only the result bindings in R whose attribute names
belong to A. Informally:
project_A(⟨R for F where Ψ⟩) = ⟨R|A for F where Ψ⟩
At the frame level, the condition that defines the path is unchanged. Assignments to variables that are no longer exposed remain possible witnesses for the projected result. When displayed as a formula over only the remaining result variables, those hidden result variables may appear under existential quantifiers. When the extension is materialised as a set, duplicate result tuples collapse.
For example:
id=I:Integer, city=C:String
where ∃S:Supplier (s.supplier(S) ∧ S.id(I) ∧ S.city(C))
projected to id gives:
id=I:Integer
where ∃S:Supplier ∃C:String (s.supplier(S) ∧ S.id(I) ∧ S.city(C))
The city variable is no longer a result, but it is still a witness: the resulting id appears when there exists some city satisfying the original condition.
For an s-relation under heading H, projection to A ⊆ H restricts every tuple to the
attributes in A:
π_A(S) = { s|A | s ∈ S }
For example:
S =
{
{ id: 17, city: "London" },
{ id: 17, city: "Athens" },
{ id: 23, city: "Paris" }
}
π_{id}(S) =
{
{ id: 17 },
{ id: 23 }
}
The two rows for supplier 17 collapse because s-relations are sets.
For m-relations, projection must still be judged by the s-relation semantics. If M is interpreted
under heading H, then projection to A ⊆ H is correct only when:
π_A^m(M)!_A = π_A(M!_H)
A tempting definition is to project every m-tuple attribute-wise:
π_A^naive(M) = { t|A | t ∈ M }
This is correct only under a precondition: no m-tuple may be unsatisfiable because of an attribute that projection will drop.
Consider the m-relation:
M = { < id: {17}, status: {} > }
Under heading {id, status}, this m-relation has empty expansion:
M!_{id,status} = {}
Therefore projecting the s-relation extension gives:
π_{id}(M!_{id,status}) = {}
But naive m-relation projection gives:
π_{id}^naive(M) = { < id: {17} > }
π_{id}^naive(M)!_{id} =
{
{ id: 17 }
}
The naive projection has invented a result that was not present in the s-relation extension. It dropped the very attribute that made the rectangle unsatisfiable.
The factorised projection must discard unsatisfiable m-tuples before dropping attributes:
π_A^m(M) = { t|A | t ∈ M and t!_H ≠ {} }
Equivalently, for the finite m-tuples used in this part, discard any m-tuple whose present attributes include an empty value set. Then:
π_A^m(M)!_A = π_A(M!_H)
This is the same discipline used throughout the Relations part. The m-relation operation is accepted only because it preserves the heading-relative s-relation semantics.
Projection should not be used to smuggle empty predicate extensions into rows. If the intended result is one row per supplier with the whole status extension recorded, then the frame must expose a set-valued result term such as:
statuses=TS:Set<Integer>
where TS = { T:Integer | S.status(T) }
That is different from exposing status=T:Integer and then projecting away failed status
assignments. Projection keeps fewer attributes from an existing extension; it does not turn an
unsatisfied single-valued result into an empty set-valued result.
This chapter uses a small supplier example to show why both s-relations and m-relations are useful views of Predica data. Its purpose is to make the extension view concrete.
Consider a supplier type with attributes having different cardinality constraints:
entity Supplier
{
Integer id;
String city*;
Integer status?;
}
entity ShipmentsSystem
{
Supplier supplier*;
}
Suppose a particular shipments system contains two suppliers. The first supplier has id 17,
is located in London and Athens, and has no status. The second supplier has id 23, is located
in Paris, and has status 20.
As unary predicate extensions on each supplier, this can be displayed compactly as one m-tuple per supplier:
{
< id: {17}, city: {"London", "Athens"}, status: {} >,
< id: {23}, city: {"Paris"}, status: {20} >
}
This is an object-centric view. Each supplier remains one represented object, and each attribute records the extension of the corresponding unary predicate for that supplier.
The path expression s.supplier.(id, city) may be displayed as a logic frame whose result attributes
are id and city:
id=I:Integer, city=C:String
where ∃S:Supplier (s.supplier(S) ∧ S.id(I) ∧ S.city(C))
Under the heading {id, city}, the object-centric m-tuples above project to the m-relation:
M =
{
< id: {17}, city: {"London", "Athens"} >,
< id: {23}, city: {"Paris"} >
}
Multiplying out gives the s-relation extension:
M!_{id,city} =
{
{ id: 17, city: "London" },
{ id: 17, city: "Athens" },
{ id: 23, city: "Paris" }
}
This is where an m-relation can be a useful implementation representation. It records two supplier rectangles rather than three id-city rows, and the saving can be much larger when several attributes have many values.
Now expose id, city, and status as single-valued relation attributes:
id=I:Integer, city=C:String, status=T:Integer
where ∃S:Supplier (s.supplier(S) ∧ S.id(I) ∧ S.city(C) ∧ S.status(T))
The corresponding m-relation is:
N =
{
< id: {17}, city: {"London", "Athens"}, status: {} >,
< id: {23}, city: {"Paris"}, status: {20} >
}
Under the heading {id, city, status}, the first m-tuple expands to no s-tuples because status
has no values. Therefore:
N!_{id,city,status} =
{
{ id: 23, city: "Paris", status: 20 }
}
This is the correct s-relation extension of the frame above. The frame asks for assignments to
I, C, and T such that S.status(T) holds. For supplier 17 no such T exists.
Sometimes the desired result is not "one row for each status value", but "one row per supplier,
where the status attribute records the whole status extension". That is a different result shape.
It must return a set-valued term rather than multiply out status as a single-valued relation
attribute.
id=I:Integer, cities=CS:Set<String>, statuses=TS:Set<Integer>
where ∃S:Supplier (
s.supplier(S)
∧ S.id(I)
∧ CS = { C:String | S.city(C) }
∧ TS = { T:Integer | S.status(T) }
)
For the sample data, this gives a supplier-preserving s-relation:
{
{ id: 17, cities: {"London", "Athens"}, statuses: {} },
{ id: 23, cities: {"Paris"}, statuses: {20} }
}
The rule of thumb is therefore:
The relational-model view is excellent for the s-relation extension. The m-relation view is useful because Predica objects naturally carry many unary predicate extensions with different cardinality constraints. A single m-tuple can preserve that structure without immediately forming every Cartesian combination.
The m-relation is still trusted only through expansion. It is a correct representation of the s-relation extension when its heading-relative expansion is the intended s-relation.
Predica can recover ordinary relational-model relations as extensions of logic frames. This correspondence is useful, but it is deliberately one-way: it explains how an RM-style relation can be obtained from a Predica frame, not that Predica's primary denotations are RM relations.
The governing picture is:
logic frame --heading H--> s-relation extension under H
The relational model lives on the right-hand side of this picture. Predica's logic semantics lives on the left-hand side.
| Relational model idea | Predica relation view |
|---|---|
| Heading | A result heading: the selected result labels of a frame. |
| Tuple | An s-tuple: a finite mapping from result attributes to single values. |
| Relation body | The set of s-tuples obtained by satisfying the frame condition and evaluating the result terms. |
| Projection | Keeping fewer result labels; hidden values remain witnesses in the frame. |
| Restriction | Strengthening the frame condition. |
| Rename | Choosing different result labels, usually with as in tuple-forming path expressions. |
| Join | Combining requirements in one frame, with shared witnesses or explicit equality constraints. |
| Union and difference | Set operations on materialised extensions under the same heading. |
The table is extensional. It describes the s-relation corresponding to a frame. It does not say that the frame has lost its formula, witnesses, path provenance, or result terms.
In the supplier-and-parts example, the RM relation S is conventionally presented with heading
{S#, SNAME, STATUS, CITY}. The corresponding Predica expression can be written as a supplier
result:
s.supplier.(id, name, status, city)
In frame notation this is displayed as:
id=I:Integer, name=N:String, status=T:Integer, city=C:String
where ∃S:Supplier (
s.supplier(S)
∧ S.id(I)
∧ S.name(N)
∧ S.status(T)
∧ S.city(C)
)
If each of id, name, status, and city is single-valued for each supplier, the corresponding
extension has one s-tuple for each supplier. Under an RM-style heading, those s-tuples are just the
rows of S, modulo attribute naming.
If some selected attribute is optional or multi-valued, the correspondence still goes through, but the resulting extension follows the frame. Multi-valued attributes multiply out. Optional attributes with no value contribute no rows when they are exposed as single-valued relation attributes. To preserve an empty predicate extension as a value, expose a set-valued result term instead.
The RM expression:
(S WHERE CITY = 'London'){SNAME}
is recovered by strengthening the supplier frame with a city condition, then keeping only the name:
s.supplier.[city = "London"].name
Displayed as a frame:
name=N:String
where ∃S:Supplier ∃C:String (
s.supplier(S)
∧ S.city(C)
∧ C = "London"
∧ S.name(N)
)
In the relation view, restriction corresponds to adding the predicate C = "London", and projection
corresponds to keeping only name. The city is no longer in the heading, but it remains a witness
that justifies the resulting supplier name.
The RM expression:
(SP JOIN S JOIN P){SNAME, PNAME, QTY}
is recovered in Predica by navigating through the shipment witnesses:
s.shipment.(
supplier.name as sname,
part.name as pname,
qty)
The frame exposes the supplier name, part name, and quantity while keeping the shipment, supplier, and part values as witnesses:
sname=SN:String, pname=PN:String, qty=Q:Integer
where ∃X:Shipment ∃S:Supplier ∃P:Part (
s.shipment(X)
∧ X.supplier(S)
∧ X.part(P)
∧ S.name(SN)
∧ P.name(PN)
∧ X.qty(Q)
)
In an RM encoding, the same result is obtained by joining SP to S and P using shared key
values. In Predica, the shared supplier and part witnesses are already in the frame. The join is
therefore not a primitive semantic operation; it is what the corresponding extension looks like after the
frame's constraints are satisfied.
RM queries often need rename because relation headings are flat. For example, comparing supplier cities with part cities requires two different attribute names in the RM:
( (S RENAME CITY AS SCITY) TIMES (P RENAME CITY AS PCITY) )
WHERE SCITY > PCITY
In Predica, the comparison can be written against paths before choosing a flat result heading:
s.(supplier, part).[supplier.city > part.city]
If the two cities are to be exposed as relation attributes, the frame chooses distinct result labels:
s.(supplier, part).[supplier.city > part.city].(
supplier.city as scity,
part.city as pcity)
The result labels scity and pcity belong to the frame result. The paths supplier.city and
part.city belong to the frame that produces the extension.
The correspondence is exact only for the chosen result heading. Two different frames can have the same s-relation, so equality of RM-style extensions is not equality of Predica denotations. The frame still records the result terms, free variables, and formula that produced the extension.
The correspondence is also heading-relative. The same frame can be materialised under different headings, producing different s-relations. Complement and universe are additionally domain-relative: an implementation must know which value domains are being used for the heading.
Finally, an implementation may avoid materialising the s-relation at all. It may use symbolic frames, indexes, m-relations, or other factorised structures. Such representations are valid only to the extent that their externally visible behaviour agrees with the frame semantics. For m-relations, the specific test is heading-relative expansion:
M is valid under H iff M!_H is the intended s-relation extension
A historical table comparing many Supplier/Parts queries in Tutorial D and Predica is retained under this part's redundant notes. This chapter does not repeat that catalogue. Instead it takes a few representative cases and reads them through the relation view developed in this part.
The recurring pattern is:
Predica path expression --logic frame--> corresponding s-relation
The familiar RM operations appear on the corresponding relation. The logic frame remains the primary denotation, as in the correspondence chapter.
The RM relvars S, P, and SP can be recovered by choosing RM-style result headings for Predica
frames. For suppliers:
s.supplier.(id as s_id, name as sname, status, city)
corresponds to:
s_id=SI:Integer, sname=SN:String, status=ST:Integer, city=C:String
where ∃S:Supplier (
s.supplier(S)
∧ S.id(SI)
∧ S.name(SN)
∧ S.status(ST)
∧ S.city(C)
)
For parts:
s.part.(id as p_id, name as pname, color, weight, city)
and for shipments:
s.shipment.(
supplier.id as s_id,
part.id as p_id,
qty)
The shipment frame keeps the shipment value as a witness, while exposing the supplier id, part id, and quantity:
s_id=SI:Integer, p_id=PI:Integer, qty=Q:Integer
where ∃X:Shipment ∃S:Supplier ∃P:Part (
s.shipment(X)
∧ X.supplier(S)
∧ X.part(P)
∧ S.id(SI)
∧ P.id(PI)
∧ X.qty(Q)
)
Under these result headings, the s-relation extensions have the same row shape as the RM relations
S, P, and SP, modulo label spelling.
The RM query:
(S WHERE CITY = 'London'){SNAME}
is recovered by:
s.supplier.[city = "London"].name
The corresponding relation has heading {name: String}:
name=N:String
where ∃S:Supplier ∃C:String (
s.supplier(S)
∧ S.city(C)
∧ C = "London"
∧ S.name(N)
)
Restriction strengthens the frame condition. Projection exposes fewer result labels. The hidden supplier and city remain witnesses for the resulting supplier name.
The RM query:
(SP JOIN S JOIN P){SNAME, PNAME, QTY}
is recovered by:
s.shipment.(
supplier.name as sname,
part.name as pname,
qty)
The frame has one shipment witness and navigates to its supplier and part witnesses:
sname=SN:String, pname=PN:String, qty=Q:Integer
where ∃X:Shipment ∃S:Supplier ∃P:Part (
s.shipment(X)
∧ X.supplier(S)
∧ X.part(P)
∧ S.name(SN)
∧ P.name(PN)
∧ X.qty(Q)
)
In the RM view, this appears as a join because SP, S, and P are separate unnested relations. In
the Predica frame, the shared witnesses are already present in the navigation path. The corresponding
s-relation has the joined row shape.
The RM query for suppliers who ship nothing can be written:
S MINUS (SP {S#} JOIN S)
The corresponding Predica expression is:
s.supplier -- s.shipment.supplier
The first operand returns all supplier values. The second returns supplier values that are witnessed by some shipment. Difference is then ordinary set difference on the common heading:
{ supplier: Supplier }
If the result is later exposed as supplier rows, the surviving supplier witnesses can be expanded with:
(...).(id as s_id, name as sname, status, city)
The important point is that anti-join is not a primary Predica denotation here. It is the relation view of subtracting one extension from another under the same heading.
The query "suppliers who supply all red parts" is a standard RM division-style query:
S WHERE NOT EXISTS (
(P WHERE COLOR = 'Red')
WHERE NOT EXISTS (
SP WHERE SP.S# = S.S#
AND SP.P# = P.P#
)
)
In Predica:
s.part.[
color = Color.red
]⇒(shipment.supplier)
The ordinary navigation . is existential path composition. The universal navigation ⇒ is
universal path composition: the left-hand witnesses are protected while the right-hand path is
translated, then the left-hand witnesses are universally closed. In this query, the selected red
parts are the protected witnesses, and the result is the suppliers for which the shipment.supplier
path succeeds for every selected red part.
In the relation view, the corresponding extension is an s-relation under a unary heading of supplier values. If an RM-shaped result is required, that extension can then be expanded through supplier attributes.
The RM query:
S WHERE STATUS >= ALL S{STATUS}
is recovered by:
s.supplier.[
status ≥ s.supplier⇒status
]
The right-hand side uses universal path composition to express the ALL side of the comparison.
In frame terms, the supplier witnesses from s.supplier are universally closed in the comparison
condition. The frame keeps the candidate supplier on the left as a witness and adds the condition
that its status is at least every resulting supplier status.
The ANY-style query:
P WHERE WEIGHT > ANY (
(SP WHERE S# = 'S1' JOIN P){WEIGHT}
)
is recovered by:
s.part.[
weight > s.shipment.[
supplier.id = 1
].part.weight
]
Here ordinary navigation on the right gives the existential, ANY-style side of the comparison:
weights reachable from S1's shipments. The filter keeps each candidate part whose weight satisfies
the comparison against that resulting set of weights.
Most Supplier/Parts examples use single-valued attributes, so the corresponding s-relation rows are close to the familiar RM rows. M-relations become more important when attributes have mixed cardinalities, or when intermediate results would otherwise multiply out large Cartesian products.
For example, if suppliers can have many cities and optional statuses, then an implementation may keep an intermediate supplier result as rectangles:
{
< id: {1}, city: {"London"}, status: {20} >,
< id: {5}, city: {"Athens"}, status: {30} >
}
Such a representation is trusted only through heading-relative expansion. It is useful when it avoids unnecessary multiplication, but every externally visible result must agree with the s-relation extension for the chosen heading.
Once a frame has been materialised as an s-relation, it is sometimes useful to extract an ordinary set, tuple, or value from that relation-shaped result. The accessors in this chapter are conveniences for that situation.
This chapter writes the accessors as $set, $tup, and $val. Those spellings are accessor
conventions in the relation view; if they are exposed as Predica surface syntax or library operations,
the shape requirements below are the behaviour they must preserve.
Each accessor deliberately forgets part of the relation structure. Therefore each accessor is defined only when the materialised relation has the required shape.
| Accessor | Required shape | Result | Structure forgotten |
|---|---|---|---|
R.$set |
R is under a unary heading. |
The set of values in the single attribute. | The attribute label and tuple wrappers. |
R.$tup |
R has exactly one tuple. |
That s-tuple. | The outer relation wrapper. |
R.$val |
R is under a unary heading and has exactly one tuple. |
The single value in that tuple. | The relation, tuple, and attribute wrappers. |
If the required shape does not hold, the accessor is undefined for that materialised result. An implementation may report this as a static error when the shape is known statically, or as a runtime error when the shape depends on the data.
Let R be a materialised s-relation under a unary heading:
H = { city: String }
R =
{
{ city: "London" },
{ city: "Paris" },
{ city: "Athens" }
}
Then:
R.$set = { "London", "Paris", "Athens" }
In general, for H = { a: T }:
R.$set = { s.a | s ∈ R }
The result is a set of values of type T. The attribute name a is discarded. This is why $set
is defined only for unary headings. For a non-unary heading there is no
single attribute whose values should be extracted.
Let R be a materialised s-relation with exactly one tuple:
H = { id: Integer, city: String }
R =
{
{ id: 17, city: "London" }
}
Then:
R.$tup = { id: 17, city: "London" }
The heading need not be unary. The only requirement is that the relation body contains exactly one
s-tuple. If R is empty or contains more than one tuple, $tup is undefined.
The $val accessor combines the requirements of $set and $tup. The materialised relation must
be under a unary heading and contain exactly one tuple:
H = { status: Integer }
R =
{
{ status: 20 }
}
Then:
R.$val = 20
Equivalently, if H = { a: T } and R = { { a: v } }, then:
R.$val = v
If R is empty, has more than one tuple, or is not under a unary heading, $val is
undefined.
The word "value" in $val refers to the single value stored in the single result attribute. That
value may itself be a set-valued value.
For example:
H = { statuses: Set<Integer> }
R =
{
{ statuses: {} }
}
Then:
R.$val = {}
This is not the same situation as the empty relation under the unary heading
status: Integer:
H = { status: Integer }
R = {}
In that case R.$set is {}, but R.$val is undefined because there is no singleton tuple. The
difference is exactly the difference between returning a set-valued result term and returning a
single-valued relation attribute whose predicate has no witness.
These accessors are defined on the s-relation view. If an implementation stores a result as an m-relation, it may implement the accessor without fully multiplying out the result, but the answer must be the same as applying the accessor after heading-relative expansion.
accessor(M under H) is defined iff accessor(M!_H) is defined
and then accessor(M under H) = accessor(M!_H)
This is the same trust boundary used for the rest of the m-relation operations. The implementation may keep the compact representation only when the materialised result agrees with the s-relation semantics.
The accessors are useful precisely because relation-shaped results are often more structure than the caller wants. A query may naturally produce a unary relation when the caller wants a set, a singleton relation when the caller wants a tuple, or a singleton unary relation when the caller wants a value.
The conversion should be explicit because it is lossy. $set drops attribute labels, $tup drops
relation cardinality, and $val drops both. Keeping these operations explicit protects the
logic-frame denotation while still giving implementors and users a compact way to consume
materialised results.
This chapter collects implementation-facing consequences of the relation view. It is informative. The semantic authority remains the logic-frame denotation of path expressions. Relations, s-tuples, m-tuples, and m-relations are useful ways to explain, materialise, index, and optimise corresponding extensions of those frames.
An implementation is not required to materialise relation extensions. For a query result it may:
These choices are physical choices. The obligation is that externally visible results agree with the logic-frame semantics and, when a relation view is exposed, with the relevant s-relation extension under its heading.
An m-relation is a valid implementation representation only relative to a heading and domain context. The representation is correct when heading-relative expansion gives the intended s-relation extension:
M represents S under H iff M!_H = S
Equality of m-relation data structures is therefore not the important equality. Two different m-relations may be equally valid when they have the same heading-relative expansion:
M ≡_H N iff M!_H = N!_H
This is the same boundary used for accessors. If a materialised accessor is implemented directly on an m-relation, it must be defined exactly when the accessor on the expanded s-relation is defined, and it must return the same result.
An m-relation operation is valid when it commutes with the corresponding s-relation operation under heading-relative expansion:
op_m(M1, ..., Mn)!_H = op_s(M1!_H, ..., Mn!_H)
This form is the test for proposed implementation operations. The operations may be chosen for performance, compactness, streaming, or index use, but their externally visible result is judged by expansion.
| Implementation operation | Required extensional behaviour |
|---|---|
| Union of m-relations | |
| Pairwise meet | |
| Complement | |
| Difference | |
| Projection | |
| Normalisation | |
Normalisation is an implementation freedom. It may remove redundancy, simplify rectangles, and improve later operations. A normalisation pass may, for example:
The implementation does not need a unique normal form. It only needs preservation of the extension under the chosen heading. If two normalisers produce different m-relation structures with the same expansion, they are equivalent for this purpose.
The finite checker and the examples in this part highlight several pitfalls.
The development harness:
implementation/predica/python/m-relation/test.py
checks candidate laws over small finite universes. It is not a proof system and is not the specification. Its purpose is to expose bad candidate operations quickly and to build confidence before committing a law to prose or proof.
The checker has three modes:
fast: a small random sample intended for development.
deep: a larger random sample intended for higher confidence.
exhaustive: enumeration of finite cases up to a chosen m-relation size.
For example:
python3 implementation/predica/python/m-relation/test.py --mode fast
python3 implementation/predica/python/m-relation/test.py --mode deep
python3 implementation/predica/python/m-relation/test.py --mode exhaustive --max-relation-size 2
The useful tested ideas include:
The checker also records expected counterexamples for tempting definitions, including attribute-wise negation as complement, pairwise generalised union as relation union, and algebraic reasoning over partial expansion where a fixed heading is required.
This chapter should be read as guidance for preserving the relation view efficiently. It does not require a particular storage structure, query engine, normal form, or finite-domain strategy. It does require that any externally visible relation result, relation accessor, or implementation-facing m-relation operation agree with the logic-frame semantics through the appropriate heading-relative extension.
This part collects the design of updates in Predica. The view update problem is included here for now: it is one important case of the broader question of how an update expression determines a well-defined change to the underlying predicates.
The Updates part also specifies the statement language: local variables, assignment, control flow, printing, procedure declarations, and block-bodied functions. A block-bodied function remains pure when its body uses only local state and calls only pure functions. Side-effecting reusable operations are written as procedures.
Parameter declarations state access and ownership directly. Plain parameters
are read-only, & parameters grant a procedure temporary exclusive
access to a caller variable, and ^ parameters are owned by the
callee. For & and an explicit ^, the caller uses an
expression that denotes a location. The former temporarily grants access
to it; the latter moves its value and leaves it uninitialised.
Runtime failures are represented as exceptions and may be handled by statement-level try/catch.
Static errors are rejected before execution and cannot be caught.
Predica distinguishes local program-state updates from database-state updates. A local update writes only program locations, such as variables or locations inside values held by variables. It does not require a transaction.
A database-state update writes locations that belong to persistent database state, such as mutable attributes of entities in a database partition. Database-state updates are performed inside transactions. The syntax for opening, committing, and rolling back a transaction depends on the host language, but a database-state update requires an open transaction on a single data partition. Constructing an entity value is still an expression; the database-state update is the statement that adds or changes that value at a base persistent location.
The syntax for transaction control depends on the host language. Transactions apply to database-state updates, not to ordinary local program-state updates. Semantically, a transaction is opened on a single data partition before database-state update statements are performed. Those statements transform the current transaction state for that partition.
A database-state update statement requires an open transaction. A statement whose target would update data outside the open transaction's partition is not a valid partition-local update.
The transaction state need not be a disk-resident copy of the partition. An implementation may represent a partition as in-memory objects, mutate those objects during the transaction, and record which objects became dirty. Dirty-object sets, redo logs, log-structured stores, snapshots, and copy-on-write pages are physical strategies. They are valid when reads observe the specified pre-state and post-state, commit accepts exactly the final database state, and recovery preserves transaction atomicity.
A local program-state update writes only program locations. It does not require an open transaction and is not committed or rolled back by the database transaction mechanism. It is still subject to the static update-formation rules: the target must be assignable, and the replacement expression must be statically compatible with the target's element type and cardinality. Declared constraints are not required to hold for the resulting ordinary program state.
Constraints apply at the final transaction boundary, not after each update statement. A transaction may contain intermediate states that violate constraints, provided the transaction's final state satisfies the applicable constraints when the transaction is committed.
Per-statement constraint checking would be too strong for construction of shared identities. For example, creating a shipment that refers to an existing supplier and part may require several mutations before the supplier, part, and shipment predicates are again mutually consistent. The transaction boundary, not each individual mutation, is the point where the constrained database state is accepted or rejected.
The final state at commit is a constraint boundary. A program that attempts to commit a state violating an applicable constraint is illegal. Predica does not prescribe a runtime check, exception, trap, rollback, or other execution behaviour for that violation. See Constraint-Violating Updates.
A DBMS that supports failure recovery must ensure transactions are atomic. After recovery from a failure, a transaction's effects are either all present or all absent.
Predica transactions do not use distributed atomic commit to enforce invariants across unrelated partitions. If an invariant must be enforced transactionally, the data needed to decide that invariant belongs in a single partition.
Relationships that cross partition boundaries are therefore maintained by read-only models, replication, projections, asynchronous derivations, or messages between partition owners. Those mechanisms may detect and report cross-partition facts, but they are not a substitute for a single transaction that atomically updates multiple partitions.
Strict two-phase locking is not the preferred concurrency model for a Predica DBMS. A system whose normal operation depends on deadlock detection, victim selection, and rollback has made rollback part of ordinary concurrency control rather than an exceptional recovery mechanism.
A Predica DBMS is expected to favour architectures where ownership and concurrency are made explicit by partitioning. A database may be embedded within a process; its data may be divided into partitions; and each partition may be protected by its own exclusive lock or by an actor that serialises all operations for that partition.
Transactions are scoped to single partitions. A transaction that spans multiple partitions inside one process is a design to avoid. Distributed transactions are a particularly poor fit for this model.
Concurrency between partitions is then handled by design patterns that avoid global lock cycles: replication, master-slave ownership, operational transformation, MVCC, snapshot isolation, and message passing between partition actors. These techniques do not remove the requirement for transactional atomicity inside each partition; they shape how a DBMS obtains concurrency without making cross-partition atomic commit the normal mechanism.
The single-partition transaction rule is compatible with stronger global views because those views are read-only. The general pattern is described in read-only models over weakly constrained data.
A general implementation strategy is to keep the updatable data in partitions with deliberately weak constraints. Those partitions accept local writes quickly, support high ingestion rates, and allow concurrency patterns such as independent partition ownership, operational transformation, replication, and asynchronous merge.
Read-only models sit above that mutable state. They may be maintained asynchronously, or they may be computed by read-only queries over MVCC or snapshot-isolated views that span many partitions, provided each query sees a stable read view. Querying many partitions is not the same as updating many partitions: writes remain partition-local even when reads combine replicated, projected, or snapshot information from several partitions.
The essential mathematical point is that a read-only model may be a non-injective function of the mutable data. Different mutable states may map to the same read-only state. That non-injectivity is what allows the read-only model to impose idealised, complex constraints without requiring the mutable partitions to reject data at ingestion time.
This fits naturally with components. The updatable partition is one component. A separate component defines the pristine read-only model. The two components may use the same ordinary type names because components are namespaces.
For example, the updatable component may record customers and orders with weak constraints:
component SalesMutable;
entity Customer
{
Text email;
Text name?;
}
entity Order
{
Text customerEmail;
Text orderNumber;
}
entity Sales
{
Customer customer*;
Order order*;
}
The read-only component then imports the mutable component and exposes its own Sales, Customer,
and Order types. These are not the same types: they are declarations in the read-only component.
The root object has a base attribute that points at a snapshot of the mutable root:
component SalesPristine;
using SalesMutable as mutable;
entity Sales
{
snapshot mutable.Sales source;
→ Customer customer* = source.customer.[
let c=this in source.customer.[email = c.email].Σcount = 1
].(
Customer { email = email; name = name }
);
}
The snapshot qualifier is proposed notation rather than ordinary base-attribute syntax. It means
that source is supplied by the runtime as an MVCC or snapshot-isolated view of another partition or
component root. It is a base attribute of the read-only root object, but it is read-only in this
context: Predica expressions can navigate through it, but updates cannot be performed through it.
The derived customer attribute is a non-injective function from the snapshot to the pristine model.
If two mutable customers have the same email, neither need be projected into the read-only model. The
conflicting facts remain in the mutable component, but readers of SalesPristine.Sales.customer see a
model satisfying the intended key constraint.
The same pattern can impose a functional dependency. Suppose the pristine model wants email to determine name. The mutable component may still record conflicting names, while the read-only component keeps only customers whose names are unambiguous:
entity Sales
{
snapshot mutable.Sales source;
→ Customer customer* = source.customer.[
let c=this in source.customer.[email = c.email].name.Σcount <= 1
].(
Customer { email = email; name = name }
);
}
The read-only schema can also drill down into dirty data and rebuild structure using its own types. For example, it can expose pristine orders only when the referenced customer survived the customer projection:
entity Customer
{
Text email;
Text name?;
}
entity Order
{
Customer customer;
Text orderNumber;
}
entity Sales
{
snapshot mutable.Sales source;
→ Customer customer* = source.customer.[
let c=this in source.customer.[email = c.email].Σcount = 1
].(
Customer { email = email; name = name }
);
→ Order order* = source.order.[customerEmail ∈ customer.email.Σ].(
Order {
customer = customer.[email = customerEmail];
orderNumber = orderNumber;
}
);
}
This derived order attribute imposes an inclusion dependency by throwing out orders whose
customerEmail is not present in the pristine customer projection. It also changes shape: mutable
orders refer to customers by email text, while pristine orders refer to a pristine Customer entity.
The read-only component is therefore not merely filtering rows. It is interpreting weakly constrained
data and rebuilding a more useful structure.
The cross-partition and asynchronous boundary is therefore a runtime contract around snapshot attributes. A snapshot attribute may be backed by an MVCC snapshot, by a replicated projection, or by an asynchronous feed that materialises a stable read view. The Predica definitions describe the pure read-only function from that stable input to the pristine model. The implementation decides whether the function is recomputed on demand, incrementally maintained, or served from a stale but consistent projection.
This chapter therefore points to a likely language extension: Predica needs a way to declare that an attribute of a read-only root is a snapshot of another component root, possibly from another partition. Such an attribute has a base value supplied by the runtime, but it is read-only to Predica update statements. Derived attributes in the read-only component can then define non-injective functions over that snapshot, throwing away duplicates, dangling references, ambiguous facts, or work in progress.
This is why the pattern belongs with updates even though the model itself is read-only. Weak constraints on updatable data make updates local, fast, and mergeable. Read-only non-injective functions recover the stronger, idealised models needed by readers, reports, validation workflows, security views, and downstream services.
var status = 0;
status := 25;
var Integer id* = {};
id := s.supplier.id;
The declaration var status = 0; creates a mutable program location named
status. Its element type is Integer, its cardinality is one, and status :=
25; replaces the contents of that location with the single value 25.
The declaration var Integer id* = {}; creates a mutable program location whose
element type is Integer and whose cardinality is zero or more. The expression
s.supplier.id may reach many values, so assigning it to id is valid only
because the target location permits many values.
This is the same unary-predicate reading used for attributes. The name id is
singular because it names one predicate location. The * constrains the
extension of that location; it does not change the element type to a set type.
Thus var Integer id* = {}; should be read as “the id extension contains zero
or more Integer values,” not as “id is a Set<Integer> value.”
var id1 = 0;
id1 := s.supplier.id; // static cardinality constraint check failure
var Integer id2* = {};
id2 := s.supplier.id; // ok
The first assignment is rejected statically. The declaration var id1 = 0;
gives id1 cardinality one, while the path s.supplier.id has a static
cardinality that may be many. This is a static cardinality constraint check
failure, not a static type check failure: both sides have element type Integer.
No query is executed to discover this error.
A program variable has two denotations. In an expression context, the variable denotes the value or extension obtained by reading its location. In an update target context, the same variable denotes the location itself. The assignment operator writes a replacement extension to that location after checking the element type and cardinality constraint.
var B b* = { A { x = 1 }, A { x = 2 } };
The cardinality qualifier belongs to the variable location, not to the element
type. In var B b* = ...;, each member assigned to b must have element type
B; the * says the location may contain zero or more such members.
The variable's element type is still just B.
The keyword var still introduces a statically typed variable. The element
type may be inferred from the initializer, as in var status = 0;, or supplied
explicitly, as in var Integer id* = {};. An empty initializer does not by
itself determine an element type, so an explicit type annotation is required
unless another expected type is available from the surrounding context.
A typed variable may be declared without an initializer:
var String message;
Such a variable is uninitialised. A declaration with neither an
explicit type nor an initializer, such as var message;, is a static
error because its type cannot be inferred. The legality of declarations,
reads, and partial initialisation is specified in
Uninitialised Locations.
Updating a program variable is a local program-state update. It does not require a database transaction and is not committed or rolled back by the database transaction mechanism.
A location is either initialised or uninitialised. An initialised location contains a value or extension satisfying its type and cardinality constraint. An uninitialised location has no current value or extension. Uninitialised is a state of a location, not a value in the carrier of its declared type.
var String message;
This declaration creates an uninitialised location. The declaration is legal: a location need not be initialised merely because it is declared, and it may remain uninitialised until it goes out of scope if it is never read. An implementation may warn that the variable is unused, but unused and uninitialised variables are not by themselves illegal.
Initialisation is independent of cardinality. An empty extension is an ordinary initialised state when the location's cardinality permits it:
var String names*; // uninitialised
var String others* = {}; // initialised with the empty extension
Assignment to an uninitialised location initialises it:
var String message;
message := "hello";
print message; // legal
The assigned value or extension must satisfy the same type and cardinality rules as an assignment to an already-initialised location.
A program that reads an uninitialised location is illegal. Every control-flow path that can reach a read must have initialised the location or relevant sublocation. The compiler must reject the program unless definite- initialisation analysis establishes this property.
var String s;
var String t = s + "a"; // static error: s may be uninitialised
A read is any operation that requires or exposes the current contents of a location. Reads include:
For both an exclusive-reference argument &s and an explicit
move argument ^s, the call-site expression must denote a location.
Using an expression as a location designator does not by itself read that
location. Nevertheless, both forms require the denoted location to be
initialised. An exclusive-reference call cannot be used to initialise an
uninitialised caller location, and an explicit move cannot move a value from
one. Initialise the location by assignment before making the call.
The denoted location may be a whole variable or a sublocation within it. For
example, &p.x grants exclusive access to the sublocation
p.x. That sublocation must be initialised even if other
sublocations of p are not. The initialisation requirement is
applied to the particular location denoted by the argument expression.
A plain assignment does not read its target and may therefore initialise it. A compound update does read its target before writing the result and requires the target to be initialised:
var Integer count;
count := 1; // legal: initialises count
var Integer total;
total += 1; // static error: reads total before writing it
At a control-flow join, a location is definitely initialised only if it is initialised on every reachable incoming path. It is a static error to read a location when any reachable incoming path may leave it uninitialised:
var String message;
if condition {
message := "yes";
}
print message; // static error: message may be uninitialised
The analysis accounts for branches, loops, early returns, exception paths, and
other control transfers. A path ending in unreachable; has no
normally completing successor and therefore does not contribute a state to a
later join; see Unreachable.
Moving from an initialised owned location leaves that location uninitialised.
Its binding remains in scope and a later plain assignment may initialise it
again. Once a call argument ^s moves the value out of the
caller-owned location denoted by s, that location remains
uninitialised on both normal and exceptional exits from the call. See
Parameter Access and Ownership.
A value, entity, quotient value, or structural value may be partially initialised while it is under construction. Its attributes and other constituent locations have their own initialisation states:
value Point
{
Float32 x;
Float32 y;
}
func Point makePoint()
{
var Point p;
p.x := 1;
var Float32 x = p.x; // legal: p.x is initialised
var Float32 y = p.y; // static error: p.y is uninitialised
return p; // static error: p is only partially initialised
}
Reading an initialised sublocation of a partially initialised composite is legal. Reading an uninitialised sublocation is illegal. Reading, copying, moving, returning, passing, capturing, or otherwise allowing the composite as a whole to escape requires every constituent location to be initialised.
The same principle applies to arrays, lists, maps, and other composite structures: initialisation is tracked at the locations that constitute the value. A value inserted into a collection or otherwise made externally observable must be fully initialised. An optional or many-valued constituent location is still required to be initialised before escape, although its initialised extension may be empty when its cardinality permits.
For nominal values, all required constituent locations must be initialised before the value is read or transferred as a whole.
These are language-legality and static-analysis rules. The language does not require implementations to represent the initialisation state with a runtime tag or to insert runtime checks. A conforming compiler rejects a program when it cannot establish definite initialisation at every read.
Predica states parameter access directly. A plain parameter provides read-only
access to a value. At a pass-by-reference or explicit pass-by-move call site,
the caller uses an expression that denotes a location. &
temporarily grants exclusive access to the denoted location, while
^ moves its value into an owned parameter.
| Parameter | Callee access | Call | Effect on caller's variable |
|---|---|---|---|
String x |
read-only and non-owning | inspect(s) |
s remains initialised and is not changed through the parameter |
&String x |
exclusive read/write access | modify(&s) |
the location s remains initialised and may be changed |
^String x |
owns the parameter variable | consume(s) |
the value is copied and s remains initialised |
^String x |
owns the parameter variable | consume(^s) |
the value is moved from s, which becomes uninitialised |
func String identity(String x) =
x;
A plain parameter is read-only and non-owning. The callee may read it and may copy its value into an owned variable, but it cannot update or consume the parameter. This is a source-language access rule, not a physical calling convention. An implementation may pass a cheap representation by value and an expensive representation by const reference.
proc appendGreeting(&String x)
{
x += "hi";
}
var String s = "hello";
appendGreeting(&s);
The & on the procedure parameter requires exclusive access, and the
matching & at the call site explicitly grants that access. The
argument expression must denote an initialised mutable location. For the
duration of the call, no other code may read, update, or consume that location.
The callee may read or update it but does not own it and cannot consume it.
A variable may contain sublocations. Attributes of a value held by a variable, and other mutable components selected from it, are locations in their own right. A caller may grant a callee exclusive access to such a sublocation by using an expression that denotes it:
value Point
{
Integer x;
Integer y;
}
proc increment(&Integer n)
{
n++;
}
var Point p = Point { x = 1, y = 2 };
increment(&p.x);
The call grants increment exclusive access to the sublocation
p.x, not to a copy of its value. The callee's update is therefore
an update of p.x. During the call, the caller and other callees
may not access p.x through that expression, through
p, or through any other expression denoting an overlapping
location. Access to a disjoint sublocation such as p.y does not
conflict with the grant.
A reference exists only for the call. It cannot be stored, returned, captured, or otherwise used after the call finishes. Exclusive access may be passed on temporarily to another reference parameter:
proc inner(&String x)
{
x += "b";
}
proc outer(&String x)
{
x += "a";
inner(&x);
x += "c";
}
While inner is executing, outer has temporarily lent
its exclusive access and cannot use x. It regains access when
inner finishes. Reference parameters are permitted on procedures,
where mutation of caller state is an explicit effect. They are not permitted
on functions, folds, or external pure functions.
func String appendGreeting(^String x)
{
x += "hi";
return x;
}
The ^ on a parameter declares an owned variable in the callee.
The callee may read, update, or consume it. An ordinary argument expression
initialises the parameter by value. An explicit ^ argument moves
the value from an initialised location owned by the caller. The call-site
expression denotes that location; the callee receives the value in an owned
parameter and the caller's location becomes uninitialised:
var String a = appendGreeting("a"); // constructs the owned parameter
var String s = "a";
var String b = appendGreeting(s); // copies s; s remains initialised
var String c = appendGreeting(^s); // moves s; s becomes uninitialised
A borrowed reference parameter cannot be moved because the current procedure does not own the referenced location. An explicit move may use any expression that denotes an initialised location owned by the caller, including an owned sublocation.
Moving, reinitialisation, and the requirement that every passed location be initialised are governed by Uninitialised Locations.
Once a call argument ^s transfers its value, s remains
uninitialised if the call raises an exception. An exclusive reference granted
by &s ends on every exit from the call, including an exceptional
exit. Updates performed through the reference before the exception remain
visible unless another specified mechanism, such as transaction rollback,
reverses them.
Predica and Rust both reject unrestricted simultaneous mutation and detect use after ownership transfer statically. Their surface models differ because Rust is a systems programming language, whereas Predica delegates physical representation and calling conventions to its compiler.
| Meaning | Predica | Rust |
|---|---|---|
| Read-only access | inspect(s) with parameter String x |
inspect(&s) with parameter x: &String |
| Exclusive mutable access | modify(&s) with parameter &String x |
modify(&mut s) with parameter x: &mut String |
| Ownership transfer | consume(^s) with parameter ^String x |
consume(s) with parameter x: String |
| Copy into an owned parameter | consume(s) |
consume(s.clone()) for a non-Copy value |
Predica places the explicit marker on ownership transfer:
consume(s); // copy; s remains initialised
consume(^s); // move; s becomes uninitialised
For a non-Copy Rust value, an ordinary by-value call transfers
ownership and an explicit clone preserves the caller's value:
consume(s); // move
consume(s.clone()); // clone
Predica therefore makes loss of ownership visible at the call site without requiring the reader to inspect the parameter declaration. Its unmarked form preserves the caller's variable. The trade-off is that an unmarked call to an owned parameter may perform a potentially expensive copy that is not visible in the call syntax.
A plain Predica parameter is read-only and non-owning:
func Boolean empty(String x) =
x = "";
empty(s)
The source language does not specify whether the physical argument is passed by value or by const reference. CIR may choose a value for a cheap representation and a const reference for an expensive representation without changing the Predica declaration. Rust expresses the shared borrow in its type and call syntax.
Predica's reference form corresponds most closely to a Rust mutable borrow:
proc append(&String target)
{
target += "!";
}
append(&s);
Both languages grant exclusive access and prohibit competing reads and writes for the duration of that access. Predica references are deliberately more restricted: they exist only as procedure parameters, last exactly for the call, and cannot be stored, returned, captured, or otherwise escape.
Rust references are general types with statically checked lifetimes. They can be stored and returned when their lifetime relationships permit it, and they support APIs such as borrowed slices, iterators, and zero-copy views. Predica avoids lifetime syntax and the associated analysis at the cost of not directly expressing those patterns.
An owned Predica parameter is marked in its declaration:
func String appendGreeting(^String x)
{
x += "hi";
return x;
}
The owned parameter may be read, updated, or consumed. A caller may initialize it from a new value, copy an existing value, or explicitly transfer ownership:
var String a = appendGreeting("a");
var String b = appendGreeting(s);
var String c = appendGreeting(^s);
Rust separates ownership from mutability of the parameter binding. A parameter
of type String owns its value, while mut x: String
additionally permits mutation through the binding. Predica's ^String
x grants both ownership and permission to update or consume the
parameter. This is less fine-grained but requires fewer parameter concepts.
Predica combines its access rules with a distinction between pure functions and
effectful procedures. A function may have plain or owned parameters. Updating
an owned parameter is local state and does not make the function impure. An
exclusive & parameter is restricted to procedures because it permits
the callee to update a caller variable.
Rust has no corresponding language-level separation between pure functions and procedures. A Rust function may accept a mutable reference and update caller state without being declared as a different kind of operation.
Both languages reject reading a variable after its value has been moved. Predica describes the result directly as an uninitialised variable:
consume(^s);
inspect(s); // static error: s is uninitialised
s := "new value";
inspect(s); // valid
Assignment can reinitialize the same binding. Predica applies the same definite-initialisation analysis to variables declared without initializers, moved variables, branches, loops, and exceptional control flow.
Rust exposes ownership, borrowing, mutability, and lifetimes in enough detail to support systems programming, long-lived zero-copy views, and precise control over memory representation. This expressiveness brings additional type-system and lifetime complexity.
Predica instead presents three application-level operations: read a value, temporarily grant exclusive access, or explicitly transfer ownership. It leaves physical borrowing, copying, moving, and representation choices to CIR. This gives a smaller source-language model but may introduce implicit copying and cannot directly express Rust's long-lived borrowed data structures.
status := 25;
status++;
status += 5;
qty--;
qty -= 2;
weight *= 1000;
ratio /= 2;
score ^= 2;
An update target can be an ordinary variable. If status has numerical type,
:= replaces its value, ++ increments it, and += adds the right-hand value
to it. The other compound numerical operators have the same shape: they read
the current value, apply the corresponding arithmetic operator, and write the
result back to the selected target.
When the selected target is an ordinary program variable, the update is a local program-state update. It does not require a database transaction. The same operators may also be used for database-state locations selected by paths; those updates are governed by the transaction rules.
| Update | Meaning |
|---|---|
T := E |
replace the selected value of T with E |
T++ |
T := T + 1 |
T-- |
T := T - 1 |
T += E |
T := T + E |
T -= E |
T := T - E |
T *= E |
T := T * E |
T /= E |
T := T / E |
T ^= E |
T := T ^ E |
Each operator is defined only when the corresponding expression is defined and can be assigned to the selected target. Plain assignment may initialise an uninitialised target. Every compound update reads its target before writing and therefore requires it to be definitely initialised; see Uninitialised Locations.
name := "Smith";
name += " and Sons";
name[0:0] := "Mr ";
name[:3] := "Dr ";
name[3:8] := "Smythe";
name[3:8] := "";
name[-4:] := "Ltd";
name[-1:-1] := "!";
For a string variable, += appends the right-hand string. Slice assignment
updates the selected substring. The slice form follows Python's half-open
convention: S[i:j] selects positions from i up to but not including j;
omitted bounds mean the start or end of the string; negative indices count from
the end.
Assigning to an empty slice inserts text at that position. Assigning the empty string to a non-empty slice deletes the selected text. Assigning any other string to a non-empty slice replaces the selected text.
For example, name[:3] selects the prefix ending before index 3,
name[-4:] selects the suffix beginning four characters from the end, and
name[-1:-1] is an empty slice immediately before the final character.
color := PrimaryColor.red;
color := PrimaryColor.blue;
An enumerated variable supports assignment. The assigned value must be a value of the enumerated type, or of a subtype whose values are valid for that enumerated type.
shape := newShape;
shape.circle.radius += 1;
A variant variable supports assignment at the variant type. A variant also has
named attributes, so operations that depend on the payload of a particular
attribute use ordinary attribute navigation. In the example,
shape.circle selects the location of the circle payload before updating
radius. If shape is not currently a circle, the path selects no locations
and the update performs no writes.
value := newValue;
value.as<Circle>.radius += 1;
A union value does not have a variant attribute that can be used as a path step.
When an update needs to drill into a particular member type of a
union, the type is the handle. The expression value.as<Circle> casts the
location to the Circle member before updating radius. If value is not
currently a Circle, the path selects no locations and the update performs no
writes.
Update operators are type-directed. Numerical variables and numerical
locations support :=, ++, --, +=, -=, *=, /=, and ^=.
String variables and string locations support :=, +=, and slice assignment.
Enumerated variables and locations support assignment only.
Variant variables and locations support assignment at the variant type; named
variant attributes are ordinary path navigation. Union variables and
locations support assignment at the union type; as<T> is used when the
member type is the only handle for drilling down into the selected value.
Predica has one kind of constraint boundary: a transaction boundary. Every constraint applicable to transaction-controlled database state is required to hold at that boundary. A program that produces a constraint-violating database state there is illegal. A compiler should reject as many such illegal programs as it can prove will, or may, produce a violation at a transaction boundary.
This legality rule is independent of constraint-checking strategy. The language does not require an implementation to check constraints at runtime and does not prescribe an exception, trap, rollback, continuation, or other behaviour when an undetected violation occurs. An implementation may insert checks in a debug or checked build and omit them in another build.
Construction, assignment, procedure calls, function returns, collection insertion, and other ordinary program operations are not constraint boundaries. Completing a constructor is not a constraint boundary. Constraints are not required to hold merely because a value is fully initialised, read, passed, returned, captured, or otherwise made available to program code.
An update transaction begins from committed database state and may pass through any number of intermediate states that violate constraints. The several coordinated updates required to restore consistency need not be expressed as one statement. Constraints are required to hold again only at the transaction boundary.
Constraints are not required to hold for ordinary program variables or for values held in them. A program may construct, read, pass, return, capture, and update a fully initialised local value whose declared constraints are false. Such a value may later be changed into one satisfying the constraints, or may never be written to transaction-controlled database state at all.
Constructor syntax is only a convenience for initialising a value. It does not check constraints or create a point at which they are required to hold. The same value may instead be built by declaring a variable and initialising its constituent locations programmatically. The definite-initialisation rules still apply independently of constraints; see Uninitialised Locations.
The individual database-state updates inside a transaction are not constraint boundaries. Intermediate transaction states may violate constraints. At the transaction boundary, the resulting database state must satisfy all constraints applicable to that data partition.
A program that attempts to commit a constraint-violating final state is illegal. The language does not prescribe whether the implementation detects the violation, rejects the commit, reports an exception, traps, or exhibits some other behaviour. A database or transaction API may provide additional checked-operation guarantees, but those guarantees are library or implementation contracts rather than consequences of the Predica language semantics.
Constraint-violation legality is distinct from the static formation rules of the language:
An implementation may use static reasoning, generated checks, database validation, or any combination of them to diagnose illegal programs. These techniques do not change which programs are legal.
var Integer total = 0;
for Integer n in 1..100 {
total += n;
}
print total;
This program prints 5050. The declaration creates an exactly-one Integer
location named total. The range expression 1..100 denotes the finite
extension containing the integers from 1 to 100 inclusive. The for
statement executes the body once for each member of that extension, binding n
to one integer at a time. The statement total += n; is a local program-state
update.
var Integer total = 0;
var Integer n = 1;
while n ≤ 100 {
total += n;
n++;
}
print total;
The while form is useful when the next iteration depends on state changed by
the body. Its condition must have element type Boolean and cardinality one.
In strict mode this is checked statically. In dynamic mode a condition that
does not produce exactly one Boolean value raises a runtime exception.
if total = 5050 {
print "ok";
} else {
print "wrong", total;
}
An if statement chooses between statement blocks. Its condition has the same
requirements as a while condition: it must produce exactly one Boolean value.
The expression form if C then A else B remains available when the program
needs to choose a value inside a larger expression.
The expression A..B is an inclusive integer range. Both endpoints must have
element type Integer and cardinality one. If A ≤ B, the range contains
each integer from A to B inclusive. If A > B, the range is empty. The
element type of the range is Integer; its cardinality may be many. This is
the infix range form; the prefix form ..supplier[...] remains lookup notation
inside literals.
A block is a sequence of statements surrounded by braces. Variables declared inside a block are visible from their declaration to the end of that block, including nested blocks. They are not visible after the block has finished. Declaring a block variable with the same name as a visible block variable or loop binding is a static error.
Control-flow analysis also tracks the initialisation state of locations. A location may be read after a branch or loop only when it is definitely initialised on every reachable incoming path; see Uninitialised Locations.
The loop variable introduced by for is an exactly-one local binding scoped to
the loop body. It is rebound for each iteration and cannot be used as an update
target. The explicit type in for Integer n in 1..100 may be omitted when it
can be inferred from the source expression.
A for source is an expression whose result may contain zero, one, or many
members. The body executes once for each member. If the source is empty, the
body does not execute.
var Integer total = 0;
for Integer qty in s.shipment.qty {
total += qty;
}
This example adds quantities reached by a query path. Because addition is commutative, the result does not depend on iteration order. A numeric range has the natural ascending order of its integers. Other extensions are mathematical sets; unless an expression explicitly defines an order, a program must not rely on the order in which their members are visited.
var Integer total = 0;
var Integer n = 0;
while true {
n++;
if n > 100 {
break;
}
if n = 50 {
continue;
}
total += n;
}
The statement break; exits the nearest enclosing loop. The statement
continue; starts the next iteration of the nearest enclosing loop. Using
either statement outside a loop is a static error.
Control flow does not change the distinction between local program-state updates and database-state updates. A loop that updates only program variables does not require a transaction. A loop that writes persistent database locations requires an open transaction for the relevant data partition, just as the same database-state update would outside a loop.
The statement unreachable; asserts that execution cannot reach its
position. It terminates the current control-flow path and does not complete
normally.
A program that can execute an unreachable; statement violates that
assertion and is illegal. A compiler should reject as many such illegal
programs as it can prove will, or may, reach the statement. This requirement is
not a promise that every violation can be detected statically: reachability may
depend on input data or on computations that cannot in general be decided at
compile time.
func Integer sign(Integer x)
{
if x < 0 {
return -1;
}
if x = 0 {
return 0;
}
if x > 0 {
return 1;
}
unreachable;
}
The final statement records the programmer's assertion that the three preceding conditions are exhaustive. It is useful when that fact follows from an invariant or from knowledge that the compiler's ordinary control-flow analysis does not establish.
The unreachable statement allows the standard library
assert operation to be implemented in Predica:
proc assert(Boolean condition)
{
if ¬condition {
unreachable;
}
}
When condition is true, assert completes normally.
When it is false, execution reaches unreachable; and the program is
illegal. The compiler should reject calls whose failed assertion it can
establish statically. The language does not prescribe whether an undetected
failed assertion produces a diagnostic trap or any other particular runtime
behaviour.
For static analysis, unreachable; has no normally completing
successor. Consequently, a path ending in unreachable; does not
need to return a value from a block-bodied function and does not contribute an
uninitialised location state to a later program point.
Before applying that control-flow effect, the compiler should attempt to establish whether the assertion can be violated. If it can prove that control reaches the statement, or that some possible execution reaches it, it should reject the program as illegal rather than merely emit an unreachable-code warning. Failure to prove a violation does not establish that the assertion is true.
func String describe(Boolean known, String text)
{
var String result;
if known {
result := text;
} else {
unreachable;
}
return result;
}
The read of result is legal: every control-flow path that can
complete normally and reach the return has initialised it. The assertion does
not prove its own premise and does not make an otherwise invalid expression,
update, or declaration well-typed. All statements that are analyzed before it
remain subject to the ordinary static rules.
An unconditional return, break,
continue, or unreachable makes the remainder of the
same block unreachable by normal control flow. Such statements are permitted,
but an implementation should diagnose them as unreachable-code warnings.
They are not static errors, because generated code and temporarily disabled
code may deliberately contain them.
If execution reaches unreachable;, the program has violated a
programmer assertion and is illegal, even when the compiler did not detect the
violation statically. The language does not prescribe what happens after that
point. In particular, it does not require a Predica runtime exception, a trap,
termination, or continuation with any particular behaviour.
An implementation may insert a diagnostic trap, for example in a debug build, and may omit it in another build. Optimizers may rely on the assertion that the statement is not reached. These implementation choices do not alter the legality rule and are not prescribed by the language.
return completes the enclosing function or procedure normally.break transfers control beyond the nearest enclosing loop.continue transfers control to the next iteration of the nearest enclosing loop.unreachable asserts that its position cannot be reached; reaching it makes the program illegal.
The statement may appear wherever a statement is allowed. Unlike
break and continue, it need not be enclosed by a
loop. Unlike return, it carries no expression and is independent
of the enclosing function's result type.
Runtime exceptions are failures explicitly specified by a language operation or reported through a runtime or external-operation contract. A static error is different: it is reported before the program runs, is never raised by executing the program, and cannot be caught.
The standard library type Exception is the common supertype of Predica runtime
exceptions. A catch clause binds the thrown exception to a local variable
whose scope is the catch block.
The following failures are runtime exceptions:
By contrast, static type check failures, static cardinality constraint check
failures, name-resolution failures, illegal update targets, and invalid
transaction shapes that can be rejected statically are static errors. They are
not exceptions and cannot be caught with try.
A possible read of an uninitialised location is likewise a static error rather than a runtime exception; see Uninitialised Locations.
Constraint violations are not specified as runtime exceptions; see Constraint-Violating Updates.
Reaching an unreachable; statement is also not a catchable
exception. It makes the program illegal by violating a programmer assertion
and the language does not prescribe the subsequent execution behaviour, as
specified in Unreachable.
A try statement executes its try block. If no runtime exception is raised, the
catch clauses are skipped. If a runtime exception is raised, normal execution of
the try block stops and control transfers to the first catch clause whose type
matches the exception. If no catch clause matches, the exception propagates to
the enclosing statement context.
The catch type may be Exception or a subtype of Exception. If several catch
clauses are written, they are tested in source order and the first matching
catch block is executed.
A try statement is statement syntax. It may appear in top-level scripts,
procedures, and block-bodied function bodies. In a block-bodied function, try does
not by itself make the function impure; the try block and catch blocks must still
satisfy the ordinary function-purity rules.
Predica does not currently define a general source-level throw statement.
Runtime exceptions are raised by specified language operations, by transaction
commit, or by external operations that report failure through the Predica
runtime.
Ownership transfer is not reversed by exception propagation. If
^s transfers a value into a call that raises an exception,
s remains uninitialised. An exclusive reference granted with
&s ends when the call exits exceptionally, just as it does on a
normal return. See
Parameter Access and Ownership.
Predica separates expressions from procedures. An expression denotes a value extension and may be used in queries, constraints, derived attributes, folds, and function bodies. A procedure is a no-result callable whose body is permitted to perform effects: it may update locations, print output, call other procedures, or perform database-state updates inside an open transaction.
Permission to perform effects is not a requirement to perform them. A procedure remains a procedure when its body happens to have no observable effect, because it has no result and its call is a statement.
proc recordShipment(ShipmentsSystem system, Supplier supplier, Part part, Integer qty)
{
var Shipment shipment = Shipment {
supplier = supplier,
part = part,
qty = qty
};
system.shipment ∪= shipment;
}
A procedure declaration uses the proc keyword followed by a name,
parameters, and a statement block. It has no result type and cannot be used as
an expression. Plain procedure parameters are
read-only and non-owning. A parameter prefixed with & receives
exclusive access to a caller variable, while one prefixed with ^ is
owned by the procedure. Parameters are single-valued, like function parameters.
Each argument must be assignable to the parameter type and must have static
cardinality one after any cardinality-flow refinements. A procedure call is a
statement:
recordShipment(system, supplier, part, 12);
The caller writes & when granting exclusive access and writes
^ when explicitly transferring ownership. An owned parameter may
also be initialised from an ordinary expression, copying where necessary. See
Parameter Access and Ownership.
Function calls and procedure calls deliberately live in different contexts. Calling a pure function for its result is an expression. Calling a procedure is a statement. A procedure call is not valid inside a query expression, a constraint, a derived attribute, a fold body, an expression-bodied function, or a pure block-bodied function.
Procedure names may be qualified through component aliases and namespaces, such as
admin.recordShipment(system, supplier, part, 12);. Procedures do not have
receiver-call syntax. The expression-style spelling
supplier.recordShipment(system, part, 12); is not a procedure call merely because
supplier is one of the arguments; write
recordShipment(system, supplier, part, 12); instead.
Every func declaration denotes a pure function. An expression-bodied
function is pure by construction. A block-bodied function is valid only when its
statement body is statically pure: it may use local variables, local updates,
loops, branches, read-only query expressions, and pure function calls, but it
must not print, call a procedure, read or write program-global state, write
database-state locations, or depend on an open transaction.
Side-effecting reusable operations are procedures, not impure functions. This keeps ordinary expression evaluation free of hidden updates and makes it clear when program execution crosses from query calculation into state change.
A procedure body is checked as a procedure context. It may call pure functions and other procedures. It may update program-state locations. It may print. It may update database-state locations only when the call is executed with an open transaction for the relevant data partition.
The effect of a procedure is determined from its body and the procedures it calls. A procedure that may write database-state locations has a database-write effect. Calling such a procedure has the same transaction requirement as writing the database-state locations directly. The call must not silently filter away out-of-partition updates; partition validity is checked by the update rules.
Procedures may use a bare return; statement to leave the procedure early. A
procedure cannot return a value. If a program needs a value, compute it with a
function and pass it to a procedure, or assign it to a program variable before the
procedure performs its updates.
Constructing an entity value is not the same thing as making that entity part of persistent database state. The constructor expression produces an entity value with the supplied attributes. A database-state update makes that value observable from a persistent root or base collection.
proc recordShipment(ShipmentsSystem system, Supplier supplier, Part part, Integer qty)
{
var Shipment shipment = Shipment {
supplier = supplier,
part = part,
qty = qty
};
system.shipment ∪= shipment;
}
The declaration var Shipment shipment = ...; creates a local program variable
holding an entity value. It does not insert a row, allocate persistent storage,
or update a database partition. The statement system.shipment ∪= shipment;
is the database-state update: it adds the shipment to the base shipment
extension of the persistent ShipmentsSystem.
A base attribute is an attribute stored directly on the receiver's state. Base attributes have location denotations and may be update targets when the receiver is mutable program state or persistent database state.
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
Shipment shipment*;
}
In this shape, supplier, part, and shipment are base attributes of
ShipmentsSystem. Updating system.shipment changes the stored shipment
extension.
A derived attribute is defined by an expression. It is read-only unless the specification gives it an explicit update rule. An inverse attribute is a derived attribute: it is calculated by inverting a base path.
entity Supplier
{
Integer id;
String name;
→ Shipment shipment* = inverse shipment.supplier;
}
The path supplier.shipment is useful for queries, but it is not an ordinary
update target. The update that records a shipment writes the base collection
from which the inverse is derived:
system.shipment ∪= shipment; // base update
supplier.shipment ∪= shipment; // invalid without an explicit view-update rule
This keeps the write path explicit. A view-update rule may later define how a particular derived attribute can be updated, but no such rule is inferred merely because a derived attribute is navigable.
For any entity type E, every key of E must be unique within the extension of
any attribute of type E. This affects updates to locations holding such
extensions; it does not affect construction of entity values. Constructing an
entity value is an ordinary expression and neither reads nor updates an
extension.
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier, part};
}
Suppose a location holding an extension of Shipment already contains a
shipment with a particular supplier and part. Applying ∪= to that
location with a new Shipment having the same supplier and part replaces
the existing member with the new value. This is a keyed extension update, not
an effect of the entity constructor. If no member has the same key, the new
value is inserted.
The design is less obvious when an entity type declares multiple candidate keys and the supplied value matches different existing members by different keys. Predica could define any of the following behaviours:
This remains a language-design choice. The provisional recommendation is to reject an update that matches more than one existing member. That rule avoids an implicit merge and treats all candidate keys symmetrically. An explicit merge operation could be provided separately if merging is required.
A function declared with func may have a block body. Block bodies
use the statement language: local variables, assignment, loops, branches, and
return. This is the function form for
pure algorithms that are clearer as steps than as a single expression.
func Integer sumTo(Integer limit)
{
var Integer total = 0;
for Integer n in 1..limit {
total += n;
}
return total;
}
The function signature is the same as an expression-bodied function: result
type, name, optional generic parameters, parameters, and optional generic
where constraints. A plain parameter is read-only and non-owning.
A parameter prefixed with ^ is an owned variable and may be updated
or consumed. Functions cannot have exclusive-reference parameters. The body
is a statement block instead of = Expression;.
The following declarations are alternative definitions of factorial for inputs
where n ≥ 0. A component may contain only one declaration named factorial
in a namespace; they are shown together only to compare body forms.
func Integer factorial(Integer n)
{
var Integer result = 1;
for Integer k in 1..n {
result *= k;
}
return result;
}
func Integer factorial(Integer n) =
(1..n).Σ{ 1 ; result value ↦ result * value };
func Integer factorial(Integer n)
{
if n = 0 {
return 1;
}
return n * factorial(n - 1);
}
func Integer factorial(Integer n) =
if n = 0 then 1 else n * factorial(n - 1);
All four declarations define the same pure function on non-negative inputs. The
first body uses local program-state updates and a loop. The second expresses the
same computation as a fold over the finite range 1..n. The third uses a
block body with recursive calls and early return. The fourth writes the
same recursion as a single conditional expression. The body form does not
change how callers use the function.
A block-bodied function returns with a return statement:
return total;
The returned expression is checked in the context of the declared result type, and its type must be
assignable to that result type. In a block-bodied function, return must include an expression.
A return inside a nested block returns from the enclosing function. A non-void function
body must return on every reachable path. The language has no Void result type; operations that
exist only for side effects are procedures. Outside a block-bodied function or procedure body, using
return is a static error. A path ending with unreachable; has no
normally completing successor and therefore does not require a return; see
Unreachable.
Returning an expression reads and exposes its value. A returned location, including every constituent location of a composite value, must therefore be definitely initialised as specified in Uninitialised Locations.
A block body does not by itself make a function impure. A block-bodied function is pure when its body:
print;Such a function may be called from read-only expression contexts, including derived attributes, constraints, query expressions, and expression-bodied functions. It is observationally the same as an expression-bodied pure function, even though its implementation uses local mutation or control flow.
Updating an owned parameter is a local update, just like updating a local variable, and does not make the function impure. Parameter access and ownership are specified in Parameter Access and Ownership.
A function body that would read or write global program state, perform I/O,
write database-state locations, depend on a transaction, or call a procedure is
rejected as a function body. Write the operation as a proc instead. This
rule keeps function calls valid in ordinary expression contexts without
introducing hidden updates.
Generic block-bodied functions use the same generic parameter, default, constraint, inference, and canonical instantiation rules as expression-bodied generic functions. Instantiation substitutes the generic arguments into the signature and statement body before type and effect checking.
func T firstOr<Type T>(T fallback, Set<T> values)
{
for T value in values {
return value;
}
return fallback;
}
The example is pure despite using a loop because it reads only its parameters, writes no non-local state, and returns a value determined by its inputs.
Some path expressions have a location denotation as well as a value denotation. In an expression context, the path denotes the value obtained by reading that location. In an update target context, the path denotes the selected locations.
read(location(E)) = value(E)
This equation is the consistency requirement for update targets. The ordinary value denotation describes what a query observes. The location denotation keeps the address witnesses needed for a write, such as the selected receiver and attribute slot.
An update target is valid only when it denotes locations. A single path expression may select zero, one, or many locations, so a single update statement may perform zero, one, or many writes. Assignment replaces the value at each selected location, and compound update operators read the current value at each location and write the updated value back to the same location.
Not every readable path denotes locations. Program variables and base
attributes are updateable. Derived attributes, including attributes defined with
inverse, are read-only unless an explicit view-update rule gives them a
location denotation. Ordinary path-expression updates therefore write base
locations, not derived views.
The target location denotation and the right-hand value denotation are evaluated in the statement pre-state. For a local program-state update, this is the current program state. For a database-state update, this is the current transaction state before the statement. The resulting writes are then applied to produce the next state of the same kind.
s.supplier.[city = "London"].status := 5;
This statement assigns status 5 to every supplier in London.
s.shipment.[supplier.name = "Smith"].qty := 100;
s.shipment.[supplier.name = "Smith"].qty++;
s.shipment.[supplier.name = "Smith"].qty += 10;
The same update operators can be used when the target is selected by a path
expression. These updates start at the Supplier/Parts system s, range over
its shipments, filter to shipments whose supplier is named "Smith", and then
select their required qty attribute. A cardinality-one attribute location is
the selected receiver together with the required attribute slot; reading it
yields exactly one value, and assignment replaces that value.
s.supplier.[name = "Smith"].status := 25;
s.supplier.[name = "Smith"].status := none;
For an attribute with cardinality zero or one, the selected target is an
optional location. The location exists for each selected receiver even when the
current extension is empty. Assigning a value of the attribute's element type
sets the selected extension to that single value. Assigning none clears the
selected extension.
The expression none denotes an empty result for an optional location. It is not a value and is
not inserted as a stored marker. For an optional attribute, clearing the attribute removes the
corresponding predicate fact from the selected receiver. Setting the attribute inserts or replaces
that fact so that the cardinality remains zero or one.
s.supplier.[name = "Smith"].city ∪= {"London"};
s.supplier.[name = "Smith"].city ∩= {"London", "Athens"};
s.supplier.[name = "Smith"].city ∖= {"Paris"};
s.supplier.[name = "Smith"].city ∩= {};
s.supplier.[name = "Smith"].city := {"London", "Athens"};
For an attribute with cardinality zero or more, the selected target is a
set-valued attribute slot whose current value is an extension of values. If
city is such an attribute, ∪= adds city values,
∩= keeps only city values in the right-hand set, ∖= removes city values,
and := replaces the selected city extension with the given set of values.
The update applies to each supplier selected by the path.
For any attribute whose cardinality permits zero, intersecting with the empty set clears the selected extension.
These examples are intentionally written with the same navigation and filtering notation used for queries. An update target is not a separate addressing language: it is a path expression with a location denotation.
s.supplier.[name = "No such supplier"].status := 25;
shape.circle.radius += 1;
value.as<Circle>.radius += 1;
If a path expression selects no locations, the update succeeds and performs no writes. This is the ordinary empty result case for location denotations, not an exception. Variant attribute navigation and union casts can therefore be used as conditional update targets: if the current variant attribute or union member does not match, the selected location set is empty.
// Invalid when the open transaction owns only one supplier partition.
network.supplier.status := 5;
For a database-state update, every selected database location must belong to the single partition owned by the open transaction. An implementation may reject an update statically when the target is known to cross partition boundaries, or dynamically when evaluating the target discovers an out-of-partition location. It must not silently turn a cross-partition update into a filtered partition-local update.
Local program-state locations do not belong to database partitions and are not subject to this partition rule. A single update statement must not mix local program-state locations and database-state locations in the same target.
A Predica component is the unit used to group a set of type definitions for compilation, schema evolution, and deployment. The component's filesystem location identifies it.
Consider the following directory structure:
root/
acme/
Geometry/
_component_.predica
point.predica
circle.predica
internal/
vectors.predica
The folder named root is called an include root. These are folders that may contain
any number of components. The folder named Geometry contains the component manifest
_component_.predica. That marks Geometry/ as a component root folder. The component name
is the name of that folder, Geometry. The qualified component name is the path from the
include root to the component root with path separators replaced by dots: acme.Geometry.
A compiler is given an ordered list of filesystem paths denoting include roots. An include
root is a folder from which components can be discovered, for tools that recursively scan
subfolders for _component_.predica files, or looked up for a given qualified component name.
A compiler might support declaring include roots on the command line, for example using a -I
switch. A qualified component name is allowed to appear under more than one include root.
Lookup by qualified component name searches include roots in order and uses the first matching
component root. Qualified component names are case sensitive in Predica. Distinct components
cannot have the same qualified component name under case-insensitive comparison. If
R is the list of include roots, no root in
R may be a descendant of another root in R.
A valid component root satisfies the following rules.
_component_.predica.Folder names below an include root that correspond to namespace names or component names must also be valid identifiers, as defined by the lexical grammar.
A Predica file is a file with the .predica extension. The Predica files belonging
to a component are found by searching the component root's folder tree. The component manifest
identifies that folder as a component root; other Predica files in the tree provide the
component's declarations and optional program/query items.
The folder structure inside a component has no semantic meaning. Subfolders may be used for convenience, but they do not create namespaces. Any namespaces used inside a component must be declared explicitly in Predica source.
A component declaration is optional at the start of any Predica file, including the component manifest. When present, it is the first non-whitespace, non-comment token in the file.
The following form gives the component name:
component Geometry;
Alternatively, this form gives the qualified component name:
component acme.Geometry;
The compiler ensures these match the component name or qualified component name determined by the file system.
This is an example of a Predica source file:
component acme.Geometry;
@dynamic = true;
using acme.Units { Metre };
value Point
{
Float64 x;
Float64 y;
}
show(Point { x = 0, y = 0 });
The top-level items in a Predica source file appear in this order:
@dynamic directive.using declarations.Top-level program statements may appear in ordinary component source files after all declarations. Schema and check modes load and type-check those items as source, but they do not execute them.
Namespace declarations contain schema/library declarations only. They do not contain
using declarations or program statements. Once a file has reached its program
statement section, no further declarations are allowed.
The recommended encoding for Predica files is UTF-8 without a byte order mark and without a shebang. This is the only encoding currently supported by the reference implementation.
The handling of symlinked files and folders, hidden folders, and generated folders is currently unspecified by the reference compiler.
A component manifest is the _component_.predica file in a component root. It identifies the
containing folder as a component root and records schema-evolution release metadata.
A component manifest has the following content model:
releases block, when the component has schema-evolution release metadata.
A component with schema-evolution release metadata records it with a releases block. The
schema evolution chapter defines the components that require this block. Each release entry
records a component release and the release numbers of its serialised representation
dependencies.
component SpaceShuttle;
releases
{
0 :
{
name: "Release 1.0",
date: (2012,April,18),
acme.Geometry: 1,
nasa.CommsModule: 0
},
1 :
{
name: "Release 1.1",
date: (2015,July,11),
acme.Geometry: 2,
nasa.CommsModule: 3,
nasa.Scrubber: 2
},
2 :
{
name: "Release 1.2",
date: (2015,August,24),
acme.Geometry: 2,
nasa.Scrubber: 2
},
3 :
{
name: "Release 2.0",
date: (2018,March,6),
acme.Geometry: 4,
nasa.Scrubber: 3,
nasa.Telemetry: 0
}
}
Release dates are metadata only and do not determine dependency versions. Dependency release numbers are explicit release metadata. The set of serialised representation dependencies grows or shrinks between component releases as the serialised representation changes.
Predica declarations are organised using components and namespaces. A component is the filesystem and dependency unit. Namespaces organise declarations inside one component.
Where Predica is used in a computer system, a component is typically associated with a deployment unit such as a library containing implementations of the types and functions belonging to that component. In a legal system, components are typically associated with the division into parts, such as acts or articles.
Namespaces are used to organise declarations and to avoid accidental name collisions. A namespace does not create a component dependency, and a folder inside a component does not create a namespace. Namespaces must be declared explicitly in Predica source.
namespace Geometry
{
value Point
{
Real x;
Real y;
}
namespace Shapes
{
value Circle
{
Point centre;
Real radius;
}
}
}
Within a namespace, unqualified component-local names are resolved relative to the current namespace and then its ancestor namespaces, stopping at the root namespace of the current component. A more local declaration hides a declaration with the same name in an ancestor namespace.
value Thing {}
namespace Inner
{
value Thing {}
value Use
{
Thing x; // resolves to Inner.Thing
}
}
If Inner did not declare Thing, the reference would continue to the
component root namespace and find the outer Thing.
Qualified names that are not component aliases are interpreted relative to the current component
namespace chain. For a reference to A.B inside
acme.Model.Geometry.Units, lookup may try:
acme.Model.Geometry.Units.A.B
acme.Model.Geometry.A.B
acme.Model.A.B
Lookup does not continue to acme.A.B or to any other component merely because the
component names share a dotted prefix. Component namespace ancestry organises components; it does
not create implicit dependencies between them.
A using declaration adds component aliases or selected imported names to the
current source file's lexical name-resolution context. It affects name resolution only; it
does not create schema-evolution release metadata.
A source file uses using to reference any available component. This is sufficient for
Predica used as a contract language, or for other uses without a DBMS persistence and schema
evolution contract.
Schema evolution has a separate requirement: when serialised representations depend on types
implemented by another component, the component manifest's releases block records the
dependency's release number. That requirement is independent of ordinary name lookup.
Every source file has a standard prelude scope that makes selected std declarations available
unqualified and makes std available as an explicit component qualifier. This prelude is a
language rule, not a hidden using std; declaration.
using ComponentName;
using ComponentName as Alias;
using ComponentName { QualifiedName1, QualifiedName2 as Alias2, ... };
Using declarations are file scoped. They affect only the Predica file in which they appear.
If two files in the same component need the same imported names, both files must declare the
relevant using declarations.
An ordinary source file starts with a component declaration when the file records a
filesystem-location check, then an @dynamic directive when the file opts into dynamic
variables, followed by zero or more using declarations, followed by all ordinary declarations
and top-level statements. This is the position for using declarations.
A declaration of the form using C; is syntactic sugar for
using C as A;, where A is the final segment of the component name
C. The implicit alias behaves identically to an explicitly declared alias.
using acme.Geometry;
using acme.Physics as phys;
value Example
{
Geometry.Point point;
phys.Vector momentum;
}
A component alias enables qualified lookup through that component. It does not import every declaration from that component as an unqualified name. To use an imported declaration unqualified, use a selected import.
The implicit std qualifier behaves like a component alias for qualification
purposes. The standard-library chapter defines the standard declarations with an explicit
std.Name spelling.
A declaration of the form using C { ... }; imports selected declarations from
component C under unqualified local names. A selected name is qualified within
the imported component when it names a nested declaration.
using acme.Geometry { Point, Shapes.Circle as Disk };
using acme.Math { pi, sin };
Name imports the declaration C.Name under the local name Name.Qualified.Name imports the declaration C.Qualified.Name under the local name Name.Qualified.Name as Alias imports the declaration under the local name Alias.
Selected imports name exported types, type aliases, constants, functions, procedures, folds,
generic declarations, and namespaces. Importing a function name imports every overload of that
name. A selected spelling that matches more than one non-function declaration kind in the
imported component is ambiguous and is rejected at the using declaration.
Geometry.Point.Point or aliases such as Disk.
Rule: A using statement is
- allowed to make a program with type errors no longer have type errors
- allowed to make a program with no type errors now have type errors because of ambiguity
- never allowed to silently cause a program without type errors to produce different behaviour
A using statement alters the set of available names and type relationships while
preserving the behaviour of all previously well-typed programs. It does not silently change
the meaning of equality, embeddings, function calls, folds, or any expression whose typing and
semantics were previously determined.
Name resolution determines which declarations a source name denotes. It is separate from function overload resolution: lookup may produce several function declarations, after which the argument types select the best viable overload. This separation supports both ad-hoc polymorphism through overloading and parametric polymorphism through generic declarations.
If the first segment of a qualified name is a component alias, the remaining segments are looked up from the root namespace of that component. Otherwise the qualified name is looked up relative to the current component namespace chain as described in the Namespaces chapter. Qualification names one lookup location; it does not search other components and does not perform argument-dependent lookup.
Every intermediate segment must denote a namespace. The final segment is resolved in the name category required by its context, such as a type, constant, function, fold, or namespace. A declaration of another category does not hide declarations in the required category.
For a component-local name, lookup examines the current namespace and then successive parent namespaces. The first namespace containing one or more declarations of the required name and category ends this search. For functions, all declarations with that name in the namespace form an overload set; one overload does not hide another overload in the same namespace. Finding a local set prevents functions of that name in ancestor namespaces from being added.
If the component namespace chain supplies no declaration, lookup considers selected imports in the current source file and then the standard prelude. Several selected imports of functions with the same local name contribute to one overload set. Several imported non-function declarations of the same category are ambiguous. A local declaration wins over an imported declaration, as specified by the Using Declarations chapter.
A component alias participates only as the first segment of a qualified name. Merely making a component available under an include root never adds its declarations to ordinary unqualified lookup.
An unqualified function call or symbolic operator expression performs ordinary unqualified lookup and argument-dependent lookup (ADL). ADL allows an operation to be declared with the types on which it operates without requiring every caller to import the operation explicitly. A qualified call does not perform ADL.
The associated namespaces of an argument type are determined from its static type, never from the run-time value. They include:
For a nominal type declared at component root, its associated namespace is that component's root namespace. Including supertypes means that an operation declared alongside a base type remains an ADL candidate when called with a subtype, including when the subtype and base type are declared in different components.
ADL contributes accessible functions with the called name from each associated namespace. It does not search parent or child namespaces from an associated namespace. The ordinary and ADL sets are then united. The same declaration reached by more than one route appears only once; declaration identity, rather than spelling or signature, is used for deduplication. Lookup is not ambiguous merely because the resulting set contains several functions. The Function Overload Resolution chapter determines whether one is the unique best match.
namespace Numbers
{
value Complex { Real re; Real im; }
func Complex +(Complex a, Complex b) =
Complex { re = a.re + b.re; im = a.im + b.im };
}
func Numbers.Complex add(Numbers.Complex a, Numbers.Complex b) =
a + b; // Numbers is associated with both operands, so ADL finds Numbers.+
Lookup fails when it finds no declaration of the required category. Non-function lookup is ambiguous when its rules produce more than one declaration. Function lookup may intentionally produce many declarations; it is an error only if overload resolution finds no viable function or does not find a unique best viable function.
In systems where value and entity types serve as the logical basis for a database, there is a practical requirement to accommodate type definition evolution.
Schema evolution is defined relative to component releases. Every type is implemented by exactly one component, and the release number of that component identifies the serialised representation used for values and entities of that type. Components that do not have a historical deserialisation contract do not need release metadata.
Type definition evolution is modelled by allowing attributes of a value or entity type to be added or dropped over time. The lifetime of each attribute is tied to release numbers of the component that implements the type.
The current attributes of a type are declared directly in the type body. Attributes that existed
in older releases but are not current are declared in a dropped block. The
schema block records when attributes are added or dropped.
No serialised representation of Point values existed in releases 0 and 1.
Release 2 introduced Point with Cartesian coordinates.
value Point
{
Float64 x;
Float64 y;
}
Release 3 added z. New attributes are added after existing attributes, so the serialised
attribute order became (x,y,z). The schema block records that z was added in this release.
value Point
{
Float64 x;
Float64 y;
Float64 z;
schema
{
2: +x +y
3: +z
}
}
Release 4 did not change the serialised representation of Point values. The release 3
representation therefore carried forward: serialised Point values still had (x,y,z).
Release 5 dropped z. The current representation was back to (x,y), but z remained in the
dropped block so serialised data from release 3 and release 4 could still be understood.
value Point
{
Float64 x;
Float64 y;
dropped
{
Float64 z;
}
schema
{
2: +x +y
3: +z
5: -z
}
}
Release 6 changed the current representation to polar coordinates. The old Cartesian fields moved
to the dropped block, and the evolve functions described how to construct the new current fields
from older serialised data.
value Point
{
Float64 r;
Float64 theta;
dropped
{
Float64 x;
Float64 y;
Float64 z;
}
schema
{
2: +x +y // Release #2 : (x,y)
3: +z // Release #3,4 : (x,y,z)
5: -z // Release #5 : (x,y)
6: -x -y +r +theta // Release #6- : (r,theta)
}
evolve
{
r(x,y) { r = sqrt(x*x + y*y); }
theta(x,y) { theta = atan2(y,x); }
}
}
The release numbers in a schema block are release numbers of the component that implements the
type. They must be strictly increasing. Each release number used as a schema change point must
be declared in that component's manifest. A schema block does not need an entry for every
declared release; a representation continues unchanged until the next schema change point. Before
the first schema change point for a type, no serialised representation of values of that type
exists.
Each attribute has a single lifetime. The schema adds an attribute once; the attribute is then either current or dropped by a later schema change. The schema cannot add the same attribute again after it has been dropped, even with the same type. Reusing the name would make the historical meaning of that attribute discontinuous; use a new attribute name when a later release introduces a distinct serialised field.
The schema block defines the serialised attribute order for each historical representation. A
release entry is applied from left to right. Added attributes are appended to the serialised
representation in the order in which they appear in the schema block. For example, +x +y means
x appears before y in that release's serialised representation, and a later +z appends z
after the existing fields.
Attribute order is part of the serialised representation. A schema evolution step adds attributes at the end or drops existing attributes; it does not reorder existing attributes. Source attribute names are not stored in the serialised representation. The serialised representation is determined by attribute positions. Therefore changing an attribute name in the source does not by itself define a schema evolution step, provided the attribute remains at the same serialised position with the same type.
Type names have a different status from attribute names, including enum attribute names, when serialised data uses
dynamic type identifiers. For example, if a serialised value is statically known only as Shape
but the actual value is a Circle, the serialised representation must identify the concrete type
whose representation follows. The stable identifier available in Predica is the fully qualified
type name, such as acme.Circle.
Therefore type names that can appear as dynamic type identifiers cannot generally be changed without creating a schema evolution issue. A type rename changes the identifier stored in old serialised data unless schema evolution for that type records the old name and maps it to the current type.
A schema block is consistent when all of the following conditions hold:
-name schema change refers to an attribute that has appeared in an earlier +name schema change.-name schema change refers to an attribute that has not appeared in an earlier -name schema change.dropped block are exactly the attributes dropped by the complete schema block.+name and not dropped.
The evolve block defines functions for current attributes that are derived from historical
attributes during deserialisation. It is used when a new current attribute must be initialised from
attributes present in older serialised representations.
Enum schema evolution follows the same principle as value and entity evolution: serialisation is defined by integer representation rather than source names. An explicit representation-value expression is evaluated at compile time in the enum's generic environment. An attribute without an explicit expression has the value of the preceding attribute plus one, or zero if there is no preceding attribute. Attribute names are not stored in the serialised representation. Therefore changing an attribute name in the source does not by itself define a schema evolution step, provided its representation value remains unchanged for every valid instantiation.
To preserve serialisation compatibility during schema evolution, the author must add new enum attributes at the bottom of the enum declaration. This leaves existing implicit representation values unchanged. Every existing attribute must continue to have the same representation value for every valid instantiation, and each new attribute must have a value that does not collide with an existing historical value for any valid instantiation.
Existing enum attributes are dropped with an explicit remapping to a current attribute so old serialised values
are deserialised. The dropped attribute is kept in the enum declaration with a dropped prefix, followed
by as and the current replacement attribute. Dropped attributes are not values of the current enum type.
They remain only as schema evolution metadata for interpreting old serialised values. A dropped
entry remains in the sequence used to determine subsequent implicit values. It is an error for old
serialised enum values to have no current interpretation.
enum OrderStatus
{
pending = 0,
accepted = 1,
dropped cancelled = 2 as rejected,
rejected = 3
}
Variant types are closed: the attributes are listed in the variant declaration. Their serialised representation follows the same representation-value stability rule as enums, followed by the serialised representation of the selected variant attribute's value. Variant attributes do not have explicit discriminant values; the variant discriminant is the zero-based position of the attribute entry, counting both current and dropped entries. Attribute names are not stored in the serialised representation, so an attribute can be renamed without schema evolution when it keeps the same position and type.
New variant attributes are added at the bottom of the variant declaration. Existing attributes keep their position and type. Changing the type of an existing variant attribute is not an in-place change; it requires a new attribute or an explicit remapping from the old attribute.
A dropped variant attribute remains in the declaration as schema evolution metadata and remaps old serialised values to a current variant attribute. The remapping expression is evaluated with the old attribute value bound to the dropped attribute name and produces the value for the replacement attribute.
variant Shape
{
Circle circle;
Rectangle rectangle;
dropped Square square as rectangle = square.boundingRectangle;
}
Dropped attributes remain part of schema evolution metadata, but they are not current attributes. Current schema expressions see only current attributes. Derived attributes, constraints, receiver expressions, and constructor initializers are encoded against the current type. If a current schema expression refers to a dropped attribute, the compiler reports that the attribute was dropped instead of treating it as a current attribute.
For example, after Point.x is dropped, a constructor such as
Point { x = 1 } is a schema error.
Evolve functions are used to generate deserialisation code. They are not ordinary current-schema expressions. Their parameters name historical input fields from older serialised representations.
In:
r(x,y) { r = sqrt(x*x + y*y); }
the names x and y are historical fields used while deserialising old
data. They are not current attributes of this.
Component release metadata is declared in the component manifest. Release metadata is only needed
for schema evolution; ordinary name lookup uses source-file using declarations.
A releases block is required for a component when serialised data contains values or entities
implemented by that component and an older serialised representation must be deserialised by a newer
release. This is the normal case for persistent database components. The release number is the
key used to interpret the historical representation before evolve functions produce the current
representation.
A releases block is therefore required when a component records type-local representation
evolution with schema, dropped, or evolve, and when serialised representations of values or
entities implemented by the component include serialised representation dependencies. Another
component is a serialised representation dependency for a component release when a serialised value
or entity of the component contains representation supplied by that other component, such as a
has-a attribute whose type is
implemented there or embedded inherited state whose representation is implemented there.
For each recorded component release, the release entry records the release number of every component that is a serialised representation dependency in that release. The dependency names are fully qualified component names. This lets old data be read deterministically after either the component or a represented dependency has evolved. Components used only for name lookup, constraints, formulas, contracts, or other non-serialised schema logic do not need release numbers in the entry.
Release metadata conforms to the schema evolution model only when the recorded release graph is coherent. Every dependency release number must name a release declared by that dependency component. For a dependency component that appears in more than one release entry, the recorded dependency release numbers must be non-decreasing as the depending component's release number increases. A later component release keeps using the same dependency release or moves to a newer one; it does not revert to an earlier dependency release.
A component with no historical deserialisation contract omits the releases block: its values and
entities are not serialised for later reading across releases, and it has no historical serialised
representations to describe. Omitting the block means the component declares no release sequence
for schema evolution.
component SpaceShuttle;
releases
{
0 :
{
name: "Release 1.0",
date: (2012,April,18),
acme.Geometry: 1,
nasa.CommsModule: 0
},
1 :
{
name: "Release 1.1",
date: (2015,July,11),
acme.Geometry: 2,
nasa.CommsModule: 3,
nasa.Scrubber: 2
},
2 :
{
name: "Release 1.2",
date: (2015,August,24),
acme.Geometry: 2,
nasa.Scrubber: 2
},
3 :
{
name: "Release 2.0",
date: (2018,March,6),
acme.Geometry: 4,
nasa.Scrubber: 3,
nasa.Telemetry: 0
}
}
The manifest records release dependency versions, not the component's ordinary import graph. A
source file uses using declarations to introduce component aliases or selected names.
Definition (release dependency): component C1 is a release dependency of component C2 at
release R when C2's release R entry records a release number for C1.
The release dependency graph for a recorded release must be acyclic. Equivalently, no component release directly or indirectly requires itself while interpreting a serialised representation.
The release dependency closure of a component release is the transitive closure of its release dependencies for that release.
A component release fixes the releases of its serialised representation dependencies. This mapping is explicit and forms part of the component's release metadata. Release dates are metadata only and do not determine dependency versions.
This allows for:
The Predica standard library is provided as an ordinary component named
std. It is resolved from an include root in the same way as any
other component.
-I root/
std/
_component_.predica
folds.predica
With root/ supplied as an include root, this tree defines the
component std. The file _component_.predica marks
the component root, and folds.predica contains the standard fold
declarations.
Every Predica source file can use selected standard library declarations without writing a
using std; declaration. For example, standard folds such as count, exists, and empty
are available as Σcount, Σexists, and Σempty. The standard library component can also
be named explicitly as std.
The standard fold declarations are:
component std;
// Plain E.Σ is primitive syntax and is not declared as a named fold.
// Cardinality folds
fold Integer count<Type T> = Σ{ 0 ; n value ↦ n + 1 };
fold Boolean exists<Type T> = Σ{ false ; found value ↦ true };
fold Boolean empty<Type T> = Σ{ true ; empty value ↦ false };
// Boolean folds
fold Boolean all =
Σ{ true ; result value ↦ result ∧ value };
fold Boolean any =
Σ{ false ; result value ↦ result ∨ value };
// Numeric folds
fold T sum<Type T> where T isa Real =
Σ{ 0 ; total value ↦ total + value };
fold T product<Type T> where T isa Real =
Σ{ 1 ; total value ↦ total * value };
fold Real mean<Type T> over input where T isa Real =
input.Σsum / input.Σcount
defined where input.Σcount > 0;
fold Real variance<Type T> over input where T isa Real =
((input - input.Σmean)^2).Σmean
defined where input.Σcount > 0;
fold Real stddev<Type T> over input where T isa Real =
sqrt(input.Σvariance)
defined where input.Σcount > 0;
// Ordered folds
fold T min<Type T> where ordered(T) =
Σ{ first value ↦ value ; best value ↦ if value < best then value else best };
fold T max<Type T> where ordered(T) =
Σ{ first value ↦ value ; best value ↦ if best < value then value else best };
// Ordered-input folds
fold String concat =
Σ{ "" ; text value ↦ text + value };
The standard cardinality folds satisfy the following laws for finite inputs:
items.Σexists = (items.Σcount > 0)
items.Σempty = (items.Σcount = 0)
items.Σexists = not items.Σempty
These equations are laws, not definitions. Σexists and Σempty are defined directly so an
implementation can recognise their logical meaning and avoid counting every value when that is not
needed.
Some standard folds are intentionally listed before all of their supporting type constructors or
declaration forms have been specified. For example, Σgroup remains part of the standard fold
vocabulary as a planned map-producing fold even though its full declaration depends on the map and
grouping model. Such entries are specification todos and should remain visible in the standard
library chapter. A planned entry does not by itself introduce a usable std declaration; the
fold becomes a standard fold when this chapter specifies its declaration, typing, and definedness
rules.
The standard library defines the common exception supertype used by catch clauses:
abstract value Exception
{
String message?;
}
Concrete runtime exception values are instances of implementation-provided or
library-provided subtypes of Exception. The standard prelude makes Exception
available unqualified.
The declarations in std are ordinary component declarations, but every source
file has a standard prelude scope. The prelude makes selected standard names
available unqualified and makes std available as an explicit component
qualifier. For folds this means the common standard folds are used without
qualifying the component name:
items.Σcount
items.Σexists
items.Σempty
These expressions refer to std.count, std.exists, and std.empty. The
standard spelling is deliberately items.Σcount, not items.Σstd.count.
However, explicit qualification with std is legal and denotes the same fold:
items.Σcount // canonical spelling for std.count
items.Σstd.count // explicit qualified spelling for the same fold
Renderers and examples should prefer the unqualified standard spelling. The component name is still part of declaration identity and may be written when explicit disambiguation is useful.
The std qualifier is not a way to opt in to every aggregate name. A fold
such as items.Σstd.median or items.Σstd.collapse is legal only if the
standard library component actually declares median or collapse. In this
specification those names are ordinary fold names, not predefined standard
folds.
The prelude is not equivalent to writing using std;. Under the
ordinary using rules, using std; would introduce a component alias
only. The prelude also provides selected unqualified standard names.
Other components may define folds with the same local names as standard folds.
For example, a statistics package may define its own count fold with different
empty-input or weighting semantics. Such a declaration is legal, but an
unqualified Σcount continues to mean the standard library fold. To use the
third-party fold, qualify it with a component name or component alias:
using vendor.Stats as stats;
items.Σstats.count
Alternatively, a selected import may give the third-party fold a distinct local name:
using vendor.Stats { count as weightedCount };
items.ΣweightedCount
A selected import must not bind a non-standard fold to a local name that is a
standard prelude fold name. For example, if count and mean are standard
fold names, then this import is invalid:
using vendor.Stats { count, avg as mean };
The invalid import would try to bind vendor.Stats.count as the local fold name
count, and vendor.Stats.avg as the local fold name mean. Those local names
already belong to the standard prelude folds std.count and std.mean. The
user must either qualify the vendor folds or import them with distinct aliases:
using vendor.Stats as stats;
using vendor.Stats { count as statsCount, avg as statsMean };
items.Σcount // std.count
items.Σmean // std.mean
items.Σstats.count // vendor.Stats.count
items.Σstats.avg // vendor.Stats.avg
items.ΣstatsCount // vendor.Stats.count
items.ΣstatsMean // vendor.Stats.avg
This rule keeps existing source stable when new libraries are imported: adding a
third-party component cannot silently change the meaning of an existing
standard fold such as Σcount.
Names that are not standard prelude fold names remain available to user
components. A component may declare a fold named median, collapse, or any
other non-standard local name, subject to the usual declaration and import
rules. An unqualified use such as items.Σmedian is valid only when it
resolves to such a visible declaration; otherwise it is an unresolved fold
name.
This part relates Predica's domain-and-attribute style to the familiar normal forms of the Relational Model. The goal is not to turn Predica schemas into unnested tables and then normalise them. The normal forms are used as diagnostics: they help identify facts stored on the wrong subject, independent facts combined together, and derived combinations stored as base facts.
The early chapters cover 1NF, 2NF, 3NF, and BCNF. In this setting, 1NF is mainly about the distinction between an attribute's declared type and the cardinality of its extension. 2NF, 3NF, and BCNF diagnose several ways a fact can be attached to the wrong subject. The later chapters cover 4NF, 5NF, 6NF, and DKNF: independent many-valued facts, join dependencies, decomposition into irreducible facts, and the ideal of reducing constraints to consequences of domains and keys.
The type system distinguishes between entity types and value types. That distinction is discussed in 9 Types, but normalisation uses a more general point: both kinds of type define domains with attributes, and both kinds can participate in dependencies between attributes. Recursive decomposition lets a model replace misplaced dependencies with smaller, more explicit domains. In a decomposed model, each type can remain simpler rather than accumulating facts that belong to another domain.
Attributes of an entity are unary predicates over its domain; attributes of a value type are unary predicates over the value itself. Types may also have mathematically derived attributes, but normalisation is concerned with a broader notion of dependency: constraints on which database states are possible. For example, if a manufacturer is assumed to exist in exactly one country, then manufacturer determines country even when country is not computed from the manufacturer name. Recursive decomposition of both entity and value types yields a hierarchy of domains in which facts are attached to the domains they describe, usually producing simpler types.
This perspective clarifies the intent behind Codd's First Normal Form, or 1NF. Rather than treating "atomic" as a vague prohibition on internal structure, it is better to separate Codd's original formulation from the Predica version. Codd required types to be simple and have no internal structure. Predica does not make that requirement. Codd's notion of atomicity depends on an informal judgement about whether a type is simple. Predica replaces that judgement with a syntactic and semantic distinction: structure is allowed inside a type, but multiplicity belongs to the extension of an attribute, not to the attribute's declared type. Predica therefore preserves the part of 1NF concerned with avoiding nested tuple values, relation values, repeating groups, and collections stored as single attribute values. A declared type may itself have structure, but each reached attribute value is still one value of that type. What violates Predica's version of 1NF is treating a repeating group or collection as one attribute value instead of using an attribute extension with the appropriate cardinality.
In this way, recursive decomposition using both entity and value types gives a Predica interpretation of Codd's concern for atomicity: normalisation is not merely an external process applied to tables, but a natural consequence of expressing the world as a network of well-defined domains, each with its own type, definition, and attributes.
The following example violates Predica's version of 1NF:
entity Person
{
String name;
Set<String> emails;
key {name};
}
The attribute emails has a collection type, so the collection of email addresses is being stored as
one attribute value.
The violation is apparent in both the plural attribute name emails and its collection type
Set<String>. The fix is to make both the attribute and its type singular, and to relax the
cardinality constraint on the attribute's extension:
entity Person
{
String name;
String email*;
key {name};
}
Here email has the singular type String. The * cardinality says that the predicate may hold for
zero or more email values, so multiplicity is represented by the extension of the attribute rather than
by a set-valued attribute value.
Informally, Second Normal Form, or 2NF, says that every non-key attribute should depend on the whole of each candidate key, not merely on part of a composite key.
The following relation has the composite key {shop, book}:
The functional dependency {shop} → {shop_city} is visible in the repeated city values. The city depends
on the shop, not on the whole {shop, book} key. The same modelling mistake can be written in Predica:
entity BookStock
{
String shop;
String book;
String shop_city;
key {shop, book};
}
Here shop_city is a fact about the shop, but it is stored on BookStock.
If the model is left in this shape, the functional dependency has to be imposed as a constraint over
the containing collection of BookStock values. One way to write it is to say that there is no pair
of stock records with the same shop and different shop cities:
entity BookStore
{
BookStock stock*;
constraints
{
let store = this in
stock.(let x = this in
store.stock.[
shop = x.shop
∧ shop_city ≠ x.shop_city
].Σempty);
// Alternatively
stock.group(g)by(shop).(g.shop_city : ?);
}
}
This constraint is the Predica form of {shop} → {shop_city}. It is non-local because BookStock
does not contain a single independently updatable city fact for each shop; instead the city fact is
repeated across the stock records for that shop.
The fix is to separate the fact about the shop from the fact that a shop stocks a book. In relational form, this decomposes the original relation into two relations:
In Predica, the same repair is to model Shop explicitly:
entity Shop
{
String name;
String city;
key {name};
}
entity BookStock
{
Shop shop;
String book;
key {shop, book};
}
entity BookStore
{
Shop shop*;
BookStock stock*;
}
After this repair, BookStore contains shops and stock records directly, and the cross-record FD
constraint on BookStock is no longer needed. The city is a local attribute of Shop, so changing a
shop's city has a natural update target and does not require coordinated updates to every book stocked
by that shop.
Informally, Third Normal Form, or 3NF, says that a non-key attribute should not depend on another non-key attribute.
The following relation has the key {emp_id}:
Assume the following functional dependencies hold:
{emp_id} → {dept_id}
because each employee is assigned to one department
{dept_id} → {dept_name}
because each department identifier has one department name
The repeated department names make the transitive dependency visible.
The same modelling mistake can be written in Predica. The RM-style attribute names become contextual
inside the Employee type, so emp_id is written as id:
entity Employee
{
Integer id;
Integer dept_id;
String dept_name;
key {id};
}
Here dept_name does not depend directly on the key. It depends on another non-key attribute,
dept_id, which is being used as an identifier for an unmodelled department.
If the model is left in this shape, the dependency from department identifier to department name has to be imposed over the containing collection of employees. One way to write it is to say that there is no pair of employees with the same department identifier and different department names:
entity Company
{
Employee employee*;
constraints
{
let company = this in
employee.(let e = this in
company.employee.[
dept_id = e.dept_id
∧ dept_name ≠ e.dept_name
].Σempty);
}
}
This constraint is the Predica form of {dept_id} → {dept_name}. It is non-local because the
department name is repeated on employee records instead of being a local fact about a department.
The fix is to separate the facts about employees from the facts about departments. In relational form, this decomposes the original relation into two relations:
In Predica, the same repair is to model Department explicitly:
entity Department
{
Integer id;
String name;
key {id};
}
entity Employee
{
Integer id;
Department dept;
key {id};
}
entity Company
{
Department department*;
Employee employee*;
}
After this repair, the cross-employee FD constraint is no longer needed. The department name is a
local attribute of Department, and each employee points to the department they belong to.
Informally, Boyce-Codd Normal Form, or BCNF, says that whenever a set of attributes determines some other attribute, that determinant should be a superkey.
The following relation records the lecturer for each student's course enrolment:
Assume the following functional dependencies hold:
{student, course} → {lecturer}
because a student's enrolment in a course has one lecturer
{lecturer} → {course}
because each lecturer teaches exactly one course
The second dependency is visible in the repeated lecturer-course pairs. It violates BCNF because
lecturer is not a superkey, yet {lecturer} → {course} holds. It can still satisfy 3NF because
course is part of a candidate key: {student, lecturer} also determines all attributes.
entity CourseEnrolment
{
String student;
String course;
String lecturer;
key {student, course};
key {student, lecturer};
}
Here course is a fact about the lecturer, but it is stored on CourseEnrolment.
If the model is left in this shape, the dependency from lecturer to course has to be imposed over the containing collection of enrolments. One way to write it is to say that there is no pair of enrolments with the same lecturer and different courses:
entity University
{
CourseEnrolment enrolment*;
constraints
{
let university = this in
enrolment.(let e = this in
university.enrolment.[
lecturer = e.lecturer
∧ course ≠ e.course
].Σempty);
}
}
This constraint is the Predica form of {lecturer} → {course}. It is non-local because the course
taught by a lecturer is repeated on enrolment records instead of being a local fact about a lecturer.
The fix is to separate the fact about the lecturer's course from the fact that a student is enrolled with a lecturer. In relational form, this decomposes the original relation into two relations:
In Predica, the same repair is to model Lecturer explicitly:
entity Lecturer
{
String name;
String course;
key {name};
}
entity CourseEnrolment
{
String student;
Lecturer lecturer;
key {student, lecturer};
}
entity University
{
Lecturer lecturer*;
CourseEnrolment enrolment*;
}
After this repair, the cross-enrolment FD constraint is no longer needed. The course is a local
attribute of Lecturer, and each enrolment points to the lecturer responsible for that enrolment.
Informally, Fourth Normal Form, or 4NF, says that independent multi-valued facts should not be combined into one relation merely because they concern the same key.
The following relation records the skills and languages known by a student:
Assume the following multi-valued dependencies hold. Here ↠ denotes a multi-valued dependency:
{student} ↠ {skill}
because each student has a set of skills
{student} ↠ {language}
because each student has a set of languages
For a given student, the skills and languages are independent many-valued facts. The rows for Alice contain every combination of her skills and languages, even though knowing Logic and speaking French are separate facts. The same modelling mistake can be written in Predica:
entity StudentCapability
{
String student;
String skill;
String language;
key {student, skill, language};
}
Here the independent many-valued facts have been combined into one entity type.
If the model is left in this shape, the multi-valued dependencies have to be imposed over the containing collection of capability records. One way to write the constraint is to say that for any two rows for the same student, the recombined row must also exist:
entity School
{
StudentCapability capability*;
constraints
{
capability.(student, skill, language).Σ =
(
capability.(student, skill)
⊓ capability.(student, language)
).Σ;
}
}
This constraint says that the stored StudentCapability records must be closed under recombining
skills and languages for the same student. The equality says that the stored ternary relation is
exactly the join of its two binary projections. It is non-local because the representation stores a
Cartesian product of independent facts instead of storing each student's skills and languages as
separate predicate extensions.
The fix is to represent the two independent many-valued facts separately. In relational form, this decomposes the original relation into two relations:
In Predica, the same repair is to give the student two separate attributes with many-valued extensions:
entity Student
{
String name;
String skill*;
String language*;
key {name};
}
entity School
{
Student student*;
}
The attributes skill and language still have singular types. Their multiplicity is represented
by separate attribute extensions, so Predica does not need to manufacture the Cartesian product of
skills and languages.
This repair is different from the 2NF, 3NF, and BCNF examples. There the problem was a fact stored too high up, and the fix was to introduce a more specific domain. Here the problem is an unnecessary combination of independent many-valued facts. The fix is to keep those facts as separate attributes of their common subject.
This is one of the places where Predica's treatment of attribute extensions is especially useful. Independent multiplicities can remain independent predicates. They do not have to be forced into a combined relation unless the combinations themselves are meaningful facts.
Informally, Fifth Normal Form, or 5NF, says that a relation should not store a fact that can be reconstructed without loss from smaller projections.
The following relation records which suppliers may supply which parts to which projects:
Assume the business rule is that a supply assignment is valid exactly when three binary facts all hold: the supplier can supply the part, the supplier is approved for the project, and the project uses the part. Under that rule, the ternary relation is determined by a join dependency rather than by an irreducible ternary fact:
SupplyAssignment = SupplierPart ⨝ SupplierProject ⨝ ProjectPart
Unlike the 2NF, 3NF, and BCNF examples, this is not a functional-dependency problem. The problem is that the whole ternary relation is determined by its binary projections.
The same modelling mistake can be written in Predica:
entity SupplyAssignment
{
String supplier;
String part;
String project;
key {supplier, part, project};
}
Here one entity type stores a ternary fact even though the intended rule is the conjunction of three binary facts.
If the model is left in this shape, the join dependency has to be imposed over the containing collection of supply assignments. Since the binary facts are projections of the stored ternary assignments, the constraint says that whenever those projections support a ternary assignment, that assignment must be present:
entity SupplySystem
{
SupplyAssignment assignment*;
constraints
{
assignment.(supplier, part, project).Σ =
(
assignment.(supplier, part)
⊓ assignment.(supplier, project)
⊓ assignment.(project, part)
).Σ;
}
}
This is awkward because the base representation stores the derived ternary relation and then has to be closed under the join of its binary projections. The equality says that the stored ternary relation is exactly the join of the three binary projections. The constraint is doing work that would disappear if the three binary facts were stored directly.
The fix is to store the three binary facts directly. In relational form, this decomposes the original relation into three relations:
In Predica, the same repair is to model the participating domains explicitly and put the attributes on the domains where they belong:
entity Supplier
{
String name;
Part part*;
Project project*;
key {name};
}
entity Project
{
String name;
Part part*;
key {name};
}
entity Part
{
String name;
key {name};
}
entity SupplySystem
{
Supplier supplier*;
Project project*;
Part part*;
}
The RM decomposition uses three binary relations. The Predica schema expresses the same information
as unary predicates on domains: Supplier.part, Supplier.project, and Project.part. If the
ternary view is needed, it can be derived from those attributes. The base schema does not need a
separate SupplyAssignment entity unless the assignment itself has independent facts.
Part and Project are modelled as entity types here because the example treats them as domains in
their own right. In particular, Project has its own part* attribute, so it is not merely an
uninterpreted name.
Superkey Normal Form, or SKNF, is a technical normal form between 4NF and 5NF. A relvar is in SKNF when every component of every irreducible join dependency it satisfies is a superkey. 5NF implies SKNF, and SKNF implies 4NF, but neither converse holds. Conditions equivalent to SKNF have sometimes been presented incorrectly as definitions of 5NF.
For Predica, SKNF can refine the analysis of an entity or relationship type whose extension represents an n-ary fact. Its practical question is still whether the fact is irreducible or should be represented by smaller predicate extensions. The existing 5NF analysis captures that modelling decision, so SKNF is noted here for theoretical completeness rather than treated as a separate Predica design stage.
Essential Tuple Normal Form, or ETNF, refines the connection between join dependencies and tuple redundancy. A relvar is in ETNF when every tuple in every permitted value of the relvar is essential, meaning that it is neither partly nor wholly redundant. ETNF is strictly weaker than 5NF, but is exactly as effective as 5NF at excluding redundant tuples. Thus 5NF can require a decomposition that is not required merely to eliminate tuple redundancy.
Redundancy Free Normal Form, or RFNF, considers redundancy at the finer level of an attribute value within a tuple. Informally, such a value is redundant when the constraints make it uniquely determined by the rest of the relation. RFNF requires that no permitted relation value contain such a redundant value occurrence.
Predica does not need independently declared relvars in order to apply these ideas. Attribute
extensions and relation denotations such as assignment.(supplier, part, project).Σ can still be
examined for redundant tuples and values. In the example above, the stored ternary extension is
determined by the three smaller predicate extensions, so it should be derived rather than stored as
an independent base fact. More generally, the Predica interpretation of ETNF and RFNF is to ask
whether each stored predicate fact is essential, or whether it is already forced by the remaining
database state and its constraints.
Sixth Normal Form, or 6NF, is defined in terms of join dependencies. A relvar is in 6NF if and only if every join dependency it satisfies is trivial. A join dependency is trivial when one of its components is the entire heading of the relvar. Equivalently, a 6NF relvar cannot be nonloss decomposed into smaller projections.
Informally, 6NF decomposes relations into irreducible facts. In the common case of a relvar with a key and several non-key attributes, this produces one relvar for each non-key fact, with each relvar recording the key together with one non-key attribute. This familiar shape is a consequence of the join-dependency definition, not the definition of 6NF itself.
The following relation records several facts about suppliers:
A 6NF decomposition separates those facts into one relation per non-key attribute:
This is close in spirit to Predica's representation of attributes as unary predicates. The Predica schema keeps the facts grouped under the domain they describe, while the denotation into logic still has one unary predicate for each attribute:
entity Supplier
{
String id;
String name;
Integer status;
String city;
key {id};
}
Predica is therefore not merely the RM written in 6NF. It has entity, value, enum, variant, quotient, and structural types; attributes may have cardinality; and path expressions navigate between domains. But the 6NF instinct is useful: facts should be decomposed until each one has a clear subject and a clear attribute.
Domain-Key Normal Form, or DKNF, is an ideal normal form in which every constraint on a relvar is a logical consequence of domain constraints and key constraints. A domain constraint restricts the values that may appear for an attribute, while a key constraint states that certain attributes uniquely identify a tuple. If some further constraint is needed to prevent an invalid relvar value, then the relvar is not in DKNF.
DKNF is stronger in scope than the normal forms based on particular classes of dependencies. Rather than considering functional, multivalued, or join dependencies separately, it asks whether all of the constraints have been reduced to two fundamental kinds. It is an ideal rather than a normal form that every practical design can necessarily attain.
Consider a relation that records an employee, the employee's department, and the manager of that department:
Suppose each employee belongs to one department and each department has one manager. The relation
therefore satisfies the dependencies employee -> department and department -> manager. If
employee is the key, the second dependency is not implied merely by the attribute domains and that
key. A separate constraint is required to prevent two employees in the same department from naming
different managers. The relation is therefore not in DKNF.
The familiar relational repair decomposes the facts according to their determinants. One relation records each employee's department, and another records each department's manager. The dependency from department to manager is then represented by a key of the latter relation rather than by an additional constraint over the former.
Predica can express the same repair by introducing the domain that is the subject of the manager fact:
entity Department
{
String name;
String manager;
key {name};
}
entity Employee
{
String name;
Department department;
key {name};
}
entity Organisation
{
Department department*;
Employee employee*;
}
The manager is now an attribute of Department, the domain it describes. Employees refer to values
of type Department instead of repeating a department name and manager combination. The constraint
that was previously stated across employee tuples has been absorbed into the type structure, the key
of Department, and the cardinality of its attributes.
This illustrates why the DKNF principle is especially relevant to Predica. Predica domains are not limited to primitive scalar types. Entity, value, enum, variant, quotient, intersection, and union types can express semantic distinctions directly, and their definitions can include keys and attributes. Introducing the right nominal domain can therefore convert some apparently global constraints into local consequences of the type structure.
The relational definition of DKNF mentions only domain and key constraints. A direct Predica analogue would be broader because cardinality constraints belong to predicate extensions rather than to value types, and subtype and relationship structure can also constrain valid database states. It is therefore better to treat DKNF as a design principle than to claim that these additional constructs are part of relational DKNF.
Nor should every Predica constraint be forced into a type or key declaration. Some business rules genuinely relate several independently meaningful predicate extensions. Such a rule remains a general constraint on the database type. The useful DKNF question is whether a constraint is genuinely global, or whether its presence reveals a missing domain, a misplaced fact, or an undeclared key.
Normal-form violations are not random. They are often the algebraic footprint of a misplaced fact. The fact may be stored on the wrong subject, combined with facts that are independent of it, or stored as a base fact even though it is derivable from smaller facts.
The 2NF, 3NF, and BCNF examples all had this shape:
BookStock
Employee
CourseEnrolment
In each case an identifier was present, but the thing being identified was not modelled as the subject
of its own facts. The result was a partial, transitive, or non-superkey dependency. The repair was to
introduce a more specific domain: Shop, Department, or Lecturer.
This is a useful diagnostic: an identifier with attached facts often signals a domain. When a key identifies something and the model also records facts about that thing, that thing may deserve:
If that domain is not modelled explicitly, the schema often tries to repair itself by duplicating attributes, creating transitive dependencies, or embedding facts in the wrong place.
The 4NF example had a different shape. Skills and languages were independent many-valued facts about
the same student, but they were combined into StudentCapability. The repair was not to introduce a
more specific domain. It was to keep the independent facts as separate attributes with many-valued
extensions on their common subject.
The 5NF example had another shape. SupplyAssignment stored a ternary combination even though the
intended rule was the conjunction of smaller facts: supplier-part, supplier-project, and project-part.
The repair was to store those smaller facts directly and derive the ternary view when needed.
The 6NF example pushes the same instinct further: decompose facts until each one has a clear subject and a clear attribute. Predica does not require users to manually create one relation per attribute, because attributes are already unary predicates on domains. But the normal-form diagnosis remains useful: ask what each fact is about, whether it is independent of nearby facts, and whether it should be stored at all.
When the model uses an identifier for a thing that has its own properties, relationships, or lifecycle, model that thing explicitly. In many business schemas that means introducing an entity type. For mathematical values, use an enum, value, variant or quotient type. When the problem is not a missing domain, avoid inventing a false combination: keep independent many-valued facts separate, and derive composite views from their smaller facts.
// Component: geometry
value Point
{
Real x;
Real y;
}
@domain("A circle which is a locus of points in ℝ^2")
value Circle
{
@predicate("The centre of the circle is [centre]")
Point centre;
@predicate("The radius of the circle is [radius]")
Real radius;
}
entity Wheel
{
embed Circle;
Real mass;
}
var x = 10;
var s = "hello";
var y = x + 1;
var p = Point { x = 3.2, y = 4.7 };
var c = Circle { centre = geometry.Point { x = 1.0, y = 2.0 }, radius = 5.0 };
var r = c.radius;
r := r + 1;
print p,c,r;
/*
Alternative embed forms:
value Circle
{
Point centre;
Real radius;
}
entity Wheel
{
embed Circle; // get centre and radius
Real mass;
}
entity Wheel
{
embed Circle as circle; // get circle, centre, radius
Real mass;
}
*/
In an event-source system entity types are event types. For example:
namespace Genealogy
{
enum Month
{
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec
}
// By default order is lexicographic on the order in which the base attributes appear.
value Date
{
Integer year;
Month month;
Integer day;
}
value Time
{
Integer hour where hour ∈ [0..23];
Integer minute where minute ∈ [0..59];
Integer second where second ∈ [0..59];
}
/*
value Date
{
Integer year;
Month month;
Integer day;
// Lexicographic order on year descending then month ascending then day ascending
order [year ↓, month, day];
}
// Another way to define an order
Boolean <(Date d1, Date d2) =
d1.year < d2.year
∨ (d1.year = d2.year ∧ d1.month < d2.month)
∨ (d1.year = d2.year ∧ d1.month = d2.month ∧ d1.day < d2.day);
*/
value DateTime
{
Date date;
Time time;
}
/*
In a true events database, the GenealogySystem would just record events in base attributes.
Person person* would be a derived attribute!
entity GenealogySystem
{
BirthEvent birth*;
DeathEvent death*;
MarriageEvent marriage*;
DivorceEvent divorce*;
→ Person person* =
birth.child
| birth.mother
| birth.father
| death.person
| marriage.spouse
| divorce.spouse;
}
*/
entity GenealogySystem
{
Person person*;
→ MarriageEvent marriage* = person.marriage;
→ DivorceEvent divorce* = person.divorce;
→ birth* = (person,person.birth);
→ death* = (person,person.death);
}
enum Gender
{
male,
female
}
entity Person
{
Integer id;
String name;
embed Gender;
BirthEvent birth?;
DeathEvent death?;
MarriageEvent marriage* where let p=this in marriage.(p ∈ spouse.Σ);
DivorceEvent divorce* where let p=this in divorce.(p ∈ spouse.Σ);
→ Person mother? = birth.mother;
→ Person father? = birth.father;
→ Person parent* = mother | father;
Person child* where let p=this in child.(p ∈ parent.Σ);
→ Person grandparent* = parent.parent;
→ Person greatgrandparent* = grandparent.parent;
→ Person grandchild* = child.child;
→ Person greatgrandchild* = grandchild.child;
→ Person son* = child.[male];
→ Person daughter* = child.[female];
→ Person sibling* = let p=this in parent.child.[this ≠ p];
→ Person brother* = sibling.[male];
→ Person sister* = sibling.[female];
→ Person cousin* = parent.sibling.child;
→ Person cousinOnceRemoved* = cousin.child | parent.cousin;
→ Person secondCousin* = parent.cousin.child;
→ Person ancestor* = parent | parent.ancestor;
→ Person descendant* = child | child.descendant;
→ Person auntOrUncle* = parent.sibling;
→ Person aunt* = auntOrUncle.[female];
→ Person uncle* = auntOrUncle.[male];
→ Person nieceOrNephew* = sibling.child;
→ Person niece* = nieceOrNephew.[female];
→ Person nephew* = nieceOrNephew.[male];
→ Person parentInLaw* = spouse.parent;
→ Person siblingInLaw* = spouse.sibling | sibling.spouse;
→ Person childInLaw* = child.spouse;
// Doesn't account for remarry
//→ Person spouse* = marriage.spouse ∖ (this | divorce.spouse);
→ RelationshipEvent? lastRelationshipEvent = (marriage | divorce).orderBy(datetime).last;
// Assuming monogamy
→ Person spouse? =
if lastRelationshipEvent is MarriageEvent
then lastRelationshipEvent.spouse ∖ this
else none;
→ Boolean married = (spouse ≠ none);
@predicate("The person's current age is [age]")
→ Integer age? =
if birth ≠ none ∧ death ≠ none then
yearsBetween(death.datetime, birth.datetime)
else if birth ≠ none then
yearsBetween(now, birth.datetime)
else
none;
/*
@predicate("The person is currently married")
→ Boolean married =
(marriage | divorce)
.orderBy(datetime)
.Σ{ false ;
marriedSoFar evt ->
if evt is MarriageEvent then true
else if evt is DivorceEvent then false
else marriedSoFar
};
*/
key {id};
constraints
{
// ∀M:Person (this.mother(M) → M ≠ this)
mother ≠ this;
// ∀F:Person (this.father(F) → F ≠ this)
father ≠ this;
// ∀M:Boolean ∀A:Integer (this.married(M) ∧ this.age(A) ∧ M → A ≥ 18)
married → age ≥ 18;
}
}
entity BirthEvent
{
DateTime datetime;
String location?;
Person mother?;
Person father?;
constraints
{
mother.female;
father.male;
}
}
entity DeathEvent
{
DateTime datetime;
String location?;
}
abstract entity RelationshipEvent
{
Person spouse[2];
DateTime datetime;
// This key constraint on an abstract entity matters if there is an attribute of type
// RelationshipEvent that allows for cardinality greater than 1.
// It matters for intermediate expressions too, such as (marriage | divorce)
key {spouse, datetime};
}
entity MarriageEvent isa RelationshipEvent
{
String location?;
}
entity DivorceEvent isa RelationshipEvent
{
}
}
Derived attributes provide logical independence by allowing queries to be written without regard for the distinction between base and derived attributes.
As an example, p.(name, sibling) is equivalent to p.(name, parent.child ∖ this as sibling).
p.(sibling, cousin, ancestor, niece) is equivalent to
p.(parent.child ∖ this as sibling,
parent.sibling.child as cousin,
(parent | parent.ancestor) as ancestor,
sibling.child.[gender=female] as niece)
A useful principle when developing a Predica schema is to push information down into the
domain where it naturally belongs. This tends to produce a more modular and simpler
design. When an entity type is defined over a smaller, more specific domain, it no
longer needs an explicit identifier. The surrounding context provides identity
implicitly. For example, in this schema a BirthEvent does not need to identify the
person who was born, because the event is stored under that person.
Birth and death events are therefore pushed down into the domains where they belong. A birth event belongs to the person being born, and likewise a death event belongs to the person who died.
Pushing marriages and divorces down into Person eliminates the need for using inverse to
derive inverse relationships. This is a case where it is better to derive global populations
(which involves navigation downwards) than to make them base and require subdomains to navigate
upwards.
This fits the Predica principle of pushing information downward to where it belongs.
As a result, many global populations become derived, not base. For example, the
global marriage* and divorce* populations are defined by downward navigation: start at the
system, go down to all persons, and collect their marriage or divorce events. This is cheap,
clean, and compositional.
entity GenealogySystem
{
Person person*;
→ MarriageEvent marriage* = person.marriage; // downward navigation
→ DivorceEvent divorce* = person.divorce; // downward navigation
}
Predica’s philosophy is: Information belongs where it is most specific. Derived information belongs where it is most general.
This genealogy schema is a perfect example:
Person.
Person.
This is the opposite of conventional RM-style modelling, where global relvars are base and relationships are reconstructed upward. Predica flips that on its head. The genealogy schema demonstrates why that’s better.
This schema achieves full, symmetric sharing of each marriage event between its two spouses without needing
a base relation of all marriage events.
Person.marriage* is the base representation, and GenealogySystem.marriage* is a derived population.
The key {spouse, date} of MarriageEvent canonicalises identity. This is the crucial mechanism.
If Alice and Bob both store a marriage event with the same spouse pair and date, then they refer to the same
logical MarriageEvent.
If only one spouse stores it, then the constraints force the other spouse to store it too.
This achieves shared identity without a global base relation.
The compiler can implement this with a single shared object because the key canonicalises identity,
the event appears under both spouses, the global population is derived, and there is no duplication.
Therefore, the compiler is free to implement one MarriageEvent object referenced by both Person objects,
with no global base relation, and no need for reconciliation logic
This is a very efficient object-centric representation.
Roughly speaking, encoded data in memory or on disk should be read or written in chunks whose size is on the order of the product of latency and bandwidth for the device. This product can be fairly large - e.g. 64 kB. In such cases it is advantageous to localise related information. In a genealogy database it is better to localise all information about a person so it can be accessed as a single chunk.
When folding over historical events, the fold must operate on a list ordered by event time.
Assignment-like updates are not commutative, so unordered folds over sets are not appropriate.
Predica expresses this by combining .orderBy(date) with a fold.
Predica’s derived attributes are not “computed columns” or “views”. They are semantic operators that extend the type itself. They let you build a vocabulary of domain concepts directly into the model, so that:
It is particular useful for event sourcing because derived attributes can hold the current state as folds over history.
Event-sourced systems represent the state of an entity not by storing its current attributes directly, but by storing the events that describe how the entity changed over time. The current state is obtained by folding over those events.
Predica’s general fold operator is a natural fit for this pattern. An event stream is simply a set of events, and a fold reduces that set to a single value by accumulating the effect of each event. Because folds are associative and order-insensitive, they work cleanly with Predica’s set-based semantics.
Suppose a person’s life events include birth, marriage, divorce, and death. We can reconstruct derived attributes by folding over the relevant events. When folding over history it is generally necessary to use lists ordered by date. This is because assignment is not commutative.
For example, to compute whether a person is currently married:
person.(marriages ∪ divorces)
.orderBy(date)
.Σ{ false ;
married evt ->
if evt is MarriageEvent then true
else if evt is DivorceEvent then false
else married
}
This fold walks through all marriage-related events and accumulates the
current marital status. The initial value is false, and each event updates
the accumulated value.
A person’s age can be reconstructed by folding over their birth and death events:
person.(birth, death)
.Σ{ none ;
age (b, d) ->
if b exists and d exists then d.date - b.date
else if b exists then today - b.date
else none
}
The fold combines the birth and death information into a single derived value.
Event sourcing often requires reconstructing collections, not just scalars. Folds make this straightforward:
// All children of a person
person.births.Σ{ {} ; a e -> a ∪ { e.child } }
// All spouses (past and present)
(person.marriages ∪ person.divorces)
.Σ{ {} ;
a e ->
if e is MarriageEvent then a ∪ { e.spouse1, e.spouse2 }
else a
}
These folds accumulate sets of related entities by walking the event history.
In this way, Predica treats event sourcing as a natural application of its
general reduction mechanism. The same fold operator that defines
Σsum and Σcount also reconstructs the state of an entity from its
event history.
namespace BOO
{
variant Shape
{
{ Real radius; } sphere;
{ Real radius; Real height; } cylinder;
{ Real side; } tetrahedron;
{ Real radius; Real height; } cone;
{ Real width; Real height; Real depth; } rectangular_prism;
{ Real a; Real b; Real c; } ellipsoid;
{ Real major_radius; Real minor_radius; } torus;
{ Real base_width; Real base_depth; Real height; } pyramid;
{ Real side; Real height; Real length; } triangular_prism;
{ Real radius; Real height; } capsule;
}
enum Element
{
iron, carbon, chromium, nickel, manganese,
molybdenum, vanadium, tungsten, cobalt, copper,
zinc, tin, lead, aluminium, magnesium, titanium,
silicon, phosphorus, sulfur
}
entity Metal
{
{ Element element; Real fraction; } component*;
}
enum Polymer
{
polyethylene, polypropylene, polystyrene, polyvinyl_chloride,
polyethylene_terephthalate, polycarbonate, polymethyl_methacrylate,
polyamide, polyurethane, epoxy, phenolic, silicone,
polytetrafluoroethylene, polyetheretherketone,
acrylonitrile_butadiene_styrene
}
entity PolymerBlend
{
{ Polymer polymer; Real fraction; } component*;
}
variant Material
{
wood;
glass;
ceramic;
concrete;
Metal metal;
PolymerBlend polymer_blend;
{ Real fiber_fraction; Real resin_fraction; } composite;
}
enum Color
{
red, green, blue, magenta, cyan, yellow
}
entity Object
{
Shape shape;
Material material;
Color color;
}
entity CountedObject
{
Object object;
Integer count;
key { object };
constraints { count > 0; }
}
entity Box
{
CountedObject countedObject*;
}
}
-- =========================================================
-- BOO namespace translated to Tutorial D
-- Fully relational, Information Principle compliant
-- Includes value-based uniqueness constraint for Object kinds
-- =========================================================
-- =========================================================
-- 1. Scalar types (Enums)
-- =========================================================
TYPE Element POSSREP { E CHAR };
CONSTRAINT E IN {
'iron','carbon','chromium','nickel','manganese',
'molybdenum','vanadium','tungsten','cobalt','copper',
'zinc','tin','lead','aluminium','magnesium','titanium',
'silicon','phosphorus','sulfur'
};
TYPE Polymer POSSREP { P CHAR };
CONSTRAINT P IN {
'polyethylene','polypropylene','polystyrene','polyvinyl_chloride',
'polyethylene_terephthalate','polycarbonate','polymethyl_methacrylate',
'polyamide','polyurethane','epoxy','phenolic','silicone',
'polytetrafluoroethylene','polyetheretherketone',
'acrylonitrile_butadiene_styrene'
};
TYPE Color POSSREP { C CHAR };
CONSTRAINT C IN { 'red','green','blue','magenta','cyan','yellow' };
-- =========================================================
-- 2. Object identity (surrogate for object *kinds*)
-- =========================================================
VAR Object BASE RELATION {
object_id INTEGER
}
KEY { object_id };
-- =========================================================
-- 3. Shape variant (10 constructors → 10 relations)
-- =========================================================
VAR Sphere BASE RELATION {
object_id INTEGER,
radius REAL
}
KEY { object_id };
VAR Cylinder BASE RELATION {
object_id INTEGER,
radius REAL,
height REAL
}
KEY { object_id };
VAR Tetrahedron BASE RELATION {
object_id INTEGER,
side REAL
}
KEY { object_id };
VAR Cone BASE RELATION {
object_id INTEGER,
radius REAL,
height REAL
}
KEY { object_id };
VAR RectangularPrism BASE RELATION {
object_id INTEGER,
width REAL,
height REAL,
depth REAL
}
KEY { object_id };
VAR Ellipsoid BASE RELATION {
object_id INTEGER,
a REAL,
b REAL,
c REAL
}
KEY { object_id };
VAR Torus BASE RELATION {
object_id INTEGER,
major_radius REAL,
minor_radius REAL
}
KEY { object_id };
VAR Pyramid BASE RELATION {
object_id INTEGER,
base_width REAL,
base_depth REAL,
height REAL
}
KEY { object_id };
VAR TriangularPrism BASE RELATION {
object_id INTEGER,
side REAL,
height REAL,
length REAL
}
KEY { object_id };
VAR Capsule BASE RELATION {
object_id INTEGER,
radius REAL,
height REAL
}
KEY { object_id };
-- =========================================================
-- 4. Material variant
-- =========================================================
-- Simple materials
VAR Wood BASE RELATION {
object_id INTEGER
}
KEY { object_id };
VAR Glass BASE RELATION {
object_id INTEGER
}
KEY { object_id };
VAR Ceramic BASE RELATION {
object_id INTEGER
}
KEY { object_id };
VAR Concrete BASE RELATION {
object_id INTEGER
}
KEY { object_id };
-- Metal with components
VAR MetalComponent BASE RELATION {
object_id INTEGER,
element Element,
fraction REAL
}
KEY { object_id, element };
CONSTRAINT fraction > 0.0;
-- Polymer blend with components
VAR PolymerComponent BASE RELATION {
object_id INTEGER,
polymer Polymer,
fraction REAL
}
KEY { object_id, polymer };
CONSTRAINT fraction > 0.0;
-- Composite material
VAR CompositeMaterial BASE RELATION {
object_id INTEGER,
fiber_fraction REAL,
resin_fraction REAL
}
KEY { object_id };
-- =========================================================
-- 5. Color
-- =========================================================
VAR ObjectColor BASE RELATION {
object_id INTEGER,
color Color
}
KEY { object_id };
-- =========================================================
-- 6. Boxes and counted objects
-- =========================================================
VAR Box BASE RELATION {
box_id INTEGER
}
KEY { box_id };
VAR BoxContents BASE RELATION {
box_id INTEGER,
object_id INTEGER,
count INTEGER
}
KEY { box_id, object_id };
CONSTRAINT count > 0;
-- =========================================================
-- 7. Operators for value-based equality of object kinds
-- =========================================================
-- Compare shapes
OPERATOR SameShape(o1 INTEGER, o2 INTEGER) RETURNS BOOLEAN IS
(EXISTS (Sphere WHERE object_id = o1)
AND EXISTS (Sphere WHERE object_id = o2)
AND ( (Sphere WHERE object_id = o1).radius =
(Sphere WHERE object_id = o2).radius ))
OR (EXISTS (Cylinder WHERE object_id = o1)
AND EXISTS (Cylinder WHERE object_id = o2)
AND ( (Cylinder WHERE object_id = o1).radius =
(Cylinder WHERE object_id = o2).radius )
AND ( (Cylinder WHERE object_id = o1).height =
(Cylinder WHERE object_id = o2).height ))
OR ... -- similar clauses for all other shape constructors
;
-- Compare materials
OPERATOR SameMaterial(o1 INTEGER, o2 INTEGER) RETURNS BOOLEAN IS
(EXISTS (Wood WHERE object_id = o1)
AND EXISTS (Wood WHERE object_id = o2))
OR (EXISTS (Glass WHERE object_id = o1)
AND EXISTS (Glass WHERE object_id = o2))
OR (EXISTS (Ceramic WHERE object_id = o1)
AND EXISTS (Ceramic WHERE object_id = o2))
OR (EXISTS (Concrete WHERE object_id = o1)
AND EXISTS (Concrete WHERE object_id = o2))
OR (
-- metal comparison: same set of components
NOT EXISTS (
(MetalComponent WHERE object_id = o1)
MINUS
(MetalComponent WHERE object_id = o2)
)
AND
NOT EXISTS (
(MetalComponent WHERE object_id = o2)
MINUS
(MetalComponent WHERE object_id = o1)
)
)
OR (
-- polymer blend comparison
NOT EXISTS (
(PolymerComponent WHERE object_id = o1)
MINUS
(PolymerComponent WHERE object_id = o2)
)
AND
NOT EXISTS (
(PolymerComponent WHERE object_id = o2)
MINUS
(PolymerComponent WHERE object_id = o1)
)
)
OR (
-- composite comparison
EXISTS (CompositeMaterial WHERE object_id = o1)
AND EXISTS (CompositeMaterial WHERE object_id = o2)
AND ( (CompositeMaterial WHERE object_id = o1).fiber_fraction =
(CompositeMaterial WHERE object_id = o2).fiber_fraction )
AND ( (CompositeMaterial WHERE object_id = o1).resin_fraction =
(CompositeMaterial WHERE object_id = o2).resin_fraction )
)
;
-- Compare colors
OPERATOR SameColor(o1 INTEGER, o2 INTEGER) RETURNS BOOLEAN IS
(ObjectColor WHERE object_id = o1).color =
(ObjectColor WHERE object_id = o2).color;
-- =========================================================
-- 8. Global constraint enforcing Predica's key { object }
-- =========================================================
CONSTRAINT NoDuplicateObjectKinds
NOT EXISTS (
Object AS O1 JOIN Object AS O2
WHERE O1.object_id < O2.object_id
AND SameShape(O1.object_id, O2.object_id)
AND SameMaterial(O1.object_id, O2.object_id)
AND SameColor(O1.object_id, O2.object_id)
);
This part gives the syntactic grammar for Predica. The expression grammar is aligned with the
current proof-of-concept parser, especially for path expressions, filters, folds, set operators,
renaming and let expressions.
The surrounding program and type-definition grammar describes the intended language structure and may be broader than the current proof-of-concept parser.
The lexical grammar defines how characters in the source text are grouped into tokens such as identifiers, literals, keywords, and symbolic operators. These rules operate at the character level: outside string literals, whitespace and comments act only as separators and never appear within tokens. The syntactic grammar below is defined over the tokens produced by this lexical layer.
Literal = BooleanLiteral | IntegerLiteral | RealLiteral | StringLiteral ;
BooleanLiteral = "false" | "true" ;
IntegerLiteral = Digit, { Digit } ;
RealLiteral = Digit, { Digit }, ".", Digit, { Digit } ;
StringLiteral = SingleQuotedString | DoubleQuotedString ;
SingleQuotedString =
"'", { SingleQuotedStringChar }, "'" ;
DoubleQuotedString =
'"', { DoubleQuotedStringChar }, '"' ;
SingleQuotedStringChar =
? any character except "'", "\", CR, or LF ?
| EscapeSequence ;
DoubleQuotedStringChar =
? any character except '"', "\", CR, or LF ?
| EscapeSequence ;
EscapeSequence =
"\", ? any character except CR or LF ? ;
Identifier = ( Letter | "_" ), { Letter | Digit | "_" } ;
Letter = "A" … "Z" | "a" … "z" ;
Digit = "0" … "9" ;
Keyword =
"abstract" | "as" | "break" | "catch"
| "component" | "const" | "constraints" | "continue"
| "date" | "defined" | "disjoint" | "dropped" | "dynamic" | "either"
| "else" | "embed" | "entity" | "enum" | "equivalence"
| "evolve" | "extern" | "first" | "fold" | "for"
| "func" | "if" | "in" | "intersection" | "inverse"
| "isa" | "key" | "let" | "name" | "namespace"
| "none" | "object" | "order" | "over" | "print" | "proc" | "quotient"
| "releases" | "return" | "schema" | "subtype" | "then"
| "theorems" | "this" | "try" | "type" | "union" | "unreachable"
| "unordered" | "using" | "value" | "var" | "variant"
| "where" | "while" ;
HorizontalWhitespace = " " | "\t" ;
LineEnding = "\n" | "\r\n" ;
Whitespace = { HorizontalWhitespace | LineEnding } ;
Punctuation =
"{" | "}" | "(" | ")" | "[" | "]"
| "," | ";" | ":" | "@" | "?" | "&" ;
SymbolicOperator =
"++"
| "+=" | "-=" | "*=" | "/=" | "^="
| "∪=" | "∩=" | "∖=" | ":="
| "+" | "-" | "*" | "/" | "^"
| "." | "--" | ".."
| "\" | "∖"
| "=" | "≠" | "<" | ">" | "≤" | "≥"
| "⊓" | "⊔" | "|" | "∩" | "∪" | "⨝"
| "⊂" | "⊃" | "⊆" | "⊇" | "⊄" | "⊅"
| "∈" | "∉"
| "⇒" | "→" | "←" | "↔"
| "∨" | "∧" | "¬"
| "Σ" | "∅" | "∞" | "!" | "~" | "↦"
| "↑" | "↓" ;
Comment = LineComment | BlockComment ;
LineComment =
"//", { ? any character except CR or LF ? } ;
BlockComment =
"/*", { ? any character except "*/" ? }, "*/" ;
Predica source is UTF-8 without a byte order mark. Identifiers deliberately use only the ASCII letters listed above; non-ASCII characters remain available in string literals and in the symbolic operators defined by the language. Source files use LF or CRLF line endings. A bare CR is not a valid line ending.
Keywords are reserved and cannot be used as identifiers. The spellings
true and false are reserved Boolean literals rather
than identifiers or keywords. Punctuation delimits grammatical constructs;
symbolic operators denote operations or form part of operator syntax.
The declaration keywords func and proc introduce
functions and procedures respectively. The full words “function” and
“procedure” are used in specification prose but are not alternative declaration
keywords.
Within a string literal, a backslash escapes the next source character. This allows the delimiter itself and a backslash to occur without terminating the literal. Escape sequences cannot contain a physical line ending. The lexical token retains the original source spelling; interpretation of an escape sequence occurs after lexing.
Whitespace and comments separate tokens and are otherwise discarded. Block
comments do not nest: the first */ terminates the comment. End of
file terminates a line comment, but reaching it inside a block comment or
string literal is a lexical error.
When two symbolic operators share a prefix, the lexer chooses the longest token.
For example, += is one token, not + followed by
=. The same rule distinguishes ∪= from
∪ and .. from ..
Predica source has canonical Unicode spellings for some operators that are not
easy to type on an ordinary keyboard. For set difference, the canonical source
character is Unicode set minus ∖ (U+2216). The ordinary keyboard backslash
\ (U+005C, reverse solidus) is accepted as an input alias for the same
operator. Tools that format or rewrite Predica source may normalize that alias
to the canonical ∖ spelling.
Note that the following grammar requires name resolution to disambiguate certain cases.
For example, the expression a.b.c might involve namespace names, type names, attribute names,
variable names or function names. A dotted call such as a.b(c) may be a
qualified function call, a procedure call, or a receiver-call path segment; the
function-call and procedure chapters give the resolution rules.
A call-shaped expression such as Point(1, 2) may also resolve as the
positional shorthand for a nominal constructor when Point names a concrete
nominal type. The named-field form Point { x = 1, y = 2 } is the preferred
constructor syntax.
The expression grammar exposes delimiter forms syntactically. Later name and type resolution
classifies f(x) as a function call, Point(1, 2) as a positional constructor,
Point { x = 1, y = 2 } as a named-field constructor, and Box<Integer> { value = 1 } as a
generic constructor application. Postfix square syntax is the source shape for indexing, but an
implementation may reject it until the relevant list, map, or indexable-value semantics are
available in that context. A primary square expression after a dot, as in items.[predicate], is
the filter path segment.
The grammar is presented in a natural mathematical style and is not intended to be directly consumed by an LL parser.
The grammar is written in an extended BNF notation that follows ISO/IEC 14977
(the ISO standard for EBNF), with the sole exception that ε is used
to denote the empty production where this simplifies the denotational semantics.
Non-terminals are written in CamelCase. Terminals appear as quoted symbols or lexical categories. The following conventions apply:
ε denotes the empty production, meaning that the non-terminal may derive the empty sequence of tokens.X, Y denotes concatenation of grammar symbols.X | Y denotes alternatives.[ X ] denotes optionality.{ X } means repetition (zero or more).The syntactic grammar is split into the following chapters:
Predica has two top-level file grammars. _component_.predica is a component manifest: it marks
the component root and has an optional component declaration followed by an optional releases
block. Each release entry has a fixed name field and date field, followed by zero or more
component release dependencies.
An ordinary .predica source file has a prologue followed by declarations and then optional
program statements. Its prologue consists of an optional component declaration, an optional
@dynamic directive, and zero or more file-scoped using declarations, in that order.
A using declaration belongs to an ordinary source-file prologue. Once a program variable, update
statement, procedure call, or expression statement appears, no further declarations may appear in that
file.
(************** Program **************)
Program =
ComponentManifestFile
| SourceFile;
ComponentManifestFile =
[ ComponentDeclaration ],
[ ComponentReleasesBlock ];
SourceFile =
SourceFilePrologue,
TypeDefinitions,
{ Statement };
SourceFilePrologue =
[ ComponentDeclaration ],
[ DynamicModeDeclaration ],
{ UsingDeclaration };
(* ComponentManifestFile is the grammar for _component_.predica.
SourceFile is the grammar for ordinary .predica source files.
UsingDeclaration is permitted only in SourceFilePrologue, not inside a namespace. *)
ComponentDeclaration =
"component", QualifiedComponentName, ";";
ComponentReleasesBlock =
"releases",
"{",
{ ComponentReleaseEntry, [ "," ] },
"}";
ComponentReleaseEntry =
IntegerLiteral,
":",
"{",
"name", ":", StringLiteral,
",",
"date", ":", ComponentReleaseDate,
{ ",", ComponentReleaseDependency },
[ "," ],
"}";
ComponentReleaseDate =
"(", IntegerLiteral, ",", Identifier, ",", IntegerLiteral, ")";
ComponentReleaseDependency =
QualifiedComponentName,
":",
IntegerLiteral;
DynamicModeDeclaration =
"@", "dynamic", "=", BooleanLiteral, ";";
UsingDeclaration =
"using",
QualifiedComponentName,
[ ( "as", ComponentName ) | SelectedImportList ],
";";
SelectedImportList =
"{",
SelectedImport, { ",", SelectedImport },
"}";
SelectedImport =
QualifiedImportName, [ "as", Identifier ];
QualifiedImportName =
Identifier, { ".", Identifier };
(************** Metadata **************)
MetaData =
MetaDataItem, { MetaDataItem };
MetaDataItem =
"@", Identifier, [ "(", [ ExpressionList ], ")" ];
(************** Names **************)
(* Qualified names used in references *)
QualifiedNamespaceName = NamespaceName, { ".", NamespaceName };
QualifiedComponentName = QualifiedNamespaceName;
QualifiedTypeName = [ComponentName, "."], TypeName;
(* Unqualified names, typically used in definitions. These are also used for aliases *)
NamespaceName = Identifier;
TypeName = Identifier;
AttributeName = Identifier;
GlobalVarName = Identifier;
ComponentName = Identifier;
(************** Statements **************)
Statement =
BlockStatement
| VarDecl, ";"
| UpdateStmt, ";"
| ProcedureCallStmt, ";"
| IfStmt
| WhileStmt
| ForStmt
| BreakStmt, ";"
| ContinueStmt, ";"
| ReturnStmt, ";"
| UnreachableStmt, ";"
| TryStmt
| PrintStmt, ";";
BlockStatement =
"{", { Statement }, "}";
VarDecl =
"var",
( InferredVariableDecl | TypedVariableDecl ),
[ "=", Expression ];
InferredVariableDecl =
VariableWithCardinality;
TypedVariableDecl =
TypeExpression, VariableWithCardinality;
VariableWithCardinality =
Identifier, CardinalityQualifier;
UpdateStmt =
UpdateTarget, AssignmentOperator, Expression
| UpdateTarget, IncrementOperator;
UpdateTarget =
Expression;
ProcedureCallStmt =
QualifiedProcedureName, [ GenericApplication ], "(", [ CallArgumentList ], ")";
QualifiedProcedureName =
Identifier, { ".", Identifier };
AssignmentOperator =
":="
| "+=" | "-=" | "*=" | "/=" | "^="
| "∪=" | "∩=" | "∖=";
IncrementOperator =
"++"
| "--";
IfStmt =
"if", Expression, BlockStatement, [ "else", ( IfStmt | BlockStatement ) ];
WhileStmt =
"while", Expression, BlockStatement;
ForStmt =
"for", ForBinding, "in", Expression, BlockStatement;
ForBinding =
TypedForBinding
| InferredForBinding;
TypedForBinding =
TypeExpression, Identifier;
InferredForBinding =
Identifier;
BreakStmt =
"break";
ContinueStmt =
"continue";
ReturnStmt =
"return", [ Expression ];
UnreachableStmt =
"unreachable";
TryStmt =
"try", BlockStatement, CatchClause, { CatchClause };
CatchClause =
"catch", "(", TypeExpression, Identifier, ")", BlockStatement;
PrintStmt = "print", Expression, { ",", Expression };
(************** Expressions **************)
Expression =
EquivalenceExpression;
EquivalenceExpression =
ImplicationExpression,
{ "↔", ImplicationExpression };
ImplicationExpression =
OrExpression,
[ "→", ImplicationExpression
| "←", ImplicationExpression ];
OrExpression =
AndExpression,
{ "∨", AndExpression };
AndExpression =
NotExpression,
{ "∧", NotExpression };
NotExpression =
"¬", NotExpression
| RenameExpression;
RenameExpression =
DifferenceExpression,
{ "as", Identifier };
DifferenceExpression =
UnionExpression,
{ ( SetDifferenceOperator | PathDifferenceOperator ), UnionExpression };
SetDifferenceOperator =
"∖" (* Unicode set minus U+2216; canonical spelling *)
| "\"; (* reverse solidus U+005C; keyboard input alias *)
PathDifferenceOperator =
"--"; (* path operator difference *)
UnionExpression =
IntersectionExpression,
{ ( "⊔" | "∪" | "|" ), IntersectionExpression };
IntersectionExpression =
ComparisonExpression,
{ ( "⊓" | "∩" | "⨝" ), ComparisonExpression };
ComparisonExpression =
( RangeExpression | TypeExpression ), "isa", TypeExpression
| RangeExpression, { ComparisonOperator, RangeExpression };
RangeExpression =
AdditiveExpression,
[ "..", AdditiveExpression ];
ComparisonOperator =
"=" | "≠"
| "<" | ">" | "≤" | "≥"
| "⊂" | "⊃" | "⊆" | "⊇" | "⊄" | "⊅"
| "∈" | "∉";
AdditiveExpression =
MultiplicativeExpression,
{ ( "+" | "-" ), MultiplicativeExpression };
MultiplicativeExpression =
PowerExpression,
{ ( "*" | "/" ), PowerExpression };
PowerExpression =
CardinalityExpression,
[ "^", PowerExpression ];
CardinalityExpression =
CompositionExpression,
[ CardinalitySuffix ];
CompositionExpression =
PostfixExpression,
{
".", PathSegment
| "⇒", PostfixExpression
};
PathSegment =
FoldSuffix
| FilterExpression
| ProjectionExpression
| ReceiverCall
| PostfixExpression;
ProjectionExpression =
RoundExpression;
ReceiverCall =
Identifier, [ GenericApplication ], "(", [ CallArgumentList ], ")";
PostfixExpression =
PrimaryExpression,
{ PostfixSuffix };
PostfixSuffix =
GenericApplication
| PostfixRound
| PostfixSquare
| PostfixCurly
| "!"
| "~";
PostfixRound =
"(", [ CallArgumentList ], ")";
PostfixSquare =
"[", [ Slice | ExpressionList ], "]";
Slice =
[ SliceBound ], ":", [ SliceBound ];
SliceBound =
AdditiveExpression;
PostfixCurly =
"{", [ ExpressionList ], "}";
CardinalitySuffix =
":",
(
"?"
| "+"
| ExpressionCardinalityBound,
[ "+" | "..", ( "*" | "∞" | ExpressionCardinalityBound ) ]
);
ExpressionCardinalityBound =
IntegerLiteral
| Identifier
| "(", Expression, ")";
PrimaryExpression =
Literal
| LetExpression
| IfExpression
| InverseExpression
| Identifier
| "this"
| "none"
| "*"
| "∅"
| "@", Identifier
| "Σ", [ FoldTail ]
| FilterExpression
| RoundExpression
| CurlyExpression
| UnaryExpression;
UnaryExpression =
( "+" | "-" ), PrimaryExpression;
InverseExpression =
"inverse", CompositionExpression;
GenericApplication =
"<", [ GenericArgumentList ], ">";
LetExpression =
"let", LetBinding, { ",", LetBinding }, "in", Expression;
LetBinding =
Identifier, "=", Expression;
IfExpression =
"if", Expression, "then", Expression, "else", Expression;
FilterExpression =
"[", Expression, "]";
RoundExpression =
"(", [ ExpressionList ], ")";
CurlyExpression =
"{", [ ExpressionList ], "}";
ExpressionList =
Expression, { ",", Expression };
CallArgumentList =
CallArgument, { ",", CallArgument };
CallArgument =
Expression
| "&", Identifier
| "^", Identifier;
FoldSuffix =
"Σ", [ FoldTail ];
FoldTail =
NamedFoldApplication
| FoldBlock;
NamedFoldApplication =
QualifiedFoldName, [ GenericApplication ], [ "(", [ ExpressionList ], ")" ];
QualifiedFoldName =
FoldName, { ".", FoldName };
FoldName =
Identifier;
FoldBlock =
UnseededFoldBlock
| SeededFoldBlock;
UnseededFoldBlock =
"{", Expression, ";", Identifier, Identifier, "↦", Expression, "}";
SeededFoldBlock =
"{",
"first", Identifier, "↦", Expression,
";",
Identifier, Identifier, "↦", Expression,
"}";
Literal =
BooleanLiteral
| IntegerLiteral
| RealLiteral
| StringLiteral;
The expression grammar is written in precedence levels. The following table is a summary of those productions. Operators appearing earlier in the table bind more tightly than those appearing later. Where there is any doubt, the grammar productions are normative.
| Precedence (high → low) | Operators | Associativity | Notes |
|---|---|---|---|
| 1 (highest) | literals, identifiers, grouping, primary delimiter forms | n/a | Includes this, none, *, ∅, @name, Σ..., [], (), and {}. |
| 2 | +, - (prefix) |
right-associative | Unary arithmetic prefix operators. |
| 3 | <...>, (...), [...], {...}, !, ~ |
left-associative | Postfix generic, call, index, slice, constructor, path negation, and path complement forms. |
| 4 | ., ⇒, .[E], .f(...), .Σ, .Σname, .Σname<...>(...), .Σqualified.name, .Σ{...} |
left-associative | Path composition, universal path composition, explicit filter segments, receiver calls, and folds. |
| 5 | :?, :+, :k, :k+, :k..m |
n/a | Cardinality qualifiers on complete path expressions. |
| 6 | ^ |
right-associative | Exponentiation. |
| 7 | *, / |
left-associative | Multiplicative arithmetic operators. |
| 8 | +, - (binary) |
left-associative | Additive arithmetic operators. |
| 9 | .. |
non-associative | Inclusive integer range expression. |
| 10 |
=, ≠,
<, >, ≤, ≥,
⊂, ⊃, ⊆, ⊇,
⊄, ⊅, ∈, ∉,
isa
|
left-associative syntax | Comparison, set comparison, membership, and value-or-type-expression type tests. |
| 11 | ⊓, ∩, ⨝ |
left-associative | Path conjunction, set intersection, and join. |
| 12 | ⊔, ∪, | |
left-associative | Path disjunction, set union, and the alternative operator. |
| 13 | --; set difference |
left-associative | -- is path difference. Set difference uses the canonical set-minus character described in the lexical grammar, with keyboard backslash accepted as an input alias. |
| 14 | as |
left-associative | Rename operator. |
| 15 | ¬ |
right-associative | Logical negation. |
| 16 | ∧ |
left-associative | Logical conjunction. |
| 17 | ∨ |
left-associative | Logical disjunction. |
| 18 | →, ->, ← |
right-associative | Logical implication. |
| 19 | ↔ |
left-associative | Logical equivalence. |
| 20 (lowest) | if ... then ... else ... |
special form | Conditional expression; the condition and branches are full expressions. |
A postfix square expression containing an expression list is an index operation, such as items[0]
or matrix[i, j]. A postfix square expression containing a colon is a one-dimensional slice:
name[0:3]
name[:3]
name[3:]
name[:]
name[-4:]
The lower bound is inclusive and the upper bound is exclusive. An omitted lower bound denotes the
start of the value, and an omitted upper bound denotes its end. Negative bounds count backwards
from the end. Slice bounds are additive expressions, so bounds such as start + 1 and end - 1
are permitted.
A slice is an ordinary postfix expression when read and may also denote a location when used as an
update target. For example, name[:3] reads the selected prefix, while name[:3] := "Dr "
replaces that prefix. Assigning to an empty slice inserts text, and assigning the empty string to a
non-empty slice deletes the selected text, as described in 103 Variable Updates.
The current syntax supports one-dimensional slices only. A slice cannot be combined with other comma-separated square items; multidimensional slicing is not defined.
Repeated comparison operators are parsed as ordinary left-nested binary expressions, not as
mathematical comparison chains. For example, a = b = c is parsed like (a = b) = c, not like
a = b ∧ b = c. Use explicit conjunction when a chain of comparisons is intended.
The type-test operator isa is parsed at comparison precedence. Its left operand may be either a
value expression or a type expression, and its right operand is a type expression. Thus
value isa T tests membership of a value in T, while S isa T tests whether S is a subtype of T.
The same keyword is used in declaration and generic constraint syntax, for example
where T isa Real.
The operator ⇒ is universal path composition, not logical implication. Logical implication
is written →.
Parentheses may always be used to override or clarify the default precedence and associativity rules.
(************** Type definitions **************)
(****** Top-Level Structure ******)
TypeDefinitions = { Declaration };
Declaration =
NamespaceBlock
| ValueTypeDecl
| EntityDecl
| TypeAliasDecl
| EnumTypeDecl
| VariantTypeDecl
| QuotientTypeDecl
| RetroactiveSubtypeDecl
| SubtypeEmbeddingDecl
| MutuallyDisjointTypesDecl
| ConstantDecl
| FunctionDecl
| ProcedureDecl
| FoldDecl
| ExternBlockDecl;
NamespaceBlock =
"namespace", QualifiedNamespaceName,
"{",
TypeDefinitions,
"}";
(****** Nominal Type Declarations ******)
ValueTypeDecl =
[MetaData],
[ "abstract" ], "value", [ "unordered" ], TypeName,
[ GenericParameterList ], [ SuperTypeList ],
[ GenericWhereClause ],
"{",
{ ValueTypeBodyItem },
"}";
EntityDecl =
[MetaData],
[ "abstract" ], "entity", [ "unordered" ], TypeName,
[ GenericParameterList ], [ SuperTypeList ],
[ GenericWhereClause ],
"{",
{ EntityTypeBodyItem },
"}";
ValueTypeBodyItem =
FieldDecl
| OrderDecl
| TypeEvolutionItem
| Constraints
| Theorems;
EntityTypeBodyItem =
FieldDecl
| KeyConstraint
| OrderDecl
| TypeEvolutionItem
| Constraints
| Theorems;
EnumTypeDecl =
[MetaData],
"enum", [ "unordered" ], TypeName,
[ GenericParameterList ],
[ ( ":", EnumStorageType ) | SuperTypeList ],
[ GenericWhereClause ],
"{",
{ EnumTypeBodyItem },
"}";
EnumStorageType =
"Int8" | "Int16" | "Int32" | "Int64"
| "Uint8" | "Uint16" | "Uint32" | "Uint64";
EnumTypeBodyItem =
EnumAttributeDecl, [ "," ]
| [MetaData], DerivedFieldDecl, ";";
EnumAttributeDecl =
CurrentEnumAttributeDecl
| DroppedEnumAttributeDecl;
CurrentEnumAttributeDecl =
AttributeName, [ "=", Expression ];
DroppedEnumAttributeDecl =
"dropped", AttributeName, [ "=", Expression ], "as", AttributeName;
(* The final "as" in DroppedEnumAttributeDecl introduces the replacement attribute. If the
representation-value expression contains rename expressions, the final
"as" is therefore the delimiter and any earlier occurrences belong to the
expression. *)
VariantTypeDecl =
[MetaData],
"variant", [ "unordered" ], TypeName,
[ GenericParameterList ], [ SuperTypeList ],
[ GenericWhereClause ],
"{",
{ VariantFieldDecl },
[ OrderDecl ],
[Constraints],
[Theorems]
"}";
VariantFieldDecl =
CurrentVariantFieldDecl
| DroppedVariantFieldDecl;
CurrentVariantFieldDecl =
[MetaData],
(
CurrentVariantAttributeDecl, [ WhereClause ]
| DerivedFieldDecl
),
";";
CurrentVariantAttributeDecl =
TypedAttribute | AttributeName;
DroppedVariantFieldDecl =
[MetaData],
"dropped",
( TypedAttribute | AttributeName ),
"as",
AttributeName,
[ "=", Expression ],
";";
TypeAliasDecl =
[MetaData],
"type", TypeName, [GenericParameterList], [ GenericWhereClause ], "=",
TypeExpression, ";";
QuotientTypeDecl =
[MetaData],
"quotient", TypeName, [GenericParameterList], [SuperTypeList],
[ GenericWhereClause ],
"{",
{ FieldDecl },
"equivalence",
"{",
Identifier, "~", Identifier, "where", Expression, ";",
"}",
[ OrderDecl ],
[Constraints],
[Theorems]
"}";
SuperTypeList = "isa", QualifiedTypeName, { ",", QualifiedTypeName };
RetroactiveSubtypeDecl =
QualifiedTypeName, SuperTypeList, ";";
SubtypeEmbeddingDecl =
[MetaData],
"embed", TypeExpression, Identifier, "isa", TypeExpression,
"=", Expression, ";";
MutuallyDisjointTypesDecl =
"disjoint", TypeExpressionList, ";";
GenericParameterList =
"<", [ GenericParameter, { ",", GenericParameter } ], ">";
GenericParameter =
[MetaData],
TypeExpression,
Identifier,
[ "=", GenericArgument ];
GenericArgumentList =
GenericArgument, { ",", GenericArgument };
GenericArgument =
TypeExpression | Expression;
GenericWhereClause =
"where", GenericConstraint, { ",", GenericConstraint };
GenericConstraint =
Identifier, "isa", TypeExpression
| Expression;
(****** Field Declarations ******)
FieldDecl =
[MetaData],
(
MemberEmbedDecl
| TypedAttribute, [ WhereClause ]
| DerivedFieldDecl
),
";";
OrderDecl =
"order", "[", OrderKey, { ",", OrderKey }, "]", ";";
OrderKey =
Expression, [ OrderDirection ];
OrderDirection =
"↑"
| "↓";
MemberEmbedDecl =
"embed", QualifiedTypeName, [ "as", AttributeName ];
TypedAttribute =
TypeExpression, AttributeWithCardinality;
AttributeWithCardinality =
AttributeName, CardinalityQualifier;
CardinalityQualifier = ε
| "*"
| "+"
| "?"
| "[", CardinalityBound,
[ "..", ( "*" | "∞" | CardinalityBound ) ], "]";
CardinalityBound = AdditiveExpression;
TypeExpression =
RefinementTypeExpression
| TypeExpressionNoRefinement;
RefinementTypeExpression =
"subtype", TypeExpressionNoRefinement, "where", Expression;
TypeExpressionNoRefinement =
ObjectTypeExpression
| NamedTypeExpression
| TupleTypeExpression
| UnionTypeExpression
| IntersectionTypeExpression
| "(", TypeExpression, ")";
ObjectTypeExpression =
"object", TypeExpressionNoRefinement;
NamedTypeExpression =
QualifiedTypeName, [ TypeGenericApplication ];
TypeGenericApplication =
"<", [ GenericArgumentList ], ">";
TupleTypeExpression =
"{",
[ TypedAttribute, { ";", TypedAttribute }, [ ";" ] ],
"}";
UnionTypeExpression =
"union", "{", TypeExpressionList, "}";
IntersectionTypeExpression =
"intersection", "{", TypeExpressionList, "}";
TypeExpressionList =
TypeExpression, { ",", TypeExpression };
DerivedFieldDecl =
"→", OptTypedAttribute, "=", DerivedFieldDefinition;
DerivedFieldDefinition =
"either", Expression, ",", Expression, { ",", Expression }
| Expression;
OptTypedAttribute =
TypedAttribute | AttributeWithCardinality;
(****** Schema Evolution ******)
TypeEvolutionItem =
DroppedAttributesBlock
| SchemaEvolutionBlock
| EvolveBlock;
DroppedAttributesBlock =
"dropped",
"{",
{ DroppedAttributeDecl },
"}";
DroppedAttributeDecl =
[MetaData],
TypedAttribute,
[ WhereClause ],
";";
SchemaEvolutionBlock =
"schema",
"{",
{ SchemaReleaseDecl, [ ";" ] },
"}";
SchemaReleaseDecl =
IntegerLiteral, ":", { SchemaChange, [ "," ] };
SchemaChange =
"+", AttributeName
| "-", AttributeName;
EvolveBlock =
"evolve",
"{",
{ EvolveFunctionDecl },
"}";
EvolveFunctionDecl =
AttributeName, "(", [ EvolveParameterList ], ")",
"{", AttributeName, "=", Expression, ";", "}";
EvolveParameterList =
Identifier, { ",", Identifier };
(****** Keys, constraints, and theorems ******)
KeyConstraint =
"key",
"{",
AttributeName, { ",", AttributeName },
"}", ";";
Constraints =
"constraints",
"{",
{ ConstraintDecl },
"}";
ConstraintDecl = DisjointConstraint | ExpressionConstraint;
DisjointConstraint =
"disjoint",
"{",
AttributePath, { ",", AttributePath },
"}", ";";
AttributePath = AttributeName, { ".", AttributeName };
ExpressionConstraint = Expression, ";";
Theorems =
"theorems",
"{",
{ TheoremDecl },
"}";
TheoremDecl = DisjointConstraint | ExpressionConstraint;
(****** Constants, functions, procedures, and folds ******)
ConstantDecl =
[MetaData],
"const", [ TypeExpression ], Identifier,
[ GenericParameterList ], [ GenericWhereClause ], "=", Expression, ";";
FunctionDecl =
[MetaData],
"func", TypeExpression, FunctionName, [CallableGenericClause],
"(", [ FunctionParameterList ], ")",
FunctionBody;
CallableGenericClause =
GenericParameterList, [ GenericWhereClause ];
FunctionBody =
"=", Expression, ";"
| BlockStatement;
ProcedureDecl =
[MetaData],
"proc", ProcedureName, [CallableGenericClause],
"(", [ ProcedureParameterList ], ")",
BlockStatement;
FoldDecl =
[MetaData],
"fold", TypeExpression, FoldName, [GenericParameterList],
[ "(", [ FoldParameterList ], ")" ],
FoldDeclTail, ";";
FoldParameterList =
ReadOnlyParameter, { ",", ReadOnlyParameter };
FoldDeclTail =
[ GenericWhereClause ], "=", "Σ", FoldBlock
| "over", Identifier, [ GenericWhereClause ], "=", Expression,
[ DefinedWhereClause ];
DefinedWhereClause =
"defined", WhereClause;
ExternBlockDecl =
"extern", StringLiteral,
"{",
{ ExternFunctionDecl },
"}";
ExternFunctionDecl =
[MetaData],
TypeExpression, FunctionName, [CallableGenericClause],
"(", [ ReadOnlyParameterList ], ")",
";";
FunctionName =
Identifier
| SymbolicFunctionName;
SymbolicFunctionName =
"=" | "≠"
| "<" | ">" | "≤" | "≥"
| "⊂" | "⊃" | "⊆" | "⊇" | "⊄" | "⊅"
| "∈" | "∉"
| "+" | "-" | "*" | "/" | "^"
| "∩" | "∪" | "∖" | "\"
| "⊓" | "⊔" | "⨝" | "|"
| "⇒" | "→" | "←" | "↔"
| "∧" | "∨" | "¬";
ProcedureName =
Identifier;
WhereClause =
"where", Expression, { ",", Expression };
FunctionParameterList =
FunctionParameter, { ",", FunctionParameter };
FunctionParameter =
[ "^" ], TypeExpression, Identifier;
ProcedureParameterList =
ProcedureParameter, { ",", ProcedureParameter };
ProcedureParameter =
[ "&" | "^" ], TypeExpression, Identifier;
ReadOnlyParameterList =
ReadOnlyParameter, { ",", ReadOnlyParameter };
ReadOnlyParameter =
TypeExpression, Identifier;
This part records the technical semantics of Predica. Earlier parts introduce the motivation, examples, query notation, and relation views; this part gives the denotational account those explanations rely on.
The current query account is logic-first. Predica query expressions denote logic frames. Relation-shaped results are extensional observations of those frames under headings, as described in the Relations part.
Location denotation is a second, partial denotation for expressions that can be used as update targets. It records writable places while retaining the address witnesses needed to perform writes. It is interpreted under the schema constraints, which determine when paths reach shared entity occurrences and therefore denote the same location. Reading the locations selected by an expression agrees with the expression's ordinary query-frame denotation. This relationship gives updates a precise meaning without identifying locations with the values currently stored at them.
A frame is the logical denotation of a Predica path expression. Frames were informally described in 22 Navigation as the three components of a set builder notation of an n-ary relation. We will now give a more formal definition.
A frame records three things: the result of the expression, the generated logic variables that remain free, and the formula that constrains those variables. Every path expression denotes a frame:
⟨R for F where Ψ⟩
where:
| Component | Meaning |
|---|---|
R |
A set of bindings giving values of attributes using expressions for both the type and value of each attribute.
This is formalised as a set of
The notation
It gives the named, typed result of the frame. For example:
|
F |
The set of free variables of the frame,
formalised as a set of
The notation
For example:
|
Ψ |
The logic formula that constrains the frame. |
This notation is a mnemonic for the triple (R,F,Ψ):
resultR, for free variablesF, where formulaΨholds.
The key of R is the attribute name:
(a,E₁,T₁) ∈ R ∧ (a,E₂,T₂) ∈ R ⇒ E₁ = E₂ ∧ T₁ = T₂
The key of F is the variable:
(V,T₁,a₁) ∈ F ∧ (V,T₂,a₂) ∈ F ⇒ T₁ = T₂ ∧ a₁ = a₂
The lowercase source variable s is a Predica source-code variable of type ShipmentsSystem.
It denotes the shipments system supplied by the surrounding program.
The denotation introduces fresh logic variables to range over values reached by path
navigation. The uppercase logic variable S is the fresh logic variable of type Supplier
introduced by the path expression below.
The logical denotation of the path expression s.supplier is the frame ⟨R for F where Ψ⟩
where:
| Component | Value | Meaning |
|---|---|---|
| R | |
The path exposes S as a result attribute named supplier. |
| F | |
The logic variable S is free in the frame. |
| Ψ | |
S is a supplier of the shipments system s. |
In classical first order logic predicates are global.
In this denotational semantics the logic formulas instead involve qualified unary predicates.
For example, the attribute supplier is a unary predicate that must be qualified,
as in s.supplier(S).
The special attribute _anon_ is used for unnamed results in unary frames.
R may contain terms that are not variables, such as arithmetic expressions, boolean expressions,
or set comprehensions.
Consider the logical denotation of the path expression s.supplier.(status*rating).
This path first ranges over suppliers of s, then has the computed term T*Q as its anonymous result.
It has a frame ⟨R for F where Ψ⟩ where:
| Component | Value | Meaning |
|---|---|---|
| R | |
The frame result is the computed term T*Q, with attribute _anon_. |
| F | |
The variables T and Q occur free in the frame. |
| Ψ | |
There is a supplier S of s with the shown status and rating values. |
Here R contains the term T*Q. The variables used by that term are recorded separately
in F.
The notation ⟨R for F where Ψ⟩ reads like a set-builder expression:
⟨ result for free variables where condition ⟩
but the brackets ⟨...⟩ are not curly braces {...}.
It retains the components R,F,Ψ without building a set.
It keeps the denotation symbolic as logic, so later path operations can compose, close, negate,
or fold it without first materialising an extension.
For a result binding r = (a,E,T):
attribute(r) = a
term(r) = E
type(r) = T
For a free-variable declaration d = (V,T,a):
var(d) = V
type(d) = T
provenance(d) = a
For a result R, a free-variable set F, and a set Q of logic variables:
attrib(R) = { attribute(r) | r ∈ R }
vars(F) = { var(d) | d ∈ F }
decls(F,Q) = { d ∈ F | var(d) ∈ Q }
For a frame γ = ⟨R for F where Ψ⟩:
result(γ) = R
free(γ) = F
formula(γ) = Ψ
Quantification over a set of free-variable declarations retains their types while ignoring their provenance attributes. If:
K = {
V₁:T₁ from a₁,
...,
Vₙ:Tₙ from aₙ
}
then:
∃(K) Ψ abbreviates ∃V₁:T₁ ... ∃Vₙ:Tₙ Ψ.
∀(K) Ψ abbreviates ∀V₁:T₁ ... ∀Vₙ:Tₙ Ψ.
∃(∅) Ψ = Ψ.
∀(∅) Ψ = Ψ.
The provenance attribute a in a triple (V,T,a) in F is only informational. For example, the
following records that S was introduced by the supplier attribute:
S:Supplier from supplier
When V is introduced by attribute navigation, a is the attribute whose extension produced V
by ranging over that extension. When V is introduced by a calculation, fold, or cardinality
expression, a may be _anon_ or the result attribute. The calculation itself is represented by
the result term and formula, not by a.
Multiple different variables may have the same attribute label in F. This is intentional: F is
not a result.
A frame γ = ⟨R for F where Ψ⟩ is well formed when:
R is keyed by attribute name.
F is keyed by logic variable.
Ψ or in a result term is declared in F.
F occurs free in Ψ or in a result term.
F.
Equivalently:
vars(F) = FV(Ψ) ∪ FV(R)
where FV(R) means the free logic variables occurring in the terms of R. This equality is about
logic variables, not source-level program terms.
Source-level values supplied by the surrounding program, such as a root variable or the this value
supplied to a constraint, are terms rather than generated logic variables. They therefore require
no entry in F merely because they occur in R or Ψ.
The full frame notation is the precise display form because it shows the result, the free variables, and the formula. For denotation output aimed at people reading examples or debugging ordinary path translations, a shorter display form may be used:
R where Ψ
In this short form, R is written as a comma-separated list of result bindings such as
supplier=S:Supplier, and the free-variable set F is not printed. The omitted F is still part of
the frame being denoted. Short form is appropriate when the renderer is not trying to communicate
the exact free-variable set or provenance, or when that information is already clear from context.
Four additional rules keep short frame displays compact and unambiguous:
R contains a single anonymous binding _anon_=X:T, the short form prints just X:T.
Ψ is true, the short form omits where true and prints only the result.
R is empty, the short form prints only Ψ. This is a zero-result frame, not an
anonymous Boolean result, so no :Boolean suffix is printed.
R is empty and Ψ is true, the short form prints true.
For example, the frame for s.supplier may be displayed in full as:
⟨ {supplier=S:Supplier} for {S:Supplier from supplier} where s.supplier(S) ⟩
or in short form as:
supplier=S:Supplier where s.supplier(S)
For a tuple-valued path such as s.supplier.(name,status), the short form is:
name=N:String,
status=S1:Integer
where
∃S:Supplier (
s.supplier(S)
∧ S.name(N)
∧ S.status(S1)
)
An anonymous unary frame such as:
⟨ {_anon_=X:Set<String> } for ∅ where X = { C:String for ... } ⟩
has the short form:
X:Set<String> where X = { C:String for ... }
Likewise, a frame whose formula is true displays just its result in short form.
For a path whose result set is empty, such as s.supplier.(), the full form:
⟨ ∅ for ∅ where ∃S:Supplier (s.supplier(S)) ⟩
has the short form:
∃S:Supplier (s.supplier(S))
Use the full frame form whenever the exact free-variable set, variable provenance, protected variables, or frame invariants matter. Short form is only a rendering convention; it does not change the denotation.
Every type T determines a carrier set carrier(T) whose members are the values of that type.
The construction of these carrier sets is described for structural types,
value types, and entity types.
A source environment σ supplies the source-level bindings used when interpreting a frame. These
include program variables, root variables, and contextual bindings such as this. If a program
variable x has element type T, its current extension in σ is a subset of carrier(T) satisfying
the variable's declared cardinality constraint:
σ(x) ⊆ carrier(T)
For a cardinality-one variable, this extension contains exactly one value, and the source term x
denotes that value. Optional and multivalued variables instead have extensions containing the
number of values permitted by their cardinality constraints. A contextual binding such as this
supplies a single value in the carrier of its type.
A logic-variable assignment ρ is distinct from the source environment. It maps each generated
logic variable declared in the free variables F of a frame to a value in the carrier of its declared type:
(V:T from a) ∈ F ⇒ ρ(V) ∈ carrier(T)
A frame is a logical denotation, not its materialised extension. From a frame and a source
environment σ, one can derive an extension: the set of results obtained from assignments ρ for
which Ψ holds, by evaluating the terms in R:
Extσ(⟨R for F where Ψ⟩) =
{ evalσ(R, ρ) | ρ assigns vars(F) and σ,ρ ⊨ Ψ }
Thus Extσ(⟨R for F where Ψ⟩) is a set of result tuples. Each tuple is keyed by attribute rather
than by position. For every binding (a,E,T) in R, the tuple maps a to the single value obtained
by evaluating E; that value belongs to the carrier of T.
The extension is derived from the frame; it is not the frame itself. It forgets the formula, free-variable provenance, and the result terms after they have been evaluated. Distinct frames may therefore have the same extension.
Materialising Extσ(γ) is an execution strategy, not part of the denotational definition. Negation,
implication, constraints, folds, and normative systems may all be interpreted as formulas without
enumerating the full extension.
For a frame γ, the domain operation removes the result while preserving the condition and free
variables:
domain(γ) = ⟨∅ for free(γ) where formula(γ)⟩
This is useful when a scalar expression is evaluated inside a current frame. The current frame's condition still constrains the expression, even if the current frame's result is not retained.
The operation domain(γ) is distinct from the current frame itself. Operations such as this,
splat, and attribute navigation need the result term from the current frame and therefore use the
frame itself. Operations that only need the current frame's condition and free variables use
domain(γ).
For a frame γ and a set Q of logic variables, close∃(γ,Q) existentially binds the selected
variables, while close∀(γ,Q) universally binds them. Both operations have the preconditions:
Q ⊆ vars(free(γ))
Q ⊆ FV(formula(γ)) \ FV(result(γ))
Thus every variable in Q has a typed declaration in the frame, occurs free in the formula, and does
not occur in the result. The operations recover the declarations and their types from free(γ):
close∃(γ, Q)
{
let K = decls(free(γ), Q)
return ⟨result(γ) for free(γ) \ K where ∃(K) formula(γ)⟩
}
close∀(γ, Q)
{
let K = decls(free(γ), Q)
return ⟨result(γ) for free(γ) \ K where ∀(K) formula(γ)⟩
}
Continuing the supplier example from 137 Frames, translating s.supplier.name produces the
following frame before closure, with both the supplier variable S and the result variable N
free:
γ_name_before_close =
⟨
{ name = N:String }
for
{
S:Supplier from supplier,
N:String from name
}
where
s.supplier(S)
∧ S.name(N)
⟩
The ordinary composition rule closes the supplier variable that is not needed by the result:
close∃(γ_name_before_close, {S})
=
⟨
{ name = N:String }
for
{ N:String from name }
where
∃S:Supplier (
s.supplier(S)
∧ S.name(N)
)
⟩
Trying to close N would be an error, because N appears in the result
{ name = N:String }.
Relabelling changes the result attribute name of a unary frame. It does not change free(γ);
the attribute labels in free(γ) describe where variables were introduced, not the current result
attribute name.
relabel(γ, b)
{
if result(γ) does not contain exactly one term
raise error
let (a,C,T) be the unique element of result(γ)
return ⟨{ b = C:T } for free(γ) where formula(γ)⟩
}
When two frames are combined horizontally, matching result attributes denote the same result attribute. Alignment unifies their terms and types; incompatibility is an error for meet and difference.
align(γ₁, γ₂)
{
for every attribute a in attrib(result(γ₁)) ∩ attrib(result(γ₂))
{
let (a,C₁,T₁) ∈ result(γ₁)
let (a,C₂,T₂) ∈ result(γ₂)
unify T₁ with T₂
unify C₁ with C₂
if unification fails
raise error
}
apply the resulting substitution to both frames
return (γ₁', γ₂')
}
Union has one additional alignment case. If two matching result attributes have the same type but
their terms cannot be unified, union may introduce a fresh variable V for that attribute, replace
the result term in both branches by V, conjoin V = C₁ to the left branch and V = C₂ to the
right branch, and declare V in the free variables of the aligned frame.
Fresh variables introduced by one translation run are globally fresh, except where frames are reused from the environment or matching attributes are explicitly unified. Therefore sibling frames do not need alpha-renaming before alignment. Implementations that compose independently translated frames may alpha-rename first.
The three horizontal operations differ only in the formula combiner used after result alignment. All three produce the union of the aligned results and declare the union of the free variables, including any fresh variables introduced by union alignment.
combine(α, κ, γ₁, γ₂)
{
let (γ₁', γ₂') = α(γ₁, γ₂)
return ⟨
result(γ₁') ∪ result(γ₂')
for
free(γ₁') ∪ free(γ₂')
where
κ(formula(γ₁'), formula(γ₂'))
⟩
}
γ₁ ⊓ γ₂ = combine(align, (Ψ₁,Ψ₂) ↦ Ψ₁ ∧ Ψ₂, γ₁, γ₂)
γ₁ ⊔ γ₂ = combine(alignUnion, (Ψ₁,Ψ₂) ↦ Ψ₁ ∨ Ψ₂, γ₁, γ₂)
γ₁ -- γ₂ = combine(align, (Ψ₁,Ψ₂) ↦ Ψ₁ ∧ ¬Ψ₂, γ₁, γ₂)
The difference operation is logical difference, not relational-algebra difference. It does not
require union-compatible results. The abstract path-difference operator is written --, distinct
from scalar and set difference. Scalar and set difference use the canonical set-minus character
described in the lexical grammar, with keyboard backslash accepted as an input alias.
Because horizontal operations expose the union of the aligned results, some result variables may be unconstrained in some branches of the formula. This is intentional. A frame is a logical denotation over assignments, not a promise that the extension can be finitely materialised by enumeration. Materialising such a frame may require finite domains, active-domain evaluation, solvers, indexes, or other execution assumptions; those assumptions are operational and do not restrict the denotation.
Tuple construction uses a stricter result merge than path-level meet and union. Tuple components are constructing a result, and duplicate attribute names across sibling component frames make the tuple ill formed. If the same visible variable is exposed twice and one occurrence still has its provenance label, that is also ill formed; two explicit aliases of the same variable are allowed.
tupleMerge(γ₁, γ₂)
{
if attrib(result(γ₁)) ∩ attrib(result(γ₂)) ≠ ∅
raise error(duplicate result attribute)
if result(γ₁) contains a = V:T
and result(γ₂) contains b = V:T
and a ≠ b
and (V:T from a ∈ free(γ₁) or V:T from b ∈ free(γ₂))
raise error(duplicate implicit result term)
return ⟨
result(γ₁) ∪ result(γ₂)
for
free(γ₁) ∪ free(γ₂)
where
formula(γ₁) ∧ formula(γ₂)
⟩
}
For example, merging a component frame with result { name = N₁:String } and another component
frame with result { name = N₂:String } is an error. tupleMerge does not try to align N₁ and
N₂; duplicate result attributes are rejected because a tuple cannot contain two result bindings
for name. Likewise, merging { supplier = S:Supplier } with { x = S:Supplier } is an error:
the tuple would expose the implicit supplier result again under another name. Merging
{ y = S:Supplier } with { w = S:Supplier } is allowed when both are explicit aliases.
A genuine query-level union illustrates result alignment. The expression
s.(supplier.city ⊔ part.city) asks for cities reached either through suppliers or through parts.
Both operands have result attribute city = C:String, so alignment unifies the result city term
before the formulas are joined by ∨. The operand frames below are shown after result alignment
has unified their fresh city variables:
γ_supplier_city =
⟨
{ city = C:String }
for
{ C:String from city }
where
∃S:Supplier (
s.supplier(S)
∧ S.city(C)
)
⟩
γ_part_city =
⟨
{ city = C:String }
for
{ C:String from city }
where
∃P:Part (
s.part(P)
∧ P.city(C)
)
⟩
γ_supplier_city ⊔ γ_part_city =
⟨
{ city = C:String }
for
{ C:String from city }
where
(∃S:Supplier (s.supplier(S) ∧ S.city(C)))
∨
(∃P:Part (s.part(P) ∧ P.city(C)))
⟩
Vertical composition is current-frame-sensitive. The right-hand frame is translated in an environment where the left-hand frame is the current frame.
bind∃(γ_left, γ_right, P)
{
let D = vars(free(γ_left)) \ FV(result(γ_right))
if D ∩ FV(formula(γ_right)) = ∅
return γ_right
let γ =
⟨
result(γ_right)
for
free(γ_left) ∪ free(γ_right)
where
formula(γ_left) ∧ formula(γ_right)
⟩
let Q =
(vars(free(γ_left)) \ P)
∩ FV(formula(γ))
\ FV(result(γ))
return close∃(γ, Q)
}
bind∃ is ordinary path composition. If the right-hand formula still depends on variables from the
left-hand frame, the left-hand formula is carried forward before non-result, non-protected variables
are closed. This includes protected variables: protection prevents capture, but it does not make the
formula that introduced the protected variable optional.
bind∀(γ_left, γ_right, P)
{
let γ =
⟨
result(γ_right)
for free(γ_left) ∪ free(γ_right)
where formula(γ_left) → formula(γ_right)
⟩
let Q =
(vars(free(γ_left)) \ P)
∩ FV(formula(γ))
\ FV(result(γ))
return close∀(γ, Q)
}
bind∀ is universal path composition. The intermediate implication frame keeps free variables from
both sides because the formula mentions both formula(γ_left) and formula(γ_right).
pointNot(γ_current, γ)
{
return ⟨
result(γ)
for
free(γ_current) ∪ free(γ)
where
formula(γ_current) ∧ ¬formula(γ)
⟩
}
pathComplement(γ_current, γ, P)
{
let F = free(γ)
let Q_close =
(vars(F) \ vars(free(γ_current)) \ P)
∩ FV(formula(γ))
let Q_keep = vars(F) ∩ P ∩ FV(formula(γ))
let K_close = decls(F, Q_close)
let K_keep = decls(F, Q_keep)
return ⟨
result(γ_current)
for
free(γ_current) ∪ K_keep
where
formula(γ_current) ∧ ¬∃(K_close) formula(γ)
⟩
}
Both operations are logical negations. Any limits on materialising complements are operational
limits, not denotational restrictions. pathComplement retains free variables for protected
variables from the negated frame when those variables remain free in the returned formula.
Since the frame γ is normally translated in an environment containing γ_current, formula(γ) may
already contain conjuncts from formula(γ_current). Implementations may factor out those known
conjuncts before placing formula(γ) under negation; the denotation is unchanged in the returned
frame because formula(γ_current) is conjoined outside the negation.
Folds and cardinality expressions turn a unary frame into a set comprehension. In a current frame,
some variables remain free in the comprehension term rather than being bound inside it. The
protected set P records variables that the caller has already decided to retain; the current frame
may also contribute protected variables when the folded value does not itself bind the current
result.
comprehension(Γ, P, γ)
{
let γ₀ = currentFrame(Γ)
let (a,C,T) = unary(γ)
let bindsCurrent =
result(γ) = result(γ₀)
or FV(C) ∩ vars(free(γ₀)) ≠ ∅
let Ψ = formula(γ)
for each γ_known in Γ
{
if γ_known = γ₀ and bindsCurrent
continue
if γ_known = γ₀ and vars(free(γ_known)) ∩ vars(P) = ∅
continue
if vars(free(γ_known)) ∩ vars(P) ≠ ∅
Ψ = Ψ with formula(γ_known) factored out
}
let P' = vars(P)
if bindsCurrent
P' = P' \ vars(free(γ₀))
else
P' = P' ∪ vars(free(γ₀))
let Q = vars(free(γ)) \ P'
let D = { V:T | (V,T,a) ∈ free(γ) and V ∈ Q }
let K = { d ∈ free(γ) | var(d) ∈ P' ∩ (FV(Ψ) ∪ FV(C)) }
return (
{ C:T for D where Ψ },
K
)
}
The returned set K is retained by the caller, because those protected variables remain free in the
comprehension term. As with negation, conjuncts that are already known outside the fold may be
factored out of formula(γ) as a normalization step. Cardinality expressions protect the current
frame before calling comprehension, so receiver predicates such as s.supplier(S) are not counted
inside the per-receiver set.
Many rules require a unary frame:
unary(γ)
{
if result(γ) does not contain exactly one term
raise error
return the unique (a,C,T) ∈ result(γ)
}
A unary frame may still have a non-trivial formula and free variables.
| Path | R | F | Ψ |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Given type T let attributes(T) be the attributes of T, as a set of (name,type) pairs.
The types Boolean, Integer, Real, String have no attributes.
Let resulttype(f,T₁, … , Tₙ) denote the result type of applying function f to n
arguments of types T₁, … , Tₙ.
A term is a syntactic value-expression represented as a string of symbols, built from logic variables and function symbols.
The set of terms is defined inductively:
f is an n-ary function symbol and t₁,… ,tₙ are terms,
then f(t₁,…,tₙ) is a term.
Only expressions obtained by finitely many applications of these rules are terms.
Function symbols include:
+, -, *, /.
A 0-ary function symbol can denote a program variable in the environment of an imperative Predica program, perhaps
associated with a database.
For example a variable named s of type ShipmentsSystem.
Within a term in a formula in logic, a program variable represents a function symbol not a logic variable.
Note therefore that program variables are treated like constants and can never be captured
(i.e. become bound using quantifiers ∃ and ∀ or in a set comprehension or a fold).
| Precedence | Operator | Associativity | Meaning |
|---|---|---|---|
| 1 (highest) | attribute names, constants, variables, none, unary +, unary -, [E], (...) |
atomic / prefix unary | primary expressions (including unary sign, filter, and tuple path tails) |
| 2 | !, ~, :k, :k..m, :?, :+, :k+ | postfix | pointwise negation, path complement, cardinality constraint |
| 3 | ., ⇒, [E], .Σ, .Σexists, .Σcount, .Σmin, .Σmax, ... | left-associative | path composition (existential, universal, filter) |
| 4 | ^ | right-associative | Power |
| 5 | *, / | left-associative | Multiply and divide |
| 6 | +, - | left-associative | Add and subtract |
| 7 | =, ≠, <, >, ≤, ≥, ⊂, ⊃, ⊆, ⊇, ⊄, ⊅, ∈, ∉, is | left-associative | Comparisons and type tests |
| 8 | ⊓, ∩, ⨝ | left-associative | tuple meet (logical AND) |
| 9 | ⊔, ∪, | | left-associative | tuple join (logical OR), set union, alternative operator |
| 10 | set difference, -- |
left-associative | set difference uses the canonical set-minus character described in the lexical grammar, with keyboard backslash accepted as an input alias; -- is path operator difference |
| 11 | as | left-associative | attribute renaming |
| 12 | ¬ | prefix | logical NOT |
| 13 | ∧ | left-associative | logical AND |
| 14 | ∨ | left-associative | logical OR |
| 15 | → | right-associative | logical IMPLIES |
| 16 | ↔ | left-associative | logical equivalence |
| 17 | if ... then ... else ... | special form | conditional expression |
| 18 (lowest) | , (inside (…)) | left-associative | tuple component separator |
The logic-frame denotation of an expression is independent of attribute cardinality constraints. An attribute declaration contributes a predicate to the denotation. Cardinality constraints say which predicate extensions are valid for stored values; they do not rewrite the expression's logical meaning.
Consider the following schema:
abstract entity A {}
entity B isa A {}
entity E
{
A a*;
→ Boolean c* = (a is B) ∧ false;
}
The denotation of c is:
c=false:Boolean
where
∃X:A (this.a(X))
If the extension of a is empty then the extension of c is empty as well, otherwise
the extension of c is {false}.
If instead a and c have exactly-one cardinality constraints:
entity E
{
A a;
→ Boolean c = (a is B) ∧ false;
}
Then the denotation of c is unchanged. Of course the extension of a cannot
be empty, so the extension of c is always {false}.
The denotational rules define the frame denoted by each path expression. Most of the work is
delegated to frame operations such as domain, relabel, close∃, close∀, ⊓, ⊔, --,
tupleMerge, bind∃, and bind∀.
The rough mental model is that a rule translates an expression relative to the current frame and
returns a new frame. The environment stack is bookkeeping for the cases where more information is
available: structural lookup through enclosing frames, let-bound names, and tuple components already
constructed.
Examples use the display notation introduced in the frame calculus section:
result(γ) = { attr = Term:Type, ... }
free(γ) = { Var:Type from attr, ... }
The formal triples are still present: Attr = Term:Type is (Attr,Term,Type) in the result, and
Var:Type from Attr is (Var,Type,Attr) in the free variables. Lowercase
names such as s are Predica source-code variables supplied by the program;
uppercase names such as S, P, SP, N, and C are generated logic variables.
An environment stack is a stack of frames. The top frame is the current frame; lower frames are available for structural lookup, let-bound names, and tuple-local names.
Γ = [γ₀, γ₁, ..., γₙ]
top(Γ) = γₙ
init(Γ) = [γ₀, γ₁, ..., γₙ₋₁]
ℒ(Γ, P, E) = γ
The protected set P contains variables protected from closure introduced while translating E.
emptyFrame = ⟨∅ for ∅ where true⟩
topOrEmpty(Γ)
{
if Γ = []
return emptyFrame
else
return top(Γ)
}
currentFrame(Γ) = topOrEmpty(Γ)
currentDomain(Γ) = domain(currentFrame(Γ))
push(Γ, γ) = Γ with γ appended on top
Some helper rules use the following meta-level sentinels. They are not Predica expressions, not values, and not frames in the semantic domain:
noFrame = absence of a receiver frame
noResult = absence of a produced result term
currentFrame is the top frame, including its result. currentDomain is the same frame viewed only
through its free variables and formula. Scalar terms use currentDomain; operations such as this,
splat, and attribute navigation use frames with their results from the environment.
Tuple construction needs a way to make earlier tuple components available to later tuple components without replacing the current frame:
insertBeforeTop(Γ, γ)
{
if result(γ) = ∅
return Γ
elif Γ = []
return [γ]
else
return init(Γ) ⊕ [γ] ⊕ [top(Γ)]
}
ℒ(Γ, P, E as b)
{
return relabel(ℒ(Γ, P, E), b)
}
Tuple construction evaluates every component relative to the same current frame. Later components may also see attributes from earlier component results.
ℒ(Γ, P, (E₁, ..., Eₙ))
{
let γ_base = currentDomain(Γ)
if n = 0
return γ_base
let γ_acc = emptyFrame
for each i
{
let Γᵢ = insertBeforeTop(Γ, γ_acc)
let Pᵢ = P ∪ vars(free(γ_base)) ∪ vars(free(γ_acc))
let γᵢ = ℒ(Γᵢ, Pᵢ, Eᵢ)
// Attribute-set expansion.
if result(γᵢ) = { a = A:Set<Attribute> }
{
let {a₁, ..., aₖ} = value(A)
let γᵢ = ℒ(Γᵢ, Pᵢ, (a₁, ..., aₖ))
}
γ_acc = tupleMerge(γ_acc, γᵢ)
}
return γ_acc
}
The accumulated frame starts empty for non-empty tuples. Each component is still translated in the original frame environment, so the current frame's condition is carried by the component frames. The current frame and earlier tuple fields are protected while later components are translated; this matters for folds, cardinality expressions, and complements inside tuple components. The empty tuple is simply the current domain:
ℒ(Γ, P, ()) = currentDomain(Γ)
A filter strengthens the current frame and preserves its result.
ℒ(Γ, P, [E])
{
if Γ = []
raise error
let γ₀ = top(Γ)
let γ₁ = ℒ(Γ, P ∪ vars(free(γ₀)), E)
let (a,C,T) = unary(γ₁)
if T ≠ Boolean
raise error
let Θ = formula(γ₁) ∧ C
let F = free(γ₁)
let Q_close =
(vars(F) \ vars(free(γ₀)) \ P)
∩ FV(Θ)
let Q_keep = vars(F) ∩ P ∩ FV(Θ)
let K_close = decls(F, Q_close)
let K_keep = decls(F, Q_keep)
return ⟨
result(γ₀)
for
free(γ₀) ∪ K_keep
where
formula(γ₀) ∧ ∃(K_close) Θ
⟩
}
This is the strict boolean-filter rule. A non-strict existence-filter mode can replace Θ with
formula(γ₁), but the strict rule keeps existence tests explicit.
Because the condition expression is translated with γ₀ in the environment, formula(γ₁) may include
conjuncts already present in formula(γ₀). Implementations may factor those known conjuncts out of
Θ before existential closure; the displayed examples below use this normalized form.
Putting filtering and composition together, the supplier query from the foundations section:
s.supplier.[city = "London"].name
has, after the obvious formula simplifications, the frame:
⟨
{ name = N:String }
for
{ N:String from name }
where
∃S:Supplier ∃C:String (
s.supplier(S)
∧ S.city(C)
∧ C = "London"
∧ S.name(N)
)
⟩
The formula contains qualified unary predicates. In s.supplier(S), S.city(C), and S.name(N),
the type of the value before the dot determines which attribute is meant.
Ordinary composition translates the right-hand expression with the left-hand frame as the current frame, then closes left-hand variables not needed by the result.
ℒ(Γ, P, E₁.E₂)
{
let γ₁ = ℒ(Γ, P, E₁)
let γ₂ = ℒ(push(Γ, γ₁), P, E₂)
return bind∃(γ₁, γ₂, P)
}
For s.supplier.name, the right-hand translation has only name = N:String in its result.
Therefore bind∃ closes the supplier variable S, which is not needed by the result, and produces:
⟨
{ name = N:String }
for
{ N:String from name }
where
∃S:Supplier (
s.supplier(S)
∧ S.name(N)
)
⟩
Universal composition translates the right-hand expression with the left-hand variables protected, then universally closes left-hand variables not needed by the result.
ℒ(Γ, P, E₁⇒E₂)
{
let γ₁ = ℒ(Γ, P, E₁)
let γ₂ = ℒ(push(Γ, γ₁), P ∪ vars(free(γ₁)), E₂)
return bind∀(γ₁, γ₂, P)
}
Because γ₂ is translated with γ₁ as the current frame, its formula may syntactically include
formula(γ₁). Implementations may factor those known conjuncts out of the consequent before rendering
the implication.
The path-level logical connectives are direct calls to the horizontal frame operations:
ℒ(Γ, P, E₁ ⊓ E₂)
{
return ℒ(Γ, P, E₁) ⊓ ℒ(Γ, P, E₂)
}
ℒ(Γ, P, E₁ ⊔ E₂)
{
return ℒ(Γ, P, E₁) ⊔ ℒ(Γ, P, E₂)
}
ℒ(Γ, P, E₁ -- E₂)
{
return ℒ(Γ, P, E₁) -- ℒ(Γ, P, E₂)
}
The difference rule is unrestricted logical negation. Union-compatible results are not
required. Source-level path difference is written --. Scalar and set difference use the
canonical set-minus character described in the lexical grammar, with keyboard backslash accepted
as an input alias.
For example, s.(supplier.city ⊔ part.city) is a genuine query-level union. Both operands have
result attribute city = C:String, so alignment unifies the result city term and combines the two
formulas with ∨.
The operator | is the expression-level alternative operator. It combines branches with
exactly one result term by logical disjunction. The branch result names do not need to align:
matching names are preserved, while differing names produce an anonymous result. Each branch
contributes possible values for one shared result, which is useful for expressions such as
variant-derived definitions:
sphere.(4/3*pi*radius^3) | cube.(side^3) | cone.(pi*radius^2*height/3)
Each operand must denote exactly one result. The operand result types must have a common result type. The alternative expression introduces one fresh output variable and each branch states that this output variable equals the value produced by that branch.
branchAlt(γ, C, O, γ_receiver, P)
{
let Ψ = formula(γ) ∧ O = C
let F = free(γ)
let Q = vars(F)
if γ_receiver ≠ noFrame
Q = Q \ vars(free(γ_receiver))
Q = Q \ P
Q = Q ∩ FV(Ψ)
let K = decls(F, Q)
return ∃(K) Ψ
}
ℒ(Γ, P, E₁ | E₂)
{
let γ₁ = ℒ(Γ, P, E₁)
let γ₂ = ℒ(Γ, P, E₂)
let (a₁,C₁,T₁) = unary(γ₁)
let (a₂,C₂,T₂) = unary(γ₂)
require T₁ and T₂ have common type T
let a = if a₁ = a₂ then a₁ else _anon_
let O:T be fresh
let γ_receiver = if Γ = [] then noFrame else currentFrame(Γ)
let Ψ =
branchAlt(γ₁, C₁, O, γ_receiver, P)
∨ branchAlt(γ₂, C₂, O, γ_receiver, P)
let F =
{ O:T from a }
∪ { d ∈ free(γ₁) ∪ free(γ₂) | var(d) ∈ FV(Ψ) }
return ⟨
{ a = O:T }
for
F
where
Ψ
⟩
}
This rule explains why a derived attribute over variant attributes naturally renders as a
disjunction. For a Shape.volume definition, the sphere, cube, and cone branch formulas are
inclusive alternatives for the same output volume variable. The | operator exposes one shared
result whose possible values are constrained by the disjunction of the branch formulas; if the branch
result names differ, that shared result is anonymous.
The raw alternative denotation is independent of schema cardinality constraints. A later
schema-aware formula simplifier may use cardinality facts for display. For example, if a branch
contains two structural equalities for the same single-valued record fields, the simplifier may infer
the corresponding field-term equalities and remove redundant witnesses. That is a simplification of
the rendered formula, not a side condition of the | denotation rule.
Declaration-level either is intentionally outside these path-expression rules. It is not a
composite expression and does not introduce a disjunction into the denotation of a path expression.
The expression if A then B else C is not translated as ordinary function application. The
condition and both branches are contextual expressions; a branch may carry path constraints, receiver
variables, and an optional no-result denotation. The conditional introduces one shared output term,
then guards the whole denotation of each producing branch.
branchIf(γ_cond, B, whenTrue, γ_branch, V, O, γ_receiver, P)
{
let G = formula(γ_cond) ∧ (if whenTrue then B else ¬B)
let Ψ = G ∧ formula(γ_branch) ∧ O = V
let F = free(γ_cond) ∪ free(γ_branch)
let Q = vars(F)
if γ_receiver ≠ noFrame
Q = Q \ vars(free(γ_receiver))
Q = Q \ P
Q = Q ∩ FV(Ψ)
let K = decls(F, Q)
return ∃(K) Ψ
}
ℒ(Γ, P, if A then E₁ else E₂)
{
let γ₀ = ℒ(Γ, P, A)
let (a₀,B,T₀) = unary(γ₀)
require T₀ = Boolean
let γ₁ = ℒ(Γ, P, E₁)
let γ₂ = ℒ(Γ, P, E₂)
let r₁ = if result(γ₁) = ∅ then noResult else unary(γ₁)
let r₂ = if result(γ₂) = ∅ then noResult else unary(γ₂)
if r₁ = noResult and r₂ = noResult
return ⟨∅ for ∅ where false⟩
require the producing branch result types have common type T
let a be the producing branch attribute
if all producing branches use the same one,
otherwise _anon_
let O:T be fresh
let γ_receiver = if Γ = [] then noFrame else currentFrame(Γ)
let branches = ∅
if r₁ = (a₁,V₁,T₁)
add branchIf(
γ₀, B, true, γ₁, V₁, O, γ_receiver, P
) to branches
if r₂ = (a₂,V₂,T₂)
add branchIf(
γ₀, B, false, γ₂, V₂, O, γ_receiver, P
) to branches
let Ψ = disjunction(branches)
let F =
{ O:T from a }
∪ {
d ∈ free(γ₀) ∪ free(γ₁) ∪ free(γ₂)
| var(d) ∈ FV(Ψ)
}
return ⟨
{ a = O:T }
for
F
where
Ψ
⟩
}
A branch with no result contributes no possible output values; it does not contribute a marker value.
For example, in if email ≠ none then email else none, the then branch contributes the email values
under the presence guard and the else branch contributes no values.
ℒ(Γ, P, E!)
{
let γ₀ = currentFrame(Γ)
let γ = ℒ(Γ, P, E)
return pointNot(γ₀, γ)
}
ℒ(Γ, P, E~)
{
if Γ = []
raise error
let γ₀ = currentFrame(Γ)
let γ = ℒ(Γ, P ∪ vars(free(γ₀)), E)
return pathComplement(γ₀, γ, P)
}
Folds turn a unary path frame into a set comprehension. Variables in the protected set remain free
in the comprehension term; all other frame variables are bound by the comprehension. The
comprehension helper is sensitive to currentFrame(Γ): if the folded value does not itself bind
the current result, variables from the current frame are retained so the fold is evaluated per
current receiver.
The fresh variables introduced by folds and cardinality are calculated variables. Their from
component is an attribute label such as _anon_; the actual derivation is the defining equation
X = Σop(comp) or N = Σcount(comp). _anon_ is the attribute name for unnamed results in unary
frames.
ℒ(Γ, P, E.Σ)
{
let γ = ℒ(Γ, P, E)
let (comp,K) = comprehension(Γ, P, γ)
let comp have type Set<T>
let X be fresh of type Set<T>
return ⟨
{ _anon_ = X:Set<T> }
for
{ X:Set<T> from _anon_ } ∪ K
where
X = comp
⟩
}
ℒ(Γ, P, E.Σop)
{
let γ = ℒ(Γ, P, E)
let (comp,K) = comprehension(Γ, P, γ)
let T_out = foldtype(op, type(comp))
let X be fresh of type T_out
return ⟨
{ _anon_ = X:T_out }
for
{ X:T_out from _anon_ } ∪ K
where
X = Σop(comp)
⟩
}
Cardinality expressions are boolean scalar folds over the same comprehension:
Cardinality protects the current frame before building the comprehension. This keeps receiver
predicates outside the counted set, so a path such as supplier.[city:+] counts cities per supplier
rather than counting supplier-city pairs globally.
ℒ(Γ, P, E:k)
ℒ(Γ, P, E:k..m)
ℒ(Γ, P, E:?)
ℒ(Γ, P, E:+)
ℒ(Γ, P, E:k+)
{
let γ = ℒ(Γ, P, E)
let Pcard = P ∪ vars(free(currentFrame(Γ)))
let (comp,K) = comprehension(Γ, Pcard, γ)
let N be fresh of type Integer
let Ccard =
N = k for E:k
0 ≤ N ≤ 1 for E:?
N ≥ 1 for E:+
k ≤ N for E:k+
k ≤ N ≤ m for E:k..m
return ⟨
{ _anon_ = Ccard:Boolean }
for
{ N:Integer from _anon_ } ∪ K
where
N = Σcount(comp)
⟩
}
The expression none is a curtailed no-result expression. It is not a value term and is not
translated by the scalar/function-application rule. It may appear only in source contexts that
explicitly permit absence, such as optional-result conditionals, presence tests, and optional
location clearing. It is never a member of any value domain.
ℒ(Γ, P, none) =
⟨∅ for ∅ where false⟩
Source comparisons with none are desugared before denotation: E = none means E.Σempty,
and E ≠ none means E.Σexists. After this rewrite, none is not passed to the scalar
comparison rule as an operand.
this and splat
ℒ(Γ, P, this)
{
if Γ = []
raise error
return currentFrame(Γ)
}
ℒ(Γ, P, *)
{
if Γ = []
raise error
let γ = currentFrame(Γ)
let (x,C,Tₓ) = unary(γ)
let A = { a | (a,Tₐ) ∈ attributes(Tₓ) }
return ⟨
{ _anon_ = A:Set<Attribute> }
for
free(γ)
where
formula(γ)
⟩
}
ℒ(Γ, P, let a = E₁ in E₂)
{
let γ₁ = ℒ(Γ, P, E₁)
let (b,C,T) = unary(γ₁)
let γₐ = ⟨{ a = C:T } for free(γ₁) where formula(γ₁)⟩
let γ₂ = ℒ(push(Γ, γₐ), P, E₂)
return bind∃(γ₁, γ₂, P)
}
The binding is structural: the name points to the bound value's result term while keeping the bound formula and free variables attached to it.
For a constraint expression C declared on type T, start with a frame for the constrained
value:
γ_this = ⟨{ this = this:T } for ∅ where true⟩
γ_C = ℒ([γ_this], ∅, C)
this is a source-level value supplied by the surrounding constraint, not a generated logic
variable. Fresh variables introduced while translating the constraint are added to free(γ_C).
if result(γ_C) = { a = B:Boolean }
constraint formula = ∀vars(free(γ_C)) ( formula(γ_C) → B )
else
reject the constraint
The frame calculus has the following consequences:
F is the set of free variables of the frame, not a result. Its attribute labels are
informational and do not replace the derivation given by R and Ψ.
domain.
bind∀, which selects variables satisfying
the precondition of close∀.
--, distinct from scalar and set difference.
Scalar and set difference use the canonical set-minus character described in the lexical
grammar, with keyboard backslash accepted as an input alias.
Consider a variant type Shape:
value Point
{
Real x;
Real y;
}
value Circle
{
Point centre;
Real radius;
}
value Triangle
{
Point vertex[3];
}
variant Shape
{
Circle circle;
Triangle triangle;
}
Path query expressions and their denotation into logic work on variants in the same way as entity and value types.
For example, given a program variable s of type Shape, the following gives examples of query expressions:
| Predica | Logic |
|---|---|
|
|
|
|
The empty structural type is often useful in variant types, for example:
variant Tree
{
{} empty;
Tree child+;
}
In a Boolean context, a path with result type {} is treated by static elaboration, before
denotation, as an existence test. The same rule applies to path expressions whose frame result has no
attributes. Define:
asBoolean(E)
{
if type(E) = Boolean
return E
if type(E) = {}
return E.Σexists
if resultAttributes(E) = ∅
return E.Σexists
otherwise
raise error
}
Boolean contexts include filters, logical negation and connectives, constraints, and Boolean-valued
function arguments. Each such context applies asBoolean to the expression it expects to be
Boolean. The denotational rules therefore still receive an ordinary Boolean expression and do not
need special cases for {} or empty result sets.
For example, given a variable t of type Tree, the path expression t.empty
can be used instead of t.empty.Σexists in a filter.
Likewise, an empty projection such as s.supplier.() has no result attributes. In a Boolean
context such as logical negation it is elaborated as an existence test, so:
¬(s.supplier.())
is elaborated as:
¬(s.supplier.().Σexists)
This does not make s.supplier.() a scalar Boolean result. Its short denotation may still be
displayed as the formula alone, for example ∃S:Supplier (s.supplier(S)).
enum Color
{
red, green, blue
}
is syntactic sugar for:
variant Color
{
{} red;
{} green;
{} blue;
}
The following query finds the red or green parts shipped by suppliers in London:
s.supplier.[city = 'London].shipment.part.[color.red ∨ color.green]
The examples below focus on the result R and formula Ψ parts of path-frame denotations.
The free-variable set F is part of the frame, but is omitted from these compact examples when it is
clear from the displayed result and formula.
| Path | Result | Formula |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The following examples use this small schema:
value Prism
{
Real width;
Real height;
Real depth;
}
value Sphere
{
Real radius;
}
variant Shape
{
Prism prism;
Sphere sphere;
}
enum Color
{
red, green, blue
}
entity Object
{
Shape shape;
Color color;
}
entity Inventory
{
Object object*;
}
| Path | Result | Formula |
|---|---|---|
|
|
|
| Path | Result | Formula |
|---|---|---|
|
|
|
The following table provides examples of queries expressed in Predica and shows how they correspond to logic-frame denotations. The right-hand column uses the short display form of a frame, omitting the free-variable set when it is clear from the result and formula:
| Predica | Logic-frame display |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Suppliers that don't ship any parts (anti-join)
|
|
suppliers where all parts they ship are red
|
|
Alternatively
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The query-frame calculus defines the value denotation of Predica expressions. Location denotation is a second, partial denotation for expressions that can be used as update targets. Query denotation answers which values an expression produces. Location denotation answers which occurrences of those values the expression can update. It retains the provenance and entity witnesses needed for writes without making those witnesses visible in the value result.
A location is an addressable place in a Predica state, not the value currently stored there. Equal values may occur in independent variable locations. Conversely, schema constraints may make several paths reach one shared entity occurrence and therefore the same attribute location. Location denotation is interpreted relative to those constraints.
Let σ be the pre-state in which an expression is evaluated and let Τ be the applicable schema
theory. A raw entity occurrence records a receiver value together with the provenance by which it was
reached from a root location:
RawOccurrence ::= Occ(RootAddress, ReceiverTerm, Provenance)
RawAddress ::=
VarLoc(Environment, VariableName)
| RawAttrLoc(RawOccurrence, QualifiedAttributeName)
The provenance distinguishes equal values held independently. For example, equal Supplier values
held by variables a and b have occurrences rooted at different VarLoc addresses. Updating
a.status therefore does not update b.status merely because the values read from a and b are
equal.
Entity occurrences can nevertheless be shared. The schema theory induces an equivalence relation
~Τ on raw occurrences. Two raw occurrences are equivalent when the sharing rules generated by the
applicable constraints establish that they are occurrences of the same entity in the model. A
concrete entity occurrence is an equivalence class [O]Τ, and concrete addresses are:
Address ::=
VarLoc(Environment, VariableName)
| AttrLoc([RawOccurrence]Τ, QualifiedAttributeName)
It follows directly that attributes reached through equivalent occurrences have equal addresses:
O₁ ~Τ O₂
⇒ AttrLoc([O₁]Τ, T.a) = AttrLoc([O₂]Τ, T.a)
Keys, attribute constraints, inverse definitions, derived definitions, and inclusion dependencies can establish sharing. They do not make all equal entity values shared: sharing depends on the occurrences being connected by the constraints of the model in which they occur.
TODO: Define the generators of ~Τ for every constraint form that establishes
sharing. The rules must specify the extension within which a key is unique and how occurrence
equivalence is carried through inverse paths, derived alternatives, and inclusion dependencies.
TODO: Define an effective construction of occurrence equivalence. Its implementation may use canonical occurrence handles, key indexes, references, or another representation, but mechanical evaluation must not require deciding unrestricted logical entailment.
Raw location denotation is written RawLoc(Τ, Γ, P, E) and mirrors the shape of a query frame:
RawLoc(Τ, Γ, P, E) = ⟪ L for F where Ψ ⟫
where:
L is an attribute-keyed set of typed raw-address bindings, written a = A:T;
F is the set of free logic variables retained as witnesses; and
Ψ is the formula selecting those witnesses.
The well-formedness invariant is:
vars(F) = FV(Ψ) ∪ FV(L)
Variables occurring only in addresses remain visible to raw location denotation. This is what keeps the receiver and its provenance available for an update after query projection would otherwise hide them.
Concrete location denotation quotients raw occurrences by the sharing equivalence and removes duplicate concrete addresses:
Loc(Τ, Γ, P, E) = RawLoc(Τ, Γ, P, E) / ~Τ
The shorter notation Loc(Γ, P, E) used elsewhere omits Τ. It always means the concrete location
denotation relative to the applicable schema theory.
There are two read operations. Read₀ preserves the address witnesses needed by an update. Readᵥ
existentially closes address-only witnesses so the result agrees with ordinary query denotation. The
central consistency law is:
Readᵥσ(Loc(Τ, Γ, P, E)) ≡ ℒσ(Γ, P, E)
The equivalence ≡ is equality of query frames up to ordinary frame normalisation and existential
closure. The law applies whenever location denotation is defined.
For a program variable location, reading returns the value held in its environment slot:
Read₀σ(⟪ { _anon_ = VarLoc(env, s):T } for F where Ψ ⟫)
=
⟨ { _anon_ = valueσ(env, s):T } for F where Ψ ⟩
For an attribute location whose occurrence has receiver term C, reading introduces the current
attribute value as a fresh logic variable:
Read₀σ(⟪ { a = AttrLoc([O]Τ, T.a):U } for F where Ψ ⟫)
=
⟨ { a = V:U } for F ∪ { V:U from a } where Ψ ∧ C.a(V) ⟩
where C = receiver(O)
This read must be independent of the representative O chosen from [O]Τ. Optional and set-valued
attributes remain addressable when their current extensions are empty; absence of a value witness
does not imply absence of the location.
TODO: Prove that reading and writing an attribute location are well-defined on occurrence equivalence classes: equivalent representatives must produce the same read and the same state transition.
Location denotation is partial. It is defined for mutable variables, mutable base attributes, and structured targets whose components have location denotations. It is undefined for literals, arithmetic expressions, function calls, comparisons, folds, and derived or read-only attributes unless an explicit inverse update rule supplies a location denotation.
RawLoc(Τ, [], ∅, s)
=
⟪ { _anon_ = VarLoc(env, s):ShipmentsSystem }
for ∅
where true ⟫
When the current receiver frame exposes C:T together with occurrence witness O, and T declares
a mutable base attribute a:U, the raw location is:
RawLoc(Τ, Γ, P, a)
=
⟪ { a = RawAttrLoc(O, T.a):U }
for free(γ) ∪ occurrenceVars(O)
where formula(γ) ⟫
where γ ∈ Γ, result(γ) = { x = C:T }, and receiver(O) = C
Unlike query navigation, this rule does not add C.a(V) to the formula. The attribute location
exists even when its extension is empty. The value predicate is added only when the location is
read.
For a composed target E₁.E₂, the left side selects receiver values using query denotation while raw
location evaluation retains the root and path provenance of those receivers. The right side is then
interpreted as a target relative to the resulting receiver occurrences:
RawLoc(Τ, Γ, P, E₁.E₂)
{
let γ₁ = ℒ(Γ, P, E₁)
let Ω₁ = receiverOccurrences(Τ, Γ, P, E₁, γ₁)
return RawLoc(Τ, pushOccurrences(Γ, γ₁, Ω₁), P, E₂)
}
This is not an inversion of query semantics. Query denotation selects the receiver values; raw location evaluation additionally preserves how each selected occurrence was reached. Filters retain the provenance of the receivers that satisfy their conditions.
TODO: Define receiverOccurrences and pushOccurrences for every path-expression
form. In particular, projection and existential closure in the value frame must not discard
provenance needed by a later update target.
RawLoc(Τ, Γ, P, (E₁, ..., Eₙ))
{
return rawLocationTupleMerge(
RawLoc(Τ, Γ, P, E₁), ..., RawLoc(Τ, Γ, P, Eₙ))
}
Each component must have a location denotation. Result attributes must be unique, formulas are conjoined, and free and occurrence witnesses are unioned. A structured target is invalid if two of its fields reduce to the same concrete address and would supply competing replacements.
assignable(Τ, Γ, P, E)
⇔ Loc(Τ, Γ, P, E) is defined and writable(Loc(Τ, Γ, P, E))
Failure to form a location denotation is an invalid update target. A valid location frame whose formula selects no occurrences is instead a zero-location update and performs no writes.
Every address has an element type and cardinality obtained from the variable environment or schema:
elem(VarLoc(env, x)) = element type of x
card(VarLoc(env, x)) = cardinality of x
elem(AttrLoc([O]Τ, T.a)) = declared element type of T.a
card(AttrLoc([O]Τ, T.a)) = declared cardinality of T.a
validWrite(A, X)
⇔ (∀v ∈ X. v isa elem(A)) ∧ |X| ∈ card(A)
For a required location, X contains exactly one value. For an optional location it contains zero
or one value, and assigning none supplies the empty extension. For a set-valued location, X is
the replacement extension. The same rule covers all other declared cardinality bounds.
Writing is a semantic state transformation:
Write(σ, A, X) = σ′
For a VarLoc, the transformation replaces the value in that variable slot. For an AttrLoc, it
replaces the attribute extension of the shared entity occurrence. Every path that reaches the same
occurrence class subsequently reads the replacement. This requirement is independent of whether an
implementation mutates shared storage, updates indexes, or reconstructs enclosing values.
TODO: Complete the definition of Write for shared entity occurrences. It must state
how a revised occurrence is reflected in every base extension containing it, preserve constraints on
enclosing values, and cover changes to both key and non-key attributes.
For T := E, the target locations and right-hand query frame are evaluated in the same statement
pre-state:
λ = Loc(Τ, Γ, P, T)
ρ = ℒ(Γ, P, E)
If λ and ρ each contain one binding, they align regardless of attribute name. Otherwise their
attributes must match exactly. Each aligned right-hand result supplies the replacement extension for
the corresponding target address.
The target must be assignable, right-hand element types must be assignable to the target element
types, and static right-hand cardinalities must fit the target cardinalities. Runtime extensions are
then checked with validWrite. All selected addresses and replacements are computed and validated
before any write is applied. Failure leaves the statement state unchanged.
The selected concrete addresses form a set, not a bag. Several paths or witness assignments that reduce to the same address cause that address to be read and written once. Writes to distinct addresses are applied simultaneously to produce the statement post-state.
Numeric and string compound operators read each selected address in the pre-state, compute its
replacement, and apply the same validation and simultaneous-write rules. For ordinary value-typed
extensions, ∪=, ∩=, and ∖= compute set union, intersection, and difference respectively.
For an entity-typed extension, ∪= is a keyed extension update: it inserts a right-hand member whose
key is absent and replaces the existing member whose key is present.
TODO: Complete keyed ∪= for set-valued right-hand operands and for the case where
different candidate keys match different existing members. The Updates part records the available
design choices and the provisional recommendation.
Consider a schema in which Supplier.shipment contains shipments constrained to name that supplier,
Part.shipment contains shipments constrained to name that part, Shipment has key
{supplier, part}, and ShipmentsSystem.shipment is derived as the alternative of the two shipment
paths. Let s be a supplier, p a part, and h their shipment.
s.shipment.[part = p].qty
p.shipment.[supplier = s].qty
Raw location evaluation retains two path occurrences for h, one reached through the supplier and
one through the part. The ownership constraints, derived alternative, and shipment key establish
that those occurrences are shared:
O_supplier ~Τ O_part
Both targets therefore reduce to:
AttrLoc([O_supplier]Τ, Shipment.qty)
= AttrLoc([O_part]Τ, Shipment.qty)
Assigning a quantity through either path changes this one location. Reading through the other path,
or through the derived ShipmentsSystem.shipment path, observes the assigned quantity. This is a
required semantic behaviour and is the principal worked test for the completed occurrence-equivalence
and Write rules.
C++ is used in this chapter to illustrate mappings from Predica into an implementation. This provides a convenient way to understand possible in-memory representations. Predica provides physical implementation independence, so in general the compiler is free to map Predica types to physical representations that favour space efficiency and performance. The persistence of these representations is introduced briefly here and discussed in more detail later.
The mapping to an in-memory representation in a low-level language such as C++ is typically inaccessible to users. There is therefore no requirement for the representation itself to enforce the Predica constraints. The compiler and runtime may diagnose violations elsewhere while using a simpler internal representation. Constraint enforcement is specified independently in Constraint-Violating Updates.
One of the most important implementation approaches is to map Predica types to what C++ calls structures. Where the cardinality constraints permit it, a concrete instantiation of a generic Predica value type can then have a fixed, compact in-memory layout. For example, consider these generic types:
value Point<Type T>
{
T x;
T y;
}
value Circle<Type T>
{
Point<T> centre;
T radius;
}
Instantiating these types as Point<Float32> and Circle<Float32> fixes
each coordinate and radius to the Predica Float32 type. An implementation in
C++ can represent those values using float fields:
struct PointFloat32
{
float x;
float y;
};
struct CircleFloat32
{
PointFloat32 centre;
float radius;
};
An attribute with a fixed cardinality can be represented by a fixed-size array. For example:
value Triangle<Type T>
{
Point<T> vertex[3];
}
Because every Triangle<T> has exactly three vertices,
Triangle<Float32> can be mapped directly to a C++ structure containing a
std::array<PointFloat32, 3>:
struct TriangleFloat32
{
std::array<PointFloat32, 3> vertex;
};
Attributes with a many-valued cardinality can be represented by B-trees. For example, consider the following Predica types:
entity Supplier
{
String id;
String name;
Integer status;
String city;
key {id};
}
entity ShipmentsSystem
{
Supplier supplier*;
}
The many-valued attribute can be mapped to a btree<Key, Value> member in
C++. The key of the B-tree is the id key declared by Supplier.
struct Supplier
{
std::string id;
std::string name;
std::int64_t status;
std::string city;
};
struct ShipmentsSystem
{
btree<std::string, Supplier> supplier;
};
A btree<K, V> hides a great deal of complexity, including:
A persistent B-tree allows an implementation to deal with billions of records
without loading the entire collection into memory. Opening the database can be
fast because loading ShipmentsSystem need only load the root node of the
supplier B-tree; other nodes can be loaded on demand. Operations can remain
very efficient because B-tree nodes have high fan-out. For example, with a
fan-out of 1000, a suitably laid-out tree can locate a record using only two
random-access fetches from disk after the root node has been loaded.
Specialised variants may be preferable for particular workloads. In a B+ tree, all mapped values are stored in leaf nodes, while internal nodes store only the keys needed for navigation. This can be superior when keys are small compared with their mapped values because more keys fit in each internal node, increasing the fan-out. The leaf nodes can also be linked together, allowing faster iteration through the values and efficient traversal of key ranges.
Using a B-tree for every many-valued attribute would be a significant pessimisation. When a collection is expected to have low cardinality, a contiguous vector can use less memory, require fewer allocations, and provide better cache locality. Linear search over a small vector can also be faster than traversing a B-tree.
struct ShipmentsSystem
{
std::vector<Supplier> supplier;
};
Other possible C++ representations include:
std::map, commonly implemented as a red-black tree, when ordered keys
and logarithmic lookup and update are useful, and the cardinality is low
enough that the collection can reasonably be expected to fit in memory;
unlike a persistent B-tree, it does not lazily load nodes from secondary
storage;std::unordered_map when hashing is available and fast lookup by key is
more important than key order; it generally carries the same assumption
that the collection fits in memory and can have substantial memory
overhead;std::set when only distinct values, rather than separate keys and
values, need to be stored; andstd::queue when the implementation only needs first-in, first-out
access to the values.
The choice between a vector, a B-tree, and another representation is a physical
implementation decision. It can be based on expected cardinality, observed
data, access patterns, cardinality constraints, and other implementation
assumptions. Regardless of that choice, the implementation must preserve the
Predica cardinality and key constraints; when a vector is used, uniqueness of
Supplier.id must still be enforced. Comparison and hashing must agree with
Predica equality.
The same Predica entity may occur in multiple attribute extensions. This suggests an implementation in which those occurrences are represented by references to a single object with implementation-level identity. For example, the same shipment can occur under both a supplier and a part:
entity Supplier
{
String id;
Shipment shipment* where shipment.supplier = this;
key {id};
}
entity Part
{
String id;
Shipment shipment* where shipment.part = this;
key {id};
}
entity Shipment
{
Supplier supplier;
Part part;
Integer quantity;
key {supplier, part};
}
In C++, the direct mapping of these multiple occurrences is to dynamically allocate
one Shipment object and store memory pointers to that same object under both
the supplier and the part. std::shared_ptr can be used for those pointers to
manage the lifetime of the object:
struct Supplier;
struct Part;
struct Shipment;
struct Supplier
{
std::string id;
std::vector<std::shared_ptr<Shipment>> shipment;
};
struct Part
{
std::string id;
std::vector<std::shared_ptr<Shipment>> shipment;
};
struct Shipment
{
Supplier* supplier;
Part* part;
std::int64_t quantity;
};
Supplier supplier;
Part part;
std::shared_ptr<Shipment> shipment = std::make_shared<Shipment>();
shipment->supplier = &supplier;
shipment->part = ∂
supplier.shipment.push_back(shipment);
part.shipment.push_back(shipment);
Both collections refer to one object rather than containing independent copies
of the shipment. The identity supplied by the C++ object is an implementation
mechanism and is not part of the Predica value's semantics. The back-pointers
from Shipment to Supplier and Part are non-owning, and their validity is
maintained by the implementation.
Whenever Predica types are mapped to memory representations using pointers, there is an opportunity to represent persistent objects using object identifiers in a persistent object store and to make pointer dereference trigger loading from disk on demand. This behaviour can be embodied in smart pointers, pointer swizzling, or faulting.
A quotient type defines values as equivalence classes over a representation.
For example, Rational<T> can be parameterised by an integer type:
quotient Rational<Type T> where T isa Integer
{
T numerator;
T denominator where denominator > 0;
equivalence
{
x ~ y where
x.numerator * y.denominator = y.numerator * x.denominator;
}
}
The direct mapping is a structure that directly implements the
representation fields and implements the equivalence relation in operator==:
template<typename T>
struct Rational
{
T numerator;
T denominator;
bool operator==(const Rational& other) const
{
return numerator * other.denominator
== other.numerator * denominator;
}
};
Structural equality of the two fields would be incorrect: for example, 1/2
and 2/4 are different representatives of the same quotient value. The C++
equality operator therefore compares the representatives using the equivalence
relation declared by the Predica type.
The structure mappings described earlier are row-oriented: the attributes of
each value are stored together. A more specialised alternative is a
column-oriented representation, in which the values of each attribute are
stored together. For example, Supplier could be represented conceptually as:
struct SupplierColumns
{
std::vector<std::string> id;
std::vector<std::string> name;
std::vector<std::int64_t> status;
std::vector<std::string> city;
};
Entries at the same physical position belong to the same supplier. This position is an implementation-level row identifier and is not part of Predica semantics. A separate index can map the declared supplier key to its physical position.
Column-oriented storage is particularly useful for scans, projections, and aggregations because a query need load only the attributes it uses. Keeping values of the same type together improves cache locality, permits vectorised processing, and creates opportunities for dictionary encoding, run-length encoding, delta encoding, bit packing, and other specialised compression. Column pages can also record summary information such as minimum and maximum values, allowing a query to skip pages that cannot contain a result.
Column-oriented representations are particularly suitable for read-heavy analytical workloads, especially data warehousing, where queries scan many records but use only a subset of their attributes. They are generally less suitable for workloads dominated by individual record retrieval and frequent fine-grained updates. Updates can still be supported through techniques such as delta stores, batching, and hybrid representations.
A column-oriented representation need not be the mutable authoritative representation. It may instead be a read-only analytical replica derived from a transactional primary store. Committed changes can be propagated to it through a log, change-data-capture stream, or periodic batch. This allows the replica to use immutable, compressed column segments optimised for analytical queries.
Cardinality constraints can guide the column representation. An exactly-one attribute can use a dense array, an optional attribute can use a presence bitmap and an array of present values, and a many-valued attribute can use an offsets array together with a values array. Fixed-cardinality attributes can reserve a fixed number of entries for each value.
The disadvantages are that reconstructing a complete value requires gathering its attributes from several columns, while individual updates may require coordinated changes to several structures. A compiler can therefore choose row-oriented, column-oriented, or hybrid layouts for different types and workloads without changing the observable Predica semantics.
Serialisation of a value produces a stream of bytes representing its state. This can cause data that is non-contiguous in memory to become a contiguous byte sequence when it is turned into a blob: the serialised state of an object stored on disk. Deserialisation performs the reverse mapping, reconstructing an in-memory representation from the byte stream.
For example, an in-memory structure containing std::string, std::vector,
std::deque, std::map, std::unordered_map, std::set, std::unique_ptr,
or std::shared_ptr members can be traversed and serialised into a single
object on disk. The internal allocations, nodes, and pointers do not need to
retain their in-memory layout in the serialised representation. Serialising
into a blob can therefore also improve space efficiency: pointer addresses and
in-memory allocation overhead are not serialised, while necessary relationships
can use compact serialised references.
The ideal blob size is generally of the same order as the natural chunk size for the storage medium. This relationship is discussed in the chapter on chunk size.
Small object graphs can be serialized as blobs, while large collections can remain partitioned into independently loaded B-tree nodes.
This is very useful for performance because fine-grained random access to data on secondary storage is one of the largest performance costs in database systems. Reading or writing a contiguous blob can replace many small, scattered storage I/O operations with a much smaller number of larger I/O operations.
A persistent object store is a database that maps an object identifier, or OID, to a byte sequence:
OID → byte sequence
This is the conceptual interface. The physical location of the byte sequence is not part of the model and is not observable by clients. The store may move, copy, compact, compress, or replicate bytes without changing the OID.
An OID is part of the serialised state and remains stable across database server restarts. Ordinary memory addresses are meaningful only within a running process and are never persistent references.
A byte sequence need not be presented as a contiguous memory buffer. It may be available only through input and output streams. Conceptually, the POS provides operations such as:
InputStream read(Snapshot snapshot, OID oid);
OID create(Transaction transaction);
OutputStream write(Transaction transaction, OID oid);
void erase(Transaction transaction, OID oid);
read observes an immutable version of the byte sequence in a snapshot.
write creates a replacement version associated with a transaction; it does
not expose a mutable persistent memory address. A concrete API may use
callbacks, buffered streams, asynchronous streams, or scatter/gather I/O while
preserving this model.
The POS does not know whether a byte sequence represents a person, a B-tree node, an image, or an application log. It does not interpret object fields, schema declarations, embedded OIDs, or application-level version tags. Its responsibilities are naming byte sequences, preserving committed versions, and returning the correct bytes for a snapshot.
In particular, the following are serialisation-layer concerns rather than POS concerns:
Objects in the POS are normally coarser than objects or allocations in memory. One persistent byte sequence can encode a record containing scalar fields, several strings, optional values, and vectors. The strings and vectors do not need separate OIDs merely because they use separate allocations in an in-memory C++ representation.
struct Person
{
PersonId id;
string name;
optional<BirthEvent> birth;
vector<OID> marriage;
vector<OID> child;
};
A serialisation function writes the complete persistent representation of this
Person to an output stream. A deserialisation function reads the stream and
constructs whatever in-memory strings, vectors, and optional values are useful
to the running program.
void serialise(OutputStream& out, const Person& person);
Person deserialisePerson(InputStream& in);
The appropriate persistent granularity is a physical design choice. Very small values can be encoded together to reduce OID and I/O overhead. A large collection can instead use several persistent objects, such as a header and B-tree nodes, so that updating or reading one part does not require rewriting the entire collection.
The schema version is part of the application's serialised byte sequence. A deserialisation function reads that version before decoding the remaining fields:
Person deserialisePerson(InputStream& in)
{
SchemaVersion version = readSchemaVersion(in);
switch (version)
{
case 1: return readPersonV1AndUpgrade(in);
case 2: return readPersonV2(in);
default: throw UnsupportedSchemaVersion(version);
}
}
This permits software using a newer in-memory layout to read an older serialised representation and supply defaults, rename fields, or perform other schema-evolution steps. Rewriting the upgraded object is optional and occurs through an ordinary POS transaction.
The POS neither interprets nor validates this schema version. Three distinct notions of version must not be confused:
| Version | Meaning |
|---|---|
| Schema version | How application code decodes one byte sequence. |
| Object rendition | The bytes written for an OID by a committed update. |
| Mapping snapshot | A consistent selection of renditions for all OIDs. |
One database server process owns the POS files, caches, transaction state, and background maintenance. Other processes communicate with that server rather than opening the same persistent store directly.
Exclusive process ownership avoids competing caches and cross-process coordination of recovery, compaction, and garbage collection. Concurrency still exists among clients and transactions, but it is mediated by one storage runtime.
The POS is divided into partitions. An OID identifies, either directly or through an OID directory, the partition responsible for that byte sequence. Each partition owns its version index, cached state, and update queue.
A partition is associated with a mutex and an actor. Read requests can be served from immutable committed versions, while every mutation is submitted to the partition actor. The actor processes mutation messages serially, so there is at most one mutator of a partition's state at a time.
client mutations
↓
partition queue
↓
partition actor ── protected by partition mutex
↓
new committed versions
This avoids fine-grained locking within the partition's mutation logic. Different partition actors can make progress concurrently. Partitioning therefore provides both a concurrency boundary and a unit for caching, checkpointing, compaction, and recovery.
A natural implementation writes new renditions of byte sequences to an append-only log. Rewriting an OID appends new bytes; it does not overwrite the bytes of the preceding rendition:
transaction 100: OID 42 → bytes A transaction 130: OID 42 → bytes B transaction 170: OID 42 → bytes C
The old renditions remain physically present until the cleaner determines that no retained snapshot or recovery operation can use them. Append-only writing is therefore a useful physical basis for MVCC, but retaining old bytes is not by itself sufficient. The POS also needs a snapshot-consistent way to select one rendition for every OID and must prevent cleaning of selected renditions.
The POS maintains a persistent mapping from each OID to its selected byte sequence rendition:
OID → rendition
The map can be implemented as a persistent radix tree, B-tree, or another copy-on-write structure. Updating a small number of OIDs creates new versions of the affected map nodes and shares every unchanged subtree with the preceding map. Modified map nodes are appended to the log just like object renditions.
old map root
├── unchanged subtree ─────────┐
└── OID 42 → rendition A │
│ shared
new map root │
├── unchanged subtree ─────────┘
└── OID 42 → rendition B
The map and its internal references are POS metadata. They do not alter the
conceptual interface OID → byte sequence, and neither their structure nor
their physical positions are visible to the object layer.
A snapshot identifies an immutable version of the persistent OID map. A reader uses the same snapshot for every POS read in a logical operation, so every OID lookup is resolved through one consistent mapping even while partition actors publish newer map versions. The transaction-level model is described in Snapshot Isolation.
snapshot S100:
OID 42 → rendition A
OID 57 → rendition X
snapshot S130:
OID 42 → rendition B
OID 57 → rendition X
Writers append new renditions and construct a new map version without modifying the map visible to existing readers. Commit atomically publishes the new map state and its visibility sequence. A delete publishes a map containing a tombstone or no entry for that OID; older snapshots can continue to select the preceding rendition.
The POS detects the write conflicts required by snapshot isolation. For example, a transaction that attempts to replace an OID can fail if another transaction has committed a new rendition since the writer's snapshot.
Publishing a snapshot does not require rewriting a complete persistent map after every transaction. One implementation publishes a copy-on-write root at each commit. Another represents a transaction snapshot as a checkpoint root plus immutable committed deltas:
snapshot at transaction 145
=
OID-map root at checkpoint 100
+
committed deltas 101 ... 145
Recent deltas can be held in immutable maps, merged into levels, and consulted before the checkpoint map. A later checkpoint folds them into a new persistent root. Checkpointing is then a batching and recovery mechanism; it does not restrict readers to checkpoint-granularity snapshots.
Whether the implementation publishes roots, retains deltas, or combines both, a snapshot handle protects all map nodes, deltas, and byte-sequence renditions needed to resolve reads through that snapshot.
A partition actor is the sole publisher of new mapping states for its partition. Readers use immutable partition snapshots and therefore do not need to hold the mutation mutex while reading.
struct Snapshot
{
CommitSequence sequence;
vector<PartitionSnapshot> partition;
};
A transaction confined to one partition can commit through that partition's actor. A transaction spanning partitions requires database-level coordination so that the affected partition states acquire one atomic visibility outcome. The partition actors perform the local mutations; a transaction coordinator determines whether the collection of changes commits or aborts.
There are two distinct reachability problems.
Because the POS treats byte sequences as opaque, it cannot discover application object reachability by inspecting them. The serialisation layer knows which decoded fields are strong OID references and can trace from application-defined persistent roots. It supplies the resulting set of live OIDs, or equivalent incremental reachability information, to the storage runtime.
This separation permits tracing to handle cyclic object graphs without teaching the POS about application schemas. Indexes, caches, and other derived structures are normally weak: they help find an OID but do not make the corresponding application object reachable.
The POS traces or otherwise accounts for map nodes, delta layers, and object renditions reachable from every retained snapshot:
storage-live data =
reachable from the current map root
∪ reachable from active snapshot roots
∪ reachable from retained recovery roots
The cleaner may reclaim a rendition only when no retained mapping can select it. Likewise, it may reclaim an old map node only when no snapshot root or delta layer depends on it. A long-running reader can therefore delay reclamation without blocking writers.
An OID that is no longer live in the newest application state may still have a rendition selected by an older snapshot. Application deletion first publishes a new map state in which the OID is absent or tombstoned. Its earlier renditions become physically reclaimable only after the oldest relevant snapshot and recovery boundary have advanced beyond them.
Application retention rules remain above the POS. The reachability layer may continue to report an otherwise unreachable OID as retained for audit, historical queries, or another policy. From the POS perspective it is simply still live.
| Serialisation and object layer | Persistent object store |
|---|---|
| In-memory types and object granularity | Opaque byte sequences addressed by OID |
| Serialisation and deserialisation | Input and output stream access |
| Schema versions and evolution | MVCC versions and snapshot visibility |
| Interpretation of OID fields | Durable storage of those OID bytes |
| Reachability and retention policy | Safe reclamation of obsolete versions |
Snapshot isolation allows a logical operation to read a stable database state while other transactions publish newer states. At the start of the operation, the implementation obtains a snapshot identifying the committed state visible to that operation. Every subsequent database read made by the operation uses that same snapshot.
A snapshot is an implementation facility, not a value exposed in the Predica logical model. Predica expressions observe values and predicate extensions; they do not observe versions, object identifiers, map roots, or transaction sequence numbers.
All objects reached during one logical operation must be resolved through the same snapshot. This includes objects loaded only after navigation reaches them. Lazy loading must not cause a later object to be read from a newer committed state than an object loaded earlier in the same operation.
operation snapshot S100
load A → rendition visible at S100
navigate A.b
load B later → rendition visible at S100
This rule is particularly important for large object graphs such as
TiledImage. Loading one tile on demand does not advance the snapshot. If
another transaction replaces a tile while the image is being read, the reader
continues to observe the rendition selected by its existing snapshot.
A writing transaction reads from a committed base snapshot together with its own updates. Its intermediate changes are not visible to other operations. Commit validates the final transaction state and makes the transaction's changes visible as one new committed state. An operation that already holds an older snapshot continues to observe its existing state.
Snapshot isolation alone is not equivalent to serial execution: transactions that update disjoint objects can still make decisions from the same older snapshot. Predica avoids relying on snapshot isolation alone for transactional invariants. Updates are confined to a partition, and the partition's ownership or serialisation mechanism remains responsible for ensuring that accepted commits satisfy its constraints.
The versioned OID maps, write-conflict detection, checkpoint roots, and transaction deltas that can implement this behaviour belong to the persistent object store and are described in Persistent Object Store.
A materialised derived attribute is a cache of a logically derived value. A cached result may be reused only when it corresponds to the snapshot and input renditions from which it was derived. A result computed from a newer input must not be introduced into an older snapshot merely because it is already available in memory.
Implementations may key cached results by snapshot, by the versions of their dependencies, or by another scheme with the same effect. Publishing an update invalidates or supersedes affected cached results for later snapshots without altering results still reachable through retained snapshots.
A snapshot remains valid until its logical operation releases it. While it is retained, every value reachable through it must remain available. How the persistent object store retains and eventually reclaims the required physical versions is described in Persistent Object Store.
CEDA implements multiversion concurrency control across two layers. The
Log-Structured Store (LSS) stores opaque serial elements and provides stable
read snapshots. The Persistent Object Store (POS), implemented by
cxPersistStore, interprets those serial elements as persistent C++ objects
and provides snapshot-consistent resident objects.
The LSS is log structured: the log is the store, not merely a recovery journal. One writer serialises transactions into a total order. Each transaction appends its serial elements followed by a snapshot record marking the completed transaction. An unfinished transaction at the end of the log has no such record and is ignored during recovery.
The single writer removes the need for writer conflict detection, strict two-phase locking, deadlock recovery, or transaction rollback. Only one process opens an LSS file. Writes can be accumulated into complete segments, so committing a transaction need not imply flushing a partially filled segment.
The log is a daisy chain of fixed-size segments, commonly around 1 MB. A
single-threaded SegmentWriter appends serial elements one after another. A
serial element begins with a 13-byte header containing its 64-bit OID. A later
serial element with the same OID is a new rendition of that element.
A serial element may cross segment boundaries and is therefore represented by a chain of data packets. Serial elements can be very large, although that is not the common case. The LSS also writes RPM packets containing nodes of the recoverable packet map.
A packet's physical location consists of a segment index and an offset within that segment.
The recoverable packet map (RPM) maps each 64-bit OID to the physical location of its latest visible serial element. It is an eight-level radix map whose levels correspond to the eight bytes of an OID. An RPM node has up to 256 children.
Nodes at levels 1 through 7 contain physical locations of child RPM nodes.
Level-0 nodes contain physical locations of serial elements. The level-7 root
also records the next available OIDhigh, while level-3 nodes record the next
available OIDlow for their corresponding OIDhigh.
OID allocation is structured rather than random. The high 32 bits identify a
private allocation space containing up to 232 low OIDs. Operations
allocate new OIDhigh values and allocate OIDlow values within a selected
high space.
During an LSS checkpoint, dirty RPM nodes are appended to the log from the lowest dirty nodes upwards. Once their descendants are durable, the new root location is written to a distinguished area at the beginning of the file. A graceful close performs a checkpoint.
After an ungraceful close, recovery begins with the last checkpointed RPM root and replays the later log. Transaction snapshot records identify the complete transactions to apply and allow an incomplete tail transaction to be ignored.
Checkpoint frequency balances write amplification against recovery time. Writing checkpoints too frequently appends many dirty RPM nodes; delaying them leaves a longer log tail to scan during recovery.
A cleaner selects segments with low utilisation and copies their live packets to the end of the log. The relocated packets supersede their old physical copies, causing the source segment's utilisation eventually to reach zero.
Packets selected by any reader-pinned RPM root remain live even when a newer RPM root selects a later rendition of the same serial element. A source segment cannot be reclaimed while an active snapshot still resolves a packet to its old physical location.
Reusable segment identifiers are held in the Free Segment Stack (FSS). A segment after the last checkpoint cannot return to the FSS even when it has no live packets, because recovery may still need to scan that part of the log. Checkpointing advances this reclamation boundary and allows eligible segment identifiers to return to the FSS.
The in-memory RPM uses copy-on-write nodes. Published roots and their shared unchanged descendants form a DAG. A reader pins one published root and uses it for every serial-element read, while the writer continues changing a separate working view.
Publishing an RPM root for every writer transaction would impose unnecessary cost at transaction rates approaching one million per second. Roots are therefore published in batches or epochs. A reader begins with the latest valid published root; it does not force publication of a newer root. Allowing new readers to begin slightly stale lets many readers share the same snapshot and substantially reduces the number of retained RPM nodes.
Any number of readers may use a published root concurrently. Roots and nodes are reference counted. Starting a reader retains its root; ending the reader releases it. When a stale root's count reaches zero it is deleted, recursively decrementing its children. Nodes shared with another root remain alive, while unreachable stale nodes are automatically reclaimed from memory.
The LSS exposes the snapshot lifetime explicitly:
auto txn = lss.BeginReadTxn();
auto serialElement = lss.Read(txn, oid);
lss.EndReadTxn(txn);
BeginReadTxn() returns a handle that pins the selected RPM root. Every call
that reads a serial element takes that handle and resolves the OID through the
same root. EndReadTxn() releases the handle and may consequently release a
stale root and RPM nodes no longer reachable from any reader.
BeginWriteTxn() likewise returns a transaction handle that may be passed to
serial-element reads as well as writes. It gives the single writer a
consistent view containing the latest committed renditions together with the
transaction's own writes. The writer can therefore load an existing serial
element before replacing it without opening a separate read transaction.
The POS divides persistent objects into persistent spaces, or PSpaces. A
PSpace owns an OIDhigh and allocates its objects from the corresponding low
OID space. It is both an allocation domain and a concurrency domain.
One mutex protects all objects and POS metadata within a PSpace. POS operations normally lock only one PSpace at a time. An operation that exceptionally locks several PSpaces acquires them in a total order, making deadlock impossible without strict two-phase locking.
A transaction updates exactly one PSpace. Inter-PSpace OID references are not permitted, and constraints cannot span PSpaces. There is consequently no transactional invariant that requires an atomic commit over several PSpaces and no need for a multi-PSpace transaction protocol.
The mutex protects the mutable frontier of the PSpace. Immutable ROT nodes and immutable resident object renditions may be read without holding it. This keeps mutation simple without preventing concurrent access to published snapshots.
This design proposes a corresponding restriction on Predica's logical model: each persistent object has exactly one owning location within its PSpace. Other relationships do not become additional owners merely because they provide another way to reach the object. For example, a supplier may own its shipments while a part has a non-owning relationship to the shipments in which it occurs.
One possible surface syntax is an owns annotation on the owning relationship:
entity Shipment
{
Supplier supplier;
Part part;
}
entity Supplier
{
owns object Shipment shipment* where shipment.supplier = this;
}
entity Part
{
Shipment shipment* where shipment.part = this;
}
The two shipment attributes retain the same predicate and navigation
semantics, but only Supplier.shipment owns its members. Deleting a supplier
therefore deletes its shipments, whereas the relationship from a part does not
give the part ownership of them. Changing a shipment's supplier transfers its
ownership.
Updates within the PSpace must preserve referential integrity. Deleting an owner deletes its owned objects, and every non-owning relationship to those objects is derived or removed by the same transaction. It cannot be left dangling. Because OID references do not cross PSpace boundaries, this rule can be enforced under the one PSpace mutex and checked at its transaction boundary.
Deleting a shipment likewise removes it from both relationship extensions. The shipment identifies the supplier and part that refer to it, so the compiler can update any physical inverse indexes directly rather than searching the PSpace. These changes form part of the same transaction as the deletion.
Unique physical ownership avoids persistent reference counts for ordinary object lifetime. The proposed logical restriction concerns ownership, not reachability: several logical paths may still reach the same entity, but only one of them is owning. Reference counting used by the MVCC ROT and RPM DAGs is a separate, in-memory mechanism for retaining shared immutable versions while readers use them. The proposal is recorded here without yet imposing the restriction on the rest of this specification.
Each PSpace has one mutable ROT root used by writers holding the PSpace mutex.
That root owns the LSS transaction handle returned by BeginWriteTxn(). A
cache miss is resolved through that handle, so objects loaded for reading or
writing belong to the writer's consistent LSS view and are the latest versions
available to the PSpace.
ROT nodes private to the mutable writer view are marked dirty and may be changed in place while the mutex is held. A clean node may also be reachable from a published reader root and is immutable. On the first update beneath such a node, the writer copies the node and changes the private copy. Only the path affected by the update is copied; all unchanged subtrees remain shared.
Each PSpace has a Resident Object Table (ROT) mapping an OID to the resident C++ object rendition for that snapshot. Within one snapshot it supplies the canonical C++ object for an OID. The ROT is an in-memory copy-on-write radix structure with published snapshot roots, analogous to the RPM.
Readers pin snapshot ROT roots. Shared ROT nodes remain alive across snapshots, and reference counting recursively deletes stale nodes and releases resident object renditions after their last reader completes.
Each snapshot ROT root owns an LSS read-transaction handle. That handle pins the corresponding RPM root. A ROT cache hit therefore returns an object from the correct object snapshot, while a cache miss or lazy load resolves the persistent object through the same LSS snapshot. Releasing the last reference to the ROT root releases its LSS transaction handle and allows the two snapshot structures to be reclaimed coherently.
Creating a new PSpace snapshot requires the PSpace mutex. While holding it, the implementation freezes the mutable ROT root and the resident C++ object renditions reachable from that view. They become immutable and can be published to readers. The implementation then creates a fresh mutable writer root that initially shares the frozen nodes and objects.
Subsequent writers use copy-on-write for resident objects as well as ROT nodes. Reading an immutable object does not copy it. The first attempt to mutate it clones that object rendition and changes the writer ROT path to refer to the clone. Consequently, a writer may read millions of resident objects and change one while cloning only the changed object and the affected ROT path.
Freezing stable object renditions allows their serialisation and the writing of dirty objects to proceed after the PSpace mutex has been released. Physical I/O therefore need not extend the critical section. Buffered serialisation into the LSS segment cache may further separate snapshot publication from device writes.
Snapshot publication has a tunable cadence. Publishing too often increases copy-on-write work, reference-count traffic, and the number of simultaneously retained roots and object versions. Publishing too infrequently means that a new reader begins from an older view. Readers take the most recently published snapshot rather than forcing the writer to create one, so many readers can share a root.
The genealogy example in the Event Sourcing chapter contains a graph of people and events. A marriage event can occur under both spouses, an event refers back to its spouses, and people refer to parents and children.
This chapter applies the persistent object store described in the preceding chapter. Persistent entity references are encoded as OIDs. The POS maps each OID to an opaque byte sequence, and genealogy deserialisation functions reconstruct the corresponding in-memory object. The representation therefore does not require compiler-placed ownership arenas or serialised memory pointers.
The following structures illustrate one possible lowering of the logical schema. They describe serialised records rather than a required C++ ABI:
using OID = uint64_t;
struct GenealogySystemRecord
{
// Person.id is the logical lookup key; the OID names its serialised record.
btree<PersonId, OID> person;
};
struct PersonRecord
{
PersonId id;
string name;
Gender gender;
optional<BirthEventRecord> birth;
optional<DeathEventRecord> death;
vector<OID> marriage;
vector<OID> divorce;
vector<OID> child;
};
struct BirthEventRecord
{
DateTime datetime;
optional<string> location;
optional<OID> mother;
optional<OID> father;
};
struct DeathEventRecord
{
DateTime datetime;
optional<string> location;
};
struct MarriageEventRecord
{
array<OID, 2> spouse;
DateTime datetime;
optional<string> location;
};
struct DivorceEventRecord
{
array<OID, 2> spouse;
DateTime datetime;
};
GenealogySystem.person* is represented by a B-tree because Person.id is its
declared key. The B-tree maps that logical key to the OID of a person record.
The POS stores the serialised PersonRecord bytes under that OID. This
separation permits the storage engine to reorganise those bytes without changing
the B-tree key or any OIDs stored elsewhere.
Birth and death events are optional and belong to only one person in this schema, so they can be encoded inline in the person record. Mothers, fathers, children, marriages, divorces, and spouses are references to persistent entities and are encoded as OIDs.
The child, marriage, and divorce collections are vectors because they are normally small and are commonly traversed in full. Although a vector has a physical order, the corresponding Predica attributes are set-valued. The implementation must prevent duplicate OIDs, and insertion order has no logical meaning. A sorted vector, B-tree, or another collection can be substituted for an unusual workload without changing the schema.
An OID is not a declared Predica key. The two have different purposes:
| Identifier | Purpose |
|---|---|
{spouse, datetime} |
Logical uniqueness and lookup of a relationship event. |
OID |
Stable physical reference to a stored object. |
A physical index can map a canonical relationship-event key to an event OID.
Because spouse[2] is set-valued, the two spouse OIDs must first be placed in a
canonical order. The pairs {Alice, Bob} and {Bob, Alice} must generate the
same index key.
struct RelationshipEventKey
{
array<OID, 2> spouse; // Ascending OID order
DateTime datetime;
RelationshipEventKey(OID a, OID b, DateTime dt)
: spouse{min(a, b), max(a, b)}, datetime(dt)
{
assert(a != b);
}
friend bool operator==(const RelationshipEventKey& a,
const RelationshipEventKey& b)
{
return tie(a.spouse, a.datetime) == tie(b.spouse, b.datetime);
}
friend bool operator<(const RelationshipEventKey& a,
const RelationshipEventKey& b)
{
return tie(a.spouse, a.datetime) < tie(b.spouse, b.datetime);
}
};
The assertion represents the cardinality requirement that the spouse set contain two distinct people. Production code reports a constraint violation instead of relying on an assertion. Equality and ordering compare the same canonical fields, so the ordering is suitable for a map or B-tree index.
Alice and Bob can both store the OID of one marriage event:
Alice.marriage ──┐
├── OID 8341 ──→ MarriageEventRecord
Bob.marriage ────┘
The serialised payload is stored once. There is no persistent shared_ptr, and the
compiler does not have to choose an arena owned by Alice, Bob, or their
GenealogySystem. The POS stores one byte sequence under the event OID, while
the two person records contain ordinary serialisable OIDs.
The object store is a physical mechanism. Its storing all marriage records
does not make GenealogySystem.marriage* a logical base population.
GenealogySystem.marriage* remains derived by navigating the people and
eliminating duplicate event OIDs. A physical index over relationship events is
likewise an access structure rather than an independent source of logical
facts.
Sharing and symmetric membership are separate requirements. The existing
Person.marriage constraint says that the containing person is one of the
event's spouses, but it does not require the other spouse to contain the same
event OID. An additional constraint, a derived inverse, or an atomic update
operation is needed to maintain that symmetry.
Once a PersonRecord has been loaded, the representation exposes several useful
directions of navigation directly:
child contains the OIDs of the person's children;birth.mother and birth.father contain the OIDs of both parents;marriage contains the OIDs of marriage events in which the person
participates;divorce contains the OIDs of divorce events in which the person
participates; andThe vectors are normally small, so navigation commonly consists of reading one person record, iterating a short vector, and resolving its OIDs through the POS snapshot. The strings, optional birth and death events, and the vectors themselves are decoded with the person rather than requiring a separate lookup for every in-memory allocation.
Current and previous spouses are reached through the same event graph. Previous
spouses are obtained by following marriage-event OIDs and selecting the other
spouse recorded by each event. The current spouse is determined by ordering the
person's marriage and divorce events by datetime and interpreting the last
relationship event. The physical vector order is irrelevant; the ordering is
the logical event-time order declared by the query.
Person ├── child[] ──────────────────────────────→ Person ├── birth.mother ─────────────────────────→ Person ├── birth.father ─────────────────────────→ Person └── marriage[] / divorce[] ──→ Event ──→ spouse[2] ──→ Person
These directions compose. A sibling path follows a parent OID and then that parent's child vector. A grandparent path follows a parent and then another parent. Cousins, descendants, in-laws, and other derived relationships are built from the same small set of typed navigation steps. The compiler therefore begins with known routes through the data rather than having to rediscover every relationship during query planning.
Efficient navigation in both parent-to-child and child-to-parent directions is not free. The child vector and the parent OIDs in the birth event must describe the same relationship, and updates must preserve that constraint. The additional direction consumes storage and update work in the same way that an additional index does. Its benefit is that both common directions become explicit, type-checked access paths.
In a complex information system it can be entirely appropriate for an entity type to declare one hundred or more attributes. Predica does not treat a wide entity as evidence that its facts should be distributed among unrelated top-level populations. The usual design principle is to place each fact in the most specific domain to which it belongs.
Many such attributes are scalars, optional values, small strings, or OIDs. A serialised entity containing a hundred of them can still be only a few kilobytes, well within the useful I/O granularity discussed in the chapter on chunk size. Reading the entity then obtains a substantial amount of related information with one object-store read and one deserialisation operation.
A wide entity can also act as a navigation hub. Its OID-valued attributes and small collections expose typed routes to many other entities:
parent
↑
supplier ← account ← Customer → address → region
↓
order[] / contact[]
The compiler knows the type and cardinality of every outgoing route. A query can read the hub once and then follow only the references it needs. Several paths that begin at the same entity can reuse the already deserialised record.
This does not mean that every attribute should always be encoded in one persistent object. A large document, an unbounded collection, or infrequently used data with very different access characteristics may deserve its own OID. The physical boundary should reflect encoded size, update frequency, and access locality rather than an automatic objection to a large number of logical attributes.
This representation is graph-shaped, but it is not a generic property-graph
representation made from untyped vertices, edges, labels, and dynamic property
maps. The compiler knows the record type at each OID-valued attribute, the field
offset after deserialisation, and the cardinality of the relationship. A path
such as person.birth.mother does not need to search a generic edge-label index
or interpret a property value dynamically.
Nor does a Predica path require the implementation to execute one pointer-like step at a time. Typed OID navigation is attractive for a selective query that visits a small neighbourhood. A query that processes most of the population may instead be compiled into batched OID reads, sequential scans, vectorised operations, or set-oriented joins.
| Workload | Likely useful execution style |
|---|---|
| One person's parents, children, or spouses | Direct local navigation and OID resolution |
| A selective multi-hop relationship query | Typed navigation combined with indexes and filters |
| Whole-population or iterative graph analysis | Bulk scans, batching, vectorisation, and set-oriented operations |
The performance claim is therefore not that graph databases are inherently faster than relational databases. It is that a typed, chunk-local representation avoids unnecessary reconstruction for common entity-centred navigation while retaining enough logical information for the compiler to choose a bulk execution strategy when the workload calls for one.
A conventional relational representation is likely to use relations for people,
births, relationship events, event spouses, and possibly parent-child links.
Navigating several generations or roles then requires many joins. The same
Person relation must be renamed for each role, such as person, mother, maternal
grandmother, father, child, sibling, and spouse.
For example, finding a person's siblings can require joining a person to a birth
record, joining the recorded parents to other birth records, and joining those
records back to differently renamed occurrences of Person. Grandparents and
cousins add further occurrences and join paths. Indexes can make each individual
lookup efficient, but the relational optimiser must still select access methods,
choose join orders, estimate intermediate cardinalities, and recognise when
different aliases represent repeated navigation through the same relationships.
The contrast becomes stronger in a large, fully normalised, unnested schema. Facts that are local attributes in Predica can be spread across many relations. Partial information can require additional optional-fact relations rather than one locally encoded presence marker. Reconstructing one conceptual entity may therefore require many indexed lookups and joins before navigation to another entity even begins.
For workloads centred on selective entity access and local navigation, a direct physical mapping of that schema often has worse locality than reading a chunk-sized hub object and following its OIDs. A relational implementation can close or reverse the gap with clustered storage, covering indexes, materialised views, column groups, bulk columnar execution, or deliberate denormalisation. Those are valid physical optimisations; several work by recovering or materialising locality that the Predica type structure already makes explicit, while bulk execution can be intrinsically better for scans over much of the population.
The object representation does not make every genealogy query constant-time. Recursive ancestor and descendant queries can visit large parts of the graph, OID resolution can require I/O, and indexes or cached derived relationships may still be valuable. Its advantage is that common relationship directions are already embodied by typed OID references and local collections. Query implementation can follow those routes directly or batch them into set-oriented work, leaving the optimiser to concentrate on genuinely set-valued choices, filtering, ordering, cardinality, and I/O planning.
The database catalog contains the OID of each named GenealogySystemRecord.
That record reaches person records through its B-tree, people reach other
people and relationship events through their OID fields, and relationship
events refer back to their spouses. The genealogy object layer traces this graph
using the record-layout metadata generated by the compiler and reports live OIDs
to the POS.
database catalog
↓
GenealogySystemRecord
↓
PersonRecord
↙ ↘
Person RelationshipEvent
The cycles between people and relationship events do not require special ownership rules. If an entire genealogy system becomes unreachable from the catalog, tracing can identify its unreachable component. If an individual event becomes unreachable, the object layer can remove its weak index entries and mark its OID dead. The POS can reclaim its obsolete byte-sequence versions after no transaction snapshot can still observe them.
Event retention remains an application policy. Audit or temporal requirements may keep committed birth, death, marriage, and divorce events even after ordinary application references disappear. Reachability determines which objects could be collected; the retention policy determines which historical events may actually be deleted.
For this schema the compiler generates serialisation and deserialisation functions for the records above, schema-version handling, tracing metadata for their OID fields, comparison code for the declared keys, and navigation code that reads referenced byte sequences and reconstructs objects. The POS supplies OID allocation, stream access, partition actors, transactions, snapshot visibility, and safe reclamation of obsolete versions. Neither component needs to infer a person-owned, event-owned, or system-owned arena.
Predica schemas describe types, attributes, cardinalities, keys, and constraints. They do not say that every entity must become a separately allocated object or that every relationship must become a memory pointer. A triangulated mesh illustrates why that distinction matters.
Mesh algorithms commonly process millions of vertices and triangles. A representation made from one heap allocation per vertex, triangle, and edge would have substantial allocation overhead and poor cache locality. A Predica compiler is free to use dense arrays and compact integer indices instead.
value Point3
{
Float32 x;
Float32 y;
Float32 z;
}
entity Vertex
{
Integer id;
Point3 position;
key {id};
}
entity Triangle
{
Integer id;
List<Vertex> vertex;
key {id};
constraints
{
vertex.length = 3;
}
}
entity Mesh
{
Vertex vertex*;
Triangle triangle*;
}
The schema says that every triangle has an ordered list of exactly three vertices and that vertices and triangles have logical keys. The list order defines the triangle orientation needed by normal and half-edge algorithms. The schema does not prescribe OIDs, pointers, heap allocation, a half-edge structure, or a particular order of triangles in memory.
For a mesh stored and processed as one unit, the compiler can assign each vertex a dense slot number. A triangle then stores three 32-bit slot numbers:
using VertexIndex = uint32_t;
struct Point3f
{
float x;
float y;
float z;
};
struct MeshData
{
vector<Point3f> position;
vector<array<VertexIndex, 3>> triangleVertex;
};
position
0 ── (x₀, y₀, z₀)
1 ── (x₁, y₁, z₁)
2 ── (x₂, y₂, z₂)
3 ── (x₃, y₃, z₃)
triangleVertex
0 ── [0, 1, 2]
1 ── [2, 1, 3]
The VertexIndex values are not persistent pointers. They are compact local
indices interpreted relative to the containing mesh representation. If
Vertex.id values are already dense they can equal the slot numbers.
Otherwise, loading or constructing the mesh creates a map from logical vertex
keys to dense slots. Serialisation can record the logical keys alongside the
positions when they must remain externally visible.
A mesh of up to roughly four billion vertices can use 32-bit indices even when the surrounding persistent object store uses 64-bit OIDs. One OID can name the coarser serialised mesh object; its internal strings, vectors, vertices, and triangles are encoded inside that object's byte sequence.
Some algorithms use only one coordinate or stream coordinates into vector or
GPU operations. The same logical Point3 values can be lowered into a
structure-of-arrays representation:
struct MeshData
{
vector<float> positionX;
vector<float> positionY;
vector<float> positionZ;
vector<VertexIndex> triangleVertex0;
vector<VertexIndex> triangleVertex1;
vector<VertexIndex> triangleVertex2;
};
Array-of-structures and structure-of-arrays layouts implement the same Predica schema. The choice depends on vectorisation, GPU transfer, compression, and which fields an algorithm reads together.
Computing a normal for every triangle is a sequential pass over the triangle arrays. For each triangle the implementation performs three indexed loads from the position arrays:
for (auto v : mesh.triangleVertex)
{
Point3f a = mesh.position[v[0]];
Point3f b = mesh.position[v[1]];
Point3f c = mesh.position[v[2]];
normal.push_back(cross(b - a, c - a));
}
There are no heap objects to traverse and no edge labels to interpret.
Triangles are read sequentially, vertex records are compact, and adjacent
triangles often reuse positions already present in a processor cache. The
logical path mesh.triangle.vertex.position has been compiled into array and
index operations.
The triangle representation directly supports triangle-to-vertex navigation. Algorithms that repeatedly navigate from a vertex to its incident triangles benefit from an additional physical index. A compressed sparse row representation stores all incident triangle indices in one array:
struct VertexTriangleAdjacency
{
// Incident triangles for vertex v occupy
// triangle[first[v] ... first[v + 1]).
vector<uint32_t> first;
vector<uint32_t> triangle;
};
first: [0, 1, 3, 5, 6] triangle: [0, 0, 1, 0, 1, 1] vertex 2 incident triangles = triangle[3 ... 5) = [0, 1]
This index is analogous to materialising an inverse relationship. It consumes storage and must be rebuilt or maintained when topology changes, but it permits compact sequential access without allocating a vector object for every vertex. It is derived physical state rather than another source of logical facts.
Algorithms that walk around faces or across neighbouring faces often use a
half-edge representation. That representation also need not contain pointers.
With three half-edges per triangle, a half-edge can be identified by
3 * triangleIndex + corner:
using HalfEdgeIndex = uint32_t;
const HalfEdgeIndex noHalfEdge = UINT32_MAX;
struct HalfEdgeData
{
vector<VertexIndex> origin;
vector<HalfEdgeIndex> opposite;
};
The next half-edge around a triangle is calculated arithmetically. The
opposite array gives the half-edge across the same undirected edge, or
noHalfEdge at a boundary. Face, corner, origin, next, and opposite navigation
therefore use integer arithmetic and indexed array loads.
This demonstrates an important physical-independence property. The logical schema contains vertices and triangles, while a particular algorithm may use a generated half-edge working representation. The compiler or library can build it transiently, cache it as derived state, or serialise it when the workload justifies the update cost.
A moderate mesh can be one persistent object containing its vectors. Reading its OID returns one byte sequence from which the dense arrays are deserialised. This is much coarser than assigning an OID to every vertex and triangle.
A mesh too large to read or rewrite as one object can be divided into spatial tiles or fixed-size blocks. The top-level mesh record then contains OIDs for those blocks, while vertices and triangles inside each block still use compact local indices. Boundary tables translate the comparatively small number of cross-block references.
Mesh OID ├── block OID 1 → dense local arrays ├── block OID 2 → dense local arrays └── block OID 3 → dense local arrays
The persistent boundary is chosen from I/O size, update locality, and the algorithms applied to the mesh. It is not determined by the number of entity values in the logical schema.
A generic object-graph implementation might allocate every vertex and triangle
separately and store three pointers in each triangle. A direct mapping into an
unnested relational schema might use Vertex, Triangle, and
TriangleVertex relations and reconstruct triangle vertices with joins. Both
are possible implementations of the logical information, but neither is the
natural high-performance representation for typical mesh algorithms.
Dense arrays avoid allocation headers, improve cache density, support vectorisation, and make topology suitable for GPU transfer. They also show why Predica navigation should not be equated with pointer chasing: a typed path can compile into field access, integer-indexed arrays, a B-tree lookup, an OID resolution, a set-oriented operation, or another representation appropriate to the type and workload.
The Predica annotator is a source-to-source analysis tool. It loads Predica files, resolves and type-checks them in their component context, and writes annotated Predica files that preserve the same source-tree structure. The annotated files are still Predica source files; generated annotations are comments inserted next to the source items they explain.
This chapter describes the annotator, not any particular command-line interface. The interface used to choose input components, include roots, output folders, or diagnostic policy is outside this chapter.
Given a set of loaded Predica source files, the annotator produces a corresponding set of output files with the same relative paths and filenames. Authors write ordinary Predica source, not a special annotation-only form. The ordinary source text, comments, declaration order, formatting, and folder layout are preserved; generated annotation comments are the differences.
An annotation is attached to the source item whose denotation it describes by placing one or more line comments immediately before that item. The indentation of the comment follows the indentation of the annotated item. Long annotations wrap over several line comments.
// area=(W * H):Real
// where
// this.width(W) ∧ this.height(H)
→ Real area = width * height;
When the annotator has no current translation for a particular source item, it preserves the copied source file and attaches a local skipped annotation with the reason. Unsupported denotations are visible at the source item they explain.
// denotation skipped: no denotation rule for fold prototype
→ Real prototypeStatus = supplier.status.Σprototype;
The annotator is not limited to top-level query examples. It annotates Predica code in general wherever a useful source item has a value, logic, or location denotation. This includes:
constraints blocks and constraints attached to attributes;The presentation varies with the source item. A query expression is annotated with its value denotation. A constraint is annotated with the closed formula that the constraint contributes to schema validity. An assignment is annotated by relating its target-location denotation to the right-hand value denotation.
For a constraint expression C declared on a type T, the annotator uses the
same constraint rule as the query-frame semantics. It translates C in an
environment where this denotes the constrained value of type T. If the
translation produces one Boolean result term B, the annotation is the closed
formula:
∀vars(free(γ_C)) ( formula(γ_C) → B )
This is a universal formula over the values reached while evaluating the
constraint. A constraint such as age ≥ 18 on Person does not mean that
there exists an age greater than or equal to 18; it means every age value of the
person satisfies the predicate.
entity Person
{
Integer age;
constraints
{
// ∀A:Integer (this.age(A) → A ≥ 18)
age ≥ 18;
}
}
Boolean-valued path expressions are handled the same way. The path contributes the predicates needed to reach the Boolean value, and the Boolean value becomes the consequent of the implication.
entity FirearmLicence
{
Boolean valid;
}
entity FirearmPurchase
{
FirearmLicence licence;
constraints
{
// ∀V:Boolean ∀L:FirearmLicence (
// this.licence(L) ∧ L.valid(V) → V)
licence.valid;
}
}
The names chosen for generated logic variables are not semantically significant. Alpha-equivalent annotations express the same constraint.
A derived attribute is a member whose value is computed from an expression rather than stored as a base attribute. The same treatment applies to any derived variable declaration whose right-hand side has a value denotation.
For a derived member a on type T, the right-hand expression is translated
with this:T in scope. If the expression produces one result, the annotation
is relabelled with the declared member name and declared type of a. This
makes the comment describe the source declaration being annotated, not merely
the anonymous result of the right-hand expression.
value Rectangle
{
Real width;
Real height;
// area=(W * H):Real
// where
// this.width(W) ∧ this.height(H)
→ Real area = width * height;
}
Derived path declarations expose the path formula that defines the derived member.
entity Course {}
entity Enrolment
{
Student student;
Course course;
}
entity Student
{
Enrolment enrolment* where enrolment.student = this;
// enrolled=C:Course
// where
// ∃E:Enrolment (this.enrolment(E) ∧ E.course(C))
→ Course enrolled* = enrolment.course;
}
When a derived declaration is inherited, specialised, or produced by a generic instantiation, the default annotation remains close to the source declaration the user wrote. Debug views show instantiated forms, but the ordinary annotation of a generic source declaration is parametric rather than a list of unrelated monomorphic copies.
The annotator also explains program statements. For an assignment, the left-hand side is interpreted as a location expression and the right-hand side as a value expression, both in the statement pre-state. The annotation makes both denotations visible enough to explain what is being written.
// target:
// status=AttrLoc(S, Supplier.status):Integer
// where s.supplier(S) ∧ S.city("London")
// value:
// 5:Integer
s.supplier.[city = "London"].status := 5;
The target part is a location denotation: it identifies the selected attribute slots, not the value currently stored in those slots. The value part is the ordinary value denotation of the right-hand expression. For compound update operators, the annotation also indicates the read-modify-write expression being applied when that is not obvious from the source operator.
Annotations are denotations in logic. The annotator may simplify expressions and formulas, remove redundant quantifiers, rename generated variables, use compact names, wrap lines, and pretty-print.
Generated annotations preserve ordinary fold abstraction. Named folds such as
Σsum, Σcount, Σmean, and user-defined folds such as Σmedian render as
fold applications over their input comprehension, for example
Σcount({ ... }):Integer.
The resolved standard logical folds std.exists, std.empty, std.all, and
std.any render as logical formulas in every annotation context. Boolean value
annotations may therefore be formulas.
For an input comprehension
C = { V:T for F where Ψ }
the logical readings before simplification are:
C.Σexists ↦ ∃vars(F) Ψ
C.Σempty ↦ ¬∃vars(F) Ψ
C.Σall ↦ ∀vars(F) (Ψ → V) // V:Boolean
C.Σany ↦ ∃vars(F) (Ψ ∧ V) // V:Boolean
The final comment is rendered after simplification and pretty-printing.
For example, C.Σempty may render as ∀vars(F) ¬Ψ, which is equivalent to
¬∃vars(F) Ψ.
All other folds use Σop(C) fold-application rendering.
// total=Σsum({ S:Integer where this.status(S) }):Integer
→ Integer total = status.Σsum;
// ∀S:Integer (this.status(S) → S > 0)
status.(this > 0).Σall;
Although Predica treats entities as pure values whose identity is defined solely by their keys, a real implementation will typically store these values in efficient data structures such as hash maps, B-trees, or red-black trees. These structures index entity values by their keys so that expressions like:
..supplier[id=1]
can be resolved efficiently.
When a system literal is evaluated, the implementation can be viewed as proceeding in two conceptual phases:
supplier*, part*) are constructed first
and indexed by their keys.
..supplier[id=1] are then evaluated by performing a lookup in the
corresponding index. If the key is present, the implementation returns the actual entity value
stored in the set. If the key is missing, for example:
Shipment { supplier = ..supplier[id=9], part = ..part[id=5], qty = 400 }
If that expression is evaluated, the implementation reports an error because no entity with that
key exists in the system literal.
These internal indices necessarily assign implementation-level identifiers or handles to entity values. However, these identifiers are not part of Predica’s semantics:
They exist solely to support efficient lookup and value sharing. Predica’s semantics remain purely value-based, with identity determined only by keys.
The compiler optimiser can and will use the statically defined cardinality constraints. An important one is an exactly one constraint because the compiler doesn't need to use a set. This is important because many attributes are exactly one.
entity Supplier
{
Integer id;
String name;
Integer status;
String city;
key {id};
}
Every attribute has exactly one value. This means the compiler knows, statically that id, name, status and city
are scalars.
There is no need to represent these as sets or collections internally.
This is a huge optimisation lever.
Derived attributes are logical predicates whose values are defined by folds over event histories. Whether these values are materialised, cached, or computed on demand is an implementation detail outside the type system.
The logical layer defines:
This layer is pure meaning. It says what is true, not how it is stored.
The physical layer (backend implementation), if it even exists may:
For a given device, the product of latency and sustained bandwidth gives an idea of the size in bytes of contiguous chunks which are read or written where transfer time starts to be dominated by bandwidth rather than latency.
| Device / Link | Latency (approx) | Bandwidth (approx) | Latency × Bandwidth (“natural chunk”) |
|---|---|---|---|
| L1 cache | 1 ns | 1 TB/s | ~1 KB |
| L2 cache | 3 ns | 500 GB/s | ~1.5 KB |
| L3 cache | 10 ns | 200 GB/s | ~2 KB |
| Main memory (DRAM) | 80 ns | 50 GB/s | ~4 KB |
| NVMe SSD | 100 µs | 3 GB/s | ~300 KB |
| SATA SSD | 100 µs | 500 MB/s | ~50 KB |
| HDD | 10 ms | 200 MB/s | ~2 MB |
| 1 GbE LAN | 0.1 ms | 125 MB/s | ~12.5 KB |
| 10 GbE LAN | 0.1 ms | 1.25 GB/s | ~125 KB |
| Typical WAN | 50 ms | 50 Mb/s | ~300 KB |
This chapter explains how typed path navigation and the algebraic properties of Predica’s operators enable systematic query optimisation. Much ordinary navigation can be compiled directly. Where a query combines set-valued paths, many apparently distinct operations (such as selection and constraint application) can be expressed as ordinary joins. That part of the query can be treated as a join graph and reordered using associativity and commutativity to minimise intermediate cardinalities.
A compiler works with path expressions directly to implement queries; it does not need to translate them into logic frames. The logic denotation provides a precise specification of what each path expression means, ensuring that users and compiler writers share the same definition of the language.
For example, mycircle.centre.x can be implemented as simple navigation
through the attributes of nested data structures: first from
mycircle to its centre, and then from that value to its
x attribute. The compiler can analyse and optimise this navigation
directly. Its implementation is correct when it preserves the meaning given by
the logic denotation in the language specification.
In relational DBMSs such as PostgreSQL, a query states which results are required without normally prescribing a physical route between tables. When a query joins several tables, the optimiser may need to choose the join order, select among nested-loop, hash, and merge joins, and decide whether each input should be obtained through an index or a scan. These decisions depend on statistical estimates of cardinality and selectivity. The number of possible plans grows rapidly with the number of joined relations, so a substantial part of relational query optimisation consists of searching for a sufficiently inexpensive execution plan.
In Predica, a typed path often supplies a natural route through the model. An
expression such as mycircle.centre.x already describes navigation from
one nested value to another. The compiler does not need to rediscover that
route by considering alternative join trees: it can follow the
centre attribute and then the x attribute, much like
compiling ordinary field access in a programming language. Cost-based choices
remain important where paths are multi-valued, inverse, indexed, or combined
with constraints, but ordinary structural navigation requires much less
planning.
The mapping from a Predica schema into its physical representation tends to dominate how query implementation is understood. That mapping will often naturally mirror the nested types and local relationships already present in the logical model. The resulting implementation is made from structures such as objects, fields, maps, collections, and pointers, so a path expression is normally understood as navigation through those structures.
For a simple example, suppose a circle and its centre are represented by nested structures:
struct PointFloat32
{
float x;
float y;
};
struct CircleFloat32
{
PointFloat32 centre;
float radius;
};
Here mycircle.centre.x maps directly onto two field accesses. The
layout has already embodied the route expressed by the path, so there is no
choice of join order or join algorithm to recover during query planning.
Now suppose the same values are mapped directly onto unnested relations:
Circle(circle_id, centre_id, radius)
Point(point_id, x, y)
Obtaining the same x value requires matching
Circle.centre_id with Point.point_id. With more levels of
nesting, each level can introduce another relation and another join. The
relational implementation must then decide how to perform those joins and
whether to use indexes or scans. If a query combines several such paths, it may
also have to choose their evaluation order using estimates of selectivity and
intermediate cardinality.
The familiar suppliers, parts, and shipments example shows the difference more clearly. In the relational model it is natural to begin with three relations, with the shipment relation containing supplier and part keys. Predica best practice instead pushes the relationships down into the types that expose them:
entity Supplier
{
Integer id;
Shipment shipment* where shipment.supplier = this;
key {id};
}
entity Part
{
Integer id;
Color color;
Shipment shipment* where shipment.part = this;
key {id};
}
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier, part};
}
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
→ Shipment shipment* = either supplier.shipment, part.shipment;
}
A natural representation gives the shipments system maps of suppliers and parts. Each supplier and part has a local collection of pointers to shipments, and the pointers from both sides can refer to the same shared shipment object. The shipment in turn refers to its supplier and part. The implementation is therefore more naturally pictured as a typed graph than as three flat tables.
For example, the path
s.supplier.[id = 17].shipment.part.color suggests the following
execution directly:
supplier-map lookup
→ supplier's local shipment collection
→ shared shipment object
→ part pointer
→ color attribute
The path through the data has already been organised by the Predica schema and preserved by its representation. The compiler need not begin with a mental picture of three n-ary relations and then discover a three-way join plan. Pushing relationships down is therefore not merely a surface-syntax convenience: it gives queries direct, typed routes through the model.
An important relational-model argument must nevertheless be taken seriously. An n-ary relation does not privilege one direction of navigation. Given the relations
Supplier(supplier_id, ...)
Part(part_id, ...)
Shipment(supplier_id, part_id, quantity)
a query can proceed from suppliers through shipments to parts, from parts through shipments to suppliers, or directly from shipments to either endpoint. Indexes and join plans can be chosen independently of this symmetric logical representation. This is a genuine strength of the relational model.
If Predica physically stored the same information only as the following tree, the traditional criticism of hierarchical databases would apply:
Supplier
└── Shipment
└── Part
Finding shipments by part would then go against the privileged direction. It might require scanning all suppliers, duplicating data under a second hierarchy, or reorganising the database. A change in the required access direction could consequently force changes to both the physical schema and the programs that traverse it.
The direct representation suggested by the Predica model above is not such a tree. It is a typed graph with useful navigation in several directions:
ShipmentsSystem
├── suppliers: map<SupplierId, Supplier>
├── parts: map<PartId, Part>
│
├── Supplier ──shipment[]──┐
│ ▼
│ Shipment
│ ▲
└── Part ─────shipment[]───┘
Shipment ──supplier──→ Supplier
Shipment ──part──────→ Part
One shared shipment object can appear in the local shipment collections of both its supplier and its part. The following paths are consequently all natural:
supplier.shipment.part
part.shipment.supplier
shipment.supplier
shipment.part
The local collections under Supplier and Part are, in
physical terms, comparable to maintaining indexes on both
Shipment.supplier and Shipment.part. Predica is therefore
not claiming that one arbitrary hierarchy makes every direction efficient. It
allows the logical model and its direct representation to expose several typed
directions over the same entities.
Supporting these directions is not free. If a system contains one hundred
million shipments, efficient navigation in both directions requires physical
structures corresponding to both
supplier → shipments and part → shipments. A relational
implementation might use two B-tree indexes. A direct Predica implementation
might use two local collections of pointers. Either approach consumes storage
and adds work when shipments are inserted, removed, or changed.
If only Supplier.shipment has an efficient representation, then
Part.shipment cannot also be efficient by virtue of the path syntax
alone. It must be supported by another index or local collection, calculated as
an inverse mapping, found by a scan, or obtained using some other planned
operation. Predica does not eliminate the physical-design problem; it gives
common access paths a natural typed expression and, usually, a direct
representation.
The two local shipment collections must not be treated as independent logical
facts or as copied shipment records. They are constrained or derived views of
the same shared Shipment entities. There remains one logical fact that
a shipment has a particular supplier and part. Materialising navigation from
both endpoints is analogous to constructing several indexes over one relation:
it provides several access paths without introducing several independent
sources of truth.
Predica’s physical-independence claim is that a query specifies logical navigation without specifying the mechanism that implements each step. For example,
part.shipment.supplier
does not say whether part.shipment is represented by a vector of
pointers, a B-tree, a persistent index, a computed inverse, a cached derived
collection, or a relational join. Similarly, a reference from a shipment to a
part could be a memory pointer, an object identifier, a smart pointer that
loads an object on demand, or a foreign-key lookup. The representation can
change without changing the path or its meaning.
This is physical independence, but it is not physical neutrality and does not promise automatic efficiency. A path can remain valid while performing poorly until an appropriate representation or index is supplied. No representation can make every possible access pattern equally efficient without paying the cost of maintaining the corresponding access structures.
Query optimisation is especially pressing when an implementation maps a nested model onto unnested relations. Navigation already present in the source path must then be reconstructed by a join plan. When the physical layout preserves the nesting and graph relationships, ordinary navigation maps onto structural access and requires fewer planning decisions. More generally, the closer the physical representation is to the navigational shape of common queries, the less of that structure an optimiser must recover.
Predica does not require the physical representation to preserve this nesting. If the supplier model is stored in ordinary unnested SQL tables, the compiler or database backend must reconstruct the same path using joins and inherits the associated planning problem. The logic denotation remains the same in either implementation; it specifies the result, while the representation determines how naturally the source path maps onto an execution route.
Predica’s claims about resilience to schema changes do not follow from nesting alone. Several other properties of the language contribute:
Supplier is logically identified as an entity in the
Supplier domain. Queries need not use an exposed surrogate
identifier as the identity of the supplier.shipment.part denotes a Part; the query need not
explicitly equate Shipment.part_id with
Part.id.
For example, changing the representation of
ShipmentsSystem.supplier from a vector to a B-tree does not change
queries. Adding an index for Part.color need not change the path
s.part.[color = red]. These are physical changes behind stable
logical expressions.
A genuine logical change can still affect queries. Removing or changing the meaning of an attribute cannot be hidden by physical independence. Changing a singular relationship into a many-valued relationship may also invalidate assumptions made by existing expressions. Predica reduces the propagation of incidental changes to keys, cardinality declarations, and representations; it does not make semantic changes disappear.
| Relational model | Predica |
|---|---|
| Relations do not privilege a navigation direction. | Named paths make logical navigation explicit, and a typed graph can expose several directions. |
| Physical indexes support selected directions. | Maps, local collections, inverse mappings, and indexes support selected directions. |
| Joins reconstruct relationships during execution. | Direct representations often embody relationships as references. |
| Queries remain unchanged when indexes change. | Paths remain unchanged when collection and reference representations change. |
| The optimiser chooses among global relational plans. | The compiler usually follows typed paths, with planning at set-valued, inverse, filtered, or indexed steps. |
Preserving nesting therefore does not eliminate optimisation. An attribute or
relationship implemented by a B-tree is comparable to a lookup in a
std::map: an access structure is involved, but that alone does not
create the global planning problem associated with joining many tables. More
substantial choices arise when a path traverses a multi-valued or inverse
relationship, when both an index and a scan are available, or when constraints
and set-valued paths can be evaluated in different orders.
In those cases the optimiser may select access paths, push restrictions earlier, estimate candidate-set sizes, and reorder genuinely set-valued operations. For example, when a path reaches a collection of orders and then filters their items, the compiler may have to choose between scanning the collection and using an index on an item attribute. If two filters apply, it can first apply the one expected to leave the smaller candidate set. A Predica optimiser can therefore be thought of primarily as a typed path optimiser, with relational-style planning used where the structure of a query actually calls for it.
None of this weakens Predica’s grounding in logic. Its logic applies uniformly
to user-defined types such as Circle and Point, to
variants, and even to the attributes of enum values, which are predicates. The
logic denotation fixes the meaning of all these expressions; it does not
prescribe that they be implemented by materialising relations or executing
table joins. Predica’s direct-mapping claim is that the logical type structure
usually has a simple and useful implementation, not that this implementation
is the only possible one or is optimal for every workload.
In Predica, selection is not a primitive operator. Instead, it is expressed as
join with a constraint tuple. For example, the selection
“attribute a must include value v” is represented by the
single-valued constraint tuple { a ↦ {v} }, and selection is:
σa=v(t) = t ⊓ { a ↦ {v} }.
More generally, any finite conjunction of attribute-value constraints can be
represented as a tuple, and applying those constraints is just another join.
This means that selections, filters, and constraints are all instances of the
same operator ⊓.
Because join ⊓ is commutative, associative, and idempotent, any query
built from joins and selections can be viewed as a set of joins over a
collection of base relations and constraint tuples. The optimiser is therefore
free to:
Since join never creates Cartesian products (it is a true meet, not “product + filter”), the optimiser does not need special rules to avoid explosive intermediate results. All joins are structurally safe, and the main goal is to reduce cardinalities as early as possible.
Projection πA is domain restriction and distributes over both
join and generalised union:
πA(t₁ ⊓ t₂) = πA(t₁) ⊓ πA(t₂), πA(t₁ ⊔ t₂) = πA(t₁) ⊔ πA(t₂).
This allows the optimiser to push projections down as well, trimming attributes early to reduce the width of intermediate tuples. Combined with early constraint joins, this yields smaller, narrower intermediate relations, which is exactly what a cost-based optimiser wants.
The expand operator ! is primarily semantic, but its algebraic
properties are still relevant for optimisation. Since expand commutes with join,
generalised union, and projection:
(t₁ ⊓ t₂)! = t₁! ⊓ t₂!, (t₁ ⊔ t₂)! = t₁! ⊔ t₂!, πA(t!) = (πA(t))!,
the optimiser can reason about queries either in the MVA domain or in the expanded “possible worlds” domain, knowing that the two views are consistent. In practice, expand is usually avoided during execution because it increases cardinality, but its laws justify algebraic rewrites and equivalence checking.
Minus − removes possibilities inside tuples rather than removing whole
tuples. It is monotone in its first argument and antitone in its second, and
distributes over both join and generalised union:
(t₁ − t₂) ⊓ (t₁ − t₃) = t₁ − (t₂ ⊔ t₃), (t₁ − t₂) ⊔ (t₁ − t₃) = t₁ − (t₂ ⊓ t₃).
These properties allow the optimiser to factor and reorder minus operations,
pushing them closer to the data sources where they can prune possibilities
early. Because Predica retains conflicting information (e.g.
{red} ⊔ {blue} = {red, blue}), minus can be used to remove specific
conflicting possibilities without discarding entire tuples, giving a more
fine-grained form of conflict-aware pruning than classical relational
difference.
Finally, the expand operator provides a semantic notion of equivalence: two relations are information-equivalent exactly when their expansions coincide:
r₁! = r₂!.
This gives a solid foundation for algebraic rewrite rules: any transformation
the optimiser applies must preserve !-equivalence. Since all core
operators are monotone and commute appropriately with expand, the usual
optimisation rewrites (join reordering, selection pushdown, projection
pushdown, minus factoring) are sound with respect to this semantic criterion.
A reference implementation of the Predica language is being developed in C++. It is a work in progress. Development began in April 2026.
The initial emphasis is on developing a Turing-complete language in which databases can be created or opened and can be the targets of updates and queries. Predica supports defining types and functions, writing statement-based code, and using control-flow constructs such as loops. The question of how to use Predica from other host languages will be addressed in the future.
The lexer and parser support the complete language described by the grammar chapters and convert Predica source files into ASTs at about 80 MB/sec, or about 4 million lines per second.
The lexer, AST, and parser operate on a std::string_view containing
one complete source file. The source is a contiguous immutable byte buffer and
remains alive while a parsed result is in use. Tokens and AST nodes can
therefore identify text by byte offset and length without allocating strings.
SourceFiles owns loaded source buffers at stable addresses. Adding
another file does not invalidate an earlier file, its path, its text, or its
file ID. A file is opened in binary mode at its end, its size is obtained with
tellg(), and its string is allocated once before a direct read.
Source loading deliberately requires an ordinary seekable file and detects a
file which grows or shrinks during the read.
Human-readable positions are needed when a diagnostic is displayed, not for
every token and AST node. A loaded source file eagerly records the 32-bit byte
offset of each line start. Given an error offset,
std::upper_bound performs a binary search for the containing line;
the byte column is then a subtraction from that line start. This keeps tokens
compact and avoids updating line and column state in the lexer hot path.
The lexer tracks its current byte offset, so it returns the representation which the parser will retain directly:
enum class TokenKind : std::uint8_t
{
EndOfFile,
Identifier,
Keyword,
BooleanLiteral,
IntegerLiteral,
RealLiteral,
StringLiteral,
Operator,
Punctuation
};
struct Token
{
std::uint32_t begin;
std::uint32_t size;
TokenKind kind;
};
static_assert(sizeof(Token) == 12);
Earlier versions returned a TokenKind and
std::string_view, then converted each token into a separate
12-byte stored form. The current representation removes that conversion and
halves the size of tokens retained in a vector from 24 to 12 bytes. Token text
is reconstructed temporarily from the source only when the parser needs to
inspect its spelling. The EOF token has an empty range beginning at
source.size().
Before tokenisation, the token vector reserves capacity estimated from the source size. A parser workspace retains that vector between files, so later parses clear its logical contents without releasing its allocation. Compact tokens reduce both the final vector size and the copying performed if an estimate is exceeded.
In the current benchmark, a reserved vector and a reused vector sustain about 125 MB/s and 127 MB/s respectively, compared with about 129 MB/s when tokens are consumed immediately without retaining them. Token storage is therefore a small part of total lexer time. The earliest implementation measured about 61 MB/s when retaining tokens and 77 MB/s when consuming them immediately, so the retained-token path is now approximately twice as fast.
These measurements use approximately 10 MB of source on the development x86-64 Linux laptop, compiled by GCC 13.3.0 in release mode. Laptop temperature causes visible variation, so the figures describe sustained ranges rather than portable absolute limits.
ASCII punctuation and operators are selected by direct first-byte dispatch. Identifiers are scanned with simple ASCII range tests, and keywords are found by binary search in a sorted table. Less common Unicode operators are matched against a compact table, with longer prefix-sharing spellings first. Once a known Unicode spelling has matched, its bytes are not validated a second time. Arbitrary non-ASCII input in comments and string literals is still fully validated, including rejection of overlong UTF-8, surrogate encodings, and code points outside the Unicode range.
The implementation follows programming by contract: public interfaces state strong preconditions and postconditions, while the implementation maintains explicit invariants. For example, source loading and the public parser establish that a file fits the 32-bit source-offset representation. The lexer and token buffer can therefore narrow offsets directly. Assertions check the contract and invariants in debug builds without adding repeated branches to release hot paths.
This is deliberately different from applying Postel's law within the compiler pipeline. Silently accepting malformed inputs or defensively accommodating states that preceding layers promised not to produce would weaken the contracts, obscure programming errors, and cost performance. Invalid source text is, of course, reported as a normal diagnostic; a violated internal contract is instead treated as an implementation defect.
Expressions, type expressions, statements, and declarations are held in four separate contiguous vectors. Recursive relationships use named aliases for 32-bit integer indexes rather than pointers to separately allocated nodes. They are deliberately aliases rather than integer wrapper classes: their names document their meaning while allowing direct, uncluttered vector indexing.
using SourceOffset = std::uint32_t;
using ExpressionIndex = std::uint32_t;
using TypeExpressionIndex = std::uint32_t;
using StatementIndex = std::uint32_t;
using DeclarationIndex = std::uint32_t;
using HeapIndex = std::uint32_t;
struct HeapSlice
{
HeapIndex begin;
std::uint32_t count;
};
using ExpressionIndexSlice = HeapSlice;
using TypeExpressionIndexSlice = HeapSlice;
using StatementIndexSlice = HeapSlice;
using DeclarationIndexSlice = HeapSlice;
Slice aliases similarly state what the words in a slice mean without adding a new representation or traversal abstraction. Indexes remain valid when vectors move or reallocate, and a complete parsed file is relocatable without rewriting links between nodes. Later compiler passes can store semantic facts in separate arrays addressed by the same indexes, leaving the syntax tree compact and effectively immutable.
The principal node types each occupy 16 bytes. A node is a compact tagged
record whose kind determines the interpretation of
detail, data0, and data1. A binary
expression, for example, stores its operator in detail and the
indexes of its operands in data0 and data1.
struct Expression
{
SourceOffset source;
ExpressionKind kind;
std::uint8_t detail;
std::uint16_t reserved;
std::uint32_t data0;
std::uint32_t data1;
};
static_assert(sizeof(Expression) == 16);
The Heap class owns one contiguous vector of 32-bit words. The
parser appends variable-length arrays and less common records to this vector.
A HeapIndex identifies a record. A HeapSlice
identifies the first word of an array and records its number of elements.
Records are copied with memcpy, avoiding alignment,
object-lifetime, and aliasing problems which would result from casting the
word storage to arbitrary record types. Clearing the heap removes its contents
but retains the vector's capacity for the next file.
class Heap
{
public:
template<class T> HeapIndex append(const T& value);
template<class T> HeapSlice appendArray(const std::vector<T>& values);
template<class T> T read(HeapIndex index) const;
void clear() noexcept;
private:
std::vector<std::uint32_t> words_;
};
struct AstArena
{
std::vector<Expression> expressions;
std::vector<TypeExpression> typeExpressions;
std::vector<Statement> statements;
std::vector<Declaration> declarations;
Heap heap;
};
A node normally stores a 32-bit byte offset rather than a pointer, line and column, or complete source range. Source-backed text is represented by an offset and byte count. The parsed result aliases its immutable source buffer; it does not own or copy that buffer.
struct ParsedSourceFile
{
std::string_view source;
AstArena arena;
SourceFile root;
};
Returning a large allocation-owning result by value can avoid copying through move elision, but it cannot return the result's vector capacity to the next parse. The public API therefore takes both reusable temporary storage and the output by reference:
void parseSourceFile(
std::string_view source,
ParserWorkspace& workspace,
ast::ParsedSourceFile& output);
ParserWorkspace is opaque in the public header and retains compact
token storage. The output owns its AST arena. Before parsing, the five arena
vectors are cleared without releasing capacity. A compiler can keep one
workspace per parsing thread and reuse an output sequentially, while retaining
independent outputs for files whose ASTs must remain available.
The public-API benchmark measures both patterns. Creating a workspace and output for every parse sustains about 66 MB/s. Reusing the token buffer and all five AST vector allocations sustains about 78 MB/s, an improvement of approximately 17%. This difference is why the performance test goes through the public API rather than constructing private parser machinery.
The parser is a hand-written recursive-descent parser with separate functions for declarations, statements, type expressions, and expression precedence levels. It tokenises the complete file once, advances through the contiguous token vector by index, appends completed nodes directly to the output arena, and returns typed 32-bit indexes. There is no pointer-rich intermediate syntax tree and no per-node allocation.
class Parser
{
public:
Parser(
std::string_view source,
ast::AstArena& arena,
std::vector<Token>& tokenStorage);
ast::SourceFile parseSourceFile();
private:
ast::ExpressionIndex expression();
ast::TypeExpressionIndex typeExpression();
ast::StatementIndex statement();
ast::DeclarationIndex declaration();
TokenBuffer tokens_;
std::size_t current_ = 0;
ArenaBuilder arena_;
};
The token buffer appends a second EOF token as a sentinel. Parser lookahead can then inspect one token beyond the ordinary EOF position without a bounds test. For example, qualified-name and procedure-call recognition can examine the token after a trailing dot safely. The parser asserts that ordinary advancement never consumes EOF, making the sentinel an internal invariant rather than error recovery.
Every AST belongs to one source file, not to an entire multi-file component. The source-size limit therefore bounds typed node counts and slice element counts for that file; debug builds assert those relationships when nodes are appended.
The heap can expand by more than one word per source byte, so its final size
still requires a release-build check. Checking every record and array append
reduced parser throughput by 2–4%. Instead, heap indexes are not
dereferenced during construction, and the four node-vector sizes and heap size
are validated once after parsing and before the root is published. If a size
cannot be represented, parsing throws a structured ParseError.
Separately retained old and new benchmark executables were run in alternating order to control for laptop temperature and scheduling. End-of-parse validation measured about 0.7% faster than the baseline in both fresh and reused modes, a difference small enough to treat as performance-neutral.
enum class ParseErrorKind : std::uint8_t
{
ExpectedSpelling,
ExpectedSyntax,
IntegerLiteralTooLarge,
...
};
enum class ParseExpectation : std::uint8_t
{
Unspecified,
Expression,
Declaration,
...
};
enum class ParseExpectedSpelling : std::uint8_t
{
Unspecified,
LeftBrace,
RightBrace,
...
};
class ParseError : public std::exception
{
public:
ParseError(
ParseErrorKind kind,
std::uint32_t offset,
ParseExpectation expectation = ParseExpectation::Unspecified,
ParseExpectedSpelling expectedSpelling =
ParseExpectedSpelling::Unspecified);
const char* what() const noexcept override;
ParseErrorKind kind() const noexcept;
std::uint32_t offset() const noexcept;
ParseExpectation expectation() const noexcept;
ParseExpectedSpelling expectedSpelling() const noexcept;
private:
std::uint32_t offset_;
ParseErrorKind kind_;
ParseExpectation expectation_;
ParseExpectedSpelling expectedSpelling_;
};
Lexer, parser, and source-loading exceptions store compact structured facts:
an error kind, byte offset, and only the additional enum or byte required by
that kind. Formatting is performed later by writeErrorMessage and
the source diagnostic renderer. This keeps string construction out of parsing
and, in particular, out of the sites that throw errors. It also tends to make
the binary code for the hot parser paths more compact by keeping formatting
machinery out of them, which can improve use of the CPU instruction caches.
The structured representation also supports future localisation and allows
the compiler to combine the error with a filename, line, byte column, source
line, and caret.