""" FALSIFY presentation helpers — console state + interactive graph visualization. These functions turn the belief graph into the two things a judge actually sees: * :func:`print_graph_state` — the BEFORE/AFTER console beat, each node tagged with a colored ``ALIVE`` / ``REFUTED`` / ``INVALIDATED`` / ``SUPERSEDED`` marker. * :func:`visualize_belief_graph` — a single self-contained HTML file (vis-network via CDN, no build step) that colors nodes by truth-state so the cascade is legible at a glance: green = alive, red (dashed) = refuted, grey = invalidated, amber = superseded. * :func:`get_belief_summary` — counts by type/state, used in output and tests. * :func:`verify_propagation` — a small assertion helper for tests. Everything degrades gracefully on an empty graph and never hard-depends on ``rich``. """ from __future__ import annotations import html import json import logging import os from typing import Dict, List, Optional from falsify import graph_ops from falsify.models import TruthState logger = logging.getLogger("falsify.utils") # truth-state -> (console tag, hex color for the graph) _STATE_STYLE = { TruthState.ALIVE.value: ("ALIVE", "#22c55e"), TruthState.REFUTED.value: ("REFUTED", "#ef4444"), TruthState.INVALIDATED.value: ("INVALIDATED", "#9ca3af"), TruthState.SUPERSEDED.value: ("SUPERSEDED", "#f59e0b"), TruthState.FORGOTTEN.value: ("FORGOTTEN", "#4b5563"), } # ANSI colors for console tags (fall back to plain text if not a TTY). _ANSI = { "ALIVE": "\033[92m", "REFUTED": "\033[91m", "INVALIDATED": "\033[90m", "SUPERSEDED": "\033[93m", "FORGOTTEN": "\033[90m", } _RESET = "\033[0m" def _colored(tag: str) -> str: """Return an ANSI-colored tag if stdout is a TTY, else the plain tag.""" if os.environ.get("NO_COLOR") or not _stdout_is_tty(): return tag return f"{_ANSI.get(tag, '')}{tag}{_RESET}" def _stdout_is_tty() -> bool: try: import sys return bool(sys.stdout.isatty()) except Exception: return False async def _state_of(node_ids: List[str]) -> Dict[str, str]: """Return ``{id: single-state-string}`` (first alignment entry, default alive).""" truth = await graph_ops.get_truth(node_ids) return {nid: (align[0] if align else TruthState.ALIVE.value) for nid, align in truth.items()} async def get_belief_summary(dataset: Optional[str] = None) -> Dict[str, Dict[str, int]]: """Count nodes grouped by node type and truth-state. Returns e.g. ``{"Hypothesis": {"alive": 2, "superseded": 1}, "Conclusion": {...}}``. Node type is inferred from the node's ``type`` property, falling back to the dominant embeddable field present. """ nodes, _edges = await graph_ops.load_graph() if not nodes: return {} states = await _state_of([str(nid) for nid, _p in nodes]) summary: Dict[str, Dict[str, int]] = {} for nid, props in nodes: ntype = _infer_type(props) state = states.get(str(nid), TruthState.ALIVE.value) summary.setdefault(ntype, {}) summary[ntype][state] = summary[ntype].get(state, 0) + 1 return summary def _infer_type(props: dict) -> str: """Best-effort node-type label from properties.""" if props.get("type"): return str(props["type"]) for field, label in ( ("question", "InvestigationQuestion"), ("claim", "Evidence"), ("statement", "Hypothesis/Conclusion"), ("text", "Assertion"), ): if props.get(field): return label return "Node" async def print_graph_state(title: str, dataset: Optional[str] = None) -> None: """Print every node with a colored truth-state tag (the BEFORE/AFTER beat).""" nodes, _edges = await graph_ops.load_graph() print(f"\n{'=' * 64}\n {title}\n{'=' * 64}") if not nodes: print(" (empty graph)") return states = await _state_of([str(nid) for nid, _p in nodes]) # Stable, readable ordering: questions, hypotheses, evidence, conclusions. order = {"InvestigationQuestion": 0, "Hypothesis/Conclusion": 1, "Evidence": 2, "Assertion": 3} rows = [] for nid, props in nodes: ntype = _infer_type(props) state = states.get(str(nid), TruthState.ALIVE.value) tag = _STATE_STYLE.get(state, ("ALIVE", ""))[0] label = graph_ops.node_label(props) rows.append((order.get(ntype, 9), ntype, tag, label)) for _o, ntype, tag, label in sorted(rows, key=lambda r: r[0]): clipped = label if len(label) <= 66 else label[:63] + "..." print(f" [{_colored(tag):<22}] {ntype:<22} {clipped}") print() async def visualize_belief_graph( out_path: str = "output/graph.html", dataset: Optional[str] = None, title: str = "FALSIFY belief graph", ) -> Optional[str]: """Write a self-contained interactive HTML visualization of the belief graph. Nodes are colored by truth-state (green/red/grey/amber). Refuted nodes are drawn with a dashed red border so the cascade result reads at a glance. Returns the written path, or ``None`` if the graph is empty. """ nodes, edges = await graph_ops.load_graph() if not nodes: logger.info("visualize_belief_graph: empty graph, nothing to draw") return None states = await _state_of([str(nid) for nid, _p in nodes]) vis_nodes = [] for nid, props in nodes: nid = str(nid) state = states.get(nid, TruthState.ALIVE.value) tag, color = _STATE_STYLE.get(state, ("ALIVE", "#22c55e")) label = graph_ops.node_label(props) short = label if len(label) <= 40 else label[:37] + "..." vis_nodes.append( { "id": nid, "label": short, "title": f"{_infer_type(props)} — {tag}\n{html.escape(label)}", "color": {"background": color, "border": "#111827"}, "shapeProperties": {"borderDashes": state == TruthState.REFUTED.value}, "borderWidth": 3 if state == TruthState.REFUTED.value else 1, "font": {"color": "#0b1020"}, } ) vis_edges = [] for src, dst, rel, props in edges: vis_edges.append( { "from": str(src), "to": str(dst), "label": rel, "arrows": "to", "font": {"align": "middle", "size": 10}, "color": {"color": "#94a3b8"}, } ) os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) doc = _HTML_TEMPLATE.replace("__TITLE__", html.escape(title)) \ .replace("__NODES__", json.dumps(vis_nodes)) \ .replace("__EDGES__", json.dumps(vis_edges)) with open(out_path, "w", encoding="utf-8") as fh: fh.write(doc) logger.info("wrote visualization to %s (%d nodes, %d edges)", out_path, len(vis_nodes), len(vis_edges)) return out_path async def verify_propagation(refuted_id: str, expected_affected: List[str]) -> bool: """Test helper: assert every ``expected_affected`` id is now non-alive. Returns True iff the refuted node is refuted and each expected dependent is in a dead state (refuted/invalidated/forgotten or absent from the graph). """ ids = [str(refuted_id)] + [str(x) for x in expected_affected] truth = await graph_ops.get_truth(ids) dead = {TruthState.REFUTED.value, TruthState.INVALIDATED.value, TruthState.FORGOTTEN.value} nodes, _edges = await graph_ops.load_graph() present = {str(nid) for nid, _p in nodes} r_align = truth.get(str(refuted_id), []) if TruthState.REFUTED.value not in r_align: return False for dep in expected_affected: dep = str(dep) if dep not in present: # forgotten (deleted) counts as affected continue if not any(s in dead for s in truth.get(dep, [TruthState.ALIVE.value])): return False return True _HTML_TEMPLATE = """