Initial commit, ACM PEARC25 and PEARC26 chosen restaurants work.
Committer: hpcdisrespecter <noreply@git.asperger.pro> modified: .gitignore modified: README.md new file: menus/peoples-organic.sexp new file: menus/townhall-shortnorth.sexp new file: organic-cafe.asd new file: run.lisp new file: src/package.lisp new file: src/planner.lisp new file: test/tests.lisp
This commit is contained in:
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,25 +1 @@
|
||||
# ---> Haskell
|
||||
dist
|
||||
dist-*
|
||||
cabal-dev
|
||||
*.o
|
||||
*.hi
|
||||
*.hie
|
||||
*.chi
|
||||
*.chs.h
|
||||
*.dyn_o
|
||||
*.dyn_hi
|
||||
.hpc
|
||||
.hsenv
|
||||
.cabal-sandbox/
|
||||
cabal.sandbox.config
|
||||
*.prof
|
||||
*.aux
|
||||
*.hp
|
||||
*.eventlog
|
||||
.stack-work/
|
||||
cabal.project.local
|
||||
cabal.project.local~
|
||||
.HTF/
|
||||
.ghc.environment.*
|
||||
|
||||
|
||||
119
README.md
119
README.md
@@ -1,3 +1,120 @@
|
||||
# organicCafe
|
||||
|
||||
MC sampling a downtown cafe's menu under the Federal per diem
|
||||
~~Practical joke~~ ~~vent art~~ SERIOUS DISCUSSION code for generating multi-day,
|
||||
budget-constrained meal plans from a menu using MC sampling.
|
||||
|
||||
- Never make a decision about food at a technical conference again!
|
||||
- Make wait staff feel awkward by ordering as soon as you arrive!
|
||||
- Avoid the temptation to eat the same thing every day!
|
||||
|
||||
The menu and meal templates are a small, data-only S-expression DSL.
|
||||
|
||||
Shoutout to People's Organic in MSP (Federal per diem: $92, FY2026) and
|
||||
TownHall Short North in Columbus ($80, FY2025), the respective off-site ordering adventures
|
||||
for ACM PEARC26 and PEARC25.
|
||||
|
||||
People's Organic has a small menu and fewer options, whereas TownHall produces
|
||||
a Cartesian product of garnishes. Both are supported in this framework.
|
||||
|
||||
## General Ideas
|
||||
|
||||
Prices are converted to integer cents when loaded, so budget comparisons do
|
||||
not rely on floating-point arithmetic.
|
||||
|
||||
For normal generation, the planner computes the weighted number of completions
|
||||
at each remaining budget and samples only branches that can finish beneath the
|
||||
cap. This avoids both rejection loops and materializing large Cartesian products.
|
||||
|
||||
Any given day, a small pool of feasible days is drawn, and least-repetitive
|
||||
candidates are favored.
|
||||
|
||||
`feasible-candidates` still eagerly enumerates combinations for tests,
|
||||
diagnostics, and suitably small menus such as People's Organic.
|
||||
|
||||
## Variables
|
||||
|
||||
- `--budget DOLLARS` sets the daily per diem.
|
||||
- `--days N` sets the number of days to plan.
|
||||
- `--menu FILE` selects a menu file from `menus/`.
|
||||
- `--plan PLAN` selects a plan from the menu. If omitted, the first plan is used.
|
||||
- `--add-lazy-day DOLLARS` appends a breakfast-only day with its own budget. For outbound flight days.
|
||||
- `--seed N` sets the random seed for reproducibility (don't use this you orthorexic nerd).
|
||||
|
||||
## Usage
|
||||
|
||||
SBCL and ASDF are the only requirements.
|
||||
|
||||
```sh
|
||||
# PEARC26 default
|
||||
sbcl --script run.lisp
|
||||
# the same thing explicitly with seed
|
||||
sbcl --script run.lisp -- --budget 92 --days 3 --seed 1337 --add-lazy-day 30
|
||||
# PEARC25 default
|
||||
sbcl --script run.lisp -- \
|
||||
--menu menus/townhall-shortnorth.sexp \
|
||||
--budget 80 --add-lazy-day 30 --seed 1337
|
||||
```
|
||||
|
||||
Use `--help` for all options.
|
||||
|
||||
Ask for help if you need it. Someone will probably answer.
|
||||
|
||||
## Tests
|
||||
|
||||
Run the tests:
|
||||
|
||||
```sh
|
||||
sbcl --non-interactive \
|
||||
--eval '(require "asdf")' \
|
||||
--eval '(asdf:load-asd (truename "organic-cafe.asd"))' \
|
||||
--eval '(asdf:test-system "organic-cafe/tests")'
|
||||
```
|
||||
|
||||
## The DSL
|
||||
|
||||
The complete menus live in `menus/` and defaults to `menus/peoples-organic.sexp`. The DSL is a small S-expression language for describing menus and meal plans.
|
||||
|
||||
`:lazy-day` is a special plan item, for the day you leave town.
|
||||
|
||||
### Basic example
|
||||
|
||||
```lisp
|
||||
(:menu "A Cafe"
|
||||
(:group :mains
|
||||
(:item "Soup and sandwich" 12.50)
|
||||
(:item "Grain bowl" 14))
|
||||
(:group :drinks
|
||||
(:item "Coffee" 3.25))
|
||||
|
||||
(:plan :lunch
|
||||
(:meal :lunch
|
||||
(:one :main :mains)
|
||||
(:maybe :drink :drinks :chance 1/2))))
|
||||
```
|
||||
|
||||
`(:one LABEL GROUP)` always chooses one item. `(:maybe LABEL GROUP :chance P)`
|
||||
chooses no item with probability `1-P`; if present, each item in the group has
|
||||
an equal share of `P`.
|
||||
|
||||
### Composable forms
|
||||
|
||||
TownHall's menu has options, so it demonstrates compositional forms:
|
||||
|
||||
```lisp
|
||||
(:choose :side :bowl-side :count 2)
|
||||
|
||||
(:either :meal
|
||||
(:branch 1/2
|
||||
(:one :shared-plate :pour-la-table))
|
||||
(:branch 1/2
|
||||
(:one :protein :bowl-protein)
|
||||
(:one :base :bowl-base)))
|
||||
```
|
||||
|
||||
`:choose` samples the requested number of distinct items. `:either` selects one
|
||||
weighted branch, whose body may contain any number of other plan forms. Branch
|
||||
weights must sum to one. `:one` and `:maybe` are also valid. You are also valid.
|
||||
|
||||
### License and Sharing
|
||||
|
||||
Do what the fuck you want.
|
||||
|
||||
62
menus/peoples-organic.sexp
Normal file
62
menus/peoples-organic.sexp
Normal file
@@ -0,0 +1,62 @@
|
||||
(:menu "People's Organic Cafe"
|
||||
(:group :breakfast-main
|
||||
(:item "Steak and eggs" 21)
|
||||
(:item "Breakfast bowl" 16)
|
||||
(:item "Avocado toast" 17)
|
||||
(:item "Breakfast quesadilla" 17)
|
||||
(:item "Breakfast croissant" 16)
|
||||
(:item "Classic quiche Lorraine" 14)
|
||||
(:item "Seasonal quiche" 11)
|
||||
(:item "Breakfast burrito" 16))
|
||||
|
||||
(:group :lunch-main
|
||||
(:item "Bison burger" 19)
|
||||
(:item "Cheeseburger" 17)
|
||||
(:item "Northern California wrap" 16)
|
||||
(:item "Roasted turkey pesto sandwich" 15)
|
||||
(:item "MN grilled cheese" 12)
|
||||
(:item "Rosemary flatbread" 15))
|
||||
|
||||
(:group :soup
|
||||
(:item "Soup du jour" 6)
|
||||
(:item "Tomato basil soup" 6))
|
||||
|
||||
(:group :dinner-main
|
||||
(:item "Chicken pot pie" 18)
|
||||
(:item "Sesame glazed salmon" 22)
|
||||
(:item "Ahi tuna" 19)
|
||||
(:item "Chicken fried rice (ask for no hemp seeds)" 20)
|
||||
(:item "Street tacos (chicken)" 16)
|
||||
(:item "Street tacos (mahi mahi)" 16))
|
||||
|
||||
(:group :hot-drink
|
||||
(:item "16 oz cafe au lait" 5)
|
||||
(:item "16 oz chai tea" 6.25)
|
||||
(:item "12 oz sweet matcha latte" 6.60)
|
||||
(:item "London fog" 5.10)
|
||||
(:item "Hot cocoa" 5))
|
||||
|
||||
(:group :cold-drink
|
||||
(:item "Ginger green matcha shake" 9.50)
|
||||
(:item "Co-co antioxidant shake" 9.50)
|
||||
(:item "Garden elixir" 6.70)
|
||||
(:item "Green elixir" 6.70)
|
||||
(:item "16 oz cold press" 5.10))
|
||||
|
||||
(:plan :full-day
|
||||
(:meal :breakfast
|
||||
(:one :main :breakfast-main)
|
||||
(:one :hot-drink :hot-drink))
|
||||
(:meal :lunch
|
||||
(:one :main :lunch-main)
|
||||
(:maybe :soup :soup :chance 1/2)
|
||||
(:one :cold-drink :cold-drink))
|
||||
(:meal :dinner
|
||||
(:one :main :dinner-main)
|
||||
(:one :hot-drink :hot-drink)))
|
||||
|
||||
(:plan :lazy-day
|
||||
(:meal :breakfast
|
||||
(:one :main :breakfast-main)
|
||||
(:one :hot-drink :hot-drink)
|
||||
(:one :cold-drink :cold-drink))))
|
||||
122
menus/townhall-shortnorth.sexp
Normal file
122
menus/townhall-shortnorth.sexp
Normal file
@@ -0,0 +1,122 @@
|
||||
(:menu "TownHall Short North"
|
||||
;; Bowl construction
|
||||
(:group :bowl-protein
|
||||
(:item "Organic chicken" 14)
|
||||
(:item "Grass-fed steak" 17)
|
||||
(:item "Braised beef" 16)
|
||||
(:item "Wild salmon" 16)
|
||||
(:item "Wild shrimp" 17))
|
||||
|
||||
(:group :bowl-base
|
||||
(:item "White rice" 0)
|
||||
(:item "Rice noodle" 0)
|
||||
(:item "Bone broth" 0))
|
||||
|
||||
(:group :bowl-addon
|
||||
(:item "Konjac noodle" 1)
|
||||
(:item "Mushrooms" 1)
|
||||
(:item "Shaved Parmesan" 1)
|
||||
(:item "Extra sauce" 0.50)
|
||||
(:item "Extra side" 3))
|
||||
|
||||
(:group :bowl-side
|
||||
(:item "Garlic broccoli" 0)
|
||||
(:item "Charred pineapple" 0)
|
||||
(:item "Grilled vegetables" 0)
|
||||
(:item "Roasted Brussels sprouts" 0)
|
||||
(:item "Smashed avocado" 0)
|
||||
(:item "Kimchi slaw" 0)
|
||||
(:item "Scrambled eggs" 0))
|
||||
|
||||
(:group :bowl-sauce
|
||||
(:item "Bang bang" 0)
|
||||
(:item "Rosemary aioli" 0)
|
||||
(:item "Korean BBQ" 0)
|
||||
(:item "Togarashi aioli" 0))
|
||||
|
||||
(:group :bowl-garnish
|
||||
(:item "Scallions" 0)
|
||||
(:item "Red onion" 0))
|
||||
|
||||
;; Broth construction
|
||||
(:group :broth-size
|
||||
(:item "12 oz" 3.75))
|
||||
|
||||
(:group :broth-type
|
||||
(:item "Chicken bone broth" 0)
|
||||
(:item "Beef bone broth" 0))
|
||||
|
||||
(:group :broth-addon
|
||||
(:item "Garlic" 0.50)
|
||||
(:item "Onions" 0.50)
|
||||
(:item "Scallions" 0.50)
|
||||
(:item "Konjac noodles" 1)
|
||||
(:item "Mushrooms" 1)
|
||||
(:item "Chicken" 2))
|
||||
|
||||
(:group :pour-la-table
|
||||
(:item "Truffle fries" 9)
|
||||
(:item "Steak bites" 18)
|
||||
(:item "Roasted Brussels sprouts" 6)
|
||||
(:item "Garlic broccoli" 5)
|
||||
(:item "Red sauce flatbread" 13))
|
||||
|
||||
(:group :dessert
|
||||
(:item "Acai bowl" 8)
|
||||
(:item "Energy bites" 8))
|
||||
|
||||
(:group :juice
|
||||
(:item "Citrus" 5)
|
||||
(:item "Pineapple" 5)
|
||||
(:item "Wheatgrass" 5))
|
||||
|
||||
(:group :smoothie
|
||||
(:item "Keto power" 10)
|
||||
(:item "Leg day (no flax)" 8)
|
||||
(:item "Keto cacao" 8))
|
||||
|
||||
(:group :hot-drink
|
||||
(:item "Original" 5)
|
||||
(:item "Honey latte" 5)
|
||||
(:item "Keto nootropic" 6))
|
||||
|
||||
(:group :iced-drink
|
||||
(:item "Kombucha" 5)
|
||||
(:item "Coconut water" 6))
|
||||
|
||||
(:plan :full-day
|
||||
(:meal :breakfast
|
||||
(:one :smoothie :smoothie)
|
||||
(:one :hot-drink :hot-drink)
|
||||
(:one :dessert :dessert))
|
||||
|
||||
(:meal :lunch
|
||||
(:one :protein :bowl-protein)
|
||||
(:one :base :bowl-base)
|
||||
(:choose :side :bowl-side :count 2)
|
||||
(:one :sauce :bowl-sauce)
|
||||
(:choose :garnish :bowl-garnish :count 2)
|
||||
(:one :hot-drink :hot-drink)
|
||||
(:one :cold-drink :iced-drink))
|
||||
|
||||
(:meal :dinner
|
||||
(:either :meal
|
||||
(:branch 1/2
|
||||
(:one :shared-plate :pour-la-table))
|
||||
(:branch 1/2
|
||||
(:one :protein :bowl-protein)
|
||||
(:one :base :bowl-base)
|
||||
(:choose :side :bowl-side :count 2)
|
||||
(:one :sauce :bowl-sauce)
|
||||
(:choose :garnish :bowl-garnish :count 2)))
|
||||
(:one :broth-size :broth-size)
|
||||
(:one :broth-type :broth-type)
|
||||
(:one :cold-drink :iced-drink)
|
||||
(:one :dessert :dessert)))
|
||||
|
||||
(:plan :lazy-day
|
||||
(:meal :breakfast
|
||||
(:one :smoothie :smoothie)
|
||||
(:one :hot-drink :hot-drink)
|
||||
(:one :cold-drink :iced-drink)
|
||||
(:one :dessert :dessert))))
|
||||
15
organic-cafe.asd
Normal file
15
organic-cafe.asd
Normal file
@@ -0,0 +1,15 @@
|
||||
(asdf:defsystem "organic-cafe"
|
||||
:description "A small S-expression DSL and budget-aware cafe meal planner."
|
||||
:version "0.1.0"
|
||||
:license "WTFPL-2.0"
|
||||
:serial t
|
||||
:components ((:file "src/package")
|
||||
(:file "src/planner")))
|
||||
|
||||
(asdf:defsystem "organic-cafe/tests"
|
||||
:depends-on ("organic-cafe")
|
||||
:serial t
|
||||
:components ((:file "test/tests"))
|
||||
:perform (asdf:test-op (operation component)
|
||||
(declare (ignore operation component))
|
||||
(uiop:symbol-call :organic-cafe/tests :run-tests)))
|
||||
17
run.lisp
Normal file
17
run.lisp
Normal file
@@ -0,0 +1,17 @@
|
||||
(require "asdf")
|
||||
|
||||
(let* ((script (or *load-truename* *compile-file-truename*))
|
||||
(root (uiop:pathname-directory-pathname script))
|
||||
(default-menu (merge-pathnames "menus/peoples-organic.sexp" root)))
|
||||
;; Loading source here keeps the CLI read-only. The ASDF system remains
|
||||
;; available for compiled development and test workflows.
|
||||
(load (merge-pathnames "src/package.lisp" root))
|
||||
(load (merge-pathnames "src/planner.lisp" root))
|
||||
(handler-case
|
||||
(uiop:quit
|
||||
(uiop:symbol-call :organic-cafe :run-cli
|
||||
(rest sb-ext:*posix-argv*)
|
||||
:default-menu default-menu))
|
||||
(error (condition)
|
||||
(format *error-output* "Error: ~A~%" condition)
|
||||
(uiop:quit 1))))
|
||||
7
src/package.lisp
Normal file
7
src/package.lisp
Normal file
@@ -0,0 +1,7 @@
|
||||
(defpackage #:organic-cafe
|
||||
(:use #:cl)
|
||||
(:export #:load-menu
|
||||
#:feasible-candidates
|
||||
#:generate-plans
|
||||
#:print-candidate
|
||||
#:run-cli))
|
||||
569
src/planner.lisp
Normal file
569
src/planner.lisp
Normal file
@@ -0,0 +1,569 @@
|
||||
(in-package #:organic-cafe)
|
||||
|
||||
(defstruct item
|
||||
name
|
||||
price
|
||||
group)
|
||||
|
||||
(defstruct plan-slot
|
||||
label
|
||||
group
|
||||
(count 1)
|
||||
(chance 1))
|
||||
|
||||
(defstruct plan-branch
|
||||
weight
|
||||
nodes)
|
||||
|
||||
(defstruct plan-either
|
||||
label
|
||||
branches)
|
||||
|
||||
(defstruct meal
|
||||
name
|
||||
slots)
|
||||
|
||||
(defstruct cafe-plan
|
||||
name
|
||||
meals)
|
||||
|
||||
(defstruct cafe-menu
|
||||
name
|
||||
groups
|
||||
plans)
|
||||
|
||||
(defstruct selection
|
||||
meal
|
||||
label
|
||||
item)
|
||||
|
||||
(defstruct candidate
|
||||
selections
|
||||
total
|
||||
weight)
|
||||
|
||||
(defstruct choice-option
|
||||
selections
|
||||
cost
|
||||
weight)
|
||||
|
||||
(defun dollars-to-cents (value)
|
||||
(unless (and (realp value) (not (minusp value)))
|
||||
(error "Price must be a non-negative number, got ~S." value))
|
||||
(round (* value 100)))
|
||||
|
||||
(defun ensure-keyword (value description)
|
||||
(unless (keywordp value)
|
||||
(error "~A must be a keyword, got ~S." description value))
|
||||
value)
|
||||
|
||||
(defun parse-item (form group)
|
||||
(destructuring-bind (operator name price) form
|
||||
(unless (eql operator :item)
|
||||
(error "Expected (:item name price), got ~S." form))
|
||||
(unless (stringp name)
|
||||
(error "Item name must be a string, got ~S." name))
|
||||
(make-item :name name
|
||||
:price (dollars-to-cents price)
|
||||
:group group)))
|
||||
|
||||
(defun parse-group (form groups)
|
||||
(destructuring-bind (operator name &rest item-forms) form
|
||||
(unless (eql operator :group)
|
||||
(error "Expected a :group form, got ~S." form))
|
||||
(ensure-keyword name "Group name")
|
||||
(when (gethash name groups)
|
||||
(error "Duplicate group ~S." name))
|
||||
(unless item-forms
|
||||
(error "Group ~S has no items." name))
|
||||
(setf (gethash name groups)
|
||||
(mapcar (lambda (item-form)
|
||||
(parse-item item-form name))
|
||||
item-forms))))
|
||||
|
||||
(defun parse-slot (form)
|
||||
(destructuring-bind (operator label group &rest options) form
|
||||
(unless (member operator '(:one :maybe :choose))
|
||||
(error "Expected :one, :maybe, or :choose, got ~S." operator))
|
||||
(ensure-keyword label "Slot label")
|
||||
(ensure-keyword group "Slot group")
|
||||
(when (oddp (length options))
|
||||
(error "Malformed options in ~S." form))
|
||||
(let ((chance (if (eql operator :maybe)
|
||||
(getf options :chance 1/2)
|
||||
1))
|
||||
(count (if (eql operator :choose)
|
||||
(getf options :count)
|
||||
1)))
|
||||
(unless (and (realp chance) (<= 0 chance 1))
|
||||
(error "Chance must be between zero and one, got ~S." chance))
|
||||
(unless (and (integerp count) (plusp count))
|
||||
(error ":choose requires a positive integer :count, got ~S." count))
|
||||
(make-plan-slot :label label
|
||||
:group group
|
||||
:count count
|
||||
:chance chance))))
|
||||
|
||||
(defun parse-node (form)
|
||||
(case (first form)
|
||||
((:one :maybe :choose)
|
||||
(parse-slot form))
|
||||
(:either
|
||||
(destructuring-bind (operator label &rest branch-forms) form
|
||||
(declare (ignore operator))
|
||||
(ensure-keyword label "Either label")
|
||||
(unless branch-forms
|
||||
(error "Either ~S has no branches." label))
|
||||
(let ((branches
|
||||
(mapcar
|
||||
(lambda (branch-form)
|
||||
(destructuring-bind
|
||||
(branch-operator weight &rest node-forms)
|
||||
branch-form
|
||||
(unless (eql branch-operator :branch)
|
||||
(error "Expected a :branch form, got ~S." branch-form))
|
||||
(unless (and (realp weight) (plusp weight))
|
||||
(error "Branch weight must be positive, got ~S." weight))
|
||||
(unless node-forms
|
||||
(error "A branch in ~S is empty." label))
|
||||
(make-plan-branch
|
||||
:weight weight
|
||||
:nodes (mapcar #'parse-node node-forms))))
|
||||
branch-forms)))
|
||||
(unless (= (reduce #'+ branches
|
||||
:key #'plan-branch-weight)
|
||||
1)
|
||||
(error "Branch weights in ~S must sum to one." label))
|
||||
(make-plan-either :label label :branches branches))))
|
||||
(otherwise
|
||||
(error "Unknown plan node ~S." form))))
|
||||
|
||||
(defun parse-meal (form)
|
||||
(destructuring-bind (operator name &rest slot-forms) form
|
||||
(unless (eql operator :meal)
|
||||
(error "Expected a :meal form, got ~S." form))
|
||||
(ensure-keyword name "Meal name")
|
||||
(unless slot-forms
|
||||
(error "Meal ~S has no slots." name))
|
||||
(make-meal :name name :slots (mapcar #'parse-node slot-forms))))
|
||||
|
||||
(defun parse-plan (form)
|
||||
(destructuring-bind (operator name &rest meal-forms) form
|
||||
(unless (eql operator :plan)
|
||||
(error "Expected a :plan form, got ~S." form))
|
||||
(ensure-keyword name "Plan name")
|
||||
(unless meal-forms
|
||||
(error "Plan ~S has no meals." name))
|
||||
(make-cafe-plan :name name :meals (mapcar #'parse-meal meal-forms))))
|
||||
|
||||
(defun validate-node-groups (node plan groups)
|
||||
(etypecase node
|
||||
(plan-slot
|
||||
(let ((items (gethash (plan-slot-group node) groups)))
|
||||
(unless items
|
||||
(error "Plan ~S refers to unknown group ~S."
|
||||
(cafe-plan-name plan)
|
||||
(plan-slot-group node)))
|
||||
(when (> (plan-slot-count node) (length items))
|
||||
(error "Plan ~S chooses ~D items from group ~S, which has only ~D."
|
||||
(cafe-plan-name plan)
|
||||
(plan-slot-count node)
|
||||
(plan-slot-group node)
|
||||
(length items)))))
|
||||
(plan-either
|
||||
(dolist (branch (plan-either-branches node))
|
||||
(dolist (child (plan-branch-nodes branch))
|
||||
(validate-node-groups child plan groups))))))
|
||||
|
||||
(defun validate-plan-groups (plans groups)
|
||||
(maphash
|
||||
(lambda (plan-name plan)
|
||||
(declare (ignore plan-name))
|
||||
(dolist (meal (cafe-plan-meals plan))
|
||||
(dolist (node (meal-slots meal))
|
||||
(validate-node-groups node plan groups))))
|
||||
plans))
|
||||
|
||||
(defun load-menu (pathname)
|
||||
"Read and validate one data-only cafe menu DSL file."
|
||||
(let ((*read-eval* nil))
|
||||
(with-open-file (stream pathname :direction :input)
|
||||
(let ((form (read stream nil :eof))
|
||||
(trailing (read stream nil :eof)))
|
||||
(when (eql form :eof)
|
||||
(error "Menu file ~A is empty." pathname))
|
||||
(unless (eql trailing :eof)
|
||||
(error "Menu file must contain exactly one top-level form."))
|
||||
(destructuring-bind (operator name &rest clauses) form
|
||||
(unless (and (eql operator :menu) (stringp name))
|
||||
(error "Menu must start with (:menu \"name\" ...)."))
|
||||
(let ((groups (make-hash-table :test #'eq))
|
||||
(plans (make-hash-table :test #'eq)))
|
||||
(dolist (clause clauses)
|
||||
(case (first clause)
|
||||
(:group (parse-group clause groups))
|
||||
(:plan
|
||||
(let ((plan (parse-plan clause)))
|
||||
(when (gethash (cafe-plan-name plan) plans)
|
||||
(error "Duplicate plan ~S." (cafe-plan-name plan)))
|
||||
(setf (gethash (cafe-plan-name plan) plans) plan)))
|
||||
(otherwise
|
||||
(error "Unknown menu clause ~S." clause))))
|
||||
(when (zerop (hash-table-count plans))
|
||||
(error "Menu has no plans."))
|
||||
(validate-plan-groups plans groups)
|
||||
(make-cafe-menu :name name :groups groups :plans plans)))))))
|
||||
|
||||
(defun combinations (items count)
|
||||
"Return all distinct COUNT-element combinations of ITEMS."
|
||||
(cond
|
||||
((zerop count) (list nil))
|
||||
((endp items) nil)
|
||||
(t
|
||||
(append
|
||||
(mapcar (lambda (tail) (cons (first items) tail))
|
||||
(combinations (rest items) (1- count)))
|
||||
(combinations (rest items) count)))))
|
||||
|
||||
(defun slot-options (slot meal-name groups)
|
||||
(let* ((items (gethash (plan-slot-group slot) groups))
|
||||
(choices (combinations items (plan-slot-count slot)))
|
||||
(chance (plan-slot-chance slot))
|
||||
(choice-weight (/ chance (length choices)))
|
||||
(options
|
||||
(mapcar
|
||||
(lambda (chosen)
|
||||
(make-choice-option
|
||||
:selections
|
||||
(mapcar (lambda (item)
|
||||
(make-selection :meal meal-name
|
||||
:label (plan-slot-label slot)
|
||||
:item item))
|
||||
chosen)
|
||||
:cost (reduce #'+ chosen :key #'item-price :initial-value 0)
|
||||
:weight choice-weight))
|
||||
choices)))
|
||||
(if (< chance 1)
|
||||
(cons (make-choice-option :selections nil
|
||||
:cost 0
|
||||
:weight (- 1 chance))
|
||||
options)
|
||||
options)))
|
||||
|
||||
(defun combine-option-lists (left-options right-options)
|
||||
(loop for left in left-options
|
||||
append
|
||||
(loop for right in right-options
|
||||
collect
|
||||
(make-choice-option
|
||||
:selections (append (choice-option-selections left)
|
||||
(choice-option-selections right))
|
||||
:cost (+ (choice-option-cost left)
|
||||
(choice-option-cost right))
|
||||
:weight (* (choice-option-weight left)
|
||||
(choice-option-weight right))))))
|
||||
|
||||
(defun sequence-options (nodes meal-name groups)
|
||||
(reduce (lambda (options node)
|
||||
(combine-option-lists
|
||||
options
|
||||
(node-options node meal-name groups)))
|
||||
nodes
|
||||
:initial-value
|
||||
(list (make-choice-option :selections nil :cost 0 :weight 1))))
|
||||
|
||||
(defun node-options (node meal-name groups)
|
||||
(etypecase node
|
||||
(plan-slot
|
||||
(slot-options node meal-name groups))
|
||||
(plan-either
|
||||
(loop for branch in (plan-either-branches node)
|
||||
append
|
||||
(mapcar
|
||||
(lambda (option)
|
||||
(setf (choice-option-weight option)
|
||||
(* (plan-branch-weight branch)
|
||||
(choice-option-weight option)))
|
||||
option)
|
||||
(sequence-options (plan-branch-nodes branch)
|
||||
meal-name
|
||||
groups))))))
|
||||
|
||||
(defun plan-option-lists (plan groups)
|
||||
(loop for meal in (cafe-plan-meals plan)
|
||||
append
|
||||
(loop for node in (meal-slots meal)
|
||||
collect (node-options node (meal-name meal) groups))))
|
||||
|
||||
(defun feasible-candidates (menu plan-name budget)
|
||||
"Materialize feasible candidates, intended for diagnostics and small menus."
|
||||
(let* ((budget-cents (dollars-to-cents budget))
|
||||
(plan (gethash plan-name (cafe-menu-plans menu)))
|
||||
(option-lists
|
||||
(and plan
|
||||
(plan-option-lists plan (cafe-menu-groups menu))))
|
||||
(results '()))
|
||||
(unless plan
|
||||
(error "Unknown plan ~S." plan-name))
|
||||
(labels ((walk (remaining selections total weight)
|
||||
(if (endp remaining)
|
||||
(push (make-candidate
|
||||
:selections (reverse selections)
|
||||
:total total
|
||||
:weight weight)
|
||||
results)
|
||||
(dolist (option (first remaining))
|
||||
(let ((next-total
|
||||
(+ total (choice-option-cost option))))
|
||||
(when (<= next-total budget-cents)
|
||||
(walk (rest remaining)
|
||||
(append (reverse
|
||||
(choice-option-selections option))
|
||||
selections)
|
||||
next-total
|
||||
(* weight
|
||||
(choice-option-weight option)))))))))
|
||||
(walk option-lists '() 0 1)
|
||||
(nreverse results))))
|
||||
|
||||
(defun repetition-score (candidate history)
|
||||
(let ((local (make-hash-table :test #'equal))
|
||||
(score 0))
|
||||
(dolist (selection (candidate-selections candidate) score)
|
||||
(let ((name (item-name (selection-item selection))))
|
||||
(incf score (gethash name history 0))
|
||||
(when (gethash name local)
|
||||
(incf score))
|
||||
(setf (gethash name local) t)))))
|
||||
|
||||
(defun weighted-choice (candidates state)
|
||||
(let* ((total (loop for candidate in candidates
|
||||
sum (coerce (candidate-weight candidate)
|
||||
'double-float)))
|
||||
(target (random total state)))
|
||||
(dolist (candidate candidates (car (last candidates)))
|
||||
(decf target (coerce (candidate-weight candidate) 'double-float))
|
||||
(when (minusp target)
|
||||
(return candidate)))))
|
||||
|
||||
(defun least-repetitive-choice (candidates history state)
|
||||
(let ((best-score nil)
|
||||
(best '()))
|
||||
(dolist (candidate candidates)
|
||||
(let ((score (repetition-score candidate history)))
|
||||
(cond
|
||||
((or (null best-score) (< score best-score))
|
||||
(setf best-score score
|
||||
best (list candidate)))
|
||||
((= score best-score)
|
||||
(push candidate best)))))
|
||||
(weighted-choice best state)))
|
||||
|
||||
(defun remember-candidate (candidate history)
|
||||
(dolist (selection (candidate-selections candidate))
|
||||
(incf (gethash (item-name (selection-item selection)) history 0))))
|
||||
|
||||
(defun make-conditioned-sampler (option-lists budget-cents state)
|
||||
"Return a sampler weighted by choices that fit within BUDGET-CENTS."
|
||||
(let* ((options (coerce option-lists 'vector))
|
||||
(count (length options))
|
||||
(memo (make-hash-table :test #'equal)))
|
||||
(labels
|
||||
((partition (index remaining)
|
||||
(if (= index count)
|
||||
1.0d0
|
||||
(let ((key (cons index remaining)))
|
||||
(multiple-value-bind (cached present-p) (gethash key memo)
|
||||
(if present-p
|
||||
cached
|
||||
(setf
|
||||
(gethash key memo)
|
||||
(loop for option in (aref options index)
|
||||
for cost = (choice-option-cost option)
|
||||
when (<= cost remaining)
|
||||
sum (* (coerce (choice-option-weight option)
|
||||
'double-float)
|
||||
(partition (1+ index)
|
||||
(- remaining cost)))
|
||||
into total
|
||||
finally (return total))))))))
|
||||
(pick-option (index remaining)
|
||||
(let ((total (partition index remaining)))
|
||||
(when (zerop total)
|
||||
(error "No completion fits within the remaining budget."))
|
||||
(let ((target (random total state))
|
||||
(fallback nil))
|
||||
(dolist (option (aref options index) fallback)
|
||||
(when (<= (choice-option-cost option) remaining)
|
||||
(setf fallback option)
|
||||
(decf target
|
||||
(* (coerce (choice-option-weight option)
|
||||
'double-float)
|
||||
(partition (1+ index)
|
||||
(- remaining
|
||||
(choice-option-cost option)))))
|
||||
(when (minusp target)
|
||||
(return option))))))))
|
||||
(let ((total-weight (partition 0 budget-cents)))
|
||||
(values
|
||||
(when (plusp total-weight)
|
||||
(lambda ()
|
||||
(let ((remaining budget-cents)
|
||||
(selections '())
|
||||
(total 0))
|
||||
(dotimes (index count)
|
||||
(let ((option (pick-option index remaining)))
|
||||
(decf remaining (choice-option-cost option))
|
||||
(incf total (choice-option-cost option))
|
||||
(setf selections
|
||||
(append (reverse
|
||||
(choice-option-selections option))
|
||||
selections))))
|
||||
(make-candidate :selections (reverse selections)
|
||||
:total total
|
||||
:weight 1))))
|
||||
total-weight)))))
|
||||
|
||||
(defun generate-plans
|
||||
(menu plan-name budget days
|
||||
&key (state *random-state*) (diversity-pool-size 32))
|
||||
"Generate DAYS budget-conditioned plans, preferring varied candidate pools."
|
||||
(unless (and (integerp days) (plusp days))
|
||||
(error "Days must be a positive integer, got ~S." days))
|
||||
(unless (and (integerp diversity-pool-size)
|
||||
(plusp diversity-pool-size))
|
||||
(error "Diversity pool size must be a positive integer."))
|
||||
(let ((plan (gethash plan-name (cafe-menu-plans menu))))
|
||||
(unless plan
|
||||
(error "Unknown plan ~S." plan-name))
|
||||
(multiple-value-bind (sample total-weight)
|
||||
(make-conditioned-sampler
|
||||
(plan-option-lists plan (cafe-menu-groups menu))
|
||||
(dollars-to-cents budget)
|
||||
state)
|
||||
(declare (ignore total-weight))
|
||||
(unless sample
|
||||
(error "No ~S plan fits within a $~,2F budget."
|
||||
plan-name budget))
|
||||
(let ((history (make-hash-table :test #'equal))
|
||||
(chosen '()))
|
||||
(loop repeat days
|
||||
finally (return (nreverse chosen))
|
||||
do
|
||||
(let* ((pool
|
||||
(loop repeat diversity-pool-size
|
||||
collect (funcall sample)))
|
||||
(candidate
|
||||
(least-repetitive-choice pool history state)))
|
||||
(remember-candidate candidate history)
|
||||
(push candidate chosen)))))))
|
||||
|
||||
(defun display-name (keyword)
|
||||
(string-capitalize
|
||||
(substitute #\Space #\- (symbol-name keyword))))
|
||||
|
||||
(defun print-candidate (candidate &key (stream *standard-output*) day budget)
|
||||
(when day
|
||||
(format stream "~&Day ~D~%" day))
|
||||
(let ((current-meal nil))
|
||||
(dolist (selection (candidate-selections candidate))
|
||||
(unless (eql current-meal (selection-meal selection))
|
||||
(setf current-meal (selection-meal selection))
|
||||
(format stream "~A:~%" (display-name current-meal)))
|
||||
(format stream " ~A: ~A — $~,2F~%"
|
||||
(display-name (selection-label selection))
|
||||
(item-name (selection-item selection))
|
||||
(/ (item-price (selection-item selection)) 100.0))))
|
||||
(format stream "Total: $~,2F" (/ (candidate-total candidate) 100.0))
|
||||
(when budget
|
||||
(format stream " (remaining: $~,2F)"
|
||||
(- budget (/ (candidate-total candidate) 100.0))))
|
||||
(terpri stream))
|
||||
|
||||
(defun read-number (string description)
|
||||
(let ((*read-eval* nil))
|
||||
(multiple-value-bind (value position)
|
||||
(read-from-string string nil nil)
|
||||
(unless (and (realp value) (= position (length string)))
|
||||
(error "~A must be a number, got ~S." description string))
|
||||
value)))
|
||||
|
||||
(defun keyword-argument (string)
|
||||
(intern (string-upcase string) :keyword))
|
||||
|
||||
(defun usage (&optional (stream *standard-output*))
|
||||
(format stream
|
||||
"Usage: sbcl --script run.lisp [--menu FILE] [--plan NAME]~
|
||||
~% [--budget DOLLARS] [--days N]~
|
||||
~% [--add-lazy-day DOLLARS]~
|
||||
~% [--seed N]~
|
||||
~%Defaults: menus/peoples-organic.sexp, full-day, $92, 3 days.~%"))
|
||||
|
||||
(defun run-cli (arguments &key default-menu)
|
||||
(let ((menu-path default-menu)
|
||||
(plan-name :full-day)
|
||||
(budget 92)
|
||||
(days 3)
|
||||
(lazy-budget 30)
|
||||
(seed nil))
|
||||
(labels ((take-value (option)
|
||||
(unless arguments
|
||||
(error "~A requires a value." option))
|
||||
(pop arguments)))
|
||||
(loop while arguments
|
||||
for argument = (pop arguments)
|
||||
do (cond
|
||||
((string= argument "--"))
|
||||
((string= argument "--help")
|
||||
(usage)
|
||||
(return-from run-cli 0))
|
||||
((string= argument "--menu")
|
||||
(setf menu-path (take-value argument)))
|
||||
((string= argument "--plan")
|
||||
(setf plan-name
|
||||
(keyword-argument (take-value argument))))
|
||||
((string= argument "--budget")
|
||||
(setf budget
|
||||
(read-number (take-value argument) "Budget")))
|
||||
((string= argument "--days")
|
||||
(setf days
|
||||
(parse-integer (take-value argument)
|
||||
:junk-allowed nil)))
|
||||
((string= argument "--add-lazy-day")
|
||||
(setf lazy-budget
|
||||
(read-number (take-value argument)
|
||||
"Lazy-day budget")))
|
||||
((string= argument "--seed")
|
||||
(setf seed
|
||||
(parse-integer (take-value argument)
|
||||
:junk-allowed nil)))
|
||||
(t (error "Unknown argument ~A." argument)))))
|
||||
(unless menu-path
|
||||
(error "No menu path was supplied."))
|
||||
(when (eql plan-name :lazy-day)
|
||||
(error "The lazy-day plan cannot be selected directly; use --add-lazy-day DOLLARS."))
|
||||
(when (and lazy-budget
|
||||
(not (and (realp lazy-budget) (plusp lazy-budget))))
|
||||
(error "--add-lazy-day must be a positive dollar amount."))
|
||||
(let* ((menu (load-menu menu-path))
|
||||
(state (if seed
|
||||
(sb-ext:seed-random-state seed)
|
||||
(make-random-state t)))
|
||||
(plans (generate-plans menu plan-name budget days :state state))
|
||||
(lazy-plans
|
||||
(when lazy-budget
|
||||
(generate-plans menu :lazy-day lazy-budget 1
|
||||
:state state))))
|
||||
(format t "~&~A — ~A plan, $~,2F daily budget~2%"
|
||||
(cafe-menu-name menu)
|
||||
(display-name plan-name)
|
||||
budget)
|
||||
(loop for candidate in plans
|
||||
for day from 1
|
||||
do (print-candidate candidate :day day :budget budget)
|
||||
(terpri))
|
||||
(loop for candidate in lazy-plans
|
||||
do (format t "~&Lazy Day (outbound)~%")
|
||||
(print-candidate candidate :budget lazy-budget)
|
||||
(terpri))
|
||||
0)))
|
||||
150
test/tests.lisp
Normal file
150
test/tests.lisp
Normal file
@@ -0,0 +1,150 @@
|
||||
(defpackage #:organic-cafe/tests
|
||||
(:use #:cl)
|
||||
(:export #:run-tests))
|
||||
|
||||
(in-package #:organic-cafe/tests)
|
||||
|
||||
(defun menu-path (filename)
|
||||
(asdf:system-relative-pathname
|
||||
"organic-cafe"
|
||||
(format nil "menus/~A" filename)))
|
||||
|
||||
(defmacro check (form)
|
||||
`(unless ,form
|
||||
(error "Check failed: ~S" ',form)))
|
||||
|
||||
(defun selections (candidate meal label)
|
||||
(remove-if-not
|
||||
(lambda (selection)
|
||||
(and (eql meal (organic-cafe::selection-meal selection))
|
||||
(eql label (organic-cafe::selection-label selection))))
|
||||
(organic-cafe::candidate-selections candidate)))
|
||||
|
||||
(defun check-within-budget (plans budget-cents)
|
||||
(check
|
||||
(every (lambda (candidate)
|
||||
(<= (organic-cafe::candidate-total candidate) budget-cents))
|
||||
plans)))
|
||||
|
||||
(defun test-peoples-organic ()
|
||||
(let* ((menu
|
||||
(organic-cafe:load-menu
|
||||
(menu-path "peoples-organic.sexp")))
|
||||
(candidates
|
||||
;; test a smaller budget to check filter
|
||||
(organic-cafe:feasible-candidates menu :full-day 85))
|
||||
(plans
|
||||
(organic-cafe:generate-plans
|
||||
menu :full-day 85 3
|
||||
:state (sb-ext:seed-random-state 1337))))
|
||||
(check (= 107168 (length candidates)))
|
||||
(check-within-budget candidates 8500)
|
||||
(check (= 3 (length plans)))
|
||||
(check-within-budget plans 8500)
|
||||
(length candidates)))
|
||||
|
||||
(defun test-townhall ()
|
||||
(let* ((menu
|
||||
(organic-cafe:load-menu
|
||||
(menu-path "townhall-shortnorth.sexp")))
|
||||
(plans
|
||||
(organic-cafe:generate-plans
|
||||
menu :full-day 70 3
|
||||
:state (sb-ext:seed-random-state 67)))
|
||||
(lazy-plan
|
||||
(first
|
||||
(organic-cafe:generate-plans
|
||||
menu :lazy-day 30 1
|
||||
:state (sb-ext:seed-random-state 69))))
|
||||
(branch-samples
|
||||
(organic-cafe:generate-plans
|
||||
menu :full-day 100 20
|
||||
:state (sb-ext:seed-random-state 69)
|
||||
:diversity-pool-size 1)))
|
||||
(check (= 3 (length plans)))
|
||||
(check-within-budget plans 7000)
|
||||
(dolist (candidate plans)
|
||||
(check (= 2 (length (selections candidate :lunch :side))))
|
||||
(check (= 2 (length (selections candidate :lunch :garnish))))
|
||||
(check
|
||||
(= 2
|
||||
(length
|
||||
(remove-duplicates
|
||||
(mapcar (lambda (selection)
|
||||
(organic-cafe::item-name
|
||||
(organic-cafe::selection-item selection)))
|
||||
(selections candidate :lunch :side))
|
||||
:test #'string=))))
|
||||
(let ((shared (selections candidate :dinner :shared-plate))
|
||||
(bowl (selections candidate :dinner :protein)))
|
||||
(check (not (and shared bowl)))
|
||||
(check (or shared bowl))
|
||||
(check shared)))
|
||||
(check (<= (organic-cafe::candidate-total lazy-plan) 3000))
|
||||
(check (= 4 (length
|
||||
(organic-cafe::candidate-selections lazy-plan))))
|
||||
(dolist (candidate branch-samples)
|
||||
(let ((shared (selections candidate :dinner :shared-plate))
|
||||
(bowl (selections candidate :dinner :protein)))
|
||||
(check (not (and shared bowl)))
|
||||
(check (or shared bowl))
|
||||
(when bowl
|
||||
(check (= 2 (length (selections candidate :dinner :side))))
|
||||
(check (= 2 (length
|
||||
(selections candidate :dinner :garnish)))))))
|
||||
;; At a roomy budget, both weighted dinner branches are reachable.
|
||||
(check
|
||||
(some (lambda (candidate)
|
||||
(selections candidate :dinner :shared-plate))
|
||||
branch-samples))
|
||||
(check
|
||||
(some (lambda (candidate)
|
||||
(selections candidate :dinner :protein))
|
||||
branch-samples))
|
||||
t))
|
||||
|
||||
(defun test-cli-lazy-day-budget ()
|
||||
(let* ((peoples-menu (menu-path "peoples-organic.sexp"))
|
||||
(output
|
||||
(with-output-to-string (stream)
|
||||
(let ((*standard-output* stream))
|
||||
(organic-cafe:run-cli
|
||||
(list "--days" "1"
|
||||
"--add-lazy-day" "30"
|
||||
"--seed" "1337")
|
||||
:default-menu peoples-menu)))))
|
||||
(check (search "Day 1" output))
|
||||
(check (search "Lazy Day (outbound)" output))
|
||||
(check (not (search "Lazy Day 2" output)))
|
||||
(check
|
||||
(handler-case
|
||||
(let ((*standard-output* (make-broadcast-stream)))
|
||||
(organic-cafe:run-cli
|
||||
(list "--days" "1"
|
||||
"--add-lazy-day" "1"
|
||||
"--seed" "1337")
|
||||
:default-menu peoples-menu)
|
||||
nil)
|
||||
(error (condition)
|
||||
(search "LAZY-DAY" (string-upcase
|
||||
(princ-to-string condition))))))
|
||||
(check
|
||||
(handler-case
|
||||
(progn
|
||||
(organic-cafe:run-cli
|
||||
(list "--plan" "lazy-day")
|
||||
:default-menu peoples-menu)
|
||||
nil)
|
||||
(error (condition)
|
||||
(search "--add-lazy-day"
|
||||
(princ-to-string condition)))))
|
||||
t))
|
||||
|
||||
(defun run-tests ()
|
||||
(let ((peoples-count (test-peoples-organic)))
|
||||
(test-townhall)
|
||||
(test-cli-lazy-day-budget)
|
||||
(format t
|
||||
"~&All organic-cafe tests passed (~:D enumerated People's days; TownHall DP sampling covered).~%"
|
||||
peoples-count)
|
||||
t))
|
||||
Reference in New Issue
Block a user