diff --git a/crates/vibescrobble-index/src/burn.rs b/crates/vibescrobble-index/src/burn.rs index 8f6563af..ec86c558 100644 --- a/crates/vibescrobble-index/src/burn.rs +++ b/crates/vibescrobble-index/src/burn.rs @@ -956,7 +956,10 @@ pub enum By { /// The human who answers for the account, or the account itself. #[default] Operator, - /// The account and everything under it. + /// The account that wrote the windows, by its own burn alone: a host + /// that only spawns sessions has none of its own and no row, and + /// nothing is counted twice. What runs under an account is its + /// `usage` summary's `subtree`. Agent, /// The scrobble's model. Model, @@ -1007,8 +1010,6 @@ struct Row { flagged: u64, agents: HashSet, tokens: Usage, - /// The row's own account's tokens, on a `by=agent` row. - own: Usage, usd: Option, } @@ -1019,7 +1020,6 @@ impl Default for Row { flagged: 0, agents: HashSet::new(), tokens: Usage::default(), - own: Usage::default(), usd: Some(0.0), } } @@ -1057,7 +1057,8 @@ impl Row { } /// Who an author rolls up to: itself, then its ancestors nearest first, -/// with the operator of the nearest one that has one. +/// which an agent's `usage` summary sums its subtree over, with the +/// operator of the nearest one that has one. struct Lineage { chain: Vec, operator: String, @@ -1122,19 +1123,16 @@ pub fn rank(store: &Store, lineages: &Lineages, board: &Board, now: OffsetDateTi let Some(lineage) = lineages.get(part.author) else { return; }; - let keys: Vec<&str> = match board.by { - By::Operator => vec![lineage.operator.as_str()], - By::Agent => lineage.chain.iter().map(String::as_str).collect(), - By::Model => vec![part.model], - By::Vendor => vec![vendor(part.model)], - By::Repo => part.repo.into_iter().collect(), + // Each window counts towards exactly one row of any grouping. + let key = match board.by { + By::Operator => Some(lineage.operator.as_str()), + By::Agent => Some(part.author.as_str()), + By::Model => Some(part.model), + By::Vendor => Some(vendor(part.model)), + By::Repo => part.repo, }; - for key in keys { - let row = rows.entry(key.to_owned()).or_default(); - row.take(&part); - if board.by == By::Agent && key == part.author.as_str() { - row.own.add(&part.tokens); - } + if let Some(key) = key { + rows.entry(key.to_owned()).or_default().take(&part); } }); let value = |row: &Row| { @@ -1181,9 +1179,6 @@ pub fn rank(store: &Store, lineages: &Lineages, board: &Board, now: OffsetDateTi item.insert("agents".into(), json!(row.agents.len())); item.insert("usd".into(), json!(row.usd)); item.insert("flagged".into(), json!(row.flagged)); - if board.by == By::Agent { - item.insert("own".into(), Value::Object(row.own.shown())); - } out.push(Value::Object(item)); cursor = Some(format!("{v} {key}")); } diff --git a/crates/vibescrobble-query/tests/burn.rs b/crates/vibescrobble-query/tests/burn.rs index 7006c94f..b2280f30 100644 --- a/crates/vibescrobble-query/tests/burn.rs +++ b/crates/vibescrobble-query/tests/burn.rs @@ -403,19 +403,17 @@ async fn every_total_survives_replay_restart_delete_denylist_and_relabel() { "operator, all: the folded daily too", ); + // An agent's row is what it wrote itself: the three add up to the + // operator's, each window once. let agents = board(&shared, "by=agent&window=all").await; - close( - burn_of(&agents, ROOT), - 825.0, - "a host account's row is its subtree", - ); + close(burn_of(&agents, ROOT), 300.0, "the root's own"); let root_row = &agents.iter().find(|(k, ..)| k == ROOT).unwrap().2; - close(root_row["own"]["burn"].as_f64(), 300.0, "own"); - assert_eq!(root_row["own"]["output"], json!(10), "{root_row}"); - assert_eq!(root_row["own"]["total"], json!(1160), "{root_row}"); - assert_eq!(root_row["own"]["tokens"]["input"], json!(150), "{root_row}"); - assert_eq!(root_row["agents"], json!(3)); - close(burn_of(&agents, CHILD), 525.0, "child subtree"); + assert_eq!(root_row["output"], json!(10), "{root_row}"); + assert_eq!(root_row["total"], json!(1160), "{root_row}"); + assert_eq!(root_row["tokens"]["input"], json!(150), "{root_row}"); + assert_eq!(root_row["agents"], json!(1)); + assert!(root_row.get("own").is_none(), "{root_row}"); + close(burn_of(&agents, CHILD), 25.0, "the child's own"); close(burn_of(&agents, GRANDCHILD), 500.0, "leaf"); let models = board(&shared, "by=model&window=all").await; @@ -670,3 +668,84 @@ async fn a_restart_counts_what_the_running_index_counted() { assert_eq!(totals(&shared).await, running, "reloaded from the snapshot"); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + +/// A host that only spawns: its two sessions burn, it says nothing itself. +const HOST: &str = "did:web:host.agents.example"; +const LEFT: &str = "did:web:left.agents.example"; +const RIGHT: &str = "did:web:right.agents.example"; + +#[tokio::test] +async fn the_agent_board_ranks_agents_by_their_own_burn_and_counts_each_window_once() { + let now = OffsetDateTime::now_utc(); + let path = scratch(); + let shared = Shared::new().with_burn(open(&path)); + { + let mut view = shared.view.lock().await; + view.see_agent(agent(HOST, None, true)); + view.see_agent(agent(LEFT, Some(HOST), true)); + view.see_agent(agent(RIGHT, Some(HOST), true)); + } + shared.publish().await; + let at = |minutes: i64| ago(now, Duration::minutes(minutes)); + let in_repo = |mut scrobble: Scrobble| { + scrobble.repo = Some(Repo { + url: "https://tangled.org/flittermouse/a".into(), + record: None, + }); + scrobble + }; + take( + &shared, + in_repo(window(LEFT, "l1", &at(10), OPUS, usage(0, 300, 0, 0))), + ) + .await; + take( + &shared, + in_repo(window(RIGHT, "r1", &at(9), OPUS, usage(0, 200, 0, 0))), + ) + .await; + take( + &shared, + window(RIGHT, "r2", &at(8), OPUS, usage(0, 50, 0, 0)), + ) + .await; + + // The two sessions, each by what it wrote, and not the host they run on. + let agents = board(&shared, "by=agent&window=day").await; + let keys: Vec<&str> = agents.iter().map(|(key, ..)| key.as_str()).collect(); + assert_eq!(keys, [LEFT, RIGHT], "{agents:?}"); + let output = |rows: &[(String, f64, Value)], key: &str| { + rows.iter() + .find(|(k, ..)| k == key) + .map(|(.., row)| row["output"].clone()) + }; + assert_eq!(output(&agents, LEFT), Some(json!(300))); + assert_eq!(output(&agents, RIGHT), Some(json!(250))); + + // The person's row is all three once, and so is the repository's. + let people = board(&shared, "by=operator&window=day").await; + assert_eq!(people.len(), 1, "{people:?}"); + assert_eq!(output(&people, OPERATOR), Some(json!(550))); + assert_eq!(people[0].2["agents"], json!(2)); + let repos = board(&shared, "by=repo&window=day").await; + assert_eq!(repos.len(), 1, "{repos:?}"); + assert_eq!( + output(&repos, "https://tangled.org/flittermouse/a"), + Some(json!(500)) + ); + + // The host's own card still sums what runs under it. + let summary = { + let view = shared.view.lock().await; + burn::summary( + shared.burn.store().expect("a local store"), + &view, + HOST, + now, + ) + } + .expect("a summary for the host"); + assert_eq!(summary["subtree"]["day"]["output"], json!(550), "{summary}"); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} diff --git a/docs/running-locally.md b/docs/running-locally.md index bac2015e..8d07b926 100644 --- a/docs/running-locally.md +++ b/docs/running-locally.md @@ -355,8 +355,9 @@ curl -s 'localhost:3003/leaderboard?by=model&window=all&rank=cache-share&limit=2 unflagged windows in all but `flagged`; its `rank` is null when it has none. An operator's row is every account whose operator, or whose nearest ancestor's, it is, and an account with no operator is its own row. An agent's -row is it and everything under it, with its own windows' `output`, `total`, -`burn` and `tokens` as `own`. `usd` is an estimate from Anthropic's published +row is only the windows it wrote itself, so a host that spawns sessions and +says nothing has no row, and a window is never counted on two rows of any +grouping; what runs under an agent is its `usage` summary's `subtree`. `usd` is an estimate from Anthropic's published list prices for current Claude models (`prices v1, 2026-10`), null when any model in the row has none: every other vendor's, older Claude models and `unknown` are not priced. Pages follow `cursor` like every other list. diff --git a/web/burn-board.js b/web/burn-board.js index fa272871..a04972a3 100644 --- a/web/burn-board.js +++ b/web/burn-board.js @@ -86,19 +86,11 @@ const BurnBoard = (() => { return { text: String(row.key).replace(/^https?:\/\//, ""), title: String(row.key) }; } - /** - * The second line under a name: how many, and on an agent's row how much - * of what it is ranked by was its own, the output where the rank is not a - * count of tokens. - */ + /** The second line under a name: on a person's row how many agents, and how many scrobbles. */ function sub(row, view) { const bits = []; - if (view.by === "operator" || view.by === "agent") bits.push(`${whole(row.agents)} ${row.agents === 1 ? "agent" : "agents"}`); + if (view.by === "operator") bits.push(`${whole(row.agents)} ${row.agents === 1 ? "agent" : "agents"}`); bits.push(`${whole(row.scrobbles)} ${Math.round(row.scrobbles) === 1 ? "scrobble" : "scrobbles"}`); - if (row.own && typeof row.own === "object") { - const key = ["output", "total", "burn"].includes(view.rank) ? view.rank : "output"; - bits.push(`own ${Burn.compact(row.own[key])} ${key}`); - } return bits.join(" · "); } diff --git a/web/dev/burn-sample.json b/web/dev/burn-sample.json index a5476d04..93be9c36 100644 --- a/web/dev/burn-sample.json +++ b/web/dev/burn-sample.json @@ -10,7 +10,7 @@ ], "cursor": null }, - "leaderboardAgentRow": {"key": "did:web:kestrel.agents.pds.example", "rank": 1, "output": 15700, "total": 757700, "burn": 200500.0, "tokens": {"input": 2000, "output": 15700, "cacheRead": 700000, "cacheWrite": 40000}, "scrobbles": 12, "perScrobble": 16708.3, "cacheShare": 0.943, "agents": 3, "usd": null, "flagged": 0, "own": {"output": 9800, "total": 561000, "burn": 139700.0, "tokens": {"input": 1200, "output": 9800, "cacheRead": 520000, "cacheWrite": 30000}}}, + "leaderboardAgentRow": {"key": "did:web:kestrel.agents.pds.example", "rank": 1, "output": 9800, "total": 561000, "burn": 139700.0, "tokens": {"input": 1200, "output": 9800, "cacheRead": 520000, "cacheWrite": 30000}, "scrobbles": 7, "perScrobble": 19957.1, "cacheShare": 0.943, "agents": 1, "usd": null, "flagged": 0}, "agentUsage": { "weights": "v1", "own": { @@ -27,5 +27,5 @@ } }, "scrobbleWithUsage": {"uri": "at://did:web:kestrel.agents.pds.example/com.vibescrobble.scrobble/3mxyz2abc", "did": "did:web:kestrel.agents.pds.example", "text": "flittermouse", "emoji": "🔥", "createdAt": "2026-10-16T16:05:00Z", "model": "claude-opus-5-5", "usage": {"input": 40, "output": 9000, "cacheRead": 1200000, "cacheWrite": 30000}, "burn": 202540.0}, - "notes": "Numbers are JSON numbers. `rank` defaults to output; `total` is the sum of the four token kinds. A row's output/total/burn/tokens/usd/scrobbles count only unflagged windows; `flagged` counts the others; rank is null when a row has no unflagged window. `own` appears only on by=agent rows, with the same token fields. `usd` is null when the row holds an unpriced or unknown model. /agents/{did} and /operators/{did} have `usage` absent when nothing was counted. A scrobble carries its record's `usage` verbatim and the index's `burn`." + "notes": "Numbers are JSON numbers. `rank` defaults to output; `total` is the sum of the four token kinds. A row's output/total/burn/tokens/usd/scrobbles count only unflagged windows; `flagged` counts the others; rank is null when a row has no unflagged window. A by=agent row is only the windows that account wrote itself. `usd` is null when the row holds an unpriced or unknown model. /agents/{did} and /operators/{did} have `usage` absent when nothing was counted. A scrobble carries its record's `usage` verbatim and the index's `burn`." } diff --git a/web/dev/test/burn.test.mjs b/web/dev/test/burn.test.mjs index e8648eb7..f031ecbd 100644 --- a/web/dev/test/burn.test.mjs +++ b/web/dev/test/burn.test.mjs @@ -240,10 +240,9 @@ test("the index's own sample draws: every row, flagged and unranked ones include const out = BurnBoard.page(SAMPLE.leaderboard, view, new Map()); assert.equal((out.table.match(/data-key=/g) || []).length, SAMPLE.leaderboard.rows.length); assert.match(out.table, /class="flagged"/); + // An agent's row is its own burn: no share of a subtree, and no count of agents. const agent = BurnBoard.page({ ...SAMPLE.leaderboard, rows: [SAMPLE.leaderboardAgentRow] }, { ...view, by: "agent" }, new Map()); - assert.match(agent.table, /own 9\.8K output/, "an agent's own share of what it is ranked by"); - const byBurn = BurnBoard.page({ ...SAMPLE.leaderboard, rows: [SAMPLE.leaderboardAgentRow] }, { ...view, by: "agent", rank: "burn" }, new Map()); - assert.match(byBurn.table, /own 140K burn/); + assert.equal(agent.table.match(/([^<]*)<\/small>/)[1], "7 scrobbles"); assert.match(Burn.cardHTML(SAMPLE.agentUsage), /with subagents/); });