| """HTML helpers for visualizing hop-wise IFR/AttnLRP attributions.""" |
|
|
| from __future__ import annotations |
|
|
| import math |
| from typing import Any, Dict, List, Optional, Sequence |
|
|
| from html import escape |
|
|
|
|
| TOKEN_SCALE_QUANTILE = 0.995 |
|
|
|
|
| def _robust_abs_max(scores: Sequence[float], *, quantile: float = TOKEN_SCALE_QUANTILE) -> float: |
| """Return a robust abs max to avoid a single outlier washing out the colormap. |
| |
| Uses a high quantile (default: p99.5) over |scores|. Top outliers saturate. |
| """ |
|
|
| abs_vals: List[float] = [] |
| for x in scores: |
| try: |
| v = float(x) |
| except Exception: |
| continue |
| if math.isnan(v): |
| continue |
| abs_vals.append(abs(v)) |
|
|
| if not abs_vals: |
| return 0.0 |
|
|
| abs_vals.sort() |
| q = float(quantile) |
| if q < 0.0: |
| q = 0.0 |
| if q > 1.0: |
| q = 1.0 |
| idx = int(q * (len(abs_vals) - 1)) |
| return float(abs_vals[idx]) |
|
|
|
|
| def _color_for_score(score: float, max_score: float) -> str: |
| if max_score <= 0: |
| return "background-color: rgba(245,245,245,0.7);" |
| ratio = min(1.0, score / (max_score + 1e-12)) |
| r = 255 |
| g = int(235 - 90 * ratio) |
| b = int(220 - 160 * ratio) |
| alpha = 0.25 + 0.55 * ratio |
| return f"background-color: rgba({r}, {g}, {b}, {alpha});" |
|
|
|
|
| def _render_sentence_list(title: str, sentences: Sequence[str], scores: Sequence[float], max_score: float) -> str: |
| rows: List[str] = [] |
| for sent, sc in zip(sentences, scores): |
| style = _color_for_score(abs(float(sc)), max_score) |
| rows.append( |
| f'<div class="sent-row" style="{style}"><span class="score">{sc:.4f}</span>' |
| f'<span class="text">{escape(sent)}</span></div>' |
| ) |
| return f""" |
| <div class="sent-block"> |
| <div class="sent-title">{escape(title)}</div> |
| {''.join(rows)} |
| </div> |
| """ |
|
|
|
|
| def _render_tokens( |
| tokens: Sequence[str], |
| scores: Sequence[float], |
| max_score: float, |
| roles: Sequence[str], |
| ) -> str: |
| spans: List[str] = [] |
| if max_score <= 0: |
| max_score = 1e-8 |
| for idx, tok in enumerate(tokens): |
| score = float(scores[idx]) if idx < len(scores) else 0.0 |
| style = _color_for_score(abs(score), max_score) |
| role = roles[idx] if idx < len(roles) else "gen" |
| safe_tok = escape(tok) |
| spans.append( |
| f'<span class="tok {role}" title="idx={idx}, score={score:.6f}" style="{style}">{safe_tok}</span>' |
| ) |
| return "".join(spans) |
|
|
|
|
| def _render_top_table(top_items: List[Dict[str, Any]]) -> str: |
| if not top_items: |
| return "<div class='top-table'><em>No attribution mass.</em></div>" |
|
|
| header = "<div class='top-row top-header'><span>Rank</span><span>Idx</span><span>Score</span><span>Sentence</span></div>" |
| body_rows = [] |
| for rank, item in enumerate(top_items, start=1): |
| body_rows.append( |
| f"<div class='top-row'><span>{rank}</span><span>{item['idx']}</span>" |
| f"<span>{item['score']:.4f}</span><span>{escape(item['sentence'])}</span></div>" |
| ) |
| return f"<div class='top-table'>{header}{''.join(body_rows)}</div>" |
|
|
|
|
| def render_case_html( |
| case_meta: Dict[str, Any], |
| *, |
| token_view_raw: Dict[str, Any], |
| token_view_prompt: Dict[str, Any], |
| context: Optional[Dict[str, Any]] = None, |
| hops_sent: Optional[Sequence[Dict[str, Any]]] = None, |
| ) -> str: |
| has_sentence_view = bool(context) and bool(hops_sent) |
| prompt_len = len((context or {}).get("prompt_sentences") or []) if has_sentence_view else 0 |
| gen_len = len((context or {}).get("generation_sentences") or []) if has_sentence_view else 0 |
|
|
| prompt_max = 0.0 |
| gen_max = 0.0 |
| if has_sentence_view: |
| prompt_max = max( |
| ( |
| max(h["sentence_scores_raw"][:prompt_len]) |
| for h in (hops_sent or []) |
| if h.get("sentence_scores_raw") and h["sentence_scores_raw"][:prompt_len] |
| ), |
| default=0.0, |
| ) |
| gen_max = max( |
| ( |
| max(h["sentence_scores_raw"][prompt_len:]) |
| for h in (hops_sent or []) |
| if h.get("sentence_scores_raw") and h["sentence_scores_raw"][prompt_len:] |
| ), |
| default=0.0, |
| ) |
|
|
| raw_hops = token_view_raw.get("hops", []) or [] |
| prompt_hops = token_view_prompt.get("hops", []) or [] |
| if len(raw_hops) != len(prompt_hops): |
| raise ValueError( |
| "token_view_raw and token_view_prompt must have the same number of panels: " |
| f"raw={len(raw_hops)} prompt={len(prompt_hops)}" |
| ) |
|
|
| hop_sections: List[str] = [] |
| hop_count = len(prompt_hops) |
| mode = case_meta.get("mode", "ft") |
| ifr_view = case_meta.get("ifr_view", "aggregate") |
| sink_span = case_meta.get("sink_span") |
| panel_titles = case_meta.get("panel_titles") |
|
|
| def _panel_title(panel_idx: int) -> str: |
| if isinstance(panel_titles, list) and panel_idx < len(panel_titles): |
| try: |
| title = panel_titles[panel_idx] |
| except Exception: |
| title = None |
| if title is not None: |
| return str(title) |
| if mode in ("ft", "ft_improve", "ft_split_hop", "ifr_in_all_gen", "ft_attnlrp"): |
| return f"Hop {panel_idx}" |
| if mode == "ifr_all_positions_output_only": |
| return f"IFR output-only panel {panel_idx}" |
| if mode == "ifr_all_positions": |
| return f"IFR all-positions panel {panel_idx}" |
| if mode == "attnlrp": |
| return "AttnLRP (sink-span aggregate)" |
| return "IFR (sink-span aggregate)" |
|
|
| for hop_idx in range(hop_count): |
| raw_entry = raw_hops[hop_idx] |
| raw_scores = raw_entry.get("token_scores") or [] |
| raw_mass = float(raw_entry.get("total_mass", 0.0)) |
| raw_scale = _robust_abs_max(raw_scores) |
| if raw_scale <= 0: |
| raw_scale = float(raw_entry.get("token_score_max") or 0.0) |
| if raw_scale <= 0: |
| raw_scale = 1e-8 |
|
|
| prompt_entry = prompt_hops[hop_idx] |
| prompt_scores = prompt_entry.get("token_scores") or [] |
| prompt_mass = float(prompt_entry.get("total_mass", 0.0)) |
| prompt_scale = _robust_abs_max(prompt_scores) |
| if prompt_scale <= 0: |
| prompt_scale = float(prompt_entry.get("token_score_max") or 0.0) |
| if prompt_scale <= 0: |
| prompt_scale = 1e-8 |
|
|
| tok_raw_html = f""" |
| <div class="tokens-block"> |
| <div class="tokens-title">{escape(token_view_raw.get("label", "Pre-trim token-level heatmap (full)"))}</div> |
| <div class="tokens-row"> |
| {_render_tokens(token_view_raw.get("tokens", []), raw_scores, raw_scale, token_view_raw.get("roles", []))} |
| </div> |
| </div> |
| """ |
|
|
| tok_prompt_html = f""" |
| <div class="tokens-block"> |
| <div class="tokens-title">{escape(token_view_prompt.get("label", "Prompt-only token-level heatmap"))}</div> |
| <div class="tokens-row"> |
| {_render_tokens(token_view_prompt.get("tokens", []), prompt_scores, prompt_scale, token_view_prompt.get("roles", []))} |
| </div> |
| </div> |
| """ |
|
|
| sentence_html = "" |
| top_html = "" |
| if has_sentence_view and hop_idx < len(hops_sent or []): |
| hop = (hops_sent or [])[hop_idx] |
| raw_scores = hop.get("sentence_scores_raw") or [] |
| prompt_scores = raw_scores[:prompt_len] |
| gen_scores = raw_scores[prompt_len:] |
| |
| sentence_html = f""" |
| <div class="columns"> |
| {_render_sentence_list('Prompt sentences', (context or {}).get('prompt_sentences') or [], prompt_scores, prompt_max)} |
| {_render_sentence_list('Generation sentences', (context or {}).get('generation_sentences') or [], gen_scores, gen_max)} |
| </div> |
| """ |
| top_html = f""" |
| <div class="top-wrap"> |
| <div class="section-label">Top sentences (all)</div> |
| {_render_top_table(hop.get('top_sentences') or [])} |
| </div> |
| """ |
|
|
| hop_sections.append( |
| f""" |
| <div class="hop"> |
| <div class="hop-header"> |
| <div class="hop-title">{escape(_panel_title(hop_idx))}</div> |
| <div class="hop-meta"> |
| raw mass: {raw_mass:.6f} | raw scale(p{int(TOKEN_SCALE_QUANTILE*1000)/10:.1f} abs): {raw_scale:.6g} |
| | |
| prompt mass: {prompt_mass:.6f} | prompt scale(p{int(TOKEN_SCALE_QUANTILE*1000)/10:.1f} abs): {prompt_scale:.6g} |
| </div> |
| </div> |
| {tok_raw_html} |
| {tok_prompt_html} |
| {sentence_html} |
| {top_html} |
| </div> |
| """ |
| ) |
|
|
| thinking_ratios = case_meta.get("thinking_ratios") or [] |
| ratios_str = ", ".join(f"{r:.4f}" for r in thinking_ratios) if thinking_ratios else "N/A" |
|
|
| if mode == "ft": |
| mode_label = "FT Multi-hop (IFR)" |
| elif mode == "ifr_in_all_gen": |
| mode_label = "IFR In-all-gen (multi-hop)" |
| elif mode == "ifr": |
| mode_label = "IFR Standard" |
| elif mode == "ifr_all_positions": |
| mode_label = "IFR All-positions" |
| elif mode == "ifr_all_positions_output_only": |
| mode_label = "IFR Output-only (all positions)" |
| elif mode == "attnlrp": |
| mode_label = "AttnLRP" |
| elif mode == "ft_attnlrp": |
| mode_label = "FT Multi-hop (AttnLRP)" |
| else: |
| mode_label = str(mode) |
|
|
| if mode in ("ft", "ifr_in_all_gen", "ft_attnlrp"): |
| view_key = "Recursive hops" |
| view_val = case_meta.get("n_hops") |
| elif mode in ("ifr", "ifr_all_positions", "ifr_all_positions_output_only"): |
| view_key = "IFR view" |
| view_val = ifr_view |
| elif mode == "attnlrp": |
| view_key = "AttnLRP view" |
| view_val = "ft_hop0_span_aggregate" |
| else: |
| view_key = "View" |
| view_val = "N/A" |
|
|
| scale_row = f"<div>Token scale: per-panel per-view p{int(TOKEN_SCALE_QUANTILE*1000)/10:.1f}(|score|)</div>" |
| neg_handling = case_meta.get("attnlrp_neg_handling") |
| norm_mode = case_meta.get("attnlrp_norm_mode") |
| ratio_enabled = case_meta.get("attnlrp_ratio_enabled") |
| attn_rows = [] |
| if neg_handling: |
| attn_rows.append(f"<div>FT-AttnLRP neg_handling: {escape(str(neg_handling))}</div>") |
| if norm_mode: |
| attn_rows.append(f"<div>FT-AttnLRP norm_mode: {escape(str(norm_mode))}</div>") |
| if ratio_enabled is not None: |
| attn_rows.append(f"<div>FT-AttnLRP ratio_enabled: {escape(str(bool(ratio_enabled)))}</div>") |
|
|
| header = f""" |
| <div class="header"> |
| <div> |
| <div class="title">{escape(mode_label)} Case Study</div> |
| <div class="subtitle">Dataset: {escape(str(case_meta.get('dataset')))} | index: {case_meta.get('index')}</div> |
| </div> |
| <div class="meta"> |
| <div>Sink span (gen idx): {escape(str(case_meta.get('sink_span')))}</div> |
| <div>Thinking span (gen idx): {escape(str(case_meta.get('thinking_span')))}</div> |
| <div>Panels: {hop_count}</div> |
| <div>{escape(str(view_key))}: {escape(str(view_val))}</div> |
| {scale_row} |
| {''.join(attn_rows)} |
| <div>Thinking ratios: {ratios_str}</div> |
| </div> |
| </div> |
| """ |
|
|
| style = """ |
| <style> |
| body { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 24px; background: #fcfcff; color: #1f2933; } |
| .title { font-size: 24px; font-weight: 700; } |
| .subtitle { font-size: 14px; color: #566; margin-top: 4px; } |
| .header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid #e5e8ee; } |
| .meta { font-size: 13px; color: #334; line-height: 1.6; } |
| .hop { margin-top: 20px; padding: 16px; border: 1px solid #e5e8ee; border-radius: 10px; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.04); } |
| .hop-header { display: flex; justify-content: space-between; align-items: center; } |
| .hop-title { font-weight: 600; font-size: 16px; } |
| .hop-meta { font-size: 12px; color: #556; } |
| .tokens-block { margin-top: 12px; border: 1px solid #eef1f6; border-radius: 8px; padding: 10px; background: #f9fbff; } |
| .tokens-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: #263; } |
| .tokens-row { font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; line-height: 1.8; word-break: break-word; } |
| .tok { display: inline; padding: 2px 1px; margin: 0 0px; border-radius: 3px; } |
| .tok.prompt { border-bottom: 1px dashed #6b8fb8; } |
| .tok.user { border-bottom: 1px dashed #4f72c7; } |
| .tok.template { border-bottom: 1px dashed #9aa9c0; } |
| .tok.think { border-bottom: 1px dashed #8ba86b; } |
| .tok.output { border-bottom: 1px dashed #c78a6e; } |
| .tok.gen { border-bottom: 1px dashed #999; } |
| .tok:hover { outline: 1px solid #8899aa; } |
| .columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 12px; } |
| .sent-block { padding: 8px; border: 1px solid #eef1f6; border-radius: 8px; background: #f9fbff; } |
| .sent-title { font-weight: 600; font-size: 13px; margin-bottom: 6px; color: #263; } |
| .sent-row { padding: 6px 8px; border-radius: 6px; margin-bottom: 6px; display: flex; gap: 8px; align-items: flex-start; } |
| .sent-row:last-child { margin-bottom: 0; } |
| .sent-row .score { font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; color: #233; min-width: 60px; } |
| .sent-row .text { flex: 1; font-size: 13px; } |
| .top-wrap { margin-top: 10px; } |
| .section-label { font-size: 13px; font-weight: 600; margin-bottom: 6px; color: #263; } |
| .top-table { border: 1px solid #eef1f6; border-radius: 8px; background: #fff; } |
| .top-row { display: grid; grid-template-columns: 50px 50px 80px 1fr; padding: 6px 8px; gap: 8px; font-size: 12px; } |
| .top-header { background: #f3f6fb; font-weight: 700; color: #223; } |
| .top-row:nth-child(odd):not(.top-header) { background: #fbfdff; } |
| </style> |
| """ |
|
|
| title = f"{mode_label} Case Study" |
| html = f"""<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8" /> |
| <title>{escape(title)}</title> |
| {style} |
| </head> |
| <body> |
| {header} |
| {''.join(hop_sections)} |
| </body> |
| </html>""" |
| return html |
|
|
|
|
| def _render_sentence_spans(title: str, sentences: Sequence[str], scores: Sequence[float]) -> str: |
| max_abs = max((abs(float(x)) for x in scores), default=0.0) |
| spans: List[str] = [] |
| for idx, sentence in enumerate(sentences): |
| score = float(scores[idx]) if idx < len(scores) else 0.0 |
| style = _color_for_score(abs(score), max_abs) |
| spans.append( |
| f'<span class="sent-span" title="idx={idx}, score={score:.6f}" style="{style}">{escape(sentence)}</span>' |
| ) |
| return f""" |
| <div class="sentmap"> |
| <div class="sentmap-title">{escape(title)}</div> |
| <div class="sentmap-text">{''.join(spans)}</div> |
| </div> |
| """ |
|
|
|
|
| def _render_token_spans(title: str, tokens: Sequence[str], scores: Sequence[float]) -> str: |
| max_abs = max((abs(float(x)) for x in scores), default=0.0) |
| spans: List[str] = [] |
| for idx, tok in enumerate(tokens): |
| score = float(scores[idx]) if idx < len(scores) else 0.0 |
| style = _color_for_score(abs(score), max_abs) |
| spans.append( |
| f'<span class="tok-span" title="idx={idx}, score={score:.6f}" style="{style}">{escape(tok)}</span>' |
| ) |
| return f""" |
| <div class="tokmap"> |
| <div class="tokmap-title">{escape(title)}</div> |
| <div class="tokmap-text">{''.join(spans)}</div> |
| </div> |
| """ |
|
|
|
|
| def render_mas_sentence_html( |
| case_meta: Dict[str, Any], |
| *, |
| prompt_sentences: Sequence[str], |
| panels: Sequence[Dict[str, Any]], |
| generation: Optional[str] = None, |
| ) -> str: |
| """Render MAS sentence-level diagnostics (attribution / pure ablation / guided marginal).""" |
|
|
| method_label = case_meta.get("attr_method_label") or case_meta.get("attr_method") or "Unknown method" |
| title = f"MAS Sentence Study ({method_label})" |
|
|
| neg_handling = case_meta.get("attnlrp_neg_handling") |
| norm_mode = case_meta.get("attnlrp_norm_mode") |
| ratio_enabled = case_meta.get("attnlrp_ratio_enabled") |
| attn_rows = [] |
| if neg_handling: |
| attn_rows.append(f"<div>FT-AttnLRP neg_handling: {escape(str(neg_handling))}</div>") |
| if norm_mode: |
| attn_rows.append(f"<div>FT-AttnLRP norm_mode: {escape(str(norm_mode))}</div>") |
| if ratio_enabled is not None: |
| attn_rows.append(f"<div>FT-AttnLRP ratio_enabled: {escape(str(bool(ratio_enabled)))}</div>") |
|
|
| base_score = case_meta.get("base_score") |
| base_score_row = f"<div>Base score: {float(base_score):.6f}</div>" if isinstance(base_score, (int, float)) else "" |
|
|
| gen_block = "" |
| if isinstance(generation, str) and generation: |
| gen_block = f""" |
| <div class="text-block"> |
| <div class="text-title">Generation (scored)</div> |
| <div class="text-body">{escape(generation)}</div> |
| </div> |
| """ |
|
|
| header = f""" |
| <div class="header"> |
| <div> |
| <div class="title">{escape(title)}</div> |
| <div class="subtitle">Dataset: {escape(str(case_meta.get('dataset')))} | index: {case_meta.get('index')}</div> |
| </div> |
| <div class="meta"> |
| <div>Attribution method: {escape(str(case_meta.get('attr_method')))}</div> |
| <div>Sink span (gen idx): {escape(str(case_meta.get('sink_span')))}</div> |
| <div>Thinking span (gen idx): {escape(str(case_meta.get('thinking_span')))}</div> |
| <div>Panels: {len(panels)}</div> |
| {''.join(attn_rows)} |
| {base_score_row} |
| </div> |
| </div> |
| """ |
|
|
| panel_sections: List[str] = [] |
| for panel in panels: |
| label = panel.get("variant_label") or panel.get("panel_label") or panel.get("variant") or "Panel" |
| metrics = panel.get("metrics") or {} |
| metrics_str = " | ".join( |
| f"{k}: {float(metrics[k]):.4f}" if isinstance(metrics.get(k), (int, float)) else f"{k}: {metrics.get(k)}" |
| for k in ("RISE", "MAS", "RISE+AP") |
| if k in metrics |
| ) |
|
|
| attr_weights = panel.get("attr_weights") or [] |
| pure_deltas = panel.get("pure_sentence_deltas_raw") or [] |
| guided_deltas = panel.get("guided_sentence_deltas_raw") or panel.get("sentence_deltas_raw") or [] |
| rank_order = panel.get("sorted_attr_indices") or [] |
| rank_str = ", ".join(str(int(x)) for x in rank_order) if rank_order else "N/A" |
|
|
| panel_sections.append( |
| f""" |
| <div class="panel"> |
| <div class="panel-header"> |
| <div class="panel-title">{escape(str(label))}</div> |
| <div class="panel-meta">{escape(metrics_str)}</div> |
| </div> |
| |
| {_render_sentence_spans("Method attribution (sentence weights)", prompt_sentences, attr_weights)} |
| {_render_sentence_spans("Pure sentence ablation (base − score)", prompt_sentences, pure_deltas)} |
| {_render_sentence_spans("Attribution-guided MAS marginal (path deltas)", prompt_sentences, guided_deltas)} |
| |
| <div class="panel-foot">Rank order: {escape(rank_str)}</div> |
| </div> |
| """ |
| ) |
|
|
| style = """ |
| <style> |
| body { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 24px; background: #fcfcff; color: #1f2933; } |
| .title { font-size: 24px; font-weight: 700; } |
| .subtitle { font-size: 14px; color: #566; margin-top: 4px; } |
| .header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid #e5e8ee; } |
| .meta { font-size: 13px; color: #334; line-height: 1.6; } |
| |
| .text-block { margin-top: 16px; border: 1px solid #eef1f6; border-radius: 10px; padding: 12px; background: #fff; } |
| .text-title { font-size: 13px; font-weight: 700; color: #263; margin-bottom: 8px; } |
| .text-body { font-size: 13px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; } |
| |
| .panel { margin-top: 18px; padding: 16px; border: 1px solid #e5e8ee; border-radius: 10px; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.04); } |
| .panel-header { display: flex; justify-content: space-between; align-items: center; } |
| .panel-title { font-weight: 600; font-size: 16px; } |
| .panel-meta { font-size: 12px; color: #556; } |
| .panel-foot { margin-top: 8px; font-size: 12px; color: #556; } |
| |
| .sentmap { margin-top: 12px; border: 1px solid #eef1f6; border-radius: 8px; padding: 10px; background: #f9fbff; } |
| .sentmap-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: #263; } |
| .sentmap-text { font-size: 13px; line-height: 1.8; white-space: pre-wrap; word-break: break-word; } |
| .sent-span { display: inline; padding: 2px 2px; margin: 0 0px; border-radius: 4px; } |
| .sent-span:hover { outline: 1px solid #8899aa; } |
| </style> |
| """ |
|
|
| html = f"""<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8" /> |
| <title>{escape(title)}</title> |
| {style} |
| </head> |
| <body> |
| {header} |
| {gen_block} |
| {''.join(panel_sections)} |
| </body> |
| </html>""" |
| return html |
|
|
|
|
| def render_mas_token_html( |
| case_meta: Dict[str, Any], |
| *, |
| prompt_tokens: Sequence[str], |
| panels: Sequence[Dict[str, Any]], |
| generation: Optional[str] = None, |
| ) -> str: |
| """Render MAS token-level diagnostics (attribution weights + guided marginal deltas).""" |
|
|
| method_label = case_meta.get("attr_method_label") or case_meta.get("attr_method") or "Unknown method" |
| title = f"MAS Token Study ({method_label})" |
|
|
| neg_handling = case_meta.get("attnlrp_neg_handling") |
| norm_mode = case_meta.get("attnlrp_norm_mode") |
| ratio_enabled = case_meta.get("attnlrp_ratio_enabled") |
| attn_rows = [] |
| if neg_handling: |
| attn_rows.append(f"<div>FT-AttnLRP neg_handling: {escape(str(neg_handling))}</div>") |
| if norm_mode: |
| attn_rows.append(f"<div>FT-AttnLRP norm_mode: {escape(str(norm_mode))}</div>") |
| if ratio_enabled is not None: |
| attn_rows.append(f"<div>FT-AttnLRP ratio_enabled: {escape(str(bool(ratio_enabled)))}</div>") |
|
|
| base_score = case_meta.get("base_score") |
| base_score_row = f"<div>Base score: {float(base_score):.6f}</div>" if isinstance(base_score, (int, float)) else "" |
|
|
| gen_block = "" |
| if isinstance(generation, str) and generation: |
| gen_block = f""" |
| <div class="text-block"> |
| <div class="text-title">Generation (scored)</div> |
| <div class="text-body">{escape(generation)}</div> |
| </div> |
| """ |
|
|
| header = f""" |
| <div class="header"> |
| <div> |
| <div class="title">{escape(title)}</div> |
| <div class="subtitle">Dataset: {escape(str(case_meta.get('dataset')))} | index: {case_meta.get('index')}</div> |
| </div> |
| <div class="meta"> |
| <div>Attribution method: {escape(str(case_meta.get('attr_method')))}</div> |
| <div>Sink span (gen idx): {escape(str(case_meta.get('sink_span')))}</div> |
| <div>Thinking span (gen idx): {escape(str(case_meta.get('thinking_span')))}</div> |
| <div>Prompt tokens: {len(prompt_tokens)}</div> |
| <div>Panels: {len(panels)}</div> |
| {''.join(attn_rows)} |
| {base_score_row} |
| </div> |
| </div> |
| """ |
|
|
| panel_sections: List[str] = [] |
| for panel in panels: |
| label = panel.get("variant_label") or panel.get("panel_label") or panel.get("variant") or "Panel" |
| metrics = panel.get("metrics") or {} |
| metrics_str = " | ".join( |
| f"{k}: {float(metrics[k]):.4f}" if isinstance(metrics.get(k), (int, float)) else f"{k}: {metrics.get(k)}" |
| for k in ("RISE", "MAS", "RISE+AP") |
| if k in metrics |
| ) |
|
|
| attr_weights = panel.get("attr_weights") or [] |
| guided_deltas = panel.get("token_deltas_raw") or [] |
| rank_order = panel.get("sorted_attr_indices") or [] |
| rank_str = ", ".join(str(int(x)) for x in rank_order) if rank_order else "N/A" |
|
|
| panel_sections.append( |
| f""" |
| <div class="panel"> |
| <div class="panel-header"> |
| <div class="panel-title">{escape(str(label))}</div> |
| <div class="panel-meta">{escape(metrics_str)}</div> |
| </div> |
| |
| {_render_token_spans("Method attribution (token weights)", prompt_tokens, attr_weights)} |
| {_render_token_spans("Attribution-guided MAS marginal (path deltas)", prompt_tokens, guided_deltas)} |
| |
| <div class="panel-foot">Rank order: {escape(rank_str)}</div> |
| </div> |
| """ |
| ) |
|
|
| style = """ |
| <style> |
| body { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 24px; background: #fcfcff; color: #1f2933; } |
| .title { font-size: 24px; font-weight: 700; } |
| .subtitle { font-size: 14px; color: #566; margin-top: 4px; } |
| .header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid #e5e8ee; } |
| .meta { font-size: 13px; color: #334; line-height: 1.6; } |
| |
| .text-block { margin-top: 16px; border: 1px solid #eef1f6; border-radius: 10px; padding: 12px; background: #fff; } |
| .text-title { font-size: 13px; font-weight: 700; color: #263; margin-bottom: 8px; } |
| .text-body { font-size: 13px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; } |
| |
| .panel { margin-top: 18px; padding: 16px; border: 1px solid #e5e8ee; border-radius: 10px; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.04); } |
| .panel-header { display: flex; justify-content: space-between; align-items: center; } |
| .panel-title { font-weight: 600; font-size: 16px; } |
| .panel-meta { font-size: 12px; color: #556; } |
| .panel-foot { margin-top: 8px; font-size: 12px; color: #556; } |
| |
| .tokmap { margin-top: 12px; border: 1px solid #eef1f6; border-radius: 8px; padding: 10px; background: #f9fbff; } |
| .tokmap-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: #263; } |
| .tokmap-text { font-size: 13px; line-height: 1.8; white-space: pre-wrap; word-break: break-word; } |
| .tok-span { display: inline; padding: 1px 1px; margin: 0 0px; border-radius: 3px; } |
| .tok-span:hover { outline: 1px solid #8899aa; } |
| </style> |
| """ |
|
|
| html = f"""<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8" /> |
| <title>{escape(title)}</title> |
| {style} |
| </head> |
| <body> |
| {header} |
| {gen_block} |
| {''.join(panel_sections)} |
| </body> |
| </html>""" |
| return html |
|
|