web+server: carbon page — split into Built + Run, tangible anchors (#803)
Rework the public carbon page two ways.
1) Replace the circular "member-years of running the service" equivalence
with a curated set of tangible high-carbon anchors, applied to BOTH
sections (each scaled to its own magnitude): petrol car (kept), a
short-haul economy flight expressed against one real leg (Dublin–London
~460 km ≈ 67 kg CO₂e incl. the ~1.9× radiative-forcing uplift), a petrol
leaf blower (~2.3 kg CO₂/h), beef (~60 kg CO₂e/kg, land-use-inclusive),
and tree-years to reabsorb (~21 kg CO₂/yr). Each factor is cited, banded,
and snapshot-dated in docs/comparison-carbon.md. Distances/volumes convert
with the metric/imperial toggle; masses convert via massF; counts and
durations stay unit-agnostic.
2) Split the page into Built (one-time, the committed AI-build tally) and Run
(ongoing, a LIVE all-services operational estimate). The published
carbon.json is now { updated_at, methodology, built:{…}, run:{…} }:
carbon-tally.mjs writes the nested `built` (committed, republished as-is);
deploy/status-publish.sh injects a live `run` block at upload
(carbon_run_object): router-box watts from the Hetzner server_type's vCPUs
× per-vCPU watt band × 24h × the box region's grid intensity × PUE ~1.15,
plus small explicit AWS-storage and bounded bot-inference terms — all
BANDED low/central/high, as a daily rate, a cumulative-to-date, and
normalised per active space (denominator = the public instance count, never
per-member). Absent inputs → run stays absent and the page shows
"operational estimate pending", never a fake zero. Framing is neutral:
one-time-to-make vs ongoing-to-operate-and-rising-with-scale, never
"building cost more than running".
Contract test allowlists the run block's keys, asserts no hostnames/IPs and
built-side unchanged, and pins the watts→kg math; a dry-run twin folds the run
block from the Hetzner + registry fixtures. Fixture test/status-fixtures/
carbon.json regenerated from --dry-run. npm test + status-contract green.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+769
−194
deploy/status-publish.sh+215 −8docs/comparison-carbon.md+97 −12test/status-contract.test.mjs+73 −12test/status-fixtures/carbon.json+1 −0tools/carbon-tally.mjs+27 −10web/status/carbon.html+300 −98web/status/carbon.json+56 −54
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index b07f272..f3ef584 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -47,10 +47,14 @@ |
| # usage breakdowns. The AWS creds and the Anthropic Admin key are read for |
| # the API calls and must never be echoed, logged, or uploaded. |
| # |
| -# carbon.json — NOT generated here: it's COMMITTED data (the AI-build carbon |
| -# tally, web/status/carbon.json, accrued by tools/carbon-tally.mjs), |
| -# staged with the pages and republished as-is. Aggregate token counts and |
| -# kg estimates only — never per-session data. |
| +# carbon.json — two halves. `built` is COMMITTED data (the AI-build carbon |
| +# tally, web/status/carbon.json, accrued by tools/carbon-tally.mjs), staged |
| +# with the pages and republished as-is — aggregate token counts + kg only, |
| +# never per-session data. `run` is a LIVE operational estimate this script |
| +# INJECTS at upload (build_carbon): AGGREGATES ONLY — total vCPUs, watts, |
| +# grid intensity, banded kg, and the public instance count as the per-space |
| +# denominator (NEVER per-member, never a hostname/IP). Absent inputs → run |
| +# stays absent and the page shows "pending", never a fake zero. |
| # |
| # contributors.json — also COMMITTED data (tools/contributors.mjs), |
| # republished as-is. Public GitHub identities + commit counts only; |
| @@ -162,6 +166,30 @@ box_region() { |
| STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env} |
| PAGES_DIR=${STATUS_PAGES_DIR:-/opt/lchat/status-pages} |
| STRIPE_API=${STATUS_STRIPE_API:-https://api.stripe.com} |
| + |
| +# Carbon "run" block coefficients (see build_carbon + docs/comparison-carbon.md |
| +# § The run methodology; snapshot July 2026, all BANDED low/central/high). The |
| +# fleet hibernates and runs at a fraction of the box, so the attributable draw |
| +# is a low-single-digit-to-~15W slice of a lightly loaded server — modelled here |
| +# as per-vCPU average watts (marginal → whole-box-idle-inclusive): |
| +CARBON_W_LO=${STATUS_CARBON_W_LO:-1.5} # W/vCPU, marginal (load-proportional) |
| +CARBON_W_CE=${STATUS_CARBON_W_CE:-3.5} # W/vCPU, central |
| +CARBON_W_HI=${STATUS_CARBON_W_HI:-7.5} # W/vCPU, whole-box idle-inclusive share |
| +CARBON_PUE=${STATUS_CARBON_PUE:-1.15} # AWS-published fleet-PUE ballpark |
| +# AWS storage+requests: near-zero but NOT zero — a small explicit banded term |
| +# (~a gram a day: ~0.1 TB of our ciphertext × ~1 Wh/TB-h × 24h × a grid). |
| +CARBON_AWS_LO=${STATUS_CARBON_AWS_LO:-0.0002} |
| +CARBON_AWS_CE=${STATUS_CARBON_AWS_CE:-0.001} |
| +CARBON_AWS_HI=${STATUS_CARBON_AWS_HI:-0.003} |
| +# Bot inference (the sidecars' ongoing Anthropic API use): small today, bounded |
| +# wide — the high end allows for busy days. Precise token counts exist in the |
| +# provider console; we publish a bounded estimate rather than couple to it. |
| +CARBON_INF_LO=${STATUS_CARBON_INF_LO:-0.0005} |
| +CARBON_INF_CE=${STATUS_CARBON_INF_CE:-0.005} |
| +CARBON_INF_HI=${STATUS_CARBON_INF_HI:-0.05} |
| +# The fleet's start-of-life (drives the cumulative-to-date figure). The dry-run |
| +# pins STATUS_CARBON_DAYS instead so its cumulative is deterministic. |
| +CARBON_SINCE=${STATUS_CARBON_SINCE:-2026-07-05} |
| SELF=/opt/lchat/status-publish.sh |
| UNIT=/etc/systemd/system/lchat-status.service |
| TIMER=/etc/systemd/system/lchat-status.timer |
| @@ -863,6 +891,121 @@ SQL |
| } | sqlite3 :memory: > "$out" |
| } |
| +# ---- carbon.json: republish the committed built ledger + inject a live run block |
| + |
| +# grid_band — the box region's grid carbon intensity as "<low> <central> <high>" |
| +# g CO₂e/kWh. low is always ~0 (a market-based renewable contract — Hetzner's |
| +# EU sites run on renewable electricity); central+high are the LOCATION grid, |
| +# banded. Region resolves from box_region() (coarse continent+country; the prod |
| +# box is Helsinki → Finland's low-carbon grid). Operator overrides: |
| +# STATUS_CARBON_GRID_CENTRAL / _HIGH. Cited in docs/comparison-carbon.md. |
| +grid_band() { |
| + local gce ghi region |
| + region=${BOX_REGION:-$(box_region)} |
| + case "$region" in |
| + "EU · Finland") gce=100; ghi=130 ;; # Finnish grid ~80–130 (Fingrid/ElectricityMaps 2026) |
| + "EU · Sweden") gce=40; ghi=60 ;; |
| + "EU · Germany") gce=350; ghi=400 ;; |
| + "EU · Ireland") gce=300; ghi=380 ;; |
| + "EU · UK") gce=210; ghi=280 ;; |
| + "NA · USA") gce=370; ghi=480 ;; |
| + *) gce=350; ghi=480 ;; # unknown: IEA-ish global band |
| + esac |
| + [ -n "${STATUS_CARBON_GRID_CENTRAL:-}" ] && gce=$STATUS_CARBON_GRID_CENTRAL |
| + [ -n "${STATUS_CARBON_GRID_HIGH:-}" ] && ghi=$STATUS_CARBON_GRID_HIGH |
| + printf '0 %s %s' "$gce" "$ghi" |
| +} |
| + |
| +# hetzner_vcpus — total vCPUs across every server in the Hetzner account (the |
| +# hardware the whole fleet runs on; sums so it scales as boxes are added). Same |
| +# paginated servers-API read as fetch_hetzner_cents (dry-run twin included); |
| +# each server_type.cores must be an integer or we can't honestly total. Prints |
| +# the sum, or returns 1 (no token / bad page) so the run block stays absent. |
| +hetzner_vcpus() { |
| + local page=1 n=0 f next load="CREATE TABLE srv(j TEXT);" |
| + while :; do |
| + n=$((n + 1)) |
| + f="$TMP/hetzner-vcpus-$n.json" |
| + hetzner_get_page "$page" "$f" || return 1 |
| + [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.servers')='array' |
| + AND json_type(readfile('$f'), '\$.meta.pagination')='object'")" = "1" ] || return 1 |
| + load="$load INSERT INTO srv SELECT value FROM json_each(readfile('$f'), '\$.servers');" |
| + next=$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.meta.pagination.next_page'), '')") |
| + [ -n "$next" ] || break |
| + case "$next" in *[!0-9]*) return 1 ;; esac |
| + [ "$n" -lt 10 ] || return 1 |
| + page=$next |
| + done |
| + [ "$(sqj "$load SELECT COUNT(*) FROM srv WHERE json_type(j, '\$.server_type.cores') <> 'integer'")" = "0" ] || return 1 |
| + sqj "$load SELECT COALESCE(SUM(json_extract(j, '\$.server_type.cores')), 0) FROM srv" |
| +} |
| + |
| +# carbon_run_object — the live operational estimate as a JSON object, or nothing |
| +# printed when the router term can't be resolved (no Hetzner token / fetch fail) |
| +# so the published `run` stays absent and the page shows "pending", never a fake |
| +# zero. Math (all BANDED): daily kg = vCPUs × W/vCPU × 24h × grid(g/kWh) × PUE |
| +# / 1e6, plus small explicit AWS-storage + bot-inference terms. Reported as a |
| +# daily rate, a cumulative-to-date (× days since CARBON_SINCE, or a pinned |
| +# STATUS_CARBON_DAYS), and normalised per active space (denominator = the public |
| +# instance count from the registry — NEVER per-member). Aggregates only: vCPU |
| +# total, watts, grid, kg, space count — no hostnames/IPs/per-space needles. |
| +carbon_run_object() { |
| + local vcpus glo gce ghi spaces days since now |
| + vcpus=$(hetzner_vcpus) || vcpus="" |
| + case "$vcpus" in ''|*[!0-9]*|0) return 0 ;; esac # no router term → run absent |
| + set -- $(grid_band); glo=$1 gce=$2 ghi=$3 |
| + spaces=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance'" 2>/dev/null || echo "") |
| + case "$spaces" in ''|*[!0-9]*|0) spaces=1 ;; esac |
| + since=$CARBON_SINCE |
| + days=${STATUS_CARBON_DAYS:-} |
| + [ -n "$days" ] || days=$(sqj "SELECT MAX(1, CAST(julianday('now') - julianday('$since') AS INTEGER))") |
| + case "$days" in ''|*[!0-9]*) days=1 ;; esac |
| + now=$(now_iso) |
| + # Daily bands live in the CTE d(lo,ce,hi); cumulative/per-space derive from the |
| + # UNROUNDED daily so rounding never compounds. Watts round to whole; kg to 6dp. |
| + sqj "WITH d(lo, ce, hi) AS (VALUES( |
| + $vcpus*$CARBON_W_LO*24*$glo*$CARBON_PUE/1e6 + $CARBON_AWS_LO + $CARBON_INF_LO, |
| + $vcpus*$CARBON_W_CE*24*$gce*$CARBON_PUE/1e6 + $CARBON_AWS_CE + $CARBON_INF_CE, |
| + $vcpus*$CARBON_W_HI*24*$ghi*$CARBON_PUE/1e6 + $CARBON_AWS_HI + $CARBON_INF_HI)) |
| + SELECT json_object( |
| + 'generated_at', '$now', |
| + 'since', '$since', |
| + 'spaces', $spaces, |
| + 'pue', $CARBON_PUE, |
| + 'vcpus', $vcpus, |
| + 'watts', json_object('low', CAST(ROUND($vcpus*$CARBON_W_LO) AS INTEGER), |
| + 'central', CAST(ROUND($vcpus*$CARBON_W_CE) AS INTEGER), |
| + 'high', CAST(ROUND($vcpus*$CARBON_W_HI) AS INTEGER)), |
| + 'grid_g_per_kwh', json_object('low', $glo, 'central', $gce, 'high', $ghi), |
| + 'daily_kg', json_object('low', ROUND(lo,6), 'central', ROUND(ce,6), 'high', ROUND(hi,6)), |
| + 'cumulative_kg', json_object('low', ROUND(lo*$days,6), 'central', ROUND(ce*$days,6), 'high', ROUND(hi*$days,6)), |
| + 'per_space_daily_kg', json_object('low', ROUND(lo/$spaces,6), 'central', ROUND(ce/$spaces,6), 'high', ROUND(hi/$spaces,6)), |
| + 'services', json_array( |
| + json_object('name', 'router + instances', 'daily_kg_central', ROUND($vcpus*$CARBON_W_CE*24*$gce*$CARBON_PUE/1e6, 6)), |
| + json_object('name', 'object storage (AWS)', 'daily_kg_central', $CARBON_AWS_CE), |
| + json_object('name', 'bot inference', 'daily_kg_central', $CARBON_INF_CE))) |
| + FROM d" |
| +} |
| + |
| +# build_carbon <out> <src> — republish the committed carbon.json (the built |
| +# ledger, tools/carbon-tally.mjs) and inject a live `run` block. We nest HERE |
| +# rather than in the tool: the committed file stays exactly the built ledger |
| +# ({ built:{…} }), and the only transform is this one merge — so "carbon is |
| +# committed data" holds and the run block never touches git. A malformed source |
| +# or no live inputs → republished verbatim (run stays absent → page = pending). |
| +build_carbon() { |
| + local out=$1 src=$2 |
| + if [ "$(sqj "SELECT json_valid(readfile('$src')) AND json_type(readfile('$src'), '\$.built')='object'")" != "1" ]; then |
| + cp "$src" "$out"; return |
| + fi |
| + carbon_run_object > "$TMP/carbon-run.json" || true |
| + if [ -s "$TMP/carbon-run.json" ]; then |
| + sqj "SELECT json_set(readfile('$src'), '\$.run', json(readfile('$TMP/carbon-run.json')))" > "$out" |
| + else |
| + cp "$src" "$out" |
| + fi |
| +} |
| + |
| # ---- upload ------------------------------------------------------------------ |
| upload() { # file key content-type cache-control |
| @@ -897,9 +1040,13 @@ publish() { |
| [ -f "$PAGES_DIR/index.html" ] && upload "$PAGES_DIR/index.html" index.html "text/html; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/books.html" ] && upload "$PAGES_DIR/books.html" books.html "text/html; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/carbon.html" ] && upload "$PAGES_DIR/carbon.html" carbon.html "text/html; charset=utf-8" "max-age=300" |
| - # The carbon tally + contributors index are committed data (see header) — |
| - # republished, not built. |
| - [ -f "$PAGES_DIR/carbon.json" ] && upload "$PAGES_DIR/carbon.json" carbon.json "application/json" "max-age=300" |
| + # The carbon tally is committed data (the built ledger) — republished as-is |
| + # EXCEPT for a live `run` block injected here (build_carbon). Built untouched, |
| + # run recomputed each publish; absent inputs → run stays absent (page pending). |
| + if [ -f "$PAGES_DIR/carbon.json" ]; then |
| + build_carbon "$TMP/carbon.json" "$PAGES_DIR/carbon.json" |
| + upload "$TMP/carbon.json" carbon.json "application/json" "max-age=300" |
| + fi |
| [ -f "$PAGES_DIR/contributors.html" ] && upload "$PAGES_DIR/contributors.html" contributors.html "text/html; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/contributors.json" ] && upload "$PAGES_DIR/contributors.json" contributors.json "application/json" "max-age=300" |
| # The security ledger: page + committed, build-time-scanned, REDACTED JSON |
| @@ -1260,9 +1407,69 @@ EOF |
| { echo "dry-run: metered results must be cached per provider" >&2; exit 1; } |
| ;; |
| esac |
| + |
| + # ---- carbon.json: the committed BUILT ledger (a fixed fixture here, so the |
| + # run fixture stays stable across real accruals) + a live-computed RUN block. |
| + # The run block folds from the Hetzner servers fixture (cpx31=4 + cx22=2 → 6 |
| + # vCPUs), the box region grid (Germany → 0/350/400 g/kWh), PUE 1.15, a pinned |
| + # 10-day span and the fixture registry's 6 instances. Only the expense modes |
| + # that stage the Hetzner servers fixture (default + hetzner-constant) give the |
| + # run its vCPU input; omit/stale-cache legitimately leave run absent (pending). |
| + cat > "$FIXDIR/carbon-built.json" <<'EOF' |
| +{ |
| + "updated_at": "2026-07-11T00:00:00.000Z", |
| + "methodology": "https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md", |
| + "built": { |
| + "unit": "kg CO2e", |
| + "factors_mg_co2e_per_output_token": { "low": 0.1, "central": 0.55, "high": 3 }, |
| + "not_counted": "cache reads, cloud-side agent runs, and contributor machines not yet accrued", |
| + "entries": [ |
| + { "period_start": "2026-07-01T00:00:00.000Z", "period_end": "2026-07-11T00:00:00.000Z", |
| + "source": "ada-sketching", "basis": "measured", "sessions": 10, "output_tokens": 1000000, |
| + "fresh_input_tokens": 5000000, "cache_read_tokens": 90000000, |
| + "kg_low": 0.1, "kg_central": 0.6, "kg_high": 3, "note": "dry-run fixture" } |
| + ], |
| + "totals": { "output_tokens": 1000000, "kg_low": 0.1, "kg_central": 0.6, "kg_high": 3 } |
| + } |
| +} |
| +EOF |
| + local cvc cwt cgr cda ccu cps |
| + CARBON_SINCE=2026-07-02 |
| + STATUS_CARBON_DAYS=10 |
| + build_carbon "$outdir/carbon.json" "$FIXDIR/carbon-built.json" |
| + [ "$(sqj "SELECT json_valid(readfile('$outdir/carbon.json'))")" = "1" ] || |
| + { echo "dry-run: carbon.json is not valid JSON" >&2; exit 1; } |
| + [ "$(sqj "SELECT json_type(readfile('$outdir/carbon.json'), '\$.built')='object'")" = "1" ] || |
| + { echo "dry-run: carbon built ledger missing (built must be republished verbatim)" >&2; exit 1; } |
| + case "${STATUS_DRY_EXPENSES:-}" in |
| + omit|stale-cache) |
| + [ "$(sqj "SELECT json_type(readfile('$outdir/carbon.json'), '\$.run') IS NULL")" = "1" ] || |
| + { echo "dry-run: carbon run must be absent when no server inputs resolve (pending, never a fake zero)" >&2; exit 1; } |
| + ;; |
| + *) |
| + cvc=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.vcpus')") |
| + cwt=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.watts.central')") |
| + cgr=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.grid_g_per_kwh.central')") |
| + cda=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.daily_kg.central')") |
| + ccu=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.cumulative_kg.central')") |
| + cps=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.per_space_daily_kg.central')") |
| + # daily central = 6 × 3.5 W × 24h × 350 g/kWh × 1.15 / 1e6 + 0.001 + 0.005 |
| + # = 0.20886 kg; cumulative = ×10 days = 2.0886; per space = ÷6. |
| + [ "$cvc" = "6" ] && [ "$cwt" = "21" ] && [ "$cgr" = "350" ] && |
| + [ "$cda" = "0.20886" ] && [ "$ccu" = "2.0886" ] && [ "$cps" = "0.03481" ] || |
| + { echo "dry-run: carbon run math (got vcpus=$cvc watts=$cwt grid=$cgr daily=$cda cumulative=$ccu per_space=$cps)" >&2; exit 1; } |
| + ;; |
| + esac |
| + # Privacy: no fixture marker, hostname, socket path, or IP may reach carbon.json. |
| + if grep -qE 'hzfix|ada-sketching@|\.example|\.sock|[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "$outdir/carbon.json"; then |
| + echo "dry-run: PRIVACY LEAK — a hostname/IP reached carbon.json" >&2 |
| + exit 1 |
| + fi |
| + |
| echo "--- fleet.json ---"; cat "$outdir/fleet.json" |
| echo "--- books.json ---"; cat "$outdir/books.json" |
| - echo "dry-run ok: valid JSON, no hostnames, no per-payer fields, capacity abstract ($lvl), expenses ${STATUS_DRY_EXPENSES:-itemized}" |
| + echo "--- carbon.json ---"; cat "$outdir/carbon.json" |
| + echo "dry-run ok: valid JSON, no hostnames, no per-payer fields, capacity abstract ($lvl), expenses ${STATUS_DRY_EXPENSES:-itemized}, carbon run ${cda:-absent}" |
| } |
| case "${1:-}" in |
diff --git a/docs/comparison-carbon.md b/docs/comparison-carbon.md| index 8b85601..d0f899c 100644 |
| --- a/docs/comparison-carbon.md |
| +++ b/docs/comparison-carbon.md |
| @@ -136,12 +136,14 @@ Honesty section, in the spirit of the siblings: |
| market-based-zero renewable contract versus this stack on a coal grid |
| inverts the operational numbers. Embodied carbon — ~10 servers versus |
| ~500 — favors the small fleet regardless of who signs which PPA. |
| -- **Runtime AI inference isn't counted.** Heavy LLM use by the bot sidecars |
| - would dwarf either infrastructure's footprint — and applies to both models |
| - equally, so it cancels out of the ratio and dominates the absolutes. (It is |
| - at least *measurable* exactly: the sidecars run on an API key, so the |
| - provider console has precise token counts.) The AI cost of *building* the |
| - app, though, is now estimated — see the next section. |
| +- **Runtime AI inference isn't counted in the ratio.** Heavy LLM use by the |
| + bot sidecars would dwarf either infrastructure's footprint — and applies to |
| + both models equally, so it cancels out of *this comparison's* ratio and |
| + dominates the absolutes. (It is at least *measurable* exactly: the sidecars |
| + run on an API key, so the provider console has precise token counts.) The |
| + live status-page **run** estimate does carry a small, bounded inference term |
| + (see *The status page*), and the AI cost of *building* the app is estimated |
| + in full — see the next section. |
| - **Client-side work moves joules, it doesn't create many.** Crypto and |
| image downscaling run on members' devices; the marginal energy per |
| message on a phone is micro-joules against a battery already being |
| @@ -182,12 +184,14 @@ serving energy, and nobody publishes a price for cached prefill, which is |
| cloud-side agent runs and any contributor machine that hasn't accrued its |
| entries yet, make this a floor. |
| -**Result so far: ~3–100 kg CO₂e, central ~19 kg** — like driving a petrol |
| -car ~110 km, or ~8 litres of petrol. Set against the fleet's ~2 g per member |
| -per year, the central estimate equals roughly **9,000 member-years of |
| -actually running the service**. E2EE keeps the *servers* from burning watts |
| -on members' content; the watts moved upstream, into the model that wrote |
| -the code. |
| +**Result so far: ~6–180 kg CO₂e, central ~34 kg.** To make that legible, |
| +the status page renders the central estimate as a set of everyday anchors |
| +(each factor documented under *What the anchors mean* below): about **half a |
| +Dublin–London economy flight**, **~15 hours** of a petrol leaf blower, |
| +**~0.6 kg of beef**, and **~1.6 tree-years** to soak it back up — or **~200 |
| +km** in a petrol car (~15 litres). E2EE keeps the *servers* from burning |
| +watts on members' content; the watts moved upstream, into the model that |
| +wrote the code. |
| **This number accrues, so it's kept as a running tally**: the ledger lives |
| in [`web/status/carbon.json`](../web/status/carbon.json) and publishes with |
| @@ -203,6 +207,77 @@ machine's last entry — recipe in `docs/status.md § Keeping the carbon tally |
| accruing`. A gap that slips past the pruning window gets an |
| explicitly-flagged `extrapolated` entry, like the first week did. |
| +## The status page: two figures, built and run |
| + |
| +The public page carries **two** ledgers, deliberately separate and neither |
| +editorialised against the other: |
| + |
| +- **Built** — the one-time AI cost of *making* the app (the tally above). It |
| + grows only when more AI development happens. |
| +- **Run** — the ongoing cost of *operating* the whole fleet, recomputed live |
| + at every publish. It rises with the fleet — one box today, fifty later. |
| + |
| +They are two different things — *one-time to make* versus *ongoing to |
| +operate, and rising with scale* — so the page does **not** claim one is |
| +"bigger" than the other; the ratio between them is a moving target that says |
| +more about how young and how small the fleet is than about either number. |
| +The figure that stays meaningful at any size is the **per-space** run rate. |
| + |
| +**The run estimate, banded low/central/high.** Computed at publish time in |
| +`deploy/status-publish.sh` (`carbon_run_object`), summing every service the |
| +stack needs: |
| + |
| +1. **The router box + its instances.** The Hetzner Cloud API gives each |
| + server's `server_type` (its vCPU count); the whole fleet is a lightly |
| + loaded, hibernating workload (load ~0.2 on the box), so its attributable |
| + draw is a **low-single-digit-to-~15 W slice** of the box, modelled as |
| + **per-vCPU average watts — 1.5 / 3.5 / 7.5 W** (marginal load-proportional |
| + → whole-box idle-inclusive). Times **24 h**, times the **box region's grid |
| + intensity**, times **PUE ≈ 1.15**. The prod box is in **Helsinki, so the |
| + Finnish grid ≈ 80–130 g CO₂e/kWh** (a low-carbon, hydro/nuclear/wind mix); |
| + the low band takes the grid to **≈ 0** (Hetzner's EU sites run on a |
| + renewable-electricity contract), the high band to the location grid. vCPUs |
| + sum across every box in the account, so the term scales as boxes are added. |
| +2. **Object storage (AWS).** Our S3 use is a slice of ciphertext plus |
| + requests — near-zero, but stated as a **small explicit banded term** |
| + (~a gram a day), never folded to zero. |
| +3. **Bot inference.** The sidecars' ongoing Anthropic API use. Small today |
| + and **bounded wide** (the high band allows for busy days); precise token |
| + counts exist in the provider console, but the page publishes a bounded |
| + estimate rather than couple to a live spend feed. |
| + |
| +The result publishes as a **daily rate**, a **cumulative-to-date** (the daily |
| +rate × days since the fleet's start — a first-order figure that assumes |
| +today's rate held), and **per active space** (denominator = the public |
| +instance count from the registry — **never** per-member, no member counts). |
| +At today's single box that lands at **tens of grams of CO₂e per day** for the |
| +whole fleet — less than a nightlight — and a per-space rate of a **gram or so |
| +per space per day**. All coefficients are honest bands, and the region grid |
| +is the one that can dominate everything (see below). |
| + |
| +## What the anchors mean |
| + |
| +The everyday equivalents shown beside **both** figures (each scaled to its |
| +own magnitude — the build total, and the run's cumulative-to-date) convert |
| +the central kg through published, banded factors. Snapshot **July 2026**; |
| +these move, so re-check before quoting. |
| + |
| +| anchor | factor (central) | band | source class | |
| +|---|---|---|---| |
| +| Petrol car | 170 g CO₂e/km; 1 L petrol ≈ 2.3 kg | ±~15% | DEFRA/EPA average passenger car | |
| +| **Short-haul flight** | **0.145 kg CO₂e per economy passenger-km, incl. the non-CO₂ altitude uplift (~1.9× radiative forcing)** | **0.13–0.16** | DEFRA short-haul-with-RF economy class | |
| +| **Leaf blower** | 2-stroke ~1 L/h → **~2.3 kg CO₂/h** | ±~25% | EPA small-engine fuel burn × petrol EF | |
| +| **Beef** | **~60 kg CO₂e/kg** (land-use-inclusive) | 40–100 | Poore & Nemecek (Science 2018) / Our World in Data | |
| +| **Tree-years** | a mature tree absorbs **~21 kg CO₂/yr** | 10–40 | EPA / arboricultural estimates | |
| + |
| +The flight is expressed against **one real short leg — Dublin–London |
| +(DUB–LHR) ≈ 460 km great-circle → ≈ 67 kg CO₂e per economy leg** — so "half a |
| +Dublin–London flight" is ~34 kg. The uplift matters: without the |
| +radiative-forcing multiplier the same leg is ~40% lower, so the page names it |
| +explicitly. Distances and volumes convert with the metric/imperial toggle; |
| +the mass anchors (beef) convert too; counts and durations (flights, |
| +leaf-blower time, tree-years) are unit-agnostic. |
| + |
| ## The one-sentence version |
| **~2 g versus ~75 g CO₂e per member per year — the difference between an |
| @@ -217,6 +292,16 @@ provisioning forever.** |
| - [Datadog Container Report — utilization findings](https://www.datadoghq.com/container-report/) |
| - [AWS sustainability — fleet PUE](https://sustainability.aboutamazon.com/products-services/aws-cloud) |
| - [IEA — global electricity carbon intensity](https://www.iea.org/energy-system/electricity) |
| +- [Electricity Maps — Finland grid carbon intensity](https://app.electricitymaps.com/zone/FI) (the prod box's Helsinki region; ~80–130 g CO₂e/kWh) |
| +- Everyday anchors (all snapshot July 2026, banded): |
| + [DEFRA/DESNZ greenhouse-gas conversion factors](https://www.gov.uk/government/collections/government-conversion-factors-for-company-reporting) |
| + (average car ~0.17 kg CO₂e/km, petrol ~2.31 kg/L, short-haul economy air |
| + ~0.15 kg CO₂e/pax-km with radiative forcing); |
| + [Poore & Nemecek, *Reducing food's environmental impacts* (Science 2018)](https://www.science.org/doi/10.1126/science.aaq0216) |
| + via [Our World in Data](https://ourworldindata.org/environmental-impacts-of-food) |
| + (beef ~60 kg CO₂e/kg, land-use-inclusive); |
| + [US EPA — greenhouse gas equivalencies](https://www.epa.gov/energy/greenhouse-gases-equivalencies-calculator-calculations-and-references) |
| + (a mature tree ~21 kg CO₂/yr; small-engine fuel burn) |
| - [Google Cloud — measuring the environmental impact of AI inference](https://cloud.google.com/blog/products/infrastructure/measuring-the-environmental-impact-of-ai-inference) |
| - [Mistral AI — lifecycle analysis of Large 2](https://mistral.ai/news/our-contribution-to-a-global-environmental-standard-for-ai) |
| - [Epoch AI — how much energy does ChatGPT use?](https://epoch.ai/gradient-updates/how-much-energy-does-chatgpt-use) |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index 4f57140..64fdb78 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -78,9 +78,15 @@ function resolve(obj, path) { |
| const indexHtml = read("web/status/index.html"); |
| const booksHtml = read("web/status/books.html"); |
| -// The carbon tally is COMMITTED data (accrued by tools/carbon-tally.mjs), not |
| -// dry-run output — the real file is its own fixture. |
| +// The carbon page reads TWO shapes. `built` is COMMITTED data (accrued by |
| +// tools/carbon-tally.mjs) — the real web/status/carbon.json is its own fixture, |
| +// and carries only { updated_at, methodology, built:{…} } (run absent → page |
| +// shows "pending"). The live `run` block is injected by deploy/status-publish.sh |
| +// at upload; its shape is pinned by the dry-run fixture test/status-fixtures/ |
| +// carbon.json (built + run — regenerate with `./deploy/status-publish.sh |
| +// --dry-run <dir>` then copy <dir>/carbon.json here). |
| const carbon = JSON.parse(read("web/status/carbon.json")); |
| +const carbonRun = JSON.parse(read("test/status-fixtures/carbon.json")); |
| const carbonHtml = read("web/status/carbon.html"); |
| // The contributors index is COMMITTED data too (tools/contributors.mjs). |
| const contributors = JSON.parse(read("web/status/contributors.json")); |
| @@ -109,9 +115,14 @@ await test("books page fields all exist in one of the two books shapes", () => { |
| } |
| }); |
| -await test("carbon page fields all exist in the committed carbon.json", () => { |
| +await test("carbon page fields resolve — built in committed carbon.json, run in the live fixture", () => { |
| for (const f of fields(carbonHtml)) { |
| - assert.notEqual(resolve(carbon, f), undefined, "carbon.json is missing " + f); |
| + if (f.startsWith("run.")) { |
| + // run.* is absent from the committed file by design — pinned by the fixture. |
| + assert.notEqual(resolve(carbonRun, f), undefined, "run fixture is missing " + f); |
| + } else { |
| + assert.notEqual(resolve(carbon, f), undefined, "committed carbon.json is missing " + f); |
| + } |
| } |
| }); |
| @@ -174,7 +185,7 @@ await test("degraded books shape carries available:false + reason", () => { |
| // customer, transaction, or provider-account/workspace id may appear in |
| // published output. |
| await test("fixtures contain no per-payer ids or hostnames", () => { |
| - for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, contributors, ...securityShapes.map(([, s]) => s)]) { |
| + for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, carbonRun, contributors, ...securityShapes.map(([, s]) => s)]) { |
| const s = JSON.stringify(j); |
| assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|hzfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| } |
| @@ -294,11 +305,12 @@ await test("hetzner: API-computed cents with rounding; constant overrides", () = |
| // anonymous name-pair labels, same rule as the fleet's box labels: the tally |
| // is public, so never a hostname or username. |
| await test("carbon tally arithmetic + per-source chronology hold", () => { |
| - const f = carbon.factors_mg_co2e_per_output_token; |
| + const b = carbon.built; |
| + const f = b.factors_mg_co2e_per_output_token; |
| assert.ok(f.low < f.central && f.central < f.high, "factors ordered"); |
| let out = 0, kg = { low: 0, central: 0, high: 0 }; |
| const prevEnd = new Map(); |
| - for (const e of carbon.entries) { |
| + for (const e of b.entries) { |
| assert.match(e.source, /^[a-z]+-[a-z]+$/, "source must be a name-pair label, got " + e.source); |
| assert.ok(["measured", "extrapolated"].includes(e.basis), "basis word: " + e.basis); |
| assert.ok(Date.parse(e.period_start) < Date.parse(e.period_end), "period runs forward"); |
| @@ -313,13 +325,62 @@ await test("carbon tally arithmetic + per-source chronology hold", () => { |
| } |
| out += e.output_tokens; |
| } |
| - assert.equal(carbon.totals.output_tokens, out, "totals.output_tokens is the ledger sum"); |
| + assert.equal(b.totals.output_tokens, out, "built.totals.output_tokens is the ledger sum"); |
| for (const band of ["low", "central", "high"]) { |
| - assert.ok(Math.abs(carbon.totals["kg_" + band] - kg[band]) <= 0.05, |
| - "totals.kg_" + band + " is the ledger sum"); |
| + assert.ok(Math.abs(b.totals["kg_" + band] - kg[band]) <= 0.05, |
| + "built.totals.kg_" + band + " is the ledger sum"); |
| + } |
| + assert.equal(Date.parse(carbon.updated_at), Math.max(...b.entries.map((e) => Date.parse(e.period_end))), |
| + "updated_at is the newest built period_end"); |
| +}); |
| + |
| +// The injected run block: an ALLOWLIST schema (any extra key fails, so a needle |
| +// can't ship by accident), banded low≤central≤high, aggregates only — no |
| +// hostname/IP/socket, and the per-space denominator is the public instance |
| +// count, NEVER per-member. |
| +await test("run block carries only the allowed operational schema, banded, no needles", () => { |
| + const r = carbonRun.run; |
| + assert.deepEqual(Object.keys(r).sort(), |
| + ["cumulative_kg", "daily_kg", "generated_at", "grid_g_per_kwh", "per_space_daily_kg", |
| + "pue", "services", "since", "spaces", "vcpus", "watts"], "run keys"); |
| + assert.match(r.generated_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "generated_at is ISO UTC"); |
| + assert.match(r.since, /^\d{4}-\d{2}-\d{2}$/, "since is a date"); |
| + assert.ok(Number.isInteger(r.spaces) && r.spaces > 0, "spaces is a positive instance count"); |
| + assert.ok(Number.isInteger(r.vcpus) && r.vcpus > 0, "vcpus is a positive count"); |
| + assert.ok(typeof r.pue === "number" && r.pue >= 1, "pue is a real ≥ 1"); |
| + for (const band of ["watts", "grid_g_per_kwh", "daily_kg", "cumulative_kg", "per_space_daily_kg"]) { |
| + assert.deepEqual(Object.keys(r[band]).sort(), ["central", "high", "low"], band + " is banded"); |
| + assert.ok(r[band].low <= r[band].central && r[band].central <= r[band].high, band + " ordered low≤central≤high"); |
| } |
| - assert.equal(Date.parse(carbon.updated_at), Math.max(...carbon.entries.map((e) => Date.parse(e.period_end))), |
| - "updated_at is the newest period_end"); |
| + for (const s of r.services) { |
| + assert.deepEqual(Object.keys(s).sort(), ["daily_kg_central", "name"], "service " + s.name + " keys"); |
| + assert.ok(typeof s.name === "string" && s.name.length > 0, "service name"); |
| + assert.ok(typeof s.daily_kg_central === "number" && s.daily_kg_central >= 0, s.name + " daily kg ≥ 0"); |
| + } |
| + const t = JSON.stringify(r); |
| + assert.ok(!/\d+\.\d+\.\d+\.\d+|[a-z0-9-]+\.(sock|example|com|net|chat|at)\b/i.test(t), |
| + "run block reads like a hostname/IP: " + t.slice(0, 80)); |
| +}); |
| + |
| +// The run math folds server_type + region grid → watts → kg exactly, so the |
| +// generator's arithmetic can't drift (mirrors deploy/status-publish.sh's |
| +// carbon_run_object + its dry-run assertions). 6 vCPUs (cpx31=4 + cx22=2), the |
| +// Germany fixture grid 350 g/kWh, W/vCPU central 3.5, PUE 1.15, 10-day span, 6 |
| +// spaces. |
| +await test("run fixture folds server_type + grid to the asserted watts→kg math", () => { |
| + const r = carbonRun.run; |
| + assert.equal(r.vcpus, 6, "vCPUs summed across the servers fixture"); |
| + assert.equal(r.watts.central, 21, "6 vCPU × 3.5 W"); |
| + assert.equal(r.grid_g_per_kwh.central, 350, "Germany fixture grid"); |
| + assert.equal(r.pue, 1.15); |
| + // 6 × 3.5 × 24 × 350 × 1.15 / 1e6 + 0.001 (AWS) + 0.005 (inference) = 0.20886. |
| + assert.equal(r.daily_kg.central, 0.20886, "daily central kg"); |
| + assert.equal(r.cumulative_kg.central, 2.0886, "cumulative = daily × 10 days"); |
| + assert.equal(r.per_space_daily_kg.central, 0.03481, "per space = daily ÷ 6 spaces"); |
| + // The service breakdown's central terms sum to the daily central (router term |
| + // is the watts→kg piece; AWS + inference are the small explicit terms). |
| + const sum = r.services.reduce((a, s) => a + s.daily_kg_central, 0); |
| + assert.ok(Math.abs(sum - r.daily_kg.central) <= 1e-6, "services sum to the daily central"); |
| }); |
| // Capacity + hosts publish ABSTRACT levels only (Paul's call: bucketed words, |
diff --git a/test/status-fixtures/carbon.json b/test/status-fixtures/carbon.json| new file mode 100644 |
| index 0000000..50a5748 |
| --- /dev/null |
| +++ b/test/status-fixtures/carbon.json |
| @@ -0,0 +1 @@ |
| +{"updated_at":"2026-07-11T00:00:00.000Z","methodology":"https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md","built":{"unit":"kg CO2e","factors_mg_co2e_per_output_token":{"low":0.1,"central":0.55,"high":3},"not_counted":"cache reads, cloud-side agent runs, and contributor machines not yet accrued","entries":[{"period_start":"2026-07-01T00:00:00.000Z","period_end":"2026-07-11T00:00:00.000Z","source":"ada-sketching","basis":"measured","sessions":10,"output_tokens":1000000,"fresh_input_tokens":5000000,"cache_read_tokens":90000000,"kg_low":0.1,"kg_central":0.6,"kg_high":3,"note":"dry-run fixture"}],"totals":{"output_tokens":1000000,"kg_low":0.1,"kg_central":0.6,"kg_high":3}},"run":{"generated_at":"2026-07-12T16:37:32Z","since":"2026-07-02","spaces":6,"pue":1.15,"vcpus":6,"watts":{"low":9,"central":21,"high":45},"grid_g_per_kwh":{"low":0,"central":350,"high":400},"daily_kg":{"low":0.0007,"central":0.20886,"high":0.5498},"cumulative_kg":{"low":0.007,"central":2.0886,"high":5.498},"per_space_daily_kg":{"low":0.000117,"central":0.03481,"high":0.091633},"services":[{"name":"router + instances","daily_kg_central":0.20286},{"name":"object storage (AWS)","daily_kg_central":0.001},{"name":"bot inference","daily_kg_central":0.005}]}} |
diff --git a/tools/carbon-tally.mjs b/tools/carbon-tally.mjs| index caff488..8079807 100644 |
| --- a/tools/carbon-tally.mjs |
| +++ b/tools/carbon-tally.mjs |
| @@ -66,9 +66,26 @@ if (!existsSync(tallyPath)) { |
| process.exit(mode === "check" ? 0 : 1); |
| } |
| const tally = JSON.parse(readFileSync(tallyPath, "utf8")); |
| +// The published carbon.json is { updated_at, methodology, built:{…}, run:{…} }: |
| +// `built` is this ledger (accrued here), `run` is a live operational block the |
| +// status publisher injects at upload (never written by this tool). We only ever |
| +// touch `built`. A pre-nesting flat tally is migrated in place so old checkouts |
| +// still accrue cleanly. |
| +if (!tally.built) { |
| + tally.built = { |
| + unit: tally.unit, |
| + factors_mg_co2e_per_output_token: tally.factors_mg_co2e_per_output_token, |
| + not_counted: tally.not_counted, |
| + entries: tally.entries || [], |
| + totals: tally.totals, |
| + }; |
| + for (const k of ["unit", "factors_mg_co2e_per_output_token", "not_counted", "entries", "totals"]) delete tally[k]; |
| +} |
| +const built = tally.built; |
| let mainEntries = []; |
| -try { mainEntries = JSON.parse(git("show origin/main:web/status/carbon.json")).entries; } catch {} |
| -const known = [...tally.entries, ...mainEntries]; |
| +// origin/main may still be flat (pre-nesting) or already nested — handle both. |
| +try { const m = JSON.parse(git("show origin/main:web/status/carbon.json")); mainEntries = (m.built ? m.built.entries : m.entries) || []; } catch {} |
| +const known = [...built.entries, ...mainEntries]; |
| const cutoff = Math.max(0, ...known.filter((e) => e.source === source).map((e) => Date.parse(e.period_end))); |
| if (mode === "check") { |
| @@ -132,7 +149,7 @@ if (out === 0) { |
| } |
| if (noTs) console.error("note: skipped " + noTs + " usage lines without timestamps"); |
| -const factors = tally.factors_mg_co2e_per_output_token; |
| +const factors = built.factors_mg_co2e_per_output_token; |
| const kg = (f) => Math.round((out * f) / 1e6 * 10) / 10; // mg → kg, 1 decimal |
| const entry = { |
| period_start: new Date(tsMin).toISOString(), |
| @@ -156,18 +173,18 @@ for (const [model, n] of [...byModel].sort((a, b) => b[1] - a[1])) { |
| console.log(JSON.stringify(entry, null, 2)); |
| if (mode === "write") { |
| - tally.entries.push(entry); |
| - const sum = (pick) => Math.round(tally.entries.reduce((a, e) => a + pick(e), 0) * 10) / 10; |
| - tally.totals = { |
| - output_tokens: tally.entries.reduce((a, e) => a + e.output_tokens, 0), |
| + built.entries.push(entry); |
| + const sum = (pick) => Math.round(built.entries.reduce((a, e) => a + pick(e), 0) * 10) / 10; |
| + built.totals = { |
| + output_tokens: built.entries.reduce((a, e) => a + e.output_tokens, 0), |
| kg_low: sum((e) => e.kg_low), |
| kg_central: sum((e) => e.kg_central), |
| kg_high: sum((e) => e.kg_high), |
| }; |
| - tally.updated_at = new Date(Math.max(...tally.entries.map((e) => Date.parse(e.period_end)))).toISOString(); |
| + tally.updated_at = new Date(Math.max(...built.entries.map((e) => Date.parse(e.period_end)))).toISOString(); |
| writeFileSync(tallyPath, JSON.stringify(tally, null, 2) + "\n"); |
| - console.error("appended to " + tallyPath + " — totals now " + tally.totals.kg_central + " kg central (" + |
| - tally.totals.kg_low + "–" + tally.totals.kg_high + ")"); |
| + console.error("appended to " + tallyPath + " — built totals now " + built.totals.kg_central + " kg central (" + |
| + built.totals.kg_low + "–" + built.totals.kg_high + ")"); |
| } else { |
| console.error("dry run — pass --write to append"); |
| } |
diff --git a/web/status/carbon.html b/web/status/carbon.html| index ef89060..5968612 100644 |
| --- a/web/status/carbon.html |
| +++ b/web/status/carbon.html |
| @@ -1,8 +1,15 @@ |
| -<!-- 🤖 Public AI-build carbon tally (the honesty leg of docs/comparison-carbon.md). |
| +<!-- 🤖 Public carbon page: two honest ledgers side by side (docs/comparison-carbon.md). |
| Fully self-contained: inline CSS/JS, no external requests except |
| - ./carbon.json (relative). Unlike fleet/books, the JSON is COMMITTED data |
| - (web/status/carbon.json, accrued by tools/carbon-tally.mjs), republished |
| - as-is by deploy/status-publish.sh — aggregate token counts and kg only. |
| + ./carbon.json (relative). The JSON is { updated_at, methodology, |
| + built:{…}, run:{…} }: |
| + built — COMMITTED data (web/status/carbon.json, accrued by |
| + tools/carbon-tally.mjs): the one-time AI cost of BUILDING the app, |
| + republished as-is by deploy/status-publish.sh (token counts + kg only). |
| + run — a LIVE operational estimate the publisher INJECTS at upload |
| + (deploy/status-publish.sh § build_carbon): the ongoing cost of running |
| + every service in the fleet, banded, as a daily rate + cumulative + |
| + per-space. Absent (no live inputs) → the Run section shows a pending |
| + note, never a fake zero. |
| The JSON stays metric (canonical); the metric/imperial toggle is purely |
| presentational (?units=imperial|metric, remembered per browser). --> |
| <!doctype html> |
| @@ -15,18 +22,15 @@ |
| <link rel="icon" type="image/png" href="./icon-192.png" /> |
| <link rel="stylesheet" href="./status.css" /> |
| <style> |
| - /* Carbon-only: the units toggle, the equivalents lines, the accrual chart, |
| - and the ledger bars' wider columns + period/basis/kg cells. Everything |
| - shared lives in status.css. */ |
| - .tiles { margin-bottom: 12px; } |
| - .equiv { color: var(--ink-2); margin: 0 0 28px; } |
| - /* The undercounts note follows the ledger list flush — give it air. */ |
| - #notcounted { margin-top: 18px; } |
| - |
| - .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 0 0 10px; } |
| + /* Carbon-page-only styles — the Built/Run sections, units toggle, everyday |
| + anchors, accrual chart, and the ledger bars' wider columns. Everything |
| + shared (tokens, header, nav, .bars base, #error, footer) is in status.css. */ |
| + |
| + .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 12px 0 24px; } |
| .row h2 { margin: 0; } |
| /* Metric/imperial: a labelled two-button group; the selected unit reads by |
| - weight + an underline mark, never color alone. */ |
| + weight + an underline mark, never color alone. One toggle drives both |
| + sections. */ |
| .units { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } |
| .units button { |
| font: inherit; font-size: 13px; padding: 4px 12px; border: 0; cursor: pointer; |
| @@ -39,9 +43,33 @@ |
| } |
| .units button:focus-visible { outline: 2px solid var(--series); outline-offset: -2px; } |
| - /* Cumulative chart: single series (the title names it), 2px line, 10%-wash |
| - uncertainty band, hairline gridlines, crosshair + one tooltip. The ledger |
| - list below is the chart's table view. */ |
| + /* Two peer sections — Built (one-time) and Run (ongoing). The big heading |
| + names each; sub-heads inside are h3. */ |
| + .part { border-top: 1px solid var(--border); padding-top: 22px; margin-top: 30px; } |
| + .part:first-of-type { border-top: 0; padding-top: 0; margin-top: 8px; } |
| + .part-title { font-size: 19px; font-weight: 650; margin: 0 0 4px; } |
| + .part-lede { color: var(--ink-2); margin: 0 0 16px; } |
| + .part h3 { font-size: 15px; font-weight: 650; margin: 22px 0 10px; } |
| + |
| + .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-bottom: 12px; } |
| + .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| + .tile .label { font-size: 13px; color: var(--ink-2); } |
| + .tile .value { font-size: 28px; font-weight: 600; margin-top: 2px; } |
| + .equiv { color: var(--ink-2); margin: 0 0 14px; } |
| + #built-notcounted { margin-top: 18px; } |
| + |
| + /* Everyday anchors: a short list, each an "≈ …" line of real text; the mark |
| + is decorative (aria-hidden) so screen readers just read the phrase. */ |
| + .anchors { list-style: none; padding: 0; margin: 2px 0 8px; } |
| + .anchors li { color: var(--ink-2); padding: 3px 0 3px 22px; position: relative; } |
| + .anchors li::before { |
| + content: "\2248"; position: absolute; left: 0; color: var(--series); |
| + font-weight: 650; |
| + } |
| + |
| + /* Cumulative chart (Built): single series (no legend box — the title names |
| + it), 2px line, 10%-wash uncertainty band, hairline gridlines, crosshair + |
| + one tooltip. The ledger list below is the chart's table view. */ |
| figure { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 8px 6px; position: relative; } |
| figure svg { display: block; width: 100%; height: auto; } |
| figcaption { color: var(--ink-2); font-size: 13px; padding: 4px 8px 8px; } |
| @@ -54,12 +82,14 @@ |
| #tip .sub { color: var(--ink-2); } |
| .pt:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 50%; } |
| - /* Measurement ledger: wider first column + a kg cell; shared .bars supplies |
| - the container, rows, and bar mark. */ |
| + /* Built ledger + Run breakdown both use .bars (base in status.css); they |
| + just want a wider first column + a kg cell. */ |
| .bars li { grid-template-columns: minmax(120px, 200px) 1fr 5.5em; } |
| .bars .period { font-size: 14px; font-weight: 600; } |
| .bars .basis { display: block; font-size: 12px; font-weight: 400; color: var(--ink-2); } |
| .bars .kg { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } |
| + |
| + #run-pending { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; color: var(--ink-2); } |
| </style> |
| </head> |
| <body> |
| @@ -79,73 +109,115 @@ |
| <a href="./contributors.html">Contributors</a> |
| <a href="./prompts.html">Prompt Ledger</a> |
| </nav> |
| - <p class="lede">Eleven is AI-written, and the honest carbon ledger includes |
| - the AI: this is a running estimate of the CO₂e cost of the LLM tokens spent |
| - <em>building</em> the app. Token counts are exact; the carbon conversion is |
| - an estimate with wide error bars, because no model provider publishes |
| - per-token figures — <span id="factors">—</span>. Full methodology and |
| - honesty notes: |
| + <p class="lede">Eleven is AI-written, and the honest carbon ledger has two |
| + halves: what it cost to <em>build</em> the app (the AI tokens spent writing |
| + it) and what it costs to <em>run</em> it (every service in the fleet, every |
| + day). Building is one-time and grows only as more AI development happens; |
| + running is ongoing and rises with the fleet. Full methodology and honesty |
| + notes: |
| <a href="https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md">the |
| carbon comparison</a>.</p> |
| - <p class="asof">Last measured <time id="asof" datetime="">—</time></p> |
| + |
| + <div class="row"> |
| + <p class="asof">Last measured <time id="asof" datetime="">—</time></p> |
| + <div class="units" role="group" aria-label="Measurement units"> |
| + <button type="button" id="u-metric" aria-pressed="false">Metric</button> |
| + <button type="button" id="u-imperial" aria-pressed="false">Imperial</button> |
| + </div> |
| + </div> |
| <p id="error" role="alert">Couldn’t load the carbon tally. It may be mid-publish — this page retries every minute.</p> |
| - <div id="tally"> |
| - <section aria-labelledby="h-total"> |
| - <div class="row"> |
| - <h2 id="h-total">Estimated so far</h2> |
| - <div class="units" role="group" aria-label="Measurement units"> |
| - <button type="button" id="u-metric" aria-pressed="false">Metric</button> |
| - <button type="button" id="u-imperial" aria-pressed="false">Imperial</button> |
| - </div> |
| - </div> |
| + <!-- BUILT — the one-time AI cost of making the app --> |
| + <section class="part" id="built" aria-labelledby="h-built"> |
| + <h2 class="part-title" id="h-built">Built — one-time, to make it</h2> |
| + <p class="part-lede">A running estimate of the CO₂e cost of the LLM tokens |
| + spent building Eleven. Token counts are exact; the carbon conversion is an |
| + estimate with wide error bars, because no model provider publishes per-token |
| + figures — <span id="factors">—</span>. This grows only when more AI |
| + development happens.</p> |
| + <div class="tiles"> |
| + <div class="tile"><div class="label">Central estimate</div><div class="value" id="bt-central">—</div></div> |
| + <div class="tile"><div class="label">Honest range</div><div class="value" id="bt-range">—</div></div> |
| + <div class="tile"><div class="label">Output tokens</div><div class="value" id="bt-tokens">—</div></div> |
| + </div> |
| + <p class="equiv" style="margin-bottom:4px">That’s about the same CO₂e as, centrally:</p> |
| + <ul class="anchors" id="built-anchors"></ul> |
| + <p class="equiv" id="built-band"></p> |
| + |
| + <h3 id="h-built-chart">Accrual over time</h3> |
| + <figure> |
| + <svg id="chart" viewBox="0 0 720 240" role="img" aria-label=""></svg> |
| + <div id="tip"></div> |
| + <figcaption id="chart-cap">Cumulative CO₂e — the line is the central |
| + estimate, the wash is the low–high band. Exact figures are in the ledger |
| + below.</figcaption> |
| + </figure> |
| + |
| + <h3 id="h-built-entries">The ledger (central estimate per period)</h3> |
| + <ul class="bars" id="built-entries"><li><span class="period">Loading…</span></li></ul> |
| + <p class="equiv" id="built-notcounted"></p> |
| + </section> |
| + |
| + <!-- RUN — the ongoing cost of operating the whole fleet --> |
| + <section class="part" id="run" aria-labelledby="h-run"> |
| + <h2 class="part-title" id="h-run">Run — ongoing, to operate it</h2> |
| + <p class="part-lede">A live estimate of the CO₂e cost of running every |
| + service in the fleet — the router box and its instances, object storage, and |
| + bot inference — banded low to high. It rises as boxes are added; the |
| + per-space figure is what stays meaningful at any size.</p> |
| + |
| + <div id="run-body" hidden> |
| <div class="tiles"> |
| - <div class="tile"><div class="label">Central estimate</div><div class="value" id="t-central">—</div></div> |
| - <div class="tile"><div class="label">Honest range</div><div class="value" id="t-range">—</div></div> |
| - <div class="tile"><div class="label">Output tokens</div><div class="value" id="t-tokens">—</div></div> |
| + <div class="tile"><div class="label">Per day, whole fleet</div><div class="value" id="rt-daily">—</div><div class="label" id="rt-daily-range">—</div></div> |
| + <div class="tile"><div class="label" id="rt-cum-label">Cumulative to date</div><div class="value" id="rt-cum">—</div><div class="label" id="rt-cum-range">—</div></div> |
| + <div class="tile"><div class="label">Per space, per day</div><div class="value" id="rt-space">—</div></div> |
| </div> |
| - <p class="equiv" id="equiv"></p> |
| - </section> |
| - |
| - <section aria-labelledby="h-chart"> |
| - <h2 id="h-chart">Accrual over time</h2> |
| - <figure> |
| - <svg id="chart" viewBox="0 0 720 240" role="img" aria-label=""></svg> |
| - <div id="tip"></div> |
| - <figcaption id="chart-cap">Cumulative CO₂e — the line is the central |
| - estimate, the wash is the low–high band. Exact figures are in the |
| - ledger below.</figcaption> |
| - </figure> |
| - </section> |
| - |
| - <section aria-labelledby="h-entries"> |
| - <h2 id="h-entries">The ledger (central estimate per period)</h2> |
| - <ul class="bars" id="entries"><li><span class="period">Loading…</span></li></ul> |
| - <p class="equiv" id="notcounted"></p> |
| - </section> |
| - </div> |
| + <p class="equiv" style="margin-bottom:4px" id="run-anchors-lede">Cumulatively so far, that’s about the same CO₂e as, centrally:</p> |
| + <ul class="anchors" id="run-anchors"></ul> |
| + <p class="equiv" id="run-band"></p> |
| + |
| + <h3 id="h-run-breakdown">Where the watts go (central, per day)</h3> |
| + <ul class="bars" id="run-bars"></ul> |
| + <p class="equiv" id="run-assumptions"></p> |
| + </div> |
| + <p id="run-pending" hidden>Operational estimate pending — the live fleet |
| + inputs (server type and grid) weren’t available at the last publish. It |
| + fills in on the next successful publish; nothing here is ever shown as a |
| + fake zero.</p> |
| + </section> |
| <footer> |
| - <p>Accrues when we re-measure (at least weekly — the tally is committed to |
| - the repo and republished with these dashboards). The running fleet’s own |
| - footprint is tiny by comparison: see the carbon comparison linked above.</p> |
| + <p>The build tally accrues when we re-measure (at least weekly — it’s |
| + committed to the repo and republished with these dashboards). The run |
| + estimate is recomputed live at each publish. How the two compare in full is |
| + in the carbon comparison linked above.</p> |
| </footer> |
| </div> |
| <script> |
| // contract: the fields this page reads from ./carbon.json — kept in sync with |
| -// web/status/carbon.json (committed) and checked by test/status-contract.test.mjs. |
| +// web/status/carbon.json (committed `built`) + the run block the publisher |
| +// injects, and checked by test/status-contract.test.mjs. Built fields resolve |
| +// against the committed file; run fields resolve against the live/dry-run |
| +// fixture (run is absent from the committed file → the Run section is pending). |
| const FIELDS = [ |
| "updated_at", |
| - "factors_mg_co2e_per_output_token.low", |
| - "factors_mg_co2e_per_output_token.central", |
| - "factors_mg_co2e_per_output_token.high", |
| - "not_counted", |
| - "entries[].period_start", "entries[].period_end", "entries[].source", |
| - "entries[].basis", "entries[].output_tokens", "entries[].kg_low", |
| - "entries[].kg_central", "entries[].kg_high", "entries[].note", |
| - "totals.output_tokens", "totals.kg_low", "totals.kg_central", "totals.kg_high", |
| + "built.factors_mg_co2e_per_output_token.low", |
| + "built.factors_mg_co2e_per_output_token.central", |
| + "built.factors_mg_co2e_per_output_token.high", |
| + "built.not_counted", |
| + "built.entries[].period_start", "built.entries[].period_end", "built.entries[].source", |
| + "built.entries[].basis", "built.entries[].output_tokens", "built.entries[].kg_low", |
| + "built.entries[].kg_central", "built.entries[].kg_high", "built.entries[].note", |
| + "built.totals.output_tokens", "built.totals.kg_low", "built.totals.kg_central", "built.totals.kg_high", |
| + "run.generated_at", "run.since", "run.spaces", "run.pue", "run.vcpus", |
| + "run.watts.low", "run.watts.central", "run.watts.high", |
| + "run.grid_g_per_kwh.low", "run.grid_g_per_kwh.central", "run.grid_g_per_kwh.high", |
| + "run.daily_kg.low", "run.daily_kg.central", "run.daily_kg.high", |
| + "run.cumulative_kg.low", "run.cumulative_kg.central", "run.cumulative_kg.high", |
| + "run.per_space_daily_kg.low", "run.per_space_daily_kg.central", "run.per_space_daily_kg.high", |
| + "run.services[].name", "run.services[].daily_kg_central", |
| ]; |
| const $ = (id) => document.getElementById(id); |
| @@ -153,12 +225,28 @@ const day = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short |
| const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); |
| const NS = "http://www.w3.org/2000/svg"; |
| -// Everyday equivalents (docs/comparison-carbon.md): a typical petrol car emits |
| -// ~170 g CO₂e/km, burning 1 L of petrol ≈ 2.3 kg, and the fleet estimate is |
| -// ~2 g CO₂e per member per year. |
| -const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3, FLEET_G_PER_MEMBER_YEAR = 2; |
| +// Everyday-equivalent factors (all documented + banded + dated in |
| +// docs/comparison-carbon.md § What the anchors mean; snapshot July 2026): |
| +// petrol car ~170 g CO₂e/km, burning 1 L petrol ≈ 2.3 kg (DEFRA class). |
| +// short-haul air ~0.145 kg CO₂e per economy passenger-km INCLUDING the |
| +// non-CO₂ altitude uplift (~1.9× radiative forcing; DEFRA |
| +// short-haul-with-RF class, band 0.13–0.16). Expressed against |
| +// one real short leg — Dublin–London (DUB–LHR) ≈ 460 km great |
| +// circle → ≈ 67 kg CO₂e per economy leg. |
| +// leaf blower 2-stroke ~1 L/h → ~2.3 kg CO₂/h. |
| +// beef ~60 kg CO₂e/kg, land-use-inclusive (Poore & Nemecek 2018). |
| +// tree a mature tree absorbs ~21 kg CO₂/yr. |
| +// These apply to BOTH sections, each scaled to its own magnitude. |
| +const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3; |
| +const FLIGHT_KG_PER_PAX_KM = 0.145, FLIGHT_LEG_KM = 460; |
| +const FLIGHT_LEG_KG = FLIGHT_KG_PER_PAX_KM * FLIGHT_LEG_KM; // ≈ 67 kg / leg |
| +const LEAFBLOWER_KG_PER_H = 2.3; |
| +const BEEF_KG_CO2E_PER_KG = 60; |
| +const TREE_KG_PER_YEAR = 21; |
| -// The JSON is canonical metric; imperial is a display conversion only. |
| +// The JSON is canonical metric; imperial is a display conversion only. Distances |
| +// and volumes convert (car km/litres); the new anchors are mass/time/count — |
| +// mass converts via massF (beef), counts/durations stay unit-agnostic. |
| const UNITS = { |
| metric: { mass: "kg", massF: 1, dist: "km", distF: 1, vol: "litres", volF: 1 }, |
| imperial: { mass: "lb", massF: 2.20462, dist: "miles", distF: 0.621371, vol: "US gallons", volF: 0.264172 }, |
| @@ -177,12 +265,74 @@ function initialUnits() { |
| } |
| let units = initialUnits(); |
| const U = () => UNITS[units]; |
| -// Whole numbers once they're big enough to not need the decimal. |
| +// Whole numbers once they're big enough to not need the decimal (used by the |
| +// chart's kg-scale axis). |
| const nice = (v) => (Math.abs(v) >= 20 ? Math.round(v) : Math.round(v * 10) / 10); |
| const mass = (kg) => nice(kg * U().massF); |
| +// Adaptive precision for a plain number: fewer decimals the bigger it is. |
| +function fmtNum(v) { |
| + const a = Math.abs(v); |
| + const r = a >= 100 ? Math.round(v) : a >= 1 ? Math.round(v * 10) / 10 |
| + : a >= 0.1 ? Math.round(v * 100) / 100 : Math.round(v * 1000) / 1000; |
| + return r.toLocaleString(); |
| +} |
| +// Mass, adaptive across scales so tiny run figures don't collapse to "0 kg": |
| +// grams under a kilo (metric), ounces under a pound (imperial). |
| +function fmtMass(kg) { |
| + if (units === "metric") return Math.abs(kg) < 1 ? fmtNum(kg * 1000) + " g" : fmtNum(kg) + " kg"; |
| + const lb = kg * UNITS.imperial.massF; |
| + return Math.abs(lb) < 1 ? fmtNum(lb * 16) + " oz" : fmtNum(lb) + " lb"; |
| +} |
| +// A low–high pair: collapse to one unit when both ends share it, else keep both. |
| +function rangeStr(lo, hi) { |
| + const a = fmtMass(lo), b = fmtMass(hi); |
| + const unit = (s) => s.replace(/^[\d.,]+\s*/, ""); |
| + return unit(a) === unit(b) ? a.replace(/\s*\S+$/, "") + "–" + b : a + " – " + b; |
| +} |
| +const plur = (n, s) => n + " " + (n === 1 ? s : s + "s"); |
| let lastData = null; |
| +// ---- everyday anchors (shared by both sections) ------------------------------ |
| +function flightPhrase(legs) { |
| + if (legs >= 0.95) return "about " + plur(nice(legs), "Dublin–London economy flight"); |
| + const pct = legs * 100; |
| + return "about " + (pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10) + "% of one Dublin–London economy flight"; |
| +} |
| +function durationPhrase(hours) { |
| + if (hours >= 1) return "for about " + plur(nice(hours), "hour"); |
| + const mins = hours * 60; |
| + if (mins >= 1) return "for about " + plur(Math.round(mins), "minute"); |
| + return "for about " + plur(Math.max(1, Math.round(mins * 60)), "second"); |
| +} |
| +function treePhrase(years) { |
| + if (years >= 1) return "about " + plur(nice(years), "tree-year") + " to soak it back up"; |
| + const days = years * 365; |
| + if (days >= 1) return "about " + plur(Math.round(days), "tree-day") + " to soak it back up"; |
| + return "under a tree-day to soak it back up"; |
| +} |
| +// Build the anchor list for a given central-estimate kg. Central values only; |
| +// the band is noted once beneath (in the *-band element). |
| +function anchorList(ul, kg) { |
| + ul.replaceChildren(); |
| + const U0 = U(); |
| + const items = [ |
| + "driving a petrol car about " + fmtNum(kg * 1000 / CAR_G_PER_KM * U0.distF) + " " + U0.dist + |
| + " (burning ~" + fmtNum(kg / PETROL_KG_PER_L * U0.volF) + " " + U0.vol + " of petrol)", |
| + flightPhrase(kg / FLIGHT_LEG_KG), |
| + "running a petrol leaf blower " + durationPhrase(kg / LEAFBLOWER_KG_PER_H), |
| + "about " + fmtMass(kg / BEEF_KG_CO2E_PER_KG) + " of beef", |
| + treePhrase(kg / TREE_KG_PER_YEAR), |
| + ]; |
| + for (const t of items) { |
| + const li = document.createElement("li"); |
| + li.textContent = t; |
| + ul.append(li); |
| + } |
| +} |
| +const bandNote = (lo, hi) => "Central estimates; the honest range is " + rangeStr(lo, hi) + |
| + " CO₂e. Flights are economy short-haul, including the altitude (radiative-forcing) uplift."; |
| + |
| // All rendering is textContent / createElement — never innerHTML of fetched |
| // data, even though the feed is our own. |
| function render(d) { |
| @@ -193,29 +343,30 @@ function render(d) { |
| $("asof").dateTime = d.updated_at; |
| $("asof").textContent = new Date(d.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| - const f = d.factors_mg_co2e_per_output_token; |
| + renderBuilt(d.built); |
| + renderRun(d.run); |
| +} |
| + |
| +function renderBuilt(b) { |
| + const f = b.factors_mg_co2e_per_output_token; |
| $("factors").textContent = "we band public calibration points at " + f.low + "–" + f.high + |
| " mg CO₂e per output token, central " + f.central + " mg"; |
| - $("t-central").textContent = "~" + mass(d.totals.kg_central) + " " + U().mass + " CO₂e"; |
| - $("t-range").textContent = mass(d.totals.kg_low) + "–" + mass(d.totals.kg_high) + " " + U().mass; |
| - $("t-tokens").textContent = compact.format(d.totals.output_tokens); |
| + $("bt-central").textContent = "~" + fmtMass(b.totals.kg_central) + " CO₂e"; |
| + $("bt-range").textContent = rangeStr(b.totals.kg_low, b.totals.kg_high); |
| + $("bt-tokens").textContent = compact.format(b.totals.output_tokens); |
| - const dist = nice(d.totals.kg_central * 1000 / CAR_G_PER_KM * U().distF); |
| - const vol = nice(d.totals.kg_central / PETROL_KG_PER_L * U().volF); |
| - const memberYears = Math.round(d.totals.kg_central * 1000 / FLEET_G_PER_MEMBER_YEAR); |
| - $("equiv").textContent = "Centrally, that’s like driving a petrol car about " + dist + " " + U().dist + |
| - " (burning ~" + vol + " " + U().vol + " of petrol) — or roughly " + |
| - memberYears.toLocaleString() + " member-years of actually running the service."; |
| + anchorList($("built-anchors"), b.totals.kg_central); |
| + $("built-band").textContent = bandNote(b.totals.kg_low, b.totals.kg_high); |
| $("chart-cap").textContent = "Cumulative " + U().mass + " CO₂e — the line is the central estimate, " + |
| "the wash is the low–high band. Exact figures are in the ledger below."; |
| - chart(d); |
| + builtChart(b); |
| - const list = $("entries"); |
| + const list = $("built-entries"); |
| list.replaceChildren(); |
| // Ledger order = time order, even when contributor machines interleave. |
| - const entries = (d.entries || []).slice().sort((a, b) => Date.parse(a.period_end) - Date.parse(b.period_end)); |
| + const entries = (b.entries || []).slice().sort((a, c) => Date.parse(a.period_end) - Date.parse(c.period_end)); |
| const max = Math.max(1, ...entries.map((e) => e.kg_central)); |
| for (const e of entries) { |
| const li = document.createElement("li"); |
| @@ -237,27 +388,78 @@ function render(d) { |
| track.append(bar); |
| const kg = document.createElement("span"); |
| kg.className = "kg"; |
| - kg.textContent = mass(e.kg_central) + " " + U().mass; |
| + kg.textContent = fmtMass(e.kg_central); |
| + li.append(period, track, kg); |
| + list.append(li); |
| + } |
| + |
| + $("built-notcounted").textContent = "Known undercounts, on purpose: " + b.not_counted + "."; |
| +} |
| + |
| +function renderRun(r) { |
| + const body = $("run-body"), pending = $("run-pending"); |
| + if (!r) { body.hidden = true; pending.hidden = false; return; } |
| + body.hidden = false; pending.hidden = true; |
| + |
| + $("rt-daily").textContent = "~" + fmtMass(r.daily_kg.central); |
| + $("rt-daily-range").textContent = "range " + rangeStr(r.daily_kg.low, r.daily_kg.high); |
| + $("rt-cum-label").textContent = "Cumulative since " + day(r.since); |
| + $("rt-cum").textContent = "~" + fmtMass(r.cumulative_kg.central); |
| + $("rt-cum-range").textContent = "range " + rangeStr(r.cumulative_kg.low, r.cumulative_kg.high); |
| + $("rt-space").textContent = "~" + fmtMass(r.per_space_daily_kg.central); |
| + |
| + // Anchors scale to the cumulative-to-date central — the run section's most |
| + // legible magnitude as the fleet grows (a single day is grams). |
| + $("run-anchors-lede").textContent = "Cumulatively since " + day(r.since) + |
| + ", that’s about the same CO₂e as, centrally:"; |
| + anchorList($("run-anchors"), r.cumulative_kg.central); |
| + $("run-band").textContent = bandNote(r.cumulative_kg.low, r.cumulative_kg.high); |
| + |
| + // Service breakdown: central daily kg per service (rows text, bars aria-hidden). |
| + const list = $("run-bars"); |
| + list.replaceChildren(); |
| + const max = Math.max(1e-9, ...r.services.map((s) => s.daily_kg_central)); |
| + for (const s of r.services) { |
| + const li = document.createElement("li"); |
| + const period = document.createElement("span"); |
| + period.className = "period"; |
| + period.textContent = s.name; |
| + const track = document.createElement("span"); |
| + track.className = "track"; |
| + track.setAttribute("aria-hidden", "true"); |
| + const bar = document.createElement("span"); |
| + bar.className = "bar"; |
| + bar.style.width = (100 * s.daily_kg_central / max) + "%"; |
| + bar.style.display = "block"; |
| + track.append(bar); |
| + const kg = document.createElement("span"); |
| + kg.className = "kg"; |
| + kg.textContent = fmtMass(s.daily_kg_central); |
| li.append(period, track, kg); |
| list.append(li); |
| } |
| - $("notcounted").textContent = "Known undercounts, on purpose: " + d.not_counted + "."; |
| + const asof = new Date(r.generated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| + $("run-assumptions").textContent = "Estimated from ~" + r.vcpus.toLocaleString() + " vCPUs drawing ~" + |
| + r.watts.central.toLocaleString() + " W (central), a ~" + r.grid_g_per_kwh.central.toLocaleString() + |
| + " g CO₂e/kWh grid and PUE ~" + r.pue + ", plus small explicit terms for object storage and bot " + |
| + "inference; normalised over " + plur(r.spaces, "space") + ". Recomputed live at each publish (as of " + |
| + asof + ")."; |
| } |
| -// Cumulative line over time: central line + low–high wash. Crosshair snaps to |
| -// the nearest measurement; points are keyboard-focusable with the same tooltip. |
| -// Values are cumulated in canonical kg, converted to the display unit at the |
| -// last moment so labels, ticks, and tooltip always agree. |
| -function chart(d) { |
| +// Cumulative line over time (Built): central line + low–high wash. Crosshair |
| +// snaps to the nearest measurement; points are keyboard-focusable with the same |
| +// tooltip. Values are cumulated in canonical kg, converted to the display unit |
| +// at the last moment so labels, ticks, and tooltip always agree. |
| +function builtChart(b) { |
| const svg = $("chart"); |
| svg.replaceChildren(); |
| const round1 = (v) => Math.round(v * 10) / 10; |
| const disp = (kg) => nice(kg * U().massF); |
| // Cumulate in period_end order across ALL sources; the curve starts at the |
| // earliest period_start any source measured from. |
| - const ordered = d.entries.slice().sort((a, b) => Date.parse(a.period_end) - Date.parse(b.period_end)); |
| - const t0 = Math.min(...d.entries.map((e) => Date.parse(e.period_start))); |
| + const ordered = b.entries.slice().sort((a, c) => Date.parse(a.period_end) - Date.parse(c.period_end)); |
| + const t0 = Math.min(...b.entries.map((e) => Date.parse(e.period_start))); |
| const pts = [{ t: t0, c: 0, lo: 0, hi: 0 }]; |
| let c = 0, lo = 0, hi = 0; |
| for (const e of ordered) { |
diff --git a/web/status/carbon.json b/web/status/carbon.json| index 1083b59..a06d0de 100644 |
| --- a/web/status/carbon.json |
| +++ b/web/status/carbon.json |
| @@ -1,61 +1,63 @@ |
| { |
| "updated_at": "2026-07-12T11:08:41.593Z", |
| - "unit": "kg CO2e", |
| "methodology": "https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md", |
| - "factors_mg_co2e_per_output_token": { |
| - "low": 0.1, |
| - "central": 0.55, |
| - "high": 3 |
| - }, |
| - "not_counted": "cache reads (no public per-token figure exists), cloud-side agent runs, and contributor machines that haven't accrued their entries yet", |
| - "entries": [ |
| - { |
| - "period_start": "2026-06-27T00:00:00.000Z", |
| - "period_end": "2026-07-03T00:00:00.000Z", |
| - "source": "radia-doodling", |
| - "basis": "extrapolated", |
| - "sessions": null, |
| - "output_tokens": 9098065, |
| - "fresh_input_tokens": null, |
| - "cache_read_tokens": null, |
| - "kg_low": 0.9, |
| - "kg_central": 5, |
| - "kg_high": 27.3, |
| - "note": "transcripts pruned before the first measurement; commit-weighted share (165 of the 616 commits to 2026-07-11) of the first measured window's per-commit rate" |
| + "built": { |
| + "unit": "kg CO2e", |
| + "factors_mg_co2e_per_output_token": { |
| + "low": 0.1, |
| + "central": 0.55, |
| + "high": 3 |
| }, |
| - { |
| - "period_start": "2026-07-03T08:19:42.191Z", |
| - "period_end": "2026-07-11T19:26:21.699Z", |
| - "source": "radia-doodling", |
| - "basis": "measured", |
| - "sessions": 162, |
| - "output_tokens": 24889647, |
| - "fresh_input_tokens": 144657973, |
| - "cache_read_tokens": 7949132294, |
| - "kg_low": 2.5, |
| - "kg_central": 13.7, |
| - "kg_high": 74.7, |
| - "note": "local Claude Code transcripts, one dev machine" |
| - }, |
| - { |
| - "period_start": "2026-07-03T08:19:42.191Z", |
| - "period_end": "2026-07-12T11:08:41.593Z", |
| - "source": "barbara-painting", |
| - "basis": "measured", |
| - "sessions": 171, |
| - "output_tokens": 27347111, |
| - "fresh_input_tokens": 171281918, |
| - "cache_read_tokens": 9095881269, |
| - "kg_low": 2.7, |
| - "kg_central": 15, |
| - "kg_high": 82, |
| - "note": "local Claude Code transcripts, one dev machine" |
| + "not_counted": "cache reads (no public per-token figure exists), cloud-side agent runs, and contributor machines that haven't accrued their entries yet", |
| + "entries": [ |
| + { |
| + "period_start": "2026-06-27T00:00:00.000Z", |
| + "period_end": "2026-07-03T00:00:00.000Z", |
| + "source": "radia-doodling", |
| + "basis": "extrapolated", |
| + "sessions": null, |
| + "output_tokens": 9098065, |
| + "fresh_input_tokens": null, |
| + "cache_read_tokens": null, |
| + "kg_low": 0.9, |
| + "kg_central": 5, |
| + "kg_high": 27.3, |
| + "note": "transcripts pruned before the first measurement; commit-weighted share (165 of the 616 commits to 2026-07-11) of the first measured window's per-commit rate" |
| + }, |
| + { |
| + "period_start": "2026-07-03T08:19:42.191Z", |
| + "period_end": "2026-07-11T19:26:21.699Z", |
| + "source": "radia-doodling", |
| + "basis": "measured", |
| + "sessions": 162, |
| + "output_tokens": 24889647, |
| + "fresh_input_tokens": 144657973, |
| + "cache_read_tokens": 7949132294, |
| + "kg_low": 2.5, |
| + "kg_central": 13.7, |
| + "kg_high": 74.7, |
| + "note": "local Claude Code transcripts, one dev machine" |
| + }, |
| + { |
| + "period_start": "2026-07-03T08:19:42.191Z", |
| + "period_end": "2026-07-12T11:08:41.593Z", |
| + "source": "barbara-painting", |
| + "basis": "measured", |
| + "sessions": 171, |
| + "output_tokens": 27347111, |
| + "fresh_input_tokens": 171281918, |
| + "cache_read_tokens": 9095881269, |
| + "kg_low": 2.7, |
| + "kg_central": 15, |
| + "kg_high": 82, |
| + "note": "local Claude Code transcripts, one dev machine" |
| + } |
| + ], |
| + "totals": { |
| + "output_tokens": 61334823, |
| + "kg_low": 6.1, |
| + "kg_central": 33.7, |
| + "kg_high": 184 |
| } |
| - ], |
| - "totals": { |
| - "output_tokens": 61334823, |
| - "kg_low": 6.1, |
| - "kg_central": 33.7, |
| - "kg_high": 184 |
| } |
| } |