better graph explorer
Validate Operations / validate-operations (push) Waiting to run

re-enable locations in dumps
This commit is contained in:
NiccoloN
2026-07-22 14:25:56 +02:00
parent 563921bccd
commit 8da4603be3
15 changed files with 500 additions and 188 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ void dumpModule(mlir::ModuleOp moduleOp, const std::string& name, bool assumeVer
llvm::raw_os_ostream os(file);
mlir::OpPrintingFlags flags;
flags.elideLargeElementsAttrs().enableDebugInfo(false, false);
flags.elideLargeElementsAttrs().enableDebugInfo(true, false);
if (assumeVerified)
flags.assumeVerified();
moduleOp.print(os, flags);
+6 -4
View File
@@ -155,11 +155,11 @@ Motifs are not inferred from rendered geometry. For each operation graph the too
## Viewer and API
Except for spatial4, the viewer initially fetches an aggregate operation graph unless the browser has a saved view choice. Sigma renders the graph with WebGL. Controls cover report/view/metric selection, text and tensor search, mapping filters, self edges, relayout, fitting, and motif selection. Raw nodes and edges have their own selection details and never call aggregate-only detail or mapping endpoints. Mapping panels remain available for operation aggregate edges.
The viewer initially fetches an aggregate operation graph, so a previously selected raw view cannot make a new report exceed the display safety cap before the first render. Browser responses disable caching so HTML and JavaScript from different tool revisions cannot be mixed. Sigma renders the graph with WebGL. Controls cover report/view selection, text and tensor search, mapping filters, self edges, relayout, fitting, and motif selection. Edges use a fixed width. Raw nodes and edges have their own selection details and never call aggregate-only detail or mapping endpoints. Nodes without an SSA name fall back to their source identity. Mapping panels remain available for operation aggregate edges.
Operation expansion is a deterministic projection of the source graph. Expand selected, Expand all operations, Collapse selected operation, and Collapse all rebuild the complete display from the current expanded-operation set. An expanded operation's raw nodes replace its aggregate node, including isolated raw nodes. An aggregate edge is retained only when both endpoint operations are collapsed; otherwise its raw edges replace it with endpoints calculated from the complete expansion set. Expansion order therefore cannot leave stale endpoints or simultaneous aggregate/raw representations.
Operation ranks are calculated from the complete operation graph, including isolated operations. Collapsed nodes use stable operation anchors. Expanded nodes are sorted by lane and node ID; lane numbers form perpendicular rows, equal lanes align across adjacent operations, and lane-less nodes use distinct deterministic scalar rows. This same model is used by Rerun layout, so unrelated operations do not jump during expansion or collapse and disconnected nodes never pile up at `(0, 0)`.
Operation ranks are calculated from the complete operation graph, including isolated operations, and every view flows top-to-bottom. A collapsed operation reserves one displayed row. Expanded nodes use compact rows for distinct lanes in numeric order, followed by deterministic lane-less rows, so sparse lane numbers create no empty geometric space. Raw nodes sharing a lane receive centered deterministic horizontal offsets. Operation and raw views use stable server coordinates, and `Reset layout` restores them; core and node-kind views use ELK, and `Rerun layout` runs it again. Operation labels use the first SSA name; Sigma's collision grid thins normal labels, and a label is skipped when its measured screen rectangle intersects another visible node. Selected or hovered nodes keep the original white bubble with black text. Selecting a motif frames its member nodes on the first click. Aggregate-node sizes remain bounded.
Browser dependencies are pinned in one place, `static/index.html`:
@@ -192,8 +192,10 @@ Raw pages default to 100 and cannot exceed 500. Subgraph depth cannot exceed fiv
## Performance behavior
- CSV readers stream rows and insert them in bounded batches.
- Temporary SQLite staging and bulk joins resolve endpoints; ingestion performs no per-edge node query.
- Secondary indexes are created after raw insertion.
- Temporary SQLite staging tables are function-scoped and dropped immediately; bulk joins resolve endpoints without per-edge node queries.
- CSVs without additional columns use a constant empty JSON representation.
- Ingestion closes its write connection before derived indexes, aggregation, motifs, and diagnostics reopen the database.
- Secondary indexes are created after raw insertion and focus on serving and aggregation queries.
- Raw edges are never retained as a Python object graph or loaded into NetworkX.
- Only aggregate operation nodes/edges enter NetworkX.
- Mapping statistics are grouped in SQL; exact stencil comparison streams one operation pair.
@@ -42,6 +42,12 @@ def create_app(output_directory: str | Path, *, max_page_size: int = 500,
manifest = json.loads(manifest_path.read_text())
app = FastAPI(title="Raptor Graph Explorer", version=manifest.get("tool_version", "unknown"))
@app.middleware("http")
async def disable_browser_cache(request, call_next):
response = await call_next(request)
response.headers["Cache-Control"] = "no-store"
return response
def connection():
db = connect_readonly(database)
try:
@@ -10,7 +10,7 @@ from pathlib import Path
from . import __version__
from .aggregate import aggregate_report
from .database import connect_readonly, create_database, create_indexes, store_diagnostics
from .database import connect_readonly, connect_writable, create_database, create_indexes, store_diagnostics
from .discovery import discover_reports
from .ingest import ingest_report
from .motifs import analyze_report
@@ -52,6 +52,12 @@ def build_output(inputs: list[str | Path], output_directory: str | Path, *, stri
with connection:
for pair in pairs:
ingest_report(connection, pair, diagnostics)
finally:
connection.close()
connection = connect_writable(output / "graph.sqlite")
try:
with connection:
create_indexes(connection)
for pair in pairs:
aggregate_report(connection, pair.report_id)
@@ -7,7 +7,6 @@ from pathlib import Path
from .schema import Diagnostic, SCHEMA_VERSION
DDL = """
PRAGMA foreign_keys=ON;
CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE reports(
report_id TEXT PRIMARY KEY, stage TEXT NOT NULL, raw_node_count INTEGER NOT NULL DEFAULT 0,
@@ -65,25 +64,25 @@ CREATE TABLE diagnostics(
"""
INDEX_DDL = """
CREATE INDEX raw_nodes_report_op ON raw_nodes(report_id,op_id);
CREATE INDEX raw_nodes_report_lane ON raw_nodes(report_id,op_id,lane);
CREATE INDEX raw_nodes_report_core ON raw_nodes(report_id,core);
CREATE INDEX raw_edges_source_node ON raw_edges(report_id,source_node_id);
CREATE INDEX raw_edges_target_node ON raw_edges(report_id,target_node_id);
CREATE INDEX raw_edges_source_op ON raw_edges(report_id,source_op_id,target_op_id);
CREATE INDEX raw_edges_target_op ON raw_edges(report_id,target_op_id,source_op_id);
CREATE INDEX raw_edges_mapping ON raw_edges(report_id,source_op_id,target_op_id,source_lane,target_lane);
CREATE INDEX raw_edges_channel ON raw_edges(report_id,channel_id);
CREATE INDEX aggregate_nodes_lookup ON aggregate_nodes(report_id,level,op_id,core);
CREATE INDEX aggregate_edges_lookup ON aggregate_edges(report_id,level);
CREATE INDEX motifs_report ON motifs(report_id);
"""
def create_database(path: Path) -> sqlite3.Connection:
def connect_writable(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path)
connection.row_factory = sqlite3.Row
connection.executescript("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;" + DDL)
connection.executescript("PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
return connection
def create_database(path: Path) -> sqlite3.Connection:
connection = connect_writable(path)
connection.executescript(DDL)
connection.execute("INSERT INTO metadata VALUES('schema_version',?)", (str(SCHEMA_VERSION),))
return connection
@@ -4,6 +4,7 @@ import csv
import json
import re
import sqlite3
import sys
from pathlib import Path
from .schema import DiagnosticCollector, EDGE_COLUMNS, NODE_COLUMNS, NODE_KINDS, ReportPair
@@ -36,9 +37,10 @@ def _id_parts(node_id: str) -> tuple[str, int | None, int | None]:
return prefix, op_id, lane
def _extra(row: dict[str, str | None], known: set[str]) -> str:
return json.dumps({key: value for key, value in row.items() if key not in known},
sort_keys=True, separators=(",", ":"))
def _extra(row: dict[str, str | None], columns: tuple[str, ...]) -> str:
if not columns:
return "{}"
return json.dumps({key: row.get(key) for key in columns}, sort_keys=True, separators=(",", ":"))
def _batches(rows, size: int = BATCH_SIZE):
@@ -61,6 +63,15 @@ def _check_columns(reader: csv.DictReader, required: set[str], kind: str, report
return True
def _drop_staging(connection: sqlite3.Connection, table: str) -> None:
active_exception = sys.exc_info()[0]
try:
connection.execute(f"DROP TABLE IF EXISTS temp.{table}")
except sqlite3.Error:
if active_exception is None:
raise
def _read_nodes(connection: sqlite3.Connection, pair: ReportPair, stage: str,
diagnostics: DiagnosticCollector) -> int:
connection.executescript("""
@@ -70,63 +81,67 @@ def _read_nodes(connection: sqlite3.Connection, pair: ReportPair, stage: str,
core INTEGER, ssa_name TEXT, attributes_json TEXT
);
""")
valid = 0
with pair.nodes_path.open(newline="", encoding="utf-8-sig") as stream:
reader = csv.DictReader(stream)
if not _check_columns(reader, NODE_COLUMNS, "node", pair.report_id, diagnostics):
return 0
try:
valid = 0
with pair.nodes_path.open(newline="", encoding="utf-8-sig") as stream:
reader = csv.DictReader(stream)
if not _check_columns(reader, NODE_COLUMNS, "node", pair.report_id, diagnostics):
return 0
extra_columns = tuple(column for column in reader.fieldnames or () if column not in NODE_COLUMNS)
def normalized():
nonlocal valid
for row_number, row in enumerate(reader, 2):
node_id = (row.get("Id") or "").strip()
if not node_id:
diagnostics.add("invalid_node", "node Id is empty", pair.report_id, f"row {row_number}")
continue
prefix, id_op, id_lane = _id_parts(node_id)
try:
op_id = _integer(row.get("op_id"), "op_id")
lane = _integer(row.get("lane"), "lane")
core = _integer(row.get("core"), "core")
except ValueError as exc:
diagnostics.add("malformed_integer", str(exc), pair.report_id, f"row {row_number}")
continue
if op_id is None:
if id_op is None:
diagnostics.add("invalid_node", "missing op_id and unusable node ID fallback", pair.report_id,
f"row {row_number}: {node_id}")
def normalized():
nonlocal valid
for row_number, row in enumerate(reader, 2):
node_id = (row.get("Id") or "").strip()
if not node_id:
diagnostics.add("invalid_node", "node Id is empty", pair.report_id, f"row {row_number}")
continue
op_id = id_op
diagnostics.add("id_fallback", "op_id parsed from node ID", pair.report_id, node_id,
severity="warning")
if lane is None and prefix in {"gcb", "scb"} and id_lane is not None:
lane = id_lane
diagnostics.add("id_fallback", "lane parsed from node ID", pair.report_id, node_id,
severity="warning")
valid += 1
yield (row_number, node_id, NODE_KINDS.get(prefix, f"unknown:{prefix}"), op_id, lane, core,
row.get("ssa_name") or "", _extra(row, NODE_COLUMNS))
prefix, id_op, id_lane = _id_parts(node_id)
try:
op_id = _integer(row.get("op_id"), "op_id")
lane = _integer(row.get("lane"), "lane")
core = _integer(row.get("core"), "core")
except ValueError as exc:
diagnostics.add("malformed_integer", str(exc), pair.report_id, f"row {row_number}")
continue
if op_id is None:
if id_op is None:
diagnostics.add("invalid_node", "missing op_id and unusable node ID fallback",
pair.report_id, f"row {row_number}: {node_id}")
continue
op_id = id_op
diagnostics.add("id_fallback", "op_id parsed from node ID", pair.report_id, node_id,
severity="warning")
if lane is None and prefix in {"gcb", "scb"} and id_lane is not None:
lane = id_lane
diagnostics.add("id_fallback", "lane parsed from node ID", pair.report_id, node_id,
severity="warning")
valid += 1
yield (row_number, node_id, NODE_KINDS.get(prefix, f"unknown:{prefix}"), op_id, lane, core,
row.get("ssa_name") or "", _extra(row, extra_columns))
for batch in _batches(normalized()):
connection.executemany("INSERT INTO ingest_nodes VALUES(?,?,?,?,?,?,?,?)", batch)
for batch in _batches(normalized()):
connection.executemany("INSERT INTO ingest_nodes VALUES(?,?,?,?,?,?,?,?)", batch)
duplicate = connection.execute(
"SELECT COALESCE(SUM(c-1),0) FROM (SELECT COUNT(*) c FROM ingest_nodes GROUP BY node_id HAVING c>1)"
).fetchone()[0]
if duplicate:
examples = [row[0] for row in connection.execute(
"SELECT node_id FROM ingest_nodes GROUP BY node_id HAVING COUNT(*)>1 ORDER BY node_id LIMIT 5")]
diagnostics.add("duplicate_node_id", "duplicate node ID; first row retained", pair.report_id,
", ".join(examples), duplicate)
connection.execute("""
INSERT INTO raw_nodes(report_id,stage,node_id,node_kind,op_id,lane,core,ssa_name,attributes_json)
SELECT ?,?,n.node_id,n.node_kind,n.op_id,n.lane,n.core,n.ssa_name,n.attributes_json
FROM ingest_nodes n
JOIN (SELECT node_id,MIN(row_number) first_row FROM ingest_nodes GROUP BY node_id) first
ON first.node_id=n.node_id AND first.first_row=n.row_number
ORDER BY n.row_number
""", (pair.report_id, stage))
return valid - duplicate
duplicate = connection.execute(
"SELECT COALESCE(SUM(c-1),0) FROM (SELECT COUNT(*) c FROM ingest_nodes GROUP BY node_id HAVING c>1)"
).fetchone()[0]
if duplicate:
examples = [row[0] for row in connection.execute(
"SELECT node_id FROM ingest_nodes GROUP BY node_id HAVING COUNT(*)>1 ORDER BY node_id LIMIT 5")]
diagnostics.add("duplicate_node_id", "duplicate node ID; first row retained", pair.report_id,
", ".join(examples), duplicate)
connection.execute("""
INSERT INTO raw_nodes(report_id,stage,node_id,node_kind,op_id,lane,core,ssa_name,attributes_json)
SELECT ?,?,n.node_id,n.node_kind,n.op_id,n.lane,n.core,n.ssa_name,n.attributes_json
FROM ingest_nodes n
JOIN (SELECT node_id,MIN(row_number) first_row FROM ingest_nodes GROUP BY node_id) first
ON first.node_id=n.node_id AND first.first_row=n.row_number
ORDER BY n.row_number
""", (pair.report_id, stage))
return valid - duplicate
finally:
_drop_staging(connection, "ingest_nodes")
def _read_edges(connection: sqlite3.Connection, pair: ReportPair, stage: str,
@@ -139,74 +154,79 @@ def _read_edges(connection: sqlite3.Connection, pair: ReportPair, stage: str,
tensor_type TEXT, channel_id INTEGER, attributes_json TEXT
);
""")
with pair.edges_path.open(newline="", encoding="utf-8-sig") as stream:
reader = csv.DictReader(stream)
if not _check_columns(reader, EDGE_COLUMNS, "edge", pair.report_id, diagnostics):
return 0
try:
with pair.edges_path.open(newline="", encoding="utf-8-sig") as stream:
reader = csv.DictReader(stream)
if not _check_columns(reader, EDGE_COLUMNS, "edge", pair.report_id, diagnostics):
return 0
extra_columns = tuple(column for column in reader.fieldnames or () if column not in EDGE_COLUMNS)
def normalized():
for row_number, row in enumerate(reader, 2):
source = (row.get("Source") or "").strip()
target = (row.get("Target") or "").strip()
if not source or not target:
diagnostics.add("invalid_edge", "edge Source or Target is empty", pair.report_id,
f"row {row_number}")
continue
try:
source_lane = _integer(row.get("source_lane"), "source_lane")
target_lane = _integer(row.get("target_lane"), "target_lane")
weight = _integer(row.get("Weight"), "Weight")
channel_id = _integer(row.get("channel_id"), "channel_id")
except ValueError as exc:
diagnostics.add("malformed_integer", str(exc), pair.report_id, f"row {row_number}")
continue
yield (row_number, (row.get("stage") or stage).strip() or stage, source, target,
source_lane, target_lane, weight, row.get("Type") or "", channel_id,
_extra(row, EDGE_COLUMNS))
def normalized():
for row_number, row in enumerate(reader, 2):
source = (row.get("Source") or "").strip()
target = (row.get("Target") or "").strip()
if not source or not target:
diagnostics.add("invalid_edge", "edge Source or Target is empty", pair.report_id,
f"row {row_number}")
continue
try:
source_lane = _integer(row.get("source_lane"), "source_lane")
target_lane = _integer(row.get("target_lane"), "target_lane")
weight = _integer(row.get("Weight"), "Weight")
channel_id = _integer(row.get("channel_id"), "channel_id")
except ValueError as exc:
diagnostics.add("malformed_integer", str(exc), pair.report_id, f"row {row_number}")
continue
yield (row_number, (row.get("stage") or stage).strip() or stage, source, target,
source_lane, target_lane, weight, row.get("Type") or "", channel_id,
_extra(row, extra_columns))
for batch in _batches(normalized()):
connection.executemany("INSERT INTO ingest_edges VALUES(?,?,?,?,?,?,?,?,?,?)", batch)
for batch in _batches(normalized()):
connection.executemany("INSERT INTO ingest_edges VALUES(?,?,?,?,?,?,?,?,?,?)", batch)
unknown_count = connection.execute("""
SELECT COUNT(*) FROM ingest_edges e
LEFT JOIN raw_nodes s ON s.report_id=? AND s.node_id=e.source_node_id
LEFT JOIN raw_nodes t ON t.report_id=? AND t.node_id=e.target_node_id
WHERE s.node_id IS NULL OR t.node_id IS NULL
""", (pair.report_id, pair.report_id)).fetchone()[0]
if unknown_count:
examples = [f"{row[0]}->{row[1]}" for row in connection.execute("""
SELECT e.source_node_id,e.target_node_id FROM ingest_edges e
unknown_count = connection.execute("""
SELECT COUNT(*) FROM ingest_edges e
LEFT JOIN raw_nodes s ON s.report_id=? AND s.node_id=e.source_node_id
LEFT JOIN raw_nodes t ON t.report_id=? AND t.node_id=e.target_node_id
WHERE s.node_id IS NULL OR t.node_id IS NULL ORDER BY e.row_number LIMIT 5
""", (pair.report_id, pair.report_id))]
diagnostics.add("unknown_endpoint", "edge endpoint is absent from the matching report; edge omitted",
pair.report_id, ", ".join(examples), unknown_count)
WHERE s.node_id IS NULL OR t.node_id IS NULL
""", (pair.report_id, pair.report_id)).fetchone()[0]
if unknown_count:
examples = [f"{row[0]}->{row[1]}" for row in connection.execute("""
SELECT e.source_node_id,e.target_node_id FROM ingest_edges e
LEFT JOIN raw_nodes s ON s.report_id=? AND s.node_id=e.source_node_id
LEFT JOIN raw_nodes t ON t.report_id=? AND t.node_id=e.target_node_id
WHERE s.node_id IS NULL OR t.node_id IS NULL ORDER BY e.row_number LIMIT 5
""", (pair.report_id, pair.report_id))]
diagnostics.add("unknown_endpoint", "edge endpoint is absent from the matching report; edge omitted",
pair.report_id, ", ".join(examples), unknown_count)
contradictions = connection.execute("""
SELECT COUNT(*) FROM ingest_edges e
JOIN raw_nodes s ON s.report_id=? AND s.node_id=e.source_node_id
JOIN raw_nodes t ON t.report_id=? AND t.node_id=e.target_node_id
WHERE (e.explicit_source_lane IS NOT NULL AND s.lane IS NOT NULL AND e.explicit_source_lane<>s.lane)
OR (e.explicit_target_lane IS NOT NULL AND t.lane IS NOT NULL AND e.explicit_target_lane<>t.lane)
""", (pair.report_id, pair.report_id)).fetchone()[0]
if contradictions:
diagnostics.add("contradictory_lane", "explicit edge lane contradicts endpoint lane; explicit lane retained",
pair.report_id, count=contradictions)
contradictions = connection.execute("""
SELECT COUNT(*) FROM ingest_edges e
JOIN raw_nodes s ON s.report_id=? AND s.node_id=e.source_node_id
JOIN raw_nodes t ON t.report_id=? AND t.node_id=e.target_node_id
WHERE (e.explicit_source_lane IS NOT NULL AND s.lane IS NOT NULL AND e.explicit_source_lane<>s.lane)
OR (e.explicit_target_lane IS NOT NULL AND t.lane IS NOT NULL AND e.explicit_target_lane<>t.lane)
""", (pair.report_id, pair.report_id)).fetchone()[0]
if contradictions:
diagnostics.add("contradictory_lane",
"explicit edge lane contradicts endpoint lane; explicit lane retained",
pair.report_id, count=contradictions)
connection.execute("""
INSERT INTO raw_edges(
edge_id,report_id,stage,source_node_id,target_node_id,source_op_id,target_op_id,
source_lane,target_lane,weight,tensor_type,channel_id,attributes_json)
SELECT printf('%s:e:%012d',?,e.row_number),?,e.stage,e.source_node_id,e.target_node_id,s.op_id,t.op_id,
COALESCE(e.explicit_source_lane,s.lane),COALESCE(e.explicit_target_lane,t.lane),
e.weight,e.tensor_type,e.channel_id,e.attributes_json
FROM ingest_edges e
JOIN raw_nodes s ON s.report_id=? AND s.node_id=e.source_node_id
JOIN raw_nodes t ON t.report_id=? AND t.node_id=e.target_node_id
ORDER BY e.row_number
""", (pair.report_id, pair.report_id, pair.report_id, pair.report_id))
return connection.execute("SELECT COUNT(*) FROM raw_edges WHERE report_id=?", (pair.report_id,)).fetchone()[0]
connection.execute("""
INSERT INTO raw_edges(
edge_id,report_id,stage,source_node_id,target_node_id,source_op_id,target_op_id,
source_lane,target_lane,weight,tensor_type,channel_id,attributes_json)
SELECT printf('%s:e:%012d',?,e.row_number),?,e.stage,e.source_node_id,e.target_node_id,s.op_id,t.op_id,
COALESCE(e.explicit_source_lane,s.lane),COALESCE(e.explicit_target_lane,t.lane),
e.weight,e.tensor_type,e.channel_id,e.attributes_json
FROM ingest_edges e
JOIN raw_nodes s ON s.report_id=? AND s.node_id=e.source_node_id
JOIN raw_nodes t ON t.report_id=? AND t.node_id=e.target_node_id
ORDER BY e.row_number
""", (pair.report_id, pair.report_id, pair.report_id, pair.report_id))
return connection.execute("SELECT COUNT(*) FROM raw_edges WHERE report_id=?", (pair.report_id,)).fetchone()[0]
finally:
_drop_staging(connection, "ingest_edges")
def ingest_report(connection: sqlite3.Connection, pair: ReportPair, diagnostics: DiagnosticCollector) -> None:
@@ -69,12 +69,15 @@ def _dominates(dominators: dict, entry: int, node: int) -> bool:
def detect_motifs(graph: nx.DiGraph) -> tuple[list[Motif], int]:
if not graph:
return [], 0
dag, components, _ = _condense(graph)
dag, components, member_to_component = _condense(graph)
source, sink = "__source__", "__sink__"
dag.add_node(source)
dag.add_node(sink)
dag.add_edges_from((source, node) for node in list(dag) if node not in {source, sink} and dag.in_degree(node) == 0)
dag.add_edges_from((node, sink) for node in list(dag) if node not in {source, sink} and dag.out_degree(node) == 0)
ranks = {}
for component in nx.topological_sort(dag):
ranks[component] = max((ranks[parent] + 1 for parent in dag.predecessors(component)), default=0)
dominators = nx.immediate_dominators(dag, source)
post_dominators = nx.immediate_dominators(dag.reverse(copy=False), sink)
motifs = []
@@ -113,7 +116,8 @@ def detect_motifs(graph: nx.DiGraph) -> tuple[list[Motif], int]:
motifs.append(Motif(entry_aggregate, exit_aggregate, internal_aggregates, branch_aggregates, paths,
len(expanded), subgraph.number_of_edges(), cycle, kind,
1.0 if kind != "split_rejoin" else 0.9))
motifs.sort(key=lambda motif: (motif.entry, motif.exit, motif.internal))
motifs.sort(key=lambda motif: (ranks[member_to_component[motif.entry]], motif.entry,
motif.exit, motif.internal))
return motifs, len(components)
@@ -11,7 +11,9 @@ JSON_COLUMNS = {
"tensor_type_histogram",
}
LANE_SPACING = 42
OPERATION_SPACING = 240
OPERATION_SPACING = 126
RANK_SPACING = 600
DUPLICATE_NODE_SPACING = 12
class DisplayGraphTooLarge(ValueError):
@@ -25,7 +27,8 @@ def _record(row: sqlite3.Row) -> dict:
return result
def _operation_anchors(nodes: list[dict], edges: list[dict]) -> dict[int, tuple[float, float]]:
def _operation_anchors(nodes: list[dict], edges: list[dict],
expanded_rows: dict[int, int]) -> dict[int, tuple[float, float]]:
graph = nx.DiGraph()
graph.add_nodes_from(node["op_id"] for node in nodes)
graph.add_edges_from((edge["source_op_id"], edge["target_op_id"]) for edge in edges)
@@ -39,17 +42,13 @@ def _operation_anchors(nodes: list[dict], edges: list[dict]) -> dict[int, tuple[
by_rank[operation_ranks[node["op_id"]]].append(node)
anchors = {}
rank_offset = 0
for rank, ranked_nodes in sorted(by_rank.items()):
ranked_nodes.sort(key=lambda item: item["op_id"])
cursor = 0
for node in ranked_nodes:
minimum = node["minimum_lane"] if node["minimum_lane"] is not None else 0
maximum = node["maximum_lane"] if node["maximum_lane"] is not None else minimum - 1
lane_rows = max(0, maximum - minimum + 1)
scalar_rows = max(0, node["instance_count"] - node["lane_count"])
anchor_y = 0 if len(ranked_nodes) == 1 else cursor - minimum * LANE_SPACING
anchors[node["op_id"]] = (rank * OPERATION_SPACING, anchor_y)
cursor += max(1, lane_rows + scalar_rows) * LANE_SPACING + 2 * LANE_SPACING
for index, node in enumerate(ranked_nodes):
anchors[node["op_id"]] = (index * OPERATION_SPACING, -rank_offset)
rank_offset += max(expanded_rows.get(node["op_id"], 1) for node in ranked_nodes) * LANE_SPACING
rank_offset += RANK_SPACING
return anchors
@@ -90,6 +89,35 @@ def _raw_edges(db: sqlite3.Connection, report_id: str, expanded: set[int], all_e
return [result[key] for key in sorted(result)]
def _display_rows(raw_nodes: list[dict]) -> tuple[
dict[int, int], dict[int, dict[int, int]], dict[int, dict[str, int]], dict[str, float]]:
by_operation: dict[int, list[dict]] = defaultdict(list)
for node in raw_nodes:
by_operation[node["op_id"]].append(node)
row_counts = {}
lane_rows = {}
scalar_rows = {}
duplicate_offsets = {}
for op_id, nodes in by_operation.items():
lanes = sorted({node["lane"] for node in nodes if node["lane"] is not None})
lane_rows[op_id] = {lane: index for index, lane in enumerate(lanes)}
scalars = sorted(node["node_id"] for node in nodes if node["lane"] is None)
scalar_rows[op_id] = {node_id: len(lanes) + index for index, node_id in enumerate(scalars)}
row_counts[op_id] = max(1, len(lanes) + len(scalars))
duplicates: dict[int, list[str]] = defaultdict(list)
for node in nodes:
if node["lane"] is not None:
duplicates[node["lane"]].append(node["node_id"])
for node_ids in duplicates.values():
node_ids.sort()
centre = (len(node_ids) - 1) / 2
for index, node_id in enumerate(node_ids):
duplicate_offsets[node_id] = (index - centre) * DUPLICATE_NODE_SPACING
return row_counts, lane_rows, scalar_rows, duplicate_offsets
def display_graph(db: sqlite3.Connection, report_id: str, *, view: str = "operation",
expanded_operation_ids=(), expand_all: bool = False, cap: int = 5_000) -> dict:
if view not in {"operation", "raw"}:
@@ -119,7 +147,9 @@ def display_graph(db: sqlite3.Connection, report_id: str, *, view: str = "operat
f"safety cap is {cap}. Use an aggregate view or increase the configured expansion cap."
)
anchors = _operation_anchors(operation_nodes, operation_edges)
raw_nodes = _raw_nodes(db, report_id, expanded, expanded == known)
row_counts, lane_rows, scalar_rows, duplicate_offsets = _display_rows(raw_nodes)
anchors = _operation_anchors(operation_nodes, operation_edges, row_counts)
nodes = []
for node in operation_nodes:
if node["op_id"] not in expanded:
@@ -127,22 +157,15 @@ def display_graph(db: sqlite3.Connection, report_id: str, *, view: str = "operat
nodes.append({**node, "display_id": node["aggregate_id"], "representation": "aggregate",
"x": x, "y": y})
raw_nodes = _raw_nodes(db, report_id, expanded, expanded == known)
lane_occurrences: dict[tuple[int, int], int] = defaultdict(int)
scalar_indices: dict[int, int] = defaultdict(int)
maximum_lanes = {node["op_id"]: node["maximum_lane"] for node in operation_nodes}
for node in raw_nodes:
anchor_x, anchor_y = anchors[node["op_id"]]
if node["lane"] is None:
index = scalar_indices[node["op_id"]]
scalar_indices[node["op_id"]] += 1
row = (maximum_lanes[node["op_id"]] if maximum_lanes[node["op_id"]] is not None else -1) + 1 + index
x, y = anchor_x, anchor_y + row * LANE_SPACING
row = scalar_rows[node["op_id"]][node["node_id"]]
x = anchor_x
else:
key = (node["op_id"], node["lane"])
x = anchor_x + lane_occurrences[key] * 12
y = anchor_y + node["lane"] * LANE_SPACING
lane_occurrences[key] += 1
row = lane_rows[node["op_id"]][node["lane"]]
x = anchor_x + duplicate_offsets[node["node_id"]]
y = anchor_y - row * LANE_SPACING
nodes.append({**node, "display_id": node["node_id"], "representation": "raw", "x": x, "y": y})
edges = []
@@ -16,18 +16,16 @@ function definition(object, keys) {
return `<dl>${keys.filter(key => object[key] !== undefined).map(key => `<dt>${html(key.replaceAll("_", " "))}</dt><dd>${html(typeof object[key] === "object" ? JSON.stringify(object[key]) : object[key])}</dd>`).join("")}</dl>`;
}
function status(message, error = false) { $("status").textContent = message; $("status").className = error ? "error" : ""; }
function selectedReport() { return state.reports.find(report => report.report_id === $("report").value); }
function defaultViewForReport(report) {
if (report?.stage === "spatial4") return "raw";
const saved = localStorage.getItem("raptor-graph-view");
return ["operation", "raw", "core", "node_kind"].includes(saved) ? saved : "operation";
function updateLayoutButton() {
$("layout").textContent = ["operation", "raw"].includes($("level").value) ? "Reset layout" : "Rerun layout";
}
async function loadReports() {
state.reports = await api("/api/reports");
if (!state.reports.length) throw new Error("No reports were preprocessed.");
$("report").innerHTML = state.reports.map(report => `<option>${html(report.report_id)}</option>`).join("");
$("level").value = defaultViewForReport(selectedReport());
$("level").value = "operation";
updateLayoutButton();
await loadGraph();
}
function aggregateGraph(data) {
@@ -72,32 +70,60 @@ function updateStatus() {
} else status(`${state.data.nodes.length} aggregate nodes, ${state.data.edges.length} aggregate edges`);
}
function nodeLabel(node) {
if (node.representation === "raw") return node.lane === null ? node.node_id : `${node.node_id} · lane ${node.lane}`;
return node.op_id !== null ? `op ${node.op_id}` : node.group_key;
if (node.representation === "raw") {
const name = node.ssa_name || node.node_id;
return node.lane === null ? name : `${name} · lane ${node.lane}`;
}
return node.op_id !== null ? node.ssa_summary.find(Boolean) || `op ${node.op_id}` : node.group_key;
}
function edgeSize(edge) {
if (edge.representation === "raw") return 1;
const value = edge[$("metric").value] || 0;
return 1 + Math.log2(1 + Math.max(0, value)) / 3;
function labelIntersectsNode(context, data, settings) {
context.font = `${settings.labelWeight} ${settings.labelSize}px ${settings.labelFont}`;
const metrics = context.measureText(data.label), x = data.x + data.size + 3, y = data.y + settings.labelSize / 3;
const bounds = {
left: x - (metrics.actualBoundingBoxLeft || 0), right: x + (metrics.actualBoundingBoxRight || metrics.width),
top: y - (metrics.actualBoundingBoxAscent || settings.labelSize), bottom: y + (metrics.actualBoundingBoxDescent || 0),
};
return state.graph.nodes().some(id => {
if (id === data.key) return false;
const node = state.renderer.getNodeDisplayData(id);
if (!node || node.hidden) return false;
const position = state.renderer.framedGraphToViewport(node), radius = state.renderer.scaleSize(node.size) + 1;
const dx = position.x - Math.max(bounds.left, Math.min(position.x, bounds.right));
const dy = position.y - Math.max(bounds.top, Math.min(position.y, bounds.bottom));
return dx * dx + dy * dy < radius * radius;
});
}
async function renderGraph() {
if (state.renderer) state.renderer.kill();
const graph = new graphology.MultiDirectedGraph();
for (const node of state.data.nodes) graph.addNode(node.display_id, {
label: nodeLabel(node), x: node.x, y: node.y,
size: node.representation === "raw" ? 3 : 4 + Math.log2(1 + node.instance_count),
size: node.representation === "raw" ? 3 : Math.min(6, 3 + Math.log2(1 + node.instance_count) / 8),
color: node.representation === "raw" ? colors.raw : colors[node.level], raw: node,
searchable: JSON.stringify(node).toLowerCase(),
});
for (const edge of state.data.edges) if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
graph.addDirectedEdgeWithKey(edge.display_id, edge.source, edge.target, {
size: edgeSize(edge), color: edge.representation === "raw" ? "#e0a94f" : "#718096",
size: 1, color: edge.representation === "raw" ? "#e0a94f" : "#718096",
type: "arrow", raw: edge,
});
}
state.graph = graph;
await runLayout();
state.renderer = new Sigma(graph, $("graph"), {defaultEdgeType: "arrow", enableEdgeEvents: true, nodeReducer: reduceNode, edgeReducer: reduceEdge});
state.renderer = new Sigma(graph, $("graph"), {
defaultEdgeType: "arrow", enableEdgeEvents: true,
labelColor: {color: "#e7edf5"}, labelWeight: "500",
labelDensity: 0.6, labelGridCellSize: 120, labelRenderedSizeThreshold: 0,
stagePadding: 60,
nodeReducer: reduceNode, edgeReducer: reduceEdge,
});
const labelRenderer = state.renderer.getSetting("labelRenderer");
state.renderer.setSetting("labelRenderer", (context, data, settings) => {
if (!labelIntersectsNode(context, data, settings)) labelRenderer(context, data, settings);
});
const hoverRenderer = state.renderer.getSetting("hoverRenderer");
state.renderer.setSetting("hoverRenderer", (context, data, settings) =>
hoverRenderer(context, data, {...settings, labelColor: {color: "#000000"}}));
state.renderer.on("clickNode", event => showNode(event.node));
state.renderer.on("clickEdge", event => showEdge(event.edge));
applyFilters();
@@ -111,7 +137,7 @@ async function runLayout() {
} else {
const layout = await new ELK().layout({
id: "root",
layoutOptions: {"elk.algorithm": "layered", "elk.direction": "RIGHT", "elk.spacing.nodeNode": "35", "elk.layered.spacing.nodeNodeBetweenLayers": "65"},
layoutOptions: {"elk.algorithm": "layered", "elk.direction": "DOWN", "elk.spacing.nodeNode": "35", "elk.layered.spacing.nodeNodeBetweenLayers": "65"},
children: state.graph.nodes().map(id => ({id, width: 28, height: 28})),
edges: state.graph.edges().map(id => ({id, sources: [state.graph.source(id)], targets: [state.graph.target(id)]})),
});
@@ -128,7 +154,7 @@ function reduceNode(id, attributes) {
if (state.motif) {
const members = new Set([state.motif.entry_aggregate_id, state.motif.exit_aggregate_id, ...state.motif.internal_aggregate_ids]);
const identity = motifIdentity(attributes.raw);
if (!members.has(identity)) { result.color = "#263341"; result.label = ""; }
if (!members.has(identity)) result.color = "#263341";
else if (identity === state.motif.entry_aggregate_id) { result.color = "#54d67b"; result.size *= 1.5; }
else if (identity === state.motif.exit_aggregate_id) { result.color = "#ff6f6f"; result.size *= 1.5; }
}
@@ -210,11 +236,32 @@ async function loadMappingRows(id) {
$("next").onclick = () => { state.mappingOffset += 25; loadMappingRows(id); };
}
function renderMotifs() {
$("motifs").innerHTML = state.motifs.length ? state.motifs.map((motif, index) => `<button class="motif" data-index="${index}">${html(motif.motif_kind)}: ${html(motif.entry_aggregate_id)}${html(motif.exit_aggregate_id)} (${motif.node_count})</button>`).join("") : "No motifs detected.";
$("motifs").innerHTML = state.motifs.length ? state.motifs.map((motif, index) => `<button class="motif" data-index="${index}">${html(motif.motif_kind)}: ${html(operationName(motif.entry_aggregate_id))}${html(operationName(motif.exit_aggregate_id))} (${motif.node_count})</button>`).join("") : "No motifs detected.";
document.querySelectorAll(".motif").forEach(button => button.onclick = () => selectMotif(state.motifs[button.dataset.index]));
}
function operationName(aggregateId) {
const node = state.data.nodes.find(item => motifIdentity(item) === aggregateId);
const name = node?.ssa_summary?.[0] || node?.ssa_name;
return name || aggregateId;
}
function fitMotif(motif) {
const members = new Set([motif.entry_aggregate_id, motif.exit_aggregate_id, ...motif.internal_aggregate_ids]);
const positions = state.graph.nodes()
.filter(id => members.has(motifIdentity(state.graph.getNodeAttribute(id, "raw"))))
.map(id => state.graph.getNodeAttributes(id));
if (!positions.length) return;
const xs = positions.map(node => node.x), ys = positions.map(node => node.y);
const width = Math.max(1, Math.max(...xs) - Math.min(...xs));
const height = Math.max(1, Math.max(...ys) - Math.min(...ys));
state.renderer.setCustomBBox({
x: [Math.min(...xs) - width * 0.1, Math.max(...xs) + width * 0.1],
y: [Math.min(...ys) - height * 0.1, Math.max(...ys) + height * 0.1],
});
state.renderer.refresh();
state.renderer.getCamera().animatedReset();
}
function selectMotif(motif) {
state.motif = motif; applyFilters();
state.motif = motif; applyFilters(); fitMotif(motif);
$("details").innerHTML = '<p class="muted">Structural SCC-condensed split/rejoin; rendered geometry is not evidence.</p>' + definition(motif, ["motif_kind", "entry_aggregate_id", "exit_aggregate_id", "internal_aggregate_ids", "branch_successor_aggregate_ids", "representative_paths", "node_count", "edge_count", "contains_cycle", "confidence"]);
}
async function loadSummary() {
@@ -223,10 +270,9 @@ async function loadSummary() {
$("mapping").innerHTML = '<option value="">All mappings</option>' + Object.keys(summary.mapping_classes).map(kind => `<option>${html(kind)}</option>`).join("");
}
$("report").onchange = () => { $("level").value = defaultViewForReport(selectedReport()); loadGraph(); };
$("level").onchange = () => { localStorage.setItem("raptor-graph-view", $("level").value); loadGraph(); };
$("metric").onchange = renderGraph;
$("report").onchange = () => { $("level").value = "operation"; updateLayoutButton(); loadGraph(); };
$("level").onchange = () => { updateLayoutButton(); loadGraph(); };
for (const id of ["search", "mapping", "tensor", "selfEdges"]) $(id).addEventListener(id === "selfEdges" ? "change" : "input", applyFilters);
$("layout").onclick = runLayout; $("fit").onclick = () => state.renderer?.getCamera().animatedReset();
$("layout").onclick = runLayout; $("fit").onclick = () => { state.renderer?.setCustomBBox(null); state.renderer?.refresh(); state.renderer?.getCamera().animatedReset(); };
$("expandAll").onclick = expandAllOperations; $("collapseAll").onclick = collapseAllOperations;
loadReports().catch(error => status(error.message, true));
@@ -15,17 +15,16 @@
<aside class="controls" aria-label="Graph controls">
<label>Report<select id="report"></select></label>
<label>Aggregation/view<select id="level"><option value="operation">Operation</option><option value="raw">Raw instances</option><option value="core">Core</option><option value="node_kind">Node kind</option></select></label>
<label>Edge metric<select id="metric"><option value="raw_edge_count">Count</option><option value="total_weight">Total weight</option></select></label>
<label>Search<input id="search" type="search" placeholder="ID, op, core, SSA"></label>
<label>Mapping<select id="mapping"><option value="">All mappings</option></select></label>
<label>Tensor type<input id="tensor" type="search" placeholder="substring"></label>
<label class="check"><input id="selfEdges" type="checkbox">Show self edges</label>
<div class="buttons"><button id="expandAll">Expand all operations</button><button id="collapseAll" hidden>Collapse all</button><button id="layout">Rerun layout</button><button id="fit">Fit graph</button></div>
<div class="buttons"><button id="expandAll">Expand all operations</button><button id="collapseAll" hidden>Collapse all</button><button id="layout">Reset layout</button><button id="fit">Fit graph</button></div>
<h2>Motifs</h2><div id="motifs" class="scroll">No motifs detected.</div>
<h2>Report statistics and diagnostics</h2><div id="summary" class="scroll"></div>
</aside>
<section id="graph" aria-label="Interactive directed graph"></section>
<aside class="details" aria-label="Selection details"><h2>Details</h2><div id="details">Select a node, edge, or motif.</div><canvas id="matrix" width="320" height="220" aria-label="Lane mapping heatmap" hidden></canvas><div id="mappingRows"></div></aside>
</main>
<script src="/app.js"></script>
<script src="/app.js?v=5"></script>
</body></html>
+41 -1
View File
@@ -8,7 +8,10 @@ from raptor_graph_explorer.api import create_app
def test_manifest_graph_motifs_and_pagination(built_output: Path):
client=TestClient(create_app(built_output,max_page_size=2))
assert "Raptor Graph Explorer" in client.get("/").text
page = client.get("/")
assert "Raptor Graph Explorer" in page.text
assert page.headers["cache-control"] == "no-store"
assert client.get("/app.js").headers["cache-control"] == "no-store"
assert "runLayout" in client.get("/app.js").text
assert client.get("/api/manifest").status_code==200
assert len(client.get("/api/reports").json())==2
@@ -43,3 +46,40 @@ def test_unsupported_database_schema_is_rejected(built_output: Path):
db.execute("UPDATE metadata SET value='999' WHERE key='schema_version'");db.commit();db.close()
with pytest.raises(ValueError,match="unsupported database schema"):
create_app(built_output)
def test_static_layout_and_label_settings(built_output: Path):
client = TestClient(create_app(built_output))
script = client.get("/app.js").text
assert 'id="metric"' not in client.get("/").text
assert "edgeSize" not in script and '$("metric")' not in script
assert "size: 1" in script
assert "localStorage" not in script
assert 'node.ssa_name || node.node_id' in script
assert 'node.ssa_summary.find(Boolean) || `op ${node.op_id}`' in script
motif_reducer = script[script.index("function reduceNode"):script.index("function reduceEdge")]
assert 'result.label = ""' not in motif_reducer
assert 'labelColor: {color: "#e7edf5"}' in script
assert "labelDensity: 0.6" in script
assert "labelGridCellSize: 120" in script
assert "labelRenderedSizeThreshold: 0" in script
assert "stagePadding: 60" in script
assert "forceLabel" not in script
assert 'getSetting("labelRenderer")' in script
assert "labelIntersectsNode" in script
assert "framedGraphToViewport" in script
assert "dx * dx + dy * dy < radius * radius" in script
assert "Math.min(6, 3 + Math.log2(1 + node.instance_count) / 8)" in script
assert 'getSetting("hoverRenderer")' in script
assert 'labelColor: {color: "#000000"}' in script
projection = script[script.index("async function rebuildProjection"):script.index("function expandOperation")]
assert projection.count("renderGraph()") == 1
assert '"Reset layout"' in script and '"Rerun layout"' in script
layout = script[script.index("async function runLayout"):script.index("function motifIdentity")]
assert '["operation", "raw"].includes' in layout
assert "new ELK().layout" in layout
assert '"elk.direction": "DOWN"' in layout
assert "fitMotif(motif)" in script
assert "setCustomBBox" in script
fit = script[script.index("function fitMotif"):script.index("function selectMotif")]
assert "setCustomBBox" in fit and "refresh()" in fit and "animatedReset()" in fit
+30 -1
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from raptor_graph_explorer.cli import main
from raptor_graph_explorer.cli import build_output, main
from raptor_graph_explorer.database import connect_readonly
def test_build_inspect_and_exit_codes(tmp_path: Path, fixture_dir: Path, capsys):
@@ -11,3 +12,31 @@ def test_build_inspect_and_exit_codes(tmp_path: Path, fixture_dir: Path, capsys)
assert "demo_graph" in captured and "mappings:" in captured
assert main(["build",str(fixture_dir),"--output",str(output)])==2
assert main(["build",str(tmp_path/"missing"),"--output",str(tmp_path/"bad")])==2
def test_multi_report_build_finalizes_readonly_database(tmp_path: Path):
reports = tmp_path / "reports"; reports.mkdir()
header = "Id,op_id,lane,core,ssa_name\n"
edge_header = "Source,Target,Weight,Type,stage,source_lane,target_lane,channel_id\n"
(reports / "a.nodes.csv").write_text(header + "gc:0,0,,,%0\n")
(reports / "a.edges.csv").write_text(edge_header)
(reports / "b.nodes.csv").write_text(header + "gc:0,0,,,%0\ngc:1,1,,,%1\n")
(reports / "b.edges.csv").write_text(
edge_header
+ "gc:0,gc:1,1,tensor<1xf32>,b,,,\n"
+ "gc:1,gc:missing,1,tensor<1xf32>,b,,,\n")
output = tmp_path / "output"
manifest = build_output([reports], output)
assert (output / "manifest.json").is_file()
assert [report["report_id"] for report in manifest["reports"]] == ["a", "b"]
assert manifest["diagnostic_count"] == 1
db = connect_readonly(output / "graph.sqlite")
counts = [tuple(row) for row in db.execute(
"SELECT report_id,raw_node_count,raw_edge_count,operation_node_count,operation_edge_count "
"FROM reports ORDER BY report_id")]
assert counts == [("a", 1, 0, 1, 0), ("b", 2, 1, 2, 1)]
assert tuple(db.execute("SELECT code,occurrence_count FROM diagnostics").fetchone()) == ("unknown_endpoint", 1)
assert db.execute("PRAGMA journal_mode").fetchone()[0] == "delete"
db.close()
@@ -5,6 +5,14 @@ import sqlite3
from pathlib import Path
from raptor_graph_explorer.cli import build_output
from raptor_graph_explorer.database import create_database
from raptor_graph_explorer.ingest import _read_edges, _read_nodes
from raptor_graph_explorer.schema import DiagnosticCollector, ReportPair
def _temporary_tables(db: sqlite3.Connection) -> list[str]:
return [row[0] for row in db.execute(
"SELECT name FROM sqlite_temp_master WHERE type='table' ORDER BY name")]
def test_normalization_unknown_columns_prefix_and_lane_fallback(built_output: Path):
@@ -56,3 +64,47 @@ def test_explicit_edge_lane_precedes_endpoint_lane(tmp_path: Path):
assert db.execute("SELECT source_lane,target_lane FROM raw_edges").fetchone()==(9,8)
assert db.execute("SELECT occurrence_count FROM diagnostics WHERE code='contradictory_lane'").fetchone()==(1,)
db.close()
def test_ingestion_drops_staging_tables_on_success_and_missing_columns(tmp_path: Path):
nodes = tmp_path / "good.nodes.csv"
nodes.write_text("Id,op_id,lane,core,ssa_name\ngc:0,0,,,%0\ngc:1,1,,,%1\n")
edges = tmp_path / "good.edges.csv"
edges.write_text(
"Source,Target,Weight,Type,stage,source_lane,target_lane,channel_id\n"
"gc:0,gc:1,1,tensor<1xf32>,good,,,\n")
pair = ReportPair("good", nodes, edges)
diagnostics = DiagnosticCollector()
db = create_database(tmp_path / "graph.sqlite")
db.execute("INSERT INTO reports(report_id,stage) VALUES('good','good')")
assert _read_nodes(db, pair, "good", diagnostics) == 2
assert _temporary_tables(db) == []
assert _read_edges(db, pair, "good", diagnostics) == 1
assert _temporary_tables(db) == []
missing_nodes = tmp_path / "missing.nodes.csv"
missing_nodes.write_text("Id,op_id,lane,core\ngc:2,2,,,\n")
db.execute("INSERT INTO reports(report_id,stage) VALUES('missing','missing')")
assert _read_nodes(db, ReportPair("missing", missing_nodes, edges), "missing", diagnostics) == 0
assert _temporary_tables(db) == []
missing_edges = tmp_path / "missing.edges.csv"
missing_edges.write_text("Source,Target\ngc:0,gc:1\n")
assert _read_edges(db, ReportPair("good", nodes, missing_edges), "good", diagnostics) == 0
assert _temporary_tables(db) == []
db.close()
def test_required_columns_store_literal_empty_attributes(tmp_path: Path):
reports = tmp_path / "reports"; reports.mkdir()
(reports / "x.nodes.csv").write_text(
"Id,op_id,lane,core,ssa_name\ngc:0,0,,,%0\ngc:1,1,,,%1\n")
(reports / "x.edges.csv").write_text(
"Source,Target,Weight,Type,stage,source_lane,target_lane,channel_id\n"
"gc:0,gc:1,1,tensor<1xf32>,x,,,\n")
output = tmp_path / "out"; build_output([reports], output)
db = sqlite3.connect(output / "graph.sqlite")
assert db.execute("SELECT DISTINCT attributes_json FROM raw_nodes").fetchall() == [("{}",)]
assert db.execute("SELECT DISTINCT attributes_json FROM raw_edges").fetchall() == [("{}",)]
db.close()
@@ -49,3 +49,9 @@ def test_external_entrance_rejected():
def test_external_exit_rejected():
edges=[("s","a"),("s","b"),("a","t"),("b","t"),("a","x")]
assert detect_motifs(graph(edges))[0] == []
def test_motifs_are_sorted_by_topological_rank_not_node_name():
edges = [("9", "a"), ("9", "b"), ("a", "z"), ("b", "z"), ("z", "10"),
("10", "c"), ("10", "d"), ("c", "20"), ("d", "20")]
assert [motif.entry for motif in detect_motifs(graph(edges))[0]] == ["9", "10"]
@@ -6,8 +6,9 @@ from fastapi.testclient import TestClient
import pytest
from raptor_graph_explorer.api import create_app
from raptor_graph_explorer.cli import build_output
from raptor_graph_explorer.database import connect_readonly
from raptor_graph_explorer.projection import DisplayGraphTooLarge, display_graph
from raptor_graph_explorer.projection import DisplayGraphTooLarge, LANE_SPACING, display_graph
def project(output: Path, report_id: str, expanded=(), *, view="operation", cap=5_000):
@@ -18,6 +19,36 @@ def project(output: Path, report_id: str, expanded=(), *, view="operation", cap=
db.close()
@pytest.fixture
def sparse_projection_output(tmp_path: Path) -> Path:
reports = tmp_path / "reports"; reports.mkdir()
(reports / "sparse.nodes.csv").write_text(
"Id,op_id,lane,core,ssa_name\n"
"gc:0,0,,,%0\n"
"gcb:1:0:a,1,0,,%1\n"
"gcb:1:0:b,1,0,,%2\n"
"gcb:1:0:c,1,0,,%3\n"
"gcb:1:1000000,1,1000000,,%4\n"
"gc:1:scalar_a,1,,,%5\n"
"gc:1:scalar_b,1,,,%6\n"
"gc:2,2,,,%7\n")
edge_header = "Source,Target,Weight,Type,stage,source_lane,target_lane,channel_id\n"
pairs = [
("gc:0", "gcb:1:0:a"),
("gcb:1:0:a", "gcb:1:0:b"),
("gcb:1:0:b", "gcb:1:0:c"),
("gcb:1:0:c", "gcb:1:1000000"),
("gcb:1:1000000", "gc:1:scalar_a"),
("gc:1:scalar_a", "gc:1:scalar_b"),
("gc:1:scalar_b", "gc:2"),
]
(reports / "sparse.edges.csv").write_text(
edge_header + "".join(
f"{source},{target},1,tensor<1xf32>,sparse,,,\n" for source, target in pairs))
output = tmp_path / "output"; build_output([reports], output)
return output
def test_spatial1_baseline_and_one_operation_expansion(regression_output: Path):
db = connect_readonly(regression_output / "graph.sqlite")
source_counts = db.execute("""
@@ -113,7 +144,7 @@ def test_display_api_and_static_raw_interactions(regression_output: Path):
index = client.get("/").text
script = client.get("/app.js").text
assert '<option value="raw">Raw instances</option>' in index
assert 'report?.stage === "spatial4"' in script
assert '$("level").value = "operation"' in script
assert 'local.representation === "raw"' in script
reducer = script[script.index("function reduceEdge"):script.index("function applyFilters")]
assert "state.graph.source(id)" not in reducer
@@ -123,3 +154,52 @@ def test_display_api_and_static_raw_interactions(regression_output: Path):
assert "return;" in script[raw_edge_branch:aggregate_request]
aggregate_edge = "operation:spatial1_graph:1->2"
assert client.get(f"/api/aggregate-edges/{aggregate_edge}/matrix").status_code == 200
def test_collapsed_sparse_lanes_have_compact_deterministic_extent(sparse_projection_output: Path):
first = project(sparse_projection_output, "sparse")
second = project(sparse_projection_output, "sparse")
aggregate_coordinates = [(node["x"], node["y"]) for node in first["nodes"]]
assert first["nodes"] == second["nodes"]
assert len(set(aggregate_coordinates)) == 3
assert max(y for _, y in aggregate_coordinates) - min(y for _, y in aggregate_coordinates) < 100 * LANE_SPACING
def test_operation_edges_are_laid_out_top_to_bottom(sparse_projection_output: Path):
graph = project(sparse_projection_output, "sparse")
y = {node["op_id"]: node["y"] for node in graph["nodes"]}
cross_operation = [edge for edge in graph["edges"] if edge["source_op_id"] != edge["target_op_id"]]
assert cross_operation
assert all(y[edge["source_op_id"]] > y[edge["target_op_id"]] for edge in cross_operation)
def test_expanded_sparse_lanes_use_compact_rows_and_valid_endpoints(sparse_projection_output: Path):
graph = project(sparse_projection_output, "sparse", [1])
nodes = {node["node_id"]: node for node in graph["nodes"] if node["representation"] == "raw"}
assert nodes["gcb:1:1000000"]["y"] - nodes["gcb:1:0:a"]["y"] == -LANE_SPACING
assert "operation:sparse:1" not in {node["display_id"] for node in graph["nodes"]}
displayed = {node["display_id"] for node in graph["nodes"]}
assert all(edge["source"] in displayed and edge["target"] in displayed for edge in graph["edges"])
def test_same_lane_duplicates_are_centered_and_deterministic(sparse_projection_output: Path):
collapsed = project(sparse_projection_output, "sparse")
anchor_x = next(node["x"] for node in collapsed["nodes"] if node["op_id"] == 1)
first = project(sparse_projection_output, "sparse", [1])
second = project(sparse_projection_output, "sparse", [1])
duplicate_ids = ["gcb:1:0:a", "gcb:1:0:b", "gcb:1:0:c"]
nodes = {node["node_id"]: node for node in first["nodes"] if node["representation"] == "raw"}
xs = [nodes[node_id]["x"] for node_id in duplicate_ids]
assert len({nodes[node_id]["y"] for node_id in duplicate_ids}) == 1
assert len(set(xs)) == 3
assert sum(xs) / len(xs) == pytest.approx(anchor_x)
assert first["nodes"] == second["nodes"]
def test_lane_less_nodes_follow_lane_rows_in_node_id_order(sparse_projection_output: Path):
graph = project(sparse_projection_output, "sparse", [1])
nodes = {node["node_id"]: node for node in graph["nodes"] if node["representation"] == "raw"}
lane_y = min(node["y"] for node in nodes.values() if node["lane"] is not None)
scalar_y = [nodes[node_id]["y"] for node_id in ("gc:1:scalar_a", "gc:1:scalar_b")]
assert scalar_y[0] < lane_y
assert scalar_y[1] - scalar_y[0] == -LANE_SPACING