Compare commits

...

44 Commits

Author SHA1 Message Date
codex-bot ec95f3be31 明确收入图表的业务类别与统计分组层级 2026-10-30 15:51:57 +08:00
Zayden-Jung 25c5e83d08 Merge pull request '新增 Smalltalk-80 现代化研究导读与源码研究入口' (#13) from codex/smalltalk-guide into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m56s
Reviewed-on: #13
2026-10-30 06:19:43 +00:00
codex-bot 798c6d17d0 docs: add modern Smalltalk-80 study guide 2026-10-30 14:17:39 +08:00
Zayden-Jung c2ea0900e6 Merge pull request '补充AI公司收入、合同与业务规模口径核查' (#12) from codex/ai-research-revenue-scope into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m30s
Reviewed-on: #12
2026-10-30 06:04:36 +00:00
codex-bot 1cc2c9f97f 补充6家AI公司合同与交易支出口径核查 2026-10-30 13:53:00 +08:00
Zayden-Jung 62bba8d320 Merge pull request '新增AI公司收入分布、证据核查与研究报告' (#11) from codex/ai-research-income into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m48s
Reviewed-on: #11
2026-10-30 05:48:56 +00:00
codex-bot 28fa155829 深化8家AI公司收入声明及合同并购口径核查 2026-10-30 06:58:06 +08:00
codex-bot db47c1da13 补全20家AI公司主营买家与交付边界 2026-10-30 06:51:40 +08:00
codex-bot 446ad80089 深化20条目录资料并保留发现来源审计 2026-10-30 06:47:02 +08:00
codex-bot b52d1e59a4 增加研究覆盖审计及分类数据直达链接 2026-10-30 06:24:56 +08:00
codex-bot c7aa6e54c6 补充Bud收购案例与Rocket转型业务边界 2026-10-30 06:08:28 +08:00
codex-bot f93e094ba2 明确核查范围并补齐公司明细关联 2026-10-30 06:02:20 +08:00
codex-bot 5ee28cef98 补充Gradium第三方语音评测及测试范围 2026-10-30 05:58:54 +08:00
codex-bot 8da38694bc 增加收入分布快照报告与公司明细直达链接 2026-10-30 05:54:51 +08:00
codex-bot 850ed8f9a7 补充Speedrun收入声明并关联公司核查记录 2026-10-30 05:48:49 +08:00
codex-bot 43bd5a86bf 补足ARR历史月份并更新企业规模与业务边界 2026-10-30 05:44:25 +08:00
codex-bot cfb9273f88 增加技术验证证据筛选并区分材料与独立测试 2026-10-30 05:41:35 +08:00
codex-bot 3e0cbf9831 补充中文AI样本及成立年和近名实体核查 2026-10-30 05:38:35 +08:00
codex-bot aa2d3fe244 补充Runable经营案例与商业模式初评 2026-10-30 05:35:30 +08:00
codex-bot 2e4dd8c6c7 补齐59条主营分类并记录3条资料缺口 2026-10-30 05:32:05 +08:00
codex-bot 1c4e2fc9b6 按官方长简介复核62条AI公司业务分类 2026-10-30 05:30:01 +08:00
codex-bot 69fa0b240f 新增支付平台收入样本与分布分析 2026-10-30 05:25:05 +08:00
Zayden-Jung 6d26a5b0c1 Merge pull request 'ci: 仅 main 分支执行 CI/CD' (#8) from codex/main-only-cicd into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m20s
Reviewed-on: #8
2026-10-29 20:34:12 +00:00
Zayden-Jung bb9ebaa052 Merge pull request '扩展 AI 公司目录覆盖与来源核查台账' (#10) from codex/ai-research-coverage into main
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled
Reviewed-on: #10
2026-10-29 20:34:04 +00:00
codex-bot d50281a1e9 扩展 AI 公司目录覆盖并保留逐条核查台账
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 11m10s
2026-10-30 04:31:33 +08:00
codex-bot 3e2183798d 补充 AI 公司收入证据及声明纠错案例
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 13m33s
2026-10-30 03:54:22 +08:00
codex-bot 562618cd68 ci: restrict workflows to main branch 2026-10-30 03:43:53 +08:00
Zayden-Jung f550fc8955 Merge pull request '修复 AI 公司研究导入引用与平板布局' (#7) from codex/ai-research-validation into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m51s
Reviewed-on: #7
2026-10-29 19:37:04 +00:00
codex-bot 157144b3f9 Correct research review references and advance the data revision
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 6m46s
2026-10-30 03:35:17 +08:00
codex-bot 3cfce44a25 Align research workbench breakpoints with document navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Waiting to run
2026-10-30 03:34:05 +08:00
Zayden-Jung e91255856c Merge pull request '新增 AI 公司研究工作台并共用文档站数据库' (#6) from codex/ai-company-research into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 2m26s
Reviewed-on: #6
2026-10-29 19:31:59 +00:00
codex-bot 1250b674b0 Add AI company research workbench backed by the shared site database
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m57s
2026-10-30 03:25:38 +08:00
Zayden-Jung 074881b650 Merge pull request '补充 Agent 编程边界与 LangGraph / DSPy 研究草案' (#5) from codex/agent-composition into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m7s
Reviewed-on: #5
2026-10-28 14:28:52 +00:00
codex-bot 1966d7a0a7 Document agent composition boundaries and judgment object proposal
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 7m58s
2026-10-28 22:26:12 +08:00
Zayden-Jung 319de8a3a7 Merge pull request '按 Supabase 参考修正为横向分类下拉导航' (#4) from codex/category-menus into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m33s
Reviewed-on: #4
2026-10-28 11:55:47 +00:00
codex-bot 787491622b Replace collection select with horizontal category dropdown navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 11m20s
2026-10-28 19:38:07 +08:00
Zayden-Jung e448acb904 Merge pull request '按分类与合集组织文档 URL,收紧导航与阅读排版' (#2) from codex/docs-navigation into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m33s
Reviewed-on: #2
2026-10-28 11:16:16 +00:00
codex-bot e286139572 Redirect legacy document URLs after authentication without prerendering aliases
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 5m25s
2026-10-28 19:15:31 +08:00
codex-bot c49c8348d2 Merge published comic changes and adapt navigation verification
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m28s
2026-10-28 19:02:26 +08:00
Zayden-Jung 7a9c88a05a Merge pull request '为 Endor 项目讨论增加双图漫画与共享图片压缩策略' (#3) from codex/endor-review-comic into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m34s
Reviewed-on: #3
2026-10-28 10:57:58 +00:00
codex-bot 422d6622a2 Organize document URLs by category and collection with compact navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 18:38:13 +08:00
codex-bot bd932c36b2 Add a compressed illustration carousel with slide indicators
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m44s
2026-10-28 18:16:17 +08:00
codex-bot c08371647d Document reusable review capabilities and publish compressed comic 2026-10-28 18:02:37 +08:00
Zayden-Jung 8be53c1e25 Merge pull request '完善文档独立导航,精简边框并增加渐变与交互反馈' (#1) from codex/docs-ui into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 10m25s
Reviewed-on: #1
2026-10-28 07:35:50 +00:00
62 changed files with 60830 additions and 72 deletions
+1
View File
@@ -6,4 +6,5 @@
**/*.pyc
**/*.tsbuildinfo
.artifacts
apps/docs/public/figures/**/*.webp
.env*
+1 -1
View File
@@ -2,7 +2,6 @@ name: Verify and deploy Tabularium
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
@@ -11,6 +10,7 @@ concurrency:
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main'
runs-on: ces-control
timeout-minutes: 45
env:
+3
View File
@@ -11,3 +11,6 @@ __pycache__/
!.env.example
.artifacts/
# Browser raster derivatives are generated on the confirmed remote runner.
/apps/docs/public/figures/**/*.webp
+72
View File
@@ -80,9 +80,81 @@ AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=fal
`pnpm test:auth`运行算法、会话、重放及限流测试。容器验证使用自己的匿名测试卷完成登录/并发重放/CSRF/限流测试,并在重启后检查状态仍存在。生产部署只运行无副作用的 `verify`,不会消耗用户动态码或触发生产限流。
## AI 公司研究
导航入口为 **其他研究 → AI 公司研究**,页面路径是
`/docs/other-research/ai-company-research/overview`。工作台提供业务分类、公司搜索与筛选、
公司明细、收入观测、原始证据和数据核查记录。所有页面与
`GET /api/research/ai-companies` 均使用文档站现有的登录认证。
研究数据与登录状态**共用文档站现有的 SQLite 数据库文件**:
`AUTH_CONFIG_FILE` 同目录下的 `login-state.sqlite`。该文件名是历史名称,未创建第二个数据库。
`login_state` 保留登录限流与重放状态,新增的 `research_*` 表保存数据版本、分类、
公司、经营指标、证据与核查记录。沿用现有 `/auth` 持久挂载,数据库不会写入镜像或公开目录。
`content/research/ai-companies.seed.json` 是可评审的研究导入快照,
服务端首次读取该版本时将它以事务导入同一数据库,之后从数据库查询记录。
导入使用外键校验,不删除登录数据,也不覆盖旧研究版本。每次更新快照必须修改 `version`;
同一版本内容变化会拒绝导入,避免静默读取旧数据。回退代码时继续读取对应旧版本。
所有修改都经过工作分支和 PR,部署仍等待人工合并主线。
当前资料包含 1,015 条公司/产品记录、10 类业务及待分类组、1,091 条证据记录和 183 条经营与技术观测。
其中保留 86 家个案资料、913 条目录基础资料,并新增 16 个平台产品/业务样本;目录项不等于完成财务和技术核验。
补充ThinkWheel与中国NaiveAI;MiroMind成立年未核,公开研发年份单列。保留灵初智能成立年排除、深度原理日期待核和Naive近名实体复核记录。
新增7条Speedrun个案,保留标题/正文冲突、七位数数量级、币种不明、合同管线与履历归属排除记录;相对日期不充当同期收入时点。核查页优先按人工确认的公司ID关联明细,旧版本保留按证据归属回退;54条核查中43条指向具体公司、3条属于全局方法、8条是未纳入候选。明细反向列出关联核查;币种不明的声明保留原值,不绘制美元收入长度条。
Bud保留为2025成立且YC标记已收购的案例;Rocket.new按前身2021成立、2025产品上线的转型对照记录,历史ARR不混入新成立公司同期统计。
验收按2026-10-30确认的公开来源清单边界逐条核对并标明缺项;YC候选池有完整读取台账,其他来源目前为定向补充,不能把来源出现次数当作覆盖完成。收入披露不足且统计时点不统一,不代表全部 AI 公司。
界面将公司自述、第三方估算、融资、客户收入和履历收入分开,未知收入不记为零。
保留被撤回的声明作为核查记录,并排除收入比较;并购后业务、管理层预测与历史里程碑单列。
团队规模下界在列表中显示比较符,商业成熟度展示公开交付证据,不等于技术能力排名。
Emergent的1亿美元ARR里程碑补足至2026年2月;Peec采用规模和Wonderful团队规模各保留其公告日期,不将较新非收入指标当作ARR更新。Wonderful按2026年9月官方综合平台定位归C05。
公司披露的 ARR 尚未建立同期同口径比较组。新增平台 MRR 单独计算探索性样本统计,
不能称行业平均 ARR、总体离散排名或财务造假概率。
收入分布:`IncomeDistribution.tsx` 从数据库返回的观测中筛选平台 USD MRR,
每个产品只保留符合条件的最新值,默认数据日期距截止日不超过7天。
按业务类别、支付来源及可选收费模式分组,显示均值、中位数、样本CV(标准差分母n−1)、
均值/中位数和移除最大值后的均值敏感性。n<3标注样本不足;n=1不计算CV。
MRR×12仅作为显示单位转换,不改写为公司ARR;公司ARR、估算与近30日收入均不混入。
`ai-income-analysis.json` 是独立Python复算的7日/90日快照参考,用于检查统计选择和计算,
页面本身从同一SQLite中的公司与观测记录计算,不读取第二个数据库。
平台同步时间、连接过期状态、业务描述的可编辑性和产品/法律主体未去重限制均保留。
覆盖审计页位于 `/docs/other-research/ai-company-research/coverage`,分类链接 `overview?category=<id>` 的筛选数据。
`python3 scripts/audit-ai-research.py --check` 检查候选池逐条计数与覆盖报告一致性;CI在ces-control运行此检查,未更改触发范围。
收入快照报告位于 `/docs/other-research/ai-company-research/income-snapshot`,样本名称链接 `overview?company=<id>` 的公司明细。
`python3 scripts/summarize-ai-income.py --check` 独立复核统计与报告一致性;不触发编译或构建。数据变更后先审查方法,再运行不带参数的命令更新固定快照。
默认7日样本14个产品;90日窗口新增Gojiberry的8月历史点,营销样本的均值和CV随之明显变化。
这提示先核查时间与样本构成,不能将高离散度直接解释为造假。两个近名营养应用按不同App Store ID分别建档。
覆盖核查:从 yc-oss 非官方镜像发现 2025—2026 八个 YC 批次的 1,428 条候选,
逐条读取对应 YC 官方公开页面,以页面中的成立年份、团队字段和业务描述筛选。
962 条初筛匹配,其中 34 条与原表 URL 重合,实际新增 928 条;268 条年份不符或缺失、
192 条 AI 业务待确认、6 条成立年份晚于批次年份未纳入本批新增。
`content/research/ai-companies.coverage.json` 保留逐条筛选结果、来源与响应摘要,
数据快照中的 `coverage` 也随版本进入同一 SQLite 数据库。只覆盖该候选池,不能称全球全量。
新增业务分类优先匹配主营简介,再参考目录标签;未匹配者进入待分类组。
初分规则位于 `scripts/classify-ai-profiles.py`,只生成待评审标签,不执行抓取或构建。
目录基础项保留 `classificationBasis` 和 `directoryTags` 供复核。
当前目录基础项中160条已人工复核主营与买家,Contrario及14条收入声明/排除案例已深化为个案。`directoryOrigin`保留原发现来源,`profileOnly`只表示当前资料深度;深化不改变原候选池新增928条的审计分母。
分类按主要工作任务和买家判断,例如Paragon归客户与收入运营,Oki归消费者应用,Lucidic归Agent设施。市场支出、采用规模、性能主张及客户收益均不作为公司收入。
其中121条原待分类记录已按YC官方业务介绍人工复核主营业务,分类理由保存在公司字段与对应证据摘要中;
仍有3条因简介不足保留待分类。人工复核分类不代表独立验证技术水平、客户效果或财务数据。
AI 关键词可能出现在业务背景中,收入、买家和交付状态仍须逐家深查,不能把目录项的未知收入当作未披露。
技术专项评审用独立字段记录层级、日期、摘要与证据:未评审、已读技术自述、公开代码/模型材料可访问、有第三方独立评测记录。
当前7条完成该字段的专项记录,其中3条确认材料可访问、1条记录第三方已发表评测(Gradium TTS Sep 2026);本轮未自行运行模型测试。缺字段不表示无技术能力,代码或模型卡可读不证明性能。
界面支持按资料深度、记录团队规模和技术验证证据筛选;证据列表每页 30 条,避免一次渲染全部来源。
`pnpm test:research` 在远程 Runner 上验证同库持久化、重复读取、版本导入与回退、
登录限流保留、引用完整性及收入排除规则。镜像冒烟检查会验证研究页面与 API 的认证、
返回记录数量、只读方法和容器重启后的读取结果。页面筛选交互仍需实际浏览器验证。
## CI/CD 与缓存
`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。
当前工作流仅在 main 推送或针对 main 的手动运行时执行。工作分支PR完成本地静态核查后,
完整类型、测试、构建与容器检查在用户手动合并后的主线CI运行;报告须区分未运行与已通过。
1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建脚本。
2. 命中 `verified-<scope>-<摘要>` 镜像时跳过依赖安装和 Next.js build,只做认证、实际路由与资源冒烟检查。
+7 -3
View File
@@ -3,12 +3,15 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "NEXT_TELEMETRY_DISABLED=1 next dev --hostname 127.0.0.1",
"build": "NEXT_TELEMETRY_DISABLED=1 next build && node scripts/build-auth.mjs",
"dev": "node scripts/build-doc-images.mjs && NEXT_TELEMETRY_DISABLED=1 next dev --hostname 127.0.0.1",
"build": "node scripts/build-doc-images.mjs && NEXT_TELEMETRY_DISABLED=1 next build && node scripts/build-auth.mjs",
"images": "node scripts/build-doc-images.mjs",
"test:images": "node scripts/build-doc-images.mjs --check",
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
"lint": "eslint . --max-warnings=0",
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
"test:auth": "node scripts/test-auth.mjs"
"test:auth": "node scripts/test-auth.mjs",
"test:research": "node scripts/test-research.mjs"
},
"dependencies": {
"next": "16.4.3",
@@ -29,6 +32,7 @@
"eslint": "^9.39.5",
"eslint-config-next": "16.4.3",
"postcss": "8.5.31",
"sharp": "0.35.5",
"tailwindcss": "4.3.3",
"typescript": "~5.9.3"
}
+70 -4
View File
@@ -141,6 +141,8 @@ async function main() {
"/",
"/docs/00-endor-concepts-draft",
"/figures/endor-architecture.svg",
"/figures/endor-review-gate.webp",
"/figures/endor-review-desk.webp",
"/docs/pretext/01-chapter",
"/materials/pretext/verify.cjs",
"/materials/pretext/results.chrome156.json",
@@ -158,7 +160,7 @@ async function main() {
path,
);
}
for (const path of ["/api/private", "/api/unknown", "/api/search"])
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/research/ai-companies"])
assert.equal((await request(path)).status, 401, path);
assert.equal(
(
@@ -179,18 +181,82 @@ async function main() {
const headers = {
Authorization: "Bearer " + signSession(config, undefined, 600),
};
const researchResponse = await request("/api/research/ai-companies", { headers });
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, 1015);
assert.equal(research.categories.length, 11);
assert.equal(research.evidence.length, 1091);
assert.equal(research.observations.length, 183);
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);
const researchHtml = await researchPage.text();
assert.ok(researchHtml.includes('data-category="其他研究"'));
assert.ok(researchHtml.includes("AI 公司研究"));
assert.ok(researchHtml.includes("正在读取研究数据库"));
const searchResponse = await request("/api/search", { headers });
assert.equal(searchResponse.status, 200);
const searchIndex = await searchResponse.json();
assert.ok(searchIndex.some((doc: { slug: string; searchText: string }) => doc.slug === "pretext/04-chapter" && doc.searchText.includes("getSegmentMetrics")));
assert.ok(searchIndex.some((doc: { slug: string; searchText: string }) => doc.slug === "source-studies/pretext/measurement-cache" && doc.searchText.includes("getSegmentMetrics")));
for (const doc of searchIndex as { slug: string; sourceSlug: string }[]) {
assert.ok(!doc.slug.split("/").some((part) => /^\d+-/.test(part)), doc.slug);
const legacy = await request(`/docs/${doc.sourceSlug}?from=legacy`, { headers });
assert.equal(legacy.status, 308, doc.sourceSlug);
const destination = new URL(legacy.headers.get("location")!, base);
assert.equal(destination.pathname, `/docs/${doc.slug}`);
assert.equal(destination.searchParams.get("from"), "legacy");
assert.ok(legacy.headers.get("cache-control")?.includes("no-store"));
}
let home = "";
let purpose = "";
for (const route of routes) {
const response = await request(route, { headers });
assert.equal(response.status, 200, route);
if (route === "/") home = await response.text();
const html = await response.text();
if (route === "/") home = html;
if (route === "/docs/endor/endor-purpose-draft") purpose = html;
if (route.startsWith("/docs/")) {
assert.ok(html.includes('aria-label="文档分类"'), route);
assert.ok(html.includes('data-category="语言与运行时"'), route);
assert.ok(html.includes('class="category-panel" hidden=""'), route);
assert.ok(!html.includes('class="collection-picker"'), route);
assert.ok(!html.includes("<select"), route);
assert.ok(!html.includes('class="nav-back"'), route);
const ids = [...html.matchAll(/<h[23] id="([^"]+)"/g)].map((match) => match[1]);
assert.equal(new Set(ids).size, ids.length, `Unique heading anchors: ${route}`);
for (const id of ids) assert.ok(html.includes(`href="#${id}"`), `TOC anchor: ${route}#${id}`);
}
}
assert.ok(home.includes("资料库"));
assert.ok(home.includes("/docs/pretext/00-overview"));
assert.ok(home.includes("/docs/source-studies/pretext/overview"));
// Inline preview and zoom dialog must both use the compressed browser asset.
assert.equal(
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-gate\.webp"/g)].length,
2,
"The comic preview and lightbox must use WebP",
);
assert.ok(purpose.includes('aria-label="上一张图片"'));
assert.ok(purpose.includes('aria-label="下一张图片"'));
assert.ok(purpose.includes('aria-label="显示第 1 张图片" aria-current="true"'));
assert.ok(purpose.includes('aria-label="显示第 2 张图片"'));
assert.equal(
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-desk\.webp"/g)].length,
0,
"The second slide should not download until selected",
);
for (const name of ["endor-review-gate", "endor-review-desk"]) {
assert.ok(!purpose.includes(`/figures/${name}.png`));
const comic = await request(`/figures/${name}.webp`, { headers });
assert.equal(comic.status, 200);
assert.match(comic.headers.get("content-type") || "", /image\/webp/);
assert.deepEqual(
Buffer.from(await comic.arrayBuffer()),
readFileSync(join(__dirname, `public/figures/${name}.webp`)),
);
assert.equal((await request(`/figures/${name}.png`, { headers })).status, 404);
}
const assets = new Set(
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
m[1].replaceAll("&amp;", "&"),
+62
View File
@@ -0,0 +1,62 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const root = fileURLToPath(new URL("../../../", import.meta.url));
const sources = path.join(root, "tools/figures/assets");
const output = path.join(root, "apps/docs/public/figures");
const check = process.argv.includes("--check");
const policy = JSON.parse(await readFile(path.join(root, "content/image-policy.json"), "utf8"));
async function files(directory) {
const entries = await readdir(directory, { withFileTypes: true });
return (await Promise.all(entries.map((entry) => {
const name = path.join(directory, entry.name);
return entry.isDirectory() ? files(name) : [name];
}))).flat().sort();
}
// Originals stay outside public; both inline and lightbox images use the WebP.
for (const name of await files(path.join(root, "apps/docs/public"))) {
if (/\.(?:png|jpe?g|tiff?|bmp)$/i.test(name)) {
throw new Error(`Original raster exposed in public: ${path.relative(root, name)}. Move it to tools/figures/assets and run images.`);
}
}
for (const name of await files(path.join(root, "content/docs"))) {
if (!name.endsWith(".md")) continue;
const content = await readFile(name, "utf8");
const images = [
...content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
...content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
];
for (const [, source] of images) {
if (/\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)) {
throw new Error(`${path.relative(root, name)} embeds an original raster: ${source}. Use the compressed WebP.`);
}
}
}
await mkdir(output, { recursive: true });
for (const source of await files(sources)) {
if (!/\.(?:png|jpe?g|tiff?|bmp)$/i.test(source)) continue;
const relative = path.relative(sources, source).replace(/\.[^.]+$/, ".webp");
const destination = path.join(output, relative);
const original = await readFile(source);
const encoded = await sharp(original)
.rotate()
.resize({ width: policy.maxWidth, withoutEnlargement: true })
.webp({ quality: policy.quality, effort: 6 })
.toBuffer();
if (encoded.length > policy.maxBytes) {
throw new Error(`${relative}: ${encoded.length} bytes exceeds the ${policy.maxBytes}-byte image budget.`);
}
if (check) {
const generated = await readFile(destination);
if (!encoded.equals(generated)) throw new Error(`${relative} is stale. Regenerate browser images on the confirmed remote runner.`);
} else {
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, encoded);
}
console.log(`${check ? "Verified" : "Generated"} ${relative}: ${original.length} → ${encoded.length} bytes (${(100 * (1 - encoded.length / original.length)).toFixed(1)}% smaller)`);
}
+14
View File
@@ -0,0 +1,14 @@
import { build } from "esbuild";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const dir = await mkdtemp(join(tmpdir(), "tabularium-research-tests-"));
try {
const file = join(dir, "research.test.cjs");
await build({ entryPoints: ["tests/research.test.ts"], bundle: true,
platform: "node", target: "node22", format: "cjs", outfile: file });
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
process.exitCode = result.status ?? 1;
} finally { await rm(dir, { recursive: true, force: true }); }
@@ -0,0 +1,181 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/router";
import IncomeDistribution from "./IncomeDistribution";
import {
categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot,
} from "@/lib/ai-research";
const pageSize = 12;
const evidencePageSize = 30;
const revenueLabels = { self: "公司披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
const useLabels: Record<string, string> = {
platform: "支付平台记录", signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑",
"historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模",
funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料",
};
const money = (value: number, unit = "USD") => unit === "USD"
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value)
: `${new Intl.NumberFormat("zh-CN").format(value)} ${unit}`;
function External({ url, children }: { url: string; children: React.ReactNode }) {
const safe = evidenceUrl(url);
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{children} ↗</a> : <span>{children}</span>;
}
function Evidence({ item, prefix = "evidence" }: { item: ResearchEvidence; prefix?: string }) {
return <li id={`${prefix}-${item.id}`} className="research-evidence">
<div><code>{item.id}</code><span className="research-tag">{item.kind}</span></div>
<strong><External url={item.url}>{item.title}</External></strong>
<p>{item.summary}</p><p className="research-muted">{item.limitation}</p>
<small>发布:{item.publishedAt ?? "未注明"} 读取:{item.accessedAt}</small>
</li>;
}
export default function AICompanyResearch() {
const router = useRouter();
const [data, setData] = useState<ResearchSnapshot | null>(null);
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
const [query, setQuery] = useState("");
const [categoryChoice, setCategoryChoice] = useState<{ route: string; id: string } | null>(null);
const linkedCategory = router.query.category;
const category = categoryChoice && categoryChoice.route === router.asPath ? categoryChoice.id
: router.isReady && typeof linkedCategory === "string" && data?.categories.some((c) => c.id === linkedCategory)
? linkedCategory : "";
function setCategory(id: string) { setCategoryChoice({ route: router.asPath, id }); }
const [cohort, setCohort] = useState("");
const [income, setIncome] = useState("");
const [page, setPage] = useState(0);
const [sourcePage, setSourcePage] = useState(0);
const [depth, setDepth] = useState("");
const [teamBand, setTeamBand] = useState("");
const [technicalLevel, setTechnicalLevel] = useState("");
const [selection, setSelection] = useState<{ route: string; id: string | null } | null>(null);
const linkedCompany = router.query.company;
const selected = selection && selection.route === router.asPath ? selection.id
: router.isReady && typeof linkedCompany === "string" && data?.companies.some((c) => c.id === linkedCompany)
? linkedCompany : null;
function setSelected(id: string | null) { setSelection({ route: router.asPath, id }); }
const [tab, setTab] = useState<"companies" | "revenue" | "sources" | "reviews">("companies");
const companySection = useRef<HTMLElement>(null);
const detailSection = useRef<HTMLElement>(null);
useEffect(() => {
const controller = new AbortController();
fetch("/api/research/ai-companies", { signal: controller.signal, cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("Research unavailable");
const snapshot: ResearchSnapshot = await response.json();
setData(snapshot);
}).catch(() => { if (!controller.signal.aborted) setError(true); });
return () => controller.abort();
}, [attempt]);
useEffect(() => { if (selected) detailSection.current?.focus(); }, [selected]);
const summaries = useMemo(() => data ? categorySummary(data) : [], [data]);
const filtered = useMemo(() => data?.companies.filter((c) => {
const search = `${c.name} ${c.business} ${c.location ?? ""} ${c.buyer}`.toLocaleLowerCase();
const yearMatch = !cohort || (cohort === "other" ? ![2025, 2026].includes(c.year ?? 0) : c.year === Number(cohort));
const depthMatch = !depth || (depth === "profile" ? c.profileOnly : !c.profileOnly);
const teamMatch = !teamBand || (teamBand === "unknown" ? c.team === null || (!!c.teamBound && c.team < 51)
: c.team !== null && (teamBand === "xl" ? c.team >= 51 : !c.teamBound && (teamBand === "small" ? c.team <= 5 : teamBand === "medium" ? c.team >= 6 && c.team <= 20 : c.team >= 21 && c.team <= 50)));
return (!technicalLevel || (c.technicalReview?.level ?? "unreviewed") === technicalLevel) && depthMatch && teamMatch && (!category || c.cat === category) && yearMatch && (!income || c.revenueStatus === income)
&& search.includes(query.trim().toLocaleLowerCase());
}) ?? [], [data, query, category, cohort, income, depth, teamBand, technicalLevel]);
if (error) return <div className="research-message" role="alert">研究数据库暂时不可用。<button onClick={() => { setError(false); setAttempt((n) => n + 1); }}>重新读取</button></div>;
if (!data) return <p className="research-message" role="status">正在读取研究数据库…</p>;
const categories = new Map(data.categories.map((c) => [c.id, c]));
const selectedCompany = data.companies.find((c) => c.id === selected);
const observed = data.observations.filter((o) => ["signal", "estimate"].includes(o.selection));
const disclosedCompanies = new Set(observed.map((o) => o.company)).size;
const revenueCompanies = data.companies.filter((c) => c.revenueStatus !== "unknown").length;
const selectedObservations = data.observations.filter((o) => o.company === selected);
const selectedEvidenceIds = new Set([
...(selectedCompany?.evidenceIds ?? []), ...(selectedCompany?.pricingEvidence ? [selectedCompany.pricingEvidence] : []),
...(selectedCompany?.technicalReview?.evidenceIds ?? []),
...selectedObservations.map((o) => o.evidence),
]);
const selectedEvidence = data.evidence.filter((e) => e.companyId === selected || selectedEvidenceIds.has(e.id));
const reviewCompanies = (review: ResearchReview) => {
const ids = new Set(reviewCompanyIds(data, review));
return data.companies.filter((c) => ids.has(c.id));
};
const selectedReviews = data.reviews.filter((r) => selected && reviewCompanyIds(data, r).includes(selected));
function chooseCategory(id: string) {
setCategory(id); setPage(0); setTab("companies");
companySection.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
return <div className="ai-research not-prose">
<div className="research-meta"><span>资料截至 {data.asOf}</span><span>首批研究 · 持续补全</span></div>
<div className="research-stats">
<div><strong>{data.companies.length}</strong><span>公司/产品记录</span></div>
<div><strong>{data.categories.length}</strong><span>业务类别</span></div>
<div><strong>{revenueCompanies}</strong><span>有收入线索的记录</span></div>
<div><strong>{data.evidence.length}</strong><span>证据记录</span></div>
</div>
<div className="research-scope"><p>{data.scope}</p>
{data.coverage && <p>YC 候选池 {data.coverage.candidateRecords} 条,官方页面读取 {data.coverage.profilesRead} 条;初筛匹配 {data.coverage.matchingProfiles} 条,新增 {data.coverage.newProfiles} 条目录记录。年份不符或缺失 {data.coverage.otherOrMissingYear} 条、AI 业务待确认 {data.coverage.unclearAI} 条、日期冲突 {data.coverage.dateConflicts} 条。目录基础资料尚未逐家核验收入和交付能力。</p>}
<details><summary>数据口径与覆盖限制</summary><ul>{data.limitations.map((note) => <li key={note}>{note}</li>)}</ul>
<p>来源以 YC、公司官网、投资机构名录与经营案例为主。同一披露的转载不构成独立验证。团队人数包含公司自报的 Team Size,与正式员工口径可能不同。</p>
</details>
</div>
<section aria-labelledby="research-categories-title" className="research-section">
<h2 id="research-categories-title">业务分类</h2><p className="research-muted">点击类别查看对应公司。数量反映本次采集覆盖,不是市场份额。</p>
<div className="research-categories">{summaries.map((c) => <button key={c.id} onClick={() => chooseCategory(c.id)} aria-label={`查看${c.name}的${c.count}条记录`}>
<div><span>{c.name}</span><strong>{c.count}</strong></div>
<span className="research-bar" aria-hidden="true"><i style={{ width: `${c.count / Math.max(1, ...summaries.map((s) => s.count)) * 100}%` }} /></span>
<small>{c.description}</small>
</button>)}</div>
</section>
<section ref={companySection} className="research-section" aria-labelledby="research-records-title">
<h2 id="research-records-title">研究数据</h2>
<div className="research-tabs" role="group" aria-label="研究数据视图">
{([['companies','公司与产品'],['revenue','收入观测'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
</div>
{tab === "companies" && <>
<div className="research-filters">
<label className="research-query">搜索公司<input type="search" value={query} placeholder="名称、业务、地区或买家" onChange={(e) => { setQuery(e.target.value); setPage(0); }} /></label>
<label>业务类别<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(0); }}><option value="">全部类别</option>{data.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></label>
<label>成立或发布<select value={cohort} onChange={(e) => { setCohort(e.target.value); setPage(0); }}><option value="">全部组别</option><option value="2025">2025 年成立</option><option value="2026">2026 年成立</option><option value="other">公开发布与转型对照</option></select></label>
<label>资料深度<select value={depth} onChange={(e) => { setDepth(e.target.value); setPage(0); }}><option value="">全部资料</option><option value="case">原有个案研究</option><option value="profile">目录基础资料</option></select></label>
<label>记录团队规模<select value={teamBand} onChange={(e) => { setTeamBand(e.target.value); setPage(0); }}><option value="">全部规模</option><option value="small">1—5 人</option><option value="medium">6—20 人</option><option value="large">21—50 人</option><option value="xl">至少 51 人</option><option value="unknown">未披露或分组不确定</option></select></label>
<label>技术验证证据<select value={technicalLevel} onChange={(e) => { setTechnicalLevel(e.target.value); setPage(0); }}><option value="">全部验证层级</option>{Object.entries(technicalReviewLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
<label>收入资料<select value={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到公司披露</option><option value="estimate">仅第三方估算</option><option value="platform">仅支付平台记录</option><option value="unknown">暂无可用数字</option></select></label>
</div>
<p className="research-muted">技术验证层级记录我们查到了什么,不是能力强弱评分。技术专项评审与业务分类复核分别记录。</p>
<div className="research-result-count" role="status">{filtered.length} 条匹配记录<button onClick={() => { setCategory(""); setQuery(""); setCohort(""); setIncome(""); setDepth(""); setTeamBand(""); setTechnicalLevel(""); setPage(0); }}>清除筛选</button></div>
<div className="research-table-wrap"><table><caption className="sr-only">公司/产品分类与经营资料。选择名称查看指标和原始证据。</caption><thead><tr><th>公司或产品 / 地区</th><th>业务与买家</th><th>成立 / 发布组别</th><th>团队规模</th><th>交付与技术验证证据</th><th>收入资料</th></tr></thead><tbody>
{filtered.slice(page * pageSize, (page + 1) * pageSize).map((c) => <tr key={c.id} data-selected={selected === c.id || undefined}>
<td><button className="research-company-name" aria-expanded={selected === c.id} aria-controls="research-company-detail" onClick={() => setSelected(c.id)}>{c.name}</button><small>{c.location ?? "地区未注明"}</small></td>
<td><span className="research-tag">{categories.get(c.cat)?.name}</span>{c.profileOnly && <small>目录基础资料 · 分类依据见明细</small>}<p>{c.business}</p><small>买家:{c.buyer}</small></td>
<td>{c.cohort}</td><td>{c.team !== null && c.teamBound}{c.team ?? "未披露"}{c.team !== null && " 人"}</td><td>{c.stage}<small>{technicalReviewLabels[c.technicalReview?.level ?? "unreviewed"]}</small></td><td>{c.profileOnly ? "收入尚未逐一核验" : revenueLabels[c.revenueStatus]}</td>
</tr>)}
</tbody></table></div>
{!filtered.length && <p className="research-message">没有匹配公司,请调整筛选条件。</p>}
{filtered.length > pageSize && <nav className="research-pagination" aria-label="公司数据分页"><button disabled={page === 0} onClick={() => setPage((n) => n - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= filtered.length} onClick={() => setPage((n) => n + 1)}>下一页</button></nav>}
</>}
{tab === "revenue" && <>
<IncomeDistribution data={data} onSelect={setSelected} />
<h3>公司披露与估算的历史里程碑</h3>
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号,多为异时点历史里程碑,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p>
<div className="research-revenue-chart" aria-label="不同时间的公开收入里程碑,线性长度,非同行排名">{observed.map((o) => <div key={o.id}>
<div><button onClick={() => setSelected(o.company)}>{o.name}</button><strong>{o.bound !== "=" && o.bound}{money(o.value, o.unit)}</strong></div>
{o.unit === "USD" && <span className="research-bar" data-estimate={o.selection === "estimate" || undefined} aria-hidden="true"><i style={{width: `${o.value / Math.max(1, ...observed.filter((v) => v.unit === "USD").map((v) => v.value)) * 100}%`}} /></span>}
<small>{o.kind} · {useLabels[o.selection]} · {o.timing}</small>
</div>)}</div>
<div className="research-table-wrap"><table><caption>当前研究数据中的收入可得性,目录项未逐一检索;未建立同期、同龄、同口径比较组</caption><thead><tr><th>业务分类</th><th>公司披露数</th><th>仅估算数</th><th>仅平台数</th><th>暂无可用数字</th><th>同期均值 / 离散度</th></tr></thead><tbody>{summaries.map((c) => <tr key={c.id}><td><button onClick={() => chooseCategory(c.id)}>{c.name}</button></td><td>{c.self}</td><td>{c.estimate}</td><td>{c.platform}</td><td>{c.unknown}</td><td>条件不足</td></tr>)}</tbody></table></div>
<p className="research-muted">离散度用于筛选值得核查的异常,还需排除公司年龄、口径和披露偏差;不能据此认定财务造假。</p>
</>}
{tab === "sources" && <><p className="research-muted">来源链接保留原始位置。相同 URL 的记录可能属于同一披露,不能按记录数计算独立证据数量。</p><ul className="research-evidence-list">{data.evidence.slice(sourcePage * evidencePageSize, (sourcePage + 1) * evidencePageSize).map((e) => <Evidence key={e.id} item={e} />)}</ul>{data.evidence.length > evidencePageSize && <nav className="research-pagination" aria-label="原始证据分页"><button disabled={sourcePage === 0} onClick={() => setSourcePage((n) => n - 1)}>上一页</button><span>{sourcePage + 1} / {Math.ceil(data.evidence.length / evidencePageSize)}</span><button disabled={(sourcePage + 1) * evidencePageSize >= data.evidence.length} onClick={() => setSourcePage((n) => n + 1)}>下一页</button></nav>}</>}
{tab === "reviews" && <ul className="research-review-list">{data.reviews.map((r) => <li key={r.id}><span className="research-tag">{r.type}</span><h3>{r.company}</h3>{r.scope === "dataset" && <small>全局数据方法核查</small>}{r.scope === "excluded-candidate" && <small>未纳入公司表的候选</small>}<p>{r.summary}</p><p>{r.handling}</p><div>{reviewCompanies(r).map((c) => <button key={c.id} className="research-company-name" onClick={() => setSelected(c.id)}>查看 {c.name} 明细</button>)}</div><div>{r.evidenceIds.map((id) => { const e=data.evidence.find((item) => item.id === id); return e ? <External key={id} url={e.url}>{id}</External> : null; })}</div></li>)}</ul>}
</section>
{selectedCompany && <section id="research-company-detail" ref={detailSection} tabIndex={-1} className="research-detail research-section" aria-label={`${selectedCompany.name}资料明细`}>
<div className="research-detail-heading"><h2>{selectedCompany.name}</h2><button onClick={() => setSelected(null)} aria-label="关闭公司明细">关闭</button></div>
<p><External url={selectedCompany.url}>资料入口</External> {selectedCompany.extraUrl && <External url={selectedCompany.extraUrl}>额外核验入口</External>}</p>
<dl className="research-company-facts"><div><dt>主营业务</dt><dd>{selectedCompany.business}</dd></div><div><dt>样本组别</dt><dd>{selectedCompany.cohort}</dd></div><div><dt>买家</dt><dd>{selectedCompany.buyer}</dd></div><div><dt>收费/交付模式</dt><dd>{selectedCompany.businessModel ?? "待进一步核验"}</dd></div><div><dt>收费方式</dt><dd>{selectedCompany.price}</dd></div><div><dt>交付证据</dt><dd>{selectedCompany.delivery}</dd></div><div><dt>团队口径</dt><dd>{selectedCompany.teamBasis ?? "本轮未核验人数"}</dd></div><div><dt>分类依据</dt><dd>{selectedCompany.classificationBasis ?? "原有个案分类;随研究复核"}</dd></div><div><dt>技术验证证据</dt><dd>{technicalReviewLabels[selectedCompany.technicalReview?.level ?? "unreviewed"]}<p>{selectedCompany.technicalReview?.summary ?? "本轮未形成专项技术评审记录;不表示公司没有技术能力。"}</p>{selectedCompany.technicalReview && <small>评审日期:{selectedCompany.technicalReview.reviewedAt}</small>}{selectedCompany.technicalReview?.testScope && <p>测试范围:{selectedCompany.technicalReview.testScope}</p>}<p>材料可访问仅确认可查看,不证明性能、可靠性或商业交付质量;不同任务不作统一能力排名。</p></dd></div><div><dt>观测主体</dt><dd>{selectedCompany.entityBasis ?? "公司或经营品牌,法律主体未逐一核验"}</dd></div><div><dt>身份与限制</dt><dd>{selectedCompany.notes}</dd></div></dl>
<h3>经营与技术观测</h3>
{!!selectedReviews.length && <details><summary>关联核查记录({selectedReviews.length})</summary><ul>{selectedReviews.map((r) => <li key={r.id}><strong>{r.type}:{r.summary}</strong><p>{r.handling}</p><div>{r.evidenceIds.map((id) => { const item = data.evidence.find((e) => e.id === id); return item ? <External key={id} url={item.url}>{id}</External> : null; })}</div></li>)}</ul></details>}
{selectedObservations.length ? <div className="research-table-wrap"><table><thead><tr><th>指标 / 归属</th><th>原始数值</th><th>数据时点</th><th>用途及限制</th><th>证据</th></tr></thead><tbody>{selectedObservations.map((o) => <tr key={o.id}><td>{o.kind}<small>{o.scope}</small></td><td>{o.bound !== "=" && o.bound}{money(o.value,o.unit)}</td><td>{o.timing}</td><td>{useLabels[o.selection] ?? o.selection}<small>{o.reason}</small></td><td><a href={`#detail-evidence-${o.evidence}`}>{o.evidence}</a></td></tr>)}</tbody></table></div> : <p>本轮尚未找到可记录的量化经营指标。收入保持未知。</p>}
<h3>原始证据</h3><ul className="research-evidence-list">{selectedEvidence.map((e) => <Evidence key={e.id} prefix="detail-evidence" item={e} />)}</ul>
</section>}
<p className="research-footnote">数据版本 {data.version}。当前为首批资料,覆盖、收入定义、财务透明度与商业模式评估将随核验补充。</p>
</div>;
}
@@ -0,0 +1,93 @@
import Link from "next/link";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import type { DocLink } from "@/lib/docs";
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
import { Icon } from "./Icon";
export function CategoryNavigation({ docs, current }: { docs: DocLink[]; current?: string }) {
const navigation = useRef<HTMLElement>(null);
const triggers = useRef(new Map<string, HTMLButtonElement>());
const [open, setOpen] = useState<string | null>(null);
const id = useId();
const categories = [...new Set(docs.map((doc) => doc.category))];
const collections = collectionEntries(docs);
const selected = docs.find((doc) => doc.slug === current);
useEffect(() => {
function outside(event: PointerEvent) {
if (event.target instanceof Node && !navigation.current?.contains(event.target)) setOpen(null);
}
function escape(event: globalThis.KeyboardEvent) {
if (event.key !== "Escape" || !open) return;
if (navigation.current?.contains(document.activeElement)) {
event.preventDefault();
triggers.current.get(open)?.focus();
}
setOpen(null);
}
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape);
};
}, [open]);
function focusItem(panel: string, last = false) {
requestAnimationFrame(() => {
const links = document.getElementById(panel)?.querySelectorAll<HTMLAnchorElement>("a");
links?.[last ? links.length - 1 : 0]?.focus();
});
}
function move(event: KeyboardEvent<HTMLDivElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const links = [...event.currentTarget.querySelectorAll<HTMLAnchorElement>("a")];
const index = links.indexOf(document.activeElement as HTMLAnchorElement);
if (index < 0) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? links.length - 1 :
(index + (event.key === "ArrowDown" ? 1 : -1) + links.length) % links.length;
links[next]?.focus();
}
return <nav ref={navigation} className="top-nav category-navigation" aria-label="文档分类"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(null);
}}>
<ul>
<li><Link className="category-home" href="/" aria-current={!current ? "page" : undefined} onClick={() => setOpen(null)}>文档中心</Link></li>
{categories.map((category, index) => {
const panel = `${id}-category-${index}`;
const expanded = open === category;
return <li className="category-menu" key={category}
onPointerEnter={(event) => { if (event.pointerType === "mouse") setOpen(category); }}
onPointerLeave={(event) => { if (event.pointerType === "mouse") setOpen(null); }}>
<button ref={(element) => { if (element) triggers.current.set(category, element); else triggers.current.delete(category); }}
type="button" className="category-trigger" data-category={category}
data-active={selected?.category === category || undefined}
aria-expanded={expanded} aria-controls={panel}
onClick={() => setOpen(expanded ? null : category)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpen(category);
focusItem(panel, event.key === "ArrowUp");
}
}}>
{category}<Icon name="chevron" size={11} />
</button>
<div id={panel} className="category-panel" hidden={!expanded} onKeyDown={move}>
<p className="category-panel-title">{category}</p>
{collections.filter(({entry}) => entry.category === category).map(({name, entry}) => <Link
key={name} href={`/docs/${entry.slug}`} data-tone={collectionTone(name)}
aria-current={current === entry.slug ? "page" : selected?.collection === name ? "true" : undefined}
onClick={() => setOpen(null)}>
<Icon name={collectionIcon(name)} size={19} />
<span><strong>{collectionLabel(name)}</strong><small>{entry.description}</small></span>
</Link>)}
</div>
</li>;
})}
</ul>
</nav>;
}
@@ -0,0 +1,66 @@
import { useId, useState } from "react";
import { ArchitectureFigure } from "./ArchitectureFigure";
type Slide = { source: string; alt: string };
export function FigureGallery({ slides }: { slides: Slide[] }) {
const [active, setActive] = useState(0);
const viewport = useId();
const slide = slides[active];
if (!slide) return null;
function move(offset: number) {
setActive((current) => (current + offset + slides.length) % slides.length);
}
return (
<section
className="figure-gallery"
role="region"
aria-roledescription="轮播"
aria-label="文档配图"
onKeyDown={(event) => {
if ((event.target as HTMLElement).closest("dialog")) return;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
move(event.key === "ArrowLeft" ? -1 : 1);
}
}}
>
<div
id={viewport}
role="group"
aria-roledescription="幻灯片"
aria-label={`第 ${active + 1} 张,共 ${slides.length} 张`}
>
<ArchitectureFigure key={slide.source} source={slide.source} alt={slide.alt} />
</div>
<div className="figure-gallery-controls">
<button type="button" className="figure-gallery-arrow" aria-label="上一张图片" aria-controls={viewport} onClick={() => move(-1)}>
<span aria-hidden="true">‹</span>
</button>
<div className="figure-gallery-dots" role="group" aria-label="选择图片">
{slides.map((item, index) => (
<button
key={item.source}
type="button"
className="figure-gallery-dot"
aria-label={`显示第 ${index + 1} 张图片`}
aria-current={index === active ? "true" : undefined}
aria-controls={viewport}
onClick={() => setActive(index)}
>
<span aria-hidden="true" />
</button>
))}
</div>
<button type="button" className="figure-gallery-arrow" aria-label="下一张图片" aria-controls={viewport} onClick={() => move(1)}>
<span aria-hidden="true">›</span>
</button>
</div>
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">
第 {active + 1} 张,共 {slides.length} 张:{slide.alt}
</p>
</section>
);
}
@@ -0,0 +1,87 @@
import { useMemo, useState } from "react";
import type { ResearchSnapshot } from "@/lib/ai-research";
import { groupIncome, platformIncomeSample } from "@/lib/research-statistics";
const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
style: "currency", currency: "USD", maximumFractionDigits: 0,
}).format(value);
const percent = (value: number | null) => value === null ? "—" : `${(value * 100).toFixed(1)}%`;
export default function IncomeDistribution({ data, onSelect }: {
data: ResearchSnapshot; onSelect: (id: string) => void;
}) {
const [dateWindow, setDateWindow] = useState("7");
const [year, setYear] = useState("");
const [annualize, setAnnualize] = useState(false);
const [splitModels, setSplitModels] = useState(true);
const [logScale, setLogScale] = useState(true);
const sample = useMemo(() => platformIncomeSample(data, {
windowDays: dateWindow === "all" ? null : Number(dateWindow), foundedYear: year ? Number(year) : undefined, annualize,
}), [data, dateWindow, year, annualize]);
const groups = useMemo(() => groupIncome(sample.points, splitModels).sort((a, b) =>
a.category.localeCompare(b.category) || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model)), [sample.points, splitModels]);
const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({
category, rows: groups.filter((group) => group.category === category),
}));
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);
const minValue = Math.min(...values);
const maxValue = Math.max(1, ...values);
const axisMin = logScale && minValue > 0 && Number.isFinite(minValue) ? 10 ** Math.floor(Math.log10(minValue)) : 0;
const axisMax = Math.max(axisMin * 10, 10 ** Math.ceil(Math.log10(maxValue)));
const scale = logScale ? (value: number) => Math.log10(value + 1) : (value: number) => value;
const left = 250, right = 865;
const x = (value: number) => left + (scale(value) - scale(axisMin)) / (scale(axisMax) - scale(axisMin)) * (right - left);
const ticks = logScale ? [0, 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]
.filter((value) => value >= axisMin && value <= axisMax) : [0, .25, .5, .75, 1].map((fraction) => fraction * axisMax);
return <section className="research-distribution" aria-labelledby="income-distribution-title">
<h3 id="income-distribution-title">收入分布与均值敏感性</h3>
<p>单独比较支付平台显示的 USD MRR。每个点是一个产品或业务,法律主体与支付账户的独立性尚未全部核实;这不是行业总体或公司财报样本。</p>
<div className="research-filters">
<label>最后同步日期窗口<select value={dateWindow} onChange={(event) => setDateWindow(event.target.value)}><option value="7">截止日前 7 天内</option><option value="30">截止日前 30 天内</option><option value="90">截止日前 90 天内</option><option value="all">全部已知日期(混合历史)</option></select></label>
<label>资料中的创立年<select value={year} onChange={(event) => setYear(event.target.value)}><option value="">2025 与 2026</option><option value="2025">2025</option><option value="2026">2026</option></select></label>
<label>显示单位<select value={annualize ? "annualized" : "mrr"} onChange={(event) => setAnnualize(event.target.value === "annualized")}><option value="mrr">MRR(美元/月)</option><option value="annualized">MRR×12参考年化(美元/年)</option></select></label>
</div>
<div className="income-options"><label><input type="checkbox" checked={splitModels} onChange={(event) => setSplitModels(event.target.checked)} /> 按收费与交付模式细分</label><label><input type="checkbox" checked={logScale} onChange={(event) => setLogScale(event.target.checked)} /> 对数横轴</label></div>
<p>{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}</p>
<p className="research-notice">资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}</p>
{sample.points.length ? <>
<figure>
<div className="research-table-wrap"><svg className="income-distribution-chart" viewBox={`0 0 900 ${groups.length * 70 + 65}`} role="group" aria-label={`${metric}分布,${logScale ? "对数" : "线性"}横轴;圆点为产品,竖线为均值,菱形为中位数`}>
{ticks.map((tick) => <g key={tick}><line x1={x(tick)} x2={x(tick)} y1={15} y2={groups.length * 70 + 10} stroke="#dfe6e0" /><text x={x(tick)} y={groups.length * 70 + 35} textAnchor="middle" fontSize="11" fill="#637066">{usd(tick)}</text></g>)}
{groups.map((group, index) => {
const y = index * 70 + 40;
const firstInCategory = index === 0 || groups[index - 1].category !== group.category;
const categoryRowCount = categoryGroups.find((entry) => entry.category === group.category)!.rows.length;
return <g key={group.key} role="group" aria-label={`${categories.get(group.category)},${group.provider},${group.model},${group.stats.n}个产品`}>
{firstInCategory && <>
{index > 0 && <line x1={8} x2={right} y1={y - 34} y2={y - 34} stroke="#dfe6e0" />}
<text x={8} y={y - 8} fontSize="12" fontWeight="600" fill="#202724">{categories.get(group.category)}({categoryRowCount}组)</text>
{categoryRowCount > 1 && <line x1={10} x2={10} y1={y + 3} y2={y + (categoryRowCount - 1) * 70 + 12} stroke="#95b4a6" strokeWidth={2} />}
</>}
<text x={20} y={y + 10} fontSize="10" fill="#637066">{group.provider} · {group.model} · n={group.stats.n}</text>
{group.stats.n < 3 && <text x={20} y={y + 26} fontSize="9" fill="#637066">{group.stats.n === 1 ? "仅1个产品,无法判断组内离散度" : "仅2个产品,不代表领域典型收入"}</text>}
<line x1={x(group.stats.min)} x2={x(group.stats.max)} y1={y} y2={y} stroke="#95b4a6" strokeWidth={2} />
<line x1={x(group.stats.mean)} x2={x(group.stats.mean)} y1={y - 17} y2={y + 17} stroke="#b17725" strokeWidth={3}><title>样本均值 {usd(group.stats.mean)}</title></line>
<path d={`M${x(group.stats.median)},${y - 7} l7,7 l-7,7 l-7,-7 Z`} fill="#8a78ad"><title>样本中位数 {usd(group.stats.median)}</title></path>
{group.points.map((point, position) => <g key={point.company.id} role="button" tabIndex={0} aria-label={`查看${point.company.name},${metric} ${usd(point.value)}`} onClick={() => onSelect(point.company.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onSelect(point.company.id); } }}>
<title>{point.company.name} · {usd(point.value)} · 数据日期 {point.observation.observedAt}{point.observation.connectionExpired ? " · 连接提示过期" : ""}</title>
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={10} fill="transparent" />
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={4.5} fill="#27745b" stroke="white" />
</g>)}
</g>;
})}
</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}>
{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>
</tr>)}</tbody>)}</table></div>
</> : <p className="research-message">当前窗口和创立年份没有符合条件的 MRR 记录。</p>}
<details><summary>统计规则与解释</summary><p>每个产品只取符合条件的最新一条平台 MRR;币种为美元,要求有效数据日期、确定数值及支付来源。公司 ARR、估算、融资、目标和近30日收入均不进入这里。未知值不补零。</p><p>CV=样本标准差÷均值,标准差分母为 n−1;n=1 不算 CV。n=2 时均值必然等于中位数,不能据此认为均值有代表性。较大的均值/中位数差距、CV或移除最大值的变化,仅提示进一步核查样本构成。</p><p>支付商与平台的退款、试用、年付、币种及指标计算可能不同(<a href="https://trustmrr.com/faq" target="_blank" rel="noopener noreferrer">平台方法说明</a>)。不能将 MRR 与收款差异、单个高值或高离散度直接解释为财务造假。比较商业模式还需要毛利、获客成本、留存、人工交付和客户集中度。</p></details>
{sample.excluded.length > 0 && <details><summary>未纳入的 MRR 记录({sample.excluded.length})</summary><ul>{sample.excluded.map(({ observation, reason }) => <li key={observation.id}><button onClick={() => onSelect(observation.company)}>{observation.name}</button>:{usd(observation.value)} / 月,{observation.observedAt ?? "日期未知"};{reason}</li>)}</ul></details>}
</section>;
}
+3 -9
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { CategoryNavigation } from "./CategoryNavigation";
import { Icon } from "@/components/Icon";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { DocLink } from "@/lib/docs";
@@ -36,9 +37,6 @@ export function Navigation({
return (
<nav aria-label="文档导航">
{collection ? <>
<Link className="nav-back" href="/" onClick={onNavigate}>
<Icon name="arrow" size={15} style={{ transform: "rotate(180deg)" }} />全部文档
</Link>
<Link className="collection-identity" href={`/docs/${collections.find((c) => c.name === collection)!.entry.slug}`} onClick={onNavigate}>
<Icon name={collectionIcon(collection)} size={22} /><span>{collectionLabel(collection)}</span>
</Link>
@@ -52,7 +50,7 @@ export function Navigation({
{scopedDocs.filter((d) => d.group === group).map((d) => (
<Link onClick={onNavigate} aria-current={d.slug === current ? "page" : undefined}
className="nav-item" href={`/docs/${d.slug}`} key={d.slug}>
<span>{d.slug.endsWith("/00-overview") ? "概览" : d.title.replace("(草案)", "")}</span>
<span>{d.slug.endsWith("/overview") ? "概览" : d.title.replace("(草案)", "").replace(/^\d+\s*·\s*/, "")}</span>
{d.draft && <span className="tiny-dot" aria-label="草案" />}
</Link>
))}
@@ -81,7 +79,6 @@ export function Shell({
const [selected, setSelected] = useState(0);
const [menu, setMenu] = useState(false);
const collection = docs.find((d) => d.slug === current)?.collection;
const collections = collectionEntries(docs);
const results = searchDocs.filter((d) =>
(!collection || d.collection === collection) &&
`${d.title} ${d.description} ${d.searchText ?? ""}`
@@ -126,10 +123,7 @@ export function Shell({
</a>
<header className="site-header">
<Brand />
<nav className="top-nav" aria-label="主导航">
<Link href="/" aria-current={!current ? "page" : undefined}>文档中心</Link>
{collections.map(({ name, entry }) => <Link key={name} href={`/docs/${entry.slug}`} aria-current={collection === name ? "page" : undefined}>{collectionLabel(name)}</Link>)}
</nav>
<CategoryNavigation docs={docs} current={current} />
<button
ref={trigger}
className="search-trigger"
+87
View File
@@ -0,0 +1,87 @@
import seed from "../../../../content/research/ai-companies.seed.json";
import { createHash } from "node:crypto";
import { openSiteDatabase } from "./site-database";
import { validateResearch, type ResearchSnapshot } from "./ai-research";
// Each release selects an immutable research revision. New revisions are added
// transactionally, preserving both earlier research and all authentication rows.
export function readResearchSnapshot(snapshot = seed as ResearchSnapshot): ResearchSnapshot {
validateResearch(snapshot);
const digest = createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
const db = openSiteDatabase();
try {
db.exec(`
CREATE TABLE IF NOT EXISTS research_datasets (
id TEXT PRIMARY KEY, as_of TEXT NOT NULL, metadata TEXT NOT NULL, content_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS research_categories (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_companies (
dataset_id TEXT NOT NULL, id TEXT NOT NULL, category_id TEXT NOT NULL,
name TEXT NOT NULL, founded_year INTEGER, team_size INTEGER,
revenue_status TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id),
FOREIGN KEY(dataset_id,category_id) REFERENCES research_categories(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_evidence (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, url TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_observations (
dataset_id TEXT NOT NULL, id TEXT NOT NULL, company_id TEXT NOT NULL,
evidence_id TEXT NOT NULL, kind TEXT NOT NULL, amount REAL NOT NULL,
unit TEXT NOT NULL, record_use TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id),
FOREIGN KEY(dataset_id,company_id) REFERENCES research_companies(dataset_id,id),
FOREIGN KEY(dataset_id,evidence_id) REFERENCES research_evidence(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_reviews (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
);
`);
// The database transaction also serializes first requests from two workers.
db.exec("BEGIN IMMEDIATE");
try {
const exists = db.prepare("SELECT content_hash FROM research_datasets WHERE id=?")
.get(snapshot.version) as { content_hash: string } | undefined;
if (exists && exists.content_hash !== digest)
throw new Error("Research data changed without a new revision identifier");
if (!exists) {
const { categories, companies, observations, evidence, reviews, ...metadata } = snapshot;
db.prepare("INSERT INTO research_datasets VALUES (?,?,?,?)")
.run(snapshot.version, snapshot.asOf, JSON.stringify(metadata), digest);
const insertCategory = db.prepare("INSERT INTO research_categories VALUES (?,?,?)");
for (const row of categories) insertCategory.run(snapshot.version, row.id, JSON.stringify(row));
const insertCompany = db.prepare("INSERT INTO research_companies VALUES (?,?,?,?,?,?,?,?)");
for (const row of companies) insertCompany.run(snapshot.version, row.id, row.cat,
row.name, row.year, row.team, row.revenueStatus, JSON.stringify(row));
const insertEvidence = db.prepare("INSERT INTO research_evidence VALUES (?,?,?,?)");
for (const row of evidence) insertEvidence.run(snapshot.version, row.id, row.url, JSON.stringify(row));
const insertObservation = db.prepare("INSERT INTO research_observations VALUES (?,?,?,?,?,?,?,?,?)");
for (const row of observations) insertObservation.run(snapshot.version, row.id, row.company,
row.evidence, row.kind, row.value, row.unit, row.selection, JSON.stringify(row));
const insertReview = db.prepare("INSERT INTO research_reviews VALUES (?,?,?)");
for (const row of reviews) insertReview.run(snapshot.version, row.id, JSON.stringify(row));
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
const meta = db.prepare("SELECT metadata FROM research_datasets WHERE id=?").get(snapshot.version) as { metadata: string };
// All displayed records come from the site database, not the seed arrays.
const rows = (table: string) => (db.prepare(
`SELECT payload FROM ${table} WHERE dataset_id=? ORDER BY id`,
).all(snapshot.version) as { payload: string }[]).map((r) => JSON.parse(r.payload));
const stored: ResearchSnapshot = { ...JSON.parse(meta.metadata),
categories: rows("research_categories"), companies: rows("research_companies"),
observations: rows("research_observations"), evidence: rows("research_evidence"),
reviews: rows("research_reviews") };
validateResearch(stored);
return stored;
} finally { db.close(); }
}
+134
View File
@@ -0,0 +1,134 @@
export type ResearchCategory = { id: string; name: string; description: string };
export const technicalReviewLabels = {
unreviewed: "技术证据尚未专项评审",
description: "已读技术自述",
"public-artifact": "公开代码/模型材料可访问",
"independent-test": "有第三方独立评测记录",
};
export type ResearchCompany = {
id: string; name: string; url: string; cat: string; year: number | null;
team: number | null; teamBound?: ">" | "≥"; location: string | null; business: string; buyer: string;
delivery: string; notes: string; cohort: string; teamBasis: string | null;
price: string; stage: string; sourceType: string; evidenceIds: string[];
revenueStatus: "self" | "estimate" | "platform" | "unknown";
extraUrl?: string; pricingEvidence?: string;
businessModel?: string; entityBasis?: string;
profileOnly?: boolean; classificationBasis?: string; directoryTags?: string[];
directoryOrigin?: "yc-2025-2026";
technicalReview?: {
level: "description" | "public-artifact" | "independent-test";
reviewedAt: string; summary: string; evidenceIds: string[]; testScope?: string;
};
};
export type ResearchObservation = {
id: string; company: string; name: string; kind: string; value: number;
unit: string; timing: string; evidence: string; scope: string;
selection: string; reason: string; bound: string; comparisonGroup: string | null;
metric?: "mrr" | "arr" | "revenue-30d" | "annualized-revenue";
observedAt?: string; provider?: string; connectionExpired?: boolean; origin?: "payment-platform";
};
export type ResearchEvidence = {
id: string; companyId: string | null; title: string; url: string;
publishedAt: string | null; accessedAt: string; kind: string;
summary: string; limitation: string;
};
export type ResearchReview = {
id: string; type: string; company: string; summary: string;
evidenceIds: string[]; handling: string;
scope?: "companies" | "dataset" | "excluded-candidate"; companyIds?: string[];
};
export type ResearchCoverage = {
source: string; accessedAt: string; candidateRecords: number; profilesRead: number;
matchingProfiles: number; existingProfiles: number; newProfiles: number;
otherOrMissingYear: number; unclearAI: number; dateConflicts: number; failedProfiles: number;
discoverySha256: string;
};
export type ResearchSnapshot = {
version: string; asOf: string; scope: string; limitations: string[];
categories: ResearchCategory[]; companies: ResearchCompany[];
observations: ResearchObservation[]; evidence: ResearchEvidence[];
reviews: ResearchReview[]; coverage?: ResearchCoverage;
};
export function evidenceUrl(value: string): string | undefined {
try {
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
} catch { return undefined; }
}
export function validateResearch(snapshot: ResearchSnapshot) {
const unique = (values: string[], label: string) => {
if (new Set(values).size !== values.length || values.some((v) => !v))
throw new Error(`Duplicate or missing research ${label}`);
return new Set(values);
};
if (!snapshot.version || !/^\d{4}-\d{2}-\d{2}$/.test(snapshot.asOf))
throw new Error("Invalid research dataset metadata");
const categories = unique(snapshot.categories.map((c) => c.id), "categories");
const companies = unique(snapshot.companies.map((c) => c.id), "companies");
const evidence = unique(snapshot.evidence.map((e) => e.id), "evidence");
unique(snapshot.observations.map((o) => o.id), "observations");
unique(snapshot.reviews.map((r) => r.id), "reviews");
for (const c of snapshot.companies) {
const technical = c.technicalReview;
if (technical && (!["description", "public-artifact", "independent-test"].includes(technical.level) ||
!/^\d{4}-\d{2}-\d{2}$/.test(technical.reviewedAt) ||
Number.isNaN(Date.parse(technical.reviewedAt)) ||
new Date(technical.reviewedAt).toISOString().slice(0, 10) !== technical.reviewedAt ||
technical.reviewedAt > snapshot.asOf || !technical.summary.trim() ||
!technical.evidenceIds.length || technical.evidenceIds.some((id) => !evidence.has(id)) ||
(technical.level === "independent-test" && !technical.testScope?.trim())))
throw new Error(`Invalid technical review: ${c.id}`);
if (c.profileOnly && (c.revenueStatus !== "unknown" || !c.classificationBasis))
throw new Error(`Invalid profile-only research record: ${c.id}`);
if (c.directoryOrigin && (c.directoryOrigin !== "yc-2025-2026" || !/^YC\d+$/.test(c.id)))
throw new Error(`Invalid research directory origin: ${c.id}`);
if (!categories.has(c.cat) || !evidenceUrl(c.url) ||
c.evidenceIds.some((id) => !evidence.has(id)) ||
(c.pricingEvidence && !evidence.has(c.pricingEvidence)))
throw new Error(`Invalid research company reference: ${c.id}`);
}
for (const e of snapshot.evidence) {
if (!evidenceUrl(e.url) || (e.companyId && !companies.has(e.companyId)))
throw new Error(`Invalid research evidence: ${e.id}`);
}
for (const o of snapshot.observations) {
if (!companies.has(o.company) || !evidence.has(o.evidence) ||
!Number.isFinite(o.value))
throw new Error(`Invalid research observation: ${o.id}`);
}
const coverage = snapshot.coverage;
if (coverage && (coverage.profilesRead + coverage.failedProfiles !== coverage.candidateRecords ||
coverage.matchingProfiles !== coverage.existingProfiles + coverage.newProfiles ||
coverage.matchingProfiles + coverage.otherOrMissingYear + coverage.unclearAI + coverage.dateConflicts !== coverage.profilesRead ||
snapshot.companies.filter((c) => c.profileOnly || c.directoryOrigin === "yc-2025-2026").length !== coverage.newProfiles || !evidenceUrl(coverage.source)))
throw new Error("Invalid research coverage accounting");
for (const r of snapshot.reviews)
if (r.evidenceIds.some((id) => !evidence.has(id)) ||
(r.companyIds && (new Set(r.companyIds).size !== r.companyIds.length ||
r.companyIds.some((id) => !companies.has(id)))) ||
(r.scope && !["companies", "dataset", "excluded-candidate"].includes(r.scope)) ||
(r.scope === "companies" && !r.companyIds?.length) ||
(r.scope && r.scope !== "companies" && !!r.companyIds?.length))
throw new Error(`Invalid research review: ${r.id}`);
}
/** Explicit reviewed associations take priority; older revisions use evidence ownership. */
export function reviewCompanyIds(snapshot: ResearchSnapshot, review: ResearchReview): string[] {
if (review.companyIds) return review.companyIds;
const evidenceIds = new Set(review.evidenceIds);
return [...new Set(snapshot.evidence.flatMap((e) =>
e.companyId && evidenceIds.has(e.id) ? [e.companyId] : []))];
}
export function categorySummary(snapshot: ResearchSnapshot) {
return snapshot.categories.map((category) => {
const companies = snapshot.companies.filter((c) => c.cat === category.id);
return { ...category, count: companies.length,
self: companies.filter((c) => c.revenueStatus === "self").length,
platform: companies.filter((c) => c.revenueStatus === "platform").length,
estimate: companies.filter((c) => c.revenueStatus === "estimate").length,
unknown: companies.filter((c) => c.revenueStatus === "unknown").length };
});
}
+2 -8
View File
@@ -1,14 +1,8 @@
import { chmodSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { authId, matchingStep, type AuthConfig } from "./auth";
import { openSiteDatabase } from "./site-database";
function database() {
const configFile = process.env.AUTH_CONFIG_FILE;
if (!configFile) throw new Error("Authentication is not configured");
const file = join(dirname(configFile), "login-state.sqlite");
const db = new DatabaseSync(file);
chmodSync(file, 0o600);
const db = openSiteDatabase();
db.exec(`PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS login_state (
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
+1 -1
View File
@@ -18,7 +18,7 @@ export function collectionIcon(name?: string): "book" | "code" | "layers" | "nod
export function collectionEntries(docs: DocLink[]) {
return [...new Set(docs.map((doc) => doc.collection))].map((name) => {
const items = docs.filter((doc) => doc.collection === name);
const entry = items.find((doc) => doc.slug.endsWith("/00-overview")) ?? items[0];
const entry = items.find((doc) => doc.slug.endsWith("/overview")) ?? items[0];
return { name, entry, count: items.length };
});
}
+13 -7
View File
@@ -4,20 +4,24 @@ import catalog from "../../../../content/catalog.json";
const directory = path.resolve(process.cwd(), "../../content/docs");
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
type Metadata = { collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean };
type Metadata = { slug?: string; category?: string; collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean };
export type DocLink = {
slug: string; title: string; searchText?: string;
slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
group: string; collection: string; description: string; draft: boolean; featured: boolean;
};
export type Doc = DocLink & { content: string };
export async function readDoc(slug: string): Promise<Doc> {
if (!validSlug.test(slug)) throw new Error("Invalid document slug");
const content = await fs.readFile(path.join(directory, `${slug}.md`), "utf8");
const sourceSlug = Object.entries(catalog.documents).find(([, metadata]) => metadata.slug === slug)?.[0] ?? slug;
const content = await fs.readFile(path.join(directory, `${sourceSlug}.md`), "utf8");
const title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug;
const collection = catalog.collections.find((c) => !c.path || slug.startsWith(`${c.path}/`))!;
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[slug] ?? {};
return { slug, title, content, searchText: content,
const collection = catalog.collections.find((c) => !c.path || sourceSlug.startsWith(`${c.path}/`))!;
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[sourceSlug] ?? {};
if (!metadata.slug || !metadata.category || !validSlug.test(metadata.slug) || metadata.slug.split("/").some((part) => /^\d+-/.test(part))) {
throw new Error(`Register a semantic slug and category in content/catalog.json: ${sourceSlug}`);
}
return { slug: metadata.slug, sourceSlug, category: metadata.category, title, content, searchText: content,
group: metadata.group ?? collection.title, collection: metadata.collection ?? collection.title,
description: metadata.description ?? collection.description,
draft: metadata.draft ?? false, featured: metadata.featured ?? false,
@@ -36,9 +40,11 @@ export async function listDocs(): Promise<DocLink[]> {
}));
return files.flat();
}
return Promise.all((await walk("")).sort().map(async (slug) => {
const docs = await Promise.all((await walk("")).sort().map(async (slug) => {
const { content: _, searchText: __, ...doc } = await readDoc(slug);
void _; void __;
return doc;
}));
if (new Set(docs.map((doc) => doc.slug)).size !== docs.length) throw new Error("Duplicate public document slug");
return docs;
}
+82
View File
@@ -0,0 +1,82 @@
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number };
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<string, ResearchObservation>();
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<string, { category: string; provider: string; model: string; points: IncomePoint[] }>();
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))! }));
}
+20
View File
@@ -0,0 +1,20 @@
import { chmodSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
// Keep the historical filename so existing authentication state is preserved.
// Research and authentication use different tables in this same site database.
export function openSiteDatabase() {
const configFile = process.env.AUTH_CONFIG_FILE;
if (!configFile) throw new Error("Authentication is not configured");
const file = join(dirname(configFile), "login-state.sqlite");
const db = new DatabaseSync(file);
try {
chmodSync(file, 0o600);
db.exec("PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;");
return db;
} catch (error) {
db.close();
throw error;
}
}
@@ -0,0 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { readResearchSnapshot } from "@/lib/ai-research-store";
// The site's existing proxy protects this endpoint with the same login session.
export default function handler(request: NextApiRequest, response: NextApiResponse) {
response.setHeader("Cache-Control", "private, no-store");
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
return response.status(405).end();
}
try { return response.status(200).json(readResearchSnapshot()); }
catch { return response.status(503).json({ error: "研究数据库暂时不可用,请稍后重试" }); }
}
+36 -9
View File
@@ -4,6 +4,7 @@ import type {
InferGetStaticPropsType,
} from "next";
import Head from "next/head";
import dynamic from "next/dynamic";
import Link from "next/link";
import {
Children,
@@ -17,12 +18,14 @@ import remarkGfm from "remark-gfm";
import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
import { Shell, Navigation } from "@/components/Shell";
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
import { FigureGallery } from "@/components/FigureGallery";
import { CodeBlock } from "@/components/CodeBlock";
import { collectionEntries, collectionLabel } from "@/lib/collections";
import { Icon } from "@/components/Icon";
import { headingsFor } from "@/lib/navigation";
type Props = { doc: Doc; docs: DocLink[] };
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
export const getStaticPaths: GetStaticPaths = async () => ({
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
fallback: false,
@@ -87,7 +90,7 @@ export default function DocPage({
<meta name="description" content={doc.description} />
</Head>
<Shell key={doc.slug} docs={docs} current={doc.slug}>
<div className="reading-layout">
<div className={`reading-layout${doc.collection === "AI 公司研究" ? " research-layout" : ""}`}>
<aside className="sidebar">
<Navigation docs={docs} current={doc.slug} />
</aside>
@@ -102,15 +105,41 @@ export default function DocPage({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children, node }) =>
node?.children.some(
a: ({ href, children }) => {
if (!href) return <a>{children}</a>;
let target = href;
if (!/^(?:[a-z]+:|\/\/|#)/i.test(href)) {
const resolved = new URL(href, `https://docs.local/docs/${doc.sourceSlug}`);
const source = decodeURIComponent(resolved.pathname).replace(/^\/docs\//, "").replace(/\.md$/, "");
const linked = docs.find((d) => d.sourceSlug === source || d.slug === source);
if (linked) target = `/docs/${linked.slug}${resolved.search}${resolved.hash}`;
}
return <a href={target}>{children}</a>;
},
p: ({ children, node }) => {
const slides = node?.children.flatMap((child) =>
child.type === "element" && child.tagName === "img" &&
typeof child.properties.src === "string" &&
child.properties.src.startsWith("/figures/endor-")
? [{ source: child.properties.src, alt: String(child.properties.alt ?? "") }]
: [],
) ?? [];
const onlyImages = node?.children.every((child) =>
(child.type === "element" && child.tagName === "img" &&
typeof child.properties.src === "string" &&
child.properties.src.startsWith("/figures/endor-")) ||
(child.type === "text" && child.value.trim() === ""),
);
if (slides.length > 1 && onlyImages) return <FigureGallery slides={slides} />;
return node?.children.some(
(child) =>
child.type === "element" && child.tagName === "img",
) ? (
<div>{children}</div>
) : (
<p>{children}</p>
),
);
},
img: ({ src, alt }) =>
typeof src === "string" && src.startsWith("/figures/endor-") ? (
<ArchitectureFigure source={src} alt={alt ?? "Endor 架构图"} />
@@ -198,10 +227,11 @@ export default function DocPage({
{doc.content}
</ReactMarkdown>
</article>
{doc.slug === "other-research/ai-company-research/overview" && <AICompanyResearch />}
<div className="article-end">
<a
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.slug}.md`}
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.sourceSlug}.md`}
>
查看文档源码 ↗
</a>
@@ -213,10 +243,7 @@ export default function DocPage({
<strong>{siblings[index - 1].title}</strong>
</Link>
) : (
<Link href="/">
<small>← 返回</small>
<strong>文档概览</strong>
</Link>
<span />
)}
{index < siblings.length - 1 && (
<Link
+12
View File
@@ -1,5 +1,10 @@
import { NextResponse, type NextRequest } from "next/server";
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
import catalog from "../../../content/catalog.json";
const legacyRoutes = new Map(Object.entries(catalog.documents).map(([source, metadata]) => [
`/docs/${source}`, `/docs/${metadata.slug}`,
]));
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
@@ -49,6 +54,13 @@ export function proxy(request: NextRequest) {
);
}
}
// Keep legacy URLs behind authentication and out of static generation.
const destination = legacyRoutes.get(path);
if (destination && ["GET", "HEAD"].includes(request.method)) {
const target = request.nextUrl.clone();
target.pathname = destination;
return NextResponse.redirect(target, { status: 308, headers });
}
return NextResponse.next({ headers });
}
if (path.startsWith("/api/") || !["GET", "HEAD"].includes(request.method)) {
+218
View File
@@ -882,6 +882,57 @@ button {
border-radius: 0;
background: transparent;
}
.figure-gallery {
margin: 28px 0;
}
.figure-gallery .architecture-preview {
margin: 0;
}
.figure-gallery-controls,
.figure-gallery-dots {
display: flex;
align-items: center;
justify-content: center;
}
.figure-gallery-controls {
gap: 20px;
margin-top: 12px;
}
.figure-gallery-arrow,
.figure-gallery-dot {
display: grid;
place-items: center;
min-width: 36px;
min-height: 36px;
padding: 0;
border: 0;
border-radius: 50%;
color: #57534e;
background: transparent;
cursor: pointer;
}
.figure-gallery-arrow {
font-size: 28px;
line-height: 1;
}
.figure-gallery-dot span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #d6d3d1;
}
.figure-gallery-dot[aria-current="true"] span {
background: #2563eb;
}
.figure-gallery-arrow:hover,
.figure-gallery-dot:hover {
background: #f5f5f4;
}
.figure-gallery-arrow:focus-visible,
.figure-gallery-dot:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.image-lightbox {
width: 100vw;
height: 100dvh;
@@ -1262,3 +1313,170 @@ svg { flex-shrink: 0; }
*, *::before, *::after { animation: none !important; transition: none !important; }
.featured-link:hover, .featured-link:hover .collection-art, .pagination > a:hover { transform: none; }
}
/* Compact navigation and local font roles, with no font downloads. */
:root {
--font-reading: "Charter", "Iowan Old Style", "Songti SC", "STSong", "Noto Serif CJK SC", serif;
--font-code: "SFMono-Regular", Consolas, "Liberation Mono", "PingFang SC", monospace;
}
.top-nav { margin-left: 0; }
.sidebar { padding: 22px 16px 32px; }
.collection-identity { font-size: 14px; gap: 9px; margin: 0 8px 20px; }
.collection-identity svg { width: 18px; height: 18px; }
.nav-group { margin-top: 20px; }
.nav-group > p { margin: 0 8px 6px; font-size: 11px; letter-spacing: .02em; color: #7d8981; }
.nav-item { font-size: 13px; padding: 5px 8px; margin: 0; line-height: 1.5; gap: 6px; }
.nav-item[aria-current="page"]::before { top: 7px; bottom: 7px; }
.collection-nav-link { padding: 7px 8px; margin-bottom: 2px; gap: 9px; font-size: 13px; }
.prose { font-family: var(--font-reading); font-size: 16px; line-height: 1.95; }
.prose :is(h1,h2,h3,h4), .prose table, .code-toolbar, .mobile-toc { font-family: var(--font-sans); }
.prose :is(h1,h2,h3) { font-weight: 600; letter-spacing: -.025em; }
.prose :is(code,pre), .code-block pre, kbd { font-family: var(--font-code); font-variant-ligatures: none; }
.prose code { font-size: .86em; }
@media (min-width: 1201px) {
.home-layout { grid-template-columns: 240px minmax(0,1fr); }
.reading-layout { grid-template-columns: 240px minmax(0,1fr) 222px; }
}
@media (min-width: 761px) and (max-width: 1200px) {
.home-layout, .reading-layout { grid-template-columns: 224px minmax(0,1fr); }
}
@media (max-width: 760px) {
.top-nav { overflow: visible; }
.mobile-navigation .nav-group { margin-top: 16px; }
}
/* Categories stay visible across the header, each with its own disclosure panel. */
.top-nav.category-navigation { position: relative; display: flex; margin-left: 0; gap: 0; }
.category-navigation > ul { display: flex; align-items: stretch; gap: 22px; height: 100%; list-style: none; padding: 0; margin: 0; }
.category-navigation > ul > li { display: flex; align-items: stretch; }
.category-menu { position: relative; }
.category-navigation .category-home, .category-trigger { display: flex; align-items: center; gap: 6px; color: var(--muted); font-family: var(--font-sans); font-size: 13px; white-space: nowrap; }
.category-trigger { position: relative; padding: 0; background: transparent; cursor: pointer; }
.category-trigger > svg { transform: rotate(90deg); transition: transform 160ms; }
.category-trigger[aria-expanded="true"] > svg { transform: rotate(-90deg); }
.category-trigger:hover, .category-trigger[aria-expanded="true"], .category-trigger[data-active="true"] { color: var(--ink); }
.category-trigger[data-active="true"]::after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--green); }
.category-trigger:focus-visible, .category-navigation a:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: 3px; }
.category-panel { position: absolute; top: 100%; left: -16px; width: 320px; padding: 12px; background: #fdfefd; border: 1px solid var(--line); border-radius: 0 0 10px 10px; box-shadow: 0 16px 40px #24352b1a; z-index: 50; }
.category-panel[hidden] { display: none; }
.category-menu:last-child .category-panel { left: auto; right: -16px; }
.category-panel-title { margin: 3px 10px 8px; font-size: 11px; color: var(--muted); }
.top-nav .category-panel a { display: flex; align-items: flex-start; gap: 12px; padding: 12px 10px; white-space: normal; border-radius: 6px; color: var(--ink); }
.top-nav .category-panel a::after { display: none; }
.category-panel a:hover, .category-panel a[aria-current] { background: var(--accent-wash); }
.category-panel a > svg { margin-top: 2px; color: var(--green); }
.category-panel strong { display: block; font-size: 13px; font-weight: 550; line-height: 1.5; }
.category-panel small { display: block; margin-top: 4px; font-size: 12px; line-height: 1.6; color: var(--muted); }
@media (min-width: 761px) and (max-width: 1100px) {
.site-header { gap: 20px; }
.category-navigation > ul { gap: 16px; }
.category-navigation .category-home, .category-trigger { font-size: 12px; }
.search-trigger { width: 36px; height: 34px; padding: 8px; justify-content: center; }
.search-trigger span, .search-trigger kbd { display: none; }
}
@media (max-width: 760px) {
.top-nav.category-navigation { overflow: visible; }
.category-navigation > ul { width: 100%; gap: 18px; }
.category-navigation .category-home, .category-trigger { font-size: 12px; }
.category-menu { position: static; }
.category-panel, .category-menu:last-child .category-panel { width: auto; left: 0; right: 0; }
}
/* Research keeps the documentation navigation and uses the remaining reading width. */
.research-layout { grid-template-columns: 240px minmax(0, 1fr); }
.research-layout .reading-main { max-width: 1440px; width: 100%; }
.research-layout > .toc { display: none; }
.ai-research { margin-top: 28px; font-size: 13px; line-height: 1.65; }
.ai-research h2 { font-size: 20px; font-weight: 600; margin: 0 0 10px; letter-spacing: -.4px; }
.ai-research h3 { font-size: 15px; font-weight: 600; margin: 18px 0 8px; }
.ai-research p { margin: 6px 0 10px; }
.ai-research a, .research-company-name { color: var(--green); }
.ai-research a:hover, .research-company-name:hover { text-decoration: underline; }
.ai-research button { text-align: left; }
.research-meta { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--muted); font-size: 12px; }
.research-stats { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; margin: 22px 0; }
.research-stats > div { border-top: 1px solid var(--line); padding: 14px 0; }
.research-stats strong { display: block; font-size: 29px; line-height: 1.3; font-weight: 550; font-variant-numeric: tabular-nums; }
.research-stats span { color: var(--muted); font-size: 12px; }
.research-scope { padding: 14px 18px; background: var(--soft); border-radius: 7px; }
.research-scope summary { color: var(--green); font-weight: 500; }
.research-scope ul { padding-left: 20px; }
.research-section { margin: 38px 0; scroll-margin-top: 92px; }
.research-muted, .research-footnote { color: var(--muted); font-size: 12px; }
.research-categories { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px 26px; margin-top: 20px; }
.research-categories button { padding: 12px 0; background: transparent; border: 0; border-bottom: 1px solid var(--line); }
.research-categories button:hover { color: var(--green); }
.research-categories button > div, .research-revenue-chart > div > div { display: flex; justify-content: space-between; gap: 16px; }
.research-categories strong { font-variant-numeric: tabular-nums; }
.research-categories small { font-size: 11px; color: var(--muted); }
.research-bar { display: block; height: 5px; background: #f0f3f1; margin: 8px 0; border-radius: 3px; overflow: hidden; }
.research-bar i { display: block; height: 100%; background: #47856d; border-radius: 3px; }
.research-bar[data-estimate] i { background: #b98a40; }
.research-tabs { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin: 18px 0; overflow-x: auto; }
.research-tabs button { padding: 10px 0; white-space: nowrap; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); }
.research-tabs button[aria-pressed="true"] { border-bottom-color: var(--green); color: var(--green); font-weight: 600; }
.research-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
.research-filters label { display: flex; flex-direction: column; gap: 5px; color: var(--muted); font-size: 11px; flex: 1 1 140px; }
.research-filters .research-query { flex: 2 1 210px; }
.research-filters :is(input,select) { min-width: 0; width: 100%; padding: 8px 10px; font-size: 12px; color: var(--ink); background: #fff; border: 1px solid #dfe6e0; border-radius: 5px; height: 36px; }
.research-result-count { display: flex; justify-content: space-between; margin: 14px 0 8px; color: var(--muted); font-size: 12px; }
.research-result-count button { border: 0; background: none; color: var(--green); }
.research-table-wrap { overflow-x: auto; max-width: 100%; }
.ai-research table { width: 100%; border-collapse: collapse; min-width: 720px; text-align: left; font-size: 12px; }
.ai-research caption { text-align: left; color: var(--muted); padding: 12px 0; font-size: 12px; }
.ai-research th { color: var(--muted); background: #f7f9f7; font-weight: 500; }
.ai-research :is(th,td) { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
.ai-research td:first-child { min-width: 140px; }
.ai-research td:nth-child(2) { min-width: 220px; }
.ai-research td p { margin: 6px 0; }
.ai-research td small { display: block; color: var(--muted); margin-top: 5px; }
.ai-research tr[data-selected] { background: #f0f8f3; }
.research-company-name { font-weight: 600; background: none; border: 0; padding: 0; }
.research-tag { display: inline-block; font-size: 10px; line-height: 1.7; padding: 1px 6px; border-radius: 3px; background: #eef3ef; color: #596e60; }
.research-pagination { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 16px; }
.research-pagination button, .research-message button, .research-detail-heading button { padding: 6px 11px; border-radius: 4px; border: 1px solid var(--line); background: white; }
.research-pagination button:disabled { color: #a8afa9; cursor: default; }
.research-message { padding: 24px 0; color: var(--muted); }
.research-message button { margin-left: 12px; color: var(--green); }
.research-notice { border-left: 3px solid #c6a063; padding: 10px 16px; background: #faf8f2; }
.research-revenue-chart { display: grid; gap: 18px; padding: 18px 0 30px; }
.research-revenue-chart button { padding: 0; border: 0; background: none; color: var(--green); }
.research-revenue-chart strong { font-variant-numeric: tabular-nums; font-weight: 500; }
.research-revenue-chart small { color: var(--muted); font-size: 11px; }
.research-evidence-list, .research-review-list { list-style: none; padding: 0; margin: 0; }
.research-evidence { padding: 18px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 92px; }
.research-evidence > div { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.research-evidence code { font-size: 11px; color: var(--muted); }
.research-evidence strong { font-weight: 500; }
.research-evidence small { color: var(--muted); font-size: 11px; }
.research-review-list > li { padding: 20px 0; border-bottom: 1px solid var(--line); }
.research-review-list h3 { margin: 8px 0; }
.research-review-list > li > div { display: flex; gap: 14px; flex-wrap: wrap; }
.research-detail { padding-top: 24px; border-top: 2px solid #dbe8df; }
.research-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.research-company-facts { margin: 24px 0; }
.research-company-facts > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); }
.research-company-facts dt { color: var(--muted); }
.research-company-facts dd { margin: 0; }
.research-footnote { border-top: 1px solid var(--line); padding: 18px 0; }
@media (max-width: 760px) {
.research-layout { grid-template-columns: 1fr; }
.research-layout .reading-main { padding: 26px 24px 0; }
.research-section, .research-evidence { scroll-margin-top: 176px; }
}
@media (max-width: 560px) {
.research-stats { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px 18px; }
.research-categories { grid-template-columns: 1fr; }
.research-tabs { gap: 16px; }
.research-layout .reading-main { padding: 22px 16px 0; }
}
.research-distribution { margin: 24px 0 38px; }
.research-distribution figure { margin: 20px 0; }
.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; }
.research-distribution figcaption { color: var(--muted); font-size: 11px; line-height: 1.7; margin: 10px 0; }
.income-options { display: flex; flex-wrap: wrap; gap: 18px; margin: 12px 0; font-size: 12px; }
.income-options label { display: flex; gap: 6px; align-items: center; }
.research-distribution details { margin: 16px 0; font-size: 12px; line-height: 1.8; }
+257
View File
@@ -0,0 +1,257 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import seed from "../../../content/research/ai-companies.seed.json";
import coverageAudit from "../../../content/research/ai-companies.coverage.json";
import incomeReference from "../../../content/research/ai-income-analysis.json";
import { readResearchSnapshot } from "../src/lib/ai-research-store";
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 { describeIncome, groupIncome, platformIncomeSample } from "../src/lib/research-statistics";
test("research revisions share the authentication database and preserve replay limits", () => {
const dir = mkdtempSync(join(tmpdir(), "site-research-"));
const previous = process.env.AUTH_CONFIG_FILE;
process.env.AUTH_CONFIG_FILE = join(dir, "config.json");
try {
initializeStore();
const config = newAuthConfig();
for (let i = 0; i < 20; i++) consumeCode(config, "invalid");
assert.equal(consumeCode(config, "invalid"), "limited");
const initial = readResearchSnapshot();
assert.equal(initial.companies.length, 1015);
assert.equal(initial.evidence.length, 1091);
assert.equal(initial.observations.length, 183);
assert.deepEqual(readResearchSnapshot(), initial, "reopening does not duplicate records");
const db = openSiteDatabase();
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
assert.ok(tables.some((r) => r.name === "login_state"));
assert.ok(tables.some((r) => r.name === "research_companies"));
assert.equal(db.prepare("PRAGMA foreign_keys").get()?.foreign_keys, 1);
db.close();
const next = structuredClone(seed) as ResearchSnapshot;
next.version += "-revision-test";
next.companies[0].name = "Revised company";
assert.equal(readResearchSnapshot(next).companies[0].name, "Revised company");
assert.deepEqual(readResearchSnapshot(), initial, "old release still reads its original revision");
assert.equal(consumeCode(config, "invalid"), "limited", "research import must not reset auth limits");
const collision = structuredClone(seed) as ResearchSnapshot;
collision.companies[0].name = "Changed without revision";
assert.throws(() => readResearchSnapshot(collision), /new revision/);
assert.deepEqual(readResearchSnapshot(), initial, "failed import leaves valid revision intact");
} finally {
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
else process.env.AUTH_CONFIG_FILE = previous;
rmSync(dir, { recursive: true, force: true });
}
});
test("research validation rejects broken evidence and unsafe source links", () => {
validateResearch(seed as ResearchSnapshot);
const invalid = structuredClone(seed) as ResearchSnapshot;
invalid.observations[0].evidence = "missing";
assert.throws(() => validateResearch(invalid), /observation/);
assert.equal(evidenceUrl("javascript:alert(1)"), undefined);
assert.equal(evidenceUrl("/local-link"), undefined);
assert.equal(evidenceUrl("https://example.com/evidence"), "https://example.com/evidence");
});
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, 43);
assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 3);
assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 8);
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")!), []);
const oldReview = structuredClone(snapshot.reviews.find((r) => r.id === "TECH-GRAD-R-SCOPE")!);
delete oldReview.scope;
delete oldReview.companyIds;
assert.deepEqual(reviewCompanyIds(snapshot, oldReview), ["AI051"], "old revisions retain evidence-owner fallback");
review.companyIds = ["unknown-company"];
assert.throws(() => validateResearch(snapshot), /research review/);
review.companyIds = ["AI007", "AI007"];
assert.throws(() => validateResearch(snapshot), /research review/);
review.companyIds = [];
assert.throws(() => validateResearch(snapshot), /research review/);
});
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), 1015);
assert.equal(summary.reduce((n, c) => n + c.self, 0), 26);
assert.equal(summary.reduce((n, c) => n + c.estimate, 0), 1);
assert.equal(summary.reduce((n, c) => n + c.platform, 0), 16);
assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 972);
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 === "公司整体"));
});
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, 7);
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "public-artifact").length, 3);
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/);
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("TECH-GRAD-O-")).every((o) => o.selection === "technical" && o.comparisonGroup === null));
const company = snapshot.companies.find((c) => c.id === "AI061")!;
const review = company.technicalReview!;
review.evidenceIds = ["missing-technical-evidence"];
assert.throws(() => validateResearch(snapshot), /technical review/);
review.evidenceIds = ["TECH-NAIVE-HF"];
review.reviewedAt = "2026-02-30";
assert.throws(() => validateResearch(snapshot), /technical review/);
review.reviewedAt = "2099-01-01";
assert.throws(() => validateResearch(snapshot), /technical review/);
review.reviewedAt = snapshot.asOf;
review.level = "independent-test";
assert.throws(() => validateResearch(snapshot), /technical review/,
"an accessible artifact cannot become a test record without a stated test scope");
});
test("ambiguous ARR claims retain currency, precision and attribution limits", () => {
const snapshot = seed as ResearchSnapshot;
const variant = snapshot.observations.filter((o) => o.company === "AI066");
assert.deepEqual(variant.filter((o) => o.selection === "crosscheck").map((o) => o.value).sort(), [500000, 600000]);
assert.equal(variant.filter((o) => o.selection === "signal").length, 0);
assert.ok(variant.filter((o) => [2500000, 40000000].includes(o.value)).every((o) => o.selection === "excluded"));
const codos = snapshot.observations.find((o) => o.id === "SR-O-AI065-arr")!;
assert.equal(codos.bound, "≥");
assert.equal(codos.unit, "币种未注明");
assert.equal(codos.comparisonGroup, null);
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("SR-O-")).every((o) => o.comparisonGroup === null && o.unit !== "USD"));
assert.equal(snapshot.companies.find((c) => c.id === "AI071")?.year, 2021);
const rocket = snapshot.observations.find((o) => o.id === "ROCKET-O-arr")!;
assert.equal(rocket.selection, "historical");
assert.equal(rocket.comparisonGroup, null);
assert.notEqual(rocket.scope, "公司整体");
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");
});
// Retractions and forecasts must stay visible for review without becoming revenue signals.
test("retracted ARR and projected margins remain excluded", () => {
const snapshot = seed as ResearchSnapshot;
const cluely = snapshot.companies.find((c) => c.name === "Cluely")!;
assert.equal(cluely.revenueStatus, "unknown");
assert.ok(snapshot.reviews.some((r) => r.company === "Cluely" && r.evidenceIds.includes("E100")));
const retracted = snapshot.observations.filter((o) => o.company === cluely.id);
assert.ok(retracted.length > 0);
assert.ok(retracted.every((o) => o.selection === "excluded"));
const forecast = snapshot.observations.find((o) => o.kind === "非GAAP毛利率预期")!;
assert.equal(forecast.selection, "excluded");
const peec = snapshot.companies.find((c) => c.name === "Peec AI")!;
assert.equal(peec.teamBound, ">");
assert.equal(snapshot.observations.filter((o) => o.company === peec.id && o.selection === "signal").length, 1);
assert.ok(snapshot.observations.some((o) => o.company === peec.id && o.selection === "historical-estimate"));
});
test("directory coverage reconciles every candidate and keeps profile-only income unknown", () => {
const snapshot = seed as ResearchSnapshot;
const coverage = snapshot.coverage!;
assert.equal(coverageAudit.records.length, coverage.candidateRecords);
assert.equal(new Set(coverageAudit.records.map((r) => r.ycId)).size, coverage.candidateRecords);
const count = (status: string) => coverageAudit.records.filter((r) => r.status === status).length;
assert.equal(count("imported"), coverage.newProfiles);
assert.equal(count("existing"), coverage.existingProfiles);
assert.equal(count("date-conflict"), coverage.dateConflicts);
assert.equal(count("year-outside-or-missing"), coverage.otherOrMissingYear);
assert.equal(count("ai-unclear"), coverage.unclearAI);
for (const row of coverageAudit.records.filter((r) => r.status === "imported")) {
const company = snapshot.companies.find((c) => c.id === `YC${row.ycId}`)!;
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!));
}
const invalid = structuredClone(snapshot);
invalid.coverage!.newProfiles += 1;
assert.throws(() => validateResearch(invalid), /coverage accounting/);
const promoted = snapshot.companies.find((c) => c.id === "YC30215")!;
assert.equal(promoted.profileOnly, false);
assert.equal(promoted.directoryOrigin, "yc-2025-2026");
assert.equal(promoted.revenueStatus, "self");
const lostOrigin = structuredClone(snapshot);
delete lostOrigin.companies.find((c) => c.id === promoted.id)!.directoryOrigin;
assert.throws(() => validateResearch(lostOrigin), /coverage accounting/);
});
test("income descriptions report sample dispersion and maximum-value sensitivity", () => {
const stats = describeIncome([100, 200, 300])!;
assert.equal(stats.mean, 200);
assert.equal(stats.median, 200);
assert.equal(stats.sampleDeviation, 100);
assert.equal(stats.cv, .5);
assert.equal(stats.largestShare, .5);
assert.equal(stats.meanWithoutLargest, 150);
assert.equal(stats.largestRemovalDrop, .25);
assert.equal(describeIncome([5])!.cv, null);
assert.equal(describeIncome([0, 0])!.mean, 0, "observed zero is distinct from missing");
assert.equal(describeIncome([0, 0])!.cv, null);
assert.equal(describeIncome([]), null);
assert.equal(describeIncome([100, NaN]), null);
assert.equal(describeIncome([-1]), null);
assert.ok(describeIncome([1, 2, 100])!.largestRemovalDrop! > .95);
});
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.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.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.ok(older.points.some((p) => p.company.id === "AI016"));
const scaled = platformIncomeSample(snapshot, { windowDays: 7, annualize: true });
const monthlyGroups = groupIncome(recent.points);
const annualGroups = groupIncome(scaled.points);
for (const group of monthlyGroups) {
const annual = annualGroups.find((g) => g.key === group.key)!;
assert.ok(Math.abs(annual.stats.mean - group.stats.mean * 12) < 1e-6);
if (group.stats.cv !== null) assert.ok(Math.abs(annual.stats.cv! - group.stats.cv) < 1e-10);
}
const invalid = structuredClone(snapshot);
invalid.observations = invalid.observations.filter((o) => o.company === "MR001" && o.metric === "mrr");
const first = invalid.observations[0];
invalid.observations.push({ ...first, id: "future", observedAt: "2026-11-01", value: 999999 });
invalid.observations.push({ ...first, id: "bad-date", observedAt: "2026-02-30", value: 999999 });
invalid.observations.push({ ...first, id: "currency", unit: "EUR", value: 999999 });
invalid.observations.push({ ...first, id: "older", observedAt: "2026-10-28", value: 1 });
const selected = platformIncomeSample(invalid, { windowDays: 7 });
assert.equal(selected.points.length, 1);
assert.equal(selected.points[0].observation.id, first.id, "bad newer data and older duplicate must not replace latest valid point");
assert.equal(selected.excluded.length, 4);
assert.throws(() => platformIncomeSample(snapshot, { windowDays: -1 }), /window/);
});
test("distribution statistics match independently calculated snapshot references", () => {
const snapshot = seed as ResearchSnapshot;
assert.equal(incomeReference.sourceVersion, snapshot.version);
for (const reference of incomeReference.windows) {
const sample = platformIncomeSample(snapshot, { windowDays: reference.days });
assert.deepEqual(new Set(sample.points.map((p) => p.company.id)), new Set(reference.products));
const groups = groupIncome(sample.points);
assert.equal(groups.length, reference.groups.length);
for (const expected of reference.groups) {
const actual = groups.find((g) => g.category === expected.category && g.provider === expected.provider && g.model === expected.model)!;
assert.equal(actual.stats.n, expected.n);
for (const metric of ["mean", "median", "sampleDeviation", "cv", "meanWithoutLargest", "largestRemovalDrop"] as const) {
const value = expected[metric];
if (value === null) assert.equal(actual.stats[metric], null);
else assert.ok(Math.abs(actual.stats[metric]! - value) < 1e-6, metric);
}
}
}
});
+226 -21
View File
@@ -1,5 +1,10 @@
{
"collections": [
{
"path": "smalltalk",
"title": "Smalltalk-80 研究导读",
"description": "从消息、类与集合到图形、开发工具和经典 MVC。"
},
{
"path": "pretext",
"title": "Pretext 源码教程",
@@ -17,94 +22,294 @@
}
],
"documents": {
"ai-company-research/00-overview": {
"collection": "AI 公司研究",
"group": "公司与市场",
"description": "按业务分类查看 AI 公司、经营指标与原始证据,检查收入定义与数据缺项。",
"slug": "other-research/ai-company-research/overview",
"category": "其他研究",
"featured": true
},
"00-endor-concepts-draft": {
"group": "架构设计",
"description": "Endor 的全球入口、区域运行时与对象数据。",
"draft": true,
"featured": true
"featured": true,
"slug": "endor/endor-concepts-draft",
"category": "Endor"
},
"01-getting-started": {
"group": "开始使用",
"description": "本地开发、文档编写与发布。"
"description": "本地开发、文档编写与发布。",
"slug": "endor/getting-started",
"category": "Endor"
},
"02-mounting-draft": {
"group": "架构设计",
"description": "节点、对象与统一接入协议。",
"draft": true
"draft": true,
"slug": "endor/mounting-draft",
"category": "Endor"
},
"03-figures": {
"group": "图形档案",
"description": "Python 源码与可编辑 SVG 的制图工作流。"
"description": "Python 源码与可编辑 SVG 的制图工作流。",
"slug": "endor/figures",
"category": "Endor"
},
"04-endor-purpose-draft": {
"group": "项目讨论",
"description": "从程序入口走向用户与 AI 的对象操作。",
"draft": true
"draft": true,
"slug": "endor/endor-purpose-draft",
"category": "Endor"
},
"05-object-graphs-gc": {
"collection": "对象关系与垃圾回收",
"group": "运行时基础",
"description": "从对象引用环到标记清除、分代回收与跨代引用,记录内存管理讨论。"
"description": "从对象引用环到标记清除、分代回收与跨代引用,记录内存管理讨论。",
"slug": "language-and-runtime/object-relations/object-graphs-gc",
"category": "语言与运行时"
},
"06-agent-programming-draft": {
"collection": "Agent 编程",
"group": "研究与提案",
"description": "从指令重解释、组合边界与灵活性取舍,讨论 Agent 编程抽象。",
"draft": true,
"slug": "language-and-runtime/agent-programming/from-instructions-to-composition",
"category": "语言与运行时"
},
"07-composable-judgments-draft": {
"collection": "Agent 编程",
"group": "研究与提案",
"description": "比较 LangGraph 与 DSPy,探索判断对象、依据变化与局部重算。",
"draft": true,
"slug": "language-and-runtime/agent-programming/composable-judgments",
"category": "语言与运行时"
},
"pretext/00-overview": {
"description": "理解高性能文本布局的原理,沿着 12 章教程阅读源码。",
"featured": true
"featured": true,
"slug": "source-studies/pretext/overview",
"category": "源码研究"
},
"language-design/00-overview": {
"description": "从名称与作用域到反射与表示,按 271 项议题逐步讨论语言设计。",
"featured": true
"featured": true,
"slug": "language-and-runtime/language-design/overview",
"category": "语言与运行时"
},
"language-design/02-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/names-and-scope",
"category": "语言与运行时"
},
"language-design/03-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/values-and-types",
"category": "语言与运行时"
},
"language-design/04-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/storage-and-lifetimes",
"category": "语言与运行时"
},
"language-design/05-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/objects-and-members",
"category": "语言与运行时"
},
"language-design/06-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/expressions-and-operators",
"category": "语言与运行时"
},
"language-design/07-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/functions-and-calls",
"category": "语言与运行时"
},
"language-design/08-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/control-flow",
"category": "语言与运行时"
},
"language-design/09-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/collections-and-access",
"category": "语言与运行时"
},
"language-design/10-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/errors-and-resources",
"category": "语言与运行时"
},
"language-design/11-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/modules-and-execution",
"category": "语言与运行时"
},
"language-design/12-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/concurrency-and-async",
"category": "语言与运行时"
},
"language-design/13-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/external-interaction",
"category": "语言与运行时"
},
"language-design/14-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
"draft": true,
"slug": "language-and-runtime/language-design/reflection-and-representation",
"category": "语言与运行时"
},
"pretext/01-chapter": {
"slug": "source-studies/pretext/text-measurement",
"category": "源码研究"
},
"pretext/02-chapter": {
"slug": "source-studies/pretext/prepare-and-layout",
"category": "源码研究"
},
"pretext/03-chapter": {
"slug": "source-studies/pretext/text-segmentation",
"category": "源码研究"
},
"pretext/04-chapter": {
"slug": "source-studies/pretext/measurement-cache",
"category": "源码研究"
},
"pretext/05-chapter": {
"slug": "source-studies/pretext/line-breaking",
"category": "源码研究"
},
"pretext/06-chapter": {
"slug": "source-studies/pretext/height-only-layout",
"category": "源码研究"
},
"pretext/07-chapter": {
"slug": "source-studies/pretext/long-text",
"category": "源码研究"
},
"pretext/08-chapter": {
"slug": "source-studies/pretext/accuracy",
"category": "源码研究"
},
"pretext/09-chapter": {
"slug": "source-studies/pretext/virtual-lists",
"category": "源码研究"
},
"pretext/10-chapter": {
"slug": "source-studies/pretext/performance-verification",
"category": "源码研究"
},
"pretext/11-chapter": {
"slug": "source-studies/pretext/source-reading",
"category": "源码研究"
},
"pretext/12-chapter": {
"slug": "source-studies/pretext/applying-the-design",
"category": "源码研究"
},
"ai-company-research/01-income-snapshot": {
"collection": "AI 公司研究",
"group": "公司与市场",
"description": "查看14个产品的平台MRR分布、均值与离散度、历史点敏感性和逐项证据入口。",
"slug": "other-research/ai-company-research/income-snapshot",
"category": "其他研究"
},
"ai-company-research/02-coverage-and-gaps": {
"collection": "AI 公司研究",
"group": "公司与市场",
"description": "核对候选池、来源偏差、分类与验证完成度,区分全量读取和定向补充。",
"slug": "other-research/ai-company-research/coverage",
"category": "其他研究"
},
"smalltalk/00-overview": {
"slug": "source-studies/smalltalk/overview",
"category": "源码研究",
"group": "阅读地图",
"description": "Smalltalk-80:从消息到交互系统;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/01-messages": {
"slug": "source-studies/smalltalk/messages",
"category": "源码研究",
"group": "语言核心",
"description": "消息与对象:理解执行模型;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/02-syntax-and-blocks": {
"slug": "source-studies/smalltalk/syntax-and-blocks",
"category": "源码研究",
"group": "语言核心",
"description": "语法与块:读懂一段 Smalltalk;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/03-classes-and-methods": {
"slug": "source-studies/smalltalk/classes-and-methods",
"category": "源码研究",
"group": "语言核心",
"description": "类、方法与元类:行为放在哪里;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/04-object-protocol": {
"slug": "source-studies/smalltalk/object-protocol",
"category": "源码研究",
"group": "对象模型",
"description": "对象协议:身份、复制与依赖;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/05-reflection": {
"slug": "source-studies/smalltalk/reflection",
"category": "源码研究",
"group": "对象模型",
"description": "类的架构与反射:系统怎样描述自己;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/06-collections-and-streams": {
"slug": "source-studies/smalltalk/collections-and-streams",
"category": "源码研究",
"group": "类库与执行",
"description": "基础类库:数值、集合与流;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/07-processes": {
"slug": "source-studies/smalltalk/processes",
"category": "源码研究",
"group": "类库与执行",
"description": "进程与同步:协作执行的对象;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/08-graphics": {
"slug": "source-studies/smalltalk/graphics",
"category": "源码研究",
"group": "交互系统",
"description": "图形系统:坐标、位图与绘制;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/09-live-environment": {
"slug": "source-studies/smalltalk/live-environment",
"category": "源码研究",
"group": "交互系统",
"description": "开发环境:浏览、检查与调试;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/10-mvc": {
"slug": "source-studies/smalltalk/mvc",
"category": "源码研究",
"group": "交互系统",
"description": "MVC 与曲线案例:连接数据和界面;依据原书章节整理,区分历史机制与补充说明。"
},
"smalltalk/11-reference": {
"slug": "source-studies/smalltalk/reference",
"category": "源码研究",
"group": "阅读地图",
"description": "术语、练习与原书对照;依据原书章节整理,区分历史机制与补充说明。"
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ pnpm dev
## 添加文档
在 `content/docs` 下添加 Markdown 文件,例如 `04-runtime.md`。文件名使用小写字母、数字和连字符;第一个一级标题是导航标题。该文件对应 `/docs/04-runtime`。
在 `content/docs` 下添加 Markdown 文件,例如 `04-runtime.md`。文件名使用小写字母、数字和连字符;第一个一级标题是导航标题。在 `content/catalog.json` 的 `documents` 中以源文件路径登记 `slug` 和 `category`,例如 `slug: "endor/runtime"`、`category: "Endor"`。公开地址为 `/docs/endor/runtime`;源文件编号仅用于排序,不进入公开 URL。既有源文件地址保留永久跳转,修改已有公开 slug 时需另行保留旧地址映射。
开发模式下刷新页面读取修改;本地生产预览需要重新构建。
+24 -1
View File
@@ -16,4 +16,27 @@ pnpm figures
CI 会重新生成图片并检查与提交版本一致。Python 依赖保留独立缓存,不进入生产运行镜像。
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局、PNG 转换或 TypeScript SVG 工具。
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局或 TypeScript SVG 工具。
## 位图默认发布压缩版本
SVG 保留可编辑矢量格式;漫画、照片等位图的原始 PNG、JPEG 放在 `tools/figures/assets`,不放进 `public`。浏览器只使用生成的 WebP,正文、点击放大和图片适应屏幕都使用同一个压缩地址。
共享策略位于 `content/image-policy.json`:默认最大宽度 1600 像素、WebP 质量 82、每张图最多 192 KiB,不放大小图。修改策略后重新生成并检查公式、细线和小字是否清晰。
以下涉及图像编码与构建产物生成的命令在已确认的远程 CI/CD runner 上执行;未获明确许可时不在用户本机运行。
```sh
pnpm --filter @endor/docs images
pnpm --filter @endor/docs test:images
```
原图相对 `tools/figures/assets` 的路径会映射到 `apps/docs/public/figures` 下同名的 `.webp`。例如 `endor-review-gate.png` 对应 `/figures/endor-review-gate.webp`。文档只嵌入后一个地址,制作原稿不随网页发布。
远程生产构建会运行压缩脚本;CI 先生成 WebP,再重新编码核对产物与原稿、策略是否一致。WebP 是构建派生文件,不必在本机生成或提交;Git 保存原稿、制作记录与共享策略。检查同时拒绝公开目录里的原始位图及 Markdown 图片或 HTML img 直接引用的原始 PNG、JPEG。SVG 无需转成位图,仍按前述流程提交可编辑输出。
## 同一位置切换多张配图
把两张或更多 `/figures/endor-` 图片写在同一段,中间换行但不插入空行,正文会将它们呈现为图组。左右按钮和键盘左右方向键可以切换,下方圆点表示当前图片,也可点击直接选择。图片保留点击放大能力。
图组一次只挂载当前图片,切换后才加载下一张压缩图,不预先下载整组原图。
+50
View File
@@ -16,6 +16,54 @@
这一定位希望形成的体验是:**对象池构成持续可用的工作世界,Agent 成为其中执行工作的主体,Endor 语言让对象和行为能够被表达、检查与组合。** 用户与 AI 因而可以在同一套对象基础上发起、接续和检查工作。这是本文的设计方向,不表示所有系统能力已经实现。
## 让产出成为检查下一次产出的帮手
![Coding Agent 不断产出相同的原始文件:上方待检查文件积累为积分 dW,单人的检查门保持固定能力 C₀;下方 Endor 只把本次新增文件 dW 送到人与检查门前,处理完成的文件变成结构化蓝色帮手,箭头回到检查门,表示历史成果积累为新的检查能力](/figures/endor-review-gate.webp)
![检查台版:上方单人检查台面对积累的待处理文件;下方只检查本次新增文件,已处理文件变成蓝色帮手,向检查台贡献更多检查工具](/figures/endor-review-desk.webp)
两张漫画用检查门与检查台表达同一个目标,可用左右按钮或下方圆点切换。
上方把 Coding Agent 比作一台不断吐出工作结果的机器。机器可以持续产出,但人的时间和精力没有同步增长;等待检查的材料越积越多。虚线圈出的 **∫dW** 表示累积的待处理工作,门上的 **C₀** 表示在这个对比中保持不变的检查能力。通过门、带上勾的文件才表示已处理的结果。
下方的机器吐出同样的原始文件,改变的是 Endor 如何组织和接续这些产出。人和检查门直接面对的是这次新增的 **dW**;已经处理的文件被组织成有类型、来源、关系与可调用能力的对象,成为图里的蓝色帮手。帮手的箭头指向检查门,表达它们为后续检查提供支持,而不是让新生成的结果自己证明自己正确。
例如,已确认的需求可以成为验收条件;稳定的接口约定可以帮助发现不兼容修改;图片发布规则可以检查下一份文档有没有直接嵌入原图。**过去的产出从待处理的负担,逐步变成帮助人处理新工作的能力。** 新结果是否符合要求,仍需要适用的检查逻辑与人的判断。
门上的 **C₀ + k∫dW** 表示我们希望实现的积累效应:在原有能力上,增加由历史成果转化而来的可复用检查能力。这里的积分、微分和系数 k 是设计隐喻,不是性能公式或实测结论;只有经过确认、适合复用并保持有效的成果才能贡献能力,产出数量不等于正确性,实际检查也可能需要重新覆盖受影响的历史对象。
> **设计目标**:这张漫画表达希望建立的工作组织方式,不表示当前 Endor 已能自动把任意文件变成检查工具。要兑现它,需要保存来源和依赖、表达检查规则、执行检查,并把失败与待确认事项呈现给人。
## 从压缩图片想到的继承:让要求成为共同能力
这张漫画也带来一个具体要求:**文档在浏览器中默认使用压缩图,原图留作制作与归档,不因打开正文或放大图片就下载原图。** 如果很多文档都需要这条规则,用户不应该每次写文档时重新提醒一次。
单次 Coding Agent 对话中的“记得压缩图片”,通常首先是一项需要读取、理解和落实的指令。指令可以放进项目约定、技能或模板,也可以让 Agent 写出共享压缩脚本;这些都能减少重复劳动。但一句自然语言要求本身,并没有自动建立“这类文档如何派生、谁沿用默认行为、哪个文档允许覆盖”的类型与行为关系。这里讨论的是这种**可持续、可检查的继承**,不是说 Agent 不能编写使用继承的程序。
### 为什么 Endor 的设计能承载继承
在现有设计中,文档、图片、类型定义、函数和 Agent 都进入同一对象体系。对象具有可引用的身份,类型可以表达对象的结构与能力,函数可以表达处理行为;继承、实例化与代码依赖可以保存为有不同含义的关联。因此,“默认如何发布文档图片”可以被表达为一个可引用的共同定义,以及读取和执行它的行为,而不只是一段随对话流逝的提醒。
沿着这个设计方向,可以把图片发布要求放到共享文档类型或其呈现策略上:普通文档沿用“优先压缩图”的默认行为,需要精确查看像素的专业文档再显式采用另一种策略。具体通过类型继承、策略对象委托还是能力组合实现,需要结合语言规则确定;不是因为对象之间有一个链接,就天然获得了继承。
| 层次 | 压缩图例子中承担的职责 |
| --- | --- |
| 共同定义 | 保存默认图片尺寸、编码质量、字节预算及浏览器应使用的派生版本 |
| 继承或委托关系 | 让采用共同定义的文档持续引用它,明确哪些行为来自共同来源 |
| 实际处理能力 | 根据策略生成压缩版本,保留它与原图的来源关系,并让呈现入口选择压缩版本 |
| 检查与覆盖 | 检查新产出是否符合默认要求;特例显式选择覆盖,并能说明生效的规则来自哪里 |
关键不只是“记住用户偏好”,而是**规则有归属,使用者有关系,行为可以执行,结果能够检查**。当 Agent 新建同类文档时,它使用已有文档能力与策略,就能让之前确认的要求继续参与这次工作;人主要判断本次差异和真正的例外。这也是漫画里历史成果增强检查门的一个具体例子。
定义和依赖是对象,使系统有基础追踪受影响的文档,并通过名称、类型与调用检查发现部分不一致;图片编码与发布仍需实际运行相应能力。“一切皆对象”本身不保证每份文档都调用正确的渲染器,也不自动决定规则修改后是否追溯更新历史文档。
> **语义边界**:继承与委托、覆盖与冲突规则仍在[语言设计问题集的对象模型部分](/docs/language-design/05-questions)讨论中。父定义更新如何生效、文档是否绑定特定版本、多来源冲突如何处理,需要明确后才能成为稳定承诺。这里解释 Endor 为什么有承载这类继承的共同基础,以及希望形成的体验,不宣称这些规则已全部实现。
### 本站现在怎样落实这条规则
Tabularium 目前用共享构建策略落实这项要求:原图保存在非公开的制作目录,脚本统一生成有尺寸、质量与字节预算的 WebP,文档正文和放大查看都使用这个压缩版本。新增文档直接引用生成的图片,不需要逐篇重新实现压缩;构建检查会拒绝公开原始位图或直接嵌入原始 PNG、JPEG。
这是今天在文档仓库里可以执行和验证的共享规则,具体工作流见[图形与脚本](/docs/03-figures)。它示范了“把一次提醒变成后续工作可复用的能力”,并不表示 Tabularium 这个 Next.js 网站已经运行了 Endor 的继承机制。
## 一对图:我们希望改变什么
![从程序入口到对象操作的对比:上图中 OS 分出九个进程,按二、四、三个分为三组,对应三个程序;对象以进程内的随机小圆圈表示,用户通过程序入口访问。下图中 OS 只分出少量进程,承载 Endorphin 的对象关系网络,用户与 AI 用多条贝塞尔曲线直接指向选定对象;OS、进程区与 Endorphin 都位于 Endor OS 边界内](/figures/endor-purpose.svg)
@@ -284,4 +332,6 @@ Endor 也不应仅凭对象之间可连接,就宣称用户可以无损迁移
这一版先把程序入口、对象、操作权、AI 与语言之间的关系串起来。后续可以用真实工作流修订这些描述,进一步确定对象身份、执行与挂载的语义。
[从给 AI 写教程,到组合 AI 能力](/docs/language-and-runtime/agent-programming/from-instructions-to-composition)进一步记录指令重解释、组合边界与灵活性的三个问题;[LangGraph、DSPy 与可接续的判断对象](/docs/language-and-runtime/agent-programming/composable-judgments)比较现有探索,并提出依据变化后复核与局部重算的原型方向。两篇均为讨论草案,不表示现有语言或运行时已经实现。
[系统架构](/docs/00-endor-concepts-draft)讨论部署与存储,[节点与对象挂载](/docs/02-mounting-draft)讨论运行位置和挂载边界;本文关注它们为何能给用户带来值得验证的变化。
@@ -0,0 +1,83 @@
# 从给 AI 写教程,到组合 AI 能力
记录日期:2026 年 10 月 28 日。本文保存关于 Agent 编程抽象的讨论,重点是指令的重新解释、能力的组合边界,以及约束与灵活性的取舍。
> **讨论草案**:文中的分析和候选机制尚未成为 Endor 语言规范,也不表示 Aster、Hestia 或其他组件已经实现。对外部项目的事实描述附官方来源;设计判断与后续提案分别说明。
## 我们真正追问的是什么
为什么很多 Agent 扩展仍像是给 AI 的教程?传统编程发展出函数、对象、函数式组合和状态管理方法,Agent 是否也应该发展出可以理解、测试和组合的能力单元?
**关键区别在于:代码中的抽象有运行机制支撑,而很多 Agent 抽象的效果,仍然依赖模型理解并遵守一段话。**
函数的局部变量受到作用域规则约束,模块可以控制导出内容,React Hooks 的状态与调用有框架机制支撑。这些机制并不能自动证明业务逻辑正确,却让开发者有明确的执行规则可依赖。
如果在 skill 中写“这段规则只对当前任务有效,不得影响其他任务”,它首先还是一条需要模型遵守的指令。只有宿主系统真正隔离了上下文、存储或权限,才形成相应的执行边界。**把一段话放进文件夹,获得了组织和复用能力,却不等于获得了封装。**
这里不能把 skill 与 MCP 完全等同:Agent Skills 用 Markdown 指导、可选脚本和资源打包任务知识;MCP 还规范工具发现、调用、资源与消息交换,具有真实的协议机制。但协议不替宿主决定模型怎样组织推理。[Agent Skills 规范](https://agentskills.io/specification)、[MCP 架构](https://modelcontextprotocol.io/docs/learn/architecture)
## 第一,结构在每次推理中被重新解释
自然语言把大量原来需要开发者提前定义的步骤,交给模型临时组织。用户说“检查合同,找出对我不利的条款”,模型能够补出搜索、比较、解释等步骤,不必先穷举每个分支。这使教程式扩展具有实际价值:便宜、灵活,也能承载尚未形式化的经验。
**但代价是,结构没有明确保存在程序里,而是在每次推理时由模型重新解释。因此,增加一个 skill,可能改变另一个 skill 的使用方式;换一个模型,原来有效的指令可能表现不同。**
这里说的是仅由自然语言承担的结构。skill 随附脚本、宿主的工具权限和明确的数据流,仍可以由普通代码实现硬边界,不能把整个 Agent 系统都说成没有结构。
需要区分三件事:指导内容是否被正确加载,模型是否按预期理解,执行环境是否强制实施约束。改进第一件事,不自动完成后两件事。
一个值得探索的方向是把“能力的公开输入与输出”“本次可见的数据”“允许产生的外部效果”显式表示。自然语言继续描述意图,系统承担能够检查的边界。模型升级时,对相同任务重新评估,而不是假定文件格式相同就行为兼容。
## 第二,AI 能力还缺少容易验证的组合边界
假设有三个能力:
1. A:查找资料。
2. B:判断资料是否可信。
3. C:写成报告。
把它们串起来很容易。难的是确保:
- B 拒绝的资料不会又被 C 使用。
- C 能区分“原文事实”和“模型推断”。
- A 没找到证据时,后续不会把空缺补成事实。
传统代码也不能自动保证业务正确,但可以控制显式数据流。例如 C 只收到 B 允许传递的证据引用,检索工具也只能读取这一集合。如果三个阶段只是向同一个上下文添加文字,“通过”和“拒绝”可能都只是模型看得到的文本。
**模块之间存在名称上的边界,却未必存在执行上的边界。**
可验证边界不等于“保证模型不知道被拒绝的事情”。模型可能在预训练中已经接触过相关信息,也可能凭一般知识推断。可实现的约束是限制本次输入、检索权限和可接受输出;要求报告中的事实主张指向获准证据,再检查关联是否有效。证据是否真正支持主张,仍需要独立评估或人工判断,不能只检查引用 ID 存在。
还要区分“来源可信”和“支持某个结论”:一份可靠文件可以被错误解读,也可能对另一个问题毫无证明力。审核应该说明对象、版本、问题范围与判断依据。
## 第三,把 AI 约束得足够明确,又可能损失它最有价值的灵活性
把流程完全固定成状态机,可以获得更明确的行为,但也要求开发者提前定义更多场景。反过来,让模型自由决定,就重新依赖它对指令的解释。
这不是无解,而是需要找出:哪些地方应该由系统确定,哪些地方值得留给模型判断。不同任务的答案还不一样,因此很难很快出现一套统一范式。
固定状态机只是取舍的一端。图执行也可以动态分支,模型也可以在受限环境中自主选择路径;不能把所有框架描述成僵硬流程。
一个候选划分是:**把可用材料、权限、预算和结果接纳条件固定下来,把寻找解法的路径留给模型。** 模型可以换检索策略、拆分问题和请求更多材料,但不能通过修改提示词给自己新增写权限,也不能把尚未审核的结果自行标成业务事实。
这仍需要具体任务检验:接纳条件过强可能拒绝有用结果,过弱可能放过错误;某些开放式创作没有唯一验收答案,应保留人工评价和不确定状态。
## 从指导语句到可执行的规则
| 常见指导 | 可以落实的机制 | 仍未解决的部分 |
| --- | --- | --- |
| 不要把资料当成指令 | 分开传递资料与控制配置,资料不能授予工具权限 | 提示注入仍可能影响内容判断,不能只靠分隔符解决 |
| 只记住当前项目 | 按项目划分存储与检索范围,读取时检查身份 | 共享模型本身不因此遗忘已有知识 |
| 交给另一个 Agent 做 | 显式传入对象、权限、预算和返回契约 | 子任务是否理解业务目标仍需评估 |
| 确认以后才能发布 | 发布接口检查有效审批及对应内容版本 | 人的审批是否充分,不能由接口自动证明 |
| 每个结论都要有证据 | 结论关联证据版本并检查引用有效性 | 证据是否充分、结论是否成立仍需判断 |
这些机制有些可以直接用现有语言实现。研究重点应是把它们变成一致、可复用的运行约定,而不是只增加新术语或语法。
## 对 Endor 的意义
Endor 已在设计讨论中把对象池、对象身份和 Agent 任务联系起来。这为表达证据、判断和结果之间的关系提供了切入点,但不能从“一切皆对象”推导出隔离、授权、持久化和业务正确性已经成立。
下一步可以讨论:一个判断是否应成为关联输入版本、检查记录与适用范围的对象?依据变化时,下游工作怎样知道需要复核?同一能力更换模型后,哪些行为需要重新验证?
更深入的外部方案比较、候选机制与原型验收,见[LangGraph、DSPy 与可接续的判断对象](/docs/language-and-runtime/agent-programming/composable-judgments)。项目整体定位见[Endor 的意义](/docs/endor/endor-purpose-draft)。
@@ -0,0 +1,180 @@
# LangGraph、DSPy 与可接续的判断对象
记录日期:2026 年 10 月 28 日。本文从[Agent 编程的三个问题](/docs/language-and-runtime/agent-programming/from-instructions-to-composition)继续讨论:已有探索为什么有价值,边界在哪里,以及 Endor 可以验证什么。
> **研究与设计草案**:官方机制、本文的分析和候选设计在各节中区分。下文没有确认新语言语法、对象内置类型或权限规范;没有修改现有 271 项语言设计议题的确认状态,也没有实现运行时功能。资料按本文日期查阅,具体 API 会演进。
## 两条路线分别解决了什么
LangGraph 把状态、节点和转移显式组织起来;DSPy 把模型任务表示为签名和模块,并根据指标优化程序。它们分别使执行过程更可管理、模型行为更可测量。它们也可以组合使用,并不是相互排斥的完整替代品。
| 维度 | LangGraph | DSPy | 本文进一步提出的问题 |
| --- | --- | --- | --- |
| 组织单位 | 状态、节点、边与子图 | 任务签名、模块与模型程序 | 一项判断能否跨流程保留身份、依据与适用范围 |
| 关键进步 | 显式组织路径、状态与恢复 | 分离任务声明、调用策略与优化 | 如何接纳、复用及作废一个结果 |
| 可获得的检查 | 图结构、状态约束、应用代码检查 | 输出结构、约束验证、数据集上的指标 | 证据版本、权限、业务条件与变化后的复核 |
| 不会自动成立的事情 | 节点执行过不代表业务判断正确 | 得分提高不代表每次都满足真实需求 | 有来源也不代表内容真实或推理正确 |
## LangGraph 为什么好
### 从聊天历史走向明确的执行状态
官方 Graph API 把工作流组织为 State、Nodes 和 Edges,并用 reducer 规定更新怎样合入状态。它还支持输入、输出及内部状态的不同组织方式。这让开发者可以明确决定数据传到哪里,而不必把所有东西拼接成一段聊天。[Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api)
其价值是把一部分程序结构从模型的临时理解中拿回来:例如审核节点产生的允许集合,由普通代码选择后再提供给写作节点。
### 让中断与长期工作可以被管理
checkpointer 保存线程的图状态,store 保存跨线程数据;持久化实现的选择决定重启后是否还能接续。interrupt 支持等待外部输入,恢复时可能重新执行节点开头的代码,因此调用者必须处理重入和副作用。[Persistence](https://docs.langchain.com/oss/python/langgraph/persistence)、[Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts)
这是真实的执行能力,不只是“请记住进度”的指导。它让人可以在工作中间介入,而不必完全重开任务。
### 保留动态决策空间
LangGraph 有条件转移、动态路由和子图,节点里也可以包含开放式 Agent。不能把它批评成“只能预先画死全部步骤”。其强项在于让确定性逻辑和模型判断出现在一个可组织的执行过程里。[Graph API 的 Command](https://docs.langchain.com/oss/python/langgraph/graph-api#command)
## LangGraph 为什么还没有回答完这个问题
以下是对职责边界的分析,并不是说 LangGraph 无法用应用代码实现这些能力。
**流程状态不自动等于业务世界的有效状态。** checkpoint 可以保存“审核完成”,但被审核文件之后可能已经修改,或者用户已经撤回了授权。恢复执行时是否重读、复核和拒绝过期结果,需要应用定义。
**状态结构不自动等于信息安全边界。** 节点输入过滤有实际价值,但不能仅凭字段叫 private 就假设它不会进入日志、流输出或其他路径;数据访问、工具访问和输出通道仍需检查。一个图也可以做严格隔离,只是隔离不能由图形名称推导。
**检查节点不能天然证明上游结论。** 模型审核也可能犯错,与生成者共享偏差。增加一个 reviewer 节点是一种策略,实际效果要用独立样例和人工核验评估。
**跨任务的判断复用需要共同约定。** 两张图都产生“供应商通过审核”,是否针对同一公司、同一资料版本、同一政策和时间范围?这些业务关系可以加入 state 和数据库,但各应用往往还需自行定义。
因此,应继续利用图的执行能力,同时把跨流程可用的结果关系设计清楚。新做一个流程编辑器,未必解决这些问题。
## DSPy 为什么好
### 把任务声明与求解策略分开
Signature 声明输入、输出及任务意图;Module 组织调用策略。同一任务可以更换求解方式而保留接口。这让“我们要什么结果”和“模型如何尝试完成”更容易分别修改。[Signatures in depth](https://dspy.ai/3.4.1/diving-deeper/signatures-in-depth/)
例如,可以把证据列表到候选结论表示为一个模块,在相同输入集上比较不同策略,而不必重写整个应用。
### 让改进从手调措辞走向可测量的优化
DSPy 的优化器接受程序、指标和样例,根据反馈调整指令、示例,某些优化路径还涉及模型权重。这让提示词成为可优化的实现,而不是只能凭直觉维护的字符串。[DSPy 优化器官方文档源码](https://github.com/stanfordnlp/dspy/blob/main/docs/docs/learn/optimization/optimizers.md)
真正值得借鉴的是对“好不好”的显式追问:必须给出数据和评估方法,才能比较优化前后的程序。
## DSPy 为什么还没有回答完这个问题
**结构类型和业务成立条件不同。** 输出符合字段与类型要求,不说明结论受证据支持。“供应商可靠”可以是合法字符串,却没有包含可靠性的范围、依据和时效。增加业务字段有帮助,字段内容仍需要检验。
**指标是目标的可测量代理。** 如果只奖励引用数量,系统可能更愿意多加引用,而没有提高引用的支持程度。需要分别评估正确性、覆盖、证据支持、拒答、成本和耗时,并使用独立保留样本,避免只追逐一个总分。
**离线优化与在线条件之间存在距离。** 真实对象会变化,工具可能失败,权限可能收回,用户目标也会调整。验证集上的改进不能自动替代每次行动前的状态与授权检查。
**换模型后的可替换性仍需验证。** 接口兼容不等于行为兼容;优化产物需要连同模型配置、数据集与评估记录版本化。更强模型可能减少某些提示策略的必要性,也可能改变结果分布。
这些并不削弱 DSPy 的价值。它提供了进行比较和优化的机制;把它当作业务真理的证明器,才是越过了其能力边界。
## 我们可以补上的一层:让判断成为可接续的对象
候选方向是让 Agent 产出的判断,成为后续程序可以检查、组合和更新的工作对象。重点不在新的提示词容器,而在输出被其他任务使用时仍保留必要关系。
一项判断可以包含以下信息。字段仅用于原型讨论,不是已确认的内置类型。
| 信息 | 例子 | 能检查什么 |
| --- | --- | --- |
| 主张与范围 | 供应商 A 满足本次采购的材料要求 | 区分不同问题,避免把局部结论扩成全面认可 |
| 输入对象及版本 | 营业文件 v3、报价单 v8 | 判断产生时实际使用了哪些材料 |
| 证据关联 | 某项要求对应文件的哪些位置 | 引用是否存在,是否为允许使用的材料 |
| 判断性质 | 原文摘录、计算结果、推断、人工确认 | 不把不同可靠性来源压成一个字符串 |
| 生成与检查记录 | 模型配置、程序版本、检查规则与结果 | 复核、模型替换和实验追溯 |
| 适用条件 | 政策版本、对象版本、复核期限 | 条件变化时识别需要重新检查的结果 |
| 后续使用关系 | 报告段落、采购建议、候选任务 | 沿已记录依赖定位可能受影响的工作 |
这里的“可接纳”始终相对于明确政策与检查过程,不命名为无条件的“已证实事实”。模型自报的置信度也不能当作校准后的正确率。
### 把研究、提案与真实修改分开
模型可以自主探索、重试和比较方案,但先在隔离的候选工作区里产生结果与变更意图。准备提交时,由运行环境检查材料版本、当前授权、接纳条件和影响范围,再实际调用修改能力。
候选工作区可以采用写时复制、事件记录或暂存对象实现,选型尚未确定。它不是整个互联网的快照,也不能回滚已经发出的邮件或外部付款。外部读取可能计费或写日志,也应纳入效果清单;无法模拟的动作需要单独定义提交方式。
## 一个更有吸引力的体验:修改依据,工作能够局部更新
把前文的 A 查资料、B 审核、C 写报告,继续推进到一个用户可感知的场景:
1. AI 用三份资料形成报告,每个需要依据的结论关联具体来源版本。
2. 用户发现第二份资料有误,将其从本次允许集合移除。
3. 系统沿记录的依赖标出相关结论、报告段落和待提交任务;旧结果保留为历史,但不再作为当前有效依据。
4. Agent 可以寻找替代证据、缩小结论或报告材料不足;与这份资料无关且仍满足条件的结果可以复用。
5. 用户看到需要复核的内容、新旧结论和候选变更,确认后再更新正式对象。
**用户感知到的变化是:改一份依据,就能知道哪些判断需要复核,并接着完成这些工作。** 对开发者而言,可复用单元从一段生成文本,变成带输入、条件和检查记录的判断过程。
这借用了电子表格重算、增量构建、数据血缘和版本控制的成熟思想,不声称发明了依赖跟踪。需要验证的新价值是:这些关系能否在用户、模型与不同流程之间共同使用,减少每个应用手写一套更新逻辑的成本。
### 必须承认的限制
- 系统只能沿已记录的依赖传播失效,不能自动理解并发现全部语义影响。模型偷偷使用了未记录输入时,依赖图会不完整;原型应限制数据入口并记录读取,必要时保守地复核整个产物。
- “读取过”不等于“真正依赖”。粗粒度跟踪可能重算过多,细粒度声明可能漏记,需要衡量漏失效与过度失效。
- 版本变化只说明旧条件不再完全匹配,不代表旧结论必然错误。应标为待复核,而不是宣称系统已发现事实错误。
- 重算不保证得到相同结果。回放历史记录与重新调用模型是两回事,外部服务和模型行为也可能变化。
- 本地候选分支可丢弃,不等于全部业务效果可撤销。跨系统提交需要各自支持版本检查、幂等或补偿,否则必须暴露部分完成状态。
## 怎样形成真正的组合边界
下面的概念接口只表达职责,不是 Endor 已有或拟定语法,也不是可运行代码。
```text
查找(问题, 允许的数据源) -> 资料引用与版本 | 材料不足
审核(资料, 问题, 审核政策) -> 本次允许的证据集合 | 需要人工判断
生成(问题, 允许的证据集合) -> 候选结论与证据关联
接纳(候选结论, 检查政策) -> 接纳记录 | 拒绝原因 | 无法确定
准备(接纳记录, 目标对象版本) -> 候选变更
提交(候选变更, 当前授权, 版本前置条件) -> 实际效果记录 | 冲突 | 部分完成
```
这些阶段不要求固定成六次模型调用;查找与生成可以动态循环,审核也可以由确定性规则、模型和人工共同完成。受限的是数据与效果边界,解题路径仍可自主选择。
“接纳记录”不能只是让模型在 JSON 中填写 `accepted: true`。原型可让受信任的检查服务生成记录,并在每次使用时验证记录所属项目、对象版本、政策与有效期。它能证明检查过程发生过,不能证明自然语言结论永远正确。权限应沿用项目既有授权体系,不能通过候选结果自动扩大。
## 与 Endor 对象模型怎样衔接
这项提案与[Endor 的对象操作定位](/docs/endor/endor-purpose-draft)有连接点:材料、判断、候选变更、任务可以保留独立身份和关系,而不只存在于某个聊天上下文或图执行记录中。
候选职责划分如下,仍需逐项设计和确认:
| 层次 | 候选职责 | 不应声称自动完成的事情 |
| --- | --- | --- |
| 语言与类型 | 表达输入、结果分支、对象引用和可调用能力 | 仅靠类型名证明自然语言主张 |
| 对象与存储 | 保存身份、版本、关系和历史 | 自动发现所有语义依赖 |
| 运行环境 | 限制效果、检查当前授权与提交前置条件 | 跨任意外部系统提供统一事务 |
| 模型与模块 | 解释目标、探索方案、生成候选判断 | 自行授予权限或给自己签发可信结论 |
| 检查与人 | 定义接纳条件、核验内容、处理不能确定的情况 | 用一次总分覆盖全部业务风险 |
先用现有语言和接口验证这些机制,再决定哪些值得进入 Endor 语言。不要先把假设写成语法,再用语法存在证明系统有价值。
## 最小原型与公平比较
第一版只选“资料到报告,依据撤回后继续修订”这一条工作流。使用有限的版本化资料集和一个可检查版本的报告存储,不接真实付款或邮件,也不扩展成万能 Agent 平台。
对照组采用认真实现的 LangGraph + DSPy 应用,允许它使用显式输入过滤、持久化、同样的检查器及自定义依赖记录;实验组使用相同模型、材料、预算和检查政策,增加可复用的判断对象与依赖处理层。对照组也能实现相同行为,比较重点是重复代码、集成工作量和变更后的可靠性,而不是把对照组故意降成一个长提示词。
| 实验 | 需要观察的结果 |
| --- | --- |
| 撤回一份材料 | 下游是否还有引用被撤回依据的当前有效结果;受影响项是否漏标 |
| 修改材料中的一个事实 | 哪些产物需要复核,哪些可以复用;是否产生过度失效 |
| 模型替换 | 固定任务上的质量、拒答、成本与耗时怎样变化;既有检查能否复用 |
| 中断与重试 | 是否继续正确任务,重复提交是否被目标端拒绝或去重 |
| 提交前版本变化 | 是否报告冲突或重新验证,避免覆盖用户新修改 |
| 收回写权限 | 接续任务是否重新检查并拒绝写入;历史审批不能覆盖当前授权 |
| 来源不支持结论 | 结构检查能否通过,独立内容审核能否识别错误;如实记录漏检 |
| 增加第二个工作流 | 需要重复编写多少字段、失效传播、权限和接纳逻辑 |
先保存基线数据,再预先确定可接受门槛和独立评估集,不凭最顺利的演示设定标准。分别记录内容质量、来源支持、失效覆盖、过度重算、人工介入、模型与工具成本、延迟和开发工作量;不要把它们合成一个无法解释的“智能分数”。
原型如果不能降低第二条工作流的实现成本,或只是增加很多对象却没有减少错误和复核负担,就说明这层抽象还没有证明价值。如果 LangGraph + DSPy 的直接实现已经足够清晰,则优先沉淀薄库和约定,不必新造完整运行时。
## 当前建议
**保留 LangGraph 对执行的组织能力、DSPy 对模型程序的优化能力;探索让判断的依据、适用条件和后续影响成为共同的对象关系。**
最值得做的演示不是更多 Agent 同时对话,而是用户修改一份依据后,系统解释哪些工作受到影响、哪些仍可复用,并让 AI 在受约束的空间里继续完成修订。这项体验能否用更少的应用专用代码实现,才是继续投入的依据。
@@ -0,0 +1,64 @@
# AI 公司研究
调查 2025—2026 年不同类型 AI 公司的业务、交付能力、规模与商业模式。分类表链接公司数据,公司数据链接经营指标与原始证据。
以下工作台读取研究数据库。首批为公开资料定向试样,成立、公开发布和转型发布分别记录。未披露收入的公司继续保留,避免把收入资料缺失误当成零收入。
[收入样本分布快照](/docs/other-research/ai-company-research/income-snapshot)列出当前平台MRR的分组结果、逐项证据入口和历史样本敏感性。
[资料覆盖与缺项](/docs/other-research/ai-company-research/coverage)区分候选池读取、定向样本、人工复核及尚未核验的字段。
## 商业模式初评:截至 2026-10-30
以下为研究假设,用于设计付费试验,尚不足以给行业排收益名次。规模、交付证据和收入来源必须一起看;目录条目数量不是市场需求,平台样本也存在自愿公开与幸存者偏差。
| 模式 | 工作台可搜索样本 | 当前证据 | 商业试验要测什么 | 主要缺项 |
| --- | --- | --- | --- | --- |
| 企业获客与营销软件 | Gojiberry、Traxy AI、NotFair、ChatSEO | 有平台MRR记录,但同步日期不同 | 有效商机、续费与单客户贡献毛利 | 获客费、客户集中度、退款、留存 |
| 消费者个人应用 | nutrIA、Calyra、Verbi、ABMAXX | 有平台收入与订阅记录;订阅份数不是独立人数 | 渠道成本、后续月份留存、平台抽成后利润 | 取消与退款、月/年套餐结构、重复订阅 |
| 创意软件与制作服务 | Speel.co、Laper、GetEbook AI、White Glove Content | 软件订阅和交付服务必须分组 | 合格交付的计算与人工成本、返工率、重复购买 | 项目收入占比、人工工时、产能 |
| 小企业综合Agent | Runable(AI059) | 公司披露历史ARR里程碑;[官方公告](https://runable.com/blogs/runable-grow) | 限定一个任务,测完整成功率、收费与执行成本 | 当前付费客户、续费、推理成本、人工接管 |
| 行业专业Agent与服务 | LunaBill、Plena Health、Bizmark | 有合同ARR、数量级或ARR自述,日历截止日和交付拆分不完整 | 采购周期、上线工时、失败处理与验收标准 | 合同、经常性与实施费拆分、使用深度 |
| AI辅助工程与研究服务 | Marengo、Harbor | 有首月收入、待交付或多年服务合同;不直接当ARR | 已验收项目毛利、专业人员工时、返工和责任成本 | 合同收入确认、软件/服务拆分、应收账款和交付集中度 |
| 模型、算力与机器人 | Wafer、Induction Labs、Congruent | 可确定产品方向;团队小或融资多不证明单位经济 | 客户真实负载、性能成本、交付与售后成本 | 同口径收入、资本开支、独立性能测试 |
**对我们商业试验的推论:** 先选择能接触真实买家、结果可验收、成本可计量的工作流,比较软件订阅与服务交付的实际毛利,再评估是否扩成综合Agent。当前证据不足以断定某类必然更赚钱;平台数据更适合帮助选择访谈与试验对象。
收入里程碑还需结合成本核查:Runable创始人称毛利为负。[原始采访](https://techcrunch.com/2026/08/26/runable-hits-21m-to-bet-ai-agents-can-go-from-building-businesses-to-growing-them/)
## ARR 分布与财务声明核查
目前可绘制的是按日期窗口、支付平台及收费模式分组的产品 **MRR** 分布。公司自述ARR缺少统一截止日、定义和独立凭证,尚不能形成可靠的行业ARR排名。MRR乘12仅为参考年化;项目收入、融资、近30日收款与ARR分别保存。
| 现象 | 后续核查 | 推断边界 |
| --- | --- | --- |
| 平均值远高于中位数,移除最大值后均值明显下降 | 回查大样本的日期、业务边界和收费模式 | 高离散度不等于造假 |
| ARR与近30日收款差距大 | 查年付、季付、退款、试用、币种及平台计算方法 | 不同指标不应机械相等 |
| 用户很多,付费数缺失 | 取得付费客户、套餐与账期数据,再核ARPA | 不能用注册人数直接反推真实性 |
| 公司、投资方与媒体重复一个数字 | 追到首发声明及独立底账 | 多篇转述不等于独立证据 |
| 声明撤回、口径变更或含并购业务 | 保留历史版本,拆分边界并核对更正原因 | 单个异常无法生成可靠造假概率 |
### 从原文到可比较收入:新增核查实例
以下均为2026-10-30读取的公司自述,读取日不代表收入业务截止日。点击公司可查看分项观测、核查和原始来源。原文仅有美元符号时保留币种待核;未给币种的数字不补成美元。
| 公司 | 原始声明 | 数据表处理 | 对商业模式评估的影响 |
| --- | --- | --- | --- |
| [Risely AI](/docs/other-research/ai-company-research/overview?company=YC30724#research-company-detail) | 上线六周达到8千MRR,原文$ | 保存MRR;无日历截止日,不转换为同期ARR | 高校系统集成还需核实施成本、采购周期与续约 |
| [Hyper](/docs/other-research/ai-company-research/overview?company=YC31548#research-company-detail) | 上线十二天达到1千MRR、采用团队50多 | 币种未列;采用团队不直接当付费客户 | 试点与持续付费需要分开;不直接算每团队收入 |
| [Bizmark](/docs/other-research/ai-company-research/overview?company=YC32200#research-company-detail) | 工厂产品10.8万ARR,原文$ | 保留声明;创始人此前6000万业务收入排除 | 核对软件与定制集成收入、上线成本及客户集中度 |
| [LunaBill](/docs/other-research/ai-company-research/overview?company=YC30997#research-company-detail) | 76.4万contracted ARR,其中42.8万称live revenue,原文$ | 合同承诺、已上线部分分别保存,不相加;后者未擅自补为ARR | 上线转化和已验收用量比总合同承诺更能检验交付;客户追回款项排除 |
| [Plena Health](/docs/other-research/ai-company-research/overview?company=YC31493#research-company-detail) | 七位数committed/contracted ARR | 仅存数量级下界,不填精确100万;八个月/十个月表述保留 | 年度或多年合同需拆上线状态、取消条款和定制实施费 |
| [Brickwise](/docs/other-research/ai-company-research/overview?company=YC30847#research-company-detail) | 销售管线中有超过100万cARR的企业合同,原文$ | 不确认成已签或已实现ARR;融资及前公司业绩排除 | 大合同集中度和签约到上线的转化需要单独核查 |
| [Radley](/docs/other-research/ai-company-research/overview?company=YC33020#research-company-detail) | 收购诊所带来460万年化收入,另有220万合同,原文$ | 收购、交割与并表边界未核,不当纯软件ARR,不把两项相加 | 医疗服务收入需结合医生工时、收购成本与经营毛利,不能直接与SaaS比较 |
| [Misprint](/docs/other-research/ai-company-research/overview?company=YC30302#research-company-detail) | 创始人此前卖卡50万ARR,原文$ | 发布帖明确早于平台构想,排除平台收入 | 卡牌销售额、交易额、平台佣金与软件订阅是不同口径 |
| [Harbor](/docs/other-research/ai-company-research/overview?company=YC31445#research-company-detail) | 未来十二个月18.7万软件合同、月软件收入1.56万、三年193万CRO合同,原文$ | 软件与服务分开,重叠声明不相加;多年合同不均摊为ARR | 从软件扩至服务后,需核项目履约、人员成本和回款周期 |
| [Marengo](/docs/other-research/ai-company-research/overview?company=YC32981#research-company-detail) | 首月7万收入、400万待交付合同,原文$ | 单月收入不自动年化,待交付合同不当确认收入 | 数据中心设计是专业工程服务,需核工程师审核、返工与验收成本 |
| [Tsenta](/docs/other-research/ai-company-research/overview?company=YC31259#research-company-detail) | 三个月达到约300万Annualized,原文$ | 指标没有展开定义,暂不补成ARR | 按申请额度收费,求职结束后的留存与持续付费需单独测量 |
| [Arbital](/docs/other-research/ai-company-research/overview?company=YC32749#research-company-detail) | 累计13.5亿、月约4.35亿交易量,原文$ | 交易量不当收入;没有费率不反推佣金 | 交易量可能包含重复换手,平台抽成、激励和净收入才决定经济性 |
| [Jcode](/docs/other-research/ai-company-research/overview?company=YC32172#research-company-detail) | 每月100万推理支出吞吐,原文$ | 推理支出不当MRR;源码公开与厂商基准分开标记 | 需核工具收费、推理成本承担方和开发者留存 |
| [NebuSec](/docs/other-research/ai-company-research/overview?company=YC32138#research-company-detail) | 40万漏洞奖金,原文$ | 团队与Agent归属、获奖期及收款主体待核,不当ARR | 奖金与安全服务订阅不同;独立漏洞记录及持续合同需分别验证 |
这批记录补充了收入定义差异,尚未增加同日同币种的可比ARR样本。合同承诺、并购业务或履历混入会使领域均值失真;资料有这些差异本身不证明财务造假。
下一轮优先取得统一业务截止日的ARR定义、付费客户变动、留存、收入与现金勾稽、直接成本及可独立复核的来源。缺项留在表内并保持未知。上述核查是研究流程,不是审计意见。
@@ -0,0 +1,64 @@
# AI 公司收入样本分布快照
截至 2026-10-30,7日窗口内有 14 个产品的美元平台MRR可纳入分组描述。创意软件订阅组的样本均值较高、CV也较大;营销软件组与消费者个人应用组的CV接近。各组只有1至5个产品,尚不足以估计行业总体或造假概率。
这是已采集资料的固定快照。[动态工作台](/docs/other-research/ai-company-research/overview)提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是1,015条公司/产品记录的收入普查。
## 7日窗口的分组结果
MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。
| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |
| --- | --- | --- | --- | --- | --- | --- |
| 客户与收入运营 · 软件订阅 | Stripe | 4 | 34,815 | 36,723 | 0.469 | 16.9% |
| 内容与创意 · 消费者订阅 | RevenueCat | 1 | 53,101 | 53,101 | 不计算 | 不计算 |
| 内容与创意 · 内容制作服务 | Stripe | 1 | 62,423 | 62,423 | 不计算 | 不计算 |
| 内容与创意 · 软件订阅 | Stripe | 3 | 41,693 | 49,069 | 0.674 | 28.4% |
| 消费者个人应用 · 消费者订阅 | RevenueCat | 5 | 25,349 | 20,483 | 0.471 | 20.9% |
在至少3个样本的组内,内容创意软件订阅的均值较高,但移除最大样本后的下降也最大。营销软件与个人应用的CV仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。
均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。
## 样本与证据入口
点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。
| 产品 | 成立年 | MRR美元每月 | 最后同步 | 平台 | 连接状态 |
| --- | --- | --- | --- | --- | --- |
| [Boothie](/docs/other-research/ai-company-research/overview?company=MR001#research-company-detail) | 2026 | 53,101 | 2026-10-29 | RevenueCat | 采集时未标过期 |
| [White Glove Content](/docs/other-research/ai-company-research/overview?company=MR002#research-company-detail) | 2026 | 62,423 | 2026-10-29 | Stripe | 采集时未标过期 |
| [Speel.co](/docs/other-research/ai-company-research/overview?company=MR003#research-company-detail) | 2025 | 65,381 | 2026-10-29 | Stripe | 采集时未标过期 |
| [Laper](/docs/other-research/ai-company-research/overview?company=MR004#research-company-detail) | 2025 | 49,069 | 2026-10-29 | Stripe | 采集时未标过期 |
| [GETebook.ai](/docs/other-research/ai-company-research/overview?company=MR005#research-company-detail) | 2025 | 10,629 | 2026-10-29 | Stripe | 采集时未标过期 |
| [TrackAI](/docs/other-research/ai-company-research/overview?company=MR006#research-company-detail) | 2025 | 19,437 | 2026-10-27 | RevenueCat | 已过期的日期快照 |
| [nutrIA(Nutria Inc)](/docs/other-research/ai-company-research/overview?company=MR007#research-company-detail) | 2025 | 18,198 | 2026-10-29 | RevenueCat | 采集时未标过期 |
| [Calyra(平台名Nutria 9e3eaa)](/docs/other-research/ai-company-research/overview?company=MR008#research-company-detail) | 2025 | 46,562 | 2026-10-29 | RevenueCat | 采集时未标过期 |
| [Verbi](/docs/other-research/ai-company-research/overview?company=MR009#research-company-detail) | 2026 | 22,066 | 2026-10-29 | RevenueCat | 采集时未标过期 |
| [AbMaxx](/docs/other-research/ai-company-research/overview?company=MR010#research-company-detail) | 2026 | 20,483 | 2026-10-29 | RevenueCat | 采集时未标过期 |
| [traxy.ai](/docs/other-research/ai-company-research/overview?company=MR011#research-company-detail) | 2026 | 52,438 | 2026-10-29 | Stripe | 采集时未标过期 |
| [NotFair](/docs/other-research/ai-company-research/overview?company=MR012#research-company-detail) | 2026 | 13,376 | 2026-10-29 | Stripe | 采集时未标过期 |
| [Rank Prompt](/docs/other-research/ai-company-research/overview?company=MR015#research-company-detail) | 2025 | 40,112 | 2026-10-29 | Stripe | 采集时未标过期 |
| [ChatSEO](/docs/other-research/ai-company-research/overview?company=MR016#research-company-detail) | 2025 | 33,334 | 2026-10-29 | Stripe | 采集时未标过期 |
## 扩大窗口后的敏感性
营销软件订阅组从7日窗口的4个产品扩大到90日窗口的5个产品,均值从34,815升至112,726美元每月,中位数从36,723变为40,112,CV从0.469升至1.551。
新增点来自[Gojiberry AI](/docs/other-research/ai-company-research/overview?company=AI016#research-company-detail)的8月历史快照,连接已过期。移除该最大值后,均值回到34,815美元,下降69.1%。这是日期与样本构成的敏感性,不能解释为行业风险变化。
## 默认窗口排除的历史记录
| 产品 | MRR美元每月 | 最后同步 | 排除原因 |
| --- | --- | --- | --- |
| [Adspirer](/docs/other-research/ai-company-research/overview?company=MR013#research-company-detail) | 54,684 | 2026-06-29 | 超过7日窗口,保留历史观测 |
| [PROSP](/docs/other-research/ai-company-research/overview?company=MR014#research-company-detail) | 128,005 | 2026-02-05 | 超过7日窗口,保留历史观测 |
| [Gojiberry AI](/docs/other-research/ai-company-research/overview?company=AI016#research-company-detail) | 424,368 | 2026-08-14 | 超过7日窗口,保留历史观测 |
## 对 ARR 与商业模式的含义
MRR乘12只是参考年化。公司声明的ARR、合同管线、近30日收款、一次性额度和服务交付收入仍需分开;支付平台重建指标也不等于财务审计。方法与平台限制见[TrustMRR FAQ](https://trustmrr.com/faq)。
当前更适合选择访谈对象和设计收费试验,不能据最高均值直接决定业务方向。软件订阅优先核留存与推理成本,服务交付优先核人工工时和返工,消费者应用优先核渠道成本、退款和平台抽成。
行业ARR比较仍需补齐同一业务截止日、币种、经常性定义及业务边界。收入日期不完整或同页数值冲突的声明,继续保留在[核查工作台](/docs/other-research/ai-company-research/overview),不补成零值或精确数字。
@@ -0,0 +1,73 @@
# AI 公司资料覆盖与缺项
截至 2026-10-30,研究表含1,015条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。
验收范围已于2026-10-30确认为:以确认的公开来源清单为边界,逐条核对并标明缺项。下方区分已逐条审计的候选池与定向补充来源;已使用某来源不代表该来源已被完整覆盖。
[分类和数据工作台](/docs/other-research/ai-company-research/overview)链接原始证据,[收入样本快照](/docs/other-research/ai-company-research/income-snapshot)展示当前可计算的MRR样本。
## 候选池与公司表的关系
| 环节 | 记录数 | 含义 |
| --- | --- | --- |
| YC批次候选 | 1,428 | 从公开镜像发现,随后读取YC官方页面 |
| 官方页面读取 | 1,428 | 此候选池读取失败0条 |
| 初筛匹配 | 962 | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |
| 与原表重合 | 34 | URL匹配,未再次新增 |
| 新增目录记录 | 928 | 原始入表来源,后续深化不改变发现计数 |
| 年份不符或缺失 | 268 | 此轮不作为成立年匹配项 |
| AI业务待确认 | 192 | 不强行把背景关键词当主营 |
| 成立年与批次冲突 | 6 | 另保留核查记录 |
全表由913条目录基础项和102条个案或平台记录组成。候选池已匹配项之外另有53条记录,包含其他来源及发布/转型对照;其中已有15条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。
## 成立年与证据深度
| 维度 | 当前记录 | 解读 |
| --- | --- | --- |
| 记录为2025年成立 | 619 | 成立来源和精度见公司明细 |
| 记录为2026年成立 | 391 | 不将产品发布日期代替成立日 |
| 成立年未知或范围外 | 5 | 发布/转型对照单列,不进入两年成立分组 |
| 目录业务分类人工复核 | 160 | 仅复核主营分类,不代表技术或财务审计 |
| 其他目录基础项 | 753 | 规则初分或资料不足,继续待复核 |
| 有团队数字 | 945 | 多为自报Team Size或Employees,时点与口径不统一 |
| 专项技术证据记录 | 7 | 其中3条自述、3条公开材料、1条第三方评测 |
| 公司披露收入线索 | 26 | 含历史、下界、币种不明及产品业务声明,不等于可比ARR |
| 仅平台收入线索 | 16 | 同时有公司披露者计入上一行,不重复计数 |
| 仅第三方估算 | 1 | 不与直接披露混合 |
| 暂无可用收入数字 | 972 | 包含尚未逐家搜索的目录项,不代表没有收入 |
## 业务分类与数据入口
| 业务类别 | 记录数 |
| --- | --- |
| [基础模型](/docs/other-research/ai-company-research/overview?category=C01) | 29 |
| [算力与模型基础设施](/docs/other-research/ai-company-research/overview?category=C02) | 102 |
| [Agent 开发与运行设施](/docs/other-research/ai-company-research/overview?category=C03) | 51 |
| [软件开发](/docs/other-research/ai-company-research/overview?category=C04) | 109 |
| [企业通用工作](/docs/other-research/ai-company-research/overview?category=C05) | 176 |
| [客户与收入运营](/docs/other-research/ai-company-research/overview?category=C06) | 52 |
| [行业专业应用](/docs/other-research/ai-company-research/overview?category=C07) | 321 |
| [内容与创意](/docs/other-research/ai-company-research/overview?category=C08) | 45 |
| [消费者个人应用](/docs/other-research/ai-company-research/overview?category=C09) | 46 |
| [机器人及实体产业](/docs/other-research/ai-company-research/overview?category=C10) | 81 |
| [业务待分类](/docs/other-research/ai-company-research/overview?category=C00) | 3 |
## 已使用来源的分布
1,091条证据记录对应1,077个不同URL。不同URL不自动构成独立验证;同一声明可能由公司、投资方和媒体重复转述。
| 来源 | 证据记录 | 不同URL | 用途及限制 |
| --- | --- | --- | --- |
| YC官方目录 | 984 | 975 | 公司自述的成立、团队与业务;英文及加速器样本偏多 |
| TrustMRR | 18 | 18 | 支付平台重建指标及方法;公开者存在选择偏差,非财务审计 |
| a16z Speedrun | 11 | 10 | 投资机构中的公司介绍及收入声明;存在利益关系 |
| TechCrunch | 3 | 3 | 原始采访或报道;转述公司数字不等于查账 |
| Sacra | 3 | 3 | 第三方估算;与直接披露分开 |
| 其他官网、评测及公开资料 | 72 | 68 | 按每条来源限制理解,不能统一当作独立证据 |
## 尚未满足的研究条件
行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。
下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。
+51
View File
@@ -0,0 +1,51 @@
# Smalltalk-80:从消息到交互系统
Smalltalk 把语言、类库和开发工具放进同一个可探索的对象世界。本教程以“消息怎样找到行为”为起点,逐步走到集合、进程、图形与 MVC,帮助你阅读经典系统中的类与方法。
**资料范围**:A. Mével、T. Guéguen 著《Smalltalk-80》,Macmillan Education,1987;英文版编辑 Mario Wolczko,翻译 M. J. Stewart。书中说明以 Xerox Version 2 Virtual Image 为基础。用户提供的扫描 PDF 共 190 页,正文印刷页 1–174 对应 PDF 页 12–185;印刷页 p 的 PDF 页为 p + 11。
这里是按学习任务重组的中文研究导读,覆盖原书 21 章的主要主题,保留章节与页码定位,不是逐页译本或完整 API 手册。补充示例为本文编写,预期值来自语义推演,未在 Smalltalk 镜像运行。本文没有对某个 Smalltalk 实现仓库做逐文件源码核验;“源码研究”是站点分类,阅读原书后仍需进入对应镜像浏览实际方法。
## 选择阅读路线
| 目标 | 路线 | 读完能做什么 |
| --- | --- | --- |
| 初次接触 | 消息 → 语法 → 类与方法 → 集合 | 读懂表达式,分清返回值与接收者 |
| 研究语言机制 | 类与方法 → 对象协议 → 反射 | 追踪查找起点、对象身份和类侧行为 |
| 研究交互系统 | 开发环境 → 图形 → MVC | 追踪一次选择如何改变模型并刷新视图 |
| 对照原书 | 下方映射表 → 各篇来源说明 | 找到对应章、印刷页和扫描页 |
## 教程目录
1. [消息与对象:理解执行模型](/docs/source-studies/smalltalk/messages)
2. [语法与块:读懂一段 Smalltalk](/docs/source-studies/smalltalk/syntax-and-blocks)
3. [类、方法与元类:行为放在哪里](/docs/source-studies/smalltalk/classes-and-methods)
4. [对象协议:身份、复制与依赖](/docs/source-studies/smalltalk/object-protocol)
5. [类的架构与反射:系统怎样描述自己](/docs/source-studies/smalltalk/reflection)
6. [基础类库:数值、集合与流](/docs/source-studies/smalltalk/collections-and-streams)
7. [进程与同步:协作执行的对象](/docs/source-studies/smalltalk/processes)
8. [图形系统:坐标、位图与绘制](/docs/source-studies/smalltalk/graphics)
9. [开发环境:浏览、检查与调试](/docs/source-studies/smalltalk/live-environment)
10. [MVC 与曲线案例:连接数据和界面](/docs/source-studies/smalltalk/mvc)
11. [术语、练习与原书对照](/docs/source-studies/smalltalk/reference)
## 原书与新结构
| 原章 | 印刷页 | PDF 页 | 本教程 |
| --- | --- | --- | --- |
| 1 Introduction;2 Principles | 1–8 | 12–19 | 消息与对象 |
| 3 Classes;5 Methods | 9–12;21–25 | 20–23;32–36 | 类、方法与元类 |
| 4 Syntax | 13–20 | 24–31 | 语法与块 |
| 7 Object | 31–39 | 42–50 | 对象协议 |
| 8 Architecture of Classes | 40–52 | 51–63 | 类的架构与反射 |
| 9 Magnitude;10 Numeric;11 Collection;12 Stream | 53–90 | 64–101 | 基础类库 |
| 13 Processes | 91–95 | 102–106 | 进程与同步 |
| 14 Point/Rectangle;15 Graphics | 96–117 | 107–128 | 图形系统 |
| 6 Interface;16–19 Windows/Browsers | 26–30;118–149 | 37–41;129–160 | 开发环境 |
| 20 User Events;21 MVC | 150–174 | 161–185 | MVC 与曲线案例 |
## 阅读约定
代码使用 `:=` 表示赋值、`^` 表示方法返回,替代扫描版的箭头符号;字符串用单引号,注释用双引号。`smalltalk` 代码块可复制,但页面不执行它们。历史类名与选择子保留原样,所有平台相关操作均以原书镜像为范围。
“现代化”体现在清晰的阅读路线、可搜索文本、代码块、对照表、语义链接与练习。本文不把 1987 年的鼠标操作、字符集、进程调度或 MVC API 当作所有今天实现的共同规范。
+53
View File
@@ -0,0 +1,53 @@
# 消息与对象:理解执行模型
> 原书依据:第 1–2 章,印刷页 1–8,PDF 页 12–19。
Smalltalk 的基本动作是:向一个接收者发送消息,由接收者的类决定执行哪个方法。数字、字符串、集合、类和块都能成为接收者。程序由相互发送消息的对象组成。
## 把表达式拆成三个部分
```smalltalk
8 + 5
'hello' size
#(10 20 30) at: 2
```
| 表达式 | 接收者 | 选择子 | 参数 | 推演结果 |
| --- | --- | --- | --- | --- |
| `8 + 5` | 整数 8 | `+` | 5 | 13 |
| `'hello' size` | 字符串 | `size` | 无 | 5 |
| `#(10 20 30) at: 2` | 数组 | `at:` | 2 | 20 |
消息是请求;方法是为响应请求而执行的实现。选择子标识这次请求的名称。不同类可以实现同一个选择子,因此同一条消息能够触发不同的行为。
## 变量保存引用
```smalltalk
| first second |
first := OrderedCollection new.
second := first.
first add: 8.
second size
```
预期最后得到 1:两个变量指向同一个集合。赋值没有自动复制集合。理解引用关系,是之后理解浅复制、模型共享和对象检查器的基础。
对象的状态与它支持的消息要分开看。调用方通过协议与对象协作;读取某个内部槽位的调试能力,不等于应用程序应依赖那个槽位。
## 表达式可以继续发送消息
```smalltalk
(8 + 5) printString
```
先得到 13,再向结果发送 `printString`。与之不同,`collection add: item` 的结果不一定是集合;链式发送前必须确认方法返回值。[语法篇](/docs/source-studies/smalltalk/syntax-and-blocks)解释如何用级联表达“始终向原接收者发送”。
## 镜像为什么重要
原书描述的 virtual image 是包含对象和类的系统世界。开发时可以浏览类、编译方法、检查运行对象并继续执行。语言工具和应用共享对象体系,使重用既有类库成为日常开发方式。
这是历史系统的组织方式;不能据此推断任意镜像的持久化、发布或隔离策略。研究时要同时记录类的定义和当前镜像状态,避免把一次手工修改当成可复现的工程成果。
## 自检
为何两个对象都能接收 `size`,却不需要有相同的数据布局?因为调用方依赖消息协议,各自的类提供方法。下一篇从词法和优先级说明如何读出真正发送的消息。
@@ -0,0 +1,81 @@
# 语法与块:读懂一段 Smalltalk
> 原书依据:第 4 章,印刷页 13–20,PDF 页 24–31。赋值和返回符号按导读约定规范化。
## 先识别字面量
| 写法 | 含义 |
| --- | --- |
| `42`、`16rFF`、`2.5` | 整数、十六进制整数、浮点数 |
| `$A` | 字符 |
| `'Ada'` | 字符串 |
| `'it''s'` | 包含单引号的字符串 |
| `#size`、`#at:put:` | 符号,常用于表示选择子 |
| `#(2 4 6)` | 字面量数组 |
| `[ :x | x + 1 ]` | 接受参数的块 |
`nil`、`true`、`false`、`self`、`super` 是具有特殊语义的伪变量,不能按普通局部变量赋值。不要把字符串 `'size'` 与符号 `#size` 混用。
## 三种消息与优先级
一元消息没有参数;二元消息接受一个参数;关键词消息通过带冒号的片段标出参数位置。`at:put:` 是一个选择子。
优先级为:**括号内表达式 → 一元消息 → 二元消息 → 关键词消息**。同级按从左到右处理,二元选择子没有传统乘除优先的规则。
```smalltalk
2 + 3 * 4 "推演为 20"
2 + (3 * 4) "推演为 14"
10 raisedTo: 2 + 3 "等价于 10 raisedTo: (2 + 3)"
```
`a at: 1 put: 9` 是一条消息。两个关键词调用连续出现时,用括号明确哪一个结果作为另一个的参数。
## 句点与分号
句点分隔表达式;分号让消息继续发给同一个接收者。
```smalltalk
| values |
values := OrderedCollection new.
values add: 4; add: 9; add: 16.
values size
```
这里三次 `add:` 的接收者都是 `values`。若要在一段级联表达式末尾取得集合本身,可发送 `yourself`:
```smalltalk
OrderedCollection new
add: 4;
add: 9;
yourself
```
## 块把计算推迟
```smalltalk
| square |
square := [ :n | n * n ].
square value: 6
```
创建块不执行块体;`value:` 传入参数并求值,预期结果是 36。无参数块接收 `value`,两个参数可用 `value:value:`。
控制流也可以用消息与块组织:
```smalltalk
| count total |
count := 1.
total := 0.
[count <= 4] whileTrue: [
total := total + count.
count := count + 1].
total
```
推演结果为 10。条件块每轮重新求值;把条件提前算成单个布尔对象,会失去更新后的判断。`ifTrue:ifFalse:` 则把两段待执行的分支交给布尔对象选择。
## 返回的边界
块通常返回最后一个表达式的值。方法若没有 `^` 显式返回,则默认返回接收者。块中的 `^` 涉及从其所属方法返回,不能简单理解成普通函数局部 return。本文示例避免把含非局部返回的块保存到方法退出之后;需要这样的用法时,应核对目标镜像的块实现。
练习:`3 + 4 factorial` 先执行哪条消息?先向 4 发送 `factorial`,再向 3 发送 `+`;按数学结果推演为 27。
@@ -0,0 +1,76 @@
# 类、方法与元类:行为放在哪里
> 原书依据:第 3 章和第 5 章,印刷页 9–12、21–25,PDF 页 20–23、32–36。
## 状态的可见范围
| 名称 | 用途 | 阅读时应问 |
| --- | --- | --- |
| 实例变量 | 每个实例自己的状态槽位 | 是否继承自父类? |
| 类变量 | 由类及相关子类共享的绑定 | 是否存在跨实例影响? |
| 全局变量 | 经系统字典访问的绑定 | 是否形成隐含依赖? |
| 池变量 | 多个类共享的变量集合 | 类定义引用了哪个池? |
| 参数、临时变量 | 一次方法或块计算的局部名称 | 生命周期与可赋值规则是什么? |
原书第 5 章明确限制对方法参数再赋值。本教程沿用不修改参数的写法,不把其他实现的语法扩展套回原书。
## 用一个小类串起语法
以下为本文编写的历史风格定义。类定义在 Browser 或相应定义工具中提交;后续方法分别安装在实例侧,不是把整段当作 Workspace 表达式执行。
```smalltalk
Object subclass: #StudyCounter
instanceVariableNames: 'count'
classVariableNames: ''
poolDictionaries: ''
category: 'Study-Examples'
```
实例侧方法:
```smalltalk
initialize
count := 0
```
```smalltalk
increment
count := count + 1
```
```smalltalk
value
^count
```
工作区中的使用示意:
```smalltalk
| counter |
counter := StudyCounter new.
counter initialize.
counter increment.
counter value
```
这里显式初始化,不依赖 `new` 是否自动发送 `initialize`。这些安装步骤和类定义消息需要在目标镜像核对。`increment` 没有显式返回,所以返回 `counter`;`value` 返回槽位保存的数值。
## 方法查找
普通消息从接收者的类开始找选择子,找不到就沿父类向上查找。原书的根类为 `Object`。整个链中没有对应方法时,进入 `doesNotUnderstand:` 协议。
重写方法只改变行为的选择,不把接收者换成父类对象。`self` 始终指向当前接收者。
## super 改变查找起点
假设 B 继承 A,C 继承 B;方法 `describe` 定义在 B 中。如果该方法发送 `super name`,查找从 A 开始,即便当前接收者是 C 的实例。若发送 `self name`,查找从接收者实际的类 C 开始。
因此 `super` 不是“父对象”。它保留同一接收者,按方法定义位置确定查找起点。
## 实例侧与类侧
`StudyCounter new` 的接收者是类对象;`counter increment` 的接收者是它的实例。类自身也是对象,类侧方法由元类组织。
读类侧构造方法时,要检查它究竟分配实例、初始化状态还是返回缓存对象。`new` 只是发给类对象的消息,不能把它视为所有类都必须遵循的固定语法。
原书的 primitive 方法把部分操作交给虚拟机。追踪执行路径时,需区分 Smalltalk 方法体与 VM 提供的原语;本教程没有验证任何 VM 的原语源码。
@@ -0,0 +1,51 @@
# 对象协议:身份、复制与依赖
> 原书依据:第 7 章,印刷页 31–39,PDF 页 42–50。
`Object` 提供许多基础协议。阅读子类时,既要看它新增了什么,也要看它重写了什么。
## 身份与相等
`==` 判断是否为同一对象;`=` 表达由类定义的相等关系。两个内容相同的集合可以是两个不同对象。修改其中一个是否影响另一个,还取决于它们内部引用是否共享。
`class` 返回所属类。`isMemberOf:` 针对确切的类;`isKindOf:` 包含继承关系。检查 `nil` 通常使用 `isNil` 或 `notNil`。面向协议设计时,不必为了发送每条消息都先做具体类判断。
## 浅复制复制的是哪一层
```text
原容器 ──→ 可变子对象
副本 ──→ 同一个可变子对象
```
浅复制产生新的外层对象,内部引用仍可指向相同子对象。给副本的某个槽位重新赋值,与修改该槽位所指子对象,是两种不同操作:前者改变副本自己的引用,后者可能被原对象观察到。
原书还讨论 `deepCopy` 和可被子类重写的 `copy`。深复制不应简单等同于“任意对象图都可以安全递归复制”:循环、共享关系和外部资源需要实现策略。本文没有对这些情况做运行验证。
## 表示与动态发送
| 消息 | 主要意图 |
| --- | --- |
| `printString` | 得到便于人阅读的表示 |
| `storeString` | 得到用于重建对象的源文本表示 |
| `perform:` | 用符号指定要发送的消息 |
| `perform:with:` | 动态发送带一个参数的消息 |
可读表示不等于持久化格式。动态发送也不是解析任意源代码:`perform:` 选择一个消息,而编译器求值会解释完整表达式。
## 用依赖通知连接对象
原书的依赖协议包含 `addDependent:`、`removeDependent:`、`changed:` 和 `update:`。模型改变后通知依赖者,让视图自行读取需要显示的状态。
```text
视图注册到模型
↓
模型状态修改 → changed: 某方面
↓
依赖者收到 update: → 重新读取模型 → 更新显示
```
通知描述“需要更新”,并不天然携带完整的新状态。实现时要明确通知粒度、订阅解除和是否可能形成递归更新。原书的键盘互斥示例说明对象之间可以靠依赖关系协调状态;最后的 MVC 案例继续使用这套机制。
## 阅读检查点
对一个复制操作,画出复制前后的引用箭头;对一次通知,列出谁订阅、谁修改、谁通知、谁重绘。这两张简图往往比逐个记忆方法名更能暴露误解。
+44
View File
@@ -0,0 +1,44 @@
# 类的架构与反射:系统怎样描述自己
> 原书依据:第 8 章,印刷页 40–52,PDF 页 51–63;结合第 3 章元类介绍。
类既组织实例行为,又作为对象接受消息。把“谁是谁的实例”与“谁继承谁”分成两条关系,才能读懂元类。
## 三个层次的责任
| 类 | 原书讨论的主要责任 |
| --- | --- |
| `Behavior` | 方法字典、实例创建和类层级操作 |
| `ClassDescription` | 变量名、分类、源码组织与编译相关描述 |
| `Class` | 表示通常使用的类对象 |
| `Metaclass` | 表示类对象所对应的元类 |
`Class` 和 `Metaclass` 都属于 `ClassDescription` 的子类体系。对于具体类对象,可以用 `class` 访问其元类;实例侧与类侧的方法分别组织在相应行为对象中。
原书元类部分文字较简略。不能据此把所有具体元类都画成直接继承 `Class` 的平面结构;具体元类的父类关系应在目标镜像中检查。
## 研究一次消息查找
```smalltalk
Integer superclass
Number subclasses
Number allSubclasses
```
这是向类对象发消息,查询系统结构。阅读结果时区分“直接子类”和“全部后代”;枚举顺序不应成为应用逻辑依赖。
方法字典把选择子映射到方法。查找协议从当前类的字典开始,再沿 superclass 关系继续。Browser 的实现者查询、类层级浏览和方法安装,都建立在这类可访问的描述结构上。
## 编译是系统服务
原书把方法编译、分类管理、变量名访问和类的文件导出放进类描述协议。由此可以理解:Browser 中接受一段方法文本,是向运行中的系统安装行为。
应分别记录:源文本、编译后方法、当前类结构,以及镜像中的对象状态。只导出源文本,并不一定包含恢复某个实验状态所需的全部数据。
## 多重继承的边界
第 8 章最后讨论多重继承。阅读时将“讨论的组织方案”与前面实际使用的单一父类查找链分开,不能因为书中出现这一节就宣称标准 Smalltalk-80 类定义普遍支持多重继承。
## 可复现的探索记录
在镜像中选择一个类,记录其父类、实例变量、实例侧选择子与类侧选择子;再选择一个继承而来的方法,注明实际定义类。这个小表可以验证你是否把类对象、实例和元类混在了一起。本文只给出检查路线,尚未在镜像执行。
@@ -0,0 +1,58 @@
# 基础类库:数值、集合与流
> 原书依据:第 9–12 章,印刷页 53–90,PDF 页 64–101。
这些章节适合按“要保存什么、如何比较、如何遍历”阅读。具体消息是否存在仍以书中 Version 2 镜像为范围。
## 有序值与数值
`Magnitude` 抽象可比较对象;原书讨论其下的 `Date`、`Time`、`Character` 与数值体系。日期和时间章节的构造协议具有历史性,不能直接当作时区或国际化指南。字符章节基于当时的有限字符集合,不能扩展成 Unicode 结论。
`Number` 的主要子类包含 `Integer`、`Fraction` 和 `Float`。整数层级还区分小整数与大整数。算术消息的结果可以由不同数值类表示,不能仅根据左操作数认定返回类型。
```smalltalk
1 / 3
7 // 2
7 \\ 2
```
在原书数值模型中,第一条可以表示精确分数,后两条分别体现整除和余数协议。这里的余数选择子写作两个反斜杠;使用其他方言时务必核对拼写和负数规则。浮点计算则要区分显示值和实际精度。
## 选择集合
| 集合 | 关注点 | 典型选择理由 |
| --- | --- | --- |
| `Array` | 固定长度、按位置访问 | 已知元素数量 |
| `OrderedCollection` | 保留顺序、可增删 | 动态列表 |
| `SortedCollection` | 根据排序关系组织 | 持续维护顺序 |
| `Set` | 去重 | 关心是否出现 |
| `Bag` | 保留重复次数 | 计数、频次 |
| `Dictionary` | 键到值的映射 | 命名索引 |
| `IdentityDictionary` | 以身份区分键 | 相等值仍需视为不同键 |
| `Interval` | 规则数列 | 数字范围遍历 |
| `LinkedList` | 链式组织 | 研究链节点协议 |
有索引不代表所有集合都使用相同索引语义;数组和序列类的常见索引从 1 开始。
## 四种遍历意图
```smalltalk
#(2 3 4) do: [ :n | n squared ].
#(2 3 4) collect: [ :n | n squared ].
#(2 3 4) select: [ :n | n > 2 ].
#(2 3 4) inject: 0 into: [ :sum :n | sum + n ]
```
`do:` 强调逐个执行,不收集块结果;`collect:` 映射结果;`select:` 保留满足条件的元素;`inject:into:` 累积一个值。依次推演,后三条分别表达平方序列、筛出 3 和 4、求和 9。结果容器类型取决于接收者协议,不能一概写为 Array。
`add:` 的返回值可能是加入的元素;对 Dictionary,`at:put:` 表示设置键值;对固定数组则是替换槽位。共享选择子并不抹平各类的约束。
## 流把位置与内容分开
`ReadStream`、`WriteStream` 和 `ReadWriteStream` 在底层集合之上维护读取或写入位置。`next`、`nextPut:`、`atEnd` 表达逐项读、逐项写和结束判断。`contents` 用于取得流的内容,`position` 与 `reset` 处理位置。
文件流把同一思路扩展到外部文件;原书还介绍 `FileDirectory`。不要把内存流的生命周期直接套给文件:外部资源需关闭,并明确失败与部分写入的处理方式。本文没有运行文件写入示例。
## 练习
对“保留输入顺序并允许重复”“按名称找对象”“统计同一值出现几次”,依次考虑 OrderedCollection、Dictionary、Bag。再问一层:键相等采用什么语义,修改键会不会破坏查找?带着这些问题去浏览实际类的方法,能比背类名得到更多信息。
+35
View File
@@ -0,0 +1,35 @@
# 进程与同步:协作执行的对象
> 原书依据:第 13 章,印刷页 91–95,PDF 页 102–106。
原书的 `Process` 是 Smalltalk 镜像中的执行活动。不能把这个名字直接解释为操作系统进程,也不能据此推断多核并行。
## 四类对象的分工
| 类 | 作用 |
| --- | --- |
| `Process` | 表示可恢复、可挂起的执行活动 |
| `ProcessorScheduler` | 按调度策略管理执行活动 |
| `Delay` | 表示一定时间的等待 |
| `Semaphore` | 协调活动之间的等待与信号 |
块可以作为执行活动的计算内容。是否立即运行、以什么优先级运行,要看创建和启动协议;不要把“创建 Process”和“已经执行完成”混为一谈。
## 等待不是空转
Delay 表达经过一段时间后再继续。Semaphore 的 `wait` 与 `signal` 表达同步关系。等待活动暂停后,其他可运行活动可以继续推进;反复检查共享变量的循环则可能消耗执行时间。
```text
消费者:等待信号 → 检查共享状态 → 取出结果
生产者:准备结果 → 更新共享状态 → 发出信号
```
这是一张概念顺序图,不是可直接运行的队列实现。仅有信号量不代表共享集合访问自动安全,还要定义互斥、空队列、重复信号和退出条件。
## 阅读调度代码的顺序
先找创建与启动位置,再找可能挂起的位置,最后沿恢复路径检查共享状态。重点记录“此处继续执行时,其他活动可能改过什么”。原书的优先级说明属于该系统调度模型,不是跨实现的性能保证。
## 思考题
如果发送信号后才写入结果,消费者恢复时能否看见完整状态?这取决于调度与同步保证;设计时应先完成状态转换,再通过约定的同步协议通知。研究时也要核对目标 VM 的具体行为。本教程未运行并发或调度测试。
+49
View File
@@ -0,0 +1,49 @@
# 图形系统:坐标、位图与绘制
> 原书依据:第 14–15 章,印刷页 96–117,PDF 页 107–128。
图形对象不是外接的独立语言。坐标、矩形、位图、光标和绘图动作都通过消息协议组织。
## Point 与 Rectangle
```smalltalk
12 @ 18
(12 @ 18) x
(12 @ 18) y
```
`@` 是向数字发送的二元消息,产生一个 Point。后两条分别取 x 与 y。因为 `@` 和 `+` 同为二元消息,混用时最好加括号。
Rectangle 表示矩形区域。阅读构造调用时要分清传入的是角点还是尺寸;阅读几何操作时要区分包含、相交、平移和缩放。边界点是否包含应核对具体方法,不能只凭图形直觉。
## 坐标系必须写在图旁
原书图形环境的屏幕坐标通常从左上向右下增加;数学曲线常要求 y 轴向上。因此绘制之前要说明对象坐标与显示坐标之间的转换。
```text
模型坐标 → 视图变换 → 显示坐标 → 裁剪 → 像素绘制
```
相同点在不同视图中可以对应不同屏幕位置。移动视口时只改变显示映射,不应顺便改变领域模型中的坐标。
## 从存储到绘制
| 对象 | 阅读重点 |
| --- | --- |
| `Bitmap` | 像素位信息的底层存储 |
| `DisplayObject` | 可显示对象的公共概念 |
| `DisplayText` | 文本的图形表示 |
| `Form` | 位图图像、尺寸与显示属性 |
| `Cursor` | 光标图形 |
| `DisplayScreen` | 屏幕显示对象 |
| `Path` | 图形路径 |
| `BitBlt` | 源区域到目标区域的位图传输与组合 |
| `Pen` | 以位置和移动描述绘图 |
研究 BitBlt 时记录源、目标、传输区域、裁剪范围和组合规则。它涉及像素组合,不只是语言层面的对象复制。Form 的副本若共享底层位图,修改像素仍可能影响原图;这与对象协议篇的浅复制问题相连。
## 曲线绘制的最小思路
把函数保存为块,对一系列 x 求 y,转换成显示点,再用线段连接。采样步长越大,细节越容易丢失。固定采样不是精度保证;异常值、坐标越界和不连续曲线都需要额外策略。
这是对原书曲线案例的阅读提示,不是新增的绘图运行时。下一篇先介绍如何借助系统工具观察这些对象,再进入 MVC 的连接过程。
@@ -0,0 +1,47 @@
# 开发环境:浏览、检查与调试
> 原书依据:第 6 章、第 16–19 章,印刷页 26–30、118–149,PDF 页 37–41、129–160。
原书使用三键鼠标与特定窗口菜单。现代阅读时,保留动作的目的:求值、查找行为、检查对象、定位调用现场。具体菜单位置和快捷键以使用的镜像为准。
## 从工作区开始
Workspace 是编辑和求值表达式的空间。原书区分执行选中文本的 `do it` 与执行后显示返回值的 `print it`。检查器则让你观察得到的对象。
```smalltalk
OrderedCollection new inspect
```
这段表达式会请求打开对象检查器;它不是网页中的交互按钮。运行前应准备具有相应 GUI 工具的镜像。
## Browser 看行为,Inspector 看状态
| 工具 | 主要问题 | 需要记录 |
| --- | --- | --- |
| Browser | 方法定义在哪里? | 类侧或实例侧、协议分类、定义类 |
| Inspector | 这个对象现在引用了什么? | 对象身份、槽位、子对象 |
| Debugger | 执行为什么到这里? | 调用链、接收者、参数、临时变量 |
| 文件浏览工具 | 源码怎样导入导出? | 文件内容、导入动作与当前镜像变化 |
| Form/Bit Editor | 图形数据怎样变化? | 图像对象与像素编辑结果 |
Browser 常按类分类、类、协议、方法逐层选择。协议分类是组织方法的标签,不应与消息选择子或语言类型系统混同。
## 沿选择子阅读源码
1. 在 Browser 中找到一个方法,确认实例侧还是类侧。
2. 查看 implementors,比较相同选择子在不同类中的实现。
3. 查看 senders,找出哪些方法发送这条消息。
4. 查实例变量引用,理解状态由谁读写。
5. 对具体对象求值并 inspect,核对静态阅读与实际状态是否一致。
senders 反映可被工具识别的引用;使用 `perform:` 的动态调用可能需要另外追踪。只看到一个实现者,也不能直接推断所有调用都落到它。
## 调试的四个视角
原书 Debugger 把调用链、代码、接收者检查器和局部变量检查器放在一起。某个对象不理解消息时,先检查接收者实际是什么,再检查参数,最后追踪调用者为何给它发送这个选择子。
修改运行系统中的方法可能影响其他窗口。为了复现,单独记录实验类、方法文本、镜像版本和预期结果,不把手工修复后的镜像当作原始系统状态。
## 一个建议实验
创建本教程的 StudyCounter,求值并 inspect;调用 increment 后再看 count;随后在 Browser 中查 value 的实现。实验将“表达式结果、对象状态、方法定义”连成一条路线。此实验尚未在本次任务执行。
+61
View File
@@ -0,0 +1,61 @@
# MVC 与曲线案例:连接数据和界面
> 原书依据:第 20–21 章,印刷页 150–174,PDF 页 161–185。曲线应用位于印刷页 167–174。
Smalltalk-80 的 MVC 是桌面交互对象之间的协作。不要直接把 View、Controller 与 Web 请求处理器或某个现代框架组件一一等同。
## 输入与职责
原书先介绍 `InputState` 与 `InputSensor`,再讨论 View、Controller 和 ControlManager。输入状态、输入读取和交互控制是相邻但不同的责任。
| 角色 | 保存或处理什么 | 对曲线应用的意义 |
| --- | --- | --- |
| Model | 应用数据与状态转换 | 曲线文本列表及当前选择 |
| View | 读取模型并显示,管理视图几何 | 列表显示、曲线图形 |
| Controller | 输入处理与操作协调 | 选择、菜单、编辑动作 |
| ControlManager | 活动控制器的管理 | 协调窗口交互 |
一个模型可以有多个视图。视图和控制器组成显示与交互的配对关系;模型不需要直接包含像素绘制代码。
## window 与 viewport
原书的 View 既有自身坐标范围,也有放到上层视图中的区域。曲线区域将数学坐标映射到窗口子区域,以便 y 轴向上,而屏幕坐标保持其约定。
调试错误显示时,先核对数据,再核对 window、viewport 和显示变换。模型数值正确不等于变换正确。
## 曲线案例的对象结构
原书以 `CurveList` 继承 `OrderedCollection`,增加当前曲线选择;以 `CurveListView` 继承 `View`,重定义绘制行为。列表视图复用 SelectionInListView,并用选择子告诉它如何访问列表、读取选择、改变选择和取得菜单。
```text
窗口 StandardSystemView
├─ 列表子视图 ──读写──→ CurveList
└─ 曲线子视图 ──读取──→ 同一个 CurveList
│
状态变化通知
↓
更新依赖视图
```
类名在扫描 OCR 中存在大小写混淆,本文按语义统一写作 CurveList 和 CurveListView;安装原版代码前应以页面图像与镜像类名复核。
## 追踪一次选择
1. 用户在列表中选择一条曲线。
2. 交互对象通过配置的选择子更新模型的当前选择。
3. 模型发送改变通知。
4. 依赖视图接收更新并重新读取模型。
5. 曲线视图清理绘制区域,将选择的文本求值为块。
6. 对采样点执行块,通过显示变换绘出折线。
这里“文本 → 块”是编译求值,不是普通数据解析。原书在可信镜像中演示原型编程;把这一机制用于接收外部输入的服务,需要另行设计输入语言与隔离策略。本文页面不执行用户代码。
## 增删改也是模型转换
添加曲线会收集文本、追加列表、重置选择并通知;删除会移除当前项、清空选择并通知;修改会替换列表中的相应文本,再使显示更新。无选择时的菜单与有选择时不同。
这说明通知应该位于状态转换之后,让所有视图看到一致状态。曲线文本、当前选择与显示结果应作为三个不同层次检查。
## 迁移设计思想
可以借鉴的组织方式是:将领域状态集中在模型,让输入动作调用明确的状态转换,由订阅者负责刷新显示。原书具体的窗口类、编译器调用和采样算法仍属于历史实现。本教程没有复原完整 GUI 程序或声称它已在当代实现中运行。
+68
View File
@@ -0,0 +1,68 @@
# 术语、练习与原书对照
> 来源范围:全书主要章节;各主题的精确印刷页与 PDF 页见[总览](/docs/source-studies/smalltalk/overview)。本篇练习和术语释义为整理者编写。
## 术语速查
| 原词 | 本文译法 | 区分要点 |
| --- | --- | --- |
| object | 对象 | 有身份、状态和行为的实体 |
| receiver | 接收者 | 本次消息发给谁 |
| message | 消息 | 对接收者的行为请求 |
| selector | 选择子 | 消息的名称,如 at:put: |
| method | 方法 | 某类提供的响应实现 |
| class / instance | 类 / 实例 | 行为组织与由其描述的对象 |
| metaclass | 元类 | 类对象所属的类 |
| protocol | 协议 | 一组协作消息;Browser 中也用于分类 |
| block | 块 | 延迟求值的计算对象 |
| cascade | 级联 | 以分号向同一接收者继续发送 |
| primitive | 原语 | 由虚拟机支持的底层操作 |
| identity / equality | 身份 / 相等 | 同一对象与值意义上的相等 |
| shallow copy | 浅复制 | 复制外层但可能共享内部对象 |
| dependent | 依赖者 | 注册后接收模型变化通知的对象 |
| image | 镜像 | 保存并运行对象系统的状态载体 |
| workspace | 工作区 | 编辑与求值表达式的工具 |
| browser | 类浏览器 | 查看、编辑类与方法的工具 |
| inspector | 对象检查器 | 查看具体对象状态的工具 |
| window / viewport | 坐标窗口 / 视口 | 自身坐标范围与显示映射区域 |
## 预测结果
以下是语义练习,答案为推演,未运行。
| 问题 | 答案与原因 |
| --- | --- |
| `2 + 3 * 4` | 20,二元消息从左到右 |
| `2 + (3 * 4)` | 14,括号改变次序 |
| `[ :x | x + 2 ] value: 5` | 7,value: 触发块求值 |
| 方法只有 `count := 9` 时返回什么? | 接收者,方法无显式返回 |
| B 中的方法对 C 实例发送 super 消息,从哪查找? | 从 B 的父类开始,接收者仍是 C 实例 |
| 浅复制后替换副本某个槽位,是否替换原件槽位? | 不会;修改共享子对象才可能双方可见 |
| do: 能否代替 collect: 收集块结果? | 不能直接代替,两者协议目的不同 |
| 给模型发送 changed: 是否自动修改模型数据? | 不应这样理解;先完成修改,再通知依赖者 |
## 三次源码阅读练习
**消息路线**:选择一个集合的 `size`,找实际定义类,再查看一个发送者;记录“接收者类 → 继承链 → 方法”。
**状态路线**:创建两个指向同一集合的变量,画引用图;再复制外层容器,比较替换槽位与修改子对象的差别。
**界面路线**:对原书曲线案例画出选择、修改模型、通知、重绘与显示变换,注明每一步所在对象。
完成实验时另记镜像名称、版本、源代码、实际输出和差异。本次任务只完成文档整理,没有运行这些实验。
## 扫描校勘与适用边界
| 扫描问题 | 本文处理 |
| --- | --- |
| 箭头被识别成 `<-`、字母或感叹号 | 赋值统一为 :=,返回统一为 ^ |
| Smalltalk-80 被识别成 Smalltalk-SO | 依据书名与语境规范化 |
| 选择子被拆开、类名大小写混淆 | 行文合并;具体 API 安装前再核对原页 |
| 第 4 章个别科学计数法示例数位不一致 | 不沿用异常数值作为例题 |
| 后部 GUI 源码嵌在截图中 | 解释调用结构,不将 OCR 文本冒充可运行源码 |
原书是学习 Smalltalk-80 系统的资料,不是本文对所有 Smalltalk 方言兼容性的承诺。全文以语义重述和新增短例组织;没有把原始扫描件、OCR 全文或本机下载路径发布到网站。
## 后续核验清单
准备目标镜像后,可分别验证类定义消息、初始化约定、块返回边界、数值除法、集合返回类型、信号量时序,以及 MVC 选择子与坐标变换。核验结果应与本文的语义推演分别记录,避免把“读过”写成“运行通过”。
+5
View File
@@ -0,0 +1,5 @@
{
"maxWidth": 1600,
"quality": 82,
"maxBytes": 196608
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+221
View File
@@ -0,0 +1,221 @@
{
"sourceVersion": "2026-10-30-depth-4",
"asOf": "2026-10-30",
"metric": "platform-mrr-usd",
"sampleUnit": "product-or-business",
"note": "独立Python复算参考值,仅验证样本计算;不是行业估计或财务审计。",
"windows": [
{
"days": 7,
"products": [
"MR001",
"MR002",
"MR003",
"MR004",
"MR005",
"MR006",
"MR007",
"MR008",
"MR009",
"MR010",
"MR011",
"MR012",
"MR015",
"MR016"
],
"groups": [
{
"category": "C06",
"provider": "Stripe",
"model": "软件订阅",
"products": [
"MR011",
"MR012",
"MR015",
"MR016"
],
"n": 4,
"mean": 34815.0,
"median": 36723.0,
"sampleDeviation": 16334.54845820151,
"cv": 0.46918134304758036,
"meanWithoutLargest": 28940.666666666668,
"largestRemovalDrop": 0.16872995356407675
},
{
"category": "C08",
"provider": "RevenueCat",
"model": "消费者订阅",
"products": [
"MR001"
],
"n": 1,
"mean": 53101.0,
"median": 53101,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C08",
"provider": "Stripe",
"model": "内容制作服务",
"products": [
"MR002"
],
"n": 1,
"mean": 62423.0,
"median": 62423,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C08",
"provider": "Stripe",
"model": "软件订阅",
"products": [
"MR003",
"MR004",
"MR005"
],
"n": 3,
"mean": 41693.0,
"median": 49069,
"sampleDeviation": 28111.375064197768,
"cv": 0.6742468775141575,
"meanWithoutLargest": 29849.0,
"largestRemovalDrop": 0.2840764636749574
},
{
"category": "C09",
"provider": "RevenueCat",
"model": "消费者订阅",
"products": [
"MR006",
"MR007",
"MR008",
"MR009",
"MR010"
],
"n": 5,
"mean": 25349.2,
"median": 20483,
"sampleDeviation": 11942.947278624319,
"cv": 0.4711370488466823,
"meanWithoutLargest": 20046.0,
"largestRemovalDrop": 0.20920581320120557
}
]
},
{
"days": 90,
"products": [
"MR001",
"MR002",
"MR003",
"MR004",
"MR005",
"MR006",
"MR007",
"MR008",
"MR009",
"MR010",
"MR011",
"MR012",
"MR015",
"MR016",
"AI016"
],
"groups": [
{
"category": "C06",
"provider": "Stripe",
"model": "软件订阅",
"products": [
"MR011",
"MR012",
"MR015",
"MR016",
"AI016"
],
"n": 5,
"mean": 112725.6,
"median": 40112,
"sampleDeviation": 174786.78744916618,
"cv": 1.5505509613536426,
"meanWithoutLargest": 34815.0,
"largestRemovalDrop": 0.6911526751687284
},
{
"category": "C08",
"provider": "RevenueCat",
"model": "消费者订阅",
"products": [
"MR001"
],
"n": 1,
"mean": 53101.0,
"median": 53101,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C08",
"provider": "Stripe",
"model": "内容制作服务",
"products": [
"MR002"
],
"n": 1,
"mean": 62423.0,
"median": 62423,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C08",
"provider": "Stripe",
"model": "软件订阅",
"products": [
"MR003",
"MR004",
"MR005"
],
"n": 3,
"mean": 41693.0,
"median": 49069,
"sampleDeviation": 28111.375064197768,
"cv": 0.6742468775141575,
"meanWithoutLargest": 29849.0,
"largestRemovalDrop": 0.2840764636749574
},
{
"category": "C09",
"provider": "RevenueCat",
"model": "消费者订阅",
"products": [
"MR006",
"MR007",
"MR008",
"MR009",
"MR010"
],
"n": 5,
"mean": 25349.2,
"median": 20483,
"sampleDeviation": 11942.947278624319,
"cv": 0.4711370488466823,
"meanWithoutLargest": 20046.0,
"largestRemovalDrop": 0.20920581320120557
}
]
}
]
}
+3 -2
View File
@@ -13,7 +13,8 @@
"lint": "pnpm --filter @endor/docs run lint",
"typecheck": "pnpm --filter @endor/docs run typecheck",
"figures": "uv run --locked --project tools/figures endor-figures",
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run figures && pnpm run build",
"test:auth": "pnpm --filter @endor/docs run test:auth"
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run test:research && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
"test:auth": "pnpm --filter @endor/docs run test:auth",
"test:research": "pnpm --filter @endor/docs run test:research"
}
}
+4 -3
View File
@@ -59,6 +59,9 @@ importers:
postcss:
specifier: 8.5.31
version: 8.5.31
sharp:
specifier: 0.35.5
version: 0.35.5(@types/node@22.20.5)
tailwindcss:
specifier: 4.3.3
version: 4.3.3
@@ -2876,8 +2879,7 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@img/colour@1.1.0':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.5':
optionalDependencies:
@@ -5184,7 +5186,6 @@ snapshots:
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
'@types/node': 22.20.5
optional: true
shebang-command@2.0.0:
dependencies:
+110
View File
@@ -0,0 +1,110 @@
"""Summarize observed research coverage; does not claim an exhaustive AI census."""
import argparse
from collections import Counter
import json
from pathlib import Path
import sys
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
AUDIT = json.loads((ROOT / 'content/research/ai-companies.coverage.json').read_text())
TARGET = ROOT / 'content/docs/ai-company-research/02-coverage-and-gaps.md'
BASE = '/docs/other-research/ai-company-research/overview'
def render():
cov = DATA['coverage']
companies = DATA['companies']
depth = sum(bool(c.get('profileOnly')) for c in companies)
origin = sum(bool(c.get('profileOnly')) or c.get('directoryOrigin') == 'yc-2025-2026' for c in companies)
promoted = origin - depth
reviewed = sum(bool(c.get('profileOnly')) and '人工复核' in c.get('classificationBasis', '') for c in companies)
years = Counter(c['year'] for c in companies)
income = Counter(c['revenueStatus'] for c in companies)
technical = Counter(c.get('technicalReview', {}).get('level', 'unreviewed') for c in companies)
assert cov['profilesRead'] + cov['failedProfiles'] == cov['candidateRecords']
assert cov['matchingProfiles'] == cov['existingProfiles'] + cov['newProfiles']
assert cov['matchingProfiles'] + cov['otherOrMissingYear'] + cov['unclearAI'] + cov['dateConflicts'] == cov['profilesRead']
assert origin == cov['newProfiles']
assert AUDIT['accessedAt'] == cov['accessedAt']
assert AUDIT['counts'] == cov
assert len(AUDIT['records']) == cov['profilesRead']
assert Counter(x['status'] for x in AUDIT['records']) == {
'imported': cov['newProfiles'], 'existing': cov['existingProfiles'],
'year-outside-or-missing': cov['otherOrMissingYear'],
'ai-unclear': cov['unclearAI'], 'date-conflict': cov['dateConflicts']}
assert len({x['ycId'] for x in AUDIT['records']}) == len(AUDIT['records'])
lines = ['# AI 公司资料覆盖与缺项', '',
f"截至 {DATA['asOf']},研究表含{len(companies):,}条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。", '',
'验收范围已于2026-10-30确认为:以确认的公开来源清单为边界,逐条核对并标明缺项。下方区分已逐条审计的候选池与定向补充来源;已使用某来源不代表该来源已被完整覆盖。', '',
f"[分类和数据工作台]({BASE})链接原始证据,[收入样本快照](/docs/other-research/ai-company-research/income-snapshot)展示当前可计算的MRR样本。", '',
'## 候选池与公司表的关系', '',
'| 环节 | 记录数 | 含义 |', '| --- | --- | --- |',
f"| YC批次候选 | {cov['candidateRecords']:,} | 从公开镜像发现,随后读取YC官方页面 |",
f"| 官方页面读取 | {cov['profilesRead']:,} | 此候选池读取失败{cov['failedProfiles']}条 |",
f"| 初筛匹配 | {cov['matchingProfiles']:,} | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |",
f"| 与原表重合 | {cov['existingProfiles']} | URL匹配,未再次新增 |",
f"| 新增目录记录 | {cov['newProfiles']:,} | 原始入表来源,后续深化不改变发现计数 |",
f"| 年份不符或缺失 | {cov['otherOrMissingYear']} | 此轮不作为成立年匹配项 |",
f"| AI业务待确认 | {cov['unclearAI']} | 不强行把背景关键词当主营 |",
f"| 成立年与批次冲突 | {cov['dateConflicts']} | 另保留核查记录 |", '',
f"全表由{depth}条目录基础项和{len(companies)-depth}条个案或平台记录组成。候选池已匹配项之外另有{len(companies)-cov['matchingProfiles']}条记录,包含其他来源及发布/转型对照;其中已有{promoted}条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。", '',
'## 成立年与证据深度', '',
'| 维度 | 当前记录 | 解读 |', '| --- | --- | --- |',
f"| 记录为2025年成立 | {years[2025]} | 成立来源和精度见公司明细 |",
f"| 记录为2026年成立 | {years[2026]} | 不将产品发布日期代替成立日 |",
f"| 成立年未知或范围外 | {sum(v for k,v in years.items() if k not in (2025,2026))} | 发布/转型对照单列,不进入两年成立分组 |",
f"| 目录业务分类人工复核 | {reviewed} | 仅复核主营分类,不代表技术或财务审计 |",
f"| 其他目录基础项 | {depth-reviewed} | 规则初分或资料不足,继续待复核 |",
f"| 有团队数字 | {sum(c['team'] is not None for c in companies)} | 多为自报Team Size或Employees,时点与口径不统一 |",
f"| 专项技术证据记录 | {len(companies)-technical['unreviewed']} | 其中{technical['description']}条自述、{technical['public-artifact']}条公开材料、{technical['independent-test']}条第三方评测 |",
f"| 公司披露收入线索 | {income['self']} | 含历史、下界、币种不明及产品业务声明,不等于可比ARR |",
f"| 仅平台收入线索 | {income['platform']} | 同时有公司披露者计入上一行,不重复计数 |",
f"| 仅第三方估算 | {income['estimate']} | 不与直接披露混合 |",
f"| 暂无可用收入数字 | {income['unknown']} | 包含尚未逐家搜索的目录项,不代表没有收入 |", '',
'## 业务分类与数据入口', '',
'| 业务类别 | 记录数 |', '| --- | --- |',
]
counts = Counter(c['cat'] for c in companies)
for category in DATA['categories']:
lines.append(f"| [{category['name']}]({BASE}?category={category['id']}) | {counts[category['id']]} |")
lines += ['', '## 已使用来源的分布', '',
f"{len(DATA['evidence']):,}条证据记录对应{len({e['url'] for e in DATA['evidence']}):,}个不同URL。不同URL不自动构成独立验证;同一声明可能由公司、投资方和媒体重复转述。", '',
'| 来源 | 证据记录 | 不同URL | 用途及限制 |', '| --- | --- | --- | --- |',
]
families = [('YC官方目录', 'www.ycombinator.com', '公司自述的成立、团队与业务;英文及加速器样本偏多'),
('TrustMRR', 'trustmrr.com', '支付平台重建指标及方法;公开者存在选择偏差,非财务审计'),
('a16z Speedrun', 'speedrun.a16z.com', '投资机构中的公司介绍及收入声明;存在利益关系'),
('TechCrunch', 'techcrunch.com', '原始采访或报道;转述公司数字不等于查账'),
('Sacra', 'sacra.com', '第三方估算;与直接披露分开')]
included = set()
for name, host, note in families:
entries = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc == host]
included.add(host)
lines.append(f"| {name} | {len(entries)} | {len({e['url'] for e in entries})} | {note} |")
others = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc not in included]
lines.append(f"| 其他官网、评测及公开资料 | {len(others)} | {len({e['url'] for e in others})} | 按每条来源限制理解,不能统一当作独立证据 |")
lines += ['', '## 尚未满足的研究条件', '',
'行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '',
'下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
]
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
content = render()
if args.check:
if not TARGET.exists() or TARGET.read_text() != content:
sys.exit('Research coverage report differs; review and regenerate')
print('Research coverage accounting and report are consistent')
else:
TARGET.write_text(content)
print(TARGET.relative_to(ROOT))
if __name__ == '__main__':
main()
+3
View File
@@ -6,6 +6,9 @@ uv sync --project tools/figures --locked
pnpm run lint
pnpm run typecheck
pnpm --filter @endor/docs run test:auth
pnpm --filter @endor/docs run test:research
pnpm --filter @endor/docs run images
pnpm --filter @endor/docs run test:images
cp -R apps/docs/public/figures /tmp/expected-figures
pnpm run figures
uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v
+4 -1
View File
@@ -14,7 +14,8 @@ ROOT = Path(__file__).resolve().parents[1]
APP = "endorphin-tabularium-docs"
INPUTS = ["Dockerfile", "Dockerfile.ci", ".dockerignore", ".npmrc", "package.json",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "apps/docs", "content", "tools/figures",
"scripts/check-container.sh", "scripts/ci.py"]
"scripts/check-container.sh", "scripts/ci.py", "scripts/summarize-ai-income.py",
"scripts/audit-ai-research.py"]
def run(*args: str, capture: bool = False) -> str:
@@ -63,6 +64,8 @@ def smoke_image(image: str, name: str) -> None:
def main() -> None:
os.chdir(ROOT)
run(sys.executable, "scripts/summarize-ai-income.py", "--check")
run(sys.executable, "scripts/audit-ai-research.py", "--check")
run(sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py")
scope = os.environ.get("CI_CACHE_SCOPE", "main")
if not re.fullmatch(r"main|pr-[0-9]+", scope):
+50
View File
@@ -0,0 +1,50 @@
"""Draft business labels for public company profiles, never financial verification.
Input is a directory of JSON records with a `company` object containing
one_liner and tags, as retained by the official-profile research snapshot.
Output is for review; unmatched records remain C00 rather than being forced.
This tool does not fetch sources, modify the site database or run a build.
"""
import argparse
import json
import re
from pathlib import Path
def match(pattern,text):return bool(re.search(pattern,text,re.I))
def classify(c):
short=c.get('one_liner') or '';tags=' '.join(c.get('tags') or [])
rules=[
('C10',r'\brobot(?:s|ics|ic)?\b|embodied|physical intelligence|autonomous (?:driving|vehicle)|drones?|manufacturing hardware'),
('C02',r'context compression|compression middleware|RNA intelligence|audio data for AI|synthetic data|data quality|\bGPU\b|\bGPUs\b|\bchips?\b|semiconductor|data centers?|datacenters?|inference|compute|training data|data labeling|data labelling|RL environments|reinforcement learning|model training'),
('C07',r'precision medicine|ambulance|clinic|biology|manufacturers|distributors|fabrication|people-intelligence|deskless workers|health|medical|clinical|dental|physician|hospital|veterinar|therap|radiolog|pharma|legal|law firm|lawyer|litigation|accountant|accounting|audit\b|insurance|underwrit|financ|hedge funds?|bank|wealth|investment|investing|real estate|property|properties|construction|building (?:plan|code)|engineering projects|\bCAD\b|\bEDA\b|chip design|architecture|architects|logistics|supply chain|freight|global trade|compliance|government|govtech|lobby|energy|utilities|\bfleets?\b|education|edtech|\btutor|schools?|exam prep|tax\b|recruit|hiring|human resources|procurement|retail|e-commerce|ecommerce|merchants?|industrial'),
('C03',r'AI evaluations|AI governance|voice agents|chat AI agents|harnesses|agent.{0,25}(?:infrastructure|tools|identity|identities|memory|evaluation|eval|observability|browser|payment|communication)|(?:infrastructure|tools|identity|identities|evaluation|eval|observability|browsers?|payments?).{0,25}agents?|\bMCP\b|agentmail|API.{0,20}agents?|developer.{0,15}agents?'),
('C04',r'firmware|internal apps|coding|software engineer|code\b|codebase|developer|programming|app(?:lication)? (?:builder|creation|generation)|(?:build|building|make|create|generat).{0,25}(?:apps|applications|software)|\bDevOps\b|product docs|documentation engineer|\bIDE\b|logs and fix|testing|debug'),
('C06',r'\bsales\b|marketing|customer support|customer service|call center|call centre|\bGTM\b|\bCRM\b|\bSEO\b|\bGEO\b|\bAEO\b|growth engine|outbound|telemarketing|advertis|\bads\b|contact center|customer success'),
('C08',r'\bvideo\b|\bimage\b|\bphoto\b|\bfilm\b|\bdesign\b|animation|creative|creators?|content creation|\bmusic\b|media|audio edit|voiceover'),
('C01',r'foundation models?|frontier models?|large language models?|\bLLMs?\b|voice model|speech model|world model'),
('C09',r'consumer|personal|companions?|\bstor(?:y|ies)\b|entertainment|\bgam(?:e|es|ing)\b|social network|\bphone\b|smartphone|keyboard|your digital life'),
('C05',r'\bB2B\b|enterprise|workflow|workspace|productivity|operations|\bSaaS\b|\bSOP\b|Word editor|spreadsheet|knowledge|company OS|company brain|work\b|teams?|business|employee|meeting|security|cyber|search'),
]
for label,text in [('主营简介',short),('目录标签',tags)]:
for cat,pattern in rules:
if match(pattern,text):return cat,label+'的规则初分'
return 'C00','简介不足以确定主业务,待复核'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
result = []
for path in sorted(args.input.glob("*.json")):
record = json.loads(path.read_text())
company = record["company"]
category, basis = classify(company)
result.append({"ycId": company["id"], "category": category,
"basis": basis, "status": "provisional"})
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -27,7 +27,7 @@ def verify(base: str) -> None:
assert status == 200 and json.loads(health) == {"status": "ok"}
status, _, login = get("/login")
assert status == 200 and "输入动态码" in login.decode()
for path in ("/", "/docs/00-endor-concepts-draft", "/figures/endor-architecture.svg", "/docs/pretext/01-chapter", "/materials/pretext/verify.cjs", "/materials/pretext/results.chrome156.json"):
for path in ("/", "/docs/00-endor-concepts-draft", "/figures/endor-architecture.svg", "/figures/endor-review-gate.webp", "/figures/endor-review-desk.webp", "/docs/pretext/01-chapter", "/materials/pretext/verify.cjs", "/materials/pretext/results.chrome156.json"):
status, headers, _ = get(path)
assert status == 307, path
target = urllib.parse.urlparse(urllib.parse.urljoin(base, headers["Location"]))
+153
View File
@@ -0,0 +1,153 @@
"""Render a fixed research snapshot; --check performs data analysis only, no build."""
import argparse
import datetime as dt
import json
from pathlib import Path
import statistics as st
import sys
ROOT = Path(__file__).resolve().parents[1]
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
REFERENCE = json.loads((ROOT / 'content/research/ai-income-analysis.json').read_text())
TARGET = ROOT / 'content/docs/ai-company-research/01-income-snapshot.md'
BASE = '/docs/other-research/ai-company-research/overview'
COMPANIES = {c['id']: c for c in DATA['companies']}
CATEGORIES = {c['id']: c['name'] for c in DATA['categories']}
END = dt.date.fromisoformat(DATA['asOf'])
def sample(days):
latest, excluded = {}, []
for o in DATA['observations']:
if o.get('selection') != 'platform' or o.get('metric') != 'mrr':
continue
c = COMPANIES[o['company']]
day = o.get('observedAt')
try:
parsed = dt.date.fromisoformat(day)
valid_date = parsed.isoformat() == day
except (TypeError, ValueError):
valid_date = False
valid = (c['year'] in (2025, 2026) and o.get('origin') == 'payment-platform'
and o['unit'] == 'USD' and o['bound'] == '='
and o.get('comparisonGroup') == 'trustmrr-usd-mrr'
and o.get('provider') and isinstance(o['value'], (int, float))
and 0 <= o['value'] < float('inf') and valid_date
and 0 <= (END - parsed).days <= days)
if not valid:
excluded.append(o)
continue
prior = latest.get(c['id'])
if prior is None or day > prior['observedAt']:
latest[c['id']] = o
return latest, excluded
def groups(points):
result = {}
for cid, o in points.items():
c = COMPANIES[cid]
key = (c['cat'], o['provider'], c.get('businessModel', '收费模式未核验'))
result.setdefault(key, []).append(o['value'])
return result
def stats(values):
mean = st.mean(values)
sd = st.stdev(values) if len(values) > 1 else None
reduced = st.mean(sorted(values)[:-1]) if len(values) > 1 else None
return dict(n=len(values), mean=mean, median=st.median(values),
sampleDeviation=sd, cv=sd / mean if sd is not None and mean else None,
meanWithoutLargest=reduced,
largestRemovalDrop=(mean - reduced) / mean if reduced is not None and mean else None)
def verify():
assert REFERENCE['sourceVersion'] == DATA['version'], 'Reference revision mismatch'
for window in REFERENCE['windows']:
points, _ = sample(window['days'])
assert set(points) == set(window['products']), 'Sample selection mismatch'
computed = groups(points)
assert len(computed) == len(window['groups'])
for group in window['groups']:
values = computed[(group['category'], group['provider'], group['model'])]
for key, value in stats(values).items():
expected = group[key]
assert (value is None and expected is None) or (
value is not None and expected is not None
and abs(value - expected) <= 1e-8 * max(1, abs(expected))), key
def money(value):
return f'{value:,.0f}' if value is not None else '不计算'
def link(cid):
return f"[{COMPANIES[cid]['name']}]({BASE}?company={cid}#research-company-detail)"
def render():
points, excluded = sample(7)
lines = [
'# AI 公司收入样本分布快照', '',
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。创意软件订阅组的样本均值较高、CV也较大;营销软件组与消费者个人应用组的CV接近。各组只有1至5个产品,尚不足以估计行业总体或造假概率。", '',
f"这是已采集资料的固定快照。[动态工作台]({BASE})提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是{len(DATA['companies']):,}条公司/产品记录的收入普查。", '',
'## 7日窗口的分组结果', '',
'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '',
'| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |',
'| --- | --- | --- | --- | --- | --- | --- |',
]
for key, values in sorted(groups(points).items()):
cat, provider, model = key
v = stats(values)
cv = f"{v['cv']:.3f}" if v['cv'] is not None else '不计算'
drop = f"{v['largestRemovalDrop']:.1%}" if v['largestRemovalDrop'] is not None else '不计算'
lines.append(f"| {CATEGORIES[cat]} · {model} | {provider} | {v['n']} | {money(v['mean'])} | {money(v['median'])} | {cv} | {drop} |")
lines += ['',
'在至少3个样本的组内,内容创意软件订阅的均值较高,但移除最大样本后的下降也最大。营销软件与个人应用的CV仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。', '',
'均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '',
'## 样本与证据入口', '',
'点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '',
'| 产品 | 成立年 | MRR美元每月 | 最后同步 | 平台 | 连接状态 |',
'| --- | --- | --- | --- | --- | --- |',
]
for cid, o in sorted(points.items()):
status = '已过期的日期快照' if o.get('connectionExpired') else '采集时未标过期'
lines.append(f"| {link(cid)} | {COMPANIES[cid]['year']} | {money(o['value'])} | {o['observedAt']} | {o['provider']} | {status} |")
fresh = stats(groups(points)[('C06', 'Stripe', '软件订阅')])
broad, _ = sample(90)
older = stats(groups(broad)[('C06', 'Stripe', '软件订阅')])
lines += ['', '## 扩大窗口后的敏感性', '',
f"营销软件订阅组从7日窗口的{fresh['n']}个产品扩大到90日窗口的{older['n']}个产品,均值从{money(fresh['mean'])}升至{money(older['mean'])}美元每月,中位数从{money(fresh['median'])}变为{money(older['median'])},CV从{fresh['cv']:.3f}升至{older['cv']:.3f}。", '',
f"新增点来自{link('AI016')}的8月历史快照,连接已过期。移除该最大值后,均值回到{money(older['meanWithoutLargest'])}美元,下降{older['largestRemovalDrop']:.1%}。这是日期与样本构成的敏感性,不能解释为行业风险变化。", '',
'## 默认窗口排除的历史记录', '',
'| 产品 | MRR美元每月 | 最后同步 | 排除原因 |',
'| --- | --- | --- | --- |',
]
for o in excluded:
lines.append(f"| {link(o['company'])} | {money(o['value'])} | {o.get('observedAt', '未知')} | 超过7日窗口,保留历史观测 |")
lines += ['', '## 对 ARR 与商业模式的含义', '',
'MRR乘12只是参考年化。公司声明的ARR、合同管线、近30日收款、一次性额度和服务交付收入仍需分开;支付平台重建指标也不等于财务审计。方法与平台限制见[TrustMRR FAQ](https://trustmrr.com/faq)。', '',
'当前更适合选择访谈对象和设计收费试验,不能据最高均值直接决定业务方向。软件订阅优先核留存与推理成本,服务交付优先核人工工时和返工,消费者应用优先核渠道成本、退款和平台抽成。', '',
f"行业ARR比较仍需补齐同一业务截止日、币种、经常性定义及业务边界。收入日期不完整或同页数值冲突的声明,继续保留在[核查工作台]({BASE}),不补成零值或精确数字。", '',
]
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
verify()
content = render()
if args.check:
if not TARGET.exists() or TARGET.read_text() != content:
sys.exit('Income snapshot differs; regenerate after reviewing data changes')
print('Income reference, samples and report are consistent')
else:
TARGET.write_text(content)
print(TARGET.relative_to(ROOT))
if __name__ == '__main__':
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,14 @@
Endor inspection desk comic — approved second slide
Source: built-in image_gen; physics inspection-bench variant with corrected top-row flow.
Original: exec-432e0f3d-d01d-4658-b5d5-0c63db95e472.png
The user selected this alongside the gate variant for one carousel.
The two identical machines emit raw papers. Above, a dashed integral dW boundary
encloses the backlog BEFORE one person's fixed-capacity C0 inspection desk.
Below, a dashed dW boundary encloses the one new paper being inspected. Earlier
checked outputs become structured document helpers that contribute additional
magnifiers to the Endor desk, whose capacity is C0 + k integral dW.
Store this PNG as the production master outside public. The confirmed remote
runner generates /figures/endor-review-desk.webp using content/image-policy.json.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,20 @@
Endor review gate comic — approved source
Source: built-in image_gen, physics gate variant, approved by the user on 2026-10-28.
Original: exec-380f8db3-6450-4981-97df-473c13a62223.png
The PNG is the production master, not a public browser asset.
The confirmed remote runner generates the browser WebP with:
pnpm --filter @endor/docs images
Visual specification:
Two rows, white background, charcoal outlines, blue accents, identical coding
machines emitting identical raw sheets. Top: accumulated unchecked sheets inside
a dashed integral dW boundary, one person and a fixed C0 inspection gate, checked
output after the gate. Bottom: one new sheet inside a dashed dW boundary directly
inspected by a stick figure at an Endor gate. Gate capacity is C0 + k integral dW.
Checked output becomes a structured document helper, joins historical helpers,
and blue arrows from helpers feed the gate to increase inspection capability.
No dialogue or explanatory prose. Math is a conceptual metaphor, not measured data.
The bitmap master retains the approved artwork, including its mathematical
symbols. Browser optimization changes encoding and dimensions only.