Merge pull request '扩展 AI 公司研究并修复收入统计表布局' (#31) from codex/ai-research-cn-sme into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 9m0s
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 9m0s
Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
@@ -182,10 +182,10 @@ async function main() {
|
||||
assert.equal(researchResponse.status, 200, "authenticated research API");
|
||||
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
|
||||
const research = await researchResponse.json();
|
||||
assert.equal(research.companies.length, 1029);
|
||||
assert.equal(research.companies.length, 1051);
|
||||
assert.equal(research.categories.length, 11);
|
||||
assert.equal(research.evidence.length, 1412);
|
||||
assert.equal(research.observations.length, 192);
|
||||
assert.equal(research.evidence.length, 1858);
|
||||
assert.equal(research.observations.length, 237);
|
||||
assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405);
|
||||
const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers });
|
||||
assert.equal(researchPage.status, 200);
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function IncomeDistribution({ data, onSelect }: {
|
||||
</svg></div>
|
||||
<figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。业务类别标题下各行按支付平台{splitModels && "及收费与交付模式"}分别统计;n为该组产品数。点击点或下表产品名可看原始证据。</figcaption>
|
||||
</figure>
|
||||
<div className="research-table-wrap"><table><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
|
||||
<div className="research-table-wrap"><table className="income-statistics"><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
|
||||
{index === 0 && <th scope="rowgroup" rowSpan={rows.length}>{categories.get(category)}<small>{rows.length}个统计组</small></th>}
|
||||
<td>{group.provider}<small>{group.model}</small></td><td>{group.stats.n}</td><td>{usd(group.stats.mean)}</td><td>{usd(group.stats.median)}</td><td>{group.stats.meanMedianRatio?.toFixed(2) ?? "—"}</td><td>{group.stats.cv?.toFixed(2) ?? "—"}</td><td>{usd(group.stats.meanWithoutLargest)}<small>{percent(group.stats.largestRemovalDrop)}</small></td>
|
||||
<td>{group.points.map((point) => <span key={point.company.id}><button className="research-company-name" onClick={() => onSelect(point.company.id)}>{point.company.name}</button>{" "}</span>)}<small>{group.stats.n < 3 ? "样本少于3,不能据此判断领域典型收入。" : "自选的小样本,须结合产品年龄、成本与留存评估。"}</small></td>
|
||||
|
||||
@@ -1473,6 +1473,13 @@ svg { flex-shrink: 0; }
|
||||
|
||||
.research-distribution { margin: 24px 0 38px; }
|
||||
.research-distribution figure { margin: 20px 0; }
|
||||
.ai-research .income-statistics { min-width: 1200px; }
|
||||
/* Row-spanning category headings shift cell indexes between rows. */
|
||||
.ai-research .income-statistics td { min-width: 0; }
|
||||
.ai-research .income-statistics th[scope="rowgroup"] { min-width: 150px; white-space: nowrap; }
|
||||
.ai-research .income-statistics th[scope="rowgroup"] small { display: block; margin-top: 5px; }
|
||||
.ai-research .income-statistics td:not(:last-child) { white-space: nowrap; }
|
||||
.ai-research .income-statistics td:last-child { min-width: 220px; }
|
||||
.income-distribution-chart { display: block; width: 100%; min-width: 900px; height: auto; }
|
||||
.income-distribution-chart [role="button"] { cursor: pointer; outline: none; }
|
||||
.income-distribution-chart [role="button"]:focus circle:last-child { stroke: #b17725; stroke-width: 3; }
|
||||
|
||||
@@ -23,9 +23,9 @@ test("research revisions share the authentication database and preserve replay l
|
||||
for (let i = 0; i < 20; i++) consumeCode(config, "invalid");
|
||||
assert.equal(consumeCode(config, "invalid"), "limited");
|
||||
const initial = readResearchSnapshot();
|
||||
assert.equal(initial.companies.length, 1029);
|
||||
assert.equal(initial.evidence.length, 1412);
|
||||
assert.equal(initial.observations.length, 192);
|
||||
assert.equal(initial.companies.length, 1051);
|
||||
assert.equal(initial.evidence.length, 1858);
|
||||
assert.equal(initial.observations.length, 237);
|
||||
assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata");
|
||||
assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit");
|
||||
assert.deepEqual(readResearchSnapshot(), initial, "reopening does not duplicate records");
|
||||
@@ -64,9 +64,9 @@ test("research validation rejects broken evidence and unsafe source links", () =
|
||||
|
||||
test("reviews link explicit companies without turning global or excluded cases into companies", () => {
|
||||
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 74);
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 10);
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 37);
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 115);
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 12);
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 44);
|
||||
const review = snapshot.reviews.find((r) => r.id === "R4")!;
|
||||
assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]);
|
||||
assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []);
|
||||
@@ -85,20 +85,24 @@ test("reviews link explicit companies without turning global or excluded cases i
|
||||
test("disclosure counts exclude client income, biographies, targets and duplicate milestones", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
const summary = categorySummary(snapshot);
|
||||
assert.equal(summary.reduce((n, c) => n + c.count, 0), 1029);
|
||||
assert.equal(summary.reduce((n, c) => n + c.self, 0), 28);
|
||||
assert.equal(summary.reduce((n, c) => n + c.count, 0), 1051);
|
||||
assert.equal(summary.reduce((n, c) => n + c.self, 0), 43);
|
||||
assert.equal(summary.reduce((n, c) => n + c.estimate, 0), 2);
|
||||
assert.equal(summary.reduce((n, c) => n + c.platform, 0), 16);
|
||||
assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 983);
|
||||
assert.equal(summary.reduce((n, c) => n + c.platform, 0), 19);
|
||||
assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 987);
|
||||
assert.equal(snapshot.companies.find((c) => c.name === "Rork")?.revenueStatus, "unknown");
|
||||
assert.equal(snapshot.companies.find((c) => c.name === "Result")?.revenueStatus, "unknown");
|
||||
assert.ok(snapshot.observations.filter((o) => o.selection === "signal").every((o) => o.scope === "公司整体"));
|
||||
const arena = snapshot.observations.find((o) => o.id === "COARENA-O-CUMULATIVE")!;
|
||||
assert.equal(arena.selection, "historical", "cumulative launch revenue is not a recurring company-wide signal");
|
||||
assert.equal(arena.comparisonGroup, null);
|
||||
assert.ok(arena.metric !== "mrr" && arena.metric !== "arr", "a two-week cumulative figure cannot become MRR or ARR");
|
||||
});
|
||||
|
||||
test("technical reviews keep accessible materials separate from tested capabilities", () => {
|
||||
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||
assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 28);
|
||||
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "public-artifact").length, 7);
|
||||
assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 59);
|
||||
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "public-artifact").length, 9);
|
||||
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "independent-test").length, 1);
|
||||
assert.equal(snapshot.companies.find((c) => c.id === "AI051")?.revenueStatus, "unknown");
|
||||
assert.match(snapshot.companies.find((c) => c.id === "AI051")!.technicalReview!.testScope!, /Sep 2026/);
|
||||
@@ -137,6 +141,43 @@ test("ambiguous ARR claims retain currency, precision and attribution limits", (
|
||||
assert.equal(snapshot.companies.find((c) => c.id === "AI070")?.revenueStatus, "unknown");
|
||||
assert.equal(snapshot.observations.filter((o) => o.company === "AI070").length, 0,
|
||||
"unreadable announcement summaries do not become numeric ARR");
|
||||
const waitlist = snapshot.observations.find((o) => o.id === "SR25-O-OMI-WAITLIST")!;
|
||||
assert.equal(waitlist.selection, "excluded", "waitlist conversions are not paid recurring revenue");
|
||||
assert.equal(snapshot.companies.find((c) => c.id === waitlist.company)?.revenueStatus, "unknown");
|
||||
const bluma = snapshot.observations.find((o) => o.id === "BLUMA-O-HISTORICAL-MRR")!;
|
||||
assert.equal(bluma.selection, "historical");
|
||||
assert.ok(bluma.unit !== "USD" && !bluma.observedAt && bluma.comparisonGroup === null);
|
||||
const native = snapshot.observations.find((o) => o.id === "NATIVE-O-ARR-CLAIM")!;
|
||||
assert.equal(native.selection, "crosscheck");
|
||||
assert.ok(!native.metric && !native.observedAt && native.comparisonGroup === null);
|
||||
const marketVolume = snapshot.observations.find((o) => o.id === "PALISADE-O-GMV")!;
|
||||
assert.equal(marketVolume.selection, "excluded", "customer GMV must not establish company revenue or ARR");
|
||||
assert.equal(snapshot.companies.find((c) => c.id === marketVolume.company)?.revenueStatus, "unknown");
|
||||
const pingo = snapshot.observations.find((o) => o.id === "PINGO-O-MRR-JUL25")!;
|
||||
assert.equal(pingo.selection, "historical", "a July 2025 self-report is not a current payment-platform sample");
|
||||
assert.ok(pingo.unit !== "USD" && !pingo.observedAt && pingo.comparisonGroup === null);
|
||||
const designService = snapshot.observations.find((o) => o.id === "SEMIOTIC-O-LAUNCH-REVENUE")!;
|
||||
assert.equal(designService.selection, "historical");
|
||||
assert.ok(!designService.metric && designService.comparisonGroup === null, "four-week service revenue is not MRR or ARR");
|
||||
const restaurantAdoption = snapshot.observations.filter((o) => o.company === "YC32815");
|
||||
assert.ok(restaurantAdoption.length > 0 && restaurantAdoption.every((o) => o.selection === "adoption" && !o.metric),
|
||||
"restaurant count and list price must not produce an invented MRR observation");
|
||||
const fishPilot = snapshot.observations.find((o) => o.id === "OCTAPULSE-O-CONTRACT")!;
|
||||
assert.equal(fishPilot.selection, "excluded", "a paid pilot or annual contract does not establish recognized recurring revenue");
|
||||
assert.equal(snapshot.companies.find((c) => c.id === fishPilot.company)?.revenueStatus, "unknown");
|
||||
assert.equal(fishPilot.comparisonGroup, null);
|
||||
assert.notEqual(fishPilot.unit, "USD", "six-figure wording does not establish the currency");
|
||||
assert.ok(!fishPilot.metric && !fishPilot.observedAt, "the publication date is not the financial reporting date");
|
||||
const cedar = snapshot.observations.filter((o) => o.company === "AI102");
|
||||
assert.ok(cedar.every((o) => o.selection !== "signal" && o.comparisonGroup === null),
|
||||
"closed and contracted amounts without recognition or period do not establish ARR");
|
||||
assert.equal(snapshot.observations.find((o) => o.id === "SR25-O-LOOPS-CUSTOMER-SALES")?.selection, "excluded");
|
||||
const portfolio = snapshot.observations.filter((o) => o.company === "AI100");
|
||||
assert.deepEqual(portfolio.filter((o) => o.selection === "signal").map((o) => o.id), ["SR25-O-DRS-PORTFOLIO"],
|
||||
"a product milestone must not be added to its company portfolio as another company");
|
||||
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("SR25-O-")).every((o) =>
|
||||
o.unit !== "USD" && !o.metric && !o.observedAt && o.comparisonGroup === null),
|
||||
"access dates and dollar symbols must not invent dated USD income samples");
|
||||
});
|
||||
|
||||
// Retractions and forecasts must stay visible for review without becoming revenue signals.
|
||||
@@ -179,8 +220,18 @@ test("directory coverage reconciles every candidate and keeps profile-only incom
|
||||
assert.ok(company.profileOnly || company.directoryOrigin === "yc-2025-2026");
|
||||
assert.equal(company.url, row.source);
|
||||
if (company.profileOnly) assert.equal(company.revenueStatus, "unknown");
|
||||
assert.equal(company.year, row.foundedYear);
|
||||
assert.ok([2025, 2026].includes(company.year!));
|
||||
if (company.id === "YC31370") {
|
||||
assert.equal(row.foundedYear, 2025, "original discovery remains unchanged");
|
||||
assert.equal(company.year, null, "a pivot year cannot settle the company founding year");
|
||||
assert.ok(snapshot.reviews.some((review) => review.id === "DAYJOB-R-COHORT" && review.companyIds?.includes(company.id)));
|
||||
} else if (company.id === "YC36279") {
|
||||
assert.equal(row.foundedYear, 2026, "original Native discovery remains unchanged");
|
||||
assert.equal(company.year, null, "a model launch and YC date cannot settle the older business founding year");
|
||||
assert.ok(snapshot.reviews.some((review) => review.id === "NATIVE-R-COHORT" && review.companyIds?.includes(company.id)));
|
||||
} else {
|
||||
assert.equal(company.year, row.foundedYear);
|
||||
assert.ok([2025, 2026].includes(company.year!));
|
||||
}
|
||||
}
|
||||
const invalid = structuredClone(snapshot);
|
||||
invalid.coverage!.newProfiles += 1;
|
||||
@@ -194,6 +245,20 @@ test("directory coverage reconciles every candidate and keeps profile-only incom
|
||||
assert.throws(() => validateResearch(lostOrigin), /coverage accounting/);
|
||||
});
|
||||
|
||||
test("business pivots and procurement volume do not become comparable startup ARR", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
const dayjob = snapshot.companies.find((company) => company.id === "YC31370")!;
|
||||
assert.equal(dayjob.year, null);
|
||||
assert.equal(dayjob.directoryOrigin, "yc-2025-2026");
|
||||
const milestones = snapshot.observations.filter((observation) => observation.company === dayjob.id);
|
||||
assert.deepEqual(milestones.filter((observation) => observation.selection === "crosscheck").map((observation) => observation.value).sort(), [496000, 653000]);
|
||||
assert.ok(milestones.every((observation) => observation.comparisonGroup === null && !observation.metric && !observation.observedAt));
|
||||
assert.ok(milestones.filter((observation) => [150000, 800000].includes(observation.value)).every((observation) => observation.selection === "excluded"));
|
||||
const procurement = snapshot.observations.find((observation) => observation.id === "SAUDARA-O-SOURCING")!;
|
||||
assert.equal(procurement.selection, "excluded");
|
||||
assert.equal(procurement.comparisonGroup, null);
|
||||
});
|
||||
|
||||
test("income descriptions report sample dispersion and maximum-value sensitivity", () => {
|
||||
const stats = describeIncome([100, 200, 300])!;
|
||||
assert.equal(stats.mean, 200);
|
||||
@@ -215,14 +280,21 @@ test("income descriptions report sample dispersion and maximum-value sensitivity
|
||||
test("platform samples exclude stale data and keep ARR, estimates and collections separate", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
const recent = platformIncomeSample(snapshot, { windowDays: 7 });
|
||||
assert.equal(recent.points.length, 14);
|
||||
assert.equal(recent.excluded.length, 3);
|
||||
assert.equal(recent.points.length, 16);
|
||||
assert.equal(recent.excluded.length, 4);
|
||||
assert.ok(recent.points.every((p) => p.observation.metric === "mrr" && p.observation.selection === "platform"));
|
||||
assert.deepEqual(new Set(recent.excluded.map((p) => p.observation.company)), new Set(["AI016", "MR013", "MR014"]));
|
||||
assert.deepEqual(new Set(recent.excluded.map((p) => p.observation.company)), new Set(["AI016", "MR013", "MR014", "MR017"]));
|
||||
assert.ok(recent.points.some((p) => p.company.name === "TrackAI" && p.observation.connectionExpired), "dated recent value is not a claim of active connection");
|
||||
const older = platformIncomeSample(snapshot, { windowDays: 90 });
|
||||
assert.equal(older.points.length, 15);
|
||||
assert.equal(older.points.length, 18);
|
||||
assert.ok(older.points.some((p) => p.company.id === "AI016"));
|
||||
assert.ok(!recent.points.some((p) => p.company.id === "MR017"));
|
||||
assert.ok(older.points.some((p) => p.company.id === "MR017" && p.observation.connectionExpired),
|
||||
"an expired connection retains its historical observation date in the wider window");
|
||||
assert.equal(recent.points.find((p) => p.company.id === "MR018")?.value, 67864,
|
||||
"an older Markdown cache must not replace or duplicate the later web snapshot");
|
||||
assert.equal(recent.points.find((p) => p.company.id === "MR019")?.value, 65431,
|
||||
"period-table revenue and revenue snapshots must not replace MRR");
|
||||
const scaled = platformIncomeSample(snapshot, { windowDays: 7, annualize: true });
|
||||
const monthlyGroups = groupIncome(recent.points);
|
||||
const annualGroups = groupIncome(scaled.points);
|
||||
@@ -307,7 +379,7 @@ test("unreconciled directory financials remain source reviews instead of income
|
||||
assert.ok(review.evidenceIds.includes("PH-TANA-DR"));
|
||||
assert.equal(reviewCompanyIds(snapshot, review).length, 0);
|
||||
assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR"));
|
||||
assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 10);
|
||||
assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 18);
|
||||
});
|
||||
|
||||
test("product pivots and founder histories do not become new company years or current revenue", () => {
|
||||
@@ -320,6 +392,15 @@ test("product pivots and founder histories do not become new company years or cu
|
||||
assert.equal(candidate.companyId, pivot.id);
|
||||
assert.ok(!platformIncomeSample(snapshot, { windowDays: null }).points.some((point) => point.company.id === pivot.id));
|
||||
assert.ok(!snapshot.observations.some((o) => ["PH-FUNDRAISLY-ABOUT", "LF-PIVOT", "LF-HISTORY"].includes(o.evidence)), "other ventures' ARR, users and funding must not transfer");
|
||||
const candidates = snapshot.candidateBatches!.flatMap((b) => b.records);
|
||||
assert.equal(candidates.find((r) => r.id === "PH26-12")?.status, "outside-cohort",
|
||||
"a brand iteration with an earlier business chain is not a new company");
|
||||
const happycapy = candidates.find((r) => r.id === "PH26-04")!;
|
||||
assert.ok(happycapy.evidenceIds?.includes("FOLLOWUP-CAPY-ACRA"));
|
||||
assert.equal(happycapy.status, "pending-year", "a seller's registration does not establish a new global business");
|
||||
assert.equal(happycapy.companyId, undefined);
|
||||
assert.ok(!snapshot.observations.some((o) => o.evidence.startsWith("FOLLOWUP-")),
|
||||
"registration, funding, app-store prices and client amounts are not company income");
|
||||
});
|
||||
|
||||
test("follow-up evidence can admit an uncertain directory candidate without rewriting its original status", () => {
|
||||
|
||||
Reference in New Issue
Block a user