Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
ev-next-free finds the earliest free slot >= after for a duration within a horizon, probing 'after' + busy-interval ends via the busy_in rule (ev-free?). Finds gaps, skips too-short gaps, half-open at edges. 59/59 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
5.3 KiB
Plaintext
178 lines
5.3 KiB
Plaintext
;; lib/events/availability.sx — free/busy + conflict detection on Datalog.
|
|
;;
|
|
;; Availability is per-actor and is forward-chained Datalog over two EDB
|
|
;; relations:
|
|
;;
|
|
;; (occurrence Key EventId Start End) ; an expanded calendar occurrence
|
|
;; (booking Actor Key) ; actor attends/holds that occurrence
|
|
;;
|
|
;; The derived relations are the whole policy:
|
|
;;
|
|
;; busy(A,S,E) — A is committed for [S,E) (a booked occurrence)
|
|
;; conflict(A,O1,O2) — A double-booked into two overlapping occurrences
|
|
;; busy_in(A,QS,QE) — A is busy somewhere inside query window [QS,QE)
|
|
;;
|
|
;; Intervals are half-open [Start,End) in epoch minutes (see calendar.sx), so
|
|
;; adjacent slots (E == next start) do NOT conflict. Conflict pairs are
|
|
;; canonical (O1 < O2 by key) so each overlap is reported once. The same `busy`
|
|
;; rule answers "is A free in [QS,QE)?" (busy_in is empty) and feeds "when is A
|
|
;; next free?" (ev-next-free probes candidate slots with the same rule).
|
|
|
|
;; A stable key for an occurrence dict {:id :start :end}.
|
|
(define ev-occ-key (fn (occ) (str (get occ :id) "@" (get occ :start))))
|
|
|
|
(define
|
|
ev-occurrence-fact
|
|
(fn
|
|
(occ)
|
|
(list
|
|
(quote occurrence)
|
|
(ev-occ-key occ)
|
|
(get occ :id)
|
|
(get occ :start)
|
|
(get occ :end))))
|
|
|
|
(define ev-occurrence-facts (fn (occs) (map ev-occurrence-fact occs)))
|
|
|
|
(define ev-booking-fact (fn (actor key) (list (quote booking) actor key)))
|
|
|
|
(define ev-qwindow-fact (fn (qs qe) (list (quote qwindow) qs qe)))
|
|
|
|
;; Range restriction: each comparison's variables are bound by an earlier
|
|
;; positive literal (qwindow / busy precede the < tests). Conflict uses
|
|
;; (< O1 O2) on the keys so each overlapping pair is reported once.
|
|
(define
|
|
ev-avail-rules
|
|
(quote
|
|
((busy A S E <- (booking A O) (occurrence O _ S E))
|
|
(conflict
|
|
A
|
|
O1
|
|
O2
|
|
<-
|
|
(booking A O1)
|
|
(booking A O2)
|
|
(occurrence O1 _ S1 E1)
|
|
(occurrence O2 _ S2 E2)
|
|
(< O1 O2)
|
|
(< S1 E2)
|
|
(< S2 E1))
|
|
(busy_in A QS QE <- (qwindow QS QE) (busy A S E) (< S QE) (< QS E)))))
|
|
|
|
;; Build a Datalog db from EDB facts under the availability ruleset.
|
|
(define ev-build-avail (fn (facts) (dl-program-data facts ev-avail-rules)))
|
|
|
|
;; Convenience: build a db from occurrence dicts + booking pairs.
|
|
;; bookings is a list of (actor key) pairs.
|
|
(define
|
|
ev-avail-db
|
|
(fn
|
|
(occs bookings)
|
|
(ev-build-avail
|
|
(append
|
|
(ev-occurrence-facts occs)
|
|
(map
|
|
(fn (b) (ev-booking-fact (first b) (first (rest b))))
|
|
bookings)))))
|
|
|
|
;; Helper: insertion sort a list of (S E ...) lists ascending by S then E.
|
|
(define
|
|
ev-list-before?
|
|
(fn
|
|
(a b)
|
|
(cond
|
|
((< (first a) (first b)) true)
|
|
((> (first a) (first b)) false)
|
|
(else (< (first (rest a)) (first (rest b)))))))
|
|
|
|
(define
|
|
ev-list-insert
|
|
(fn
|
|
(x sorted)
|
|
(cond
|
|
((empty? sorted) (list x))
|
|
((ev-list-before? x (first sorted)) (cons x sorted))
|
|
(else (cons (first sorted) (ev-list-insert x (rest sorted)))))))
|
|
|
|
(define
|
|
ev-sort-lists
|
|
(fn (xs) (reduce (fn (acc x) (ev-list-insert x acc)) (list) xs)))
|
|
|
|
(define
|
|
ev-dedup-sorted
|
|
(fn
|
|
(xs)
|
|
(cond
|
|
((empty? xs) xs)
|
|
((empty? (rest xs)) xs)
|
|
((= (first xs) (first (rest xs))) (ev-dedup-sorted (rest xs)))
|
|
(else (cons (first xs) (ev-dedup-sorted (rest xs)))))))
|
|
|
|
;; All busy intervals (list S E) for an actor, ascending by start.
|
|
(define
|
|
ev-busy
|
|
(fn
|
|
(db actor)
|
|
(let
|
|
((rows (dl-query db (list (quote busy) actor (quote S) (quote E)))))
|
|
(ev-sort-lists (map (fn (b) (list (get b :S) (get b :E))) rows)))))
|
|
|
|
;; Distinct conflicting occurrence-key pairs for an actor (each pair once).
|
|
(define
|
|
ev-conflicts
|
|
(fn
|
|
(db actor)
|
|
(dl-query db (list (quote conflict) actor (quote O1) (quote O2)))))
|
|
|
|
(define
|
|
ev-has-conflict?
|
|
(fn (db actor) (> (len (ev-conflicts db actor)) 0)))
|
|
|
|
;; Is `actor` free across the whole window [qs,qe)? (no booked occurrence
|
|
;; overlaps it). Asserts a transient qwindow fact, queries, retracts.
|
|
(define
|
|
ev-free?
|
|
(fn
|
|
(db actor qs qe)
|
|
(do
|
|
(dl-assert! db (ev-qwindow-fact qs qe))
|
|
(let
|
|
((rows (dl-query db (list (quote busy_in) actor (quote QS) (quote QE)))))
|
|
(begin (dl-retract! db (ev-qwindow-fact qs qe)) (empty? rows))))))
|
|
|
|
;; ---- next-free slot search ----
|
|
;; The earliest start s >= `after` such that [s, s+duration) is entirely free
|
|
;; for `actor` and ends at or before `horizon`, or nil if none. The earliest
|
|
;; such slot must begin either at `after` or immediately after some busy
|
|
;; interval ends (classic interval packing), so those are the only candidates
|
|
;; we probe — each probe reuses the busy_in rule via ev-free?.
|
|
|
|
(define
|
|
ev-first-free
|
|
(fn
|
|
(db actor cands duration horizon)
|
|
(cond
|
|
((empty? cands) nil)
|
|
(else
|
|
(let
|
|
((s (first cands)))
|
|
(if
|
|
(and
|
|
(<= (+ s duration) horizon)
|
|
(ev-free? db actor s (+ s duration)))
|
|
s
|
|
(ev-first-free db actor (rest cands) duration horizon)))))))
|
|
|
|
(define
|
|
ev-next-free
|
|
(fn
|
|
(db actor after duration horizon)
|
|
(let
|
|
((ends (filter (fn (e) (>= e after)) (map (fn (iv) (first (rest iv))) (ev-busy db actor)))))
|
|
(ev-first-free
|
|
db
|
|
actor
|
|
(ev-dedup-sorted (sort (cons after ends)))
|
|
duration
|
|
horizon))))
|