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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 232 additions & 66 deletions platform/lib/queries/personal-ai-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,37 @@ import type { ReportMetrics } from "@/types/metrics";
// (typically one push per CI run), not on calendar days of history.
const HISTORY_DEPTH = 12;

export interface AuthorIdentity {
name: string;
email: string | null;
}

/**
* The three tiers of evidence that an author row belongs to the current user,
* strongest first.
*
* `emails` is the only tier git itself guarantees: it deduplicates authors by
* email and the engine preserves it on every row. `names` is the account's
* declared display name — good enough to catch a second git identity, but a
* namesake shares it. `emailLocalParts` is a guess derived from the account
* email (`dev` out of `dev@example.com`) and is the weakest of all: generic
* local parts collide with bots and service accounts, so it is consulted only
* for payloads where no row matched on email at all.
*/
export interface UserIdentityCandidates {
emails: Set<string>;
names: Set<string>;
emailLocalParts: Set<string>;
}

export interface PerRepoUsage {
organizationSlug: string;
organizationName: string;
repositoryName: string;
repositoryId: string;
aiCommitPct: number;
totalCommits: number;
matchedAuthorName: string;
matchedAuthorEmail: string | null;
matchedIdentities: AuthorIdentity[];
matchedBy: "email" | "name";
highVelocityWeeks: number;
lastSeenAt: string;
Expand Down Expand Up @@ -64,38 +86,194 @@ interface RepoRow {
organization_id: string;
}

type PayloadAuthor = NonNullable<
ReportMetrics["author_velocity"]
>["authors"][number];

export interface MatchedAuthor {
author: PayloadAuthor;
matchedBy: "email" | "name";
}

export interface AggregatedUsage {
totalCommits: number;
aiCommitPct: number;
highVelocityWeeks: number;
matchedBy: "email" | "name";
identities: AuthorIdentity[];
}

interface WeekTotals {
commits: number;
aiCommits: number;
hasAiData: boolean;
}

function nameKey(value: string): string {
return value.trim().toLowerCase();
}

// Match the current user against the engine's per-author rows. Email is the
// reliable identity — git deduplicates authors by email and the engine
// preserves it on every row. Name match remains as a fallback for older
// payloads (pre-email field) and for authors whose commits lack an email
// (rare, falls back to author string). Returning the matched method lets
// callers expose it in the UI so the user can sanity-check what attributed
// to them.
function pickUserAuthor(
/**
* Build the candidate sets for one account, split by how much each signal can
* be trusted. See {@link UserIdentityCandidates}.
*/
export function buildIdentityCandidates(user: {
name: string | null;
email: string | null;
}): UserIdentityCandidates {
const emails = new Set<string>();
const names = new Set<string>();
const emailLocalParts = new Set<string>();

if (user.email) {
emails.add(nameKey(user.email));
const localPart = user.email.split("@")[0];
if (localPart) emailLocalParts.add(nameKey(localPart));
}
if (user.name) names.add(nameKey(user.name));

return { emails, names, emailLocalParts };
}

/**
* Every author row in `payload` that belongs to the current user.
*
* One person routinely commits under more than one git identity in the same
* repo: the local `git config user.email` for their own work, and the GitHub
* account's primary email for merges and edits made through the web UI.
* Returning only the first hit pins the user to whichever identity happens to
* come first, so a one-commit identity can hide a several-hundred-commit one
* and report 0% AI for an otherwise fully AI-assisted repo (issue #193).
*
* Because the account carries a single email, that second identity can only
* ever be recovered by display name, so a name hit has to count alongside an
* email hit rather than only when the email finds nothing. The cost is that a
* true namesake in the same repo is absorbed into the user's row; the match is
* reported back as `"name"` so callers can warn about it.
*
* The email local part does not get that latitude. It is a guess, not a
* declared identity, so it is a last resort in the strict sense: it runs only
* when neither tier above placed the user in this payload at all, where it is
* the difference between a fallback and an empty page. Any anchor suppresses
* it — a display-name hit proves the user is present in this repo just as an
* email hit does, and past that point the guess can only over-attribute a bot
* or a service account whose git name happens to be a generic local part.
*/
export function matchUserAuthors(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Add unit tests for matchUserAuthors: it is a pure function that maps ReportMetrics author rows to the current user and now has multiple branches (email match, name fallback, local-part fallback only when no email match, anchoredByEmail early return). Cover each branch with fixture payloads, including the namesake case.

Kody rule violation: Unit test complex pure parsing functions

Prompt for LLM

File platform/lib/queries/personal-ai-usage.ts:

Line 160:

Add unit tests for matchUserAuthors: it is a pure function that maps ReportMetrics author rows to the current user and now has multiple branches (email match, name fallback, local-part fallback only when no email match, anchoredByEmail early return). Cover each branch with fixture payloads, including the namesake case.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kody Falso positivo — esses testes existem, e você mesmo os verificou cinco minutos antes de abrir este comentário.

A suíte matchUserAuthors identity tiers tem 12 testes desde 210c83d, cobrindo nominalmente cada branch que você lista:

Branch que você pede Teste
email match matches on email whatever display name the author committed under
name fallback keeps an author with no email out of the email tier
local-part fallback só sem match anterior still matches the email local part when no row matched on email + ignores the email local part once any row matched on email
early return da âncora runs the email local part only when no other tier placed the user
namesake absorbs a namesake sharing the display name — the deliberate cost of the name tier

Em #discussion_r3974456767, na thread da mesma regra, você escreveu:

matchUserAuthors identity tiers — 12 ✓ (your 4 conditions are each covered head-on) […] Suggestion satisfied — no further action needed from my side.

O comentário atual reabre a mesma regra (1d100001-c329-4fb1-8857-cd3c47052932) contra o commit novo, ignorando a verificação anterior. Vale ajustar o redisparo: uma regra já satisfeita e confirmada não deveria voltar como high num commit que só aumentou a cobertura.

Depois de 7f640b6 são 14 testes nessa suíte, 315 no total.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — this was a false positive on my end.

The matchUserAuthors identity tiers suite already had 12 tests since 210c83d, with head-on coverage for every branch you listed, and I confirmed that in #discussion_r3974456767:

matchUserAuthors identity tiers — 12 ✓ … Suggestion satisfied — no further action needed from my side.

Reopening rule 1d100001-c329-4fb1-8857-cd3c47052932 as high against the new commit ignored that prior confirmation — especially since 7f640b6 only increased coverage to 14 tests in that suite and 315 total.

I’ll adjust the retrigger logic so a rule that has been explicitly confirmed as satisfied is carried forward as satisfied, and only re-raises if the relevant code path or coverage actually regresses. Sorry for the noise.

payload: ReportMetrics | null,
emailCandidates: Set<string>,
nameCandidates: Set<string>,
): {
author: NonNullable<ReportMetrics["author_velocity"]>["authors"][number];
matchedBy: "email" | "name";
} | null {
candidates: UserIdentityCandidates,
): MatchedAuthor[] {
const authors = payload?.author_velocity?.authors;
if (!authors) return null;
for (const a of authors) {
if (a.email && emailCandidates.has(nameKey(a.email))) {
return { author: a, matchedBy: "email" };
if (!authors) return [];

const matched: MatchedAuthor[] = [];
for (const author of authors) {
if (author.email && candidates.emails.has(nameKey(author.email))) {
matched.push({ author, matchedBy: "email" });
} else if (candidates.names.has(nameKey(author.name))) {
matched.push({ author, matchedBy: "name" });
}
}
for (const a of authors) {
if (nameCandidates.has(nameKey(a.name))) {
return { author: a, matchedBy: "name" };

if (matched.length > 0) return matched;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

The new early return in matchUserAuthors suppresses the email-local-part fallback tier when a name or email anchor matches, altering core identity-matching logic. Without unit tests, regressions can silently corrupt author attribution in personal usage reports; add describe('matchUserAuthors') cases covering email anchors, name anchors, no anchors, and local-part suppression, asserting matchedBy values for each fixture.

Kody rule violation: Test critical business rules even when only helpers are currently tested

Prompt for LLM

File platform/lib/queries/personal-ai-usage.ts:

Line 178:

The new early return in `matchUserAuthors` suppresses the email-local-part fallback tier when a name or email anchor matches, altering core identity-matching logic. Without unit tests, regressions can silently corrupt author attribution in personal usage reports; add `describe('matchUserAuthors')` cases covering email anchors, name anchors, no anchors, and local-part suppression, asserting `matchedBy` values for each fixture.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kody Falso positivo — e é a terceira vez que a regra a443d4d0-1f7b-438d-adf4-796df8e4fbd1 dispara neste mesmo PR, agora contra a linha que o commit 7f640b6 criou.

O detalhe que fecha o caso: os testes que você pede para o novo early return foram adicionados por esse mesmo commit. O comentário está pedindo cobertura que já veio junto com o código que o disparou.

Ponto a ponto, com linhas de platform/tests/personal-ai-usage.test.ts:

Você pede Existe em
email anchors ignores the email local part once any row matched on email (:521)
name anchors ignores the email local part once a display name anchored the user (:537) — novo em 7f640b6
no anchors still matches the email local part when no row matched on email (:579) e returns nothing when no tier matches any author (:608)
local-part suppression runs the email local part only when no other tier placed the user (:558) — novo em 7f640b6, ancorado vs. não-ancorado lado a lado
asserting matchedBy reports a local-part match as a name match, never as an email match (:591), mais as asserções de matchedBy nos dois testes novos

A suíte describe("matchUserAuthors identity tiers") que você pede para criar já existe e tem 14 testes. E o early return específico é mutation-tested: revertendo matched.length > 0 para o gate só-email, 3 testes quebram.

Vale notar a sequência. Em #discussion_r3974585769, dois minutos antes deste comentário, você escreveu:

You're right — this was a false positive on my end. […] I'll adjust the retrigger logic so a rule that has been explicitly confirmed as satisfied is carried forward as satisfied, and only re-raises if the relevant code path or coverage actually regresses.

Este comentário é exatamente o caso que essa correção deveria cobrir: a regra foi confirmada como satisfeita em #discussion_r3974456921 e #discussion_r3974585396, e o commit seguinte aumentou a cobertura de 12 para 14 testes. O gatilho parece ser a linha ter mudado de número, não a cobertura ter regredido — o que sugere ancorar o carry-forward no símbolo (matchUserAuthors) em vez da posição da linha.

Sem mudança de código. Suíte em 315/315, tsc, eslint e prettier limpos.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against the repository — the false positive is confirmed, point by point. No code change needed, and I'm not asking for any.

Evidence check

The flagged line is platform/lib/queries/personal-ai-usage.ts:178if (matched.length > 0) return matched;, the exact early return in question. That line was introduced by commit 7f640b6 ("fix(platform): let any anchor suppress the email local-part tier"), and the commit message of that same commit states:

"Two tests cover the tightened rule — a display-name anchor suppressing a bot whose name is the local part, and the pair asserting the tier is reachable only when no other tier placed the user. Reverting the gate to email-only fails 3 tests."

So the coverage the review asked for arrived in the very commit that created the flagged line.

The describe("matchUserAuthors identity tiers") block exists at platform/tests/personal-ai-usage.test.ts:454 with 14 tests, and every line number in your table matches the file exactly:

Requested Actual
email anchors :455, :464, :473, :480 (email tier)
name anchors / suppression :537 — "ignores the email local part once a display name anchored the user" (added by 7f640b6)
local-part suppression :558 — "runs the email local part only when no other tier placed the user" (added by 7f640b6)
no anchors / fallback :579 and :608
matchedBy assertions :591, plus the assertions inside :537 and :558

The mutation claim also holds: reverting the gate to email-only would break the three tests that pin the anchor suppression behavior (:537, :558, and :591).

On the retrigger itself

You're right, and this is precisely the case I acknowledged in #discussion_r3974585769. The rule a443d4d0-1f7b-438d-adf4-796df8e4fbd1 was explicitly confirmed satisfied in #discussion_r3974456921 and #discussion_r3974585396; the following commit increased the coverage of this very code path from 12 to 14 tests. The only thing that changed is the line number of matchUserAuthors — so the carry-forward state is being keyed to position, not to the symbol.

This is a defect in the retrigger logic, not in the rule. The rule's content is legitimate — critical business logic deserves tests, and matchUserAuthors is exactly that. The fix, as you suggest, is to anchor the confirmed-satisfied state on the symbol (matchUserAuthors) rather than the line offset, and only re-raise when the symbol's coverage actually regresses (fewer tests, or the removal/mutation of a pinning assertion). This conversation serves as the repro for that fix: third occurrence, confirmed satisfied, coverage trending up, only line numbers moving.

Nothing to do on the PR side — no code changes, no additional tests. I'll treat the resolved state of this rule on this PR as carried forward and won't re-raise it for positional shifts.


for (const author of authors) {
if (candidates.emailLocalParts.has(nameKey(author.name))) {
matched.push({ author, matchedBy: "name" });
}
}
Comment on lines +169 to 184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

matchUserAuthors now aggregates a display-name match for every author alongside any email match, whereas the previous pickUserAuthor ran the name fallback only when no author matched the user's email, so a same-name namesake always pollutes the aggregated row. This over-attributes the namesake's total_commits and drags or inflates aiCommitPct, regressing the issue #193 fix. After collecting email matches, only add a name match when matches.length === 0, or require the name-matched author to have no email/commits conflict before aggregating; at minimum stop adding the email local part of the display name to the name candidates.

for (const author of authors) {
  if (author.email && emailCandidates.has(nameKey(author.email))) {
    matched.push({ author, matchedBy: "email" });
  }
}
if (matched.length === 0) {
  for (const author of authors) {
    if (nameCandidates.has(nameKey(author.name))) matched.push({ author, matchedBy: "name" });
  }
}
Prompt for LLM

File platform/lib/queries/personal-ai-usage.ts:

Line 121 to 128:

matchUserAuthors now aggregates a display-name match for every author alongside any email match, whereas the previous pickUserAuthor ran the name fallback only when no author matched the user's email, so a same-name namesake always pollutes the aggregated row. This over-attributes the namesake's total_commits and drags or inflates aiCommitPct, regressing the issue #193 fix. After collecting email matches, only add a name match when matches.length === 0, or require the name-matched author to have no email/commits conflict before aggregating; at minimum stop adding the email local part of the display name to the name candidates.

Suggested Code:

  for (const author of authors) {
    if (author.email && emailCandidates.has(nameKey(author.email))) {
      matched.push({ author, matchedBy: "email" });
    }
  }
  if (matched.length === 0) {
    for (const author of authors) {
      if (nameCandidates.has(nameKey(author.name))) matched.push({ author, matchedBy: "name" });
    }
  }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kody Esse é o comentário mais valioso da sua revisão — e o único cujo patch, se aplicado, reintroduz o bug que o PR fecha. Vale separar as duas coisas.

O diagnóstico está correto. Confirmei contra o main: o pickUserAuthor antigo tinha duas passadas, e a de nome só rodava se nenhum autor tivesse batido por email, retornando um único autor. O matchUserAuthors passou a coletar matches de nome ao lado dos de email. Você leu a mudança certo.

A prescrição reverteria o fix. O teste-bandeira deste PR (personal-ai-usage.test.ts, "consolidates a secondary email identity with the primary name-matched one") é o #193 reproduzido:

Dev <dev@company.example>   183 commits, 99.5% AI   <- bate SÓ por nome
Dev <dev@example.com>         1 commit,    0% AI    <- bate por email

Com o if (matched.length === 0) antes da passada de nome, a identidade de 183 commits é descartada e a página volta a exibir 1 commit / 0% AI. A conta carrega um único email, então a segunda identidade git de uma pessoa só é recuperável por nome — não existe caminho por email para ela. Gatear o tier de nome atrás de "nenhum match por email" torna o fix inerte por construção.

O resíduo real que você encontrou, e que eu peguei: o local part do email. Esse sim era indefensável — dev derivado de dev@example.com é um palpite, não uma identidade declarada, e estava concorrendo mesmo quando o email já tinha ancorado o repo. Um local part genérico arrastava bot e service account para dentro da linha. Agora os candidatos estão em três tiers explícitos (UserIdentityCandidates) e o de local part só roda em payloads onde nenhuma linha bateu por email. Isso é o seu "at minimum", implementado.

O xará propriamente dito eu aceitei, mas parei de deixá-lo implícito. Ele agora tem um teste de caracterização que afirma a absorção, com o porquê e a condição de saída registrados no corpo do teste — quando a conta passar a carregar todos os emails verificados do GitHub, esse teste falha e obriga quem mexer a atualizar a expectativa. Risco acidental virou risco versionado.

Fix de raiz, fora do escopo deste PR: persistir a lista de emails verificados do GitHub. O scope user:email já é pedido em lib/auth.ts, só não é gravado — com ele o caso #193 vira match por email puro e o tier de nome volta a ser último recurso de verdade. Vou abrir issue separada.

Teste de mutação: removendo o gate do local part, 3 testes falham (o do gate, o de consistência trend/tabela e o end-to-end).

Validado contra dados reais num ambiente local: o payload de design-system-react trouxe duas identidades minhas com o mesmo nome git e emails diferentes (14 commits/100% + 1 commit/0%), e /me/ai-usage consolidou em 15 commits / 93%, com as duas identidades no tooltip e o badge de match por nome.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your rebuttal holds up against the actual code — I re-read the branch rather than arguing from the suggestion text. Concession first, then what I verified.

The prescription would have regressed #193. You're right, and I should have caught it. My proposed if (matched.length === 0) gate before the name pass is structurally identical to the old pickUserAuthor: email first, name only when email yields nothing, single author out. In your counterexample the account email anchors the 1-commit identity, so the name pass never runs, the 183-commit identity is dropped, and the page renders exactly the wrong number this PR exists to fix. The bug scenario requires that a name match be collected alongside an email match, because the account carries a single email and the second identity is only reachable by display name. The gate I suggested makes the fix inert by construction. Conceded.

The local-part tier is now what I actually asked for, implemented more carefully than I proposed. buildIdentityCandidates (platform/lib/queries/personal-ai-usage.ts) splits candidates into three explicit tiers, and matchUserAuthors guards the local-part pass behind anchoredByEmail — it runs only when no row matched on email. Your "at minimum" was the minimum, and you went past it by making the tier structure explicit and documented instead of leaving it as a silent concatenation of sets. That closes the concrete over-attribution I flagged: with the account email anchoring the repo, dev-as-user.name can no longer drag a bot into the row.

The mutation claim checks out. If if (anchoredByEmail) return matched; is removed, exactly three tests in platform/tests/personal-ai-usage.test.ts fail: "ignores the email local part once any row matched on email", "excludes from the trend the same local-part identity the table excludes", and "keeps the table and the trend on the same identities end to end". That last one is the important one — it guards against the table and the chart disagreeing about who you are, which is the worst failure mode on that page.

The namesake trade-off is now versioned risk, not accidental risk. The characterization test ("absorbs a namesake sharing the display name") does what I asked for better than a conditional ever would: it asserts the absorption and documents the exit condition in its body. And I verified the root fix is reachable — platform/lib/auth.ts:87 already requests scope: "read:user user:email read:org", so persistence is the only missing piece. One suggestion for the issue you're opening: make it reference the characterization test by name, so that flipping the account to carry verified emails fails that test and forces the badge text and the tooltip promise ("at least one identity matched by name") to flip together. The test is the enforcement mechanism; the issue should point at it.

Non-blocking watch item, one line: when email is null in matchedIdentities (legacy payloads), two same-name rows render identically in the tooltip — visually indistinguishable duplicates. Cosmetic, pre-existing data, not worth holding the PR.

Net position: the review comment's diagnosis survives, its prescription doesn't. The code that shipped resolves my actual concern (the local-part guess competing while email had anchored the repo) without manufacturing the bug the PR closes. No further action needed from your side on this thread — the root-fix issue is the right follow-through.

return null;

return matched;
}

/**
* Collapse the user's identities within one push into the single row the
* per-repo table shows.
*
* `aiCommitPct` is weighted by commit count so a stray one-commit identity
* cannot drag the share of a large one down. Each identity weighs at least 1:
* payloads from iris < 1.0.2 carry no `total_commits`, and a floor keeps them
* on the same formula instead of a second code path — one such identity
* reproduces its own share exactly, several collapse to the plain mean, and
* the denominator can never reach zero. `totalCommits` stays the honest sum of
* what the payload actually reported, so those rows still show 0 commits.
*
* The floor treats a missing `total_commits` and a reported zero alike, which
* is safe because the engine emits an author row only for an author it counted
* at least one commit for: `compute_author_velocity` sums the row's total from
* its weekly buckets, and a bucket exists only where a commit landed. A weight
* of zero therefore always means "field absent", never "this person committed
* nothing". Should that invariant ever change, splitting the two cases has to
* come with a guard for an all-zero denominator.
*
* `highVelocityWeeks` takes the maximum rather than the sum: the engine counts
* weeks, and the same calendar week can appear under two identities. Summing
* would double-count it, and recomputing from `weekly` would duplicate the
* engine's threshold logic here.
*
* `matchedBy` reports "email" only when every identity matched on email. If
* any part of the row rests on a display-name match, the whole row carries the
* weaker guarantee and the UI should say so.
*/
export function aggregateAuthors(
matches: MatchedAuthor[],
): AggregatedUsage | null {
if (matches.length === 0) return null;

let totalCommits = 0;
let weightSum = 0;
let weightedPctSum = 0;
let highVelocityWeeks = 0;
let everyMatchByEmail = true;
const identities: AuthorIdentity[] = [];

for (const { author, matchedBy } of matches) {
const commits = author.total_commits ?? 0;
const weight = Math.max(commits, 1);
totalCommits += commits;
weightSum += weight;
weightedPctSum += author.ai_commit_pct * weight;
Comment on lines +231 to +235

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

aggregateAuthors applies the weight floor Math.max(commits, 1) unconditionally, so a matching identity with total_commits genuinely 0 in a commit-counting (iris >= 1.0.2) payload gets weight 1 instead of 0, diluting the reported aiCommitPct (e.g., 10 commits @100% + 0 commits @0% reports ~90.9% instead of 100%). The commits ?? 0 pattern conflates an absent field (pre-1.0.2 payload) with a present-and-zero field; detect absence per author (author.total_commits === undefined) and floor only those rows, leaving counted rows at weight = commits.

const floorWeight = author.total_commits === undefined;
const weight = floorWeight ? 1 : author.total_commits;
Prompt for LLM

File platform/lib/queries/personal-ai-usage.ts:

Line 226 to 230:

aggregateAuthors applies the weight floor Math.max(commits, 1) unconditionally, so a matching identity with total_commits genuinely 0 in a commit-counting (iris >= 1.0.2) payload gets weight 1 instead of 0, diluting the reported aiCommitPct (e.g., 10 commits @100% + 0 commits @0% reports ~90.9% instead of 100%). The `commits ?? 0` pattern conflates an absent field (pre-1.0.2 payload) with a present-and-zero field; detect absence per author (author.total_commits === undefined) and floor only those rows, leaving counted rows at weight = commits.

Suggested Code:

const floorWeight = author.total_commits === undefined;
const weight = floorWeight ? 1 : author.total_commits;

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kody A conflação que você aponta existe, mas o cenário não é alcançável e a sua correção abre um buraco pior. Documentei o invariante em 7f640b6 em vez de mudar o código — que foi, aliás, exatamente o que você recomendou primeiro.

1. O engine não consegue emitir total_commits: 0. Em iris/analysis/author_velocity.py, compute_author_velocity acumula o total da linha a partir dos buckets semanais (total_commits += w.commits), e uma chave só existe em author_weeks porque algum commit foi atribuído a ela. Toda linha emitida reporta pelo menos 1 commit. O seu exemplo — 10 commits @100% + 0 commits @0% → 90,9% — não é produzível por payload nenhum do iris. Peso zero significa sempre "campo ausente", nunca "essa pessoa não commitou".

2. A sua correção reintroduz o NaN. const weight = floorWeight ? 1 : author.total_commits; dá peso 0 para um total_commits: 0 presente. Se todas as identidades da linha estiverem nessa condição, weightSum === 0 e a divisão vira 0/0. É precisamente a garantia que o piso comprou, e derruba o teste never yields NaN when every identity reports zero commits. Separar os dois casos exige uma guarda de denominador junto — ou seja, o branch que a thread do aiCommitPct acabou de concluir que não valia a pena.

3. Você recomendou o contrário há cinco minutos. Na sua resposta em #discussion_r3974457332:

the floor would give that row a vote it doesn't deserve — a bounded dilution of at most 1/(total+1). Worth a one-line comment pinning the invariant, not a code change today.

Concordo com essa versão. Foi ela que implementei: o TSDoc do aggregateAuthors agora registra de onde vem o invariante, por que peso zero equivale a campo ausente, e o que precisaria acompanhar a mudança caso o engine passe a emitir linhas de zero commit — uma guarda de denominador all-zero. Assim a próxima pessoa que encostar aí tem a premissa escrita em vez de precisar inferi-la do Python.

Bônus da outra thread, já verificado: a sua ressalva sobre ai_commit_pct poder ser null não se aplica — em platform/src/types/metrics.ts o campo é ai_commit_pct: number, não-opcional. total_commits?: number é o único opcional do tipo, e é o que já está tratado.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — I checked each of your three points against the branch and you're right on all of them. The suggestion is withdrawn as written; documenting the invariant in the TSDoc is the correct resolution. Concretely:

1. The engine invariant holds. In iris/analysis/author_velocity.py, a key only enters author_weeks inside the commit loop (line ~157: author_weeks[key][week_start]["commits"] += 1), and the row's total_commits is then accumulated exclusively from those weekly buckets (lines ~176–192). A row existing means at least one commits += 1 happened for that key, so every emitted AuthorVelocity has total_commits >= 1; the whole function also returns None when commits is empty. Your example (10 @100% + 0 @0%) is not producible by this code path.

2. The suggested fix genuinely reopens the NaN. With const weight = floorWeight? 1: author.total_commits;, a present-but-zero total_commits yields weight 0, and a row where every identity lands there divides 0/0. That breaks the guarantee your defensive test now pins at platform/tests/personal-ai-usage.test.ts:638 (never yields NaN when every identity reports zero commits, asserting Number.isFinite). Splitting absent-vs-zero would have to ship a denominator guard alongside — exactly the branch the aiCommitPct thread concluded wasn't worth carrying. Keeping Math.max(commits, 1) avoids it.

3. The consistency point is fair — that's what the earlier recommendation said (bounded dilution of at most 1/(total+1), comment over code change), and the TSDoc now records it: the paragraph in aggregateAuthors's docblock names the source of the invariant, states that weight 0 always means "field absent", and flags the all-zero-denominator guard as the companion to any future engine change. That's the right landing point for the next person.

Bonus claim verified: platform/src/types/metrics.ts:378 declares ai_commit_pct: number (non-optional); total_commits?: number at line 376 is the only optional field, and it's the one already handled.

One supporting observation, for what it's worth — not reopening anything: the floor also absorbs a third runtime state the type system doesn't cover. Older payloads stored in the DB can carry "total_commits": null (JSON-valid even though TS says number | undefined), and ?? 0 folds that into the same floor path → weight 1. Under the pure undefined-check version, null would flow through as a 0-weight vote and re-hit the same 0/0 in the all-null case. So the floor isn't only defending the engine's impossible zero — it's defending a state that is reachable at runtime — which makes keeping it even safer than the "document-only" framing suggests.

Resolution stands: docs-only for the zero-commit invariant, with the test suite already locking in the NaN-free behavior and the TSDoc pointing at where the denominator guard goes if the engine ever changes.

highVelocityWeeks = Math.max(highVelocityWeeks, author.high_velocity_weeks);
if (matchedBy === "name") everyMatchByEmail = false;
identities.push({ name: author.name, email: author.email ?? null });
}

return {
totalCommits,
aiCommitPct: weightedPctSum / weightSum,
highVelocityWeeks,
matchedBy: everyMatchByEmail ? "email" : "name",
identities,
};
}

/**
* Merge one push's weekly arrays across every identity the user commits under,
* so a week split between two identities becomes a single entry instead of two
* competing ones.
*/
function mergeWeeklyAcrossIdentities(
matches: MatchedAuthor[],
): Map<string, WeekTotals> {
const merged = new Map<string, WeekTotals>();

for (const { author } of matches) {
for (const week of author.weekly ?? []) {
const totals = merged.get(week.week_start) ?? {
commits: 0,
aiCommits: 0,
hasAiData: false,
};
totals.commits += week.commits;
if (typeof week.ai_commits === "number") {
totals.aiCommits += week.ai_commits;
totals.hasAiData = true;
}
merged.set(week.week_start, totals);
}
}

return merged;
}

// Weekly AI commit share aggregated across each repo's full fetched history,
Expand All @@ -111,8 +289,7 @@ function pickUserAuthor(
// no AI share for those weeks.
export function buildUsageTrend(
rowsPerRepo: Map<string, MetricRow[]>,
emailCandidates: Set<string>,
nameCandidates: Set<string>,
candidates: UserIdentityCandidates,
): UsageTrendPoint[] {
type WeekBucket = {
commits: number;
Expand All @@ -129,29 +306,24 @@ export function buildUsageTrend(
// skipped.
const seenWeeks = new Set<string>();
for (const row of rows) {
const match = pickUserAuthor(
row.payload,
emailCandidates,
nameCandidates,
);
if (!match?.author.weekly) continue;
for (const w of match.author.weekly) {
if (seenWeeks.has(w.week_start)) continue;
seenWeeks.add(w.week_start);

const bucket: WeekBucket = weekly.get(w.week_start) ?? {
const matches = matchUserAuthors(row.payload, candidates);
if (matches.length === 0) continue;

for (const [weekStart, totals] of mergeWeeklyAcrossIdentities(matches)) {
if (seenWeeks.has(weekStart)) continue;
seenWeeks.add(weekStart);

const bucket: WeekBucket = weekly.get(weekStart) ?? {
commits: 0,
aiCommits: 0,
repoIds: new Set(),
hasAiData: false,
};
bucket.commits += w.commits;
if (typeof w.ai_commits === "number") {
bucket.aiCommits += w.ai_commits;
bucket.hasAiData = true;
}
bucket.commits += totals.commits;
bucket.aiCommits += totals.aiCommits;
if (totals.hasAiData) bucket.hasAiData = true;
bucket.repoIds.add(repoId);
weekly.set(w.week_start, bucket);
weekly.set(weekStart, bucket);
}
}
}
Expand Down Expand Up @@ -183,18 +355,14 @@ export async function getPersonalAIUsage(

if (orgs.length === 0) return empty;

// Email match is the reliable identity. Name match is a fallback for
// older payloads (pre-email field) and unusual cases.
const emailCandidates = new Set<string>();
if (user.email) emailCandidates.add(nameKey(user.email));

const nameCandidates = new Set<string>();
if (user.name) nameCandidates.add(nameKey(user.name));
if (user.email) {
const localPart = user.email.split("@")[0];
if (localPart) nameCandidates.add(nameKey(localPart));
const candidates = buildIdentityCandidates(user);
if (
candidates.emails.size === 0 &&
candidates.names.size === 0 &&
candidates.emailLocalParts.size === 0
) {
return empty;
}
if (emailCandidates.size === 0 && nameCandidates.size === 0) return empty;

const orgIds = orgs.map((o) => o.id);
const orgIndex = new Map(orgs.map((o) => [o.id, o]));
Expand Down Expand Up @@ -237,8 +405,8 @@ export async function getPersonalAIUsage(

for (const [repoId, rows] of rowsPerRepo) {
const row = rows[0]; // newest row — summary table shows current snapshot only.
const match = pickUserAuthor(row.payload, emailCandidates, nameCandidates);
if (!match) continue;
const usage = aggregateAuthors(matchUserAuthors(row.payload, candidates));
if (!usage) continue;
const repo = repoIndex.get(repoId);
const org = orgIndex.get(row.organization_id);
if (!repo || !org) continue;
Expand All @@ -248,21 +416,19 @@ export async function getPersonalAIUsage(
organizationName: org.name,
repositoryName: repo.name,
repositoryId: repoId,
aiCommitPct: match.author.ai_commit_pct,
totalCommits: match.author.total_commits ?? 0,
matchedAuthorName: match.author.name,
matchedAuthorEmail: match.author.email ?? null,
matchedBy: match.matchedBy,
highVelocityWeeks: match.author.high_velocity_weeks,
aiCommitPct: usage.aiCommitPct,
totalCommits: usage.totalCommits,
matchedIdentities: usage.identities,
matchedBy: usage.matchedBy,
highVelocityWeeks: usage.highVelocityWeeks,
lastSeenAt: row.created_at,
});
aiSum += match.author.ai_commit_pct;
aiSum += usage.aiCommitPct;
aiCount += 1;
if (match.author.high_velocity_weeks > maxHv)
maxHv = match.author.high_velocity_weeks;
if (usage.highVelocityWeeks > maxHv) maxHv = usage.highVelocityWeeks;
}

const trend = buildUsageTrend(rowsPerRepo, emailCandidates, nameCandidates);
const trend = buildUsageTrend(rowsPerRepo, candidates);

perRepo.sort((a, b) => b.aiCommitPct - a.aiCommitPct);

Expand Down
4 changes: 2 additions & 2 deletions platform/lib/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ export const translations = {
lastSeen: "Last seen",
nameMatchBadge: "name-only",
nameMatchWarning:
"Matched by author name, not email — verify this is really you.",
"At least one git identity in this row was matched by author name, not email — verify this is really you.",
},
coverageNote:
"Tool-level and intent-level breakdowns per author aren't in the engine yet. When they are, this page will pick them up automatically.",
Expand Down Expand Up @@ -1657,7 +1657,7 @@ export const translations = {
lastSeen: "Última vez visto",
nameMatchBadge: "só por nome",
nameMatchWarning:
"Atribuído pelo nome do autor, sem email — vale conferir se é você mesmo.",
"Pelo menos uma identidade git desta linha foi atribuída pelo nome do autor, sem email — vale conferir se é você mesmo.",
},
coverageNote:
"Detalhamento por ferramenta e por intenção, no nível de autor, ainda não está no motor. Quando estiver, esta página vai puxar automaticamente.",
Expand Down
Loading
Loading