@max-null/dsh-allostasis 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/LICENSE +21 -0
- package/README.md +71 -0
- package/cordis.patch.yml +6 -0
- package/dist/anchor.d.ts +23 -0
- package/dist/anchor.js +31 -0
- package/dist/drift.d.ts +42 -0
- package/dist/drift.js +48 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +65 -0
- package/dist/thinking.d.ts +27 -0
- package/dist/thinking.js +34 -0
- package/dist/throttle.d.ts +33 -0
- package/dist/throttle.js +30 -0
- package/package.json +69 -0
- package/tools/analyze-thinking-lang.mjs +151 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Max-Null
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @max-null/dsh-allostasis
|
|
2
|
+
|
|
3
|
+
本插件属于 **`@max-null/*` 插件系列**——这一系列共同构成 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 桌面体验。SSID 是整合它们的盒:`dsh-allostasis` · `dsh-capture` · `dsh-chat-rail` · `dsh-chinese-thinking` · `dsh-draft-polish` · `dsh-guardian` · `dsh-habit` · `dsh-memory` · `dsh-node-appearance` · `dsh-plugin-center` · `dsh-quick-toolbar` · `dsh-skill-mcp-center` · `dsh-ssid-panels` · `dsh-ssid-zh-ui` · `dsh-achievements`。
|
|
4
|
+
|
|
5
|
+
This plugin belongs to the **`@max-null/*` family** — a set of plugins that together form the **[SSID (思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** desktop experience.
|
|
6
|
+
|
|
7
|
+
Allostasis for the DeepSeek Harness — session self-regulation rather than monitoring.
|
|
8
|
+
Its first capability is **Chinese anchoring**: before each step it reads the most recent
|
|
9
|
+
reasoning block, and when that thinking has drifted into English it appends one near-end
|
|
10
|
+
message to bring it back.
|
|
11
|
+
|
|
12
|
+
## 它做什么
|
|
13
|
+
|
|
14
|
+
**应变**(allostasis)取「**通过改变自己来维持自己**」之义——它不只是一个监测器,它会改变行为。
|
|
15
|
+
|
|
16
|
+
| 能力 | 状态 | 触发条件 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| **中文锚定** | **已实现** | 上一步思考的英文功能词密度越线 |
|
|
19
|
+
| 上下文占用感知 | 计划中 | 需先定「什么情况下才出现」(持续在场会退化成背景音) |
|
|
20
|
+
| 压缩预约落盘 | 计划中 | 待通路验证:`turn/end` → `whenIdle()` → `compactNow()` |
|
|
21
|
+
|
|
22
|
+
判据来源、实测数据与完整设计见 `docs/设计/2026-09-20-应变-设计方案.md`。
|
|
23
|
+
|
|
24
|
+
## 为什么需要它
|
|
25
|
+
|
|
26
|
+
`dsh-chinese-thinking` 把「始终使用中文进行思考」挂在 system prompt 的**固定前缀**上——那是对的定位:基线、常驻、cache-safe。但固定前缀离每一次输出最远,而语言模式受**近因**支配;长会话里基线会变弱。
|
|
27
|
+
|
|
28
|
+
本插件补的是**纠偏信号**——「你正在漂移」。它是叠加而非替代:基线层永远在场,增强层只在检测到漂移时出现。**平时不出现**正是它作为信号的前提;一段一直存在的提醒会退化成背景音,重演它要修的那个问题。
|
|
29
|
+
|
|
30
|
+
## 判据
|
|
31
|
+
|
|
32
|
+
**英文功能词密度** =(`the` / `is` / `are` / `and` / `of` / `to` / `that` 这类功能词数)/ 英文词数,**且英文词数 ≥ 50 才判定**。
|
|
33
|
+
|
|
34
|
+
实测数据(一份 10 turn / 157 step 的真实会话):中文期中位 **0.012**、英文期中位 **0.273–0.389**,阈值 **0.15** 使两群完全分离。词数门槛不可省——中文期唯一的越线点只有 26 个英文词,小样本会让密度失真。
|
|
35
|
+
|
|
36
|
+
另外两个看起来更直观的指标被否掉:**中文字符占比**(中文思考本来就大量夹英文标识符,实测中文期只有 0.19–0.37,区分度不足);**最长连续英文游程**(中文思考引用一段代码就会把它顶高,它测的是「引用了多长的代码」而非「用什么语言思考」)。
|
|
37
|
+
|
|
38
|
+
阈值由 `tools/analyze-thinking-lang.mjs` 在一份真实会话上标定,该工具随包发布。
|
|
39
|
+
|
|
40
|
+
## 截图
|
|
41
|
+
|
|
42
|
+
本插件是**会话行为调节类**:不新增任何按钮、面板或设置项。它在会话过程中读取思考的语言,漂移时向请求末尾追加一条提醒,效果体现在模型行为上。
|
|
43
|
+
|
|
44
|
+
> 按《SSiD 开发手册》§9 截图规范:截图须回答「装完会多出/变成什么」的**入口与面板**。
|
|
45
|
+
> 本插件无界面元素(no UI surface),故**不适用**该项要求,改以上述行为效果说明代替。
|
|
46
|
+
|
|
47
|
+
## Compose
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
# cordis.yml (or via the bundle patch):
|
|
51
|
+
- id: allostasis
|
|
52
|
+
name: '@max-null/dsh-allostasis'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requires `agents` and `system-prompt` in the host composition (dsh-base ships both).
|
|
56
|
+
Installs as a bundle: `dsh plugin --profile <name> add @max-null/dsh-allostasis`.
|
|
57
|
+
|
|
58
|
+
## Config
|
|
59
|
+
|
|
60
|
+
第一期没有配置项;阈值与门槛是经过标定的常量,写在 `src/drift.ts` 里并附实测依据。
|
|
61
|
+
|
|
62
|
+
## Develop
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npm install --legacy-peer-deps # DSH peer types resolve via tsconfig paths
|
|
66
|
+
npm test # vitest
|
|
67
|
+
npm run typecheck # tsc against the adjacent deepseek-harness lib/types
|
|
68
|
+
npm run build # emit dist/
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**注意 tsconfig 的 `paths` 用 `../../deepseek-harness/...`**(本仓库位于 `max-null-plugins/` 下,`deepseek-harness/` 在再上一级)。写成 `../deepseek-harness/...` 时 TypeScript 会**静默回落**到 `node_modules` 里 npm 装的旧版 DSH 类型——症状是「某些显然存在的成员报 not exported」,而不是找不到模块。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# dsh-allostasis bundle patch: one insert over the profile root.
|
|
2
|
+
# `system-prompt` / `agents` / `token-meter` already live in the host (dsh-base);
|
|
3
|
+
# this adds only the allostasis plugin, which registers its own reminders.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: allostasis
|
|
6
|
+
name: '@max-null/dsh-allostasis'
|
package/dist/anchor.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 锚定文本的组装。
|
|
3
|
+
*
|
|
4
|
+
* 单独成文件而不是留在入口里:入口 `index.ts` 要导入 `@deepseek-ai/dsh-llm` 的
|
|
5
|
+
* `createUserMessage`(运行时依赖,由宿主 profile 提供),而测试环境只装了 npm 上的
|
|
6
|
+
* 部分 DSH 包——放在同一个文件里会让纯文本逻辑的测试连带去解析 DSH 运行时。
|
|
7
|
+
* @module @max-null/dsh-allostasis/anchor
|
|
8
|
+
*/
|
|
9
|
+
import type { DriftMetrics } from './drift.ts';
|
|
10
|
+
/**
|
|
11
|
+
* 组装锚定文本。
|
|
12
|
+
*
|
|
13
|
+
* 刻意点明「引用英文标识符是正常的」:判据只看英文语法结构,若不说明,模型可能为了
|
|
14
|
+
* 规避提醒而不敢写代码符号,那会伤到正常工作。
|
|
15
|
+
* 第二次起带上序号并改口径(「你仍然在用英文思考」):节流之后,同一条提醒仍可能在
|
|
16
|
+
* 后续 turn 重复出现,措辞一成不变的话,重复的那几条就是纯噪音。
|
|
17
|
+
* @param turn - 产出该思考的 turn。
|
|
18
|
+
* @param step - 产出该思考的 step。
|
|
19
|
+
* @param metrics - 那一步思考的量化结果。
|
|
20
|
+
* @param reminder - 这是本会话第几次提醒;默认 1。
|
|
21
|
+
* @returns 一条锚定消息的正文。
|
|
22
|
+
*/
|
|
23
|
+
export declare function anchorText(turn: number, step: number, metrics: DriftMetrics, reminder?: number): string;
|
package/dist/anchor.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 锚定文本的组装。
|
|
3
|
+
*
|
|
4
|
+
* 单独成文件而不是留在入口里:入口 `index.ts` 要导入 `@deepseek-ai/dsh-llm` 的
|
|
5
|
+
* `createUserMessage`(运行时依赖,由宿主 profile 提供),而测试环境只装了 npm 上的
|
|
6
|
+
* 部分 DSH 包——放在同一个文件里会让纯文本逻辑的测试连带去解析 DSH 运行时。
|
|
7
|
+
* @module @max-null/dsh-allostasis/anchor
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 组装锚定文本。
|
|
11
|
+
*
|
|
12
|
+
* 刻意点明「引用英文标识符是正常的」:判据只看英文语法结构,若不说明,模型可能为了
|
|
13
|
+
* 规避提醒而不敢写代码符号,那会伤到正常工作。
|
|
14
|
+
* 第二次起带上序号并改口径(「你仍然在用英文思考」):节流之后,同一条提醒仍可能在
|
|
15
|
+
* 后续 turn 重复出现,措辞一成不变的话,重复的那几条就是纯噪音。
|
|
16
|
+
* @param turn - 产出该思考的 turn。
|
|
17
|
+
* @param step - 产出该思考的 step。
|
|
18
|
+
* @param metrics - 那一步思考的量化结果。
|
|
19
|
+
* @param reminder - 这是本会话第几次提醒;默认 1。
|
|
20
|
+
* @returns 一条锚定消息的正文。
|
|
21
|
+
*/
|
|
22
|
+
export function anchorText(turn, step, metrics, reminder = 1) {
|
|
23
|
+
const where = `(turn ${turn} step ${step})的思考是英文的`
|
|
24
|
+
+ `(英文功能词密度 ${metrics.funcDensity.toFixed(2)},共 ${metrics.words} 个英文词)。`;
|
|
25
|
+
const head = reminder > 1
|
|
26
|
+
? `⚠️ 语言漂移提醒(应变,第 ${reminder} 次):你仍然在用英文思考——上一步${where}`
|
|
27
|
+
: `⚠️ 语言漂移提醒(应变):你上一步${where}`;
|
|
28
|
+
return head
|
|
29
|
+
+ '现在回到中文思考。注意:读代码时引用英文标识符是正常的,'
|
|
30
|
+
+ '这里判定的是整句英文——出现英文语法结构才算漂移。';
|
|
31
|
+
}
|
package/dist/drift.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 思考语言漂移判定。
|
|
3
|
+
*
|
|
4
|
+
* 判据是**英文功能词密度** = 英文功能词数 / 英文词数,并要求英文词数达到门槛。
|
|
5
|
+
* 实测依据(2026-09-20,会话 `session-3bf8bcfb`,156 条 `assistant/message`):
|
|
6
|
+
* 中文期中位密度 0.012、英文期中位 0.273–0.389,阈值 0.15 两群完全分离。词数门槛
|
|
7
|
+
* 是必需的——中文期唯一的越线点(密度 0.192)只有 26 个英文词,小样本让密度失真。
|
|
8
|
+
*
|
|
9
|
+
* 另外两个看起来更直观的指标不可用:
|
|
10
|
+
* · 中文字符占比:中文思考本来就大量夹英文标识符,实测中文期只有 0.19–0.37,
|
|
11
|
+
* 与英文期的 0.00–0.15 区分度太小。
|
|
12
|
+
* · 最长连续英文游程:中文思考引用一段代码就会把它顶到 43,它测的是「引用了多长
|
|
13
|
+
* 的代码」,不是「用什么语言思考」。
|
|
14
|
+
*
|
|
15
|
+
* 设计出处:`docs/设计/2026-09-20-应变-设计方案.md` §4.3
|
|
16
|
+
* @module @max-null/dsh-allostasis/drift
|
|
17
|
+
*/
|
|
18
|
+
/** 功能词密度达到此值即判为漂移;中文期中位 0.012、英文期中位 0.273 以上。 */
|
|
19
|
+
export declare const DRIFT_THRESHOLD = 0.15;
|
|
20
|
+
/** 判定所需的最少英文词数;低于此值只报数不判定,避免小样本把密度算飞。 */
|
|
21
|
+
export declare const MIN_WORDS = 50;
|
|
22
|
+
/** 一条思考文本的量化结果。 */
|
|
23
|
+
export interface DriftMetrics {
|
|
24
|
+
/** 文本总字符数。 */
|
|
25
|
+
chars: number;
|
|
26
|
+
/** 中文字符数。 */
|
|
27
|
+
cjk: number;
|
|
28
|
+
/** 中文字符占比,仅用于诊断,不参与判定。 */
|
|
29
|
+
cjkRatio: number;
|
|
30
|
+
/** 英文词数。 */
|
|
31
|
+
words: number;
|
|
32
|
+
/** 英文功能词数。 */
|
|
33
|
+
funcWords: number;
|
|
34
|
+
/** 功能词密度——判据本体。 */
|
|
35
|
+
funcDensity: number;
|
|
36
|
+
}
|
|
37
|
+
/** 三态判定;词数不足时不下结论。 */
|
|
38
|
+
export type DriftVerdict = 'drift' | 'chinese' | 'insufficient';
|
|
39
|
+
/** 统计一条思考文本的各量。空文本返回全零。 */
|
|
40
|
+
export declare function measureThinking(text: string): DriftMetrics;
|
|
41
|
+
/** 对一次测量下判定。 */
|
|
42
|
+
export declare function verdict(metrics: DriftMetrics): DriftVerdict;
|
package/dist/drift.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 思考语言漂移判定。
|
|
3
|
+
*
|
|
4
|
+
* 判据是**英文功能词密度** = 英文功能词数 / 英文词数,并要求英文词数达到门槛。
|
|
5
|
+
* 实测依据(2026-09-20,会话 `session-3bf8bcfb`,156 条 `assistant/message`):
|
|
6
|
+
* 中文期中位密度 0.012、英文期中位 0.273–0.389,阈值 0.15 两群完全分离。词数门槛
|
|
7
|
+
* 是必需的——中文期唯一的越线点(密度 0.192)只有 26 个英文词,小样本让密度失真。
|
|
8
|
+
*
|
|
9
|
+
* 另外两个看起来更直观的指标不可用:
|
|
10
|
+
* · 中文字符占比:中文思考本来就大量夹英文标识符,实测中文期只有 0.19–0.37,
|
|
11
|
+
* 与英文期的 0.00–0.15 区分度太小。
|
|
12
|
+
* · 最长连续英文游程:中文思考引用一段代码就会把它顶到 43,它测的是「引用了多长
|
|
13
|
+
* 的代码」,不是「用什么语言思考」。
|
|
14
|
+
*
|
|
15
|
+
* 设计出处:`docs/设计/2026-09-20-应变-设计方案.md` §4.3
|
|
16
|
+
* @module @max-null/dsh-allostasis/drift
|
|
17
|
+
*/
|
|
18
|
+
/** 英文功能词:中文思考引用英文标识符时不会带这些词。 */
|
|
19
|
+
const FUNC_WORDS = new Set(('the is are was were and or but to of in that this with for it as be not if on at by from an a ' +
|
|
20
|
+
'we i you they have has will can should would there which when what how so then than also all ' +
|
|
21
|
+
'one two no yes do does did its their our my his her them these those been being more most some ' +
|
|
22
|
+
'any each both into over after before while because however thus therefore instead about only ' +
|
|
23
|
+
'just even still yet first second next last new same other such per here where who whose must ' +
|
|
24
|
+
'may might shall').split(' '));
|
|
25
|
+
/** 功能词密度达到此值即判为漂移;中文期中位 0.012、英文期中位 0.273 以上。 */
|
|
26
|
+
export const DRIFT_THRESHOLD = 0.15;
|
|
27
|
+
/** 判定所需的最少英文词数;低于此值只报数不判定,避免小样本把密度算飞。 */
|
|
28
|
+
export const MIN_WORDS = 50;
|
|
29
|
+
/** 统计一条思考文本的各量。空文本返回全零。 */
|
|
30
|
+
export function measureThinking(text) {
|
|
31
|
+
const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
|
32
|
+
const words = text.match(/[A-Za-z][A-Za-z'-]*/g) ?? [];
|
|
33
|
+
const funcWords = words.filter(word => FUNC_WORDS.has(word.toLowerCase())).length;
|
|
34
|
+
return {
|
|
35
|
+
chars: text.length,
|
|
36
|
+
cjk,
|
|
37
|
+
cjkRatio: text.length === 0 ? 0 : cjk / text.length,
|
|
38
|
+
words: words.length,
|
|
39
|
+
funcWords,
|
|
40
|
+
funcDensity: words.length === 0 ? 0 : funcWords / words.length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** 对一次测量下判定。 */
|
|
44
|
+
export function verdict(metrics) {
|
|
45
|
+
if (metrics.words < MIN_WORDS)
|
|
46
|
+
return 'insufficient';
|
|
47
|
+
return metrics.funcDensity >= DRIFT_THRESHOLD ? 'drift' : 'chinese';
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 应变(allostasis):会话状态的自我调节。
|
|
3
|
+
*
|
|
4
|
+
* 第一期只实现一个能力——**中文锚定**:每个 step 之前读最近一条思考,若判定为语言
|
|
5
|
+
* 漂移(英文功能词密度越线),就在请求末尾追加一条中文锚定消息。**平时不出现、漂移
|
|
6
|
+
* 时才出现**——按需出现是它作为信号的前提。但漂移持续时也不每个 step 都提醒:同一
|
|
7
|
+
* turn 至多一次,见 `throttle.ts`。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不挂到 system prompt 前缀上:那是 `dsh-chinese-thinking` 的位置,它作为基线
|
|
10
|
+
* 永远在场。而基线在长会话里会失效(固定前缀离输出最远,语言模式受近因支配)。本插件
|
|
11
|
+
* 补的是「**你正在漂移**」这个纠偏信号,因此必须落在近因位置。
|
|
12
|
+
*
|
|
13
|
+
* 为什么用 `agent/pre-step` 而不是 `systemPrompt.context()`:前者直接给出 `turn` /
|
|
14
|
+
* `step`,且追加的是一条独立消息,落点比快照里的一个段更靠后。用法先例见官方
|
|
15
|
+
* `packages/context/time-context/src/index.ts:180-220`。
|
|
16
|
+
*
|
|
17
|
+
* 设计出处:`docs/设计/2026-09-20-应变-设计方案.md`
|
|
18
|
+
* @module @max-null/dsh-allostasis
|
|
19
|
+
*/
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
21
|
+
import { anchorText } from './anchor.ts';
|
|
22
|
+
/** Cordis 插件名,同时用作注入消息的 `source.plugin` 与 section 名。 */
|
|
23
|
+
export declare const name = "dsh-allostasis";
|
|
24
|
+
/** 需要 `agents` 服务来接收 `agent/pre-step` 事件。 */
|
|
25
|
+
export declare const inject: string[];
|
|
26
|
+
export { anchorText };
|
|
27
|
+
/**
|
|
28
|
+
* 注册 pre-step 监听器;监听器随 `ctx` 生命周期销毁。
|
|
29
|
+
*
|
|
30
|
+
* 用 `{ prepend: true }` 以取得 `next()` 的决策后再追加,与官方 `time-context` 一致。
|
|
31
|
+
* @param ctx - 插件上下文。
|
|
32
|
+
*/
|
|
33
|
+
export declare function apply(ctx: Context): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 应变(allostasis):会话状态的自我调节。
|
|
3
|
+
*
|
|
4
|
+
* 第一期只实现一个能力——**中文锚定**:每个 step 之前读最近一条思考,若判定为语言
|
|
5
|
+
* 漂移(英文功能词密度越线),就在请求末尾追加一条中文锚定消息。**平时不出现、漂移
|
|
6
|
+
* 时才出现**——按需出现是它作为信号的前提。但漂移持续时也不每个 step 都提醒:同一
|
|
7
|
+
* turn 至多一次,见 `throttle.ts`。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不挂到 system prompt 前缀上:那是 `dsh-chinese-thinking` 的位置,它作为基线
|
|
10
|
+
* 永远在场。而基线在长会话里会失效(固定前缀离输出最远,语言模式受近因支配)。本插件
|
|
11
|
+
* 补的是「**你正在漂移**」这个纠偏信号,因此必须落在近因位置。
|
|
12
|
+
*
|
|
13
|
+
* 为什么用 `agent/pre-step` 而不是 `systemPrompt.context()`:前者直接给出 `turn` /
|
|
14
|
+
* `step`,且追加的是一条独立消息,落点比快照里的一个段更靠后。用法先例见官方
|
|
15
|
+
* `packages/context/time-context/src/index.ts:180-220`。
|
|
16
|
+
*
|
|
17
|
+
* 设计出处:`docs/设计/2026-09-20-应变-设计方案.md`
|
|
18
|
+
* @module @max-null/dsh-allostasis
|
|
19
|
+
*/
|
|
20
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
21
|
+
import { anchorText } from "./anchor.js";
|
|
22
|
+
import { measureThinking, verdict } from "./drift.js";
|
|
23
|
+
import { latestThinking } from "./thinking.js";
|
|
24
|
+
import { admitAnchor } from "./throttle.js";
|
|
25
|
+
/** Cordis 插件名,同时用作注入消息的 `source.plugin` 与 section 名。 */
|
|
26
|
+
export const name = 'dsh-allostasis';
|
|
27
|
+
/** 需要 `agents` 服务来接收 `agent/pre-step` 事件。 */
|
|
28
|
+
export const inject = ['agents'];
|
|
29
|
+
export { anchorText };
|
|
30
|
+
/**
|
|
31
|
+
* 注册 pre-step 监听器;监听器随 `ctx` 生命周期销毁。
|
|
32
|
+
*
|
|
33
|
+
* 用 `{ prepend: true }` 以取得 `next()` 的决策后再追加,与官方 `time-context` 一致。
|
|
34
|
+
* @param ctx - 插件上下文。
|
|
35
|
+
*/
|
|
36
|
+
export function apply(ctx) {
|
|
37
|
+
/** 每个会话一份节流状态;用 WeakMap 以免会话销毁后残留。 */
|
|
38
|
+
const throttles = new WeakMap();
|
|
39
|
+
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
40
|
+
const decision = await next();
|
|
41
|
+
if (decision.kind === 'reject' || signal.aborted)
|
|
42
|
+
return decision;
|
|
43
|
+
const sample = latestThinking(agent.session);
|
|
44
|
+
if (sample === undefined)
|
|
45
|
+
return decision;
|
|
46
|
+
const metrics = measureThinking(sample.text);
|
|
47
|
+
if (verdict(metrics) !== 'drift')
|
|
48
|
+
return decision;
|
|
49
|
+
const advanced = admitAnchor(throttles.get(agent.session), sample.turn);
|
|
50
|
+
if (advanced === undefined)
|
|
51
|
+
return decision;
|
|
52
|
+
throttles.set(agent.session, advanced);
|
|
53
|
+
const text = anchorText(sample.turn, sample.step, metrics, advanced.count);
|
|
54
|
+
return {
|
|
55
|
+
...decision,
|
|
56
|
+
messages: [
|
|
57
|
+
...decision.messages,
|
|
58
|
+
createUserMessage({
|
|
59
|
+
content: [{ type: 'text', text }],
|
|
60
|
+
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
|
|
61
|
+
}),
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}, { prepend: true });
|
|
65
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从会话事件里取最近一条思考文本。
|
|
3
|
+
*
|
|
4
|
+
* 落点:`assistant/message` 事件的 `stream` 数组里 `type: 'reasoning-chunks'` 的
|
|
5
|
+
* `texts`(`packages/llm/llm/src/assistant-stream.ts:28-34`,文件头注明是
|
|
6
|
+
* `Lossless compact representation`)。每个 step 一条事件,带 `turn` / `step`。
|
|
7
|
+
*
|
|
8
|
+
* 取「最近一条含思考的事件」而不是「恰好上一步」:有些 step 没有思考文本(极短的
|
|
9
|
+
* 工具步),沿事件倒扫直到命中可以跨过它们。
|
|
10
|
+
*
|
|
11
|
+
* 用 `snapshotEvents()` 而非 `eventAt(SessionSeq(n))` 遍历:后者需要 `SessionSeq`,
|
|
12
|
+
* 而它并未从 `@deepseek-ai/dsh-session` 的包入口导出(`lib/types/index.d.ts:11` 只有
|
|
13
|
+
* import,不是 re-export)。前者的用法在 `agent-loop/src/runtime-context.ts` 有先例。
|
|
14
|
+
* @module @max-null/dsh-allostasis/thinking
|
|
15
|
+
*/
|
|
16
|
+
import type { Session } from '@deepseek-ai/dsh-session';
|
|
17
|
+
/** 一条思考文本及其来源位置。 */
|
|
18
|
+
export interface ThinkingSample {
|
|
19
|
+
/** 产出它的 turn。 */
|
|
20
|
+
turn: number;
|
|
21
|
+
/** 产出它的 step。 */
|
|
22
|
+
step: number;
|
|
23
|
+
/** 拼接后的思考全文。 */
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
/** 取会话里最近一条非空思考;一条都没有时返回 `undefined`。 */
|
|
27
|
+
export declare function latestThinking(session: Session): ThinkingSample | undefined;
|
package/dist/thinking.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从会话事件里取最近一条思考文本。
|
|
3
|
+
*
|
|
4
|
+
* 落点:`assistant/message` 事件的 `stream` 数组里 `type: 'reasoning-chunks'` 的
|
|
5
|
+
* `texts`(`packages/llm/llm/src/assistant-stream.ts:28-34`,文件头注明是
|
|
6
|
+
* `Lossless compact representation`)。每个 step 一条事件,带 `turn` / `step`。
|
|
7
|
+
*
|
|
8
|
+
* 取「最近一条含思考的事件」而不是「恰好上一步」:有些 step 没有思考文本(极短的
|
|
9
|
+
* 工具步),沿事件倒扫直到命中可以跨过它们。
|
|
10
|
+
*
|
|
11
|
+
* 用 `snapshotEvents()` 而非 `eventAt(SessionSeq(n))` 遍历:后者需要 `SessionSeq`,
|
|
12
|
+
* 而它并未从 `@deepseek-ai/dsh-session` 的包入口导出(`lib/types/index.d.ts:11` 只有
|
|
13
|
+
* import,不是 re-export)。前者的用法在 `agent-loop/src/runtime-context.ts` 有先例。
|
|
14
|
+
* @module @max-null/dsh-allostasis/thinking
|
|
15
|
+
*/
|
|
16
|
+
/** 取会话里最近一条非空思考;一条都没有时返回 `undefined`。 */
|
|
17
|
+
export function latestThinking(session) {
|
|
18
|
+
const events = session.snapshotEvents();
|
|
19
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
20
|
+
const event = events[index];
|
|
21
|
+
if (event?.type !== 'assistant/message')
|
|
22
|
+
continue;
|
|
23
|
+
const parts = [];
|
|
24
|
+
for (const record of event.data.stream) {
|
|
25
|
+
if (record.type === 'reasoning-chunks')
|
|
26
|
+
parts.push(...record.texts);
|
|
27
|
+
}
|
|
28
|
+
const text = parts.join('');
|
|
29
|
+
if (text.length === 0)
|
|
30
|
+
continue;
|
|
31
|
+
return { turn: event.data.turn, step: event.data.step, text };
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 锚定注入的节流。
|
|
3
|
+
*
|
|
4
|
+
* 实测(2026-09-21,会话 `session-502b3e2b`,记录见设计方案 §12.4):漂移持续时**每个
|
|
5
|
+
* step 都会判定为漂移**,于是同一个 turn 内会连着注入多条几乎一样的提醒——观测到 3 连注,
|
|
6
|
+
* 其中 turn 1 之内就有 2 条。第 2 条起信息量已经递减:模型上一个 step 刚读过同一句话。
|
|
7
|
+
*
|
|
8
|
+
* 所以收紧到「同一 turn 至多一次」:turn 是用户能感知的自然边界,重试留到下一轮,
|
|
9
|
+
* 而不是在一步之内反复催促。上限写成常数而不是配置项,是因为**没有数据支撑别的取值**;
|
|
10
|
+
* 等无对抗的自然漂移样本出来,再决定要不要放宽(设计方案 §八 第 9 条)。
|
|
11
|
+
* @module @max-null/dsh-allostasis/throttle
|
|
12
|
+
*/
|
|
13
|
+
/** 锚定在单个 turn 内的注入上限。 */
|
|
14
|
+
export declare const MAX_ANCHORS_PER_TURN = 1;
|
|
15
|
+
/** 节流状态。不可变——每次放行都返回一份新状态。 */
|
|
16
|
+
export interface AnchorThrottleState {
|
|
17
|
+
/** 最近一次注入所针对的 turn(产出那段漂移思考的 turn)。 */
|
|
18
|
+
lastTurn: number;
|
|
19
|
+
/** 该 turn 内已经注入了几次。 */
|
|
20
|
+
inTurn: number;
|
|
21
|
+
/** 本会话累计注入次数,用于让重复提醒带上序号。 */
|
|
22
|
+
count: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 判定本次是否放行,并推进状态。
|
|
26
|
+
*
|
|
27
|
+
* `sampledTurn` 是**产出那段被判定漂移的思考**的 turn,不是当前 turn——锚定的措辞
|
|
28
|
+
* 指向「你上一步在想什么」,节流的计次也应当跟着它走。
|
|
29
|
+
* @param state - 上一次的状态;首次调用传 `undefined`。
|
|
30
|
+
* @param sampledTurn - 产出该思考的 turn。
|
|
31
|
+
* @returns 放行时返回新状态;应当跳过时返回 `undefined`。
|
|
32
|
+
*/
|
|
33
|
+
export declare function admitAnchor(state: AnchorThrottleState | undefined, sampledTurn: number): AnchorThrottleState | undefined;
|
package/dist/throttle.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 锚定注入的节流。
|
|
3
|
+
*
|
|
4
|
+
* 实测(2026-09-21,会话 `session-502b3e2b`,记录见设计方案 §12.4):漂移持续时**每个
|
|
5
|
+
* step 都会判定为漂移**,于是同一个 turn 内会连着注入多条几乎一样的提醒——观测到 3 连注,
|
|
6
|
+
* 其中 turn 1 之内就有 2 条。第 2 条起信息量已经递减:模型上一个 step 刚读过同一句话。
|
|
7
|
+
*
|
|
8
|
+
* 所以收紧到「同一 turn 至多一次」:turn 是用户能感知的自然边界,重试留到下一轮,
|
|
9
|
+
* 而不是在一步之内反复催促。上限写成常数而不是配置项,是因为**没有数据支撑别的取值**;
|
|
10
|
+
* 等无对抗的自然漂移样本出来,再决定要不要放宽(设计方案 §八 第 9 条)。
|
|
11
|
+
* @module @max-null/dsh-allostasis/throttle
|
|
12
|
+
*/
|
|
13
|
+
/** 锚定在单个 turn 内的注入上限。 */
|
|
14
|
+
export const MAX_ANCHORS_PER_TURN = 1;
|
|
15
|
+
/**
|
|
16
|
+
* 判定本次是否放行,并推进状态。
|
|
17
|
+
*
|
|
18
|
+
* `sampledTurn` 是**产出那段被判定漂移的思考**的 turn,不是当前 turn——锚定的措辞
|
|
19
|
+
* 指向「你上一步在想什么」,节流的计次也应当跟着它走。
|
|
20
|
+
* @param state - 上一次的状态;首次调用传 `undefined`。
|
|
21
|
+
* @param sampledTurn - 产出该思考的 turn。
|
|
22
|
+
* @returns 放行时返回新状态;应当跳过时返回 `undefined`。
|
|
23
|
+
*/
|
|
24
|
+
export function admitAnchor(state, sampledTurn) {
|
|
25
|
+
const current = state ?? { lastTurn: Number.NaN, inTurn: 0, count: 0 };
|
|
26
|
+
const inTurn = current.lastTurn === sampledTurn ? current.inTurn : 0;
|
|
27
|
+
if (inTurn >= MAX_ANCHORS_PER_TURN)
|
|
28
|
+
return undefined;
|
|
29
|
+
return { lastTurn: sampledTurn, inTurn: inTurn + 1, count: current.count + 1 };
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@max-null/dsh-allostasis",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "应变(allostasis):会话状态的自我调节。第一期:中文思考的漂移检测与近因锚定",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"cordis.patch.yml",
|
|
21
|
+
"tools"
|
|
22
|
+
],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"deepseek",
|
|
30
|
+
"harness",
|
|
31
|
+
"dsh",
|
|
32
|
+
"dsh-plugin",
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"allostasis",
|
|
35
|
+
"应变",
|
|
36
|
+
"self-regulation",
|
|
37
|
+
"plugin",
|
|
38
|
+
"cordis"
|
|
39
|
+
],
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/Max-Null/dsh-allostasis.git"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"prepublishOnly": "npm run build"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"zod": "^4.4.3"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
55
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.1"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^26.2.0",
|
|
61
|
+
"tsx": "^4.0.0",
|
|
62
|
+
"typescript": "^5.5.0",
|
|
63
|
+
"vitest": "^2.1.0",
|
|
64
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
65
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.1"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 思考块语言漂移分析 — 读一份 DSH 会话日志(`session.v3.jsonl`),逐 step 量「思考用什么语言」。
|
|
4
|
+
*
|
|
5
|
+
* 判据:**英文功能词密度** = 英文功能词数 / 英文词数,且**英文词数 ≥ 50** 才判定。
|
|
6
|
+
* 中文期(中位 0.012)与英文期(中位 0.273–0.389)以 **0.15** 干净分开。
|
|
7
|
+
*
|
|
8
|
+
* 为什么要词数门槛:中文期唯一的越线点(Turn 1 step 13,密度 0.192)只有 26 个英文词——
|
|
9
|
+
* 小样本让密度失真。加门槛后中文期零越线、英文期全部越线。
|
|
10
|
+
*
|
|
11
|
+
* 为什么不用另外两个看起来更直观的指标:
|
|
12
|
+
* · `cjkRatio`(中文字符占比)—— 中文思考本来就大量夹英文标识符,实测中文期只有
|
|
13
|
+
* 0.19–0.37,与英文期的 0.00–0.15 区分度太小。
|
|
14
|
+
* · `maxRun`(最长连续英文游程)—— 中文思考里引用一段代码就会把它顶到 43,而英文期
|
|
15
|
+
* 反而有大量 1–5 的短思考。它测的是「引用了多长的代码」,不是「用什么语言思考」。
|
|
16
|
+
*
|
|
17
|
+
* 用法:
|
|
18
|
+
* node analyze-thinking-lang.mjs <session.v3.jsonl> # 逐 step 明细
|
|
19
|
+
* node analyze-thinking-lang.mjs <session.v3.jsonl> --summary # 只报每 turn 汇总与首个越线点
|
|
20
|
+
*
|
|
21
|
+
* 退出码:0 = 未发现漂移;1 = 有 step 越线;2 = 用法错误或读不出 reasoning 文本。
|
|
22
|
+
*
|
|
23
|
+
* 实测样本(2026-09-20,会话 session-3bf8bcfb-fd48-4127-a9e7-08b89b4e7167):
|
|
24
|
+
* 10 turn / 157 step / 156 条 `assistant/message`,其中 147 条含 reasoning 文本。
|
|
25
|
+
* Turn 1(deepseek-flash)中文;Turn 1→2 之间 4 次 `model/selection` 切到
|
|
26
|
+
* `qwen-token-plan/qwen3.8-flash`,Turn 2 全程英文;Turn 2→3 切回 deepseek-flash 后
|
|
27
|
+
* **英文仍未恢复**——Turn 2 的英文思考已进会话历史,后续模型跟随历史的语言。
|
|
28
|
+
*
|
|
29
|
+
* 设计输入(判据来源与完整数据):`max-null-plugins/dsh-allostasis/docs/设计/2026-09-20-应变-设计方案.md` §4
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from 'node:fs'
|
|
32
|
+
|
|
33
|
+
const FUNC = new Set(('the is are was were and or but to of in that this with for it as be not if on at by from an a ' +
|
|
34
|
+
'we i you they have has will can should would there which when what how so then than also all one two no yes do does did ' +
|
|
35
|
+
'its their our my his her them these those been being more most some any each both into over after before while because ' +
|
|
36
|
+
'however thus therefore instead about only just even still yet first second next last new same other such per here where ' +
|
|
37
|
+
'who whose must may might shall').split(' '))
|
|
38
|
+
|
|
39
|
+
/** 功能词密度越线阈值;中文期中位 0.012、英文期中位 0.273 以上,取两者之间。 */
|
|
40
|
+
const DRIFT_THRESHOLD = 0.15
|
|
41
|
+
|
|
42
|
+
/** 判定所需的最少英文词数;低于此值只报数不判定,避免小样本把密度算飞。 */
|
|
43
|
+
const MIN_WORDS = 50
|
|
44
|
+
|
|
45
|
+
/** 从一条思考文本算出各量。`funcDensity` 是判据,其余保留用于诊断与对比。 */
|
|
46
|
+
function metrics(text) {
|
|
47
|
+
const cjk = (text.match(/[\u4e00-\u9fff]/g) || []).length
|
|
48
|
+
const words = text.match(/[A-Za-z][A-Za-z'-]*/g) || []
|
|
49
|
+
const funcw = words.filter(w => FUNC.has(w.toLowerCase())).length
|
|
50
|
+
const segs = text.split(/[\u4e00-\u9fff]+/)
|
|
51
|
+
let maxRun = 0
|
|
52
|
+
for (const s of segs) {
|
|
53
|
+
const n = (s.match(/[A-Za-z][A-Za-z'-]*/g) || []).length
|
|
54
|
+
if (n > maxRun) maxRun = n
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
chars: text.length,
|
|
58
|
+
cjk,
|
|
59
|
+
cjkRatio: text.length ? cjk / text.length : 0,
|
|
60
|
+
words: words.length,
|
|
61
|
+
funcw,
|
|
62
|
+
funcDensity: words.length ? funcw / words.length : 0,
|
|
63
|
+
maxRun,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 三态判定:词数不够时不下结论。 */
|
|
68
|
+
function verdict(s) {
|
|
69
|
+
if (s.words < MIN_WORDS) return '样本不足'
|
|
70
|
+
return s.funcDensity >= DRIFT_THRESHOLD ? '漂移' : '中文'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const file = process.argv[2]
|
|
74
|
+
const summaryOnly = process.argv.includes('--summary')
|
|
75
|
+
if (!file) {
|
|
76
|
+
console.error('用法:node analyze-thinking-lang.mjs <session.v3.jsonl> [--summary]')
|
|
77
|
+
process.exit(2)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const steps = []
|
|
81
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
82
|
+
if (!line.trim()) continue
|
|
83
|
+
let ev
|
|
84
|
+
try { ev = JSON.parse(line) } catch { continue }
|
|
85
|
+
if (ev.type !== 'assistant/message') continue
|
|
86
|
+
const d = ev.data || {}
|
|
87
|
+
const texts = []
|
|
88
|
+
for (const rec of Array.isArray(d.stream) ? d.stream : []) {
|
|
89
|
+
if (rec && rec.type === 'reasoning-chunks' && Array.isArray(rec.texts)) texts.push(...rec.texts)
|
|
90
|
+
}
|
|
91
|
+
steps.push({ turn: d.turn, step: d.step, ...metrics(texts.join('')) })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const withThinking = steps.filter(s => s.chars > 0)
|
|
95
|
+
if (withThinking.length === 0) {
|
|
96
|
+
console.error(`读不出 reasoning 文本:${steps.length} 条 assistant/message 全为空。`)
|
|
97
|
+
console.error('这通常意味着该会话是旧格式(无 reasoning-chunks),别把「扫不出来」读成「没问题」。')
|
|
98
|
+
process.exit(2)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const drifted = withThinking.filter(s => verdict(s) === '漂移')
|
|
102
|
+
const skipped = withThinking.filter(s => verdict(s) === '样本不足')
|
|
103
|
+
const first = drifted[0]
|
|
104
|
+
|
|
105
|
+
console.log(`assistant/message ${steps.length} 条,含 reasoning 文本 ${withThinking.length} 条`)
|
|
106
|
+
console.log(`判据「英文功能词密度」≥ ${DRIFT_THRESHOLD} 且英文词数 ≥ ${MIN_WORDS} 视为漂移;越线 ${drifted.length} 条,样本不足 ${skipped.length} 条`)
|
|
107
|
+
if (first) {
|
|
108
|
+
console.log(`首个越线点:turn ${first.turn} step ${first.step}(密度 ${first.funcDensity.toFixed(2)},${first.words} 词)`)
|
|
109
|
+
} else {
|
|
110
|
+
console.log('未发现越线 step。')
|
|
111
|
+
}
|
|
112
|
+
console.log('')
|
|
113
|
+
|
|
114
|
+
if (summaryOnly) {
|
|
115
|
+
const byTurn = new Map()
|
|
116
|
+
for (const s of withThinking) {
|
|
117
|
+
if (!byTurn.has(s.turn)) byTurn.set(s.turn, [])
|
|
118
|
+
byTurn.get(s.turn).push(s)
|
|
119
|
+
}
|
|
120
|
+
console.log('turn 步数 中位密度 最大密度 越线 样本不足 cjkRatio 范围')
|
|
121
|
+
for (const [turn, list] of [...byTurn.entries()].sort((a, b) => a[0] - b[0])) {
|
|
122
|
+
const dens = list.map(s => s.funcDensity).sort((a, b) => a - b)
|
|
123
|
+
const median = dens[Math.floor(dens.length / 2)]
|
|
124
|
+
const ratios = list.map(s => s.cjkRatio)
|
|
125
|
+
console.log(
|
|
126
|
+
String(turn).padStart(4) + ' ' +
|
|
127
|
+
String(list.length).padStart(5) + ' ' +
|
|
128
|
+
median.toFixed(3).padStart(10) + ' ' +
|
|
129
|
+
Math.max(...dens).toFixed(3).padStart(10) + ' ' +
|
|
130
|
+
String(list.filter(s => verdict(s) === '漂移').length).padStart(5) + ' ' +
|
|
131
|
+
String(list.filter(s => verdict(s) === '样本不足').length).padStart(7) + ' ' +
|
|
132
|
+
Math.min(...ratios).toFixed(2) + '–' + Math.max(...ratios).toFixed(2))
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
console.log('turn step chars cjk cjkRat words funcW density maxRun 判定')
|
|
136
|
+
for (const s of withThinking) {
|
|
137
|
+
console.log(
|
|
138
|
+
String(s.turn).padStart(4) + ' ' +
|
|
139
|
+
String(s.step).padStart(4) + ' ' +
|
|
140
|
+
String(s.chars).padStart(7) + ' ' +
|
|
141
|
+
String(s.cjk).padStart(6) + ' ' +
|
|
142
|
+
s.cjkRatio.toFixed(2).padStart(6) + ' ' +
|
|
143
|
+
String(s.words).padStart(6) + ' ' +
|
|
144
|
+
String(s.funcw).padStart(6) + ' ' +
|
|
145
|
+
s.funcDensity.toFixed(3).padStart(8) + ' ' +
|
|
146
|
+
String(s.maxRun).padStart(6) + ' ' +
|
|
147
|
+
verdict(s))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
process.exit(drifted.length > 0 ? 1 : 0)
|