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 says things like … | A Dolfin ontology can also says… |
|---|---|
An animal has a name column | An animal must have exactly one name |
An appointment has a vet_id foreign key | An 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 thirteen 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.
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
?animal vaccinations 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"
| Field | Required | What 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.
Dr. Portbridge saved the file as
package.dol. 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:
- There is a concept called
Animal. - An Animal can have a
name, which is text. - An Animal can have a
species(also text) and anage(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:
| Type | What it holds | Examples |
|---|---|---|
string | Text | "Biscuit", "cat" |
int | Whole numbers | 42, 0, -3 |
float | Decimal numbers | 3.14, 36.6 |
boolean | True or false | true, false |
Adding the owner
An animal doesn’t walk into a clinic alone (well, cats might). We need an owner:
concept Owner:
has first_name: string
has last_name: 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 <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
concept Animal:
has name: string
has species: string
has age: int
has owner: Owner
concept Owner:
has first_name: string
has last_name: 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 first_name: string
has last_name: 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
datea string? Just to keep this chapter to the basics. Dolfin does have real temporal types (date,time,date_time, andduration) that understand calendars and clocks. We’ll upgrade this string to a properdate_timein 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 license_number: 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 case | Use has | Use 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 <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
concept Animal:
has name: string
has species: string
has age: int
has owner: Owner
concept Owner:
has first_name: string
has last_name: 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 license_number: 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 first_name: string
has last_name: 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 license_number: string
property treatedBy:
Animal -> Veterinarian
# Add your properties here
The appointment book was taking shape. But as Dr. Portbridge typed
species: stringfor 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
stringfield 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”.
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:
| Enum | Concept | |
|---|---|---|
| Instances | Fixed at design time | Created at runtime |
| Has attributes | ❌ | ✅ |
| Can be extended | ❌ (closed list) | ✅ (via sub) |
| Use case | Dropdowns, categories | Real-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 <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
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 first_name: string
has last_name: string
has phone: string
concept Veterinarian:
has name: string
has license_number: 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
| Keyword | Meaning | Example |
|---|---|---|
any (default) | Any (0 to ∞), the default | has task: any Task |
one | Exactly one (required) | has name: one string |
optional | Zero or one | has 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:
nameandspeciesare required: exactly one value, always presentage,weight, andownerare 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?”
| Attribute | Cardinality | Reasoning |
|---|---|---|
name | one | Every animal must have a name, even “Unknown” |
species | one | You always know if it’s a dog or a cat |
age | optional | Strays often have unknown ages |
weight | optional | Not always measured on first visit |
owner | optional | Strays exist |
Applying cardinality to Owner and Appointment
Let’s apply the same thinking to Owner:
concept Owner:
has first_name: one string
has last_name: 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 <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
concept Species:
only values:
Dog
Cat
Bird
Rabbit
Reptile
Other
concept Urgency:
only values:
Routine
Urgent
Emergency
concept AppointmentStatus:
only values:
Scheduled
InProgress
Completed
Cancelled
concept Owner:
has first_name: one string
has last_name: one string
has phone: one string
has email: optional string
has address: optional string
concept Veterinarian:
has name: one string
has license_number: 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 license_number. Add an optional specialization attribute (as a string) and an optional phone:
concept Veterinarian:
has name: one string
has license_number: 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 stringbut 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:
| Keyword | Meaning | Example |
|---|---|---|
at least N | N or more values | has phone: at least 1 string |
between N M | Between N and M (inclusive) | has ref: between 2 5 Owner |
at most N | Between 0 and N (inclusive) | has parents: at most 2 Person |
exactly N | Exactly N values | has 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
| Syntax | Meaning | Example use case |
|---|---|---|
one Type | Exactly one (required) | has name: one string |
optional Type | Zero or one | has nickname: optional string |
Type | Any (0 to ∞, the default) | has tag: string |
at least N Type | N or more | has phone: at least 1 string |
at most N Type | 0 to N (inclusive) | has parents: at most 2 Person |
between N M Type | Between N and M (inclusive) | has references: between 2 5 Owner |
exactly N Type | Exactly N | has 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 first_name: one string
has last_name: one string
has phone_numbers: 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 vaccine_name: one string
has date_administered: one string
has batch_number: 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 <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
concept Species:
only values:
Dog
Cat
Bird
Rabbit
Reptile
Other
concept Urgency:
only values:
Routine
Urgent
Emergency
concept AppointmentStatus:
only values:
Scheduled
InProgress
Completed
Cancelled
concept Owner:
has first_name: one string
has last_name: one string
has phone_numbers: at least 1 string
has email: optional string
has address: optional string
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Vaccination:
has vaccine_name: one string
has date_administered: one string
has batch_number: 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 license_number: 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
Veterinarianwould lose the surgery part. Creating a completely separateSurgeonconcept 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 license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
The keyword sub says: “A Surgeon is a specialization of Veterinarian.” A Surgeon automatically has name, license_number, and specialization (inherited from Veterinarian), plus its own surgery_count and certified_procedures.
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 license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
concept Dentist:
sub Veterinarian
has dental_certification: one string
concept Intern:
sub Veterinarian
has university: one string
has year: one int
All four concepts share name, license_number, 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 can_fly: 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.
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 surgery_count: one int # ← then own attributes
The story so far
package <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
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 first_name: one string
has last_name: one string
has phone_numbers: at least 1 string
has email: optional string
has address: optional string
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
concept Dentist:
sub Veterinarian
has dental_certification: one string
concept Intern:
sub Veterinarian
has university: one string
has year: one int
concept Vaccination:
has vaccine_name: one string
has date_administered: one string
has batch_number: 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 can_fly: 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 machine_certified_on attribute (a string, the machine name) and a readings_performed count. Add the concept:
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: 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"
license_number "VET-2025-001"
The structure mirrors the schema:
factintroduces an instance declaration.DrPortbridgeis the instance’s identifier, you can reference it elsewhere as:DrPortbridge.a Veterinarianasserts that this instance is of typeVeterinarian.- 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.
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 kind | Example |
|---|---|
| String | "Biscuit" |
| Integer | 5 |
| Float | 32.4 |
| Boolean | true |
| Enum value | Dog (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 a leading colon:
fact JohnSmith a Owner
first_name "John"
last_name "Smith"
phone_numbers "555-1234"
preferred_vet :DrPortbridge
fact Biscuit a Dog
name "Biscuit"
species Dog
age 5
weight 32.4
neutered true
owner :JohnSmith
:DrPortbridge and :JohnSmith refer to the DrPortbridge and JohnSmith facts declared elsewhere in the file. Order doesn’t matter. The parser resolves identifiers after reading the whole file, so forward references work.
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 [
vaccine_name "Rabies"
date_administered "2024-03-15"
batch_number "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
first_name "John"
last_name "Smith"
phone_numbers "555-1234"
phone_numbers "555-5678"
preferred_vet :DrPortbridge
Or write them as a comma-separated list:
fact JohnSmith a Owner
first_name "John"
last_name "Smith"
phone_numbers "555-1234", "555-5678"
preferred_vet :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 [
vaccine_name "Rabies"
date_administered "2024-03-15"
]
vaccinations [
vaccine_name "Distemper"
date_administered "2024-03-15"
]
vaccinations [
vaccine_name "Bordetella"
date_administered "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"
license_number "VET-2025-001"
fact DrReyes a Surgeon
name "Carlos Reyes"
license_number "VET-2025-042"
surgery_count 0
certified_procedures "Soft tissue"
certified_procedures "Orthopaedic"
# --- Owners ---
fact JohnSmith a Owner
first_name "John"
last_name "Smith"
phone_numbers "555-1234"
preferred_vet :DrPortbridge
# --- Animals ---
fact Biscuit a Dog
name "Biscuit"
species Dog
age 5
weight 32.4
neutered true
owner :JohnSmith
vaccinations [
vaccine_name "Rabies"
date_administered "2024-03-15"
]
vaccinations [
vaccine_name "Distemper"
date_administered "2024-03-15"
]
vaccinations [
vaccine_name "Bordetella"
date_administered "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:
:Biscuitin the appointment refers back to theBiscuitfact.Pixelhas noowner, she’s the stray kitten. Theoptional Ownercardinality allows this.urgency Routineandstatus Completedare enum values without quotes.
The story so far
# ============================================================
# Happy Paws: Schema + Opening Day Facts
# ============================================================
package <http://happypaws.com/clinic>:
dolfin_version "1"
version "1.0.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
# --- 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 first_name: one string
has last_name: one string
has phone_numbers: at least 1 string
has email: optional string
has address: optional string
has preferred_vet: optional Veterinarian
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
concept Vaccination:
has vaccine_name: one string
has date_administered: one string
has batch_number: 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"
license_number "VET-2025-001"
fact DrReyes a Surgeon
name "Carlos Reyes"
license_number "VET-2025-042"
surgery_count 0
certified_procedures "Soft tissue"
certified_procedures "Orthopaedic"
fact JohnSmith a Owner
first_name "John"
last_name "Smith"
phone_numbers "555-1234"
preferred_vet :DrPortbridge
fact Biscuit a Dog
name "Biscuit"
species Dog
age 5
weight 32.4
neutered true
owner :JohnSmith
vaccinations [
vaccine_name "Rabies"
date_administered "2024-03-15"
]
vaccinations [
vaccine_name "Distemper"
date_administered "2024-03-15"
]
vaccinations [
vaccine_name "Bordetella"
date_administered "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
first_name "Maria"
last_name "Garcia"
phone_numbers "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.
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:
| Type | Holds | Example value |
|---|---|---|
date | A calendar day | date(March 15th 2025) |
time | A wall-clock time | time(2:30 PM) |
date_time | A day and a time | date_time(June 1st 2026, 14:30) |
duration | A length of time | duration(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
given_on date(15/03/2025) # no mask needed the file already knows
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
scheduled_for 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:
y | mo | w | d | h | min | s |
|---|---|---|---|---|---|---|
| years | months | weeks | days | hours | minutes | seconds |
duration(3y) # → P3Y a rabies shot good for three years
duration(2w) # → P2W 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.)
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 scheduled_for: one date_time # was: has date: string
has reason: string
concept Vaccination:
has animal: one Animal
has vaccine_name: one string
has given_on: one date
has valid_for: 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
vaccine_name "Rabies"
given_on date(March 15th 2025)
valid_for duration(3y)
Because given_on is a real date and valid_for 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 <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
concept Animal:
has name: string
has species: string
has age: int
has owner: Owner
concept Owner:
has first_name: string
has last_name: string
has phone: string
concept Vaccination:
has animal: one Animal
has vaccine_name: one string
has given_on: one date
has valid_for: one duration
fact biscuit a Animal
name "Biscuit"
species "Dog"
age 5
fact rabies_shot a Vaccination
animal :biscuit
vaccine_name "Rabies"
given_on date(March 15th 2025)
valid_for duration(3y)
Try it
Add an Appointment fact for Biscuit’s booster with a real date_time, and give the Vaccination a reminder_lead of two weeks:
@locale d/m/y
concept Animal:
has name: string
has species: string
has age: int
has owner: Owner
concept Owner:
has first_name: string
has last_name: string
has phone: string
concept Vaccination:
has animal: one Animal
has vaccine_name: one string
has given_on: one date
has valid_for: one duration
# add: has reminder_lead: 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.
rule flag_unvaccinated:
match:
?animal a Animal
?animal vaccinations 0
then:
?animal a UnvaccinatedAnimal
Note: This is a simplified example. Real vaccine-overdue logic would compare the
given_ondate andvalid_forduration 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:
| Pattern | Meaning |
|---|---|
?animal a Animal | ?animal is an instance of Animal |
?animal vaccinations 0 | ?animal has an attribute vaccinations whose value 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:
| Assertion | Meaning |
|---|---|
?animal a UnvaccinatedAnimal | Classify ?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 [ preferred_vet ?vet ]
then:
?animal treatedBy ?vet
“If an animal’s owner has a preferred vet, assign that vet to the animal.”
(This requires adding preferred_vet to Owner, we’ll do that below.)
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.
Not available yet: aggregators. Rules match and compare individual values. They cannot yet count, sum, or average over a collection (e.g. “an animal with zero vaccinations” or “an owner with more than 5 animals” are aggregations and are not supported in rules today). Stick to attribute matches, comparisons, nested patterns, and relationships, which run end-to-end now.
The story so far
package <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
concept Species:
only values:
Dog
Cat
Bird
Rabbit
Reptile
Other
concept Urgency:
only values:
Routine
Urgent
Emergency
concept AppointmentStatus:
only values:
Scheduled
InProgress
Completed
Cancelled
concept Owner:
has first_name: one string
has last_name: one string
has phone_numbers: at least 1 string
has email: optional string
has address: optional string
has preferred_vet: optional Veterinarian
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
concept Dentist:
sub Veterinarian
has dental_certification: one string
concept Intern:
sub Veterinarian
has university: one string
has year: one int
concept Vaccination:
has vaccine_name: one string
has date_administered: one string
has batch_number: 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 can_fly: one boolean
concept Appointment:
has animal: one Animal
has scheduled_for: 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
?animal vaccinations 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 [ preferred_vet ?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.
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, andasconversions. 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
floataccepts any quantity,quantity(45 kg)orquantity(3 s)alike, nothing here checks that aweightis actually a mass. If you want that checked, the Units & Quantities reference covers dimension-typed ranges (has weight: unit.Mass). This tutorial keepsfloatto 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.
Both sides must be quantities. The unit-aware comparison only runs when the stored value and the threshold are
quantity(...). Comparing aquantity(...)weight against a bare40.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 <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
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 first_name: one string
has last_name: one string
has phone_numbers: at least 1 string
has email: optional string
has address: optional string
has preferred_vet: optional Veterinarian
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
concept Dentist:
sub Veterinarian
has dental_certification: one string
concept Intern:
sub Veterinarian
has university: one string
has year: one int
concept Vaccination:
has vaccine_name: one string
has date_administered: one string
has batch_number: 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 can_fly: one boolean
concept Appointment:
has animal: one Animal
has scheduled_for: 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
?animal vaccinations 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:
| Quantifier | Meaning |
|---|---|
all | Every element must satisfy the condition |
none | No element may satisfy the condition |
at_least n | At least n elements must satisfy it |
at_most n | At most n elements may satisfy it |
exactly n | Exactly 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
?dog vaccinations 0
then:
?dog a UnderVaccinatedDog
Comparison operators
You’ve already seen > and = in rules. Here’s the complete set:
| Operator | Meaning | Example |
|---|---|---|
= | 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 |
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) ]
?animal vaccinations 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:
- Define an empty concept that represents a condition
- Write a rule that classifies instances into that concept
- 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 :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 <http://happypaws.com/clinic>:
dolfin_version "1"
version "0.1.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
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 first_name: one string
has last_name: one string
has phone_numbers: at least 1 string
has email: optional string
has address: optional string
has preferred_vet: optional Veterinarian
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
concept Dentist:
sub Veterinarian
has dental_certification: one string
concept Intern:
sub Veterinarian
has university: one string
has year: one int
concept Vaccination:
has vaccine_name: one string
has date_administered: one string
has batch_number: 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 can_fly: one boolean
concept Appointment:
has animal: one Animal
has scheduled_for: 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
?animal vaccinations 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
?dog vaccinations 0
then:
?dog a UnderVaccinatedDog
rule flag_at_risk:
match:
?animal a Animal
?animal age [ > 15 ]
?animal weight [ < quantity(2 kg) ]
?animal vaccinations 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 athttp://fao.org/species/.Dr. Portbridge looked at her Dolfin file. Her concepts were called
AnimalandDog. The board expectedhttp://naho.gov/ontology/Animalandhttp://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 first_name: one string
has last_name: one string
has phone_numbers: 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 with prefixes
prefix <http://naho.gov/ontology/> as naho
prefix <http://fao.org/species/> as fao
prefix <http://schema.org/> as schema
package <http://happypaws.com/clinic>:
dolfin_version "1"
version "1.0.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
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 first_name: one string
has last_name: one string
has phone_numbers: at least 1 string
has email: optional string
has address: optional string
has preferred_vet: optional Veterinarian
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
concept Dentist:
sub Veterinarian
has dental_certification: one string
concept Intern:
sub Veterinarian
has university: one string
has year: one int
concept Vaccination:
has vaccine_name: one string
has date_administered: one string
has batch_number: 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 can_fly: one boolean
concept Appointment:
has animal: one Animal
has scheduled_for: 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
?animal vaccinations 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
?dog vaccinations 0
then:
?dog a UnderVaccinatedDog
rule flag_at_risk:
match:
?animal a Animal
?animal age [ > 15 ]
?animal weight [ < quantity(2 kg) ]
?animal vaccinations 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.
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 thirteen chapters, you’ve built a complete ontology from scratch. Here’s what each chapter introduced:
| Chapter | Feature | Why you needed it |
|---|---|---|
| 1 | Packages | Identity and metadata for the project |
| 2 | Concepts & primitive types | Describing real-world entities |
| 3 | Properties & references | Connecting concepts to each other |
| 4 | Enums | Controlled vocabularies instead of free text |
| 5 | Cardinality | Constraining how many values an attribute holds |
| 6 | Multi-valued attributes | Lists, required collections, and promoting strings to concepts |
| 7 | Inheritance (sub) | Shared structure without duplication |
| 8 | Facts | Asserting real instances: actual animals, owners, appointments |
| 9 | Temporal types | Real dates, times, and durations instead of strings |
| 10 | Rules | Automated reasoning and inference over facts |
| 11 | Units & quantities | Measurements that carry their unit and compare across units |
| 12 | Constraints & quantifiers | Validation and guard rails |
| 13 | Prefixes & IRIs | Interoperability with external systems |
The complete ontology
Here is the full Happy Paws ontology, everything from every chapter, in one file:
# ============================================================
# Happy Paws Veterinary Clinic: Complete Ontology
# ============================================================
prefix <http://naho.gov/ontology/> as naho
prefix <http://fao.org/species/> as fao
prefix <http://schema.org/> as schema
package <http://happypaws.com/clinic>:
dolfin_version "1"
version "1.0.0"
author "Dr. Helen Portbridge"
description "The Happy Paws veterinary clinic data model"
# ------------------------------------------------------------
# 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 first_name: one string
has last_name: one string
has phone_numbers: at least 1 string
has email: optional string
has address: optional string
has preferred_vet: optional Veterinarian
concept Veterinarian:
has name: one string
has license_number: one string
has specialization: optional string
concept Surgeon:
sub Veterinarian
has surgery_count: one int
has certified_procedures: at least 1 string
concept Dentist:
sub Veterinarian
has dental_certification: one string
concept Intern:
sub Veterinarian
has university: one string
has year: one int
concept Vaccination:
has vaccine_name: one string
has date_administered: one string
has batch_number: 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 can_fly: one boolean
concept Appointment:
has animal: one Animal
has scheduled_for: 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
?animal vaccinations 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
?dog vaccinations 0
then:
?dog a UnderVaccinatedDog
rule flag_at_risk:
match:
?animal a Animal
?animal age [ > 15 ]
?animal weight [ < quantity(2 kg) ]
?animal vaccinations 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"
| Field | Required | Description |
|---|---|---|
dolfin_version | yes | Language version to use (currently "1") |
version | yes | Ontology version (semver string) |
author | no | Author name |
description | no | Human-readable description of the ontology |
*.dlf ontology files: one or more files containing declarations (concepts, enums, properties, rules).
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
Concepts
A concept defines a category of things and the attributes they can carry.
concept Person:
has first_name: one string
has last_name: 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 employee_id: 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.
| Keyword | Meaning |
|---|---|
| (none) | Any number (zero or more) |
any | Any number (zero or more, explicit) |
one | Exactly one (required) |
optional | Zero or one |
some | One or more |
N | Exactly N (integer literal) |
at least N | N or more |
at most N | N or fewer |
N..M | Between N and M (inclusive) |
N..* | At least N (same as at least N) |
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 lucky_numbers: 3 int # exactly three
has phone_numbers: 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)
Primitive types
| Type | Description | Examples |
|---|---|---|
string | Text | "hello", "" |
int | Integer | 0, 42, -7 |
float | Floating-point | 3.14, -0.5 |
boolean | Boolean | true, 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).
| Type | Description | XSD type | Value example |
|---|---|---|---|
date | Calendar date | xsd:date | date(June 1st 2026) |
time | Wall-clock time | xsd:time | time(2:30 PM UTC) |
date_time | Date + time | xsd:dateTime | date_time(June 1st 2026, 14:30) |
duration | Length of time | xsd:duration | duration(1y 6mo) |
concept Appointment:
has scheduled_for: one date_time
has reminder_lead: 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
scheduled_for date_time(June 1st 2026, 2:30 PM)
reminder_lead 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:
| Unit | y | mo | w | d | h | min | s |
|---|---|---|---|---|---|---|---|
| Means | years | months | weeks | days | hours | minutes | seconds |
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
Properties are used in rule patterns and assertions like any attribute.
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
first_name "Alice"
last_name "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 ahasdeclaration (or inherited one) on the concept.
Value forms
| Form | Example | Meaning |
|---|---|---|
| String literal | "Alice" | Text value |
| Integer literal | 42 | Integer value |
| Float literal | 3.14 | Floating-point value |
| Boolean literal | true | Boolean value |
| Temporal literal | date(June 1st 2026) | Date/time/datetime/duration (smart literal) |
| Enum value | Active | A one of member, unquoted |
| Reference | :alice | Another named fact in this module |
| Anonymous block | [ ... ] | Inline instance (blank node) |
| List | v1, v2 | Multiple values for multi-valued properties |
References
A reference points to another fact declaration by its identifier, prefixed with ::
fact acme a Organization
name "Acme Corp"
fact alice a Person
first_name "Alice"
employer :acme
Forward references are allowed; all identifiers are resolved after the full file is parsed.
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
first_name "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
phone_numbers "555-0001"
phone_numbers "555-0002"
Or use a comma-separated list:
fact alice a Person
phone_numbers "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 [
vaccine_name "Rabies"
date_administered "2024-03-15"
]
vaccinations [
vaccine_name "Distemper"
date_administered "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.
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 primary_contact ?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" ] ]
Comparison operators
Used inside constraint blocks:
| Operator | Meaning |
|---|---|
= | Equal |
!= | Not equal |
< | Less than |
<= | Less than or equal |
> | Greater than |
>= | Greater than or equal |
Quantifiers
Quantifiers express conditions over collections of matching bindings:
| Quantifier | Meaning |
|---|---|
all | Every binding must satisfy the sub-patterns |
none | No binding may satisfy the sub-patterns |
at least N | At least N bindings must satisfy the sub-patterns |
at most N | At most N bindings may satisfy the sub-patterns |
exactly N | Exactly N bindings must satisfy the sub-patterns |
between N, M | Between 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
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
Names and identifiers
Simple names are alphanumeric identifiers (with underscores): Person, first_name, 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 segment derived from a file’s name. It appears at the top of an ontology file, before any declarations:
@iri_name "custom-segment"
concept Foo:
has bar: string
This is useful when the file name doesn’t match the IRI fragment expected by external systems.
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
starts_at 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/Brusselsare resolved from a bundled timezone database, available when the parser is built with theiana-tzfeature (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 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
| Type | Description | XSD datatype | Value examples |
|---|---|---|---|
string | Text | xsd:string | "hello", "" |
int | Integer | xsd:integer | 0, 42, -7 |
float | Floating-point | xsd:float | 3.14, -0.5 |
boolean | Boolean | xsd:boolean | true, false |
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.
| Type | XSD datatype | Smart literal | Emitted value |
|---|---|---|---|
date | xsd:date | date(June 1st 2026) | 2026-06-01 |
time | xsd:time | time(2:30 PM UTC) | 14:30:00+00:00 |
date_time | xsd:dateTime | date_time(June 1st 2026, 14:30) | 2026-06-01T14:30:00 |
duration | xsd:duration | duration(1y 6mo) | P1Y6M |
Two file-level directives supply defaults used while resolving temporal values:
@locale d/m/y: field order for numeric dates without an inlineasmask.@timezone <zone>: default timezone fortime/date_timevalues without an inline offset.
See File-level directives. Inline
information (an as mask, an inline offset) always overrides the directive.
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.
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: optional 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> )
| Written | Means |
|---|---|
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).
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.mis a newton-metre./divides:km/his kilometres per hour. A unit after/has its exponent negated, sokm/handkm.h^(-1)mean the same thing.^nraises 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:
| Prefix | k | M | G | c | m | µ / u | n |
|---|---|---|---|---|---|---|---|
| Factor | 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. Prefix
decomposition is only tried when a token is not a known unit on its own.
Known units
The built-in registry covers the SI base units, the named derived units, and the common non-SI units:
| Layer | Tokens (selection) |
|---|---|
| SI base | m, kg, s, A, K, mol, cd |
| SI derived | N, Pa, J, W, Hz, V, Ohm/Ω, F, C, T |
| Mass (non-SI) | g, t (tonne), oz, lb |
| Length (non-SI) | mi, ft, in, yd |
| Time (non-SI) | min, h, d |
| Volume | L/l (litre), mL/ml |
| Pressure/energy | bar, atm, cal, kcal |
| Temperature | K; °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
unitdefand notunit.unitis already an ordinary identifier — the readability convention inhas weight: unit.Massabove is a qualified name, not a reserved word. Reservingunitfor this declaration would break that existing convention, so the declaration keyword isunitdef.
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)
| Operation | Same unit | Same family, no cast | Same 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:
| Operation | Requirement |
|---|---|
+ / - | both operands share the same dimension |
* / / | none (dimensions combine) |
as / convert | target 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:
| Dimension | Letter | SI base unit |
|---|---|---|
| Length | L | m |
| Mass | M | kg |
| Time | T | s |
| Electric current | I | A |
| Temperature | Θ | K |
| Amount of substance | N | mol |
| Luminous intensity | J | cd |
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 are values, not a range type
There is no quantity primitive type. A quantity is a value;
its type is the dimension the value carries. Model a quantity-valued attribute
with a numeric range and give it 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. Dimensions are not a keyword or a grammar feature — they
are ordinary concepts, declared in units.dlf (see the
code samples for this page)
(Mass, Length, Time, Speed, Force, Pressure, Energy, Power,
Frequency, Area, Volume, Density, Momentum, and the other four SI
base dimensions). Drop that file into a project — dolfin has no import
statement, but a project is just a directory of .dlf files whose
declarations are all merged together — and reference a dimension by name:
concept Animal:
has weight: unit.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)
unit. is a readability convention, not a namespace import: a qualified type
name resolves by its last segment, so unit.Mass and a bare Mass are the
same reference. Analysis recognises the concept by name and 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 emits owl:DatatypeProperty
with an rdfs:range naming the dimension IRI (e.g.
https://dolfin.dev/dimension/M1), not the Mass concept itself — the value
is a literal, not an instance of that concept.
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 (
weightin kilograms compared toquantity(3 m)) makes the comparison fail and the row is dropped, it never matches. - Compatible operands are compared by their SI magnitude (value × coefficient).
So a dog whose
weightwas writtenquantity(90 lb)(≈ 40.8 kg) does trip a> quantity(40 kg)threshold, even though 90 and 40 alone would not suggest it.
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 (rulesemantic/quantity-exact-comparison). The ordering operators are the sound ones.
Representation in Turtle
Compiling a quantity uses representation “L”: the value is a typed literal
whose datatype is a canonical unit IRI, and each distinct unit’s
(coefficient, dimension, symbol) is stated once in a definition block.
@prefix dq: <https://dolfin.dev/quantity#> .
:rex :weight "45"^^<https://dolfin.dev/unit/kg> .
:bella :weight "90"^^<https://dolfin.dev/unit/lb> .
# one definition block per distinct unit that appears in the data:
<https://dolfin.dev/unit/kg> dq:coefficient 1.0 ;
dq:dimension "M1" ;
dq:symbol "kg" .
<https://dolfin.dev/unit/lb> dq:coefficient 0.45359237 ;
dq:dimension "M1" ;
dq:symbol "lb" .
- Unit datatype IRI —
https://dolfin.dev/unit/+ a canonical unit string. The canonical form keeps the written unit (no SI reduction), orders numerator terms before denominator terms, and renders powers assym/sym2/sym-1:km/handkm.h^-1both becomeunit:km.h-1;m/sbecomesunit:m.s-1;N/m^2becomesunit:N.m-2. It is written as a full IRI, not a prefixed name, because the local part contains.and-. dq:terms —dq:coefficient(factor to the SI base magnitude),dq:dimension(the canonical dimension string, base orderL M T I Θ N J, e.g."L1.T-1"), anddq:symbol(the human-readable unit, e.g."km/h").- A dimensionless quantity carries no unit and degrades to a plain
"<value>"^^xsd:double.
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:
?s :topSpeed ?v .
BIND(datatype(?v) AS ?u)
?u dq:coefficient ?c ; dq:dimension ?d .
FILTER(?d = "L1.T-1" && (xsd:double(str(?v)) * ?c) >= 16.666…)
The target magnitude (16.666…) and dimension ("L1.T-1") are computed at
compile time from the comparison literal. Rules unfold the same way for the
reasoner, using dedicated dq: comparison predicates
(dq:greaterThanOrEqual, …) 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:kg renders as 45 kg, "42"^^unit:km.h-1 as 42 km/h.
The datatype IRI maps back to a symbol via the definition block’s dq:symbol.
This is representation “L”, the default. A self-describing structured-node form (“S”) that inlines
dq:value/dq:coefficient/dq:dimension/dq:symbolon a blank node, needing no definition block, is a documented alternative.
Errors
| Situation | Result |
|---|---|
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: unit.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 family | compile error INVALID_QUANTITY (S007) |
| Dimension mismatch in a comparison | not 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.
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
.dlfpackage to an OWL graph. - Turtle → Dolfin: reconstruct a
.dlfpackage 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:
- a base IRI (the package identity),
- the file path of the declaration inside the package,
- 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.
Local names and prefix labels
An entity’s local name is its Dolfin name, joined to its namespace IRI with the
# fragment separator:
| Dolfin | IRI |
|---|---|
concept Mammal in mammals.dlf | http://example.com/animals/mammals#Mammal |
concept Animal in the root file | http://example.com/animals#Animal |
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.
Overriding the IRI with @iri_name
The @iri_name annotation overrides the derived IRI. It has three 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: overrides just that concept's IRI
concept Mammal:
@iri_name <http://animals.kingdom/Mammals>
@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.
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: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.
| Dolfin | OWL / RDF |
|---|---|
concept C | C rdf:type rdfs:Class |
concept C: sub P | C rdfs:subClassOf P |
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 |
enum / one of (a, b, …) | owl:equivalentClass [ owl:oneOf ( … ) ] + one owl:NamedIndividual per variant |
fact block | owl:NamedIndividual with rdf:type and property-value triples (the ABox) |
Package metadata
| Dolfin manifest field | OWL triple on the owl:Ontology node |
|---|---|
version | owl:versionInfo |
description | rdfs:comment |
author | dc:creator (dc: = http://purl.org/dc/elements/1.1/) |
Primitive types
| Dolfin | XSD datatype |
|---|---|
string | xsd:string |
int | xsd:integer |
float | xsd:double |
boolean | xsd:boolean |
date | xsd:date |
date_time | xsd:dateTime |
time | xsd:time |
duration | xsd:duration |
Quantities
A quantity(...) value is not a primitive type. It compiles to a typed literal
whose datatype is a canonical unit IRI ("45"^^<https://dolfin.dev/unit/kg>),
accompanied by a once-per-unit definition block that states the unit’s
coefficient, dimension, and symbol with dq: predicates. See
Units & Quantities for the full mapping.
Comments and labels
The name and comment attached to a declaration become SKOS annotations:
| Source | OWL / SKOS |
|---|---|
| declaration name | skos:prefLabel |
| leading comment text | skos:definition |
| annotated alternate label | skos:altLabel |
| annotated scope note | skos:scopeNote |
Cardinality
A has field’s cardinality becomes an OWL restriction on the owning class:
| Dolfin cardinality | OWL restriction on the class |
|---|---|
one | owl:equivalentClass [ owl:cardinality 1 ], property is owl:FunctionalProperty |
optional | owl:equivalentClass [ owl:maxCardinality 1 ], property is owl:FunctionalProperty |
some | owl:subClassOf [ owl:minCardinality 1 ] |
exactly n | owl:subClassOf [ owl:cardinality n ] |
n to m | owl:subClassOf [ owl:minCardinality n ; owl:maxCardinality m ] |
any (default) | no restriction |
Property axioms
Axioms declared on a property map to OWL property characteristics:
| Dolfin | OWL |
|---|---|
symmetric | rdf:type owl:SymmetricProperty |
reflexive | rdf:type owl:ReflexiveProperty |
transitive | rdf:type owl:TransitiveProperty |
sub q | rdfs:subPropertyOf q |
inverse of q | owl:inverseOf q |
equivalent to q | owl:equivalentProperty q |
equivalent to ^q | owl: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.
Round trips
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 inmain.dlf. Turtle that carries nordfs:isDefinedBy(e.g. graphs not produced by this compiler) falls back to deriving the file from the entity IRI structure. @iri_nameoverrides 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>; an isolated one becomes a concept-level@iri_name <iri>. The recovered directive uses the absolute form (a@iri_name "segment"is recovered as the equivalent absolute IRI).- 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_versionis 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 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
concept ::=
"concept" Name ":" NEWLINE
INDENT
concept_member+
DEDENT
concept_member ::=
sub_concept
| has_property
| "one" "of" ":" NEWLINE
INDENT
enum_value+
DEDENT
sub_concept ::= "sub" type_ref ("," typeref)*
has_property ::= "has" Name ":" cardinality? type_ref NEWLINE
property ::=
"property" Name ":" cardinality? type_ref "->" cardinality? type_ref NEWLINE
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
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
subject ::=
variable
| qualified_name
| contraint_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 contraint_block
constraint ::=
no_comp_constraint
| comparison_op literal
comparion_op ::=
"="
| "!="
| "<"
| "<="
| ">"
| ">="
type_ref ::=
"string"
| "int"
| "float"
| "boolean"
| "date"
| "time"
| "date_time"
| "duration"
| qualified_name
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. *)