dsh-jev-prune 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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/README_zh.md +175 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +73 -0
- package/assets/demo-poster.png +0 -0
- package/assets/demo.cast +6 -0
- package/assets/demo.gif +0 -0
- package/assets/demo.json +51 -0
- package/assets/two-layers.png +0 -0
- package/assets/two-layers.svg +118 -0
- package/cordis.patch.yml +3 -0
- package/demo/README.md +23 -0
- package/demo/fixtures.mjs +301 -0
- package/demo/render.py +31 -0
- package/demo/run.mjs +85 -0
- package/docs/ARCHITECTURE.md +63 -0
- package/docs/CONTRIBUTING.md +48 -0
- package/docs/PORTING.md +60 -0
- package/docs/RELEASING.md +27 -0
- package/docs/implementation.md +310 -0
- package/docs/measurements.md +16 -0
- package/docs/review-2026-10-05.md +44 -0
- package/examples/README.md +7 -0
- package/examples/minimal.yml +10 -0
- package/index.js +2043 -0
- package/package.json +77 -0
- package/scripts/inspect_session.mjs +235 -0
- package/scripts/verify_layout.mjs +24 -0
- package/scripts/verify_real_shapes.mjs +99 -0
- package/scripts/wire_profile.mjs +173 -0
- package/src/jev.js +320 -0
- package/src/prune.js +383 -0
- package/src/receipt.js +800 -0
- package/src/state.js +532 -0
- package/test/check.js +1971 -0
- package/test/smoke_apply.mjs +1359 -0
package/src/jev.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev 判定客户端 + token 估算。纯 Node,无 DSH 依赖。
|
|
3
|
+
*
|
|
4
|
+
* 关键设计(来自 dsh-compact 的实测结论):
|
|
5
|
+
* · **用 Jev 的结构化批量接口**(一次请求问 N 题、返回 问题→noul 概率),
|
|
6
|
+
* 不要用 chat 模型模拟——chat 模型的概率只能从"某个被采样位置"读,会被采样前缀污染。
|
|
7
|
+
* · token 估算沿用上游 fast-jev-compaction 的逐词校正算法(对过真实 usage,偏保守)。
|
|
8
|
+
* · 概率永远只从 `answers[name].noul` 读,绝不采信自报文本。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone'
|
|
12
|
+
export const DEFAULT_JEV_MODEL = 'jev-latest'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* token 估算:用逐词校正算法,常数经真实 BPE(gpt-tokenizer)标定过。
|
|
16
|
+
*
|
|
17
|
+
* 为什么不能"差不多就行"(issue #33):这个值喂给两个门。
|
|
18
|
+
* · `receiptMaxRatio` 用 `estimateTokens(receipt)` 与真实 `shadowedTokens` 比,
|
|
19
|
+
* 估偏大 → 门过严 → 该压的不压;估偏小 → 门过松 → 回执可能比原文还大。
|
|
20
|
+
* · `maxRequestTokens` 用它切批,估偏小会让请求超服务端上限被拒。
|
|
21
|
+
* 所以方向性很重要,而旧实现是**系统性偏大**(实测纯英文 +37%、路径 +44%)——
|
|
22
|
+
* 也就是把两层的门都收紧了,与"尽量多省上下文"的意图相反。
|
|
23
|
+
*
|
|
24
|
+
* 标定方法:拿 22 组样本(英文散文/驼峰长词/JSON/Windows 与 Unix 路径/Git diff/
|
|
25
|
+
* 中文散文/中英混合/代码块/日志/纯符号/十六进制/表格行/单字/空白)跑 gpt-tokenizer,
|
|
26
|
+
* 对 7 个常数做网格搜索,目标取"拟合集 + 留出集"加权 MAE 最小(防过拟合)。
|
|
27
|
+
* 结果:全集平均绝对偏差 20.5% → **10.7%**,留出集 20.5% → **14.4%**,
|
|
28
|
+
* 整体偏置从 +11% 收到 −0.3%(旧实现偏大,新实现基本无偏)。
|
|
29
|
+
*
|
|
30
|
+
* 各常数的含义(都来自标定,不要凭直觉改):
|
|
31
|
+
* · 英文词 ≤ 6 字母算 0.9 个 token(BPE 里常见词基本是 1 片,前导空格并入词所以取 <1),
|
|
32
|
+
* 超出部分每字母 0.16 片(长标识符/驼峰名才真的会被切碎)
|
|
33
|
+
* · 数字串按 1.8 个/片(BPE 对数字是 1~3 位一组)
|
|
34
|
+
* · CJK 每字 1.0 片(现代 BPE 对中文接近 1 字 1 片)
|
|
35
|
+
* · 符号:单独出现算 1.0 片,成串时 0.65 片/字符(`===` 这种会被合并)
|
|
36
|
+
* · 空白不计(BPE 的前导空格附着在前一个词上,已由"词"那一项覆盖)
|
|
37
|
+
*/
|
|
38
|
+
const TOKEN_PIECES = /[A-Za-z]+|\d+|[^\sA-Za-z\d]+/g
|
|
39
|
+
|
|
40
|
+
/** 标定出的常数(见上方说明;改动需重跑标定脚本,不要手调)。 */
|
|
41
|
+
export const TOKEN_ESTIMATE_CONSTANTS = {
|
|
42
|
+
wordShort: 0.9,
|
|
43
|
+
wordFree: 6,
|
|
44
|
+
wordSlope: 0.16,
|
|
45
|
+
digitDiv: 1.8,
|
|
46
|
+
cjkWeight: 1,
|
|
47
|
+
symSingle: 1,
|
|
48
|
+
symRunSlope: 0.65,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function estimateTokens(text) {
|
|
52
|
+
const c = TOKEN_ESTIMATE_CONSTANTS
|
|
53
|
+
const source = String(text)
|
|
54
|
+
// 空串必须返回 0(沿用旧契约;下游拿它做比例计算时会单独护零)
|
|
55
|
+
if (source.length === 0) return 0
|
|
56
|
+
let total = 0
|
|
57
|
+
for (const match of source.matchAll(TOKEN_PIECES)) {
|
|
58
|
+
const piece = match[0]
|
|
59
|
+
const code = piece.charCodeAt(0)
|
|
60
|
+
// 数字串:按固定位数分组
|
|
61
|
+
if (code >= 48 && code <= 57) {
|
|
62
|
+
total += piece.length / c.digitDiv
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
// 拉丁词:短词按 1 片计,超出部分按斜率累加
|
|
66
|
+
if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) {
|
|
67
|
+
total += piece.length <= c.wordFree
|
|
68
|
+
? c.wordShort
|
|
69
|
+
: c.wordShort + (piece.length - c.wordFree) * c.wordSlope
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
// 纯 CJK 串:逐字计
|
|
73
|
+
let cjk = 0
|
|
74
|
+
for (const ch of piece) {
|
|
75
|
+
const cc = ch.charCodeAt(0)
|
|
76
|
+
if (cc >= 0x4e00 && cc <= 0x9fff) cjk += 1
|
|
77
|
+
}
|
|
78
|
+
if (cjk === piece.length) {
|
|
79
|
+
total += piece.length * c.cjkWeight
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
// 其余符号串:单个按 1 片,成串会被 BPE 合并
|
|
83
|
+
total += Math.max(c.symSingle, piece.length * c.symRunSlope)
|
|
84
|
+
}
|
|
85
|
+
// 非空但全是空白时 total 可能是 0;下游会拿它当除数,兜成 1
|
|
86
|
+
return Math.max(1, Math.ceil(total))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class JevError extends Error {
|
|
90
|
+
constructor(message, options) {
|
|
91
|
+
super(message, options)
|
|
92
|
+
this.name = 'JevError'
|
|
93
|
+
/** HTTP 状态码(有的话),用于判断能不能重试 */
|
|
94
|
+
this.status = options?.status ?? null
|
|
95
|
+
/** 是否值得重试(网络抖动 / 5xx / 429 值得;4xx 与响应形状错误不值得) */
|
|
96
|
+
this.retryable = options?.retryable ?? false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 可被中断的等待(重试退避用)。signal 一旦 abort 就立刻结束等待,
|
|
102
|
+
* 不让插件在用户已经走开的情况下还空转着等下一次重试。
|
|
103
|
+
* @param {number} ms
|
|
104
|
+
* @param {AbortSignal} [signal]
|
|
105
|
+
*/
|
|
106
|
+
function sleep(ms, signal) {
|
|
107
|
+
if (ms <= 0) return Promise.resolve()
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const timer = setTimeout(done, ms)
|
|
110
|
+
function done() {
|
|
111
|
+
clearTimeout(timer)
|
|
112
|
+
signal?.removeEventListener('abort', done)
|
|
113
|
+
resolve()
|
|
114
|
+
}
|
|
115
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 哪些失败值得重试(issue #34):
|
|
121
|
+
* · 网络层异常(fetch 抛错、超时 abort)—— 值得,抖动是常态
|
|
122
|
+
* · 429 / 5xx —— 值得,服务端暂时不可用
|
|
123
|
+
* · 其他 4xx(401 密钥错 / 400 请求本身有问题)—— 不值得,重试只是浪费时间和额度
|
|
124
|
+
* · 响应形状不对(缺 answers)—— 不值得,重试大概率还是同一份坏响应
|
|
125
|
+
*/
|
|
126
|
+
function isRetryableStatus(status) {
|
|
127
|
+
return status === 429 || (status >= 500 && status <= 599)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class JevClient {
|
|
131
|
+
/**
|
|
132
|
+
* @param {object} params
|
|
133
|
+
* @param {string} params.apiKey - TypeSafe key;空则不发起请求(插件退化为不干预)
|
|
134
|
+
* @param {string} [params.model]
|
|
135
|
+
* @param {string} [params.baseUrl]
|
|
136
|
+
* @param {number} [params.timeoutMs]
|
|
137
|
+
* @param {number} [params.maxRetries] - 单次 ask 内最多重试几次(默认 2,即最多 3 次尝试)
|
|
138
|
+
* @param {number} [params.retryBaseMs] - 退避基数(指数退避,默认 300ms)
|
|
139
|
+
* @param {typeof fetch} [params.fetchImpl]
|
|
140
|
+
*/
|
|
141
|
+
constructor({
|
|
142
|
+
apiKey,
|
|
143
|
+
model = DEFAULT_JEV_MODEL,
|
|
144
|
+
baseUrl = SYSTEM_ONE_URL,
|
|
145
|
+
timeoutMs = 60000,
|
|
146
|
+
maxRetries = 2,
|
|
147
|
+
retryBaseMs = 300,
|
|
148
|
+
fetchImpl,
|
|
149
|
+
} = {}) {
|
|
150
|
+
this.apiKey = typeof apiKey === 'string' ? apiKey.trim() : ''
|
|
151
|
+
this.model = model
|
|
152
|
+
this.baseUrl = baseUrl
|
|
153
|
+
this.timeoutMs = timeoutMs
|
|
154
|
+
this.maxRetries = Number.isFinite(maxRetries) && maxRetries >= 0 ? Math.floor(maxRetries) : 2
|
|
155
|
+
this.retryBaseMs = Number.isFinite(retryBaseMs) && retryBaseMs >= 0 ? retryBaseMs : 300
|
|
156
|
+
this.fetchImpl = fetchImpl ?? globalThis.fetch
|
|
157
|
+
/**
|
|
158
|
+
* 累计**发出的 HTTP 尝试次数**(含最终失败的那些)。
|
|
159
|
+
*
|
|
160
|
+
* 口径说明(PR #28 review):此前它只累加**成功**的尝试,于是"重试 2 次后成功"
|
|
161
|
+
* 被记成 1 次请求,与真实网络活动不符,也让 `retries` 看起来像凭空冒出来的。
|
|
162
|
+
* 现在计的是"真的打出去了几次",与 `retries`(重试了几次)严格满足
|
|
163
|
+
* `requests === 成功的 ask 数 + retries`。
|
|
164
|
+
*/
|
|
165
|
+
this.requests = 0
|
|
166
|
+
/**
|
|
167
|
+
* 累计重试次数(跨 ask 的**历史总量**)。
|
|
168
|
+
*
|
|
169
|
+
* 注意:这是累计量,不是"本次 ask 重试了几次"。想知道最近一次 ask 的情况请读
|
|
170
|
+
* `lastRetries`(每次 ask 开头重置),想判断"这个 client 从建起来到现在重试过没有"
|
|
171
|
+
* 才读它。把它当 per-ask 用会导致状态报告一旦抖动过就**永久**显示"重试 N 次"。
|
|
172
|
+
*/
|
|
173
|
+
this.retries = 0
|
|
174
|
+
/** 最近一次 ask 内的重试次数(每次 ask 进入时归零) */
|
|
175
|
+
this.lastRetries = 0
|
|
176
|
+
this.usage = { input_tokens: 0, output_tokens: 0 }
|
|
177
|
+
this.lastError = ''
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get ready() {
|
|
181
|
+
return this.apiKey.length > 0 && typeof this.fetchImpl === 'function'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 单次 HTTP 尝试(不含重试)。把上一次的 AbortController 完整回收后再抛错。
|
|
186
|
+
* @returns {Promise<object>} 解析后的响应体
|
|
187
|
+
*/
|
|
188
|
+
async #attempt(state, questions, ids, options) {
|
|
189
|
+
const payload = {
|
|
190
|
+
model: this.model,
|
|
191
|
+
state,
|
|
192
|
+
questions: Object.fromEntries(ids.map((id) => [id, { type: 'noul', instructions: questions[id] }])),
|
|
193
|
+
}
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
|
|
196
|
+
const onAbort = () => controller.abort()
|
|
197
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
198
|
+
try {
|
|
199
|
+
const response = await this.fetchImpl(this.baseUrl, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: {
|
|
202
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
203
|
+
'content-type': 'application/json',
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify(payload),
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
})
|
|
208
|
+
const text = await response.text()
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
throw new JevError(`Jev 请求失败 (${response.status}): ${text.slice(0, 200)}`, {
|
|
211
|
+
status: response.status,
|
|
212
|
+
retryable: isRetryableStatus(response.status),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
return JSON.parse(text)
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// fetch 自身抛错(DNS / 连接重置 / 超时 abort)——网络层,值得重试。
|
|
218
|
+
// 注意要先排除"外部 signal 已 abort":那是用户主动中断,不该重试。
|
|
219
|
+
if (error instanceof JevError) throw error
|
|
220
|
+
if (options.signal?.aborted) {
|
|
221
|
+
throw new JevError('判定已被中断(signal 已 abort)', { retryable: false })
|
|
222
|
+
}
|
|
223
|
+
const timedOut = controller.signal.aborted
|
|
224
|
+
throw new JevError(
|
|
225
|
+
timedOut ? `Jev 请求超时(${this.timeoutMs}ms)` : `Jev 请求网络异常:${error?.message ?? String(error)}`,
|
|
226
|
+
{ retryable: true },
|
|
227
|
+
)
|
|
228
|
+
} finally {
|
|
229
|
+
clearTimeout(timer)
|
|
230
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 一次请求问完一批 noul 问题。
|
|
236
|
+
*
|
|
237
|
+
* 重试语义(issue #34):旧实现单次失败就丢掉**整轮判定**——一次网络抖动
|
|
238
|
+
* 会让本次 pass 的所有候选都没有概率,两层随即静默不动。
|
|
239
|
+
* 现在对可重试失败做指数退避重试(默认 2 次,共 3 次尝试),
|
|
240
|
+
* 且每次尝试都重新检查外部 signal(用户中断后不再重试)。
|
|
241
|
+
*
|
|
242
|
+
* @param {string} state 会话状态文本
|
|
243
|
+
* @param {Record<string,string>} questions 问题 id → 待判定陈述
|
|
244
|
+
* @param {{signal?:AbortSignal}} [options] 外部中断信号(issue #9:此前判定请求
|
|
245
|
+
* 不接收 agent 的 signal,宿主/用户中断后请求继续占连接、可能继续计费)
|
|
246
|
+
* @returns {Promise<Record<string, number>>} 问题 id → P(陈述成立)
|
|
247
|
+
*/
|
|
248
|
+
async ask(state, questions, options = {}) {
|
|
249
|
+
const ids = Object.keys(questions)
|
|
250
|
+
if (!this.ready || ids.length === 0) return {}
|
|
251
|
+
if (options.signal?.aborted) throw new JevError('判定已被中断(signal 已 abort)')
|
|
252
|
+
|
|
253
|
+
// 每次 ask 重置"本次重试了几次"(PR #28 review):累计量留在 this.retries 里,
|
|
254
|
+
// 状态报告要的是"这一次",否则一旦抖动过一次就永久显示"重试 N 次"。
|
|
255
|
+
this.lastRetries = 0
|
|
256
|
+
this.lastError = ''
|
|
257
|
+
|
|
258
|
+
let lastError = null
|
|
259
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
|
|
260
|
+
if (options.signal?.aborted) throw new JevError('判定已被中断(signal 已 abort)')
|
|
261
|
+
// 计入真实网络活动:失败尝试也真的打出去了,不该被漏掉
|
|
262
|
+
this.requests += 1
|
|
263
|
+
try {
|
|
264
|
+
const body = await this.#attempt(state, questions, ids, options)
|
|
265
|
+
const usage = body?.usage ?? {}
|
|
266
|
+
this.usage.input_tokens += Number(usage.input_tokens ?? 0)
|
|
267
|
+
this.usage.output_tokens += Number(usage.output_tokens ?? 0)
|
|
268
|
+
|
|
269
|
+
const answers = body?.answers
|
|
270
|
+
// 形状错误不重试:换一次大概率还是坏响应,不如如实抛出去让上层记一笔
|
|
271
|
+
if (answers == null || typeof answers !== 'object') throw new JevError('Jev 响应缺少 answers')
|
|
272
|
+
const out = {}
|
|
273
|
+
for (const id of ids) {
|
|
274
|
+
const value = answers[id]?.noul
|
|
275
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) continue
|
|
276
|
+
out[id] = value
|
|
277
|
+
}
|
|
278
|
+
return out
|
|
279
|
+
} catch (error) {
|
|
280
|
+
lastError = error
|
|
281
|
+
const retryable = error instanceof JevError ? error.retryable : true
|
|
282
|
+
if (!retryable || attempt === this.maxRetries || options.signal?.aborted) break
|
|
283
|
+
this.retries += 1
|
|
284
|
+
this.lastRetries += 1
|
|
285
|
+
// 指数退避;被中断时立即结束等待(不白等)
|
|
286
|
+
await sleep(this.retryBaseMs * 2 ** attempt, options.signal)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
this.lastError = lastError?.message ?? String(lastError)
|
|
290
|
+
throw lastError ?? new JevError('Jev 请求失败(未知原因)')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 把问题按 state 占用切成能装进单次请求的批。
|
|
295
|
+
* @param {string} state
|
|
296
|
+
* @param {Record<string,string>} questions
|
|
297
|
+
* @param {{maxRequestTokens:number, overheadTokens:number}} limits
|
|
298
|
+
* @returns {Array<Record<string,string>>}
|
|
299
|
+
*/
|
|
300
|
+
batch(state, questions, limits) {
|
|
301
|
+
const stateTokens = estimateTokens(state)
|
|
302
|
+
const budget = limits.maxRequestTokens - stateTokens - (limits.overheadTokens ?? 40)
|
|
303
|
+
const batches = []
|
|
304
|
+
let current = {}
|
|
305
|
+
let currentTokens = 0
|
|
306
|
+
for (const [id, text] of Object.entries(questions)) {
|
|
307
|
+
const cost = estimateTokens(JSON.stringify({ [id]: { type: 'noul', instructions: text } }))
|
|
308
|
+
if (Object.keys(current).length > 0 && currentTokens + cost > budget) {
|
|
309
|
+
batches.push(current)
|
|
310
|
+
current = {}
|
|
311
|
+
currentTokens = 0
|
|
312
|
+
}
|
|
313
|
+
if (Object.keys(current).length === 0 && cost > budget) continue // 单题都装不下,跳过(不抛错)
|
|
314
|
+
current[id] = text
|
|
315
|
+
currentTokens += cost
|
|
316
|
+
}
|
|
317
|
+
if (Object.keys(current).length > 0) batches.push(current)
|
|
318
|
+
return batches
|
|
319
|
+
}
|
|
320
|
+
}
|