gini-toolkit 6.0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,209 @@
1
+ """Assembly — turn a set of chosen fragments into ONE complete, gradable mission by graph closure.
2
+
3
+ This is the engine behind "the reasoner pieces templates together into bigger missions." Given one
4
+ or more **core** fragments, assembly:
5
+
6
+ 1. **closes the graph** — every `requires` is satisfied by some fragment's `provides`, pulling in a
7
+ provider when a requirement is unmet (so nothing dangles);
8
+ 2. **fills layers** — for engaged genres it adds an *exercise* and an *observe* companion so a bare
9
+ skeleton becomes educational (the completeness rule of the design);
10
+ 3. **defaults genre + quest level** from the shape of the assembly (both pinnable);
11
+ 4. **emits a Lesson** — the union of the fragments' objectives (semantic-dedup on the predicate,
12
+ ids kept unique) + merged intent + a help level from the genre — and rejects anything that
13
+ doesn't `validate()` (never ship an ungradable mission).
14
+
15
+ The LLM never runs here: assembly is deterministic. The model's only job upstream is choosing the
16
+ core(s) and reskinning — GINI owns the gradable structure. See GINI_MISSIONS_COMPOSABLE_DESIGN.md.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from . import capabilities as _caps
21
+ from . import fragments as _frag
22
+ from . import lesson as _lesson
23
+ from .objectives import Objective
24
+
25
+ EXPERIENCE, EXPEDITION, CHALLENGE = "experience", "expedition", "challenge"
26
+
27
+ # how much the game master helps, per genre
28
+ _HELP_BY_GENRE = {EXPERIENCE: "full_tutor_logged", EXPEDITION: "warmer_colder", CHALLENGE: "none"}
29
+
30
+
31
+ def _provided(frags) -> set[str]:
32
+ out: set[str] = set()
33
+ for f in frags:
34
+ out.update(f.provides)
35
+ return out
36
+
37
+
38
+ def _requirement_met(required: str, frags) -> bool:
39
+ return any(_caps.any_satisfies(f.provides, required) for f in frags)
40
+
41
+
42
+ def close_graph(frags: list, exclude=None) -> tuple[list, list[str]]:
43
+ """Add providers until every `requires` is satisfied. Returns (fragments, still-unmet). Prefers
44
+ a provider that is itself a core/enrichment already in the registry; deterministic + terminating
45
+ (each role is resolved at most once). Providers the student excluded are never pulled in."""
46
+ chosen = list(frags)
47
+ chosen_ids = {f.id for f in chosen}
48
+ unmet: list[str] = []
49
+ guard = 0
50
+ queue = list(chosen)
51
+ while queue and guard < 100:
52
+ guard += 1
53
+ f = queue.pop(0)
54
+ for req in f.requires:
55
+ if _requirement_met(req, chosen):
56
+ continue
57
+ provider = next((p for p in _frag.find_providers(req)
58
+ if p.id not in chosen_ids and not _excluded(p, exclude)), None)
59
+ if provider is None:
60
+ if req not in unmet:
61
+ unmet.append(req)
62
+ continue
63
+ chosen.append(provider)
64
+ chosen_ids.add(provider.id)
65
+ queue.append(provider)
66
+ return chosen, unmet
67
+
68
+
69
+ def _excluded(fragment, exclude) -> bool:
70
+ if exclude is None:
71
+ return False
72
+ from . import constraints as _con
73
+ return _con.fragment_excluded(fragment, exclude)
74
+
75
+
76
+ def _fill_layer(chosen: list, layer: str, exclude=None) -> list:
77
+ """If no fragment of `layer` is present, add the first enrichment fragment of that layer whose
78
+ own requirements are already satisfied AND that the student hasn't excluded. A layer the student
79
+ excluded outright is never filled."""
80
+ if exclude is not None and layer in getattr(exclude, "layers", set()):
81
+ return chosen # the student asked to leave this layer out
82
+ if any(f.layer == layer for f in chosen):
83
+ return chosen
84
+ ids = {f.id for f in chosen}
85
+ for cand in _frag.by_layer(layer):
86
+ if cand.id in ids or _excluded(cand, exclude):
87
+ continue
88
+ if all(_requirement_met(r, chosen) for r in cand.requires):
89
+ return chosen + [cand]
90
+ return chosen
91
+
92
+
93
+ def default_genre(chosen: list, *, faults: int = 0) -> str:
94
+ has_ex = any(f.layer == _frag.EXERCISE for f in chosen)
95
+ has_ob = any(f.layer == _frag.OBSERVE for f in chosen)
96
+ if faults > 0 and has_ex and has_ob:
97
+ return CHALLENGE
98
+ if has_ex:
99
+ return EXPEDITION
100
+ return EXPERIENCE
101
+
102
+
103
+ def default_level(chosen: list, *, faults: int = 0, guided: bool = False) -> int:
104
+ n_core = sum(1 for f in chosen if f.layer == _frag.CORE)
105
+ has_ex = any(f.layer == _frag.EXERCISE for f in chosen)
106
+ has_ob = any(f.layer == _frag.OBSERVE for f in chosen)
107
+ score = max(0, n_core - 1) + (1 if has_ex else 0) + (1 if has_ob else 0) + faults
108
+ score -= 1 if guided else 0
109
+ return max(0, min(3, score))
110
+
111
+
112
+ def _forks_of(primary) -> list:
113
+ """The primary core's difficulty forks, instantiated into live Objectives, carried onto the
114
+ lesson. Forks come from the CORE fragment only — a composition's difficulty is the core's."""
115
+ out = []
116
+ for fk in getattr(primary, "forks", ()) or ():
117
+ out.append({"id": fk.id, "label": fk.label, "difficulty": fk.difficulty,
118
+ "kind": fk.kind, "objectives": fk.instantiate()})
119
+ return out
120
+
121
+
122
+ def _merge_objectives(chosen: list) -> list[Objective]:
123
+ objs: list[Objective] = []
124
+ seen_checks: set[str] = set()
125
+ seen_ids: set[str] = set()
126
+ for f in chosen:
127
+ for o in f.instantiate():
128
+ if o.check and o.check in seen_checks:
129
+ continue # same requirement already covered by another fragment
130
+ if o.check:
131
+ seen_checks.add(o.check)
132
+ oid = o.id if o.id not in seen_ids else f"{f.id}-{o.id}"
133
+ seen_ids.add(oid)
134
+ objs.append(Objective(id=oid, say=o.say, kind=o.kind, check=o.check, probe=o.probe,
135
+ level=o.level, stars=getattr(o, "stars", 0)))
136
+ from .objectives import by_level
137
+ return by_level(objs) # progressive ladder: place → connect → group → prove live
138
+
139
+
140
+ def assemble(core_ids, *, genre: str | None = None, level: int | None = None, lesson_id: str,
141
+ title: str = "", brief: str = "", persona: str = "coach", fill: bool | None = None,
142
+ exclude=None, **overrides) -> _lesson.Lesson:
143
+ """Assemble one Lesson from the given core fragment id(s).
144
+
145
+ genre/level default from the assembly's shape but may be pinned. `fill` forces layer-filling on
146
+ (Expedition/Challenge default) or off (Experience default = core only, guided). `exclude` (a
147
+ constraints.Excludes) suppresses layers/companions the student asked to leave out."""
148
+ seeds = [_frag.get(cid) for cid in core_ids]
149
+ seeds = [f for f in seeds if f is not None] or [_frag.cores()[0]]
150
+
151
+ chosen, _unmet = close_graph(seeds, exclude)
152
+
153
+ # experience = keep it a bare, guided build; engaged genres fill exercise + observe
154
+ engaged = genre in (EXPEDITION, CHALLENGE) if genre else True
155
+ do_fill = engaged if fill is None else fill
156
+ if do_fill:
157
+ chosen = _fill_layer(chosen, _frag.EXERCISE, exclude)
158
+ chosen = _fill_layer(chosen, _frag.OBSERVE, exclude)
159
+
160
+ guided = genre == EXPERIENCE
161
+ genre = genre or default_genre(chosen)
162
+ if level is None:
163
+ level = default_level(chosen, guided=guided)
164
+ help_level = overrides.get("help", _HELP_BY_GENRE.get(genre, "warmer_colder"))
165
+
166
+ primary = chosen[0]
167
+ objs = _merge_objectives(chosen)
168
+ spirit = " ".join(f.spirit for f in chosen if f.spirit)
169
+ misconceptions: list[str] = []
170
+ for f in chosen:
171
+ for m in f.misconceptions:
172
+ if m not in misconceptions:
173
+ misconceptions.append(m)
174
+ steps = [_lesson.Step(say=f.step) for f in chosen if f.step] if guided else []
175
+
176
+ # A teacher's authored intent AUGMENTS the fragments' own: their goal/emphasis leads, but the
177
+ # fragment's spirit (what actually counts as success) is preserved and their misconceptions are
178
+ # added. `notes` is pure teacher nuance the game master reasons over at play time.
179
+ ti = overrides.get("intent") or {}
180
+ t_spirit = (ti.get("spirit") or "").strip()
181
+ intent = _lesson.Intent(
182
+ concept=ti.get("concept") or primary.teaches,
183
+ goal=ti.get("goal") or title or primary.summary,
184
+ spirit=(f"{t_spirit} {spirit}".strip() if t_spirit else spirit),
185
+ misconceptions=list(dict.fromkeys(list(ti.get("misconceptions") or []) + misconceptions)),
186
+ notes=ti.get("notes", ""))
187
+ summary = brief or " ".join(f.summary for f in chosen if f.summary)
188
+ quest_tag = f"[Quest L{level} · {genre}] "
189
+
190
+ cw = overrides.get("complete_when") or getattr(primary, "complete_when", "all")
191
+ lesson = _lesson.Lesson(
192
+ id=lesson_id, title=title or primary.summary, brief=quest_tag + summary, objectives=objs,
193
+ steps=steps, stage=dict(getattr(primary, "stage", {}) or {}), complete_when=cw,
194
+ time_limit_s=_lesson.parse_duration(overrides.get("time_limit", "20m")),
195
+ attempts=int(overrides.get("attempts", 3)), help=help_level, persona=persona,
196
+ intent=intent, archetype=primary.id, genre=genre, level=level,
197
+ fragments=[f.id for f in chosen], forks=_forks_of(primary))
198
+
199
+ if not _lesson.is_valid(lesson): # guardrail: fall back to the primary core alone
200
+ objs = _merge_objectives([primary])
201
+ lesson = _lesson.Lesson(
202
+ id=lesson_id, title=title or primary.summary, brief=quest_tag + (brief or primary.summary),
203
+ objectives=objs, complete_when=primary.complete_when,
204
+ time_limit_s=_lesson.parse_duration(overrides.get("time_limit", "20m")),
205
+ attempts=int(overrides.get("attempts", 3)), help=help_level, persona=persona,
206
+ intent=_lesson.Intent(concept=primary.teaches, goal=title or primary.summary,
207
+ spirit=primary.spirit, misconceptions=list(primary.misconceptions)),
208
+ archetype=primary.id, genre=genre, level=level, fragments=[primary.id])
209
+ return lesson
@@ -0,0 +1,353 @@
1
+ """Fragment authoring — objectives *by demonstration*.
2
+
3
+ A professor shouldn't hand-write `path(web_app, database)`. Instead they build the winning arrangement
4
+ on the real canvas, and this reads it back into candidate objectives they confirm or prune. The
5
+ canvas is the predicate builder (GINI_AUTHORING_DESIGN.md, Seam B). Pure logic over a Topology; the UI
6
+ in `ui/` just drives it.
7
+
8
+ What we derive, auto-levelled onto the ladder (place → connect → group):
9
+ * L1 placement — `exists(type)`, or `count(type) >= n` when the board has several of one type;
10
+ * L2 connection — `link(a, b)` for each *type-pair* that's directly cabled;
11
+ * L3 containment — `contains_type(box, type)` for each element inside a grouping box (VPC/subnet…).
12
+
13
+ Live (L4) objectives (`reach/http/…`) can't be read off a static picture — the author adds those
14
+ explicitly. We offer the structural skeleton; the human supplies judgment and the live checks.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from . import devices as _devices
19
+ from .content import ENGINE_VERSION, FRAGMENT_SCHEMA
20
+
21
+
22
+ def _type(topo, dev_id: str) -> str:
23
+ d = topo.devices.get(dev_id)
24
+ return d.type_key if d else ""
25
+
26
+
27
+ def _spec(topo, dev_id: str) -> str:
28
+ """`type` for a delta element, `type@slot` for a scaffold element tagged with a slot."""
29
+ d = topo.devices.get(dev_id)
30
+ if d is None:
31
+ return ""
32
+ return f"{d.type_key}@{d.slot}" if getattr(d, "slot", "") else d.type_key
33
+
34
+
35
+ def _slot_label(spec: str) -> str:
36
+ """Human label for a possibly slot-scoped type spec, e.g. 'switch (slot A)'."""
37
+ tk, _, slot = spec.partition("@")
38
+ return _label(tk) + (f" (slot {slot})" if slot else "")
39
+
40
+
41
+ def _is_rider(type_key: str) -> bool:
42
+ """Sources/Sinks are I/O ports, not structural elements — kept out of derived objectives."""
43
+ dt = _devices.get(type_key) if type_key else None
44
+ return bool(dt and getattr(dt, "rider", False))
45
+
46
+
47
+ def _is_container(type_key: str) -> bool:
48
+ """Grouping boxes (VPC / Subnet / Region) — the ones containment is about."""
49
+ dt = _devices.get(type_key)
50
+ cat = getattr(dt, "category", None)
51
+ return bool(dt) and (str(getattr(cat, "value", cat)).lower() in ("group", "boundary", "zone")
52
+ or type_key in ("vpc", "subnet", "region"))
53
+
54
+
55
+ def derive_objectives(topo, exclude=None) -> list[dict]:
56
+ """Read a Topology → ordered candidate objectives (dicts: id, say, check, kind, level).
57
+
58
+ Deterministic and type-based (like everything gradable in GINI), so the derived checks match what
59
+ the student is actually graded on. The author confirms/prunes; nothing is auto-committed.
60
+
61
+ `exclude` = device ids belonging to a SCAFFOLD (a loaded dependency/provider). Those elements are
62
+ NOT this fragment's — they're provided at composition time — so they're skipped. A link with BOTH
63
+ endpoints in the scaffold is scaffold-internal (skipped); a link from a NEW element to a scaffold
64
+ one (e.g. router→switch) IS the delta and is kept."""
65
+ from .objectives import CONNECTION, CONTAINMENT, PLACEMENT
66
+
67
+ exclude = exclude or set()
68
+ out: list[dict] = []
69
+ seen: set[str] = set()
70
+
71
+ def add(key, oid, say, check, level):
72
+ if key in seen:
73
+ return
74
+ seen.add(key)
75
+ out.append({"id": oid, "say": say, "check": check, "kind": "structural", "level": level,
76
+ "key": key})
77
+
78
+ # --- L1: placement -------------------------------------------------------
79
+ # `key` is stable across count changes ("place:host"), so the recorder updates one step as more
80
+ # of a type appear rather than spawning a new step per element.
81
+ counts: dict[str, int] = {}
82
+ for d in topo.devices.values():
83
+ if d.id in exclude: # a scaffold (provided dependency) element — not ours
84
+ continue
85
+ if getattr(d.type, "rider", False): # Sources/Sinks are ports, not structure
86
+ continue
87
+ counts[d.type_key] = counts.get(d.type_key, 0) + 1
88
+ for tk, n in sorted(counts.items()):
89
+ label = _label(tk)
90
+ if n >= 2:
91
+ add(f"place:{tk}", f"place-{tk}", f"Place at least {n} {label}(s)",
92
+ f"count({tk}) >= {n}", PLACEMENT)
93
+ else:
94
+ add(f"place:{tk}", f"place-{tk}", f"Place a {label}", f"exists({tk})", PLACEMENT)
95
+
96
+ # --- L2: connection (one objective per directly-cabled TYPE pair) --------
97
+ pairs: set[tuple[str, str]] = set()
98
+ for l in topo.links.values():
99
+ if getattr(l, "kind", "link") == "attach": # a rider mount is not a cable
100
+ continue
101
+ if l.source_id in exclude and l.target_id in exclude: # scaffold-internal wiring — not ours
102
+ continue
103
+ # a delta→scaffold link scopes the scaffold end by its slot: link(router, switch@A)
104
+ sa, sb = _spec(topo, l.source_id), _spec(topo, l.target_id)
105
+ ta, tb = sa.split("@")[0], sb.split("@")[0]
106
+ if sa and sb and not _is_rider(ta) and not _is_rider(tb):
107
+ pairs.add(tuple(sorted((sa, sb))))
108
+ for a, b in sorted(pairs):
109
+ key = f"link:{a}|{b}"
110
+ oid = f"link-{a}-{b}".replace("@", "-at-")
111
+ add(key, oid, f"Wire the {_slot_label(a)} to the {_slot_label(b)}",
112
+ f"link({a}, {b})", CONNECTION)
113
+
114
+ # --- L3: containment (one per element type inside a grouping box) --------
115
+ contained: set[tuple[str, str]] = set()
116
+ for d in topo.devices.values():
117
+ if d.id in exclude or not d.parent_id:
118
+ continue
119
+ box = _type(topo, d.parent_id)
120
+ if box and _is_container(box) and box != d.type_key:
121
+ contained.add((box, d.type_key))
122
+ for box, tk in sorted(contained):
123
+ add(f"in:{box}|{tk}", f"in-{box}-{tk}", f"The {_label(tk)} sits inside the {_label(box)}",
124
+ f"contains_type({box}, {tk})", CONTAINMENT)
125
+
126
+ return out
127
+
128
+
129
+ def live_check(src_type: str, dst_type: str, expect_ok: bool) -> dict:
130
+ """Build a LIVE (L4) objective — a runtime probe the recorder can't capture, because it's an
131
+ assertion about behavior, not a drag-and-drop action. Added by hand in the editor."""
132
+ verb = "reaches" if expect_ok else "cannot reach"
133
+ tail = "ok" if expect_ok else "fail"
134
+ oid = f"live-{src_type}-{dst_type}-{tail}".replace("@", "-at-")
135
+ return {"id": oid,
136
+ "say": f"Live: the {_slot_label(src_type)} {verb} the {_slot_label(dst_type)} "
137
+ f"(Run to check)",
138
+ "check": "", "kind": "behavioral", "level": 4,
139
+ "probe": f"reach({src_type} -> {dst_type}) == {tail}",
140
+ "key": f"live:{src_type}|{dst_type}|{tail}"}
141
+
142
+
143
+ def derive_contract(topo, exclude=None) -> tuple[list[str], list[str]]:
144
+ """Auto-derive a fragment's (provides, requires) capability contract from the built board — no
145
+ teacher typing. `provides` = the roles of everything built; `requires` = roles the board NEEDS
146
+ (a source needs a target; the grammar's required partners) but does NOT build itself. The
147
+ polarity is pure build-vs-consume. Matching is is-a aware, so a router-gateway satisfies a
148
+ required l3-gateway.
149
+
150
+ `exclude` = SCAFFOLD device ids (a loaded dependency). They're not part of THIS fragment, so they
151
+ contribute nothing here — the requirement they represent is added by the caller from the provider
152
+ fragment's own `provides` (a clean 'I require what that block provides')."""
153
+ from . import capabilities as _caps
154
+ from . import connection_rules as _cr
155
+
156
+ exclude = exclude or set()
157
+ provides: set[str] = set()
158
+ for d in topo.devices.values():
159
+ if d.id in exclude:
160
+ continue
161
+ provides.update(_caps.roles_for(d.type_key))
162
+ # a required role is met if any provided role is that role or a sub-role of it
163
+ provided_closure: set[str] = set()
164
+ for r in provides:
165
+ provided_closure |= _caps.ancestors(r)
166
+
167
+ requires: set[str] = set()
168
+ for d in topo.devices.values():
169
+ if d.id in exclude:
170
+ continue
171
+ for p in _cr.required_partners(d.type_key): # structural needs from the grammar
172
+ requires.update(_caps.roles_for(p.type_key))
173
+ if getattr(d.type, "role", "") == "source": # a stimulus needs something to hit
174
+ requires.add("traffic-sink")
175
+ requires = {r for r in requires if r not in provided_closure} # drop anything we build ourselves
176
+ return sorted(provides), sorted(requires)
177
+
178
+
179
+ def output_check(rider_type: str, metric: str, op: str, value: float) -> dict:
180
+ """Build an OUTPUT objective — an assertion on a Source/Sink's measurement (the gradable output
181
+ of an experiment). Graded via the `measure(...)` probe, so it rides the same runner as reach."""
182
+ from .objectives import LIVE
183
+ v = int(value) if float(value).is_integer() else value
184
+ return {"id": f"out-{rider_type}-{metric}",
185
+ "say": f"{_label(rider_type)}: {metric.replace('_', ' ')} {op} {v}",
186
+ "check": "", "kind": "behavioral", "level": LIVE,
187
+ "probe": f"measure({rider_type}, {metric}) {op} {v}",
188
+ "key": f"measure:{rider_type}|{metric}"}
189
+
190
+
191
+ class Recorder:
192
+ """Scan mode: capture ordered steps as the teacher builds on the canvas. `capture(topology)` is
193
+ called on every canvas change while recording; each new derivable fact becomes a step, IN THE
194
+ ORDER it first appeared. Steps are keyed so counts update in place (a second host bumps the
195
+ existing 'place host' step to count>=2, it doesn't add a new one). Deletes are NOT auto-pruned —
196
+ the teacher removes stray steps by hand (predictable beats clever)."""
197
+
198
+ def __init__(self, exclude=None) -> None:
199
+ self.steps: list[dict] = []
200
+ self._by_key: dict[str, dict] = {}
201
+ self.exclude = set(exclude or ()) # scaffold device ids — captured deltas skip these
202
+
203
+ def capture(self, topo) -> None:
204
+ for cand in derive_objectives(topo, exclude=self.exclude):
205
+ k = cand["key"]
206
+ existing = self._by_key.get(k)
207
+ if existing is None:
208
+ self.steps.append(cand)
209
+ self._by_key[k] = cand
210
+ else: # same fact, count/label may have grown — refresh
211
+ existing["say"] = cand["say"]
212
+ existing["check"] = cand["check"]
213
+
214
+ def result(self) -> list[dict]:
215
+ return list(self.steps)
216
+
217
+
218
+ def _label(type_key: str) -> str:
219
+ dt = _devices.get(type_key)
220
+ return (dt.label if dt else type_key).lower()
221
+
222
+
223
+ def materialize(ctx, objectives, x0: float = 60.0) -> list[str]:
224
+ """Build a minimal board satisfying a provider fragment's STRUCTURAL objectives, as a SCAFFOLD
225
+ the teacher authors a dependent fragment on top of. Returns the created device ids (the scaffold
226
+ set). Behavioral objectives are ignored (a reach can't be placed). Links use a star: every A of a
227
+ `link(a, b)` is wired to the first B, so a LAN provider comes out as hosts-on-a-switch.
228
+
229
+ `x0` is the left edge of this scaffold's layout band. Each call otherwise starts at the same x,
230
+ so a fragment with several slots (or several representatives) would stack them all on top of
231
+ each other; the caller passes a per-slot offset to lay them out side by side."""
232
+ import re
233
+ created: list[str] = []
234
+ by_type: dict[str, list[str]] = {}
235
+
236
+ def place(type_key: str, n: int) -> None:
237
+ have = by_type.setdefault(type_key, [])
238
+ while len(have) < n:
239
+ inst = ctx.add_device(type_key, x=x0 + len(created) * 70, y=70 + len(by_type) * 90)
240
+ have.append(inst.id)
241
+ created.append(inst.id)
242
+
243
+ for o in objectives: # 1. placements
244
+ chk = o.get("check") or ""
245
+ m = re.match(r"^\s*exists\((\w+)\)\s*$", chk)
246
+ if m:
247
+ place(m.group(1), 1); continue
248
+ m = re.match(r"^\s*count\((\w+)\)\s*>=\s*(\d+)", chk)
249
+ if m:
250
+ place(m.group(1), int(m.group(2)))
251
+
252
+ for o in objectives: # 2. containment (place T inside a box)
253
+ m = re.match(r"^\s*contains_type\((\w+)\s*,\s*(\w+)\)\s*$", o.get("check") or "")
254
+ if m:
255
+ box, tk = m.group(1), m.group(2)
256
+ place(box, 1)
257
+ place(tk, 1)
258
+ child = by_type[tk][0]
259
+ ctx.topology.devices[child].parent_id = by_type[box][0]
260
+
261
+ for o in objectives: # 3. links (star: all A → first B)
262
+ m = re.match(r"^\s*link\((\w+)\s*,\s*(\w+)\)\s*$", o.get("check") or "")
263
+ if m and by_type.get(m.group(2)):
264
+ b0 = by_type[m.group(2)][0]
265
+ for aid in by_type.get(m.group(1), []):
266
+ if aid != b0:
267
+ try:
268
+ ctx.add_link(aid, b0)
269
+ except Exception: # noqa: BLE001 — grammar refusal: skip
270
+ pass
271
+ return created
272
+
273
+
274
+ # -- assemble a fragment from authored parts + save it ----------------------- #
275
+ def build_fragment_dict(*, frag_id: str, teaches: str, summary: str, spirit: str,
276
+ objectives: list[dict], provides=None, requires=None,
277
+ forks: list[dict] | None = None, stage: dict | None = None,
278
+ author: str = "", certified: bool = False, slots=None, peerings=None) -> dict:
279
+ """Assemble the fragment YAML dict from authored parts, stamped with the engine version so it can
280
+ be version-gated downstream. Returns a dict ready for `fragment_yaml.fragment_from_dict`."""
281
+ d: dict = {"id": frag_id, "layer": "core", "engine_version": ENGINE_VERSION,
282
+ "schema_version": FRAGMENT_SCHEMA}
283
+ if teaches:
284
+ d["teaches"] = teaches
285
+ if summary:
286
+ d["summary"] = summary
287
+ if spirit:
288
+ d["spirit"] = spirit
289
+ if provides:
290
+ d["provides"] = list(provides)
291
+ if requires:
292
+ d["requires"] = list(requires)
293
+ if author:
294
+ d["author"] = author
295
+ d["objectives"] = [_clean_obj(o) for o in objectives]
296
+ if forks:
297
+ d["forks"] = [{"id": f["id"], "label": f.get("label", ""),
298
+ "difficulty": int(f.get("difficulty", 2)), "kind": f.get("kind", "converge"),
299
+ "objectives": [_clean_obj(o) for o in f.get("objectives", [])]}
300
+ for f in forks]
301
+ if stage:
302
+ d["stage"] = stage
303
+ if slots:
304
+ d["slots"] = [dict(s) for s in slots]
305
+ if peerings:
306
+ d["peerings"] = [dict(p) for p in peerings]
307
+ if certified:
308
+ d["certified"] = True
309
+ return d
310
+
311
+
312
+ def slug(text: str) -> str:
313
+ """A safe fragment/lesson id: lowercase, spaces→hyphens, only [a-z0-9-]. Ids travel in URL routes
314
+ (e.g. /lessons/<id>/pack, matched by [\\w-]+), so a space or punctuation would break the fetch. A
315
+ teacher types 'Simple LAN'; we store 'simple-lan'."""
316
+ import re
317
+ s = re.sub(r"[^a-z0-9]+", "-", (text or "").strip().lower()).strip("-")
318
+ return s or "fragment"
319
+
320
+
321
+ def _clean_obj(o: dict) -> dict:
322
+ out = {"id": o["id"], "say": o.get("say", o["id"])}
323
+ if o.get("kind", "structural") != "structural":
324
+ out["kind"] = o["kind"]
325
+ if o.get("check"):
326
+ out["check"] = o["check"]
327
+ if o.get("probe"):
328
+ out["probe"] = o["probe"]
329
+ if o.get("level"):
330
+ out["level"] = o["level"]
331
+ if o.get("stars"):
332
+ out["stars"] = int(o["stars"])
333
+ return out
334
+
335
+
336
+ def validate_dict(d: dict) -> list[str]:
337
+ """Problems that would make the authored fragment unloadable (empty = good)."""
338
+ from . import fragment_yaml as _fy
339
+ return _fy.validate(_fy.fragment_from_dict(d))
340
+
341
+
342
+ def save_fragment(d: dict) -> str:
343
+ """Write a blessed fragment to the user content layer. Returns the path. Raises if it doesn't
344
+ validate — we never write an ungradable fragment to disk."""
345
+ from . import content as _content
346
+ from . import fragment_yaml as _fy
347
+ problems = validate_dict(d)
348
+ if problems:
349
+ raise ValueError("; ".join(problems))
350
+ frag = _fy.fragment_from_dict(d)
351
+ path = _content.ensure_user_content_dir() / f"{frag.id}.yaml"
352
+ path.write_text(_fy.to_yaml(frag), encoding="utf-8")
353
+ return str(path)
@@ -0,0 +1,5 @@
1
+ """DEPRECATED — the requirement chip / auto-build Wizard was replaced by the goal-guided
2
+ Wizard (see ``domain/missions.py``). This module is intentionally empty and can be
3
+ deleted; it is kept only because the sandbox can't remove files (delete on the host:
4
+ ``git rm src/gini/domain/blueprints.py``).
5
+ """