# 知识原生循环:把 ClearAI 接回 DSH 原生行为 > 这一份是**执行账本**,不是设计宣言。每阶段结束在本文件对应分节里追加 > 「完成项 / 跑过的命令 / 实际输出 / 阻塞点」——不另立第二份进度台账。 > > **起点**(一次真实长跑,4 条主张在运行态卡上显示成 6~8 行读数;用户明确要求 > 「构建 JEPA 本体图和实体图」之前,模型没有自主进入本体化研究模式): > 现有的本体、实体、认识论机制**都能被调用**,但没有进入任务的完成函数—— > 于是普通研究任务的最短路径仍然是「检索 → 总结 → 写报告」。 > > 三条纪律沿用本仓库既有的那三条:凡能被算出来的不要被说出来; > 凡要成为边界的落进 schema / 注册表 / 投影 / 测试;**不新增第二本账**。 ## 0. 现状基线(阶段 0) ### 0.1 原生能力清单:今天真的挂着什么 预设内核(`preset/plugins/clearai-kernel.js`)当前挂在下面这些原生钩子上。**没有一处需要新钩子**—— 分诊与知识门要落的地方,宿主早就开好了: | 原生事件 / 服务 | 模式 | 内核今天拿它做什么 | 这一轮要用它做什么 | |---|---|---|---| | `agent/pre-step` | waterfall | 注入每回合运行态卡、外脑目录、本体声明、世界线回灌 | **知识模式与缺口读数进卡**(阶段 1) | | `tools/pre-execute` | waterfall | 路径越狱、`clear/` 拒写、L4 人放行、危险 bash | **完成前的知识门**(阶段 3) | | `agent/turn-stopping` / `agent/error` | serial / emit | 回合收尾落工作区快照 | 不变 | | `agent/created` | emit | 抢在原生技能目录快照之前铺工作区 | 不变 | | `subagent/end` | emit | 子 run 落定结算 | 不变 | | `goals`(服务) | — | 布防续跑令牌;只当驱动器,从不读它做判断 | 不变 | | `sessions` / `skills` / `tools` / `systemPrompt` | 服务 | 读会话、注册技能提供者与工具、注册提示段 | 不变 | | `webServer` | 服务 | 面板三条只读/人门路由 | 不变 | 未使用但**可考虑**的原生面(本轮暂不接,除非有真实消费方): `tools/post-execute`、`session/event`、`system-prompt/assemble`、`goal/changed`、`fs/observed`。 ### 0.2 ClearAI → 原生对象的映射(谁是谁的第二本账) | ClearAI 概念 | 原生对应物 | 关系 | |---|---|---| | goal | `goals` 服务(宿主目标) | **同源**:内核只在宿主目标上布防续跑令牌,不另存一份 | | plan / step | 无原生对应 | ClearAI 自有(原生 plan-mode 刻意不挂) | | session 日志 | `sessions` + projection | 折法吃的是它,不是自建存储 | | sub-run | `subagents` 注册表 + `subagent/end` | 注册表在宿主平面,预设只贡献工具 | | approval / L4 人放行 | `approval` 服务 + `tools/pre-execute` 的 `ask` | **同源**:不另造审批栈 | | 面板数据 | `sessionProjections` 的 `clearai` 单元 | 一个投影、多个读面 | ### 0.3 测试基线(阶段 0 开跑前,全绿一遍) ```text 内核:718 · 宿主:99 · 外脑:40 · 客户端:214 · 领域语言:92 · 本体:96 · 真值表:24 · 状态机:43 · 文档:16 · 注释:7 · 边界:15 · 组合:20 · 段:13 · 长测:41 · 不变量:27 ``` ### 0.4 阶段 0 抓到的真缺陷(不在原计划里,按证据直接修) **命题身份在修订时被重新签发。** 真跑现场里,`SetGoal` 每次调用都给假设**发新 id** (`h-${Math.random()…}`),而旧的那批从来不落 `hypothesis/superseded`——于是同一个 id 空间里 躺着同一句话的两份读数:一份「已支持」、一份「未触及」。真会话的运行态卡实测 **4 条主张显示成 6~8 行**,模型得自己去调和两份打架的读数。 两条根源,两条都补: 1. **修订必须用回原 id**:主张原文一字不动(去空白后逐字相等)时复用既有 id; 换了主张才发新 id——与领域词汇那条「语义变化必须换 id」是同一条纪律。 2. **`hypothesis/superseded` 一直没有生产者**:折法早就认识这条变更(`fold.js` 有 case), 生产侧从来没有人发过它。这与文档里记过的 `retracted`「声明了却没有生产者」是同一种病。 现在:这一版没再列出来的、且还没升格成事实的,如实落 `superseded`。 折法侧同时补上「一个 id 只对应一条主张」:按 id upsert,**只更新不改变身份的那些字段** (断言、推翻条件、版本),终态(`superseded` / `refuted`)黏住,拿旧 id 换 `claim` 一律拒。 ## 1. 知识模式(分诊) ### 判据:结构的,不是词法的 **知识模式 ⟺ 目标还开着,而且它带着登记过的命题。** 不猜「这句话像不像研究任务」。立约(`SetGoal`)并用相互竞争的假设登记它,是模型自己 已经做出的那次承诺。日常问答从不立约,于是从不进这一档。词面启发式猜错了没人能复核—— 真跑里评估者抓到的那类「硬编码比例」就是这么来的;结构判据可以复核。 `minHypotheses` 是部署自己的产品立场(内核缺省 0 = 机制中立),所以一个把下限设成 0 的部署 明确说了「我不要这条纪律」,那时它也不该收到知识模式的读数。 ### 缺口:读数,不是拦截 四条,每一条都从**已有事实**算出来、都指得出一个今天就能补的动作: | 缺口 | 算什么 | 为什么这么算 | |---|---|---| | `no_language` | 概念与谓词**都**为空 | 只有概念没有谓词是进展,不是缺口——否则每注册一个概念就多一条永远擦不掉的抱怨 | | `prose_only_claims` | 非终态命题里没有断言的 | 断言是加法不是门槛,所以这里不是违规,是「还不能被机器比对」 | | `unstructured_facts` | 升格时没带断言、**且有 `hypothesis` 关联**的事实 | 更早的事实补不上断言,算成欠账就是一条永远还不掉的抱怨 | | `untouched_claims` | 没被任何证据碰过的命题,**且这次会话真的跑出过证据** | 计划刚立时所有命题都是「没碰过」,那是起点不是缺口 | 补齐后按原有措辞在运行态卡里逐条说出来:**普通任务一个字都不加**(零成本契约)。 ## 2. 进度 ### 阶段 2 · 认识论骨架:**结论是「本来就有,只是断了」**(已完成,零新对象) 原计划要新增 `Claim` / `Evidence` / `Source` / `Inference` 等一等公民。按奥卡姆剃刀先核了一遍 **已有的链条**——结论是它早就通了,缺的是一条链环和两处可见性: | 链条环节 | 今天的承载物 | 状态 | |---|---|---| | 事实 → 命题 | `fact/promoted` 带 `hypothesis` id | 已有 | | 命题 → 步骤 | `step.tests = {hypothesis, level}` | 已有 | | 步骤 → 证据 | `evidence.recorded` 带 `plan` / `step` | 已有 | | 证据 → 出处 | `evidence.origins`:artifact / audit-card / evaluator-session / approval-record | 已有 | | 观察 → 全文 | `materials` 带 `path` / `bytes` / `step` | 已有 | | **命题身份跨修订** | —— | ✗ **断的**(阶段 0 已修) | 所以这一阶段**没有新增任何对象**,只做了三件事: 1. **修断链**:命题身份在目标修订时被重新签发(见 §0.4)——它正是「实体边指不回命题」的根源; 2. **不引入抽象来源标签**:`direct / extracted / inferred` 这一类标签今天**没有消费方** (图上的出处已经有四类具体指针,比一个抽象标签更能办事),按「不为不存在的消费方引入机制」不做; 3. **把「进不了实体图的那些」如实记账**:真跑里 147 条断言中 52 条是字面宾语、画不出边。 这不是缺对象,是图侧的表达问题——归阶段 4/5,不在这一层造假节点。 ### 阶段 3 · 知识门(已完成) `requireTypedPromotion`(内核缺省 `false` = 断言始终是加法;preset 里 `true`)。 - **拦在哪里**:`CloseGoal(achieved)` 里,**计划已收尾之后、派评估者之前**。 判据与准入同一条顺序纪律:先把能做的前提查完,再花钱请人裁决。 - **判据是结构谓词**:将要升格的那几条命题(与升格循环**逐字同一套谓词**)里, 只要有一条没有断言就拦——不是猜,是可复核的。 - **两条诚实出口**:① 先 `RegisterTerm` / `RegisterPredicate` 立词,再用 `SetGoal` 修订目标 把这些命题连断言重列一遍(主张原文一字不动就用回原 id,验到哪一级接着算),然后重新结案; ② 如实 `CloseGoal(outcome="abandoned")`。**缺口不许被伪装成 support**。 - **为什么是独立一个键**:它与 `minHypotheses` 是两条不同的立场(开工要有候选对比 / 结论要有形态),一个部署完全可以只要前者。与 `blockedThreshold` 同一个模式: 机制在代码里中立,立场写在 preset 里。 验证: ```text $ node test/kernel.test.mjs → 742 通过,0 失败(原 718) $ node test/domain-language.test.mjs → 117 通过,0 失败(原 92) $ node test/preset-composition.test.mjs → 22 通过,0 失败(原 20) $ node tools/verify-truth-table.mjs → 25 项全过(64 条机制,未变) 其余 11 套源侧测试全绿(宿主/客户端两套跑部署产物,留到走查那一步一并同步) ``` 备注: - 知识门在**真跑里第一次会拦下一批正在进行的会话**(它们按老纪律走到了结案)。 这是刻意的:拦下来的消息给的是两条可执行的路,不是一句禁令。 - 机制测试因此**没有被改写**:缺省态下老账本照旧结案,门的行为由专门的用例覆盖。 ### 阶段 1 · 知识模式(已完成) 完成项: - `ui/lib/fold.js`:`deriveKnowledge()`(模式 + 缺口,纯函数)、`derive()` 增 `knowledge`、 `renderCard()` 增知识模式与缺口读数、`view()` 增 `knowledge`;顺手修掉命题身份那两条; - `preset/plugins/clearai-kernel.js`:`SetGoal` 修订复用 id + 发 `hypothesis/superseded`; - `test/domain-language.test.mjs`:知识模式 19 条 + 假设身份 6 条; - `test/kernel.test.mjs`:修订身份 10 条。 验证: ```text $ node test/domain-language.test.mjs → 117 通过,0 失败(原 92) $ node test/kernel.test.mjs → 728 通过,0 失败(原 718) ``` 备注: - 宿主/客户端两套测试跑的是**部署出去的那一份**,改完源之后要 `node tools/build-package.mjs && node tools/install-native.mjs --profile web` 才追得上; 本阶段只跑了源侧三套,部署同步留到真浏览器走查那一步一起做。 ### 阶段 4+5 · 统一的图 DTO 与图带的交互(已完成) **判据只有一处:投影。** 客户端不再自己按 `kind` 数组重推一遍「这一层画不画」——那正是 「同一份账本、两处各解释一次」的老毛病。新增三个字段,全部由 `graphProjection()` 算: | 字段 | 在哪 | 为什么 | |---|---|---| | `layer` | 节点与边 | `'ontology'` / `'entity'`。客户端按它分层,不再猜 | | `degree` | 节点 | 连接度。**先画谁**的依据,而且同一份账本永远算同一次序(纯函数,不是渲染时机的函数) | | `claim` | 断言边 | 事实指回产出它的那条命题。旧事实没有这条关联时如实给 `null`——不编一个 | 图带(`ui/lib/client.js` 的 `GraphBand`)重写,四件事直接对着「图不清晰」: 1. **不再有断边**。旧写法切数组前 40 个,被切掉的节点仍连着边 ⇒ 图上出现没有端点的边。 现在按 `degree` 排序取前 N,**只画两端都在场上的边**;截断时说清依据是连接度, 并说明未画入的节点不参与成边。选中/悬停节点的**邻域整个在场**——否则点了 A、与 A 有关的边却不画。 2. **交互走 Pointer Events + pointer capture**(鼠标 / 触摸 / 笔同一条路); 按下到抬起**位移超过 4px 才算拖动**,否则算点击——不然「想点节点」会变成「拖歪了」。 节点可拖、画布可平移、缩放**以光标为中心**。 3. **悬停与选中真的看得见**:邻接边高亮加粗,节点描边加亮;冲突节点与冲突边一律红色; 边标签只在缩放够近或有人问到时才画(常驻标签是这张图最吵的东西)。 4. **键盘可用**:`Tab` 进图,方向键平移,`+` / `-` 缩放,`Esc` 清空选中; `role="img"` + `aria-label`。 边的详情卡现在给出**认识论读数**:事实 id · 等级 · 状态 · **命题 id** · 边界, 并能一键按此谓词过滤。 验证: ```text $ node test/domain-language.test.mjs → 127 通过,0 失败(阶段 3 后 117) $ node test/client.test.mjs → 217 通过,0 失败(阶段 3 后 214) $ bash test/run.sh → 15 套全绿 内核:742 · 宿主:99 · 外脑:40 · 客户端:217 · 领域语言:127 · 本体:96 · 真值表:24 · 状态机:43 · 文档:16 · 注释:7 · 边界:15 · 组合:22 · 段:13 · 长测:41 · 不变量:27 ``` 备注: - 夹具必须跟着生产者走:测试里那份手写的图投影加了 `layer` / `degree` / `claim`。 夹具落后于生产者时,它测的就不再是要跑的那份代码。 - **部署同步有个坑**:`pnpm add file:` 按版本号判「已是最新」,同一个 `0.2.0` 重新构建之后**不会**替换已装的那份。要么先 `rm -rf node_modules/clearai-dsh` 再 `pnpm install`, 要么提版本号。这一步不做,宿主/客户端两套测试会以「与源不一致」如实报红。 - 注释风格套件有一道棘轮(不许写事故叙事),本轮踩过一次并已改掉。 ### 真浏览器走查(待做) `node tools/build-package.mjs && node tools/install-native.mjs --profile web`,重启 `dsh web`,然后逐项看: 图带两层切换、点节点过滤、拖节点、拖画布、光标居中缩放、悬停高亮、边详情里的命题 id、 键盘(方向键 / `+` `-` / `Esc`)、以及**拖动与缩放一个字节都不进账本**(账本重放后回到同一张图)。 ### 真跑验收(阶段 2–5 的实测证据) `tools/e2e-run.mjs` 用的是**仓库里的预设源**,不用等部署同步——所以这几场跑的就是本分支的代码。 **第一场(普通任务,1 回合)**:「算 1 到 5 的平方和,判断它是不是质数」。 模型只用 `bash` 算完就答,**没有立目标、没有建计划、没有碰本体**——零成本契约成立: 分诊没有把它拖进知识模式。这一场同时修好了 e2e 工具的启动器(见下「工具链」)。 **第二场(`falsification` 剧本,175 秒,38/38 全过)**:两条互斥假设、一条被推翻。 卡上每回合都出现知识模式与缺口读数(实测 28 次),但模型**没有登记任何词汇**—— 因为那条支持证据只到 L2,`promote_at_level` 是 L3,**没有任何命题会升格**, 于是知识门没有可拦的对象。这一场证明:分诊与缺口可见性在真跑里成立; 同时它也说明知识门是**条件门**——没有要升格的命题时它一声不响。 **第三场(把门槛打到 L3,32/32 全过)**:任务要求第一步的 `tests.level = L3`。实测因果链: ```text SetGoal → bash → CreatePlan → RegisterTerm python3_interpreter → RegisterPredicate runs_inline_python(domain/range/functional 齐全) → bash → write → AdvancePlan → write → AdvancePlan → VoidPlanStep → SetGoal(rev2,把断言挂回原命题) → ClosePlan → CloseGoal → fact/promoted ``` 升格出来的那条事实: ```json { "hypothesis": "h-921hs7", "level": "L3", "evidence": ["e-zjtfak"], "scope": "该命令退出码非 0,或 stderr 非空…", "assertions": [{ "predicate": "runs_inline_python", "subject": { "id": "python3", "type": "python3_interpreter" } }] } ``` 链是完整的:事实 → 命题 id → 等级 → 证据 → 边界 → 断言 → 词汇。 模型给谓词写的依据是「两条互斥假设均是对同一解释器同一命令结果的断言,**是该谓词的两个竞争取值**」—— 竞争假设本身成了立词的理由,这正是这套机制想要的形状。 **这一场最值得记的读数**:`claims_untyped` **一次都没有出现**——门没有拦过任何东西。 模型是在**看到卡上的缺口读数之后**主动去立词、并把断言挂回命题的。 也就是说,这一轮里真正改变行为的是**阶段 1 的缺口可见性**,不是阶段 3 的门。 这与设计时的猜测相反(当时认为提示词式的可见性不够、非门不可),所以两条都留着: 可见性负责「让它想做」,门负责「不让它绕过」。 **这一场是一份样本,不是统计。** 它证明这条路走得通,不证明模型每次都走。 知识门本身的两条分支(拦下 / 放行)只有单元测试覆盖,真跑里还没有被触发过。 ### 工具链:真跑起不来,是启动器的问题(顺手修掉) `tools/e2e-run.mjs` 原来只用 `npx --no-install @deepseek-ai/dsh` 起进程。不带版本号时 npx 按 registry 的 `latest` 标签解析,而本地缓存里那一份是**另一个版本**——两边一错开,npx 直接 `canceled due to missing packages and no YES option`,整场以「读不到 profile 行清单」告终, 看起来像产品坏了,其实是启动器挑错了。现在:**PATH 上有 `dsh` 就直接用**(产品安装形态、 或 npx 缓存自己的 bin 都属于这种),没有才退回 npx。断言一条没动,换的只是怎么把进程起起来。 ### 货架所有权:长跑里「评估者与主线各读各的真相」的根因(已修) **症状**(用户那场长跑):独立评估者两次报告 `clear/ontology/domain.md` 是 **7 行、含占位 「(还没有词条…)」、词条数 0**;主线连读三次都是 **96 行、21 概念 9 谓词、md5 稳定**。 两边各自稳定、谁都不像说谎,当时的结案只能靠改判据绕过去。 **根因**:词汇货架与事实货架是**工作区级**读面,而派出去的子会话(评估者 / 侦察 / 执行者) 与主线**共享同一个工作区**、却各持一份投影——子会话的投影里没有主线的词汇。它的 pre-step 也走 `ensureDomainShelf`,于是 `renderShelf(子会话)` 渲染出的正是「(还没有词条…)」占位版, 把共享货架重写掉。文件在「谁最后铺了一拍」之间摆动:评估者读到的是**它自己刚铺的**占位版, 主线读到的是**它自己刚铺的**全量版。评估器从不「缓存错误」——是两个写者在抢同一份读面。 `facts/INDEX.md` 是同一种病(只是空投影的事实渲染成 null,没踩响)。 **方案取舍**(用户挑战过这一点,值得记下推理):第一版把判据摆在**调用点** (`if (!isSpawnedChild(...))`)。被指出后改掉——不变量是「**写**的属性」,不是「哪一次调用」 的属性:摆在 N 个调用点,新增一个就能绕过;摆进**写函数**,它就成了这条写路径的性质本身。 这与仓库自己的先例同构:模型写 `clear/` 是在 `tools/pre-execute` 单一咽喉点结构上拒掉的 (「不可表达优于不可违反」),这次漏的是**内核自己替子会话写**,所以拒绝住进内核的写函数。 奥卡姆同一刀:两个调用点条件 → 一个咽喉点条件。 **刻意不做的两刀**(证据不支持砍那么多): - 不做「子会话整个跳过内核 pre-step」——那边还有回合快照、潜在的人门消息等承重路径; - 不做「词汇改成工作区作用域」(建模级的根治)——那是跨会话 fold / 账本持久化词汇的架构改动, 记进已知缺口,本版不做。新主会话在同一工作区仍会照自己的空投影重铺,这是同一条缺口的一半。 **落点**: - `isSpawnedChild(sessionId)`:读宿主会话头(`parentSession` / `origin === 'subagent'`, 与 `dsh-subagent` 写死 `cwd: parentHeader.cwd` 同一处);问不出来时按「不是子会话」处理 (与 `sessionCwd` 同一个退路方向); - `ensureDomainShelf` / `ensureFactsShelf` **函数体内**第一道即拒; - 行为由 kernel 套件钉(子会话的 pre-step 不改写两份文件 + 主线照常维护 + 反例自检); 结构由 authority-boundary 套件钉(两个写函数体内都含判据——判据不在调用点维护)。 验证: ```text $ node test/kernel.test.mjs → 749 通过,0 失败(阶段 5 后 742) $ node test/authority-boundary.test.mjs→ 18 通过,0 失败(原 15) $ bash test/run.sh → 15 套全绿 ``` **真跑复核**(修复合入后,同一道 L3 任务再跑一场,32/32 全过): ```text 变更直方图:ontology/term_added×1 ontology/predicate_added×1 … audit/dispatched×2 audit/settled×2 … fact/promoted×1(整条链照常走完) ``` - 词汇登记**先于**两次评估者派遣,跑完后 `clear/ontology/domain.md` 完好:1 概念 (`python3`,带依据)+ 1 谓词(`runs_probe_cleanly`,带依据)+ mermaid 图,无占位版回写; - 两个评估者子会话的真实会话头都是 `parentSession` + `origin: "subagent"` + **与主线相同的 cwd**——判据的字段与生产形状逐字对上,而这两个子会话正是修掉之前会抢写货架的那两只手。 ### 知识预检:把「已知」自动送到面前(阶段 1–2) **解决的问题**:真跑里模型不查就开工——不是因为不知道有 \`QueryKnowledge\`,而是因为 **没人提醒它此刻该查**。提示词会被读成建议;卡里的读数不会。 **判据的形状**(\`knowledgePreflight\`,fold 的另一个只读读面): 主张文本(去空白)**包含**词条的 label / id / alias,或命题已有的断言引用了该谓词。 前者是「这个词已经在对话里出现了」,后者是「已经在用了」。命中依据逐条可复核—— 不猜「系统认为相关」。 三条纪律与缺口读数同一套:**只读**(不产生变更)、**有界**(terms/predicates/facts 各 20, 超出如实报 truncated)、**不猜语义**。普通任务 preflight = null,卡里一个字节都不加。 卡里多一行: \`\`\`text · 相关已知(词面命中,可直接引用):概念 furnace_batch(炉次);谓词 oxygen_ppm(氧含量) · 可复用事实 3 条:f-1(L3)、f-2(L3)… \`\`\` 没有命中时如实说「没命中 + 先立词」,不把空读数写成「世上没有」。 **Prompt 与 Skill 同步**: - \`clearai/domain-language\` 增「进入知识模式后的自主操作顺序」:先复用 → 缺什么补什么 → 命题带形态 → 不够精确才查; - \`clearai-loop\` SKILL 增「知识模式的启动协议」决策表;修正「六个对象」为「九个对象」 (与本体声明对齐);**删掉**「失败与不确定」「上下文纪律」两节——那是 DSH 层的工作, 不是循环契约(用户指出)。 验证: \`\`\`text $ node test/domain-language.test.mjs → 140 通过,0 失败(阶段 6 后 127) $ bash test/run.sh → 15 套全绿 $ node tools/e2e-run.mjs (L3 任务,叮嘱「不要先运行任何查询工具」) → 32/32 全过,模型照常自主立词、升格完整事实 \`\`\` 真跑复核:卡里**真的出现**「相关已知」行(会话日志中可数),模型在看到后直接引用 已有 id 立词——没有先跑一次查询。 ### 阶段 5 · 知识 Inspector:图上的对象成为知识入口(已完成) **起点**:图能画出来,却只把节点当索引用——点一下得到「按此过滤」, 读的人仍然不知道那个词是什么意思、凭什么信、谁改过它。 **组装在宿主半**(`ui/lib/fold.js` 的 `inspectGraphSelection(state, selection, derived)`), 五类选择各一条: | 选择 | 给什么 | |---|---| | `concept` | 定义(名字 / 释义 / 别名 / 依据 / 版本)/ 父概念 / 子概念 / 用它的谓词 / 实例 / 相关事实链 / 登记与修订史 | | `predicate` | 主词域(**带标签**,不只给 id)/ 值域 / 单值性 / 用到它的事实 / 主词清单 / 由它产生的冲突 | | `instance` | 类型与它的概念标签 / 入边 / 出边(每条带事实的等级与状态)/ 每条断言的完整链 | | `literal` | 值形态 / 取值 / 单位 / 产生它的事实 | | `edge` | **事实 → 命题(按 id)→ 证据 → 四类出处 → 产生步骤 → 复核态 → 历史** | 三条纪律与其余读面同一套:**只读**(纯函数,组装只有这一处实现,客户端拿不到「自己拼链」的机会)、 **有界**(每类列表带 `truncated`)、**不编**(关联不到如实给 `null`/空——旧事实没有 `hypothesis` 关联时给 `null`,不拿文本相等冒充身份;那条退路只属于折法,不属于读面)。 暴露:**只读路由** `GET /api/clearai/inspector`(选择是动态的,不能预算进投影—— 把 61 节点 / 147 边的完整链都算一遍再推下去是浪费;组装仍在宿主半)。 **客户端**:`GraphInspector` 组件 + **点击语义改变**——点节点 / 边 = 打开详情, 「按此筛选」是详情里的**显式**动作。请求失败或找不到时如实分区说明,**不清空旧详情** (把上一次结果换成一片空白,读的人会以为这条知识没了)。 测试:domain-language 140 → 175(35 条)、client 217 → 226(9 条)、host 99 → 105(6 条)。 其中两条真 bug 是被测试抓出来的:派生命题字段没读(会写「支持到 —」而卡片上有等级)、 实例投影漏了 `ref` 字段。 ### 阶段 6 · 图渲染换成 React Flow(已完成):把轮子还回去 **判断**:自己写图引擎是造轮子。第一性原理下这事很清楚——**视口、命中判定、拖动、 缩放、可见性**都是**已经被解决的问题**,它们的正确实现需要上千行边界处理 (指针捕获、坐标换算、触摸、框选),而这些与 ClearAI 的知识语义毫无关系。 奥卡姆剃刀在这里指向同一个答案:删掉自己那份,用库。 **删掉的**(净减 192 行 client 代码 + 投影里一个字段): | 删掉的东西 | 为什么它不该存在 | |---|---| | 手写 SVG 画布(节点 rect/text、边 line/marker) | React Flow 的节点/边 | | pointer capture 拖动 + 以光标为中心的缩放 + 键盘平移 | 库的 `nodesDraggable` / `panOnDrag` / `zoomOnScroll` | | 客户端「先画谁」的连接度排序 + 40 节点截断 | 视口归库;截断是**用错的工具解决可读性**——它让图缺行少边 | | `graphProjection()` 的 `degree` | 唯一消费者就是上面那段排序;删掉排序后它成了无消费者的派生字段 | | 客户端按 `kind` 数组重推层次 | 投影早就给了 `layer` | **留下的**(库不管、只有 ClearAI 该管的三件): 1. **适配层**:投影 → React Flow 的 `nodes`/`edges`,类型 / 冲突 / 状态的视觉编码; 2. **点击语义**:点节点 · 边 = 打开 Inspector;过滤是 Inspector 里的显式动作; 3. **全屏工作区**:`position: fixed` 的真 overlay + 打开时 `fitView`。 加一条**如实降级**:React Flow 那一行没装上时明说原因,不炸掉整块面板 (其余格子照常可读)——「降级要如实、不要崩」。 **依赖怎么进来**(唯一的技术障碍):客户端半由宿主模块系统装载, `require` 只认**平台种子字**(react / react/jsx-runtime)与**已注册的行**, npm 包解析不到;而本插件的构建只拷文件、没有打包步骤。解法是把 @xyflow/react 打成**插件自己的一个模块行**: ```text tools/build-vendor.mjs esbuild 打包 @xyflow/react(react 外部化) → ui/vendor/xyflow.js(一个自执行的 load() 注册,含 style.css 注入) tools/build-package.mjs 先调 buildVendor(),再把 vendor 与主文件**拼成一份** lib/client.js ``` vendor 是生成物,**不入库**(与 `dist/` 同一条纪律);`npm run build` 自动产出, 缺失时两套测试**如实跳过**而不是假红。CSS 必须跟着走:缺了它图能渲染但布局是散的。 **测试跟着渲染器走**(删掉钉旧实现的断言,钉住新契约): - 删:截断措辞、连接度排序、指针事件驱动(那些是测我们自己的轮子); - 留/加:节点一条不少地交出去、标签与坐标来自投影、库的零件(MiniMap/Controls)用上了、 三根回调接上了、降级如实、**部署件里真有那一行**、vendor 的依赖面只有种子字。 15 套全绿:749/105/40/**237**/173/96/24/43/16/7/18/22/13/41/27 · verify-package 32/0 ### 阶段 6 补记 · 「图组件不可用」的真因,与一条真浏览器检查 两次真机失败,两次都不是图本身的问题,而是**它怎么被装载 / 怎么被判在不在**: | 症状 | 真因 | |---|---| | 第一次:`require('@xyflow/react')` 不认 | 我把 vendor 注册成**运行时动态模块行**——模块系统的 require 只认平台种子字与**启动图里的行** | | 第二次:降级文案成了空括号 `图组件不可用()` | vendor 跑成功了,但守卫写成 `typeof ReactFlow !== 'function'`——而 v12 的 `ReactFlow` 是 `React.forwardRef` **对象**(`Controls`/`MiniMap`/`Background` 是 `memo` 对象)。**一个合法组件被判成「没装上」** | 第二次为什么单测全绿也没抓到:**渲染桩里的组件全是函数**——桩的形状跟真库不一样, 于是「按 typeof 判组件在不在」这个错在桩里永远成立。桩已经把形状改成真库那样 (`forwardRef`/`memo` **对象**,带 `render`),两个渲染桩也都学会认识它们; 把旧守卫放回去,单测当场红 9 条。 **新增一条真浏览器检查**(`tools/browser-graph-check.mjs`,`npm run check:browser`): 真 Chrome + 真 React 19 + 真 vendored React Flow,按宿主的**装载契约** (`window.__ModuleLoader__.load({ id, factory })` → 用两个种子字做 `require` → 调工厂) 把 `GraphBand` 挂到 DOM 上,断言 `.react-flow` / 节点 / 边 / Controls / MiniMap / 样式注入都在。 反例自检过:旧守卫在这个检查里当场红(`canvas=0` + 退化成「图组件不可用」)。 它**不复刻 GUI**(GUI 有鉴权、要一场真实会话),复刻的是**装载那一步**—— 那正是两次翻车的地方。真浏览器里的整体观感仍然要人看(见 known-gaps)。 ### 阶段 6 续 · 力导向布局(知识图谱的原生形状)+ 截图工具 **判断改向**:上一版用「分层 + 折行」排本体图,用户一句话点破——**分层只认得父子这一种关系**, 而知识图谱里大量边是**非层级**的(谓词、断言)。真数据里 21 个概念只有 9 条 `is_a`, 按层排就退化成一排;那不是图的形状,是硬套的形状。知识图谱的原生做法是**力导向** (参考实现 `refs/semantica/explorer` 也是 graphology + ForceAtlas2)。 **落地**: - `graphology` + `graphology-layout-forceatlas2` 打进 vendor(77 KB,零外部依赖), 与 React Flow 同一条路进来(同作用域函数 `__clearaiForce`); - **确定性**:起点用投影给的坐标(按层折行),FA2 从同一起点、同一参数出发必得同一结果 ⇒「同一份账本 ⇒ 同一张图」仍然成立,而且首屏不用先看一团随机散点; - 投影那套按层折行**保留为种子**(它仍是确定性纯函数),参数照参考实现 (`barnesHutOptimize` / `linLogMode` / `gravity 0.14` / `scalingRatio 4.8` / `slowDown 6`)。 **这一轮被真机教出来的四个缺陷**(截图过程中逐个现形): | 症状 | 真因 | |---|---| | 切到全屏后图缩在左上角 | v12 里给 `` 传 ref 拿到的是 **DOM**,不是带 `fitView` 的实例——那次调用静默成了空操作。改用 `onInit` | | 切层/开抽屉后画布空了 | 画布**几何或内容变了**就必须重新适配;React Flow 的视口不会自己跟。改成在这几件事发生时重算(人自己拖过的视角不被夺走) | | 详情一出现刚点开的节点就掉出视野 | 详情摆在画布**下方**会压缩画布高度。改成**右侧抽屉**(计划里本来就写的是侧栏) | | 缩略图是块白板 | 节点尺寸只写在 `style` 里,MiniMap 在测量完成前拿不到宽高就**跳过**节点。补 `initialWidth/initialHeight`,并按类型给缩略图实色 | **顺带修掉一个产品级缺陷:所有时间戳都是 1970**。 `applyMutation` 的 `at` 取 `mutation.at ?? 0`,而内核**不写** `at`——于是凡显示时间的地方 (Inspector 的历史、计划开合、证据时刻)一律 1970-01-01。修法:时间属于**日志里的那一刻** (`event.time`),由折法在入口盖上去(变更自己带了就以它为准;旧日志没有时间就不假装知道)。 内核因此不必读时钟,折法仍然只有一个入口。 **新增两个工具**: - `tools/graph-shots.mjs`(`npm run shots`):用**真实会话的投影**给图谱拍照—— 折一份真日志 → 拿折出来的 `lexicon.graph` → 喂给真组件在真 Chrome 里渲染 → 连 Inspector 的读数都由真的 `inspectGraphSelection` 回答。素材与产品同源,可复现; - `tools/browser-graph-check.mjs`(`npm run check:browser`):真 Chrome 里的图谱渲染检查(见上一节)。 **素材**(`docs/shots/zh/`,取自用户最初那场 JEPA 真实会话:21 概念 / 9 谓词 / 23 节点 18 边): `jepa-band.png`(图带)· `jepa-ontology.png`(工作区本体图)· `jepa-inspector.png`(点概念看详情)。 验证:15 套全绿(749/105/40/240/177/96/24/43/16/7/18/22/13/41/27)· 浏览器检查 7/7。