gini-toolkit 6.0.1.dev0__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 (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1415 @@
1
+ """Fragment Manager — teacher mode.
2
+
3
+ A narrow icon rail (List · Create · Edit · Delete) over the fragment editor. The editor authors a
4
+ fragment from the live canvas two ways:
5
+
6
+ * **Record (scan mode)** — flip it on and BUILD on the canvas; each action becomes an ordered step
7
+ (place → wire → group). Deletes are not auto-pruned — remove stray steps by hand.
8
+ * **Read from canvas** — the one-shot fallback for a board you already built.
9
+
10
+ Steps are editable inline: per-row ▲▼ reorder, ✕ delete, and a clickable level chip (place → connect
11
+ → group → live). Plus **Add a live check** (the L4 runtime probe the recorder can't see) and **Add a
12
+ fork** (the difficulty knob). Finalize validates + writes the stamped YAML to ~/.gini/content.
13
+
14
+ Non-modal (floats over gBuilder) so recording keeps the canvas interactive.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from PySide6.QtCore import Qt, Signal
19
+ from PySide6.QtGui import QPalette
20
+ from PySide6.QtWidgets import (
21
+ QDialog, QFrame, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem,
22
+ QMessageBox, QPushButton, QScrollArea, QToolButton, QVBoxLayout, QWidget,
23
+ )
24
+
25
+ from ..domain import authoring as _au
26
+ from ..domain import certify as _certify
27
+ from ..domain import content as _content
28
+ from ..domain import fragments as _frag
29
+ from ..domain import objectives as _obj
30
+ from ..domain import riders as _riders
31
+ from .theme import icons as _icons
32
+
33
+ _LEVEL_SHORT = {_obj.PLACEMENT: "L1", _obj.CONNECTION: "L2",
34
+ _obj.CONTAINMENT: "L3", _obj.LIVE: "L4"}
35
+ _LEVEL_TIP = {_obj.PLACEMENT: "Place", _obj.CONNECTION: "Connect",
36
+ _obj.CONTAINMENT: "Group", _obj.LIVE: "Live"}
37
+ # a distinct, high-contrast colour per level so the chips read clearly on any theme
38
+ _LEVEL_COLOR = {_obj.PLACEMENT: "#3B82F6", _obj.CONNECTION: "#10B981",
39
+ _obj.CONTAINMENT: "#F59E0B", _obj.LIVE: "#8B5CF6"}
40
+
41
+
42
+ class FragmentManager(QDialog):
43
+ _cert_ready = Signal(object, object) # (CertReport, frag_dict) — from the grade thread
44
+ _tc_index_ready = Signal(object) # set[str] | None — ids the Teaching Center holds
45
+
46
+ def __init__(self, parent, ctx, *, author: str = "") -> None:
47
+ super().__init__(parent)
48
+ self.ctx = ctx
49
+ self._author = author
50
+ self._certified_hash: str | None = None # hash of the dict last certified runtime-green
51
+ self._cert_ready.connect(self._on_cert_ready)
52
+ # ids published to the Teaching Center. None = UNKNOWN (offline / not a teacher / not asked
53
+ # yet) — distinct from an empty set, so the list can stay silent instead of claiming a
54
+ # fragment is unpublished when we simply couldn't check.
55
+ self._tc_ids: set | None = None
56
+ self._tc_index_ready.connect(self._on_tc_index)
57
+ self._recorder: _au.Recorder | None = None
58
+ self._editing_id: str | None = None
59
+ self._steps: list[dict] = []
60
+ self._forks: list[dict] = []
61
+ self.setWindowTitle("Fragment Manager")
62
+ self.setMinimumSize(520, 480) # narrow enough for a small student laptop
63
+ self.resize(560, 560)
64
+ self.setModal(False)
65
+ self.setWindowModality(Qt.NonModal)
66
+ self.setWindowFlag(Qt.Tool, True)
67
+ self.setAttribute(Qt.WA_DeleteOnClose, True)
68
+ self._icon_color = self.palette().color(QPalette.ColorRole.WindowText).name()
69
+
70
+ root = QHBoxLayout(self)
71
+ root.setContentsMargins(8, 8, 8, 8)
72
+ root.setSpacing(8)
73
+
74
+ # -- narrow icon rail ------------------------------------------------
75
+ rail = QVBoxLayout(); rail.setSpacing(4)
76
+ for icon, tip, slot in (("layout", "List", self._show_list),
77
+ ("plus", "Create", self._create),
78
+ ("pencil", "Edit", self._edit_selected),
79
+ ("compile", "Composer — build a concrete network from certified "
80
+ "fragments and save it", self._composer),
81
+ ("trash", "Delete", self._delete_selected)):
82
+ b = QToolButton()
83
+ b.setIcon(self._ic(icon, 20)); b.setToolTip(tip)
84
+ b.setToolButtonStyle(Qt.ToolButtonIconOnly)
85
+ b.setAutoRaise(True); b.setFixedSize(38, 34)
86
+ b.clicked.connect(slot)
87
+ rail.addWidget(b)
88
+ rail.addStretch(1)
89
+ # Help sits at the BOTTOM of the rail — a "?" glyph (there's no help icon in the set, and a
90
+ # question mark reads unambiguously anyway).
91
+ helpb = QToolButton()
92
+ helpb.setText("?")
93
+ helpb.setToolTip("Help — what fragments, slots, certification and composition mean")
94
+ helpb.setAutoRaise(True); helpb.setFixedSize(38, 34)
95
+ helpb.setStyleSheet("QToolButton { font-size: 17px; font-weight: 700; }")
96
+ helpb.clicked.connect(self._show_help)
97
+ rail.addWidget(helpb)
98
+ rail_w = QWidget(); rail_w.setLayout(rail); rail_w.setFixedWidth(46)
99
+ rail_w.setObjectName("FragRail")
100
+ # a darker panel behind the icon rail so it reads as its own column
101
+ rail_w.setStyleSheet("#FragRail { background: rgba(0,0,0,0.11); border-radius: 10px; }")
102
+ root.addWidget(rail_w)
103
+
104
+ self._body = QVBoxLayout(); self._body.setSpacing(8)
105
+ body_w = QWidget(); body_w.setLayout(self._body)
106
+ root.addWidget(body_w, 1)
107
+ self._show_list()
108
+
109
+ # -- helpers ------------------------------------------------------------ #
110
+ def _ic(self, name: str, size: int = 18):
111
+ return _icons.icon(name, self._icon_color, size)
112
+
113
+ def _card(self, layout) -> QWidget:
114
+ """A flat grouping — no drop shadow (it muddied the look). Just spacing + a hairline top rule
115
+ via a thin separator, which reads clean on every theme."""
116
+ w = QWidget(); w.setLayout(layout)
117
+ return w
118
+
119
+ def _clear_body(self) -> None:
120
+ while self._body.count():
121
+ it = self._body.takeAt(0)
122
+ w = it.widget()
123
+ if w is not None:
124
+ w.setParent(None); w.deleteLater()
125
+
126
+ # --------------------------------------------------------------- help
127
+ HELP = """
128
+ <h3>Fragment Manager</h3>
129
+ <p>A <b>fragment</b> is a certified building block. You author one on the canvas, prove it works on
130
+ the live stack, and the composer then builds bigger networks out of it — that's how a handful of
131
+ blocks turn into many experiments.</p>
132
+
133
+ <h4>Two kinds of fragment</h4>
134
+ <ul>
135
+ <li><b>▪ Terminal</b> — self-contained, no holes. A LAN (2 hosts + a switch) is one. It's a
136
+ <i>value</i>: it can be dropped into another fragment's slot.</li>
137
+ <li><b>◆ Non-terminal</b> — has one or more open <b>slots</b>, shown in the list as
138
+ <code>⟨nets ×2+⟩</code>. A router "over 2-or-more networks" is one. It's a <i>template</i>: it isn't
139
+ a real network until its slots are filled.</li>
140
+ </ul>
141
+ <p>Think of a terminal as a value and a non-terminal as a function:
142
+ <code>router-net(nets := cap-lan, N=4)</code> evaluates to a concrete 4-LAN network.</p>
143
+
144
+ <h4>The buttons</h4>
145
+ <ul>
146
+ <li><b>Record</b> — turn it on and BUILD on the canvas; each action becomes a step.</li>
147
+ <li><b>Read canvas</b> — one-shot: derive steps from a board you already built.</li>
148
+ <li><b>Add dependency</b> — build ON another certified fragment. It loads as a <b>locked
149
+ scaffold</b> and becomes a <b>slot</b>; only your own delta is saved. Choose a floor of 2+ to make
150
+ the slot <i>repeatable</i> (the composer can then scale it to any N), and choose whether members
151
+ hub back to your delta or interconnect as peers (mesh / ring / line / star).</li>
152
+ <li><b>Add a live check</b> — an L4 runtime probe (e.g. "this host reaches that one"), which only a
153
+ running system can answer.</li>
154
+ <li><b>Certify</b> — grade this fragment on the LIVE stack. Takes ~5–10s: it starts any Sources and
155
+ Sinks, lets traffic accumulate, grades, then stops them.</li>
156
+ <li><b>Validate ×N</b> — a <b>TEST</b>, not a build: fill the slots with N providers, materialize
157
+ the whole topology, and grade it. It proves your pattern generalizes. <b>Nothing is saved</b> and
158
+ the result is thrown away.</li>
159
+ <li><b>Composer</b> (rail) — the opposite: build a concrete network from certified fragments and
160
+ <b>keep</b> it, saved as a new terminal block.</li>
161
+ </ul>
162
+
163
+ <h4>Ports · In and Out</h4>
164
+ <p>Attach a <b>Source</b> (Ping Probe, HTTP Probe…) or a <b>Sink</b> (Packet View, iPerf Server…) to
165
+ an element and it becomes an input/output <b>port</b>. A Sink's measurement can be turned into a
166
+ graded <b>output check</b> with <b>+ check</b> — that's how you grade what the system <i>did</i>,
167
+ not just how it was wired. The <b>Contract</b> line (provides / requires) is derived automatically;
168
+ you never type capability roles.</p>
169
+
170
+ <h4>The steps list</h4>
171
+ <p>Each step has a coloured <b>level</b> chip — <b>L1</b> place, <b>L2</b> connect, <b>L3</b> group,
172
+ <b>L4</b> live — click it to change. The <b>☆/★</b> is the difficulty <i>pass</i>: 0 stars is the
173
+ base experiment, ★ steps switch on in later passes so a student walks the experiment
174
+ progressively. ▲▼ reorder, ✕ deletes.</p>
175
+
176
+ <h4>Certification — and why it matters</h4>
177
+ <p>The <b>✓</b> means the fragment was graded green against a running system. Only certified
178
+ fragments can fill a slot or be uploaded, because the composer builds on them blindly — a broken
179
+ block would poison every experiment made from it. Editing a fragment drops its ✓ until you
180
+ re-certify.</p>
181
+
182
+ <h4>Typical flow</h4>
183
+ <ol>
184
+ <li>Create a terminal (e.g. a LAN) → Run → Certify → Save.</li>
185
+ <li>Create a non-terminal → <b>Add dependency</b> (the terminal, floor 2+) → build your delta →
186
+ add a live check → Run → Certify → Save.</li>
187
+ <li><b>Validate ×N</b> at a few values of N to prove the pattern scales.</li>
188
+ <li><b>Composer</b> to build and keep a concrete network, or <b>Upload</b> to the Teaching Center.</li>
189
+ </ol>
190
+
191
+ <h4>Two gotchas</h4>
192
+ <ul>
193
+ <li>Validate ×N <b>replaces the canvas</b>. Reopen the fragment from the list before composing
194
+ again — and Save is blocked while a validation instance is showing (it would overwrite your
195
+ authored pattern with the scaled copy).</li>
196
+ <li>A fragment can never fill <b>its own</b> slot (directly or in a cycle) — that would be a
197
+ grammar with no base case. Nesting <i>different</i> fragments (campus → site → LAN) is fine and is
198
+ exactly what recursion means here.</li>
199
+ </ul>
200
+ """
201
+
202
+ def _show_help(self) -> None:
203
+ """The manual, in-app. This dialog is a lot of concepts (terminals, slots, certification,
204
+ validation vs composition) and a student shouldn't have to infer them from button labels."""
205
+ from PySide6.QtWidgets import QDialog, QDialogButtonBox, QTextBrowser
206
+ dlg = QDialog(self)
207
+ dlg.setWindowTitle("Fragment Manager — Help")
208
+ dlg.resize(620, 640)
209
+ lay = QVBoxLayout(dlg)
210
+ view = QTextBrowser()
211
+ view.setOpenExternalLinks(True)
212
+ view.setHtml(self.HELP)
213
+ lay.addWidget(view, 1)
214
+ bb = QDialogButtonBox(QDialogButtonBox.Close)
215
+ bb.rejected.connect(dlg.reject)
216
+ bb.accepted.connect(dlg.accept)
217
+ lay.addWidget(bb)
218
+ dlg.exec()
219
+
220
+ # ------------------------------------------------------------ certify
221
+ def _library_excluding(self, fid: str) -> list:
222
+ return [f for f in _frag.all_fragments() if f.id != fid]
223
+
224
+ def _dict_from_fragment(self, f) -> dict:
225
+ """Rebuild the authoring dict from a saved Fragment (inverse of build_fragment_dict).
226
+
227
+ Must mirror `_current_dict` field-for-field: the two are compared by `_dict_hash` to decide
228
+ whether a reopened fragment is still the content that was certified. A field present in one
229
+ and missing in the other (e.g. `stars`) would look like an edit and drop the ✓."""
230
+ def objs(items):
231
+ return [{"id": o.id, "say": o.say, "check": o.check, "kind": o.kind,
232
+ "probe": o.probe, "level": o.level or _obj.level_of(o),
233
+ "stars": getattr(o, "stars", 0)} for o in items]
234
+ forks = [{"id": fk.id, "label": fk.label, "difficulty": fk.difficulty, "kind": fk.kind,
235
+ "objectives": objs(fk.objectives)} for fk in f.forks]
236
+ return _au.build_fragment_dict(
237
+ frag_id=f.id, teaches=f.teaches, summary=f.summary, spirit=f.spirit,
238
+ objectives=objs(f.objectives), forks=forks or None,
239
+ provides=f.provides or None, requires=f.requires or None, author=self._author)
240
+
241
+ def _cert_text(self, rep) -> str:
242
+ out = []
243
+ blocks, warns, infos = rep.of(_certify.BLOCK), rep.of(_certify.WARN), rep.of(_certify.INFO)
244
+ if blocks:
245
+ out.append("✗ Blocking — must fix before upload:")
246
+ out += [f" • {i.message}" for i in blocks]
247
+ if warns:
248
+ if out:
249
+ out.append("")
250
+ out.append("⚠ Composability (soft — the composer will see these):")
251
+ out += [f" • {i.message}" for i in warns]
252
+ if infos:
253
+ if out:
254
+ out.append("")
255
+ out += [f" {i.message}" for i in infos]
256
+ return "\n".join(out) or "Certified — no issues."
257
+
258
+ def _dict_hash(self, d: dict) -> str:
259
+ import hashlib
260
+ import json
261
+ body = json.dumps({k: d.get(k) for k in ("id", "spirit", "objectives", "forks",
262
+ "provides", "requires")},
263
+ sort_keys=True, default=str)
264
+ return hashlib.sha256(body.encode()).hexdigest()
265
+
266
+ def _runtime_grade(self, d: dict):
267
+ """Grade the fragment's authoring board on the LIVE stack (Item 2): auto-start its Sources so
268
+ the Sinks see traffic, grade every objective, then stop the Sources. Runs off the GUI thread.
269
+ Returns a certify.RuntimeGrade (available=False when there's no running stack)."""
270
+ import time
271
+ from ..domain import objectives as _obj
272
+ from ..domain import fragment_yaml as _fy
273
+ from ..domain.probes import TypeRunner
274
+ orch = getattr(self.ctx, "orchestrator", None)
275
+ runner = None
276
+ if orch is not None:
277
+ from ..services.probe_runner import RuntimeRunner
278
+ # measure() reads the LIVE streaming rider snapshots (ctx.rider_results), which is why we
279
+ # start the Sinks too and let them accumulate before grading.
280
+ base = RuntimeRunner(orch, lambda: self.ctx.topology, lambda: self.ctx.rider_results)
281
+ if base.available():
282
+ runner = TypeRunner(base, lambda: self.ctx.topology)
283
+ if runner is None:
284
+ return _certify.RuntimeGrade(available=False)
285
+
286
+ started = []
287
+ from ..domain.connection_rules import is_rider
288
+ # start BOTH Sources (drive traffic) and Sinks (accumulate a live reading) as streaming
289
+ # sessions — the same path that works when you double-click them by hand.
290
+ for dev in list(self.ctx.topology.devices.values()):
291
+ if is_rider(dev.type_key) and self.ctx.topology.donor_of(dev.id) is not None:
292
+ if self.ctx.start_rider(dev.id).get("ok"):
293
+ started.append(dev.id)
294
+ time.sleep(5.0) # let traffic flow + sinks accumulate
295
+ try:
296
+ frag = _fy.fragment_from_dict(d)
297
+ world = _obj.TopologyWorld(self.ctx.topology)
298
+ # On a COMPOSED board, grade the composed instance's objectives (their labels match the
299
+ # board); otherwise the fragment's own authoring objectives. instantiate() → Objective
300
+ # objects (evaluate_all needs .is_behavioral(); the stored templates don't have it).
301
+ objs = getattr(self, "_composed_objectives", None) or frag.instantiate()
302
+ results = _obj.evaluate_all(objs, world, runner)
303
+ finally:
304
+ for rid in started:
305
+ self.ctx.stop_rider(rid)
306
+ return _certify.runtime_from_results(results, available=True)
307
+
308
+ def _certify_current(self) -> None:
309
+ d = self._current_dict()
310
+ if d is None:
311
+ return
312
+ import threading
313
+ self.ctx.log("Certifying (grading the board on the live stack)…", "info")
314
+
315
+ def work():
316
+ try:
317
+ runtime = self._runtime_grade(d)
318
+ rep = _certify.certify(d, library=self._library_excluding(d.get("id", "")),
319
+ runtime=runtime)
320
+ except Exception as e: # noqa: BLE001 — surface, never die silently
321
+ rep = _certify.CertReport(fragment_id=d.get("id", ""))
322
+ rep.add(_certify.BLOCK, "error", f"Certify hit an error: {e}")
323
+ self._cert_ready.emit(rep, d)
324
+ threading.Thread(target=work, daemon=True).start()
325
+
326
+ def _on_cert_ready(self, rep, d) -> None:
327
+ if rep.certified:
328
+ self._certified_hash = self._dict_hash(d) # Save may now stamp it certified
329
+ title = "Certified ✓" if rep.certified else "Not certified ✗"
330
+ QMessageBox.information(self, title, self._cert_text(rep))
331
+
332
+ # ------------------------------------------------------------------ list
333
+ def _authored_ids(self) -> list[str]:
334
+ d = _content.user_content_dir()
335
+ return sorted(p.stem for p in d.glob("*.yaml")) if d.exists() else []
336
+
337
+ @staticmethod
338
+ def _open_slots(f) -> list[str]:
339
+ """The names of a fragment's unfilled slots / peer groups, as `name ×min+`. Empty = a
340
+ TERMINAL (self-contained); non-empty = a NON-TERMINAL (a template that must be filled)."""
341
+ if f is None:
342
+ return []
343
+ out = []
344
+ for s in list(getattr(f, "slots", ()) or ()):
345
+ out.append(f"{s.name} ×{s.min}" + ("+" if s.max != 1 else ""))
346
+ for p in list(getattr(f, "peerings", ()) or ()):
347
+ out.append(f"{p.name} ×{p.min}+ {p.topology}")
348
+ return out
349
+
350
+ def _is_terminal(self, f) -> bool:
351
+ return not self._open_slots(f)
352
+
353
+ def _show_list(self) -> None:
354
+ self._clear_body()
355
+ self._body.addWidget(QLabel("<b>Your fragments</b>"))
356
+ from PySide6.QtGui import QColor
357
+ self.listw = QListWidget()
358
+ for fid in self._authored_ids():
359
+ f = _frag.get(fid)
360
+ certified = bool(getattr(f, "certified", False))
361
+ forks = f" · {len(f.forks)} fork(s)" if (f and f.forks) else ""
362
+ mark = "✓ " if certified else "○ " # ✓ = runtime-certified, ○ = not yet
363
+ # TERMINAL (▪) vs NON-TERMINAL (◆) — the grammar distinction that decides what you can DO
364
+ # with a block: a terminal is self-contained (a value, fills other fragments' slots); a
365
+ # non-terminal has open slots (a template, must be filled before it's a real network).
366
+ open_slots = self._open_slots(f)
367
+ shape = "◆ " if open_slots else "▪ "
368
+ holes = f" ⟨{', '.join(open_slots)}⟩" if open_slots else ""
369
+ tc_suffix, tc_tip = self._tc_mark(fid)
370
+ it = QListWidgetItem(f"{mark}{shape}{fid}{holes}{forks}{tc_suffix}")
371
+ it.setData(Qt.UserRole, fid)
372
+ cert_tip = ("Certified — runtime-playtested (winnable + live)" if certified
373
+ else "Not certified — Run the topology and press Certify before upload")
374
+ shape_tip = (f"NON-TERMINAL — a template with open slot(s): {', '.join(open_slots)}. "
375
+ f"Fill them with Validate ×N (or use it in the Composer)."
376
+ if open_slots else
377
+ "TERMINAL — self-contained. Can fill another fragment's slot.")
378
+ it.setToolTip("\n".join(x for x in (shape_tip, cert_tip, tc_tip) if x))
379
+ if certified:
380
+ it.setForeground(QColor("#10B981")) # green marks a certified block
381
+ self.listw.addItem(it)
382
+ self.listw.itemDoubleClicked.connect(lambda *_: self._edit_selected())
383
+ self._body.addWidget(self.listw, 1)
384
+ if not self._authored_ids():
385
+ self._body.addWidget(QLabel("None yet — press + (Create).", objectName="Faint"))
386
+ up = QPushButton(" Upload selected to Teaching Center")
387
+ up.setObjectName("Accent") # the primary action on the list — make it pop
388
+ up.setIcon(_icons.icon("send", "#ffffff", 16)) # white glyph on the accent fill
389
+ up.setMinimumHeight(34)
390
+ up.clicked.connect(self._upload_selected)
391
+ self._body.addWidget(up)
392
+ self._refresh_tc_index() # background; re-renders with ↑ marks when it lands
393
+
394
+ # -- "is it on the Teaching Center?" ------------------------------------ #
395
+ def _refresh_tc_index(self, force: bool = False) -> None:
396
+ """Ask the Center which fragments it holds, off the GUI thread. Cached: the list re-renders
397
+ when the answer arrives. `force` re-asks after an upload or delete changed it."""
398
+ if self._tc_ids is not None and not force:
399
+ return
400
+ tc = getattr(self.ctx, "teaching_center", None)
401
+ if tc is None or not getattr(tc, "is_teacher", lambda: False)():
402
+ return # not a teacher: stays UNKNOWN, shown blank
403
+ import threading
404
+
405
+ def work():
406
+ try:
407
+ lib = tc.fragment_library()
408
+ except Exception: # noqa: BLE001 — offline stays unknown
409
+ lib = None
410
+ ids = None
411
+ if isinstance(lib, list):
412
+ ids = {str(x.get("id", x) if isinstance(x, dict) else x) for x in lib}
413
+ self._tc_index_ready.emit(ids)
414
+ threading.Thread(target=work, daemon=True).start()
415
+
416
+ def _on_tc_index(self, ids) -> None:
417
+ self._tc_ids = ids
418
+ if getattr(self, "listw", None) is not None: # only re-render if the list is showing
419
+ self._show_list()
420
+
421
+ def _tc_mark(self, fid: str) -> tuple[str, str]:
422
+ """(suffix, tooltip-line) for a fragment's publication state. Silent when unknown."""
423
+ if self._tc_ids is None:
424
+ return "", ""
425
+ if fid in self._tc_ids:
426
+ return " ↑", "Published to the Teaching Center — experiments can be composed from it."
427
+ return "", "Local only — not yet uploaded to the Teaching Center."
428
+
429
+ def _selected_id(self) -> str | None:
430
+ it = getattr(self, "listw", None) and self.listw.currentItem()
431
+ return it.data(Qt.UserRole) if it else None
432
+
433
+ def _delete_selected(self) -> None:
434
+ fid = self._selected_id()
435
+ if not fid:
436
+ QMessageBox.information(self, "Delete", "Select a fragment in the list first.")
437
+ return
438
+ if QMessageBox.question(self, "Delete fragment", f"Delete '{fid}'?") != QMessageBox.Yes:
439
+ return
440
+ # Remove the authored file. Match by stem first, but also sweep any file whose INTERNAL id
441
+ # equals fid — a fragment saved with a spaced/mixed-case id (e.g. "simple LAN.yaml") won't
442
+ # match a slugified stem, and would otherwise survive the delete.
443
+ import yaml as _yaml
444
+ d = _content.user_content_dir()
445
+ removed = False
446
+ target = d / f"{fid}.yaml"
447
+ if target.exists():
448
+ target.unlink(missing_ok=True); removed = True
449
+ for p in list(d.glob("*.yaml")) if d.exists() else []:
450
+ try:
451
+ spec = _yaml.safe_load(p.read_text(encoding="utf-8")) or {}
452
+ except Exception: # noqa: BLE001 — unreadable → skip
453
+ continue
454
+ if str(spec.get("id", "")) == fid or p.stem == fid:
455
+ p.unlink(missing_ok=True); removed = True
456
+ _frag.reload()
457
+ # The teacher owns both sides, so a local delete deletes centrally too — otherwise the copy
458
+ # is just re-pulled on the next sign-in and the deletion silently undoes itself. Best-effort
459
+ # and non-blocking: an unreachable Center must never make a local delete fail.
460
+ central = self._delete_on_center(fid)
461
+ if _frag.get(fid) is not None: # survived the unlink → it's a built-in
462
+ QMessageBox.information(self, "Built-in fragment",
463
+ f"'{fid}' is a built-in that ships with GINI, so it reloads from the app each "
464
+ f"launch and can't be removed here. (Any authored copy was deleted.) Built-ins are "
465
+ f"uncertified, so they no longer show up as dependency options.")
466
+ elif removed:
467
+ QMessageBox.information(self, "Deleted", f"Removed '{fid}'.{central}")
468
+ self._show_list()
469
+
470
+ def _delete_on_center(self, fid: str) -> str:
471
+ """Delete the fragment from the Teaching Center too (teacher only). Returns a sentence to
472
+ append to the local confirmation. Never raises — losing the Center must not block a delete."""
473
+ tc = getattr(self.ctx, "teaching_center", None)
474
+ if tc is None or not getattr(tc, "is_teacher", lambda: False)():
475
+ return "" # not signed in as teacher: nothing central
476
+ try:
477
+ res = tc.delete_fragment(fid)
478
+ except Exception as e: # noqa: BLE001
479
+ res = {"ok": False, "error": str(e)}
480
+ if not isinstance(res, dict): # a malformed reply is a failure, not a crash
481
+ res = {"ok": False, "error": f"unexpected reply: {res!r}"}
482
+ if res.get("ok"):
483
+ self.ctx.log(f"Removed '{fid}' from the Teaching Center too.", "ok")
484
+ if self._tc_ids is not None:
485
+ self._tc_ids.discard(fid) # drop the ↑ mark immediately
486
+ return " Removed from the Teaching Center as well."
487
+ err = res.get("error", "unknown error")
488
+ if "no such" in str(err).lower() or "not found" in str(err).lower():
489
+ return "" # never published there — nothing to say
490
+ self.ctx.log(f"Couldn't remove '{fid}' from the Teaching Center: {err}", "error")
491
+ return (f"\n\n⚠ It could NOT be removed from the Teaching Center ({err}), so it will "
492
+ f"re-sync on your next sign-in. Delete it there, or retry when the server is up.")
493
+
494
+ def _edit_selected(self) -> None:
495
+ fid = self._selected_id()
496
+ if not fid:
497
+ QMessageBox.information(self, "Edit", "Select a fragment in the list first.")
498
+ return
499
+ self._open_editor(fid)
500
+
501
+ def _create(self) -> None:
502
+ self._open_editor(None)
503
+
504
+ def _load_board(self, stage: dict) -> None:
505
+ """Restore a fragment's saved authoring board onto the canvas (types, positions, wiring, and
506
+ rider attachments). Names auto-regenerate — grading is type-based, so that's fine."""
507
+ devs = stage.get("devices", []) or []
508
+ links = stage.get("links", []) or []
509
+ if not devs:
510
+ return
511
+ self.ctx.clear_topology()
512
+ idmap: dict[str, str] = {}
513
+ for d in devs:
514
+ inst = self.ctx.add_device(d["type_key"], x=float(d.get("x", 0)), y=float(d.get("y", 0)),
515
+ properties=dict(d.get("properties") or {}))
516
+ inst.slot = d.get("slot", "") or "" # scaffold membership — drives `type@slot`
517
+ idmap[d["id"]] = inst.id
518
+ for attr in ("size", "w", "h"):
519
+ if d.get(attr):
520
+ setattr(inst, attr, d[attr])
521
+ for d in devs: # containment, once every id is remapped
522
+ pid = d.get("parent_id")
523
+ if pid and pid in idmap:
524
+ self.ctx.topology.devices[idmap[d["id"]]].parent_id = idmap[pid]
525
+ for l in links:
526
+ s, t = idmap.get(l.get("source_id")), idmap.get(l.get("target_id"))
527
+ if not (s and t):
528
+ continue
529
+ try:
530
+ if l.get("kind") == "attach":
531
+ self.ctx.add_attach(s, t)
532
+ else:
533
+ self.ctx.add_link(s, t)
534
+ except Exception: # noqa: BLE001 — a stale/invalid edge is skipped
535
+ pass
536
+ self.ctx.bus.topology_changed.emit()
537
+
538
+ def _upload_selected(self) -> None:
539
+ fid = self._selected_id()
540
+ if not fid:
541
+ QMessageBox.information(self, "Upload", "Select a fragment first."); return
542
+ tc = getattr(self.ctx, "teaching_center", None)
543
+ if tc is None or not getattr(tc, "is_teacher", lambda: False)():
544
+ QMessageBox.information(self, "Upload", "Sign in as a teacher to upload fragments.")
545
+ return
546
+ from ..domain import fragment_yaml as _fy
547
+ f = _frag.get(fid)
548
+ if f is None:
549
+ return
550
+ # HARD gate: upload requires a runtime pass — the fragment must be certified (Run + Certify).
551
+ if not getattr(f, "certified", False):
552
+ QMessageBox.warning(self, "Not certified",
553
+ "This fragment isn't runtime-certified. Open it, Run the topology, "
554
+ "and press Certify so its live/output checks are proven — the "
555
+ "Teaching Center only accepts certified building blocks.")
556
+ return
557
+ # its live checks are proven (the stamp); surface any SOFT composability warnings
558
+ rep = _certify.certify(self._dict_from_fragment(f), library=self._library_excluding(fid),
559
+ runtime=_certify.RuntimeGrade(available=True))
560
+ if rep.blocked:
561
+ QMessageBox.warning(self, "Not certified",
562
+ "This fragment can't go to the Teaching Center until it's fixed:\n\n"
563
+ + self._cert_text(rep))
564
+ return
565
+ if rep.of(_certify.WARN):
566
+ if QMessageBox.question(self, "Certified with warnings",
567
+ self._cert_text(rep) + "\n\nUpload anyway?") != QMessageBox.Yes:
568
+ return
569
+ import threading
570
+ yaml_text = _fy.to_yaml(f)
571
+
572
+ def work():
573
+ res = tc.upload_fragment(yaml_text)
574
+ self.ctx.log(f"Uploaded '{res.get('id', fid)}' — the Teaching Center can now compose "
575
+ f"experiments from it." if res.get("ok")
576
+ else f"Upload refused: {res.get('error', 'unknown error')}",
577
+ "ok" if res.get("ok") else "error")
578
+ if res.get("ok"):
579
+ self._refresh_tc_index(force=True) # the ↑ mark appears without a reopen
580
+ threading.Thread(target=work, daemon=True).start()
581
+ self.ctx.log(f"Uploading '{fid}' to the Teaching Center…", "info")
582
+
583
+ # ---------------------------------------------------------------- editor
584
+ def _open_editor(self, fid: str | None) -> None:
585
+ self._editing_id = fid
586
+ self._steps = []
587
+ self._forks = []
588
+ self._scaffold_ids: set = set() # loaded-dependency device ids (excluded from the delta)
589
+ f = _frag.get(fid) if fid else None
590
+ self._slots: list = [{"name": s.name, "role": s.role, "min": s.min, "max": s.max,
591
+ "distinct": s.distinct} for s in getattr(f, "slots", ())] \
592
+ if f is not None else []
593
+ self._peerings: list = [{"name": p.name, "role": p.role, "min": p.min, "max": p.max,
594
+ "topology": p.topology} for p in getattr(f, "peerings", ())] \
595
+ if f is not None else []
596
+ # when the canvas holds a COMPOSED board (from Compose ×N), Certify must grade THESE objectives
597
+ # (the composed instance), not the fragment's authoring @slot objectives whose labels differ.
598
+ self._composed_objectives = None
599
+ if f is not None:
600
+ for o in f.objectives:
601
+ self._steps.append({"id": o.id, "say": o.say, "check": o.check, "kind": o.kind,
602
+ "probe": o.probe, "level": o.level or _obj.level_of(o),
603
+ "stars": getattr(o, "stars", 0)})
604
+ self._forks = [{"id": fk.id, "label": fk.label, "difficulty": fk.difficulty,
605
+ "kind": fk.kind,
606
+ "objectives": [{"id": o.id, "say": o.say, "check": o.check,
607
+ "kind": o.kind, "probe": o.probe,
608
+ "level": o.level or _obj.level_of(o)}
609
+ for o in fk.objectives]} for fk in f.forks]
610
+ # bring the fragment's own authoring board back, so steps/ports/contract all agree
611
+ if getattr(f, "stage", None) and f.stage.get("devices"):
612
+ if (not self.ctx.topology.devices
613
+ or QMessageBox.question(self, "Load board",
614
+ "Load this fragment's saved board onto the canvas? "
615
+ "(replaces what's currently there)")
616
+ == QMessageBox.Yes):
617
+ self._load_board(f.stage)
618
+ # Slotted devices ARE the scaffold (materialized dependencies); the delta carries
619
+ # no slot. Rebuild the exclude set so re-save/recompute stays delta-only.
620
+ self._scaffold_ids = {did for did, dev in self.ctx.topology.devices.items()
621
+ if getattr(dev, "slot", "")}
622
+ # CARRY THE CERTIFICATION FORWARD. Save stamps `certified` only when the content hash
623
+ # matches the one last graded green; on a freshly-opened editor that hash is None, so
624
+ # re-saving an already-certified fragment would silently ERASE its ✓ (which is exactly
625
+ # what happens when you reopen a fragment between Validate ×N runs). Seed the hash from
626
+ # the stored fragment: unchanged content keeps the stamp, edited content still drops it.
627
+ if getattr(f, "certified", False):
628
+ self._certified_hash = self._dict_hash(self._dict_from_fragment(f))
629
+ else:
630
+ self._certified_hash = None
631
+ else:
632
+ self._certified_hash = None # a brand-new fragment starts uncertified
633
+
634
+ self._clear_body()
635
+
636
+ # --- meta card ------------------------------------------------------
637
+ meta = QVBoxLayout(); meta.setContentsMargins(10, 8, 10, 8); meta.setSpacing(4)
638
+ self.fid = QLineEdit(fid or ""); self.fid.setPlaceholderText("fragment id (e.g. simple-lan)")
639
+ if fid:
640
+ self.fid.setReadOnly(True)
641
+ self.teaches = QLineEdit(getattr(f, "teaches", "") if f else "")
642
+ self.teaches.setPlaceholderText("teaches — a concept key")
643
+ self.summary = QLineEdit(getattr(f, "summary", "") if f else "")
644
+ self.summary.setPlaceholderText("one-line summary the student sees")
645
+ self.spirit = QLineEdit(getattr(f, "spirit", "") if f else "")
646
+ self.spirit.setPlaceholderText("spirit — what counts as success (the AI reasons on this)")
647
+ meta.addWidget(self.fid); meta.addWidget(self.teaches)
648
+ meta.addWidget(self.summary); meta.addWidget(self.spirit)
649
+ self._body.addWidget(self._card(meta))
650
+
651
+ # --- actions card ---------------------------------------------------
652
+ acts = QHBoxLayout(); acts.setContentsMargins(8, 6, 8, 6); acts.setSpacing(6)
653
+ self.record_btn = QPushButton(" Record")
654
+ self.record_btn.setCheckable(True)
655
+ self.record_btn.setToolTip("Scan mode — build on the canvas and each action becomes a step")
656
+ self.record_btn.toggled.connect(self._toggle_record)
657
+ acts.addWidget(self.record_btn)
658
+ read = QPushButton(" Read canvas"); read.setIcon(self._ic("search", 16))
659
+ read.setToolTip("Read objectives off the board you've already built")
660
+ read.clicked.connect(self._read_once); acts.addWidget(read)
661
+ dep = QPushButton(" Add dependency"); dep.setIcon(self._ic("layers", 16))
662
+ dep.setToolTip("Load a provider fragment as a locked scaffold and build on top of it")
663
+ dep.clicked.connect(self._add_dependency); acts.addWidget(dep)
664
+ acts.addStretch(1)
665
+ live = QPushButton(); live.setIcon(self._ic("metrics", 16)); live.setToolTip("Add a live check")
666
+ live.clicked.connect(self._add_live); acts.addWidget(live)
667
+ # (difficulty is now per-step stars — a step's ★ marks it as a harder progressive pass; the
668
+ # old "Add fork" button is retired.)
669
+ cert = QPushButton("Certify"); cert.setToolTip("Check this fragment (compiler + composability)")
670
+ cert.clicked.connect(self._certify_current); acts.addWidget(cert)
671
+ # A TEST, not a build: it proves this fragment's pattern still holds at N. The result is a
672
+ # throwaway validation instance (Save stays disabled while one is showing) — to keep a
673
+ # composition, use the Composer, which saves it as a new terminal fragment.
674
+ comp = QPushButton("Validate ×N")
675
+ comp.setToolTip("TEST that this fragment's pattern scales: fill its slot with N certified "
676
+ "providers, build the topology, and grade it. Nothing is saved.")
677
+ comp.clicked.connect(self._compose_validate); acts.addWidget(comp)
678
+ self._body.addWidget(self._card(acts))
679
+ self._sync_record_btn()
680
+
681
+ # --- Ports (In / Out) — attached Sources & Sinks -------------------- #
682
+ self._ports_box = QVBoxLayout(); self._ports_box.setSpacing(2)
683
+ self._ports_box.setContentsMargins(10, 2, 10, 2)
684
+ self._body.addWidget(self._card(self._ports_box))
685
+
686
+ self.rec_note = QLabel(""); self.rec_note.setObjectName("Faint")
687
+ self.rec_note.setWordWrap(True)
688
+ self._body.addWidget(self.rec_note)
689
+
690
+ # --- steps (custom rows with per-row controls) ----------------------
691
+ self._steps_box = QVBoxLayout(); self._steps_box.setSpacing(4)
692
+ self._steps_box.setContentsMargins(2, 2, 2, 2)
693
+ inner = QWidget(); inner.setLayout(self._steps_box)
694
+ scroll = QScrollArea(); scroll.setWidget(inner); scroll.setWidgetResizable(True)
695
+ scroll.setFrameShape(QFrame.NoFrame)
696
+ self._body.addWidget(scroll, 1)
697
+
698
+ # --- footer ---------------------------------------------------------
699
+ foot = QHBoxLayout()
700
+ back = QPushButton("Back to list"); back.clicked.connect(self._show_list); foot.addWidget(back)
701
+ foot.addStretch(1)
702
+ save = QPushButton(" Save fragment"); save.setObjectName("Accent")
703
+ save.setIcon(self._ic("save", 16)); save.clicked.connect(self._finalize)
704
+ foot.addWidget(save)
705
+ fw = QWidget(); fw.setLayout(foot); self._body.addWidget(fw)
706
+ self._render_steps()
707
+ self._render_ports()
708
+
709
+ # -- recording -----------------------------------------------------------
710
+ def _sync_record_btn(self) -> None:
711
+ on = self._recorder is not None
712
+ self.record_btn.setText(" Stop" if on else " Record")
713
+ self.record_btn.setIcon(self._ic("minus" if on else "robot", 16))
714
+
715
+ def _toggle_record(self, on: bool) -> None:
716
+ if on:
717
+ self._recorder = _au.Recorder(exclude=self._scaffold_ids)
718
+ self._recorder.steps = list(self._steps)
719
+ self._recorder._by_key = {s.get("key", s["id"]): s for s in self._steps}
720
+ self.ctx.bus.topology_changed.connect(self._on_change)
721
+ self._rec_connected = True
722
+ self.rec_note.setText("Recording — build on the canvas; each action becomes a step.")
723
+ else:
724
+ self._disconnect_recorder()
725
+ if self._recorder is not None:
726
+ self._steps = self._recorder.result()
727
+ self._recorder = None
728
+ self.rec_note.setText("Stopped. Reorder ▲▼, delete ✕, or click a level chip to change it.")
729
+ self._render_steps()
730
+ self._sync_record_btn()
731
+
732
+ def _disconnect_recorder(self) -> None:
733
+ if getattr(self, "_rec_connected", False):
734
+ self.ctx.bus.topology_changed.disconnect(self._on_change)
735
+ self._rec_connected = False
736
+
737
+ def _on_change(self) -> None:
738
+ if self._recorder is not None:
739
+ self._recorder.capture(self.ctx.topology)
740
+ self._steps = self._recorder.result()
741
+ self._render_steps()
742
+ self._render_ports() # attaching a rider updates the Ports panel live
743
+
744
+ def closeEvent(self, e) -> None:
745
+ self._disconnect_recorder()
746
+ super().closeEvent(e)
747
+
748
+ def _read_once(self) -> None:
749
+ self._composed_objectives = None # back to authoring — grade the fragment, not a compose
750
+ derived = _au.derive_objectives(self.ctx.topology, exclude=self._scaffold_ids)
751
+ have = {s.get("key") for s in self._steps}
752
+ for d in derived:
753
+ if d["key"] not in have:
754
+ self._steps.append(d)
755
+ self._render_steps()
756
+ self._render_ports()
757
+ self.rec_note.setText(f"Read {len(derived)} candidate(s) from the canvas.")
758
+
759
+ def _add_dependency(self) -> None:
760
+ """Load a certified provider fragment onto the canvas as a LOCKED scaffold, so a *dependent*
761
+ fragment (router/gateway/firewall) can be authored + certified on top of it. Its `provides`
762
+ become this fragment's `requires`; only the delta the teacher builds is captured and saved."""
763
+ self._composed_objectives = None # editing again — Certify grades the fragment
764
+ # a dependency must be a CERTIFIED block — you only build on proven, runtime-playtested
765
+ # fragments (this also hides the uncertified built-in samples).
766
+ providers = [f for f in _frag.all_fragments()
767
+ if getattr(f, "provides", ()) and getattr(f, "certified", False)]
768
+ if not providers:
769
+ QMessageBox.information(self, "Add dependency",
770
+ "No certified providers yet. A dependency must be a CERTIFIED fragment that "
771
+ "`provides` a capability (e.g. a LAN that provides an l2-fabric). Author one, Run "
772
+ "the topology, and press Certify — then it can be built on.")
773
+ return
774
+ labels = [f"{f.id} · provides {', '.join(f.provides)}" for f in providers]
775
+ pick, ok = QInputDialog.getItem(
776
+ self, "Add dependency",
777
+ "Build on which provider? It loads as a LOCKED scaffold — only your delta is saved:",
778
+ labels, 0, False)
779
+ if not ok:
780
+ return
781
+ prov = providers[labels.index(pick)]
782
+ slot_role = self._slot_role(prov)
783
+
784
+ # Cardinal? A repeatable leg (min≥2, unbounded) is what the composer SCALES — the fragment
785
+ # becomes "a router over N of these", not a fixed 2-legged one. A single leg keeps the old
786
+ # A/B behaviour. We drop `min` representatives on the board, all sharing ONE slot name, so the
787
+ # derived objective is the symbolic `link(router, switch@<slot>)` the assembler expands.
788
+ n, ok = QInputDialog.getInt(
789
+ self, "Repeatable leg — set the floor",
790
+ "Minimum members that must bind (the floor).\n\n"
791
+ "2 or more → a REPEATABLE leg the composer scales to N (a router over N LANs).\n"
792
+ "1 → a single fixed leg.\n\n"
793
+ "Note: to author + certify, only TWO representatives are placed — that proves the pattern. "
794
+ "Scaling to more is Compose ×N.",
795
+ 2, 1, 16, 1)
796
+ if not ok:
797
+ return
798
+ cardinal = n >= 2
799
+
800
+ # Cardinal legs can either be a HUB (each member links back to your delta — a router over N
801
+ # LANs) or a lateral PEER GROUP (members interconnect as a graph — a mesh of routers).
802
+ if cardinal:
803
+ shape, ok = QInputDialog.getItem(
804
+ self, "How do the members connect?",
805
+ "none → each links to YOUR delta (a hub, e.g. a router over N LANs)\n"
806
+ "mesh / ring / line / star → the members interconnect as PEERS (a graph of routers)",
807
+ ["none (hub from your delta)", "mesh", "ring", "line", "star"], 0, False)
808
+ if not ok:
809
+ return
810
+ shape = shape.split()[0]
811
+ if shape != "none":
812
+ base, name, k = "sites", "sites", 0
813
+ taken = {p["name"] for p in self._peerings} | {s["name"] for s in self._slots}
814
+ while name in taken:
815
+ k += 1; name = f"{base}{k}"
816
+ self._peerings.append({"name": name, "role": slot_role, "min": n, "max": 0,
817
+ "topology": shape})
818
+ self._steps.append({"id": f"reach-{name}",
819
+ "say": f"every peer in {name} reaches every other",
820
+ "kind": "behavioral",
821
+ "probe": f"reach(host@{name} -> host@{name}) == ok", "level": 4})
822
+ self._render_steps()
823
+ self.rec_note.setText(
824
+ f"Declared a {shape} peer group '{name}' of {n}+ {slot_role} members. Press "
825
+ f"Compose ×N to build + validate the graph.")
826
+ return
827
+ base = "lans" if "l2-fabric" in prov.provides else "nets"
828
+ slot_name = base
829
+ k = 0
830
+ while any(s["name"] == slot_name for s in self._slots):
831
+ k += 1; slot_name = f"{base}{k}"
832
+ else:
833
+ slot_name = chr(ord("A") + len(self._slots)) # A, B, C, … (fixed leg)
834
+
835
+ # AUTHOR the pattern with just TWO representatives — enough to prove the connection and keep
836
+ # the certify board small (fast boot). The floor `n` still rides on the slot for Compose ×N.
837
+ reps = 2 if cardinal else 1
838
+ composite = bool(getattr(prov, "slots", ()) or getattr(prov, "peerings", ()))
839
+ if composite:
840
+ # A composite provider (a routed-network with its own LANs) is materialized FULLY through
841
+ # the composer so its internals show — router + LANs + hosts — not just its top router.
842
+ # Resolve its sub-binding once, reuse it for each representative.
843
+ sub = self._resolve_binding(prov)
844
+ if sub is None:
845
+ return
846
+ from ..domain import compose as _compose
847
+ for r in range(reps):
848
+ try:
849
+ subtopo, _ = _compose.materialize(sub)
850
+ except _compose.CompositionError as e:
851
+ QMessageBox.warning(self, "Add dependency", str(e)); return
852
+ self._merge_scaffold(subtopo, slot_name, col=r)
853
+ else:
854
+ objs = [{"check": o.check} for o in prov.instantiate() if o.check]
855
+ band = self._slot_band(slot_name) # this slot's own column band
856
+ for r in range(reps): # drop `reps` representatives of the LAN
857
+ ids = _au.materialize(self.ctx, objs, x0=60 + (band + r) * 300)
858
+ for did in ids: # all share the one slot name
859
+ self.ctx.topology.devices[did].slot = slot_name
860
+ self.ctx.topology.devices[did].slot_source = prov.id # …and what filled it
861
+ self._scaffold_ids |= set(ids)
862
+ self._slots.append({"name": slot_name, "role": slot_role,
863
+ "min": n if cardinal else 1, "max": 0 if cardinal else 1,
864
+ "distinct": True})
865
+ self.ctx.bus.topology_changed.emit()
866
+ kind = f"a repeatable slot (bind {n}+, the composer scales it)" if cardinal else "a single leg"
867
+ hint = (f"router@{slot_name}" if composite else f"switch@{slot_name}") # what to wire the delta to
868
+ self.rec_note.setText(
869
+ f"Loaded '{prov.id}' as slot {slot_name} — {kind}. Wire your delta to it and refer to it "
870
+ f"as type@{slot_name} — e.g. link(router, {hint}). Only your delta is saved; press "
871
+ f"Compose ×N to scale + validate.")
872
+
873
+ def _slot_band(self, slot_name: str) -> int:
874
+ """The first free layout column for a slot's scaffold. Each slot occupies its own band of
875
+ columns, so R_net(X, Y, Z) shows three side-by-side groups rather than three piles at x=70."""
876
+ used = {getattr(d, "slot", "") for d in self.ctx.topology.devices.values()}
877
+ used.discard("")
878
+ used.discard(slot_name) # this slot's own reps continue its band
879
+ return len(used) * 2 # 2 columns per slot (the two representatives)
880
+
881
+ def _slot_role(self, prov) -> str:
882
+ """The role a slot should require: the ROOT ancestor of a network-ish provide (loose, so the
883
+ composer can substitute a LAN or a routed sub-network → recursion), else the first provide."""
884
+ from ..domain import capabilities as _caps
885
+ best = None
886
+ for r in prov.provides:
887
+ for anc in _caps.ancestors(r):
888
+ if not _caps.PARENTS.get(anc): # a root role
889
+ if anc == "network":
890
+ return "network"
891
+ best = best or anc
892
+ return best or (prov.provides[0] if prov.provides else "network")
893
+
894
+ def _compose_validate(self) -> None:
895
+ """Scale THIS fragment: bind each slot to N certified providers, materialize the whole
896
+ topology on the canvas, and grade it structurally. This is the generative step — a hand-built
897
+ fragment becomes a real N-node network the oracle checks. (Run + Certify then proves it live.)"""
898
+ from ..domain import compose as _compose
899
+ from ..domain import fragment_yaml as _fy
900
+ d = self._current_dict()
901
+ if not d:
902
+ QMessageBox.information(self, "Compose", "Give the fragment an id and some steps first.")
903
+ return
904
+ frag = _fy.fragment_from_dict(d)
905
+ if not (frag.slots or frag.peerings):
906
+ QMessageBox.information(self, "Compose ×N",
907
+ "This fragment has nothing to scale. Press 'Add dependency', choose a certified "
908
+ "provider, and make it a repeatable leg (2+) or a peer group (mesh/ring/…).")
909
+ return
910
+ # Validate ×N is READ-ONLY. The composer resolves `binding["fragment"]` through the registry,
911
+ # so the in-editor draft has to be visible there for the call — but the draft carries no
912
+ # `certified` flag (that's stamped at Save), so installing it plainly would overwrite the
913
+ # loaded, certified entry and the list would show the fragment as uncertified. Carry the
914
+ # stamp onto the draft, and put the original back when we're done.
915
+ prev = _frag.FRAGMENTS.get(frag.id)
916
+ if prev is not None and getattr(prev, "certified", False):
917
+ import dataclasses
918
+ frag = dataclasses.replace(frag, certified=True)
919
+ _frag.FRAGMENTS[frag.id] = frag
920
+ try:
921
+ self._compose_validate_inner(frag)
922
+ finally:
923
+ if prev is not None: # restore the saved fragment verbatim
924
+ _frag.FRAGMENTS[frag.id] = prev
925
+ else:
926
+ _frag.FRAGMENTS.pop(frag.id, None) # never-saved draft: leave no trace
927
+
928
+ def _compose_validate_inner(self, frag) -> None:
929
+ from ..domain import compose as _compose
930
+ binding = self._resolve_binding(frag)
931
+ if binding is None:
932
+ return
933
+ mode_pick, ok = QInputDialog.getItem(self, "Grading mode",
934
+ "How are students graded?\n\n"
935
+ "• open — any N ≥ the floor passes (the general pattern; the composer's power move)\n"
936
+ "• fixed — exactly this N (a specific, reproducible lab)",
937
+ ["open (student picks N ≥ floor)", "fixed (exactly this N)"], 0, False)
938
+ if not ok:
939
+ return
940
+ mode = "open" if mode_pick.startswith("open") else "fixed"
941
+ try:
942
+ topo, objs = _compose.materialize(binding, mode=mode)
943
+ except _compose.CompositionError as e:
944
+ QMessageBox.warning(self, "Compose ×N", str(e))
945
+ return
946
+ from ..domain import objectives as _obj
947
+ results = _obj.evaluate_all(objs, _obj.TopologyWorld(topo), None) # structural preview
948
+ self._composed_objectives = objs # Certify (after Run) grades THESE, live
949
+ self._load_composed(topo)
950
+ met = sum(1 for r in results if r.status == "met")
951
+ pend = sum(1 for r in results if r.status == "pending")
952
+ unmet = [r.say for r in results if r.status == "unmet"]
953
+ nodes = len(topo.devices)
954
+ gm = ("Open-N — students may build ANY N ≥ the floor; graded by quantifiers."
955
+ if mode == "open" else "Fixed-N — students must match this exact shape.")
956
+ msg = (f"Built a {nodes}-element sample topology on the canvas.\n{gm}\n\n"
957
+ f"{met}/{len(results)} structural objectives met.")
958
+ if pend:
959
+ msg += (f"\n{pend} live check(s) pending — press Run, then Certify to prove reachability "
960
+ f"on the running stack.")
961
+ if unmet:
962
+ msg += "\n\nNot satisfied:\n• " + "\n• ".join(unmet[:8])
963
+ QMessageBox.information(self, "Composition validated", msg)
964
+
965
+ # ------------------------------------------------------------- composer
966
+ def _composer(self) -> None:
967
+ """Build a CONCRETE network from certified fragments and keep it.
968
+
969
+ The counterpart to Validate ×N: that one *tests* whether an authored pattern scales (and
970
+ throws the result away); this one *applies* a non-terminal to real arguments and saves the
971
+ result as a new fragment. Grammar-wise it's a derivation — applying a production rule to
972
+ terminals yields a terminal — so the output is a self-contained block with no open slots,
973
+ which can itself fill another fragment's slot, or be uploaded to the Teaching Center."""
974
+ from ..domain import compose as _compose
975
+ from ..domain import objectives as _obj
976
+
977
+ composable = [f for f in _frag.all_fragments()
978
+ if getattr(f, "certified", False) and not self._is_terminal(f)]
979
+ if not composable:
980
+ QMessageBox.information(self, "Composer",
981
+ "No certified NON-TERMINAL fragments yet.\n\nA composable block is one with open "
982
+ "slots (◆ in the list) — author one with 'Add dependency', certify it, then compose "
983
+ "it here.")
984
+ return
985
+ labels = [f"{f.id} ⟨{', '.join(self._open_slots(f))}⟩" for f in composable]
986
+ pick, ok = QInputDialog.getItem(self, "Composer",
987
+ "Which template do you want to build from?\nIts open slots are shown in ⟨…⟩ — you'll "
988
+ "fill them next:", labels, 0, False)
989
+ if not ok:
990
+ return
991
+ frag = composable[labels.index(pick)]
992
+
993
+ binding = self._resolve_binding(frag) # asks provider + N per slot (recursively)
994
+ if binding is None:
995
+ return
996
+ mode_pick, ok = QInputDialog.getItem(self, "Grading mode",
997
+ "How are students graded on the result?\n\n"
998
+ "• open — any N ≥ the floor passes (the general pattern)\n"
999
+ "• fixed — exactly this N (a specific, reproducible lab)",
1000
+ ["open (student picks N ≥ floor)", "fixed (exactly this N)"], 1, False)
1001
+ if not ok:
1002
+ return
1003
+ mode = "open" if mode_pick.startswith("open") else "fixed"
1004
+ try:
1005
+ topo, objs = _compose.materialize(binding, mode=mode)
1006
+ except _compose.CompositionError as e:
1007
+ QMessageBox.warning(self, "Composer", str(e))
1008
+ return
1009
+
1010
+ new_id, ok = QInputDialog.getText(self, "Name this composition",
1011
+ "Save the built network as a new fragment id:", text=f"{frag.id}-built")
1012
+ if not ok or not new_id.strip():
1013
+ return
1014
+ new_id = _au.slug(new_id)
1015
+
1016
+ self._load_composed(topo) # draw it, tagged by slot
1017
+ results = _obj.evaluate_all(objs, _obj.TopologyWorld(topo), None)
1018
+ rows = [{"id": o.id, "say": o.say, "kind": o.kind, "check": o.check, "probe": o.probe,
1019
+ "level": o.level, "stars": getattr(o, "stars", 0)} for o in objs]
1020
+ provides, requires = _au.derive_contract(self.ctx.topology)
1021
+ d = _au.build_fragment_dict(
1022
+ frag_id=new_id, teaches=frag.teaches, spirit=frag.spirit,
1023
+ summary=(frag.summary or frag.id) + f" — built ({len(topo.devices)} elements)",
1024
+ objectives=rows, provides=provides or None, requires=requires or None,
1025
+ stage=topo.to_dict(), author=self._author)
1026
+ problems = _au.validate_dict(d)
1027
+ if problems:
1028
+ QMessageBox.warning(self, "Composer", "The built network isn't gradable:\n• "
1029
+ + "\n• ".join(problems))
1030
+ return
1031
+ _au.save_fragment(d)
1032
+ _frag.reload()
1033
+ met = sum(1 for r in results if r.status == "met")
1034
+ pend = sum(1 for r in results if r.status == "pending")
1035
+ # it is NOT certified — it has never been run. That gate stays honest.
1036
+ QMessageBox.information(self, "Composition saved",
1037
+ f"Saved '{new_id}' — a TERMINAL block of {len(topo.devices)} elements "
1038
+ f"(no open slots).\n\n{met}/{len(results)} structural objectives met"
1039
+ + (f", {pend} live check(s) pending." if pend else ".")
1040
+ + "\n\nIt is NOT certified yet: press Run, then open it and press Certify to prove it "
1041
+ "live. Then it can fill another fragment's slot or go to the Teaching Center.")
1042
+ self._composed_objectives = None # it's saved now, not a scratch validation
1043
+ self._show_list()
1044
+
1045
+ def _load_composed(self, topo) -> None:
1046
+ """Replace the canvas with a materialized composition, laid out in a simple grid and keeping
1047
+ each device's slot tag so the scaled network is visible and re-gradable."""
1048
+ self.ctx.clear_topology()
1049
+ idmap: dict[str, str] = {}
1050
+ for i, dv in enumerate(topo.devices.values()):
1051
+ col, row = i % 8, i // 8
1052
+ inst = self.ctx.add_device(dv.type_key, x=90 + col * 120, y=90 + row * 120)
1053
+ inst.slot = getattr(dv, "slot", "")
1054
+ inst.slot_source = getattr(dv, "slot_source", "") # keep "what filled this" for labels
1055
+ idmap[dv.id] = inst.id
1056
+ for l in topo.links.values():
1057
+ s, t = idmap.get(l.source_id), idmap.get(l.target_id)
1058
+ if not (s and t):
1059
+ continue
1060
+ try:
1061
+ self.ctx.add_attach(s, t) if l.kind == "attach" else self.ctx.add_link(s, t)
1062
+ except Exception: # noqa: BLE001 — skip an invalid edge
1063
+ pass
1064
+ self._scaffold_ids = set() # a composed board isn't a scaffold
1065
+ self.ctx.bus.topology_changed.emit()
1066
+
1067
+ def _merge_scaffold(self, topo, slot_name: str, col: int = 0) -> None:
1068
+ """Merge a fully-materialized composite provider onto the canvas as a scaffold — every device
1069
+ tagged with `slot_name` (so the delta refers to it as type@slot), laid out in a column so
1070
+ representatives don't overlap. Its links (and rider attachments) come along too.
1071
+
1072
+ `col` is the representative index WITHIN this slot; the slot's own band offset is added here,
1073
+ so a multi-slot fragment (R_net(X, Y, Z)) lays its scaffolds out side by side instead of
1074
+ stacking every slot on top of the first."""
1075
+ idmap: dict[str, str] = {}
1076
+ base_x = 70 + (self._slot_band(slot_name) + col) * 300
1077
+ for i, dv in enumerate(topo.devices.values()):
1078
+ inst = self.ctx.add_device(dv.type_key, x=base_x + (i % 3) * 85, y=70 + (i // 3) * 90)
1079
+ inst.slot = slot_name
1080
+ idmap[dv.id] = inst.id
1081
+ self._scaffold_ids.add(inst.id)
1082
+ for l in topo.links.values():
1083
+ s, t = idmap.get(l.source_id), idmap.get(l.target_id)
1084
+ if not (s and t):
1085
+ continue
1086
+ try:
1087
+ self.ctx.add_attach(s, t) if l.kind == "attach" else self.ctx.add_link(s, t)
1088
+ except Exception: # noqa: BLE001 — skip an invalid edge
1089
+ pass
1090
+
1091
+ @staticmethod
1092
+ def _materializable(f) -> bool:
1093
+ """A provider the assembler can build: a leaf with a saved board, or a composite (has its own
1094
+ slots/peerings, built by derivation)."""
1095
+ return (bool((getattr(f, "stage", None) or {}).get("devices"))
1096
+ or bool(getattr(f, "slots", ())) or bool(getattr(f, "peerings", ())))
1097
+
1098
+ def _resolve_binding(self, frag, seen: frozenset = frozenset()) -> dict | None:
1099
+ """Recursively ask which certified provider (and how many) fills each slot / peer group —
1100
+ descending into composite providers — and return a composition binding, or None if cancelled.
1101
+
1102
+ `seen` carries the chain of fragments already being expanded, so a fragment can never be
1103
+ bound into its own slot (directly or through a cycle A→B→A). That would be a grammar with
1104
+ no base case: the dialog would recurse forever asking to fill the same slot. Genuine
1105
+ recursion (campus → site → lan) is DIFFERENT fragments per level and is unaffected."""
1106
+ chain = seen | {frag.id}
1107
+ bind: dict = {}
1108
+ for S in frag.slots:
1109
+ members = self._pick_members(S.name, S.role, S.min, S.max,
1110
+ cardinal=(S.max != 1), owner=frag.id, seen=chain)
1111
+ if members is None:
1112
+ return None
1113
+ bind.setdefault("bind", {})[S.name] = members
1114
+ for PG in frag.peerings:
1115
+ members = self._pick_members(PG.name, PG.role, PG.min, PG.max,
1116
+ cardinal=True, owner=frag.id, seen=chain)
1117
+ if members is None:
1118
+ return None
1119
+ bind.setdefault("peer", {})[PG.name] = members
1120
+ return {"fragment": frag.id, **bind}
1121
+
1122
+ def _pick_members(self, name, role, mn, mx, cardinal, owner: str = "", seen: frozenset = frozenset()):
1123
+ from ..domain import capabilities as _caps
1124
+ provs = [f for f in _frag.all_fragments()
1125
+ if getattr(f, "certified", False) and _caps.any_satisfies(f.provides, role)
1126
+ and self._materializable(f)
1127
+ and f.id not in seen] # no self-reference / no cycles
1128
+ if not provs:
1129
+ QMessageBox.information(self, "Validate ×N",
1130
+ f"No certified provider fills '{name}' (role {role}).\n\nIt needs a CERTIFIED "
1131
+ f"fragment that provides {role} — and it can't be one already in this composition "
1132
+ f"({', '.join(sorted(seen))}), since a block can't contain itself.")
1133
+ return None
1134
+ names = [f.id for f in provs]
1135
+ # Name the fragment being scaled: this dialog supplies the ARGUMENT to `owner`'s slot — it is
1136
+ # not choosing a new pattern. Without the owner named it reads as "pick a fragment".
1137
+ head = f"Scaling '{owner}' — slot '{name}'" if owner else f"Fill '{name}'"
1138
+ pick, ok = QInputDialog.getItem(self, head,
1139
+ f"'{owner}' needs {role} in its '{name}' slot.\n"
1140
+ f"Which certified fragment goes in each member?", names, 0, False)
1141
+ if not ok:
1142
+ return None
1143
+ n = mn
1144
+ if cardinal:
1145
+ n, ok = QInputDialog.getInt(self, head,
1146
+ f"How many '{pick}' in '{owner}'.{name}?", max(2, mn), mn, mx or 32, 1)
1147
+ if not ok:
1148
+ return None
1149
+ pf = _frag.get(pick)
1150
+ member = pick
1151
+ if pf is not None and (pf.slots or pf.peerings): # composite → resolve its sub-binding once
1152
+ member = self._resolve_binding(pf, seen)
1153
+ if member is None:
1154
+ return None
1155
+ return [member] * n # a uniform group of the chosen provider
1156
+
1157
+ # -- step rows -----------------------------------------------------------
1158
+ def _render_steps(self) -> None:
1159
+ while self._steps_box.count():
1160
+ it = self._steps_box.takeAt(0)
1161
+ w = it.widget()
1162
+ if w is not None:
1163
+ w.setParent(None); w.deleteLater()
1164
+ for i, s in enumerate(self._steps):
1165
+ self._steps_box.addWidget(self._step_row(i, s))
1166
+ self._steps_box.addStretch(1)
1167
+
1168
+ def _step_row(self, i: int, s: dict) -> QWidget:
1169
+ row = QWidget() # flat row — no per-row box (many frames read busy)
1170
+ h = QHBoxLayout(row); h.setContentsMargins(6, 2, 6, 2); h.setSpacing(6)
1171
+ lvl = int(s.get("level", 1))
1172
+ col = _LEVEL_COLOR.get(lvl, "#3B82F6")
1173
+ chip = QPushButton(_LEVEL_SHORT.get(lvl, "L1"))
1174
+ chip.setFixedSize(30, 22)
1175
+ chip.setToolTip(f"{_LEVEL_TIP.get(lvl, '')} — click to change the level")
1176
+ chip.setStyleSheet(
1177
+ f"QPushButton {{ background:{col}; color:white; font-weight:700; border:none; "
1178
+ f"border-radius:6px; }} QPushButton:hover {{ background:{col}; }}")
1179
+ chip.clicked.connect(lambda _=False, idx=i: self._cycle_level(idx))
1180
+ h.addWidget(chip)
1181
+ stars = int(s.get("stars", 0) or 0)
1182
+ star = QToolButton()
1183
+ star.setText("☆" if stars == 0 else "★" * stars)
1184
+ star.setAutoRaise(True)
1185
+ star.setToolTip("Difficulty pass — click to add a star (0 = base pass, ★ = harder pass "
1186
+ "the student unlocks after the base)")
1187
+ star.clicked.connect(lambda _=False, idx=i: self._cycle_stars(idx))
1188
+ h.addWidget(star)
1189
+ body = s.get("check") or s.get("probe") or ""
1190
+ lbl = QLabel(s.get("say", "")); lbl.setToolTip(body)
1191
+ h.addWidget(lbl, 1)
1192
+ for glyph, tip, slot in (("▲", "Move up", lambda _=False, idx=i: self._move(idx, -1)),
1193
+ ("▼", "Move down", lambda _=False, idx=i: self._move(idx, 1)),
1194
+ ("✕", "Delete step", lambda _=False, idx=i: self._del(idx))):
1195
+ b = QToolButton(); b.setText(glyph); b.setAutoRaise(True); b.setToolTip(tip)
1196
+ b.clicked.connect(slot); h.addWidget(b)
1197
+ return row
1198
+
1199
+ def _move(self, i: int, delta: int) -> None:
1200
+ j = i + delta
1201
+ if 0 <= i < len(self._steps) and 0 <= j < len(self._steps):
1202
+ self._steps[i], self._steps[j] = self._steps[j], self._steps[i]
1203
+ self._render_steps()
1204
+
1205
+ def _del(self, i: int) -> None:
1206
+ if 0 <= i < len(self._steps):
1207
+ del self._steps[i]
1208
+ self._render_steps()
1209
+
1210
+ def _cycle_level(self, i: int) -> None:
1211
+ if 0 <= i < len(self._steps):
1212
+ self._steps[i]["level"] = (int(self._steps[i].get("level", 1)) % 4) + 1
1213
+ self._render_steps()
1214
+
1215
+ def _cycle_stars(self, i: int) -> None:
1216
+ if 0 <= i < len(self._steps):
1217
+ self._steps[i]["stars"] = (int(self._steps[i].get("stars", 0) or 0) + 1) % 4 # 0…3 passes
1218
+ self._render_steps()
1219
+
1220
+ # -- ports (In / Out from attached Sources & Sinks) ----------------------
1221
+ def _canvas_riders(self):
1222
+ """Attached riders on the canvas, split (sources, sinks) — the fragment's In / Out ports."""
1223
+ from ..domain.connection_rules import is_rider
1224
+ srcs, snks = [], []
1225
+ for d in self.ctx.topology.devices.values():
1226
+ if not is_rider(d.type_key) or self.ctx.topology.donor_of(d.id) is None:
1227
+ continue
1228
+ (srcs if getattr(d.type, "role", "") == "source" else snks).append(d)
1229
+ return srcs, snks
1230
+
1231
+ def _has_check(self, rider_type: str) -> bool:
1232
+ return any(s.get("key", "").startswith(f"measure:{rider_type}|") for s in self._steps)
1233
+
1234
+ def _render_ports(self) -> None:
1235
+ while self._ports_box.count():
1236
+ it = self._ports_box.takeAt(0)
1237
+ w = it.widget()
1238
+ if w is not None:
1239
+ w.setParent(None); w.deleteLater()
1240
+ srcs, snks = self._canvas_riders()
1241
+ self._ports_box.addWidget(QLabel("<b>Ports</b>"))
1242
+ if not srcs and not snks:
1243
+ self._ports_box.addWidget(QLabel(
1244
+ "No Sources/Sinks attached. Drop a probe on an element to add an input or output "
1245
+ "port — its measurement becomes a gradable output.", objectName="Faint"))
1246
+ return
1247
+ for title, group in (("In · sources", srcs), ("Out · sinks", snks)):
1248
+ if not group:
1249
+ continue
1250
+ self._ports_box.addWidget(QLabel(title, objectName="Faint"))
1251
+ for d in group:
1252
+ self._ports_box.addWidget(self._port_row(d))
1253
+ # the composition contract, auto-derived from the board (read-only — never typed)
1254
+ provides, requires = _au.derive_contract(self.ctx.topology)
1255
+ if provides or requires:
1256
+ prov = ", ".join(provides) or "—"
1257
+ req = ", ".join(requires) or "—"
1258
+ lbl = QLabel(f"Contract (auto): provides {prov} · requires {req}")
1259
+ lbl.setObjectName("Faint"); lbl.setWordWrap(True)
1260
+ self._ports_box.addWidget(lbl)
1261
+
1262
+ def _port_row(self, d) -> QWidget:
1263
+ row = QWidget()
1264
+ h = QHBoxLayout(row); h.setContentsMargins(6, 1, 6, 1); h.setSpacing(6)
1265
+ donor = self.ctx.topology.donor_of(d.id)
1266
+ tick = "✓ " if self._has_check(d.type_key) else ""
1267
+ h.addWidget(QLabel(f"{tick}{d.type.label} on {donor.name if donor else '?'}"), 1)
1268
+ if _riders.metrics_for(d.type_key):
1269
+ b = QToolButton(); b.setText("+ check"); b.setAutoRaise(True)
1270
+ b.setToolTip("Assert on this rider's measurement — its output check")
1271
+ b.clicked.connect(lambda _=False, tk=d.type_key: self._add_output_check(tk))
1272
+ h.addWidget(b)
1273
+ return row
1274
+
1275
+ def _add_output_check(self, rider_type: str) -> None:
1276
+ metrics = _riders.metrics_for(rider_type)
1277
+ if not metrics:
1278
+ return
1279
+ labels = [lbl for _, lbl in metrics]
1280
+ keys = [k for k, _ in metrics]
1281
+ mi, ok = QInputDialog.getItem(self, "Output check", "Measure:", labels, 0, False)
1282
+ if not ok:
1283
+ return
1284
+ metric = keys[labels.index(mi)]
1285
+ op, ok = QInputDialog.getItem(self, "Output check", "Condition:",
1286
+ [">=", "<=", ">", "<", "=="], 0, False)
1287
+ if not ok:
1288
+ return
1289
+ val, ok = QInputDialog.getDouble(self, "Output check", "Threshold:", 1.0, -1e9, 1e9, 2)
1290
+ if not ok:
1291
+ return
1292
+ obj = _au.output_check(rider_type, metric, op, val)
1293
+ self._steps = [s for s in self._steps if s.get("key") != obj["key"]] # replace same metric
1294
+ self._steps.append(obj)
1295
+ self._render_steps()
1296
+ self._render_ports()
1297
+
1298
+ # -- add live / fork -----------------------------------------------------
1299
+ def _canvas_types(self) -> list[str]:
1300
+ return sorted({d.type_key for d in self.ctx.topology.devices.values()})
1301
+
1302
+ def _canvas_specs(self) -> list[str]:
1303
+ """Distinct type specs on the canvas, slot-scoped where tagged: host, switch@A, host@B, …
1304
+ This is what lets a live check target a specific slot — reach(host@A → host@B)."""
1305
+ specs = set()
1306
+ for d in self.ctx.topology.devices.values():
1307
+ if getattr(d.type, "rider", False):
1308
+ continue
1309
+ slot = getattr(d, "slot", "")
1310
+ specs.add(f"{d.type_key}@{slot}" if slot else d.type_key)
1311
+ return sorted(specs)
1312
+
1313
+ def _add_live(self) -> None:
1314
+ from ..domain import devices as _dev
1315
+ specs = self._canvas_specs()
1316
+ if not specs:
1317
+ QMessageBox.information(self, "Add a live check",
1318
+ "Put the elements on the canvas first — a live check is expressed "
1319
+ "on the fragment's own elements.")
1320
+ return
1321
+
1322
+ def label(spec: str) -> str:
1323
+ tk, _, slot = spec.partition("@")
1324
+ return f"{_dev.get(tk).label}{(' · slot ' + slot) if slot else ''} ({spec})"
1325
+
1326
+ disp = {label(s): s for s in specs}
1327
+ labels = list(disp)
1328
+ src, ok = QInputDialog.getItem(self, "Add a live check", "Source (on the canvas):",
1329
+ labels, 0, False)
1330
+ if not ok:
1331
+ return
1332
+ dst, ok = QInputDialog.getItem(self, "Add a live check", "Destination (on the canvas):",
1333
+ labels, 0, False)
1334
+ if not ok:
1335
+ return
1336
+ expect, ok = QInputDialog.getItem(self, "Add a live check", "Expected:",
1337
+ ["reaches it", "cannot reach it"], 0, False)
1338
+ if not ok:
1339
+ return
1340
+ self._steps.append(_au.live_check(disp[src], disp[dst], expect.startswith("reaches")))
1341
+ self._render_steps()
1342
+
1343
+ # -- finalize ------------------------------------------------------------
1344
+ def _current_dict(self) -> dict | None:
1345
+ """The authored fragment as a dict, from the current editor state (or None if not ready)."""
1346
+ raw = self.fid.text().strip()
1347
+ if not raw:
1348
+ QMessageBox.warning(self, "Fragment", "Give the fragment an id first."); return None
1349
+ if not self._steps:
1350
+ QMessageBox.warning(self, "Fragment", "Add at least one step first."); return None
1351
+ # auto-derive the contract from the DELTA (scaffold excluded); the slots ARE the requires
1352
+ # (one per named dependency), so requires = slot roles + any grammar needs of the delta.
1353
+ provides, requires = _au.derive_contract(self.ctx.topology, exclude=self._scaffold_ids)
1354
+ requires = sorted(set(requires) | {s["role"] for s in self._slots}
1355
+ | {p["role"] for p in self._peerings})
1356
+ # a standalone fragment carries its authoring board; a dependent one (built on a scaffold)
1357
+ # doesn't — its board only makes sense once the dependency is composed back in.
1358
+ # Save the FULL authoring board — including any scaffold dependencies and their slot tags —
1359
+ # so reloading a slot-based fragment restores exactly what was certified (the slot-scoped
1360
+ # predicates need the slotted devices present to re-grade). The shipped delta is still
1361
+ # scaffold-excluded via `_scaffold_ids`; `stage` is author-side metadata the composer ignores.
1362
+ stage = self.ctx.topology.to_dict()
1363
+ return _au.build_fragment_dict(
1364
+ frag_id=_au.slug(raw), teaches=self.teaches.text().strip(),
1365
+ summary=self.summary.text().strip(), spirit=self.spirit.text().strip(),
1366
+ objectives=self._steps, forks=self._forks or None,
1367
+ provides=provides or None, requires=requires or None, slots=self._slots or None,
1368
+ peerings=self._peerings or None,
1369
+ stage=stage if (stage and stage.get("devices")) else None, author=self._author)
1370
+
1371
+ def _finalize(self) -> None:
1372
+ # GUARD: a validation instance is on the canvas, and Save snapshots the canvas as this
1373
+ # fragment's authoring board — saving now would replace the small authored pattern with the
1374
+ # scaled instance and corrupt the fragment. Reopen to get the pattern back.
1375
+ if getattr(self, "_composed_objectives", None):
1376
+ QMessageBox.information(
1377
+ self, "Not while a validation is showing",
1378
+ "The canvas is holding a Validate ×N instance, not this fragment's authoring board.\n\n"
1379
+ "Saving now would overwrite the authored pattern with the scaled copy. Go Back to "
1380
+ "list and reopen the fragment to restore its board, then Save.\n\n"
1381
+ "(To KEEP a composition as its own block, use the Composer in the icon rail.)")
1382
+ return
1383
+ raw = self.fid.text().strip()
1384
+ if not raw:
1385
+ QMessageBox.warning(self, "Save", "Give the fragment an id."); return
1386
+ if not self._steps:
1387
+ QMessageBox.warning(self, "Save", "Add at least one step."); return
1388
+ # no dangling output: a Sink with no output check can't be graded — offer to fix first
1389
+ _, snks = self._canvas_riders()
1390
+ dangling = [d for d in snks if not self._has_check(d.type_key)]
1391
+ if dangling:
1392
+ names = ", ".join(dict.fromkeys(d.type.label for d in dangling))
1393
+ if QMessageBox.question(
1394
+ self, "Dangling output",
1395
+ f"These Sinks have no output check, so nothing is graded on their output: "
1396
+ f"{names}.\nSave anyway? (Cancel to add a + check in the Ports panel.)"
1397
+ ) != QMessageBox.Yes:
1398
+ return
1399
+ fid = _au.slug(raw)
1400
+ if fid != raw and not self._editing_id:
1401
+ self.fid.setText(fid)
1402
+ self.rec_note.setText(f"Id normalized to '{fid}' (no spaces/punctuation in ids).")
1403
+ d = self._current_dict()
1404
+ if d is None:
1405
+ return
1406
+ # carry the runtime-certification stamp only if THIS exact content was certified green
1407
+ d["certified"] = (self._certified_hash is not None
1408
+ and self._dict_hash(d) == self._certified_hash)
1409
+ problems = _au.validate_dict(d)
1410
+ if problems:
1411
+ QMessageBox.warning(self, "Not gradable", "; ".join(problems)); return
1412
+ _au.save_fragment(d)
1413
+ _frag.reload()
1414
+ QMessageBox.information(self, "Saved", f"Fragment '{fid}' saved to ~/.gini/content.")
1415
+ self._show_list()