code wiki / _hdl_build / nx_meal_plan.nx
nx_meal_plan.nx source
↩ module page · 380 lines · 13804 B
1// nx_meal_plan.nx -- LIB: the RECIPE + UNIT spine of the meal planner.
2//
3// A UNIT is whoever is cooking -- a family, one person, a restaurant. The estate's inventory engine is already
4// domain-keyed, so all three are the SAME engine over a different `domain` string; this file only gives them
5// names, kinds and an area so a surface can offer "cooking for: the Wests / Ana / Sichuan Spice House" without
6// any of them being a special case. Nothing here is per-kind.
7//
8// SIMILARITY IS EXPLAINABLE ON PURPOSE. "More like this" is integer-permille Jaccard over shared ingredients
9// (1000 * shared / union), so the page can say "shares 6 of 9 ingredients" instead of showing an opaque
10// affinity score a cook cannot argue with. No float; deterministic; identical inputs always rank identically.
11//
12// Schema (prefix passed in, e.g. knowledge/store/meal-):
13// meal:recids -> TAB list of recipe ids
14// meal:rec:<rid> -> title <t> blurb <t> serves <t> SPACE-separated ingredient ids
15// meal:units -> TAB list of unit ids
16// meal:unit:<uid> -> label <t> kind <t> inventory-domain <t> price-area
17// license_tier: ORIGINAL No hw writes (Rule 26).
18import "nx_food_science.nx"
19import "nx_inventory.nx"
20import "nx_seg_store.nx"
21import "nx_syscalls.nx"
22
23const MP_TAB: i64 = 9
24const MP_SP: i64 = 32
25const MP_KEYCAP: i64 = 160
26const MP_VALCAP: i64 = 4096
27const MP_LISTCAP: i64 = 8192
28const MP_TOKCAP: i64 = 96
29const MP_MAXITEMS: i64 = 128
30const MP_MAXREC: i64 = 512
31const MP_PERMILLE: i64 = 1000
32
33// meal:rec:<rid> fields
34const MP_F_TITLE: i64 = 0
35const MP_F_BLURB: i64 = 1
36const MP_F_SERVES: i64 = 2
37const MP_F_ITEMS: i64 = 3
38
39// meal:unit:<uid> fields
40const MP_U_LABEL: i64 = 0
41const MP_U_KIND: i64 = 1
42const MP_U_DOMAIN: i64 = 2
43const MP_U_AREA: i64 = 3
44
45static MP_KB: i64
46static MP_PQ: i64
47static MP_LQ: i64
48func mp_keybuf() -> *u8 { if MP_KB == 0 { MP_KB = sys_mmap(MP_KEYCAP) as i64 } return MP_KB as *u8 }
49func mp_pq() -> *i64 { if MP_PQ == 0 { MP_PQ = sys_mmap(16) as i64 } return MP_PQ as *i64 }
50func mp_lq() -> *i64 { if MP_LQ == 0 { MP_LQ = sys_mmap(16) as i64 } return MP_LQ as *i64 }
51
52func mp_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
53
54func mp_put(prefix: *u8, key: *u8, val: *u8) -> i64 {
55 let vl: i64 = mp_len(val)
56 if fd_streq_store(prefix, key, val, vl) == 1 { return 0 }
57 let w: *i64 = ss_begin()
58 ss_add(w, 1, key, val, vl)
59 let seg: i64 = fd_seg_next(prefix)
60 ss_commit(prefix, w, seg)
61 return 1
62}
63
64// split on an arbitrary separator byte into NUL-terminated tokens; returns count.
65func mp_split(buf: *u8, len: i64, sep: i64, outtoks: *i64) -> i64 {
66 var c: i64 = 0
67 var i: i64 = 0
68 var ls: i64 = 0
69 while i <= len {
70 var brk: i64 = 0
71 if i == len { brk = 1 } else { if buf[i] == (sep as u8) { brk = 1 } }
72 if brk == 1 {
73 let tl: i64 = i - ls
74 if tl > 0 {
75 let tok: *u8 = sys_mmap(MP_TOKCAP)
76 var t: i64 = 0
77 while t < tl { tok[t] = buf[ls + t]; t = t + 1 }
78 tok[tl] = 0 as u8
79 outtoks[c] = tok as i64
80 c = c + 1
81 }
82 ls = i + 1
83 }
84 i = i + 1
85 }
86 return c
87}
88
89// idempotent append of `token` to the TAB list at `key`.
90func mp_list_add(prefix: *u8, key: *u8, token: *u8) -> i64 {
91 let pq: *i64 = sys_mmap(16) as *i64
92 let lq: *i64 = sys_mmap(16) as *i64
93 let cur: *u8 = sys_mmap(MP_LISTCAP)
94 var o: i64 = 0
95 if ss_get(prefix, key, pq, lq) == 1 {
96 let b: *u8 = pq[0] as *u8
97 let n: i64 = lq[0]
98 let toks: *i64 = sys_mmap(8 * MP_MAXREC) as *i64
99 let nt: i64 = mp_split(b, n, MP_TAB, toks)
100 var i: i64 = 0
101 while i < nt { if fd_streq(toks[i] as *u8, token) == 1 { return 0 } i = i + 1 }
102 var u: i64 = 0
103 while u < n { cur[u] = b[u]; u = u + 1 }
104 o = n
105 cur[o] = MP_TAB as u8; o = o + 1
106 }
107 o = as_append(cur, o, token)
108 cur[o] = 0 as u8
109 return mp_put(prefix, key, cur)
110}
111
112// ---- recipes ----
113
114func mp_rec_key(rid: *u8, out: *u8) -> i64 {
115 var o: i64 = 0
116 o = as_append(out, o, "meal:rec:" as *u8)
117 o = as_append(out, o, rid)
118 out[o] = 0 as u8
119 return o
120}
121
122// itemlist is SPACE-separated ingredient ids, using the same ids the pantry stocks.
123func mp_add_recipe(prefix: *u8, rid: *u8, title: *u8, blurb: *u8, serves: i64, itemlist: *u8) -> i64 {
124 let key: *u8 = sys_mmap(MP_KEYCAP)
125 mp_rec_key(rid, key)
126 let val: *u8 = sys_mmap(MP_VALCAP)
127 var o: i64 = 0
128 o = as_append(val, o, title); val[o] = MP_TAB as u8; o = o + 1
129 o = as_append(val, o, blurb); val[o] = MP_TAB as u8; o = o + 1
130 o = fd_apnum(val, o, serves); val[o] = MP_TAB as u8; o = o + 1
131 o = as_append(val, o, itemlist)
132 val[o] = 0 as u8
133 let w: i64 = mp_put(prefix, key, val)
134 mp_list_add(prefix, "meal:recids" as *u8, rid)
135 return w
136}
137
138func mp_field(prefix: *u8, rid: *u8, f: i64, out: *u8) -> i64 {
139 let key: *u8 = mp_keybuf()
140 mp_rec_key(rid, key)
141 let pq: *i64 = mp_pq()
142 let lq: *i64 = mp_lq()
143 if ss_get(prefix, key, pq, lq) != 1 { out[0] = 0 as u8; return 0 }
144 return fd_field(pq[0] as *u8, lq[0], f, out)
145}
146
147func mp_serves(prefix: *u8, rid: *u8) -> i64 {
148 let b: *u8 = sys_mmap(64)
149 let n: i64 = mp_field(prefix, rid, MP_F_SERVES, b)
150 if n == 0 { return 0 }
151 return fd_atoi(b, n)
152}
153
154// the ingredient ids of a recipe; returns the count (0 = unknown recipe).
155func mp_items(prefix: *u8, rid: *u8, outtoks: *i64) -> i64 {
156 let b: *u8 = sys_mmap(MP_VALCAP)
157 let n: i64 = mp_field(prefix, rid, MP_F_ITEMS, b)
158 if n == 0 { return 0 }
159 return mp_split(b, n, MP_SP, outtoks)
160}
161
162func mp_recipes(prefix: *u8, outtoks: *i64) -> i64 {
163 let pq: *i64 = mp_pq()
164 let lq: *i64 = mp_lq()
165 if ss_get(prefix, "meal:recids" as *u8, pq, lq) != 1 { return 0 }
166 return mp_split(pq[0] as *u8, lq[0], MP_TAB, outtoks)
167}
168
169// ---- the ingredient catalogue: display names ----
170// An ingredient id is a JOIN KEY (pantry rows, price observations and recipe lists all use it), so it must
171// stay a terse lowercase token. Its human name is catalogue data and belongs here -- NOT in the pantry, or an
172// ingredient nobody happens to stock would have no name at all and a surface would print the raw id.
173
174func mp_ing_key(id: *u8, out: *u8) -> i64 {
175 var o: i64 = 0
176 o = as_append(out, o, "meal:ing:" as *u8)
177 o = as_append(out, o, id)
178 out[o] = 0 as u8
179 return o
180}
181
182func mp_add_ingredient(prefix: *u8, id: *u8, name: *u8) -> i64 {
183 let key: *u8 = sys_mmap(MP_KEYCAP)
184 mp_ing_key(id, key)
185 return mp_put(prefix, key, name)
186}
187
188// display name for an ingredient; 1 when the catalogue knows it, 0 otherwise (out untouched on a miss so the
189// caller can decide its own fallback).
190func mp_ing_name(prefix: *u8, id: *u8, out: *u8) -> i64 {
191 let key: *u8 = mp_keybuf()
192 mp_ing_key(id, key)
193 let pq: *i64 = mp_pq()
194 let lq: *i64 = mp_lq()
195 if ss_get(prefix, key, pq, lq) != 1 { return 0 }
196 let b: *u8 = pq[0] as *u8
197 let n: i64 = lq[0]
198 var i: i64 = 0
199 while i < n { out[i] = b[i]; i = i + 1 }
200 out[n] = 0 as u8
201 return 1
202}
203
204// ---- units: family / person / restaurant, all one engine ----
205
206func mp_unit_key(uid: *u8, out: *u8) -> i64 {
207 var o: i64 = 0
208 o = as_append(out, o, "meal:unit:" as *u8)
209 o = as_append(out, o, uid)
210 out[o] = 0 as u8
211 return o
212}
213
214func mp_add_unit(prefix: *u8, uid: *u8, label: *u8, kind: *u8, domain: *u8, area: *u8) -> i64 {
215 let key: *u8 = sys_mmap(MP_KEYCAP)
216 mp_unit_key(uid, key)
217 let val: *u8 = sys_mmap(MP_VALCAP)
218 var o: i64 = 0
219 o = as_append(val, o, label); val[o] = MP_TAB as u8; o = o + 1
220 o = as_append(val, o, kind); val[o] = MP_TAB as u8; o = o + 1
221 o = as_append(val, o, domain); val[o] = MP_TAB as u8; o = o + 1
222 o = as_append(val, o, area)
223 val[o] = 0 as u8
224 let w: i64 = mp_put(prefix, key, val)
225 mp_list_add(prefix, "meal:units" as *u8, uid)
226 return w
227}
228
229func mp_unit_field(prefix: *u8, uid: *u8, f: i64, out: *u8) -> i64 {
230 let key: *u8 = mp_keybuf()
231 mp_unit_key(uid, key)
232 let pq: *i64 = mp_pq()
233 let lq: *i64 = mp_lq()
234 if ss_get(prefix, key, pq, lq) != 1 { out[0] = 0 as u8; return 0 }
235 return fd_field(pq[0] as *u8, lq[0], f, out)
236}
237
238func mp_units(prefix: *u8, outtoks: *i64) -> i64 {
239 let pq: *i64 = mp_pq()
240 let lq: *i64 = mp_lq()
241 if ss_get(prefix, "meal:units" as *u8, pq, lq) != 1 { return 0 }
242 return mp_split(pq[0] as *u8, lq[0], MP_TAB, outtoks)
243}
244
245// ---- similarity: shared ingredients, explainable ----
246
247func mp_has_item(items: *i64, n: i64, id: *u8) -> i64 {
248 var i: i64 = 0
249 while i < n { if fd_streq(items[i] as *u8, id) == 1 { return 1 } i = i + 1 }
250 return 0
251}
252
253// how many ingredients two recipes share.
254func mp_shared(prefix: *u8, a: *u8, b: *u8) -> i64 {
255 let ia: *i64 = sys_mmap(8 * MP_MAXITEMS) as *i64
256 let ib: *i64 = sys_mmap(8 * MP_MAXITEMS) as *i64
257 let na: i64 = mp_items(prefix, a, ia)
258 let nb: i64 = mp_items(prefix, b, ib)
259 var c: i64 = 0
260 var i: i64 = 0
261 while i < na { if mp_has_item(ib, nb, ia[i] as *u8) == 1 { c = c + 1 } i = i + 1 }
262 return c
263}
264
265// integer-permille Jaccard: 1000 * shared / (na + nb - shared). 1000 = identical ingredient sets.
266// Returns 0 when either recipe is unknown -- an unknown recipe resembles nothing rather than everything.
267func mp_similarity(prefix: *u8, a: *u8, b: *u8) -> i64 {
268 let ia: *i64 = sys_mmap(8 * MP_MAXITEMS) as *i64
269 let ib: *i64 = sys_mmap(8 * MP_MAXITEMS) as *i64
270 let na: i64 = mp_items(prefix, a, ia)
271 let nb: i64 = mp_items(prefix, b, ib)
272 if na == 0 { return 0 }
273 if nb == 0 { return 0 }
274 var sh: i64 = 0
275 var i: i64 = 0
276 while i < na { if mp_has_item(ib, nb, ia[i] as *u8) == 1 { sh = sh + 1 } i = i + 1 }
277 let uni: i64 = na + nb - sh
278 if uni <= 0 { return 0 }
279 return (MP_PERMILLE * sh) / uni
280}
281
282// "more like this": every OTHER recipe scored and ranked descending. Recipes sharing nothing are omitted --
283// a zero-overlap suggestion is noise, not a recommendation. Returns how many were written.
284func mp_similar(prefix: *u8, rid: *u8, out_rids: *i64, out_scores: *i64, max: i64) -> i64 {
285 let all: *i64 = sys_mmap(8 * MP_MAXREC) as *i64
286 let n: i64 = mp_recipes(prefix, all)
287 var c: i64 = 0
288 var i: i64 = 0
289 while i < n {
290 let cand: *u8 = all[i] as *u8
291 if fd_streq(cand, rid) == 0 {
292 let sc: i64 = mp_similarity(prefix, rid, cand)
293 if sc > 0 {
294 // insertion sort, descending, bounded by max
295 var pos: i64 = c
296 var scan: i64 = 1
297 var j: i64 = 0
298 while scan == 1 {
299 if j >= c { pos = c; scan = 0 } else {
300 if out_scores[j] < sc { pos = j; scan = 0 } else { j = j + 1 }
301 }
302 }
303 if pos < max {
304 var k: i64 = c
305 if k >= max { k = max - 1 }
306 while k > pos {
307 out_scores[k] = out_scores[k - 1]
308 out_rids[k] = out_rids[k - 1]
309 k = k - 1
310 }
311 out_scores[pos] = sc
312 out_rids[pos] = cand as i64
313 if c < max { c = c + 1 }
314 }
315 }
316 }
317 i = i + 1
318 }
319 return c
320}
321
322// ---- "only what's on hand" ----
323
324// how many of a recipe's ingredients this unit does NOT currently stock.
325func mp_missing(inv_prefix: *u8, domain: *u8, prefix: *u8, rid: *u8) -> i64 {
326 let it: *i64 = sys_mmap(8 * MP_MAXITEMS) as *i64
327 let n: i64 = mp_items(prefix, rid, it)
328 var m: i64 = 0
329 var i: i64 = 0
330 while i < n { if iv_qty(inv_prefix, domain, it[i] as *u8) <= 0 { m = m + 1 } i = i + 1 }
331 return m
332}
333
334// COOKABLE RIGHT NOW = every ingredient in stock. A recipe with NO ingredients is NOT cookable: an empty
335// requirement list is trivially satisfied by an empty pantry, which would silently recommend a phantom dish.
336func mp_can_cook(inv_prefix: *u8, domain: *u8, prefix: *u8, rid: *u8) -> i64 {
337 let it: *i64 = sys_mmap(8 * MP_MAXITEMS) as *i64
338 let n: i64 = mp_items(prefix, rid, it)
339 if n == 0 { return 0 }
340 if mp_missing(inv_prefix, domain, prefix, rid) > 0 { return 0 }
341 return 1
342}
343
344// every recipe this unit can cook from stock alone; returns the count written.
345func mp_cookable(inv_prefix: *u8, domain: *u8, prefix: *u8, out_rids: *i64, max: i64) -> i64 {
346 let all: *i64 = sys_mmap(8 * MP_MAXREC) as *i64
347 let n: i64 = mp_recipes(prefix, all)
348 var c: i64 = 0
349 var i: i64 = 0
350 while i < n {
351 if c < max {
352 if mp_can_cook(inv_prefix, domain, prefix, all[i] as *u8) == 1 { out_rids[c] = all[i]; c = c + 1 }
353 }
354 i = i + 1
355 }
356 return c
357}
358
359// recipes ALMOST cookable -- missing at most `slack` ingredients. The useful middle between "cook this now"
360// and the whole catalogue: one stop on the way home and dinner is solved.
361func mp_nearly(inv_prefix: *u8, domain: *u8, prefix: *u8, slack: i64, out_rids: *i64, out_missing: *i64, max: i64) -> i64 {
362 let all: *i64 = sys_mmap(8 * MP_MAXREC) as *i64
363 let n: i64 = mp_recipes(prefix, all)
364 var c: i64 = 0
365 var i: i64 = 0
366 while i < n {
367 let rid: *u8 = all[i] as *u8
368 let it: *i64 = sys_mmap(8 * MP_MAXITEMS) as *i64
369 if mp_items(prefix, rid, it) > 0 {
370 let m: i64 = mp_missing(inv_prefix, domain, prefix, rid)
371 if m > 0 {
372 if m <= slack {
373 if c < max { out_rids[c] = rid as i64; out_missing[c] = m; c = c + 1 }
374 }
375 }
376 }
377 i = i + 1
378 }
379 return c
380}