43 Student Enrolment Courses Example
This example keeps the model deliberately small. The main design choices are:
Enrolmentis the source of truth for which student is taking which course.Student.enrolledis derived from the student's enrolments.Course.prerequisiteis the source of truth for course ordering.- There is no separate
dependentattribute, so the model cannot drift into two inconsistent views of the same prerequisite relationship.
enum Day
{
monday,
tuesday,
wednesday,
thursday,
friday,
saturday,
sunday
}
value TimeSlot
{
Day day;
Integer startMinute;
Integer endMinute;
}
func Boolean overlaps(TimeSlot a, TimeSlot b) =
a.day = b.day
∧ a.startMinute < b.endMinute
∧ b.startMinute < a.endMinute;
entity Course
{
String code;
Boolean advanced;
TimeSlot timeslot;
Course prerequisite*;
// Navigation back to enrolments is derived from Enrolment.course.
Enrolment enrolment* where enrolment.course = this;
key { code };
}
entity Student
{
String name;
Course completed*;
// Navigation to enrolments is derived from Enrolment.student.
Enrolment enrolment* where enrolment.student = this;
// A student's current courses come from the enrolment records.
→ Course enrolled* = enrolment.course;
key { name };
constraints
{
// A course cannot be both completed and currently enrolled.
[completed = enrolled].Σempty;
// If an enrolled course is advanced, all its prerequisites
// must already be completed by this student.
let s=this in
enrolled.(advanced → prerequisite.Σ ⊆ s.completed.Σ);
// No two currently enrolled courses may overlap in time.
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall);
// Do not enrol in a prerequisite after completing a course
// that already depends on it.
let s=this in
enrolled.(let c=this in
s.completed.[c ∈ prerequisite.Σ].Σempty);
}
}
entity Enrolment
{
Student student;
Course course;
}
entity University
{
Student student*;
Course course*;
}
Reading the constraints
The first constraint uses an intensional filter as a relation. It says there is no course value that is
both in completed and in enrolled.
[completed = enrolled].Σempty
The second constraint keeps the current student in s before entering enrolled.(...).
Inside that path tail, this is the enrolled course, so prerequisite.Σ is the set of prerequisites
of that course, while s.completed.Σ is the set of courses completed by the original student.
let s=this in
enrolled.(advanced → prerequisite.Σ ⊆ s.completed.Σ)
The third constraint compares each enrolled course c with the other enrolled courses for the same
student. The filter .[this ≠ c] removes the course from its own comparison set.
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall)
The final constraint checks the opposite direction of the prerequisite relationship without storing a
separate dependent attribute. For each currently enrolled course c, it rejects any completed course
whose prerequisite set contains c.
let s=this in
enrolled.(let c=this in
s.completed.[c ∈ prerequisite.Σ].Σempty)
The student constraints above translate to the following logic:
// A course cannot be both completed and currently enrolled.
∀C:Course
this.completed(C) → ¬this.enrolled(C)
// Advanced enrolled courses require completed prerequisites.
∀A:Boolean ∀E:Course ∀P:Course
(this.enrolled(E) ∧ E.advanced(A) ∧ A ∧ E.prerequisite(P))
→ this.completed(P)
// Currently enrolled courses must not overlap.
∀T:TimeSlot ∀T1:TimeSlot ∀E1:Course ∀E:Course
(this.enrolled(E1)
∧ this.enrolled(E)
∧ E1 ≠ E
∧ E1.timeslot(T)
∧ E.timeslot(T1))
→ ¬overlaps(T, T1)
// Do not enrol in a prerequisite after completing a dependent course.
∀C:Course ∀E:Course
(this.completed(C) ∧ this.enrolled(E))
→ ¬(E ∈ { P:Course | C.prerequisite(P) })
Common wrong turns
The following examples are deliberately concrete. Each one shows a tempting expression, what it actually denotes, and the form that matches the intended constraint.
1. Comparing scalar paths as if they were sets
A common first attempt is to write the prerequisite rule without folds:
enrolled.(advanced → prerequisite ⊆ completed)
This has no logic denotation, because it is not well typed. Inside enrolled.(...),
both prerequisite and completed denote individual Course values in the current path context.
The operator ⊆ expects sets, so the type checker rejects the expression:
ERROR: No overload of ⊆ matches operand types ['Course', 'Course']
The correct expression explicitly folds both sides to sets. It also saves the original student in s,
because inside enrolled.(...) the receiver this is the enrolled course.
let s=this in
enrolled.(advanced → prerequisite.Σ ⊆ s.completed.Σ)
∀A:Boolean ∀E:Course ∀P:Course
(this.enrolled(E) ∧ E.advanced(A) ∧ A ∧ E.prerequisite(P))
→ this.completed(P)
Principle: use .Σ when the constraint is about a set of values. Use let before a path composition
when the inner expression still needs the outer receiver.
2. Forgetting that this changes inside a path tail
This schedule rule looks plausible, but it accidentally compares each enrolled course only with itself:
enrolled.(let c=this in
enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall)
Its denotation exposes the problem. The antecedent contains E ≠ E, which is impossible:
∀T:TimeSlot ∀T1:TimeSlot ∀E:Course
(this.enrolled(E) ∧ E ≠ E ∧ E.timeslot(T) ∧ E.timeslot(T1))
→ ¬overlaps(T, T1)
The issue is that the unqualified inner enrolled is evaluated relative to the current enrolled course,
not relative to the student. Save the student first, then navigate back through s.enrolled.
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall)
∀T:TimeSlot ∀T1:TimeSlot ∀E1:Course ∀E:Course
(this.enrolled(E1)
∧ this.enrolled(E)
∧ E1 ≠ E
∧ E1.timeslot(T)
∧ E.timeslot(T1))
→ ¬overlaps(T, T1)
Principle: after x.(...), unqualified paths in the parentheses are evaluated from each value of x.
Use let outer=this in ... when a nested path still needs the outer object.
3. Using value inequality when the domain has a richer predicate
If TimeSlot is an interval, this rule is too weak:
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].timeslot ≠ c.timeslot)
It only says the two enrolled courses cannot have exactly equal TimeSlot values:
∀T:TimeSlot ∀T1:TimeSlot ∀E1:Course ∀E:Course
(this.enrolled(E1)
∧ this.enrolled(E)
∧ E1 ≠ E
∧ E1.timeslot(T)
∧ E.timeslot(T1))
→ T ≠ T1
For interval times, equality is not the important domain concept. Use the domain predicate:
let s=this in
enrolled.(let c=this in
s.enrolled.[this ≠ c].(¬overlaps(timeslot, c.timeslot)).Σall)
∀T:TimeSlot ∀T1:TimeSlot ∀E1:Course ∀E:Course
(this.enrolled(E1)
∧ this.enrolled(E)
∧ E1 ≠ E
∧ E1.timeslot(T)
∧ E.timeslot(T1))
→ ¬overlaps(T, T1)
Principle: write constraints in the vocabulary of the domain. If the domain concept is overlap, encode overlap directly instead of relying on value inequality.
4. Misusing universal navigation for a universal condition
This example refers to a variant of the model that stores a reverse navigation
Course dependent*. The tempting expression is:
course.(dependent ⇒ advanced)
It does not mean "all dependent courses are advanced". Universal navigation returns values that all
dependents agree on. Used as a Boolean constraint, it says that if every dependent agrees on some
advanced value A, then A must be true:
∀A:Boolean ∀C:Course
(this.course(C) ∧ (∀D:Course (C.dependent(D) → D.advanced(A))))
→ A
The direct constraint is to look for counterexamples: dependent courses that are not advanced. Then require that set to be empty.
course.dependent.[¬advanced].Σempty
∀D:Course ∀C:Course ∀A:Boolean
(this.course(C) ∧ C.dependent(D) ∧ D.advanced(A))
→ A
Principle: to say "all X satisfy P", filter X by the negation of P and require .Σempty.
This is often clearer than reaching for ⇒.
5. Traversing the relationship in the wrong direction
Again suppose the model stores dependent. This expression is easy to write backwards:
student.completed.dependent ≠ course
Its denotation says that the enrolment course must not be a dependent of any completed course:
∀D:Course ∀C1:Course ∀C:Course ∀S:Student
(this.student(S) ∧ S.completed(C) ∧ C.dependent(D) ∧ this.course(C1))
→ D ≠ C1
That is the reverse of "do not enrol in a prerequisite after completing a course that depends on it".
With a stored dependent navigation, the direction should be:
course.dependent ≠ student.completed
∀D:Course ∀C1:Course ∀C:Course ∀S:Student
(this.course(C) ∧ C.dependent(D) ∧ this.student(S) ∧ S.completed(C1))
→ D ≠ C1
The revised model avoids storing dependent at all. It asks whether the currently enrolled course c
appears in the prerequisite set of any completed course:
let s=this in
enrolled.(let c=this in
s.completed.[c ∈ prerequisite.Σ].Σempty)
∀C:Course ∀E:Course
(this.completed(C) ∧ this.enrolled(E))
→ ¬(E ∈ { P:Course | C.prerequisite(P) })
Principle: read a path left to right and name the relationship direction. If the prose says "completed courses that depend on the enrolled course", either navigate from the enrolled course to its dependents, or test whether the enrolled course appears in a completed course's prerequisite set.
Constraint-writing principles
- Decide which entity should own the constraint. If a rule is naturally about one student's courses, put it on
Student. - Keep one source of truth for each relationship. Prefer deriving reverse navigation instead of storing both directions independently.
- Use
.Σwhen comparing sets of path values; otherwise a path usually denotes individual values. - Use
letto preserve an outer receiver before entering a path composition that changesthis. - For universal requirements, look for bad cases and require
.Σempty. - Use domain predicates such as
overlapswhen equality or inequality does not capture the real business rule.