Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Agent/ballmer/.gitignore
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions Agent/ballmer/README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions Agent/ballmer/ballmer/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
234 changes: 234 additions & 0 deletions Agent/ballmer/ballmer/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"""The always-on agent: tick -> read state -> recompute BAC -> recommend -> log.

This mirrors the Always-On Ops exercise's construct (a routine wakes the agent,
it reads repo state, reasons against its runbook/policy, and writes an action).
Here the "tick" runs over FAST-FORWARDED simulated time so a whole bar night
plays out in one hands-free run.

State repo:
state/profile.json body metrics (read)
state/tab.json drinks consumed so far (read; live state)
state/target.json window + ceiling (read)
drink-library.json the menu (read)
reference/*.md assumptions + policy (the agent's "runbook")
state/log/ recommendations + transcript (WRITE — the agent's action)

The seed tab.json is treated read-only for demo repeatability; the evolving
session is written to state/log/. A real cloud Routine would commit back to
tab.json each tick instead.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path

from . import config
from .bac_model import BodyParams, DrinkEvent, bac_at, bac_curve, peak_bac
from .drinks import Drink, find_drink, load_library
from .recommend import ORDER, Recommendation, recommend_next


# --------------------------------------------------------------------------
# State loading
# --------------------------------------------------------------------------
@dataclass
class State:
profile: dict
body: BodyParams
events: list[DrinkEvent]
library: list[Drink]
session_start: datetime
window: tuple[float, float]
ceiling: float
tick_interval_min: float
session_duration_hours: float


def _hours_since(start: datetime, ts: str) -> float:
return (datetime.fromisoformat(ts) - start).total_seconds() / 3600.0


def load_state(root: str | Path = ".") -> State:
root = Path(root)
profile = json.loads((root / "state/profile.json").read_text(encoding="utf-8"))
tab = json.loads((root / "state/tab.json").read_text(encoding="utf-8"))
target = json.loads((root / "state/target.json").read_text(encoding="utf-8"))
library = load_library(root / "drink-library.json")

session_start = datetime.fromisoformat(tab["session_start"])
events: list[DrinkEvent] = []
for item in tab["consumed"]:
drink = find_drink(library, item["name"])
if drink is None:
raise ValueError(f"tab references unknown drink {item['name']!r}")
events.append(DrinkEvent(
t_hours=_hours_since(session_start, item["time"]),
grams=drink.total_ethanol_g,
label=drink.name,
food_state=item.get("food_state", config.DEFAULT_FOOD_STATE),
))

return State(
profile=profile,
body=BodyParams.from_profile(profile),
events=events,
library=library,
session_start=session_start,
window=(target["target_low"], target["target_high"]),
ceiling=target["safety_ceiling"],
tick_interval_min=target.get("tick_interval_min", 20),
session_duration_hours=target.get("session_duration_hours", 5.0),
)


# --------------------------------------------------------------------------
# Burndown: when do we leave the target window?
# --------------------------------------------------------------------------
def time_to_leave_window(events: list[DrinkEvent], body: BodyParams,
now_hours: float, window: tuple[float, float],
current_bac: float | None = None,
t_search: float = 12.0) -> float | None:
"""Hours until BAC declines below the BOTTOM of the window, assuming NO more
drinks.

Uses ODE integration (not the naive linear formula) so residual absorption
from drinks still being processed is accounted for — BAC may continue rising
for a bit before it declines, which the linear approximation misses entirely.

Returns None if already below the window (nothing to burn down).
Returns t_search if BAC never drops below low within the search window.
"""
low, _ = window
if current_bac is None:
current_bac = bac_at(events, body, now_hours)
if current_bac <= low:
return None
times, bac = bac_curve(events, body, t_end=now_hours + t_search,
t_start=now_hours)
for t, b in zip(times, bac):
if b <= low:
return round(t - now_hours, 4)
return round(t_search, 4)


# --------------------------------------------------------------------------
# Tick + session loop
# --------------------------------------------------------------------------
@dataclass
class TickRecord:
now_hours: float
clock: str # human wall-clock label
current_bac: float
status: str
action: str
drink_name: str | None
recommendation: Recommendation
burndown_hours: float | None # time-to-leave-window (None if below)


@dataclass
class SessionRecord:
state: State
ticks: list[TickRecord] = field(default_factory=list)
curve_times: list[float] = field(default_factory=list)
curve_bac: list[float] = field(default_factory=list)
final_events: list[DrinkEvent] = field(default_factory=list)


def _clock(start: datetime, now_hours: float) -> str:
from datetime import timedelta
return (start + timedelta(hours=now_hours)).strftime("%I:%M %p").lstrip("0")


def tick(events: list[DrinkEvent], st: State, now_hours: float,
food_state: str = config.DEFAULT_FOOD_STATE) -> TickRecord:
"""One wake-up: assess current BAC, produce a recommendation, package it."""
rec = recommend_next(events, st.body, st.library, now_hours,
window=st.window, ceiling=st.ceiling, food_state=food_state)
burndown = time_to_leave_window(events, st.body, now_hours, st.window,
current_bac=rec.current_bac)
return TickRecord(
now_hours=now_hours,
clock=_clock(st.session_start, now_hours),
current_bac=rec.current_bac,
status=rec.status,
action=rec.action,
drink_name=rec.drink.name if rec.drink else None,
recommendation=rec,
burndown_hours=burndown,
)


def run_session(root: str | Path = ".", auto_consume: bool = True,
food_state: str = config.DEFAULT_FOOD_STATE,
write_log: bool = True,
start_time_str: str | None = None,
profile_overrides: dict | None = None) -> SessionRecord:
"""Fast-forward the whole night, ticking every tick_interval_min.

auto_consume: if True, when the agent recommends ORDER the simulated drinker
follows it — the drink is added to the event list at that tick (STUB:
confirm_order). This is what makes the BAC curve climb into the band hands-
free and demonstrates the agent steering the night.
"""
st = load_state(root)
if start_time_str:
h, m = map(int, start_time_str.split(":"))
st.session_start = st.session_start.replace(hour=h, minute=m, second=0)
if profile_overrides:
st.profile = {**st.profile, **profile_overrides}
st.body = BodyParams.from_profile(st.profile)
events = list(st.events)

dt_h = st.tick_interval_min / 60.0
# Start ticking after the last seeded drink (or now), through the session end.
t = max((e.t_hours for e in events), default=0.0)
t_end = st.session_duration_hours

session = SessionRecord(state=st)
while t <= t_end + 1e-9:
rec_tick = tick(events, st, t, food_state=food_state)
session.ticks.append(rec_tick)
if auto_consume and rec_tick.action == ORDER and rec_tick.recommendation.drink:
d = rec_tick.recommendation.drink
events.append(DrinkEvent(t_hours=t, grams=d.total_ethanol_g,
label=d.name, food_state=food_state))
t += dt_h

# Full curve over the night for the dashboard.
times, bac = bac_curve(events, st.body, t_end=t_end + 1.0, t_start=0.0)
session.curve_times = times
session.curve_bac = bac
session.final_events = events

if write_log:
_write_log(root, session)
return session


def _write_log(root: str | Path, session: SessionRecord) -> Path:
"""Append the session transcript to state/log/ (the agent's 'action')."""
log_dir = Path(root) / "state" / "log"
log_dir.mkdir(parents=True, exist_ok=True)
stamp = session.state.session_start.strftime("%Y%m%dT%H%M")
out = log_dir / f"session-{stamp}.md"
lines = [f"# Ballmer session log — start {session.state.session_start.isoformat()}",
"",
f"- r = {session.state.body.r:.4f}, "
f"weight = {session.state.body.weight_kg:.1f} kg, "
f"β = {session.state.body.beta} %/hr, "
f"k_a = {session.state.body.k_a_base} /hr",
f"- target window {session.state.window[0]:.3f}-{session.state.window[1]:.3f}%, "
f"ceiling {session.state.ceiling:.3f}%",
"",
"| time | BAC% | status | action | drink | burndown(h) |",
"|------|------|--------|--------|-------|-------------|"]
for tk in session.ticks:
bd = f"{tk.burndown_hours:.2f}" if tk.burndown_hours is not None else "-"
lines.append(f"| {tk.clock} | {tk.current_bac:.3f} | {tk.status} | "
f"{tk.action} | {tk.drink_name or '-'} | {bd} |")
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
return out
Loading