co2114 2025.2.2__py3-none-any.whl → 2026.0.2__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.
co2114/search/graph.py CHANGED
@@ -1,13 +1,24 @@
1
+ from __future__ import annotations
2
+
1
3
  import math
2
4
  import warnings
3
5
  import json
4
6
 
7
+ from typing import Iterable, Iterator, override, Callable, Collection, TypeVar
8
+
5
9
  from matplotlib import pyplot as plt
10
+ from matplotlib.figure import Figure # for type hinting
6
11
 
7
12
  from ..agent.environment import Environment
8
- from .things import Thing, Agent, UtilityBasedAgent
13
+ from .things import Thing, Agent, UtilityBasedAgent, Action
14
+
15
+ Numeric = int | float | None
16
+ Location = Thing | Iterable
17
+ Label = str
18
+ # Weight = Numeric
9
19
 
10
- UK_CITIES_GRAPH = {
20
+ # Example graph dictionary representing UK cities and distances between them
21
+ UK_CITIES_GRAPH:dict[str, Iterable[str | tuple[str,str] | int]] = {
11
22
  "nodes": ["Edinburgh", "Glasgow", "Manchester", "Liverpool",
12
23
  "Newcastle", "York", "Sheffield", "Leicester",
13
24
  "London", "Bath", "Bristol", "Exeter", "Cardiff", "Birmingham"],
@@ -27,174 +38,276 @@ UK_CITIES_GRAPH = {
27
38
 
28
39
 
29
40
  class Node(Thing):
30
- def __init__(self, label=""):
31
- self.label = label
32
- self.neighbours = set()
33
- self.weights = {}
34
- def __repr__(self):
41
+ """ A node in a graph structure."""
42
+ def __init__(self, label:Label = "") -> None:
43
+ """ Create a node with given label. """
44
+ self.label:Label = label # identifier for node
45
+ self.neighbours:set[Node] = set() # set of neighbouring nodes
46
+ self.weights:dict[Node, Numeric] = {} # mapping of neighbouring nodes to edge weights
47
+ self.location:Location | None = None # optional location attribute
48
+
49
+ @override
50
+ def __repr__(self) -> str:
51
+ """ String representation of node, returning its label. """
35
52
  return self.label
36
- def add_neighbour(self, node, weight=None):
37
- if not isinstance(node, Node):
53
+
54
+
55
+ def add_neighbour(self, node:Node, weight:Numeric = None) -> None:
56
+ """ Add a neighbouring node with optional edge weight.
57
+
58
+ :param node: Node to be added as a neighbour.
59
+ :param weight: Weight of edge to neighbour (default None).
60
+ """
61
+ if not isinstance(node, Node): # type check
38
62
  raise TypeError(f"{self}: {node} is not a Node")
39
- if node not in self.neighbours:
63
+
64
+ if node not in self.neighbours: # avoid duplicate edges
40
65
  self.neighbours.add(node)
41
66
  self.weights[node] = weight
42
- node.add_neighbour(self, weight)
67
+ node.add_neighbour(self, weight) # undirected graph
68
+
69
+
70
+
71
+ Edge = tuple[Node, Node, Numeric] # edge type alias
72
+
43
73
 
44
74
  class Graph:
45
- def __init__(self):
46
- self.nodes = set()
75
+ """ A graph structure comprising nodes and edges. """
76
+ def __init__(self) -> None:
77
+ """ Create an empty graph. """
78
+ self.nodes:set[Node] = set()
47
79
 
48
- def __iter__(self):
80
+
81
+ def __iter__(self) -> Iterator[Node]:
82
+ """ Iterate over nodes in the graph. """
49
83
  return iter(self.nodes)
50
84
 
51
- def add_node(self, node):
52
- if not isinstance(node, Node):
85
+
86
+ def add_node(self, node:Node) -> None:
87
+ """ Add a node and its neighbours to the graph.
88
+
89
+ :param node: Node to be added to the graph.
90
+ """
91
+ if not isinstance(node, Node): # type check
53
92
  raise TypeError(f"{self}: {node} is not a Node")
54
- if node not in self.nodes:
93
+
94
+ if node not in self.nodes: # avoid duplicate nodes
55
95
  self.nodes.add(node)
56
- for neighbour in node.neighbours:
96
+ for neighbour in node.neighbours: # add neighbours recursively
57
97
  self.add_node(neighbour)
58
98
 
59
- def plot_nodes(self, condition=None, init = None, labels=None):
99
+
100
+ def plot_nodes(
101
+ self,
102
+ condition:Callable | None = None,
103
+ init:Node | Label | None = None,
104
+ labels:Iterable[Label] | None = None) -> Figure:
105
+ """ Plot the nodes in the graph
106
+
107
+ :param condition: Function to filter nodes to be plotted. If None, all nodes are plotted.
108
+ :param init: Node to use as the root of the plot. If None, a random node is used.
109
+ :param labels: Iterable of labels for the nodes. If None, node labels are used.
110
+ """
111
+
112
+ # filter nodes based on condition
60
113
  nodes = self.nodes if condition is None else \
61
114
  {node for node in self.nodes if condition(node)}
115
+
116
+ # handle empty graph
62
117
  if len(nodes) == 0:
63
118
  return plt.figure(figsize=(8,8))
119
+
120
+ # determine initial node for layout
121
+ _init = Node() # placeholder
64
122
  if not isinstance(init, Node):
65
123
  if init is None:
66
- init = next(iter(nodes)) # random node
67
- else:
124
+ _init = next(iter(nodes)) # random node
125
+ elif isinstance(init, Label): # label provided
68
126
  _flag = True
69
- for node in nodes:
127
+ for node in nodes: # find node with matching label
70
128
  if init == node.label:
71
- init = node
129
+ _init = node
72
130
  _flag = False
73
131
  break
74
132
  if _flag:
75
- init = next(iter(nodes))
76
- elif init not in nodes:
77
- init = next(iter(nodes))
133
+ _init = next(iter(nodes))
134
+ else:
135
+ raise TypeError(f"{self}: {init} is not a Node or label")
136
+ elif init not in nodes: # node not in filtered set
137
+ _init = next(iter(nodes)) # random node
78
138
 
79
- locs = {init: (0,0)}
80
- tree = {0: {init}}
139
+ locs = {_init: (0.,0.)} # locations of nodes
140
+ tree = {0: {_init}} # nodes at each distance from init
81
141
  dist = 1 # dist from init
82
- tree[dist] = init.neighbours & nodes # set intersection
142
+ tree[dist] = _init.neighbours & nodes # set intersection
83
143
  for i, node in enumerate(tree[dist]):
84
- locs[node] = (dist, i-0.5*len(tree[dist]))
144
+ locs[node] = (dist, i-0.5*len(tree[dist])) # initial layout
85
145
 
86
- while len(tree[dist]) > 0:
146
+ while len(tree[dist]) > 0: # expand tree
87
147
  tree[dist+1] = set()
148
+ # find new nodes at dist+1 from init
88
149
  for node in tree[dist]:
89
150
  neighbours = node.neighbours & nodes # set intersection
90
151
  for _node in neighbours:
91
152
  if _node not in locs and _node not in tree[dist+1]:
92
153
  tree[dist+1].add(_node)
93
- dist += 1
154
+ dist += 1 # increment distance
155
+ # layout new nodes
94
156
  for i, node in enumerate(tree[dist]):
95
157
  offset = i-0.5*len(tree[dist])
96
158
  locs[node] = (dist + 0.1*offset, offset)
97
159
 
98
- edges = set()
160
+ edges:set[Edge] = set()
99
161
  for node in nodes:
100
162
  for _node in (node.neighbours & nodes):
101
- forward = (node, _node, node.weights[_node])
102
- backward = (_node, node, _node.weights[node])
163
+ forward:Edge = (node, _node, node.weights[_node])
164
+ backward:Edge = (_node, node, _node.weights[node])
103
165
  if forward not in edges and backward not in edges:
104
166
  edges.add(forward)
105
167
 
106
168
  fig, ax = plt.subplots(figsize=(8,8))
107
- for node, loc in locs.items():
169
+ for node, loc in locs.items(): # plot nodes and labels
108
170
  name = str(node) if labels is None else labels[node]
109
171
  ax.plot(*loc, 'ko', ms=20)
110
172
  ax.text(loc[0]+0.1, loc[1]+0.1, name)
111
173
 
112
- for edge in edges:
174
+ for edge in edges: # plot edges and weights
113
175
  a,b, weight = locs[edge[0]], locs[edge[1]], edge[2]
114
176
  ax.plot(*[[i,j] for i,j in zip(a,b)],'k')
115
177
  if weight is not None:
116
178
  ax.text(*[i+(j-i)/2 for i,j in zip(a,b)], str(weight))
117
- ax.axis("off")
179
+ ax.axis("off") # turn off axes
118
180
  return fig
119
181
 
120
182
 
183
+
121
184
  class GraphEnvironment(Environment):
122
- def __init__(self, graph=None, *args, **kwargs):
123
- super().__init__(*args, **kwargs)
124
- self.graph = graph if isinstance(graph, Graph) else Graph()
185
+ """ An environment based on a graph structure. """
186
+ def __init__(self, graph:Graph | None = None, *args, **kwargs) -> None:
187
+ """ Create a graph environment."""
188
+ super().__init__(*args, **kwargs) # initialize base Environment
189
+ self.graph = graph if isinstance(graph, Graph) else Graph()
125
190
 
126
- def add_node(self, node):
191
+
192
+ def add_node(self, node:Node) -> None:
193
+ """ Add a node to the graph environment.
194
+
195
+ :param node: Node to be added to the graph.
196
+ """
127
197
  self.graph.add_node(node)
128
198
 
129
- def percept(self, agent):
130
- node = agent.location
199
+
200
+ @override
201
+ def percept(self, agent:Agent) -> list[tuple[Node, Numeric]]:
202
+ """ Return the percepts for the agent based on its location in the graph."""
203
+ node:Node = agent.location # type: ignore
131
204
  if len(node.weights) == 0:
132
- return node.neighbours
205
+ return [(n, 1) for n in node.neighbours]
133
206
  else:
134
207
  return [(n, node.weights[n]) for n in node.neighbours]
135
208
 
136
- def add_agent(self, agent, location=None, node=None):
137
- if not isinstance(agent, Agent):
209
+
210
+ @override
211
+ def add_agent(self,
212
+ agent:Agent,
213
+ location:Location | None = None,
214
+ node:Node | None = None) -> None:
215
+ """ Add an agent to the graph environment at a specified location or node.
216
+
217
+ :param agent: Agent to be added to the environment.
218
+ :param location: Location where the agent should be added (default None).
219
+ :param node: Node where the agent should be added (default None).
220
+ """
221
+ if not isinstance(agent, Agent): # type check
138
222
  raise TypeError("f{self}: {agent} is not an Agent")
139
- if node is not None:
223
+
224
+ if node is not None: # assign to specified node
140
225
  if not isinstance(node, Node):
141
226
  print(f"{self}: {node} is not a valid Node")
142
- if node in self.graph:
227
+
228
+ if node in self.graph:
143
229
  super().add_agent(agent, node)
144
230
  else:
145
231
  print(f"{self}: {node} is not in graph")
146
- elif location is not None:
232
+
233
+ elif location is not None: # assign to node with matching location
147
234
  _flag = True
148
- for node in self.graph:
235
+
236
+ for node in self.graph: # find node with matching location
149
237
  if location == node.location:
150
238
  super().add_agent(agent, node)
151
239
  _flag = False
152
240
  break
153
- if _flag:
241
+
242
+ if _flag: # location not found
154
243
  print(f"{self}: {location} was not found in environment")
155
- else:
244
+
245
+ else: # default to random node
156
246
  super().add_agent(agent)
157
247
 
248
+
158
249
  def show(self, *args, **kwargs):
250
+ """ Show a plot of the graph environment. """
159
251
  with warnings.catch_warnings():
160
252
  warnings.simplefilter("ignore")
161
253
  self.graph.plot_nodes(*args, **kwargs).show()
162
254
 
255
+
163
256
  @classmethod
164
- def from_dict(ShortestPathEnvironment, graph_dict):
257
+ def from_dict(cls,
258
+ graph_dict: dict[str, Iterable[str | tuple[str,str] | int]]
259
+ )-> GraphEnvironment:
260
+ """ Create a GraphEnvironment from a dictionary representation of a graph."""
165
261
  if "vertices" not in graph_dict and "edges" not in graph_dict:
166
262
  raise ValueError(f"No vertices in json string {graph_dict}")
167
- if "edges" not in graph_dict:
263
+
264
+ if "edges" not in graph_dict:
168
265
  raise ValueError(f"No edges in json string {graph_dict}")
169
- vertices = graph_dict['vertices' if 'vertices' in graph_dict else 'nodes']
266
+
267
+ vertices = graph_dict[ # support 'nodes' or 'vertices'
268
+ 'vertices' if 'vertices' in graph_dict else 'nodes']
170
269
  edges = graph_dict['edges']
270
+
171
271
  for edge in edges:
172
- if len(edge) != 2:
173
- raise ValueError(f"Edges must comprise two nodes, {edge} does not")
174
- if any(v not in vertices for v in edge):
175
- raise ValueError(f"Edges must map between valid vertices, {edge} does not")
272
+ if len(edge) != 2: # type: ignore
273
+ raise ValueError(
274
+ f"Edges must comprise two nodes, {edge} does not")
275
+ if any(v not in vertices for v in edge): # type: ignore
276
+ raise ValueError(
277
+ f"Edges must map between valid vertices, {edge} does not")
278
+
279
+ nodes = {v: Node(f"{v}") for v in vertices}
176
280
 
177
- nodes = {v: Node(v) for v in vertices}
178
281
  if 'weights' in graph_dict:
179
282
  weights = graph_dict['weights']
180
283
  else:
181
- weights = [None]*len(edges)
284
+ weights = [None]*len(edges) # type: ignore
182
285
  for edge, weight in zip(edges, weights):
183
- a, b = edge
184
- nodes[a].add_neighbour(nodes[b], weight)
286
+ a, b = edge # type: ignore
287
+ nodes[a].add_neighbour(nodes[b], weight) # add edge with weight
185
288
 
186
- environment = ShortestPathEnvironment()
289
+
290
+ # create environment and add nodes
291
+ environment = cls()
187
292
  for _,node in nodes.items():
188
293
  environment.add_node(node)
294
+
189
295
  return environment
190
296
 
191
297
  @classmethod
192
- def from_json(ShortestPathEnvironment, json_str):
193
- return ShortestPathEnvironment.from_dict(json.loads(json_str))
298
+ def from_json(cls, json_str: str) -> GraphEnvironment:
299
+ """ Create a GraphEnvironment from a JSON string representation
300
+ of a graph."""
301
+ return cls.from_dict(json.loads(json_str))
194
302
 
195
303
 
196
304
  class ShortestPathEnvironment(GraphEnvironment):
197
- def get_node(self, node):
305
+ """ A graph environment for shortest path finding agents. """
306
+ def get_node(self, node: Node | Label) -> Node:
307
+ """ Retrieve a node from the graph by Node or label.
308
+
309
+ :param node: Node or label of the node to retrieve.
310
+ """
198
311
  if isinstance(node, Node):
199
312
  assert node in self.graph
200
313
  else:
@@ -202,21 +315,49 @@ class ShortestPathEnvironment(GraphEnvironment):
202
315
  if node == vertex.label:
203
316
  node = vertex
204
317
  break
205
- assert isinstance(vertex, Node)
318
+ assert isinstance(node, Node)
206
319
  return node
207
320
 
208
- def add_agent(self, agent, init, target):
321
+
322
+ @override
323
+ def add_agent(self,
324
+ agent:ShortestPathAgent,
325
+ init: Node | Label,
326
+ target: Node | Label) -> None:
327
+ """ Add a shortest path agent to the environment.
328
+
329
+ :param agent: Agent to be added to the environment.
330
+ :param init: Initial node or label for the agent.
331
+ :param target: Target node or label for the agent, if applicable.
332
+ """
333
+ # retrieve nodes from labels if necessary
209
334
  init = self.get_node(init)
210
335
  target = self.get_node(target)
336
+ # add agent to environment
211
337
  super().add_agent(agent, node=init)
338
+ # initialise agent with init and target nodes
212
339
  agent.initialise(init, target)
213
340
 
214
- @property
215
- def is_done(self):
341
+
342
+ @property # override
343
+ def is_done(self) -> bool:
344
+ """ Check if all agents have delivered their paths. """
216
345
  return (hasattr(self, "delivered") and self.delivered)
217
346
 
218
-
219
- def execute_action(self, agent, action):
347
+
348
+ @override
349
+ def execute_action(self,
350
+ agent: ShortestPathAgent,
351
+ action: tuple[str, Node]) -> None:
352
+ """ Execute an action for the shortest path agent.
353
+
354
+ Commands:
355
+ - "explore": move to a neighbouring node
356
+ - "deliver": deliver the shortest path to the target node
357
+
358
+ :param agent: Agent executing the action.
359
+ :param action: Action to be executed, format (command, node).
360
+ """
220
361
  command, node = action
221
362
  match command:
222
363
  case "explore":
@@ -229,68 +370,76 @@ class ShortestPathEnvironment(GraphEnvironment):
229
370
 
230
371
 
231
372
  class ShortestPathAgent(UtilityBasedAgent):
373
+ """ An agent that finds the shortest path in a graph environment.
374
+
375
+ Needs implementation of methods:
376
+ `program(self, percepts:list[tuple[Node, Numeric]]) -> tuple[str, Node]`
377
+ `utility(action:tuple[str, Node]) -> Numeric`
378
+ `explore(node:Node) -> None`
379
+ `deliver(node:Node) -> tuple[list[Node], Numeric]`
380
+
381
+ Has attributes:
382
+ `init`: Initial node of the agent.
383
+ `target`: Target node of the agent.
384
+ `location`: Current location of the agent.
385
+ `dist`: Dictionary mapping nodes to their distance from the initial node.
386
+ `prev`: Dictionary mapping nodes to their previous node in the shortest path.
387
+ """
232
388
  def __init__(self):
389
+ """ Initialise the shortest path agent. """
233
390
  super().__init__()
234
- self.dist = {}
235
- self.prev = {}
236
- self.visited = set()
391
+
392
+ # attributes for shortest path finding
393
+ self.__init = self.__target = None
394
+ self.location:Node | None = None
395
+ self.dist:dict[Node, Numeric] = {}
396
+ self.prev:dict[Node, Node | None] = {}
397
+ self.visited:set[Node] = set()
398
+
237
399
 
238
400
  @property
239
- def at_goal(self):
401
+ def at_goal(self) -> bool:
402
+ """ Check if the agent is at the target node. """
240
403
  return self.location is self.target
404
+
241
405
 
242
- def initialise(self, node, target):
243
- self.init = node
244
- self.target = target
245
- self.dist[node] = 0
406
+ @property
407
+ def init(self) -> Node | None:
408
+ """ Get the initial node of the agent. """
409
+ return self.__init
246
410
 
247
- def explore(self, node): #actuator
248
- print(f"{self}: exploring {node}")
249
- print(f" distance from {self.init}: {self.dist}")
250
- print(f" previous node: {self.prev}")
251
- self.visited.add(self.location)
252
- self.location = node
253
-
254
- def deliver(self, node):
255
- path = []
256
- curr = node
257
- path.append(curr)
258
- while curr is not self.init:
259
- curr = self.prev[curr]
260
- path.append(curr)
261
- path = list(reversed(path))
262
- print(f"{self}: delivering shortest path between {self.init} and {node}")
263
- print(f" {path}")
264
- print(f" length: {self.dist[node]}")
265
- return path, self.dist[node]
266
-
267
- def program(self, percepts):
268
- if self.at_goal:
269
- return ("deliver", self.target)
411
+
412
+ @property
413
+ def target(self) -> Node | None:
414
+ """ Get the target node of the agent. """
415
+ return self.__target
416
+
417
+
418
+ def initialise(self, node:Node, target:Node | None) -> None:
419
+ """ Initialise the agent with its starting and target nodes.
270
420
 
271
- if isinstance(percepts, set):
272
- percepts = zip(nodes, [1]*len(nodes))
273
-
274
- for neighbour, weight in percepts:
275
- if neighbour in self.visited:
276
- continue
277
- if neighbour not in self.dist:
278
- self.dist[neighbour] = self.dist[self.location] + weight
279
- self.prev[neighbour] = self.location
280
- else:
281
- alt = self.dist[self.location] + weight
282
- if alt < self.dist[neighbour]:
283
- self.dist[neighbour] = alt
284
- self.prev[neighbour] = self.location
285
-
286
- action = self.maximise_utility(
287
- [("explore", node)
288
- for node in self.dist if (
289
- node is not self.location and
290
- node not in self.visited)])
421
+ :param node: Initial node for the agent.
422
+ :param target: Target node for the agent, if applicable.
423
+ """
424
+ self.__init = self.location = node
425
+ self.__target = target
426
+ self.dist[node] = 0 # distance from initial node
427
+ self.prev[node] = None # previous node in path
428
+
429
+
430
+ def explore(self, node:Node) -> None:
431
+ """ Explore a new node. The actuator of the agent. Needs to be implemented.
291
432
 
292
- return action
433
+ :param node: Node to be explored.
434
+ """
435
+ raise NotImplementedError
436
+
293
437
 
294
- def utility(self, action):
295
- value = self.dist[action[1]]
296
- return -value
438
+ def deliver(self, node:Node) -> tuple[list[Node], Numeric]:
439
+ """ Deliver the shortest path to the specified node,
440
+ and distance to node. Needs to be implemented
441
+
442
+ :param node: Node to deliver the shortest path to.
443
+ :return path, distance: Tuple of (shortest path as list of nodes, distance to node).
444
+ """
445
+ raise NotImplementedError
co2114/search/things.py CHANGED
@@ -1,32 +1,56 @@
1
- from ..agent import things
1
+ from ..agent.things import Thing, Agent, RationalAgent, ModelBasedAgent, Action
2
2
  from numpy import inf
3
3
 
4
- Thing, Agent, RationalAgent, ModelBasedAgent = (
5
- things.Thing,
6
- things.Agent,
7
- things.RationalAgent,
8
- things.ModelBasedAgent)
4
+ from typing import Collection, override
9
5
 
10
6
 
11
7
  class MazeRunner(Agent):
12
- def __repr__(self):
8
+ """ Agent that runs mazes, visualised as 👾 """
9
+ @override
10
+ def __repr__(self) -> str:
13
11
  return "👾"
14
12
 
13
+
15
14
  class GoalBasedAgent(ModelBasedAgent):
15
+ """ Base class for goal based agents
16
+
17
+ Requires implemnentation of methods:
18
+ `program(percepts:Collection[Thing]) -> Action`
19
+ `at_goal() -> bool`
20
+ """
16
21
  @property
17
- def at_goal(self):
18
- NotImplemented
22
+ def at_goal(self) -> bool:
23
+ """ checks if agent is at goal, needs overriding """
24
+ raise NotImplementedError
25
+
19
26
 
20
27
  class UtilityBasedAgent(GoalBasedAgent):
21
- def maximise_utility(self, actions):
22
- """ calculates maximum utility from list of actions """
28
+ """ Base class for utility based agents
29
+
30
+ Requires implemnentation of methods:
31
+ `program(percepts:Collection[Thing]) -> Action`
32
+ `at_goal() -> bool`
33
+ `utility(action:Action) -> float`
34
+ """
35
+ def maximise_utility(self, actions: Collection[Action]) -> Action:
36
+ """ calculates maximum utility from list of actions
37
+
38
+ :param actions: collection of possible actions
39
+ :return: action with maximum utility
40
+ """
23
41
  max_u = -inf # negative infinity
42
+ _flag = False # safety check
24
43
  for action in actions:
25
44
  u = self.utility(action)
26
45
  if u > max_u:
27
46
  max_u = u
28
47
  output = action
29
- return output
48
+ _flag = True
49
+ if not _flag: raise ValueError("No valid actions provided")
50
+
51
+ return output # type: ignore
52
+
30
53
 
31
- def utility(self, action):
32
- NotImplemented
54
+ def utility(self, action: Action) -> float:
55
+ """ calculates utility of an action, needs overriding """
56
+ raise NotImplementedError