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/anchors.py ADDED
@@ -0,0 +1,103 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Standard per-box anchor set (card t_2c2496ca): 16 discrete connection
3
+ points per box for the interactive layer (hover display / endpoint snapping /
4
+ explicit edge-to-anchor assignment).
5
+
6
+ Caliber (jacky 2026-11-08 verbal spec, 16-point symmetric formulation):
7
+
8
+ 4 edge midpoints + 4 corner points + 2 golden-section points per edge
9
+ (fractions 0.382 / 0.618) = 16 points.
10
+
11
+ Inset: corner points and golden points are NOT placed on the geometric
12
+ corner tips / raw border — all 16 points live on the border rectangle
13
+ inset by ``ANCHOR_INSET`` px on every side. The 3px inset reuses the
14
+ existing border-inner-offset caliber of the fan-slot clamp
15
+ (``routing.fan_spread`` clamps slots inside the border at +/- (half - 3)),
16
+ so the anchor set never touches the rounded frame corner (render rx=7)
17
+ or the exact geometric tip.
18
+
19
+ Coordinate convention: box center (cx, cy) + half extents (hw, hh).
20
+ Edge order and golden-point direction are fixed and deterministic
21
+ (clockwise, g1 = 0.382 from the edge's first endpoint in clockwise
22
+ direction, g2 = 0.618):
23
+
24
+ top (n): left -> right right (e): top -> bottom
25
+ bottom (s): right -> left left (w): bottom -> top
26
+
27
+ This module is a pure function library — nothing in the static layout /
28
+ routing pipeline imports it (the auto-routing anchor planning keeps its
29
+ own caliber unchanged; the 21-input sweep stays byte-identical). The
30
+ interactive JS layer mirrors the same formulas on live node geometry
31
+ (``_interactive_js.stdAnchors``); ``tests/test_standard_anchors.py``
32
+ pins the Python side and the probe cross-checks JS vs Python equality.
33
+ """
34
+ import math
35
+
36
+ ANCHOR_INSET = 3.0 # px, same -3 inner offset as the fan-slot clamp
37
+ GOLDEN_FRACS = (0.382, 0.618)
38
+
39
+ # Fixed deterministic order (probe hooks and export rely on the indices):
40
+ # 0..3 edge midpoints, 4..7 corners, 8..15 golden points.
41
+ ANCHOR_IDS = ("n", "e", "s", "w",
42
+ "ne", "se", "sw", "nw",
43
+ "n-g1", "n-g2", "e-g1", "e-g2",
44
+ "s-g1", "s-g2", "w-g1", "w-g2")
45
+
46
+
47
+ def _r6(v):
48
+ """Round to 1e-6 to keep JSON / probe comparisons stable."""
49
+ return round(v + 0.0, 6)
50
+
51
+
52
+ def standard_anchors(cx, cy, hw, hh, inset=ANCHOR_INSET):
53
+ """The 16 standard anchor points of one box.
54
+
55
+ Returns a list of ``[id, x, y]`` in the fixed ANCHOR_IDS order.
56
+ All points sit on the rectangle inset by ``inset`` px (half extents
57
+ ``hw - inset`` / ``hh - inset``); with the default inset the
58
+ midpoints are 3px inside the true border midpoint and the corners
59
+ sit on the inset rectangle's corner (never the geometric tip).
60
+ """
61
+ if hw <= inset or hh <= inset:
62
+ raise ValueError("box half extents must exceed the anchor inset")
63
+ a = hw - inset
64
+ b = hh - inset
65
+ g1, g2 = GOLDEN_FRACS
66
+ out = [
67
+ ["n", cx, cy - b],
68
+ ["e", cx + a, cy],
69
+ ["s", cx, cy + b],
70
+ ["w", cx - a, cy],
71
+ ["ne", cx + a, cy - b],
72
+ ["se", cx + a, cy + b],
73
+ ["sw", cx - a, cy + b],
74
+ ["nw", cx - a, cy - b],
75
+ # top edge, left -> right
76
+ ["n-g1", cx - a + 2 * a * g1, cy - b],
77
+ ["n-g2", cx - a + 2 * a * g2, cy - b],
78
+ # right edge, top -> bottom
79
+ ["e-g1", cx + a, cy - b + 2 * b * g1],
80
+ ["e-g2", cx + a, cy - b + 2 * b * g2],
81
+ # bottom edge, right -> left
82
+ ["s-g1", cx + a - 2 * a * g1, cy + b],
83
+ ["s-g2", cx + a - 2 * a * g2, cy + b],
84
+ # left edge, bottom -> top
85
+ ["w-g1", cx - a, cy + b - 2 * b * g1],
86
+ ["w-g2", cx - a, cy + b - 2 * b * g2],
87
+ ]
88
+ return [[p[0], _r6(p[1]), _r6(p[2])] for p in out]
89
+
90
+
91
+ def nearest_anchor(cx, cy, hw, hh, px, py, inset=ANCHOR_INSET):
92
+ """Nearest standard anchor of one box to an arbitrary point.
93
+
94
+ Returns ``[id, x, y]``. Deterministic tie-break: lower ANCHOR_IDS
95
+ index wins (fixed order = fixed winner).
96
+ """
97
+ pts = standard_anchors(cx, cy, hw, hh, inset)
98
+ best = None
99
+ for p in pts:
100
+ d = math.hypot(p[1] - px, p[2] - py)
101
+ if best is None or d < best[0] - 1e-9:
102
+ best = (d, p)
103
+ return list(best[1])
stdraw/bowtielayout.py ADDED
@@ -0,0 +1,350 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Bowtie-vertex (two rings sharing a single node) symmetric placement
3
+ (2026-11-06 jacky aesthetics v3, t_7fe64f08).
4
+
5
+ > jacky hand-laid the wage-price-spiral diagram: the shared node sits at the
6
+ > exact center of the figure, the remaining nodes of the two rings occupy
7
+ > four symmetric positions, and the whole figure is axis-symmetric up/down
8
+ > and left/right — "topology should decide the layout".
9
+
10
+ Trigger detection (deterministic hard-coded rules; mutually exclusive with
11
+ symlayout's shared-**edge** bowtie topology):
12
+ - Enumerate undirected simple rings (length >= 3, deduplicated by
13
+ canonical form; declared loops are **not** used — the declaration table
14
+ mixes in 2-node pseudo-rings and misses derived rings); exactly **2**
15
+ rings, the two ring node sets share exactly **1** node, and the union of
16
+ the two rings = all non-isolated nodes (no trigger when dangling nodes
17
+ exist — conservative fallback).
18
+ - Corpus hits: {eroding-goals, shifting-the-burden, wage-price-spiral}
19
+ (a directed-isomorphism family). No false positives: 08/10/17 also have
20
+ exactly 2 simple rings but share 2 nodes (shared-edge type, outside the
21
+ decision domain of symlayout's criteria, handled by existing patterns).
22
+
23
+ Placement (place_bowtie_vertex, cross-checked against the relative geometry
24
+ of jacky's hand-laid sample):
25
+ - The shared node sits at the canvas center (layout-layer axis x=0, y=0;
26
+ the delivery layer translates everything uniformly to center);
27
+ - Each ring occupies one half-plane (first ring above / second ring below;
28
+ ring order broken deterministically by kind R<B<other -> canonical
29
+ order); the remaining nodes of each ring sit on a horizontal row in the
30
+ same half-plane at mirrored left/right positions (triangle = +-D at
31
+ equal height, outer edge a horizontal straight line — matching the
32
+ sample's "top edge nearly horizontal");
33
+ - Left/right assignment = walk the ring from the shared node in the
34
+ "directed-edge-following" direction; the successor goes left, the next
35
+ one right (measured on the sample: wages left / cost right,
36
+ real_income left / demand right, same rule for both rings);
37
+ - Both rings share D and H -> the whole figure is strictly mirrored
38
+ up/down (an idealization of the sample's vertical mirror axis x~=293,
39
+ deviation 0px <= the 2px acceptance gate).
40
+
41
+ Routing (constructively injected by place_bowtie_vertex, not via the
42
+ candidate pool):
43
+ - All straight lines (line-style consistency hard rule: all 6 edges are
44
+ lines, no mixing of straight/arc/bend);
45
+ - Outer edges (ring edges that do not pass through the shared node) =
46
+ horizontal straight lines; the 4 spokes = straight lines;
47
+ - The two spoke anchors on the same side of the shared node spread
48
+ symmetrically +-SPREAD about the edge midpoint (multiple outgoing lines
49
+ of one node must not stack at a single point — 2026-11-06
50
+ connection-point convention hard rule 3).
51
+
52
+ Trial gate: reuses layout_auto._constructed_gates (box press / clearance /
53
+ anchor smoothness / card gap / inter-loop separation / crossings <= 2); any
54
+ failure silently falls back to the existing pattern chain (negative cases
55
+ byte-for-byte unchanged).
56
+ """
57
+ import math
58
+
59
+ from . import columnlayout
60
+ from .routing import border_point, seg_cross
61
+
62
+ SYM_GAP = 36 # breathing-room target clearance (same tier as SYM_GAP/SIDE_GAP)
63
+ MAX_BOWTIE_N = 12 # trigger upper bound: small graphs
64
+
65
+
66
+ class InfeasibleBowtieVertex(Exception):
67
+ pass
68
+
69
+
70
+ def _adjacency(edges):
71
+ adj = {v: set() for a, b in edges for v in (a, b)}
72
+ for a, b in edges:
73
+ adj[a].add(b)
74
+ adj[b].add(a)
75
+ return adj
76
+
77
+
78
+ def _canon(seq):
79
+ best = None
80
+ for s in (list(seq), list(seq)[::-1]):
81
+ for i in range(len(s)):
82
+ rot = s[i:] + s[:i]
83
+ if best is None or rot < best:
84
+ best = rot
85
+ return tuple(best)
86
+
87
+
88
+ def detect_bowtie_vertex(graph, why=None):
89
+ """Bowtie-vertex detection. Returns a plan dict or None.
90
+
91
+ plan = {"shared": shared node, "loops": [(kind, [c1..ck]), ...] (upper
92
+ ring first), "deg0": [isolated nodes]}
93
+
94
+ why: optional dict — receives a deterministic one-line reason for the
95
+ outcome (mode decision trace, certificate mode_decision_trace field).
96
+ """
97
+ nodes = list(graph.node_ids())
98
+ edges = [(e.a, e.b) for e in graph.edges]
99
+ adj = _adjacency(edges)
100
+ core = [v for v in nodes if adj[v]]
101
+ deg0 = [v for v in nodes if not adj[v]]
102
+ if len(core) < 5 or len(core) > MAX_BOWTIE_N:
103
+ if why is not None:
104
+ why["reason"] = (f"core n={len(core)} "
105
+ f"(need 5..{MAX_BOWTIE_N})")
106
+ return None
107
+ cycles = columnlayout._enumerate_cycles(core, adj)
108
+ if len(cycles) != 2:
109
+ if why is not None:
110
+ why["reason"] = (f"{len(cycles)} simple loops enumerated "
111
+ "(need exactly 2)")
112
+ return None
113
+ s0, s1 = set(cycles[0]), set(cycles[1])
114
+ shared_set = s0 & s1
115
+ if len(shared_set) != 1:
116
+ if why is not None:
117
+ why["reason"] = (f"the 2 simple loops share {len(shared_set)} "
118
+ "nodes (need exactly 1 — shared-2 is the "
119
+ "shared-edge bowtie topology)")
120
+ return None
121
+ if s0 | s1 != set(core):
122
+ if why is not None:
123
+ why["reason"] = ("dangling core nodes outside the two rings — "
124
+ "conservatively no trigger")
125
+ return None
126
+ if why is not None:
127
+ why["reason"] = ("exactly 2 simple loops sharing exactly 1 node "
128
+ f"(shared={sorted(shared_set)[0]}), rings cover all "
129
+ f"{len(core)} core nodes")
130
+ shared = sorted(shared_set)[0]
131
+ eset = set(edges)
132
+
133
+ def _oriented(seq):
134
+ """Ring order -> orientation starting at shared with more directed-edge follows; ties take the smaller canonical form."""
135
+ n = len(seq)
136
+ i = seq.index(shared)
137
+
138
+ def follows(o):
139
+ return sum(1 for k in range(n)
140
+ if (o[k], o[(k + 1) % n]) in eset)
141
+ o1 = [seq[(i + k) % n] for k in range(n)]
142
+ o2 = [seq[(i - k) % n] for k in range(n)]
143
+ c1, c2 = follows(o1), follows(o2)
144
+ if c1 != c2:
145
+ return o1 if c1 > c2 else o2
146
+ return o1 if _canon(o1) <= _canon(o2) else o2
147
+
148
+ kind_rank = {"R": 0, "B": 1}
149
+ entries = []
150
+ for seq in cycles:
151
+ kind = None
152
+ for lp in graph.loops:
153
+ if set(lp.nodes) == set(seq):
154
+ kind = getattr(lp, "kind", None)
155
+ break
156
+ entries.append((kind_rank.get(kind, 2), _canon(seq), kind,
157
+ _oriented(seq)[1:]))
158
+ entries.sort(key=lambda t: (t[0], t[1]))
159
+ loops = [(kind, rest) for _r, _c, kind, rest in entries]
160
+ return {"shared": shared, "loops": loops, "deg0": deg0}
161
+
162
+
163
+ def place_bowtie_vertex(plan, widths, card_h, cfg, edges):
164
+ """Bowtie-vertex symmetric placement -> ({v: x}, {v: y}, meta, constructed_routes).
165
+
166
+ constructed_routes: {(a,b): route dict} — all-straight construction
167
+ (horizontal outer edges + direct spokes, shared-side anchors spread
168
+ symmetrically at +-SPREAD), constructively injected.
169
+ """
170
+ shared = plan["shared"]
171
+ loops = plan["loops"]
172
+ ws = widths[shared]
173
+ # D: distance of the left/right vertices from the axis — sum of the two cards' half-widths + clearance (max over rings)
174
+ D = max((widths[seq[0]] + widths[seq[-1]]) / 2 + SYM_GAP
175
+ for _k, seq in loops)
176
+ D = int(round(D / 2) * 2)
177
+ # H: half-distance between ring row and shared row — breathing clearance
178
+ # >= SYM_GAP and >= D (near-square; sample radius ~196 / x ~135-150 / y ~123-145)
179
+ H = max(2 * card_h + SYM_GAP, D)
180
+ H = int(round(H / 2) * 2)
181
+
182
+ pos = {shared: 0}
183
+ y = {shared: 0}
184
+ for idx, (_kind, seq) in enumerate(loops):
185
+ s = -1 if idx == 0 else 1 # first ring upper half-plane, second lower
186
+ k = len(seq)
187
+ for j, v in enumerate(seq):
188
+ if k > 1:
189
+ x = -D + (2.0 * D) * j / (k - 1)
190
+ else:
191
+ x = 0.0
192
+ pos[v] = int(round(x / 2) * 2)
193
+ y[v] = s * H
194
+
195
+ if plan["deg0"]:
196
+ total = sum(widths[v] for v in plan["deg0"]) + 18 * (len(plan["deg0"]) + 1)
197
+ cur = -total / 2
198
+ for v in plan["deg0"]:
199
+ pos[v] = int(round((cur + widths[v] / 2) / 2) * 2)
200
+ y[v] = cfg.park_y
201
+ cur += widths[v] + 18
202
+
203
+ def _np(v):
204
+ return (pos[v], y[v], widths[v], card_h)
205
+
206
+ constructed = {}
207
+ eset = set(edges)
208
+ # Shared-node spokes: anchor = the shared card's side midpoint +-SPREAD (symmetric spread for the two rings; one
209
+ # node's multiple outgoing lines never stack — 2026-11-06 connection-point convention hard rule 3). Measured on
210
+ # the bowtie family: all 4 spokes are inter-loop pairs (bidirectional) — one line per pair would make both
211
+ # directions coincide point-for-point and necessarily FAIL the inter-loop separation gate. Inter-loop corridor:
212
+ # one parallel line per direction, anchors offset ±PAIR_T along the box edge tangent (U line = leftward travel,
213
+ # L line = reverse; same rule for both rings -> mirror-consistent), total side spread <= hh-3 (clamped inside
214
+ PAIR_T = 12
215
+ spoke_dirs = {} # endv -> [(a,b), ...] directed edges on that spoke
216
+ for _kind, seq in loops:
217
+ for endv in (seq[0], seq[-1]):
218
+ spoke_dirs[endv] = [e for e in ((shared, endv), (endv, shared))
219
+ if e in eset]
220
+ side_mutual = {side: any(len(spoke_dirs.get(v, [])) == 2
221
+ for _k, seq in loops for v in (seq[0], seq[-1])
222
+ if (1 if pos[v] > 0 else -1) == side)
223
+ for side in (-1, 1)}
224
+ spoke_anchor = {}
225
+ for endv, dirs in spoke_dirs.items():
226
+ side = -1 if pos[endv] < 0 else 1
227
+ s = -1 if any(endv in seq for _k, seq in loops[:1]) else 1
228
+ spread = 7 if side_mutual[side] else 14
229
+ spoke_anchor[endv] = [side * ws / 2, s * spread]
230
+ # t_0dedbfab: on a side hosting BOTH rings' mutual pairs, the per-ring
231
+ # slot pairs interleave when spread < PAIR_T (upper pair = {-spread-12,
232
+ # -spread+12}, lower pair = {spread-12, spread+12}; for spread 7 the
233
+ # merged slot order is -19, -5, +5, +19 while the per-ring assignment
234
+ # put the upper ring's incoming line at +5 BELOW the lower ring's
235
+ # outgoing line at -5 -- an incoming line landing below an outgoing line
236
+ # that travels into the same half-plane crosses it mid-air at the shared
237
+ # border; measured on shifting-the-burden problem box, visual_crossings
238
+ # 1 -> 0 after this fix). Assign the merged slot offsets sorted ascending
239
+ # in (upper-ring U, upper-ring L, lower-ring U, lower-ring L) order; for
240
+ # spread >= PAIR_T sides the sorted merge reproduces the per-ring
241
+ # assignment byte-for-byte (the pairs do not interleave), so non-mutual
242
+ # bowtie figures keep their delivered geometry (eroding-goals /
243
+ # wage-price-spiral sweep-verified byte-identical).
244
+ pair_slots = {}
245
+ _pside_endvs = {}
246
+ for endv, dirs in spoke_dirs.items():
247
+ if len(dirs) == 2:
248
+ _side = -1 if pos[endv] < 0 else 1
249
+ _pside_endvs.setdefault(_side, []).append(endv)
250
+ for _side, _endvs in _pside_endvs.items():
251
+ if len(_endvs) != 2:
252
+ continue
253
+ _spread = 7 if side_mutual[_side] else 14
254
+ _offs = sorted([-_spread - PAIR_T, -_spread + PAIR_T,
255
+ _spread - PAIR_T, _spread + PAIR_T])
256
+ _up = (_endvs[0] if any(_endvs[0] in seq for _k, seq in loops[:1])
257
+ else _endvs[1])
258
+ _low = _endvs[1] if _up == _endvs[0] else _endvs[0]
259
+ pair_slots[_up] = (_offs[0], _offs[1])
260
+ pair_slots[_low] = (_offs[2], _offs[3])
261
+ hh3 = card_h / 2 - 3
262
+ for endv, dirs in spoke_dirs.items():
263
+ if not dirs:
264
+ continue
265
+ nb = _np(endv)
266
+ base_sa = spoke_anchor[endv]
267
+ base_cb = border_point(nb[0], nb[1], nb[2] / 2, nb[3] / 2,
268
+ base_sa[0] - nb[0], base_sa[1] - nb[1])
269
+ on_x = abs(abs(base_cb[0] - nb[0]) - nb[2] / 2) <= \
270
+ abs(abs(base_cb[1] - nb[1]) - nb[3] / 2)
271
+ if len(dirs) == 2:
272
+ # Inter-loop corridor: U line (-t) = leftward travel direction; L line (+t) = reverse. Card-side anchor =
273
+ # the corridor center anchor truly spread ±PAIR_T along the owning box edge tangent (parallel separation
274
+ # >= 4px gate); when the card end is on a horizontal edge, pair "upper->near / lower->far" — the lower
275
+ # line starts lower and, if steeper, would flip above the upper line (crossing observed in practice);
276
+ # far-end pairing keeps the gap monotone; same rule for both rings -> up/down and left/right mirrored.
277
+ leftward = dirs[0] if pos[dirs[0][0]] > pos[dirs[0][1]] else dirs[1]
278
+ backward = dirs[1] if leftward == dirs[0] else dirs[0]
279
+ if endv in pair_slots:
280
+ _ou, _ol = pair_slots[endv]
281
+ else:
282
+ _ou, _ol = base_sa[1] - PAIR_T, base_sa[1] + PAIR_T
283
+ sa_u = [base_sa[0], max(-hh3, min(hh3, _ou))]
284
+ sa_l = [base_sa[0], max(-hh3, min(hh3, _ol))]
285
+ on_x = abs(abs(base_cb[0] - nb[0]) - nb[2] / 2) <= \
286
+ abs(abs(base_cb[1] - nb[1]) - nb[3] / 2)
287
+ if on_x:
288
+ half = nb[3] / 2 - 3
289
+ y_l = max(nb[1] - half, min(nb[1] + half, base_cb[1] - PAIR_T))
290
+ y_h = max(nb[1] - half, min(nb[1] + half, base_cb[1] + PAIR_T))
291
+ cb_u = [base_cb[0], y_l] # shifted ±t: always parallel, never crossing
292
+ cb_l = [base_cb[0], y_h]
293
+ else:
294
+ half = nb[2] / 2 - 3
295
+ x_a = max(nb[0] - half, min(nb[0] + half,
296
+ base_cb[0] - PAIR_T))
297
+ x_b = max(nb[0] - half, min(nb[0] + half,
298
+ base_cb[0] + PAIR_T))
299
+ # Anti-crossing pairing: upper/lower lines must keep their vertical order throughout — upper->near in
300
+ # the upper half-plane, upper->far in the lower (uniform deterministic "swap-if-intersecting" rule)
301
+ cb_a, cb_b = [x_a, base_cb[1]], [x_b, base_cb[1]]
302
+ if seg_cross(sa_u, cb_a, sa_l, cb_b):
303
+ cb_a, cb_b = cb_b, cb_a
304
+ cb_u, cb_l = cb_a, cb_b
305
+ constructed[leftward] = {
306
+ "kind": "line",
307
+ "points": ([sa_u, cb_u] if leftward[0] == shared
308
+ else [cb_u, sa_u])}
309
+ constructed[backward] = {
310
+ "kind": "line",
311
+ "points": ([sa_l, cb_l] if backward[0] == shared
312
+ else [cb_l, sa_l])}
313
+ else:
314
+ e = dirs[0]
315
+ a, b = e
316
+ pa = base_sa if a == shared else base_cb
317
+ pb = base_cb if a == shared else base_sa
318
+ constructed[e] = {"kind": "line", "points": [pa, pb]}
319
+ # Outer edges (ring edges not passing through the shared node): horizontal straight line (k=2) or same-chord straight line (k>2)
320
+ for _kind, seq in loops:
321
+ for j in range(len(seq) - 1):
322
+ a, b = seq[j], seq[j + 1]
323
+ for e in ((a, b), (b, a)):
324
+ if e not in eset:
325
+ continue
326
+ na, nb = _np(a), _np(b)
327
+ pa = border_point(na[0], na[1], na[2] / 2, na[3] / 2,
328
+ nb[0] - na[0], nb[1] - na[1])
329
+ pb = border_point(nb[0], nb[1], nb[2] / 2, nb[3] / 2,
330
+ na[0] - nb[0], na[1] - nb[1])
331
+ constructed[e] = {"kind": "line", "points": [pa, pb]}
332
+ break
333
+ mirror_pairs = []
334
+ if len(loops) == 2 and len(loops[0][1]) == len(loops[1][1]):
335
+ mirror_pairs = [[loops[0][1][j], loops[1][1][j]]
336
+ for j in range(len(loops[0][1]))]
337
+ meta = {
338
+ "placement": "bowtie-vertex",
339
+ "symmetry": {
340
+ "axis": "vertical",
341
+ "axis_x": 0,
342
+ "axis_y": 0,
343
+ "shared_node": shared,
344
+ "loops": [[shared] + list(seq) for _k, seq in loops],
345
+ "mirror_pairs": mirror_pairs,
346
+ "gap": SYM_GAP,
347
+ "line_style_rule": "all-straight (horizontal outer edges + spokes)",
348
+ },
349
+ }
350
+ return pos, y, meta, constructed