Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Properties and Relations

Concepts on their own describe a vocabulary. To say what holds between things in that vocabulary, you need properties (data attached to a concept) and relations (links between concepts).

In Argon, properties are concept fields — we have already seen them. Relations are a separate item kind, with two surface forms. This chapter teaches both, plus multi-valued fields, the relator pattern, and refinement-typed fields. The relator pattern uses UFO’s relator metatype, declared in UFO’s prelude with the substrate forms from Chapter 2.1.

Single-valued fields

We have written these. The shape is familiar:

pub kind Person {
    id: String,
    name: String,
}

Each field declares a name and a type. Types can be primitives (Nat, Int, Real, Decimal, String, Bool, Date, DateTime, Duration, Money — all in std::math) or other concepts (Tenant, Lease, Property).

A field of concept type holds a reference to an instance — Argon handles the indirection automatically. There is no notion of borrowing or ownership; fields read like values.

Multi-valued fields

Sometimes a concept needs to hold more than one of something — a property with several rooms, a lease with several tenants. Use the collection-type syntax [T]:

pub kind Property {
    id: String,
    address: String,
    rooms: [Room],
}

rooms: [Room] reads “an unordered collection of Room.” Argon does not commit you to a list-versus-set semantic at the surface; the runtime stores collections without duplicates by identity.

Cardinality constraints

A bare [T] admits any count, including zero. To pin the count, add a cardinality clause:

pub relator Lease {
    tenants: [Tenant; >= 1],
    landlord: Landlord,
    property: Property,
    start_date: Date,
    end_date: Date,
    monthly_rent: Money,
}

The >= 1 constraint says “at least one tenant.” Argon admits three operators in this position:

OperatorMeaning
>= NAt least N
== NExactly N
<= NAt most N

There is no < or >. If you find yourself wanting strict inequality, use >= N + 1 or <= N - 1 and pre-compute the bound, or model the constraint as a refinement type instead (see Chapter 2.6).

Tip: put the cardinality constraint on the field, not in a separate assert. The compiler can use the field-level cardinality during type-checking and reasoning; an assert that re-states the same constraint is redundant and will be flagged as such.

Examples

pub kind Square {
    sides: [LineSegment; == 4],
}

pub kind Committee {
    chair: Person,
    members: [Person; >= 3],
}

pub kind OptionalAddress {
    line_one: String,
    line_two: [String; <= 1],
}

Relations

Argon has a dedicated item kind, rel, for binary relations whose presence at the call site you want to be explicit. There are two shapes.

Binary form

pub rel HasResidence: Person -> Residence

A binary relation between two concepts. The arrow is ->. The relation name reads as a predicate at rule positions:

pub derive ActiveResident(p: Person, r: Residence) :-
    HasResidence(p, r),
    r.is_occupied

Binary relations can also carry attributes via a body:

pub rel Owns: Person -> Asset {
    acquired_on: Date,
    purchase_price: Money,
}

Treat the attached attributes as fields on the relation instance.

A worked example with decorator characteristics, from argon/examples/pizza/src/prelude.ar (the Manchester pizza ontology, ported faithfully):

// Transitive ingredient inclusion: every Margherita is a Pizza,
// every Pizza is a Food, every Food has ingredients, transitively.
@[transitive]
pub rel hasIngredient: Food -> Food

// Functional + asymmetric base: every Pizza has exactly one PizzaBase,
// and a base never "has" a pizza.
@[functional]
@[asymmetric]
pub rel hasBase: Pizza -> PizzaBase

// Functional spiciness: a topping has at most one Spiciness value.
@[functional]
pub rel hasSpiciness: PizzaTopping -> Spiciness

The pizza workspace exercises five relation characteristics — @[transitive], @[symmetric], @[asymmetric], @[functional], @[inverse-functional] — across class lattices with multiple inheritance. Read it end-to-end as a 200-line tour of relation-shape modeling without a foundational-ontology dependency. The argon/examples/wine/, argon/examples/skos/, and argon/examples/foaf/ workspaces show the same relation patterns at different domains.

Reified form

When a relation is rich enough that “binary plus attributes” feels strained — for instance, when the relation has its own subtypes, or participates in further relations — promote it to a concept of its own:

pub relator ResidentialLease : Lease {
    bedrooms: Int,
}

This is precisely the relator pattern from Chapter 2.2. The relator is a concept; its participants are fields on it; its identity is the tuple of its participants over a time interval.

The choice between rel and relator is mostly pragmatic: use rel when the relation is genuinely binary and lightweight; use a relator concept when it has substantial structure of its own. The lease in our running example is a relator because it carries dates, rent, subtypes, a lifecycle, and so on.

Composition

Binary relations can declare a composition chain over other roles:

pub rel composes_to: Agent -> Resource = role1.role2

The = a.b clause says composes_to is the composition of a and b — a derived relation, not a primitive one. We use this rarely in the tutorial; it is most useful when integrating with a foundational ontology that ships role compositions.

Inverses

If you want both directions of a relation explicitly, declare them both:

pub rel TenantOf: Tenant -> Lease
pub rel HasTenant: Lease -> Tenant

A derive rule can keep them synchronized:

pub derive HasTenant(l: Lease, t: Tenant) :- TenantOf(t, l)

Argon does not have a built-in inverse keyword. The pattern above is the idiom; it is explicit, which means the type-checker and reasoner both see the bidirectional structure.

Refinement types and field invariants

Argon admits refinement types — sub-types carved by a predicate over the metatype calculus. They live on a concept-decl’s where { ... } clause and are used at field positions like any other type:

pub subkind RigidLease : Lease where { A.rigidity == rigid }

pub kind LeaseAccount {
    lease: RigidLease,
    balance: Money,
}

The body of a refinement type is a conjunction of axis-predicates — A.rigidity == rigid, A.sortality == sortal, etc. — over the meta-property calculus, not instance-level field comparisons. The compiler proves this fragment decidable; Chapter 2.6 covers the surface in full.

Instance-level invariants — “monthly rent must be positive,” “grace days must be between zero and fourteen” — live on assert items in Chapter 2.4, not on field declarations. The two mechanisms compose: a refinement type narrows by metatype; an assert narrows by instance value.

Note: the refinement fragment is decidable in PTIME with respect to the size of the concept hierarchy and axis count. Membership uses Kleene’s strong three-valued semantics () under the open-world assumption with the Pietz-Rivieccio “Exactly True” designation: unknown does not satisfy refinement membership; only true does. We will revisit the OWA implications in Chapter 2.6.

The Top type

Sometimes a field can hold “anything.” Argon’s top type is (the Unicode character; the ASCII alias Top parses too):

pub kind Annotation {
    target: ⊤,
    note: String,
}

A field of type accepts any concept instance. The trade-off is the obvious one: refinement, dispatch, and reasoning on the field are limited because the compiler does not know what to assume about it. Use sparingly — for genuinely heterogeneous data like comment annotations or metadata blobs.

Putting it in the running example

Update src/lease.ar to use cardinality and refinement:

use metatypes::*
use party::*

pub kind Property {
    id: String,
    address: String,
}

pub relator Lease {
    id: String,
    tenant: Tenant,
    landlord: Landlord,
    property: Property,
    start_date: Date,
    end_date: Date,
    monthly_rent: Money,
}

pub subkind ResidentialLease : Lease {
    bedrooms: Int,
}

pub subkind CommercialLease : Lease {
    permitted_use: String,
}

For the tutorial we keep the lease single-tenanted (one Tenant, not [Tenant; >= 1]); a richer model would relax that. The refinement on monthly_rent will land in Chapter 2.6, where we introduce the dedicated refinement-type item.

$ ox check
   Checking lease-tutorial v0.1.0
    Finished in 0.10s

The model still compiles. We have a small but well-typed structural skeleton. Next chapter: rules.

Edge cases worth knowing

  • Cardinality versus comparison. The >= / == / <= inside [T; …] are cardinality operators, separate from value-comparison operators of the same spelling. The compiler tracks them in different enums precisely so this distinction is visible. You will not accidentally cross them.
  • Underscore separators in counts. [String; >= 1_000_000] works; underscores are stripped before the integer parses.
  • Multi-supertype fields. A field whose type is LawyerTenant (a concept with multiple supertypes) carries the closure of all its supertypes’ fields. The compiler enforces field-name uniqueness across the closure; if two parents declare a field with the same name and different types, you get a type error and must either rename or override.
  • Cyclic field types are fine. Person { friends: [Person] } works. The compiler builds a graph, not a tree.
  • No anonymous concepts in field positions. A field of type { name: String } (an inline record) is not admitted; declare a named concept instead.

Summary

Fields attach data to concepts; multi-valued fields use [T] with optional cardinality clauses; relations come in binary (rel name: A -> B) and reified (relator) shapes; refinement-typed fields sharpen the field’s type with a boolean predicate. The lease tutorial now has properties and relations enough to model a typical residential agreement. What it lacks is rules: the engine that turns this static structure into derived facts. That is the next chapter.