diff --git a/Agent/ballmer/.gitignore b/Agent/ballmer/.gitignore new file mode 100644 index 0000000..019a558 --- /dev/null +++ b/Agent/ballmer/.gitignore @@ -0,0 +1,9 @@ +# Python cache / build +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# Generated always-on session transcripts (the agent's runtime output) +state/log/* +!state/log/.gitkeep diff --git a/Agent/ballmer/README.md b/Agent/ballmer/README.md new file mode 100644 index 0000000..b510291 --- /dev/null +++ b/Agent/ballmer/README.md @@ -0,0 +1,108 @@ +# πΈ Ballmer β an always-on agent for the (joke) Ballmer Peak + +Ballmer sits with you at the bar, recomputes your blood-alcohol concentration as +the night goes on, and recommends what to order next to **reach and hold** the +XKCD #323 "Ballmer Peak" band. The framing is a comic gag; the engine underneath +is a real, auditable Widmark/Watson pharmacokinetic model. + +> ## β Read this first +> The target band (**0.129%β0.138% BAC**) is a **joke from [XKCD #323](https://xkcd.com/323/)**. +> It is **far above every legal driving limit** and is **not** a health, safety, +> or performance recommendation. Real BAC in this range means serious impairment. +> Ballmer **refuses anything tied to driving or machinery** and stops recommending +> alcohol past a safety ceiling. This is a modeling demo, nothing more. + +## Built on the "always-on" construct + +This reuses the pattern from the Always-On Ops Agent exercise: a scheduled agent +that wakes on a tick, reads repo **state**, reasons against its **runbook/policy**, +and writes an **action**. The mapping: + +| Always-On Ops | Ballmer | +|---|---| +| `issues/*.json` (live state) | `state/tab.json` β drinks consumed, timestamped | +| `deploys/recent.json` (what to act on) | `drink-library.json` β the menu | +| `runbooks/*.md` (reference) | `reference/model-assumptions.md` β constants + sources | +| `compliance-policy.md` (rules) | `reference/safety-policy.md` β band caveat, ceiling, no-driving | +| routine tick β triage issue | tick β recompute BAC β recommend next drink | +| agent comments on issue | agent writes to `state/log/` | + +The v1 demo runs the tick loop over **fast-forwarded simulated time** (a whole +bar night in one hands-free run) β no cloud, no API key. A real cloud Routine +would call the same `tick()` on a wall-clock schedule. + +## Run it + +```bash +cd ballmer + +# 1) Engine unit tests (hand-calculated Widmark/Watson checks) +python -m pytest -q # or: python tests/test_bac_model.py + +# 2) Hands-free terminal demo (the full flow + ASCII dashboard) +python run_demo.py +python run_demo.py --no-color --empty-stomach + +# 3) Live LOCAL web dashboard (standard library only β no pip installs) +python serve_dashboard.py # opens http://127.0.0.1:8765 +python serve_dashboard.py --speed 1.5 --port 9000 +``` + +The web dashboard shows, live as the simulated night plays out: an **IN RANGE / +BELOW / ABOVE / PAST CEILING** indicator, current BAC, a gauge with the band and +ceiling marked, the BAC curve with the target band shaded and drink events +marked, and the **burndown** β a projected decline line + ETA for when you'd drop +out of the range with no further drinks. Everything is served from localhost. + +## Architecture (model / data / interaction are separable) + +``` +ballmer/ +βββ ballmer/ +β βββ bac_model.py # PURE engine: Widmark + Watson TBW + 1st-order absorption +β βββ config.py # every constant, with source + plausible range +β βββ drinks.py # recipe-level ethanol math + Drink/Ingredient model +β βββ recommend.py # dwell+overshoot scoring, safety soft-nudge +β βββ agent.py # always-on tick loop + state I/O + burndown +β βββ dashboard.py # zero-dep terminal dashboard +β βββ web_data.py # SessionRecord -> JSON frames (pure transform) +β βββ stubs.py # clarifying-question STUBS (pour size, food, etc.) +βββ state/ # the always-on state repo (profile, tab, target, log/) +βββ reference/ # the agent's runbook + safety policy +βββ web/dashboard.html # vanilla-JS canvas UI (no external libraries) +βββ drink-library.json # the menu (~20 drinks, recipe-defined) +βββ run_demo.py # hands-free terminal demo +βββ serve_dashboard.py # stdlib http.server live web dashboard +βββ tests/test_bac_model.py +``` + +The BAC engine is pure and unit-tested; swap it without touching the library or +UI. The drink library is just JSON β add a drink by appending one object. + +## The model (see `reference/model-assumptions.md` for full sources) + +- **Widmark** core: `BAC% = A / (r Β· W_kg Β· 10)`, A grams ethanol, Ξ²=0.015 %/hr + zero-order elimination (range 0.012β0.020). +- **Watson (1980) TBW β r**, with the **blood-water correction**: + `r = TBW / (0.806 Β· weight_kg)`. For the demo profile (73in/220lb/39/M) this + gives r β **0.649** (vs flat Widmark 0.68 β a heavier build has more fat, less + water, so a higher peak). A naive `r = TBW/weight` gives ~0.52 and is wrong β + see the note below. +- **First-order absorption** (k_a, default 2.5/hr, food-modified). Flagged as the + weakest part of any Widmark-family model. +- **Scoring** (chosen): dwell minutes in-band β penaltyΒ·overshoot, plus a climb + gradient so the agent can build the multi-drink staircase to the band. +- **Safety** (chosen): **soft nudge** past the ceiling β recommends a + non-alcoholic option and keeps monitoring. + +### Note: a deliberate deviation from the original spec +The brief specified `r = TBW_liters / weight_kg`, but that yields r β 0.52 for the +demo profile β which **contradicts the brief's own stated anchors** (it expected +r β 0.64β0.66 and a ~0.02% peak for one drink). Both anchors require the +blood-water correction `r = TBW / (0.806Β·weight_kg)` (Widmark's r is referenced +to blood; blood is ~80.6% water). We implemented the corrected form so the model +hits the brief's numbers; the rationale is documented in `config.py` and +`bac_model.py`. + +> **`# TODO: validate against published BAC time-course data.`** "The demo runs" +> is not "the model is right." The faked tab proves the wiring, not the accuracy. diff --git a/Agent/ballmer/ballmer/__init__.py b/Agent/ballmer/ballmer/__init__.py new file mode 100644 index 0000000..1135917 --- /dev/null +++ b/Agent/ballmer/ballmer/__init__.py @@ -0,0 +1,9 @@ +"""Ballmer β an always-on agent that recommends your next drink to reach and +hold the (joke) XKCD #323 Ballmer Peak, backed by a real Widmark/Watson BAC model. + +See README.md and reference/safety-policy.md. The target band is a comic-strip +gag, well above every legal driving limit, and is NOT a health or safety +recommendation. +""" + +__all__ = ["bac_model", "drinks", "recommend", "agent", "config", "stubs"] diff --git a/Agent/ballmer/ballmer/agent.py b/Agent/ballmer/ballmer/agent.py new file mode 100644 index 0000000..148bd52 --- /dev/null +++ b/Agent/ballmer/ballmer/agent.py @@ -0,0 +1,234 @@ +"""The always-on agent: tick -> read state -> recompute BAC -> recommend -> log. + +This mirrors the Always-On Ops exercise's construct (a routine wakes the agent, +it reads repo state, reasons against its runbook/policy, and writes an action). +Here the "tick" runs over FAST-FORWARDED simulated time so a whole bar night +plays out in one hands-free run. + +State repo: + state/profile.json body metrics (read) + state/tab.json drinks consumed so far (read; live state) + state/target.json window + ceiling (read) + drink-library.json the menu (read) + reference/*.md assumptions + policy (the agent's "runbook") + state/log/ recommendations + transcript (WRITE β the agent's action) + +The seed tab.json is treated read-only for demo repeatability; the evolving +session is written to state/log/. A real cloud Routine would commit back to +tab.json each tick instead. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +from . import config +from .bac_model import BodyParams, DrinkEvent, bac_at, bac_curve, peak_bac +from .drinks import Drink, find_drink, load_library +from .recommend import ORDER, Recommendation, recommend_next + + +# -------------------------------------------------------------------------- +# State loading +# -------------------------------------------------------------------------- +@dataclass +class State: + profile: dict + body: BodyParams + events: list[DrinkEvent] + library: list[Drink] + session_start: datetime + window: tuple[float, float] + ceiling: float + tick_interval_min: float + session_duration_hours: float + + +def _hours_since(start: datetime, ts: str) -> float: + return (datetime.fromisoformat(ts) - start).total_seconds() / 3600.0 + + +def load_state(root: str | Path = ".") -> State: + root = Path(root) + profile = json.loads((root / "state/profile.json").read_text(encoding="utf-8")) + tab = json.loads((root / "state/tab.json").read_text(encoding="utf-8")) + target = json.loads((root / "state/target.json").read_text(encoding="utf-8")) + library = load_library(root / "drink-library.json") + + session_start = datetime.fromisoformat(tab["session_start"]) + events: list[DrinkEvent] = [] + for item in tab["consumed"]: + drink = find_drink(library, item["name"]) + if drink is None: + raise ValueError(f"tab references unknown drink {item['name']!r}") + events.append(DrinkEvent( + t_hours=_hours_since(session_start, item["time"]), + grams=drink.total_ethanol_g, + label=drink.name, + food_state=item.get("food_state", config.DEFAULT_FOOD_STATE), + )) + + return State( + profile=profile, + body=BodyParams.from_profile(profile), + events=events, + library=library, + session_start=session_start, + window=(target["target_low"], target["target_high"]), + ceiling=target["safety_ceiling"], + tick_interval_min=target.get("tick_interval_min", 20), + session_duration_hours=target.get("session_duration_hours", 5.0), + ) + + +# -------------------------------------------------------------------------- +# Burndown: when do we leave the target window? +# -------------------------------------------------------------------------- +def time_to_leave_window(events: list[DrinkEvent], body: BodyParams, + now_hours: float, window: tuple[float, float], + current_bac: float | None = None, + t_search: float = 12.0) -> float | None: + """Hours until BAC declines below the BOTTOM of the window, assuming NO more + drinks. + + Uses ODE integration (not the naive linear formula) so residual absorption + from drinks still being processed is accounted for β BAC may continue rising + for a bit before it declines, which the linear approximation misses entirely. + + Returns None if already below the window (nothing to burn down). + Returns t_search if BAC never drops below low within the search window. + """ + low, _ = window + if current_bac is None: + current_bac = bac_at(events, body, now_hours) + if current_bac <= low: + return None + times, bac = bac_curve(events, body, t_end=now_hours + t_search, + t_start=now_hours) + for t, b in zip(times, bac): + if b <= low: + return round(t - now_hours, 4) + return round(t_search, 4) + + +# -------------------------------------------------------------------------- +# Tick + session loop +# -------------------------------------------------------------------------- +@dataclass +class TickRecord: + now_hours: float + clock: str # human wall-clock label + current_bac: float + status: str + action: str + drink_name: str | None + recommendation: Recommendation + burndown_hours: float | None # time-to-leave-window (None if below) + + +@dataclass +class SessionRecord: + state: State + ticks: list[TickRecord] = field(default_factory=list) + curve_times: list[float] = field(default_factory=list) + curve_bac: list[float] = field(default_factory=list) + final_events: list[DrinkEvent] = field(default_factory=list) + + +def _clock(start: datetime, now_hours: float) -> str: + from datetime import timedelta + return (start + timedelta(hours=now_hours)).strftime("%I:%M %p").lstrip("0") + + +def tick(events: list[DrinkEvent], st: State, now_hours: float, + food_state: str = config.DEFAULT_FOOD_STATE) -> TickRecord: + """One wake-up: assess current BAC, produce a recommendation, package it.""" + rec = recommend_next(events, st.body, st.library, now_hours, + window=st.window, ceiling=st.ceiling, food_state=food_state) + burndown = time_to_leave_window(events, st.body, now_hours, st.window, + current_bac=rec.current_bac) + return TickRecord( + now_hours=now_hours, + clock=_clock(st.session_start, now_hours), + current_bac=rec.current_bac, + status=rec.status, + action=rec.action, + drink_name=rec.drink.name if rec.drink else None, + recommendation=rec, + burndown_hours=burndown, + ) + + +def run_session(root: str | Path = ".", auto_consume: bool = True, + food_state: str = config.DEFAULT_FOOD_STATE, + write_log: bool = True, + start_time_str: str | None = None, + profile_overrides: dict | None = None) -> SessionRecord: + """Fast-forward the whole night, ticking every tick_interval_min. + + auto_consume: if True, when the agent recommends ORDER the simulated drinker + follows it β the drink is added to the event list at that tick (STUB: + confirm_order). This is what makes the BAC curve climb into the band hands- + free and demonstrates the agent steering the night. + """ + st = load_state(root) + if start_time_str: + h, m = map(int, start_time_str.split(":")) + st.session_start = st.session_start.replace(hour=h, minute=m, second=0) + if profile_overrides: + st.profile = {**st.profile, **profile_overrides} + st.body = BodyParams.from_profile(st.profile) + events = list(st.events) + + dt_h = st.tick_interval_min / 60.0 + # Start ticking after the last seeded drink (or now), through the session end. + t = max((e.t_hours for e in events), default=0.0) + t_end = st.session_duration_hours + + session = SessionRecord(state=st) + while t <= t_end + 1e-9: + rec_tick = tick(events, st, t, food_state=food_state) + session.ticks.append(rec_tick) + if auto_consume and rec_tick.action == ORDER and rec_tick.recommendation.drink: + d = rec_tick.recommendation.drink + events.append(DrinkEvent(t_hours=t, grams=d.total_ethanol_g, + label=d.name, food_state=food_state)) + t += dt_h + + # Full curve over the night for the dashboard. + times, bac = bac_curve(events, st.body, t_end=t_end + 1.0, t_start=0.0) + session.curve_times = times + session.curve_bac = bac + session.final_events = events + + if write_log: + _write_log(root, session) + return session + + +def _write_log(root: str | Path, session: SessionRecord) -> Path: + """Append the session transcript to state/log/ (the agent's 'action').""" + log_dir = Path(root) / "state" / "log" + log_dir.mkdir(parents=True, exist_ok=True) + stamp = session.state.session_start.strftime("%Y%m%dT%H%M") + out = log_dir / f"session-{stamp}.md" + lines = [f"# Ballmer session log β start {session.state.session_start.isoformat()}", + "", + f"- r = {session.state.body.r:.4f}, " + f"weight = {session.state.body.weight_kg:.1f} kg, " + f"Ξ² = {session.state.body.beta} %/hr, " + f"k_a = {session.state.body.k_a_base} /hr", + f"- target window {session.state.window[0]:.3f}-{session.state.window[1]:.3f}%, " + f"ceiling {session.state.ceiling:.3f}%", + "", + "| time | BAC% | status | action | drink | burndown(h) |", + "|------|------|--------|--------|-------|-------------|"] + for tk in session.ticks: + bd = f"{tk.burndown_hours:.2f}" if tk.burndown_hours is not None else "-" + lines.append(f"| {tk.clock} | {tk.current_bac:.3f} | {tk.status} | " + f"{tk.action} | {tk.drink_name or '-'} | {bd} |") + out.write_text("\n".join(lines) + "\n", encoding="utf-8") + return out diff --git a/Agent/ballmer/ballmer/bac_model.py b/Agent/ballmer/ballmer/bac_model.py new file mode 100644 index 0000000..7988a4c --- /dev/null +++ b/Agent/ballmer/ballmer/bac_model.py @@ -0,0 +1,326 @@ +"""The BAC forecasting engine β PURE functions, no I/O, fully unit-testable. + +This module is the heart of Ballmer and is deliberately isolated from the drink +library and the agent/interaction layer, so the engine can be swapped or +validated independently. + +UNITS CONVENTION (read this before touching anything): + A grams of pure ethanol + W body weight in KILOGRAMS + r Widmark factor, DIMENSIONLESS (= TBW_litres / weight_kg here) + t time in HOURS + BAC reported as PERCENT, i.e. g ethanol / 100 mL blood (US "%" β 0.08% = legal limit) + beta elimination rate in %BAC per hour + k_a first-order absorption rate constant, 1/hour + +CORE WIDMARK RELATION (derivation): + Ethanol distributes into a volume Vd = r * W_kg litres (~kg of body water). + Concentration once fully absorbed, no elimination: + C = A / (r * W_kg) [grams / litre of distribution volume] + Convert g/L -> g/100mL (i.e. "%"): divide by 10. + BAC%_peak = A / (r * W_kg * 10) + Worked check: 14 g, r=0.65, W=99.8 kg -> + 14 / (0.65 * 99.8 * 10) = 14 / 648.7 = 0.0216 % (one std drink, heavy male) + With absorption spread over time AND simultaneous elimination, the *realised* + peak is a touch lower (~0.018-0.021%), matching expectation. + +r-FACTOR β IMPORTANT DERIVATION (and a deliberate deviation from a naive formula): + Widmark's BAC is a *blood* concentration. Watson's equation gives *total body* + water (TBW). Alcohol distributes through body water, so: + amount A = C_water * TBW + but BAC is measured in blood, and blood is ~80.6% water by weight, so + C_blood = C_water * BLOOD_WATER_FRACTION + Combining with Widmark's C_blood = A / (r * W): + r = TBW / (BLOOD_WATER_FRACTION * W) + The naive r = TBW/W (omitting the blood-water term) gives ~0.52 for the demo + profile, which is too low and inconsistent with the established Widmark range. + The corrected form gives r ~ 0.65 (expected band 0.64-0.66) and makes a single + 14 g standard drink peak at ~0.02% β matching the model's sanity anchors. + This is an approximation, not ground truth, but it is the defensible choice. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +from . import config + + +# ========================================================================== +# Body-water / r-factor (Watson 1980) +# ========================================================================== +def watson_tbw_liters(sex: str, age: float, height_cm: float, weight_kg: float) -> float: + """Total body water in LITRES via the Watson (1980) anthropometric equations. + + Male: TBW = 2.447 - 0.09516*age + 0.1074*height_cm + 0.3362*weight_kg + Female: TBW = -2.097 + 0.1069*height_cm + 0.2466*weight_kg + (note: the female equation has NO age term and a negative constant) + Source: Watson PE, Watson ID, Batt RD, Am J Clin Nutr 1980;33(1):27-39. + """ + sex = sex.lower() + if sex == "male": + return 2.447 - 0.09516 * age + 0.1074 * height_cm + 0.3362 * weight_kg + elif sex == "female": + return -2.097 + 0.1069 * height_cm + 0.2466 * weight_kg + raise ValueError(f"sex must be 'male' or 'female', got {sex!r}") + + +def widmark_r(tbw_liters: float, weight_kg: float) -> float: + """Widmark r-factor from Watson TBW, with the blood-water correction. + + r = TBW_litres / (BLOOD_WATER_FRACTION * weight_kg) + + See the module docstring for the full derivation. The blood-water term + (~0.806) is what converts a *total body water* volume into the *blood*- + referenced distribution ratio Widmark's equation expects. Lower r (more fat) + -> higher peak BAC for a given dose. + """ + return tbw_liters / (config.BLOOD_WATER_FRACTION * weight_kg) + + +def r_from_profile(profile: dict) -> float: + """Compute r from a human-friendly profile (imperial units in, SI conversions here). + + profile keys: height_in, weight_lb, age, sex. + All unit conversions are done HERE and commented, since that is the classic + source of a 2x BAC error. + """ + height_cm = profile["height_in"] * config.IN_TO_CM # inches -> cm + weight_kg = profile["weight_lb"] * config.LB_TO_KG # pounds -> kg + tbw = watson_tbw_liters(profile["sex"], profile["age"], height_cm, weight_kg) + return widmark_r(tbw, weight_kg) + + +def weight_kg_from_profile(profile: dict) -> float: + return profile["weight_lb"] * config.LB_TO_KG + + +# ========================================================================== +# Ethanol mass from a recipe +# ========================================================================== +def ethanol_grams(volume_ml: float, abv: float) -> float: + """Grams of pure ethanol in a liquid volume. + + grams = volume_ml * abv * density_ethanol + abv is a FRACTION (0.40 for 40% / 80-proof), not a percentage. + Worked check: 44 mL of 40% spirit -> 44 * 0.40 * 0.789 = 13.9 g (~1 std drink). + """ + return volume_ml * abv * config.ETHANOL_DENSITY_G_PER_ML + + +# ========================================================================== +# Drink events + the body parameters bundle +# ========================================================================== +@dataclass(frozen=True) +class DrinkEvent: + """A dose of ethanol entering the body at a point in time. + + t_hours : hours since the start of the session (session-relative clock). + grams : grams of pure ethanol in the drink. + label : human-readable name (for logs/plots). + food_state : stomach contents at the time of THIS drink ('empty'|'light'|'full'). + Modifies this drink's absorption rate only. + """ + t_hours: float + grams: float + label: str = "drink" + food_state: str = config.DEFAULT_FOOD_STATE + + +@dataclass(frozen=True) +class BodyParams: + """The per-person constants the engine needs. Build once from a profile.""" + r: float + weight_kg: float + beta: float = config.WIDMARK_BETA_DEFAULT # %BAC/hr elimination + k_a_base: float = config.K_A_BASE # 1/hr absorption + + @classmethod + def from_profile(cls, profile: dict, beta: float | None = None, + k_a_base: float | None = None) -> "BodyParams": + return cls( + r=r_from_profile(profile), + weight_kg=weight_kg_from_profile(profile), + beta=config.WIDMARK_BETA_DEFAULT if beta is None else beta, + k_a_base=config.K_A_BASE if k_a_base is None else k_a_base, + ) + + +def _peak_contribution(grams: float, body: BodyParams) -> float: + """Full-absorption, no-elimination BAC contribution of a dose (the Widmark peak).""" + # BAC% = A / (r * W_kg * 10) β see module docstring derivation. + return grams / (body.r * body.weight_kg * 10.0) + + +# ========================================================================== +# The time-course: first-order absorption + zero-order elimination +# ========================================================================== +# We integrate the ODE numerically because there is NO clean closed form when +# first-order absorption is combined with zero-order elimination that switches +# off at BAC = 0. Euler at 1-minute steps is transparent and accurate enough. +# +# dBAC/dt = absorption_input_rate(t) - elimination_rate(t) +# +# absorption_input_rate(t) = sum over drinks dosed at t_i <= t of: +# peak_contribution_i * k_a_i * exp(-k_a_i * (t - t_i)) [%BAC/hr] +# (this is d/dt of peak_i * (1 - exp(-k_a_i*(t-t_i))); it integrates to the +# full Widmark peak as t->inf, i.e. absorption only SPREADS the dose in time) +# +# elimination_rate(t) = beta while BAC > 0, else 0 [%BAC/hr] + +def _absorption_rate(events: list[DrinkEvent], body: BodyParams, t: float) -> float: + """Instantaneous rate at which ethanol is being delivered to blood, in %BAC/hr.""" + rate = 0.0 + for ev in events: + if t < ev.t_hours: + continue + k_a = body.k_a_base * config.FOOD_FACTORS.get(ev.food_state, 1.0) + dt = t - ev.t_hours + rate += _peak_contribution(ev.grams, body) * k_a * math.exp(-k_a * dt) + return rate + + +def bac_curve(events: list[DrinkEvent], body: BodyParams, t_end: float, + t_start: float = 0.0, dt: float = config.SIM_DT_HOURS + ) -> tuple[list[float], list[float]]: + """Integrate the BAC-vs-time curve. + + Returns (times, bac) where times are hours (session-relative) on a grid of + step `dt` and bac is %BAC at each grid point. BAC is clamped at >= 0; within + a step, elimination cannot drive BAC below zero. + """ + if t_end <= t_start: + return [t_start], [0.0] + + n = int(round((t_end - t_start) / dt)) + 1 + times: list[float] = [] + bac_series: list[float] = [] + + bac = 0.0 + # Account for any doses strictly before t_start by warming up from 0. + # Simplest correct approach: always start the integration at 0 (true zero + # BAC at t=0 by construction) and integrate forward to t_start, then record. + warmup_start = min(t_start, min((e.t_hours for e in events), default=t_start)) + t = warmup_start + record_from = t_start + while t < t_end - 1e-12: + absorb = _absorption_rate(events, body, t) + eliminate = body.beta if bac > 0.0 else 0.0 + # Euler step; clamp elimination so BAC can't cross zero mid-step. + delta = (absorb - eliminate) * dt + bac = max(0.0, bac + delta) + t += dt + if t >= record_from - 1e-12: + times.append(t) + bac_series.append(bac) + if len(times) >= n + 2: # safety bound + break + + if not times: # degenerate (t_end ~ t_start) + times = [t_start] + bac_series = [max(0.0, bac)] + return times, bac_series + + +# ========================================================================== +# Curve summaries +# ========================================================================== +def bac_at(events: list[DrinkEvent], body: BodyParams, t: float, + dt: float = config.SIM_DT_HOURS) -> float: + """BAC% at a single instant `t` (hours). Integrates from 0 to t.""" + if t <= 0: + return 0.0 + _, bac = bac_curve(events, body, t_end=t, t_start=0.0, dt=dt) + return bac[-1] if bac else 0.0 + + +def peak_bac(times: list[float], bac: list[float]) -> tuple[float, float]: + """Return (t_peak_hours, peak_bac%).""" + i = max(range(len(bac)), key=lambda j: bac[j]) + return times[i], bac[i] + + +def time_in_window(times: list[float], bac: list[float], + low: float, high: float, dt: float = config.SIM_DT_HOURS) -> float: + """Hours the curve spends inside [low, high] (inclusive).""" + return sum(dt for v in bac if low <= v <= high) + + +def time_above(times: list[float], bac: list[float], high: float, + dt: float = config.SIM_DT_HOURS) -> float: + """Hours the curve spends strictly ABOVE `high` (overshoot).""" + return sum(dt for v in bac if v > high) + + +# ========================================================================== +# Forecast: current state + a candidate next drink +# ========================================================================== +@dataclass +class ForecastResult: + """Everything the recommendation layer and the demo need to print.""" + candidate_label: str + candidate_grams: float + projected_peak_bac: float + t_peak_hours: float # session-relative + minutes_in_window: float + minutes_above_window: float + curve_times: list[float] = field(default_factory=list) + curve_bac: list[float] = field(default_factory=list) + assumptions: dict = field(default_factory=dict) + + +def forecast(current_events: list[DrinkEvent], + candidate: DrinkEvent | None, + body: BodyParams, + window: tuple[float, float], + now_hours: float, + horizon_hours: float = 3.0, + dt: float = config.SIM_DT_HOURS) -> ForecastResult: + """Project the BAC curve forward over `horizon_hours` assuming `candidate` is + consumed at `now_hours`. Pass candidate=None to forecast the do-nothing path. + + Returns peak BAC, dwell time in the target window, overshoot time, the curve, + and the full assumption set used (so every recommendation is auditable). + """ + low, high = window + events = list(current_events) + if candidate is not None: + # re-stamp the candidate to be consumed *now* + events.append(DrinkEvent(t_hours=now_hours, grams=candidate.grams, + label=candidate.label, food_state=candidate.food_state)) + + t_end = now_hours + horizon_hours + times, bac = bac_curve(events, body, t_end=t_end, t_start=now_hours, dt=dt) + t_peak, peak = peak_bac(times, bac) + mins_in = time_in_window(times, bac, low, high, dt) * 60.0 + mins_above = time_above(times, bac, high, dt) * 60.0 + + last_drink_t = max((e.t_hours for e in current_events), default=None) + assumptions = { + "r": round(body.r, 4), + "weight_kg": round(body.weight_kg, 2), + "beta_pct_per_hr": body.beta, + "k_a_base_per_hr": body.k_a_base, + "food_state_of_candidate": candidate.food_state if candidate else None, + "effective_k_a_of_candidate": ( + round(body.k_a_base * config.FOOD_FACTORS.get(candidate.food_state, 1.0), 3) + if candidate else None + ), + "now_hours": round(now_hours, 3), + "hours_since_last_drink": (round(now_hours - last_drink_t, 3) + if last_drink_t is not None else None), + "horizon_hours": horizon_hours, + "target_window_pct": [low, high], + } + return ForecastResult( + candidate_label=candidate.label if candidate else "NO DRINK (hold)", + candidate_grams=candidate.grams if candidate else 0.0, + projected_peak_bac=peak, + t_peak_hours=t_peak, + minutes_in_window=mins_in, + minutes_above_window=mins_above, + curve_times=times, + curve_bac=bac, + assumptions=assumptions, + ) diff --git a/Agent/ballmer/ballmer/config.py b/Agent/ballmer/ballmer/config.py new file mode 100644 index 0000000..6e53718 --- /dev/null +++ b/Agent/ballmer/ballmer/config.py @@ -0,0 +1,132 @@ +"""Physiological constants, unit conversions, and tunable model parameters. + +EVERY constant here carries its source and a plausible range. Where the +literature gives a range, we use a defensible midpoint and say so. Nothing in +this file is fabricated; where a value is an engineering choice rather than a +measured constant (e.g. the safety ceiling, the overshoot penalty), that is +called out explicitly. + +# TODO: validate the *whole* model against published BAC time-course data +# (e.g. controlled-dosing studies). The demo proves the wiring is correct, NOT +# that the predictions are accurate. A Widmark-family model is an approximation. +""" + +# -------------------------------------------------------------------------- +# Unit conversions (the easy thing to get wrong β so they live in one place) +# -------------------------------------------------------------------------- +LB_TO_KG = 0.45359237 # exact, international avoirdupois pound +IN_TO_CM = 2.54 # exact, international inch +ETHANOL_DENSITY_G_PER_ML = 0.789 # g/mL at 20 Β°C (CRC Handbook of Chemistry & Physics) +OZ_TO_ML = 29.5735 # US fluid ounce -> mL + +# Water fraction of whole blood, by weight (~80%). Widmark's BAC is a *blood* +# concentration, but Watson's TBW is *total body* water β so converting TBW to a +# Widmark r requires dividing by the blood water fraction (see config.py note on +# FLAT_WIDMARK_R and bac_model.widmark_r for the full derivation). Value 0.806 is +# the classically cited whole-blood water content (Widmark; widely reproduced). +BLOOD_WATER_FRACTION = 0.806 + +# -------------------------------------------------------------------------- +# Reference quantities +# -------------------------------------------------------------------------- +# US "standard drink" = 14 g pure ethanol (NIAAA, US Dept. of Health). Used only +# for sanity checks and human-readable framing, never inside the engine. +STANDARD_DRINK_G = 14.0 + +# -------------------------------------------------------------------------- +# Widmark elimination rate (zero-order) +# -------------------------------------------------------------------------- +# Ethanol elimination is saturable (Michaelis-Menten) but is well-approximated +# as ZERO-ORDER (constant rate) at the concentrations relevant here β this is +# the defining feature of the Widmark model. +# Default 0.015 %/hr. Population range ~0.012-0.020 %/hr. +# Source: Jones AW, "Evidence-based survey of the elimination rates of ethanol +# from blood", Forensic Sci Int 2010. Midpoint chosen. +WIDMARK_BETA_DEFAULT = 0.015 # %BAC per hour +WIDMARK_BETA_RANGE = (0.012, 0.020) + +# -------------------------------------------------------------------------- +# First-order absorption rate constant k_a +# -------------------------------------------------------------------------- +# ABSORPTION KINETICS IS THE WEAKEST PART OF ANY WIDMARK-FAMILY MODEL. +# We model each drink as absorbing into blood with first-order rate k_a (1/hr): +# absorbed_fraction(t) = 1 - exp(-k_a * (t - t_dose)) +# k_a relates to absorption half-life by t_half = ln(2) / k_a. +# Default k_a = 2.5 /hr -> t_half ~ 0.28 hr (~17 min), Tmax ~ 30-60 min. +# Plausible range: t_half 10-60 min -> k_a ~ 0.7-4.2 /hr. +# These are order-of-magnitude consistent with controlled-dosing literature; +# treat as an approximation, not a measured personal constant. +K_A_BASE = 2.5 # 1/hr +K_A_RANGE = (0.7, 4.2) + +# Food / stomach-contents modifier β MULTIPLIES k_a. Food slows gastric +# emptying, so ethanol reaches the small intestine (where most absorption +# happens) more slowly -> lower k_a -> lower, later, broader peak. +# These multipliers are coarse engineering approximations, NOT measured values. +FOOD_FACTORS = { + "empty": 1.4, # empty stomach -> faster absorption + "light": 1.0, # a snack -> baseline + "full": 0.6, # full meal -> markedly slower +} +DEFAULT_FOOD_STATE = "light" + +# -------------------------------------------------------------------------- +# Watson total-body-water equations (1980) +# -------------------------------------------------------------------------- +# Source: Watson PE, Watson ID, Batt RD. "Total body water volumes for adult +# males and females estimated from simple anthropometric measurements." +# Am J Clin Nutr 1980;33(1):27-39. Inputs: age (yr), height (cm), weight (kg). +# Output: TBW in LITRES. (Coefficients are embedded in bac_model.watson_tbw_liters.) +# +# We derive the Widmark r-factor from TBW. The naive form r = TBW/weight is +# DELIBERATELY NOT USED: it yields ~0.52 for the demo profile, which is both +# physiologically off and inconsistent with the well-established Widmark range. +# Widmark's r is referenced to *blood* alcohol, and blood is ~80.6% water, so: +# r = TBW_litres / (BLOOD_WATER_FRACTION * weight_kg) +# This gives r ~ 0.65 for the demo profile (heavy 39yo male) β exactly the +# expected 0.64-0.66 band β and makes a single 14 g standard drink peak at +# ~0.02% as expected. (Derivation in bac_model.widmark_r.) +# +# Using Watson TBW (rather than a flat r) adapts to body composition: a heavier +# individual carries proportionally more fat (which holds little water), lowering +# r and therefore RAISING peak BAC for a given dose. A flat 0.68 underestimates +# that peak for this profile. +FLAT_WIDMARK_R = {"male": 0.68, "female": 0.55} # for comparison / fallback only + +# -------------------------------------------------------------------------- +# Target window (THE JOKE) + safety +# -------------------------------------------------------------------------- +# XKCD #323 "Ballmer Peak": programmers code best at 0.129%-0.138% BAC. +# THIS IS A COMIC-STRIP GAG. It is far above every legal driving limit +# (e.g. 0.08% US, 0.05% many countries) and is not a health recommendation. +TARGET_LOW = 0.129 # %BAC +TARGET_HIGH = 0.138 # %BAC + +# Safety ceiling: an ENGINEERING CHOICE, not a medical threshold. Above this the +# agent stops recommending alcohol (soft-nudge mode β see recommend.py / +# safety-policy.md). Set just above the (already absurd) target band. +SAFETY_CEILING = 0.15 # %BAC + +# Scoring: dwell minutes in-window MINUS this penalty * minutes spent ABOVE the +# window. Engineering choice; tune to taste. Higher = more overshoot-averse. +OVERSHOOT_PENALTY = 2.0 + +# The chosen dwell+overshoot metric is MYOPIC (one drink ahead): from a low base +# no single drink reaches the ~0.13% band, so every candidate would tie at +# dwell=0 and the agent would HOLD forever, never climbing the staircase. We add +# a continuous gradient so the agent climbs when below the band and brakes near +# the top. Score (minutes for dwell/overshoot; %BAC for shortfall/ceiling): +# score = dwell_min +# - OVERSHOOT_PENALTY * overshoot_min +# - SHORTFALL_WEIGHT * max(0, target_low - projected_peak) # climb toward band +# - CEILING_WEIGHT * max(0, projected_peak - safety_ceiling) # never blow past +# Weights are engineering choices: SHORTFALL pulls the projected peak up toward +# the band from below; CEILING dominates everything so a drink that would cross +# the ceiling is never chosen. +SHORTFALL_WEIGHT = 5000.0 # penalty per %BAC the projected peak falls short of the band +CEILING_WEIGHT = 100000.0 # penalty per %BAC the projected peak exceeds the ceiling + +# -------------------------------------------------------------------------- +# Numerical integration +# -------------------------------------------------------------------------- +SIM_DT_HOURS = 1.0 / 60.0 # 1-minute integration step for the BAC ODE diff --git a/Agent/ballmer/ballmer/dashboard.py b/Agent/ballmer/ballmer/dashboard.py new file mode 100644 index 0000000..22e697c --- /dev/null +++ b/Agent/ballmer/ballmer/dashboard.py @@ -0,0 +1,115 @@ +"""Terminal dashboard: are you IN RANGE, and when will you burn down out of it? + +Pure-text, zero-dependency renderer so the always-on demo always works in any +terminal. Renders: + * a status banner (BELOW / IN RANGE / ABOVE / PAST CEILING) + * a BAC gauge with the target band marked + * an ASCII BAC-vs-time curve for the whole night, band shaded + * the burndown: projected time until BAC leaves the target window + +A matplotlib renderer (dashboard_plot.py) can be layered on top without touching +this file or the engine. +""" + +from __future__ import annotations + +from .agent import SessionRecord, TickRecord, time_to_leave_window +from .recommend import ABOVE, BELOW, IN, PAST_CEILING + +# ANSI colors (degrade gracefully if the terminal ignores them). +_RESET = "\033[0m" +_C = {BELOW: "\033[36m", IN: "\033[32m", ABOVE: "\033[33m", PAST_CEILING: "\033[31m"} +_LABEL = {BELOW: "BELOW BAND", IN: "IN RANGE β¦", ABOVE: "ABOVE BAND", + PAST_CEILING: "PAST CEILING β "} + + +def status_banner(tk: TickRecord, window, ceiling) -> str: + color = _C.get(tk.status, "") + low, high = window + bd = ("β" if tk.burndown_hours is None + else f"{tk.burndown_hours:.2f} h until you drop below {low:.3f}%") + return (f"{color}[{_LABEL.get(tk.status, tk.status)}]{_RESET} " + f"BAC {tk.current_bac:.3f}% | band {low:.3f}-{high:.3f}% " + f"| ceiling {ceiling:.3f}% | burndown: {bd}") + + +def gauge(current: float, window, ceiling, width: int = 50) -> str: + """A horizontal BAC gauge from 0 to just past the ceiling, band marked [ ].""" + low, high = window + top = max(ceiling * 1.15, high * 1.2, current * 1.1, 1e-6) + def pos(v): + return min(width - 1, max(0, int(round(v / top * (width - 1))))) + cells = [" "] * width + for i in range(pos(low), pos(high) + 1): # shade the target band + cells[i] = "=" + cells[pos(low)] = "[" + cells[pos(high)] = "]" + cells[pos(ceiling)] = "X" # ceiling marker + cells[pos(current)] = "β" # you are here + bar = "".join(cells) + return f"0% |{bar}| {top:.3f}% ([ ]=target band X=ceiling β=you)" + + +def curve_chart(session: SessionRecord, height: int = 12, width: int = 64) -> str: + """ASCII line chart of BAC over the night, target band shaded as 'Β·' rows.""" + times, bac = session.curve_times, session.curve_bac + if not bac: + return "(no curve)" + low, high = session.state.window + ceiling = session.state.ceiling + top = max(max(bac), ceiling) * 1.12 + # downsample to `width` columns + cols = [] + n = len(bac) + for c in range(width): + i = int(c / width * n) + cols.append(bac[i]) + rows = [] + for r in range(height, -1, -1): + level = r / height * top + line = [] + for v in cols: + if v >= level - (top / height / 2): + line.append("β") + elif low <= level <= high: + line.append("Β·") # shade the band region across the row + else: + line.append(" ") + # axis label + rows.append(f"{level:6.3f} |" + "".join(line)) + t0, t1 = times[0], times[-1] + axis = " +" + "-" * width + xlabel = f" {t0:.1f}h{' ' * (width - 8)}{t1:.1f}h (session hours)" + band_note = f" (band {low:.3f}-{high:.3f}% shown as 'Β·'; β = BAC curve)" + return "\n".join(rows) + "\n" + axis + "\n" + xlabel + band_note + + +def session_dashboard(session: SessionRecord) -> str: + """Full end-of-night dashboard: curve + per-tick burndown table + summary.""" + st = session.state + out = ["", "=" * 78, " BALLMER DASHBOARD β BAC vs. the (joke) Ballmer Peak band", "=" * 78, + "", curve_chart(session), "", + " Tick-by-tick:", " " + "-" * 74, + f" {'time':>8} {'BAC%':>7} {'status':>13} {'action':>20} {'burndown':>10}", + " " + "-" * 74] + for tk in session.ticks: + bd = "β" if tk.burndown_hours is None else f"{tk.burndown_hours:.2f}h" + label = _LABEL.get(tk.status, tk.status) + act = tk.action + (f": {tk.drink_name}" if tk.drink_name else "") + out.append(f" {tk.clock:>8} {tk.current_bac:>7.3f} {label:>13} {act:>20} {bd:>10}") + out.append(" " + "-" * 74) + + # Summary: time spent in band, peak, and final burndown. + in_band = [tk for tk in session.ticks if tk.status == IN] + peak_tk = max(session.ticks, key=lambda t: t.current_bac) + out += ["", + f" Peak BAC: {peak_tk.current_bac:.3f}% at {peak_tk.clock}", + f" Ticks spent IN the target band: {len(in_band)} / {len(session.ticks)}", + f" Final burndown: " + ( + "below band β nothing to burn down" + if peak_tk.burndown_hours is None and session.ticks[-1].burndown_hours is None + else (f"{session.ticks[-1].burndown_hours:.2f} h to leave the band" + if session.ticks[-1].burndown_hours is not None + else "below band")), + "=" * 78, ""] + return "\n".join(out) diff --git a/Agent/ballmer/ballmer/drinks.py b/Agent/ballmer/ballmer/drinks.py new file mode 100644 index 0000000..71f9e07 --- /dev/null +++ b/Agent/ballmer/ballmer/drinks.py @@ -0,0 +1,76 @@ +"""The drink library β data model + recipe-level ethanol math. + +Kept separate from the BAC engine and the agent so the menu can grow without +touching either. A drink is defined by its INGREDIENTS, never a flat ABV, so the +ethanol content is computed from the actual recipe. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from . import config +from .bac_model import ethanol_grams + + +@dataclass(frozen=True) +class Ingredient: + name: str + oz: float + abv: float # fraction, e.g. 0.40 for 80-proof + + @property + def volume_ml(self) -> float: + return self.oz * config.OZ_TO_ML + + @property + def ethanol_g(self) -> float: + return ethanol_grams(self.volume_ml, self.abv) + + +@dataclass(frozen=True) +class Drink: + name: str + ingredients: tuple[Ingredient, ...] + category: str = "" + notes: str = "" + vibe_score: int = 5 # 1-10 subjective desirability for a Ballmer Peak session + price: float = 0.0 # bar price in USD + + @property + def total_ethanol_g(self) -> float: + """Total pure ethanol in grams: sum(volume_ml * abv * 0.789) over ingredients.""" + return sum(ing.ethanol_g for ing in self.ingredients) + + @property + def standard_drinks(self) -> float: + """Ethanol expressed in US standard-drink units (14 g each).""" + return self.total_ethanol_g / config.STANDARD_DRINK_G + + @property + def is_alcoholic(self) -> bool: + return self.total_ethanol_g > 1e-6 + + +def _drink_from_dict(d: dict) -> Drink: + ings = tuple(Ingredient(name=i["name"], oz=i["oz"], abv=i["abv"]) + for i in d["ingredients"]) + return Drink(name=d["name"], ingredients=ings, + category=d.get("category", ""), notes=d.get("notes", ""), + vibe_score=int(d.get("vibe", 5)), + price=float(d.get("price", 0.0))) + + +def load_library(path: str | Path = "drink-library.json") -> list[Drink]: + """Load the menu from the JSON state file into Drink objects.""" + data = json.loads(Path(path).read_text(encoding="utf-8")) + return [_drink_from_dict(d) for d in data["drinks"]] + + +def find_drink(library: list[Drink], name: str) -> Drink | None: + for d in library: + if d.name.lower() == name.lower(): + return d + return None diff --git a/Agent/ballmer/ballmer/llm_agent.py b/Agent/ballmer/ballmer/llm_agent.py new file mode 100644 index 0000000..f5bdd49 --- /dev/null +++ b/Agent/ballmer/ballmer/llm_agent.py @@ -0,0 +1,169 @@ +"""The LLM reasoning layer β the part that makes Ballmer an actual agent. + +The math model (recommend.py) optimises dwell-time in the target band. This +module adds the layer the math can't: qualitative judgment. Claude sees the +current BAC state, the drink menu with vibe scores, and the math model's top +pick, then reasons about which drink best balances the physics AND the vibe of +the evening. + +Called once per user request from the dashboard ("Ask Claude" button), NOT +during the batch simulation β so latency is spent when the user wants insight, +not at startup. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import TypedDict + +import anthropic + +from .drinks import Drink + +SYSTEM = """\ +You are Sully, a gruff Boston bartendah who's seen it all at his dive bah in Southie. \ +You talk like a true Bostonian β drop your R's ("bah", "cah", "wicked", "pahk"), \ +use local slang ("wicked pissa", "no suh", "that's retahded", "kid", "chief"), \ +and you've got strong opinions about drinks. \ +You're also running this XKCD #323 Ballmer Peak experiment β you know the science \ +because you Googled it β but you deliver it with pure Southie attitude. \ +The physics model handles the math. Your job is the judgment call: \ +given the BAC trajectory and the bah menu, tell the customer what to ordah (or HOLD) \ +in 2β3 sentences of authentic Boston bartendah wisdom. \ +Each drink has a vibe score (1β10) β factah it in. Don't break charactah.""" + + +def _find_api_key() -> str: + key = os.environ.get("ANTHROPIC_API_KEY", "") + if key: + return key + # Walk up the directory tree looking for a .env file. + d = Path(__file__).resolve().parent + for _ in range(7): + env_file = d / ".env" + if env_file.exists(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("ANTHROPIC_API_KEY="): + val = line.split("=", 1)[1].strip().strip('"').strip("'") + if val: + return val + d = d.parent + raise RuntimeError( + "ANTHROPIC_API_KEY not found in environment or any .env file up to the repo root." + ) + + +class LLMRecommendation(TypedDict): + drink_name: str # exact library name, or "HOLD" + reasoning: str # 2-3 sentence explanation + vibe_score: int # vibe score of the chosen drink (0 if HOLD) + + +def llm_reason(frame: dict, library: list[Drink]) -> LLMRecommendation: + """Ask Claude which drink to order (or hold) given the current state frame. + + frame β the JSON frame dict from build_frames() for the current tick + library β the full drink library (with vibe_score populated) + + Returns a typed dict: {drink_name, reasoning, vibe_score}. + """ + key = _find_api_key() + client = anthropic.Anthropic(api_key=key) + + current_bac = frame["current_bac"] + status = frame["status_label"] + low, high = frame["band"] + ceiling = frame["ceiling"] + now_h = frame["now_hours"] + math_action = frame.get("action", "?") + math_drink = frame.get("drink") # name of drink the math picked, or None + + # Describe recent drinks consumed (last 3) + recent = frame.get("events", [])[-3:] + recent_str = ( + ", ".join(e["label"] for e in recent) if recent else "none yet" + ) + + # Build the menu summary, alcoholic drinks only, sorted by vibe descending + alcoholic = [d for d in library if d.is_alcoholic] + alcoholic.sort(key=lambda d: d.vibe_score, reverse=True) + menu_lines = [ + f" β’ {d.name} [{d.category}] β {d.total_ethanol_g:.1f}g ethanol " + f"({d.standard_drinks:.1f} std), vibe {d.vibe_score}/10" + + (f" β {d.notes}" if d.notes else "") + for d in alcoholic + ] + menu = "\n".join(menu_lines) + + math_line = ( + f"Physics model says: {math_action}" + + (f" β {math_drink}" if math_drink else " (no drink)") + ) + + user_msg = f"""\ +=== Current state === +BAC: {current_bac:.3f}% ({status}) +Target band: {low:.3f}β{high:.3f}% | Safety ceiling: {ceiling:.3f}% +Session time: +{now_h:.1f} h +Recent drinks: {recent_str} + +{math_line} + +=== Bar menu === +{menu} + +Pick the next drink (exact name from the menu above) or say HOLD. \ +Tell 'em what to do in 2β3 sentences β Boston bartendah voice, no suh.""" + + response = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=350, + system=SYSTEM, + tools=[{ + "name": "pick_drink", + "description": "Record the drink recommendation and reasoning.", + "input_schema": { + "type": "object", + "properties": { + "drink_name": { + "type": "string", + "description": ( + "Exact drink name from the menu (copy it verbatim), " + "or the string 'HOLD' to recommend waiting." + ) + }, + "reasoning": { + "type": "string", + "description": ( + "2β3 punchy sentences explaining the pick, " + "referencing both the BAC position and the drink's vibe." + ) + } + }, + "required": ["drink_name", "reasoning"] + } + }], + tool_choice={"type": "tool", "name": "pick_drink"}, + messages=[{"role": "user", "content": user_msg}] + ) + + for block in response.content: + if block.type == "tool_use" and block.name == "pick_drink": + inp = block.input + drink_name = inp.get("drink_name", "HOLD") + reasoning = inp.get("reasoning", "") + # Look up vibe score of the chosen drink + vibe = 0 + for d in library: + if d.name.lower() == drink_name.lower(): + vibe = d.vibe_score + break + return LLMRecommendation( + drink_name=drink_name, + reasoning=reasoning, + vibe_score=vibe, + ) + + raise RuntimeError("Claude did not call the pick_drink tool.") diff --git a/Agent/ballmer/ballmer/recommend.py b/Agent/ballmer/ballmer/recommend.py new file mode 100644 index 0000000..d240c0d --- /dev/null +++ b/Agent/ballmer/ballmer/recommend.py @@ -0,0 +1,165 @@ +"""Recommendation layer: rank candidate next-drinks against the target window. + +Scoring metric (chosen): DWELL + OVERSHOOT PENALTY. + score = minutes_in_window - OVERSHOOT_PENALTY * minutes_above_window +Rewards a candidate that keeps the BAC curve inside the (joke) target band the +longest, while penalising any time spent ABOVE the band. The "hold / no drink" +option is always scored too β sometimes the best move is to wait and let +elimination carry you, rather than overshoot. + +Safety (chosen): SOFT NUDGE. Past the configurable safety ceiling the agent +stops recommending alcohol and recommends the non-alcoholic option, but keeps +the monitoring loop alive to watch BAC decline. See reference/safety-policy.md. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from . import config +from .bac_model import BodyParams, DrinkEvent, ForecastResult, bac_at, forecast +from .drinks import Drink + +# Status of the drinker relative to the target band / safety ceiling. +BELOW = "BELOW_WINDOW" +IN = "IN_WINDOW" +ABOVE = "ABOVE_WINDOW" +PAST_CEILING = "PAST_CEILING" + +# Action the agent recommends. +ORDER = "ORDER" +HOLD = "HOLD" +NUDGE_NA = "NUDGE_NON_ALCOHOLIC" + + +def score_forecast(fc: ForecastResult, + window: tuple[float, float], + ceiling: float, + overshoot_penalty: float = config.OVERSHOOT_PENALTY) -> float: + """Dwell-minus-overshoot, plus a continuous climb/brake gradient. Higher=better. + + See config.py for the full rationale. The shortfall term lets the agent climb + the multi-drink staircase toward the band (since no single drink reaches it + from a low base); the ceiling term makes crossing the safety ceiling + strictly worse than any in-band option. + """ + low, high = window + shortfall = max(0.0, low - fc.projected_peak_bac) # %BAC below the band + over_ceiling = max(0.0, fc.projected_peak_bac - ceiling) # %BAC above the ceiling + return (fc.minutes_in_window + - overshoot_penalty * fc.minutes_above_window + - config.SHORTFALL_WEIGHT * shortfall + - config.CEILING_WEIGHT * over_ceiling) + + +@dataclass +class Candidate: + drink: Drink | None # None == the "hold / no drink" option + forecast: ForecastResult + score: float + + +@dataclass +class Recommendation: + status: str # BELOW / IN / ABOVE / PAST_CEILING + action: str # ORDER / HOLD / NUDGE_NA + current_bac: float + drink: Drink | None + forecast: ForecastResult | None + message: str + ranked: list[Candidate] = field(default_factory=list) + + +def _na_drink(library: list[Drink]) -> Drink | None: + for d in library: + if not d.is_alcoholic: + return d + return None + + +def recommend_next(events: list[DrinkEvent], + body: BodyParams, + library: list[Drink], + now_hours: float, + window: tuple[float, float] = (config.TARGET_LOW, config.TARGET_HIGH), + ceiling: float = config.SAFETY_CEILING, + food_state: str = config.DEFAULT_FOOD_STATE, + horizon_hours: float = 3.0) -> Recommendation: + """Decide what (if anything) to order next. + + Pipeline: assess current BAC -> if past ceiling, soft-nudge; else forecast + every alcoholic candidate + the hold option, rank by dwell+overshoot, and + recommend the best (which may be HOLD). + """ + low, high = window + current = bac_at(events, body, now_hours) + + # ---- Safety ceiling: soft nudge ------------------------------------- + if current >= ceiling: + na = _na_drink(library) + return Recommendation( + status=PAST_CEILING, + action=NUDGE_NA, + current_bac=current, + drink=na, + forecast=None, + message=( + f"You're at {current:.3f}% β past the safety ceiling " + f"({ceiling:.3f}%). I'm not recommending more alcohol. " + f"Have a {na.name if na else 'water'} and let it come down. " + "I'll keep watching." + ), + ) + + # ---- Forecast every candidate (alcoholic) + the HOLD option ---------- + candidates: list[Candidate] = [] + + hold_fc = forecast(events, None, body, window, now_hours, horizon_hours) + candidates.append(Candidate(drink=None, forecast=hold_fc, + score=score_forecast(hold_fc, window, ceiling))) + + for d in library: + if not d.is_alcoholic: + continue + cand_event = DrinkEvent(t_hours=now_hours, grams=d.total_ethanol_g, + label=d.name, food_state=food_state) + fc = forecast(events, cand_event, body, window, now_hours, horizon_hours) + candidates.append(Candidate(drink=d, forecast=fc, + score=score_forecast(fc, window, ceiling))) + + # Rank: best score first; tie-break toward higher dwell, then lower overshoot. + candidates.sort(key=lambda c: (c.score, c.forecast.minutes_in_window, + -c.forecast.minutes_above_window), reverse=True) + best = candidates[0] + + # ---- Status + message ------------------------------------------------ + if low <= current <= high: + status = IN + elif current < low: + status = BELOW + else: + status = ABOVE + + if best.drink is None: + action = HOLD + message = ( + f"You're at {current:.3f}% ({status.replace('_', ' ').lower()}). " + f"Best move is to HOLD β any drink right now would overshoot the " + f"{low:.3f}-{high:.3f}% band more than it helps. Let elimination work." + ) + else: + action = ORDER + fc = best.forecast + message = ( + f"You're at {current:.3f}% ({status.replace('_', ' ').lower()}). " + f"Order a {best.drink.name} ({best.drink.standard_drinks:.1f} std drinks). " + f"Projected peak {fc.projected_peak_bac:.3f}% at " + f"+{fc.t_peak_hours - now_hours:.2f} h; ~{fc.minutes_in_window:.0f} min " + f"in the target band, ~{fc.minutes_above_window:.0f} min above it." + ) + + return Recommendation( + status=status, action=action, current_bac=current, + drink=best.drink, forecast=best.forecast, message=message, + ranked=candidates, + ) diff --git a/Agent/ballmer/ballmer/stubs.py b/Agent/ballmer/ballmer/stubs.py new file mode 100644 index 0000000..5e4f2ff --- /dev/null +++ b/Agent/ballmer/ballmer/stubs.py @@ -0,0 +1,28 @@ +"""Clarifying-question STUBS. + +In a real deployment these would prompt the human at the bar. For the v1 demo +they return canned answers so the recommendation loop runs with ZERO human in +the loop. Every one is marked. Replace with an interactive intake later. +""" + + +def ask_pour_size() -> float: + # STUB: real version asks "single or double?" / measures the pour. + return 1.5 # oz + + +def ask_neat_or_rocks() -> str: + # STUB: real version asks the bartender / user. + return "rocks" + + +def ask_food_status() -> str: + # STUB: real version asks "have you eaten?". One of: empty | light | full. + # Drives the absorption-rate (k_a) food modifier. + return "light" + + +def confirm_order(drink_name: str) -> bool: + # STUB: real version asks the human to confirm before "consuming" the drink. + # In the demo the simulated drinker always follows the recommendation. + return True diff --git a/Agent/ballmer/ballmer/web_data.py b/Agent/ballmer/ballmer/web_data.py new file mode 100644 index 0000000..a85a1c4 --- /dev/null +++ b/Agent/ballmer/ballmer/web_data.py @@ -0,0 +1,139 @@ +"""Turn a deterministic SessionRecord into per-tick JSON frames for the web UI. + +Pure data transform β no I/O, no server. The web server (serve_dashboard.py) +plays these frames back on a wall-clock cadence so the dashboard updates "live". + +Each frame is the complete state to render at one tick: + * current BAC, status, the recommendation message + action + * the REALIZED BAC curve up to "now" (recomputed from drinks consumed so far, + so we never reveal the future) + * the BURNDOWN projection: a dashed decline line from now until BAC crosses the + bottom of the band, plus the ETA clock time + * drink-event markers consumed so far + * the running tick log +""" + +from __future__ import annotations + +from datetime import timedelta + +from .agent import SessionRecord, time_to_leave_window +from .bac_model import bac_curve +from .recommend import IN, ORDER, _na_drink # noqa: F401 (_na_drink kept for parity) + +_STATUS_LABEL = { + "BELOW_WINDOW": "BELOW BAND", + "IN_WINDOW": "IN RANGE", + "ABOVE_WINDOW": "ABOVE BAND", + "PAST_CEILING": "PAST CEILING", +} + + +def _clock(session: SessionRecord, now_hours: float) -> str: + return (session.state.session_start + timedelta(hours=now_hours) + ).strftime("%I:%M %p").lstrip("0") + + +def build_frames(session: SessionRecord) -> list[dict]: + """One frame per tick. Frame N shows the world as of tick N (no future leak).""" + st = session.state + low, high = st.window + beta = st.body.beta + frames: list[dict] = [] + + for i, tk in enumerate(session.ticks): + now = tk.now_hours + + # Realized curve from drinks consumed AT OR BEFORE now (no future leak). + events_so_far = [e for e in session.final_events if e.t_hours <= now + 1e-9] + if now > 0: + times, bac = bac_curve(events_so_far, st.body, t_end=now, t_start=0.0) + else: + times, bac = [0.0], [0.0] + + # Burndown projection: ODE forward from now with no new drinks. + # Uses the same integrator as the main curve so residual absorption is + # captured β the simple linear formula (current - low) / beta misses this. + bd_hours = tk.burndown_hours + bd_t, bd_bac = [], [] + bd_eta = None + if bd_hours is not None and bd_hours > 0: + low = st.window[0] + t_end_proj = now + bd_hours + 1.0 + fwd_times, fwd_bacs = bac_curve(events_so_far, st.body, + t_end=t_end_proj, t_start=now) + for ft, fb in zip(fwd_times, fwd_bacs): + bd_t.append(round(ft, 4)) + bd_bac.append(round(fb, 5)) + if fb <= low: + break + if bd_t: + bd_eta = _clock(session, bd_t[-1]) + + events_marks = [ + {"t": round(e.t_hours, 3), "label": e.label, "grams": round(e.grams, 1)} + for e in events_so_far + ] + + body = st.body + ticklog = [] + tab_total = 0.0 + for j in range(i + 1): + tk_j = session.ticks[j] + drink_obj = tk_j.recommendation.drink if tk_j.action == ORDER else None + price = drink_obj.price if drink_obj else None + bac_delta = (round(drink_obj.total_ethanol_g / (body.r * body.weight_kg * 10), 4) + if drink_obj else None) + if price: + tab_total += price + ticklog.append({ + "clock": tk_j.clock, + "bac": round(tk_j.current_bac, 3), + "status": _STATUS_LABEL.get(tk_j.status, tk_j.status), + "action": tk_j.action, + "drink": tk_j.drink_name, + "burndown": (round(tk_j.burndown_hours, 2) + if tk_j.burndown_hours is not None else None), + "price": price, + "bac_delta": bac_delta, + }) + + frames.append({ + "cursor": i, + "n_ticks": len(session.ticks), + "clock": tk.clock, + "now_hours": round(now, 4), + "band": [low, high], + "ceiling": st.ceiling, + "current_bac": round(tk.current_bac, 5), + "status": tk.status, + "status_label": _STATUS_LABEL.get(tk.status, tk.status), + "in_range": tk.status == IN, + "action": tk.action, + "drink": tk.drink_name, + "message": tk.recommendation.message, + "burndown_hours": round(bd_hours, 3) if bd_hours is not None else None, + "burndown_eta": bd_eta, + "curve": {"t": [round(x, 4) for x in times], "bac": [round(x, 5) for x in bac]}, + "burndown_line": {"t": bd_t, "bac": bd_bac}, + "events": events_marks, + "ticks": ticklog, + "tab_total": round(tab_total, 2), + }) + return frames + + +def session_meta(session: SessionRecord) -> dict: + """Static metadata shown in the dashboard header.""" + st = session.state + return { + "session_start": st.session_start.isoformat(), + "start_time": st.session_start.strftime("%H:%M"), + "profile": st.profile, + "r": round(st.body.r, 4), + "beta": st.body.beta, + "k_a": st.body.k_a_base, + "band": list(st.window), + "ceiling": st.ceiling, + "duration_hours": st.session_duration_hours, + } diff --git a/Agent/ballmer/drink-library.json b/Agent/ballmer/drink-library.json new file mode 100644 index 0000000..743b3df --- /dev/null +++ b/Agent/ballmer/drink-library.json @@ -0,0 +1,95 @@ +{ + "_comment": "The bar menu Ballmer recommends from. Each drink has a vibe score (1-10) reflecting its character for a late-night coding session: balance of enjoyability, complexity, and appropriateness to the Ballmer Peak context. High vibe = sophisticated and enjoyable; low vibe = functional-at-best or disastrous-at-worst.", + "_ethanol_formula": "per ingredient: oz * 29.5735 ml/oz * abv * 0.789 g/ml; summed per drink", + "_vibe_scale": "1=last resort, 5=functional, 7=good choice, 9=excellent, 10=peak Ballmer energy", + "drinks": [ + {"name": "Lager (12 oz)", "category": "beer", "vibe": 6, "price": 7.00, + "notes": "reliable, sessionable, no surprises", + "ingredients": [{"name": "lager", "oz": 12.0, "abv": 0.05}]}, + {"name": "IPA (12 oz)", "category": "beer", "vibe": 7, "price": 8.00, + "notes": "craft credibility, hoppy complexity", + "ingredients": [{"name": "IPA", "oz": 12.0, "abv": 0.065}]}, + {"name": "Red wine (5 oz)", "category": "wine", "vibe": 7, "price": 10.00, + "notes": "contemplative, slow-sipping energy", + "ingredients": [{"name": "red wine", "oz": 5.0, "abv": 0.135}]}, + {"name": "White wine (5 oz)", "category": "wine", "vibe": 7, "price": 10.00, + "notes": "crisp, clean, keeps you sharp", + "ingredients": [{"name": "white wine", "oz": 5.0, "abv": 0.12}]}, + {"name": "Sparkling (5 oz)", "category": "wine", "vibe": 8, "price": 12.00, + "notes": "celebratory, effervescent, light on ethanol", + "ingredients": [{"name": "sparkling wine", "oz": 5.0, "abv": 0.12}]}, + {"name": "Whiskey, neat (2 oz)", "category": "spirit", "vibe": 9, "price": 12.00, + "notes": "no-nonsense, maximum ethanol efficiency, programmer's handshake", + "ingredients": [{"name": "whiskey", "oz": 2.0, "abv": 0.40}]}, + {"name": "Whiskey, rocks (2 oz)", "category": "spirit", "vibe": 8, "price": 12.00, + "notes": "same ethanol as neat, slightly more casual", + "ingredients": [{"name": "whiskey", "oz": 2.0, "abv": 0.40}]}, + {"name": "Double whiskey, rocks (4 oz)", "category": "spirit", "vibe": 8, "price": 20.00, + "notes": "the serious pour β twice the ethanol, handle with care", + "ingredients": [{"name": "whiskey", "oz": 4.0, "abv": 0.40}]}, + {"name": "Vodka soda", "category": "highball", "vibe": 5, "price": 9.00, + "notes": "functional, low-calorie, zero personality", + "ingredients": [{"name": "vodka", "oz": 1.5, "abv": 0.40}, + {"name": "soda water", "oz": 4.0, "abv": 0.0}]}, + {"name": "Gin & tonic", "category": "highball", "vibe": 8, "price": 11.00, + "notes": "botanical, crisp, the de facto developer drink", + "ingredients": [{"name": "gin", "oz": 2.0, "abv": 0.40}, + {"name": "tonic", "oz": 4.0, "abv": 0.0}]}, + {"name": "Margarita", "category": "cocktail", "vibe": 7, "price": 13.00, + "notes": "tequila + triple sec + lime, festive and sharp", + "ingredients": [{"name": "tequila", "oz": 2.0, "abv": 0.40}, + {"name": "triple sec", "oz": 1.0, "abv": 0.30}, + {"name": "lime juice", "oz": 1.0, "abv": 0.0}]}, + {"name": "Manhattan", "category": "cocktail", "vibe": 9, "price": 14.00, + "notes": "rye + sweet vermouth, the Wall Street coder's drink", + "ingredients": [{"name": "rye whiskey", "oz": 2.0, "abv": 0.45}, + {"name": "sweet vermouth", "oz": 1.0, "abv": 0.16}, + {"name": "bitters", "oz": 0.03, "abv": 0.44}]}, + {"name": "Negroni", "category": "cocktail", "vibe": 9, "price": 14.00, + "notes": "equal parts gin/Campari/vermouth, bittersweet complexity", + "ingredients": [{"name": "gin", "oz": 1.0, "abv": 0.40}, + {"name": "Campari", "oz": 1.0, "abv": 0.24}, + {"name": "sweet vermouth", "oz": 1.0, "abv": 0.16}]}, + {"name": "Dry martini", "category": "cocktail", "vibe": 10, "price": 14.00, + "notes": "gin-forward, zero dilution, peak Ballmer Peak energy", + "ingredients": [{"name": "gin", "oz": 2.5, "abv": 0.40}, + {"name": "dry vermouth", "oz": 0.5, "abv": 0.18}]}, + {"name": "Old fashioned", "category": "cocktail", "vibe": 9, "price": 13.00, + "notes": "bourbon + sugar + bitters, the classic for a reason", + "ingredients": [{"name": "bourbon", "oz": 2.0, "abv": 0.45}, + {"name": "bitters", "oz": 0.06, "abv": 0.44}]}, + {"name": "Mojito", "category": "cocktail", "vibe": 7, "price": 12.00, + "notes": "white rum + lime + soda + mint, refreshing and alert", + "ingredients": [{"name": "white rum", "oz": 2.0, "abv": 0.40}, + {"name": "lime juice", "oz": 1.0, "abv": 0.0}, + {"name": "soda water", "oz": 2.0, "abv": 0.0}]}, + {"name": "Cosmopolitan", "category": "cocktail", "vibe": 6, "price": 12.00, + "notes": "vodka + triple sec + cranberry, fun but dated", + "ingredients": [{"name": "vodka", "oz": 1.5, "abv": 0.40}, + {"name": "triple sec", "oz": 0.5, "abv": 0.30}, + {"name": "cranberry juice", "oz": 1.0, "abv": 0.0}, + {"name": "lime juice", "oz": 0.5, "abv": 0.0}]}, + {"name": "Espresso martini", "category": "cocktail", "vibe": 8, "price": 14.00, + "notes": "vodka + coffee liqueur + espresso, dual-purpose productivity hack", + "ingredients": [{"name": "vodka", "oz": 1.5, "abv": 0.40}, + {"name": "coffee liqueur", "oz": 0.5, "abv": 0.20}, + {"name": "espresso", "oz": 1.0, "abv": 0.0}]}, + {"name": "Aperol spritz", "category": "cocktail", "vibe": 7, "price": 11.00, + "notes": "low-ABV aperitivo, good for a slow climb", + "ingredients": [{"name": "prosecco", "oz": 3.0, "abv": 0.11}, + {"name": "Aperol", "oz": 2.0, "abv": 0.11}, + {"name": "soda water", "oz": 1.0, "abv": 0.0}]}, + {"name": "Long Island iced tea", "category": "cocktail", "vibe": 2, "price": 15.00, + "notes": "four spirits at once β blows past the ceiling, no finesse", + "ingredients": [{"name": "vodka", "oz": 0.5, "abv": 0.40}, + {"name": "gin", "oz": 0.5, "abv": 0.40}, + {"name": "white rum", "oz": 0.5, "abv": 0.40}, + {"name": "tequila", "oz": 0.5, "abv": 0.40}, + {"name": "triple sec", "oz": 0.5, "abv": 0.30}, + {"name": "cola", "oz": 1.0, "abv": 0.0}, + {"name": "lemon juice", "oz": 0.75, "abv": 0.0}]}, + {"name": "Water / soda (NA)", "category": "non-alcoholic", "vibe": 1, "price": 3.00, + "notes": "the soft-nudge option β only when past ceiling", + "ingredients": [{"name": "soda water", "oz": 12.0, "abv": 0.0}]} + ] +} diff --git a/Agent/ballmer/reference/model-assumptions.md b/Agent/ballmer/reference/model-assumptions.md new file mode 100644 index 0000000..9ff889c --- /dev/null +++ b/Agent/ballmer/reference/model-assumptions.md @@ -0,0 +1,65 @@ +# Model Assumptions & Sources (the agent's "runbook") + +Every physiological constant Ballmer uses, with its source and plausible range. +This is the analog of `runbooks/*.md` in the Always-On Ops exercise β the +reference the agent reasons against. The authoritative copy of these values +lives in `ballmer/config.py`; this doc explains the *why*. + +## BAC model: Widmark + Watson + first-order absorption + +**Core relation (units: A grams, W kg, r dimensionless, BAC in % = g/100mL):** + + BAC%_peak = A / (r * W_kg * 10) + +Derivation in `ballmer/bac_model.py` module docstring. + +### r-factor β Watson TBW with blood-water correction +- Watson PE, Watson ID, Batt RD. *Total body water volumes for adult males and + females estimated from simple anthropometric measurements.* Am J Clin Nutr + 1980;33(1):27-39. +- Male TBW(L) = 2.447 β 0.09516Β·age + 0.1074Β·height_cm + 0.3362Β·weight_kg +- Female TBW(L) = β2.097 + 0.1069Β·height_cm + 0.2466Β·weight_kg +- **r = TBW / (0.806 Β· weight_kg).** The 0.806 is the water fraction of whole + blood; Widmark's r is referenced to *blood*, Watson's TBW to *total body* + water, so the conversion is required. A naive `r = TBW/weight` gives ~0.52 for + the demo profile, which is too low and breaks the sanity anchors below. +- **Demo profile (73 in / 220 lb / 39 / M):** TBW β 52.2 L, **r β 0.649** + (expected band 0.64β0.66). Below the flat Widmark 0.68 because a heavier person + has proportionally more fat (less body water) β so a flat 0.68 would + *underestimate* peak BAC here. + +### Elimination Ξ² (zero-order) +- Default **0.015 %/hr**; population range **~0.012β0.020 %/hr**. +- Jones AW. *Evidence-based survey of the elimination rates of ethanol from + blood.* Forensic Sci Int 2010. Midpoint chosen. +- Zero-order (constant-rate) is the defining Widmark approximation; true kinetics + are saturable (MichaelisβMenten) but ~zero-order at relevant concentrations. + +### Absorption k_a (first-order) β THE WEAKEST LINK +- **Absorption kinetics is the least reliable part of any Widmark-family model.** +- Modeled as first-order: absorbed_fraction(t) = 1 β exp(βk_aΒ·(tβt_dose)). +- Default **k_a = 2.5 /hr** (tΒ½ β 17 min, Tmax β 30β60 min). Range tΒ½ 10β60 min + β k_a β 0.7β4.2 /hr. Order-of-magnitude consistent with controlled-dosing + literature; treat as an approximation. +- **Food modifier** multiplies k_a: empty Γ1.4, light Γ1.0, full Γ0.6 (food slows + gastric emptying β slower, lower, broader peak). Coarse engineering values. + +### Constants +- Ethanol density **0.789 g/mL** at 20 Β°C (CRC Handbook). +- US standard drink = **14 g** ethanol (NIAAA) β framing only, not used in engine. +- 1 fl oz = 29.5735 mL; 1 in = 2.54 cm; 1 lb = 0.45359237 kg. + +## Sanity anchors (baked into tests/test_bac_model.py) +- One 14 g standard drink, demo profile, **before elimination** β ~0.0216% (the + brief's "0.018β0.020%", a touch higher because r=0.649 < 0.68). +- The *realized* continuous-model peak of one drink is lower (~0.0085%) because + Ξ² eliminates a large share of one small drink during the ~30 min it absorbs. + This is why hitting the (absurd) 0.13% band requires many drinks close together. + +## Known limitations +- First-order single-compartment absorption; no first-pass/bioavailability term. +- TBWβr blood-water correction is a simplification of a genuinely messy + physiological relationship. +- Euler integration at 1-minute steps. +- **TODO: validate against published BAC time-course data.** "The demo runs" is + NOT "the model is right." The faked tab proves the wiring, not the accuracy. diff --git a/Agent/ballmer/reference/safety-policy.md b/Agent/ballmer/reference/safety-policy.md new file mode 100644 index 0000000..80cb8df --- /dev/null +++ b/Agent/ballmer/reference/safety-policy.md @@ -0,0 +1,32 @@ +# Safety Policy (the agent's "compliance-policy.md") + +This is the policy Ballmer enforces on every tick. It is the analog of +`compliance-policy.md` in the Always-On Ops exercise. + +## β οΈ The target band is a JOKE + +The 0.129%β0.138% BAC "Ballmer Peak" comes from **XKCD comic #323**. It is a +gag. It is **far above every legal driving limit on Earth** (0.08% US, 0.05% +much of the world, 0.00β0.02% for many novice/commercial drivers). It is **not** +a health, performance, or safety recommendation. Real BAC in this range means +significant impairment. + +## Hard rules + +1. **Never tie any recommendation to driving or operating machinery.** If a user + mentions driving, riding a bike, operating equipment, or similar, the agent + refuses to recommend alcohol and says why. +2. **Safety ceiling (soft nudge).** Above the configurable `safety_ceiling` + (default 0.15%), the agent **stops recommending alcohol** regardless of the + target band, recommends the non-alcoholic option, and keeps monitoring as BAC + declines. (Chosen behavior: soft nudge, not hard refuse β the loop stays + alive so the user keeps getting guidance on the way down.) +3. **The model is an approximation.** BAC predictions are from a Widmark-family + model with first-order absorption. Individual variation is large. Treat + outputs as illustrative, never as a measured BAC. + +## What the agent will say up front + +> The "Ballmer Peak" is a comic-strip joke. The target band is above every legal +> driving limit and is not safe or health advice. Do not drive. This is a +> modeling demo. diff --git a/Agent/ballmer/run_demo.py b/Agent/ballmer/run_demo.py new file mode 100644 index 0000000..815802e --- /dev/null +++ b/Agent/ballmer/run_demo.py @@ -0,0 +1,112 @@ +"""Ballmer demo β fully hands-free. + +Flow (per the brief): + load fake profile -> compute r-factor -> load fake tab -> run BAC-vs-time + curve -> rank the next drink -> print the recommendation, projected peak BAC, + time-in-target-window, and EVERY assumption used -> then run the always-on + session across the whole night and render the dashboard. + +Run from this directory: + python run_demo.py + python run_demo.py --no-color # plain text + python run_demo.py --empty-stomach # change the food modifier +""" + +import argparse +import sys + +# Windows terminals default to cp1252, which can't encode the box-drawing / +# block / emoji glyphs in the dashboard. Force UTF-8 on stdout where possible. +try: + sys.stdout.reconfigure(encoding="utf-8") +except (AttributeError, ValueError): + pass + +from ballmer import config +from ballmer.agent import load_state, run_session, tick +from ballmer.bac_model import watson_tbw_liters +from ballmer.dashboard import gauge, session_dashboard, status_banner +from ballmer.recommend import recommend_next + +SAFETY_BANNER = """\ +ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ +β β THE "BALLMER PEAK" (0.129-0.138% BAC) IS A JOKE FROM XKCD #323. β +β It is FAR above every legal driving limit and is NOT health, safety, β +β or performance advice. Do NOT drive or operate machinery. This is a β +β BAC-modeling demo. Real BAC in this range means serious impairment. β +ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ""" + + +def main(argv=None): + p = argparse.ArgumentParser(description="Ballmer BAC always-on demo") + p.add_argument("--no-color", action="store_true", help="disable ANSI colors") + p.add_argument("--empty-stomach", action="store_true", help="food_state=empty") + p.add_argument("--full-stomach", action="store_true", help="food_state=full") + p.add_argument("--no-auto-consume", action="store_true", + help="don't let the simulated drinker follow recommendations") + args = p.parse_args(argv) + + if args.no_color: + import ballmer.dashboard as d + d._RESET = "" + d._C = {k: "" for k in d._C} + + food = ("empty" if args.empty_stomach else + "full" if args.full_stomach else config.DEFAULT_FOOD_STATE) + + print(SAFETY_BANNER) + + # 1-2. Profile + r-factor (show the unit conversions and the Watson math). + st = load_state(".") + height_cm = st.profile["height_in"] * config.IN_TO_CM + weight_kg = st.profile["weight_lb"] * config.LB_TO_KG + tbw = watson_tbw_liters(st.profile["sex"], st.profile["age"], height_cm, weight_kg) + print(f"\nProfile: {st.profile['height_in']}in / {st.profile['weight_lb']}lb / " + f"{st.profile['age']}y / {st.profile['sex']}") + print(f" -> {height_cm:.1f} cm, {weight_kg:.1f} kg") + print(f" Watson TBW = {tbw:.1f} L r = TBW/(0.806*kg) = {st.body.r:.4f} " + f"(flat Widmark would be {config.FLAT_WIDMARK_R['male']}; ours is lower " + f"=> higher peak BAC, as expected for a heavier build)") + print(f" Ξ² = {st.body.beta} %/hr, k_a = {st.body.k_a_base} /hr, food = {food}") + + # 3. Seeded tab. + print(f"\nSeeded tab (from state/tab.json), session start " + f"{st.session_start.strftime('%I:%M %p').lstrip('0')}:") + for ev in st.events: + print(f" +{ev.t_hours:.2f}h {ev.label:<16} " + f"{ev.grams:.1f} g ethanol ({ev.grams / config.STANDARD_DRINK_G:.1f} std)") + + # 4. One-shot recommendation "right now" (at the last drink time) β the + # required print of recommendation + projected peak + time-in-window + + # all assumptions + the top candidates. + now = max((e.t_hours for e in st.events), default=0.0) + rec = recommend_next(st.events, st.body, st.library, now, + window=st.window, ceiling=st.ceiling, food_state=food) + print(f"\n--- Recommendation at {st.session_start.strftime('%I:%M %p').lstrip('0')} " + f"+{now:.2f}h ---") + print(f" {rec.message}") + print(f"\n Top candidates (scored by dwell-in-band minus overshoot penalty):") + print(f" {'drink':<22}{'peak%':>7}{'min_in':>8}{'min_above':>10}{'score':>8}") + for c in rec.ranked[:5]: + name = c.drink.name if c.drink else "HOLD (no drink)" + fc = c.forecast + print(f" {name:<22}{fc.projected_peak_bac:>7.3f}" + f"{fc.minutes_in_window:>8.0f}{fc.minutes_above_window:>10.0f}{c.score:>8.1f}") + print(f"\n Assumptions used (auditable): {rec.ranked[0].forecast.assumptions}") + + # 5-6. Run the whole night hands-free + render the dashboard. + session = run_session(".", auto_consume=not args.no_auto_consume, food_state=food) + print("\n Live status at each tick:") + for tk in session.ticks: + print(" " + status_banner(tk, st.window, st.ceiling)) + if tk.action != "HOLD": + print(" " + gauge(tk.current_bac, st.window, st.ceiling)) + + print(session_dashboard(session)) + print(f"Transcript written to state/log/. " + f"Run `python -m pytest -q` for the engine tests.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Agent/ballmer/serve_dashboard.py b/Agent/ballmer/serve_dashboard.py new file mode 100644 index 0000000..d9973d1 --- /dev/null +++ b/Agent/ballmer/serve_dashboard.py @@ -0,0 +1,192 @@ +"""Local live web dashboard for Ballmer β standard library only, no pip installs. + +Runs the deterministic always-on session once, then plays it back tick-by-tick +on a wall-clock cadence so the browser shows BAC climbing into the band, the +in-range indicator flipping, and the burndown countdown β live. + +Run: + python serve_dashboard.py # http://127.0.0.1:8765 , opens browser + python serve_dashboard.py --port 9000 --speed 1.5 + python serve_dashboard.py --empty-stomach --no-open --no-loop + +Everything is served from localhost; nothing leaves your machine. +""" + +import argparse +import json +import threading +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from ballmer import config +from ballmer.agent import run_session +from ballmer.web_data import build_frames, session_meta + +ROOT = Path(__file__).parent +HTML = (ROOT / "web" / "dashboard.html").read_text(encoding="utf-8") + + +class Playback: + """Maps wall-clock elapsed time to a frame cursor, looping with an end pause.""" + def __init__(self, frames, speed=2.0, loop=True, end_pause=4.0): + self.frames = frames + self.n = len(frames) + self.speed = speed # real seconds per simulated tick + self.loop = loop + self.end_pause = end_pause + self.t0 = time.monotonic() + + def current(self): + elapsed = time.monotonic() - self.t0 + if not self.loop: + return self.frames[min(self.n - 1, int(elapsed / self.speed))] + cycle = self.n * self.speed + self.end_pause + phase = elapsed % cycle + return self.frames[min(self.n - 1, int(phase / self.speed))] + + +class AppState: + """Thread-safe container for the live playback state and config.""" + + def __init__(self, frames, meta, cfg): + self._lock = threading.RLock() + self._playback = Playback(frames, speed=cfg["speed"], loop=cfg["loop"]) + self._meta = meta + self._cfg = cfg + + def frame(self): + with self._lock: + return self._playback.current() + + def get_meta(self): + with self._lock: + return dict(self._meta) + + def get_config(self): + with self._lock: + return dict(self._cfg) + + def apply(self, patch, root): + """Merge patch into config. Re-runs simulation for physiology changes.""" + SIM_KEYS = ("food_state", "auto_consume", "start_time", "profile_overrides") + needs_sim = any(k in patch for k in SIM_KEYS) + with self._lock: + if "profile_overrides" in patch: + existing = self._cfg.get("profile_overrides") or {} + self._cfg["profile_overrides"] = {**existing, **patch.pop("profile_overrides")} + self._cfg.update(patch) + cfg = self._cfg + if needs_sim: + session = run_session( + str(root), + auto_consume=cfg["auto_consume"], + food_state=cfg["food_state"], + write_log=False, + start_time_str=cfg.get("start_time"), + profile_overrides=cfg.get("profile_overrides") or None, + ) + frames = build_frames(session) + self._meta = session_meta(session) + self._playback = Playback(frames, speed=cfg["speed"], loop=cfg["loop"]) + else: + self._playback.speed = cfg["speed"] + self._playback.loop = cfg["loop"] + + +def make_handler(app_state: AppState, root: Path): + class Handler(BaseHTTPRequestHandler): + def _send(self, body, ctype="application/json", status=200): + data = body.encode("utf-8") if isinstance(body, str) else body + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path.startswith("/api/meta"): + self._send(json.dumps(app_state.get_meta())) + elif self.path.startswith("/api/state"): + self._send(json.dumps(app_state.frame())) + elif self.path.startswith("/api/config"): + self._send(json.dumps(app_state.get_config())) + elif self.path in ("/", "/index.html"): + self._send(HTML, "text/html; charset=utf-8") + else: + self.send_error(404) + + def do_POST(self): + if self.path.startswith("/api/config"): + length = int(self.headers.get("Content-Length", 0)) + patch = json.loads(self.rfile.read(length)) + app_state.apply(patch, root) + self._send(json.dumps({"ok": True})) + elif self.path.startswith("/api/llm_reason"): + try: + from ballmer.llm_agent import llm_reason + from ballmer.drinks import load_library + frame = app_state.frame() + library = load_library(root / "drink-library.json") + result = llm_reason(frame, library) + self._send(json.dumps(result)) + except Exception as exc: + self._send(json.dumps({"error": str(exc)}), status=500) + else: + self.send_error(404) + + def log_message(self, *a): + pass + + return Handler + + +def main(argv=None): + p = argparse.ArgumentParser(description="Ballmer live web dashboard (localhost)") + p.add_argument("--port", type=int, default=8765) + p.add_argument("--speed", type=float, default=2.0, help="real seconds per simulated tick") + p.add_argument("--no-loop", action="store_true") + p.add_argument("--no-open", action="store_true", help="don't auto-open the browser") + p.add_argument("--empty-stomach", action="store_true") + p.add_argument("--full-stomach", action="store_true") + p.add_argument("--no-auto-consume", action="store_true") + args = p.parse_args(argv) + + food = ("empty" if args.empty_stomach else + "full" if args.full_stomach else config.DEFAULT_FOOD_STATE) + + cfg = { + "food_state": food, + "auto_consume": not args.no_auto_consume, + "speed": args.speed, + "loop": not args.no_loop, + "start_time": None, + "profile_overrides": {}, + } + + session = run_session(str(ROOT), auto_consume=cfg["auto_consume"], + food_state=cfg["food_state"], write_log=False) + frames = build_frames(session) + meta = session_meta(session) + cfg["start_time"] = meta["start_time"] + + app_state = AppState(frames, meta, cfg) + server = ThreadingHTTPServer(("127.0.0.1", args.port), make_handler(app_state, ROOT)) + url = f"http://127.0.0.1:{args.port}" + print(f"Ballmer dashboard live at {url}") + print(f" {len(frames)} ticks Β· {args.speed}s/tick Β· " + f"{'looping' if not args.no_loop else 'play once'} Β· food={food}") + print(" Ctrl-C to stop.") + if not args.no_open: + threading.Timer(0.6, lambda: webbrowser.open(url)).start() + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nstopped.") + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/Agent/ballmer/state/log/.gitkeep b/Agent/ballmer/state/log/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Agent/ballmer/state/profile.json b/Agent/ballmer/state/profile.json new file mode 100644 index 0000000..c8f8b56 --- /dev/null +++ b/Agent/ballmer/state/profile.json @@ -0,0 +1,7 @@ +{ + "_comment": "FAKE hardcoded profile for the demo. TODO: replace with interactive intake (see ballmer/stubs.py).", + "height_in": 73, + "weight_lb": 220, + "age": 39, + "sex": "male" +} diff --git a/Agent/ballmer/state/tab.json b/Agent/ballmer/state/tab.json new file mode 100644 index 0000000..0646595 --- /dev/null +++ b/Agent/ballmer/state/tab.json @@ -0,0 +1,8 @@ +{ + "_comment": "The live bar tab β the always-on agent's analog of issues/*.json: the mutable state the loop reads each tick. FAKE seed data for the demo: 2 drinks already consumed. Drink names must exist in drink-library.json. food_state is per-drink (empty|light|full).", + "session_start": "2026-06-23T19:00:00", + "consumed": [ + {"name": "Negroni", "time": "2026-06-23T19:15:00", "food_state": "light"}, + {"name": "Lager (12 oz)", "time": "2026-06-23T20:00:00", "food_state": "light"} + ] +} diff --git a/Agent/ballmer/state/target.json b/Agent/ballmer/state/target.json new file mode 100644 index 0000000..17aea5a --- /dev/null +++ b/Agent/ballmer/state/target.json @@ -0,0 +1,8 @@ +{ + "_comment": "Target band + safety ceiling. The band is the XKCD #323 joke. The ceiling is an engineering choice, NOT a medical threshold. All are %BAC (g/100mL). See reference/safety-policy.md.", + "target_low": 0.129, + "target_high": 0.138, + "safety_ceiling": 0.15, + "tick_interval_min": 20, + "session_duration_hours": 5.0 +} diff --git a/Agent/ballmer/tests/test_bac_model.py b/Agent/ballmer/tests/test_bac_model.py new file mode 100644 index 0000000..a2ed7f3 --- /dev/null +++ b/Agent/ballmer/tests/test_bac_model.py @@ -0,0 +1,196 @@ +"""Unit tests for the BAC engine, checked against hand calculations. + +Run from the outer `ballmer/` directory: + python -m pytest -q + # or, without pytest installed: + python tests/test_bac_model.py +""" + +import math +import os +import sys + +# Allow `import ballmer...` when run directly (not just under pytest). +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ballmer import config +from ballmer.bac_model import ( + BodyParams, + DrinkEvent, + bac_curve, + ethanol_grams, + forecast, + peak_bac, + r_from_profile, + watson_tbw_liters, + weight_kg_from_profile, + widmark_r, +) + +PROFILE = {"height_in": 73, "weight_lb": 220, "age": 39, "sex": "male"} + + +def approx(a, b, tol): + return abs(a - b) <= tol + + +# -------------------------------------------------------------------------- +# Watson TBW + r-factor +# -------------------------------------------------------------------------- +def test_watson_tbw_demo_profile(): + # height 73in -> 185.42cm, weight 220lb -> 99.79kg + # TBW = 2.447 - 0.09516*39 + 0.1074*185.42 + 0.3362*99.79 = 52.2 L (hand calc) + height_cm = PROFILE["height_in"] * config.IN_TO_CM + weight_kg = PROFILE["weight_lb"] * config.LB_TO_KG + tbw = watson_tbw_liters("male", 39, height_cm, weight_kg) + assert approx(tbw, 52.2, 0.3), f"TBW {tbw:.2f} L not ~52.2" + + +def test_r_factor_in_expected_band(): + # With the blood-water correction r = TBW/(0.806*W) -> ~0.649, in the + # prompt's expected 0.64-0.66 band (and below the flat 0.68, as expected + # for a heavier individual with more fat). + r = r_from_profile(PROFILE) + assert 0.64 <= r <= 0.66, f"r={r:.4f} outside expected 0.64-0.66" + assert r < config.FLAT_WIDMARK_R["male"] + + +def test_widmark_r_formula_directly(): + # r = TBW / (0.806 * W). Plug TBW=52.2, W=99.79. + expected = 52.2 / (config.BLOOD_WATER_FRACTION * 99.79) + got = widmark_r(52.2, 99.79) + assert approx(got, expected, 1e-9) + + +# -------------------------------------------------------------------------- +# Ethanol mass from recipe +# -------------------------------------------------------------------------- +def test_ethanol_grams_standard_shot(): + # 44 mL (1.5 oz) of 40% spirit -> 44 * 0.40 * 0.789 = 13.89 g (~1 std drink) + g = ethanol_grams(44.0, 0.40) + assert approx(g, 13.89, 0.05), f"{g:.2f} g not ~13.89" + + +def test_ethanol_grams_zero_for_water(): + assert ethanol_grams(355.0, 0.0) == 0.0 + + +# -------------------------------------------------------------------------- +# Widmark peak (no-elimination) β the closed-form anchor +# -------------------------------------------------------------------------- +def test_single_drink_widmark_peak_closed_form(): + # BAC%_peak = A / (r * W * 10). 14 g, r=0.649, W=99.79. + body = BodyParams.from_profile(PROFILE) + expected = 14.0 / (body.r * body.weight_kg * 10.0) + assert approx(expected, 0.0216, 0.001), f"closed-form peak {expected:.4f} not ~0.0216" + + +def test_single_drink_peak_before_elimination_sanity(): + # THE sanity check from the brief: one standard drink (~14 g) should land + # "around 0.018-0.020% BAC BEFORE ELIMINATION" for this profile. That is the + # closed-form Widmark peak A/(r*W*10) = ~0.0216 (a touch above 0.020 because + # our Watson-derived r=0.649 is slightly below the flat 0.68). We assert a + # band around the brief's number. If this is WILDLY off, suspect the Watson + # unit conversion (in -> cm, lb -> kg) first. + body = BodyParams.from_profile(PROFILE) + peak_before_elim = 14.0 / (body.r * body.weight_kg * 10.0) + assert 0.018 <= peak_before_elim <= 0.023, ( + f"before-elimination peak {peak_before_elim:.4f} outside sanity band") + + +def test_single_drink_realized_peak_is_lower_than_closed_form(): + # Documents (and regression-locks) the physically-correct fact that a single + # small drink's REALIZED continuous-model peak is well below its closed-form + # peak, because elimination (0.015%/hr) removes a large share of one drink's + # ~0.02% during the ~30 min it takes to absorb. This is why reaching the + # (absurd) 0.13% Ballmer band requires MANY drinks close together. + body = BodyParams.from_profile(PROFILE) + events = [DrinkEvent(t_hours=0.0, grams=14.0, label="std drink", food_state="light")] + times, bac = bac_curve(events, body, t_end=6.0) + _, realized = peak_bac(times, bac) + closed_form = 14.0 / (body.r * body.weight_kg * 10.0) + assert 0.006 <= realized <= 0.012, f"realized peak {realized:.4f} off expected ~0.0085" + assert realized < closed_form + + +# -------------------------------------------------------------------------- +# Dynamics: absorption rise, elimination decline, non-negativity +# -------------------------------------------------------------------------- +def test_curve_never_negative(): + body = BodyParams.from_profile(PROFILE) + events = [DrinkEvent(0.0, 14.0, "d", "light")] + _, bac = bac_curve(events, body, t_end=12.0) + assert min(bac) >= 0.0 + + +def test_bac_returns_to_zero_eventually(): + body = BodyParams.from_profile(PROFILE) + events = [DrinkEvent(0.0, 14.0, "d", "light")] + _, bac = bac_curve(events, body, t_end=12.0) + # Not exactly 0.0: first-order absorption is asymptotic, so an infinitesimal + # (~1e-17) tail re-seeds each step once BAC hits zero. "Essentially zero". + assert bac[-1] < 1e-4, f"BAC should be ~0 within 12h, got {bac[-1]:.6f}" + + +def test_elimination_is_monotonic_after_peak(): + body = BodyParams.from_profile(PROFILE) + events = [DrinkEvent(0.0, 14.0, "d", "light")] + times, bac = bac_curve(events, body, t_end=10.0) + i_peak = max(range(len(bac)), key=lambda j: bac[j]) + # after the peak, until it hits zero, the series must be non-increasing + tail = bac[i_peak:] + nonzero_tail = [v for v in tail if v > 0] + assert all(nonzero_tail[k] >= nonzero_tail[k + 1] - 1e-9 + for k in range(len(nonzero_tail) - 1)) + + +def test_empty_stomach_peaks_higher_and_earlier_than_full(): + body = BodyParams.from_profile(PROFILE) + empty = bac_curve([DrinkEvent(0.0, 14.0, "d", "empty")], body, t_end=6.0) + full = bac_curve([DrinkEvent(0.0, 14.0, "d", "full")], body, t_end=6.0) + te, pe = peak_bac(*empty) + tf, pf = peak_bac(*full) + assert pe > pf, "empty stomach should peak higher" + assert te < tf, "empty stomach should peak earlier" + + +def test_two_drinks_stack_higher_than_one(): + body = BodyParams.from_profile(PROFILE) + one = bac_curve([DrinkEvent(0.0, 14.0, "d", "light")], body, t_end=6.0) + two = bac_curve( + [DrinkEvent(0.0, 14.0, "d1", "light"), DrinkEvent(0.5, 14.0, "d2", "light")], + body, t_end=6.0, + ) + assert peak_bac(*two)[1] > peak_bac(*one)[1] + + +# -------------------------------------------------------------------------- +# Forecast wiring +# -------------------------------------------------------------------------- +def test_forecast_reports_assumptions_and_window_time(): + body = BodyParams.from_profile(PROFILE) + current = [DrinkEvent(0.0, 28.0, "negroni", "light")] + candidate = DrinkEvent(1.0, 14.0, "beer", "light") # t re-stamped to now inside + fc = forecast(current, candidate, body, + window=(config.TARGET_LOW, config.TARGET_HIGH), + now_hours=1.0, horizon_hours=3.0) + assert fc.projected_peak_bac > 0 + assert fc.assumptions["r"] == round(body.r, 4) + assert fc.assumptions["hours_since_last_drink"] == 1.0 + assert fc.minutes_in_window >= 0 + assert fc.minutes_above_window >= 0 + + +if __name__ == "__main__": + # Lightweight runner so the file works without pytest installed. + failures = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + print(f"PASS {name}") + except AssertionError as e: + failures += 1 + print(f"FAIL {name}: {e}") + print(f"\n{'ALL PASSED' if not failures else f'{failures} FAILED'}") + sys.exit(1 if failures else 0) diff --git a/Agent/ballmer/web/dashboard.html b/Agent/ballmer/web/dashboard.html new file mode 100644 index 0000000..9a7575c --- /dev/null +++ b/Agent/ballmer/web/dashboard.html @@ -0,0 +1,465 @@ + + +
+ + +| time | BAC% | status | action | BAC + | price | burndown |
|---|