@xiciyang/rsi-contract 0.0.0-stage → 0.1.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.
- package/CONTRACT.md +240 -0
- package/LICENSE +29 -0
- package/README.md +76 -2
- package/package.json +17 -4
- package/src/contract.mjs +890 -0
- package/src/criterion.mjs +48 -0
- package/src/direction-selection.mjs +234 -0
- package/src/effect-judgment.mjs +210 -0
- package/src/goal-setting.mjs +255 -0
- package/src/index.mjs +135 -0
- package/src/risk-assessment.mjs +186 -0
- package/src/rollback-decision.mjs +248 -0
- package/src/support.mjs +198 -0
- package/src/version.mjs +20 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-setting.mjs —— 能力 ①「目标设定」。
|
|
3
|
+
*
|
|
4
|
+
* 输入:系统状态(当前度量 / 血缘 / 既往轮次)。
|
|
5
|
+
* 输出:**排序后的候选改进目标**(每条自带证据引用、期望增量、阻断原因)。
|
|
6
|
+
*
|
|
7
|
+
* ─────────────────────────── 三条硬纪律 ───────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* ① **绝不发明度量**。`state.metrics` 缺失或为空 ⇒ `refused: true` + `goals: []`。
|
|
10
|
+
* 不做"按历史猜一个当前值"这种事 —— 那正是产物带假证据的入口。
|
|
11
|
+
*
|
|
12
|
+
* ② **期望增量必须有来源**。只接受两种来源,二者都要能在输入内复核:
|
|
13
|
+
* (a) 度量自带 `expectedDelta`(并要求该度量带可解析的 `evidenceRefs`);
|
|
14
|
+
* (b) 既往轮次的实测 `delta` 的**中位数**(证据引用=那些轮次 id)。
|
|
15
|
+
* 两者都没有 ⇒ 该候选 `blocked: E_GOAL_MEANINGLESS`。
|
|
16
|
+
* 中位数而不是均值:均值会被单次异常轮次拉动,而"用哪一版统计量"必须写死在代码里。
|
|
17
|
+
*
|
|
18
|
+
* ③ **冲突不替调用方选边**。已声明的 tradeoff / mutually-exclusive 关系里的**双方**都 blocked,
|
|
19
|
+
* 并互相点名(`conflictsWith`)。静默挑一个"更优的"就是把一个已知冲突藏起来。
|
|
20
|
+
*
|
|
21
|
+
* 排序是**全序**(blocked 在后 → priority 降序 → goalId 升序),故输出顺序与输入顺序无关。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { CAPABILITY_VERSION } from './version.mjs'
|
|
25
|
+
import {
|
|
26
|
+
assertPlainObject, assertArray, optionalArray, finiteNumber, nonEmptyString, directionOf,
|
|
27
|
+
median, uniqSorted, RsiLoopError,
|
|
28
|
+
} from './support.mjs'
|
|
29
|
+
|
|
30
|
+
export const CAPABILITY_ID = 'goalSetting'
|
|
31
|
+
|
|
32
|
+
export function goalSetting(input) {
|
|
33
|
+
const arg = assertPlainObject(input ?? {}, 'goalSetting 入参', 'E_INPUT_SHAPE')
|
|
34
|
+
const state = assertPlainObject(arg.state ?? null, 'state', 'E_INPUT_SHAPE')
|
|
35
|
+
const metrics = state.metrics
|
|
36
|
+
|
|
37
|
+
// ── ① fail-closed:没有度量就没有目标 ──
|
|
38
|
+
if (metrics === undefined || metrics === null || (typeof metrics === 'object' && Object.keys(metrics).length === 0)) {
|
|
39
|
+
return {
|
|
40
|
+
capability: CAPABILITY_ID,
|
|
41
|
+
version: CAPABILITY_VERSION,
|
|
42
|
+
goals: [],
|
|
43
|
+
refused: true,
|
|
44
|
+
code: 'E_METRICS_ABSENT',
|
|
45
|
+
reasons: ['state.metrics 缺失或为空 ⇒ 不产出任何目标(本库不发明度量:兜底默认值必须 fail-closed)'],
|
|
46
|
+
evidenceRefs: [],
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
assertPlainObject(metrics, 'state.metrics', 'E_INPUT_SHAPE')
|
|
50
|
+
|
|
51
|
+
const history = optionalArray(state.history, 'state.history', 'E_INPUT_SHAPE')
|
|
52
|
+
const constraints = optionalArray(state.constraints, 'state.constraints', 'E_INPUT_SHAPE')
|
|
53
|
+
const lineage = state.lineage === undefined || state.lineage === null ? {} : assertPlainObject(state.lineage, 'state.lineage', 'E_INPUT_SHAPE')
|
|
54
|
+
const means = arg.means === undefined || arg.means === null ? null : assertArray(arg.means, 'means', 'E_INPUT_SHAPE')
|
|
55
|
+
const options = arg.options === undefined || arg.options === null ? {} : assertPlainObject(arg.options, 'options', 'E_INPUT_SHAPE')
|
|
56
|
+
|
|
57
|
+
const lineageKeys = Object.keys(lineage)
|
|
58
|
+
const roundIds = history.map((h) => h?.roundId).filter(nonEmptyString)
|
|
59
|
+
|
|
60
|
+
/** 证据引用解析器:引用必须能在**本次输入内**落到一个真实对象上,否则不算证据。 */
|
|
61
|
+
const resolveRef = (ref) => {
|
|
62
|
+
if (!nonEmptyString(ref)) return null
|
|
63
|
+
if (Object.prototype.hasOwnProperty.call(metrics, ref)) return { kind: 'metric', id: ref }
|
|
64
|
+
if (lineageKeys.includes(ref)) return { kind: 'lineage', id: ref }
|
|
65
|
+
if (roundIds.includes(ref)) return { kind: 'round', id: ref }
|
|
66
|
+
const [prefix, ...rest] = ref.split(':')
|
|
67
|
+
const tail = rest.join(':')
|
|
68
|
+
if (prefix === 'metric' && Object.prototype.hasOwnProperty.call(metrics, tail)) return { kind: 'metric', id: tail }
|
|
69
|
+
if (prefix === 'lineage' && lineageKeys.includes(tail)) return { kind: 'lineage', id: tail }
|
|
70
|
+
if (prefix === 'round' && roundIds.includes(tail)) return { kind: 'round', id: tail }
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── ② 逐度量造候选 ──
|
|
75
|
+
const goals = []
|
|
76
|
+
const reasons = []
|
|
77
|
+
|
|
78
|
+
for (const metricId of Object.keys(metrics).sort()) {
|
|
79
|
+
const m = metrics[metricId]
|
|
80
|
+
const base = {
|
|
81
|
+
goalId: `goal:${metricId}`,
|
|
82
|
+
targetMetric: metricId,
|
|
83
|
+
evidenceRefs: [],
|
|
84
|
+
priority: null,
|
|
85
|
+
priorityBasis: 'unavailable',
|
|
86
|
+
}
|
|
87
|
+
if (!isPlain(metrics[metricId])) {
|
|
88
|
+
goals.push(blocked(base, 'E_GOAL_MEANINGLESS', `度量 ${metricId} 不是对象 ⇒ 无可测目标`, 'unmeasurable'))
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
const refs = Array.isArray(m.evidenceRefs) ? m.evidenceRefs : []
|
|
92
|
+
const resolvable = refs.filter((r) => resolveRef(r) !== null)
|
|
93
|
+
const unresolvable = refs.filter((r) => resolveRef(r) === null)
|
|
94
|
+
|
|
95
|
+
if (!finiteNumber(m.value)) {
|
|
96
|
+
goals.push(blocked({ ...base, evidenceRefs: uniqSorted(resolvable) }, 'E_GOAL_MEANINGLESS', `度量 ${metricId} 的 value 不是有限数 ⇒ 不可测`, 'unmeasurable'))
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const direction = directionOf(m.direction)
|
|
100
|
+
if (direction === null) {
|
|
101
|
+
goals.push(blocked({ ...base, evidenceRefs: uniqSorted(resolvable) }, 'E_GOAL_MEANINGLESS', `度量 ${metricId} 未声明合法 direction ⇒ 无可测目标`, 'unmeasurable'))
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (refs.length === 0) {
|
|
105
|
+
goals.push(blocked({ ...base }, 'E_GOAL_MEANINGLESS', `度量 ${metricId} 未附任何证据引用 ⇒ 无证据的目标不得被提出`, 'no-evidence'))
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (unresolvable.length > 0) {
|
|
109
|
+
goals.push(blocked({ ...base, evidenceRefs: uniqSorted(resolvable) }, 'E_GOAL_MEANINGLESS', `度量 ${metricId} 的证据引用无法在本次输入内解析:${unresolvable.join(', ')} ⇒ 视为无证据`, 'unresolvable-evidence'))
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 期望增量的来源(只有两种,都要可复核)
|
|
114
|
+
let expectedDelta = null
|
|
115
|
+
let deltaBasis = null
|
|
116
|
+
let deltaRefs = []
|
|
117
|
+
if (finiteNumber(m.expectedDelta)) {
|
|
118
|
+
expectedDelta = m.expectedDelta
|
|
119
|
+
deltaBasis = 'declared'
|
|
120
|
+
deltaRefs = uniqSorted(resolvable)
|
|
121
|
+
} else {
|
|
122
|
+
const rounds = history.filter((h) => h && h.targetMetric === metricId && h.outcome === 'success' && finiteNumber(h.delta))
|
|
123
|
+
if (rounds.length > 0) {
|
|
124
|
+
expectedDelta = median(rounds.map((h) => h.delta))
|
|
125
|
+
deltaBasis = `history-median(${rounds.length})`
|
|
126
|
+
deltaRefs = uniqSorted(rounds.map((h) => `round:${h.roundId}`))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const evidenceRefs = uniqSorted([...resolvable, ...deltaRefs])
|
|
130
|
+
|
|
131
|
+
if (expectedDelta === null) {
|
|
132
|
+
goals.push(blocked({ ...base, evidenceRefs }, 'E_GOAL_MEANINGLESS', `度量 ${metricId} 既无自带 expectedDelta,也无成功轮次的实测 delta ⇒ 期望增量无来源(不猜)`, 'no-delta-source'))
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 可达性
|
|
137
|
+
if (m.atBound === true) {
|
|
138
|
+
goals.push(blocked({ ...base, evidenceRefs, expectedDelta }, 'E_GOAL_UNREACHABLE', `度量 ${metricId} 已声明 atBound=true ⇒ 无改进余量`, 'at-bound'))
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (!(expectedDelta > 0)) {
|
|
142
|
+
goals.push(blocked({ ...base, evidenceRefs, expectedDelta }, 'E_GOAL_UNREACHABLE', `度量 ${metricId} 的期望增量 ${expectedDelta} ≤ 0 ⇒ 按当前证据并不比现状更好`, 'non-positive-delta'))
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (means !== null) {
|
|
146
|
+
const covering = means.filter((x) => x && Array.isArray(x.targets) && x.targets.includes(metricId))
|
|
147
|
+
if (covering.length === 0) {
|
|
148
|
+
goals.push(blocked({ ...base, evidenceRefs, expectedDelta }, 'E_GOAL_UNREACHABLE', `已声明的 ${means.length} 个手段中没有任何一个覆盖度量 ${metricId} ⇒ 无可达路径`, 'no-means'))
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const priority = m.value !== 0 ? Math.abs(expectedDelta) / Math.abs(m.value) : null
|
|
154
|
+
goals.push({
|
|
155
|
+
...base,
|
|
156
|
+
statement: `${direction === 'higher-is-better' ? '提升' : '降低'} ${metricId}(当前 ${m.value}${nonEmptyString(m.unit) ? ` ${m.unit}` : ''}),期望改进量 ${expectedDelta}(依据 ${deltaBasis})`,
|
|
157
|
+
direction,
|
|
158
|
+
expectedDelta,
|
|
159
|
+
rationale: `期望增量来源=${deltaBasis};证据引用=${evidenceRefs.join(', ') || '无'}`,
|
|
160
|
+
evidenceRefs,
|
|
161
|
+
priority,
|
|
162
|
+
priorityBasis: priority === null ? 'unavailable' : 'relative-to-current',
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── ③ 冲突检测:只依据**已声明**的关系,双方都 blocked ──
|
|
167
|
+
applyConflicts(goals, constraints)
|
|
168
|
+
|
|
169
|
+
// ── ④ 排序(全序) ──
|
|
170
|
+
goals.sort(compareGoals)
|
|
171
|
+
const limited = Number.isInteger(options.maxGoals) && options.maxGoals >= 0 ? goals.slice(0, options.maxGoals) : goals
|
|
172
|
+
|
|
173
|
+
if (limited.length === 0) reasons.push('metrics 存在但没有任何度量能产出可复核的候选(详见各 blocked 原因)')
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
capability: CAPABILITY_ID,
|
|
177
|
+
version: CAPABILITY_VERSION,
|
|
178
|
+
goals: limited,
|
|
179
|
+
refused: false,
|
|
180
|
+
code: null,
|
|
181
|
+
reasons,
|
|
182
|
+
evidenceRefs: uniqSorted([...lineageKeys.map((k) => `lineage:${k}`), ...roundIds.map((r) => `round:${r}`), ...Object.keys(metrics).map((k) => `metric:${k}`)]),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isPlain(v) {
|
|
187
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function blocked(base, code, reason, tag) {
|
|
191
|
+
return {
|
|
192
|
+
goalId: base.goalId,
|
|
193
|
+
statement: `(已阻断)${base.targetMetric}`,
|
|
194
|
+
targetMetric: base.targetMetric,
|
|
195
|
+
// 被阻断的候选**不伪造**方向与增量:null 表示"没有可复核的值",而不是 0/'higher-is-better'。
|
|
196
|
+
// (把 0 写成"期望增量为零"、把占位方向写成"想要提升",都是产物带假证据。)
|
|
197
|
+
direction: base.direction ?? null,
|
|
198
|
+
expectedDelta: finiteNumber(base.expectedDelta) ? base.expectedDelta : null,
|
|
199
|
+
rationale: `${tag}: ${reason}`,
|
|
200
|
+
evidenceRefs: base.evidenceRefs ?? [],
|
|
201
|
+
priority: null,
|
|
202
|
+
priorityBasis: 'unavailable',
|
|
203
|
+
blocked: { code, reason },
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 已声明的关系里,冲突双方**都**被阻断并互相点名 —— 不静默选边。 */
|
|
208
|
+
function applyConflicts(goals, constraints) {
|
|
209
|
+
const byMetric = new Map(goals.map((g) => [g.targetMetric, g]))
|
|
210
|
+
const pairs = []
|
|
211
|
+
for (const c of constraints) {
|
|
212
|
+
if (!c || typeof c !== 'object') continue
|
|
213
|
+
const between = Array.isArray(c.between) ? c.between.filter(nonEmptyString) : []
|
|
214
|
+
if (c.kind === 'tradeoff' || c.kind === 'mutually-exclusive') {
|
|
215
|
+
for (let i = 0; i < between.length; i += 1) {
|
|
216
|
+
for (let j = i + 1; j < between.length; j += 1) pairs.push([between[i], between[j], c.id ?? 'constraint'])
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const [a, b, cid] of pairs) {
|
|
221
|
+
const ga = byMetric.get(a)
|
|
222
|
+
const gb = byMetric.get(b)
|
|
223
|
+
if (!ga || !gb) continue
|
|
224
|
+
// 已经因更具体的理由(无意义/不可达)被阻断的候选不再被覆盖:
|
|
225
|
+
// 冲突不是它的首要病因,替换掉会更难定位。
|
|
226
|
+
if (ga.blocked || gb.blocked) continue
|
|
227
|
+
// 冲突关系覆盖既有阻断(冲突本身也是阻断理由之一),但保留原 code 里的更具体信息
|
|
228
|
+
block(ga, 'E_GOAL_CONFLICT', `与目标 ${gb.goalId} 冲突(已声明关系 ${cid}:${a} ↔ ${b})—— 冲突不由本能力替调用方裁决`, [gb.goalId])
|
|
229
|
+
block(gb, 'E_GOAL_CONFLICT', `与目标 ${ga.goalId} 冲突(已声明关系 ${cid}:${a} ↔ ${b})—— 冲突不由本能力替调用方裁决`, [ga.goalId])
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function block(goal, code, reason, conflictsWith) {
|
|
234
|
+
goal.blocked = { code, reason, conflictsWith }
|
|
235
|
+
goal.rationale = `${goal.rationale}|冲突:${reason}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 全序:未阻断在前 → priority 降序(null 在后)→ goalId 升序。 */
|
|
239
|
+
function compareGoals(a, b) {
|
|
240
|
+
const ba = a.blocked ? 1 : 0
|
|
241
|
+
const bb = b.blocked ? 1 : 0
|
|
242
|
+
if (ba !== bb) return ba - bb
|
|
243
|
+
const pa = finiteNumber(a.priority) ? a.priority : null
|
|
244
|
+
const pb = finiteNumber(b.priority) ? b.priority : null
|
|
245
|
+
if (pa === null && pb !== null) return 1
|
|
246
|
+
if (pa !== null && pb === null) return -1
|
|
247
|
+
if (pa !== null && pb !== null && pa !== pb) return pb - pa
|
|
248
|
+
return a.goalId < b.goalId ? -1 : a.goalId > b.goalId ? 1 : 0
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 供 index 侧做形状自检(本能力不自行抛 RsiLoopError 之外的错误)。 */
|
|
252
|
+
export function assertGoalSettingOutput(out) {
|
|
253
|
+
if (!Array.isArray(out.goals)) throw new RsiLoopError('E_INPUT_SHAPE', 'goalSetting 输出缺 goals')
|
|
254
|
+
return out
|
|
255
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xiciyang/rsi-contract —— 五个「自」能力的**载体库** v0。
|
|
3
|
+
*
|
|
4
|
+
* ─────────────────────────── 这个库是什么 / 不是什么 ───────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* **是**:五个能力各自的最小确定性实现 + 它们的 I/O schema + 声明的失败模式 + 机读清单。
|
|
7
|
+
* goalSetting(目标设定)/ directionSelection(方向选择)/ effectJudgment(效果判定)/
|
|
8
|
+
* riskAssessment(风险识别)/ rollbackDecision(回退决策)。
|
|
9
|
+
*
|
|
10
|
+
* **不是**:**不是循环**。本库**不含**任何编排、调度、迭代控制、重试、停止条件、
|
|
11
|
+
* 预算控制或状态持久化 —— 那些属于调用它的编排层。本库的每个能力都是**一次纯函数调用**:
|
|
12
|
+
* 给输入、出结论、附证据、自带阻断理由。逐轮推进是别人的事。
|
|
13
|
+
* 具体地:本库不 import `timer`、不读时钟、不写文件、不发请求、不开子进程。
|
|
14
|
+
*
|
|
15
|
+
* **也**:不 import 任何其它包(零运行时依赖)。需要文件系统/网络时,由调用方在**库外**取好数据,
|
|
16
|
+
* 以参数注入(沿用 `@xiciyang/state-core` 的方言约定:`parseFile(fs, path)` 而不是自己 import fs)。
|
|
17
|
+
*
|
|
18
|
+
* ─────────────────────────── 五条横切纪律 ───────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* ① **每个产物自带证据**:`evidenceRefs` / `evidence` 使第三方能重算,
|
|
21
|
+
* 且产物**保证 JSON 可序列化**:不含函数、活对象、循环引用、非有限数。
|
|
22
|
+
* (`directionSelection` 透传风险、`rollbackDecision` 透传产物清单时都做深拷贝 + 可 JSON 化校验。)
|
|
23
|
+
*
|
|
24
|
+
* ② **确定性**:同输入同输出。本库不读时钟、不用随机数、不依赖环境变量;
|
|
25
|
+
* 所有排序都是**全序**(带唯一 tiebreak),故输出与输入顺序无关。
|
|
26
|
+
* 产物里**没有任何时间字段**(时间戳可以存在,但只允许由调用方作为输入携带,
|
|
27
|
+
* 并且不得参与决策 —— `rollbackDecision` 明确不按 `verifiedAt` 排序)。
|
|
28
|
+
*
|
|
29
|
+
* ③ **fail-closed**:缺输入就拒绝,绝不猜。没有"兜底默认值"这条路径:
|
|
30
|
+
* 阈值/离散度/z 一律由调用方给出,缺即 `E_THRESHOLD_NOT_PROVIDED`;
|
|
31
|
+
* 无可信回退目标即 `E_NO_ROLLBACK_TARGET`;度量缺失即空目标列表 + `refused`。
|
|
32
|
+
*
|
|
33
|
+
* ④ **不自证**:能力不得消费自己的产物当验证。`effectJudgment` 的完整性证据必须由别处生产
|
|
34
|
+
* (`integrity.producedBy === 'effectJudgment'` 直接判 `measurement-invalid`),
|
|
35
|
+
* `verifyRollbackDecision` 的现场观测同理。
|
|
36
|
+
*
|
|
37
|
+
* ⑤ **声明即可枚举**:`CAPABILITY_MANIFEST` 给出每个能力的 id / version / input schema /
|
|
38
|
+
* output schema / 失败模式(含"哪条测试会让它变红")。CI 不需要跑实现就能枚举契约面。
|
|
39
|
+
*
|
|
40
|
+
* 契约细节与失败模式逐条说明见同目录 `CONTRACT.md`;工作区根有一份人读报告
|
|
41
|
+
* `RSI_SELF_CAPABILITIES.md`。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { CONTRACT_VERSION, CAPABILITY_VERSION } from './version.mjs'
|
|
45
|
+
import {
|
|
46
|
+
ERROR_CODES, CAPABILITY_IDS, VOCAB, VERIFICATION_COMPARATORS,
|
|
47
|
+
buildManifest, assertManifestComplete, schemasFor, failureModesFor,
|
|
48
|
+
} from './contract.mjs'
|
|
49
|
+
import { RsiLoopError, canonicalJson, jsonSafeReport, jsonClone, DIRECTIONS } from './support.mjs'
|
|
50
|
+
import { goalSetting } from './goal-setting.mjs'
|
|
51
|
+
import { directionSelection, SELECTION_RULE, CONSTRAINT_SEMANTICS } from './direction-selection.mjs'
|
|
52
|
+
import { effectJudgment, EFFECT_CHECK_ORDER } from './effect-judgment.mjs'
|
|
53
|
+
import { riskAssessment, FACTOR_TABLE, QUALITY_FACTORS, RISK_ORDER, assertNoSafeLevel, factorTable } from './risk-assessment.mjs'
|
|
54
|
+
import { rollbackDecision, verifyRollbackDecision, VERIFICATION_CRITERION } from './rollback-decision.mjs'
|
|
55
|
+
import { significance, SIGNIFICANCE_CRITERION } from './criterion.mjs'
|
|
56
|
+
|
|
57
|
+
export { CONTRACT_VERSION, CAPABILITY_VERSION }
|
|
58
|
+
export { ERROR_CODES, CAPABILITY_IDS, VOCAB, VERIFICATION_COMPARATORS }
|
|
59
|
+
export { RsiLoopError, DIRECTIONS }
|
|
60
|
+
export { goalSetting, directionSelection, effectJudgment, riskAssessment, rollbackDecision, verifyRollbackDecision }
|
|
61
|
+
export { significance, SIGNIFICANCE_CRITERION }
|
|
62
|
+
export { SELECTION_RULE, CONSTRAINT_SEMANTICS, EFFECT_CHECK_ORDER, VERIFICATION_CRITERION, RISK_ORDER }
|
|
63
|
+
/** 规范化(键序无关)JSON —— 确定性断言用;`JSON.stringify` 的键序取决于构造顺序,不可作指纹。 */
|
|
64
|
+
export { canonicalJson, jsonClone }
|
|
65
|
+
/** JSON 卫生自检:`{ ok, violations[] }`(不抛,因为它的用途正是把违规报出来)。 */
|
|
66
|
+
export { jsonSafeReport }
|
|
67
|
+
/** 风险因子词表只读副本(供 CI 枚举,不复制第二份实现)。 */
|
|
68
|
+
export { factorTable, assertNoSafeLevel, FACTOR_TABLE, QUALITY_FACTORS }
|
|
69
|
+
|
|
70
|
+
/** 五个能力的实现体(清单据此判定 `implemented`)。 */
|
|
71
|
+
const IMPLEMENTATIONS = Object.freeze({
|
|
72
|
+
goalSetting,
|
|
73
|
+
directionSelection,
|
|
74
|
+
effectJudgment,
|
|
75
|
+
riskAssessment,
|
|
76
|
+
rollbackDecision,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* **机读能力清单** —— 调用方与 CI 用它枚举能力、schema 与失败模式。
|
|
81
|
+
* 冻结(`Object.freeze`),防止运行期被改写后"契约"与"实现"分叉。
|
|
82
|
+
*/
|
|
83
|
+
export const CAPABILITY_MANIFEST = Object.freeze({
|
|
84
|
+
...buildManifest(CAPABILITY_VERSION, IMPLEMENTATIONS),
|
|
85
|
+
contractVersion: CONTRACT_VERSION,
|
|
86
|
+
loopControlFlow: false,
|
|
87
|
+
runtimeDependencies: [],
|
|
88
|
+
jsonSafe: true,
|
|
89
|
+
deterministic: true,
|
|
90
|
+
timestampsAffectDecision: false,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/** 按 id 取一个能力的契约与实现(未知 id 抛错,不返回 undefined)。 */
|
|
94
|
+
export function getCapability(id) {
|
|
95
|
+
const entry = CAPABILITY_MANIFEST.capabilities.find((c) => c.id === id)
|
|
96
|
+
if (!entry) throw new RsiLoopError('E_INPUT_SHAPE', `未知能力 id:${String(id)}(可用:${CAPABILITY_IDS.join(', ')})`)
|
|
97
|
+
return { ...entry, schema: schemasFor(id), failureModes: failureModesFor(id), run: IMPLEMENTATIONS[id] }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 契约自检 —— 让"契约面完整"这件事**可执行**而不是靠人读文档:
|
|
102
|
+
* ① 清单完整(五个能力 × id/两个 schema/实现/失败模式,且每条模式都指向会变红的测试);
|
|
103
|
+
* ② 风险词表里**不存在**"安全/无风险"档(有人加回来即红);
|
|
104
|
+
* ③ 若给了 `sourceText`,则必须同时给出**扫描词表** `patterns`,逐个检查是否出现 ——
|
|
105
|
+
* 词表**不由本库自带**:它是"什么算非确定性"的**判据**,判据属于调用方(这里是测试)。
|
|
106
|
+
* 给了源码却不给词表 ⇒ 报问题(fail-closed,绝不静默跳过扫描)。
|
|
107
|
+
*
|
|
108
|
+
* 为什么词表不写在本库源码里:写了就会让"源码不含非确定性调用"这条扫描**扫到自己**,
|
|
109
|
+
* 变成自指的假阳性。判据放在会调用它的测试里,扫描才是在扫实现。
|
|
110
|
+
*
|
|
111
|
+
* 返回 `{ ok, problems[] }`(不抛,因为它的用途正是把问题**报出来**)。
|
|
112
|
+
*/
|
|
113
|
+
export function verifyCapabilityContract({ sourceText = null, patterns = null } = {}) {
|
|
114
|
+
const problems = []
|
|
115
|
+
const m = assertManifestComplete(CAPABILITY_MANIFEST)
|
|
116
|
+
problems.push(...m.problems)
|
|
117
|
+
try {
|
|
118
|
+
assertNoSafeLevel()
|
|
119
|
+
} catch (e) {
|
|
120
|
+
problems.push(String(e.message))
|
|
121
|
+
}
|
|
122
|
+
if (typeof sourceText === 'string') {
|
|
123
|
+
if (!Array.isArray(patterns) || patterns.length === 0) {
|
|
124
|
+
problems.push('提供了 sourceText 但没有提供扫描词表 patterns ⇒ 扫描未执行(不得静默跳过)')
|
|
125
|
+
} else {
|
|
126
|
+
for (const bad of patterns) {
|
|
127
|
+
if (sourceText.includes(bad)) problems.push(`源码出现非确定性/环境相关调用:${bad}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { ok: problems.length === 0, problems }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 默认自检(不含源码扫描;源码扫描由测试侧读文件并传入词表后执行)。 */
|
|
135
|
+
export const CONTRACT_SELF_CHECK = verifyCapabilityContract()
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* risk-assessment.mjs —— 能力 ④「风险识别」。
|
|
3
|
+
*
|
|
4
|
+
* 输入:候选方向 + 系统状态。输出:`{ level, mitigations[], requiresHumanConfirmation, reasons[] }`。
|
|
5
|
+
*
|
|
6
|
+
* ─────────────────────────── 三条硬纪律 ───────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* ① **未知不得等于安全**。词表外的因子、或**根本没有声明任何因子** ⇒ 直接升到 `critical`
|
|
9
|
+
* 且 `requiresHumanConfirmation: true`。词表里**没有 `none`/`safe` 这一档** ——
|
|
10
|
+
* 这不是"忘了写",而是刻意的:风险档位的值域里不存在"安全"这个结论。
|
|
11
|
+
*
|
|
12
|
+
* ② **就地变更是独立风险档**(本仓库原则:"就地变更失败是独立风险档")。
|
|
13
|
+
* 只要 `irreversible === true` 或 `inPlace === true`,无论调用方列了什么因子,
|
|
14
|
+
* 都会**自动注入**因子 `irreversible-in-place`(档位 high),并在产物里标 `injected: true`。
|
|
15
|
+
* 即"调用方漏写 ⇒ 被结构事实补上",而不是"漏写 ⇒ 被判为安全"。
|
|
16
|
+
*
|
|
17
|
+
* ③ **档位取上界(max),不取平均**。多个因子时取最高档 —— 平均值会把一个 critical
|
|
18
|
+
* 摊薄成 medium,那正是"低估风险"的机制化形态。
|
|
19
|
+
*
|
|
20
|
+
* ## 失败模式的检出方式(这是本能力最需要说清的一点)
|
|
21
|
+
*
|
|
22
|
+
* `低估风险` / `高估风险` **无法**从不含自评的输入里判定(没有"真值"可比)。
|
|
23
|
+
* 因此本能力要求调用方**可选地**给出 `declaredLevel`(它自己的判断),然后:
|
|
24
|
+
* - `declaredLevel < 计算档位` ⇒ `E_RISK_UNDERESTIMATE`,**采用计算档位**(不采纳低估);
|
|
25
|
+
* - `declaredLevel > 计算档位` ⇒ `E_RISK_OVERESTIMATE`,报出来但**保留调用方的更高档位**
|
|
26
|
+
* (把人的谨慎降下来是另一种错);
|
|
27
|
+
* - 未给自评 ⇒ `undeclared`,**既不因此升级也不因此降级**(不编造校准结论)。
|
|
28
|
+
* 另有一条**校准对照**(见 `tests/unit/risk-assessment.spec.mjs`):一个已完全归类、
|
|
29
|
+
* 只读/可逆本地的方向必须落在 `low`。若实现恒判 `critical`,那条断言变红 —— 那是
|
|
30
|
+
* "高估风险"的可证伪面。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { CAPABILITY_VERSION } from './version.mjs'
|
|
34
|
+
import { assertPlainObject, nonEmptyString, jsonClone, uniqSorted, optionalArray, RsiLoopError } from './support.mjs'
|
|
35
|
+
import { VOCAB } from './contract.mjs'
|
|
36
|
+
|
|
37
|
+
export const CAPABILITY_ID = 'riskAssessment'
|
|
38
|
+
|
|
39
|
+
/** 档位的全序(取上界用)。 */
|
|
40
|
+
export const RISK_ORDER = Object.freeze(['low', 'medium', 'high', 'critical'])
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 因子 → 档位 / 缓解措施的**冻结对照表**。
|
|
44
|
+
*
|
|
45
|
+
* 这张表是"已归类的风险事实"的全部词表。表外的一切都是**未识别**(⇒ critical)。
|
|
46
|
+
* 之所以是表而不是公式:风险档位是**判断**,把判断写成表使它可以被逐条审计与对照,
|
|
47
|
+
* 而"按某个公式算出来"会把判断藏在系数里(那正是"未识别"的伪装)。
|
|
48
|
+
*/
|
|
49
|
+
export const FACTOR_TABLE = Object.freeze({
|
|
50
|
+
'read-only-observation': { level: 'low', mitigation: '只读观测:不写入任何共享状态;保留原始观测记录' },
|
|
51
|
+
'reversible-local': { level: 'low', mitigation: '本地可逆:变更前快照,失败即恢复;不触碰共享状态根' },
|
|
52
|
+
'resource-consuming': { level: 'low', mitigation: '资源消耗:在预算上限内执行,超限暂停并报告(不降级)' },
|
|
53
|
+
'shared-state': { level: 'medium', mitigation: '共享状态:变更前备份并在链内记录;变更后复核内容哈希' },
|
|
54
|
+
'external-side-effect': { level: 'medium', mitigation: '外部副作用:先干跑(dry-run)确认影响面,再实施' },
|
|
55
|
+
'artifact-overwrite': { level: 'medium', mitigation: '覆盖产物:保留原件与内容哈希,覆盖前后逐位比对' },
|
|
56
|
+
'irreversible-in-place': { level: 'high', mitigation: '就地变更(独立风险档):必须先产出完整回退目标(含产物哈希)并取得人工确认,方可执行' },
|
|
57
|
+
'irreproducible': { level: 'high', mitigation: '不可复现:把全部输入(种子/版本/数据哈希)随产物落盘,否则视为不可接受' },
|
|
58
|
+
'affects-eval-data': { level: 'high', mitigation: '触及评估数据:评估集是只读红线;任何写入必须先封印复核' },
|
|
59
|
+
'user-visible-position': { level: 'high', mitigation: '对外可见位点:须人工确认后执行,并保留可回退版本' },
|
|
60
|
+
/* ── 结果质量风险(R5-G2,2026-10-02 第五轮事故驱动)─────────────────────
|
|
61
|
+
* 由来:第五轮"延长训练"方向只声明了**过程**因子(resource-consuming / shared-state),
|
|
62
|
+
* 拿到 medium 档;而实际发生的代价是**模型被交易**——格式合规 0.126→0.602 的同时,
|
|
63
|
+
* wikitext NLL 统计显著退化(6.2× 于噪声界),是**工具侧的遗忘门**把它拦住的。
|
|
64
|
+
* ⇒ 系统会评"做这件事危不危险",**不会**评"做完之后这还是不是原来那个东西"。
|
|
65
|
+
* 下面四个因子专门描述**结果质量**:执行成功 ≠ 资产没被弄坏。 */
|
|
66
|
+
'capability-tradeoff': { level: 'high', mitigation: '能力交易:明确声明"换取什么、付出什么",并把**代价维度写进冻结判据**(G8 并列必过),不接受"只看目标指标"的验收' },
|
|
67
|
+
'distribution-shift': { level: 'high', mitigation: '分布偏移:目标分布外的度量(如通用语言建模)必须**前后各测一次**并给出噪声界' },
|
|
68
|
+
'task-overfit': { level: 'medium', mitigation: '任务过拟合:训练步数/轮次增加时须同时报告留出集与**非目标任务**的指标' },
|
|
69
|
+
'data-contamination': { level: 'high', mitigation: '数据污染:确认训练集与评估集内容哈希不相交(本仓库已有 seal 机制)' },
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/** **结果质量**类因子(R5-G2):只声明过程因子拿到的档位**不能代表**这类风险。 */
|
|
73
|
+
export const QUALITY_FACTORS = Object.freeze(['capability-tradeoff', 'distribution-shift', 'task-overfit', 'data-contamination'])
|
|
74
|
+
|
|
75
|
+
/** 自动注入的结构布尔 —— 它们不做判断,只是把"方向自己声明的结构事实"翻译成因子。 */
|
|
76
|
+
const INJECTED_FACTORS = Object.freeze({
|
|
77
|
+
irreversible: 'irreversible-in-place',
|
|
78
|
+
inPlace: 'irreversible-in-place',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
export function riskAssessment(input) {
|
|
82
|
+
const arg = assertPlainObject(input ?? {}, 'riskAssessment 入参', 'E_INPUT_SHAPE')
|
|
83
|
+
const direction = assertPlainObject(arg.direction ?? null, 'direction', 'E_INPUT_SHAPE')
|
|
84
|
+
if (!nonEmptyString(direction.id)) throw new RsiLoopError('E_INPUT_SHAPE', 'direction.id 必须是非空字符串')
|
|
85
|
+
|
|
86
|
+
const declared = optionalArray(direction.factors, 'direction.factors', 'E_INPUT_SHAPE').map(String)
|
|
87
|
+
const injected = Object.keys(INJECTED_FACTORS).filter((k) => direction[k] === true).map((k) => INJECTED_FACTORS[k])
|
|
88
|
+
|
|
89
|
+
// 未声明任何因子(含注入)⇒ 未识别。**注意这是"没有信息",不是"没有风险"。**
|
|
90
|
+
const declaredNothing = declared.length === 0 && injected.length === 0
|
|
91
|
+
const factorNames = [...new Set([...declared, ...injected])].sort()
|
|
92
|
+
|
|
93
|
+
const reasons = []
|
|
94
|
+
const factorLevels = []
|
|
95
|
+
let level = null
|
|
96
|
+
|
|
97
|
+
if (declaredNothing) {
|
|
98
|
+
level = 'critical'
|
|
99
|
+
reasons.push('未声明任何风险因子 ⇒ 风险未识别(E_RISK_UNCLASSIFIED)⇒ 升到 critical(绝不判安全)')
|
|
100
|
+
} else {
|
|
101
|
+
for (const f of factorNames) {
|
|
102
|
+
const known = Object.prototype.hasOwnProperty.call(FACTOR_TABLE, f)
|
|
103
|
+
const lv = known ? FACTOR_TABLE[f].level : 'critical'
|
|
104
|
+
factorLevels.push({ factor: f, level: lv, known, injected: injected.includes(f) && !declared.includes(f) })
|
|
105
|
+
if (!known) reasons.push(`因子「${f}」不在已归类词表中 ⇒ 未识别 ⇒ 升到 critical(E_RISK_UNCLASSIFIED,fail-closed)`)
|
|
106
|
+
}
|
|
107
|
+
// 取上界
|
|
108
|
+
level = factorLevels.map((x) => x.level).reduce((a, b) => (RISK_ORDER.indexOf(b) > RISK_ORDER.indexOf(a) ? b : a), 'low')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── R5-G2:结果质量风险是否被声明(**可见性**,不改档位)──
|
|
112
|
+
const qualityDeclared = factorNames.filter((f) => QUALITY_FACTORS.includes(f))
|
|
113
|
+
if (!declaredNothing && qualityDeclared.length === 0) {
|
|
114
|
+
reasons.push('**未声明任何结果质量风险因子**(capability-tradeoff / distribution-shift / task-overfit / data-contamination 一个都没有)⇒ 本次评估只覆盖了"执行过程"风险,**没有覆盖"执行后资产是否被弄坏"**(第五轮实测:正是这一类被漏掉)')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const mitigations = uniqSorted(
|
|
118
|
+
factorNames
|
|
119
|
+
.map((f) => (Object.prototype.hasOwnProperty.call(FACTOR_TABLE, f) ? FACTOR_TABLE[f].mitigation : null))
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.concat(declaredNothing ? ['未归类风险禁止在无人工确认的情况下执行'] : []),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
// ── 自评校准(可选输入;不给就不编造校准结论) ──
|
|
125
|
+
const computedLevel = level
|
|
126
|
+
const hasUnknownFactor = factorLevels.some((f) => !f.known)
|
|
127
|
+
let verdict = 'undeclared'
|
|
128
|
+
// "未识别"是独立于自评的一等结论:没有因子、或有词表外的因子,都必须报出来。
|
|
129
|
+
let code = declaredNothing || hasUnknownFactor ? 'E_RISK_UNCLASSIFIED' : null
|
|
130
|
+
let declaredLevel = null
|
|
131
|
+
if (nonEmptyString(direction.declaredLevel)) {
|
|
132
|
+
if (!VOCAB.riskLevel.includes(direction.declaredLevel)) {
|
|
133
|
+
throw new RsiLoopError('E_INPUT_SHAPE', `direction.declaredLevel=${direction.declaredLevel} 不在 ${VOCAB.riskLevel.join('/')} 之内`)
|
|
134
|
+
}
|
|
135
|
+
declaredLevel = direction.declaredLevel
|
|
136
|
+
const d = RISK_ORDER.indexOf(declaredLevel)
|
|
137
|
+
const c = RISK_ORDER.indexOf(computedLevel)
|
|
138
|
+
if (d < c) {
|
|
139
|
+
verdict = 'underestimated'
|
|
140
|
+
code = 'E_RISK_UNDERESTIMATE'
|
|
141
|
+
reasons.push(`低估风险:自评 ${declaredLevel} 低于计算档位 ${computedLevel} ⇒ 采用计算档位(不采纳低估)`)
|
|
142
|
+
} else if (d > c) {
|
|
143
|
+
verdict = 'overestimated'
|
|
144
|
+
code = code ?? 'E_RISK_OVERESTIMATE'
|
|
145
|
+
level = declaredLevel // 保留更高档位:把人的谨慎降下来是另一种错
|
|
146
|
+
reasons.push(`高估风险:自评 ${declaredLevel} 高于计算档位 ${computedLevel} ⇒ 保留自评档位(本能力不替调用方降档)`)
|
|
147
|
+
} else {
|
|
148
|
+
verdict = 'consistent'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const requiresHumanConfirmation = level === 'high' || level === 'critical'
|
|
153
|
+
if (requiresHumanConfirmation) reasons.push(`档位 ${level} ⇒ requiresHumanConfirmation=true`)
|
|
154
|
+
if (injected.length > 0 && !declaredNothing) {
|
|
155
|
+
reasons.push(`由结构布尔自动注入因子:${uniqSorted(injected).join(', ')}(调用方未列出,但结构事实已声明)`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
capability: CAPABILITY_ID,
|
|
160
|
+
version: CAPABILITY_VERSION,
|
|
161
|
+
level,
|
|
162
|
+
mitigations,
|
|
163
|
+
requiresHumanConfirmation,
|
|
164
|
+
reasons,
|
|
165
|
+
factorLevels,
|
|
166
|
+
calibration: { declaredLevel, computedLevel, verdict },
|
|
167
|
+
qualityRisk: { declared: qualityDeclared, vocabulary: [...QUALITY_FACTORS], covered: qualityDeclared.length > 0 },
|
|
168
|
+
refused: false,
|
|
169
|
+
code,
|
|
170
|
+
evidenceRefs: uniqSorted(Array.isArray(direction.evidenceRefs) ? direction.evidenceRefs.filter(nonEmptyString) : []),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 供 index 侧断言"词表里没有 safe/none 档"(这条断言会随任何人加回 safe 而变红)。 */
|
|
175
|
+
export function assertNoSafeLevel() {
|
|
176
|
+
const bad = VOCAB.riskLevel.filter((l) => ['none', 'safe', 'no-risk'].includes(l))
|
|
177
|
+
if (bad.length > 0) throw new RsiLoopError('E_INPUT_SHAPE', `风险词表不得含"安全"档:${bad.join(', ')}`)
|
|
178
|
+
const badFactor = Object.entries(FACTOR_TABLE).filter(([, v]) => ['none', 'safe'].includes(v.level)).map(([k]) => k)
|
|
179
|
+
if (badFactor.length > 0) throw new RsiLoopError('E_INPUT_SHAPE', `因子 ${badFactor.join(', ')} 被归为"安全"档 —— 风险档位里不存在安全`)
|
|
180
|
+
return true
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 只读暴露因子表(供测试与 CI 枚举,不复制第二份)。 */
|
|
184
|
+
export function factorTable() {
|
|
185
|
+
return jsonClone(FACTOR_TABLE, 'FACTOR_TABLE')
|
|
186
|
+
}
|