682 lines
27 KiB
Python
682 lines
27 KiB
Python
#!/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" ", " ", 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())
|