import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research"; export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number }; /** An evidence ledger, never a comparable sample: retain historical claims and estimates separately. */ export function arrDisclosures(snapshot: ResearchSnapshot, options: { category?: string; foundedYear?: number } = {}) { const companies = new Map(snapshot.companies.map((company) => [company.id, company])); return snapshot.observations.flatMap((observation) => { const company = companies.get(observation.company); if (!company || ![2025, 2026].includes(company.year ?? 0) || observation.metric !== "arr" || !["signal", "estimate", "historical", "historical-estimate"].includes(observation.selection) || !Number.isFinite(observation.value) || observation.value < 0 || (options.category && company.cat !== options.category) || (options.foundedYear && company.year !== options.foundedYear)) return []; return [{ company, observation }]; }); } /** Count companies once; missing ARR is a coverage gap, never zero revenue. */ export function arrDisclosureCoverage(snapshot: ResearchSnapshot, foundedYear?: number) { const rows = arrDisclosures(snapshot, { foundedYear }); return snapshot.categories.map((category) => { const companies = snapshot.companies.filter((company) => company.cat === category.id && [2025, 2026].includes(company.year ?? 0) && (!foundedYear || company.year === foundedYear)); const claims = rows.filter((row) => row.company.cat === category.id); const disclosed = new Set(claims.map((row) => row.company.id)); const reported = new Set(claims.filter((row) => ["signal", "historical"].includes(row.observation.selection)).map((row) => row.company.id)); return { id: category.id, name: category.name, companies: companies.length, disclosed: disclosed.size, reported: reported.size, estimatedOnly: disclosed.size - reported.size, missing: companies.length - disclosed.size, records: claims.length }; }); } export type ArrFieldGap = "date" | "currency" | "amount" | "estimate"; /** Prerequisites for inspecting exact, dated USD disclosures; never a comparability or audit verdict. */ export function arrFieldGaps(observation: ResearchObservation, asOf: string): ArrFieldGap[] { const gaps: ArrFieldGap[] = []; const day = calendarDay(observation.observedAt); const end = calendarDay(asOf); if (day === null || end === null || day > end) gaps.push("date"); if (observation.unit !== "USD") gaps.push("currency"); if (observation.bound !== "=" || !Number.isFinite(observation.value) || observation.value < 0) gaps.push("amount"); if (["estimate", "historical-estimate"].includes(observation.selection)) gaps.push("estimate"); return gaps; } export type IncomeStats = { n: number; mean: number; median: number; min: number; max: number; sampleDeviation: number | null; cv: number | null; meanMedianRatio: number | null; largestShare: number | null; meanWithoutLargest: number | null; largestRemovalDrop: number | null; }; /** Describes the supplied sample only; missing values never become zero. */ export function describeIncome(values: number[]): IncomeStats | null { if (!values.length || values.some((value) => !Number.isFinite(value) || value < 0)) return null; const ordered = [...values].sort((a, b) => a - b); const n = ordered.length; const total = ordered.reduce((sum, value) => sum + value, 0); const mean = total / n; const median = n % 2 ? ordered[(n - 1) / 2] : (ordered[n / 2 - 1] + ordered[n / 2]) / 2; const sampleDeviation = n > 1 ? Math.sqrt(ordered.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1)) : null; const max = ordered[n - 1]; const meanWithoutLargest = n > 1 ? (total - max) / (n - 1) : null; return { n, mean, median, min: ordered[0], max, sampleDeviation, cv: sampleDeviation !== null && mean > 0 ? sampleDeviation / mean : null, meanMedianRatio: median > 0 ? mean / median : null, largestShare: total > 0 ? max / total : null, meanWithoutLargest, largestRemovalDrop: meanWithoutLargest !== null && mean > 0 ? (mean - meanWithoutLargest) / mean : null }; } function calendarDay(value: string | undefined): number | null { if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; const time = Date.parse(`${value}T00:00:00Z`); return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value ? time / 86400000 : null; } /** Latest dated platform MRR per product, never ARR, cash collections or estimates. */ export function platformIncomeSample(snapshot: ResearchSnapshot, options: { windowDays: number | null; foundedYear?: number; annualize?: boolean; }) { const end = calendarDay(snapshot.asOf); if (end === null || (options.windowDays !== null && (!Number.isInteger(options.windowDays) || options.windowDays < 0))) throw new Error("Invalid income sample window"); const companies = new Map(snapshot.companies.map((company) => [company.id, company])); const latest = new Map(); const excluded: { observation: ResearchObservation; reason: string }[] = []; const candidates = snapshot.observations.filter((o) => o.selection === "platform" && o.metric === "mrr"); for (const observation of candidates) { const company = companies.get(observation.company); const day = calendarDay(observation.observedAt); let reason = ""; if (!company || ![2025, 2026].includes(company.year ?? 0)) reason = "创立年份未纳入2025—2026组"; else if (options.foundedYear && company.year !== options.foundedYear) reason = "不属于所选创立年份"; else if (observation.origin !== "payment-platform" || observation.unit !== "USD" || observation.bound !== "=" || observation.comparisonGroup !== "trustmrr-usd-mrr" || !observation.provider || !Number.isFinite(observation.value) || observation.value < 0) reason = "来源、币种、指标或数值口径不满足条件"; else if (day === null) reason = "缺少有效数据日期"; else if (day > end) reason = "数据日期晚于研究截止日"; else if (options.windowDays !== null && end - day > options.windowDays) reason = "最后同步日期超出所选窗口"; if (reason) { excluded.push({ observation, reason }); continue; } const previous = latest.get(observation.company); if (!previous || observation.observedAt! > previous.observedAt!) { if (previous) excluded.push({ observation: previous, reason: "同一产品的较早观测" }); latest.set(observation.company, observation); } else excluded.push({ observation, reason: "同一产品已选较新或同日观测" }); } const points: IncomePoint[] = [...latest.values()].map((observation) => ({ company: companies.get(observation.company)!, observation, value: observation.value * (options.annualize ? 12 : 1), })); return { points, excluded }; } export function groupIncome(points: IncomePoint[], splitModels = true) { const groups = new Map(); for (const point of points) { const category = point.company.cat; const provider = point.observation.provider!; const model = splitModels ? point.company.businessModel ?? "收费模式未核验" : "全部收费模式"; const key = JSON.stringify([category, provider, model]); if (!groups.has(key)) groups.set(key, { category, provider, model, points: [] }); groups.get(key)!.points.push(point); } 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] }]; }); }