PrERT-CNM-Demo / src /prert /phase4 /visual_layers.py
JayNightmare's picture
Upload folder using huggingface_hub
967451a verified
Raw
History Blame Contribute Delete
34 kB
"""Neural layer introspection helpers for the Phase 4 Visual Layers GUI.
This module provides PrivacyBERT-focused analysis with layer activations,
attention summaries, math breakdown snapshots, and architecture map payloads.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from math import sqrt
from pathlib import Path
import importlib
import json
import tempfile
import uuid
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from prert.phase4.compliance_assessor import split_policy_clauses
DEFAULT_MAX_CLAUSES = 16
DEFAULT_TOP_TOKENS = 8
BERT_LAYER_COUNT = 12
BERT_HEAD_COUNT = 12
HEATMAP_MAX_TOKENS = 64
@dataclass(frozen=True)
class _ModelBundle:
tokenizer: Any
model: Any
torch: Any
model_id: str
revision: str
def _normalize_model_id(model_id: str) -> str:
return str(model_id or "").strip()
def _normalize_revision(revision: str) -> str:
value = str(revision or "").strip()
return value or "main"
def _load_kwargs(revision: str) -> Dict[str, Any]:
kwargs: Dict[str, Any] = {"revision": revision}
token = (
importlib.import_module("os").getenv("HF_TOKEN")
or importlib.import_module("os").getenv("HUGGINGFACEHUB_API_TOKEN")
)
if token:
kwargs["token"] = token
return kwargs
def _force_eager_attention(model: Any) -> None:
config = getattr(model, "config", None)
if config is not None:
setattr(config, "output_hidden_states", True)
setattr(config, "output_attentions", True)
# Newer Transformers versions may read either public or private keys.
if hasattr(config, "attn_implementation"):
setattr(config, "attn_implementation", "eager")
if hasattr(config, "_attn_implementation"):
setattr(config, "_attn_implementation", "eager")
setter = getattr(model, "set_attn_implementation", None)
if callable(setter):
try:
setter("eager")
except Exception:
# Keep graceful fallback for architectures without runtime switching.
pass
@lru_cache(maxsize=3)
def _get_model_bundle(model_id: str, revision: str) -> _ModelBundle:
normalized_model_id = _normalize_model_id(model_id)
if not normalized_model_id:
raise ValueError("MODEL_ID is required for Visual Layers analysis.")
normalized_revision = _normalize_revision(revision)
torch_module = importlib.import_module("torch")
transformers_module = importlib.import_module("transformers")
load_kwargs = _load_kwargs(normalized_revision)
tokenizer = transformers_module.AutoTokenizer.from_pretrained(normalized_model_id, **load_kwargs)
eager_load_kwargs = dict(load_kwargs)
eager_load_kwargs["attn_implementation"] = "eager"
try:
model = transformers_module.AutoModelForSequenceClassification.from_pretrained(
normalized_model_id,
**eager_load_kwargs,
)
except TypeError:
# Some older model classes do not accept attn_implementation at load time.
model = transformers_module.AutoModelForSequenceClassification.from_pretrained(
normalized_model_id,
**load_kwargs,
)
_force_eager_attention(model)
model.eval()
return _ModelBundle(
tokenizer=tokenizer,
model=model,
torch=torch_module,
model_id=normalized_model_id,
revision=normalized_revision,
)
def _coerce_indices(selected: Optional[Sequence[Any]], maximum: int, default_count: int) -> List[int]:
if not selected:
return list(range(min(default_count, maximum)))
parsed: List[int] = []
for value in selected:
try:
index = int(value)
except (TypeError, ValueError):
continue
if 0 <= index < maximum:
parsed.append(index)
unique = sorted(set(parsed))
if unique:
return unique
return list(range(min(default_count, maximum)))
def _sigmoid(value: float) -> float:
import math
return 1.0 / (1.0 + math.exp(-value))
def _clean_tokens(tokens: Sequence[str]) -> List[str]:
cleaned: List[str] = []
for token in tokens:
normalized = str(token)
if normalized in {"[PAD]", "[CLS]", "[SEP]"}:
cleaned.append(normalized)
continue
cleaned.append(normalized.replace("##", ""))
return cleaned
def _sequence_length_from_mask(mask: Any) -> int:
try:
return int(mask.sum().item())
except Exception:
try:
return int(mask.sum())
except Exception:
return int(len(mask))
def _build_architecture_map(layer_scores: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
nodes: List[Dict[str, Any]] = [
{"id": "input", "label": "Input Tokens", "group": "io", "level": 0, "trigger": 1.0},
{"id": "embedding", "label": "Embeddings", "group": "embedding", "level": 1, "trigger": 1.0},
]
edges: List[Dict[str, Any]] = [
{"src": "input", "dst": "embedding", "weight": 1.0},
]
for index, score in enumerate(layer_scores, start=1):
node_id = f"layer_{index}"
trigger = float(score.get("mean_norm", 0.0))
nodes.append(
{
"id": node_id,
"label": f"Layer {index}",
"group": "transformer",
"level": index + 1,
"trigger": trigger,
}
)
previous = "embedding" if index == 1 else f"layer_{index - 1}"
edges.append({"src": previous, "dst": node_id, "weight": trigger})
nodes.extend(
[
{"id": "pooler", "label": "Pooler", "group": "head", "level": len(layer_scores) + 2, "trigger": 1.0},
{"id": "classifier", "label": "Classifier", "group": "head", "level": len(layer_scores) + 3, "trigger": 1.0},
{"id": "softmax", "label": "Softmax", "group": "head", "level": len(layer_scores) + 4, "trigger": 1.0},
]
)
last_layer = f"layer_{len(layer_scores)}" if layer_scores else "embedding"
edges.extend(
[
{"src": last_layer, "dst": "pooler", "weight": 1.0},
{"src": "pooler", "dst": "classifier", "weight": 1.0},
{"src": "classifier", "dst": "softmax", "weight": 1.0},
]
)
return {
"nodes": nodes,
"edges": edges,
"triggered_node_ids": [str(node["id"]) for node in nodes],
}
def _summarize_layer_activations(torch_module: Any, hidden_states: Sequence[Any], token_count: int) -> List[Dict[str, Any]]:
summaries: List[Dict[str, Any]] = []
usable = list(hidden_states[1:])
for index, tensor in enumerate(usable, start=1):
layer_tensor = tensor[0, :token_count, :]
norms = torch_module.linalg.vector_norm(layer_tensor, dim=-1)
summaries.append(
{
"layer": index,
"mean_norm": float(norms.mean().item()),
"max_norm": float(norms.max().item()),
"mean_abs": float(layer_tensor.abs().mean().item()),
"activation_score": float(_sigmoid(float(layer_tensor.mean().item()))),
}
)
return summaries
def _attention_entropy(row: Sequence[float]) -> float:
import math
epsilon = 1e-12
return float(-sum(float(p) * math.log(float(p) + epsilon) for p in row))
def _summarize_attention(
torch_module: Any,
attentions: Sequence[Any],
tokens: Sequence[str],
token_count: int,
selected_layers: Sequence[int],
selected_heads: Sequence[int],
top_tokens: int,
) -> List[Dict[str, Any]]:
summaries: List[Dict[str, Any]] = []
for layer_index in selected_layers:
if layer_index < 0 or layer_index >= len(attentions):
continue
layer_attention = attentions[layer_index][0, :, :token_count, :token_count]
heads = min(int(layer_attention.shape[0]), BERT_HEAD_COUNT)
for head_index in selected_heads:
if head_index < 0 or head_index >= heads:
continue
head_matrix = layer_attention[head_index]
cls_row = head_matrix[0]
values, indices = torch_module.topk(cls_row, k=min(top_tokens, token_count))
top_focus = [
{
"token": str(tokens[int(idx)]),
"score": float(val),
}
for val, idx in zip(values.detach().cpu().tolist(), indices.detach().cpu().tolist())
]
summaries.append(
{
"layer": layer_index + 1,
"head": head_index,
"mean": float(head_matrix.mean().item()),
"max": float(head_matrix.max().item()),
"entropy": _attention_entropy(cls_row.detach().cpu().tolist()),
"cls_focus": top_focus,
"tokens": [str(token) for token in tokens[: min(token_count, HEATMAP_MAX_TOKENS)]],
"matrix": head_matrix[
: min(token_count, HEATMAP_MAX_TOKENS),
: min(token_count, HEATMAP_MAX_TOKENS),
]
.detach()
.cpu()
.tolist(),
}
)
return summaries
def _math_breakdown(
bundle: _ModelBundle,
hidden_states: Sequence[Any],
attentions: Sequence[Any],
token_count: int,
selected_layer_index: int,
) -> Dict[str, Any]:
if selected_layer_index < 0:
selected_layer_index = 0
if selected_layer_index >= len(attentions):
selected_layer_index = max(0, len(attentions) - 1)
details: Dict[str, Any] = {
"layer": selected_layer_index + 1,
"supported": False,
"note": "Detailed Q/K/V extraction unavailable for this architecture.",
}
base_model = getattr(bundle.model, "base_model", None)
encoder = getattr(base_model, "encoder", None) if base_model is not None else None
layers = getattr(encoder, "layer", None) if encoder is not None else None
if layers is None or selected_layer_index >= len(layers):
return details
layer_module = layers[selected_layer_index]
attention_self = getattr(getattr(layer_module, "attention", None), "self", None)
intermediate = getattr(layer_module, "intermediate", None)
output_dense = getattr(getattr(layer_module, "output", None), "dense", None)
if attention_self is None or intermediate is None or output_dense is None:
return details
torch_module = bundle.torch
hidden_in = hidden_states[selected_layer_index][0, :token_count, :]
query_projection = attention_self.query(hidden_in)
key_projection = attention_self.key(hidden_in)
value_projection = attention_self.value(hidden_in)
num_heads = int(getattr(attention_self, "num_attention_heads", BERT_HEAD_COUNT))
head_dim = int(getattr(attention_self, "attention_head_size", int(query_projection.shape[-1] / max(num_heads, 1))))
q = query_projection.view(token_count, num_heads, head_dim).transpose(0, 1)
k = key_projection.view(token_count, num_heads, head_dim).transpose(0, 1)
v = value_projection.view(token_count, num_heads, head_dim).transpose(0, 1)
scale = 1.0 / sqrt(float(head_dim))
scores = torch_module.matmul(q, k.transpose(-1, -2)) * scale
probs = torch_module.softmax(scores, dim=-1)
context = torch_module.matmul(probs, v)
ffn_intermediate = intermediate(hidden_in)
ffn_output = output_dense(ffn_intermediate)
observed_attention = attentions[selected_layer_index][0, :, :token_count, :token_count]
divergence = float((probs - observed_attention).abs().mean().item())
details = {
"layer": selected_layer_index + 1,
"supported": True,
"q_mean_norm": float(torch_module.linalg.vector_norm(query_projection, dim=-1).mean().item()),
"k_mean_norm": float(torch_module.linalg.vector_norm(key_projection, dim=-1).mean().item()),
"v_mean_norm": float(torch_module.linalg.vector_norm(value_projection, dim=-1).mean().item()),
"attention_score_mean": float(scores.mean().item()),
"attention_prob_mean": float(probs.mean().item()),
"context_mean_abs": float(context.abs().mean().item()),
"ffn_intermediate_mean": float(ffn_intermediate.mean().item()),
"ffn_output_mean": float(ffn_output.mean().item()),
"attention_reconstruction_mae": divergence,
}
return details
def _step_trace(
text: str,
token_count: int,
predictions: Sequence[Mapping[str, Any]],
selected_layers: Sequence[int],
) -> List[Dict[str, Any]]:
trace: List[Dict[str, Any]] = [
{
"step": "input",
"detail": f"Received {len(text)} characters.",
},
{
"step": "tokenize",
"detail": f"Generated {token_count} tokens after truncation and padding.",
},
]
for layer_index in selected_layers:
trace.append(
{
"step": f"transformer_layer_{layer_index + 1}",
"detail": "Self-attention + feed-forward block executed.",
}
)
trace.extend(
[
{
"step": "classifier",
"detail": "Linear classification head projected pooled representation into logits.",
},
{
"step": "softmax",
"detail": f"Normalized logits across {len(predictions)} output classes.",
},
]
)
return trace
def _analyze_clause(
bundle: _ModelBundle,
text: str,
max_length: int,
selected_layers: Sequence[int],
selected_heads: Sequence[int],
top_tokens: int,
) -> Dict[str, Any]:
if not text.strip():
raise ValueError("Input text is empty.")
encoded = bundle.tokenizer(
text,
truncation=True,
padding="max_length",
max_length=max_length,
return_tensors="pt",
)
with bundle.torch.no_grad():
outputs = bundle.model(
**encoded,
output_hidden_states=True,
output_attentions=True,
return_dict=True,
)
hidden_states = outputs.hidden_states
attentions = outputs.attentions
if hidden_states is None or attentions is None:
raise RuntimeError("Model did not return hidden_states and attentions. Use a BERT-compatible checkpoint.")
token_count = _sequence_length_from_mask(encoded["attention_mask"][0])
input_ids = encoded["input_ids"][0, :token_count]
raw_tokens = bundle.tokenizer.convert_ids_to_tokens(input_ids)
tokens = _clean_tokens(raw_tokens)
logits = outputs.logits[0]
probabilities = bundle.torch.softmax(logits, dim=-1).detach().cpu().tolist()
labels = []
id2label = getattr(bundle.model.config, "id2label", None)
for index in range(len(probabilities)):
label = str(id2label.get(index, str(index))) if isinstance(id2label, dict) else str(index)
labels.append(label)
predictions = [
{
"label": labels[index],
"score": float(probabilities[index]),
}
for index in range(len(probabilities))
]
predictions.sort(key=lambda item: float(item.get("score", 0.0)), reverse=True)
layer_activation = _summarize_layer_activations(bundle.torch, hidden_states, token_count)
architecture = _build_architecture_map(layer_activation)
attention = _summarize_attention(
bundle.torch,
attentions,
tokens,
token_count,
selected_layers,
selected_heads,
top_tokens,
)
math = _math_breakdown(bundle, hidden_states, attentions, token_count, selected_layers[0] if selected_layers else 0)
trace = _step_trace(text, token_count, predictions, selected_layers)
return {
"text": text,
"token_count": token_count,
"tokens": tokens,
"predictions": predictions,
"layer_activation": layer_activation,
"attention": attention,
"math_breakdown": math,
"step_trace": trace,
"architecture": architecture,
}
def _aggregate_policy_results(results: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
if not results:
return {
"clauses": 0,
"avg_token_count": 0.0,
"layer_activation": [],
"attention": [],
"label_distribution": {},
}
clause_count = len(results)
avg_tokens = sum(int(item.get("token_count", 0)) for item in results) / max(clause_count, 1)
layer_table: Dict[int, Dict[str, float]] = {}
for item in results:
for layer in item.get("layer_activation", []):
layer_idx = int(layer.get("layer", 0))
bucket = layer_table.setdefault(layer_idx, {"mean_norm": 0.0, "max_norm": 0.0, "mean_abs": 0.0, "count": 0.0})
bucket["mean_norm"] += float(layer.get("mean_norm", 0.0))
bucket["max_norm"] += float(layer.get("max_norm", 0.0))
bucket["mean_abs"] += float(layer.get("mean_abs", 0.0))
bucket["count"] += 1.0
layer_activation: List[Dict[str, Any]] = []
for layer_idx in sorted(layer_table):
bucket = layer_table[layer_idx]
count = max(float(bucket.get("count", 1.0)), 1.0)
layer_activation.append(
{
"layer": layer_idx,
"mean_norm": float(bucket.get("mean_norm", 0.0) / count),
"max_norm": float(bucket.get("max_norm", 0.0) / count),
"mean_abs": float(bucket.get("mean_abs", 0.0) / count),
}
)
attention_rows: List[Dict[str, Any]] = []
attention_table: Dict[Tuple[int, int], Dict[str, float]] = {}
for item in results:
for row in item.get("attention", []):
key = (int(row.get("layer", 0)), int(row.get("head", 0)))
bucket = attention_table.setdefault(key, {"mean": 0.0, "max": 0.0, "entropy": 0.0, "count": 0.0})
bucket["mean"] += float(row.get("mean", 0.0))
bucket["max"] += float(row.get("max", 0.0))
bucket["entropy"] += float(row.get("entropy", 0.0))
bucket["count"] += 1.0
for (layer, head), bucket in sorted(attention_table.items()):
count = max(float(bucket.get("count", 1.0)), 1.0)
attention_rows.append(
{
"layer": layer,
"head": head,
"mean": float(bucket.get("mean", 0.0) / count),
"max": float(bucket.get("max", 0.0) / count),
"entropy": float(bucket.get("entropy", 0.0) / count),
}
)
label_counts: Dict[str, int] = {}
for item in results:
predictions = item.get("predictions", [])
if not predictions:
continue
top_label = str(predictions[0].get("label", "unknown"))
label_counts[top_label] = label_counts.get(top_label, 0) + 1
return {
"clauses": clause_count,
"avg_token_count": avg_tokens,
"layer_activation": layer_activation,
"attention": attention_rows,
"label_distribution": label_counts,
}
def run_visual_layers_analysis(
*,
mode: str,
clause_text: str,
policy_text: str,
model_id: str,
model_revision: str,
max_length: int,
max_clauses: int,
selected_layers: Optional[Sequence[Any]] = None,
selected_heads: Optional[Sequence[Any]] = None,
top_tokens: int = DEFAULT_TOP_TOKENS,
) -> Dict[str, Any]:
normalized_mode = str(mode or "single_clause").strip().lower()
if normalized_mode not in {"single_clause", "full_policy"}:
raise ValueError("Mode must be either 'single_clause' or 'full_policy'.")
selected_layer_indices = _coerce_indices(selected_layers, BERT_LAYER_COUNT, default_count=4)
selected_head_indices = _coerce_indices(selected_heads, BERT_HEAD_COUNT, default_count=3)
requested_max_length = max(32, min(int(max_length), 512))
requested_max_clauses = max(1, min(int(max_clauses), 64))
requested_top_tokens = max(1, min(int(top_tokens), 24))
bundle = _get_model_bundle(_normalize_model_id(model_id), _normalize_revision(model_revision))
if normalized_mode == "single_clause":
clause = str(clause_text or "").strip()
if not clause:
raise ValueError("Single-clause mode requires input text.")
clause_result = _analyze_clause(
bundle,
clause,
requested_max_length,
selected_layer_indices,
selected_head_indices,
requested_top_tokens,
)
return {
"mode": "single_clause",
"model": {"model_id": bundle.model_id, "revision": bundle.revision},
"selected_layers": selected_layer_indices,
"selected_heads": selected_head_indices,
"max_length": requested_max_length,
"result": clause_result,
}
clauses = split_policy_clauses(policy_text)
if not clauses:
raise ValueError("Full-policy mode requires valid policy text.")
limited_clauses = clauses[:requested_max_clauses]
clause_results: List[Dict[str, Any]] = []
for clause in limited_clauses:
clause_results.append(
_analyze_clause(
bundle,
clause,
requested_max_length,
selected_layer_indices,
selected_head_indices,
requested_top_tokens,
)
)
aggregate = _aggregate_policy_results(clause_results)
architecture = _build_architecture_map(aggregate.get("layer_activation", []))
return {
"mode": "full_policy",
"model": {"model_id": bundle.model_id, "revision": bundle.revision},
"selected_layers": selected_layer_indices,
"selected_heads": selected_head_indices,
"max_length": requested_max_length,
"requested_clauses": requested_max_clauses,
"result": {
"clauses": clause_results,
"aggregate": aggregate,
"architecture": architecture,
},
}
def render_visual_layers_svg(analysis: Mapping[str, Any]) -> str:
mode = str(analysis.get("mode", "single_clause"))
result = analysis.get("result")
architecture = {}
if mode == "full_policy":
architecture = dict((result or {}).get("architecture", {})) if isinstance(result, dict) else {}
else:
architecture = dict((result or {}).get("architecture", {})) if isinstance(result, dict) else {}
nodes = architecture.get("nodes", []) if isinstance(architecture, dict) else []
edges = architecture.get("edges", []) if isinstance(architecture, dict) else []
if not nodes:
return "<div style='padding:12px;border:1px solid #d6d6d6;border-radius:10px;background:#fafaf8;'>No neural layer nodes are available for visualization.</div>"
width = 1260
height = 360
margin_x = 56.0
margin_y = 70.0
max_level = max(int(node.get("level", 0)) for node in nodes)
spacing = (width - 2 * margin_x) / max(max_level, 1)
positions: Dict[str, Tuple[float, float]] = {}
for node in nodes:
node_id = str(node.get("id", ""))
level = int(node.get("level", 0))
x = margin_x + (spacing * level)
y = height / 2.0
positions[node_id] = (x, y)
node_colors = {
"io": "#0ca678",
"embedding": "#1971c2",
"transformer": "#6741d9",
"head": "#f08c00",
}
frame_id = f"viz_{uuid.uuid4().hex}"
svg: List[str] = [
f"<div id='{frame_id}' style='border:1px solid #d6d6d6;border-radius:12px;background:#f8fafc;padding:8px;overflow:auto;'>",
f"<svg width='{width}' height='{height}' viewBox='0 0 {width} {height}' xmlns='http://www.w3.org/2000/svg'>",
"<defs><style>.ttl{font:700 14px sans-serif;fill:#1f2a37}.lbl{font:11px sans-serif;fill:#213547}.sub{font:10px sans-serif;fill:#5b6b72}.hint{font:10px sans-serif;fill:#495057}</style></defs>",
"<rect x='0' y='0' width='100%' height='100%' fill='#f8fafc' rx='14' />",
"<text class='ttl' x='24' y='28'>Visual Layers Node Map</text>",
"<text class='hint' x='24' y='44'>Scroll to zoom. Shift+scroll to pan horizontally.</text>",
f"<g id='{frame_id}_viewport' transform='translate(0,0) scale(1)'>",
]
for edge in edges:
src = str(edge.get("src", ""))
dst = str(edge.get("dst", ""))
if src not in positions or dst not in positions:
continue
x1, y1 = positions[src]
x2, y2 = positions[dst]
weight = float(edge.get("weight", 0.0))
opacity = 0.35 + min(max(weight, 0.0), 1.0) * 0.5
svg.append(
f"<line x1='{x1}' y1='{y1}' x2='{x2}' y2='{y2}' stroke='#2d3748' stroke-width='1.8' stroke-opacity='{opacity:.3f}' />"
)
for node in nodes:
node_id = str(node.get("id", ""))
if node_id not in positions:
continue
x, y = positions[node_id]
group = str(node.get("group", "transformer"))
color = node_colors.get(group, "#334155")
trigger = float(node.get("trigger", 0.0))
radius = 10.0 if group != "transformer" else 8.0 + min(max(trigger, 0.0), 16.0)
label = str(node.get("label", node_id))
safe_label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
svg.append(f"<circle cx='{x}' cy='{y}' r='{radius:.2f}' fill='{color}' fill-opacity='0.88'><title>{safe_label} | trigger={trigger:.3f}</title></circle>")
svg.append(f"<text class='lbl' x='{x - 24}' y='{y + 28}'>{label}</text>")
svg.append(f"<text class='sub' x='{x - 22}' y='{y + 42}'>trigger={trigger:.3f}</text>")
svg.append("</g>")
svg.append("</svg>")
svg.append("</div>")
svg.append(
"""
<script>
(function() {
var root = document.getElementById('"""
+ frame_id
+ """');
if (!root) return;
var viewport = document.getElementById('"""
+ frame_id
+ """_viewport');
if (!viewport) return;
var scale = 1.0;
var tx = 0.0;
var ty = 0.0;
function apply() {
viewport.setAttribute('transform', 'translate(' + tx.toFixed(2) + ',' + ty.toFixed(2) + ') scale(' + scale.toFixed(3) + ')');
}
root.addEventListener('wheel', function(event) {
event.preventDefault();
if (event.shiftKey) {
tx -= event.deltaY * 0.35;
apply();
return;
}
var zoomStep = event.deltaY < 0 ? 1.08 : 0.92;
scale = Math.max(0.45, Math.min(3.2, scale * zoomStep));
apply();
}, { passive: false });
})();
</script>
"""
)
return "".join(svg)
def render_attention_heatmap_png(
analysis: Mapping[str, Any],
*,
layer: int,
head: int,
) -> str:
mode = str(analysis.get("mode", "single_clause"))
result = _as_dict(analysis.get("result"))
attention_rows = _as_list(result.get("attention"))
if mode == "full_policy":
clauses = _as_list(result.get("clauses"))
if clauses:
attention_rows = _as_list(_as_dict(clauses[0]).get("attention"))
match = {}
for item in attention_rows:
row = _as_dict(item)
if int(row.get("layer", -1)) == int(layer) and int(row.get("head", -1)) == int(head):
match = row
break
matrix = _as_list(match.get("matrix")) if match else []
tokens = [str(token) for token in _as_list(match.get("tokens"))] if match else []
if not matrix:
raise ValueError("No heatmap matrix available for selected layer/head in the current view.")
matplotlib_module = importlib.import_module("matplotlib")
matplotlib_module.use("Agg")
pyplot = importlib.import_module("matplotlib.pyplot")
output_dir = Path(tempfile.gettempdir()) / "prert-phase4-visual-layers"
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / f"attention_heatmap_l{int(layer)}_h{int(head)}_{uuid.uuid4().hex}.png"
fig, ax = pyplot.subplots(figsize=(7.2, 6.4), dpi=160)
image = ax.imshow(matrix, cmap="viridis", interpolation="nearest", aspect="auto")
ax.set_title(f"Attention Heatmap | Layer {int(layer)} Head {int(head)}")
ax.set_xlabel("Key Token Index")
ax.set_ylabel("Query Token Index")
token_cap = min(len(tokens), 12)
if token_cap > 0:
ticks = list(range(token_cap))
short_labels = [tokens[index][:8] for index in ticks]
ax.set_xticks(ticks)
ax.set_yticks(ticks)
ax.set_xticklabels(short_labels, rotation=45, ha="right", fontsize=7)
ax.set_yticklabels(short_labels, fontsize=7)
fig.colorbar(image, ax=ax, fraction=0.046, pad=0.04)
fig.tight_layout()
fig.savefig(path, format="png")
pyplot.close(fig)
return str(path)
def _as_dict(value: Any) -> Dict[str, Any]:
return value if isinstance(value, dict) else {}
def _as_list(value: Any) -> List[Any]:
return value if isinstance(value, list) else []
def _render_png_from_architecture(analysis: Mapping[str, Any], target_path: Path) -> None:
matplotlib_module = importlib.import_module("matplotlib")
matplotlib_module.use("Agg")
pyplot = importlib.import_module("matplotlib.pyplot")
mode = str(analysis.get("mode", "single_clause"))
result = analysis.get("result")
architecture = {}
if mode == "full_policy":
architecture = dict((result or {}).get("architecture", {})) if isinstance(result, dict) else {}
else:
architecture = dict((result or {}).get("architecture", {})) if isinstance(result, dict) else {}
nodes = architecture.get("nodes", []) if isinstance(architecture, dict) else []
edges = architecture.get("edges", []) if isinstance(architecture, dict) else []
if not nodes:
raise ValueError("No architecture nodes available for PNG export.")
max_level = max(int(node.get("level", 0)) for node in nodes)
positions: Dict[str, Tuple[float, float]] = {}
for node in nodes:
node_id = str(node.get("id", ""))
level = int(node.get("level", 0))
positions[node_id] = (float(level), 0.0)
fig, ax = pyplot.subplots(figsize=(16, 3.6), dpi=160)
ax.set_axis_off()
for edge in edges:
src = str(edge.get("src", ""))
dst = str(edge.get("dst", ""))
if src not in positions or dst not in positions:
continue
x1, y1 = positions[src]
x2, y2 = positions[dst]
weight = float(edge.get("weight", 0.0))
alpha = 0.35 + min(max(weight, 0.0), 1.0) * 0.5
ax.plot([x1, x2], [y1, y2], color="#1f2937", linewidth=1.5, alpha=alpha)
for node in nodes:
node_id = str(node.get("id", ""))
if node_id not in positions:
continue
x, y = positions[node_id]
trigger = float(node.get("trigger", 0.0))
size = 160 if str(node.get("group", "")) != "transformer" else 80 + (trigger * 22)
ax.scatter([x], [y], s=size, color="#2563eb", alpha=0.85)
ax.text(x, y - 0.14, str(node.get("label", node_id)), ha="center", va="top", fontsize=8)
ax.set_xlim(-0.6, float(max_level) + 0.8)
ax.set_ylim(-0.4, 0.45)
fig.tight_layout()
fig.savefig(target_path, format="png")
pyplot.close(fig)
def export_visual_layers_map(analysis: Mapping[str, Any], file_format: str) -> str:
normalized = str(file_format or "svg").strip().lower()
output_dir = Path(tempfile.gettempdir()) / "prert-phase4-visual-layers"
output_dir.mkdir(parents=True, exist_ok=True)
if normalized == "svg":
path = output_dir / f"visual_layers_{uuid.uuid4().hex}.svg"
path.write_text(render_visual_layers_svg(analysis), encoding="utf-8")
return str(path)
if normalized == "png":
path = output_dir / f"visual_layers_{uuid.uuid4().hex}.png"
_render_png_from_architecture(analysis, path)
return str(path)
raise ValueError("Unsupported export format. Use svg or png.")
def build_visual_layers_markdown(analysis: Mapping[str, Any]) -> str:
mode = str(analysis.get("mode", "single_clause"))
model = analysis.get("model", {})
lines = [
"### Visual Layers Summary",
f"- Mode: {mode.replace('_', ' ')}",
f"- Model: {str(model.get('model_id', 'n/a'))}",
f"- Revision: {str(model.get('revision', 'n/a'))}",
]
if mode == "single_clause":
result = analysis.get("result", {})
token_count = int(result.get("token_count", 0)) if isinstance(result, dict) else 0
predictions = result.get("predictions", []) if isinstance(result, dict) else []
lines.append(f"- Tokens analyzed: {token_count}")
if predictions:
top = predictions[0]
lines.append(f"- Top label: {top.get('label', 'n/a')} ({float(top.get('score', 0.0)):.4f})")
else:
result = analysis.get("result", {})
aggregate = result.get("aggregate", {}) if isinstance(result, dict) else {}
lines.append(f"- Clauses analyzed: {int(aggregate.get('clauses', 0))}")
lines.append(f"- Avg tokens per clause: {float(aggregate.get('avg_token_count', 0.0)):.2f}")
distribution = aggregate.get("label_distribution", {})
if isinstance(distribution, dict) and distribution:
joined = ", ".join(f"{key}:{value}" for key, value in sorted(distribution.items()))
lines.append(f"- Dominant label distribution: {joined}")
return "\n".join(lines)
def write_visual_layers_json(analysis: Mapping[str, Any]) -> str:
output_dir = Path(tempfile.gettempdir()) / "prert-phase4-visual-layers"
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / f"visual_layers_{uuid.uuid4().hex}.json"
path.write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding="utf-8")
return str(path)