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.
- gini/domain/__init__.py +9 -0
- gini/domain/aop.py +563 -0
- gini/domain/aop_assemble.py +216 -0
- gini/domain/aop_patterns.py +331 -0
- gini/domain/aop_report.py +201 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +278 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +785 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flows.py +199 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/kernel_board.py +397 -0
- gini/domain/kernel_source.py +145 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +478 -0
- gini/domain/mcast.py +128 -0
- gini/domain/memwatch.py +111 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/narration.py +247 -0
- gini/domain/objectives.py +549 -0
- gini/domain/os_events.py +251 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +252 -0
- gini/domain/profile.py +142 -0
- gini/domain/proof.py +513 -0
- gini/domain/proof_events.py +226 -0
- gini/domain/qos.py +66 -0
- gini/domain/reach_strategy.py +180 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/router_scripts.py +63 -0
- gini/domain/routetable.py +70 -0
- gini/domain/routing_model.py +1013 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/ticket.py +134 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +247 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +973 -0
- gini/domain/xv6_fs.py +317 -0
- gini/domain/xv6_runner.py +209 -0
- gini/domain/xv6_vm.py +433 -0
- gini_core-0.1.0.dist-info/METADATA +316 -0
- gini_core-0.1.0.dist-info/RECORD +100 -0
- gini_core-0.1.0.dist-info/WHEEL +5 -0
- gini_core-0.1.0.dist-info/top_level.txt +1 -0
gini/domain/__init__.py
ADDED
|
@@ -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))
|