aetherorchestrate 2.0.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.
- aetherorchestrate/__init__.py +16 -0
- aetherorchestrate/__version__.py +1 -0
- aetherorchestrate/assignment/__init__.py +3 -0
- aetherorchestrate/assignment/assigner.py +58 -0
- aetherorchestrate/cli.py +33 -0
- aetherorchestrate/decomposition/__init__.py +3 -0
- aetherorchestrate/decomposition/decomposer.py +32 -0
- aetherorchestrate/dependencies/__init__.py +3 -0
- aetherorchestrate/dependencies/graph.py +64 -0
- aetherorchestrate/models.py +190 -0
- aetherorchestrate/routing/__init__.py +3 -0
- aetherorchestrate/routing/router.py +27 -0
- aetherorchestrate/scheduling/__init__.py +3 -0
- aetherorchestrate/scheduling/scheduler.py +41 -0
- aetherorchestrate/simulation/__init__.py +5 -0
- aetherorchestrate/simulation/network.py +36 -0
- aetherorchestrate/simulation/node.py +21 -0
- aetherorchestrate/simulation/testbed.py +35 -0
- aetherorchestrate-2.0.0.dist-info/METADATA +641 -0
- aetherorchestrate-2.0.0.dist-info/RECORD +25 -0
- aetherorchestrate-2.0.0.dist-info/WHEEL +5 -0
- aetherorchestrate-2.0.0.dist-info/entry_points.txt +2 -0
- aetherorchestrate-2.0.0.dist-info/licenses/AUTHORS.md +31 -0
- aetherorchestrate-2.0.0.dist-info/licenses/LICENSE +26 -0
- aetherorchestrate-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherOrchestrate v2.0.0 — Orchestration for the Aether family.
|
|
3
|
+
|
|
4
|
+
Built through a seven-phase methodology:
|
|
5
|
+
1. Architectural Specification
|
|
6
|
+
2. Invariant Definition
|
|
7
|
+
3. Test Design
|
|
8
|
+
4. Simulator Implementation
|
|
9
|
+
5. Test Execution
|
|
10
|
+
6. Experiments
|
|
11
|
+
7. Release
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .__version__ import __version__
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""L2 — Assignment (task → agent)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from ..models import Task, Claim, CapabilityRecord
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AssignmentResult:
|
|
13
|
+
claim: Claim
|
|
14
|
+
valid: bool = False
|
|
15
|
+
note: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _hash(task_id: str, agent: str) -> str:
|
|
19
|
+
return hashlib.sha256(f"{task_id}|{agent}".encode()).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def eligible(task: Task, agents: list[str], capabilities: list[CapabilityRecord],
|
|
23
|
+
revoked: set[str] = frozenset()) -> list[str]:
|
|
24
|
+
"""Return the list of eligible agents for a task."""
|
|
25
|
+
capable = set()
|
|
26
|
+
for cap in capabilities:
|
|
27
|
+
if cap.capability in task.requires:
|
|
28
|
+
capable.add(cap.agent)
|
|
29
|
+
eligible_set = [a for a in agents if a in capable or not task.requires]
|
|
30
|
+
return [a for a in eligible_set if a not in revoked]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def preferred_assignee(task: Task, agents: list[str], capabilities: list[CapabilityRecord],
|
|
34
|
+
revoked: set[str] = frozenset()) -> str:
|
|
35
|
+
"""Return the preferred assignee (deterministic, view-sensitive)."""
|
|
36
|
+
elig = eligible(task, agents, capabilities, revoked)
|
|
37
|
+
if not elig:
|
|
38
|
+
return ""
|
|
39
|
+
return min(elig, key=lambda a: _hash(task.id, a))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assign(task: Task, agents: list[str], capabilities: list[CapabilityRecord],
|
|
43
|
+
revoked: set[str] = frozenset(), lease_seconds: int = 60) -> AssignmentResult:
|
|
44
|
+
"""Assign a task to its preferred assignee."""
|
|
45
|
+
agent = preferred_assignee(task, agents, capabilities, revoked)
|
|
46
|
+
if not agent:
|
|
47
|
+
return AssignmentResult(
|
|
48
|
+
claim=Claim(task_id=task.id, agent="", lease_until=datetime.utcnow()),
|
|
49
|
+
valid=False,
|
|
50
|
+
note="no eligible agent",
|
|
51
|
+
)
|
|
52
|
+
claim = Claim(
|
|
53
|
+
task_id=task.id,
|
|
54
|
+
agent=agent,
|
|
55
|
+
lease_until=datetime.utcnow() + timedelta(seconds=lease_seconds),
|
|
56
|
+
signature=f"sig:{agent}:{task.id}",
|
|
57
|
+
)
|
|
58
|
+
return AssignmentResult(claim=claim, valid=True, note=f"assigned to {agent}")
|
aetherorchestrate/cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""AetherOrchestrate CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from .__version__ import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> int:
|
|
10
|
+
args = sys.argv[1:]
|
|
11
|
+
if not args or args[0] == "info":
|
|
12
|
+
print(f"AetherOrchestrate v{__version__}")
|
|
13
|
+
print("Status: Phase 4 (Simulator Implementation)")
|
|
14
|
+
print("Source paper: DOI 10.5281/zenodo.24095399")
|
|
15
|
+
print("Seven-phase methodology:")
|
|
16
|
+
print(" 1. Architectural Specification ✅")
|
|
17
|
+
print(" 2. Invariant Definition ✅")
|
|
18
|
+
print(" 3. Test Design ✅")
|
|
19
|
+
print(" 4. Simulator Implementation ⏳ (in progress)")
|
|
20
|
+
print(" 5. Test Execution ⏳")
|
|
21
|
+
print(" 6. Experiments ⏳")
|
|
22
|
+
print(" 7. Release ⏳")
|
|
23
|
+
return 0
|
|
24
|
+
if args[0] == "verify":
|
|
25
|
+
from . import __version__ as v
|
|
26
|
+
print(f"✅ aetherorchestrate {v} importable")
|
|
27
|
+
return 0
|
|
28
|
+
print(f"Unknown command: {args[0]}")
|
|
29
|
+
return 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
sys.exit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""L1 — Decomposition (goal → tasks)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from ..models import Goal, Task, Decomposition
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DecompositionResult:
|
|
11
|
+
goal: Goal
|
|
12
|
+
decomposition: Decomposition
|
|
13
|
+
valid: bool = False
|
|
14
|
+
note: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def decompose(goal: Goal, hint: str = "") -> DecompositionResult:
|
|
18
|
+
"""Decompose a goal into tasks (trivial decomposition)."""
|
|
19
|
+
task = Task(goal_id=goal.id, requires=frozenset(), acceptance=goal.acceptance)
|
|
20
|
+
decomp = Decomposition(
|
|
21
|
+
goal_id=goal.id,
|
|
22
|
+
tasks=[task],
|
|
23
|
+
edges=[],
|
|
24
|
+
kappa={task.id: frozenset()},
|
|
25
|
+
acceptance=goal.acceptance,
|
|
26
|
+
)
|
|
27
|
+
return DecompositionResult(
|
|
28
|
+
goal=goal,
|
|
29
|
+
decomposition=decomp,
|
|
30
|
+
valid=decomp.is_acyclic(),
|
|
31
|
+
note="trivial decomposition" if not hint else "trivial (hint ignored)",
|
|
32
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""L4 — Dependency tracking (task DAG)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from ..models import DependencyEdge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class DependencyGraph:
|
|
12
|
+
nodes: set[str] = field(default_factory=set)
|
|
13
|
+
edges: list[DependencyEdge] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
def add_edge(self, edge: DependencyEdge) -> None:
|
|
16
|
+
self.nodes.add(edge.from_task)
|
|
17
|
+
self.nodes.add(edge.to_task)
|
|
18
|
+
self.edges.append(edge)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_graph(edges: list[DependencyEdge]) -> DependencyGraph:
|
|
22
|
+
g = DependencyGraph()
|
|
23
|
+
for e in edges:
|
|
24
|
+
g.add_edge(e)
|
|
25
|
+
return g
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _has_cycle(nodes: set[str], edges: list[DependencyEdge]) -> bool:
|
|
29
|
+
adj = defaultdict(list)
|
|
30
|
+
indeg = {n: 0 for n in nodes}
|
|
31
|
+
for e in edges:
|
|
32
|
+
adj[e.from_task].append(e.to_task)
|
|
33
|
+
indeg[e.to_task] = indeg.get(e.to_task, 0) + 1
|
|
34
|
+
queue = [n for n, d in indeg.items() if d == 0]
|
|
35
|
+
visited = 0
|
|
36
|
+
while queue:
|
|
37
|
+
cur = queue.pop()
|
|
38
|
+
visited += 1
|
|
39
|
+
for nxt in adj[cur]:
|
|
40
|
+
indeg[nxt] -= 1
|
|
41
|
+
if indeg[nxt] == 0:
|
|
42
|
+
queue.append(nxt)
|
|
43
|
+
return visited != len(nodes)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def materialize(g: DependencyGraph) -> DependencyGraph:
|
|
47
|
+
"""Return the acyclic materialization of g (deterministic cycle-breaking).
|
|
48
|
+
|
|
49
|
+
Rule: repeatedly remove the edge with greatest id inside a cycle,
|
|
50
|
+
until no cycle remains. Deterministic function of the edge set.
|
|
51
|
+
"""
|
|
52
|
+
edges = list(g.edges)
|
|
53
|
+
while _has_cycle(g.nodes, edges):
|
|
54
|
+
# Find edges that participate in a cycle, remove the one with max id
|
|
55
|
+
cyclic_edges = [e for e in edges if _has_cycle(g.nodes, edges)]
|
|
56
|
+
if not cyclic_edges:
|
|
57
|
+
break
|
|
58
|
+
to_remove = max(cyclic_edges, key=lambda e: e.id)
|
|
59
|
+
edges = [e for e in edges if e.id != to_remove.id]
|
|
60
|
+
|
|
61
|
+
result = DependencyGraph(nodes=set(g.nodes))
|
|
62
|
+
for e in edges:
|
|
63
|
+
result.add_edge(e)
|
|
64
|
+
return result
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""AetherOrchestrate v2.0.0 — Core data models.
|
|
2
|
+
|
|
3
|
+
Implements the 7 entities from SPECIFICATION.md §2.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Optional
|
|
12
|
+
import hashlib
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
# Enums
|
|
18
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskStatus(str, Enum):
|
|
22
|
+
PENDING = "pending"
|
|
23
|
+
ASSIGNED = "assigned"
|
|
24
|
+
RUNNING = "running"
|
|
25
|
+
COMPLETED = "completed"
|
|
26
|
+
FAILED = "failed"
|
|
27
|
+
CANCELLED = "cancelled"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DependencyType(str, Enum):
|
|
31
|
+
FINISH_TO_START = "finish-to-start"
|
|
32
|
+
START_TO_START = "start-to-start"
|
|
33
|
+
DATA = "data"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
37
|
+
# Entities
|
|
38
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _content_id(*parts: str) -> str:
|
|
42
|
+
"""Content-addressed id from parts."""
|
|
43
|
+
h = hashlib.sha256("|".join(parts).encode()).hexdigest()
|
|
44
|
+
return h[:16]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Goal:
|
|
49
|
+
"""E1 — Goal."""
|
|
50
|
+
id: str = ""
|
|
51
|
+
description: str = ""
|
|
52
|
+
authority: str = ""
|
|
53
|
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
54
|
+
acceptance: str = ""
|
|
55
|
+
|
|
56
|
+
def __post_init__(self):
|
|
57
|
+
if not self.id:
|
|
58
|
+
self.id = _content_id("goal", self.description, self.authority)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Task:
|
|
63
|
+
"""E2 — Task."""
|
|
64
|
+
id: str = ""
|
|
65
|
+
goal_id: str = ""
|
|
66
|
+
requires: frozenset[str] = frozenset()
|
|
67
|
+
acceptance: str = ""
|
|
68
|
+
duration: Optional[float] = None
|
|
69
|
+
label: str = ""
|
|
70
|
+
|
|
71
|
+
def __post_init__(self):
|
|
72
|
+
if not self.id:
|
|
73
|
+
self.id = _content_id(
|
|
74
|
+
"task",
|
|
75
|
+
self.goal_id,
|
|
76
|
+
self.label,
|
|
77
|
+
str(sorted(self.requires)),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Decomposition:
|
|
83
|
+
"""E3 — Decomposition: (T, <, κ, Acc)."""
|
|
84
|
+
id: str = ""
|
|
85
|
+
goal_id: str = ""
|
|
86
|
+
tasks: list[Task] = field(default_factory=list)
|
|
87
|
+
edges: list[tuple[str, str]] = field(default_factory=list) # (from_id, to_id)
|
|
88
|
+
kappa: dict[str, frozenset[str]] = field(default_factory=dict)
|
|
89
|
+
acceptance: str = ""
|
|
90
|
+
|
|
91
|
+
def __post_init__(self):
|
|
92
|
+
if not self.id:
|
|
93
|
+
task_ids = sorted(t.id for t in self.tasks)
|
|
94
|
+
self.id = _content_id("decomp", self.goal_id, ",".join(task_ids))
|
|
95
|
+
|
|
96
|
+
def is_acyclic(self) -> bool:
|
|
97
|
+
"""Check that the < relation is acyclic (I2)."""
|
|
98
|
+
from collections import defaultdict, deque
|
|
99
|
+
adj = defaultdict(list)
|
|
100
|
+
indeg = {t.id: 0 for t in self.tasks}
|
|
101
|
+
for a, b in self.edges:
|
|
102
|
+
adj[a].append(b)
|
|
103
|
+
indeg[b] = indeg.get(b, 0) + 1
|
|
104
|
+
queue = deque([t for t, d in indeg.items() if d == 0])
|
|
105
|
+
visited = 0
|
|
106
|
+
while queue:
|
|
107
|
+
cur = queue.popleft()
|
|
108
|
+
visited += 1
|
|
109
|
+
for nxt in adj[cur]:
|
|
110
|
+
indeg[nxt] -= 1
|
|
111
|
+
if indeg[nxt] == 0:
|
|
112
|
+
queue.append(nxt)
|
|
113
|
+
return visited == len(indeg)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class DependencyEdge:
|
|
118
|
+
"""E4 — Dependency Edge."""
|
|
119
|
+
id: str = ""
|
|
120
|
+
from_task: str = ""
|
|
121
|
+
to_task: str = ""
|
|
122
|
+
type: DependencyType = DependencyType.FINISH_TO_START
|
|
123
|
+
|
|
124
|
+
def __post_init__(self):
|
|
125
|
+
if not self.id:
|
|
126
|
+
self.id = _content_id("edge", self.from_task, self.to_task, self.type.value)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Claim:
|
|
131
|
+
"""E5 — Claim."""
|
|
132
|
+
id: str = ""
|
|
133
|
+
task_id: str = ""
|
|
134
|
+
agent: str = ""
|
|
135
|
+
lease_until: datetime = field(default_factory=datetime.utcnow)
|
|
136
|
+
signature: str = ""
|
|
137
|
+
|
|
138
|
+
def __post_init__(self):
|
|
139
|
+
if not self.id:
|
|
140
|
+
self.id = _content_id("claim", self.task_id, self.agent, self.lease_until.isoformat())
|
|
141
|
+
|
|
142
|
+
def is_active(self, now: Optional[datetime] = None) -> bool:
|
|
143
|
+
now = now or datetime.utcnow()
|
|
144
|
+
return now < self.lease_until
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class Completion:
|
|
149
|
+
"""E6 — Completion."""
|
|
150
|
+
id: str = ""
|
|
151
|
+
task_id: str = ""
|
|
152
|
+
agent: str = ""
|
|
153
|
+
result_hash: str = ""
|
|
154
|
+
evidence: Optional[str] = None
|
|
155
|
+
completed_at: datetime = field(default_factory=datetime.utcnow)
|
|
156
|
+
|
|
157
|
+
def __post_init__(self):
|
|
158
|
+
if not self.id:
|
|
159
|
+
self.id = _content_id("completion", self.task_id, self.agent, self.result_hash)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class CapabilityRecord:
|
|
164
|
+
"""E7 — Capability Record."""
|
|
165
|
+
id: str = ""
|
|
166
|
+
agent: str = ""
|
|
167
|
+
capability: str = ""
|
|
168
|
+
authority: str = ""
|
|
169
|
+
valid_until: Optional[datetime] = None
|
|
170
|
+
|
|
171
|
+
def __post_init__(self):
|
|
172
|
+
if not self.id:
|
|
173
|
+
self.id = _content_id("cap", self.agent, self.capability, self.authority)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
177
|
+
# Public API
|
|
178
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
179
|
+
|
|
180
|
+
__all__ = [
|
|
181
|
+
"TaskStatus",
|
|
182
|
+
"DependencyType",
|
|
183
|
+
"Goal",
|
|
184
|
+
"Task",
|
|
185
|
+
"Decomposition",
|
|
186
|
+
"DependencyEdge",
|
|
187
|
+
"Claim",
|
|
188
|
+
"Completion",
|
|
189
|
+
"CapabilityRecord",
|
|
190
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""L5 — Routing (task → executor)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from ..models import Task, Claim
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class RouteResult:
|
|
11
|
+
delivered: dict[str, bool] = field(default_factory=dict)
|
|
12
|
+
complete: bool = False
|
|
13
|
+
note: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def route(tasks: list[Task], claims: list[Claim]) -> RouteResult:
|
|
17
|
+
"""Route tasks to their assignees (direct routing)."""
|
|
18
|
+
claim_by_task = {c.task_id: c for c in claims if c.is_active()}
|
|
19
|
+
delivered = {}
|
|
20
|
+
for t in tasks:
|
|
21
|
+
c = claim_by_task.get(t.id)
|
|
22
|
+
delivered[t.id] = c is not None and c.agent != ""
|
|
23
|
+
return RouteResult(
|
|
24
|
+
delivered=delivered,
|
|
25
|
+
complete=all(delivered.values()) if delivered else False,
|
|
26
|
+
note="direct routing",
|
|
27
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""L3 — Scheduling (when tasks run)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from ..models import Task, DependencyEdge, DependencyType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ScheduleResult:
|
|
11
|
+
order: list[str] = field(default_factory=list)
|
|
12
|
+
feasible: bool = False
|
|
13
|
+
note: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def schedule(tasks: list[Task], edges: list[DependencyEdge]) -> ScheduleResult:
|
|
17
|
+
"""Compute a schedule hint (deterministic function of S)."""
|
|
18
|
+
task_ids = {t.id for t in tasks}
|
|
19
|
+
adj: dict[str, list[str]] = {tid: [] for tid in task_ids}
|
|
20
|
+
indeg: dict[str, int] = {tid: 0 for tid in task_ids}
|
|
21
|
+
for e in edges:
|
|
22
|
+
if e.from_task in task_ids and e.to_task in task_ids:
|
|
23
|
+
adj[e.from_task].append(e.to_task)
|
|
24
|
+
indeg[e.to_task] += 1
|
|
25
|
+
|
|
26
|
+
queue = sorted([t for t, d in indeg.items() if d == 0])
|
|
27
|
+
order = []
|
|
28
|
+
while queue:
|
|
29
|
+
cur = queue.pop(0)
|
|
30
|
+
order.append(cur)
|
|
31
|
+
for nxt in sorted(adj[cur]):
|
|
32
|
+
indeg[nxt] -= 1
|
|
33
|
+
if indeg[nxt] == 0:
|
|
34
|
+
queue.append(nxt)
|
|
35
|
+
queue.sort()
|
|
36
|
+
feasible = len(order) == len(task_ids)
|
|
37
|
+
return ScheduleResult(
|
|
38
|
+
order=order,
|
|
39
|
+
feasible=feasible,
|
|
40
|
+
note="feasible" if feasible else "cycle detected",
|
|
41
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Simulated network."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Network:
|
|
10
|
+
nodes: list[str] = field(default_factory=list)
|
|
11
|
+
partitions: dict[str, set[str]] = field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
def add_node(self, node_id: str) -> None:
|
|
14
|
+
if node_id in self.nodes:
|
|
15
|
+
return
|
|
16
|
+
self.nodes.append(node_id)
|
|
17
|
+
# كل العقد الموجودة يمكنها الوصول إلى العقدة الجديدة
|
|
18
|
+
for existing in self.nodes:
|
|
19
|
+
if existing == node_id:
|
|
20
|
+
continue
|
|
21
|
+
self.partitions.setdefault(existing, set()).add(node_id)
|
|
22
|
+
# العقدة الجديدة يمكنها الوصول إلى كل العقد الموجودة
|
|
23
|
+
self.partitions[node_id] = set(self.nodes) - {node_id}
|
|
24
|
+
|
|
25
|
+
def can_reach(self, src: str, dst: str) -> bool:
|
|
26
|
+
return dst in self.partitions.get(src, set())
|
|
27
|
+
|
|
28
|
+
def partition(self, group_a: set[str], group_b: set[str]) -> None:
|
|
29
|
+
for a in group_a:
|
|
30
|
+
self.partitions[a] = set(group_a) - {a}
|
|
31
|
+
for b in group_b:
|
|
32
|
+
self.partitions[b] = set(group_b) - {b}
|
|
33
|
+
|
|
34
|
+
def heal(self) -> None:
|
|
35
|
+
for n in self.nodes:
|
|
36
|
+
self.partitions[n] = set(self.nodes) - {n}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Simulated node."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Node:
|
|
11
|
+
id: str
|
|
12
|
+
admitted: set[str] = field(default_factory=set) # delta ids
|
|
13
|
+
claims: list[str] = field(default_factory=list)
|
|
14
|
+
revocations: set[str] = field(default_factory=set)
|
|
15
|
+
is_byzantine: bool = False
|
|
16
|
+
|
|
17
|
+
def admit(self, delta_id: str) -> None:
|
|
18
|
+
self.admitted.add(delta_id)
|
|
19
|
+
|
|
20
|
+
def has(self, delta_id: str) -> bool:
|
|
21
|
+
return delta_id in self.admitted
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Simulation testbed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from .network import Network
|
|
7
|
+
from .node import Node
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Testbed:
|
|
12
|
+
network: Network = field(default_factory=Network)
|
|
13
|
+
nodes: dict[str, Node] = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
def add_node(self, node_id: str, byzantine: bool = False) -> Node:
|
|
16
|
+
node = Node(id=node_id, is_byzantine=byzantine)
|
|
17
|
+
self.nodes[node_id] = node
|
|
18
|
+
self.network.add_node(node_id)
|
|
19
|
+
return node
|
|
20
|
+
|
|
21
|
+
def broadcast(self, src: str, delta_id: str) -> int:
|
|
22
|
+
count = 0
|
|
23
|
+
for dst, node in self.nodes.items():
|
|
24
|
+
if dst == src:
|
|
25
|
+
continue
|
|
26
|
+
if self.network.can_reach(src, dst):
|
|
27
|
+
node.admit(delta_id)
|
|
28
|
+
count += 1
|
|
29
|
+
return count
|
|
30
|
+
|
|
31
|
+
def converge(self) -> bool:
|
|
32
|
+
if not self.nodes:
|
|
33
|
+
return True
|
|
34
|
+
first = next(iter(self.nodes.values()))
|
|
35
|
+
return all(n.admitted == first.admitted for n in self.nodes.values())
|