VoTa logo v0.1

A language for defining types based on fully decomposed RM

Part I: Foundations

This part introduces the philosophical and foundational principles that underpin the Predica language.

1 Introduction to Predica

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.

Decomposition into domains with unary predicates

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:

  1. Predica: domain = subject
  2. Other literature: domain = type or carrier

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.

Example: Circle

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.

Derived attributes

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.

Cardinality constraints

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.

Recursive decomposition

Predica supports two parallel forms of recursive decomposition:

  1. Decomposition in the world, where domains decompose into subdomains; and
  2. Decomposition in the model, where types decompose into attribute types.

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.

Domains that exist in time and space

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};

Why distinguish entities from values?

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.

It's like applying the RM to many domains

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:

What the RM separates into relvars and attributes, Predica unifies as named attributes/predicates within domains.

Correspondence between query expressions and logic formulas

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.

Qualified predicate names

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.

2 Entity Types as Logical Sorts

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:

  1. Informally, 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.
  2. Formally, 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.

3 Cardinality Constraints and 1NF

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.

The 1NF distinction in Predica

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.

4 Supplier-and-Parts Database

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.

S (suppliers)
S1,Smith,20,London S2,Jones,10,Paris S3,Blake,30,Paris S4,Clark,20,London S5,Adams,30,Athens
P (parts)
P1,Nut,Red,12.0,London P2,Bolt,Green,17.0,Paris P3,Screw,Blue,17.0,Oslo P4,Screw,Red,14.0,London P5,Cam,Blue,12.0,Paris P6,Cog,Red,19.0,London
SP (shipments)
S1,P1,300 S1,P2,200 S1,P3,400 S1,P4,200 S1,P5,100 S1,P6,100 S2,P1,300 S2,P2,400 S3,P2,200 S4,P2,200 S4,P4,300 S4,P5,400

Schema in Tutorial D versus Predica

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
VAR S BASE RELATION
{
    S#     CHAR,
    SNAME  CHAR,
    STATUS INTEGER,
    CITY   CHAR
}
KEY { S# };


VAR P BASE RELATION
{
    P#     CHAR,
    PNAME  CHAR,
    COLOR  CHAR,
    WEIGHT RATIONAL,
    CITY   CHAR
}
KEY { P# };


VAR SP BASE RELATION
{
    S#   CHAR,
    P#   CHAR,
    QTY  INTEGER
}
KEY { S#, P# };


CONSTRAINT SP_S_FK
    SP { S# } ⊆ S { S# };

CONSTRAINT SP_P_FK
    SP { P# } ⊆ P { P# };
entity Supplier
{
    String id;
    String name;
    Integer status;
    String city;
    Shipment shipment* where shipment.supplier = this;
    key {id};
}

entity Part
{
    String 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;
}

// Database variable of type ShipmentsSystem
var s = openDatabase<ShipmentsSystem>("/path/to/database");

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
  • Keys are declared on the global relvars.
  • Referential constraints between relvars are declared separately.
  • Keys are declared within entity types.
  • Shipment.supplier and Shipment.part are typed references.
  • The where clauses define Supplier.shipment and Part.shipment as the inverse relationships.
  • ShipmentsSystem.shipment is derived from those local relationships.

Example queries

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.

Suppliers located in London

Description Return the names of suppliers located in London.
Tutorial D
S WHERE CITY = 'London' { SNAME }
Predica
s.supplier.[city = "London"].name
Logical denotation
name=N:String
where
  ∃S:Supplier ∃C:String
    s.supplier(S)
  ∧ S.city(C)
  ∧ C = "London"
  ∧ S.name(N)

Parts supplied by supplier S1

Description Return the names of parts supplied by the supplier whose identifier is S1.
Tutorial D
( ( S WHERE S# = 'S1' ) JOIN SP JOIN P ) { PNAME }
Predica
s.supplier.[id = "S1"].shipment.part.name
Logical denotation
name=N:String
where
  ∃S:Supplier ∃I:String ∃SP:Shipment ∃P:Part
    s.supplier(S)
  ∧ S.id(I)
  ∧ I = "S1"
  ∧ S.shipment(SP)
  ∧ SP.part(P)
  ∧ P.name(N)

Shipments with a quantity greater than 300

Description Return the supplier identifier, part identifier, and quantity for shipments whose quantity is greater than 300.
Tutorial D
( SP WHERE QTY > 300 ) { S#, P#, QTY }
Predica
s.shipment.[qty > 300].
    (supplier.id as sid, part.id as pid, qty)
Logical denotation
sid=SI:String, pid=PI:String, qty=Q:Integer
where
  ∃SP:Shipment ∃S:Supplier ∃P:Part
    s.shipment(SP)
  ∧ SP.qty(Q)
  ∧ Q > 300
  ∧ SP.supplier(S)
  ∧ S.id(SI)
  ∧ SP.part(P)
  ∧ P.id(PI)

Suppliers of part P2

Description Return the names of suppliers that supply the part whose identifier is P2.
Tutorial D
( ( P WHERE P# = 'P2' ) JOIN SP JOIN S ) { SNAME }
Predica
s.part.[id = "P2"].shipment.supplier.name
Logical denotation
name=N:String
where
  ∃P:Part ∃I:String ∃SP:Shipment ∃S:Supplier
    s.part(P)
  ∧ P.id(I)
  ∧ I = "P2"
  ∧ P.shipment(SP)
  ∧ SP.supplier(S)
  ∧ S.name(N)

Suppliers who supply all red parts

Description Return the suppliers that supply every red part.
Tutorial D
S WHERE NOT EXISTS (
    (P WHERE COLOR = 'Red')
    WHERE NOT EXISTS (
        SP WHERE SP.S# = S.S#
           AND SP.P# = P.P#
    )
)
Predica
s.part.[color = Color.red]⇒(shipment.supplier)
Logical denotation
supplier=S:Supplier
where
  ∀P:Part
    (s.part(P) ∧ P.color(Color.red))
    → ∃SP:Shipment
      P.shipment(SP)
      ∧ SP.supplier(S)

Suppliers for which all shipped parts are red

Description Return the suppliers for which every part they ship is red.
Tutorial D
S WHERE NOT EXISTS (
    (SP JOIN (P WHERE COLOR <> 'Red'))
    WHERE SP.S# = S.S#
)
Predica
s.supplier.[shipment.part.color.Σ ⊆ {Color.red}]
Logical denotation
supplier=S:Supplier
where
  s.supplier(S)
  ∧ { C:Color | ∃SP:Shipment ∃P:Part
      S.shipment(SP)
      ∧ SP.part(P)
      ∧ P.color(C) } ⊆ {Color.red}

Supplier city, part city, and shipment quantity

Description Return the supplier city, part city, and quantity for every shipment.
Tutorial D
( SP JOIN S JOIN P ) { SCITY := S.CITY, PCITY := P.CITY, QTY }
Predica
s.shipment.(supplier.city as scity, part.city as pcity, qty)
Logical denotation
scity=SC:String, pcity=PC:String, qty=Q:Integer
where
  ∃SP:Shipment ∃S:Supplier ∃P:Part
    s.shipment(SP)
  ∧ SP.supplier(S)
  ∧ S.city(SC)
  ∧ SP.part(P)
  ∧ P.city(PC)
  ∧ SP.qty(Q)

Queries with unary predicates

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.

Relationship to graph queries

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.

5 Nesting the Relational Model

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.

The conventional RM is unnested

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:

S1,Smith,20,London S2,Jones,10,Paris S3,Blake,30,Paris S4,Clark,20,London S5,Adams,30,Athens

Predica : nesting the RM

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:

Predica decomposition of Supplier

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

S1   Smith   20   London

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
S1   Smith   20   London
S2   Jones   10   Paris
S3   Blake   30   Paris
S4   Clark   20   London
S5   Adams   30   Athens

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.

6 Cardinality

Attribute cardinality

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
S1   Smith Smith Ltd   20   London Oxford
S2   Jones     Paris Marseille Montpellier Le Mans

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:

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

VAR S BASE RELATION
{ 
    S#     CHAR,
    SNAME  CHAR,
    STATUS INTEGER,
    CITY   CHAR 
}
KEY { S# };

-- Supplier S# exists
VAR S BASE RELATION
{
    S#  CHAR
}
KEY { S# };

-- Supplier S# has NAME
VAR S_NAME BASE RELATION
{
    S#   CHAR,
    NAME CHAR
}
KEY { S#, NAME };

CONSTRAINT S_NAME_FK
    S_NAME { S# } ⊆ S { S# };

CONSTRAINT S_NAME_CARDINALITY
    IS_EMPTY (
        S
        MINUS
        ( ( S_NAME GROUP ( NAME AS NAMES ) )
            WHERE COUNT(NAMES) BETWEEN 1 AND 2 ) { S# }
    );

-- Supplier S# has status STATUS
VAR S_STATUS BASE RELATION
{
    S#     CHAR,
    STATUS INTEGER
}
KEY { S# };

CONSTRAINT S_STATUS_FK
    S_STATUS { S# } ⊆ S { S# };

-- Supplier S# is located in CITY
VAR S_CITY BASE RELATION
{
    S#   CHAR,
    CITY CHAR
}
KEY { S#, CITY };

CONSTRAINT S_CITY_FK
    S_CITY { S# } ⊆ S { S# };

CONSTRAINT S_CITY_MIN_1
    IS_EMPTY (
        S
        MINUS
        S_CITY { S# }
    );    

Partial information

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:

The 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.

Example

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.

Zaniolo’s interpretation of partial information

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.

The 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.

Difference from SQL NULL

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.

7 Identity

Example where the RM needs artificial surrogate keys

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

variant Shape
{
    { Real radius } sphere;
    { Real side } cube;
    { Real radius, Real height } cone;
}
        

TYPE Sphere POSSREP { radius RATIONAL };
TYPE Cube   POSSREP { side   RATIONAL };
TYPE Cone   POSSREP { radius RATIONAL, height RATIONAL };

TYPE Shape UNION { Sphere, Cube, Cone };
        

enum Color
{
    red, green, blue
}
        

TYPE Color POSSREP { tag CHAR }
CONSTRAINT ColorConstraint
    tag IN { 'red', 'green', 'blue' };
        

entity Object
{
    Shape shape;
    Color color+;
}
        

TYPE ObjectId POSSREP { id INTEGER };

VAR Object :
    RELATION {
        ObjectId : ObjectId,
        Shape    : Shape
    }
KEY { ObjectId };

VAR ObjectColors :
    RELATION {
        ObjectId : ObjectId,
        Color   : Color
    }
KEY { ObjectId, Color };

-- Inclusion dependencies
CONSTRAINT ColorRefersToObject
    ObjectColors.ObjectId ⊆ Object.ObjectId;

CONSTRAINT EveryObjectHasColor
    Object.ObjectId ⊆ ObjectColors.ObjectId;
        

entity Box
{
    Object object*;
}
        

-- No two objects may have the same
-- shape and the same set of colors
CONSTRAINT NoDuplicateObjects
    IS_EMPTY (
        (Object JOIN Object AS O2)
        WHERE
            Object.ObjectId <> O2.ObjectId
        AND Object.Shape = O2.Shape
        AND
            (ObjectColors WHERE ObjectId = Object.ObjectId)
            =
            (ObjectColors WHERE ObjectId = O2.ObjectId)
    );
        

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.

8 Boolean-valued Attributes

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

Boolean attribute with relaxed cardinality constraint

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*;
}

9 Types

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.

Structural types

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.

Value types

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.

Entity types

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.

Semantic distinction: entity occurrences can be shared

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.

Another possible distinction when value types have keys

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

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.

Type expressions

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)>

Type aliases

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

Subtyping is subset inclusion:


A is a subtype of B iff A ⊆ B

The subtype relation gives rise to a type graph.

Abstract types and nominal subtyping

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.

Refinement types

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 and comparability

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.

Static typing and cardinality

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.

10 Database Values and Consistency

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.

Consistency as type membership

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 and correctness

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.

Consistency and satisfiability

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.

Transactions preserve consistency

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.

Local and global constraints

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.

11 Closed World Assumption

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.

12 Relvar versus attribute predicates

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)
⇔
There exists a supplier identified by supplier number 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)
⇔
The supplier has id I
name(N)
⇔
The supplier has name N
status(S)
⇔
The supplier has status S
city(C)
⇔
The supplier is located in city C

and in the ShipmentsSystem there is a predicate for existence of a supplier:

supplier(S)
⇔
The shipments system has a supplier S

13 Metadata annotations

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.

14 Pushing relationships down into the entities

Schema which aligns closely with the RM

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:

Predica supports this, but it does not require it.

Pushing relationships down into the entities

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.

Why this works so well

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:

Shared ownership emerges naturally

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.

The global relationship becomes derived, not stored


→ 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.

Emphasising local relationships in queries

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.

15 The Information Principle

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.

Predica's information principle

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.

No hidden identifiers

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 entity types are not OO objects

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.

16 The Atomicity Paradox

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:

  1. information inside structured values to be recursively decomposed through attributes down to simple types such as Boolean, Integer, Real, and String
  2. multiplicity to be represented by attribute extensions, not by collection-valued attribute values
  3. every value that appears in the database to be a value of some attribute
  4. every attribute to be a unary predicate
  5. attributes to record their extensions

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.

17 The First Great Blunder

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.

Does Predica commit the first great blunder?

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:

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.

18 Design Principles and Features

This chapter summarises some of the design principles behind Predica and the language features that follow from them.

Design Principles

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.

Logical denotation

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.

Additional principles

Together, these principles explain why Predica's type, query, and constraint constructs share the same predicate-based interpretation.

Features

Predica offers:

19 Benefits of a Decomposition into Domains with Unary Predicates

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:

Part II: Path Expressions

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.

21 Example Schema

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.

22 Navigation

The University database

Let u be a program variable of type University. It can be thought of as the database being queried.


University u;

The aim for the chapters in this part is to explain how to write read-only queries on u. The emphasis will be on using examples to illustrate how it works, without delving into the formal denotational semantics.

Path expressions

A path expression is an expression which can appear in Predica programs and in queries:


u.student.name
u.course.[advanced].code
u.course.(code, title)
u.student.name as studentName
u.course.session.time.(day, startMinute)
u.course.let c = this in c.prerequisite.code
u.course.assessment.exam.durationMinutes.Σsum
u.course.[advanced]⇒session.time.day

Path expressions are analogous to relational algebra expressions used to express queries on a relational database.

In our examples path expressions start at u then typically follow attributes through the schema.

Frames

Here we give an informal description of frames and explain the notation used to write them. They underpin the semantics of Predica.

Consider the following set builder notation for the points on the unit circle:


{ (X,Y) ∈ ℝ² | X² + Y² = 1 }

In an analogous way we can use a set builder notation to denote a relation with two attributes code and title which represents a query on the university database:


{ { (code=X:String), (title=T:String) } 
  where ∃C:Course 
        u.course(C) ∧ C.code(X) ∧ C.title(T) }

This is an expression of the form {R where Ψ}.

In this case we are ranging over the variables in R. That is not always the case, the more general form is written {R for F where Ψ}. The following illustrates use of for in a set builder notation:


{ r(X,Y) for X:A,Y:B where p(X,Y) }

In the formal denotational semantics a path expression denotes a frame (R,F,Ψ), i.e. the three components of a set builder notation of an n-ary relation.

It is convenient to write a frame using the notation R for F where Ψ or sometimes just R where Ψ.

For example, the path expression u.student denotes the frame:


student=S:Student where u.student(S)

Navigation

The simplest paths select one kind of object from the university.


u.student
u.course
u.department
u.enrolment

A path is read from left to right. The expression u.student.name starts with the university, reaches its students, and then reaches each student's name.


u.student.name

The same idea works for longer paths. The following expression starts at the university, reaches students, reaches their enrolments, reaches the courses for those enrolments, and finally reaches the course codes.


u.student.enrolment.course.code

This is relational-join-like traversal: the path crosses from students to enrolments to courses by following declared relationships instead of writing the relational joins explicitly.

Paths can move through derived attributes in the same way as stored attributes. Student.enrolled is calculated from Student.enrolment.course, so these two expressions reach the same courses. Path expressions do not need to distinguish between base attributes and derived attributes.


u.student.enrolment.course
u.student.enrolled

A path may reach many values. The expression u.course.session.room reaches the rooms used by course sessions; it is not a single room unless the data and the schema make it single-valued.


u.course.session.room

Navigation can also follow relationships that were written in the reverse direction for convenience. Department.course is derived from Course.department, but it still reads as ordinary navigation from a department to the courses it offers.


u.department.course.code

The important habit is to keep track of the current kind of value at each step. In u.student.enrolment.course.department.name, the path passes through a university, students, enrolments, courses, departments, and finally department names.


u.student.enrolment.course.department.name

For example, the path u.student.enrolment.course.code corresponds to a chain of relationships in logic:


code=X:String
where
  ∃S:Student ∃E:Enrolment ∃C:Course
    u.student(S)
  ∧ S.enrolment(E)
  ∧ E.course(C)
  ∧ C.code(X)

The result attribute is code, bound to X. The variables S, E and C are the intermediate values reached by the path. Each dotted step contributes another predicate, and the intermediate values are existentially quantified. This is why a dotted path should be read as relational navigation, not as ordinary object property access.

Navigation is uniform across type kinds

This is an important unification in Predica, and may be surprising at first. Base and derived attributes of value types, entity types and quotient types, fields of structural types, and attributes of enum types and variant types are all navigated in the same way. Enum and variant attributes are regarded as always present as unary predicates, which is why they can be used in path expressions without conditionals. Their denotations into logic work identically as well.

For example:


u.course.session.time.startMinute
u.course.session.time.day.monday
u.course.assessment.exam.durationMinutes
u.student.enrolled.code

The first path uses attributes of entity and value types. The second uses an enum attribute. The third uses a variant attribute. The fourth uses the derived attribute Student.enrolled. All of these are ordinary path expressions, and their denotations as frames have the same form.

23 Path Tails

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:

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

24 Filters

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

Path tails inside filters

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

Attributes are reached one value at a time

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.

Using richer logic formulas in filters

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

25 Tuple Construction

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.

Nested tuple expressions

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.

Analogies with unwrapping, projection and Cartesian products

These examples have several relational-model analogies:

Later chapters use the same parentheses with renaming, calculations, folds and nested path expressions.

Path tails inside tuple construction

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.

A theta-join-like expression

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.

26 Renaming with 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)

Restrictions on where 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 inside tuple construction

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.

Avoiding name clashes

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.

27 This

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.

28 Calculated Attributes

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.

Calculations over assessment fields

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 inside filters

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.

Gotcha: Cartesian products with repeated use of attributes

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.

29 Let Bindings

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.

Naming an intermediate calculation

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.

Keeping an outer value available

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.

Gotcha: this changes inside path tails

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.

Let bindings inside filters

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.

30 Splat

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 sets

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.

Removing 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.

Adding selected attributes

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.

Name clashes

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.

31 Folds

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.

Common folds

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.

Collecting reached 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.

Folds after filters

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.

Inline folds

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.

Empty inputs

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.

Composability

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.

32 Universal Navigation

Ordinary navigation reaches values that can be reached from at least one current value. Universal navigation reaches values that can be reached from every current value.

For example, the following expression reaches the teaching days used by every advanced course:


u.course.[advanced]⇒session.time.day

A day is output only if each advanced course has some session on that day. The left side, u.course.[advanced], supplies the courses being tested. The right side, session.time.day, is tested from each of those courses.

The logical shape is:


day=D:Day
where
  ∀C:Course ∀A:Boolean
    (u.course(C) ∧ C.advanced(A) ∧ A)
  → ∃S:Session ∃T:TimeSlot
      C.session(S)
    ∧ S.time(T)
    ∧ T.day(D)

For every advanced course C, the right-hand path must be able to reach the output day D.

Ordinary navigation versus universal navigation

Changing ⇒ to . changes the meaning:


u.course.[advanced].session.time.day

This reaches days used by at least one advanced course. The universal version:


u.course.[advanced]⇒session.time.day

reaches only days used by all advanced courses. Ordinary navigation is like a union of what each current value can reach. Universal navigation is like an intersection of what each current value can reach.

Asking whether all values satisfy a condition

Often a universal requirement is easiest to write by looking for counterexamples. This expression reaches courses that have no assessment worth more than 60 percent:


u.course.[assessment.[weight > 60].Σempty]

The inner filter reaches the bad assessments. Σempty then says that there are none. This pattern is often the clearest way to say "all reached values satisfy the condition".

Using folds and subset tests

Another common pattern is to collect reached values with .Σ, then compare sets. For example, this expression reaches advanced courses whose prerequisites are all mathematics courses:


u.course.[advanced].[
    prerequisite.Σ ⊆ u.department.[name = "Mathematics"].course.Σ
]

Here the requirement is naturally a set inclusion. The left side is the set of prerequisites of the current course. The right side is the set of courses offered by the mathematics department.

Relation to relational division

Universal navigation is the path-expression form of division-style queries. A division query asks for the values related to all values in some divisor relation. In the first example, the divisor is the advanced courses, and the result contains days related to every one of those courses by the session.time.day path.

The classic supplier-parts query "suppliers who supply all parts" has exactly this shape:

In Tutorial D, it can be written using non-existence:


S WHERE NOT EXISTS (
    P WHERE NOT EXISTS (
        SP WHERE SP.S# = S.S#
           AND SP.P# = P.P#
    )
)

It can also be written using DIVIDEBY:


S MATCHING (
    SP { S#, P# }
    DIVIDEBY P { P# }
    PER ( S { S# } )
)

In Predica, universal navigation gives the same query directly:


s.part⇒(shipment.supplier)

The left side supplies the required parts. The right side reaches the suppliers for each part. The output suppliers are those reached from every part.

Adding a filter gives the usual "suppliers who supply all red parts" query:

In Tutorial D, the non-existence form is:


S WHERE NOT EXISTS (
    (P WHERE COLOR = 'Red')
    WHERE NOT EXISTS (
        SP WHERE SP.S# = S.S#
           AND SP.P# = P.P#
    )
)

The corresponding DIVIDEBY form is:


S MATCHING (
    SP { S#, P# }
    DIVIDEBY (P WHERE COLOR = 'Red') { P# }
    PER ( S { S# } )
)

In Predica, the filter is part of the left side of the universal navigation:


s.part.[color = Color.red]⇒(shipment.supplier)

The DIVIDEBY versions make the relation to relational division explicit. In practice, Tutorial D users will often prefer the non-existence form: choose the candidate values for which there does not exist a required value with no matching relationship. Universal navigation packages that double-negation pattern into a path operator.

Choosing the universal pattern

There are three common ways to express universal requirements. The best choice usually depends on the sentence you are trying to write.

Use When the thought is Example
Σempty there are no counterexamples assessment.[weight > 60].Σempty
a.Σ ⊆ b.Σ everything reached by a is among the things reached by b

prerequisite.Σ ⊆
  u.department.[name="Mathematics"].course.Σ
⇒ find the values reachable from every value on the left u.course.[advanced]⇒session.time.day
→ filter values by saying whenever this condition holds, that condition must hold

u.course.[
    assessment → assessment.weight ≤ 60
]

The difference is that Σempty tests for absence, a.Σ ⊆ b.Σ compares two collected sets, ⇒ produces common reachable values, and → states a conditional requirement.

Using → inside a filter often makes universal quantification appear when the frame condition is written out. The implication itself is the condition, but the witnesses used by its antecedent are usually universally closed when the filter is rendered. This is why:


u.course.[assessment → assessment.weight ≤ 60]

reads like "courses for which every assessment has weight at most 60". The same idea can also be written by looking for no counterexamples:


u.course.[assessment.[weight > 60].Σempty]

The two expressions approach the universal requirement from different directions. The first says what must follow from each reached assessment. The second says there are no reached assessments that break the rule.

Do not reach for it too soon

Universal navigation is powerful, but it is not the only way to write universal requirements. Use ⇒ when the result should be values reachable from every left-side value. Use Σempty when the clearest thought is "there are no bad cases". Use .Σ with subset testing when the clearest thought is "this set is contained in that set".

33 Path Operations

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.

Meet

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.

Union

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.

Difference

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.

Pointwise negation

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.

Path complement

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.

Composability

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.

34 Alternatives

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.

Naming the 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.

Alternatives are not union

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.

Using alternatives in calculations

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.

When the branches differ

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.

A gotcha about repeated alternatives

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.

Composability

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.

35 Conditionals

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".

Conditions can use path tails

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.

Branches can use path tails too

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.

Both branches produce one result

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 are not alternatives

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.

Composability

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.

36 Lists

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.

List items can be tuples

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.

Ordering keys

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↑)

Filtering list items

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.

Lists and sets

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.

List items can use earlier ideas

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.

Composability

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.

37 Groups

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.

Explicit grouping

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.

Naming the 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.

Grouping by another key

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.

Grouping by several values

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.

Relation to relational grouping

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 and lists

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.

Composability

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.

38 Type Checking Path Expressions

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.Σ.

Current type

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.

Cardinality is not part of the type

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.

Tuple construction

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.

Filters

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.

Calculated outputs

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.

Σ gives a Set<T>

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.

Part III: Constraints

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.

39 Constraint Forms

Predica supports several forms of constraints, each governing different aspects of the values and relationships in a model.

Constraint expressions

A constraint expression may appear in a where clause or a constraints block.

A constraint expression must be Boolean-valued. It may use:

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);
    }
}

Cardinality constraints in path expressions

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]).

40 Firearm Purchase Example

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)

41 Construction Project Example

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;
    }
}

42 Cardinality Constraint Patterns

Exactly one requirements


entity Person
{
    Address primaryResidence;
}

At least one requirements


entity Company
{
    Person director+;
}

At most one

A person may have at most one active passport.


entity Person
{
    Passport passport?;
}

Optional-but-constrained fields

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;
    }
}

Collections that must be non-empty when another condition holds

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:+;
    }
}

Cardinality constraints on joins

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;
    }
}

43 Student Enrolment Courses Example

This example keeps the model deliberately small. The main design choices are:


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*;
}

Reading the constraints

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) })

Common wrong turns

The following examples are deliberately concrete. Each one shows a tempting expression, what it actually denotes, and the form that matches the intended constraint.

1. Comparing scalar paths as if they were sets

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.

2. Forgetting that this changes inside a path tail

This 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.

3. Using value inequality when the domain has a richer predicate

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.

4. Misusing universal navigation for a universal condition

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 ⇒.

5. Traversing the relationship in the wrong direction

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.

Constraint-writing principles

44 Using Predica to define legality

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.

Why no modal operators are needed

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.

Part IV: Types

45 Types

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:

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.

Type test expressions

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.

46 Built-in Types

Predica defines a fixed set of built-in types. Built-in types are available without user declarations.

Boolean type

The Boolean type has exactly two values: false and true.

Integer types

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.

Real and floating-point types

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.

Floating-point arithmetic

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.

String and Unicode types

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:

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.

Carrier sets of primitive types

The carriers of built-in primitive types are not defined in terms of attributes. They are specified directly as follows:

TypeCarrier
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.

Built-in collection types

Predica provides the following built-in collection types:

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.

Core language and standard library

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.

47 Structural types

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.

Carrier sets of structural types

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.

Structural identity

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.

Empty and nested structural types

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.

Structural values

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.

48 Type expressions

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:

The full list of built-in types is given in 46 Built-in Types.

The following type expressions denote nominal entity types:

The following type expressions denote structural types:

Structural type identity, aliases, nesting, and structural values are described in 47 Structural types.

The following type expressions are formed from other types:

Grouping with parentheses

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;

When type expressions denote the same type

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.

49 Type Aliases

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.

50 Equality Between Types

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

Intrinsic equality

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.

Pairwise equality definitions

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

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.

Disjointness declarations

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.

51 Expression literals and constructors

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.

Scalar Literals

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.

Empty Set

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.

Finite Set Literals

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.

Integer Ranges

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.

Structural Literals

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.

Nominal Constructors

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.

52 Static vs dynamic typing

Static typing

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";

Cardinality flow

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 typing

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";

Default strict mode

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;

Rationale

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.

53 Isomorphisms

Predica supports declarations of isomorphisms between types.

Example: isomorphism between optional and variant


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);
}

Example: isomorphism with separation into known and unknown


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.

Attribute predicates as unary relations

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.

Example: internal isomorphisms

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 θ;
    }
}

54 Objects

Physical representation qualifier

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.

JPEG image

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.

Tiled image

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;
}

The 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.

Morton-ordered 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):

Morton order traversing a 4 by 4 grid

The next image illustrates Morton order with two levels of nesting (8 × 8):

Morton order recursively traversing an 8 by 8 grid

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.

Object identity from spatial position

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.

Incremental population and on-demand loading

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.

Indexing and navigation

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.

Indexing and filtering syntax

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]

Part V: Nominal Types

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.

55 Value Types

Example: Point type

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:

Why the keyword value instead of struct or record

Although struct or record may seem more conventional, the keyword value is used for two reasons:

Example: Circle type

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:

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.

Carrier set

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.

56 Entity Types

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.

Carrier set

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.

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*;
}

Keys are sets of attribute names

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

Equality on key attribute types

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
}

Attribute extensions are key components

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.

Base and derived key attributes

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.

Keys on abstract entity types

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.

Multiple candidate keys

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.

Default key

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.

58 Enumerated Types

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.

Carrier set

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.

Formalisation as a variant with {} 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

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.

Storage and serialisation

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.

59 Variant types

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.

Carrier set

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.

Variant constraint

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.

Constraints inherited from an abstract supertype

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:

Payload-free attributes

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

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:

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;
}

Generalisation to Algebraic Data Types

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:

60 Quotient types

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.

Carrier set

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.

Representatives and equality

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.

Well-defined quotient operations

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.

61 Attributes

All attributes hold a set of values

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.

Multiplicity on attributes

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”.

Attributes represent functions

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:

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.

Const attributes

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.

Const attributes in type expressions

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>.

62 Parameterised Attributes

Predicates indexed by parameters

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

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

Enum parameters

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.

Derived parameterised attributes

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.

Queries involving parameterised attributes

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)]

Updating parameterised attributes

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.

Large or infinite parameter domains

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.

Embedding parameterised attributes

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>.

63 Derived Attributes

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

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

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.

Variant types

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

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.

64 Embedding

Motivating example

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.

Named embedding

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.

Static rules

Embedding in quotient representations

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.

Embedding enumerated types

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.

65 Generics

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.

Parameter declarations

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)
{
}

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;
}

Applications and canonical form

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.

Supported declarations

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.

Generic constants and functions

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.

Instantiation model

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.

Annotation behaviour

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.

Cross-component use

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.

66 Ordering

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.

Order axioms

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:

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.

Derived comparison operators

The standard order operator ≤ is the primitive comparison associated with an ordered type. The other ordered comparison operators are derived from ≤ and equality:

These derived operators do not add separate ordering semantics. They are available exactly when the standard order relation ≤ is available for the compared values.

Default ordering and opt-out

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];
}

Implicit ordering

Predica may define an implicit order when the declaration contains enough ordered structure to compare values without adding policy. The default orders are:

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.

Ordering value and entity types

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.

Ordering an enum 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;

Ordering a quotient type

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.

67 Constraints

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.

Supported nominal types

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.

Stored-attribute 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.

Constraints on derived attributes

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.

Effect on carrier sets

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.

Disjoint declarations

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.

Inclusion dependencies

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.

68 Theorems

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.

Nominal types that support theorems

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.

Possible semantics

Several useful interpretations are possible. They have different consequences and should not be treated as interchangeable merely through compiler policy.

Static proof obligation

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.

Runtime verification

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.

Compiler information

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.

Trusted assumption

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.

Documentation or external verification

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.

Relationship to assertions and constraints

The possible meanings may ultimately deserve separate language constructs:

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.

Possible staged introduction

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.

Part VI: Subtypes

69 Type graph

Type graph

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

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.

Type aliases do not add graph facts

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.

Refinement equivalence is extensional

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.

Do we need static types which understand intersection of types?

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.Σ;

The type hierarchy

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.

70 Necessarily disjoint and non-disjoint

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 is a relation on types: overlap(A,B).
  • Necessary overlap is the modal lifting: ☐overlap(A,B).
  • Disjointness is a relation: disjoint(A,B).
  • Necessary disjointness is: ☐disjoint(A,B).

The modal operator □ (“necessarily”) means: True in all models consistent with the type graph and intrinsic rules. In other words:

  • Necessary overlap = overlap in every model the compiler cannot rule out.
  • Necessary disjointness = disjointness in every model the compiler cannot rule out.

This is why:

  • Overlap propagates upward (monotone in supertypes).
  • Disjointness propagates downward (monotone in subtypes).

These are exactly the modal closure rules.

Necessary overlap

Two types necessarily overlap if:

  • They are the same type.
  • They are supertypes of types that necessarily overlap.
  • They have a declared non-empty common subtype.
  • One is a known subtype of the other, such as Integer and Real, or a componentwise structural subtype.

Necessary disjointness

Two types are necessarily disjoint if:

  • They belong to different intrinsic disjoint families. The predefined families are Boolean, String, and the Real family, where the Real family includes Integer, the fixed-width integer types, finite floating-point types, UnicodeCodePoint, and UnicodeScalar through intrinsic subtyping.
  • They are structural types whose ordered attribute-name sequences differ.
  • They are structural types with the same ordered attribute names, and some corresponding attribute declarations cannot be satisfied by any common finite set. For example, their cardinality ranges may be disjoint, or their attribute types may be necessarily disjoint while every common cardinality requires at least one value.
  • They are subtypes of types which are necessarily disjoint.
  • One of the types is the empty type Null.
  • They appear together in a declared disjointness declaration.

It is an error to declare a non-empty common subtype of two types which are necessarily disjoint.

71 Abstract types

Concrete types

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 ∈ ℤ }

Uncountable types

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 ∈ ℝ }

Abstract types

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:

  • a list of supertypes
  • constraints
  • derived attributes
  • theorems

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:

  • The set of all shapes in ℝ² is uncountably infinite.
  • No finite set of constructors can enumerate them.
  • No concrete representation can cover the entire domain.
  • Any concrete subtype (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.

Equality on abstract types

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.

Abstract 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

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.

Abstract attribute requirements

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.

Unimplemented attribute requirements

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 type IncompleteRectangle does not implement
attribute area defined in supertype Shape.

Inherited constraints

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.

Keys

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.

Covariant attribute types

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;
}

Refining inherited derived attributes

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.

Refining free functions on subtypes

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.

Narrowing cardinality constraints

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+;
}

72 Subtypes of abstract types

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;

73 Subtypes of concrete types

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:

  • Refinement of a concrete supertype (adding constraints).
  • Embedding declaration into a concrete supertype.

In both cases, the subtype Square inherits the attributes of its supertype (width, height, perimeter, and area).

Hiding rule

Inheritance can produce name collisions. Predica applies a hiding rule:

  • Redefined attributes in a subtype hide attributes of the same name in a supertype.
  • If the same name appears in two supertypes that were not inherited through a common supertype, the name is ambiguous and produces an error.

Subtype through refinement

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;

Subtype through embedding

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.

Refining derived attributes through embedding

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.

74 Comparable Types

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

75 Union types

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.

Union syntax does not assert disjointness

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.

Equality is a separate concern

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 is non-nominal

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;

Part VII: Query

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.

76 Constants

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.

77 Functions

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.

Expression-bodied functions

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.

Owned parameters

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.

External functions

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.

Function calls

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.

Receiver-call syntax


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.

Names and overload sets

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.

Symbolic and comparison functions

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 ≥.

Generic functions

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.

Rendering and hidden instantiations

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.

78 Function Overload Resolution

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.

Viable candidates

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.

More specific parameter lists

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.

Selecting the overload

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 and incomparable overloads

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.

79 Example: Head-Tail List Functions

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
            }
        }
    };

Membership as an Extension

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.

Boolean Membership Function

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.

Implementation Note

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.

80 Query Examples

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.

Example Model


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.

Navigation and Projection

Question Predica Reading
All suppliers
s.supplier
Navigate from the system to its supplier values.
Supplier names
s.supplier.name
For each supplier reached, observe its name.
Supplier names and cities
s.supplier.(name, city)
Reshape each supplier into a result with two exposed labels.
Shipment rows with names
s.shipment.(
    supplier.name as supplier_name,
    part.name as part_name,
    qty)
Use the shipment as the witness and expose values reached through it.

Filtering

Question Predica Reading
Suppliers in London
s.supplier.[city = "London"]
Keep the current suppliers whose city satisfies the filter.
Names of suppliers in London
s.supplier.[city = "London"].name
Filter suppliers first, then observe their names.
Parts heavier than 15
s.part.[weight > 15]
Use a numeric condition in the context of each part.
Shipments of red parts
s.shipment.[part.color = Color.red]
The filter may navigate from the current shipment to related values.

Inverse Navigation

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
s.supplier.shipment
Navigate backwards from supplier to referring shipments.
Part names shipped by each supplier
s.supplier.(name, shipment.part.name as part_name)
Combine forward and inverse navigation in one result shape.

Folds and Set Observations

Question Predica Reading
Suppliers with shipments
s.supplier.[shipment.Σexists]
Fold the supplier's shipment path to a Boolean condition.
Suppliers with no shipments
s.supplier.[shipment.Σempty]
Keep suppliers whose inverse shipment path is empty.
Number of shipments per supplier
s.supplier.(
    name,
    shipment.Σcount as shipment_count)
Fold the reached shipments while preserving the supplier context.
Total quantity shipped by supplier
s.supplier.(
    name,
    shipment.qty.Σsum as total_qty)
Navigate to quantities and aggregate them for each supplier.
Parts shipped by exactly one supplier
s.part.[shipment.supplier.Σcount = 1]
Use a folded path inside a filter condition.

Cardinality-Aware Assignment

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

Value Sharing in Literals

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.

81 Filter Conditions

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.

Selection as a Relation View

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.

Why Not Intensional Relations Here?

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;
  • renaming is specified by result labels, not by deriving it from relation join and projection.

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.

82 Inverse relationships

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.

Example: suppliers and shipments

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.

All Shipment objects whose supplier attribute equals this Supplier.

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 and inverse navigation

Forward navigation follows declared attributes:

  • shipment.supplier: from a Shipment to its Supplier
  • shipment.part: from a Shipment to its Part

Inverse navigation goes the other way, by inverting a path:

  • inverse shipment.supplier: from a Supplier to all Shipment objects that refer to it
  • inverse 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.

Complete shipments example

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.

Informal semantics of inverse

An expression of the form:


inverse p
  

where p is a downward path, is interpreted as:

“The set of all objects at the start of path p whose value along p equals this.”

In the supplier example:

  • p is shipment.supplier
  • the start of p is the collection of all Shipment objects
  • the end of p is a Supplier
  • inside Supplier, inverse shipment.supplier returns all shipments whose supplier is this

Constraints on inverse

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:

  1. Downward path: p must be a downward path expression (a sequence of attribute selections).
  2. Type match: The final attribute of p must have type T, the entity in which inverse p appears. This ensures that the path “lands on” objects of the current type.
  3. Upward resolution (hiding rule): Resolution of 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:

  • structural: it operates on paths, not global entity names
  • type-safe: the inverted path always ends at the current entity type
  • compositional: it respects the same scoping and hiding rules as normal paths

Why inverse relationships matter

Inverse relationships let you express “backwards” navigation declaratively, without introducing explicit foreign-key style attributes or manual queries. They are especially useful when:

  • one entity holds a reference to another (e.g. Shipment.supplier)
  • the referenced entity wants to expose the set of all referring objects (e.g. all shipments for a 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.

83 Views

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.

Example: A View to Control Exposed Information

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.

Semantic Status

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.

84 Responsive Applications without async/await

Predica 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.

Partial availability is ordinary information

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:

  • available: the value can be used now;
  • pending: work needed to obtain the value has been requested but has not completed;
  • partial: a useful result is available but further work can refine or extend it;
  • refreshing: an earlier result remains usable while a replacement is calculated;
  • failed: the requested loading or calculation failed; and
  • absent: the logical value is not present in the snapshot.

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.

Database access expresses demand

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.

Example: progressively rendering a multiresolution image

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.

One evaluation, one MVCC snapshot

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.

The display is a current projection

A user interface continuously derives a presentation from current information. It does not wait for that information to become complete. The same model supports:

  • refining an image as higher-resolution tiles become resident;
  • extending a list as additional database ranges are loaded;
  • showing partial query or analysis results;
  • updating a progress bar from published progress state;
  • showing Calculating… while a demanded result is pending;
  • retaining a previous result while a changed input causes it to be refreshed; and
  • displaying a failure without preventing unrelated information from being shown.

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.

Dependencies without writer-to-reader notification

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.

Why Predica applications do not normally need 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.

85 Folds

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.

Standard Library Folds

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]

Fold typing and definedness

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.

General fold syntax

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.

Named fold declarations

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.

86 Lists

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"
)

Syntax


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.

Meaning

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.
  • Each 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.

87 Group Aggregation

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.

Aggregates in Tuple Results


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.

Implicit Grouping by Receiver

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

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)
S1,P1,300 S1,P2,200 S1,P3,400 S1,P4,200 S1,P5,100 S1,P6,100 S2,P1,300 S2,P2,400 S3,P2,200 S4,P2,200 S4,P4,300 S4,P5,400 S1 P1,300 P2,200 P3,400 P4,200 P5,100 P6,100 S2 P1,300 P2,400 S3 P2,200 S4 P2,200 P4,300 P5,400

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

Group aggregation examples

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

SUMMARIZE SP 
    PER SP { S# }
    ADD SUM(QTY) AS TOTAL_QTY

s.supplier.(
    id,
    shipment.qty.Σsum as total_qty)
2 Total quantity shipped by part

SUMMARIZE SP 
    PER SP { P# }
    ADD SUM(QTY) AS TOTAL_QTY

s.part.(
    id,
    shipment.qty.Σsum as total_qty)
3 Number of shipments per supplier

SUMMARIZE SP 
    PER SP { S# }
    ADD COUNT(*) AS NUM_SHIPMENTS

s.supplier.(
    id,
    shipment.Σcount as num_shipments)
4 Number of distinct parts shipped by supplier

SUMMARIZE SP 
    PER SP { S# }
    ADD COUNT(P#) AS NUM_PARTS

s.supplier.(
    id,
    shipment.part.Σcount as num_parts)
5 Total quantity and number of shipments per supplier

SUMMARIZE SP 
    PER SP { S# }
    ADD SUM(QTY) AS TOTAL_QTY,
        COUNT(*) AS NUM_SHIPMENTS

s.supplier.(
    id,
    shipment.qty.Σsum as total_qty,
    shipment.Σcount as num_shipments)
6 Total quantity shipped per supplier–city

SUMMARIZE (SP JOIN S) 
    PER (SP JOIN S) { S#, CITY }
    ADD SUM(QTY) AS TOTAL_QTY

s.supplier.(
    id,
    city,
    shipment.qty.Σsum as total_qty)
7 Total quantity and number of suppliers per part

SUMMARIZE SP 
    PER SP { P# }
    ADD SUM(QTY) AS TOTAL_QTY,
        COUNT(S#) AS NUM_SUPPLIERS

s.part.(
    id,
    shipment.qty.Σsum as total_qty,
    shipment.supplier.Σcount 
        as num_suppliers)
8 Total weight of parts shipped per supplier

SUMMARIZE (SP JOIN P) 
    PER (SP JOIN P) { S# }
    ADD SUM(QTY * WEIGHT) 
        AS TOTAL_WEIGHT

s.supplier.(
    id,
    shipment.(qty * part.weight).Σsum 
        as total_weight)
9 List of parts shipped by each supplier

SUMMARIZE SP 
    PER SP { S# }
    ADD { P#, PNAME } AS PARTS

s.supplier.(
    id,
    shipment.part.(id, name).Σ as parts)
10 Number of heavy parts shipped (weight > 15) per supplier

SUMMARIZE (SP JOIN P 
        WHERE WEIGHT > 15) 
    PER (SP JOIN P) { S# }
    ADD COUNT(*) AS NUM_HEAVY

s.supplier.(
    id,
    shipment.[part.weight > 15].Σcount
        as num_heavy)

Nested grouping examples

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)

SUMMARIZE SP PER SP { S#, P# }
    ADD SUM(QTY) AS TOTAL_QTY

s.shipment.(
    supplier.id as s#,
    part.id as p#,
    qty.Σsum as total_qty)
12 For each supplier, aggregates grouped by part (nested grouping)

(SUMMARIZE (SP RENAME S# AS OUTER_S#) PER (SP RENAME S# AS OUTER_S#) { OUTER_S# }
    ADD SUMMARIZE SP WHERE S# = OUTER_S#
        PER SP { P# }
        ADD SUM(QTY) AS TOTAL_QTY
        AS PART_SUMMARY
) RENAME OUTER_S# AS S#

s.supplier.(
    id,
    shipment.(
        part.id,
        qty.Σsum as total_qty
    ).Σ as part_summary)
13 For each city, total quantity shipped and number of suppliers

SUMMARIZE (SP JOIN S) PER (SP JOIN S) { CITY }
    ADD SUM(QTY) AS TOTAL_QTY,
        COUNT(DISTINCT S#) AS NUM_SUPPLIERS

s.shipment.(
    supplier.city,
    qty.Σsum as total_qty,
    supplier.id.{ }.Σcount as num_suppliers)
14 For each part, list of suppliers and total quantity per supplier

(SUMMARIZE (SP RENAME P# AS OUTER_P#) PER (SP RENAME P# AS OUTER_P#) { OUTER_P# }
    ADD SUMMARIZE SP WHERE P# = OUTER_P#
        PER SP { S# }
        ADD SUM(QTY) AS TOTAL_QTY
        AS SUPPLIER_SUMMARY
) RENAME OUTER_P# AS P#

s.part.(
    id,
    shipment.(
        supplier.id,
        qty.Σsum as total_qty
    ).Σ as supplier_summary)
15 For each supplier, total quantity of red parts shipped

SUMMARIZE (SP JOIN P WHERE COLOR = 'Red') PER (SP JOIN P) { S# }
    ADD SUM(QTY) AS TOTAL_RED_QTY

s.shipment.[part.color = Color.red].(
    supplier.id,
    qty.Σsum as total_red_qty)
16 For each supplier, number of parts shipped grouped by color

(SUMMARIZE ((SP JOIN P) RENAME S# AS OUTER_S#) PER ((SP JOIN P) RENAME S# AS OUTER_S#) { OUTER_S# }
    ADD SUMMARIZE (SP JOIN P WHERE S# = OUTER_S#)
        PER (SP JOIN P) { COLOR }
        ADD COUNT(*) AS NUM_PARTS
        AS COLOR_SUMMARY
) RENAME OUTER_S# AS S#

s.supplier.(
    id,
    shipment.part.(
        color,
        Σcount as num_parts
    ).Σ as color_summary)
17 For each city, list of suppliers and their total shipments

(SUMMARIZE (S RENAME CITY AS OUTER_CITY) PER (S RENAME CITY AS OUTER_CITY) { OUTER_CITY }
    ADD SUMMARIZE (SP JOIN S WHERE CITY = OUTER_CITY)
        PER (SP JOIN S) { S# }
        ADD SUM(QTY) AS TOTAL_QTY
        AS SUPPLIER_SUMMARY
) RENAME OUTER_CITY AS CITY

s.supplier.(
    city,
    shipment.(
        supplier.id,
        qty.Σsum as total_qty
    ).Σ as supplier_summary)
18 For each part, total quantity shipped by suppliers in London

SUMMARIZE (SP JOIN S WHERE CITY = 'London') PER (SP JOIN S) { P# }
    ADD SUM(QTY) AS TOTAL_QTY

s.shipment.(
    part.id,
    supplier.[city = "London"].qty.Σsum as total_qty)

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)

(SUMMARIZE (SP RENAME S# AS OUTER_S#) PER (SP RENAME S# AS OUTER_S#) { OUTER_S# }
  ADD SUMMARIZE SP WHERE S# = OUTER_S#
      PER SP { P# }
      ADD SUM(QTY) AS TOTAL_QTY
      AS PART_SUMMARY
) RENAME OUTER_S# AS S#

s.supplier.(
    id,
    shipment.(
        part.id,
        qty.Σsum as total_qty
    ).Σ as part_summary)
20 For each city, for each supplier, total shipments (multi-level grouping)

(SUMMARIZE (S RENAME CITY AS OUTER_CITY) PER (S RENAME CITY AS OUTER_CITY) { OUTER_CITY }
  ADD SUMMARIZE (SP JOIN S WHERE CITY = OUTER_CITY)
      PER (SP JOIN S) { S# }
      ADD SUM(QTY) AS TOTAL_QTY
      AS SUPPLIER_SUMMARY
) RENAME OUTER_CITY AS CITY

s.supplier.(
    city,
    shipment.(
        supplier.id,
        qty.Σsum as total_qty
    ).Σ as supplier_summary)
21 For each supplier, total quantity per part color (grouping by derived attribute)

(SUMMARIZE ((SP JOIN P) RENAME S# AS OUTER_S#) PER ((SP JOIN P) RENAME S# AS OUTER_S#) { OUTER_S# }
  ADD SUMMARIZE (SP JOIN P WHERE S# = OUTER_S#)
      PER (SP JOIN P) { COLOR }
      ADD SUM(QTY) AS TOTAL_QTY
      AS COLOR_SUMMARY
) RENAME OUTER_S# AS S#

s.supplier.(
    id,
    shipment.(
        part.color,
        qty.Σsum as total_qty
    ).Σ as color_summary)
22 For each part, total quantity per supplier city (tuple-valued grouping key)

SUMMARIZE (SP JOIN S) PER (SP JOIN S) { P#, CITY }
  ADD SUM(QTY) AS TOTAL_QTY

s.shipment.(
    part.id as p#,
    supplier.city,
    qty.Σsum as total_qty)
23 For each supplier, average quantity per part (aggregate over aggregates)

(SUMMARIZE (SP RENAME S# AS OUTER_S#) PER (SP RENAME S# AS OUTER_S#) { OUTER_S# }
  ADD AVG(
        SUMMARIZE SP WHERE S# = OUTER_S#
            PER SP { P# }
            ADD SUM(QTY)
      ) AS AVG_QTY
) RENAME OUTER_S# AS S#

s.supplier.(
    id,
    shipment.(
        part.id,
        qty.Σsum as total_qty
    ).total_qty.Σavg as avg_qty)
24 For each city, number of suppliers and total shipments (multi-aggregate grouping)

(SUMMARIZE (S RENAME CITY AS OUTER_CITY) PER (S RENAME CITY AS OUTER_CITY) { OUTER_CITY }
  ADD COUNT(*) AS NUM_SUPPLIERS,
      SUMMARIZE (SP JOIN S WHERE CITY = OUTER_CITY)
          PER (SP JOIN S) { CITY }
          ADD SUM(QTY) AS TOTAL_QTY
          AS SHIPMENT_SUMMARY
) RENAME OUTER_CITY AS CITY

s.supplier.(
    city,
    Σcount as num_suppliers,
    shipment.qty.Σsum as total_qty)
25 For each supplier, summary of heavy vs light parts shipped

(SUMMARIZE (SP RENAME S# AS OUTER_S#) PER (SP RENAME S# AS OUTER_S#) { OUTER_S# }
  ADD SUMMARIZE (SP JOIN P WHERE S# = OUTER_S# AND WEIGHT > 15)
        PER (SP JOIN P) { S# }
        ADD SUM(QTY) AS HEAVY_QTY
        AS HEAVY,
      SUMMARIZE (SP JOIN P WHERE S# = OUTER_S# AND WEIGHT ≤ 15)
        PER (SP JOIN P) { S# }
        ADD SUM(QTY) AS LIGHT_QTY
        AS LIGHT
) RENAME OUTER_S# AS S#

s.supplier.(
    id,
    shipment.[part.weight > 15].qty.Σsum as heavy_qty,
    shipment.[part.weight ≤ 15].qty.Σsum as light_qty)
26 For each part, summary of shipments grouped by supplier status

(SUMMARIZE ((SP JOIN S) RENAME P# AS OUTER_P#) PER ((SP JOIN S) RENAME P# AS OUTER_P#) { OUTER_P# }
  ADD SUMMARIZE (SP JOIN S WHERE P# = OUTER_P#)
        PER (SP JOIN S) { STATUS }
        ADD SUM(QTY) AS TOTAL_QTY
        AS STATUS_SUMMARY
) RENAME OUTER_P# AS P#

s.part.(
    id,
    shipment.(
        supplier.status,
        qty.Σsum as total_qty
    ).Σ as status_summary)
27 For each supplier, total shipments and average shipment size

SUMMARIZE SP PER SP { S# }
  ADD SUM(QTY) AS TOTAL_QTY,
      AVG(QTY) AS AVG_QTY

s.shipment.(
    supplier.id,
    qty.Σsum as total_qty,
    qty.Σavg as avg_qty)

Part VIII: Relations

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.

88 Relations as Extensions

Status

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.

Frame Extensions

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.

S-Tuples and S-Relations

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 }.

M-Tuples and M-Relations

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.

Set-valued Attribute View

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.

Expansion

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.

Operations

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.

Union and Meet

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 and Difference

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)

Normalisation

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:

  • remove m-tuples whose expansion is empty;
  • remove attributes whose value set is the whole value domain, since they do not restrict expansion;
  • remove an m-tuple whose expansion is contained in the expansion of another m-tuple.

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

Fixed Heading Algebra

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.

Chapter Scope

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.

89 Notation and Surface Syntax

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.

Three Layers of Notation

Layer Used for Examples
Predica syntax Source expressions in the language.
s.supplier.name
s.shipment.(supplier.name as sname, qty)
s.supplier -- s.shipment.supplier
Predica logic display Human-readable display of logic frames.
name=N:String
where ∃S:Supplier (
    s.supplier(S)
    ∧ S.name(N)
)
Relation-view notation Mathematical notation for relation extensions and implementation representations.
{ id: 17 }
M!_H
M ≡_H N
π_A(M!_H)

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.

Relation-View Notation

The following notation is used to describe relation views and does not by itself introduce Predica surface syntax:

  • s-tuples such as { id: 17, city: "London" };
  • relation bodies such as { { id: 17 }, { id: 23 } };
  • headings such as { id: Integer, city: String };
  • projection notation such as π_A(R);
  • universe notation such as universe(H);
  • m-tuples such as < id: {17}, city: {"London", "Athens"} >;
  • heading-relative expansion such as M!_H;
  • m-relation equivalence such as M ≡_H N;
  • m-relation algebra displays such as 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.

Overlapping Symbols

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.

Accessor Conventions

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

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.

Relation Literals

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.

90 Headings, Domains, and Universe

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.

Headings

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.

Universe for a Heading

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 Relative

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.

Abstract Domains and Active Domains

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:

  • Under an abstract domain reading, Integer means the whole integer domain.
  • Under an active-domain reading, the integer domain may be restricted to a finite set relevant to the current materialisation.

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.

M-Relations and Unrestricted Attributes

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.

Relation Types

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.

91 Projection and Result Shape

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.

Frame Projection

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.

S-Relation Projection

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.

M-Relation Projection

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.

The Bottom-Tuple Pitfall

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.

Extensional Projection

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 and Set-Valued Results

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.

92 Supplier Relation Examples

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.

Mixed Cardinalities

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.

Observing Id and City

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.

Adding Optional Status

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.

Preserving the Empty Extension

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:

  • Use multiplied-out s-relation attributes when the result should contain one row per value combination.
  • Use set-valued result terms when the result should preserve an object while recording an attribute's whole predicate extension, including the empty extension.

Why This Matters

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.

93 Relational Model Correspondence

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.

Basic Correspondence

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.

Relation Variables as Results

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.

Projection and Restriction

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.

Join as Shared Witnesses

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.

Rename and Nested Paths

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.

Where the Correspondence Stops

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

94 Supplier/Parts Translation

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.

Base Relvars as Results

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.

Projection and Restriction

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.

Join Through Witnesses

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.

Difference and Anti-Join

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.

Universal Navigation

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.

Quantified Comparisons

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.

Where M-Relations Help

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.

95 Materialised Accessors

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 Summary

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.

Unary Set Extraction

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.

Singleton Tuple Extraction

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.

Singleton Value Extraction

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.

Set-Valued Values Are Still Values

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.

Use With M-Relations

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.

Why These Are Accessors

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.

96 Implementation Notes

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.

Implementation Freedom

An implementation is not required to materialise relation extensions. For a query result it may:

  • keep the logic frame symbolic;
  • compile the frame into an iterator or query plan;
  • materialise the s-relation extension;
  • store indexes over the underlying unary predicate extensions;
  • use m-relations or another factorised representation;
  • use finite active domains for specific materialised operations.

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.

Expansion Is the Trust Boundary

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.

Operation Obligations

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
(M ∪ N)!_H = M!_H ∪ N!_H
Pairwise meet
(M ∧ N)!_H = M!_H ⋈ N!_H
Complement
(not M)!_H = universe(H) \ M!_H
Difference
(M -- N)!_H = M!_H \ N!_H
Projection
π_A^m(M)!_A = π_A(M!_H)
Normalisation
normalize(M,H)!_H = M!_H

Normalisation

Normalisation is an implementation freedom. It may remove redundancy, simplify rectangles, and improve later operations. A normalisation pass may, for example:

  • discard m-tuples whose expansion under the heading is empty;
  • drop attributes whose value set is the whole heading domain;
  • remove an m-tuple whose expansion is contained in another m-tuple's expansion.

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.

Important Pitfalls

The finite checker and the examples in this part highlight several pitfalls.

  • Projection must account for unsatisfiable m-tuples before dropping attributes. Otherwise it can invent rows by removing the attribute whose empty value set made the rectangle empty.
  • Complement is heading-relative and domain-relative. It cannot be computed from the relation body alone.
  • Attribute-wise negation is not semantic complement. Complement requires an s-tuple to escape on at least one constrained attribute, not every constrained attribute.
  • Attribute-wise union of m-tuples is not ordinary relation union. It forms one larger rectangle, while disjunction of rectangles is usually a set of rectangles.
  • Partial expansion is not a substitute for heading-relative expansion. Losing the heading can invalidate algebraic laws that hold under a fixed heading.

Finite Law Checker

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:

  • m-lift preserves expansion;
  • m-relation union and pairwise meet commute with heading-relative expansion;
  • normalisation preserves heading-relative expansion and is idempotent in the tested finite model;
  • compact complement and compact difference match exact multiply-out definitions;
  • fixed-heading complement, difference, double complement, excluded middle, non-contradiction, and De Morgan laws hold modulo heading-relative expansion;
  • extensional projection commutes with heading-relative expansion;
  • naive projection works only under its precondition.

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.

Specification Status

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.

Part IX: Updates

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.

97 Transactions

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.

Partition Constraints

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.

Concurrency Model

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.

Read-only Constraints

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.

98 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.

99 Program Variables as Locations


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.

100 Uninitialised Locations

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 and cardinality

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.

Reads

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:

  • evaluating the location in an expression;
  • copying, moving, returning, or capturing its contents;
  • passing it as an ordinary argument;
  • allowing a composite containing an uninitialised sublocation to escape.

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

Definite initialisation and control flow

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.

Partially initialised composites

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.

No required runtime representation

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.

101 Parameter Access and Ownership

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

Read-only parameters


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.

Exclusive reference parameters


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.

Exclusive access to sublocations

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.

Owned parameters and moves


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.

Exceptional exits

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.

102 Comparison with Rust

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

Explicit ownership transfer

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.

Read-only access is the default

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.

Exclusive reference access

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.

Owned parameters

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.

Functions and procedures

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.

Definite initialisation

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.

Different design priorities

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.

103 Variable Updates

Numerical Variables


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.

String Variables


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.

Enumerated Variables


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.

Variant Variables


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.

Union Variables


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.

Operator Validity

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.

104 Constraint-Violating Updates

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.

The transaction boundary

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.

Program values and constructors

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.

Transaction state

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.

Kinds of constraint

Constraint-violation legality is distinct from the static formation rules of the language:

  • an expression or update with incompatible element types is rejected statically;
  • a statically provable cardinality incompatibility is rejected statically;
  • a possible read of an uninitialised location is rejected statically;
  • the definite-initialisation rules govern when locations may be read;
  • nominal, relational, key, cardinality, and other declared constraints on database state are required to hold at transaction boundaries.

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.

105 Control Flow


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.

Integer Ranges

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.

Blocks and Scope

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.

Iteration Sources

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.

Break and Continue


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.

Transactions

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.

106 Unreachable

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.

Standard library assertions

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.

Static control-flow effect

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.

Execution of an illegal program

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.

Relationship to other terminating statements

  • 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.
  • an uncaught exception propagates through enclosing statement contexts.
  • 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.

107 Runtime Failures and Exceptions

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.

Catchable Failures

The following failures are runtime exceptions:

  • a transaction commit whose final state violates constraints (in configurations where that is enforced);
  • a dynamic-mode type failure discovered while executing;
  • a procedure or external operation that reports a runtime failure through the Predica runtime.

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.

Try Statements

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.

108 Procedures and Effects

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.

Functions stay pure

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.

Procedure effects

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.

109 Entity Creation and Base 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.

Base Attributes

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.

Derived and Inverse Attributes

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.

Keyed Extension Updates

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:

  • reject the update because the candidate keys select conflicting existing members;
  • replace every matching member with the supplied value, thereby merging those members;
  • give one candidate key precedence for replacement, while continuing to require every candidate key to be unique in the resulting extension.

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.

110 Block-Bodied Functions

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;.

Same Function, Four Bodies

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.

Return values

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.

Local state and purity

A block body does not by itself make a function impure. A block-bodied function is pure when its body:

  • uses only its parameters, local variables, constants, read-only query expressions, and pure function calls;
  • writes only local variables allocated inside the function body;
  • does not read or write program-global variables;
  • does not write database-state locations;
  • does not perform I/O such as print;
  • does not call a procedure.

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.

Side effects belong in procedures

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

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.

111 Path Expression Updates

Location Denotations

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.

Attributes With Cardinality One


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.

Attributes With Cardinality Zero Or One


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.

Attributes With Cardinality Zero Or More


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.

Zero-Location Updates


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.

Partition Validity


// 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.

Part X: Components

112 Introduction to components

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.

Include roots

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.

Component roots

A valid component root satisfies the following rules.

  • The component root folder name must be a valid identifier and must match the component name.
  • The component root folder must contain a component manifest named _component_.predica.
  • Components cannot nest: no descendant folder may contain another component manifest.

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.

Files inside a component

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.

Optional component declarations

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.

Ordinary source-file order

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:

  1. An optional component declaration.
  2. An optional @dynamic directive.
  3. Zero or more file-scoped using declarations.
  4. All schema/library declarations.
  5. Optional top-level program statements.

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.

Encoding and file system details

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.

113 Component Manifest

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:

  1. A component declaration, when the manifest records a filesystem-location check.
  2. A releases block, when the component has schema-evolution release metadata.

Releases

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.

114 Namespaces

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;
        }
    }
}

Namespace lookup

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 inside a component

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.

115 Using Declarations

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.

Syntax


using ComponentName;
using ComponentName as Alias;
using ComponentName { QualifiedName1, QualifiedName2 as Alias2, ... };
    

File scope and ordering

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.

Component aliases

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.

Selected imports

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.
  • A selected import does not introduce an alias for the component as a whole.

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.

Visibility and qualification

  • Declarations in the same component as the current file are visible through the current namespace chain.
  • Component aliases permit qualified names such as Geometry.Point.
  • Selected imports permit unqualified names such as Point or aliases such as Disk.
  • Imported declarations are never discovered merely because another component exists under an include root.

Ambiguity of unqualified names

  • Multiple imported non-function declarations introducing the same unqualified name make that name ambiguous.
  • Imported functions with the same unqualified name are merged into one overload set.
  • An unqualified use of an ambiguous imported name is a compile-time error.
  • Component aliases and selected-import aliases with distinct local names resolve ambiguity.
  • A local declaration in the current component namespace chain wins over imported names.

Using statement stability rule

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.

116 Name Resolution

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.

Qualified names

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.

Ordinary unqualified lookup

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.

Function lookup and ADL

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:

  • the namespace containing each nominal type occurring in the type expression;
  • the namespaces containing its nominal supertypes;
  • the corresponding namespaces obtained recursively from generic type arguments and from the members of union and intersection types.

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 errors

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.

117 Schema evolution

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.

Type-local evolution

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.

Example: Point over time

Releases 0 and 1

No serialised representation of Point values existed in releases 0 and 1.

Release 2

Release 2 introduced Point with Cartesian coordinates.


value Point
{
    Float64 x;
    Float64 y;
}

Release 3

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

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

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

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); }
    }
}

Schema consistency

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 as dynamic type identifiers

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:

  • Each -name schema change refers to an attribute that has appeared in an earlier +name schema change.
  • Each -name schema change refers to an attribute that has not appeared in an earlier -name schema change.
  • A single release entry does not both add and drop the same attribute.
  • The attributes declared in the dropped block are exactly the attributes dropped by the complete schema block.
  • The current attributes declared directly in the type body are exactly the attributes that have been added using +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 evolution

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 evolution

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

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

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 dependencies

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.

Dependency graph

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.

Deployment upgrade independence

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:

  • Incremental deployment: a site running one release of a component does not need to upgrade a dependency unless that component release requires it.
  • Safe evolution: a dependency can evolve without forcing dependent components to evolve immediately.
  • Reproducibility: the system can be rebuilt at a historical release with no ambiguity.

118 Standard Library

Component layout

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.

Standard fold declarations

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.

Standard exception type

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.

Prelude lookup

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.

Third-party folds and name clashes

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.

Part XI: Normalisation

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.

119 1NF

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.

Example which violates 1NF

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.

Fixing the violation

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.

120 2NF

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.

Example which violates 2NF

The following relation has the composite key {shop, book}:

BookStock
Pages,Logic Primer,Perth Pages,Type Systems,Perth Harbour Books,Logic Primer,Sydney Harbour Books,Database Design,Sydney

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.

Writing the dependency as a constraint

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.

Fixing the violation

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:

BookStock
Pages,Logic Primer Pages,Type Systems Harbour Books,Logic Primer Harbour Books,Database Design
Shop
Pages,Perth Harbour Books,Sydney

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.

121 3NF

Informally, Third Normal Form, or 3NF, says that a non-key attribute should not depend on another non-key attribute.

Example which violates 3NF

The following relation has the key {emp_id}:

Employee
101,10,Sales 102,10,Sales 103,20,Engineering 104,20,Engineering

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.

Writing the dependency as a constraint

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.

Fixing the violation

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:

Employee
101,10 102,10 103,20 104,20
Department
10,Sales 20,Engineering

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.

122 BCNF

Informally, Boyce-Codd Normal Form, or BCNF, says that whenever a set of attributes determines some other attribute, that determinant should be a superkey.

Example which violates BCNF

The following relation records the lecturer for each student's course enrolment:

CourseEnrolment
Alice,Logic,Prof Gray Alice,Databases,Prof Chen Bob,Logic,Prof Gray Bob,Databases,Prof Chen

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.

Writing the dependency as a constraint

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.

Fixing the violation

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:

CourseEnrolment
Alice,Prof Gray Alice,Prof Chen Bob,Prof Gray Bob,Prof Chen
Lecturer
Prof Gray,Logic Prof Chen,Databases

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.

123 4NF

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.

Example which violates 4NF

The following relation records the skills and languages known by a student:

StudentCapability
Alice,Logic,English Alice,Logic,French Alice,Databases,English Alice,Databases,French Bob,Databases,English

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.

Writing the dependency as a constraint

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.

Fixing the violation

The fix is to represent the two independent many-valued facts separately. In relational form, this decomposes the original relation into two relations:

StudentSkill
Alice,Logic Alice,Databases Bob,Databases
StudentLanguage
Alice,English Alice,French Bob,English

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.

124 5NF

Informally, Fifth Normal Form, or 5NF, says that a relation should not store a fact that can be reconstructed without loss from smaller projections.

Example which violates 5NF

The following relation records which suppliers may supply which parts to which projects:

SupplyAssignment
Acme,Bolt,Bridge Acme,Nut,Bridge Acme,Nut,Tunnel BoltCo,Bolt,Bridge

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.

Writing the dependency as a constraint

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.

Fixing the violation

The fix is to store the three binary facts directly. In relational form, this decomposes the original relation into three relations:

SupplierPart
Acme,Bolt Acme,Nut BoltCo,Bolt
SupplierProject
Acme,Bridge Acme,Tunnel BoltCo,Bridge
ProjectPart
Bridge,Bolt Bridge,Nut Tunnel,Nut

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.

SKNF

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.

ETNF and RFNF

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.

125 6NF

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.

Example of 6NF decomposition

The following relation records several facts about suppliers:

Supplier
S1,Smith,20,London S2,Jones,10,Paris S3,Blake,30,Paris

A 6NF decomposition separates those facts into one relation per non-key attribute:

SupplierName
S1,Smith S2,Jones S3,Blake
SupplierStatus
S1,20 S2,10 S3,30
SupplierCity
S1,London S2,Paris S3,Paris

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.

126 DKNF

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.

A dependency not captured by domains and keys

Consider a relation that records an employee, the employee's department, and the manager of that department:

EmployeeDepartment
Alice,Engineering,Carol Bob,Engineering,Carol David,Sales,Erin

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.

The Predica interpretation

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.

Beyond relational DKNF

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.

127 Misplaced Facts

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.

Facts stored on the wrong subject

The 2NF, 3NF, and BCNF examples all had this shape:

  • The shop's city was stored on BookStock
  • The department name was stored on Employee
  • The lecturer's course was stored on 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:

  • a name
  • a type
  • a set of attributes
  • a key
  • a place in the schema

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.

Independent facts combined together

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.

Derived combinations stored as base facts

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.

Part XII: Examples

128 Geometry Example


// 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;
}
*/

129 Event Sourcing

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 are available in queries

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)

Pushing birth, death, marriage and divorce events under a person domain

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.

Global populations become derived, not base

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
}

This flips RM modelling

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:

  • Base information (specific): A marriage event belongs to the two people involved. Therefore shared marriage events are stored under Person.
  • Derived information (general): The global set of all marriages belongs to the system. So they are derived from 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.

Sharing a single marriage event between two spouses

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.

Performance advantages of pushing information downwards

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.

Folding over events

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.

Derived attributes

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:

  • queries become simpler
  • constraints become clearer
  • reasoning becomes easier
  • the model becomes more expressive

It is particular useful for event sourcing because derived attributes can hold the current state as folds over history.

Folds and Event Sourcing

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.

Reconstructing state from events

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.

Computing age from birth and death events

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.

Accumulating children, spouses, and relationships

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.

Why folds are ideal for event sourcing

  • Uniformity: Events are just values in a set; folds reduce them.
  • No special syntax: Event reconstruction uses the same fold mechanism as aggregates.
  • Declarative: The fold describes what the accumulated state is, not how to iterate.
  • Composable: Folds can be nested, combined, and reused across domains.
  • Purely functional: No mutation, no ordering assumptions, no procedural logic.

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.

130 Box of objects


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)
    );

Part XIII: Syntax

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.

131 Lexical Grammar

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.

Grammar


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.

132 Syntactic Grammar

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.

Grammar conventions

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:

  • Source files and declarations
  • Statements
  • Expressions
  • Type definition grammar

133 Source Files and Declarations

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.

Grammar in EBNF


(************** 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;


134 Statement Grammar


(************** 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 };

135 Expression Grammar


(************** 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;

Operator precedence table

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.

Index and slice 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.

Notes on ambiguity and associativity

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.

136 Type Definition Grammar


(************** 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;

Part XIV: Semantics

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.

137 Frames

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
R ⊆ AttrName × Term × Type

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 (a,E,T) triples, where:

  • a is an attribute, unique within R
  • E is a value expression
  • T is a type expression

The notation a = E:T is a mnemonic for that triple.

It gives the named, typed result of the frame.

For example:

R = { supplier = S:Supplier, _anon_ = T+10:Integer }
F
F ⊆ Var × Type × AttrName

The set of free variables of the frame, formalised as a set of (V,T,a) triples, where:

  • V is a logic variable, unique within F
  • T is a type expression
  • a is the provenance attribute

The notation V:T from a is a mnemonic for that triple.

For example:

{ S:Supplier from supplier, C:String from city }
Ψ The logic formula that constrains the frame.

This notation is a mnemonic for the triple (R,F,Ψ):

result R, for free variables F, 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₂

Attribute navigation

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
{
  supplier = S:Supplier
}
The path exposes S as a result attribute named supplier.
F
{
  S:Supplier from supplier
}
The logic variable S is free in the frame.
Ψ
s.supplier(S)
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).

Computed result term

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
{
  _anon_ = T*Q:Real
}
The frame result is the computed term T*Q, with attribute _anon_.
F
{
  T:Integer from status,
  Q:Real from rating
}
The variables T and Q occur free in the frame.
Ψ
∃S:Supplier (
  s.supplier(S)
  ∧ S.status(T)
  ∧ S.rating(Q)
)
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.

Similarity to set builder notation

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.

Accessors

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 declarations

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ₙ Ψ.
  • ∃(∅) Ψ = Ψ.
  • ∀(∅) Ψ = Ψ.

Provenance attributes

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.

Well-formed frames

A frame γ = ⟨R for F where Ψ⟩ is well formed when:

  • R is keyed by attribute name.
  • F is keyed by logic variable.
  • Every free logic variable in Ψ or in a result term is declared in F.
  • Every variable declared in F occurs free in Ψ or in a result term.
  • Bound variables do not appear in F.
  • Types are consistent between free variables, result terms, predicates, and functions.

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 Ψ.

138 Frame Display Forms

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:

  • If R contains a single anonymous binding _anon_=X:T, the short form prints just X:T.
  • If Ψ is true, the short form omits where true and prints only the result.
  • If 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.
  • If 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.

139 Frame Construction and Closure

Source environments

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)

Frame extension and materialisation

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.

Domain

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(γ).

Closure

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(γ)⟩
}

Example of closure

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 }.

140 Frame Relabelling and Merge

Relabelling

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(γ)⟩
}

Alignment and result merge

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.

141 Horizontal Frame Operations

Horizontal frame operations

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)))
  ⟩

142 Vertical Frame Operations

Vertical frame operations

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).

143 Frame Negation, Protected Comprehensions, and Unary Frames

Current-frame negation


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.

Protected comprehensions

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.

Unary frames

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.

Examples

Path R F Ψ
10
{
  _anon_ = 10:Integer
}
∅
true
s.supplier
{
  supplier = S:Supplier
}
{
  S:Supplier from supplier
}
s.supplier(S)
s.supplier.name
{
  name = N:String
}
{
  N:String from name
}
∃S:Supplier (
  s.supplier(S)
  ∧ S.name(N)
)
s.supplier.(status*rating)
{
  _anon_ = T*Q:Real
}
{
  T:Integer from status,
  Q:Real from rating
}
∃S:Supplier (
  s.supplier(S)
  ∧ S.status(T)
  ∧ S.rating(Q)
)
s.supplier.(status*rating).Σsum
{
  _anon_ = X:Real
}
{
  X:Real from _anon_
}
X = Σsum({
  T*Q : Real
  for(T:Integer, Q:Real)
  where ∃S:Supplier (
    s.supplier(S)
    ∧ S.status(T)
    ∧ S.rating(Q)
  )
})

144 Denotation Type Conventions

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ₙ.

145 Denotation Terms

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:

  • Any logic variable symbol is a term.
  • If 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:

  • constants (as 0-ary function symbols)
  • arithmetic operators like +, -, *, /.
  • user-defined functions
  • program variables

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).

146 Denotation Precedence

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

147 Attribute Cardinality Constraints and Denotation

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}.

148 Denotation Environment

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.

Environment and denotation function

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(Γ)]
}

149 Denotation of Scalar Terms and Attribute Navigation

1. Scalar terms and function application

Function application covers literals, arithmetic, comparisons, type tests, boolean connectives, ordinary function calls, symbolic constants, finite value-set literals, and references to program variables. Boolean expressions are scalar terms until a rule lifts them into the formula layer.


ℒ(Γ, P, f(E₁, ..., Eₙ))
{
    let γ₀ = currentDomain(Γ)

    for each i
    {
        let γᵢ = ℒ(Γ, P, Eᵢ)
        let (aᵢ,Cᵢ,Tᵢ) = unary(γᵢ)
    }

    let T = resulttype(f, T₁, ..., Tₙ)

    return ⟨
        { _anon_ = f(C₁,...,Cₙ):T }
        for
        free(γ₀) ∪ free(γ₁) ∪ ... ∪ free(γₙ)
        where
        formula(γ₀) ∧ formula(γ₁) ∧ ... ∧ formula(γₙ)
    ⟩
}

For a literal or nullary program symbol, this reduces to:


ℒ(Γ, P, c) =
    ⟨{ _anon_ = c:type(c) } for free(currentDomain(Γ)) where formula(currentDomain(Γ))⟩

2. Attribute lookup and navigation

An attribute name is resolved by searching frames from top to bottom. Within a frame, scalar result navigation is tried before structural lookup.

The set attributes(T) includes the attributes of entity, value, variant, and enum types. Variant and enum attributes therefore use the same navigation rule as ordinary entity and value attributes.


ℒ(Γ, P, a)
{
    for each γ in Γ, searching top first
    {
        // Nominal navigation: unary frame whose result type defines attribute a.
        if result(γ) = { x = C:Tₓ } and (a,T) ∈ attributes(Tₓ)
        {
            let V be fresh

            return ⟨
                { a = V:T }
                for
                free(γ) ∪ { V:T from a }
                where
                formula(γ) ∧ C.a(V)
            ⟩
        }

        // Structural lookup: the frame result already contains attribute a.
        if a = C:T ∈ result(γ)
        {
            return ⟨{ a = C:T } for free(γ) where formula(γ)⟩
        }
    }

    raise error(attribute a not found)
}

This rule carries the frame's formula into the resulting path frame. Later composition rules do not need a side condition saying that the left-hand frame was already used; it is already in formula(γ).

For example, with source variable s:ShipmentsSystem, nominal navigation of supplier introduces an uppercase logic variable S:


ℒ([], ∅, s.supplier)
  =
  ⟨
    { supplier = S:Supplier }
  for
    { S:Supplier from supplier }
  where
    s.supplier(S)
  ⟩

150 Denotation of Tuples, Filtering, and Composition

3. Renaming


ℒ(Γ, P, E as b)
{
    return relabel(ℒ(Γ, P, E), b)
}

4. Tuple construction

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(Γ)

5. Filtering

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.

6. Composition

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.

151 Denotation of Path Operations and Alternatives

7. Path meet, union, and difference

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 ∨.

8. Alternative operator

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.

152 Denotation of Conditionals, Negation, Folds, and Cardinality

9. Conditional 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.

10. Complement and pointwise negation


ℒ(Γ, P, E!)
{
    let γ₀ = currentFrame(Γ)
    let γ = ℒ(Γ, P, E)

    return pointNot(γ₀, γ)
}

ℒ(Γ, P, E~)
{
    if Γ = []
        raise error

    let γ₀ = currentFrame(Γ)
    let γ = ℒ(Γ, P ∪ vars(free(γ₀)), E)

    return pathComplement(γ₀, γ, P)
}

11. Fold and cardinality

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)
    ⟩
}

153 Denotation of Absence, This, Splat, Let, and Constraints

12. Absence

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.

13. 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(γ)
    ⟩
}

14. Let


ℒ(Γ, 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.

15. Constraints

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

Design consequences

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 Ψ.
  • The current frame and current domain are distinct views of the same top frame. Rules that need the current result value are based on the current frame rather than domain.
  • Universal composition can be factored through bind∀, which selects variables satisfying the precondition of close∀.
  • Filter, complement, fold, and cardinality rules show why protected variables are more than a control flag. A protected variable that remains free in the returned formula or term is retained in the returned frame's free variables.
  • Tuple construction uses a stricter result merge than path-level meet and union. Duplicate result attributes and duplicate implicit result terms are rejected after attribute-set expansion, while deliberate aliases may expose the same variable under two explicit names.
  • Path difference is written with source syntax --, 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.
  • The right-hand frame in composition already contains the left-hand conditions that were available while the right-hand expression was translated.

154 Predicates Induced by Variants and Enums

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

            s.circle
        

            circle=C:Circle 
            where s.circle(C)
        

            s.circle.radius
        

            radius=R:Real
            where 
              ∃C:Circle
                s.circle(C) 
              ∧ C.radius(R)
        

Using attributes of the empty structural type in boolean contexts

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)).

Enumerated types


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]

155 Denotation Examples

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
s.supplier
supplier = S:Supplier
s.supplier(S)
s.supplier.name
name = N:String
∃S:Supplier
  s.supplier(S)
  ∧ S.name(N)
s.shipment.(
  supplier.name as sname,
  part.name as pname,
  qty
)
sname = SN:String,
pname = PN:String,
qty = Q:Integer
∃SP:Shipment
∃S:Supplier
∃P:Part
  s.shipment(SP)
  ∧ SP.supplier(S)
  ∧ S.name(SN)
  ∧ SP.part(P)
  ∧ P.name(PN)
  ∧ SP.qty(Q)
s.supplier.
  (status*rating)
_anon_ = T*Q:Real
∃S:Supplier
  s.supplier(S)
  ∧ S.status(T)
  ∧ S.rating(Q)

Prism coloring

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*;
}
Prism Shape
Path Result Formula
i.object.
  shape.prism
prism = P:Prism
∃O:Object
∃S:Shape
  i.object(O)
  ∧ O.shape(S)
  ∧ S.prism(P)
Red Or Green Prisms
Path Result Formula
i.object.[
  shape.prism.Σexists
  ∧ (color.red
     ∨ color.green)
]
object = O:Object
i.object(O)
∧ ∃S:Shape
  ∃P:Prism
  O.shape(S)
  ∧ S.prism(P)
∧ ∃C:Color
  O.color(C)
  ∧ (
    ∃U:{} C.red(U)
    ∨ ∃V:{} C.green(V)
  )

156 Examples of correspondence between Predica and logic

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

            s.supplier
        
supplier=S:Supplier
where
  s.supplier(S)

            s.supplier.[city="London"]
        
supplier=S:Supplier
where
  s.supplier(S)
  ∧ ∃C:String
    S.city(C)
    ∧ C = "London"

            s.supplier.[city="London"].name
        
name=N:String
where
  ∃S:Supplier ∃C:String
    s.supplier(S)
  ∧ S.city(C)
  ∧ C = "London"
  ∧ S.name(N)

        s.shipment.(
            supplier.name as sname, 
            part.name as pname, 
            qty)
        
sname=SN:String, 
pname=PN:String, 
qty=Q:Integer
where
  ∃SP:Shipment ∃S:Supplier ∃P:Part
    s.shipment(SP)
  ∧ SP.supplier(S)
  ∧ S.name(SN)
  ∧ SP.part(P)
  ∧ P.name(PN)
  ∧ SP.qty(Q)

        s.supplier.(
            name as sname,
            shipment.(part.name as pname, qty))
        
sname=SN:String, 
pname=PN:String, 
qty=Q:Integer
where
  ∃S:Supplier ∃SP:Shipment ∃P:Part
    s.supplier(S)
  ∧ S.name(SN)
  ∧ S.shipment(SP)
  ∧ SP.part(P)
  ∧ P.name(PN)
  ∧ SP.qty(Q)

        s.supplier.[shipment.Σexists]
        
supplier=S:Supplier
where
  s.supplier(S)
  ∧ ∃SP:Shipment
    S.shipment(SP)
Suppliers that don't ship any parts (anti-join)

        s.supplier.[shipment.Σempty]
        
supplier=S:Supplier
where
  s.supplier(S)
  ∧ ¬∃SP:Shipment
    S.shipment(SP)
suppliers where all parts they ship are red

        s.supplier.[shipment.part.color.Σ ⊆ {Color.red}]
        
supplier=S:Supplier
where
  s.supplier(S)
  ∧ { C:Color | ∃SP:Shipment ∃P:Part
      S.shipment(SP)
      ∧ SP.part(P)
      ∧ P.color(C) } ⊆ {Color.red}

            s.supplier.[city="London"].(name,status)
        
Alternatively

            s.supplier.[city="London"].(name ⊓ status)
        
name=N:String, 
status=T:Integer
where
  ∃S:Supplier ∃C:String
    s.supplier(S)
  ∧ S.city(C)
  ∧ C = "London"
  ∧ S.name(N)
  ∧ S.status(T)

            s.supplier.[city="London"].(name ⊔ status)
        
name=N:String, 
status=T:Integer
where
  ∃S:Supplier ∃C:String
    s.supplier(S)
  ∧ S.city(C)
  ∧ C = "London"
  ∧ (
    S.name(N)
    ∨ S.status(T)
  )

            s.supplier.[city="London"].(name, status!)
        
name=N:String, 
status=T:Integer
where
  ∃S:Supplier ∃C:String
    s.supplier(S)
  ∧ S.city(C)
  ∧ C = "London"
  ∧ S.name(N)
  ∧ ¬S.status(T)
  • s.supplier.city ⊔ s.part.city
  • s.supplier.city ∪ s.part.city
  • s.(supplier.city ⊔ part.city)
  • s.(supplier.city ∪ part.city)
city=C:String
where
  (∃S:Supplier
    s.supplier(S)
    ∧ S.city(C))
  ∨ (∃P:Part
    s.part(P)
    ∧ P.city(C))

            s.(  supplier.name
               ⊔ part.[color=Color.red].name)
        
name=N:String
where
  (∃S:Supplier
    s.supplier(S)
    ∧ S.name(N))
  ∨ (∃P:Part
    s.part(P)
    ∧ P.color(Color.red)
    ∧ P.name(N))

            s.(  supplier.(name, city)
               ⊔ part.(name, city) )
        
name=N:String, 
city=C:String
where
  (∃S:Supplier
    s.supplier(S)
    ∧ S.name(N)
    ∧ S.city(C))
  ∨ (∃P:Part
    s.part(P)
    ∧ P.name(N)
    ∧ P.city(C))

        s.supplier.[shipment.Σ = ∅]
      
supplier=S:Supplier
where
  s.supplier(S)
  ∧ {SP:Shipment | S.shipment(SP)}=∅

        s.supplier -- s.shipment.supplier
      
supplier=S:Supplier
where
  s.supplier(S)
  ∧ ¬(
    ∃SP:Shipment
      s.shipment(SP)
      ∧ SP.supplier(S)
  )

        s.part⇒(shipment.supplier)
      
supplier=S:Supplier
where
  ∀P:Part
    s.part(P)
    → ∃SP:Shipment
      P.shipment(SP)
      ∧ SP.supplier(S)

        s.supplier.[shipment.part.Σ = s.part.Σ]
      
supplier=S:Supplier
where
  s.supplier(S)
  ∧ { P:Part | ∃SP:Shipment
      S.shipment(SP)
      ∧ SP.part(P) }
    = { P:Part | s.part(P) }

157 Denotation of Predica Locations

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.

States, Occurrences, and Addresses

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 Frames

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.

Reading Locations

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.

Basic Raw Location Rules

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.

Variables

RawLoc(Τ, [], ∅, s)
  =
⟪ { _anon_ = VarLoc(env, s):ShipmentsSystem }
  for ∅
  where true ⟫

Attribute locations

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.

Composition

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.

Structured targets

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.

Assignability

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 and State Transitions

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.

Assignment and Compound Updates

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.

Supplier and Part Sharing Example

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.

Part XV: Implementation

158 Data representation

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.

Mapping Predica types to structures

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;
};

Using fixed-size arrays

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;
};

Using B-trees

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:

  • internal pointers;
  • merging, splitting, and rotating nodes;
  • binary search within nodes;
  • lookup by key;
  • iteration over a given key range;
  • set operations such as intersection, union, and difference, implemented using an approach resembling the merge step of merge sort;
  • lazy loading of nodes from secondary storage; and
  • tracking dirty nodes whose in-memory contents differ from the version on disk and which therefore need to be written back.

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.

Choosing collection representations

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; and
  • std::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.

Mapping types to objects with identity

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 = &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.

Lazy loading from secondary storage

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.

Mapping quotient types

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.

Column-oriented representations

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 and deserialisation of in-memory structures

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.

  • Blobs that are too small incur excessive I/O latency and metadata overhead.
  • Blobs that are too large cause read and write amplification when only a small part of the blob is needed or changed.

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.

159 Persistent Object Store

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.

Stream access

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 treats bytes as opaque

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:

  • the in-memory type of an object;
  • the binary encoding of strings, vectors, optionals, and variants;
  • which fields contain OIDs;
  • the schema version of an encoded object; and
  • how an older schema version is upgraded when it is read.

Serialisation and object granularity

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.

Schema versions and evolution

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 server owns the database

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.

Partitions and single mutators

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.

Log-structured renditions

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 persistent OID map

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.

Versioned OID maps

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.

Checkpoint roots and transaction deltas

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.

Snapshots across partitions

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.

Persistent garbage collection

There are two distinct reachability problems.

Application-object reachability

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.

Storage-version reachability

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.

Division of responsibilities

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

160 Snapshot Isolation

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.

One logical operation, one snapshot

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.

Transaction views

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.

Derived attributes and caches

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.

Snapshot lifetime

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.

161 CEDA MVCC Storage

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 log is the store

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.

Segments, serial elements, and packets

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

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.

Checkpoints and recovery

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.

Cleaning and segment reuse

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.

Published RPM snapshots

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.

LSS read and write transactions

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.

Persistent spaces

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.

Ownership within a PSpace

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.

The mutable writer ROT

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.

Snapshot resident-object tables

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.

Publishing a PSpace snapshot

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.

162 Implementing the Genealogy Example

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.

Representation

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.

Logical keys and physical identity

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.

Shared events

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.

Navigation through the object graph

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; and
  • each relationship event contains the OIDs of both spouses.

The 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.

Wide entities as navigation hubs

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.

A typed object graph is not a generic graph database

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.

Contrast with a relational representation

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.

Lifetime in the genealogy database

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.

Generated implementation

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.

163 Implementing a Triangulated Mesh

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.

Logical schema


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.

Dense indexed representation

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.

Structure of arrays

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.

Sequential triangle algorithms

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.

Derived adjacency indexes

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.

Half-edge navigation without pointers

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.

Persistence granularity

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.

Contrast with pointer chasing and relational joins

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.

164 Annotator

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.

Mirrored source output

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;

What is annotated

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:

  • schema constraints, including constraints written in constraints blocks and constraints attached to attributes;
  • derived attributes and other derived variables whose value is defined by an expression;
  • query or expression statements used as examples;
  • update statements, where the left-hand side has a location denotation and the right-hand side has a value denotation.

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.

Constraint annotations

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.

Derived attribute and variable annotations

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.

Statement and update annotations

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.

Inlining and normalisation policy

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;

165 Optimisation

Implementation Note: Entity Value Storage and Reference Resolution

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:

  1. Populate entity sets
    All top-level entity sets (for example, supplier*, part*) are constructed first and indexed by their keys.
  2. Resolve entity references
    Expressions such as ..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 are not visible to the user;
  • they do not participate in equality;
  • they do not define identity;
  • they do not appear in literals or queries.

They exist solely to support efficient lookup and value sharing. Predica’s semantics remain purely value-based, with identity determined only by keys.

Optimisation using the statically declared cardinality constraints

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.

166 Physical independence

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:

  • predicates
  • constraints
  • derived attributes
  • fold semantics
  • evolution rules
  • component provenance

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:

  • materialise derived attributes
  • compute them on demand
  • cache them
  • index them
  • denormalise them
  • store them in columnar or document form
  • represent database-state locations as in-memory objects or slots
  • track dirty objects or dirty pages inside a transaction
  • persist committed changes through a log-structured store, write-ahead log, or snapshot format
  • replay events or maintain snapshots

167 Chunk size

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

168 Query Optimisation

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.

Compiling path expressions directly

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.

Typed navigation and relational query planning

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.

Representation follows the type structure

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.

Multiple directions without a single hierarchy

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.

Multiple access directions have a cost

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.

What physical independence means here

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.

Why some schema changes can remain local

Predica’s claims about resilience to schema changes do not follow from nesting alone. Several other properties of the language contribute:

  • Entities belong to logical sorts. A 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.
  • Keys are constraints rather than identity plumbing. A key can support lookup and indexing without requiring every relationship and query to copy and compare the key attributes.
  • Relationships are typed attributes. shipment.part denotes a Part; the query need not explicitly equate Shipment.part_id with Part.id.
  • Cardinality constraints are local. They are declared on the relevant attribute instead of being propagated through foreign-key columns and join conditions in many parts of an unnested schema.
  • Inverse and derived relationships can expose additional directions. They need not introduce an independent source of truth.
  • Collection representations are replaceable. A vector can be replaced by a B-tree, hash map, or persistent collection without changing the logical declaration or the paths that use it.

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 planning and typed path optimisation

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.

Selection as join

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 ⊓.

Join as the central optimisation primitive

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:

  • Reorder joins: use associativity and commutativity to choose a join order that minimises intermediate result sizes.
  • Push constraints down: treat selections as joins with constraint tuples and move them as close as possible to base relations.
  • Combine constraints: merge multiple constraint tuples into a single tuple using join, avoiding repeated passes over the data.

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 and cardinality reduction

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.

Using expand in optimisation

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 and conflict-aware pruning

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.

Equivalence and rewrite rules

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.

169 Reference Implementation

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.

Performance summary

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.

Source files

Stable immutable source buffers

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.

Line and column work is kept out of the lexer

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.

Lexer

A token is already in its retained representation

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().

Capacity is estimated and reused

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.

Common bytes take short paths

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.

Assertions support strong hot-path assumptions

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.

Abstract Syntax Tree

Named integer indexes replace pointers

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.

Common nodes are fixed-size records

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);

A heap stores variable-sized AST data

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;
};

Source provenance is compact and external

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;
};

Parser

The public API makes allocation reuse possible

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 writes directly into the final arena

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_;
};

Sentinel tokens remove repeated bounds branches

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.

32-bit overflow is checked outside hot paths

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.

Errors store facts, not formatted messages

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.