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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- 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()
|