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,116 @@
1
+ """Mission narration — the LLM writes the story, GINI checks it against the truth.
2
+
3
+ A mission has two faces. The **objectives** are the truth: they come from verified fragments, they
4
+ are what gets graded, and the model never writes them. The **title and description** are the story
5
+ told about that truth — and prose is exactly what a small model is good at.
6
+
7
+ The failure this module exists to prevent: the model narrating a mission that isn't the one being
8
+ graded (a "decouple your web tier with a message queue" title sitting on top of VPC-isolation
9
+ objectives). Note that title and brief agreeing with *each other* proves nothing — they're written
10
+ in one breath, so they always agree. The only agreement worth checking is narrative ⟷ objectives.
11
+
12
+ So we check it, deterministically:
13
+
14
+ the elements the objectives grade = the only elements the prose may claim
15
+
16
+ If the story mentions a queue and no objective grades a queue, the story is false — reject it and
17
+ ask again, or fall back to the fragment's own summary. The model gets to be eloquent; it does not
18
+ get to be wrong. No LLM is asked to police itself.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ from ..domain import devices as _devices
25
+ from ..domain import objectives as _obj
26
+ from ..domain import probes as _probes
27
+
28
+ # words that name an element but are also ordinary English — only count them as a CLAIM about an
29
+ # element when the objectives don't already license them (checked below), never as free-text.
30
+ _STOP = {"internet", "cloud", "network", "host", "machine", "function", "gateway"}
31
+
32
+
33
+ def graded_types(lesson) -> set[str]:
34
+ """Every element type the lesson's objectives actually grade — the licensed vocabulary."""
35
+ out: set[str] = set()
36
+ for o in lesson.objectives:
37
+ if o.check:
38
+ out.update(_obj.element_types_in_check(o.check))
39
+ if o.probe:
40
+ try:
41
+ p = _probes.parse(o.probe)
42
+ except _probes.ProbeError:
43
+ continue
44
+ out.update(x for x in (p.src, p.dst) if x)
45
+ return {t for t in out if _devices.get(t) is not None}
46
+
47
+
48
+ def _phrases() -> list[tuple[str, str]]:
49
+ """(phrase, type_key) for every element, longest phrase first so 'load balancer' beats 'balancer'."""
50
+ out: list[tuple[str, str]] = []
51
+ for dt in _devices.all_devices():
52
+ out.append((dt.label.lower(), dt.key))
53
+ out.append((dt.key.replace("_", " ").lower(), dt.key))
54
+ return sorted(set(out), key=lambda p: -len(p[0]))
55
+
56
+
57
+ def false_claims(text: str, allowed: set[str]) -> list[str]:
58
+ """Element types the prose names that NO objective grades. These are the lies."""
59
+ low = " " + re.sub(r"[^a-z0-9 ]+", " ", (text or "").lower()) + " "
60
+ bad: list[str] = []
61
+ for phrase, key in _phrases():
62
+ if key in allowed or key in bad:
63
+ continue
64
+ if phrase in _STOP:
65
+ continue # too ordinary to read as a claim about an element
66
+ if re.search(rf"\b{re.escape(phrase)}s?\b", low):
67
+ bad.append(key)
68
+ return bad
69
+
70
+
71
+ _PROMPT = (
72
+ "You are writing the brief for a hands-on lab mission a student is about to play.\n\n"
73
+ "The student asked for: {intent!r}\n\n"
74
+ "These are the EXACT tasks they will be graded on — the mission IS these and nothing else:\n"
75
+ "{tasks}\n\n"
76
+ "Write:\n"
77
+ " title — a SHORT name, at most 6 words, no trailing period. Name the idea, not the steps.\n"
78
+ " description — 3 to 4 sentences. Say what they're building, WHY an engineer would want it, "
79
+ "and what 'done' looks like. Speak to the student as 'you'. Do not number the tasks or repeat "
80
+ "them one by one — they can already see them.\n\n"
81
+ "HARD RULE: mention ONLY these components: {allowed}. Do not mention any other technology "
82
+ "(no queues, caches, dashboards, load balancers, etc. unless listed). Naming something that "
83
+ "isn't in that list makes the brief WRONG.\n\n"
84
+ 'Reply ONLY as JSON: {{"title": "...", "description": "..."}}'
85
+ )
86
+
87
+
88
+ def narrate(lesson, intent: str, llm, *, retries: int = 1) -> tuple[str, str]:
89
+ """Ask the model for a short title + a longer description, and REFUSE anything that claims an
90
+ element the objectives don't grade. Returns ("", "") if the model can't tell the truth — the
91
+ caller then falls back to the fragment's own summary, which is always true by construction."""
92
+ from .personas import first_json
93
+ allowed = graded_types(lesson)
94
+ if not allowed or llm is None:
95
+ return "", ""
96
+ labels = sorted((_devices.get(t).label for t in allowed), key=str.lower)
97
+ tasks = "\n".join(f" - {o.say}" for o in lesson.objectives)
98
+ prompt = _PROMPT.format(intent=intent, tasks=tasks, allowed=", ".join(labels))
99
+
100
+ for _ in range(retries + 1):
101
+ try:
102
+ obj = first_json(llm(prompt))
103
+ except Exception: # noqa: BLE001
104
+ return "", ""
105
+ if not isinstance(obj, dict):
106
+ continue
107
+ title = " ".join(str(obj.get("title", "")).split()).strip(" .")
108
+ desc = " ".join(str(obj.get("description", "")).split())
109
+ if not title or not desc:
110
+ continue
111
+ if len(title.split()) > 8: # "short" is part of the brief
112
+ continue
113
+ if false_claims(title, allowed) or false_claims(desc, allowed):
114
+ continue # it narrated a mission we aren't grading
115
+ return title, desc
116
+ return "", ""
gini/agent/notifier.py ADDED
@@ -0,0 +1,86 @@
1
+ """Notification / salience — the deterministic layer that decides which changes are worth waking the
2
+ Reasoning persona (GINI_MISSIONS_AGENT_ARCHITECTURE.md §3-4). Salience is **rules-only** (decided): a
3
+ fixed table, no LLM on the hot path. Input = the verdicts that FLIPPED on the last blackboard update
4
+ (plus explicit events like a student question or a run completing); output = salient Notifications.
5
+
6
+ `MissionMonitor` ties the deterministic swarm together: world change → blackboard re-verify → salient
7
+ notifications. It's the seam the live UI and the Reasoning persona both sit against.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from .blackboard import Blackboard
12
+ from .contracts import Notification
13
+
14
+ # rules-only salience table (0..1). Tune the RULES if noisy — never add a model here.
15
+ SALIENCE = {
16
+ "objective_met": 0.6,
17
+ "objective_unmet": 0.4,
18
+ "off_task_added": 0.9,
19
+ "off_task_cleared": 0.3,
20
+ "illegal_link_added": 0.9,
21
+ "illegal_link_cleared": 0.3,
22
+ "forbid_tripped": 1.0,
23
+ "forbid_cleared": 0.3,
24
+ "mission_complete": 1.0,
25
+ "question": 1.0,
26
+ "run_complete": 0.7,
27
+ }
28
+
29
+
30
+ def _note(change: str, subjects=(), data=None) -> Notification:
31
+ return Notification(change, subjects=tuple(subjects), salience=SALIENCE.get(change, 0.5), data=data)
32
+
33
+
34
+ def notifications_from_flips(flipped, blackboard: Blackboard) -> list[Notification]:
35
+ """Map flipped verdicts → salient notifications by rule. `flipped` is what `Blackboard.update`
36
+ returned (verdicts whose value changed or newly appeared)."""
37
+ notes: list[Notification] = []
38
+ for v in flipped:
39
+ if v.verifier_id.startswith("objective:"):
40
+ notes.append(_note("objective_met" if v.value else "objective_unmet", subjects=(v.subject,)))
41
+ elif v.subject == "off_task":
42
+ data = list(v.evidence.data) if v.evidence else []
43
+ notes.append(_note("off_task_cleared" if v.value else "off_task_added",
44
+ subjects=tuple(data), data=data))
45
+ elif v.subject == "illegal_links":
46
+ data = list(v.evidence.data) if v.evidence else []
47
+ notes.append(_note("illegal_link_cleared" if v.value else "illegal_link_added",
48
+ subjects=tuple(data), data=data))
49
+ elif v.subject.startswith("forbid:"):
50
+ say = v.evidence.data if v.evidence else ""
51
+ notes.append(_note("forbid_cleared" if v.value else "forbid_tripped",
52
+ subjects=(v.subject,), data=say))
53
+ # a completion is derived from the whole board, not a single flip
54
+ if flipped and blackboard.all_objectives_met():
55
+ notes.append(_note("mission_complete"))
56
+ return notes
57
+
58
+
59
+ def top(notes) -> Notification | None:
60
+ """The single most salient notification (what the reasoner should react to first), or None."""
61
+ return max(notes, key=lambda n: n.salience) if notes else None
62
+
63
+
64
+ class MissionMonitor:
65
+ """The deterministic swarm, packaged: keeps a blackboard current and emits salient notifications
66
+ on every world change. The Reasoning persona reads the blackboard; this tells it when to wake."""
67
+
68
+ def __init__(self) -> None:
69
+ self.bb = Blackboard()
70
+
71
+ def load(self, lesson, *, pack=None, runner=None) -> None:
72
+ verifiers = pack.verifiers(lesson) if pack is not None else None
73
+ self.bb.load_lesson(lesson, verifiers=verifiers, runner=runner)
74
+
75
+ def clear(self) -> None:
76
+ self.bb.clear()
77
+
78
+ def on_world_change(self, topology, *, changed: set[str] | None = None) -> list[Notification]:
79
+ flipped = self.bb.update(topology, changed=changed)
80
+ return notifications_from_flips(flipped, self.bb)
81
+
82
+ def on_question(self, text: str) -> Notification:
83
+ return _note("question", data=text)
84
+
85
+ def on_run_complete(self) -> Notification:
86
+ return _note("run_complete")
gini/agent/personas.py ADDED
@@ -0,0 +1,79 @@
1
+ """One small model, many narrow personas (GINI_MISSIONS_AGENT_ARCHITECTURE.md §5). A Persona is a
2
+ first-class object — a "you do ONLY this" system prompt, decoding params, and a stateful flag. The
3
+ PersonaRunner is the single, clean-isolation call site: it builds a prompt from exactly
4
+ `system + context + task` and carries NOTHING else across a call (the defence against persona-leak).
5
+
6
+ State lives on the blackboard (the shared curated memory), never inside the runner — so the runner is
7
+ stateless and any persona's "history" is reconstructed per call from the memory slice it's handed.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import json
13
+ from dataclasses import dataclass
14
+
15
+
16
+ def first_json(text: str):
17
+ """The first balanced {...} object in a model reply, parsed — or None. Small models wrap JSON in
18
+ prose; this is the tolerant extractor every persona uses to read structured output."""
19
+ depth, start = 0, None
20
+ for i, ch in enumerate(text or ""):
21
+ if ch == "{":
22
+ if depth == 0:
23
+ start = i
24
+ depth += 1
25
+ elif ch == "}" and depth > 0:
26
+ depth -= 1
27
+ if depth == 0 and start is not None:
28
+ try:
29
+ return json.loads(text[start:i + 1])
30
+ except json.JSONDecodeError:
31
+ start = None
32
+ return None
33
+
34
+
35
+ @dataclass(frozen=True, eq=False)
36
+ class Persona:
37
+ name: str
38
+ system: str # "you do ONLY this" — no general-assistant framing
39
+ temperature: float = 0.3
40
+ stateful: bool = False # only the Reasoning persona is stateful (reads shared memory)
41
+ schema: dict | None = None # optional JSON Schema → decoder-constrained output (Reasoning 2.0)
42
+
43
+
44
+ class PersonaRunner:
45
+ """Invokes personas on one injected model. `llm` is `llm(prompt) -> str`, optionally accepting
46
+ `temperature=` and/or `schema=` keyword args (the runner passes each only when the callable
47
+ accepts it — so simple lambdas in tests and older backends keep working unchanged)."""
48
+
49
+ def __init__(self, llm) -> None:
50
+ self._llm = llm
51
+ self._accepts_temp = False
52
+ self._accepts_schema = False
53
+ try:
54
+ params = inspect.signature(llm).parameters
55
+ self._accepts_temp = "temperature" in params
56
+ self._accepts_schema = "schema" in params
57
+ except (TypeError, ValueError):
58
+ pass
59
+ self.last_prompt = "" # for tests / debugging — the exact text last sent
60
+
61
+ def call(self, persona: Persona, *, context: str = "", task: str = "") -> str:
62
+ parts = [persona.system]
63
+ if context:
64
+ parts.append(context)
65
+ if task:
66
+ parts.append(task)
67
+ prompt = "\n\n".join(p for p in parts if p).strip()
68
+ self.last_prompt = prompt
69
+ if self._llm is None:
70
+ return ""
71
+ kwargs = {}
72
+ if self._accepts_temp:
73
+ kwargs["temperature"] = persona.temperature
74
+ if self._accepts_schema and persona.schema is not None:
75
+ kwargs["schema"] = persona.schema
76
+ try:
77
+ return (self._llm(prompt, **kwargs) or "").strip()
78
+ except Exception:
79
+ return ""
@@ -0,0 +1,172 @@
1
+ """The Reasoning persona — the centre of the loop (GINI_MISSIONS_AGENT_ARCHITECTURE.md §3,5,6). It
2
+ reads TRUTH from the blackboard (never recomputes it), the mission intent, and the shared curated
3
+ memory, then reasons out the next move for whatever woke it (a salient Notification or a student
4
+ Intent). The deterministic layer decides *when* to wake it and supplies the facts; the wording and the
5
+ pedagogy are entirely the model's.
6
+
7
+ Grounding is by construction: the prompt carries the live objective/legality facts, so the answer is
8
+ about the student's actual board — not canned. A non-LLM fallback keeps it usable offline (degraded).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .contracts import Intent, Move, Notification
13
+ from .personas import Persona, PersonaRunner, first_json
14
+
15
+ _SYSTEM = (
16
+ "You are the game master of a hands-on lab mission — warm, concise, and in character. You ONLY "
17
+ "produce the next short line to the student (1-2 sentences). Ground every statement in the FACTS "
18
+ "you are given; never invent devices, connections, or states that aren't listed. If the facts "
19
+ "don't cover the question, say what IS known and stop — don't guess.")
20
+
21
+ REASONING = Persona("Reasoning", system=_SYSTEM, temperature=0.35, stateful=True)
22
+
23
+ # The covered variant (Reasoning 2.0): same voice, but the reply is ONE JSON object carrying the
24
+ # line plus a coverage report the Twin diffs exactly. Decoder-constrained where the backend
25
+ # supports structured outputs (schema); the tolerant first_json path handles the rest.
26
+ def _covered_persona():
27
+ from .twin.dialectic import COVERAGE_SCHEMA
28
+ return Persona("Reasoning", system=_SYSTEM, temperature=0.35, stateful=True,
29
+ schema=COVERAGE_SCHEMA)
30
+
31
+
32
+ REASONING_COVERED = None # built lazily (avoids a twin import at module load)
33
+
34
+ # how a triggering change maps to the SHAPE of the move (routing, not reasoning — the model still
35
+ # writes the content)
36
+ _MOVE_KIND = {
37
+ "mission_complete": "advance", "objective_met": "say", "objective_unmet": "hint",
38
+ "off_task_added": "flag", "illegal_link_added": "flag", "forbid_tripped": "flag",
39
+ "question": "answer",
40
+ }
41
+
42
+
43
+ class ReasoningAgent:
44
+ def __init__(self, runner: PersonaRunner, blackboard, lesson) -> None:
45
+ self.runner = runner
46
+ self.bb = blackboard
47
+ self.lesson = lesson
48
+ self.last_coverage = None # the last covered turn's report (None = coverage-silent)
49
+
50
+ # -- grounding: turn the blackboard's verdicts into a compact fact sheet -- #
51
+ def _situation(self) -> str:
52
+ say = {o.id: o.say for o in self.lesson.objectives}
53
+ met = [say.get(s, s) for s in
54
+ (v.subject for v in self.bb.verdicts() if v.verifier_id.startswith("objective:") and v.value)]
55
+ unmet_ids = self.bb.unmet_objectives()
56
+ unmet = [say.get(s, s) + self._why(s) for s in unmet_ids] # each open item + WHY it's red
57
+ flags = self.bb.flags()
58
+ lines = [f"Objectives met ({len(met)}/{len(met) + len(unmet)}): " + ("; ".join(met) or "none"),
59
+ "Still open: " + ("; ".join(unmet) or "none — all done")]
60
+ if flags.get("off_task"):
61
+ lines.append("Off-task elements on the board: " + ", ".join(flags["off_task"]))
62
+ if flags.get("illegal_links"):
63
+ lines.append("Illegal connections present.")
64
+ return "\n".join(lines)
65
+
66
+ def _why(self, objective_id: str) -> str:
67
+ """A deterministic, board-grounded reason an objective is red (the predicate explainer). We
68
+ need the live topology for this; the blackboard caches the world on evaluation."""
69
+ obj = next((o for o in self.lesson.objectives if o.id == objective_id), None)
70
+ world = getattr(self.bb, "_world", None)
71
+ if obj is None or world is None:
72
+ return ""
73
+ try:
74
+ from ..domain import explain as _explain
75
+ reason = _explain.diagnose(obj, world)
76
+ except Exception:
77
+ reason = ""
78
+ return f" — {reason}" if reason else ""
79
+
80
+ def _intent(self) -> str:
81
+ it = self.lesson.intent
82
+ parts = [f"Mission goal: {it.goal}"] if it.goal else []
83
+ if it.spirit:
84
+ parts.append(f"What success means: {it.spirit}")
85
+ if it.misconceptions:
86
+ parts.append("Watch for these misconceptions: " + "; ".join(it.misconceptions))
87
+ # the instructor's own nuance, authored on the Teaching Center and interpreted HERE (this is
88
+ # why the course server needs no LLM — the teacher writes plain language, we reason over it)
89
+ if getattr(it, "notes", ""):
90
+ parts.append(f"Your instructor's guidance for this lab: {it.notes}")
91
+ return "\n".join(parts)
92
+
93
+ def _context(self) -> str:
94
+ return "\n".join(p for p in (self._intent(), "FACTS:\n" + self._situation(),
95
+ self.bb.memory.digest()) if p)
96
+
97
+ # -- the reasoning turn ------------------------------------------------- #
98
+ def react(self, trigger, *, note: str = "", coverage_concerns=None) -> Move:
99
+ """React to a Notification or a student Intent → one grounded Move. `note` (optional) is a
100
+ critique from the Critic persona used to revise a first draft. `coverage_concerns`
101
+ (Reasoning 2.0) asks the persona to ALSO report coverage against the Twin's concern list;
102
+ the parsed report lands on `self.last_coverage` (None = coverage-silent)."""
103
+ change, task, refs = self._frame(trigger)
104
+ if note:
105
+ task = f"{task}\nA reviewer noted: {note}. Fix that in your line."
106
+ self.last_coverage = None
107
+ if coverage_concerns:
108
+ global REASONING_COVERED
109
+ if REASONING_COVERED is None:
110
+ REASONING_COVERED = _covered_persona()
111
+ from .twin.dialectic import concern_context, coverage_instruction
112
+ # twin-as-context: the concern set rides in the GROUNDING (guaranteed recall shapes
113
+ # the draft), and again as the coverage checklist the exact diff runs against.
114
+ ctx = "\n\n".join(p for p in (self._context(),
115
+ concern_context(coverage_concerns)) if p)
116
+ raw = self.runner.call(REASONING_COVERED, context=ctx,
117
+ task=task + coverage_instruction(coverage_concerns))
118
+ text = self._parse_covered(raw) or self._fallback(change)
119
+ else:
120
+ text = (self.runner.call(REASONING, context=self._context(), task=task)
121
+ or self._fallback(change))
122
+ if not note: # don't double-record on a revision pass
123
+ self._remember(change, trigger)
124
+ return Move(kind=_MOVE_KIND.get(change, "say"), text=text, refs=refs)
125
+
126
+ def _parse_covered(self, raw: str) -> str:
127
+ """A covered reply -> the tutor line, with the coverage report set aside for the Twin.
128
+ Tolerant: a model that ignored the JSON shape just becomes coverage-silent prose."""
129
+ from .twin.contracts import parse_coverage
130
+ obj = first_json(raw or "")
131
+ if isinstance(obj, dict) and isinstance(obj.get("text"), str):
132
+ self.last_coverage = parse_coverage(obj.get("coverage"))
133
+ return obj["text"].strip()
134
+ return (raw or "").strip()
135
+
136
+ def _frame(self, trigger) -> tuple[str, str, tuple]:
137
+ if isinstance(trigger, Intent):
138
+ return ("question",
139
+ f"The student asks: {trigger.text!r}. Answer using ONLY the facts above, tied to "
140
+ "what they've built.", trigger.refs)
141
+ change = getattr(trigger, "change", "say")
142
+ subs = tuple(getattr(trigger, "subjects", ()) or ())
143
+ tasks = {
144
+ "mission_complete": "The student just completed every objective. Congratulate them in ONE "
145
+ "specific line tied to what they achieved.",
146
+ "objective_met": "The student just satisfied an objective. Say 'warmer' in ONE line, naming "
147
+ "what improved — do NOT reveal the next step.",
148
+ "objective_unmet": "The student moved away from a goal. Nudge in ONE line — no solution.",
149
+ "off_task_added": f"The student placed something off-task ({', '.join(subs)}). Flag it in "
150
+ "ONE in-character line and tell them to remove it — do NOT remove it yourself.",
151
+ "illegal_link_added": "The student made a connection that isn't allowed. Flag it in ONE line "
152
+ "and tell them to rewire it.",
153
+ "forbid_tripped": f"The student tripped a rule that must stay false ({trigger.data}). Flag it "
154
+ "in ONE line.",
155
+ }
156
+ return (change, tasks.get(change, "Give the student ONE short, helpful line for the current "
157
+ "situation."), subs)
158
+
159
+ def _remember(self, change: str, trigger) -> None:
160
+ if change == "question" and isinstance(trigger, Intent):
161
+ self.bb.memory.threads.append(trigger.text)
162
+ elif change == "objective_met":
163
+ self.bb.memory.note_fact("progress: an objective was satisfied")
164
+ elif change in ("off_task_added", "illegal_link_added", "forbid_tripped"):
165
+ self.bb.memory.note_tried(f"made a flagged move ({change})")
166
+ self.bb.memory.arc = self._situation().splitlines()[0]
167
+
168
+ def _fallback(self, change: str) -> str:
169
+ return {"mission_complete": "Nice — every objective is green. You did it!",
170
+ "off_task_added": "That doesn't belong in this mission — take it off the board.",
171
+ "objective_met": "Warmer — that helped.",
172
+ }.get(change, "Keep going — check what's still open above.")