A from-scratch redesign of constraints in Mindful Routine Planner.
The planner places recurring routine tasks onto dates. Constraints exist to shape where a task is allowed (or preferred) to land. From the real usage we have, the wishes are small and concrete:
- "Only on certain days." This Roborock task only on days I'm at T8; this backup only on a Lara day; this chore only on Mondays. A per-task date filter.
- "Not too many at once." At most one backup per day; at most three tasks per day. A capacity limit across a set of tasks.
- Reuse. One "at T8" rule shared by all four Roborock tasks — not four copies.
- It must never blow up. Rescheduling a task must not throw a 500 just because some other task is currently mis-placed.
- One truth. The "Reschedule" button and the calendar date-picker must agree about which dates are allowed.
That's the whole brief. Everything below is in service of those five lines.
Today a constraint is a single boolean about the whole world:
(constraint-satisfiedp constraint) → t / nil ; is this constraint OK, globally?and the scheduler keeps a candidate date only if every constraint in the world is satisfied after a tentative move:
;; find-possible-next-dates
(every #'constraint-satisfiedp (list-constraints))Three pains fall straight out of that one shape:
- Global poisoning (#45). One unsatisfiable constraint makes the global
everyfalse for every candidate date, so no task can be scheduled at all — even a task with no constraints of its own. That's thefind-best-next-date did not find a next-date500. - The "single" trap (#41).
single-task-date-constraintis satisfied only when it has exactly one task whose date matches the predicate. Attach it to four Roborock tasks and it is permanently violated — there's no way to reuse a date rule across tasks. - Two different truths. The calendar picker (
calendar-valid-dates) does not use the global rule — it asks a gentler, per-task question ("does moving here make this task's own constraints worse?"). So the calendar can happily offer a date that Reschedule then rejects with a 500.
The root cause is the abstraction itself: a constraint answers a global yes/no, when the scheduler only ever needs a local one.
A constraint should answer a local question — "may this task go on this date, with everything else where it is?" — not a global one about the entire schedule.
Hold that thought; the rest is mechanical.
A schedule is an assignment of each task to a date. Scheduling proceeds one task at a time: to (re)place task T, we hold every other task fixed and choose a date for T.
So a constraint only needs to expose one operation:
admissible-p (constraint task date) → boolean
;; "May TASK be placed on DATE, given the current placement of all other tasks?"
The scheduler then has a tiny, total definition:
candidate dates for T = { d : every constraint admits (T, d) }
chosen date = argmin over candidates of crowding-cost(T, d)
Two clean layers:
- Hard layer — admissibility. Constraints carve out the feasible region (which dates are allowed for
T). - Soft layer — cost. The existing crowding cost (sum of squares of per-day load) picks the nicest date inside that region. Constraints never need to know about cost; cost never needs to know about constraints.
And one guarantee we design in:
T's current date is (almost) always admissible, so there is always at least one candidate → the 500 becomes structurally impossible. (Pathological over-subscription is handled in §6.)
That's the entire engine. Note what disappeared: the global every, the world-boolean, and the asymmetry between Reschedule and the calendar — both now ask admissible-p.
Everything the app needs is two implementations of admissible-p.
A set of tasks {T1…Tn} and a date predicate P ("is at-T8", "is Monday").
admissible-p(c, T, d) = (T ∉ tasks(c)) ∨ P(d)
Each governed task is filtered independently by its own date. Attach it to one task or fifty — same code. The "single" trap is gone: there's nothing single about it. Reuse (#41) is now the default.
A set of tasks and a per-day limit L ("max 1 backup/day", "max 3 tasks/day").
admissible-p(c, T, d) = (T ∉ tasks(c))
∨ ( |{ t ∈ tasks(c) : t ≠ T, date(t) = d }| < L )
;; "Would adding T to day d keep this group at or under its limit?"
This is the same idea — a local question — applied to capacity instead of identity: count the group's other members already on d and check there's room for one more.
Both kinds are local and monotone in T's own date. That locality is exactly what kills global poisoning and unifies the two code paths.
The Constraints page still wants a green/red badge. Define it as derived, never as something the scheduler consults:
satisfied(c) = every task t in tasks(c) is on an admissible-for-itself date
For a date predicate: every attached task is on a P-day. For capacity: no day exceeds L. It's a view over the schedule, not a gate.
| # | Scenario | Under the architecture | Fixes |
|---|---|---|---|
| 1 | Four Roborock tasks, "only at T8" | One date-predicate constraint, 4 tasks. Each Roborock task is filtered to T8 days, independently. Rescheduling one is unaffected by the others. | #41 |
| 2 | "Backup bei Lara" only on Lara days | Date-predicate, 1 task. Identical behaviour to today. | — |
| 3 | "Max 1 backup per day" | Capacity over the backup tasks, L=1. Placing a backup on a day that already holds another → inadmissible → goes elsewhere. |
— |
| 4 | "Max 3 tasks per day" | Capacity, L=3. |
— |
| 5 | Reschedule "Nano ledger" (no constraints) while "At T8" is violated elsewhere | Nano ledger is governed by nothing → every date admissible → least-crowded wins. No 500. | #45 |
| 6 | Calendar picker for a Roborock task | Shows exactly the admissible (T8) dates — the same set Reschedule would consider. | one-truth |
| 7 | A task under a capacity and a date-predicate | Admissible iff it fits capacity and matches the predicate. Constraints just AND together, locally. | — |
| 8 | Loosen the old "single" to N tasks, vs N separate single-task constraints | Identical (admissible-p is per-task either way) → the convenience is provably free. |
#41 |
T's current date is admissible for every constraint unless that day is already over a capacity limit (e.g. someone hand-placed four tasks where the cap is three). To keep the engine total:
Prefer strictly-admissible dates. If none exists (pathological over-subscription), fall back to the dates that minimise added violations — never error.
That fallback is precisely the calendar's current "don't increase violations" rule, now shared by both paths. So: ideal-when-possible, graceful-when-not, never a stack trace.
Because the hard layer (admissible-p) and the soft layer (cost) are separate, a preference ("ideally on a Monday, but fine otherwise") is just a constraint that contributes cost instead of a veto — cost-contribution(c, T, d). We don't need it yet; the point is the architecture has room for it without a redesign.
| Current | Proposed | |
|---|---|---|
| Constraint API | satisfiedp(c) → bool, global |
admissible-p(c, task, date) → bool, local |
| Scheduler core | "all constraints satisfied in the whole world" | "every constraint admits (T, d)" |
| Date rule | single-task only; ≥2 tasks ⇒ permanently violated | one predicate over N tasks, each filtered independently |
| Capacity rule | global per-day recount over the whole store | local "does T fit on d, others fixed" |
| Failure mode | one bad constraint ⇒ nothing schedules ⇒ 500 | current date stays admissible ⇒ no 500 |
| Reschedule vs Calendar | different rules (strict vs lenient) | one rule (admissible-p) |
| "satisfied" badge | what the engine keys on | a derived report only |
| Hard vs soft | tangled (crowding cost lives beside hard constraints) | separated: constraints = feasible region, crowding = cost |
We don't rename persisted classes. The mapping is behaviour-only:
single-task-date-constraint→ keep the symbol; it is the date-predicate constraint (now accepting any number of tasks). Its oldsatisfiedpbecomes the derived report; the engine usesadmissible-p.multi-task-limit-per-day-constraint→ keep the symbol; it is the capacity constraint.admissible-pis the local "fits on day d" check.- New code:
admissible-pmethods + a rewrittenfind-possible-next-datesthat maps over(constraints-relevant-to task)instead of(list-constraints);calendar-valid-datescollapses into the same call. The "satisfied?" report is recomputed fromadmissible-p.
No new tables, no data migration — just two new methods and a smaller scheduler.
The bug list (#41, #45, calendar-vs-Reschedule) wasn't three bugs. It was one wrong question — "is the whole world OK?" — asked in three places. Replace it with the only question the scheduler ever has — "may this task go here, now?" — and the date-rule reuse, the no-500 guarantee, and the single source of truth all fall out of one tiny interface.
Constraints don't judge the world. They answer, locally, whether one move is allowed. Keep them that small and the scheduler gets simple, total, and honest.