Skip to content

Instantly share code, notes, and snippets.

@kisp
Created June 9, 2026 13:25
Show Gist options
  • Select an option

  • Save kisp/2ec7a8fc474917f54e772403b5822140 to your computer and use it in GitHub Desktop.

Select an option

Save kisp/2ec7a8fc474917f54e772403b5822140 to your computer and use it in GitHub Desktop.
Mindful Routine Planner — a local-admissibility architecture for scheduling constraints (redesign for #41/#45)

Stop Asking the World: a Local-Admissibility Architecture for Scheduling Constraints

A from-scratch redesign of constraints in Mindful Routine Planner.


1. What we actually want

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:

  1. "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.
  2. "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.
  3. Reuse. One "at T8" rule shared by all four Roborock tasks — not four copies.
  4. It must never blow up. Rescheduling a task must not throw a 500 just because some other task is currently mis-placed.
  5. 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.


2. Where the current design hurts

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 every false for every candidate date, so no task can be scheduled at all — even a task with no constraints of its own. That's the find-best-next-date did not find a next-date 500.
  • The "single" trap (#41). single-task-date-constraint is 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.


3. The reframe (the whole idea in one sentence)

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.


4. The architecture

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.


5. Two constraint kinds, one protocol

Everything the app needs is two implementations of admissible-p.

a) Date-predicate constraint (generalises single-task-date-constraint)

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.

b) Capacity constraint (this is multi-task-limit-per-day-constraint)

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.

"Satisfied?" becomes a report, not the engine

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.


6. Does it work? Use cases

# 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

Feasibility / the no-500 guarantee

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.

A free extension (kept out of scope, but note the shape)

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.


7. Differences from the current design

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

8. Migration notes (clobber)

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 old satisfiedp becomes the derived report; the engine uses admissible-p.
  • multi-task-limit-per-day-constraint → keep the symbol; it is the capacity constraint. admissible-p is the local "fits on day d" check.
  • New code: admissible-p methods + a rewritten find-possible-next-dates that maps over (constraints-relevant-to task) instead of (list-constraints); calendar-valid-dates collapses into the same call. The "satisfied?" report is recomputed from admissible-p.

No new tables, no data migration — just two new methods and a smaller scheduler.


9. The takeaway

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment