code wiki / _hdl_build / nx_meal_effort.nx

nx_meal_effort.nx source

↩ module page · 490 lines · 18644 B

1// nx_meal_effort.nx -- LIB: OPPORTUNITY COST. "Is cooking this actually the right use of my evening?" 2// 3// The planner already knows what a dish COSTS in ingredients. That is only half a decision. The other half is 4// TIME -- and time is not a property of the recipe, it is a property of the PERSON COOKING IT. A cook who has 5// never dressed an onion takes two and a half times as long as one who has, so the same dish is a cheap 6// weeknight for one household and a bad trade for another. This engine makes that explicit, and it is the seam 7// where the skill coach changes the answer: get better at the knife and cooking-at-home genuinely wins more 8// often. Skill is not decoration here, it is a term in the arithmetic. 9// 10// Options compared: SCRATCH (raw ingredients + your time), PREPPED (pre-cut fresh, less time, more money), 11// FROZEN (least time, least money, a quality call the engine does NOT pretend to make), RESTAURANT (no time, 12// most money). Alternatives are DATA rows, so adding "meal kit" is a row, not a recompile (rule 11). 13// 14// THREE HONESTY INVARIANTS, each gate-enforced, because each one silently flatters cooking at home: 15// 16// 1. AN UNASSESSED SKILL IS NOT AN EXPERT SKILL. If we have never measured someone chopping, ef_step_time 17// returns -1 and the step is counted as UNKNOWN. Defaulting an unmeasured cook to "fast" is how a planner 18// tells a beginner a two-hour braise is a thirty-minute dish. 19// 20// 2. NO INVENTED WAGE. If a household has not said what an hour of theirs is worth, the comparison reports 21// MONEY ONLY and says so. Picking a plausible-looking hourly rate would make the ranking an opinion wearing 22// arithmetic's clothes. 23// 24// 3. ONLY FULLY-KNOWN OPTIONS MAY BE RANKED. An option missing either its money or its time is reported, never 25// ranked -- the same coverage rule that stops a shop carrying one ingredient winning the basket. 26// 27// Integer cents and whole seconds throughout; no floats anywhere. 28// 29// Schema (prefix passed in, e.g. knowledge/store/meal-): 30// meal:skill:<sid> -> name <t> parent-sid <t> what it is (the broad -> specific skill tree) 31// meal:skills -> TAB list of skill ids 32// meal:steps:<rid> -> step count 33// meal:step:<rid>:<n> -> label <t> skill-sid <t> base_seconds (base = a competent cook's pace) 34// meal:level:<unit>:<sid> -> assessed level 0..4 (absent = NEVER ASSESSED, not zero) 35// meal:tmul:<level> -> time multiplier in permille (data-driven, never hardcoded) 36// meal:timevalue:<unit> -> cents per hour (absent = not valued, not free) 37// meal:alt:<rid>:<kind> -> price_cents <t> minutes <t> source <t> date 38// license_tier: ORIGINAL No hw writes (Rule 26). 39import "nx_meal_plan.nx" 40import "nx_food_science.nx" 41import "nx_seg_store.nx" 42import "nx_syscalls.nx" 43 44const EF_TAB: i64 = 9 45const EF_KEYCAP: i64 = 200 46const EF_VALCAP: i64 = 2048 47const EF_PERMILLE: i64 = 1000 48const EF_SECS_PER_HOUR: i64 = 3600 49const EF_MAXSTEPS: i64 = 128 50const EF_MAXDEPTH: i64 = 8 51 52// skill record fields 53const EF_S_NAME: i64 = 0 54const EF_S_PARENT: i64 = 1 55const EF_S_ABOUT: i64 = 2 56 57// step record fields 58const EF_P_LABEL: i64 = 0 59const EF_P_SKILL: i64 = 1 60const EF_P_SECONDS: i64 = 2 61 62// alternative record fields 63const EF_A_PRICE: i64 = 0 64const EF_A_MINUTES: i64 = 1 65const EF_A_SOURCE: i64 = 2 66const EF_A_DATE: i64 = 3 67 68// option table slots 69const EF_O_MONEY: i64 = 0 70const EF_O_SECONDS: i64 = 1 71const EF_O_TIMECOST: i64 = 2 72const EF_O_TOTAL: i64 = 3 73const EF_O_KNOWN: i64 = 4 74const EF_O_KIND: i64 = 5 75const EF_O_SLOTS: i64 = 6 76 77// sentinel: this person has never been assessed on this skill 78const EF_UNASSESSED: i64 = 0 - 1 79 80static EF_KB: i64 81static EF_PQ: i64 82static EF_LQ: i64 83func ef_keybuf() -> *u8 { if EF_KB == 0 { EF_KB = sys_mmap(EF_KEYCAP) as i64 } return EF_KB as *u8 } 84func ef_pq() -> *i64 { if EF_PQ == 0 { EF_PQ = sys_mmap(16) as i64 } return EF_PQ as *i64 } 85func ef_lq() -> *i64 { if EF_LQ == 0 { EF_LQ = sys_mmap(16) as i64 } return EF_LQ as *i64 } 86 87func ef_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 88 89// A REAL empty string, for a ROOT skill's parent. The bare "" literal is unsafe in this dialect (it reads as 90// length > 0 and walks whatever follows), so a root skill seeded with "" would corrupt its own record. 91static EF_EMPTY: i64 92func ef_empty() -> *u8 { 93 if EF_EMPTY == 0 { EF_EMPTY = sys_mmap(8) as i64 } 94 let b: *u8 = EF_EMPTY as *u8 95 b[0] = 0 as u8 96 return b 97} 98 99func ef_num_key(out: *u8, o: i64, v: i64) -> i64 { 100 var m: i64 = v 101 var p: i64 = o 102 let t: *u8 = sys_mmap(28) 103 var k: i64 = 0 104 if m == 0 { t[0] = 48 as u8; k = 1 } 105 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 106 var i: i64 = 0 107 while i < k { out[p + i] = t[k - 1 - i]; i = i + 1 } 108 return p + k 109} 110 111// generic field read for any of our records; returns field length (0 = no record) 112func ef_field_at(prefix: *u8, key: *u8, f: i64, out: *u8) -> i64 { 113 let pq: *i64 = ef_pq() 114 let lq: *i64 = ef_lq() 115 if ss_get(prefix, key, pq, lq) != 1 { out[0] = 0 as u8; return 0 } 116 return fd_field(pq[0] as *u8, lq[0], f, out) 117} 118 119// ---- the skill tree: broad -> specific ---- 120 121func ef_skill_key(sid: *u8, out: *u8) -> i64 { 122 var o: i64 = as_append(out, 0, "meal:skill:" as *u8) 123 o = as_append(out, o, sid) 124 out[o] = 0 as u8 125 return o 126} 127 128// parent = "" (empty) for a root skill like "knife-skills". 129func ef_add_skill(prefix: *u8, sid: *u8, name: *u8, parent: *u8, about: *u8) -> i64 { 130 let key: *u8 = sys_mmap(EF_KEYCAP) 131 ef_skill_key(sid, key) 132 let val: *u8 = sys_mmap(EF_VALCAP) 133 var o: i64 = 0 134 o = as_append(val, o, name); val[o] = EF_TAB as u8; o = o + 1 135 o = as_append(val, o, parent); val[o] = EF_TAB as u8; o = o + 1 136 o = as_append(val, o, about) 137 val[o] = 0 as u8 138 let w: i64 = mp_put(prefix, key, val) 139 mp_list_add(prefix, "meal:skills" as *u8, sid) 140 return w 141} 142 143func ef_skill_field(prefix: *u8, sid: *u8, f: i64, out: *u8) -> i64 { 144 let key: *u8 = ef_keybuf() 145 ef_skill_key(sid, key) 146 return ef_field_at(prefix, key, f, out) 147} 148 149// Walk sid up to its root, writing "root > ... > sid" display names into out. Returns the depth walked. 150// Depth-capped so a malformed parent cycle can never spin -- a data error must not hang a page render. 151func ef_skill_path(prefix: *u8, sid: *u8, out: *u8) -> i64 { 152 let chain: *i64 = sys_mmap(8 * EF_MAXDEPTH) as *i64 153 var depth: i64 = 0 154 let cur: *u8 = sys_mmap(EF_KEYCAP) 155 var c: i64 = 0 156 while sid[c] != (0 as u8) { cur[c] = sid[c]; c = c + 1 } 157 cur[c] = 0 as u8 158 var go: i64 = 1 159 while go == 1 { 160 if depth >= EF_MAXDEPTH { go = 0 } else { 161 let nm: *u8 = sys_mmap(EF_VALCAP) 162 if ef_skill_field(prefix, cur, EF_S_NAME, nm) == 0 { go = 0 } else { 163 chain[depth] = nm as i64 164 depth = depth + 1 165 let par: *u8 = sys_mmap(EF_KEYCAP) 166 if ef_skill_field(prefix, cur, EF_S_PARENT, par) == 0 { go = 0 } else { 167 if ef_len(par) == 0 { go = 0 } else { 168 var k: i64 = 0 169 while par[k] != (0 as u8) { cur[k] = par[k]; k = k + 1 } 170 cur[k] = 0 as u8 171 } 172 } 173 } 174 } 175 } 176 var o: i64 = 0 177 var i: i64 = depth - 1 178 while i >= 0 { 179 if o > 0 { o = as_append(out, o, " > " as *u8) } 180 o = as_append(out, o, chain[i] as *u8) 181 i = i - 1 182 } 183 out[o] = 0 as u8 184 return depth 185} 186 187// ---- recipe steps, each naming the ONE skill it exercises ---- 188 189func ef_step_key(rid: *u8, idx: i64, out: *u8) -> i64 { 190 var o: i64 = as_append(out, 0, "meal:step:" as *u8) 191 o = as_append(out, o, rid) 192 out[o] = 58 as u8; o = o + 1 193 o = ef_num_key(out, o, idx) 194 out[o] = 0 as u8 195 return o 196} 197func ef_steps_key(rid: *u8, out: *u8) -> i64 { 198 var o: i64 = as_append(out, 0, "meal:steps:" as *u8) 199 o = as_append(out, o, rid) 200 out[o] = 0 as u8 201 return o 202} 203 204// base_seconds = how long this step takes a COMPETENT cook (the reference pace the multipliers scale from). 205func ef_add_step(prefix: *u8, rid: *u8, idx: i64, label: *u8, skill: *u8, base_seconds: i64) -> i64 { 206 let key: *u8 = sys_mmap(EF_KEYCAP) 207 ef_step_key(rid, idx, key) 208 let val: *u8 = sys_mmap(EF_VALCAP) 209 var o: i64 = 0 210 o = as_append(val, o, label); val[o] = EF_TAB as u8; o = o + 1 211 o = as_append(val, o, skill); val[o] = EF_TAB as u8; o = o + 1 212 o = fd_apnum(val, o, base_seconds) 213 val[o] = 0 as u8 214 let w: i64 = mp_put(prefix, key, val) 215 // keep the count at the high-water mark 216 let ck: *u8 = sys_mmap(EF_KEYCAP) 217 ef_steps_key(rid, ck) 218 let cnt: *u8 = sys_mmap(64) 219 var have: i64 = 0 220 if ef_field_at(prefix, ck, 0, cnt) > 0 { have = fd_atoi(cnt, ef_len(cnt)) } 221 if idx + 1 > have { 222 let nv: *u8 = sys_mmap(64) 223 let no: i64 = fd_apnum(nv, 0, idx + 1) 224 nv[no] = 0 as u8 225 mp_put(prefix, ck, nv) 226 } 227 return w 228} 229 230func ef_step_count(prefix: *u8, rid: *u8) -> i64 { 231 let ck: *u8 = ef_keybuf() 232 ef_steps_key(rid, ck) 233 let cnt: *u8 = sys_mmap(64) 234 if ef_field_at(prefix, ck, 0, cnt) == 0 { return 0 } 235 return fd_atoi(cnt, ef_len(cnt)) 236} 237 238func ef_step_field(prefix: *u8, rid: *u8, idx: i64, f: i64, out: *u8) -> i64 { 239 let key: *u8 = ef_keybuf() 240 ef_step_key(rid, idx, key) 241 return ef_field_at(prefix, key, f, out) 242} 243 244func ef_step_base(prefix: *u8, rid: *u8, idx: i64) -> i64 { 245 let b: *u8 = sys_mmap(64) 246 if ef_step_field(prefix, rid, idx, EF_P_SECONDS, b) == 0 { return EF_UNASSESSED } 247 return fd_atoi(b, ef_len(b)) 248} 249 250// ---- the person: assessed level, and what an hour of theirs is worth ---- 251 252func ef_level_key(unit: *u8, sid: *u8, out: *u8) -> i64 { 253 var o: i64 = as_append(out, 0, "meal:level:" as *u8) 254 o = as_append(out, o, unit) 255 out[o] = 58 as u8; o = o + 1 256 o = as_append(out, o, sid) 257 out[o] = 0 as u8 258 return o 259} 260 261func ef_set_level(prefix: *u8, unit: *u8, sid: *u8, level: i64) -> i64 { 262 let key: *u8 = sys_mmap(EF_KEYCAP) 263 ef_level_key(unit, sid, key) 264 let v: *u8 = sys_mmap(64) 265 let o: i64 = fd_apnum(v, 0, level) 266 v[o] = 0 as u8 267 return mp_put(prefix, key, v) 268} 269 270// ASSESSED level, or EF_UNASSESSED. There is deliberately no default: "we have not watched you chop" and 271// "you are a beginner" are different facts and must not collapse. 272func ef_level(prefix: *u8, unit: *u8, sid: *u8) -> i64 { 273 let key: *u8 = ef_keybuf() 274 ef_level_key(unit, sid, key) 275 let v: *u8 = sys_mmap(64) 276 if ef_field_at(prefix, key, 0, v) == 0 { return EF_UNASSESSED } 277 return fd_atoi(v, ef_len(v)) 278} 279 280func ef_set_tmul(prefix: *u8, level: i64, permille: i64) -> i64 { 281 let key: *u8 = sys_mmap(EF_KEYCAP) 282 var o: i64 = as_append(key, 0, "meal:tmul:" as *u8) 283 o = ef_num_key(key, o, level) 284 key[o] = 0 as u8 285 let v: *u8 = sys_mmap(64) 286 let vo: i64 = fd_apnum(v, 0, permille) 287 v[vo] = 0 as u8 288 return mp_put(prefix, key, v) 289} 290 291func ef_tmul(prefix: *u8, level: i64) -> i64 { 292 let key: *u8 = ef_keybuf() 293 var o: i64 = as_append(key, 0, "meal:tmul:" as *u8) 294 o = ef_num_key(key, o, level) 295 key[o] = 0 as u8 296 let v: *u8 = sys_mmap(64) 297 if ef_field_at(prefix, key, 0, v) == 0 { return 0 } 298 return fd_atoi(v, ef_len(v)) 299} 300 301func ef_set_timevalue(prefix: *u8, unit: *u8, cents_per_hour: i64) -> i64 { 302 let key: *u8 = sys_mmap(EF_KEYCAP) 303 var o: i64 = as_append(key, 0, "meal:timevalue:" as *u8) 304 o = as_append(key, o, unit) 305 key[o] = 0 as u8 306 let v: *u8 = sys_mmap(64) 307 let vo: i64 = fd_apnum(v, 0, cents_per_hour) 308 v[vo] = 0 as u8 309 return mp_put(prefix, key, v) 310} 311 312// cents per hour this household puts on its own time; 0 = NOT SET (never a guessed wage). 313func ef_timevalue(prefix: *u8, unit: *u8) -> i64 { 314 let key: *u8 = ef_keybuf() 315 var o: i64 = as_append(key, 0, "meal:timevalue:" as *u8) 316 o = as_append(key, o, unit) 317 key[o] = 0 as u8 318 let v: *u8 = sys_mmap(64) 319 if ef_field_at(prefix, key, 0, v) == 0 { return 0 } 320 return fd_atoi(v, ef_len(v)) 321} 322 323// ---- time, at THIS person's measured pace ---- 324 325// Seconds this step takes this unit. EF_UNASSESSED when the skill has never been measured OR the level has no 326// multiplier row -- an unknown pace is reported, never approximated. 327func ef_step_time(prefix: *u8, unit: *u8, rid: *u8, idx: i64) -> i64 { 328 let base: i64 = ef_step_base(prefix, rid, idx) 329 if base < 0 { return EF_UNASSESSED } 330 let sid: *u8 = sys_mmap(EF_KEYCAP) 331 if ef_step_field(prefix, rid, idx, EF_P_SKILL, sid) == 0 { return EF_UNASSESSED } 332 let lv: i64 = ef_level(prefix, unit, sid) 333 if lv == EF_UNASSESSED { return EF_UNASSESSED } 334 let mul: i64 = ef_tmul(prefix, lv) 335 if mul <= 0 { return EF_UNASSESSED } 336 return (base * mul) / EF_PERMILLE 337} 338 339// Total cook time. out[0] = seconds over the KNOWN steps, out[1] = how many steps were unknown, 340// out[2] = total step count. A caller must check out[1] before presenting out[0] as "the time". 341func ef_cook_time(prefix: *u8, unit: *u8, rid: *u8, out: *i64) -> i64 { 342 let n: i64 = ef_step_count(prefix, rid) 343 out[0] = 0 344 out[1] = 0 345 out[2] = n 346 var i: i64 = 0 347 while i < n { 348 let t: i64 = ef_step_time(prefix, unit, rid, i) 349 if t < 0 { out[1] = out[1] + 1 } else { out[0] = out[0] + t } 350 i = i + 1 351 } 352 return out[0] 353} 354 355// what those seconds are worth, in cents. A zero rate yields zero -- the caller must say "time not valued". 356func ef_time_cost(seconds: i64, cents_per_hour: i64) -> i64 { 357 if cents_per_hour <= 0 { return 0 } 358 if seconds <= 0 { return 0 } 359 return (seconds * cents_per_hour) / EF_SECS_PER_HOUR 360} 361 362// ---- the alternatives to cooking it ---- 363 364func ef_alt_key(rid: *u8, kind: *u8, out: *u8) -> i64 { 365 var o: i64 = as_append(out, 0, "meal:alt:" as *u8) 366 o = as_append(out, o, rid) 367 out[o] = 58 as u8; o = o + 1 368 o = as_append(out, o, kind) 369 out[o] = 0 as u8 370 return o 371} 372 373func ef_add_alt(prefix: *u8, rid: *u8, kind: *u8, price_cents: i64, minutes: i64, source: *u8, date: *u8) -> i64 { 374 let key: *u8 = sys_mmap(EF_KEYCAP) 375 ef_alt_key(rid, kind, key) 376 let val: *u8 = sys_mmap(EF_VALCAP) 377 var o: i64 = 0 378 o = fd_apnum(val, o, price_cents); val[o] = EF_TAB as u8; o = o + 1 379 o = fd_apnum(val, o, minutes); val[o] = EF_TAB as u8; o = o + 1 380 o = as_append(val, o, source); val[o] = EF_TAB as u8; o = o + 1 381 o = as_append(val, o, date) 382 val[o] = 0 as u8 383 return mp_put(prefix, key, val) 384} 385 386func ef_alt_field(prefix: *u8, rid: *u8, kind: *u8, f: i64, out: *u8) -> i64 { 387 let key: *u8 = ef_keybuf() 388 ef_alt_key(rid, kind, key) 389 return ef_field_at(prefix, key, f, out) 390} 391 392func ef_alt_num(prefix: *u8, rid: *u8, kind: *u8, f: i64) -> i64 { 393 let b: *u8 = sys_mmap(64) 394 if ef_alt_field(prefix, rid, kind, f, b) == 0 { return EF_UNASSESSED } 395 return fd_atoi(b, ef_len(b)) 396} 397 398// ---- the comparison ---- 399 400// Fill one option row. known=1 only when BOTH money and time are real numbers. 401func ef_opt(out: *i64, slot: i64, kind: *u8, money: i64, seconds: i64, rate: i64) -> i64 { 402 let b: i64 = slot * EF_O_SLOTS 403 out[b + EF_O_KIND] = kind as i64 404 out[b + EF_O_MONEY] = money 405 out[b + EF_O_SECONDS] = seconds 406 var known: i64 = 1 407 if money < 0 { known = 0 } 408 if seconds < 0 { known = 0 } 409 out[b + EF_O_KNOWN] = known 410 if known == 1 { 411 let tc: i64 = ef_time_cost(seconds, rate) 412 out[b + EF_O_TIMECOST] = tc 413 out[b + EF_O_TOTAL] = money + tc 414 } else { 415 out[b + EF_O_TIMECOST] = 0 416 out[b + EF_O_TOTAL] = 0 417 } 418 return known 419} 420 421// Build the full comparison for one dish and one unit. 422// scratch_money = the shopping total from nx_meal_cost (cents) 423// scratch_complete = mc_total_is_complete: 0 means that money figure is itself partial 424// Writes up to `max` option rows. Returns the number written. 425// The SCRATCH row is unknown if either its money is partial or any step's pace is unmeasured -- both are ways 426// of not knowing what cooking it would really take. 427func ef_compare(prefix: *u8, unit: *u8, rid: *u8, scratch_money: i64, scratch_complete: i64, out: *i64, max: i64) -> i64 { 428 let rate: i64 = ef_timevalue(prefix, unit) 429 let tm: *i64 = sys_mmap(8 * 4) as *i64 430 ef_cook_time(prefix, unit, rid, tm) 431 var scratch_secs: i64 = tm[0] 432 if tm[1] > 0 { scratch_secs = EF_UNASSESSED } 433 var scratch_cash: i64 = scratch_money 434 if scratch_complete == 0 { scratch_cash = EF_UNASSESSED } 435 436 var n: i64 = 0 437 if n < max { ef_opt(out, n, "scratch" as *u8, scratch_cash, scratch_secs, rate); n = n + 1 } 438 439 let kinds: *i64 = sys_mmap(8 * 8) as *i64 440 kinds[0] = "prepped" as *u8 as i64 441 kinds[1] = "frozen" as *u8 as i64 442 kinds[2] = "restaurant" as *u8 as i64 443 var i: i64 = 0 444 while i < 3 { 445 let k: *u8 = kinds[i] as *u8 446 let p: i64 = ef_alt_num(prefix, rid, k, EF_A_PRICE) 447 if p >= 0 { 448 let mins: i64 = ef_alt_num(prefix, rid, k, EF_A_MINUTES) 449 var secs: i64 = EF_UNASSESSED 450 if mins >= 0 { secs = mins * 60 } 451 if n < max { ef_opt(out, n, k, p, secs, rate); n = n + 1 } 452 } 453 i = i + 1 454 } 455 return n 456} 457 458// Index of the cheapest FULLY-KNOWN option by total cost, or -1 when nothing is fully known. 459// Options missing money or time are never ranked -- being cheap on the half we measured is not being cheap. 460func ef_best(out: *i64, n: i64) -> i64 { 461 var best: i64 = 0 - 1 462 var bv: i64 = 0 463 var i: i64 = 0 464 while i < n { 465 let b: i64 = i * EF_O_SLOTS 466 if out[b + EF_O_KNOWN] == 1 { 467 let t: i64 = out[b + EF_O_TOTAL] 468 var take: i64 = 0 469 if best < 0 { take = 1 } else { if t < bv { take = 1 } } 470 if take == 1 { best = i; bv = t } 471 } 472 i = i + 1 473 } 474 return best 475} 476 477// How many of the compared options could not be ranked -- the number a surface must disclose. 478func ef_unranked(out: *i64, n: i64) -> i64 { 479 var c: i64 = 0 480 var i: i64 = 0 481 while i < n { if out[i * EF_O_SLOTS + EF_O_KNOWN] == 0 { c = c + 1 } i = i + 1 } 482 return c 483} 484 485// TRUE only when an hour of this household's time has actually been priced. A surface that ranks by TOTAL cost 486// while this is 0 is ranking on money alone and must say so rather than implying it weighed the evening. 487func ef_time_is_valued(prefix: *u8, unit: *u8) -> i64 { 488 if ef_timevalue(prefix, unit) > 0 { return 1 } 489 return 0 490}