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}