stdraw 0.7.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stdraw/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ # -*- coding: utf-8 -*-
2
+ """stdraw systems-thinking diagram engine.
3
+
4
+ Three-layer architecture:
5
+ Layout layer (planar / layout3d, replaceable implementations)
6
+ Quality layer (metrics five-metric certificate + check geometry self-inspection + loops loop semantic audit) -- this project's moat
7
+ Viewer layer (viewer/)
8
+
9
+ Honest tiering promise: if a certificate cannot be obtained we say so; we never claim 0 crossings for arbitrary graphs.
10
+ """
11
+
12
+ __version__ = "0.7.13"
13
+
14
+ from .model import Graph, Node, Edge, Loop, Group
15
+ from .dsl import parse, dumps
16
+ from .planar import PlanarLayout
17
+ from . import recipes
18
+ from .metrics import certificate, certificate_auto
19
+ from .check import geometry_report
20
+ from .loops import loops_audit
21
+ from . import crossing_exact # WS2: exact crossing-number layer (optional ortools)
22
+ from . import regularity # WS2: structure detection + computable definitions of regularity
23
+ from . import placement # WS2: slot-discretized placement (optional ortools)
24
+ from . import ringlayout # t_af83815d: loop-driven ring placement (trigger decision + slots)
25
+
26
+ __all__ = [
27
+ "Graph", "Node", "Edge", "Loop", "Group",
28
+ "parse", "dumps",
29
+ "PlanarLayout", "certificate", "certificate_auto", "geometry_report",
30
+ "loops_audit", "recipes",
31
+ "crossing_exact", "regularity", "placement",
32
+ ]
stdraw/__main__.py ADDED
@@ -0,0 +1,350 @@
1
+ # -*- coding: utf-8 -*-
2
+ """CLI: python -m stdraw layout <input.cld|input.json> [options]
3
+
4
+ Outputs (--outdir, default ./stdraw-out):
5
+ graph.json graph model (DSL->JSON normalization)
6
+ layout.json 2D layout (delivered coordinates, all integers)
7
+ geometry_report.json 11-check geometry self-report (CI gate form)
8
+ certificate.json five-metric + placement metrics + honest grading certificate
9
+ loops_audit.json loop semantics audit (lint)
10
+ demo-2d.html standalone rendering page (certificate embedded)
11
+ positions3d.json 3D per-solid assignment coordinates
12
+ demo-3d.html 3D interactive page (needs ../assets/three.min.js relative path)
13
+ """
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+
19
+ from . import __version__
20
+ from .model import Graph
21
+ from .dsl import parse, dumps
22
+ from .planar import PlanarLayout, NotPlanarError, LayoutConfig
23
+ from .recipes import run as recipes_run
24
+ from .check import geometry_report_to_file
25
+ from .metrics import certificate, scene_from_planar
26
+ from .loops import loops_audit
27
+ from .render import render_html
28
+ from .layout3d import SOLIDS, layout3d
29
+ from .render3d import render_3d_html
30
+ from .delivery import strip_delivery_metadata, stamp_cli_deliverable
31
+
32
+
33
+ def load_graph(path: str) -> Graph:
34
+ if path.endswith(".json"):
35
+ with open(path, encoding="utf-8") as f:
36
+ return Graph.from_dict(json.load(f))
37
+ with open(path, encoding="utf-8") as f:
38
+ text = f.read()
39
+ return parse(text)
40
+
41
+
42
+ def load_manual_layout(path: str) -> dict:
43
+ """Manual layout JSON -> the manual argument of layout().
44
+
45
+ Accepts a full layout.json or the demo-2d.html "Export layout" subset
46
+ ({nodes: {id: {x,y}|[x,y]}, viewBox}). source records the file name for
47
+ meta provenance.
48
+ Optionally edges: [{a, b, waypoints: [[x,y],...]}] — waypoints manually
49
+ anchored by the user on demo-2d bend handles; --from-layout recomputes
50
+ them under the same caliber.
51
+ """
52
+ with open(path, encoding="utf-8") as f:
53
+ raw = json.load(f)
54
+ if "nodes" not in raw:
55
+ raise ValueError(f"{path}: manual layout is missing nodes (node id -> x,y)")
56
+ return {"nodes": raw["nodes"], "viewBox": raw.get("viewBox"),
57
+ "edges": raw.get("edges"),
58
+ "source": os.path.basename(path)}
59
+
60
+
61
+ def main(argv=None):
62
+ ap = argparse.ArgumentParser(
63
+ prog="stdraw",
64
+ description="stdraw — planarity-aware systems-thinking diagram engine "
65
+ "(text DSL in, certified diagram out)",
66
+ epilog=(
67
+ "Examples:\n"
68
+ " stdraw layout examples/limits-to-growth.cld --outdir out\n"
69
+ " python -m stdraw layout examples/limits-to-growth.cld --outdir out --pitch 62\n"
70
+ "Outputs (--outdir, default ./stdraw-out): graph.json / layout.json /\n"
71
+ "geometry_report.json / certificate.json / loops_audit.json /\n"
72
+ "demo-2d.html / positions3d.json / demo-3d.html"
73
+ ),
74
+ formatter_class=argparse.RawDescriptionHelpFormatter)
75
+ ap.add_argument("command", choices=["layout", "layout-diff"],
76
+ help="subcommand: layout = full layout pipeline; layout-diff = auto vs manual three-layer diff")
77
+ ap.add_argument("input", help="input .cld (DSL) or .json (graph model)")
78
+ ap.add_argument("input2", nargs="?",
79
+ help="layout-diff second argument: manual layout layout.json")
80
+ ap.add_argument("--outdir", default="stdraw-out")
81
+ ap.add_argument("--recipe", default="auto", choices=["auto", "ortho"],
82
+ help="layout recipe: auto = auto-v2 (default, straight/arc lines + new hard gates); "
83
+ "ortho = ortho-v1 (orthogonal vertical-line constructive, behavior frozen, pin-safe)")
84
+ ap.add_argument("--linestyle", default="straight", choices=["straight", "arc"],
85
+ help="auto recipe line style (default straight; arc = all-arc preference)")
86
+ ap.add_argument("--skip-3d", action="store_true", help="run the 2D layer only")
87
+ ap.add_argument("--solids", default="sphere,cube,tetra,cone",
88
+ help="3D solid list (comma separated)")
89
+ ap.add_argument("--pitch", type=int, default=None,
90
+ help="row pitch px (default 56; raise it for small graphs to pass the clearance gate, parameter is transparent)")
91
+ ap.add_argument("--value-pad", type=float, default=None, metavar="PX",
92
+ help="extra per-side padding px for the value (second) text line "
93
+ "(default: historical baseline; raise it when long ticker/price "
94
+ "value strings overflow the planned card width)")
95
+ ap.add_argument("--column-order", default=None, metavar="V1,V2,...",
96
+ help="v0.7.11 deterministic constraint (comb layouts): explicit "
97
+ "source-column internal order, top->bottom, as a hard "
98
+ "constraint (components keep their internal DFS order)")
99
+ ap.add_argument("--hub-side", default=None, metavar="NODE=left|right,...",
100
+ help="v0.7.11 deterministic constraint (comb layouts): explicit "
101
+ "hub side assignment, e.g. wdc=left,stx=right")
102
+ ap.add_argument("--name", default="", help="graph name (default: the DSL graph name)")
103
+ ap.add_argument("--exact", action="store_true",
104
+ help="WS2 exact crossing-number layer: B&B squeeze computes cr(G) and writes the "
105
+ "certificate exactness block (proven_optimal/gap; requires the optional "
106
+ "ortools dependency; honestly degraded when not installed)")
107
+ ap.add_argument("--from-layout", default=None, metavar="MANUAL_JSON",
108
+ help="manual layout JSON (the demo-2d.html \"Export layout\" product or a "
109
+ "layout.json subset: node id->x,y + viewBox). Runs the same routing + "
110
+ "certificate pipeline on manual coordinates; hard-gate caliber unchanged, "
111
+ "graded honestly")
112
+ ap.add_argument("--compare-auto", action="store_true",
113
+ help="with --from-layout: also runs the automatic layout of the same graph into <outdir>-compare-auto/, "
114
+ "writing the dual-certificate comparison compare-auto.json (the aesthetic distance between engine and human)")
115
+ ap.add_argument("--cld", default=None,
116
+ help="layout-diff: graph source (.cld/.json), enables the semantic layer (loop membership / cohesion)")
117
+ ap.add_argument("--diff-out", default="layout-diff.json",
118
+ help="layout-diff: machine-readable JSON output path")
119
+ a = ap.parse_args(argv)
120
+
121
+ if a.command == "layout-diff":
122
+ if not a.input2:
123
+ print("layout-diff requires two arguments: <auto-layout.json> <manual-layout.json>",
124
+ file=sys.stderr)
125
+ sys.exit(2)
126
+ from .layoutdiff import main as diff_main
127
+ sys.exit(diff_main([a.input, a.input2] +
128
+ (["--cld", a.cld] if a.cld else []) +
129
+ ["--out", a.diff_out]))
130
+
131
+ g = load_graph(a.input)
132
+ os.makedirs(a.outdir, exist_ok=True)
133
+ deferred_exit = 0 # --compare-auto: emit the comparison report first, exit later on geometry fail
134
+ name = a.name or g.name or os.path.splitext(os.path.basename(a.input))[0]
135
+ problems = g.validate()
136
+ if problems:
137
+ print("Structural validation failed:", *problems, sep="\n ", file=sys.stderr)
138
+ sys.exit(2)
139
+
140
+ def w(fn, obj):
141
+ p = os.path.join(a.outdir, fn)
142
+ with open(p, "w", encoding="utf-8") as f:
143
+ json.dump(obj, f, ensure_ascii=False, indent=1)
144
+ return p
145
+
146
+ # Graph model + normalized DSL text
147
+ p = w("graph.json", g.to_dict())
148
+ with open(os.path.join(a.outdir, "graph.cld"), "w", encoding="utf-8") as f:
149
+ f.write(dumps(g))
150
+ print(f"[1/6] graph model OK: {len(g.nodes)} nodes / {len(g.edges)} edges / "
151
+ f"{len(g.loops)} declared loops -> {p}")
152
+
153
+ # Loop semantics audit (lint)
154
+ lr = loops_audit(g)
155
+ p = w("loops_audit.json", lr)
156
+ print(f"[2/6] loop audit: declared {lr['summary']['declared']}, closed "
157
+ f"{lr['summary']['closed']}, independently enumerated {lr['summary']['enumerated']}, "
158
+ f"problems {lr['summary']['problems']} -> {p}")
159
+ for pr in lr["problems"]:
160
+ print(" lint:", pr)
161
+
162
+ # 2D layout
163
+ if a.recipe == "ortho":
164
+ cfg = LayoutConfig()
165
+ if a.pitch:
166
+ cfg.pitch = a.pitch
167
+ eng = PlanarLayout(g, cfg)
168
+ try:
169
+ v4 = eng.run()
170
+ except NotPlanarError as e:
171
+ print(f"[3/6] 2D planar layer inapplicable: {e}")
172
+ print(" honest degradation: skipping the 2D certificate (a replaceable layout-layer implementation is M1+)")
173
+ v4 = None
174
+ else:
175
+ # ortho-v1 freeze-compat: the pipeline stamps delivery-contract
176
+ # metadata (stdraw/delivery.py), but the CLI ortho layout.json is a
177
+ # byte-frozen production pin (tests/baselines/, ci_checks gate A),
178
+ # so the metadata is stripped here; the ortho delivery contract
179
+ # travels on certificate.json (interactive / drag_layer) instead.
180
+ v4 = strip_delivery_metadata(v4)
181
+ labels = {n.id: n.label for n in g.nodes}
182
+ values = {n.id: n.value for n in g.nodes if n.value}
183
+ p = w("layout.json", v4)
184
+ print(f"[3/6] 2D layout (ortho-v1 frozen recipe): {v4['viewBox'][2]}x"
185
+ f"{v4['viewBox'][3]}, {v4['meta']['rows']} rows -> {p}")
186
+ # 11-check geometry self-report (runs on delivered coordinates)
187
+ rc = geometry_report_to_file(
188
+ v4, os.path.join(a.outdir, "geometry_report.json"))
189
+ # Five-metric certificate (legacy caliber, byte-compatible)
190
+ cert = certificate(scene_from_planar(v4), graph_name=name,
191
+ layer="2D",
192
+ notes=["unrelated lines piercing cards occlusion-style is allowed by design (cards are opaque)"],
193
+ interactive="yes", drag_layer="yes")
194
+ p = w("certificate.json", cert)
195
+ m = cert["metrics"]
196
+ print(f"[4/6] certificate: crossings {m['crossings']} / true card piercings {m['occlusions_true_0px']} / "
197
+ f"gap {m['min_card_gap']} / alignment {m['row_align_rate']} / CV "
198
+ f"{m['spacing_cv']} -> grade [{cert['grade']}] -> {p}")
199
+ hp = os.path.join(a.outdir, "demo-2d.html")
200
+ with open(hp, "w", encoding="utf-8") as f:
201
+ f.write(render_html(v4, name, cert, lr, labels, values))
202
+ print(f" demo -> {hp}")
203
+ if rc:
204
+ sys.exit(1)
205
+ else:
206
+ from .layout_auto import AutoConfig
207
+ from .metrics import certificate_auto, scene_from_auto
208
+ cfg = AutoConfig()
209
+ if a.pitch:
210
+ cfg.pitch = a.pitch
211
+ if a.value_pad is not None:
212
+ cfg.value_pad_extra = a.value_pad
213
+ if a.column_order:
214
+ cfg.column_order = [v.strip() for v in a.column_order.split(",") if v.strip()]
215
+ if a.hub_side:
216
+ cfg.hub_side = {}
217
+ for _part in a.hub_side.split(","):
218
+ if "=" in _part:
219
+ _k, _v = _part.split("=", 1)
220
+ _v = _v.strip().lower()
221
+ if _v in ("left", "right"):
222
+ cfg.hub_side[_k.strip()] = _v
223
+ if a.from_layout:
224
+ # Manual layout: same routing + certificate pipeline; hard-gate
225
+ # caliber unchanged, graded honestly
226
+ from .layout_auto import layout as _auto_layout_fn
227
+ try:
228
+ _manual = load_manual_layout(a.from_layout)
229
+ v4 = _auto_layout_fn(g, cfg, a.linestyle, manual=_manual)
230
+ except ValueError as e:
231
+ print(f"manual layout unusable: {e}", file=sys.stderr)
232
+ sys.exit(2)
233
+ else:
234
+ v4 = recipes_run("auto-v2", g, cfg, a.linestyle)
235
+ labels = {n.id: n.label for n in g.nodes}
236
+ values = {n.id: n.value for n in g.nodes if n.value}
237
+ # Full CLI deliverable (demo-2d.html + certificate.json) is produced
238
+ # below: the layout.json that ships with it is re-stamped interactive
239
+ # (delivery="stdraw-cli", no warning) per the delivery contract.
240
+ v4 = stamp_cli_deliverable(v4)
241
+ p = w("layout.json", v4)
242
+ meta = v4["meta"]
243
+ print(f"[3/6] 2D layout (auto-v2, engine={meta['position_engine']}, "
244
+ f"line style={meta['line_style']}): {v4['viewBox'][2]}x{v4['viewBox'][3]}, "
245
+ f"{meta['rows']} rows -> {p}")
246
+ rc = geometry_report_to_file(
247
+ v4, os.path.join(a.outdir, "geometry_report.json"))
248
+ exactness = None
249
+ if a.exact:
250
+ from .crossing_exact import crossing_exact
251
+ exactness = crossing_exact(g)
252
+ print(f" [exact] cr={exactness['crossing_number']} "
253
+ f"proven_optimal={exactness['proven_optimal']} "
254
+ f"lb={exactness['lb']} gap={exactness['gap']} "
255
+ f"coverage={exactness['coverage']}")
256
+ cert = certificate_auto(scene_from_auto(v4), graph_name=name, layer="2D",
257
+ graph=g, exactness=exactness,
258
+ interactive="yes", drag_layer="yes")
259
+ p = w("certificate.json", cert)
260
+ m = cert["metrics"]
261
+ print(f"[4/6] certificate: crossings {m['crossings']} / box press {m['box_press_1px']} / "
262
+ f"edge-box clearance {m['edge_box_min_clear']} / anchor violations {m['anchor_violations']} / "
263
+ f"gap {m['min_card_gap']} / CV {m['spacing_cv']} -> "
264
+ f"grade [{cert['grade']}] -> {p}")
265
+ hp = os.path.join(a.outdir, "demo-2d.html")
266
+ with open(hp, "w", encoding="utf-8") as f:
267
+ f.write(render_html(v4, name, cert, lr, labels, values))
268
+ print(f" demo -> {hp}")
269
+ if rc:
270
+ if a.from_layout and a.compare_auto:
271
+ deferred_exit = 1 # emit the comparison report first, exit code deferred
272
+ else:
273
+ sys.exit(1)
274
+
275
+ # 3D per-solid assignment
276
+ if not a.skip_3d:
277
+ gd = {"nodes": [{"id": n.id, "label": n.label} for n in g.nodes],
278
+ "edges": [{"a": e.a, "b": e.b, "p": e.polarity} for e in g.edges]}
279
+ solids = [s.strip() for s in a.solids.split(",") if s.strip()]
280
+ unknown = [s for s in solids if s not in SOLIDS]
281
+ if unknown:
282
+ print(f"unknown solids {unknown}, choose from {list(SOLIDS)}", file=sys.stderr)
283
+ sys.exit(2)
284
+ layouts = {s: layout3d(gd, s) for s in solids}
285
+ p = w("positions3d.json",
286
+ {s: layouts[s]["positions"] for s in solids})
287
+ print(f"[5/6] 3D assignment: {solids} -> {p}")
288
+ hp = os.path.join(a.outdir, "demo-3d.html")
289
+ note = ("deterministic placement: slots + minimum-total-edge-length assignment; "
290
+ "no crossing promise in 3D, projection crossings are solved by the camera")
291
+ with open(hp, "w", encoding="utf-8") as f:
292
+ f.write(render_3d_html(gd, layouts, name, note))
293
+ print(f" demo -> {hp} (must live in the same tree as assets/three.min.js relative path)")
294
+ # ── compare-auto: automatic-layout comparison for the same graph (dual certificates) ──
295
+ if a.from_layout and a.compare_auto:
296
+ cmp_dir = a.outdir.rstrip("/\\") + "-compare-auto"
297
+ print("\n-- compare-auto: automatic layout comparison for the same graph --")
298
+ try:
299
+ rc_cmp = main(["layout", a.input, "--outdir", cmp_dir, "--skip-3d"]
300
+ + (["--name", name] if name else []))
301
+ except SystemExit as _e: # geometry fail -> exit 1, recorded honestly
302
+ rc_cmp = _e.code
303
+
304
+ def _cert(d):
305
+ with open(os.path.join(d, "certificate.json"), encoding="utf-8") as f:
306
+ return json.load(f)
307
+
308
+ cert_m, cert_a = _cert(a.outdir), _cert(cmp_dir)
309
+ keys = ("crossings", "box_press_1px", "edge_box_min_clear",
310
+ "anchor_violations", "avg_edge_length", "row_align_rate",
311
+ "min_card_gap", "spacing_cv", "column_snap24_rate")
312
+ table = {}
313
+ for k in keys:
314
+ mv, av = cert_m["metrics"].get(k), cert_a["metrics"].get(k)
315
+ delta = (mv - av) if isinstance(mv, (int, float)) and \
316
+ isinstance(av, (int, float)) else None
317
+ table[k] = {"manual": mv, "auto": av,
318
+ "delta_manual_minus_auto": delta}
319
+ cmp_obj = {
320
+ "tool": "stdraw --compare-auto", "graph": name,
321
+ "manual_outdir": a.outdir, "auto_outdir": cmp_dir,
322
+ "auto_cli_exit": rc_cmp,
323
+ "metrics": table,
324
+ "grade": {"manual": cert_m["grade"], "auto": cert_a["grade"]},
325
+ "gates_passed": {"manual": cert_m["gates_passed"],
326
+ "auto": cert_a["gates_passed"]},
327
+ "note": ("the gap is the aesthetic distance between engine and human "
328
+ "(the basis for future objective-function tuning); the hard-gate "
329
+ "caliber applies equally to manual layouts and grades honestly "
330
+ "without manual favors; the comparison automatic layout did not "
331
+ "run the --exact layer"),
332
+ }
333
+ p = os.path.join(a.outdir, "compare-auto.json")
334
+ with open(p, "w", encoding="utf-8") as f:
335
+ json.dump(cmp_obj, f, ensure_ascii=False, indent=1)
336
+ print(" metric manual auto")
337
+ for k, v in table.items():
338
+ print(f" {k:<26} {str(v['manual']):>8} {str(v['auto']):>10}"
339
+ f" D={v['delta_manual_minus_auto']}")
340
+ print(f" grade: manual={cert_m['grade']} / auto={cert_a['grade']}; "
341
+ f"gates manual {cert_m['gates_passed']} / auto {cert_a['gates_passed']}"
342
+ f" (auto_cli_exit={rc_cmp})")
343
+ print(f" -> {p}")
344
+
345
+ print("[6/6] done.")
346
+ return deferred_exit
347
+
348
+
349
+ if __name__ == "__main__":
350
+ sys.exit(main())