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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/domain/lesson.py ADDED
@@ -0,0 +1,241 @@
1
+ """Lesson Pack parsing + validation.
2
+
3
+ A Lesson is the authored (or AI-resolved-then-ratified) unit a Mission plays. In Phase 1 a
4
+ Lesson is read from a dict/YAML with concrete objectives; the `intent` block is carried for the
5
+ game master to reason over (Phase 3). Validation catches authoring mistakes early — a structural
6
+ predicate that doesn't parse, or an element type that doesn't exist — so a broken lesson never
7
+ reaches a student.
8
+
9
+ Pure data + parsing; no Qt. YAML is optional (falls back to a dict) so tests need no file I/O.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ from . import catalog as _catalog
16
+ from .objectives import Objective, check_ok, unknown_element_types
17
+
18
+ _HELP = ("none", "warmer_colder", "full_tutor_logged")
19
+ _PERSONA = ("coach", "challenger")
20
+ _KINDS = ("structural", "behavioral")
21
+
22
+
23
+ @dataclass
24
+ class Intent:
25
+ """Teacher intent — the game master reasons over this; it is NOT a checklist.
26
+
27
+ `notes` is the teacher's free-text nuance ("stress that private doesn't mean unreachable —
28
+ the web tier still gets in"). It travels WITH the mission and is interpreted by the student's
29
+ game master at play time, so the course server never needs an LLM: the teacher writes plain
30
+ language, and the model that reads it is the one already running on the student's machine."""
31
+ concept: str = ""
32
+ goal: str = ""
33
+ spirit: str = ""
34
+ misconceptions: list[str] = field(default_factory=list)
35
+ notes: str = "" # the teacher's nuance, reasoned over by the game master
36
+
37
+
38
+ @dataclass
39
+ class Forbid:
40
+ """A rule the student must NOT trip — a structural predicate that should stay FALSE. When it
41
+ becomes true (e.g. `link(database, cloud)` — the DB wired to the Internet), the offending move
42
+ is flagged (red badge) and the game master explains. `say` is the student-facing reason."""
43
+ say: str
44
+ check: str
45
+
46
+
47
+ @dataclass
48
+ class Step:
49
+ """One beat of a GUIDED mission: an instruction, and how the student advances past it.
50
+ `advance` is one of: 'reply'/'ack' (any student message — a read/reflect beat), a structural
51
+ predicate (drop/connect/configure — advances when the canvas satisfies it), or a behavioral
52
+ probe (run/observe — advances on a Run). The game master presents each beat and reasons about
53
+ what the student did before moving on."""
54
+ say: str
55
+ advance: str = "reply"
56
+ hint: str = ""
57
+
58
+ def kind(self) -> str:
59
+ return step_kind(self.advance)
60
+
61
+
62
+ def step_kind(advance: str) -> str:
63
+ a = (advance or "reply").strip().lower()
64
+ if a in ("reply", "ack", "say", "read", "observe", ""):
65
+ return "reply"
66
+ from . import probes as _probes
67
+ if _probes.probe_ok(advance):
68
+ return "behavioral"
69
+ return "structural"
70
+
71
+
72
+ @dataclass
73
+ class Lesson:
74
+ id: str
75
+ title: str = ""
76
+ brief: str = ""
77
+ objectives: list[Objective] = field(default_factory=list)
78
+ steps: list[Step] = field(default_factory=list) # optional GUIDED beats (empty = free-form)
79
+ forbid: list = field(default_factory=list) # Forbid rules: predicates that must stay FALSE
80
+ complete_when: str = "all" # all | any | at_least(n)
81
+ time_limit_s: int | None = None # None = untimed
82
+ attempts: int = 3
83
+ help: str = "warmer_colder"
84
+ persona: str = "coach"
85
+ stage: str = "" # optional saved-canvas file to pre-build
86
+ intent: Intent = field(default_factory=Intent)
87
+ archetype: str = "" # the Game-Catalog archetype it was resolved from (if any)
88
+ params: dict = field(default_factory=dict)
89
+ genre: str = "" # experience | expedition | challenge (defaults from assembly)
90
+ level: int | None = None # quest level 0..3 (defaults from assembly; may be pinned)
91
+ fragments: list = field(default_factory=list) # fragment ids this lesson was assembled from
92
+ # optional difficulty FORKS (the golden-path model): each is
93
+ # {id, label, difficulty, kind, objectives: [Objective]}. Completing a fork lifts the band.
94
+ forks: list = field(default_factory=list)
95
+
96
+ def behavioral_ids(self) -> list[str]:
97
+ ids = [o.id for o in self.objectives if o.is_behavioral()]
98
+ for fk in self.forks:
99
+ ids += [o.id for o in fk.get("objectives", []) if o.is_behavioral()]
100
+ return ids
101
+
102
+ @property
103
+ def guided(self) -> bool:
104
+ """True when the lesson leads the student through beats turn-by-turn (vs. free-form)."""
105
+ return bool(self.steps)
106
+
107
+
108
+ class LessonError(ValueError):
109
+ pass
110
+
111
+
112
+ # -- time parsing ----------------------------------------------------------- #
113
+ def parse_duration(v) -> int | None:
114
+ """'25m' / '90s' / '1h' / 1500 (seconds) → seconds. None/'' → untimed."""
115
+ if v is None or v == "":
116
+ return None
117
+ if isinstance(v, (int, float)):
118
+ return int(v)
119
+ s = str(v).strip().lower()
120
+ units = {"s": 1, "m": 60, "h": 3600}
121
+ if s and s[-1] in units:
122
+ try:
123
+ return int(float(s[:-1]) * units[s[-1]])
124
+ except ValueError as e:
125
+ raise LessonError(f"bad duration {v!r}") from e
126
+ try:
127
+ return int(s)
128
+ except ValueError as e:
129
+ raise LessonError(f"bad duration {v!r}") from e
130
+
131
+
132
+ def _objective_from(d: dict) -> Objective:
133
+ if "id" not in d:
134
+ raise LessonError("objective missing 'id'")
135
+ kind = d.get("kind", "structural")
136
+ if kind not in _KINDS:
137
+ raise LessonError(f"objective {d['id']}: bad kind {kind!r}")
138
+ return Objective(id=d["id"], say=d.get("say", d["id"]), kind=kind,
139
+ check=d.get("check", ""), probe=d.get("probe", ""), level=d.get("level"))
140
+
141
+
142
+ def from_dict(d: dict) -> Lesson:
143
+ """Build a Lesson from a plain dict (already-parsed YAML)."""
144
+ if "id" not in d:
145
+ raise LessonError("lesson missing 'id'")
146
+ it = d.get("intent", {}) or {}
147
+ intent = Intent(concept=it.get("concept", ""), goal=it.get("goal", ""),
148
+ spirit=it.get("spirit", ""),
149
+ misconceptions=list(it.get("misconceptions", []) or []),
150
+ notes=it.get("notes", "") or d.get("notes", ""))
151
+ objs = [_objective_from(o) for o in (d.get("objectives", []) or [])]
152
+ steps = [Step(say=s.get("say", ""), advance=s.get("advance", "reply"), hint=s.get("hint", ""))
153
+ for s in (d.get("steps", []) or [])]
154
+ forbid = [Forbid(say=f.get("say", ""), check=f.get("check", ""))
155
+ for f in (d.get("forbid", []) or [])]
156
+ return Lesson(
157
+ id=d["id"], title=d.get("title", ""), brief=d.get("brief", ""),
158
+ objectives=objs, steps=steps, forbid=forbid, complete_when=d.get("complete_when", "all"),
159
+ time_limit_s=parse_duration(d.get("time_limit")),
160
+ attempts=int(d.get("attempts", 3)), help=d.get("help", "warmer_colder"),
161
+ persona=d.get("persona", "coach"), stage=d.get("stage", ""), intent=intent,
162
+ archetype=d.get("archetype", ""), params=dict(d.get("params", {}) or {}),
163
+ genre=d.get("genre", ""), level=d.get("level"),
164
+ fragments=list(d.get("fragments", []) or []),
165
+ )
166
+
167
+
168
+ def from_yaml(text: str) -> Lesson:
169
+ import yaml # PyYAML ships with the app; imported lazily
170
+ return from_dict(yaml.safe_load(text))
171
+
172
+
173
+ def from_archetype(archetype_id: str, params: dict, *, id: str, title: str = "",
174
+ brief: str = "", **over) -> Lesson:
175
+ """Build a concrete Lesson by instantiating a Game-Catalog archetype (the shortcut the
176
+ resolver will use; handy for explicit authoring + tests)."""
177
+ arch = _catalog.get(archetype_id)
178
+ if arch is None:
179
+ raise LessonError(f"unknown archetype {archetype_id!r}")
180
+ missing = _catalog.unbound_refs(arch, params)
181
+ if missing:
182
+ raise LessonError(f"archetype {archetype_id!r} missing params: {missing}")
183
+ objs = _catalog.instantiate(arch, params)
184
+ intent = Intent(concept=arch.teaches, goal=arch.summary, spirit=arch.spirit,
185
+ misconceptions=list(arch.misconceptions))
186
+ return Lesson(id=id, title=title or arch.summary, brief=brief or arch.summary,
187
+ objectives=objs, complete_when=over.get("complete_when", arch.complete_when),
188
+ time_limit_s=parse_duration(over.get("time_limit")),
189
+ attempts=int(over.get("attempts", 3)), help=over.get("help", "warmer_colder"),
190
+ persona=over.get("persona", "coach"),
191
+ stage=over.get("stage") or getattr(arch, "stage", "") or "",
192
+ intent=intent, archetype=archetype_id, params=dict(params))
193
+
194
+
195
+ # -- validation ------------------------------------------------------------- #
196
+ def validate(lesson: Lesson) -> list[str]:
197
+ """Return a list of problems (empty = valid). Catches bad predicates, unknown element
198
+ types, empty objectives, and bad enum values — the authoring safety net."""
199
+ problems: list[str] = []
200
+ if not lesson.objectives:
201
+ problems.append("lesson has no objectives")
202
+ if lesson.help not in _HELP:
203
+ problems.append(f"bad help level {lesson.help!r}")
204
+ if lesson.persona not in _PERSONA:
205
+ problems.append(f"bad persona {lesson.persona!r}")
206
+ cw = lesson.complete_when
207
+ if cw not in ("all", "any") and not cw.startswith("at_least("):
208
+ problems.append(f"bad complete_when {cw!r}")
209
+ if lesson.attempts < 1:
210
+ problems.append("attempts must be >= 1")
211
+ seen = set()
212
+ for o in lesson.objectives:
213
+ if o.id in seen:
214
+ problems.append(f"duplicate objective id {o.id!r}")
215
+ seen.add(o.id)
216
+ if o.kind == "structural":
217
+ if not o.check:
218
+ problems.append(f"structural objective {o.id!r} has no check")
219
+ elif not check_ok(o.check):
220
+ problems.append(f"objective {o.id!r}: check does not parse: {o.check!r}")
221
+ else:
222
+ bad = unknown_element_types(o.check)
223
+ if bad:
224
+ problems.append(f"objective {o.id!r}: unknown element types {bad}")
225
+ elif o.kind == "behavioral" and not o.probe:
226
+ problems.append(f"behavioral objective {o.id!r} has no probe")
227
+ for i, s in enumerate(lesson.steps):
228
+ if not s.say:
229
+ problems.append(f"step {i + 1} has no instruction ('say')")
230
+ k = s.kind()
231
+ if k == "structural" and not check_ok(s.advance):
232
+ problems.append(f"step {i + 1}: advance predicate does not parse: {s.advance!r}")
233
+ elif k == "structural":
234
+ bad = unknown_element_types(s.advance)
235
+ if bad:
236
+ problems.append(f"step {i + 1}: unknown element types {bad}")
237
+ return problems
238
+
239
+
240
+ def is_valid(lesson: Lesson) -> bool:
241
+ return not validate(lesson)
gini/domain/lexicon.py ADDED
@@ -0,0 +1,150 @@
1
+ """Lexical normalization for GINI retrieval — the dependency-free layer that closes most of
2
+ the recall gap before we ever reach embeddings.
3
+
4
+ Three cheap transforms turn a student's phrasing into the GINI vocabulary the (lexical)
5
+ retriever indexes on:
6
+
7
+ 1. tokenize — split to lowercase word tokens, drop query-noise ("explain", "what", …);
8
+ 2. stem — a tiny suffix stemmer so "routing"/"routes" collapse to one form;
9
+ 3. synonyms — a small, HAND-OWNED map from student words / real-world product names to
10
+ GINI's canonical terms (e.g. "vm" -> instance, "software defined" -> sdn).
11
+
12
+ Pure data + functions, no dependencies. The synonym map is deliberately tiny and lives here
13
+ beside the concepts it feeds; a test asserts every mapped target is a real concept keyword,
14
+ so the map can't rot silently.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ # words that carry no topic signal in a question — stripped from the QUERY side so a short
21
+ # query like "explain SDN" reduces to its content term {sdn}.
22
+ _QUERY_STOP = frozenset((
23
+ "a", "an", "the", "is", "are", "am", "of", "to", "in", "on", "for", "and", "or", "with",
24
+ "how", "what", "whats", "why", "when", "where", "which", "who", "do", "does", "did", "can",
25
+ "could", "would", "should", "i", "we", "you", "my", "me", "us", "explain", "tell", "about",
26
+ "show", "give", "describe", "please", "help", "want", "need", "use", "using", "work",
27
+ "works", "working", "it", "this", "that", "these", "those", "be", "get", "make", "set",
28
+ "up", "into", "from", "at", "as", "if", "so", "then", "than", "vs", "versus", "between",
29
+ ))
30
+
31
+ # single-token synonyms → one or more canonical GINI terms (already in the KB's vocabulary).
32
+ _SYNONYMS: dict[str, tuple[str, ...]] = {
33
+ "vm": ("instance",), "vms": ("instance",), "virtualmachine": ("instance",),
34
+ "lambda": ("function", "serverless"), "faas": ("function", "serverless"),
35
+ "serverlessfunction": ("function",),
36
+ "bucket": ("object", "storage"), "buckets": ("object", "storage"), "s3": ("object", "storage"),
37
+ "blob": ("object", "storage"),
38
+ "bus": ("queue", "messaging"), "broker": ("queue", "messaging"),
39
+ "kafka": ("stream",), "rabbitmq": ("queue",), "nats": ("messaging",),
40
+ "waf": ("firewall",), "iptables": ("firewall",),
41
+ "segmentation": ("security", "group", "isolation"),
42
+ "microsegmentation": ("security", "group", "isolation"),
43
+ "k8s": ("kubernetes",), "k3s": ("kubernetes",), "hpa": ("autoscaling", "pod"),
44
+ "db": ("database",), "rdbms": ("database",), "postgres": ("database",), "postgresql": ("database",),
45
+ "mongo": ("nosql",), "mongodb": ("nosql",), "redis": ("cache",), "memcached": ("cache",),
46
+ "lb": ("load", "balancer"), "nginx": ("load", "balancer"), "haproxy": ("load", "balancer"),
47
+ "traefik": ("proxy", "gateway"), "envoy": ("proxy",),
48
+ "prometheus": ("metrics",), "grafana": ("dashboard",), "jaeger": ("tracing",),
49
+ "microvm": ("kata", "isolation"), "hypervisor": ("kata", "isolation"),
50
+ "os": ("kernel", "process"), "syscall": ("system", "call"),
51
+ "vpn": ("gateway", "network"), "nat": ("internet", "gateway"),
52
+ "middlebox": ("vnf", "nfv"), "appliance": ("vnf", "nfv"),
53
+ "l2": ("switch", "layer"), "l3": ("router", "layer"),
54
+ "wifi": ("wap", "wireless"), "wlan": ("wap", "wireless"),
55
+ }
56
+
57
+ # multi-word phrases → canonical terms, matched on the raw (lowercased) query before tokenizing.
58
+ # This is where "software defined" -> sdn lives (the exact case that exposed lexical brittleness).
59
+ _PHRASES: dict[str, tuple[str, ...]] = {
60
+ "software defined": ("sdn", "openflow"),
61
+ "software-defined": ("sdn", "openflow"),
62
+ "control plane": ("sdn", "control"),
63
+ "data plane": ("sdn", "data"),
64
+ "message bus": ("queue", "messaging"),
65
+ "message queue": ("queue",),
66
+ "event log": ("stream",),
67
+ "object storage": ("object", "storage"),
68
+ "block storage": ("block", "volume"),
69
+ "virtual machine": ("instance", "vm"),
70
+ "load balancer": ("load", "balancer"),
71
+ "load balancing": ("load", "balancer"),
72
+ "reverse proxy": ("proxy",),
73
+ "service mesh": ("proxy", "sidecar"),
74
+ "security group": ("security", "group"),
75
+ "network function": ("vnf", "nfv"),
76
+ "service chain": ("sfc", "chain"),
77
+ "service function chain": ("sfc", "chain"),
78
+ "access point": ("wap", "wireless"),
79
+ "context switch": ("scheduler", "context"),
80
+ "page table": ("paging", "memory"),
81
+ "virtual memory": ("paging", "memory"),
82
+ "file system": ("filesystem", "inode"),
83
+ "operating system": ("kernel", "process"),
84
+ "system call": ("syscall", "kernel"),
85
+ }
86
+
87
+ _WORD = re.compile(r"[a-z0-9][a-z0-9+]*")
88
+
89
+
90
+ def _stem(word: str) -> str:
91
+ """A deliberately tiny suffix stemmer — enough to fold common inflections without a
92
+ dictionary. Order matters (longest suffix first); never stem below 3 chars."""
93
+ w = word
94
+ for suf in ("ization", "isation", "ing", "ies", "ied", "ion", "ers", "er", "ed", "es", "s"):
95
+ if w.endswith(suf) and len(w) - len(suf) >= 3:
96
+ base = w[: -len(suf)]
97
+ if suf == "ies":
98
+ base += "y"
99
+ return base
100
+ return w
101
+
102
+
103
+ def tokenize(text: str, *, drop_stop: bool = False) -> list[str]:
104
+ """Lowercase word tokens. With `drop_stop`, remove query-noise words (used on the QUERY
105
+ side; documents keep all their words)."""
106
+ toks = _WORD.findall((text or "").lower())
107
+ if drop_stop:
108
+ toks = [t for t in toks if t not in _QUERY_STOP]
109
+ return toks
110
+
111
+
112
+ def _expand_phrases(low: str) -> list[str]:
113
+ extra: list[str] = []
114
+ for phrase, terms in _PHRASES.items():
115
+ if phrase in low:
116
+ extra.extend(terms)
117
+ return extra
118
+
119
+
120
+ def normalize(text: str, *, query: bool = False) -> list[str]:
121
+ """Normalized, de-duplicated token list for scoring: phrase expansion + tokenization +
122
+ stemming + single-token synonym expansion. On the query side, noise words are dropped."""
123
+ low = (text or "").lower()
124
+ raw = tokenize(low, drop_stop=query)
125
+ out: list[str] = []
126
+ seen: set[str] = set()
127
+
128
+ def add(tok: str) -> None:
129
+ s = _stem(tok)
130
+ if s and s not in seen:
131
+ seen.add(s)
132
+ out.append(s)
133
+
134
+ for tok in _expand_phrases(low): # multi-word phrase hits first
135
+ add(tok)
136
+ for tok in raw:
137
+ add(tok)
138
+ for syn in _SYNONYMS.get(tok, ()): # single-token synonyms
139
+ add(syn)
140
+ return out
141
+
142
+
143
+ def synonym_targets() -> set[str]:
144
+ """Every canonical term the synonym/phrase maps point at (for the sync test)."""
145
+ out: set[str] = set()
146
+ for terms in _SYNONYMS.values():
147
+ out.update(terms)
148
+ for terms in _PHRASES.values():
149
+ out.update(terms)
150
+ return out