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
gini/domain/compose.py ADDED
@@ -0,0 +1,413 @@
1
+ """Slot-aware composition — the deterministic assembler that scales primitives into larger topologies.
2
+
3
+ This is Phase 3: given a dependent fragment (e.g. a hub router) whose slots are *cardinal* (a slot
4
+ that binds to N distinct providers, `min..max`), plus a BINDING that says which certified provider
5
+ fills each slot, the assembler MATERIALIZES one concrete topology and the EXPANDED objective set that
6
+ grades it — then the oracle runs it. No LLM here: composition is a derivation, graded by the runtime.
7
+
8
+ How it scales without new grammar:
9
+ * each cardinal slot expands to N distinct LABELS (`lans0, lans1, …`);
10
+ * a provider is materialized per label from its faithful certified board (its saved `stage`), and
11
+ every device it contributes is tagged with that label — so `switch@lans2` means "the switch of
12
+ LAN #2", reusing the exact `type@slot` oracle Phase 1 already proved;
13
+ * the dependent fragment's own delta (the router) is placed once, unlabelled;
14
+ * a `@<cardinal>` predicate on the dependent fragment expands to one objective PER label
15
+ (forall — e.g. the router wires to every LAN), and a same-slot behavioral `reach(host@s -> host@s)`
16
+ expands to cross-member pairs (forall-pairs — every LAN reaches every other).
17
+
18
+ A binding:
19
+ {"fragment": "hub-router", "bind": {"lans": ["cap-lan", "cap-lan", "cap-lan", "cap-lan"]}}
20
+ A provider entry may itself be a nested binding `{"fragment": ..., "bind": {...}}` for recursion.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ import re
26
+
27
+ from . import devices as _devices
28
+ from . import fragments as _frag
29
+ from .objectives import Objective, _TYPE_ARG_FUNCS
30
+ from .topology import Topology
31
+
32
+ _SLOT_RE = re.compile(r"@(\w+)")
33
+ MAX_DEPTH = 6 # recursion budget — a composition can't nest forever
34
+ ROOT = "root" # label of the top fragment's own delta (never a member prefix)
35
+
36
+
37
+ class CompositionError(ValueError):
38
+ pass
39
+
40
+
41
+ # ---- predicate rewriting (structural checks are python-parseable) ---------- #
42
+ def _suffix_plain(expr: str, label: str) -> str:
43
+ """Scope every PLAIN type-arg to `@label` (a bare `host` becomes `host@label`). Args already
44
+ slot-scoped are left alone. Used when a provider's own objectives are placed under a label."""
45
+ if not expr or not label:
46
+ return expr
47
+ tree = ast.parse(expr, mode="eval")
48
+
49
+ class T(ast.NodeTransformer):
50
+ def visit_Call(self, node): # noqa: N802
51
+ self.generic_visit(node)
52
+ if isinstance(node.func, ast.Name) and node.func.id in _TYPE_ARG_FUNCS:
53
+ node.args = [
54
+ ast.BinOp(a, ast.MatMult(), ast.Name(label, ast.Load()))
55
+ if isinstance(a, ast.Name) else a
56
+ for a in node.args]
57
+ return node
58
+
59
+ return ast.unparse(T().visit(tree).body)
60
+
61
+
62
+ def _rebind(expr: str, slot: str, label: str) -> str:
63
+ """Rebind a specific cardinal reference: `switch@lans` -> `switch@lans2`."""
64
+ if not expr:
65
+ return expr
66
+ tree = ast.parse(expr, mode="eval")
67
+
68
+ class T(ast.NodeTransformer):
69
+ def visit_BinOp(self, node): # noqa: N802
70
+ self.generic_visit(node)
71
+ if (isinstance(node.op, ast.MatMult) and isinstance(node.right, ast.Name)
72
+ and node.right.id == slot):
73
+ node.right = ast.Name(label, ast.Load())
74
+ return node
75
+
76
+ return ast.unparse(T().visit(tree).body)
77
+
78
+
79
+ def _slots_in(text: str) -> set[str]:
80
+ return set(_SLOT_RE.findall(text or ""))
81
+
82
+
83
+ def _placements(check: str) -> list[tuple[str, int]]:
84
+ """(type, count) for a PLAIN `exists`/`count` placement — for building the delta's devices.
85
+ Objectives are single predicates, so `count(T) >= n` yields n and `exists(T)` yields 1."""
86
+ try:
87
+ top = ast.parse(check or "", mode="eval").body
88
+ except SyntaxError:
89
+ return []
90
+ if (isinstance(top, ast.Compare) and isinstance(top.left, ast.Call)
91
+ and isinstance(top.left.func, ast.Name) and top.left.func.id == "count"
92
+ and top.left.args and isinstance(top.left.args[0], ast.Name)
93
+ and isinstance(top.comparators[0], ast.Constant)):
94
+ return [(top.left.args[0].id, int(top.comparators[0].value))]
95
+ out: list[tuple[str, int]] = []
96
+ for node in ast.walk(top):
97
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
98
+ and node.func.id == "exists" and node.args and isinstance(node.args[0], ast.Name)):
99
+ out.append((node.args[0].id, 1))
100
+ return out
101
+
102
+
103
+ # ---- materialization ------------------------------------------------------- #
104
+ def _is_rider(type_key: str) -> bool:
105
+ dt = _devices.get(type_key)
106
+ return bool(dt and getattr(dt, "rider", False))
107
+
108
+
109
+ def _instantiate_stage(topo: Topology, stage: dict, label: str, source: str = "") -> None:
110
+ """Rebuild a provider's saved board into `topo`, tagging every (non-rider) device with `label`.
111
+ Reproduces the exact certified structure (all hosts wired to the switch, etc.).
112
+
113
+ `source` records WHICH fragment was instantiated here — provenance only (no predicate reads it),
114
+ so a composed board can say "these came from cap-lan" rather than leaving the reader to guess."""
115
+ idmap: dict[str, str] = {}
116
+ for d in stage.get("devices", []) or []:
117
+ if _is_rider(d.get("type_key", "")):
118
+ continue # ports, not structure
119
+ inst = topo.add_device(d["type_key"])
120
+ inst.slot = label
121
+ inst.slot_source = source
122
+ idmap[d["id"]] = inst.id
123
+ for l in stage.get("links", []) or []:
124
+ if l.get("kind") == "attach":
125
+ continue
126
+ s, t = idmap.get(l.get("source_id")), idmap.get(l.get("target_id"))
127
+ if s and t:
128
+ topo.add_link(s, t)
129
+
130
+
131
+ def _link_exists(topo: Topology, a_id: str, b_id: str) -> bool:
132
+ return any({l.source_id, l.target_id} == {a_id, b_id} for l in topo.links.values())
133
+
134
+
135
+ def _connect_via(F) -> str:
136
+ """The device type external consumers link to — a provider's connection point. Explicit
137
+ `connect_via` wins; else inferred: a composite's is the source of its own slot-links (its
138
+ gateway, e.g. `router`); a leaf's is the structural hub of its board (first non-host device,
139
+ e.g. `switch`). This is what lets a `network` slot bind a LAN (→switch) OR a routed net (→router)."""
140
+ cv = getattr(F, "connect_via", "")
141
+ if cv:
142
+ return cv
143
+ for t in F.objectives: # composite → its gateway
144
+ m = re.match(r"^\s*link\((\w+)\s*,\s*\w+@\w+\)\s*$", t.check or "")
145
+ if m:
146
+ return m.group(1)
147
+ for d in (getattr(F, "stage", None) or {}).get("devices", []) or []: # leaf → structural hub
148
+ tk = d.get("type_key", "")
149
+ if tk and tk != "host" and not _is_rider(tk):
150
+ return tk
151
+ return ""
152
+
153
+
154
+ def _conn_point(topo: Topology, type_key: str, member_path: str):
155
+ """A member's own top device of `type_key` — the shallowest one under the member's path (its
156
+ delta / hub), never a device buried deeper in a nested sub-composition."""
157
+ from .objectives import slot_match
158
+ cands = [d for d in topo.devices.values()
159
+ if d.type_key == type_key and slot_match(d.slot, member_path)]
160
+ return min(cands, key=lambda d: d.slot.count("_"), default=None)
161
+
162
+
163
+ def _build(binding: dict, topo: Topology, out: list[Objective], path: str,
164
+ is_provider: bool, depth: int = 0) -> None:
165
+ if depth > MAX_DEPTH:
166
+ raise CompositionError(f"composition nests deeper than {MAX_DEPTH} levels")
167
+ F = _frag.get(binding.get("fragment", ""))
168
+ if F is None:
169
+ raise CompositionError(f"unknown fragment: {binding.get('fragment')!r}")
170
+
171
+ # A LEAF provider (a certified board, no slots) is reproduced faithfully, every device tagged
172
+ # with this member's path.
173
+ if is_provider and getattr(F, "stage", None) and F.stage.get("devices") and not F.slots:
174
+ _instantiate_stage(topo, F.stage, path, source=F.id)
175
+ for t in F.objectives:
176
+ # A provider's OWN live/output checks (reach, measure on its riders) are proven by its
177
+ # certificate — we trust it, we don't re-run them here. (The composer strips riders, so a
178
+ # `measure(packet_view…)` couldn't pass anyway.) Only its STRUCTURAL shape is re-checked.
179
+ if t.kind == "behavioral":
180
+ continue
181
+ out.append(Objective(id=f"{path}-{t.id}", say=t.say, kind=t.kind,
182
+ check=_suffix_plain(t.check, path), probe="",
183
+ level=t.level, stars=t.stars))
184
+ return
185
+
186
+ # Otherwise DERIVE (the top fragment, or a composite provider). Its own delta is tagged
187
+ # `path_self` — a leaf segment no member shares, so a `type@path_self` scope never bleeds into
188
+ # the sub-networks it contains (crucial when delta and members share a type, e.g. router↔router).
189
+ own = f"{path}_self"
190
+ delta: dict[str, list[str]] = {}
191
+ for t in F.objectives:
192
+ if _slots_in(t.check) or _slots_in(t.probe):
193
+ continue
194
+ for typ, n in _placements(t.check):
195
+ for _ in range(n):
196
+ d = topo.add_device(typ)
197
+ d.slot = own
198
+ delta.setdefault(typ, []).append(d.id)
199
+
200
+ slotmap: dict[str, list[tuple[str, str]]] = {} # slot -> [(member_path, connect_via_type)]
201
+ for S in F.slots:
202
+ provs = (binding.get("bind") or {}).get(S.name) or []
203
+ if len(provs) < S.min or (S.max and S.max > 0 and len(provs) > S.max):
204
+ raise CompositionError(
205
+ f"slot {S.name!r} takes {S.min}..{S.max or '∞'} providers, got {len(provs)}")
206
+ members: list[tuple[str, str]] = []
207
+ for i, pv in enumerate(provs):
208
+ mp = f"{path}_{S.name}{i}"
209
+ child = pv if isinstance(pv, dict) else {"fragment": pv}
210
+ _build(child, topo, out, mp, is_provider=True, depth=depth + 1)
211
+ members.append((mp, _connect_via(_frag.get(child["fragment"]))))
212
+ slotmap[S.name] = members
213
+
214
+ # LATERAL peer groups (Phase 5): members interconnect as peers per a topology (mesh/ring/…),
215
+ # rather than each linking back to the delta. Meshes/graphs of routers live here.
216
+ for PG in getattr(F, "peerings", ()):
217
+ provs = (binding.get("peer") or {}).get(PG.name) or []
218
+ if len(provs) < PG.min or (PG.max and PG.max > 0 and len(provs) > PG.max):
219
+ raise CompositionError(
220
+ f"peering {PG.name!r} takes {PG.min}..{PG.max or '∞'} peers, got {len(provs)}")
221
+ members = []
222
+ for i, pv in enumerate(provs):
223
+ mp = f"{path}_{PG.name}{i}"
224
+ child = pv if isinstance(pv, dict) else {"fragment": pv}
225
+ _build(child, topo, out, mp, is_provider=True, depth=depth + 1)
226
+ members.append((mp, _connect_via(_frag.get(child["fragment"]))))
227
+ _wire_peering(topo, out, members, PG.topology, PG.name)
228
+ slotmap[PG.name] = members # @name reach objectives expand all-pairs
229
+
230
+ for t in F.objectives:
231
+ refs = _slots_in(t.check) | _slots_in(t.probe)
232
+ if is_provider and t.kind == "behavioral":
233
+ continue # a bound provider's live checks: trusted
234
+ if not (refs & set(slotmap)):
235
+ out.append(Objective(id=f"{path}-{t.id}", say=t.say, kind=t.kind,
236
+ check=_suffix_plain(t.check, own),
237
+ probe=_suffix_plain(t.probe, own), level=t.level, stars=t.stars))
238
+ _wire_plain_link(topo, t.check, delta, own)
239
+ continue
240
+ slot = next(iter(refs & set(slotmap)))
241
+ if t.kind == "behavioral":
242
+ out.extend(_expand_behavioral(t, slot, [mp for mp, _ in slotmap[slot]]))
243
+ else:
244
+ out.extend(_expand_structural(t, slot, slotmap[slot], topo, delta, own))
245
+
246
+
247
+ def _wire_plain_link(topo: Topology, check: str, delta: dict, own: str) -> None:
248
+ """`link(a, b)` within a node's own delta → wire the a's to the first b (star for repeats)."""
249
+ m = re.match(r"^\s*link\((\w+)\s*,\s*(\w+)\)\s*$", check or "")
250
+ if not m:
251
+ return
252
+ a_ids = delta.get(m.group(1), [])
253
+ b = _conn_point(topo, m.group(2), own) or topo.devices.get((delta.get(m.group(2)) or [None])[0])
254
+ if not a_ids or b is None:
255
+ return
256
+ for aid in a_ids:
257
+ if aid != b.id and not _link_exists(topo, aid, b.id):
258
+ topo.add_link(aid, b.id)
259
+
260
+
261
+ def _expand_structural(t, slot: str, members: list[tuple[str, str]], topo: Topology,
262
+ delta: dict, own: str) -> list[Objective]:
263
+ """forall members: one objective per member. A `link(a, b@slot)` wires this node's delta `a` to
264
+ each member's connection point (its `connect_via` type — a LAN's switch, a routed net's router)."""
265
+ objs: list[Objective] = []
266
+ link_m = re.match(r"^\s*link\((\w+)\s*,\s*(\w+)@" + re.escape(slot) + r"\)\s*$", t.check or "")
267
+ for mp, cv in members:
268
+ if link_m:
269
+ a, attach = link_m.group(1), (cv or link_m.group(2))
270
+ objs.append(Objective(id=f"{t.id}-{mp}", say=f"{t.say} [{mp}]", kind=t.kind,
271
+ check=f"link({a}@{own}, {attach}@{mp})", level=t.level, stars=t.stars))
272
+ adev = topo.devices.get((delta.get(a) or [None])[0])
273
+ bdev = _conn_point(topo, attach, mp)
274
+ if adev is not None and bdev is not None and not _link_exists(topo, adev.id, bdev.id):
275
+ topo.add_link(adev.id, bdev.id)
276
+ else:
277
+ objs.append(Objective(id=f"{t.id}-{mp}", say=f"{t.say} [{mp}]", kind=t.kind,
278
+ check=_rebind(_suffix_plain(t.check, own), slot, mp),
279
+ level=t.level, stars=t.stars))
280
+ return objs
281
+
282
+
283
+ def _topology_pairs(topology: str, n: int) -> list[tuple[int, int]]:
284
+ """Which member index pairs a peering interconnects, by shape."""
285
+ if n < 2:
286
+ return []
287
+ topo = (topology or "mesh").lower()
288
+ if topo == "ring":
289
+ return [(0, 1)] if n == 2 else [(i, (i + 1) % n) for i in range(n)]
290
+ if topo == "line":
291
+ return [(i, i + 1) for i in range(n - 1)]
292
+ if topo == "star":
293
+ return [(0, i) for i in range(1, n)] # member 0 is the hub
294
+ return [(i, j) for i in range(n) for j in range(i + 1, n)] # mesh: every pair
295
+
296
+
297
+ def _wire_peering(topo: Topology, out: list[Objective], members: list[tuple[str, str]],
298
+ topology: str, name: str) -> None:
299
+ """Interconnect peer members per `topology`, linking each pair's connection points, and emit a
300
+ structural objective per peered pair so the graph is gradable."""
301
+ for i, j in _topology_pairs(topology, len(members)):
302
+ (mpi, cvi), (mpj, cvj) = members[i], members[j]
303
+ a, b = _conn_point(topo, cvi, mpi), _conn_point(topo, cvj, mpj)
304
+ if a is not None and b is not None and not _link_exists(topo, a.id, b.id):
305
+ topo.add_link(a.id, b.id)
306
+ out.append(Objective(id=f"peer-{name}-{i}-{j}", say=f"{name}: peers {i}↔{j} interconnected",
307
+ kind="structural", check=f"link({cvi}@{mpi}, {cvj}@{mpj})", level=2))
308
+
309
+
310
+ def _expand_behavioral(t, slot: str, member_paths: list[str]) -> list[Objective]:
311
+ """forall-pairs: a same-slot `reach(x@s -> y@s)` becomes one probe per distinct member pair."""
312
+ objs: list[Objective] = []
313
+ for i in range(len(member_paths)):
314
+ for j in range(i + 1, len(member_paths)):
315
+ probe = _rebind_probe(t.probe, slot, member_paths[i], member_paths[j])
316
+ objs.append(Objective(id=f"{t.id}-{member_paths[i]}-{member_paths[j]}",
317
+ say=f"{t.say} [{member_paths[i]}↔{member_paths[j]}]", kind="behavioral",
318
+ check="", probe=probe, level=t.level, stars=t.stars))
319
+ return objs
320
+
321
+
322
+ def _rebind_probe(probe: str, slot: str, la: str, lb: str) -> str:
323
+ """Rebind the two same-slot operands of a probe to a specific member pair: the first `@slot`
324
+ occurrence becomes `@la`, the second `@lb` (a probe has src then dst)."""
325
+ parts = probe.split(f"@{slot}") # template carries the bare slot name twice
326
+ if len(parts) == 3:
327
+ return parts[0] + f"@{la}" + parts[1] + f"@{lb}" + parts[2]
328
+ return probe.replace(f"@{slot}", f"@{la}") # single-operand fallback
329
+
330
+
331
+ def _leaf_type(F) -> str:
332
+ """A provider's 'leaf' element — the endpoints its hub connects (a LAN's `host`). Inferred as a
333
+ non-hub, non-rider device type in its board."""
334
+ hub = _connect_via(F)
335
+ for d in (getattr(F, "stage", None) or {}).get("devices", []) or []:
336
+ tk = d.get("type_key", "")
337
+ if tk and tk != hub and not _is_rider(tk):
338
+ return tk
339
+ return ""
340
+
341
+
342
+ def _open_objectives(binding: dict) -> list[Objective]:
343
+ """The OPEN-N win condition — quantified and N-INDEPENDENT, so a student may build ANY number of
344
+ members ≥ the slot's floor and still pass. Instead of expanding to concrete `@lans2` labels we
345
+ emit: `count(hub) >= K` (the floor), `all_linked(hub, delta)` / `all_linked(leaf, hub)` (every
346
+ member wired), and `reach(x -> y, all)` (every pair reachable). This is the generalizable pattern
347
+ the AI composer plans against; the concrete board materialized alongside is just a sample."""
348
+ F = _frag.get(binding.get("fragment", ""))
349
+ if F is None:
350
+ raise CompositionError(f"unknown fragment: {binding.get('fragment')!r}")
351
+ objs: list[Objective] = []
352
+ for t in F.objectives: # delta placements stay as-is (exists router)
353
+ if not (_slots_in(t.check) or _slots_in(t.probe)):
354
+ objs.append(Objective(id=t.id, say=t.say, kind=t.kind, check=t.check, probe=t.probe,
355
+ level=t.level, stars=t.stars))
356
+
357
+ groups = [(S.name, S.min, (binding.get("bind") or {}).get(S.name)) for S in F.slots]
358
+ groups += [(P.name, P.min, (binding.get("peer") or {}).get(P.name)) for P in getattr(F, "peerings", ())]
359
+ for name, k, provs in groups:
360
+ if not provs:
361
+ continue
362
+ first = provs[0]
363
+ pf = _frag.get(first["fragment"] if isinstance(first, dict) else first)
364
+ if pf is None:
365
+ continue
366
+ hub = _connect_via(pf)
367
+ # A composite member (a routed-network with its own LANs) has its internal structure proven
368
+ # by its certificate — we don't re-assert "leaf on hub" for it. Only a LEAF provider (a LAN:
369
+ # hosts directly on a switch) gets the `all_linked(host, switch)` check.
370
+ composite = bool(getattr(pf, "slots", ()) or getattr(pf, "peerings", ()))
371
+ leaf = "" if composite else _leaf_type(pf)
372
+ objs.append(Objective(id=f"open-count-{name}", say=f"at least {k} {hub}(s) in {name}",
373
+ kind="structural", check=f"count({hub}) >= {k}", level=1))
374
+ for t in F.objectives: # every member wired to the delta it links
375
+ m = re.match(r"^\s*link\((\w+)\s*,\s*(\w+)@" + re.escape(name) + r"\)\s*$", t.check or "")
376
+ if m:
377
+ objs.append(Objective(id=f"open-wire-{name}", say=f"every {hub} wired to the {m.group(1)}",
378
+ kind="structural", check=f"all_linked({hub}, {m.group(1)})",
379
+ level=2))
380
+ if leaf and leaf != hub:
381
+ objs.append(Objective(id=f"open-leaf-{name}", say=f"every {leaf} on a {hub}",
382
+ kind="structural", check=f"all_linked({leaf}, {hub})", level=2))
383
+ for t in F.objectives: # cross-member reach → every pair (all)
384
+ if t.kind == "behavioral" and name in _slots_in(t.probe):
385
+ probe = re.sub(r"@\w+", "", t.probe) # strip slot scopes
386
+ probe = re.sub(r"\)\s*==", ", all) ==", probe, count=1)
387
+ objs.append(Objective(id=f"open-reach-{name}", say=t.say + " (all pairs, any N)",
388
+ kind="behavioral", probe=probe, level=4))
389
+ return objs
390
+
391
+
392
+ def materialize(binding: dict, mode: str = "fixed") -> tuple[Topology, list[Objective]]:
393
+ """Assemble the concrete topology + objective ladder. `mode='fixed'` bakes this exact N (a
394
+ specific lab); `mode='open'` grades a quantified, N-independent pattern (student picks N ≥ floor).
395
+ Either way a concrete board is materialized — in open mode it's a representative sample."""
396
+ topo = Topology()
397
+ out: list[Objective] = []
398
+ _build(binding, topo, out, path=ROOT, is_provider=False)
399
+ from .objectives import by_level
400
+ if mode == "open":
401
+ out = _open_objectives(binding)
402
+ return topo, by_level(out)
403
+
404
+
405
+ def grade(binding: dict, runner=None, mode: str = "fixed"):
406
+ """Materialize + grade a composition against the oracle. `runner` supplies behavioral verdicts
407
+ (a live DockerProbeRunner on the Mac, a FakeRunner headless). `mode` picks fixed-N or open-N
408
+ grading. Returns (topology, results)."""
409
+ from . import objectives as _obj
410
+ from . import probes as _probes
411
+ topo, objs = materialize(binding, mode=mode)
412
+ r = _probes.TypeRunner(runner, lambda: topo) if runner is not None else None
413
+ return topo, _obj.evaluate_all(objs, _obj.TopologyWorld(topo), r)
@@ -0,0 +1,88 @@
1
+ """Composition-by-reference — how the Teaching Center distributes missions (M4).
2
+
3
+ The agreed model: **foundational fragments are local** (bound to the local oracle's predicates); the
4
+ Center never ships new primitives. It ships **compositions** — a small spec that REFERENCES local
5
+ fragment ids plus framing (genre/level/title/brief), which the engine assembles locally against its
6
+ trusted primitives. This guarantees gradability (every objective comes from a verified local fragment)
7
+ and needs a **version/existence check**: a referenced fragment id (or capability role) that isn't
8
+ installed fails gracefully instead of mis-grading.
9
+
10
+ A composition spec (YAML/JSON):
11
+
12
+ id: hw1
13
+ fragments: [basic-lan, service-chain] # local fragment ids
14
+ genre: expedition # optional
15
+ level: 2 # optional
16
+ title: "Homework 1" # optional reskin
17
+ brief: "Bridge two LANs to the Internet through a firewall."
18
+
19
+ An *escape hatch* is allowed: a spec with inline `objectives` (no `fragments`) is a self-contained
20
+ lesson — but its predicates are still bounded by what the local oracle can evaluate (validation).
21
+ """
22
+ from __future__ import annotations
23
+
24
+
25
+ class CompositionError(ValueError):
26
+ pass
27
+
28
+
29
+ def missing_refs(spec: dict) -> list[str]:
30
+ """Referenced things the local install doesn't have — the version/existence check. Returns tags
31
+ like 'fragment:<id>' / 'role:<name>'. Empty = the composition can be assembled here."""
32
+ from . import capabilities as _caps
33
+ from . import fragments as _frag
34
+ miss: list[str] = []
35
+ for fid in spec.get("fragments", []) or []:
36
+ if _frag.get(fid) is None:
37
+ miss.append(f"fragment:{fid}")
38
+ for role in spec.get("requires_roles", []) or []: # optional explicit role requirements
39
+ if not _caps.is_role(role):
40
+ miss.append(f"role:{role}")
41
+ return miss
42
+
43
+
44
+ def from_composition(spec: dict, *, lesson_id: str | None = None):
45
+ """Assemble a Lesson from a composition spec. Raises CompositionError if it references anything
46
+ not installed locally (the version/existence guard)."""
47
+ from . import assembly as _assembly
48
+ from . import lesson as _lesson
49
+ lid = lesson_id or spec.get("id") or "composed"
50
+
51
+ if spec.get("fragments"):
52
+ miss = missing_refs(spec)
53
+ if miss:
54
+ raise CompositionError(
55
+ "this mission needs pieces this GINI doesn't have: " + ", ".join(miss)
56
+ + " — update GINI or ask your instructor for a compatible version.")
57
+ over = {k: spec[k] for k in ("time_limit", "attempts", "help", "persona", "complete_when")
58
+ if k in spec}
59
+ # the teacher's plain-language nuance rides along with the mission and is interpreted by the
60
+ # student's game master — so the course server never needs a model of its own
61
+ intent = dict(spec.get("intent") or {})
62
+ if spec.get("notes"):
63
+ intent.setdefault("notes", spec["notes"])
64
+ if intent:
65
+ over["intent"] = intent
66
+ # A teacher's composition is LITERAL: the student gets exactly the fragments referenced —
67
+ # never auto-filled exercise/observe layers they didn't ask for. (Want a load generator?
68
+ # Reference `drive-load`.) Opt in explicitly with `fill: true`.
69
+ return _assembly.assemble(
70
+ list(spec["fragments"]), genre=spec.get("genre"), level=spec.get("level"),
71
+ lesson_id=lid, title=spec.get("title", ""), brief=spec.get("brief", ""),
72
+ persona=spec.get("persona", "coach"), fill=bool(spec.get("fill", False)), **over)
73
+
74
+ # escape hatch: a self-contained lesson (inline objectives), still bounded by the local oracle
75
+ if spec.get("objectives"):
76
+ d = dict(spec)
77
+ d.setdefault("id", lid)
78
+ les = _lesson.from_dict(d)
79
+ problems = _lesson.validate(les)
80
+ if problems:
81
+ raise CompositionError("lesson does not validate locally: " + "; ".join(problems))
82
+ return les
83
+
84
+ raise CompositionError("composition has neither 'fragments' nor 'objectives'")
85
+
86
+
87
+ def is_composition(spec) -> bool:
88
+ return isinstance(spec, dict) and bool(spec.get("fragments") or spec.get("objectives"))