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

Introduction

What is Dolfin?

Dolfin is a language for modelling knowledge.

You use it to describe the concepts in a domain, the relationships between them, the constraints they must satisfy, and the rules that can be inferred from them. The result is an ontology: a precise, human-readable definition of what exists in your world and how the pieces fit together.

Dolfin is not tied to any particular storage backend or runtime. The model you write is independent of whether your data lives in a graph database, a relational database, a document store, or something else entirely. Dolfin describes what your data means; the implementation decides where and how it is stored and queried.

This separation is what makes ontologies powerful. The same model can drive validation in one system, feed a reasoning engine in another, and serve as shared vocabulary across a team, all without being rewritten.

What is an ontology?

The word ontology comes from philosophy. It’s the study of what exists. In software, an ontology is a formal description of the concepts in a domain and the relationships between them.

Think of it like a schema, but with more expressive power:

A database schema can say things like …A Dolfin ontology can also say…
An animal has a name columnAn animal must have exactly one name
An appointment has a vet_id foreign keyAn intern cannot treat an emergency
_An unvaccinated animal is automatically flagged
_Animal in this model is the same concept as fao:Animal in the global species registry

Dolfin lets you express all of that in a clean, readable file.

What you will build

This tutorial follows Dr. Helen Portbridge as she designs the data model for her new veterinary clinic, Happy Paws. Over fourteen chapters, starting from a blank file, you will build a complete ontology that:

  • Describes animals, owners, appointments, and veterinary staff
  • Enforces rules like “a surgeon must be on call for any surgery”
  • Automatically flags at-risk or overdue patients
  • Connects to external registries using standard IRIs

By the end, you will have touched every major feature of the language.

What Dolfin looks like

Here is a small taste. Don’t worry about the details, each piece will be introduced step by step.

Video Part 2 · Modelling in Dolfin, full episode. Watch on PeerTube

concept Animal:
  has name: one string
  has species: one Species
  has owner: optional Owner
  has vaccinations: Vaccination

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

rule flag_unvaccinated:
  match:
    ?animal a Animal
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a UnvaccinatedAnimal

Dolfin is designed to be readable without training. A domain expert, a developer, and a data architect can all look at the same file and understand it.

How to read this tutorial

The tutorial is structured as a story. Each chapter opens with a short scene from the clinic, poses a new modelling problem, introduces the Dolfin feature that solves it, and ends with a prompt for the next problem.

You can read it cover to cover, or use it as a reference. The Reference section at the end is a complete description of the language.

Ready? The clinic opens in Chapter 1.

Chapter 1: Opening Day

Chapter 1: Opening Day

Dr. Helen Portbridge had been dreaming of this day for years. The sign on the door read Happy Paws Veterinary Clinic, the smell of fresh paint still lingered, and the reception desk was empty except for a brand-new laptop. Before any patient walked in, she needed a system. A way to describe every animal, every owner, every appointment that would ever pass through these doors.

She opened a text editor and typed:


Every Dolfin project begins with a package. Think of a package as the identity card of your ontology: its name, its version, who made it, and what it’s for.


package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

Let’s unpack this line by line.

The package name

package <http://happypaws.com/clinic>:

The name uses IRI-notation. If Dr. Portbridge later builds a separate ontology for her research lab, she could call it http://happypaws.com/research and there would be no collision.

The colon (:) at the end is important. It opens an indented block. Everything that belongs to this package declaration must be indented underneath it, exactly like Python.

Metadata

  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"
FieldRequiredWhat it does
dolfin_version✅Which version of the Dolfin language to use
version✅Your ontology’s own version (semver)
author❌A human name
description❌A sentence explaining the purpose

dolfin_version "1" tells the parser which grammar to expect. Right now there is only version 1, but including it means your file will still work when the language evolves.

Try it

Change the author to your own name and hit Check:


package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

Common mistakes

Forgetting the colon:


package <http://happypaws.com/clinic>
  dolfin_version "1"

Dolfin will tell you: “Did you forget a : after the package name?”

Inconsistent indentation:


package <http://happypaws.com/clinic>:
  dolfin_version "1"
    version "0.1.0"   # ← too deep!

All lines inside a block must be at the same indentation level.


Where it lives

A Dolfin project is a folder. package.dlf holds the package declaration above and nothing else. Concepts, properties, rules and facts go in other .dlf files next to it, and the path of each file decides its namespace: a file clinic.dlf in the package http://happypaws.com/clinic lives in http://happypaws.com/clinic/clinic. From the next chapter on, every listing is labelled with the file it belongs in, package.dlf or clinic.dlf.


Dr. Portbridge saved the file as package.dlf. A package with no concepts is like a clinic with no exam rooms, technically it exists, but it can’t do anything yet. She needed to describe the things that would populate her world.

Chapter 2: Meet the Animals

The first patient arrived before the furniture did: a nervous-looking golden retriever named Biscuit, dragged in by an equally nervous owner. Dr. Portbridge grabbed a pen and a napkin and started writing down what she needed to know. Name? Biscuit. Owner? Some guy. Species? Dog. Age? Maybe five?

She looked at the napkin and thought: “I can do better than this.”


A concept is the core building block of any Dolfin ontology. It describes a category of things, not a specific individual, but the shape that all individuals of that kind share.

Your first concept


concept Animal:
  has name: string
  has species: string
  has age: int

This says three things:

  1. There is a concept called Animal.
  2. An Animal can have a name, which is text.
  3. An Animal can have a species (also text) and an age (a whole number).

The keyword has introduces an attribute, a piece of data that instances of this concept can carry. After has comes the attribute name, then a colon, then its type.

No constraints yet. Right now, every attribute has cardinality “any”: zero values, one value, or fifty values are all legal. That’s intentional for a first sketch, but it does mean nothing prevents an Animal with no name or three species. In a later chapter, we’ll learn how to say “exactly one name” or “at most one owner.”

Primitive types

Dolfin ships with four primitive types:

TypeWhat it holdsExamples
stringText"Biscuit", "cat"
intWhole numbers42, 0, -3
floatDecimal numbers3.14, 36.6
booleanTrue or falsetrue, false

Adding the owner

An animal doesn’t walk into a clinic alone (well, cats might). We need an owner:


concept Owner:
  has firstName: string
  has lastName: string
  has phone: string

Concepts can reference other concepts

Here’s the interesting part. An attribute’s type doesn’t have to be a primitive, it can be another concept:


concept Animal:
  has name: string
  has species: string
  has age: int
  has owner: Owner

has owner: Owner means: an Animal can be linked to an Owner. Not to a string containing the owner’s name, to the actual Owner concept, with all its attributes. This is how you build a graph of interconnected data, not just flat tables.

The story so far

Here’s what we have after this chapter:


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Animal:
  has name: string
  has species: string
  has age: int
  has owner: Owner
  
concept Owner:
  has firstName: string
  has lastName: string
  has phone: string

Try it

Add a weight attribute (as a float) to the Animal concept:


concept Animal:
  has name: string
  has species: string
  has age: int
  has owner: Owner
  
concept Owner:
  has firstName: string
  has lastName: string
  has phone: string

Dr. Portbridge looked at the model and felt a little proud. But as she started typing in Biscuit’s details, she realized something: an appointment isn’t just an animal and an owner. It has a date, a reason, a diagnosis. The animal and the owner exist before the appointment and after it. She needed something to represent the visit itself.

Chapter 3: The Appointment Book

By noon, Dr. Portbridge had seen three patients. She’d scribbled notes on separate napkins, but already she couldn’t remember whether the cat with the limp came before or after the parrot with the cough. She needed a concept for the visit itself, something that ties an animal, a date, and a reason together.


Modeling the appointment


concept Appointment:
  has animal: Animal
  has `date`: string
  has reason: string
  has diagnosis: string
  has treatment: string

Notice that animal is of type Animal, a reference to the concept we defined earlier. This is how relationships emerge naturally in Dolfin. You don’t need a separate “relationship” syntax for simple ownership; has does the job.

Why is date a string? Just to keep this chapter to the basics. Dolfin does have real temporal types (date, time, date_time, and duration) that understand calendars and clocks. We’ll upgrade this string to a proper date_time in Chapter 9: Marking Time.

Standalone properties

So far, we’ve defined relationships inside concepts using has. But some relationships are important enough to deserve their own definition, especially when they could apply to multiple concepts or when you want to give them metadata.

A property is a standalone, reusable relationship:


property treatedBy: Animal -> string

This reads: “treatedBy is a relationship from Animal to a string.” The arrow (->) separates the domain (what has the property) from the range (what values it takes).

That string is a placeholder. In reality, we’d want a Veterinarian concept. Let’s make one:


concept Veterinarian:
  has name: string
  has licenseNumber: string

property treatedBy: Animal -> Veterinarian

Now treatedBy is a first-class relationship between Animal and Veterinarian, defined separately from either concept.

When to use has vs property

Use caseUse hasUse property
Simple attribute (name, age)✅❌
Core part of a concept’s identity✅❌
Relationship shared across concepts❌✅
Relationship you want to annotate or reason about❌✅

Both compile to OWL properties. The difference is readability and intent.

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Animal:
  has name: string
  has species: string
  has age: int
  has owner: Owner
  
concept Owner:
  has firstName: string
  has lastName: string
  has phone: string

concept Appointment:
  has animal: Animal
  has `date`: string
  has reason: string
  has diagnosis: string
  has treatment: string

concept Veterinarian:
  has name: string
  has licenseNumber: string

property treatedBy: Animal -> Veterinarian

Try it

Add a standalone property owns that goes from Owner to Animal. Then add a property scheduledWith from Appointment to Veterinarian:


concept Animal:
  has name: string
  has species: string
  has age: int
  has owner: Owner
  
concept Owner:
  has firstName: string
  has lastName: string
  has phone: string

concept Appointment:
  has animal: Animal
  has `date`: string
  has reason: string
  has diagnosis: string
  has treatment: string

concept Veterinarian:
  has name: string
  has licenseNumber: string

property treatedBy: Animal -> Veterinarian

# Add your properties here

The appointment book was taking shape. But as Dr. Portbridge typed species: string for the tenth time, she winced. “Dog” could be typed as “dog”, “Dog”, “DOG”, “canine”, or “golden retriever”. A free-text field was an invitation for chaos. She needed a fixed list.

Chapter 4: Species and Breeds

The receptionist had entered “dgo” as the species for a patient. Then “Feline (domestic shorthair)”. Then just “bird”. Dr. Portbridge realized that a string field for species was a liability, it offered infinite freedom where she needed a closed list.


The problem with strings

When a field accepts any string, you get inconsistency:

  • "Dog" vs "dog" vs "Canine"
  • "Cat" vs "Feline" vs "feline (domestic)"

Queries break. Reports are nonsense. You need a controlled vocabulary.

Closed concepts

A closed concept defines a fixed, exhaustive set of allowed values:


concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

Now replace the string in Animal:


concept Animal:
  has name: string
  has species: Species
  has age: int
  has weight: float
  has owner: Owner

species can only be one of the six values listed in the enum. No typos, no ambiguity, no “dgo”.

Video 2.1 · The Dolfin editor, chapter “Closed lists with one of” (0:10–0:25). Watch on PeerTube

Multiple closed concepts

The clinic also needs to categorize appointment urgency:


concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

And appointment status:


concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

Now Appointment becomes:


concept Appointment:
  has animal: Animal
  has `date`: string
  has reason: string
  has urgency: Urgency
  has status: AppointmentStatus
  has diagnosis: string
  has treatment: string

Enums vs concepts

Enums and concepts are very different things:

EnumConcept
InstancesFixed at design timeCreated at runtime
Has attributes❌✅
Can be extended❌ (closed list)✅ (via sub)
Use caseDropdowns, categoriesReal-world entities

Use enums for things that won’t change or change rarely (statuses, categories, units). Use concepts for things that live and breathe (patients, people, appointments).

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: string
  has lastName: string
  has phone: string

concept Veterinarian:
  has name: string
  has licenseNumber: string

concept Animal:
  has name: string
  has species: Species
  has age: int
  has weight: float
  has owner: Owner

concept Appointment:
  has animal: Animal
  has `date`: string
  has reason: string
  has urgency: Urgency
  has status: AppointmentStatus
  has diagnosis: string
  has treatment: string

property treatedBy: Animal -> Veterinarian

Try it

Add an enum PaymentMethod with values Cash, Card, Insurance, and Pending. Then add a payment attribute to Appointment:


concept PaymentMethod:
  one of:
    Cash

concept Appointment:
  has animal: Animal
  has `date`: string
  has reason: string
  has urgency: Urgency
  has status: AppointmentStatus

The enums solved the typo problem. Then a cardboard box appeared on the exam table. Inside was a tiny stray kitten, no collar, no owner, no known age. Dr. Portbridge named her Pixel on the spot and tried to enter her into the system: species Cat, name “Pixel”, and… that was it. No owner to reference, no age to record. The system accepted Pixel without complaint. In fact, it would have accepted an Animal with no name at all, or one with five species. Every attribute had cardinality “any”: no minimum, no maximum, no constraints whatsoever. That was fine for a first sketch, but now she needed precision. “Every animal MUST have exactly one name. An owner might not have an email. A phone number is required.” She needed cardinality.

Chapter 5: Tightening the Rules

Dr. Portbridge had been entering patient data all morning. Then she noticed: the system had accepted an Animal with no name. Another had two species. An Appointment existed with zero dates. Nothing prevented nonsense because every attribute had cardinality “any”, zero or more, no questions asked. The sketch had been useful, but now she needed precision.


The problem

So far, every has declaration is unconstrained. The default cardinality is “any”, meaning zero to infinity. That’s great for rapid prototyping, but a real system needs rules:

  • An animal must have exactly one name
  • A species is required, and there’s only one
  • An age might be unknown (strays, for instance)
  • An owner is not always present (think of Pixel, the stray kitten)

Dolfin lets you tighten these rules with cardinality keywords placed between the colon and the type.

Cardinality keywords

KeywordMeaningExample
any (default)Any (0 to ∞), the defaulthas task: any Task
oneExactly one (required)has name: one string
optionalZero or one: may be absent, at most one valuehas age: optional int

The keyword goes after the colon and before the type:

has <attribute>: <cardinality> <Type>

Applying cardinality to Animal

Let’s revisit our Animal concept with proper constraints:


concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner

Now:

  • name and species are required: exactly one value, always present
  • age, weight, and owner are optional: they can be absent

Choosing the right cardinality

This is a modeling decision, not a technical one. Ask yourself: “How many of this attribute can an entity reasonably have? And how many must it have?”

AttributeCardinalityReasoning
nameoneEvery animal must have a name, even “Unknown”
speciesoneYou always know if it’s a dog or a cat
ageoptionalStrays often have unknown ages
weightoptionalNot always measured on first visit
owneroptionalStrays exist

Applying cardinality to Owner and Appointment

Let’s apply the same thinking to Owner:


concept Owner:
  has firstName: one string
  has lastName: one string
  has phone: one string
  has email: optional string
  has address: optional string

And to Appointment:


concept Appointment:
  has animal: one Animal
  has `date`: one string
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatment: optional string
  has notes: string

A diagnosis and treatment are unknown when the appointment is first scheduled: they only get filled in during or after the visit.

Leaving an attribute unconstrained

If you omit the cardinality keyword, you get the default: any (zero or more, no upper limit). This is useful for attributes where you genuinely don’t know the bounds yet, or where any number of values is acceptable:


concept Appointment:
  has notes: string

Here notes has no constraint: an Appointment can have zero notes, one note, or a hundred. That’s sometimes exactly what you want.

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phone: one string
  has email: optional string
  has address: optional string

concept Veterinarian:
  has name: one string
  has licenseNumber: one string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner

concept Appointment:
  has animal: one Animal
  has `date`: one string
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatment: optional string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

Try it

The Veterinarian concept currently requires name and licenseNumber. Add an optional specialization attribute (as a string) and an optional phone:


concept Veterinarian:
  has name: one string
  has licenseNumber: one string

The model finally rejected nonsense. An Animal without a name? Error. An Appointment with no date? Error. Pixel, the stray kitten, was registered with no owner and no known age, and the system accepted her gracefully.

A week later, the clinic ran a vaccination drive. Thirty animals in one day. Dr. Portbridge needed to record which vaccines each animal had received, not one, not two, but a variable number. She tried adding has vaccine: one string but that only held one. She needed a list.

Chapter 6: How Many Vaccines?

Biscuit the golden retriever needed three vaccines: rabies, distemper, and bordetella. Dr. Portbridge tried has vaccine: one string, but that could only hold one value. She tried adding three separate fields, vaccine1, vaccine2, vaccine3, and immediately hated herself for it. What about animals that need five? Or none?


The problem

Sometimes an attribute holds more than one value. A single has vaccine: one string gives you exactly one. The real world isn’t that tidy.

Multi-valued cardinality

In the previous chapter, you learned one and optional. But Dolfin has richer cardinality keywords for multi-valued attributes:

KeywordMeaningExample
at least NN or more valueshas phone: at least 1 string
between N MBetween N and M (inclusive)has ref: between 2 5 Owner
at most NBetween 0 and N (inclusive)has parents: at most 2 Person
exactly NExactly N valueshas coord: exactly 3 float
(none)Any (0 to ∞)has tag: string

Combined with the keywords from Chapter 5, here’s the full cheat sheet:

Cardinality cheat sheet

SyntaxMeaningExample use case
one TypeExactly one (required)has name: one string
optional TypeMay be absent, at most one (0 to 1)has nickname: optional string
TypeAny (0 to ∞, the default)has tag: string
at least N TypeN or morehas phone: at least 1 string
at most N Type0 to N (inclusive)has parents: at most 2 Person
between N M TypeBetween N and M (inclusive)has references: between 2 5 Owner
exactly N TypeExactly Nhas coordinates: exactly 3 float

Applying cardinality to the clinic

Let’s think about what needs multi-valued cardinality:

Animals can have multiple vaccines, and might have multiple allergies:


concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccines: string
  has allergies: string

Here vaccines and allergies use the default “any” that is zero or more strings, no upper limit.

Owners must have at least one phone number but could have several:


concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string

Appointments might involve multiple treatments:


concept Appointment:
  has animal: one Animal
  has `date`: one string
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: string

A concept for vaccines

Actually, a vaccine isn’t just a string. It has a name, a date administered, and a batch number. Let’s promote it to a concept:


concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

Now each vaccination is a rich object, not just a name. And because vaccinations uses the default cardinality (“any”), an animal can have zero, one, or many vaccinations.

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string

concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Appointment:
  has animal: one Animal
  has `date`: one string
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

Try it

A Veterinarian can have multiple certifications (at least one) and speaks one or more languages. Add these attributes:


concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

The vaccination records looked clean. Biscuit had three entries; Pixel had none yet. The system handled both gracefully.

But then a colleague, Dr. Reyes, joined the practice. He was a surgeon, not a general vet. And Dr. Portbridge realized her model treated every Veterinarian identically. She needed a way to say “a Surgeon is a Veterinarian, but with extra capabilities.” She needed inheritance.

Chapter 7: The Specialist Problem

Dr. Reyes could do everything Dr. Portbridge could, checkups, vaccinations, prescriptions. Plus surgery. Modeling him as a plain Veterinarian would lose the surgery part. Creating a completely separate Surgeon concept would duplicate all the shared fields. Neither option felt right.


The problem

You have two kinds of things that share most of their structure but differ in some aspects. Duplicating attributes across concepts is fragile: change one, forget the other. You need inheritance.

#sub: concept inheritance


concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

The keyword sub says: “A Surgeon is a specialization of Veterinarian.” A Surgeon automatically has name, licenseNumber, and specialization (inherited from Veterinarian), plus its own surgeryCount and certifiedProcedures.

In OWL terms, this is rdfs:subClassOf. In object-oriented terms, it’s inheritance. In plain English: every Surgeon is a Veterinarian, but not every Veterinarian is a Surgeon.

Building a hierarchy

Let’s extend this further. The clinic is growing and has different roles:


concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

concept Dentist:
  sub Veterinarian
  has dentalCertification: one string

concept Intern:
  sub Veterinarian
  has university: one string
  has year: one int

All four concepts share name, licenseNumber, and specialization. Each adds its own details.

Inheritance for animals too

The Species enum tells us what kind of animal something is, but it doesn’t let us attach species-specific attributes. With inheritance, we can:


concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

concept Cat:
  sub Animal
  has indoor: one boolean

concept Bird:
  sub Animal
  has wingspan: optional float
  has canFly: one boolean

A Dog is an Animal with breed and neuter status. A Bird is an Animal with a wingspan and flight ability. The shared core (name, species, age, etc.) is defined once and inherited everywhere.

Video 2.1 · The Dolfin editor, chapter “Inheritance and completion” (0:24–1:02). Watch on PeerTube

Ordering convention

Inside a concept body, put sub first, then has declarations. This isn’t enforced by the parser, but it’s the standard Dolfin style:


concept Surgeon:
  sub Veterinarian            # ← parent first
  has surgeryCount: one int  # ← then own attributes

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string

concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

concept Dentist:
  sub Veterinarian
  has dentalCertification: one string

concept Intern:
  sub Veterinarian
  has university: one string
  has year: one int

concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

concept Cat:
  sub Animal
  has indoor: one boolean

concept Bird:
  sub Animal
  has wingspan: optional float
  has canFly: one boolean

concept Appointment:
  has animal: one Animal
  has `date`: one string
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

Try it

The clinic just hired a Radiologist (a specialized Veterinarian). They have a machineCertifiedOn attribute (a string, the machine name) and a readingsPerformed count. Add the concept:


concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

# Add Radiologist here

The model now captured the difference between Dr. Portbridge (general practice), Dr. Reyes (surgeon), and the new dental specialist who started on Tuesdays. The schema was rich enough to describe every living thing that would walk through the clinic’s door.

And then the first patient walked through the door. Biscuit the golden retriever, nervous as ever, dragged in by John Smith. Dr. Portbridge opened the system to register him, and realized she had no way to record a specific dog. The schema described the shape of a Dog. It said nothing about this particular dog, with this particular owner, these particular vaccines. She needed to enter actual data.

Chapter 8: Populating the Clinic

The model had grown up. Dr. Portbridge now had concepts for animals, owners, vets, and appointments, with cardinality constraints, enums, and a full inheritance hierarchy. But the system was still abstract: no actual dogs, no actual owners, no actual appointments. Biscuit, the nervous golden retriever, the first patient of the morning, was sitting in the waiting room. It was time to enter some real data.


The problem

So far, your Dolfin file contains descriptions of categories: what an Animal looks like, what an Appointment requires. It contains no individuals: no specific dog named Biscuit, no actual appointment on a Tuesday afternoon. This is the gap between a schema and a database.

Dolfin fills this gap with facts.

Your first fact


fact DrPortbridge a Veterinarian
  name "Helen Portbridge"
  licenseNumber "VET-2025-001"

The structure mirrors the schema:

  • fact introduces an instance declaration.
  • DrPortbridge is the instance’s identifier, you can reference it elsewhere as DrPortbridge.
  • a Veterinarian asserts that this instance is of type Veterinarian.
  • The indented block lists property assertions, one per line: the property name, then the value.

This is the same indentation-based style you already know from concept, rule, and package declarations. No new punctuation to learn.

Video 3.1 · Facts, rules and SPARQL, opening (0:04–0:49). Watch on PeerTube

Primitive values

Facts support all the same primitive types you use in has declarations:


fact Biscuit a Dog
  name "Biscuit"
  species Dog
  age 5
  weight 32.4
  neutered true
  allergies "pollen"
Value kindExample
String"Biscuit"
Integer5
Float32.4
Booleantrue
Enum valueDog (no quotes)

Enum values are written without quotes; the parser resolves them against the property’s declared type.

Referencing other facts

When a property’s type is a concept (not a primitive), the value is a reference to another fact, written with its identifier:


fact JohnSmith a Owner
  firstName "John"
  lastName "Smith"
  phoneNumbers "555-1234"
  preferredVet DrPortbridge

fact Biscuit a Dog
  name "Biscuit"
  species Dog
  age 5
  weight 32.4
  neutered true
  owner JohnSmith

DrPortbridge and JohnSmith refer to the facts declared elsewhere in the file. Order doesn’t matter. The parser resolves identifiers after reading the whole file, so forward references work.

A bare identifier looks for the fact in the current file first, then in the one other file of the package that declares it. To point at a fact in a specific file, use its dotted path (people.customers.alice).

Don’t write :JohnSmith. As in Turtle, a leading colon is the package’s default prefix: :JohnSmith is the IRI <http://happypaws.com/clinic#JohnSmith>, not the JohnSmith fact of clinic.dlf (which is clinic:JohnSmith). package.dlf declares no facts, so the analyzer warns that :JohnSmith points at the package namespace, where JohnSmith is not declared.

Anonymous blocks

Some properties hold a value that is a structured object rather than a primitive or a reference. Biscuit’s vaccination record is a good example: it’s a Vaccination instance that only belongs to Biscuit, with no need for its own global identifier.

Use an anonymous block (square brackets) for this:


fact Biscuit a Dog
  name "Biscuit"
  species Dog
  age 5
  weight 32.4
  neutered true
  owner JohnSmith
  vaccinations [
    vaccineName "Rabies"
    dateAdministered "2024-03-15"
    batchNumber "RB-2024-0042"
  ]

The block’s type (Vaccination) is inferred from the property’s declared range. If the range is an abstract parent concept and you need to specify a subtype, you can add a SubType as the first line of the block:


animal [
  a Dog
  name "Rex"
]

Multi-valued properties

For properties with cardinality some, any, or at least N, simply repeat the property name on multiple lines:


fact JohnSmith a Owner
  firstName "John"
  lastName "Smith"
  phoneNumbers "555-1234"
  phoneNumbers "555-5678"
  preferredVet DrPortbridge

Or write them as a comma-separated list:


fact JohnSmith a Owner
  firstName "John"
  lastName "Smith"
  phoneNumbers "555-1234", "555-5678"
  preferredVet DrPortbridge

Both forms are equivalent. The first reads like structured records; the second saves space when the values are short.

Multiple anonymous blocks work the same way. Biscuit received three vaccines:


fact Biscuit a Dog
  name "Biscuit"
  species Dog
  age 5
  weight 32.4
  neutered true
  owner JohnSmith
  vaccinations [
    vaccineName "Rabies"
    dateAdministered "2024-03-15"
  ]
  vaccinations [
    vaccineName "Distemper"
    dateAdministered "2024-03-15"
  ]
  vaccinations [
    vaccineName "Bordetella"
    dateAdministered "2024-03-15"
  ]

Multiple types

A fact can assert membership in more than one concept, separated by commas:


fact Pixel a Cat, UnvaccinatedAnimal
  name "Pixel"
  species Cat
  indoor true

This is useful when an instance simultaneously belongs to a base concept and a flag concept. Here, Pixel is both a Cat and (currently) an UnvaccinatedAnimal. In practice, flag concepts like UnvaccinatedAnimal are usually derived by rules rather than asserted directly, but the syntax supports both.

A day at the clinic

Here are the facts for opening day at Happy Paws:


# --- Staff ---

fact DrPortbridge a Veterinarian
  name "Helen Portbridge"
  licenseNumber "VET-2025-001"

fact DrReyes a Surgeon
  name "Carlos Reyes"
  licenseNumber "VET-2025-042"
  surgeryCount 0
  certifiedProcedures "Soft tissue"
  certifiedProcedures "Orthopaedic"

# --- Owners ---

fact JohnSmith a Owner
  firstName "John"
  lastName "Smith"
  phoneNumbers "555-1234"
  preferredVet DrPortbridge

# --- Animals ---

fact Biscuit a Dog
  name "Biscuit"
  species Dog
  age 5
  weight 32.4
  neutered true
  owner JohnSmith
  vaccinations [
    vaccineName "Rabies"
    dateAdministered "2024-03-15"
  ]
  vaccinations [
    vaccineName "Distemper"
    dateAdministered "2024-03-15"
  ]
  vaccinations [
    vaccineName "Bordetella"
    dateAdministered "2024-03-15"
  ]

fact Pixel a Cat
  name "Pixel"
  species Cat
  indoor true

# --- Appointments ---

fact Appt001 a Appointment
  animal Biscuit
  `date` "2025-01-15"
  reason "annual checkup"
  urgency Routine
  status Completed
  diagnosis "Healthy"
  treatments "Bordetella booster"

Notice:

  • Biscuit in the appointment refers back to the Biscuit fact.
  • Pixel has no owner, she’s the stray kitten. The optional Owner cardinality allows this.
  • urgency Routine and status Completed are enum values without quotes.

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "1.0.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# ============================================================
#  clinic.dlf: Schema + Opening Day Facts
# ============================================================

# --- Schema (chapters 1–7) ---

concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string
  has preferredVet: optional Veterinarian

concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

concept Cat:
  sub Animal
  has indoor: one boolean

concept Appointment:
  has animal: one Animal
  has `date`: one string
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

# --- Facts (chapter 8) ---

fact DrPortbridge a Veterinarian
  name "Helen Portbridge"
  licenseNumber "VET-2025-001"

fact DrReyes a Surgeon
  name "Carlos Reyes"
  licenseNumber "VET-2025-042"
  surgeryCount 0
  certifiedProcedures "Soft tissue"
  certifiedProcedures "Orthopaedic"

fact JohnSmith a Owner
  firstName "John"
  lastName "Smith"
  phoneNumbers "555-1234"
  preferredVet DrPortbridge

fact Biscuit a Dog
  name "Biscuit"
  species Dog
  age 5
  weight 32.4
  neutered true
  owner JohnSmith
  vaccinations [
    vaccineName "Rabies"
    dateAdministered "2024-03-15"
  ]
  vaccinations [
    vaccineName "Distemper"
    dateAdministered "2024-03-15"
  ]
  vaccinations [
    vaccineName "Bordetella"
    dateAdministered "2024-03-15"
  ]

fact Pixel a Cat
  name "Pixel"
  species Cat
  indoor true

fact Appt001 a Appointment
  animal Biscuit
  `date` "2025-01-15"
  reason "annual checkup"
  urgency Routine
  status Completed
  diagnosis "Healthy"
  treatments "Bordetella booster"

Try it

Register a new patient: a rabbit named Clover, owned by Maria Garcia (phone "555-9900"). Clover is 3 years old, weighs 1.8 kg, and has had one vaccination ("RHDV2", administered "2024-11-01").


fact MariaGarcia a Owner
  firstName "Maria"
  lastName "Garcia"
  phoneNumbers "555-9900"

# Add Clover here

The system was no longer just a blueprint. It knew who worked at Happy Paws, who owned which animals, which vaccines had been given, and which appointments had been completed. Biscuit’s record showed three vaccinations and a clean checkup. Pixel’s record showed none at all.

Dr. Portbridge stared at Pixel’s entry. She was manually scanning for unvaccinated animals, overdue checkups, interns assigned to emergencies. Every check was a thing she had to remember to do. “What if the system could notice these things automatically?” she wondered. She needed the system to reason, to look at the facts and draw its own conclusions.

Chapter 9: Marking Time

Biscuit was due for a booster. Dr. Portbridge was sure of it, but the appointment book fought her every step. One entry read "15/03/2025", another "March 15", a third "2025-03-15". To her they were the same afternoon; to the computer they were three unrelated strings. She couldn’t ask “which vaccinations are overdue?” because a string doesn’t know it’s a date. It was time to give dates real meaning.


The problem with string dates

Back in Chapter 3 we stored a date as a string and promised a better way later. This is later.

Video 3.2.1 · Dates, times and durations, opening (0:04–0:46). Watch on PeerTube

A string like "15/03/2025" is opaque. Nothing stops a typo ("2025-13-40"), nothing agrees on the format, and, worst of all, the date carries no meaning the machine can compute with. Dolfin now has four temporal types that fix this:

TypeHoldsExample value
dateA calendar daydate(March 15th 2025)
timeA wall-clock timetime(2:30 PM)
date_timeA day and a timedate_time(June 1st 2026, 14:30)
durationA length of timeduration(1y 6mo)

The value inside the parentheses is a smart literal: you write the date the way a human would, and Dolfin parses it into the exact form an RDF datastore expects (xsd:date, xsd:time, xsd:dateTime, xsd:duration).

Writing dates

The most natural way is to spell it out. Month names are case-insensitive, and the ordinal ending (st, nd, rd, th) is optional:


date(March 15th 2025)     # → 2025-03-15
date(15 March 2025)       # → 2025-03-15
date(Mar 15 2025)         # → 2025-03-15

You can also write it numerically. But 01/06/2025 is ambiguous (is it June 1st or January 6th?), so Dolfin makes you say which order the fields are in with an as mask:


date(15/03/2025 as d/m/y) # → 2025-03-15
date(2025-03-15 as y-m-d) # → 2025-03-15

The separator (/, -, or .) must match on both sides.

Set the locale once

Typing as d/m/y on every date gets old fast. The @locale directive, placed at the top of the file, sets the default field order for the whole file:


@locale d/m/y

fact rabies_shot a Vaccination
  givenOn date(15/03/2025)   # no mask needed the file already knows
Video 3.2.1 · Dates, times and durations, chapter “Day first or month first?” (0:45–1:45). Watch on PeerTube

Times and timestamps

A time is 24-hour by default; add AM/PM for the 12-hour clock. An appointment slot pairs a day with a time in a date_time:


time(14:30)                          # → 14:30:00
time(2:30 PM)                        # → 14:30:00
date_time(June 1st 2026, 2:30 PM)    # → 2026-06-01T14:30:00

Clinics keep local time, so you can pin a timezone, either inline, or file-wide with @timezone:


@timezone Europe/Brussels

fact checkup a Appointment
  scheduledFor date_time(June 1st 2026, 9:00 AM)  # → …T09:00:00+01:00

An inline offset (time(9:00 AM +02:00)) always overrides the file default.

Durations

A duration measures a span, how long a vaccine stays valid, how far ahead to send a reminder. Write one or more <number><unit> terms in any order:

ymowdhmins
yearsmonthsweeksdayshoursminutesseconds

duration(3y)          # → P3Y      a rabies shot good for three years
duration(2w)          # → P14D     send the reminder two weeks ahead
duration(1h 30min)    # → PT1H30M  a long surgery slot

(The month unit is mo, so it never collides with minutes.)

A duration is also the time dimension of quantities: duration(20 min) and quantity(20 min) are the same value, "PT20M"^^xsd:dayTimeDuration. A span made only of weeks, days, hours, minutes and seconds compiles to xsd:dayTimeDuration (duration(2w) → P14D); one with years or months stays an xsd:duration (P3Y). Months and years have no fixed length, so they are refused in quantity arithmetic: quantity(1 mo) is an error, while duration(1mo) is fine.

Upgrading the clinic

Now the appointment book earns its keep. The Appointment gets a real date_time, and a new Vaccination concept records when a shot was given and how long it lasts:


concept Appointment:
  has animal: one Animal
  has scheduledFor: one date_time   # was: has `date`: string
  has reason: string

concept Vaccination:
  has animal: one Animal
  has vaccineName: one string
  has givenOn: one date
  has validFor: one duration

With that, Biscuit’s booster is now a date the system understands, paired with a validity span:


fact rabies_shot a Vaccination
  animal biscuit
  vaccineName "Rabies"
  givenOn date(March 15th 2025)
  validFor duration(3y)

Because givenOn is a real date and validFor a real duration, the machine can now do what Dr. Portbridge couldn’t do by squinting at napkins: compare them, and work out exactly when the protection runs out. That arithmetic is the seed of the alerts we build next chapter.

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Animal:
  has name: string
  has species: string
  has age: int
  has owner: Owner
  
concept Owner:
  has firstName: string
  has lastName: string
  has phone: string

concept Vaccination:
  has animal: one Animal
  has vaccineName: one string
  has givenOn: one date
  has validFor: one duration

fact biscuit a Animal
  name "Biscuit"
  species "Dog"
  age 5

fact rabies_shot a Vaccination
  animal biscuit
  vaccineName "Rabies"
  givenOn date(March 15th 2025)
  validFor duration(3y)

Try it

Add an Appointment fact for Biscuit’s booster with a real date_time, and give the Vaccination a reminderLead of two weeks:


@locale d/m/y

concept Animal:
  has name: string
  has species: string
  has age: int
  has owner: Owner
  
concept Owner:
  has firstName: string
  has lastName: string
  has phone: string

concept Vaccination:
  has animal: one Animal
  has vaccineName: one string
  has givenOn: one date
  has validFor: one duration
  # add: has reminderLead: optional duration

# Add your booster appointment and vaccination facts here

Dr. Portbridge closed the appointment book (the real one, the one in the computer) and smiled. Every date now knew it was a date. But knowing the dates wasn’t the same as being warned in time. What she really wanted was for the system to speak up on its own: “Biscuit’s rabies protection expires next month.” For that, the clinic would have to start thinking.

Chapter 10: Automatic Alerts

It was 8 PM. Dr. Portbridge was still at the clinic, manually cross-referencing vaccination records with appointment dates to find overdue animals. “There has to be a way to automate this,” she muttered. She needed the system to reason, to look at data and draw conclusions.


The problem

So far, our ontology describes things but doesn’t infer anything. If an animal hasn’t been vaccinated in over a year, a human has to notice. If an appointment is marked as an emergency but assigned to an intern, nobody catches it. We want the system to derive new facts from existing ones.

Rules

A rule defines an if-then inference: if certain patterns hold in the data, then new facts follow.

Video 3.1 · Facts, rules and SPARQL, chapter “Rules infer new facts” (0:48–1:11). Watch on PeerTube

rule flag_unvaccinated:
  match:
    ?animal a Animal
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a UnvaccinatedAnimal

Note: This is a simplified example. Real vaccine-overdue logic would compare the givenOn date and validFor duration from Chapter 9, date arithmetic. The important thing here is the pattern: match conditions, then assert conclusions.

Let’s unpack the syntax.

Variables

Variables start with ?. They bind to values during pattern matching:

  • ?animal: binds to any Animal instance

Patterns

Each line in the match: block is a pattern:

PatternMeaning
?animal a Animal?animal is an instance of Animal
count ?shot as ?shots?shots is the number of ?shot values the indented lines below it find for this ?animal (0 when there are none)
?animal vaccinations ?shot(indented, inside the count) ?shot is one of ?animal’s vaccinations
?shots [ = 0 ]that number is 0

Patterns are combined with implicit AND. All must hold simultaneously.

Assertions

Each line in the then: block is an assertion, a new fact to create:

AssertionMeaning
?animal a UnvaccinatedAnimalClassify ?animal as an UnvaccinatedAnimal

A more practical example

Let’s flag emergency appointments that are assigned to interns:


concept UnsafeAssignment

rule flag_intern_emergency:
  match:
    ?appt a Appointment
    ?appt urgency Emergency
    ?appt animal [ treatedBy [ a Intern ] ]
  then:
    ?appt a UnsafeAssignment

This says: “If an appointment is an Emergency, and the treating vet is an Intern, flag it as an UnsafeAssignment.”

Inferring new relationships

Rules don’t just classify, they can create relationships:


rule assign_primary_vet:
  match:
    ?animal a Animal
    ?animal owner [ preferredVet ?vet ]
  then:
    ?animal treatedBy ?vet

“If an animal’s owner has a preferred vet, assign that vet to the animal.”

(This requires adding preferredVet to Owner, we’ll do that below.)

To follow a chain of relationships of any length (parentOf+), see property paths.

Weight-based alerts

Here’s a rule using numeric comparison:


concept OverweightAnimal

rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > 40.0 ]
  then:
    ?dog a OverweightAnimal

Worked example

Suppose the clinic records two dogs:


fact rex a Dog
  name "Rex"
  species Dog
  weight 45.0
  neutered true

fact buddy a Dog
  name "Buddy"
  species Dog
  weight 22.0
  neutered true

When the rules run, the reasoner walks every Dog, checks the weight constraint, and derives one new fact:

rex   a OverweightAnimal      # 45.0 > 40.0  → flagged
# buddy is left untouched     # 22.0 > 40.0  → does not match

The derived rex a OverweightAnimal triple is added to the graph alongside the data you wrote. Nothing in the original facts changes. A rule only ever adds facts.

Classifying by an exact value

Comparisons aren’t the only test. A pattern can match an attribute against a literal value directly (a boolean, an enum member, a string, a date, or a date-time):


concept NeedsNeutering

rule flag_unneutered_dog:
  match:
    ?dog a Dog
    ?dog neutered false
  then:
    ?dog a NeedsNeutering

“Any dog whose neutered flag is false is tagged NeedsNeutering.”

Where rules run

These rules aren’t just documentation. When a Dolfin package is deployed in Agrafe, its rules are compiled and handed to the reasoner (retox). Every time data is written, the reasoner forward-chains the rules to a fixpoint, and the derived triples (rex a OverweightAnimal, …) become queryable through the deployment’s SPARQL API right next to the data you wrote.

Counting, summing, averaging

A rule can aggregate a group of values, as flag_unvaccinated above counts vaccinations. An aggregate line (count, count distinct, sum, average, min or max) names the aggregated variable and the result, and its indented lines say what to aggregate for each match of the lines above it. The result is used on later lines and in then::


concept BusyOwner:
  sub Owner
  has animal_count: int

rule flag_busy_owner:
  match:
    ?owner a Owner
    count ?pet as ?n
      ?pet owner ?owner
    ?n [ > 5 ]
  then:
    ?owner a BusyOwner
    ?owner animal_count ?n

“An owner of more than 5 animals is a BusyOwner, with the count recorded.” A count over no value is 0 (an owner with no animal has ?n = 0); sum, average, min and max over no value have no result, so the rule does not fire. A rule may aggregate over what another rule infers (?pet a UnvaccinatedAnimal): it runs once that rule has finished. Two rules aggregating over each other’s conclusions in a loop have no answer and are refused, naming the rules.

How each backend runs aggregate rules:

Backendcount, count distinctsum, average, min, max
SHACL-AF (radeau validate, the validation of an Agrafe deployment)runrun
N3 output (radeau build --emit n3-rules)written as N3 (log:collectAllIn + list:length)written as a # rule <name>: aggregate, runs as SHACL-AF only comment
retox (the reasoner)run, after the rules deriving their inputnot run (reported, see above)
Agrafe deployment (SPARQL queries)inferred triples are queryablelisted among the deployment’s skipped constructs: queries do not see them

A plain count whose indented lines bind another new variable (?pet owner ?owner then ?pet weight ?w) counts a value once per row, which N3 cannot say: like sum, it runs as SHACL-AF only (write count distinct to count each value once). The editor warns about every rule that runs as SHACL-AF only (S020).

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string
  has preferredVet: optional Veterinarian

concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

concept Dentist:
  sub Veterinarian
  has dentalCertification: one string

concept Intern:
  sub Veterinarian
  has university: one string
  has year: one int

concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

concept Cat:
  sub Animal
  has indoor: one boolean

concept Bird:
  sub Animal
  has wingspan: optional float
  has canFly: one boolean

concept Appointment:
  has animal: one Animal
  has scheduledFor: one date_time
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

# Derived concepts (created by rules)
concept UnvaccinatedAnimal
concept UnsafeAssignment
concept OverweightAnimal

# Rules
rule flag_unvaccinated:
  match:
    ?animal a Animal
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a UnvaccinatedAnimal

rule flag_intern_emergency:
  match:
    ?appt a Appointment
    ?appt urgency Emergency
    ?appt animal [ treatedBy [ a Intern ] ]
  then:
    ?appt a UnsafeAssignment

rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > 40.0 ]
  then:
    ?dog a OverweightAnimal

rule assign_primary_vet:
  match:
    ?animal a Animal
    ?animal owner [ preferredVet ?vet ]
  then:
    ?animal treatedBy ?vet

Try it

Write a rule that classifies a Cat as a SeniorCat if its age is greater than or equal to 10:


concept SeniorCat

rule flag_senior_cat:
  match:
    # your patterns here
  then:
    # your assertion here

The alerts were a revelation. The system caught an intern assigned to an emergency before it became a problem. It flagged three overweight dogs whose owners hadn’t noticed the gradual change. But when Dr. Portbridge looked closely at that last rule, weight [ > 40.0 ], a doubt crept in. Forty what? A number with no unit was a number she couldn’t trust, and one threshold couldn’t possibly fit a parakeet and a mastiff alike. Before she added any more rules, the weights themselves needed to mean something.

Chapter 11: A Matter of Weight

The overweight-dog alert had been firing for a week, and Dr. Portbridge was starting to distrust it. It flagged Rex, a 45-kilo Labrador, fair enough. But it had also flagged nothing at all for a visiting Great Dane that clearly needed a diet, and it had thrown an alarm for a Chihuahua whose owner recorded its weight in grams. She looked at the rule again: weight [ > 40.0 ]. Forty. Forty what? And why would one number fit a parrot and a mastiff? The rule wasn’t wrong so much as it didn’t know what it was talking about.


The problem with a bare number

In Chapter 10 we wrote our first numeric comparison:


rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > 40.0 ]
  then:
    ?dog a OverweightAnimal

That 40.0 is a bare float, and a bare float carries no meaning. Is it forty kilograms? Forty pounds? Whoever writes a fact has to remember the convention and stick to it, and the moment one owner records weight 45.0 meaning 45 kg while another records weight 20.0 meaning 20 lb, the numbers lie to us. It is the same trouble string dates gave us in Chapter 9: a value the machine can store but cannot understand.

What we actually mean is a physical quantity: a number and a unit, together, as one indivisible thing.

Quantities

Dolfin writes a quantity as a smart literal, the same shape as the temporal values from Chapter 9, a keyword and human-friendly notation in parentheses:


quantity(45 kg)      # forty-five kilograms
quantity(4500 g)     # four and a half kilograms, written in grams
quantity(90 lb)      # ninety pounds
quantity(55 g)       # a small parakeet

The unit lives inside the value. Nothing is left to convention. And because Dolfin understands the units, it can do the one thing a bare number never could: compare measurements written in different units. Ninety pounds and forty kilograms are no longer two unrelated numbers, Dolfin knows that 90 lb is about 40.8 kg, and can tell you which is heavier.

Video 3.2.2 · Units and quantities, opening (0:04–0:25). Watch on PeerTube

Quantities are not just for weight. quantity(42 km/h), quantity(9.81 m/s^2), quantity(10 N/m^2) all work, Dolfin has a whole SI unit system built in. The Units & Quantities reference covers compound units, prefixes, arithmetic, and as conversions. Here we only need weight.

Weighing the patients properly

The Animal concept already has a weight attribute. We leave its declared type as float, a quantity’s real type is its unit, which the value carries, so the attribute only has to say “a number”:


concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float   # values are quantities: quantity(45 kg)
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

float accepts any quantity, quantity(45 kg) or quantity(3 s) alike, nothing here checks that a weight is actually a mass. If you want that checked, the Units & Quantities reference covers quantity-typed ranges (has weight: mass). This tutorial keeps float to focus on quantities themselves first.

Now the facts record real measurements, and each animal can use whatever unit its chart was written in. Here are this week’s patients:


fact rex a Dog
  name "Rex"
  species Dog
  weight quantity(45 kg)
  neutered true

fact bella a Dog
  name "Bella"
  species Dog
  weight quantity(90 lb)     # ≈ 40.8 kg
  neutered true

fact buddy a Dog
  name "Buddy"
  species Dog
  weight quantity(22 kg)
  neutered true

fact mittens a Cat
  name "Mittens"
  species Cat
  weight quantity(7 kg)
  indoor true

A threshold that knows its units

Now we rewrite the alert so the threshold is a quantity too:


rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > quantity(40 kg) ]
  then:
    ?dog a OverweightAnimal

When the reasoner runs, it does not compare the written numbers, it compares the underlying physical magnitudes:

rex    a OverweightAnimal    # 45 kg      > 40 kg   → flagged
bella  a OverweightAnimal    # 90 lb ≈ 40.8 kg > 40 kg → flagged
# buddy is left untouched    # 22 kg      > 40 kg   → does not match

Bella is the point of the whole chapter. Her weight was recorded in pounds, the threshold is in kilograms, and the rule still fires correctly, because Dolfin converts both to a common footing before comparing. The bare-number version could never have caught her.

Video 3.2.2 · Units and quantities, chapter “Compare across units” (0:24–1:08). Watch on PeerTube

Both sides must be quantities. The unit-aware comparison only runs when the stored value and the threshold are quantity(...). Comparing a quantity(...) weight against a bare 40.0, or vice versa, falls back to a plain numeric test with no unit reasoning, exactly the ambiguity we set out to remove. So once a value is a quantity, keep the threshold a quantity as well.

One threshold per species

Forty kilograms is a sensible line for a dog and absurd for a cat. Because the threshold is written right into each rule, every species gets its own:


concept OverweightCat

rule flag_overweight_cat:
  match:
    ?cat a Cat
    ?cat weight [ > quantity(6 kg) ]
  then:
    ?cat a OverweightCat

Mittens, the cat in our patient list above at 7 kg, is a chunky cat and gets flagged. A parakeet recorded as quantity(55 g) would sit far below any of these lines, and a threshold written in grams (quantity(60 g)) would compare against it perfectly, grams, kilograms, and pounds are all the same dimension (mass), so they are all comparable.

Comparing unlike things

What if a threshold and a value are not the same kind of measurement? Suppose someone fat-fingers a length where a weight belongs:


?dog weight [ > quantity(40 m) ]     # metres — a length, not a weight

Dolfin does not crash and does not silently coerce. Mass and length are different dimensions, so the comparison simply fails and the animal is never flagged. A comparison you cannot make is treated as one that does not hold, the same rule Dolfin uses everywhere: a condition it cannot satisfy does not match.

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string
  has preferredVet: optional Veterinarian

concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

concept Dentist:
  sub Veterinarian
  has dentalCertification: one string

concept Intern:
  sub Veterinarian
  has university: one string
  has year: one int

concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

concept Cat:
  sub Animal
  has indoor: one boolean

concept Bird:
  sub Animal
  has wingspan: optional float
  has canFly: one boolean

concept Appointment:
  has animal: one Animal
  has scheduledFor: one date_time
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

# Flag concepts and inference rules (Chapter 10)
concept UnvaccinatedAnimal
concept UnsafeAssignment
concept OverweightAnimal

rule flag_unvaccinated:
  match:
    ?animal a Animal
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a UnvaccinatedAnimal

rule flag_intern_emergency:
  match:
    ?appt a Appointment
    ?appt urgency Emergency
    ?appt animal [ treatedBy [ a Intern ] ]
  then:
    ?appt a UnsafeAssignment

rule assign_primary_vet:
  match:
    ?animal a Animal
    ?animal owner [ preferred_vet ?vet ]
  then:
    ?animal treatedBy ?vet

# Weight thresholds, now unit-aware (this chapter)
concept OverweightCat

rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > quantity(40 kg) ]
  then:
    ?dog a OverweightAnimal

rule flag_overweight_cat:
  match:
    ?cat a Cat
    ?cat weight [ > quantity(6 kg) ]
  then:
    ?cat a OverweightCat

Try it

Birds are recorded in grams. Write a rule that flags a Bird heavier than 500 grams as a HeavyBird, then add a fact for a parrot that weighs quantity(1.2 kg) and check that it trips the threshold even though its weight is written in kilograms and the threshold in grams:


concept HeavyBird

rule flag_heavy_bird:
  match:
    ?bird a Bird
    # your quantity comparison here
  then:
    # your assertion here

Dr. Portbridge re-ran the alerts. Rex and Bella both surfaced, the Great Dane among them this time, and the Chihuahua-in-grams was quietly left alone. Every weight now meant exactly what it said. But catching an overweight dog after the fact was one thing. She wanted the system to refuse bad data outright, a surgery booked with an intern, a completed appointment with no diagnosis, before it was ever written down. She needed guard rails.

Chapter 12: Guard Rails

An owner brought in a hamster for dental surgery. The system accepted the appointment without complaint, it didn’t know that dental procedures require a Dentist, or that hamsters aren’t in the Species enum (they’d have to be Other). The rules we’ve written so far could flag problems after the fact, but Dr. Portbridge wanted to prevent mistakes from being recorded in the first place.


The problem

Rules infer new facts, they add information. But they don’t prevent bad data. We need a way to express expectations: “Every X must satisfy Y”, “No X should ever have Z.”

Quantifiers in rules

Dolfin provides quantifiers that express conditions over collections:

QuantifierMeaning
allEvery element must satisfy the condition
noneNo element may satisfy the condition
at_least nAt least n elements must satisfy it
at_most nAt most n elements may satisfy it
exactly nExactly n elements must satisfy it

Ensuring surgical appointments have surgeons


concept InvalidSurgery

rule validate_surgery_staff:
  match:
    ?appt a Appointment
    ?appt reason "surgery"
    ?appt animal ?animal
    none ?vet:
      ?animal treatedBy ?vet
      ?vet a Surgeon
  then:
    ?appt a InvalidSurgery

This reads: “If an appointment is for surgery but no Surgeon is treating the animal, flag it as invalid.”

The none ?vet: quantifier introduces a fresh variable and a nested block; it succeeds when no binding of ?vet satisfies the sub-patterns, here, no vet who both treats the animal and is a Surgeon.

Minimum vaccination rules

Every dog should have at least one vaccination (in the real world, rabies is required by law in most places):


concept UnderVaccinatedDog

rule check_dog_vaccines:
  match:
    ?dog a Dog
    count ?shot as ?shots
      ?dog vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?dog a UnderVaccinatedDog

Comparison operators

You’ve already seen > and = in rules. Here’s the complete set:

OperatorMeaningExample
=Equal?x = 5
!=Not equal?x != "none"
<Less than?age < 1
<=Less than or equal?age <= 12
>Greater than?weight > quantity(40 kg)
>=Greater than or equal?age >= 10
Video 3.4 · Rules with comparisons, opening (0:04–0:59). Watch on PeerTube

Combining conditions

Rule patterns are combined with implicit AND. All conditions must hold for the rule to fire:


concept AtRiskAnimal

rule flag_at_risk:
  match:
    ?animal a Animal
    ?animal age [ > 15 ]
    ?animal weight [ < quantity(2 kg) ]
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a AtRiskAnimal

“An animal older than 15, weighing less than 2 kg, with no vaccinations, is at risk.”

Validation concepts as a pattern

Notice the design pattern emerging: we create empty concepts (InvalidSurgery, UnderVaccinatedDog, AtRiskAnimal) that exist only to be assigned by rules. They act as tags or flags. Downstream systems can query for all instances of InvalidSurgery and take action.

This is a powerful idiom:

  1. Define an empty concept that represents a condition
  2. Write a rule that classifies instances into that concept
  3. Query or display instances of the condition

Worked example: from data to flag to query

Take a single rule built only from conditions that run today:


concept SeniorCat

rule flag_senior_cat:
  match:
    ?cat a Cat
    ?cat age [ >= 10 ]
  then:
    ?cat a SeniorCat

Given this patient:


fact mittens a Cat
  name "Mittens"
  species Cat
  age 12
  indoor true

the reasoner derives mittens a SeniorCat. Because the flag is a normal concept, a downstream system finds every senior cat with an ordinary query:

SELECT ?cat WHERE { ?cat a clinic:SeniorCat }

The result includes mittens even though no one ever wrote mittens a SeniorCat by hand. The rule produced it, and it sits in the graph next to the asserted data.

Reminder: no aggregators yet. Guard rails phrased as counts (e.g. “a dog with zero vaccinations”, “an appointment with more than 3 cancellations”) are aggregations and don’t run as rules yet. Express conditions over a single value (comparisons, exact matches, nested patterns) until aggregator support lands.

The story so far


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "0.1.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# clinic.dlf
concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string
  has preferredVet: optional Veterinarian

concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

concept Dentist:
  sub Veterinarian
  has dentalCertification: one string

concept Intern:
  sub Veterinarian
  has university: one string
  has year: one int

concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

concept Cat:
  sub Animal
  has indoor: one boolean

concept Bird:
  sub Animal
  has wingspan: optional float
  has canFly: one boolean

concept Appointment:
  has animal: one Animal
  has scheduledFor: one date_time
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

# Flag concepts and inference rules (Chapter 10)
concept UnvaccinatedAnimal
concept UnsafeAssignment
concept OverweightAnimal

rule flag_unvaccinated:
  match:
    ?animal a Animal
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a UnvaccinatedAnimal

rule flag_intern_emergency:
  match:
    ?appt a Appointment
    ?appt urgency Emergency
    ?appt animal [ treatedBy [ a Intern ] ]
  then:
    ?appt a UnsafeAssignment

rule assign_primary_vet:
  match:
    ?animal a Animal
    ?animal owner [ preferred_vet ?vet ]
  then:
    ?animal treatedBy ?vet

# Unit-aware weight thresholds (Chapter 11)
concept OverweightCat

rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > quantity(40 kg) ]
  then:
    ?dog a OverweightAnimal

rule flag_overweight_cat:
  match:
    ?cat a Cat
    ?cat weight [ > quantity(6 kg) ]
  then:
    ?cat a OverweightCat

# Guard rails: validation concepts and rules (this chapter)
concept SeniorCat
concept InvalidSurgery
concept UnderVaccinatedDog
concept AtRiskAnimal

rule flag_senior_cat:
  match:
    ?cat a Cat
    ?cat age [ >= 10 ]
  then:
    ?cat a SeniorCat

rule validate_surgery_staff:
  match:
    ?appt a Appointment
    ?appt reason "surgery"
    ?appt animal ?animal
    none ?vet:
      ?animal treatedBy ?vet
      ?vet a Surgeon
  then:
    ?appt a InvalidSurgery

rule check_dog_vaccines:
  match:
    ?dog a Dog
    count ?shot as ?shots
      ?dog vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?dog a UnderVaccinatedDog

rule flag_at_risk:
  match:
    ?animal a Animal
    ?animal age [ > 15 ]
    ?animal weight [ < quantity(2 kg) ]
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a AtRiskAnimal

Try it

Write a validation rule that flags an Appointment as MissingDiagnosis when its status is Completed but diagnosis is absent (equals empty string ""):


concept MissingDiagnosis

rule check_completed_diagnosis:
  match:
    # your patterns here
  then:
    # your assertion here

The guard rails caught two problems on day one: a surgery booked with an intern and a completed appointment with no recorded diagnosis. Dr. Portbridge felt like the system was finally earning its keep.

Then the regional veterinary board called. They needed the clinic to export its data in a format compatible with the national animal health registry, which used standard OWL/RDF vocabularies. Dr. Portbridge’s concept names were fine for her clinic, but the outside world expected specific IRIs. She needed a bridge.

Chapter 13: Talking to the Outside World

*The email from the Regional Veterinary Board was blunt:

Please submit your animal health records using the National Animal Health Ontology (NAHO) vocabulary. All concepts must use IRIs from http://naho.gov/ontology/. All species must reference the FAO species classification at http://fao.org/species/.

Dr. Portbridge looked at her Dolfin file. Her concepts were called Animal and Dog. The board expected http://naho.gov/ontology/Animal and http://fao.org/species/CanineDomestic. She needed a way to map her clean, readable names to the bureaucratic world of IRIs.


The problem

Dolfin ontologies live in a clean, human-readable world. The semantic web lives in a world of IRIs (Internationalized Resource Identifiers), long URLs that uniquely identify every concept, property, and individual. To interoperate, we need to connect the two.

Prefixes

A prefix declares a short alias for an IRI namespace:


prefix <http://naho.gov/ontology/> as naho
prefix <http://fao.org/species/> as fao

A prefix is only an alias for qualified references, naho.Animal expands to http://naho.gov/ontology/Animal. Declaring a prefix does not move your own concepts into that namespace. This:


prefix <http://naho.gov/ontology/> as naho

concept Animal:
  has name: one string

still defines Animal under your package’s own namespace. The naho prefix sits unused. To bind one of your concepts to an external IRI you must say so explicitly with @iri_name (see below).

Using prefixed references

Prefixes let you reference concepts from other ontologies:


prefix <http://schema.org/> as schema

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional schema.PostalAddress

schema.PostalAddress refers to http://schema.org/PostalAddress, Schema.org’s definition of a postal address. You’re linking your clinic ontology to a globally recognized vocabulary.

The @iri_name annotation

This is how you actually bind a concept to an external IRI. The @iri_name annotation sits inside the concept body and takes the complete IRI in angle brackets. That IRI is used verbatim, it is not appended to your package namespace:


concept DomesticDog:
  @iri_name <http://fao.org/species/CanineDomestic>
  sub Animal
  has breed: optional string
  has neutered: one boolean

Here the local name DomesticDog stays clean and readable in your code, but the concept compiles to http://fao.org/species/CanineDomestic. Exactly the IRI the external system expects. This is what the board’s request required: local names mapped to bureaucratic IRIs. The annotation takes a full IRI in angle brackets only. A prefixed qualified name is not accepted here.

Multiple prefixes

A real-world ontology often bridges multiple external vocabularies:


prefix <http://naho.gov/ontology/> as naho
prefix <http://fao.org/species/> as fao
prefix <http://schema.org/> as schema
prefix <http://purl.org/dc/elements/1.1/> as dc

Each prefix is independent. You can use as many as needed.

The complete clinic

package.dlf holds only the package declaration:


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "1.0.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

The ontology goes in a second file, clinic.dlf. If it used prefixes, the prefix lines would sit at the top of that file, never in package.dlf. This listing declares none: @iri_name takes a full IRI, so DomesticDog does not need one.


# clinic.dlf
concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string
  has preferredVet: optional Veterinarian

concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

concept Dentist:
  sub Veterinarian
  has dentalCertification: one string

concept Intern:
  sub Veterinarian
  has university: one string
  has year: one int

concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

concept Cat:
  sub Animal
  has indoor: one boolean

concept Bird:
  sub Animal
  has wingspan: optional float
  has canFly: one boolean

concept Appointment:
  has animal: one Animal
  has scheduledFor: one date_time
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

# Binding to the outside world (this chapter)
concept DomesticDog:
  @iri_name <http://fao.org/species/CanineDomestic>
  sub Dog

# Flag concepts and inference rules (Chapter 10)
concept UnvaccinatedAnimal
concept UnsafeAssignment
concept OverweightAnimal

rule flag_unvaccinated:
  match:
    ?animal a Animal
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a UnvaccinatedAnimal

rule flag_intern_emergency:
  match:
    ?appt a Appointment
    ?appt urgency Emergency
    ?appt animal [ treatedBy [ a Intern ] ]
  then:
    ?appt a UnsafeAssignment

rule assign_primary_vet:
  match:
    ?animal a Animal
    ?animal owner [ preferred_vet ?vet ]
  then:
    ?animal treatedBy ?vet

# Unit-aware weight thresholds (Chapter 11)
concept OverweightCat

rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > quantity(40 kg) ]
  then:
    ?dog a OverweightAnimal

rule flag_overweight_cat:
  match:
    ?cat a Cat
    ?cat weight [ > quantity(6 kg) ]
  then:
    ?cat a OverweightCat

# Guard rails (Chapter 12)
concept SeniorCat
concept InvalidSurgery
concept UnderVaccinatedDog
concept AtRiskAnimal

rule flag_senior_cat:
  match:
    ?cat a Cat
    ?cat age [ >= 10 ]
  then:
    ?cat a SeniorCat

rule validate_surgery_staff:
  match:
    ?appt a Appointment
    ?appt reason "surgery"
    ?appt animal ?animal
    none ?vet:
      ?animal treatedBy ?vet
      ?vet a Surgeon
  then:
    ?appt a InvalidSurgery

rule check_dog_vaccines:
  match:
    ?dog a Dog
    count ?shot as ?shots
      ?dog vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?dog a UnderVaccinatedDog

rule flag_at_risk:
  match:
    ?animal a Animal
    ?animal age [ > 15 ]
    ?animal weight [ < quantity(2 kg) ]
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a AtRiskAnimal

Try it

Add a prefix for Dublin Core (http://purl.org/dc/elements/1.1/) and FOAF (http://xmlns.com/foaf/0.1/):


prefix <http://naho.gov/ontology/> as naho

# Add Dublin Core and FOAF prefixes here

Dr. Portbridge submitted the data export. The board’s system accepted it without complaint, her concepts mapped cleanly to NAHO’s IRIs, and the species references aligned with FAO’s vocabulary. Her little clinic was speaking the same language as the national registry.

She leaned back in her chair and looked at the screen. What had started as a napkin sketch was now a complete data model: concepts with inheritance, cardinality constraints, enums for controlled vocabularies, rules for automated reasoning, constraints for validation, and prefixes for interoperability. Biscuit dozed at her feet. Pixel purred on the printer.

Chapter 14: Mapping a JSON API Dump

A week after the submission, the Regional Veterinary Board wrote back. This time there was no form to fill in, just an attachment: Venue.json, an export from their API listing every place where the next vaccination campaign would run. “Please link your records to these venues.”

Dr. Portbridge opened it. It was not a table. It was nested braces, lists inside objects inside lists. Typing each venue as a fact by hand was out of the question.


The problem

Facts (Chapter 8) name one instance at a time. Real data arrives in bulk, and more and more often it arrives as JSON: nested, with no obvious “row”. A mapping declares the shape of an instance once and lets the file supply the values. For JSON it needs two things a spreadsheet never asks for: what counts as one record, and where each value sits inside it.

The file

Here is the board’s export:

{
  "venue": [
    {
      "latitude": 50.901389,
      "longitude": 4.484444,
      "location": { "continent": "EU", "country": "BE", "city": "Brussels" }
    }
  ]
}

One venue, with a position and a nested location. We want two things out of it: a city, and the coordinates the city is linked to.

The whole mapping


prefix <http://example.com/ns#> as ex

mapping from <package://Venue.json> as JSONPath
  for each "$.venue[*]"
  map:
    "$.latitude" as ?lat
    "$.longitude" as ?long
    "$.location.continent" as ?continent
    "$.location.country" as ?country
    "$.location.city" as ?city
  to:
    fact <http://loc.example.com/city/{?city}> a ex:City
      ex:countryCode ?country
      ex:latlong <http://loc.example.com/latlong/{?lat},{?long}>
      ex:onContinent ?continent
    fact <http://loc.example.com/latlong/{?lat},{?long}> a ex:latlong
      ex:lat {?lat}
      ex:long {?long}

It reads top to bottom in four steps.

1. The source

mapping from <package://Venue.json> as JSONPath names the file, relative to the package root (the directory holding package.dlf), and picks the JSON reader. Values will be addressed with JSONPath strings, where $ is the root and .name steps into an object member.

2. for each: what is a row?

for each "$.venue[*]" says: every element of the venue array is one record. The mapping runs once per element. With three venues in the array, the to: block would run three times.

Leave the line out and the whole document is a single record.

3. map:: where the values are

Each line binds a path to a variable. The paths are relative to the current venue: inside map:, $ is the venue being processed, not the whole file. So "$.latitude" reads that venue’s latitude, and "$.location.city" steps into its nested location object.

JSON values bind as plain text. The number 50.901389 binds the text "50.901389", exactly as a spreadsheet cell would. To get a typed literal, add a cast in the template ({?lat} as float), the same casts a CSV mapping uses.

4. to:: what to build

The to: block is ordinary fact syntax with {?var} holes. There are two fact templates, and both run for every venue:

  • the first builds a city, named after ?city, with its country, continent and a link to a coordinates node;
  • the second builds that coordinates node, named after ?lat and ?long.

The link works because both templates build the same IRI, <http://loc.example.com/latlong/{?lat},{?long}>, from the same variables.

What comes out

Building the package expands the mapping before Turtle is generated, so the venue becomes two subjects, exactly as if they had been written by hand:

<http://loc.example.com/city/Brussels> rdf:type owl:NamedIndividual
  , ex:City
  ; rdfs:isDefinedBy <http://example.org/venues/venues>
  .
<http://loc.example.com/city/Brussels> ex:countryCode "BE" .
<http://loc.example.com/city/Brussels> ex:latlong <http://loc.example.com/latlong/50.901389,4.484444> .
<http://loc.example.com/city/Brussels> ex:onContinent "EU" .

<http://loc.example.com/latlong/50.901389,4.484444> rdf:type owl:NamedIndividual
  , ex:latlong
  ; rdfs:isDefinedBy <http://example.org/venues/venues>
  .
<http://loc.example.com/latlong/50.901389,4.484444> ex:lat "50.901389" .
<http://loc.example.com/latlong/50.901389,4.484444> ex:long "4.484444" .

Each subject is typed by its template (ex:City, ex:latlong) and carries its mapped properties: three for the city, two for the coordinates. The owl:NamedIndividual and rdfs:isDefinedBy lines are added to every fact, mapped or hand-written.

When a path does not match

Real exports are messy. Suppose a second venue had no location.city. Then "$.location.city" matches nothing in that venue, and ?city is empty, exactly like an empty spreadsheet cell. Since ?city builds the city’s subject IRI, there is no sensible subject to build, so that whole venue is skipped with a diagnostic, and the other venues are unaffected.

An empty value that only feeds an optional property just drops that one property. JSON null counts as empty too. The other cases:

  • a path that hits an object or an array ("$.location") skips the venue: a whole subtree is never turned into a text literal;
  • a path that hits several values binds the first one and warns; to get one record per value, move that path into for each instead.

The syntax reference lists every rule.


The board’s venues landed in the system as cities and coordinates, linked to each other, without a single line typed by hand. When the board published next month’s export, Dr. Portbridge replaced the file and rebuilt. The mapping did not change.

Chapter 15: Mapping an XML Feed

The vaccination campaign needed transport. The regional bus company was happy to help, and sent its timetable feed: Transport.xml. “It’s what our ticket machines read,” the dispatcher said. “It’s XML, of course.”

Dr. Portbridge had just learned to map the board’s JSON. The tags looked different, but the question was the same one: where does one record start, and where does each value sit inside it?


The problem

Chapter 14 mapped a JSON document by pointing at its values with JSONPath. XML works the same way, with a different path language: XPath. A mapping still needs to know what counts as one record and where each value sits inside it; only the way of writing those paths changes.

The file

Here is the bus company’s feed:

<transport>
  <bus id="25">
    <route>
      <stop id="645">Central</stop>
    </route>
  </bus>
  <bus id="26">
    <route>
      <stop id="700">Harbour</stop>
    </route>
  </bus>
</transport>

Two buses, each with a route and one stop. A stop carries its number in an id attribute and its name as text. We want a bus and a stop out of each bus element, linked together.

The whole mapping


prefix <http://vocab.org/transit/terms/> as transit
prefix <http://www.w3.org/2000/01/rdf-schema#> as rdfs

mapping from <package://Transport.xml> as XPath
  for each "/transport/bus"
  map:
    "@id" as ?id
    "route/stop/@id" as ?stop_id
    "route/stop" as ?stop_label
  to:
    fact <http://trans.example.com/bus/{?id}> a transit:Bus
      transit:stop <http://trans.example.com/stop/{?stop_id}>
    fact <http://trans.example.com/stop/{?stop_id}> a transit:Stop
      transit:code {?stop_id as int}
      rdfs:label {?stop_label}

It reads top to bottom in the same four steps as the JSON mapping.

1. The source

mapping from <package://Transport.xml> as XPath names the file, relative to the package root, and picks the XML reader. Values will be addressed with XPath strings: / steps into a child element, @ reads an attribute.

2. for each: what is a row?

for each "/transport/bus" says: every bus element under the root transport element is one record. The feed has two, so the to: block runs twice.

Leave the line out and the whole document is a single record, exactly as with JSON.

3. map:: where the values are

Each line binds a path to a variable. The paths are relative to the current bus:

  • "@id" reads the bus’s own id attribute;
  • "route/stop/@id" steps into its route, then its stop, and reads the stop’s id;
  • "route/stop" stops at the stop element itself. An element that holds only text binds that text, so this gives the stop’s name.

The same stop element therefore gives two values: its number and its name.

An XPath string literal uses single quotes, as in "stop[@kind='express']", so that it does not end the Dolfin string around it. Square brackets pick among several matches: "route/stop[2]" is the second stop of the route.

4. to:: what to build

There are two fact templates, and both run for every bus:

  • the first builds the bus, named after ?id, with a link to its stop;
  • the second builds the stop, named after ?stop_id, with a code and a label.

The link works because both templates build the same IRI, <http://trans.example.com/stop/{?stop_id}>, from the same variable.

XML values bind as plain text, like JSON values and CSV cells. The {?stop_id as int} cast, written inside the braces, turns the text "645" into an integer literal.

What comes out

Building the package expands the mapping before Turtle is generated, so each bus becomes two subjects:

<http://trans.example.com/bus/25> rdf:type owl:NamedIndividual
  , transit:Bus
  ; rdfs:isDefinedBy <http://example.org/transport/transport>
  .
<http://trans.example.com/bus/25> transit:stop <http://trans.example.com/stop/645> .

<http://trans.example.com/stop/645> rdf:type owl:NamedIndividual
  , transit:Stop
  ; rdfs:isDefinedBy <http://example.org/transport/transport>
  .
<http://trans.example.com/stop/645> transit:code "645"^^xsd:integer .
<http://trans.example.com/stop/645> rdfs:label "Central" .

<http://trans.example.com/bus/26> rdf:type owl:NamedIndividual
  , transit:Bus
  ; rdfs:isDefinedBy <http://example.org/transport/transport>
  .
<http://trans.example.com/bus/26> transit:stop <http://trans.example.com/stop/700> .

<http://trans.example.com/stop/700> rdf:type owl:NamedIndividual
  , transit:Stop
  ; rdfs:isDefinedBy <http://example.org/transport/transport>
  .
<http://trans.example.com/stop/700> transit:code "700"^^xsd:integer .
<http://trans.example.com/stop/700> rdfs:label "Harbour" .

Each subject is typed by its template (transit:Bus, transit:Stop) and carries its mapped properties: one for the bus, two for its stop.

When a path does not match

The rules are the ones you already know from JSON:

  • a path that matches nothing gives an empty value, and if that value builds the subject IRI the whole bus is skipped with a diagnostic;
  • a path that hits an element with child elements ("route") skips the bus: a whole subtree is never turned into a text literal;
  • a path that hits several nodes binds the first one and warns. A route with two stops would do that with "route/stop/@id"; to get one record per stop, iterate over the stops instead with for each "/transport/bus/route/stop".

Real feeds often put their elements in an XML namespace. Declare it with an ordinary prefix line and use that prefix in the path ("/t:transport/t:bus"). The reader only understands a fixed subset of XPath, and anything outside it is an error rather than a silent empty match. The syntax reference lists every rule, including namespaces and the supported XPath subset.


The two buses, their stops and the links between them were in the system in seconds. When the company added a third line the following week, the dispatcher sent a new feed. Dr. Portbridge replaced the file and rebuilt. The mapping did not change.

Chapter 16: Mapping a Spreadsheet

The bus company’s feed covered the routes. The stops themselves lived somewhere else: in a spreadsheet the dispatcher kept on the office computer, workbook.xlsx. “Everything is in the Stops tab,” she said. “Don’t mind the title at the top.”

Dr. Portbridge opened it. A banner, an empty line, then a table: one row per stop. It looked a lot like the CSV files from earlier, only with more than one tab, and with real dates in it.


The problem

A spreadsheet is a CSV file with extras: several sheets in one file, a table that does not always start on the first row, and cells that know they hold a number, a date or a formula. The mapping still reads one row at a time and still finds each value by its column name. It only needs to be told which sheet, and which row holds the column names.

The file

The Stops sheet, as the spreadsheet application shows it:

ABCDE
1Stop register
2
3idopenedoknf
465232024-01-01TRUE25=A4*2 (shows 13046)
5
665242024-01-01 12:00FALSE0.5=1/0 (shows #DIV/0!)

Column opened is formatted as a date, ok holds booleans, n the number of platforms, and f a formula: a stop code computed from the id. The workbook has a second sheet, Info, before this one.

The whole mapping


prefix <http://vocab.org/transit/terms/> as transit

mapping from <package://workbook.xlsx> as XLSX
  sheet "Stops"
  header row 3
  map:
    id     as ?id
    opened as ?opened
    ok     as ?ok
    n      as ?n
  to:
    fact <http://airport.example.com/stop/{?id}> a transit:Stop
      transit:opened {?opened} as date
      transit:open {?ok} as boolean
      transit:platforms {?n} as int

mapping from <package://workbook.xlsx> as XLSX
  sheet "Stops"
  header row 3
  map:
    id as ?id
    f  as ?code
  to:
    fact <http://airport.example.com/code/{?id}> a transit:Code
      transit:value {?code}

There are two mappings over the same sheet: the first builds the stops, the second their codes.

1. The source

mapping from <package://workbook.xlsx> as XLSX names the file and picks the Excel reader. For a LibreOffice .ods file, write as ODS. The selector decides the reader, not the file name.

2. sheet and header row

sheet "Stops" picks the tab by its exact name. Without it the mapping would read the first tab, Info.

header row 3 says the column names are on row 3, numbered as the application numbers its rows. Without it the first non-empty row would be the header, and that is the banner: the mapping would then fail on mapping references unknown column 'id'. Everything above the header is ignored, and the empty row 5 is skipped.

3. map: and to:

From here on it is a CSV mapping: column names on the left of as, one fact template per row, and the usual casts.

What comes out

<http://airport.example.com/stop/6523> rdf:type owl:NamedIndividual
  , transit:Stop
  ; rdfs:isDefinedBy <http://example.org/stops/stops>
  .
<http://airport.example.com/stop/6523> transit:opened "2024-01-01"^^xsd:date .
<http://airport.example.com/stop/6523> transit:open "true"^^xsd:boolean .
<http://airport.example.com/stop/6523> transit:platforms "25"^^xsd:integer .

<http://airport.example.com/code/6523> rdf:type owl:NamedIndividual
  , transit:Code
  ; rdfs:isDefinedBy <http://example.org/stops/stops>
  .
<http://airport.example.com/code/6523> transit:value "13046" .

Look at the values:

  • Dates. The date cell binds 2024-01-01, the way it is displayed, so as date gives an xsd:date. Inside the file it is the number 45292; the reader converts any cell formatted as a date.
  • Numbers. A spreadsheet stores every number as a decimal, so the 25 in column n is really 25.0. It binds "25", and as int accepts it.
  • Booleans. TRUE binds "true", which as boolean reads.
  • Formulas. =A4*2 binds its saved result, 13046. Nothing is recalculated.

Without a cast, a cell still gives a plain string, like a CSV cell: the code is "13046", not a number.

Why row 6 is missing

Stop 6524 is in neither mapping, for two different reasons:

  • its opened cell holds a date and a time. It binds 2024-01-01T12:00:00, and as date refuses it rather than drop the time without telling you: row 2: cannot cast '2024-01-01T12:00:00' to date for property transit:opened.
  • its f cell is an error, #DIV/0!. A row with an error in a bound column is skipped (row 2: cell error #DIV/0! for binding ?code), even when the property is optional: an error is a broken sheet, not a missing value.

row 2 is the second data row, not the application’s row 6. These are warnings: the build goes on with the rows that worked.

The same mappings over the workbook saved as workbook.ods (as ODS) give the same triples, plus a code for 6524 with no value: OpenDocument has no error cells, and LibreOffice saves =1/0 as an empty string.

Spreadsheets are read by radeau build only. The playground and Agrafe cannot open them and report unsupported mapping format 'XLSX'. Word-processor files (.odt, .docx) are not spreadsheets and are not read at all. The syntax reference has every rule.


The dispatcher fixed the formula and changed the time to midnight. Dr. Portbridge rebuilt: two stops, two codes. When the spreadsheet grew a new tab for the night buses, it got a second sheet line, in a second mapping.

Chapter 17: Cleaning Up on the Way In

Dr. Reyes had had enough of typing vaccine names. “Canigard”, “canigard DHP”, “Canigard D.H.P.”: the clinic’s records held every spelling of every product. “The national index lists every authorised product,” she said. “Can’t we just load that?”

Dr. Portbridge downloaded catalogue.csv. Every product was there: horses, cattle, worming tablets, products withdrawn twenty years ago, and somewhere in the middle, the vaccines Happy Paws actually used.


The problem

The mappings in Chapters 14 to 16 turned every record of a file into facts. Those files were written for the clinic. This one is not: it is a national list, and most of it is of no use to a small-animal practice. Its values are not tidy either. One person wrote Dog, another dog , a third CAT, and the doses are bare numbers whose unit is only in the column name.

Four tools clean data up on the way in, without touching the file:

ToolWhat it does
where:keeps only the rows the clinic needs
caseturns the file’s text into the clinic’s own values
{slug(?x)}turns a name into a safe identifier
as quantity(mL)gives a bare number its unit

The file

product,holder,category,target_species,dose_ml,status
Rabivet Pro,Vetolab SA,Vaccine,Dog,1,authorised
Rabivet Pro,Vetolab SA,Vaccine,CAT,1,authorised
Canigard DHP,Vetolab SA,Vaccine,dog ,1,authorised
Canigard DHP (1998 formula),Vetolab SA,Vaccine,Dog,1,withdrawn
Félishield 3,Animalia Pharma,Vaccine,Cat,1,authorised
Kennel-Cough Intranasal,Animalia Pharma,Vaccine,Dog,0.4,authorised
Myxo-Lapin,Cuniculab,Vaccine,Rabbit,0.5,authorised
Psittavax,Avian Health,Vaccine,Bird,0.2,authorised
Equiflu,Equisan,Vaccine,Horse,2,authorised
Bovishield BVD,Agrivet,Vaccine,Cattle,2,authorised
Wormex Plus,Vetolab SA,Antiparasitic,Dog,,authorised

Eleven products. Happy Paws wants seven of them: the authorised vaccines for dogs, cats, rabbits and birds.

The whole mapping


prefix <http://happypaws.com/clinic/vaccines#> as hp

concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Manufacturer:
  has name: one string

concept VaccineProduct:
  has productName: one string
  has manufacturer: one Manufacturer
  has forSpecies: some Species
  has dose: one float  # a volume: quantity(1 mL)

mapping from <package://catalogue.csv> as CSV
  map:
    product        as ?product
    holder         as ?holder
    category       as ?category
    target_species as ?species
    dose_ml        as ?dose
    status         as ?status
  where:
    ?status [ = "authorised" ]
    ?category [ = "Vaccine" ]
    ?species [ != "Horse", != "Cattle" ]
  to:
    fact hp:maker_{slug(?holder)} a Manufacturer
      name {?holder}
    fact hp:vaccine_{slug(?product)} a VaccineProduct
      productName {?product}
      manufacturer hp:maker_{slug(?holder)}
      forSpecies case ?species:
        "dog" -> Dog
        "cat" -> Cat
        "rabbit" -> Rabbit
        "bird" -> Bird
      dose {?dose} as quantity(mL)

The map: block is the one you know from earlier chapters. The new parts come after it.

1. where:: keeping the rows you want


  where:
    ?status [ = "authorised" ]
    ?category [ = "Vaccine" ]
    ?species [ != "Horse", != "Cattle" ]

Each line takes a variable from map: and puts conditions on it, in the same [ ... ] brackets as the constraint blocks of a rule. A row is kept only if every condition holds. The others are dropped silently: they are not errors, just rows you did not ask for.

So the withdrawn 1998 formula goes, the worming tablets go, and the horse and cattle vaccines go.

These comparisons are exact. [ != "Horse" ] would let horse through. That is fine here, because the index writes its large animals one way only. If it did not, starts with and the numeric comparisons ([ >= 0.5 ]) are there too; the syntax reference lists them.

2. case: translating the file’s values


      forSpecies case ?species:
        "dog" -> Dog
        "cat" -> Cat
        "rabbit" -> Rabbit
        "bird" -> Bird

forSpecies needs a Species member, from the enumeration of Chapter 4, not a piece of text. case gives the table: on the left the text as the file writes it, on the right the value to use.

Unlike where:, case is forgiving. It ignores spaces around the cell, upper and lower case, and accents. Dog, dog and DOG all give Dog; CAT gives Cat. One arm per meaning is enough, however many ways the file writes it.

A cell that matches no arm gives no value. For an optional property the line is just left out. forSpecies is some, so the row would be skipped with a warning instead. An else -> Other arm would catch everything else. Here where: has already removed the species the clinic does not treat.

3. slug(): a safe name


    fact hp:vaccine_{slug(?product)} a VaccineProduct

A product name is not an identifier: Félishield 3 has an accent and a space, Kennel-Cough Intranasal a dash. {slug(?product)} lowercases the name, drops the accents and turns every run of other characters into a single _:

ProductFact
Rabivet Prohp:vaccine_rabivet_pro
Félishield 3hp:vaccine_felishield_3
Kennel-Cough Intranasalhp:vaccine_kennel_cough_intranasal

The same name always gives the same identifier, and that does real work here. The index lists Rabivet Pro twice, once for dogs and once for cats. Both rows build hp:vaccine_rabivet_pro, so they add up to one product for two species. Vetolab SA makes two of them and is still a single manufacturer, hp:maker_vetolab_sa, which both point to.

4. as quantity(mL): putting the unit back


      dose {?dose} as quantity(mL)

The dose_ml column holds 1, 0.4, 0.2: numbers whose unit lives only in the column’s name. In Chapter 11 a weight became quantity(45 kg). as quantity(mL) does the same for every row: the cell 0.4 becomes quantity(0.4 mL). From then on the dose is a volume, and compares with doses written in litres, or in any other volume unit.

A cell that is not a number, such as 0.4 mL or n/a, cannot be read this way. Its row is skipped with a warning, as for any failed cast.

What comes out

The last product of the file, with the unit definition the build adds at the end:

hp:maker_avian_health rdf:type owl:NamedIndividual
  , vaccines:Manufacturer
  ; rdfs:isDefinedBy <http://happypaws.com/clinic/vaccines>
  .
hp:maker_avian_health vaccines:name "Avian Health" .

hp:vaccine_psittavax rdf:type owl:NamedIndividual
  , vaccines:VaccineProduct
  ; rdfs:isDefinedBy <http://happypaws.com/clinic/vaccines>
  .
hp:vaccine_psittavax vaccines:productName "Psittavax" .
hp:vaccine_psittavax vaccines:manufacturer hp:maker_avian_health .
hp:vaccine_psittavax vaccines:forSpecies vaccines:Species_Bird .
hp:vaccine_psittavax vaccines:dose "0.2"^^<http://qudt.org/vocab/unit/MilliL> .


# Quantity unit definitions
<http://qudt.org/vocab/unit/MilliL> a qudt:Unit, rdfs:Datatype ; qudt:conversionMultiplier 0.000001 ; qudt:hasDimensionVector <http://qudt.org/vocab/dimensionvector/A0E0L3I0M0H0T0D0> ; qudt:symbol "mL" ; qudt:hasQuantityKind quantitykind:Volume .

Psittavax became hp:vaccine_psittavax, Bird the enumeration member Species_Bird, and 0.2 a typed volume.

Seven rows survived where:. They describe six products from four manufacturers: the two Rabivet Pro rows are one product.


Dr. Reyes scrolled through the new list: six vaccines, one spelling each, every dose in millilitres. “And next year?” she asked. “Next year we download the new index and rebuild,” said Dr. Portbridge. “The cleaning is in the mapping. Nobody has to do it again.”

Chapter 18: Asking Questions

Friday afternoon. The board wanted numbers: how many patients of each species, which vets had no one on their list, which dog owners could be reached by email. The data was all there, typed, checked and linked. Dr. Portbridge just needed a way to ask.


The problem

Facts and mappings fill the graph. Rules add to it. But nothing so far reads it back. In Chapter 12 a downstream system found the senior cats with a SPARQL query, written by hand, in another language. Dolfin has its own way to ask: the query.

A first query


query all_cats:
  ?cat a Cat
    name ?name
    age ?age

This reads like the shape it looks for: a cat, with a name and an age. Every cat that has both gives one row, with columns cat, name and age.

Video 3.3 · Dolfin queries, opening (0:04–0:55). Watch on PeerTube

A query is translated to SPARQL. This one becomes:

SELECT ?cat ?name ?age
WHERE {
  ?cat a Cat .
  ?cat name ?name .
  ?cat age ?age .
}

The indented lines all talk about the subject above them, so ?cat is written once instead of on every line. Patterns are combined with implicit AND, as in the match: block of a rule.

Choosing the columns

The board wants the five oldest cats past ten, oldest first. A constraint filters, and a return block shapes the result:


query senior_cats:
  ?cat a Cat
    name ?name
    age [?age >= 10]
  return
    ?name
    ?age
      order desc
    limit 5
  • age [?age >= 10] binds the age to ?age and keeps only ages of 10 and more.
  • return lists the columns. ?cat is not in it, so it is not returned.
  • order desc under a column sorts on it; limit 5 keeps five rows.

Without a return block, every variable is returned. A variable that starts with ?_ is silent: it is used for matching but never returned.

Owners are not animals: they hang off the owner property. A [ … ] block opens a nested subject, without naming it:


query owners_of_dogs:
  a Dog
    name ?dogName
    owner [
      lastName ?ownerName
      optional email ?email
    ]

The dog has no variable either: a Dog alone is enough when nothing else refers to it. email is optional in the schema, so the line is marked optional: an owner without an email still gives a row, with an empty email column. Without the keyword, those owners would vanish from the result.

Links can be followed backwards. treatedBy goes from an animal to a vet; to start from the vet, read it with is … of:


?vet is treatedBy of ?animal
Video 3.3 · Dolfin queries, chapter “Read a property backwards” (0:54–1:40). Watch on PeerTube

Something, or nothing

Which vets have no patients? This is a question about something that is not there. A none: block keeps a row only if its content never matches:


query vets_without_patients:
  ?vet a Veterinarian
    name ?name
  none:
    ?vet is treatedBy of ?_animal

some: is the opposite: the row stays if the content matches at least once. Inside both, ?vet is the same vet as outside; ?_animal is local.

And when either of two conditions will do, either … or says so:


query heavy_or_old:
  ?pet a Animal
    name ?name
    either weight [ > 30.0 ]
    or age [ > 12 ]

Counting

How many patients of each species? A group by block groups the rows and computes one value per group:


query animals_per_species:
  ?animal a Animal
    species ?species
  group by ?species
    count ?animal as ?total
    ?total >= 2
  return
    ?species
    ?total
      order desc

count ?animal as ?total counts the animals in each species. The line ?total >= 2 drops species with a single patient. sum, average, min and max work the same way as count. count distinct ?x counts each value of ?x once: count distinct ?owner as ?owners counts owners, not the animals that lead to them.

To count per animal while keeping one row per animal, put the count in the body, with what it counts indented below it:


query vaccine_count:
  ?animal a Animal
    name ?name
  count ?shot as ?shots
    ?animal vaccinations ?shot

?animal appears above the count, so each animal gets its own ?shots.

Building on other queries

A query can use another one. Write the question once, reuse it:


query cats_with_vet:
  ?cat a Cat
    treatedBy ?vet
  return
    ?cat
    ?vet

query cat_vet_names:
  cats_with_vet as [
    cat ?c
    vet ?v
  ]
  ?c name ?catName
  ?v name ?vetName

cats_with_vet as [ … ] runs the first query and binds its cat and vet columns to ?c and ?v. A query with a single column binds in one line: some_query as ?x.

Summary

WriteTo
query name:Declare a query
?x a Type / a TypeLook for instances, named or not
prop ?vRead a property
prop [?v > 3]Read and filter it
optional prop ?vRead it if it is there
prop [ … ]Follow a link
is prop of ?xFollow a link backwards
some: / none:Require that something exists, or does not
either … orAccept any of several conditions
group by ?x + count ?y as ?nCount, sum, average per group
returnChoose, rename, sort and limit the columns
other_query as [ … ]Reuse a query

The syntax reference has every form, and the SPARQL each one becomes.


The board meeting lasted twenty minutes. Dr. Portbridge answered every question by running a query she had written that morning. When the treasurer asked for one more number, she wrote a new one on the spot, four lines long.

Epilogue: The Full Picture

Three months later, Happy Paws had treated 847 animals. The system had caught 23 unsafe assignments, flagged 156 overdue vaccinations, and identified 4 at-risk animals that might have been missed. Dr. Reyes had performed 31 surgeries without a single scheduling error. And Pixel, now a healthy six-month-old, had been adopted by the receptionist.


What you’ve learned

Over eighteen chapters, you’ve built a complete ontology from scratch. Here’s what each chapter introduced:

ChapterFeatureWhy you needed it
1PackagesIdentity and metadata for the project
2Concepts & primitive typesDescribing real-world entities
3Properties & referencesConnecting concepts to each other
4EnumsControlled vocabularies instead of free text
5CardinalityConstraining how many values an attribute holds
6Multi-valued attributesLists, required collections, and promoting strings to concepts
7Inheritance (sub)Shared structure without duplication
8FactsAsserting real instances: actual animals, owners, appointments
9Temporal typesReal dates, times, and durations instead of strings
10RulesAutomated reasoning and inference over facts
11Units & quantitiesMeasurements that carry their unit and compare across units
12Constraints & quantifiersValidation and guard rails
13Prefixes & IRIsInteroperability with external systems
14JSON mappingsBulk-loading instances from a JSON export
15XML mappingsBulk-loading instances from an XML feed
16Spreadsheet mappingsBulk-loading instances from an Excel or LibreOffice workbook
17Filters, value tables, slug(), quantity castsLoading a national catalogue with only the rows and values the clinic needs
18QueriesAsking questions of the data, translated to SPARQL

The complete ontology

Here is the full Happy Paws ontology, everything from every chapter, in two files: the package manifest and the ontology itself.


# package.dlf
package <http://happypaws.com/clinic>:
  dolfin_version "1"
  version "1.0.0"
  author "Dr. Helen Portbridge"
  description "The Happy Paws veterinary clinic data model"

# ============================================================
#  clinic.dlf: Happy Paws Veterinary Clinic, Complete Ontology
# ============================================================

# ------------------------------------------------------------
# Schema: concepts, enums, and properties (Chapters 4-9)
# ------------------------------------------------------------

concept Species:
  one of:
    Dog
    Cat
    Bird
    Rabbit
    Reptile
    Other

concept Urgency:
  one of:
    Routine
    Urgent
    Emergency

concept AppointmentStatus:
  one of:
    Scheduled
    InProgress
    Completed
    Cancelled

concept Owner:
  has firstName: one string
  has lastName: one string
  has phoneNumbers: at least 1 string
  has email: optional string
  has address: optional string
  has preferredVet: optional Veterinarian

concept Veterinarian:
  has name: one string
  has licenseNumber: one string
  has specialization: optional string

concept Surgeon:
  sub Veterinarian
  has surgeryCount: one int
  has certifiedProcedures: at least 1 string

concept Dentist:
  sub Veterinarian
  has dentalCertification: one string

concept Intern:
  sub Veterinarian
  has university: one string
  has year: one int

concept Vaccination:
  has vaccineName: one string
  has dateAdministered: one string
  has batchNumber: optional string

concept Animal:
  has name: one string
  has species: one Species
  has age: optional int
  has weight: optional float
  has owner: optional Owner
  has vaccinations: Vaccination
  has allergies: string

concept Dog:
  sub Animal
  has breed: optional string
  has neutered: one boolean

concept Cat:
  sub Animal
  has indoor: one boolean

concept Bird:
  sub Animal
  has wingspan: optional float
  has canFly: one boolean

concept Appointment:
  has animal: one Animal
  has scheduledFor: one date_time
  has reason: one string
  has urgency: one Urgency
  has status: one AppointmentStatus
  has diagnosis: optional string
  has treatments: string
  has notes: optional string

property treatedBy: Animal -> Veterinarian

# ------------------------------------------------------------
# Binding to the outside world (Chapter 13)
# ------------------------------------------------------------

concept DomesticDog:
  @iri_name <http://fao.org/species/CanineDomestic>
  sub Dog

# ------------------------------------------------------------
# Flag concepts and inference rules (Chapter 10)
# ------------------------------------------------------------

concept UnvaccinatedAnimal
concept UnsafeAssignment
concept OverweightAnimal

rule flag_unvaccinated:
  match:
    ?animal a Animal
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a UnvaccinatedAnimal

rule flag_intern_emergency:
  match:
    ?appt a Appointment
    ?appt urgency Emergency
    ?appt animal [ treatedBy [ a Intern ] ]
  then:
    ?appt a UnsafeAssignment

rule assign_primary_vet:
  match:
    ?animal a Animal
    ?animal owner [ preferred_vet ?vet ]
  then:
    ?animal treatedBy ?vet

# ------------------------------------------------------------
# Unit-aware weight thresholds (Chapter 11)
# ------------------------------------------------------------

concept OverweightCat

rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > quantity(40 kg) ]
  then:
    ?dog a OverweightAnimal

rule flag_overweight_cat:
  match:
    ?cat a Cat
    ?cat weight [ > quantity(6 kg) ]
  then:
    ?cat a OverweightCat

# ------------------------------------------------------------
# Guard rails: validation concepts and rules (Chapter 12)
# ------------------------------------------------------------

concept SeniorCat
concept InvalidSurgery
concept UnderVaccinatedDog
concept AtRiskAnimal

rule flag_senior_cat:
  match:
    ?cat a Cat
    ?cat age [ >= 10 ]
  then:
    ?cat a SeniorCat

rule validate_surgery_staff:
  match:
    ?appt a Appointment
    ?appt reason "surgery"
    ?appt animal ?animal
    none ?vet:
      ?animal treatedBy ?vet
      ?vet a Surgeon
  then:
    ?appt a InvalidSurgery

rule check_dog_vaccines:
  match:
    ?dog a Dog
    count ?shot as ?shots
      ?dog vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?dog a UnderVaccinatedDog

rule flag_at_risk:
  match:
    ?animal a Animal
    ?animal age [ > 15 ]
    ?animal weight [ < quantity(2 kg) ]
    count ?shot as ?shots
      ?animal vaccinations ?shot
    ?shots [ = 0 ]
  then:
    ?animal a AtRiskAnimal

Dr. Portbridge closed her laptop and looked around the clinic. The walls were covered in thank-you cards from pet owners. The system hummed quietly in the background, catching errors, inferring relationships, and speaking the language of the wider world. What had started as a napkin sketch on opening day was now a living, breathing data model.

Biscuit dozed at her feet. Pixel purred on the printer. All was well at Happy Paws.

Syntax Overview

Dolfin uses indentation-sensitive syntax, like Python. Blocks are opened with a colon (:) and delimited by consistent indentation, tabs or spaces, but never mixed. All lines inside a block must be at the same indentation level.

Comments begin with # and run to the end of the line.


# This is a comment
concept Foo:
  has bar: string  # inline comment

File types

A Dolfin project uses two kinds of files.

package.dlf: one per project, declares the package identity:


package <http://example.com/my-ontology>:
  dolfin_version "1"
  version "0.3.0"
  author "Alice"
  description "A short description"
  import <http://acme.com/hr> "1.2.0"
FieldRequiredDescription
dolfin_versionyesLanguage version to use (currently "1")
versionyesOntology version (semver string)
authornoAuthor name
descriptionnoHuman-readable description of the ontology
importnoAn ontology this package reuses, one line each. See Imports
reasoningnoReasoning profiles: rdfs, owl, or none. See Reasoning

*.dlf ontology files: one or more files containing declarations (concepts, enums, properties, facts, mappings, rules, queries).


Prefixes

Prefixes bind short aliases to IRI namespaces, enabling interoperability with external vocabularies.


prefix <http://schema.org/> as schema
prefix <http://purl.org/dc/elements/1.1/> as dc

Once declared, a prefix can be used in qualified names:


has address: optional schema.PostalAddress

Prefixes can also be declared in a hierarchical block to share a common path:


prefix com.example:
  Person
  Organization as Org

This is equivalent to:


prefix com.example.Person
prefix com.example.Organization as Org

Imports

A package reuses another ontology by listing it in package.dlf, one import line each:


package <http://acme.com/app>:
  dolfin_version "1"
  version "0.1.0"
  import <http://acme.com/hr> "1.2.0"
  import <http://xmlns.com/foaf/0.1/> from <https://xmlns.com/foaf/spec/index.rdf>
  • The IRI is the imported ontology’s IRI. It is emitted as owl:imports <IRI>.
  • The quoted version is optional and pins the importee’s version.
  • from <IRI> is optional and says where to fetch the ontology. It defaults to the IRI itself.

Locking imports on disk is covered in the Agrafe docs: Import locks on disk.

Using imported names

The import is declared once, for the whole package. Each .dlf file that uses an imported name still declares a prefix over one of the import’s namespaces, then writes prefixed or dotted refs:


prefix com.acme.hr.person as hr
prefix <http://xmlns.com/foaf/0.1/> as foaf

concept Team:
  has lead: one hr:Employee
  has member: some foaf:Person

An import has one namespace per IRI stem (the IRI up to its last # or /). Each namespace is named from its IRIs:

ImporteeNamespace IRIName
Dolfin package com.acme.hr, file person.dlf…/com/acme/hr/person#com.acme.hr.person
Web vocabularyhttp://xmlns.com/foaf/0.1/com.xmlns.foaf.v0_1
  • A Dolfin importee keeps its dotted package path, followed by the file name: one namespace per file of the importee.
  • Other IRIs give their host reversed (without a leading www), then their path. A part that does not start with a letter gets a v in front (0.1 → v0_1), and any character other than a letter, digit or _ becomes _.
  • An IRI prefix (prefix <…> as x) works for any import, whatever its namespace name.

Rules

  • Checked refs. A ref into an imported namespace must name something that import defines. Otherwise it is an error: `hr:Ghost` is not defined by import `http://acme.com/hr`.
  • Not transitive. Only direct imports are nameable. If app imports hr and hr imports org, app cannot reach org’s names through hr: it imports org itself. A deployment still loads the whole import closure for reasoning.
  • Ambiguous names. When two imports map to the same dotted name, a prefix naming it fails with Ambiguous imported namespace. Declare it as an IRI prefix instead.
  • Outside every import. A prefix over a namespace no import covers, such as prefix <http://schema.org/> as schema without a schema.org import, stays unchecked and is emitted as written.

Reasoning

The optional reasoning line of package.dlf picks the inference rules applied to the package’s data:


package <http://acme.com/app>:
  dolfin_version "1"
  version "0.1.0"
  reasoning rdfs, owl
  • rdfs: subclass, subproperty, domain and range entailment.
  • owl: the symmetric and transitive property axioms.
  • Write any subset of rdfs and owl, comma-separated, in any order, each at most once. The formatter writes rdfs first.
  • reasoning none turns reasoning off. It cannot be combined with rdfs or owl.
  • An unknown profile, a repeated profile, or none next to another profile is an error on that word.

Without the line, the profile is chosen automatically: rdfs is always on, and owl is on as soon as a property of the package is symmetric or transitive, whether it is a property declaration or a has field:


concept Truc:
  has link: one Truc
    symmetric

Here link turns owl on. inverse of does not need owl: inverse links are always inferred. Only the package’s own declarations count; to get owl for an imported symmetric property, write reasoning rdfs, owl.

With an explicit reasoning line that leaves owl out (reasoning rdfs, reasoning none), each symmetric or transitive axiom gets warning S030 (`link` is symmetric but OWL reasoning is off: inverse links won't be inferred), with a quick fix that adds owl to the line. Without the line there is no warning: owl turns on by itself.

A rule that only restates an axiom gets warning S031, with a quick fix that deletes the rule:


rule linksym:
  match:
    ?a link ?b
  then:
    ?b link ?a

Here rule `linksym` is redundant: `link` is declared symmetric. The same holds for ?a p ?b and ?b p ?c giving ?a p ?c with p transitive, and for ?a p ?b giving ?b q ?a with q inverse of p (or p of q). Variable names do not matter; a rule with any other match line, a constant, or a second then line is not reported. With owl off, symmetric and transitive links are not inferred, so the rule is not redundant: S031 is then a hint to enable OWL reasoning instead, without a fix. Inverse links are always inferred, so that case stays a warning.

The profile applies wherever the package is reasoned over: queries, validation and radeau export. Validation never infers a type from a domain or range (it would hide a missing sh:class value), but it does apply owl: a symmetric link written on one side satisfies the cardinality of the other. A deployment keeps the profile it was deployed (or last updated) with; editing the reasoning line or a property’s axioms afterwards does not change it until the next deploy or update.


Alias

alias gives one name, imported or local, a short name in one file:


prefix com.acme.hr.person as hr
alias com.acme.hr.person.Employee as WorkingPerson

concept Team:
  has lead: one WorkingPerson

alias hr:Manager as Boss
  • An alias is file-scoped: other files of the package do not see it.
  • It is a declaration, so it can appear anywhere a declaration can, before or after its uses.
  • It introduces no new term. In the Turtle output, WorkingPerson is the target’s IRI.
  • The target must be defined by a direct import or declared in the package. Otherwise: Alias '…' targets '…', which no import defines and the package does not declare.
  • The alias name must not match another declaration or alias of the same file. Otherwise: Alias '…' clashes with a declaration of the same name.

Concepts

A concept defines a category of things and the attributes they can carry.


concept Person:
  has firstName: one string
  has lastName: one string
  has email: optional string
  has age: optional int

An empty concept (no body) is also valid, useful as a flag or tag:


concept FlaggedForReview

Inheritance

A concept can inherit from one or more parents using sub:


concept Employee:
  sub Person
  has employeeId: one string
  has department: one string

concept Manager:
  sub Employee
  has reports: Employee

Multiple parents are comma-separated:


concept PartTimeEmployee:
  sub Employee, Contractor

Attributes

Each attribute is declared with has:

has <name>: [cardinality] <type>

The type can be a primitive or another concept name:


has count: one int
has owner: optional Person
has tags: string          # zero or more (default cardinality)

Cardinality

Cardinality constrains how many values an attribute can hold.

KeywordMeaning
(none)Any number (zero or more)
anyAny number (zero or more, explicit)
oneExactly one (required)
optionalZero or one (like at most 1, but never owl:FunctionalProperty)
someOne or more
NExactly N (integer literal)
at least NN or more
at most NN or fewer
N..MBetween N and M (inclusive)
N..*At least N (same as at least N)

As ranges:

KeywordRange
one1..1
any0..*
some1..*
optional0..1
at least nn..*
at most n0..n

A cardinality whose maximum is 1 (one, at most 1, 1, n..1) makes the property owl:FunctionalProperty; see Turtle.


has name: one string          # required, single value
has nickname: optional string # may be absent
has tags: some string         # at least one
has aliases: string           # any number (default)
has luckyNumbers: 3 int      # exactly three
has phoneNumbers: at least 1 string  # one or more
has referees: at most 2 string        # no more than two
has scores: 1..5 float        # between one and five
has comments: 0..* string     # zero or more (explicit range)

A fact must set every field whose minimum is above zero (one, some, N, at least N, N..M with N > 0), including fields inherited from parent concepts and standalone properties with a range cardinality (property mother: Human -> one Human). A missing one is error S010 (missing required field). A field p of fact F is set by:

  • F’s own p value line (a value inside a nested [ … ] block belongs to that block, not to F);
  • is p of F in another fact (F’s own is p of x sets p on x, not on F);
  • q F in any fact, or is q of x in F, when q is inverse of p (or p of q): inverse links are always inferred;
  • p F in another fact when p is symmetric and owl reasoning is on (see Reasoning). With reasoning rdfs it is not inferred, so S010 stays.

These lines may sit in any file of the package. A field that a rule asserts in its then: (directly or through its inverse) is never reported: whether the rule fires is only known on the reasoned data, where validation checks it. A subconcept cannot relax an inherited minimum: restrictions add up.


Primitive types

TypeDescriptionExamples
stringText"hello", ""
intInteger0, 42, -7
floatFloating-point3.14, -0.5
booleanBooleantrue, false

Temporal types

Four temporal types cover dates, times, timestamps and durations. As range types they name the expected value; as values they use smart literals (see Temporal values).

TypeDescriptionXSD typeValue example
dateCalendar datexsd:datedate(June 1st 2026)
timeWall-clock timexsd:timetime(2:30 PM UTC)
date_timeDate + timexsd:dateTimedate_time(June 1st 2026, 14:30)
durationLength of timexsd:durationduration(1y 6mo)

concept Appointment:
  has scheduledFor: one date_time
  has reminderLead: optional duration

Temporal values

Temporal values are written as smart literals: a type keyword followed by human-friendly notation in parentheses. The keyword fixes the type; the text inside is parsed into the corresponding XSD value.


fact checkup a Appointment
  scheduledFor date_time(June 1st 2026, 2:30 PM)
  reminderLead duration(1d)

Dates: date(...)

Natural-language dates, in either field order (month names are case-insensitive, ordinal suffixes optional):


date(June 1st 2026)      # → 2026-06-01
date(1st June 2026)      # → 2026-06-01
date(Jun 1 2026)

Numeric dates are ambiguous, so the field order must be given, either inline with an as mask, or file-wide with @locale:


date(01/06/2026 as d/m/y)   # → 2026-06-01
date(2001-09-11 as y-m-d)   # → 2001-09-11

The separator (/, -, or .) must match between the value and the mask.

Times: time(...)

24-hour by default; AM/PM (case-insensitive) switches to 12-hour. An optional timezone follows as an offset or an abbreviation:


time(14:30)              # → 14:30:00
time(2:30 PM)            # → 14:30:00
time(08:46:00 UTC)       # → 08:46:00+00:00
time(14:30 +02:00)       # → 14:30:00+02:00

Datetimes: date_time(...)

A date part and a time part separated by a comma or a space:


date_time(June 1st 2026, 2:30 PM)
date_time(01/06/2026 14:30 as d/m/y)

Durations: duration(...)

One or more <number><unit> terms, in any order. Distinct unit tokens avoid clashing with quantity units:

Unitymowdhmins
Meansyearsmonthsweeksdayshoursminutesseconds

duration(7d)             # → P7D
duration(1h 40min)       # → PT1H40M
duration(1y 6mo 3d)      # → P1Y6M3D

Closed concepts

An closed concept defines a closed set of named values. Only the declared variants are valid:


concept Status:
  one of:
    Pending
    Active
    Archived

Enumurated values are referenced by name in attributes and rules:


has status: one Status

Properties

A property is a named relationship declared independently of any concept, rather than inside one. It connects a domain type to a range type:


property worksFor: Employee -> Organization

Cardinality can be specified on either side:


property manages: one Manager -> Employee

The range cardinality (after ->) bounds the values per subject; the domain cardinality bounds the subjects per value. A maximum of 1 on the range side makes the property owl:FunctionalProperty, on the domain side owl:InverseFunctionalProperty (manages above: each employee has at most one manager). No cardinality means any: neither.

A side too long for one line goes in the property’s body as a domain X or range X line, which takes the with block form (see Restrictions). The header is then just property p; body lines and axioms (sub, inverse of, transitive, …) come in any order:


concept Process
concept TemporalRegion
property occupiesTemporalRegion

property happensDuring
  sub occupiesTemporalRegion
  domain Process
    with occupiesTemporalRegion: TemporalRegion
  range TemporalRegion
  • A side may be left out (property occupiesTemporalRegion has neither): no rdfs:domain / rdfs:range is emitted for it.
  • Each side is given once. Giving it both in the one-line D -> R and in a domain / range line, or in two lines, is error S014.
  • Cardinalities belong to the one-line form: domain / range lines take none.
  • domain and range are not reserved: a field or fact property named domain keeps working.

Properties are used in rule patterns and assertions like any attribute.


Annotations

A #@ annotation attaches structured, plugin-style metadata to a concept or a has declaration without touching the grammar — it’s a regular comment whose first non-space character is @. A tool that doesn’t recognise the name simply ignores it; the concept or property it decorates parses and compiles exactly the same either way.


#@ glossary:
#@   label "Cat"
#@   definition "A small domesticated feline."
concept Cat:
  #@ form:
  #@   label "Name"
  has name: string

General shape, at concept level (directly above concept Name:) or property level (directly above one has line):

#@ <name>:
#@   <key> <value>
#@   <key> <value>
  ...
  • The header line is #@ <name>: — the trailing colon marks “a block with indented args follows”. A bare #@ <name> with no colon and no indented lines is also valid and carries no arguments (equivalent to omitting the annotation).
  • Argument lines are key value or key=value — space, not colon: write label "First name", not label: "First name". A value containing spaces needs double quotes; a single-word value doesn’t.
  • Attachment is positional: the annotation sits directly above the concept or has line it decorates. A property-level annotation can only ever apply to the one has line right below it — there’s no “unknown property name” failure mode the way a block referencing property names by string would have.
  • Annotation names and keys are not reserved words in any sense the parser knows about — a concept or property named label or title is unaffected and needs no backtick-escaping.

Two annotations exist today: #@glossary (SKOS metadata — skos:prefLabel and rdfs:label from pref_label / label, skos:definition, skos:altLabel — read by the Turtle generator, see Comments and labels) and #@form, below.


Forms

#@form describes how a concept renders as a data-entry form — labels, placeholder text, help text, input widgets, and grid placement — the same way #@glossary describes SKOS metadata. It’s read by form-rendering consumers (such as Formol); it has no effect on the compiled Turtle/RDF output.


#@ form:
#@   title "Register a person"
#@   display first_name
concept Person:
  #@ form:
  #@   label "First name"
  #@   placeholder "Jane"
  #@   row 1
  #@   col 1
  has first_name: one string

  #@ form:
  #@   label "Last name"
  #@   row 1
  #@   col 2
  has last_name: one string

  #@ form:
  #@   label "Email address"
  #@   widget email
  #@   row 2
  has email: optional string

  #@ form:
  #@   label "Employer"
  #@   row 3
  has employer: optional Organization

Concept-level keys (above concept Name:):

KeyMeaningDefault when absent
titleForm heading#@glossary label, else the concept name
displayWhich property’s value labels this concept’s instances in a reference pickerconsumer-defined fallback

Property-level keys (above one has line):

KeyMeaningDefault when absent
labelField label#@glossary label, else the property name humanized (first_name → “First name”)
placeholderInput placeholder textnone
helpField-level help text#@glossary definition, else none
widgetOverrides the type-inferred widget (below)inferred from the property’s declared type
rowGrid row (fields sharing a row render side by side)one field per row, in has-declaration order
colGrid column within a rowleft to right, in has-declaration order
groupSubheading text grouping several fieldsnone

A property not carrying its own #@ form: block still appears in the rendered form, using these defaults — the annotation overrides layout, it doesn’t opt properties in or out. A concept with no #@form anywhere is fully renderable from its #@glossary annotations and, failing those, its bare has declarations alone.

Widget catalogue — widget’s value is a plain string, matched against this table when present; otherwise the default comes from the property’s type:

has typeDefault widgetNotes
stringtextwidget textarea opts into a multi-line field (never inferred)
int / floatnumber
booleancheckbox
datedatenative date picker
timetimenative time picker
date_timedate_timenative datetime picker
durationdurationfree-text, no native widget
a one of: closed-concept typeselectwidget radio opts into radio buttons
a reference to another conceptreferencerenders an instance picker

widget email is the one widget not inferable from the type system (the property is still plain string underneath) — it always requires an explicit override; it only changes the input type and adds client-side format validation.


Facts

A fact declares a named instance of a concept. Facts use the same indentation-based style as every other Dolfin construct.


fact alice a Person
  firstName "Alice"
  lastName "Chen"
  age 34

The general form is:

fact <id> a <ConceptName>
  <property-name> <value>
  ...
  • <id> is the instance identifier. It can be referenced elsewhere as <id>.
  • a <ConceptName> asserts the type. Multiple types are comma-separated: fact x a Dog, Neutered.
  • Each property line is <name> <value>, where the name matches a has declaration (or inherited one) on the concept.

Value forms

FormExampleMeaning
String literal"Alice"Text value
Integer literal42Integer value
Float literal3.14Floating-point value
Boolean literaltrueBoolean value
Temporal literaldate(June 1st 2026)Date/time/datetime/duration (smart literal)
Enum valueActiveA one of member, unquoted
ReferencealiceAnother named fact of the package
Anonymous block[ ... ]Inline instance (blank node)
Listv1, v2Multiple values for multi-valued properties

References

A reference points to another fact declaration by its identifier:


fact acme a Organization
  name "Acme Corp"

fact alice a Person
  firstName "Alice"
  employer acme

Forward references are allowed; all identifiers are resolved after the full file is parsed. A bare identifier is looked up in the current file first, then in the one other file of the package that declares it.

A leading colon, :acme, is the package’s default prefix, as in Turtle: the package namespace itself, not the acme fact of the current file. Since package.dlf declares no facts, the analyzer warns about every :acme.

Anonymous blocks

When a property’s value is a structured object with no need for a global identifier, use an inline block:


fact alice a Person
  address [
    street "12 Main St"
    city "Paris"
    country "France"
  ]

The block’s type is inferred from the property’s declared range. To specify a subtype explicitly, add a SubType as the first line of the block:


fact rex a Animal
  owner [
    a LegalGuardian
    firstName "Bob"
  ]

Multi-valued properties

For properties with cardinality any, some, or at least N, repeat the property name on multiple lines:


fact alice a Person
  phoneNumbers "555-0001"
  phoneNumbers "555-0002"

Or use a comma-separated list:


fact alice a Person
  phoneNumbers "555-0001", "555-0002"

Both forms are equivalent.

Multiple anonymous blocks

Repeating a property with block values works the same way:


fact rex a Dog
  vaccinations [
    vaccineName "Rabies"
    dateAdministered "2024-03-15"
  ]
  vaccinations [
    vaccineName "Distemper"
    dateAdministered "2024-03-15"
  ]

Scoping and identity

All fact declarations in a module share a flat namespace. An id must be unique within the module. Facts from imported modules are referenced with module.id syntax.


Mappings

A mapping reads a CSV file (or a JSON or XML document, or a spreadsheet, see JSON mappings, XML mappings and Spreadsheet mappings) and expands a fact-shaped template into one set of triples per data row. It is the bulk-loading counterpart to fact: where a fact names one instance by hand, a mapping declares the shape of an instance once and lets the file supply the values.


prefix <http://airport.example.com/> as ap

mapping from <package://Airport.csv> as CSV
  map:
    id as ?id
    stop as ?stop
    latitude as ?lat
    longitude as ?long
  to:
    fact ap:{?id} a transit:Stop
      transit:route {?stop} as int
      wgs84_pos:lat {?lat}
      wgs84_pos:long {?long}

Given Airport.csv:

id,stop,latitude,longitude
6523,25,50.901389,4.484444

the row expands to exactly the triples the equivalent hand-written fact would produce:

<http://airport.example.com/6523> rdf:type transit:Stop .
<http://airport.example.com/6523> transit:route "25"^^xsd:int .
<http://airport.example.com/6523> wgs84_pos:lat "50.901389" .
<http://airport.example.com/6523> wgs84_pos:long "4.484444" .

The general form is:

mapping from <package://<path>> as CSV
  map:
    <column> as ?<var>
    ...
  to:
    fact <subject-template> a <ConceptName>
      <property-name> <value>
      ...

Expansion happens at translation time, before Turtle is generated, so mapped rows are indistinguishable from hand-written facts everywhere downstream.

mapping is a reserved word. from, for, each, sheet, header, row, map, where, starts, case, else, slug and to are not — they are matched only inside a mapping block, so existing schemas that use them as concept or property names keep working.

The source

<package://<path>> resolves relative to the package root — the directory holding package.dlf — not to the file containing the mapping. A path that escapes the package (.., an absolute path, a symlink pointing outside) is rejected.

package:// is the only scheme in v0.1: http(s):// and file:// sources are errors. Five readers exist: CSV, JSONPath (see JSON mappings), XPath (see XML mappings), and XLSX and ODS (see Spreadsheet mappings). A CSV file is read as comma-delimited, header-row-present UTF-8.

The map: block

Under as CSV, each line binds one column to one variable (under as JSONPath the left-hand side is a path instead, see Path bindings):


  map:
    id as ?id
    "stop id" as ?stop

Column names match the header exactly, case included. A column name may be bare or quoted; quote one that contains spaces or reserved characters. A column the file does not have fails the whole mapping, and a variable used in to: but never bound in map: is a parse error.

The to: block

The body is fact syntax (see Facts) with variables in place of literals. More than one fact template is allowed, and all of them expand for every row — the usual way to emit a second, linked instance per row.

Subject templates. A {?var} hole may appear in the subject:

FormExampleYields
Prefixedap:{?id}<http://airport.example.com/6523>
Prefixed with textap:stop{?id}<http://airport.example.com/stop6523>
Full IRI<http://ex.com/{?id}/{?stop}><http://ex.com/6523/25>
Constantap:allStopsthe same subject for every row

Values. Every value form a fact accepts still works, plus:

FormExampleMeaning
Braced variable{?lat}The cell’s raw text
Bare variable?latShorthand for {?lat}
Cast{?stop} as intTyped literal (casts)
IRI template<http://ex.com/{?lat},{?long}>An IRI built from cells
Literal"metres"A constant, same on every row

Checking. Unprefixed types and properties in a template must be declared in the package, exactly as in a hand-written fact. Names under an IRI prefix (transit:Stop, wgs84_pos:lat) belong to an external vocabulary and are not checked, the same as prefixed names anywhere else in Dolfin.

Casts

Without as, a cell becomes a plain string literal. With it, the text is parsed and typed exactly as the same literal would be in a hand-written fact:

as string, as int, as float, as boolean, as date (ISO-8601), and as quantity(<unit>).

as quantity(kW) reads the cell as a number (_ separators allowed, as for int and float) and gives the quantity a human would write: the cell 22 becomes quantity(22 kW). The unit is any unit a quantity(...) literal accepts, conversion included (as quantity(kW as W)), and is checked the same way. A cell that is not a number, such as 22 kW, is a failed cast.

The cast may also sit inside the braces: {?stop as int} means the same as {?stop} as int, and the formatter writes the outside form.

A cast belongs on a value, never on a subject: ap:{?id} as int is a parse error, since a subject template is always string-substituted into an IRI.

Empty cells and bad values

Per-row problems skip the row rather than failing the build; a count of skipped rows is reported at the end of translation.

SituationResult
Empty cell, property is optional or anyThat one assertion is dropped; the rest of the row is emitted
Empty cell, property is one, some or at least NThe whole row is skipped, with a diagnostic
Empty cell bound into the subject templateThe whole row is skipped
Cast fails (as int on "twenty-six")The whole row is skipped, with a diagnostic

Problems with the mapping itself — an unknown scheme, a missing file, a column the header does not have — fail the whole mapping and stop the build.

Filtering rows: where:

A where: block, between map: and to:, keeps only the rows that pass it:


mapping from <package://irve.csv> as CSV
  map:
    code_insee_commune as ?insee
    id_station_itinerance as ?station
    puissance_nominale as ?kw
  where:
    ?insee [ starts with "69" ]
    ?station [ != "", != "Non concerné" ]
    ?kw [ >= 50 ]
  to:
    ...

Each line names a variable from map: and lists conditions in [ ... ], like a constraint block in a rule. Every condition of every line must hold. A row that fails one is dropped silently: it is not a skipped row, and gets no diagnostic.

ConditionHolds when the cell, trimmed, …
= "text", != "text"is (is not) exactly that text, case included
< "text", <=, >, >=sorts before / after that text (so ISO dates compare as dates)
= 50, < 50, … (a number)is a number and compares so; a cell that is not a number fails everything but !=
starts with "text"starts with that text

An empty cell is "": != "" keeps the rows where the cell has a value.

Value tables: case

case turns the text of a cell into a value, for instance an enumeration member:


      siteType case ?site:
        "Voirie" -> OnStreet
        "Parking public" -> PublicCarPark
      openAllHours case ?hours:
        "24/7" -> true
        else -> false

Each arm is "<text>" -> <value>, where the value is anything a fact can say (a member, a reference, a literal); an optional else -> <value> comes last. The cell and the keys are compared after trimming, lowercasing and dropping accents, so " PARKING PUBLIC" and "Parking public" match. Two keys that are equal once compared that way are an error.

A cell that matches no arm, with no else, is an empty value: an optional property loses that one assertion. That is how a value is given on some rows only:


      connectors case ?type2:
        "true" -> Type2
      connectors case ?ccs:
        "true" -> CCS

IRI-safe names: slug()

A template hole can be {slug(?var)} instead of {?var}, in a subject, a value or an <...> IRI. The cell is lowercased, its accents dropped, every run of other characters than a–z and 0–9 replaced by one _, and _ trimmed from both ends:

Cellev:op_{slug(?op)}
ALDI SARLev:op_aldi_sarl
Électricité de Lyonev:op_electricite_de_lyon

Different cells can give the same name (e-totem, E-TOTEM): they then describe one subject. A cell that gives nothing (empty, or only punctuation) counts as an empty cell.

Rows and identity

Rows are independent: no row can reference another row’s bindings, and row order does not affect the resulting triples. Two rows that build the same subject IRI simply contribute to that one subject — identical triples dedupe, and a conflicting value for a one-cardinality property is a validation warning, the same as it would be between two hand-written facts.

JSON mappings

as JSONPath reads a JSON document. Values are addressed by RFC 9535 JSONPath strings instead of column names, and a for each line says what counts as a row. Everything else — the source, the to: block, casts and the empty-value rules — is shared with CSV.


prefix <http://example.com/ns#> as ex

mapping from <package://Venue.json> as JSONPath
  for each "$.venue[*]"
  map:
    "$.latitude" as ?lat
    "$.longitude" as ?long
    "$.location.continent" as ?continent
    "$.location.country" as ?country
    "$.location.city" as ?city
  to:
    fact <http://loc.example.com/city/{?city}> a ex:City
      ex:countryCode ?country
      ex:latlong <http://loc.example.com/latlong/{?lat},{?long}>
      ex:onContinent ?continent
    fact <http://loc.example.com/latlong/{?lat},{?long}> a ex:latlong
      ex:lat {?lat}
      ex:long {?long}

Given Venue.json:

{
  "venue": [
    {
      "latitude": 50.901389,
      "longitude": 4.484444,
      "location": { "continent": "EU", "country": "BE", "city": "Brussels" }
    }
  ]
}

the one venue entry is one row, and both fact templates expand for it:

<http://loc.example.com/city/Brussels> rdf:type owl:NamedIndividual
  , ex:City
  ; rdfs:isDefinedBy <http://example.org/venues/venues>
  .
<http://loc.example.com/city/Brussels> ex:countryCode "BE" .
<http://loc.example.com/city/Brussels> ex:latlong <http://loc.example.com/latlong/50.901389,4.484444> .
<http://loc.example.com/city/Brussels> ex:onContinent "EU" .

<http://loc.example.com/latlong/50.901389,4.484444> rdf:type owl:NamedIndividual
  , ex:latlong
  ; rdfs:isDefinedBy <http://example.org/venues/venues>
  .
<http://loc.example.com/latlong/50.901389,4.484444> ex:lat "50.901389" .
<http://loc.example.com/latlong/50.901389,4.484444> ex:long "4.484444" .

The iteration root: for each

for each "<path>" sits between the header and map:. Each node the path matches is one row; for an array selection such as [*], rows follow document order.

  • Paths in map: are relative to the current match: $ means the matched node, not the document.
  • Without for each, the whole document is the single row, so for each "$" is the same as leaving the line out.
  • A for each that matches nothing yields zero rows and a warning, iteration path '<path>' matched nothing, since a typo looks the same as an empty dataset.
  • A match may be a scalar: for each "$.tags[*]" with "$" as ?tag binds each tag in turn.
  • for each under as CSV is a parse error: for each is not applicable to CSV sources. A second for each line is a parse error too.

Path bindings

On the left of as, a quoted string is a path under as JSONPath and a column name under as CSV. Under JSONPath the path must be quoted: a bare name is a parse error, a JSONPath binding must be a quoted path expression. Quote member names with spaces inside the path: "$['stop id']".

Path syntax, including the for each path, is checked at parse time: invalid JSONPath expression '<path>': <reason>. Whether a path matches anything is only known per row.

Matches and values

Within one row a path may match zero, one or several nodes:

MatchResult
None, or nullAn empty value: the empty-cell rules apply unchanged
One scalarBinds the scalar’s text
One object or arrayThe row is skipped: row <n>: binding ?<var> matched a non-scalar node
Several nodesThe first one binds, with a warning: row <n>: binding ?<var> matched <N> nodes, using the first

To get one row per node instead, move the path into for each.

A scalar binds as raw text, whatever its JSON type: 1 binds "1", true binds "true", a string binds its content. Without a cast the result is a plain string literal, exactly like a CSV cell; with one, the same casts apply. JSON types never pick the literal type.

Numbers bind as re-printed by the JSON parser, not as written: 1.50 binds "1.5". Object members are visited in key order, which only matters when a path selects several members of one object.

A document that is not valid JSON fails the whole mapping: malformed JSON in mapping source '<path>': <reason>.

A shape form of map: (a JSON literal with ?var holes) is planned, as sugar over path bindings.

XML mappings

as XPath reads an XML document. Values are addressed by XPath strings, from a fixed subset of XPath 1.0, and a for each line says what counts as a row. Everything else — the source, the to: block, casts and the empty-value rules — is shared with CSV and JSON.


prefix <http://example.com/ns#> as ex

mapping from <package://Transport.xml> as XPath
  for each "/transport/bus/route/stop"
  map:
    "@id" as ?stop
    "text()" as ?label
  to:
    fact <http://trans.example.com/stop/{?stop}> a ex:Stop
      ex:label ?label

Given Transport.xml:

<transport>
  <bus id="25">
    <route>
      <stop id="645">Central</stop>
      <stop id="651">Airport</stop>
    </route>
  </bus>
</transport>

each <stop> element is one row, so the template expands twice: stop 645 labelled "Central" and stop 651 labelled "Airport".

The XML iteration root: for each

for each "<path>" sits between the header and map:. Each node the path matches is one row, in document order.

  • Paths in map: are relative to the current match: @id is the matched element’s attribute and . is the matched node itself. An absolute path (/… or //…) still starts from the document root, whatever the row.
  • Without for each, the whole document is the single row, and relative paths start at the document node: transport/bus/@id and /transport/bus/@id select the same nodes. for each "." is the same as leaving the line out (/ alone is not a valid path).
  • A for each that matches attributes ("//stop/@id") gives rows that are skipped with row <n>: for each matched an attribute; iterate over its element instead: iterate over the element and bind @id. A for each on text nodes ("//stop/text()") is fine; . binds each one’s text.
  • A for each that matches nothing warns iteration path '<path>' matched nothing and gives no rows.
  • for each and its rules are otherwise those of JSON mappings; a second for each line is a parse error.

XPath path bindings

On the left of as, a quoted string is an XPath expression under as XPath. The path must be quoted: a bare name is a parse error, a XPath binding must be a quoted path expression. Inside a path, write string literals with single quotes ([@kind='express']), so that they do not end the quoted binding.

A path outside the subset, or one that is not XPath at all, is rejected with one of the messages listed there — never a silent empty match.

XML matches and values

Within one row a path may match zero, one or several nodes. The 0/1/N rules are those of JSON mappings:

MatchResult
NoneAn empty value: the empty-cell rules apply unchanged
One scalar node (see below)Binds that node’s text
One element with element childrenThe row is skipped: row <n>: binding ?<var> matched a non-scalar node
Several nodesThe first one in document order binds, with a warning: row <n>: binding ?<var> matched <N> nodes, using the first

What a single node binds:

  • an attribute binds its value: @id on <stop id="645"> binds "645";
  • a text node (text(), CDATA included) binds its text;
  • an element with only text children binds that text: <stop id="645">Central</stop> binds "Central", so one element can give both its @id and its label; an empty element binds an empty value;
  • an element with element children is non-scalar, and the row is skipped. Whitespace between child elements is layout, not content;
  • an element with mixed content, non-blank text beside child elements (<a>x<b>z</b>y</a>), binds all its descendant text concatenated ("xzy"), with a warning: row <n>: binding ?<var> matched mixed content, using its concatenated text.

As with JSON, a value binds as raw text: without a cast it is a plain string literal; with one, the same casts apply. Text is not trimmed.

XPath subset

as XPath supports a small, fixed subset of XPath 1.0. Anything outside it is an error, never an empty result.

FormMeaning
/a/bAbsolute path: child steps from the document root
a/bRelative path: child steps from the current row’s node
.The context node itself (./@id is @id)
*Any element, in any namespace
//b, a//bDescendant-or-self, then a child step: b at any depth below
@attrAn attribute of the context element
text()The text-node children of the context element
a[n]The n-th matching child, 1-based and counted per parent
a[@attr='value']Matching children whose attribute equals the string (either quote)
  • Names may be prefixed (t:bus, @xml:lang): see Namespaces.
  • Predicates apply to element steps (a name or *) only, and may be stacked; each one filters the result of the previous one, per parent. Over <route><stop id="645"/><stop id="651"/></route>, stop[@id='651'][1] selects the second stop while stop[1][@id='651'] selects nothing.
  • An attribute or text() step must be the last step.
  • Results come back in document order, without duplicates (//a//b returns each b once, however many a ancestors it has).
  • //stop[1] is the first stop of each parent, as in XPath, not the first stop of the document.

A construct outside the subset fails with unsupported XPath construct '<c>' — see docs/src/language/reference/syntax.md#xpath-subset, naming the construct:

Written<c> in the message
....
An axis: child::a, ancestor::bus, parent::*child::, ancestor::, parent::
Any function but text(): count(//bus), node(), last(), position()count(), node(), last(), position()
A union: //bus | //stop|
A comparison other than =: [@id!='1'], [@id<'1'], [@id>='1']!=, <, >= (likewise >, <=)
Arithmetic or grouping: [1 + 1], -1, (//bus)[1]+, -, (
A variable: [@id=$id]$id
and, or, div, mod in a predicatethe keyword
Any other predicate: [@id], [route='x'], [@id=1]the whole predicate, e.g. [@id=1]
A wildcard attribute or prefixed wildcard: @*, t:*@*, t:*

Text that is not an XPath expression fails with invalid XPath expression '<path>': <reason>:

ExampleReason
""empty path
/a/, /, a//trailing '/'
a[0]positions are 1-based, '[0]' matches nothing
a[1unbalanced '['
a]unbalanced ']'
a[@id='xunterminated string literal
a bexpected '/' between steps
@id[1]predicates apply only to element steps
@id/xan attribute or text() step must be the last step
a/#unexpected character '#'

Namespaces

XML names can live in namespaces; JSON has none. A path names them through the .dlf file’s own prefix declarations:

  • A prefix in a path (t:bus, @t:id) resolves through the prefix <…> as t declarations of the same .dlf file. Matching is by namespace IRI, so the file’s prefix need not be the one the document uses. Only IRI declarations name a namespace: a package alias such as prefix hr.human as h has no namespace IRI, so h: in a path counts as undeclared. The IRI is compared exactly as written (a trailing # or / matters, as in xmlns).
  • An unprefixed element name matches that local name in any namespace, including none.
  • An unprefixed attribute matches only an attribute in no namespace: @id matches id="1" but not x:id="1". This is deliberate — unprefixed attributes are in no namespace in XML, whatever their element’s namespace.
  • xml: is predefined (@xml:lang) and needs no declaration.
  • A prefix with no declaration fails, even if nothing would have matched: undeclared prefix '<p>' in XPath expression.

prefix <http://trans.example.com/ns#> as t

mapping from <package://Transport.xml> as XPath
  for each "/t:transport/t:bus"
  map:
    "@id" as ?bus
  to:
    fact <http://trans.example.com/bus/{?bus}> a t:Bus

reads <tr:transport xmlns:tr="http://trans.example.com/ns#"><tr:bus id="25"/></tr:transport>: t: and tr: name the same namespace, and @id is a no-namespace attribute. For this one-bus document, the single binding "/t:transport/t:bus/@id" as ?bus without for each binds the same value.

Spreadsheet mappings

as XLSX reads an Office Open XML workbook (.xlsx, and .xlsm, whose macros are never run); as ODS reads an OpenDocument spreadsheet (.ods). One mapping reads one worksheet, row by row, exactly like a CSV file: the map: block binds column names, and the source, the to: block, casts and the empty-value rules are shared with CSV.

The selector says which reader opens the file; the extension is never looked at, so as XLSX on an .ods file fails with malformed XLSX in mapping source '<path>': <reason>. .xls and .xlsb are not read. Neither are .odt and .docx: those are word-processor documents (OpenDocument Text, Word), with tables inside prose and no sheets, so an .odt is not an .ods.

Spreadsheets are read by radeau build and radeau check only. The playground and Agrafe read source files as text and do not include the spreadsheet reader, so there an as XLSX or as ODS mapping fails with unsupported mapping format 'XLSX' (resp. 'ODS').


prefix <http://vocab.org/transit/terms/> as transit

mapping from <package://workbook.xlsx> as XLSX
  sheet "Stops"
  header row 3
  map:
    id     as ?id
    opened as ?opened
    ok     as ?ok
    n      as ?n
  to:
    fact <http://airport.example.com/stop/{?id}> a transit:Stop
      transit:opened {?opened} as date
      transit:open {?ok} as boolean
      transit:platforms {?n} as int

mapping from <package://workbook.xlsx> as XLSX
  sheet "Stops"
  header row 3
  map:
    id as ?id
    f  as ?code
  to:
    fact <http://airport.example.com/code/{?id}> a transit:Code
      transit:value {?code}

Given workbook.xlsx, whose sheet Stops holds a banner, a blank row, then the header on row 3 (shown as the spreadsheet application displays it):

ABCDE
1Stop register
2
3idopenedoknf
465232024-01-01 (date)TRUE25=A4*2 (shows 13046)
5
665242024-01-01 12:00 (date and time)FALSE0.5=1/0 (shows #DIV/0!)

only sheet row 4 survives, in both mappings:

<http://airport.example.com/stop/6523> rdf:type owl:NamedIndividual
  , transit:Stop
  ; rdfs:isDefinedBy <http://example.org/stops/stops>
  .
<http://airport.example.com/stop/6523> transit:opened "2024-01-01"^^xsd:date .
<http://airport.example.com/stop/6523> transit:open "true"^^xsd:boolean .
<http://airport.example.com/stop/6523> transit:platforms "25"^^xsd:integer .

<http://airport.example.com/code/6523> rdf:type owl:NamedIndividual
  , transit:Code
  ; rdfs:isDefinedBy <http://example.org/stops/stops>
  .
<http://airport.example.com/code/6523> transit:value "13046" .

Sheet row 6 is skipped by the first mapping because its date has a time (as date fails, see Dates) and by the second because its f cell is an error.

Sheet and header row

Two optional lines sit between the header and map:, sheet first by convention:

  • sheet "<name>" picks the worksheet. The name is always quoted and must match exactly, case and spaces included. Without it, the mapping reads the first worksheet in tab order (chart sheets are skipped, hidden sheets are not). A name that matches no worksheet fails the whole mapping: mapping references unknown sheet '<name>'.
  • header row <N> says which row holds the column names, counted the way the spreadsheet application numbers its rows (row 1 holds A1). Without it, the header is the first non-empty row, so a title banner above the table would become the header. Rows above the header are ignored; data starts on the row after it. An empty header row fails the whole mapping: mapping header row <N> is empty. header row 0 is a parse error.
  • for each is a parse error under as XLSX / as ODS, as under CSV: the row is the iterator. sheet and header row are parse errors under every other format.

Blank rows are skipped and not counted: row <n> in diagnostics is the 1-based index among the data rows, not the row number the application shows. In the example, sheet row 6 is row 2.

Cell values

A cell binds the text of its stored value, then the usual casts apply. The cell’s type never picks the literal type: without a cast the result is a plain string literal, as for a CSV cell.

CellBinds
TextIts text, verbatim; an empty string is an empty value
NumberShortest text that reads back as the same number, never with an exponent: 25 binds "25" (not "25.0"), 0.1 binds "0.1", -0 binds "0"
Boolean"true" or "false"
Date, timeISO-8601 text, see Dates
FormulaIts cached result, the value the application saved; formulas are never recalculated
Error (#DIV/0!, #REF!, …)The row is skipped: row <n>: cell error <code> for binding ?<var>, even for an optional property
EmptyAn empty value: the empty-cell rules apply

Number formats other than dates are not applied: a cell showing 50% binds "0.5", €3.00 binds "3". A formula saved without a cached value (some libraries write files like that) binds empty, with one warning per mapping, formula cells without a cached value read as empty (first: row <n>, binding ?<var>); opening and saving the file in a spreadsheet application fixes it. An error cell in a column the mapping does not bind is ignored. OpenDocument has no error cells: LibreOffice saves =1/0 as an empty string, so in an .ods the same row binds an empty value instead of being skipped.

Dates in cells

A cell formatted as a date binds ISO-8601 text, so as date works on it:

Cell valueBinds
A date, no time partYYYY-MM-DD: 2024-01-01
A date with a timeYYYY-MM-DDTHH:MM:SS: 2024-01-01T12:00:00
A time of day onlyHH:MM:SS: 18:00:00
An elapsed time ([h]:mm)an ISO-8601 duration: PT36H00M00S

A date with a time is not truncated: as date fails on it (row <n>: cannot cast '2024-01-01T12:00:00' to date for property <p>), so a time part is never dropped silently. A date-formatted =NOW() therefore fails as date; use =TODAY() in the sheet.

Both XLSX date systems (1900, and the 1904 system of old Mac files) are read as the application displays them. Excel’s non-existent 1900-02-29 (serial 60) binds "1900-02-29", which as date rejects. No time zone is added.


Exports

An export is the reverse of a mapping: it runs a query and writes one row per solution to a file. It has no name; its target file (and sheet) identifies it.

The query can be a named one:


query bulletin_s1:
  ?e a Etudiant
    numero ?num
    nom ?nom
  average ?v as ?moyenne
    ?note etudiant ?e
      valeur ?v
  return
    ?num
    ?nom
    ?moyenne
      order desc

export
  from query bulletin_s1
  map:
    "Numéro étudiant" as ?num
    "Nom" as ?nom
    "Moyenne" as ?moyenne as float
  to:
    <export://bulletins.xlsx> as XLSX
    sheet "Semestre 1"

or written in place, under from::


export
  from:
    ?e a Etudiant
      numero ?num
      nom ?nom
    average ?v as ?moyenne
      ?note etudiant ?e
        valeur ?v
  map:
    "Numéro étudiant" as ?num
    "Nom" as ?nom
    "Moyenne" as ?moyenne as float
      order desc
    limit 10
  to:
    <export://bulletins.csv> as CSV

The general form is:

export
  from query <query-name>           (or  from:  + query clauses)
  map:
    <column> as ?<var> [as <cast>]
      [order asc|desc]
      [distinct]
    ...
    [limit <N>]
  to:
    <export://<path>> as <FORMAT>
    [sheet "<name>"]
    [for each "<path>"]

The source

from query <name> names a query (a qualified name, so a query of another package works too). from: holds a query body in place: query clauses and an optional group by, but no return: the map: block plays that part.

The map: block

Each line writes one variable into one column: the column header (a Name, or a String when it holds spaces or other characters), as, the variable, and an optional cast written after it (as int, float, string, boolean, date or quantity(<unit>), the casts of a mapping read in reverse). The column modifiers of return (order asc|desc, distinct) go in an indented block under the line they apply to, and limit <N> may close the block, as in return.

map: is required with from:. With from query, it may be left out: the columns are then the query’s return columns, in order.

The to: block

The first line is the target file, <export://<path>> as <FORMAT>, followed by the optional sheet "<name>" (spreadsheets) and for each "<path>" (JSON) lines of a mapping, each at most once. header row is a reading option and is refused here.

export is not a reserved word: it is recognised only at the start of a top-level declaration, so a concept or property named export keeps working. from, map, limit, to, sheet, for and each are contextual too, as in a mapping; only query is reserved.

Rules

A rule defines an if-then inference. When all patterns in the match: block hold, the assertions in the then: block are applied.


rule classify_senior:
  match:
    ?p a Person
    ?p age [ >= 65 ]
  then:
    ?p a SeniorPerson

Variables

Variables begin with ?. They bind to values during matching and can be referenced in assertions:


rule link_preferred_contact:
  match:
    ?org a Organization
    ?org primaryContact ?person
  then:
    ?person worksFor ?org

Match patterns

Each line in match: is a pattern. All patterns are combined with implicit AND.

Type pattern: checks that a variable is an instance of a concept:


?x a SomeConcept

Triple pattern: checks that a subject has a property with a given value:


?x someProperty someValue
?x someProperty ?y
?x someProperty "literal"
?x someProperty 42

Constraint block: inline conditions on a value using [...]:


?x age [ > 18 ]
?x status [ = "Active" ]
?x address [ city "Paris" ]
?x manager [ a Director ]

Multiple constraints in a block are AND-combined:


?x score [ >= 50, <= 100 ]

Constraint blocks can be nested:


?x owner [ address [ country "France" ] ]

Property paths

In a match: triple, the property can be a path: ?x <path> ?y. The pattern matches when ?y can be reached from ?x by following the path.

OperatorMeaningPrecedence
( )Grouping1 (tightest)
p+One or more steps of p2
p*Zero or more steps of p2
p?Zero or one step of p2
^pInverse: follow p backwards3
!(p|q)One step through any property except those listed (also !p, !^p)3
p1/p2Sequence: p1, then p24
p1|p2Alternative: p1 or p25 (loosest)

So x|y/z is x|(y/z), ^p+ is ^(p+), and ^p/q is (^p)/q. A path is written without spaces; a single name, even (p), is a plain triple.

Ancestors, at any depth:


rule ancestor:
  match:
    ?a parentOf+ ?d
  then:
    ?a ancestorOf ?d

Siblings, plus the person themself (zero steps of siblingOf):


rule sibling_or_self:
  match:
    ?x a Person
    ?x siblingOf? ?y
  then:
    ?x familyOf ?y

Uncles: a brother of the father or of the mother:


rule uncle:
  match:
    ?u brotherOf/(fatherOf|motherOf) ?n
  then:
    ?u uncleOf ?n

Limits. Paths only appear in match:; a then: assertion must be a plain triple, and ?a ancestorOf+ ?b in then: is a parse error. A path that can match zero steps (p*, p?, or any path built only from them) needs at least one end bound by another pattern of the rule, as ?x a Person does above. With both ends free, the reasoner rejects the rule when it loads it (“needs one end bound by another condition”). Cycles in the data are fine: the search does not loop.

Comparison operators

Used inside constraint blocks:

OperatorMeaning
=Equal
!=Not equal
<Less than
<=Less than or equal
>Greater than
>=Greater than or equal

A comparison takes a literal or a variable, not a name. To match a named individual, write the triple directly: ?x status Active.

Quantifiers

Quantifiers express conditions over collections of matching bindings:

QuantifierMeaning
allEvery binding must satisfy the sub-patterns
noneNo binding may satisfy the sub-patterns
at least NAt least N bindings must satisfy the sub-patterns
at most NAt most N bindings may satisfy the sub-patterns
exactly NExactly N bindings must satisfy the sub-patterns
between N, MBetween N and M bindings (inclusive)

rule require_manager_approval:
  match:
    ?req a Request
    none ?approver:
      ?approver a Manager
      ?req approvedBy ?approver
  then:
    ?req a UnapprovedRequest

An optional constraint block on the quantifier variable filters the set being quantified:


at least 2 ?member [ a SeniorEmployee ]:
  ?member worksIn ?dept

Aggregates in rules

A match: line can compute a value over a group of bindings, with the same line as a query’s per-row aggregation: an aggregate, the aggregated variable, as, the result variable, then an indented block of match lines. The block is grouped by the variables it shares with the lines above it, so it computes one value per binding of those lines.


rule busy_owner:
  match:
    ?owner a Owner
    count ?pet as ?n
      ?pet owner ?owner
    ?n [ > 5 ]
  then:
    ?owner a BusyOwner

rule under_vaccinated_dog:
  match:
    ?dog a Dog
    count ?shot as ?shots
      ?dog vaccinations ?shot
    ?shots [ < 2 ]
  then:
    ?dog a UnderVaccinatedDog
AggregateValue
countNumber of bindings of the variable
count distinctNumber of distinct values of the variable
sumSum of a number, duration or quantity variable
averageAverage of a number, duration or quantity variable
min, maxSmallest / largest number, date, duration or quantity
  • The aggregated variable must be bound inside the block.
  • The result variable is bound from the next line on: later match: lines and then: can use it, an earlier line or the block itself cannot. A variable bound only inside the block is not visible after it.
  • ?n [ > 5 ] is a condition on a bound variable: the value must satisfy the constraint block, as in ?x age [ > 5 ].
  • then: can write the computed value: ?owner pet_count ?n.
  • An aggregate cannot be nested in another aggregate’s block (a parse error). Compute the inner value in a separate rule.

Semantics.

  • A count (or count distinct) over an empty group is 0: the under_vaccinated_dog rule above fires for a dog with no vaccination. sum, average, min and max have no value over an empty group, so the rule does not fire for that row.
  • Quantities of the same unit keep their unit (sum of kg values is in kg). Quantities of mixed units of the same dimension are converted to the SI unit first (g and kg sum to kg). Mixing dimensions is an error.
  • Rules are stratified around aggregates: a rule that aggregates over a property runs after every rule that can derive that property. A cycle through an aggregate (a rule whose aggregate depends, directly or through other rules, on its own conclusion) is a compile-time error.

Status. The SHACL output (radeau build --emit shacl, radeau validate) runs aggregate rules as SHACL-AF sh:SPARQLRules, with the semantics above; mixed units are converted with the qudt: unit definitions of the Turtle output, which the data must include. The N3 output writes such a rule commented out, with the reason.

Then assertions

Each line in then: asserts a new fact:

Type assertion: classifies a variable as an instance of a concept:


?x a SomeConcept

Triple assertion: asserts a property relationship:


?x someProperty ?y
?x someProperty "value"

Nested rules

A then: block can contain a nested match:/then: block for conditional sub-inferences:


rule complex_inference:
  match:
    ?x a Foo
  then:
    match:
      ?x bar ?y
    then:
      ?y a Baz

Queries

A query asks a question of the data. Where a rule adds facts, a query only reads them: it describes a shape to look for and the columns to give back. Each query is translated to one SPARQL 1.1 SELECT.


query senior_cats:
  ?cat a Cat
    name ?name
    age [?age >= 10]
  return
    ?name
    ?age
      order desc
    limit 5
SELECT ?name ?age
WHERE {
  ?cat a Cat .
  ?cat name ?name .
  ?cat age ?age .
  FILTER (?age >= 10)
}
ORDER BY DESC(?age)
LIMIT 5

A query body is, in this order: any number of clauses, an optional group by block, and an optional return block.

Variables in queries

Variables are written ?name, as in rules. A variable starting with ?_ (?_animal) is silent: it takes part in matching but is never returned.

Subject patterns

A subject pattern names a subject and lists, one per indented line, the properties it must have. All lines are AND-combined.


?cat a Cat            # typed subject
  name ?name
  age ?age

a Cat                 # anonymous typed subject (a fresh silent variable)
  name ?name

?cat                  # untyped subject
  name ?name

A pattern with a single property fits on one line:


?animal treatedBy ?vet
?animal treatedBy [ a Intern ]
?vet is treatedBy of ?animal

Property patterns

Inside a subject block, each line is one of:

FormMeaning
name ?nameThe subject has name; bind its value to ?name
urgency EmergencyThe value is this named individual or literal
age [ > 12 ]The value satisfies a constraint block
age [?age >= 10]Bind the value to ?age and filter on it
optional email ?emailBind ?email when the property exists, keep the row otherwise (OPTIONAL)
is treatedBy of ?animalInverse: ?animal treatedBy the subject
owner [ … ]Nested anonymous subject, one property per line
either … or …At least one branch holds (UNION)

A nested subject spans several lines between [ and ]:


query owners_of_dogs:
  a Dog
    name ?dogName
    owner [
      lastName ?ownerName
      optional email ?email
    ]
SELECT ?dogName ?ownerName ?email
WHERE {
  ?_s0 a Dog .
  ?_s0 name ?dogName .
  ?_s0 owner ?_n1 .
  ?_n1 lastName ?ownerName .
  OPTIONAL { ?_n1 email ?email . }
}

either and or take a property and a constraint block. Add as many or lines as needed:


query heavy_or_old:
  ?pet a Animal
    name ?name
    either weight [ > 30.0 ]
    or age [ > 12 ]

Existence: some: and none:

some: keeps a row only if its inner clauses match at least once (FILTER EXISTS); none: keeps it only if they never match (FILTER NOT EXISTS). Variables shared with the outer query refer to the same resource; the others are local to the block.


query vets_without_patients:
  ?vet a Veterinarian
    name ?name
  none:
    ?vet is treatedBy of ?_animal
SELECT ?vet ?name
WHERE {
  ?vet a Veterinarian .
  ?vet name ?name .
  FILTER NOT EXISTS {
    ?_animal treatedBy ?vet .
  }
}

Filters

A clause of the form ?var op value filters rows, value being a literal or another variable. The operators are the comparison operators.


?patients > 3
?directorAvg > ?globalAvg

Grouping and aggregation: group by

A group by ?var block, after the clauses, groups rows by ?var. Each line inside is either an aggregation kind ?input as ?result, with kind one of count, count distinct, sum, average, min, max, or a filter on a result (HAVING). count distinct ?x counts each value of ?x once (COUNT(DISTINCT ?x)).


query animals_per_species:
  ?animal a Animal
    species ?species
  group by ?species
    count ?animal as ?total
    ?total >= 2
  return
    ?species
    ?total
      order desc
SELECT ?species (COUNT(?animal) AS ?total)
WHERE {
  ?animal a Animal .
  ?animal species ?species .
}
GROUP BY ?species
HAVING (COUNT(?animal) >= 2)
ORDER BY DESC(?total)

Without a return block, a grouped query returns the group by variable and the aggregates: other variables have no single value per group.

Aggregating per row

An aggregation can also stand in the body, followed by an indented block of clauses. It computes one value per row of the rest of the query: the block is grouped by the variables it shares with the clauses above it. With no shared variable, it computes a single value over all the data.


query vaccine_count:
  ?animal a Animal
    name ?name
  count ?shot as ?shots
    ?animal vaccinations ?shot
SELECT ?animal ?name ?shots
WHERE {
  ?animal a Animal .
  ?animal name ?name .
  {
    SELECT ?animal (COUNT(?shot) AS ?shots)
    WHERE {
      ?animal vaccinations ?shot .
    }
    GROUP BY ?animal
  }
}

Only variables written above the aggregation are shared with it.

The return block

return lists the result columns, one per line. Without a return block, every non-silent variable is returned, in order of first appearance.

LineMeaning
?nameReturn ?name as column name
ownerName ?nReturn ?n as column ownerName
limit 10At most 10 rows (LIMIT)

A column may carry an indented modifier block:

ModifierMeaning
order asc / order descSort on this column. Several sorted columns sort in column order
distinctDrop duplicate rows. Applies to the whole result

Composing queries

A query can use another query of the same file as a clause. Its rows, after its own grouping, ordering and limit, are joined with the rest of the body (a SPARQL subquery). Bind its columns to local variables by column name:


query cats_with_vet:
  ?cat a Cat
    treatedBy ?vet
  return
    ?cat
    ?vet

query cat_vet_names:
  cats_with_vet as [
    cat ?c
    vet ?v
  ]
  ?c name ?catName
  ?v name ?vetName
SELECT ?c ?v ?catName ?vetName
WHERE {
  {
    SELECT (?cat AS ?c) (?vet AS ?v)
    WHERE {
      {
        SELECT ?cat ?vet
        WHERE {
          ?cat a Cat .
          ?cat treatedBy ?vet .
        }
      }
    }
  }
  ?c name ?catName .
  ?v name ?vetName .
}

The short form heavy_dogs as ?dog binds the first column. Binding a column the called query does not return is an error, and so is a query that uses itself, directly or not.

Names in the SPARQL

The examples on this page show property and concept names as written. When a package is compiled to Turtle, each query is written out as a commented SPARQL SELECT, with every name resolved to its IRI in the package.


Names and identifiers

Simple names are alphanumeric identifiers (with underscores): Person, firstName, status.

Qualified names use dot-notation to reference names in a namespace: schema.Person, com.example.Thing.

IRIs are enclosed in angle brackets: <http://example.com/Thing>. They can appear as package names and prefix targets.

Variables begin with ?: ?person, ?count.


The @iri_name annotation

The @iri_name annotation overrides the IRI that Dolfin derives from the file’s path. It has several forms.

File-level, string. At the top of an ontology file, before any declaration, a string replaces the last segment of the file’s namespace:


@iri_name "custom-segment"

concept Foo:
  has bar: string

File-level, IRI. An absolute IRI replaces the whole namespace of the file:


@iri_name <http://animals.kingdom/Mammalian>

Concept-level. Inside a concept body, an absolute IRI is used verbatim for that concept only:


concept Foo:
  @iri_name <http://schema.org/Person>
  has bar: string

Property-level. Inside a property body, the same absolute form sets that property’s IRI. A property can have a body with only this line:


property occupiesTemporalRegion
  @iri_name <http://purl.obolibrary.org/obo/BFO_0000199>

Field-level. Indented under a has line, it sets the IRI of the field’s property. It mixes with the field’s axioms in any order. Every has of that name in the file is the same property, so they all get this IRI; two fields of one name giving different IRIs is an error (S019):


concept Truc:
  @iri_name <https://example.com/Truc>
  has dependence: Dep
    @iri_name <https://example.com/truc_dep>
    inverse of dependent_of

Entity-level @iri_name takes only the absolute <…> form; the string form is file-level only.

This is useful when the file or concept name doesn’t match the IRI expected by external systems, or contains characters that cannot appear in an IRI. See Turtle & OWL Correspondence for the resulting IRIs.


The #@ owl marker

A bare #@ owl line in a concept’s leading comment block records that the concept is an OWL class: the Turtle output types it owl:Class instead of rdfs:Class. Importing Turtle adds it to every concept the source declared owl:Class, so that type survives a round trip.


#@ glossary:
#@   pref_label@fr Chat
#@ owl
# A small feline.
concept Cat

Place it after any #@ glossary: block, on its own line. See Turtle & OWL Correspondence.


File-level directives

Two directives set defaults for temporal values across the whole file. Like @iri_name they sit at the top of the file; they may appear before or after prefix statements, in any order, but before the first declaration.

@locale

@locale sets the field order (and separator) for numeric dates that carry no inline as mask:


@locale d/m/y

fact bob a Person
  birthDate date(01/06/2026)   # → 2026-06-01, no mask needed

The order is any arrangement of d, m, y (e.g. m/d/y, y-m-d). The separator in the directive must match the one used in numeric dates. Without @locale (and without an inline mask), a numeric date is a parse error.

@timezone

@timezone sets the default timezone applied to any time / date_time value that has no inline offset. It accepts an IANA name, a fixed offset, or UTC/Z:


@timezone Europe/Brussels

fact e a Event
  startsAt time(14:30)        # → 14:30:00+01:00

An inline timezone always wins over @timezone; an inline as mask always wins over @locale. Neither directive affects date or duration values.

IANA names such as Europe/Brussels are resolved from a bundled timezone database, available when the parser is built with the iana-tz feature (on by default). Fixed offsets (+02:00) and abbreviations (CET, UTC) work regardless.

Type System

Every attribute (has) and property range in Dolfin has a type. A type is one of:

  • a primitive (string, int, float, boolean),
  • a temporal type (date, time, date_time, duration),
  • a quantity type (mass, length, speed, …),
  • a concept you declared, or
  • a closed concept (one of).

Types are what appears after the : in a has declaration or on either side of a property’s ->:


concept Person:
  has name: one string
  has born: optional date
  has employer: optional Organization

Primitive types

TypeDescriptionXSD datatypeValue examples
stringTextxsd:string"hello", ""
intIntegerxsd:integer0, 42, -7
floatFloating-pointxsd:float3.14, -0.5
booleanBooleanxsd:booleantrue, false

An int value is typed xsd:integer (unbounded), never xsd:int. See Turtle correspondence.


Temporal types

Temporal types carry dates, times, timestamps and durations. Their values are written as smart literals, a type keyword plus human-friendly notation in parentheses (see Temporal values). Dolfin parses the notation and emits the exact XSD lexical form.

TypeXSD datatypeSmart literalEmitted value
datexsd:datedate(June 1st 2026)2026-06-01
timexsd:timetime(2:30 PM UTC)14:30:00+00:00
date_timexsd:dateTimedate_time(June 1st 2026, 14:30)2026-06-01T14:30:00
durationxsd:durationduration(1y 6mo)P1Y6M

duration is also the time dimension of the quantity types below: duration(20 min) and quantity(20 min) are the same value, "PT20M"^^xsd:dayTimeDuration.

Two file-level directives supply defaults used while resolving temporal values:

  • @locale d/m/y: field order for numeric dates without an inline as mask.
  • @timezone <zone>: default timezone for time / date_time values without an inline offset.

See File-level directives. Inline information (an as mask, an inline offset) always overrides the directive.


Quantity types

A physical dimension is a built-in lowercase type, used like float or date. Its values are quantity(...) literals of that dimension, in any unit of it:


concept Dog:
  has weight: mass
  has topSpeed: optional speed
  has length: optional length   # a field may share a type's name

fact rex a Dog
  weight quantity(45 kg)
  topSpeed quantity(42 km/h)
  length quantity(1.2 m)
TypeRDF rangeValue exampleEmitted value
massquantitykind:Massquantity(45 kg)"45"^^<http://qudt.org/vocab/unit/KiloGM>
lengthquantitykind:Lengthquantity(1.2 m)"1.2"^^<http://qudt.org/vocab/unit/M>
speedquantitykind:Velocityquantity(42 km/h)"42"^^<http://qudt.org/vocab/unit/KiloM-PER-HR>
durationxsd:durationquantity(20 min)"PT20M"^^xsd:dayTimeDuration

The other quantity types are current, temperature, amount, luminosity, acceleration, force, pressure, energy, power, frequency, area, volume, density and momentum; the Units & Quantities reference lists each one with its dimension and QUDT quantity kind. A value of another dimension, or a bare number, is error S008. The names are not keywords: length or power remain valid field names. Older models that declare concept Mass and use Mass or unit.Mass as a range still compile, with lint warnings and quick-fixes towards mass (see legacy names).


Concept types

Any concept name is a type. Using it as a range means “an instance of that concept” (or a subtype of it, since sub inheritance is transitive):


concept Animal
concept Dog:
  sub Animal

concept Shelter:
  has residents: some Animal   # Dogs qualify, being Animals

Concept-typed values are references (:id) or inline anonymous blocks ([ ... ]) in facts, see Facts.

Class expressions: or, and, not

Concept types combine with or (either), and (both) and not (anything but):


concept Clinic:
  has patient: (Dog or Cat)
  has staff: (Person and not Intern)
  has partner: (Hospital or (Clinic and not Franchise))

property treats: (Vet or Nurse) -> not Human
  • Every or / and group is written in parentheses: (A or B), (A and B and C). A single name needs none.
  • Groups nest: (X or (Y and not T)).
  • and and or cannot be mixed in one group: (A or B and C) is an error, write (A or (B and C)) or ((A or B) and C).
  • not applies to what directly follows it: not T, or a whole group with not (A or B). It needs no parentheses, also at the top level (has partner: not Competitor).
  • or accepts primitives ((string or int)); and and not work on concepts only.
  • or, and and not are reserved words: a concept or field named and is written `and`.

See Turtle correspondence for the OWL form.

Video 2.4.2 · Union types, opening (0:04–0:38). Watch on PeerTube

Restrictions: with

with p: C is a type made of the things whose property p points to a C: an anonymous owl:Restriction. It declares nothing: p must already be a property (a property declaration or a has field), and a name that is unknown or is a concept is an error.

has and with are different verbs:

VerbMeaningDefault count
has p: C (concept body)declares the field p, with a global range0..* (any)
with p: C (type expression)a local constraint on an anonymous classat least one

A bare with p: C means “at least one p that is a C”: a restriction that required nothing would be useless. The forms are:

DolfinMeaning
with p: Cat least one value of p is a C
with p: only Cevery value of p is a C
with p: 2 Cexactly 2 values of p are Cs
with p: at least 2 C, with p: at most 3 C, with p: 1..4 Ca bounded number of C values
with p: = alice, with p: = "Bob"p has this exact value
with ^p: Cthe inverse of p: something that is a C points to this one through p

The counts are the cardinality keywords of has. only stays an ordinary identifier everywhere else.

Inline form. T with p: C is (T and (with p: C)), usable wherever a type is. Several withs chain, each one a member of the same and:


concept Person:
  has hasChild: Person
property knows: Person -> Person

property admires: (Person with hasChild: Person) -> Person
property trusts: Person with hasChild: Person with knows: only Person -> Person

A filler is a single name or a parenthesised group, so a chained with never attaches to the previous filler. Narrow a filler inline with parentheses: with hasChild: (Person with knows: Person).

Block form. Under sub T, equivalent to T, or a property’s domain T / range T line, an indented block of with lines is the intersection of T with every line. A block under a with line narrows that line’s filler:


concept Process
concept TemporalRegion
concept TemporalInterval
property occupiesTemporalRegion: Process -> TemporalRegion
property occurrentPartOf: Process -> Process

concept Act:
  sub Process
    with occupiesTemporalRegion: TemporalRegion   # Process and ∃occ.(TemporalRegion and …)
      with occurrentPartOf: TemporalInterval
    with ^occurrentPartOf: Process
  • with applies to concepts only: int with p: C and with age: int are errors.
  • with is a reserved word: a concept or field named with is written `with`.

See Turtle correspondence for the OWL form.

Defined concepts: equivalent to

sub X says every member of the concept is an X (necessary condition). equivalent to X says the concept is X: the members are exactly the things that are Xs (necessary and sufficient, owl:equivalentClass). X is any type expression, and takes the same with block as sub:


concept Person:
  has hasChild: Person

# Parent ≡ Person ⊓ ∃hasChild.Person
concept Parent:
  equivalent to Person
    with hasChild: Person

# Mother ⊑ Person ⊓ ∃hasChild.Person (necessary only)
concept Mother:
  sub Person
    with hasChild: Person
  • A concept may have several equivalent to lines, next to sub and has lines. Each line, and each type in equivalent to A, B, gives its own owl:equivalentClass statement, so the concept is equivalent to all of them. A with block needs a single type on its line.
  • There is no = form: equivalent to is the only way to say it.
  • Names in the line are checked like sub parents: an undeclared name is an error.

See Turtle correspondence.

Property domain and range lines

A class expression too long for property p: D -> R goes in a domain or range body line, with the same block form:


concept Process
concept TemporalRegion
concept TemporalInterval
property occupiesTemporalRegion
property occurrentPartOf

property partOfProcess
  sub occurrentPartOf
  domain Process
    with occupiesTemporalRegion: TemporalRegion
      with occurrentPartOf: TemporalInterval
  range Process
    with ^occupiesTemporalRegion: TemporalRegion

Each side is given once, either in the one-line form or in one line (error S014 otherwise). See Properties and Turtle correspondence.


Closed concepts (one of)

A closed concept enumerates a fixed set of named members; only those members are valid values:


concept Status:
  one of:
    Pending
    Active
    Archived

concept Ticket:
  has status: one Status

Members are referenced unquoted (Active), not as strings.


Cardinality

Cardinality is orthogonal to type: it constrains how many values of the type an attribute may hold (one, optional, some, N, N..M, …). It is written before the type. See Cardinality for the full table.


has tags: some string          # one or more strings
has born: at most 1 date       # zero or one date
has visits: date_time          # any number of timestamps

Units & Quantities

A quantity is a physical measurement, a number paired with a unit: 45 kg, 42 km/h, 9.81 m/s^2, 10 N/m^2. Dolfin writes them as smart literals, exactly like the temporal values of Chapter 9: a keyword followed by human-friendly notation in parentheses.


fact rex a Dog
  weight quantity(45 kg)

Behind the keyword sits a small dimensional-analysis engine. It knows the SI system, resolves prefixes, does unit arithmetic, and reduces everything to the seven SI base dimensions so that quantities written in different units can still be compared.


The quantity(...) smart literal

The general form is a value, then a unit expression:

quantity( <number> <unit-expression> )
WrittenMeans
quantity(45 kg)45 kilograms
quantity(42 km/h)42 kilometres per hour
quantity(9.81 m/s^2)9.81 metres per second squared
quantity(10 N/m^2)10 newtons per square metre
quantity(1.609 km.s^(-1))1.609 kilometres per second
quantity(273.15 K)273.15 kelvin
quantity(1e3 Pa)1000 pascals

The literal is resolved at compile time: the value, the unit, and the dimension vector are computed once, when the file is parsed. A malformed quantity (unknown unit, impossible conversion) is a compile error, not a runtime surprise (see Errors).

Video 3.2.2 · Units and quantities, opening (0:04–0:25). Watch on PeerTube

Unit expressions

A unit expression is one or more unit tokens combined with . (multiply), / (divide), and ^ (power):


quantity(5 kg)             # a single unit
quantity(42 km/h)          # a quotient
quantity(9.81 m.s^(-2))    # a product with a negative power
quantity(10 N/m^2)         # 'm^2' is metre-squared
  • . multiplies the units on either side: N.m is a newton-metre.
  • / divides: km/h is kilometres per hour. A unit after / has its exponent negated, so km/h and km.h^(-1) mean the same thing.
  • ^n raises a unit to a power. Negative powers use ^(-n) or a bare ^-n: s^(-1), m^2, m.s^-2.

Prefixes

SI prefixes compose with any unit, so you do not enumerate km, cm, mm separately, they are k+m, c+m, m+m:

All 24 SI prefixes are recognised, from quetta (Q, 10³⁰) down to quecto (q, 10⁻³⁰):

PrefixQRYZEPTGMkhda
Factor10³⁰10²⁷10²⁴10²¹10¹⁸10¹⁵10¹²10⁹10⁶10³10²10¹
Prefixdcmµ / μ / unpfazyrq
Factor10⁻¹10⁻²10⁻³10⁻⁶10⁻⁹10⁻¹²10⁻¹⁵10⁻¹⁸10⁻²¹10⁻²⁴10⁻²⁷10⁻³⁰

kg, mg, MHz, kPa, cm all resolve compositionally. When a token is both a standalone unit and a prefix, the standalone unit wins: m is a metre (not milli-anything), T is a tesla (not tera), h is an hour, d is a day, kt is a knot, pt a pint. Prefix decomposition is only tried when a token is not a known unit on its own.

Some units never take a prefix: kg (prefix the gram: mg, not mkg), the tonne t (kt is the knot; there is no kilotonne), min, h, d, ha, u, and the non-metric units (ft, in, yd, mi, nmi, kt, oz, lb, ct, pt, atm, at).

Known units

The built-in registry covers the SI base units, the named derived units, and the common non-SI units:

LayerTokens (selection)
SI basem, kg, s, A, K, mol, cd
SI derivedN, Pa, J, W, Hz, V, Ohm/Ω, F, C, T
Mass (non-SI)g, t (tonne), oz, lb, ct (carat), u/Da (dalton)
Length (non-SI)mi, ft, in, yd, nmi (nautical mile)
Areaha (hectare)
Speedkt (knot)
Time (non-SI)min, h, d
VolumeL/l (litre), mL/ml, pt (US liquid pint)
Pressure/energybar, atm, at (technical atmosphere), cal, kcal
TemperatureK; °C/degC, °F/degF (standalone only)

An unknown token is a compile error.


User-defined units (unitdef)

The built-in registry above is fixed — it ships with dolfin-units and cannot be extended from a .dlf file. unitdef is the separate mechanism for a project to declare its own units: currencies, or units that aren’t physically commensurable at all (a bunch of carrots is not a length or a mass). It is a top-level declaration, like concept or property:


unitdef USD: scale 0.92 EUR
unitdef family vegetables
unitdef bunch_of_carrots: nominal of vegetables scale 2
unitdef cabbages: nominal of vegetables scale 1

Why unitdef and not unit. unit is an ordinary identifier, and older models still write qualified ranges such as has weight: unit.Mass (see legacy names). Reserving unit for this declaration would break them, so the declaration keyword is unitdef.

There are two independent kinds of unitdef, and they behave very differently. Picking the wrong one for what you’re modelling gets you either silently-wrong arithmetic or arithmetic dolfin unnecessarily refuses.

Derived units (currencies, and anything else on a real dimension)


unitdef USD: scale 0.92 EUR

This declares USD as an ordinary unit: 1 USD = 0.92 EUR. It behaves exactly like declaring km from m — dolfin-units ships EUR as its one built-in currency unit (an arbitrary base pick, scale 1.0, no other currency and no live exchange rate bundled), and a project adds any other currency itself, with whatever rate it wants to use.

Because a derived unit is a real dimension (dolfin-units has a currency axis alongside the seven SI ones), it composes and converts exactly like any other unit:


quantity(25 USD/h)              # composes fine — a salary rate
quantity(100 USD + 8 EUR)       # auto-converts and adds, like `1 km + 500 m`
quantity(100 USD as EUR)        # → 92 EUR

scale <factor> <reference> requires <reference> to already be a resolvable unit (built-in, or another unitdef — declared in any order, even across files: dolfin resolves references in dependency order, not declaration order). A reference that never resolves is a compile error naming the broken unitdef.

Nominal (incommensurable) units and families


unitdef family vegetables
unitdef bunch_of_carrots: nominal of vegetables scale 2
unitdef cabbages: nominal of vegetables scale 1

unitdef family <name> declares an abstract, dimensionless grouping — not a usable unit by itself, just something other unitdefs can belong to. unitdef <name>: nominal of <family> scale <factor> declares a unit that belongs to that family, with a per-unit scale used only when explicitly widening into the family (see below) — it is not a conversion rate between nominal units.

Unlike a derived unit, a nominal unit is not a real dimension: two different nominal units are incommensurable by design, even if they belong to the same family:


quantity(2 bunch_of_carrots + 3 bunch_of_carrots)   # OK: same unit, ordinary add → 5
quantity(2 bunch_of_carrots + 3 cabbages)            # error: incommensurable units

The only way to +/- different nominal units of the same family is an explicit as <family> cast, which widens each operand via its declared scale into the family’s dimensionless base and only then combines them:


quantity(2 bunch_of_carrots + 3 cabbages as vegetables)   # → 7 (2×2 + 3×1)
quantity(3 bunch_of_carrots as vegetables)                 # → 6 (single operand also widens)
quantity(3 bunch_of_carrots as fruit)                      # error: not a member of `fruit`

Casting to a family you aren’t a member of, or casting a non-nominal quantity to a family, is the same INVALID_QUANTITY-class compile error as casting 5 km as s.

*// follow a different rule than +/-: a nominal unit composes freely with an ordinary (non-nominal) unit, same as any other unit — a carrot-cutting machine’s rate is a perfectly good quantity:


quantity(10 bunch_of_carrots / 2 h)   # → 5 bunch_of_carrots/h, a rate

Two nominal units can also multiply/divide against each other, as long as they belong to the same family — the result is nominal-typed but no longer a plain member of the family (bunch_of_carrots * cabbages is a vegetables^2-shaped quantity, not a vegetables):


quantity(2 bunch_of_carrots * 3 cabbages)   # → 6 bunch_of_carrots.cabbages
quantity(6 bunch_of_carrots / 3 cabbages)   # → 2 bunch_of_carrots/cabbages

Multiplying/dividing nominal units from different families is still rejected — there’s no meaningful composite of bunch_of_carrots (vegetables) and some unit from an unrelated family:


quantity(2 bunch_of_carrots * 3 apples)   # error: incommensurable units (different families)
OperationSame unitSame family, no castSame family, as <family>Different family
+ / -✅❌ error✅ widens via scale❌ error
* / /✅✅ (nominal, family-typed)n/a (not a valid cast target for *//)❌ error
* / / with an ordinary (non-nominal) unit✅ composes into a rate, e.g. bunch_of_carrots/h———

Arithmetic and conversion

A quantity literal may contain a single arithmetic operation between two measurements, evaluated at parse time:


quantity(42 km / 30 min)   # a speed: 1.4 km/min
quantity(10 N / 1 m^2)     # a pressure: 10 N/m^2 (i.e. 10 Pa)

A trailing as converts the result to a target unit, or reduces it to SI base units. The target must be dimensionally compatible:


quantity(42 km/h as m/s)       # → 11.666… m/s
quantity(90 lb as kg)          # → 40.82… kg
quantity(42 km / 30 min as km/h)  # → 84 km/h
quantity(10 N/m^2 as Pa)       # → 10 Pa (named derived unit)
quantity(5 km as SI)           # → 5000 m (reduce to base units)

Converting across incompatible dimensions (quantity(5 km as s), a length to a time) is a compile error.

The dimensional rules are the ordinary ones:

OperationRequirement
+ / -both operands share the same dimension
* / /none (dimensions combine)
as / converttarget dimension equals source dimension

These are the rules for ordinary (physical, and derived/currency) units. A nominal unit follows a different rule for +/-: it requires the same specific unit (not merely the same dimension — nominal units are all dimensionless). *// compose freely with ordinary units (bunch_of_carrots/h) and with other nominal units of the same family (bunch_of_carrots * cabbages); different families still reject. See that section for the as <family> widening cast, which is the only way to +/- two different nominal units.


Dimensions

Every unit reduces to a vector over the seven SI base dimensions:

DimensionLetterSI base unit
LengthLm
MassMkg
TimeTs
Electric currentIA
TemperatureΘK
Amount of substanceNmol
Luminous intensityJcd

A quantity’s dimension is its real type. km/h and mi/h are different units but the same dimension (L1.T-1, a speed), and so they are comparable; kg and m are not. Two quantities are dimensionally compatible when their dimension vectors are equal.

There is an eighth, non-SI axis, currency, used only by EUR and any derived currency unitdef a project declares — it behaves exactly like the seven physical axes above (composes, converts, compares). Nominal units sit outside this vector entirely: they’re dimensionless (all-zero vector) but carry a separate family tag that ordinary dimension-vector equality doesn’t see, which is precisely why they need their own arithmetic rules instead of just being “another dimension”.


Quantities as values of a float field

There is no quantity primitive type: a quantity’s type is the dimension the value carries. The loosest way to hold one is a numeric range with quantity(...) values:


concept Animal:
  has weight: optional float   # the value supplies the unit

fact rex a Dog
  weight quantity(45 kg)

The declared float range says only “a number”. The unit lives in the value, so one attribute can hold quantity(45 kg) for one animal and quantity(90 lb) for another and still compare them correctly. float accepts any quantity (quantity(45 kg), quantity(3 s)) or a bare number alike — nothing checks the dimension.

Dimension-typed properties

To require a specific dimension, declare the range as a physical dimension instead of float. The dimensions are built-in lowercase quantity datatypes, like float or date; nothing needs to be declared (there is no concept Mass and no units file to import):

TypeDimensionQUDT quantity kind (rdfs:range)
lengthL1quantitykind:Length
massM1quantitykind:Mass
durationT1(xsd:duration, see below)
currentI1quantitykind:ElectricCurrent
temperatureTh1quantitykind:Temperature
amountN1quantitykind:AmountOfSubstance
luminosityJ1quantitykind:LuminousIntensity
speedL1.T-1quantitykind:Velocity
accelerationL1.T-2quantitykind:Acceleration
forceL1.M1.T-2quantitykind:Force
pressureL-1.M1.T-2quantitykind:Pressure
energyL2.M1.T-2quantitykind:Energy
powerL2.M1.T-3quantitykind:Power
frequencyT-1quantitykind:Frequency
areaL2quantitykind:Area
volumeL3quantitykind:Volume
densityL-3.M1quantitykind:Density
momentumL1.M1.T-1quantitykind:Momentum

These names are types only in type position. They are not keywords, so length, power or area stay valid field names: has length: length is fine. time is not in the list: it is the xsd:time clock-time type, and the time dimension is duration.


concept Animal:
  has weight: mass   # was: optional float

fact rex a Dog
  weight quantity(45 kg)      # OK — a mass

fact oops a Dog
  weight quantity(3 m)        # error: S008 dimension mismatch (length, not mass)

Analysis checks every quantity(...) value assigned to that property against the dimension — a mismatched dimension or a bare number (weight 45) is diagnostic S008 DIMENSION_MISMATCH. In Turtle, such a property is an owl:DatatypeProperty whose rdfs:range is the QUDT quantity kind (quantitykind:Mass), declared as an rdfs:Datatype; the value is a literal whose datatype is its unit (see Representation in Turtle).

duration is the time dimension

A duration field holds a time span, written either as a temporal duration(...) or as any time quantity. Both forms mean the same value:


concept Dog:
  has nap: optional duration

fact rex a Dog
  nap duration(20 min)     # → "PT20M"^^xsd:dayTimeDuration

fact bella a Dog
  nap quantity(20 min)     # → "PT20M"^^xsd:dayTimeDuration, the same value

In unit algebra duration is the T1 dimension, so quantity(3 km / 30 min) is a speed. Months and years have no fixed length, so mo and y are refused in quantity arithmetic and conversion (quantity(1 mo) is error S007 “Calendar unit ‘mo’ has variable length”). Calendar durations remain available as duration(1y 6mo), which compiles to "P1Y6M"^^xsd:duration.

Legacy capitalised names

Models written before the built-in types declared concept Mass (often in a shared units.dlf) and used it as a range (has weight: Mass or has weight: unit.Mass). Such models still compile to the same Turtle as has weight: mass, with two lint warnings, each with a quick-fix. The capitalised name only resolves while that declaration is there: Mass alone, with no concept Mass, is an unresolved type, so apply both fixes.

LintTriggered byQuick-fix
quantity/capitalized-dimension-typehas weight: Mass, unit.Massreplace with mass
quantity/builtin-dimension-conceptconcept Massdelete the declaration

A concept named after a built-in dimension is not written as an rdfs:Class. The legacy name Time maps to duration.


Comparisons

A comparison against a quantity, in a rule or query constraint block, is unit-aware:


rule flag_overweight_dog:
  match:
    ?dog a Dog
    ?dog weight [ > quantity(40 kg) ]
  then:
    ?dog a OverweightAnimal

The comparison compares physical magnitudes, not lexical numbers:

  • The two operands must share a dimension. A mismatch (weight in kilograms compared to quantity(3 m)) makes the comparison fail and the row is dropped, it never matches. In a query, when the property’s dimension is declared (has weight: mass), analysis reports this as an S008 warning ( `weight` is a Mass but is compared with a Length quantity, this query matches nothing). A plain float property carries no dimension, so a mismatch on it stays silent.
  • Compatible operands are compared by their SI magnitude (value × coefficient). So a dog whose weight was written quantity(90 lb) (≈ 40.8 kg) does trip a > quantity(40 kg) threshold, even though 90 and 40 alone would not suggest it.
Video 3.2.2 · Units and quantities, chapter “Compare across units” (0:24–1:08). Watch on PeerTube

For the unit-aware path to run, both sides must be quantities. A bare number (weight [ > 40.0 ]) is compared as a plain scalar, with no unit reasoning, so mixing a quantity(...) value with a bare-number threshold does not do what you want. Keep both the stored values and the threshold as quantity(...).

Use > < >= <=, not = / !=. After a quantity is normalised to its SI magnitude, two “equal” measurements almost never match to the bit, so exact (in)equality is unreliable. The linter warns on = / != against a quantity (rule semantic/quantity-exact-comparison). The ordering operators are the sound ones.


Representation in Turtle

A quantity compiles to a typed literal whose datatype is its unit, taken from QUDT, the shared vocabulary for units and quantity kinds. Each distinct unit used in the output gets one qudt: description:

@prefix qudt: <http://qudt.org/schema/qudt/> .
@prefix quantitykind: <http://qudt.org/vocab/quantitykind/> .

:weight a owl:DatatypeProperty ;
    rdfs:range quantitykind:Mass .

:rex   :weight "45"^^<http://qudt.org/vocab/unit/KiloGM> .
:bella :weight "90"^^<http://qudt.org/vocab/unit/LB> .
:rex   :nap    "PT20M"^^xsd:dayTimeDuration .

# one block per quantity kind used as a range, and per unit used in the data:
quantitykind:Mass a qudt:QuantityKind, rdfs:Datatype .
<http://qudt.org/vocab/unit/KiloGM> a qudt:Unit, rdfs:Datatype ;
    qudt:conversionMultiplier 1 ;
    qudt:hasDimensionVector <http://qudt.org/vocab/dimensionvector/A0E0L0I0M1H0T0D0> ;
    qudt:symbol "kg" ;
    qudt:hasQuantityKind quantitykind:Mass .
<http://qudt.org/vocab/unit/LB> a qudt:Unit, rdfs:Datatype ;
    qudt:conversionMultiplier 0.45359237 ;
    qudt:hasDimensionVector <http://qudt.org/vocab/dimensionvector/A0E0L0I0M1H0T0D0> ;
    qudt:symbol "lb" ;
    qudt:hasQuantityKind quantitykind:Mass .
  • Unit datatype: the QUDT unit IRI (kg → unit:KiloGM, km/h → unit:KiloM-PER-HR, lb → unit:LB), written as a full IRI (<http://qudt.org/vocab/unit/KiloGM>), not a prefixed name. The value is kept as written, in its own unit (no SI reduction).
  • Units QUDT lacks (a unitdef, an unusual compound such as km/min) get a datatype in the project namespace, <namespace>unit/<canonical> (km/min → …/unit/km.min-1, USD → …/unit/USD), described with the same qudt: triples.
  • qudt: terms: qudt:conversionMultiplier (factor to the SI base magnitude), qudt:hasDimensionVector (a QUDT qkdv: dimension vector), qudt:symbol (the human-readable unit), qudt:hasQuantityKind. A currency unit has no dimension vector; its kind is quantitykind:Currency.
  • Range: a property typed mass has range quantitykind:Mass, itself declared a qudt:QuantityKind, rdfs:Datatype. OWL reasoners do not treat a unit:KiloGM value as a member of that range: the dimension check is done by the SHACL shapes, which compare the value’s qudt:hasDimensionVector.
  • Time: any time quantity, and duration(...) without months or years, is an xsd:dayTimeDuration ("PT20M"), with no unit block. A duration field has range xsd:duration.
  • A dimensionless quantity carries no unit and degrades to a plain "<value>"^^xsd:double.
  • The qudt: and quantitykind: prefixes are only declared when the output has quantities.

How comparisons unfold

A quantity comparison in a query does not emit a bare scalar FILTER. It unfolds to standard SPARQL 1.1 that resolves the stored value’s unit and compares SI magnitudes, guarded by the dimension vector:

?s :topSpeed ?v .
BIND(datatype(?v) AS ?u)
?u <http://qudt.org/schema/qudt/conversionMultiplier> ?c .
?u <http://qudt.org/schema/qudt/hasDimensionVector> ?d .
FILTER(?d = <http://qudt.org/vocab/dimensionvector/A0E0L1I0M0H0T-1D0>
       && (xsd:double(str(?v)) * ?c) >= 16.666…)

The target magnitude (16.666…) and dimension vector are computed at compile time from the comparison literal. The unit descriptions must be in the queried dataset. A time target compares xsd:dayTimeDuration values directly (FILTER(?v >= "PT20M"^^xsd:dayTimeDuration)). Rules unfold the same way for the reasoner, using dedicated comparison predicates (dq:greaterThanOrEqual, …, in the https://dolfin.dev/quantity# namespace, since QUDT has no comparison functions) rather than overloaded math: builtins.

Displaying results

A result value keeps its unit datatype, so a client can reconstruct the unit for display: "45"^^unit:KiloGM renders as 45 kg, "42"^^unit:KiloM-PER-HR as 42 km/h. The datatype IRI maps back to a symbol via its qudt:symbol.


Errors

SituationResult
Unknown unit token (quantity(42 zonks))compile error INVALID_QUANTITY (S007)
Incompatible as conversion (5 km as s)compile error INVALID_QUANTITY (S007)
Wrong dimension on a dimension-typed property (has weight: mass, value quantity(3 m))compile error DIMENSION_MISMATCH (S008)
Bare number on a dimension-typed property (weight 45)compile error DIMENSION_MISMATCH (S008)
Two different nominal units combined without a cast (2 bunch_of_carrots + 3 cabbages)compile error, incommensurable units
*// between nominal units of different families (2 bunch_of_carrots * 3 apples)compile error, incommensurable units
as <family> on a unit that isn’t a member of that familycompile error INVALID_QUANTITY (S007)
Months or years in a quantity (quantity(1 mo), 1 y as s)compile error INVALID_QUANTITY (S007)
Dimension mismatch in a comparisonnot an error, the row is dropped at run time

The first four are reported by dolfin-analysis and surfaced in the editor. The last is a data-dependent runtime condition, so it is handled by dropping the non-matching row rather than by a diagnostic.

Video 3.2.2 · Units and quantities, chapter “Which units?” (1:07–1:34). Watch on PeerTube

Turtle & OWL Correspondence

Dolfin is a source language for OWL ontologies. Compiling a package produces a single Turtle document (plus a companion N3 file for rules, and a companion SPARQL file for queries). The reverse is also supported: an OWL/RDF graph can be recovered back into a Dolfin package. This chapter describes the mapping in both directions.

Two conversions exist:

  • Dolfin → Turtle: compile a .dlf package to an OWL graph.
  • Turtle → Dolfin: reconstruct a .dlf package from an OWL graph.

The mapping is defined at the level of triples, not text. A round trip Dolfin → Turtle → Dolfin preserves the ontology’s meaning; it does not guarantee a byte-identical source file (see Round trips).


How IRIs are named

Every concept, property, and individual gets an IRI. IRIs are built from three ingredients:

  1. a base IRI (the package identity),
  2. the file path of the declaration inside the package,
  3. the local name of the entity.

The base IRI

The base IRI comes from the package declaration in package.dlf:


package <http://example.com/animals>:
  dolfin_version "1"
  version "0.1.0"

If the package is declared with an absolute IRI (<http://…>), that IRI is the base directly. If it is declared with a bare name (package animals:), the base is <compiler-base-iri>/animals, where the compiler base IRI is supplied at compile time (default http://example.org/).

File path → namespace segment

Each ontology file contributes a namespace segment derived from its path relative to the package root. A file named mammals.dlf produces the namespace IRI:

http://example.com/animals/mammals#

The filename (without .dlf) becomes the last path segment, and a # fragment terminator is appended. Nested files nest on disk and in the IRI: a file at vertebrates/mammals.dlf yields …/animals/vertebrates/mammals#.

The base namespace (declarations that belong to the package root rather than a sub-file) uses the base IRI directly with no extra path segment. On the way back, base-namespace entities are written to main.dlf, with @iri_name <base/> when the base IRI ends in /.

Local names and prefix labels

An entity’s local name is its Dolfin name, appended to its namespace IRI. The namespace ends with a terminator, # unless an @iri_name says otherwise (see The namespace terminator):

DolfinIRI
concept Mammal in mammals.dlfhttp://example.com/animals/mammals#Mammal
concept Animal in the root filehttp://example.com/animals#Animal
concept Foo in a file with @iri_name <http://ex.org/ns/>http://ex.org/ns/Foo
variant Herbivore of concept Diet: one of: in mammals.dlfhttp://example.com/animals/mammals#Diet_Herbivore
fact Biscuit a Dog in data.dlfhttp://example.com/animals/data#Biscuit

A one of: variant is named <Concept>_<Variant> in the namespace of the concept’s file, so two closed concepts of one file can share a variant name. A concept’s own @iri_name does not change its variants’ IRIs; a file-level one does, as for every entity of the file. A fact is named by its id in the namespace of the file that declares it, so facts written in data.dlf are <package>/data#<id>; a fact id written prefixed (ex:Biscuit) or as an IRI is used as written.

In the emitted Turtle, entities are referenced with a prefix label taken from the filename. Mammal in mammals.dlf is written mammals:Mammal; entities in the base namespace use the empty prefix, :Animal. Each file’s namespace IRI is bound with an @prefix declaration at the top of the document.

A Dolfin fact reference keeps the Turtle meaning of the empty prefix: owner :JohnSmith is emitted as :JohnSmith, in the base namespace, whatever file it is written in. To reference the JohnSmith fact of data.dlf, write the bare owner JohnSmith, emitted as data:JohnSmith. No fact lives in the base namespace (package.dlf only holds the manifest), so the analyzer warns on every :Name fact reference.

Overriding the IRI with @iri_name

The @iri_name annotation overrides the derived IRI. It has these forms:


# Absolute: replaces the whole namespace IRI for this file
@iri_name <http://animals.kingdom/Mammalian>

# Local segment: replaces only the last path segment
@iri_name "Mammalian"      # → http://example.com/animals/Mammalian#

# On a single concept or property: overrides just that entity's IRI
concept Mammal:
  @iri_name <http://animals.kingdom/Mammals>
property occupiesTemporalRegion
  @iri_name <http://purl.obolibrary.org/obo/BFO_0000199>

# Under a `has` line: the field's property IRI, for every `has` of that name in the file
concept Truc:
  has dependence: Dep
    @iri_name <https://example.com/truc_dep>

@iri_name affects only the IRI. The prefix label used to reference the entity is still derived from the filename, and sibling files and sibling concepts are unaffected. Every reference to a concept or property with its own @iri_name (sub, inverse of, domain, with p: C, fact properties, from any file of the package) is written with that IRI; the Dolfin name only survives as its skos:prefLabel / rdfs:label.

The namespace terminator

Entity IRIs are namespace + terminator + local name. The terminator is decided by the file-level @iri_name:

@iri_name in the fileNamespace used for entitiesconcept Foo becomes
none (path-derived)…/animals/mammals#…/animals/mammals#Foo
"Mammalian" (segment)…/animals/Mammalian#…/animals/Mammalian#Foo
<http://ex.org/ns> (no terminator)http://ex.org/ns#http://ex.org/ns#Foo
<http://ex.org/ns#> (ends in #)http://ex.org/ns#http://ex.org/ns#Foo
<http://ex.org/ns/> (ends in /)http://ex.org/ns/http://ex.org/ns/Foo

A declared IRI that already ends in # or / keeps that terminator; anything else gets the default #. The @prefix declarations in the Turtle follow the same rule, so ns:Foo and <http://ex.org/ns/Foo> always agree.

Behaviour change. Older versions always appended #, so @iri_name <http://ex.org/ns#> produced http://ex.org/ns##Foo. It now produces http://ex.org/ns#Foo. If you worked around the doubled #, remove the workaround.

A concept-level @iri_name is never modified: its IRI is used verbatim.

File resources (rdfs:isDefinedBy)

To make files (and @iri_name overrides) recoverable, every emitted entity carries an rdfs:isDefinedBy triple pointing at its file resource — the file’s canonical, path-derived namespace IRI with no trailing terminator and no @iri_name override applied:

mammals:Mammal rdf:type rdfs:Class
  ; skos:prefLabel "Mammal"
  ; rdfs:label "Mammal"
  ; rdfs:isDefinedBy <http://example.com/animals/mammals> .

Each non-base file also declares its file resource and links it to the package ontology, so the package’s file set is explicit:

<http://example.com/animals/mammals> rdfs:isDefinedBy <http://example.com/animals> .

Base-namespace entities are defined by the package IRI itself (their file resource equals the owl:Ontology subject), so no self-loop is emitted.

Because the file resource is override-free, the reverse direction uses it to (1) assign each entity to its real file even when @iri_name rewrote the entity IRIs, and (2) detect that an override was used — by comparing the entity’s actual IRI against <file-resource>#<local>.


Declaration correspondence

The following table summarises the core mapping from Dolfin declarations to OWL triples.

DolfinOWL / RDF
concept CC rdf:type rdfs:Class
#@ owl + concept CC rdf:type owl:Class
concept C: sub PC rdfs:subClassOf P
concept C: equivalent to PC owl:equivalentClass P, one statement per type and per line
has p: T (T primitive)p rdf:type owl:DatatypeProperty ; rdfs:domain C ; rdfs:range xsd:…
has p: T (T a concept)p rdf:type owl:ObjectProperty ; rdfs:domain C ; rdfs:range T
(A or B) type[ a owl:Class ; owl:unionOf ( A B ) ]
(A and B) type[ a owl:Class ; owl:intersectionOf ( A B ) ]
not A type[ a owl:Class ; owl:complementOf A ]
with p: C type[ a owl:Restriction ; owl:onProperty p ; owl:someValuesFrom C ], see Restrictions
enum / one of (a, b, …)owl:equivalentClass [ owl:oneOf ( … ) ] + one owl:NamedIndividual per variant
fact blockowl:NamedIndividual with rdf:type and property-value triples (the ABox)

Class expressions nest, in rdfs:domain and rdfs:range alike: has z: (X or (Y and not T)) gives rdfs:range [ a owl:Class ; owl:unionOf ( X [ a owl:Class ; owl:intersectionOf ( Y [ a owl:Class ; owl:complementOf T ] ) ] ) ].

Video 2.4.2 · Union types, chapter “In RDF” (0:37–0:56). Watch on PeerTube

A field name used by several concepts of one file is a single property: its rdfs:domain is [ a owl:Class ; owl:unionOf ( C1 C2 … ) ] (and its range the union of their ranges when they differ), so an instance of C1 is never inferred to be a C2.

A concept is rdfs:Class unless its leading comment block carries the bare #@ owl marker, which makes it owl:Class. Importing Turtle sets the marker on every class typed owl:Class (including one typed both owl:Class and rdfs:Class), so the type round-trips.

Restrictions: with

Each with is one owl:Restriction blank node; with joined to a type is an owl:intersectionOf.

DolfinOWL
with p: C[ a owl:Restriction ; owl:onProperty p ; owl:someValuesFrom C ]
with p: only C[ a owl:Restriction ; owl:onProperty p ; owl:allValuesFrom C ]
with p: 2 C[ a owl:Restriction ; owl:onProperty p ; owl:qualifiedCardinality "2"^^xsd:nonNegativeInteger ; owl:onClass C ]
with p: at least 2 C… owl:minQualifiedCardinality "2"^^xsd:nonNegativeInteger ; owl:onClass C
with p: at most 3 C… owl:maxQualifiedCardinality "3"^^xsd:nonNegativeInteger ; owl:onClass C
with p: 1..4 C[ a owl:Class ; owl:intersectionOf ( min 1 restriction max 4 restriction ) ]
with p: = x[ a owl:Restriction ; owl:onProperty p ; owl:hasValue x ]
with ^p: C[ a owl:Restriction ; owl:onProperty [ owl:inverseOf p ] ; owl:someValuesFrom C ]
T with p: C, or T + a block of with lines[ a owl:Class ; owl:intersectionOf ( T restriction … ) ]

N..M is two restrictions because OWL 2 allows a single cardinality per restriction. A nested block narrows the filler into an intersection:


concept Act:
  sub Process
    with occupiesTemporalRegion: TemporalRegion
      with occurrentPartOf: TemporalInterval

gives

:Act rdfs:subClassOf [ a owl:Class ; owl:intersectionOf ( :Process
    [ a owl:Restriction ; owl:onProperty :occupiesTemporalRegion ;
      owl:someValuesFrom [ a owl:Class ; owl:intersectionOf ( :TemporalRegion
        [ a owl:Restriction ; owl:onProperty :occurrentPartOf ;
          owl:someValuesFrom :TemporalInterval ] ) ] ] ) ] .

Defined concepts: equivalent to

sub X and equivalent to X render X the same way; only the predicate changes. Under either line, a with block gives the intersection:


concept Parent:
  equivalent to Person
    with hasChild: Person

gives

:Parent owl:equivalentClass [ a owl:Class ; owl:intersectionOf ( :Person
    [ a owl:Restriction ; owl:onProperty :hasChild ;
      owl:someValuesFrom :Person ] ) ] .

With sub Person in place of equivalent to Person, the same expression is the object of rdfs:subClassOf.

Package metadata

Dolfin manifest fieldOWL triple on the owl:Ontology node
versionowl:versionInfo
descriptionrdfs:comment
authordc:creator (dc: = http://purl.org/dc/elements/1.1/)

Primitive types

DolfinXSD datatype
stringxsd:string
intxsd:integer
floatxsd:float
booleanxsd:boolean
datexsd:date
date_timexsd:dateTime
timexsd:time
durationxsd:duration

An int value is always written "42"^^xsd:integer, never xsd:int: xsd:int is the 32-bit subtype of xsd:integer, and Dolfin does not bound int.

Quantities

Quantities use the QUDT vocabulary (qudt: = http://qudt.org/schema/qudt/, quantitykind: = http://qudt.org/vocab/quantitykind/, unit IRIs under http://qudt.org/vocab/unit/):

DolfinTurtle
has weight: mass:weight a owl:DatatypeProperty ; rdfs:range quantitykind:Mass
range mass usedquantitykind:Mass a qudt:QuantityKind, rdfs:Datatype .
has nap: duration:nap a owl:DatatypeProperty ; rdfs:range xsd:duration
weight quantity(3 kg):weight "3"^^<http://qudt.org/vocab/unit/KiloGM>
nap quantity(20 min), nap duration(20 min):nap "PT20M"^^xsd:dayTimeDuration
a unit used in the data<unit> a qudt:Unit, rdfs:Datatype ; qudt:conversionMultiplier … ; qudt:hasDimensionVector <qkdv> ; qudt:symbol "kg" ; qudt:hasQuantityKind quantitykind:Mass .
a unit QUDT lacks (unitdef, km/min)datatype <namespace>unit/<canonical>, same qudt: triples
concept Mass (legacy)nothing: no rdfs:Class for a built-in dimension

Unit IRIs are written in full, not as unit: prefixed names. The qudt: and quantitykind: prefixes are declared only when the output has quantities. See Units & Quantities for the details.

Comments and labels

A declaration’s name, leading comment and #@ glossary: annotation become RDFS / SKOS annotations:

SourceRDFS / SKOS
#@ glossary: pref_label[@xx]= (alias prefLabel)skos:prefLabel + rdfs:label
#@ glossary: label[@xx]=rdfs:label (the first one of a language without pref_label also skos:prefLabel)
declaration name, when no label/pref_label is givenskos:prefLabel + rdfs:label (untagged)
leading comment text (# … / #xx> …)rdfs:comment
#@ glossary: definition= / definition@xx=skos:definition
#@ glossary: alt_label[@xx]=skos:altLabel
#@ glossary: scope_note=skos:scopeNote

A #xx> prefix on a leading comment line gives the literal its language tag (#fr> … → rdfs:comment "…"@fr, one triple per language); a plain # … line yields an untagged literal. Likewise definition@xx= yields a tagged skos:definition and a plain definition= an untagged one.

This holds for a has line as for a concept: a plain # comment directly above has describes that field, as its rdfs:comment. Only a #@ glossary: definition argument writes skos:definition; a comment never does.


concept Recipe:
  # How long the recipe takes, preparation included.
  has total_time: duration

→ :total_time rdfs:comment "How long the recipe takes, preparation included.", and no skos:definition.

pref_label and label, per language

Labels are resolved per language; untagged labels form their own language. Every pref_label and label becomes an rdfs:label. In each language, one of them is also the skos:prefLabel:

  • pref_label@xx is the preferred label of xx. Write at most one per language; an extra one is kept as a plain rdfs:label.
  • Without a pref_label@xx, the first label@xx is promoted to preferred label; later ones stay plain rdfs:label.
  • With a pref_label@xx, every label@xx stays plain rdfs:label, even one written before it.
  • With no label and no pref_label at all, the declaration name is the untagged preferred label. A tagged label alone is enough to suppress it.

This applies to concepts, has fields, properties and enum members (a #@ glossary: block right above the member line).


#@ glossary:
#@   label@en Kitty
#@   pref_label@en Cat
#@   label@fr Chat
#@   label@fr Minou
concept Cat
:Cat rdf:type rdfs:Class
  ; skos:prefLabel "Cat"@en      # pref_label@en
  ; rdfs:label "Cat"@en
  ; rdfs:label "Kitty"@en        # en has a pref_label: plain label
  ; skos:prefLabel "Chat"@fr     # first label@fr, promoted
  ; rdfs:label "Chat"@fr
  ; rdfs:label "Minou"@fr
  …

concept Dog with no glossary labels gets skos:prefLabel "Dog" and rdfs:label "Dog".

Back from Turtle it reverses: each skos:prefLabel is a pref_label[@xx], each other rdfs:label a label[@xx]. An untagged label equal to the declaration name is left out when it is the only label, since writing it again regenerates it. A fact gets no label from its id, so on a fact every label is kept: its glossary and doc comment come back above the fact line.

Video 2.4.1 · Multilingual descriptions, full episode. Watch on PeerTube

Cardinality

A has field’s cardinality becomes an OWL restriction on the owning class:

Dolfin cardinalityOWL restriction on the class
one (1..1)owl:equivalentClass [ owl:cardinality 1 ], property is owl:FunctionalProperty
at most 1 (0..1)owl:subClassOf [ owl:minCardinality 0 ; owl:maxCardinality 1 ], property is owl:FunctionalProperty
some (1..*)owl:subClassOf [ owl:minCardinality 1 ]
n (exactly n)owl:subClassOf [ owl:cardinality n ]
n..m, at least n, at most nowl:subClassOf [ owl:minCardinality n ; owl:maxCardinality m ] (no max when unbounded)
optional (0..1)owl:subClassOf [ owl:maxCardinality 1 ]
any (default, 0..*)no restriction

A property is owl:FunctionalProperty exactly when the maximum is 1: one and at most 1 (also 1, n..1), never optional: it is 0..1 too, but marks a field that may be missing, not a functional property. A field name shared by several concepts is functional only when every one of them has maximum 1.

A standalone property p: D -> R reads its cardinalities the same way: a maximum of 1 before the range gives owl:FunctionalProperty, before the domain owl:InverseFunctionalProperty. Back from Turtle, either one reads as at most 1 on that side.

Property domain and range lines

A domain X / range X body line gives the same rdfs:domain / rdfs:range triple as the one-line D -> R; X renders like any type expression, so a with block becomes nested intersection and restriction blank nodes:


property partOfProcess
  domain Process
    with occupiesTemporalRegion: TemporalRegion
  range Process
    with ^occupiesTemporalRegion: TemporalInterval

gives

:partOfProcess rdf:type owl:ObjectProperty
  ; rdfs:domain [ a owl:Class ; owl:intersectionOf ( :Process
      [ a owl:Restriction ; owl:onProperty :occupiesTemporalRegion ;
        owl:someValuesFrom :TemporalRegion ] ) ]
  ; rdfs:range [ a owl:Class ; owl:intersectionOf ( :Process
      [ a owl:Restriction ;
        owl:onProperty [ owl:inverseOf :occupiesTemporalRegion ] ;
        owl:someValuesFrom :TemporalInterval ] ) ] .

A side that is not given writes no triple. A property without a range is an owl:ObjectProperty; one whose range is a primitive type is an owl:DatatypeProperty, as with the one-line form.

Property axioms

Axioms declared on a property map to OWL property characteristics:

DolfinOWL
symmetricrdf:type owl:SymmetricProperty
reflexiverdf:type owl:ReflexiveProperty
transitiverdf:type owl:TransitiveProperty
sub qrdfs:subPropertyOf q
inverse of qowl:inverseOf q
equivalent to qowl:equivalentProperty q
equivalent to ^qowl:equivalentProperty [ owl:inverseOf q ]
equivalent to a . b (chain)owl:propertyChainAxiom ( a b ) via an rdfs:subPropertyOf node
equivalent to q+transitive + propertyChainAxiom ( q self )
equivalent to q*transitive + reflexive + propertyChainAxiom ( q self )

Rules

Rules do not fit the OWL description-logic fragment. They compile to N3 (Notation3) { … } => { … } implications, written to a companion N3 document alongside the Turtle. The N3 file shares the same @prefix bindings as the Turtle so the two are consistent.

Comparisons

A comparison in a match: constraint block compiles to an N3 builtin. Which family is chosen depends on what is compared: the literal on the right, or, against a variable, the declared range of the property whose value is constrained.

Video 3.4 · Rules with comparisons, chapter “Rules in the Turtle” (1:28–1:43). Watch on PeerTube
Compared values=!=< <= > >=
Numbers, dates, times, durationsmath:equalTomath:notEqualTomath:lessThan, math:notGreaterThan, math:greaterThan, math:notLessThan
Quantities (quantity(...), or a quantity range such as mass)dq:equalTodq:notEqualTodq:lessThan, dq:lessThanOrEqual, dq:greaterThan, dq:greaterThanOrEqual
Strings, booleans, IRIs, individuals of a conceptlog:equalTolog:notEqualTomath: as for numbers

?p age [ = 18 ]        # ?_v1 math:equalTo "18"^^xsd:integer .
?p name [ = "Bob" ]    # ?_v2 log:equalTo "Bob" .
?q friend [ != ?f ]    # ?_v3 log:notEqualTo ?f .

log:equalTo is term identity: "18"^^xsd:integer and "18.0"^^xsd:decimal are different terms, and "Bob" differs from "Bob"@en. math:equalTo compares values, so it only holds between numbers, dates or times. A variable whose type cannot be inferred, or a float property holding quantities, is compared with math:.

Quantifiers

Quantifiers use the N3 log: and list: builtins, evaluated in the reasoning scope ?_scope:

DolfinN3
none ?v [C]: P?_scope log:notIncludes { ?v C . P } .
all ?v [C]: P( { ?v C } { P } ) log:forAllIn ?_scope .
at least N ?v [C]: P( ?v { ?v C . P } ?L ) log:collectAllIn ?_scope . ?L list:length ?n . ?n math:notLessThan N .
at most N, exactly N, between N, Msame, with math:notGreaterThan, math:equalTo, or both bounds
any ?v [C]: PC and P inline

A rule is never emitted with a weaker condition than its source. When a quantifier has no faithful encoding, the whole rule is written as a comment with the reason, and radeau build prints a warning:

  • all ?v without a [ … ] constraint: there is no domain for ?v to range over.
  • A count whose sub-patterns bind variables other than ?v and the rule’s own (e.g. ?r distance ?d): log:collectAllIn would count one ?v once per ?d.

radeau build --emit n3-rules (for retox) follows the same rules: quantified rules are emitted live, and retox’s ReasoningDataset evaluates them in strata. Loading fails when a quantifier depends on its own conclusion (a cycle through a quantifier), or when a quantified rule derives rdfs:subClassOf, rdfs:subPropertyOf or owl:sameAs.


Round trips

In Agrafe this direction is the Import from Turtle feature.

Reconstructing Dolfin from an OWL graph works at the triple level: it reads the owl:Ontology node, the classes, properties, restrictions, individuals, and SKOS/dc/rdfs metadata, and rebuilds the package layout from rdfs:isDefinedBy (falling back to the entity IRIs when it is absent).

A Dolfin → Turtle → Dolfin round trip preserves meaning but normalises form. Keep in mind:

  • Files are rebuilt from rdfs:isDefinedBy. Each entity’s file resource names its file (…/animals/mammals → mammals.dlf); base-namespace entities land in main.dlf. Turtle that carries no rdfs:isDefinedBy (e.g. graphs not produced by this compiler) falls back to deriving the file from the entity IRI structure.
  • @iri_name overrides are reconstructed. When an entity’s IRI deviates from its file resource, the override is recovered: a file-wide, uniform deviation becomes a file-level @iri_name <ns> (or <ns/> when every entity shares a /-terminated namespace; # wins if both occur); an isolated one becomes a concept-, property- or field-level @iri_name <iri>. A has field whose property IRI is not <its concept's file>#<name> (another vocabulary, another file) keeps it with an @iri_name <iri> line under the has. The recovered directive uses the absolute form (a @iri_name "segment" is recovered as the equivalent absolute IRI).
  • Opaque IRIs get readable names. A class or standalone property whose local name is letters, an optional _, then digits (BFO_0000015, ont00001857) is named from its rdfs:label (English first), camel-cased ("process" → Process, "occupies temporal region" → occupiesTemporalRegion), with an @iri_name <full IRI> line keeping the real IRI. Every reference uses the new name. A label that clashes with another name in the same file, starts with a digit, or has no usable letters keeps the local name.
  • Class expressions come back as with lines. Each restriction shape of Restrictions: with is read back, N..M included; an intersection of a type and restrictions becomes the block form, and a restriction in a filler a nested block. Complex rdfs:subClassOf objects become sub lines, owl:equivalentClass becomes equivalent to. A property whose rdfs:domain or rdfs:range holds a restriction gets domain / range lines instead of D -> R, and one with neither is a bare property p. A property only used in restrictions is still declared. A shape outside this list (a datatype filler, owl:onDataRange, owl:oneOf, facets) is left as before, never half-read. Unqualified cardinalities (owl:minCardinality …) stay has fields.
  • Entities outside the package base are dropped. Only entities defined by the package (via rdfs:isDefinedBy, or IRI-under-base in the fallback) are reconstructed. Imported/external vocabulary is treated as external and skipped.
  • dolfin_version is not carried in the graph and defaults to "1" on the way back.
  • Comments and formatting are not preserved. Only the structured annotations that became triples (rdfs:comment, SKOS labels, dc:creator) are recovered.

Full Grammar

There are two kind of files in a dolfin package. The package.dlf placed at the root of a package folder, it serves as a manifest of the whole package. We give an exemple of such a file here and let the grammar being deduced.


package <http://my.special.iri/of-ontology>:
  dolfin_version: 1
  version: 1.2.3
  author: not only me
  author: but also you
  description: This package is an example

There are regular dolfin files. The folder in which they are, are used to compose the iri of each ontology and then the iri of each element.

ontology ::= 
    iri_name_annotation?
    header_item*
    declaration*
    EOF

iri_name_annotation ::=
    "@iri_name" ( String | IRI ) NEWLINE

(* Prefixes and file-level directives may appear in any order at the top of
   the file, before the first declaration. *)
header_item ::=
    prefix_statement
  | locale_directive
  | timezone_directive

locale_directive ::=
    "@locale" date_field_order NEWLINE

timezone_directive ::=
    "@timezone" timezone_spec NEWLINE

date_field_order ::= field date_sep field date_sep field   (* e.g. d/m/y *)
field             ::= "y" | "m" | "d"
date_sep          ::= "/" | "-" | "."
timezone_spec     ::= iana_name        (* e.g. Europe/Brussels *)
                    | utc_offset       (* e.g. +02:00, -05:00 *)
                    | tz_abbrev        (* e.g. UTC, Z, CET *)

prefix_statement ::=
    "prefix" prefix_target

prefix_target ::=
    qualified_name_or_iri ":" NEWLINE
    INDENT
      prefix_target+
    DEDENT
  | qualified_name_or_iri "as" Name NEWLINE
  | qualified_name_or_iri NEWLINE

declaration ::=
    concept
  | property
  | rule
  | query
  | fact
  | mapping
  | export

(* A concept with no members is written without the colon: `concept Foo` *)
concept ::=
    "concept" Name NEWLINE
  | "concept" Name ":" NEWLINE
    INDENT
      concept_member+
    DEDENT

concept_member ::=
    concept_iri_name
  | sub_concept
  | equivalent_concept
  | concept_condition
  | has_property
  | key_block
  | "one" "of" ":" NEWLINE
    INDENT
      enum_value+
    DEDENT

(* An enum value may fix the concept's key fields:
     one of:
       black [red 0.0, green 0.0, blue 0.0]
       red   [red 1.0, green 0.0, blue 0.0]
   Its IRI is prefixed with the concept (Color_red). *)
enum_value ::= Name no_comp_constraint_block? NEWLINE

(* Two instances with equal keys are the same individual (owl:sameAs /
   owl:hasKey). `key:` + fields is the same as `has key` on each field. A key
   field implies `one`: any other cardinality is an error. A multi-valued key
   is typed `set of T` or `list of T` (see has_property). *)
key_block ::= "key" ":" NEWLINE INDENT has_property+ DEDENT

concept_iri_name ::= "@iri_name" IRI NEWLINE

sub_concept ::= "sub" type_ref ("," type_ref)* NEWLINE with_block?
(* A with_block needs a single type: `sub T` + block is (T and line and …). *)

equivalent_concept ::= "equivalent to" type_ref ("," type_ref)* NEWLINE with_block?
(* owl:equivalentClass, one per type and per line; same with_block rule as sub. *)

(* Concept conditions (SHACL side). `equivalent to T` + a condition block
   defines the concept as T narrowed by the block: members are inferred and
   asserted members validated. `check` + a block validates only. The block is
   one line, indented under the keyword line (no line continuation inside
   brackets); it cannot be mixed with a with_block. `check` is matched
   contextually and stays usable as a name; its ":" is optional. *)
concept_condition ::=
    "equivalent to" type_ref ("," type_ref)* NEWLINE
    INDENT condition_block NEWLINE DEDENT
  | "check" ":"? NEWLINE
    INDENT condition_block NEWLINE DEDENT

condition_block ::= "[" condition ("," condition)* "]"

condition ::=
    "a" type_ref
  | comparison_op (field_expr | focus_ref) (* `<= now() - $birthdate` *)
  | condition_quantifier? qualified_name variable? condition_block?
  | "$" Name variable?                      (* binds the focus node's field *)

(* Default quantifier: all. `some` = at least one. "at least"/"at most" also
   take the underscore spelling. No `between`; a count is an integer or a
   variable bound earlier in the block (`[ $ quota ?q, at_least ?q item ]`). *)
condition_quantifier ::=
    "all" | "some" | "none"
  | ("at least" | "at most" | "exactly") (Integer | variable)

(* `$` is the focus node (SHACL $this), always the outermost one at any
   nesting depth. `$age` is shorthand for `$ age ?age`; `$age` and `$ age` lex
   the same. Name an intermediate node with a variable:
   `[ some of_country ?c [ … ] ]`. `$` only appears in condition blocks. *)
focus_ref ::= "$" | "$" Name | "$" Name variable

(* Example:
     concept CanVote:
       equivalent to Citizen
         [ some of_country [ legal_age_of_vote [ <= $age ]]]
     concept Adult:
       check
         [ $ birthdate ?b, some of_country [ legal_age_of_vote [ <= $ age ?a ]]] *)

with_block ::= INDENT with_line+ DEDENT
with_line ::= with_clause NEWLINE with_block?   (* a nested block narrows the filler *)

has_property ::=
    "has" "key"? Name ":" (cardinality? | collection) type_ref field_block? NEWLINE
    (INDENT has_line+ DEDENT)?

(* A `has` body: property axioms, and `@iri_name <IRI>` (the field's property
   IRI, shared by every `has` of that name in the file), in any order. No
   domain / range lines. *)
has_line ::=
    "@iri_name" IRI NEWLINE
  | "sub" qualified_name NEWLINE
  | "inverse of" qualified_name NEWLINE
  | "transitive" NEWLINE | "symmetric" NEWLINE | "reflexive" NEWLINE
  | "equivalent to" property_path NEWLINE

(* `has tags: set of string`: any number of values, no order (a plain
   multi-valued property; a set key compares as a set). `has steps: list of
   Step`: one rdf:List node (rdfs:range rdf:List), its rdf:first values the
   Steps in order. A collection takes no cardinality; type_ref is the element
   type, and a field_block constrains the elements. `set` and `list` are
   matched contextually (before `of`) and stay usable as names. *)
collection ::= "set" "of" | "list" "of"

(* Value constraints on the field (SHACL side): `has red: float [ <= 1, >= 0 ]`.
   An `=` makes a derived field, computed rather than stored:
   `has age: duration [ = now() - $birthdate ]`. `now()` (`now` is matched
   contextually) and `$name` (the focus node's field) appear in field_block
   and condition comparisons only. *)
field_block ::= "[" comparison_op field_expr ("," comparison_op field_expr)* "]"

field_expr ::= expr                (* arithmetic over literals, variables, and: *)
  | "now" "(" ")"
  | "$" Name

property ::=
    "property" Name (":" cardinality? type_ref "->" cardinality? type_ref)? NEWLINE
    (INDENT property_line+ DEDENT)?

property_line ::=
    "@iri_name" IRI NEWLINE                    (* this property's IRI *)
  | "sub" qualified_name NEWLINE
  | "inverse of" qualified_name NEWLINE
  | "transitive" NEWLINE | "symmetric" NEWLINE | "reflexive" NEWLINE
  | "equivalent to" property_path NEWLINE
  | "domain" type_ref NEWLINE with_block?     (* rdfs:domain *)
  | "range" type_ref NEWLINE with_block?      (* rdfs:range *)
(* `domain` and `range` are matched contextually: they stay usable as ordinary
   names. Body lines come in any order. A side given twice (by the one-line
   `D -> R` and a line, or by two lines) is error S014. *)

(* `fact` declares one instance; `mapping` declares a template expanded once
   per row of a CSV file or a spreadsheet sheet, or once per `for each` match of
   a JSON or XML document. `mapping` is a reserved word, but `from`, `for`,
   `each`, `sheet`, `header`, `row`, `map`, `where`, `starts`, `case`, `else`,
   `slug` and `to` are matched contextually and stay usable as ordinary names. *)
fact ::=
    "fact" Name "a" type_ref ("," type_ref)* NEWLINE
    INDENT
      fact_assertion+
    DEDENT

fact_assertion ::=
    qualified_name fact_value ("," fact_value)* NEWLINE
  | "is" qualified_name "of" fact_value NEWLINE
  | "a" type_ref NEWLINE

fact_value ::=
    literal
  | ":" Name                      (* reference to another fact *)
  | Name                          (* enum value *)
  | "[" NEWLINE INDENT fact_assertion+ DEDENT "]"

mapping ::=
    "mapping" "from" IRI "as" Name NEWLINE
    INDENT
      mapping_header_line*
      "map" ":" NEWLINE
      INDENT
        source_binding+
      DEDENT
      where_block?
      "to" ":" NEWLINE
      INDENT
        mapping_template+
      DEDENT
    DEDENT

(* At most one of each. `for each` is for JSONPath and XPath only; `sheet`
   and `header row` (an integer >= 1) are for XLSX and ODS only. *)
mapping_header_line ::=
    "for" "each" String NEWLINE   (* iteration root *)
  | "sheet" String NEWLINE        (* worksheet name, matched exactly *)
  | "header" "row" Integer NEWLINE

(* A String is a column name under CSV, XLSX and ODS, a JSONPath expression under
   JSONPath and an XPath expression under XPath; under the last two a bare
   Name is an error. *)
source_binding ::= (Name | String) "as" variable NEWLINE

(* A row failing any condition is dropped silently. *)
where_block ::=
    "where" ":" NEWLINE
    INDENT
      (variable "[" where_condition ("," where_condition)* "]" NEWLINE)+
    DEDENT

where_condition ::=
    comparison_op (String | Integer | Float)
  | "starts" "with" String

mapping_template ::=
    "fact" subject_template "a" type_ref ("," type_ref)* NEWLINE
    (INDENT mapping_assertion+ DEDENT)?

(* A `{?var}` hole may appear in the subject; a cast there is an error. *)
subject_template ::=
    Name ":" template_tail
  | prefixed_name template_tail?
  | IRI                           (* holes are written inside the IRI text *)
  | Name

template_tail ::= template_hole template_chunk*

(* `{slug(?x)}`: the cell lowercased, accents dropped, other runs of
   characters than a-z0-9 replaced by `_`. Also allowed inside an IRI. *)
template_hole ::= "{" variable "}" | "{" "slug" "(" variable ")" "}"

template_chunk ::= template_hole | Name | Integer

mapping_assertion ::=
    qualified_name mapping_value ("," mapping_value)* NEWLINE
  | "is" qualified_name "of" mapping_value NEWLINE
  | "a" type_ref NEWLINE
  | qualified_name "case" variable ":" NEWLINE
    INDENT
      (String "->" fact_value NEWLINE)+
      ("else" "->" fact_value NEWLINE)?
    DEDENT

mapping_value ::=
    "{" variable "}" cast?
  | "{" variable cast "}"         (* same as "{" variable "}" cast *)
  | variable cast?                (* shorthand for the braced form *)
  | subject_template              (* an IRI or prefixed name built from cells *)
  | "[" NEWLINE INDENT mapping_assertion+ DEDENT "]"
  | fact_value                    (* a constant, identical on every row *)

cast ::= "as" ("string" | "int" | "float" | "boolean" | "date" | quantity_unit)

(* `quantity(kW)`: the cell is the number, the unit is written here. *)
quantity_unit ::= "quantity" "(" unit_expr ("as" convert_target)? ")"

(* `export` writes the rows of a query to a file. Unlike `mapping`, `export`
   is not a reserved word: it is matched contextually at the start of a
   top-level declaration, so a concept or property named `export` keeps
   working. `from`, `map`, `limit`, `to`, `sheet`, `for` and `each` are
   contextual too; `query` is the reserved word of `query`. `map:` may be
   left out only after `from query`. `header row` is refused under `to:`. *)
export ::=
    "export" NEWLINE
    INDENT
      export_source
      export_map?                 (* required with `from:` *)
      "to" ":" NEWLINE
      INDENT
        IRI "as" Name NEWLINE     (* <export://path> as CSV | XLSX | JSONPath | ... *)
        mapping_header_line*      (* `sheet` and `for each` only *)
      DEDENT
    DEDENT

export_source ::=
    "from" "query" qualified_name NEWLINE
  | "from" ":" NEWLINE
    INDENT
      query_clause*
      group_by_block?             (* a query body without `return` *)
    DEDENT

export_map ::=
    "map" ":" NEWLINE
    INDENT
      export_column+
      ("limit" Integer NEWLINE)?
    DEDENT

export_column ::=
    (Name | String) "as" variable cast? NEWLINE
    (INDENT column_modifier+ DEDENT)?

rule ::=
    "rule" Name ":" NEWLINE
    INDENT
      match_block
      then_block
    DEDENT

match_block ::=
    "match" ":" NEWLINE
    INDENT
      match_pattern+
    DEDENT

then_block ::=
    "then" ":" NEWLINE
    INDENT
      then_item+
    DEDENT

match_pattern ::=
    subject qualified_name object NEWLINE
  | subject qualified_name contraint_block NEWLINE
  | subject "a" tyep_ref NEWLINE
  | "among" ":" NEWLINE
    INDENT
      match_pattern+
    DEDENT quantifier ":" NEWLINE
    INDENT
      match_pattern+
    DEDENT
  | quantifier ":" NEWLINE
    INDENT
      match_pattern+
    DEDENT
  | variable constraint_block NEWLINE                 (* condition on a bound value *)
  | aggregation variable "as" variable NEWLINE        (* aggregate, same line as in queries *)
    INDENT
      match_pattern+                                  (* no aggregate inside *)
    DEDENT

quantifier ::=
    "all"
  | "none"
  | "at least" Integer
  | "at most" Integer
  | "exactly" Integer
  | "between" Integer "," Integer

then_item ::=
    assertion NEWLINE
  | nested_rule

assertion ::=
    subject qualified_name object
  | subject qualified_name no_comp_contraint_block
  | subject "a" type_ref

nested_rule ::= match_block then_block

(* ----------------------------------------------------------------------- *)
(* Queries                                                                  *)
(* ----------------------------------------------------------------------- *)

query ::=
    "query" Name ":" NEWLINE
    INDENT
      query_clause*
      group_by_block?
      return_block?
    DEDENT

query_clause ::=
    query_subject_pattern
  | existence_block
  | query_composition
  | variable comparison_op (variable | literal) NEWLINE     (* filter *)
  | aggregation variable "as" variable NEWLINE              (* aggregate sub-query *)
    INDENT
      query_clause+
    DEDENT
  | "is" qualified_name "of" object NEWLINE

query_subject_pattern ::=
    variable "a" type_ref NEWLINE (INDENT property_pattern+ DEDENT)?
  | "a" type_ref NEWLINE (INDENT property_pattern+ DEDENT)?
  | variable NEWLINE INDENT property_pattern+ DEDENT
  | variable qualified_name object NEWLINE (INDENT property_pattern+ DEDENT)?
  | variable qualified_name constraint_block NEWLINE
  | variable qualified_name subject_block NEWLINE
  | variable "is" qualified_name "of" (variable | constraint_block) NEWLINE

property_pattern ::=
    qualified_name object NEWLINE
  | qualified_name constraint_block NEWLINE
  | qualified_name subject_block NEWLINE
  | "optional" qualified_name object NEWLINE
  | "is" qualified_name "of" (variable | constraint_block) NEWLINE
  | "either" qualified_name constraint_block NEWLINE
    ("or" qualified_name constraint_block NEWLINE)+

subject_block ::= "[" NEWLINE INDENT property_pattern+ DEDENT "]"

existence_block ::=
    ("some" | "none") ":" NEWLINE
    INDENT
      query_clause+
    DEDENT

(* The field names are the return columns of the called query. *)
query_composition ::=
    qualified_name "as" variable NEWLINE
  | qualified_name "as" "[" NEWLINE
    INDENT
      (Name variable NEWLINE)+
    DEDENT
    "]" NEWLINE?

aggregation ::= "count" | "count" "distinct" | "sum" | "average" | "min" | "max"

(* Each line is an aggregate or a HAVING filter, in any order. *)
group_by_block ::=
    "group by" variable NEWLINE
    INDENT
      ( aggregation variable "as" variable NEWLINE
      | variable comparison_op (variable | literal) NEWLINE )+
    DEDENT

return_block ::=
    "return" NEWLINE
    INDENT
      return_line*
    DEDENT

return_line ::=
    Name? variable NEWLINE
    (INDENT column_modifier+ DEDENT)?
  | "limit" Integer NEWLINE

column_modifier ::=
    "order" ("asc" | "desc") NEWLINE
  | "distinct" NEWLINE

subject ::=
    variable
  | qualified_name
  | constraint_block

object ::=
    variable
  | literal
  | qualified_name

constraint_block ::= "[" constraint ("," constraint)* "]"

no_comp_constraint_block ::= "[" no_comp_constraint ("," no_comp_constraint)* "]"

no_comp_constraint ::=
    "a" type_ref
  | qualified_name object
  | qualified_name constraint_block

(* In rule and query blocks, a comparison takes a literal, a
   variable or an arithmetic expression over them; quantifiers and `$` are
   condition_block only. *)
constraint ::=
    no_comp_constraint
  | variable? comparison_op expr
  | "is" qualified_name "of" (object | constraint_block)

comparison_op ::=
    "="
  | "!="
  | "<"
  | "<="
  | ">"
  | ">="

type_ref ::=
    type_primary
  | type_primary with_clause+                       (* T and (with …) and … *)
  | with_clause+

type_primary ::=
    type_atom
  | "(" type_ref ")"
  | "not" type_primary                              (* owl:complementOf *)
  | "(" type_ref ("or" type_ref)+ ")"               (* owl:unionOf *)
  | "(" type_ref ("and" type_ref)+ ")"              (* owl:intersectionOf *)
(* Mixing "and" and "or" in one group is an error. "and"/"not"/"with"
   operands must not contain primitives. *)

with_clause ::= "with" "^"? qualified_name ":" with_filler   (* owl:Restriction *)
with_filler ::=
    type_primary                                    (* owl:someValuesFrom *)
  | "only" type_primary                             (* owl:allValuesFrom; `only` is contextual *)
  | cardinality type_primary                        (* qualified cardinalities + owl:onClass *)
  | "=" (literal | qualified_name)                  (* owl:hasValue *)

type_atom ::=
    "string"
  | "int"
  | "float"
  | "boolean"
  | "date"
  | "time"
  | "date_time"
  | "duration"
  | quantity_type
  | qualified_name

(* Built-in quantity datatypes. Contextual, not keywords: only a type in type
   position, so `has length: length` is valid. A capitalised `Mass` or
   `unit.Mass` is a qualified_name, linted towards `mass`. "duration" (above)
   is the time dimension. *)
quantity_type ::=
    "length" | "mass" | "current" | "temperature" | "amount"
  | "luminosity" | "speed" | "acceleration" | "force" | "pressure" | "energy"
  | "power" | "frequency" | "area" | "volume" | "density" | "momentum"

qualified_name ::=
  

(* ----------------------------------------------------------------------- *)
(* Literals                                                                 *)
(* ----------------------------------------------------------------------- *)

literal ::=
    String
  | Integer
  | Float
  | Boolean
  | IRI
  | temporal_literal
  | quantity_literal

(* Temporal smart literals. The keyword names the XSD type; the parenthesised
   content is human-friendly notation, parsed by the dolfin-datetime library.
   The content must NOT contain a ")". *)
temporal_literal ::=
    "date"      "(" date_content     ")"
  | "time"      "(" time_content     ")"
  | "date_time" "(" datetime_content ")"
  | "duration"  "(" duration_content ")"

date_content ::= natural_date | numeric_date ("as" date_field_order)?

natural_date ::= month_name day_ordinal year
               | day_ordinal month_name year
month_name   ::= "January" | "Jan" | "February" | "Feb" | ... | "December" | "Dec"  (* case-insensitive *)
day_ordinal  ::= Integer ("st" | "nd" | "rd" | "th")?   (* suffix is cosmetic *)
year         ::= Integer
numeric_date ::= Integer date_sep Integer date_sep Integer
(* The separator in the value and in the "as" mask must match. Without a mask
   the file-level @locale supplies the field order; with neither, an ambiguous
   numeric date is an error. *)

time_content ::= hour ":" minute (":" second)? am_pm? timezone_spec?
hour   ::= Integer          (* 0-23, or 1-12 with AM/PM *)
minute ::= Integer          (* 00-59 *)
second ::= Integer          (* 00-59 *)
am_pm  ::= "AM" | "PM"      (* case-insensitive; switches to 12-hour clock *)

datetime_content ::= date_content ("," )? time_content   (* comma or space separated *)

duration_content ::= duration_term+
duration_term    ::= Integer duration_unit
duration_unit    ::= "y" | "mo" | "w" | "d" | "h" | "min" | "s"

(* Physical-quantity smart literal. The parenthesised content is a value and a
   unit expression, parsed by the dolfin-units library. The content must NOT
   contain a ")". See the Units & Quantities reference. *)
quantity_literal ::= "quantity" "(" quantity_content ")"
quantity_content ::= simple_quantity (arith_op simple_quantity)* ("as" convert_target)?
simple_quantity  ::= Number unit_expr | Number       (* bare number = dimensionless *)
arith_op         ::= "+" | "-" | "*" | "/"
convert_target   ::= unit_expr | "SI"
unit_expr        ::= unit_term (("." | "/") unit_term)*
unit_term        ::= unit_name ("^" power)?
power            ::= Integer | "(" Integer ")"        (* e.g. s^(-1) *)
unit_name        ::= (* a unit token, optionally SI-prefixed, e.g. m, kg, km, N, km/h components *)

cardinality ::=
    "one"
  | "any"
  | "some"
  | "optional"
  | Integer
  | "at least" Integer
  | "at most" Integer
  | Integer ".." Integer
  | Integer ".." "*"

(* "at least"/"at most" (and the "at_least"/"at_most" underscore aliases) are
   shared with the rule quantifiers above. *)