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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/domain/xv6.py ADDED
@@ -0,0 +1,808 @@
1
+ """xv6 (RISC-V) state model + parsers — the Machine Lab's read side.
2
+
3
+ State is read from a running xv6 under QEMU without patching the kernel (GDB-read-first):
4
+ • the process table from xv6's built-in Ctrl-P dump (`procdump`), or from GDB reading `proc[]`;
5
+ • CPU registers from GDB `info registers`;
6
+ • the kernel stack from GDB `bt`.
7
+
8
+ This module is PURE (text -> dataclasses), so the parsing is unit-tested without QEMU/GDB.
9
+ The GDB/console client that produces the text is a thin runtime bridge (Mac/Docker side); the
10
+ Machine Lab renders whatever this returns and accumulates a scheduling timeline.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass, field
16
+
17
+ # xv6 procdump prints short state words (Ctrl-P); the in-container gdb agent emits full words.
18
+ # Accept both and normalise to full names.
19
+ _STATE = {"unused": "unused", "used": "used", "sleep": "sleeping", "sleeping": "sleeping",
20
+ "runble": "runnable", "runnable": "runnable", "run": "running", "running": "running",
21
+ "zombie": "zombie"}
22
+ _RUNNING = "running"
23
+
24
+
25
+ @dataclass
26
+ class Proc:
27
+ pid: int
28
+ state: str # unused|used|sleeping|runnable|running|zombie
29
+ name: str
30
+ parent: int | None = None
31
+ cpu_ticks: int | None = None
32
+ priority: int | None = None # GINI sched field (lower = higher); None if the build omits it
33
+ tickets: int | None = None # lottery weight
34
+ level: int | None = None # MLFQ queue level (student policies)
35
+ wait_ticks: int | None = None # aging counter (slices spent RUNNABLE)
36
+
37
+ @property
38
+ def running(self) -> bool:
39
+ return self.state == _RUNNING
40
+
41
+
42
+ @dataclass
43
+ class CpuState:
44
+ regs: dict = field(default_factory=dict) # name -> value string (pc, sp, ra, satp, …)
45
+
46
+ def key(self, name: str) -> str:
47
+ return self.regs.get(name, "—")
48
+
49
+
50
+ @dataclass
51
+ class Frame:
52
+ fn: str
53
+ loc: str = "" # file:line, if known
54
+
55
+
56
+ @dataclass
57
+ class Snapshot:
58
+ procs: list = field(default_factory=list) # [Proc]
59
+ running_pid: int | None = None
60
+ ticks: int | None = None
61
+ cpu: CpuState | None = None
62
+ stack: list = field(default_factory=list) # [Frame]
63
+ cpus: dict = field(default_factory=dict) # cpu_index -> running pid (SMP; {} = single CPU)
64
+ cpu_regs: dict = field(default_factory=dict) # cpu_index -> CpuState (per-CPU registers)
65
+ modetime: dict = field(default_factory=dict) # {"user","kernel","idle"} cumulative timer ticks
66
+ csr: dict = field(default_factory=dict) # dumping hart's control CSRs {name: int}
67
+ source: str = "real" # "real" (live kernel) or "demo" (DemoScheduler)
68
+
69
+
70
+ # -- parsers ---------------------------------------------------------------- #
71
+ # `<pid> <state> <name> [<ppid>]` — ppid is optional (stock procdump omits it; gini_dump adds it).
72
+ _PROC_RE = re.compile(r"^\s*(\d+)\s+([A-Za-z]+)\s+(\S+)(?:\s+(\d+))?")
73
+
74
+
75
+ def parse_procdump(text: str) -> list[Proc]:
76
+ """xv6 process dump lines `<pid> <state> <name> [<ppid>]` -> Procs (active only). The
77
+ optional ppid (from gini_dump) drives the process tree; stock procdump lines parse fine
78
+ with parent left as None."""
79
+ out: list[Proc] = []
80
+ for line in (text or "").splitlines():
81
+ m = _PROC_RE.match(line)
82
+ if not m:
83
+ continue
84
+ st = _STATE.get(m.group(2).lower())
85
+ if st is None or st == "unused":
86
+ continue
87
+ parent = int(m.group(4)) if m.group(4) is not None else None
88
+ out.append(Proc(int(m.group(1)), st, m.group(3), parent=parent))
89
+ return out
90
+
91
+
92
+ _REG_RE = re.compile(r"^\s*([a-z][a-z0-9_]*)\s+(0x[0-9a-fA-F]+|-?\d+)")
93
+
94
+
95
+ def parse_registers(text: str, keep=("pc", "sp", "ra", "satp", "epc", "a0", "a7")) -> CpuState:
96
+ """GDB `info registers` -> CpuState (all regs; `keep` are the ones the UI leads with)."""
97
+ regs: dict = {}
98
+ for line in (text or "").splitlines():
99
+ m = _REG_RE.match(line)
100
+ if m:
101
+ regs[m.group(1)] = m.group(2)
102
+ return CpuState(regs=regs)
103
+
104
+
105
+ _BT_RE = re.compile(r"^#\d+\s+(?:0x[0-9a-fA-F]+\s+in\s+)?(\w+)\s*\([^)]*\)(?:\s+at\s+(\S+))?")
106
+
107
+
108
+ def parse_backtrace(text: str) -> list[Frame]:
109
+ """GDB `bt` -> [Frame] (innermost first)."""
110
+ out: list[Frame] = []
111
+ for line in (text or "").splitlines():
112
+ m = _BT_RE.match(line.strip())
113
+ if m:
114
+ out.append(Frame(m.group(1), m.group(2) or ""))
115
+ return out
116
+
117
+
118
+ def parse_regs_line(text: str) -> CpuState:
119
+ """The FIRST `REGS …` line's registers (a single-CPU summary for the Ask GINI card)."""
120
+ for line in (text or "").splitlines():
121
+ if "REGS" in line:
122
+ return CpuState(regs=dict(re.findall(r"([a-z0-9]+)\s+(0x[0-9a-fA-F]+)", line)))
123
+ return CpuState(regs={})
124
+
125
+
126
+ def parse_cpu_regs(text: str) -> dict:
127
+ """Parse gini_dump's per-CPU `REGS cpu <i> pid <p> pc 0x.. sp 0x.. …` lines ->
128
+ {cpu_index: CpuState} — each CPU's live registers, no gdb halt."""
129
+ out: dict = {}
130
+ for m in re.finditer(r"REGS cpu (\d+) pid (\d+)([^\n]*)", text or ""):
131
+ regs = dict(re.findall(r"([a-z0-9]+)\s+(0x[0-9a-fA-F]+)", m.group(3)))
132
+ regs["pid"] = m.group(2)
133
+ out[int(m.group(1))] = CpuState(regs=regs)
134
+ return out
135
+
136
+
137
+ def parse_cpu_lines(text: str) -> dict:
138
+ """Parse gini_dump's `CPU <i> pid <n>` lines -> {cpu_index: running_pid}. Empty on a
139
+ single-CPU kernel (or old build) — the UI then falls back to one strip for running_pid."""
140
+ return {int(m.group(1)): int(m.group(2))
141
+ for m in re.finditer(r"CPU\s+(\d+)\s+pid\s+(\d+)", text or "")}
142
+
143
+
144
+ def parse_sched(text: str) -> dict:
145
+ """Parse gini_dump's `SCHED policy N quantum N` line — the kernel's ACTUAL scheduler settings
146
+ (so the UI can show the real quantum/policy and confirm a control took effect)."""
147
+ m = re.search(r"SCHED\s+policy\s+(\d+)\s+quantum\s+(\d+)", text or "")
148
+ return {"policy": int(m.group(1)), "quantum": int(m.group(2))} if m else {}
149
+
150
+
151
+ # scheduler policy codes must match gini_patch.py's gini_pick() (0=RR, 1=priority, 2=lottery).
152
+ # Custom student policies (MLFQ, stride, …) added via the Scheduler Builder extend this map.
153
+ POLICY_NAMES = {0: "round-robin", 1: "priority", 2: "lottery"}
154
+ POLICY_IDS = {v: k for k, v in POLICY_NAMES.items()}
155
+
156
+
157
+ def policy_name(num) -> str:
158
+ return POLICY_NAMES.get(num, f"policy{num}")
159
+
160
+
161
+ _POLICY_ROSTER_RE = re.compile(r"POLICY\s+(\d+)\s+(\S+)")
162
+
163
+
164
+ def parse_policies(text: str) -> dict:
165
+ """gini_dump's `POLICY <id> <name>` roster lines -> {id: name}. Lets the UI populate the policy
166
+ selector from whatever the KERNEL ships (a new policy auto-appears), instead of a hardcoded
167
+ list. Empty on an older build (the UI then falls back to POLICY_NAMES)."""
168
+ return {int(m.group(1)): m.group(2) for m in _POLICY_ROSTER_RE.finditer(text or "")}
169
+
170
+
171
+ _PROC_SCHED_RE = re.compile(
172
+ r"PROC\s+(\d+)\s+pri\s+(-?\d+)\s+tk\s+(-?\d+)\s+lv\s+(-?\d+)\s+wait\s+(-?\d+)")
173
+
174
+
175
+ def parse_proc_sched(text: str) -> dict:
176
+ """gini_dump's per-proc `PROC <pid> pri P tk T lv L wait W` lines ->
177
+ {pid: {"priority":P, "tickets":T, "level":L, "wait_ticks":W}}. Absent on an older build."""
178
+ out: dict = {}
179
+ for m in _PROC_SCHED_RE.finditer(text or ""):
180
+ out[int(m.group(1))] = {"priority": int(m.group(2)), "tickets": int(m.group(3)),
181
+ "level": int(m.group(4)), "wait_ticks": int(m.group(5))}
182
+ return out
183
+
184
+
185
+ # -- mode-time + control CSRs (the CPU face) --------------------------------------------------- #
186
+ _MODETIME_RE = re.compile(r"MODETIME\s+user\s+(\d+)\s+kernel\s+(\d+)\s+idle\s+(\d+)")
187
+ _CSR_RE = re.compile(
188
+ r"CSR\s+sstatus\s+(0x[0-9a-fA-F]+)\s+sie\s+(0x[0-9a-fA-F]+)\s+sip\s+(0x[0-9a-fA-F]+)"
189
+ r"\s+stvec\s+(0x[0-9a-fA-F]+)\s+scause\s+(0x[0-9a-fA-F]+)\s+sepc\s+(0x[0-9a-fA-F]+)")
190
+
191
+
192
+ def parse_modetime(text: str) -> dict:
193
+ """gini_dump's `MODETIME user U kernel K idle I` — cumulative timer-tick counts by privilege
194
+ source. {} on an older build. The CPU face diffs two samples to get the last-second split."""
195
+ m = _MODETIME_RE.search(text or "")
196
+ return {"user": int(m.group(1)), "kernel": int(m.group(2)), "idle": int(m.group(3))} if m else {}
197
+
198
+
199
+ def parse_csr(text: str) -> dict:
200
+ """gini_dump's `CSR sstatus .. sie .. sip .. stvec .. scause .. sepc ..` -> {name: int}. These
201
+ are the DUMPING hart's control CSRs (read inside the trap handler). {} on an older build."""
202
+ m = _CSR_RE.search(text or "")
203
+ if not m:
204
+ return {}
205
+ keys = ("sstatus", "sie", "sip", "stvec", "scause", "sepc")
206
+ return {k: int(m.group(i + 1), 16) for i, k in enumerate(keys)}
207
+
208
+
209
+ def mode_split(prev: dict | None, cur: dict | None) -> dict:
210
+ """Fraction of timer ticks spent in each mode between two MODETIME samples. `prev=None` (or an
211
+ empty/zero baseline) yields the since-boot ratio — a sane first frame before a delta exists.
212
+ Always sums to 1.0 (or all-zero when there's no motion yet)."""
213
+ cur = cur or {}
214
+ prev = prev or {}
215
+ d = {k: max(0, cur.get(k, 0) - prev.get(k, 0)) for k in ("user", "kernel", "idle")}
216
+ total = sum(d.values())
217
+ if total == 0:
218
+ return {"user": 0.0, "kernel": 0.0, "idle": 0.0}
219
+ return {k: v / total for k, v in d.items()}
220
+
221
+
222
+ # RISC-V sstatus / sie / sip bit positions (privileged spec).
223
+ SSTATUS_SIE, SSTATUS_SPIE, SSTATUS_SPP = 1 << 1, 1 << 5, 1 << 8
224
+ SSTATUS_SUM, SSTATUS_MXR = 1 << 18, 1 << 19
225
+ _INT_BITS = (("software", 1 << 1), ("timer", 1 << 5), ("external", 1 << 9)) # S-mode xIE/xIP
226
+
227
+
228
+ def sstatus_flags(v: int) -> dict:
229
+ """Decode sstatus into the flags a student cares about. SPP = the privilege the current trap
230
+ interrupted (U for a running user program, S for a kernel-path trap) — our honest 'where the
231
+ CPU came from', since the live mode is always S while the dump runs."""
232
+ return {"SIE": bool(v & SSTATUS_SIE), "SPIE": bool(v & SSTATUS_SPIE),
233
+ "SPP": "S" if v & SSTATUS_SPP else "U",
234
+ "SUM": bool(v & SSTATUS_SUM), "MXR": bool(v & SSTATUS_MXR)}
235
+
236
+
237
+ _SCAUSE_INT = {1: "software int", 5: "timer int", 9: "external int"}
238
+ _SCAUSE_EXC = {0: "instruction misaligned", 2: "illegal instruction", 8: "ecall (syscall)",
239
+ 12: "instruction page fault", 13: "load page fault", 15: "store page fault"}
240
+
241
+
242
+ def short_pid(pid: int) -> str:
243
+ """Last two digits of a pid, unpadded — a compact Gantt label. Small pids print as-is
244
+ (6->'6', 10->'10', 11->'11'); longer ones take the last two (230->'30', 3615->'15', 3613->'13'),
245
+ so pids that share a middle stay distinct (a centre-clipped full pid showed '61' for both)."""
246
+ return str(pid % 100)
247
+
248
+
249
+ def scause_str(v: int) -> str:
250
+ """Decode the last-trap cause CSR. Top bit set = interrupt, else exception; low bits = code."""
251
+ if v is None:
252
+ return "—"
253
+ code = v & 0xFF
254
+ if v >> 63:
255
+ return _SCAUSE_INT.get(code, f"interrupt {code}")
256
+ return _SCAUSE_EXC.get(code, f"exception {code}")
257
+
258
+
259
+ def interrupt_sources(sie: int, sip: int) -> list:
260
+ """The three S-mode interrupt sources with their enabled (sie) + pending (sip) bits — the honest
261
+ 'interrupt state', read from the enable CONFIG rather than the momentary global bit."""
262
+ return [{"name": name, "enabled": bool(sie & bit), "pending": bool(sip & bit)}
263
+ for name, bit in _INT_BITS]
264
+
265
+
266
+ # -- alarm state (the sigalarm lab; gini_dump `ALARM …` lines) --------------------------------- #
267
+ @dataclass
268
+ class AlarmState:
269
+ pid: int
270
+ interval: int # alarm period in timer ticks (0 = no alarm set)
271
+ ticks: int # ticks elapsed since the last fire
272
+ handler: str = "0x0" # handler VA (hex)
273
+ on: int = 0 # 1 while the handler is running (the re-entrancy guard)
274
+
275
+ @property
276
+ def active(self) -> bool:
277
+ return self.interval > 0
278
+
279
+ @property
280
+ def remaining(self) -> int:
281
+ return max(0, self.interval - self.ticks)
282
+
283
+
284
+ _ALARM_RE = re.compile(
285
+ r"ALARM\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(0x[0-9a-fA-F]+)\s+(-?\d+)")
286
+
287
+
288
+ def parse_alarms(text: str) -> dict:
289
+ """gini_dump's `ALARM <pid> <interval> <ticks> <handler> <on>` lines -> {pid: AlarmState},
290
+ for processes that actually have an alarm set (interval > 0). Absent on a kernel where the
291
+ student hasn't wired sigalarm yet (the fields are all zero -> nothing returned)."""
292
+ out: dict = {}
293
+ for m in _ALARM_RE.finditer(text or ""):
294
+ a = AlarmState(int(m.group(1)), int(m.group(2)), int(m.group(3)),
295
+ m.group(4), int(m.group(5)))
296
+ if a.active:
297
+ out[a.pid] = a
298
+ return out
299
+
300
+
301
+ def apply_proc_sched(procs, text: str) -> list:
302
+ """Set the scheduling fields on each Proc from the `PROC …` lines (no-op if the build omits
303
+ them). Returns the same list for chaining."""
304
+ sched = parse_proc_sched(text)
305
+ for p in procs:
306
+ s = sched.get(p.pid)
307
+ if s:
308
+ p.priority, p.tickets = s["priority"], s["tickets"]
309
+ p.level, p.wait_ticks = s["level"], s["wait_ticks"]
310
+ return procs
311
+
312
+
313
+ def running_pid(procs) -> int | None:
314
+ for p in procs:
315
+ if p.running:
316
+ return p.pid
317
+ return None
318
+
319
+
320
+ # -- shadow manifest -------------------------------------------------------- #
321
+ # The kernel emits one line per SHADOWABLE function so the oracle/AI can tell, deterministically,
322
+ # which student shadows are wired and healthy — the OS analog of "is this router configured".
323
+ # SHADOW <name> present=<0|1> enabled=<0|1> active=<0|1> faults=<n> hash=<hex|baseline>
324
+ # present : a non-stub shadow was compiled in (student wrote something)
325
+ # enabled : the shadow toggle is on
326
+ # active : the dispatcher is currently running the shadow (not the primary)
327
+ # faults : times the shadow crashed and fell back to the primary
328
+ # hash : build-time hash of the student's file ("baseline" = the shipped stub)
329
+ @dataclass
330
+ class ShadowStatus:
331
+ name: str
332
+ present: bool = False
333
+ enabled: bool = False
334
+ active: bool = False
335
+ faults: int = 0
336
+ hash: str = "baseline"
337
+
338
+ @property
339
+ def is_student(self) -> bool:
340
+ """A real student submission (not the shipped baseline stub)."""
341
+ return self.present and self.hash not in ("", "baseline")
342
+
343
+ @property
344
+ def healthy(self) -> bool:
345
+ """Wired in and running without having crashed back to the primary."""
346
+ return self.active and self.faults == 0
347
+
348
+
349
+ _SHADOW_RE = re.compile(r"SHADOW\s+(\S+)\s+(.*)")
350
+
351
+
352
+ def parse_shadow_manifest(text: str) -> dict:
353
+ """gini_shadowdump lines -> {name: ShadowStatus}. The liveness signal the assignment oracle
354
+ checks first: are the required shadows present, active, and fault-free?"""
355
+ out: dict = {}
356
+ for line in (text or "").splitlines():
357
+ m = _SHADOW_RE.match(line.strip())
358
+ if not m:
359
+ continue
360
+ kv = dict(re.findall(r"(\w+)=(\S+)", m.group(2)))
361
+ out[m.group(1)] = ShadowStatus(
362
+ name=m.group(1),
363
+ present=kv.get("present") == "1",
364
+ enabled=kv.get("enabled") == "1",
365
+ active=kv.get("active") == "1",
366
+ faults=int(kv.get("faults", "0") or 0),
367
+ hash=kv.get("hash", "baseline"))
368
+ return out
369
+
370
+
371
+ def ready_queue(procs) -> list:
372
+ """The RUNNABLE processes in scheduling order — the 'who's waiting now, and why' view that
373
+ complements the Gantt (which is who-ran-over-time). Ordered by MLFQ level, then priority
374
+ (lower number = higher), then pid, so the proc the scheduler would tend to favour is first.
375
+ Missing sched fields sort as 0, so it degrades cleanly on an older kernel."""
376
+ ready = [p for p in procs if p.state == "runnable"]
377
+ return sorted(ready, key=lambda p: (p.level or 0, p.priority if p.priority is not None else 0,
378
+ p.pid))
379
+
380
+
381
+ # current xv6-riscv system-call numbers (fork=1 .. sync=22); custom syscalls (Syscall Builder)
382
+ # start at 23 and are supplied via the `extra` map.
383
+ SYSCALL_NAMES = {
384
+ 1: "fork", 2: "exit", 3: "wait", 4: "pipe", 5: "read", 6: "kill", 7: "exec", 8: "fstat",
385
+ 9: "chdir", 10: "dup", 11: "getpid", 12: "sbrk", 13: "pause", 14: "uptime", 15: "open",
386
+ 16: "write", 17: "mknod", 18: "unlink", 19: "link", 20: "mkdir", 21: "close", 22: "sync",
387
+ }
388
+
389
+
390
+ def syscall_name(num: int, extra: dict | None = None) -> str:
391
+ if extra and num in extra:
392
+ return extra[num]
393
+ return SYSCALL_NAMES.get(num, f"sys{num}")
394
+
395
+
396
+ def parse_sccounts(text: str) -> dict:
397
+ """gini_scdump `SC <num> <count>` lines -> {syscall_number: cumulative_count}."""
398
+ return {int(m.group(1)): int(m.group(2))
399
+ for m in re.finditer(r"SC (\d+) (\d+)", text or "")}
400
+
401
+
402
+ @dataclass
403
+ class SyscallEvent:
404
+ pid: int
405
+ num: int
406
+ a0: str = "" # first arg (hex) at call time
407
+ ret: str = "" # return value (hex)
408
+
409
+
410
+ def parse_sctrace(text: str) -> list:
411
+ """gini_scdump `TRACE <pid> <num> <a0> <ret>` lines -> [SyscallEvent] (oldest -> newest)."""
412
+ out: list = []
413
+ for m in re.finditer(r"TRACE (\d+) (\d+) (0x[0-9a-fA-F]+) (0x[0-9a-fA-F]+)", text or ""):
414
+ out.append(SyscallEvent(int(m.group(1)), int(m.group(2)), m.group(3), m.group(4)))
415
+ return out
416
+
417
+
418
+ class SyscallRate:
419
+ """Rolling per-syscall call count over a time window (default 60s) — the histogram feed.
420
+ Feed cumulative `{num: count}` snapshots with their wall-clock time; `rates()` returns how
421
+ many of each syscall happened in the last `window` seconds (now-count minus the count as of
422
+ ~window ago). Before the window fills, it uses the oldest snapshot (calls-so-far)."""
423
+
424
+ def __init__(self, window: float = 60.0, cap: int = 300) -> None:
425
+ from collections import deque
426
+ self.window = window
427
+ self.snaps = deque(maxlen=cap) # (t, {num: cumulative_count})
428
+
429
+ def add(self, t: float, counts: dict) -> None:
430
+ self.snaps.append((t, dict(counts)))
431
+
432
+ def rates(self) -> dict:
433
+ if not self.snaps:
434
+ return {}
435
+ now_t, now = self.snaps[-1]
436
+ cutoff = now_t - self.window
437
+ base = self.snaps[0][1] # oldest, until we have a snapshot past the cutoff
438
+ for t, c in self.snaps:
439
+ if t <= cutoff:
440
+ base = c
441
+ else:
442
+ break
443
+ # On the very first snapshot there's no earlier baseline (oldest == now), so a delta would
444
+ # be 0 across the board. Treat pre-observation as empty -> the histogram shows counts-so-far
445
+ # immediately (the "calls-so-far" the docstring promises) instead of a blank first read.
446
+ if len(self.snaps) == 1:
447
+ base = {}
448
+ out = {num: now[num] - base.get(num, 0) for num in now}
449
+ return {k: v for k, v in out.items() if v > 0}
450
+
451
+
452
+ # -- traps & interrupts (the trap-taxonomy ring; gini_trapdump over Ctrl-R) ------------------- #
453
+ TRAP_KINDS = {0: "syscall", 1: "pagefault", 2: "timer", 3: "device", 4: "illegal", 5: "other"}
454
+
455
+
456
+ def trap_kind_name(kind: int) -> str:
457
+ return TRAP_KINDS.get(kind, f"kind{kind}")
458
+
459
+
460
+ def parse_trapcounts(text: str) -> dict:
461
+ """gini_trapdump `TC <kind> <name> <count>` lines -> {kind_index: cumulative_count}."""
462
+ return {int(m.group(1)): int(m.group(2))
463
+ for m in re.finditer(r"TC (\d+) \w+ (\d+)", text or "")}
464
+
465
+
466
+ @dataclass
467
+ class TrapEvent:
468
+ pid: int
469
+ kind: int
470
+ cause: str = "" # scause (hex); interrupt causes have the top bit set
471
+ epc: str = "" # faulting / trapping PC (hex)
472
+ tval: str = "" # stval — faulting address for page faults (hex)
473
+
474
+
475
+ def parse_traptrace(text: str) -> list:
476
+ """gini_trapdump `TR <pid> <kind> <cause> <epc> <tval>` lines -> [TrapEvent] (oldest->newest)."""
477
+ out: list = []
478
+ for m in re.finditer(
479
+ r"TR (\d+) (\d+) (0x[0-9a-fA-F]+) (0x[0-9a-fA-F]+) (0x[0-9a-fA-F]+)", text or ""):
480
+ out.append(TrapEvent(int(m.group(1)), int(m.group(2)),
481
+ m.group(3), m.group(4), m.group(5)))
482
+ return out
483
+
484
+
485
+ class TrapRate(SyscallRate):
486
+ """Rolling per-trap-kind counts over a window (default 60s) — the trap histogram feed.
487
+ Same mechanics as SyscallRate (cumulative snapshots -> deltas in the window), keyed by trap
488
+ kind index instead of syscall number, so `rates()` returns traps-per-kind in the last window."""
489
+
490
+
491
+ # -- freeze a real trap (Phase 2: /trapcatch -> seed the CPU journey with live values) --------- #
492
+ # RISC-V S-mode exception codes -> a human name (interrupts are handled separately, by code).
493
+ _SCAUSE_EXC = {
494
+ 0: "instruction address misaligned", 1: "instruction access fault", 2: "illegal instruction",
495
+ 3: "breakpoint", 4: "load address misaligned", 5: "load access fault",
496
+ 6: "store address misaligned", 7: "store access fault", 8: "ecall from U-mode (syscall)",
497
+ 12: "instruction page fault", 13: "load page fault", 15: "store page fault",
498
+ }
499
+
500
+
501
+ def decode_scause(cause) -> tuple:
502
+ """An scause value (hex string or int) -> (kind_index, human_name). Mirrors the kernel
503
+ gini_kind() bucketing, but adds the specific exception/interrupt name for the journey caption."""
504
+ try:
505
+ c = int(cause, 16) if isinstance(cause, str) else int(cause)
506
+ except (ValueError, TypeError):
507
+ return 5, "other"
508
+ if c & (1 << 63): # interrupt (top bit set)
509
+ code = c & 0xff
510
+ if code == 9:
511
+ return 3, "supervisor external interrupt (device)"
512
+ if code == 5:
513
+ return 2, "supervisor timer interrupt"
514
+ if code == 1:
515
+ return 2, "supervisor software interrupt"
516
+ return 2, f"interrupt (code {code})"
517
+ code = c & 0xff
518
+ if code == 8:
519
+ return 0, _SCAUSE_EXC[8]
520
+ if code in (12, 13, 15):
521
+ return 1, _SCAUSE_EXC[code]
522
+ if code == 2:
523
+ return 4, _SCAUSE_EXC[2]
524
+ return 5, _SCAUSE_EXC.get(code, f"exception (code {code})")
525
+
526
+
527
+ @dataclass
528
+ class TrapFrame:
529
+ """A single trap frozen at usertrap entry (from /trapcatch): the trap CSRs plus the user
530
+ registers uservec saved into the trapframe. `ok` is False when the catch timed out (idle
531
+ kernel) — the journey then falls back to its authored captions."""
532
+ scause: str = ""
533
+ sepc: str = ""
534
+ stval: str = ""
535
+ pid: int | None = None
536
+ regs: dict = field(default_factory=dict) # epc/ra/sp/a0../a7 (hex strings)
537
+ kind: int = 5
538
+ kind_name: str = "other"
539
+ ok: bool = False
540
+
541
+
542
+ def parse_trapframe(text: str) -> TrapFrame:
543
+ """Parse the agent's /trapcatch gdb output (`key 0x…` lines after a ===TRAP=== marker) into a
544
+ TrapFrame. Missing/garbled fields are tolerated; ok=True only once we have a valid scause."""
545
+ fr = TrapFrame()
546
+ body = (text or "").split("===TRAP===", 1)[-1]
547
+ if "gdb-timeout" in (text or "") or "gdb-error" in (text or ""):
548
+ return fr # ok stays False -> authored fallback
549
+ for line in body.splitlines():
550
+ m = re.match(r"\s*([a-z]\w*)\s+(0x[0-9a-fA-F]+|-?\d+)\s*$", line)
551
+ if not m:
552
+ continue
553
+ key, val = m.group(1), m.group(2)
554
+ if key in ("scause", "sepc", "stval"):
555
+ setattr(fr, key, val)
556
+ elif key == "pid":
557
+ try:
558
+ fr.pid = int(val)
559
+ except ValueError:
560
+ pass
561
+ elif key in ("epc", "ra", "sp", "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7"):
562
+ fr.regs[key] = val
563
+ if fr.scause:
564
+ fr.kind, fr.kind_name = decode_scause(fr.scause)
565
+ fr.ok = True
566
+ return fr
567
+
568
+
569
+ @dataclass
570
+ class TreeNode:
571
+ proc: Proc
572
+ depth: int
573
+
574
+
575
+ def build_process_tree(procs) -> list:
576
+ """Order procs as a depth-first process TREE by parent links, returning [TreeNode(proc, depth)]
577
+ in display order (depth = indentation). Roots are procs whose parent is 0/None/missing (init).
578
+ Defensive against cycles and orphans so a bad read can never hang or drop a process."""
579
+ by_pid = {p.pid: p for p in procs}
580
+ children: dict = {}
581
+ roots: list = []
582
+ for p in procs:
583
+ par = p.parent
584
+ if par and par in by_pid and par != p.pid:
585
+ children.setdefault(par, []).append(p)
586
+ else:
587
+ roots.append(p)
588
+ out: list = []
589
+ seen: set = set()
590
+
591
+ def walk(p, depth):
592
+ if p.pid in seen: # cycle guard
593
+ return
594
+ seen.add(p.pid)
595
+ out.append(TreeNode(p, depth))
596
+ for c in sorted(children.get(p.pid, []), key=lambda c: c.pid):
597
+ walk(c, depth + 1)
598
+
599
+ for r in sorted(roots, key=lambda p: p.pid):
600
+ walk(r, 0)
601
+ for p in procs: # any left over (cycle) -> show at root
602
+ if p.pid not in seen:
603
+ out.append(TreeNode(p, 0))
604
+ return out
605
+
606
+
607
+ # -- scheduling timeline (Gantt) -------------------------------------------- #
608
+ @dataclass
609
+ class Slot:
610
+ tick: int | None
611
+ pid: int | None
612
+ name: str
613
+
614
+
615
+ class SchedTimeline:
616
+ """Accumulate which process is RUNNING across snapshots — the Gantt strip that makes
617
+ context switches visible. Records a slot only when the running process changes (a switch)
618
+ or the tick advances, so the strip reads as the switch history."""
619
+
620
+ def __init__(self, cap: int = 400) -> None:
621
+ self.slots: list[Slot] = []
622
+ self.cap = cap
623
+ self._last_pid: int | None = None
624
+
625
+ def add(self, snap: Snapshot) -> None:
626
+ pid = snap.running_pid if snap.running_pid is not None else running_pid(snap.procs)
627
+ name = next((p.name for p in snap.procs if p.pid == pid), "")
628
+ self.add_run(pid, snap.ticks, name)
629
+
630
+ def add_run(self, pid, ticks, name="") -> None:
631
+ """Record a single (pid, ticks) sample — used per-CPU on SMP as well as the aggregate."""
632
+ if pid != self._last_pid or (self.slots and self.slots[-1].tick != ticks):
633
+ self.slots.append(Slot(ticks, pid, name))
634
+ self._last_pid = pid
635
+ if len(self.slots) > self.cap:
636
+ self.slots = self.slots[-self.cap:]
637
+
638
+ def recent(self, n: int = 60) -> list[Slot]:
639
+ return self.slots[-n:]
640
+
641
+ def switches(self) -> int:
642
+ return max(0, sum(1 for i in range(1, len(self.slots))
643
+ if self.slots[i].pid != self.slots[i - 1].pid))
644
+
645
+ def shares(self, n: int = 60) -> dict:
646
+ """CPU share over the last `n` slots: {pid: fraction of slots it held} (idle excluded).
647
+ This is the evidence a lottery/fairness assignment leans on — the share should track the
648
+ ticket ratio. Coarse (the timeline is sampled), but a real observation of the live kernel."""
649
+ pids = [s.pid for s in self.slots[-n:] if s.pid is not None]
650
+ total = len(pids)
651
+ if not total:
652
+ return {}
653
+ from collections import Counter
654
+ return {pid: c / total for pid, c in Counter(pids).items()}
655
+
656
+
657
+ # -- offline demo provider -------------------------------------------------- #
658
+ # A pure, deterministic feed over a few procs, so the Machine Lab is explorable (and testable)
659
+ # without a live QEMU/GDB. It honours the same policies as the real gini_pick() so the scheduler
660
+ # face behaves offline: round-robin, priority (with aging), and lottery (ticket-weighted). On the
661
+ # Mac a real GDB bridge replaces this with the same Snapshot shape.
662
+ _DEMO_PROCS = [(1, "init"), (2, "sh"), (3, "spin"), (4, "spin"), (5, "primes")]
663
+ # per-proc scheduling params for the CPU-bound demo procs (init/sh sleep, so they don't compete).
664
+ _DEMO_META = {3: {"priority": 5, "tickets": 1}, # high priority, few tickets
665
+ 4: {"priority": 10, "tickets": 2},
666
+ 5: {"priority": 10, "tickets": 4}} # low priority, most tickets
667
+
668
+
669
+ class DemoScheduler:
670
+ """Deterministic policy-aware feed of Snapshots — the offline stand-in for the GDB bridge.
671
+ Mirrors gini_pick(): round-robin / priority (aging) / lottery, so switching the policy in the
672
+ UI visibly changes who runs even with no container attached."""
673
+
674
+ def __init__(self, timeslice: int = 1, policy: str = "round-robin") -> None:
675
+ self.timeslice = max(1, int(timeslice))
676
+ self.policy = policy
677
+ self._ticks = 0
678
+ self._runnable = [3, 4, 5]
679
+ self._run = 3 # currently running pid (starts on the first CPU-bound proc)
680
+ self._rr_ix = 2 # round-robin cursor into _DEMO_PROCS
681
+ self._wait = {p: 0 for p in self._runnable} # aging counters (priority policy)
682
+ self._seed = 2463534242 # xorshift PRNG state (lottery), fixed -> deterministic
683
+
684
+ def set_timeslice(self, ticks: int) -> None:
685
+ self.timeslice = max(1, int(ticks))
686
+
687
+ def set_policy(self, policy) -> None:
688
+ """Accept a policy name ('priority') or its numeric id (1)."""
689
+ self.policy = POLICY_NAMES.get(policy, policy) if isinstance(policy, int) else policy
690
+
691
+ def sc(self) -> str:
692
+ """Offline demo of gini_scdump — growing syscall counts + a few recent calls, so the
693
+ histogram/trace panels are explorable without a container."""
694
+ self._sc_t = getattr(self, "_sc_t", 0) + 1
695
+ n = self._sc_t
696
+ lines = [f"SC 1 {n}", f"SC 16 {n * 7}", f"SC 5 {n * 3}", f"SC 13 {n * 2}", f"SC 12 {n}"]
697
+ trace = ["TRACE 2 1 0x0 0x5", "TRACE 5 16 0x4 0x6", "TRACE 5 12 0x1000 0x4000",
698
+ "TRACE 2 3 0x0 0x5"]
699
+ return "\n".join(lines + trace) + "\n"
700
+
701
+ def traps(self) -> str:
702
+ """Offline demo of gini_trapdump — a plausible growing trap mix so the Traps face is
703
+ explorable without a container: mostly timer, a syscall trickle, an occasional page
704
+ fault. Counters are cumulative (TrapRate turns them into a 60s window)."""
705
+ self._tr_t = getattr(self, "_tr_t", 0) + 1
706
+ n = self._tr_t
707
+ kinds = [(0, "syscall", n * 4), (1, "pagefault", n // 2), (2, "timer", n * 9),
708
+ (3, "device", n // 3), (4, "illegal", 0), (5, "other", 0)]
709
+ tc = [f"TC {k} {name} {cnt}" for k, name, cnt in kinds]
710
+ tr = ["TR 5 2 0x8000000000000005 0x0000000000001050 0x0", # a timer interrupt
711
+ "TR 5 1 0x000000000000000f 0x0000000000001080 0x0000000000004000", # store fault
712
+ "TR 2 0 0x0000000000000008 0x0000000000001d3c 0x0", # a syscall (ecall)
713
+ "TR 5 2 0x8000000000000005 0x0000000000001054 0x0"]
714
+ return "\n".join(tc + tr) + "\n"
715
+
716
+ def catch_trap(self, kind: str = "any") -> "TrapFrame":
717
+ """Offline demo of /trapcatch — a plausible frozen trap, so the CPU journey can be seeded
718
+ with real-looking values without a container. Honours the requested `kind` where it can."""
719
+ if kind == "syscall":
720
+ return TrapFrame(scause="0x0000000000000008", sepc="0x0000000000001d3c", stval="0x0",
721
+ pid=2, regs={"epc": "0x1d3c", "a0": "0x0", "a7": "0x7"},
722
+ kind=0, kind_name="ecall from U-mode (syscall)", ok=True)
723
+ if kind == "timer":
724
+ return TrapFrame(scause="0x8000000000000005", sepc="0x0000000000001050", stval="0x0",
725
+ pid=5, regs={"epc": "0x1050", "sp": "0x3fffff9000"},
726
+ kind=2, kind_name="supervisor timer interrupt", ok=True)
727
+ return TrapFrame( # default / "pagefault": a store fault
728
+ scause="0x000000000000000f", sepc="0x0000000000001080",
729
+ stval="0x0000000000004000", pid=5,
730
+ regs={"epc": "0x0000000000001080", "ra": "0x0000000000001d3c",
731
+ "sp": "0x0000003fffff9000", "a0": "0x0000000000000005", "a7": "0x000000000000000f"},
732
+ kind=1, kind_name="store page fault", ok=True)
733
+
734
+ def alarms(self) -> str:
735
+ """Offline demo of the ALARM dump lines — one process with a periodic alarm whose
736
+ countdown advances each call, so the alarm strip animates without a container."""
737
+ self._al_t = getattr(self, "_al_t", 0) + 1
738
+ interval = 10
739
+ ticks = self._al_t % (interval + 1)
740
+ on = 1 if ticks == 0 else 0
741
+ return f"ALARM 5 {interval} {ticks} 0x0000000000001120 {on}\n"
742
+
743
+ def _pick(self) -> int:
744
+ """Choose the next running pid per policy — the offline mirror of kernel gini_pick()."""
745
+ rn = self._runnable
746
+ if self.policy == "priority":
747
+ best, best_eff = None, None
748
+ for p in rn:
749
+ self._wait[p] += 1 # aging: waiting raises effective priority
750
+ eff = _DEMO_META[p]["priority"] - self._wait[p] // 4
751
+ if best is None or eff < best_eff:
752
+ best, best_eff = p, eff
753
+ self._wait[best] = 0
754
+ return best
755
+ if self.policy == "lottery":
756
+ total = sum(_DEMO_META[p]["tickets"] for p in rn)
757
+ self._seed ^= (self._seed << 13) & 0xFFFFFFFF
758
+ self._seed ^= self._seed >> 17
759
+ self._seed ^= (self._seed << 5) & 0xFFFFFFFF
760
+ win, acc = self._seed % total, 0
761
+ for p in rn:
762
+ acc += _DEMO_META[p]["tickets"]
763
+ if win < acc:
764
+ return p
765
+ return rn[-1]
766
+ # round-robin
767
+ self._rr_ix = (self._rr_ix + 1) % len(_DEMO_PROCS)
768
+ while _DEMO_PROCS[self._rr_ix][0] not in rn:
769
+ self._rr_ix = (self._rr_ix + 1) % len(_DEMO_PROCS)
770
+ return _DEMO_PROCS[self._rr_ix][0]
771
+
772
+ def step(self) -> "Snapshot":
773
+ """Advance one context switch and return the new snapshot."""
774
+ self._ticks += self.timeslice
775
+ self._run = self._pick()
776
+ return self.snapshot()
777
+
778
+ def snapshot(self) -> "Snapshot":
779
+ run_pid = self._run
780
+ procs = []
781
+ for pid, name in _DEMO_PROCS:
782
+ st = ("running" if pid == run_pid
783
+ else "runnable" if pid in self._runnable else "sleeping")
784
+ meta = _DEMO_META.get(pid)
785
+ procs.append(Proc(pid, st, name,
786
+ priority=meta["priority"] if meta else None,
787
+ tickets=meta["tickets"] if meta else None,
788
+ level=0 if meta else None,
789
+ wait_ticks=self._wait.get(pid)))
790
+ pc = 0x80001000 + (run_pid * 0x40) + (self._ticks & 0xF)
791
+ cpu = CpuState(regs={
792
+ "pc": hex(pc), "sp": hex(0x3FFFFF9000 - run_pid * 0x1000),
793
+ "ra": hex(0x80001D3C), "s0": hex(0x3FFFFF9F00 - run_pid * 0x1000),
794
+ "satp": hex(0x8000000000080000 + run_pid),
795
+ "a0": hex(run_pid), "a7": "0x7"})
796
+ stack = [Frame("swtch", "kernel/swtch.S:20"),
797
+ Frame("sched", "kernel/proc.c:493"),
798
+ Frame("yield", "kernel/proc.c:515"),
799
+ Frame("usertrap", "kernel/trap.c:67")]
800
+ # a plausible, advancing mode-time (≈70% user / 20% kernel / 10% idle) so the CPU face's
801
+ # bar animates offline; + representative CSRs: SPP=U (came from a user proc), all three
802
+ # S-interrupts enabled, a timer scause. Grounded in real bit layouts, not the wire.
803
+ modetime = {"user": self._ticks * 7, "kernel": self._ticks * 2, "idle": self._ticks}
804
+ csr = {"sstatus": SSTATUS_SPIE, "sie": (1 << 1) | (1 << 5) | (1 << 9),
805
+ "sip": 1 << 5, "stvec": 0x80001BB4,
806
+ "scause": 0x8000000000000005, "sepc": pc}
807
+ return Snapshot(procs=procs, running_pid=run_pid, ticks=self._ticks,
808
+ cpu=cpu, stack=stack, modetime=modetime, csr=csr, source="demo")