dqy08 commited on
Commit
21b5186
·
1 Parent(s): a0b7722

新增高惊讶度节点视觉放大选项;新增高惊讶度目标弱化入边选项;文案、UI改进

Browse files
backend/visit_stats.py CHANGED
@@ -16,6 +16,27 @@ _API = defaultdict(int)
16
  _OS_REPORTS = defaultdict(int) # 与同页「首轮心跳」(delta_active_sec == total_active_sec) 对齐,仅凭该包附带 client_os 计一次
17
  _VALID_CLIENT_OS = frozenset({"ios", "android", "windows", "macos", "linux", "unknown"})
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  # RLock:_persist_tick 在已持锁时调用 _sample_locked_counters,同线程需可重入。
20
  _LOCK = threading.RLock()
21
 
@@ -32,6 +53,8 @@ _HF_REPO = "dqy08/info-lens-stats"
32
  _HF_TOKEN = os.environ.get("HF_TOKEN_stats_write")
33
  _HF_TOTAL_FILE = "stats_total.json"
34
  _HF_DELTA_DIR = "stats_delta"
 
 
35
  def _stats_record(saved_at: str, body: dict) -> dict:
36
  """total / delta 磁盘与仓库共用:saved_at + 计数字段 + server_platform(若有)"""
37
  return {"saved_at": saved_at, **body}
@@ -73,6 +96,13 @@ def _restart_log_repo_path() -> str:
73
  return f"{_HF_DELTA_DIR}/{_delta_time_slug()}.restart.log"
74
 
75
 
 
 
 
 
 
 
 
76
  def _download_stats_total() -> dict | None:
77
  """从 HF Dataset 读取 stats_total.json,失败返回 None。"""
78
  if not _HF_TOKEN:
@@ -310,6 +340,13 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
310
  for k in set(bpo) | set(so)
311
  }
312
 
 
 
 
 
 
 
 
313
  tpl, tav = s["bp"] + s["sw_pl"], s["bav"] + s["sw_av"]
314
 
315
  public = {
@@ -323,16 +360,16 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
323
  stats_body = {
324
  "page_loads": tpl,
325
  "active_visits": tav,
 
326
  "page_sec": total_page_sec,
327
  "api": total_api,
328
- "os": total_os,
329
  }
330
  delta_body = {
331
  "page_loads": s["sw_pl"],
332
  "active_visits": s["sw_av"],
333
- "page_sec": sp,
334
- "api": sa,
335
- "os": so,
336
  }
337
  return public, stats_body, delta_body
338
 
 
16
  _OS_REPORTS = defaultdict(int) # 与同页「首轮心跳」(delta_active_sec == total_active_sec) 对齐,仅凭该包附带 client_os 计一次
17
  _VALID_CLIENT_OS = frozenset({"ios", "android", "windows", "macos", "linux", "unknown"})
18
 
19
+ # client/src/ts/utils/settingsMenuManager.ts handleVisitStatsClick:PAGE_ORDER / API_ORDER / OS_ORDER
20
+ _STATS_PAGE_ORDER = (
21
+ "index.html",
22
+ "analysis.html",
23
+ "compare.html",
24
+ "chat.html",
25
+ "attribution.html",
26
+ "gen_attribute.html",
27
+ )
28
+ _STATS_API_ORDER = (
29
+ "analyze",
30
+ "analyze_semantic",
31
+ "chat",
32
+ "causal_flow",
33
+ "prediction_attribute",
34
+ "prediction_attribute__attribution.html",
35
+ "prediction_attribute__chat.html",
36
+ "prediction_attribute__analysis.html",
37
+ )
38
+ _STATS_OS_ORDER = ("ios", "android", "windows", "macos", "linux", "unknown")
39
+
40
  # RLock:_persist_tick 在已持锁时调用 _sample_locked_counters,同线程需可重入。
41
  _LOCK = threading.RLock()
42
 
 
53
  _HF_TOKEN = os.environ.get("HF_TOKEN_stats_write")
54
  _HF_TOTAL_FILE = "stats_total.json"
55
  _HF_DELTA_DIR = "stats_delta"
56
+
57
+
58
  def _stats_record(saved_at: str, body: dict) -> dict:
59
  """total / delta 磁盘与仓库共用:saved_at + 计数字段 + server_platform(若有)"""
60
  return {"saved_at": saved_at, **body}
 
96
  return f"{_HF_DELTA_DIR}/{_delta_time_slug()}.restart.log"
97
 
98
 
99
+ def _ordered_str_int_map(primary: tuple[str, ...], m: Mapping[str, object]) -> dict[str, int]:
100
+ primary_set = frozenset(primary)
101
+ head = [k for k in primary if k in m]
102
+ tail = sorted(k for k in m if k not in primary_set)
103
+ return {k: int(m[k]) for k in (*head, *tail)}
104
+
105
+
106
  def _download_stats_total() -> dict | None:
107
  """从 HF Dataset 读取 stats_total.json,失败返回 None。"""
108
  if not _HF_TOKEN:
 
340
  for k in set(bpo) | set(so)
341
  }
342
 
343
+ total_page_sec = _ordered_str_int_map(_STATS_PAGE_ORDER, total_page_sec)
344
+ total_api = _ordered_str_int_map(_STATS_API_ORDER, total_api)
345
+ total_os = _ordered_str_int_map(_STATS_OS_ORDER, total_os)
346
+ ord_pg = _ordered_str_int_map(_STATS_PAGE_ORDER, sp)
347
+ ord_api = _ordered_str_int_map(_STATS_API_ORDER, sa)
348
+ ord_os = _ordered_str_int_map(_STATS_OS_ORDER, so)
349
+
350
  tpl, tav = s["bp"] + s["sw_pl"], s["bav"] + s["sw_av"]
351
 
352
  public = {
 
360
  stats_body = {
361
  "page_loads": tpl,
362
  "active_visits": tav,
363
+ "os": total_os,
364
  "page_sec": total_page_sec,
365
  "api": total_api,
 
366
  }
367
  delta_body = {
368
  "page_loads": s["sw_pl"],
369
  "active_visits": s["sw_av"],
370
+ "os": ord_os,
371
+ "page_sec": ord_pg,
372
+ "api": ord_api,
373
  }
374
  return public, stats_body, delta_body
375
 
client/src/css/gen_attribute.scss CHANGED
@@ -103,7 +103,8 @@
103
  // 节点标签:与测量层同源 1em × display-scale × node-text-font-scale(后者略小于 1 替代固定 padding)
104
  #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node-text {
105
  font-size: calc(
106
- 1em * var(--gen-attr-dag-display-scale, 1) * var(--gen-attr-dag-node-text-font-scale, 0.9)
 
107
  );
108
  font-family: inherit;
109
  font-weight: inherit;
@@ -387,6 +388,14 @@ body.gen-attribute-page .input-section {
387
  margin-top: 10px;
388
  }
389
 
 
 
 
 
 
 
 
 
390
  .attribution-exclude-prompt-patterns-header {
391
  flex-wrap: wrap;
392
 
 
103
  // 节点标签:与测量层同源 1em × display-scale × node-text-font-scale(后者略小于 1 替代固定 padding)
104
  #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node-text {
105
  font-size: calc(
106
+ 1em * var(--gen-attr-dag-display-scale, 1) *
107
+ var(--gen-attr-dag-node-text-font-scale, 0.9) * var(--gen-attr-dag-node-ci-visual-scale, 1)
108
  );
109
  font-family: inherit;
110
  font-weight: inherit;
 
388
  margin-top: 10px;
389
  }
390
 
391
+ // Start / Model 行与首行 DAG 参数(含 layout mode)之间略加大,避免与按钮挤在一起
392
+ .gen-attribute-page
393
+ .input-section
394
+ > .textarea-wrapper.chat-prompt-actions-row
395
+ + .gen-attr-dag-measure-width-row {
396
+ margin-top: 30px;
397
+ }
398
+
399
  .attribution-exclude-prompt-patterns-header {
400
  flex-wrap: wrap;
401
 
client/src/css/home.scss CHANGED
@@ -185,6 +185,7 @@ html[data-theme='dark'] body.nav-landing-page {
185
  .nav-landing-card {
186
  display: flex;
187
  flex-direction: column;
 
188
  gap: $nav-card-gap;
189
  padding: $nav-card-pad-block $nav-card-pad-inline;
190
  border-radius: $nav-card-radius;
@@ -196,11 +197,52 @@ html[data-theme='dark'] body.nav-landing-page {
196
  transition: background 0.15s ease, border-color 0.15s ease;
197
  box-sizing: border-box;
198
 
199
- &:hover {
200
  background: var(--nav-landing-card-bg-hover, #dcdcdc);
201
  }
202
  }
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  html[data-theme='dark'] .nav-landing-card {
205
  border-color: var(--border-color, #333);
206
  }
 
185
  .nav-landing-card {
186
  display: flex;
187
  flex-direction: column;
188
+ position: relative;
189
  gap: $nav-card-gap;
190
  padding: $nav-card-pad-block $nav-card-pad-inline;
191
  border-radius: $nav-card-radius;
 
197
  transition: background 0.15s ease, border-color 0.15s ease;
198
  box-sizing: border-box;
199
 
200
+ &:hover:not(:has(.nav-landing-card-badge:hover, .nav-landing-card-badge:focus-visible)) {
201
  background: var(--nav-landing-card-bg-hover, #dcdcdc);
202
  }
203
  }
204
 
205
+ .nav-landing-card-badge {
206
+ position: absolute;
207
+ top: -10px;
208
+ right: 12px;
209
+ z-index: 2;
210
+ height: 22px;
211
+ padding: 0 10px;
212
+ border-radius: 9999px;
213
+ border: 1px solid rgba(212, 107, 8, 0.35);
214
+ background: rgba(255, 247, 230, 0.78);
215
+ color: rgba(141, 73, 0, 0.86);
216
+ font-size: 12px;
217
+ font-weight: 600;
218
+ line-height: 22px;
219
+ white-space: nowrap;
220
+ cursor: pointer;
221
+ opacity: 0.82;
222
+ transition: opacity 0.16s ease, background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease;
223
+ }
224
+
225
+ .nav-landing-card-badge:hover,
226
+ .nav-landing-card-badge:focus-visible {
227
+ opacity: 1;
228
+ border-color: #ffb86b;
229
+ background: #fff2d9;
230
+ color: #ad4e00;
231
+ }
232
+
233
+ html[data-theme='dark'] .nav-landing-card-badge {
234
+ border-color: rgba(212, 107, 8, 0.42);
235
+ background: #383028;
236
+ color: rgba(237, 214, 192, 0.95);
237
+ }
238
+
239
+ html[data-theme='dark'] .nav-landing-card-badge:hover,
240
+ html[data-theme='dark'] .nav-landing-card-badge:focus-visible {
241
+ border-color: #ffb86b;
242
+ background: #4a4034;
243
+ color: #fff2e8;
244
+ }
245
+
246
  html[data-theme='dark'] .nav-landing-card {
247
  border-color: var(--border-color, #333);
248
  }
client/src/gen_attribute.html CHANGED
@@ -225,6 +225,26 @@
225
  <span class="semantic-submode-label">px</span>
226
  </span>
227
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
229
  <span class="semantic-submode-group">
230
  <label class="semantic-submode-label">
 
225
  <span class="semantic-submode-label">px</span>
226
  </span>
227
  </div>
228
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
229
+ <span class="semantic-submode-group">
230
+ <label class="semantic-submode-label">
231
+ <input type="checkbox" id="gen_attr_dag_node_ci_visual_scale" checked
232
+ title="When checked, generated nodes are visually enlarged based on their conditional information (surprisal). Takes effect on next generation."
233
+ data-i18n="title">
234
+ Enlarge high-surprisal nodes
235
+ </label>
236
+ </span>
237
+ </div>
238
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
239
+ <span class="semantic-submode-group">
240
+ <label class="semantic-submode-label">
241
+ <input type="checkbox" id="gen_attr_dag_edge_weaken_high_surprisal" checked
242
+ title="When checked, edges leading to high-surprisal (uncertain) target tokens are visually weakened by the mutual information ratio. Takes effect on next generation."
243
+ data-i18n="title">
244
+ Weaken attribution to high-surprisal target
245
+ </label>
246
+ </span>
247
+ </div>
248
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
249
  <span class="semantic-submode-group">
250
  <label class="semantic-submode-label">
client/src/scripts/injectPageMetaIntoHtml.js CHANGED
@@ -114,7 +114,12 @@ function injectPageMeta(html, pageKey, doc) {
114
  navKey === 'genAttribute'
115
  ? `<video class="nav-landing-card-shot" muted loop playsinline autoplay preload="metadata" aria-hidden="true"></video>`
116
  : `<div class="nav-landing-card-shot" aria-hidden="true"></div>`;
 
 
 
 
117
  const inner =
 
118
  `<div class="nav-landing-card-text">` +
119
  `<span class="nav-landing-card-title" data-i18n>${escapeHtmlText(navMeta.title)}</span>` +
120
  `<span class="nav-landing-card-subtitle" data-i18n>${escapeHtmlText(navMeta.subtitle)}</span>` +
 
114
  navKey === 'genAttribute'
115
  ? `<video class="nav-landing-card-shot" muted loop playsinline autoplay preload="metadata" aria-hidden="true"></video>`
116
  : `<div class="nav-landing-card-shot" aria-hidden="true"></div>`;
117
+ const badge =
118
+ navKey === 'genAttribute'
119
+ ? `<span class="nav-landing-card-badge" title="Go to demo on RedNote: xhslink.com/o/A7VLi99aBvG" data-i18n="text,title">100K+ plays on RedNote</span>`
120
+ : '';
121
  const inner =
122
+ badge +
123
  `<div class="nav-landing-card-text">` +
124
  `<span class="nav-landing-card-title" data-i18n>${escapeHtmlText(navMeta.title)}</span>` +
125
  `<span class="nav-landing-card-subtitle" data-i18n>${escapeHtmlText(navMeta.subtitle)}</span>` +
client/src/ts/attribution/genAttributeDagLinkSegment.ts CHANGED
@@ -1,13 +1,13 @@
1
- /** 轴对齐矩形节点:用于连线从边界起止(与 layout 所用的 x,y 左上角一致)。 */
2
  export type DagLinkRectNode = {
3
- x: number;
4
- y: number;
5
  nodeW: number;
6
  nodeH: number;
7
  };
8
 
9
  function nodeCenter(n: DagLinkRectNode): { cx: number; cy: number } {
10
- return { cx: n.x + n.nodeW / 2, cy: n.y + n.nodeH / 2 };
11
  }
12
 
13
  /** 轴对齐矩形(半宽 hw、半高 hh)中心沿单位向量 (ux,uy) 到边界的距离。 */
 
1
+ /** 轴对齐矩形节点:用于连线从边界起止,以矩形中心坐标表示。 */
2
  export type DagLinkRectNode = {
3
+ cx: number;
4
+ cy: number;
5
  nodeW: number;
6
  nodeH: number;
7
  };
8
 
9
  function nodeCenter(n: DagLinkRectNode): { cx: number; cy: number } {
10
+ return { cx: n.cx, cy: n.cy };
11
  }
12
 
13
  /** 轴对齐矩形(半宽 hw、半高 hh)中心沿单位向量 (ux,uy) 到边界的距离。 */
client/src/ts/attribution/genAttributeDagTextMeasure.ts CHANGED
@@ -6,9 +6,9 @@ import { visualizeSpecialChars } from '../utils/tokenDisplayUtils';
6
  import type { PromptTokenSpan } from './genAttributeDagPreprocess';
7
 
8
  export type GenAttrDagTokenGeom = {
9
- /** 锚点 fragment 左上角 {@link geomFromTokenFragments},DAG 节点框点对齐 */
10
- originX: number;
11
- originY: number;
12
  width: number;
13
  height: number;
14
  };
@@ -93,8 +93,8 @@ function geomFromTokenFragments(frags: TokenFragmentRect[], raw: string): GenAtt
93
  : 1;
94
  const widthSum = Math.max(geomWidthSum, expandedFloor);
95
  return {
96
- originX: first.x,
97
- originY: first.y,
98
  width: widthSum,
99
  height: hFirst,
100
  };
 
6
  import type { PromptTokenSpan } from './genAttributeDagPreprocess';
7
 
8
  export type GenAttrDagTokenGeom = {
9
+ /** token 基础矩形 尺寸的中心坐标;同行 token 的 cy 相,与 CI 缩放无关。 */
10
+ cx: number;
11
+ cy: number;
12
  width: number;
13
  height: number;
14
  };
 
93
  : 1;
94
  const widthSum = Math.max(geomWidthSum, expandedFloor);
95
  return {
96
+ cx: first.x + widthSum / 2,
97
+ cy: first.y + hFirst / 2,
98
  width: widthSum,
99
  height: hFirst,
100
  };
client/src/ts/attribution/genAttributeDagView.ts CHANGED
@@ -1,6 +1,6 @@
1
  import * as d3 from 'd3';
2
  import { DirectedGraph } from 'graphology';
3
- import type { D3Sel } from '../utils/Util';
4
  import { visualizeSpecialChars } from '../utils/tokenDisplayUtils';
5
  import {
6
  clampDagEdgeTopPCoverage,
@@ -11,6 +11,11 @@ import {
11
  type PromptTokenSpan,
12
  } from './genAttributeDagPreprocess';
13
  import { DAG_EDGE_MIN_DISPLAY_OPACITY } from './genAttributeDagEdgeDisplay';
 
 
 
 
 
14
  import { isOffsetSpanFullyExcluded } from './attributionDisplayModel';
15
  import {
16
  alignAndAggregateByNode,
@@ -39,6 +44,9 @@ import { paintTextFlowLayout } from './genAttributeDagViewTextFlowMode';
39
  import { paintSpiralLayout } from './genAttributeDagViewSpiralMode';
40
  import { tr } from '../lang/i18n-lite';
41
 
 
 
 
42
  /** 再次挂载前执行上一轮 detach(当前为空操作,保留扩展点) */
43
  const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>();
44
 
@@ -55,35 +63,39 @@ export function clampDagCompactness(n: number): number {
55
  return Math.min(DAG_COMPACTNESS_MAX, Math.max(DAG_COMPACTNESS_MIN, n));
56
  }
57
 
58
- /**
59
- * 零信心概率基准 p₀:surprisal log₂(1/p₀) 视作单 token 的绝对信息量参照(此处 20 bit)。
60
- * p = p₀ 时 {@link computeMutualInformationRatio} 为 0。
61
- */
62
- const ZERO_CONFIDENCE_PROBABILITY_BASELINE = 2 ** -20;
63
 
64
- function clamp01(n: number): number {
65
- return Math.min(1, Math.max(0, n));
 
 
 
 
 
 
 
 
 
66
  }
67
 
68
  /**
69
- * 互信息率 α在参照熵 log₂(1/p₀) 下将「前文与目标 token 的可对齐程度」
70
- * (log₂(1/p₀) log₂(1/p)) / log₂(1/p₀) = log₂(p/p₀) / log₂(1/p₀) clamp 到 [0,1]
71
- * surprisal α;仅用于本步入边透明度,不参与边筛选。缺省 `target_prob` 时返回 1(兼容旧缓存)
72
  */
73
- function computeMutualInformationRatio(targetProb: number | undefined): number {
74
- if (targetProb === undefined) return 1;
75
- if (!Number.isFinite(targetProb) || targetProb <= 0) return 0;
 
 
76
 
77
- return clamp01(
78
- Math.log2(targetProb / ZERO_CONFIDENCE_PROBABILITY_BASELINE) /
79
- Math.log2(1 / ZERO_CONFIDENCE_PROBABILITY_BASELINE)
80
- );
 
81
  }
82
 
83
- /**
84
- * 节点/边原生 `<title>` 中互信息率 α 的展示:α∈[0,1] 转为百分号字符串,
85
- * 与 analysis 主视图 Tooltip 中 Top-K 概率列 {@link formatTopkTooltipProbabilityPercent} 同形。
86
- */
87
  function formatMutualInformationRatioForTooltip(miRatio: number): string {
88
  if (!Number.isFinite(miRatio)) return String(miRatio);
89
  return formatTopkTooltipProbabilityPercent(miRatio);
@@ -109,12 +121,17 @@ type DagNodeAttrs = {
109
  */
110
  start: number;
111
  end: number;
112
- /** 节点框左上角(与测量层 origin 同坐标系) */
113
- x: number;
114
- y: number;
115
- /** 测量层几何 × display-scale 后的宽、高 */
 
 
 
116
  nodeW: number;
117
  nodeH: number;
 
 
118
  /** {@link visualizeSpecialChars}(DAG:仅「空格后是 [A-Za-z0-9]」保留空格,其余空格为 ·),建点后不变 */
119
  displayLabel: string;
120
  /** 原生 `<title>` 全文(与 `DISABLE_DAG_NODE_TOOLTIPS` 无关,便于切换时不必重算) */
@@ -141,9 +158,10 @@ type DagLink = {
141
  titleText: string;
142
  };
143
 
144
- /** 与 {@link refreshNodeLinkHighlight} 中边的 `stroke-opacity` 一致:`normalizedScore × mutualInformationRatio`。 */
145
  function dagLinkStrokeOpacity(d: Pick<DagLink, 'normalizedScore' | 'mutualInformationRatio'>): number {
146
- return (d.normalizedScore ?? 1) * (d.mutualInformationRatio ?? 1);
 
147
  }
148
 
149
  function dagLinkEndpointKey(source: string, target: string): string {
@@ -295,7 +313,7 @@ function linkEndInsetBaseAtUnitScalePx(measureLayerEl: HTMLElement): number {
295
  }
296
 
297
  function nodeRx(d: DagNode): number {
298
- return Math.min(9, d.nodeW / 2, d.nodeH / 2);
299
  }
300
 
301
  export type GenAttributeDagHandle = {
@@ -401,8 +419,14 @@ function buildNodeNativeTitleText(
401
  ];
402
  const { targetProb } = d;
403
  if (targetProb !== undefined && Number.isFinite(targetProb)) {
404
- lines.push(`Prob: ${formatTopkTooltipProbabilityPercent(targetProb)}`);
405
- lines.push(`MI ratio: ${formatMutualInformationRatioForTooltip(computeMutualInformationRatio(targetProb))}`);
 
 
 
 
 
 
406
  }
407
  return lines.join('\n');
408
  }
@@ -445,15 +469,15 @@ function buildLinkTitleText(
445
  const GLUE_EDGE_CHAR = /^(?:(?!\p{Script=Han})\p{L}|['\-_])$/u;
446
 
447
  /**
448
- * 子词拼接:offset 紧贴、同行、prev 末码点与当前首码点均满足 {@link GLUE_EDGE_CHAR}
449
- * → 左移到 prev 右缘(链式调用时 prev.x 已调整,自动支持多段续片)。
450
  */
451
  function snapSubwordNode(node: DagNode, prev: DagNode | null): void {
452
- if (!prev || prev.end !== node.start || node.y !== prev.y) return;
453
  const last = [...prev.label].at(-1) ?? '';
454
  const first = [...node.label][0] ?? '';
455
  if (!GLUE_EDGE_CHAR.test(last) || !GLUE_EDGE_CHAR.test(first)) return;
456
- node.x = prev.x + prev.nodeW;
457
  }
458
 
459
  /** 焦点 + 一层入邻(直接祖先)+ 一层出邻(直接后代),用于选中/悬停高亮范围 */
@@ -801,6 +825,7 @@ export function initGenAttributeDagView(
801
  });
802
  }
803
 
 
804
  const drag = d3
805
  .drag<SVGGElement, DagNode>()
806
  // 与 d3 默认 filter 一致,并仅在「当前节点已单击选中」时允许拖动手势生效,减少误拖
@@ -812,16 +837,22 @@ export function initGenAttributeDagView(
812
  selectedId === d.id &&
813
  layoutMode === 'text-flow'
814
  )
815
- .on('start', (event) => {
816
  event.sourceEvent?.stopPropagation();
 
 
817
  })
818
  .on('drag', (event, d) => {
819
  layoutDirty = true;
820
  userDraggedNodes = true;
821
  const [x, y] = d3.pointer(event, rootG.node());
822
- d.x = x;
823
- d.y = y;
 
824
  paint();
 
 
 
825
  });
826
 
827
  /**
@@ -949,7 +980,10 @@ export function initGenAttributeDagView(
949
  // 节点身份 append-only、几何(nodeW/nodeH)一旦建立不再变化(drag 仅改 x/y,
950
  // 由 paint 通过 transform 处理),故与几何相关的属性���在 enter 写一次即可;
951
  // 同理 `--prompt` class 依据 step === -1,step 初始化后不变。
952
- const g = enter.append('g').attr('class', 'gen-attr-dag-node');
 
 
 
953
  g.classed('gen-attr-dag-node--prompt', (d: DagNode) => d.step === -1);
954
  if (!DISABLE_DAG_NODE_TOOLTIPS) {
955
  g.append('title').text((d: DagNode) => d.nativeTitleText);
@@ -1034,10 +1068,11 @@ export function initGenAttributeDagView(
1034
  step: -1,
1035
  start: ns,
1036
  end: ne,
1037
- x: g.originX,
1038
- y: g.originY,
1039
  nodeW: g.width * displayScale,
1040
  nodeH: g.height * displayScale,
 
1041
  displayLabel,
1042
  nativeTitleText: buildNodeNativeTitleText({
1043
  displayLabel,
@@ -1081,16 +1116,18 @@ export function initGenAttributeDagView(
1081
  const displayLabel = visualizeSpecialChars(token, {
1082
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
1083
  });
 
1084
  const targetNode: DagNode = {
1085
  id: targetId,
1086
  label: token,
1087
  step: stepProcessed,
1088
  start: targetStart,
1089
  end: targetEnd,
1090
- x: g.originX,
1091
- y: g.originY,
1092
- nodeW: g.width * displayScale,
1093
- nodeH: g.height * displayScale,
 
1094
  displayLabel,
1095
  nativeTitleText: buildNodeNativeTitleText({
1096
  displayLabel,
 
1
  import * as d3 from 'd3';
2
  import { DirectedGraph } from 'graphology';
3
+ import { calculateSurprisal, type D3Sel } from '../utils/Util';
4
  import { visualizeSpecialChars } from '../utils/tokenDisplayUtils';
5
  import {
6
  clampDagEdgeTopPCoverage,
 
11
  type PromptTokenSpan,
12
  } from './genAttributeDagPreprocess';
13
  import { DAG_EDGE_MIN_DISPLAY_OPACITY } from './genAttributeDagEdgeDisplay';
14
+ import {
15
+ computeMutualInformationRatio,
16
+ computeConditionalInformationRatio,
17
+ FULL_CONFIDENCE_PROBABILITY_BASELINE,
18
+ } from '../utils/surprisalMath';
19
  import { isOffsetSpanFullyExcluded } from './attributionDisplayModel';
20
  import {
21
  alignAndAggregateByNode,
 
44
  import { paintSpiralLayout } from './genAttributeDagViewSpiralMode';
45
  import { tr } from '../lang/i18n-lite';
46
 
47
+ /** 与 {@link ToolTip} 中 surprisal 数值格式一致(`.3g`) */
48
+ const DAG_TITLE_SURPRISAL_FMT = d3.format('.3g');
49
+
50
  /** 再次挂载前执行上一轮 detach(当前为空操作,保留扩展点) */
51
  const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>();
52
 
 
63
  return Math.min(DAG_COMPACTNESS_MAX, Math.max(DAG_COMPACTNESS_MIN, n));
64
  }
65
 
 
 
 
 
 
66
 
67
+
68
+ /** 节点 CI 视觉放大开关;`false` 时所有生成节点 ciVisualScale 恒为 1×,下次 update() 起生效。 */
69
+ let dagNodeCiVisualScaleEnabled = true;
70
+ export function setDagNodeCiVisualScaleEnabled(enabled: boolean): void {
71
+ dagNodeCiVisualScaleEnabled = enabled;
72
+ }
73
+
74
+ /** 高惊讶度目标边弱化开关;`false` 时 mutualInformationRatio 恒为 1(不弱化),下次 update() 起生效。 */
75
+ let dagEdgeWeakenHighSurprisalEnabled = true;
76
+ export function setDagEdgeWeakenHighSurprisalEnabled(enabled: boolean): void {
77
+ dagEdgeWeakenHighSurprisalEnabled = enabled;
78
  }
79
 
80
  /**
81
+ * DAG 生成节点矩形/标签缩放CI=0→1×CI=1→2×(prompt 节点恒用 1,见建点处)。
82
+ * p > {@link FULL_CONFIDENCE_PROBABILITY_BASELINE}(surprisal < 3 bit)时截断为 1×,不放大
83
+ * {@link dagNodeCiVisualScaleEnabled} false返回 1。
84
  */
85
+ function dagGeneratedNodeCiVisualScale(targetProb: number | undefined): number {
86
+ if (!dagNodeCiVisualScaleEnabled) return 1;
87
+ if (targetProb !== undefined && Number.isFinite(targetProb) && targetProb > FULL_CONFIDENCE_PROBABILITY_BASELINE) return 1;
88
+ return 1 + computeConditionalInformationRatio(targetProb);
89
+ }
90
 
91
+ /** 原生 `<title>` 中 CI/MI 百分号展示,与 Top-K 概率列 {@link formatTopkTooltipProbabilityPercent} 同形。 */
92
+ function formatCiMiRatiosLineForTooltip(ciRatio: number, miRatio: number): string {
93
+ const ci = Number.isFinite(ciRatio) ? formatTopkTooltipProbabilityPercent(ciRatio) : String(ciRatio);
94
+ const mi = Number.isFinite(miRatio) ? formatTopkTooltipProbabilityPercent(miRatio) : String(miRatio);
95
+ return `CI/MI: ${ci} / ${mi}`;
96
  }
97
 
98
+ /** 边原生 `<title>` 中互信息率 α 的展示(节点 title 改用 {@link formatCiMiRatiosLineForTooltip})。 */
 
 
 
99
  function formatMutualInformationRatioForTooltip(miRatio: number): string {
100
  if (!Number.isFinite(miRatio)) return String(miRatio);
101
  return formatTopkTooltipProbabilityPercent(miRatio);
 
121
  */
122
  start: number;
123
  end: number;
124
+ /**
125
+ * 节点矩形中心坐标。center 不随 CI 缩放变化,故同行 token 的 cy 始终相等,
126
+ * 可直接用于 {@link snapSubwordNode} 同行��测,无需额外 baseY 字段。
127
+ */
128
+ cx: number;
129
+ cy: number;
130
+ /** 测量层几何 × display-scale × CI 缩放 后的宽、高 */
131
  nodeW: number;
132
  nodeH: number;
133
+ /** CI 视觉缩放倍数 `1 + CI` ∈ [1, 2];prompt 节点为 `1`。供 CSS 字号变量使用。 */
134
+ ciVisualScale: number;
135
  /** {@link visualizeSpecialChars}(DAG:仅「空格后是 [A-Za-z0-9]」保留空格,其余空格为 ·),建点后不变 */
136
  displayLabel: string;
137
  /** 原生 `<title>` 全文(与 `DISABLE_DAG_NODE_TOOLTIPS` 无关,便于切换时不必重算) */
 
158
  titleText: string;
159
  };
160
 
161
+ /** 与 {@link refreshNodeLinkHighlight} 中边的 `stroke-opacity` 一致:`normalizedScore × mutualInformationRatio`(开关关闭时 MI 系数恒为 1)。 */
162
  function dagLinkStrokeOpacity(d: Pick<DagLink, 'normalizedScore' | 'mutualInformationRatio'>): number {
163
+ const mi = dagEdgeWeakenHighSurprisalEnabled ? (d.mutualInformationRatio ?? 1) : 1;
164
+ return (d.normalizedScore ?? 1) * mi;
165
  }
166
 
167
  function dagLinkEndpointKey(source: string, target: string): string {
 
313
  }
314
 
315
  function nodeRx(d: DagNode): number {
316
+ return Math.min(d.nodeW / 2, d.nodeH / 2);
317
  }
318
 
319
  export type GenAttributeDagHandle = {
 
419
  ];
420
  const { targetProb } = d;
421
  if (targetProb !== undefined && Number.isFinite(targetProb)) {
422
+ lines.push(`\nProb: ${formatTopkTooltipProbabilityPercent(targetProb)}`);
423
+ lines.push(`Information: ${DAG_TITLE_SURPRISAL_FMT(calculateSurprisal(targetProb))} bits`);
424
+ lines.push(
425
+ formatCiMiRatiosLineForTooltip(
426
+ computeConditionalInformationRatio(targetProb),
427
+ computeMutualInformationRatio(targetProb),
428
+ ),
429
+ );
430
  }
431
  return lines.join('\n');
432
  }
 
469
  const GLUE_EDGE_CHAR = /^(?:(?!\p{Script=Han})\p{L}|['\-_])$/u;
470
 
471
  /**
472
+ * 子词拼接:offset 紧贴、同行(cy 相等)、prev 末码点与当前首码点均满足 {@link GLUE_EDGE_CHAR}
473
+ * → 将当前节点中心 cx 紧贴 prev 右缘(链式调用时 prev.cx 已调整,自动支持多段续片)。
474
  */
475
  function snapSubwordNode(node: DagNode, prev: DagNode | null): void {
476
+ if (!prev || prev.end !== node.start || node.cy !== prev.cy) return;
477
  const last = [...prev.label].at(-1) ?? '';
478
  const first = [...node.label][0] ?? '';
479
  if (!GLUE_EDGE_CHAR.test(last) || !GLUE_EDGE_CHAR.test(first)) return;
480
+ node.cx = prev.cx + (prev.nodeW + node.nodeW) / 2;
481
  }
482
 
483
  /** 焦点 + 一层入邻(直接祖先)+ 一层出邻(直接后代),用于选中/悬停高亮范围 */
 
825
  });
826
  }
827
 
828
+ let dragPointerOffset: { x: number; y: number } | null = null;
829
  const drag = d3
830
  .drag<SVGGElement, DagNode>()
831
  // 与 d3 默认 filter 一致,并仅在「当前节点已单击选中」时允许拖动手势生效,减少误拖
 
837
  selectedId === d.id &&
838
  layoutMode === 'text-flow'
839
  )
840
+ .on('start', (event, d) => {
841
  event.sourceEvent?.stopPropagation();
842
+ const [x, y] = d3.pointer(event, rootG.node());
843
+ dragPointerOffset = { x: x - d.cx, y: y - d.cy };
844
  })
845
  .on('drag', (event, d) => {
846
  layoutDirty = true;
847
  userDraggedNodes = true;
848
  const [x, y] = d3.pointer(event, rootG.node());
849
+ const offset = dragPointerOffset ?? { x: 0, y: 0 };
850
+ d.cx = x - offset.x;
851
+ d.cy = y - offset.y;
852
  paint();
853
+ })
854
+ .on('end', () => {
855
+ dragPointerOffset = null;
856
  });
857
 
858
  /**
 
980
  // 节点身份 append-only、几何(nodeW/nodeH)一旦建立不再变化(drag 仅改 x/y,
981
  // 由 paint 通过 transform 处理),故与几何相关的属性���在 enter 写一次即可;
982
  // 同理 `--prompt` class 依据 step === -1,step 初始化后不变。
983
+ const g = enter
984
+ .append('g')
985
+ .attr('class', 'gen-attr-dag-node')
986
+ .style('--gen-attr-dag-node-ci-visual-scale', (d: DagNode) => String(d.ciVisualScale));
987
  g.classed('gen-attr-dag-node--prompt', (d: DagNode) => d.step === -1);
988
  if (!DISABLE_DAG_NODE_TOOLTIPS) {
989
  g.append('title').text((d: DagNode) => d.nativeTitleText);
 
1068
  step: -1,
1069
  start: ns,
1070
  end: ne,
1071
+ cx: g.cx,
1072
+ cy: g.cy,
1073
  nodeW: g.width * displayScale,
1074
  nodeH: g.height * displayScale,
1075
+ ciVisualScale: 1,
1076
  displayLabel,
1077
  nativeTitleText: buildNodeNativeTitleText({
1078
  displayLabel,
 
1116
  const displayLabel = visualizeSpecialChars(token, {
1117
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
1118
  });
1119
+ const ciVisualScale = dagGeneratedNodeCiVisualScale(response.target_prob);
1120
  const targetNode: DagNode = {
1121
  id: targetId,
1122
  label: token,
1123
  step: stepProcessed,
1124
  start: targetStart,
1125
  end: targetEnd,
1126
+ cx: g.cx,
1127
+ cy: g.cy,
1128
+ nodeW: g.width * displayScale * ciVisualScale,
1129
+ nodeH: g.height * displayScale * ciVisualScale,
1130
+ ciVisualScale,
1131
  displayLabel,
1132
  nativeTitleText: buildNodeNativeTitleText({
1133
  displayLabel,
client/src/ts/attribution/genAttributeDagViewLinearArcMode.ts CHANGED
@@ -25,7 +25,7 @@ export function clampLinearArcAdjacentGap(px: number): number {
25
  );
26
  }
27
 
28
- type LinearArcNodeLike = { nodeW: number };
29
 
30
  /** `step === -1` 表示 prompt(与 `genAttributeDagView` 中 `DagNode.step` 约定一致) */
31
  type LinearArcSteppedNode = LinearArcNodeLike & { step: number };
@@ -76,7 +76,8 @@ export function paintLinearArcLayout<
76
  if (srcCx === undefined || tgtCx === undefined) {
77
  throw new Error('paintLinearArcLayout: link endpoint not in linear node list');
78
  }
79
- const y = LINEAR_ARC_BASELINE_Y;
 
80
  const dx = Math.abs(tgtCx - srcCx);
81
  const arcH = dx * 0.4;
82
  const upY = y - arcH;
@@ -98,6 +99,6 @@ export function paintLinearArcLayout<
98
  nodeSel.attr('transform', (d) => {
99
  const cx = centerXByNode.get(d);
100
  if (cx === undefined) return null; // 不在布局列表中(已 display:none),不更新 transform
101
- return `translate(${cx - d.nodeW / 2},${LINEAR_ARC_BASELINE_Y})`;
102
  });
103
  }
 
25
  );
26
  }
27
 
28
+ type LinearArcNodeLike = { nodeW: number; nodeH: number; ciVisualScale: number };
29
 
30
  /** `step === -1` 表示 prompt(与 `genAttributeDagView` 中 `DagNode.step` 约定一致) */
31
  type LinearArcSteppedNode = LinearArcNodeLike & { step: number };
 
76
  if (srcCx === undefined || tgtCx === undefined) {
77
  throw new Error('paintLinearArcLayout: link endpoint not in linear node list');
78
  }
79
+ // 用未放大的半高(nodeH / ciVisualScale / 2)定位弧端点,使所有节点顶部对齐同一 y 基线。
80
+ const y = LINEAR_ARC_BASELINE_Y - src.nodeH / (2 * src.ciVisualScale);
81
  const dx = Math.abs(tgtCx - srcCx);
82
  const arcH = dx * 0.4;
83
  const upY = y - arcH;
 
99
  nodeSel.attr('transform', (d) => {
100
  const cx = centerXByNode.get(d);
101
  if (cx === undefined) return null; // 不在布局列表中(已 display:none),不更新 transform
102
+ return `translate(${cx - d.nodeW / 2},${LINEAR_ARC_BASELINE_Y - d.nodeH / 2})`;
103
  });
104
  }
client/src/ts/attribution/genAttributeDagViewSpiralMode.ts CHANGED
@@ -10,6 +10,8 @@ const SPIRAL_SPACING = 60;
10
  const SPIRAL_ARC_STEP = 40;
11
  /** 螺旋旋转相位(弧度):控制螺旋臂展开方向。0 = 向右,-Math.PI/2 = 向上。 */
12
  const SPIRAL_PHASE = Math.PI * 0.6;
 
 
13
  // ────────────────────────────────────────────────────────────────────────────
14
 
15
  type SpiralNodeLike = { nodeW: number; nodeH: number };
@@ -63,10 +65,19 @@ export function paintSpiralLayout<
63
  positionByNode.set(nodes[i]!, rawPos[i]!);
64
  }
65
 
 
 
 
 
 
 
66
  // 节点:中心落在螺旋点,矩形保持水平
67
  nodeSel.attr('transform', (d) => {
68
  const pos = positionByNode.get(d);
69
  if (pos === undefined) return null;
 
 
 
70
  return `translate(${pos.cx - d.nodeW / 2},${pos.cy - d.nodeH / 2})`;
71
  });
72
 
@@ -76,18 +87,10 @@ export function paintSpiralLayout<
76
  const pa = positionByNode.get(src);
77
  const pb = positionByNode.get(tgt);
78
  if (pa === undefined || pb === undefined) return;
79
- const srcRect = {
80
- x: pa.cx - src.nodeW / 2,
81
- y: pa.cy - src.nodeH / 2,
82
- nodeW: src.nodeW,
83
- nodeH: src.nodeH,
84
- };
85
- const tgtRect = {
86
- x: pb.cx - tgt.nodeW / 2,
87
- y: pb.cy - tgt.nodeH / 2,
88
- nodeW: tgt.nodeW,
89
- nodeH: tgt.nodeH,
90
- };
91
  const seg = linkSegmentThroughNodeRects(srcRect, tgtRect, linkEndInsetPx);
92
  d3.select(this)
93
  .selectAll('path.gen-attr-dag-link-visible')
 
10
  const SPIRAL_ARC_STEP = 40;
11
  /** 螺旋旋转相位(弧度):控制螺旋臂展开方向。0 = 向右,-Math.PI/2 = 向上。 */
12
  const SPIRAL_PHASE = Math.PI * 0.6;
13
+ /** 螺旋上第一个(起始位置)token 的相对视觉放大倍数(仅 spiral 布局)。 */
14
+ const SPIRAL_FIRST_TOKEN_SCALE = 2;
15
  // ────────────────────────────────────────────────────────────────────────────
16
 
17
  type SpiralNodeLike = { nodeW: number; nodeH: number };
 
65
  positionByNode.set(nodes[i]!, rawPos[i]!);
66
  }
67
 
68
+ const firstSpiralNode = nodes.length > 0 ? nodes[0]! : null;
69
+ const effNodeSize = (n: NodeDatum) =>
70
+ firstSpiralNode !== null && n === firstSpiralNode
71
+ ? { nodeW: n.nodeW * SPIRAL_FIRST_TOKEN_SCALE, nodeH: n.nodeH * SPIRAL_FIRST_TOKEN_SCALE }
72
+ : { nodeW: n.nodeW, nodeH: n.nodeH };
73
+
74
  // 节点:中心落在螺旋点,矩形保持水平
75
  nodeSel.attr('transform', (d) => {
76
  const pos = positionByNode.get(d);
77
  if (pos === undefined) return null;
78
+ if (firstSpiralNode !== null && d === firstSpiralNode) {
79
+ return `translate(${pos.cx},${pos.cy}) scale(${SPIRAL_FIRST_TOKEN_SCALE}) translate(${-d.nodeW / 2},${-d.nodeH / 2})`;
80
+ }
81
  return `translate(${pos.cx - d.nodeW / 2},${pos.cy - d.nodeH / 2})`;
82
  });
83
 
 
87
  const pa = positionByNode.get(src);
88
  const pb = positionByNode.get(tgt);
89
  if (pa === undefined || pb === undefined) return;
90
+ const sw = effNodeSize(src);
91
+ const tw = effNodeSize(tgt);
92
+ const srcRect = { cx: pa.cx, cy: pa.cy, nodeW: sw.nodeW, nodeH: sw.nodeH };
93
+ const tgtRect = { cx: pb.cx, cy: pb.cy, nodeW: tw.nodeW, nodeH: tw.nodeH };
 
 
 
 
 
 
 
 
94
  const seg = linkSegmentThroughNodeRects(srcRect, tgtRect, linkEndInsetPx);
95
  d3.select(this)
96
  .selectAll('path.gen-attr-dag-link-visible')
client/src/ts/attribution/genAttributeDagViewTextFlowMode.ts CHANGED
@@ -2,8 +2,8 @@ import * as d3 from 'd3';
2
  import { linkSegmentThroughNodeRects } from './genAttributeDagLinkSegment';
3
 
4
  type TextFlowNodeLike = {
5
- x: number;
6
- y: number;
7
  nodeW: number;
8
  nodeH: number;
9
  };
@@ -23,5 +23,5 @@ export function paintTextFlowLayout<LinkDatum, NodeDatum extends TextFlowNodeLik
23
  .selectAll('path.gen-attr-dag-link-visible')
24
  .attr('d', `M ${seg.x1} ${seg.y1} L ${seg.x2} ${seg.y2}`);
25
  });
26
- nodeSel.attr('transform', (d) => `translate(${d.x},${d.y})`);
27
  }
 
2
  import { linkSegmentThroughNodeRects } from './genAttributeDagLinkSegment';
3
 
4
  type TextFlowNodeLike = {
5
+ cx: number;
6
+ cy: number;
7
  nodeW: number;
8
  nodeH: number;
9
  };
 
23
  .selectAll('path.gen-attr-dag-link-visible')
24
  .attr('d', `M ${seg.x1} ${seg.y1} L ${seg.x2} ${seg.y2}`);
25
  });
26
+ nodeSel.attr('transform', (d) => `translate(${d.cx - d.nodeW / 2},${d.cy - d.nodeH / 2})`);
27
  }
client/src/ts/gen_attribute.ts CHANGED
@@ -25,6 +25,8 @@ import {
25
  } from './attribution/genAttributeDagPreprocess';
26
  import {
27
  initGenAttributeDagView,
 
 
28
  type DagLayoutMode,
29
  clampDagCompactness,
30
  clampLinearArcAdjacentGap,
@@ -97,6 +99,8 @@ const GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_layout_mod
97
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
98
  const GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_replay_pacing_mode';
99
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_total_s';
 
 
100
  const GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_inactive_edges';
101
  const GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_excluded_tokens';
102
  const GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY =
@@ -379,6 +383,12 @@ function applyDagLayoutModeUi(): void {
379
  const dagHideExcludedTokensInput = document.getElementById(
380
  'gen_attr_dag_hide_excluded_tokens'
381
  ) as HTMLInputElement | null;
 
 
 
 
 
 
382
  const dagHideInactiveEdgesInput = document.getElementById(
383
  'gen_attr_dag_hide_inactive_edges'
384
  ) as HTMLInputElement | null;
@@ -408,6 +418,50 @@ if (dagPlaybackTotalSInput) dagPlaybackTotalSInput.value = String(initialDagPlay
408
  applyDagReplaySpeedUi();
409
 
410
  const genAttrResultsNode = genAttrResultsEl.node() as HTMLElement | null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  function applyDagHideInactiveEdges(hide: boolean): void {
412
  if (!genAttrResultsNode) return;
413
  genAttrResultsNode.classList.toggle('gen-attr-dag-hide-inactive-edges', hide);
@@ -871,6 +925,18 @@ function isDagBusy(): boolean {
871
  return inFlight || dagPlaybackTimer !== null || dagLastTokenDwellTimer !== null;
872
  }
873
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  dagMeasureWidthInput?.addEventListener('change', () => {
875
  const raw = parseInt(dagMeasureWidthInput.value, 10);
876
  const w = Number.isFinite(raw)
@@ -883,14 +949,7 @@ dagMeasureWidthInput?.addEventListener('change', () => {
883
  /* ignore */
884
  }
885
  dagHandle.setMeasureWidthPx(w);
886
- if (isDagBusy()) return;
887
- const h = runnerHandle;
888
- dagHandle.reset();
889
- if (h && h.tokenCount > 0) {
890
- replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
891
- }
892
- dagHandle.fitViewportToContent();
893
- dagHandle.clearNodeSelection();
894
  });
895
 
896
  dagCompactnessInput?.addEventListener('change', () => {
@@ -903,14 +962,7 @@ dagCompactnessInput?.addEventListener('change', () => {
903
  /* ignore */
904
  }
905
  dagHandle.setDagCompactness(c);
906
- if (isDagBusy()) return;
907
- const h = runnerHandle;
908
- dagHandle.reset();
909
- if (h && h.tokenCount > 0) {
910
- replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
911
- }
912
- dagHandle.fitViewportToContent();
913
- dagHandle.clearNodeSelection();
914
  });
915
 
916
  dagEdgeTopPCoverageInput?.addEventListener('change', () => {
@@ -925,14 +977,7 @@ dagEdgeTopPCoverageInput?.addEventListener('change', () => {
925
  /* ignore */
926
  }
927
  dagHandle.setEdgeTopPCoverage(c);
928
- if (isDagBusy()) return;
929
- const h = runnerHandle;
930
- dagHandle.reset();
931
- if (h && h.tokenCount > 0) {
932
- replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
933
- }
934
- dagHandle.fitViewportToContent();
935
- dagHandle.clearNodeSelection();
936
  });
937
 
938
  dagLinearArcIntervalInput?.addEventListener('change', () => {
@@ -1300,7 +1345,7 @@ async function applyGenAttrCachedRun(
1300
  }
1301
  }
1302
 
1303
- /** Cached history 与 `?content=` 共用;`shouldTouch` 为 true 时 touch MRU 并刷新下拉镜像。 */
1304
  async function restoreGenAttrFromCachedRun(
1305
  contentKey: string,
1306
  shouldTouch: boolean,
 
25
  } from './attribution/genAttributeDagPreprocess';
26
  import {
27
  initGenAttributeDagView,
28
+ setDagNodeCiVisualScaleEnabled,
29
+ setDagEdgeWeakenHighSurprisalEnabled,
30
  type DagLayoutMode,
31
  clampDagCompactness,
32
  clampLinearArcAdjacentGap,
 
99
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
100
  const GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_replay_pacing_mode';
101
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_total_s';
102
+ const GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY = 'info_radar_gen_attr_dag_node_ci_visual_scale';
103
+ const GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY = 'info_radar_gen_attr_dag_edge_weaken_high_surprisal';
104
  const GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_inactive_edges';
105
  const GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_excluded_tokens';
106
  const GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY =
 
383
  const dagHideExcludedTokensInput = document.getElementById(
384
  'gen_attr_dag_hide_excluded_tokens'
385
  ) as HTMLInputElement | null;
386
+ const dagNodeCiVisualScaleInput = document.getElementById(
387
+ 'gen_attr_dag_node_ci_visual_scale'
388
+ ) as HTMLInputElement | null;
389
+ const dagEdgeWeakenHighSurprisalInput = document.getElementById(
390
+ 'gen_attr_dag_edge_weaken_high_surprisal'
391
+ ) as HTMLInputElement | null;
392
  const dagHideInactiveEdgesInput = document.getElementById(
393
  'gen_attr_dag_hide_inactive_edges'
394
  ) as HTMLInputElement | null;
 
418
  applyDagReplaySpeedUi();
419
 
420
  const genAttrResultsNode = genAttrResultsEl.node() as HTMLElement | null;
421
+ function readStoredDagNodeCiVisualScale(): boolean {
422
+ try {
423
+ const v = localStorage.getItem(GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY);
424
+ return v === null ? true : v === '1';
425
+ } catch {
426
+ return true;
427
+ }
428
+ }
429
+ const initialDagNodeCiVisualScale = readStoredDagNodeCiVisualScale();
430
+ if (dagNodeCiVisualScaleInput) dagNodeCiVisualScaleInput.checked = initialDagNodeCiVisualScale;
431
+ setDagNodeCiVisualScaleEnabled(initialDagNodeCiVisualScale);
432
+ dagNodeCiVisualScaleInput?.addEventListener('change', () => {
433
+ const enabled = dagNodeCiVisualScaleInput.checked;
434
+ try {
435
+ localStorage.setItem(GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY, enabled ? '1' : '0');
436
+ } catch {
437
+ /* ignore */
438
+ }
439
+ setDagNodeCiVisualScaleEnabled(enabled);
440
+ tryResetAndReplayDag();
441
+ });
442
+
443
+ function readStoredDagEdgeWeakenHighSurprisal(): boolean {
444
+ try {
445
+ const v = localStorage.getItem(GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY);
446
+ return v === null ? true : v === '1';
447
+ } catch {
448
+ return true;
449
+ }
450
+ }
451
+ const initialDagEdgeWeakenHighSurprisal = readStoredDagEdgeWeakenHighSurprisal();
452
+ if (dagEdgeWeakenHighSurprisalInput) dagEdgeWeakenHighSurprisalInput.checked = initialDagEdgeWeakenHighSurprisal;
453
+ setDagEdgeWeakenHighSurprisalEnabled(initialDagEdgeWeakenHighSurprisal);
454
+ dagEdgeWeakenHighSurprisalInput?.addEventListener('change', () => {
455
+ const enabled = dagEdgeWeakenHighSurprisalInput.checked;
456
+ try {
457
+ localStorage.setItem(GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY, enabled ? '1' : '0');
458
+ } catch {
459
+ /* ignore */
460
+ }
461
+ setDagEdgeWeakenHighSurprisalEnabled(enabled);
462
+ tryResetAndReplayDag();
463
+ });
464
+
465
  function applyDagHideInactiveEdges(hide: boolean): void {
466
  if (!genAttrResultsNode) return;
467
  genAttrResultsNode.classList.toggle('gen-attr-dag-hide-inactive-edges', hide);
 
925
  return inFlight || dagPlaybackTimer !== null || dagLastTokenDwellTimer !== null;
926
  }
927
 
928
+ /** 非忙状态下 reset + replay + fit,供各设置项切换后复用。忙时为 no-op。 */
929
+ function tryResetAndReplayDag(): void {
930
+ if (isDagBusy()) return;
931
+ const h = runnerHandle;
932
+ dagHandle.reset();
933
+ if (h && h.tokenCount > 0) {
934
+ replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
935
+ }
936
+ dagHandle.fitViewportToContent();
937
+ dagHandle.clearNodeSelection();
938
+ }
939
+
940
  dagMeasureWidthInput?.addEventListener('change', () => {
941
  const raw = parseInt(dagMeasureWidthInput.value, 10);
942
  const w = Number.isFinite(raw)
 
949
  /* ignore */
950
  }
951
  dagHandle.setMeasureWidthPx(w);
952
+ tryResetAndReplayDag();
 
 
 
 
 
 
 
953
  });
954
 
955
  dagCompactnessInput?.addEventListener('change', () => {
 
962
  /* ignore */
963
  }
964
  dagHandle.setDagCompactness(c);
965
+ tryResetAndReplayDag();
 
 
 
 
 
 
 
966
  });
967
 
968
  dagEdgeTopPCoverageInput?.addEventListener('change', () => {
 
977
  /* ignore */
978
  }
979
  dagHandle.setEdgeTopPCoverage(c);
980
+ tryResetAndReplayDag();
 
 
 
 
 
 
 
981
  });
982
 
983
  dagLinearArcIntervalInput?.addEventListener('change', () => {
 
1345
  }
1346
  }
1347
 
1348
+ /** 从缓存恢复运行;`shouldTouch` 为 true 时 touch MRU下拉选中恒为 false,↑ 置顶走单独路径)。 */
1349
  async function restoreGenAttrFromCachedRun(
1350
  contentKey: string,
1351
  shouldTouch: boolean,
client/src/ts/home.ts CHANGED
@@ -22,6 +22,7 @@ const GEN_ATTRIBUTE_HOME_DEMO_SLUG: Record<'en' | 'zh', string> = {
22
  en: 'Write a sonnet about love',
23
  zh: '写一首绝句,主题是春天',
24
  };
 
25
 
26
  function applyGenAttributeNavCardHref(): void {
27
  const a = document.querySelector<HTMLAnchorElement>('a.nav-landing-card[data-nav-page="genAttribute"]');
@@ -43,7 +44,20 @@ function syncGenAttributeCardPreviewVideo(theme: Theme): void {
43
  v.src = DAG_PREVIEW_BY_THEME[theme];
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  applyGenAttributeNavCardHref();
 
47
 
48
  const apiPrefix = URLHandler.parameters['api'] || '';
49
  const { api } = initializeCommonApp(apiPrefix);
 
22
  en: 'Write a sonnet about love',
23
  zh: '写一首绝句,主题是春天',
24
  };
25
+ const GEN_ATTRIBUTE_BADGE_LINK = 'http://xhslink.com/o/A7VLi99aBvG';
26
 
27
  function applyGenAttributeNavCardHref(): void {
28
  const a = document.querySelector<HTMLAnchorElement>('a.nav-landing-card[data-nav-page="genAttribute"]');
 
44
  v.src = DAG_PREVIEW_BY_THEME[theme];
45
  }
46
 
47
+ function bindGenAttributeBadgeLink(): void {
48
+ const badge = document.querySelector<HTMLElement>(
49
+ 'a.nav-landing-card[data-nav-page="genAttribute"] .nav-landing-card-badge'
50
+ );
51
+ if (!badge) return;
52
+ badge.addEventListener('click', (event: MouseEvent) => {
53
+ event.preventDefault();
54
+ event.stopPropagation();
55
+ window.open(GEN_ATTRIBUTE_BADGE_LINK, '_blank', 'noopener');
56
+ });
57
+ }
58
+
59
  applyGenAttributeNavCardHref();
60
+ bindGenAttributeBadgeLink();
61
 
62
  const apiPrefix = URLHandler.parameters['api'] || '';
63
  const { api } = initializeCommonApp(apiPrefix);
client/src/ts/lang/translations.ts CHANGED
@@ -29,6 +29,8 @@ export const translations: Translations = {
29
  '- attribute a predicted token to its context': '- 将预测 token 归因到上下文',
30
  'LLM Causal Flow': 'LLM Causal Flow 因果流',
31
  '- explore the context-attribution DAG': '- 探索上下文归因的 DAG 关系图',
 
 
32
  // 合成标题串(<title>):英文 key 与 injectPageMeta documentTitleEn 拼接一致
33
  'Info Lens - A toolbox for exploring the informational nature of LLMs and language': 'Info Lens 信息透镜 - 用于探索 LLM 与语言的信息本质的工具箱',
34
  "Info Highlight - highlight the 'informative' parts": 'Info Highlight 信息高亮 - 高亮“信息量大”的地方',
 
29
  '- attribute a predicted token to its context': '- 将预测 token 归因到上下文',
30
  'LLM Causal Flow': 'LLM Causal Flow 因果流',
31
  '- explore the context-attribution DAG': '- 探索上下文归因的 DAG 关系图',
32
+ '100K+ plays on RedNote': '小红书 10万+次播放',
33
+ 'Go to demo on RedNote: xhslink.com/o/A7VLi99aBvG': '在小红书打开demo:xhslink.com/o/A7VLi99aBvG)',
34
  // 合成标题串(<title>):英文 key 与 injectPageMeta documentTitleEn 拼接一致
35
  'Info Lens - A toolbox for exploring the informational nature of LLMs and language': 'Info Lens 信息透镜 - 用于探索 LLM 与语言的信息本质的工具箱',
36
  "Info Highlight - highlight the 'informative' parts": 'Info Highlight 信息高亮 - 高亮“信息量大”的地方',
client/src/ts/utils/SurprisalColorConfig.ts CHANGED
@@ -1,5 +1,6 @@
1
  import * as d3 from "d3";
2
  import { isFiniteNumber } from "./Util";
 
3
 
4
  /**
5
  * 惊讶度颜色配置模块
@@ -11,7 +12,7 @@ import { isFiniteNumber } from "./Util";
11
  // ==========================================
12
 
13
  /** Token surprisal 的最大值,用于颜色映射(默认上限,可被调用方传入的可选 max 覆盖) */
14
- const TOKEN_SURPRISAL_MAX = 18;
15
 
16
  /** Byte surprisal 的最大值,用于颜色映射 */
17
  const BYTE_SURPRISAL_MAX = 6;
 
1
  import * as d3 from "d3";
2
  import { isFiniteNumber } from "./Util";
3
+ import { REFERENCE_MAX_SURPRISAL_BITS } from "./surprisalMath";
4
 
5
  /**
6
  * 惊讶度颜色配置模块
 
12
  // ==========================================
13
 
14
  /** Token surprisal 的最大值,用于颜色映射(默认上限,可被调用方传入的可选 max 覆盖) */
15
+ const TOKEN_SURPRISAL_MAX = REFERENCE_MAX_SURPRISAL_BITS;
16
 
17
  /** Byte surprisal 的最大值,用于颜色映射 */
18
  const BYTE_SURPRISAL_MAX = 6;
client/src/ts/utils/cachedHistoryUi.ts CHANGED
@@ -27,8 +27,7 @@ export type InitCachedHistoryQueryDropdownOptions = {
27
  listMru: () => Promise<CachedHistoryListRow[]>;
28
  /**
29
  * 第一参为 {@link CachedHistoryListRow.contentKey}(与 `?content=` 一致)。
30
- * 第二参 `shouldTouch` {@link initQueryHistoryDropdown} 的 `onHistorySelect` 一致:
31
- * 是否应对关联 MRU 执行 touch(悬停为 false)。
32
  */
33
  onSelectEntry: (
34
  contentKey: string,
@@ -40,7 +39,7 @@ export type InitCachedHistoryQueryDropdownOptions = {
40
  };
41
 
42
  /**
43
- * 三页 Cached history 共用的「无 input + MRU 异步刷新 + 悬停预览」接线。
44
  * 返回 `refreshList` 供 URL hydrate 等与下拉无关的路径刷新内存列表。
45
  */
46
  export function initCachedHistoryQueryDropdown(
@@ -60,8 +59,8 @@ export function initCachedHistoryQueryDropdown(
60
  filterHistoryByInput: false,
61
  onSelect: () => {},
62
  fillInputOnSelect: false,
63
- onHistorySelect: (contentKey, shouldTouch) => {
64
- void Promise.resolve(options.onSelectEntry(contentKey, shouldTouch, ctx));
65
  },
66
  onRemove: options.onRemove,
67
  onPromote: options.onPromote,
 
27
  listMru: () => Promise<CachedHistoryListRow[]>;
28
  /**
29
  * 第一参为 {@link CachedHistoryListRow.contentKey}(与 `?content=` 一致)。
30
+ * 第二参恒为 false:列表点击或悬停预览均不 bump MRU,仅 {@link onPromote}(↑)会 touch。
 
31
  */
32
  onSelectEntry: (
33
  contentKey: string,
 
39
  };
40
 
41
  /**
42
+ * 三页 Cached history 共用的「无 input + MRU 异步刷新 + 悬停预览」接线;选中条目不写 MRU
43
  * 返回 `refreshList` 供 URL hydrate 等与下拉无关的路径刷新内存列表。
44
  */
45
  export function initCachedHistoryQueryDropdown(
 
59
  filterHistoryByInput: false,
60
  onSelect: () => {},
61
  fillInputOnSelect: false,
62
+ onHistorySelect: (contentKey) => {
63
+ void Promise.resolve(options.onSelectEntry(contentKey, false, ctx));
64
  },
65
  onRemove: options.onRemove,
66
  onPromote: options.onPromote,
client/src/ts/utils/settingsMenuManager.ts CHANGED
@@ -374,7 +374,7 @@ export class SettingsMenuManager {
374
  }
375
 
376
  private async handleVisitStatsClick(): Promise<void> {
377
- /** Visit stats 弹窗专用主序列与 webpack 顶层页 / access_log 归类一致 */
378
  const PAGE_ORDER = [
379
  'index.html',
380
  'analysis.html',
@@ -445,14 +445,19 @@ export class SettingsMenuManager {
445
  };
446
 
447
  const fetchAndRender = async (container: d3.Selection<HTMLDivElement, unknown, HTMLElement, any>) => {
448
- container.selectAll('*').remove();
449
- const block = container
450
- .append('div')
451
- .attr('class', 'visit-stats-body')
452
- .style('margin', '0')
453
- .style('white-space', 'pre-wrap')
454
- .style('font', 'inherit')
455
- .style('font-size', '13px');
 
 
 
 
 
456
  try {
457
  const data = await this.api.getVisitStats();
458
  if (!data?.success) throw new Error('bad');
@@ -460,6 +465,7 @@ export class SettingsMenuManager {
460
  } catch {
461
  block.text('Failed to load stats.');
462
  }
 
463
  };
464
 
465
  showDialog({
 
374
  }
375
 
376
  private async handleVisitStatsClick(): Promise<void> {
377
+ // backend/visit_stats.py_STATS_PAGE_ORDER / _STATS_API_ORDER / _STATS_OS_ORDER
378
  const PAGE_ORDER = [
379
  'index.html',
380
  'analysis.html',
 
445
  };
446
 
447
  const fetchAndRender = async (container: d3.Selection<HTMLDivElement, unknown, HTMLElement, any>) => {
448
+ let block = container.select<HTMLDivElement>('div.visit-stats-body');
449
+ if (block.empty()) {
450
+ block = container
451
+ .append('div')
452
+ .attr('class', 'visit-stats-body')
453
+ .style('margin', '0')
454
+ .style('white-space', 'pre-wrap')
455
+ .style('font', 'inherit')
456
+ .style('font-size', '13px');
457
+ } else {
458
+ // 与 Model Management 一致:用透明度保留占位,避免清空 DOM 导致弹窗高度塌陷抖动
459
+ block.style('opacity', '0');
460
+ }
461
  try {
462
  const data = await this.api.getVisitStats();
463
  if (!data?.success) throw new Error('bad');
 
465
  } catch {
466
  block.text('Failed to load stats.');
467
  }
468
+ block.style('opacity', '1');
469
  };
470
 
471
  showDialog({
client/src/ts/utils/surprisalMath.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 惊讶度与信息量的数学基础模块。
3
+ * 颜色映射相关常量见 {@link SurprisalColorConfig}。
4
+ */
5
+
6
+ /**
7
+ * 零信心概率基准 p₀:surprisal log₂(1/p₀) 视作单 token 的绝对信息量参照。
8
+ * 超过此值视为模型已无法有效预测,各处可视化统一在此封顶。
9
+ * 此处为 18 bit,大致对应模型的词表大小256K时的平均token概率。
10
+ */
11
+ export const ZERO_CONFIDENCE_PROBABILITY_BASELINE = 2 ** -18;
12
+
13
+ /** 与 p₀ 对应的参照 surprisal 上界(bit);同时作为 token 着色标尺上限。 */
14
+ export const REFERENCE_MAX_SURPRISAL_BITS = Math.log2(1 / ZERO_CONFIDENCE_PROBABILITY_BASELINE);
15
+
16
+ /**
17
+ * 全信心概率阈值 p₁:surprisal 低于对应 bit 数时视作模型已充分自信,视觉上不放大节点。
18
+ * 此处为 3 bit,对应概率 > 1/8(约 12.5%)。
19
+ */
20
+ export const FULL_CONFIDENCE_PROBABILITY_BASELINE = 2 ** -3;
21
+
22
+ /** 与 p₁ 对应的 surprisal 下界(bit);低于此值的节点 ciVisualScale 截断为 1×。 */
23
+ export const REFERENCE_NO_SURPRISAL_BITS = Math.log2(1 / FULL_CONFIDENCE_PROBABILITY_BASELINE);
24
+
25
+ function clamp01(n: number): number {
26
+ return Math.min(1, Math.max(0, n));
27
+ }
28
+
29
+ /**
30
+ * 互信息率 α:在参照熵 log₂(1/p₀) 下,将「前文与目标 token 的可对齐程度」
31
+ * (log₂(1/p₀) − log₂(1/p)) / log₂(1/p₀) = log₂(p/p₀) / log₂(1/p₀) clamp 到 [0,1]。
32
+ * 低 surprisal → 高 α;仅用于本步入边透明度,不参与边筛选。缺省 `target_prob` 时返回 1(兼容旧缓存)。
33
+ */
34
+ export function computeMutualInformationRatio(targetProb: number | undefined): number {
35
+ if (targetProb === undefined) return 1;
36
+ if (!Number.isFinite(targetProb) || targetProb <= 0) return 0;
37
+ return clamp01(
38
+ Math.log2(targetProb / ZERO_CONFIDENCE_PROBABILITY_BASELINE) / REFERENCE_MAX_SURPRISAL_BITS
39
+ );
40
+ }
41
+
42
+ /**
43
+ * 条件信息量比率 CI:surprisal/max = (−log₂ p) / log₂(1/p₀) clamp 到 [0,1],
44
+ * 与 {@link computeMutualInformationRatio} 对称(同 p 下 CI + MI = 1)。
45
+ * 缺省 `target_prob` 时返回 0;非法或 p≤0 时返回 1。
46
+ */
47
+ export function computeConditionalInformationRatio(targetProb: number | undefined): number {
48
+ if (targetProb === undefined) return 0;
49
+ if (!Number.isFinite(targetProb) || targetProb <= 0) return 1;
50
+ return clamp01(-Math.log2(targetProb) / REFERENCE_MAX_SURPRISAL_BITS);
51
+ }
data/demo/public/CN/政府工作报告极简版.json DELETED
The diff for this file is too large to render. See raw diff