gini-core 0.1.0__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 (100) hide show
  1. gini/domain/__init__.py +9 -0
  2. gini/domain/aop.py +563 -0
  3. gini/domain/aop_assemble.py +216 -0
  4. gini/domain/aop_patterns.py +331 -0
  5. gini/domain/aop_report.py +201 -0
  6. gini/domain/assembly.py +209 -0
  7. gini/domain/authoring.py +353 -0
  8. gini/domain/blueprints.py +5 -0
  9. gini/domain/capabilities.py +177 -0
  10. gini/domain/catalog.py +85 -0
  11. gini/domain/certify.py +201 -0
  12. gini/domain/compose.py +413 -0
  13. gini/domain/composition.py +88 -0
  14. gini/domain/concepts.py +383 -0
  15. gini/domain/connection_rules.py +278 -0
  16. gini/domain/constraints.py +153 -0
  17. gini/domain/content.py +59 -0
  18. gini/domain/cpu_journey.py +89 -0
  19. gini/domain/devices.py +785 -0
  20. gini/domain/diagnose.py +201 -0
  21. gini/domain/element_guide.py +327 -0
  22. gini/domain/explain.py +90 -0
  23. gini/domain/fingerprint.py +201 -0
  24. gini/domain/firewall.py +34 -0
  25. gini/domain/flowlog.py +61 -0
  26. gini/domain/flows.py +199 -0
  27. gini/domain/flowtable.py +179 -0
  28. gini/domain/fragment_yaml.py +230 -0
  29. gini/domain/fragments.py +169 -0
  30. gini/domain/games/__init__.py +2 -0
  31. gini/domain/games/paging_games.py +119 -0
  32. gini/domain/games/policy_game.py +86 -0
  33. gini/domain/games/process_game.py +48 -0
  34. gini/domain/games/thrash_game.py +75 -0
  35. gini/domain/games/translate_game.py +60 -0
  36. gini/domain/games/trap_game.py +86 -0
  37. gini/domain/grader.py +155 -0
  38. gini/domain/grouping.py +67 -0
  39. gini/domain/kernel_board.py +397 -0
  40. gini/domain/kernel_source.py +145 -0
  41. gini/domain/legality.py +103 -0
  42. gini/domain/lesson.py +241 -0
  43. gini/domain/lexicon.py +150 -0
  44. gini/domain/machine_state.py +478 -0
  45. gini/domain/mcast.py +128 -0
  46. gini/domain/memwatch.py +111 -0
  47. gini/domain/missions/networking/basic-lan.yaml +32 -0
  48. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  49. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  50. gini/domain/missions/networking/drive-load.yaml +20 -0
  51. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  52. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  53. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  54. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  55. gini/domain/missions/networking/least-privilege.yaml +21 -0
  56. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  57. gini/domain/missions/networking/observe-it.yaml +24 -0
  58. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  59. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  60. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  61. gini/domain/missions/networking/send-request.yaml +19 -0
  62. gini/domain/missions/networking/serverless-api.yaml +25 -0
  63. gini/domain/missions/networking/service-chain.yaml +33 -0
  64. gini/domain/missions/os/lottery-fix.yaml +19 -0
  65. gini/domain/missions/os/priority-fix.yaml +24 -0
  66. gini/domain/missions.py +111 -0
  67. gini/domain/modulechain.py +36 -0
  68. gini/domain/narration.py +247 -0
  69. gini/domain/objectives.py +549 -0
  70. gini/domain/os_events.py +251 -0
  71. gini/domain/os_zoo.py +79 -0
  72. gini/domain/paging_sim.py +141 -0
  73. gini/domain/pricing.py +199 -0
  74. gini/domain/probes.py +252 -0
  75. gini/domain/profile.py +142 -0
  76. gini/domain/proof.py +513 -0
  77. gini/domain/proof_events.py +226 -0
  78. gini/domain/qos.py +66 -0
  79. gini/domain/reach_strategy.py +180 -0
  80. gini/domain/recipes.py +738 -0
  81. gini/domain/riders.py +309 -0
  82. gini/domain/router_modules.py +224 -0
  83. gini/domain/router_scripts.py +63 -0
  84. gini/domain/routetable.py +70 -0
  85. gini/domain/routing_model.py +1013 -0
  86. gini/domain/scoring.py +76 -0
  87. gini/domain/staging.py +122 -0
  88. gini/domain/syscall_builder.py +144 -0
  89. gini/domain/ticket.py +134 -0
  90. gini/domain/topic_cloud.py +62 -0
  91. gini/domain/topology.py +247 -0
  92. gini/domain/vocabulary.py +51 -0
  93. gini/domain/xv6.py +973 -0
  94. gini/domain/xv6_fs.py +317 -0
  95. gini/domain/xv6_runner.py +209 -0
  96. gini/domain/xv6_vm.py +433 -0
  97. gini_core-0.1.0.dist-info/METADATA +316 -0
  98. gini_core-0.1.0.dist-info/RECORD +100 -0
  99. gini_core-0.1.0.dist-info/WHEEL +5 -0
  100. gini_core-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ """Pure-Python domain model — no Qt dependency."""
2
+ from . import devices, topology
3
+ from .devices import Category, DeviceType, REGISTRY, all_devices, by_category, get
4
+ from .topology import DeviceInstance, Link, Topology
5
+
6
+ __all__ = [
7
+ "devices", "topology", "Category", "DeviceType", "REGISTRY",
8
+ "all_devices", "by_category", "get", "DeviceInstance", "Link", "Topology",
9
+ ]
gini/domain/aop.py ADDED
@@ -0,0 +1,563 @@
1
+ """Activity Observation Plan — the schema, its hash, and the gate that keeps bad plans out.
2
+
3
+ An AOP is the fixed instrument a cohort is observed against: the teacher describes an activity, a
4
+ model *selects* certified patterns, and a deterministic assembler expands that selection into this
5
+ object. Students never see it. See ACTIVITY_OBSERVATION_PLAN_DESIGN.md for the whole design.
6
+
7
+ This module is the contract every other AOP component rides on, so it is deliberately the dullest:
8
+ dataclasses, a canonical serialization, a hash, and a validator. No Qt, no Docker, no model.
9
+
10
+ **Why the validator is a hard gate rather than a lint.** Plans are machine-generated. A malformed
11
+ probe that reaches a student does not announce itself — it evaluates to "not satisfied" and is read
12
+ as *the student didn't do the work*. Every rule in `validate()` exists to convert a silent
13
+ misattribution into a loud authoring failure, at the Teaching Center, before any code is minted.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import hashlib
19
+ import re
20
+ from dataclasses import dataclass, field, replace
21
+
22
+ from . import objectives as _obj
23
+ from . import probes as _probes
24
+ from .devices import REGISTRY
25
+ from .proof import canonical_json
26
+
27
+ AOP_VERSION = 1
28
+
29
+ # An expectation's place in the stack. Drives evaluation order (cheap and foundational first) and
30
+ # the §8.1 cost split. Deliberately coarse: finer layering buys ordering precision nobody needs.
31
+ LAYERS = ("L2", "L3", "L4", "policy")
32
+
33
+ POSITIVE, NEGATIVE = "positive", "negative"
34
+ SENSES = (POSITIVE, NEGATIVE)
35
+
36
+ # Predicates whose arguments are element TYPE keys (mirrors objectives._TYPE_ARG_FUNCS). These are
37
+ # the ones whose tokens we can check against the palette.
38
+ _TYPE_ARG_FUNCS = frozenset(_obj._TYPE_ARG_FUNCS)
39
+
40
+ # Where slot scoping (`host@lanA`) is NOT yet honoured, and therefore must be refused rather than
41
+ # silently evaluated as false. Two known gaps, both recorded in the design note §5:
42
+ # * probes `balances(...)` / `flow_installed(...)` — their regexes accept `\w+`, so `@` fails
43
+ # to parse at all;
44
+ # * objectives `through(...)` — `through_types()` compares `type_key` raw instead of using the
45
+ # slot-aware matcher its siblings use, so a scoped token matches nothing and returns False.
46
+ # Delete each entry here as the corresponding gap is closed.
47
+ _NO_SLOT_SUPPORT = frozenset({"balances", "flow_installed", "through"})
48
+
49
+ # Predicates whose arguments are device NAMES the student chose (`linked("M1","S1")`). A free-form
50
+ # plan is written before any device exists and cannot know what anything will be called, so naming
51
+ # one is always a mistake — and a silent one, since the lookup simply finds nothing and reports
52
+ # unmet. Refused outright on a blank canvas; allowed when GINI composed the starter topology and
53
+ # therefore chose the names itself.
54
+ _NAME_ARG_FUNCS = frozenset(_obj._PREDICATES) - _TYPE_ARG_FUNCS
55
+
56
+ # Starting points (header param `starting_point`). "blank" is the free-form default: the student
57
+ # draws everything. "composed" means the activity hands out a starter topology GINI assembled.
58
+ BLANK, COMPOSED = "blank", "composed"
59
+
60
+
61
+ def _starting_point(header) -> str:
62
+ return str((header.params or {}).get("starting_point", BLANK) or BLANK)
63
+
64
+ # --- the no-time rule (design §6.1) ---------------------------------------- #
65
+ # v1 has NO temporal expectations: no windows, no settling periods, no orderings in time. This is
66
+ # not a convention to be observed politely — a model asked for "check the routes converge within 30
67
+ # seconds" will cheerfully emit exactly that, so it is refused mechanically.
68
+ #
69
+ # Deliberately conservative. A false rejection costs the author a rephrase; a false acceptance ships
70
+ # an expectation nothing can evaluate. When these fire on something legitimate, the fix is to
71
+ # rewrite the expectation without the time, which is the point.
72
+ _TIME_WORDS = re.compile(
73
+ r"\b(within|converg\w*|sustain\w*|settl\w*|elapsed|deadline|duration|timeout|"
74
+ r"stabili[sz]\w*|eventually|afterwards)\b", re.I)
75
+ _TIME_LITERAL = re.compile(r"\b\d+(?:\.\d+)?\s*(?:ms|s|sec|secs|second|seconds|"
76
+ r"m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\b", re.I)
77
+
78
+
79
+ class AopError(ValueError):
80
+ """An AOP that cannot be accepted. Carries the defects (see `Defect`)."""
81
+
82
+ def __init__(self, defects) -> None:
83
+ self.defects = list(defects)
84
+ super().__init__("; ".join(str(d) for d in self.defects) or "invalid AOP")
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Defect:
89
+ """One reason a plan is rejected, addressed to whoever authored it.
90
+
91
+ `where` is the expectation id (or "" for a header/plan-wide defect) so the Teaching Center can
92
+ point at the offending row rather than printing a wall of text."""
93
+ rule: str
94
+ where: str
95
+ detail: str
96
+
97
+ def __str__(self) -> str:
98
+ at = f" [{self.where}]" if self.where else ""
99
+ return f"{self.rule}{at}: {self.detail}"
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Observation:
104
+ """How often, and on what, behavioural expectations are evaluated.
105
+
106
+ This lives in the *plan*, not in the recorder's configuration, and that placement is the whole
107
+ point: if two students' work were sampled on different schedules, identical work could produce
108
+ different reports — the same bias a fixed instrument exists to remove, reappearing in time.
109
+ Continuous fixed-cadence sampling is also what lets v1 drop temporal expectations entirely: a
110
+ behaviour that takes 40s to settle is simply caught at the next tick.
111
+
112
+ There is deliberately **no verification-strategy field**. An earlier draft carried one, offering
113
+ a choice between inferring reachability from transitivity (cheap, n−1 probes) and probing every
114
+ pair (sound, n(n−1) probes). Measurement dissolved the choice: batching a whole host's probes
115
+ into ONE exec gives complete n(n-1) coverage in n process spawns, so the sound option is also
116
+ the fast one. A setting whose second value should never be chosen is worse than no setting — it
117
+ invites someone to choose it. See `domain.reach_strategy`.
118
+ """
119
+ cadence_s: float = 20.0
120
+ behavioural_on: tuple = ("run_state", "cadence", "check")
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Expectation:
125
+ """One checkable claim about the activity.
126
+
127
+ Exactly one of `probe` / `check` is set. They differ in cost by about six orders of magnitude —
128
+ a `check` walks the in-memory graph, a `probe` shells into a container — which is what the
129
+ evaluation split in §8.1 is built on.
130
+ """
131
+ id: str
132
+ say: str # teacher-facing prose for the report; NEVER shown to a student
133
+ layer: str = "L3"
134
+ sense: str = POSITIVE
135
+ probe: str = "" # behavioural — needs a live lab
136
+ check: str = "" # structural — a predicate over the graph
137
+ requires: tuple = () # ids that must hold first; gives ordering AND the `blocked` verdict
138
+ pattern: str = "" # which certified pattern emitted this
139
+
140
+ @property
141
+ def is_behavioural(self) -> bool:
142
+ return bool(self.probe)
143
+
144
+ def to_dict(self) -> dict:
145
+ return {"id": self.id, "say": self.say, "layer": self.layer, "sense": self.sense,
146
+ "probe": self.probe, "check": self.check,
147
+ "requires": list(self.requires), "pattern": self.pattern}
148
+
149
+ @classmethod
150
+ def from_dict(cls, d: dict) -> "Expectation":
151
+ return cls(id=str(d.get("id", "")), say=str(d.get("say", "")),
152
+ layer=str(d.get("layer", "L3")), sense=str(d.get("sense", POSITIVE)),
153
+ probe=str(d.get("probe") or ""), check=str(d.get("check") or ""),
154
+ requires=tuple(d.get("requires") or ()), pattern=str(d.get("pattern", "")))
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class Header:
159
+ """What the plan is for, and how it is to be applied.
160
+
161
+ `deadline_s` is the ONLY time bound anywhere in the system, and it scopes the whole session
162
+ rather than any expectation (§4). Work past it is marked out-of-window in the report and is
163
+ never discarded or evaluated differently.
164
+ """
165
+ intent: str = ""
166
+ params: dict = field(default_factory=dict)
167
+ patterns: tuple = ()
168
+ answers: tuple = () # ({"q":…, "a":…}, …) from the clarification loop
169
+ created: float = 0.0
170
+ gini_version: str = ""
171
+ observation: Observation = field(default_factory=Observation)
172
+ deadline_s: float | None = None
173
+ guidance: tuple = () # pattern-level public summaries; NEVER expectation text
174
+
175
+ def to_dict(self) -> dict:
176
+ return {"intent": self.intent, "params": dict(self.params),
177
+ "patterns": list(self.patterns), "answers": [dict(a) for a in self.answers],
178
+ "created": self.created, "gini_version": self.gini_version,
179
+ "observation": {"cadence_s": float(self.observation.cadence_s),
180
+ "behavioural_on": list(self.observation.behavioural_on)},
181
+ # Numbers are coerced so an int and a float of the same value cannot hash
182
+ # differently: `plan_hash` is only useful if a plan written here and the same plan
183
+ # read back off disk agree, and JSON renders 3600 and 3600.0 as different text.
184
+ "created": float(self.created),
185
+ "deadline_s": None if self.deadline_s is None else float(self.deadline_s),
186
+ "guidance": list(self.guidance)}
187
+
188
+ @classmethod
189
+ def from_dict(cls, d: dict) -> "Header":
190
+ o = d.get("observation") or {}
191
+ return cls(intent=str(d.get("intent", "")), params=dict(d.get("params") or {}),
192
+ patterns=tuple(d.get("patterns") or ()),
193
+ answers=tuple(dict(a) for a in (d.get("answers") or ())),
194
+ created=float(d.get("created", 0.0) or 0.0),
195
+ gini_version=str(d.get("gini_version", "")),
196
+ observation=Observation(
197
+ cadence_s=float(o.get("cadence_s", 20.0) or 20.0),
198
+ behavioural_on=tuple(o.get("behavioural_on")
199
+ or ("run_state", "cadence", "check"))),
200
+ deadline_s=(None if d.get("deadline_s") in (None, "")
201
+ else float(d["deadline_s"])),
202
+ guidance=tuple(d.get("guidance") or ()))
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class Aop:
207
+ """A frozen Activity Observation Plan."""
208
+ header: Header
209
+ expectations: tuple
210
+ aop_version: int = AOP_VERSION
211
+
212
+ def to_dict(self, *, with_hash: bool = True) -> dict:
213
+ d = {"aop_version": self.aop_version, "header": self.header.to_dict(),
214
+ "expectations": [e.to_dict() for e in self.expectations]}
215
+ if with_hash:
216
+ d["plan_hash"] = plan_hash(self)
217
+ return d
218
+
219
+ @classmethod
220
+ def from_dict(cls, d: dict) -> "Aop":
221
+ return cls(header=Header.from_dict(d.get("header") or {}),
222
+ expectations=tuple(Expectation.from_dict(e)
223
+ for e in (d.get("expectations") or ())),
224
+ aop_version=int(d.get("aop_version", AOP_VERSION)))
225
+
226
+ def by_id(self, eid: str):
227
+ for e in self.expectations:
228
+ if e.id == eid:
229
+ return e
230
+ return None
231
+
232
+
233
+ def plan_hash(aop: Aop) -> str:
234
+ """The plan's identity: sha256 over its canonical form, excluding the hash field itself.
235
+
236
+ A ticket is minted against this, and a proof's genesis records it, so a submission can be
237
+ verified against *the exact plan the student worked to* rather than whatever plan the
238
+ instructor happens to have loaded. Regenerating a plan therefore always means new codes —
239
+ which is the intent, not a limitation.
240
+ """
241
+ return hashlib.sha256(canonical_json(aop.to_dict(with_hash=False)).encode("utf-8")).hexdigest()
242
+
243
+
244
+ # -- token extraction -------------------------------------------------------- #
245
+ def _split_slot(token: str) -> tuple[str, str]:
246
+ """`host@lanA` → ("host", "lanA"). An unscoped token has an empty slot."""
247
+ base, _, slot = str(token).partition("@")
248
+ return base, slot
249
+
250
+
251
+ def probe_tokens(probe: str) -> list[tuple[str, str]]:
252
+ """(token, function) pairs naming element types in a probe string.
253
+
254
+ Returns [] for an unparseable probe — parse failure is reported by its own rule, and one defect
255
+ per problem reads better than a cascade.
256
+ """
257
+ try:
258
+ p = _probes.parse(probe)
259
+ except _probes.ProbeError:
260
+ return []
261
+ fn = p.kind
262
+ if p.kind in (_probes.REACH, _probes.PING, _probes.HTTP):
263
+ return [(p.src, fn), (p.dst, fn)]
264
+ if p.kind == _probes.BALANCES:
265
+ return [(p.src, "balances")]
266
+ if p.kind == _probes.FLOW:
267
+ return [(p.src, "flow_installed")]
268
+ if p.kind == _probes.MEASURE:
269
+ return [(p.src, "measure")]
270
+ return []
271
+
272
+
273
+ def check_tokens(check: str) -> list[tuple[str, str]]:
274
+ """(token, predicate) pairs naming element types in a structural check.
275
+
276
+ Only the type-argument predicates are collected: `linked("M1","S1")` takes device *names* the
277
+ student chose, which a plan cannot know about and must not constrain.
278
+ """
279
+ try:
280
+ tree = _obj.parse_check(check)
281
+ except _obj.PredicateError:
282
+ return []
283
+ out: list[tuple[str, str]] = []
284
+ for node in ast.walk(ast.Expression(body=tree)):
285
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
286
+ continue
287
+ fn = node.func.id
288
+ if fn not in _TYPE_ARG_FUNCS:
289
+ continue
290
+ # Only the LEADING args of some predicates name element types — `property_type` takes a
291
+ # property key and value after its type. `objectives._TYPE_ARITY` is the one place that
292
+ # knows; reading it here keeps the two from drifting apart.
293
+ for arg in node.args[:_obj._TYPE_ARITY.get(fn, len(node.args))]:
294
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
295
+ out.append((arg.value, fn))
296
+ elif isinstance(arg, ast.Name): # bare token, e.g. exists(router)
297
+ out.append((arg.id, fn))
298
+ return out
299
+
300
+
301
+ def _named_predicates(check: str) -> list[str]:
302
+ """Name-taking predicates used in a check, in source order and without duplicates."""
303
+ try:
304
+ tree = _obj.parse_check(check)
305
+ except _obj.PredicateError:
306
+ return []
307
+ out: list[str] = []
308
+ for node in ast.walk(ast.Expression(body=tree)):
309
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
310
+ and node.func.id in _NAME_ARG_FUNCS and node.func.id not in out):
311
+ out.append(node.func.id)
312
+ return out
313
+
314
+
315
+ def _mentions_time(text: str) -> str:
316
+ """The offending fragment when `text` carries a time bound, else ""."""
317
+ m = _TIME_WORDS.search(text or "") or _TIME_LITERAL.search(text or "")
318
+ return m.group(0) if m else ""
319
+
320
+
321
+ # -- validation -------------------------------------------------------------- #
322
+ def validate(aop: Aop, *, known_types=None, observability=None) -> list[Defect]:
323
+ """Every reason this plan must not reach a student. Empty list == acceptable.
324
+
325
+ `known_types` defaults to the palette registry; pass a set to validate against a different
326
+ catalogue (another domain — see design §14). `observability`, when supplied, must expose
327
+ `possible(expectation) -> bool`: the §7.3 precondition oracle answering "could this *ever* be
328
+ observed", which rejects e.g. a tshark expectation on a toolkit that has no tshark. It is
329
+ optional so the schema can ship and be tested before that oracle exists.
330
+ """
331
+ types = set(known_types) if known_types is not None else set(REGISTRY)
332
+ defects: list[Defect] = []
333
+ add = lambda rule, where, detail: defects.append(Defect(rule, where, detail)) # noqa: E731
334
+ free_form = _starting_point(aop.header) == BLANK
335
+
336
+ if aop.aop_version != AOP_VERSION:
337
+ add("version", "", f"plan is version {aop.aop_version}, this build speaks {AOP_VERSION}")
338
+ if not aop.expectations:
339
+ add("empty", "", "a plan with no expectations observes nothing")
340
+
341
+ obs = aop.header.observation
342
+ if obs.cadence_s <= 0:
343
+ add("cadence", "", f"observation cadence must be positive, got {obs.cadence_s}")
344
+ if aop.header.deadline_s is not None and aop.header.deadline_s <= 0:
345
+ add("deadline", "", "a deadline must be positive, or null for no deadline")
346
+
347
+ seen: set[str] = set()
348
+ for e in aop.expectations:
349
+ w = e.id or "<no id>"
350
+
351
+ if not e.id:
352
+ add("id", "", "every expectation needs an id")
353
+ elif e.id in seen:
354
+ add("duplicate-id", w, "two expectations share this id")
355
+ seen.add(e.id)
356
+
357
+ if not e.say.strip():
358
+ add("say", w, "every expectation needs teacher-facing prose")
359
+ if e.layer not in LAYERS:
360
+ add("layer", w, f"unknown layer {e.layer!r}; expected one of {', '.join(LAYERS)}")
361
+ if e.sense not in SENSES:
362
+ add("sense", w, f"unknown sense {e.sense!r}; expected one of {', '.join(SENSES)}")
363
+
364
+ # -- exactly one of probe / check --------------------------------- #
365
+ if bool(e.probe) == bool(e.check):
366
+ add("probe-xor-check", w,
367
+ "set exactly one of probe (behavioural) or check (structural); "
368
+ + ("both are set" if e.probe else "neither is set"))
369
+
370
+ # -- they must parse ------------------------------------------------ #
371
+ if e.probe:
372
+ try:
373
+ _probes.parse(e.probe)
374
+ except _probes.ProbeError as exc:
375
+ add("probe-parse", w, str(exc))
376
+ if e.check:
377
+ try:
378
+ _obj.parse_check(e.check)
379
+ except _obj.PredicateError as exc:
380
+ add("check-parse", w, str(exc))
381
+
382
+ # -- no time bounds anywhere (§6.1) --------------------------------- #
383
+ for fieldname, text in (("probe", e.probe), ("check", e.check), ("say", e.say)):
384
+ hit = _mentions_time(text)
385
+ if hit:
386
+ add("no-temporal", w,
387
+ f"{fieldname} carries a time bound ({hit!r}). v1 has no temporal "
388
+ f"expectations — state what must be true, not when or how fast.")
389
+
390
+ # -- type tokens must exist, and slots must be honoured ------------- #
391
+ for token, fn in (probe_tokens(e.probe) + check_tokens(e.check)):
392
+ base, slot = _split_slot(token)
393
+ if base and base not in types:
394
+ add("unknown-type", w,
395
+ f"{fn}({token}) names {base!r}, which is not an element type")
396
+ if slot and fn in _NO_SLOT_SUPPORT:
397
+ add("slot-unsupported", w,
398
+ f"{fn}() does not resolve slot scoping yet, so {token!r} would silently "
399
+ f"match nothing; drop the @slot or fix {fn}() first")
400
+ elif slot and free_form:
401
+ # Slots are a COMPOSITION artifact: only compose.py ever tags a device, so on a
402
+ # blank canvas every device has slot="" and `slot_match("", "lanA")` is False.
403
+ # A scoped token in a free-form plan therefore matches nothing and reports unmet —
404
+ # blaming the student for the plan's mistake. Say so at authoring time instead.
405
+ add("slot-on-free-form", w,
406
+ f"{token!r} is slot-scoped, but this activity starts from a blank canvas, "
407
+ f"where the student's devices carry no slot and nothing would ever match. "
408
+ f"Use the plain type ({base!r}), with `all` when you mean every one of them.")
409
+
410
+ # -- a free-form plan may not name devices --------------------------- #
411
+ if e.check and free_form:
412
+ for fn in _named_predicates(e.check):
413
+ add("names-on-free-form", w,
414
+ f"{fn}() takes device names the student picked, which this plan cannot know; "
415
+ f"on a blank canvas it silently matches nothing. Use a type-based predicate "
416
+ f"({', '.join(sorted(_TYPE_ARG_FUNCS))}) instead.")
417
+
418
+ # -- could this ever be observed? (§7.3, optional oracle) ----------- #
419
+ if observability is not None and not observability.possible(e):
420
+ add("unobservable", w,
421
+ "no legal construction could ever satisfy this expectation's preconditions")
422
+
423
+ # -- two expectations that can never disagree ---------------------------- #
424
+ # Identical observation + identical sense means one measurement reported twice. In a plan built
425
+ # from several patterns this is easy to introduce by accident and impossible to notice later:
426
+ # the pair always agrees, so it looks like corroboration while being pure noise, and it doubles
427
+ # the `docker exec` cost of a behavioural check. It is also a signal the author meant something
428
+ # temporal ("still reaches, now that delay is on") which v1 cannot express — so saying it twice
429
+ # is the closest they could get, and the honest answer is to say it once.
430
+ by_observation: dict[tuple, str] = {}
431
+ for e in aop.expectations:
432
+ obs = (e.probe or e.check, e.sense)
433
+ if not obs[0]:
434
+ continue
435
+ first = by_observation.setdefault(obs, e.id)
436
+ if first != e.id:
437
+ add("duplicate-observation", e.id or "<no id>",
438
+ f"makes the same observation as {first!r} ({obs[0]}), so the two can never "
439
+ f"disagree; keep one")
440
+
441
+ # -- dependency graph ---------------------------------------------------- #
442
+ ids = {e.id for e in aop.expectations if e.id}
443
+ for e in aop.expectations:
444
+ for dep in e.requires:
445
+ if dep == e.id:
446
+ add("self-dependency", e.id or "<no id>", "an expectation cannot require itself")
447
+ elif dep not in ids:
448
+ add("dangling-requires", e.id or "<no id>",
449
+ f"requires {dep!r}, which is not an expectation in this plan")
450
+ for cycle in _cycles(aop):
451
+ add("cyclic-requires", cycle[0], "dependency cycle: " + " -> ".join(cycle + [cycle[0]]))
452
+
453
+ return defects
454
+
455
+
456
+ def _cycles(aop: Aop) -> list[list[str]]:
457
+ """Every `requires` cycle, reported once each.
458
+
459
+ A cycle is not merely invalid — it would deadlock evaluation ordering, and every expectation in
460
+ it would report `blocked` forever with no root cause to point at. Iterative DFS so a
461
+ pathological generated plan cannot blow the stack.
462
+ """
463
+ graph = {e.id: [d for d in e.requires if d != e.id] for e in aop.expectations if e.id}
464
+ found: list[list[str]] = []
465
+ seen_sets: list[frozenset] = []
466
+ WHITE, GREY, BLACK = 0, 1, 2
467
+ colour = {n: WHITE for n in graph}
468
+
469
+ for root in graph:
470
+ if colour[root] != WHITE:
471
+ continue
472
+ stack = [(root, iter(graph[root]))]
473
+ path = [root]
474
+ colour[root] = GREY
475
+ while stack:
476
+ node, it = stack[-1]
477
+ nxt = next(it, None)
478
+ if nxt is None:
479
+ colour[node] = BLACK
480
+ stack.pop()
481
+ path.pop()
482
+ continue
483
+ if nxt not in graph: # dangling; its own rule reports it
484
+ continue
485
+ if colour[nxt] == GREY: # back-edge → cycle
486
+ cyc = path[path.index(nxt):]
487
+ key = frozenset(cyc)
488
+ if key not in seen_sets:
489
+ seen_sets.append(key)
490
+ found.append(cyc)
491
+ continue
492
+ if colour[nxt] == WHITE:
493
+ colour[nxt] = GREY
494
+ path.append(nxt)
495
+ stack.append((nxt, iter(graph[nxt])))
496
+ return found
497
+
498
+
499
+ def validate_or_raise(aop: Aop, **kw) -> Aop:
500
+ """The plan, or `AopError` carrying every defect. The Teaching Center's gate."""
501
+ defects = validate(aop, **kw)
502
+ if defects:
503
+ raise AopError(defects)
504
+ return aop
505
+
506
+
507
+ # -- ordering ---------------------------------------------------------------- #
508
+ def evaluation_order(aop: Aop) -> list[Expectation]:
509
+ """Expectations in the order they should be evaluated: dependencies first, lowest layer first.
510
+
511
+ Bottom-up ordering is what makes root cause fall out for free. A failed L3 expectation sitting
512
+ on a broken L2 one is not an independent finding, it is a consequence — so its dependency is
513
+ evaluated first and it reports `blocked` rather than adding a second failure the teacher has to
514
+ correlate by hand.
515
+
516
+ A *global* layer preference, not a per-wave one. Ordering wave by wave (everything currently
517
+ unblocked, sorted by layer, then the next wave) looks equivalent and is not: it interleaves
518
+ patterns, so a plan combining two patterns reads as L2, L3, policy, L2, L3 — which is
519
+ unreadable in a report and puts an L3 measurement before the L2 structure it rests on. Kahn's
520
+ algorithm with a layer-keyed priority queue keeps every available low-layer expectation ahead
521
+ of every higher one, subject to dependencies.
522
+
523
+ Cyclic plans never reach this function (`validate` rejects them), but a cycle here degrades to
524
+ "emit the remainder in layer order" rather than looping.
525
+ """
526
+ import heapq
527
+
528
+ rank = {name: i for i, name in enumerate(LAYERS)}
529
+ by_id = {e.id: e for e in aop.expectations if e.id}
530
+ key = lambda e: (rank.get(e.layer, 99), e.id) # noqa: E731
531
+
532
+ # Only dependencies present in the plan can gate anything; a dangling one has its own defect.
533
+ deps = {e.id: {d for d in e.requires if d in by_id and d != e.id} for e in by_id.values()}
534
+ dependents: dict[str, list[str]] = {i: [] for i in by_id}
535
+ for eid, ds in deps.items():
536
+ for d in ds:
537
+ dependents[d].append(eid)
538
+
539
+ outstanding = {eid: len(ds) for eid, ds in deps.items()}
540
+ heap = [(*key(by_id[eid]), eid) for eid, n in outstanding.items() if n == 0]
541
+ heapq.heapify(heap)
542
+
543
+ out: list[Expectation] = []
544
+ while heap:
545
+ *_, eid = heapq.heappop(heap)
546
+ out.append(by_id[eid])
547
+ for child in dependents[eid]:
548
+ outstanding[child] -= 1
549
+ if outstanding[child] == 0:
550
+ heapq.heappush(heap, (*key(by_id[child]), child))
551
+
552
+ if len(out) < len(by_id): # a cycle held some back
553
+ emitted = {e.id for e in out}
554
+ out.extend(sorted((e for e in by_id.values() if e.id not in emitted), key=key))
555
+ # Expectations with no id are invalid but must not silently vanish from a dry run.
556
+ out.extend(e for e in aop.expectations if not e.id)
557
+ return out
558
+
559
+
560
+ def with_expectations(aop: Aop, expectations) -> Aop:
561
+ """A copy carrying different expectations — the assembler's building block. The hash follows
562
+ automatically, since it is computed from content rather than stored."""
563
+ return replace(aop, expectations=tuple(expectations))