dqy08 commited on
Commit
973dfd2
·
1 Parent(s): b9d399a

Causal Flow 下对称支持递归下游红边

Browse files
client/src/causal_flow.html CHANGED
@@ -308,7 +308,7 @@
308
  <span class="semantic-submode-group" id="gen_attr_dag_show_downstream_influence_group">
309
  <label class="semantic-submode-label">
310
  <input type="checkbox" id="gen_attr_dag_show_downstream_influence"
311
- title="When checked, direct attribution focus also shows outgoing edges from the selected or hovered token as downstream influence. Causal Flow Mode keeps showing upstream attribution chains only."
312
  data-i18n="title">
313
  Show downstream influence
314
  </label>
 
308
  <span class="semantic-submode-group" id="gen_attr_dag_show_downstream_influence_group">
309
  <label class="semantic-submode-label">
310
  <input type="checkbox" id="gen_attr_dag_show_downstream_influence"
311
+ title="When checked, focus also shows outgoing edges as downstream influence (red). Direct mode: one hop. Causal Flow Mode: recursive propagate as arrive × one-hop weight (sum at merges); display scales each source’s outs so strongest = arrive."
312
  data-i18n="title">
313
  Show downstream influence
314
  </label>
client/src/features/causal_flow/genAttributeBundledDemoManifest.generated.ts CHANGED
@@ -3,4 +3,4 @@
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":"过拟合|李白 将进酒","label":"过拟合|李白 将进酒","featured":"bold"},{"slug":"CN-EN翻译","label":"CN->EN | 翻译"},{"slug":"注意力的具象化","label":"Attention|注意力的具象化","featured":"bold"},{"slug":"注意力 诗 螺旋版","label":"Attention|注意力的具象化 螺旋版"},{"slug":"CoT|苏州所在省的省会","label":"CoT | 苏州所在省的省会","featured":"bold"},{"slug":"CoT|苏州所在省的省会城市里最高的山","label":"CoT | 苏州所在省的省会的最高的山"},{"slug":"CoT|反向传播归因动画","label":"CoT | 思维链的反向归因过程动画","featured":"bold"},{"slug":"CoT|最长两条河的入海口|草稿链","label":"CoT | 最长两条河的入海口|草稿链","featured":"bold"},{"slug":"CoT|多“跳”推理","label":"CoT | 多跳推理中“跳”的具象化"},{"slug":"strawberry里有几个r","label":"CoT | strawberry里有几个r"},{"slug":"Tool-call|北京天气","label":"Tool-call|北京天气","featured":"bold"},{"slug":"Tool | 马斯克","label":"CoT+Tool|SpaceX 马斯克","featured":"bold"},{"slug":"闪电效果 | 马斯克","label":"闪电效果 | 马斯克"},{"slug":"Attention","label":"Attention"},{"slug":"注意力的算力开销对比","label":"注意力的算力开销对比"}];
 
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":"过拟合|李白 将进酒","label":"过拟合|李白 将进酒","featured":"bold"},{"slug":"CN-EN翻译","label":"CN->EN | 翻译"},{"slug":"注意力的具象化","label":"Attention|注意力的具象化","featured":"bold"},{"slug":"注意力 诗 螺旋版","label":"Attention|注意力的具象化 螺旋版"},{"slug":"CoT|苏州所在省的省会","label":"CoT | 苏州所在省的省会","featured":"bold"},{"slug":"CoT|苏州所在省的省会城市里最高的山","label":"CoT | 苏州所在省的省会的最高的山"},{"slug":"CoT|反向传播归因动画","label":"CoT | 思维链的反向归因过程动画","featured":"bold"},{"slug":"CoT|最长两条河的入海口|草稿链","label":"CoT | 最长两条河的入海口|草稿链","featured":"bold"},{"slug":"CoT|多“跳”推理","label":"CoT | 多跳推理中“跳”的具象化"},{"slug":"strawberry里有几个r","label":"CoT | strawberry里有几个r"},{"slug":"Tool-call|北京天气","label":"Tool-call|北京天气","featured":"bold"},{"slug":"Tool | 马斯克","label":"CoT+Tool|SpaceX 马斯克","featured":"bold"},{"slug":"闪电效果 | 马斯克","label":"闪电效果 | 马斯克"},{"slug":"Attention","label":"Attention"}];
client/src/pages/causal_flow/index.ts CHANGED
@@ -1155,7 +1155,8 @@ function applyDagRecursiveAttributionSubmodeUi(): void {
1155
  const recursive = dagRecursiveAttributionInput?.checked ?? false;
1156
  const forward = recursive && currentDagRecursiveEdgeAnimationDirection() === 'forward';
1157
  if (dagShowDownstreamInfluenceGroup) {
1158
- dagShowDownstreamInfluenceGroup.hidden = recursive;
 
1159
  }
1160
  if (dagRecursiveEdgeAnimationDirectionGroup) {
1161
  dagRecursiveEdgeAnimationDirectionGroup.hidden = !recursive;
 
1155
  const recursive = dagRecursiveAttributionInput?.checked ?? false;
1156
  const forward = recursive && currentDagRecursiveEdgeAnimationDirection() === 'forward';
1157
  if (dagShowDownstreamInfluenceGroup) {
1158
+ // 直接模式与因果流 forward 均显示;backward 仅上游蓝链,隐藏下游选项。
1159
+ dagShowDownstreamInfluenceGroup.hidden = recursive && !forward;
1160
  }
1161
  if (dagRecursiveEdgeAnimationDirectionGroup) {
1162
  dagRecursiveEdgeAnimationDirectionGroup.hidden = !recursive;
client/src/shared/lang/translations.ts CHANGED
@@ -179,8 +179,8 @@ export const translations: Translations = {
179
  '未勾选:原始直接归因(仅一跳前驱,默认)。勾选:因果流模式(↯)— 从焦点 token 向上追溯到信息来源。来源:prompt;高惊讶或 teacher-forced 的生成 token(MI 衰减可截断链)。传导:高置信中间 token,归因穿过。蓝边:传播份额;节点环:归因停留(解释落点处更强)。建议与「向高惊讶目标衰减归因」配合使用。DAG 上:↯ 播放焦点传播链;无焦点时 ▶ 步进重放生成过程。',
180
  'Direction for focus-chain batch animation when you press propagation play (↯) on the DAG with a focused token in Causal Flow Mode.':
181
  '因果流模式下,对已聚焦 token 在 DAG 上按传播播放(↯)时,焦点传播链分批动画的方向。',
182
- 'When checked, direct attribution focus also shows outgoing edges from the selected or hovered token as downstream influence. Causal Flow Mode keeps showing upstream attribution chains only.':
183
- '勾选后,直接归因焦点下还会显示从选中/悬浮 token 出发的下游影响出边。因果流模式仍只显示向上游归因链。',
184
  'Total duration or per-step simulated cost. DAG step replay (▶) divides evenly or uses a fixed per-token cost; propagation chain animation (↯) scales each frame by attribution weight.':
185
  '总时长或单步模拟开销。DAG 步进重放(▶)按步均分或固定单步开销;传播链动画(↯)按各层归因权重缩放每帧模拟开销。',
186
  'Total seconds. DAG step replay (▶) divides evenly across steps; propagation chain (↯) splits by layer weight. Saved locally; applied when you press play or select a focus node.':
 
179
  '未勾选:原始直接归因(仅一跳前驱,默认)。勾选:因果流模式(↯)— 从焦点 token 向上追溯到信息来源。来源:prompt;高惊讶或 teacher-forced 的生成 token(MI 衰减可截断链)。传导:高置信中间 token,归因穿过。蓝边:传播份额;节点环:归因停留(解释落点处更强)。建议与「向高惊讶目标衰减归因」配合使用。DAG 上:↯ 播放焦点传播链;无焦点时 ▶ 步进重放生成过程。',
180
  'Direction for focus-chain batch animation when you press propagation play (↯) on the DAG with a focused token in Causal Flow Mode.':
181
  '因果流模式下,对已聚焦 token 在 DAG 上按传播播放(↯)时,焦点传播链分批动画的方向。',
182
+ 'When checked, focus also shows outgoing edges as downstream influence (red). Direct mode: one hop. Causal Flow Mode: recursive propagate as arrive × one-hop weight (sum at merges); display scales each source’s outs so strongest = arrive.':
183
+ '勾选后,焦点下额外显示下游影响出边(红)直接模式:一跳。因果流模式:递归——传播为 arrive × 一跳边权(汇合相加);显示时将每个源出边缩放到最强 = arrive。',
184
  'Total duration or per-step simulated cost. DAG step replay (▶) divides evenly or uses a fixed per-token cost; propagation chain animation (↯) scales each frame by attribution weight.':
185
  '总时长或单步模拟开销。DAG 步进重放(▶)按步均分或固定单步开销;传播链动画(↯)按各层归因权重缩放每帧模拟开销。',
186
  'Total seconds. DAG step replay (▶) divides evenly across steps; propagation chain (↯) splits by layer weight. Saved locally; applied when you press play or select a focus node.':
client/src/shared/prediction_attribution/causal_flow/genAttributeDagEdgeRenderStrength.ts CHANGED
@@ -190,3 +190,35 @@ export function buildMaxNormalizedRenderStrengthByKey(
190
  }
191
  return byKey;
192
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  }
191
  return byKey;
192
  }
193
+
194
+ /**
195
+ * 下游红边渲染:每源出边 `display = arrive × (raw / maxRaw)`,故最强出边 = arrive;
196
+ * 再对 display 做全表 max 归一得到 opacity。传播原值仍在 `sharesByKey`(tooltip)。
197
+ */
198
+ export function buildDownstreamArriveScaledRenderStrengthByKey(
199
+ sharesByKey: Map<string, number>,
200
+ arriveById: ReadonlyMap<string, number>,
201
+ maxOpacity = 1,
202
+ ): Map<string, number> {
203
+ const maxRawBySource = new Map<string, number>();
204
+ for (const [key, share] of sharesByKey) {
205
+ if (!(share > 0) || !Number.isFinite(share)) continue;
206
+ const sep = key.indexOf('->');
207
+ if (sep <= 0) continue;
208
+ const src = key.slice(0, sep);
209
+ const prev = maxRawBySource.get(src) ?? 0;
210
+ if (share > prev) maxRawBySource.set(src, share);
211
+ }
212
+ const displayByKey = new Map<string, number>();
213
+ for (const [key, share] of sharesByKey) {
214
+ const sep = key.indexOf('->');
215
+ if (sep <= 0) continue;
216
+ const src = key.slice(0, sep);
217
+ const maxRaw = maxRawBySource.get(src) ?? 0;
218
+ if (maxRaw <= 0) continue;
219
+ const arrive = arriveById.get(src) ?? 0;
220
+ if (!(arrive > 0) || !Number.isFinite(arrive)) continue;
221
+ displayByKey.set(key, arrive * (share / maxRaw));
222
+ }
223
+ return buildMaxNormalizedRenderStrengthByKey(displayByKey, maxOpacity);
224
+ }
client/src/shared/prediction_attribution/causal_flow/genAttributeDagFocusAttribution.ts CHANGED
@@ -24,6 +24,12 @@ export type DagFocusAttributionLink = {
24
  export type ComputeFocusAttributionOptions = {
25
  maxIncomingDepth: number;
26
  includeDownstreamInfluence: boolean;
 
 
 
 
 
 
27
  allowedEdgeKeys?: ReadonlySet<string>;
28
  /** 与「Decay attribution to high-surprisal targets」一致;默认 false。 */
29
  decayAttributionToHighSurprisalTarget?: boolean;
@@ -98,8 +104,66 @@ function compareNodesByOffsetDesc<T extends DagFocusAttributionNode>(
98
  return nb.end - na.end;
99
  }
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  /**
102
  * 从焦点沿入边反向传播归因份额(`start ≤ focus.start` 的节点按 offset 降序单遍)。
 
103
  * 前提:每条边 `src → tgt` 满足 `src.start < tgt.start`(context 前缀 + 合成边)。
104
  */
105
  export function computeFocusAttributionState<T extends DagFocusAttributionNode>(
@@ -114,6 +178,7 @@ export function computeFocusAttributionState<T extends DagFocusAttributionNode>(
114
  const activeNodeIds = new Set<string>([focusId]);
115
  const incomingEdgeShareByKey = new Map<string, number>();
116
  const downstreamEdgeStrengthByKey = new Map<string, number>();
 
117
  const nodeShareById = new Map<string, number>([[focusId, 1]]);
118
  const remainingDepthByNodeId = new Map<string, number>([[focusId, options.maxIncomingDepth]]);
119
 
@@ -152,14 +217,24 @@ export function computeFocusAttributionState<T extends DagFocusAttributionNode>(
152
  }
153
 
154
  if (options.includeDownstreamInfluence) {
155
- graph.forEachOutEdge(focusId, (_edgeId, edgeAttrs, srcId, tgtId) => {
156
- const link = edgeAttrs as unknown as DagFocusAttributionLink;
157
- const strength = directAttributionStrength(link, decay);
158
- if (strength < DAG_MIN_ATTRIBUTION_SHARE) return;
159
- downstreamEdgeStrengthByKey.set(dagLinkEndpointKey(srcId, tgtId), strength);
160
- activeNodeIds.add(tgtId);
161
- });
 
 
 
 
162
  }
163
 
164
- return { activeNodeIds, incomingEdgeShareByKey, downstreamEdgeStrengthByKey, nodeShareById };
 
 
 
 
 
 
165
  }
 
24
  export type ComputeFocusAttributionOptions = {
25
  maxIncomingDepth: number;
26
  includeDownstreamInfluence: boolean;
27
+ /**
28
+ * 下游影响出边深度:`1` = 仅焦点一跳出边;`Infinity` = 递归
29
+ *(传播:`arrive ×` 一跳边权,汇合 sum;显示:每源最强出边刻度 = arrive)。
30
+ * 仅在 `includeDownstreamInfluence` 时有效;默认 `1`。
31
+ */
32
+ maxOutgoingDepth?: number;
33
  allowedEdgeKeys?: ReadonlySet<string>;
34
  /** 与「Decay attribution to high-surprisal targets」一致;默认 false。 */
35
  decayAttributionToHighSurprisalTarget?: boolean;
 
104
  return nb.end - na.end;
105
  }
106
 
107
+ /** 下游影响处理序:offset 升序(因果边单调 ⇒ 先上游后下游)。 */
108
+ function compareNodesByOffsetAsc<T extends DagFocusAttributionNode>(
109
+ graph: DirectedGraph<T>,
110
+ a: string,
111
+ b: string,
112
+ ): number {
113
+ return compareNodesByOffsetDesc(graph, b, a);
114
+ }
115
+
116
+ /**
117
+ * 从焦点沿出边向前累积影响强度(非份额:汇合 sum、不归 1、不乘传导 prop)。
118
+ * `strength = arrive × w`(`w` = {@link directAttributionStrength});
119
+ * 渲染侧另将每源出边缩放到最强 = arrive(不改本 map)。
120
+ */
121
+ function accumulateDownstreamInfluence<T extends DagFocusAttributionNode>(
122
+ graph: DirectedGraph<T>,
123
+ focusId: string,
124
+ focusStart: number,
125
+ maxOutgoingDepth: number,
126
+ decay: boolean,
127
+ allowedEdgeKeys: ReadonlySet<string> | undefined,
128
+ activeNodeIds: Set<string>,
129
+ downstreamEdgeStrengthByKey: Map<string, number>,
130
+ arriveById: Map<string, number>,
131
+ ): void {
132
+ const remainingDepthByNodeId = new Map<string, number>([[focusId, maxOutgoingDepth]]);
133
+
134
+ const processOrder = graph
135
+ .mapNodes((id) => id)
136
+ .filter((id) => (graph.getNodeAttributes(id) as T).start >= focusStart)
137
+ .sort((a, b) => compareNodesByOffsetAsc(graph, a, b));
138
+
139
+ for (const nodeId of processOrder) {
140
+ const arrive = arriveById.get(nodeId) ?? 0;
141
+ const remainingDepth = remainingDepthByNodeId.get(nodeId) ?? 0;
142
+ if (arrive < DAG_MIN_ATTRIBUTION_SHARE || remainingDepth <= 0) continue;
143
+
144
+ graph.forEachOutEdge(nodeId, (_edgeId, edgeAttrs, srcId, tgtId) => {
145
+ const link = edgeAttrs as unknown as DagFocusAttributionLink;
146
+ const edgeKey = dagLinkEndpointKey(srcId, tgtId);
147
+ if (allowedEdgeKeys && !allowedEdgeKeys.has(edgeKey)) return;
148
+
149
+ const edgeWeight = directAttributionStrength(link, decay);
150
+ const strength = arrive * edgeWeight;
151
+ if (strength < DAG_MIN_ATTRIBUTION_SHARE) return;
152
+
153
+ downstreamEdgeStrengthByKey.set(edgeKey, strength);
154
+ activeNodeIds.add(tgtId);
155
+ arriveById.set(tgtId, (arriveById.get(tgtId) ?? 0) + strength);
156
+ remainingDepthByNodeId.set(
157
+ tgtId,
158
+ Math.max(remainingDepthByNodeId.get(tgtId) ?? 0, remainingDepth - 1),
159
+ );
160
+ });
161
+ }
162
+ }
163
+
164
  /**
165
  * 从焦点沿入边反向传播归因份额(`start ≤ focus.start` 的节点按 offset 降序单遍)。
166
+ * 可选:沿出边向前累积下游影响强度(见 {@link accumulateDownstreamInfluence})。
167
  * 前提:每条边 `src → tgt` 满足 `src.start < tgt.start`(context 前缀 + 合成边)。
168
  */
169
  export function computeFocusAttributionState<T extends DagFocusAttributionNode>(
 
178
  const activeNodeIds = new Set<string>([focusId]);
179
  const incomingEdgeShareByKey = new Map<string, number>();
180
  const downstreamEdgeStrengthByKey = new Map<string, number>();
181
+ const downstreamArriveById = new Map<string, number>([[focusId, 1]]);
182
  const nodeShareById = new Map<string, number>([[focusId, 1]]);
183
  const remainingDepthByNodeId = new Map<string, number>([[focusId, options.maxIncomingDepth]]);
184
 
 
217
  }
218
 
219
  if (options.includeDownstreamInfluence) {
220
+ accumulateDownstreamInfluence(
221
+ graph,
222
+ focusId,
223
+ focusStart,
224
+ options.maxOutgoingDepth ?? 1,
225
+ decay,
226
+ options.allowedEdgeKeys,
227
+ activeNodeIds,
228
+ downstreamEdgeStrengthByKey,
229
+ downstreamArriveById,
230
+ );
231
  }
232
 
233
+ return {
234
+ activeNodeIds,
235
+ incomingEdgeShareByKey,
236
+ downstreamEdgeStrengthByKey,
237
+ downstreamArriveById,
238
+ nodeShareById,
239
+ };
240
  }
client/src/shared/prediction_attribution/causal_flow/genAttributeDagRecursiveEdgeAnimation.ts CHANGED
@@ -62,6 +62,8 @@ export type DagFocusAttributionState = {
62
  activeNodeIds: Set<string>;
63
  incomingEdgeShareByKey: Map<string, number>;
64
  downstreamEdgeStrengthByKey: Map<string, number>;
 
 
65
  nodeShareById: Map<string, number>;
66
  };
67
 
 
62
  activeNodeIds: Set<string>;
63
  incomingEdgeShareByKey: Map<string, number>;
64
  downstreamEdgeStrengthByKey: Map<string, number>;
65
+ /** 下游影响传播的节点到达量(焦点为 1);供红边渲染将每源最强出边刻度钉在 arrive。 */
66
+ downstreamArriveById: Map<string, number>;
67
  nodeShareById: Map<string, number>;
68
  };
69
 
client/src/shared/prediction_attribution/causal_flow/genAttributeDagView.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
  DAG_NODE_STROKE_OPACITY_BASE,
21
  } from './genAttributeDagEdgeDisplay';
22
  import {
 
23
  buildMaxNormalizedRenderStrengthByKey,
24
  DAG_LIGHTNING_SLOW_MO_DEFAULT,
25
  DAG_LIGHTNING_THRESHOLD_TAU_DEFAULT,
@@ -645,7 +646,7 @@ export type GenAttributeDagHandle = {
645
  enterLightningTauPreview(): void;
646
  /** 结束 {@link enterLightningTauPreview}。 */
647
  exitLightningTauPreview(): void;
648
- /** 是否在直接归因焦点上额外展示从焦点出发的下游影响出边。 */
649
  setShowDownstreamInfluence(show: boolean): void;
650
  /** prompt 层节点是否已注入(即 {@link setPromptTokenSpans} 至少成功添加过一个节点) */
651
  hasPromptSpans(): boolean;
@@ -777,7 +778,7 @@ type DagLinkHighlightDisplay = {
777
  recursiveAttributionShare?: number;
778
  };
779
 
780
- /** 焦点下边的视觉规则:传播归因向上原因链”,直接看“一跳关系 + 可选下游影响。 */
781
  function resolveDagLinkHighlightDisplay(
782
  d: DagLink,
783
  edgeKey: string,
@@ -895,7 +896,7 @@ export type InitGenAttributeDagViewOptions = {
895
  getReplayPacing?: () => DagRecursiveEdgeReplayPacing;
896
  /** forward 是否 slide 有 share 的 prompt 等节点;默认 `{ forwardSlideSharedNodes: false }`。 */
897
  getPropagationPlaybackOptions?: () => DagPropagationPlaybackOptions;
898
- /** 直接归因模式下是否展示从焦点出发的下游影响出边;默认 `false`。 */
899
  showDownstreamInfluence?: boolean;
900
  /** 边 Top-P 覆盖阈值(候选池内累计份额);默认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT}。 */
901
  edgeTopPCoverage?: number;
@@ -1652,10 +1653,20 @@ export function initGenAttributeDagView(
1652
 
1653
  function refreshNodeLinkHighlight(): void {
1654
  const focusId = effectiveFocusId();
 
 
 
 
 
 
 
 
 
1655
  const focusState = focusId
1656
  ? computeFocusAttributionState(graph, incomingLinksByTarget, focusId, {
1657
  maxIncomingDepth: recursiveAttributionEnabled ? Number.POSITIVE_INFINITY : 1,
1658
- includeDownstreamInfluence: !recursiveAttributionEnabled && showDownstreamInfluence,
 
1659
  decayAttributionToHighSurprisalTarget: dagDecayAttributionToHighSurprisalTargetEnabled,
1660
  })
1661
  : null;
@@ -1707,9 +1718,12 @@ export function initGenAttributeDagView(
1707
  useAnimationIncomingHighlight ? animOverlay.incomingMaxForRender : undefined,
1708
  );
1709
  const downstreamHighlightRenderByKey =
1710
- focusState == null
1711
  ? new Map<string, number>()
1712
- : buildMaxNormalizedRenderStrengthByKey(focusState.downstreamEdgeStrengthByKey);
 
 
 
1713
  grayRenderCache ??= buildGrayRenderStrengthByEdgeKey(graph, incomingLinksByTarget);
1714
  const grayRenderByKey = grayRenderCache;
1715
  const {
@@ -1764,7 +1778,6 @@ export function initGenAttributeDagView(
1764
  );
1765
  const nodeDisplay = (d: DagNode): string | null =>
1766
  hideExcludedTokens && nodeLowVisReasonById.get(d.id) != null ? 'none' : null;
1767
- const propagationPlaybackPhase = recursiveEdgeAnimation.getPlaybackPhase();
1768
  const lightningEffectEnabled = getPropagationPlaybackOptions().lightningEffect;
1769
  const lightningPreviewActive =
1770
  lightningEffectEnabled &&
@@ -2021,7 +2034,8 @@ export function initGenAttributeDagView(
2021
  const incident =
2022
  linkFocusState != null &&
2023
  (linkFocusState.incomingEdgeShareByKey.has(edgeKey) ||
2024
- (focusState?.downstreamEdgeStrengthByKey.has(edgeKey) ?? false));
 
2025
  const parent = incident ? linkGFront : linkG;
2026
  const parentNode = parent.node()!;
2027
  if (this.parentNode !== parentNode) {
 
20
  DAG_NODE_STROKE_OPACITY_BASE,
21
  } from './genAttributeDagEdgeDisplay';
22
  import {
23
+ buildDownstreamArriveScaledRenderStrengthByKey,
24
  buildMaxNormalizedRenderStrengthByKey,
25
  DAG_LIGHTNING_SLOW_MO_DEFAULT,
26
  DAG_LIGHTNING_THRESHOLD_TAU_DEFAULT,
 
646
  enterLightningTauPreview(): void;
647
  /** 结束 {@link enterLightningTauPreview}。 */
648
  exitLightningTauPreview(): void;
649
+ /** 是否在焦点上额外展示下游影响出边(直接一跳 / 因果流递归)。 */
650
  setShowDownstreamInfluence(show: boolean): void;
651
  /** prompt 层节点是否已注入(即 {@link setPromptTokenSpans} 至少成功添加过一个节点) */
652
  hasPromptSpans(): boolean;
 
778
  recursiveAttributionShare?: number;
779
  };
780
 
781
+ /** 焦点下边的视觉规则:传播蓝边看向上原因链可选红边看下游影响(一跳或递归)。 */
782
  function resolveDagLinkHighlightDisplay(
783
  d: DagLink,
784
  edgeKey: string,
 
896
  getReplayPacing?: () => DagRecursiveEdgeReplayPacing;
897
  /** forward 是否 slide 有 share 的 prompt 等节点;默认 `{ forwardSlideSharedNodes: false }`。 */
898
  getPropagationPlaybackOptions?: () => DagPropagationPlaybackOptions;
899
+ /** 是否展示从焦点出发的下游影响出边(直接一跳 / 因果流递归);默认 `false`。 */
900
  showDownstreamInfluence?: boolean;
901
  /** 边 Top-P 覆盖阈值(候选池内累计份额);默认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT}。 */
902
  edgeTopPCoverage?: number;
 
1653
 
1654
  function refreshNodeLinkHighlight(): void {
1655
  const focusId = effectiveFocusId();
1656
+ const propagationPlaybackPhase = recursiveEdgeAnimation.getPlaybackPhase();
1657
+ const includeDownstreamInfluence =
1658
+ showDownstreamInfluence &&
1659
+ !(
1660
+ recursiveAttributionEnabled &&
1661
+ (recursiveEdgeAnimation.getDirection() === 'backward' ||
1662
+ propagationPlaybackPhase === 'playing' ||
1663
+ propagationPlaybackPhase === 'paused')
1664
+ );
1665
  const focusState = focusId
1666
  ? computeFocusAttributionState(graph, incomingLinksByTarget, focusId, {
1667
  maxIncomingDepth: recursiveAttributionEnabled ? Number.POSITIVE_INFINITY : 1,
1668
+ includeDownstreamInfluence,
1669
+ maxOutgoingDepth: recursiveAttributionEnabled ? Number.POSITIVE_INFINITY : 1,
1670
  decayAttributionToHighSurprisalTarget: dagDecayAttributionToHighSurprisalTargetEnabled,
1671
  })
1672
  : null;
 
1718
  useAnimationIncomingHighlight ? animOverlay.incomingMaxForRender : undefined,
1719
  );
1720
  const downstreamHighlightRenderByKey =
1721
+ focusState == null || !includeDownstreamInfluence
1722
  ? new Map<string, number>()
1723
+ : buildDownstreamArriveScaledRenderStrengthByKey(
1724
+ focusState.downstreamEdgeStrengthByKey,
1725
+ focusState.downstreamArriveById,
1726
+ );
1727
  grayRenderCache ??= buildGrayRenderStrengthByEdgeKey(graph, incomingLinksByTarget);
1728
  const grayRenderByKey = grayRenderCache;
1729
  const {
 
1778
  );
1779
  const nodeDisplay = (d: DagNode): string | null =>
1780
  hideExcludedTokens && nodeLowVisReasonById.get(d.id) != null ? 'none' : null;
 
1781
  const lightningEffectEnabled = getPropagationPlaybackOptions().lightningEffect;
1782
  const lightningPreviewActive =
1783
  lightningEffectEnabled &&
 
2034
  const incident =
2035
  linkFocusState != null &&
2036
  (linkFocusState.incomingEdgeShareByKey.has(edgeKey) ||
2037
+ (includeDownstreamInfluence &&
2038
+ (focusState?.downstreamEdgeStrengthByKey.has(edgeKey) ?? false)));
2039
  const parent = incident ? linkGFront : linkG;
2040
  const parentNode = parent.node()!;
2041
  if (this.parentNode !== parentNode) {
client/src/tests/prediction_attribution/genAttributeDagFocusAttribution.test.ts CHANGED
@@ -191,5 +191,170 @@ console.log('3. 直接归因(一跳)不含 tool_call');
191
  }
192
  }
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  console.log(`\n${passed} passed, ${failed} failed`);
195
  if (failed > 0) process.exit(1);
 
191
  }
192
  }
193
 
194
+ /**
195
+ * F[0,1) → A[1,2) → B[2,3)
196
+ * ↘ C[2,4)
197
+ * 传播:FA=0.4;A 出边 AB=0.5、AC=0.3 → 二跳 0.4×0.5 / 0.4×0.3(显示侧另做 per-source max 归一)
198
+ */
199
+ function buildDownstreamChainFixture(): {
200
+ graph: DirectedGraph<DagNodeAttrs>;
201
+ incomingLinksByTarget: Map<string, DagLink[]>;
202
+ ids: { f: string; a: string; b: string; c: string };
203
+ } {
204
+ const f = '0_1';
205
+ const a = '1_2';
206
+ const b = '2_3';
207
+ const c = '2_4';
208
+ const graph = new DirectedGraph<DagNodeAttrs>();
209
+ graph.addNode(f, mkNode(f, 'F', 0, 0, 1));
210
+ graph.addNode(a, mkNode(a, 'A', 1, 1, 2));
211
+ graph.addNode(b, mkNode(b, 'B', 2, 2, 3));
212
+ graph.addNode(c, mkNode(c, 'C', 3, 2, 4));
213
+ const incomingLinksByTarget = new Map<string, DagLink[]>();
214
+ addEdge(graph, incomingLinksByTarget, {
215
+ source: f,
216
+ target: a,
217
+ attributionShare: 0.4,
218
+ normalizedScore: 0.4,
219
+ });
220
+ addEdge(graph, incomingLinksByTarget, {
221
+ source: a,
222
+ target: b,
223
+ attributionShare: 0.5,
224
+ normalizedScore: 0.5,
225
+ });
226
+ addEdge(graph, incomingLinksByTarget, {
227
+ source: a,
228
+ target: c,
229
+ attributionShare: 0.3,
230
+ normalizedScore: 0.3,
231
+ });
232
+ return { graph, incomingLinksByTarget, ids: { f, a, b, c } };
233
+ }
234
+
235
+ console.log('4. 下游影响一跳:仅焦点出边');
236
+ {
237
+ const { graph, incomingLinksByTarget, ids } = buildDownstreamChainFixture();
238
+ const state = computeFocusAttributionState(graph, incomingLinksByTarget, ids.f, {
239
+ maxIncomingDepth: 1,
240
+ includeDownstreamInfluence: true,
241
+ maxOutgoingDepth: 1,
242
+ decayAttributionToHighSurprisalTarget: false,
243
+ });
244
+ assert('state 非空', state != null);
245
+ if (state) {
246
+ assertClose(
247
+ '一跳 F→A = 0.4',
248
+ state.downstreamEdgeStrengthByKey.get(`${ids.f}->${ids.a}`) ?? 0,
249
+ 0.4,
250
+ );
251
+ assert('一跳无 A→B', !state.downstreamEdgeStrengthByKey.has(`${ids.a}->${ids.b}`));
252
+ assert('一跳无 A→C', !state.downstreamEdgeStrengthByKey.has(`${ids.a}->${ids.c}`));
253
+ assertHas('active 含 A', state.activeNodeIds, ids.a);
254
+ assert('active 不含 B', !state.activeNodeIds.has(ids.b));
255
+ }
256
+ }
257
+
258
+ console.log('5. 下游影响递归:arrive × 一跳出边,汇合 sum');
259
+ {
260
+ const { graph, incomingLinksByTarget, ids } = buildDownstreamChainFixture();
261
+ const state = computeFocusAttributionState(graph, incomingLinksByTarget, ids.f, {
262
+ maxIncomingDepth: Number.POSITIVE_INFINITY,
263
+ includeDownstreamInfluence: true,
264
+ maxOutgoingDepth: Number.POSITIVE_INFINITY,
265
+ decayAttributionToHighSurprisalTarget: false,
266
+ });
267
+ assert('state 非空', state != null);
268
+ if (state) {
269
+ assertClose(
270
+ 'F→A = 0.4',
271
+ state.downstreamEdgeStrengthByKey.get(`${ids.f}->${ids.a}`) ?? 0,
272
+ 0.4,
273
+ );
274
+ assertClose(
275
+ 'A→B = 0.4×0.5',
276
+ state.downstreamEdgeStrengthByKey.get(`${ids.a}->${ids.b}`) ?? 0,
277
+ 0.2,
278
+ );
279
+ assertClose(
280
+ 'A→C = 0.4×0.3',
281
+ state.downstreamEdgeStrengthByKey.get(`${ids.a}->${ids.c}`) ?? 0,
282
+ 0.12,
283
+ );
284
+ assertHas('active 含 B', state.activeNodeIds, ids.b);
285
+ assertHas('active 含 C', state.activeNodeIds, ids.c);
286
+ }
287
+ }
288
+
289
+ console.log('6. 下游递归多路汇合 sum;高惊讶只淡化入边不挡外扩');
290
+ {
291
+ // F → A (0.5), F → A2 (0.5), A→B (1), A2→B (1);再 B→C (0.8)
292
+ // arrive(B)=0.5+0.5=1;B→C = 1×0.8=0.8(即便 B 高惊讶也不乘 prop)
293
+ const f = '0_1';
294
+ const a = '1_2';
295
+ const a2 = '1_3';
296
+ const b = '3_4';
297
+ const c = '4_5';
298
+ const graph = new DirectedGraph<DagNodeAttrs>();
299
+ graph.addNode(f, mkNode(f, 'F', 0, 0, 1));
300
+ graph.addNode(a, mkNode(a, 'A', 1, 1, 2));
301
+ graph.addNode(a2, mkNode(a2, 'A2', 2, 1, 3));
302
+ const bNode = mkNode(b, 'B', 3, 3, 4);
303
+ bNode.dagTargetProb = 0.01; // 高惊讶
304
+ graph.addNode(b, bNode);
305
+ graph.addNode(c, mkNode(c, 'C', 4, 4, 5));
306
+ const incomingLinksByTarget = new Map<string, DagLink[]>();
307
+ addEdge(graph, incomingLinksByTarget, {
308
+ source: f,
309
+ target: a,
310
+ attributionShare: 0.5,
311
+ mutualInformationRatio: 1,
312
+ });
313
+ addEdge(graph, incomingLinksByTarget, {
314
+ source: f,
315
+ target: a2,
316
+ attributionShare: 0.5,
317
+ mutualInformationRatio: 1,
318
+ });
319
+ addEdge(graph, incomingLinksByTarget, {
320
+ source: a,
321
+ target: b,
322
+ attributionShare: 1,
323
+ mutualInformationRatio: 0.2, // 入 B 淡化
324
+ });
325
+ addEdge(graph, incomingLinksByTarget, {
326
+ source: a2,
327
+ target: b,
328
+ attributionShare: 1,
329
+ mutualInformationRatio: 0.2,
330
+ });
331
+ addEdge(graph, incomingLinksByTarget, {
332
+ source: b,
333
+ target: c,
334
+ attributionShare: 0.8,
335
+ mutualInformationRatio: 1,
336
+ });
337
+
338
+ const state = computeFocusAttributionState(graph, incomingLinksByTarget, f, {
339
+ maxIncomingDepth: Number.POSITIVE_INFINITY,
340
+ includeDownstreamInfluence: true,
341
+ maxOutgoingDepth: Number.POSITIVE_INFINITY,
342
+ decayAttributionToHighSurprisalTarget: true,
343
+ });
344
+ assert('state 非空', state != null);
345
+ if (state) {
346
+ // A→B = 0.5×(1×0.2)=0.1;A2→B 同理 0.1;arrive(B)=0.2
347
+ assertClose('A→B faded', state.downstreamEdgeStrengthByKey.get(`${a}->${b}`) ?? 0, 0.1);
348
+ assertClose('A2→B faded', state.downstreamEdgeStrengthByKey.get(`${a2}->${b}`) ?? 0, 0.1);
349
+ // B→C = arrive(B)×0.8×1 = 0.2×0.8 = 0.16(不因 B 高惊讶再乘 prop)
350
+ assertClose(
351
+ 'B→C = sum(arrive)×out,不乘 prop(B)',
352
+ state.downstreamEdgeStrengthByKey.get(`${b}->${c}`) ?? 0,
353
+ 0.16,
354
+ );
355
+ assertClose('arrive(B) = 0.2', state.downstreamArriveById.get(b) ?? 0, 0.2);
356
+ }
357
+ }
358
+
359
  console.log(`\n${passed} passed, ${failed} failed`);
360
  if (failed > 0) process.exit(1);
client/src/tests/prediction_attribution/genAttributeDagPropagationPlayback.test.ts CHANGED
@@ -523,6 +523,7 @@ console.log('6. createDagRecursiveEdgeAnimationController pause/resume');
523
  activeNodeIds: new Set(['p', 'a', 'b', focusId]),
524
  incomingEdgeShareByKey: incoming,
525
  downstreamEdgeStrengthByKey: new Map<string, number>(),
 
526
  nodeShareById: nodeShare,
527
  };
528
  const ctx = {
@@ -593,6 +594,7 @@ console.log('7. backward skips first prompt region slide');
593
  activeNodeIds: new Set(['p', 'a', 'b', focusId]),
594
  incomingEdgeShareByKey: incoming,
595
  downstreamEdgeStrengthByKey: new Map<string, number>(),
 
596
  nodeShareById: nodeShare,
597
  };
598
  const ctx = {
 
523
  activeNodeIds: new Set(['p', 'a', 'b', focusId]),
524
  incomingEdgeShareByKey: incoming,
525
  downstreamEdgeStrengthByKey: new Map<string, number>(),
526
+ downstreamArriveById: new Map<string, number>(),
527
  nodeShareById: nodeShare,
528
  };
529
  const ctx = {
 
594
  activeNodeIds: new Set(['p', 'a', 'b', focusId]),
595
  incomingEdgeShareByKey: incoming,
596
  downstreamEdgeStrengthByKey: new Map<string, number>(),
597
+ downstreamArriveById: new Map<string, number>(),
598
  nodeShareById: nodeShare,
599
  };
600
  const ctx = {