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/meaning.py ADDED
@@ -0,0 +1,225 @@
1
+ """The meaning-side agents around the Reasoning persona: Understanding, Critic, the always-on
2
+ fast-path Classifier, and the MissionAgent that orchestrates one turn (GINI_MISSIONS_AGENT_ARCHITECTURE
3
+ .md §3-5). All are the SAME model in different personas; each is a narrow task with a strict small
4
+ output — the way we keep a small model reliable.
5
+
6
+ Turn flow: classify (always) → understand (if an utterance and routed) → reason → critique + verify
7
+ claims (if routed) → optionally one revision. Understanding & Critic are stateless; Reasoning owns the
8
+ shared memory.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from .contracts import CONCEPT, ENTITY, META, NEXT_STEP, OBJECTIVE, OFF_TASK, Intent, Move
15
+ from .personas import Persona, PersonaRunner, first_json
16
+ from .reasoning import ReasoningAgent
17
+
18
+ _KINDS = {CONCEPT, ENTITY, OBJECTIVE, NEXT_STEP, OFF_TASK, META}
19
+
20
+
21
+ # ---- Understanding -------------------------------------------------------- #
22
+ class UnderstandingAgent:
23
+ persona = Persona("Understanding",
24
+ system="You classify a student's message during a hands-on lab. Output ONLY "
25
+ "JSON: {\"kind\": concept|entity|objective|next_step|off_task|meta, "
26
+ "\"objective_ref\": \"<objective id or empty>\", \"refs\": [\"<name>\"]}. "
27
+ "No prose.", temperature=0.1)
28
+
29
+ def __init__(self, runner: PersonaRunner, lesson) -> None:
30
+ self.runner = runner
31
+ self.lesson = lesson
32
+
33
+ def parse(self, utterance: str) -> Intent:
34
+ objs = "; ".join(f"{o.id}: {o.say}" for o in self.lesson.objectives)
35
+ out = self.runner.call(self.persona, context=f"Objectives — {objs}",
36
+ task=f"Message: {utterance!r}")
37
+ obj = first_json(out)
38
+ if not isinstance(obj, dict):
39
+ # tolerant fallback (never keyword-decide meaning): a '?' reads as a question
40
+ return Intent(kind=OBJECTIVE if "?" in utterance else META, text=utterance)
41
+ kind = obj.get("kind") if obj.get("kind") in _KINDS else META
42
+ return Intent(kind=kind, text=utterance,
43
+ refs=tuple(str(r) for r in obj.get("refs", []) or []),
44
+ objective_ref=str(obj.get("objective_ref", "")))
45
+
46
+
47
+ # ---- Critic --------------------------------------------------------------- #
48
+ @dataclass
49
+ class Critique:
50
+ ok: bool = True
51
+ missing: tuple[str, ...] = ()
52
+ unsupported: tuple[str, ...] = ()
53
+
54
+ def note(self) -> str:
55
+ bits = []
56
+ if self.missing:
57
+ bits.append("missing: " + "; ".join(self.missing))
58
+ if self.unsupported:
59
+ bits.append("unsupported: " + "; ".join(self.unsupported))
60
+ return " / ".join(bits)
61
+
62
+
63
+ class CriticAgent:
64
+ persona = Persona("Critic",
65
+ system="You audit a draft tutor line against the FACTS. Output ONLY JSON: "
66
+ "{\"ok\": true|false, \"missing\": [\"...\"], \"unsupported\": [\"...\"]}. "
67
+ "'unsupported' = claims not backed by the facts; 'missing' = the question "
68
+ "isn't actually answered. No prose.", temperature=0.0)
69
+
70
+ def __init__(self, runner: PersonaRunner) -> None:
71
+ self.runner = runner
72
+
73
+ def audit(self, move: Move, facts: str, *, question: str = "") -> Critique:
74
+ out = self.runner.call(self.persona, context="FACTS:\n" + facts,
75
+ task=f"Question: {question!r}\nDraft: {move.text!r}")
76
+ obj = first_json(out)
77
+ if not isinstance(obj, dict):
78
+ return Critique(ok=True)
79
+ return Critique(ok=bool(obj.get("ok", True)),
80
+ missing=tuple(str(x) for x in obj.get("missing", []) or []),
81
+ unsupported=tuple(str(x) for x in obj.get("unsupported", []) or []))
82
+
83
+ @staticmethod
84
+ def verify_claims(move: Move, blackboard) -> list[str]:
85
+ """Deterministic grounding check: any Claim whose expected value contradicts the blackboard.
86
+ This is the oracle re-checking the model, independent of the LLM Critic."""
87
+ bad = []
88
+ for c in move.claims:
89
+ v = blackboard.verdict(c.predicate)
90
+ if v is not None and v.value != c.expected:
91
+ bad.append(c.predicate)
92
+ return bad
93
+
94
+
95
+ # ---- fast-path classifier (always-on) ------------------------------------- #
96
+ @dataclass
97
+ class Route:
98
+ reason: bool = True
99
+ understand: bool = False
100
+ critic: bool = False
101
+
102
+
103
+ class Classifier:
104
+ persona = Persona("Router",
105
+ system="You route one turn of a lab tutor. Output ONLY JSON: {\"reason\": "
106
+ "true|false, \"understand\": true|false, \"critic\": true|false}. "
107
+ "reason=does it need a reply; understand=is there a message to interpret; "
108
+ "critic=is it worth auditing (complex/answering a question). No prose.",
109
+ temperature=0.0)
110
+
111
+ def __init__(self, runner: PersonaRunner) -> None:
112
+ self.runner = runner
113
+
114
+ def route(self, *, change: str = "", utterance: str = "") -> Route:
115
+ # deterministic fallback when offline: reply to any trigger, understand iff there's a message
116
+ if self.runner is None or self.runner._llm is None:
117
+ return Route(reason=bool(change or utterance), understand=bool(utterance), critic=False)
118
+ out = self.runner.call(self.persona,
119
+ context=f"change={change or 'none'} utterance={utterance!r}",
120
+ task="Route this turn.")
121
+ obj = first_json(out) or {}
122
+ understand = bool(obj.get("understand", bool(utterance))) and bool(utterance)
123
+ return Route(reason=bool(obj.get("reason", True)), understand=understand,
124
+ critic=bool(obj.get("critic", False)))
125
+
126
+
127
+ # ---- orchestration -------------------------------------------------------- #
128
+ class MissionAgent:
129
+ """One model wearing all three hats, plus the deterministic blackboard, run as a single turn.
130
+ With a `twin` (Reasoning 2.0), the turn is additionally audited for COVERAGE: the Twin
131
+ enumerates the concerns that matter, diffs the persona's coverage report exactly, objects
132
+ once ("why not X?"), and flags whatever survives. `twin=None` ≡ the pre-Twin behavior."""
133
+
134
+ def __init__(self, runner: PersonaRunner, blackboard, lesson, twin=None) -> None:
135
+ self.runner = runner
136
+ self.bb = blackboard
137
+ self.lesson = lesson
138
+ self.classifier = Classifier(runner)
139
+ self.understanding = UnderstandingAgent(runner, lesson)
140
+ self.reasoning = ReasoningAgent(runner, blackboard, lesson)
141
+ self.critic = CriticAgent(runner)
142
+ self.twin = twin
143
+ self.last_twin_result = None # TwinResult of the last audited turn (tests/metrics)
144
+
145
+ def turn(self, trigger=None, *, utterance: str = "") -> Move:
146
+ change = getattr(trigger, "change", "") if trigger is not None else ""
147
+ route = self.classifier.route(change=change, utterance=utterance) # always-on
148
+ if not route.reason:
149
+ return Move("quiet")
150
+ intent = self.understanding.parse(utterance) if (route.understand and utterance) else None
151
+ react_trigger = intent if intent is not None else (
152
+ trigger if trigger is not None else Intent(text=utterance))
153
+ concerns = self._concerns()
154
+ move = self.reasoning.react(react_trigger, coverage_concerns=concerns or None)
155
+
156
+ bad = self.critic.verify_claims(move, self.bb) # deterministic oracle check (always)
157
+ if route.critic or bad:
158
+ crit = self.critic.audit(move, self.reasoning._context(), question=utterance)
159
+ note = crit.note()
160
+ if bad:
161
+ note = (note + " / " if note else "") + "contradicts facts: " + "; ".join(bad)
162
+ if not crit.ok or crit.unsupported or bad:
163
+ move = self.reasoning.react(react_trigger, note=note, # one revision pass
164
+ coverage_concerns=concerns or None)
165
+ return self._twin_audit(move, react_trigger, concerns)
166
+
167
+ # -- Reasoning 2.0: the Twin's coverage audit --------------------------- #
168
+ def _concerns(self) -> list:
169
+ if self.twin is None:
170
+ return []
171
+ try:
172
+ from .twin.mission import mission_concerns
173
+ return mission_concerns(self.bb, self.lesson)
174
+ except Exception:
175
+ return [] # the Twin is strictly additive — never break a turn
176
+
177
+ def _twin_ctx(self, move: Move, react_trigger):
178
+ """The adjudication context for this turn: what shape of move this is, what (if anything)
179
+ was asked, the live board, the covered-history, and the schema-constrained translator for
180
+ state claims (LLM proposes the predicate; the oracle evaluates it)."""
181
+ from .twin.dialectic import TwinContext
182
+ from .twin.justify import llm_translate
183
+ utterance = react_trigger.text if isinstance(react_trigger, Intent) else ""
184
+ return TwinContext(move_kind=move.kind, utterance=utterance,
185
+ world=getattr(self.bb, "_world", None),
186
+ history=self.twin.history,
187
+ translate=lambda why: llm_translate(self.runner, why))
188
+
189
+ def _twin_audit(self, move: Move, react_trigger, concerns: list) -> Move:
190
+ if self.twin is None or not concerns:
191
+ return move
192
+ try:
193
+ import time
194
+ from .twin.dialectic import TwinResult
195
+ twin = self.twin
196
+ twin.metrics["turns"] += 1
197
+ cov = self.reasoning.last_coverage
198
+ first_cov = cov
199
+ if cov is None:
200
+ twin.metrics["coverage_silent"] += 1
201
+ objections = twin.audit(concerns, cov, self._twin_ctx(move, react_trigger))
202
+ first = tuple(objections)
203
+ twin.metrics["objections"] += len(first)
204
+ # the bounded dialectic: revise while objections stand, up to max_rounds or budget
205
+ rounds, deadline = 0, time.monotonic() + twin.budget_s
206
+ while objections and rounds < twin.max_rounds and time.monotonic() < deadline:
207
+ move = self.reasoning.react(react_trigger, note=twin.note(objections),
208
+ coverage_concerns=concerns)
209
+ rounds += 1
210
+ twin.metrics["revisions"] += 1
211
+ cov = self.reasoning.last_coverage
212
+ objections = twin.audit(concerns, cov, self._twin_ctx(move, react_trigger))
213
+ surviving = tuple(objections)
214
+ if surviving:
215
+ move = self.twin.flag(move, list(surviving)) # visible, never a silent ship
216
+ twin.metrics["flags"] += 1
217
+ twin.record(cov) # covered ids -> mission history
218
+ accepted, rejected = twin.split_omissions(concerns, cov)
219
+ self.last_twin_result = TwinResult(
220
+ concerns=tuple(concerns), coverage_silent=first_cov is None,
221
+ objections=first, surviving=surviving,
222
+ accepted_omissions=accepted, rejected_omissions=rejected, rounds=rounds)
223
+ return move
224
+ except Exception:
225
+ return move # additive: a Twin fault must not lose the turn
gini/agent/mission.py ADDED
@@ -0,0 +1,210 @@
1
+ """The Mission runtime — a per-attempt state machine (pure logic; the UI drives it).
2
+
3
+ One Mission plays one Lesson. It runs the clock and the attempt (life) counter, evaluates the
4
+ lesson's objectives against the live topology (structural objectives resolve instantly in Phase 1;
5
+ behavioral ones stay `pending` until Phase 2's probe harness), detects completion via the lesson's
6
+ `complete_when`, and computes the broad band. It persists (`to_dict`/`from_dict`) so a student can
7
+ close GINI mid-lab and resume.
8
+
9
+ No Qt, no LLM, no wall-clock coupling — the clock is an injected `now()` callable, so time and
10
+ completion are fully unit-testable. GINI's runtime/objective engine is the oracle; this module is
11
+ just the bookkeeping around it.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from dataclasses import dataclass, field
17
+
18
+ from ..domain import objectives as _obj
19
+ from ..domain import scoring as _scoring
20
+ from ..domain.lesson import Lesson
21
+
22
+ # states
23
+ STAGED = "staged" # board pre-built, not yet started
24
+ BRIEFED = "briefed" # brief shown, awaiting start
25
+ PLAYING = "playing" # clock running, live evaluation
26
+ WITNESSED = "witnessed" # an attempt was judged (band computed)
27
+ DONE = "done" # complete, or out of lives/time
28
+
29
+
30
+ @dataclass
31
+ class Mission:
32
+ lesson: Lesson
33
+ now: callable = time.monotonic # injected clock (seconds, monotonic)
34
+ state: str = STAGED
35
+ attempt: int = 0 # lives spent
36
+ started_at: float | None = None
37
+ ended_at: float | None = None
38
+ last_results: list = field(default_factory=list)
39
+ last_band: str = ""
40
+ step_index: int = 0 # guided missions: which beat we're on
41
+
42
+ # -- lifecycle ---------------------------------------------------------- #
43
+ def brief(self) -> None:
44
+ if self.state == STAGED:
45
+ self.state = BRIEFED
46
+
47
+ def start(self) -> None:
48
+ """Begin (or begin the next) attempt: clock runs, a life is spent."""
49
+ if self.state not in (STAGED, BRIEFED, WITNESSED):
50
+ return
51
+ if self.state == WITNESSED and not self.can_retry():
52
+ return
53
+ self.attempt += 1
54
+ self.started_at = self.now()
55
+ self.ended_at = None
56
+ self.step_index = 0 # a (re)start walks the guided beats from the top
57
+ self.state = PLAYING
58
+
59
+ # -- clock -------------------------------------------------------------- #
60
+ def elapsed(self) -> float:
61
+ if self.started_at is None:
62
+ return 0.0
63
+ end = self.ended_at if self.ended_at is not None else self.now()
64
+ return max(0.0, end - self.started_at)
65
+
66
+ def remaining(self) -> float | None:
67
+ """Seconds left, or None if the lesson is untimed."""
68
+ if self.lesson.time_limit_s is None:
69
+ return None
70
+ return self.lesson.time_limit_s - self.elapsed()
71
+
72
+ def expired(self) -> bool:
73
+ r = self.remaining()
74
+ return r is not None and r <= 0
75
+
76
+ def on_time(self) -> bool:
77
+ return not self.expired()
78
+
79
+ # -- evaluation --------------------------------------------------------- #
80
+ def evaluate(self, world, runner=None) -> list:
81
+ """Re-evaluate objectives against the live world (structural = instant; behavioral resolve
82
+ when a `runner`/runtime is supplied, else pending). Auto-witnesses when the mission genuinely
83
+ completes or the clock expires — including from an already-WITNESSED attempt, so a band can
84
+ still be *upgraded* if the student pushes on and finishes."""
85
+ self.last_results = _obj.evaluate_all(self.lesson.objectives, world, runner)
86
+ # difficulty forks are evaluated too, but they NEVER block completion — they only lift the
87
+ # band. Kept in a side channel so the core ladder the panel shows is unchanged.
88
+ self._fork_results = {}
89
+ for fk in getattr(self.lesson, "forks", []) or []:
90
+ objs = fk.get("objectives", [])
91
+ self._fork_results[fk.get("id", "")] = _obj.evaluate_all(objs, world, runner) if objs else []
92
+ if self.state in (PLAYING, WITNESSED) and self._should_finish():
93
+ self._witness()
94
+ return self.last_results
95
+
96
+ def _should_finish(self) -> bool:
97
+ """When to end the attempt: the clock expired, OR — for a GUIDED mission — the student
98
+ finished all the beats, OR — for a free-form mission — the win conditions are met. (A
99
+ guided mission is NOT ended early by objective completion, so read/reflect beats still run.)"""
100
+ if self.expired():
101
+ return True
102
+ if self.guided:
103
+ return self.steps_done
104
+ return self._complete()
105
+
106
+ def check(self, world, runner=None) -> "_scoring.Score":
107
+ """Explicit 'Run/Check' — resolve the behavioral probes against the running system.
108
+
109
+ It must NOT end the attempt on its own. The live checks are REQUIRED to finish, so the
110
+ student presses Run precisely in order to complete — forcing a witness here froze a PARTIAL
111
+ band the moment they ran, and the mission could never be re-judged once it finished
112
+ ("PARTIAL — 9/9"). `evaluate()` witnesses when the mission is genuinely complete or expired."""
113
+ self.evaluate(world, runner)
114
+ return self.score()
115
+
116
+ def _complete(self) -> bool:
117
+ return _scoring.is_complete(self.last_results, self.lesson.complete_when)
118
+
119
+ def _witness(self) -> None:
120
+ self.ended_at = self.now()
121
+ self.last_band = self.score().band
122
+ self.state = WITNESSED
123
+ if self._complete() or not self.can_retry():
124
+ self.state = DONE
125
+
126
+ def score(self) -> "_scoring.Score":
127
+ forks = getattr(self.lesson, "forks", []) or []
128
+ fr = getattr(self, "_fork_results", {})
129
+ # a fork is "done" only when every objective in it is met (an empty fork can't count)
130
+ done = sum(1 for fk in forks
131
+ if fr.get(fk.get("id", "")) and all(r.met for r in fr[fk.get("id", "")]))
132
+ return _scoring.score(self.last_results, complete_when=self.lesson.complete_when,
133
+ on_time=self.on_time(), forks_done=done, forks_total=len(forks))
134
+
135
+ # -- attempts ----------------------------------------------------------- #
136
+ def lives_left(self) -> int:
137
+ return max(0, self.lesson.attempts - self.attempt)
138
+
139
+ def can_retry(self) -> bool:
140
+ return self.lives_left() > 0 and not self._complete()
141
+
142
+ def retry(self) -> None:
143
+ if self.state == WITNESSED and self.can_retry():
144
+ self.start()
145
+
146
+ @property
147
+ def complete(self) -> bool:
148
+ return self._complete()
149
+
150
+ # -- guided steps ------------------------------------------------------- #
151
+ @property
152
+ def guided(self) -> bool:
153
+ return self.lesson.guided
154
+
155
+ def current_step(self):
156
+ steps = self.lesson.steps
157
+ return steps[self.step_index] if 0 <= self.step_index < len(steps) else None
158
+
159
+ @property
160
+ def steps_done(self) -> bool:
161
+ return self.step_index >= len(self.lesson.steps)
162
+
163
+ def step_number(self) -> tuple[int, int]:
164
+ return (min(self.step_index + 1, len(self.lesson.steps)), len(self.lesson.steps))
165
+
166
+ def step_satisfied(self, world, runner=None, replied: bool = False) -> bool:
167
+ """Is the CURRENT step's advance condition met? reply/ack → the student sent a message;
168
+ structural → the canvas satisfies the predicate; behavioral → the probe passes (needs a
169
+ runner/run, else not yet)."""
170
+ step = self.current_step()
171
+ if step is None:
172
+ return False
173
+ kind = step.kind()
174
+ if kind == "reply":
175
+ return replied
176
+ if kind == "structural":
177
+ try:
178
+ return _obj.evaluate_check(step.advance, world)
179
+ except _obj.PredicateError:
180
+ return False
181
+ # behavioral
182
+ if runner is None or not getattr(runner, "available", lambda: False)():
183
+ return False
184
+ from ..domain import probes as _probes
185
+ try:
186
+ return _probes.evaluate(step.advance, runner)
187
+ except _probes.ProbeError:
188
+ return False
189
+
190
+ def advance_step(self):
191
+ """Move to the next beat; returns the new current step (or None when the guided path ends)."""
192
+ if self.step_index < len(self.lesson.steps):
193
+ self.step_index += 1
194
+ return self.current_step()
195
+
196
+ # -- persistence (save/resume) ----------------------------------------- #
197
+ def to_dict(self) -> dict:
198
+ return {"lesson_id": self.lesson.id, "state": self.state, "attempt": self.attempt,
199
+ "started_at": self.started_at, "ended_at": self.ended_at,
200
+ "last_band": self.last_band}
201
+
202
+ @classmethod
203
+ def from_dict(cls, d: dict, lesson: Lesson, now=time.monotonic) -> "Mission":
204
+ m = cls(lesson=lesson, now=now)
205
+ m.state = d.get("state", STAGED)
206
+ m.attempt = int(d.get("attempt", 0))
207
+ m.started_at = d.get("started_at")
208
+ m.ended_at = d.get("ended_at")
209
+ m.last_band = d.get("last_band", "")
210
+ return m
@@ -0,0 +1,208 @@
1
+ """MissionController — the coordinator that runs a live Mission inside GINI.
2
+
3
+ Ties together the pieces built in Phases 1–4: the Mission state machine, the objective engine
4
+ (with a Runner for behavioral probes), the GameMaster reasoning loop, the MissionPanel HUD, and
5
+ the student Profile. It's the "brain" the UI drives.
6
+
7
+ Kept UI-agnostic and fully testable by taking its couplings as callables:
8
+ • get_topology() → the live topology (source of the world); a `get_world` override may
9
+ be passed instead (e.g. a snapshot world) so the game master's
10
+ reasoning can run off the UI thread without touching the live canvas
11
+ • make_runner() → a probes.Runner for behavioral eval on Run/Check (or None)
12
+ • post(role, text) → deliver a game-master line to the chat
13
+ • llm(prompt)->str → the reasoning model (required; Missions are LLM-gated)
14
+ • on_change(cb) → subscribe to canvas changes (returns unsubscribe or None)
15
+ • panel → an optional MissionPanel to refresh (any object with refresh/…)
16
+
17
+ Flow: `start(lesson)` → brief. Canvas changes → live structural refresh + a game-master
18
+ observation (warmer/colder, nudges). `run_check()` → behavioral probes via the runner, witness,
19
+ record to the profile, and a celebrate/defeat line. `ask(text)` → a game-master reply.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from ..domain import objectives as _obj
24
+ from .gamemaster import GameMaster, QUIET
25
+ from .mission import Mission
26
+
27
+
28
+ class MissionController:
29
+ def __init__(self, *, llm, post, get_topology=None, get_world=None, make_runner=None,
30
+ panel=None, profile=None, now=None, gm_factory=None, submit=None) -> None:
31
+ self.submit = submit # submit(lesson_id, mission) -> report to the Teaching Center
32
+ self.get_topology = get_topology
33
+ self._get_world = get_world # optional: a snapshot world (thread-safe reasoning)
34
+ self.llm = llm
35
+ self.post = post
36
+ self.make_runner = make_runner or (lambda: None)
37
+ self.panel = panel
38
+ self.profile = profile
39
+ self._now = now
40
+ self._gm_factory = gm_factory or GameMaster # swappable brain (AgentGameMaster in prod)
41
+ self.mission: Mission | None = None
42
+ self.gm: GameMaster | None = None
43
+ self._recorded = False
44
+
45
+ @property
46
+ def active(self) -> bool:
47
+ return self.mission is not None and self.mission.state != "done"
48
+
49
+ def available(self) -> bool:
50
+ """Missions require a reasoning LLM (hard gate)."""
51
+ return self.llm is not None
52
+
53
+ # -- lifecycle ---------------------------------------------------------- #
54
+ def start(self, lesson) -> bool:
55
+ if not self.available():
56
+ self.post("GINI", "Missions need a local model — enable one in Settings.")
57
+ return False
58
+ self.mission = Mission(lesson) if self._now is None else Mission(lesson, now=self._now)
59
+ self.gm = self._gm_factory(lesson, llm=self.llm)
60
+ if hasattr(self.gm, "bind_world"): # let the game master ground diagnosis (explainer)
61
+ self.gm.bind_world(self._world)
62
+ self._recorded = False
63
+ self.mission.brief()
64
+ self.mission.start()
65
+ if self.panel is not None:
66
+ self.panel.set_mission(self.mission)
67
+ if self.mission.guided:
68
+ # multi-turn: present just the first beat, not the whole wall of objectives
69
+ self._advance_guided(self._world()) # skip any beats already satisfied
70
+ self._present_current_step()
71
+ else:
72
+ self.post("GINI", self.gm.brief_line() or lesson.brief) # free-form: the brief
73
+ self._refresh_live()
74
+ return True
75
+
76
+ # -- guided-beat driving ------------------------------------------------ #
77
+ def _present_current_step(self, acked: str = "") -> None:
78
+ step = self.mission.current_step()
79
+ if step is None:
80
+ return
81
+ idx, total = self.mission.step_number()
82
+ self.post("GINI", self.gm.present_step(step, idx, total, acked=acked))
83
+ if self.panel is not None and hasattr(self.panel, "set_step"):
84
+ self.panel.set_step(step.say, idx, total)
85
+
86
+ def _advance_guided(self, world, *, replied: bool = False, runner=None) -> bool:
87
+ """Advance through every currently-satisfied beat, presenting each one we land on. Returns
88
+ True if we moved. A `replied`/`runner` lets reply/behavioral beats advance."""
89
+ moved = False
90
+ while not self.mission.steps_done and self.mission.step_satisfied(world, runner, replied):
91
+ prev = self.mission.current_step()
92
+ self.mission.advance_step()
93
+ replied = False # a reply advances only the current beat
94
+ moved = True
95
+ if self.mission.steps_done:
96
+ if self.panel is not None and hasattr(self.panel, "clear_step"):
97
+ self.panel.clear_step()
98
+ self.mission.evaluate(world, runner) # guided path done → witness → band
99
+ break
100
+ self._present_current_step(acked=prev.say)
101
+ return moved
102
+
103
+ def _maybe_finish(self) -> bool:
104
+ """If the attempt has been witnessed, deliver the game master's closing line and record it."""
105
+ if self.mission is not None and self.mission.state == "done":
106
+ self._speak(self.gm.decide(self.mission, self.mission.last_results))
107
+ self._finish()
108
+ return True
109
+ return False
110
+
111
+ def _world(self):
112
+ if self._get_world is not None:
113
+ return self._get_world()
114
+ return _obj.TopologyWorld(self.get_topology())
115
+
116
+ # -- canvas changed → live structural eval + a game-master observation --- #
117
+ def on_canvas_changed(self) -> None:
118
+ if not self.active:
119
+ return
120
+ world = self._world()
121
+ results = self.mission.evaluate(world) # structural live (no runner)
122
+ if self.panel is not None:
123
+ self.panel.render_current()
124
+ if self._maybe_finish(): # free-form win / expiry
125
+ return
126
+ if self.mission.guided:
127
+ self._advance_guided(world) # a drop/connect advanced a beat?
128
+ self._maybe_finish() # ...maybe that finished the path
129
+ else:
130
+ self._speak(self.gm.decide(self.mission, results)) # free-form warmer/colder/nudge
131
+
132
+ def _refresh_live(self) -> None:
133
+ if self.mission is None:
134
+ return
135
+ self.mission.evaluate(self._world())
136
+ if self.panel is not None:
137
+ self.panel.render_current()
138
+
139
+ # -- Run/Check → behavioral probes, witness, record --------------------- #
140
+ def run_check(self):
141
+ if self.mission is None:
142
+ return None
143
+ world = self._world()
144
+ runner = self.make_runner()
145
+ # tell the student WHY nothing changed when the live checks can't run (no running stack)
146
+ if self.mission.lesson.behavioral_ids() and (
147
+ runner is None or not getattr(runner, "available", lambda: False)()):
148
+ self.post("GINI", "The live checks (⏳) need the running system — start the topology with "
149
+ "the ▶ Run button on the toolbar first, then press Run / Check again. "
150
+ "Your build already meets the objectives to complete this mission.")
151
+ score = self.mission.check(world, runner)
152
+ if self.panel is not None:
153
+ self.panel.render_current()
154
+ if self.mission.guided and not self.mission.steps_done:
155
+ self._advance_guided(world, runner=runner) # a run advanced a behavioral beat?
156
+ # A DETERMINISTIC summary of the live checks, straight from the probe verdicts — the oracle,
157
+ # not the model, so it can NEVER contradict the objective panel (kills the 'connection test
158
+ # successful' hallucination when a live check actually failed).
159
+ live = [r for r in self.mission.last_results if r.kind == "behavioral"]
160
+ if live:
161
+ mark = {"met": "✓", "unmet": "✗", "pending": "⏳"}
162
+ self.post("GINI", "Live checks — "
163
+ + "; ".join(f"{mark.get(r.status, '?')} {r.say}" for r in live))
164
+ if not self._maybe_finish():
165
+ failed = [r.say for r in live if not r.met]
166
+ if failed and hasattr(self.gm, "run_note"):
167
+ self.post("GINI", self.gm.run_note(failed)) # grounded: names what FAILED, no false wins
168
+ else:
169
+ self._speak(self.gm.decide(self.mission, self.mission.last_results))
170
+ return score
171
+
172
+ # -- a student message during a mission --------------------------------- #
173
+ def ask(self, text: str) -> None:
174
+ if not self.active:
175
+ return
176
+ world = self._world()
177
+ results = self.mission.evaluate(world)
178
+ if self.panel is not None:
179
+ self.panel.render_current()
180
+ step = self.mission.current_step() if self.mission.guided else None
181
+ if step is not None and step.kind() == "reply":
182
+ # this beat wanted the student to reply (read/reflect) → respond + advance
183
+ self.post("GINI", self.gm.react_reply(step, text, results))
184
+ self._advance_guided(world, replied=True)
185
+ self._maybe_finish()
186
+ return
187
+ # otherwise it's a question/hint mid-beat (or free-form) → the game master reasons
188
+ self._speak(self.gm.decide(self.mission, results, utterance=text))
189
+ if self.mission.guided and not self.mission.steps_done:
190
+ self._advance_guided(world) # maybe they described an action
191
+ self._maybe_finish()
192
+
193
+ # -- helpers ------------------------------------------------------------ #
194
+ def _speak(self, move) -> None:
195
+ if move is not None and move.kind != QUIET and move.text:
196
+ self.post("GINI", move.text)
197
+
198
+ def _finish(self) -> None:
199
+ if self._recorded or self.mission is None:
200
+ return
201
+ if self.profile is not None:
202
+ self.profile.record(self.mission.lesson, self.mission)
203
+ self._recorded = True
204
+ if self.submit is not None: # report the result to the Teaching Center
205
+ try:
206
+ self.submit(self.mission.lesson.id, self.mission)
207
+ except Exception: # noqa: BLE001 — never let a sync break the mission
208
+ pass