dqy08 commited on
Commit
ca74a9e
·
1 Parent(s): 28147a1

反向传播动画改进

Browse files
Files changed (31) hide show
  1. backend/api/openai_completions.py +40 -5
  2. backend/api/utils.py +7 -0
  3. backend/core/completion_generator.py +33 -3
  4. client/src/assets/demos/causal_flow/CoT | 反向传播归因动画.json +0 -0
  5. client/src/assets/demos/causal_flow/CoT | 苏州所在省的省会城市里最高的山.json +0 -0
  6. client/src/assets/demos/causal_flow/order.json +18 -5
  7. client/src/causal_flow.html +7 -5
  8. client/src/chat.html +1 -1
  9. client/src/css/components/_query-history-dropdown.scss +4 -0
  10. client/src/css/pages/causal_flow.scss +8 -4
  11. client/src/features/causal_flow/bundledDemos.ts +13 -2
  12. client/src/features/causal_flow/genAttributeBundledDemoManifest.generated.ts +3 -2
  13. client/src/features/chat/chatPromptTemplateMode.ts +3 -0
  14. client/src/package.json +1 -0
  15. client/src/pages/causal_flow/index.ts +56 -20
  16. client/src/pages/chat/index.ts +87 -18
  17. client/src/scripts/genAttributeDemoManifestPlugin.js +65 -9
  18. client/src/shared/api/completionsClient.ts +16 -3
  19. client/src/shared/cross/maxNewTokensConfig.ts +100 -0
  20. client/src/shared/cross/queryHistory.ts +5 -2
  21. client/src/shared/cross/tokenDisplayUtils.ts +6 -2
  22. client/src/shared/lang/translations.ts +2 -0
  23. client/src/shared/prediction_attribution/causal_flow/genAttributeDagEdgeRenderStrength.ts +52 -0
  24. client/src/shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackLog.ts +103 -0
  25. client/src/shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackPacing.ts +145 -0
  26. client/src/shared/prediction_attribution/causal_flow/genAttributeDagRecursiveEdgeAnimation.ts +267 -377
  27. client/src/shared/prediction_attribution/causal_flow/genAttributeDagTextMeasure.ts +1 -0
  28. client/src/shared/prediction_attribution/causal_flow/genAttributeDagView.ts +129 -64
  29. client/src/shared/prediction_attribution/causal_flow/tokenGenAttributionRunner.ts +3 -2
  30. client/src/shared/vis/ToolTip.ts +2 -2
  31. client/src/tests/prediction_attribution/genAttributeDagPropagationPlayback.test.ts +271 -0
backend/api/openai_completions.py CHANGED
@@ -10,10 +10,13 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
10
  from backend.models.model_manager import inference_lock, get_instruct_model_display_name
11
  from backend.core.prediction_attributor import slot_for_prediction_attr_model
12
  from backend.platform.oom import exit_if_oom, is_oom_error
 
13
  from backend.core.completion_generator import (
 
14
  PromptTooLongError,
15
  apply_chat_template_for_completion,
16
  completion_cancel_requested,
 
17
  generate_completion_text,
18
  global_completion_stop_event,
19
  inference_shutdown_event,
@@ -84,6 +87,7 @@ def _completion_inference_after_lock(
84
  *,
85
  stream_delta: Optional[Callable[[str, bool], None]] = None,
86
  max_tokens: Optional[int] = None,
 
87
  ) -> CompletionRunResult:
88
  """
89
  在已持有推理锁的上下文中执行续写(旧版非流式路径的持锁体内逻辑)。
@@ -92,7 +96,12 @@ def _completion_inference_after_lock(
92
  from backend.platform.access_log import log_openai_completions_start
93
 
94
  log_openai_completions_start(request_id, lock_wait_time)
95
- return generate_completion_text(prompt, stream_delta=stream_delta, max_tokens=max_tokens)
 
 
 
 
 
96
 
97
 
98
  def _log_completion_finished(
@@ -136,6 +145,7 @@ def _generate_completion_events(
136
  request_id: int,
137
  *,
138
  max_tokens: Optional[int] = None,
 
139
  ):
140
  global_completion_stop_event.clear()
141
  q: queue.Queue = queue.Queue()
@@ -163,6 +173,7 @@ def _generate_completion_events(
163
  lock_wait_time,
164
  stream_delta=stream_delta,
165
  max_tokens=max_tokens,
 
166
  )
167
  finally:
168
  inference_lock.release()
@@ -246,8 +257,8 @@ def _generate_completion_events(
246
  return
247
  elif kind == "error":
248
  err = item[1]
249
- if isinstance(err, PromptTooLongError):
250
- _log_cmpl_issue(request_id, f"prompt too long: {err}")
251
  yield send_error_event(str(err), 400)
252
  elif isinstance(err, QueueTimeoutError):
253
  _log_cmpl_issue(request_id, f"排队超时: {err}")
@@ -276,9 +287,15 @@ def _completions_sse_response(
276
  request_id: int,
277
  *,
278
  max_tokens: Optional[int] = None,
 
279
  ):
280
  return SSEProgressReporter(
281
- lambda: _generate_completion_events(prompt, request_id, max_tokens=max_tokens)
 
 
 
 
 
282
  ).create_response()
283
 
284
 
@@ -390,7 +407,25 @@ def completions(completions_request):
390
  else:
391
  max_tokens = max_tokens_raw
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  client_ip = get_client_ip()
394
  request_id = _log_request(model, prompt, client_ip)
395
 
396
- return _completions_sse_response(prompt, request_id, max_tokens=max_tokens)
 
 
 
 
 
 
10
  from backend.models.model_manager import inference_lock, get_instruct_model_display_name
11
  from backend.core.prediction_attributor import slot_for_prediction_attr_model
12
  from backend.platform.oom import exit_if_oom, is_oom_error
13
+ from backend.api.utils import request_has_valid_admin
14
  from backend.core.completion_generator import (
15
+ ModelContextLimitUnknownError,
16
  PromptTooLongError,
17
  apply_chat_template_for_completion,
18
  completion_cancel_requested,
19
+ completion_max_token_length,
20
  generate_completion_text,
21
  global_completion_stop_event,
22
  inference_shutdown_event,
 
87
  *,
88
  stream_delta: Optional[Callable[[str, bool], None]] = None,
89
  max_tokens: Optional[int] = None,
90
+ bypass_site_context_limit: bool = False,
91
  ) -> CompletionRunResult:
92
  """
93
  在已持有推理锁的上下文中执行续写(旧版非流式路径的持锁体内逻辑)。
 
96
  from backend.platform.access_log import log_openai_completions_start
97
 
98
  log_openai_completions_start(request_id, lock_wait_time)
99
+ return generate_completion_text(
100
+ prompt,
101
+ stream_delta=stream_delta,
102
+ max_tokens=max_tokens,
103
+ bypass_site_context_limit=bypass_site_context_limit,
104
+ )
105
 
106
 
107
  def _log_completion_finished(
 
145
  request_id: int,
146
  *,
147
  max_tokens: Optional[int] = None,
148
+ bypass_site_context_limit: bool = False,
149
  ):
150
  global_completion_stop_event.clear()
151
  q: queue.Queue = queue.Queue()
 
173
  lock_wait_time,
174
  stream_delta=stream_delta,
175
  max_tokens=max_tokens,
176
+ bypass_site_context_limit=bypass_site_context_limit,
177
  )
178
  finally:
179
  inference_lock.release()
 
257
  return
258
  elif kind == "error":
259
  err = item[1]
260
+ if isinstance(err, (PromptTooLongError, ModelContextLimitUnknownError)):
261
+ _log_cmpl_issue(request_id, str(err))
262
  yield send_error_event(str(err), 400)
263
  elif isinstance(err, QueueTimeoutError):
264
  _log_cmpl_issue(request_id, f"排队超时: {err}")
 
287
  request_id: int,
288
  *,
289
  max_tokens: Optional[int] = None,
290
+ bypass_site_context_limit: bool = False,
291
  ):
292
  return SSEProgressReporter(
293
+ lambda: _generate_completion_events(
294
+ prompt,
295
+ request_id,
296
+ max_tokens=max_tokens,
297
+ bypass_site_context_limit=bypass_site_context_limit,
298
+ )
299
  ).create_response()
300
 
301
 
 
407
  else:
408
  max_tokens = max_tokens_raw
409
 
410
+ bypass_site = request_has_valid_admin() and max_tokens is not None
411
+ if (
412
+ not bypass_site
413
+ and max_tokens is not None
414
+ and max_tokens > completion_max_token_length
415
+ ):
416
+ return {
417
+ "success": False,
418
+ "message": (
419
+ f"max_tokens 不得超过续写上下文上限 {completion_max_token_length}"
420
+ ),
421
+ }, 400
422
+
423
  client_ip = get_client_ip()
424
  request_id = _log_request(model, prompt, client_ip)
425
 
426
+ return _completions_sse_response(
427
+ prompt,
428
+ request_id,
429
+ max_tokens=max_tokens,
430
+ bypass_site_context_limit=bypass_site,
431
+ )
backend/api/utils.py CHANGED
@@ -68,6 +68,13 @@ def get_admin_token() -> str:
68
  return os.environ.get('INFORADAR_ADMIN_TOKEN')
69
 
70
 
 
 
 
 
 
 
 
71
  def validate_admin_token(request_token: str) -> tuple[bool, str]:
72
  """
73
  验证管理员token是否有效
 
68
  return os.environ.get('INFORADAR_ADMIN_TOKEN')
69
 
70
 
71
+ def request_has_valid_admin() -> bool:
72
+ """当前 HTTP 请求是否携带有效的 X-Admin-Token。"""
73
+ token = request.headers.get('X-Admin-Token') or ''
74
+ is_valid, _ = validate_admin_token(token)
75
+ return is_valid
76
+
77
+
78
  def validate_admin_token(request_token: str) -> tuple[bool, str]:
79
  """
80
  验证管理员token是否有效
backend/core/completion_generator.py CHANGED
@@ -24,7 +24,22 @@ from .pred_topk_format import pred_topk_pairs_from_probs_1d
24
  from backend.platform.runtime_config import DEFAULT_TOPK
25
 
26
  # 续写路径:prompt + 续写合计不得超过该 token 数(与语义分析 runtime 无关)。
27
- completion_max_token_length = 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  # 特殊 token 亦视为分析/展示内容,故不跳过。
30
  _COMPLETION_DECODE_SKIP_SPECIAL = False
@@ -69,6 +84,10 @@ class PromptTooLongError(ValueError):
69
  """prompt 过长或占满上下文导致无法续写(``input_len >= ctx_limit`` 时由 ``core_generate_from_text`` 抛出)。"""
70
 
71
 
 
 
 
 
72
  def _completion_without_generate(
73
  prompt_tokens: int,
74
  ) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
@@ -392,6 +411,7 @@ def core_generate_from_text(
392
  *,
393
  stream_delta: Optional[Callable[[str, bool], None]] = None,
394
  max_tokens: Optional[int] = None,
 
395
  ) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
396
  """
397
  对一段已确定的模型输入字符串做自回归续写(默认贪心;函数内 ``_use_low_temp_sampling`` 可临时切到低温采样)。
@@ -403,19 +423,23 @@ def core_generate_from_text(
403
  Args:
404
  stream_delta: 可选;若提供则额外调用(如 SSE)。本地 verbose 打印由 ``_print_completion_stream_delta`` 单独控制,与是否传入 stream_delta 无关。
405
  max_tokens: 可选;正整数,限制本次最多生成多少个新 token(与 ``min(max_tokens, 上限 − prompt)`` 取小)。省略则用尽剩余上下文额度。
 
406
 
407
  Returns:
408
  (续写文本, finish_reason, prompt_tokens, completion_tokens, 续写段 bpe_strings, ttft_s)。
409
  ttft_s 为自 ``model.generate`` 起至首次产出续写片段的秒数;仅取消时为 ``None``。
410
  """
411
  tokenizer, model, device = ensure_instruct_slot_ready()
412
- ctx_limit = completion_max_token_length
413
 
414
  model.eval()
415
  enc = tokenizer(formatted_text, return_tensors="pt")
416
  input_ids = enc["input_ids"].to(device)
417
  input_len = input_ids.shape[1]
418
  n = int(input_len)
 
 
 
 
419
  if n >= ctx_limit:
420
  raise PromptTooLongError(
421
  "Prompt too long: "
@@ -549,6 +573,7 @@ def generate_completion_text(
549
  stream_delta: Optional[Callable[[str, bool], None]] = None,
550
  *,
551
  max_tokens: Optional[int] = None,
 
552
  ) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
553
  """
554
  ``prompt`` 须为已确定的完整模型输入(不再在服务端套用 chat template)。
@@ -556,4 +581,9 @@ def generate_completion_text(
556
  流式可传 stream_delta;中止由 ``completion_cancel_requested()`` 统一判断。
557
  ``max_tokens`` 为可选的正整数续写上限(与 API 约定一致)。
558
  """
559
- return core_generate_from_text(prompt, stream_delta=stream_delta, max_tokens=max_tokens)
 
 
 
 
 
 
24
  from backend.platform.runtime_config import DEFAULT_TOPK
25
 
26
  # 续写路径:prompt + 续写合计不得超过该 token 数(与语义分析 runtime 无关)。
27
+ completion_max_token_length = 300
28
+
29
+
30
+ def _model_context_token_limit(tokenizer, model) -> int:
31
+ """管理员续写路径:须能解析模型上下文,否则抛错(不回退站点 500)。"""
32
+ pe = getattr(getattr(model, "config", None), "max_position_embeddings", None)
33
+ if isinstance(pe, int) and pe > 0:
34
+ return pe
35
+ ml = getattr(tokenizer, "model_max_length", None)
36
+ if isinstance(ml, int) and 0 < ml < 1_000_000:
37
+ return ml
38
+ raise ModelContextLimitUnknownError(
39
+ "无法从模型 config.max_position_embeddings 或 tokenizer.model_max_length "
40
+ "确定上下文长度;管理员续写已拒绝。"
41
+ )
42
+
43
 
44
  # 特殊 token 亦视为分析/展示内容,故不跳过。
45
  _COMPLETION_DECODE_SKIP_SPECIAL = False
 
84
  """prompt 过长或占满上下文导致无法续写(``input_len >= ctx_limit`` 时由 ``core_generate_from_text`` 抛出)。"""
85
 
86
 
87
+ class ModelContextLimitUnknownError(ValueError):
88
+ """管理员 bypass 站点上限时无法解析模型上下文长度。"""
89
+
90
+
91
  def _completion_without_generate(
92
  prompt_tokens: int,
93
  ) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
 
411
  *,
412
  stream_delta: Optional[Callable[[str, bool], None]] = None,
413
  max_tokens: Optional[int] = None,
414
+ bypass_site_context_limit: bool = False,
415
  ) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
416
  """
417
  对一段已确定的模型输入字符串做自回归续写(默认贪心;函数内 ``_use_low_temp_sampling`` 可临时切到低温采样)。
 
423
  Args:
424
  stream_delta: 可选;若提供则额外调用(如 SSE)。本地 verbose 打印由 ``_print_completion_stream_delta`` 单独控制,与是否传入 stream_delta 无关。
425
  max_tokens: 可选;正整数,限制本次最多生成多少个新 token(与 ``min(max_tokens, 上限 − prompt)`` 取小)。省略则用尽剩余上下文额度。
426
+ bypass_site_context_limit: 为 True 时(管理员显式 max_tokens)不按站点上限封顶,``ctx_limit`` 为模型上下文上限;无法解析时抛 ``ModelContextLimitUnknownError``。
427
 
428
  Returns:
429
  (续写文本, finish_reason, prompt_tokens, completion_tokens, 续写段 bpe_strings, ttft_s)。
430
  ttft_s 为自 ``model.generate`` 起至首次产出续写片段的秒数;仅取消时为 ``None``。
431
  """
432
  tokenizer, model, device = ensure_instruct_slot_ready()
 
433
 
434
  model.eval()
435
  enc = tokenizer(formatted_text, return_tensors="pt")
436
  input_ids = enc["input_ids"].to(device)
437
  input_len = input_ids.shape[1]
438
  n = int(input_len)
439
+ if bypass_site_context_limit and max_tokens is not None:
440
+ ctx_limit = _model_context_token_limit(tokenizer, model)
441
+ else:
442
+ ctx_limit = completion_max_token_length
443
  if n >= ctx_limit:
444
  raise PromptTooLongError(
445
  "Prompt too long: "
 
573
  stream_delta: Optional[Callable[[str, bool], None]] = None,
574
  *,
575
  max_tokens: Optional[int] = None,
576
+ bypass_site_context_limit: bool = False,
577
  ) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
578
  """
579
  ``prompt`` 须为已确定的完整模型输入(不再在服务端套用 chat template)。
 
581
  流式可传 stream_delta;中止由 ``completion_cancel_requested()`` 统一判断。
582
  ``max_tokens`` 为可选的正整数续写上限(与 API 约定一致)。
583
  """
584
+ return core_generate_from_text(
585
+ prompt,
586
+ stream_delta=stream_delta,
587
+ max_tokens=max_tokens,
588
+ bypass_site_context_limit=bypass_site_context_limit,
589
+ )
client/src/assets/demos/causal_flow/CoT | 反向传播归因动画.json ADDED
The diff for this file is too large to render. See raw diff
 
client/src/assets/demos/causal_flow/CoT | 苏州所在省的省会城市里最高的山.json ADDED
The diff for this file is too large to render. See raw diff
 
client/src/assets/demos/causal_flow/order.json CHANGED
@@ -1,23 +1,36 @@
1
  [
2
  {
3
  "slug": "Write a sonnet about love",
4
- "label": "Poem | Write a sonnet about love"
 
5
  },
6
  {
7
  "slug": "写一首绝句,主题是春天",
8
- "label": "写诗 | 写一首绝句,主题是春天"
 
9
  },
10
  {
11
  "slug": "CoT | 苏州所在省的省会",
12
- "label": "CoT | 苏州所在省的省会"
 
 
 
 
 
 
 
 
 
 
13
  },
14
  {
15
  "slug": "CoT | 多“跳”推理",
16
- "label": "CoT | 多推理"
17
  },
18
  {
19
  "slug": "过拟合|李白 将进酒",
20
- "label": "过拟合|李白 将进酒"
 
21
  },
22
  {
23
  "slug": "CN->EN翻译",
 
1
  [
2
  {
3
  "slug": "Write a sonnet about love",
4
+ "label": "Poem | Write a sonnet about love",
5
+ "featured": "bold"
6
  },
7
  {
8
  "slug": "写一首绝句,主题是春天",
9
+ "label": "写诗 | 写一首绝句,主题是春天",
10
+ "featured": "bold"
11
  },
12
  {
13
  "slug": "CoT | 苏州所在省的省会",
14
+ "label": "CoT | 苏州所在省的省会",
15
+ "featured": "bold"
16
+ },
17
+ {
18
+ "slug": "CoT | 苏州所在省的省会城市里最高的山",
19
+ "label": "CoT | 苏州所在省的省会的最高的山"
20
+ },
21
+ {
22
+ "slug": "CoT | 反向传播归因动画",
23
+ "label": "CoT | 思维链的反向传播归因动画",
24
+ "featured": "bold"
25
  },
26
  {
27
  "slug": "CoT | 多“跳”推理",
28
+ "label": "CoT | 多跳推理中“跳”的具象化"
29
  },
30
  {
31
  "slug": "过拟合|李白 将进酒",
32
+ "label": "过拟合|李白 将进酒",
33
+ "featured": "bold"
34
  },
35
  {
36
  "slug": "CN->EN翻译",
client/src/causal_flow.html CHANGED
@@ -182,9 +182,9 @@
182
  </select>
183
  </span>
184
  <span class="semantic-submode-group">
185
- <label class="semantic-submode-label" for="gen_attr_max_tokens">Max tokens</label>
186
  <input type="number" id="gen_attr_max_tokens" class="gen-attr-max-tokens-input"
187
- value="100" min="1" max="500" step="1">
188
  </span>
189
  </div>
190
  <div class="button-group">
@@ -269,13 +269,15 @@
269
  </span>
270
  </div>
271
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
272
- <span class="semantic-submode-group">
273
  <label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Attribution top-p coverage</label>
274
  <input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
275
  value="0.7" min="0.05" max="1" step="0.05"
276
  title="Coverage is the cumulative mass share within each generation step's Top-N candidate pool (after sorting candidates into the pool and normalizing mass inside that pool). Higher values keep more incoming edges. The denominator is this pool only, not every token-attribution entry returned for the step."
277
  data-i18n="title">
278
  </span>
 
 
279
  <span class="semantic-submode-group semantic-submode-group--emphasis">
280
  <label class="semantic-submode-label">
281
  <input type="checkbox" id="gen_attr_dag_recursive_attribution"
@@ -284,7 +286,7 @@
284
  Propagated attribution mode
285
  </label>
286
  </span>
287
- <span class="semantic-submode-group" id="gen_attr_dag_recursive_edge_animation_group" hidden>
288
  <label class="semantic-submode-label">
289
  <input type="checkbox" id="gen_attr_dag_recursive_edge_animation" checked
290
  title="When checked, propagated incoming edges are shown batch-by-batch with the configured direction."
@@ -293,7 +295,7 @@
293
  </label>
294
  </span>
295
  <span class="semantic-submode-group" id="gen_attr_dag_recursive_edge_animation_direction_group" hidden>
296
- <label class="semantic-submode-label" for="gen_attr_dag_recursive_edge_animation_direction">Animation direction</label>
297
  <select id="gen_attr_dag_recursive_edge_animation_direction" class="semantic-submode-select"
298
  title="Choose one direction for propagated-edge batch animation."
299
  data-i18n="title">
 
182
  </select>
183
  </span>
184
  <span class="semantic-submode-group">
185
+ <label class="semantic-submode-label" for="gen_attr_max_tokens" data-i18n>Max new tokens</label>
186
  <input type="number" id="gen_attr_max_tokens" class="gen-attr-max-tokens-input"
187
+ value="200" min="1" max="300" step="1" required>
188
  </span>
189
  </div>
190
  <div class="button-group">
 
269
  </span>
270
  </div>
271
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
272
+ <span class="semantic-submode-group semantic-submode-group--emphasis">
273
  <label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Attribution top-p coverage</label>
274
  <input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
275
  value="0.7" min="0.05" max="1" step="0.05"
276
  title="Coverage is the cumulative mass share within each generation step's Top-N candidate pool (after sorting candidates into the pool and normalizing mass inside that pool). Higher values keep more incoming edges. The denominator is this pool only, not every token-attribution entry returned for the step."
277
  data-i18n="title">
278
  </span>
279
+ </div>
280
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
281
  <span class="semantic-submode-group semantic-submode-group--emphasis">
282
  <label class="semantic-submode-label">
283
  <input type="checkbox" id="gen_attr_dag_recursive_attribution"
 
286
  Propagated attribution mode
287
  </label>
288
  </span>
289
+ <span class="semantic-submode-group semantic-submode-group--emphasis" id="gen_attr_dag_recursive_edge_animation_group" hidden>
290
  <label class="semantic-submode-label">
291
  <input type="checkbox" id="gen_attr_dag_recursive_edge_animation" checked
292
  title="When checked, propagated incoming edges are shown batch-by-batch with the configured direction."
 
295
  </label>
296
  </span>
297
  <span class="semantic-submode-group" id="gen_attr_dag_recursive_edge_animation_direction_group" hidden>
298
+ <label class="semantic-submode-label" for="gen_attr_dag_recursive_edge_animation_direction">Direction</label>
299
  <select id="gen_attr_dag_recursive_edge_animation_direction" class="semantic-submode-select"
300
  title="Choose one direction for propagated-edge batch animation."
301
  data-i18n="title">
client/src/chat.html CHANGED
@@ -120,7 +120,7 @@
120
  <span class="semantic-submode-group">
121
  <label class="chat-max-new-tokens-label" for="chat_max_new_tokens">
122
  <span class="semantic-submode-label" data-i18n>Max new tokens:</span>
123
- <input type="text" id="chat_max_new_tokens" class="semantic-threshold-input chat-max-new-tokens-input" inputmode="numeric" autocomplete="off" />
124
  </label>
125
  </span>
126
  </div>
 
120
  <span class="semantic-submode-group">
121
  <label class="chat-max-new-tokens-label" for="chat_max_new_tokens">
122
  <span class="semantic-submode-label" data-i18n>Max new tokens:</span>
123
+ <input type="number" id="chat_max_new_tokens" class="semantic-threshold-input chat-max-new-tokens-input" min="1" max="300" step="1" value="200" required />
124
  </label>
125
  </span>
126
  </div>
client/src/css/components/_query-history-dropdown.scss CHANGED
@@ -38,6 +38,10 @@
38
  overflow: hidden;
39
  text-overflow: ellipsis;
40
  white-space: nowrap;
 
 
 
 
41
  }
42
  }
43
  }
 
38
  overflow: hidden;
39
  text-overflow: ellipsis;
40
  white-space: nowrap;
41
+
42
+ &--bold {
43
+ font-weight: 500;
44
+ }
45
  }
46
  }
47
  }
client/src/css/pages/causal_flow.scss CHANGED
@@ -7,7 +7,7 @@
7
  @use '../base/narrow-ios-form-font-tail' as ios-form-font;
8
 
9
  $gen-attr-option-row-gap: 12px;
10
- $gen-attr-cached-demos-panel-width: 18em;
11
 
12
  // 单行右对齐:Cached history 最右;Cached demo 靠其左侧
13
  .chat-cached-history-bar.chat-cached-history-bar--dual {
@@ -143,7 +143,7 @@ $gen-attr-cached-demos-panel-width: 18em;
143
  box-sizing: border-box;
144
  }
145
 
146
- // DAG Top‑K:贴在 #results 内侧右下(absolute);固定尺寸 HUD;Top‑K 区超出则在滚动层内滚动
147
  #results.gen-attr-results-surface.LMF > .tooltip.gen-attr-dag-topk-tooltip {
148
  box-sizing: border-box;
149
  // 与 #major_tooltip 一致:不参与 opacity 过渡,避免 reposition 后与底层叠影
@@ -164,8 +164,7 @@ $gen-attr-cached-demos-panel-width: 18em;
164
  flex: 1 1 auto;
165
  min-height: 0;
166
  margin-top: 6px;
167
- overflow-x: hidden;
168
- overflow-y: auto;
169
  }
170
  }
171
 
@@ -224,6 +223,11 @@ $gen-attr-cached-demos-panel-width: 18em;
224
  stroke-opacity: var(--gen-attr-dag-node-recursive-share, 1);
225
  }
226
 
 
 
 
 
 
227
  // 选中/悬停 = 焦点(追因起点,nodeShare=1);置于 recursive-chain 之后以覆盖链上节点的 stroke-opacity
228
  &--hover .gen-attr-dag-node-stroke,
229
  &--selected .gen-attr-dag-node-stroke {
 
7
  @use '../base/narrow-ios-form-font-tail' as ios-form-font;
8
 
9
  $gen-attr-option-row-gap: 12px;
10
+ $gen-attr-cached-demos-panel-width: 21em;
11
 
12
  // 单行右对齐:Cached history 最右;Cached demo 靠其左侧
13
  .chat-cached-history-bar.chat-cached-history-bar--dual {
 
143
  box-sizing: border-box;
144
  }
145
 
146
+ // DAG Top‑K:贴在 #results 内侧右下(absolute);固定尺寸 HUD;超出部分裁剪
147
  #results.gen-attr-results-surface.LMF > .tooltip.gen-attr-dag-topk-tooltip {
148
  box-sizing: border-box;
149
  // 与 #major_tooltip 一致:不参与 opacity 过渡,避免 reposition 后与底层叠影
 
164
  flex: 1 1 auto;
165
  min-height: 0;
166
  margin-top: 6px;
167
+ overflow: hidden;
 
168
  }
169
  }
170
 
 
223
  stroke-opacity: var(--gen-attr-dag-node-recursive-share, 1);
224
  }
225
 
226
+ // 反向传播动画:当前滑过节点仅改描边色相(与出边红一致),stroke-opacity 仍由 recursive-share 控制。
227
+ &--recursive-chain.gen-attr-dag-node--backward-slide .gen-attr-dag-node-stroke {
228
+ stroke: var(--dag-highlight-line-color-out);
229
+ }
230
+
231
  // 选中/悬停 = 焦点(追因起点,nodeShare=1);置于 recursive-chain 之后以覆盖链上节点的 stroke-opacity
232
  &--hover .gen-attr-dag-node-stroke,
233
  &--selected .gen-attr-dag-node-stroke {
client/src/features/causal_flow/bundledDemos.ts CHANGED
@@ -24,11 +24,22 @@ function isSafeDemoSlug(s: string): boolean {
24
  const payloadCache = new Map<string, GenAttrCachedRun>();
25
  const payloadInflight = new Map<string, Promise<GenAttrCachedRun | undefined>>();
26
 
27
- export type BundledDemoListEntry = { id: string; label: string };
28
 
29
  /** 构建期固定的 bundled demo 列表(与当前 JS 同版本)。 */
30
  export function getBundledGenAttributeDemoList(): readonly BundledDemoListEntry[] {
31
- return GEN_ATTRIBUTE_BUNDLED_DEMOS.map(({ slug, label }) => ({ id: slug, label }));
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
 
34
  /**
 
24
  const payloadCache = new Map<string, GenAttrCachedRun>();
25
  const payloadInflight = new Map<string, Promise<GenAttrCachedRun | undefined>>();
26
 
27
+ export type BundledDemoListEntry = { id: string; label: string; featuredStyle?: string };
28
 
29
  /** 构建期固定的 bundled demo 列表(与当前 JS 同版本)。 */
30
  export function getBundledGenAttributeDemoList(): readonly BundledDemoListEntry[] {
31
+ return GEN_ATTRIBUTE_BUNDLED_DEMOS.map(({ slug, label, featured }) => ({
32
+ id: slug,
33
+ label,
34
+ ...(featured ? { featuredStyle: featured } : {}),
35
+ }));
36
+ }
37
+
38
+ /** `?demo=` / 列表 id 为 slug;UI 展示用 order 中的 label,未知 slug 则回退 slug。 */
39
+ export function getBundledGenAttributeDemoLabel(slug: string): string {
40
+ const s = slug.trim();
41
+ const hit = GEN_ATTRIBUTE_BUNDLED_DEMOS.find((d) => d.slug === s);
42
+ return hit?.label ?? s;
43
  }
44
 
45
  /**
client/src/features/causal_flow/genAttributeBundledDemoManifest.generated.ts CHANGED
@@ -1,5 +1,6 @@
1
  /**
2
  * Generated by GenAttributeDemoManifestPlugin — do not edit.
3
  */
4
- export type GenAttributeBundledDemoManifestEntry = { readonly slug: string; readonly label: string };
5
- export const GEN_ATTRIBUTE_BUNDLED_DEMOS: readonly GenAttributeBundledDemoManifestEntry[] = [{"slug":"Write a sonnet about love","label":"Poem | Write a sonnet about love"},{"slug":"写一首绝句,主题是春天","label":"写诗 | 写一首绝句,主题是春天"},{"slug":"CoT | 苏州所在省的省会","label":"CoT | 苏州所在省的省会"},{"slug":"CoT | 多“跳”推理","label":"CoT | 多“跳”推理"},{"slug":"过拟合|李白 将进酒","label":"过拟合|李白 将进酒"},{"slug":"CN->EN翻译","label":"CN->EN | 翻译"}];
 
 
1
  /**
2
  * Generated by GenAttributeDemoManifestPlugin — do not edit.
3
  */
4
+ export type GenAttributeBundledDemoFeaturedStyle = 'bold';
5
+ export type GenAttributeBundledDemoManifestEntry = { readonly slug: string; readonly label: string; readonly featured?: GenAttributeBundledDemoFeaturedStyle };
6
+ export const GEN_ATTRIBUTE_BUNDLED_DEMOS: readonly GenAttributeBundledDemoManifestEntry[] = [{"slug":"Write a sonnet about love","label":"Poem | Write a sonnet about love","featured":"bold"},{"slug":"写一首绝句,主题是春天","label":"写诗 | 写一首绝句,主题是春天","featured":"bold"},{"slug":"CoT | 苏州所在省的省会","label":"CoT | 苏州所在省的省会","featured":"bold"},{"slug":"CoT | 苏州所在省的省会城市里最高的山","label":"CoT | 苏州所在省的省会的最高的山"},{"slug":"CoT | 反向传播归因动画","label":"CoT | 思维链的反向传播归因动画","featured":"bold"},{"slug":"CoT | 多“跳”推理","label":"CoT | 多跳推理中“跳”的具象化"},{"slug":"过拟合|李白 将进酒","label":"过拟合|李白 将进酒","featured":"bold"},{"slug":"CN->EN翻译","label":"CN->EN | 翻译"}];
client/src/features/chat/chatPromptTemplateMode.ts CHANGED
@@ -1,6 +1,9 @@
1
  /** Chat / Generate & Attribute 共用的「Raw prompt mode」开关 storage key */
2
  export const LS_SKIP_CHAT_TEMPLATE = 'chat_skip_chat_template';
3
 
 
 
 
4
  /** Enable thinking 开关(Chat / Causal Flow 各页独立 key,仅在 Chat template 模式下生效) */
5
  export const CHAT_ENABLE_THINKING_STORAGE_KEY = 'info_radar_chat_enable_thinking';
6
  export const GEN_ATTR_ENABLE_THINKING_STORAGE_KEY = 'info_radar_gen_attr_enable_thinking';
 
1
  /** Chat / Generate & Attribute 共用的「Raw prompt mode」开关 storage key */
2
  export const LS_SKIP_CHAT_TEMPLATE = 'chat_skip_chat_template';
3
 
4
+ /** Chat 页 Max new tokens(与 Causal Flow 的 gen_attr 键独立,不共享) */
5
+ export const CHAT_MAX_NEW_TOKENS_STORAGE_KEY = 'info_radar_chat_max_new_tokens';
6
+
7
  /** Enable thinking 开关(Chat / Causal Flow 各页独立 key,仅在 Chat template 模式下生效) */
8
  export const CHAT_ENABLE_THINKING_STORAGE_KEY = 'info_radar_chat_enable_thinking';
9
  export const GEN_ATTR_ENABLE_THINKING_STORAGE_KEY = 'info_radar_gen_attr_enable_thinking';
client/src/package.json CHANGED
@@ -8,6 +8,7 @@
8
  "test:charIndex": "npx tsx tests/utils/charIndexForByteLimit.test.ts",
9
  "test:findSplitPoint": "npx tsx tests/utils/findSplitPoint.test.ts",
10
  "test:splitChunks": "npx tsx tests/utils/splitTextToChunks.test.ts",
 
11
  "prebuild": "node scripts/updateIntroHTML.js",
12
  "prebuild:dev": "node scripts/updateIntroHTML.js",
13
  "wp": "npm run build:dev",
 
8
  "test:charIndex": "npx tsx tests/utils/charIndexForByteLimit.test.ts",
9
  "test:findSplitPoint": "npx tsx tests/utils/findSplitPoint.test.ts",
10
  "test:splitChunks": "npx tsx tests/utils/splitTextToChunks.test.ts",
11
+ "test:dagPropagationPlayback": "npx tsx tests/prediction_attribution/genAttributeDagPropagationPlayback.test.ts",
12
  "prebuild": "node scripts/updateIntroHTML.js",
13
  "prebuild:dev": "node scripts/updateIntroHTML.js",
14
  "wp": "npm run build:dev",
client/src/pages/causal_flow/index.ts CHANGED
@@ -4,7 +4,7 @@ import '../../css/pages/causal_flow.scss';
4
 
5
  import { initThemeManager } from '../../shared/ui/theme';
6
  import { initLanguageManager } from '../../shared/ui/language';
7
- import { initI18n, tr } from '../../shared/lang/i18n-lite';
8
  import { AdminManager } from '../../shared/cross/adminManager';
9
  import { SettingsMenuManager } from '../../shared/cross/settingsMenuManager';
10
  import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
@@ -37,10 +37,16 @@ import type { DagRecursiveEdgeReplayPacing } from '../../shared/prediction_attri
37
  import {
38
  createHydratedTokenGenHandle,
39
  startTokenGenAttribution,
40
- TOKEN_GEN_MAX_TOKENS_DEFAULT,
41
  type TokenGenAttributionHandle,
42
  type TokenGenStep,
43
  } from '../../shared/prediction_attribution/causal_flow/tokenGenAttributionRunner';
 
 
 
 
 
 
 
44
  import { fetchTokenize } from '../../shared/prediction_attribution/core/predictionAttributeClient';
45
  import { completionFinishReasonLabel, type CompletionFinishReason } from '../../shared/cross/generationEndReasonLabel';
46
  import {
@@ -72,6 +78,7 @@ import {
72
  } from '../../shared/cross/contentUrl';
73
  import {
74
  fetchBundledGenAttributeDemoBySlug,
 
75
  getBundledGenAttributeDemoList,
76
  isGenAttrRunPayloadValidForUi,
77
  } from '../../features/causal_flow/bundledDemos';
@@ -110,7 +117,6 @@ const showToast = createToast('#toast').show;
110
 
111
  const GEN_ATTR_MODEL_VARIANT_STORAGE_KEY = 'info_radar_gen_attr_model_variant';
112
  const GEN_ATTR_MAX_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_max_tokens';
113
- const GEN_ATTR_MAX_TOKENS_DEFAULT = TOKEN_GEN_MAX_TOKENS_DEFAULT;
114
  const GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY = 'info_radar_gen_attr_dag_measure_width';
115
  const GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_layout_mode';
116
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
@@ -198,8 +204,9 @@ function readStoredModelVariant(): PredictionAttributeModelVariant {
198
  }
199
 
200
  function readStoredMaxTokens(): number {
201
- return lsReadNumber(GEN_ATTR_MAX_TOKENS_STORAGE_KEY, GEN_ATTR_MAX_TOKENS_DEFAULT, {
202
- validate: (n) => n >= 1 && n <= 500,
 
203
  });
204
  }
205
 
@@ -532,7 +539,10 @@ function genAttrEffectiveExcludeGeneratedPatternsText(): string {
532
  return genAttrExcludeGeneratedPatternsTa?.value ?? '';
533
  }
534
 
535
- if (maxTokensInput) maxTokensInput.value = String(readStoredMaxTokens());
 
 
 
536
  const initialDagLayoutMode = readStoredDagLayoutMode();
537
  if (dagLayoutModeSelect) dagLayoutModeSelect.value = initialDagLayoutMode;
538
  applyDagLayoutModeUi();
@@ -748,12 +758,17 @@ modelVariantSelect?.addEventListener('change', () => {
748
  });
749
 
750
  maxTokensInput?.addEventListener('change', () => {
 
751
  lsSet(
752
  GEN_ATTR_MAX_TOKENS_STORAGE_KEY,
753
- maxTokensInput?.value ?? String(GEN_ATTR_MAX_TOKENS_DEFAULT),
754
  );
755
  syncSubmitButtonState();
756
  });
 
 
 
 
757
 
758
  // DAG 回放节奏(与上节「DAG 测量宽度」无关;宽度 listener 在后文)
759
  dagPlaybackStepMsInput?.addEventListener('change', () => {
@@ -1575,13 +1590,22 @@ function currentModelVariant(): PredictionAttributeModelVariant {
1575
  }
1576
 
1577
  function currentMaxTokens(): number {
1578
- const n = parseInt(
1579
- maxTokensInput?.value ?? String(GEN_ATTR_MAX_TOKENS_DEFAULT),
1580
- 10
1581
  );
1582
- return Number.isFinite(n) && n >= 1
1583
- ? Math.min(n, 500)
1584
- : GEN_ATTR_MAX_TOKENS_DEFAULT;
 
 
 
 
 
 
 
 
 
1585
  }
1586
 
1587
  function syncIdleModelMetric(): void {
@@ -1605,7 +1629,7 @@ let lastRunInputSnapshot: string | null = null;
1605
  function getInputSnapshotForRun(): string {
1606
  const runOpts = {
1607
  v: currentModelVariant(),
1608
- max: currentMaxTokens(),
1609
  tfOn: isGenAttrTeacherForcingUiOn(),
1610
  tfText: (teacherForcingTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
1611
  saOn: isStopAfterTeacherForcingOn(),
@@ -1640,7 +1664,10 @@ function isInputReadyForRun(): boolean {
1640
  const forcing = teacherForcingContinuationForRun();
1641
  if (prompt.length === 0 && forcing === undefined) return false;
1642
  if (prompt.length > 0 && isGenAttrTeacherForcingUiOn() && forcing === undefined) return false;
1643
- return true;
 
 
 
1644
  }
1645
 
1646
  function syncSubmitButtonState(): void {
@@ -1971,12 +1998,13 @@ const genAttrCachedHistoryBtn = document.getElementById('gen_attr_cached_history
1971
  const genAttrCachedDemosBtn = document.getElementById('gen_attr_cached_demos_btn');
1972
  const genAttrCachedDemosValueBtn = document.getElementById('gen_attr_cached_demos_value_btn');
1973
  const genAttrCachedDemosValueEl = document.getElementById('gen_attr_cached_demos_value');
1974
- let genAttrBundledDemoEntries: Array<{ id: string; label: string }> = [];
1975
 
1976
  function syncGenAttrCachedDemosValueDisplay(): void {
1977
  const slug = readDemoUrlParam();
1978
- if (genAttrCachedDemosValueEl) genAttrCachedDemosValueEl.textContent = slug ?? '';
1979
- if (genAttrCachedDemosValueBtn) genAttrCachedDemosValueBtn.title = slug ?? '';
 
1980
  }
1981
 
1982
  genAttrCachedDemosValueBtn?.addEventListener('click', (e) => {
@@ -2343,8 +2371,16 @@ function syncGenAttrExportDemoBtn(): void {
2343
  if (!exportDemoBtn) return;
2344
  exportDemoBtn.style.display = adminManager.isInAdminMode() ? '' : 'none';
2345
  }
2346
- syncGenAttrExportDemoBtn();
2347
- adminManager.onAdminModeChange(() => syncGenAttrExportDemoBtn());
 
 
 
 
 
 
 
 
2348
  exportDemoBtn?.addEventListener('click', () => {
2349
  void (async () => {
2350
  const h = runnerHandle;
 
4
 
5
  import { initThemeManager } from '../../shared/ui/theme';
6
  import { initLanguageManager } from '../../shared/ui/language';
7
+ import { initI18n, tr, trf } from '../../shared/lang/i18n-lite';
8
  import { AdminManager } from '../../shared/cross/adminManager';
9
  import { SettingsMenuManager } from '../../shared/cross/settingsMenuManager';
10
  import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
 
37
  import {
38
  createHydratedTokenGenHandle,
39
  startTokenGenAttribution,
 
40
  type TokenGenAttributionHandle,
41
  type TokenGenStep,
42
  } from '../../shared/prediction_attribution/causal_flow/tokenGenAttributionRunner';
43
+ import {
44
+ DEFAULT_MAX_NEW_TOKENS,
45
+ finalizeMaxNewTokensInput,
46
+ isMaxNewTokensRawValid,
47
+ parseMaxNewTokens,
48
+ syncMaxNewTokensInputSiteMax,
49
+ } from '../../shared/cross/maxNewTokensConfig';
50
  import { fetchTokenize } from '../../shared/prediction_attribution/core/predictionAttributeClient';
51
  import { completionFinishReasonLabel, type CompletionFinishReason } from '../../shared/cross/generationEndReasonLabel';
52
  import {
 
78
  } from '../../shared/cross/contentUrl';
79
  import {
80
  fetchBundledGenAttributeDemoBySlug,
81
+ getBundledGenAttributeDemoLabel,
82
  getBundledGenAttributeDemoList,
83
  isGenAttrRunPayloadValidForUi,
84
  } from '../../features/causal_flow/bundledDemos';
 
117
 
118
  const GEN_ATTR_MODEL_VARIANT_STORAGE_KEY = 'info_radar_gen_attr_model_variant';
119
  const GEN_ATTR_MAX_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_max_tokens';
 
120
  const GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY = 'info_radar_gen_attr_dag_measure_width';
121
  const GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_layout_mode';
122
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
 
204
  }
205
 
206
  function readStoredMaxTokens(): number {
207
+ const admin = adminManager.isInAdminMode();
208
+ return lsReadNumber(GEN_ATTR_MAX_TOKENS_STORAGE_KEY, DEFAULT_MAX_NEW_TOKENS, {
209
+ validate: (n) => isMaxNewTokensRawValid(String(n), admin),
210
  });
211
  }
212
 
 
539
  return genAttrExcludeGeneratedPatternsTa?.value ?? '';
540
  }
541
 
542
+ if (maxTokensInput) {
543
+ maxTokensInput.value = String(readStoredMaxTokens());
544
+ syncMaxNewTokensInputSiteMax(maxTokensInput, adminManager.isInAdminMode());
545
+ }
546
  const initialDagLayoutMode = readStoredDagLayoutMode();
547
  if (dagLayoutModeSelect) dagLayoutModeSelect.value = initialDagLayoutMode;
548
  applyDagLayoutModeUi();
 
758
  });
759
 
760
  maxTokensInput?.addEventListener('change', () => {
761
+ if (!normalizeGenAttrMaxTokensField()) return;
762
  lsSet(
763
  GEN_ATTR_MAX_TOKENS_STORAGE_KEY,
764
+ maxTokensInput?.value ?? String(DEFAULT_MAX_NEW_TOKENS),
765
  );
766
  syncSubmitButtonState();
767
  });
768
+ maxTokensInput?.addEventListener('input', () => syncSubmitButtonState());
769
+ maxTokensInput?.addEventListener('blur', () => {
770
+ normalizeGenAttrMaxTokensField();
771
+ });
772
 
773
  // DAG 回放节奏(与上节「DAG 测量宽度」无关;宽度 listener 在后文)
774
  dagPlaybackStepMsInput?.addEventListener('change', () => {
 
1590
  }
1591
 
1592
  function currentMaxTokens(): number {
1593
+ return parseMaxNewTokens(
1594
+ maxTokensInput?.value ?? String(DEFAULT_MAX_NEW_TOKENS),
1595
+ adminManager.isInAdminMode()
1596
  );
1597
+ }
1598
+
1599
+ function normalizeGenAttrMaxTokensField(): boolean {
1600
+ const ok = finalizeMaxNewTokensInput(
1601
+ maxTokensInput,
1602
+ adminManager.isInAdminMode(),
1603
+ (msg) => showAlertDialog(tr('LLM Causal Flow'), msg),
1604
+ tr,
1605
+ trf
1606
+ );
1607
+ syncSubmitButtonState();
1608
+ return ok;
1609
  }
1610
 
1611
  function syncIdleModelMetric(): void {
 
1629
  function getInputSnapshotForRun(): string {
1630
  const runOpts = {
1631
  v: currentModelVariant(),
1632
+ max: maxTokensInput?.value ?? String(DEFAULT_MAX_NEW_TOKENS),
1633
  tfOn: isGenAttrTeacherForcingUiOn(),
1634
  tfText: (teacherForcingTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
1635
  saOn: isStopAfterTeacherForcingOn(),
 
1664
  const forcing = teacherForcingContinuationForRun();
1665
  if (prompt.length === 0 && forcing === undefined) return false;
1666
  if (prompt.length > 0 && isGenAttrTeacherForcingUiOn() && forcing === undefined) return false;
1667
+ return isMaxNewTokensRawValid(
1668
+ maxTokensInput?.value ?? '',
1669
+ adminManager.isInAdminMode()
1670
+ );
1671
  }
1672
 
1673
  function syncSubmitButtonState(): void {
 
1998
  const genAttrCachedDemosBtn = document.getElementById('gen_attr_cached_demos_btn');
1999
  const genAttrCachedDemosValueBtn = document.getElementById('gen_attr_cached_demos_value_btn');
2000
  const genAttrCachedDemosValueEl = document.getElementById('gen_attr_cached_demos_value');
2001
+ let genAttrBundledDemoEntries: Array<{ id: string; label: string; featuredStyle?: string }> = [];
2002
 
2003
  function syncGenAttrCachedDemosValueDisplay(): void {
2004
  const slug = readDemoUrlParam();
2005
+ const display = slug ? getBundledGenAttributeDemoLabel(slug) : '';
2006
+ if (genAttrCachedDemosValueEl) genAttrCachedDemosValueEl.textContent = display;
2007
+ if (genAttrCachedDemosValueBtn) genAttrCachedDemosValueBtn.title = display;
2008
  }
2009
 
2010
  genAttrCachedDemosValueBtn?.addEventListener('click', (e) => {
 
2371
  if (!exportDemoBtn) return;
2372
  exportDemoBtn.style.display = adminManager.isInAdminMode() ? '' : 'none';
2373
  }
2374
+ function syncGenAttrAdminUi(): void {
2375
+ syncGenAttrExportDemoBtn();
2376
+ syncMaxNewTokensInputSiteMax(maxTokensInput, adminManager.isInAdminMode());
2377
+ if (maxTokensInput) {
2378
+ maxTokensInput.value = String(readStoredMaxTokens());
2379
+ }
2380
+ normalizeGenAttrMaxTokensField();
2381
+ }
2382
+ syncGenAttrAdminUi();
2383
+ adminManager.onAdminModeChange(() => syncGenAttrAdminUi());
2384
  exportDemoBtn?.addEventListener('click', () => {
2385
  void (async () => {
2386
  const h = runnerHandle;
client/src/pages/chat/index.ts CHANGED
@@ -4,7 +4,7 @@ import '../../css/pages/chat.scss';
4
 
5
  import { initThemeManager } from '../../shared/ui/theme';
6
  import { initLanguageManager } from '../../shared/ui/language';
7
- import { initI18n, tr } from '../../shared/lang/i18n-lite';
8
  import { AdminManager } from '../../shared/cross/adminManager';
9
  import { SettingsMenuManager } from '../../shared/cross/settingsMenuManager';
10
  import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from '../../shared/cross/cachedHistoryUi';
@@ -53,14 +53,24 @@ import {
53
  } from '../../shared/cross/contentUrl';
54
  import { CHAT_SURPRISAL_COLOR_MAP_MAX } from '../../shared/cross/SurprisalColorConfig';
55
  import { updateChatCompletionMetrics } from '../../shared/cross/textMetricsUpdater';
56
- import { lsReadBool, lsWriteBool } from '../../shared/storage/localStorageHelpers';
57
  import {
58
  CHAT_ENABLE_THINKING_STORAGE_KEY,
 
59
  LS_SKIP_CHAT_TEMPLATE,
60
  } from '../../features/chat/chatPromptTemplateMode';
61
  import { createToast } from '../../shared/ui/toast';
62
  import { initDensityAttributionSidebar } from '../../shared/prediction_attribution/density_sidebar/densityAttributionSidebar';
63
  import { syncDraftCommittedButtonPair } from '../../shared/cross/syncDraftCommittedButtonPair';
 
 
 
 
 
 
 
 
 
64
 
65
  // 与首页一致:默认隐藏 Ask 旁的小菊花,仅在请求进行中再显示
66
  d3.selectAll('.loadersmall').style('display', 'none');
@@ -305,7 +315,8 @@ function fingerprintsEqual(a: ChatCommittedFingerprint, b: ChatCommittedFingerpr
305
 
306
  function syncAskButtonState(): void {
307
  const fp = getCurrentFingerprint();
308
- const idleInputsReady = 'raw' in fp ? fp.raw.length > 0 : fp.user.length > 0;
 
309
  const hasUncommittedDraft =
310
  lastCommittedFingerprint === null ||
311
  !fingerprintsEqual(lastCommittedFingerprint, fp);
@@ -380,6 +391,12 @@ void new SettingsMenuManager(
380
  'common'
381
  );
382
 
 
 
 
 
 
 
383
  const flushStreamingPreview = (text: string, streamEnd: boolean): void => {
384
  if (
385
  !streamEnd &&
@@ -398,20 +415,64 @@ const getActivePromptValue = (): string => {
398
  return (chatUserTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
399
  };
400
 
401
- /** 空白 = 不限制(用尽剩余上下文);否则须为正整数。 */
402
- function parseOptionalMaxNewTokens(raw: string): number | undefined {
403
- const t = raw.trim();
404
- if (t === '') return undefined;
405
- if (!/^\d+$/.test(t)) {
406
- throw new Error(tr('Max new tokens must be a positive integer or empty'));
 
 
 
 
407
  }
408
- const n = parseInt(t, 10);
409
- if (n <= 0) {
410
- throw new Error(tr('Max new tokens must be a positive integer or empty'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  }
412
- return n;
413
  }
414
 
 
 
415
  const setAskLoading = (loading: boolean): void => {
416
  askInFlight = loading;
417
  loaderSmall.style('display', loading ? null : 'none');
@@ -433,9 +494,9 @@ const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
433
  if (askInFlight || prompt.length === 0) return;
434
  const forceRefresh = options?.forceRefresh === true;
435
 
436
- let maxTokensOpt: number | undefined;
437
  try {
438
- maxTokensOpt = parseOptionalMaxNewTokens(maxNewTokensInput?.value ?? '');
439
  } catch (e: unknown) {
440
  const msg = e instanceof Error ? e.message : String(e);
441
  showAlertDialog(tr('LLM Raw Chat'), translateApiErrorMessage(msg));
@@ -494,7 +555,7 @@ const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
494
  {
495
  model: completionModel,
496
  prompt: modelPrompt,
497
- ...(maxTokensOpt !== undefined ? { max_tokens: maxTokensOpt } : {})
498
  },
499
  {
500
  signal: askAbort.signal,
@@ -578,8 +639,16 @@ if (chatSystemPromptTextarea) {
578
  syncAskButtonState();
579
  });
580
  }
581
- maxNewTokensInput?.addEventListener('input', () => {
582
- syncAskButtonState();
 
 
 
 
 
 
 
 
583
  });
584
 
585
  async function restoreChatFromCachedPrompt(
 
4
 
5
  import { initThemeManager } from '../../shared/ui/theme';
6
  import { initLanguageManager } from '../../shared/ui/language';
7
+ import { initI18n, tr, trf } from '../../shared/lang/i18n-lite';
8
  import { AdminManager } from '../../shared/cross/adminManager';
9
  import { SettingsMenuManager } from '../../shared/cross/settingsMenuManager';
10
  import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from '../../shared/cross/cachedHistoryUi';
 
53
  } from '../../shared/cross/contentUrl';
54
  import { CHAT_SURPRISAL_COLOR_MAP_MAX } from '../../shared/cross/SurprisalColorConfig';
55
  import { updateChatCompletionMetrics } from '../../shared/cross/textMetricsUpdater';
56
+ import { lsReadBool, lsReadNumber, lsSet, lsWriteBool } from '../../shared/storage/localStorageHelpers';
57
  import {
58
  CHAT_ENABLE_THINKING_STORAGE_KEY,
59
+ CHAT_MAX_NEW_TOKENS_STORAGE_KEY,
60
  LS_SKIP_CHAT_TEMPLATE,
61
  } from '../../features/chat/chatPromptTemplateMode';
62
  import { createToast } from '../../shared/ui/toast';
63
  import { initDensityAttributionSidebar } from '../../shared/prediction_attribution/density_sidebar/densityAttributionSidebar';
64
  import { syncDraftCommittedButtonPair } from '../../shared/cross/syncDraftCommittedButtonPair';
65
+ import {
66
+ DEFAULT_MAX_NEW_TOKENS,
67
+ finalizeMaxNewTokensInput,
68
+ formatMaxNewTokensParseError,
69
+ MaxNewTokensParseError,
70
+ parseMaxNewTokens as parseMaxNewTokensShared,
71
+ isMaxNewTokensRawValid,
72
+ syncMaxNewTokensInputSiteMax,
73
+ } from '../../shared/cross/maxNewTokensConfig';
74
 
75
  // 与首页一致:默认隐藏 Ask 旁的小菊花,仅在请求进行中再显示
76
  d3.selectAll('.loadersmall').style('display', 'none');
 
315
 
316
  function syncAskButtonState(): void {
317
  const fp = getCurrentFingerprint();
318
+ const idleInputsReady =
319
+ ('raw' in fp ? fp.raw.length > 0 : fp.user.length > 0) && isMaxNewTokensInputValid();
320
  const hasUncommittedDraft =
321
  lastCommittedFingerprint === null ||
322
  !fingerprintsEqual(lastCommittedFingerprint, fp);
 
391
  'common'
392
  );
393
 
394
+ adminManager.onAdminModeChange(() => {
395
+ api.setAdminToken(adminManager.isInAdminMode() ? adminManager.getAdminToken() : null);
396
+ syncChatMaxNewTokensUi();
397
+ normalizeChatMaxNewTokensField();
398
+ });
399
+
400
  const flushStreamingPreview = (text: string, streamEnd: boolean): void => {
401
  if (
402
  !streamEnd &&
 
415
  return (chatUserTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
416
  };
417
 
418
+ function parseMaxNewTokens(raw: string): number {
419
+ try {
420
+ return parseMaxNewTokensShared(raw, adminManager.isInAdminMode());
421
+ } catch (e) {
422
+ if (e instanceof MaxNewTokensParseError) {
423
+ throw new Error(
424
+ formatMaxNewTokensParseError(e.code, tr, trf)
425
+ );
426
+ }
427
+ throw e;
428
  }
429
+ }
430
+
431
+ function normalizeChatMaxNewTokensField(): boolean {
432
+ const ok = finalizeMaxNewTokensInput(
433
+ maxNewTokensInput,
434
+ adminManager.isInAdminMode(),
435
+ (msg) => showAlertDialog(tr('LLM Raw Chat'), msg),
436
+ tr,
437
+ trf
438
+ );
439
+ syncAskButtonState();
440
+ return ok;
441
+ }
442
+
443
+ function isMaxNewTokensInputValid(): boolean {
444
+ return isMaxNewTokensRawValid(
445
+ maxNewTokensInput?.value ?? '',
446
+ adminManager.isInAdminMode()
447
+ );
448
+ }
449
+
450
+ function readChatStoredMaxTokens(): number {
451
+ return lsReadNumber(CHAT_MAX_NEW_TOKENS_STORAGE_KEY, DEFAULT_MAX_NEW_TOKENS, {
452
+ validate: (n) =>
453
+ isMaxNewTokensRawValid(String(n), adminManager.isInAdminMode()),
454
+ });
455
+ }
456
+
457
+ function persistChatMaxNewTokens(): void {
458
+ if (!maxNewTokensInput) return;
459
+ try {
460
+ const n = parseMaxNewTokens(maxNewTokensInput.value);
461
+ lsSet(CHAT_MAX_NEW_TOKENS_STORAGE_KEY, String(n));
462
+ } catch {
463
+ /* 非法值不写 storage */
464
+ }
465
+ }
466
+
467
+ function syncChatMaxNewTokensUi(): void {
468
+ if (maxNewTokensInput) {
469
+ maxNewTokensInput.value = String(readChatStoredMaxTokens());
470
  }
471
+ syncMaxNewTokensInputSiteMax(maxNewTokensInput, adminManager.isInAdminMode());
472
  }
473
 
474
+ syncChatMaxNewTokensUi();
475
+
476
  const setAskLoading = (loading: boolean): void => {
477
  askInFlight = loading;
478
  loaderSmall.style('display', loading ? null : 'none');
 
494
  if (askInFlight || prompt.length === 0) return;
495
  const forceRefresh = options?.forceRefresh === true;
496
 
497
+ let maxTokensOpt: number;
498
  try {
499
+ maxTokensOpt = parseMaxNewTokens(maxNewTokensInput?.value ?? '');
500
  } catch (e: unknown) {
501
  const msg = e instanceof Error ? e.message : String(e);
502
  showAlertDialog(tr('LLM Raw Chat'), translateApiErrorMessage(msg));
 
555
  {
556
  model: completionModel,
557
  prompt: modelPrompt,
558
+ max_tokens: maxTokensOpt
559
  },
560
  {
561
  signal: askAbort.signal,
 
639
  syncAskButtonState();
640
  });
641
  }
642
+ maxNewTokensInput?.addEventListener('input', () => syncAskButtonState());
643
+ maxNewTokensInput?.addEventListener('change', () => {
644
+ if (normalizeChatMaxNewTokensField()) {
645
+ persistChatMaxNewTokens();
646
+ }
647
+ });
648
+ maxNewTokensInput?.addEventListener('blur', () => {
649
+ if (normalizeChatMaxNewTokensField()) {
650
+ persistChatMaxNewTokens();
651
+ }
652
  });
653
 
654
  async function restoreChatFromCachedPrompt(
client/src/scripts/genAttributeDemoManifestPlugin.js CHANGED
@@ -1,7 +1,8 @@
1
  /**
2
  * 构建前扫描 `assets/demos/causal_flow/*.json`,写入 `features/causal_flow/genAttributeBundledDemoManifest.generated.ts`,供 bundle 内联 demo 列表。
3
- * 顺序与 UI 名:`order.json` 数组;项为 slug 字符串或 `{ slug, label? }`(无 label 则 UI 显示 slug)。
4
- * 未列入 order 的 demo 按 UTF-16 码元序追加到末尾label slug
 
5
  * order 中的 slug 必须对应目录内已有 demo JSON;重复 slug 亦会在构建时报错。
6
  */
7
  const path = require('path');
@@ -10,6 +11,7 @@ const fs = require('fs');
10
  const REL_DIR = 'assets/demos/causal_flow';
11
  const GENERATED_BASENAME = 'genAttributeBundledDemoManifest.generated.ts';
12
  const ORDER_FILENAME = 'order.json';
 
13
 
14
  function utf16Sort(slugs) {
15
  return [...slugs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
@@ -24,7 +26,7 @@ function discoverSlugs(srcDir) {
24
  .filter((s) => s.length > 0);
25
  }
26
 
27
- /** @returns {{ slug: string, label: string | null } | null} */
28
  function parseOrderEntry(entry, index) {
29
  const at = `${ORDER_FILENAME}[${index}]`;
30
  if (typeof entry === 'string') {
@@ -38,10 +40,19 @@ function parseOrderEntry(entry, index) {
38
  typeof entry.label === 'string' && entry.label.trim().length > 0
39
  ? entry.label.trim()
40
  : null;
41
- return { slug, label };
 
 
 
 
 
 
 
 
 
42
  }
43
  throw new Error(
44
- `${at}: expected a slug string or { "slug": "...", "label"?: "..." }`
45
  );
46
  }
47
 
@@ -64,6 +75,40 @@ function resolveLabel(slug, label) {
64
  return label ?? slug;
65
  }
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  function collectDemoEntries(srcDir) {
68
  const discovered = new Set(discoverSlugs(srcDir));
69
  const order = readOrderEntries(srcDir);
@@ -75,7 +120,7 @@ function collectDemoEntries(srcDir) {
75
  }
76
  const seen = new Set();
77
  const result = [];
78
- for (const { slug, label } of order) {
79
  if (seen.has(slug)) {
80
  throw new Error(`${ORDER_FILENAME}: duplicate slug ${JSON.stringify(slug)}`);
81
  }
@@ -85,7 +130,9 @@ function collectDemoEntries(srcDir) {
85
  );
86
  }
87
  seen.add(slug);
88
- result.push({ slug, label: resolveLabel(slug, label) });
 
 
89
  }
90
  for (const slug of utf16Sort([...discovered].filter((s) => !seen.has(s)))) {
91
  result.push({ slug, label: slug });
@@ -94,12 +141,14 @@ function collectDemoEntries(srcDir) {
94
  }
95
 
96
  function writeGeneratedModule(srcDir, outPath) {
 
97
  const entries = collectDemoEntries(srcDir);
98
  const content =
99
  '/**\n' +
100
  ' * Generated by GenAttributeDemoManifestPlugin — do not edit.\n' +
101
  ' */\n' +
102
- 'export type GenAttributeBundledDemoManifestEntry = { readonly slug: string; readonly label: string };\n' +
 
103
  `export const GEN_ATTRIBUTE_BUNDLED_DEMOS: readonly GenAttributeBundledDemoManifestEntry[] = ${JSON.stringify(entries)};\n`;
104
  if (fs.existsSync(outPath) && fs.readFileSync(outPath, 'utf8') === content) return;
105
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
@@ -126,4 +175,11 @@ class GenAttributeDemoManifestPlugin {
126
  }
127
  }
128
 
129
- module.exports = { GenAttributeDemoManifestPlugin };
 
 
 
 
 
 
 
 
1
  /**
2
  * 构建前扫描 `assets/demos/causal_flow/*.json`,写入 `features/causal_flow/genAttributeBundledDemoManifest.generated.ts`,供 bundle 内联 demo 列表。
3
+ * 顺序与 UI 名:`order.json` 数组;项为 slug 字符串或 `{ slug, label?, featured? }`(无 label 则 UI 显示 slug;`featured: "bold"` 等样式见 VALID_ORDER_FEATURED)。
4
+ * 目录内存在但尚未列入 order 的 demo 会在构建时自动追加到 order.json 末尾(`slug` 与 `label` 均为文件名 stem,便于人工改 label)
5
+ * 若 order.json 不存在则按 UTF-16 码元序生成完整列表。manifest 生成时若仍有遗漏则按 UTF-16 追加。
6
  * order 中的 slug 必须对应目录内已有 demo JSON;重复 slug 亦会在构建时报错。
7
  */
8
  const path = require('path');
 
11
  const REL_DIR = 'assets/demos/causal_flow';
12
  const GENERATED_BASENAME = 'genAttributeBundledDemoManifest.generated.ts';
13
  const ORDER_FILENAME = 'order.json';
14
+ const VALID_ORDER_FEATURED = new Set(['bold']);
15
 
16
  function utf16Sort(slugs) {
17
  return [...slugs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
 
26
  .filter((s) => s.length > 0);
27
  }
28
 
29
+ /** @returns {{ slug: string, label: string | null, featured?: string } | null} */
30
  function parseOrderEntry(entry, index) {
31
  const at = `${ORDER_FILENAME}[${index}]`;
32
  if (typeof entry === 'string') {
 
40
  typeof entry.label === 'string' && entry.label.trim().length > 0
41
  ? entry.label.trim()
42
  : null;
43
+ let featured;
44
+ if (entry.featured != null) {
45
+ if (typeof entry.featured !== 'string' || !VALID_ORDER_FEATURED.has(entry.featured)) {
46
+ throw new Error(
47
+ `${at}: unknown featured ${JSON.stringify(entry.featured)} (supported: ${[...VALID_ORDER_FEATURED].join(', ')})`,
48
+ );
49
+ }
50
+ featured = entry.featured;
51
+ }
52
+ return featured ? { slug, label, featured } : { slug, label };
53
  }
54
  throw new Error(
55
+ `${at}: expected a slug string or { "slug": "...", "label"?: "...", "featured"?: "bold" }`
56
  );
57
  }
58
 
 
75
  return label ?? slug;
76
  }
77
 
78
+ /** @param {{ slug: string, label: string | null, featured?: string }[]} entries */
79
+ function serializeOrderFile(entries) {
80
+ const body = entries.map(({ slug, label, featured }) => {
81
+ const row = { slug, label: resolveLabel(slug, label) };
82
+ if (featured) row.featured = featured;
83
+ return row;
84
+ });
85
+ return `${JSON.stringify(body, null, 2)}\n`;
86
+ }
87
+
88
+ /** 将新发现的 demo 追加到 order.json 末尾;无 order 文件时生成完整列表。 */
89
+ function syncOrderJson(srcDir) {
90
+ const discovered = discoverSlugs(srcDir);
91
+ if (discovered.length === 0) return;
92
+
93
+ const orderPath = path.join(srcDir, ORDER_FILENAME);
94
+ let orderEntries = readOrderEntries(srcDir);
95
+ const seen = new Set((orderEntries ?? []).map((e) => e.slug));
96
+ const missing = utf16Sort(discovered.filter((s) => !seen.has(s)));
97
+ if (missing.length === 0) return;
98
+
99
+ if (orderEntries == null) {
100
+ orderEntries = utf16Sort(discovered).map((slug) => ({ slug, label: slug }));
101
+ } else {
102
+ for (const slug of missing) {
103
+ orderEntries.push({ slug, label: slug });
104
+ }
105
+ }
106
+
107
+ const next = serializeOrderFile(orderEntries);
108
+ if (fs.existsSync(orderPath) && fs.readFileSync(orderPath, 'utf8') === next) return;
109
+ fs.writeFileSync(orderPath, next, 'utf8');
110
+ }
111
+
112
  function collectDemoEntries(srcDir) {
113
  const discovered = new Set(discoverSlugs(srcDir));
114
  const order = readOrderEntries(srcDir);
 
120
  }
121
  const seen = new Set();
122
  const result = [];
123
+ for (const { slug, label, featured } of order) {
124
  if (seen.has(slug)) {
125
  throw new Error(`${ORDER_FILENAME}: duplicate slug ${JSON.stringify(slug)}`);
126
  }
 
130
  );
131
  }
132
  seen.add(slug);
133
+ const row = { slug, label: resolveLabel(slug, label) };
134
+ if (featured) row.featured = featured;
135
+ result.push(row);
136
  }
137
  for (const slug of utf16Sort([...discovered].filter((s) => !seen.has(s)))) {
138
  result.push({ slug, label: slug });
 
141
  }
142
 
143
  function writeGeneratedModule(srcDir, outPath) {
144
+ syncOrderJson(srcDir);
145
  const entries = collectDemoEntries(srcDir);
146
  const content =
147
  '/**\n' +
148
  ' * Generated by GenAttributeDemoManifestPlugin — do not edit.\n' +
149
  ' */\n' +
150
+ 'export type GenAttributeBundledDemoFeaturedStyle = \'bold\';\n' +
151
+ 'export type GenAttributeBundledDemoManifestEntry = { readonly slug: string; readonly label: string; readonly featured?: GenAttributeBundledDemoFeaturedStyle };\n' +
152
  `export const GEN_ATTRIBUTE_BUNDLED_DEMOS: readonly GenAttributeBundledDemoManifestEntry[] = ${JSON.stringify(entries)};\n`;
153
  if (fs.existsSync(outPath) && fs.readFileSync(outPath, 'utf8') === content) return;
154
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
 
175
  }
176
  }
177
 
178
+ module.exports = {
179
+ GenAttributeDemoManifestPlugin,
180
+ syncOrderJson,
181
+ collectDemoEntries,
182
+ discoverSlugs,
183
+ ORDER_FILENAME,
184
+ REL_DIR,
185
+ };
client/src/shared/api/completionsClient.ts CHANGED
@@ -1,7 +1,20 @@
1
  import URLHandler from '../core/URLHandler';
2
  import * as completionResultCache from '../../features/chat/completionResultCache';
 
3
  import type { TokenWithOffset } from './generatedSchemas';
4
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  /** 与 server.yaml basePath `/api` + `/v1/completions` 一致 */
6
  const COMPLETIONS_PATH = '/api/v1/completions';
7
  const COMPLETIONS_PROMPT_PATH = '/api/v1/completions/prompt';
@@ -53,7 +66,7 @@ export function postCompletionsStop(): void {
53
  const url = URLHandler.basicURL() + COMPLETIONS_STOP_PATH;
54
  void fetch(url, {
55
  method: 'POST',
56
- headers: { 'Content-Type': 'application/json; charset=UTF-8' },
57
  body: '{}'
58
  }).catch(() => {
59
  /* 忽略:Stop 与 SSE 并行,失败时生成仍可能靠墙钟或其它路径结束 */
@@ -90,7 +103,7 @@ export async function postCompletionsPrompt(
90
  }
91
  const res = await fetch(url, {
92
  method: 'POST',
93
- headers: { 'Content-Type': 'application/json; charset=UTF-8' },
94
  body: JSON.stringify(payload),
95
  signal
96
  });
@@ -219,7 +232,7 @@ export async function postCompletions(
219
  function fetchRemote(): void {
220
  fetch(URLHandler.basicURL() + COMPLETIONS_PATH, {
221
  method: 'POST',
222
- headers: { 'Content-Type': 'application/json; charset=UTF-8' },
223
  body: JSON.stringify(body),
224
  signal
225
  })
 
1
  import URLHandler from '../core/URLHandler';
2
  import * as completionResultCache from '../../features/chat/completionResultCache';
3
+ import { AdminManager } from '../cross/adminManager';
4
  import type { TokenWithOffset } from './generatedSchemas';
5
 
6
+ function completionsRequestHeaders(): Record<string, string> {
7
+ const headers: Record<string, string> = {
8
+ 'Content-Type': 'application/json; charset=UTF-8',
9
+ };
10
+ const admin = AdminManager.getInstance();
11
+ const token = admin.isInAdminMode() ? admin.getAdminToken() : null;
12
+ if (token) {
13
+ headers['X-Admin-Token'] = token;
14
+ }
15
+ return headers;
16
+ }
17
+
18
  /** 与 server.yaml basePath `/api` + `/v1/completions` 一致 */
19
  const COMPLETIONS_PATH = '/api/v1/completions';
20
  const COMPLETIONS_PROMPT_PATH = '/api/v1/completions/prompt';
 
66
  const url = URLHandler.basicURL() + COMPLETIONS_STOP_PATH;
67
  void fetch(url, {
68
  method: 'POST',
69
+ headers: completionsRequestHeaders(),
70
  body: '{}'
71
  }).catch(() => {
72
  /* 忽略:Stop 与 SSE 并行,失败时生成仍可能靠墙钟或其它路径结束 */
 
103
  }
104
  const res = await fetch(url, {
105
  method: 'POST',
106
+ headers: completionsRequestHeaders(),
107
  body: JSON.stringify(payload),
108
  signal
109
  });
 
232
  function fetchRemote(): void {
233
  fetch(URLHandler.basicURL() + COMPLETIONS_PATH, {
234
  method: 'POST',
235
+ headers: completionsRequestHeaders(),
236
  body: JSON.stringify(body),
237
  signal
238
  })
client/src/shared/cross/maxNewTokensConfig.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 与 backend/core/completion_generator.py completion_max_token_length 一致。 */
2
+ export const SITE_MAX_NEW_TOKENS = 300;
3
+
4
+ export const DEFAULT_MAX_NEW_TOKENS = 200;
5
+
6
+ export type MaxNewTokensParseErrorCode = 'empty' | 'invalid' | 'exceeds_site';
7
+
8
+ export class MaxNewTokensParseError extends Error {
9
+ readonly code: MaxNewTokensParseErrorCode;
10
+
11
+ constructor(code: MaxNewTokensParseErrorCode) {
12
+ super(code);
13
+ this.code = code;
14
+ this.name = 'MaxNewTokensParseError';
15
+ }
16
+ }
17
+
18
+ export function parseMaxNewTokens(raw: string, admin: boolean): number {
19
+ const t = raw.trim();
20
+ if (t === '') {
21
+ throw new MaxNewTokensParseError('empty');
22
+ }
23
+ if (!/^\d+$/.test(t)) {
24
+ throw new MaxNewTokensParseError('invalid');
25
+ }
26
+ const n = parseInt(t, 10);
27
+ if (n <= 0) {
28
+ throw new MaxNewTokensParseError('invalid');
29
+ }
30
+ if (!admin && n > SITE_MAX_NEW_TOKENS) {
31
+ throw new MaxNewTokensParseError('exceeds_site');
32
+ }
33
+ return n;
34
+ }
35
+
36
+ export function isMaxNewTokensRawValid(raw: string, admin: boolean): boolean {
37
+ try {
38
+ parseMaxNewTokens(raw, admin);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export function ensureMaxNewTokensInputNotEmpty(input: HTMLInputElement | null): void {
46
+ if (input && input.value.trim() === '') {
47
+ input.value = String(DEFAULT_MAX_NEW_TOKENS);
48
+ }
49
+ }
50
+
51
+ /** 非管理员时 HTML max=站点上限;管理员去掉 max 以便填更大数字。 */
52
+ export function syncMaxNewTokensInputSiteMax(
53
+ input: HTMLInputElement | null,
54
+ admin: boolean
55
+ ): void {
56
+ if (!input) return;
57
+ if (admin) {
58
+ input.removeAttribute('max');
59
+ } else {
60
+ input.max = String(SITE_MAX_NEW_TOKENS);
61
+ }
62
+ }
63
+
64
+ type TrFn = (text: string) => string;
65
+ type TrfFn = (text: string, vars: Record<string, string | number>) => string;
66
+
67
+ export function formatMaxNewTokensParseError(
68
+ code: MaxNewTokensParseErrorCode,
69
+ tr: TrFn,
70
+ trf: TrfFn
71
+ ): string {
72
+ if (code === 'exceeds_site') {
73
+ return trf('Max new tokens must not exceed {limit}', { limit: SITE_MAX_NEW_TOKENS });
74
+ }
75
+ return tr('Max new tokens must be a positive integer');
76
+ }
77
+
78
+ /** blur/change 时校验;非法则 alert、修正输入框,返回是否有效。 */
79
+ export function finalizeMaxNewTokensInput(
80
+ input: HTMLInputElement | null,
81
+ admin: boolean,
82
+ onAlert: (message: string) => void,
83
+ tr: TrFn,
84
+ trf: TrfFn
85
+ ): boolean {
86
+ ensureMaxNewTokensInputNotEmpty(input);
87
+ if (!input) return false;
88
+ try {
89
+ parseMaxNewTokens(input.value, admin);
90
+ return true;
91
+ } catch (e) {
92
+ if (!(e instanceof MaxNewTokensParseError)) throw e;
93
+ onAlert(formatMaxNewTokensParseError(e.code, tr, trf));
94
+ input.value =
95
+ e.code === 'exceeds_site'
96
+ ? String(SITE_MAX_NEW_TOKENS)
97
+ : String(DEFAULT_MAX_NEW_TOKENS);
98
+ return false;
99
+ }
100
+ }
client/src/shared/cross/queryHistory.ts CHANGED
@@ -108,7 +108,7 @@ export interface InitQueryHistoryDropdownOptions {
108
  * 与 {@link getHistoryItems} 二选一:每项含稳定 id(如续写缓存的 contentKey)与展示 label。
109
  * 选中/删除/置顶回调均传递 id。
110
  */
111
- getHistoryEntries?: () => Array<{ id: string; label: string }>;
112
  /**
113
  * 每次渲染列表前调用(如打开下拉时从 IndexedDB 刷新内存镜像)。
114
  * 失败时仍会继续渲染,避免下拉空白。
@@ -210,7 +210,10 @@ export function initQueryHistoryDropdown(options: InitQueryHistoryDropdownOption
210
  const display = row.label;
211
  const li = document.createElement('li');
212
  const span = document.createElement('span');
213
- span.className = 'history-text';
 
 
 
214
  span.textContent = display;
215
  if (!pointerFineHover) span.title = display;
216
  let promoteBtn: HTMLButtonElement | null = null;
 
108
  * 与 {@link getHistoryItems} 二选一:每项含稳定 id(如续写缓存的 contentKey)与展示 label。
109
  * 选中/删除/置顶回调均传递 id。
110
  */
111
+ getHistoryEntries?: () => Array<{ id: string; label: string; featuredStyle?: string }>;
112
  /**
113
  * 每次渲染列表前调用(如打开下拉时从 IndexedDB 刷新内存镜像)。
114
  * 失败时仍会继续渲染,避免下拉空白。
 
210
  const display = row.label;
211
  const li = document.createElement('li');
212
  const span = document.createElement('span');
213
+ span.className =
214
+ row.featuredStyle === 'bold'
215
+ ? 'history-text history-text--bold'
216
+ : 'history-text';
217
  span.textContent = display;
218
  if (!pointerFineHover) span.title = display;
219
  let promoteBtn: HTMLButtonElement | null = null;
client/src/shared/cross/tokenDisplayUtils.ts CHANGED
@@ -50,6 +50,11 @@ export type VisualizeSpecialCharsOptions = {
50
  * 省略或 false:每个 ASCII 空格都变为 ·(与 Tooltip / 候选词等一致)。
51
  */
52
  spaceDotExceptBeforeAsciiLetterOrNumber?: boolean;
 
 
 
 
 
53
  };
54
 
55
  function visualizeSpecialCharsImpl(text: string, options?: VisualizeSpecialCharsOptions): string {
@@ -88,8 +93,7 @@ function visualizeSpecialCharsImpl(text: string, options?: VisualizeSpecialChars
88
  } else {
89
  const codePoint = char.codePointAt(0);
90
  if (codePoint !== undefined) {
91
- const hexCode = codePoint.toString(16).toLowerCase().padStart(4, '0');
92
- processed.push(`[${hexCode}]`);
93
  } else {
94
  processed.push(char);
95
  }
 
50
  * 省略或 false:每个 ASCII 空格都变为 ·(与 Tooltip / 候选词等一致)。
51
  */
52
  spaceDotExceptBeforeAsciiLetterOrNumber?: boolean;
53
+ /**
54
+ * 为 true(如 DAG 节点 SVG 标签):不可打印码点显示为 `[]` 而非 `[hex]`。
55
+ * Tooltip 等需辨认码点的场景勿开启。
56
+ */
57
+ omitHexInCodePointLabel?: boolean;
58
  };
59
 
60
  function visualizeSpecialCharsImpl(text: string, options?: VisualizeSpecialCharsOptions): string {
 
93
  } else {
94
  const codePoint = char.codePointAt(0);
95
  if (codePoint !== undefined) {
96
+ processed.push(options?.omitHexInCodePointLabel === true ? '[]' : `[${codePoint.toString(16).toLowerCase().padStart(4, '0')}]`);
 
97
  } else {
98
  processed.push(char);
99
  }
client/src/shared/lang/translations.ts CHANGED
@@ -37,6 +37,8 @@ export const translations: Translations = {
37
  'LLM Raw Chat - chat with explicit raw prompts': 'LLM Raw Chat 原始对话 - 用精确的 prompt 进行对话',
38
  'Context Attribution - attribute a predicted token to its context': 'Context Attribution 上下文归因 - 将预测 token 归因到上下文',
39
  'LLM Causal Flow - explore the context-attribution DAG': 'LLM Causal Flow 因果流 - 探索上下文归因的 DAG 关系图',
 
 
40
  // LLM Causal Flow(gen_attribute)页:placeholder / title 文案
41
  'When enabled, each line below is a regex with the global flag, matched only within the initial static prompt prefix (excluding generated continuation). If a token offset lies fully inside a match, its score is treated as 0.':
42
  '启用后仅在初始静态 prompt 前缀内按下列正则匹配(不含已生成 continuation);token 的 offset 完全落在某次匹配区间内则 score 视为 0。',
 
37
  'LLM Raw Chat - chat with explicit raw prompts': 'LLM Raw Chat 原始对话 - 用精确的 prompt 进行对话',
38
  'Context Attribution - attribute a predicted token to its context': 'Context Attribution 上下文归因 - 将预测 token 归因到上下文',
39
  'LLM Causal Flow - explore the context-attribution DAG': 'LLM Causal Flow 因果流 - 探索上下文归因的 DAG 关系图',
40
+ 'Max new tokens must not exceed {limit}': 'Max new tokens 不得超过 {limit}',
41
+ 'Max new tokens must be a positive integer': 'Max new tokens 须为正整数',
42
  // LLM Causal Flow(gen_attribute)页:placeholder / title 文案
43
  'When enabled, each line below is a regex with the global flag, matched only within the initial static prompt prefix (excluding generated continuation). If a token offset lies fully inside a match, its score is treated as 0.':
44
  '启用后仅在初始静态 prompt 前缀内按下列正则匹配(不含已生成 continuation);token 的 offset 完全落在某次匹配区间内则 score 视为 0。',
client/src/shared/prediction_attribution/causal_flow/genAttributeDagEdgeRenderStrength.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DAG_EDGE_RENDER_OPACITY_FLOOR } from './genAttributeDagEdgeDisplay';
2
+ import { maxHighlightEdgeShare } from './genAttributeDagRecursiveEdgeAnimation';
3
+
4
+ /**
5
+ * 池内 max 归一后的 `stroke-opacity`;最强边刻度为 {@link maxOpacity}(默认 1)。
6
+ * 按实际值计算后,最终不低于 {@link DAG_EDGE_RENDER_OPACITY_FLOOR},防止过淡不可见。
7
+ */
8
+ export function normalizeEdgeRenderOpacity(share: number, maxShare: number, maxOpacity = 1): number {
9
+ if (!Number.isFinite(share) || share <= 0) return 0;
10
+ const cap = Number.isFinite(maxOpacity) && maxOpacity > 0 ? maxOpacity : 1;
11
+ const scaled =
12
+ !Number.isFinite(maxShare) || maxShare <= 0
13
+ ? Math.min(cap, share)
14
+ : Math.min(cap, (share / maxShare) * cap);
15
+ if (scaled <= 0) return 0;
16
+ return Math.max(DAG_EDGE_RENDER_OPACITY_FLOOR, scaled);
17
+ }
18
+
19
+ /**
20
+ * 池内 max 归一后的 render 强度。
21
+ * - 默认:{@link sharesByKey} 全表 max;
22
+ * - {@link maxShareOverride}:蓝入边前沿分母;
23
+ * - {@link onlyKeys}:仅输出这些 key(红入边:集合内 max,忽略 maxShareOverride 外的键)。
24
+ */
25
+ export function buildMaxNormalizedRenderStrengthByKey(
26
+ sharesByKey: Map<string, number>,
27
+ maxOpacity = 1,
28
+ maxShareOverride?: number,
29
+ onlyKeys?: ReadonlySet<string>,
30
+ ): Map<string, number> {
31
+ let maxShare: number;
32
+ if (maxShareOverride != null && Number.isFinite(maxShareOverride) && maxShareOverride > 0) {
33
+ maxShare = maxShareOverride;
34
+ } else if (onlyKeys != null) {
35
+ maxShare = 0;
36
+ for (const key of onlyKeys) {
37
+ const share = sharesByKey.get(key);
38
+ if (share != null && share > maxShare) maxShare = share;
39
+ }
40
+ } else {
41
+ maxShare = maxHighlightEdgeShare(sharesByKey);
42
+ }
43
+ const byKey = new Map<string, number>();
44
+ const keys = onlyKeys ?? sharesByKey.keys();
45
+ for (const key of keys) {
46
+ const share = sharesByKey.get(key);
47
+ if (share != null) {
48
+ byKey.set(key, normalizeEdgeRenderOpacity(share, maxShare, maxOpacity));
49
+ }
50
+ }
51
+ return byKey;
52
+ }
client/src/shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackLog.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 浏览器控制台调试前缀;过滤:`dag-prop`。 */
2
+ const DAG_PROPAGATION_PLAYBACK_LOG = '[dag-prop]';
3
+
4
+ /** playback 日志最小列宽(不足则填充;超出不截断)。 */
5
+ export const DAG_PROP_LOG_W = {
6
+ event: 7,
7
+ frame: 6,
8
+ token: 10,
9
+ weight: 7,
10
+ dwell: 5,
11
+ focus: 10,
12
+ direction: 8,
13
+ int3: 3,
14
+ } as const;
15
+
16
+ /** localStorage:`localStorage.setItem('info_radar.dag_propagation_playback_log', '1')` */
17
+ export const DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY = 'info_radar.dag_propagation_playback_log';
18
+
19
+ export function isDagPropagationPlaybackLogEnabled(): boolean {
20
+ if (typeof globalThis === 'undefined') return false;
21
+ const g = globalThis as typeof globalThis & { __DAG_PROPAGATION_PLAYBACK_LOG__?: boolean };
22
+ if (g.__DAG_PROPAGATION_PLAYBACK_LOG__ === true) return true;
23
+ try {
24
+ return localStorage.getItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY) === '1';
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /** 控制台:`infoRadar.dagPropagationPlaybackLog(true)` */
31
+ export function setDagPropagationPlaybackLogEnabled(enabled: boolean): void {
32
+ if (typeof globalThis !== 'undefined') {
33
+ (globalThis as typeof globalThis & { __DAG_PROPAGATION_PLAYBACK_LOG__?: boolean }).__DAG_PROPAGATION_PLAYBACK_LOG__ =
34
+ enabled;
35
+ }
36
+ try {
37
+ if (enabled) localStorage.setItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY, '1');
38
+ else localStorage.removeItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY);
39
+ } catch {
40
+ /* private mode / disabled storage */
41
+ }
42
+ }
43
+
44
+ export function logDagPropagationPlaybackLine(line: string): void {
45
+ if (!isDagPropagationPlaybackLogEnabled()) return;
46
+ console.log(`${DAG_PROPAGATION_PLAYBACK_LOG} ${line}`);
47
+ }
48
+
49
+ if (typeof window !== 'undefined') {
50
+ const w = window as Window & { infoRadar?: Record<string, unknown> };
51
+ w.infoRadar = { ...w.infoRadar, dagPropagationPlaybackLog: setDagPropagationPlaybackLogEnabled };
52
+ }
53
+
54
+ export function dagPropLogFmtToken(label: string | null): string {
55
+ return label ?? '?';
56
+ }
57
+
58
+ export function dagPropLogFmtWeight(w: number | undefined): string {
59
+ return w != null ? w.toFixed(4) : '-';
60
+ }
61
+
62
+ export function dagPropLogPad(value: string, width: number): string {
63
+ return value.length >= width ? value : value.padEnd(width, ' ');
64
+ }
65
+
66
+ export function dagPropLogPadInt(value: number, width: number): string {
67
+ const s = String(value);
68
+ return s.length >= width ? s : s.padStart(width, ' ');
69
+ }
70
+
71
+ export function dagPropLogPadWeight(w: number | undefined): string {
72
+ return dagPropLogPad(dagPropLogFmtWeight(w), DAG_PROP_LOG_W.weight);
73
+ }
74
+
75
+ export type DagPropagationPlaybackLogNodeShare = { id: string; share: number };
76
+
77
+ export function nodesAtNodeShareTotalForPlaybackLog(
78
+ nodeShareById: ReadonlyMap<string, number>,
79
+ total: number,
80
+ options?: {
81
+ excludeFocusId?: string;
82
+ onlyNodeIds?: ReadonlySet<string>;
83
+ },
84
+ ): DagPropagationPlaybackLogNodeShare[] {
85
+ const out: DagPropagationPlaybackLogNodeShare[] = [];
86
+ for (const [nodeId, share] of nodeShareById) {
87
+ if (options?.excludeFocusId != null && nodeId === options.excludeFocusId) continue;
88
+ if (options?.onlyNodeIds != null && !options.onlyNodeIds.has(nodeId)) continue;
89
+ if (share === total) out.push({ id: nodeId, share });
90
+ }
91
+ out.sort((a, b) => a.id.localeCompare(b.id));
92
+ return out;
93
+ }
94
+
95
+ export function dagPropLogFmtNodeShareList(
96
+ entries: readonly DagPropagationPlaybackLogNodeShare[],
97
+ tokenLabelOf: (id: string) => string | null,
98
+ ): string {
99
+ if (entries.length === 0) return '-';
100
+ return entries
101
+ .map((e) => `${dagPropLogFmtToken(tokenLabelOf(e.id))}(${dagPropLogPadWeight(e.share)})`)
102
+ .join(', ');
103
+ }
client/src/shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackPacing.ts ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** forward prompt / backward 首帧固定停留(ms),不参与 Play speed 权重分配。 */
2
+ export const FORWARD_PROMPT_FRAME_DWELL_MS = 500;
3
+
4
+ /** 链序 running max 前瞻:lookahead = max(MIN, round(RATIO × 传播组数))。 */
5
+ export const DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO = 0.1;
6
+ export const DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN = 2;
7
+
8
+ export function propagationRunningMaxLookaheadForGroupCount(groupCount: number): number {
9
+ if (groupCount <= 0) return 0;
10
+ return Math.max(
11
+ DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN,
12
+ Math.round(DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO * groupCount),
13
+ );
14
+ }
15
+
16
+ /** 与 UI「DAG replay speed」一致。 */
17
+ export type DagReplayPacingMode = 'total' | 'step';
18
+
19
+ export type DagRecursiveEdgeReplayPacing = {
20
+ mode: DagReplayPacingMode;
21
+ /**
22
+ * `step`:单步名义间隔(ms)。
23
+ * 实际间隔 = `propagationWeight × stepMs`;对权重连续,权重为 0 时恰为 0ms。
24
+ */
25
+ stepMs: number;
26
+ /**
27
+ * `total`:整段动画名义总时长(s)。
28
+ * 权重步从 `totalS×1000 − {@link FORWARD_PROMPT_FRAME_DWELL_MS}` 按占比分配;固定帧另计。
29
+ */
30
+ totalS: number;
31
+ };
32
+
33
+ /**
34
+ * **当前帧**展示完成后的停留时长(ms),再切到下一批(不含 forward prompt / backward 首帧等固定帧)。
35
+ *
36
+ * **与权重的关系**:停留时间对 `propagationWeight` 连续;权重为 0 时恰为 0(`step` 下为 0ms,不设最小间隔)。
37
+ *
38
+ * - `step`:`propagationWeight × stepMs`
39
+ * - `total`:`(propagationWeight / weightTotal) × (totalS×1000 − FORWARD_PROMPT_FRAME_DWELL_MS)`;
40
+ * 假定 `weightTotal > 0`。
41
+ */
42
+ export function batchPlaybackDelayMs(
43
+ batch: { propagationWeight: number },
44
+ plan: { weightTotal: number },
45
+ pacing: DagRecursiveEdgeReplayPacing,
46
+ ): number {
47
+ const w = batch.propagationWeight;
48
+ if (pacing.mode === 'step') {
49
+ return Math.round(w * pacing.stepMs);
50
+ }
51
+ const totalWeight = plan.weightTotal;
52
+ const weightedBudgetMs = Math.max(0, pacing.totalS * 1000 - FORWARD_PROMPT_FRAME_DWELL_MS);
53
+ return Math.round((w / totalWeight) * weightedBudgetMs);
54
+ }
55
+
56
+ export type PropagationWeightGroup = { tgtIds: Iterable<string> };
57
+
58
+ export type PropagationGroupPrep = {
59
+ propagationWeight: number;
60
+ runningMaxNorm: number;
61
+ shareNorm?: number;
62
+ };
63
+
64
+ function summarizePropagationGroup(
65
+ group: PropagationWeightGroup,
66
+ nodeShareById: ReadonlyMap<string, number>,
67
+ focusId: string,
68
+ ): { hasFocus: boolean; nonFocusGroupShare: number } {
69
+ let hasFocus = false;
70
+ let nonFocusGroupShare = 0;
71
+ for (const tgtId of group.tgtIds) {
72
+ if (tgtId === focusId) {
73
+ hasFocus = true;
74
+ continue;
75
+ }
76
+ const share = nodeShareById.get(tgtId) ?? 0;
77
+ if (share > nonFocusGroupShare) nonFocusGroupShare = share;
78
+ }
79
+ return { hasFocus, nonFocusGroupShare };
80
+ }
81
+
82
+ function maxShareNormInRunningMaxLookaheadWindow(
83
+ shareNormPacing: readonly number[],
84
+ startIndex: number,
85
+ lookahead: number,
86
+ ): number {
87
+ let windowMax = 0;
88
+ const end = Math.min(shareNormPacing.length - 1, startIndex + lookahead);
89
+ for (let j = startIndex; j <= end; j++) {
90
+ windowMax = Math.max(windowMax, shareNormPacing[j] ?? 0);
91
+ }
92
+ return windowMax;
93
+ }
94
+
95
+ /**
96
+ * 文序准备:非焦点 `weightMax` → share_norm pacing → running max(含 lookahead)→ `propagationWeight`。
97
+ * 含焦点的组无 `shareNorm`(pacing 仍用非焦点 share,通常为 0)。
98
+ */
99
+ export function computePropagationGroupPacings(
100
+ groups: readonly PropagationWeightGroup[],
101
+ nodeShareById: ReadonlyMap<string, number>,
102
+ focusId: string,
103
+ ): {
104
+ groupPreps: PropagationGroupPrep[];
105
+ weightMax: number;
106
+ weightTotal: number;
107
+ runningMaxLookahead: number;
108
+ } {
109
+ const groupSummaries = groups.map((group) =>
110
+ summarizePropagationGroup(group, nodeShareById, focusId),
111
+ );
112
+
113
+ let weightMax = 0;
114
+ for (const { nonFocusGroupShare } of groupSummaries) {
115
+ if (nonFocusGroupShare > weightMax) weightMax = nonFocusGroupShare;
116
+ }
117
+
118
+ const invWeightMax = weightMax > 0 ? 1 / weightMax : 0;
119
+ const shareNormPacing = groupSummaries.map(
120
+ ({ nonFocusGroupShare }) => nonFocusGroupShare * invWeightMax,
121
+ );
122
+ const runningMaxLookahead = propagationRunningMaxLookaheadForGroupCount(groups.length);
123
+
124
+ const groupPreps: PropagationGroupPrep[] = [];
125
+ let runningMaxNorm = 0;
126
+ let weightTotal = 0;
127
+
128
+ for (let i = 0; i < groups.length; i++) {
129
+ const { hasFocus } = groupSummaries[i]!;
130
+ const shareNorm = shareNormPacing[i]!;
131
+ runningMaxNorm = Math.max(
132
+ runningMaxNorm,
133
+ maxShareNormInRunningMaxLookaheadWindow(shareNormPacing, i, runningMaxLookahead),
134
+ );
135
+ const propagationWeight = runningMaxNorm > 0 ? shareNorm / runningMaxNorm : 0;
136
+ weightTotal += propagationWeight;
137
+ groupPreps.push({
138
+ propagationWeight,
139
+ runningMaxNorm,
140
+ ...(hasFocus ? {} : { shareNorm: shareNorm }),
141
+ });
142
+ }
143
+
144
+ return { groupPreps, weightMax, weightTotal, runningMaxLookahead };
145
+ }
client/src/shared/prediction_attribution/causal_flow/genAttributeDagRecursiveEdgeAnimation.ts CHANGED
@@ -1,27 +1,44 @@
1
  import { DAG_MIN_ATTRIBUTION_SHARE } from './genAttributeDagEdgeDisplay';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  export type DagRecursiveEdgeAnimationDirection = 'backward' | 'forward';
4
 
5
  /** forward 专有第 0 帧:仅 prompt(稳态描边/归一),无传播链边。 */
6
  export const FORWARD_PROMPT_BATCH_INDEX = -1;
7
 
8
- /** forward prompt 第 0 帧固定停留(ms),不参与 Play speed 权重分配。 */
9
- const FORWARD_PROMPT_FRAME_DWELL_MS = 500;
10
-
11
- /** 链序 running max 前瞻:lookahead = max(MIN, round(RATIO × 传播层数))。 */
12
- //决定了动画前期的播放速度,值越小,前面部分播放速度越慢
13
- export const DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO = 0.1;
14
- export const DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN = 2;
15
-
16
- /** 与 {@link computePropagationLayerPacings} 一致:按层数算向后看的层数。 */
17
- export function propagationRunningMaxLookaheadForLayerCount(layerCount: number): number {
18
- if (layerCount <= 0) return 0;
19
- return Math.max(
20
- DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN,
21
- Math.round(DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO * layerCount),
22
- );
23
- }
24
-
25
  /** forward {@link FORWARD_PROMPT_BATCH_INDEX} 帧:仅展示 prompt,外观与稳态一致。 */
26
  export function isForwardPromptOnlyBatchIndex(
27
  direction: DagRecursiveEdgeAnimationDirection,
@@ -45,7 +62,7 @@ export type DagFocusAttributionComputeOptions = {
45
  };
46
 
47
  export type DagFocusAttributionGraphContext = {
48
- nodesSortedByStepDesc: readonly { id: string }[];
49
  incomingLinksByTarget: ReadonlyMap<string, readonly unknown[]>;
50
  };
51
 
@@ -63,124 +80,6 @@ type ComputeSteadyStateStayShareByIdFn = (
63
  /** 无 {@link CreateDagRecursiveEdgeAnimationControllerOptions.getReplayPacing} 时的兜底 step 间隔(ms)。 */
64
  const DAG_RECURSIVE_EDGE_BATCH_STEP_MS_FALLBACK = 500;
65
 
66
- /** 浏览器控制台调试前缀;过滤:`dag-prop`。 */
67
- const DAG_PROPAGATION_PLAYBACK_LOG = '[dag-prop]';
68
-
69
- /** playback 日志最小列宽(不足则填充;超出不截断)。 */
70
- const DAG_PROP_LOG_W = {
71
- event: 7,
72
- frame: 6,
73
- token: 10,
74
- weight: 7,
75
- dwell: 5,
76
- focus: 10,
77
- direction: 8,
78
- int3: 3,
79
- } as const;
80
-
81
- /** localStorage:`localStorage.setItem('info_radar.dag_propagation_playback_log', '1')` */
82
- export const DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY = 'info_radar.dag_propagation_playback_log';
83
-
84
- export function isDagPropagationPlaybackLogEnabled(): boolean {
85
- if (typeof globalThis === 'undefined') return false;
86
- const g = globalThis as typeof globalThis & { __DAG_PROPAGATION_PLAYBACK_LOG__?: boolean };
87
- if (g.__DAG_PROPAGATION_PLAYBACK_LOG__ === true) return true;
88
- try {
89
- return localStorage.getItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY) === '1';
90
- } catch {
91
- return false;
92
- }
93
- }
94
-
95
- /** 控制台:`infoRadar.dagPropagationPlaybackLog(true)` */
96
- export function setDagPropagationPlaybackLogEnabled(enabled: boolean): void {
97
- if (typeof globalThis !== 'undefined') {
98
- (globalThis as typeof globalThis & { __DAG_PROPAGATION_PLAYBACK_LOG__?: boolean }).__DAG_PROPAGATION_PLAYBACK_LOG__ =
99
- enabled;
100
- }
101
- try {
102
- if (enabled) localStorage.setItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY, '1');
103
- else localStorage.removeItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY);
104
- } catch {
105
- /* private mode / disabled storage */
106
- }
107
- }
108
-
109
- function playbackLogLine(line: string): void {
110
- if (!isDagPropagationPlaybackLogEnabled()) return;
111
- console.log(`${DAG_PROPAGATION_PLAYBACK_LOG} ${line}`);
112
- }
113
-
114
- if (typeof window !== 'undefined') {
115
- const w = window as Window & { infoRadar?: Record<string, unknown> };
116
- w.infoRadar = { ...w.infoRadar, dagPropagationPlaybackLog: setDagPropagationPlaybackLogEnabled };
117
- }
118
-
119
- function playbackFmtToken(label: string | null): string {
120
- return label ?? '?';
121
- }
122
-
123
- function playbackFmtWeight(w: number | undefined): string {
124
- return w != null ? w.toFixed(4) : '-';
125
- }
126
-
127
- function playbackPad(value: string, width: number): string {
128
- return value.length >= width ? value : value.padEnd(width, ' ');
129
- }
130
-
131
- function playbackPadInt(value: number, width: number): string {
132
- const s = String(value);
133
- return s.length >= width ? s : s.padStart(width, ' ');
134
- }
135
-
136
- function playbackPadWeight(w: number | undefined): string {
137
- return playbackPad(playbackFmtWeight(w), DAG_PROP_LOG_W.weight);
138
- }
139
-
140
- function playbackFmtNodeShareList(
141
- entries: readonly NodeShareEntry[],
142
- tokenLabelOf: (id: string) => string | null,
143
- ): string {
144
- if (entries.length === 0) return '-';
145
- return entries
146
- .map((e) => `${playbackFmtToken(tokenLabelOf(e.id))}(${playbackFmtWeight(e.share)})`)
147
- .join(', ');
148
- }
149
-
150
- /** 与 UI「DAG replay speed」一致。 */
151
- export type DagReplayPacingMode = 'total' | 'step';
152
-
153
- export type DagRecursiveEdgeReplayPacing = {
154
- mode: DagReplayPacingMode;
155
- /** `step`:单步名义间隔(ms),实际间隔 = `propagationWeight × stepMs`。 */
156
- stepMs: number;
157
- /** `total`:整段动画名义总时长(s),各步按权重占比分配。 */
158
- totalS: number;
159
- };
160
-
161
- /**
162
- * **当前帧**展示完成后的停留时长(ms),再切到下一批。
163
- * - `step`:`propagationWeight × stepMs`
164
- * - `total`:`(propagationWeight / weightTotal) × totalS`(权重全 0 时均分 `totalS`)
165
- */
166
- export function batchPlaybackDelayMs(
167
- batch: DagRecursiveIncomingEdgeBatch,
168
- plan: Pick<DagPropagationPlaybackPlan, 'batches' | 'weightTotal'>,
169
- pacing: DagRecursiveEdgeReplayPacing,
170
- ): number {
171
- const w = batch.propagationWeight;
172
- if (pacing.mode === 'step') {
173
- return Math.round(w * pacing.stepMs);
174
- }
175
- const totalWeight = plan.weightTotal;
176
- const totalMs = pacing.totalS * 1000;
177
- if (totalWeight <= 0) {
178
- const intervalCount = Math.max(0, plan.batches.length - 1);
179
- return intervalCount > 0 ? Math.round(totalMs / intervalCount) : 0;
180
- }
181
- return Math.round((w / totalWeight) * totalMs);
182
- }
183
-
184
  /**
185
  * 仅用于「Propagated attribution mode」焦点入边的分批显示状态。
186
  * 两方向均按 `start(tgt)` 分批;backward 从高 tgt 向低 tgt 播放(贴合向上追溯),forward 反向。
@@ -192,38 +91,45 @@ export function batchPlaybackDelayMs(
192
  * **forward**
193
  * - 第 0 帧 {@link FORWARD_PROMPT_BATCH_INDEX}:无传播链边,仅 prompt 节点按稳态 stay 描边/归一;固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms。
194
  * - 其后从最远 batch 递减;share 始终用全量焦点快照,动画只改「可见边集合」与归一分母(前沿内 max share)。
 
195
  * - 同一帧内,已可见边的相对强弱 = share 相对强弱;绝对 opacity 可因分母随新批次变大而变暗。
196
  * - 末帧 `batchIndex === 0` 时前沿 = 全链、分母 = 全链 max、可见性全开,与无动画稳定态数值一致(收敛)。
197
  *
198
  * **backward**
199
- * - 首帧 `batchIndex === 0`(焦点侧):固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms,与 forward prompt 首帧一致,不参与权重分配。
200
- * - 部分帧沿前沿重算 share(部分快照);节点 stay live partial,与 forward 仅门控可见性不同
201
- * - 节点描边 opacity分子 live partial stay,分母`max(stay)`(与蓝边用全max 一致,避免 prompt 等过早顶满
 
 
202
  *
203
  * **播放计划(见 {@link DagPropagationPlaybackPlan})**
204
- * - 一批 = 同一生成 offset 的入边;`layerOffset` + `tgtId` 标 token。
205
- * - 播放间隔权重(准备阶段一遍):按**文字顺序**对非焦点 `layerShare/weightMax` 做 running max 归一化;向后看数 = max({@link DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN}, round(比例×数));与播放方向无关。内含焦点则无 `shareNorm`。
206
- * - `backwardFrontierByBatchIndex` / `forwardFrontierByBatchIndex`:各 batchIndex 下可见边并集,render 热路径 O(1)。
207
  * - forward / backward 共用同一 plan;不用 backward 部分快照的 nodeShare 定权重。
 
 
 
 
208
  */
209
- /** 传播链动画的一批:同一生成 offset 的入边 + 元数据。 */
210
  export type DagRecursiveIncomingEdgeBatch = {
211
- /** 与 {@link buildPropagationPlaybackPlan} 分批键一致。 */
212
- layerOffset: number;
213
- /** 本代表 token(forward 高亮)。 */
214
  tgtId: string;
215
- /** 本传播链入边(`src->tgt`)。 */
216
  edgeKeys: string[];
217
  /**
218
  * 文字顺序局部归一化权重:share_norm ÷ runningMax(含向后 lookahead 窗口内的非焦点 share_norm)。
219
  */
220
  propagationWeight: number;
221
  /**
222
- * 非焦点 `layerShare / weightMax`(playback 日志 share_norm)。
223
- * 内含焦点时为 undefined。
224
  */
225
  shareNorm?: number;
226
- /** 准备阶段:截至本(含 lookahead 窗口)的链序 running max。 */
227
  runningMaxNorm: number;
228
  };
229
 
@@ -231,11 +137,11 @@ export type DagRecursiveIncomingEdgeBatch = {
231
  export type DagPropagationPlaybackPlan = {
232
  focusId: string;
233
  batches: DagRecursiveIncomingEdgeBatch[];
234
- /** 全链非焦点 Total share 上限(日志 / 对照;量纲同 Total share)。 */
235
  weightMax: number;
236
  /** Σ `batches[].propagationWeight`;total 模式分母。 */
237
  weightTotal: number;
238
- /** 本计划 running max 前瞻数(max(MIN, round(比例×数)))。 */
239
  runningMaxLookahead: number;
240
  /** backward:`batchIndex = i` 时可见边 = `batches[0..i]` 并集。 */
241
  backwardFrontierByBatchIndex: ReadonlyArray<ReadonlySet<string>>;
@@ -258,6 +164,22 @@ export function tgtIdFromEdgeKey(edgeKey: string): string | null {
258
 
259
  const EMPTY_EDGE_KEY_SET: ReadonlySet<string> = new Set();
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  /** 当前 batchIndex、方向下已启用的传播链入边(计划内预计算)。 */
262
  function frontierEdgeKeysAtBatch(
263
  plan: DagPropagationPlaybackPlan,
@@ -272,7 +194,7 @@ function frontierEdgeKeysAtBatch(
272
  return table[batchIndex] ?? EMPTY_EDGE_KEY_SET;
273
  }
274
 
275
- function maxShareInEdgeKeySet(
276
  incomingEdgeShareByKey: Map<string, number>,
277
  edgeKeys: ReadonlySet<string>,
278
  ): number {
@@ -330,7 +252,7 @@ export function isRecursiveEdgeAnimationFrontierPartial(
330
  if (lastBatch <= 0) return false;
331
  return animation.batchIndex < lastBatch;
332
  }
333
- // forward:末帧 batchIndex===0 为稳态;含 prompt 0 帧(-1)与其余部
334
  return animation.batchIndex !== 0;
335
  }
336
 
@@ -345,27 +267,6 @@ function isBackwardRecursiveEdgeAnimationInProgress(
345
  );
346
  }
347
 
348
- type NodeShareEntry = { id: string; share: number };
349
-
350
- function nodesAtNodeShareTotal(
351
- nodeShareById: ReadonlyMap<string, number>,
352
- total: number,
353
- options?: {
354
- excludeFocusId?: string;
355
- /** 若设,仅保留该集合内的节点。 */
356
- onlyNodeIds?: ReadonlySet<string>;
357
- },
358
- ): NodeShareEntry[] {
359
- const out: NodeShareEntry[] = [];
360
- for (const [nodeId, share] of nodeShareById) {
361
- if (options?.excludeFocusId != null && nodeId === options.excludeFocusId) continue;
362
- if (options?.onlyNodeIds != null && !options.onlyNodeIds.has(nodeId)) continue;
363
- if (share === total) out.push({ id: nodeId, share });
364
- }
365
- out.sort((a, b) => a.id.localeCompare(b.id));
366
- return out;
367
- }
368
-
369
  function tgtIdsInBatch(batch: DagRecursiveIncomingEdgeBatch): Set<string> {
370
  const ids = new Set<string>();
371
  for (const edgeKey of batch.edgeKeys) {
@@ -375,106 +276,14 @@ function tgtIdsInBatch(batch: DagRecursiveIncomingEdgeBatch): Set<string> {
375
  return ids;
376
  }
377
 
378
- type PropagationWeightLayer = { tgtIds: Iterable<string> };
379
-
380
- /** 文序单层:pacing 权重 + 日志字段(与 {@link computePropagationLayerPacings} 一致)。 */
381
- type PropagationLayerPrep = {
382
- propagationWeight: number;
383
- runningMaxNorm: number;
384
- shareNorm?: number;
385
- };
386
-
387
- function summarizePropagationLayer(
388
- layer: PropagationWeightLayer,
389
- nodeShareById: ReadonlyMap<string, number>,
390
- focusId: string,
391
- ): { hasFocus: boolean; nonFocusLayerShare: number } {
392
- let hasFocus = false;
393
- let nonFocusLayerShare = 0;
394
- for (const tgtId of layer.tgtIds) {
395
- if (tgtId === focusId) {
396
- hasFocus = true;
397
- continue;
398
- }
399
- const share = nodeShareById.get(tgtId) ?? 0;
400
- if (share > nonFocusLayerShare) nonFocusLayerShare = share;
401
- }
402
- return { hasFocus, nonFocusLayerShare };
403
- }
404
-
405
- function maxShareNormInRunningMaxLookaheadWindow(
406
- shareNormPacing: readonly number[],
407
- startIndex: number,
408
- lookahead: number,
409
- ): number {
410
- let windowMax = 0;
411
- const end = Math.min(shareNormPacing.length - 1, startIndex + lookahead);
412
- for (let j = startIndex; j <= end; j++) {
413
- windowMax = Math.max(windowMax, shareNormPacing[j] ?? 0);
414
- }
415
- return windowMax;
416
- }
417
-
418
- /**
419
- * 文序准备:非焦点 `weightMax` → share_norm pacing → running max(含 lookahead)→ `propagationWeight`。
420
- * 含焦点的层无 `shareNorm`(pacing 仍用非焦点 share,通常为 0)。
421
- */
422
- function computePropagationLayerPacings(
423
- layers: readonly PropagationWeightLayer[],
424
- nodeShareById: ReadonlyMap<string, number>,
425
- focusId: string,
426
- ): {
427
- layerPreps: PropagationLayerPrep[];
428
- weightMax: number;
429
- weightTotal: number;
430
- runningMaxLookahead: number;
431
- } {
432
- const layerSummaries = layers.map((layer) =>
433
- summarizePropagationLayer(layer, nodeShareById, focusId),
434
- );
435
-
436
- let weightMax = 0;
437
- for (const { nonFocusLayerShare } of layerSummaries) {
438
- if (nonFocusLayerShare > weightMax) weightMax = nonFocusLayerShare;
439
- }
440
-
441
- const invWeightMax = weightMax > 0 ? 1 / weightMax : 0;
442
- const shareNormPacing = layerSummaries.map(
443
- ({ nonFocusLayerShare }) => nonFocusLayerShare * invWeightMax,
444
- );
445
- const runningMaxLookahead = propagationRunningMaxLookaheadForLayerCount(layers.length);
446
-
447
- const layerPreps: PropagationLayerPrep[] = [];
448
- let runningMaxNorm = 0;
449
- let weightTotal = 0;
450
-
451
- for (let i = 0; i < layers.length; i++) {
452
- const { hasFocus } = layerSummaries[i]!;
453
- const shareNorm = shareNormPacing[i]!;
454
- runningMaxNorm = Math.max(
455
- runningMaxNorm,
456
- maxShareNormInRunningMaxLookaheadWindow(shareNormPacing, i, runningMaxLookahead),
457
- );
458
- const propagationWeight = runningMaxNorm > 0 ? shareNorm / runningMaxNorm : 0;
459
- weightTotal += propagationWeight;
460
- layerPreps.push({
461
- propagationWeight,
462
- runningMaxNorm,
463
- ...(hasFocus ? {} : { shareNorm: shareNorm }),
464
- });
465
- }
466
-
467
- return { layerPreps, weightMax, weightTotal, runningMaxLookahead };
468
- }
469
-
470
  function batchesInTextOrder(
471
  batches: readonly DagRecursiveIncomingEdgeBatch[],
472
  ): DagRecursiveIncomingEdgeBatch[] {
473
- return [...batches].sort((a, b) => a.layerOffset - b.layerOffset);
474
  }
475
 
476
- /** 内代表 tgt;并列时取 id 字典序最小。 */
477
- function primaryTgtIdForLayer(
478
  tgtIds: Iterable<string>,
479
  nodeShareById: ReadonlyMap<string, number>,
480
  ): string {
@@ -490,17 +299,17 @@ function primaryTgtIdForLayer(
490
  return bestId;
491
  }
492
 
493
- function incomingEdgeBatchFromLayer(
494
- layerOffset: number,
495
- layer: { edgeKeys: string[]; tgtIds: Set<string> },
496
- prep: PropagationLayerPrep,
497
  nodeShareById: ReadonlyMap<string, number>,
498
  ): DagRecursiveIncomingEdgeBatch {
499
- layer.edgeKeys.sort();
500
  return {
501
- layerOffset,
502
- tgtId: primaryTgtIdForLayer(layer.tgtIds, nodeShareById),
503
- edgeKeys: layer.edgeKeys,
504
  propagationWeight: prep.propagationWeight,
505
  runningMaxNorm: prep.runningMaxNorm,
506
  ...(prep.shareNorm != null ? { shareNorm: prep.shareNorm } : {}),
@@ -511,19 +320,17 @@ function buildFrontierEdgeKeysByBatchIndex(
511
  batches: readonly DagRecursiveIncomingEdgeBatch[],
512
  ): Pick<DagPropagationPlaybackPlan, 'backwardFrontierByBatchIndex' | 'forwardFrontierByBatchIndex'> {
513
  const n = batches.length;
514
- const backward: Set<string>[] = Array.from({ length: n }, () => new Set<string>());
515
- const forward: Set<string>[] = Array.from({ length: n }, () => new Set<string>());
516
 
517
  for (let i = 0; i < n; i++) {
518
- if (i > 0) {
519
- for (const key of backward[i - 1]!) backward[i]!.add(key);
520
- }
521
  for (const key of batches[i]!.edgeKeys) backward[i]!.add(key);
522
  }
523
  for (let i = n - 1; i >= 0; i--) {
524
- if (i < n - 1) {
525
- for (const key of forward[i + 1]!) forward[i]!.add(key);
526
- }
527
  for (const key of batches[i]!.edgeKeys) forward[i]!.add(key);
528
  }
529
 
@@ -532,7 +339,7 @@ function buildFrontierEdgeKeysByBatchIndex(
532
 
533
  /**
534
  * 传播链播放计划:入边按 `start(tgt)` 分批(offset 降序),并预计算双向前沿。
535
- * backward 从 index 0 递增,forward 从末批递减。
536
  */
537
  export function buildPropagationPlaybackPlan(
538
  incomingEdgeShareByKey: Map<string, number>,
@@ -547,25 +354,26 @@ export function buildPropagationPlaybackPlan(
547
  const tgtId = tgtIdFromEdgeKey(edgeKey);
548
  if (tgtId == null) continue;
549
  const offset = offsetOf(tgtId);
550
- let layer = byOffset.get(offset);
551
- if (layer == null) {
552
- layer = { edgeKeys: [], tgtIds: new Set() };
553
- byOffset.set(offset, layer);
554
  }
555
- layer.edgeKeys.push(edgeKey);
556
- layer.tgtIds.add(tgtId);
557
  }
558
 
559
  const sortedOffsetsAsc = [...byOffset.keys()].sort((a, b) => a - b);
560
  const sortedOffsetsDesc = [...sortedOffsetsAsc].reverse();
561
- const { layerPreps, weightMax, weightTotal, runningMaxLookahead } = computePropagationLayerPacings(
562
- sortedOffsetsAsc.map((layerOffset) => byOffset.get(layerOffset)!),
563
  nodeShareById,
564
  focusId,
565
  );
566
- const batches: DagRecursiveIncomingEdgeBatch[] = sortedOffsetsDesc.map((layerOffset, j) => {
567
- const prep = layerPreps[sortedOffsetsAsc.length - 1 - j]!;
568
- return incomingEdgeBatchFromLayer(layerOffset, byOffset.get(layerOffset)!, prep, nodeShareById);
 
569
  });
570
 
571
  return {
@@ -579,8 +387,8 @@ export function buildPropagationPlaybackPlan(
579
  }
580
 
581
  /**
582
- * 动画前沿处的归因快照:backward 部分态沿前沿边集追溯;
583
- * forward 用全量(边可见性由 {@link frontierEdgeKeysAtBatch} 预计算前沿单独)。
584
  */
585
  function resolveFocusAttributionAtFrontier(
586
  focusId: string,
@@ -617,7 +425,38 @@ function resolveFocusAttributionAtFrontier(
617
  return partial ?? fullState;
618
  }
619
 
620
- /** 传播模式描边:backward 动画进行中用部分快照的有效 stay,否则稳定态 stay。 */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  function resolveEffectiveStayShareByIdForStroke(
622
  focusState: DagFocusAttributionState,
623
  focusId: string,
@@ -629,6 +468,7 @@ function resolveEffectiveStayShareByIdForStroke(
629
  if (!isBackwardRecursiveEdgeAnimationInProgress(animation, focusId)) {
630
  return computeSteadyStateStayShareById(focusState.nodeShareById, focusId);
631
  }
 
632
  const atFrontier = resolveFocusAttributionAtFrontier(
633
  focusId,
634
  focusState,
@@ -636,19 +476,35 @@ function resolveEffectiveStayShareByIdForStroke(
636
  computeFocusState,
637
  ctx,
638
  );
639
- return computeLivePartialStayShareById(
640
  atFrontier.nodeShareById,
641
  atFrontier.incomingEdgeShareByKey,
642
  focusId,
643
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
  }
645
 
646
  export type RecursiveEdgeAnimationRenderOverlay = {
647
  animationFrontierPartial: boolean;
648
  anim: DagEdgeBatchAnimationState | null;
649
  frontierEdgeKeys: ReadonlySet<string> | null;
 
650
  linkFocusState: DagFocusAttributionState | null;
651
- displayFocusState: DagFocusAttributionState | null;
652
  nodeStrokeShareById: Map<string, number> | null;
653
  /** backward 部分帧:稳定态 stay 池 max,供描边归一分母;否则 undefined 用当前池 max。 */
654
  nodeStrokeMaxForRender?: number;
@@ -656,12 +512,29 @@ export type RecursiveEdgeAnimationRenderOverlay = {
656
  incomingMaxForRender: number;
657
  /** forward {@link FORWARD_PROMPT_BATCH_INDEX}:仅 prompt 稳态描边,无链边、无 slide 高亮。 */
658
  forwardPromptOnlyFrame: boolean;
659
- forwardSlideTgtId: string | null;
 
 
 
 
 
660
  edgeVisibility(edgeKey: string, inPropagationChain: boolean): number;
661
  };
662
 
663
  const INACTIVE_EDGE_VISIBILITY = (_edgeKey: string, _inPropagationChain: boolean): number => 1;
664
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
666
  effectiveFocusId: string | null;
667
  focusState: DagFocusAttributionState | null;
@@ -703,12 +576,13 @@ export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
703
  anim: null,
704
  frontierEdgeKeys: null,
705
  linkFocusState: focusState,
706
- displayFocusState: focusState,
707
  nodeStrokeShareById,
708
  incomingShareForRender: focusState?.incomingEdgeShareByKey ?? emptyIncoming,
709
  incomingMaxForRender: maxHighlightEdgeShare(focusState?.incomingEdgeShareByKey ?? emptyIncoming),
710
  forwardPromptOnlyFrame: false,
711
- forwardSlideTgtId: null,
 
 
712
  edgeVisibility: INACTIVE_EDGE_VISIBILITY,
713
  };
714
  }
@@ -721,15 +595,6 @@ export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
721
  animationFrontierPartial && anim != null
722
  ? frontierEdgeKeysAtBatch(anim.plan, anim.direction, anim.batchIndex)
723
  : null;
724
- const displayFocusState = resolveFocusAttributionAtFrontier(
725
- userAnimationFocusId,
726
- focusState,
727
- anim,
728
- computeFocusState,
729
- ctx,
730
- );
731
- const linkFocusState =
732
- animationFrontierPartial && anim?.direction === 'backward' ? displayFocusState : focusState;
733
  const nodeStrokeShareById = resolveEffectiveStayShareByIdForStroke(
734
  focusState,
735
  focusId,
@@ -742,52 +607,92 @@ export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
742
  animationFrontierPartial && anim?.direction === 'backward'
743
  ? maxHighlightEdgeShare(computeSteadyStateStayShareById(focusState.nodeShareById, focusId))
744
  : undefined;
745
- const incomingShareForRender =
746
- animationFrontierPartial && anim?.direction === 'backward' && displayFocusState != null
747
- ? displayFocusState.incomingEdgeShareByKey
748
- : focusState.incomingEdgeShareByKey;
749
  const incomingMaxForRender =
750
  animationFrontierPartial && frontierEdgeKeys != null
751
- ? maxShareInEdgeKeySet(focusState.incomingEdgeShareByKey, frontierEdgeKeys)
752
  : maxHighlightEdgeShare(incomingShareForRender);
753
  const forwardPromptOnlyFrame =
754
  anim != null && isForwardPromptOnlyBatchIndex(anim.direction, anim.batchIndex);
755
- const forwardSlideTgtId =
756
- anim?.direction === 'forward' && animationFrontierPartial && !forwardPromptOnlyFrame
757
- ? (anim.plan.batches[anim.batchIndex]?.tgtId ?? null)
758
- : null;
 
 
 
 
 
 
 
 
759
  const edgeVisibility = (edgeKey: string, inPropagationChain: boolean): number => {
760
- if (
761
- !animationFrontierPartial ||
762
- anim?.direction !== 'forward' ||
763
- !inPropagationChain
764
- ) {
765
  return 1;
766
  }
767
  return frontierEdgeKeys?.has(edgeKey) ? 1 : 0;
768
  };
769
-
770
  return {
771
  animationFrontierPartial,
772
  anim,
773
  frontierEdgeKeys,
774
- linkFocusState,
775
- displayFocusState,
776
  nodeStrokeShareById,
777
  nodeStrokeMaxForRender,
778
  incomingShareForRender,
779
  incomingMaxForRender,
780
  forwardPromptOnlyFrame,
781
- forwardSlideTgtId,
 
 
782
  edgeVisibility,
783
  };
784
  }
785
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
786
  export type DagRecursiveEdgeAnimationController = {
787
  onUserSelect(focusId: string, ctx: DagFocusAttributionGraphContext): void;
788
  onClear(): void;
789
  setEnabled(enabled: boolean): void;
790
  setDirection(direction: DagRecursiveEdgeAnimationDirection): void;
 
791
  getUserAnimationFocusId(): string | null;
792
  isEnabled(): boolean;
793
  resolveRenderOverlay(args: {
@@ -837,8 +742,8 @@ export function createDagRecursiveEdgeAnimationController(
837
  const s = animation;
838
  const batch = s.plan.batches[s.batchIndex];
839
  const lastBatch = s.plan.batches.length - 1;
840
- playbackLogLine(
841
- `${playbackPad('stop', DAG_PROP_LOG_W.event)} | focus=${playbackPad(playbackFmtToken(options.tokenLabelOf(s.plan.focusId)), DAG_PROP_LOG_W.focus)} | frame=${playbackPad(`${s.batchIndex}/${lastBatch}`, DAG_PROP_LOG_W.frame)} | token=${playbackPad(playbackFmtToken(batch != null ? options.tokenLabelOf(batch.tgtId) : null), DAG_PROP_LOG_W.token)}`,
842
  );
843
  }
844
  version++;
@@ -885,34 +790,15 @@ export function createDagRecursiveEdgeAnimationController(
885
  direction,
886
  batchIndex: initialBatchIndex,
887
  };
888
- const pacing = getReplayPacing();
889
- const pacingLine =
890
- pacing.mode === 'step'
891
- ? `pacing=step stepMs=${pacing.stepMs}`
892
- : `pacing=total totalS=${pacing.totalS}`;
893
- playbackLogLine(
894
- `${playbackPad('start', DAG_PROP_LOG_W.event)} | focus=${playbackPad(playbackFmtToken(options.tokenLabelOf(focusId)), DAG_PROP_LOG_W.focus)} | direction=${playbackPad(direction, DAG_PROP_LOG_W.direction)} | batches=${playbackPadInt(plan.batches.length, DAG_PROP_LOG_W.int3)} | initial=${playbackPadInt(initialBatchIndex, DAG_PROP_LOG_W.int3)} | ${pacingLine}`,
895
- );
896
- const nodeShareById = focusState.nodeShareById;
897
- const batchTgtIds = new Set<string>();
898
- for (const b of plan.batches) {
899
- for (const tgtId of tgtIdsInBatch(b)) batchTgtIds.add(tgtId);
900
- }
901
- const refNodes = nodesAtNodeShareTotal(nodeShareById, plan.weightMax, {
902
- excludeFocusId: focusId,
903
- onlyNodeIds: batchTgtIds,
904
  });
905
- playbackLogLine(
906
- `${playbackPad('pacing', DAG_PROP_LOG_W.event)} | weightMax=${playbackPadWeight(plan.weightMax)} | weightTotal=${playbackPadWeight(plan.weightTotal)} | lookahead=${playbackPadInt(plan.runningMaxLookahead, DAG_PROP_LOG_W.int3)} | nodes=${playbackFmtNodeShareList(refNodes, options.tokenLabelOf)}`,
907
- );
908
- const planTextOrder = batchesInTextOrder(plan.batches);
909
- for (let chainStep = 0; chainStep < planTextOrder.length; chainStep++) {
910
- const b = planTextOrder[chainStep]!;
911
- const token = playbackFmtToken(options.tokenLabelOf(b.tgtId));
912
- playbackLogLine(
913
- `${playbackPad(`plan[${chainStep}]`, DAG_PROP_LOG_W.event)} | token=${playbackPad(token, DAG_PROP_LOG_W.token)} | share_norm=${playbackPadWeight(b.shareNorm)} | running_max=${playbackPadWeight(b.runningMaxNorm)} | weight=${playbackPadWeight(b.propagationWeight)}`,
914
- );
915
- }
916
  scheduleAnimationStep(focusId);
917
  }
918
 
@@ -937,6 +823,7 @@ export function createDagRecursiveEdgeAnimationController(
937
  return delayMsForCurrentBatch(state);
938
  }
939
 
 
940
  function hasNextBatch(state: DagEdgeBatchAnimationState, lastBatch: number): boolean {
941
  if (state.direction === 'backward') {
942
  return state.batchIndex < lastBatch;
@@ -966,10 +853,10 @@ export function createDagRecursiveEdgeAnimationController(
966
  const dwellMs = dwellMsAfterCurrentFrame(state);
967
  const token = promptFrame
968
  ? 'prompt'
969
- : playbackFmtToken(batch?.tgtId != null ? options.tokenLabelOf(batch.tgtId) : null);
970
- const weight = promptFrame ? 'fixed' : playbackFmtWeight(batch?.propagationWeight);
971
- playbackLogLine(
972
- `${playbackPad('frame', DAG_PROP_LOG_W.event)} ${playbackPad(`${state.batchIndex}/${lastBatch}`, DAG_PROP_LOG_W.frame)} | token=${playbackPad(token, DAG_PROP_LOG_W.token)} | weight=${playbackPad(weight, DAG_PROP_LOG_W.weight)} | dwellMs=${playbackPadInt(dwellMs, DAG_PROP_LOG_W.dwell)}`,
973
  );
974
  }
975
 
@@ -986,27 +873,27 @@ export function createDagRecursiveEdgeAnimationController(
986
  return;
987
  }
988
 
989
- const v = ++version;
990
 
991
  const showFrameAndScheduleNext = (): void => {
992
- if (version !== v) return;
993
- const s = animation;
994
- if (!s || s.plan.focusId !== focusId) return;
995
 
996
  options.onTick();
997
- logPropagationFrame(s);
998
 
999
- const dwellMs = dwellMsAfterCurrentFrame(s);
1000
  timer = setTimeout(() => {
1001
- if (version !== v) return;
1002
- const s2 = animation;
1003
- if (!s2 || s2.plan.focusId !== focusId) return;
1004
 
1005
- if (!hasNextBatch(s2, lastBatch)) {
1006
  timer = null;
1007
  return;
1008
  }
1009
- advanceBatchIndex(s2);
1010
  showFrameAndScheduleNext();
1011
  }, dwellMs);
1012
  };
@@ -1031,6 +918,9 @@ export function createDagRecursiveEdgeAnimationController(
1031
  direction = next;
1032
  stopAnimation();
1033
  },
 
 
 
1034
  getUserAnimationFocusId(): string | null {
1035
  return userAnimationFocusId;
1036
  },
 
1
  import { DAG_MIN_ATTRIBUTION_SHARE } from './genAttributeDagEdgeDisplay';
2
+ import {
3
+ DAG_PROP_LOG_W,
4
+ dagPropLogFmtNodeShareList,
5
+ dagPropLogFmtToken,
6
+ dagPropLogFmtWeight,
7
+ dagPropLogPad,
8
+ dagPropLogPadInt,
9
+ dagPropLogPadWeight,
10
+ logDagPropagationPlaybackLine,
11
+ nodesAtNodeShareTotalForPlaybackLog,
12
+ } from './genAttributeDagPropagationPlaybackLog';
13
+ import {
14
+ batchPlaybackDelayMs,
15
+ computePropagationGroupPacings,
16
+ FORWARD_PROMPT_FRAME_DWELL_MS,
17
+ type DagRecursiveEdgeReplayPacing,
18
+ type DagReplayPacingMode,
19
+ type PropagationGroupPrep,
20
+ } from './genAttributeDagPropagationPlaybackPacing';
21
+
22
+ export type { DagRecursiveEdgeReplayPacing, DagReplayPacingMode } from './genAttributeDagPropagationPlaybackPacing';
23
+ export {
24
+ batchPlaybackDelayMs,
25
+ computePropagationGroupPacings,
26
+ DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN,
27
+ DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO,
28
+ FORWARD_PROMPT_FRAME_DWELL_MS,
29
+ propagationRunningMaxLookaheadForGroupCount,
30
+ } from './genAttributeDagPropagationPlaybackPacing';
31
+ export {
32
+ DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY,
33
+ isDagPropagationPlaybackLogEnabled,
34
+ setDagPropagationPlaybackLogEnabled,
35
+ } from './genAttributeDagPropagationPlaybackLog';
36
 
37
  export type DagRecursiveEdgeAnimationDirection = 'backward' | 'forward';
38
 
39
  /** forward 专有第 0 帧:仅 prompt(稳态描边/归一),无传播链边。 */
40
  export const FORWARD_PROMPT_BATCH_INDEX = -1;
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  /** forward {@link FORWARD_PROMPT_BATCH_INDEX} 帧:仅展示 prompt,外观与稳态一致。 */
43
  export function isForwardPromptOnlyBatchIndex(
44
  direction: DagRecursiveEdgeAnimationDirection,
 
62
  };
63
 
64
  export type DagFocusAttributionGraphContext = {
65
+ nodesSortedByStepDesc: readonly { id: string; step: number }[];
66
  incomingLinksByTarget: ReadonlyMap<string, readonly unknown[]>;
67
  };
68
 
 
80
  /** 无 {@link CreateDagRecursiveEdgeAnimationControllerOptions.getReplayPacing} 时的兜底 step 间隔(ms)。 */
81
  const DAG_RECURSIVE_EDGE_BATCH_STEP_MS_FALLBACK = 500;
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  /**
84
  * 仅用于「Propagated attribution mode」焦点入边的分批显示状态。
85
  * 两方向均按 `start(tgt)` 分批;backward 从高 tgt 向低 tgt 播放(贴合向上追溯),forward 反向。
 
91
  * **forward**
92
  * - 第 0 帧 {@link FORWARD_PROMPT_BATCH_INDEX}:无传播链边,仅 prompt 节点按稳态 stay 描边/归一;固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms。
93
  * - 其后从最远 batch 递减;share 始终用全量焦点快照,动画只改「可见边集合」与归一分母(前沿内 max share)。
94
+ * - 部分帧内焦点不提前高亮/描边(render 延后至末帧 `batchIndex === 0` 稳态),与反向首帧才亮焦点对称。
95
  * - 同一帧内,已可见边的相对强弱 = share 相对强弱;绝对 opacity 可因分母随新批次变大而变暗。
96
  * - 末帧 `batchIndex === 0` 时前沿 = 全链、分母 = 全链 max、可见性全开,与无动画稳定态数值一致(收敛)。
97
  *
98
  * **backward**
99
+ * - 首帧 `batchIndex === 0`(焦点侧):固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms,焦点红色 slide;与 forward prompt 首帧对称,不参与权重分配。
100
+ * - 蓝线从焦点侧逐批显现:稳态 share + {@link backwardFrontierByBatchIndex} 门控(`batches[0..i]` 递增)
101
+ * - 未滑过:live stay;已滑过 batch:稳态 stay;非播放生成 token 不描边;prompt(`step === -1`若在候选集中则用 live stay(可不在传播链上)
102
+ * - 描边分母为稳态 `max(stay)`。
103
+ * - 当前帧 slide 节点(`--backward-slide`)的入边:红色,强度在本批指向 slide 的入边集合内 max 归一 × 焦点 MI。
104
  *
105
  * **播放计划(见 {@link DagPropagationPlaybackPlan})**
106
+ * - 一批 = 同一生成 offset 的入边;`groupOffset` + `tgtId` 标���组代表 token。
107
+ * - 播放间隔权重(准备阶段一遍):按**文字顺序**对非焦点 `groupShare/weightMax` 做 running max 归一化;向后看数 = max({@link DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN}, round(比例×数));与播放方向无关。内含焦点则无 `shareNorm`。
108
+ * - `backwardFrontierByBatchIndex` / `forwardFrontierByBatchIndex`:各方向蓝线可见边并集,render 热路径 O(1)。
109
  * - forward / backward 共用同一 plan;不用 backward 部分快照的 nodeShare 定权重。
110
+ *
111
+ * **播放停留(见 {@link batchPlaybackDelayMs})**
112
+ * - 对 `propagationWeight` 连续;权重为 0 时停留恰为 0(`step` 下 0ms,不设最小间隔)。
113
+ * - `total` 模式:UI `totalS` 中预留 {@link FORWARD_PROMPT_FRAME_DWELL_MS} 给 forward prompt / backward 首帧,其余按权重分配。
114
  */
115
+ /** 传播链动画的一批:同一生成 offset 的入边 + 播放元数据。 */
116
  export type DagRecursiveIncomingEdgeBatch = {
117
+ /** 与 {@link buildPropagationPlaybackPlan} 分批键一致(`start(tgt)`)。 */
118
+ groupOffset: number;
119
+ /** 本代表 token(forward 高亮)。 */
120
  tgtId: string;
121
+ /** 本传播链入边(`src->tgt`)。 */
122
  edgeKeys: string[];
123
  /**
124
  * 文字顺序局部归一化权重:share_norm ÷ runningMax(含向后 lookahead 窗口内的非焦点 share_norm)。
125
  */
126
  propagationWeight: number;
127
  /**
128
+ * 非焦点 `groupShare / weightMax`(playback 日志 share_norm)。
129
+ * 内含焦点时为 undefined。
130
  */
131
  shareNorm?: number;
132
+ /** 准备阶段:截至本(含 lookahead 窗口)的链序 running max。 */
133
  runningMaxNorm: number;
134
  };
135
 
 
137
  export type DagPropagationPlaybackPlan = {
138
  focusId: string;
139
  batches: DagRecursiveIncomingEdgeBatch[];
140
+ /** 全链非焦点 Total share 上限(日志 / 对照;量纲同 Total share)。 */
141
  weightMax: number;
142
  /** Σ `batches[].propagationWeight`;total 模式分母。 */
143
  weightTotal: number;
144
+ /** 本计划 running max 前瞻数(max(MIN, round(比例×数)))。 */
145
  runningMaxLookahead: number;
146
  /** backward:`batchIndex = i` 时可见边 = `batches[0..i]` 并集。 */
147
  backwardFrontierByBatchIndex: ReadonlyArray<ReadonlySet<string>>;
 
164
 
165
  const EMPTY_EDGE_KEY_SET: ReadonlySet<string> = new Set();
166
 
167
+ /** backward 当前批:指向 slide 节点的入边(与全图按 tgt 筛等价,仅扫本批 edgeKeys)。 */
168
+ export function backwardSlideIncomingEdgeKeysForBatch(
169
+ plan: DagPropagationPlaybackPlan,
170
+ batchIndex: number,
171
+ focusId: string,
172
+ ): ReadonlySet<string> {
173
+ const batch = plan.batches[batchIndex];
174
+ if (batch == null) return EMPTY_EDGE_KEY_SET;
175
+ const slideTgtId = batchIndex === 0 ? focusId : batch.tgtId;
176
+ const keys = new Set<string>();
177
+ for (const key of batch.edgeKeys) {
178
+ if (tgtIdFromEdgeKey(key) === slideTgtId) keys.add(key);
179
+ }
180
+ return keys;
181
+ }
182
+
183
  /** 当前 batchIndex、方向下已启用的传播链入边(计划内预计算)。 */
184
  function frontierEdgeKeysAtBatch(
185
  plan: DagPropagationPlaybackPlan,
 
194
  return table[batchIndex] ?? EMPTY_EDGE_KEY_SET;
195
  }
196
 
197
+ export function maxShareInEdgeKeySet(
198
  incomingEdgeShareByKey: Map<string, number>,
199
  edgeKeys: ReadonlySet<string>,
200
  ): number {
 
252
  if (lastBatch <= 0) return false;
253
  return animation.batchIndex < lastBatch;
254
  }
255
+ // forward:batchIndex===0 为稳态终帧其余含 prompt(-1) 均属「动画未结束」(边/焦点行为由专帧 flag
256
  return animation.batchIndex !== 0;
257
  }
258
 
 
267
  );
268
  }
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  function tgtIdsInBatch(batch: DagRecursiveIncomingEdgeBatch): Set<string> {
271
  const ids = new Set<string>();
272
  for (const edgeKey of batch.edgeKeys) {
 
276
  return ids;
277
  }
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  function batchesInTextOrder(
280
  batches: readonly DagRecursiveIncomingEdgeBatch[],
281
  ): DagRecursiveIncomingEdgeBatch[] {
282
+ return [...batches].sort((a, b) => a.groupOffset - b.groupOffset);
283
  }
284
 
285
+ /** 内代表 tgt;并列时取 id 字典序最小。 */
286
+ function primaryTgtIdForGroup(
287
  tgtIds: Iterable<string>,
288
  nodeShareById: ReadonlyMap<string, number>,
289
  ): string {
 
299
  return bestId;
300
  }
301
 
302
+ function incomingEdgeBatchFromGroup(
303
+ groupOffset: number,
304
+ group: { edgeKeys: string[]; tgtIds: Set<string> },
305
+ prep: PropagationGroupPrep,
306
  nodeShareById: ReadonlyMap<string, number>,
307
  ): DagRecursiveIncomingEdgeBatch {
308
+ group.edgeKeys.sort();
309
  return {
310
+ groupOffset,
311
+ tgtId: primaryTgtIdForGroup(group.tgtIds, nodeShareById),
312
+ edgeKeys: group.edgeKeys,
313
  propagationWeight: prep.propagationWeight,
314
  runningMaxNorm: prep.runningMaxNorm,
315
  ...(prep.shareNorm != null ? { shareNorm: prep.shareNorm } : {}),
 
320
  batches: readonly DagRecursiveIncomingEdgeBatch[],
321
  ): Pick<DagPropagationPlaybackPlan, 'backwardFrontierByBatchIndex' | 'forwardFrontierByBatchIndex'> {
322
  const n = batches.length;
323
+ const backward: Set<string>[] = [];
324
+ const forward: Set<string>[] = [];
325
 
326
  for (let i = 0; i < n; i++) {
327
+ const prev = backward[i - 1];
328
+ backward.push(new Set(prev));
 
329
  for (const key of batches[i]!.edgeKeys) backward[i]!.add(key);
330
  }
331
  for (let i = n - 1; i >= 0; i--) {
332
+ const next = forward[i + 1];
333
+ forward[i] = new Set(next);
 
334
  for (const key of batches[i]!.edgeKeys) forward[i]!.add(key);
335
  }
336
 
 
339
 
340
  /**
341
  * 传播链播放计划:入边按 `start(tgt)` 分批(offset 降序),并预计算双向前沿。
342
+ * backward 从 index 0 递增(蓝线递增),forward 从末批递减(蓝线递增)
343
  */
344
  export function buildPropagationPlaybackPlan(
345
  incomingEdgeShareByKey: Map<string, number>,
 
354
  const tgtId = tgtIdFromEdgeKey(edgeKey);
355
  if (tgtId == null) continue;
356
  const offset = offsetOf(tgtId);
357
+ let group = byOffset.get(offset);
358
+ if (group == null) {
359
+ group = { edgeKeys: [], tgtIds: new Set() };
360
+ byOffset.set(offset, group);
361
  }
362
+ group.edgeKeys.push(edgeKey);
363
+ group.tgtIds.add(tgtId);
364
  }
365
 
366
  const sortedOffsetsAsc = [...byOffset.keys()].sort((a, b) => a - b);
367
  const sortedOffsetsDesc = [...sortedOffsetsAsc].reverse();
368
+ const { groupPreps, weightMax, weightTotal, runningMaxLookahead } = computePropagationGroupPacings(
369
+ sortedOffsetsAsc.map((groupOffset) => byOffset.get(groupOffset)!),
370
  nodeShareById,
371
  focusId,
372
  );
373
+ // groupPreps 按文序 ASC;batches 按播放序 DESC(远→近),故播放序下标 j 对应文序下标 n-1-j
374
+ const batches: DagRecursiveIncomingEdgeBatch[] = sortedOffsetsDesc.map((groupOffset, j) => {
375
+ const prep = groupPreps[sortedOffsetsAsc.length - 1 - j]!;
376
+ return incomingEdgeBatchFromGroup(groupOffset, byOffset.get(groupOffset)!, prep, nodeShareById);
377
  });
378
 
379
  return {
 
387
  }
388
 
389
  /**
390
+ * backward 节点 stay 用:沿 {@link backwardFrontierByBatchIndex}(与蓝线同前沿)重算部分快照。
391
+ * 蓝边不经过此函数(与 forward 一样用全量 share + 前沿控)。
392
  */
393
  function resolveFocusAttributionAtFrontier(
394
  focusId: string,
 
425
  return partial ?? fullState;
426
  }
427
 
428
+ function passedBatchTgtIdsBeforeIndex(
429
+ batches: readonly DagRecursiveIncomingEdgeBatch[],
430
+ batchIndex: number,
431
+ ): Set<string> {
432
+ const ids = new Set<string>();
433
+ for (let i = 0; i < batchIndex; i++) {
434
+ for (const id of tgtIdsInBatch(batches[i]!)) ids.add(id);
435
+ }
436
+ return ids;
437
+ }
438
+
439
+ /** 播放计划传播链上的全部 tgt(含同 offset 非代表 token)。 */
440
+ function playbackChainNodeIds(batches: readonly DagRecursiveIncomingEdgeBatch[]): Set<string> {
441
+ const onChain = new Set<string>();
442
+ for (const batch of batches) {
443
+ for (const id of tgtIdsInBatch(batch)) onChain.add(id);
444
+ }
445
+ return onChain;
446
+ }
447
+
448
+ function promptNodeIdsFromCtx(ctx: DagFocusAttributionGraphContext): Set<string> {
449
+ const ids = new Set<string>();
450
+ for (const n of ctx.nodesSortedByStepDesc) {
451
+ if (n.step === -1) ids.add(n.id);
452
+ }
453
+ return ids;
454
+ }
455
+
456
+ /**
457
+ * 传播模式描边:backward 动画进行中,未滑过 batch 用 live stay;
458
+ * 已滑过 batch 用稳态 stay;候选中的 prompt 用 live(可不在链上);其余链外生成 token 不描边。
459
+ */
460
  function resolveEffectiveStayShareByIdForStroke(
461
  focusState: DagFocusAttributionState,
462
  focusId: string,
 
468
  if (!isBackwardRecursiveEdgeAnimationInProgress(animation, focusId)) {
469
  return computeSteadyStateStayShareById(focusState.nodeShareById, focusId);
470
  }
471
+ // ① 与蓝线同前沿的部分快照 → live stay 池
472
  const atFrontier = resolveFocusAttributionAtFrontier(
473
  focusId,
474
  focusState,
 
476
  computeFocusState,
477
  ctx,
478
  );
479
+ const liveById = computeLivePartialStayShareById(
480
  atFrontier.nodeShareById,
481
  atFrontier.incomingEdgeShareByKey,
482
  focusId,
483
  );
484
+ // ② 全链稳态 stay 池(已滑过 batch 用)
485
+ const steadyById = computeSteadyStateStayShareById(focusState.nodeShareById, focusId);
486
+ const batches = animation!.plan.batches;
487
+ const batchIndex = animation!.batchIndex;
488
+ const passedTgtIds = passedBatchTgtIdsBeforeIndex(batches, batchIndex);
489
+ const onChain = playbackChainNodeIds(batches);
490
+ const promptIds = promptNodeIdsFromCtx(ctx);
491
+ // ③ 合并候选:未滑过→live,已滑过→steady;仅链上或 prompt
492
+ const strokeCandidates = new Set([...liveById.keys(), ...passedTgtIds]);
493
+ const byNodeId = new Map<string, number>();
494
+ for (const nodeId of strokeCandidates) {
495
+ if (!onChain.has(nodeId) && !promptIds.has(nodeId)) continue;
496
+ const stay = passedTgtIds.has(nodeId) ? steadyById.get(nodeId) : liveById.get(nodeId);
497
+ if (stay != null && stay >= DAG_MIN_ATTRIBUTION_SHARE) byNodeId.set(nodeId, stay);
498
+ }
499
+ return byNodeId;
500
  }
501
 
502
  export type RecursiveEdgeAnimationRenderOverlay = {
503
  animationFrontierPartial: boolean;
504
  anim: DagEdgeBatchAnimationState | null;
505
  frontierEdgeKeys: ReadonlySet<string> | null;
506
+ /** 与 focusState 同引用;入边 share 恒为全量,可见性由 frontier / edgeVisibility 裁切。 */
507
  linkFocusState: DagFocusAttributionState | null;
 
508
  nodeStrokeShareById: Map<string, number> | null;
509
  /** backward 部分帧:稳定态 stay 池 max,供描边归一分母;否则 undefined 用当前池 max。 */
510
  nodeStrokeMaxForRender?: number;
 
512
  incomingMaxForRender: number;
513
  /** forward {@link FORWARD_PROMPT_BATCH_INDEX}:仅 prompt 稳态描边,无链边、无 slide 高亮。 */
514
  forwardPromptOnlyFrame: boolean;
515
+ /** 当前帧 slide token:forward 为 batch 代表;backward 首帧为焦点。不含 forward prompt 专帧。 */
516
+ propagationSlideTgtId: string | null;
517
+ /** 正向传播部分帧:焦点不提前全亮(末帧 partial 结束即恢复)。 */
518
+ deferFocusHighlightDuringAnim: boolean;
519
+ /** 传播部分帧:不为焦点挂 `--selected` 描边(正向全程;反向仅 slide=焦点时)。 */
520
+ suppressFocusSelectedStroke: boolean;
521
  edgeVisibility(edgeKey: string, inPropagationChain: boolean): number;
522
  };
523
 
524
  const INACTIVE_EDGE_VISIBILITY = (_edgeKey: string, _inPropagationChain: boolean): number => 1;
525
 
526
+ /** backward 首帧 slide = 焦点;forward prompt 专帧和无动画 = null;其余 = 当前批代表 token。 */
527
+ function resolvePropagationSlideTgtId(
528
+ anim: DagEdgeBatchAnimationState | null,
529
+ animationFrontierPartial: boolean,
530
+ forwardPromptOnlyFrame: boolean,
531
+ focusId: string,
532
+ ): string | null {
533
+ if (anim == null || !animationFrontierPartial || forwardPromptOnlyFrame) return null;
534
+ if (anim.direction === 'backward' && anim.batchIndex === 0) return focusId;
535
+ return anim.plan.batches[anim.batchIndex]?.tgtId ?? null;
536
+ }
537
+
538
  export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
539
  effectiveFocusId: string | null;
540
  focusState: DagFocusAttributionState | null;
 
576
  anim: null,
577
  frontierEdgeKeys: null,
578
  linkFocusState: focusState,
 
579
  nodeStrokeShareById,
580
  incomingShareForRender: focusState?.incomingEdgeShareByKey ?? emptyIncoming,
581
  incomingMaxForRender: maxHighlightEdgeShare(focusState?.incomingEdgeShareByKey ?? emptyIncoming),
582
  forwardPromptOnlyFrame: false,
583
+ propagationSlideTgtId: null,
584
+ deferFocusHighlightDuringAnim: false,
585
+ suppressFocusSelectedStroke: false,
586
  edgeVisibility: INACTIVE_EDGE_VISIBILITY,
587
  };
588
  }
 
595
  animationFrontierPartial && anim != null
596
  ? frontierEdgeKeysAtBatch(anim.plan, anim.direction, anim.batchIndex)
597
  : null;
 
 
 
 
 
 
 
 
 
598
  const nodeStrokeShareById = resolveEffectiveStayShareByIdForStroke(
599
  focusState,
600
  focusId,
 
607
  animationFrontierPartial && anim?.direction === 'backward'
608
  ? maxHighlightEdgeShare(computeSteadyStateStayShareById(focusState.nodeShareById, focusId))
609
  : undefined;
610
+ const incomingShareForRender = focusState.incomingEdgeShareByKey;
 
 
 
611
  const incomingMaxForRender =
612
  animationFrontierPartial && frontierEdgeKeys != null
613
+ ? maxShareInEdgeKeySet(incomingShareForRender, frontierEdgeKeys)
614
  : maxHighlightEdgeShare(incomingShareForRender);
615
  const forwardPromptOnlyFrame =
616
  anim != null && isForwardPromptOnlyBatchIndex(anim.direction, anim.batchIndex);
617
+ const forwardPartial = animationFrontierPartial && anim?.direction === 'forward';
618
+ const propagationSlideTgtId = resolvePropagationSlideTgtId(
619
+ anim,
620
+ animationFrontierPartial,
621
+ forwardPromptOnlyFrame,
622
+ focusId,
623
+ );
624
+ const deferFocusHighlightDuringAnim = forwardPartial;
625
+ const suppressFocusSelectedStroke =
626
+ animationFrontierPartial &&
627
+ focusId != null &&
628
+ (forwardPartial || propagationSlideTgtId === focusId);
629
  const edgeVisibility = (edgeKey: string, inPropagationChain: boolean): number => {
630
+ if (!animationFrontierPartial || !inPropagationChain) {
 
 
 
 
631
  return 1;
632
  }
633
  return frontierEdgeKeys?.has(edgeKey) ? 1 : 0;
634
  };
 
635
  return {
636
  animationFrontierPartial,
637
  anim,
638
  frontierEdgeKeys,
639
+ linkFocusState: focusState,
 
640
  nodeStrokeShareById,
641
  nodeStrokeMaxForRender,
642
  incomingShareForRender,
643
  incomingMaxForRender,
644
  forwardPromptOnlyFrame,
645
+ propagationSlideTgtId,
646
+ deferFocusHighlightDuringAnim,
647
+ suppressFocusSelectedStroke,
648
  edgeVisibility,
649
  };
650
  }
651
 
652
+ function logPropagationPlaybackPlanOnStart(args: {
653
+ plan: DagPropagationPlaybackPlan;
654
+ focusId: string;
655
+ direction: DagRecursiveEdgeAnimationDirection;
656
+ initialBatchIndex: number;
657
+ pacing: DagRecursiveEdgeReplayPacing;
658
+ nodeShareById: ReadonlyMap<string, number>;
659
+ tokenLabelOf: (id: string) => string | null;
660
+ }): void {
661
+ const { plan, focusId, direction, initialBatchIndex, pacing, nodeShareById, tokenLabelOf } = args;
662
+ const pacingLine =
663
+ pacing.mode === 'step'
664
+ ? `pacing=step stepMs=${pacing.stepMs}`
665
+ : `pacing=total totalS=${pacing.totalS}`;
666
+ logDagPropagationPlaybackLine(
667
+ `${dagPropLogPad('start', DAG_PROP_LOG_W.event)} | focus=${dagPropLogPad(dagPropLogFmtToken(tokenLabelOf(focusId)), DAG_PROP_LOG_W.focus)} | direction=${dagPropLogPad(direction, DAG_PROP_LOG_W.direction)} | batches=${dagPropLogPadInt(plan.batches.length, DAG_PROP_LOG_W.int3)} | initial=${dagPropLogPadInt(initialBatchIndex, DAG_PROP_LOG_W.int3)} | ${pacingLine}`,
668
+ );
669
+ const batchTgtIds = new Set<string>();
670
+ for (const b of plan.batches) {
671
+ for (const tgtId of tgtIdsInBatch(b)) batchTgtIds.add(tgtId);
672
+ }
673
+ const refNodes = nodesAtNodeShareTotalForPlaybackLog(nodeShareById, plan.weightMax, {
674
+ excludeFocusId: focusId,
675
+ onlyNodeIds: batchTgtIds,
676
+ });
677
+ logDagPropagationPlaybackLine(
678
+ `${dagPropLogPad('pacing', DAG_PROP_LOG_W.event)} | weightMax=${dagPropLogPadWeight(plan.weightMax)} | weightTotal=${dagPropLogPadWeight(plan.weightTotal)} | lookahead=${dagPropLogPadInt(plan.runningMaxLookahead, DAG_PROP_LOG_W.int3)} | nodes=${dagPropLogFmtNodeShareList(refNodes, tokenLabelOf)}`,
679
+ );
680
+ const planTextOrder = batchesInTextOrder(plan.batches);
681
+ for (let chainStep = 0; chainStep < planTextOrder.length; chainStep++) {
682
+ const b = planTextOrder[chainStep]!;
683
+ const token = dagPropLogFmtToken(tokenLabelOf(b.tgtId));
684
+ logDagPropagationPlaybackLine(
685
+ `${dagPropLogPad(`plan[${chainStep}]`, DAG_PROP_LOG_W.event)} | token=${dagPropLogPad(token, DAG_PROP_LOG_W.token)} | share_norm=${dagPropLogPadWeight(b.shareNorm)} | running_max=${dagPropLogPadWeight(b.runningMaxNorm)} | weight=${dagPropLogPadWeight(b.propagationWeight)}`,
686
+ );
687
+ }
688
+ }
689
+
690
  export type DagRecursiveEdgeAnimationController = {
691
  onUserSelect(focusId: string, ctx: DagFocusAttributionGraphContext): void;
692
  onClear(): void;
693
  setEnabled(enabled: boolean): void;
694
  setDirection(direction: DagRecursiveEdgeAnimationDirection): void;
695
+ getDirection(): DagRecursiveEdgeAnimationDirection;
696
  getUserAnimationFocusId(): string | null;
697
  isEnabled(): boolean;
698
  resolveRenderOverlay(args: {
 
742
  const s = animation;
743
  const batch = s.plan.batches[s.batchIndex];
744
  const lastBatch = s.plan.batches.length - 1;
745
+ logDagPropagationPlaybackLine(
746
+ `${dagPropLogPad('stop', DAG_PROP_LOG_W.event)} | focus=${dagPropLogPad(dagPropLogFmtToken(options.tokenLabelOf(s.plan.focusId)), DAG_PROP_LOG_W.focus)} | frame=${dagPropLogPad(`${s.batchIndex}/${lastBatch}`, DAG_PROP_LOG_W.frame)} | token=${dagPropLogPad(dagPropLogFmtToken(batch != null ? options.tokenLabelOf(batch.tgtId) : null), DAG_PROP_LOG_W.token)}`,
747
  );
748
  }
749
  version++;
 
790
  direction,
791
  batchIndex: initialBatchIndex,
792
  };
793
+ logPropagationPlaybackPlanOnStart({
794
+ plan,
795
+ focusId,
796
+ direction,
797
+ initialBatchIndex,
798
+ pacing: getReplayPacing(),
799
+ nodeShareById: focusState.nodeShareById,
800
+ tokenLabelOf: options.tokenLabelOf,
 
 
 
 
 
 
 
 
801
  });
 
 
 
 
 
 
 
 
 
 
 
802
  scheduleAnimationStep(focusId);
803
  }
804
 
 
823
  return delayMsForCurrentBatch(state);
824
  }
825
 
826
+ /** batchIndex 时间线:backward `0 → last`;forward `-1(prompt) → last → … → 0(稳态)`。 */
827
  function hasNextBatch(state: DagEdgeBatchAnimationState, lastBatch: number): boolean {
828
  if (state.direction === 'backward') {
829
  return state.batchIndex < lastBatch;
 
853
  const dwellMs = dwellMsAfterCurrentFrame(state);
854
  const token = promptFrame
855
  ? 'prompt'
856
+ : dagPropLogFmtToken(batch?.tgtId != null ? options.tokenLabelOf(batch.tgtId) : null);
857
+ const weight = promptFrame ? 'fixed' : dagPropLogFmtWeight(batch?.propagationWeight);
858
+ logDagPropagationPlaybackLine(
859
+ `${dagPropLogPad('frame', DAG_PROP_LOG_W.event)} ${dagPropLogPad(`${state.batchIndex}/${lastBatch}`, DAG_PROP_LOG_W.frame)} | token=${dagPropLogPad(token, DAG_PROP_LOG_W.token)} | weight=${dagPropLogPad(weight, DAG_PROP_LOG_W.weight)} | dwellMs=${dagPropLogPadInt(dwellMs, DAG_PROP_LOG_W.dwell)}`,
860
  );
861
  }
862
 
 
873
  return;
874
  }
875
 
876
+ const capturedVersion = ++version;
877
 
878
  const showFrameAndScheduleNext = (): void => {
879
+ if (version !== capturedVersion) return;
880
+ const liveState = animation;
881
+ if (!liveState || liveState.plan.focusId !== focusId) return;
882
 
883
  options.onTick();
884
+ logPropagationFrame(liveState);
885
 
886
+ const dwellMs = dwellMsAfterCurrentFrame(liveState);
887
  timer = setTimeout(() => {
888
+ if (version !== capturedVersion) return;
889
+ const stateAfterDwell = animation;
890
+ if (!stateAfterDwell || stateAfterDwell.plan.focusId !== focusId) return;
891
 
892
+ if (!hasNextBatch(stateAfterDwell, lastBatch)) {
893
  timer = null;
894
  return;
895
  }
896
+ advanceBatchIndex(stateAfterDwell);
897
  showFrameAndScheduleNext();
898
  }, dwellMs);
899
  };
 
918
  direction = next;
919
  stopAnimation();
920
  },
921
+ getDirection(): DagRecursiveEdgeAnimationDirection {
922
+ return direction;
923
+ },
924
  getUserAnimationFocusId(): string | null {
925
  return userAnimationFocusId;
926
  },
client/src/shared/prediction_attribution/causal_flow/genAttributeDagTextMeasure.ts CHANGED
@@ -42,6 +42,7 @@ function estimateExpandedLabelWidthFloorPx(raw: string): number {
42
  const APPROX_CHAR_WIDTH_PX = 10;
43
  const displayLabel = visualizeSpecialChars(raw, {
44
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
 
45
  });
46
  const displayLen = Array.from(displayLabel).length;
47
  return Math.max(displayLen * APPROX_CHAR_WIDTH_PX, 1);
 
42
  const APPROX_CHAR_WIDTH_PX = 10;
43
  const displayLabel = visualizeSpecialChars(raw, {
44
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
45
+ omitHexInCodePointLabel: true,
46
  });
47
  const displayLen = Array.from(displayLabel).length;
48
  return Math.max(displayLen * APPROX_CHAR_WIDTH_PX, 1);
client/src/shared/prediction_attribution/causal_flow/genAttributeDagView.ts CHANGED
@@ -12,11 +12,15 @@ import {
12
  } from './genAttributeDagPreprocess';
13
  import {
14
  DAG_EDGE_MIN_NORMALIZED_SCORE,
15
- DAG_EDGE_RENDER_OPACITY_FLOOR,
16
  DAG_MIN_ATTRIBUTION_SHARE,
17
  DAG_NODE_STROKE_OPACITY_BASE,
18
  } from './genAttributeDagEdgeDisplay';
19
  import {
 
 
 
 
 
20
  createDagRecursiveEdgeAnimationController,
21
  type DagRecursiveEdgeReplayPacing,
22
  maxHighlightEdgeShare,
@@ -214,7 +218,7 @@ type DagNodeAttrs = {
214
  * 下台阶等处用 {@link dagStepDownEffectiveCiRatio}(dagTargetProb)(高置信 p>p₁ 为 0;与「关闭 CI 视觉」无关);
215
  */
216
  dagTargetProb?: number;
217
- /** {@link visualizeSpecialChars}(DAG:仅「空格后是 [A-Za-z0-9]」保留空格,其余空格·),建点后不变 */
218
  displayLabel: string;
219
  /** 悬停 / 选中焦点时 Top‑K tooltip;仅生成节点(`step >= 0`) */
220
  gltrTooltipToken?: FrontendToken;
@@ -271,21 +275,6 @@ function nodeTargetMiRatio(node: DagNode): number {
271
  return computeMutualInformationRatio(node.dagTargetProb);
272
  }
273
 
274
- /**
275
- * 池内 max 归一后的 `stroke-opacity`;最强边刻度为 {@link maxOpacity}(默认 1)。
276
- * 按实际值计算后,最终不低于 {@link DAG_EDGE_RENDER_OPACITY_FLOOR},防止过淡不可见。
277
- */
278
- function normalizeEdgeRenderOpacity(share: number, maxShare: number, maxOpacity = 1): number {
279
- if (!Number.isFinite(share) || share <= 0) return 0;
280
- const cap = Number.isFinite(maxOpacity) && maxOpacity > 0 ? maxOpacity : 1;
281
- const scaled =
282
- !Number.isFinite(maxShare) || maxShare <= 0
283
- ? Math.min(cap, share)
284
- : Math.min(cap, (share / maxShare) * cap);
285
- if (scaled <= 0) return 0;
286
- return Math.max(DAG_EDGE_RENDER_OPACITY_FLOOR, scaled);
287
- }
288
-
289
  /**
290
  * 候选归因节点描边透明度:池内 `stay / max(stay)` 线性映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`,
291
  * 避免弱节点描边过淡、在 UI 里看不出来(见 {@link DAG_NODE_STROKE_OPACITY_BASE})。
@@ -352,23 +341,6 @@ function computeSteadyStateStayShareById(
352
  return byNodeId;
353
  }
354
 
355
- /** 池内 max 归一后的 render 强度;{@link maxOpacity} 为链内最强边刻度(蓝入边见 {@link refreshNodeLinkHighlight},默认 1)。 */
356
- function buildMaxNormalizedRenderStrengthByKey(
357
- sharesByKey: Map<string, number>,
358
- maxOpacity = 1,
359
- maxShareOverride?: number,
360
- ): Map<string, number> {
361
- const maxShare =
362
- maxShareOverride != null && Number.isFinite(maxShareOverride) && maxShareOverride > 0
363
- ? maxShareOverride
364
- : maxHighlightEdgeShare(sharesByKey);
365
- const byKey = new Map<string, number>();
366
- for (const [key, share] of sharesByKey) {
367
- byKey.set(key, normalizeEdgeRenderOpacity(share, maxShare, maxOpacity));
368
- }
369
- return byKey;
370
- }
371
-
372
  /** 递归链候选节点描边强度:stay 池内 max 归一后映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`。 */
373
  function buildNodeStrokeRenderStrengthById(
374
  stayByNodeId: Map<string, number>,
@@ -722,9 +694,10 @@ function buildLinkTitleText(snapshot: DagLinkTitleSnapshot): string {
722
  `Link strength: ${formatTooltipLinkStrength(snapshot.linkStrength)}`,
723
  ];
724
 
 
725
  return [
726
- `From:\n${snapshot.src.displayLabel}\nOffset: ${formatNodeOffsetRange(snapshot.src.id)}`,
727
- `To:\n${snapshot.tgt.displayLabel}\nOffset: ${formatNodeOffsetRange(snapshot.tgt.id)}`,
728
  metrics.join('\n'),
729
  ].join('\n\n');
730
  }
@@ -792,6 +765,7 @@ function resolveDagLinkHighlightDisplay(
792
  grayRenderByKey: Map<string, number>,
793
  incomingHighlightRenderByKey: Map<string, number>,
794
  downstreamHighlightRenderByKey: Map<string, number>,
 
795
  ): DagLinkHighlightDisplay {
796
  const directStrength = directAttributionStrength(d);
797
  const grayRender = grayRenderByKey.get(edgeKey) ?? directStrength;
@@ -808,9 +782,14 @@ function resolveDagLinkHighlightDisplay(
808
 
809
  const incomingShare = focusState.incomingEdgeShareByKey.get(edgeKey);
810
  if (incomingShare != null) {
 
811
  return {
812
- stroke: `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_IN})`,
813
- renderStrength: incomingHighlightRenderByKey.get(edgeKey)!,
 
 
 
 
814
  linkStrength: incomingShare,
815
  recursiveAttributionShare: recursiveAttributionEnabled ? incomingShare : undefined,
816
  };
@@ -1181,6 +1160,11 @@ export function initGenAttributeDagView(
1181
  let hoveredId: string | null = null;
1182
  /** 最近一次 {@link refreshNodeLinkHighlight} 计算出的归因状态(基于 {@link effectiveFocusId});tooltip 用于展示归因份额 */
1183
  let currentFocusState: FocusAttributionState | null = null;
 
 
 
 
 
1184
 
1185
  const focusAttributionCtx = () => ({
1186
  nodesSortedByStepDesc,
@@ -1217,10 +1201,35 @@ export function initGenAttributeDagView(
1217
  return selectedId ?? hoveredId;
1218
  }
1219
 
1220
- /** tooltip 锚点:悬浮任意节点时展示该节点无悬浮则展示焦点节点(选> 无) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1221
  function tooltipFocusId(): string | null {
1222
- if (hoveredId != null) {
1223
- return graph.hasNode(hoveredId) ? hoveredId : null;
 
 
 
1224
  }
1225
  return effectiveFocusId();
1226
  }
@@ -1282,6 +1291,15 @@ export function initGenAttributeDagView(
1282
  svg.attr('width', w).attr('height', h);
1283
  }
1284
 
 
 
 
 
 
 
 
 
 
1285
  function paint(): void {
1286
  syncNodeStrokeRects(nodeSel, displayScale);
1287
  if (layoutMode === 'linear-arc' || layoutMode === 'linear-arc-step-down') {
@@ -1294,10 +1312,7 @@ export function initGenAttributeDagView(
1294
  nodes: layoutNodes,
1295
  adjacentGapPx: linearArcAdjacentGapPx,
1296
  variant: layoutMode === 'linear-arc-step-down' ? 'step-down' : 'flat',
1297
- getLinkNodes: (d) => ({
1298
- src: endpointNode(d.source, graph),
1299
- tgt: endpointNode(d.target, graph),
1300
- }),
1301
  });
1302
  } else if (layoutMode === 'spiral') {
1303
  const layoutNodes = hideExcludedTokens
@@ -1308,20 +1323,14 @@ export function initGenAttributeDagView(
1308
  nodeSel,
1309
  nodes: layoutNodes,
1310
  linkEndInsetPx,
1311
- getLinkNodes: (d) => ({
1312
- src: endpointNode(d.source, graph),
1313
- tgt: endpointNode(d.target, graph),
1314
- }),
1315
  });
1316
  } else {
1317
  paintTextFlowLayout({
1318
  linkSel,
1319
  nodeSel,
1320
  linkEndInsetPx,
1321
- getLinkNodes: (d) => ({
1322
- src: endpointNode(d.source, graph),
1323
- tgt: endpointNode(d.target, graph),
1324
- }),
1325
  });
1326
  }
1327
  syncNodeHitTransforms();
@@ -1374,6 +1383,11 @@ export function initGenAttributeDagView(
1374
  recursiveAttributionEnabled,
1375
  ctx: focusAttributionCtx(),
1376
  });
 
 
 
 
 
1377
  const linkFocusState = animOverlay.linkFocusState ?? focusState;
1378
  const focusNodeIds = focusState?.activeNodeIds ?? null;
1379
  const nodeStrokeShareById = animOverlay.nodeStrokeShareById;
@@ -1408,8 +1422,42 @@ export function initGenAttributeDagView(
1408
  : buildMaxNormalizedRenderStrengthByKey(focusState.downstreamEdgeStrengthByKey);
1409
  grayRenderCache ??= buildGrayRenderStrengthByEdgeKey(graph, incomingLinksByTarget);
1410
  const grayRenderByKey = grayRenderCache;
1411
- const forwardSlideTgtId = animOverlay.forwardSlideTgtId;
1412
- const forwardPromptOnlyFrame = animOverlay.forwardPromptOnlyFrame;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1413
  const nodeOnChainForRender = (d: DagNode): boolean => {
1414
  if (!forwardPromptOnlyFrame) return nodeStrokeShareById?.has(d.id) ?? false;
1415
  return d.step === -1 && (nodeStrokeShareById?.has(d.id) ?? false);
@@ -1420,15 +1468,15 @@ export function initGenAttributeDagView(
1420
  : null;
1421
  nodeSel
1422
  .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
1423
- .classed('gen-attr-dag-node--selected', (d) => selectedId === d.id)
1424
  .style('display', nodeDisplay)
1425
  .attr('opacity', (d) => {
1426
  const nodeFullyHighlighted = recursiveAttributionEnabled
1427
  ? forwardPromptOnlyFrame
1428
  ? nodeOnChainForRender(d)
1429
- : d.id === focusId ||
1430
  (nodeStrokeShareById?.has(d.id) ?? false) ||
1431
- (forwardSlideTgtId != null && d.id === forwardSlideTgtId)
1432
  : (focusNodeIds?.has(d.id) ?? false);
1433
  if (nodeFullyHighlighted) return 1;
1434
  if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
@@ -1439,15 +1487,19 @@ export function initGenAttributeDagView(
1439
  if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
1440
  return 1;
1441
  })
1442
- .classed('gen-attr-dag-node--recursive-chain', (d) => nodeOnChainForRender(d))
 
 
 
 
1443
  .style(CSS_VAR_DAG_NODE_RECURSIVE_SHARE, (d) => {
1444
- if (!nodeOnChainForRender(d)) return null;
1445
  const renderStrength = nodeStrokeRenderById?.get(d.id);
1446
  return renderStrength != null ? String(renderStrength) : null;
1447
  });
1448
  nodeHitSel
1449
  .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
1450
- .classed('gen-attr-dag-node--selected', (d) => selectedId === d.id)
1451
  .style('display', nodeDisplay);
1452
  // 每条边:颜色/强度(见 resolveDagLinkHighlightDisplay)、`<title>` 一并刷新(含 linkGFront 高亮边)。
1453
  rootG.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function(d) {
@@ -1463,6 +1515,7 @@ export function initGenAttributeDagView(
1463
  grayRenderByKey,
1464
  incomingHighlightRenderByKey,
1465
  downstreamHighlightRenderByKey,
 
1466
  );
1467
  const finalRenderStrength =
1468
  renderStrength *
@@ -1536,16 +1589,24 @@ export function initGenAttributeDagView(
1536
  pred_topk: [],
1537
  };
1538
 
1539
- // 构建 augment:归因份额token 下方最前+ CI/MI 行(surprisal 之后)
1540
  const rowsBeforeInfo: ToolTipUpdateAugment['rowsBeforeInfo'] = [];
1541
- if (selectedId && hoveredId && currentFocusState && hoveredId !== selectedId && graph.hasNode(selectedId)) {
 
 
 
 
 
 
 
 
1542
  const selectedStep = (graph.getNodeAttributes(selectedId) as DagNode).step;
1543
  // 归因范围:选中 token 之前的所有 token(prompt 节点 step=-1,生成节点 step < selectedStep)
1544
  const inAttributionRange =
1545
  selectedStep >= 0 &&
1546
  (attrs.step === -1 || (attrs.step >= 0 && attrs.step < selectedStep));
1547
  if (inAttributionRange) {
1548
- const share = currentFocusState.nodeShareById.get(hoveredId) ?? 0;
1549
  if (recursiveAttributionEnabled) {
1550
  const stay = share * (1 - nodePropagationMiRatio(attrs));
1551
  rowsBeforeInfo.push(
@@ -1728,6 +1789,7 @@ export function initGenAttributeDagView(
1728
  }
1729
  const displayLabel = visualizeSpecialChars(attr.raw, {
1730
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
 
1731
  });
1732
  const srcNode: DagNode = {
1733
  id: srcId,
@@ -1784,6 +1846,7 @@ export function initGenAttributeDagView(
1784
  const g = textMeasure.appendGeneratedToken(token, [targetStart, targetEnd]);
1785
  const displayLabel = visualizeSpecialChars(token, {
1786
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
 
1787
  });
1788
  const ciVisualScale = dagGeneratedNodeCiVisualScale(response.target_prob);
1789
  const gltrTooltipToken = frontendTokenFromGenAttrStep(step);
@@ -2075,6 +2138,7 @@ export function initGenAttributeDagView(
2075
  if (recursiveAttributionEnabled === enabled) return;
2076
  recursiveAttributionEnabled = enabled;
2077
  if (!enabled) recursiveEdgeAnimation.stopAnimation();
 
2078
  refreshNodeLinkHighlight();
2079
  }
2080
 
@@ -2089,6 +2153,7 @@ export function initGenAttributeDagView(
2089
 
2090
  function setRecursiveEdgeBatchAnimationDirection(direction: DagRecursiveEdgeAnimationDirection): void {
2091
  recursiveEdgeAnimation.setDirection(direction);
 
2092
  refreshNodeLinkHighlight();
2093
  }
2094
 
 
12
  } from './genAttributeDagPreprocess';
13
  import {
14
  DAG_EDGE_MIN_NORMALIZED_SCORE,
 
15
  DAG_MIN_ATTRIBUTION_SHARE,
16
  DAG_NODE_STROKE_OPACITY_BASE,
17
  } from './genAttributeDagEdgeDisplay';
18
  import {
19
+ buildMaxNormalizedRenderStrengthByKey,
20
+ normalizeEdgeRenderOpacity,
21
+ } from './genAttributeDagEdgeRenderStrength';
22
+ import {
23
+ backwardSlideIncomingEdgeKeysForBatch,
24
  createDagRecursiveEdgeAnimationController,
25
  type DagRecursiveEdgeReplayPacing,
26
  maxHighlightEdgeShare,
 
218
  * 下台阶等处用 {@link dagStepDownEffectiveCiRatio}(dagTargetProb)(高置信 p>p₁ 为 0;与「关闭 CI 视觉」无关);
219
  */
220
  dagTargetProb?: number;
221
+ /** {@link visualizeSpecialChars}(DAG 节点词界空格 + 不可打印`[]`),建点后不变;边 tooltip 用完整 `[hex]` */
222
  displayLabel: string;
223
  /** 悬停 / 选中焦点时 Top‑K tooltip;仅生成节点(`step >= 0`) */
224
  gltrTooltipToken?: FrontendToken;
 
275
  return computeMutualInformationRatio(node.dagTargetProb);
276
  }
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  /**
279
  * 候选归因节点描边透明度:池内 `stay / max(stay)` 线性映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`,
280
  * 避免弱节点描边过淡、在 UI 里看不出来(见 {@link DAG_NODE_STROKE_OPACITY_BASE})。
 
341
  return byNodeId;
342
  }
343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  /** 递归链候选节点描边强度:stay 池内 max 归一后映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`。 */
345
  function buildNodeStrokeRenderStrengthById(
346
  stayByNodeId: Map<string, number>,
 
694
  `Link strength: ${formatTooltipLinkStrength(snapshot.linkStrength)}`,
695
  ];
696
 
697
+ const dagTooltipLabelOpts = { spaceDotExceptBeforeAsciiLetterOrNumber: true as const };
698
  return [
699
+ `From:\n${visualizeSpecialChars(snapshot.src.label, dagTooltipLabelOpts)}\nOffset: ${formatNodeOffsetRange(snapshot.src.id)}`,
700
+ `To:\n${visualizeSpecialChars(snapshot.tgt.label, dagTooltipLabelOpts)}\nOffset: ${formatNodeOffsetRange(snapshot.tgt.id)}`,
701
  metrics.join('\n'),
702
  ].join('\n\n');
703
  }
 
765
  grayRenderByKey: Map<string, number>,
766
  incomingHighlightRenderByKey: Map<string, number>,
767
  downstreamHighlightRenderByKey: Map<string, number>,
768
+ backwardSlideIncomingRenderByKey: Map<string, number> | null,
769
  ): DagLinkHighlightDisplay {
770
  const directStrength = directAttributionStrength(d);
771
  const grayRender = grayRenderByKey.get(edgeKey) ?? directStrength;
 
782
 
783
  const incomingShare = focusState.incomingEdgeShareByKey.get(edgeKey);
784
  if (incomingShare != null) {
785
+ const backwardSlideRender = backwardSlideIncomingRenderByKey?.get(edgeKey);
786
  return {
787
+ stroke:
788
+ backwardSlideRender != null
789
+ ? `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_OUT})`
790
+ : `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_IN})`,
791
+ renderStrength:
792
+ backwardSlideRender ?? incomingHighlightRenderByKey.get(edgeKey)!,
793
  linkStrength: incomingShare,
794
  recursiveAttributionShare: recursiveAttributionEnabled ? incomingShare : undefined,
795
  };
 
1160
  let hoveredId: string | null = null;
1161
  /** 最近一次 {@link refreshNodeLinkHighlight} 计算出的归因状态(基于 {@link effectiveFocusId});tooltip 用于展示归因份额 */
1162
  let currentFocusState: FocusAttributionState | null = null;
1163
+ /** 传播链动画进行中 tooltip 锚点;播放结束后为 null,恢复 target / hover。 */
1164
+ let propagationPlaybackTooltip: {
1165
+ nodeId: string;
1166
+ direction: DagRecursiveEdgeAnimationDirection;
1167
+ } | null = null;
1168
 
1169
  const focusAttributionCtx = () => ({
1170
  nodesSortedByStepDesc,
 
1201
  return selectedId ?? hoveredId;
1202
  }
1203
 
1204
+ /** 传播链动画当前帧应对应 tooltip 节点;非播放返回 null。 */
1205
+ function resolvePropagationPlaybackTooltipNodeId(
1206
+ animOverlay: ReturnType<typeof recursiveEdgeAnimation.resolveRenderOverlay>,
1207
+ focusId: string | null,
1208
+ ): string | null {
1209
+ if (focusId == null || !animOverlay.animationFrontierPartial || animOverlay.anim == null) {
1210
+ return null;
1211
+ }
1212
+ const { anim, forwardPromptOnlyFrame, propagationSlideTgtId, nodeStrokeShareById } = animOverlay;
1213
+ if (forwardPromptOnlyFrame) {
1214
+ if (nodeStrokeShareById != null) {
1215
+ for (const id of nodeStrokeShareById.keys()) {
1216
+ if (graph.hasNode(id) && (graph.getNodeAttributes(id) as DagNode).step === -1) {
1217
+ return id;
1218
+ }
1219
+ }
1220
+ }
1221
+ return nodes.find((n) => n.step === -1)?.id ?? null;
1222
+ }
1223
+ return propagationSlideTgtId ?? anim.plan.batches[anim.batchIndex]?.tgtId ?? null;
1224
+ }
1225
+
1226
+ /** tooltip 锚点:传播播放(忽略 hover)> hover > 焦点 */
1227
  function tooltipFocusId(): string | null {
1228
+ if (propagationPlaybackTooltip != null && graph.hasNode(propagationPlaybackTooltip.nodeId)) {
1229
+ return propagationPlaybackTooltip.nodeId;
1230
+ }
1231
+ if (hoveredId != null && graph.hasNode(hoveredId)) {
1232
+ return hoveredId;
1233
  }
1234
  return effectiveFocusId();
1235
  }
 
1291
  svg.attr('width', w).attr('height', h);
1292
  }
1293
 
1294
+ /** 传播归因 + backward:仅 UI 路径反向,不改边数据与归因 key。 */
1295
+ function linkEndpointsForPaint(d: DagLink): { src: DagNode; tgt: DagNode } {
1296
+ const src = endpointNode(d.source, graph);
1297
+ const tgt = endpointNode(d.target, graph);
1298
+ const flipArrows =
1299
+ recursiveAttributionEnabled && recursiveEdgeAnimation.getDirection() === 'backward';
1300
+ return flipArrows ? { src: tgt, tgt: src } : { src, tgt };
1301
+ }
1302
+
1303
  function paint(): void {
1304
  syncNodeStrokeRects(nodeSel, displayScale);
1305
  if (layoutMode === 'linear-arc' || layoutMode === 'linear-arc-step-down') {
 
1312
  nodes: layoutNodes,
1313
  adjacentGapPx: linearArcAdjacentGapPx,
1314
  variant: layoutMode === 'linear-arc-step-down' ? 'step-down' : 'flat',
1315
+ getLinkNodes: linkEndpointsForPaint,
 
 
 
1316
  });
1317
  } else if (layoutMode === 'spiral') {
1318
  const layoutNodes = hideExcludedTokens
 
1323
  nodeSel,
1324
  nodes: layoutNodes,
1325
  linkEndInsetPx,
1326
+ getLinkNodes: linkEndpointsForPaint,
 
 
 
1327
  });
1328
  } else {
1329
  paintTextFlowLayout({
1330
  linkSel,
1331
  nodeSel,
1332
  linkEndInsetPx,
1333
+ getLinkNodes: linkEndpointsForPaint,
 
 
 
1334
  });
1335
  }
1336
  syncNodeHitTransforms();
 
1383
  recursiveAttributionEnabled,
1384
  ctx: focusAttributionCtx(),
1385
  });
1386
+ const playbackNodeId = resolvePropagationPlaybackTooltipNodeId(animOverlay, focusId);
1387
+ propagationPlaybackTooltip =
1388
+ playbackNodeId != null && animOverlay.anim != null
1389
+ ? { nodeId: playbackNodeId, direction: animOverlay.anim.direction }
1390
+ : null;
1391
  const linkFocusState = animOverlay.linkFocusState ?? focusState;
1392
  const focusNodeIds = focusState?.activeNodeIds ?? null;
1393
  const nodeStrokeShareById = animOverlay.nodeStrokeShareById;
 
1422
  : buildMaxNormalizedRenderStrengthByKey(focusState.downstreamEdgeStrengthByKey);
1423
  grayRenderCache ??= buildGrayRenderStrengthByEdgeKey(graph, incomingLinksByTarget);
1424
  const grayRenderByKey = grayRenderCache;
1425
+ const {
1426
+ propagationSlideTgtId,
1427
+ forwardPromptOnlyFrame,
1428
+ deferFocusHighlightDuringAnim,
1429
+ suppressFocusSelectedStroke,
1430
+ incomingShareForRender,
1431
+ anim,
1432
+ animationFrontierPartial,
1433
+ } = animOverlay;
1434
+ let backwardSlideIncomingRenderByKey: Map<string, number> | null = null;
1435
+ if (
1436
+ animationFrontierPartial &&
1437
+ anim?.direction === 'backward' &&
1438
+ !forwardPromptOnlyFrame &&
1439
+ focusId != null
1440
+ ) {
1441
+ const slideKeys = backwardSlideIncomingEdgeKeysForBatch(
1442
+ anim.plan,
1443
+ anim.batchIndex,
1444
+ focusId,
1445
+ );
1446
+ if (slideKeys.size > 0) {
1447
+ backwardSlideIncomingRenderByKey = buildMaxNormalizedRenderStrengthByKey(
1448
+ incomingShareForRender,
1449
+ focusTargetMiRatio,
1450
+ undefined,
1451
+ slideKeys,
1452
+ );
1453
+ }
1454
+ }
1455
+ const isPropagationSlide = (d: DagNode): boolean =>
1456
+ propagationSlideTgtId != null && d.id === propagationSlideTgtId;
1457
+ const isBackwardSlide = (d: DagNode): boolean =>
1458
+ animOverlay.anim?.direction === 'backward' && isPropagationSlide(d);
1459
+ const showFocusSelectedStroke = (d: DagNode): boolean =>
1460
+ selectedId === d.id && !(suppressFocusSelectedStroke && d.id === focusId);
1461
  const nodeOnChainForRender = (d: DagNode): boolean => {
1462
  if (!forwardPromptOnlyFrame) return nodeStrokeShareById?.has(d.id) ?? false;
1463
  return d.step === -1 && (nodeStrokeShareById?.has(d.id) ?? false);
 
1468
  : null;
1469
  nodeSel
1470
  .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
1471
+ .classed('gen-attr-dag-node--selected', showFocusSelectedStroke)
1472
  .style('display', nodeDisplay)
1473
  .attr('opacity', (d) => {
1474
  const nodeFullyHighlighted = recursiveAttributionEnabled
1475
  ? forwardPromptOnlyFrame
1476
  ? nodeOnChainForRender(d)
1477
+ : (!deferFocusHighlightDuringAnim && d.id === focusId) ||
1478
  (nodeStrokeShareById?.has(d.id) ?? false) ||
1479
+ isPropagationSlide(d)
1480
  : (focusNodeIds?.has(d.id) ?? false);
1481
  if (nodeFullyHighlighted) return 1;
1482
  if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
 
1487
  if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
1488
  return 1;
1489
  })
1490
+ .classed(
1491
+ 'gen-attr-dag-node--recursive-chain',
1492
+ (d) => nodeOnChainForRender(d) || isBackwardSlide(d),
1493
+ )
1494
+ .classed('gen-attr-dag-node--backward-slide', isBackwardSlide)
1495
  .style(CSS_VAR_DAG_NODE_RECURSIVE_SHARE, (d) => {
1496
+ if (!nodeOnChainForRender(d) && !isBackwardSlide(d)) return null;
1497
  const renderStrength = nodeStrokeRenderById?.get(d.id);
1498
  return renderStrength != null ? String(renderStrength) : null;
1499
  });
1500
  nodeHitSel
1501
  .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
1502
+ .classed('gen-attr-dag-node--selected', showFocusSelectedStroke)
1503
  .style('display', nodeDisplay);
1504
  // 每条边:颜色/强度(见 resolveDagLinkHighlightDisplay)、`<title>` 一并刷新(含 linkGFront 高亮边)。
1505
  rootG.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function(d) {
 
1515
  grayRenderByKey,
1516
  incomingHighlightRenderByKey,
1517
  downstreamHighlightRenderByKey,
1518
+ backwardSlideIncomingRenderByKey,
1519
  );
1520
  const finalRenderStrength =
1521
  renderStrength *
 
1589
  pred_topk: [],
1590
  };
1591
 
1592
+ // 反向播放当前 token 展示稳态归因份额( hover;正向播放 / 非播放:仅 hover 时展示
1593
  const rowsBeforeInfo: ToolTipUpdateAugment['rowsBeforeInfo'] = [];
1594
+ const shareSourceId =
1595
+ propagationPlaybackTooltip?.direction === 'backward' ? focusIdNext : hoveredId;
1596
+ if (
1597
+ selectedId &&
1598
+ shareSourceId &&
1599
+ currentFocusState &&
1600
+ shareSourceId !== selectedId &&
1601
+ graph.hasNode(selectedId)
1602
+ ) {
1603
  const selectedStep = (graph.getNodeAttributes(selectedId) as DagNode).step;
1604
  // 归因范围:选中 token 之前的所有 token(prompt 节点 step=-1,生成节点 step < selectedStep)
1605
  const inAttributionRange =
1606
  selectedStep >= 0 &&
1607
  (attrs.step === -1 || (attrs.step >= 0 && attrs.step < selectedStep));
1608
  if (inAttributionRange) {
1609
+ const share = currentFocusState.nodeShareById.get(shareSourceId) ?? 0;
1610
  if (recursiveAttributionEnabled) {
1611
  const stay = share * (1 - nodePropagationMiRatio(attrs));
1612
  rowsBeforeInfo.push(
 
1789
  }
1790
  const displayLabel = visualizeSpecialChars(attr.raw, {
1791
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
1792
+ omitHexInCodePointLabel: true,
1793
  });
1794
  const srcNode: DagNode = {
1795
  id: srcId,
 
1846
  const g = textMeasure.appendGeneratedToken(token, [targetStart, targetEnd]);
1847
  const displayLabel = visualizeSpecialChars(token, {
1848
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
1849
+ omitHexInCodePointLabel: true,
1850
  });
1851
  const ciVisualScale = dagGeneratedNodeCiVisualScale(response.target_prob);
1852
  const gltrTooltipToken = frontendTokenFromGenAttrStep(step);
 
2138
  if (recursiveAttributionEnabled === enabled) return;
2139
  recursiveAttributionEnabled = enabled;
2140
  if (!enabled) recursiveEdgeAnimation.stopAnimation();
2141
+ paint();
2142
  refreshNodeLinkHighlight();
2143
  }
2144
 
 
2153
 
2154
  function setRecursiveEdgeBatchAnimationDirection(direction: DagRecursiveEdgeAnimationDirection): void {
2155
  recursiveEdgeAnimation.setDirection(direction);
2156
+ paint();
2157
  refreshNodeLinkHighlight();
2158
  }
2159
 
client/src/shared/prediction_attribution/causal_flow/tokenGenAttributionRunner.ts CHANGED
@@ -6,9 +6,10 @@ import type { AttributionApiResponse, PredictionAttributeModelVariant } from '..
6
  import type { PromptTokenSpan } from './genAttributeDagPreprocess';
7
  import type { CompletionFinishReason } from '../../cross/generationEndReasonLabel';
8
  import { fetchPredictionAttribute, fetchTokenize } from '../core/predictionAttributeClient';
 
9
 
10
- /** 与生成归因页(含 DAG)「Max tokens」输入框默认值一致 */
11
- export const TOKEN_GEN_MAX_TOKENS_DEFAULT = 100;
12
 
13
  function splitCodePointPrefix(text: string, prefixLength: number): { prefix: string; rest: string } | null {
14
  if (prefixLength < 0) return null;
 
6
  import type { PromptTokenSpan } from './genAttributeDagPreprocess';
7
  import type { CompletionFinishReason } from '../../cross/generationEndReasonLabel';
8
  import { fetchPredictionAttribute, fetchTokenize } from '../core/predictionAttributeClient';
9
+ import { DEFAULT_MAX_NEW_TOKENS } from '../../cross/maxNewTokensConfig';
10
 
11
+ /** @deprecated 使用 {@link DEFAULT_MAX_NEW_TOKENS} */
12
+ export const TOKEN_GEN_MAX_TOKENS_DEFAULT = DEFAULT_MAX_NEW_TOKENS;
13
 
14
  function splitCodePointPrefix(text: string, prefixLength: number): { prefix: string; rest: string } | null {
15
  if (prefixLength < 0) return null;
client/src/shared/vis/ToolTip.ts CHANGED
@@ -21,13 +21,13 @@ export type ToolTipOptions = {
21
  surprisalRowLabel?: string;
22
  /**
23
  * `parent-bottom-right`:`position:absolute`,贴在定位包含块(`offsetParent`)右下角(DAG Top‑K 作为 `#results` 直接子节点时即为 results 内侧右下)。
24
- * 该 HUD 模式不依赖锚点几何;面板应由页面 CSS(如 `.gen-attr-dag-topk-tooltip`)约束宽高与内部滚动而非按内容 shrink-wrap
25
  * 默认 `anchor`:沿用原有相对 token rect 的定位。
26
  */
27
  placement?: 'anchor' | 'parent-bottom-right';
28
  /**
29
  * false:面板不参与命中测试(`pointer-events: none`),避免盖住底层 SVG 时在节点上反复 `mouseleave`/闪动;
30
- * 同时不注册点击/触摸收起,且不接收内部滚动交互
31
  */
32
  pointerInteractive?: boolean;
33
  };
 
21
  surprisalRowLabel?: string;
22
  /**
23
  * `parent-bottom-right`:`position:absolute`,贴在定位包含块(`offsetParent`)右下角(DAG Top‑K 作为 `#results` 直接子节点时即为 results 内侧右下)。
24
+ * 该 HUD 模式不依赖锚点几何;面板应由页面 CSS(如 `.gen-attr-dag-topk-tooltip`)约束宽高,超出部分裁剪
25
  * 默认 `anchor`:沿用原有相对 token rect 的定位。
26
  */
27
  placement?: 'anchor' | 'parent-bottom-right';
28
  /**
29
  * false:面板不参与命中测试(`pointer-events: none`),避免盖住底层 SVG 时在节点上反复 `mouseleave`/闪动;
30
+ * 同时不注册点击/触摸收起。
31
  */
32
  pointerInteractive?: boolean;
33
  };
client/src/tests/prediction_attribution/genAttributeDagPropagationPlayback.test.ts ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 传播链播放计划 / 节奏单元测试
3
+ * 运行: cd client/src && npm run test:dagPropagationPlayback
4
+ */
5
+ import {
6
+ batchPlaybackDelayMs,
7
+ computePropagationGroupPacings,
8
+ FORWARD_PROMPT_FRAME_DWELL_MS,
9
+ propagationRunningMaxLookaheadForGroupCount,
10
+ } from '../../shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackPacing';
11
+ import { buildMaxNormalizedRenderStrengthByKey } from '../../shared/prediction_attribution/causal_flow/genAttributeDagEdgeRenderStrength';
12
+ import {
13
+ backwardSlideIncomingEdgeKeysForBatch,
14
+ buildPropagationPlaybackPlan,
15
+ maxShareInEdgeKeySet,
16
+ tgtIdFromEdgeKey,
17
+ } from '../../shared/prediction_attribution/causal_flow/genAttributeDagRecursiveEdgeAnimation';
18
+
19
+ /** 与重构前独立的 `buildMaxNormalizedRenderStrengthForEdgeKeySet` 同公式,用于回归对照。 */
20
+ function legacySubsetMaxNormalizedRender(
21
+ sharesByKey: Map<string, number>,
22
+ edgeKeys: ReadonlySet<string>,
23
+ maxOpacity = 1,
24
+ ): Map<string, number> {
25
+ let maxShare = 0;
26
+ for (const key of edgeKeys) {
27
+ const share = sharesByKey.get(key);
28
+ if (share != null && share > maxShare) maxShare = share;
29
+ }
30
+ const byKey = new Map<string, number>();
31
+ for (const key of edgeKeys) {
32
+ const share = sharesByKey.get(key);
33
+ if (share != null) {
34
+ byKey.set(
35
+ key,
36
+ buildMaxNormalizedRenderStrengthByKey(
37
+ new Map([[key, share]]),
38
+ maxOpacity,
39
+ maxShare,
40
+ ).get(key)!,
41
+ );
42
+ }
43
+ }
44
+ return byKey;
45
+ }
46
+
47
+ let passed = 0;
48
+ let failed = 0;
49
+
50
+ function assert(desc: string, cond: boolean) {
51
+ if (cond) {
52
+ console.log(` ✓ ${desc}`);
53
+ passed++;
54
+ } else {
55
+ console.error(` ✗ ${desc}`);
56
+ failed++;
57
+ }
58
+ }
59
+
60
+ function assertEq<T>(desc: string, actual: T, expected: T) {
61
+ assert(desc, actual === expected);
62
+ }
63
+
64
+ function assertClose(desc: string, actual: number, expected: number, eps = 1e-9) {
65
+ assert(desc, Math.abs(actual - expected) <= eps);
66
+ }
67
+
68
+ // ── lookahead ───────────────────────────────────────────────────────────────
69
+ console.log('1. propagationRunningMaxLookaheadForGroupCount');
70
+ assertEq('0 组 → 0', propagationRunningMaxLookaheadForGroupCount(0), 0);
71
+ assertEq('1 组 → MIN(2)', propagationRunningMaxLookaheadForGroupCount(1), 2);
72
+ assertEq('10 组 → max(2, round(1))', propagationRunningMaxLookaheadForGroupCount(10), 2);
73
+ assertEq('30 组 → 3', propagationRunningMaxLookaheadForGroupCount(30), 3);
74
+
75
+ // ── computePropagationGroupPacings ──────────────────────────────────────────
76
+ console.log('2. computePropagationGroupPacings');
77
+ {
78
+ const focusId = 'f';
79
+ const nodeShare = new Map([
80
+ ['f', 1],
81
+ ['a', 0.4],
82
+ ['b', 0.2],
83
+ ['c', 0.1],
84
+ ]);
85
+ const groups = [
86
+ { tgtIds: ['a'] },
87
+ { tgtIds: ['b'] },
88
+ { tgtIds: ['c'] },
89
+ ];
90
+ const { groupPreps, weightMax, weightTotal, runningMaxLookahead } = computePropagationGroupPacings(
91
+ groups,
92
+ nodeShare,
93
+ focusId,
94
+ );
95
+ assertClose('weightMax = 非焦点组内 max', weightMax, 0.4);
96
+ assertEq('3 组 lookahead', runningMaxLookahead, 2);
97
+ assert('每组有 propagationWeight', groupPreps.length === 3);
98
+ assert('weightTotal > 0', weightTotal > 0);
99
+ assertClose('首组 shareNorm = 1', groupPreps[0]!.shareNorm ?? -1, 1);
100
+ assert('running max 非降', groupPreps[1]!.runningMaxNorm >= groupPreps[0]!.runningMaxNorm);
101
+ assert(
102
+ 'propagationWeight ∈ [0,1]',
103
+ groupPreps.every((p) => p.propagationWeight >= 0 && p.propagationWeight <= 1),
104
+ );
105
+ }
106
+
107
+ {
108
+ const { groupPreps, weightTotal } = computePropagationGroupPacings(
109
+ [{ tgtIds: ['f', 'x'] }, { tgtIds: ['y'] }],
110
+ new Map([
111
+ ['f', 1],
112
+ ['x', 0.5],
113
+ ['y', 0.25],
114
+ ]),
115
+ 'f',
116
+ );
117
+ assert('含焦点组无 shareNorm', groupPreps[0]!.shareNorm === undefined);
118
+ assert('weightTotal 可累加', weightTotal >= 0);
119
+ }
120
+
121
+ // ── batchPlaybackDelayMs ────────────────────────────────────────────────────
122
+ console.log('3. batchPlaybackDelayMs');
123
+ const batch = { propagationWeight: 0.25 };
124
+ const plan = { weightTotal: 1 };
125
+
126
+ assertEq(
127
+ 'step:0 权重 → 0ms',
128
+ batchPlaybackDelayMs({ propagationWeight: 0 }, plan, { mode: 'step', stepMs: 500, totalS: 7 }),
129
+ 0,
130
+ );
131
+ assertEq(
132
+ 'step:权重连续',
133
+ batchPlaybackDelayMs(batch, plan, { mode: 'step', stepMs: 400, totalS: 7 }),
134
+ 100,
135
+ );
136
+
137
+ const totalPacing = { mode: 'total' as const, stepMs: 500, totalS: 7 };
138
+ const weightedBudgetMs = 7 * 1000 - FORWARD_PROMPT_FRAME_DWELL_MS;
139
+ assertEq(
140
+ 'total:按权重占比,预算已扣固定帧',
141
+ batchPlaybackDelayMs(batch, plan, totalPacing),
142
+ Math.round(0.25 * weightedBudgetMs),
143
+ );
144
+
145
+ assertEq(
146
+ 'total:权重 0 → 0ms',
147
+ batchPlaybackDelayMs({ propagationWeight: 0 }, plan, totalPacing),
148
+ 0,
149
+ );
150
+
151
+ // ── buildPropagationPlaybackPlan ────────────────────────────────────────────
152
+ console.log('4. buildPropagationPlaybackPlan');
153
+ {
154
+ const incoming = new Map<string, number>([
155
+ ['p->a', 0.3],
156
+ ['a->b', 0.2],
157
+ ['b->f', 0.5],
158
+ ]);
159
+ const offsetOf = (id: string) => ({ p: 0, a: 1, b: 2, f: 3 })[id] ?? 0;
160
+ const nodeShare = new Map([
161
+ ['f', 1],
162
+ ['b', 0.4],
163
+ ['a', 0.3],
164
+ ['p', 0.2],
165
+ ]);
166
+ const plan = buildPropagationPlaybackPlan(incoming, offsetOf, nodeShare, 'f');
167
+ assert('非空计划', plan != null);
168
+ if (plan != null) {
169
+ assertEq('批次数 = offset 组数', plan.batches.length, 3);
170
+ assert('播放序 offset 降序', plan.batches[0]!.groupOffset > plan.batches[1]!.groupOffset);
171
+ assertEq(
172
+ 'backward batch0 = 焦点侧单组(b->f)',
173
+ plan.backwardFrontierByBatchIndex[0]?.has('b->f') ?? false,
174
+ true,
175
+ );
176
+ assert(
177
+ 'forward batch0 前沿 = 全链',
178
+ plan.forwardFrontierByBatchIndex[0]?.size === 3,
179
+ );
180
+ const last = plan.batches.length - 1;
181
+ assertEq(
182
+ 'backward 末批前沿 = 全链',
183
+ plan.backwardFrontierByBatchIndex[last]?.size ?? 0,
184
+ 3,
185
+ );
186
+ for (const b of plan.batches) {
187
+ for (const key of b.edgeKeys) {
188
+ assertEq('edgeKey 可解析 tgt', tgtIdFromEdgeKey(key) != null, true);
189
+ }
190
+ }
191
+ const textOrder = [...plan.batches].sort((a, b) => a.groupOffset - b.groupOffset);
192
+ assertClose('文序首组 offset 最小', textOrder[0]!.groupOffset, 1);
193
+ }
194
+ }
195
+
196
+ assertEq('空入边 → null', buildPropagationPlaybackPlan(new Map(), () => 0, new Map(), 'f'), null);
197
+
198
+ // ── buildMaxNormalizedRenderStrengthByKey(重构前后等价 + 蓝/红分母)────────
199
+ console.log('5. buildMaxNormalizedRenderStrengthByKey');
200
+ {
201
+ const shares = new Map<string, number>([
202
+ ['p->a', 0.3],
203
+ ['a->b', 0.2],
204
+ ['b->f', 0.5],
205
+ ['x->y', 0.9],
206
+ ]);
207
+ const slideKeys = new Set(['a->b', 'p->a']);
208
+ const merged = buildMaxNormalizedRenderStrengthByKey(shares, 0.75, undefined, slideKeys);
209
+ const legacy = legacySubsetMaxNormalizedRender(shares, slideKeys, 0.75);
210
+ assert('onlyKeys 与重构前子集归一一致', merged.size === legacy.size);
211
+ for (const key of slideKeys) {
212
+ assertClose(`onlyKeys[${key}]`, merged.get(key) ?? -1, legacy.get(key) ?? -2);
213
+ }
214
+ assert('onlyKeys 不输出集合外键', !merged.has('b->f'));
215
+
216
+ const frontierMax = 0.5;
217
+ const blue = buildMaxNormalizedRenderStrengthByKey(shares, 0.8, frontierMax);
218
+ const red = buildMaxNormalizedRenderStrengthByKey(shares, 0.8, undefined, new Set(['a->b']));
219
+ assertClose('蓝入边用前沿 max', blue.get('a->b') ?? 0, 0.8 * (0.2 / 0.5));
220
+ assertClose('红入边用集合内 max', red.get('a->b') ?? 0, 0.8);
221
+ assert('红边强于同键蓝边(分母更小)', (red.get('a->b') ?? 0) > (blue.get('a->b') ?? 0));
222
+ }
223
+
224
+ // ── backwardSlideIncomingEdgeKeysForBatch + 播放计划前沿 ───────────────────
225
+ console.log('6. backwardSlideIncomingEdgeKeysForBatch');
226
+ {
227
+ const incoming = new Map<string, number>([
228
+ ['p->a', 0.3],
229
+ ['a->b', 0.2],
230
+ ['b->f', 0.5],
231
+ ]);
232
+ const offsetOf = (id: string) => ({ p: 0, a: 1, b: 2, f: 3 })[id] ?? 0;
233
+ const nodeShare = new Map([
234
+ ['f', 1],
235
+ ['b', 0.4],
236
+ ['a', 0.3],
237
+ ['p', 0.2],
238
+ ]);
239
+ const plan = buildPropagationPlaybackPlan(incoming, offsetOf, nodeShare, 'f');
240
+ assert('计划非空', plan != null);
241
+ if (plan != null) {
242
+ const batch0Keys = backwardSlideIncomingEdgeKeysForBatch(plan, 0, 'f');
243
+ assertEq('batch0 仅焦点入边', batch0Keys.size, 1);
244
+ assert('batch0 含 b->f', batch0Keys.has('b->f'));
245
+
246
+ const batch1 = plan.batches[1]!;
247
+ const batch1Keys = backwardSlideIncomingEdgeKeysForBatch(plan, 1, 'f');
248
+ assert('batch1 含指向 slide(b) 的 a->b', batch1Keys.has('a->b'));
249
+ for (const key of batch1Keys) {
250
+ assertEq('batch1 键的 tgt = slide', tgtIdFromEdgeKey(key), batch1.tgtId);
251
+ }
252
+
253
+ const frontier = plan.backwardFrontierByBatchIndex[1]!;
254
+ const frontierMax = maxShareInEdgeKeySet(incoming, frontier);
255
+ const mi = 0.6;
256
+ const redMap = buildMaxNormalizedRenderStrengthByKey(incoming, mi, undefined, batch1Keys);
257
+ const blueMap = buildMaxNormalizedRenderStrengthByKey(incoming, mi, frontierMax);
258
+ for (const key of batch1Keys) {
259
+ assert('红图仅含 slide 入边', redMap.has(key));
260
+ assertClose(`红[${key}] 集合内 max`, redMap.get(key) ?? 0, mi);
261
+ assert(
262
+ `红[${key}] ≥ 蓝(前沿 max 归一)`,
263
+ (redMap.get(key) ?? 0) >= (blueMap.get(key) ?? 0) - 1e-9,
264
+ );
265
+ }
266
+ }
267
+ }
268
+
269
+ // ── summary ─────────────────────────────────────────────────────────────────
270
+ console.log(`\n${passed} passed, ${failed} failed`);
271
+ if (failed > 0) process.exit(1);