From 79cfa5fa2d9b2739b18399dcd6b33404cff231a8 Mon Sep 17 00:00:00 2001 From: geracosta Date: Wed, 16 Sep 2026 17:13:26 -0300 Subject: [PATCH 1/4] segment benchmarks by core/support --- svc/api/spec.ts | 29 ++++++++++++++++++++++++++++- svc/counts.ts | 23 +++++++++++++++++++++++ svc/util/queries.ts | 11 ++++++++--- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/svc/api/spec.ts b/svc/api/spec.ts index 4aa0c301a..4f9e654fe 100644 --- a/svc/api/spec.ts +++ b/svc/api/spec.ts @@ -1430,6 +1430,17 @@ You can use the API without a key, but registering for a key allows increased ra type: "integer", }, }, + { + name: "role", + in: "query", + description: + "Segment the bracket further by core or support, estimated from farm within each team. Requires bracket. Omit for all roles", + required: false, + schema: { + type: "string", + enum: ["core", "support"], + }, + }, ], responses: { 200: { @@ -1457,7 +1468,23 @@ You can use the API without a key, but registering for a key allows increased ra .json({ error: "bracket must be an integer between 1 and 8" }); } } - const result = await getHeroBenchmarks(req.query.hero_id, bracket); + let role: string | null = null; + if (req.query.role !== undefined) { + role = String(req.query.role); + if (role !== "core" && role !== "support") { + return res + .status(400) + .json({ error: "role must be core or support" }); + } + if (!bracket) { + return res.status(400).json({ error: "role requires bracket" }); + } + } + const result = await getHeroBenchmarks( + req.query.hero_id, + bracket, + role, + ); return res.json(result); }, }, diff --git a/svc/counts.ts b/svc/counts.ts index d6c6d6269..cd7ff27bd 100644 --- a/svc/counts.ts +++ b/svc/counts.ts @@ -213,6 +213,23 @@ runReliableQueue("counts", 2, async (job: CountsJob, metadata) => { const turbo = isTurbo(match); // 1 to 8 based on the average rank of the match, as in updateHeroCounts const rank = !turbo && avg ? Math.floor(avg / 10) : null; + // Rank each team by gold_per_min and call the top 3 cores, the rest + // supports. The exact 1-5 position needs parsed data, but this split is + // right for 99.4% of players using api data alone (see #2980). + const roles = new Map(); + if (rank) { + [true, false].forEach((radiant) => { + const team = match.players.filter((p) => isRadiant(p) === radiant); + if (team.length === 5) { + team + .slice() + .sort((a, b) => (b.gold_per_min ?? 0) - (a.gold_per_min ?? 0)) + .forEach((p, i) => + roles.set(p.player_slot, i < 3 ? "core" : "support"), + ); + } + }); + } if ( match.match_id % 100 < Number(config.BENCHMARKS_SAMPLE_PERCENT) && (isSignificant(match) || turbo) @@ -248,6 +265,12 @@ runReliableQueue("counts", 2, async (job: CountsJob, metadata) => { const rankKey = `${rkey}:${rank}`; redis.zadd(rankKey, metric, match.match_id); redis.expireat(rankKey, expiretime); + const role = roles.get(p.player_slot); + if (role) { + const rankRoleKey = `${rankKey}:${role}`; + redis.zadd(rankRoleKey, metric, match.match_id); + redis.expireat(rankRoleKey, expiretime); + } } } }); diff --git a/svc/util/queries.ts b/svc/util/queries.ts index 8da05a341..260cd2f6a 100644 --- a/svc/util/queries.ts +++ b/svc/util/queries.ts @@ -108,11 +108,16 @@ export async function getHeroItemPopularity(heroId: string) { late_game_items: lateGameItems, }; } -export async function getHeroBenchmarks(heroId: string, bracket?: number | null) { +export async function getHeroBenchmarks( + heroId: string, + bracket?: number | null, + role?: string | null, +) { const ret: AnyDict = {}; const arr = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99]; - // Bracketed sets add the 1-8 rank bracket after the global/turbo slot - const suffix = bracket ? `:${bracket}` : ""; + // Bracketed sets add the 1-8 rank bracket after the global/turbo slot, and + // core/support after that. The role only segments the bracketed sets. + const suffix = bracket ? (role ? `:${bracket}:${role}` : `:${bracket}`) : ""; const items: [metric: string, percentile: number][] = []; Object.keys(benchmarks).forEach((metric) => { arr.forEach((percentile) => { From ed4d7ffd1b816fe8d0d110b368322e5df3cd775a Mon Sep 17 00:00:00 2001 From: geracosta Date: Thu, 17 Sep 2026 10:36:02 -0300 Subject: [PATCH 2/4] support the bracket and role filters independently --- svc/api/spec.ts | 5 +---- svc/counts.ts | 9 +++++++-- svc/util/queries.ts | 12 +++++++++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/svc/api/spec.ts b/svc/api/spec.ts index 4f9e654fe..eb5ad3c83 100644 --- a/svc/api/spec.ts +++ b/svc/api/spec.ts @@ -1434,7 +1434,7 @@ You can use the API without a key, but registering for a key allows increased ra name: "role", in: "query", description: - "Segment the bracket further by core or support, estimated from farm within each team. Requires bracket. Omit for all roles", + "Benchmark against cores or supports only, estimated from farm within each team. Can be combined with bracket. Omit for all roles", required: false, schema: { type: "string", @@ -1476,9 +1476,6 @@ You can use the API without a key, but registering for a key allows increased ra .status(400) .json({ error: "role must be core or support" }); } - if (!bracket) { - return res.status(400).json({ error: "role requires bracket" }); - } } const result = await getHeroBenchmarks( req.query.hero_id, diff --git a/svc/counts.ts b/svc/counts.ts index cd7ff27bd..8f61538ab 100644 --- a/svc/counts.ts +++ b/svc/counts.ts @@ -217,7 +217,7 @@ runReliableQueue("counts", 2, async (job: CountsJob, metadata) => { // supports. The exact 1-5 position needs parsed data, but this split is // right for 99.4% of players using api data alone (see #2980). const roles = new Map(); - if (rank) { + if (!turbo) { [true, false].forEach((radiant) => { const team = match.players.filter((p) => isRadiant(p) === radiant); if (team.length === 5) { @@ -261,11 +261,16 @@ runReliableQueue("counts", 2, async (job: CountsJob, metadata) => { 2, ); redis.expireat(rkey, expiretime); + const role = roles.get(p.player_slot); + if (role) { + const roleKey = `${rkey}:${role}`; + redis.zadd(roleKey, metric, match.match_id); + redis.expireat(roleKey, expiretime); + } if (rank) { const rankKey = `${rkey}:${rank}`; redis.zadd(rankKey, metric, match.match_id); redis.expireat(rankKey, expiretime); - const role = roles.get(p.player_slot); if (role) { const rankRoleKey = `${rankKey}:${role}`; redis.zadd(rankRoleKey, metric, match.match_id); diff --git a/svc/util/queries.ts b/svc/util/queries.ts index 260cd2f6a..c90871f08 100644 --- a/svc/util/queries.ts +++ b/svc/util/queries.ts @@ -115,9 +115,15 @@ export async function getHeroBenchmarks( ) { const ret: AnyDict = {}; const arr = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99]; - // Bracketed sets add the 1-8 rank bracket after the global/turbo slot, and - // core/support after that. The role only segments the bracketed sets. - const suffix = bracket ? (role ? `:${bracket}:${role}` : `:${bracket}`) : ""; + // The bracket and the role are independent slots after the global/turbo one, + // so either can be used on its own: ...:::5, ...:::core + let suffix = ""; + if (bracket) { + suffix += `:${bracket}`; + } + if (role) { + suffix += `:${role}`; + } const items: [metric: string, percentile: number][] = []; Object.keys(benchmarks).forEach((metric) => { arr.forEach((percentile) => { From 6ee4928440b205b904546997220452901aace9c2 Mon Sep 17 00:00:00 2001 From: geracosta Date: Sun, 20 Sep 2026 12:11:32 -0300 Subject: [PATCH 3/4] give the bracket and role their own key slots --- svc/counts.ts | 8 +++++--- svc/util/queries.ts | 12 +++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/svc/counts.ts b/svc/counts.ts index 8f61538ab..cbfc4ce44 100644 --- a/svc/counts.ts +++ b/svc/counts.ts @@ -261,18 +261,20 @@ runReliableQueue("counts", 2, async (job: CountsJob, metadata) => { 2, ); redis.expireat(rkey, expiretime); + // Filtered sets keep both slots so every key has the same + // shape, with an empty segment for the filter not in use. const role = roles.get(p.player_slot); if (role) { - const roleKey = `${rkey}:${role}`; + const roleKey = `${rkey}::${role}`; redis.zadd(roleKey, metric, match.match_id); redis.expireat(roleKey, expiretime); } if (rank) { - const rankKey = `${rkey}:${rank}`; + const rankKey = `${rkey}:${rank}:`; redis.zadd(rankKey, metric, match.match_id); redis.expireat(rankKey, expiretime); if (role) { - const rankRoleKey = `${rankKey}:${role}`; + const rankRoleKey = `${rkey}:${rank}:${role}`; redis.zadd(rankRoleKey, metric, match.match_id); redis.expireat(rankRoleKey, expiretime); } diff --git a/svc/util/queries.ts b/svc/util/queries.ts index c90871f08..51f422f12 100644 --- a/svc/util/queries.ts +++ b/svc/util/queries.ts @@ -115,14 +115,12 @@ export async function getHeroBenchmarks( ) { const ret: AnyDict = {}; const arr = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99]; - // The bracket and the role are independent slots after the global/turbo one, - // so either can be used on its own: ...:::5, ...:::core + // Filtered sets always carry both slots, empty for the ones not in use: + // ...:::5: ...::::core ...:::5:core + // The unfiltered key keeps no suffix at all so existing ones still resolve. let suffix = ""; - if (bracket) { - suffix += `:${bracket}`; - } - if (role) { - suffix += `:${role}`; + if (bracket || role) { + suffix = `:${bracket ?? ""}:${role ?? ""}`; } const items: [metric: string, percentile: number][] = []; Object.keys(benchmarks).forEach((metric) => { From de4a1c12af4106dc320a00e303bda8ae950c05a7 Mon Sep 17 00:00:00 2001 From: geracosta Date: Fri, 2 Oct 2026 21:54:51 -0300 Subject: [PATCH 4/4] Name the benchmark key slots in the comment The examples only add up once you know the base key already ends in the empty turbo slot, so spell out every slot. --- svc/util/queries.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/svc/util/queries.ts b/svc/util/queries.ts index 51f422f12..c337df06a 100644 --- a/svc/util/queries.ts +++ b/svc/util/queries.ts @@ -115,7 +115,9 @@ export async function getHeroBenchmarks( ) { const ret: AnyDict = {}; const arr = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99]; - // Filtered sets always carry both slots, empty for the ones not in use: + // Key slots are benchmarks:::::: + // Filtered sets always carry both the bracket and role slots, empty for the + // ones not in use, and are only written for non-turbo matches: // ...:::5: ...::::core ...:::5:core // The unfiltered key keeps no suffix at all so existing ones still resolve. let suffix = "";