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/agent/ask.py ADDED
@@ -0,0 +1,123 @@
1
+ """Ask GINI orchestration (pure) — turn an Intent + retrieved knowledge into a Plan, and
2
+ assemble the grounded context for the reasoning call.
3
+
4
+ This is the routing matrix from the design, kept Qt-free so it's testable. The assistant
5
+ (Qt side) calls `plan()` to decide what to do and `grounded_context()` to build the block
6
+ the reasoning LLM sees; the actual building (apply_recipe), tool execution, and chat posting
7
+ happen in the assistant.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass
15
+ class Plan:
16
+ action: str # reason | build_recipe | execute | diagnose | clarify | chitchat | meta
17
+ recipe_id: str = "" # for build_recipe, or the recipe to offer on a reason
18
+ offer_build: bool = False # reason path: end by offering to build recipe_id
19
+ clarify: str = "" # for clarify
20
+
21
+
22
+ _CLARIFY = ("Could you say a bit more? For example, name an element you want explained, "
23
+ "or tell me what you'd like to build.")
24
+
25
+
26
+ def plan(intent, retrieval, *, min_confidence: float = 0.45) -> Plan:
27
+ """Dispatch by (type x output_form x anchor). See the design's routing matrix."""
28
+ it = intent
29
+ recipe = getattr(retrieval, "recipe", None)
30
+ rid = recipe.id if recipe is not None else ""
31
+
32
+ if it.type == "chitchat":
33
+ return Plan("chitchat")
34
+ if it.type == "meta":
35
+ return Plan("meta")
36
+
37
+ # genuinely ambiguous fragment -> ask one question rather than guess
38
+ if it.confidence < min_confidence and not it.topics and not it.refs:
39
+ return Plan("clarify", clarify=_CLARIFY)
40
+
41
+ # imperative command ("add a router", "connect R1 and S1") -> tool execution
42
+ if it.type == "build" and it.output_form not in ("show", "guide"):
43
+ return Plan("execute")
44
+
45
+ if it.type == "diagnose":
46
+ return Plan("diagnose")
47
+
48
+ # generative "show me / construct / build" with a known pattern
49
+ if it.output_form in ("show", "guide") and recipe is not None:
50
+ if it.anchor == "concept":
51
+ return Plan("build_recipe", recipe_id=rid) # empty canvas -> auto-build
52
+ return Plan("reason", offer_build=True, recipe_id=rid) # populated -> offer first
53
+
54
+ # explain / how_to / tell -> reason, offering the example if we have one
55
+ return Plan("reason", offer_build=recipe is not None, recipe_id=rid)
56
+
57
+
58
+ _STANCE = {
59
+ "strong": (
60
+ "GROUNDING STANCE — CLOSED WORLD: the GINI knowledge and canvas below fully cover this "
61
+ "question. Answer only from them; every element you name must appear in the elements "
62
+ "list."
63
+ ),
64
+ "thin": (
65
+ "GROUNDING STANCE — MOSTLY CLOSED: the KB match here is only partial. Lean on what's "
66
+ "provided; if a detail isn't covered, say so rather than inventing a GINI element."
67
+ ),
68
+ "empty": (
69
+ "GROUNDING STANCE — OPEN BUT FENCED: this question falls outside GINI's built-in "
70
+ "topics. You may answer from general knowledge, but do NOT claim any GINI element or "
71
+ "feature exists unless it's in the elements list; if GINI has no element for it, say so "
72
+ "plainly and point to the nearest thing GINI does have."
73
+ ),
74
+ }
75
+
76
+
77
+ def grounding_stance(retrieval, intent=None) -> str:
78
+ """One directive line telling the reasoner how tightly to stay on the GINI KB this turn,
79
+ derived from retrieval strength. The 'never invent a GINI element' rule holds in EVERY
80
+ tier — only the freedom to reason from general knowledge widens when the KB is thin."""
81
+ # no retrieval at all → default to the closed-world floor (keep the student on GINI); the
82
+ # open stance is only chosen when retrieval explicitly reports an empty/off-topic match.
83
+ strength = getattr(retrieval, "strength", "strong") if retrieval is not None else "strong"
84
+ return _STANCE.get(strength, _STANCE["strong"])
85
+
86
+
87
+ def grounded_context(always_on: str, accumulator: str, retrieval, canvas_digest: str,
88
+ intent, machine_card: str = "", stance: str = "") -> str:
89
+ """Assemble the context block the reasoning LLM sees: grounding stance + always-on index +
90
+ canvas + live machine (xv6) state + accumulated session knowledge + this turn's cards.
91
+
92
+ `machine_card` is the xv6 kernel state card (from domain.machine_state) when the focus is a
93
+ running xv6 Machine — a second 'ground truth' source alongside the canvas, so OS help is
94
+ grounded in this student's actual kernel run. `stance` (from `grounding_stance`) is prepended
95
+ so the model knows how closed-world to be for THIS question."""
96
+ parts = []
97
+ if stance:
98
+ parts.append(stance)
99
+ parts.append(always_on)
100
+ if canvas_digest:
101
+ parts.append("Current canvas (ground truth):\n" + canvas_digest)
102
+ if machine_card:
103
+ parts.append(machine_card)
104
+ if accumulator:
105
+ parts.append(accumulator)
106
+ rc = retrieval.as_context() if retrieval is not None else ""
107
+ if rc:
108
+ parts.append(rc)
109
+ return "\n\n".join(p for p in parts if p)
110
+
111
+
112
+ def machine_card_level(text: str) -> int:
113
+ """How deep an xv6 state card the question warrants (progressive; keeps the small-LLM
114
+ budget lean). 0 = scheduling picture; 1 = + registers/stack; 2 = + memory/FS."""
115
+ q = (text or "").lower()
116
+ if any(w in q for w in ("page table", "pagetable", "satp", "virtual memory", "paging",
117
+ "address space", "inode", "file system", "filesystem", "journal",
118
+ "buffer cache", "disk", "block")):
119
+ return 2
120
+ if any(w in q for w in ("register", "pc", "stack", "backtrace", "trap", "trapframe",
121
+ "context", "swtch", "ra ", "sp ", "program counter")):
122
+ return 1
123
+ return 0
@@ -0,0 +1,72 @@
1
+ """Authoring loop — a teacher turns a rough intent into a releasable mission (M5).
2
+
3
+ The flow: **propose** (a vague intent → a composition-by-reference the teacher can read) →
4
+ **playtest** (assemble + validate + a dry-run report so nothing broken is released) → **to_pack**
5
+ (the YAML the Teaching Center distributes). The teacher is sovereign: the LLM drafts, the human
6
+ ratifies/tweaks the spec before releasing.
7
+
8
+ A proposal is a *composition* (references local fragment ids) — never new foundational fragments — so
9
+ whatever a teacher authors is gradable by construction on any install that has the referenced
10
+ fragments (guarded by the version/existence check in `domain.composition`).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import yaml
15
+
16
+ from ..domain import composition as _comp
17
+ from ..domain import fragments as _frag
18
+ from ..domain import lesson as _lesson
19
+
20
+
21
+ def propose(intent_text: str, llm=None, *, lesson_id: str = "", genre: str | None = None) -> dict:
22
+ """Draft a composition spec from a rough intent (reuses the student-facing composer, but emits a
23
+ releasable REFERENCE spec — the core fragment ids + framing — not a one-off lesson)."""
24
+ from . import lesson_resolver
25
+ prop = lesson_resolver.compose(intent_text, llm, lesson_id=lesson_id or "authored", genre=genre)
26
+ if prop is None or prop.lesson is None:
27
+ return {}
28
+ les = prop.lesson
29
+ # reference only the CORE fragments the teacher chose; genre re-derives the enrichment on assembly
30
+ cores = [fid for fid in les.fragments
31
+ if (_frag.get(fid) is not None and _frag.get(fid).layer == _frag.CORE)]
32
+ spec = {"id": lesson_id or "authored", "fragments": cores or [prop.archetype_id],
33
+ "genre": les.genre or "expedition", "title": les.title,
34
+ "brief": les.brief.split("] ", 1)[-1] if les.brief.startswith("[") else les.brief}
35
+ if les.level is not None:
36
+ spec["level"] = les.level
37
+ return spec
38
+
39
+
40
+ def playtest(spec: dict) -> dict:
41
+ """Assemble + validate a spec without releasing. Returns a report: {ok, problems, summary,
42
+ objectives}. This is the safety gate — a teacher sees exactly what a student would be graded on."""
43
+ problems = list(_comp.missing_refs(spec))
44
+ les = None
45
+ if not problems:
46
+ try:
47
+ les = _comp.from_composition(spec)
48
+ except _comp.CompositionError as e:
49
+ problems.append(str(e))
50
+ if les is not None:
51
+ problems.extend(_lesson.validate(les))
52
+ return {
53
+ "ok": not problems,
54
+ "problems": problems,
55
+ "summary": (f"{les.title} — {len(les.objectives)} objectives"
56
+ f" · L{les.level} · {les.genre}") if les else "",
57
+ "objectives": [o.say for o in les.objectives] if les else [],
58
+ }
59
+
60
+
61
+ def to_pack(spec: dict) -> str:
62
+ """The releasable YAML pack (a composition-by-reference the Teaching Center serves)."""
63
+ ordered = {k: spec[k] for k in ("id", "fragments", "genre", "level", "title", "brief",
64
+ "time_limit", "attempts", "help", "persona") if k in spec}
65
+ return yaml.safe_dump(ordered, sort_keys=False, allow_unicode=True, width=100)
66
+
67
+
68
+ def ratify(spec: dict, **tweaks) -> dict:
69
+ """Apply a teacher's edits (title, genre, level, fragments, brief…) to a proposed spec."""
70
+ out = dict(spec)
71
+ out.update({k: v for k, v in tweaks.items() if v is not None})
72
+ return out
@@ -0,0 +1,114 @@
1
+ """The Blackboard — the shared truth cache the deterministic swarm keeps current and the Reasoning
2
+ persona reads from (GINI_MISSIONS_AGENT_ARCHITECTURE.md §4). It holds the latest Verdict per subject
3
+ plus the shared, curated MissionMemory. Verdicts are recomputed incrementally: an update names the
4
+ state keys that changed, and only verifiers whose `deps()` intersect those keys re-run.
5
+
6
+ Pure Python (no Qt): the substrate is unit-testable with a real Topology + lesson. The live wiring
7
+ (bus → observers → verifiers → notifications → reasoning) sits on top in later phases.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from .contracts import Fact, MissionMemory, Verdict
12
+ from .verifiers import WorldView, for_lesson
13
+
14
+
15
+ class Registry:
16
+ def __init__(self) -> None:
17
+ self._verifiers: dict[str, object] = {}
18
+
19
+ def add(self, verifier) -> None:
20
+ self._verifiers[verifier.id] = verifier
21
+
22
+ def add_all(self, verifiers) -> None:
23
+ for v in verifiers:
24
+ self.add(v)
25
+
26
+ def remove(self, verifier_id: str) -> None:
27
+ self._verifiers.pop(verifier_id, None)
28
+
29
+ def clear(self) -> None:
30
+ self._verifiers.clear()
31
+
32
+ def verifiers(self) -> list:
33
+ return list(self._verifiers.values())
34
+
35
+ def affected(self, changed: set[str] | None) -> list:
36
+ """Verifiers to re-run: all when `changed` is None, else those whose deps intersect it."""
37
+ if changed is None:
38
+ return self.verifiers()
39
+ return [v for v in self._verifiers.values() if set(v.deps()) & changed]
40
+
41
+
42
+ class Blackboard:
43
+ def __init__(self) -> None:
44
+ self.registry = Registry()
45
+ self.memory = MissionMemory()
46
+ self._verdicts: dict[str, object] = {}
47
+ self._lesson = None
48
+ self._runner = None
49
+
50
+ # -- lifecycle ---------------------------------------------------------- #
51
+ def load_lesson(self, lesson, *, verifiers=None, runner=None) -> None:
52
+ """Register the tiny verifiers a mission needs and reset the truth cache. `verifiers` lets a
53
+ domain pack supply its own set; it defaults to the networking `for_lesson`."""
54
+ self.registry.clear()
55
+ self.registry.add_all(verifiers if verifiers is not None else for_lesson(lesson))
56
+ self._verdicts.clear()
57
+ self._lesson = lesson
58
+ self._runner = runner
59
+
60
+ def clear(self) -> None:
61
+ self.registry.clear()
62
+ self._verdicts.clear()
63
+ self._lesson = None
64
+ self.memory = MissionMemory()
65
+
66
+ # -- truth maintenance -------------------------------------------------- #
67
+ def update(self, topology, *, changed: set[str] | None = None) -> list:
68
+ """Re-run the affected verifiers against the current topology; refresh the cache. Returns the
69
+ verdicts that CHANGED (value flipped or newly appeared) — the raw material for notifications."""
70
+ if self._lesson is None:
71
+ return []
72
+ view = WorldView.of(topology, self._lesson, runner=self._runner)
73
+ flipped = []
74
+ for v in self.registry.affected(changed):
75
+ for verdict in v.check(view):
76
+ prev = self._verdicts.get(verdict.subject)
77
+ if prev is None or prev.value != verdict.value:
78
+ flipped.append(verdict)
79
+ self._verdicts[verdict.subject] = verdict
80
+ return flipped
81
+
82
+ def ingest_results(self, results) -> None:
83
+ """Set objective verdicts directly from pre-computed ObjectiveResults (the mission already
84
+ evaluated them) — so the Reasoning persona is grounded without a second evaluation and even
85
+ when only results, not a topology, are on hand."""
86
+ for r in results:
87
+ self._verdicts[r.id] = Verdict(f"objective:{r.id}", subject=r.id, value=r.met,
88
+ evidence=Fact("status", r.status), deps=("topology",))
89
+
90
+ # -- reads -------------------------------------------------------------- #
91
+ def verdict(self, subject: str):
92
+ return self._verdicts.get(subject)
93
+
94
+ def value(self, subject: str) -> bool:
95
+ v = self._verdicts.get(subject)
96
+ return bool(v and v.value)
97
+
98
+ def verdicts(self) -> list:
99
+ return list(self._verdicts.values())
100
+
101
+ def unmet_objectives(self) -> list[str]:
102
+ return [v.subject for v in self._verdicts.values()
103
+ if v.verifier_id.startswith("objective:") and not v.value]
104
+
105
+ def all_objectives_met(self) -> bool:
106
+ objs = [v for v in self._verdicts.values() if v.verifier_id.startswith("objective:")]
107
+ return bool(objs) and all(v.value for v in objs)
108
+
109
+ def flags(self) -> dict:
110
+ """Current legality problems (for the red badges), read straight off the cache."""
111
+ off = self.verdict("off_task")
112
+ bad = self.verdict("illegal_links")
113
+ return {"off_task": list(off.evidence.data) if off and off.evidence and not off.value else [],
114
+ "illegal_links": list(bad.evidence.data) if bad and bad.evidence and not bad.value else []}
@@ -0,0 +1,142 @@
1
+ """Multi-agent message contracts — the domain-neutral types the mission agents pass on the bus /
2
+ blackboard. Pinning these down first is the whole point: a multi-agent system with sloppy message
3
+ types is the real maintenance nightmare. See GINI_MISSIONS_AGENT_ARCHITECTURE.md §7.
4
+
5
+ Two families:
6
+ • truth & change (deterministic agents): Observation, Fact, Verdict, Notification;
7
+ • meaning (LLM personas): Intent, Claim, Move; plus the shared, curated MissionMemory.
8
+
9
+ Everything here is a plain dataclass (no Qt, no LLM) so the whole substrate is unit-testable. The
10
+ `Verifier`/`Observer` protocols are the domain-pack seam: a domain implements tiny verifiers and
11
+ observers; the framework never looks inside them.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Protocol, runtime_checkable
18
+
19
+ # ---- truth & change ------------------------------------------------------- #
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Fact:
24
+ """A checkable datum — a counterexample, disconnected components, a value, a status. NEVER prose;
25
+ turning a Fact into meaning is always the Reasoning persona's job."""
26
+ kind: str
27
+ data: Any = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Verdict:
32
+ """The result of ONE tiny verifier for ONE subject: a boolean + the fact that evidences it."""
33
+ verifier_id: str
34
+ subject: str # what was checked (an objective id, a device id, "off_task" …)
35
+ value: bool
36
+ evidence: Fact | None = None
37
+ deps: tuple[str, ...] = () # state keys this verdict depends on (for incremental re-check)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Observation:
42
+ """A sensed datum from the running world (a snapshot or a discrete event)."""
43
+ source: str
44
+ data: Any = None
45
+ event: str = "" # "" for a snapshot; else a discrete event name (panic, run_done…)
46
+ ts: float = field(default_factory=time.time)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Notification:
51
+ """A salient CHANGE, routed to wake the Reasoning persona. Salience is rules-only (0..1)."""
52
+ change: str # e.g. "objective_met", "off_task_added", "run_complete", "question"
53
+ subjects: tuple[str, ...] = ()
54
+ salience: float = 1.0
55
+ data: Any = None
56
+ ts: float = field(default_factory=time.time)
57
+
58
+
59
+ # ---- meaning -------------------------------------------------------------- #
60
+
61
+ # Intent kinds the Understanding persona classifies a student input into
62
+ CONCEPT, ENTITY, OBJECTIVE, NEXT_STEP, OFF_TASK, META = (
63
+ "concept", "entity", "objective", "next_step", "off_task", "meta")
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Intent:
68
+ """A student input, understood: what they mean and what it references."""
69
+ kind: str = META
70
+ text: str = ""
71
+ refs: tuple[str, ...] = () # resolved world-entity ids
72
+ objective_ref: str = ""
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Claim:
77
+ """A single factual assertion inside a Move — re-checkable against the oracle, so the model may
78
+ reason freely while nothing false ships."""
79
+ predicate: str # a verifier subject or a structural predicate to re-evaluate
80
+ expected: bool = True
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Move:
85
+ """The Reasoning persona's output for a turn."""
86
+ kind: str = "say" # say | hint | advance | flag | answer | quiet
87
+ text: str = ""
88
+ refs: tuple[str, ...] = ()
89
+ claims: tuple[Claim, ...] = ()
90
+
91
+
92
+ @dataclass
93
+ class MissionMemory:
94
+ """The Reasoning persona's curated, SHARED working memory (read-only to other personas). Compact
95
+ and structured — not a raw transcript — so a small model stays sharp over a long mission."""
96
+ arc: str = "" # where the mission is, in a line
97
+ tried: list[str] = field(default_factory=list) # what the student has attempted
98
+ facts: list[str] = field(default_factory=list) # established facts worth carrying
99
+ threads: list[str] = field(default_factory=list) # open questions / loose ends
100
+
101
+ def note_tried(self, s: str) -> None:
102
+ if s and s not in self.tried:
103
+ self.tried.append(s)
104
+
105
+ def note_fact(self, s: str) -> None:
106
+ if s and s not in self.facts:
107
+ self.facts.append(s)
108
+
109
+ def digest(self, *, max_items: int = 6) -> str:
110
+ """A compact slice for a persona prompt (bounded — small models drown in clutter)."""
111
+ parts = []
112
+ if self.arc:
113
+ parts.append(f"Where we are: {self.arc}")
114
+ if self.tried:
115
+ parts.append("Tried: " + "; ".join(self.tried[-max_items:]))
116
+ if self.facts:
117
+ parts.append("Known: " + "; ".join(self.facts[-max_items:]))
118
+ if self.threads:
119
+ parts.append("Open: " + "; ".join(self.threads[-max_items:]))
120
+ return "\n".join(parts)
121
+
122
+
123
+ # ---- the domain-pack seam ------------------------------------------------- #
124
+
125
+
126
+ @runtime_checkable
127
+ class Verifier(Protocol):
128
+ """A tiny, single-aspect, deterministic checker. Produces Verdicts for a WorldView; declares the
129
+ state keys it depends on so the blackboard only re-runs it when relevant state changes."""
130
+ id: str
131
+
132
+ def deps(self) -> tuple[str, ...]: ...
133
+
134
+ def check(self, view) -> list[Verdict]: ...
135
+
136
+
137
+ @runtime_checkable
138
+ class Observer(Protocol):
139
+ """Senses the running world into Observations (a snapshot or discrete events)."""
140
+ id: str
141
+
142
+ def observe(self, world) -> list[Observation]: ...
gini/agent/domains.py ADDED
@@ -0,0 +1,91 @@
1
+ """Domain packs — the seam that makes the mission engine domain-agnostic (GINI_MISSIONS_AGENT_
2
+ ARCHITECTURE.md §8). The framework (blackboard, notifier, personas, reasoning) is blind to the domain;
3
+ a DomainPack supplies the domain-specific pieces behind one interface: the tiny Verifiers, the Observers,
4
+ the capability Vocabulary, the objective PredicateSet, the KB, the palette, and the fragments.
5
+
6
+ Networking is the first pack — it just wraps the modules we already have, so nothing is duplicated.
7
+ Cloud and xv6/OS become future packs implementing the same protocol; the reasoner discovers a pack's
8
+ tools by enumerating its verifiers/observers, which is the proof the reasoning is domain-neutral.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Protocol, runtime_checkable
13
+
14
+ from .contracts import Observation
15
+
16
+
17
+ @runtime_checkable
18
+ class DomainPack(Protocol):
19
+ name: str
20
+
21
+ def verifiers(self, lesson) -> list: ... # tiny verifiers for this lesson
22
+ def observers(self) -> list: ... # world sensors
23
+ def vocabulary(self): ... # capability roles module (is-a hierarchy)
24
+ def predicates(self) -> set: ... # objective predicate names
25
+ def fragments(self) -> list: ... # composable content
26
+ def palette(self) -> dict: ... # buildable/observable elements
27
+ def knowledge(self): ... # KB module (retrieval)
28
+
29
+
30
+ class TopologyObserver:
31
+ """Senses the networking world (the drawn topology) as a snapshot Observation."""
32
+ id = "topology"
33
+
34
+ def observe(self, world) -> list[Observation]:
35
+ data = world.to_dict() if hasattr(world, "to_dict") else world
36
+ return [Observation(source="topology", data=data)]
37
+
38
+
39
+ class NetworkingPack:
40
+ """The first domain pack — a thin adapter over the existing networking modules."""
41
+ name = "networking"
42
+
43
+ def verifiers(self, lesson) -> list:
44
+ from . import verifiers as _v
45
+ return _v.for_lesson(lesson)
46
+
47
+ def observers(self) -> list:
48
+ return [TopologyObserver()]
49
+
50
+ def vocabulary(self):
51
+ from ..domain import capabilities
52
+ return capabilities
53
+
54
+ def predicates(self) -> set:
55
+ from ..domain.objectives import _PREDICATES
56
+ return set(_PREDICATES)
57
+
58
+ def fragments(self) -> list:
59
+ from ..domain import fragments
60
+ return fragments.all_fragments()
61
+
62
+ def palette(self) -> dict:
63
+ from ..domain.devices import REGISTRY
64
+ return dict(REGISTRY)
65
+
66
+ def knowledge(self):
67
+ from . import kb
68
+ return kb
69
+
70
+
71
+ # -- registry --------------------------------------------------------------- #
72
+ _PACKS: dict[str, DomainPack] = {}
73
+
74
+
75
+ def register(pack: DomainPack) -> None:
76
+ _PACKS[pack.name] = pack
77
+
78
+
79
+ def get(name: str) -> DomainPack | None:
80
+ return _PACKS.get(name)
81
+
82
+
83
+ def names() -> list[str]:
84
+ return list(_PACKS)
85
+
86
+
87
+ register(NetworkingPack())
88
+
89
+ # the OS pack — same protocol, drops xv6/OS onto the domain-neutral engine.
90
+ from .xv6_pack import Xv6Pack # noqa: E402 (import here to avoid a cycle at module top)
91
+ register(Xv6Pack())
gini/agent/embed.py ADDED
@@ -0,0 +1,123 @@
1
+ """L2 semantic recall — embeddings over the (small, fixed) GINI knowledge base.
2
+
3
+ The KB is ~75 short documents (concept notes + recipes + element descriptions), so semantic
4
+ search is a cosine over a precomputed matrix, not a vector-database problem:
5
+
6
+ • POPULATE (offline, shipped): `scripts/build_kb_index.py` embeds every `kb_documents()`
7
+ entry once and writes `data/kb_index.json` (model tag + KB hash + id→vector). A test
8
+ asserts the hash matches the live KB, so an edited note without a rebuild fails CI.
9
+ • QUERY (online, reused infra): `OllamaEmbeddings` embeds the student's query via the SAME
10
+ Ollama server GINI already uses for the LLM (`/api/embed`) — no new Python dependency —
11
+ then cosines against the shipped vectors.
12
+
13
+ `NullEmbeddings` is the graceful fallback when no index/embed-model is available, so GINI
14
+ still runs offline (it simply drops to L0+L1). Callers use the `EmbeddingIndex` protocol and
15
+ never care which is active.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import math
22
+ from pathlib import Path
23
+ from typing import Protocol
24
+
25
+ from ..domain import concepts as _concepts
26
+ from ..domain import recipes as _recipes
27
+ from ..domain.devices import all_devices
28
+
29
+ _INDEX_PATH = Path(__file__).resolve().parent.parent / "data" / "kb_index.json"
30
+
31
+
32
+ # -- the shared document set (builder + validation) ------------------------- #
33
+ def kb_documents() -> list[tuple[str, str]]:
34
+ """(doc_id, text) for every embeddable KB card. Stable IDs `concept:<key>` /
35
+ `recipe:<id>` / `element:<key>` so recall can map a vector back to its object."""
36
+ docs: list[tuple[str, str]] = []
37
+ for c in _concepts.CONCEPTS:
38
+ docs.append((f"concept:{c.key}",
39
+ f"{c.title}. {' '.join(c.keywords)}. {c.body}"))
40
+ for r in _recipes.RECIPES:
41
+ docs.append((f"recipe:{r.id}",
42
+ f"{r.name}. {r.summary} {r.teaches} {' '.join(r.intent)}"))
43
+ for d in all_devices():
44
+ docs.append((f"element:{d.key}", f"{d.label}: {d.description}"))
45
+ return docs
46
+
47
+
48
+ def kb_hash() -> str:
49
+ """A stable hash of the KB text — the index records it so we can detect drift."""
50
+ h = hashlib.sha256()
51
+ for doc_id, text in kb_documents():
52
+ h.update(doc_id.encode()); h.update(b"\x00"); h.update(text.encode()); h.update(b"\x01")
53
+ return h.hexdigest()
54
+
55
+
56
+ # -- cosine helpers --------------------------------------------------------- #
57
+ def _cosine(a: list[float], b: list[float]) -> float:
58
+ if not a or not b or len(a) != len(b):
59
+ return 0.0
60
+ dot = sum(x * y for x, y in zip(a, b))
61
+ na = math.sqrt(sum(x * x for x in a))
62
+ nb = math.sqrt(sum(y * y for y in b))
63
+ return dot / (na * nb) if na and nb else 0.0
64
+
65
+
66
+ # -- the protocol + implementations ----------------------------------------- #
67
+ class EmbeddingIndex(Protocol):
68
+ def available(self) -> bool: ...
69
+ def query(self, text: str, k: int = 4) -> list[tuple[str, float]]: ...
70
+
71
+
72
+ class NullEmbeddings:
73
+ """The offline fallback — never matches, so recall degrades to L0+L1."""
74
+
75
+ def available(self) -> bool:
76
+ return False
77
+
78
+ def query(self, text: str, k: int = 4) -> list[tuple[str, float]]:
79
+ return []
80
+
81
+
82
+ def load_index(path: Path = _INDEX_PATH) -> dict | None:
83
+ try:
84
+ return json.loads(Path(path).read_text())
85
+ except (OSError, json.JSONDecodeError):
86
+ return None
87
+
88
+
89
+ class OllamaEmbeddings:
90
+ """Semantic recall backed by the precomputed index + an Ollama `/api/embed` query call.
91
+
92
+ `backend` is any object exposing `embed(texts) -> list[vector]` (the OllamaBackend). The
93
+ index is loaded once; if it's missing or the KB has drifted, `available()` is False and we
94
+ fall back cleanly. `min_score` gates weak cosine matches out."""
95
+
96
+ def __init__(self, backend, *, index: dict | None = None, index_path: Path = _INDEX_PATH,
97
+ min_score: float = 0.55, check_hash: bool = True) -> None:
98
+ self.backend = backend
99
+ self.min_score = min_score
100
+ self._index = index if index is not None else load_index(index_path)
101
+ self._ok = bool(self._index) and bool(self._index.get("vectors"))
102
+ if self._ok and check_hash:
103
+ # a mismatched hash means the notes changed without a rebuild — don't trust it
104
+ if self._index.get("kb_hash") not in (None, kb_hash()):
105
+ self._ok = False
106
+
107
+ def available(self) -> bool:
108
+ return self._ok and self.backend is not None
109
+
110
+ def query(self, text: str, k: int = 4) -> list[tuple[str, float]]:
111
+ if not self.available():
112
+ return []
113
+ try:
114
+ vecs = self.backend.embed([text])
115
+ qv = vecs[0] if vecs else None
116
+ except Exception:
117
+ return []
118
+ if not qv:
119
+ return []
120
+ scored = [(doc_id, _cosine(qv, vec)) for doc_id, vec in self._index["vectors"].items()]
121
+ scored = [(d, s) for d, s in scored if s >= self.min_score]
122
+ scored.sort(key=lambda p: -p[1])
123
+ return scored[:k]