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,488 @@
1
+ """Objective predicate engine — the GINI-checkable win conditions behind Missions.
2
+
3
+ An Objective is one win condition. Two kinds:
4
+
5
+ • **structural** — a boolean predicate over the topology GRAPH, evaluated instantly and
6
+ offline (no run). Written in a tiny, safe expression language:
7
+ exists(<type>) · count(<type>) >= n · linked(a, b) · connected(a, b) ·
8
+ contains(box, member) · property(dev, key) == 'val'
9
+ combined with `and` / `or` / `not` and comparisons.
10
+ • **behavioral** — a probe over the RUNNING system ("reach(a -> b) == ok"). GINI's runtime
11
+ is the oracle; these need a run, so in Phase 1 they evaluate to **pending** (Phase 2 wires
12
+ the probe harness). The string is stored opaquely here.
13
+
14
+ Design notes:
15
+ - The expression evaluator uses Python's `ast` in a *whitelist* — no `eval`, no attribute
16
+ access, no arbitrary calls — so a lesson file can't execute anything. Only the handful of
17
+ predicate functions below are callable.
18
+ - Everything is pure and evaluated against a `World` (duck-typed), so it's unit-testable with
19
+ a real `Topology` or a fake. `TopologyWorld` adapts the live canvas model.
20
+
21
+ Convention: `exists`/`count` take an element **type_key**; `linked`/`connected`/`contains`/
22
+ `property` take element **names** (as placed on the canvas).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import ast
27
+ import operator
28
+ from dataclasses import dataclass
29
+
30
+ from .devices import REGISTRY
31
+
32
+ # ---- results -------------------------------------------------------------- #
33
+ MET = "met"
34
+ UNMET = "unmet"
35
+ PENDING = "pending" # behavioral, awaiting a run (Phase 2)
36
+
37
+
38
+ @dataclass
39
+ class Objective:
40
+ id: str
41
+ say: str # student-facing one-liner
42
+ kind: str = "structural" # structural | behavioral
43
+ check: str = "" # structural predicate expression
44
+ probe: str = "" # behavioral probe (opaque in Phase 1)
45
+ level: int | None = None # explicit ladder tier; None = derived from the predicate
46
+ stars: int = 0 # difficulty PASS: 0 = base experiment, 1+ = harder progressive passes
47
+
48
+ def is_behavioral(self) -> bool:
49
+ return self.kind == "behavioral"
50
+
51
+
52
+ def _stars_of(o) -> int:
53
+ """Star rating of an objective given as either an Objective or a plain dict."""
54
+ v = o.get("stars", 0) if isinstance(o, dict) else getattr(o, "stars", 0)
55
+ try:
56
+ return max(0, int(v or 0))
57
+ except (TypeError, ValueError):
58
+ return 0
59
+
60
+
61
+ def objectives_for_pass(objectives, pass_level: int) -> list:
62
+ """The objectives active at star-pass `pass_level` — everything rated at or below it. Pass 0 is
63
+ the base experiment; each higher pass switches on the next tier of harder (starred) steps, so the
64
+ student walks the experiment progressively rather than facing it all at once."""
65
+ return [o for o in objectives if _stars_of(o) <= pass_level]
66
+
67
+
68
+ def max_stars(objectives) -> int:
69
+ """The deepest star-pass this set defines (0 = no harder passes, just the base)."""
70
+ return max((_stars_of(o) for o in objectives), default=0)
71
+
72
+
73
+ @dataclass
74
+ class ObjectiveResult:
75
+ id: str
76
+ say: str
77
+ kind: str
78
+ status: str # met | unmet | pending
79
+ level: int = 1 # which rung of the progressive ladder this sits on
80
+
81
+ @property
82
+ def met(self) -> bool:
83
+ return self.status == MET
84
+
85
+
86
+ # ---- the safe expression evaluator ---------------------------------------- #
87
+ _CMP = {ast.Eq: operator.eq, ast.NotEq: operator.ne, ast.Gt: operator.gt,
88
+ ast.GtE: operator.ge, ast.Lt: operator.lt, ast.LtE: operator.le}
89
+
90
+ # name-based predicates take element NAMES; type-based ones take element TYPE_KEYS. Type-based
91
+ # predicates are what missions should use, so an objective matches what the student built
92
+ # regardless of the (auto-generated) device names.
93
+ _PREDICATES = {"exists", "count", "linked", "connected", "contains", "property", "prop",
94
+ "link", "path", "contains_type", "through", "all_linked"}
95
+ _TYPE_ARG_FUNCS = {"exists", "count", "link", "path", "contains_type", "through",
96
+ "all_linked"} # args are type_keys
97
+
98
+
99
+ class PredicateError(ValueError):
100
+ """A structural check that doesn't parse or uses an unknown function."""
101
+
102
+
103
+ def slot_match(dev_slot: str, spec_slot: str) -> bool:
104
+ """Does a device tagged `dev_slot` fall under the scope `spec_slot`? An empty scope matches any
105
+ device (unscoped type predicate). Otherwise it matches the exact label OR anything NESTED beneath
106
+ it: composition labels are hierarchical with '_' segments (`pods0_lans1` is inside `pods0`), so a
107
+ `host@pods0` predicate reaches every host in pod 0's sub-networks. This is what makes recursive,
108
+ multi-level compositions gradable by the same `type@slot` oracle."""
109
+ dev_slot = dev_slot or ""
110
+ if not spec_slot:
111
+ return True
112
+ return dev_slot == spec_slot or dev_slot.startswith(spec_slot + "_")
113
+
114
+
115
+ # ---- the progressive ladder ------------------------------------------------ #
116
+ # Objectives are ordered basic → advanced so a student always has an obvious next move: place the
117
+ # elements, wire them, group them, then prove it live. The tier is DERIVED from the predicate, so
118
+ # every mission (existing and future) gets the ladder for free — no hand-tagging.
119
+ PLACEMENT, CONNECTION, CONTAINMENT, LIVE = 1, 2, 3, 4
120
+
121
+ _PRED_LEVEL = {
122
+ "exists": PLACEMENT, "count": PLACEMENT, "property": PLACEMENT, "prop": PLACEMENT,
123
+ "link": CONNECTION, "path": CONNECTION, "through": CONNECTION, "all_linked": CONNECTION,
124
+ "linked": CONNECTION, "connected": CONNECTION,
125
+ "contains": CONTAINMENT, "contains_type": CONTAINMENT,
126
+ }
127
+
128
+ LEVEL_NAME = {PLACEMENT: "Place the elements", CONNECTION: "Make the connections",
129
+ # L3 carries both containment (inside a VPC) and isolation (shielded / no bypass) —
130
+ # the "get the structure right" rung, not merely grouping.
131
+ CONTAINMENT: "Group & isolate", LIVE: "Prove it live (Run / Check)"}
132
+
133
+
134
+ def check_level(expr: str) -> int:
135
+ """The tier of a structural check — the HARDEST predicate it uses (a compound is as advanced as
136
+ its most advanced part)."""
137
+ try:
138
+ tree = ast.parse(expr or "", mode="eval")
139
+ except SyntaxError:
140
+ return PLACEMENT
141
+ levels = [_PRED_LEVEL.get(n.func.id, PLACEMENT)
142
+ for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)]
143
+ return max(levels) if levels else PLACEMENT
144
+
145
+
146
+ def level_of(objective) -> int:
147
+ """Which rung of the ladder an objective sits on. An explicit `level:` (authored in the YAML)
148
+ wins — the derived tier is only a sensible default, and some checks are semantically more
149
+ advanced than their predicate suggests (an isolation check reads as `path`, but it's an
150
+ advanced idea)."""
151
+ explicit = getattr(objective, "level", None)
152
+ if explicit:
153
+ return int(explicit)
154
+ if getattr(objective, "kind", "structural") == "behavioral":
155
+ return LIVE
156
+ return check_level(getattr(objective, "check", ""))
157
+
158
+
159
+ def by_level(objectives) -> list:
160
+ """Objectives ordered basic → advanced (stable within a tier, so authored order is preserved)."""
161
+ return sorted(objectives, key=level_of)
162
+
163
+
164
+ def _arg(node) -> object:
165
+ """A predicate argument is a bare identifier (element name/type), a literal, or a SLOT-scoped
166
+ type `type@slot`. We reuse Python's `@` (matmul) operator for scoping, so `switch@A` parses as
167
+ BinOp(Name, MatMult, Name) and we render it back to the string 'switch@A' for the World."""
168
+ if isinstance(node, ast.Constant):
169
+ return node.value
170
+ if isinstance(node, ast.Name):
171
+ return node.id
172
+ if (isinstance(node, ast.BinOp) and isinstance(node.op, ast.MatMult)
173
+ and isinstance(node.left, ast.Name) and isinstance(node.right, ast.Name)):
174
+ return f"{node.left.id}@{node.right.id}"
175
+ raise PredicateError(f"unsupported argument: {ast.dump(node)}")
176
+
177
+
178
+ def _eval(node, world) -> object:
179
+ if isinstance(node, ast.BoolOp):
180
+ vals = [_eval(v, world) for v in node.values]
181
+ return all(vals) if isinstance(node.op, ast.And) else any(vals)
182
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
183
+ return not _eval(node.operand, world)
184
+ if isinstance(node, ast.Compare):
185
+ if len(node.ops) != 1:
186
+ raise PredicateError("chained comparisons are not supported")
187
+ left = _eval(node.left, world)
188
+ right = _eval(node.comparators[0], world)
189
+ op = _CMP.get(type(node.ops[0]))
190
+ if op is None:
191
+ raise PredicateError("unsupported comparison operator")
192
+ return op(left, right)
193
+ if isinstance(node, ast.Call):
194
+ if not isinstance(node.func, ast.Name) or node.func.id not in _PREDICATES:
195
+ raise PredicateError(f"unknown predicate: {ast.dump(node.func)}")
196
+ args = [_arg(a) for a in node.args]
197
+ return _call(node.func.id, args, world)
198
+ if isinstance(node, ast.Constant):
199
+ return node.value
200
+ if isinstance(node, ast.Name):
201
+ return node.id
202
+ raise PredicateError(f"unsupported expression: {ast.dump(node)}")
203
+
204
+
205
+ def _call(fn: str, args: list, world) -> object:
206
+ if fn == "exists":
207
+ return world.exists(str(args[0]))
208
+ if fn == "count":
209
+ return world.count(str(args[0]))
210
+ if fn == "linked":
211
+ return world.linked(str(args[0]), str(args[1]))
212
+ if fn == "connected":
213
+ return world.connected(str(args[0]), str(args[1]))
214
+ if fn == "contains":
215
+ return world.contains(str(args[0]), str(args[1]))
216
+ if fn in ("property", "prop"):
217
+ return world.prop(str(args[0]), str(args[1]))
218
+ if fn == "link": # a link between ANY typeA device and ANY typeB device
219
+ return world.link_types(str(args[0]), str(args[1]))
220
+ if fn == "path": # a path between some typeA and some typeB device
221
+ return world.path_types(str(args[0]), str(args[1]))
222
+ if fn == "contains_type": # a memberType device inside a boxType box
223
+ return world.contains_types(str(args[0]), str(args[1]))
224
+ if fn == "through": # every src->dst path crosses a gate (a chokepoint)
225
+ return world.through_types(str(args[0]), str(args[1]), str(args[2]))
226
+ if fn == "all_linked": # EVERY type_a device has a direct link to SOME type_b device
227
+ return world.all_linked_types(str(args[0]), str(args[1]))
228
+ raise PredicateError(f"unknown predicate: {fn}")
229
+
230
+
231
+ def parse_check(expr: str) -> ast.AST:
232
+ """Parse+validate a structural predicate (raises PredicateError). Used by lesson
233
+ validation so a bad predicate fails at authoring time, not mid-mission."""
234
+ try:
235
+ tree = ast.parse(expr, mode="eval")
236
+ except SyntaxError as e:
237
+ raise PredicateError(f"syntax error in check {expr!r}: {e}") from e
238
+ for node in ast.walk(tree):
239
+ if isinstance(node, ast.Call):
240
+ if not isinstance(node.func, ast.Name) or node.func.id not in _PREDICATES:
241
+ raise PredicateError(f"unknown predicate in {expr!r}")
242
+ elif isinstance(node, (ast.Attribute, ast.Subscript, ast.Lambda, ast.comprehension)):
243
+ raise PredicateError(f"disallowed syntax in {expr!r}")
244
+ return tree.body
245
+
246
+
247
+ def check_ok(expr: str) -> bool:
248
+ try:
249
+ parse_check(expr)
250
+ return True
251
+ except PredicateError:
252
+ return False
253
+
254
+
255
+ def evaluate_check(expr: str, world) -> bool:
256
+ """Evaluate a structural predicate against a world → bool."""
257
+ return bool(_eval(parse_check(expr), world))
258
+
259
+
260
+ def evaluate(obj: Objective, world, runner=None) -> ObjectiveResult:
261
+ """One objective → a result.
262
+
263
+ Structural objectives evaluate against the graph instantly. Behavioral objectives need a
264
+ live `runner` (GINI's runtime, the oracle): with one available they resolve to met/unmet via
265
+ the probe; without one they stay `pending` (Phase 1 behavior, and whenever nothing is running)."""
266
+ if obj.is_behavioral():
267
+ if runner is None or not getattr(runner, "available", lambda: False)():
268
+ return ObjectiveResult(obj.id, obj.say, obj.kind, PENDING, level_of(obj))
269
+ from . import probes as _probes
270
+ try:
271
+ met = _probes.evaluate(obj.probe, runner)
272
+ except _probes.ProbeError:
273
+ met = False
274
+ return ObjectiveResult(obj.id, obj.say, obj.kind, MET if met else UNMET, level_of(obj))
275
+ try:
276
+ met = evaluate_check(obj.check, world)
277
+ except PredicateError:
278
+ met = False
279
+ return ObjectiveResult(obj.id, obj.say, obj.kind, MET if met else UNMET, level_of(obj))
280
+
281
+
282
+ def evaluate_all(objectives, world, runner=None) -> list[ObjectiveResult]:
283
+ return [evaluate(o, world, runner) for o in objectives]
284
+
285
+
286
+ # ---- the World adapter over a live Topology ------------------------------- #
287
+ class TopologyWorld:
288
+ """Adapts a `domain.topology.Topology` to the predicate World interface. Duck-typed:
289
+ anything exposing `.devices` (values with .name/.type_key/.parent_id/.properties) and
290
+ `.links` (values with .source_id/.target_id) works, so tests can use a real Topology."""
291
+
292
+ def __init__(self, topology) -> None:
293
+ self.t = topology
294
+
295
+ def _by_name(self, name: str):
296
+ for d in self.t.devices.values():
297
+ if d.name == name:
298
+ return d
299
+ return None
300
+
301
+ @staticmethod
302
+ def _match(d, spec: str) -> bool:
303
+ """A device matches `type` or a slot-scoped `type@slot` (a scaffold/bound-dependency tag)."""
304
+ type_key, _, slot = str(spec).partition("@")
305
+ return d.type_key == type_key and slot_match(getattr(d, "slot", ""), slot)
306
+
307
+ def exists(self, type_key: str) -> bool:
308
+ return any(self._match(d, type_key) for d in self.t.devices.values())
309
+
310
+ def count(self, type_key: str) -> int:
311
+ return sum(1 for d in self.t.devices.values() if self._match(d, type_key))
312
+
313
+ def _adjacency(self) -> dict:
314
+ adj: dict[str, set] = {}
315
+ for l in self.t.links.values():
316
+ adj.setdefault(l.source_id, set()).add(l.target_id)
317
+ adj.setdefault(l.target_id, set()).add(l.source_id)
318
+ return adj
319
+
320
+ def linked(self, a: str, b: str) -> bool:
321
+ da, db = self._by_name(a), self._by_name(b)
322
+ if da is None or db is None:
323
+ return False
324
+ return any((l.source_id, l.target_id) in ((da.id, db.id), (db.id, da.id))
325
+ for l in self.t.links.values())
326
+
327
+ def connected(self, a: str, b: str) -> bool:
328
+ """A path exists between a and b over links (undirected BFS)."""
329
+ da, db = self._by_name(a), self._by_name(b)
330
+ if da is None or db is None:
331
+ return False
332
+ if da.id == db.id:
333
+ return True
334
+ adj = self._adjacency()
335
+ seen, frontier = {da.id}, [da.id]
336
+ while frontier:
337
+ nxt = []
338
+ for node in frontier:
339
+ for peer in adj.get(node, ()):
340
+ if peer == db.id:
341
+ return True
342
+ if peer not in seen:
343
+ seen.add(peer)
344
+ nxt.append(peer)
345
+ frontier = nxt
346
+ return False
347
+
348
+ def contains(self, box: str, member: str) -> bool:
349
+ """Is `member` inside the box named `box` (walking the parent_id chain, so nested
350
+ VPC→Subnet→member counts)?"""
351
+ boxdev, md = self._by_name(box), self._by_name(member)
352
+ if boxdev is None or md is None:
353
+ return False
354
+ cur = md
355
+ seen = set()
356
+ while cur is not None and cur.parent_id and cur.id not in seen:
357
+ seen.add(cur.id)
358
+ parent = self.t.devices.get(cur.parent_id)
359
+ if parent is not None and parent.id == boxdev.id:
360
+ return True
361
+ cur = parent
362
+ return False
363
+
364
+ def prop(self, dev: str, key: str):
365
+ d = self._by_name(dev)
366
+ if d is None:
367
+ return None
368
+ props = getattr(d, "properties", {}) or {}
369
+ if key in props:
370
+ return props[key]
371
+ low = {k.lower(): v for k, v in props.items()} # forgiving on case
372
+ return low.get(key.lower())
373
+
374
+ # -- type-based (name-agnostic) predicates: match what the student built, not the names -- #
375
+ def link_types(self, type_a: str, type_b: str) -> bool:
376
+ """A link directly connecting some device matching `type_a` to some matching `type_b`
377
+ (each may be slot-scoped as `type@slot`)."""
378
+ for l in self.t.links.values():
379
+ s = self.t.devices.get(l.source_id)
380
+ d = self.t.devices.get(l.target_id)
381
+ if s is None or d is None:
382
+ continue
383
+ if ((self._match(s, type_a) and self._match(d, type_b))
384
+ or (self._match(s, type_b) and self._match(d, type_a))):
385
+ return True
386
+ return False
387
+
388
+ def all_linked_types(self, type_a: str, type_b: str) -> bool:
389
+ """EVERY device matching `type_a` has a direct link to SOME device matching `type_b` — the
390
+ universal that makes an open-N win condition N-independent ("every LAN is wired to the
391
+ router", any number of LANs). Vacuously true with no type_a devices; a `count(...)>=K` floor
392
+ guards emptiness separately. Each arg may be slot-scoped as `type@slot`."""
393
+ a_devs = [d for d in self.t.devices.values() if self._match(d, type_a)]
394
+ b_ids = {d.id for d in self.t.devices.values() if self._match(d, type_b)}
395
+ if not a_devs or not b_ids:
396
+ return not a_devs # no A → vacuously true; A but no B → false
397
+ adj = self._adjacency()
398
+ return all(any(peer in b_ids for peer in adj.get(da.id, ())) for da in a_devs)
399
+
400
+ def path_types(self, type_a: str, type_b: str) -> bool:
401
+ """Is there a path (over links) between some device of type_a and some device of type_b?"""
402
+ dsts = {d.id for d in self.t.devices.values() if self._match(d, type_b)}
403
+ if not dsts:
404
+ return False
405
+ adj = self._adjacency()
406
+ for src in [d.id for d in self.t.devices.values() if self._match(d, type_a)]:
407
+ seen, frontier = {src}, [src]
408
+ while frontier:
409
+ nxt = []
410
+ for node in frontier:
411
+ for peer in adj.get(node, ()):
412
+ if peer in dsts and peer != src:
413
+ return True
414
+ if peer not in seen:
415
+ seen.add(peer)
416
+ nxt.append(peer)
417
+ frontier = nxt
418
+ return False
419
+
420
+ def through_types(self, gate_type: str, src_type: str, dst_type: str) -> bool:
421
+ """Chokepoint: some src-type reaches some dst-type, and EVERY such path crosses a device of
422
+ `gate_type` — i.e. removing all gate-type devices disconnects the src tier from the dst tier.
423
+ This is the correct "traffic passes THROUGH the gate" semantic (rejects a parallel bypass),
424
+ as opposed to `path` (any route counts) or `link` (a direct cable)."""
425
+ if not self.path_types(src_type, dst_type):
426
+ return False # no traffic flows at all → not "through" anything
427
+ gates = {d.id for d in self.t.devices.values() if d.type_key == gate_type}
428
+ if not gates:
429
+ return False # the gate isn't even present
430
+ dsts = {d.id for d in self.t.devices.values()
431
+ if d.type_key == dst_type and d.id not in gates}
432
+ srcs = [d.id for d in self.t.devices.values()
433
+ if d.type_key == src_type and d.id not in gates]
434
+ if not dsts or not srcs:
435
+ return True # removing gates erases a tier → the gate was on the path
436
+ adj = self._adjacency()
437
+ for src in srcs: # can any src reach a dst WITHOUT crossing a gate?
438
+ seen, frontier = {src}, [src]
439
+ while frontier:
440
+ nxt = []
441
+ for node in frontier:
442
+ for peer in adj.get(node, ()):
443
+ if peer in gates:
444
+ continue # never route through a gate node
445
+ if peer in dsts and peer != src:
446
+ return False # a gate-free path exists → NOT a chokepoint
447
+ if peer not in seen:
448
+ seen.add(peer)
449
+ nxt.append(peer)
450
+ frontier = nxt
451
+ return True
452
+
453
+ def contains_types(self, box_type: str, member_type: str) -> bool:
454
+ """Is some device of member_type inside a box of box_type (walking the parent chain)?"""
455
+ for d in self.t.devices.values():
456
+ if d.type_key != member_type:
457
+ continue
458
+ cur, seen = d, set()
459
+ while cur is not None and getattr(cur, "parent_id", None) and cur.id not in seen:
460
+ seen.add(cur.id)
461
+ parent = self.t.devices.get(cur.parent_id)
462
+ if parent is not None and parent.type_key == box_type:
463
+ return True
464
+ cur = parent
465
+ return False
466
+
467
+
468
+ def element_types_in_check(expr: str) -> list[str]:
469
+ """The type_keys referenced by exists()/count() in a check — for lesson validation."""
470
+ out: list[str] = []
471
+ try:
472
+ tree = ast.parse(expr, mode="eval")
473
+ except SyntaxError:
474
+ return out
475
+ for node in ast.walk(tree):
476
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
477
+ and node.func.id in _TYPE_ARG_FUNCS):
478
+ for a in node.args: # every arg of a type-based predicate is a type_key
479
+ if isinstance(a, ast.Name):
480
+ out.append(a.id)
481
+ elif isinstance(a, ast.Constant) and isinstance(a.value, str):
482
+ out.append(a.value)
483
+ return out
484
+
485
+
486
+ def unknown_element_types(expr: str) -> list[str]:
487
+ """type_keys in exists()/count() that aren't real GINI elements (validation)."""
488
+ return [t for t in element_types_in_check(expr) if t not in REGISTRY]
gini/domain/os_zoo.py ADDED
@@ -0,0 +1,79 @@
1
+ """OS Zoo catalog — the historical/classic operating systems students can boot in GINI.
2
+
3
+ Pure data (no Qt, no Docker), so the palette, the compiler emission, and the Zoo Lab all read one
4
+ source of truth and it's unit-testable. Two tiers:
5
+
6
+ • FULL — freely redistributable; GINI builds/ships the image, boots out of the box.
7
+ • SKELETAL — proprietary (Windows, classic Mac); GINI ships only a shell with the right emulator
8
+ and a REFERENCE link. The student obtains the image/ROM legally and supplies its
9
+ path. GINI hosts nothing copyrighted (see OS_ZOO_DESIGN.md §8).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ FULL = "full"
16
+ SKELETAL = "skeletal"
17
+
18
+ # emulator backends
19
+ QEMU = "qemu"
20
+ DOSBOX = "dosbox-x"
21
+ BASILISK = "basilisk" # 68k classic Mac (needs a Mac ROM -> always skeletal)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ZooOS:
26
+ id: str # catalog id / ZOO_OS env value
27
+ label: str # palette + Zoo Lab title
28
+ tier: str # FULL | SKELETAL
29
+ emulator: str # QEMU | DOSBOX | BASILISK
30
+ arch: str # x86 | x86_64 | 68k
31
+ year: str # era, for the palette blurb
32
+ blurb: str # one line
33
+ source: str = "" # SKELETAL: where the student can legally obtain the image/ROM
34
+ nic: str = "e1000" # v2 networking default (per-OS; old guests need old NICs)
35
+
36
+ @property
37
+ def full(self) -> bool:
38
+ return self.tier == FULL
39
+
40
+ @property
41
+ def byo(self) -> bool:
42
+ """Skeletal = 'bring your own image': the student must supply the disk/ROM."""
43
+ return self.tier == SKELETAL
44
+
45
+
46
+ CATALOG: tuple[ZooOS, ...] = (
47
+ ZooOS("freedos", "FreeDOS", FULL, QEMU, "x86", "1998→",
48
+ "An open, still-maintained MS-DOS-compatible OS — the command-line PC of the DOS era.",
49
+ nic="ne2k_pci"),
50
+ ZooOS("kolibri", "KolibriOS", FULL, QEMU, "x86", "2004→",
51
+ "A tiny, blazing-fast GUI OS written entirely in assembly — the whole system boots from "
52
+ "a single 1.44 MB floppy in seconds.",
53
+ nic="rtl8139"),
54
+ ZooOS("menuet", "MenuetOS", FULL, QEMU, "x86", "2000→",
55
+ "The assembly GUI OS that KolibriOS forked from — the whole desktop, apps and all, on a "
56
+ "single 1.44 MB floppy. The 32-bit build is GPL. Boots in seconds.",
57
+ nic="rtl8139"),
58
+ ZooOS("win95", "Windows 95", SKELETAL, QEMU, "x86", "1995",
59
+ "The OS that put a Start button on the world. Bring your own installation media.",
60
+ source="https://archive.org (search Windows 95 — you must own a license)",
61
+ nic="ne2k_pci"),
62
+ ZooOS("mac7", "Mac System 7", SKELETAL, BASILISK, "68k", "1991",
63
+ "Classic Mac OS on a Motorola 68k. Needs a Mac disk image AND a Macintosh ROM you own.",
64
+ source="https://archive.org / macintoshgarden.org (image) + a Mac ROM you own"),
65
+ )
66
+
67
+ _BY_ID = {o.id: o for o in CATALOG}
68
+
69
+
70
+ def get(os_id: str) -> ZooOS | None:
71
+ return _BY_ID.get(os_id)
72
+
73
+
74
+ def full_set() -> list[ZooOS]:
75
+ return [o for o in CATALOG if o.full]
76
+
77
+
78
+ def skeletal_set() -> list[ZooOS]:
79
+ return [o for o in CATALOG if o.byo]