clearai-dsh 0.4.0 → 0.5.0

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.
@@ -1026,12 +1026,17 @@ export function describeDomainGraph(lexicon) {
1026
1026
  //
1027
1027
  // 会话账本只活在一次会话里,而研究要跨会话攒下来。所以「攒下来的东西」住在工作区的文件里:
1028
1028
  // · `clear/knowledge/facts/<id>.json`:升格的事实,一条一个文件,只有系统写;
1029
+ // · `clear/knowledge/lessons/<id>.json`:结案时经独立评估核过的经验,一条一个文件,只有系统写;
1029
1030
  // · `clear/ontology/{concepts,relations,entities}/**.json`:本体,模型用原生文件工具直接写。
1030
1031
  // 内核每一拍把这两处的变化折成一条 `workspace/synced` 变更,投影从文件内容派生词汇、实体与
1031
1032
  // 事实——折法仍然只吃账本,文件是账本之外唯一的输入,而且进账本时就是一条可重放的事实。
1032
1033
 
1033
1034
  /** 事实文件所在的目录(相对工作区)。 */
1034
1035
  export const FACTS_DIR = 'clear/knowledge/facts'
1036
+ /** 经验文件所在的目录(相对工作区)。 */
1037
+ export const LESSONS_DIR = 'clear/knowledge/lessons'
1038
+ /** 经验的四类:坑、要核的读数、会骗人的捷径、先验。 */
1039
+ export const LESSON_KINDS = ['trap', 'check', 'shortcut', 'prior']
1035
1040
  /** 本体文件树的根(相对工作区)。 */
1036
1041
  export const ONTOLOGY_DIR = 'clear/ontology'
1037
1042
  /** 本体文件树的三支:目录名 → 文件种类。 */
@@ -1056,7 +1061,11 @@ function hashText(value) {
1056
1061
  */
1057
1062
  export function definitionFingerprint(entry) {
1058
1063
  if (!isPlainObject(entry)) return null
1059
- const meaning = 'range' in entry || 'domain' in entry ? { domain: text(entry.domain), range: isPlainObject(entry.range) ? { term: text(entry.range.term), form: text(entry.range.form), unit: text(entry.range.unit) } : null, functional: entry.functional === true } : { gloss: text(entry.gloss), parent: text(entry.parent) }
1064
+ /** 单位与形状只在写了时进指纹:没写这两格的旧词条,指纹与从前逐字相同。 */
1065
+ const meaning =
1066
+ 'range' in entry || 'domain' in entry
1067
+ ? { domain: text(entry.domain), range: isPlainObject(entry.range) ? { term: text(entry.range.term), form: text(entry.range.form), unit: text(entry.range.unit) } : null, functional: entry.functional === true, ...(text(entry.shape) === '' ? {} : { shape: text(entry.shape) }) }
1068
+ : { gloss: text(entry.gloss), parent: text(entry.parent), ...(text(entry.unit) === '' ? {} : { unit: text(entry.unit) }) }
1060
1069
  return `fnv:${hashText(JSON.stringify(meaning))}`
1061
1070
  }
1062
1071
 
@@ -1114,6 +1123,8 @@ export function classifyWorkspacePath(path) {
1114
1123
  const id = parts.at(-1).slice(0, -'.json'.length)
1115
1124
  const facts = FACTS_DIR.split('/')
1116
1125
  if (parts.length === facts.length + 1 && facts.every((part, index) => parts[index] === part)) return { kind: 'fact', id, dirs: [] }
1126
+ const lessons = LESSONS_DIR.split('/')
1127
+ if (parts.length === lessons.length + 1 && lessons.every((part, index) => parts[index] === part)) return { kind: 'lesson', id, dirs: [] }
1117
1128
  const root = ONTOLOGY_DIR.split('/')
1118
1129
  if (parts.length >= root.length + 2 && root.every((part, index) => parts[index] === part)) {
1119
1130
  const kind = ONTOLOGY_BRANCHES[parts[root.length]]
@@ -1145,6 +1156,26 @@ export function factFromFile(data, path) {
1145
1156
  }
1146
1157
  }
1147
1158
 
1159
+ /**
1160
+ * 经验文件 → 经验行(与 `lesson/recorded` 折出来的同形)。`status: "retracted"` 的不再推送。
1161
+ * 经验改变的是注意力与做法(「这台装置的温度要拿参考读数核」),事实改变的是信念。
1162
+ */
1163
+ export function lessonFromFile(data, path) {
1164
+ if (!isPlainObject(data) || text(data.id) === '' || text(data.text) === '') return null
1165
+ return {
1166
+ id: text(data.id),
1167
+ goal: data.goal ?? null,
1168
+ text: String(data.text),
1169
+ kind: LESSON_KINDS.includes(data.kind) ? data.kind : 'trap',
1170
+ about: Array.isArray(data.about) ? data.about.map(String).filter((item) => item.trim() !== '').slice(0, 6) : [],
1171
+ evidence: typeof data.evidence === 'string' ? data.evidence : null,
1172
+ boundary: typeof data.boundary === 'string' ? data.boundary : null,
1173
+ status: data.status === 'retracted' ? 'retracted' : 'active',
1174
+ path: path ?? null,
1175
+ at: typeof data.at === 'number' ? data.at : null,
1176
+ }
1177
+ }
1178
+
1148
1179
  // ═══ 本体文件树:字段、单文件校验、从文件折出词汇与实体 ═════════════════════════
1149
1180
  //
1150
1181
  // 本体由模型用原生文件工具直接写,住在 `clear/ontology/{concepts,relations,entities}/` 下:
@@ -1164,6 +1195,14 @@ const ONTOLOGY_STATUS = ['active', 'deprecated']
1164
1195
  * 三种本体文件的字段定义。内核把它铺成 `clear/ontology/SCHEMA.json`(只读),
1165
1196
  * 写入时的校验与这份定义是同一份:字段在这里改,两边一起变。
1166
1197
  */
1198
+ /**
1199
+ * **本体只留能产出预期的东西**:概念分三类,度量必须写单位(释义就是口径);关系分四类,
1200
+ * 「影响」带大致形状——预期就从这里来(「温度升,杂质单调升」);「测量」写清怎么核读数。
1201
+ */
1202
+ export const CONCEPT_KINDS = ['category', 'measure', 'phenomenon']
1203
+ export const RELATION_KINDS = ['affects', 'defines', 'measures', 'manifests_as']
1204
+ export const RELATION_SHAPES = ['increasing', 'decreasing', 'peak', 'threshold', 'coupled']
1205
+
1167
1206
  export const ONTOLOGY_SCHEMA = bilingual({
1168
1207
  about: [
1169
1208
  '本体文件的字段定义(系统铺设,只读)。命名规则:X.json 描述节点 X,它的子节点放在同级的 X/ 目录里;id 必须等于文件名。概念目录嵌套 = is_a;实体目录嵌套 = 组成 / 属于;关系可以平铺,也可以分目录(只是分组)。引用一律只写 id,挪目录不断引用。写入时只查单个文件;引用断了、类型对不上等跨文件问题在卡片上提示,升格时才拦。',
@@ -1174,9 +1213,11 @@ export const ONTOLOGY_SCHEMA = bilingual({
1174
1213
  required: {
1175
1214
  id: ['slug:小写字母开头,字母/数字/下划线,≤40,等于文件名', 'slug: starts with a lowercase letter; letters, digits, underscores; ≤40; equals the file name'],
1176
1215
  label: ['给人看的名字', 'the human-readable name'],
1177
- gloss: ['一句话释义:它指什么', 'one-sentence gloss: what it refers to'],
1216
+ gloss: ['一句话释义:它指什么;度量(良率、收率……)写明口径,也就是怎么算', 'one-sentence gloss: what it refers to; for a measure (yield, conversion …) state its definition, that is, how it is computed'],
1178
1217
  },
1179
1218
  optional: {
1219
+ kind: ['category = 类别(反应器、催化剂);measure = 度量(收率、杂质,要写 unit);phenomenon = 现象(漂移、失活)', 'category (reactor, catalyst); measure (yield, impurity; needs unit); phenomenon (drift, deactivation)'],
1220
+ unit: ['单位(度量必填,无量纲写 "1")', 'unit (required for a measure; "1" if dimensionless)'],
1180
1221
  aliases: ['别名数组', 'array of aliases'],
1181
1222
  basis: ['依据:哪份材料让这个词成立', 'basis: which material establishes this term'],
1182
1223
  status: 'active | deprecated',
@@ -1203,6 +1244,9 @@ export const ONTOLOGY_SCHEMA = bilingual({
1203
1244
  },
1204
1245
  optional: {
1205
1246
  gloss: ['一句话释义', 'one-sentence gloss'],
1247
+ kind: ['affects = 影响(一个量怎样随另一个量变);defines = 定义(一个度量怎么算);measures = 测量(哪个仪器测哪个量);manifests_as = 表现为(一个现象在读数上什么样)', 'affects (how one quantity moves with another); defines (how a measure is computed); measures (which instrument reads which quantity); manifests_as (what a phenomenon looks like in the readings)'],
1248
+ shape: [`影响的大致形状:${RELATION_SHAPES.join(' | ')};预期从这里来`, `rough shape of an effect: ${RELATION_SHAPES.join(' | ')}; expectations come from it`],
1249
+ check: ['测量关系:读数怎么核(参考探头、标样、重复点)', 'for a measurement: how to check the reading (reference probe, standard, repeat point)'],
1206
1250
  domain: ['主语必须是哪个概念(下位概念也算)', 'which concept the subject must be (subconcepts count)'],
1207
1251
  functional: ['true = 单值:同一主语只能有一个取值,两个不同取值会被报成冲突', 'true = single-valued: one value per subject; two different values are reported as a conflict'],
1208
1252
  basis: ['依据', 'basis'],
@@ -1226,6 +1270,7 @@ export const ONTOLOGY_SCHEMA = bilingual({
1226
1270
  },
1227
1271
  optional: {
1228
1272
  aliases: ['别名数组', 'array of aliases'],
1273
+ history: ['带日期的经历:[{"at": "YYYY-MM-DD", "what": "校准 / 更换 / 发现漂移", "evidence"?: {"kind", "ref"}}]', 'dated history: [{"at": "YYYY-MM-DD", "what": "calibrated / replaced / drift found", "evidence"?: {"kind", "ref"}}]'],
1229
1274
  relations: [
1230
1275
  '它对外的关系,每条一个出处:{"predicate": 关系 id, "object": 另一个实体的 id} 或 {"predicate": 关系 id, "value": 字面值, "unit"?: 单位};两种都要带 "evidence": {"kind", "ref"}',
1231
1276
  'its outgoing relations, each with a source: {"predicate": relation id, "object": another entity id} or {"predicate": relation id, "value": literal, "unit"?: unit}; both need "evidence": {"kind", "ref"}',
@@ -1247,10 +1292,11 @@ export const ONTOLOGY_SCHEMA = bilingual({
1247
1292
  })
1248
1293
 
1249
1294
  const FIELDS = {
1250
- concept: ['id', 'label', 'gloss', 'aliases', 'basis', 'status', 'replaced_by', 'note'],
1251
- relation: ['id', 'label', 'gloss', 'domain', 'range', 'functional', 'basis', 'status', 'replaced_by', 'note'],
1252
- entity: ['id', 'label', 'type', 'basis', 'provenance', 'aliases', 'relations', 'note'],
1295
+ concept: ['id', 'label', 'gloss', 'kind', 'unit', 'aliases', 'basis', 'status', 'replaced_by', 'note'],
1296
+ relation: ['id', 'label', 'gloss', 'kind', 'shape', 'check', 'domain', 'range', 'functional', 'basis', 'status', 'replaced_by', 'note'],
1297
+ entity: ['id', 'label', 'type', 'basis', 'provenance', 'aliases', 'relations', 'history', 'note'],
1253
1298
  }
1299
+
1254
1300
  const RELATION_FIELDS = ['predicate', 'object', 'value', 'unit', 'evidence', 'note']
1255
1301
 
1256
1302
  /** 一个出处对象的形状问题(没有就返回 null)。 */
@@ -1269,7 +1315,7 @@ function evidenceShape(value, at) {
1269
1315
  */
1270
1316
  export function checkOntologyFile(path, content) {
1271
1317
  const place = classifyWorkspacePath(path)
1272
- if (place === null || place.kind === 'fact') return [tr(`不是本体文件的位置:本体文件要放在 ${ONTOLOGY_DIR}/{concepts,relations,entities}/ 下,扩展名 .json`, `Not an ontology file location: ontology files go under ${ONTOLOGY_DIR}/{concepts,relations,entities}/ with a .json extension`)]
1318
+ if (place === null || place.kind === 'fact' || place.kind === 'lesson') return [tr(`不是本体文件的位置:本体文件要放在 ${ONTOLOGY_DIR}/{concepts,relations,entities}/ 下,扩展名 .json`, `Not an ontology file location: ontology files go under ${ONTOLOGY_DIR}/{concepts,relations,entities}/ with a .json extension`)]
1273
1319
  let data = content
1274
1320
  if (typeof content === 'string') {
1275
1321
  try {
@@ -1298,9 +1344,18 @@ export function checkOntologyFile(path, content) {
1298
1344
  for (const key of ['basis', 'gloss', 'note', 'replaced_by']) if (data[key] !== undefined && data[key] !== null && typeof data[key] !== 'string') problems.push(tr(`${key} 只能是字符串`, `${key} must be a string`))
1299
1345
  if (data.status !== undefined && !ONTOLOGY_STATUS.includes(text(data.status))) problems.push(tr(`status 只能是 ${ONTOLOGY_STATUS.join(' / ')}`, `status must be ${ONTOLOGY_STATUS.join(' / ')}`))
1300
1346
  if (kind === 'concept' && text(data.gloss) === '') problems.push(tr('gloss 必填:一句话说清它指什么,不然引用它的人各读各的', 'gloss is required: one sentence on what it refers to, or everyone who cites it reads it differently'))
1347
+ if (kind === 'concept') {
1348
+ if (data.kind !== undefined && !CONCEPT_KINDS.includes(text(data.kind))) problems.push(tr(`kind 只能是 ${CONCEPT_KINDS.join(' / ')}(类别 / 度量 / 现象)`, `kind must be ${CONCEPT_KINDS.join(' / ')}`))
1349
+ if (data.unit !== undefined && typeof data.unit !== 'string') problems.push(tr('unit 只能是字符串', 'unit must be a string'))
1350
+ if (text(data.kind) === 'measure' && text(data.unit) === '') problems.push(tr('度量要写 unit(无量纲写 "1"):口径和单位不写下来,「定义已变」就无从发现', 'a measure needs a unit (write "1" if dimensionless): without the definition and unit written down, a changed definition cannot be noticed'))
1351
+ }
1301
1352
  if (kind === 'relation') {
1302
1353
  if (data.domain !== undefined && data.domain !== null && !ID_PATTERN.test(text(data.domain))) problems.push(tr('domain 要是一个概念 id', 'domain must be a concept id'))
1303
1354
  if (data.functional !== undefined && typeof data.functional !== 'boolean') problems.push(tr('functional 只能是 true / false', 'functional must be true / false'))
1355
+ if (data.kind !== undefined && !RELATION_KINDS.includes(text(data.kind))) problems.push(tr(`kind 只能是 ${RELATION_KINDS.join(' / ')}(影响 / 定义 / 测量 / 表现为)`, `kind must be ${RELATION_KINDS.join(' / ')}`))
1356
+ if (data.shape !== undefined && !RELATION_SHAPES.includes(text(data.shape))) problems.push(tr(`shape 只能是 ${RELATION_SHAPES.join(' / ')}(单调升 / 单调降 / 有峰 / 阈值 / 与别的量耦合)`, `shape must be ${RELATION_SHAPES.join(' / ')}`))
1357
+ if (data.shape !== undefined && data.kind !== undefined && text(data.kind) !== 'affects') problems.push(tr('shape 只给「影响」(kind=affects)的关系', 'shape only applies to an affects relation (kind=affects)'))
1358
+ if (data.check !== undefined && typeof data.check !== 'string') problems.push(tr('check 只能是字符串', 'check must be a string'))
1304
1359
  const range = data.range
1305
1360
  if (typeof range === 'string') {
1306
1361
  if (!ID_PATTERN.test(text(range))) problems.push(tr('range 写成字符串时要是一个概念 id', 'range given as a string must be a concept id'))
@@ -1319,6 +1374,13 @@ export function checkOntologyFile(path, content) {
1319
1374
  if (text(data.basis) === '') problems.push(tr('basis 必填:实体是观测,要说清哪份材料让它可以被指认', 'basis is required: an entity is an observation, so say which material identifies it'))
1320
1375
  const shape = evidenceShape(data.provenance, 'provenance')
1321
1376
  if (shape !== null) problems.push(tr(`${shape}(实体没有出处就进不了图)`, `${shape} (an entity without a source cannot enter the graph)`))
1377
+ if (data.history !== undefined) {
1378
+ if (!Array.isArray(data.history)) problems.push(tr('history 只能是数组', 'history must be an array'))
1379
+ else
1380
+ data.history.forEach((item, index) => {
1381
+ if (!isPlainObject(item) || !/^\d{4}-\d{2}-\d{2}/.test(text(item.at)) || text(item.what) === '') problems.push(tr(`history[${index}] 要是 {"at": "YYYY-MM-DD", "what": "发生了什么"}`, `history[${index}] must be {"at": "YYYY-MM-DD", "what": "what happened"}`))
1382
+ })
1383
+ }
1322
1384
  if (data.relations !== undefined) {
1323
1385
  if (!Array.isArray(data.relations)) problems.push(tr('relations 只能是数组', 'relations must be an array'))
1324
1386
  else
@@ -1361,7 +1423,7 @@ export function materializeOntology(files) {
1361
1423
  const flag = (path, id, code, detail, severity = 'warning') => problems.push({ path, id, code, detail, severity })
1362
1424
  const entries = Object.entries(isPlainObject(files) ? files : {})
1363
1425
  .map(([path, file]) => ({ path, file, place: classifyWorkspacePath(path) }))
1364
- .filter((item) => item.place !== null && item.place.kind !== 'fact')
1426
+ .filter((item) => item.place !== null && item.place.kind !== 'fact' && item.place.kind !== 'lesson')
1365
1427
  .sort((a, b) => (a.path < b.path ? -1 : 1))
1366
1428
  const terms = []
1367
1429
  const predicates = []
@@ -1389,9 +1451,9 @@ export function materializeOntology(files) {
1389
1451
  const status = text(data.status) === 'deprecated' ? 'deprecated' : 'admitted'
1390
1452
  const parent = place.dirs.length === 0 ? null : place.dirs.at(-1)
1391
1453
  if (place.kind === 'concept') {
1392
- terms.push({ id: place.id, label: text(data.label), gloss: text(data.gloss), aliases: Array.isArray(data.aliases) ? data.aliases.map(String) : [], parent, status, basis: text(data.basis) || null, replacedBy: text(data.replaced_by) || null, path })
1454
+ terms.push({ id: place.id, label: text(data.label), gloss: text(data.gloss), ...(CONCEPT_KINDS.includes(text(data.kind)) ? { kind: text(data.kind) } : {}), ...(text(data.unit) === '' ? {} : { unit: text(data.unit) }), aliases: Array.isArray(data.aliases) ? data.aliases.map(String) : [], parent, status, basis: text(data.basis) || null, replacedBy: text(data.replaced_by) || null, path })
1393
1455
  } else if (place.kind === 'relation') {
1394
- predicates.push({ id: place.id, label: text(data.label), gloss: text(data.gloss), domain: text(data.domain) || null, range: rangeOf(data.range), functional: data.functional === true, status, basis: text(data.basis) || null, replacedBy: text(data.replaced_by) || null, path })
1456
+ predicates.push({ id: place.id, label: text(data.label), gloss: text(data.gloss), ...(RELATION_KINDS.includes(text(data.kind)) ? { kind: text(data.kind) } : {}), ...(RELATION_SHAPES.includes(text(data.shape)) ? { shape: text(data.shape) } : {}), ...(text(data.check) === '' ? {} : { check: text(data.check) }), domain: text(data.domain) || null, range: rangeOf(data.range), functional: data.functional === true, status, basis: text(data.basis) || null, replacedBy: text(data.replaced_by) || null, path })
1395
1457
  } else {
1396
1458
  entityRows.push({ place, path, data, parent })
1397
1459
  }
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, changedDefinitions, materializeOntology, classifyWorkspacePath, deriveConflicts, emptyLexicon, factFromFile, formatAssertion, graphProjection, lexiconHealth, normalizeLexicon, objectKey, termUsage, VALUE_FORMS } from './domain-language.js'
19
+ import { applyLexiconMutation, changedDefinitions, materializeOntology, classifyWorkspacePath, deriveConflicts, emptyLexicon, factFromFile, formatAssertion, lessonFromFile, graphProjection, lexiconHealth, normalizeLexicon, objectKey, termUsage, VALUE_FORMS } from './domain-language.js'
20
20
  import { handleOf, knowledgeView, trustOf } from './knowledge-view.js'
21
21
  import { bilingual, detectLanguage, messageText, tr, withLanguage } from './lang.js'
22
22
 
@@ -60,9 +60,15 @@ export const MUTATION_KIND = 'clearai'
60
60
  * `definitionsChanged`。本体(词汇、实体、实体关系)改为从本体文件折出来,跨文件问题记在
61
61
  * `ontologyProblems`。旧日志里没有这些,折出来就是空的。
62
62
  * v15 → v16:多了 `language`(人说话用的语言,从人的消息折出来):系统写给人的话、面板里的读数都跟着它。
63
+ * v16 → v17:**预期与未解释**:步骤带 `expect`(`step/expected` 也能补写);多了 `anomalies`(未解释项,
64
+ * `anomaly/opened` / `anomaly/resolved` 折进来,评估者报的随 `audit/settled` 一起来);目标带
65
+ * `irreversible`(不可逆动作的命令特征);放行带 `action`。对手判断(`rival` / `split_by`)删了,
66
+ * 旧账里的这两格不再读。
67
+ * v17 → v18:多了 `lessons`(经验:结案时经独立评估核过的「下次要留意什么」,`lesson/recorded` 折进来;
68
+ * 别的会话留下的从 `clear/knowledge/lessons/<id>.json` 经 `workspace/synced` 进来)。
63
69
  * 投影缓存按版本判定,所以旧缓存会被丢弃、从日志重折一遍。
64
70
  */
65
- export const STATE_VERSION = 16
71
+ export const STATE_VERSION = 18
66
72
 
67
73
  /**
68
74
  * **只留台账、不折进视图**的变更类型(词汇表的另一半)。
@@ -89,6 +95,13 @@ export function emptyState() {
89
95
  facts: [],
90
96
  blocks: {},
91
97
  releases: [],
98
+ /**
99
+ * **未解释项**(认识论里的「反常」):和预期或本体对不上的观测。只有三个去处:
100
+ * 被解释(`explained`)、写明理由排除(`ruled_out`)、交给人(`escalated`);没去处的就是开着(`open`)。
101
+ */
102
+ anomalies: [],
103
+ /** **经验**:本会话结案写下的(别的会话的在工作区文件里,派生时合进来)。 */
104
+ lessons: [],
92
105
  /**
93
106
  * **本体形状**:内核随投影下发的那份声明(对象/状态/边/等级)。
94
107
  * 为什么进投影而不是在界面里手抄:面板那一格的页眉要从**声明**生成——
@@ -205,6 +218,8 @@ function appendSteps(plan, rawSteps) {
205
218
  artifacts: raw.artifacts ?? [],
206
219
  done_criteria: raw.done_criteria,
207
220
  tests: normalizeTests(raw.tests),
221
+ /** 动手前写下的预期(可选);落空的地方记成未解释项。 */
222
+ expect: typeof raw.expect === 'string' && raw.expect !== '' ? raw.expect : null,
208
223
  status: 'open',
209
224
  evidence: null,
210
225
  advancedAt: null,
@@ -226,6 +241,7 @@ export function applyMutation(state, mutation) {
226
241
  if (mutation === null || typeof mutation !== 'object' || typeof mutation.t !== 'string') return state
227
242
  const at = typeof mutation.at === 'number' ? mutation.at : 0
228
243
  const next = clone(state)
244
+ if (!Array.isArray(next.anomalies)) next.anomalies = []
229
245
  const RANK = { open: 0, blocked: 0, advanced: 1, void: 1 }
230
246
  const settle = (step, status) => {
231
247
  if (step === undefined || step === null) return
@@ -243,6 +259,7 @@ export function applyMutation(state, mutation) {
243
259
  previous.claim = mutation.claim
244
260
  previous.done_criteria = mutation.done_criteria
245
261
  previous.promote_at_level = mutation.promote_at_level
262
+ if (Array.isArray(mutation.irreversible)) previous.irreversible = clone(mutation.irreversible)
246
263
  /** 修订可以带上新的速览与判据清单(没带就保持旧值——「不提供」不等于「清空」)。 */
247
264
  if (typeof mutation.headline === 'string' && mutation.headline !== '') previous.headline = mutation.headline
248
265
  if (Array.isArray(mutation.criteria)) previous.criteria = clone(mutation.criteria)
@@ -255,6 +272,8 @@ export function applyMutation(state, mutation) {
255
272
  claim: mutation.claim,
256
273
  done_criteria: mutation.done_criteria,
257
274
  promote_at_level: mutation.promote_at_level,
275
+ /** 不可逆动作:`{action, command}`,命令里出现 `command` 那段原文就要人放行。 */
276
+ irreversible: Array.isArray(mutation.irreversible) ? clone(mutation.irreversible) : [],
258
277
  status: 'open',
259
278
  revision: mutation.revision ?? 1,
260
279
  reasons: [mutation.reason ?? null],
@@ -297,6 +316,7 @@ export function applyMutation(state, mutation) {
297
316
  known.refute_when = hypothesis.refute_when
298
317
  known.version = hypothesis.version ?? known.version
299
318
  if (typeof hypothesis.name === 'string' && hypothesis.name !== '') known.name = hypothesis.name
319
+ if (typeof hypothesis.retests === 'string' && hypothesis.retests !== '') known.retests = hypothesis.retests
300
320
  if (Array.isArray(hypothesis.assertions)) known.assertions = clone(hypothesis.assertions)
301
321
  continue
302
322
  }
@@ -307,6 +327,8 @@ export function applyMutation(state, mutation) {
307
327
  ...(typeof hypothesis.name === 'string' && hypothesis.name !== '' ? { name: hypothesis.name } : {}),
308
328
  claim,
309
329
  refute_when: hypothesis.refute_when,
330
+ /** 复检的是哪条已有事实(事实 id;可以是别的会话留下的)。 */
331
+ ...(typeof hypothesis.retests === 'string' && hypothesis.retests !== '' ? { retests: hypothesis.retests } : {}),
310
332
  status: 'proposed',
311
333
  version: hypothesis.version ?? 1,
312
334
  /**
@@ -378,6 +400,44 @@ export function applyMutation(state, mutation) {
378
400
  step.criteria_versions.push(mutation.new_criteria) // 旧版本留着,什么都不删
379
401
  break
380
402
  }
403
+ case 'step/expected': {
404
+ const step = stepOf(mutation.plan, mutation.step)
405
+ if (step === undefined || step.status !== 'open') break
406
+ step.expect = String(mutation.expect ?? '') || null
407
+ break
408
+ }
409
+ case 'anomaly/opened': {
410
+ if (next.anomalies.some((item) => item.id === mutation.id)) break
411
+ next.anomalies.push({ id: mutation.id, what: String(mutation.what ?? ''), anchor: mutation.anchor ?? null, step: mutation.step ?? null, by: mutation.by ?? 'model', status: 'open', reason: null, explainedBy: null, at, resolvedAt: null })
412
+ break
413
+ }
414
+ case 'lesson/recorded': {
415
+ if (!Array.isArray(next.lessons)) next.lessons = []
416
+ if (next.lessons.some((item) => item.id === mutation.id)) break
417
+ next.lessons.push({
418
+ id: mutation.id,
419
+ goal: mutation.goal ?? null,
420
+ text: String(mutation.text ?? ''),
421
+ kind: mutation.kind ?? 'trap',
422
+ about: Array.isArray(mutation.about) ? mutation.about.map(String) : [],
423
+ evidence: mutation.evidence ?? null,
424
+ boundary: mutation.boundary ?? null,
425
+ status: 'active',
426
+ path: mutation.path ?? null,
427
+ at,
428
+ })
429
+ break
430
+ }
431
+ case 'anomaly/resolved': {
432
+ const item = next.anomalies.find((entry) => entry.id === mutation.id)
433
+ if (item === undefined || item.status !== 'open') break
434
+ if (!['explained', 'ruled_out', 'escalated'].includes(mutation.outcome)) break
435
+ item.status = mutation.outcome
436
+ item.reason = mutation.reason ?? null
437
+ item.explainedBy = mutation.by ?? null
438
+ item.resolvedAt = at
439
+ break
440
+ }
381
441
  case 'plan/voided': {
382
442
  const step = stepOf(mutation.plan, mutation.step)
383
443
  if (step === undefined) break
@@ -484,6 +544,13 @@ export function applyMutation(state, mutation) {
484
544
  audit.card_path = mutation.card_path ?? null
485
545
  if (mutation.digest !== undefined) audit.digest = mutation.digest ?? null
486
546
  }
547
+ /** 评估者报的、做的人没登记的异常:记成未解释项(id 由裁决 id 派生,重放幂等)。 */
548
+ const found = Array.isArray(mutation.anomalies) ? mutation.anomalies : []
549
+ for (const [index, item] of found.entries()) {
550
+ const id = `${mutation.id}#u${index + 1}`
551
+ if (next.anomalies.some((entry) => entry.id === id)) continue
552
+ next.anomalies.push({ id, what: String(item?.what ?? ''), anchor: null, step: mutation.step ?? null, by: 'evaluator', matters: item?.matters ?? null, status: 'open', reason: null, explainedBy: null, at, resolvedAt: null })
553
+ }
487
554
  break
488
555
  }
489
556
  case 'evidence/recorded': {
@@ -534,6 +601,8 @@ export function applyMutation(state, mutation) {
534
601
  via: mutation.via ?? null,
535
602
  at,
536
603
  call: mutation.call ?? null,
604
+ /** 放行的是哪件不可逆动作(拦在命令上的那道门);步级放行没有这一格。 */
605
+ ...(typeof mutation.action === 'string' && mutation.action !== '' ? { action: mutation.action } : {}),
537
606
  })
538
607
  break
539
608
  }
@@ -584,9 +653,9 @@ export function applyMutation(state, mutation) {
584
653
  */
585
654
  const touchedOntology = (mutation.changes ?? []).some((change) => {
586
655
  const kind = classifyWorkspacePath(change?.path)?.kind
587
- return kind !== undefined && kind !== 'fact'
656
+ return kind !== undefined && kind !== 'fact' && kind !== 'lesson'
588
657
  })
589
- if (touchedOntology || Object.keys(files).some((path) => classifyWorkspacePath(path)?.kind !== 'fact')) {
658
+ if (touchedOntology || Object.keys(files).some((path) => !['fact', 'lesson'].includes(classifyWorkspacePath(path)?.kind))) {
590
659
  const ontology = materializeOntology(files)
591
660
  next.lexicon = ontology.lexicon
592
661
  next.entities = ontology.entities
@@ -1077,7 +1146,9 @@ export function knowledgePreflight(state, derived) {
1077
1146
  const factRows = Array.isArray(derived?.factRows) ? derived.factRows : []
1078
1147
  const conflicts = Array.isArray(derived?.conflicts) ? derived.conflicts : []
1079
1148
  /** 命题的文本面:目标主张 + 每条命题的主张(去空白后做包含判断)。 */
1080
- const claimText = [state?.goal?.claim, ...hypotheses.map((item) => item.claim)].filter((text) => typeof text === 'string' && text !== '').map((text) => String(text).replace(/\s+/g, ''))
1149
+ /** 加上下一步要做什么与它的预期:预期引用的关系也要递到眼前。 */
1150
+ const nextStep = (derived?.activePlan?.steps ?? []).find((step) => step?.status === 'open') ?? null
1151
+ const claimText = [state?.goal?.claim, ...hypotheses.map((item) => item.claim), nextStep?.do, nextStep?.expect].filter((text) => typeof text === 'string' && text !== '').map((text) => String(text).replace(/\s+/g, ''))
1081
1152
  /** 命题已经引用的谓词(断言在假设上时就该算「在用」)。 */
1082
1153
  const usedPredicates = new Set(hypotheses.flatMap((item) => (Array.isArray(item.assertions) ? item.assertions : [])).map((assertion) => String(assertion?.predicate ?? '')))
1083
1154
  const hits = (entry) => {
@@ -1089,19 +1160,27 @@ export function knowledgePreflight(state, derived) {
1089
1160
  const matchedPredicates = predicates.filter((predicate) => usedPredicates.has(predicate.id) || hits(predicate))
1090
1161
  const matchedTermIds = new Set(matchedTerms.map((term) => term.id))
1091
1162
  const matchedPredicateIds = new Set(matchedPredicates.map((predicate) => predicate.id))
1092
- /** 事实:断言引用了命中的谓词,或主词类型是命中的概念。 */
1093
- const matchedFacts = factRows.filter((fact) =>
1094
- (Array.isArray(fact.assertions) ? fact.assertions : []).some((assertion) => matchedPredicateIds.has(String(assertion?.predicate ?? '')) || matchedTermIds.has(String(assertion?.subject?.type ?? ''))),
1163
+ /**
1164
+ * 事实:断言引用了命中的谓词或概念;**或**升格时按词面挂上的定义里有命中的词(真跑里事实几乎都没有断言,
1165
+ * 只认断言等于从不命中);**或**有判断用 `retests` 指着它。撤回的不给。
1166
+ */
1167
+ const retested = new Set(hypotheses.map((item) => item.retests).filter((id) => typeof id === 'string'))
1168
+ const matchedFacts = factRows.filter(
1169
+ (fact) =>
1170
+ fact.review?.decision !== 'retracted' &&
1171
+ (retested.has(fact.id) ||
1172
+ Object.keys(fact.definitions ?? {}).some((id) => matchedTermIds.has(id) || matchedPredicateIds.has(id)) ||
1173
+ (Array.isArray(fact.assertions) ? fact.assertions : []).some((assertion) => matchedPredicateIds.has(String(assertion?.predicate ?? '')) || matchedTermIds.has(String(assertion?.subject?.type ?? '')))),
1095
1174
  )
1096
1175
  return {
1097
1176
  mode: 'knowledge',
1098
1177
  /** 词面命中的词汇:模型接下来要写的结论大概率会用到它们。 */
1099
- 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 })),
1178
+ terms: matchedTerms.slice(0, LIMIT).map((term) => ({ id: term.id, label: term.label, gloss: term.gloss, kind: term.kind ?? null, unit: term.unit ?? null, parent: term.parent ?? null, status: term.status, uses: term.uses ?? 0, basis: term.basis ?? null })),
1100
1179
  termsTruncated: Math.max(0, matchedTerms.length - LIMIT),
1101
- 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 })),
1180
+ predicates: matchedPredicates.slice(0, LIMIT).map((predicate) => ({ id: predicate.id, label: predicate.label, gloss: predicate.gloss ?? '', kind: predicate.kind ?? null, shape: predicate.shape ?? null, check: predicate.check ?? null, domain: predicate.domain, range: predicate.range, functional: predicate.functional === true, status: predicate.status, uses: predicate.uses ?? 0, basis: predicate.basis ?? null })),
1102
1181
  predicatesTruncated: Math.max(0, matchedPredicates.length - LIMIT),
1103
1182
  /** 命中的既有事实(可复用的「已知」)。 */
1104
- 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 })),
1183
+ 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, foreign: fact.foreign === true })),
1105
1184
  factsTruncated: Math.max(0, matchedFacts.length - LIMIT),
1106
1185
  /** 冲突:有就带上(它们约束「哪些结论还不能随便写)。 */
1107
1186
  conflicts: conflicts.map((conflict) => ({ predicate: conflict.predicate, subject: conflict.subject })),
@@ -1685,15 +1764,18 @@ export function derive(state) {
1685
1764
  * · `review` ——人已经审查过(撤回 / 维持),决定连缘由一起留着。
1686
1765
  */
1687
1766
  const lexicon = normalizeLexicon(state.lexicon)
1767
+ /** 声明了 `retests` 的判断被推翻 ⇒ 它复检的那条事实也算被推翻(跨会话只认事实 id)。 */
1768
+ const retestRefuted = new Set(hypotheses.filter((item) => typeof item.retests === 'string' && (item.refutations ?? 0) > 0).map((item) => item.retests))
1688
1769
  const ownFacts = (state.facts ?? []).map((fact) => {
1689
1770
  const linked = typeof fact.hypothesis === 'string' && fact.hypothesis !== ''
1690
1771
  const owner = hypotheses.find((item) => (linked ? item.id === fact.hypothesis : String(item.claim ?? '') === String(fact.text ?? '')))
1691
- return { ...fact, refuted: (owner?.refutations ?? 0) > 0, review: fact.review ?? null, foreign: false }
1772
+ return { ...fact, refuted: (owner?.refutations ?? 0) > 0 || retestRefuted.has(fact.id), review: fact.review ?? null, foreign: false }
1692
1773
  })
1693
1774
  /**
1694
1775
  * **别的会话留下的事实**(住在 `clear/knowledge/facts/<id>.json`,经 `workspace/synced` 进账)。
1695
1776
  * 同一个 id 本会话账上也有就以账上那条为准(它带着推翻读数);文件上的复核结论照样认。
1696
- * 它们的假设不在本会话,所以 `refuted` 只能是 false——推翻要在这里重新检验一次才算。
1777
+ * 它们的假设不在本会话,所以推翻要在这里重新检验一次才算:本会话的判断用 `retests` 指向它,
1778
+ * 那条判断被推翻,这条事实就标「被推翻」。
1697
1779
  */
1698
1780
  const ownIds = new Set(ownFacts.map((fact) => fact.id))
1699
1781
  const foreignFacts = []
@@ -1706,7 +1788,7 @@ export function derive(state) {
1706
1788
  if (own.review === null && row.review !== null) own.review = row.review
1707
1789
  continue
1708
1790
  }
1709
- foreignFacts.push({ ...row, refuted: false, foreign: true })
1791
+ foreignFacts.push({ ...row, refuted: retestRefuted.has(row.id), foreign: true })
1710
1792
  }
1711
1793
  foreignFacts.sort((a, b) => (a.at ?? 0) - (b.at ?? 0))
1712
1794
  /**
@@ -1724,6 +1806,18 @@ export function derive(state) {
1724
1806
  * 两者都吃 `factRows` 而不是 `state.facts`:事实的复核态与推翻标记是派生的,
1725
1807
  * 在这里重算一遍就等于第二份判据。
1726
1808
  */
1809
+ /**
1810
+ * **经验行**:本会话写下的,加上别的会话留在 `clear/knowledge/lessons/` 的。同 id 以文件为准
1811
+ * (人可以在文件上标 `status: "retracted"` 撤回);撤回的不再推送。新的在前。
1812
+ */
1813
+ const lessonById = new Map()
1814
+ for (const item of state.lessons ?? []) lessonById.set(item.id, { ...item })
1815
+ for (const [path, file] of Object.entries(state.workspace?.files ?? {})) {
1816
+ if (classifyWorkspacePath(path)?.kind !== 'lesson' || file?.data === undefined) continue
1817
+ const row = lessonFromFile(file.data, path)
1818
+ if (row !== null) lessonById.set(row.id, { ...lessonById.get(row.id), ...row, at: row.at ?? lessonById.get(row.id)?.at ?? null })
1819
+ }
1820
+ const lessonRows = [...lessonById.values()].filter((item) => item.status !== 'retracted').sort((a, b) => (b.at ?? 0) - (a.at ?? 0))
1727
1821
  const conflicts = deriveConflicts(factRows, lexicon)
1728
1822
  const lexiconIssues = lexiconHealth(lexicon, factRows)
1729
1823
  /**
@@ -1771,6 +1865,7 @@ export function derive(state) {
1771
1865
  closedPlans,
1772
1866
  pendingAudit,
1773
1867
  factRows,
1868
+ lessonRows,
1774
1869
  settlement,
1775
1870
  stepOf,
1776
1871
  needYou,