bytesim 0.1.0__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.
Files changed (48) hide show
  1. bytesim/__init__.py +14 -0
  2. bytesim/__main__.py +3 -0
  3. bytesim/cli.py +45 -0
  4. bytesim/constants.py +48 -0
  5. bytesim/demos.py +74 -0
  6. bytesim/model/__init__.py +5 -0
  7. bytesim/model/addressing.py +160 -0
  8. bytesim/model/base.py +57 -0
  9. bytesim/model/cloning.py +249 -0
  10. bytesim/model/equivalence.py +86 -0
  11. bytesim/model/groups.py +133 -0
  12. bytesim/model/ha.py +93 -0
  13. bytesim/model/layer2.py +106 -0
  14. bytesim/model/layout.py +203 -0
  15. bytesim/model/naming.py +114 -0
  16. bytesim/model/network.py +138 -0
  17. bytesim/model/routing.py +117 -0
  18. bytesim/model/stp.py +315 -0
  19. bytesim/model/types.py +107 -0
  20. bytesim/py.typed +0 -0
  21. bytesim/scripting.py +108 -0
  22. bytesim/synthesis.py +644 -0
  23. bytesim/topology.py +517 -0
  24. bytesim/tui/__init__.py +4 -0
  25. bytesim/tui/app.py +161 -0
  26. bytesim/tui/cables.py +58 -0
  27. bytesim/tui/canvas.py +489 -0
  28. bytesim/tui/command_input.py +29 -0
  29. bytesim/tui/commands/__init__.py +1 -0
  30. bytesim/tui/commands/addressing.py +235 -0
  31. bytesim/tui/commands/base.py +43 -0
  32. bytesim/tui/commands/groups.py +215 -0
  33. bytesim/tui/commands/history.py +134 -0
  34. bytesim/tui/commands/interpreter.py +85 -0
  35. bytesim/tui/commands/naming.py +68 -0
  36. bytesim/tui/commands/registry.py +28 -0
  37. bytesim/tui/commands/scripting.py +54 -0
  38. bytesim/tui/commands/switching.py +192 -0
  39. bytesim/tui/commands/topology.py +255 -0
  40. bytesim/tui/commands/traffic.py +312 -0
  41. bytesim/tui/help.py +50 -0
  42. bytesim/tui/inspector.py +115 -0
  43. bytesim/tui/theme.py +12 -0
  44. bytesim-0.1.0.dist-info/METADATA +178 -0
  45. bytesim-0.1.0.dist-info/RECORD +48 -0
  46. bytesim-0.1.0.dist-info/WHEEL +4 -0
  47. bytesim-0.1.0.dist-info/entry_points.txt +2 -0
  48. bytesim-0.1.0.dist-info/licenses/LICENSE +21 -0
bytesim/synthesis.py ADDED
@@ -0,0 +1,644 @@
1
+ """Synthesize a ByteSim command script that rebuilds a network.
2
+
3
+ The script is written in ByteSim's own console language, so it can be replayed
4
+ with ``run <file>`` (after ``clear``). Every script is checked before it is
5
+ returned: it is replayed into a fresh network by the real command handlers
6
+ (``bytesim.scripting``) and compared with the original (``model.equivalence``).
7
+
8
+ How it works:
9
+
10
+ 1. **Explicit plan.** Devices are created in id order (``add s 5 3`` makes the
11
+ next three switches), with placeholders created and deleted to recreate gaps
12
+ in the numbering. Then groups (innermost first), HA clusters, interface
13
+ settings, every cable on its exact ports, STP and DHCP-server settings, and
14
+ finally failures. This always works, and it is the fallback.
15
+ 2. **Reuse.** ByteSim's ``clone`` is deterministic: the source's devices, sorted
16
+ by kind and number, get the next free ids in order. So a group B can be
17
+ cloned from an earlier group A exactly when that one mapping preserves
18
+ kinds, port counts, internal cables (with ports and roles), the nested group
19
+ tree, clusters and settings, and B's ids are consecutive. ``ports copy`` and
20
+ ``copy A(…) B(…)`` reproduce a group's cabling to other groups when it follows
21
+ another group's pattern, with groups matched by position.
22
+ 3. **Choice.** Candidates are tried best saving first; each is kept only if the
23
+ script with it still replays into an equivalent network and is shorter.
24
+ Search is bounded (``Limits``), so ``shortest`` means the shortest script
25
+ found within those limits, not a proven optimum.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import time
30
+ from collections import Counter
31
+ from dataclasses import dataclass, field
32
+ from itertools import combinations, permutations
33
+
34
+ from .constants import KINDS, RANK
35
+ from .model import NetError, Network
36
+ from .model.equivalence import DEFAULT_MODE, differences, iface_config
37
+
38
+ MODES = ("explicit", "readable", "shortest")
39
+ READABLE_MIN_SAVING = 3 # readable mode only reuses structure when it saves this many commands
40
+
41
+
42
+ @dataclass
43
+ class Limits:
44
+ max_candidates: int = 60 # candidate substitutions tried (each costs one replay)
45
+ max_seconds: float = 8.0 # total optimisation time
46
+ max_group_size: int = 300 # larger groups aren't considered for reuse
47
+
48
+
49
+ @dataclass
50
+ class Substitution:
51
+ kind: str # clone | copy | ports
52
+ src: str
53
+ dst: str
54
+ pairs: list[tuple[str, str]] = field(default_factory=list)
55
+ claims: Counter = field(default_factory=Counter) # (dst device, endpoint device, role) cables it makes
56
+ saves: int = 0
57
+
58
+ def command(self) -> str:
59
+ if self.kind == "clone":
60
+ return f"clone {self.src} {self.dst}"
61
+ old = ",".join(o for o, _ in self.pairs)
62
+ new = ",".join(n for _, n in self.pairs)
63
+ prefix = "ports copy" if self.kind == "ports" else "copy"
64
+ return f"{prefix} {self.src}({old}) {self.dst}({new})"
65
+
66
+ def describe(self) -> str:
67
+ if self.kind == "clone":
68
+ return f"{self.dst} has the same structure as {self.src}: clone it"
69
+ ends = ", ".join(f"{o}→{n}" for o, n in self.pairs)
70
+ if self.kind == "copy":
71
+ return f"{self.dst} is {self.src} again, cabled the same way with {ends}: copy it"
72
+ return f"{self.dst} is cabled like {self.src}, with {ends}"
73
+
74
+
75
+ @dataclass
76
+ class Synthesis:
77
+ script: str
78
+ mode: str
79
+ commands: int
80
+ explicit_commands: int
81
+ used: list[Substitution]
82
+ limitations: list[str]
83
+ tried: int = 0
84
+ rejected: int = 0
85
+
86
+
87
+ class _Infeasible(Exception):
88
+ pass
89
+
90
+
91
+ def _order(did: str) -> tuple[int, int]:
92
+ return RANK[did[0]], int(did[1:])
93
+
94
+
95
+ def _commands(lines: list[str]) -> int:
96
+ return sum(1 for ln in lines if ln and not ln.startswith("#"))
97
+
98
+
99
+ def _cost(lines: list[str]) -> tuple[int, int]:
100
+ body = [ln for ln in lines if ln and not ln.startswith("#")]
101
+ return len(body), sum(len(ln) for ln in body)
102
+
103
+
104
+ # ---------------------------------------------------------------------------- structure helpers
105
+ def _subtree(net: Network, g: str) -> list[str]:
106
+ out = [g]
107
+ for m in net.groups[g]:
108
+ if m in net.groups:
109
+ out += _subtree(net, m)
110
+ return out
111
+
112
+
113
+ def _postorder(net: Network, g: str) -> list[str]:
114
+ out: list[str] = []
115
+ for m in net.groups[g]:
116
+ if m in net.groups:
117
+ out += _postorder(net, m)
118
+ return out + [g]
119
+
120
+
121
+ def _clone_map(net: Network, a: str, b: str) -> dict[str, str] | None:
122
+ """The device mapping `clone a` would produce if b's ids were the ones allocated."""
123
+ da, db = net.devices_in(a), net.devices_in(b)
124
+ if len(da) != len(db) or set(da) & set(db):
125
+ return None
126
+ phi = {}
127
+ for k in KINDS:
128
+ xa = sorted((d for d in da if d[0] == k), key=_order)
129
+ xb = sorted((d for d in db if d[0] == k), key=_order)
130
+ if len(xa) != len(xb):
131
+ return None
132
+ nums = [int(d[1:]) for d in xb]
133
+ if nums and (nums != list(range(nums[0], nums[0] + len(nums))) or int(xa[-1][1:]) >= nums[0]):
134
+ return None # b's ids must be consecutive and come after a's
135
+ phi.update(zip(xa, xb))
136
+ return phi
137
+
138
+
139
+ def _clone_equivalent(net: Network, a: str, b: str, phi: dict[str, str]) -> bool:
140
+ for x, y in phi.items():
141
+ dx, dy = net.devices[x], net.devices[y]
142
+ if dx.nports != dy.nports:
143
+ return False
144
+ if (x in net.stp_off) != (y in net.stp_off) or net.stp_priority.get(x) != net.stp_priority.get(y):
145
+ return False
146
+ if (x in net.dhcp_off) != (y in net.dhcp_off):
147
+ return False
148
+ if dx.kind != "s":
149
+ for p in range(1, dx.nports + 1):
150
+ mode, _ = iface_config(net, x, p)
151
+ if mode == "static": # clone turns static into dhcp/auto, without addresses
152
+ mode = "dhcp" if dx.kind == "c" else "auto"
153
+ if iface_config(net, y, p) != (mode, None):
154
+ return False
155
+
156
+ def same_tree(ga: str, gb: str) -> bool:
157
+ ma, mb = net.groups[ga], net.groups[gb]
158
+ if len(ma) != len(mb) or net.clusters.get(ga) != net.clusters.get(gb):
159
+ return False
160
+ for x, y in zip(ma, mb):
161
+ if x in net.groups:
162
+ if y not in net.groups or not same_tree(x, y):
163
+ return False
164
+ elif phi.get(x) != y:
165
+ return False
166
+ return True
167
+
168
+ if not same_tree(a, b):
169
+ return False
170
+ inside_a, inside_b = set(phi), set(phi.values())
171
+ from .model.equivalence import link_key
172
+ want = Counter(link_key(phi[l.a], l.pa, phi[l.b], l.pb, l.role)
173
+ for l in net.links if l.a in inside_a and l.b in inside_a)
174
+ have = Counter(link_key(l.a, l.pa, l.b, l.pb, l.role)
175
+ for l in net.links if l.a in inside_b and l.b in inside_b)
176
+ return want == have
177
+
178
+
179
+ def _boundary(net: Network, devs: set[str]) -> list[tuple[str, str, str]]:
180
+ """(inside device, outside device, role) for every cable leaving a device set."""
181
+ out = []
182
+ for l in net.links:
183
+ if (l.a in devs) != (l.b in devs):
184
+ near, far = (l.a, l.b) if l.a in devs else (l.b, l.a)
185
+ out.append((near, far, l.role))
186
+ return out
187
+
188
+
189
+ def _endpoint_groups(net: Network, group: str, boundary: list[tuple[str, str, str]]) -> list[str]:
190
+ """Innermost groups of a boundary's far ends, in first-seen order (not ones containing the group)."""
191
+ around = set(net.chain(group)) | {group}
192
+ out: list[str] = []
193
+ for _, far, _ in boundary:
194
+ g = net.group_of(far)
195
+ if g and g not in around and g not in out:
196
+ out.append(g)
197
+ return out
198
+
199
+
200
+ def _pattern(net: Network, a: str, near_map: dict[str, str],
201
+ pairs: list[tuple[str, str]]) -> Counter | None:
202
+ """The cables (dst device, new endpoint device, role) a copy of a's boundary would make."""
203
+ ends: dict[str, str] = {}
204
+ for e, f in pairs:
205
+ de, df = net.devices_in(e), net.devices_in(f)
206
+ if len(de) != len(df):
207
+ return None
208
+ for x, y in zip(de, df):
209
+ if net.devices[x].kind != net.devices[y].kind or x in ends:
210
+ return None
211
+ ends[x] = y
212
+ out: Counter = Counter()
213
+ for near, far, role in _boundary(net, set(near_map)):
214
+ if far in ends:
215
+ out[(near_map[near], ends[far], role)] += 1
216
+ return out
217
+
218
+
219
+ def _best_rewire(net: Network, a: str, b: str, near_map: dict[str, str]) -> tuple[list, Counter] | None:
220
+ """Endpoint pairs under which b's outside cabling repeats a's (largest match wins)."""
221
+ ba, bb = _boundary(net, set(near_map)), _boundary(net, set(near_map.values()))
222
+ ea, fb = _endpoint_groups(net, a, ba), _endpoint_groups(net, b, bb)
223
+ if not ea or not fb or len(ea) > 4 or len(fb) > 4:
224
+ return None
225
+ have = Counter((near, far, role) for near, far, role in bb)
226
+ inside_b = set(near_map.values())
227
+ best = None
228
+ for r in range(min(len(ea), len(fb)), 0, -1):
229
+ for es in combinations(ea, r):
230
+ for fs in permutations(fb, r):
231
+ pairs = list(zip(es, fs))
232
+ if any(set(net.devices_in(f)) & inside_b for f in fs):
233
+ continue
234
+ pat = _pattern(net, a, near_map, pairs)
235
+ if not pat or pat - have:
236
+ continue
237
+ if best is None or sum(pat.values()) > sum(best[1].values()):
238
+ best = (pairs, pat)
239
+ if best:
240
+ return best
241
+ return None
242
+
243
+
244
+ def _explicit_cost(net: Network, g: str) -> int:
245
+ devs = net.devices_in(g)
246
+ inside = set(devs)
247
+ return (len({d[0] for d in devs}) + len(_subtree(net, g))
248
+ + sum(1 for l in net.links if l.a in inside and l.b in inside)
249
+ + sum(1 for d in devs if d in net.stp_priority or d in net.dhcp_off)
250
+ + sum(1 for x in _subtree(net, g) if x in net.clusters))
251
+
252
+
253
+ def candidates(net: Network, limits: Limits) -> list[Substitution]:
254
+ """Every reuse the network allows, best saving first (deterministic order)."""
255
+ out: list[Substitution] = []
256
+ groups = [g for g in net.groups if len(net.devices_in(g)) <= limits.max_group_size]
257
+ for b in groups:
258
+ for a in groups:
259
+ if a == b or a in _subtree(net, b) or b in _subtree(net, a):
260
+ continue
261
+ if set(net.devices_in(a)) & set(net.devices_in(b)):
262
+ continue
263
+ phi = _clone_map(net, a, b)
264
+ if phi is not None and _clone_equivalent(net, a, b, phi):
265
+ saved = _explicit_cost(net, b) - 1
266
+ out.append(Substitution("clone", a, b, saves=saved))
267
+ rw = _best_rewire(net, a, b, phi)
268
+ if rw:
269
+ out.append(Substitution("copy", a, b, rw[0], rw[1], saved + sum(rw[1].values())))
270
+ da, db = net.devices_in(a), net.devices_in(b)
271
+ if len(da) == len(db) and all(net.devices[x].kind == net.devices[y].kind for x, y in zip(da, db)):
272
+ rw = _best_rewire(net, a, b, dict(zip(da, db)))
273
+ if rw and sum(rw[1].values()) > 1:
274
+ out.append(Substitution("ports", a, b, rw[0], rw[1], sum(rw[1].values()) - 1))
275
+ rank = {"copy": 0, "clone": 1, "ports": 2}
276
+ out.sort(key=lambda s: (-s.saves, rank[s.kind], list(net.groups).index(s.dst), s.src))
277
+ return out
278
+
279
+
280
+ # ---------------------------------------------------------------------------- rendering
281
+ def render(net: Network, subs: list[Substitution], mode: str = "readable") -> tuple[list[str], list[str]]:
282
+ """The script (lines) for a plan, and the properties the DSL can't express.
283
+
284
+ Raises _Infeasible if the substitutions can't be ordered into a valid script."""
285
+ comments = mode != "shortest"
286
+ lines: list[str] = []
287
+ limitations: list[str] = []
288
+
289
+ def say(text: str) -> None:
290
+ if comments:
291
+ lines.append(f"# {text}")
292
+
293
+ def blank() -> None:
294
+ if comments and lines and lines[-1]:
295
+ lines.append("")
296
+
297
+ def drop_empty_heading() -> None:
298
+ """Remove a trailing heading (and blank line) that no command followed."""
299
+ while lines and (lines[-1].startswith("#") or not lines[-1]):
300
+ lines.pop()
301
+
302
+ clones = sorted((s for s in subs if s.kind in ("clone", "copy")),
303
+ key=lambda s: min(_order(d) for d in net.devices_in(s.dst)))
304
+ copies = [s for s in subs if s.kind == "ports"]
305
+ produced: dict[str, Substitution] = {d: s for s in clones for d in net.devices_in(s.dst)}
306
+ produced_groups = {g for s in clones for g in _subtree(net, s.dst)}
307
+
308
+ # Which cables the substitutions make; the rest are written out.
309
+ made: set[int] = set()
310
+ for i, l in enumerate(net.links):
311
+ sa, sb = produced.get(l.a), produced.get(l.b)
312
+ if sa is not None and sa is sb:
313
+ made.add(i) # inside a clone: copied with its ports
314
+ for s in [c for c in clones if c.kind == "copy"] + copies:
315
+ for (x, y, role), n in s.claims.items():
316
+ for i, l in enumerate(net.links):
317
+ if n and i not in made and l.role == role and {l.a, l.b} == {x, y}:
318
+ made.add(i)
319
+ n -= 1
320
+ if n:
321
+ raise _Infeasible(f"{s.command()} claims cables that aren't there")
322
+
323
+ present = {k: {d.num: d for d in net.devices.values() if d.kind == k} for k in KINDS}
324
+ top = {k: max([net.counters[k], *present[k]]) for k in KINDS}
325
+ nxt = {k: 1 for k in KINDS}
326
+ created: set[str] = set()
327
+ done_groups: set[str] = set(produced_groups)
328
+ done_links: set[int] = set(made)
329
+ configured: set[str] = set(produced) # clones copy settings
330
+
331
+ # ---- devices, in id order (gaps recreated with placeholders) ----------
332
+ def create(k: str, upto: int) -> None:
333
+ n = nxt[k]
334
+ while n <= upto:
335
+ if f"{k}{n}" in produced:
336
+ raise _Infeasible("a clone's ids would be taken by explicit devices")
337
+ m = n
338
+ if n in present[k]:
339
+ ports = present[k][n].nports
340
+ while (m + 1 <= upto and m + 1 in present[k] and present[k][m + 1].nports == ports
341
+ and m + 1 - n < 100 and f"{k}{m + 1}" not in produced):
342
+ m += 1
343
+ lines.append(f"add {k} {ports} {m - n + 1}")
344
+ created.update(f"{k}{i}" for i in range(n, m + 1))
345
+ else:
346
+ while m + 1 <= upto and m + 1 not in present[k] and m + 1 - n < 100:
347
+ m += 1
348
+ ids = [f"{k}{i}" for i in range(n, m + 1)]
349
+ say(f"{' '.join(ids)} {'was' if len(ids) == 1 else 'were'} deleted: "
350
+ f"create and remove placeholders so later ids match")
351
+ lines.append(f"add {k} 1 {len(ids)}")
352
+ lines.append("del " + " ".join(ids))
353
+ n = m + 1
354
+ nxt[k] = max(nxt[k], upto + 1)
355
+
356
+ # ---- per-device settings ------------------------------------------------
357
+ def ip_commands(d: str) -> list[str]:
358
+ dev = net.devices[d]
359
+ out = []
360
+ if dev.kind == "s":
361
+ return out
362
+ for p in range(1, dev.nports + 1):
363
+ mode_, static = iface_config(net, d, p)
364
+ if (mode_, static) == (DEFAULT_MODE[dev.kind], None):
365
+ continue
366
+ where = f"{d}:{p}"
367
+ if mode_ == "static" and static:
368
+ n_, h, gw = static
369
+ out.append(f"ip {where} ({n_},{h})" + (f" {gw}" if gw is not None and dev.kind == "c" else ""))
370
+ elif mode_ in ("none", "adhoc") and (mode_ == "none" or dev.kind == "c"):
371
+ out.append(f"ip {where} {mode_}")
372
+ elif mode_ == "auto" and dev.kind != "c":
373
+ continue
374
+ else:
375
+ limitations.append(f"interface {where} mode {mode_!r} can't be set from the console")
376
+ return out
377
+
378
+ def settings(devs: list[str], stp_now: bool) -> tuple[list[str], list[str]]:
379
+ """(commands, switches to turn STP off for) for devices not yet configured."""
380
+ cmds, off = [], []
381
+ for d in sorted(devs, key=_order):
382
+ if d in configured:
383
+ continue
384
+ configured.add(d)
385
+ cmds += ip_commands(d)
386
+ if d in net.stp_priority:
387
+ cmds.append(f"stp priority {d} {net.stp_priority[d]}")
388
+ if d in net.dhcp_off:
389
+ cmds.append(f"dhcpd {d} off")
390
+ if d in net.stp_off:
391
+ off.append(d)
392
+ if stp_now and off:
393
+ cmds.append(f"stp {' '.join(off)} off")
394
+ off = []
395
+ return cmds, off
396
+
397
+ def group_cmds(roots: list[str]) -> list[str]:
398
+ out = []
399
+ for root in roots:
400
+ for g in _postorder(net, root):
401
+ if g in done_groups:
402
+ continue
403
+ done_groups.add(g)
404
+ out.append(f"group {g} {' '.join(net.groups[g])}")
405
+ if g in net.clusters:
406
+ router = next((m for m in net.groups[g] if m in net.devices and net.is_router(m)), None)
407
+ if router:
408
+ out.append(f"cluster {g} {router} policy={net.clusters[g]}")
409
+ else:
410
+ limitations.append(f"cluster {g} has no router directly in it, so `cluster` "
411
+ f"can't mark it (policy {net.clusters[g]})")
412
+ return out
413
+
414
+ def cable(i: int) -> str:
415
+ l = net.links[i]
416
+ return f"{l.a}:{l.pa} {l.b}:{l.pb}" + (f" role={l.role}" if l.role != "data" else "")
417
+
418
+ def cables_for(devs: set[str], others: set[str] | None = None) -> list[str]:
419
+ out = []
420
+ for i, l in enumerate(net.links):
421
+ if i in done_links:
422
+ continue
423
+ inside = l.a in devs and l.b in devs
424
+ crossing = others is not None and ((l.a in devs and l.b in others) or (l.b in devs and l.a in others))
425
+ if inside or crossing:
426
+ done_links.add(i)
427
+ out.append(cable(i))
428
+ return out
429
+
430
+ # ---- the plan ------------------------------------------------------------
431
+ pending = list(clones)
432
+ phase = 0
433
+ while True:
434
+ limit = {k: min([min(int(d[1:]) for d in net.devices_in(c.dst) if d[0] == k)
435
+ for c in pending if any(d[0] == k for d in net.devices_in(c.dst))],
436
+ default=top[k] + 1) - 1 for k in KINDS}
437
+ before = len(lines)
438
+ if any(nxt[k] <= limit[k] for k in KINDS):
439
+ blank()
440
+ say("Devices: add <kind> <ports> <count> creates the next ids of that kind" if phase == 0
441
+ else "More devices, up to the next reused group's ids")
442
+ for k in KINDS:
443
+ create(k, limit[k])
444
+ phase += 1 if len(lines) > before else 0
445
+ if not pending:
446
+ break
447
+ c = pending.pop(0)
448
+ dst = net.devices_in(c.dst)
449
+ for k in KINDS:
450
+ ids = sorted((int(d[1:]) for d in dst if d[0] == k))
451
+ if ids and nxt[k] != ids[0]:
452
+ raise _Infeasible(f"{c.dst}'s {KINDS[k][0]} ids don't follow on")
453
+ src = net.devices_in(c.src)
454
+ if not set(src) <= created:
455
+ raise _Infeasible(f"{c.src} isn't built before {c.dst}")
456
+ # Finish the source (groups, internal cables, settings) and, for copy, its endpoints.
457
+ roots = [c.src] + [g for pair in c.pairs for g in pair]
458
+ need = {d for g in roots for d in net.devices_in(g)}
459
+ if not need <= created:
460
+ raise _Infeasible(f"endpoints of {c.command()} aren't built yet")
461
+ blank()
462
+ say(f"Finish {c.src}" + (" and the groups its copy connects to" if c.pairs else "")
463
+ + " so it can be reused")
464
+ mark = len(lines)
465
+ lines.extend(group_cmds(roots))
466
+ cmds, _ = settings(src, stp_now=True)
467
+ lines.extend(cmds)
468
+ ends = {d for _, f in c.pairs for d in net.devices_in(f)} | {d for e, _ in c.pairs for d in net.devices_in(e)}
469
+ lines.extend(cables_for(set(src), ends if c.kind == "copy" else None))
470
+ if len(lines) == mark:
471
+ drop_empty_heading()
472
+ blank()
473
+ say(c.describe())
474
+ lines.append(c.command())
475
+ created.update(dst)
476
+ for k in KINDS:
477
+ ids = [int(d[1:]) for d in dst if d[0] == k]
478
+ if ids:
479
+ nxt[k] = max(ids) + 1
480
+
481
+ # ---- everything else ------------------------------------------------------
482
+ roots = [g for g in net.groups if net.parent_of(g) is None]
483
+ gcmds = group_cmds(roots)
484
+ if gcmds:
485
+ blank()
486
+ say("Groups, innermost first (group <name> <members…>)")
487
+ lines.extend(gcmds)
488
+ cmds, off = settings([d for d in net.devices if d not in configured], stp_now=False)
489
+ ipc = [c for c in cmds if c.startswith("ip ")]
490
+ other = [c for c in cmds if not c.startswith("ip ")]
491
+ if ipc:
492
+ blank()
493
+ say("Interface settings (computers use DHCP and router ports set themselves up otherwise)")
494
+ lines.append("mode addr")
495
+ lines.extend(ipc)
496
+ rest = [i for i in range(len(net.links)) if i not in done_links]
497
+ if rest:
498
+ blank()
499
+ say("Cables, on their exact ports: <device>:<port> <device>:<port> [role=…]")
500
+ if mode == "readable": # by the top-level group they belong to
501
+ top_of = {d: (net.chain(d) or [None])[0] for d in net.devices}
502
+ order = [g for g in net.groups if net.parent_of(g) is None]
503
+ buckets: dict[str | None, list[int]] = {}
504
+ for i in rest:
505
+ l = net.links[i]
506
+ key = top_of[l.a] if top_of[l.a] == top_of[l.b] else None
507
+ buckets.setdefault(key, []).append(i)
508
+ for key in [g for g in order if g in buckets] + ([None] if None in buckets else []):
509
+ if len(buckets) > 1:
510
+ say(f"inside {key}" if key else "between groups")
511
+ lines.extend(cable(i) for i in buckets[key])
512
+ else:
513
+ lines.extend(cable(i) for i in rest)
514
+ done_links.update(rest)
515
+ for s in copies:
516
+ blank()
517
+ say(s.describe())
518
+ lines.append(s.command())
519
+ if other or off:
520
+ blank()
521
+ say("Switch and router settings")
522
+ lines.extend(other)
523
+ if off:
524
+ lines.append(f"stp {' '.join(off)} off")
525
+ down_links = [i for i, l in enumerate(net.links) if not l.up]
526
+ down_devs = [d for d in net.sorted_devices() if not d.up]
527
+ if down_links or down_devs:
528
+ blank()
529
+ say("Runtime state: failures")
530
+ lines.extend(f"fail {net.links[i].a}:{net.links[i].pa}" for i in down_links)
531
+ lines.extend(f"fail {d.id}" for d in down_devs)
532
+ return lines, sorted(set(limitations))
533
+
534
+
535
+ # ---------------------------------------------------------------------------- verification
536
+ def _ignore(limitations: list[str]) -> set[str]:
537
+ """Canonical keys the DSL can't express (masked out of the comparison, and reported)."""
538
+ out = set()
539
+ for text in limitations:
540
+ if text.startswith("cluster "):
541
+ out.add(f"clusters {text.split()[1]}")
542
+ elif text.startswith("interface "):
543
+ out.add(f"ifaces {text.split()[1]}")
544
+ return out
545
+
546
+
547
+ def verify(net: Network, script: str, limitations: list[str] | None = None) -> list[str]:
548
+ """Replay a script into a fresh network and compare. [] means equivalent."""
549
+ from .scripting import replay
550
+ try:
551
+ got = replay(script)
552
+ except NetError as e:
553
+ return [f"replay failed: {e}"]
554
+ masked = _ignore(limitations or [])
555
+ return [d for d in differences(net, got, limit=1000)
556
+ if not any(d.startswith(m + ":") for m in masked)][:8]
557
+
558
+
559
+ def _header(net: Network, mode: str, body: list[str], explicit: int, used: list[Substitution],
560
+ limitations: list[str]) -> list[str]:
561
+ n = _commands(body)
562
+ out = [f"# ByteSim construction script ({mode}) — {len(net.devices)} devices, "
563
+ f"{len(net.links)} cables, {len(net.groups)} groups in {n} commands"]
564
+ if mode != "shortest":
565
+ out += ["# Replay it into an empty network: `clear`, then `run <this file>`.",
566
+ "# Rebuilds: device ids and port counts, groups, cables (exact ports) and roles,",
567
+ "# HA clusters, STP / DHCP-server / interface settings, failures.",
568
+ "# Not rebuilt: positions (auto-arranged), learned addresses (re-learned),",
569
+ "# MAC tables, storms."]
570
+ if used:
571
+ out.append(f"# Reuses structure {len(used)} time{'s' if len(used) != 1 else ''}: "
572
+ f"{explicit} commands without it.")
573
+ for text in limitations:
574
+ out.append(f"# NOT EXPRESSIBLE: {text}")
575
+ out.append("# Verified: replaying this script rebuilds an equivalent network.")
576
+ return out
577
+
578
+
579
+ # ---------------------------------------------------------------------------- the entry point
580
+ def synthesize(net: Network, mode: str = "readable", limits: Limits | None = None) -> Synthesis:
581
+ """A verified construction script for ``net``. Raises NetError if none can be made."""
582
+ if mode not in MODES:
583
+ raise NetError(f"mode must be one of: {', '.join(MODES)}")
584
+ limits = limits or Limits()
585
+ body, limitations = render(net, [], mode)
586
+ problems = verify(net, "\n".join(body), limitations)
587
+ if problems:
588
+ raise NetError("couldn't build a script that rebuilds this network: " + "; ".join(problems))
589
+ explicit = _commands(body)
590
+ used: list[Substitution] = []
591
+ tried = rejected = 0
592
+ if mode != "explicit":
593
+ floor = READABLE_MIN_SAVING if mode == "readable" else 1
594
+ start = time.monotonic()
595
+ for cand in candidates(net, limits):
596
+ if tried >= limits.max_candidates or time.monotonic() - start > limits.max_seconds:
597
+ break
598
+ if cand.saves < floor or _conflicts(net, used, cand):
599
+ continue
600
+ if mode == "readable" and _first(net, cand.dst) < _first(net, cand.src):
601
+ continue # readable: later groups follow earlier ones, not back
602
+ tried += 1
603
+ try:
604
+ trial, lim = render(net, used + [cand], mode)
605
+ except _Infeasible:
606
+ rejected += 1
607
+ continue
608
+ if _cost(trial) >= _cost(body) or verify(net, "\n".join(trial), lim):
609
+ rejected += 1
610
+ continue
611
+ used.append(cand)
612
+ body, limitations = trial, lim
613
+ lines = _header(net, mode, body, explicit, used, limitations) + ([""] if mode != "shortest" else []) + body
614
+ while lines and not lines[-1]:
615
+ lines.pop()
616
+ return Synthesis("\n".join(lines) + "\n", mode, _commands(body), explicit, used, limitations,
617
+ tried, rejected)
618
+
619
+
620
+ def _first(net: Network, g: str) -> tuple[int, int]:
621
+ return min(_order(d) for d in net.devices_in(g))
622
+
623
+
624
+ def _conflicts(net: Network, used: list[Substitution], cand: Substitution) -> bool:
625
+ """Two reuses can't both make the same devices or the same cables."""
626
+ def devs(s: Substitution) -> set[str]:
627
+ return set(net.devices_in(s.dst)) if s.kind in ("clone", "copy") else set()
628
+
629
+ mine = devs(cand)
630
+ claimed: Counter = Counter()
631
+ for s in used:
632
+ if mine & devs(s) or cand.dst == s.dst and cand.kind != "ports" and s.kind != "ports":
633
+ return True
634
+ if cand.kind in ("clone", "copy") and s.kind in ("clone", "copy") and (
635
+ cand.dst in _subtree(net, s.dst) or s.dst in _subtree(net, cand.dst)):
636
+ return True
637
+ claimed += s.claims
638
+ if cand.claims:
639
+ have = Counter()
640
+ for l in net.links:
641
+ have[(l.a, l.b, l.role)] += 1
642
+ have[(l.b, l.a, l.role)] += 1
643
+ return bool((claimed + cand.claims) - have)
644
+ return False