From 875768f2b3617cf1b73545f8c0294c57bd4ec1e7 Mon Sep 17 00:00:00 2001 From: hash-panda <110599333+hash-panda@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:43:59 +0800 Subject: [PATCH] Launch AI learning course with reviewed token-choice lesson and learner check --- apps/course/catalog.json | 92 +++++++++++ apps/site/learn/course.css | 3 + apps/site/learn/token-choice/LESSON.md | 69 ++++++++ apps/site/learn/token-choice/SCENE_SPEC.json | 79 +++++++++ apps/site/learn/token-choice/app.mjs | 44 +++++ apps/site/learn/token-choice/index.html | 13 ++ apps/site/learn/token-choice/model.mjs | 60 +++++++ apps/site/learn/token-choice/model.test.mjs | 98 +++++++++++ apps/site/learn/token-choice/stage.mjs | 163 +++++++++++++++++++ apps/site/learn/token-choice/style.css | 13 ++ docs/ai-learning/REVIEW.md | 38 +++++ docs/ai-learning/STATUS.md | 23 +++ docs/ai-learning/roadmap.json | 111 +++++++++++++ evals/verify-course.mjs | 17 ++ evals/verify-token-choice.mjs | 21 +++ package.json | 4 +- scripts/build-course.mjs | 14 ++ scripts/build.mjs | 8 +- scripts/bundle-token-choice.mjs | 19 +++ 19 files changed, 884 insertions(+), 5 deletions(-) create mode 100644 apps/course/catalog.json create mode 100644 apps/site/learn/course.css create mode 100644 apps/site/learn/token-choice/LESSON.md create mode 100644 apps/site/learn/token-choice/SCENE_SPEC.json create mode 100644 apps/site/learn/token-choice/app.mjs create mode 100644 apps/site/learn/token-choice/index.html create mode 100644 apps/site/learn/token-choice/model.mjs create mode 100644 apps/site/learn/token-choice/model.test.mjs create mode 100644 apps/site/learn/token-choice/stage.mjs create mode 100644 apps/site/learn/token-choice/style.css create mode 100644 docs/ai-learning/REVIEW.md create mode 100644 docs/ai-learning/STATUS.md create mode 100644 docs/ai-learning/roadmap.json create mode 100644 evals/verify-course.mjs create mode 100644 evals/verify-token-choice.mjs create mode 100644 scripts/build-course.mjs create mode 100644 scripts/bundle-token-choice.mjs diff --git a/apps/course/catalog.json b/apps/course/catalog.json new file mode 100644 index 0000000..59e1dc4 --- /dev/null +++ b/apps/course/catalog.json @@ -0,0 +1,92 @@ +[ + { + "id": "tokenization", + "order": 1, + "title": "文字怎样变成 Token?", + "outcome": "观察同一句话如何分成片段并映射为编号;区分字符、词和 Token。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "embedding", + "order": 2, + "title": "编号怎样变成向量?", + "outcome": "跟踪查表得到的向量,并比较方向、距离与其教学边界。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "attention", + "order": 3, + "title": "一个词如何参考其他词?", + "outcome": "从 QK 分数到因果遮罩、注意力权重与 V 的加权结果。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "transformer", + "order": 4, + "title": "一层网络怎样更新表示?", + "outcome": "跟踪注意力、残差、归一化与前馈变换中的同一个向量。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "token-choice", + "order": 5, + "title": "一个词,是怎样被选出来的?", + "outcome": "分数经过温度、指数和归一化,怎样得到一次选择。", + "status": "reviewed", + "route": "/learn/token-choice/", + "published": false + }, + { + "id": "autoregression", + "order": 6, + "title": "为什么生成要一个 Token 接一个?", + "outcome": "把选中的 Token 接回上下文,区分重算与 KV 缓存的复用。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "context", + "order": 7, + "title": "System Prompt 怎样进入上下文?", + "outcome": "查看消息、指令与窗口容量,理解裁剪、优先级和能力边界。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "rag", + "order": 8, + "title": "检索怎样帮助模型回答?", + "outcome": "从文档切片、检索到带来源的上下文;区分找到、引用与答对。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "skills-tools-mcp", + "order": 9, + "title": "Skill、工具和 MCP 分别做什么?", + "outcome": "用一次任务追踪操作说明、工具调用、协议与实际执行。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "personal-agent", + "order": 10, + "title": "Personal Agent 怎样完成一件事?", + "outcome": "跟踪计划、行动、观察、记忆与结束条件,看到失败如何反馈。", + "status": "planned", + "route": null, + "published": false + } +] diff --git a/apps/site/learn/course.css b/apps/site/learn/course.css new file mode 100644 index 0000000..c557747 --- /dev/null +++ b/apps/site/learn/course.css @@ -0,0 +1,3 @@ +:root{font-family:Inter,'PingFang SC','Microsoft YaHei',system-ui,sans-serif;color:#393543;background:#f7f5ef;font-synthesis:none}*{box-sizing:border-box}body{margin:0}a{color:inherit;text-decoration:none}a:focus-visible{outline:2px solid #8b73ac;outline-offset:6px}.page{max-width:1360px;margin:auto;padding:0 56px}header{height:90px;display:flex;justify-content:space-between;align-items:center}.brand{font-size:20px;font-weight:700;letter-spacing:-.5px}.brand i{font:italic bold 29px Georgia,serif;color:#8066a6;margin-right:5px}nav{display:flex;gap:28px;color:#8a7c95;font-size:12px}.hero{padding:65px 0 78px;position:relative;isolation:isolate}.eyebrow{font-size:9px;font-weight:600;letter-spacing:2px;color:#9b86ac;margin:0 0 24px}h1{font-size:clamp(46px,6vw,78px);font-weight:600;line-height:1.18;letter-spacing:-3px;margin:0}h1 span{color:#8a769d}.hero-bottom{display:flex;gap:55px;align-items:flex-end;margin-top:30px}.hero-bottom p{font-size:14px;color:#8d7f98;line-height:1.9;margin:0}.start{font-size:12px;padding:13px 17px;border:1px solid #dfd4e6;border-radius:8px;color:#7b618e;background:#fffdfa}.start:hover{background:#eee7f3}.probability-art{position:absolute;right:0;top:145px;width:34%;height:112px;display:flex;gap:2px;transform:skewY(-6deg);border:1px solid #ded9e2;border-radius:4px;padding:6px;background:#fefdfb;box-shadow:6px 8px 0 #e8e4eb,8px 9px 0 #d9d2e1;z-index:-1}.probability-art i{height:100%;border:1px solid #ffffff9c;box-shadow:inset -8px 0 9px #ffffff49}.probability-art i:nth-child(1){width:50.6%;background:#91abd1}.probability-art i:nth-child(2){width:27.1%;background:#b09cd0}.probability-art i:nth-child(3){width:14.5%;background:#97c0ae}.probability-art i:nth-child(4){width:7.8%;background:#d8af9c}.probability-art span{position:absolute;top:135px;right:0;transform:skewY(6deg);font-size:10px;color:#a18cae;letter-spacing:1px}.section-title{padding:30px 0 24px;border-top:1px solid #e4ded9;display:flex;align-items:flex-end;justify-content:space-between}.section-title .eyebrow{margin-bottom:12px}.section-title h2,.method h2{font-size:28px;font-weight:550;letter-spacing:-.7px;margin:0}.section-title>p{font-size:11px;color:#8d7c99;margin:0 0 3px}.lessons{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.lesson{border:1px solid #e6e0d9;padding:25px 28px 22px;min-height:215px;border-radius:13px;background:#faf8f3}.lesson-meta{display:flex;justify-content:space-between;font:10px ui-monospace,monospace;color:#a390b2}.lesson h2{font-size:22px;font-weight:550;letter-spacing:-.5px;margin:24px 0 12px}.lesson p{font-size:12px;line-height:1.85;color:#918199;margin:0;max-width:460px}.lesson-foot{font-size:10px;color:#b0a0b8;margin-top:23px}.lesson.available{background:#fffefa;border-color:#cdb9dc;box-shadow:0 8px 28px #8b73ac06}.lesson.available h2{color:#6f5388}.lesson.available:hover{border-color:#9c7fb6;transform:translateY(-2px)}.lesson.available .lesson-foot{color:#9374aa}.lesson{transition:transform .2s,border-color .2s}.method{display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:70px 0;border-bottom:1px solid #e4ded9}.method p{font-size:13px;line-height:1.95;color:#8d7d98;margin:0 0 15px}.method a{font-size:12px;color:#8b6ca2}footer{padding:30px 0 36px;display:flex;justify-content:space-between;color:#a18daa;font-size:10px} +@media(max-width:760px){.page{padding:0 23px}header{height:73px}nav{gap:15px;font-size:10px}.brand{font-size:17px}.hero{padding:40px 0 48px}h1{font-size:53px;letter-spacing:-2px}.hero-bottom{display:block;margin-top:24px}.hero-bottom p{font-size:12px}.start{display:inline-block;margin-top:22px;font-size:11px}.probability-art{top:118px;right:6px;width:30%;height:65px;opacity:.8;box-shadow:4px 6px 0 #e8e4eb}.probability-art span{display:none}.eyebrow{font-size:8px;letter-spacing:1.4px}.section-title{gap:15px}.section-title h2,.method h2{font-size:24px}.section-title>p{font-size:9px;white-space:nowrap}.lessons{grid-template-columns:1fr;gap:11px}.lesson{min-height:185px;padding:22px}.lesson h2{font-size:21px;margin-top:20px}.lesson p{font-size:11px}.method{grid-template-columns:1fr;gap:22px;padding:45px 0}.method p{font-size:12px}footer{font-size:8px;gap:20px;line-height:1.8}} +@media(prefers-reduced-motion:reduce){*{transition:none!important}} diff --git a/apps/site/learn/token-choice/LESSON.md b/apps/site/learn/token-choice/LESSON.md new file mode 100644 index 0000000..b03fcc3 --- /dev/null +++ b/apps/site/learn/token-choice/LESSON.md @@ -0,0 +1,69 @@ +# 分数如何变成下一个 Token + +这是一段从候选分数出发的单步采样解释。同一组候选依次经历温度缩放、指数权重、归一化与随机数定位;词卡和颜色贯穿全过程。数值按数学定义实时计算,移动与形变用于引导注意力。 + +## 可复用的用户请求 + +“用精细、克制的彩色空间图解,解释分数如何经过温度和 Softmax 变成下一词的选择。每次只讲一步,气泡跟随正在变化的对象,播放时收起配置;能暂停检查计算并调温度比较。” + +## 50 秒的教学顺序 + +| 时间 | 当前问题 | 可观察证据 | +| --- | --- | --- | +| 0–8 秒 | 模型给出的分数是什么? | 四个候选和分数 `[3, 2, 1, 0]`,没有百分号 | +| 8–18 秒 | 温度改变什么? | 原始分数保留;`z/T` 和分差随 T 变化 | +| 18–27 秒 | 怎样得到正权重? | `exp(z/T)`,权重总和尚未归一化 | +| 27–36 秒 | 怎样得到概率? | 每项除以同一个总和;概率带合计 100% | +| 36–45 秒 | 如何按概率选择? | 固定种子生成的均匀随机数在 `[0,1)` 概率带上定位 | +| 45–50 秒 | 这次选择了什么? | 浮起的词卡与所落区间一致;不继续生成下一词 | + +默认 T=0.8,`mat` 概率约为 71.8%。固定随机数 `u≈0.621868`,本次会选择 `mat`;将 T 改成 2,同一随机数会落入 `floor` 区间。这个对照能够隔离“分布改变”的效果。点击再次抽样时才更换抽样序号,温度与输入保持不变。 + +## 图形与数学的边界 + +四个候选是本页完整的人工教学词表,分数不是任何真实语言模型的预测结果。真实 tokenizer 可能把一个词拆成多个 token,本页词卡不承担分词教学。本节也不讲 logits 是如何由神经网络算出来的。 + +所有阶段都保持候选身份与颜色一致。厚度、遮挡、倒角与微阴影只形成层次;概率必须由前视长度编码,不能用透视造成数值大小错觉。指数权重若使用自动量程,应显示量程或“相对高度”提示。概率带中的区域不可为了放文字而人为拉宽。 + +温度先缩放 logits,然后 Softmax 得到概率;原始 logits 不会被滑块改写。指数权重没有概率含义,必须完成归一化之后才显示百分比。提高温度使整体分布更平缓,不承诺每个较低排名候选概率都增加,也不承诺回答更正确。 + +累积概率区间采用左闭右开约定,边界上的 u 选择其右侧候选。随机数先确定,游标运动只展示这个数的位置。固定种子让暂停、倒退与重播可复现。多个抽样保持固定上下文;这是条件分布的重复实验,不是自回归续写。 + +## 实现与验证 + +纯模型位于 `model.mjs`,固定数据为只读常量,返回状态独立可修改。`compute(T)` 返回缩放分数、权重、权重和、概率、CDF 与熵;`sample(time, inputs)` 再加入章节、进度和采样结果。框架或渲染器只消费这些数据。 + +运行数学验证: + +```sh +node apps/site/learn/token-choice/model.test.mjs +``` + +已经通过的计算检查包括 Python 独立黄金值、概率比恒等式、正权重、归一化、CDF 半开边界、温度与熵关系、两万次固定种子采样频率、43.2 秒的选择时点、正反向跳转和返回值隔离。 + +实际浏览器中的播放、布局、焦点、响应式和完成恢复由场景集成验证记录补充;纯模型测试不证明视觉或学习效果。 + +## 来源与范围 + +- [PyTorch Softmax 文档](https://docs.pytorch.org/docs/2.14/generated/torch.nn.Softmax.html):指数归一化定义。 +- [Hugging Face TemperatureLogitsWarper](https://huggingface.co/docs/transformers/en/internal/generation_utils#transformers.TemperatureLogitsWarper):温度采样分布的调节。 +- [Anatomy](https://github.com/wheresryan22/anatomy):用户已确认的视觉工艺参考。本场景重新设计数学对象,不沿用轮盘作为主体。 + +此教学样例只在受限值域内直接计算 `exp(z/T)`,最大指数输入为 7.5,因此有限且安全。用于任意 logits 的通用实现应减去输入最大值,再计算指数;此平移不改变最终概率。 + +## 本地完成记录(2026-10-25) + +- 新绘制 SVG 数学对象;主体未复用 Anatomy 轮盘。沿用前版已认可的浅色阶、细线、倒角与候选配色。 +- 完整50秒播放后进度为50/50,设置恢复。中途暂停在29.45秒,恢复后保持原位置继续。 +- 桌面浏览器与390×844手机布局:图形、气泡和公式无交叠,概率标签通过真实区间中心的细线关联。 +- 相同u=0.621868…,T=.8选mat,T=2选floor;浏览器显示与模型测试一致。 +- 指数阶段明细:mat的原分数3,除以.8得到3.75,指数42.52,权重总和59.19,概率71.8%;浏览器核对通过。 +- 通用Skill的旧脚手架检查器要求runtime/viewer.mjs、runtime/playback.mjs、templates/catalog.mjs,本场景使用网站原生SVG适配层,因此该结构检查不适用且未通过;没有添加空文件凑通过。替代的evals/verify-token-choice.mjs检查实际交付依赖、完整spec、数值量程和概率区间。 +- 未验证真实手机硬件、屏幕阅读器实机、系统减少动态效果开关或不同浏览器引擎;减少动态效果逻辑已实现。未运行真实LLM,无语音。 +- 构建:npm run build;独立文件:node scripts/bundle-token-choice.mjs。完整检查:npm test。 + +## 2026-10-26 发布教学评审 + +通过开发者教学适配评审,尚未进行真实学习者效果研究。加入完成后的“检验理解”:先预测固定分数和u不变、T从.8到2时的候选,再得到累计概率区间的因果反馈,最后回到同一组参数的图形验证。错误答案不是只显示叉号;说明mat上界.4551、floor上界.7311,以及u=.6219为何属于floor。 + +本轮实查:课程入口可进入该场景;桌面端错误/正确选择与回到图形后T=2、floor27.6%一致;390×844手机布局下学习题完整可读。`npm run check`覆盖既有机械模板约束、新场景的独立模型测试与课程清单检查。真实设备与屏幕阅读器仍未实查。 diff --git a/apps/site/learn/token-choice/SCENE_SPEC.json b/apps/site/learn/token-choice/SCENE_SPEC.json new file mode 100644 index 0000000..fb5cf3b --- /dev/null +++ b/apps/site/learn/token-choice/SCENE_SPEC.json @@ -0,0 +1,79 @@ +{ + "id": "token-choice", + "question": "模型已经给出候选分数后,温度、Softmax 和随机采样怎样得到下一个 Token?", + "audience": "希望逐步理解 AI 原理、没有概率论背景的读者", + "context": "the cat sat on the …", + "objects": [ + {"id":"scores","meaning":"四个教学候选的原始 logits,保留候选身份与颜色","units":"无量纲分数"}, + {"id":"temperature","meaning":"把每个原始分数除以同一温度 T","units":"无量纲"}, + {"id":"weights","meaning":"exp(z/T) 给出的正权重,尚未归一化","units":"无量纲权重"}, + {"id":"probability","meaning":"每个权重除以全部权重之和,概率带宽度正比于概率","units":"概率,合计 1 或 100%"}, + {"id":"sampling","meaning":"0 到 1 的均匀随机数 u 在累积概率带上的位置","units":"无量纲,0 ≤ u < 1"}, + {"id":"result","meaning":"u 所在区间对应的候选词卡","units":"候选索引"} + ], + "relationships": [ + "tokens = [mat, floor, bed, sofa];logits = [3, 2, 1, 0]", + "scaled_i = logits_i / T", + "weight_i = exp(scaled_i)", + "p_i = weight_i / sum_j(weight_j)", + "cdf_0 = 0;cdf_(i+1) = sum_(j≤i)(p_j);cdf_4 = 1", + "selected = i,当 cdf_i ≤ u < cdf_(i+1)", + "固定输入下调节温度不改变原始 logits;固定 seed 和 draw 不改变随机数 u" + ], + "parameters": [ + {"id":"temperature","min":0.4,"max":2,"default":0.8,"units":"无量纲"}, + {"id":"seed","default":22,"units":"整数伪随机种子"}, + {"id":"draw","default":0,"min":0,"units":"非负整数抽样序号"} + ], + "outputs": ["scaled","weights","totalWeight","p","cdf","entropy","u","selected"], + "durationSeconds": 50, + "chapters": [ + {"id":"scores","start":0,"end":8,"message":"分数不是概率"}, + {"id":"temperature","start":8,"end":18,"message":"除以温度,缩放分数差异"}, + {"id":"weights","start":18,"end":27,"message":"取指数,把分差变成权重之比"}, + {"id":"probability","start":27,"end":36,"message":"归一化得到总和为 1 的概率"}, + {"id":"sampling","start":36,"end":45,"message":"随机数在累积概率带上定位"}, + {"id":"result","start":45,"end":50,"message":"显示这一次抽到的候选"} + ], + "timeline": { + "sampler":"sample(time, {temperature, seed, draw})", + "selectionVisibleAtSeconds":43.2, + "numericState":"所有数值从同一组输入计算,几何变形插值不改变它们的数学定义", + "playback":"实际 elapsed time,支持暂停、继续、反向拖动、重播与结束恢复;隐藏标签页暂停", + "narration":"每次仅一条气泡跟随当前数学对象;颜色在所有阶段保持候选身份一致" + }, + "invariants": [ + "四个概率均为正且总和约为 1", + "全部 cdf 区间有序、无重叠,覆盖 [0,1)", + "log(p_i / p_j) = (z_i - z_j) / T", + "在这组不相等的 logits 上,提高 T 提高熵并降低最大概率", + "seed、draw、temperature 和 time 相同时,状态完全可重现", + "43.2 秒前 selected 为 null;之后与 inverse CDF 结果一致", + "对返回数组的修改不会污染后续 sample 或原始教学数据" + ], + "simplifications": [ + "四个词构成完整的教学词表;不是从真实模型词表截取的前四项,也不是线上模型输出", + "本节从已有 logits 开始,不讲 tokenizer、Embedding、Attention 或语言模型怎样产生这些分数", + "词卡用于展示教学候选,不声称它们在任何具体 tokenizer 中恰好各占一个 token", + "固定输入的单步采样,不把结果接到上下文进行下一轮自回归", + "不使用 top-k、top-p、重复惩罚或其他采样过滤器", + "受限的 logits 与 T 使直接计算 exp(z/T) 安全;通用实现需做最大值平移以防溢出", + "数值刻度必须明确:权重柱如自适应缩放,应标明最大值或相对高度;概率宽度始终按真实概率", + "游标移动用于揭示一个预先确定的伪随机数,不通过动画速度或物理运动决定选择", + "立体厚度与阴影只用于层次,数值长度在正视平面编码", + "高温不会保证每个低排名候选的概率都增大,也不意味着答案更正确" + ], + "sources": [ + {"url":"https://docs.pytorch.org/docs/2.14/generated/torch.nn.Softmax.html","supports":"指数归一化公式与输出概率和为 1"}, + {"url":"https://huggingface.co/docs/transformers/en/internal/generation_utils#transformers.TemperatureLogitsWarper","supports":"正温度对采样分布的调节;温度与随机采样的关系"}, + {"url":"https://github.com/wheresryan22/anatomy","supports":"已获用户认可的细线、等轴层次与克制配色工艺参考;本场景不复用原轮盘几何"} + ], + "acceptance": [ + "独立 Python 黄金值与 JavaScript 数学结果一致", + "概率归一化、指数权重、CDF 边界、温度熵关系、固定种子采样与反向跳转测试通过", + "在桌面和手机实际检查当前讲解对象、箭头和数值均清晰,无重叠或裁切", + "播放、暂停、继续、拖动、参数改变、再次抽样、重播及完成后的配置恢复均可观察验证", + "保留单一焦点;未讲到的计算与参数不会同时占据主视觉" + ], + "untested": ["真实手机硬件性能与所有浏览器组合", "独立的学习效果实验"] +} diff --git a/apps/site/learn/token-choice/app.mjs b/apps/site/learn/token-choice/app.mjs new file mode 100644 index 0000000..cbf0907 --- /dev/null +++ b/apps/site/learn/token-choice/app.mjs @@ -0,0 +1,44 @@ +import {TOKENS,ZH,COLORS,STARTS,DURATION,sample,narration} from './model.mjs'; +import {createStage} from './stage.mjs'; +const $=selector=>document.querySelector(selector),$$=selector=>[...document.querySelectorAll(selector)]; +const app=$('#app'),box=$('.exhibit'),art=$('#artwork'),bubble=$('#callout'),stage=createStage(art); +const motion=matchMedia('(prefers-reduced-motion: reduce)'),phaseNames=['候选分数','温度缩放','指数权重','概率归一化','随机采样','得到结果']; +let time=0,temperature=.8,draw=0,playing=false,raf=0,lastNow=0,lastCopy='',lastPhase=-1,disposed=false; +const settings=()=>({temperature,seed:22,draw}); +const percent=x=>x>0&&x<.001?'<0.1':(x*100).toFixed(1); +const number=x=>x.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}); +function focus(on){app.classList.toggle('is-playing',on);$$('.secondary').forEach(el=>{el.inert=on;el.setAttribute('aria-hidden',String(on));});} +function anchor(id){const outerRect=box.getBoundingClientRect(),bubbleRect=bubble.getBoundingClientRect();const top=Math.max(185,bubbleRect.bottom-outerRect.top+13);art.style.top=top+'px';const point=stage.projectAnchor(id);if(!point||!point.visible)return;const outer=box.getBoundingClientRect(),inner=art.getBoundingClientRect(),x=inner.left-outer.left+point.x*inner.width,y=inner.top-outer.top+point.y*inner.height;const half=bubble.offsetWidth/2,cx=Math.max(half+17,Math.min(outer.width-half-17,x));bubble.style.left=cx+'px';const sy=bubble.getBoundingClientRect().bottom-outer.top;$('#leader').setAttribute('viewBox',`0 0 ${outer.width} ${outer.height}`);$('#leader-line').setAttribute('d',`M${cx} ${sy} C${cx} ${sy+20},${x} ${Math.max(sy+20,y-24)},${x} ${y-8}`);$('#leader-dot').setAttribute('cx',x);$('#leader-dot').setAttribute('cy',y);} +function render(){if(disposed)return;const frame=sample(time,settings());frame.reducedMotion=motion.matches;stage.render(frame);const copy=narration(frame),key=JSON.stringify(copy);if(key!==lastCopy){lastCopy=key;$('#copy-eyebrow').textContent=copy.eyebrow;$('#copy-title').textContent=copy.title;$('#copy-body').textContent=copy.body;} + $('#phase-label').textContent=`0${frame.phase+1} / 06 · ${phaseNames[frame.phase]}`;$('#live-temperature').textContent=`T = ${temperature.toFixed(2)}`; + $('#temperature').value=temperature;$('#temperature-value').textContent=temperature.toFixed(2);$('#temperature').style.setProperty('--fill',((temperature-.4)/1.6*100)+'%'); + $$('[data-temperature]').forEach(button=>button.setAttribute('aria-pressed',String(Math.abs(+button.dataset.temperature-temperature)<.001))); + $('#timeline').value=time;$('#timeline').style.setProperty('--fill',(time/DURATION*100)+'%');$('#timeline').setAttribute('aria-valuetext',`${time.toFixed(1)} 秒,共 ${DURATION} 秒,${phaseNames[frame.phase]}`);$('#time').textContent=`00:${String(Math.floor(time)).padStart(2,'0')} / 00:50`; + $('#play-label').textContent=playing?'暂停':time>=DURATION?'再看一遍':time>0?'继续讲解':'播放讲解';$('#play-icon').textContent=playing?'Ⅱ':'▶';$('#play').setAttribute('aria-label',playing?'暂停讲解':time>=DURATION?'重新播放讲解':time>0?'继续讲解':'播放讲解');$('#play').setAttribute('aria-pressed',String(playing)); + $('#back').disabled=time<=0;$('#next').disabled=time>=DURATION;$('#play-note').textContent=playing?'配置已收起 · 空格暂停':'六个步骤 · 一条因果关系'; + $('#stage-note').textContent=frame.phase===5?`本次 u = ${frame.u.toFixed(4)} · 输入保持不变`:frame.phase===4?`u = ${frame.u.toFixed(4)} · 在 [0, 1) 上均匀取值`:frame.phase===2?'权重使用独立量程 · 当前总和 '+number(frame.totalWeight):'4 个完整教学候选 · 原始分数固定'; + $('#draw-again').hidden=frame.phase!==5;$('#challenge').hidden=frame.phase!==5;$('#detail').hidden=frame.phase===5; + if(lastPhase!==frame.phase){lastPhase=frame.phase;box.dataset.phase=frame.phase;} + anchor(frame.focus); +} +function advance(now){if(playing){time=Math.min(DURATION,time+Math.max(0,now-lastNow)/1000);lastNow=now;}} +function stop({capture=true}={}){if(playing&&capture)advance(performance.now());playing=false;cancelAnimationFrame(raf);raf=0;focus(false);render();} +function tick(now){if(!playing||disposed)return;advance(now);render();if(time>=DURATION){stop({capture:false});return;}raf=requestAnimationFrame(tick);} +function play(){if(playing){stop();return;}if(time>=DURATION)time=0;$('#play').focus({preventScroll:true});playing=true;focus(true);lastNow=performance.now();render();raf=requestAnimationFrame(tick);} +function seek(value){stop();time=Math.max(0,Math.min(DURATION,value));render();} +function changeTemperature(value){stop();temperature=Math.max(.4,Math.min(2,value));render();} +function drawAgain(){stop();draw++;time=STARTS[4];play();} +$('#play').onclick=play;$('#timeline').oninput=e=>seek(+e.target.value);$('#temperature').oninput=e=>changeTemperature(+e.target.value);$$('[data-temperature]').forEach(button=>button.onclick=()=>changeTemperature(+button.dataset.temperature)); +$('#back').onclick=()=>seek(STARTS.filter(value=>valueseek(STARTS.find(value=>value>time+.1)??DURATION); +$('#reset').onclick=()=>{stop();time=0;temperature=.8;draw=0;box.classList.remove('is-detailed');$('#detail').setAttribute('aria-pressed','false');$('#detail').textContent='放大细节 ⤢';render();}; +$('#experiment').onclick=()=>{seek(STARTS[3]+7);$('#temperature').focus({preventScroll:true});};$('#new-draw').onclick=drawAgain;$('#draw-again').onclick=drawAgain; +$('#detail').onclick=()=>{const on=box.classList.toggle('is-detailed');$('#detail').setAttribute('aria-pressed',String(on));$('#detail').textContent=on?'恢复全景 ↙':'放大细节 ⤢';}; +$('#calculation').onclick=()=>{stop();const frame=sample(time,settings());$('#calculation-summary').textContent=`温度 T = ${temperature.toFixed(2)}。每一行始终对应同一个候选。`;$('#formula').textContent=narration(frame).formula; + $('#calculation-rows').innerHTML=TOKENS.map((token,i)=>`${token}${number(frame.logits[i])}${number(frame.scaled[i])}${number(frame.weights[i])}${percent(frame.p[i])}%`).join('');$('#calculation-total').textContent=`权重总和 ${number(frame.totalWeight)} → 概率总和 100%`;$('#calculation-dialog').showModal();}; +$('#challenge').onclick=()=>{stop();$('#challenge-feedback').textContent='';$('#challenge-try').hidden=true;$$('[data-answer]').forEach(button=>button.removeAttribute('aria-pressed'));$('#challenge-dialog').showModal();}; +$$('[data-answer]').forEach(button=>button.onclick=()=>{const frame=sample(DURATION,{temperature:2,seed:22,draw:0}),correct=+button.dataset.answer===frame.selected;$$('[data-answer]').forEach(option=>option.setAttribute('aria-pressed',String(option===button)));$('#challenge-feedback').textContent=`${correct?'预测正确。':'这次会选中 '+TOKENS[frame.selected]+'。'}T = 2 时,mat 区间结束于 ${frame.cdf[1].toFixed(4)},floor 区间结束于 ${frame.cdf[2].toFixed(4)}。u = ${frame.u.toFixed(4)} 落在 floor 区间。原始分数和随机数都没有改变。`;$('#challenge-try').hidden=false;}); +$('#challenge-try').onclick=()=>{$('#challenge-dialog').close();temperature=2;draw=0;time=DURATION;render();};$('#close-challenge').onclick=()=>$('#challenge-dialog').close(); +$('#about').onclick=()=>{stop();$('#info').showModal();};$('#close-about').onclick=()=>$('#info').close();$('#close-calculation').onclick=()=>$('#calculation-dialog').close(); +document.addEventListener('visibilitychange',()=>{if(document.hidden&&playing)stop();});document.addEventListener('keydown',e=>{if($('#info').open||$('#calculation-dialog').open||$('#challenge-dialog').open)return;if(e.key==='Escape'){stop();return;}if(e.code==='Space'&&!['INPUT','BUTTON','A'].includes(e.target.tagName)){e.preventDefault();play();}}); +motion.addEventListener('change',render);const resize=new ResizeObserver(()=>{stage.resize();anchor(sample(time,settings()).focus);});resize.observe(art);box.addEventListener('transitionend',()=>anchor(sample(time,settings()).focus)); +window.addEventListener('pagehide',event=>{if(event.persisted){stop();return;}disposed=true;playing=false;cancelAnimationFrame(raf);stage.dispose();resize.disconnect();motion.removeEventListener('change',render);});render(); diff --git a/apps/site/learn/token-choice/index.html b/apps/site/learn/token-choice/index.html new file mode 100644 index 0000000..dc1501d --- /dev/null +++ b/apps/site/learn/token-choice/index.html @@ -0,0 +1,13 @@ + +一个词,是怎样被选出来的? · Explain3D +
+
e· Explain3DAI 原理课堂 / 05
+

THE MAKING OF A TOKEN

一个词,是怎样被选出来的?

从分数到概率,再到一次选择。

+
+
01 / 06 · 候选分数T = 0.80
固定输入 · 教学示例

the cat sat on the …

4 个教学候选 · 原始分数固定
+
六个步骤 · 一条因果关系00:00 / 00:50
+
Explain3D · 数学图解实验教学数据 · 实时计算 · 可暂停检查
+
+

FOLLOW THE NUMBERS

跟着同一个词,走完计算。

候选原始分数 zz / T指数权重概率

这 4 个候选构成完整的教学词表;分数由示例设定,未调用真实语言模型。每个候选按一个教学 Token 处理。现实模型的词表与分词方式更复杂。

+

A SMALL, COMPLETE EXPLANATION

看清一次选择是怎么发生的。

这节课从模型已经给出候选分数的位置开始,演示温度缩放、指数运算、概率归一化与随机采样。图形只表达这些数学关系。

输入固定为 the cat sat on the …,4 个教学候选为 mat、floor、bed、sofa,原始分数为 3、2、1、0。这里只完成一次选择,不把结果接回上下文继续生成。

温度改变分布的集中程度;较高温度不意味着答案更正确。抽样使用可复现的均匀随机数,游标移动用来展示已确定的落点。重复抽样也可能选中同一个词。

本版保留上一版参考 Anatomy 的细线、浅色阶与轻立体风格,重新绘制数学图形,没有复用轮盘主体。查看视觉参考 ↗

浏览器内实时计算 · SVG 矢量绘制 · 支持减少动态效果 · 本版讲解为同步文字气泡,无语音

+

PREDICT · TRY · EXPLAIN

先预测,再看发生了什么。

固定分数 [3, 2, 1, 0] 和随机数 u = 0.6219。T = 0.8 时选中了 mat。现在把温度提高到 2.0,这次会选中哪个候选?

检验的是分布与随机数的关系;一次选择不会因为温度高就自动更正确。

diff --git a/apps/site/learn/token-choice/model.mjs b/apps/site/learn/token-choice/model.mjs new file mode 100644 index 0000000..1922a07 --- /dev/null +++ b/apps/site/learn/token-choice/model.mjs @@ -0,0 +1,60 @@ +// A complete four-candidate teaching vocabulary, not live language-model output. +export const TOKENS=Object.freeze(['mat','floor','bed','sofa']); +export const ZH=Object.freeze(['地垫','地板','床','沙发']); +export const LOGITS=Object.freeze([3,2,1,0]); +export const COLORS=Object.freeze(['#6b8fc6','#9b84ca','#73a995','#c58b73']); +export const STARTS=Object.freeze([0,8,18,27,36,45]); +export const DURATION=50; +const FOCUS=Object.freeze(['scores','temperature','weights','probability','sampling','result']); +const sum=values=>values.reduce((total,value)=>total+value,0); + +export function compute(temperature=.8){ + if(!Number.isFinite(temperature)||temperature<.4||temperature>2)throw new RangeError('Temperature must be between 0.4 and 2.'); + const logits=[...LOGITS],scaled=logits.map(z=>z/temperature); + // These bounded toy scores make the literal exp(z/T) safe and inspectable. + // General-purpose softmax should subtract its largest input before exp. + const weights=scaled.map(z=>Math.exp(z)),totalWeight=sum(weights),p=weights.map(w=>w/totalWeight); + const cdf=[0];for(const probability of p)cdf.push(cdf.at(-1)+probability);cdf[cdf.length-1]=1; + return {temperature,logits,scaled,weights,totalWeight,p,cdf,entropy:-sum(p.map(value=>value*Math.log2(value)))}; +} + +export function choose(u,p){ + if(!Number.isFinite(u)||u<0||u>=1)throw new RangeError('Uniform sample must be in [0, 1).'); + if(!Array.isArray(p)||!p.length||p.some(value=>!Number.isFinite(value)||value<0)||Math.abs(sum(p)-1)>1e-10)throw new RangeError('Probabilities must be non-negative and sum to one.'); + let cumulative=0; + for(let i=0;i0&&uvalue>0); +} + +export function random(seed,index=0){ + if(!Number.isSafeInteger(seed)||!Number.isSafeInteger(index)||index<0)throw new RangeError('A finite integer seed and non-negative draw index are required.'); + let a=seed+Math.imul(index+1,1831565813)|0,t=Math.imul(a^a>>>15,1|a); + t=t+Math.imul(t^t>>>7,61|t)^t; + return ((t^t>>>14)>>>0)/4294967296; +} + +export function sample(time,{temperature=.8,seed=22,draw=0}={}){ + if(!Number.isFinite(time))throw new RangeError('Time must be finite.'); + const t=Math.max(0,Math.min(DURATION,time)),phase=STARTS.findLastIndex(start=>start<=t); + const end=STARTS[phase+1]??DURATION,progress=Math.min(1,(t-STARTS[phase])/(end-STARTS[phase])); + const data=compute(temperature),u=random(seed,draw); + const selected=phase>4||phase===4&&progress>=.8?choose(u,data.p):null; + return {time:t,phase,progress,...data,u,selected,focus:FOCUS[phase]}; +} + +const number=value=>value.toFixed(2).replace(/\.00$/,''); +const percent=value=>value>0&&value<.001?'<0.1':(value*100).toFixed(1); + +export function narration(frame){ + const {phase,temperature,scaled,weights,totalWeight,p,u,selected}=frame; + const t=number(temperature); + return [ + {eyebrow:'看这里 · 候选分数',title:'先有分数,还没有概率。',body:'同一个输入,四个候选各有一个分数。数值越大,倾向越强;它们还不是百分比。',formula:'教学分数 z = [3, 2, 1, 0]'}, + {eyebrow:'看这里 · 温度缩放',title:'除以温度,改变分数之间的差距。',body:`原始分数保持不变。T = ${t} 时,相邻候选的分差从 1 变为 ${number(1/temperature)}。`,formula:`3 ÷ ${t} = ${number(scaled[0])};2 ÷ ${t} = ${number(scaled[1])}`}, + {eyebrow:'看这里 · 指数权重',title:'取指数,让分差变成权重之比。',body:'每个数经过指数运算,得到一个正的权重。高分候选获得更多权重,此时总和还不是 1。',formula:`exp(${number(scaled[0])}) = ${number(weights[0])};权重总和 = ${number(totalWeight)}`}, + {eyebrow:'看这里 · 概率分布',title:frame.progress<.73?'每个权重除以总和,得到概率。':'除以总和,所有机会合成 100%。',body:frame.progress<.73?'四个概率的总和为 1。现在把这组概率重新排成一条带,候选与颜色保持不变。':'每个权重都除以同一个总和。彩色区间的宽度,就表示这个候选被抽中的概率。',formula:`P(mat) = ${number(weights[0])} ÷ ${number(totalWeight)} = ${percent(p[0])}%`}, + {eyebrow:'看这里 · 随机数游标',title:selected===null?'一个随机数,落入一个概率区间。':`游标落定,选中了 ${TOKENS[selected]}。`,body:'先生成 0 到 1 之间的均匀随机数。游标展示它的位置,落入哪个区间,就选哪个候选。',formula:`u = ${u.toFixed(4)};选择满足 Cᵢ ≤ u < Cᵢ₊₁ 的候选`}, + {eyebrow:'看这里 · 这次的选择',title:`这次得到 ${TOKENS[selected]??'一个候选'}。`,body:`${selected===null?'':ZH[selected]+' · 概率 '+percent(p[selected])+'%。'}同一个输入再抽一次,可能选到别的候选。`,formula:'固定上下文的一次采样 · 本节没有继续生成后续 Token'} + ][phase]; +} diff --git a/apps/site/learn/token-choice/model.test.mjs b/apps/site/learn/token-choice/model.test.mjs new file mode 100644 index 0000000..47c0f6f --- /dev/null +++ b/apps/site/learn/token-choice/model.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import {TOKENS,ZH,LOGITS,COLORS,STARTS,DURATION,compute,choose,random,sample,narration} from './model.mjs'; + +const close=(actual,expected,tolerance=1e-11)=>assert.ok(Math.abs(actual-expected)<=tolerance,`${actual} != ${expected}`); +const sum=values=>values.reduce((a,b)=>a+b,0); + +assert.deepEqual(LOGITS,[3,2,1,0]); +assert.equal(TOKENS.length,4);assert.equal(ZH.length,4);assert.equal(COLORS.length,4); +assert.equal(DURATION,50);assert.deepEqual(STARTS,[0,8,18,27,36,45]); +for(const t of [-1,0,.399,2.001,NaN,Infinity])assert.throws(()=>compute(t),RangeError); + +// These reference values were generated independently with Python's math.exp +// and math.log2, not obtained by calling compute(). +const golden=[ + [.4,[.9179566765447413,.07535047253097381,.006185143434015109,.0005077074902697469],.44538129291215134,1808.0424144560632], + [.8,[.7183353083752138,.20580651160354263,.05896455279947734,.016893627221766207],1.1524816974330627,42.52108200006278], + [1,[.6439142598879722,.23688281808991013,.08714431874203256,.03205860328008499],1.3670068789863308,20.085536923187668], + [1.6,[.5062980458803836,.27100181529430745,.14505681878567053,.07764332003963831],1.6979195411088617,6.5208191203301125], + [2,[.45505423392341127,.27600434470659363,.16740509727844333,.1015363240915518],1.7962280773638981,4.4816890703380645] +]; +for(const [t,p,entropy,topWeight]of golden){ + const state=compute(t);p.forEach((value,i)=>close(state.p[i],value)); + close(state.entropy,entropy);close(state.weights[0],topWeight); + assert.deepEqual(state.logits,[3,2,1,0]);close(state.scaled[0],3/t); + assert.equal(state.weights.at(-1),1,'A zero logit has exp(0)=1, not zero weight.'); +} + +// Independent log-ratio identities expose wrong sign, temperature or normalization. +let previousEntropy=-1,previousLargest=Infinity; +for(let step=0;step<=32;step++){ + const t=.4+step*.05,state=compute(t); + assert.ok(state.scaled.every(Number.isFinite)); + assert.ok(state.weights.every(x=>Number.isFinite(x)&&x>0)); + assert.ok(state.p.every(x=>x>0&&x<1));close(sum(state.p),1); + close(sum(state.weights),state.totalWeight); + close(Math.log(state.p[0]/state.p[1]),1/t); + close(Math.log(state.p[0]/state.p[3]),3/t); + assert.ok(state.entropy>previousEntropy);assert.ok(state.p[0]0)assert.equal(choose(state.cdf[i],state.p),i,'CDF boundaries belong to the interval on their right.'); + } +} +const stable=LOGITS.map(z=>Math.exp((z-3)/.8)),stableSum=sum(stable); +stable.forEach((w,i)=>close(w/stableSum,compute(.8).p[i])); + +// Half-open CDF intervals, including empty intervals; u=1 is excluded. +const fixture=[0,.2,0,.3,.5]; +assert.equal(choose(0,fixture),1);assert.equal(choose(.2-Number.EPSILON,fixture),1); +assert.equal(choose(.2,fixture),3);assert.equal(choose(.5,fixture),4); +assert.equal(choose(1-Number.EPSILON,fixture),4); +for(const u of [-1,1,NaN,Infinity])assert.throws(()=>choose(u,[.5,.5]),RangeError); +for(const p of [[],[0,0],[.2,.3],[-.2,1.2],[NaN,1]])assert.throws(()=>choose(.5,p),RangeError); + +assert.deepEqual([0,1,2,3,4].map(draw=>random(22,draw)),[ + .6218683323822916,.23673223098739982,.22517993114888668,.20531759224832058,.3645721802022308 +]); +assert.throws(()=>random(22,-1),RangeError);assert.throws(()=>random(NaN),RangeError); +const p=compute(1.6).p,counts=[0,0,0,0],draws=20000; +for(let draw=0;draw=0&&u<1);assert.equal(u,random(22,draw));counts[choose(u,p)]++; +} +p.forEach((mass,i)=>assert.ok(Math.abs(counts[i]/draws-mass)<5*Math.sqrt(mass*(1-mass)/draws)+2/draws)); +assert.equal(choose(random(22),compute(.4).p),0); +assert.equal(choose(random(22),compute(2).p),1,'With the same u, changing temperature can change the selected interval.'); + +for(const t of [NaN,Infinity,-Infinity])assert.throws(()=>sample(t),RangeError); +assert.deepEqual(sample(-1),sample(0));assert.deepEqual(sample(100),sample(50)); +STARTS.forEach((time,phase)=>{assert.equal(sample(time).phase,phase);assert.equal(sample(time).progress,0);}); +assert.equal(sample(50).progress,1); +for(const temperature of [.4,.8,1.6,2]){ + const options={temperature,seed:22,draw:2},numerical=compute(temperature); + for(const time of [0,7.999,8,17.999,18,26.999,27,35.999,36,43.199999,43.2,44.999,45,50]){ + const frame=sample(time,options);assert.deepEqual(frame.p,numerical.p); + assert.deepEqual(frame.logits,LOGITS);assert.equal(frame.u,random(22,2)); + assert.ok(frame.progress>=0&&frame.progress<=1); + assert.equal(frame.selected,time<43.2?null:choose(frame.u,frame.p)); + const copy=narration(frame);assert.ok(copy.title&©.body&©.formula&©.eyebrow); + assert.ok(!JSON.stringify(copy).includes('undefined')); + } + for(const boundary of STARTS.slice(1)){ + const before=sample(boundary-1e-6,options),after=sample(boundary,options); + assert.deepEqual(before.scaled,after.scaled);assert.deepEqual(before.weights,after.weights); + assert.deepEqual(before.p,after.p);assert.equal(before.u,after.u); + } +} +const times=[0,5,8,12,18,22,27,31,36,40,43.2,45,50]; +const frames=times.map(time=>sample(time,{temperature:1.3,seed:7,draw:4})); +for(let i=times.length-1;i>=0;i--)assert.deepEqual(sample(times[i],{temperature:1.3,seed:7,draw:4}),frames[i]); +const damaged=sample(50);damaged.logits[0]=999;damaged.p.fill(0);damaged.cdf.fill(0);damaged.weights.fill(0); +assert.deepEqual(sample(50).logits,[3,2,1,0]);close(sum(sample(50).p),1);assert.equal(sample(50).cdf.at(-1),1); +assert.equal(sample(0).selected,null);assert.equal(sample(50).selected,0); +assert.throws(()=>{LOGITS[0]=999;},TypeError); + +console.log('Token-choice checks passed: independent golden values, exponent/normalization identities, temperature entropy, CDF boundaries, seeded sampling, phase boundaries and deterministic backward seek.'); diff --git a/apps/site/learn/token-choice/stage.mjs b/apps/site/learn/token-choice/stage.mjs new file mode 100644 index 0000000..e46d8a5 --- /dev/null +++ b/apps/site/learn/token-choice/stage.mjs @@ -0,0 +1,163 @@ +/** + * A mathematical scene for Explain3D. Original vector geometry, inspired by + * Anatomy's disciplined strokes and surface hierarchy. No wheel geometry reused. + * Numerical lengths are front-face lengths; shallow extrusion only conveys depth. + */ +const NS='http://www.w3.org/2000/svg'; +const TOKENS=['mat','floor','bed','sofa']; +const CHINESE=['地垫','地板','床','沙发']; +const COLORS=['#6b8fc6','#9b84ca','#73a995','#c58b73']; +const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x)); +const ease=x=>{x=clamp(x);return x*x*(3-2*x);}; +const mix=(a,b,t)=>a+(b-a)*t; +const f=x=>Math.round(x*100)/100; +const value=x=>x>=1000?x.toFixed(0):x>=100?x.toFixed(1):x>=10?x.toFixed(2):x.toFixed(3).replace(/0+$/,'').replace(/\.$/,''); +const percent=p=>p===0?'0%':p===1?'100%':p<.001?'<0.1%':p>.999?'>99.9%':`${(p*100).toFixed(1)}%`; +const css=` + :host{display:block;width:100%;height:100%;min-height:0;color:#334056;font-family:Inter,"PingFang SC","Microsoft YaHei",system-ui,sans-serif} + svg{display:block;width:100%;height:100%;overflow:visible;contain:layout style} + path,line,rect,circle{vector-effect:non-scaling-stroke} + text{user-select:none;paint-order:stroke;stroke:none;text-rendering:geometricPrecision} + .axis{font:11px ui-monospace,SFMono-Regular,Consolas,monospace;fill:#8290a1} + .token{font:25px Georgia,"Times New Roman",serif;fill:#3a4657;letter-spacing:.1px} + .translation{font-size:11px;fill:#7e8795} + .number{font:18px ui-monospace,SFMono-Regular,Consolas,monospace;fill:#485872;font-variant-numeric:tabular-nums} + .note{font-size:12px;fill:#778292;letter-spacing:.15px} + .formula{font:17px ui-monospace,SFMono-Regular,Consolas,monospace;fill:#5a6680;font-variant-numeric:tabular-nums} + .small{font:11px ui-monospace,SFMono-Regular,Consolas,monospace;fill:#8992a0} + .fine{fill:none;stroke:#c3ccd5;stroke-width:.6;stroke-linecap:round;stroke-linejoin:round} + .guide{fill:none;stroke:#b2bfce;stroke-width:.6;stroke-dasharray:2 4} + .grid{stroke:#dfe5ea;stroke-width:.5} + .bright{stroke:#fff;stroke-width:1;fill:none} + .mobile .axis{font-size:13px}.mobile .number{font-size:18px}.mobile .note{font-size:13px}.mobile .small{font-size:12px}.mobile .translation{font-size:12px}.mobile .token{font-size:23px}.mobile .formula{font-size:16px} +`; + +function node(tag,attrs={},parent){const n=document.createElementNS(NS,tag);for(const[k,v]of Object.entries(attrs))n.setAttribute(k,String(v));parent?.append(n);return n;} +function attr(n,a){for(const[k,v]of Object.entries(a))n.setAttribute(k,String(v));} +function text(n,s){if(n.textContent!==String(s))n.textContent=String(s);} +function poly(points){return points.map(([x,y],i)=>`${i?'L':'M'}${f(x)} ${f(y)}`).join('')+'Z';} +function bevel(x,y,w,h,b=2){b=Math.min(b,w/3,h/3);return poly([[x+b,y],[x+w-b,y],[x+w,y+b],[x+w,y+h-b],[x+w-b,y+h],[x+b,y+h],[x,y+h-b],[x,y+b]]);} + +export function createStage(host){ + const root=host.shadowRoot??host.attachShadow({mode:'open'});root.innerHTML=''; + const style=document.createElement('style');style.textContent=css;root.append(style); + const svg=node('svg',{viewBox:'0 0 1000 560',preserveAspectRatio:'xMidYMid meet',role:'img','aria-label':'四个候选词的分数、温度缩放、指数权重、概率和采样过程'},root); + const desc=node('desc',{},svg);text(desc,'教学用四候选分布。图形的正面高度表示数值;侧面厚度只表示层次。'); + const defs=node('defs',{},svg); + const floorGradient=node('linearGradient',{id:'tc-floor',x1:0,y1:0,x2:0,y2:1},defs);node('stop',{offset:0,'stop-color':'#fdfefe'},floorGradient);node('stop',{offset:1,'stop-color':'#edf1f4'},floorGradient); + const goldGradient=node('linearGradient',{id:'tc-gold',x1:0,y1:0,x2:0,y2:1},defs);node('stop',{offset:0,'stop-color':'#f4d799'},goldGradient);node('stop',{offset:1,'stop-color':'#d7ac57'},goldGradient); + const glow=node('filter',{id:'tc-shadow',x:'-40%',y:'-100%',width:'180%',height:'300%'},defs);node('feGaussianBlur',{stdDeviation:4},glow); + const gradients=COLORS.map((c,i)=>{const g=node('linearGradient',{id:`tc-bar-${i}`,x1:0,y1:0,x2:1,y2:0},defs);const a=node('stop',{offset:0,'stop-color':c,'stop-opacity':.48},g),b=node('stop',{offset:.83,'stop-color':c,'stop-opacity':.28},g);node('stop',{offset:1,'stop-color':'#fff','stop-opacity':.7},g);return[a,b];}); + const axisGroup=node('g',{},svg),axisTicks=[]; + const plotBacking=node('path',{fill:'#f5f7f9',opacity:.6},axisGroup); + const gridGroup=node('g',{},axisGroup); + for(let i=0;i<=4;i++){const g=node('g',{},gridGroup);axisTicks.push({line:node('line',{class:'grid'},g),tick:node('line',{class:'fine'},g),label:node('text',{class:'axis','text-anchor':'end'},g)});} + const axisSpine=node('path',{class:'fine'},axisGroup),axisName=node('text',{class:'small','text-anchor':'start'},axisGroup); + const baseGroup=node('g',{},svg),baseShadow=node('path',{fill:'#9daab9',opacity:.1,filter:'url(#tc-shadow)'},baseGroup),baseTop=node('path',{fill:'url(#tc-floor)',stroke:'#d3dbe3','stroke-width':.6},baseGroup),baseFront=node('path',{fill:'#e9eef3',stroke:'#c7d2dd','stroke-width':.6},baseGroup),baseShine=node('path',{class:'bright'},baseGroup); + const barGroup=node('g',{},svg); + const bars=TOKENS.map((token,i)=>{ + const g=node('g',{'data-candidate':token},barGroup); + const ghost=node('path',{class:'guide',opacity:0},g); + const side=node('path',{fill:COLORS[i],opacity:.32,stroke:'#a4b1c1','stroke-width':.6},g); + const front=node('path',{fill:`url(#tc-bar-${i})`,stroke:COLORS[i],'stroke-opacity':.65,'stroke-width':.65},g); + const top=node('path',{fill:'#f8fbfe',stroke:'#a6b5c5','stroke-width':.6},g); + const shine=node('path',{class:'bright',opacity:.9},g); + const seam=node('path',{fill:'none',stroke:COLORS[i],'stroke-opacity':.25,'stroke-width':.5},g); + const foot=node('path',{fill:'url(#tc-floor)',stroke:'#b8c5d1','stroke-width':.6},g); + g.append(ghost); + const operation=node('g',{opacity:0},g),operationLine=node('path',{fill:'none',stroke:COLORS[i],'stroke-width':.7},operation),operationDot=node('circle',{r:2.4,fill:COLORS[i]},operation); + const number=node('text',{class:'number','text-anchor':'middle'},g); + const original=node('text',{class:'small','text-anchor':'middle',opacity:0},g); + const labelGroup=node('g',{},g),dot=node('circle',{r:3.5,fill:COLORS[i]},labelGroup),label=node('text',{class:'token','text-anchor':'middle'},labelGroup),translation=node('text',{class:'translation','text-anchor':'middle'},labelGroup); + text(label,token);text(translation,CHINESE[i]); + return{g,ghost,side,front,top,shine,seam,foot,operation,operationLine,operationDot,number,original,labelGroup,dot,label,translation}; + }); + const bandScale=node('g',{opacity:0},svg),bandTicks=[]; + for(let i=0;i<=20;i++){const tick=node('line',{class:'fine'},bandScale);const label=i%5===0?node('text',{class:'axis','text-anchor':'middle'},bandScale):null;bandTicks.push({tick,label});} + const bandBounds=node('g',{},bandScale),bounds=Array.from({length:3},()=>node('path',{fill:'none',stroke:'#b0bdca','stroke-width':.65},bandBounds)); + const formulaGroup=node('g',{},svg),formulaBack=node('path',{fill:'#fcfdfd',stroke:'#dce2e8','stroke-width':.6},formulaGroup),formulaBottom=node('path',{class:'fine',opacity:.55},formulaGroup),formulaLabel=node('text',{class:'formula','text-anchor':'middle'},formulaGroup); + const weightScaleNote=node('text',{class:'small','text-anchor':'middle',opacity:0},formulaGroup); + const bandLeaders=node('g',{opacity:0},svg),labelLeaders=Array.from({length:4},()=>node('path',{fill:'none',stroke:'#b9c6d3','stroke-width':.6},bandLeaders)); + const cursorGroup=node('g',{opacity:0},svg),cursorGlow=node('line',{stroke:'#d8b972','stroke-width':6,opacity:.14},cursorGroup),cursorLine=node('line',{stroke:'#bc9144','stroke-width':1},cursorGroup),cursorHead=node('path',{fill:'url(#tc-gold)',stroke:'#ae884b','stroke-width':.65},cursorGroup),cursorReadout=node('text',{class:'small','text-anchor':'middle'},cursorGroup),cursorNumber=node('text',{class:'number','text-anchor':'middle',fill:'#9b783a'},cursorGroup); + const resultGroup=node('g',{opacity:0},svg),resultLink=node('path',{class:'guide'},resultGroup),resultShadow=node('rect',{rx:8,fill:'#8998ab',opacity:.1,filter:'url(#tc-shadow)'},resultGroup),resultSide=node('path',{fill:'#e4eaf0',stroke:'#c7d3df','stroke-width':.6},resultGroup),resultPlate=node('rect',{rx:8,fill:'#fff',stroke:'#becbd9','stroke-width':.7},resultGroup),resultAccent=node('rect',{height:3,rx:1.5},resultGroup),resultToken=node('text',{class:'token','text-anchor':'middle','font-size':30},resultGroup),resultProbability=node('text',{class:'small','text-anchor':'middle'},resultGroup),resultTitle=node('text',{class:'small','text-anchor':'middle'},resultGroup); + let lastFrame=null,disposed=false,anchors={},width=1000,height=560; + function render(frame){ + if(disposed)return;lastFrame=frame; + const mobile=host.getBoundingClientRect().width<600; width=mobile?560:1000;height=mobile?430:420; + attr(svg,{viewBox:`0 0 ${width} ${height}`,class:mobile?'mobile':'desktop'}); + const phase=clamp(frame.phase,0,5),pr=clamp(frame.progress??0),P=frame.p,T=frame.temperature; + const cdf=frame.cdf?.length===5?frame.cdf:[0,...P.map((_,i)=>P.slice(0,i+1).reduce((a,b)=>a+b,0))]; + const chosen=frame.selected,base=mobile?290:285,plotH=mobile?214:225,barW=mobile?62:110,dx=mobile?7:11,dy=mobile?5:8; + const xs=mobile?[91,202,313,424]:[130,345,560,775]; + const axisX=mobile?64:87,plotEnd=xs[3]+barW+dx; + const bandX=mobile?52:110,bandW=mobile?456:780,bandY=170,bandH=mobile?68:72; + let morph=phase>3?1:phase===3?ease((pr-.27)/.46):0; + if(frame.reducedMotion&&phase===3)morph=pr>=.5?1:0; + const isBand=morph>.99,chartOpacity=1-morph; + let values=frame.logits,max=4,axisTitle='原始分数 z'; + if(phase===1){values=frame.scaled;max=8;axisTitle='缩放分数 z / T';} + if(phase===2){values=frame.weights;max=Math.max(...values);axisTitle='指数权重 exp(z / T)';} + if(phase>=3){values=P;max=1;axisTitle='概率 p';} + attr(axisGroup,{opacity:chartOpacity});attr(baseGroup,{opacity:chartOpacity}); + attr(plotBacking,{d:poly([[axisX,base],[axisX,base-plotH],[plotEnd,base-plotH],[plotEnd,base]])}); + attr(axisSpine,{d:`M${axisX} ${base-plotH-8}V${base}H${plotEnd+12}`}); + attr(axisName,{x:axisX,y:base-plotH-25});text(axisName,axisTitle); + axisTicks.forEach((a,i)=>{const y=base-plotH*i/4;attr(a.line,{x1:axisX,y1:y,x2:plotEnd,y2:y});attr(a.tick,{x1:axisX-4,y1:y,x2:axisX,y2:y});attr(a.label,{x:axisX-12,y:y+4});text(a.label,phase>=3?`${i*25}%`:value(max*i/4));}); + const bx=xs[0]-25,bw=plotEnd-bx+24,by=base+8; + attr(baseShadow,{d:poly([[bx-6,by+12],[bx+bw+12,by+12],[bx+bw+19,by+26],[bx,by+26]])}); + attr(baseTop,{d:poly([[bx,by],[bx+dx,by-dy],[bx+bw+dx,by-dy],[bx+bw,by]])}); + attr(baseFront,{d:poly([[bx,by],[bx+bw,by],[bx+bw,by+8],[bx,by+8]])}); + attr(baseShine,{d:`M${bx+1} ${by+.8}H${bx+bw-1}`}); + bars.forEach((b,i)=>{ + const color=frame.color===false?['#8e9bad','#9eabba','#aeb9c5','#bdc7d0'][i]:COLORS[i]; + gradients[i].forEach(stop=>stop.setAttribute('stop-color',color));attr(b.side,{fill:color});attr(b.front,{stroke:color});attr(b.dot,{fill:color});attr(b.seam,{stroke:color}); + const h=Math.max(0,values[i]/max*plotH),targetX=bandX+cdf[i]*bandW,targetW=P[i]*bandW; + const x=mix(xs[i],targetX,morph),y=mix(base-h,bandY,morph),w=mix(barW,targetW,morph),hh=mix(h,bandH,morph); + const depthX=mix(dx,mobile?5:8,morph),depthY=mix(dy,mobile?4:6,morph); + const active=phase>=4&&chosen!==null,muted=active&&i!==chosen; + attr(b.g,{opacity:muted?.49:1}); + attr(b.front,{d:bevel(x,y,w,Math.max(.5,hh),Math.min(2,w/5))}); + attr(b.side,{d:poly([[x+w,y],[x+w+depthX,y-depthY],[x+w+depthX,y+hh-depthY],[x+w,y+hh]])}); + attr(b.top,{d:poly([[x,y],[x+depthX,y-depthY],[x+w+depthX,y-depthY],[x+w,y]]),opacity:hh>.5?1:0}); + attr(b.shine,{d:`M${f(x+1.5)} ${f(y+1.7)}H${f(x+w-1.5)}M${f(x+1.5)} ${f(y+1.7)}V${f(y+hh-1.5)}`,opacity:w>4&&hh>4?.8:0}); + attr(b.seam,{d:`M${f(x+4)} ${f(y+hh-4)}H${f(x+w-4)}`,opacity:hh>10&&w>12?.8:0}); + attr(b.foot,{d:poly([[xs[i]-4,base+1],[xs[i]+barW+5,base+1],[xs[i]+barW+dx+5,base-dy+1],[xs[i]+barW+dx+5,base+1],[xs[i]+barW+5,base+dy+1],[xs[i]-4,base+dy+1]]),opacity:chartOpacity}); + const ghostH=frame.logits[i]/8*plotH;attr(b.ghost,{d:bevel(xs[i]-4,base-ghostH,barW+8,Math.max(.4,ghostH),0),opacity:phase===1?.9:0}); + attr(b.operation,{opacity:phase===1&&i<3&&Math.abs(h-ghostH)>2?1:0}); + const ox=xs[i]+barW+17,oy=base-ghostH,ey=base-h,travel=frame.reducedMotion?1:ease((pr-.12)/.65); + attr(b.operationLine,{d:`M${ox-4} ${f(oy)}H${ox}V${f(ey)}H${ox-4}`,stroke:color}); + attr(b.operationDot,{cx:ox,cy:mix(oy,ey,travel),fill:color}); + const nx=xs[i]+barW/2;attr(b.number,{x:nx,y:base-h-18,opacity:chartOpacity});text(b.number,phase>=3?percent(P[i]):value(values[i])); + attr(b.original,{x:nx,y:base+22,opacity:phase===1?1:0});text(b.original,`z = ${frame.logits[i]}`); + const lx=mix(nx,bandX+bandW*(i+.5)/4,morph),ly=mix(base+(phase===1?53:48),bandY+bandH+69,morph); + attr(b.labelGroup,{transform:`translate(${f(lx)} ${f(ly)})`});attr(b.label,{x:0,y:0});attr(b.translation,{x:0,y:19});attr(b.dot,{cx:-(mobile?25:28),cy:-7}); + text(b.translation,isBand?percent(P[i]):CHINESE[i]); + }); + attr(bandScale,{opacity:morph}); + bandTicks.forEach((a,i)=>{const x=bandX+bandW*i/20;attr(a.tick,{x1:x,y1:bandY-18,x2:x,y2:bandY-(i%5===0?29:23)});if(a.label){attr(a.label,{x,y:bandY-38});text(a.label,phase>=4?(i/20).toFixed(i===0||i===20?0:2):`${i*5}%`);}}); + bounds.forEach((p,i)=>{const x=bandX+cdf[i+1]*bandW;attr(p,{d:`M${f(x)} ${bandY+bandH+4}V${bandY+bandH+12}`});}); + attr(bandLeaders,{opacity:morph}); + labelLeaders.forEach((leader,i)=>{const sx=bandX+(cdf[i]+P[i]/2)*bandW,ex=bandX+bandW*(i+.5)/4,sy=bandY+bandH+7,ey=bandY+bandH+40;attr(leader,{d:`M${f(sx)} ${sy}C${f(sx)} ${sy+14},${f(ex)} ${ey-13},${f(ex)} ${ey}`,stroke:frame.color===false?'#a7b3c0':COLORS[i],opacity:.55});}); + const formulaY=mobile?378:365,fw=mobile?490:570; + attr(formulaBack,{d:bevel((width-fw)/2,formulaY,fw,44,7)});attr(formulaBottom,{d:`M${(width-fw)/2+7} ${formulaY+47}H${(width+fw)/2-7}`});attr(formulaLabel,{x:width/2,y:formulaY+(phase===2?18:28)}); + const sum=frame.weights.reduce((a,b)=>a+b,0),selected=chosen??0; + const formulas=[`z = [ ${frame.logits.join(' , ')} ]`,`z / T T = ${T.toFixed(2)}`,`wᵢ = exp(zᵢ / T)`,`pᵢ = wᵢ / Σw Σp = 100%`,`u = ${frame.u.toFixed(3)} u ∈ [0, 1)`,`本次选择 ${TOKENS[selected]} · ${percent(P[selected])}`]; + text(formulaLabel,formulas[phase]); + attr(weightScaleNote,{x:width/2,y:formulaY+35,opacity:phase===2?1:0});text(weightScaleNote,`高度按最大权重 ${value(max)} 缩放 · Σw = ${value(sum)}`); + const cursorTravel=phase>4?1:frame.reducedMotion?(pr>=.8?1:0):ease(pr/.8),cursorX=bandX+frame.u*bandW*cursorTravel; + attr(cursorGroup,{opacity:phase===4?1:0});attr(cursorGlow,{x1:cursorX,y1:bandY-10,x2:cursorX,y2:bandY+bandH+8});attr(cursorLine,{x1:cursorX,y1:bandY-10,x2:cursorX,y2:bandY+bandH+8});attr(cursorHead,{d:poly([[cursorX-6,bandY-19],[cursorX+6,bandY-19],[cursorX+6,bandY-11],[cursorX,bandY-5],[cursorX-6,bandY-11]])}); + attr(cursorReadout,{x:width/2,y:bandY-85,opacity:0});text(cursorReadout,chosen===null?'定位随机数':'已落入对应区间');attr(cursorNumber,{x:width/2,y:bandY-60,opacity:0});text(cursorNumber,chosen===null?`x = ${(frame.u*cursorTravel).toFixed(3)}`:`u = ${frame.u.toFixed(3)}`); + const resultT=phase===5?(frame.reducedMotion?1:ease(pr/.3)):0; + attr(resultGroup,{opacity:phase===5?1:0}); + const resultW=mobile?156:174,resultH=77,segmentX=bandX+(cdf[selected]+P[selected]/2)*bandW,resultX=clamp(segmentX,resultW/2+28,width-resultW/2-28),resultY=mix(bandY-12,bandY-(mobile?100:118),resultT); + attr(resultLink,{d:`M${f(resultX)} ${f(resultY+resultH)}C${f(resultX)} ${f(resultY+resultH+12)},${f(segmentX)} ${bandY-16},${f(segmentX)} ${bandY+8}`}); + attr(resultShadow,{x:resultX-resultW/2+2,y:resultY+6,width:resultW,height:resultH}); + attr(resultSide,{d:poly([[resultX-resultW/2,resultY+resultH-5],[resultX+resultW/2,resultY+resultH-5],[resultX+resultW/2+7,resultY+resultH-12],[resultX+resultW/2+7,resultY+resultH-4],[resultX+resultW/2,resultY+resultH+3],[resultX-resultW/2,resultY+resultH+3]])}); + attr(resultPlate,{x:resultX-resultW/2,y:resultY,width:resultW,height:resultH});attr(resultAccent,{x:resultX-resultW/2+15,y:resultY+12,width:resultW-30,fill:COLORS[selected]});attr(resultToken,{x:resultX,y:resultY+43});text(resultToken,TOKENS[selected]);attr(resultProbability,{x:resultX,y:resultY+62});text(resultProbability,`${CHINESE[selected]} · ${percent(P[selected])}`);attr(resultTitle,{x:resultX,y:resultY-13});text(resultTitle,'SELECTED TOKEN'); + anchors={scores:[xs[0]+barW*.5,base-frame.logits[0]/4*plotH*.72],temperature:[xs[0]+barW+17,base-frame.scaled[0]/8*plotH*.72],weights:[xs[0]+barW/2,base-plotH*.7],probability:morph>.5?[bandX+P[0]*bandW*.5,bandY+bandH/2]:[xs[0]+barW/2,base-P[0]*plotH*.7],cursor:[cursorX,bandY+bandH/2],sampling:[cursorX,bandY+bandH/2],result:[resultX,resultY+12]}; + const spoken=phase<3?TOKENS.map((token,i)=>`${token} ${phase===0?'原始分数':phase===1?'缩放分数':'指数权重'} ${value(values[i])}`).join(','):TOKENS.map((token,i)=>`${token} 概率 ${percent(P[i])}`).join(','); + text(desc,`阶段 ${phase+1}。温度 ${T.toFixed(2)}。${spoken}。${chosen===null?'尚未完成采样。':`本次选中 ${TOKENS[chosen]}。`}`); + } + function projectAnchor(id){const a=anchors[id]??anchors.probability??[width/2,height/2],m=svg.getScreenCTM(),r=host.getBoundingClientRect();if(!m||!r.width||!r.height)return{x:.5,y:.5,visible:false};const p=svg.createSVGPoint();p.x=a[0];p.y=a[1];const q=p.matrixTransform(m),x=(q.x-r.left)/r.width,y=(q.y-r.top)/r.height;return{x,y,visible:x>=0&&x<=1&&y>=0&&y<=1};} + return{backend:'svg',render,projectAnchor,resize(){if(lastFrame)render(lastFrame);},dispose(){disposed=true;root.innerHTML='';lastFrame=null;}}; +} diff --git a/apps/site/learn/token-choice/style.css b/apps/site/learn/token-choice/style.css new file mode 100644 index 0000000..ef60d00 --- /dev/null +++ b/apps/site/learn/token-choice/style.css @@ -0,0 +1,13 @@ +:root{--paper:#f7f5ef;--ink:#343743;--muted:#817b87;--accent:#8066a6;--line:#e8e3da;font-family:Inter,'PingFang SC','Microsoft YaHei',system-ui,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button{font:inherit;cursor:pointer;border:0}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid #8e74b5;outline-offset:4px}button:disabled{opacity:.3;cursor:default}a{color:inherit;text-decoration:none}.app{height:100svh;min-height:790px;max-width:1600px;margin:auto;padding:0 40px;display:flex;flex-direction:column}header{height:58px;display:flex;gap:24px;align-items:center;flex-shrink:0}.brand{font-weight:700;font-size:19px;letter-spacing:-.6px}.brand i{font:italic bold 28px Georgia,serif;color:var(--accent);margin-right:4px}header>span{font-size:10px;letter-spacing:1px;color:#928a98}header>.quiet{margin-left:auto}.quiet{background:transparent;color:#82768b;border-radius:7px;font-size:11px;padding:8px 10px;white-space:nowrap}.quiet:hover{color:#554362;background:#eee8ee}.intro{display:flex;align-items:flex-end;justify-content:space-between;max-height:112px;padding:10px 0 23px;overflow:hidden;flex-shrink:0}.eyebrow{font-size:9px;letter-spacing:2px;font-weight:600;color:#8e7c9b;margin:0 0 10px}h1{margin:0;font-size:clamp(27px,2.6vw,38px);font-weight:650;letter-spacing:-1.2px;line-height:1.35}h1 span{color:#7c6c8e}.intro-copy{font-size:12px;color:#817788;margin:0 0 4px}.controls{height:60px;max-height:60px;display:flex;align-items:center;gap:17px;border:1px solid #e6e0d7;background:#efede6;padding:0 18px;border-radius:13px;margin-bottom:17px;flex-shrink:0;overflow:hidden}.temperature{display:flex;align-items:center;gap:20px}.temperature label{font-size:11px;white-space:nowrap;color:#756c7c}.temperature output{font-family:ui-monospace,monospace;color:var(--accent);margin-left:12px;font-size:12px}input[type=range]{appearance:none;background:transparent;height:24px;margin:0;cursor:pointer}input[type=range]::-webkit-slider-runnable-track{height:3px;border-radius:4px;background:linear-gradient(to right,#9b85b9 var(--fill,0%),#d9d1df var(--fill,0%))}input[type=range]::-webkit-slider-thumb{appearance:none;width:12px;height:12px;border-radius:50%;background:#8b73ac;margin-top:-4.5px;box-shadow:0 0 0 3px #f7f5ef}input[type=range]::-moz-range-track{height:3px;background:#d9d1df}input[type=range]::-moz-range-progress{height:3px;background:#9b85b9}input[type=range]::-moz-range-thumb{height:12px;width:12px;border-radius:50%;border:0;background:#8b73ac}#temperature{width:155px}.presets{display:flex;gap:3px}.presets button{font-size:10px;padding:8px 11px;background:none;color:#7f7187;border-radius:6px;white-space:nowrap}.presets button[aria-pressed=true],.presets button:hover{background:#fffcf8;color:#6f5387}.divider{height:22px;width:1px;background:#ded7df;margin-left:auto}#new-draw span{margin-right:4px;font-size:15px}.experiment{font-size:11px;padding:10px 14px;background:#8b749e;color:white;border-radius:7px;white-space:nowrap}.experiment:hover{background:#78618e}.exhibit{position:relative;flex:1;min-height:420px;background:#fffdfa;border:1px solid #e9e4db;border-radius:18px;overflow:hidden;isolation:isolate}.exhibit-meta{position:absolute;top:21px;left:25px;right:25px;display:flex;justify-content:space-between;color:#84798c;font-size:10px;letter-spacing:.6px;z-index:2}#live-temperature{font-family:ui-monospace,monospace;color:#8a73a2}.context{position:absolute;top:65px;left:27px;z-index:2;max-height:80px;overflow:hidden}.context>span{font-size:9px;color:#94899c}.context p{font:14px Georgia,serif;color:#7c7186;margin:9px 0}.context b{color:#a28dbb}.callout{position:absolute;top:56px;width:380px;left:50%;transform:translateX(-50%);padding:15px 19px 11px;border:1px solid #e7dfee;background:#fffffff7;border-radius:13px;box-shadow:0 8px 26px #88779905;z-index:4}.callout>span{font-size:9px;letter-spacing:.8px;color:#9a80b4}.callout h2{font-size:18px;font-weight:650;line-height:1.5;letter-spacing:-.25px;margin:7px 0}.callout p{font-size:12px;line-height:1.8;color:#766a7e;margin:0}.calculation{font-size:10px;color:#9579ac;background:transparent;padding:8px 0 0}.calculation:hover{color:#674280}.leader{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:3;color:#af97c5}.leader path{fill:none;stroke:currentColor;stroke-width:1.1}.leader circle{fill:#fffdfa;stroke:currentColor;stroke-width:1.1}.artwork{position:absolute;left:50%;bottom:39px;transform:translateX(-50%);width:min(96%,1160px);height:calc(100% - 208px);display:flex;align-items:center;justify-content:center;transition:transform .4s}.exhibit.is-detailed .artwork{transform:translateX(-50%) scale(1.3);transform-origin:center 70%}.stage-bottom{position:absolute;bottom:13px;left:25px;right:17px;display:flex;align-items:center;justify-content:space-between;z-index:5;color:#8a7f92;font-size:9px}.stage-bottom .quiet{font-size:10px}.transport{height:70px;display:flex;gap:18px;align-items:center;flex-shrink:0}.play{height:42px;width:134px;display:flex;align-items:center;justify-content:center;gap:11px;border-radius:9px;background:#3c3543;color:#fff;font-size:11px;flex-shrink:0}.play:hover{background:#62516f}#play-icon{font-size:10px}.track{flex:1;min-width:0}.track input{display:block;width:100%}.track>div{display:flex;justify-content:space-between;color:#8c7d98;font-size:9px;font-variant-numeric:tabular-nums}footer{height:30px;max-height:30px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;overflow:hidden;font-size:9px;color:#a497ac}.secondary{transition:opacity .35s,max-height .6s cubic-bezier(.22,1,.36,1),padding .6s,margin .6s}.is-playing .secondary{max-height:0;opacity:0;padding-top:0;padding-bottom:0;margin-top:0;margin-bottom:0;pointer-events:none}.is-playing .controls{border-width:0}.is-playing .exhibit{border-color:transparent}.is-playing .calculation,.is-playing #detail{display:none}.is-playing .artwork{height:calc(100% - 185px);bottom:35px}.is-playing .stage-bottom{bottom:19px}.is-playing .callout{padding-bottom:15px} +dialog{border:1px solid #e5dce9;background:#fffdf8;border-radius:18px;max-width:720px;width:94%;max-height:88svh;padding:34px;color:#4d4058;box-shadow:0 25px 100px #3e2c4829}dialog::backdrop{background:#34263e55;backdrop-filter:blur(4px)}dialog h2{font-size:25px;line-height:1.4;margin:0 0 16px;letter-spacing:-.6px}dialog p{font-size:13px;line-height:1.9;color:#81718d}dialog a{color:#8b6aa4;text-decoration:underline;text-underline-offset:3px}.close{position:absolute;right:16px;top:10px;background:none;color:#9883a6;font-size:26px}.fine{font-size:11px!important;color:#9786a2!important}.formula{font:14px/1.8 ui-monospace,monospace;background:#f0ebf3;border:1px solid #e4dce9;border-radius:8px;padding:12px 16px;margin:16px 0;color:#7b5e98;overflow-x:auto}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:right;font-variant-numeric:tabular-nums;font-size:12px}th{font-size:10px;color:#95819f;font-weight:500;white-space:nowrap;padding:9px 10px;border-bottom:1px solid #e7dfeb}td{padding:14px 10px;border-bottom:1px solid #eee8f1;color:#6b567a}td:first-child,th:first-child{text-align:left}td:first-child i{width:7px;height:7px;display:inline-block;border-radius:50%;margin-right:7px}.calculation-total{font-size:12px;color:#79628a;text-align:right} +@media(max-width:1100px){.app{padding:0 24px}.intro-copy{display:none}.controls{gap:10px}.temperature{gap:12px}#temperature{width:120px}.presets button{padding:8px}.context{display:none}.divider{margin-left:auto}} +@media(max-width:700px){.app{padding:0 12px;min-height:790px}header{height:48px;gap:14px}.brand{font-size:17px}.brand i{font-size:25px}header>span{font-size:8px;letter-spacing:0}header>.quiet{font-size:9px;padding-right:0}.intro{padding:9px 0 18px;max-height:105px}h1{font-size:24px;line-height:1.45;letter-spacing:-.7px}.eyebrow{font-size:8px;margin-bottom:7px}.controls{height:98px;max-height:98px;display:grid;grid-template-columns:1fr auto;gap:8px 4px;padding:10px 12px;margin-bottom:11px}.temperature{grid-column:1/-1;grid-row:1;gap:17px}.temperature label{font-size:11px}#temperature{flex:1;width:auto}.presets{grid-row:2;gap:0}.presets button{font-size:9px;padding:7px 9px}.divider{display:none}#new-draw{display:none}.experiment{font-size:9px;padding:9px 10px}.exhibit{min-height:408px;border-radius:13px}.exhibit-meta{top:17px;left:17px;right:17px;font-size:9px}.callout{top:47px;width:calc(100% - 34px);max-width:380px;padding:13px 16px 10px}.callout h2{font-size:16px;line-height:1.5}.callout p{font-size:11px;line-height:1.7}.callout>span{font-size:8px}.calculation{font-size:9px}.artwork{width:100%;height:calc(100% - 192px);bottom:37px}.stage-bottom{left:17px;right:7px;bottom:10px;font-size:8px}.stage-bottom .quiet{font-size:9px}.transport{height:62px;gap:6px}.play{width:110px;height:39px;font-size:10px}.transport>.quiet{padding:6px}.track>div span:first-child{display:none}.track>div{justify-content:flex-end}.track input{height:23px}footer{height:27px;max-height:27px;font-size:8px}footer>span:last-child{max-width:150px;text-align:right}.is-playing{min-height:600px}.is-playing .artwork{height:calc(100% - 190px);bottom:40px}.is-playing .stage-bottom{bottom:16px}dialog{padding:30px 19px}dialog h2{font-size:22px}td,th{padding:10px 6px}td{font-size:11px}.formula{font-size:12px}} +@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}[hidden]{display:none!important} + +/* Reserve the callout before fitting the vector diagram. */ +.artwork,.is-playing .artwork{top:220px;bottom:35px;height:auto}.stage-actions{display:flex;align-items:center;gap:5px} +@media(max-width:700px){.artwork,.is-playing .artwork{top:205px;bottom:36px;height:auto}} + +.challenge-answers{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:20px 0}.challenge-answers button{background:#f1ebf5;color:#725389;border:1px solid #dfd3e8;border-radius:8px;padding:13px 8px;font:20px Georgia,serif}.challenge-answers button[aria-pressed=true]{background:#8b749e;color:white;border-color:#8b749e}#challenge-feedback:empty{display:none}#challenge-feedback{padding:15px;background:#f2f1ed;border-radius:8px;color:#665873;line-height:1.9}.stage-actions #challenge{color:#80609e} + +.is-playing #challenge{display:none} diff --git a/docs/ai-learning/REVIEW.md b/docs/ai-learning/REVIEW.md new file mode 100644 index 0000000..92c96bc --- /dev/null +++ b/docs/ai-learning/REVIEW.md @@ -0,0 +1,38 @@ +# AI learning scene acceptance + +This is the review contract for the ten AI learning scenes. It is separate from the older mechanical template gallery. Review one bounded question at a time. Preserve the approved pale surfaces, fine strokes, restrained color, slight depth and single guided callout; choose each topic's graphical representation by its actual relationships. + +## Required evidence before release + +| Gate | Learner-facing question | Required evidence | +| --- | --- | --- | +| Purpose | Can a new reader identify the question, input and output? | A one-sentence outcome, visible example and a bounded starting point. | +| Causality | Can the learner see why the next step follows? | A timed sequence whose values, labels, arrows and geometry share one model state. | +| Mapping | What does length, color, position and motion mean? | Explicit units/scales; stable identity colors; no perspective distortion of quantitative dimensions. | +| Distinctions | What tempting wrong conclusion does the scene prevent? | Visible or readily inspectable simplifications: toy vs actual model, score vs probability, word vs token, retrieval vs truth, instructions vs execution. | +| Experiment | Can changing one thing produce a meaningful difference? | One counterfactual with controlled remaining variables and an independently checked result. | +| Transfer | Can the learner predict and explain a new case? | A short predict → operate → explain check, with causal feedback rather than an unexplained correct/incorrect marker. | +| Operation | Can the scene be inspected without losing the explanation? | Pause/resume, forward/backward seek, reset/replay/completion, parameter changes, focus restoration and hidden controls inert. | +| Accessibility | Can it be used on a narrow screen and with a keyboard? | Actual desktop and phone-width browser observations, legible formula/labels, meaningful control names and reduced-motion implementation. Note untested devices. | +| Truthfulness | What is actually established? | Official sources, model invariants, distinct automated/browser observations and clearly stated unverified learning outcomes. | + +Do not publish a failed hard gate to meet a cadence. A twelve-hour cadence schedules an iteration; it does not certify twelve hours of uninterrupted execution or guarantee that each iteration will pass. If a scene needs another iteration, continue it before advancing. Never count a renamed visualization as a new concept. + +## Release procedure + +1. Read the latest main, roadmap, STATUS, relevant scene files and existing PR feedback; use an isolated branch. +2. Implement and fix observed problems. Reuse existing components where their semantics fit; no empty scaffold files solely to satisfy a checker. +3. Run `npm run check` and the subject's independent invariants. Record endpoints, backward seeking and representative counterfactuals. +4. Inspect the actual local or branch-preview UI at desktop and phone width; exercise affected controls. Keep useful evidence, not only a self-assigned score. +5. Merge only the reviewed head with successful relevant CI. The Vercel Git integration performs the production build. Check the resulting SHA and READY deployment, then the production route. +6. Update the scene's source/spec/lesson, catalog, roadmap and STATUS. Report only real release links and concise outcomes. Failed or pending deployments are not published. + +Source and automatic deployment: `docs/RELEASING.md`. The public repository and existing hosting target are unchanged. Social posting is outside this authorization. + +## Reusable skill boundary + +The eventual general skill chooses a representation from the question and constrains model, timeline, annotations, experiment and verification. The ten scenes are examples produced by that method, not ten separate skills. Feed proven improvements back after scene acceptance; skill installation or cross-host compatibility must be verified separately. + +## Learning evidence boundary + +Developer review, mathematical tests and a self-check question establish an inspectable teaching design. They do not establish measured learning gains. Until actual learner sessions are conducted, describe educational suitability as a review judgment and record potential misconceptions. Never claim an award, universal fidelity or a completed user study. diff --git a/docs/ai-learning/STATUS.md b/docs/ai-learning/STATUS.md new file mode 100644 index 0000000..7ea7e26 --- /dev/null +++ b/docs/ai-learning/STATUS.md @@ -0,0 +1,23 @@ +# AI principles course status + +Updated: 2026-10-26 (Asia/Shanghai). + +The user authorized ten scenes, an iteration every twelve hours, independent learner-oriented review and publication after acceptance. A recurring task has been created. Each scheduled run works on one scene; failure keeps that scene active rather than counting it as complete. + +## First release candidate: scores to one Token + +- Course position: 05; first implemented/reviewed scene in this release sequence. +- Route: `/learn/token-choice/`; course index: `/learn/`. +- Visuals: original SVG mathematical objects; no wheel body reused. Preserves the previously approved fine-line/color direction. +- Model: four complete teaching candidates, fixed logits, temperature scaling, exponent weights, normalization, inverse-CDF sampling. No live LLM, tokenizer implementation or autoregressive continuation. +- Counterfactual: keep u ≈ 0.621868 fixed; T=.8 selects mat, T=2 selects floor. Original logits remain unchanged. +- Learning improvement: final-state prediction check asks which candidate is selected after the temperature change, explains the CDF boundaries and lets the learner verify the same case in the diagram. +- Independent model checks and full site checks: passed npm run check on 2026-10-26. +- Current desktop browser: course index entry, lesson route, final-state learning check, wrong/right feedback and return-to-diagram verified. The challenge returned T=2, u=.6219 and floor at 27.6%, matching the model. The same end-state check was opened and inspected in a 390×844 iframe; all four choices and explanatory text fit without clipping. Earlier playback evidence is retained in the scene LESSON. +- Publication: pending. A route in source or a passing local build is not a live release. + +## Remaining work + +Nine scenes are planned, not implemented or advertised as playable. See `roadmap.json` for topics, learning outcomes and current source status. The existing experimental autoregression and roulette pages are not silently promoted to accepted lessons. + +Real learner sessions, physical phones, screen-reader devices and independent browser-engine coverage have not been completed. These limits do not prevent releasing a clearly labeled teaching sample, but they must not be represented as tested. diff --git a/docs/ai-learning/roadmap.json b/docs/ai-learning/roadmap.json new file mode 100644 index 0000000..3953e2d --- /dev/null +++ b/docs/ai-learning/roadmap.json @@ -0,0 +1,111 @@ +{ + "version": 1, + "updated": "2026-10-26", + "target": 10, + "cadenceHours": 12, + "releasePolicy": "One reviewed scene per iteration; continue a failed scene before advancing.", + "iterationOrder": [ + "token-choice", + "tokenization", + "embedding", + "attention", + "transformer", + "autoregression", + "context", + "rag", + "skills-tools-mcp", + "personal-agent" + ], + "scenes": [ + { + "id": "tokenization", + "order": 1, + "title": "文字怎样变成 Token?", + "outcome": "观察同一句话如何分成片段并映射为编号;区分字符、词和 Token。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "embedding", + "order": 2, + "title": "编号怎样变成向量?", + "outcome": "跟踪查表得到的向量,并比较方向、距离与其教学边界。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "attention", + "order": 3, + "title": "一个词如何参考其他词?", + "outcome": "从 QK 分数到因果遮罩、注意力权重与 V 的加权结果。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "transformer", + "order": 4, + "title": "一层网络怎样更新表示?", + "outcome": "跟踪注意力、残差、归一化与前馈变换中的同一个向量。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "token-choice", + "order": 5, + "title": "一个词,是怎样被选出来的?", + "outcome": "分数经过温度、指数和归一化,怎样得到一次选择。", + "status": "reviewed", + "route": "/learn/token-choice/", + "published": false + }, + { + "id": "autoregression", + "order": 6, + "title": "为什么生成要一个 Token 接一个?", + "outcome": "把选中的 Token 接回上下文,区分重算与 KV 缓存的复用。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "context", + "order": 7, + "title": "System Prompt 怎样进入上下文?", + "outcome": "查看消息、指令与窗口容量,理解裁剪、优先级和能力边界。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "rag", + "order": 8, + "title": "检索怎样帮助模型回答?", + "outcome": "从文档切片、检索到带来源的上下文;区分找到、引用与答对。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "skills-tools-mcp", + "order": 9, + "title": "Skill、工具和 MCP 分别做什么?", + "outcome": "用一次任务追踪操作说明、工具调用、协议与实际执行。", + "status": "planned", + "route": null, + "published": false + }, + { + "id": "personal-agent", + "order": 10, + "title": "Personal Agent 怎样完成一件事?", + "outcome": "跟踪计划、行动、观察、记忆与结束条件,看到失败如何反馈。", + "status": "planned", + "route": null, + "published": false + } + ] +} diff --git a/evals/verify-course.mjs b/evals/verify-course.mjs new file mode 100644 index 0000000..b77b247 --- /dev/null +++ b/evals/verify-course.mjs @@ -0,0 +1,17 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +const root=path.resolve(import.meta.dirname,'..'); +const roadmap=JSON.parse(fs.readFileSync(path.join(root,'docs/ai-learning/roadmap.json'),'utf8')); +const catalog=JSON.parse(fs.readFileSync(path.join(root,'apps/course/catalog.json'),'utf8')); +assert.equal(roadmap.target,10);assert.equal(roadmap.cadenceHours,12);assert.equal(catalog.length,10); +assert.equal(new Set(catalog.map(s=>s.id)).size,10);assert.deepEqual(catalog.map(s=>s.order),Array.from({length:10},(_,i)=>i+1)); +for(const scene of catalog){ + assert(scene.title&&scene.outcome); + assert(['planned','reviewed','published'].includes(scene.status)); + if(scene.status==='planned'){assert.equal(scene.route,null);continue;} + assert(scene.route.startsWith('/learn/')); + for(const name of ['index.html','app.mjs','model.mjs','stage.mjs','SCENE_SPEC.json','LESSON.md'])assert(fs.existsSync(path.join(root,'apps/site',scene.route,name)),`${scene.id}/${name}`); + assert(roadmap.scenes.some(s=>s.id===scene.id&&s.status===scene.status)); +} +console.log('Course checks passed: ten unique lessons, truthful planned/open states, existing source and persistent review roadmap.'); diff --git a/evals/verify-token-choice.mjs b/evals/verify-token-choice.mjs new file mode 100644 index 0000000..5e8aaea --- /dev/null +++ b/evals/verify-token-choice.mjs @@ -0,0 +1,21 @@ +// Validation for this site's self-contained SVG adapter (no scaffold runtime). +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import {sample,compute,STARTS,DURATION} from '../apps/site/learn/token-choice/model.mjs'; +const dir=path.resolve('apps/site/learn/token-choice'); +for(const name of ['index.html','style.css','app.mjs','stage.mjs','model.mjs','SCENE_SPEC.json','LESSON.md'])assert(fs.statSync(path.join(dir,name)).isFile(),name); +for(const name of ['app.mjs','stage.mjs','model.mjs']){ + const source=fs.readFileSync(path.join(dir,name),'utf8'); + for(const match of source.matchAll(/\bfrom\s*['"](\.[^'"]+)['"]/g))assert(fs.existsSync(path.resolve(dir,match[1])),`missing ${match[1]}`); + assert(!/https?:\/\/.*\.(?:js|css|png|jpg)/.test(source),'runtime asset must be local'); +} +const spec=JSON.parse(fs.readFileSync(path.join(dir,'SCENE_SPEC.json'),'utf8'));assert(spec.question); +// Check every phase and temperature endpoint for finite, ordered and exact domains. +for(const temperature of [.4,.8,1,2])for(const t of [...STARTS,DURATION,43.199,43.2]){ + const f=sample(t,{temperature});assert(f.cdf.length===5);assert(f.cdf[0]===0&&f.cdf.at(-1)===1);assert(f.cdf.every((c,i)=>!i||c>f.cdf[i-1]));assert(f.scaled.every(z=>z>=0&&z<=8));assert(f.weights.every(w=>w>0&&Number.isFinite(w))); + if(f.selected!==null)assert(f.u>=f.cdf[f.selected]&&f.u=22.12.0" diff --git a/scripts/build-course.mjs b/scripts/build-course.mjs new file mode 100644 index 0000000..5bc3775 --- /dev/null +++ b/scripts/build-course.mjs @@ -0,0 +1,14 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +const escape=value=>String(value).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); +export async function buildCourse(root,out){ + const catalog=JSON.parse(await fs.readFile(path.join(root,'apps/course/catalog.json'),'utf8')); + const available=catalog.filter(scene=>scene.route&&['reviewed','published'].includes(scene.status)); + const cards=catalog.map(scene=>{ + const open=available.includes(scene),tag=open?'a':'article'; + return `<${tag} class="lesson ${open?'available':'planned'}" ${open?`href="${escape(scene.route)}"`:''}>
${String(scene.order).padStart(2,'0')}${open?'打开演示 ↗':'待开放'}

${escape(scene.title)}

${escape(scene.outcome)}

${open?'观看 · 调参 · 验证理解':'逐个打磨,通过验收后开放'}
`; + }).join(''); + const html=`AI 原理课堂 · Explain3D
e· Explain3D

AI PRINCIPLES, MADE VISIBLE

AI 原理,
逐步看见。

跟着一次真实的计算关系。
看它变化,亲手调整,再验证自己的理解。

从一件能操作的作品开始 ↗

TEN SMALL, COMPLETE EXPLANATIONS

从底层,到完整任务。

${available.length} / ${catalog.length} 个场景已开放

${cards}

看过之后,能解释为什么。

每节只回答一个问题:先看动画中的因果,再暂停检查计算。改变一个参数,预测下一次会发生什么。

教学数据、简化关系与未覆盖内容都会说明。可运行的图形与自检题用于辅助理解;目前尚未开展真实学习者效果研究。

把同样的方法,带进你的 Agent ↗
`; + await fs.mkdir(path.join(out,'learn'),{recursive:true}); + await fs.writeFile(path.join(out,'learn/index.html'),html); +} diff --git a/scripts/build.mjs b/scripts/build.mjs index 7931f2e..d4fc7f3 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import {buildCourse} from './build-course.mjs'; import {templates,promptFor} from '../templates/catalog.mjs'; const root=path.resolve(import.meta.dirname,'..'),out=path.join(root,'dist'); await fs.mkdir(out,{recursive:true});for(const name of await fs.readdir(out))await fs.rm(path.join(out,name),{recursive:true,force:true}); @@ -7,20 +8,21 @@ for(const [from,to] of [['apps/site',''],['packages/runtime','runtime'],['packag const viewerPath=path.join(out,'runtime/viewer.mjs');await fs.writeFile(viewerPath,(await fs.readFile(viewerPath,'utf8')).replace('../../templates/catalog.mjs','../templates/catalog.mjs')); await fs.cp(path.join(root,'templates/planetary-full'),path.join(out,'demos/planetary'),{recursive:true}); const esc=s=>String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); -const nav=`
`; +const nav=`
`; const foot=``; const page=(title,desc,body,url)=>`${esc(title)} | Explain3D by funAgent${nav}${body}${foot}`; const viewport=(id,cls='')=>`
正在装配场景
`; const card=s=>`
${esc(s.name)}的真实三维渲染预览

${s.title}

${s.summary}

${s.name} / ${s.category}打开体验 ↗
`; async function write(route,html){const target=path.join(out,route,'index.html');await fs.mkdir(path.dirname(target),{recursive:true});await fs.writeFile(target,html);} -await write('',page('把原理,放到眼前。','用 AI 把一个原理做成可以亲手探索的 3D 作品。十个实时模板、对应提示词和可复用 Skill。',`

可交互的原理讲解工具

把原理,
放到眼前。

让 AI 把一个原理,做成可以亲手探索的 3D 作品。

${viewport('planetary','hero-scene')}行星齿轮 / 实时生成的几何,可拖动观察

先看懂一个,
再做出你的那个。

机械结构、光学过程、AI 与系统流程。打开模板,操作参数,再带着提示词开始新的场景。

${[templates[0],templates[5],templates[8]].map(card).join('')}

从一句想法,
到一件作品。

想清楚

只讲清楚一个问题。

对象、关系、可调变量与简化边界先写下来。让每一次运动都有原因。

做出来

让模型与交互,一起运转。

Skill 引导 Agent 构建真实几何、可采样的运动和同步控制;播放时界面退场,让主体接管画面。

验明白

漂亮之外,还要经得起操作。

检查公式、参数、暂停与拖动,也检查手机布局。源码、提示词与验证记录一起交付。

用你熟悉的 AI 开始。

Skill 提供工作方法,Agent 提供代码执行与预览环境。按你的工具选择接入方式。

ChatGPT / Codex

当前制作环境

这组模板在当前 Codex 环境构建;可使用 Skill 与提示词,实际功能取决于宿主工具。

Claude Code

安装说明已提供

复制标准 Skill 到项目内,调用完整的创建与验收流程。尚未完成跨宿主复现。

DeepSeek

提示词模式

复制提示词,并由具备文件、执行和预览能力的 Agent 宿主完成运行。

WorkBuddy

导入说明已提供

使用标准 Skill 文件夹或按宿主要求打包导入。尚未完成跨宿主复现。

炫酷,是入口。
理解,才是结果。

这里的深度来自三维几何、透视、遮挡与运动视差。普通手机和电脑就能打开;真正的双眼裸眼立体显示仍需要对应硬件。

十个模板都是可操作的教学作品,并明确记录各自简化。你可以读源码、改参数,也可以让 Agent 为新问题重新建模。

查看开源项目 ↗
`,'/')); +await write('',page('把原理,放到眼前。','用 AI 把一个原理做成可以亲手探索的 3D 作品。十个实时模板、对应提示词和可复用 Skill。',`

可交互的原理讲解工具

把原理,
放到眼前。

让 AI 把一个原理,做成可以亲手探索的 3D 作品。

${viewport('planetary','hero-scene')}行星齿轮 / 实时生成的几何,可拖动观察

先看懂一个,
再做出你的那个。

机械结构、光学过程、AI 与系统流程。打开模板,操作参数,再带着提示词开始新的场景。

${[templates[0],templates[5],templates[8]].map(card).join('')}

从一句想法,
到一件作品。

想清楚

只讲清楚一个问题。

对象、关系、可调变量与简化边界先写下来。让每一次运动都有原因。

做出来

让模型与交互,一起运转。

Skill 引导 Agent 构建真实几何、可采样的运动和同步控制;播放时界面退场,让主体接管画面。

验明白

漂亮之外,还要经得起操作。

检查公式、参数、暂停与拖动,也检查手机布局。源码、提示词与验证记录一起交付。

用你熟悉的 AI 开始。

Skill 提供工作方法,Agent 提供代码执行与预览环境。按你的工具选择接入方式。

ChatGPT / Codex

当前制作环境

这组模板在当前 Codex 环境构建;可使用 Skill 与提示词,实际功能取决于宿主工具。

Claude Code

安装说明已提供

复制标准 Skill 到项目内,调用完整的创建与验收流程。尚未完成跨宿主复现。

DeepSeek

提示词模式

复制提示词,并由具备文件、执行和预览能力的 Agent 宿主完成运行。

WorkBuddy

导入说明已提供

使用标准 Skill 文件夹或按宿主要求打包导入。尚未完成跨宿主复现。

炫酷,是入口。
理解,才是结果。

这里的深度来自三维几何、透视、遮挡与运动视差。普通手机和电脑就能打开;真正的双眼裸眼立体显示仍需要对应硬件。

十个模板都是可操作的教学作品,并明确记录各自简化。你可以读源码、改参数,也可以让 Agent 为新问题重新建模。

查看开源项目 ↗
`,'/')); await write('templates',page('找到一个,拆开来看。','十个可操作的三维原理讲解模板,附源码、提示词、计算公式与模型边界。',`
`,'/templates/')); for(const s of templates){const p=s.parameter;await write(`templates/${s.id}`,page(s.title,s.summary,`
← 全部模板

${s.title.replace(",",",
")}

${s.summary}

${viewport(s.id,'demo-viewport')}
0.0 / 10s

${s.chapters[0]}

这个场景,讲什么?

${s.chapters.join('。')}。

${esc(s.formula)}

模型边界

${s.boundary}

方法参考:${s.sourceLabel}。源码与模型检查:GitHub。

${s.id==='planetary'?'

打开完整精密仪表版 ↗

':''}

带走这个方法。

在有代码执行和网页预览能力的 Agent 中使用。新场景需要重新定义对象与约束。

`,`/templates/${s.id}/`));} await write('create',page('把想解释的,写下来。','选一个模板起点,生成可以复制到 AI Agent 的完整创作提示词。',`

生成后交给 Agent 执行。几何精度、讲解正确性与设备表现需要分别验收。

`,'/create/')); await write('start',page('让 Agent,多一个本领。','安装 Explain3D Skill,使用模板、共享运行时与验收流程创建新的三维讲解。',`
git clone https://github.com/funAgent/explain3d.git\ncd explain3d\nnpm test\nnpm run build\npython3 -m http.server 8000 --directory dist
${['Codex','Claude Code','WorkBuddy','DeepSeek'].map((s,i)=>``).join('')}

ChatGPT / Codex

当前这些作品由 Codex 构建。CLI 项目内安装:

mkdir -p .agents/skills\ncp -R skills/create-spatial-explainer .agents/skills/

在支持自定义 Skill 的 ChatGPT / Codex 宿主中,按宿主入口添加该 Skill。不同界面的工具和安装入口可能不同;也可直接复制提示词使用。

公开仓库提供完整 Skill 文件夹;各执行环境的复现情况见验证记录。

第一次可以这样说。

使用 $create-spatial-explainer,解释凸轮如何让气门往复运动。\n做成10秒可暂停、可拖动进度的实时3D作品。\n播放时收起配置,结束后恢复;给出可运行源码与验证记录。

Skill 提供方法与资源,不能保证任意复杂场景一次生成正确。请检查模型假设、计算关系和设备表现。

查看完整工作流与验证边界
`,'/start/')); await write('docs',page('怎么做,也怎么验证。','Explain3D 的结构、工作流、模板边界、自动发布与验证记录。',`

能力如何组成

Skill 负责把一个问题拆成对象、关系、变量、约束与叙事。运行时负责绝对时间采样、播放状态、视角、参数和配置退场。场景适配器负责各自的几何与计算。

产品采用可静态部署的网站,核心演示使用 Three.js;无 WebGL 时使用同一模型的 CPU 投影。官网不存储 API Key,也不直接运行用户提示词。

创建一个新场景

  1. 限定一个闭环问题与适合的受众。
  2. 写场景规格,注明来源、约束与简化。
  3. 选择合适的模板,重建新对象与计算关系。
  4. 共用时间、播放和交互状态,制作十秒讲解。
  5. 分别验证数学、交互、画面、键盘、手机与性能。
  6. 交付源码、提示词、运行方法与验证记录。

当前验证到哪里

已执行模型约束、时间线与静态构建检查,并在当前浏览器检查交互和响应式。当前云端验收浏览器禁用 WebGL,画面使用 CPU 兼容渲染。真实手机 GPU、方向传感器与持续帧率尚未实机验收。

标准 Skill 包已提供。Claude Code、WorkBuddy 和 DeepSeek 宿主的独立复现尚未完成;网站不会把文档适配标为验证通过。

自动发布

GitHub 保存网站、Skill、模板与提示词。Vercel Git 集成负责分支预览和正式分支发布;生产构建先执行 npm run check,模型检查失败时构建中止。每个版本可按 Git 提交追溯,正式站点绑定 explain.funagent.app。

Skill 的本地检查完成后再同步到发布目录。依赖、模型假设和第三方许可证保留在源码中。社交文案在素材页准备,发布到社交账号单独执行。

深度效果的边界

透视、遮挡、光照和运动视差能在普通屏幕中形成明显深度感;它们不让普通屏幕自动变成双眼立体硬件。倾斜控制依赖方向传感器,完整行星齿轮版可使用;其他模板支持拖动观察。

参与

提交新模板时,附带可运行场景、参数、公式或事件模型、简化说明和测试。请通过 GitHub Issues 提出问题或 Pull Request 分享作品。

仓库与贡献指南 ↗

`,'/docs/')); await write('press',page('把可以操作的理解,分享出去。','Explain3D 的真实演示、发布文案、品牌说明和素材。',`
Explain3D 实际交互作品预览

产品一句话

让 AI 把一个原理,做成可以亲手探索的 3D 作品。

可使用的宣传材料

小红书、短视频、公众号、X、社区与 GitHub 发布文案 ↗

下载分享封面 / 下载行星齿轮场景图

演示素材的准确表述

这是可操作的实时三维原理讲解。可以说“普通屏幕里的深度感”“可拖动、可拆解”“AI 辅助生成源码”。不能据此宣称普通手机拥有真正的双眼裸眼立体显示,也不能把未验证平台说成一键必成。

`,'/press/')); -const urls=['/','/templates/','/create/','/start/','/docs/','/press/',...templates.map(s=>`/templates/${s.id}/`)]; +await buildCourse(root,out); +const urls=['/','/learn/','/learn/token-choice/','/templates/','/create/','/start/','/docs/','/press/',...templates.map(s=>`/templates/${s.id}/`)]; await fs.writeFile(path.join(out,'sitemap.xml'),`${urls.map(u=>`https://explain.funagent.app${u}`).join('')}`); await fs.writeFile(path.join(out,'robots.txt'),'User-agent: *\nAllow: /\nSitemap: https://explain.funagent.app/sitemap.xml\n'); await fs.writeFile(path.join(out,'favicon.svg'),'e'); diff --git a/scripts/bundle-token-choice.mjs b/scripts/bundle-token-choice.mjs new file mode 100644 index 0000000..ab91739 --- /dev/null +++ b/scripts/bundle-token-choice.mjs @@ -0,0 +1,19 @@ +import fs from 'node:fs'; +import path from 'node:path'; +const root=path.resolve('apps/site/learn/token-choice'); +const cache=new Map(); +function moduleURL(file){ + const absolute=path.resolve(file); + if(!absolute.startsWith(root+path.sep))throw Error('Import outside scene'); + if(cache.has(absolute))return cache.get(absolute); + const source=fs.readFileSync(absolute,'utf8').replace(/\bfrom\s*(['"])(\.\/[^'"]+)\1/g,(_,quote,relative)=>`from ${JSON.stringify(moduleURL(path.resolve(path.dirname(absolute),relative)))}`); + const url='data:text/javascript;base64,'+Buffer.from(source).toString('base64'); + cache.set(absolute,url);return url; +} +const read=name=>fs.readFileSync(path.join(root,name),'utf8'); +const html=read('index.html') + .replace('',()=>``) + .replace('',()=>``); +const out=process.argv[2]??'dist/ai-token-choice.html'; +fs.mkdirSync(path.dirname(out),{recursive:true});fs.writeFileSync(out,html); +console.log(`Bundled ${out} (${Buffer.byteLength(html)} bytes, ${cache.size} local modules)`);