diff --git a/.github/workflows/citations.yml b/.github/workflows/citations.yml
new file mode 100644
index 0000000..541e7ac
--- /dev/null
+++ b/.github/workflows/citations.yml
@@ -0,0 +1,27 @@
+name: Verify citations
+
+on:
+ pull_request:
+ workflow_dispatch:
+ schedule:
+ - cron: "17 4 * * 1" # weekly, catches venue/ID drift
+
+permissions:
+ contents: read
+
+jobs:
+ citations:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Check reference titles, venues and years
+ run: python3 scripts/verify_citations.py --json citation-findings.json
+ - if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: citation-findings
+ path: citation-findings.json
+ if-no-files-found: ignore
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..0a885b0
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,41 @@
+name: Deploy site
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ - run: pip install -r requirements-docs.txt
+ - name: Build
+ run: python3 scripts/build_site.py --strict
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 18100cb..36b9bd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,8 @@ __pycache__/
dist/
build/
.worktrees/
+_site/
+.mkdocs-build/
+_to_delete/
+.learnings/
+.workbuddy/
diff --git a/README.md b/README.md
index 800d152..b107443 100644
--- a/README.md
+++ b/README.md
@@ -2,12 +2,40 @@
_Created by Codex and Xiaodong Cun (Corresponding Author), from GVC Lab, Great Bay University_
+[](https://gvclab.github.io/Video-Generation-101/)
+[](docs/taxonomy.md)
+[](docs/timeline.md)
+[](LICENSE)
+
+> 📖 **在线阅读(带全文搜索):**
+
+
一份面向初学者、研究者、工程师与创作者的视频生成知识体系。它不以堆叠模型和榜单为目标,而是试图建立一条从**生成原理、系统能力、世界理解到真实应用与验证**的完整学习路径。
> 资料更新时间:**2026-08**
---
+
+
+English summary
+
+**Video Generation 101** is a structured knowledge base on video generation, written for learners,
+researchers, engineers and creators. Rather than ranking models, it builds one continuous path from
+generative principles, through system capabilities and world understanding, to real applications and
+the evidence needed to believe them.
+
+Five layers organise the material: **generation foundations**, **system capabilities**,
+**controllable creation**, **world understanding**, and an **evidence framework**.
+
+Every chapter carries an explicit evidence snapshot date and separates author-reported numbers from
+independently reproduced ones. The prose is in Chinese; paper titles, venues and links are in English,
+and reference metadata is checked in CI.
+
+
+
+---
+
## 🎯 项目定位
视频生成正在从“根据条件合成一段画面”,走向能够持续保持状态、接受复杂控制、理解动作后果并服务真实任务的通用视频系统。本仓库围绕五个层面组织内容:
@@ -41,7 +69,7 @@ _Coding Agent 扩展研究覆盖面,研究者对结论与证据负责。_
仓库以“原理 → 系统 → 能力 → 世界 → 应用”为主轴,并让评测与治理贯穿每一层。
-
+
### 内容导航
| 板块 | 核心入口 | 延伸专题 |
diff --git "a/assets/imagegen-diagrams/001/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_Video_2026-08-30T17-01-58.png" b/assets/imagegen-diagrams/001/diagram.png
similarity index 100%
rename from "assets/imagegen-diagrams/001/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_Video_2026-08-30T17-01-58.png"
rename to assets/imagegen-diagrams/001/diagram.png
diff --git "a/assets/imagegen-diagrams/002/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\344\273\216\344\275\277\347\224\250\345\220\210\345\220\214_2026-08-30T17-01-57.png" b/assets/imagegen-diagrams/002/diagram.png
similarity index 100%
rename from "assets/imagegen-diagrams/002/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\344\273\216\344\275\277\347\224\250\345\220\210\345\220\214_2026-08-30T17-01-57.png"
rename to assets/imagegen-diagrams/002/diagram.png
diff --git "a/assets/imagegen-diagrams/003/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\345\210\233\346\204\217\350\247\206\351\242\221\347\232\204_2026-08-30T17-01-57.png" b/assets/imagegen-diagrams/003/diagram.png
similarity index 100%
rename from "assets/imagegen-diagrams/003/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\345\210\233\346\204\217\350\247\206\351\242\221\347\232\204_2026-08-30T17-01-57.png"
rename to assets/imagegen-diagrams/003/diagram.png
diff --git "a/assets/imagegen-diagrams/004/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\350\247\206\351\242\221\347\224\237\346\210\220\350\257\204_2026-08-30T17-01-58.png" b/assets/imagegen-diagrams/004/diagram.png
similarity index 100%
rename from "assets/imagegen-diagrams/004/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\350\247\206\351\242\221\347\224\237\346\210\220\350\257\204_2026-08-30T17-01-58.png"
rename to assets/imagegen-diagrams/004/diagram.png
diff --git a/assets/js/mathjax.js b/assets/js/mathjax.js
new file mode 100644
index 0000000..20d915e
--- /dev/null
+++ b/assets/js/mathjax.js
@@ -0,0 +1,23 @@
+// Material for MkDocs swaps page content without a reload when
+// navigation.instant is on, so MathJax has to be re-run per navigation via the
+// document$ observable rather than once on DOMContentLoaded.
+window.MathJax = {
+ tex: {
+ inlineMath: [["\\(", "\\)"]],
+ displayMath: [["\\[", "\\]"]],
+ processEscapes: true,
+ processEnvironments: true,
+ },
+ options: {
+ ignoreHtmlClass: ".*|",
+ processHtmlClass: "arithmatex",
+ },
+};
+
+document$.subscribe(() => {
+ if (!window.MathJax || !window.MathJax.typesetPromise) return;
+ MathJax.startup.output.clearCache();
+ MathJax.typesetClear();
+ MathJax.texReset();
+ MathJax.typesetPromise();
+});
diff --git a/assets/zh/video-reasoning-roadmap.png b/assets/zh/video-reasoning-roadmap.png
new file mode 100644
index 0000000..0840152
Binary files /dev/null and b/assets/zh/video-reasoning-roadmap.png differ
diff --git a/docs/applications.md b/docs/applications.md
index 8d3b3b5..d73d3f5 100644
--- a/docs/applications.md
+++ b/docs/applications.md
@@ -26,7 +26,7 @@
**图 1:部署是带硬门槛的证据链。** 四条领域证据并不是四个排行榜,而是说明同一个生成模型进入不同场景时,必须换一套成功标准。创作关心可控、连续与可改;数字人增加同意、身份与音画同步;交互世界要求动作响应、状态记忆和 deadline;Physical AI 最终要看反事实、闭环成功和安全。图中没有性能数字,避免把示意值误读成 benchmark 结果。
-
+
顺序化文字替代:先写用户、决策和伤害,再写允许使用的文字、图像、视频、音频或动作条件;把基础模型与编辑、音频、安全和版本工具组装成工作流;人工选择和修订后,以多个随机种子、分项指标和压力测试验收。任何硬门槛失败都停止上线。通过后仍需监测服务等级、成本、隐私、来源、事故和分布漂移,并保留回滚入口。
## 2. 五种证据对象不能混用
@@ -83,7 +83,7 @@ NIST AI 600-1 把生成式 AI 风险管理组织为面向生命周期的 govern
专业制作的核心循环是:
-
+
最低报告项:
- 每个 shot 生成多少候选、采用率和返工次数;
diff --git a/docs/evaluation.md b/docs/evaluation.md
index 7232c3c..415a249 100644
--- a/docs/evaluation.md
+++ b/docs/evaluation.md
@@ -28,7 +28,7 @@
下面是同一逻辑的可编辑、可搜索版本:
-
+
顺序化文字替代:声明任务和失败代价;按开放生成、开放集个性化、编辑、推理或 World Model 分流;先压力测试自动指标,再用冻结人类样本校准;完成人工盲测与部署门槛;最后发布分项结果、置信区间、失败类型和质量—速度—成本 Pareto。
### 1.1 历史范式为什么不断扩大
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..0e5ecd5
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,95 @@
+site_name: Video Generation 101
+site_description: 从生成原理、系统能力、世界理解到真实应用与验证的视频生成知识体系
+site_author: Xiaodong Cun, GVC Lab, Great Bay University
+site_url: https://gvclab.github.io/Video-Generation-101/
+repo_url: https://github.com/GVCLab/Video-Generation-101
+repo_name: GVCLab/Video-Generation-101
+edit_uri: edit/main/
+copyright: MIT License · GVC Lab, Great Bay University
+
+# The repository is browsed on GitHub as much as on the site, so the whole repo
+# is the docs root. That keeps every existing relative link (docs/x.md,
+# ../../assets/y.png) working identically in both places.
+docs_dir: .mkdocs-build/docs
+site_dir: _site
+
+not_in_nav: |
+ sources/*
+
+theme:
+ name: material
+ language: zh
+ icon:
+ repo: fontawesome/brands/github
+ features:
+ - navigation.instant
+ - navigation.tracking
+ - navigation.top
+ - navigation.indexes
+ - navigation.footer
+ - toc.follow
+ - search.suggest
+ - search.highlight
+ - search.share
+ - content.code.copy
+ - content.action.edit
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/weather-night
+ name: 切换到深色模式
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/weather-sunny
+ name: 切换到浅色模式
+
+plugins:
+ - search:
+ lang:
+ - zh
+ - en
+
+markdown_extensions:
+ - abbr
+ - admonition
+ - attr_list
+ - def_list
+ - footnotes
+ - md_in_html
+ - tables
+ - toc:
+ permalink: true
+ toc_depth: 3
+ - pymdownx.details
+ - pymdownx.highlight:
+ anchor_linenums: true
+ - pymdownx.inlinehilite
+ - pymdownx.snippets
+ - pymdownx.arithmatex:
+ generic: true
+ - pymdownx.superfences:
+ custom_fences:
+ # The chapters write display equations as ```math fences (GitHub style).
+ - name: math
+ class: arithmatex
+ format: !!python/object/apply:pymdownx.arithmatex.arithmatex_fenced_format
+ kwds:
+ mode: generic
+ tag: pre
+
+extra_javascript:
+ - assets/js/mathjax.js
+ - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js
+
+extra:
+ social:
+ - icon: fontawesome/brands/github
+ link: https://github.com/GVCLab
+ - icon: fontawesome/solid/flask
+ link: https://gvclab.github.io/
diff --git a/requirements-docs.txt b/requirements-docs.txt
new file mode 100644
index 0000000..dd8697d
--- /dev/null
+++ b/requirements-docs.txt
@@ -0,0 +1,4 @@
+mkdocs-material==9.7.6
+pymdown-extensions>=10.21
+jieba>=0.42 # Chinese tokenisation for the search index
+Pillow>=10 # scripts/optimize_assets.py
diff --git a/resources/datasets.md b/resources/datasets.md
index 3095ea4..2a73baa 100644
--- a/resources/datasets.md
+++ b/resources/datasets.md
@@ -67,7 +67,7 @@ N_{\mathrm{eff,source}}
下面给出可编辑、可搜索、对读屏器更友好的确定性版本:
-
+
顺序化文字替代:
1. 登记来源、权利证据与 opt-out 通道;通过权利门后再做不可变摄取。
diff --git a/resources/open-models.md b/resources/open-models.md
index dcfd370..a8b849c 100644
--- a/resources/open-models.md
+++ b/resources/open-models.md
@@ -72,7 +72,7 @@ R(m)=(P,C,W,I,T,D,E,L,H),
**图 1:开放模型不是一个下载按钮。** 主链把模型身份、版本、发布物、许可证、硬件、运行证据和最终 manifest 连起来;红色出口表示证据不足时的隔离路径。上图是教学总览,下方 Mermaid 是可编辑且适合读屏器的规范版本。
-
+
顺序化文字替代:确认作者身份;固定代码和权重 revision;分别核对九个发布面;求许可证交集;检查显存、磁盘、CUDA 和精度;用固定输入与随机种子跑最小样例;验证输出媒体契约;人工回读条件遵循和失败模式;最后保存 manifest、hash、命令、硬件和日志。任一关键门失败都应记录并隔离。
---
@@ -138,7 +138,7 @@ R(m)=(P,C,W,I,T,D,E,L,H),
## 4. 先选研究问题,再选模型
-
+
### 4.1 按显存筛选时的正确读法
| 官方入口级别 | 可先考虑的路线 | 关键限定 |
@@ -175,7 +175,7 @@ R(m)=(P,C,W,I,T,D,E,L,H),
单个“最好看”的视频只能证明一次成功,不能证明模型稳定。最低限度应使用多个 prompt 与 seed,并统一输出合同:容器、编码、宽高、FPS、帧数、音轨和时长。FFmpeg 可检查媒体规格,但不能替代语义和物理评测 [[30]](#ref-30)。
-
+
---
## 6. 训练与微调:先问“开放到哪一层”
diff --git a/scripts/build_site.py b/scripts/build_site.py
new file mode 100755
index 0000000..7ef6a15
--- /dev/null
+++ b/scripts/build_site.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Stage the repository into a MkDocs docs_dir, then build the site.
+
+MkDocs refuses to use the directory holding mkdocs.yml as docs_dir, but this
+repo is read on GitHub as much as on the site, so its links are all written
+relative to the repo root (docs/x.md, ../../assets/y.png). Copying the tree
+into .mkdocs-build/docs preserves that shape exactly, so one set of links
+works in both places and nothing has to be rewritten.
+
+Files are hard-linked where the filesystem allows it, so staging ~240MB of
+diagrams costs no extra disk and no copy time.
+
+ python3 scripts/build_site.py # build into _site/
+ python3 scripts/build_site.py --serve # live preview on :8000
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+STAGE = ROOT / ".mkdocs-build" / "docs"
+
+# Everything the site needs, in repo-root-relative form.
+INCLUDE = ["README.md", "CONTRIBUTING.md", "CITATION.cff", "LICENSE",
+ "docs", "assets", "resources", "bibliography", "sources"]
+SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", "_site",
+ ".mkdocs-build"}
+
+
+def stage() -> None:
+ if STAGE.exists():
+ shutil.rmtree(STAGE)
+ STAGE.mkdir(parents=True)
+ linked = copied = 0
+ for name in INCLUDE:
+ src = ROOT / name
+ if not src.exists():
+ print(f" skip (missing): {name}", file=sys.stderr)
+ continue
+ if src.is_file():
+ _place(src, STAGE / name)
+ linked += 1
+ continue
+ for path in src.rglob("*"):
+ if any(part in SKIP_DIRS for part in path.parts):
+ continue
+ if path.is_dir():
+ continue
+ dst = STAGE / path.relative_to(ROOT)
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ if _place(path, dst):
+ linked += 1
+ else:
+ copied += 1
+ print(f"staged {linked} hard-linked + {copied} copied file(s) -> {STAGE}",
+ file=sys.stderr)
+
+
+def _place(src: Path, dst: Path) -> bool:
+ try:
+ os.link(src, dst)
+ return True
+ except OSError:
+ shutil.copy2(src, dst)
+ return False
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--serve", action="store_true")
+ ap.add_argument("--strict", action="store_true")
+ args = ap.parse_args()
+
+ stage()
+ cmd = ["mkdocs", "serve" if args.serve else "build",
+ "-f", str(ROOT / "mkdocs.yml")]
+ if args.strict:
+ cmd.append("--strict")
+ return subprocess.call(cmd, cwd=ROOT)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/fix_asset_links.py b/scripts/fix_asset_links.py
new file mode 100755
index 0000000..84a0ae4
--- /dev/null
+++ b/scripts/fix_asset_links.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+"""Normalise asset filenames and repair relative links in Markdown.
+
+Two problems this fixes, both of which render as broken images on GitHub:
+
+1. Image generators write their prompt into the filename, producing paths with
+ CJK characters, spaces and timestamps. Those break on case-insensitive and
+ non-UTF-8 filesystems and are unusable as stable URLs.
+2. Chapters under docs/ referenced `assets/...` when the assets live at the
+ repository root, so the path only resolves from the root, not from the file
+ doing the referencing.
+
+The script is idempotent: run it after adding new diagrams.
+
+ python3 scripts/fix_asset_links.py --dry-run
+ python3 scripts/fix_asset_links.py
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import urllib.parse
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+ASSETS = ROOT / "assets"
+LINK = re.compile(r"(!?\[[^\]\[]*\]\()([^)\s]+)(\))")
+MD_ROOTS = ["docs", "resources", "sources"]
+IMAGE_EXT = {".png", ".webp", ".jpg", ".jpeg", ".gif", ".svg"}
+
+# assets/zh/ never existed; the file lives under assets/diagrams/
+MISPLACED = {"assets/zh/video-reasoning-roadmap.png":
+ "assets/diagrams/video-reasoning-roadmap.png"}
+
+
+def markdown_files() -> list[Path]:
+ out = [ROOT / "README.md"]
+ for d in MD_ROOTS:
+ out += sorted((ROOT / d).rglob("*.md"))
+ return [p for p in out if p.is_file()]
+
+
+def normalise_names(dry: bool) -> dict[str, str]:
+ """Rename generator-named files to stable slugs. Returns old->new (repo-relative)."""
+ renames: dict[str, str] = {}
+
+ # assets/imagegen-diagrams/NNN/ holds exactly one diagram
+ for d in sorted(ASSETS.glob("imagegen-diagrams/[0-9][0-9][0-9]")):
+ files = [f for f in d.iterdir() if f.is_file()]
+ if len(files) == 1 and files[0].stem != "diagram":
+ new = d / ("diagram" + files[0].suffix)
+ renames[str(files[0].relative_to(ROOT))] = str(new.relative_to(ROOT))
+ if not dry:
+ os.rename(files[0], new)
+
+ # assets/imagegen-diagrams/batches// holds the multi-up source sheets
+ for d in sorted(ASSETS.glob("imagegen-diagrams/batches/*")):
+ if not d.is_dir():
+ continue
+ for i, f in enumerate(sorted(p for p in d.iterdir() if p.is_file()), 1):
+ new = d / f"sheet-{i:02d}{f.suffix}"
+ if f != new:
+ renames[str(f.relative_to(ROOT))] = str(new.relative_to(ROOT))
+ if not dry:
+ os.rename(f, new)
+ return renames
+
+
+def repair_links(renames: dict[str, str], dry: bool) -> tuple[int, list[str]]:
+ fixed = 0
+ unresolved: list[str] = []
+ for md in markdown_files():
+ text = original = md.read_text(encoding="utf-8")
+
+ def repl(m: re.Match) -> str:
+ nonlocal fixed
+ pre, target, post = m.groups()
+ if target.startswith(("http://", "https://", "mailto:", "#")):
+ return m.group(0)
+ raw = urllib.parse.unquote(target.split("#")[0])
+ if not raw or (md.parent / raw).exists():
+ return m.group(0)
+ if Path(raw).suffix.lower() not in IMAGE_EXT:
+ return m.group(0)
+ # re-anchor against the repository root, then apply known fixups
+ norm = raw.lstrip("./")
+ while norm.startswith("../"):
+ norm = norm[3:]
+ norm = MISPLACED.get(norm, norm)
+ norm = renames.get(norm, norm)
+ if not (ROOT / norm).exists():
+ unresolved.append(f"{md.relative_to(ROOT)}: {target}")
+ return m.group(0)
+ fixed += 1
+ return f"{pre}{os.path.relpath(ROOT / norm, md.parent)}{post}"
+
+ text = LINK.sub(repl, text)
+ if text != original and not dry:
+ md.write_text(text, encoding="utf-8")
+ return fixed, unresolved
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--dry-run", action="store_true")
+ args = ap.parse_args()
+
+ renames = normalise_names(args.dry_run)
+ print(f"{'would rename' if args.dry_run else 'renamed'} {len(renames)} asset file(s)")
+ fixed, unresolved = repair_links(renames, args.dry_run)
+ print(f"{'would repair' if args.dry_run else 'repaired'} {fixed} Markdown link(s)")
+ for u in unresolved:
+ print(f" UNRESOLVED {u}")
+ return 1 if unresolved else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/optimize_assets.py b/scripts/optimize_assets.py
new file mode 100755
index 0000000..e0dcaef
--- /dev/null
+++ b/scripts/optimize_assets.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""Convert diagram PNGs to WebP and repoint every Markdown reference.
+
+The diagrams are 1672x941 AI-rendered images. As PNG they cost ~119 MB, which
+every clone pays for. WebP q=95 reproduces them at higher fidelity than a
+256-colour PNG at roughly a sixth of the size, and renders natively on GitHub
+and in every current browser.
+
+ python3 scripts/optimize_assets.py --dry-run
+ python3 scripts/optimize_assets.py
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import urllib.parse
+from pathlib import Path
+
+from PIL import Image
+
+ROOT = Path(__file__).resolve().parent.parent
+QUALITY = 95
+LINK = re.compile(r"(!?\[[^\]\[]*\]\()([^)\s]+)(\))")
+MD_ROOTS = ["docs", "resources", "sources"]
+
+
+def markdown_files() -> list[Path]:
+ out = [ROOT / "README.md"]
+ for d in MD_ROOTS:
+ out += sorted((ROOT / d).rglob("*.md"))
+ return [p for p in out if p.is_file()]
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--dry-run", action="store_true")
+ ap.add_argument("--quality", type=int, default=QUALITY)
+ args = ap.parse_args()
+
+ pngs = sorted((ROOT / "assets").rglob("*.png"))
+ before = after = 0
+ mapping: dict[str, str] = {}
+
+ for png in pngs:
+ webp = png.with_suffix(".webp")
+ im = Image.open(png)
+ im = im.convert("RGBA") if im.mode in ("RGBA", "LA", "P") else im.convert("RGB")
+ before += png.stat().st_size
+ if args.dry_run:
+ import io
+ buf = io.BytesIO(); im.save(buf, "WEBP", quality=args.quality, method=6)
+ after += buf.tell()
+ else:
+ im.save(webp, "WEBP", quality=args.quality, method=6)
+ after += webp.stat().st_size
+ png.unlink()
+ mapping[str(png.relative_to(ROOT))] = str(webp.relative_to(ROOT))
+
+ print(f"{len(pngs)} PNG -> WebP q{args.quality}: "
+ f"{before/1048576:.1f} MB -> {after/1048576:.1f} MB "
+ f"({after/max(before,1):.0%}, saved {(before-after)/1048576:.1f} MB)")
+
+ if args.dry_run:
+ return 0
+
+ rewritten = 0
+ for md in markdown_files():
+ text = original = md.read_text(encoding="utf-8")
+
+ def repl(m: re.Match) -> str:
+ nonlocal rewritten
+ pre, target, post = m.groups()
+ if target.startswith(("http://", "https://", "mailto:", "#")):
+ return m.group(0)
+ raw = urllib.parse.unquote(target.split("#")[0])
+ if not raw.lower().endswith(".png"):
+ return m.group(0)
+ abs_old = (md.parent / raw).resolve()
+ try:
+ key = str(abs_old.relative_to(ROOT))
+ except ValueError:
+ return m.group(0)
+ if key not in mapping:
+ return m.group(0)
+ rewritten += 1
+ return f"{pre}{os.path.relpath(ROOT / mapping[key], md.parent)}{post}"
+
+ text = LINK.sub(repl, text)
+ if text != original:
+ md.write_text(text, encoding="utf-8")
+ print(f"repointed {rewritten} Markdown reference(s)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/verify_citations.py b/scripts/verify_citations.py
new file mode 100755
index 0000000..3177d5b
--- /dev/null
+++ b/scripts/verify_citations.py
@@ -0,0 +1,331 @@
+#!/usr/bin/env python3
+"""Verify in-chapter reference entries against authoritative sources.
+
+The central bibliography (bibliography/registry.json) is already machine-built
+from arXiv/Crossref, and CI runs lychee for dead links. Neither checks the
+~2000 hand-written per-chapter reference entries, where the anchor text
+(title), venue and year can be wrong even when the URL resolves fine.
+
+This script checks exactly that:
+ * arXiv links -> title claimed in anchor text vs real arXiv title
+ -> year claimed in the line vs arXiv v1 announcement year
+ * CVF / ICLR / NeurIPS / ECVA links -> venue+year encoded in the URL path
+ vs venue+year claimed in the line
+
+Usage:
+ python3 scripts/verify_citations.py # full run
+ python3 scripts/verify_citations.py --offline # URL-path checks only
+ python3 scripts/verify_citations.py --json out.json
+"""
+from __future__ import annotations
+
+import argparse
+import difflib
+import json
+import re
+import sys
+import time
+import urllib.parse
+import urllib.request
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass, field, asdict
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SCAN_DIRS = ["docs", "resources", "sources"]
+SCAN_FILES = ["README.md"]
+
+ARXIV_API = "https://export.arxiv.org/api/query"
+ARXIV_BATCH = 50
+ARXIV_SLEEP = 3.0 # arXiv asks for >=3s between programmatic requests
+TITLE_FLAG = 0.72 # similarity below this is reported
+
+MD_LINK = re.compile(r"\[([^\]\[]+)\]\((https?://[^\s)]+)\)")
+ARXIV_ABS = re.compile(r"arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})")
+YEAR = re.compile(r"\b(19[89]\d|20[0-3]\d)\b")
+
+# venue + year encoded in the URL path
+URL_VENUE = [
+ (re.compile(r"openaccess\.thecvf\.com/content/([A-Za-z]+)(\d{4})"), None),
+ (re.compile(r"openaccess\.thecvf\.com/content_(\w+?)_(\d{4})"), None),
+ (re.compile(r"proceedings\.iclr\.cc/paper_files/paper/(\d{4})"), "ICLR"),
+ (re.compile(r"proceedings\.neurips\.cc/paper_files/paper/(\d{4})"), "NeurIPS"),
+ (re.compile(r"papers\.nips\.cc/paper_files/paper/(\d{4})"), "NeurIPS"),
+ (re.compile(r"ecva\.net/papers/eccv_(\d{4})"), "ECCV"),
+]
+VENUE_ALIASES = {
+ "NEURIPS": "NEURIPS", "NIPS": "NEURIPS", "NEURAL": "NEURIPS",
+ "CVPR": "CVPR", "ICCV": "ICCV", "WACV": "WACV", "ECCV": "ECCV",
+ "ICLR": "ICLR", "ICML": "ICML", "AAAI": "AAAI", "SIGGRAPH": "SIGGRAPH",
+ "CVPRW": "CVPR", "ICCVW": "ICCV",
+}
+VENUE_TOKEN = re.compile(
+ r"\b(CVPR|ICCV|ECCV|WACV|ICLR|ICML|NeurIPS|NIPS|AAAI|SIGGRAPH|TPAMI|IJCV|TMLR|TVCG)\b",
+ re.I,
+)
+
+
+@dataclass
+class Finding:
+ severity: str
+ kind: str
+ file: str
+ line: int
+ url: str
+ claimed: str
+ actual: str
+ note: str = ""
+
+
+@dataclass
+class Ref:
+ file: str
+ line: int
+ text: str # anchor text
+ url: str
+ context: str # the whole source line
+ arxiv_id: str | None = None
+
+
+def norm_title(s: str) -> str:
+ s = s.lower()
+ s = re.sub(r"[‐-―]", "-", s)
+ s = re.sub(r"[^a-z0-9]+", " ", s)
+ return " ".join(s.split())
+
+
+def title_score(claimed: str, actual: str) -> float:
+ a, b = norm_title(claimed), norm_title(actual)
+ if not a or not b:
+ return 0.0
+ if a == b:
+ return 1.0
+ # anchor text often prefixes the real title with a short model name
+ # ("PAB: Real-Time ..." vs "Real-Time ..."), or truncates the subtitle.
+ if a in b or b in a:
+ return 0.97
+ at, bt = set(a.split()), set(b.split())
+ jacc = len(at & bt) / max(1, len(at | bt))
+ return max(difflib.SequenceMatcher(None, a, b).ratio(), jacc)
+
+
+def iter_markdown() -> list[Path]:
+ out: list[Path] = []
+ for d in SCAN_DIRS:
+ p = ROOT / d
+ if p.is_dir():
+ out += sorted(p.rglob("*.md"))
+ for f in SCAN_FILES:
+ p = ROOT / f
+ if p.is_file():
+ out.append(p)
+ return out
+
+
+def collect_refs() -> list[Ref]:
+ refs: list[Ref] = []
+ for path in iter_markdown():
+ rel = str(path.relative_to(ROOT))
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ for text, url in MD_LINK.findall(line):
+ if text.startswith("!") or "img.shields.io" in url:
+ continue
+ m = ARXIV_ABS.search(url)
+ refs.append(Ref(rel, lineno, text.strip(), url,
+ line.strip(), m.group(1) if m else None))
+ return refs
+
+
+class ArxivUnavailable(RuntimeError):
+ """The arXiv API could not be reached; absence of a record proves nothing."""
+
+
+def arxiv_lookup(ids: list[str], verbose: bool = True) -> tuple[dict[str, dict], set[str]]:
+ """Return (metadata_by_id, ids_actually_queried).
+
+ Only ids in the second element carry a meaningful negative result. Ids from
+ a failed batch are simply unknown and must not be reported as missing.
+ """
+ out: dict[str, dict] = {}
+ queried: set[str] = set()
+ failed_batches = 0
+ total_batches = (len(ids) + ARXIV_BATCH - 1) // ARXIV_BATCH
+ for i in range(0, len(ids), ARXIV_BATCH):
+ chunk = ids[i:i + ARXIV_BATCH]
+ params = urllib.parse.urlencode(
+ {"id_list": ",".join(chunk), "max_results": len(chunk)}
+ )
+ if verbose:
+ print(f" arXiv batch {i // ARXIV_BATCH + 1}"
+ f"/{(len(ids) + ARXIV_BATCH - 1) // ARXIV_BATCH}"
+ f" ({len(chunk)} ids)", file=sys.stderr)
+ try:
+ with urllib.request.urlopen(f"{ARXIV_API}?{params}", timeout=90) as r:
+ root = ET.fromstring(r.read())
+ except Exception as exc: # network / API failure
+ failed_batches += 1
+ print(f" ! batch failed: {exc}", file=sys.stderr)
+ time.sleep(ARXIV_SLEEP)
+ continue
+ queried.update(chunk)
+ ns = {"a": "http://www.w3.org/2005/Atom"}
+ for entry in root.findall("a:entry", ns):
+ eid = entry.findtext("a:id", "", ns)
+ m = re.search(r"abs/(\d{4}\.\d{4,5})v(\d+)", eid)
+ if not m:
+ continue
+ out[m.group(1)] = {
+ "title": " ".join((entry.findtext("a:title", "", ns) or "").split()),
+ "published": entry.findtext("a:published", "", ns),
+ "authors": [a.findtext("a:name", "", ns)
+ for a in entry.findall("a:author", ns)],
+ }
+ time.sleep(ARXIV_SLEEP)
+ if total_batches and failed_batches == total_batches:
+ raise ArxivUnavailable(
+ "every arXiv batch failed -- the API is unreachable from this host. "
+ "Refusing to report missing records, which would all be false."
+ )
+ if failed_batches:
+ print(f" ! {failed_batches}/{total_batches} batches failed; "
+ f"{len(ids) - len(queried)} ids left unverified", file=sys.stderr)
+ return out, queried
+
+
+def check_arxiv(refs: list[Ref], meta: dict[str, dict],
+ queried: set[str]) -> list[Finding]:
+ per_line: dict[tuple[str, int], int] = {}
+ for r in refs:
+ per_line[(r.file, r.line)] = per_line.get((r.file, r.line), 0) + 1
+ findings: list[Finding] = []
+ for r in refs:
+ if not r.arxiv_id:
+ continue
+ info = meta.get(r.arxiv_id)
+ if info is None:
+ if r.arxiv_id not in queried:
+ continue # never successfully queried -- unknown, not absent
+ findings.append(Finding(
+ "ERROR", "arxiv-not-found", r.file, r.line, r.url,
+ r.text, "(arXiv API returned nothing)",
+ "id may be wrong, withdrawn, or not yet announced"))
+ continue
+ # title check -- only when the anchor text looks like a title
+ if len(r.text) > 12 and not r.text.lower().startswith(("http", "github", "官方", "见")):
+ score = title_score(r.text, info["title"])
+ if score < TITLE_FLAG:
+ findings.append(Finding(
+ "ERROR", "title-mismatch", r.file, r.line, r.url,
+ r.text, info["title"], f"similarity={score:.2f}"))
+ # year check -- compare arXiv v1 year against years stated on the line
+ pub_year = info["published"][:4]
+ stated = set(YEAR.findall(r.context)) if per_line[(r.file, r.line)] == 1 else set()
+ if pub_year and stated and pub_year not in stated:
+ # allow a claimed venue year later than the preprint year
+ if all(int(y) < int(pub_year) for y in stated):
+ findings.append(Finding(
+ "WARN", "year-before-preprint", r.file, r.line, r.url,
+ "/".join(sorted(stated)), f"arXiv v1 {pub_year}",
+ "line claims a year earlier than the first arXiv version"))
+ return findings
+
+
+def check_url_venue(refs: list[Ref]) -> list[Finding]:
+ """Venue/year encoded in the URL vs venue/year claimed on the same line.
+
+ Only applied to lines carrying exactly one reference link. Table rows list
+ several papers from different venues side by side, so a line-level claim
+ cannot be attributed to a specific link there.
+ """
+ per_line: dict[tuple[str, int], int] = {}
+ for r in refs:
+ per_line[(r.file, r.line)] = per_line.get((r.file, r.line), 0) + 1
+ findings: list[Finding] = []
+ for r in refs:
+ if per_line[(r.file, r.line)] != 1:
+ continue
+ got_venue = got_year = None
+ for pat, fixed in URL_VENUE:
+ m = pat.search(r.url)
+ if not m:
+ continue
+ if fixed:
+ got_venue, got_year = fixed, m.group(1)
+ else:
+ got_venue, got_year = m.group(1), m.group(2)
+ break
+ if not got_venue:
+ continue
+ gv = VENUE_ALIASES.get(got_venue.upper(), got_venue.upper())
+ # what does the surrounding line claim?
+ claimed_venues = {VENUE_ALIASES.get(v.upper(), v.upper())
+ for v in VENUE_TOKEN.findall(r.context)}
+ claimed_years = set(YEAR.findall(r.context))
+ if claimed_venues and gv not in claimed_venues:
+ findings.append(Finding(
+ "ERROR", "venue-mismatch", r.file, r.line, r.url,
+ "/".join(sorted(claimed_venues)), f"{gv} (from URL)"))
+ if claimed_years and got_year not in claimed_years:
+ findings.append(Finding(
+ "ERROR", "venue-year-mismatch", r.file, r.line, r.url,
+ "/".join(sorted(claimed_years)), f"{got_year} (from URL)"))
+ return findings
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--offline", action="store_true",
+ help="skip arXiv API, run URL-path checks only")
+ ap.add_argument("--json", type=Path, help="write findings as JSON")
+ ap.add_argument("--fail-on", default="ERROR", choices=["ERROR", "WARN", "NONE"])
+ args = ap.parse_args()
+
+ refs = collect_refs()
+ arxiv_ids = sorted({r.arxiv_id for r in refs if r.arxiv_id})
+ print(f"scanned {len(set(r.file for r in refs))} files, "
+ f"{len(refs)} links, {len(arxiv_ids)} unique arXiv ids",
+ file=sys.stderr)
+
+ findings = check_url_venue(refs)
+ unverified = 0
+ if not args.offline:
+ try:
+ meta, queried = arxiv_lookup(arxiv_ids)
+ except ArxivUnavailable as exc:
+ print(f"\nFATAL: {exc}", file=sys.stderr)
+ print("Re-run with --offline for URL-path checks only.", file=sys.stderr)
+ return 2
+ findings += check_arxiv(refs, meta, queried)
+ unverified = len(arxiv_ids) - len(queried)
+
+ order = {"ERROR": 0, "WARN": 1}
+ findings.sort(key=lambda f: (order.get(f.severity, 2), f.file, f.line))
+
+ for f in findings:
+ print(f"[{f.severity}] {f.kind} {f.file}:{f.line}")
+ print(f" claimed: {f.claimed}")
+ print(f" actual : {f.actual}")
+ if f.note:
+ print(f" note : {f.note}")
+ print(f" url : {f.url}")
+
+ errs = sum(1 for f in findings if f.severity == "ERROR")
+ warns = sum(1 for f in findings if f.severity == "WARN")
+ print(f"\n{errs} error(s), {warns} warning(s)"
+ + (f", {unverified} arXiv id(s) unverified" if unverified else ""),
+ file=sys.stderr)
+
+ if args.json:
+ args.json.write_text(json.dumps([asdict(f) for f in findings],
+ ensure_ascii=False, indent=2),
+ encoding="utf-8")
+
+ if args.fail_on == "ERROR":
+ return 1 if errs else 0
+ if args.fail_on == "WARN":
+ return 1 if (errs or warns) else 0
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/sources/research_20260829_evaluation.md b/sources/research_20260829_evaluation.md
index 7e43cb1..c4297a9 100644
--- a/sources/research_20260829_evaluation.md
+++ b/sources/research_20260829_evaluation.md
@@ -89,7 +89,7 @@ For each promoted source, the audit checked the title, venue/status, current ver
The review workflow is shown below. This is a trace diagram, not a numerical PRISMA flow because a complete candidate denominator was not captured.
-
+
## Evidence levels
Evidence level describes the source and the maximum claim it can safely support. It is not a score for paper quality.
@@ -431,7 +431,7 @@ The interpretation boundary is non-negotiable: absence of C2PA does not prove me
The protocol below prevents a metric from being promoted beyond the task it measures.
-
+
| Task | Evaluation unit | Primary evidence | Claim that remains unsupported |
|---|---|---|---|
| Open generation | Prompt by seed | Quality, distribution/coverage, compositional adherence, time, task-specific physics and human preference | Action causality or planning value |
diff --git a/sources/research_20260829_generative_models.md b/sources/research_20260829_generative_models.md
index 5a446bc..cd5519c 100644
--- a/sources/research_20260829_generative_models.md
+++ b/sources/research_20260829_generative_models.md
@@ -378,7 +378,7 @@ Few-step 只降低每帧/chunk 的 NFE,causal factorization 只限制信息方
图名建议为“视频生成系统不是单标签:五个正交选择汇合为一个可评测系统”。图不能画成 VAE → AR → Diffusion → Streaming 的线性进化,因为五轴可以自由组合。画面应先让五个独立选择汇入 `Configured generator`,再从系统声明反向指向相应证据。
-
+
### 10.2 节点与视觉规范
| 节点 ID | 形状 | 色彩建议 | 文本上限 | 进入/离开边 |
diff --git a/sources/research_20260829_physical_consistency.md b/sources/research_20260829_physical_consistency.md
index 6a79972..56829ec 100644
--- a/sources/research_20260829_physical_consistency.md
+++ b/sources/research_20260829_physical_consistency.md
@@ -116,7 +116,7 @@ E_k=
## 4. 证据阶梯
-
+
使用规则:
- claim 不得高于实际通过的最高证据层;L2 高分不能写成 L3,L3 也不能自动写成 L6。
@@ -242,7 +242,7 @@ E_k=
## 7. 训练、后训练与验证路线
-
+
推荐的工程顺序:
1. **先定义 claim。** 如果目标只是减少明显穿透,就不应把工作描述成“学习真实世界定律”;如果目标是机器人 planning,就必须预先定义决策指标。