Files
sap-architecture/scripts/compare.py
T
2026-07-24 20:47:16 +00:00

682 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Compare two .drawio files and produce a similarity / divergence report.
The goal isn't pixel-perfect equality — it's to surface *structural* and *style*
divergences so an iterative loop can drive a generated diagram toward an SAP
reference. Two diagrams that draw the same scenario end up with very similar
fingerprints across these dimensions:
Structural
* canvas size (W × H) — should match the selected SAP template
* total cell count, vertex count, edge count
* zone count (cells with arcSize=16, strokeWidth=1.5, fontStyle=1, top-left
label)
* service-icon count (cells with shape=image and SAP icon SVG data URI)
* pill count (small cells with arcSize=50)
Style
* palette (set of hex colors, Jaccard similarity)
* fonts (set of fontFamily values)
* stroke widths (set)
* presence of `absoluteArcSize=1`, `labelBackgroundColor=default`
* grid-snap rate (% of geometries on the 10-px grid)
Usage:
compare.py reference.drawio candidate.drawio # human report
compare.py --json reference.drawio candidate.drawio # JSON report
compare.py --score reference.drawio candidate.drawio # one-line score 0..100
Score is a weighted blend of the dimensions above; 100 = identical fingerprint
(not necessarily identical content), 0 = nothing in common.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass, field
from pathlib import Path
try:
from validate import SAP_PALETTE
except Exception: # pragma: no cover - compare.py can run standalone
SAP_PALETTE = {
"#0070F2", "#EBF8FF", "#475E75", "#F5F6F7", "#1D2D3E", "#556B82",
"#188918", "#F5FAE5", "#C35500", "#FFF8D6", "#D20A0A", "#FFEAF4",
"#07838F", "#DAFDF5", "#5D36FF", "#F1ECFF", "#CC00DC", "#FFF0FA",
"#FFFFFF", "#FFF", "#000000", "#000", "#FCFCFC",
}
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
DATA_URI_RE = re.compile(r"data:image/[^&\";]+")
INLINE_SVG_ICON_RE = re.compile(r"shape=image[^\"]*image=data:image/svg")
STENCIL_ICON_RE = re.compile(r"shape=mxgraph\.sap\.icon")
ICON_RE = re.compile(r"shape=image[^\"]*image=data:image/svg|shape=mxgraph\.sap\.icon")
EXTERNAL_IMAGE_RE = re.compile(r"shape=image[^\"]*image=https?://|image=https?://")
SHAPE_RE = re.compile(r"(?:^|;)shape=([^;\"]+)")
ARC16_RE = re.compile(r"arcSize=16\b")
ARC50_RE = re.compile(r"arcSize=50\b")
ABS_ARC_RE = re.compile(r"absoluteArcSize=1\b")
LABEL_BG_RE = re.compile(r"labelBackgroundColor=default\b")
ZONE_HINT_RE = re.compile(r"strokeWidth=1\.5[^\"]*fontStyle=1|fontStyle=1[^\"]*strokeWidth=1\.5")
PILL_HINT_RE = re.compile(r"arcSize=50[^\"]*strokeWidth=1\b|strokeWidth=1\b[^\"]*arcSize=50")
FONT_RE = re.compile(r"fontFamily=([^;\"]+)")
STROKE_RE = re.compile(r"strokeWidth=([0-9.]+)")
LINE_RE = re.compile(r"^line", re.I)
PAGE_BG_RE = re.compile(r'(?:background|pageBackgroundColor)="([^"]+)"')
# Canonical SAP flow-pill vocabulary, kept in sync with validate.py.
CANONICAL_PILL_VOCAB = {
"trust", "authenticate", "authentication", "authorization",
"identity", "identity lifecycle", "customer-managed identity lifecycle",
"user", "usergroup", "group", "role", "role collection", "role collections",
"policy", "scim", "saml2/oidc", "oidc", "saml", "openid",
"https", "https/active", "https/standby", "rest", "rest/spi",
"rest/token", "rest / odata", "odata/rest", "odata/rest/soap",
"destination", "source", "target", "harmonized api",
"data federation", "data sync", "task data",
"a2a", "mcp", "ord",
"business data cloud", "business role", "cdm",
"role replica",
"commit", "build & test", "release", "deploy", "connectivity", "private link",
"sql", "security logs", "alerts, findings & enriched events",
"correlated incidents", "status & closure updates", "notification", "open ticket",
"data", "metadata",
}
STOPWORDS = {
"a", "an", "and", "app", "apps", "architecture", "as", "at", "be", "by",
"cloud", "create", "diagram", "for", "from", "in", "into", "is", "l0",
"l1", "l2", "of", "on", "or", "page", "ref", "reference", "sap", "show",
"solution", "style", "the", "to", "use", "using", "via", "with",
}
TOKEN_CANONICAL = {
"adminstrator": "administrator",
"plaforms": "platforms",
"provisoning": "provisioning",
}
SAP_REFERENCE_GRID_BASELINE = 0.23
# --- Fingerprint ---------------------------------------------------------------
@dataclass
class Fingerprint:
path: str
canvas_w: int = 0
canvas_h: int = 0
cells_total: int = 0
vertices: int = 0
edges: int = 0
zones: int = 0
icons: int = 0
icons_inline: int = 0 # bundled inline-SVG icons (preferred)
icons_stencil: int = 0 # mxgraph.sap.icon stencil legacy
external_images: int = 0
pills: int = 0
grid_snap_rate: float = 0.0
has_absolute_arc: bool = False
has_label_bg: bool = False
palette: set[str] = field(default_factory=set)
edge_palette: set[str] = field(default_factory=set) # strokeColors used on edges
fonts: set[str] = field(default_factory=set)
stroke_widths: set[float] = field(default_factory=set)
shapes: set[str] = field(default_factory=set)
label_count: int = 0
label_tokens: set[str] = field(default_factory=set)
pill_vocab: set[str] = field(default_factory=set)
canonical_pill_count: int = 0
novelty_pill_count: int = 0
page_background: str = ""
zone_depth: int = 0 # max nested zone depth observed
sap_logo_count: int = 0
def split_words(text: str) -> list[str]:
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
text = re.sub(r"([a-z])([A-Z])", r"\1 \2", text)
text = text.replace("_", " ").replace("-", " ").replace("/", " ")
return [t.lower() for t in re.findall(r"[A-Za-z0-9]+", text)]
def clean_label(value: str) -> str:
value = html.unescape(value)
value = re.sub(r"<br\s*/?>", " ", value, flags=re.I)
value = re.sub(r"<[^>]+>", " ", value)
value = re.sub(r"&nbsp;", " ", value)
return re.sub(r"\s+", " ", value).strip()
def tokens(text: str) -> set[str]:
out: set[str] = set()
words = split_words(text)
joined = "".join(words)
for word in words:
word = TOKEN_CANONICAL.get(word, word)
if len(word) >= 2 and word not in STOPWORDS:
out.add(word)
for compact in ("xsuaa", "privatelink", "workzone", "eventmesh", "multiaz", "multiregion", "businessdatacloud"):
if compact in joined:
out.add(compact)
if "businessdatacloud" in out:
out.add("bdc")
if "cloudconnector" in joined:
out.add("cloudconnector")
if "principalpropagation" in joined:
out.add("principalpropagation")
if "s4hana" in joined or "4hana" in out:
out.add("s4hana")
return out
def parse_style_dict(style: str) -> dict[str, str]:
out: dict[str, str] = {}
if not style:
return out
for part in style.split(";"):
part = part.strip()
if not part or "=" not in part:
continue
k, v = part.split("=", 1)
out[k.strip()] = v.strip()
return out
def fingerprint(path: Path) -> Fingerprint:
fp = Fingerprint(path=str(path))
text = path.read_text(encoding="utf-8")
try:
root = ET.parse(path).getroot()
except ET.ParseError:
palette_text = DATA_URI_RE.sub("", text)
fp.palette = {h.upper() for h in HEX_RE.findall(palette_text)}
fp.fonts = set(FONT_RE.findall(palette_text))
fp.stroke_widths = {float(s) for s in STROKE_RE.findall(palette_text)}
fp.has_absolute_arc = bool(ABS_ARC_RE.search(text))
fp.has_label_bg = bool(LABEL_BG_RE.search(text))
bg_match = PAGE_BG_RE.search(palette_text)
if bg_match:
fp.page_background = bg_match.group(1).strip().lower()
return fp
graph = root.find(".//mxGraphModel")
scope = graph if graph is not None else root
scope_text = ET.tostring(scope, encoding="unicode")
palette_text = DATA_URI_RE.sub("", scope_text)
fp.palette = {h.upper() for h in HEX_RE.findall(palette_text)}
fp.fonts = set(FONT_RE.findall(palette_text))
fp.stroke_widths = {float(s) for s in STROKE_RE.findall(palette_text)}
fp.has_absolute_arc = bool(ABS_ARC_RE.search(scope_text))
fp.has_label_bg = bool(LABEL_BG_RE.search(scope_text))
bg_match = PAGE_BG_RE.search(palette_text)
if bg_match:
fp.page_background = bg_match.group(1).strip().lower()
if graph is not None:
fp.canvas_w = int(graph.get("pageWidth") or graph.get("dx") or 0)
fp.canvas_h = int(graph.get("pageHeight") or graph.get("dy") or 0)
if not fp.page_background:
bg = (graph.get("background") or graph.get("pageBackgroundColor") or "").strip().lower()
if bg:
fp.page_background = bg
cells = scope.findall(".//mxCell")
fp.cells_total = len(cells)
coords: list[float] = []
labels: set[str] = set()
for elem in scope.iter():
for attr in ("name", "label", "value"):
raw = elem.get(attr)
if not raw:
continue
label = clean_label(raw)
if label:
labels.add(label)
fp.label_count = len(labels)
for label in labels:
fp.label_tokens |= tokens(label)
# Map cell id → cell so we can compute parent-zone nesting depth.
cells_by_id: dict[str, ET.Element] = {}
for c in cells:
cid = c.get("id")
if cid:
cells_by_id[cid] = c
parent_by_elem = {id(child): parent for parent in scope.iter() for child in list(parent)}
def is_zone_cell(c: ET.Element) -> bool:
style_text = c.get("style") or ""
if not ARC16_RE.search(style_text):
return False
if "strokeWidth=1.5" not in style_text:
return False
# SAP zone styling encodes bold via inline HTML in `value`, not fontStyle.
# So we accept zone cells with arcSize=16 + strokeWidth=1.5 even without
# fontStyle=1 — a critical fix for accurate zone counting.
return True
zone_ids: set[str] = set()
for c in cells:
if c.get("vertex") == "1":
fp.vertices += 1
style = c.get("style") or ""
sd = parse_style_dict(style)
inline_icon = bool(INLINE_SVG_ICON_RE.search(style))
stencil_icon = bool(STENCIL_ICON_RE.search(style))
if inline_icon:
fp.icons += 1
fp.icons_inline += 1
elif stencil_icon:
fp.icons += 1
fp.icons_stencil += 1
if EXTERNAL_IMAGE_RE.search(style):
fp.external_images += 1
image = sd.get("image", "")
if image and "sap_logo" in image.lower():
fp.sap_logo_count += 1
for shape in SHAPE_RE.findall(style):
if shape != "image":
fp.shapes.add(shape)
if ARC50_RE.search(style):
fp.pills += 1
# capture pill label vocabulary
raw_label = c.get("value") or ""
if not raw_label:
parent = parent_by_elem.get(id(c))
if parent is not None and parent.tag == "UserObject":
raw_label = parent.get("value") or parent.get("label") or ""
pill_label = clean_label(raw_label).strip().lower()
if pill_label:
fp.pill_vocab.add(pill_label)
if pill_label in CANONICAL_PILL_VOCAB:
fp.canonical_pill_count += 1
else:
fp.novelty_pill_count += 1
elif is_zone_cell(c):
fp.zones += 1
cid = c.get("id")
if cid:
zone_ids.add(cid)
geo = c.find("mxGeometry")
if geo is not None:
for attr in ("x", "y", "width", "height"):
v = geo.get(attr)
if v is not None:
try:
coords.append(float(v))
except ValueError:
pass
elif c.get("edge") == "1":
fp.edges += 1
style = c.get("style") or ""
sd = parse_style_dict(style)
stroke = sd.get("strokeColor", "").upper()
if stroke and stroke.startswith("#"):
fp.edge_palette.add(stroke)
# Zone nesting depth: count how many zone cells appear in the parent chain.
def zone_depth_for(c: ET.Element) -> int:
depth = 0
parent_id = c.get("parent")
seen = set()
while parent_id and parent_id not in seen:
seen.add(parent_id)
parent_cell = cells_by_id.get(parent_id)
if parent_cell is None:
break
if parent_cell.get("id") in zone_ids:
depth += 1
parent_id = parent_cell.get("parent")
return depth
if zone_ids:
max_depth = 0
for c in cells:
if c.get("vertex") != "1":
continue
d = zone_depth_for(c)
if d > max_depth:
max_depth = d
fp.zone_depth = max_depth
if coords:
snapped = sum(1 for v in coords if abs(v - round(v)) < 1e-6 and round(v) % 10 == 0)
fp.grid_snap_rate = snapped / len(coords)
return fp
# --- Comparison ----------------------------------------------------------------
def jaccard(a: set, b: set) -> float:
if not a and not b:
return 1.0
return len(a & b) / max(1, len(a | b))
@dataclass
class CompareResult:
score: float = 0.0
breakdown: dict = field(default_factory=dict)
diffs: list[str] = field(default_factory=list)
@dataclass
class SapLikenessResult:
score: float = 0.0
breakdown: dict[str, float] = field(default_factory=dict)
issues: list[str] = field(default_factory=list)
def sap_likeness(fp: Fingerprint, *, validator_errors: int = 0) -> SapLikenessResult:
"""Reference-free SAP Architecture Center style score."""
result = SapLikenessResult()
parts: dict[str, float] = {}
accepted_bgs = {"", "none", "default", "#ffffff", "#fff"}
parts["page_bg"] = 1.0 if fp.page_background.lower() in accepted_bgs else 0.0
if not parts["page_bg"]:
result.issues.append(f"non-white page background {fp.page_background!r}")
parts["validator_errors"] = 1.0 if validator_errors == 0 else 0.0
if validator_errors:
result.issues.append(f"{validator_errors} validator error(s)")
parts["zones"] = min(1.0, fp.zones / 1.0)
if fp.zones == 0:
result.issues.append("no SAP-style zones detected")
parts["icons"] = min(1.0, fp.icons / 3.0) if fp.vertices >= 4 else min(1.0, fp.icons / 1.0)
if fp.icons == 0:
result.issues.append("no bundled/icon-library assets detected")
parts["pills"] = min(1.0, fp.pills / 3.0) if fp.edges >= 2 else 1.0
if fp.edges >= 2 and fp.pills == 0:
result.issues.append("no SAP-style flow pills detected")
if fp.pills:
parts["pill_vocab"] = max(0.0, 1.0 - (fp.novelty_pill_count / max(1, fp.pills)))
else:
parts["pill_vocab"] = 0.8
if parts["pill_vocab"] < 1.0:
result.issues.append(f"{fp.novelty_pill_count} non-canonical pill label(s)")
sap_palette = {color.upper() for color in SAP_PALETTE}
visible_palette = {color.upper() for color in fp.palette}
if visible_palette:
parts["palette"] = len(visible_palette & sap_palette) / len(visible_palette)
else:
parts["palette"] = 1.0
if parts["palette"] < 1.0:
result.issues.append(f"off-palette colors: {sorted(visible_palette - sap_palette)[:6]}")
if fp.edge_palette:
parts["edge_palette"] = len(fp.edge_palette & sap_palette) / len(fp.edge_palette)
else:
parts["edge_palette"] = 1.0
fonts = {font.lower() for font in fp.fonts}
parts["fonts"] = 1.0 if not fonts or fonts <= {"helvetica", "arial"} else 0.0
if not parts["fonts"]:
result.issues.append(f"non-SAP font families: {sorted(fp.fonts)}")
allowed_strokes = {1.0, 1.5, 2.0, 3.0, 4.0}
if fp.stroke_widths:
parts["strokes"] = len(fp.stroke_widths & allowed_strokes) / len(fp.stroke_widths)
else:
parts["strokes"] = 1.0
if parts["strokes"] < 1.0:
result.issues.append(f"non-standard stroke widths: {sorted(fp.stroke_widths - allowed_strokes)}")
parts["abs_arc"] = 1.0 if fp.has_absolute_arc else 0.6
parts["label_bg"] = 1.0 if fp.has_label_bg or fp.edges == 0 else 0.8
parts["grid_snap"] = min(1.0, fp.grid_snap_rate / SAP_REFERENCE_GRID_BASELINE)
if fp.grid_snap_rate < SAP_REFERENCE_GRID_BASELINE:
result.issues.append(f"grid-snap rate {fp.grid_snap_rate * 100:.1f}%")
parts["external_images"] = max(0.0, 1.0 - min(fp.external_images, 3) * 0.1)
if fp.external_images > 1:
result.issues.append(f"{fp.external_images} external image(s)")
weights = {
"page_bg": 2.0,
"validator_errors": 2.0,
"zones": 1.25,
"icons": 1.0,
"pills": 0.75,
"pill_vocab": 1.25,
"palette": 1.5,
"edge_palette": 0.75,
"fonts": 1.0,
"strokes": 0.75,
"abs_arc": 0.5,
"label_bg": 0.5,
"grid_snap": 1.0,
"external_images": 1.0,
}
total = sum(weights[k] for k in parts)
result.score = round(sum(parts[k] * weights[k] for k in parts) / total * 100, 1)
result.breakdown = parts
return result
def compare(ref: Fingerprint, cand: Fingerprint) -> CompareResult:
r = CompareResult()
parts: dict[str, float] = {}
parts["canvas"] = 1.0 if (ref.canvas_w == cand.canvas_w and ref.canvas_h == cand.canvas_h) else 0.0
if not parts["canvas"]:
r.diffs.append(f"canvas mismatch — ref {ref.canvas_w}x{ref.canvas_h} vs cand {cand.canvas_w}x{cand.canvas_h}")
# Page background fidelity. SAP diagrams use white/transparent canvas;
# any explicit non-white background is a major red flag.
accepted_bgs = {"", "none", "default", "#ffffff", "#fff"}
cand_bg = cand.page_background.lower()
ref_bg = ref.page_background.lower()
if cand_bg in accepted_bgs:
parts["page_bg"] = 1.0
elif cand_bg == ref_bg:
parts["page_bg"] = 1.0
else:
parts["page_bg"] = 0.0
r.diffs.append(
f"non-white page background {cand.page_background!r} — SAP uses white/transparent canvas"
)
def ratio(a: float, b: float) -> float:
if a == 0 and b == 0:
return 1.0
if a == 0 or b == 0:
return 0.0
return min(a, b) / max(a, b)
# zones: zone detection now uses arcSize=16 + strokeWidth=1.5 (no fontStyle
# requirement) — matches SAP's actual encoding where bold is HTML-inline.
if ref.zones > 0 or cand.zones > 0:
parts["zones"] = ratio(ref.zones, cand.zones)
# zone nesting depth — matters for templates like Joule-inside-vs-beside-BTP
if ref.zone_depth > 0 or cand.zone_depth > 0:
parts["zone_depth"] = ratio(ref.zone_depth, cand.zone_depth)
if ref.zone_depth != cand.zone_depth:
r.diffs.append(
f"zone nesting depth differs — ref {ref.zone_depth} vs cand {cand.zone_depth}"
)
# icons: prefer inline-SVG over legacy mxgraph.sap.icon stencils. SAP's own
# corpus uses both, so we don't penalize stencils outright; we just count
# inline + stencil together to match SAP's behavior.
parts["icons"] = ratio(ref.icons, cand.icons)
parts["external_images"] = 1.0 if cand.external_images <= ref.external_images else ratio(ref.external_images, cand.external_images)
parts["edges"] = ratio(ref.edges, cand.edges)
parts["vertices"] = ratio(ref.vertices, cand.vertices)
parts["pills"] = ratio(ref.pills, cand.pills) if (ref.pills or cand.pills) else 1.0
# Pill vocabulary fidelity. Reward use of SAP-canonical pill labels;
# penalize candidates whose pills are mostly novelty verbs.
if ref.pills > 0 or cand.pills > 0:
ref_canon_rate = ref.canonical_pill_count / max(1, ref.pills)
cand_canon_rate = cand.canonical_pill_count / max(1, cand.pills)
if ref.pills == 0:
parts["pill_vocab"] = 1.0 if cand_canon_rate >= 0.6 else cand_canon_rate
else:
# Match the reference's own canon rate. Some official SAP diagrams
# intentionally use scenario-specific flow labels such as
# "Security Logs" or "Open Ticket"; comparing a reference with
# itself must still score 100.
target = ref_canon_rate
parts["pill_vocab"] = 1.0 if cand_canon_rate >= target else (cand_canon_rate / max(0.01, target))
if cand.novelty_pill_count > 0 and cand.novelty_pill_count > ref.novelty_pill_count:
r.diffs.append(
f"novelty pill labels: {cand.novelty_pill_count} (cand) vs {ref.novelty_pill_count} (ref) "
"— prefer canonical SAP verbs (TRUST/Authenticate/A2A/MCP/ORD/...)"
)
# Edge palette: which colors are actually used on edges? This catches
# green↔magenta semantic swaps that the global palette set hides.
if ref.edge_palette or cand.edge_palette:
parts["edge_palette"] = jaccard(ref.edge_palette, cand.edge_palette)
edge_diff = ref.edge_palette - cand.edge_palette
if edge_diff:
r.diffs.append(f"edge stroke colors missing from candidate: {sorted(edge_diff)[:6]}")
parts["palette"] = jaccard(ref.palette, cand.palette)
only_in_cand = cand.palette - ref.palette
if only_in_cand:
r.diffs.append(f"colors in candidate not in reference: {sorted(only_in_cand)[:8]}")
# fonts: candidate ⊆ ref counts as full credit. SAP's own files mix
# Arial+Helvetica; if our candidate uses only one of them, that's fine.
if cand.fonts and ref.fonts and cand.fonts <= ref.fonts:
parts["fonts"] = 1.0
else:
parts["fonts"] = jaccard(ref.fonts, cand.fonts)
if cand.fonts and not (cand.fonts <= ref.fonts):
r.diffs.append(f"fonts: ref={sorted(ref.fonts)} cand={sorted(cand.fonts)}")
parts["strokes"] = jaccard(ref.stroke_widths, cand.stroke_widths)
parts["shapes"] = jaccard(ref.shapes, cand.shapes)
parts["label_count"] = ratio(ref.label_count, cand.label_count)
parts["label_tokens"] = jaccard(ref.label_tokens, cand.label_tokens)
missing_label_tokens = ref.label_tokens - cand.label_tokens
extra_label_tokens = cand.label_tokens - ref.label_tokens
if parts["label_tokens"] < 0.8:
r.diffs.append(
"label token drift — "
f"missing={sorted(missing_label_tokens)[:8]} extra={sorted(extra_label_tokens)[:8]}"
)
extra_shapes = cand.shapes - ref.shapes
if extra_shapes:
r.diffs.append(f"shape styles in candidate not in reference: {sorted(extra_shapes)[:8]}")
if cand.external_images > ref.external_images:
r.diffs.append(f"external image count increased — ref {ref.external_images} vs cand {cand.external_images}")
parts["abs_arc"] = 1.0 if ref.has_absolute_arc == cand.has_absolute_arc else 0.5
parts["label_bg"] = 1.0 if ref.has_label_bg == cand.has_label_bg else 0.5
# grid_snap: candidate at-or-above reference scores 1.0; else proportional.
# We don't penalise the candidate for being MORE snapped than SAP's own files,
# we just want it to be at least as clean. Target absolute rate is ≥ 0.95
# which we surface as a separate diff.
if ref.grid_snap_rate >= 0.95:
parts["grid_snap"] = 1.0 if cand.grid_snap_rate >= ref.grid_snap_rate * 0.95 else cand.grid_snap_rate
else:
# SAP reference itself is sloppy — give candidate full credit if it matches or exceeds it
parts["grid_snap"] = 1.0 if cand.grid_snap_rate >= ref.grid_snap_rate else cand.grid_snap_rate / max(0.01, ref.grid_snap_rate)
if cand.grid_snap_rate < 0.95:
r.diffs.append(f"grid-snap rate {cand.grid_snap_rate*100:.1f}% (recommend 95%+; reference is {ref.grid_snap_rate*100:.1f}%)")
weights = {
"canvas": 1.0,
"page_bg": 1.5, # NEW: dark/branded canvas now penalized
"zones": 1.5,
"zone_depth": 1.0, # NEW: nesting hierarchy match (Joule-in-BTP bug)
"icons": 1.5,
"external_images": 0.5,
"edges": 1.0,
"vertices": 0.5,
"pills": 0.5,
"pill_vocab": 1.5, # NEW: canonical SAP pill verbs vs novelty
"palette": 1.5,
"edge_palette": 1.0, # NEW: connector colors actually used on edges
"fonts": 1.0,
"strokes": 0.5,
"shapes": 1.0,
"label_count": 0.5,
"label_tokens": 2.0,
"abs_arc": 0.5,
"label_bg": 0.5,
"grid_snap": 1.0,
}
total_weight = sum(weights[k] for k in parts)
score = sum(parts[k] * weights[k] for k in parts) / total_weight * 100
r.score = round(score, 1)
r.breakdown = parts
return r
# --- CLI -----------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("reference", type=Path)
ap.add_argument("candidate", type=Path)
ap.add_argument("--json", action="store_true")
ap.add_argument("--score", action="store_true")
args = ap.parse_args()
ref = fingerprint(args.reference)
cand = fingerprint(args.candidate)
result = compare(ref, cand)
quality = sap_likeness(cand)
if args.score:
print(f"{result.score:.1f}")
return 0
if args.json:
out = {
"score": result.score,
"sap_likeness": asdict(quality),
"breakdown": result.breakdown,
"diffs": result.diffs,
"reference": asdict(ref),
"candidate": asdict(cand),
}
# sets aren't JSON-serializable; coerce
for fp_dict in (out["reference"], out["candidate"]):
for k in ("palette", "edge_palette", "fonts", "stroke_widths", "shapes", "label_tokens", "pill_vocab"):
fp_dict[k] = sorted(fp_dict[k])
print(json.dumps(out, indent=2))
return 0
print(f"reference : {args.reference}")
print(f"candidate : {args.candidate}")
print(f"score : {result.score:.1f}/100")
print(f"sap-like : {quality.score:.1f}/100")
print("breakdown :")
for k, v in result.breakdown.items():
bar = "█" * int(v * 20)
print(f" {k:10s} {v*100:5.1f}% {bar}")
if result.diffs:
print("\nnotable diffs:")
for d in result.diffs:
print(f" - {d}")
print("\nreference fingerprint:")
for k, v in asdict(ref).items():
if isinstance(v, set):
v = sorted(v)
print(f" {k}: {v}")
print("\ncandidate fingerprint:")
for k, v in asdict(cand).items():
if isinstance(v, set):
v = sorted(v)
print(f" {k}: {v}")
return 0
if __name__ == "__main__":
sys.exit(main())