Falsify / falsify /utils.py
Aaryan Kumar
deploy to hugging face
1605cbb
Raw
History Blame Contribute Delete
9.53 kB
"""
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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>__TITLE__</title>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
body { margin:0; font-family: ui-sans-serif, system-ui, sans-serif; background:#0b1020; color:#e5e7eb; }
#hdr { padding:14px 18px; font-size:18px; font-weight:600; border-bottom:1px solid #1f2937; }
#legend { padding:8px 18px; font-size:13px; color:#9ca3af; }
.chip { display:inline-block; width:11px; height:11px; border-radius:3px; margin:0 5px 0 14px; vertical-align:middle; }
#net { width:100%; height:calc(100vh - 92px); }
</style>
</head>
<body>
<div id="hdr">__TITLE__</div>
<div id="legend">
<span class="chip" style="background:#22c55e"></span>alive
<span class="chip" style="background:#ef4444"></span>refuted
<span class="chip" style="background:#9ca3af"></span>invalidated
<span class="chip" style="background:#f59e0b"></span>superseded
</div>
<div id="net"></div>
<script>
const nodes = new vis.DataSet(__NODES__);
const edges = new vis.DataSet(__EDGES__);
const container = document.getElementById('net');
const options = {
physics: { stabilization: true, barnesHut: { gravitationalConstant: -8000, springLength: 150 } },
nodes: { shape: 'box', margin: 10, widthConstraint: { maximum: 200 } },
edges: { smooth: { type: 'cubicBezier' } }
};
new vis.Network(container, { nodes, edges }, options);
</script>
</body>
</html>
"""