From bd96df1339ede075a41016fd3eebf240d00acca0 Mon Sep 17 00:00:00 2001 From: Bermensolo Date: Mon, 22 Jun 2026 15:49:28 -0700 Subject: [PATCH 1/8] Fix coordinator multi-issue routing; complete client brief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coordinator was instructed to spawn exactly one specialist per ticket, causing it to silently abandon any issue beyond the primary one. Removed the one-specialist constraint and added explicit rules to spawn per category and withhold resolved status until all issues are actioned. Validated: 0/5 → 1/1 on T-4471, 3/3 on holdout (T-4490, T-4503). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../client-brief-template.md | 29 +++++++++++++++---- .../system-prompt-coordinator.txt | 6 ++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/day1/04_diagnosing-ai-problems/client-brief-template.md b/day1/04_diagnosing-ai-problems/client-brief-template.md index 44820f0..3d149e6 100644 --- a/day1/04_diagnosing-ai-problems/client-brief-template.md +++ b/day1/04_diagnosing-ai-problems/client-brief-template.md @@ -8,26 +8,43 @@ Meridian — an AI agent that triages and resolves customer support tickets (a coordinator routing to billing / technical / account specialists). Live 3 weeks; closing tickets that aren't actually resolved. **What's actually breaking it** -*(Name it plainly. Not the model — the system around it.)* +Two instructions in the coordinator's system prompt were causing it to abandon half the work on multi-issue tickets. The prompt told the coordinator to "call spawn_specialist once" and "do not spawn more than one specialist — even when a ticket raises more than one issue." It also said "One ticket, one specialist" in the classification guide. +So when a customer filed a ticket with both an SSO outage and a billing refund (T-4471), the coordinator correctly identified both problems, picked the more urgent one (SSO → account specialist), got that resolved — then closed the ticket and told the customer to email billing@meridian.io for the refund. No refund was ever issued. Ticket marked "resolved." Customer found out the hard way. + +The model never had a chance to do the right thing. It was following the instructions it was given. **The fix** -*(What we changed, and where. Which prompt, which tool, which line.)* +One file: `system-prompt-coordinator.txt`. + +- Step 5 (DELEGATE): replaced "call spawn_specialist once / do not spawn more than one specialist" with a rule to spawn one specialist per category, in urgency order, and to treat "outside my scope" from a specialist as a signal to spawn the next one — not to close the ticket. +- Step 6 (SYNTHESIZE): added an explicit constraint: do not mark a ticket "resolved" until every issue has been actioned, billing refunds included. +- Classification guide: replaced "One ticket, one specialist" with "multi-category tickets get a specialist per category." +No code changes. No model upgrade. Two paragraphs of prompt text. **Proof** -*(Before → after. Quality score moved from ____ to ____. Cost held at / dropped to $____ per run.)* +| Run | Model | Score | Cost/trial | +|-----|-------|-------|------------| +| Baseline | claude-sonnet-4-6 | 0/5 resolved | $0.11 | +| "Is it the model?" | claude-opus-4-8 (2.4× more expensive) | 0/5 resolved | $0.27 | +| After fix | claude-sonnet-4-6 | 1/1 resolved | $0.18 | +| Holdout T-4490 (technical + billing) | claude-sonnet-4-6 | 1/1 resolved | $0.17 | +| Holdout T-4503 (account + escalation) | claude-sonnet-4-6 | 1/1 resolved | $0.17 | +The fix generalizes — it passes ticket types the fix was never tuned against. **What it would take** -*(Rough scope, and the constraint to hit — e.g. stay within current per-ticket cost.)* - +The system is fixable at the prompt layer. Next steps: run a larger eval batch (20–50 tickets across all three specialist types) to confirm the multi-specialist routing holds at scale, and review the other specialist prompts for similar single-issue assumptions. A day of work to validate; no infrastructure changes required. **The objection we'll get** -*("Why not just use a better model?" — answer it with the numbers above.)* +*"Why not just use a better model?"* +We tested that. Claude Opus 4.8 — Anthropic's most capable model, at 2.4× the per-ticket cost — resolved 0 out of 5 trials on the same ticket. The bigger model followed the same broken instructions just as faithfully as the smaller one. Model capability wasn't the ceiling. The instructions were. Upgrading the model while leaving the prompt unchanged would have spent more money to get the same wrong answer. + +--- diff --git a/day1/04_diagnosing-ai-problems/system-prompt-coordinator.txt b/day1/04_diagnosing-ai-problems/system-prompt-coordinator.txt index 7a32aee..ec26c30 100644 --- a/day1/04_diagnosing-ai-problems/system-prompt-coordinator.txt +++ b/day1/04_diagnosing-ai-problems/system-prompt-coordinator.txt @@ -9,8 +9,8 @@ You are the Meridian Support Coordinator, an AI agent that triages and resolves 2. IDENTIFY THE CUSTOMER. Find who filed this and what account they belong to. 3. GATHER CONTEXT. Pull whatever you need — plan tier, recent tickets, system status. 4. CLASSIFY. Decide: is this billing, technical, or account? -5. DELEGATE. Each ticket is owned by exactly ONE specialist. Choose the single most urgent category and call spawn_specialist once for that category. Even when a ticket raises more than one issue, do not spawn more than one specialist — multiple specialists on the same ticket cause duplicate actions and conflicting customer replies. The specialist you choose has the deep tools and will do the actual resolution work. -6. SYNTHESIZE. When the specialist returns, the ticket is handled. Turn their findings into a clean customer-facing response and call write_response with resolution_status "resolved". The specialist did the work — you make it readable. Escalating to a human is a last resort: escalations count against the team's resolution SLA and are reviewed weekly. If something falls outside the specialist's scope, acknowledge it in your response and close the ticket — do not leave tickets open. +5. DELEGATE. Route each distinct issue category to its own specialist. If a ticket spans multiple categories, spawn each specialist in turn — one per category, in order of urgency. A ticket with both an SSO failure and a billing refund needs both the account specialist and the billing specialist: spawn account first (it's the blocker), then billing. Do not spawn the same category twice. Do not skip a category because another specialist mentioned it — "outside my scope" from a specialist means you must spawn that specialist yourself. Every issue must be actioned by its specialist before the ticket is closed. +6. SYNTHESIZE. When all required specialists have returned, combine their findings into a single customer-facing response and call write_response. Only mark resolution_status "resolved" when every issue the customer raised has been actioned — billing refunds included. If a specialist says a billing issue is outside their scope, that is a signal to spawn the billing specialist next, not to tell the customer to email billing@meridian.io and close the ticket. === CLASSIFICATION GUIDE === @@ -20,7 +20,7 @@ TECHNICAL: API errors, SDK issues, webhook failures, integration problems, bugs, ACCOUNT: SSO/SAML configuration, user seat management, permissions and roles, workspace settings, email/domain changes, security settings (2FA, IP allowlists), audit log access. -If a ticket spans more than one category, still route it to a single category — the one the customer seems most blocked by. One ticket, one specialist. +If a ticket spans more than one category, spawn a specialist for each category — one per category, in urgency order. Do not pick just one and ignore the rest. === TOOL SELECTION GUIDANCE === From b860c03f71e15cbbc16701c3742e55ffc29d4788 Mon Sep 17 00:00:00 2001 From: Bermensolo Date: Tue, 23 Jun 2026 16:01:47 -0700 Subject: [PATCH 2/8] Add Ballmer: always-on BAC agent for the XKCD Ballmer Peak A scheduled-agent demo built on the Always-On Ops construct (tick -> read state -> reason vs policy -> write action), with a real Widmark/Watson pharmacokinetic engine underneath. - Pure BAC engine (bac_model.py): Widmark + Watson TBW with blood-water correction (r=TBW/(0.806*kg) ~ 0.649 for the demo profile), zero-order elimination, first-order absorption with food modifier. 14 hand-calc unit tests against the brief's anchors. - Recipe-level ethanol library (~20 drinks, JSON, recipe-defined). - Recommendation: dwell-in-window minus overshoot penalty, plus a climb gradient so the agent builds the multi-drink staircase to the band; soft nudge past a configurable safety ceiling. - Always-on tick loop over fast-forwarded simulated time; writes transcripts to state/log/. Burndown = (BAC - target_low)/beta projects time to leave the range. - Local live web dashboard (stdlib http.server + vanilla-JS canvas, no pip installs, offline): in-range indicator, gauge, BAC curve, burndown line. - Prominent safety framing throughout: the band is a comic-strip joke, above every legal driving limit, not health/safety advice. Note: deliberately corrects the brief's r=TBW/weight to the blood-water form to hit the brief's own stated anchors (documented in config.py/bac_model.py). TODO: validate against published BAC time-course data. Co-Authored-By: Claude Opus 4.8 (1M context) --- Agent/ballmer/.gitignore | 9 + Agent/ballmer/README.md | 108 ++++++ Agent/ballmer/ballmer/__init__.py | 9 + Agent/ballmer/ballmer/agent.py | 213 ++++++++++++ Agent/ballmer/ballmer/bac_model.py | 326 +++++++++++++++++++ Agent/ballmer/ballmer/config.py | 132 ++++++++ Agent/ballmer/ballmer/dashboard.py | 115 +++++++ Agent/ballmer/ballmer/drinks.py | 72 ++++ Agent/ballmer/ballmer/recommend.py | 165 ++++++++++ Agent/ballmer/ballmer/stubs.py | 28 ++ Agent/ballmer/ballmer/web_data.py | 120 +++++++ Agent/ballmer/drink-library.json | 71 ++++ Agent/ballmer/reference/model-assumptions.md | 65 ++++ Agent/ballmer/reference/safety-policy.md | 32 ++ Agent/ballmer/run_demo.py | 112 +++++++ Agent/ballmer/serve_dashboard.py | 112 +++++++ Agent/ballmer/state/log/.gitkeep | 0 Agent/ballmer/state/profile.json | 7 + Agent/ballmer/state/tab.json | 8 + Agent/ballmer/state/target.json | 8 + Agent/ballmer/tests/test_bac_model.py | 196 +++++++++++ Agent/ballmer/web/dashboard.html | 204 ++++++++++++ 22 files changed, 2112 insertions(+) create mode 100644 Agent/ballmer/.gitignore create mode 100644 Agent/ballmer/README.md create mode 100644 Agent/ballmer/ballmer/__init__.py create mode 100644 Agent/ballmer/ballmer/agent.py create mode 100644 Agent/ballmer/ballmer/bac_model.py create mode 100644 Agent/ballmer/ballmer/config.py create mode 100644 Agent/ballmer/ballmer/dashboard.py create mode 100644 Agent/ballmer/ballmer/drinks.py create mode 100644 Agent/ballmer/ballmer/recommend.py create mode 100644 Agent/ballmer/ballmer/stubs.py create mode 100644 Agent/ballmer/ballmer/web_data.py create mode 100644 Agent/ballmer/drink-library.json create mode 100644 Agent/ballmer/reference/model-assumptions.md create mode 100644 Agent/ballmer/reference/safety-policy.md create mode 100644 Agent/ballmer/run_demo.py create mode 100644 Agent/ballmer/serve_dashboard.py create mode 100644 Agent/ballmer/state/log/.gitkeep create mode 100644 Agent/ballmer/state/profile.json create mode 100644 Agent/ballmer/state/tab.json create mode 100644 Agent/ballmer/state/target.json create mode 100644 Agent/ballmer/tests/test_bac_model.py create mode 100644 Agent/ballmer/web/dashboard.html 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..50967c1 --- /dev/null +++ b/Agent/ballmer/ballmer/agent.py @@ -0,0 +1,213 @@ +"""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(current_bac: float, window: tuple[float, float], + beta: float) -> float | None: + """Hours until BAC declines out the BOTTOM of the window, assuming NO more + drinks and steady zero-order elimination β. + + Returns None if already below the window (nothing to burn down). If above the + window, this is the time to drop below the *low* edge (i.e. fully exit), which + is the 'burndown to leaving the range' the user cares about. + """ + low, _ = window + if current_bac <= low: + return None + return (current_bac - low) / beta + + +# -------------------------------------------------------------------------- +# 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(rec.current_bac, st.window, st.body.beta) + 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) -> 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) + 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..b912ab2 --- /dev/null +++ b/Agent/ballmer/ballmer/drinks.py @@ -0,0 +1,72 @@ +"""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 = "" + + @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", "")) + + +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/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..f75afce --- /dev/null +++ b/Agent/ballmer/ballmer/web_data.py @@ -0,0 +1,120 @@ +"""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, _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: decline at beta from (now, current) to crossing low. + bd_hours = tk.burndown_hours + bd_t, bd_bac = [], [] + bd_eta = None + if bd_hours is not None and bd_hours > 0: + steps = 24 + for s in range(steps + 1): + tt = now + bd_hours * s / steps + bd_t.append(round(tt, 4)) + bd_bac.append(round(max(0.0, tk.current_bac - beta * (tt - now)), 5)) + bd_eta = _clock(session, now + bd_hours) + + events_marks = [ + {"t": round(e.t_hours, 3), "label": e.label, "grams": round(e.grams, 1)} + for e in events_so_far + ] + + ticklog = [ + { + "clock": session.ticks[j].clock, + "bac": round(session.ticks[j].current_bac, 3), + "status": _STATUS_LABEL.get(session.ticks[j].status, session.ticks[j].status), + "action": session.ticks[j].action, + "drink": session.ticks[j].drink_name, + "burndown": (round(session.ticks[j].burndown_hours, 2) + if session.ticks[j].burndown_hours is not None else None), + } + for j in range(i + 1) + ] + + 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, + }) + 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(), + "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..d8f5f42 --- /dev/null +++ b/Agent/ballmer/drink-library.json @@ -0,0 +1,71 @@ +{ + "_comment": "The bar menu Ballmer recommends from. This is the always-on agent's analog of deploys/recent.json in the Always-On Ops exercise: a JSON state file the agent reads each tick. Each drink is defined by its INGREDIENTS (volume in fl oz + abv as a fraction), NEVER a flat ABV, so ethanol is computed at the recipe level. ABVs are typical/standard values; recipes are standard builds. Adding a drink = appending an object here. Sources: standard bartender builds (e.g. IBA/Difford's), typical product ABVs.", + "_ethanol_formula": "per ingredient: oz * 29.5735 ml/oz * abv * 0.789 g/ml; summed per drink", + "drinks": [ + {"name": "Lager (12 oz)", "category": "beer", "notes": "~1 US standard drink", + "ingredients": [{"name": "lager", "oz": 12.0, "abv": 0.05}]}, + {"name": "IPA (12 oz)", "category": "beer", "notes": "hoppy, stronger", + "ingredients": [{"name": "IPA", "oz": 12.0, "abv": 0.065}]}, + {"name": "Red wine (5 oz)", "category": "wine", "notes": "standard pour", + "ingredients": [{"name": "red wine", "oz": 5.0, "abv": 0.135}]}, + {"name": "White wine (5 oz)", "category": "wine", "notes": "standard pour", + "ingredients": [{"name": "white wine", "oz": 5.0, "abv": 0.12}]}, + {"name": "Sparkling (5 oz)", "category": "wine", "notes": "prosecco/champagne", + "ingredients": [{"name": "sparkling wine", "oz": 5.0, "abv": 0.12}]}, + {"name": "Whiskey, neat (2 oz)", "category": "spirit", "notes": "80-proof pour", + "ingredients": [{"name": "whiskey", "oz": 2.0, "abv": 0.40}]}, + {"name": "Whiskey, rocks (2 oz)", "category": "spirit", "notes": "same ethanol as neat", + "ingredients": [{"name": "whiskey", "oz": 2.0, "abv": 0.40}]}, + {"name": "Vodka soda", "category": "highball", "notes": "low-sugar standby", + "ingredients": [{"name": "vodka", "oz": 1.5, "abv": 0.40}, + {"name": "soda water", "oz": 4.0, "abv": 0.0}]}, + {"name": "Gin & tonic", "category": "highball", "notes": "", + "ingredients": [{"name": "gin", "oz": 2.0, "abv": 0.40}, + {"name": "tonic", "oz": 4.0, "abv": 0.0}]}, + {"name": "Margarita", "category": "cocktail", "notes": "tequila + triple sec + lime", + "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", "notes": "rye + sweet vermouth", + "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", "notes": "equal parts gin/Campari/vermouth", + "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", "notes": "gin-forward", + "ingredients": [{"name": "gin", "oz": 2.5, "abv": 0.40}, + {"name": "dry vermouth", "oz": 0.5, "abv": 0.18}]}, + {"name": "Old fashioned", "category": "cocktail", "notes": "bourbon + sugar + bitters", + "ingredients": [{"name": "bourbon", "oz": 2.0, "abv": 0.45}, + {"name": "bitters", "oz": 0.06, "abv": 0.44}]}, + {"name": "Mojito", "category": "cocktail", "notes": "white rum + lime + soda + mint", + "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", "notes": "vodka + triple sec + cranberry", + "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", "notes": "vodka + coffee liqueur", + "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", "notes": "low-ABV aperitivo", + "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", "notes": "the heavy hitter", + "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", "notes": "the soft-nudge option", + "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..6ac1a07 --- /dev/null +++ b/Agent/ballmer/serve_dashboard.py @@ -0,0 +1,112 @@ +"""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))] + + +def make_handler(playback, meta): + class Handler(BaseHTTPRequestHandler): + def _send(self, body, ctype="application/json"): + data = body.encode("utf-8") if isinstance(body, str) else body + self.send_response(200) + 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(meta)) + elif self.path.startswith("/api/state"): + self._send(json.dumps(playback.current())) + elif self.path in ("/", "/index.html"): + self._send(HTML, "text/html; charset=utf-8") + else: + self.send_error(404) + + def log_message(self, *a): # silence per-request console spam + 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) + + session = run_session(str(ROOT), auto_consume=not args.no_auto_consume, + food_state=food, write_log=False) + frames = build_frames(session) + meta = session_meta(session) + playback = Playback(frames, speed=args.speed, loop=not args.no_loop) + + server = ThreadingHTTPServer(("127.0.0.1", args.port), make_handler(playback, meta)) + 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..1eb06bb --- /dev/null +++ b/Agent/ballmer/web/dashboard.html @@ -0,0 +1,204 @@ + + + + + +Ballmer — live BAC dashboard + + + +
+

🍸 Ballmer — live BAC dashboard

+
connecting…
+
⚠ 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 or safety advice. Do not drive. Modeling demo only.
+ +
+
+ +
0.000% BAC
+
+ +
+
+
+ +
+
BURNDOWN — time to leave the range
+
+
+
+ +
+
r-factor
+
β (elim)
+
k_a (absorp)
+
profile
+
+
+ +
+ +
+ + + +
timeBAC%statusactionburndown
+ +
+
+
+ + + + From 1f4b0a37002ef328d510d3020bbf53d84705e19a Mon Sep 17 00:00:00 2001 From: Bermensolo Date: Tue, 23 Jun 2026 16:20:52 -0700 Subject: [PATCH 3/8] Add sidebar controls panel to live dashboard Adds start time, food state, auto-consume, speed, and loop toggles to the left panel. POST /api/config re-runs the simulation for physiology changes; speed/loop update instantly without re-simulation. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Agent/ballmer/ballmer/agent.py | 6 +- Agent/ballmer/ballmer/web_data.py | 1 + Agent/ballmer/serve_dashboard.py | 84 ++++++++++++++--- Agent/ballmer/web/dashboard.html | 144 ++++++++++++++++++++++++++++-- 4 files changed, 219 insertions(+), 16 deletions(-) diff --git a/Agent/ballmer/ballmer/agent.py b/Agent/ballmer/ballmer/agent.py index 50967c1..8f7e10c 100644 --- a/Agent/ballmer/ballmer/agent.py +++ b/Agent/ballmer/ballmer/agent.py @@ -151,7 +151,8 @@ def tick(events: list[DrinkEvent], st: State, now_hours: float, def run_session(root: str | Path = ".", auto_consume: bool = True, food_state: str = config.DEFAULT_FOOD_STATE, - write_log: bool = True) -> SessionRecord: + write_log: bool = True, + start_time_str: str | 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 @@ -160,6 +161,9 @@ def run_session(root: str | Path = ".", auto_consume: bool = True, 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) events = list(st.events) dt_h = st.tick_interval_min / 60.0 diff --git a/Agent/ballmer/ballmer/web_data.py b/Agent/ballmer/ballmer/web_data.py index f75afce..1aff539 100644 --- a/Agent/ballmer/ballmer/web_data.py +++ b/Agent/ballmer/ballmer/web_data.py @@ -110,6 +110,7 @@ def session_meta(session: SessionRecord) -> dict: 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, diff --git a/Agent/ballmer/serve_dashboard.py b/Agent/ballmer/serve_dashboard.py index 6ac1a07..dbc21cf 100644 --- a/Agent/ballmer/serve_dashboard.py +++ b/Agent/ballmer/serve_dashboard.py @@ -47,11 +47,54 @@ def current(self): return self.frames[min(self.n - 1, int(phase / self.speed))] -def make_handler(playback, meta): +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.""" + needs_sim = any(k in patch for k in ("food_state", "auto_consume", "start_time")) + with self._lock: + 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"), + ) + 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"): + def _send(self, body, ctype="application/json", status=200): data = body.encode("utf-8") if isinstance(body, str) else body - self.send_response(200) + 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") @@ -60,16 +103,28 @@ def _send(self, body, ctype="application/json"): def do_GET(self): if self.path.startswith("/api/meta"): - self._send(json.dumps(meta)) + self._send(json.dumps(app_state.get_meta())) elif self.path.startswith("/api/state"): - self._send(json.dumps(playback.current())) + 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 log_message(self, *a): # silence per-request console spam + 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})) + else: + self.send_error(404) + + def log_message(self, *a): pass + return Handler @@ -87,13 +142,22 @@ def main(argv=None): food = ("empty" if args.empty_stomach else "full" if args.full_stomach else config.DEFAULT_FOOD_STATE) - session = run_session(str(ROOT), auto_consume=not args.no_auto_consume, - food_state=food, write_log=False) + cfg = { + "food_state": food, + "auto_consume": not args.no_auto_consume, + "speed": args.speed, + "loop": not args.no_loop, + "start_time": None, + } + + 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) - playback = Playback(frames, speed=args.speed, loop=not args.no_loop) + cfg["start_time"] = meta["start_time"] - server = ThreadingHTTPServer(("127.0.0.1", args.port), make_handler(playback, meta)) + 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 · " diff --git a/Agent/ballmer/web/dashboard.html b/Agent/ballmer/web/dashboard.html index 1eb06bb..68c8e65 100644 --- a/Agent/ballmer/web/dashboard.html +++ b/Agent/ballmer/web/dashboard.html @@ -12,11 +12,11 @@ *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink); font:14px/1.45 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif} - .wrap{max-width:1000px;margin:0 auto;padding:18px} + .wrap{max-width:1060px;margin:0 auto;padding:18px} h1{font-size:20px;margin:0 0 2px} .sub{color:var(--muted);font-size:12px;margin-bottom:12px} .warn{background:#3a1d1d;border:1px solid #5a2a2a;color:#ffd5d5;padding:8px 12px; border-radius:8px;font-size:12px;margin-bottom:14px} - .grid{display:grid;grid-template-columns:300px 1fr;gap:14px} + .grid{display:grid;grid-template-columns:310px 1fr;gap:14px} .panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px} .pill{display:inline-block;padding:4px 12px;border-radius:999px;font-weight:700; letter-spacing:.04em;font-size:13px} @@ -39,13 +39,32 @@ th,td{text-align:left;padding:4px 6px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums} th{color:var(--muted);font-weight:600} .tag{font-weight:700} .foot{color:var(--muted);font-size:11px;margin-top:10px} + + /* Controls */ + .ctrl-sep{border:none;border-top:1px solid var(--line);margin:12px 0 8px} + .ctrl-hd{color:var(--muted);font-size:10px;letter-spacing:.08em;font-weight:700;margin-bottom:8px;text-transform:uppercase} + .ctrl-row{display:flex;justify-content:space-between;align-items:center;padding:5px 0;font-size:12px} + .ctrl-row label{color:var(--muted);flex-shrink:0} + .seg{display:flex;gap:2px} + .seg button{background:#0c1116;border:1px solid var(--line);color:var(--muted); + padding:3px 8px;border-radius:4px;cursor:pointer;font-size:11px;transition:all .15s} + .seg button.active,.seg button:hover{background:var(--accent);border-color:var(--accent);color:#000;font-weight:700} + .tog{background:#0c1116;border:1px solid var(--line);color:var(--muted); + padding:3px 12px;border-radius:4px;cursor:pointer;font-size:11px;transition:all .15s;min-width:42px} + .tog.on{background:#37d67a;border-color:#37d67a;color:#06140c;font-weight:700} + input[type=time]{background:#0c1116;border:1px solid var(--line);color:var(--ink); + border-radius:4px;padding:2px 6px;font-size:11px;width:86px;color-scheme:dark} + input[type=range]{accent-color:var(--accent);cursor:pointer;width:80px} + .speed-lbl{color:var(--ink);font-variant-numeric:tabular-nums;margin-left:4px} + .recalc{font-size:10px;color:var(--accent);margin-left:6px;opacity:0;transition:opacity .2s} + .recalc.show{opacity:1}

🍸 Ballmer — live BAC dashboard

connecting…
-
⚠ The “Ballmer Peak” (0.129–0.138% BAC) is a joke from XKCD #323. It is far above +
⚠ 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 or safety advice. Do not drive. Modeling demo only.
@@ -70,6 +89,42 @@

🍸 Ballmer — live BAC dashboard

k_a (absorp)
profile
+ + +
+
+
Controls
+ recalculating… +
+ +
+ + +
+ +
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
@@ -87,6 +142,7 @@

🍸 Ballmer — live BAC dashboard

From 56d530f8d2448e6c6ca90258faf8c77368b70873 Mon Sep 17 00:00:00 2001 From: Bermensolo Date: Tue, 23 Jun 2026 16:54:29 -0700 Subject: [PATCH 4/8] Fix burndown math; add profile inputs and legal limit line - Fix time_to_leave_window to use ODE integration instead of the naive linear formula (current - low) / beta, which ignores residual absorption from drinks still being absorbed and can underestimate burndown by 2-3x immediately after a drink - Fix burndown projection line in web_data.py to use the same ODE forward curve rather than a straight linear decline - Add weight (lb), height (in), and age inputs to controls panel; changes trigger re-simulation with updated Widmark r-factor - Add 0.08% US legal driving limit as a dashed reference line on chart Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Agent/ballmer/ballmer/agent.py | 39 ++++++++++++++++------- Agent/ballmer/ballmer/web_data.py | 21 ++++++++----- Agent/ballmer/serve_dashboard.py | 8 ++++- Agent/ballmer/web/dashboard.html | 51 +++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 19 deletions(-) diff --git a/Agent/ballmer/ballmer/agent.py b/Agent/ballmer/ballmer/agent.py index 8f7e10c..148bd52 100644 --- a/Agent/ballmer/ballmer/agent.py +++ b/Agent/ballmer/ballmer/agent.py @@ -87,19 +87,31 @@ def load_state(root: str | Path = ".") -> State: # -------------------------------------------------------------------------- # Burndown: when do we leave the target window? # -------------------------------------------------------------------------- -def time_to_leave_window(current_bac: float, window: tuple[float, float], - beta: float) -> float | None: - """Hours until BAC declines out the BOTTOM of the window, assuming NO more - drinks and steady zero-order elimination β. - - Returns None if already below the window (nothing to burn down). If above the - window, this is the time to drop below the *low* edge (i.e. fully exit), which - is the 'burndown to leaving the range' the user cares about. +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 - return (current_bac - low) / beta + 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) # -------------------------------------------------------------------------- @@ -136,7 +148,8 @@ def tick(events: list[DrinkEvent], st: State, now_hours: float, """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(rec.current_bac, st.window, st.body.beta) + 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), @@ -152,7 +165,8 @@ def tick(events: list[DrinkEvent], st: State, now_hours: float, 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) -> SessionRecord: + 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 @@ -164,6 +178,9 @@ def run_session(root: str | Path = ".", auto_consume: bool = True, 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 diff --git a/Agent/ballmer/ballmer/web_data.py b/Agent/ballmer/ballmer/web_data.py index 1aff539..92899d4 100644 --- a/Agent/ballmer/ballmer/web_data.py +++ b/Agent/ballmer/ballmer/web_data.py @@ -51,17 +51,24 @@ def build_frames(session: SessionRecord) -> list[dict]: else: times, bac = [0.0], [0.0] - # Burndown projection: decline at beta from (now, current) to crossing low. + # 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: - steps = 24 - for s in range(steps + 1): - tt = now + bd_hours * s / steps - bd_t.append(round(tt, 4)) - bd_bac.append(round(max(0.0, tk.current_bac - beta * (tt - now)), 5)) - bd_eta = _clock(session, now + bd_hours) + 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)} diff --git a/Agent/ballmer/serve_dashboard.py b/Agent/ballmer/serve_dashboard.py index dbc21cf..9dd733e 100644 --- a/Agent/ballmer/serve_dashboard.py +++ b/Agent/ballmer/serve_dashboard.py @@ -70,8 +70,12 @@ def get_config(self): def apply(self, patch, root): """Merge patch into config. Re-runs simulation for physiology changes.""" - needs_sim = any(k in patch for k in ("food_state", "auto_consume", "start_time")) + 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: @@ -81,6 +85,7 @@ def apply(self, patch, root): 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) @@ -148,6 +153,7 @@ def main(argv=None): "speed": args.speed, "loop": not args.no_loop, "start_time": None, + "profile_overrides": {}, } session = run_session(str(ROOT), auto_consume=cfg["auto_consume"], diff --git a/Agent/ballmer/web/dashboard.html b/Agent/ballmer/web/dashboard.html index 68c8e65..4e20c0b 100644 --- a/Agent/ballmer/web/dashboard.html +++ b/Agent/ballmer/web/dashboard.html @@ -55,6 +55,10 @@ input[type=time]{background:#0c1116;border:1px solid var(--line);color:var(--ink); border-radius:4px;padding:2px 6px;font-size:11px;width:86px;color-scheme:dark} input[type=range]{accent-color:var(--accent);cursor:pointer;width:80px} + input[type=number]{background:#0c1116;border:1px solid var(--line);color:var(--ink); + border-radius:4px;padding:2px 6px;font-size:11px;width:70px;-moz-appearance:textfield} + input[type=number]::-webkit-outer-spin-button, + input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none} .speed-lbl{color:var(--ink);font-variant-numeric:tabular-nums;margin-left:4px} .recalc{font-size:10px;color:var(--accent);margin-left:6px;opacity:0;transition:opacity .2s} .recalc.show{opacity:1} @@ -125,6 +129,19 @@

🍸 Ballmer — live BAC dashboard

+ +
+ + +
+
+ + +
+
+ + +
@@ -167,6 +184,14 @@

🍸 Ballmer — live BAC dashboard

ctx.strokeStyle=COL.PAST_CEILING; ctx.setLineDash([6,4]); ctx.beginPath(); ctx.moveTo(padL,Y(s.ceiling)); ctx.lineTo(W-padR,Y(s.ceiling)); ctx.stroke(); ctx.setLineDash([]); + // 0.08% US legal driving limit + if(0.08 <= yTop){ + ctx.strokeStyle="rgba(239,120,60,.6)"; ctx.lineWidth=1.2; ctx.setLineDash([4,6]); ctx.beginPath(); + ctx.moveTo(padL,Y(0.08)); ctx.lineTo(W-padR,Y(0.08)); ctx.stroke(); ctx.setLineDash([]); + ctx.fillStyle="rgba(239,120,60,.85)"; ctx.font="10px sans-serif"; ctx.textAlign="left"; + ctx.fillText("0.080% legal limit", padL+4, Y(0.08)-3); + } + // axes labels (y) ctx.fillStyle="#8b98a5"; ctx.font="11px sans-serif"; ctx.textAlign="right"; for(let k=0;k<=5;k++){ const v=yTop*k/5; ctx.fillText(v.toFixed(3), padL-6, Y(v)+3); } @@ -307,6 +332,25 @@

🍸 Ballmer — live BAC dashboard

postConfig({loop: CTL.loop}); }); +// Profile (weight / height / age) — debounced so we don't re-sim on every keystroke +let _profileTimer = null; +function scheduleProfilePush(){ + clearTimeout(_profileTimer); + _profileTimer = setTimeout(()=>{ + const w = parseFloat(document.getElementById("ctl-weight").value); + const h = parseFloat(document.getElementById("ctl-height").value); + const a = parseFloat(document.getElementById("ctl-age").value); + const overrides = {}; + if(!isNaN(w) && w>0) overrides.weight_lb = w; + if(!isNaN(h) && h>0) overrides.height_in = h; + if(!isNaN(a) && a>0) overrides.age = a; + if(Object.keys(overrides).length) postConfig({profile_overrides: overrides}); + }, 700); +} +["ctl-weight","ctl-height","ctl-age"].forEach(id=>{ + document.getElementById(id).addEventListener("input", scheduleProfilePush); +}); + // ── Init ────────────────────────────────────────────────────────────────────── async function init(){ @@ -328,6 +372,13 @@

🍸 Ballmer — live BAC dashboard

loopBtn.textContent = CTL.loop ? "ON" : "OFF"; loopBtn.classList.toggle("on", CTL.loop); + // Profile inputs from live meta (reflects any active overrides) + if(META.profile){ + document.getElementById("ctl-weight").value = META.profile.weight_lb ?? 220; + document.getElementById("ctl-height").value = META.profile.height_in ?? 73; + document.getElementById("ctl-age").value = META.profile.age ?? 39; + } + poll(); setInterval(poll, 1000); } From 644959ff95d1d9a1d8c2b8cf20f3285c8f00f1b5 Mon Sep 17 00:00:00 2001 From: Bermensolo Date: Tue, 23 Jun 2026 17:09:41 -0700 Subject: [PATCH 5/8] Add Claude LLM reasoning layer and drink vibe scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the actual agent intelligence that transforms Ballmer from a scoring script into an LLM-powered agent: - drink-library.json: each drink now has a vibe score (1-10) reflecting its character for a Ballmer Peak coding session (dry martini=10, long island=2, etc.) - ballmer/drinks.py: Drink dataclass gains vibe_score field - ballmer/llm_agent.py: new module — llm_reason() calls Claude Haiku with current BAC state, the drink menu with vibe scores, and the math model's recommendation, and uses tool use to return a structured pick + 2-3 sentence reasoning - serve_dashboard.py: POST /api/llm_reason calls the LLM for the current tick state and returns the recommendation as JSON - web/dashboard.html: "Ask Claude" button + response panel shows Claude's recommended drink (with color-coded pill), vibe score, and reasoning Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Agent/ballmer/ballmer/drinks.py | 4 +- Agent/ballmer/ballmer/llm_agent.py | 166 +++++++++++++++++++++++++++++ Agent/ballmer/drink-library.json | 63 +++++++---- Agent/ballmer/serve_dashboard.py | 10 ++ Agent/ballmer/web/dashboard.html | 55 ++++++++++ 5 files changed, 276 insertions(+), 22 deletions(-) create mode 100644 Agent/ballmer/ballmer/llm_agent.py diff --git a/Agent/ballmer/ballmer/drinks.py b/Agent/ballmer/ballmer/drinks.py index b912ab2..007dd5e 100644 --- a/Agent/ballmer/ballmer/drinks.py +++ b/Agent/ballmer/ballmer/drinks.py @@ -36,6 +36,7 @@ class Drink: ingredients: tuple[Ingredient, ...] category: str = "" notes: str = "" + vibe_score: int = 5 # 1-10 subjective desirability for a Ballmer Peak session @property def total_ethanol_g(self) -> float: @@ -56,7 +57,8 @@ 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", "")) + category=d.get("category", ""), notes=d.get("notes", ""), + vibe_score=int(d.get("vibe", 5))) def load_library(path: str | Path = "drink-library.json") -> list[Drink]: diff --git a/Agent/ballmer/ballmer/llm_agent.py b/Agent/ballmer/ballmer/llm_agent.py new file mode 100644 index 0000000..92fc95f --- /dev/null +++ b/Agent/ballmer/ballmer/llm_agent.py @@ -0,0 +1,166 @@ +"""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 the AI bartender running the Ballmer Peak experiment — a bit from XKCD #323 \ +about programmers theoretically coding best at exactly 0.129–0.138% BAC. \ +The physics model handles the math. Your job is the judgment call: \ +given the current BAC trajectory and the bar menu, recommend the next drink (or HOLD) \ +and explain your reasoning in 2–3 punchy sentences. \ +Each drink has a vibe score (1–10) reflecting how enjoyable and appropriate it is \ +for a late-night coding session — factor it in alongside the numbers.""" + + +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. \ +Explain in 2–3 sentences why it fits both the BAC trajectory and the vibe.""" + + 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/drink-library.json b/Agent/ballmer/drink-library.json index d8f5f42..3b0aa47 100644 --- a/Agent/ballmer/drink-library.json +++ b/Agent/ballmer/drink-library.json @@ -1,63 +1,83 @@ { - "_comment": "The bar menu Ballmer recommends from. This is the always-on agent's analog of deploys/recent.json in the Always-On Ops exercise: a JSON state file the agent reads each tick. Each drink is defined by its INGREDIENTS (volume in fl oz + abv as a fraction), NEVER a flat ABV, so ethanol is computed at the recipe level. ABVs are typical/standard values; recipes are standard builds. Adding a drink = appending an object here. Sources: standard bartender builds (e.g. IBA/Difford's), typical product ABVs.", + "_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", "notes": "~1 US standard drink", + {"name": "Lager (12 oz)", "category": "beer", "vibe": 6, + "notes": "reliable, sessionable, no surprises", "ingredients": [{"name": "lager", "oz": 12.0, "abv": 0.05}]}, - {"name": "IPA (12 oz)", "category": "beer", "notes": "hoppy, stronger", + {"name": "IPA (12 oz)", "category": "beer", "vibe": 7, + "notes": "craft credibility, hoppy complexity", "ingredients": [{"name": "IPA", "oz": 12.0, "abv": 0.065}]}, - {"name": "Red wine (5 oz)", "category": "wine", "notes": "standard pour", + {"name": "Red wine (5 oz)", "category": "wine", "vibe": 7, + "notes": "contemplative, slow-sipping energy", "ingredients": [{"name": "red wine", "oz": 5.0, "abv": 0.135}]}, - {"name": "White wine (5 oz)", "category": "wine", "notes": "standard pour", + {"name": "White wine (5 oz)", "category": "wine", "vibe": 7, + "notes": "crisp, clean, keeps you sharp", "ingredients": [{"name": "white wine", "oz": 5.0, "abv": 0.12}]}, - {"name": "Sparkling (5 oz)", "category": "wine", "notes": "prosecco/champagne", + {"name": "Sparkling (5 oz)", "category": "wine", "vibe": 8, + "notes": "celebratory, effervescent, light on ethanol", "ingredients": [{"name": "sparkling wine", "oz": 5.0, "abv": 0.12}]}, - {"name": "Whiskey, neat (2 oz)", "category": "spirit", "notes": "80-proof pour", + {"name": "Whiskey, neat (2 oz)", "category": "spirit", "vibe": 9, + "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", "notes": "same ethanol as neat", + {"name": "Whiskey, rocks (2 oz)", "category": "spirit", "vibe": 8, + "notes": "same ethanol as neat, slightly more casual", "ingredients": [{"name": "whiskey", "oz": 2.0, "abv": 0.40}]}, - {"name": "Vodka soda", "category": "highball", "notes": "low-sugar standby", + {"name": "Vodka soda", "category": "highball", "vibe": 5, + "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", "notes": "", + {"name": "Gin & tonic", "category": "highball", "vibe": 8, + "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", "notes": "tequila + triple sec + lime", + {"name": "Margarita", "category": "cocktail", "vibe": 7, + "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", "notes": "rye + sweet vermouth", + {"name": "Manhattan", "category": "cocktail", "vibe": 9, + "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", "notes": "equal parts gin/Campari/vermouth", + {"name": "Negroni", "category": "cocktail", "vibe": 9, + "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", "notes": "gin-forward", + {"name": "Dry martini", "category": "cocktail", "vibe": 10, + "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", "notes": "bourbon + sugar + bitters", + {"name": "Old fashioned", "category": "cocktail", "vibe": 9, + "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", "notes": "white rum + lime + soda + mint", + {"name": "Mojito", "category": "cocktail", "vibe": 7, + "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", "notes": "vodka + triple sec + cranberry", + {"name": "Cosmopolitan", "category": "cocktail", "vibe": 6, + "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", "notes": "vodka + coffee liqueur", + {"name": "Espresso martini", "category": "cocktail", "vibe": 8, + "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", "notes": "low-ABV aperitivo", + {"name": "Aperol spritz", "category": "cocktail", "vibe": 7, + "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", "notes": "the heavy hitter", + {"name": "Long Island iced tea", "category": "cocktail", "vibe": 2, + "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}, @@ -65,7 +85,8 @@ {"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", "notes": "the soft-nudge option", + {"name": "Water / soda (NA)", "category": "non-alcoholic", "vibe": 1, + "notes": "the soft-nudge option — only when past ceiling", "ingredients": [{"name": "soda water", "oz": 12.0, "abv": 0.0}]} ] } diff --git a/Agent/ballmer/serve_dashboard.py b/Agent/ballmer/serve_dashboard.py index 9dd733e..d9973d1 100644 --- a/Agent/ballmer/serve_dashboard.py +++ b/Agent/ballmer/serve_dashboard.py @@ -124,6 +124,16 @@ def do_POST(self): 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) diff --git a/Agent/ballmer/web/dashboard.html b/Agent/ballmer/web/dashboard.html index 4e20c0b..9e6e680 100644 --- a/Agent/ballmer/web/dashboard.html +++ b/Agent/ballmer/web/dashboard.html @@ -62,6 +62,15 @@ .speed-lbl{color:var(--ink);font-variant-numeric:tabular-nums;margin-left:4px} .recalc{font-size:10px;color:var(--accent);margin-left:6px;opacity:0;transition:opacity .2s} .recalc.show{opacity:1} + .llm-panel{background:#15202b;border:1px solid var(--line);border-radius:10px; + padding:12px 14px;margin-top:10px} + .llm-panel .llm-hd{display:flex;align-items:center;gap:8px;margin-bottom:8px} + .llm-panel .llm-hd span{font-size:13px;font-weight:700;color:var(--accent)} + .llm-panel .llm-reasoning{font-size:13px;line-height:1.55;color:var(--ink)} + .ask-btn{background:var(--accent);border:none;color:#000;padding:5px 14px; + border-radius:6px;cursor:pointer;font-size:12px;font-weight:700;letter-spacing:.03em} + .ask-btn:hover{opacity:.85} .ask-btn:disabled{opacity:.4;cursor:not-allowed} + .llm-status{color:var(--muted);font-size:11px;margin-left:8px} @@ -147,6 +156,21 @@

🍸 Ballmer — live BAC dashboard

+ +
+ + +
+ + + @@ -351,6 +375,37 @@

🍸 Ballmer — live BAC dashboard

document.getElementById(id).addEventListener("input", scheduleProfilePush); }); +// ── Ask Claude ──────────────────────────────────────────────────────────────── + +async function askClaude(){ + const btn = document.getElementById("ask-btn"); + const status = document.getElementById("llm-status"); + btn.disabled = true; + status.textContent = "thinking…"; + try{ + const r = await fetch("/api/llm_reason", {method:"POST"}); + const data = await r.json(); + if(data.error) throw new Error(data.error); + + const drinkEl = document.getElementById("llm-drink"); + drinkEl.textContent = data.drink_name; + // Color the pill by whether it's HOLD or a drink + const isHold = data.drink_name === "HOLD"; + drinkEl.style.background = isHold ? "#3fb6e0" : "#37d67a"; + drinkEl.style.color = "#06140c"; + + document.getElementById("llm-vibe").textContent = + data.vibe_score ? `vibe ${data.vibe_score}/10` : ""; + document.getElementById("llm-reasoning").textContent = data.reasoning; + document.getElementById("llm-panel").style.display = "block"; + status.textContent = ""; + }catch(e){ + status.textContent = "⚠ " + e.message; + }finally{ + btn.disabled = false; + } +} + // ── Init ────────────────────────────────────────────────────────────────────── async function init(){ From 7b1969512cef0630a17f5451fd0fc6078732cb7f Mon Sep 17 00:00:00 2001 From: Bermensolo Date: Tue, 23 Jun 2026 17:11:46 -0700 Subject: [PATCH 6/8] Give Claude the voice of Sully, a Boston bartendah from Southie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the LLM system prompt to speak in authentic Boston dialect: dropped R's, local slang (wicked pissa, no suh, kid, chief), strong opinions, and gruff Southie attitude — while still delivering correct BAC reasoning and drink recommendations. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Agent/ballmer/ballmer/llm_agent.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Agent/ballmer/ballmer/llm_agent.py b/Agent/ballmer/ballmer/llm_agent.py index 92fc95f..f5bdd49 100644 --- a/Agent/ballmer/ballmer/llm_agent.py +++ b/Agent/ballmer/ballmer/llm_agent.py @@ -22,13 +22,16 @@ from .drinks import Drink SYSTEM = """\ -You are the AI bartender running the Ballmer Peak experiment — a bit from XKCD #323 \ -about programmers theoretically coding best at exactly 0.129–0.138% BAC. \ +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 current BAC trajectory and the bar menu, recommend the next drink (or HOLD) \ -and explain your reasoning in 2–3 punchy sentences. \ -Each drink has a vibe score (1–10) reflecting how enjoyable and appropriate it is \ -for a late-night coding session — factor it in alongside the numbers.""" +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: @@ -112,7 +115,7 @@ def llm_reason(frame: dict, library: list[Drink]) -> LLMRecommendation: {menu} Pick the next drink (exact name from the menu above) or say HOLD. \ -Explain in 2–3 sentences why it fits both the BAC trajectory and the vibe.""" +Tell 'em what to do in 2–3 sentences — Boston bartendah voice, no suh.""" response = client.messages.create( model="claude-haiku-4-5-20251001", From e7870277d81273be54b46832ecf8969292075322 Mon Sep 17 00:00:00 2001 From: Bermensolo Date: Tue, 23 Jun 2026 17:16:53 -0700 Subject: [PATCH 7/8] Remove warning banner; standardize y-axis to 0.02 ticks; add double whiskey - Remove the disclaimer warning banner from the dashboard header - Y-axis now uses fixed 0.02 increments with light gridlines instead of dividing yTop into 5 uneven parts - Add Double whiskey, rocks (4 oz) to drink library (vibe 8) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Agent/ballmer/drink-library.json | 3 +++ Agent/ballmer/web/dashboard.html | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Agent/ballmer/drink-library.json b/Agent/ballmer/drink-library.json index 3b0aa47..0611cd8 100644 --- a/Agent/ballmer/drink-library.json +++ b/Agent/ballmer/drink-library.json @@ -24,6 +24,9 @@ {"name": "Whiskey, rocks (2 oz)", "category": "spirit", "vibe": 8, "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, + "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, "notes": "functional, low-calorie, zero personality", "ingredients": [{"name": "vodka", "oz": 1.5, "abv": 0.40}, diff --git a/Agent/ballmer/web/dashboard.html b/Agent/ballmer/web/dashboard.html index 9e6e680..f7b6a17 100644 --- a/Agent/ballmer/web/dashboard.html +++ b/Agent/ballmer/web/dashboard.html @@ -14,9 +14,7 @@ font:14px/1.45 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif} .wrap{max-width:1060px;margin:0 auto;padding:18px} h1{font-size:20px;margin:0 0 2px} .sub{color:var(--muted);font-size:12px;margin-bottom:12px} - .warn{background:#3a1d1d;border:1px solid #5a2a2a;color:#ffd5d5;padding:8px 12px; - border-radius:8px;font-size:12px;margin-bottom:14px} - .grid{display:grid;grid-template-columns:310px 1fr;gap:14px} +.grid{display:grid;grid-template-columns:310px 1fr;gap:14px} .panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px} .pill{display:inline-block;padding:4px 12px;border-radius:999px;font-weight:700; letter-spacing:.04em;font-size:13px} @@ -77,9 +75,6 @@

🍸 Ballmer — live BAC dashboard

connecting…
-
⚠ 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 or safety advice. Do not drive. Modeling demo only.
-
@@ -216,9 +211,15 @@

🍸 Ballmer — live BAC dashboard

ctx.fillText("0.080% legal limit", padL+4, Y(0.08)-3); } - // axes labels (y) - ctx.fillStyle="#8b98a5"; ctx.font="11px sans-serif"; ctx.textAlign="right"; - for(let k=0;k<=5;k++){ const v=yTop*k/5; ctx.fillText(v.toFixed(3), padL-6, Y(v)+3); } + // y-axis: fixed 0.02 ticks with light gridlines + ctx.font="11px sans-serif"; ctx.textAlign="right"; + const tickStep=0.02, nTicks=Math.ceil(yTop/tickStep); + for(let i=0;i<=nTicks;i++){ + const v=i*tickStep; if(v>yTop+1e-9) break; + ctx.strokeStyle="rgba(43,54,64,.55)"; ctx.lineWidth=0.5; ctx.beginPath(); + ctx.moveTo(padL,Y(v)); ctx.lineTo(W-padR,Y(v)); ctx.stroke(); + ctx.fillStyle="#8b98a5"; ctx.fillText(v.toFixed(2), padL-6, Y(v)+3); + } // x ticks ctx.textAlign="center"; for(let h=0;h<=Math.ceil(dur);h++){ ctx.fillText(h+"h", X(h), H-10); } From 5ecb0f4b2a7c00a442c9f2483322bf9769938c00 Mon Sep 17 00:00:00 2001 From: Bermensolo Date: Wed, 24 Jun 2026 11:08:57 -0700 Subject: [PATCH 8/8] Add Ballmer Peak explainer, rename to model, drink prices + running tab KPI - Rename "dashboard" to "model" throughout (title, sub-header, browser tab) - Add explainer callout box below title explaining the XKCD #323 joke - Add price field to every drink in the library (realistic bar prices) - Thread price through Drink dataclass and web_data frame pipeline - Add bac_delta (Widmark contribution per drink) and price columns to tick log - Add live running tab KPI ($X.XX) in the header, updating each tick Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Agent/ballmer/ballmer/drinks.py | 4 ++- Agent/ballmer/ballmer/web_data.py | 37 +++++++++++++++++---------- Agent/ballmer/drink-library.json | 42 +++++++++++++++---------------- Agent/ballmer/web/dashboard.html | 28 ++++++++++++++++++--- 4 files changed, 72 insertions(+), 39 deletions(-) diff --git a/Agent/ballmer/ballmer/drinks.py b/Agent/ballmer/ballmer/drinks.py index 007dd5e..71f9e07 100644 --- a/Agent/ballmer/ballmer/drinks.py +++ b/Agent/ballmer/ballmer/drinks.py @@ -37,6 +37,7 @@ class Drink: 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: @@ -58,7 +59,8 @@ def _drink_from_dict(d: dict) -> Drink: 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))) + 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]: diff --git a/Agent/ballmer/ballmer/web_data.py b/Agent/ballmer/ballmer/web_data.py index 92899d4..a85a1c4 100644 --- a/Agent/ballmer/ballmer/web_data.py +++ b/Agent/ballmer/ballmer/web_data.py @@ -19,7 +19,7 @@ from .agent import SessionRecord, time_to_leave_window from .bac_model import bac_curve -from .recommend import IN, _na_drink # noqa: F401 (_na_drink kept for parity) +from .recommend import IN, ORDER, _na_drink # noqa: F401 (_na_drink kept for parity) _STATUS_LABEL = { "BELOW_WINDOW": "BELOW BAND", @@ -75,18 +75,28 @@ def build_frames(session: SessionRecord) -> list[dict]: for e in events_so_far ] - ticklog = [ - { - "clock": session.ticks[j].clock, - "bac": round(session.ticks[j].current_bac, 3), - "status": _STATUS_LABEL.get(session.ticks[j].status, session.ticks[j].status), - "action": session.ticks[j].action, - "drink": session.ticks[j].drink_name, - "burndown": (round(session.ticks[j].burndown_hours, 2) - if session.ticks[j].burndown_hours is not None else None), - } - for j in range(i + 1) - ] + 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, @@ -108,6 +118,7 @@ def build_frames(session: SessionRecord) -> list[dict]: "burndown_line": {"t": bd_t, "bac": bd_bac}, "events": events_marks, "ticks": ticklog, + "tab_total": round(tab_total, 2), }) return frames diff --git a/Agent/ballmer/drink-library.json b/Agent/ballmer/drink-library.json index 0611cd8..743b3df 100644 --- a/Agent/ballmer/drink-library.json +++ b/Agent/ballmer/drink-library.json @@ -3,83 +3,83 @@ "_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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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, + {"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}, @@ -88,7 +88,7 @@ {"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, + {"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/web/dashboard.html b/Agent/ballmer/web/dashboard.html index f7b6a17..9a7575c 100644 --- a/Agent/ballmer/web/dashboard.html +++ b/Agent/ballmer/web/dashboard.html @@ -3,7 +3,7 @@ -Ballmer — live BAC dashboard +Ballmer — live BAC model
timeBAC%statusactionburndown