diff --git a/.github/workflows/refresh-profile-data.yml b/.github/workflows/refresh-profile-data.yml
index 7473236..f10d96e 100644
--- a/.github/workflows/refresh-profile-data.yml
+++ b/.github/workflows/refresh-profile-data.yml
@@ -9,6 +9,7 @@ on:
- "docs/i18n/zh-CN/README.md"
- "scripts/build_profile_svgs.py"
- "assets/**"
+ - "tests/**"
workflow_dispatch:
permissions:
@@ -31,6 +32,12 @@ jobs:
- name: Marker and parity lint
run: python scripts/build_profile_svgs.py --lint
+ # The characterization suite is zero-dependency and sub-second: make the
+ # contract tests, README structure guards and SVG golden masters as
+ # mandatory as the lint instead of local-only.
+ - name: Contract and structure tests
+ run: python -m unittest discover tests
+
refresh:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index a6f24b7..70821f6 100644
--- a/README.md
+++ b/README.md
@@ -212,7 +212,7 @@
- **`give-me-a-break`** — the state machine's `evaluate` has zero time dependency. It takes the clock as an argument, so the entire rest/work/AFK lifecycle is testable against a virtual clock: sleep, crash recovery with fast-forward, a display unplugged mid-break. Three modules, one of which knows nothing about macOS.
- **`hyper-git`** — `engine/` contains no `vscode` import. That single constraint is why 403 unit tests exist: changelist grouping, DAG swimlane layout, and Conventional Commits validation are all testable without an editor host. The architecture note calls it "Path B" — consume the stable `vscode.git` API and hand-render everything above it, rather than fork.
- **`hyper-git`, again** — five AI seams (`ILlmProvider`, `ICommitMessageProvider`, `IPreCommitInspector`, `IChangelistGrouper`, `IConflictResolver`) are wired in as null implementations, modeled on JetBrains' `CheckinHandler` lifecycle. The interfaces ship; the intelligence is deferred to M5. Declaring the seam early is cheap and declaring it late is not.
-- **`coding-proxy`** — the failover chain is policy, not plumbing: circuit breaker state and quota accounting are per-vendor and local, in SQLite-WAL. No Redis, no queue, so a single process is the whole deployment and a restart loses nothing that matters.
+- **`coding-proxy`** — the failover chain is policy, not plumbing: circuit breaker state and quota accounting are per-vendor and local, in SQLite-WAL — so a single process is the whole deployment, and a restart loses nothing that matters.
- **`negentropy`** — the default path runs on the cheapest of its pluggable backends with **no cloud credentials at all**; if the cheapest configuration is not runnable, nobody runs the expensive one either.
Stated as a virtue, this is testability. Stated honestly, it is what a single maintainer has to do to survive his own codebase: nothing here has a second pair of eyes, so the design has to make the mistakes cheap to find. And the null AI seams are still null — the interfaces are a plan, not a feature.
@@ -290,7 +290,7 @@ sequenceDiagram
P-->>C: SSE stream, in Anthropic's shape
```
-The client is told nothing. That is the whole product: one line of configuration, and the failure mode changes from *stop working* to *work slower on someone else's model*. Nine vendors are wired — Claude plans, GitHub Copilot, Google Antigravity, Z AI's GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao — with a per-vendor circuit breaker and quota guard, and a local SQLite-WAL dashboard so the burn is visible before the bill is.
+The client is told nothing. That is the whole product: one line of configuration, and the failure mode changes from *stop working* to *work slower on someone else's model*. Each of the nine vendors named on the card above gets its own circuit breaker and quota guard, and a local SQLite-WAL dashboard makes the burn visible before the bill is.
FastAPI and httpx. 12 releases, the latest still alpha-tagged — translation fidelity across three request shapes is the part that keeps not being finished. Chained failover also means a request can succeed on a model you did not choose; the dashboard exists partly so that is auditable.
diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md
new file mode 100644
index 0000000..635873c
--- /dev/null
+++ b/docs/.agents/issue.md
@@ -0,0 +1,32 @@
+# Issue 台账
+
+| ID | 标题 | 状态 | 完成时间 | 详情 |
+|---|---|---|---|---|
+| I-001 | 九厂商清单双层手写漂移:单侧修复未除根,结构性收敛为卡片单一事实源 | ✅ | 2026-10-18 | [详情](#I-001) |
+| I-002 | 语义级镜像漂移与锚点死链无机器门:lint 仅护 marker 级 | ✅ | 2026-10-18 | [详情](#I-002) |
+
+
+## I-001:九厂商清单双层手写漂移
+
+**问题描述**:coding-proxy 九家厂商清单在项目卡(`README.md:163`)与深潜块(`README.md:293`)两处全文手写,中英共四份拷贝,无同步机制。PR #7 曾发现卡片侧漏写 Xiaomi(8 家对 9 家),修复方式为只补卡片侧数据——漂移根因(双清单结构)未除。
+
+**表因根因**:v5 重建时同一事实一次性写入两处;两清单命名形不同(Copilot/GitHub Copilot 等),肉眼 diff 困难;lint 只校验 marker 流,散文重复不在守护范围。
+
+**处理方式**(2026-10-18,preening-substrate 结构性熵减):`README.md:163` 定为唯一权威清单(承诺性陈述,加厂商先改这里);`:293` 深潜句收敛为「上方卡片点名的九家厂商」回指,保留每家熔断/配额守卫的增量叙述;中英同步镜像。验证:31 用例 + `--lint` 全绿,残留扫描确认厂商名仅存 :163。
+
+**后续防范**:改厂商清单只动 :163;新增手写事实清单时先指定权威位置(编辑契约见 [knowledge-map.md](knowledge-map.md#双语镜像编辑契约卡) 第 7 条)。
+
+**同类影响**:其余手写数字面(`403` 三处、`:249` 的 1,378、`:36` 年度序列 prose)已登记为漂移面,按 C4(角色不同)暂保留,未动。
+
+
+## I-002:语义级镜像漂移与锚点死链无机器门
+
+**问题描述**:CI lint 与测试只守护 marker 级契约(75/13 流、parity、assets 闭包);三类承重面无机器守护——① 中英两文件 marker 外文本的语义级镜像漂移;② `` 锚点 / `#user-content-` 导航链接 / FIG key 三方一致性(破坏即静默死链);③ `tests/` 31 用例未接 CI(仅本地跑)。
+
+**表因根因**:PR #8 建立 tests 时按最小护栏取舍;锚点前缀是 GitHub 渲染侧行为,仓库内工具不感知。
+
+**处理方式**(2026-10-18,两步闭环):第一步重构时以 lab 级 gate 脚本护航(锚点三方一致 + marker 行号镜像 + 骨架行号镜像 + 行数相等),编辑契约沉淀至 [knowledge-map.md](knowledge-map.md#双语镜像编辑契约卡);第二步将检查上收为 [`tests/test_readme_structure.py`](../../tests/test_readme_structure.py)(零依赖纯正则,35 用例之一),并在 `refresh-profile-data.yml` lint job 中接入 `python -m unittest discover tests`,全套护栏由本地自觉变 PR 强制门。
+
+**后续防范**:改任一 README 后跑 knowledge-map 契约卡第 6 条两条命令(与 CI 同款);新增结构元素(details/锚点/表格)时行级镜像约束由测试自动拦截。
+
+**注意事项**:本地 Markdown 预览不加 `user-content-` 前缀,导航链接在本地必显死链——这是设计使然,以 GitHub 渲染为准。
diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md
new file mode 100644
index 0000000..bdf2ad0
--- /dev/null
+++ b/docs/.agents/knowledge-map.md
@@ -0,0 +1,28 @@
+# Knowledge Map
+
+本仓文档索引与跨文档编辑契约的唯一入口。
+
+## 文档索引
+
+| 文档 | 职责 |
+|---|---|
+| [`README.md`](../../README.md) | 英文 profile 主页(月度构建产物:13 FIG + 75 DATA 标记) |
+| [`docs/i18n/zh-CN/README.md`](../i18n/zh-CN/README.md) | 中文镜像主页(与英文版逐行结构镜像) |
+| [`docs/motion-constraints.md`](../motion-constraints.md) | SVG 动效/布局闸门的「为什么」层(闸门机械强制在 `scripts/build_profile_svgs.py`,冲突时以闸门为准) |
+| [`docs/.agents/issue.md`](issue.md) | Issue 台账(头部索引 + 详情锚点) |
+
+## 双语镜像编辑契约卡
+
+改任一 README 前必读。两份文件是**逐行结构镜像**:marker 行号、结构骨架行号(details/summary/锚点/表格/代码块)、总行数必须逐位一致;合法差异仅三类——正文语言、img `src` 绝对化(中文版仓库外渲染所需)、语言切换行相对路径。
+
+1. **机器领地,永不手改**:`…` 体(img/alt/src 每次刷新从零重建,手改必被覆盖);`值` 的值。生成器为 [`scripts/build_profile_svgs.py`](../../scripts/build_profile_svgs.py)。
+2. **标记即契约**:key 必须 ∈ `FACT_KEYS`/`FIG_SPEC`;两文件 (kind,key) 有序流必须全等且非空(`audit_parity`);增删/换序标记须两文件同步,且计数变化需同步 `tests/test_contract.py` 的 75/13 硬编码断言。
+3. **含 marker 的行谨慎编辑**:值以外的行内文字可改,但 marker 本身与其相对顺序不可动;`DATA:cur_year`/`DATA:cur_total` 是月度刷新「当年数字不回退」守卫的状态源,永不删除。
+4. **锚点三方同名同序**:`` ×13、两条簇导航 `` 行的 `#user-content-X` 链接、FIG key `X` 三者集合与顺序一致(`tests/test_readme_structure.py` 机器强制);GitHub 对手写 `name` 自动加 `user-content-` 前缀,链接侧必须手写该前缀。
+5. **冻结面绕行**:mermaid 块、upstream/census/道法术表格、IEEE 脚注、recompute 命令块承载既有契约,结构性重构一律绕行。
+6. **编辑后验证**(仓库根,两命令即 PR CI lint job 全部门禁):
+ ```bash
+ python3 -m unittest discover tests # 35 用例:Golden Master + 锚点三方一致 + 双文件行级镜像 + 单调性状态源
+ python3 scripts/build_profile_svgs.py --lint # 零网络,marker 流与 assets 闭包审计
+ ```
+7. **散文重复收敛原则**:同一事实多处手写 = 漂移风险(先例:九厂商清单曾单侧漏一家,PR #7 只修了数据);收敛时指定唯一权威位置(如承诺性陈述放项目卡),其余降为回指;机器供数(DATA/alt)的重复无漂移风险,不做收敛。
diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md
index e398b21..7de2289 100644
--- a/docs/i18n/zh-CN/README.md
+++ b/docs/i18n/zh-CN/README.md
@@ -212,7 +212,7 @@
- **`give-me-a-break`** —— 状态机的 `evaluate` 零时间依赖。时钟由参数传入,于是整套休息/工作/AFK 生命周期都能在虚拟时钟下测试:睡眠、崩溃后快进恢复、休息中途被拔掉的显示器。三个模块,其中一个对 macOS 一无所知。
- **`hyper-git`** —— `engine/` 里没有一处 `vscode` import。仅这一条约束,就是 403 个单元测试之所以存在的原因:变更列表分组、DAG 泳道布局、Conventional Commits 校验,全都不需要编辑器宿主即可测试。架构说明称之为「Path B」——消费稳定的 `vscode.git` API,其上一切手绘,而不去 fork。
- **`hyper-git`,再一次** —— 五个 AI 接缝(`ILlmProvider`、`ICommitMessageProvider`、`IPreCommitInspector`、`IChangelistGrouper`、`IConflictResolver`)以空实现预先接入,形制取自 JetBrains 的 `CheckinHandler` 生命周期。接口先交付,智能延到 M5。早声明接缝很便宜,晚声明就不便宜了。
-- **`coding-proxy`** —— 故障转移链是策略,不是管道:熔断状态与配额记账按厂商分离、且是本地的,落在 SQLite-WAL 里。没有 Redis、没有队列,于是单进程就是整个部署,重启不会丢掉任何要紧的东西。
+- **`coding-proxy`** —— 故障转移链是策略,不是管道:熔断状态与配额记账按厂商分离、且是本地的,落在 SQLite-WAL 里——于是单进程就是整个部署,重启不会丢掉任何要紧的东西。
- **`negentropy`** —— 默认路径跑在可插拔后端中最便宜的那档上,**完全不需要云凭据**;如果最便宜的配置跑不起来,也就没人会去跑贵的那个。
说得好听,这叫可测性。说得诚实,这是一个单人维护者为了在自己的代码库里活下来必须做的事:这里没有第二双眼睛,所以设计必须让错误便宜到能被找出来。而那五个空的 AI 接缝,至今仍然是空的——接口是一份计划,不是一个功能。
@@ -290,7 +290,7 @@ sequenceDiagram
P-->>C: SSE 流,仍是 Anthropic 的形状
```
-客户端什么都不知道。这就是产品的全部:一行配置,把失败模式从「停止工作」改成「用别人的模型慢一点工作」。已接入九家——Claude 套餐、GitHub Copilot、Google Antigravity、Z AI 的 GLM、MiniMax、Qwen、小米、Kimi、豆包——每家独立熔断与配额守卫,另有本地 SQLite-WAL 看板,让消耗在账单之前就可见。
+客户端什么都不知道。这就是产品的全部:一行配置,把失败模式从「停止工作」改成「用别人的模型慢一点工作」。上方卡片点名的九家厂商,每家独立熔断与配额守卫,另有本地 SQLite-WAL 看板,让消耗在账单之前就可见。
FastAPI 与 httpx。12 个 release,最新仍带 alpha 标——三种请求形状之间的转译保真度,正是那个一直没做完的部分。链式转移也意味着请求可能在你没选的模型上成功;看板的存在,一部分就是为了让这件事可审计。
diff --git a/scripts/build_profile_svgs.py b/scripts/build_profile_svgs.py
index 3a534a8..81da44f 100644
--- a/scripts/build_profile_svgs.py
+++ b/scripts/build_profile_svgs.py
@@ -1655,6 +1655,41 @@ def upstream_alt(f, items, pub_prs):
ledger, format(pub_prs, ","), len(rows) / pub_prs * 100))
+def cadence_alt(f, rel_lists, domain, trunk_commits, rel_total):
+ lanes = sorted(((n, sorted(v, key=lambda r: r["published_at"]))
+ for n, v in rel_lists.items() if v),
+ key=lambda kv: (-len(kv[1]), kv[0]))
+ # The trunk sentence carries two numbers that MOVE — negentropy is the
+ # active repo, so a literal here would have the alt contradict the
+ # neg_commits marker rendered beside it within a month.
+ trunk_rels = len(rel_lists.get(TRUNK, ()))
+ if f == EN:
+ rows = "; ".join(
+ "%s: %d releases, %s on %s through %s on %s%s" % (
+ n, len(v), v[0]["tag_name"], v[0]["published_at"][:10],
+ v[-1]["tag_name"], v[-1]["published_at"][:10],
+ "" if not all(r["prerelease"] for r in v) else ", all pre-releases")
+ for n, v in lanes)
+ return ("Dot timeline of %d public releases across %d repositories on one shared "
+ "date axis, %s to %s. %s. Hollow dots are pre-releases. %s shows only its "
+ "%d release candidates against %s commits because it is a deployed "
+ "service, not a distributed package — its shipping unit is the merged "
+ "pull request, not the tag. Data: GitHub."
+ % (rel_total, len(lanes), domain[0], domain[1], rows,
+ TRUNK, trunk_rels, format(trunk_commits, ",")))
+ rows = ";".join(
+ "%s %d 个 release,%s(%s)至 %s(%s)%s" % (
+ n, len(v), v[0]["tag_name"], v[0]["published_at"][:10],
+ v[-1]["tag_name"], v[-1]["published_at"][:10],
+ "" if not all(r["prerelease"] for r in v) else ",全部为预发布")
+ for n, v in lanes)
+ return ("各仓库公开 release 的点式时间线(共用日期轴,%s 至 %s)。%s。空心点为预发布。"
+ "%s 在 %s 条提交面前只有 %d 个 rc,因为它是部署型服务而非分发包——"
+ "它的交付单元是已合并 PR,不是 tag。数据:GitHub。"
+ % (domain[0], domain[1], rows,
+ TRUNK, format(trunk_commits, ","), trunk_rels))
+
+
LAT_EDGES = [1, 2, 5, 15, 60, 240, 1440, float("inf")]
LAT_LABELS = ["<1m", "1-2", "2-5", "5-15", "15-60m", "1-4h", "4-24h", ">24h"]
@@ -1862,41 +1897,6 @@ def windowed_run(dom):
die(f"release roster {sorted(rel_counts)} != source roster "
f"{sorted(repo_commits)} — a repo's releases would render as 0, refusing")
- def cadence_alt(f, rel_lists, domain, trunk_commits):
- lanes = sorted(((n, sorted(v, key=lambda r: r["published_at"]))
- for n, v in rel_lists.items() if v),
- key=lambda kv: (-len(kv[1]), kv[0]))
- # The trunk sentence carries two numbers that MOVE — negentropy is the
- # active repo, so a literal here would have the alt contradict the
- # neg_commits marker rendered beside it within a month.
- trunk_rels = len(rel_lists.get(TRUNK, ()))
- if f == EN:
- rows = "; ".join(
- "%s: %d releases, %s on %s through %s on %s%s" % (
- n, len(v), v[0]["tag_name"], v[0]["published_at"][:10],
- v[-1]["tag_name"], v[-1]["published_at"][:10],
- "" if not all(r["prerelease"] for r in v) else ", all pre-releases")
- for n, v in lanes)
- return ("Dot timeline of %d public releases across %d repositories on one shared "
- "date axis, %s to %s. %s. Hollow dots are pre-releases. %s shows only its "
- "%d release candidates against %s commits because it is a deployed "
- "service, not a distributed package — its shipping unit is the merged "
- "pull request, not the tag. Data: GitHub."
- % (rel_total, len(lanes), domain[0], domain[1], rows,
- TRUNK, trunk_rels, format(trunk_commits, ",")))
- rows = ";".join(
- "%s %d 个 release,%s(%s)至 %s(%s)%s" % (
- n, len(v), v[0]["tag_name"], v[0]["published_at"][:10],
- v[-1]["tag_name"], v[-1]["published_at"][:10],
- "" if not all(r["prerelease"] for r in v) else ",全部为预发布")
- for n, v in lanes)
- return ("各仓库公开 release 的点式时间线(共用日期轴,%s 至 %s)。%s。空心点为预发布。"
- "%s 在 %s 条提交面前只有 %d 个 rc,因为它是部署型服务而非分发包——"
- "它的交付单元是已合并 PR,不是 tag。数据:GitHub。"
- % (domain[0], domain[1], rows,
- TRUNK, format(trunk_commits, ","), trunk_rels))
-
-
# ------------------------------------------------- figure derivations ----
# Degenerate-but-reachable shapes die cleanly here instead of raising a bare
# traceback further down; the write-nothing contract holds either way.
@@ -2078,7 +2078,8 @@ def ecdf_points():
"accrual": {f: accrual_alt(f, monthly, DOMAIN, grad_events, commits_total)
for f in README_FILES},
"lifecycles": {f: lifecycles_alt(f, spans, DOMAIN) for f in README_FILES},
- "cadence": {f: cadence_alt(f, rel_lists, DOMAIN, len(repo_commits.get(TRUNK, ())))
+ "cadence": {f: cadence_alt(f, rel_lists, DOMAIN,
+ len(repo_commits.get(TRUNK, ())), rel_total)
for f in README_FILES},
"streak": {f: streak_alt(f, rug_act, RUG_DAYS - rug_act, rug_run, rug_dom, RUG_DAYS)
for f in README_FILES},
diff --git a/tests/test_readme_structure.py b/tests/test_readme_structure.py
new file mode 100644
index 0000000..ea4fbcb
--- /dev/null
+++ b/tests/test_readme_structure.py
@@ -0,0 +1,70 @@
+"""Machine guards for the README contracts that --lint cannot see.
+
+The marker stream, its parity and the assets closure are audited by
+scripts/build_profile_svgs.py --lint on every PR. Three load-bearing surfaces
+used to rest on human discipline only; these tests lock them:
+
+1. Anchor triple-consistency — every anchor, every
+ #user-content-X link in the two cluster-nav rows, and every FIG key
+ must be the same set in the same order (nav links read across the two
+ rows follow the FIG cluster order). A broken link here fails silently on
+ GitHub (the user-content- prefix is renderer-side).
+2. Bilingual line-mirror — the two READMEs must carry their markers and their
+ structural skeleton (details/summary/anchors/tables/code fences) at
+ identical line numbers, and stay the same length. Prose may differ; the
+ shape may not.
+3. Monotonicity state source — DATA:cur_year/DATA:cur_total must survive in
+ both files: the monthly refresh reads them back from disk as its only
+ state (see main() in the generator).
+"""
+import re
+import unittest
+
+from tests._bps import ROOT
+
+EN, ZH = "README.md", "docs/i18n/zh-CN/README.md"
+
+MARKER = re.compile(r"")
+FIG_OPEN = re.compile(r"")
+ANCHOR = re.compile(r'')
+NAV = re.compile(r"#user-content-([a-z]+)")
+SKELETON = re.compile(
+ r"\d{4}")
+ self.assertRegex(text, r"[\d,]+")
+
+
+if __name__ == "__main__":
+ unittest.main()