clearai-dsh 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/fold.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * 与预设内核的契约:`meta = { kind: 'clearai', v: 1, mutation: { t, ... } }`。
17
17
  * 词汇表由本文件的 `applyMutation` 定义;预设侧只负责产出,不负责解释。
18
18
  */
19
- import { applyLexiconMutation, deriveConflicts, emptyLexicon, formatAssertion, graphProjection, lexiconHealth, normalizeLexicon } from './domain-language.js'
19
+ import { applyLexiconMutation, deriveConflicts, emptyLexicon, formatAssertion, graphProjection, lexiconHealth, normalizeLexicon, objectKey, VALUE_FORMS } from './domain-language.js'
20
20
 
21
21
  /** 世界线分支状态的秩。秩只增;一旦出现 adopted,整个分叉的分支状态冻结。 */
22
22
  const BRANCH_RANK = { exploring: 0, evaluated: 1, adopted: 2, pruned: 2 }
@@ -285,10 +285,30 @@ export function applyMutation(state, mutation) {
285
285
  }
286
286
  }
287
287
  for (const hypothesis of mutation.hypotheses ?? []) {
288
+ /**
289
+ * **一个 id 只对应一条主张,永远**(与领域词汇那条「语义变化必须换 id」同一条纪律)。
290
+ *
291
+ * 真跑里这条缺失的代价很大:修订目标时内核给**同一句话**发了新 id,而这里照单全收,
292
+ * 于是一张运行态卡上出现 4 条主张的 6~8 行读数——同一句话挂着两个 id、各报一个状态
293
+ * (一个「已支持」、另一个「未触及」),模型得自己去调和两份打架的读数。
294
+ *
295
+ * 所以这里按 id **upsert**,但**只更新不改变身份的那些字段**(断言、推翻条件、版本):
296
+ * 修订时给一条老命题补上断言是正当的更新;而换掉它的 `claim` 就是拿旧 id 说新话——
297
+ * 那一律拒(内核已在源头保证换主张必换 id),终态(`superseded` / `refuted`)同样黏住。
298
+ */
299
+ const claim = String(hypothesis.claim ?? '')
300
+ const known = next.hypotheses.find((item) => item.id === hypothesis.id)
301
+ if (known !== undefined) {
302
+ if (String(known.claim ?? '') !== claim) continue
303
+ known.refute_when = hypothesis.refute_when
304
+ known.version = hypothesis.version ?? known.version
305
+ if (Array.isArray(hypothesis.assertions)) known.assertions = clone(hypothesis.assertions)
306
+ continue
307
+ }
288
308
  next.hypotheses.push({
289
309
  id: hypothesis.id,
290
310
  goal: mutation.id,
291
- claim: hypothesis.claim,
311
+ claim,
292
312
  refute_when: hypothesis.refute_when,
293
313
  status: 'proposed',
294
314
  version: hypothesis.version ?? 1,
@@ -813,6 +833,22 @@ export function applyMutations(state, mutations) {
813
833
  return next
814
834
  }
815
835
 
836
+ /**
837
+ * **给一批变更盖上事件时间**。
838
+ *
839
+ * 产出方(内核)只写「发生了什么」,不读时钟;时间属于**日志里的那一刻**(`event.time`)。
840
+ * 变更自己带了 `at` 就以它为准(有些路径确实知道更准的时刻),否则用事件时间。
841
+ * 事件也没时间(旧日志/合成事件)时**不动它**——那里本来就是 0,不假装知道。
842
+ */
843
+ function stampAt(mutations, time) {
844
+ if (time === null || !Array.isArray(mutations) || mutations.length === 0) return mutations
845
+ return mutations.map((mutation) => {
846
+ if (mutation === null || typeof mutation !== 'object') return mutation
847
+ if (typeof mutation.at === 'number') return mutation
848
+ return { ...mutation, at: time }
849
+ })
850
+ }
851
+
816
852
  /**
817
853
  * 会话日志事件 → 状态。这是投影的入口:除了工具结果里的变更记录,
818
854
  * 还吃三条**关于过程本身的事实**——
@@ -893,7 +929,7 @@ export function applyEvent(state, event) {
893
929
  try {
894
930
  const payload = JSON.parse(facts.text)
895
931
  if (Array.isArray(payload?.mutations) && payload.mutations.length > 0) {
896
- next = applyMutations(next, payload.mutations)
932
+ next = applyMutations(next, stampAt(payload.mutations, typeof event.time === 'number' ? event.time : null))
897
933
  touched = true
898
934
  }
899
935
  } catch {
@@ -1034,7 +1070,17 @@ export function applyEvent(state, event) {
1034
1070
  const meta = event.data === undefined || event.data === null ? undefined : event.data.meta
1035
1071
  let next = state
1036
1072
  if (meta !== null && meta !== undefined && typeof meta === 'object' && meta.kind === MUTATION_KIND) {
1037
- next = applyMutations(next, Array.isArray(meta.mutations) ? meta.mutations : meta.mutation === undefined ? [] : [meta.mutation])
1073
+ /**
1074
+ * **时间由折法盖上去,不由产出方写**:内核只写「发生了什么」,不知道也不该关心
1075
+ * 这条记录落在日志的哪一刻;而事件本身带着 `time`,那才是权威。
1076
+ *
1077
+ * 不盖的后果是全局性的:每条变更的 `at` 都取 `mutation.at ?? 0`,于是凡是显示时间的
1078
+ * 地方——Inspector 的历史、计划开合、证据时刻——一律是 **1970-01-01**。
1079
+ * 盖在这里(而不是让内核每条都写一遍时间)同时满足两件事:内核不必读时钟,
1080
+ * 折法仍然只有一个入口。
1081
+ */
1082
+ const stamped = stampAt(Array.isArray(meta.mutations) ? meta.mutations : meta.mutation === undefined ? [] : [meta.mutation], typeof event.time === 'number' ? event.time : null)
1083
+ next = applyMutations(next, stamped)
1038
1084
  }
1039
1085
  if (next.inFlight !== null) {
1040
1086
  const cleared = next === state ? clone(state) : next
@@ -1048,6 +1094,614 @@ export function applyEvent(state, event) {
1048
1094
 
1049
1095
  // ── 派生:阶段 / 完成度 / 假设状态 / 世界线阶段,全部现算,状态里不存 ──────────
1050
1096
 
1097
+ /** 假设的终态:到了这里就不再是「还要继续做的活」。 */
1098
+ const TERMINAL_HYPOTHESIS = new Set(['refuted', 'superseded', 'retracted'])
1099
+
1100
+ /**
1101
+ * **知识模式(分诊)与缺口读数。**
1102
+ *
1103
+ * 判据是**结构的,不是词法的**:这里不猜「这句话像不像研究任务」。立约(`SetGoal`)并用
1104
+ * 相互竞争的假设登记它,是模型自己已经做出的那次承诺——它意味着这件事要跨多轮、要有依据、
1105
+ * 要有可复核的结论。日常问答从不立约,于是从不进这一档。用词面启发式去猜任务类型,
1106
+ * 正是真跑里评估者抓到的那类「硬编码比例」的老路:猜错了没人能复核,而结构判据可以。
1107
+ *
1108
+ * 缺口(`gaps`)是**读数,不是拦截**,也不新增账本:每一条都从已有事实算出来,并且指得出
1109
+ * 一个今天就能补的动作。它们要进运行态卡——模型每一步唯一读到的那个窗口。让缺口每回合可见,
1110
+ * 比让提示词多叮嘱一句可靠:提示词会被读成建议,而卡里的读数不会。
1111
+ */
1112
+ export function deriveKnowledge(state, hypotheses, factRows, lexicon) {
1113
+ const goal = state?.goal ?? null
1114
+ const open = goal !== null && goal.status !== 'achieved' && goal.status !== 'abandoned'
1115
+ const registered = hypotheses.filter((item) => !TERMINAL_HYPOTHESIS.has(item.status))
1116
+ /**
1117
+ * 模式的**唯一**判据:目标还开着,而且它带着登记过的假设。
1118
+ *
1119
+ * 为什么不是「目标存在」就够:`minHypotheses` 是部署自己的产品立场(内核缺省是 0 = 机制中立)。
1120
+ * 一个把下限设成 0 的部署明确说了「我不要这条纪律」,那时它也不该收到知识模式的读数。
1121
+ */
1122
+ const mode = open && registered.length > 0 ? 'knowledge' : 'ordinary'
1123
+ const terms = Array.isArray(lexicon?.terms) ? lexicon.terms : []
1124
+ const predicates = Array.isArray(lexicon?.predicates) ? lexicon.predicates : []
1125
+ const gaps = []
1126
+ if (mode === 'knowledge') {
1127
+ /**
1128
+ * ① 语言还没立起来:跨轮复用与「按概念取用已知」都要求先有词汇。
1129
+ * 判据是**两个都空**——只有概念没有谓词时,关系还说不出来,但语言已经开张了,
1130
+ * 那是进展不是缺口(否则每注册一个概念就多一条永远擦不掉的抱怨)。
1131
+ */
1132
+ if (terms.length === 0 && predicates.length === 0) {
1133
+ gaps.push({
1134
+ code: 'no_language',
1135
+ count: registered.length,
1136
+ detail: `${registered.length} 条在验命题,但还没有任何概念与谓词:换一轮只能靠重读散文取用它们`,
1137
+ })
1138
+ }
1139
+ /**
1140
+ * ② 只有散文主张的命题:断言是可选的(「加法,不是门槛」),所以这里**不是违规**,
1141
+ * 而是「这条主张还不能被机器比对」。只算非终态的:被推翻/被替代的不再欠这一笔。
1142
+ */
1143
+ const proseOnly = registered.filter((item) => !Array.isArray(item.assertions) || item.assertions.length === 0)
1144
+ if (proseOnly.length > 0) {
1145
+ gaps.push({
1146
+ code: 'prose_only_claims',
1147
+ count: proseOnly.length,
1148
+ detail: `${proseOnly.length} 条在验命题只有散文主张:两条结论是不是在说同一件事,只能靠重读判断`,
1149
+ })
1150
+ }
1151
+ /**
1152
+ * ③ **升格时没带断言**。只算 0.2.0 那条路走出来的事实(`hypothesis` 已关联)——
1153
+ * 更早的事实补不上断言(见已知缺口),把它们算成欠账就是一条永远还不掉的抱怨。
1154
+ */
1155
+ const unstructured = factRows.filter((fact) => typeof fact.hypothesis === 'string' && fact.hypothesis !== '' && (!Array.isArray(fact.assertions) || fact.assertions.length === 0))
1156
+ if (unstructured.length > 0) {
1157
+ gaps.push({
1158
+ code: 'unstructured_facts',
1159
+ count: unstructured.length,
1160
+ detail: `${unstructured.length} 条已升格事实没有断言:它们进不了实体图,也不能按概念取用`,
1161
+ })
1162
+ }
1163
+ /**
1164
+ * ④ **从没被证据碰过的命题**。只在这次会话已经真的跑出过证据之后才报——计划刚立、
1165
+ * 一步都还没走时,所有假设都是「没碰过」,那是正常的起点而不是缺口。
1166
+ * 它和结案时那条 `unjudged` 是同一条先例:**不逼裁决,但不许把「没看过」写成「没问题」**。
1167
+ */
1168
+ if (Array.isArray(state?.evidence) && state.evidence.length > 0) {
1169
+ const untouched = registered.filter((item) => item.refutations + item.inconclusive === 0 && item.supportedLevel === null)
1170
+ if (untouched.length > 0) {
1171
+ gaps.push({
1172
+ code: 'untouched_claims',
1173
+ count: untouched.length,
1174
+ detail: `${untouched.length} 条在验命题还没有任何证据碰过(支持 / 推翻 / 无法判定都算碰过)`,
1175
+ })
1176
+ }
1177
+ }
1178
+ }
1179
+ return {
1180
+ mode,
1181
+ /** 一句人话:为什么进了这一档(或为什么没进)。它进卡,所以不许写成术语。 */
1182
+ why:
1183
+ mode === 'knowledge'
1184
+ ? `目标还开着,带着 ${registered.length} 条登记过的命题——这是跨轮、要依据、要可复核结论的活`
1185
+ : open
1186
+ ? '目标还开着,但没有登记命题:按普通任务推进'
1187
+ : '没有开着的目标:按普通任务推进',
1188
+ gaps,
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * **知识预检(preflight)**:进入知识模式那一刻,把「已知」主动送到模型面前。
1194
+ *
1195
+ * 解决的问题:真跑里模型不查就开工——不是因为不知道有 \`QueryKnowledge\`,
1196
+ * 而是因为**没人提醒它此刻该查**。提示词会被读成建议;卡里的读数不会。
1197
+ * 于是这里把相关性判断做成投影:从当前目标与命题的文本出发,圈出**有界**的一组
1198
+ * 已有词汇、事实与冲突,随运行态卡注入。模型只在这份摘要不够用时才需要精确查询。
1199
+ *
1200
+ * 三条纪律(与缺口读数同一套):
1201
+ * · **只读**:不产生变更,不新增状态——它就是 \`derive\` 的另一个读面;
1202
+ * · **有界**:terms / predicates / facts 各有上限,超出如实说「还有 N 个未列出」;
1203
+ * · **不猜语义**:相关性是**词面命中**(label / id / alias 出现在主张文本里),
1204
+ * 不是「系统认为相关」——命中的依据逐条可复核。
1205
+ *
1206
+ * 判据的形状:主张文本(去空白)包含词条的 label / id / alias(去空白),
1207
+ * 或者命题已有的断言引用了该谓词。前者是「这个词已经在对话里出现了」,后者是「已经在用了」。
1208
+ */
1209
+ export function knowledgePreflight(state, derived) {
1210
+ const knowledge = derived?.knowledge ?? deriveKnowledge(state, derived?.hypotheses ?? [], derived?.factRows ?? [], derived?.lexicon)
1211
+ if (knowledge.mode !== 'knowledge') return null
1212
+ const terms = Array.isArray(derived?.lexicon?.terms) ? derived.lexicon.terms : []
1213
+ const predicates = Array.isArray(derived?.lexicon?.predicates) ? derived.lexicon.predicates : []
1214
+ const hypotheses = Array.isArray(derived?.hypotheses) ? derived.hypotheses : []
1215
+ const factRows = Array.isArray(derived?.factRows) ? derived.factRows : []
1216
+ const conflicts = Array.isArray(derived?.conflicts) ? derived.conflicts : []
1217
+ /** 命题的文本面:目标主张 + 每条命题的主张(去空白后做包含判断)。 */
1218
+ const claimText = [state?.goal?.claim, ...hypotheses.map((item) => item.claim)].filter((text) => typeof text === 'string' && text !== '').map((text) => String(text).replace(/\s+/g, ''))
1219
+ /** 命题已经引用的谓词(断言在假设上时就该算「在用」)。 */
1220
+ const usedPredicates = new Set(hypotheses.flatMap((item) => (Array.isArray(item.assertions) ? item.assertions : [])).map((assertion) => String(assertion?.predicate ?? '')))
1221
+ const hits = (entry) => {
1222
+ const candidates = [entry.id, entry.label, ...(Array.isArray(entry.aliases) ? entry.aliases : [])].filter((text) => typeof text === 'string' && text !== '').map((text) => String(text).replace(/\s+/g, ''))
1223
+ return candidates.some((candidate) => claimText.some((text) => text.includes(candidate)))
1224
+ }
1225
+ const LIMIT = 20
1226
+ const matchedTerms = terms.filter((term) => hits(term) && term.status !== 'deprecated')
1227
+ const matchedPredicates = predicates.filter((predicate) => usedPredicates.has(predicate.id) || hits(predicate))
1228
+ const matchedTermIds = new Set(matchedTerms.map((term) => term.id))
1229
+ const matchedPredicateIds = new Set(matchedPredicates.map((predicate) => predicate.id))
1230
+ /** 事实:断言引用了命中的谓词,或主词类型是命中的概念。 */
1231
+ const matchedFacts = factRows.filter((fact) =>
1232
+ (Array.isArray(fact.assertions) ? fact.assertions : []).some((assertion) => matchedPredicateIds.has(String(assertion?.predicate ?? '')) || matchedTermIds.has(String(assertion?.subject?.type ?? ''))),
1233
+ )
1234
+ return {
1235
+ mode: 'knowledge',
1236
+ /** 词面命中的词汇:模型接下来要写的结论大概率会用到它们。 */
1237
+ terms: matchedTerms.slice(0, LIMIT).map((term) => ({ id: term.id, label: term.label, gloss: term.gloss, parent: term.parent ?? null, status: term.status, uses: term.uses ?? 0, basis: term.basis ?? null })),
1238
+ termsTruncated: Math.max(0, matchedTerms.length - LIMIT),
1239
+ predicates: matchedPredicates.slice(0, LIMIT).map((predicate) => ({ id: predicate.id, label: predicate.label, domain: predicate.domain, range: predicate.range, functional: predicate.functional === true, status: predicate.status, uses: predicate.uses ?? 0, basis: predicate.basis ?? null })),
1240
+ predicatesTruncated: Math.max(0, matchedPredicates.length - LIMIT),
1241
+ /** 命中的既有事实(可复用的「已知」)。 */
1242
+ facts: matchedFacts.slice(0, LIMIT).map((fact) => ({ id: fact.id, text: fact.text, level: fact.level ?? null, scope: fact.scope ?? null, hypothesis: fact.hypothesis ?? null, review: fact.review?.decision ?? null })),
1243
+ factsTruncated: Math.max(0, matchedFacts.length - LIMIT),
1244
+ /** 冲突:有就带上(它们约束「哪些结论还不能随便写)。 */
1245
+ conflicts: conflicts.map((conflict) => ({ predicate: conflict.predicate, subject: conflict.subject })),
1246
+ /** 缺口读数(与卡里同一份:`deriveKnowledge` 的 gaps)。 */
1247
+ gaps: knowledge.gaps,
1248
+ }
1249
+ }
1250
+
1251
+ // ── 知识 Inspector:一个选择 → 它的定义 / 关系 / 断言 / 证据链 / 历史 ──────────
1252
+
1253
+ /**
1254
+ * **图上的一个选择(节点或边),它的全部知识读数。**
1255
+ *
1256
+ * 为什么要有它:图能画出来不等于图是知识入口。点击一个节点却只得到「按此过滤」,
1257
+ * 读的人仍然不知道这个词是什么意思、凭什么信、谁改过它。这一份投影把那条链补齐:
1258
+ *
1259
+ * ```text
1260
+ * 概念 → 定义 / 父概念 / 子概念 / 用它的谓词 / 相关实例 / 相关事实 / 历史
1261
+ * 谓词 → 主词域 / 值域 / 单值性 / 用它的事实 / 由它产生的冲突 / 历史
1262
+ * 实例 → 类型 / 入边 / 出边 / 每条断言的完整链 / 冲突
1263
+ * 字面值 → 值形态 / 取值 / 产生它的事实 / 出处
1264
+ * 断言边 → 事实 → 命题 → 证据 → 出处 → 产生步骤 → 复核态
1265
+ * ```
1266
+ *
1267
+ * 三条纪律与其余读面同一套:
1268
+ * · **只读**:纯函数,不产生变更、不新增状态;判据只有这一处(客户端不许自己拼链);
1269
+ * · **有界**:每一类列表都有上限,超出如实报 truncated;
1270
+ * · **不编**:关联不到就如实给空,不猜「大概相关」——旧账本没有 `hypothesis` 关联时
1271
+ * 给 `null`,不拿文本相等去冒充身份(那条退路只属于折法,不属于读面)。
1272
+ *
1273
+ * 选择可以是 `{ kind, id }`,也可以直接给图上的 id 串(前缀即类型,见 `normalizeSelection`)。
1274
+ */
1275
+ export function inspectGraphSelection(state, selection, derived) {
1276
+ const picked = normalizeSelection(selection)
1277
+ if (picked === null) return null
1278
+ const rows = derived ?? derive(state)
1279
+ const context = inspectorContext(state, rows)
1280
+ switch (picked.kind) {
1281
+ case 'concept':
1282
+ return inspectConcept(context, picked.id)
1283
+ case 'value_type':
1284
+ return inspectValueType(context, picked.id)
1285
+ case 'predicate':
1286
+ return inspectPredicate(context, picked.id)
1287
+ case 'instance':
1288
+ return inspectInstance(context, picked.id)
1289
+ case 'literal':
1290
+ return inspectLiteral(context, picked.id)
1291
+ case 'edge':
1292
+ return inspectEdge(context, picked.id)
1293
+ default:
1294
+ return null
1295
+ }
1296
+ }
1297
+
1298
+ /** 列表上限:读面有界,超出如实报 truncated(与知识预检同一套纪律)。 */
1299
+ const INSPECT_LIMIT = 30
1300
+ const cut = (list, limit = INSPECT_LIMIT) => ({ rows: list.slice(0, limit), truncated: Math.max(0, list.length - limit) })
1301
+
1302
+ /**
1303
+ * 把选择归一到 `{ kind, id }`。
1304
+ *
1305
+ * 图上的 id 串自带类型前缀(`graphProjection` 定的形状),所以两种调用方式都认:
1306
+ * 客户端手上有节点对象时给 `{ kind, id }` 最稳;只拿到 id 串时按前缀解析。
1307
+ * 解析不出来**返回 null**,不猜一个类型——猜错会让 Inspector 端出一份别的东西的定义。
1308
+ */
1309
+ function normalizeSelection(selection) {
1310
+ const raw = typeof selection === 'string' ? { id: selection } : selection
1311
+ if (raw === null || typeof raw !== 'object') return null
1312
+ const id = String(raw.id ?? '')
1313
+ if (id === '') return null
1314
+ /** 调用方声明的类型优先认;声明了就必须对得上,不许「说 A 给 B」。 */
1315
+ const declared = typeof raw.kind === 'string' ? raw.kind : ''
1316
+ const ok = (kind) => declared === '' || declared === kind
1317
+ if (id.startsWith('term:')) return ok('concept') ? { kind: 'concept', id: id.slice('term:'.length) } : null
1318
+ if (id.startsWith('is_a:')) return ok('concept') ? { kind: 'concept', id: id.slice('is_a:'.length) } : null
1319
+ if (id.startsWith('form:')) return ok('value_type') ? { kind: 'value_type', id: id.slice('form:'.length) } : null
1320
+ if (id.startsWith('predicate:')) return ok('predicate') ? { kind: 'predicate', id: id.slice('predicate:'.length) } : null
1321
+ if (id.startsWith('assertion:')) return ok('edge') ? { kind: 'edge', id } : null
1322
+ if (declared === 'concept' || declared === 'predicate' || declared === 'value_type' || declared === 'edge') return { kind: declared, id }
1323
+ /** 剩下的两种由图上的 id 形状定:实例是 `类型|名称`,字面值是 `谓词:值形态:取值`。 */
1324
+ if (id.includes('|')) return ok('instance') ? { kind: 'instance', id } : null
1325
+ if (id.includes(':')) return ok('literal') ? { kind: 'literal', id } : null
1326
+ return null
1327
+ }
1328
+
1329
+ /**
1330
+ * Inspector 的**一次性的索引**:把状态里的几组东西按 id 归好,省得每个分支各建一遍。
1331
+ * 它不缓存、不外传——每次询问建一次,免得读面带上一份会过期的索引。
1332
+ */
1333
+ function inspectorContext(state, derived) {
1334
+ const facts = Array.isArray(derived?.factRows) ? derived.factRows : Array.isArray(state?.facts) ? state.facts : []
1335
+ const lexicon = normalizeLexicon(state?.lexicon)
1336
+ const hypothesisById = new Map((state?.hypotheses ?? []).map((item) => [String(item.id ?? ''), item]))
1337
+ /**
1338
+ * 命题**优先读派生的那一份**:`supportedLevel` / `refutations` / `inconclusive` 都是现算的,
1339
+ * 只读原始状态的话,Inspector 会写「支持到 —」而卡片上明明有等级——同一件事两处说法不同。
1340
+ */
1341
+ const derivedHypothesisById = new Map((Array.isArray(derived?.hypotheses) ? derived.hypotheses : []).map((item) => [String(item.id ?? ''), item]))
1342
+ const evidenceById = new Map((state?.evidence ?? []).map((item) => [String(item.id ?? ''), item]))
1343
+ const materialById = new Map((state?.materials ?? []).map((item) => [String(item.id ?? ''), item]))
1344
+ const stepOf = (planId, stepId) =>
1345
+ (state?.plans ?? []).find((plan) => plan.id === planId)?.steps.find((step) => step.id === stepId) ?? null
1346
+ return { state, derived, facts, lexicon, hypothesisById, derivedHypothesisById, evidenceById, materialById, stepOf, conflicts: Array.isArray(derived?.conflicts) ? derived.conflicts : [] }
1347
+ }
1348
+
1349
+ /** 一条断言的一行人话(与货架 / 卡片同源:`domain-language` 的 `formatAssertion`)。 */
1350
+ const say = (lexicon, assertion) => formatAssertion(lexicon, assertion)
1351
+
1352
+ /**
1353
+ * **一条事实的完整链**:事实 → 命题 → 证据 → 出处 → 产生步骤 → 复核态。
1354
+ *
1355
+ * 这是阶段 5 的核心:图上一条边背后到底站着什么。每一段都来自账本已有的东西,
1356
+ * 没有一段是这里推断出来的。
1357
+ */
1358
+ function factChain(context, fact) {
1359
+ const { lexicon, hypothesisById, derivedHypothesisById, evidenceById, stepOf, conflicts } = context
1360
+ const evidenceIds = Array.isArray(fact?.evidence) ? fact.evidence : []
1361
+ const evidence = evidenceIds
1362
+ .map((id) => evidenceById.get(String(id)))
1363
+ .filter((item) => item !== undefined)
1364
+ .map((item) => {
1365
+ const step = stepOf(item.plan, item.step) ?? null
1366
+ return {
1367
+ id: item.id,
1368
+ verdict: item.verdict,
1369
+ level: item.level,
1370
+ evaluator: item.evaluator,
1371
+ basis: item.basis ?? null,
1372
+ anchor: item.anchor ?? null,
1373
+ at: item.at ?? null,
1374
+ /** 四类出处由账本记账时解析好,这里只搬运。 */
1375
+ origins: Array.isArray(item.origins) ? item.origins : [],
1376
+ refs: Array.isArray(item.refs) ? item.refs : [],
1377
+ /** 材料(id 或路径)也带上摘要,读的人不必再开一次文件。 */
1378
+ materials: (Array.isArray(item.refs) ? item.refs : [])
1379
+ .map((ref) => context.materialById.get(String(ref)))
1380
+ .filter((item2) => item2 !== undefined)
1381
+ .map((item2) => ({ id: item2.id, source: item2.source ?? null, path: item2.path ?? null, note: (item2.note ?? '').slice(0, 200) })),
1382
+ step: step === null ? null : { plan: item.plan, id: item.step, do: step.do ?? null, doneCriteria: step.done_criteria ?? null, status: step.status ?? null },
1383
+ }
1384
+ })
1385
+ const hypothesisId = typeof fact?.hypothesis === 'string' && fact.hypothesis !== '' ? fact.hypothesis : null
1386
+ const hypothesis = hypothesisId === null ? null : derivedHypothesisById.get(hypothesisId) ?? hypothesisById.get(hypothesisId) ?? null
1387
+ const assertions = Array.isArray(fact?.assertions) ? fact.assertions : []
1388
+ return {
1389
+ id: fact?.id ?? null,
1390
+ text: fact?.text ?? null,
1391
+ level: fact?.level ?? null,
1392
+ scope: fact?.scope ?? null,
1393
+ path: fact?.path ?? null,
1394
+ at: fact?.at ?? null,
1395
+ /** `live` / `refuted`(有推翻证据待人裁决)/ `retracted`(人已撤回)。 */
1396
+ status: fact?.review?.decision === 'retracted' ? 'retracted' : fact?.refuted === true ? 'refuted' : 'live',
1397
+ review: fact?.review === undefined || fact?.review === null ? null : { decision: fact.review.decision ?? null, reason: fact.review.reason ?? null, at: fact.review.at ?? null },
1398
+ assertions: assertions.map((assertion) => ({ ...assertion, chip: say(lexicon, assertion) })),
1399
+ hypothesis:
1400
+ hypothesis === null
1401
+ ? null
1402
+ : { id: hypothesis.id, claim: hypothesis.claim ?? null, refuteWhen: hypothesis.refute_when ?? null, status: hypothesis.status ?? null, supportedLevel: hypothesis.supportedLevel ?? null, refutations: hypothesis.refutations ?? 0, inconclusive: hypothesis.inconclusive ?? 0 },
1403
+ evidence,
1404
+ /** 由这条事实参与构成的冲突(只暴露,不裁决——与投影同一句话)。 */
1405
+ conflicts: conflicts
1406
+ .filter((conflict) => (Array.isArray(conflict.sides) ? conflict.sides : []).some((side) => side.fact === fact?.id))
1407
+ .map((conflict) => ({ predicate: conflict.predicate, subject: conflict.subject, sides: conflict.sides.map((side) => ({ fact: side.fact ?? null, value: side.value ?? null })) })),
1408
+ history: factHistory(fact, evidence),
1409
+ }
1410
+ }
1411
+
1412
+ /**
1413
+ * 事实的**留痕**:升格 / 评审决定 / 每条证据什么时候到的。
1414
+ * 它是「状态里真的留着的那几条记录」,不是完整事件流——完整事件流在会话日志里,
1415
+ * 读面不假装自己有它(见 known-gaps)。
1416
+ */
1417
+ function factHistory(fact, evidence) {
1418
+ const events = []
1419
+ if (fact?.at !== undefined && fact?.at !== null) events.push({ kind: 'fact/promoted', at: fact.at, summary: `升格为事实(支持到 ${fact.level ?? '—'})` })
1420
+ if (fact?.review !== undefined && fact?.review !== null) {
1421
+ events.push({
1422
+ kind: 'fact/reviewed',
1423
+ at: fact.review.at ?? null,
1424
+ summary: fact.review.decision === 'retracted' ? '人审查后**撤回**(记录保留)' : '人审查后**维持**(判证据不可靠)',
1425
+ reason: fact.review.reason ?? null,
1426
+ })
1427
+ }
1428
+ for (const item of evidence) events.push({ kind: 'evidence/recorded', at: item.at ?? null, summary: `${item.verdict}(${item.evaluator} · ${item.level})`, reason: item.basis ?? null })
1429
+ return events.filter((event) => event.at !== null).sort((left, right) => left.at - right.at)
1430
+ }
1431
+
1432
+ /** 词汇条目的留痕:登记 / 历次修订 / 废止。登记与修订本来就存在词条里(折法保留的)。 */
1433
+ function entryHistory(entry, label) {
1434
+ const events = []
1435
+ if (entry?.at !== undefined && entry?.at !== null) events.push({ kind: `${label}_added`, at: entry.at, summary: `登记(依据:${entry.basis ?? '—'})`, by: entry.by ?? null })
1436
+ for (const revision of Array.isArray(entry?.revisions) ? entry.revisions : []) {
1437
+ events.push({ kind: `${label}_revised`, at: revision.at ?? null, summary: `修订到 v${revision.version ?? '?'}`, reason: revision.reason ?? null, by: revision.by ?? null })
1438
+ }
1439
+ if (entry?.deprecated !== undefined && entry?.deprecated !== null) {
1440
+ events.push({ kind: `${label}_deprecated`, at: entry.deprecated.at ?? null, summary: '废止(黏性终态,没有复活)', reason: entry.deprecated.reason ?? null, by: entry.deprecated.by ?? null })
1441
+ }
1442
+ return events.filter((event) => event.at !== null || event.kind.endsWith('_deprecated')).sort((left, right) => (left.at ?? 0) - (right.at ?? 0))
1443
+ }
1444
+
1445
+ /** 事实的断言是否碰到这个概念(主词类型 = 概念 id)。 */
1446
+ const assertsOn = (fact, termId) => (Array.isArray(fact?.assertions) ? fact.assertions : []).some((assertion) => String(assertion?.subject?.type ?? '') === termId)
1447
+ const usesPredicate = (fact, predicateId) => (Array.isArray(fact?.assertions) ? fact.assertions : []).some((assertion) => String(assertion?.predicate ?? '') === predicateId)
1448
+
1449
+ /** 概念节点:语言里这个词是什么、它连着谁、哪些事实在用它。 */
1450
+ function inspectConcept(context, termId) {
1451
+ const { lexicon, facts, conflicts, state } = context
1452
+ const term = lexicon.terms.find((item) => item.id === termId)
1453
+ if (term === undefined) return null
1454
+ const children = lexicon.terms.filter((item) => String(item.parent ?? '') === termId)
1455
+ const predicates = lexicon.predicates.filter((item) => String(item.domain ?? '') === termId || String(item.range?.term ?? '') === termId)
1456
+ const relatedFacts = facts.filter((fact) => assertsOn(fact, termId))
1457
+ /** 实例:这一概念下主词出现过的具体对象(从断言投影,与实体图同一份判据)。 */
1458
+ const instances = new Map()
1459
+ for (const fact of relatedFacts) {
1460
+ for (const assertion of Array.isArray(fact.assertions) ? fact.assertions : []) {
1461
+ if (String(assertion?.subject?.type ?? '') !== termId) continue
1462
+ const key = String(assertion.subject.id ?? '')
1463
+ if (key === '') continue
1464
+ if (!instances.has(key)) instances.set(key, { id: `${termId}|${key}`, ref: key, label: key, facts: [] })
1465
+ instances.get(key).facts.push(fact.id ?? null)
1466
+ }
1467
+ }
1468
+ const chain = cut(relatedFacts)
1469
+ return {
1470
+ selection: { kind: 'concept', id: term.id, label: term.label ?? term.id },
1471
+ definition: {
1472
+ id: term.id,
1473
+ label: term.label ?? term.id,
1474
+ gloss: term.gloss ?? null,
1475
+ aliases: Array.isArray(term.aliases) ? term.aliases : [],
1476
+ parent: term.parent === null || term.parent === undefined ? null : String(term.parent),
1477
+ status: term.status ?? 'admitted',
1478
+ version: term.version ?? 1,
1479
+ basis: term.basis ?? null,
1480
+ by: term.by ?? null,
1481
+ at: term.at ?? null,
1482
+ uses: term.uses ?? 0,
1483
+ },
1484
+ relations: {
1485
+ parent: term.parent === null || term.parent === undefined ? null : { id: String(term.parent), label: lexicon.terms.find((item) => item.id === String(term.parent))?.label ?? String(term.parent) },
1486
+ children: children.map((item) => ({ id: item.id, label: item.label ?? item.id, status: item.status ?? 'admitted' })),
1487
+ predicates: predicates.map((item) => ({ id: item.id, label: item.label ?? item.id, domain: item.domain ?? null, range: item.range ?? null, functional: item.functional === true, status: item.status ?? 'admitted' })),
1488
+ instances: [...instances.values()].sort((left, right) => (left.id < right.id ? -1 : 1)).map((item) => ({ id: item.id, ref: item.ref, label: item.label, factCount: item.facts.length })),
1489
+ },
1490
+ facts: chain.rows.map((fact) => factChain(context, fact)),
1491
+ factsTruncated: chain.truncated,
1492
+ conflicts: conflicts
1493
+ .filter((conflict) => String(conflict.subject ?? '').startsWith(`${termId}|`) || predicates.some((predicate) => predicate.id === conflict.predicate))
1494
+ .map((conflict) => ({ predicate: conflict.predicate, subject: conflict.subject, sides: conflict.sides.map((side) => ({ fact: side.fact ?? null, value: side.value ?? null })) })),
1495
+ history: entryHistory(term, 'term'),
1496
+ actions: { canFilter: true, canExpand: true, canEdit: true },
1497
+ /** 词汇是**约定**,不需要证据等级——把它说清楚,免得读的人以为它「没验」。 */
1498
+ note: '概念是约定,不是主张:它不带证据等级。用这个词写下的句子才需要。',
1499
+ }
1500
+ }
1501
+
1502
+ /** 值形态节点:系统固定的五种之一(不独立治理,所以它没有历史)。 */
1503
+ function inspectValueType(context, form) {
1504
+ const { lexicon, facts } = context
1505
+ const known = lexicon.predicates.filter((item) => String(item.range?.form ?? '') === form)
1506
+ if (known.length === 0 && !VALUE_FORMS.includes(form)) return null
1507
+ const used = facts.filter((fact) => (Array.isArray(fact.assertions) ? fact.assertions : []).some((assertion) => String(assertion?.object?.kind ?? '') === form))
1508
+ const chain = cut(used)
1509
+ return {
1510
+ selection: { kind: 'value_type', id: form, label: form },
1511
+ definition: { id: form, label: form, kind: 'value_type', status: 'builtin', gloss: VALUE_FORM_GLOSS[form] ?? null },
1512
+ relations: { predicates: known.map((item) => ({ id: item.id, label: item.label ?? item.id, domain: item.domain ?? null, range: item.range ?? null })) },
1513
+ facts: chain.rows.map((fact) => factChain(context, fact)),
1514
+ factsTruncated: chain.truncated,
1515
+ conflicts: [],
1516
+ /** 值形态由系统固定,不进领域本体、也没有版本史——如实说空,不编一段。 */
1517
+ history: [],
1518
+ actions: { canFilter: true, canExpand: false, canEdit: false },
1519
+ note: '值形态由系统固定(statement / quantity / formula / code / reference),不独立治理,所以没有版本史。',
1520
+ }
1521
+ }
1522
+
1523
+ /** 谓词节点:它允许什么关系、谁在用、用出了哪些冲突。 */
1524
+ function inspectPredicate(context, predicateId) {
1525
+ const { lexicon, facts, conflicts } = context
1526
+ const predicate = lexicon.predicates.find((item) => item.id === predicateId)
1527
+ if (predicate === undefined) return null
1528
+ const used = facts.filter((fact) => usesPredicate(fact, predicateId))
1529
+ const chain = cut(used)
1530
+ const range = predicate.range ?? null
1531
+ return {
1532
+ selection: { kind: 'predicate', id: predicate.id, label: predicate.label ?? predicate.id },
1533
+ definition: {
1534
+ id: predicate.id,
1535
+ label: predicate.label ?? predicate.id,
1536
+ gloss: predicate.gloss ?? null,
1537
+ domain: predicate.domain ?? null,
1538
+ /** 主词域的**名字**也带上:只给 id,读的人还要自己回词汇表里找。 */
1539
+ domainLabel: predicate.domain === null || predicate.domain === undefined ? null : lexicon.terms.find((item) => item.id === String(predicate.domain))?.label ?? String(predicate.domain),
1540
+ range,
1541
+ functional: predicate.functional === true,
1542
+ status: predicate.status ?? 'admitted',
1543
+ version: predicate.version ?? 1,
1544
+ basis: predicate.basis ?? null,
1545
+ by: predicate.by ?? null,
1546
+ at: predicate.at ?? null,
1547
+ uses: predicate.uses ?? 0,
1548
+ },
1549
+ relations: {
1550
+ valueForm: range?.form ?? null,
1551
+ rangeTerm: range?.term ?? null,
1552
+ /** 用这个谓词写下的**断言**(不是事实:一条事实可以带多条断言)。 */
1553
+ assertions: used.length,
1554
+ subjects: [...new Set(used.flatMap((fact) => (Array.isArray(fact.assertions) ? fact.assertions : []).filter((assertion) => String(assertion?.predicate ?? '') === predicateId).map((assertion) => `${String(assertion?.subject?.type ?? '')}|${String(assertion?.subject?.id ?? '')}`)))].map((key) => ({ key, ref: key.split('|')[1] ?? key, type: key.split('|')[0] ?? '' })),
1555
+ },
1556
+ facts: chain.rows.map((fact) => factChain(context, fact)),
1557
+ factsTruncated: chain.truncated,
1558
+ conflicts: conflicts
1559
+ .filter((conflict) => conflict.predicate === predicateId)
1560
+ .map((conflict) => ({ predicate: conflict.predicate, subject: conflict.subject, sides: conflict.sides.map((side) => ({ fact: side.fact ?? null, value: side.value ?? null })) })),
1561
+ history: entryHistory(predicate, 'predicate'),
1562
+ actions: { canFilter: true, canExpand: true, canEdit: true },
1563
+ note: predicate.functional === true ? '单值谓词:同一主词上两条未撤回的确认事实取值不同时,系统给出一对冲突读数(只暴露,不裁决)。' : null,
1564
+ }
1565
+ }
1566
+
1567
+ /** 实例节点:某个具体对象身上挂着的全部断言与它们的链。 */
1568
+ function inspectInstance(context, key) {
1569
+ const { lexicon, facts, conflicts } = context
1570
+ const separator = key.indexOf('|')
1571
+ if (separator < 0) return null
1572
+ const type = key.slice(0, separator)
1573
+ const ref = key.slice(separator + 1)
1574
+ if (ref === '') return null
1575
+ const outgoing = []
1576
+ const incoming = []
1577
+ for (const fact of facts) {
1578
+ for (const assertion of Array.isArray(fact.assertions) ? fact.assertions : []) {
1579
+ const subjectMatches = String(assertion?.subject?.type ?? '') === type && String(assertion?.subject?.id ?? '') === ref
1580
+ const objectMatches = String(assertion?.object?.kind ?? '') === 'instance' && String(assertion?.object?.value ?? '') === ref && (String(assertion?.object?.type ?? '') === type || String(assertion?.object?.type ?? '') === '')
1581
+ if (subjectMatches) outgoing.push({ fact, assertion, direction: 'out' })
1582
+ if (objectMatches) incoming.push({ fact, assertion, direction: 'in' })
1583
+ }
1584
+ }
1585
+ if (outgoing.length === 0 && incoming.length === 0 && !lexicon.terms.some((item) => item.id === type)) return null
1586
+ const touchedFacts = [...new Set([...outgoing, ...incoming].map((item) => item.fact))]
1587
+ const chain = cut(touchedFacts)
1588
+ const edges = [...outgoing, ...incoming].map((item) => ({
1589
+ direction: item.direction,
1590
+ predicate: String(item.assertion?.predicate ?? ''),
1591
+ predicateLabel: lexicon.predicates.find((entry) => entry.id === String(item.assertion?.predicate ?? ''))?.label ?? null,
1592
+ chip: say(lexicon, item.assertion),
1593
+ fact: item.fact.id ?? null,
1594
+ level: item.fact.level ?? null,
1595
+ status: item.fact.review?.decision === 'retracted' ? 'retracted' : item.fact.refuted === true ? 'refuted' : 'live',
1596
+ }))
1597
+ return {
1598
+ selection: { kind: 'instance', id: key, label: ref },
1599
+ definition: {
1600
+ id: key,
1601
+ ref,
1602
+ type: type === '' ? null : type,
1603
+ typeLabel: type === '' ? null : lexicon.terms.find((item) => item.id === type)?.label ?? type,
1604
+ /** 实例**不注册**(从断言投影出来),所以它没有独立生命周期与版本史。 */
1605
+ status: 'projected',
1606
+ },
1607
+ relations: { edges, out: outgoing.length, in: incoming.length },
1608
+ facts: chain.rows.map((fact) => factChain(context, fact)),
1609
+ factsTruncated: chain.truncated,
1610
+ conflicts: conflicts
1611
+ .filter((conflict) => String(conflict.subject ?? '') === key)
1612
+ .map((conflict) => ({ predicate: conflict.predicate, subject: conflict.subject, sides: conflict.sides.map((side) => ({ fact: side.fact ?? null, value: side.value ?? null })) })),
1613
+ history: [],
1614
+ actions: { canFilter: true, canExpand: true, canEdit: false },
1615
+ note: '实例由断言投影出来,不单独注册、也不做实体消解(同名即同节点);它没有自己的版本史。',
1616
+ }
1617
+ }
1618
+
1619
+ /** 字面值节点:一个取值,以及是谁写下它的。 */
1620
+ function inspectLiteral(context, key) {
1621
+ const { lexicon, facts } = context
1622
+ const separator = key.indexOf(':')
1623
+ if (separator < 0) return null
1624
+ const predicateId = key.slice(0, separator)
1625
+ const objectPart = key.slice(separator + 1)
1626
+ const holders = []
1627
+ for (const fact of facts) {
1628
+ for (const assertion of Array.isArray(fact.assertions) ? fact.assertions : []) {
1629
+ if (String(assertion?.predicate ?? '') !== predicateId) continue
1630
+ if (objectKey(assertion.object) !== objectPart) continue
1631
+ holders.push({ fact, assertion })
1632
+ }
1633
+ }
1634
+ if (holders.length === 0) return null
1635
+ const predicate = lexicon.predicates.find((item) => item.id === predicateId) ?? null
1636
+ const touchedFacts = [...new Set(holders.map((item) => item.fact))]
1637
+ const chain = cut(touchedFacts)
1638
+ return {
1639
+ selection: { kind: 'literal', id: key, label: say(lexicon, holders[0].assertion) },
1640
+ definition: {
1641
+ id: key,
1642
+ predicate: predicateId,
1643
+ predicateLabel: predicate?.label ?? predicateId,
1644
+ form: holders[0].assertion?.object?.kind ?? null,
1645
+ value: holders[0].assertion?.object?.value ?? null,
1646
+ unit: holders[0].assertion?.object?.unit ?? null,
1647
+ /** 值形态决定「这个取值是什么东西」:数值 / 公式 / 代码路径 / 引用。 */
1648
+ status: 'projected',
1649
+ },
1650
+ relations: { holders: holders.length, predicateRange: predicate?.range ?? null },
1651
+ facts: chain.rows.map((fact) => factChain(context, fact)),
1652
+ factsTruncated: chain.truncated,
1653
+ conflicts: [],
1654
+ history: [],
1655
+ actions: { canFilter: true, canExpand: true, canEdit: false },
1656
+ note: '字面值是断言里的客体,由事实投影出来;它没有独立生命周期。',
1657
+ }
1658
+ }
1659
+
1660
+ /** 断言边:一条事实边的完整链(图上点击一条边时看的就是它)。 */
1661
+ function inspectEdge(context, edgeId) {
1662
+ const { lexicon, facts, conflicts } = context
1663
+ const parts = edgeId.split(':')
1664
+ if (parts[0] !== 'assertion' || parts.length < 4) return null
1665
+ const factId = parts[1]
1666
+ const predicateId = parts[2]
1667
+ /** 主语键里有 `|`,而它自己可能带 `:`——所以从第 4 段起重新拼回去(不按段数硬切)。 */
1668
+ const subjectKey = parts.slice(3).join(':')
1669
+ const fact = facts.find((item) => String(item.id ?? '') === factId)
1670
+ if (fact === undefined) return null
1671
+ const predicate = lexicon.predicates.find((item) => item.id === predicateId) ?? null
1672
+ const chain = factChain(context, fact)
1673
+ const separator = subjectKey.indexOf('|')
1674
+ return {
1675
+ selection: { kind: 'edge', id: edgeId, label: `${predicate?.label ?? predicateId} · ${fact.text ?? ''}`.slice(0, 120) },
1676
+ definition: {
1677
+ kind: 'assertion',
1678
+ predicate: predicateId,
1679
+ predicateLabel: predicate?.label ?? predicateId,
1680
+ predicateGloss: predicate?.gloss ?? null,
1681
+ domain: predicate?.domain ?? null,
1682
+ range: predicate?.range ?? null,
1683
+ functional: predicate?.functional === true,
1684
+ subject: separator < 0 ? { type: null, id: subjectKey } : { type: subjectKey.slice(0, separator), id: subjectKey.slice(separator + 1) },
1685
+ },
1686
+ /** 一条边就是一条事实:**链在 `facts[0]` 里**,读面不把它拆成两处说。 */
1687
+ facts: [chain],
1688
+ factsTruncated: 0,
1689
+ conflicts: chain.conflicts,
1690
+ history: chain.history,
1691
+ actions: { canFilter: true, canExpand: true, canEdit: false },
1692
+ note: predicate?.functional === true ? '这条边落在单值谓词上:同一主词出现第二个不同取值时会产生冲突(只暴露,不裁决)。' : null,
1693
+ }
1694
+ }
1695
+
1696
+ /** 五种值形态的名字与一句话解释(名字取自 `domain-language` 的枚举,这里只加给人读的说明)。 */
1697
+ const VALUE_FORM_GLOSS = {
1698
+ statement: '短陈述字符串',
1699
+ quantity: '数值 + 单位',
1700
+ formula: '公式源码(LaTeX;本版不做语义解析)',
1701
+ code: '指向工作区里真有的文件路径',
1702
+ reference: '外部引用(文献 / URL / 编号)',
1703
+ }
1704
+
1051
1705
  export function derive(state) {
1052
1706
  const activePlan = state.plans.find((plan) => plan.status === 'active') ?? null
1053
1707
  const closedPlans = state.plans.filter((plan) => plan.status === 'closed')
@@ -1350,6 +2004,8 @@ export function derive(state) {
1350
2004
  lexicon,
1351
2005
  conflicts,
1352
2006
  lexiconIssues,
2007
+ /** 知识模式(分诊)与缺口读数:结构判据,不猜词面(见 `deriveKnowledge`)。 */
2008
+ knowledge: deriveKnowledge(state, hypotheses, factRows, lexicon),
1353
2009
  }
1354
2010
  }
1355
2011
 
@@ -1518,6 +2174,16 @@ export function view(state, sessionId) {
1518
2174
  health: derived.lexiconIssues,
1519
2175
  graph: graphProjection(state),
1520
2176
  },
2177
+ /**
2178
+ * **知识模式(分诊)**:面板据此把「这一格是知识主场还是普通进展」说清楚,
2179
+ * 与卡片读的是同一份派生(判据只有一处:`deriveKnowledge`)。
2180
+ */
2181
+ knowledge: derived.knowledge,
2182
+ /**
2183
+ * **知识预检**:进入知识模式那一刻的相关已知(词汇 / 事实 / 冲突,有界)。
2184
+ * 面板可以画它,内核的 pre-step 把它送进卡——判据与缺口读数一样,只有这一处。
2185
+ */
2186
+ preflight: knowledgePreflight(state, derived),
1521
2187
  /**
1522
2188
  * 续跑窗口的账:面板读它,于是「续跑停着——等裁决」这种**平台说不出来的话**
1523
2189
  * 有地方说。轮数与相位仍然只在原生 dock 上出现(一个事实只在**一块**面上说),
@@ -1838,10 +2504,48 @@ export function renderCard(state) {
1838
2504
  lines.push(` · ${hypothesis.id} [${hypothesis.status}] ${hypothesis.claim} — 推翻条件:${hypothesis.refute_when}${readings}`)
1839
2505
  }
1840
2506
  }
2507
+ /**
2508
+ * **知识模式与缺口**:这一行是「分诊」在模型侧的全部可见面。
2509
+ *
2510
+ * 普通任务**一个字都不加**(提示词与卡片同一条零成本纪律:不立约的会话不长出新字节)。
2511
+ * 进了这一档才报,而且报的是**缺口 + 指得出动作的读数**,不是纪律条文——
2512
+ * 真跑里模型跳过本体步骤不是因为不知道有这件工具,而是因为完成函数里没有它;
2513
+ * 缺口逐回合摆在卡上,那件事才进得了它的视野。
2514
+ */
2515
+ if (derived.knowledge.mode === 'knowledge') {
2516
+ lines.push(`- 知识模式:${derived.knowledge.why}`)
2517
+ /**
2518
+ * **知识预检**:与缺口同一拍出现的相关已知。
2519
+ *
2520
+ * 它解决的是「模型不查就开工」——相关性判断在这里做完(词面命中,逐条可复核),
2521
+ * 模型拿到的是**可以直接引用的 id 清单**,而不是一句「去查 QueryKnowledge」。
2522
+ * 没有命中时如实说「没找到相关的」,不把空读数写成「世上没有」。
2523
+ */
2524
+ const preflight = knowledgePreflight(state, derived)
2525
+ if (preflight !== null) {
2526
+ if (preflight.terms.length > 0 || preflight.predicates.length > 0) {
2527
+ const more = (count) => (count > 0 ? `(还有 ${count} 个未列出)` : '')
2528
+ lines.push(
2529
+ ` · 相关已知(词面命中,可直接引用):概念 ${preflight.terms.map((term) => `${term.id}${term.label === term.id ? '' : `(${term.label})`}`).join('、') || '无'}${more(preflight.termsTruncated)};谓词 ${preflight.predicates.map((predicate) => `${predicate.id}${predicate.label === predicate.id ? '' : `(${predicate.label})`}`).join('、') || '无'}${more(preflight.predicatesTruncated)}`,
2530
+ )
2531
+ if (preflight.facts.length > 0) {
2532
+ lines.push(` · 可复用事实 ${preflight.facts.length} 条:${preflight.facts.map((fact) => `${fact.id}(${fact.level ?? '?'})`).join('、')}${preflight.factsTruncated > 0 ? `(还有 ${preflight.factsTruncated} 条未列出)` : ''}`)
2533
+ }
2534
+ } else {
2535
+ lines.push(' · 相关已知:当前主张文本没有命中任何已有概念 / 谓词——要写断言就先立词(带依据),别把查不到当成不存在')
2536
+ }
2537
+ }
2538
+ if (derived.knowledge.gaps.length === 0) {
2539
+ lines.push(' · 结构完整:语言、断言的命题、带断言的已升格事实、证据覆盖这四项今天都不欠')
2540
+ } else {
2541
+ const total = derived.knowledge.gaps.reduce((sum, gap) => sum + gap.count, 0)
2542
+ lines.push(` · 缺口 ${total} 条(${derived.knowledge.gaps.map((gap) => `${gap.code} ${gap.count}`).join(' · ')}):`)
2543
+ for (const gap of derived.knowledge.gaps) lines.push(` - ${gap.detail}`)
2544
+ }
2545
+ }
1841
2546
  if (plan === null) {
1842
2547
  lines.push('- 当前计划:无活动计划')
1843
- } else {
1844
- if (derived.planConfirmationPending) {
2548
+ } else { if (derived.planConfirmationPending) {
1845
2549
  /**
1846
2550
  * 授权记号的语义:它是**归属**,不是闸门。
1847
2551
  *