diff --git a/packages/stats/app/src/component/model-compare-detail.tsx b/packages/stats/app/src/component/model-compare-detail.tsx
index 3790789b58b..8fc61d1e930 100644
--- a/packages/stats/app/src/component/model-compare-detail.tsx
+++ b/packages/stats/app/src/component/model-compare-detail.tsx
@@ -3,6 +3,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import {
getStatsModelsComparisonData,
type ModelUsagePoint,
+ type RetentionEntry,
type StatsModelComparisonInput,
type StatsModelComparisonEntry,
} from "@opencode-ai/stats-core/domain/home"
@@ -949,6 +950,17 @@ function buildComparisonDetailSections(models: readonly ComparisonModel[]): Comp
],
usage: models.map((model) => model.stats?.usage ?? []),
},
+ {
+ title: "Retention",
+ badge: "Week 1",
+ rows: [
+ comparisonDetailRow(
+ "Returning users",
+ models.map((model) => retentionCell(model.stats?.weeklyRetention)),
+ "higher",
+ ),
+ ],
+ },
]
}
@@ -1031,6 +1043,15 @@ function percentCell(value: number | undefined): ComparisonDetailCell {
return value === undefined ? { value: "No usage" } : { value: formatPercent(value), score: value }
}
+function retentionCell(value: RetentionEntry | null | undefined): ComparisonDetailCell {
+ if (!value || value.rank === null) return { value: "Pending" }
+ return {
+ value: formatPercent(value.rate),
+ unit: `${formatTokens(value.eligibleUserWeeks)} user-weeks`,
+ score: value.rate,
+ }
+}
+
function tokenCell(value: number | undefined, trend: number | undefined): ComparisonDetailCell {
if (value === undefined) return { value: "No usage" }
return { value: formatTokens(value), score: value, trend }
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index c9a3e6ef6d7..d2fbfdf7c5e 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -470,7 +470,7 @@ function ModelMomentumSection(props: { data: StatsModelPageData | null }) {
value={formatInteger(data().totals.sessions)}
/>
-
+
-
+
0}
fallback={
}
>
@@ -660,13 +656,13 @@ function RetentionSection(props: { data: RetentionEntry[] }) {
onPointerEnter={() => setActiveIndex(index())}
onFocus={() => setActiveIndex(index())}
onClick={() => setActiveIndex(index())}
- aria-label={`${item.model}, ${formatRetentionRate(item.rate)} seven-day retention, ${formatUsers(item.eligibleUserDays)} eligible user-days`}
+ aria-label={`${item.model}, ${formatRetentionRate(item.rate)} weekly retention, ${formatUsers(item.eligibleUserWeeks)} eligible user-weeks`}
>
{item.rank === null ? "–" : String(item.rank).padStart(2, "0")}
{item.model}
{formatRetentionRate(item.rate)}
- {formatUsers(item.eligibleUserDays)}
+ {formatUsers(item.eligibleUserWeeks)}
)}
diff --git a/packages/stats/core/src/domain/home.test.ts b/packages/stats/core/src/domain/home.test.ts
index c8b665048c3..3608d56d6a7 100644
--- a/packages/stats/core/src/domain/home.test.ts
+++ b/packages/stats/core/src/domain/home.test.ts
@@ -7,7 +7,7 @@ process.env.SST_RESOURCE_StatsDatabase = JSON.stringify({ url: "mysql://localhos
const { buildRetentionEntries } = await import("./home")
describe("retention aggregates", () => {
- test("pools the latest seven cohorts and ranks models above the sample floor", () => {
+ test("pools the latest seven weekly cohorts and ranks models above the sample floor", () => {
const rows = [
...cohorts("model-a", "provider-a", 8, 20, 10),
...cohorts("model-b", "provider-b", 8, 20, 12),
@@ -16,20 +16,20 @@ describe("retention aggregates", () => {
const entries = buildRetentionEntries(rows)
expect(entries.find((item) => item.model === "model-a")).toMatchObject({
- eligibleUserDays: 140,
- retainedUserDays: 70,
+ eligibleUserWeeks: 140,
+ retainedUserWeeks: 70,
rate: 50,
rank: 2,
})
expect(entries.find((item) => item.model === "model-b")).toMatchObject({
- eligibleUserDays: 140,
- retainedUserDays: 84,
+ eligibleUserWeeks: 140,
+ retainedUserWeeks: 84,
rate: 60,
rank: 1,
})
expect(entries.find((item) => item.model === "small-model")).toMatchObject({
- eligibleUserDays: 70,
- retainedUserDays: 63,
+ eligibleUserWeeks: 70,
+ retainedUserWeeks: 63,
rate: 90,
rank: null,
})
diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts
index c1784ed18a4..d5ce1b9c86f 100644
--- a/packages/stats/core/src/domain/home.ts
+++ b/packages/stats/core/src/domain/home.ts
@@ -29,8 +29,8 @@ export type RetentionEntry = {
provider: string
author: string
rate: number
- eligibleUserDays: number
- retainedUserDays: number
+ eligibleUserWeeks: number
+ retainedUserWeeks: number
rank: number | null
}
export type CountryEntry = { country: string; continent: string; tokens: number; share: number; rank: number }
@@ -64,7 +64,7 @@ export type StatsModelData = {
totalModels: number
tokenShare: number
tokenChange: number
- retention7d: RetentionEntry | null
+ weeklyRetention: RetentionEntry | null
totals: {
sessions: number
uniqueUsers: number
@@ -105,6 +105,7 @@ export type StatsModelComparisonEntry = {
totalModels: number
tokenShare: number
tokenChange: number
+ weeklyRetention: RetentionEntry | null
totals: StatsModelData["totals"]
usage: ModelUsagePoint[]
}
@@ -142,8 +143,8 @@ const TOKEN_SCALE = 1_000_000
const DOLLARS_PER_MICROCENT = 1 / 100_000_000
const METRIC_MODEL_LIMIT = 10
const RETENTION_MODEL_LIMIT = 15
-const RETENTION_MIN_ELIGIBLE_USER_DAYS = 100
-const RETENTION_COHORT_DAYS = 7
+const RETENTION_MIN_ELIGIBLE_USER_WEEKS = 100
+const RETENTION_COHORT_WEEKS = 7
const TOP_MODEL_SEGMENT_LIMIT = 9
// Preserve the response shape while the public site presents Go and Free as one cohort.
const SITE_PRODUCT = "Go"
@@ -193,7 +194,7 @@ export function getStatsHomeData(): Effect.Effect
const [modelRows, geoRows, retentionRows] = await Promise.all([
listModelDaily(),
listGeoDaily(),
- listRetentionDaily(),
+ listRetentionWeekly(),
])
return buildStatsHomeData(modelRows, geoRows, retentionRows)
},
@@ -207,7 +208,7 @@ export function getStatsModelData(
): Effect.Effect {
return Effect.tryPromise({
try: async () => {
- const [modelRows, retentionRows] = await Promise.all([listModelDaily(), listRetentionDaily()])
+ const [modelRows, retentionRows] = await Promise.all([listModelDaily(), listRetentionWeekly()])
const normalized = modelRows.flatMap(normalizeStatRow)
const resolvedModel = resolveModelName(model, normalized, provider)
if (!resolvedModel) return null
@@ -288,12 +289,12 @@ async function listGeoDaily(opts?: { provider?: string; model?: string }): Promi
}))
}
-async function listRetentionDaily(): Promise {
+async function listRetentionWeekly(): Promise {
try {
return (
await queryRows(
`select cohort_date, updated_at, provider, model, eligible_users, retained_users
- from model_retention where dataset = 'zen' and tier = 'all' order by cohort_date`,
+ from model_retention where dataset = 'zen' and tier = 'Go' order by cohort_date`,
)
).map((row) => ({
cohortDate: stringValue(row.cohort_date),
@@ -334,8 +335,16 @@ export const getStatsModelsComparisonData: (
) => Effect.Effect = Effect.fn("StatsModelsComparison.getData")(
function* (models) {
const modelStats = yield* ModelStatRepo
- const rows = yield* modelStats.listDaily()
- const entries = models.map((model) => toComparisonEntry(buildStatsModelData(model.model, rows, [], model.provider)))
+ const [rows, retentionRows] = yield* Effect.all([
+ modelStats.listDaily(),
+ Effect.tryPromise({
+ try: listRetentionWeekly,
+ catch: (cause) => DatabaseError.make({ cause }),
+ }),
+ ])
+ const entries = models.map((model) =>
+ toComparisonEntry(buildStatsModelData(model.model, rows, [], model.provider, retentionRows)),
+ )
const latest = entries
.map((model) => model?.updatedAt)
.flatMap((value) => (value ? [dateTime(value)] : []))
@@ -458,7 +467,7 @@ function buildStatsModelData(
const peerRank = rankIndex >= 0 ? rankIndex + 1 : 1
const totalTokens = windowPeers.reduce((sum, item) => sum + item.totalTokens, 0)
const peerTokens = rankPeers.reduce((sum, item) => sum + item.totalTokens, 0)
- const retention7d = buildRetentionEntries(retentionRows).find((item) => item.model === model) ?? null
+ const weeklyRetention = buildRetentionEntries(retentionRows).find((item) => item.model === model) ?? null
return {
updatedAt: Number.isFinite(latestUpdate) ? new Date(latestUpdate).toISOString() : null,
@@ -471,7 +480,7 @@ function buildStatsModelData(
totalModels: windowPeers.length,
tokenShare: totalTokens > 0 ? round((current.totalTokens / totalTokens) * 100, 2) : 0,
tokenChange: percentChange(current.totalTokens, previous.totalTokens),
- retention7d,
+ weeklyRetention,
totals: {
sessions: current.sessions,
uniqueUsers: current.uniqueUsers,
@@ -553,6 +562,7 @@ function toComparisonEntry(data: StatsModelData | null): StatsModelComparisonEnt
totalModels: data.totalModels,
tokenShare: data.tokenShare,
tokenChange: data.tokenChange,
+ weeklyRetention: data.weeklyRetention,
totals: data.totals,
usage: data.usage,
}
@@ -574,7 +584,7 @@ function emptyStatsHomeData(): StatsHomeData {
}
export function buildRetentionEntries(rows: RetentionMetricRow[]): RetentionEntry[] {
- const cohortDates = [...new Set(rows.map((row) => row.cohortDate))].toSorted().slice(-RETENTION_COHORT_DAYS)
+ const cohortDates = [...new Set(rows.map((row) => row.cohortDate))].toSorted().slice(-RETENTION_COHORT_WEEKS)
const aggregate = rows
.filter((row) => cohortDates.includes(row.cohortDate))
.reduce