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/model.py ADDED
@@ -0,0 +1,134 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Graph data model: variables / polarity edges / loop tags / groups.
3
+
4
+ Handling the tension with "relations determine layout, zero manual intent":
5
+ groups are merely optional semantic annotations; the layout layer by default does **not** consume groups -- deriving groups automatically via community detection
6
+ belongs to later roadmap milestones; at the current stage groups only enter certificate metadata and the loop-audit context.
7
+ """
8
+ from dataclasses import dataclass, field
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+
12
+ @dataclass
13
+ class Node:
14
+ id: str
15
+ label: str
16
+ value: str = "" # optional: value text on the card's second line (affects card width estimation)
17
+ group: Optional[str] = None
18
+ extra_w: int = 0 # optional: extra measured margin for mixed-script titles (px, multiple of 2)
19
+
20
+
21
+ @dataclass
22
+ class Edge:
23
+ a: str
24
+ b: str
25
+ polarity: str # "+" same-sign (reinforcing) / "-" opposite-sign (balancing)
26
+ loops: List[str] = field(default_factory=list) # tags of loops this edge participates in
27
+
28
+
29
+ @dataclass
30
+ class Loop:
31
+ id: str
32
+ label: str
33
+ nodes: List[str] # declared node sequence (closure verified against the sequence)
34
+ kind: str = "" # "R" reinforcing / "B" balancing (may be empty; checked against polarity parity at audit time)
35
+
36
+
37
+ @dataclass
38
+ class Group:
39
+ id: str
40
+ label: str = ""
41
+
42
+
43
+ @dataclass
44
+ class Graph:
45
+ nodes: List[Node]
46
+ edges: List[Edge]
47
+ loops: List[Loop] = field(default_factory=list)
48
+ groups: List[Group] = field(default_factory=list)
49
+ name: str = ""
50
+
51
+ # ── Convenience views ──
52
+ def node_ids(self) -> List[str]:
53
+ return [n.id for n in self.nodes]
54
+
55
+ def id2node(self) -> Dict[str, Node]:
56
+ return {n.id: n for n in self.nodes}
57
+
58
+ def adj_undirected(self) -> Dict[str, set]:
59
+ adj: Dict[str, set] = {n.id: set() for n in self.nodes}
60
+ for e in self.edges:
61
+ adj.setdefault(e.a, set()).add(e.b)
62
+ adj.setdefault(e.b, set()).add(e.a)
63
+ return adj
64
+
65
+ def degree0(self) -> List[str]:
66
+ adj = self.adj_undirected()
67
+ return [n.id for n in self.nodes if not adj[n.id]]
68
+
69
+ def core(self) -> List[str]:
70
+ adj = self.adj_undirected()
71
+ return [n.id for n in self.nodes if adj[n.id]]
72
+
73
+ def validate(self) -> List[str]:
74
+ """Structural validation: returns a list of problems (empty = passed)."""
75
+ problems = []
76
+ ids = self.node_ids()
77
+ dup = {i for i in ids if ids.count(i) > 1}
78
+ if dup:
79
+ problems.append(f"duplicate node ids: {sorted(dup)}")
80
+ idset = set(ids)
81
+ for e in self.edges:
82
+ if e.a not in idset or e.b not in idset:
83
+ problems.append(f"edge endpoint not declared: {e.a}->{e.b}")
84
+ if e.polarity not in ("+", "-"):
85
+ problems.append(f"invalid polarity: {e.a}->{e.b} {e.polarity!r}")
86
+ id2loop = {lp.id: lp for lp in self.loops}
87
+ for e in self.edges:
88
+ for lid in e.loops:
89
+ if lid not in id2loop:
90
+ problems.append(f"edge {e.a}->{e.b} references undeclared loop {lid}")
91
+ for lp in self.loops:
92
+ for v in lp.nodes:
93
+ if v not in idset:
94
+ problems.append(f"loop {lp.id} references undeclared node {v}")
95
+ for n in self.nodes:
96
+ if n.group and n.group not in {g.id for g in self.groups}:
97
+ problems.append(f"node {n.id} references undeclared group {n.group}")
98
+ return problems
99
+
100
+ def to_dict(self) -> dict:
101
+ return {
102
+ "name": self.name,
103
+ "nodes": [
104
+ {"id": n.id, "label": n.label, "value": n.value,
105
+ "group": n.group, "extra_w": n.extra_w}
106
+ for n in self.nodes
107
+ ],
108
+ "edges": [
109
+ {"a": e.a, "b": e.b, "polarity": e.polarity, "loops": e.loops}
110
+ for e in self.edges
111
+ ],
112
+ "loops": [
113
+ {"id": lp.id, "label": lp.label, "nodes": lp.nodes, "kind": lp.kind}
114
+ for lp in self.loops
115
+ ],
116
+ "groups": [{"id": g.id, "label": g.label} for g in self.groups],
117
+ }
118
+
119
+ @classmethod
120
+ def from_dict(cls, d: dict) -> "Graph":
121
+ return cls(
122
+ name=d.get("name", ""),
123
+ nodes=[Node(**{k: v for k, v in nd.items()
124
+ if k in Node.__dataclass_fields__})
125
+ for nd in d.get("nodes", [])],
126
+ edges=[Edge(a=e["a"], b=e["b"], polarity=e.get("polarity", "+"),
127
+ loops=list(e.get("loops", [])))
128
+ for e in d.get("edges", [])],
129
+ loops=[Loop(id=lp["id"], label=lp.get("label", ""),
130
+ nodes=list(lp.get("nodes", [])), kind=lp.get("kind", ""))
131
+ for lp in d.get("loops", [])],
132
+ groups=[Group(id=g["id"], label=g.get("label", ""))
133
+ for g in d.get("groups", [])],
134
+ )
stdraw/placement.py ADDED
@@ -0,0 +1,220 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Slot-discretized placement layer (WS2 item 6): structure-detection-driven regular slots.
3
+
4
+ Design (same source as docs/crossing-exact.md §6):
5
+
6
+ - **Structure-driven slot shapes**:
7
+ cycle graph -> regular k-gon slots (angles evenly split as i*2pi/k;
8
+ cycle-order assignment puts all ring edges on the convex hull
9
+ -> crossings = 0 by construction; regular-polygon deviation = 0
10
+ by construction) — constructive, no solver needed;
11
+ chain graph -> collinear equidistant slots (straightness = 1 by
12
+ construction; geometric overlap of adjacent edges is resolved
13
+ downstream by the routing layer with arcs; the center-caliber
14
+ crossing/regularity metrics are unaffected) — constructive;
15
+ general -> layered row slots + CP-SAT (the only branch that needs a solver).
16
+ - **CP-SAT branch hard constraints** (violation = infeasible):
17
+ 1) in-row card gap >= min_gap (headroom over the >=16px gate);
18
+ 2) boxes must not press lines (with 1px headroom): the interpolated x of a
19
+ cross-row edge at intermediate rows stays >= CLEARANCE from unrelated
20
+ box edges (exact linearization for t in {1/2,1/3,2/3,3/4,1/4},
21
+ conservative two-sided constraints for the other interpolation factors);
22
+ 3) inside the viewBox: slot-domain bounding.
23
+ - **CP-SAT objective**: min 60*sum(crossings) + sum(|dx|) (crossings primary,
24
+ in-row evenness secondary — isomorphic to the auto-v2 placement objective).
25
+ - **Determinism**: num_workers=1 + seed 42; no wall-clock limit (a wall-clock
26
+ limit drifts with load).
27
+ - **Delivery discipline**: after coordinates are returned the caller re-runs the
28
+ metrics/check hard gates; any violation
29
+ falls back to the auto-v2 constructive path — this layer never delivers
30
+ while injured.
31
+ - **Honest boundary**: "regularity-optimal" for the cycle/chain branches is
32
+ relative to the slot-model class
33
+ (regular-polygon slots + the declared cycle-order assignment), not a proof
34
+ of optimality over all permutations; the crossing objective of the general
35
+ branch is the conservative row-slot caliber (close proximity not counted),
36
+ and only solving to OPTIMAL makes it optimal for that caliber.
37
+ """
38
+ import math
39
+
40
+ SLOT = 24
41
+ CLEARANCE = 16 + 1 # 16px hard gate + 1px headroom
42
+
43
+
44
+ def has_solver():
45
+ try:
46
+ from ortools.sat.python import cp_model # noqa: F401
47
+ return True
48
+ except Exception:
49
+ return False
50
+
51
+
52
+ def place(graph, widths, rows=None, card_h=44, pitch=80, top=92, margin=44,
53
+ min_gap=18, target_w=1280):
54
+ """Slot placement entry point. Returns {x, y, viewBox, structure, engine} or None (fallback).
55
+
56
+ graph: stdraw.model.Graph; widths: {v: card width}; rows: layered rows (used by general).
57
+ """
58
+ from . import regularity
59
+ nodes = graph.node_ids()
60
+ edges = [(e.a, e.b) for e in graph.edges]
61
+ st = regularity.detect_structure(graph)
62
+ n = len(nodes)
63
+
64
+ if st["kind"] == "cycle" and len(st["cycle_nodes"]) == 1 \
65
+ and n == len(st["cycle_nodes"][0]) and n >= 3:
66
+ return _place_cycle(st["cycle_nodes"][0], target_w)
67
+ if st["kind"] == "chain" and len(st["chain_nodes"]) == 1 \
68
+ and n == len(st["chain_nodes"][0]) and n >= 2:
69
+ return _place_chain(st["chain_nodes"][0], widths, margin)
70
+ if has_solver():
71
+ return _place_layered(graph, nodes, edges, widths, rows, pitch,
72
+ top, margin, min_gap, target_w)
73
+ return None
74
+
75
+
76
+ def _place_cycle(cyc, target_w):
77
+ """Regular k-gon slots (angles evenly split, cycle-order assignment).
78
+ Crossings 0 and deviation 0 hold by construction.
79
+
80
+ Quantization uses a 2px grid (the delivered-coordinate caliber): coarse
81
+ 24px quantization visibly distorts the regular polygon and inflates
82
+ radial/angular CV — regularity metrics run on delivered coordinates, so
83
+ the quantization must be fine enough."""
84
+ k = len(cyc)
85
+ radius = max(150, min(420, k * 34))
86
+ cx = max(k * 48, target_w) // 2
87
+ cy = radius + 90
88
+ x, y = {}, {}
89
+ for i, v in enumerate(cyc):
90
+ ang = 2 * math.pi * i / k - math.pi / 2
91
+ x[v] = int(round((cx + radius * math.cos(ang)) / 2) * 2)
92
+ y[v] = int(round((cy + radius * math.sin(ang)) / 2) * 2)
93
+ allv = list(x.values()) + list(y.values())
94
+ W = max(allv) + 48
95
+ H = max(y.values()) + 48
96
+ return {"x": x, "y": y, "viewBox": [0, 0, W, H],
97
+ "structure": "cycle", "engine": "construct-regular-polygon"}
98
+
99
+
100
+ def _place_chain(chain, widths, margin):
101
+ """Collinear equidistant slots (declared chain order). Straightness 1 holds by construction."""
102
+ cur = max(margin, SLOT * 2)
103
+ xs, ys = {}, {}
104
+ for v in chain:
105
+ xs[v] = cur
106
+ ys[v] = 120
107
+ cur += max(SLOT * 4, int((widths.get(v, 96) + 48) / SLOT) * SLOT)
108
+ return {"x": xs, "y": ys, "viewBox": [0, 0, cur + 48, 240],
109
+ "structure": "chain", "engine": "construct-collinear"}
110
+
111
+
112
+ def _place_layered(graph, nodes, edges, widths, rows, pitch, top, margin,
113
+ min_gap, target_w):
114
+ """Layered row slots + CP-SAT (the only branch that needs a solver)."""
115
+ if not rows:
116
+ return None
117
+ from ortools.sat.python import cp_model
118
+ row_of = {v: r for r, vs in enumerate(rows) for v in vs}
119
+ n_slots = int(target_w // SLOT) + 1
120
+ m = cp_model.CpModel()
121
+ xs = {v: m.NewIntVar(margin // SLOT, n_slots - 1, f"sx_{v}") for v in nodes}
122
+
123
+ # Hard constraint 1: in-row card gap (slot scale)
124
+ for vs in rows:
125
+ for i in range(len(vs)):
126
+ for j in range(i + 1, len(vs)):
127
+ u, w = vs[i], vs[j]
128
+ need = int(math.ceil((widths[u] + widths[w]) / 2.0 + min_gap)
129
+ / SLOT) + 1
130
+ d = m.NewIntVar(0, n_slots, "")
131
+ m.AddAbsEquality(d, xs[u] - xs[w])
132
+ m.Add(d >= need)
133
+
134
+ # Hard constraint 2: cross-row edges must not press unrelated boxes (with 1px headroom)
135
+ for (a, b) in edges:
136
+ ra, rb = row_of[a], row_of[b]
137
+ if ra == rb:
138
+ continue
139
+ lo, hi = min(ra, rb), max(ra, rb)
140
+ for v in nodes:
141
+ rv = row_of[v]
142
+ if rv <= lo or rv >= hi:
143
+ continue
144
+ t = (rv - ra) / (rb - ra)
145
+ xv = xs[v]
146
+ denom = None
147
+ for q in (2, 3, 4):
148
+ if abs(t * q - round(t * q)) < 1e-9:
149
+ denom, p_ = q, round(t * q)
150
+ break
151
+ slack = int(math.ceil(CLEARANCE / SLOT)) + 1
152
+ if denom:
153
+ # x_at = xa + (xb - xa)*p_/denom; multiply both sides of the constraint by denom to integerize
154
+ left = m.NewBoolVar("")
155
+ right = m.NewBoolVar("")
156
+ expr = denom * xs[a] + p_ * (xs[b] - xs[a])
157
+ m.Add(expr <= denom * (xv - slack)).OnlyEnforceIf(left)
158
+ m.Add(expr >= denom * (xv + slack)).OnlyEnforceIf(right)
159
+ m.AddBoolOr([left, right])
160
+ else:
161
+ left_a = m.NewBoolVar("")
162
+ right_a = m.NewBoolVar("")
163
+ m.Add(xs[a] <= xv - slack).OnlyEnforceIf(left_a)
164
+ m.Add(xs[a] >= xv + slack).OnlyEnforceIf(right_a)
165
+ m.AddBoolOr([left_a, right_a])
166
+ left_b = m.NewBoolVar("")
167
+ right_b = m.NewBoolVar("")
168
+ m.Add(xs[b] <= xv - slack).OnlyEnforceIf(left_b)
169
+ m.Add(xs[b] >= xv + slack).OnlyEnforceIf(right_b)
170
+ m.AddBoolOr([left_b, right_b])
171
+
172
+ # Objective: crossings (row-interval interleaving + reversed endpoint relative order, conservative, close proximity not counted) + sum(|dx|)
173
+ einfo = []
174
+ for (a, b) in edges:
175
+ ra, rb = row_of[a], row_of[b]
176
+ einfo.append((a, b, min(ra, rb), max(ra, rb)))
177
+ cross_terms = []
178
+ for i in range(len(einfo)):
179
+ a1, b1, l1, h1 = einfo[i]
180
+ for j in range(i + 1, len(einfo)):
181
+ a2, b2, l2, h2 = einfo[j]
182
+ if {a1, b1} & {a2, b2}:
183
+ continue
184
+ if min(h1, h2) - max(l1, l2) < 1:
185
+ continue
186
+ top_a = a1 if row_of[a1] < row_of[b1] else b1
187
+ bot_a = b1 if row_of[a1] < row_of[b1] else a1
188
+ top_b = a2 if row_of[a2] < row_of[b2] else b2
189
+ bot_b = b2 if row_of[a2] < row_of[b2] else a2
190
+ if row_of[top_a] == row_of[top_b] or row_of[bot_a] == row_of[bot_b]:
191
+ continue
192
+ p = m.NewBoolVar("")
193
+ q = m.NewBoolVar("")
194
+ m.Add(xs[top_a] <= xs[top_b] - 1).OnlyEnforceIf(p)
195
+ m.Add(xs[top_a] >= xs[top_b]).OnlyEnforceIf(p.Not())
196
+ m.Add(xs[bot_a] <= xs[bot_b] - 1).OnlyEnforceIf(q)
197
+ m.Add(xs[bot_a] >= xs[bot_b]).OnlyEnforceIf(q.Not())
198
+ c = m.NewBoolVar("")
199
+ m.Add(p + q == 1).OnlyEnforceIf(c)
200
+ m.Add(p + q != 1).OnlyEnforceIf(c.Not())
201
+ cross_terms.append(c)
202
+ lens = []
203
+ for (a, b) in edges:
204
+ d = m.NewIntVar(0, n_slots, "")
205
+ m.AddAbsEquality(d, xs[a] - xs[b])
206
+ lens.append(d)
207
+ m.Minimize(60 * sum(cross_terms) + sum(lens))
208
+ solver = cp_model.CpSolver()
209
+ solver.parameters.num_workers = 1 # determinism iron rule
210
+ solver.parameters.random_seed = 42 # no wall-clock limit: time limits drift with load
211
+ st = solver.Solve(m)
212
+ if st not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
213
+ return None
214
+ x = {v: int(solver.Value(xs[v])) * SLOT for v in nodes}
215
+ y = {v: top + row_of[v] * pitch for v in nodes}
216
+ W = max(x.values()) + max(widths.values()) // 2 + 48
217
+ H = top + (max(row_of.values()) + 1) * pitch + 48
218
+ return {"x": x, "y": y, "viewBox": [0, 0, W, H],
219
+ "structure": "layered", "engine": "cp-sat",
220
+ "status": solver.StatusName(st)}