From 21fe8ded3200d6e1ef3d257dba4de1c129ae0563 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 31 Oct 2026 21:34:38 +0800 Subject: [PATCH] feat: explain income dispersion within comparable sample groups --- .../src/components/IncomeDistribution.tsx | 17 ++++++++++- apps/docs/src/lib/research-statistics.ts | 21 ++++++++++++++ apps/docs/tests/research.test.ts | 29 ++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/components/IncomeDistribution.tsx b/apps/docs/src/components/IncomeDistribution.tsx index 3196c4d..4129b51 100644 --- a/apps/docs/src/components/IncomeDistribution.tsx +++ b/apps/docs/src/components/IncomeDistribution.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import type { ResearchSnapshot } from "@/lib/ai-research"; -import { groupIncome, platformIncomeSample } from "@/lib/research-statistics"; +import { compareIncomeGroups, groupIncome, platformIncomeSample } from "@/lib/research-statistics"; const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", { style: "currency", currency: "USD", maximumFractionDigits: 0, @@ -23,6 +23,7 @@ export default function IncomeDistribution({ data, onSelect }: { const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({ category, rows: groups.filter((group) => group.category === category), })); + const comparisons = compareIncomeGroups(groups); const categories = new Map(data.categories.map((category) => [category.id, category.name])); const metric = annualize ? "MRR×12参考年化" : "平台MRR"; const values = sample.points.map((point) => point.value); @@ -47,6 +48,20 @@ export default function IncomeDistribution({ data, onSelect }: {

{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}

资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}

{sample.points.length ? <> +
{ticks.map((tick) => {usd(tick)})} diff --git a/apps/docs/src/lib/research-statistics.ts b/apps/docs/src/lib/research-statistics.ts index ffd2ee9..2ad13d6 100644 --- a/apps/docs/src/lib/research-statistics.ts +++ b/apps/docs/src/lib/research-statistics.ts @@ -110,3 +110,24 @@ export function groupIncome(points: IncomePoint[], splitModels = true) { } return [...groups.entries()].map(([key, group]) => ({ key, ...group, stats: describeIncome(group.points.map((point) => point.value))! })); } + +/** Compare descriptive statistics only within the same provider and delivery model. */ +export function compareIncomeGroups(groups: ReturnType) { + const buckets = new Map(); + for (const group of groups) { + if (group.stats.n < 3 || group.stats.cv === null || group.stats.largestRemovalDrop === null || + group.model === "全部收费模式" || group.model === "收费模式未核验") continue; + const key = JSON.stringify([group.provider, group.model]); + const bucket = buckets.get(key) ?? []; + bucket.push(group); + buckets.set(key, bucket); + } + return [...buckets.entries()].flatMap(([key, rows]) => { + if (rows.length < 2) return []; + const byCV = [...rows].sort((a, b) => a.stats.cv! - b.stats.cv! || a.key.localeCompare(b.key)); + const bySensitivity = [...rows].sort((a, b) => a.stats.largestRemovalDrop! - b.stats.largestRemovalDrop! || a.key.localeCompare(b.key)); + return [{ key, provider: rows[0].provider, model: rows[0].model, + leastDispersed: byCV[0], mostDispersed: byCV[byCV.length - 1], + leastSensitive: bySensitivity[0], mostSensitive: bySensitivity[bySensitivity.length - 1] }]; + }); +} diff --git a/apps/docs/tests/research.test.ts b/apps/docs/tests/research.test.ts index a0a9383..a5ab75e 100644 --- a/apps/docs/tests/research.test.ts +++ b/apps/docs/tests/research.test.ts @@ -11,7 +11,34 @@ import { openSiteDatabase } from "../src/lib/site-database"; import { categorySummary, evidenceUrl, reviewCompanyIds, validateResearch, type ResearchSnapshot } from "../src/lib/ai-research"; import { consumeCode, initializeStore } from "../src/lib/auth-store"; import { newAuthConfig } from "../src/lib/auth"; -import { arrDisclosureCoverage, arrDisclosures, describeIncome, groupIncome, platformIncomeSample } from "../src/lib/research-statistics"; +import { arrDisclosureCoverage, arrDisclosures, compareIncomeGroups, describeIncome, groupIncome, platformIncomeSample } from "../src/lib/research-statistics"; + +test("income interpretations compare sufficient samples within a provider and known billing model", () => { + const snapshot = structuredClone(seed) as ResearchSnapshot; + const makePoints = (cat: string, values: number[], provider = "Stripe", model = "软件订阅") => values.map((value, index) => ({ + company: { ...snapshot.companies[0], id: `${cat}-${provider}-${model}-${index}`, cat, businessModel: model }, + observation: { ...snapshot.observations[0], provider }, value, + })); + const points = [ + ...makePoints("C04", [100, 100, 100]), + ...makePoints("C06", [10, 10, 1000]), + ...makePoints("C08", [1, 10000]), + ...makePoints("C09", [1, 10, 10000], "RevenueCat"), + ...makePoints("C10", [1, 10, 10000], "Stripe", "硬件服务"), + ...makePoints("C07", [0, 0, 0]), + ...makePoints("C01", [1, 10, 100], "Stripe", "收费模式未核验"), + ]; + const comparisons = compareIncomeGroups(groupIncome(points)); + assert.equal(comparisons.length, 1); + assert.equal(comparisons[0].leastDispersed.category, "C04"); + assert.equal(comparisons[0].mostDispersed.category, "C06"); + assert.equal(comparisons[0].leastSensitive.category, "C04"); + assert.equal(comparisons[0].mostSensitive.category, "C06"); + assert.deepEqual(compareIncomeGroups(groupIncome(points, false)), [], "merged billing models are not comparable here"); + const annualized = compareIncomeGroups(groupIncome(points.map((point) => ({ ...point, value: point.value * 12 })))); + assert.equal(annualized[0].mostSensitive.category, comparisons[0].mostSensitive.category); + assert.ok(Math.abs(annualized[0].mostDispersed.stats.cv! - comparisons[0].mostDispersed.stats.cv!) < 1e-12); +}); test("ARR coverage counts companies once and separates missing disclosures from zero income", () => { const snapshot = structuredClone(seed) as ResearchSnapshot;