Files
hermes-meal-suggestion/scripts/verify_portions.py
hermes 33b7e17ef9 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
2026-08-29 07:50:16 +01:00

135 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""
Regression guard for RATIONAL PORTIONS.
Checks TODAY's meals in meal-history.json (override with --all for the 7-day window)
for:
1. CAP EXCEEDED — a protein item used in a single meal above its inventory
`max_per_meal`, or above the fallback cap when no explicit cap exists.
2. OVERLOAD — a single protein at >=70% of its cap that ALSO supplies >=85% of the
meal's protein (a "pile of one ingredient", e.g. 10 sausages). A balanced plate
where the lead protein is capped but other proteins/veg contribute is NOT flagged.
Units are normalised to grams so items logged in different units (cheese in g vs
inventory's "small block") compare correctly.
Exits non-zero (prints violations) if any rule is broken.
"""
import json, sys, os, argparse
from datetime import datetime, timedelta
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HISTORY = os.path.join(SKILL_DIR, "meal-history.json")
INV = os.path.join(SKILL_DIR, "inventory.json")
# protein (g) per unit, for the dominance estimate
PROTEIN_G = {
"egg": 6, "sausage": 11, "mince": 0.18, "lentil": 0.24, "tofu": 0.12,
"tempeh": 0.19, "bean": 9, "whey": 0.8, "cheese": 0.25, "halloumi": 0.21,
"tuna": 25, "salmon": 25, "mackerel": 20, "chicken": 0.31, "turkey": 0.29,
"prawn": 0.24, "yogurt": 0.10, "yoghurt": 0.10, "fish": 25,
}
# grams per inventory unit, for converting a stored cap to grams
GRAMS_PER_UNIT = {
"g": 1, "ml": 1, "kg": 1000, "count": 1, "can": 1,
"small block": 120, "block": 120, "serving": 1, "portion": 1,
"pack": 1, "jar": 1, "tub": 1, "bulb": 1, "bottle": 1, "loaf": 1,
}
PROTEIN_CATS = {"protein", "dairy"}
def cap_grams(name, unit, inv):
it = next((i for i in inv["items"] if i["name"].lower() == name.lower()), None)
if it and "max_per_meal" in it:
gpu = GRAMS_PER_UNIT.get(it.get("unit", unit), 1)
return it["max_per_meal"] * gpu
rp = inv.get("diet", {}).get("rational_portions", {})
defaults = rp.get("default_caps", {})
for kw, cap in defaults.items():
if kw in name.lower():
gpu = GRAMS_PER_UNIT.get(rp.get("unit_fallback_unit", unit), 1)
return cap * gpu
fb = rp.get("unit_fallback", {}).get(unit, 6)
return fb * GRAMS_PER_UNIT.get(unit, 1)
def est_protein_g(name, qty, unit):
per = PROTEIN_G.get(name.lower())
if per is None:
for kw, v in PROTEIN_G.items():
if kw in name.lower():
per = v
break
if per is None:
return 0.0
gpu = GRAMS_PER_UNIT.get(unit, 1)
base = qty * gpu # grams of food
# per is g protein per g of food for g/ml units; per-unit for count/can
if unit in ("g", "ml", "kg"):
return per * base
return per * qty
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--all", action="store_true", help="check full 7-day window")
args = ap.parse_args()
inv = json.load(open(INV))
hist = json.load(open(HISTORY))
entries = hist if isinstance(hist, list) else hist.get("entries", [])
today = datetime.now().strftime("%Y-%m-%d")
cutoff = datetime.now() - timedelta(days=7)
violations = []
for e in entries:
try:
d = datetime.strptime(e["date"], "%Y-%m-%d")
except Exception:
continue
if args.all:
if d < cutoff:
continue
else:
if e["date"] != today:
continue
meal_items = e.get("items", [])
# 1) cap check
for it in meal_items:
cap = cap_grams(it["name"], it.get("unit", "count"), inv)
used = est_protein_g(it["name"], it["qty"], it.get("unit", "count"))
# compare the RAW quantity against the cap's raw quantity (both in same unit)
cap_raw = cap / GRAMS_PER_UNIT.get(it.get("unit", "count"), 1) if it.get("unit") in ("g", "ml", "kg") else cap
# simpler: compare grams of food used vs grams of cap
food_g = it["qty"] * GRAMS_PER_UNIT.get(it.get("unit", "count"), 1)
cap_food_g = cap
if food_g > cap_food_g + 1e-6:
violations.append(
f"CAP EXCEEDED {e['date']} {e['meal']} '{e['name']}': "
f"{it['name']} {it['qty']} {it.get('unit','')} exceeds cap "
f"~{cap_food_g:.0f} g ({it.get('unit','')})")
# 2) overload check (single protein >=70% cap AND >=85% of meal protein)
prot = [(it, cap_grams(it["name"], it.get("unit", "count"), inv),
est_protein_g(it["name"], it["qty"], it.get("unit", "count")))
for it in meal_items]
total = sum(p for _, _, p in prot) or 1
for it, cap, pg in prot:
food_g = it["qty"] * GRAMS_PER_UNIT.get(it.get("unit", "count"), 1)
if cap and food_g > 0.7 * cap and pg > 0.85 * total:
violations.append(
f"OVERLOAD {e['date']} {e['meal']} '{e['name']}': "
f"{it['name']} {it['qty']} is >=70% of its cap and supplies "
f"{pg:.0f}/{total:.0f} g ({pg/total*100:.0f}%) of meal protein "
f"— not a balanced plate")
if violations:
print("FAIL: rational-portion violations:")
for v in violations:
print(" -", v)
sys.exit(1)
scope = "all logged meals (7d)" if args.all else f"today ({today})"
print(f"PASS: portions rational — no overload ({scope}).")
if __name__ == "__main__":
main()