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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -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 +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -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/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/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -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/objectives.py +488 -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 +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/domain/compose.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Slot-aware composition — the deterministic assembler that scales primitives into larger topologies.
|
|
2
|
+
|
|
3
|
+
This is Phase 3: given a dependent fragment (e.g. a hub router) whose slots are *cardinal* (a slot
|
|
4
|
+
that binds to N distinct providers, `min..max`), plus a BINDING that says which certified provider
|
|
5
|
+
fills each slot, the assembler MATERIALIZES one concrete topology and the EXPANDED objective set that
|
|
6
|
+
grades it — then the oracle runs it. No LLM here: composition is a derivation, graded by the runtime.
|
|
7
|
+
|
|
8
|
+
How it scales without new grammar:
|
|
9
|
+
* each cardinal slot expands to N distinct LABELS (`lans0, lans1, …`);
|
|
10
|
+
* a provider is materialized per label from its faithful certified board (its saved `stage`), and
|
|
11
|
+
every device it contributes is tagged with that label — so `switch@lans2` means "the switch of
|
|
12
|
+
LAN #2", reusing the exact `type@slot` oracle Phase 1 already proved;
|
|
13
|
+
* the dependent fragment's own delta (the router) is placed once, unlabelled;
|
|
14
|
+
* a `@<cardinal>` predicate on the dependent fragment expands to one objective PER label
|
|
15
|
+
(forall — e.g. the router wires to every LAN), and a same-slot behavioral `reach(host@s -> host@s)`
|
|
16
|
+
expands to cross-member pairs (forall-pairs — every LAN reaches every other).
|
|
17
|
+
|
|
18
|
+
A binding:
|
|
19
|
+
{"fragment": "hub-router", "bind": {"lans": ["cap-lan", "cap-lan", "cap-lan", "cap-lan"]}}
|
|
20
|
+
A provider entry may itself be a nested binding `{"fragment": ..., "bind": {...}}` for recursion.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import ast
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from . import devices as _devices
|
|
28
|
+
from . import fragments as _frag
|
|
29
|
+
from .objectives import Objective, _TYPE_ARG_FUNCS
|
|
30
|
+
from .topology import Topology
|
|
31
|
+
|
|
32
|
+
_SLOT_RE = re.compile(r"@(\w+)")
|
|
33
|
+
MAX_DEPTH = 6 # recursion budget — a composition can't nest forever
|
|
34
|
+
ROOT = "root" # label of the top fragment's own delta (never a member prefix)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CompositionError(ValueError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---- predicate rewriting (structural checks are python-parseable) ---------- #
|
|
42
|
+
def _suffix_plain(expr: str, label: str) -> str:
|
|
43
|
+
"""Scope every PLAIN type-arg to `@label` (a bare `host` becomes `host@label`). Args already
|
|
44
|
+
slot-scoped are left alone. Used when a provider's own objectives are placed under a label."""
|
|
45
|
+
if not expr or not label:
|
|
46
|
+
return expr
|
|
47
|
+
tree = ast.parse(expr, mode="eval")
|
|
48
|
+
|
|
49
|
+
class T(ast.NodeTransformer):
|
|
50
|
+
def visit_Call(self, node): # noqa: N802
|
|
51
|
+
self.generic_visit(node)
|
|
52
|
+
if isinstance(node.func, ast.Name) and node.func.id in _TYPE_ARG_FUNCS:
|
|
53
|
+
node.args = [
|
|
54
|
+
ast.BinOp(a, ast.MatMult(), ast.Name(label, ast.Load()))
|
|
55
|
+
if isinstance(a, ast.Name) else a
|
|
56
|
+
for a in node.args]
|
|
57
|
+
return node
|
|
58
|
+
|
|
59
|
+
return ast.unparse(T().visit(tree).body)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _rebind(expr: str, slot: str, label: str) -> str:
|
|
63
|
+
"""Rebind a specific cardinal reference: `switch@lans` -> `switch@lans2`."""
|
|
64
|
+
if not expr:
|
|
65
|
+
return expr
|
|
66
|
+
tree = ast.parse(expr, mode="eval")
|
|
67
|
+
|
|
68
|
+
class T(ast.NodeTransformer):
|
|
69
|
+
def visit_BinOp(self, node): # noqa: N802
|
|
70
|
+
self.generic_visit(node)
|
|
71
|
+
if (isinstance(node.op, ast.MatMult) and isinstance(node.right, ast.Name)
|
|
72
|
+
and node.right.id == slot):
|
|
73
|
+
node.right = ast.Name(label, ast.Load())
|
|
74
|
+
return node
|
|
75
|
+
|
|
76
|
+
return ast.unparse(T().visit(tree).body)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _slots_in(text: str) -> set[str]:
|
|
80
|
+
return set(_SLOT_RE.findall(text or ""))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _placements(check: str) -> list[tuple[str, int]]:
|
|
84
|
+
"""(type, count) for a PLAIN `exists`/`count` placement — for building the delta's devices.
|
|
85
|
+
Objectives are single predicates, so `count(T) >= n` yields n and `exists(T)` yields 1."""
|
|
86
|
+
try:
|
|
87
|
+
top = ast.parse(check or "", mode="eval").body
|
|
88
|
+
except SyntaxError:
|
|
89
|
+
return []
|
|
90
|
+
if (isinstance(top, ast.Compare) and isinstance(top.left, ast.Call)
|
|
91
|
+
and isinstance(top.left.func, ast.Name) and top.left.func.id == "count"
|
|
92
|
+
and top.left.args and isinstance(top.left.args[0], ast.Name)
|
|
93
|
+
and isinstance(top.comparators[0], ast.Constant)):
|
|
94
|
+
return [(top.left.args[0].id, int(top.comparators[0].value))]
|
|
95
|
+
out: list[tuple[str, int]] = []
|
|
96
|
+
for node in ast.walk(top):
|
|
97
|
+
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
|
98
|
+
and node.func.id == "exists" and node.args and isinstance(node.args[0], ast.Name)):
|
|
99
|
+
out.append((node.args[0].id, 1))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---- materialization ------------------------------------------------------- #
|
|
104
|
+
def _is_rider(type_key: str) -> bool:
|
|
105
|
+
dt = _devices.get(type_key)
|
|
106
|
+
return bool(dt and getattr(dt, "rider", False))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _instantiate_stage(topo: Topology, stage: dict, label: str, source: str = "") -> None:
|
|
110
|
+
"""Rebuild a provider's saved board into `topo`, tagging every (non-rider) device with `label`.
|
|
111
|
+
Reproduces the exact certified structure (all hosts wired to the switch, etc.).
|
|
112
|
+
|
|
113
|
+
`source` records WHICH fragment was instantiated here — provenance only (no predicate reads it),
|
|
114
|
+
so a composed board can say "these came from cap-lan" rather than leaving the reader to guess."""
|
|
115
|
+
idmap: dict[str, str] = {}
|
|
116
|
+
for d in stage.get("devices", []) or []:
|
|
117
|
+
if _is_rider(d.get("type_key", "")):
|
|
118
|
+
continue # ports, not structure
|
|
119
|
+
inst = topo.add_device(d["type_key"])
|
|
120
|
+
inst.slot = label
|
|
121
|
+
inst.slot_source = source
|
|
122
|
+
idmap[d["id"]] = inst.id
|
|
123
|
+
for l in stage.get("links", []) or []:
|
|
124
|
+
if l.get("kind") == "attach":
|
|
125
|
+
continue
|
|
126
|
+
s, t = idmap.get(l.get("source_id")), idmap.get(l.get("target_id"))
|
|
127
|
+
if s and t:
|
|
128
|
+
topo.add_link(s, t)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _link_exists(topo: Topology, a_id: str, b_id: str) -> bool:
|
|
132
|
+
return any({l.source_id, l.target_id} == {a_id, b_id} for l in topo.links.values())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _connect_via(F) -> str:
|
|
136
|
+
"""The device type external consumers link to — a provider's connection point. Explicit
|
|
137
|
+
`connect_via` wins; else inferred: a composite's is the source of its own slot-links (its
|
|
138
|
+
gateway, e.g. `router`); a leaf's is the structural hub of its board (first non-host device,
|
|
139
|
+
e.g. `switch`). This is what lets a `network` slot bind a LAN (→switch) OR a routed net (→router)."""
|
|
140
|
+
cv = getattr(F, "connect_via", "")
|
|
141
|
+
if cv:
|
|
142
|
+
return cv
|
|
143
|
+
for t in F.objectives: # composite → its gateway
|
|
144
|
+
m = re.match(r"^\s*link\((\w+)\s*,\s*\w+@\w+\)\s*$", t.check or "")
|
|
145
|
+
if m:
|
|
146
|
+
return m.group(1)
|
|
147
|
+
for d in (getattr(F, "stage", None) or {}).get("devices", []) or []: # leaf → structural hub
|
|
148
|
+
tk = d.get("type_key", "")
|
|
149
|
+
if tk and tk != "host" and not _is_rider(tk):
|
|
150
|
+
return tk
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _conn_point(topo: Topology, type_key: str, member_path: str):
|
|
155
|
+
"""A member's own top device of `type_key` — the shallowest one under the member's path (its
|
|
156
|
+
delta / hub), never a device buried deeper in a nested sub-composition."""
|
|
157
|
+
from .objectives import slot_match
|
|
158
|
+
cands = [d for d in topo.devices.values()
|
|
159
|
+
if d.type_key == type_key and slot_match(d.slot, member_path)]
|
|
160
|
+
return min(cands, key=lambda d: d.slot.count("_"), default=None)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build(binding: dict, topo: Topology, out: list[Objective], path: str,
|
|
164
|
+
is_provider: bool, depth: int = 0) -> None:
|
|
165
|
+
if depth > MAX_DEPTH:
|
|
166
|
+
raise CompositionError(f"composition nests deeper than {MAX_DEPTH} levels")
|
|
167
|
+
F = _frag.get(binding.get("fragment", ""))
|
|
168
|
+
if F is None:
|
|
169
|
+
raise CompositionError(f"unknown fragment: {binding.get('fragment')!r}")
|
|
170
|
+
|
|
171
|
+
# A LEAF provider (a certified board, no slots) is reproduced faithfully, every device tagged
|
|
172
|
+
# with this member's path.
|
|
173
|
+
if is_provider and getattr(F, "stage", None) and F.stage.get("devices") and not F.slots:
|
|
174
|
+
_instantiate_stage(topo, F.stage, path, source=F.id)
|
|
175
|
+
for t in F.objectives:
|
|
176
|
+
# A provider's OWN live/output checks (reach, measure on its riders) are proven by its
|
|
177
|
+
# certificate — we trust it, we don't re-run them here. (The composer strips riders, so a
|
|
178
|
+
# `measure(packet_view…)` couldn't pass anyway.) Only its STRUCTURAL shape is re-checked.
|
|
179
|
+
if t.kind == "behavioral":
|
|
180
|
+
continue
|
|
181
|
+
out.append(Objective(id=f"{path}-{t.id}", say=t.say, kind=t.kind,
|
|
182
|
+
check=_suffix_plain(t.check, path), probe="",
|
|
183
|
+
level=t.level, stars=t.stars))
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Otherwise DERIVE (the top fragment, or a composite provider). Its own delta is tagged
|
|
187
|
+
# `path_self` — a leaf segment no member shares, so a `type@path_self` scope never bleeds into
|
|
188
|
+
# the sub-networks it contains (crucial when delta and members share a type, e.g. router↔router).
|
|
189
|
+
own = f"{path}_self"
|
|
190
|
+
delta: dict[str, list[str]] = {}
|
|
191
|
+
for t in F.objectives:
|
|
192
|
+
if _slots_in(t.check) or _slots_in(t.probe):
|
|
193
|
+
continue
|
|
194
|
+
for typ, n in _placements(t.check):
|
|
195
|
+
for _ in range(n):
|
|
196
|
+
d = topo.add_device(typ)
|
|
197
|
+
d.slot = own
|
|
198
|
+
delta.setdefault(typ, []).append(d.id)
|
|
199
|
+
|
|
200
|
+
slotmap: dict[str, list[tuple[str, str]]] = {} # slot -> [(member_path, connect_via_type)]
|
|
201
|
+
for S in F.slots:
|
|
202
|
+
provs = (binding.get("bind") or {}).get(S.name) or []
|
|
203
|
+
if len(provs) < S.min or (S.max and S.max > 0 and len(provs) > S.max):
|
|
204
|
+
raise CompositionError(
|
|
205
|
+
f"slot {S.name!r} takes {S.min}..{S.max or '∞'} providers, got {len(provs)}")
|
|
206
|
+
members: list[tuple[str, str]] = []
|
|
207
|
+
for i, pv in enumerate(provs):
|
|
208
|
+
mp = f"{path}_{S.name}{i}"
|
|
209
|
+
child = pv if isinstance(pv, dict) else {"fragment": pv}
|
|
210
|
+
_build(child, topo, out, mp, is_provider=True, depth=depth + 1)
|
|
211
|
+
members.append((mp, _connect_via(_frag.get(child["fragment"]))))
|
|
212
|
+
slotmap[S.name] = members
|
|
213
|
+
|
|
214
|
+
# LATERAL peer groups (Phase 5): members interconnect as peers per a topology (mesh/ring/…),
|
|
215
|
+
# rather than each linking back to the delta. Meshes/graphs of routers live here.
|
|
216
|
+
for PG in getattr(F, "peerings", ()):
|
|
217
|
+
provs = (binding.get("peer") or {}).get(PG.name) or []
|
|
218
|
+
if len(provs) < PG.min or (PG.max and PG.max > 0 and len(provs) > PG.max):
|
|
219
|
+
raise CompositionError(
|
|
220
|
+
f"peering {PG.name!r} takes {PG.min}..{PG.max or '∞'} peers, got {len(provs)}")
|
|
221
|
+
members = []
|
|
222
|
+
for i, pv in enumerate(provs):
|
|
223
|
+
mp = f"{path}_{PG.name}{i}"
|
|
224
|
+
child = pv if isinstance(pv, dict) else {"fragment": pv}
|
|
225
|
+
_build(child, topo, out, mp, is_provider=True, depth=depth + 1)
|
|
226
|
+
members.append((mp, _connect_via(_frag.get(child["fragment"]))))
|
|
227
|
+
_wire_peering(topo, out, members, PG.topology, PG.name)
|
|
228
|
+
slotmap[PG.name] = members # @name reach objectives expand all-pairs
|
|
229
|
+
|
|
230
|
+
for t in F.objectives:
|
|
231
|
+
refs = _slots_in(t.check) | _slots_in(t.probe)
|
|
232
|
+
if is_provider and t.kind == "behavioral":
|
|
233
|
+
continue # a bound provider's live checks: trusted
|
|
234
|
+
if not (refs & set(slotmap)):
|
|
235
|
+
out.append(Objective(id=f"{path}-{t.id}", say=t.say, kind=t.kind,
|
|
236
|
+
check=_suffix_plain(t.check, own),
|
|
237
|
+
probe=_suffix_plain(t.probe, own), level=t.level, stars=t.stars))
|
|
238
|
+
_wire_plain_link(topo, t.check, delta, own)
|
|
239
|
+
continue
|
|
240
|
+
slot = next(iter(refs & set(slotmap)))
|
|
241
|
+
if t.kind == "behavioral":
|
|
242
|
+
out.extend(_expand_behavioral(t, slot, [mp for mp, _ in slotmap[slot]]))
|
|
243
|
+
else:
|
|
244
|
+
out.extend(_expand_structural(t, slot, slotmap[slot], topo, delta, own))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _wire_plain_link(topo: Topology, check: str, delta: dict, own: str) -> None:
|
|
248
|
+
"""`link(a, b)` within a node's own delta → wire the a's to the first b (star for repeats)."""
|
|
249
|
+
m = re.match(r"^\s*link\((\w+)\s*,\s*(\w+)\)\s*$", check or "")
|
|
250
|
+
if not m:
|
|
251
|
+
return
|
|
252
|
+
a_ids = delta.get(m.group(1), [])
|
|
253
|
+
b = _conn_point(topo, m.group(2), own) or topo.devices.get((delta.get(m.group(2)) or [None])[0])
|
|
254
|
+
if not a_ids or b is None:
|
|
255
|
+
return
|
|
256
|
+
for aid in a_ids:
|
|
257
|
+
if aid != b.id and not _link_exists(topo, aid, b.id):
|
|
258
|
+
topo.add_link(aid, b.id)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _expand_structural(t, slot: str, members: list[tuple[str, str]], topo: Topology,
|
|
262
|
+
delta: dict, own: str) -> list[Objective]:
|
|
263
|
+
"""forall members: one objective per member. A `link(a, b@slot)` wires this node's delta `a` to
|
|
264
|
+
each member's connection point (its `connect_via` type — a LAN's switch, a routed net's router)."""
|
|
265
|
+
objs: list[Objective] = []
|
|
266
|
+
link_m = re.match(r"^\s*link\((\w+)\s*,\s*(\w+)@" + re.escape(slot) + r"\)\s*$", t.check or "")
|
|
267
|
+
for mp, cv in members:
|
|
268
|
+
if link_m:
|
|
269
|
+
a, attach = link_m.group(1), (cv or link_m.group(2))
|
|
270
|
+
objs.append(Objective(id=f"{t.id}-{mp}", say=f"{t.say} [{mp}]", kind=t.kind,
|
|
271
|
+
check=f"link({a}@{own}, {attach}@{mp})", level=t.level, stars=t.stars))
|
|
272
|
+
adev = topo.devices.get((delta.get(a) or [None])[0])
|
|
273
|
+
bdev = _conn_point(topo, attach, mp)
|
|
274
|
+
if adev is not None and bdev is not None and not _link_exists(topo, adev.id, bdev.id):
|
|
275
|
+
topo.add_link(adev.id, bdev.id)
|
|
276
|
+
else:
|
|
277
|
+
objs.append(Objective(id=f"{t.id}-{mp}", say=f"{t.say} [{mp}]", kind=t.kind,
|
|
278
|
+
check=_rebind(_suffix_plain(t.check, own), slot, mp),
|
|
279
|
+
level=t.level, stars=t.stars))
|
|
280
|
+
return objs
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _topology_pairs(topology: str, n: int) -> list[tuple[int, int]]:
|
|
284
|
+
"""Which member index pairs a peering interconnects, by shape."""
|
|
285
|
+
if n < 2:
|
|
286
|
+
return []
|
|
287
|
+
topo = (topology or "mesh").lower()
|
|
288
|
+
if topo == "ring":
|
|
289
|
+
return [(0, 1)] if n == 2 else [(i, (i + 1) % n) for i in range(n)]
|
|
290
|
+
if topo == "line":
|
|
291
|
+
return [(i, i + 1) for i in range(n - 1)]
|
|
292
|
+
if topo == "star":
|
|
293
|
+
return [(0, i) for i in range(1, n)] # member 0 is the hub
|
|
294
|
+
return [(i, j) for i in range(n) for j in range(i + 1, n)] # mesh: every pair
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _wire_peering(topo: Topology, out: list[Objective], members: list[tuple[str, str]],
|
|
298
|
+
topology: str, name: str) -> None:
|
|
299
|
+
"""Interconnect peer members per `topology`, linking each pair's connection points, and emit a
|
|
300
|
+
structural objective per peered pair so the graph is gradable."""
|
|
301
|
+
for i, j in _topology_pairs(topology, len(members)):
|
|
302
|
+
(mpi, cvi), (mpj, cvj) = members[i], members[j]
|
|
303
|
+
a, b = _conn_point(topo, cvi, mpi), _conn_point(topo, cvj, mpj)
|
|
304
|
+
if a is not None and b is not None and not _link_exists(topo, a.id, b.id):
|
|
305
|
+
topo.add_link(a.id, b.id)
|
|
306
|
+
out.append(Objective(id=f"peer-{name}-{i}-{j}", say=f"{name}: peers {i}↔{j} interconnected",
|
|
307
|
+
kind="structural", check=f"link({cvi}@{mpi}, {cvj}@{mpj})", level=2))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _expand_behavioral(t, slot: str, member_paths: list[str]) -> list[Objective]:
|
|
311
|
+
"""forall-pairs: a same-slot `reach(x@s -> y@s)` becomes one probe per distinct member pair."""
|
|
312
|
+
objs: list[Objective] = []
|
|
313
|
+
for i in range(len(member_paths)):
|
|
314
|
+
for j in range(i + 1, len(member_paths)):
|
|
315
|
+
probe = _rebind_probe(t.probe, slot, member_paths[i], member_paths[j])
|
|
316
|
+
objs.append(Objective(id=f"{t.id}-{member_paths[i]}-{member_paths[j]}",
|
|
317
|
+
say=f"{t.say} [{member_paths[i]}↔{member_paths[j]}]", kind="behavioral",
|
|
318
|
+
check="", probe=probe, level=t.level, stars=t.stars))
|
|
319
|
+
return objs
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _rebind_probe(probe: str, slot: str, la: str, lb: str) -> str:
|
|
323
|
+
"""Rebind the two same-slot operands of a probe to a specific member pair: the first `@slot`
|
|
324
|
+
occurrence becomes `@la`, the second `@lb` (a probe has src then dst)."""
|
|
325
|
+
parts = probe.split(f"@{slot}") # template carries the bare slot name twice
|
|
326
|
+
if len(parts) == 3:
|
|
327
|
+
return parts[0] + f"@{la}" + parts[1] + f"@{lb}" + parts[2]
|
|
328
|
+
return probe.replace(f"@{slot}", f"@{la}") # single-operand fallback
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _leaf_type(F) -> str:
|
|
332
|
+
"""A provider's 'leaf' element — the endpoints its hub connects (a LAN's `host`). Inferred as a
|
|
333
|
+
non-hub, non-rider device type in its board."""
|
|
334
|
+
hub = _connect_via(F)
|
|
335
|
+
for d in (getattr(F, "stage", None) or {}).get("devices", []) or []:
|
|
336
|
+
tk = d.get("type_key", "")
|
|
337
|
+
if tk and tk != hub and not _is_rider(tk):
|
|
338
|
+
return tk
|
|
339
|
+
return ""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _open_objectives(binding: dict) -> list[Objective]:
|
|
343
|
+
"""The OPEN-N win condition — quantified and N-INDEPENDENT, so a student may build ANY number of
|
|
344
|
+
members ≥ the slot's floor and still pass. Instead of expanding to concrete `@lans2` labels we
|
|
345
|
+
emit: `count(hub) >= K` (the floor), `all_linked(hub, delta)` / `all_linked(leaf, hub)` (every
|
|
346
|
+
member wired), and `reach(x -> y, all)` (every pair reachable). This is the generalizable pattern
|
|
347
|
+
the AI composer plans against; the concrete board materialized alongside is just a sample."""
|
|
348
|
+
F = _frag.get(binding.get("fragment", ""))
|
|
349
|
+
if F is None:
|
|
350
|
+
raise CompositionError(f"unknown fragment: {binding.get('fragment')!r}")
|
|
351
|
+
objs: list[Objective] = []
|
|
352
|
+
for t in F.objectives: # delta placements stay as-is (exists router)
|
|
353
|
+
if not (_slots_in(t.check) or _slots_in(t.probe)):
|
|
354
|
+
objs.append(Objective(id=t.id, say=t.say, kind=t.kind, check=t.check, probe=t.probe,
|
|
355
|
+
level=t.level, stars=t.stars))
|
|
356
|
+
|
|
357
|
+
groups = [(S.name, S.min, (binding.get("bind") or {}).get(S.name)) for S in F.slots]
|
|
358
|
+
groups += [(P.name, P.min, (binding.get("peer") or {}).get(P.name)) for P in getattr(F, "peerings", ())]
|
|
359
|
+
for name, k, provs in groups:
|
|
360
|
+
if not provs:
|
|
361
|
+
continue
|
|
362
|
+
first = provs[0]
|
|
363
|
+
pf = _frag.get(first["fragment"] if isinstance(first, dict) else first)
|
|
364
|
+
if pf is None:
|
|
365
|
+
continue
|
|
366
|
+
hub = _connect_via(pf)
|
|
367
|
+
# A composite member (a routed-network with its own LANs) has its internal structure proven
|
|
368
|
+
# by its certificate — we don't re-assert "leaf on hub" for it. Only a LEAF provider (a LAN:
|
|
369
|
+
# hosts directly on a switch) gets the `all_linked(host, switch)` check.
|
|
370
|
+
composite = bool(getattr(pf, "slots", ()) or getattr(pf, "peerings", ()))
|
|
371
|
+
leaf = "" if composite else _leaf_type(pf)
|
|
372
|
+
objs.append(Objective(id=f"open-count-{name}", say=f"at least {k} {hub}(s) in {name}",
|
|
373
|
+
kind="structural", check=f"count({hub}) >= {k}", level=1))
|
|
374
|
+
for t in F.objectives: # every member wired to the delta it links
|
|
375
|
+
m = re.match(r"^\s*link\((\w+)\s*,\s*(\w+)@" + re.escape(name) + r"\)\s*$", t.check or "")
|
|
376
|
+
if m:
|
|
377
|
+
objs.append(Objective(id=f"open-wire-{name}", say=f"every {hub} wired to the {m.group(1)}",
|
|
378
|
+
kind="structural", check=f"all_linked({hub}, {m.group(1)})",
|
|
379
|
+
level=2))
|
|
380
|
+
if leaf and leaf != hub:
|
|
381
|
+
objs.append(Objective(id=f"open-leaf-{name}", say=f"every {leaf} on a {hub}",
|
|
382
|
+
kind="structural", check=f"all_linked({leaf}, {hub})", level=2))
|
|
383
|
+
for t in F.objectives: # cross-member reach → every pair (all)
|
|
384
|
+
if t.kind == "behavioral" and name in _slots_in(t.probe):
|
|
385
|
+
probe = re.sub(r"@\w+", "", t.probe) # strip slot scopes
|
|
386
|
+
probe = re.sub(r"\)\s*==", ", all) ==", probe, count=1)
|
|
387
|
+
objs.append(Objective(id=f"open-reach-{name}", say=t.say + " (all pairs, any N)",
|
|
388
|
+
kind="behavioral", probe=probe, level=4))
|
|
389
|
+
return objs
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def materialize(binding: dict, mode: str = "fixed") -> tuple[Topology, list[Objective]]:
|
|
393
|
+
"""Assemble the concrete topology + objective ladder. `mode='fixed'` bakes this exact N (a
|
|
394
|
+
specific lab); `mode='open'` grades a quantified, N-independent pattern (student picks N ≥ floor).
|
|
395
|
+
Either way a concrete board is materialized — in open mode it's a representative sample."""
|
|
396
|
+
topo = Topology()
|
|
397
|
+
out: list[Objective] = []
|
|
398
|
+
_build(binding, topo, out, path=ROOT, is_provider=False)
|
|
399
|
+
from .objectives import by_level
|
|
400
|
+
if mode == "open":
|
|
401
|
+
out = _open_objectives(binding)
|
|
402
|
+
return topo, by_level(out)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def grade(binding: dict, runner=None, mode: str = "fixed"):
|
|
406
|
+
"""Materialize + grade a composition against the oracle. `runner` supplies behavioral verdicts
|
|
407
|
+
(a live DockerProbeRunner on the Mac, a FakeRunner headless). `mode` picks fixed-N or open-N
|
|
408
|
+
grading. Returns (topology, results)."""
|
|
409
|
+
from . import objectives as _obj
|
|
410
|
+
from . import probes as _probes
|
|
411
|
+
topo, objs = materialize(binding, mode=mode)
|
|
412
|
+
r = _probes.TypeRunner(runner, lambda: topo) if runner is not None else None
|
|
413
|
+
return topo, _obj.evaluate_all(objs, _obj.TopologyWorld(topo), r)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Composition-by-reference — how the Teaching Center distributes missions (M4).
|
|
2
|
+
|
|
3
|
+
The agreed model: **foundational fragments are local** (bound to the local oracle's predicates); the
|
|
4
|
+
Center never ships new primitives. It ships **compositions** — a small spec that REFERENCES local
|
|
5
|
+
fragment ids plus framing (genre/level/title/brief), which the engine assembles locally against its
|
|
6
|
+
trusted primitives. This guarantees gradability (every objective comes from a verified local fragment)
|
|
7
|
+
and needs a **version/existence check**: a referenced fragment id (or capability role) that isn't
|
|
8
|
+
installed fails gracefully instead of mis-grading.
|
|
9
|
+
|
|
10
|
+
A composition spec (YAML/JSON):
|
|
11
|
+
|
|
12
|
+
id: hw1
|
|
13
|
+
fragments: [basic-lan, service-chain] # local fragment ids
|
|
14
|
+
genre: expedition # optional
|
|
15
|
+
level: 2 # optional
|
|
16
|
+
title: "Homework 1" # optional reskin
|
|
17
|
+
brief: "Bridge two LANs to the Internet through a firewall."
|
|
18
|
+
|
|
19
|
+
An *escape hatch* is allowed: a spec with inline `objectives` (no `fragments`) is a self-contained
|
|
20
|
+
lesson — but its predicates are still bounded by what the local oracle can evaluate (validation).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CompositionError(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def missing_refs(spec: dict) -> list[str]:
|
|
30
|
+
"""Referenced things the local install doesn't have — the version/existence check. Returns tags
|
|
31
|
+
like 'fragment:<id>' / 'role:<name>'. Empty = the composition can be assembled here."""
|
|
32
|
+
from . import capabilities as _caps
|
|
33
|
+
from . import fragments as _frag
|
|
34
|
+
miss: list[str] = []
|
|
35
|
+
for fid in spec.get("fragments", []) or []:
|
|
36
|
+
if _frag.get(fid) is None:
|
|
37
|
+
miss.append(f"fragment:{fid}")
|
|
38
|
+
for role in spec.get("requires_roles", []) or []: # optional explicit role requirements
|
|
39
|
+
if not _caps.is_role(role):
|
|
40
|
+
miss.append(f"role:{role}")
|
|
41
|
+
return miss
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def from_composition(spec: dict, *, lesson_id: str | None = None):
|
|
45
|
+
"""Assemble a Lesson from a composition spec. Raises CompositionError if it references anything
|
|
46
|
+
not installed locally (the version/existence guard)."""
|
|
47
|
+
from . import assembly as _assembly
|
|
48
|
+
from . import lesson as _lesson
|
|
49
|
+
lid = lesson_id or spec.get("id") or "composed"
|
|
50
|
+
|
|
51
|
+
if spec.get("fragments"):
|
|
52
|
+
miss = missing_refs(spec)
|
|
53
|
+
if miss:
|
|
54
|
+
raise CompositionError(
|
|
55
|
+
"this mission needs pieces this GINI doesn't have: " + ", ".join(miss)
|
|
56
|
+
+ " — update GINI or ask your instructor for a compatible version.")
|
|
57
|
+
over = {k: spec[k] for k in ("time_limit", "attempts", "help", "persona", "complete_when")
|
|
58
|
+
if k in spec}
|
|
59
|
+
# the teacher's plain-language nuance rides along with the mission and is interpreted by the
|
|
60
|
+
# student's game master — so the course server never needs a model of its own
|
|
61
|
+
intent = dict(spec.get("intent") or {})
|
|
62
|
+
if spec.get("notes"):
|
|
63
|
+
intent.setdefault("notes", spec["notes"])
|
|
64
|
+
if intent:
|
|
65
|
+
over["intent"] = intent
|
|
66
|
+
# A teacher's composition is LITERAL: the student gets exactly the fragments referenced —
|
|
67
|
+
# never auto-filled exercise/observe layers they didn't ask for. (Want a load generator?
|
|
68
|
+
# Reference `drive-load`.) Opt in explicitly with `fill: true`.
|
|
69
|
+
return _assembly.assemble(
|
|
70
|
+
list(spec["fragments"]), genre=spec.get("genre"), level=spec.get("level"),
|
|
71
|
+
lesson_id=lid, title=spec.get("title", ""), brief=spec.get("brief", ""),
|
|
72
|
+
persona=spec.get("persona", "coach"), fill=bool(spec.get("fill", False)), **over)
|
|
73
|
+
|
|
74
|
+
# escape hatch: a self-contained lesson (inline objectives), still bounded by the local oracle
|
|
75
|
+
if spec.get("objectives"):
|
|
76
|
+
d = dict(spec)
|
|
77
|
+
d.setdefault("id", lid)
|
|
78
|
+
les = _lesson.from_dict(d)
|
|
79
|
+
problems = _lesson.validate(les)
|
|
80
|
+
if problems:
|
|
81
|
+
raise CompositionError("lesson does not validate locally: " + "; ".join(problems))
|
|
82
|
+
return les
|
|
83
|
+
|
|
84
|
+
raise CompositionError("composition has neither 'fragments' nor 'objectives'")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_composition(spec) -> bool:
|
|
88
|
+
return isinstance(spec, dict) and bool(spec.get("fragments") or spec.get("objectives"))
|