diff --git a/README.md b/README.md index bcd9ed9..a6f24b7 100644 --- a/README.md +++ b/README.md @@ -20,62 +20,139 @@ --- +
+ +**When the work happens** · [Growth, year by year](#user-content-growth) · [Hour of day](#user-content-rhythm) · [Weekday × hour](#user-content-punchcard) · [Weekday vs weekend](#user-content-surplus) · [Per repository](#user-content-ground) · [Cumulative](#user-content-accrual) · [Repository lifecycles](#user-content-lifecycles) · [Releases in time](#user-content-cadence) · [Unbroken days](#user-content-streak) + +
+ +
+Growth, year by year + Column chart, contributions per year 2016 to 2026 on a square-root scale: 1, 0, 0, 13, 129, 198, 676, 589, 1,181, 3,193, 9,313. 2017 and 2018 are exactly zero, drawn as open slots below the axis. 2023 (589) is lower than 2022 (676). Data: GitHub. Eleven years, square-root scale. **9,313 in 2026** — after two years that are genuinely zero and one year that is genuinely down. **2016** — one commit. **2017–2018** — nothing; the gap in the chart is real, and it is two years long. **2019** — 13. **2020** — 129, the year of coming back. **2021** — 198. **2022** — 676. **2023** — 589, lower than the year before, and the chart does not smooth it. **2024** — 1,181; the first upstream patch merged into [Dify](https://github.com/langgenius/dify/pull/5631) that July. **2025** — 3,193; `negentropy-perceives` in August, `agentic-ai-cognizes` in October. **2026-01-31** — the trunk starts. **2026-05-18** — both of those repositories are archived into it. **2026** — 9,313 so far. +
+ +
+Hour of day + Histogram of 4,349 open-source commits by hour of day, Asia/Shanghai, axis running 04:00 through 03:00 so the night block stays contiguous. Values by hour from 04:00: 0, 0, 0, 11, 44, 232, 338, 319, 186, 184, 265, 285, 262, 292, 242, 206, 214, 348, 460, 329, 91, 36, 3, 2. 04:00 to 06:59 are exactly zero, drawn as open slots below the axis. Peak 22:00 with 460 commits, 10.6 percent of all commits, 2.54 times a flat baseline. Bars below the 2.5-pixel minimum height are drawn at that minimum. The surplus, located: **22:00**, 2.54× a flat baseline — 4,349 open-source commits, Asia/Shanghai. Dawn is empty, not missing. +
+ +
+Weekday × hour + Punchcard of 4,349 open-source commits by weekday and hour, Asia/Shanghai, columns running 04:00 to 03:00 and rows Monday to Sunday, window 2024-06-22 to 2026-09-01. Cell area is proportional to count; cell side rounded to one decimal. Weekday totals Monday to Sunday: 615, 557, 563, 504, 602, 780, 728. Densest cell Sunday at 22:00 with 83 commits. 37 of 168 cells are exactly zero, drawn as open squares. Weekends hold 1,508 commits = 34.7 percent, against a flat share of 28.6. Data: GitHub. The two-dimensional answer: densest cell **Sun 22:00**, 83 commits; 37 of 168 cells are true zeros; weekends hold 34.7% against a 28.6% flat share. +
+ +
+Weekday vs weekend + Two step curves on one hour-of-day axis running 04:00 to 03:00, Asia/Shanghai, 2024-06-22 to 2026-09-01, normalised to commits per day of that kind so 572 weekdays and 230 weekend days are comparable. Weekday rate by hour from 04:00: 0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.5, 0.4, 0.2, 0.2, 0.3, 0.3, 0.3, 0.3, 0.2, 0.2, 0.2, 0.4, 0.5, 0.4, 0.1, 0.0, 0.0, 0.0. Weekend rate: 0.0, 0.0, 0.0, 0.0, 0.1, 0.2, 0.2, 0.3, 0.3, 0.4, 0.4, 0.4, 0.4, 0.5, 0.5, 0.4, 0.5, 0.6, 0.7, 0.5, 0.2, 0.1, 0.0, 0.0. Weekday peak 22:00 at 0.5 per day; weekend peak 22:00 at 0.7. Data: GitHub. Weekday vs weekend, normalised to per-day rates (**572 weekdays ÷ 230 weekend days**): same shape, and the weekend actually runs hotter per day — peak at 22:00. "By day" is a job description, not a schedule. +
+ +
+Per repository + Horizontal bar chart, commits per source repository, sorted: negentropy 2,048; agentic-ai-cognizes 945; coding-proxy 638; negentropy-perceives 433; hyper-git 125; threefish-ai.github.io 117; give-me-a-break 43. Total 4,349 commits and 29 releases across 7 source repositories; negentropy is 47.1 percent of commits. Releases: coding-proxy 12, give-me-a-break 7, hyper-git 7, negentropy 2, negentropy-perceives 1. agentic-ai-cognizes and negentropy-perceives are archived — they graduated into the negentropy trunk. [negentropy](https://github.com/ThreeFish-AI/negentropy) knowledge engine · [coding-proxy](https://github.com/ThreeFish-AI/coding-proxy) failover for coding agents · [hyper-git](https://github.com/ThreeFish-AI/hyper-git) changelists for VS Code · [agents.md](https://github.com/ThreeFish-AI/agents.md) the doctrine they are written under +
+ +
+Cumulative + Cumulative authored commits per source repository at monthly resolution, 2024-06-22 to 2026-09-01 — a window of about 26 months, not the eleven years shown above. Final totals: agentic-ai-cognizes 945, coding-proxy 638, give-me-a-break 43, hyper-git 125, negentropy 2,048, negentropy-perceives 433, threefish-ai.github.io 117; all repos together 4,349. On 2026-05-18 agentic-ai-cognizes and negentropy-perceives flatten and the trunk keeps rising: they graduated into the negentropy trunk that day. Data: GitHub. Cumulative, monthly: two curves flatten on the day they graduated into the trunk, which keeps climbing. Window 2024-06-22 → 2026-09-01 — **26 months, not the eleven years above.** +
+ +
+Repository lifecycles + Timeline ribbon, one row per source repository from creation to last push, 2024-06-22 to 2026-09-01. agentic-ai-cognizes created 2025-10-22, last push 2026-05-18, archived (graduated); coding-proxy created 2026-03-30, last push 2026-08-27, active; give-me-a-break created 2026-06-23, last push 2026-09-04, active; hyper-git created 2026-06-27, last push 2026-09-01, active; negentropy created 2026-01-31, last push 2026-09-04, active; negentropy-perceives created 2025-08-26, last push 2026-05-18, archived (graduated); threefish-ai.github.io created 2024-06-22, last push 2026-07-08, active. Bar ends mark last push — the closest public proxy for archival, because GitHub exposes no archive timestamp; archived-ness rides the square terminal glyph instead. Repositories deleted before today are invisible at public caliber. Data: GitHub. Repositories as intervals, creation to last push; the two same-day endings are the graduation. Deleted repos are invisible at this caliber. +
+ +
+Releases in time + Dot timeline of 29 public releases across 5 repositories on one shared date axis, 2024-06-22 to 2026-09-01. coding-proxy: 12 releases, v0.1.1 on 2026-04-05 through v0.5.2a8 on 2026-07-06; give-me-a-break: 7 releases, v0.1.0 on 2026-06-27 through v0.1.6 on 2026-09-04; hyper-git: 7 releases, v0.0.6 on 2026-06-30 through v0.0.16 on 2026-09-01; negentropy: 2 releases, negentropy-v0.0.1-rc.1 on 2026-06-19 through negentropy-v0.0.1-rc.2 on 2026-06-19, all pre-releases; negentropy-perceives: 1 releases, v0.2.0a3 on 2026-04-20 through v0.2.0a3 on 2026-04-20. Hollow dots are pre-releases. negentropy shows only its 2 release candidates against 2,048 commits because it is a deployed service, not a distributed package — its shipping unit is the merged pull request, not the tag. Data: GitHub. Releases have timing, not just counts — latest: **give-me-a-break v0.1.6**. negentropy ships as merged PRs, not tags. +
+ +
+Unbroken days + Rug plot, one tick per day over the latest 400 days, 2025-07-29 to 2026-09-01; tick height is the square root of that day's authored-commit count. 208 days carry at least one commit (52 percent); the 192 empty ones are drawn as a thin rule below the axis, one segment per gap. The longest unbroken run inside this window is 88 days, 2026-03-19 to 2026-06-14, marked with a bracket. Counted over authored commits in source repositories — the same population as every other commit figure here, and narrower than the GitHub contributions calendar, which also counts private work, issues and reviews. Data: GitHub. The run the rug brackets: **88 days, 2026-03-19 → 2026-06-14**, the longest inside its latest-400-day window; the all-time longest is 88 days (2026-03-19 → 2026-06-14). Of the 802 days in the whole window, 249 carried a commit (31%) — authored commits in source repos, a narrower population than the GitHub calendar above. +
+ --- +
+ +**How it ships** · [How long a PR waits](#user-content-latency) · [Commit grammar](#user-content-grammar) · [Language shares](#user-content-tongues) · [Upstream pull requests](#user-content-upstream) + +
+ +
+How long a PR waits + Histogram of merged pull-request lifetime in negentropy with a step cumulative curve on a right-hand zero-to-hundred-percent axis. 1078 merged pull requests, log-spaced buckets: <1m 281, 1-2 72, 2-5 144, 5-15 225, 15-60m 177, 1-4h 92, 4-24h 83, >24h 4. Median 6 min; 90th percentile 2.8 h; 83 percent merge within an hour; the longest waited 11.9 d. 40 further closed pull requests were never merged and are excluded. This is a solo self-merge repository: the lifetime measures how small a change unit is, not how fast a review is. Data: GitHub. What the median hides: p90 **2.8 h**, longest **11.9 d**; of 1118 closed, 40 were never merged and are excluded. Solo self-merge: this measures change-unit size, not review speed. +
+ +
+Commit grammar + Waffle chart of one hundred cells, each cell one percent of 4,349 authored commit subjects classified by Conventional Commits prefix. fix 827, docs 725, feat 697, refactor 319, chore 255, style 176, test 144, ci 110, build 92, perf 6, revert 2. 996 subjects do not parse as Conventional Commits and are drawn as open cells. The accented block is fix, the largest type at 19.0 percent of all subjects. Data: GitHub. Seventy-seven of a hundred, made countable: **fix 19.0%** leads; **996 subjects (22.9%) don't parse** — drawn as open cells, not hidden. +
+ +
+Language shares + Slope chart of language byte shares over the source repositories under two conditions. Left, as GitHub's language endpoint reports it: HTML 67.8 percent, Python 22.7 percent, TypeScript 8.3 percent, Swift 0.5 percent, CSS 0.2 percent, C# 0.2 percent. Right, excluding threefish-ai.github.io, whose bytes of HTML are generated static-site output rather than authored source: Python 69.1 percent, TypeScript 25.2 percent, HTML 2.3 percent, Swift 1.4 percent, C# 0.6 percent, CSS 0.5 percent. HTML falls from rank 1 to rank 3; Python rises to rank 1. Byte counts measure committed source size, not authorship or effort, and include vendored files. Every source repo contributes counted bytes. Data: GitHub. Both conditions, not a silent pick: GitHub's endpoint says **HTML 67.8%**; excluding the one generated-site repo, **Python 69.1%**. Bytes measure source size, not effort or authorship. +
+ +
+Upstream pull requests + Dot ledger of 6 public pull requests to repositories owned by others, 2024-06-26 to 2025-12-06. langgenius/dify#5631, merged, 2024-06-26; langgenius/dify#8921, merged, 2024-09-30; langgenius/dify-plugin-daemon#389, closed unmerged, 2025-07-07; langgenius/dify-cloud-kit#3, merged, 2025-07-08; langgenius/dify#22646, merged, 2025-07-18; DayuanJiang/next-ai-draw-io#124, merged, 2025-12-06. These are 0.3 percent of 1,849 public pull requests; the rest are to my own repositories. Data: GitHub is:public search. N = 6, named: 2024-06-26 → 2025-12-06, 5 merged, one closed unmerged — a fraction of a percent of all public PRs. +
+ --- ### Built against five ways things fall apart diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index 44c4772..e398b21 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -20,62 +20,139 @@ --- +
+ +**工作发生在何时** · [增长,逐年](#user-content-growth) · [一日节律](#user-content-rhythm) · [星期 × 小时](#user-content-punchcard) · [工作日与周末](#user-content-surplus) · [按仓库计](#user-content-ground) · [累计曲线](#user-content-accrual) · [仓库生灭](#user-content-lifecycles) · [发布节奏](#user-content-cadence) · [连续天数](#user-content-streak) + +
+ +
+增长,逐年 + 2016–2026 逐年贡献柱状图,平方根标度:1, 0, 0, 13, 129, 198, 676, 589, 1,181, 3,193, 9,313。2017、2018 为真实零值,画作基线下方的空槽。2023(589)低于 2022(676)。数据:GitHub。 十一年,平方根标度。**9,313(2026)**——此前是两个真正的零,和一次真正的回落。 **2016** —— 一条提交。**2017–2018** —— 空的;图上那个缺口是真的,而且长达两年。**2019** —— 13 条。**2020** —— 129 条,回来的那一年。**2021** —— 198 条。**2022** —— 676 条。**2023** —— 589 条,低于上一年,图没有把它抹平。**2024** —— 1,181 条;当年 7 月,第一个上游补丁合入 [Dify](https://github.com/langgenius/dify/pull/5631)。**2025** —— 3,193 条;8 月有了 `negentropy-perceives`,10 月有了 `agentic-ai-cognizes`。**2026-01-31** —— 主干开始。**2026-05-18** —— 那两个仓库归档并入主干。**2026** —— 目前 9,313 条。 +
+ +
+一日节律 + 开源提交按小时分布直方图(共 4,349 条,Asia/Shanghai,横轴自 04:00 起至 03:00,使夜间块连续)。自 04:00 起逐小时数值:0, 0, 0, 11, 44, 232, 338, 319, 186, 184, 265, 285, 262, 292, 242, 206, 214, 348, 460, 329, 91, 36, 3, 2。04:00–06:59 为真实零值(基线下方空槽)。峰值 22:00 共 460 条——占全部提交 10.6%,为平坦基线的 2.54 倍。低于最小可见高度 2.5 像素的柱按最小高度绘制。 余暇的落点:**22:00**,平坦基线的 2.54×——4,349 条开源提交,Asia/Shanghai。黎明是空的,不是缺数据。 +
+ +
+星期 × 小时 + 开源提交按星期 × 小时的打卡图(共 4,349 条,Asia/Shanghai,列自 04:00 至 03:00,行自周一至周日,窗口 2024-06-22 至 2026-09-01)。格面积与提交数成正比,边长保留一位小数。周一至周日合计:615、557、563、504、602、780、728。最密格 Sunday 22:00,83 条。168 格中 37 格为真实零值,画作空心方框。周末合计 1,508 条,占 34.7%(平坦份额为 28.6%)。数据:GitHub。 二维答案:最密格 **Sun 22:00**(周日 22:00),83 条提交;168 格中 37 格为真实零值;周末占 34.7%(平坦份额 28.6%)。 +
+ +
+工作日与周末 + 同一小时轴(04:00 至 03:00,Asia/Shanghai,2024-06-22 至 2026-09-01)上的两条阶梯曲线,按「该类日」归一为日均提交,使 572 个工作日与 230 个周末日可比。工作日自 04:00 起逐小时速率:0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.5, 0.4, 0.2, 0.2, 0.3, 0.3, 0.3, 0.3, 0.2, 0.2, 0.2, 0.4, 0.5, 0.4, 0.1, 0.0, 0.0, 0.0。周末:0.0, 0.0, 0.0, 0.0, 0.1, 0.2, 0.2, 0.3, 0.3, 0.4, 0.4, 0.4, 0.4, 0.5, 0.5, 0.4, 0.5, 0.6, 0.7, 0.5, 0.2, 0.1, 0.0, 0.0。工作日峰值 22:00(日均 0.5 条);周末峰值 22:00(日均 0.7 条)。数据:GitHub。 工作日对周末,按日均归一(**572 个工作日 ÷ 230 个周末日**):形状相同,且周末的日均反而更热——峰值落在 22:00。「白天」是职业描述,不是作息表。 +
+ +
+按仓库计 + 各源仓库提交量水平条形图(降序):negentropy 2,048;agentic-ai-cognizes 945;coding-proxy 638;negentropy-perceives 433;hyper-git 125;threefish-ai.github.io 117;give-me-a-break 43。共 4,349 条提交、29 个 release、7 个源仓库;negentropy 占 47.1%。Release 分布:coding-proxy 12、give-me-a-break 7、hyper-git 7、negentropy 2、negentropy-perceives 1。agentic-ai-cognizes、negentropy-perceives 已归档——毕业并入 negentropy 主干。 [negentropy](https://github.com/ThreeFish-AI/negentropy) 知识引擎 · [coding-proxy](https://github.com/ThreeFish-AI/coding-proxy) 编码 Agent 故障转移 · [hyper-git](https://github.com/ThreeFish-AI/hyper-git) VS Code 变更列表 · [agents.md](https://github.com/ThreeFish-AI/agents.md) 它们共同遵循的规约 +
+ +
+累计曲线 + 各源仓库累计提交折线(月分辨率,2024-06-22 至 2026-09-01——约 26 个月窗口,不是上方那张十一年图)。期末合计:agentic-ai-cognizes 945;coding-proxy 638;give-me-a-break 43;hyper-git 125;negentropy 2,048;negentropy-perceives 433;threefish-ai.github.io 117;全部仓库共 4,349。2026-05-18,agentic-ai-cognizes、negentropy-perceives 走平、主干继续上升:该日毕业并入 negentropy 主干。数据:GitHub。 月度累计:两条曲线在毕业并入主干的那一天走平,而主干继续爬升。窗口 2024-06-22 → 2026-09-01——**26 个月,不是上方那张十一年图。** +
+ +
+仓库生灭 + 仓库生命周期色带,每行一个源仓库,自创建至最后推送,2024-06-22 至 2026-09-01。agentic-ai-cognizes 创建于 2025-10-22,最后推送 2026-05-18,已归档(毕业);coding-proxy 创建于 2026-03-30,最后推送 2026-08-27,活跃;give-me-a-break 创建于 2026-06-23,最后推送 2026-09-04,活跃;hyper-git 创建于 2026-06-27,最后推送 2026-09-01,活跃;negentropy 创建于 2026-01-31,最后推送 2026-09-04,活跃;negentropy-perceives 创建于 2025-08-26,最后推送 2026-05-18,已归档(毕业);threefish-ai.github.io 创建于 2024-06-22,最后推送 2026-07-08,活跃。条形终点是最后推送——归档的最近公开代理,因为 GitHub 不公开归档时间戳;归档与否由方形终端图元承载。已被删除的仓库在公开口径下不可见。数据:GitHub。 仓库即区间,自创建至最后推送;两根同日终止的条就是那次毕业。已删除的仓库在此口径下不可见。 +
+ +
+发布节奏 + 各仓库公开 release 的点式时间线(共用日期轴,2024-06-22 至 2026-09-01)。coding-proxy 12 个 release,v0.1.1(2026-04-05)至 v0.5.2a8(2026-07-06);give-me-a-break 7 个 release,v0.1.0(2026-06-27)至 v0.1.6(2026-09-04);hyper-git 7 个 release,v0.0.6(2026-06-30)至 v0.0.16(2026-09-01);negentropy 2 个 release,negentropy-v0.0.1-rc.1(2026-06-19)至 negentropy-v0.0.1-rc.2(2026-06-19),全部为预发布;negentropy-perceives 1 个 release,v0.2.0a3(2026-04-20)至 v0.2.0a3(2026-04-20)。空心点为预发布。negentropy 在 2,048 条提交面前只有 2 个 rc,因为它是部署型服务而非分发包——它的交付单元是已合并 PR,不是 tag。数据:GitHub。 Release 有时机,不止有计数——最新一个:**give-me-a-break v0.1.6**。negentropy 以已合并 PR 交付,不以 tag。 +
+ +
+连续天数 + 一维 rug 图,取最近 400 天(2025-07-29 至 2026-09-01),每天一根刻度,高度为当日署名提交数的平方根。208 天有至少一条提交(52%);其余 192 天画作基线下方的细线,每个空档一段。窗口内最长连续区间 88 天(2026-03-19 至 2026-06-14),以括号标出。口径为源仓库内我署名的提交——与本页其他提交图同一总体,且窄于 GitHub 贡献日历(后者还计入私有工作、issue 与 review)。数据:GitHub。 rug 图括号标注的区间:**88 天,2026-03-19 → 2026-06-14**,是它那最近 400 天窗口内最长的一段;历史最长为 88 天(2026-03-19 → 2026-06-14)。整个窗口 802 天中 249 天有提交(31%)——口径为源仓库内我署名的提交,窄于上方的 GitHub 贡献日历。 +
+ --- +
+ +**如何交付** · [一个 PR 等多久](#user-content-latency) · [提交语法](#user-content-grammar) · [语言占比](#user-content-tongues) · [上游 PR](#user-content-upstream) + +
+ +
+一个 PR 等多久 + negentropy 已合并 PR 的生存时间直方图,右侧带 0–100% 阶梯累积曲线。共 1078 个已合并PR,对数分箱:<1m 281、1-2 72、2-5 144、5-15 225、15-60m 177、1-4h 92、4-24h 83、>24h 4。中位 6 min;90 分位 2.8 h;83% 一小时内合并;最长等待 11.9 d。另有 40 个已关闭未合并的 PR,未计入。这是单人自合并仓库:生存时间量的是变更单元有多小,不是评审有多快。数据:GitHub。 中位数藏掉的部分:p90 **2.8 h**,最长 **11.9 d**;已关闭 1118 个中 40 个未合并,未计入。单人自合并:量的是变更单元大小,不是评审速度。 +
+ +
+提交语法 + 一百格华夫图,每格代表 4,349 条署名提交主题的百分之一,按 Conventional Commits 前缀分类。fix 827、docs 725、feat 697、refactor 319、chore 255、style 176、test 144、ci 110、build 92、perf 6、revert 2。996 条主题不能解析为 Conventional Commits,画作空心格。着色块为 fix——最大类型,占全部主题的 19.0%。数据:GitHub。 让「一百中之七十七」可数:**fix 19.0%** 居首;**996 条主题(22.9%)不合规**——画作空心格,而不是藏起来。 +
+ +
+语言占比 + 源仓库语言字节占比的斜率图,两种口径。左:GitHub 语言端点原样——HTML 67.8%,Python 22.7%,TypeScript 8.3%,Swift 0.5%,CSS 0.2%,C# 0.2%。右:剔除 threefish-ai.github.io(其 HTML 字节是静态站构建产物而非手写源码)——Python 69.1%,TypeScript 25.2%,HTML 2.3%,Swift 1.4%,C# 0.6%,CSS 0.5%。HTML 从第 1 位跌至第 3 位;Python 升至第 1 位。字节数度量的是提交源码体积,不是作者归属或工作量,且含 vendored 文件。每个源仓库均有计入字节。数据:GitHub。 两种口径并陈,而非悄悄二选一:GitHub 端点说 **HTML 67.8%**;剔除那个静态站构建产物仓库后,**Python 69.1%**。字节数度量源码体积,不度量工作量或作者归属。 +
+ +
+上游 PR + 提交给他人仓库的公开 PR 点账本,2024-06-26 至 2025-12-06。langgenius/dify#5631,已合并,2024-06-26;langgenius/dify#8921,已合并,2024-09-30;langgenius/dify-plugin-daemon#389,关闭未合并,2025-07-07;langgenius/dify-cloud-kit#3,已合并,2025-07-08;langgenius/dify#22646,已合并,2025-07-18;DayuanJiang/next-ai-draw-io#124,已合并,2025-12-06。它们占 1,849 个公开 PR 的 0.3%;其余都提交给我自己的仓库。数据:GitHub is:public 检索。 N = 6,全部具名:2024-06-26 → 2025-12-06,5 个已合并、一个关闭未合并——占全部公开 PR 的千分之几。 +
+ --- ### 针对五种崩坏方式而建 diff --git a/scripts/build_profile_svgs.py b/scripts/build_profile_svgs.py index 10dbcd3..3a534a8 100644 --- a/scripts/build_profile_svgs.py +++ b/scripts/build_profile_svgs.py @@ -70,6 +70,7 @@ MIN_SOURCE_COMMITS = 10 # "source repository" = non-fork repo with >= N commits authored by USER FIRST_YEAR = 2016 RHYTHM_ORIGIN = 4 # hour axis starts at 04:00 so the night block stays contiguous +HOUR_ORDER = [(RHYTHM_ORIGIN + k) % 24 for k in range(24)] DIFY_OWNER = "langgenius" # the one upstream ecosystem the READMEs name by hand CONVENTIONAL = re.compile( r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]*\))?(!)?:\s*\S" @@ -140,14 +141,6 @@ def die(msg): sys.exit(1) -MODE = "write" -for _arg in sys.argv[1:]: - if _arg in ("--lint", "--check"): - MODE = _arg[2:] - else: - die(f"unknown argument {_arg!r} — expected --lint or --check") - - # -------------------------------------------------------------- markers ---- def scan_markers(text, f): """Ordered [(kind, key)] of the regions in one file, with well-formedness @@ -237,17 +230,6 @@ def fig(m): fig, text, flags=re.S) -if MODE == "lint": - _orders = {f: scan_markers(t, f) for f, t in read_readmes().items()} - audit_parity(_orders) - audit_assets() - _d = sum(1 for k, _ in _orders[EN] if k == "DATA") - _g = sum(1 for k, _ in _orders[EN] if k == "FIG") - print(f"OK lint: {_d} DATA + {_g} FIG regions, identical set and order in " - f"both READMEs; assets/ clean") - sys.exit(0) - - def gh(path): r = subprocess.run(["gh", "api", path], capture_output=True, text=True) if r.returncode != 0: @@ -278,12 +260,12 @@ def anon_contribution_counts(url, expect_year=None): url, headers={"User-Agent": "threefish-profile-refresh"} ) with urllib.request.urlopen(req, timeout=60, context=SSL_CTX) as r: - html = r.read().decode("utf-8", "replace") + page = r.read().decode("utf-8", "replace") days = [ int(x.replace(",", "")) - for x in re.findall(r">([\d,]+) contributions? on ", html) + for x in re.findall(r">([\d,]+) contributions? on ", page) ] - m = re.search(r"]*>([\d,]+) contributions in (\d{4})", html) + m = re.search(r"]*>([\d,]+) contributions in (\d{4})", page) if m and expect_year and int(m.group(2)) == expect_year: h2_total = int(m.group(1).replace(",", "")) if sum(days) != h2_total: @@ -294,126 +276,6 @@ def anon_contribution_counts(url, expect_year=None): return days -# ---------------------------------------------------------------- data ---- -print("collecting: yearly contributions (anonymous view) …") -now = datetime.now(TZ) -years, values = [], [] -for y in range(FIRST_YEAR, now.year + 1): - counts = anon_contribution_counts( - f"https://github.com/users/{USER}/contributions?from={y}-01-01&to={y}-12-31", - expect_year=y, - ) - years.append(y) - values.append(sum(counts)) - print(f" {y}: {values[-1]:,}") - -if sum(values) <= 0: - die("yearly contribution totals are all zero — page parse broken") - -print("collecting: repositories …") -repos = [r for r in gh_paginate(f"users/{USER}/repos?per_page=100") if not r["fork"]] -total_stars = sum(r["stargazers_count"] for r in repos) -acc_stars = next((r["stargazers_count"] for r in repos if r["name"] == ACC_REPO), None) -if acc_stars is None: - die(f"{ACC_REPO} missing from repo list — star split is undefined") -own_stars = total_stars - acc_stars - -print("collecting: authored commits per repository (first page probe) …") -repo_commits = {} -for r in repos: - name = r["name"] - page1 = gh(f"repos/{USER}/{name}/commits?author={USER}&per_page=100") - if len(page1) < MIN_SOURCE_COMMITS: - continue # below "source repository" threshold - if len(page1) < 100: - commits = page1 - else: # keep page1; only the pages after it are new requests - commits = page1 + gh_paginate( - f"repos/{USER}/{name}/commits?author={USER}&per_page=100&page=2" - ) - repo_commits[name] = commits - print(f" {name}: {len(commits)} authored commits") - -src_repos = len(repo_commits) -all_commits = [c for lst in repo_commits.values() for c in lst] -commits_total = len(all_commits) -if commits_total < 100: - die(f"only {commits_total} authored commits collected — parse likely broken") - -hours = Counter() -weekhours = Counter() # (weekday 0=Mon, hour) -> n -day_counts = Counter() # date -> n -lang_of_commit = [] # commit dates for the shared time-domain -types = Counter() # Conventional Commits type -> n -conv = 0 -for c in all_commits: - a = c["commit"]["author"] - dt = datetime.strptime(a["date"], "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.utc - ).astimezone(TZ) - hours[dt.hour] += 1 - weekhours[(dt.weekday(), dt.hour)] += 1 - day_counts[dt.date()] += 1 - lang_of_commit.append(dt.date()) - m = CONVENTIONAL.match(c["commit"]["message"].split("\n")[0]) - if m: - conv += 1 - types[m.group(1)] += 1 - -sorted_days = sorted(day_counts) -streak = best = 1 -run_start = run_end = sorted_days[0] -best_start = best_end = sorted_days[0] -for prev, cur in zip(sorted_days, sorted_days[1:]): - if (cur - prev).days == 1: - streak += 1 - run_end = cur - else: - streak = 1 - run_start = cur - if streak > best: - best, best_start, best_end = streak, run_start, run_end -streak = best - -# The shared x-domain for every time-axis figure (accrual, lifecycles, cadence, -# streak): three stacked figures with three silently different ranges is a -# worse honesty failure than any single figure's caveat. -DOMAIN = (min(lang_of_commit), max(lang_of_commit)) -WIN_DAYS = (DOMAIN[1] - DOMAIN[0]).days + 1 - -# Pipeline-closure guard: every authored commit must land in exactly one hour -# bucket. A mismatch means the hour histogram and the repository table below -# would silently disagree with each other. -if sum(hours.values()) != commits_total: - die( - f"hour histogram {sum(hours.values())} != repo total {commits_total} — " - "caliber split, refusing" - ) -if sum(weekhours.values()) != commits_total or sum(day_counts.values()) != commits_total: - die(f"weekday/day histogram {sum(weekhours.values())}/{sum(day_counts.values())} " - f"!= repo total {commits_total} — caliber split, refusing") -if sum(types.values()) != conv: - die(f"commit-type histogram {sum(types.values())} != conventional count {conv} " - "— CONVENTIONAL regex drift, refusing") - -print("collecting: negentropy pull requests …") -pulls = gh_paginate(f"repos/{USER}/negentropy/pulls?state=closed&per_page=100") -merged = [p for p in pulls if p["merged_at"]] -neg_pr = len(merged) -pr_closed = len(pulls) -pr_unmerged = pr_closed - neg_pr -lifetimes = [] -for p in merged: - created = datetime.strptime(p["created_at"], "%Y-%m-%dT%H:%M:%SZ") - merged_at = datetime.strptime(p["merged_at"], "%Y-%m-%dT%H:%M:%SZ") - lifetimes.append((merged_at - created).total_seconds() / 60) -lifetimes_sorted = sorted(lifetimes) -neg_median = statistics.median(lifetimes) -pct_hour = sum(1 for m in lifetimes if m <= 60) / len(lifetimes) * 100 -p90_lat = lifetimes_sorted[min(len(lifetimes_sorted) - 1, int(round(0.9 * len(lifetimes_sorted))) - 1)] -lat_max_min = lifetimes_sorted[-1] - - def human_duration(minutes): if minutes < 60: return f"{minutes:.0f} min" @@ -421,77 +283,7 @@ def human_duration(minutes): return f"{minutes / 60:.1f} h" return f"{minutes / 60 / 24:.1f} d" -print("collecting: releases (every source repo, drafts excluded) …") -# Roster derived from the source repos, never hand-listed: a hand-listed roster -# silently rendered a real release as 0 for months. Drafts are excluded because -# they are invisible anonymously but visible to a push-capable token, which -# would make a local run and a CI run disagree. -rel_lists = {} -for name in repo_commits: - rel_lists[name] = [ - x for x in gh(f"repos/{USER}/{name}/releases?per_page=100") if not x["draft"] - ] -rel_counts = {k: len(v) for k, v in rel_lists.items()} -rel_total = sum(rel_counts.values()) -rel_repos = sum(1 for v in rel_counts.values() if v) - -print("collecting: language bytes per source repo (public caliber) …") -# This is exactly the data behind the language bar a logged-out visitor sees on -# each repo page — the one endpoint whose naive answer (HTML, 69.7%) is an -# artifact of a generated static site. The slope figure exists to show both -# conditions instead of silently picking one. -repo_langs = {name: gh(f"repos/{USER}/{name}/languages") for name in repo_commits} -lang_all = Counter() -lang_src = Counter() # excluding the generated-site repo GEN_SITE = "threefish-ai.github.io" -for name, by in repo_langs.items(): - for lang, byts in by.items(): - lang_all[lang] += byts - if name != GEN_SITE: - lang_src[lang] += byts -zero_byte_repos = [n for n, by in repo_langs.items() if not by] - -print("collecting: public PR totals (is:public caliber) …") -pub_prs = gh( - "search/issues?q=is:pr+author:ThreeFish-AI+is:public&per_page=1" -)["total_count"] -# per_page=30 rather than 1: the same request already carries the items, so the -# upstream ledger below is derived rather than hand-written prose that rots. -ext = gh( - "search/issues?q=is:pr+author:ThreeFish-AI+is:public+-user:ThreeFish-AI&per_page=30" -) -ext_prs = ext["total_count"] -ext_items = ext["items"] -ext_merged = sum(1 for it in ext_items if it["pull_request"]["merged_at"]) -ext_owners = {it["repository_url"].split("/repos/")[1].split("/")[0] for it in ext_items} -ext_dify = sum( - 1 for it in ext_items - if it["pull_request"]["merged_at"] - and it["repository_url"].split("/repos/")[1].startswith(f"{DIFY_OWNER}/") -) - -archived_names = sorted( - r["name"] for r in repos if r["archived"] and r["name"] in repo_commits -) - -# ------------------------------------------------------------- guards ---- -if ext_prs > 10: - die(f"external public PR count = {ext_prs} — search caliber drifted, refusing") -# The guard above doubles as the ledger's layout contract; keep them together. -if ext_prs != len(ext_items): - die(f"external PR ledger truncated: total_count {ext_prs} != items " - f"{len(ext_items)} — raise per_page, refusing") -if ext_merged > ext_prs or ext_dify > ext_merged: - die(f"ledger arithmetic broken: {ext_dify} dify <= {ext_merged} merged <= " - f"{ext_prs} total violated, refusing") -if own_stars != total_stars - acc_stars: - die("star split arithmetic broken") -if len(years) != len(values) or any(v < 0 for v in values): - die("year series malformed") -if set(rel_counts) != set(repo_commits): - die(f"release roster {sorted(rel_counts)} != source roster " - f"{sorted(repo_commits)} — a repo's releases would render as 0, refusing") - # ----------------------------------------------------------- rendering ---- # Design tokens (Primer palette, WCAG-verified: text >= 4.5:1, graphics >= 3:1 # on both GitHub canvases). No opacity layering — solid inks only. @@ -523,6 +315,16 @@ def human_duration(minutes): FONT_SLACK = 1.14 +def parse_iso(s): + """GitHub API timestamp (UTC, second precision) as a naive UTC datetime.""" + return datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ") + + +def parse_iso_local(s): + """The same timestamp re-based into the profile's timezone.""" + return parse_iso(s).replace(tzinfo=timezone.utc).astimezone(TZ) + + def text_w(s, size): """Advance width of `s` in px, over-estimated (see FONT_SLACK). Unknown Latin falls back to digit width; CJK and other wide scripts to one full em @@ -596,8 +398,8 @@ def svg_open(w, h, aria): def render_growth(values, years, asof, aria): """`aria` is passed in, not computed here: under the SVG's internal aria-label is ignored and the README's alt attribute is the only - screen-reader channel, so both languages' alt text is derived once (see - figure_alt) and this function is merely one of its consumers.""" + screen-reader channel, so both languages' alt text is derived once by the + *_alt builders / ALTS table and this function is merely one of its consumers.""" W, H = 700, 168 L, R, BASE = 42.0, 686.0, 126.0 SPAN, MIN_BAR, ZSLOT = 88.0, 2.5, 4.0 # plot height / bar floor / zero-slot h @@ -605,7 +407,6 @@ def render_growth(values, years, asof, aria): # pixels of height above baseline) while every non-zero bar is >= MIN_BAR # above it — "nothing" can never render taller than "something". ZERO_Y = BASE + 2 - assert ZERO_Y > BASE n = len(values) pitch = (R - L) / n bw = round(pitch * 0.52, 1) @@ -632,15 +433,7 @@ def render_growth(values, years, asof, aria): format(v, ","), dip)) ticks = "".join('%d' % (L + pitch * (i + 0.5), y) for i, y in enumerate(years)) - sweep_x = R - L - motion = ("\n .sweep{fill:%s;opacity:0}\n" - " @media (prefers-color-scheme:dark){.sweep{fill:%s}}\n" - " @media (prefers-reduced-motion:no-preference){\n" - " .sweep{animation:swg 1.9s cubic-bezier(.22,1,.36,1) .15s 1 both}\n" - " @keyframes swg{0%%{opacity:0;transform:translateX(0)}\n" - " 10%%{opacity:.7}80%%{opacity:.7;transform:translateX(%dpx)}\n" - " 100%%{opacity:0;transform:translateX(%dpx)}}\n }\n" - ) % (LIGHT["acc"], DARK["acc"], sweep_x, sweep_x) + motion = sweep_x_motion(round(R - L), "swg", "1.9s", ".15s", ".7", 10, 80) s = "\n".join([ svg_open(W, H, aria), style_sheet(motion), @@ -660,9 +453,8 @@ def render_rhythm(hours, asof, aria): W, H = 700, 168 L, R, BASE = 42.0, 686.0, 126.0 SPAN, MIN_BAR, ZSLOT, ORIGIN = 80.0, 2.5, 4.0, RHYTHM_ORIGIN - ZERO_Y = BASE + 2 - assert ZERO_Y > BASE # zero slots strictly below the axis - order = [(ORIGIN + k) % 24 for k in range(24)] # axis 04→03: night block contiguous + ZERO_Y = BASE + 2 # zero slots strictly below the axis + order = HOUR_ORDER # axis 04→03: night block contiguous pitch = (R - L) / 24 bw = round(pitch * 0.6, 1) cmax = max(hours.values()) or 1 @@ -683,15 +475,8 @@ def render_rhythm(hours, asof, aria): peak_v = hours[peak_h] ticks = "".join('%02d' % (L + pitch * (i + 0.5), h) for i, h in enumerate(order) if i % 4 == 0) - sweep_dx = round(peak_cx - L, 1) - motion = ("\n .sweep{fill:%s;opacity:0}\n" - " @media (prefers-color-scheme:dark){.sweep{fill:%s}}\n" - " @media (prefers-reduced-motion:no-preference){\n" - " .sweep{animation:swr 2.1s cubic-bezier(.22,1,.36,1) .6s 1 both}\n" - " @keyframes swr{0%%{opacity:0;transform:translateX(0)}\n" - " 12%%{opacity:.7}68%%{opacity:.7;transform:translateX(%spx)}\n" - " 100%%{opacity:0;transform:translateX(%spx)}}\n }\n" - ) % (LIGHT["acc"], DARK["acc"], sweep_dx, sweep_dx) + motion = sweep_x_motion(round(peak_cx - L, 1), "swr", "2.1s", ".6s", + ".7", 12, 68) s = "\n".join([ svg_open(W, H, aria), style_sheet(motion), @@ -707,7 +492,7 @@ def render_rhythm(hours, asof, aria): return s -def render_ground(repo_commits, rel, asof, aria): +def render_ground(repo_commits, rel_counts, asof, aria): W, H = 700, 224 X_NAME, X_BAR, X_REL, MAXW = 150.0, 160.0, 660.0, 420.0 rows = sorted(repo_commits.items(), key=lambda kv: -kv[1]) @@ -722,14 +507,14 @@ def render_ground(repo_commits, rel, asof, aria): body.append('%s' % (X_NAME, y + 9, name)) body.append('' % ("acc" if focus else "bar", X_BAR, y, w)) body.append('%s' % (X_BAR + w + 8, y + 9, "accv" if focus else "val", format(v, ","))) - body.append('%d' % (X_REL, y + 9, rel.get(name, 0))) + body.append('%d' % (X_REL, y + 9, rel_counts.get(name, 0))) i += 1 head = "\n".join([ 'repository' % X_NAME, 'commits' % X_BAR, 'releases' % X_REL, '']) - rel_total = sum(rel.values()) + rel_total = sum(rel_counts.values()) footer = ('%s commits · %d ' 'releases · %d source repositories · %s %.1f%%' % (X_BAR, format(total, ","), rel_total, len(repo_commits), rows[0][0], top_share)) @@ -774,6 +559,20 @@ def sweep_y_motion(dy, kf, x0=42, x1=686, y0=30, dur="1.8s", delay=".3s"): return extra, rect +def sweep_x_motion(dx, kf, dur, delay, hi, p_lo, p_hi): + """One-shot horizontal scan sweep — the translateX twin of sweep_y_motion. + Per-figure curve constants stay explicit at the call sites, as tuned.""" + return ("\n .sweep{fill:%s;opacity:0}\n" + " @media (prefers-color-scheme:dark){.sweep{fill:%s}}\n" + " @media (prefers-reduced-motion:no-preference){\n" + " .sweep{animation:%s %s cubic-bezier(.22,1,.36,1) %s 1 both}\n" + " @keyframes %s{0%%{opacity:0;transform:translateX(0)}\n" + " %d%%{opacity:%s}%d%%{opacity:%s;transform:translateX(%spx)}\n" + " 100%%{opacity:0;transform:translateX(%spx)}}\n }\n" + ) % (LIGHT["acc"], DARK["acc"], kf, dur, delay, kf, + p_lo, hi, p_hi, hi, dx, dx) + + def month_ticks(domain, L, R, y, step=2): """First-of-month x ticks across the shared domain, every `step` months. @@ -814,7 +613,7 @@ def render_punchcard(weekhours, domain, asof, aria): TOP, PITCH = 44.0, 17.0 vmax = max(weekhours.values()) or 1 peak = max(weekhours, key=lambda k: weekhours[k]) - order = [(RHYTHM_ORIGIN + k) % 24 for k in range(24)] + order = HOUR_ORDER pitch = (R - L) / 24 wk = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"} filled, zeros = [], [] @@ -828,7 +627,7 @@ def render_punchcard(weekhours, domain, asof, aria): elif (wd, h) != peak: # the peak cell draws as the accented square s = max(round(math.sqrt(v / vmax) * 13, 1), 2.2) filled.append("M%.1f %.1fh%.1fv%.1fh-%.1fz" % (cx - s / 2, cy - s / 2, s, s, s)) - pc, pr = landing_motion("ring", "pcr", + mo, ring = landing_motion("ring", "pcr", L + pitch * (order.index(peak[1]) + 0.5), TOP + 4 + peak[0] * PITCH + PITCH / 2 - 2, "1.7s", ".5s") s = max(round(math.sqrt(weekhours[peak] / vmax) * 13, 1), 2.2) @@ -840,12 +639,12 @@ def render_punchcard(weekhours, domain, asof, aria): % (L - 6, TOP + 4 + wd * PITCH + PITCH / 2, wk[wd]) for wd in range(7)) return "\n".join([ svg_open(W, H, aria), - style_sheet(pc), + style_sheet(mo), '' % (W, H), '' % " ".join(filled), '' % " ".join(zeros), '' % (cx - s / 2, cy - s / 2, s, s), - pr, + ring, rows, ticks, 'commits by weekday × hour · cell area ∝ count · axis 04→03 · %s → %s' % (L, domain[0], domain[1]), @@ -860,7 +659,7 @@ def render_surplus(wd_hour, we_hour, wd_days, we_days, domain, asof, aria): W, H = 700, 178 L, R, BASE = 42.0, 686.0, 138.0 SPAN, MIN_BAR = 92.0, 2.0 - order = [(RHYTHM_ORIGIN + k) % 24 for k in range(24)] + order = HOUR_ORDER pitch = (R - L) / 24 wd_rate = [wd_hour[h] / wd_days for h in order] we_rate = [we_hour[h] / we_days for h in order] @@ -881,14 +680,7 @@ def step_path(rates, cls): wp_i = wd_rate.index(max(wd_rate)) ep_i = we_rate.index(max(we_rate)) - motion = ("\n .sweep{fill:%s;opacity:0}\n" - " @media (prefers-color-scheme:dark){.sweep{fill:%s}}\n" - " @media (prefers-reduced-motion:no-preference){\n" - " .sweep{animation:sws 2s cubic-bezier(.22,1,.36,1) .5s 1 both}\n" - " @keyframes sws{0%%{opacity:0;transform:translateX(0)}\n" - " 12%%{opacity:.6}70%%{opacity:.6;transform:translateX(%dpx)}\n" - " 100%%{opacity:0;transform:translateX(%dpx)}}\n }\n" - ) % (LIGHT["acc"], DARK["acc"], round(R - L), round(R - L)) + motion = sweep_x_motion(round(R - L), "sws", "2s", ".5s", ".6", 12, 70) ticks = "".join('%02d' % (L + pitch * (i + 0.5), h) for i, h in enumerate(order) if i % 4 == 0) return "\n".join([ @@ -1052,7 +844,7 @@ def render_cadence(rel_lists, domain, asof, aria): rels = sorted(rels, key=lambda r: r["published_at"]) body.append('' % (L, y, R, y)) for j, r in enumerate(rels): - dt = datetime.strptime(r["published_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + dt = parse_iso(r["published_at"]).replace(tzinfo=timezone.utc) cx = x_date(dt.date(), domain, L, R) dy = (j % 3 - 1) * 5 if r["prerelease"]: @@ -1065,13 +857,13 @@ def render_cadence(rel_lists, domain, asof, aria): # other, so the end label is only drawn when it clears the start one. FS = 8.5 first, last = rels[0], rels[-1] - fx = x_date(datetime.strptime(first["published_at"], "%Y-%m-%dT%H:%M:%SZ") + fx = x_date(parse_iso(first["published_at"]) .replace(tzinfo=timezone.utc).date(), domain, L, R) fx = clamp_start(fx + 3, first["tag_name"], FS) body.append('%s' % (fx, y + 15, FS, first["tag_name"])) if last is not first: - lx = x_date(datetime.strptime(last["published_at"], "%Y-%m-%dT%H:%M:%SZ") + lx = x_date(parse_iso(last["published_at"]) .replace(tzinfo=timezone.utc).date(), domain, L, R) - 3 if lx - text_w(last["tag_name"], FS) > fx + text_w(first["tag_name"], FS) + 4: body.append('%s' @@ -1130,17 +922,11 @@ def render_streak(day_counts, run, dom, asof, aria): if run0 is not None: runs.append("M%d %dH%d" % (run0, BASE + 3, round(R))) bx0, bx1 = round(x_date(run[0], dom, L, R)), round(x_date(run[1], dom, L, R)) - motion = ("\n .tick{stroke:%s;fill:none;stroke-width:1.4}\n" - " @media (prefers-color-scheme:dark){.tick{stroke:%s}}\n" - " .sweep{fill:%s;opacity:0}\n" - " @media (prefers-color-scheme:dark){.sweep{fill:%s}}\n" - " @media (prefers-reduced-motion:no-preference){\n" - " .sweep{animation:sbs 1.8s cubic-bezier(.22,1,.36,1) .5s 1 both}\n" - " @keyframes sbs{0%%{opacity:0;transform:translateX(0)}\n" - " 15%%{opacity:.65}75%%{opacity:.65;transform:translateX(%dpx)}\n" - " 100%%{opacity:0;transform:translateX(%dpx)}}\n }\n" - ) % (LIGHT["bar"], DARK["bar"], LIGHT["acc"], DARK["acc"], - max(bx1 - bx0, 2), max(bx1 - bx0, 2)) + ticks_css = ("\n .tick{stroke:%s;fill:none;stroke-width:1.4}\n" + " @media (prefers-color-scheme:dark){.tick{stroke:%s}}" + ) % (LIGHT["bar"], DARK["bar"]) + motion = ticks_css + sweep_x_motion(max(bx1 - bx0, 2), "sbs", "1.8s", + ".5s", ".65", 15, 75) return "\n".join([ svg_open(W, H, aria), style_sheet(motion), @@ -1222,7 +1008,7 @@ def render_latency(buckets, ecdf, stats, asof, aria): "", ""]) -def render_grammar(types_sorted, nonconf, total, sub1pct, asof, aria): +def render_grammar(types_sorted, nonconf, total, under_one_pct_note, asof, aria): """100-cell waffle: makes "77 of 100" countable. Types under one percent earn no cell and are folded into the footer instead of stealing one. Height is derived from content: the last waffle row and the last legend row both @@ -1235,7 +1021,7 @@ def render_grammar(types_sorted, nonconf, total, sub1pct, asof, aria): xi = yi = 0 used = 0 - def emit(n, cls): + def emit(n): nonlocal xi, yi, used d = [] for _ in range(n): @@ -1249,7 +1035,7 @@ def emit(n, cls): return d for rank, (name, c) in enumerate(types_sorted): - d = emit(round(c / total * 100), "acc" if rank == 0 else "bar") + d = emit(round(c / total * 100)) if d: cells.append('' % ("acc" if rank == 0 else "bar", " ".join(d))) legend.append('%s %d' @@ -1257,7 +1043,7 @@ def emit(n, cls): # The non-conforming block takes every cell the types did not — exactly # 100 cells are drawn, so "one cell = one percent" stays literally true. filled = used # snapshot: the open cells below drive the cursor to 100 - zc = emit(100 - used, "zero") + zc = emit(100 - used) mo, ring = landing_motion("ring", "grr", X0 + (filled % 10 - 0.5) * P, Y0 + (filled // 10) * P + 5, "1.5s", ".7s") @@ -1274,7 +1060,7 @@ def emit(n, cls): 'commit subjects by Conventional Commits type · one cell = one percent of %s' % (42, format(total, ",")), 'open cells do not parse as Conventional Commits · %s' - % (42, sub1pct), + % (42, under_one_pct_note), 'as of %s' % (686, H - 8, asof), "", ""]) @@ -1285,16 +1071,16 @@ def render_tongues(lang_all, lang_src, gen_site, zero_repos, asof, aria): one named repo and it says Python. Both conditions, not a silent pick.""" W, H = 700, 196 XL, XR = 210.0, 470.0 - ta = sum(lang_all.values()) or 1 - ts = sum(lang_src.values()) or 1 + total_all = sum(lang_all.values()) or 1 + total_src = sum(lang_src.values()) or 1 names = sorted(set(lang_all) | set(lang_src), key=lambda n: -lang_src.get(n, 0))[:7] if "HTML" not in names and lang_all.get("HTML"): names.append("HTML") TOP, STEP, SPAN = 44.0, 19.0, 116.0 - na = {n: lang_all.get(n, 0) / ta for n in names} - ns = {n: lang_src.get(n, 0) / ts for n in names} - ymax = max(list(na.values()) + list(ns.values())) or 1 + share_all = {n: lang_all.get(n, 0) / total_all for n in names} + share_src = {n: lang_src.get(n, 0) / total_src for n in names} + ymax = max(list(share_all.values()) + list(share_src.values())) or 1 def y_of(p): return TOP + SPAN - p / ymax * SPAN @@ -1302,15 +1088,15 @@ def y_of(p): body = [] left, right = [], [] for n in names: - y1, y2 = y_of(na[n]), y_of(ns[n]) - acc = n == max(ns, key=ns.get) + y1, y2 = y_of(share_all[n]), y_of(share_src[n]) + acc = n == max(share_src, key=share_src.get) body.append('' % ("accs" if acc else "bars", XL, y1, XR, y2)) dot = "acc" if acc else "bar" body.append('' % (dot, XL, y1, dot, XR, y2)) - left.append((y1 + 3, "%s %.1f%%" % (n, na[n] * 100), "lbl")) - right.append((y2 + 3, "%s %.1f%%" % (n, ns[n] * 100), + left.append((y1 + 3, "%s %.1f%%" % (n, share_all[n] * 100), "lbl")) + right.append((y2 + 3, "%s %.1f%%" % (n, share_src[n] * 100), "accv" if acc else "val")) # Both rankings have a long tail that converges on ~0%, so several labels # land within a couple of pixels of each other and print as one blob. The @@ -1321,8 +1107,8 @@ def y_of(p): for ly, txt, cls in spread_labels(right, TOP + SPAN + 5): body.append('%s' % (XR + 8, ly, cls, txt)) - others_a = 1 - sum(na.values()) - others_s = 1 - sum(ns.values()) + others_a = 1 - sum(share_all.values()) + others_s = 1 - sum(share_src.values()) return "\n".join([ svg_open(W, H, aria), style_sheet(), @@ -1352,15 +1138,15 @@ def render_upstream(items, pub_prs, asof, aria): # by the ext_prs <= 10 guard, so the figure simply grows a row instead. BASE = TOP + (len(rows) - 1) * PITCH + 16 H = int(BASE + 50) - d0 = datetime.strptime(rows[0]["created_at"], "%Y-%m-%dT%H:%M:%SZ").date() - d1 = datetime.strptime(rows[-1]["created_at"], "%Y-%m-%dT%H:%M:%SZ").date() + d0 = parse_iso(rows[0]["created_at"]).date() + d1 = parse_iso(rows[-1]["created_at"]).date() if d1 == d0: # single PR (or several on one day): give the axis some span d0 -= timedelta(days=15) d1 += timedelta(days=15) dom = (d0, d1) body = [] for i, it in enumerate(rows): - dt = datetime.strptime(it["created_at"], "%Y-%m-%dT%H:%M:%SZ").date() + dt = parse_iso(it["created_at"]).date() x = x_date(dt, dom, L, R) y = TOP + i * PITCH slug = it["repository_url"].split("/repos/")[1] @@ -1551,7 +1337,7 @@ def growth_shape(values, years): def zero_runs(hours): """Contiguous runs of true-zero hours along the 04→03 axis, as [(lo, hi)].""" runs, run = [], [] - for h in [(RHYTHM_ORIGIN + k) % 24 for k in range(24)]: + for h in HOUR_ORDER: if hours[h] == 0: run.append(h) elif run: @@ -1560,7 +1346,7 @@ def zero_runs(hours): return runs + ([(run[0], run[-1])] if run else []) -def _join(parts, sep, last): +def oxford_join(parts, sep, last): if len(parts) < 2: return "".join(parts) return sep.join(parts[:-1]) + last + parts[-1] @@ -1573,7 +1359,7 @@ def growth_alt(f, values, years): if f == EN: zc = ("" if not z else " %s %s exactly zero, drawn as %s below the axis." % ( - _join(z, ", ", " and "), "is" if len(z) == 1 else "are", + oxford_join(z, ", ", " and "), "is" if len(z) == 1 else "are", "an open slot" if len(z) == 1 else "open slots")) dc = "".join(" %d (%s) is lower than %d (%s)." % (y, format(v, ","), py, format(pv, ",")) for y, v, py, pv in sh["dips"]) @@ -1587,7 +1373,7 @@ def growth_alt(f, values, years): def rhythm_alt(f, hours): - order = [(RHYTHM_ORIGIN + k) % 24 for k in range(24)] + order = HOUR_ORDER seq = ", ".join(str(hours[h]) for h in order) total = sum(hours.values()) ph = max(hours, key=lambda h: hours[h]) @@ -1596,7 +1382,7 @@ def rhythm_alt(f, hours): last = (RHYTHM_ORIGIN + 23) % 24 if f == EN: zc = ("" if not runs else " %s exactly zero, drawn as open slots below the axis." % ( - _join(["%02d:00 to %02d:59" % r for r in runs], ", ", " and ") + + oxford_join(["%02d:00 to %02d:59" % r for r in runs], ", ", " and ") + (" is" if len(runs) == 1 and runs[0][0] == runs[0][1] else " are"))) return ("Histogram of %s open-source commits by hour of day, Asia/Shanghai, axis " "running %02d:00 through %02d:00 so the night block stays contiguous. Values " @@ -1622,7 +1408,7 @@ def ground_alt(f, counts, rel, archived): rel_rows = [(n, c) for n, c in sorted(rel.items(), key=lambda kv: -kv[1]) if c] if f == EN: arc = ("" if not archived else " %s %s archived — %s graduated into the negentropy trunk." - % (_join(archived, ", ", " and "), "is" if len(archived) == 1 else "are", + % (oxford_join(archived, ", ", " and "), "is" if len(archived) == 1 else "are", "it" if len(archived) == 1 else "they")) return ("Horizontal bar chart, commits per source repository, sorted: %s. Total %s " "commits and %d releases across %d source repositories; %s is %.1f percent of " @@ -1670,11 +1456,11 @@ def punchcard_alt(f, weekhours, domain, wknd, wd_days, we_days): def surplus_alt(f, wd_hour, we_hour, wd_days, we_days, domain): - order = [(RHYTHM_ORIGIN + k) % 24 for k in range(24)] + order = HOUR_ORDER wd_seq = ", ".join("%.1f" % (wd_hour[h] / wd_days) for h in order) we_seq = ", ".join("%.1f" % (we_hour[h] / we_days) for h in order) - wp = max(range(24), key=lambda h: wd_hour[h]) - ep = max(range(24), key=lambda h: we_hour[h]) + wd_peak_h = max(range(24), key=lambda h: wd_hour[h]) + we_peak_h = max(range(24), key=lambda h: we_hour[h]) if f == EN: return ("Two step curves on one hour-of-day axis running 04:00 to 03:00, " "Asia/Shanghai, %s to %s, normalised to commits per day of that kind so " @@ -1682,13 +1468,13 @@ def surplus_alt(f, wd_hour, we_hour, wd_days, we_days, domain): "04:00: %s. Weekend rate: %s. Weekday peak %02d:00 at %.1f per day; " "weekend peak %02d:00 at %.1f. Data: GitHub." % (domain[0], domain[1], wd_days, we_days, wd_seq, we_seq, - wp, wd_hour[wp] / wd_days, ep, we_hour[ep] / we_days)) + wd_peak_h, wd_hour[wd_peak_h] / wd_days, we_peak_h, we_hour[we_peak_h] / we_days)) return ("同一小时轴(04:00 至 03:00,Asia/Shanghai,%s 至 %s)上的两条阶梯曲线," "按「该类日」归一为日均提交,使 %d 个工作日与 %d 个周末日可比。工作日自 04:00 起" "逐小时速率:%s。周末:%s。工作日峰值 %02d:00(日均 %.1f 条);周末峰值 %02d:00" "(日均 %.1f 条)。数据:GitHub。" % (domain[0], domain[1], wd_days, we_days, wd_seq, we_seq, - wp, wd_hour[wp] / wd_days, ep, we_hour[ep] / we_days)) + wd_peak_h, wd_hour[wd_peak_h] / wd_days, we_peak_h, we_hour[we_peak_h] / we_days)) def accrual_alt(f, monthly, domain, events, final_total): @@ -1705,7 +1491,7 @@ def accrual_alt(f, monthly, domain, events, final_total): ev_txt = " ".join( "On %s %s flatten%s and the trunk keeps rising: %s graduated into the " "negentropy trunk that day." % ( - d, _join(nms, ", ", " and "), "" if len(nms) > 1 else "s", + d, oxford_join(nms, ", ", " and "), "" if len(nms) > 1 else "s", "they" if len(nms) > 1 else "it") for d, nms in by_date.items()) return ("Cumulative authored commits per source repository at monthly resolution, " @@ -1743,41 +1529,6 @@ def lifecycles_alt(f, spans, domain): % (domain[0], domain[1], rows)) -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)) - - def streak_alt(f, act, zeros, best, dom, rug_days): if f == EN: return ("Rug plot, one tick per day over the latest %d days, %s to %s; tick height " @@ -1904,60 +1655,6 @@ def upstream_alt(f, items, pub_prs): ledger, format(pub_prs, ","), len(rows) / pub_prs * 100)) -# ------------------------------------------------- figure derivations ---- -# Degenerate-but-reachable shapes die cleanly here instead of raising a bare -# traceback further down; the write-nothing contract holds either way. -if not lifetimes: - die("no merged PRs in negentropy — latency figures undefined, refusing") -if not any(rel_lists.values()): - die("no releases in any source repo — release figures undefined, refusing") -if not types: - die("no Conventional Commits found — grammar figure undefined, refusing") -if not lang_src: - die("no language bytes outside the generated-site repo — tongues undefined, refusing") - -peak_cell = max(weekhours, key=lambda k: weekhours[k]) -wknd = sum(n for (wd, _), n in weekhours.items() if wd >= 5) -types_sorted = types.most_common() -rel_last = max( - ((n, r["tag_name"], r["published_at"]) for n, v in rel_lists.items() for r in v), - key=lambda t: t[2])[:2] -wd_hour = Counter() -we_hour = Counter() -wd_days = we_days = 0 -_d = DOMAIN[0] -while _d <= DOMAIN[1]: # calendar denominators: every day counts, commit or not - if _d.weekday() >= 5: - we_days += 1 - else: - wd_days += 1 - _d += timedelta(days=1) -if wd_days == 0 or we_days == 0: - die(f"window has {wd_days} weekdays / {we_days} weekend days — " - "surplus normalisation undefined, refusing") -for (wd, h), n in weekhours.items(): - (we_hour if wd >= 5 else wd_hour)[h] += n -we_peak = max(we_hour, key=lambda h: we_hour[h]) -monthly = Counter() -for name, lst in repo_commits.items(): - for c in lst: - dt = datetime.strptime(c["commit"]["author"]["date"], "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.utc).astimezone(TZ) - monthly[(name, dt.date().replace(day=1))] += 1 -# Graduation events for the accrual figure: pushed_at of the archived repos is -# the closest public proxy (GitHub exposes no archive timestamp); the renderer -# labels the axis "last push", never "archived on". -grad_events = [ - (datetime.strptime(r["pushed_at"], "%Y-%m-%dT%H:%M:%SZ").date(), r["name"]) - for r in repos if r["archived"] and r["name"] in repo_commits -] -spans = [ - (r["name"], - datetime.strptime(r["created_at"], "%Y-%m-%dT%H:%M:%SZ").date(), - datetime.strptime(r["pushed_at"], "%Y-%m-%dT%H:%M:%SZ").date(), - r["archived"]) - for r in repos if r["name"] in repo_commits -] 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"] @@ -1969,254 +1666,550 @@ def bucket_of(m): return len(LAT_EDGES) - 1 -lat_buckets = [(LAT_LABELS[i], sum(1 for m in lifetimes if bucket_of(m) == i)) - for i in range(len(LAT_LABELS))] +RUG_DAYS = 400 +def refresh(): + # ---------------------------------------------------------------- data ---- + print("collecting: yearly contributions (anonymous view) …") + now = datetime.now(TZ) + years, values = [], [] + for y in range(FIRST_YEAR, now.year + 1): + counts = anon_contribution_counts( + f"https://github.com/users/{USER}/contributions?from={y}-01-01&to={y}-12-31", + expect_year=y, + ) + years.append(y) + values.append(sum(counts)) + print(f" {y}: {values[-1]:,}") + + if sum(values) <= 0: + die("yearly contribution totals are all zero — page parse broken") + + print("collecting: repositories …") + repos = [r for r in gh_paginate(f"users/{USER}/repos?per_page=100") if not r["fork"]] + total_stars = sum(r["stargazers_count"] for r in repos) + acc_stars = next((r["stargazers_count"] for r in repos if r["name"] == ACC_REPO), None) + if acc_stars is None: + die(f"{ACC_REPO} missing from repo list — star split is undefined") + own_stars = total_stars - acc_stars + + print("collecting: authored commits per repository (first page probe) …") + repo_commits = {} + for r in repos: + name = r["name"] + page1 = gh(f"repos/{USER}/{name}/commits?author={USER}&per_page=100") + if len(page1) < MIN_SOURCE_COMMITS: + continue # below "source repository" threshold + if len(page1) < 100: + commits = page1 + else: # keep page1; only the pages after it are new requests + commits = page1 + gh_paginate( + f"repos/{USER}/{name}/commits?author={USER}&per_page=100&page=2" + ) + repo_commits[name] = commits + print(f" {name}: {len(commits)} authored commits") + + src_repos = len(repo_commits) + all_commits = [c for lst in repo_commits.values() for c in lst] + commits_total = len(all_commits) + if commits_total < 100: + die(f"only {commits_total} authored commits collected — parse likely broken") + + hours = Counter() + weekhours = Counter() # (weekday 0=Mon, hour) -> n + day_counts = Counter() # date -> n + commit_dates = [] # commit dates for the shared time-domain + types = Counter() # Conventional Commits type -> n + conv = 0 + for c in all_commits: + a = c["commit"]["author"] + dt = parse_iso_local(a["date"]) + hours[dt.hour] += 1 + weekhours[(dt.weekday(), dt.hour)] += 1 + day_counts[dt.date()] += 1 + commit_dates.append(dt.date()) + m = CONVENTIONAL.match(c["commit"]["message"].split("\n")[0]) + if m: + conv += 1 + types[m.group(1)] += 1 + + def windowed_run(dom): + """Longest streak + activity counts INSIDE the rug's rolling window, so the + figure's bracket and its alt text can never disagree with its own axis. + The first active day is itself a run of length 1.""" + wd_days = [d for d in sorted(day_counts) if dom[0] <= d <= dom[1]] + if not wd_days: + die("no authored commits inside the rug window — streak figure undefined") + best, b_start, b_end, run = 1, wd_days[0], wd_days[0], 1 + for prev, cur in zip(wd_days, wd_days[1:]): + run = run + 1 if (cur - prev).days == 1 else 1 + if run > best: + best, b_end = run, cur + b_start = cur - timedelta(days=run - 1) + return len(wd_days), best, (b_start, b_end) + + + # One counting implementation for both records: the all-time streak is the + # windowed run over the full domain (windowed_run dies cleanly on an empty + # window where the old hand-rolled loop would IndexError). + sorted_days = sorted(day_counts) + _, streak, (best_start, best_end) = windowed_run( + (sorted_days[0], sorted_days[-1])) + + # The shared x-domain for every time-axis figure (accrual, lifecycles, cadence, + # streak): three stacked figures with three silently different ranges is a + # worse honesty failure than any single figure's caveat. + DOMAIN = (min(commit_dates), max(commit_dates)) + WIN_DAYS = (DOMAIN[1] - DOMAIN[0]).days + 1 + + # Pipeline-closure guard: every authored commit must land in exactly one hour + # bucket. A mismatch means the hour histogram and the repository table below + # would silently disagree with each other. + if sum(hours.values()) != commits_total: + die( + f"hour histogram {sum(hours.values())} != repo total {commits_total} — " + "caliber split, refusing" + ) + if sum(weekhours.values()) != commits_total or sum(day_counts.values()) != commits_total: + die(f"weekday/day histogram {sum(weekhours.values())}/{sum(day_counts.values())} " + f"!= repo total {commits_total} — caliber split, refusing") + if sum(types.values()) != conv: + die(f"commit-type histogram {sum(types.values())} != conventional count {conv} " + "— CONVENTIONAL regex drift, refusing") + + print("collecting: negentropy pull requests …") + pulls = gh_paginate(f"repos/{USER}/negentropy/pulls?state=closed&per_page=100") + merged = [p for p in pulls if p["merged_at"]] + neg_pr = len(merged) + pr_closed = len(pulls) + pr_unmerged = pr_closed - neg_pr + lifetimes = [] + for p in merged: + created = parse_iso(p["created_at"]) + merged_at = parse_iso(p["merged_at"]) + lifetimes.append((merged_at - created).total_seconds() / 60) + lifetimes_sorted = sorted(lifetimes) + neg_median = statistics.median(lifetimes) + pct_hour = sum(1 for m in lifetimes if m <= 60) / len(lifetimes) * 100 + p90_lat = lifetimes_sorted[min(len(lifetimes_sorted) - 1, int(round(0.9 * len(lifetimes_sorted))) - 1)] + lat_max_min = lifetimes_sorted[-1] + + + print("collecting: releases (every source repo, drafts excluded) …") + # Roster derived from the source repos, never hand-listed: a hand-listed roster + # silently rendered a real release as 0 for months. Drafts are excluded because + # they are invisible anonymously but visible to a push-capable token, which + # would make a local run and a CI run disagree. + rel_lists = {} + for name in repo_commits: + rel_lists[name] = [ + x for x in gh(f"repos/{USER}/{name}/releases?per_page=100") if not x["draft"] + ] + rel_counts = {k: len(v) for k, v in rel_lists.items()} + rel_total = sum(rel_counts.values()) + rel_repos = sum(1 for v in rel_counts.values() if v) + + print("collecting: language bytes per source repo (public caliber) …") + # This is exactly the data behind the language bar a logged-out visitor sees on + # each repo page — the one endpoint whose naive answer (HTML, 69.7%) is an + # artifact of a generated static site. The slope figure exists to show both + # conditions instead of silently picking one. + repo_langs = {name: gh(f"repos/{USER}/{name}/languages") for name in repo_commits} + lang_all = Counter() + lang_src = Counter() # excluding the generated-site repo + for name, by in repo_langs.items(): + for lang, byts in by.items(): + lang_all[lang] += byts + if name != GEN_SITE: + lang_src[lang] += byts + zero_byte_repos = [n for n, by in repo_langs.items() if not by] + + print("collecting: public PR totals (is:public caliber) …") + pub_prs = gh( + "search/issues?q=is:pr+author:ThreeFish-AI+is:public&per_page=1" + )["total_count"] + # per_page=30 rather than 1: the same request already carries the items, so the + # upstream ledger below is derived rather than hand-written prose that rots. + ext = gh( + "search/issues?q=is:pr+author:ThreeFish-AI+is:public+-user:ThreeFish-AI&per_page=30" + ) + ext_prs = ext["total_count"] + ext_items = ext["items"] + ext_merged = sum(1 for it in ext_items if it["pull_request"]["merged_at"]) + ext_dify = sum( + 1 for it in ext_items + if it["pull_request"]["merged_at"] + and it["repository_url"].split("/repos/")[1].startswith(f"{DIFY_OWNER}/") + ) -def ecdf_points(): - cum, out = 0, [] - for i, (_, c) in enumerate(lat_buckets): - cum += c - out.append(cum / len(lifetimes) * 100) - return out + archived_names = sorted( + r["name"] for r in repos if r["archived"] and r["name"] in repo_commits + ) + # ------------------------------------------------------------- guards ---- + if ext_prs > 10: + die(f"external public PR count = {ext_prs} — search caliber drifted, refusing") + # The guard above doubles as the ledger's layout contract; keep them together. + if ext_prs != len(ext_items): + die(f"external PR ledger truncated: total_count {ext_prs} != items " + f"{len(ext_items)} — raise per_page, refusing") + if ext_merged > ext_prs or ext_dify > ext_merged: + die(f"ledger arithmetic broken: {ext_dify} dify <= {ext_merged} merged <= " + f"{ext_prs} total violated, refusing") + if len(years) != len(values) or any(v < 0 for v in values): + die("year series malformed") + if set(rel_counts) != set(repo_commits): + 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. + if not lifetimes: + die("no merged PRs in negentropy — latency figures undefined, refusing") + if not any(rel_lists.values()): + die("no releases in any source repo — release figures undefined, refusing") + if not types: + die("no Conventional Commits found — grammar figure undefined, refusing") + if not lang_src: + die("no language bytes outside the generated-site repo — tongues undefined, refusing") + + peak_cell = max(weekhours, key=lambda k: weekhours[k]) + wknd = sum(n for (wd, _), n in weekhours.items() if wd >= 5) + types_sorted = types.most_common() + rel_last = max( + ((n, r["tag_name"], r["published_at"]) for n, v in rel_lists.items() for r in v), + key=lambda t: t[2])[:2] + wd_hour = Counter() + we_hour = Counter() + wd_days = we_days = 0 + _d = DOMAIN[0] + while _d <= DOMAIN[1]: # calendar denominators: every day counts, commit or not + if _d.weekday() >= 5: + we_days += 1 + else: + wd_days += 1 + _d += timedelta(days=1) + if wd_days == 0 or we_days == 0: + die(f"window has {wd_days} weekdays / {we_days} weekend days — " + "surplus normalisation undefined, refusing") + for (wd, h), n in weekhours.items(): + (we_hour if wd >= 5 else wd_hour)[h] += n + we_peak = max(we_hour, key=lambda h: we_hour[h]) + monthly = Counter() + for name, lst in repo_commits.items(): + for c in lst: + dt = parse_iso_local(c["commit"]["author"]["date"]) + monthly[(name, dt.date().replace(day=1))] += 1 + # Graduation events for the accrual figure: pushed_at of the archived repos is + # the closest public proxy (GitHub exposes no archive timestamp); the renderer + # labels the axis "last push", never "archived on". + grad_events = [ + (parse_iso(r["pushed_at"]).date(), r["name"]) + for r in repos if r["archived"] and r["name"] in repo_commits + ] + spans = [ + (r["name"], + parse_iso(r["created_at"]).date(), + parse_iso(r["pushed_at"]).date(), + r["archived"]) + for r in repos if r["name"] in repo_commits + ] + lat_buckets = [(LAT_LABELS[i], sum(1 for m in lifetimes if bucket_of(m) == i)) + for i in range(len(LAT_LABELS))] + + + def ecdf_points(): + cum, out = 0, [] + for i, (_, c) in enumerate(lat_buckets): + cum += c + out.append(cum / len(lifetimes) * 100) + return out + + + lat_stats = { + "n": neg_pr, + "unmerged": pr_unmerged, + "med": human_duration(neg_median), + "p90": human_duration(p90_lat), + "med_i": next(i for i, m in enumerate(LAT_EDGES) if neg_median <= m), + "hour_i": LAT_EDGES.index(60), + "lat_max": human_duration(lat_max_min), + } -lat_stats = { - "n": neg_pr, - "unmerged": pr_unmerged, - "med": human_duration(neg_median), - "p90": human_duration(p90_lat), - "med_i": next(i for i, m in enumerate(LAT_EDGES) if neg_median <= m), - "hour_i": LAT_EDGES.index(60), - "lat_max": human_duration(lat_max_min), -} + rug_dom = (DOMAIN[1] - timedelta(days=RUG_DAYS - 1), DOMAIN[1]) + rug_act, rug_best_n, rug_run = windowed_run(rug_dom) + + + # ---------------------------------------------------------------- write ---- + asof = now.strftime("%Y-%m-%d") + current_year = years[-1] + + # Facts are language-neutral (bare numbers, dates, proper nouns) so one + # derivation serves both READMEs; sentence framing and links live in the + # markdown, per language. A word in any natural language does not belong here — + # see archived_names, where the extension is derived and the interpretation + # ("graduated, not failed") stays hand-written. + facts = { + "asof": asof, + "cur_year": current_year, + "cur_total": f"{values[-1]:,}", + "commits_total": f"{commits_total:,}", + "src_repos": src_repos, + "streak": streak, + "conv_pct": f"{conv / commits_total * 100:.1f}%", + "peak_h": f"{max(hours, key=lambda h: hours[h]):02d}:00", + "peak_n": f"{max(hours.values()):,}", + "peak_pct": f"{max(hours.values()) / commits_total * 100:.1f}%", + "peak_x": f"{max(hours.values()) / (commits_total / 24):.2f}×", + "pub_prs": f"{pub_prs:,}", + "neg_pr": f"{neg_pr:,}", + "neg_median": f"{neg_median:.0f}", + "pct_hour": f"{pct_hour:.0f}%", + "own_stars": own_stars, + "acc_stars": acc_stars, + "total_stars": total_stars, + "rel_total": rel_total, + "rel_repos": rel_repos, + "ext_prs": ext_prs, + "ext_merged": ext_merged, + "ext_dify": ext_dify, + "archived_n": len(archived_names), + "archived_names": ", ".join(archived_names), + "ext_first": min(it["created_at"] for it in ext_items)[:10], + "ext_last": max(it["created_at"] for it in ext_items)[:10], + "pc_cell": "%s %02d:00" % (("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")[peak_cell[0]], peak_cell[1]), + "pc_val": weekhours[peak_cell], + "pc_empty": sum(1 for wd in range(7) for h in range(24) if not weekhours.get((wd, h))), + "wknd_pct": f"{wknd / commits_total * 100:.1f}%", + "win_from": str(DOMAIN[0]), + "win_to": str(DOMAIN[1]), + "win_days": WIN_DAYS, + "win_months": round(WIN_DAYS / 30.44), + "p90": human_duration(p90_lat), + "lat_max": human_duration(lat_max_min), + "pr_closed": pr_closed, + "pr_unmerged": pr_unmerged, + "top_type": types_sorted[0][0], + "top_type_pct": f"{types_sorted[0][1] / commits_total * 100:.1f}%", + "nonconf_pct": f"{(commits_total - conv) / commits_total * 100:.1f}%", + "nonconf_n": commits_total - conv, + "lang_top": lang_src.most_common(1)[0][0], + "lang_top_pct": f"{lang_src.most_common(1)[0][1] / sum(lang_src.values()) * 100:.1f}%", + "lang_naive": lang_all.most_common(1)[0][0], + "lang_naive_pct": f"{lang_all.most_common(1)[0][1] / sum(lang_all.values()) * 100:.1f}%", + "rel_last_name": rel_last[0], + "rel_last_tag": rel_last[1], + "streak_from": str(best_start), + "streak_to": str(best_end), + "rug_streak": rug_best_n, + "rug_streak_from": str(rug_run[0]), + "rug_streak_to": str(rug_run[1]), + "active_days": len(day_counts), + "active_pct": f"{len(day_counts) / WIN_DAYS * 100:.0f}%", + "wd_days": wd_days, + "we_days": we_days, + "we_peak_h": f"{we_peak:02d}:00", + "neg_commits": f"{len(repo_commits.get(TRUNK, ())):,}", + "rel_cp": rel_counts.get("coding-proxy", 0), + "rel_hg": rel_counts.get("hyper-git", 0), + "rel_gmab": rel_counts.get("give-me-a-break", 0), + } -def windowed_run(dom): - """Longest streak + activity counts INSIDE the rug's rolling window, so the - figure's bracket and its alt text can never disagree with its own axis. - Same counting semantics as the all-time loop above: the first active day - is itself a run of length 1.""" - wd_days = [d for d in sorted(day_counts) if dom[0] <= d <= dom[1]] - if not wd_days: - die("no authored commits inside the rug window — streak figure undefined") - best, b_start, b_end, run = 1, wd_days[0], wd_days[0], 1 - for prev, cur in zip(wd_days, wd_days[1:]): - run = run + 1 if (cur - prev).days == 1 else 1 - if run > best: - best, b_end = run, cur - b_start = cur - timedelta(days=run - 1) - return len(wd_days), best, (b_start, b_end) + # Closure between the declared contract and the actual derivation: lint audits + # markers against FACT_KEYS without a network call, so the two must agree. + if set(facts) != set(FACT_KEYS): + die(f"FACT_KEYS closure broken: declared-not-derived " + f"{sorted(FACT_KEYS - set(facts))}, derived-not-declared " + f"{sorted(set(facts) - set(FACT_KEYS))} — refusing") + + ground_counts = {k: len(v) for k, v in repo_commits.items()} + types_under_one_pct = [n for n, c in types_sorted if c / commits_total < 0.01] + # In-frame text is English-only: one SVG serves both READMEs (the zh file + # references the same assets by absolute URL). Per-language phrasing lives in + # the alt text, which is derived per README. + under_one_pct_note = ("%d types under one percent each: %s — folded here, not drawn" + % (len(types_under_one_pct), ", ".join(types_under_one_pct))) if types_under_one_pct else "no types under one percent" + ALTS = { + "growth": {f: growth_alt(f, values, years) for f in README_FILES}, + "rhythm": {f: rhythm_alt(f, hours) for f in README_FILES}, + "ground": {f: ground_alt(f, ground_counts, rel_counts, archived_names) + for f in README_FILES}, + "punchcard": {f: punchcard_alt(f, weekhours, DOMAIN, wknd, wd_days, we_days) + for f in README_FILES}, + "surplus": {f: surplus_alt(f, wd_hour, we_hour, wd_days, we_days, DOMAIN) + for f in README_FILES}, + "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, ()))) + 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}, + "latency": {f: latency_alt(f, lat_buckets, lat_stats, pct_hour) + for f in README_FILES}, + "grammar": {f: grammar_alt(f, types_sorted, commits_total - conv, commits_total) + for f in README_FILES}, + "tongues": {f: tongues_alt(f, lang_all, lang_src, GEN_SITE, zero_byte_repos) + for f in README_FILES}, + "upstream": {f: upstream_alt(f, ext_items, pub_prs) for f in README_FILES}, + } + if set(ALTS) != set(FIG_SPEC): + die(f"alt text missing for figure(s) {sorted(set(FIG_SPEC) - set(ALTS))} — refusing") + + figures = { + FIG_SPEC["growth"][0]: render_growth(values, years, asof, ALTS["growth"][EN]), + FIG_SPEC["rhythm"][0]: render_rhythm(hours, asof, ALTS["rhythm"][EN]), + FIG_SPEC["ground"][0]: render_ground(ground_counts, rel_counts, asof, ALTS["ground"][EN]), + FIG_SPEC["punchcard"][0]: render_punchcard(weekhours, DOMAIN, asof, ALTS["punchcard"][EN]), + FIG_SPEC["surplus"][0]: render_surplus(wd_hour, we_hour, wd_days, we_days, DOMAIN, asof, + ALTS["surplus"][EN]), + FIG_SPEC["accrual"][0]: render_accrual(monthly, grad_events, DOMAIN, asof, ALTS["accrual"][EN]), + FIG_SPEC["lifecycles"][0]: render_lifecycles(spans, DOMAIN, asof, ALTS["lifecycles"][EN]), + FIG_SPEC["cadence"][0]: render_cadence(rel_lists, DOMAIN, asof, ALTS["cadence"][EN]), + FIG_SPEC["streak"][0]: render_streak(day_counts, rug_run, rug_dom, asof, + ALTS["streak"][EN]), + FIG_SPEC["latency"][0]: render_latency(lat_buckets, ecdf_points(), lat_stats, asof, + ALTS["latency"][EN]), + FIG_SPEC["grammar"][0]: render_grammar(types_sorted, commits_total - conv, commits_total, + under_one_pct_note, asof, + ALTS["grammar"][EN]), + FIG_SPEC["tongues"][0]: render_tongues(lang_all, lang_src, GEN_SITE, zero_byte_repos, + asof, ALTS["tongues"][EN]), + FIG_SPEC["upstream"][0]: render_upstream(ext_items, pub_prs, asof, ALTS["upstream"][EN]), + } + report = ( + f"OK growth(latest {values[-1]:,}) rhythm({commits_total:,} commits/{src_repos} repos, " + f"peak {facts['peak_h']}) ground({rel_total} releases/{rel_repos} repos with releases) " + f"pub_prs={pub_prs:,} ext={ext_prs} merged={ext_merged} dify={ext_dify} " + f"own_stars={own_stars} streak={streak}d median={neg_median:.0f}min " + f"archived={archived_names}" + ) + return { + "facts": facts, "alts": ALTS, "figures": figures, + "values": values, "current_year": current_year, "report": report, + } -RUG_DAYS = 400 -rug_dom = (DOMAIN[1] - timedelta(days=RUG_DAYS - 1), DOMAIN[1]) -rug_act, rug_best_n, rug_run = windowed_run(rug_dom) - - -# ---------------------------------------------------------------- write ---- -asof = now.strftime("%Y-%m-%d") -current_year = years[-1] - -# Facts are language-neutral (bare numbers, dates, proper nouns) so one -# derivation serves both READMEs; sentence framing and links live in the -# markdown, per language. A word in any natural language does not belong here — -# see archived_names, where the extension is derived and the interpretation -# ("graduated, not failed") stays hand-written. -facts = { - "asof": asof, - "cur_year": current_year, - "cur_total": f"{values[-1]:,}", - "commits_total": f"{commits_total:,}", - "src_repos": src_repos, - "streak": streak, - "conv_pct": f"{conv / commits_total * 100:.1f}%", - "peak_h": f"{max(hours, key=lambda h: hours[h]):02d}:00", - "peak_n": f"{max(hours.values()):,}", - "peak_pct": f"{max(hours.values()) / commits_total * 100:.1f}%", - "peak_x": f"{max(hours.values()) / (commits_total / 24):.2f}×", - "pub_prs": f"{pub_prs:,}", - "neg_pr": f"{neg_pr:,}", - "neg_median": f"{neg_median:.0f}", - "pct_hour": f"{pct_hour:.0f}%", - "own_stars": own_stars, - "acc_stars": acc_stars, - "total_stars": total_stars, - "rel_total": rel_total, - "rel_repos": rel_repos, - "ext_prs": ext_prs, - "ext_merged": ext_merged, - "ext_dify": ext_dify, - "archived_n": len(archived_names), - "archived_names": ", ".join(archived_names), - "ext_first": min(it["created_at"] for it in ext_items)[:10], - "ext_last": max(it["created_at"] for it in ext_items)[:10], - "pc_cell": "%s %02d:00" % (("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")[peak_cell[0]], peak_cell[1]), - "pc_val": weekhours[peak_cell], - "pc_empty": sum(1 for wd in range(7) for h in range(24) if not weekhours.get((wd, h))), - "wknd_pct": f"{wknd / commits_total * 100:.1f}%", - "win_from": str(DOMAIN[0]), - "win_to": str(DOMAIN[1]), - "win_days": WIN_DAYS, - "win_months": round(WIN_DAYS / 30.44), - "p90": human_duration(p90_lat), - "lat_max": human_duration(lat_max_min), - "pr_closed": pr_closed, - "pr_unmerged": pr_unmerged, - "top_type": types_sorted[0][0], - "top_type_pct": f"{types_sorted[0][1] / commits_total * 100:.1f}%", - "nonconf_pct": f"{(commits_total - conv) / commits_total * 100:.1f}%", - "nonconf_n": commits_total - conv, - "lang_top": lang_src.most_common(1)[0][0], - "lang_top_pct": f"{lang_src.most_common(1)[0][1] / sum(lang_src.values()) * 100:.1f}%", - "lang_naive": lang_all.most_common(1)[0][0], - "lang_naive_pct": f"{lang_all.most_common(1)[0][1] / sum(lang_all.values()) * 100:.1f}%", - "rel_last_name": rel_last[0], - "rel_last_tag": rel_last[1], - "streak_from": str(best_start), - "streak_to": str(best_end), - "rug_streak": rug_best_n, - "rug_streak_from": str(rug_run[0]), - "rug_streak_to": str(rug_run[1]), - "active_days": len(day_counts), - "active_pct": f"{len(day_counts) / WIN_DAYS * 100:.0f}%", - "wd_days": wd_days, - "we_days": we_days, - "we_peak_h": f"{we_peak:02d}:00", - "neg_commits": f"{len(repo_commits.get(TRUNK, ())):,}", - "rel_cp": rel_counts.get("coding-proxy", 0), - "rel_hg": rel_counts.get("hyper-git", 0), - "rel_gmab": rel_counts.get("give-me-a-break", 0), -} +def main(): + MODE = "write" + for _arg in sys.argv[1:]: + if _arg in ("--lint", "--check"): + MODE = _arg[2:] + else: + die(f"unknown argument {_arg!r} — expected --lint or --check") -# Closure between the declared contract and the actual derivation: lint audits -# markers against FACT_KEYS without a network call, so the two must agree. -if set(facts) != set(FACT_KEYS): - die(f"FACT_KEYS closure broken: declared-not-derived " - f"{sorted(FACT_KEYS - set(facts))}, derived-not-declared " - f"{sorted(set(facts) - set(FACT_KEYS))} — refusing") - -ground_counts = {k: len(v) for k, v in repo_commits.items()} -sub1pct = [n for n, c in types_sorted if c / commits_total < 0.01] -# In-frame text is English-only: one SVG serves both READMEs (the zh file -# references the same assets by absolute URL). Per-language phrasing lives in -# the alt text, which is derived per README. -sub1_txt = ("%d types under one percent each: %s — folded here, not drawn" - % (len(sub1pct), ", ".join(sub1pct))) if sub1pct else "no types under one percent" -ALTS = { - "growth": {f: growth_alt(f, values, years) for f in README_FILES}, - "rhythm": {f: rhythm_alt(f, hours) for f in README_FILES}, - "ground": {f: ground_alt(f, ground_counts, rel_counts, archived_names) - for f in README_FILES}, - "punchcard": {f: punchcard_alt(f, weekhours, DOMAIN, wknd, wd_days, we_days) - for f in README_FILES}, - "surplus": {f: surplus_alt(f, wd_hour, we_hour, wd_days, we_days, DOMAIN) - for f in README_FILES}, - "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, ()))) - 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}, - "latency": {f: latency_alt(f, lat_buckets, lat_stats, pct_hour) - for f in README_FILES}, - "grammar": {f: grammar_alt(f, types_sorted, commits_total - conv, commits_total) - for f in README_FILES}, - "tongues": {f: tongues_alt(f, lang_all, lang_src, GEN_SITE, zero_byte_repos) - for f in README_FILES}, - "upstream": {f: upstream_alt(f, ext_items, pub_prs) for f in README_FILES}, -} -if set(ALTS) != set(FIG_SPEC): - die(f"alt text missing for figure(s) {sorted(set(FIG_SPEC) - set(ALTS))} — refusing") - -figures = { - FIG_SPEC["growth"][0]: render_growth(values, years, asof, ALTS["growth"][EN]), - FIG_SPEC["rhythm"][0]: render_rhythm(hours, asof, ALTS["rhythm"][EN]), - FIG_SPEC["ground"][0]: render_ground(ground_counts, rel_counts, asof, ALTS["ground"][EN]), - FIG_SPEC["punchcard"][0]: render_punchcard(weekhours, DOMAIN, asof, ALTS["punchcard"][EN]), - FIG_SPEC["surplus"][0]: render_surplus(wd_hour, we_hour, wd_days, we_days, DOMAIN, asof, - ALTS["surplus"][EN]), - FIG_SPEC["accrual"][0]: render_accrual(monthly, grad_events, DOMAIN, asof, ALTS["accrual"][EN]), - FIG_SPEC["lifecycles"][0]: render_lifecycles(spans, DOMAIN, asof, ALTS["lifecycles"][EN]), - FIG_SPEC["cadence"][0]: render_cadence(rel_lists, DOMAIN, asof, ALTS["cadence"][EN]), - FIG_SPEC["streak"][0]: render_streak(day_counts, rug_run, rug_dom, asof, - ALTS["streak"][EN]), - FIG_SPEC["latency"][0]: render_latency(lat_buckets, ecdf_points(), lat_stats, asof, - ALTS["latency"][EN]), - FIG_SPEC["grammar"][0]: render_grammar(types_sorted, commits_total - conv, commits_total, - sub1_txt, asof, - ALTS["grammar"][EN]), - FIG_SPEC["tongues"][0]: render_tongues(lang_all, lang_src, GEN_SITE, zero_byte_repos, - asof, ALTS["tongues"][EN]), - FIG_SPEC["upstream"][0]: render_upstream(ext_items, pub_prs, asof, ALTS["upstream"][EN]), -} -texts = read_readmes() -orders = {f: scan_markers(t, f) for f, t in texts.items()} -audit_parity(orders) -audit_assets() - -outputs = {} -for f, text in texts.items(): - # Monotonicity guard, year-flip aware: the annual total can only decrease - # when the README still refers to the *current* year. In January the - # headline legitimately resets to a small partial-year number. The README is - # the pipeline's only state store, which is why this reads the old value - # back out of the markdown rather than from a cache. - mc = re.search(r"(\d{4})", text) - if mc and int(mc.group(1)) == current_year: - m = re.search(r"(.*?)", text, re.S) - if m: - nums = re.findall(r"\d[\d,]*", m.group(1)) - if nums and int(nums[0].replace(",", "")) > values[-1]: - die(f"current-year total decreased ({nums[0]} -> {values[-1]}) — " - "parse likely broken") - alts = {k: ALTS[k][f] for k in ALTS} - once = substitute(text, f, facts, alts) - if substitute(once, f, facts, alts) != once: - die(f"{f}: substitution is not idempotent — anchors are being consumed, " - "the next run would find nothing, refusing") - if scan_markers(once, f) != orders[f]: - die(f"{f}: substitution changed the marker sequence — refusing") - outputs[f] = once - -# Validate every rendered figure BEFORE anything is written to disk. Every -# figure's byte count is structurally bounded (fixed cells, fixed lanes, or a -# rolling window), so the flat 8,192 B ceiling stays binding for all of them. -for name, src in figures.items(): - n = assert_svg_sane(src, name) - print(f" {name}: {n}B PASS") - -if MODE == "check": + if MODE == "lint": + _orders = {f: scan_markers(t, f) for f, t in read_readmes().items()} + audit_parity(_orders) + audit_assets() + _d = sum(1 for k, _ in _orders[EN] if k == "DATA") + _g = sum(1 for k, _ in _orders[EN] if k == "FIG") + print(f"OK lint: {_d} DATA + {_g} FIG regions, identical set and order in " + f"both READMEs; assets/ clean") + sys.exit(0) + + + out = refresh() + texts = read_readmes() + orders = {f: scan_markers(t, f) for f, t in texts.items()} + audit_parity(orders) + audit_assets() + + outputs = {} + for f, text in texts.items(): + # Monotonicity guard, year-flip aware: the annual total can only decrease + # when the README still refers to the *current* year. In January the + # headline legitimately resets to a small partial-year number. The README is + # the pipeline's only state store, which is why this reads the old value + # back out of the markdown rather than from a cache. + mc = re.search(r"(\d{4})", text) + if mc and int(mc.group(1)) == out["current_year"]: + m = re.search(r"(.*?)", text, re.S) + if m: + nums = re.findall(r"\d[\d,]*", m.group(1)) + if nums and int(nums[0].replace(",", "")) > out["values"][-1]: + die(f"current-year total decreased ({nums[0]} -> {out['values'][-1]}) — " + "parse likely broken") + alts = {k: a[f] for k, a in out["alts"].items()} + once = substitute(text, f, out["facts"], alts) + if substitute(once, f, out["facts"], alts) != once: + die(f"{f}: substitution is not idempotent — anchors are being consumed, " + "the next run would find nothing, refusing") + if scan_markers(once, f) != orders[f]: + die(f"{f}: substitution changed the marker sequence — refusing") + outputs[f] = once + + # Validate every rendered figure BEFORE anything is written to disk. Every + # figure's byte count is structurally bounded (fixed cells, fixed lanes, or a + # rolling window), so the flat 8,192 B ceiling stays binding for all of them. + for name, src in out["figures"].items(): + n = assert_svg_sane(src, name) + print(f" {name}: {n}B PASS") + + if MODE == "check": + for f, text in outputs.items(): + on_disk = texts[f] + if text != on_disk: + diff = "".join(difflib.unified_diff( + on_disk.splitlines(keepends=True), text.splitlines(keepends=True), + fromfile=f"{f} (on disk)", tofile=f"{f} (would write)")) + print(diff, end="") + for name, src in out["figures"].items(): + p = pathlib.Path("assets") / name + if p.is_file() and p.read_text(encoding="utf-8") != src: + print(f"--- a/assets/{name} (on disk)\n+++ b/assets/{name} (would write)") + print("OK check: all guards passed, nothing written " + f"(re-run without --check to write)") + sys.exit(0) + + # All guards passed — write everything. + pathlib.Path("assets").mkdir(exist_ok=True) + for name, src in out["figures"].items(): + with open(pathlib.Path("assets") / name, "w", encoding="utf-8") as fh: + fh.write(src) for f, text in outputs.items(): - on_disk = texts[f] - if text != on_disk: - diff = "".join(difflib.unified_diff( - on_disk.splitlines(keepends=True), text.splitlines(keepends=True), - fromfile=f"{f} (on disk)", tofile=f"{f} (would write)")) - print(diff, end="") - for name, src in figures.items(): - p = pathlib.Path("assets") / name - if p.is_file() and p.read_text(encoding="utf-8") != src: - print(f"--- a/assets/{name} (on disk)\n+++ b/assets/{name} (would write)") - print("OK check: all guards passed, nothing written " - f"(re-run without --check to write)") - sys.exit(0) - -# All guards passed — write everything. -pathlib.Path("assets").mkdir(exist_ok=True) -for name, src in figures.items(): - with open(pathlib.Path("assets") / name, "w", encoding="utf-8") as fh: - fh.write(src) -for f, text in outputs.items(): - with open(f, "w", encoding="utf-8") as fh: - fh.write(text) - -print( - f"OK growth(latest {values[-1]:,}) rhythm({commits_total:,} commits/{src_repos} repos, " - f"peak {facts['peak_h']}) ground({rel_total} releases/{rel_repos} repos with releases) " - f"pub_prs={pub_prs:,} ext={ext_prs} merged={ext_merged} dify={ext_dify} " - f"own_stars={own_stars} streak={streak}d median={neg_median:.0f}min " - f"archived={archived_names}" -) + with open(f, "w", encoding="utf-8") as fh: + fh.write(text) + + print(out["report"]) + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_bps.py b/tests/_bps.py new file mode 100644 index 0000000..79ffe5f --- /dev/null +++ b/tests/_bps.py @@ -0,0 +1,56 @@ +"""Test loader: expose scripts/build_profile_svgs.py symbols WITHOUT executing +its module-level network collection. + +Mechanism (characterization only — no copies, no mocks): parse the module's +real source on every run and exec only (a) imports, (b) every function def, +(c) module constants on a name allowlist. Nodes are selected by NAME, not line +number, so the loader survives refactors that move code within the file; the +collection (inside refresh()) and orchestration (main()) are simply never +executed. If the module later grows an import-safe layout, tests may switch to +a plain import — assertions must not change either way (see Gate 1).""" +import ast +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "build_profile_svgs.py" + +# Module-level constants the pure functions close over (sections A/C/E). +_CONSTANTS = frozenset({ + "USER", "TZ", "EN", "ZH", "README_FILES", "ACC_REPO", "TRUNK", + "MIN_SOURCE_COMMITS", "FIRST_YEAR", "RHYTHM_ORIGIN", "DIFY_OWNER", + "CONVENTIONAL", "FACT_KEYS", "FIG_SPEC", "SRC_PREFIX", "MARKER", + "FONT", "LIGHT", "DARK", "_GLYPHS", "GLYPH_W", "FONT_SLACK", + "BANNED", "KEYFRAME", "TEXT_EL", "FILL_ONLY", "STROKED", + "LAT_EDGES", "LAT_LABELS", "RUG_DAYS", "GEN_SITE", "HOUR_ORDER", +}) + + +def _target_names(node): + out = set() + for t in node.targets: + if isinstance(t, ast.Name): + out.add(t.id) + elif isinstance(t, ast.Tuple): + out |= {e.id for e in t.elts if isinstance(e, ast.Name)} + return out + + +def _selected(tree): + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom, ast.Try)): + yield node + elif isinstance(node, ast.FunctionDef): + yield node + elif isinstance(node, ast.Assign) and _target_names(node) <= _CONSTANTS: + yield node + + +def load(): + tree = ast.parse(MODULE_PATH.read_text(encoding="utf-8")) + module = ast.Module(body=list(_selected(tree)), type_ignores=[]) + ns = {"__name__": "build_profile_svgs_under_test"} + exec(compile(module, str(MODULE_PATH), "exec"), ns) + return ns + + +NS = load() diff --git a/tests/golden/accrual_synthetic.svg b/tests/golden/accrual_synthetic.svg new file mode 100644 index 0000000..9a30de4 --- /dev/null +++ b/tests/golden/accrual_synthetic.svg @@ -0,0 +1,33 @@ + + + + + +graduated + + + +hyper-git +negentropy +total 35 + +2026-03 +cumulative authored commits per repo · monthly · 2026-01-05 → 2026-03-01 · not the eleven years above +as of 2026-09-05 + diff --git a/tests/golden/cadence_synthetic.svg b/tests/golden/cadence_synthetic.svg new file mode 100644 index 0000000..820ea40 --- /dev/null +++ b/tests/golden/cadence_synthetic.svg @@ -0,0 +1,35 @@ + + + + + + +v0.1.0 +v0.2.0 +coding-proxy · 2 + + +v0.0.1 +negentropy · 1 + +2026-03 +releases on the commit timeline · hollow = pre-release · counts at left +negentropy ships as merged PRs, not tags — it is a deployed service, not a distributed package +as of 2026-09-05 + diff --git a/tests/golden/grammar_synthetic.svg b/tests/golden/grammar_synthetic.svg new file mode 100644 index 0000000..c974242 --- /dev/null +++ b/tests/golden/grammar_synthetic.svg @@ -0,0 +1,32 @@ + + + + + + + + +fix 827 +docs 725 +feat 697 +test 6 +none-parse 996 +commit subjects by Conventional Commits type · one cell = one percent of 4,349 +open cells do not parse as Conventional Commits · 19.0 +as of 2026-09-05 + diff --git a/tests/golden/ground_synthetic.svg b/tests/golden/ground_synthetic.svg new file mode 100644 index 0000000..b49461b --- /dev/null +++ b/tests/golden/ground_synthetic.svg @@ -0,0 +1,29 @@ + + + +repository +commits +releases + +negentropy + +40 +0 +coding-proxy + +12 +3 +hyper-git + +7 +1 +59 commits · 4 releases · 3 source repositories · negentropy 67.8% +as of 2026-09-05 + diff --git a/tests/golden/growth_synthetic.svg b/tests/golden/growth_synthetic.svg new file mode 100644 index 0000000..a05361c --- /dev/null +++ b/tests/golden/growth_synthetic.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + +contributions per year · square-root scale +1 +0 +0 +13 +129 +198 +676 +589 ↓ +1,181 +3,193 +9,313 +20162017201820192020202120222023202420252026 +as of 2026-09-05 + + diff --git a/tests/golden/latency_synthetic.svg b/tests/golden/latency_synthetic.svg new file mode 100644 index 0000000..a5559a4 --- /dev/null +++ b/tests/golden/latency_synthetic.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + +median 6 min + +1 h + +5 +<1m +2 +1-2 +3 +2-5 +4 +5-15 +3 +15-60m +2 +1-4h +1 +4-24h +0 +>24h +100% +0% +merged-PR lifetime in negentropy · 20 merged, 2 closed-unmerged excluded +solo self-merge: unit size, not review speed · log buckets, tail compressed by design — longest was 11.9 d +as of 2026-09-05 + diff --git a/tests/golden/lifecycles_synthetic.svg b/tests/golden/lifecycles_synthetic.svg new file mode 100644 index 0000000..0ff1f07 --- /dev/null +++ b/tests/golden/lifecycles_synthetic.svg @@ -0,0 +1,36 @@ + + + + + +old-wing +2025-06 → 2026-02 + +negentropy +2026-01 → 2026-03 + +2025-06 +2025-08 +2025-10 +2025-12 +2026-02 +repository lifespans, creation to last push · ends are last push, not archive dates +■ graduated (archived) · bars in blue are live · deleted repos are invisible at public caliber +as of 2026-09-05 + diff --git a/tests/golden/punchcard_synthetic.svg b/tests/golden/punchcard_synthetic.svg new file mode 100644 index 0000000..7b0f2b6 --- /dev/null +++ b/tests/golden/punchcard_synthetic.svg @@ -0,0 +1,27 @@ + + + + + + + +MonTueWedThuFriSatSun +040812162000 +commits by weekday × hour · cell area ∝ count · axis 04→03 · 2026-01-05 → 2026-03-01 +as of 2026-09-05 + diff --git a/tests/golden/rhythm_synthetic.svg b/tests/golden/rhythm_synthetic.svg new file mode 100644 index 0000000..aeec4a2 --- /dev/null +++ b/tests/golden/rhythm_synthetic.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +open-source commits by hour · Asia/Shanghai · axis 04→03 +400 +040812162000 +as of 2026-09-05 + + diff --git a/tests/golden/streak_synthetic.svg b/tests/golden/streak_synthetic.svg new file mode 100644 index 0000000..d3ec48e --- /dev/null +++ b/tests/golden/streak_synthetic.svg @@ -0,0 +1,31 @@ + + + + + + + +10 days, 2026-06-10 → 2026-06-19 + +2026-06 +one tick per day, latest 30 · height ∝ √commits · authored commits in source repos, not the GitHub calendar +as of 2026-09-05 + diff --git a/tests/golden/surplus_synthetic.svg b/tests/golden/surplus_synthetic.svg new file mode 100644 index 0000000..351243d --- /dev/null +++ b/tests/golden/surplus_synthetic.svg @@ -0,0 +1,28 @@ + + + + + + +commits per day, weekday vs weekend · axis 04→03 · 2026-01-05 → 2026-03-01 +per-day rates: 90 commits ÷ 40 weekdays · 100 commits ÷ 12 weekend days +6.7/d +as of 2026-09-05 +040812162000 + diff --git a/tests/golden/tongues_synthetic.svg b/tests/golden/tongues_synthetic.svg new file mode 100644 index 0000000..5d33b51 --- /dev/null +++ b/tests/golden/tongues_synthetic.svg @@ -0,0 +1,28 @@ + + + + + + + + + +TypeScript 10.3% +Python 25.6% +HTML 64.1% +HTML 1.8% +TypeScript 28.1% +Python 70.2% +as GitHub counts it +excluding threefish-ai.github.io +others 0.0% / 0.0% · bytes measure committed source size, not authorship or effort · no zero-byte source repos +language byte share under two conditions · threefish-ai.github.io is generated static-site output +as of 2026-09-05 + diff --git a/tests/golden/upstream_synthetic.svg b/tests/golden/upstream_synthetic.svg new file mode 100644 index 0000000..f410729 --- /dev/null +++ b/tests/golden/upstream_synthetic.svg @@ -0,0 +1,22 @@ + + + + + + +langgenius/dify#5631 · merged · 2026-01-09 + + +foo/bar#124 · closed unmerged · 2026-02-01 + +2 external public PRs — 0.4% of 500 public PRs; the rest are my own repositories +pull requests into code I do not own · hollow = closed unmerged · blue = Dify ecosystem +as of 2026-09-05 + diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..428b941 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,31 @@ +"""Characterization of the CLI boundary: --lint is the exact audit the PR CI +job runs (zero network), and an unknown argument must die without writing.""" +import subprocess +import sys +import unittest + +from tests._bps import ROOT + + +class TestCliLint(unittest.TestCase): + def test_lint_passes_with_stable_output_format(self): + r = subprocess.run( + [sys.executable, "scripts/build_profile_svgs.py", "--lint"], + cwd=ROOT, capture_output=True, text=True, timeout=120) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertTrue(r.stdout.startswith("OK lint: "), r.stdout) + self.assertIn("DATA + ", r.stdout) + self.assertIn( + "identical set and order in both READMEs; assets/ clean", + r.stdout) + + def test_unknown_argument_dies(self): + r = subprocess.run( + [sys.executable, "scripts/build_profile_svgs.py", "--bogus"], + cwd=ROOT, capture_output=True, text=True, timeout=120) + self.assertEqual(r.returncode, 1) + self.assertTrue(r.stderr.startswith("ABORT:"), r.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..3229a42 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,181 @@ +"""Characterization of the marker/parity/substitution contract (Section A). + +Locks today's behavior of scan_markers / audit_parity / audit_assets / +substitute — the functions the monthly cron and the PR lint job both stand on — +plus a real-file Golden Master over both READMEs and assets/.""" +import contextlib +import io +import os +import pathlib +import re +import tempfile +import unittest + +from tests._bps import NS, ROOT + +scan_markers = NS["scan_markers"] +audit_parity = NS["audit_parity"] +audit_assets = NS["audit_assets"] +substitute = NS["substitute"] +FIG_SPEC = NS["FIG_SPEC"] +EN, ZH = NS["EN"], NS["ZH"] + +# A minimal README skeleton exercising both marker kinds. Facts/alt stubs are +# intentionally unlike anything real so a byte-exact diff proves replacement. +_DOC = ( + "stale body\n" + "total old now\n" + "2020\n" +) +_FACTS = {"cur_total": "9,313", "cur_year": "2026"} +_ALTS = {"growth": 'says "1, 0" & more'} +_STUB_FACTS = {k: "2026" if k == "cur_year" else "0" for k in NS["FACT_KEYS"]} +_STUB_ALTS = {k: "stub alt" for k in FIG_SPEC} + + +def _die_message(case, call): + """Run `call`, returning (exit_code, stderr_text) of the expected die().""" + err = io.StringIO() + with case.assertRaises(SystemExit) as cm, contextlib.redirect_stderr(err): + call() + return cm.exception.code, err.getvalue() + + +class TestScanMarkers(unittest.TestCase): + def test_wellformed_order(self): + text = _DOC + "x y" + self.assertEqual( + scan_markers(text, EN), + [("FIG", "growth"), ("DATA", "cur_total"), ("DATA", "cur_year"), + ("FIG", "streak")]) + + def test_unknown_key_dies(self): + code, err = _die_message(self, lambda: scan_markers( + "1", EN)) + self.assertEqual(code, 1) + self.assertIn("not derivable", err) + + def test_nested_region_dies(self): + code, err = _die_message(self, lambda: scan_markers( + "" + "", EN)) + self.assertEqual(code, 1) + self.assertIn("nested", err) + + def test_unclosed_region_dies(self): + code, err = _die_message(self, lambda: scan_markers( + "body", EN)) + self.assertEqual(code, 1) + self.assertIn("unclosed", err) + + def test_mismatched_closer_dies(self): + code, err = _die_message(self, lambda: scan_markers( + "body", EN)) + self.assertEqual(code, 1) + self.assertIn("does not close", err) + + +class TestSubstitute(unittest.TestCase): + def test_fig_body_rebuilt_from_spec_exactly(self): + out = substitute(_DOC, EN, _FACTS, _ALTS) + # html.escape(alt, quote=True): quotes become entities, amp doubled. + expected_img = ('') + self.assertIn( + f"{expected_img}", out) + self.assertNotIn("stale body", out) + + def test_zh_src_is_absolute(self): + out = substitute(_DOC, ZH, _FACTS, _ALTS) + self.assertIn('src="https://raw.githubusercontent.com/ThreeFish-AI/' + 'threefish-ai/master/assets/growth.svg"', out) + + def test_data_bodies_replaced_multiline(self): + doc = "old\nspans lines" + self.assertIn("9,313", + substitute(doc, EN, _FACTS, _ALTS)) + + def test_backreference_mismatch_leaves_body_untouched(self): + doc = "keep me" + self.assertEqual(substitute(doc, EN, _FACTS, _ALTS), doc) + + def test_text_outside_regions_is_byte_identical(self): + def skeleton(t): + t = re.sub(r"().*?()", + r"\1\3", t, flags=re.S) + return re.sub(r"().*?()", + r"\1\3", t, flags=re.S) + for f in (EN, ZH): + text = (ROOT / f).read_text(encoding="utf-8") + once = substitute(text, f, _STUB_FACTS, _STUB_ALTS) + self.assertEqual(skeleton(text), skeleton(once), f) + self.assertEqual(substitute(once, f, _STUB_FACTS, _STUB_ALTS), + once, f"{f}: not idempotent") + + +class TestAuditParity(unittest.TestCase): + def test_identical_streams_pass(self): + order = [("FIG", "growth"), ("DATA", "cur_total")] + audit_parity({EN: order, ZH: list(order)}) + + def test_drift_dies(self): + a = [("FIG", "growth"), ("DATA", "cur_total")] + b = [("FIG", "growth"), ("DATA", "cur_year")] + code, err = _die_message( + self, lambda: audit_parity({EN: a, ZH: b})) + self.assertEqual(code, 1) + self.assertIn("region 1 differs", err) + + def test_length_drift_dies(self): + a = [("FIG", "growth")] + b = [("FIG", "growth"), ("DATA", "cur_total")] + code, err = _die_message( + self, lambda: audit_parity({EN: a, ZH: b})) + self.assertEqual(code, 1) + self.assertIn("extra region", err) + + def test_empty_contract_dies(self): + code, err = _die_message( + self, lambda: audit_parity({EN: [], ZH: []})) + self.assertEqual(code, 1) + self.assertIn("no refreshable regions", err) + + +class TestAuditAssets(unittest.TestCase): + def test_real_assets_dir_passes(self): + audit_assets() # cwd is the repo root while unittest runs from ROOT + + def test_stray_file_dies(self): + cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmp: + pathlib.Path(tmp, "assets").mkdir() + pathlib.Path(tmp, "assets", "growth.svg").write_text("ok") + pathlib.Path(tmp, "assets", "sprite.png").write_bytes(b"\x89") + try: + os.chdir(tmp) + code, err = _die_message(self, audit_assets) + finally: + os.chdir(cwd) + self.assertEqual(code, 1) + self.assertIn("ungenerated file", err) + + +class TestRealReadmesGoldenMaster(unittest.TestCase): + """The contract as it stands on disk: identical marker streams, known + counts, and figures in cluster order (the header tab layout of PR #8).""" + + def test_both_readmes_carry_identical_marker_stream(self): + en = scan_markers((ROOT / EN).read_text(encoding="utf-8"), EN) + zh = scan_markers((ROOT / ZH).read_text(encoding="utf-8"), ZH) + self.assertEqual(en, zh) + self.assertEqual(sum(1 for k, _ in en if k == "DATA"), 75) + self.assertEqual(sum(1 for k, _ in en if k == "FIG"), 13) + self.assertEqual( + [key for kind, key in en if kind == "FIG"], + ["growth", "rhythm", "punchcard", "surplus", "ground", + "accrual", "lifecycles", "cadence", "streak", + "latency", "grammar", "tongues", "upstream"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_svg_gates.py b/tests/test_svg_gates.py new file mode 100644 index 0000000..cedd3b6 --- /dev/null +++ b/tests/test_svg_gates.py @@ -0,0 +1,196 @@ +"""Characterization of the SVG write gates (Section C) and three +representative renderers (Section C/D) as Golden Masters. + +The 13 on-disk assets are the real renderers' Golden Master (all must pass +assert_svg_sane); crafted mutants lock each gate's refusal behavior; synthetic +inputs lock renderer output byte-for-byte (see tests/golden/).""" +import datetime +import pathlib +from collections import Counter +import unittest + +from tests._bps import NS, ROOT + +assert_svg_sane = NS["assert_svg_sane"] + +ASSETS = ROOT / "assets" +GOLDEN = ROOT / "tests" / "golden" + +# A gate-clean chassis: role/aria, viewBox, well-formed, tiny, no motion. +_CHASSIS = ( + '' + 'ok') + + +class TestRealAssetsPassGates(unittest.TestCase): + def test_all_thirteen_assets_pass(self): + svgs = sorted(p.name for p in ASSETS.glob("*.svg")) + self.assertEqual(len(svgs), 13) + for name in svgs: + n = assert_svg_sane((ASSETS / name).read_text(encoding="utf-8"), + name) + self.assertLessEqual(n, 8192, name) + + +class TestGateRefusals(unittest.TestCase): + def test_loop_animation_banned(self): + src = _CHASSIS.replace("ok", "infinite") + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("infinite/indefinite loop", str(cm.exception)) + + def test_fill_only_class_on_stroked_mark(self): + src = _CHASSIS.replace("", + '') + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("carries fill-only class", str(cm.exception)) + + def test_label_past_right_edge(self): + long_label = "label_" + "x" * 200 # ~1.2kpx at 12px, past the 700px canvas + src = _CHASSIS.replace(">ok", f">{long_label}") + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("past the 700px canvas", str(cm.exception)) + + def test_labels_overlapping_on_shared_baseline(self): + src = _CHASSIS.replace( + "", + 'aaaaaaaaaa' + 'bbbbbbbbbb' + "") + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("overlap on baseline", str(cm.exception)) + + def test_a11y_metadata_missing(self): + src = _CHASSIS.replace(' role="img" aria-label="chassis"', "") + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("a11y metadata missing", str(cm.exception)) + + def test_malformed_xml(self): + src = _CHASSIS.replace("ok", "a < b") + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("not well-formed XML", str(cm.exception)) + + def test_byte_budget(self): + padding = "" + src = _CHASSIS.replace("", padding + "") + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("over budget", str(cm.exception)) + + def test_external_resource(self): + src = _CHASSIS.replace("ok", "@import url(x)") + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("external resource", str(cm.exception)) + + def test_motion_must_be_opt_in(self): + src = _CHASSIS.replace( + "", + "") + with self.assertRaises(AssertionError) as cm: + assert_svg_sane(src, "mutant") + self.assertIn("motion not opt-in", str(cm.exception)) + + +class TestRendererGoldenMaster(unittest.TestCase): + """Synthetic inputs, byte-exact expectations for ALL 13 renderers: any + structural change that alters rendering output by a byte fails here. + Goldens are regenerated ONLY by a reviewed, deliberate act (the lab's + gen_golden*.py scripts verify determinism and gate-cleanliness first).""" + + ASOF = "2026-09-05" + ARIA = "synthetic aria" + D = datetime.date + DOMAIN = (D(2026, 1, 5), D(2026, 3, 1)) + + def _cases(self): + R, D, ASOF, ARIA = NS, self.D, self.ASOF, self.ARIA + return { + "growth": lambda: R["render_growth"]( + [1, 0, 0, 13, 129, 198, 676, 589, 1181, 3193, 9313], + list(range(2016, 2027)), ASOF, ARIA), + "rhythm": lambda: R["render_rhythm"]( + Counter({18: 400, 10: 200}), ASOF, ARIA), + "ground": lambda: R["render_ground"]( + {"negentropy": 40, "coding-proxy": 12, "hyper-git": 7}, + {"coding-proxy": 3, "hyper-git": 1}, ASOF, ARIA), + "punchcard": lambda: R["render_punchcard"]( + Counter({(0, 22): 30, (6, 21): 25, (3, 10): 12, (5, 23): 8}), + self.DOMAIN, ASOF, ARIA), + "surplus": lambda: R["render_surplus"]( + Counter({22: 60, 10: 30}), Counter({22: 80, 11: 20}), + 40, 12, self.DOMAIN, ASOF, ARIA), + "accrual": lambda: R["render_accrual"]( + Counter({("negentropy", D(2026, 1, 1)): 20, + ("negentropy", D(2026, 2, 1)): 10, + ("hyper-git", D(2026, 2, 1)): 5}), + [(D(2026, 2, 15), "hyper-git")], self.DOMAIN, ASOF, ARIA), + "lifecycles": lambda: R["render_lifecycles"]( + [("negentropy", D(2026, 1, 1), D(2026, 3, 1), False), + ("old-wing", D(2025, 6, 1), D(2026, 2, 1), True)], + (D(2025, 6, 1), D(2026, 3, 1)), ASOF, ARIA), + "cadence": lambda: R["render_cadence"]( + {"coding-proxy": [ + {"tag_name": "v0.1.0", "published_at": "2026-01-20T10:00:00Z", + "prerelease": False}, + {"tag_name": "v0.2.0", "published_at": "2026-02-14T09:30:00Z", + "prerelease": True}], + "negentropy": [ + {"tag_name": "v0.0.1", "published_at": "2026-02-01T08:00:00Z", + "prerelease": False}]}, + self.DOMAIN, ASOF, ARIA), + "streak": lambda: R["render_streak"]( + {D(2026, 6, d): (d % 5) for d in range(1, 31)}, + (D(2026, 6, 10), D(2026, 6, 19)), + (D(2026, 6, 1), D(2026, 6, 30)), ASOF, ARIA), + "latency": lambda: R["render_latency"]( + [("<1m", 5), ("1-2", 2), ("2-5", 3), ("5-15", 4), ("15-60m", 3), + ("1-4h", 2), ("4-24h", 1), (">24h", 0)], + [27.8, 38.9, 55.6, 77.8, 94.4, 100.0, 100.0, 100.0], + {"n": 20, "unmerged": 2, "med": "6 min", "p90": "2.8 h", + "med_i": 3, "hour_i": 4, "lat_max": "11.9 d"}, ASOF, ARIA), + "grammar": lambda: R["render_grammar"]( + [("fix", 827), ("docs", 725), ("feat", 697), ("test", 6)], + 996, 4349, 19.0, ASOF, ARIA), + "tongues": lambda: R["render_tongues"]( + Counter({"HTML": 50000, "Python": 20000, "TypeScript": 8000}), + Counter({"Python": 20000, "TypeScript": 8000, "HTML": 500}), + "threefish-ai.github.io", [], ASOF, ARIA), + "upstream": lambda: R["render_upstream"]( + [{"html_url": "https://github.com/langgenius/dify/pull/1", + "repository_url": "https://api.github.com/repos/langgenius/dify", + "state": "closed", + "pull_request": {"merged_at": "2026-01-10T00:00:00Z"}, + "number": 5631, "created_at": "2026-01-09T00:00:00Z"}, + {"html_url": "https://github.com/foo/bar/pull/2", + "repository_url": "https://api.github.com/repos/foo/bar", + "state": "closed", + "pull_request": {"merged_at": None}, + "number": 124, "created_at": "2026-02-01T00:00:00Z"}], + 500, ASOF, ARIA), + } + + def test_all_renderers_match_goldens(self): + for name, render in self._cases().items(): + with self.subTest(figure=name): + actual = render() + assert_svg_sane(actual, name) # goldens must be gate-clean + expected = (GOLDEN / f"{name}_synthetic.svg").read_text( + encoding="utf-8") + self.assertEqual(actual, expected) + + def test_golden_files_are_exactly_thirteen(self): + self.assertEqual( + sorted(p.name for p in GOLDEN.glob("*.svg")), + [f"{k}_synthetic.svg" for k in sorted(self._cases())]) + + +if __name__ == "__main__": + unittest.main()