dsh-fast-mode 1.5.22 → 1.5.24

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.24(稳定性修复:被截断的调用身份不再互相冒充)
4
+
5
+ - **问题**:调用身份的 `argsKey` 有 400 字符上限,超出就截断。两条参数只在尾部不同的调用(最常见:同一个 heredoc 脚本改了后半段、同一个文件改了后面的内容)会得到**同一个身份**。身份被用来判断「这一步有没有进展」——若两条调用的结果长度还恰好相同(无内容哈希时按字符数当摘要),这一步就会被判成「重复调用、没进展」,模型因此收到不该有的催办;复用检查也会把结论记到同一个键上。
6
+ - **修法**:`stableKey` 截断时附上整段序列化文本的 32 位 FNV-1a(8 位十六进制),长度仍是 **399** 字符(上限 401 未变)。前缀相同的长调用从此有不同身份;不截断的短调用字节级不变,缓存与旧行为完全一致。
7
+ - **本机暴露量**:3 168 个会话、146 506 次工具调用里,48 293 次(33 %)参数超过 400 字符被截断,其中 **782 组**「同一个 390 字符前缀对应 ≥2 种不同完整参数」——这 782 组在旧身份下是同一个键。触发错误催办还要求结果大小相同,所以线上未必发生过,但这条路径现在不再可能。
8
+ - 加回归测试:5000 字符同前缀、尾部不同的两个参数必须得到不同身份,且长度仍 ≤ 401。全套 304 个测试通过。
9
+
10
+ ## 1.5.23(打包:把量测脚本一起发出去)
11
+
12
+ - 1.5.22 的文档让读者用 `bench/mechanism.py` / `textshare.py` / `reminders.py` / `patchshare.py` 复算结论,但 npm 包里只带了 `block_score.py`,从 npm 装的人复算不了。现在这四个只读分析脚本一起进包(都是 stdlib、只读 `~/.dsh/sessions`)。无行为变更。
13
+
3
14
  ## 1.5.22(只加量测与文档:机制拆解、纪律份额、同配置重复性;默认行为不变)
4
15
 
5
16
  - **机制拆解落地成工具**:新增 `bench/mechanism.py`,把 168 对「同 tag、同任务、同 pair、只差 arm」的会话转录逐项数出来(步数、调用数、并行率、只叙述步、prose/参数/推理字符)。结论写进 BENCHMARK:并行的两条口径都是 1.00(即并行**不是**杠杆);步数 0.83 / 调用 0.79 / 参数 0.84 / prose 0.80 / 推理 0.75,全都落在 0.75–0.84——插件是「每样都少一点」,不是砍某一种输出。转录里推理字符占输出字符的 99.8 %(每会话中位 47.5 k vs 92),所以只盯可见文字优化、天花板是 0.2 %。
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """What actually changes between two arms: steps, tool calls, batching, prose.
3
+
4
+ The wall-clock win of fast mode is a step-count win (see BENCHMARK.md). This
5
+ script opens the retained transcripts behind matched runs and reports *which*
6
+ count moved, so the mechanism can be named instead of inferred:
7
+
8
+ messages assistant messages = steps as the runner counts them
9
+ calls tool calls emitted
10
+ calls/msg batching width
11
+ multiShare share of messages carrying >= 2 tool calls
12
+ textOnlyShare share of messages carrying no tool call (narration-only steps)
13
+ proseShare visible prose chars / (prose + tool-argument chars)
14
+
15
+ Usage:
16
+ python3 bench/mechanism.py --arm off --arm def15 # every matched pair
17
+ python3 bench/mechanism.py --arm off --arm def15 --tag DEF_MIG
18
+ python3 bench/mechanism.py --arm off --arm def15 --json
19
+ python3 bench/mechanism.py --list-arms # what is on disk
20
+
21
+ Runs are the session directories named `suite-<tag>-<task>-p<pair>-<arm>`;
22
+ only (tag, task, pair) groups that contain **both** arms are used, so the task
23
+ mix cancels exactly like the wall-clock block score does.
24
+ """
25
+ import argparse
26
+ import collections
27
+ import glob
28
+ import importlib.util
29
+ import json
30
+ import os
31
+ import statistics as st
32
+ import sys
33
+
34
+ HERE = os.path.dirname(os.path.abspath(__file__))
35
+ SESS = os.path.expanduser('~/.dsh/sessions')
36
+
37
+ spec = importlib.util.spec_from_file_location('textshare', os.path.join(HERE, 'textshare.py'))
38
+ textshare = importlib.util.module_from_spec(spec)
39
+ spec.loader.exec_module(textshare)
40
+
41
+
42
+ def collect_all():
43
+ out = []
44
+ for d in glob.glob(os.path.join(SESS, '--*dsh-runs-suite-*')):
45
+ name = os.path.basename(d).strip('-')
46
+ m = textshare.RUN_RE.search(name)
47
+ if not m:
48
+ continue
49
+ out.append((m.group('tag'), m.group('task'), int(m.group('pair')), m.group('arm'), d, name))
50
+ out.sort(key=lambda x: x[5])
51
+ return out
52
+
53
+
54
+ def stats_for(run):
55
+ text = textshare.read_transcript(run[4])
56
+ prose, args, msgs, calls, multi, textonly = textshare.tally(text)
57
+ if not msgs:
58
+ return None
59
+ return {'prose': prose, 'args': args, 'messages': msgs, 'calls': calls,
60
+ 'multi': multi, 'textonly': textonly,
61
+ 'callsPerMsg': calls / msgs,
62
+ 'multiShare': multi / msgs,
63
+ 'textOnlyShare': textonly / msgs,
64
+ 'proseShare': prose / (prose + args) if prose + args else 0.0}
65
+
66
+
67
+ def main():
68
+ ap = argparse.ArgumentParser()
69
+ ap.add_argument('--arm', action='append', default=[])
70
+ ap.add_argument('--tag', default='')
71
+ ap.add_argument('--limit', type=int, default=0, help='max matched groups; 0 = all')
72
+ ap.add_argument('--json', action='store_true')
73
+ ap.add_argument('--list-arms', action='store_true')
74
+ a = ap.parse_args()
75
+
76
+ runs = collect_all()
77
+ if a.list_arms:
78
+ c = collections.Counter(r[3] for r in runs)
79
+ for arm, n in c.most_common():
80
+ print(f'{arm:12} {n:5} runs')
81
+ return 0
82
+ arms = a.arm or ['off', 'def15']
83
+ if len(arms) != 2:
84
+ print('give exactly two --arm values', file=sys.stderr)
85
+ return 2
86
+
87
+ groups = collections.defaultdict(dict)
88
+ for tag, task, pair, arm, d, name in runs:
89
+ if a.tag and tag != a.tag:
90
+ continue
91
+ if arm not in arms:
92
+ continue
93
+ groups[(tag, task, pair)][arm] = (tag, task, pair, arm, d, name)
94
+
95
+ cells = []
96
+ for key in sorted(groups):
97
+ g = groups[key]
98
+ if not all(x in g for x in arms):
99
+ continue
100
+ row = {x: stats_for(g[x]) for x in arms}
101
+ if any(v is None for v in row.values()):
102
+ continue
103
+ cells.append(row)
104
+ if a.limit:
105
+ cells = cells[:a.limit]
106
+ if not cells:
107
+ print('no matched groups with transcripts; nothing to compute', file=sys.stderr)
108
+ return 1
109
+
110
+ keys = ['messages', 'calls', 'callsPerMsg', 'multiShare', 'textOnlyShare', 'proseShare']
111
+ med = {x: {k: st.median([c[x][k] for c in cells]) for k in keys} for x in arms}
112
+ summary = {'arms': arms, 'tag': a.tag or '*', 'groups': len(cells)}
113
+ for k in keys:
114
+ summary[k] = {'median': {x: med[x][k] for x in arms}}
115
+ ratios = [c[arms[1]][k] / c[arms[0]][k] for c in cells if c[arms[0]][k]]
116
+ summary[k]['medianRatio'] = st.median(ratios) if ratios else None
117
+ if a.json:
118
+ print(json.dumps(summary, indent=2))
119
+ return 0
120
+ print(f"matched groups: {len(cells)} (arm A = {arms[0]}, arm B = {arms[1]})")
121
+ print(f"{'metric':14} {arms[0]:>10} {arms[1]:>10} {'B/A':>7}")
122
+ for k in keys:
123
+ b, r = med[arms[1]][k], summary[k]['medianRatio']
124
+ print(f"{k:14} {med[arms[0]][k]:10.3f} {b:10.3f} {(r if r is not None else float('nan')):7.3f}")
125
+ return 0
126
+
127
+
128
+ if __name__ == '__main__':
129
+ sys.exit(main())
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """How much of the reuse lever was switched off by patch-shaped edits.
3
+
4
+ 1.5.19 marked a mutation whose target it could not locate as "uncertain" and then
5
+ stopped offering check-reuse suggestions for the rest of that session. Patch calls
6
+ (`apply_patch` / `patch`) name their targets inside the diff body, so before 1.5.20
7
+ any session that edited once with a patch paid that cost for every later check.
8
+ This counts, from retained transcripts, how common that shape is: sessions that
9
+ call a patch tool, and the tool calls that follow it in those sessions.
10
+
11
+ Usage:
12
+ python3 bench/patchshare.py # all local sessions
13
+ python3 bench/patchshare.py --limit 0 # explicit: all
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import glob
20
+ import json
21
+ import os
22
+ import subprocess
23
+
24
+ SESS = os.path.expanduser('~/.dsh/sessions')
25
+ PATCH_CALLS = {'apply_patch', 'patch', 'apply_diff', 'edit_patch'}
26
+ WRITE_CALLS = {'write', 'write_file', 'create_file', 'edit', 'multi_edit'} | PATCH_CALLS
27
+
28
+
29
+ def sessions(limit):
30
+ out = []
31
+ for d in sorted(glob.glob(os.path.join(SESS, '--*'))):
32
+ files = glob.glob(os.path.join(d, 'session-*', 'session.v4.jsonl.zstd'))
33
+ if files:
34
+ out.append((d, files[0]))
35
+ return out[:limit] if limit else out
36
+
37
+
38
+ def tally(path):
39
+ p = subprocess.run(['zstd', '-dc', path], capture_output=True)
40
+ if p.returncode != 0:
41
+ return None
42
+ calls = []
43
+ for line in p.stdout.decode('utf-8', 'replace').splitlines():
44
+ try:
45
+ o = json.loads(line)
46
+ except Exception: # noqa: BLE001 - a torn line is not fatal
47
+ continue
48
+ if o.get('type') != 'assistant/message':
49
+ continue
50
+ for part in (o.get('data') or {}).get('message', {}).get('content') or []:
51
+ if isinstance(part, dict) and part.get('type') == 'tool-call':
52
+ calls.append(part.get('toolName') or part.get('name') or '')
53
+ if not calls:
54
+ return None
55
+ first_patch = next((i for i, n in enumerate(calls) if n in PATCH_CALLS), None)
56
+ return {
57
+ 'calls': len(calls),
58
+ 'patch_calls': sum(1 for n in calls if n in PATCH_CALLS),
59
+ 'write_calls': sum(1 for n in calls if n in WRITE_CALLS),
60
+ 'first_patch': first_patch,
61
+ 'after_patch': 0 if first_patch is None else len(calls) - first_patch - 1,
62
+ }
63
+
64
+
65
+ def main():
66
+ ap = argparse.ArgumentParser()
67
+ ap.add_argument('--limit', type=int, default=60, help='sessions, most recent first; 0 = all')
68
+ a = ap.parse_args()
69
+ rows = []
70
+ for d, f in reversed(sessions(a.limit)):
71
+ r = tally(f)
72
+ if r:
73
+ r['session'] = os.path.basename(d)[:24]
74
+ rows.append(r)
75
+ if not rows:
76
+ print('no sessions')
77
+ return
78
+ with_patch = [r for r in rows if r['patch_calls'] > 0]
79
+ calls = sum(r['calls'] for r in rows)
80
+ pcalls = sum(r['patch_calls'] for r in rows)
81
+ after = sum(r['after_patch'] for r in with_patch)
82
+ print(f"sessions {len(rows)} calls {calls}")
83
+ print(f"sessions that patched {len(with_patch)} ({100 * len(with_patch) / len(rows):.0f} %)")
84
+ print(f"patch calls {pcalls} ({100 * pcalls / calls:.1f} % of calls)")
85
+ print(f"calls after the first patch, in patching sessions {after} "
86
+ f"({100 * after / calls:.1f} % of all calls)")
87
+ closed = [r for r in with_patch if r['after_patch'] > 0]
88
+ print(f"patching sessions with work still to do after the patch {len(closed)}")
89
+ med = sorted(r['after_patch'] for r in closed)
90
+ if med:
91
+ print(f" median calls still to do after the first patch: {med[len(med) // 2]}")
92
+
93
+
94
+ if __name__ == '__main__':
95
+ main()
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Which scheduler reminders actually reached the model, and what followed.
3
+
4
+ A reminder is only a lever if it is delivered. This counts, over retained
5
+ transcripts, every directive text the plugin injected (`Auto schedule: ...`),
6
+ grouped by kind, plus how often the quoted text shows up inside the model's own
7
+ prose (the model echoing the instruction back is not delivery).
8
+
9
+ Usage:
10
+ python3 bench/reminders.py # suite sessions, default cap
11
+ python3 bench/reminders.py --limit 0 # every retained session
12
+ python3 bench/reminders.py --dir '--*dsh-runs-suite-*'
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import collections
19
+ import glob
20
+ import json
21
+ import os
22
+ import subprocess
23
+
24
+ SESS = os.path.expanduser('~/.dsh/sessions')
25
+ KINDS = (
26
+ ('that exact check already passed', 'reuse-check'),
27
+ ('this turn is long enough that prose', 'output-discipline'),
28
+ ('batch now. Put every independent tool call', 'batch-now'),
29
+ ('files changed since the last run', 'write-and-verify'),
30
+ ('reached the step budget', 'step-budget'),
31
+ ('consecutive steps produced nothing new', 'change-approach'),
32
+ ('consecutive steps produced nothing', 'change-approach'),
33
+ )
34
+
35
+
36
+ def transcripts(pattern, limit):
37
+ out = []
38
+ for d in sorted(glob.glob(os.path.join(SESS, pattern))):
39
+ for f in glob.glob(os.path.join(d, 'session-*', 'session.v4.jsonl.zstd')):
40
+ out.append((d, f))
41
+ return out[:limit] if limit else out
42
+
43
+
44
+ def scan(path):
45
+ p = subprocess.run(['zstd', '-dc', path], capture_output=True)
46
+ if p.returncode != 0:
47
+ return None
48
+ text = p.stdout.decode('utf-8', 'replace')
49
+ counts = collections.Counter()
50
+ echoed = collections.Counter()
51
+ delivered = 0
52
+ for needle, kind in KINDS:
53
+ n = text.count(needle)
54
+ if n == 0:
55
+ continue
56
+ counts[kind] += n
57
+ # A directive is delivered as its own message part; the model repeating the
58
+ # phrase inside an assistant text block is an echo, so count both and report
59
+ # the delivery count only when it is separable.
60
+ for line in text.splitlines():
61
+ if 'Auto schedule:' not in line:
62
+ continue
63
+ try:
64
+ o = json.loads(line)
65
+ except Exception: # noqa: BLE001 - a torn line is not fatal
66
+ continue
67
+ role = o.get('type')
68
+ data = o.get('data') or {}
69
+ msg = data.get('message') or data
70
+ body = ' '.join(part.get('text') or '' for part in msg.get('content') or []
71
+ if isinstance(part, dict) and part.get('type') == 'text')
72
+ if 'Auto schedule:' not in body:
73
+ continue
74
+ for needle, kind in KINDS:
75
+ if needle in body:
76
+ if role == 'assistant/message':
77
+ echoed[kind] += 1
78
+ else:
79
+ delivered += 1
80
+ break
81
+ return counts, echoed, delivered
82
+
83
+
84
+ def main():
85
+ ap = argparse.ArgumentParser()
86
+ ap.add_argument('--limit', type=int, default=0, help='transcripts; 0 = all')
87
+ ap.add_argument('--dir', default='--*dsh-runs-suite-*')
88
+ a = ap.parse_args()
89
+ files = transcripts(a.dir, a.limit)
90
+ total = collections.Counter()
91
+ echo = collections.Counter()
92
+ delivered = 0
93
+ with_any = 0
94
+ scanned = 0
95
+ for d, f in files:
96
+ r = scan(f)
97
+ if not r:
98
+ continue
99
+ scanned += 1
100
+ counts, echoed, n = r
101
+ delivered += n
102
+ total.update(counts)
103
+ echo.update(echoed)
104
+ with_any += 1 if sum(counts.values()) > 0 else 0
105
+ print(f'transcripts {scanned} with any directive {with_any} '
106
+ f'({100 * with_any / max(scanned, 1):.0f} %) delivered {delivered}')
107
+ for kind, n in total.most_common():
108
+ print(f' {kind:18} raw {n:4} echoed-in-prose {echo[kind]:4}')
109
+
110
+
111
+ if __name__ == '__main__':
112
+ main()
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """Visible assistant prose vs tool-call argument characters.
3
+
4
+ Recomputes the claim "visible assistant prose is only ~1.2 % of (prose + tool
5
+ arguments)" from retained session transcripts, so the number in the docs has a
6
+ command behind it.
7
+
8
+ Counting unit is one `assistant/message` record of a session transcript:
9
+ * numerator = sum of len(text) over `content` parts of type `text`;
10
+ * denominator = numerator + sum of len(arguments) over parts of type `tool-call`.
11
+ Reasoning parts, tool *results*, and user/system messages are excluded; a tool-call
12
+ part's `arguments` is the raw JSON string the model emitted, so the ratio compares
13
+ what the model wrote out of band with what it said in prose.
14
+
15
+ Usage:
16
+ python3 bench/textshare.py # 40 def15 sessions, fixed rule
17
+ python3 bench/textshare.py --all # every def15 session on disk
18
+ python3 bench/textshare.py --arm disc-only --limit 0 --tag D2B_MIG
19
+ python3 bench/textshare.py --json # machine-readable summary
20
+
21
+ Sampling rule (deterministic, no RNG): all session directories whose run-directory
22
+ name matches `suite-<tag>-<task>-p<pair>-<arm>` are collected, sorted by that
23
+ directory name, and the first --limit are used (--limit 0 = all). The same corpus
24
+ therefore always yields the same number.
25
+ """
26
+ import argparse
27
+ import glob
28
+ import json
29
+ import os
30
+ import re
31
+ import statistics as st
32
+ import subprocess
33
+ import sys
34
+
35
+ SESS = os.path.expanduser('~/.dsh/sessions')
36
+ RUN_RE = re.compile(r'suite-(?P<tag>[A-Za-z0-9_]+)-(?P<task>\w+)-p(?P<pair>\d+)'
37
+ r'-(?P<arm>[A-Za-z0-9_]+)$')
38
+
39
+
40
+ def collect(arm, tag, limit):
41
+ """[(session_dir, run_name)] for one arm, in a fixed sorted order."""
42
+ out = []
43
+ for d in glob.glob(os.path.join(SESS, '--*dsh-runs-suite-*')):
44
+ name = os.path.basename(d).strip('-')
45
+ m = RUN_RE.search(name)
46
+ if not m or m.group('arm') != arm:
47
+ continue
48
+ if tag and m.group('tag') != tag:
49
+ continue
50
+ out.append((d, name))
51
+ out.sort(key=lambda x: x[1])
52
+ return out[:limit] if limit else out
53
+
54
+
55
+ def read_transcript(d):
56
+ files = glob.glob(os.path.join(d, 'session-*', 'session.v4.jsonl.zstd'))
57
+ for f in files:
58
+ p = subprocess.run(['zstd', '-dc', f], capture_output=True)
59
+ if p.returncode == 0:
60
+ return p.stdout.decode('utf-8', 'replace')
61
+ return ''
62
+
63
+
64
+ def tally(text):
65
+ """(prose chars, tool-arg chars, messages, tool calls, multi-call messages)."""
66
+ prose = args = msgs = calls = multi = textonly = 0
67
+ for line in text.splitlines():
68
+ try:
69
+ o = json.loads(line)
70
+ except Exception: # noqa: BLE001 - a torn transcript line is not fatal
71
+ continue
72
+ if o.get('type') != 'assistant/message':
73
+ continue
74
+ msgs += 1
75
+ this = 0
76
+ for part in (o.get('data') or {}).get('message', {}).get('content') or []:
77
+ if not isinstance(part, dict):
78
+ continue
79
+ if part.get('type') == 'text':
80
+ prose += len(part.get('text') or '')
81
+ elif part.get('type') == 'tool-call':
82
+ calls += 1
83
+ this += 1
84
+ args += len(part.get('arguments') or '')
85
+ if this >= 2:
86
+ multi += 1
87
+ elif this == 0:
88
+ textonly += 1
89
+ return prose, args, msgs, calls, multi, textonly
90
+
91
+
92
+ def main():
93
+ ap = argparse.ArgumentParser()
94
+ ap.add_argument('--arm', default='def15', help='arm name to sample (default def15)')
95
+ ap.add_argument('--tag', default='', help='restrict to one block tag, e.g. D2B_MIG')
96
+ ap.add_argument('--limit', type=int, default=40, help='sessions, first in sorted order; 0 = all')
97
+ ap.add_argument('--json', action='store_true')
98
+ a = ap.parse_args()
99
+
100
+ rows = collect(a.arm, a.tag, a.limit)
101
+ per = []
102
+ for d, name in rows:
103
+ prose, args, msgs, calls, multi, textonly = tally(read_transcript(d))
104
+ if msgs:
105
+ per.append({'run': name, 'prose': prose, 'args': args, 'messages': msgs,
106
+ 'calls': calls, 'multi': multi, 'textonly': textonly,
107
+ 'share': prose / (prose + args) if prose + args else 0})
108
+ if not per:
109
+ print(f'no transcripts for arm={a.arm} tag={a.tag or "*"}; nothing to compute')
110
+ return 1
111
+ prose = sum(r['prose'] for r in per)
112
+ args = sum(r['args'] for r in per)
113
+ total = prose + args
114
+ shares = sorted(r['share'] for r in per)
115
+ summary = {
116
+ 'arm': a.arm, 'tag': a.tag or '*', 'limit': a.limit,
117
+ 'sessions': len(per), 'messages': sum(r['messages'] for r in per),
118
+ 'calls': sum(r['calls'] for r in per),
119
+ 'multiCallMessages': sum(r['multi'] for r in per),
120
+ 'multiCallShare': (sum(r['multi'] for r in per) /
121
+ max(sum(r['messages'] for r in per), 1)),
122
+ 'textOnlyMessages': sum(r['textonly'] for r in per),
123
+ 'textOnlyShare': (sum(r['textonly'] for r in per) /
124
+ max(sum(r['messages'] for r in per), 1)),
125
+ 'proseChars': prose, 'toolArgChars': args, 'totalChars': total,
126
+ 'share': prose / total if total else None,
127
+ 'medianShare': st.median(shares), 'p10': shares[int(0.1 * (len(shares) - 1))],
128
+ 'p90': shares[int(0.9 * (len(shares) - 1))],
129
+ 'rule': f'sorted by run name, first {a.limit or "all"} of arm={a.arm}' +
130
+ (f' tag={a.tag}' if a.tag else ''),
131
+ 'command': 'python3 bench/textshare.py' + ('' if a.arm == 'def15' else f' --arm {a.arm}') +
132
+ (f' --tag {a.tag}' if a.tag else '') +
133
+ ('' if a.limit == 40 else f' --limit {a.limit}'),
134
+ }
135
+ if a.json:
136
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
137
+ return 0
138
+ print(f"arm {a.arm} tag {a.tag or '*'} {summary['rule']}")
139
+ print(f" sessions {summary['sessions']} messages {summary['messages']} "
140
+ f"tool calls {summary['calls']}")
141
+ print(f" prose {prose:,} chars tool args {args:,} chars total {total:,} chars")
142
+ print(f" prose share {100 * summary['share']:.2f} % "
143
+ f"(median {100 * summary['medianShare']:.2f} %, "
144
+ f"p10 {100 * summary['p10']:.2f} %, p90 {100 * summary['p90']:.2f} %)")
145
+ print(f" command: {summary['command']}")
146
+ return 0
147
+
148
+
149
+ if __name__ == '__main__':
150
+ sys.exit(main())
package/index.js CHANGED
@@ -97,7 +97,7 @@ const TAG = '[fast-mode]'
97
97
  * Shipped identity of this build. Traced into the debug log so a stale install is
98
98
  * visible at a glance, and pinned by a test against `package.json`.
99
99
  */
100
- export const VERSION = '1.5.22'
100
+ export const VERSION = '1.5.24'
101
101
  const GLOBAL_AGENT = '__global__'
102
102
 
103
103
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.22",
3
+ "version": "1.5.24",
4
4
  "description": "DeepSeek Harness fast mode: same thinking effort, fewer round trips. An adaptive parallel tool fan-out, output discipline that never trims the answer, and a cancellable stall guard. Never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,6 +39,10 @@
39
39
  "CHANGELOG.md",
40
40
  "BENCHMARK.md",
41
41
  "bench/block_score.py",
42
+ "bench/mechanism.py",
43
+ "bench/textshare.py",
44
+ "bench/reminders.py",
45
+ "bench/patchshare.py",
42
46
  "docs/",
43
47
  "LICENSE"
44
48
  ],
package/src/effort.js CHANGED
@@ -305,10 +305,31 @@ export function resolveConfig(raw, overrides) {
305
305
  }
306
306
  }
307
307
 
308
+ /**
309
+ * 32-bit FNV-1a of a string, as 8 hex characters.
310
+ *
311
+ * Used only to make a bounded argument key distinguishable again: two calls
312
+ * whose serialised arguments differ past the truncation point must not collapse
313
+ * into one identity, or the scheduler could tell the model it already ran a
314
+ * call whose tail it actually changed.
315
+ *
316
+ * @param {string} text
317
+ * @returns {string}
318
+ */
319
+ function digest8(text) {
320
+ let hash = 0x811c9dc5
321
+ for (let i = 0; i < text.length; i += 1) {
322
+ hash ^= text.charCodeAt(i)
323
+ hash = Math.imul(hash, 0x01000193) >>> 0
324
+ }
325
+ return hash.toString(16).padStart(8, '0')
326
+ }
327
+
308
328
  /**
309
329
  * A stable key for a call's arguments, so a repeated identical call is
310
330
  * detectable. Key order is normalised; values are bounded so a huge payload
311
- * cannot make this expensive.
331
+ * cannot make this expensive — but a truncated key also carries a digest of the
332
+ * whole serialised text, so two long calls that share a prefix stay distinct.
312
333
  *
313
334
  * @param {unknown} args
314
335
  * @returns {string}
@@ -326,7 +347,7 @@ export function stableKey(args) {
326
347
  }
327
348
  try {
328
349
  const text = walk(args, 0)
329
- return text.length > 400 ? `${text.slice(0, 400)}…` : text
350
+ return text.length > 400 ? `${text.slice(0, 390)}…${digest8(text)}` : text
330
351
  } catch {
331
352
  return '<unserialisable>'
332
353
  }