@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,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* criterion.mjs —— 显著性判据的**唯一一份实现**(本库内)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么单独一个文件
|
|
5
|
+
*
|
|
6
|
+
* `effectJudgment` 要回答"这点改进是不是噪声"。判据形式在本仓库**已经冻结过**,
|
|
7
|
+
* 见 `inference-training/checks/overfit-criterion.mjs` 的 O2 判据:
|
|
8
|
+
*
|
|
9
|
+
* improvement > z · SE (单侧:留出集上的改进必须显著为正)
|
|
10
|
+
*
|
|
11
|
+
* 与 `@xiciyang/testing-core` 的 `dualGate` 同一条纪律:**先判测量是否成立,再判数值**。
|
|
12
|
+
* 本库**不新增第二套判据**,只把同一条形式落成一份实现,供 `effectJudgment` 调用。
|
|
13
|
+
*
|
|
14
|
+
* ## 与冻结实现的关系(可验证,不是口头声明)
|
|
15
|
+
*
|
|
16
|
+
* `tests/unit/effect-judgment.spec.mjs` 里有一条**跨实现一致性**断言:
|
|
17
|
+
* 若 `inference-training/checks/overfit-criterion.mjs` 可 import,
|
|
18
|
+
* 则本函数的 `significant` 位必须与 `evaluateOverfit(...).o2.pass` **逐例一致**。
|
|
19
|
+
* 该文件不在本库目录内(**只读引用,未修改**);不可 import 时按仓库的 skip 通道显式登记,
|
|
20
|
+
* 不计入断言数(把"没跑"写成恒真断言会灌水指标)。
|
|
21
|
+
*
|
|
22
|
+
* ## 常量从哪来
|
|
23
|
+
*
|
|
24
|
+
* `z` 与 `SE` **一律由调用方给出**,本库**不含任何默认值** ——
|
|
25
|
+
* 硬编码 `z=1.96` 会让"判据是谁定的"这件事无解,而本仓库的红线是:
|
|
26
|
+
* **阈值必须由调用方显式给出,缺失即拒绝(fail-closed)**。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** 判据的可复核文本(随证据一起返回,使第三方不必读代码就知道用了什么公式)。 */
|
|
30
|
+
export const SIGNIFICANCE_CRITERION = 'improvement > z · SE (单侧;z 与 SE 均由调用方给出,本库无默认值)'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 单侧显著性判定。
|
|
34
|
+
* @param {{improvement:number, se:number, z:number}} p
|
|
35
|
+
* @returns {{significant:boolean, improvement:number, se:number, z:number, bound:number, criterion:string}}
|
|
36
|
+
*/
|
|
37
|
+
export function significance({ improvement, se, z } = {}) {
|
|
38
|
+
const d = Number(improvement)
|
|
39
|
+
const s = Number(se)
|
|
40
|
+
const zz = Number(z)
|
|
41
|
+
// 这里**不抛错也不兜底**:调用方的体检(三个值是否齐备/是否有限)在 effectJudgment 里
|
|
42
|
+
// 按"缺阈值 ⇒ refused"的语义处理;本函数只负责算,且拒绝把非有限值算成结论。
|
|
43
|
+
if (!Number.isFinite(d) || !Number.isFinite(s) || !Number.isFinite(zz)) {
|
|
44
|
+
return { significant: null, improvement: Number.isFinite(d) ? d : null, se: Number.isFinite(s) ? s : null, z: Number.isFinite(zz) ? zz : null, bound: null, criterion: SIGNIFICANCE_CRITERION }
|
|
45
|
+
}
|
|
46
|
+
const bound = zz * s
|
|
47
|
+
return { significant: d > bound, improvement: d, se: s, z: zz, bound, criterion: SIGNIFICANCE_CRITERION }
|
|
48
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* direction-selection.mjs —— 能力 ②「方向选择」。
|
|
3
|
+
*
|
|
4
|
+
* 输入:候选目标 + **声明的可用手段菜单**(+ 可选约束)。
|
|
5
|
+
* 输出:一个推荐方向 + 理由 + 期望收益 + **透传的风险** + 被考虑过的备选。
|
|
6
|
+
*
|
|
7
|
+
* ─────────────────────────── 四条硬纪律 ───────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* ① **菜单之外的手段不可选**。`preferredMeansId` 不在 `means` 里 ⇒ 整体 refused。
|
|
10
|
+
* "不可行"最纯粹的形态就是"它根本不在你能做的事的清单上"。
|
|
11
|
+
*
|
|
12
|
+
* ② **方向必须真的打在目标度量上**。手段的 `targets` 不含 `goal.targetMetric` ⇒ 不可采纳;
|
|
13
|
+
* 被 `preferredMeansId` 强制指定时整体拒绝(不偷偷换成别的目标)。
|
|
14
|
+
*
|
|
15
|
+
* ③ **风险必须透传,不得自产**。手段必须自带 `risk`(`riskAssessment` 的产物)。
|
|
16
|
+
* 缺 risk ⇒ 整体拒绝:选一个未评估风险的方向正是"风险未识别"的入口。
|
|
17
|
+
* 本能力**不做风险判断**,只把它原样搬进产物(深拷贝,产物自持)。
|
|
18
|
+
*
|
|
19
|
+
* ④ **记录被考虑过的备选**。每一个菜单项都要在 `alternativesConsidered` 里出现,
|
|
20
|
+
* 并带上"可采纳/被拒 + 稳定 code + 原因"。只报结论不报备选,等于让第三方无法复核选择。
|
|
21
|
+
*
|
|
22
|
+
* 选择规则写进产物(`selectionRule`):在可采纳集合里取 `expectedGain` 最大者,
|
|
23
|
+
* 同值按 `meansId` 升序 —— **全序,与输入顺序无关**(确定性要求)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { CAPABILITY_VERSION } from './version.mjs'
|
|
27
|
+
import { assertPlainObject, assertArray, optionalArray, finiteNumber, directionOf, jsonClone, uniqSorted, nonEmptyString, RsiLoopError } from './support.mjs'
|
|
28
|
+
import { VOCAB } from './contract.mjs'
|
|
29
|
+
|
|
30
|
+
export const CAPABILITY_ID = 'directionSelection'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* **约束语义版本** —— 随产物返回,让第三方能回答"这个结论是用哪一版约束语义判的"。
|
|
34
|
+
*
|
|
35
|
+
* - `strict`(**默认,v2.0.0**):`forbid-irreversible` 把 `irreversible === true` **或**
|
|
36
|
+
* `inPlace === true` **都**算不可逆 —— 与 `riskAssessment` 的"`inPlace` ⇒ 自动注入
|
|
37
|
+
* `irreversible-in-place`"**对齐**。
|
|
38
|
+
* - `legacy`(**v1.x 原样保留**):只看 `means.irreversible`。经
|
|
39
|
+
* `options.legacyForbidIrreversible = true` 启用,**仅用于对照与回退**。
|
|
40
|
+
*
|
|
41
|
+
* ## 为什么这是一次 major 变更(`CONTRACT.md` §5)
|
|
42
|
+
* v1.x 下,"声明了 `forbid-irreversible` 却选中就地变更手段"是**可能的**(已实测复现)。
|
|
43
|
+
* 两个能力对"什么算不可逆"的理解不一致,属于**判据形式**的变更 ⇒ 升 `CAPABILITY_VERSION`。
|
|
44
|
+
* 旧语义**不删除**:保留可回退路径,且新 spec 用**同一份输入**跑出两种结论作对照。
|
|
45
|
+
*/
|
|
46
|
+
export const CONSTRAINT_SEMANTICS = Object.freeze({
|
|
47
|
+
STRICT: 'strict-includes-inPlace@2',
|
|
48
|
+
LEGACY: 'legacy-means.irreversible-only@1',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export const SELECTION_RULE = '在"菜单内 ∧ 打在目标度量上 ∧ 满足全部声明约束 ∧ 已附带风险结论 ∧ 增益可测"的手段中,取 expectedGain 最大者;同值按 meansId 升序(全序,与输入顺序无关)。本能力不做风险判断,风险由 riskAssessment 产出后透传。`forbid-irreversible` 默认按 strict 语义判(irreversible 或 inPlace 皆算不可逆);legacy 语义(只看 irreversible)经 options.legacyForbidIrreversible 启用。'
|
|
52
|
+
|
|
53
|
+
export function directionSelection(input) {
|
|
54
|
+
const arg = assertPlainObject(input ?? {}, 'directionSelection 入参', 'E_INPUT_SHAPE')
|
|
55
|
+
const goal = assertPlainObject(arg.goal ?? null, 'goal', 'E_INPUT_SHAPE')
|
|
56
|
+
const means = assertArray(arg.means ?? null, 'means', 'E_INPUT_SHAPE')
|
|
57
|
+
const constraints = optionalArray(arg.constraints, 'constraints', 'E_INPUT_SHAPE')
|
|
58
|
+
const options = arg.options === undefined || arg.options === null ? {} : assertPlainObject(arg.options, 'options', 'E_INPUT_SHAPE')
|
|
59
|
+
// 缺省 = strict(fail-closed:不显式要求 legacy 就拿不到旧语义)
|
|
60
|
+
const legacyForbidIrreversible = options.legacyForbidIrreversible === true
|
|
61
|
+
const constraintSemantics = legacyForbidIrreversible ? CONSTRAINT_SEMANTICS.LEGACY : CONSTRAINT_SEMANTICS.STRICT
|
|
62
|
+
|
|
63
|
+
const goalMetric = nonEmptyString(goal.targetMetric) ? goal.targetMetric : null
|
|
64
|
+
const goalDirection = directionOf(goal.direction)
|
|
65
|
+
if (goalMetric === null || goalDirection === null) {
|
|
66
|
+
throw new RsiLoopError('E_INPUT_SHAPE', 'goal 必须给出非空 targetMetric 与合法 direction')
|
|
67
|
+
}
|
|
68
|
+
const goalRefs = Array.isArray(goal.evidenceRefs) ? goal.evidenceRefs.filter(nonEmptyString) : []
|
|
69
|
+
|
|
70
|
+
const base = {
|
|
71
|
+
capability: CAPABILITY_ID,
|
|
72
|
+
version: CAPABILITY_VERSION,
|
|
73
|
+
chosen: null,
|
|
74
|
+
rationale: '',
|
|
75
|
+
expectedGain: null,
|
|
76
|
+
risk: null,
|
|
77
|
+
alternativesConsidered: [],
|
|
78
|
+
refused: false,
|
|
79
|
+
code: null,
|
|
80
|
+
reasons: [],
|
|
81
|
+
evidenceRefs: uniqSorted(goalRefs),
|
|
82
|
+
selectionRule: SELECTION_RULE,
|
|
83
|
+
constraintSemantics,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── 菜单为空:refuse(不发散) ──
|
|
87
|
+
if (means.length === 0) {
|
|
88
|
+
return { ...base, refused: true, code: 'E_NO_MEANS', reasons: ['means 菜单为空 ⇒ 无任何可采纳方向(本库不发明手段)'] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const menuIds = means.map((m) => m?.id).filter(nonEmptyString)
|
|
92
|
+
const byId = new Map()
|
|
93
|
+
for (const m of means) if (m && nonEmptyString(m.id)) byId.set(m.id, m)
|
|
94
|
+
|
|
95
|
+
// ── 逐手段判定可采纳性(顺序无关:全部算完再排序) ──
|
|
96
|
+
const considered = []
|
|
97
|
+
for (const m of means) {
|
|
98
|
+
considered.push(judgeMeans(m, { goalMetric, constraints, legacyForbidIrreversible }))
|
|
99
|
+
}
|
|
100
|
+
considered.sort((a, b) => (a.meansId < b.meansId ? -1 : a.meansId > b.meansId ? 1 : 0))
|
|
101
|
+
base.alternativesConsidered = considered.map(({ meansId, admissible, code, reason, expectedGain: eg, expectedGainBasis: eb }) => ({ meansId, admissible, code, reason, expectedGain: eg ?? null, expectedGainBasis: eb ?? null }))
|
|
102
|
+
|
|
103
|
+
// ── 调用方指定了手段:必须在菜单内,且必须真的可采纳 ──
|
|
104
|
+
if (arg.preferredMeansId !== undefined && arg.preferredMeansId !== null) {
|
|
105
|
+
const pid = String(arg.preferredMeansId)
|
|
106
|
+
if (!menuIds.includes(pid)) {
|
|
107
|
+
return {
|
|
108
|
+
...base,
|
|
109
|
+
refused: true,
|
|
110
|
+
code: 'E_MEANS_NOT_IN_MENU',
|
|
111
|
+
reasons: [`preferredMeansId=${pid} 不在声明的菜单内(菜单:${menuIds.join(', ') || '空'})⇒ 不可行,拒绝执行(fail-closed)`],
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const c = considered.find((x) => x.meansId === pid)
|
|
115
|
+
if (!c.admissible) {
|
|
116
|
+
return {
|
|
117
|
+
...base,
|
|
118
|
+
refused: true,
|
|
119
|
+
code: c.code,
|
|
120
|
+
reasons: [`preferredMeansId=${pid} 在菜单内但不可采纳:${c.reason}`],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return finalize(base, byId.get(pid), goalMetric, goalDirection, considered, goalRefs, '调用方指定且可采纳')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 自动选择:可采纳集合里取 expectedGain 最大 ──
|
|
127
|
+
const admissible = considered.filter((c) => c.admissible)
|
|
128
|
+
if (admissible.length === 0) {
|
|
129
|
+
return {
|
|
130
|
+
...base,
|
|
131
|
+
refused: true,
|
|
132
|
+
code: 'E_NO_ADMISSIBLE_MEANS',
|
|
133
|
+
reasons: [
|
|
134
|
+
`菜单内 ${considered.length} 个手段全部不可采纳 ⇒ 不选方向(拒绝理由见 alternativesConsidered)`,
|
|
135
|
+
...considered.map((c) => `${c.meansId}: ${c.code} —— ${c.reason}`),
|
|
136
|
+
],
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// **R5-G3 排序修正**:先按依据(measured 优先),再按增益降序,最后按 meansId。
|
|
140
|
+
// 为什么依据优先于数值:第五轮实测"judged 0.05 / 实测 0.4761"——
|
|
141
|
+
// 拍出来的幅度**连量级都不可信**,让它和 measured 同权比较就是把噪声当信号。
|
|
142
|
+
admissible.sort((a, b) => {
|
|
143
|
+
const ba = a.expectedGainBasis === 'measured' ? 0 : 1
|
|
144
|
+
const bb = b.expectedGainBasis === 'measured' ? 0 : 1
|
|
145
|
+
if (ba !== bb) return ba - bb
|
|
146
|
+
if (a.expectedGain !== b.expectedGain) return b.expectedGain - a.expectedGain
|
|
147
|
+
return a.meansId < b.meansId ? -1 : a.meansId > b.meansId ? 1 : 0
|
|
148
|
+
})
|
|
149
|
+
const best = admissible[0]
|
|
150
|
+
return finalize(base, byId.get(best.meansId), goalMetric, goalDirection, considered, goalRefs, `可采纳集合中增益最大(${admissible.length} 个可采纳 / 共 ${considered.length} 个)`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 单个手段的可采纳性判定 —— 四条闸门,返回稳定 code 与可直接展示的原因。 */
|
|
154
|
+
function judgeMeans(m, { goalMetric, constraints, legacyForbidIrreversible = false }) {
|
|
155
|
+
if (!m || typeof m !== 'object') return { meansId: '(非对象)', admissible: false, code: 'E_INPUT_SHAPE', reason: '手段不是对象', expectedGain: null }
|
|
156
|
+
const meansId = nonEmptyString(m.id) ? m.id : '(无 id)'
|
|
157
|
+
const targets = Array.isArray(m.targets) ? m.targets : null
|
|
158
|
+
if (targets === null) return { meansId, admissible: false, code: 'E_INPUT_SHAPE', reason: '手段缺 targets 数组', expectedGain: null }
|
|
159
|
+
|
|
160
|
+
// ② 方向必须真的打在目标度量上
|
|
161
|
+
if (!targets.includes(goalMetric)) {
|
|
162
|
+
return { meansId, admissible: false, code: 'E_DIRECTION_MISMATCH', reason: `手段 targets=[${targets.join(', ')}] 不含目标度量 ${goalMetric} ⇒ 方向错误(改了别的东西)`, expectedGain: null }
|
|
163
|
+
}
|
|
164
|
+
// 增益必须可测
|
|
165
|
+
if (!finiteNumber(m.expectedGain)) {
|
|
166
|
+
return { meansId, admissible: false, code: 'E_MEANS_UNMEASURABLE', reason: 'expectedGain 不是有限数 ⇒ 无法比较收益(不猜)', expectedGain: null }
|
|
167
|
+
}
|
|
168
|
+
// **R5-G3**:增益必须声明**依据**(measured / judged)。
|
|
169
|
+
// 由来:第五轮实测 expectedGain 判断值 0.05,实得 0.4761(**差 9.5 倍**)——
|
|
170
|
+
// 幅度预测不可信。不区分依据,排序就等于拿"拍脑袋的数"和"量出来的数"同权比较。
|
|
171
|
+
const basis = m.expectedGainBasis
|
|
172
|
+
if (basis !== 'measured' && basis !== 'judged') {
|
|
173
|
+
return { meansId, admissible: false, code: 'E_GAIN_BASIS_MISSING', reason: `expectedGainBasis=${JSON.stringify(basis)} 不在 ['measured','judged'] 之内 ⇒ 无法判断这个增益是量出来的还是拍出来的(第五轮实测拍出来的偏了 9.5 倍)`, expectedGain: m.expectedGain }
|
|
174
|
+
}
|
|
175
|
+
// 声明为 measured ⇒ 必须给出可解析的来源引用(否则"measured"三个字就是空口)
|
|
176
|
+
if (basis === 'measured') {
|
|
177
|
+
const refs = Array.isArray(m.gainEvidenceRefs) ? m.gainEvidenceRefs.filter(nonEmptyString) : []
|
|
178
|
+
if (refs.length === 0) {
|
|
179
|
+
return { meansId, admissible: false, code: 'E_GAIN_BASIS_MISSING', reason: "expectedGainBasis='measured' 但没给 gainEvidenceRefs ⇒ '量出来的'必须能指到测量", expectedGain: m.expectedGain }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// ③ 风险必须透传
|
|
183
|
+
if (m.risk === undefined || m.risk === null || typeof m.risk !== 'object' || Array.isArray(m.risk)) {
|
|
184
|
+
return { meansId, admissible: false, code: 'E_RISK_MISSING', reason: '手段未附带 risk(riskAssessment 的产物)⇒ 不得选择未评估风险的方向', expectedGain: m.expectedGain }
|
|
185
|
+
}
|
|
186
|
+
if (!VOCAB.riskLevel.includes(m.risk.level)) {
|
|
187
|
+
return { meansId, admissible: false, code: 'E_RISK_MISSING', reason: `risk.level=${String(m.risk.level)} 不在词表内 ⇒ 风险结论不可用`, expectedGain: m.expectedGain }
|
|
188
|
+
}
|
|
189
|
+
// ④ 声明约束
|
|
190
|
+
for (const c of constraints) {
|
|
191
|
+
if (!c || typeof c !== 'object') continue
|
|
192
|
+
if (c.kind === 'forbid-means' && c.meansId === meansId) {
|
|
193
|
+
return { meansId, admissible: false, code: 'E_CONSTRAINT_VIOLATION', reason: `约束 ${c.id ?? '(无 id)'} 明令禁止手段 ${meansId}`, expectedGain: m.expectedGain }
|
|
194
|
+
}
|
|
195
|
+
if (c.kind === 'forbid-irreversible') {
|
|
196
|
+
// **strict(默认)**:`inPlace` 同样算不可逆 —— 与 riskAssessment 的自动注入判据对齐。
|
|
197
|
+
// **legacy**:只看 `irreversible`(v1.x 原样行为,仅供对照/回退)。
|
|
198
|
+
// 判据基于**结构布尔**,不看调用方有没有把因子列进 `factors` ——
|
|
199
|
+
// 漏写因子正是"风险未识别"的入口(`riskAssessment` 在那边用注入兜住,这里用结构布尔兜住)。
|
|
200
|
+
const irreversible = m.irreversible === true
|
|
201
|
+
const inPlace = m.inPlace === true
|
|
202
|
+
const blocked = legacyForbidIrreversible ? irreversible : (irreversible || inPlace)
|
|
203
|
+
if (blocked) {
|
|
204
|
+
return {
|
|
205
|
+
meansId, admissible: false, code: 'E_CONSTRAINT_VIOLATION',
|
|
206
|
+
reason: `约束 ${c.id ?? '(无 id)'} 禁止不可逆手段,而本手段 declared irreversible=${irreversible} / inPlace=${inPlace}`
|
|
207
|
+
+ (legacyForbidIrreversible
|
|
208
|
+
? '(legacy 语义:只看 irreversible —— 这正是要保留的对照)'
|
|
209
|
+
: '(strict 语义:inPlace 同样算不可逆,与 riskAssessment 的自动注入一致)'),
|
|
210
|
+
expectedGain: m.expectedGain,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (c.kind === 'require-target' && Array.isArray(c.targets) && !c.targets.every((t) => targets.includes(t))) {
|
|
215
|
+
return { meansId, admissible: false, code: 'E_CONSTRAINT_VIOLATION', reason: `约束 ${c.id ?? '(无 id)'} 要求覆盖 [${c.targets.join(', ')}],本手段只覆盖 [${targets.join(', ')}]`, expectedGain: m.expectedGain }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { meansId, admissible: true, code: null, reason: null, expectedGain: m.expectedGain, expectedGainBasis: basis }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function finalize(base, means, goalMetric, goalDirection, considered, goalRefs, why) {
|
|
222
|
+
const m = means
|
|
223
|
+
const meansRefs = Array.isArray(m.evidenceRefs) ? m.evidenceRefs.filter(nonEmptyString) : []
|
|
224
|
+
// 风险**透传**:深拷贝(jsonClone 同时证明它可 JSON 化),并原样记录来源手段。
|
|
225
|
+
const risk = jsonClone(m.risk, `means[${m.id}].risk`)
|
|
226
|
+
return {
|
|
227
|
+
...base,
|
|
228
|
+
chosen: { meansId: m.id, targetMetric: goalMetric, direction: goalDirection, expectedGain: m.expectedGain },
|
|
229
|
+
rationale: `${why}。选中 ${m.id}:expectedGain=${m.expectedGain};风险档位 ${risk.level}(**透传自 riskAssessment,本能力未自行评估**)。被考虑过的备选:${considered.filter((c) => c.meansId !== m.id).map((c) => `${c.meansId}(${c.admissible ? '可采纳但增益较低' : c.code})`).join(', ') || '无'}`,
|
|
230
|
+
expectedGain: m.expectedGain,
|
|
231
|
+
risk,
|
|
232
|
+
evidenceRefs: uniqSorted([...goalRefs, ...meansRefs]),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effect-judgment.mjs —— 能力 ③「效果判定」(五个能力里最重要的一个)。
|
|
3
|
+
*
|
|
4
|
+
* 输入:before/after 指标 **+ 测量完整性**(attempted/succeeded/failed/partial + 评估集身份)+ 阈值 + 离散度。
|
|
5
|
+
* 输出:`{ verdict: 'success' | 'failure' | 'measurement-invalid', evidence, reasons[] }`。
|
|
6
|
+
*
|
|
7
|
+
* ─────────────────────────── 它必须做的事(按契约) ───────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* ① **不自证**。完整性证据必须由**别处**生产;`integrity.producedBy === 'effectJudgment'` 直接判无效。
|
|
10
|
+
* 自产的"我测过了"不是证据。
|
|
11
|
+
*
|
|
12
|
+
* ② **测量不成立 ⇒ 永不 success**。完整性缺失 / `failed > 0` / `partial === true` / 零样本
|
|
13
|
+
* ⇒ `measurement-invalid`。这条与缺陷 14 同源:**不完整的测量不是测量**。
|
|
14
|
+
*
|
|
15
|
+
* ③ **跨评估集不比 Δ**。两侧 `datasetHash` 不同或缺哈希 ⇒ `measurement-invalid`。
|
|
16
|
+
* 依据 `inference-contracts/contracts/eval-contract.md` §7:Δ 只在同一 `datasetHash` 内可比。
|
|
17
|
+
*
|
|
18
|
+
* ④ **噪声内不得判成功**。`improvement ≤ z·SE` ⇒ `failure` + `inconclusive: true`。
|
|
19
|
+
* 判据形式与 `inference-training/checks/overfit-criterion.mjs` 的 O2 判据逐字一致
|
|
20
|
+
* (见 `criterion.mjs`),且 `z`/`SE` **由调用方给出** —— 本库没有 1.96 这样的默认值。
|
|
21
|
+
* 缺阈值 ⇒ `E_THRESHOLD_NOT_PROVIDED`(refused),**绝不自行发明判据**。
|
|
22
|
+
*
|
|
23
|
+
* ## 判定顺序与可区分性
|
|
24
|
+
*
|
|
25
|
+
* 全部有效性检查**都跑完**再下结论(`reasons[]` 列出全部失败项),
|
|
26
|
+
* 但 `code` 取**固定优先级**里的第一条 —— 于是"同一个坏测量"的产物是确定的(确定性要求),
|
|
27
|
+
* 且不同病因的 `code` 互不相同(可区分性要求)。顺序见 `EFFECT_CHECK_ORDER`。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { CAPABILITY_VERSION } from './version.mjs'
|
|
31
|
+
import { assertPlainObject, finiteNumber, nonEmptyString, directionOf, jsonClone, uniqSorted, improvementOf, RsiLoopError } from './support.mjs'
|
|
32
|
+
import { significance, SIGNIFICANCE_CRITERION } from './criterion.mjs'
|
|
33
|
+
|
|
34
|
+
export const CAPABILITY_ID = 'effectJudgment'
|
|
35
|
+
|
|
36
|
+
/** 有效性检查的固定顺序(`code` 取第一条失败项;测试据此断言覆盖率)。 */
|
|
37
|
+
export const EFFECT_CHECK_ORDER = Object.freeze([
|
|
38
|
+
'E_INTEGRITY_MISSING',
|
|
39
|
+
'E_MEASUREMENT_INCOMPLETE',
|
|
40
|
+
'E_NO_SAMPLES',
|
|
41
|
+
'E_METRIC_MISMATCH',
|
|
42
|
+
'E_METRIC_VALUE_MISSING',
|
|
43
|
+
'E_DATASET_IDENTITY_MISSING',
|
|
44
|
+
'E_DATASET_MISMATCH',
|
|
45
|
+
'E_SELF_JUDGING',
|
|
46
|
+
'E_THRESHOLD_NOT_PROVIDED',
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
export function effectJudgment(input) {
|
|
50
|
+
const arg = assertPlainObject(input ?? {}, 'effectJudgment 入参', 'E_INPUT_SHAPE')
|
|
51
|
+
const before = assertPlainObject(arg.before ?? null, 'before', 'E_INPUT_SHAPE')
|
|
52
|
+
const after = assertPlainObject(arg.after ?? null, 'after', 'E_INPUT_SHAPE')
|
|
53
|
+
const direction = directionOf(arg.direction)
|
|
54
|
+
if (direction === null) throw new RsiLoopError('E_INPUT_SHAPE', 'direction 必须是 higher-is-better / lower-is-better 之一')
|
|
55
|
+
|
|
56
|
+
const integrity = arg.integrity
|
|
57
|
+
const threshold = arg.threshold
|
|
58
|
+
const noise = arg.noise
|
|
59
|
+
|
|
60
|
+
const failures = []
|
|
61
|
+
const add = (code, reason) => failures.push({ code, reason })
|
|
62
|
+
|
|
63
|
+
/* ① 完整性证据本身存不存在(`未知 ≠ 已知完整`) */
|
|
64
|
+
let hasIntegrity = false
|
|
65
|
+
if (integrity === undefined || integrity === null || typeof integrity !== 'object' || Array.isArray(integrity)) {
|
|
66
|
+
add('E_INTEGRITY_MISSING', 'integrity 缺失 ⇒ 不知道这次测量成不成立(缺证据不得当作完整)')
|
|
67
|
+
} else if (!finiteNumber(integrity.attempted) || !finiteNumber(integrity.succeeded) || !finiteNumber(integrity.failed) || typeof integrity.partial !== 'boolean') {
|
|
68
|
+
add('E_INTEGRITY_MISSING', 'integrity 缺 attempted/succeeded/failed/partial 中的字段,或类型不对 ⇒ 无法判定测量完整性')
|
|
69
|
+
} else if (integrity.succeeded > integrity.attempted) {
|
|
70
|
+
add('E_INTEGRITY_MISSING', `integrity 自相矛盾:succeeded(${integrity.succeeded}) > attempted(${integrity.attempted})`)
|
|
71
|
+
} else {
|
|
72
|
+
hasIntegrity = true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ② 不完整的测量不是测量(缺陷 14 的正面防线) */
|
|
76
|
+
if (hasIntegrity) {
|
|
77
|
+
if (integrity.failed > 0 || integrity.partial === true) {
|
|
78
|
+
add('E_MEASUREMENT_INCOMPLETE', `测量不完整:failed=${integrity.failed}/attempted=${integrity.attempted}、partial=${integrity.partial} ⇒ 不完整测量不得判 success`)
|
|
79
|
+
}
|
|
80
|
+
if (integrity.succeeded <= 0 || integrity.attempted <= 0) {
|
|
81
|
+
add('E_NO_SAMPLES', `零成功样本:succeeded=${integrity.succeeded}/attempted=${integrity.attempted} ⇒ 没有可判定的测量`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ③ 前后必须是同一个度量 */
|
|
86
|
+
if (nonEmptyString(before.metric) && nonEmptyString(after.metric) && before.metric !== after.metric) {
|
|
87
|
+
add('E_METRIC_MISMATCH', `before.metric=${before.metric} ≠ after.metric=${after.metric} ⇒ 两个不同度量之间没有 Δ`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ④ 测量值必须在场(缺值不得当 0) */
|
|
91
|
+
if (!finiteNumber(before.value) || !finiteNumber(after.value)) {
|
|
92
|
+
add('E_METRIC_VALUE_MISSING', `测量值缺失:before.value=${fmt(before.value)}、after.value=${fmt(after.value)} ⇒ 缺值不得当 0 参与判定`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ⑤ 评估集身份(eval-contract §7) */
|
|
96
|
+
const hb = nonEmptyString(before.datasetHash) ? before.datasetHash : null
|
|
97
|
+
const ha = nonEmptyString(after.datasetHash) ? after.datasetHash : null
|
|
98
|
+
if (hb === null || ha === null) {
|
|
99
|
+
add('E_DATASET_IDENTITY_MISSING', `评估集身份缺失:before.datasetHash=${fmt(hb)}、after.datasetHash=${fmt(ha)} ⇒ 不可复核 ⇒ 不得判定`)
|
|
100
|
+
} else if (hb !== ha) {
|
|
101
|
+
add('E_DATASET_MISMATCH', `跨评估集比较:before.datasetHash=${hb} ≠ after.datasetHash=${ha} ⇒ Δ 只在同一 datasetHash 内可比(eval-contract §7.1)`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ⑥ 自证防线 */
|
|
105
|
+
if (integrity && typeof integrity === 'object' && !Array.isArray(integrity) && integrity.producedBy === CAPABILITY_ID) {
|
|
106
|
+
add('E_SELF_JUDGING', `integrity.producedBy=${CAPABILITY_ID} ⇒ 本能力在用自己的产物当验证(自证不算证据)`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ⑦ 阈值:缺失即拒绝,不发明判据 */
|
|
110
|
+
const minDelta = threshold && typeof threshold === 'object' && !Array.isArray(threshold) ? threshold.minDelta : undefined
|
|
111
|
+
const se = noise && typeof noise === 'object' && !Array.isArray(noise) ? noise.se : undefined
|
|
112
|
+
const z = noise && typeof noise === 'object' && !Array.isArray(noise) ? noise.z : undefined
|
|
113
|
+
const thresholdsOk =
|
|
114
|
+
finiteNumber(minDelta) && finiteNumber(se) && finiteNumber(z) && se >= 0 && z >= 0
|
|
115
|
+
if (!thresholdsOk) {
|
|
116
|
+
add('E_THRESHOLD_NOT_PROVIDED', `判据输入不完整或非法:minDelta=${fmt(minDelta)}、se=${fmt(se)}、z=${fmt(z)}(须为有限数且 se/z ≥ 0;本库不提供默认阈值/默认 z)`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ── 结算 ── */
|
|
120
|
+
const refs = uniqSorted([
|
|
121
|
+
...(Array.isArray(before.evidenceRefs) ? before.evidenceRefs.filter(nonEmptyString) : []),
|
|
122
|
+
...(Array.isArray(after.evidenceRefs) ? after.evidenceRefs.filter(nonEmptyString) : []),
|
|
123
|
+
...(integrity && Array.isArray(integrity.evidenceRefs) ? integrity.evidenceRefs.filter(nonEmptyString) : []),
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
const commonEvidence = {
|
|
127
|
+
metric: nonEmptyString(before.metric) ? before.metric : nonEmptyString(after.metric) ? after.metric : null,
|
|
128
|
+
direction,
|
|
129
|
+
datasetHash: hb !== null && hb === ha ? hb : null,
|
|
130
|
+
integrity: integrity && typeof integrity === 'object' && !Array.isArray(integrity) ? jsonClone(integrity, 'integrity') : null,
|
|
131
|
+
values: {
|
|
132
|
+
before: finiteNumber(before.value) ? before.value : null,
|
|
133
|
+
after: finiteNumber(after.value) ? after.value : null,
|
|
134
|
+
improvement: null,
|
|
135
|
+
minDelta: finiteNumber(minDelta) ? minDelta : null,
|
|
136
|
+
se: finiteNumber(se) ? se : null,
|
|
137
|
+
z: finiteNumber(z) ? z : null,
|
|
138
|
+
noiseBound: null,
|
|
139
|
+
criterion: thresholdsOk ? SIGNIFICANCE_CRITERION : null,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (failures.length > 0) {
|
|
144
|
+
const primary = EFFECT_CHECK_ORDER.map((c) => failures.find((f) => f.code === c)).find(Boolean) ?? failures[0]
|
|
145
|
+
return {
|
|
146
|
+
capability: CAPABILITY_ID,
|
|
147
|
+
version: CAPABILITY_VERSION,
|
|
148
|
+
verdict: 'measurement-invalid',
|
|
149
|
+
refused: true,
|
|
150
|
+
code: primary.code,
|
|
151
|
+
evidence: commonEvidence,
|
|
152
|
+
reasons: failures.map((f) => `${f.code}: ${f.reason}`),
|
|
153
|
+
evidenceRefs: refs,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* 全部有效性通过 ⇒ 才谈数值 */
|
|
158
|
+
const improvement = improvementOf({ before: before.value, after: after.value, direction })
|
|
159
|
+
const sig = significance({ improvement, se, z })
|
|
160
|
+
const evidence = { ...commonEvidence, values: { ...commonEvidence.values, improvement, noiseBound: sig.bound, criterion: SIGNIFICANCE_CRITERION } }
|
|
161
|
+
|
|
162
|
+
if (!sig.significant) {
|
|
163
|
+
return {
|
|
164
|
+
capability: CAPABILITY_ID,
|
|
165
|
+
version: CAPABILITY_VERSION,
|
|
166
|
+
verdict: 'failure',
|
|
167
|
+
refused: false,
|
|
168
|
+
code: 'E_WITHIN_NOISE',
|
|
169
|
+
inconclusive: true,
|
|
170
|
+
evidence,
|
|
171
|
+
reasons: [
|
|
172
|
+
`改进量 ${fmt(improvement)} 未超过噪声界 z·SE = ${fmt(z)}×${fmt(se)} = ${fmt(sig.bound)} ⇒ 落在测量噪声内,**不得判 success**(判据:${SIGNIFICANCE_CRITERION})`,
|
|
173
|
+
],
|
|
174
|
+
evidenceRefs: refs,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (improvement < minDelta) {
|
|
179
|
+
return {
|
|
180
|
+
capability: CAPABILITY_ID,
|
|
181
|
+
version: CAPABILITY_VERSION,
|
|
182
|
+
verdict: 'failure',
|
|
183
|
+
refused: false,
|
|
184
|
+
code: 'E_BELOW_THRESHOLD',
|
|
185
|
+
inconclusive: false,
|
|
186
|
+
evidence,
|
|
187
|
+
reasons: [`改进量 ${fmt(improvement)} 显著(> ${fmt(sig.bound)})但未达阈值 minDelta=${fmt(minDelta)} ⇒ 未达标`],
|
|
188
|
+
evidenceRefs: refs,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
capability: CAPABILITY_ID,
|
|
194
|
+
version: CAPABILITY_VERSION,
|
|
195
|
+
verdict: 'success',
|
|
196
|
+
refused: false,
|
|
197
|
+
code: null,
|
|
198
|
+
inconclusive: false,
|
|
199
|
+
evidence,
|
|
200
|
+
reasons: [
|
|
201
|
+
`测量成立(failed=${integrity.failed}/attempted=${integrity.attempted}、partial=${integrity.partial}、datasetHash=${hb})`,
|
|
202
|
+
`改进量 ${fmt(improvement)} > 噪声界 ${fmt(sig.bound)} 且 ≥ 阈值 ${fmt(minDelta)}`,
|
|
203
|
+
],
|
|
204
|
+
evidenceRefs: refs,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function fmt(v) {
|
|
209
|
+
return typeof v === 'number' ? String(v) : String(v)
|
|
210
|
+
}
|