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,141 @@
1
+ """Page-replacement simulator — the real algorithms (FIFO / LRU / Optimal) run over a reference
2
+ string with a bounded frame budget. Deterministic and exact, so it is ground truth for the thrashing
3
+ game and a substrate for other paging games (policy comparison, Belady's anomaly, next-eviction).
4
+
5
+ This is a faithful implementation of the algorithms, not a hand-waved model — but it is NOT xv6-live
6
+ (xv6 has no eviction). A live page-replacement mechanism in the kernel is a separate, larger build.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class ReplayResult:
15
+ policy: str
16
+ frames: int
17
+ faults: int
18
+ evictions: int
19
+ fault_at: list # bool per reference — was it a page fault?
20
+
21
+
22
+ def simulate(refs, frames: int, policy: str = "lru") -> ReplayResult:
23
+ """Run `policy` over `refs` with `frames` physical frames. FIFO / LRU / OPT."""
24
+ refs = list(refs)
25
+ policy = policy.lower()
26
+ if frames <= 0:
27
+ return ReplayResult(policy, frames, len(refs), 0, [True] * len(refs))
28
+ if policy == "opt":
29
+ return _opt(refs, frames)
30
+ inset: set = set()
31
+ order: list = [] # FIFO: insertion order; LRU: recency (least-recent at front)
32
+ faults = evictions = 0
33
+ fault_at: list = []
34
+ for pg in refs:
35
+ hit = pg in inset
36
+ fault_at.append(not hit)
37
+ if hit:
38
+ if policy == "lru":
39
+ order.remove(pg); order.append(pg) # touch -> most recent
40
+ continue
41
+ faults += 1
42
+ if len(inset) >= frames:
43
+ victim = order.pop(0) # FIFO oldest / LRU least-recent
44
+ inset.discard(victim); evictions += 1
45
+ inset.add(pg); order.append(pg)
46
+ return ReplayResult(policy, frames, faults, evictions, fault_at)
47
+
48
+
49
+ def _opt(refs, frames) -> ReplayResult:
50
+ inset: set = set()
51
+ faults = evictions = 0
52
+ fault_at: list = []
53
+ for i, pg in enumerate(refs):
54
+ hit = pg in inset
55
+ fault_at.append(not hit)
56
+ if hit:
57
+ continue
58
+ faults += 1
59
+ if len(inset) >= frames:
60
+ def next_use(p):
61
+ for j in range(i + 1, len(refs)):
62
+ if refs[j] == p:
63
+ return j
64
+ return 1 << 30 # never used again -> evict first
65
+ victim = max(inset, key=next_use)
66
+ inset.discard(victim); evictions += 1
67
+ inset.add(pg)
68
+ return ReplayResult("opt", frames, faults, evictions, fault_at)
69
+
70
+
71
+ def resident_state(refs, frames: int, policy: str = "lru") -> tuple:
72
+ """Replay `refs` and return (resident_set, order) at the end. `order` is eviction order: FIFO
73
+ insertion order / LRU least-recent-first — so order[0] is the next victim when full."""
74
+ policy = policy.lower()
75
+ inset: set = set()
76
+ order: list = []
77
+ for pg in refs:
78
+ if pg in inset:
79
+ if policy == "lru":
80
+ order.remove(pg); order.append(pg)
81
+ continue
82
+ if len(inset) >= frames and order:
83
+ victim = order.pop(0)
84
+ inset.discard(victim)
85
+ inset.add(pg); order.append(pg)
86
+ return inset, order
87
+
88
+
89
+ # -- metrics (the observable signature) -------------------------------------------------------- #
90
+ def fault_rate(res: ReplayResult) -> float:
91
+ return res.faults / len(res.fault_at) if res.fault_at else 0.0
92
+
93
+
94
+ def unique_pages(refs) -> int:
95
+ return len(set(refs))
96
+
97
+
98
+ def peak_working_set(refs, window: int = 10) -> int:
99
+ """Largest number of distinct pages touched within any `window`-reference sliding window."""
100
+ from collections import deque
101
+ w: deque = deque()
102
+ seen: dict = {}
103
+ peak = 0
104
+ for p in refs:
105
+ w.append(p); seen[p] = seen.get(p, 0) + 1
106
+ if len(w) > window:
107
+ old = w.popleft(); seen[old] -= 1
108
+ if seen[old] == 0:
109
+ del seen[old]
110
+ peak = max(peak, len(seen))
111
+ return peak
112
+
113
+
114
+ def ws_growth(refs) -> float:
115
+ """How much the distinct-page set grows from the first half to the second (phase change)."""
116
+ if len(refs) < 4:
117
+ return 0.0
118
+ h = len(refs) // 2
119
+ a = len(set(refs[:h])); b = len(set(refs[h:]))
120
+ return max(0.0, (b - a) / max(a, 1))
121
+
122
+
123
+ def locality(refs, window: int = 8) -> float:
124
+ """Fraction of reuses whose previous touch was within `window` references (1 = tight locality)."""
125
+ last: dict = {}
126
+ hits = total = 0
127
+ for i, p in enumerate(refs):
128
+ if p in last:
129
+ total += 1
130
+ if i - last[p] <= window:
131
+ hits += 1
132
+ last[p] = i
133
+ return hits / total if total else 0.0
134
+
135
+
136
+ def run_features(refs, frames: int, policy: str = "lru") -> dict:
137
+ """The observable signature of a run — what the thrashing game shows the student."""
138
+ res = simulate(refs, frames, policy)
139
+ return {"fault_rate": fault_rate(res), "frames": frames,
140
+ "working_set": peak_working_set(refs), "unique_pages": unique_pages(refs),
141
+ "ws_growth": ws_growth(refs), "locality": locality(refs)}
gini/domain/pricing.py ADDED
@@ -0,0 +1,199 @@
1
+ """GINI $ — the toy cloud-economics model behind the dashboard.
2
+
3
+ Every element you place is a "rented" cloud resource billed by the hour in GINI $ (a
4
+ pretend currency — purely pedagogical, so students feel pay-as-you-go without spending
5
+ real money). The dashboard accrues `rate_per_hr × elapsed` while a lab runs.
6
+
7
+ Rates are deliberately round numbers that *teach the relative cost of cloud building
8
+ blocks*: a managed database costs more than a plain VM, which costs more than a switch.
9
+ Override any rate in Settings → Pricing (persisted to ~/.gini).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ # Default rental rates in GINI $ per hour, keyed by element type_key.
14
+ DEFAULT_RATES: dict[str, float] = {
15
+ # --- compute (you pay most for managed/large compute) ------------------- #
16
+ "instance": 10.0, # VM
17
+ "kinstance": 14.0, # Kata Instance — a real microVM, so it costs more than a VM image
18
+ "container": 5.0,
19
+ "host": 2.0, # a plain Machine / end host
20
+ "desktop": 3.0, # a headful Machine (lean host + a light X desktop over noVNC)
21
+ "xv6": 2.0, # xv6 teaching kernel under QEMU-RISC-V (a single-CPU learning VM)
22
+ # --- OS Zoo (emulated historical OSes; each is a QEMU/emulator VM) --------- #
23
+ "freedos": 2.0,
24
+ "kolibri": 2.0,
25
+ "menuet": 2.0,
26
+ "msdos": 2.0, # MS-DOS 6.22 preset (QEMU; disk image downloaded)
27
+ "mac7": 3.0, # Mac System 7 preset (Basilisk II; image/ROM downloaded)
28
+ "win31": 3.0, # Windows 3.11 preset (DOSBox; image downloaded)
29
+ "oszoo_byo": 3.0, # bring-your-own classic OS (Windows/Mac) under emulation
30
+ # --- serverless (cheap, scale-to-zero; pay per use, not per idle hour) --- #
31
+ "function": 1.0, # a handler in the shared FaaS runtime
32
+ "api_gateway": 5.0, # a managed API front door (Traefik)
33
+ # --- containers & kubernetes -------------------------------------------- #
34
+ "pod": 5.0, # a Deployment — billed per replica (scaling costs money)
35
+ "k8s_cluster": 8.0, # a managed control plane (k3s)
36
+ "k8s_node": 8.0, # a worker node (a VM)
37
+ "instance_group": 2.0, # Pod Autoscaler (HPA) — the autoscaling feature itself
38
+ "registry": 3.0, # an image registry
39
+ # --- networking --------------------------------------------------------- #
40
+ "router": 3.0,
41
+ "ovs": 3.0, # OpenFlow switch (a gRouter in OF mode)
42
+ "controller": 4.0, # SDN controller
43
+ "firewall": 2.0,
44
+ "vnf": 3.0, # a network-function container in the path (NFV)
45
+ "switch": 1.0,
46
+ "wap": 1.5, # access point
47
+ "hub": 0.5,
48
+ "cloud": 1.0, # the Internet element = a NAT gateway (small fee)
49
+ # --- managed services (the expensive, "someone runs it for you" tier) --- #
50
+ "database": 12.0,
51
+ "nosql": 10.0,
52
+ "object_store": 8.0,
53
+ "stream": 9.0,
54
+ "queue": 6.0,
55
+ "messaging": 6.0,
56
+ "cache": 4.0,
57
+ "load_balancer": 5.0,
58
+ "proxy": 3.0,
59
+ "web_app": 4.0,
60
+ # --- observability / workload ------------------------------------------ #
61
+ "metrics": 5.0, # Prometheus
62
+ "dashboard": 5.0, # Grafana
63
+ "tracing": 5.0, # Jaeger
64
+ "load_generator": 2.0,
65
+ }
66
+
67
+ # Instance "size" tiers (like cloud instance types). level -> (label, vCPUs, mem_MB,
68
+ # cost_multiplier). Cost roughly doubles each step up, mirroring real cloud pricing.
69
+ SIZE_TIERS: dict[int, tuple[str, float, int, int]] = {
70
+ 1: ("S", 0.5, 256, 1),
71
+ 2: ("M", 1.0, 512, 2),
72
+ 3: ("L", 2.0, 1024, 4),
73
+ 4: ("XL", 4.0, 2048, 8),
74
+ }
75
+ SIZE_MIN, SIZE_MAX = 1, 4
76
+
77
+
78
+ def size_level(level) -> int:
79
+ try:
80
+ return max(SIZE_MIN, min(SIZE_MAX, int(level or 1)))
81
+ except (TypeError, ValueError):
82
+ return 1
83
+
84
+
85
+ def size_tier(level) -> tuple[str, float, int, int]:
86
+ return SIZE_TIERS[size_level(level)]
87
+
88
+
89
+ def size_label(level) -> str:
90
+ return size_tier(level)[0]
91
+
92
+
93
+ def size_cost_mult(level) -> int:
94
+ return size_tier(level)[3]
95
+
96
+
97
+ def resizable(type_key: str) -> bool:
98
+ """Which elements expose a size knob — things that run a real workload container
99
+ and meaningfully have a capacity (compute + managed services). Not switches/hubs."""
100
+ from ..services.cloud_catalog import is_service # lazy: keep domain below services
101
+ return is_service(type_key) or type_key in ("instance", "kinstance", "container", "host", "xv6")
102
+
103
+
104
+ # Dashboard breakdown groups. Order is the display order.
105
+ CATEGORIES: dict[str, tuple[str, ...]] = {
106
+ "Compute": ("instance", "kinstance", "container", "host", "desktop", "xv6"),
107
+ "Serverless": ("function", "api_gateway"),
108
+ "Kubernetes": ("k8s_cluster", "pod", "k8s_node", "instance_group", "registry"),
109
+ "Networking": ("router", "ovs", "controller", "firewall", "vnf", "switch",
110
+ "wap", "hub", "cloud"),
111
+ "Services": ("database", "nosql", "object_store", "stream", "queue",
112
+ "messaging", "cache", "load_balancer", "proxy", "web_app"),
113
+ "Observability": ("metrics", "dashboard", "tracing", "load_generator"),
114
+ "OS Zoo": ("freedos", "kolibri", "menuet", "msdos", "mac7", "win31", "oszoo_byo"),
115
+ }
116
+ CATEGORY_ORDER = tuple(CATEGORIES.keys())
117
+ _CAT_OF = {tk: cat for cat, tks in CATEGORIES.items() for tk in tks}
118
+
119
+ # the type_keys that are actually billable (everything in a category).
120
+ BILLABLE = frozenset(_CAT_OF)
121
+
122
+ # Intentionally-free elements, listed so the coverage test can tell "deliberately not
123
+ # billed" from "someone added an element and forgot to price it" (the latter would make
124
+ # the meter under-count — the bug this guards against). Two kinds:
125
+ # * grouping boundaries — visual regions, not rented resources;
126
+ # * placeholders — elements with no real runtime yet (priced once they become real).
127
+ FREE = frozenset({
128
+ "vpc", "cloud_subnet", "region", # grouping boundaries
129
+ "security_group", "gateway", "block_volume", # not-yet-real placeholders
130
+ "terminal", "storage_volume", # xv6 peripherals (software devices, no cost)
131
+ # A GINI32 board is REAL hardware the student already owns, not a rented
132
+ # resource. The meter models a cloud bill, so billing your own $5 chip would be
133
+ # the wrong lesson — the relay that carries it is part of the lab, not a rental.
134
+ "gini32",
135
+ # Sources / Sinks (riders) — instruments that run inside a donor, no container of their own
136
+ "ping_probe", "http_probe", "packet_view", "dns_probe", "traceroute_probe",
137
+ "iperf_client", "iperf_server", "iface_stats", "xv6_shell", "xv6_workload",
138
+ })
139
+
140
+
141
+ def category_of(type_key: str) -> str | None:
142
+ return _CAT_OF.get(type_key)
143
+
144
+
145
+ def rate_of(type_key: str, overrides: dict | None = None) -> float:
146
+ """GINI $/hr for a type, honoring user overrides from Settings → Pricing."""
147
+ if overrides and type_key in overrides:
148
+ try:
149
+ return max(0.0, float(overrides[type_key]))
150
+ except (TypeError, ValueError):
151
+ pass
152
+ return float(DEFAULT_RATES.get(type_key, 0.0))
153
+
154
+
155
+ def _int(v, default: int = 1) -> int:
156
+ try:
157
+ return int(float(v))
158
+ except (TypeError, ValueError):
159
+ return default
160
+
161
+
162
+ def units(type_key: str, properties: dict | None) -> int:
163
+ """How many billable units one element represents. A Pod is a Deployment of N
164
+ replicas, so it bills per replica — scaling a workload up costs more, which is the
165
+ whole point of Kubernetes/HPA. Everything else is a single unit."""
166
+ if type_key == "pod":
167
+ return max(1, _int((properties or {}).get("Replicas"), 1))
168
+ return 1
169
+
170
+
171
+ def bill(topology, overrides: dict | None = None) -> dict:
172
+ """Cost estimate for the whole canvas.
173
+
174
+ Returns::
175
+
176
+ {"rate_per_hr": float, # GINI $/hr for every billable element
177
+ "count": int, # number of billable elements
178
+ "by_category": {cat: {"rate": float, "count": int}}} # display order
179
+ """
180
+ by_cat: dict[str, dict] = {c: {"rate": 0.0, "count": 0} for c in CATEGORY_ORDER}
181
+ total = 0.0
182
+ n = 0
183
+ for d in getattr(topology, "devices", {}).values():
184
+ tk = getattr(d, "type_key", None)
185
+ if tk not in BILLABLE:
186
+ continue
187
+ cat = _CAT_OF[tk]
188
+ # bigger instance size = proportionally more GINI $/hr (x1/x2/x4/x8)
189
+ mult = size_cost_mult(getattr(d, "size", 1)) if resizable(tk) else 1
190
+ # a Pod bills per replica (scaling a workload costs more)
191
+ qty = units(tk, getattr(d, "properties", None))
192
+ r = rate_of(tk, overrides) * mult * qty
193
+ by_cat[cat]["rate"] += r
194
+ by_cat[cat]["count"] += 1
195
+ total += r
196
+ n += 1
197
+ # drop empty categories so the strip stays compact
198
+ by_cat = {c: v for c, v in by_cat.items() if v["count"]}
199
+ return {"rate_per_hr": round(total, 2), "count": n, "by_category": by_cat}
gini/domain/probes.py ADDED
@@ -0,0 +1,226 @@
1
+ """Behavioral probes — the language of GINI-as-oracle win conditions.
2
+
3
+ A behavioral objective asserts a fact about the RUNNING system, which only GINI's runtime can
4
+ witness. This module parses the probe strings and evaluates them against a `Runner` — the thin
5
+ seam over the running topology (Docker exec of ping/curl, flow-table reads). GINI, never the
6
+ model, produces the verdict.
7
+
8
+ Probe grammar (Phase 2):
9
+ reach(A -> B) == ok|fail # L3 reachability (ICMP); with :port → TCP/L4
10
+ reach(A -> B:port) == ok|fail
11
+ reach(A -> B, all) == ok # EVERY A-B pair, not just some (see TypeRunner.reach_all)
12
+ ping(A -> B) == ok|fail # explicit ICMP
13
+ http(A -> B:port) == ok|fail # an HTTP 2xx from A to B:port
14
+ balances(LB, >= n) # LB fans out to at least n live backends
15
+ flow_installed(OVS, <match>) # an OpenFlow rule matching <match> is installed
16
+
17
+ Pure parsing + evaluation; the real Docker-backed runner lives in `services/probe_runner.py`,
18
+ and a `FakeRunner` (facts dict) makes everything unit-testable offline.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from typing import Protocol
25
+
26
+ # probe kinds
27
+ REACH, PING, HTTP, BALANCES, FLOW = "reach", "ping", "http", "balances", "flow_installed"
28
+ MEASURE = "measure"
29
+
30
+ _REACH_RE = re.compile( # `\w+` or a slot-scoped `\w+@\w+` (e.g. host@A)
31
+ r"^\s*(reach|ping|http)\(\s*([\w@]+)\s*->\s*([\w@]+)(?::(\d+))?\s*(?:,\s*(all|any)\s*)?\)"
32
+ r"\s*==\s*(ok|fail)\s*$")
33
+ _BAL_RE = re.compile(r"^\s*balances\(\s*(\w+)\s*,\s*>=\s*(\d+)\s*\)\s*$")
34
+ _FLOW_RE = re.compile(r"^\s*flow_installed\(\s*(\w+)\s*,\s*(.+?)\s*\)\s*$")
35
+ # measure(<rider_type>, <metric>) <op> <value> — an output assertion on a Sink/Source measurement
36
+ _MEASURE_RE = re.compile(
37
+ r"^\s*measure\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*(>=|<=|==|!=|>|<)\s*(-?\d+(?:\.\d+)?)\s*$")
38
+
39
+
40
+ class ProbeError(ValueError):
41
+ pass
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Probe:
46
+ kind: str
47
+ src: str = ""
48
+ dst: str = ""
49
+ port: int | None = None
50
+ expect_ok: bool = True # for reach/ping/http: True = expect success, False = expect failure
51
+ n: int = 0 # for balances
52
+ match: str = "" # for flow_installed
53
+ quant: str = "any" # "any" = SOME pair satisfies it; "all" = EVERY pair must
54
+ metric: str = "" # for measure: the rider metric (loss_pct, packets, mbps, …)
55
+ op: str = ">=" # for measure: comparison operator
56
+ value: float = 0.0 # for measure: the threshold
57
+
58
+
59
+ class Runner(Protocol):
60
+ """The running-topology seam. `available()` is False when there's no live runtime, so
61
+ behavioral objectives stay `pending` rather than being wrongly failed."""
62
+ def available(self) -> bool: ...
63
+ def reach(self, src: str, dst: str, port: int | None = None) -> bool: ...
64
+ def http(self, src: str, dst: str, port: int) -> bool: ...
65
+ def backends(self, lb: str) -> int: ...
66
+ def flow(self, ovs: str, match: str) -> bool: ...
67
+ def measure(self, rider_type: str, metric: str) -> float | None: ...
68
+
69
+
70
+ def parse(probe: str) -> Probe:
71
+ m = _REACH_RE.match(probe or "")
72
+ if m:
73
+ kind, src, dst, port, quant, expect = m.groups()
74
+ return Probe(kind=kind, src=src, dst=dst,
75
+ port=int(port) if port else None, expect_ok=(expect == "ok"),
76
+ quant=quant or "any")
77
+ m = _BAL_RE.match(probe or "")
78
+ if m:
79
+ return Probe(kind=BALANCES, src=m.group(1), n=int(m.group(2)))
80
+ m = _FLOW_RE.match(probe or "")
81
+ if m:
82
+ return Probe(kind=FLOW, src=m.group(1), match=m.group(2))
83
+ m = _MEASURE_RE.match(probe or "")
84
+ if m:
85
+ return Probe(kind=MEASURE, src=m.group(1), metric=m.group(2),
86
+ op=m.group(3), value=float(m.group(4)))
87
+ raise ProbeError(f"cannot parse probe: {probe!r}")
88
+
89
+
90
+ _OPS = {">=": lambda a, b: a >= b, "<=": lambda a, b: a <= b, ">": lambda a, b: a > b,
91
+ "<": lambda a, b: a < b, "==": lambda a, b: a == b, "!=": lambda a, b: a != b}
92
+
93
+
94
+ def probe_ok(probe: str) -> bool:
95
+ try:
96
+ parse(probe)
97
+ return True
98
+ except ProbeError:
99
+ return False
100
+
101
+
102
+ def evaluate(probe: str, runner: Runner) -> bool:
103
+ """Evaluate a probe string against a live runner → the boolean fact.
104
+
105
+ The `all` quantifier only means something to a TYPE-resolving runner (one token can name many
106
+ devices). A plain name-based runner has exactly one pair, so `all` and `any` coincide and it
107
+ can simply not implement `*_all` — hence the getattr fallback rather than a protocol break."""
108
+ p = parse(probe)
109
+ if p.kind in (REACH, PING):
110
+ fn = getattr(runner, "reach_all", None) if p.quant == "all" else None
111
+ got = fn(p.src, p.dst, p.port) if fn else runner.reach(p.src, p.dst, p.port)
112
+ return got == p.expect_ok
113
+ if p.kind == HTTP:
114
+ fn = getattr(runner, "http_all", None) if p.quant == "all" else None
115
+ port = p.port or 80
116
+ got = fn(p.src, p.dst, port) if fn else runner.http(p.src, p.dst, port)
117
+ return got == p.expect_ok
118
+ if p.kind == BALANCES:
119
+ return runner.backends(p.src) >= p.n
120
+ if p.kind == FLOW:
121
+ return runner.flow(p.src, p.match)
122
+ if p.kind == MEASURE:
123
+ got = runner.measure(p.src, p.metric)
124
+ if got is None:
125
+ return False # no reading yet — treat as not-satisfied, not a crash
126
+ return _OPS[p.op](got, p.value)
127
+ raise ProbeError(f"unknown probe kind {p.kind!r}")
128
+
129
+
130
+ # -- type-aware resolution (name-agnostic behavioral probes) ---------------- #
131
+ class TypeRunner:
132
+ """Wraps a name-based `Runner` so a probe's tokens are element TYPE keys, resolved existentially
133
+ against the live topology — the behavioral analogue of our type-based structural predicates.
134
+ `reach(web_app -> database)` becomes "SOME web_app device reaches SOME database device", so a
135
+ behavioral objective matches whatever the student actually named things (exactly like `path`).
136
+
137
+ `get_topology()` returns the live topology (anything with `.devices` whose values have
138
+ `.name`/`.type_key`). Delegates availability to the wrapped runner."""
139
+
140
+ def __init__(self, base: Runner, get_topology) -> None:
141
+ self.base = base
142
+ self._get_topology = get_topology
143
+
144
+ def available(self) -> bool:
145
+ return bool(self.base) and self.base.available()
146
+
147
+ def _names(self, spec: str) -> list[str]:
148
+ """Names of devices matching `type` or a slot-scoped `type@slot` (cross-slot reach). Slot
149
+ scope is hierarchical: `host@pods0` includes hosts nested in `pods0_lans1` (see slot_match)."""
150
+ from .objectives import slot_match
151
+ type_key, _, slot = str(spec).partition("@")
152
+ t = self._get_topology()
153
+ return [d.name for d in getattr(t, "devices", {}).values()
154
+ if getattr(d, "type_key", None) == type_key
155
+ and slot_match(getattr(d, "slot", ""), slot)]
156
+
157
+ def _pairs(self, src: str, dst: str):
158
+ """Every (src, dst) name pair — EXCLUDING a device paired with itself. When both tokens are
159
+ the same type (`reach(host -> host)`, the LAN-repair case) the self-pair would make the
160
+ probe vacuously true: a host can always ping its own loopback. Proving a host talks to
161
+ *itself* proves nothing about the network."""
162
+ for s in self._names(src):
163
+ for d in self._names(dst):
164
+ if s != d:
165
+ yield s, d
166
+
167
+ def reach(self, src: str, dst: str, port: int | None = None) -> bool:
168
+ return any(self.base.reach(s, d, port) for s, d in self._pairs(src, dst))
169
+
170
+ def http(self, src: str, dst: str, port: int) -> bool:
171
+ return any(self.base.http(s, d, port) for s, d in self._pairs(src, dst))
172
+
173
+ # -- universal ("all") resolution --------------------------------------- #
174
+ # `reach(host -> host, all)` = EVERY host reaches every other. Needed for repair missions: with
175
+ # the existential reading, one healthy pair would mask a broken third host. An empty pair set is
176
+ # False, not vacuously True — "no hosts" must never look like "all hosts talk".
177
+ def reach_all(self, src: str, dst: str, port: int | None = None) -> bool:
178
+ pairs = list(self._pairs(src, dst))
179
+ return bool(pairs) and all(self.base.reach(s, d, port) for s, d in pairs)
180
+
181
+ def http_all(self, src: str, dst: str, port: int) -> bool:
182
+ pairs = list(self._pairs(src, dst))
183
+ return bool(pairs) and all(self.base.http(s, d, port) for s, d in pairs)
184
+
185
+ def backends(self, lb: str) -> int:
186
+ return max((self.base.backends(n) for n in self._names(lb)), default=0)
187
+
188
+ def flow(self, ovs: str, match: str) -> bool:
189
+ return any(self.base.flow(n, match) for n in self._names(ovs))
190
+
191
+ def measure(self, rider_type: str, metric: str) -> float | None:
192
+ # measure is inherently type-based (you assert on "the packet_view" of the fragment) —
193
+ # delegate straight to the base, which finds a rider of that type and runs it.
194
+ fn = getattr(self.base, "measure", None)
195
+ return fn(rider_type, metric) if fn else None
196
+
197
+
198
+ # -- a fake runner for tests + offline authoring ---------------------------- #
199
+ class FakeRunner:
200
+ """A scriptable runner backed by a facts dict — for unit tests and lesson playtests without
201
+ Docker. Facts keys: ('reach', src, dst, port) -> bool, ('http', src, dst, port) -> bool,
202
+ ('backends', lb) -> int, ('flow', ovs, match) -> bool."""
203
+
204
+ def __init__(self, facts: dict | None = None, available: bool = True,
205
+ default: bool = False) -> None:
206
+ self.facts = facts or {}
207
+ self._available = available
208
+ self._default = default # verdict for unscripted reach/http facts
209
+
210
+ def available(self) -> bool:
211
+ return self._available
212
+
213
+ def reach(self, src: str, dst: str, port: int | None = None) -> bool:
214
+ return bool(self.facts.get(("reach", src, dst, port), self._default))
215
+
216
+ def http(self, src: str, dst: str, port: int) -> bool:
217
+ return bool(self.facts.get(("http", src, dst, port), self._default))
218
+
219
+ def backends(self, lb: str) -> int:
220
+ return int(self.facts.get(("backends", lb), 0))
221
+
222
+ def measure(self, rider_type: str, metric: str) -> float | None:
223
+ return self.facts.get(("measure", rider_type, metric))
224
+
225
+ def flow(self, ovs: str, match: str) -> bool:
226
+ return bool(self.facts.get(("flow", ovs, match), False))