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,410 @@
1
+ """MachineState — the shared bridge between a running xv6 Machine and the rest of GINI.
2
+
3
+ Decision (signed off): the *state model* is the bridge, not the Machine Lab dialog. One
4
+ `MachineState` owns the provider, the latest `Snapshot`, the scheduling timeline, and a
5
+ `StateWatcher`; the Lab renders from it and the Ask GINI agent reads from it, so help works
6
+ whether or not the dialog is open and both sides see one atomic snapshot.
7
+
8
+ Everything here is pure (no Qt): the provider is injected (the offline `DemoScheduler` today,
9
+ a GDB bridge on the Mac later), so the serializer, the deltas, and the event watcher are all
10
+ unit-tested without QEMU.
11
+
12
+ • `state_card(...)` — a compact, progressive (L0/L1/L2) text block the agent is fed.
13
+ • `StateWatcher` — diffs successive snapshots into pedagogical events for Coach.
14
+ • `MachineState` — owns provider + timeline + watcher; refresh/step/controls + card().
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from .xv6 import DemoScheduler, SchedTimeline, Snapshot
21
+ from .xv6_fs import DemoDisk, fs_summary
22
+ from .xv6_vm import DemoVm, memory_summary
23
+
24
+
25
+ # --------------------------------------------------------------------------- #
26
+ # Pedagogical events (Coach consumes these)
27
+ # --------------------------------------------------------------------------- #
28
+ @dataclass
29
+ class OsEvent:
30
+ kind: str # starvation | cpu_monopoly | zombie_leak | idle | control
31
+ detail: str # human one-liner
32
+ pid: int | None = None
33
+
34
+
35
+ @dataclass
36
+ class CoachLedger:
37
+ """'Measured help' bookkeeping: a per-machine hint BUDGET plus an instructor-visible LOG.
38
+ The moat is that GINI's help is grounded in live kernel state a foreign agent can't see;
39
+ the measure is that the help is finite and recorded, so offloading shows up as a signal."""
40
+ budget: int = 8
41
+ used: int = 0
42
+ log: list = field(default_factory=list) # [{n, events:[(kind,detail,pid)], hint}]
43
+
44
+ def remaining(self) -> int:
45
+ return max(0, self.budget - self.used)
46
+
47
+ def can_help(self) -> bool:
48
+ return self.remaining() > 0
49
+
50
+ def record(self, events, hint: str = "") -> int:
51
+ self.used += 1
52
+ self.log.append({"n": self.used,
53
+ "events": [(e.kind, e.detail, e.pid) for e in events],
54
+ "hint": hint})
55
+ return self.remaining()
56
+
57
+
58
+ class StateWatcher:
59
+ """Edge-triggered detector: turns a stream of snapshots into a few teachable events.
60
+
61
+ Fires each condition once per episode (when it becomes true), and re-arms when it clears,
62
+ so Coach gets a nudge on the *transition*, not every tick."""
63
+
64
+ def __init__(self, starve: int = 4, monopoly: int = 5, zombie: int = 3) -> None:
65
+ self.starve, self.monopoly, self.zombie = starve, monopoly, zombie
66
+ self._runnable_streak: dict[int, int] = {}
67
+ self._running_streak: dict[int, int] = {}
68
+ self._zombie_age: dict[int, int] = {}
69
+ self._fired: set = set() # (kind, pid) currently-active conditions
70
+
71
+ def observe(self, snap: Snapshot) -> list[OsEvent]:
72
+ events: list[OsEvent] = []
73
+ present = {p.pid for p in snap.procs}
74
+ others_runnable = sum(1 for p in snap.procs if p.state == "runnable")
75
+ any_active = False
76
+ for p in snap.procs:
77
+ if p.state == "running":
78
+ any_active = True
79
+ self._running_streak[p.pid] = self._running_streak.get(p.pid, 0) + 1
80
+ self._runnable_streak[p.pid] = 0
81
+ elif p.state == "runnable":
82
+ any_active = True
83
+ self._runnable_streak[p.pid] = self._runnable_streak.get(p.pid, 0) + 1
84
+ self._running_streak[p.pid] = 0
85
+ else:
86
+ self._running_streak[p.pid] = 0
87
+ self._runnable_streak[p.pid] = 0
88
+ if p.state == "zombie":
89
+ self._zombie_age[p.pid] = self._zombie_age.get(p.pid, 0) + 1
90
+ else:
91
+ self._zombie_age.pop(p.pid, None)
92
+
93
+ # starvation — runnable a long time without ever getting the CPU
94
+ for pid, streak in self._runnable_streak.items():
95
+ self._edge(events, ("starvation", pid), streak >= self.starve,
96
+ lambda pid=pid, s=streak: OsEvent(
97
+ "starvation",
98
+ f"pid {pid} has stayed RUNNABLE for {s} slices without running "
99
+ "— it may be starving under this policy.", pid))
100
+ # cpu monopoly — one pid holds the CPU while others are ready
101
+ for pid, streak in self._running_streak.items():
102
+ self._edge(events, ("cpu_monopoly", pid),
103
+ streak >= self.monopoly and others_runnable > 0,
104
+ lambda pid=pid, s=streak: OsEvent(
105
+ "cpu_monopoly",
106
+ f"pid {pid} has held the CPU for {s} slices while others are "
107
+ "runnable — is the time-slice too large, or the policy unfair?", pid))
108
+ # zombie not reaped
109
+ for pid, age in self._zombie_age.items():
110
+ self._edge(events, ("zombie_leak", pid), age >= self.zombie,
111
+ lambda pid=pid, a=age: OsEvent(
112
+ "zombie_leak",
113
+ f"pid {pid} has been a ZOMBIE for {a} snapshots — its parent "
114
+ "hasn't wait()ed for it.", pid))
115
+ # nothing runnable/running — idle or stuck
116
+ self._edge(events, ("idle", None), not any_active,
117
+ lambda: OsEvent("idle",
118
+ "no process is runnable or running — the CPU is idle "
119
+ "(all sleeping/zombie). Deadlock, or just waiting?"))
120
+ # drop fired flags for pids that vanished
121
+ self._fired = {(k, pid) for (k, pid) in self._fired
122
+ if pid is None or pid in present}
123
+ return events
124
+
125
+ def _edge(self, out, key, cond, make) -> None:
126
+ if cond and key not in self._fired:
127
+ out.append(make())
128
+ self._fired.add(key)
129
+ elif not cond:
130
+ self._fired.discard(key)
131
+
132
+ def active(self, kind: str | None = None) -> set:
133
+ """The pids whose conditions are CURRENTLY active (not just fired once) — so the UI can
134
+ badge a starving/monopolising proc for as long as it's true, then clear it. Optionally
135
+ filter by kind ('starvation' | 'cpu_monopoly' | 'zombie_leak')."""
136
+ return {pid for (k, pid) in self._fired
137
+ if pid is not None and (kind is None or k == kind)}
138
+
139
+
140
+ # --------------------------------------------------------------------------- #
141
+ # The state card (what the agent is fed)
142
+ # --------------------------------------------------------------------------- #
143
+ _STATE_ORDER = {"running": 0, "runnable": 1, "sleeping": 2, "zombie": 3, "used": 4,
144
+ "unused": 5}
145
+
146
+
147
+ def state_card(snap: Snapshot, timeline: SchedTimeline | None = None,
148
+ meta: dict | None = None, deltas: list[str] | None = None,
149
+ level: int = 0) -> str:
150
+ """Render a compact, LLM-facing snapshot of the xv6 kernel. `level` is progressive:
151
+ 0 = scheduling picture (always on), 1 = + registers & kernel stack, 2 = + memory/FS."""
152
+ if snap is None:
153
+ return ""
154
+ meta = meta or {}
155
+ lines = ["xv6 Machine — live kernel state (ground truth; describe only what appears here)"]
156
+ pol = meta.get("policy", "round-robin")
157
+ ts = meta.get("timeslice")
158
+ head = f"policy: {pol}"
159
+ if ts is not None:
160
+ head += f" · time-slice: {ts} tick{'s' if ts != 1 else ''}"
161
+ if snap.ticks is not None:
162
+ head += f" · ticks: {snap.ticks}"
163
+ lines.append(head)
164
+ run = snap.running_pid
165
+ runp = next((p for p in snap.procs if p.pid == run), None)
166
+ lines.append(f"running: pid {run} ({runp.name})" if runp else "running: (none — idle)")
167
+ if snap.procs:
168
+ lines.append("processes (proc[]):")
169
+ for p in sorted(snap.procs, key=lambda p: (_STATE_ORDER.get(p.state, 9), p.pid)):
170
+ mark = " *" if p.pid == run else " "
171
+ lines.append(f" {mark} pid {p.pid:<3} {p.state:<9} {p.name}")
172
+ if timeline is not None:
173
+ tail = ",".join(str(s.pid) for s in timeline.recent(10))
174
+ lines.append(f"context switches: {timeline.switches()} · recent CPU: {tail}")
175
+ if deltas:
176
+ lines.append("changes since your last question: " + "; ".join(deltas))
177
+
178
+ if level >= 1:
179
+ cpu = snap.cpu
180
+ if cpu is not None:
181
+ regs = " ".join(f"{k}={cpu.key(k)}" for k in
182
+ ("pc", "sp", "ra", "satp", "a0", "a7"))
183
+ lines.append("CPU registers: " + regs)
184
+ if snap.stack:
185
+ lines.append("kernel stack (bt):")
186
+ for i, f in enumerate(snap.stack):
187
+ loc = f" {f.loc}" if f.loc else ""
188
+ lines.append(f" #{i} {f.fn}{loc}")
189
+ if level >= 2:
190
+ pt = getattr(snap, "page_table", None)
191
+ lines.append("memory: " + (pt if pt else
192
+ "page-table / FS detail not exported in this build"))
193
+ return "\n".join(lines)
194
+
195
+
196
+ # --------------------------------------------------------------------------- #
197
+ # MachineState — the owner
198
+ # --------------------------------------------------------------------------- #
199
+ @dataclass
200
+ class MachineState:
201
+ """Owns the live state for one xv6 Machine. `provider` has snapshot()/step()/
202
+ set_timeslice() (offline DemoScheduler or the Mac GDB bridge)."""
203
+ provider: object # scheduler feed (snapshot()/step()/…)
204
+ device_id: str = ""
205
+ policy: str = "round-robin"
206
+ mode: str = "real" # "real" (live kernel) | "demo" (stand-in). A
207
+ # USER choice — never auto-switched (see set_mode)
208
+ latest: Snapshot | None = None
209
+ timeline: SchedTimeline = field(default_factory=SchedTimeline)
210
+ cpu_timelines: dict = field(default_factory=dict) # cpu_index -> SchedTimeline (SMP)
211
+ watcher: StateWatcher = field(default_factory=StateWatcher)
212
+ ledger: CoachLedger = field(default_factory=CoachLedger)
213
+ vm: object = None # virtual-memory reader (snapshot()->VmSnapshot)
214
+ fs: object = None # file-system reader (snapshot()->FsSnapshot)
215
+ on_event: object = None # callback(self) fired on new pedagogical events
216
+ _events: list = field(default_factory=list)
217
+ _prev_card: dict = field(default_factory=dict)
218
+ # The two data "planes" the mode toggles between: each is (provider, vm, fs). The injected
219
+ # trio becomes the plane matching the initial `mode`; the other is created lazily. `_real`
220
+ # may stay None when no xv6 is running — then Real mode shows an error, never demo data.
221
+ _real: tuple | None = None
222
+ _demo: tuple | None = None
223
+
224
+ def __post_init__(self) -> None:
225
+ if self.vm is None:
226
+ self.vm = DemoVm() # offline stand-ins; Mac bridge overrides
227
+ if self.fs is None:
228
+ self.fs = DemoDisk()
229
+ trio = (self.provider, self.vm, self.fs)
230
+ if self.mode == "demo":
231
+ self._demo = self._demo or trio
232
+ else:
233
+ self._real = self._real or trio
234
+ try:
235
+ self._ingest(self.provider.snapshot())
236
+ except Exception:
237
+ pass
238
+
239
+ # -- mode (Real/Demo) — an explicit user action, never automatic --------- #
240
+ def has_real(self) -> bool:
241
+ """True when a live kernel plane is attached (an xv6 is running)."""
242
+ return bool(self._real and self._real[0] is not None)
243
+
244
+ def attach_real(self, provider, vm=None, fs=None) -> None:
245
+ """Wire the live bridge as the Real plane (called when the topology starts). If we're
246
+ currently in Real mode, switch to it now so an already-open Lab goes live."""
247
+ self._real = (provider, vm if vm is not None else getattr(provider, "vm", None),
248
+ fs if fs is not None else getattr(provider, "fs", None))
249
+ if self.mode == "real":
250
+ self.provider, self.vm, self.fs = self._real
251
+ self.refresh()
252
+
253
+ def set_mode(self, mode: str) -> None:
254
+ """Switch the active data plane. This is the ONLY place the source changes, and only when
255
+ the user asks — nothing auto-falls-back from real to demo. Clears the timelines so demo
256
+ and real samples never blend on the Gantt."""
257
+ if mode not in ("real", "demo") or mode == self.mode:
258
+ return
259
+ self.mode = mode
260
+ prov, vm, fs = self._plane(mode)
261
+ self.provider, self.vm, self.fs = prov, vm, fs
262
+ self.timeline = SchedTimeline()
263
+ self.cpu_timelines = {}
264
+ self.latest = None
265
+ if prov is not None:
266
+ try:
267
+ self._ingest(prov.snapshot())
268
+ except Exception:
269
+ pass
270
+
271
+ def _plane(self, mode: str) -> tuple:
272
+ if mode == "real":
273
+ return self._real if self.has_real() else (None, None, None)
274
+ if self._demo is None: # build the demo plane on first switch to it
275
+ self._demo = (DemoScheduler(), DemoVm(), DemoDisk())
276
+ return self._demo
277
+
278
+ # -- reads --------------------------------------------------------------- #
279
+ @property
280
+ def timeslice(self) -> int:
281
+ return int(getattr(self.provider, "timeslice", 1) or 1)
282
+
283
+ def refresh(self) -> Snapshot | None:
284
+ if self.provider is None: # Real mode with no running kernel -> no data
285
+ return None
286
+ self._ingest(self.provider.snapshot())
287
+ return self.latest
288
+
289
+ def step(self) -> Snapshot | None:
290
+ if self.provider is None:
291
+ return None
292
+ self._ingest(self.provider.step())
293
+ return self.latest
294
+
295
+ def _ingest(self, snap: Snapshot | None) -> None:
296
+ # An empty process list means the read FAILED (init+sh always exist), not that the
297
+ # kernel has no processes — so keep the last good snapshot instead of blanking the UI.
298
+ if snap is None or not getattr(snap, "procs", None):
299
+ return
300
+ self.latest = snap
301
+ self.timeline.add(snap)
302
+ # per-CPU timelines for the SMP Gantt strips; fall back to one strip (cpu 0) when the
303
+ # kernel reports no CPU lines (single-CPU or older build).
304
+ cpus = snap.cpus or ({0: snap.running_pid} if snap.running_pid is not None else {})
305
+ for ci, pid in cpus.items():
306
+ name = next((p.name for p in snap.procs if p.pid == pid), "")
307
+ self.cpu_timelines.setdefault(ci, SchedTimeline()).add_run(pid, snap.ticks, name)
308
+ self._emit(self.watcher.observe(snap))
309
+
310
+ def _emit(self, events) -> None:
311
+ """Record events; notify a listener (proactive Coach) only for genuine teachable
312
+ moments — NOT the student's own control changes, which they already know about."""
313
+ if not events:
314
+ return
315
+ self._events.extend(events)
316
+ if self.on_event and any(e.kind != "control" for e in events):
317
+ try:
318
+ self.on_event(self)
319
+ except Exception:
320
+ pass
321
+
322
+ def pending_events(self) -> bool:
323
+ return bool(self._events)
324
+
325
+ def scheduling_flags(self) -> dict:
326
+ """Currently-active scheduling conditions, for the scheduler-face badges:
327
+ {"starvation": {pids}, "cpu_monopoly": {pids}, "zombie_leak": {pids}}. Read-only view of
328
+ the watcher — does NOT drain the Coach event queue."""
329
+ return {kind: self.watcher.active(kind)
330
+ for kind in ("starvation", "cpu_monopoly", "zombie_leak")}
331
+
332
+ def shadows(self) -> dict:
333
+ """The shadow manifest ({name: ShadowStatus}) from the live provider — which student
334
+ shadows are wired and healthy. Empty when the provider (or build) has no shadows."""
335
+ getter = getattr(self.provider, "shadows", None)
336
+ if callable(getter):
337
+ try:
338
+ return getter() or {}
339
+ except Exception:
340
+ return {}
341
+ return {}
342
+
343
+ def policies(self) -> dict:
344
+ """The kernel's policy roster {id: name} (from the POLICY lines) — drives the UI selector,
345
+ so a policy the kernel ships auto-appears. Empty on an older build / offline."""
346
+ return dict(getattr(self.provider, "kernel_policies", {}) or {})
347
+
348
+ # -- controls ------------------------------------------------------------ #
349
+ def set_timeslice(self, ticks: int) -> None:
350
+ old = self.timeslice
351
+ try:
352
+ self.provider.set_timeslice(ticks)
353
+ except Exception:
354
+ pass
355
+ if int(ticks) != old:
356
+ self._emit([OsEvent(
357
+ "control", f"you changed the time-slice {old} -> {ticks} ticks")])
358
+
359
+ def set_policy(self, policy: str) -> None:
360
+ if policy and policy != self.policy:
361
+ self._emit([OsEvent(
362
+ "control", f"you switched the scheduler policy {self.policy} -> {policy}")])
363
+ self.policy = policy
364
+ setter = getattr(self.provider, "set_policy", None)
365
+ if callable(setter):
366
+ try:
367
+ setter(policy)
368
+ except Exception:
369
+ pass
370
+
371
+ # -- for Coach ----------------------------------------------------------- #
372
+ def drain_events(self) -> list:
373
+ ev, self._events = self._events, []
374
+ return ev
375
+
376
+ # -- for the agent (Explain / Chat context) ------------------------------ #
377
+ def card(self, level: int = 0) -> str:
378
+ meta = {"policy": self.policy, "timeslice": self.timeslice}
379
+ deltas = self._compute_deltas(meta)
380
+ # scheduler card (L0/L1); at L2 append live memory + FS summaries (real ground truth,
381
+ # so paging/file-system questions are grounded in this student's kernel, not training).
382
+ base = state_card(self.latest, self.timeline, meta, deltas, min(level, 1))
383
+ if level >= 2:
384
+ extra = []
385
+ for provider, summarize in ((self.vm, memory_summary), (self.fs, fs_summary)):
386
+ try:
387
+ s = summarize(provider.snapshot())
388
+ if s:
389
+ extra.append(s)
390
+ except Exception:
391
+ pass
392
+ if extra:
393
+ base += "\n\n" + "\n\n".join(extra)
394
+ return base
395
+
396
+ def _compute_deltas(self, meta: dict) -> list[str]:
397
+ """Short 'what changed since the last time the agent looked' line for the card."""
398
+ out: list[str] = []
399
+ prev = self._prev_card
400
+ if prev:
401
+ if prev.get("policy") != meta["policy"]:
402
+ out.append(f"policy {prev['policy']} -> {meta['policy']}")
403
+ if prev.get("timeslice") != meta["timeslice"]:
404
+ out.append(f"time-slice {prev['timeslice']} -> {meta['timeslice']}")
405
+ sw = self.timeline.switches()
406
+ if sw != prev.get("switches"):
407
+ out.append(f"{sw - prev.get('switches', 0)} more context switch(es)")
408
+ self._prev_card = {"policy": meta["policy"], "timeslice": meta["timeslice"],
409
+ "switches": self.timeline.switches()}
410
+ return out
@@ -0,0 +1,32 @@
1
+ id: basic-lan
2
+ layer: core
3
+ teaches: networking-basics
4
+ spirit: two hosts share a switched LAN and reach off-subnet through a router gateway — hosts wire to the
5
+ switch, the switch wires to the router. Any valid such shape counts.
6
+ summary: 'Build a switched LAN: two hosts on a switch, a router as the gateway.'
7
+ parent: lan/switched
8
+ provides:
9
+ - switched-segment
10
+ - router-gateway
11
+ - service-endpoint
12
+ objectives:
13
+ - id: has-switch
14
+ say: Place a switch on the canvas
15
+ check: exists(switch)
16
+ - id: two-hosts
17
+ say: Place at least two hosts
18
+ check: count(host) >= 2
19
+ - id: has-gateway
20
+ say: Place a router (the gateway)
21
+ check: exists(router)
22
+ - id: hosts-on-switch
23
+ say: Wire the hosts to the switch
24
+ check: link(host, switch)
25
+ - id: switch-to-gateway
26
+ say: Wire the switch to the router
27
+ check: link(switch, router)
28
+ misconceptions:
29
+ - Wiring the two hosts directly together instead of through the switch.
30
+ - Forgetting the router, so the LAN has no gateway off-subnet.
31
+ peers:
32
+ - sdn-reactive
@@ -0,0 +1,23 @@
1
+ id: cache-in-front
2
+ layer: core
3
+ teaches: datastores
4
+ spirit: a cache sits in front of a database to cut read load — both exist and are wired.
5
+ summary: Put a cache in front of a database.
6
+ parent: data
7
+ provides:
8
+ - cache-tier
9
+ - relational-store
10
+ objectives:
11
+ - id: has-cache
12
+ say: Place a cache
13
+ check: exists(cache)
14
+ - id: has-db
15
+ say: Place a database
16
+ check: exists(database)
17
+ - id: fronted
18
+ say: Put the cache in front of the database
19
+ check: path(cache, database)
20
+ misconceptions:
21
+ - A cache not connected to anything it fronts.
22
+ peers:
23
+ - reachability-boundary
@@ -0,0 +1,31 @@
1
+ id: decouple-with-queue
2
+ layer: core
3
+ teaches: messaging-queue
4
+ spirit: a producer and a consumer are decoupled by a queue — the producer publishes to it and the consumer
5
+ reads from it, so neither waits on the other.
6
+ summary: Decouple a web app and a function with a queue.
7
+ parent: messaging
8
+ provides:
9
+ - message-broker
10
+ - web-endpoint
11
+ - serverless-fn
12
+ objectives:
13
+ - id: has-queue
14
+ say: Place a message queue
15
+ check: exists(queue)
16
+ - id: has-producer
17
+ say: Place a web app (the producer)
18
+ check: exists(web_app)
19
+ - id: has-consumer
20
+ say: Place a function (the consumer)
21
+ check: exists(function)
22
+ - id: produces
23
+ say: Connect the producer to the queue
24
+ check: path(web_app, queue)
25
+ - id: consumes
26
+ say: Connect the queue to the consumer
27
+ check: path(queue, function)
28
+ misconceptions:
29
+ - Wiring the producer straight to the consumer (no decoupling).
30
+ peers:
31
+ - serverless-api
@@ -0,0 +1,20 @@
1
+ id: drive-load
2
+ layer: exercise
3
+ teaches: load-testing
4
+ spirit: traffic is actively driven at the system so the student can watch it respond under load — a client
5
+ generates requests toward the entry point.
6
+ summary: Add a client that generates load against the system.
7
+ parent: traffic
8
+ step: Add a host to act as a load generator and point it at the entry point, then run it and watch the
9
+ traffic spread.
10
+ provides:
11
+ - load-generator
12
+ requires:
13
+ - traffic-sink
14
+ objectives:
15
+ - id: has-client
16
+ say: There is a client to generate load
17
+ check: exists(host)
18
+ misconceptions:
19
+ - A system with nothing driving traffic shows nothing under load.
20
+ catalog: false
@@ -0,0 +1,75 @@
1
+ id: fix-the-address
2
+ layer: core
3
+ teaches: networking-basics
4
+ spirit: everything is plugged in and the picture looks perfect, but one host was addressed into the
5
+ wrong subnet, so it is deaf to its neighbours. The student cannot see the bug — they must run the
6
+ network, observe the failure, read the addresses, and repair the one that does not belong. This is
7
+ the mis-configuration fault, where absence is visible but a wrong setting is not.
8
+ summary: 'The silent host: three hosts share a switch, but one cannot talk to the others. Find the bad
9
+ address and fix it.'
10
+ parent: lan/switched
11
+ provides:
12
+ - switched-segment
13
+ objectives:
14
+ # --- L1 / L2: the board is GIVEN — these start green, and say so out loud, because the lesson is
15
+ # that a correct-looking picture is not a working network.
16
+ - id: has-switch
17
+ say: A switch is on the board (pre-built — the wiring is not the problem)
18
+ check: exists(switch)
19
+ - id: three-hosts
20
+ say: Three hosts are on the board (pre-built)
21
+ check: count(host) >= 3
22
+ - id: hosts-on-switch
23
+ say: Every host is cabled to the switch (pre-built — look closer than the cables)
24
+ check: link(host, switch)
25
+ # --- L4: only RUNNING it can reveal a wrong address ---------------------------
26
+ - id: live-reach
27
+ say: 'Live: every host can actually reach every other host (Run to check)'
28
+ kind: behavioral
29
+ # `all` is load-bearing: with the default existential reading, the two HEALTHY hosts pinging each
30
+ # other would tick this green while the mis-addressed one stayed deaf — the mission would pass
31
+ # without the bug being fixed. Self-pairs are dropped, so a host can't ping itself into passing.
32
+ probe: reach(host -> host, all) == ok
33
+ misconceptions:
34
+ - Re-cabling the host. The cable is fine; the address is wrong — a wiring fix cannot repair a subnet
35
+ mistake.
36
+ - Assuming a host that is plugged in is a host that can talk.
37
+ - Reading the topology picture as proof of connectivity.
38
+ peers:
39
+ - basic-lan
40
+ - fix-the-lan
41
+ stage:
42
+ # manual addressing is essential: with auto-IP on, the compiler would quietly REPAIR the very
43
+ # fault we are injecting, and the mission would be unwinnable-by-being-already-won.
44
+ manual_addressing: true
45
+ devices:
46
+ - ref: s1
47
+ type: switch
48
+ x: 380
49
+ y: 260
50
+ - ref: h1
51
+ type: host
52
+ x: 180
53
+ y: 130
54
+ ips:
55
+ s1: 10.0.1.11
56
+ - ref: h2
57
+ type: host
58
+ x: 180
59
+ y: 290
60
+ ips:
61
+ s1: 10.0.1.12
62
+ - ref: h3
63
+ type: host
64
+ x: 180
65
+ y: 450
66
+ ips:
67
+ # THE FAULT — a perfectly valid address, on the wrong network. Nothing on the canvas shows it.
68
+ s1: 192.168.5.13
69
+ links:
70
+ - - h1
71
+ - s1
72
+ - - h2
73
+ - s1
74
+ - - h3
75
+ - s1
@@ -0,0 +1,43 @@
1
+ id: fix-the-lan
2
+ layer: core
3
+ teaches: networking-basics
4
+ spirit: a switched LAN was pre-built but has no gateway off-subnet — the student diagnoses the gap and
5
+ adds a router wired to the switch so the hosts can reach beyond their segment.
6
+ summary: 'Fix the LAN: two hosts sit on a switch, but there is no gateway. Add the router.'
7
+ parent: lan/switched
8
+ objectives:
9
+ - id: has-switch
10
+ say: A switch is on the board (pre-built)
11
+ check: exists(switch)
12
+ - id: two-hosts
13
+ say: Two hosts are on the board (pre-built)
14
+ check: count(host) >= 2
15
+ - id: add-gateway
16
+ say: Add a router as the gateway
17
+ check: exists(router)
18
+ - id: switch-to-gateway
19
+ say: Wire the switch to the router gateway
20
+ check: link(switch, router)
21
+ misconceptions:
22
+ - Adding a second switch instead of a router — a switch doesn't route off-subnet.
23
+ peers:
24
+ - basic-lan
25
+ stage:
26
+ devices:
27
+ - ref: s1
28
+ type: switch
29
+ x: 380
30
+ y: 260
31
+ - ref: h1
32
+ type: host
33
+ x: 200
34
+ y: 160
35
+ - ref: h2
36
+ type: host
37
+ x: 200
38
+ y: 360
39
+ links:
40
+ - - h1
41
+ - s1
42
+ - - h2
43
+ - s1
@@ -0,0 +1,16 @@
1
+ id: inspect-flows
2
+ layer: observe
3
+ teaches: sdn
4
+ spirit: the student opens the switch's flow table and watches entries and counters change as traffic flows
5
+ — seeing the control plane's decisions, not guessing them.
6
+ summary: Open the OpenFlow table and watch it react to traffic.
7
+ parent: observability
8
+ step: Double-click the OpenVSwitch to open its flow table and watch the counters move.
9
+ provides:
10
+ - flow-inspector
11
+ - visualizer
12
+ requires:
13
+ - sdn-fabric
14
+ misconceptions:
15
+ - Believing forwarding is magic instead of controller-installed flows.
16
+ catalog: false
@@ -0,0 +1,27 @@
1
+ id: k8s-autoscale
2
+ layer: core
3
+ teaches: kubernetes
4
+ spirit: a pod runs inside a cluster with an autoscaler attached so it can scale on load.
5
+ summary: Run a pod in a cluster with an autoscaler.
6
+ parent: k8s
7
+ provides:
8
+ - orchestrated-compute
9
+ - pod-workload
10
+ - metrics-source
11
+ objectives:
12
+ - id: has-cluster
13
+ say: Place a Kubernetes cluster
14
+ check: exists(k8s_cluster)
15
+ - id: has-pod
16
+ say: Place a pod
17
+ check: exists(pod)
18
+ - id: has-hpa
19
+ say: Place a pod autoscaler
20
+ check: exists(instance_group)
21
+ - id: pod-in-cluster
22
+ say: The pod lives in the cluster
23
+ check: link(k8s_cluster, pod) or contains_type(k8s_cluster, pod)
24
+ misconceptions:
25
+ - A pod outside any cluster — a pod must live in a cluster.
26
+ peers:
27
+ - load-balanced-web