aboutsummaryrefslogtreecommitdiffstats
path: root/experiments/graph_compare.py
blob: 0714554bddee690a64ceb4577cb054302e9f19bf (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
#!/usr/bin/env python3
"""Compare the layout engines on a corpus: crossings, canvas, ambiguity, cost.

    python3 experiments/graph_compare.py .auto/cfg-corpus.json
    python3 experiments/graph_compare.py .auto/cfg-corpus.json --max-blocks 40

Every number comes out of the shipping pipeline (``idatui.graph``), not out of a
side channel, so it measures what the view will actually draw.

The columns that matter:

``X``    proper segment crossings -- what the SESE decomposition is for.
``amb``  cells claimed by more than one edge. In a pixel renderer overlapping
         lines are invisible; in a terminal one cell holds one character, so an
         ambiguous cell is an edge the user cannot follow and ``edge_at`` gets
         wrong under the cursor.
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import time
from collections import defaultdict

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from idatui import graph as G  # noqa: E402


def sizer(b: G.Block) -> tuple[int, int]:
    return (len(f"loc_{b.start:X}") + 6, 4)


def real_sizer(rec: dict, max_lines: int):
    """Box sizes from the actual disassembly text, like the view's own sizer.

    Box shape is not a detail here: it decides how much horizontal room a layer
    needs, and therefore how far edges travel sideways. Comparing engines on
    uniform 20-column stubs measures the wrong graph.
    """
    texts = {}
    for b in rec["blocks"]:
        lines = list(b["lines"])
        if max_lines and len(lines) > max_lines:
            lines = lines[:max_lines - 1] + [f"... {len(b['lines']) - max_lines + 1} more"]
        texts[b["id"]] = lines

    def size(b: G.Block) -> tuple[int, int]:
        lines = texts[b.id]
        return max((len(line) for line in lines), default=8) + 4, len(lines) + 2
    return size


def segments(lay: G.Layout) -> dict[int, list[tuple[int, int, int, int]]]:
    """Per-edge segments as (x0, y0, x1, y1), rebuilt from the painting."""
    segs: dict[int, list] = defaultdict(list)
    for row, runs in lay.painting.hruns.items():
        for lo, hi, _style, eid in runs:
            segs[eid].append((lo, row, hi, row))
    for lo, hi, col, _style, eid in lay.painting.vruns:
        segs[eid].append((col, lo, col, hi))
    return segs


def crossings(segs: dict[int, list], cap: int = 400_000) -> int | None:
    def orient(px, py, qx, qy, rx, ry):
        v = (qx - px) * (ry - py) - (qy - py) * (rx - px)
        return (v > 0) - (v < 0)

    keys = list(segs)
    n = pairs = 0
    for i, a in enumerate(keys):
        for b in keys[i + 1:]:
            for ax, ay, bx, by in segs[a]:
                for cx, cy, dx, dy in segs[b]:
                    pairs += 1
                    if pairs > cap:
                        return None
                    o1 = orient(ax, ay, bx, by, cx, cy)
                    o2 = orient(ax, ay, bx, by, dx, dy)
                    o3 = orient(cx, cy, dx, dy, ax, ay)
                    o4 = orient(cx, cy, dx, dy, bx, by)
                    if o1 != o2 and o3 != o4:
                        n += 1
    return n


def ambiguous(lay: G.Layout) -> tuple[int, int]:
    """(cells claimed by >1 edge, total edge cells)."""
    owners: dict[tuple[int, int], set[int]] = defaultdict(set)
    for row, runs in lay.painting.hruns.items():
        for lo, hi, _s, eid in runs:
            for c in range(lo, hi + 1):
                owners[(row, c)].add(eid)
    for lo, hi, col, _s, eid in lay.painting.vruns:
        for r in range(lo, hi + 1):
            owners[(r, col)].add(eid)
    return sum(1 for v in owners.values() if len(v) > 1), len(owners)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("corpus")
    ap.add_argument("--max-blocks", type=int, default=10 ** 9)
    ap.add_argument("--min-blocks", type=int, default=6)
    ap.add_argument("--real-sizer", action="store_true",
                    help="size boxes from the disassembly text, as the view does")
    ap.add_argument("--max-lines", type=int, default=8)
    args = ap.parse_args()

    from idatui import graph_triskel
    if not graph_triskel.available():
        print("pytriskel not importable; set IDATUI_TRISKEL_PATH", file=sys.stderr)
        return 2

    recs = json.load(open(args.corpus))
    recs = [r for r in recs
            if args.min_blocks <= len(r["blocks"]) <= args.max_blocks]
    recs.sort(key=lambda r: len(r["blocks"]))

    print(f"{'blk':>4} | {'native ms':>9} {'canvas':>11} {'X':>5} {'amb':>5} | "
          f"{'tk ms':>7} {'canvas':>11} {'X':>5} {'amb':>5} | name")
    tot = {"native": 0.0, "triskel": 0.0}
    won = tied = lost = 0
    amb_tot = {"native": 0, "triskel": 0}
    for rec in recs:
        row = {}
        for engine in ("native", "triskel"):
            blocks = [G.Block(id=b["id"], start=b["start"], end=b["end"],
                              succs=[(d, k) for d, k in b["succs"]])
                      for b in rec["blocks"]]
            size = real_sizer(rec, args.max_lines) if args.real_sizer else sizer
            t0 = time.perf_counter()
            lay = G.layout(blocks, size, engine=engine)
            ms = (time.perf_counter() - t0) * 1000
            tot[engine] += ms
            amb, _cells = ambiguous(lay)
            amb_tot[engine] += amb
            row[engine] = (ms, lay.width, lay.height, crossings(segments(lay)), amb)
        (nm, nw, nh, nx, na) = row["native"]
        (tm, tw, th, tx, ta) = row["triskel"]
        if nx is not None and tx is not None:
            won += tx < nx
            tied += tx == nx
            lost += tx > nx
        print(f"{len(rec['blocks']):>4} | {nm:>9.1f} {f'{nw}x{nh}':>11} "
              f"{str(nx):>5} {na:>5} | {tm:>7.1f} {f'{tw}x{th}':>11} "
              f"{str(tx):>5} {ta:>5} | {rec['name']}")
    print(f"\ntotal: native {tot['native']:.0f} ms, triskel {tot['triskel']:.0f} ms")
    print(f"crossings: triskel better on {won}, equal on {tied}, worse on {lost}")
    print(f"ambiguous cells: native {amb_tot['native']}, triskel {amb_tot['triskel']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())