@max-null/dsh-allostasis 0.2.1 → 0.3.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/README.md CHANGED
@@ -34,12 +34,12 @@ notice beneath that turn.
34
34
  **除空回合外全程静默,所以「确认它在工作」只能靠日志。** 前两类命中时只在轨迹页留一行;不命中时一个字都不说——「装了没有」「阈值生效没有」「这一步为什么没提醒」三个问题原本都无从回答,只能靠改配置去试。因此它在 `src/index.ts` 的 `apply` 里留两行痕:
35
35
 
36
36
  ```
37
- [dsh-allostasis] loaded · driftThreshold=0.15 repetitionThreshold=0.5 consecutiveSteps=2
37
+ [dsh-allostasis] loaded · driftThreshold=0.15 repetitionThreshold=0.5 loopWindow=2/5
38
38
  [dsh-allostasis] turn 8 step 11 · drift=chinese funcDensity=0.6% chars=1764
39
39
  · repetition=normal units=29 ratio=0%
40
40
  ```
41
41
 
42
- 第一行 `info`、每个进程一次,报的是**生效阈值**(`Config` 的解析结果,不是代码里的缺省常量)。第二行 `debug`、**每一步判定一行**:两类判据的三态结论加度量。默认静默,排查时打开即可——不必为了看一眼判定结果去动阈值。其中 `units` 是切分后的单元数,**低于 12 判 `insufficient`**(样本不足不下结论),此时 `ratio` 仍会给出,只是不参与判定。
42
+ 第一行 `info`、每个进程一次,报的是**生效阈值**(`Config` 的解析结果,不是代码里的缺省常量)。第二行 `debug`、**每一步判定一行**:两类判据的三态结论加度量。默认静默,排查时打开即可——不必为了看一眼判定结果去动阈值。其中 `units` 是切分后**计入统计的实义单元数**(滤掉代码围栏与纯符号,见「判据」段),**低于 12 判 `insufficient`**(样本不足不下结论),此时 `ratio` 仍会给出,只是不参与判定。
43
43
 
44
44
  判据来源、实测数据与完整设计见 `docs/设计/2026-09-20-应变-设计方案.md`、
45
45
  `docs/设计/2026-09-28-应变二期-退化检测与自动干预.md`。
@@ -66,9 +66,11 @@ notice beneath that turn.
66
66
 
67
67
  ### 推理退化
68
68
 
69
- **重复率** = 单条推理按换行与中英句读切分后,**出现 ≥3 次的单元占全部单元的比例**;**单元数 ≥ 12 才判定**,**连续 N 步(默认 2)越线才触发**。
69
+ **重复率** = 单条推理按换行与中英句读切分、滤掉非实义单元后,**出现 ≥3 次的单元占全部单元的比例**;**单元数 ≥ 12 才判定**,**最近 5 步内累计 ≥2 步越线才触发**(窗口跨 turn,不要求连续)。
70
70
 
71
- 实测数据(一份 19 轮 / 416 条助手消息的真实会话):正常期 **0%–35%**、退化期 **48%–92%**,阈值 **0.5** 落在隔离带中段。连续步数不可省——正常期也有单次抖动(实测峰值 35%);代价是延迟,按同一份数据回放会晚约 3 步触发。
71
+ **非实义单元 = 以代码围栏开头的整段,以及不含「至少一个汉字或两个连续拉丁字母」的单元。** 排除它们是因为纯标记在写代码时天然高频:跨会话实测里三个会话的越线全部来自 ` ``` `、`}`、`*`,排除后归零;而真退化会话的峰值反而更高(`64e08c94` 51%→94%、`a8ac8e89` 53%→90%)——噪声单元出局后真实重复的占比更突出。
72
+
73
+ 实测数据(一份 19 轮 / 416 条助手消息的真实会话):正常期 **0%–35%**、退化期 **48%–92%**,阈值 **0.5** 落在隔离带中段。触发条件改用窗口累计后,抖动仍由「≥2 步」挡住,而散布型退化不再漏判——跨会话复核(本机 37 个 ≥3MB 会话)见 `docs/设计/2026-10-01-提醒判据修正与实测方案.md`。
72
74
 
73
75
  **退化与上下文占用脱钩**:同一会话里 26.1% 占用时重复率 84%、67.9% 时 83–92%,压缩到 26.1% 并不降低重复率。所以「调低阈值」治不了它;压缩能否治它取决于力度。完整数据见 `docs/排查/2026-09-28-推理退化与上下文占用脱钩.md`。
74
76
 
@@ -97,13 +99,14 @@ Installs as a bundle: `dsh plugin --profile <name> add @max-null/dsh-allostasis`
97
99
 
98
100
  ## Config
99
101
 
100
- 三个判定阈值加一个空回合档位可在 `config` 段覆盖;省略即用括号内的默认值。
102
+ 四个判定阈值加一个空回合档位可在 `config` 段覆盖;省略即用括号内的默认值。
101
103
 
102
104
  | 字段 | 默认 | 含义 |
103
105
  |---|---|---|
104
106
  | `driftThreshold` | `0.15` | 英文功能词密度达到此值即判为漂移 |
105
107
  | `repetitionThreshold` | `0.5` | 推理重复率阈值,取值 0–1 |
106
- | `consecutiveSteps` | `2` | 连续多少步越线才触发退化提醒 |
108
+ | `loopWindowSteps` | `5` | 观察窗口的步数;窗口**跨 turn** 累计 |
109
+ | `loopWindowHits` | `2` | 窗口内需要累计的越线步数。取 3 会漏掉「少而猛」型退化;超过 `loopWindowSteps` 会报错中止——那是一个永不成立的触发条件 |
107
110
  | `silentTurn` | `observe` | 空回合档位:`off` 宿主不判定也不干预、`observe` 判定并打一行诊断、`steer` 额外补一次生成 |
108
111
 
109
112
  **档位管不到对话页那行提示**:提示由浏览器半边折叠会话事件流得出,与宿主判据同源但独立于档位——浏览器半边的 `apply` 拿不到插件配置(2026-10-01 实测:在 `cordis.patch.yml` 里配 `silentTurn: off`,宿主读到 `off`,浏览器半边收到空对象)。要完全静默请禁用插件。
package/cordis.patch.yml CHANGED
@@ -2,7 +2,8 @@
2
2
  # `system-prompt` / `agents` / `token-meter` already live in the host (dsh-base);
3
3
  # this adds only the allostasis plugin, which registers its own reminders.
4
4
  #
5
- # `config` 可覆盖三个判定阈值:driftThreshold / repetitionThreshold / consecutiveSteps。
5
+ # `config` 可覆盖四个判定阈值:driftThreshold / repetitionThreshold /
6
+ # loopWindowSteps / loopWindowHits,以及空回合档位 silentTurn。
6
7
  # 字段都可省略,省略即用插件内的默认值——这里刻意不写具体值,免得默认值有两个来源。
7
8
  # 各字段的含义、取值范围与默认值见 README 的「配置」段。
8
9
  - insert:
package/dist/config.d.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * 只暴露**判定阈值与档位**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务
5
5
  * 类型与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
6
- * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)
7
- * 不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
6
+ * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)与
7
+ * 实义单元规则不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
8
8
  *
9
9
  * **schema 只管形式,缺省与范围在 `resolveConfig`**:两处各管一半是有意的。schema 用
10
10
  * `.default()` 会让输出类型变成 `number | Volatile<number>`(默认值允许是动态函数),
@@ -26,8 +26,15 @@ export interface Config {
26
26
  driftThreshold?: number;
27
27
  /** 重复率阈值,0–1(默认 0.5)。取 1 等于事实上关闭退化提醒。 */
28
28
  repetitionThreshold?: number;
29
- /** 连续多少步越线才触发退化提醒(默认 2)。取 1 会跟着正常期的单次抖动误报。 */
30
- consecutiveSteps?: number;
29
+ /** 观察窗口的大小,以步为单位(默认 5)。窗口内累计越线达标即触发。 */
30
+ loopWindowSteps?: number;
31
+ /**
32
+ * 窗口内需要累计的越线步数(默认 2)。
33
+ *
34
+ * 取 3 会漏掉「少而猛」型退化——单条推理重复率很高但只发生两次的那种(实测样本
35
+ * `8fa3b15e`,峰值 76%、只有 2 次语义越线)。取 1 会跟着正常期的单次抖动误报。
36
+ */
37
+ loopWindowHits?: number;
31
38
  /**
32
39
  * 空回合兜底的档位(默认 `observe`)。
33
40
  *
@@ -50,8 +57,10 @@ export interface ResolvedConfig {
50
57
  readonly driftThreshold: number;
51
58
  /** 见 {@link Config.repetitionThreshold}。 */
52
59
  readonly repetitionThreshold: number;
53
- /** 见 {@link Config.consecutiveSteps}。 */
54
- readonly consecutiveSteps: number;
60
+ /** 见 {@link Config.loopWindowSteps}。 */
61
+ readonly loopWindowSteps: number;
62
+ /** 见 {@link Config.loopWindowHits}。 */
63
+ readonly loopWindowHits: number;
55
64
  /** 见 {@link Config.silentTurn}。 */
56
65
  readonly silentTurn: SilentTurnMode;
57
66
  }
@@ -62,6 +71,9 @@ export declare const Config: Schema<Config>;
62
71
  *
63
72
  * 范围约束不交给 schema 的 `.min()/.max()`:那样报错只会说「number」,而说出该字段的
64
73
  * 合理区间与收到的实际值,正是 fail-loud 的全部价值。
74
+ *
75
+ * `loopWindowHits` 超过 `loopWindowSteps` 时报错而不是静默夹取:那是一个**永不成立**的
76
+ * 触发条件(窗口装不下所需命中数),静默接受等于关掉退化提醒而不说。
65
77
  * @param config - Loader 传入的配置;未配置时传 `undefined` 或 `{}`。
66
78
  * @returns 字段齐全且已校验的配置。
67
79
  */
package/dist/config.js CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * 只暴露**判定阈值与档位**这一类值:它们是「宁可晚一点也别误报」这条取舍的刻度,随任务
5
5
  * 类型与模型行为漂移,属于部署间会变的选择(DSH 插件规范 "No hardcoded tunables in
6
- * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)
7
- * 不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
6
+ * plugins")。判据里的小样本门槛(`MIN_WORDS` / `MIN_UNITS` / `REPEAT_MIN_COUNT`)与
7
+ * 实义单元规则不在此列——它们不是偏好,改了就是把密度与比例算飞,属于判据几何的一部分。
8
8
  *
9
9
  * **schema 只管形式,缺省与范围在 `resolveConfig`**:两处各管一半是有意的。schema 用
10
10
  * `.default()` 会让输出类型变成 `number | Volatile<number>`(默认值允许是动态函数),
@@ -16,13 +16,14 @@
16
16
  */
17
17
  import Schema from '@deepseek-ai/schemastery';
18
18
  import { DRIFT_THRESHOLD } from "./drift.js";
19
- import { CONSECUTIVE_STEPS, REPETITION_THRESHOLD } from "./repetition.js";
19
+ import { LOOP_WINDOW_HITS, LOOP_WINDOW_STEPS, REPETITION_THRESHOLD } from "./repetition.js";
20
20
  import { SILENT_TURN_MODE, SILENT_TURN_MODES } from "./tail.js";
21
21
  /** Loader 用于校验 `cordis.patch.yml` 里 `config` 段的 schema。 */
22
22
  export const Config = Schema.object({
23
23
  driftThreshold: Schema.number(),
24
24
  repetitionThreshold: Schema.number(),
25
- consecutiveSteps: Schema.number(),
25
+ loopWindowSteps: Schema.number(),
26
+ loopWindowHits: Schema.number(),
26
27
  silentTurn: Schema.union(SILENT_TURN_MODES),
27
28
  });
28
29
  /**
@@ -37,6 +38,18 @@ function ratio(field, value) {
37
38
  }
38
39
  return value;
39
40
  }
41
+ /**
42
+ * 校验一个正整数步数字段。
43
+ * @param field - 字段名,用于报错文案。
44
+ * @param value - 字段值。
45
+ * @returns 校验通过的原值。
46
+ */
47
+ function steps(field, value) {
48
+ if (!Number.isInteger(value) || value < 1) {
49
+ throw new Error(`dsh-allostasis: \`${field}\` must be an integer >= 1, got ${value}`);
50
+ }
51
+ return value;
52
+ }
40
53
  /**
41
54
  * 校验一个枚举字段。
42
55
  * @param field - 字段名,用于报错文案。
@@ -55,16 +68,21 @@ function oneOf(field, value, allowed) {
55
68
  *
56
69
  * 范围约束不交给 schema 的 `.min()/.max()`:那样报错只会说「number」,而说出该字段的
57
70
  * 合理区间与收到的实际值,正是 fail-loud 的全部价值。
71
+ *
72
+ * `loopWindowHits` 超过 `loopWindowSteps` 时报错而不是静默夹取:那是一个**永不成立**的
73
+ * 触发条件(窗口装不下所需命中数),静默接受等于关掉退化提醒而不说。
58
74
  * @param config - Loader 传入的配置;未配置时传 `undefined` 或 `{}`。
59
75
  * @returns 字段齐全且已校验的配置。
60
76
  */
61
77
  export function resolveConfig(config = {}) {
62
78
  const driftThreshold = ratio('driftThreshold', config.driftThreshold ?? DRIFT_THRESHOLD);
63
79
  const repetitionThreshold = ratio('repetitionThreshold', config.repetitionThreshold ?? REPETITION_THRESHOLD);
64
- const consecutiveSteps = config.consecutiveSteps ?? CONSECUTIVE_STEPS;
65
- if (!Number.isInteger(consecutiveSteps) || consecutiveSteps < 1) {
66
- throw new Error(`dsh-allostasis: \`consecutiveSteps\` must be an integer >= 1, got ${consecutiveSteps}`);
80
+ const loopWindowSteps = steps('loopWindowSteps', config.loopWindowSteps ?? LOOP_WINDOW_STEPS);
81
+ const loopWindowHits = steps('loopWindowHits', config.loopWindowHits ?? LOOP_WINDOW_HITS);
82
+ if (loopWindowHits > loopWindowSteps) {
83
+ throw new Error(`dsh-allostasis: \`loopWindowHits\` (${loopWindowHits}) cannot exceed`
84
+ + ` \`loopWindowSteps\` (${loopWindowSteps}) — that trigger can never fire`);
67
85
  }
68
86
  const silentTurn = oneOf('silentTurn', config.silentTurn ?? SILENT_TURN_MODE, SILENT_TURN_MODES);
69
- return { driftThreshold, repetitionThreshold, consecutiveSteps, silentTurn };
87
+ return { driftThreshold, repetitionThreshold, loopWindowSteps, loopWindowHits, silentTurn };
70
88
  }
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * · **一期 · 中文锚定**(`agent/pre-step`):判定为语言漂移(英文功能词密度越线)就追加
7
7
  * 一条中文锚定消息。
8
- * · **二期 · 退化提醒**(`agent/pre-step`):判定为推理退化(重复率越线且连续若干步成立)
8
+ * · **二期 · 退化提醒**(`agent/pre-step`):判定为推理退化(窗口内累计若干步重复率越线)
9
9
  * 就追加减速提醒。
10
10
  * · **空回合检测**(`agent/turn-stopping`):回合收尾时末条助手消息没有非空文本即判定成立,
11
11
  * `steer` 档位下追加一次补生成。
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * · **一期 · 中文锚定**(`agent/pre-step`):判定为语言漂移(英文功能词密度越线)就追加
7
7
  * 一条中文锚定消息。
8
- * · **二期 · 退化提醒**(`agent/pre-step`):判定为推理退化(重复率越线且连续若干步成立)
8
+ * · **二期 · 退化提醒**(`agent/pre-step`):判定为推理退化(窗口内累计若干步重复率越线)
9
9
  * 就追加减速提醒。
10
10
  * · **空回合检测**(`agent/turn-stopping`):回合收尾时末条助手消息没有非空文本即判定成立,
11
11
  * `steer` 档位下追加一次补生成。
@@ -79,19 +79,19 @@ function driftMessage(session, sample, resolved, throttles) {
79
79
  /**
80
80
  * 组装二期的推理退化提醒。
81
81
  *
82
- * 追踪状态**每步都推进**,包括被节流挡住的那几步:计数描述的是退化本身持续了多久,
82
+ * 追踪状态**每步都推进**,包括被节流挡住的那几步:窗口描述的是退化本身分布在哪几步,
83
83
  * 与「这一步有没有说出口」无关。
84
84
  * @param session - 产出该思考的会话。
85
85
  * @param sample - 最近一条思考。
86
86
  * @param resolved - 已校验的配置。
87
- * @param trackers - 连续越线的追踪状态表。
87
+ * @param trackers - 越线窗口的追踪状态表。
88
88
  * @param throttles - 退化提醒的节流状态表。
89
89
  * @returns 应当追加的消息;本步不提醒时返回 `undefined`。
90
90
  */
91
91
  function loopMessage(session, sample, resolved, trackers, throttles) {
92
92
  const metrics = measureRepetition(sample.text);
93
93
  const stepVerdict = repetitionVerdict(metrics, resolved.repetitionThreshold);
94
- const tracked = trackLoop(trackers.get(session), sample.turn, stepVerdict, resolved.consecutiveSteps);
94
+ const tracked = trackLoop(trackers.get(session), stepVerdict, resolved.loopWindowSteps, resolved.loopWindowHits);
95
95
  trackers.set(session, tracked.state);
96
96
  if (!tracked.fire)
97
97
  return undefined;
@@ -172,7 +172,7 @@ export function apply(ctx, config = {}) {
172
172
  // 判定行走 debug,默认静默、排查时打开即可,不必为了看一眼判定去改阈值试。
173
173
  console.info(`[${name}] loaded · driftThreshold=${resolved.driftThreshold}`
174
174
  + ` repetitionThreshold=${resolved.repetitionThreshold}`
175
- + ` consecutiveSteps=${resolved.consecutiveSteps}`
175
+ + ` loopWindow=${resolved.loopWindowHits}/${resolved.loopWindowSteps}`
176
176
  + ` silentTurn=${resolved.silentTurn}`);
177
177
  /** 每个会话各一份状态;用 WeakMap 以免会话销毁后残留。 */
178
178
  const anchorThrottles = new WeakMap();
package/dist/messages.js CHANGED
@@ -60,7 +60,11 @@ export function degenerationText(turn, step, metrics, reminder = 1) {
60
60
  const top = metrics.top.slice(0, TOP_UNITS_SHOWN)
61
61
  .map(entry => `「${entry.unit.length <= TOP_UNIT_MAX_CHARS ? entry.unit : `${entry.unit.slice(0, TOP_UNIT_MAX_CHARS)}…`}」×${entry.count}`)
62
62
  .join('、');
63
- return `${head}${where},最高频的是 ${top}。`
63
+ // `top` 只收出现 ≥ `REPEAT_MIN_COUNT` 次的片段,所以它为空**等价于**重复率为 0。
64
+ // `repetitionThreshold: 0` 是合法配置(阈值校验允许 0,等于「任何一步都提醒」),
65
+ // 那条路径下必须省略这一句,否则会拼出「最高频的是 。」这种残句。
66
+ const highlight = top === '' ? '' : `,最高频的是 ${top}`;
67
+ return `${head}${where}${highlight}。`
64
68
  + '重复不等于想得更细,它是原地打转:这些片段没有带来新信息。'
65
69
  + '现在检查手上已有的信息够不够完成任务——够就直接给结论,'
66
70
  + '不够就换一个与前面不同的动作去取,而不是把同一句话再写一遍。';
@@ -1,22 +1,29 @@
1
1
  /**
2
2
  * 思考重复度判定(退化检测的判据)。
3
3
  *
4
- * 判据 = 单条推理按换行与中英句读切分后,**出现 ≥3 次的单元占全部单元的比例**,
5
- * 并要求连续若干步越线才触发。
4
+ * 判据由两部分构成。
6
5
  *
7
- * 实测依据(2026-09-28,会话 `session-fabc21b2`,19 轮 416 条助手消息):
8
- * 正常期重复率 0%–35%、退化期 48%–92%,隔离带很宽。阈值取 50% 落在这份单会话样本
9
- * 两端的中点附近,**不是标定结果**——它是第一期可用的起点,标定留给数据积累
10
- * (设计方案 §4.2 与 §五)。
6
+ * **单步度量**:按换行与中英句读切分推理,统计「出现 ≥3 次的单元」占比。只统计**实义
7
+ * 单元**——纯标记(代码围栏、花括号、星号)在写代码时天然高频,把它们计入会让正常步越线。
8
+ * 实测(2026-10-01,本机 `sessions-ssid` 根 37 个 ≥3MB 会话):三个会话的越线全部来自这类
9
+ * 标记,排除后归零;而真退化会话的峰值反而更高——噪声单元出局后真实重复的占比更突出。
11
10
  *
12
- * 连续 N 步是必需的:正常期也会出现单次抖动(实测峰值 35%),只按单步触发会让误报
13
- * 跟着抖动走。代价是延迟——按同一份数据回放,触发会落在 t11/s2 而不是 t10/s44,
14
- * 晚约 3 步(中间夹了一个 48%,低于阈值、计数归零)。
11
+ * **触发条件**:最近若干步内**累计**越线次数,不要求连续。原判据要求连续 2 步,其依据是
12
+ * 单个会话(`fabc21b2`,本机最严重的样本)里退化在同一 turn 内连成片;换成散布型退化时
13
+ * 判据失效——`a8ac8e89` 有 17 次语义越线却凑不出一次连续 2 步,提醒因此几乎不被发出
14
+ * (该会话只触发 1 次、落在 t38;另有 `8fa3b15e` 与 `cb56f381` 两个真退化会话触发 0 次)。
15
+ * 窗口累计在同一批样本上让两个零触发的会话开始触发,而三个假阳性会话(语义口径下越线为
16
+ * 零)在任何窗口下都不触发。窗口**跨 turn** 累计:散布正是跨 turn 的,按 turn 清零会把
17
+ * 它们重新拆散。
15
18
  *
16
- * 为什么不用推理长度当辅助判据:退化期的单条推理并不特别长(实测 1,000–2,600
17
- * 字符),长度不区分两群;重复率本身已经把「这段内容有多少信息」量化了。
19
+ * `insufficient`(单元数不足)既不记命中也不记未命中:让「推理偶尔写得很短」占位会稀释
20
+ * 窗口、反复推迟触发。
18
21
  *
19
- * 设计出处:`docs/设计/2026-09-28-应变二期-退化检测与自动干预.md` §四
22
+ * 为什么不用推理长度当辅助判据:退化期的单条推理并不特别长(实测 1,000–2,600 字符),
23
+ * 长度不区分两群;重复率本身已经把「这段内容有多少信息」量化了。
24
+ *
25
+ * 设计出处:`docs/设计/2026-09-28-应变二期-退化检测与自动干预.md` §四、
26
+ * `docs/设计/2026-10-01-提醒判据修正与实测方案.md`
20
27
  * @module @max-null/dsh-allostasis/repetition
21
28
  */
22
29
  /** 单元重复达到此次数才计入「重复单元」。 */
@@ -25,11 +32,13 @@ export declare const REPEAT_MIN_COUNT = 3;
25
32
  export declare const MIN_UNITS = 12;
26
33
  /** 重复率判定阈值——起点值,非标定值。 */
27
34
  export declare const REPETITION_THRESHOLD = 0.5;
28
- /** 触发所需的连续越线步数。 */
29
- export declare const CONSECUTIVE_STEPS = 2;
35
+ /** 触发所需的观察窗口,以步为单位(默认 5)。 */
36
+ export declare const LOOP_WINDOW_STEPS = 5;
37
+ /** 窗口内需要累计的越线步数(默认 2)。取 3 会漏掉「少而猛」型退化。 */
38
+ export declare const LOOP_WINDOW_HITS = 2;
30
39
  /** 一条思考的重复度量化结果。 */
31
40
  export interface RepetitionMetrics {
32
- /** 切分后的单元总数(去空白后)。 */
41
+ /** 计入统计的实义单元总数。 */
33
42
  units: number;
34
43
  /** 重复单元的**总出现次数**(同一单元出现 5 次计 5,不是计 1)。 */
35
44
  repeated: number;
@@ -46,8 +55,9 @@ export type RepetitionVerdict = 'loop' | 'normal' | 'insufficient';
46
55
  /**
47
56
  * 统计一条思考文本的重复度。空文本返回全零。
48
57
  *
49
- * 单元按 `UNIT_SEPARATOR` 切分并去掉空白,因此纯空行的段落不参与统计。
50
- * `top` 只收达到 `REPEAT_MIN_COUNT` 的单元,最多 5 条,同次数按单元字典序稳定排序。
58
+ * 单元按 `UNIT_SEPARATOR` 切分、去掉空白、滤掉非实义单元,因此纯空行的段落与代码标记
59
+ * 都不参与统计。`top` 只收达到 `REPEAT_MIN_COUNT` 的单元,最多 5 条,同次数按单元字典序
60
+ * 稳定排序。
51
61
  * @param text - 推理原文。
52
62
  * @returns 量化结果。
53
63
  */
@@ -55,37 +65,33 @@ export declare function measureRepetition(text: string): RepetitionMetrics;
55
65
  /**
56
66
  * 对一次测量下判定。
57
67
  *
58
- * 单元数不足 `MIN_UNITS` 时返回 `insufficient`——**不下结论**。调用方据此决定该步
59
- * 既不算越线也不算清白(见 `trackLoop`)。
68
+ * 单元数不足 `MIN_UNITS` 时返回 `insufficient`——**不下结论**。调用方据此决定该步既不算
69
+ * 越线也不算清白(见 `trackLoop`)。
60
70
  * @param metrics - 量化结果。
61
71
  * @param threshold - 重复率阈值;缺省用 {@link REPETITION_THRESHOLD}。
62
72
  * @returns 三态判定。
63
73
  */
64
74
  export declare function repetitionVerdict(metrics: RepetitionMetrics, threshold?: number): RepetitionVerdict;
65
- /** 连续越线的追踪状态。不可变——每次推进都返回一份新状态。 */
75
+ /** 越线窗口的追踪状态。不可变——每次推进都返回一份新状态。 */
66
76
  export interface LoopTrackerState {
67
- /** 当前已连续越线的步数。 */
68
- consecutive: number;
69
- /** 最近一次判定的 turn,用于识别「换了一轮」。 */
70
- lastTurn: number;
77
+ /** 最近若干步的越线标记,最近的在后;长度不超过窗口步数。 */
78
+ readonly recent: readonly boolean[];
71
79
  }
72
80
  /**
73
- * 推进连续越线计数,并判定本次是否触发。
74
- *
75
- * **状态不跨 turn 延续**:新一轮的第一条推理重新起算。turn 是用户可感知的边界,
76
- * 而且实测里退化在同一个 turn 内就已连成片(t10 的 44%–64% 全在一轮内),跨轮累加
77
- * 只会让触发更晚。
81
+ * 推进越线窗口,并判定本次是否触发。
78
82
  *
79
- * `insufficient` 的样本**保持计数不变**:它既不是越线也不是清白,算作清零会让
80
- * 「推理偶尔写得很短」反复推迟触发。
83
+ * 窗口**不按 turn 清零**:退化样本常常散布在若干轮里,按轮清零会把它们重新拆散——这正是
84
+ * 原「连续 2 步」判据在 `a8ac8e89` 上失效的原因。
81
85
  *
86
+ * `insufficient` 的样本**既不记命中也不记未命中**:让它占位会稀释窗口。代价是窗口可能
87
+ * 跨过多个步才填满,但触发条件本身是「累计」而非「密度」,不受影响。
82
88
  * @param state - 上一次的状态;首次调用传 `undefined`。
83
- * @param turn - 产出该思考的 turn。
84
89
  * @param verdict - 该步的判定。
85
- * @param required - 触发所需的连续越线步数;缺省用 {@link CONSECUTIVE_STEPS}。
90
+ * @param windowSteps - 窗口大小,以步为单位;缺省用 {@link LOOP_WINDOW_STEPS}。
91
+ * @param windowHits - 触发所需的窗口内越线步数;缺省用 {@link LOOP_WINDOW_HITS}。
86
92
  * @returns `state` 为推进后的新状态;`fire` 为本次是否达到触发条件。
87
93
  */
88
- export declare function trackLoop(state: LoopTrackerState | undefined, turn: number, verdict: RepetitionVerdict, required?: number): {
94
+ export declare function trackLoop(state: LoopTrackerState | undefined, verdict: RepetitionVerdict, windowSteps?: number, windowHits?: number): {
89
95
  state: LoopTrackerState;
90
96
  fire: boolean;
91
97
  };
@@ -1,22 +1,29 @@
1
1
  /**
2
2
  * 思考重复度判定(退化检测的判据)。
3
3
  *
4
- * 判据 = 单条推理按换行与中英句读切分后,**出现 ≥3 次的单元占全部单元的比例**,
5
- * 并要求连续若干步越线才触发。
4
+ * 判据由两部分构成。
6
5
  *
7
- * 实测依据(2026-09-28,会话 `session-fabc21b2`,19 轮 416 条助手消息):
8
- * 正常期重复率 0%–35%、退化期 48%–92%,隔离带很宽。阈值取 50% 落在这份单会话样本
9
- * 两端的中点附近,**不是标定结果**——它是第一期可用的起点,标定留给数据积累
10
- * (设计方案 §4.2 与 §五)。
6
+ * **单步度量**:按换行与中英句读切分推理,统计「出现 ≥3 次的单元」占比。只统计**实义
7
+ * 单元**——纯标记(代码围栏、花括号、星号)在写代码时天然高频,把它们计入会让正常步越线。
8
+ * 实测(2026-10-01,本机 `sessions-ssid` 根 37 个 ≥3MB 会话):三个会话的越线全部来自这类
9
+ * 标记,排除后归零;而真退化会话的峰值反而更高——噪声单元出局后真实重复的占比更突出。
11
10
  *
12
- * 连续 N 步是必需的:正常期也会出现单次抖动(实测峰值 35%),只按单步触发会让误报
13
- * 跟着抖动走。代价是延迟——按同一份数据回放,触发会落在 t11/s2 而不是 t10/s44,
14
- * 晚约 3 步(中间夹了一个 48%,低于阈值、计数归零)。
11
+ * **触发条件**:最近若干步内**累计**越线次数,不要求连续。原判据要求连续 2 步,其依据是
12
+ * 单个会话(`fabc21b2`,本机最严重的样本)里退化在同一 turn 内连成片;换成散布型退化时
13
+ * 判据失效——`a8ac8e89` 有 17 次语义越线却凑不出一次连续 2 步,提醒因此几乎不被发出
14
+ * (该会话只触发 1 次、落在 t38;另有 `8fa3b15e` 与 `cb56f381` 两个真退化会话触发 0 次)。
15
+ * 窗口累计在同一批样本上让两个零触发的会话开始触发,而三个假阳性会话(语义口径下越线为
16
+ * 零)在任何窗口下都不触发。窗口**跨 turn** 累计:散布正是跨 turn 的,按 turn 清零会把
17
+ * 它们重新拆散。
15
18
  *
16
- * 为什么不用推理长度当辅助判据:退化期的单条推理并不特别长(实测 1,000–2,600
17
- * 字符),长度不区分两群;重复率本身已经把「这段内容有多少信息」量化了。
19
+ * `insufficient`(单元数不足)既不记命中也不记未命中:让「推理偶尔写得很短」占位会稀释
20
+ * 窗口、反复推迟触发。
18
21
  *
19
- * 设计出处:`docs/设计/2026-09-28-应变二期-退化检测与自动干预.md` §四
22
+ * 为什么不用推理长度当辅助判据:退化期的单条推理并不特别长(实测 1,000–2,600 字符),
23
+ * 长度不区分两群;重复率本身已经把「这段内容有多少信息」量化了。
24
+ *
25
+ * 设计出处:`docs/设计/2026-09-28-应变二期-退化检测与自动干预.md` §四、
26
+ * `docs/设计/2026-10-01-提醒判据修正与实测方案.md`
20
27
  * @module @max-null/dsh-allostasis/repetition
21
28
  */
22
29
  /** 单元重复达到此次数才计入「重复单元」。 */
@@ -25,22 +32,39 @@ export const REPEAT_MIN_COUNT = 3;
25
32
  export const MIN_UNITS = 12;
26
33
  /** 重复率判定阈值——起点值,非标定值。 */
27
34
  export const REPETITION_THRESHOLD = 0.5;
28
- /** 触发所需的连续越线步数。 */
29
- export const CONSECUTIVE_STEPS = 2;
35
+ /** 触发所需的观察窗口,以步为单位(默认 5)。 */
36
+ export const LOOP_WINDOW_STEPS = 5;
37
+ /** 窗口内需要累计的越线步数(默认 2)。取 3 会漏掉「少而猛」型退化。 */
38
+ export const LOOP_WINDOW_HITS = 2;
30
39
  /** 切分单元用的分隔符:换行与中英句读。 */
31
40
  const UNIT_SEPARATOR = /[\n。!?]/;
41
+ /**
42
+ * 一个单元是否计入重复统计。
43
+ *
44
+ * 排除两类:以代码围栏开头的整段(` ``` ` 与 ` ```ts `),以及不含实义文字的纯标记单元。
45
+ * 实义 = 至少一个汉字,或至少两个连续拉丁字母——单个字母会让 `}`、`*`、`|` 这类符号的
46
+ * 邻接字母混进来。
47
+ * @param unit - 切分并去空白后的单元。
48
+ * @returns 该单元是否计入。
49
+ */
50
+ function isSemanticUnit(unit) {
51
+ if (unit.startsWith('```'))
52
+ return false;
53
+ return /[\p{Script=Han}]|[A-Za-z]{2}/u.test(unit);
54
+ }
32
55
  /**
33
56
  * 统计一条思考文本的重复度。空文本返回全零。
34
57
  *
35
- * 单元按 `UNIT_SEPARATOR` 切分并去掉空白,因此纯空行的段落不参与统计。
36
- * `top` 只收达到 `REPEAT_MIN_COUNT` 的单元,最多 5 条,同次数按单元字典序稳定排序。
58
+ * 单元按 `UNIT_SEPARATOR` 切分、去掉空白、滤掉非实义单元,因此纯空行的段落与代码标记
59
+ * 都不参与统计。`top` 只收达到 `REPEAT_MIN_COUNT` 的单元,最多 5 条,同次数按单元字典序
60
+ * 稳定排序。
37
61
  * @param text - 推理原文。
38
62
  * @returns 量化结果。
39
63
  */
40
64
  export function measureRepetition(text) {
41
65
  const units = text.split(UNIT_SEPARATOR)
42
66
  .map(part => part.trim())
43
- .filter(part => part !== '');
67
+ .filter(part => part !== '' && isSemanticUnit(part));
44
68
  const counts = new Map();
45
69
  for (const unit of units)
46
70
  counts.set(unit, (counts.get(unit) ?? 0) + 1);
@@ -63,8 +87,8 @@ export function measureRepetition(text) {
63
87
  /**
64
88
  * 对一次测量下判定。
65
89
  *
66
- * 单元数不足 `MIN_UNITS` 时返回 `insufficient`——**不下结论**。调用方据此决定该步
67
- * 既不算越线也不算清白(见 `trackLoop`)。
90
+ * 单元数不足 `MIN_UNITS` 时返回 `insufficient`——**不下结论**。调用方据此决定该步既不算
91
+ * 越线也不算清白(见 `trackLoop`)。
68
92
  * @param metrics - 量化结果。
69
93
  * @param threshold - 重复率阈值;缺省用 {@link REPETITION_THRESHOLD}。
70
94
  * @returns 三态判定。
@@ -75,26 +99,24 @@ export function repetitionVerdict(metrics, threshold = REPETITION_THRESHOLD) {
75
99
  return metrics.ratio >= threshold ? 'loop' : 'normal';
76
100
  }
77
101
  /**
78
- * 推进连续越线计数,并判定本次是否触发。
79
- *
80
- * **状态不跨 turn 延续**:新一轮的第一条推理重新起算。turn 是用户可感知的边界,
81
- * 而且实测里退化在同一个 turn 内就已连成片(t10 的 44%–64% 全在一轮内),跨轮累加
82
- * 只会让触发更晚。
102
+ * 推进越线窗口,并判定本次是否触发。
83
103
  *
84
- * `insufficient` 的样本**保持计数不变**:它既不是越线也不是清白,算作清零会让
85
- * 「推理偶尔写得很短」反复推迟触发。
104
+ * 窗口**不按 turn 清零**:退化样本常常散布在若干轮里,按轮清零会把它们重新拆散——这正是
105
+ * 原「连续 2 步」判据在 `a8ac8e89` 上失效的原因。
86
106
  *
107
+ * `insufficient` 的样本**既不记命中也不记未命中**:让它占位会稀释窗口。代价是窗口可能
108
+ * 跨过多个步才填满,但触发条件本身是「累计」而非「密度」,不受影响。
87
109
  * @param state - 上一次的状态;首次调用传 `undefined`。
88
- * @param turn - 产出该思考的 turn。
89
110
  * @param verdict - 该步的判定。
90
- * @param required - 触发所需的连续越线步数;缺省用 {@link CONSECUTIVE_STEPS}。
111
+ * @param windowSteps - 窗口大小,以步为单位;缺省用 {@link LOOP_WINDOW_STEPS}。
112
+ * @param windowHits - 触发所需的窗口内越线步数;缺省用 {@link LOOP_WINDOW_HITS}。
91
113
  * @returns `state` 为推进后的新状态;`fire` 为本次是否达到触发条件。
92
114
  */
93
- export function trackLoop(state, turn, verdict, required = CONSECUTIVE_STEPS) {
94
- const current = state ?? { consecutive: 0, lastTurn: Number.NaN };
95
- const base = current.lastTurn === turn ? current.consecutive : 0;
115
+ export function trackLoop(state, verdict, windowSteps = LOOP_WINDOW_STEPS, windowHits = LOOP_WINDOW_HITS) {
116
+ const current = state ?? { recent: [] };
96
117
  if (verdict === 'insufficient')
97
- return { state: { consecutive: base, lastTurn: turn }, fire: false };
98
- const consecutive = verdict === 'loop' ? base + 1 : 0;
99
- return { state: { consecutive, lastTurn: turn }, fire: consecutive >= required };
118
+ return { state: current, fire: false };
119
+ const recent = [...current.recent, verdict === 'loop'].slice(-windowSteps);
120
+ const hits = recent.filter(Boolean).length;
121
+ return { state: { recent }, fire: hits >= windowHits };
100
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-allostasis",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "应变(allostasis):会话状态的自我调节。第一期:中文思考的漂移检测与近因锚定",
5
5
  "license": "MIT",
6
6
  "publishConfig": {