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}