Relax protein to 1.7 g/kg; add rational-portion guard + weekly protein floor

- diet.protein_per_kg 2 -> 1.7 g/kg (evidence ceiling for trained/resistance-adapted adults)
  daily target 190-200 -> 165-175 g/day, ~80-90 g/meal
- weekly non-whey protein floor 1330 -> 1190 g (170 g/day x 7), still excl whey
- SKILL.md: RATIONAL PORTIONS hard rule (per-item max_per_meal caps, no single-ingredient
  overload, honest shortfall reporting); WEEKLY PROTEIN FLOOR (>=3 sources, none >50%)
- inventory.json: per-item max_per_meal caps + rational_portions defaults table
- scripts/verify_portions.py: regression probe for overload/cap violations
- README + references synced to 1.7 g/kg; .idea/.agentbridge gitignored
This commit is contained in:
hermes
2026-08-29 07:50:16 +01:00
parent fb80f6a3dd
commit 33b7e17ef9
6 changed files with 229 additions and 16 deletions

View File

@ -46,6 +46,35 @@ Schema:
- Suggest **two meals** per day (e.g. lunch + dinner) using only items currently in the inventory.
- **Maximize protein** per meal; lead with a protein source.
- **EGG LIMIT (hard rule):** Eggs are capped at **4 per meal** and **only ONE of the two daily meals may contain eggs** — the other meal MUST be egg-free. Never suggest more than 4 eggs in a single meal, and never put eggs in both meals. If a meal needs more protein than 4 eggs supply, close the gap with other proteins (lentils, beans, halloumi, cheese, tinned fish, whey) or a whey shake — do NOT add a 5th+ egg. This overrides the "lead with eggs" preference below.
- **MEAL VARIETY (rule):** the two daily meals (lunch + dinner) should use **disjoint
ingredient sets** — avoid repeating the same items across both meals. Build each meal
from a different subset of the inventory so the day isn't the same plate twice. Only
reuse an ingredient if stock genuinely forces it, and keep any reuse to the minimum.
This often means leading one meal with a non-red-meat protein (lentils/beans/eggs/whey/
cheese) and the other with a different protein (e.g. in-stock red meat being used up,
or eggs if the first meal was egg-free). Honour this alongside the EGG LIMIT and the
STRICT inventory rule.
Do NOT inflate a single ingredient's quantity to an unrealistic amount just to avoid
overlap (e.g. 2 cans of baked beans in one meal). If a fully-disjoint meal can't reach
the protein target, close the gap with a whey shake / extra eggs (third hit) rather than
unrealistic quantities.
- **RATIONAL PORTIONS (hard rule):** every meal must be a *realistic, balanced, interesting*
plate — NOT overloaded with one ingredient. Each protein item has a `max_per_meal` cap in
`inventory.json` (expressed in that item's own unit, e.g. Eggs 4, Cumberland sausages 6,
Cheese 0.5 "small block" ≈ 60 g, Lamb mince 200 g, Lentils 150 g, Whey 50 g, Baked beans 1 can).
The daily job MUST NOT exceed a cap in any single meal. If an item has no `max_per_meal`,
fall back to `diet.rational_portions.default_caps` (keyword match) or `unit_fallback`
(count→6, g→200, can→1). `scripts/verify_portions.py` converts caps and logged amounts to
grams so a cap stored as "small block" is compared correctly against e.g. cheese logged in grams.
OVERLOAD test (what makes a meal "silly"): ONE protein at >=70% of its cap AND supplying
>=85% of that meal's protein — e.g. a bowl of 10 sausages. A balanced plate where the lead
protein is capped but other proteins + veg contribute is NOT overload (a normal 4-egg meal
passes). So spread across 3+ different proteins (plus veg, grain, dairy) so the plate is varied.
**If capped + balanced meals still can't reach ~165 g/day with current stock, DO NOT fake it**
— produce the best balanced meal you can, state the realistic total, and list the top 23
things to buy (eggs, whey, chicken, tuna) to close the gap. Never suggest "10 sausages" or any
single-ingredient overload. This rule overrides "maximize protein" — balance and realism beat a number.
- **Avoid red meat for PURCHASES** (beef, lamb, pork, venison, bacon, gammon) — the
weekly shopping list must NEVER suggest red meat to buy.
- **If red meat is already in the inventory, it stays usable** in daily meal
@ -55,10 +84,11 @@ Schema:
tempeh, lentils, beans, chickpeas, Greek yogurt, cottage cheese, quark, skim milk,
whey, tinned tuna/salmon, edamame, halloumi.
(Eggs are subject to the EGG LIMIT hard rule above: max 4 per meal, only one meal/day may include them.)
- **Protein target:** ~**190200 g/day** (user ~95100 kg × 2 g/kg — burns recovery +
preserving lean mass while losing ~15 kg fat). Aim each meal at **~90100 g protein**
- **Protein target:** ~**165175 g/day** (user ~95100 kg × 1.7 g/kg — evidence ceiling for
trained/resistance-adapted adults; burns recovery + preserving lean mass while losing ~15 kg
fat). Aim each meal at **~8090 g protein**
and **STATE the per-meal and daily protein totals** in the email. If 2 meals can't
reach ~190 g with current stock, say so and suggest a third hit (whey shake, extra
reach ~165 g with current stock, say so and suggest a third hit (whey shake, extra
eggs, Greek yogurt) to close the gap.
- **Weight-loss framing:** keep meals calorie-moderate — bulk with veg, go easy on oven
chips, white bread, and oil; lead with lean protein. Fat loss comes from a mild calorie
@ -110,6 +140,15 @@ To keep shops and meals varied over time, each week's list is themed around a
**randomly chosen world cuisine**. This is driven by
`scripts/pick_cuisine.py` (same skill dir).
**Theme start rule (IMPORTANT):** the cuisine is picked by the **Monday weekly job**
and it themes that week's **shop basket**, but it applies to **MEALS from TUESDAY
onward**. Monday's daily meal suggestion is cooked **UNTHEMED** from whatever is
currently in stock (the shop items aren't in inventory yet). From Tuesday the daily
meal job cooks **on-theme**. If `cuisine-rotation.json` has no `current` cuisine set
(e.g. cleared between weeks, or the rest of a week with no active theme), meals are
unthemed. This keeps a fresh shop's new ingredients aligned with themed meals without
forcing a theme onto the day the shop is only proposed.
- Run `python3 scripts/pick_cuisine.py` at the **start of the weekly job**. It
picks a cuisine seeded by the week's Monday, so it is **stable within a week**
(re-runs/retries never flip the cuisine mid-week) but **varies week to week**
@ -125,12 +164,23 @@ To keep shops and meals varied over time, each week's list is themed around a
items that are just flavour (spices, sauces, lime, herbs) are the optional
"interest" buys — keep them low-price and behind protein + veg + staples.
- Persist the active cuisine so the **daily meal job can read `cuisine-rotation.json`
and on-theme meals from the same week's buys** (e.g. build a Korean bowl, a
Mexican bowl, etc., from stock). The daily job should still respect the STRICT
inventory rule — only cook with what's actually in inventory.json.
and cook on-theme meals from the same week's buys (TUESDAYSUNDAY only; Monday is
unthemed)** (e.g. build a Korean bowl, a Mexican bowl, etc., from stock). The daily
job should still respect the STRICT inventory rule — only cook with what's actually
in inventory.json.
- The rotation file keeps a short history; no manual tracking needed.
## Weekly shopping list (Mondays)
- **WEEKLY PROTEIN FLOOR (hard rule):** the non-whey protein bought in the Monday shop MUST be
enough to cover the week. Required = 170 g/day × 7 = **1190 g** of **non-whey** protein
(whey is excluded — it is a third-hit top-up, not a meal base). Estimate the non-whey protein
ALREADY in `inventory.json` (sum each protein/cheese/dairy item's qty × its per-g from
`references/protein-sources.md`). The shop's bought non-whey protein must total at least
`MAX(0, 1190 in_stock)` g. **SPREAD it across ≥3 distinct protein sources** — no single
source may exceed ~50% of the bought total (never "20 eggs only", never one tub). If £30 can't
reach the floor, get as close as budget allows and STATE the shortfall + what to buy next.
Record `bought_protein_g_excl_whey` and `stock_protein_g_excl_whey` on `last-shop.json` and
show both in the email so weekly coverage is visible.
- Budget **£30** total. Use realistic UK supermarket prices (Tesco/Asda/Sainsbury's).
- **Pick this week's cuisine first** (see "Weekly cuisine rotation" above) and
theme the basket around it — varied ingredients keep meals interesting.
@ -300,6 +350,20 @@ Note: this is entirely on-demand — the weekly job itself never writes inventor
EGG LIMIT prose rule above, (b) `diet.egg_rules` in inventory.json, and (c) running
`scripts/verify_eggs.py` after any history/inventory change or before a resend.
Always run that probe before sending a regenerated day.
- **Don't auto-resend after a stock update.** When the user reports actual consumption or you
correct `inventory.json` mid-day, update the inventory and STOP — do NOT volunteer a fresh
meal email. The next scheduled 09:00 cron picks up the new stock automatically. Only
regenerate + resend when the user explicitly asks (usually because they flagged a
meal-QUALITY issue, e.g. unrealistic portions — "2 cans of baked beans is silly, be
realistic"). They said plainly "I will wait for the cron job to run." Distinguish: a
consumption report = update stock, no email; a meal-quality complaint = regenerate fresh.
- **Portion probe scope + threshold (avoid false positives).** `verify_portions.py` defaults to
TODAY only — run it that way before a send. Its OVERLOAD flag requires BOTH >=70% of one
protein's cap AND >=85% of that meal's protein; a capped lead protein with real supporting
proteins (e.g. a normal 4-egg meal) must NOT trip it. If you widen the threshold later you'll
start flagging legitimate balanced plates — keep the >=85% dominance condition. `--all` is an
audit of history, not a send gate; historical over-cap rows (old halloumi quantities) are
already-sent and only fixed on explicit request.
## Automation
Two cron jobs (created via the `cronjob` tool), both loading this skill:
@ -316,6 +380,16 @@ Delivery is `local` because the email itself is the deliverable; check
- `scripts/verify_eggs.py` — regression probe for the EGG LIMIT. Run after any change to
`meal-history.json`, `inventory.json`, or the daily job, and before sending a
regenerated day's email. Exits non-zero on violation. See "Pitfalls".
- `scripts/verify_portions.py` — regression probe for RATIONAL PORTIONS. By DEFAULT checks
only TODAY's entries in `meal-history.json` (the meals the daily job just wrote, before
sending). It flags (a) any protein used above its `max_per_meal` (or fallback) cap, and
(b) single-ingredient OVERLOAD — one protein at >=70% of its cap that ALSO supplies >=85%
of the meal's protein (a "pile of one thing", e.g. 10 sausages). Caps are stored in each
item's own unit (e.g. Cheese 0.5 "small block"); the probe converts to grams so it can
compare against logged grams (e.g. 40 g cheese). Run it alongside `verify_eggs.py` BEFORE
sending a regenerated day. Pass `--all` to audit the full 7-day window (this WILL flag
ALREADY-SENT historical rows like old 100200 g halloumi — audit only, NOT a send gate;
only fix history on explicit user request). Exits non-zero on violation.
## References
- `references/protein-sources.md` — allowed proteins + example pairings.